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:
2026-09-08 15:48:57 +02:00
parent bdc20a134a
commit 86ed0aa00b
38 changed files with 2127 additions and 89 deletions

View 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,
})
}