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

@ -1800,6 +1800,17 @@ impl LaunchAgent {
self
}
/// Replaces the default native-skill resolver with the shared effective
/// resolver used by runtime context injection and `idea_skill_read`.
#[must_use]
pub fn with_assigned_skill_resolver(mut self, resolver: Arc<AssignedSkillResolver>) -> Self {
self.agent_capabilities = Arc::new(ResolveAgentCapabilities::with_resolver(Arc::clone(
&resolver,
)));
self.assigned_skills = resolver;
self
}
/// Injects the local model-server ensure use case used by OpenCode profiles.
#[must_use]
pub fn with_local_model_server(mut self, ensure: Arc<EnsureLocalModelServer>) -> Self {
@ -4364,6 +4375,7 @@ mod tests {
snapshot: domain::AssignedSkillSnapshot {
skill_id: skill.id,
scope: skill.scope,
plugin: None,
name: skill.name.clone(),
description: skill.effective_description(),
kind: skill.kind,

View File

@ -33,6 +33,7 @@ pub mod orchestrator;
pub mod permission;
pub mod plugin;
pub mod project;
pub mod project_plugin;
pub mod remote;
pub mod skill;
pub mod slash_command;
@ -204,6 +205,11 @@ pub use project::{
ReadProjectContextOutput, UpdateProjectContext, UpdateProjectContextInput,
PROJECT_CONTEXT_FILE,
};
pub use project_plugin::{
plugin_skill_ref, plugin_tool_ref, AssignmentMutation, ListProjectPluginSettings,
MutateAgentPluginSkill, MutateAgentPluginTool, ProjectPluginResolver,
ProjectPluginSettingsView, SetProjectPluginEnabled,
};
pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};
pub use skill::{
AgentCapability, AssignSkillToAgent, AssignSkillToAgentInput, AssignedSkillResolver,

View File

@ -27,6 +27,8 @@ use crate::{AppError, SpawnBackgroundCommand, SpawnBackgroundCommandInput};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginContributionSummary {
/// Agent skill count.
pub skills: usize,
/// Top-level menu count.
pub top_level_menus: usize,
/// Menu item count.
@ -42,6 +44,7 @@ pub struct PluginContributionSummary {
impl From<&PluginContributionSet> for PluginContributionSummary {
fn from(c: &PluginContributionSet) -> Self {
Self {
skills: c.skills.len(),
top_level_menus: c.menus.len(),
menu_items: c.menu_items.len(),
slash_commands: c.slash_commands.len(),
@ -3310,6 +3313,8 @@ struct RawEngines {
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawContributes {
#[serde(default)]
skills: Vec<RawPluginSkill>,
#[serde(default)]
menus: Vec<RawMenu>,
#[serde(default)]
@ -3322,6 +3327,17 @@ struct RawContributes {
mcp_servers: Vec<RawMcpServer>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawPluginSkill {
id: String,
name: String,
description: String,
#[serde(default)]
kind: domain::SkillKind,
path: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawMenu {
@ -3480,6 +3496,33 @@ fn validate_contributes(raw: RawContributes) -> Result<PluginContributionSet, Pl
Ok(())
}
};
let skills = raw
.skills
.into_iter()
.map(|s| {
insert(&s.id)?;
if s.name.trim().is_empty() || s.description.trim().is_empty() {
return Err(PluginManifestError::Invalid(
"skills[].name and skills[].description are required".to_owned(),
));
}
let path = domain::RelativePath::new(s.path)
.map_err(|e| PluginManifestError::Invalid(e.to_string()))?;
if !path.as_str().ends_with(".md") {
return Err(PluginManifestError::Invalid(
"skills[].path must point to a .md file".to_owned(),
));
}
Ok(domain::PluginSkillContribution {
id: domain::PluginSkillId::new(s.id)
.map_err(|e| PluginManifestError::Invalid(e.to_string()))?,
name: s.name,
description: s.description,
kind: s.kind,
path,
})
})
.collect::<Result<Vec<_>, _>>()?;
let menus = raw
.menus
.into_iter()
@ -3617,6 +3660,7 @@ fn validate_contributes(raw: RawContributes) -> Result<PluginContributionSet, Pl
})
.collect::<Result<Vec<_>, _>>()?;
Ok(PluginContributionSet {
skills,
menus,
menu_items,
slash_commands,
@ -3688,6 +3732,7 @@ mod tests {
"trustLevel": "full",
"capabilities": ["ui", "mcp", "tooling"],
"contributes": {
"skills": [{"id":"build-debug","name":"build-debug","description":"Diagnose build failures.","kind":"workflow","path":"skills/build-debug/SKILL.md"}],
"menus": [{"id":"dev.acme.menu","label":"Graph","topLevel":true}],
"menuItems": [{"id":"dev.acme.open","targetMenuId":"panels","label":"Open","command":"dev.acme.open"}],
"slashCommands": [{"name":"/graph","shortDescription":"Open graph tools","command":"dev.acme.open","requiresConfirmation":true}],
@ -4020,12 +4065,35 @@ mod tests {
]
);
assert_eq!(m.contributes.layouts.len(), 1);
assert_eq!(m.contributes.skills.len(), 1);
assert_eq!(m.contributes.skills[0].id.as_str(), "build-debug");
assert_eq!(m.contributes.skills[0].name, "build-debug");
assert_eq!(
m.contributes.skills[0].path.as_str(),
"skills/build-debug/SKILL.md"
);
assert_eq!(m.contributes.mcp_servers.len(), 1);
assert_eq!(m.contributes.slash_commands.len(), 1);
assert_eq!(m.contributes.slash_commands[0].name, "/graph");
assert!(m.contributes.slash_commands[0].requires_confirmation);
}
#[test]
fn rejects_plugin_skill_outside_markdown_contract() {
let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();
value["contributes"]["skills"][0]["path"] = serde_json::json!("skills/build.txt");
let error = validator()
.validate(
&serde_json::to_vec(&value).unwrap(),
&domain::PluginPackageRef {
plugin_id: None,
root: "x".into(),
},
)
.unwrap_err();
assert!(error.to_string().contains("must point to a .md file"));
}
#[test]
fn rejects_unknown_manifest_capability() {
let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();

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

View File

@ -16,6 +16,7 @@ use domain::{
};
use crate::error::AppError;
use crate::ProjectPluginResolver;
// ---------------------------------------------------------------------------
// CreateSkill
@ -293,13 +294,24 @@ impl ResolveAgentCapabilities {
/// context rendering and `idea_skill_read` authorization.
pub struct AssignedSkillResolver {
skills: Arc<dyn SkillStore>,
plugin_skills: Option<Arc<ProjectPluginResolver>>,
}
impl AssignedSkillResolver {
/// Builds the resolver from the existing skill store port.
#[must_use]
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
Self { skills }
Self {
skills,
plugin_skills: None,
}
}
/// Adds the provider-agnostic project-plugin resolver.
#[must_use]
pub fn with_plugin_skills(mut self, plugin_skills: Arc<ProjectPluginResolver>) -> Self {
self.plugin_skills = Some(plugin_skills);
self
}
/// Resolves the assigned skills for `agent` in manifest order.
@ -342,6 +354,7 @@ impl AssignedSkillResolver {
let snapshot = AssignedSkillSnapshot {
skill_id: skill.id,
scope: skill.scope,
plugin: None,
name: skill.name,
description,
kind: skill.kind,
@ -353,6 +366,52 @@ impl AssignedSkillResolver {
Err(e) => return Err(e.into()),
}
}
if let Some(resolver) = &self.plugin_skills {
let settings = resolver.settings(root).await?;
if let Some(assignments) = settings.assignments_for(agent.id) {
for (offset, reference) in assignments.skills.iter().enumerate() {
let Some(manifest) = resolver
.enabled_manifest(root, &reference.plugin_id)
.await?
else {
continue;
};
let Some(skill) = manifest
.contributes
.skills
.iter()
.find(|s| s.id == reference.skill_id)
else {
continue;
};
let content = match resolver
.read_skill_body(&reference.plugin_id, &skill.path)
.await
{
Ok(content) => content,
Err(AppError::NotFound(_)) => continue,
Err(error) => return Err(error),
};
assigned.push((
AssignedSkillSnapshot {
// Native fields remain populated for backwards wire compatibility;
// plugin identity is authoritative when `plugin` is present.
skill_id: SkillId::from_uuid(uuid::Uuid::nil()),
scope: SkillScope::Project,
plugin: Some(domain::orchestrator::AssignedPluginSkillSource {
plugin_id: reference.plugin_id.clone(),
skill_id: reference.skill_id.clone(),
}),
name: skill.name.clone(),
description: skill.description.clone(),
kind: skill.kind,
assignment_index: (agent.skills.len() + offset) as u32,
},
content,
));
}
}
}
Ok(assigned)
}
@ -382,6 +441,25 @@ impl AssignedSkillResolver {
)))
}
};
if let Some(plugin) = &selected.plugin {
let resolver = self
.plugin_skills
.as_ref()
.ok_or_else(|| AppError::NotFound(format!("assigned plugin skill `{name}`")))?;
let manifest = resolver
.enabled_manifest(root, &plugin.plugin_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("assigned plugin skill `{name}`")))?;
let contribution = manifest
.contributes
.skills
.iter()
.find(|s| s.id == plugin.skill_id)
.ok_or_else(|| AppError::NotFound(format!("assigned plugin skill `{name}`")))?;
return resolver
.read_skill_body(&plugin.plugin_id, &contribution.path)
.await;
}
let skill = self
.skills
.get(selected.scope, root, selected.skill_id)