feat(plugins): paramètres projet plugins + attribution skills/tools aux agents — #282 (manifeste accepte contributes.skills IdeA-native multi-harness; store .ideai/project-plugins.json v1: enable plugin par projet + assignments skills/tools par agent; refs + use cases settings/assign/unassign; resolver skills pour idea_skill_read et injection catalogue lancement agent; permissions MCP dynamiques projetées par harness Claude Code/Codex/OpenCode; tombstones uninstall, hot reload recalcule catalogue et marque assignments cassés; frontend: Settings projet Plugins, détail capabilities, attribution agent, catalogue permissions; bump pointeur IdeaSDK 6eb8db3; QA verte: cargo test -p application, project_plugin_settings 3/3, dto_plugins 2/2, structured_launch_d3 26/26, catalogue mcp 7/7, frontend 5 tests ProjectPluginSettings/plugin, npm build frontend)
This commit is contained in:
439
crates/application/src/project_plugin.rs
Normal file
439
crates/application/src/project_plugin.rs
Normal file
@ -0,0 +1,439 @@
|
||||
//! Project-scoped plugin activation and per-agent capability assignments.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
AgentContextStore, AgentId, AgentPluginAssignments, MarkdownDoc, PluginId, PluginManifest,
|
||||
PluginManifestValidator, PluginMcpServerId, PluginPackageStore, PluginRegistryStore,
|
||||
PluginSkillId, PluginSkillRef, PluginStoreError, PluginToolRef, Project,
|
||||
ProjectPluginActivation, ProjectPluginSettings, ProjectPluginSettingsStore,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::AppError;
|
||||
|
||||
fn package_error(error: PluginStoreError) -> AppError {
|
||||
match error {
|
||||
PluginStoreError::NotFound => AppError::NotFound("plugin package".to_owned()),
|
||||
PluginStoreError::Invalid(message) => AppError::Invalid(message),
|
||||
other => AppError::Store(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared resolver for installed, runtime-active plugin manifests.
|
||||
pub struct ProjectPluginResolver {
|
||||
settings: Arc<dyn ProjectPluginSettingsStore>,
|
||||
packages: Arc<dyn PluginPackageStore>,
|
||||
registry: Arc<dyn PluginRegistryStore>,
|
||||
validator: Arc<dyn PluginManifestValidator>,
|
||||
}
|
||||
|
||||
impl ProjectPluginResolver {
|
||||
/// Builds the resolver from replaceable ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
settings: Arc<dyn ProjectPluginSettingsStore>,
|
||||
packages: Arc<dyn PluginPackageStore>,
|
||||
registry: Arc<dyn PluginRegistryStore>,
|
||||
validator: Arc<dyn PluginManifestValidator>,
|
||||
) -> Self {
|
||||
Self {
|
||||
settings,
|
||||
packages,
|
||||
registry,
|
||||
validator,
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the persisted project document.
|
||||
pub async fn settings(
|
||||
&self,
|
||||
project_root: &domain::ProjectPath,
|
||||
) -> Result<ProjectPluginSettings, AppError> {
|
||||
self.settings
|
||||
.load(project_root)
|
||||
.await
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
|
||||
/// Resolves one plugin only when enabled for the project and runtime-active
|
||||
/// globally. Missing/uninstalled plugins yield `None`, preserving tombstones.
|
||||
pub async fn enabled_manifest(
|
||||
&self,
|
||||
project_root: &domain::ProjectPath,
|
||||
plugin_id: &PluginId,
|
||||
) -> Result<Option<PluginManifest>, AppError> {
|
||||
let settings = self.settings(project_root).await?;
|
||||
if !settings.is_enabled(plugin_id) {
|
||||
return Ok(None);
|
||||
}
|
||||
let registry = self
|
||||
.registry
|
||||
.load_registry()
|
||||
.await
|
||||
.map_err(|e| AppError::Store(e.to_string()))?;
|
||||
let Some(entry) = registry.find(plugin_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !entry.lifecycle_state.is_runtime_active() {
|
||||
return Ok(None);
|
||||
}
|
||||
let package = self
|
||||
.packages
|
||||
.list_installed()
|
||||
.await
|
||||
.map_err(package_error)?
|
||||
.into_iter()
|
||||
.find(|p| p.plugin_id.as_ref() == Some(plugin_id));
|
||||
let Some(package) = package else {
|
||||
return Ok(None);
|
||||
};
|
||||
let bytes = self
|
||||
.packages
|
||||
.read_manifest(&package)
|
||||
.await
|
||||
.map_err(package_error)?;
|
||||
self.validator
|
||||
.validate(&bytes.bytes, &package)
|
||||
.map(Some)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))
|
||||
}
|
||||
|
||||
/// Reads a contributed Markdown body through the confined package port.
|
||||
pub async fn read_skill_body(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
path: &domain::RelativePath,
|
||||
) -> Result<MarkdownDoc, AppError> {
|
||||
let bytes = self
|
||||
.packages
|
||||
.read_installed_file(plugin_id, path)
|
||||
.await
|
||||
.map_err(package_error)?;
|
||||
let body = String::from_utf8(bytes)
|
||||
.map_err(|_| AppError::Invalid("plugin skill must be UTF-8 Markdown".to_owned()))?;
|
||||
if body.trim().is_empty() {
|
||||
return Err(AppError::Invalid(
|
||||
"plugin skill body must not be empty".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(MarkdownDoc::new(body))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read model consumed by project settings surfaces.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectPluginSettingsView {
|
||||
/// Persisted activation and assignment document.
|
||||
pub settings: ProjectPluginSettings,
|
||||
/// Installed plugin manifests. Runtime failures are omitted from this
|
||||
/// catalogue but their settings remain in `settings` as tombstones.
|
||||
pub installed: Vec<PluginManifest>,
|
||||
}
|
||||
|
||||
/// Lists project plugin settings and the globally installed catalogue.
|
||||
pub struct ListProjectPluginSettings {
|
||||
resolver: Arc<ProjectPluginResolver>,
|
||||
packages: Arc<dyn PluginPackageStore>,
|
||||
validator: Arc<dyn PluginManifestValidator>,
|
||||
}
|
||||
|
||||
impl ListProjectPluginSettings {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
resolver: Arc<ProjectPluginResolver>,
|
||||
packages: Arc<dyn PluginPackageStore>,
|
||||
validator: Arc<dyn PluginManifestValidator>,
|
||||
) -> Self {
|
||||
Self {
|
||||
resolver,
|
||||
packages,
|
||||
validator,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes the read.
|
||||
pub async fn execute(&self, project: Project) -> Result<ProjectPluginSettingsView, AppError> {
|
||||
let settings = self.resolver.settings(&project.root).await?;
|
||||
let mut installed = Vec::new();
|
||||
for package in self
|
||||
.packages
|
||||
.list_installed()
|
||||
.await
|
||||
.map_err(package_error)?
|
||||
{
|
||||
let bytes = self
|
||||
.packages
|
||||
.read_manifest(&package)
|
||||
.await
|
||||
.map_err(package_error)?;
|
||||
if let Ok(manifest) = self.validator.validate(&bytes.bytes, &package) {
|
||||
installed.push(manifest);
|
||||
}
|
||||
}
|
||||
installed.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str()));
|
||||
Ok(ProjectPluginSettingsView {
|
||||
settings,
|
||||
installed,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggles one globally installed plugin for a project.
|
||||
pub struct SetProjectPluginEnabled {
|
||||
store: Arc<dyn ProjectPluginSettingsStore>,
|
||||
packages: Arc<dyn PluginPackageStore>,
|
||||
}
|
||||
|
||||
impl SetProjectPluginEnabled {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn ProjectPluginSettingsStore>,
|
||||
packages: Arc<dyn PluginPackageStore>,
|
||||
) -> Self {
|
||||
Self { store, packages }
|
||||
}
|
||||
|
||||
/// Persists activation. Enabling requires a currently installed plugin;
|
||||
/// disabling a missing plugin remains valid and keeps assignments intact.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
project: Project,
|
||||
plugin_id: PluginId,
|
||||
enabled: bool,
|
||||
) -> Result<ProjectPluginSettings, AppError> {
|
||||
if enabled {
|
||||
let exists = self
|
||||
.packages
|
||||
.list_installed()
|
||||
.await
|
||||
.map_err(package_error)?
|
||||
.iter()
|
||||
.any(|p| p.plugin_id.as_ref() == Some(&plugin_id));
|
||||
if !exists {
|
||||
return Err(AppError::NotFound(format!("plugin {}", plugin_id.as_str())));
|
||||
}
|
||||
}
|
||||
let mut settings = self.store.load(&project.root).await?;
|
||||
match settings
|
||||
.plugins
|
||||
.iter_mut()
|
||||
.find(|p| p.plugin_id == plugin_id)
|
||||
{
|
||||
Some(entry) => entry.enabled = enabled,
|
||||
None => settings
|
||||
.plugins
|
||||
.push(ProjectPluginActivation { plugin_id, enabled }),
|
||||
}
|
||||
self.store.save(&project.root, &settings).await?;
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutation kind shared by skill/tool assignment use cases.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AssignmentMutation {
|
||||
/// Add the capability when it is not already assigned.
|
||||
Assign,
|
||||
/// Remove the capability when it is currently assigned.
|
||||
Unassign,
|
||||
}
|
||||
|
||||
/// Assigns or unassigns one plugin skill while retaining dangling tombstones.
|
||||
pub struct MutateAgentPluginSkill {
|
||||
store: Arc<dyn ProjectPluginSettingsStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
resolver: Arc<ProjectPluginResolver>,
|
||||
}
|
||||
|
||||
impl MutateAgentPluginSkill {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn ProjectPluginSettingsStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
resolver: Arc<ProjectPluginResolver>,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
contexts,
|
||||
resolver,
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies an idempotent mutation.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
project: Project,
|
||||
agent_id: AgentId,
|
||||
reference: PluginSkillRef,
|
||||
mutation: AssignmentMutation,
|
||||
) -> Result<ProjectPluginSettings, AppError> {
|
||||
ensure_agent(&*self.contexts, &project, agent_id).await?;
|
||||
if mutation == AssignmentMutation::Assign {
|
||||
let manifest = self
|
||||
.resolver
|
||||
.enabled_manifest(&project.root, &reference.plugin_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::Invalid(
|
||||
"plugin is not enabled and runtime-active for this project".to_owned(),
|
||||
)
|
||||
})?;
|
||||
if !manifest
|
||||
.contributes
|
||||
.skills
|
||||
.iter()
|
||||
.any(|s| s.id == reference.skill_id)
|
||||
{
|
||||
return Err(AppError::NotFound(format!(
|
||||
"plugin skill {}",
|
||||
reference.skill_id.as_str()
|
||||
)));
|
||||
}
|
||||
}
|
||||
let mut settings = self.store.load(&project.root).await?;
|
||||
let assignments = assignments_mut(&mut settings, agent_id);
|
||||
match mutation {
|
||||
AssignmentMutation::Assign if !assignments.skills.contains(&reference) => {
|
||||
assignments.skills.push(reference)
|
||||
}
|
||||
AssignmentMutation::Unassign => assignments.skills.retain(|r| r != &reference),
|
||||
_ => {}
|
||||
}
|
||||
self.store.save(&project.root, &settings).await?;
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
|
||||
/// Assigns or unassigns one namespaced external MCP tool.
|
||||
pub struct MutateAgentPluginTool {
|
||||
store: Arc<dyn ProjectPluginSettingsStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
resolver: Arc<ProjectPluginResolver>,
|
||||
}
|
||||
|
||||
impl MutateAgentPluginTool {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn ProjectPluginSettingsStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
resolver: Arc<ProjectPluginResolver>,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
contexts,
|
||||
resolver,
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies an idempotent mutation. Assignment validates the enabled server;
|
||||
/// the dynamic tool name is validated when its MCP catalogue is available.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
project: Project,
|
||||
agent_id: AgentId,
|
||||
reference: PluginToolRef,
|
||||
mutation: AssignmentMutation,
|
||||
) -> Result<ProjectPluginSettings, AppError> {
|
||||
ensure_agent(&*self.contexts, &project, agent_id).await?;
|
||||
if reference.tool_name.trim().is_empty() {
|
||||
return Err(AppError::Invalid("plugin tool name is required".to_owned()));
|
||||
}
|
||||
if mutation == AssignmentMutation::Assign {
|
||||
let manifest = self
|
||||
.resolver
|
||||
.enabled_manifest(&project.root, &reference.plugin_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::Invalid(
|
||||
"plugin is not enabled and runtime-active for this project".to_owned(),
|
||||
)
|
||||
})?;
|
||||
if !manifest
|
||||
.contributes
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.any(|s| s.id == reference.server_id)
|
||||
{
|
||||
return Err(AppError::NotFound(format!(
|
||||
"plugin MCP server {}",
|
||||
reference.server_id.as_str()
|
||||
)));
|
||||
}
|
||||
}
|
||||
let mut settings = self.store.load(&project.root).await?;
|
||||
let assignments = assignments_mut(&mut settings, agent_id);
|
||||
match mutation {
|
||||
AssignmentMutation::Assign if !assignments.tools.contains(&reference) => {
|
||||
assignments.tools.push(reference)
|
||||
}
|
||||
AssignmentMutation::Unassign => assignments.tools.retain(|r| r != &reference),
|
||||
_ => {}
|
||||
}
|
||||
self.store.save(&project.root, &settings).await?;
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_agent(
|
||||
store: &dyn AgentContextStore,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
) -> Result<(), AppError> {
|
||||
if store
|
||||
.load_manifest(project)
|
||||
.await?
|
||||
.entries
|
||||
.iter()
|
||||
.any(|e| e.agent_id == agent_id)
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::NotFound(format!("agent {agent_id}")))
|
||||
}
|
||||
}
|
||||
|
||||
fn assignments_mut(
|
||||
settings: &mut ProjectPluginSettings,
|
||||
agent_id: AgentId,
|
||||
) -> &mut AgentPluginAssignments {
|
||||
if let Some(index) = settings.agents.iter().position(|a| a.agent_id == agent_id) {
|
||||
return &mut settings.agents[index];
|
||||
}
|
||||
settings.agents.push(AgentPluginAssignments {
|
||||
agent_id,
|
||||
skills: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
});
|
||||
settings
|
||||
.agents
|
||||
.last_mut()
|
||||
.expect("assignment was just inserted")
|
||||
}
|
||||
|
||||
/// Convenience constructors for driving adapters accepting string ids.
|
||||
pub fn plugin_skill_ref(plugin_id: String, skill_id: String) -> Result<PluginSkillRef, AppError> {
|
||||
Ok(PluginSkillRef {
|
||||
plugin_id: PluginId::new(plugin_id).map_err(|e| AppError::Invalid(e.to_string()))?,
|
||||
skill_id: PluginSkillId::new(skill_id).map_err(|e| AppError::Invalid(e.to_string()))?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convenience constructor for a namespaced plugin tool reference.
|
||||
pub fn plugin_tool_ref(
|
||||
plugin_id: String,
|
||||
server_id: String,
|
||||
tool_name: String,
|
||||
) -> Result<PluginToolRef, AppError> {
|
||||
Ok(PluginToolRef {
|
||||
plugin_id: PluginId::new(plugin_id).map_err(|e| AppError::Invalid(e.to_string()))?,
|
||||
server_id: PluginMcpServerId::new(server_id)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?,
|
||||
tool_name,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user