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

@ -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();