From 86ed0aa00b01b22cda5ed9761f45c10cb84e7458 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 8 Sep 2026 15:48:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20param=C3=A8tres=20projet=20plu?= =?UTF-8?q?gins=20+=20attribution=20skills/tools=20aux=20agents=20?= =?UTF-8?q?=E2=80=94=20#282=20(manifeste=20accepte=20contributes.skills=20?= =?UTF-8?q?IdeA-native=20multi-harness;=20store=20.ideai/project-plugins.j?= =?UTF-8?q?son=20v1:=20enable=20plugin=20par=20projet=20+=20assignments=20?= =?UTF-8?q?skills/tools=20par=20agent;=20refs=20+=20use=20cases=20settings?= =?UTF-8?q?/assign/unassign;=20resolver=20skills=20pour=20idea=5Fskill=5Fr?= =?UTF-8?q?ead=20et=20injection=20catalogue=20lancement=20agent;=20permiss?= =?UTF-8?q?ions=20MCP=20dynamiques=20projet=C3=A9es=20par=20harness=20Clau?= =?UTF-8?q?de=20Code/Codex/OpenCode;=20tombstones=20uninstall,=20hot=20rel?= =?UTF-8?q?oad=20recalcule=20catalogue=20et=20marque=20assignments=20cass?= =?UTF-8?q?=C3=A9s;=20frontend:=20Settings=20projet=20Plugins,=20d=C3=A9ta?= =?UTF-8?q?il=20capabilities,=20attribution=20agent,=20catalogue=20permiss?= =?UTF-8?q?ions;=20bump=20pointeur=20IdeaSDK=206eb8db3;=20QA=20verte:=20ca?= =?UTF-8?q?rgo=20test=20-p=20application,=20project=5Fplugin=5Fsettings=20?= =?UTF-8?q?3/3,=20dto=5Fplugins=202/2,=20structured=5Flaunch=5Fd3=2026/26,?= =?UTF-8?q?=20catalogue=20mcp=207/7,=20frontend=205=20tests=20ProjectPlugi?= =?UTF-8?q?nSettings/plugin,=20npm=20build=20frontend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/app-tauri/src/lib.rs | 6 + crates/app-tauri/src/plugins.rs | 126 ++++- crates/app-tauri/tests/dto_plugins.rs | 2 + crates/application/src/agent/lifecycle.rs | 12 + crates/application/src/lib.rs | 6 + crates/application/src/plugin/mod.rs | 68 +++ crates/application/src/project_plugin.rs | 439 ++++++++++++++++++ crates/application/src/skill/usecases.rs | 80 +++- crates/application/tests/agent_lifecycle.rs | 17 +- .../application/tests/structured_launch_d3.rs | 11 +- crates/backend/src/dto.rs | 86 ++++ crates/backend/src/lib.rs | 166 ++++--- crates/domain/src/lib.rs | 26 +- crates/domain/src/orchestrator.rs | 15 + crates/domain/src/plugin.rs | 126 +++++ crates/domain/src/ports.rs | 30 ++ crates/infrastructure/src/lib.rs | 1 + crates/infrastructure/src/plugin/mod.rs | 15 + crates/infrastructure/src/store/mod.rs | 2 + .../src/store/project_plugin.rs | 76 +++ .../tests/project_plugin_settings.rs | 84 ++++ frontend/src/adapters/http/unsupported.ts | 23 + frontend/src/adapters/mock/index.ts | 88 ++++ frontend/src/adapters/plugin.test.ts | 35 ++ frontend/src/adapters/plugin.ts | 34 +- frontend/src/domain/index.ts | 70 +++ frontend/src/features/agents/AgentsPanel.tsx | 11 + .../plugins/AgentPluginCapabilities.tsx | 148 ++++++ .../src/features/plugins/PluginsPanel.tsx | 21 +- .../plugins/ProjectPluginSettings.test.tsx | 105 +++++ .../features/plugins/ProjectPluginsPanel.tsx | 143 ++++++ frontend/src/features/plugins/index.ts | 2 + .../src/features/plugins/useProjectPlugins.ts | 105 +++++ .../src/features/projects/ProjectsView.tsx | 1 + .../src/features/settings/SettingsView.tsx | 4 +- frontend/src/plugins/runtime/loader.ts | 1 + frontend/src/ports/index.ts | 29 ++ sdk/IdeaSDK | 2 +- 38 files changed, 2127 insertions(+), 89 deletions(-) create mode 100644 crates/application/src/project_plugin.rs create mode 100644 crates/infrastructure/src/store/project_plugin.rs create mode 100644 crates/infrastructure/tests/project_plugin_settings.rs create mode 100644 frontend/src/features/plugins/AgentPluginCapabilities.tsx create mode 100644 frontend/src/features/plugins/ProjectPluginSettings.test.tsx create mode 100644 frontend/src/features/plugins/ProjectPluginsPanel.tsx create mode 100644 frontend/src/features/plugins/useProjectPlugins.ts diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index a2530005..b6ed40b6 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -408,6 +408,12 @@ pub fn run() { commands::attach_background_task, commands::list_background_tasks, plugins::plugin_list_plugins, + plugins::plugin_list_project_settings, + plugins::plugin_set_project_enabled, + plugins::plugin_assign_agent_skill, + plugins::plugin_unassign_agent_skill, + plugins::plugin_assign_agent_tool, + plugins::plugin_unassign_agent_tool, plugins::plugin_review_package, plugins::plugin_install_from_archive, plugins::plugin_install_from_directory, diff --git a/crates/app-tauri/src/plugins.rs b/crates/app-tauri/src/plugins.rs index 43d509ed..375887e3 100644 --- a/crates/app-tauri/src/plugins.rs +++ b/crates/app-tauri/src/plugins.rs @@ -3,19 +3,24 @@ use std::future::Future; use std::path::{Path, PathBuf}; -use application::{ReviewPluginPackageInput, SetPluginEnabledInput, UninstallPluginInput}; +use application::{ + plugin_skill_ref, plugin_tool_ref, AppError, AssignmentMutation, ReviewPluginPackageInput, + SetPluginEnabledInput, UninstallPluginInput, +}; use backend::dto::{ - ErrorDto, PluginAdminDto, PluginConfigDocumentDto, PluginConfigDocumentReadDto, - PluginConfigDocumentUpdateDto, PluginConfigDocumentWriteResultDto, PluginEventBatchDto, - PluginEventPollDto, PluginEventSubscribeDto, PluginEventSubscriptionDto, + parse_agent_id, parse_project_id, ErrorDto, MutateAgentPluginSkillRequestDto, + MutateAgentPluginToolRequestDto, PluginAdminDto, PluginConfigDocumentDto, + PluginConfigDocumentReadDto, PluginConfigDocumentUpdateDto, PluginConfigDocumentWriteResultDto, + PluginEventBatchDto, PluginEventPollDto, PluginEventSubscribeDto, PluginEventSubscriptionDto, PluginEventUnsubscribeDto, PluginInstallResultDto, PluginProjectStructureDto, PluginProjectStructureQueryDto, PluginReloadResultDto, PluginReviewDto, PluginRunCommandDto, PluginRuntimeContributionCatalogDto, PluginStorageGetDto, PluginStorageSetDto, PluginTaskDto, PluginTaskStatusDto, PluginToolchainDiagnosticDto, PluginToolchainDiagnosticRequestDto, PluginUninstallResultDto, PluginWorkspaceBinaryFileDto, PluginWorkspaceDirectoryListingDto, PluginWorkspacePathDto, PluginWorkspaceStatDto, PluginWorkspaceTextFileDto, - PluginWorkspaceWriteBinaryDto, PluginWorkspaceWriteTextDto, ReloadPluginDto, - ReviewPluginPackageDto, + PluginWorkspaceWriteBinaryDto, PluginWorkspaceWriteTextDto, ProjectPluginSettingsDto, + ProjectPluginSettingsViewDto, ReloadPluginDto, ReviewPluginPackageDto, + SetProjectPluginEnabledRequestDto, }; use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore}; use domain::{PluginId, RelativePath}; @@ -27,6 +32,14 @@ use crate::state::AppState; const PLUGIN_ASSET_CORS_ORIGIN: &str = "*"; const PLUGIN_ASSET_CORS_METHODS: &str = "GET"; +async fn resolve_project(state: &AppState, project_id: &str) -> Result { + state + .project_store + .load_project(parse_project_id(project_id)?) + .await + .map_err(|error| ErrorDto::from(AppError::from(error))) +} + /// Lists installed plugins for the admin surface. #[tauri::command] pub async fn plugin_list_plugins( @@ -40,6 +53,107 @@ pub async fn plugin_list_plugins( .map_err(ErrorDto::from) } +/// Lists project plugin activation, assignments, and installed manifests. +#[tauri::command] +pub async fn plugin_list_project_settings( + project_id: String, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&state, &project_id).await?; + state + .list_project_plugin_settings + .execute(project) + .await + .map(ProjectPluginSettingsViewDto::from) + .map_err(ErrorDto::from) +} + +/// Enables or disables an installed plugin for one project. +#[tauri::command] +pub async fn plugin_set_project_enabled( + input: SetProjectPluginEnabledRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&state, &input.project_id).await?; + let plugin_id = + PluginId::new(input.plugin_id).map_err(|error| ErrorDto::invalid(error.to_string()))?; + state + .set_project_plugin_enabled + .execute(project, plugin_id, input.enabled) + .await + .map(ProjectPluginSettingsDto::from) + .map_err(ErrorDto::from) +} + +async fn mutate_project_skill( + input: MutateAgentPluginSkillRequestDto, + mutation: AssignmentMutation, + state: &AppState, +) -> Result { + let project = resolve_project(state, &input.project_id).await?; + let agent_id = parse_agent_id(&input.agent_id)?; + let reference = plugin_skill_ref(input.plugin_id, input.skill_id).map_err(ErrorDto::from)?; + state + .mutate_agent_plugin_skill + .execute(project, agent_id, reference, mutation) + .await + .map(ProjectPluginSettingsDto::from) + .map_err(ErrorDto::from) +} + +/// Assigns a contributed plugin skill to a project agent. +#[tauri::command] +pub async fn plugin_assign_agent_skill( + input: MutateAgentPluginSkillRequestDto, + state: State<'_, AppState>, +) -> Result { + mutate_project_skill(input, AssignmentMutation::Assign, &state).await +} + +/// Unassigns a contributed plugin skill from a project agent. +#[tauri::command] +pub async fn plugin_unassign_agent_skill( + input: MutateAgentPluginSkillRequestDto, + state: State<'_, AppState>, +) -> Result { + mutate_project_skill(input, AssignmentMutation::Unassign, &state).await +} + +async fn mutate_project_tool( + input: MutateAgentPluginToolRequestDto, + mutation: AssignmentMutation, + state: &AppState, +) -> Result { + let project = resolve_project(state, &input.project_id).await?; + let agent_id = parse_agent_id(&input.agent_id)?; + let reference = plugin_tool_ref(input.plugin_id, input.server_id, input.tool_name) + .map_err(ErrorDto::from)?; + state + .mutate_agent_plugin_tool + .execute(project, agent_id, reference, mutation) + .await + .map(ProjectPluginSettingsDto::from) + .map_err(ErrorDto::from) +} + +/// Assigns a namespaced plugin MCP tool to a project agent. +#[tauri::command] +pub async fn plugin_assign_agent_tool( + input: MutateAgentPluginToolRequestDto, + state: State<'_, AppState>, +) -> Result { + mutate_project_tool(input, AssignmentMutation::Assign, &state).await +} + +/// Unassigns a namespaced plugin MCP tool from a project agent. +#[tauri::command] +pub async fn plugin_unassign_agent_tool( + input: MutateAgentPluginToolRequestDto, + state: State<'_, AppState>, +) -> Result { + mutate_project_tool(input, AssignmentMutation::Unassign, &state).await +} + /// Reviews a local plugin package without committing it. #[tauri::command] pub async fn plugin_review_package( diff --git a/crates/app-tauri/tests/dto_plugins.rs b/crates/app-tauri/tests/dto_plugins.rs index 6e2c9e03..a350e1ba 100644 --- a/crates/app-tauri/tests/dto_plugins.rs +++ b/crates/app-tauri/tests/dto_plugins.rs @@ -22,6 +22,7 @@ fn plugin_admin_dto_serialises_exact_contract_shape() { restart_required: true, trust_level: PluginTrustLevel::Full, contribution_summary: PluginContributionSummaryDto { + skills: 5, top_level_menus: 1, menu_items: 2, slash_commands: 3, @@ -36,6 +37,7 @@ fn plugin_admin_dto_serialises_exact_contract_shape() { assert_eq!(value["lifecycleState"], "pending-disable"); assert_eq!(value["trustLevel"], "full"); assert_eq!(value["contributionSummary"]["topLevelMenus"], 1); + assert_eq!(value["contributionSummary"]["skills"], 5); assert_eq!(value["contributionSummary"]["slashCommands"], 3); } diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 3a84c0d3..763d6cb8 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -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) -> 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) -> 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, diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 6897f0fb..5fca08b6 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -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, diff --git a/crates/application/src/plugin/mod.rs b/crates/application/src/plugin/mod.rs index c4a9cb4f..cba849eb 100644 --- a/crates/application/src/plugin/mod.rs +++ b/crates/application/src/plugin/mod.rs @@ -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, #[serde(default)] menus: Vec, #[serde(default)] @@ -3322,6 +3327,17 @@ struct RawContributes { mcp_servers: Vec, } +#[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, _>>()?; let menus = raw .menus .into_iter() @@ -3617,6 +3660,7 @@ fn validate_contributes(raw: RawContributes) -> Result, _>>()?; 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(); diff --git a/crates/application/src/project_plugin.rs b/crates/application/src/project_plugin.rs new file mode 100644 index 00000000..836844e4 --- /dev/null +++ b/crates/application/src/project_plugin.rs @@ -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, + packages: Arc, + registry: Arc, + validator: Arc, +} + +impl ProjectPluginResolver { + /// Builds the resolver from replaceable ports. + #[must_use] + pub fn new( + settings: Arc, + packages: Arc, + registry: Arc, + validator: Arc, + ) -> Self { + Self { + settings, + packages, + registry, + validator, + } + } + + /// Loads the persisted project document. + pub async fn settings( + &self, + project_root: &domain::ProjectPath, + ) -> Result { + 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, 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 { + 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, +} + +/// Lists project plugin settings and the globally installed catalogue. +pub struct ListProjectPluginSettings { + resolver: Arc, + packages: Arc, + validator: Arc, +} + +impl ListProjectPluginSettings { + /// Builds the use case. + #[must_use] + pub fn new( + resolver: Arc, + packages: Arc, + validator: Arc, + ) -> Self { + Self { + resolver, + packages, + validator, + } + } + + /// Executes the read. + pub async fn execute(&self, project: Project) -> Result { + 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, + packages: Arc, +} + +impl SetProjectPluginEnabled { + /// Builds the use case. + #[must_use] + pub fn new( + store: Arc, + packages: Arc, + ) -> 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 { + 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, + contexts: Arc, + resolver: Arc, +} + +impl MutateAgentPluginSkill { + /// Builds the use case. + #[must_use] + pub fn new( + store: Arc, + contexts: Arc, + resolver: Arc, + ) -> Self { + Self { + store, + contexts, + resolver, + } + } + + /// Applies an idempotent mutation. + pub async fn execute( + &self, + project: Project, + agent_id: AgentId, + reference: PluginSkillRef, + mutation: AssignmentMutation, + ) -> Result { + 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, + contexts: Arc, + resolver: Arc, +} + +impl MutateAgentPluginTool { + /// Builds the use case. + #[must_use] + pub fn new( + store: Arc, + contexts: Arc, + resolver: Arc, + ) -> 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 { + 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 { + 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 { + 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, + }) +} diff --git a/crates/application/src/skill/usecases.rs b/crates/application/src/skill/usecases.rs index a8e76c8e..3cc2c653 100644 --- a/crates/application/src/skill/usecases.rs +++ b/crates/application/src/skill/usecases.rs @@ -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, + plugin_skills: Option>, } impl AssignedSkillResolver { /// Builds the resolver from the existing skill store port. #[must_use] pub fn new(skills: Arc) -> 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) -> 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) diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index cd6b694b..30b19d74 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -2191,7 +2191,8 @@ async fn two_agents_same_root_get_distinct_run_dirs_no_collision() { #[tokio::test] async fn launch_conventionfile_injects_assigned_skills_in_order() { // An agent with two assigned global skills; the generated convention file must - // carry both skill bodies, after the persona, in assignment order (§14.2). + // advertise both capabilities before the persona, in assignment order. With no + // explicit descriptions, the first body line is the documented fallback. let skill_id = |n: u128| SkillId::from_uuid(Uuid::from_u128(n)); let skill = |n: u128, name: &str, body: &str| { Skill::new( @@ -2244,14 +2245,20 @@ async fn launch_conventionfile_injects_assigned_skills_in_order() { assert_eq!(writes.len(), 1); let doc = String::from_utf8(writes[0].1.clone()).unwrap(); assert!(doc.contains("# persona"), "persona present: {doc}"); - assert!(doc.contains("REFAC_BODY"), "first skill body present"); - assert!(doc.contains("REVIEW_BODY"), "second skill body present"); - // Deterministic order: persona before skills, skill 1 before skill 2. + assert!( + doc.contains("**refactor** — REFAC_BODY"), + "first skill affordance present" + ); + assert!( + doc.contains("**review** — REVIEW_BODY"), + "second skill affordance present" + ); + // Durable capability-first order: skills before persona, assignments stable. let persona_at = doc.find("# persona").unwrap(); let refac_at = doc.find("REFAC_BODY").unwrap(); let review_at = doc.find("REVIEW_BODY").unwrap(); assert!( - persona_at < refac_at && refac_at < review_at, + refac_at < review_at && review_at < persona_at, "ordering: {doc}" ); } diff --git a/crates/application/tests/structured_launch_d3.rs b/crates/application/tests/structured_launch_d3.rs index b426af34..cb55fad8 100644 --- a/crates/application/tests/structured_launch_d3.rs +++ b/crates/application/tests/structured_launch_d3.rs @@ -1216,7 +1216,12 @@ fn swap_fixture(agent: &Agent, target_structured: bool, factory: FakeFactory) -> Arc::new(FakeRecall), None, ) - .with_structured(Arc::new(factory.clone()), Arc::clone(&structured)); + .with_structured(Arc::new(factory.clone()), Arc::clone(&structured)) + .with_structured_routing_mode(if target_structured { + StructuredRoutingMode::RequireStructured + } else { + StructuredRoutingMode::HumanPtyFallback + }); let swap = ChangeAgentProfile::new( Arc::new(contexts.clone()), @@ -1582,7 +1587,8 @@ fn launch_fixture_p8b( Arc::new(FakeRecall), None, ) - .with_structured(Arc::new(factory), Arc::clone(&structured)); + .with_structured(Arc::new(factory), Arc::clone(&structured)) + .with_structured_routing_mode(StructuredRoutingMode::RequireStructured); if let Some(store) = store { let provider = Arc::new(FakeProviderSessionProvider(store)) as Arc; @@ -1798,6 +1804,7 @@ fn launch_fixture_with_provider( None, ) .with_structured(Arc::new(factory), Arc::clone(&structured)) + .with_structured_routing_mode(StructuredRoutingMode::RequireStructured) .with_provider_session_provider(provider); (Arc::new(launch), agent, observed) } diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index e6f8bafe..a35dee3d 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -36,6 +36,8 @@ pub use crate::ticket_dto::*; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginContributionSummaryDto { + /// Agent skills count. + pub skills: usize, /// Top-level menus count. pub top_level_menus: usize, /// Menu items count. @@ -51,6 +53,7 @@ pub struct PluginContributionSummaryDto { impl From for PluginContributionSummaryDto { fn from(value: application::PluginContributionSummary) -> Self { Self { + skills: value.skills, top_level_menus: value.top_level_menus, menu_items: value.menu_items, slash_commands: value.slash_commands, @@ -60,6 +63,89 @@ impl From for PluginContributionSummaryD } } +/// Project-scoped plugin activation and assignment settings returned to clients. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectPluginSettingsDto { + /// Schema version. + pub version: u32, + /// Per-project plugin activation choices. + pub plugins: Vec, + /// Per-agent plugin capability assignments. + pub agents: Vec, +} + +impl From for ProjectPluginSettingsDto { + fn from(value: domain::ProjectPluginSettings) -> Self { + Self { + version: value.version, + plugins: value.plugins, + agents: value.agents, + } + } +} + +/// Project plugin settings plus the globally installed manifest catalogue. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectPluginSettingsViewDto { + /// Persisted project settings. + pub settings: ProjectPluginSettingsDto, + /// Currently installed and valid plugin manifests. + pub installed: Vec, +} + +impl From for ProjectPluginSettingsViewDto { + fn from(value: application::ProjectPluginSettingsView) -> Self { + Self { + settings: value.settings.into(), + installed: value.installed, + } + } +} + +/// Request to toggle a globally installed plugin for one project. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetProjectPluginEnabledRequestDto { + /// Owning project id. + pub project_id: String, + /// Globally installed plugin id. + pub plugin_id: String, + /// Desired project activation state. + pub enabled: bool, +} + +/// Request to assign or unassign a plugin skill for an agent. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MutateAgentPluginSkillRequestDto { + /// Owning project id. + pub project_id: String, + /// Target agent id. + pub agent_id: String, + /// Owning plugin id. + pub plugin_id: String, + /// Manifest-local skill id. + pub skill_id: String, +} + +/// Request to assign or unassign a plugin MCP tool for an agent. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MutateAgentPluginToolRequestDto { + /// Owning project id. + pub project_id: String, + /// Target agent id. + pub agent_id: String, + /// Owning plugin id. + pub plugin_id: String, + /// Manifest-local MCP server id. + pub server_id: String, + /// Tool name advertised by that server. + pub tool_name: String, +} + /// Admin plugin DTO. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 38ee3d2f..6c70e368 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -14,52 +14,54 @@ use std::sync::{Arc, Mutex}; use application::orchestrator::UpdateProjectContext as GuardedUpdateProjectContext; use application::{ AddIssueAttachment, AgentResumer, AgentWakeService, AllocateManagedTemp, AppError, - AssignIssueAgent, AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, - AuthenticateSession, BackgroundCommandArchive, BulkDeleteIssues, BulkUpdateIssuePriority, - BulkUpdateIssueStatus, CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion, - ClearChatDraft, CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab, - CloseTerminal, CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, - CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, - CreateProject, CreateSkill, CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, - DeleteIssue, DeleteLayout, DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile, - DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, - DetectProfiles, DismissEmbedderSuggestion, EnsureLocalModelServer, ExecuteSlashCommand, - FirstRunState, GcManagedTemp, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, - GetProjectPermissions, GetProjectSystemPermissions, GetProjectWorkState, GitBranches, - GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, - HarvestMemoryFromTurn, HealthUseCase, ImportChatAttachments, InspectConversation, - InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, - LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels, - ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, + AssignIssueAgent, AssignSkillToAgent, AssignTicketToSprint, AssignedSkillResolver, + AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive, BulkDeleteIssues, + BulkUpdateIssuePriority, BulkUpdateIssueStatus, CancelBackgroundTask, ChangeAgentProfile, + CheckEmbedderSuggestion, ClearChatDraft, CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, + CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant, ConfigureProfiles, + ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, + CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint, CreateTemplate, + DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory, + DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, + DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, + DismissEmbedderSuggestion, EnsureLocalModelServer, ExecuteSlashCommand, FirstRunState, + GcManagedTemp, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, GetProjectPermissions, + GetProjectSystemPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, + GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, + HealthUseCase, ImportChatAttachments, InspectConversation, InstallPluginFromArchive, + InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, + LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels, ListDevices, + ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, - ListProjectFiles, ListProjects, ListResumableAgents, ListSkills, ListSlashCommands, - ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, - LiveStateProvider, LiveStateReadProvider, LoadLayout, MarkIssueAttachmentSummarized, - McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, + ListProjectFiles, ListProjectPluginSettings, ListProjects, ListResumableAgents, ListSkills, + ListSlashCommands, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, + LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, + MarkIssueAttachmentSummarized, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow, + MutateAgentPluginSkill, MutateAgentPluginTool, MutateLayout, OnnxModelView, OpenPluginLayoutWindow, OpenProject, OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, PluginCommandTasks, PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess, - PluginToolchainDiagnostics, PluginWorkspaceAccess, ProposeContext, PurgeManagedTemp, - QueryProjectStructure, ReadAgentContext, ReadChatDraft, ReadContext, ReadConversationPage, - ReadIssue, ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, - ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, - ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn, - RecordTurnProvider, ReferenceProfiles, ReleaseManagedTemp, ReloadPlugin, ReloadPluginInput, - RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, + PluginToolchainDiagnostics, PluginWorkspaceAccess, ProjectPluginResolver, ProposeContext, + PurgeManagedTemp, QueryProjectStructure, ReadAgentContext, ReadChatDraft, ReadContext, + ReadConversationPage, ReadIssue, ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, + ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, + ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, + RecordTurn, RecordTurnProvider, ReferenceProfiles, ReleaseManagedTemp, ReloadPlugin, + ReloadPluginInput, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions, ResolveAgentSystemPermissions, ResolveManagedTemp, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft, SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile, - SessionLimitService, SetActiveLayout, SetPluginEnabled, SlashCommandRegistry, - SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, - StructuredRoutingMode, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, - TerminalSessions, TouchDevice, UnassignSkillFromAgent, UnassignTicketFromSprint, - UninstallPlugin, UnlinkIssues, UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort, - UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions, - UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext, - UpdateProjectMcpToolPermissions, UpdateProjectOrchestrator, UpdateProjectPermissions, - UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, - WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, + SessionLimitService, SetActiveLayout, SetPluginEnabled, SetProjectPluginEnabled, + SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, + StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession, + SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent, + UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContactable, + UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, + UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, + UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectOrchestrator, + UpdateProjectPermissions, UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, + WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; use domain::ports::{ @@ -70,10 +72,10 @@ use domain::ports::{ FileSystem, GitPort, IdGenerator, IssueNumberAllocator, IssueStore, ManagedTempStore, McpToolPermissionStore, MemoryRecall, MemoryStore, ModelArtifactDownloader, PermissionStore, PluginManifestValidator, PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, - PluginStorageStore, ProcessSpawner, ProfileStore, ProjectStore, PtyHandle, PtyPort, - RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore, - StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, ToolInvoker, - WakeError, WakeReason, WindowStateStore, + PluginStorageStore, ProcessSpawner, ProfileStore, ProjectPluginSettingsStore, ProjectStore, + PtyHandle, PtyPort, RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore, + SprintStore, StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, + ToolInvoker, WakeError, WakeReason, WindowStateStore, }; use domain::profile::{ AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter, @@ -98,18 +100,18 @@ use infrastructure::{ FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsManagedTempStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore, - FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore, - FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, Git2Repository, - HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, - HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, - InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem, - LocalManagedProcess, LocalProcessSpawner, ManagedTempToolProvider, McpServer, MediatedInbox, - NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader, - ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock, - SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer, - TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, - VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, - VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, + FsProfileStore, FsProjectPluginSettingsStore, FsProjectStore, FsProviderSessionStore, + FsSecretStore, FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, + FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader, + HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore, + InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime, + LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, + ManagedTempToolProvider, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, + PortablePtyAdapter, ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard, + StructuredSessionFactory, SystemClock, SystemMillisClock, TemplateToolProvider, + TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, + ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, + ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; pub mod dto; @@ -1552,6 +1554,14 @@ pub struct BackendCore { pub plugin_registry_store: Arc, /// Manifest validator exposed for the Tauri asset protocol adapter. pub plugin_manifest_validator: Arc, + /// Read project-scoped plugin activation and assignment settings. + pub list_project_plugin_settings: Arc, + /// Enable or disable an installed plugin for one project. + pub set_project_plugin_enabled: Arc, + /// Assign or unassign a plugin skill for a project agent. + pub mutate_agent_plugin_skill: Arc, + /// Assign or unassign a plugin MCP tool for a project agent. + pub mutate_agent_plugin_tool: Arc, // --- Templates & sync (L7) --- /// Create a template in the global store. pub create_template: Arc, @@ -1827,6 +1837,16 @@ impl BackendCore { let plugin_storage_store = Arc::clone(&plugin_storage) as Arc; let plugin_manifest_validator = Arc::clone(&plugin_validator) as Arc; + let project_plugin_settings = + Arc::new(FsProjectPluginSettingsStore::new(Arc::clone(&fs_port))); + let project_plugin_settings_store = + Arc::clone(&project_plugin_settings) as Arc; + let project_plugin_resolver = Arc::new(ProjectPluginResolver::new( + Arc::clone(&project_plugin_settings_store), + Arc::clone(&plugin_package_store), + Arc::clone(&plugin_registry_store), + Arc::clone(&plugin_manifest_validator), + )); let plugin_mcp_supervisor_port = Arc::clone(&plugin_mcp_supervisor) as Arc; @@ -2355,6 +2375,10 @@ impl BackendCore { app_data_dir.to_string_lossy().into_owned(), )); let skill_store_port = Arc::clone(&skill_store) as Arc; + let assigned_skill_resolver = Arc::new( + AssignedSkillResolver::new(Arc::clone(&skill_store_port)) + .with_plugin_skills(Arc::clone(&project_plugin_resolver)), + ); // Memory store + naïve recall (LOT A/B — §14.5.1/§14.5.4). Built here so the // recall port can be injected into LaunchAgent below (it composes the project @@ -2455,8 +2479,9 @@ impl BackendCore { &prompt_store_port, ))); - let agent_capabilities = - Arc::new(ResolveAgentCapabilities::new(Arc::clone(&skill_store_port))); + let agent_capabilities = Arc::new(ResolveAgentCapabilities::with_resolver(Arc::clone( + &assigned_skill_resolver, + ))); let create_agent = Arc::new(CreateAgentFromScratch::new( Arc::clone(&contexts_port), @@ -2505,6 +2530,7 @@ impl BackendCore { Arc::clone(&memory_recall_port), Some(Arc::clone(&check_embedder_suggestion)), ) + .with_assigned_skill_resolver(Arc::clone(&assigned_skill_resolver)) .with_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback) .with_permission_store(Arc::clone(&permission_store_port)) .with_system_permission_store(Arc::clone(&system_permission_store_port)) @@ -2562,6 +2588,7 @@ impl BackendCore { Arc::clone(&memory_recall_port), Some(Arc::clone(&check_embedder_suggestion)), ) + .with_assigned_skill_resolver(Arc::clone(&assigned_skill_resolver)) .with_structured_routing_mode(StructuredRoutingMode::RequireStructured) .with_permission_store(Arc::clone(&permission_store_port)) .with_system_permission_store(Arc::clone(&system_permission_store_port)) @@ -2739,9 +2766,9 @@ impl BackendCore { // Lecture d'un skill par nom pour l'outil MCP `idea_skill_read` : compose // le SkillStore et le manifeste agent afin d'autoriser uniquement les // skills assignés au requester courant. - let read_skill = Arc::new(ReadSkill::new( + let read_skill = Arc::new(ReadSkill::with_resolver( Arc::clone(&contexts_port), - Arc::clone(&skill_store_port), + Arc::clone(&assigned_skill_resolver), )); let assign_skill = Arc::new(AssignSkillToAgent::new( Arc::clone(&contexts_port), @@ -2751,6 +2778,25 @@ impl BackendCore { Arc::clone(&contexts_port), Arc::clone(&events_port), )); + let list_project_plugin_settings = Arc::new(ListProjectPluginSettings::new( + Arc::clone(&project_plugin_resolver), + Arc::clone(&plugin_package_store), + Arc::clone(&plugin_manifest_validator), + )); + let set_project_plugin_enabled = Arc::new(SetProjectPluginEnabled::new( + Arc::clone(&project_plugin_settings_store), + Arc::clone(&plugin_package_store), + )); + let mutate_agent_plugin_skill = Arc::new(MutateAgentPluginSkill::new( + Arc::clone(&project_plugin_settings_store), + Arc::clone(&contexts_port), + Arc::clone(&project_plugin_resolver), + )); + let mutate_agent_plugin_tool = Arc::new(MutateAgentPluginTool::new( + Arc::clone(&project_plugin_settings_store), + Arc::clone(&contexts_port), + Arc::clone(&project_plugin_resolver), + )); // --- Memory use cases (LOT A — §14.5.1) --- // `memory_store` / `memory_store_port` / `memory_recall_port` are built @@ -3595,6 +3641,10 @@ impl BackendCore { plugin_package_store: Arc::clone(&plugin_packages), plugin_registry_store: Arc::clone(&plugin_registry_store), plugin_manifest_validator: Arc::clone(&plugin_manifest_validator), + list_project_plugin_settings, + set_project_plugin_enabled, + mutate_agent_plugin_skill, + mutate_agent_plugin_tool, ticket_tool_binder, template_tool_binder, managed_temp_tool_binder, diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 31e5b728..3627d681 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -236,14 +236,15 @@ pub use system_permissions::{ }; pub use plugin::{ - ContentHash, CustomPluginLayout, PluginActivationScope, PluginBundleUrl, PluginCapability, - PluginCommandId, PluginContributionSet, PluginDescriptor, PluginError, PluginId, - PluginInstallSource, PluginLayoutContribution, PluginLayoutType, PluginLifecycleState, - PluginManifest, PluginMcpServerContribution, PluginMcpServerId, PluginMcpServerSpec, - PluginMcpStatus, PluginMcpStatusSet, PluginMenuItemContribution, PluginPackageRef, - PluginRegistry, PluginRegistryEntry, PluginSlashCommandContribution, - PluginTopLevelMenuContribution, PluginTrustLevel, PluginVersion, RelativePath, RemovalOutcome, - StagedPluginPackage, + AgentPluginAssignments, ContentHash, CustomPluginLayout, PluginActivationScope, + PluginBundleUrl, PluginCapability, PluginCommandId, PluginContributionSet, PluginDescriptor, + PluginError, PluginId, PluginInstallSource, PluginLayoutContribution, PluginLayoutType, + PluginLifecycleState, PluginManifest, PluginMcpServerContribution, PluginMcpServerId, + PluginMcpServerSpec, PluginMcpStatus, PluginMcpStatusSet, PluginMenuItemContribution, + PluginPackageRef, PluginRegistry, PluginRegistryEntry, PluginSkillContribution, PluginSkillId, + PluginSkillRef, PluginSlashCommandContribution, PluginToolRef, PluginTopLevelMenuContribution, + PluginTrustLevel, PluginVersion, ProjectPluginActivation, ProjectPluginSettings, RelativePath, + RemovalOutcome, StagedPluginPackage, }; pub use sandbox::{ @@ -270,8 +271,9 @@ pub use ports::{ PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStorageError, PluginStorageStore, PluginStoreError, PreparedContext, ProcessError, ProcessSpawner, - ProfileStore, ProjectStore, ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, - RemoteHost, RemotePath, RuntimeError, RuntimePermissionProbe, ScheduledTask, Scheduler, - SpawnSpec, SprintStore, SprintStoreError, StoreError, StructuredSessionEnvironment, - StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, WindowStateStore, + ProfileStore, ProjectPluginSettingsStore, ProjectStore, ProviderModelCatalogue, PtyError, + PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, RuntimePermissionProbe, + ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError, + StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore, + TemplateStore, WindowStateStore, }; diff --git a/crates/domain/src/orchestrator.rs b/crates/domain/src/orchestrator.rs index 3e800606..e177b261 100644 --- a/crates/domain/src/orchestrator.rs +++ b/crates/domain/src/orchestrator.rs @@ -17,8 +17,20 @@ use crate::conversation::ConversationParty; use crate::ids::{AgentId, NodeId, SkillId}; use crate::live_state::WorkStatus; use crate::mailbox::TicketId; +use crate::plugin::{PluginId, PluginSkillId}; use crate::skill::{SkillKind, SkillScope}; +/// Plugin origin for a resolved assigned skill. `None` on +/// [`AssignedSkillSnapshot::plugin`] denotes a native IdeA skill. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssignedPluginSkillSource { + /// Owning plugin. + pub plugin_id: PluginId, + /// Manifest-local skill id. + pub skill_id: PluginSkillId, +} + /// Current schema version for orchestration capabilities injected into an /// agent's effective runtime context. pub const ORCHESTRATION_CAPABILITY_SNAPSHOT_VERSION: u32 = 1; @@ -35,6 +47,9 @@ pub struct AssignedSkillSnapshot { pub skill_id: SkillId, /// Store scope used to resolve the skill body. pub scope: SkillScope, + /// Plugin origin. Absent for native IdeA skills. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin: Option, /// Display name shown to the model and accepted by `idea_skill_read`. pub name: String, /// One-line affordance description shown in the model context. diff --git a/crates/domain/src/plugin.rs b/crates/domain/src/plugin.rs index 65081839..68e711a3 100644 --- a/crates/domain/src/plugin.rs +++ b/crates/domain/src/plugin.rs @@ -370,6 +370,41 @@ impl PluginMcpServerId { } } +/// Stable id of a skill contributed by a plugin manifest. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PluginSkillId(String); + +impl PluginSkillId { + /// Validates and creates a plugin skill id. + pub fn new(raw: impl Into) -> Result { + PluginCommandId::new(raw).map(|v| Self(v.0)) + } + + /// Returns the raw id. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Read-only skill contribution whose body lives inside the plugin package. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginSkillContribution { + /// Stable id within the plugin. + pub id: PluginSkillId, + /// Agent-facing name accepted by `idea_skill_read`. + pub name: String, + /// Short affordance shown in the injected skill catalogue. + pub description: String, + /// Capability nature. + #[serde(default)] + pub kind: crate::SkillKind, + /// Package-relative Markdown file. + pub path: RelativePath, +} + /// Top-level menu contribution. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -484,6 +519,9 @@ pub struct PluginMcpServerContribution { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginContributionSet { + /// Read-only agent skills shipped in the package. + #[serde(default)] + pub skills: Vec, /// Top-level menus. #[serde(default)] pub menus: Vec, @@ -501,6 +539,94 @@ pub struct PluginContributionSet { pub mcp_servers: Vec, } +/// Project-scoped plugin activation entry. It is retained when a plugin is +/// temporarily uninstalled so reinstalling does not lose user intent. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectPluginActivation { + /// Globally-installed plugin id. + pub plugin_id: PluginId, + /// Whether this project may expose the plugin's capabilities. + pub enabled: bool, +} + +/// Reference to one plugin skill assigned to an agent. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginSkillRef { + /// Owning plugin. + pub plugin_id: PluginId, + /// Skill id within that plugin. + pub skill_id: PluginSkillId, +} + +/// Stable namespace of one externally-provided MCP tool. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginToolRef { + /// Owning plugin. + pub plugin_id: PluginId, + /// MCP server id within that plugin. + pub server_id: PluginMcpServerId, + /// Tool name advertised by the server. + pub tool_name: String, +} + +/// Plugin capabilities explicitly assigned to one project agent. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentPluginAssignments { + /// Agent receiving the capabilities. + pub agent_id: crate::AgentId, + /// Assigned plugin skills, in injection order. + #[serde(default)] + pub skills: Vec, + /// Assigned external MCP tools. + #[serde(default)] + pub tools: Vec, +} + +/// Versioned project-owned plugin settings persisted under +/// `.ideai/project-plugins.json`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectPluginSettings { + /// Schema version. + pub version: u32, + /// Explicit project activation choices. + #[serde(default)] + pub plugins: Vec, + /// Per-agent assignments. Dangling entries are intentional tombstones. + #[serde(default)] + pub agents: Vec, +} + +impl Default for ProjectPluginSettings { + fn default() -> Self { + Self { + version: 1, + plugins: Vec::new(), + agents: Vec::new(), + } + } +} + +impl ProjectPluginSettings { + /// Whether a plugin is explicitly enabled for this project. + #[must_use] + pub fn is_enabled(&self, plugin_id: &PluginId) -> bool { + self.plugins + .iter() + .any(|p| &p.plugin_id == plugin_id && p.enabled) + } + + /// Assignments for an agent, if any. + #[must_use] + pub fn assignments_for(&self, agent_id: crate::AgentId) -> Option<&AgentPluginAssignments> { + self.agents.iter().find(|a| a.agent_id == agent_id) + } +} + /// Validated plugin manifest. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 327d7a90..117da86a 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -446,6 +446,19 @@ pub trait PluginPackageStore: Send + Sync { package: &PluginPackageRef, ) -> Result; + /// Reads a package-relative file from an installed plugin, confined to its + /// canonical package root. + async fn read_installed_file( + &self, + plugin_id: &crate::PluginId, + path: &crate::RelativePath, + ) -> Result, PluginStoreError> { + let _ = (plugin_id, path); + Err(PluginStoreError::Invalid( + "installed package file reads are not supported by this adapter".to_owned(), + )) + } + /// Stages an archive for validation. async fn install_from_archive( &self, @@ -496,6 +509,23 @@ pub trait PluginRegistryStore: Send + Sync { async fn save_registry(&self, registry: &PluginRegistry) -> Result<(), PluginRegistryError>; } +/// Store for one project's plugin activation and agent assignments. +#[async_trait] +pub trait ProjectPluginSettingsStore: Send + Sync { + /// Loads settings, returning an empty version-1 document when missing. + async fn load( + &self, + project_root: &crate::ProjectPath, + ) -> Result; + + /// Atomically persists the complete document. + async fn save( + &self, + project_root: &crate::ProjectPath, + settings: &crate::ProjectPluginSettings, + ) -> Result<(), StoreError>; +} + /// Manifest parser and validator. pub trait PluginManifestValidator: Send + Sync { /// Validates raw manifest bytes. diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 97dcb898..33e32e43 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -110,6 +110,7 @@ pub use sandbox::{default_enforcer, NoopSandbox}; pub use scheduler::TokioScheduler; pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory}; pub use sprints::FsSprintStore; +pub use store::FsProjectPluginSettingsStore; #[cfg(feature = "vector-onnx")] pub use store::OnnxEmbedder; #[cfg(feature = "vector-http")] diff --git a/crates/infrastructure/src/plugin/mod.rs b/crates/infrastructure/src/plugin/mod.rs index 0f350487..b1964638 100644 --- a/crates/infrastructure/src/plugin/mod.rs +++ b/crates/infrastructure/src/plugin/mod.rs @@ -155,6 +155,21 @@ impl PluginPackageStore for FsPluginPackageStore { Ok(PluginManifestBytes { bytes }) } + async fn read_installed_file( + &self, + plugin_id: &PluginId, + path: &RelativePath, + ) -> Result, PluginStoreError> { + let resolved = self.resolve_asset_path(plugin_id, path)?; + fs::read(resolved).map_err(|e| { + if e.kind() == io::ErrorKind::NotFound { + PluginStoreError::NotFound + } else { + PluginStoreError::Io(e.to_string()) + } + }) + } + async fn install_from_archive( &self, archive: &LocalPath, diff --git a/crates/infrastructure/src/store/mod.rs b/crates/infrastructure/src/store/mod.rs index 9fd31376..17b7e665 100644 --- a/crates/infrastructure/src/store/mod.rs +++ b/crates/infrastructure/src/store/mod.rs @@ -14,6 +14,7 @@ mod memory; mod permission; mod profile; mod project; +mod project_plugin; mod secrets; mod skill; mod system_permission; @@ -39,6 +40,7 @@ pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall}; pub use permission::FsPermissionStore; pub use profile::{FsEmbedderProfileStore, FsProfileStore}; pub use project::FsProjectStore; +pub use project_plugin::FsProjectPluginSettingsStore; pub use secrets::FsSecretStore; pub use skill::FsSkillStore; pub use system_permission::FsSystemPermissionStore; diff --git a/crates/infrastructure/src/store/project_plugin.rs b/crates/infrastructure/src/store/project_plugin.rs new file mode 100644 index 00000000..f50185f2 --- /dev/null +++ b/crates/infrastructure/src/store/project_plugin.rs @@ -0,0 +1,76 @@ +//! Project-scoped plugin activation and assignment persistence. + +use std::sync::Arc; + +use async_trait::async_trait; +use domain::{ + FileSystem, FsError, ProjectPath, ProjectPluginSettings, ProjectPluginSettingsStore, + RemotePath, StoreError, +}; + +const SETTINGS_FILE: &str = ".ideai/project-plugins.json"; + +/// File-backed settings store using the project's location-neutral filesystem. +#[derive(Clone)] +pub struct FsProjectPluginSettingsStore { + fs: Arc, +} + +impl FsProjectPluginSettingsStore { + /// Builds the adapter. + #[must_use] + pub fn new(fs: Arc) -> Self { + Self { fs } + } + + fn path(project_root: &ProjectPath) -> RemotePath { + RemotePath::new(format!( + "{}/{}", + project_root.as_str().trim_end_matches(['/', '\\']), + SETTINGS_FILE + )) + } +} + +#[async_trait] +impl ProjectPluginSettingsStore for FsProjectPluginSettingsStore { + async fn load(&self, project_root: &ProjectPath) -> Result { + match self.fs.read(&Self::path(project_root)).await { + Ok(bytes) => { + let settings: ProjectPluginSettings = serde_json::from_slice(&bytes) + .map_err(|e| StoreError::Serialization(e.to_string()))?; + if settings.version != 1 { + return Err(StoreError::Invalid(format!( + "unsupported project plugin settings version {}", + settings.version + ))); + } + Ok(settings) + } + Err(FsError::NotFound(_)) => Ok(ProjectPluginSettings::default()), + Err(e) => Err(StoreError::Io(e.to_string())), + } + } + + async fn save( + &self, + project_root: &ProjectPath, + settings: &ProjectPluginSettings, + ) -> Result<(), StoreError> { + let dir = RemotePath::new(format!( + "{}/.ideai", + project_root.as_str().trim_end_matches(['/', '\\']) + )); + self.fs + .create_dir_all(&dir) + .await + .map_err(|e| StoreError::Io(e.to_string()))?; + let mut bytes = serde_json::to_vec_pretty(settings) + .map_err(|e| StoreError::Serialization(e.to_string()))?; + bytes.push(b'\n'); + self.fs + .write(&Self::path(project_root), &bytes) + .await + .map_err(|e| StoreError::Io(e.to_string())) + } +} diff --git a/crates/infrastructure/tests/project_plugin_settings.rs b/crates/infrastructure/tests/project_plugin_settings.rs new file mode 100644 index 00000000..45e55d39 --- /dev/null +++ b/crates/infrastructure/tests/project_plugin_settings.rs @@ -0,0 +1,84 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use domain::{ + AgentId, AgentPluginAssignments, FileSystem, PluginId, PluginSkillId, PluginSkillRef, + ProjectPath, ProjectPluginActivation, ProjectPluginSettings, ProjectPluginSettingsStore, +}; +use infrastructure::{FsProjectPluginSettingsStore, LocalFileSystem}; +use uuid::Uuid; + +struct TempDir(PathBuf); + +impl TempDir { + fn new() -> Self { + let path = std::env::temp_dir().join(format!("idea-project-plugins-{}", Uuid::new_v4())); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + + fn root(&self) -> ProjectPath { + ProjectPath::new(self.0.to_string_lossy().into_owned()).unwrap() + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn store() -> FsProjectPluginSettingsStore { + let fs: Arc = Arc::new(LocalFileSystem::new()); + FsProjectPluginSettingsStore::new(fs) +} + +#[tokio::test] +async fn missing_document_returns_version_one_defaults() { + let tmp = TempDir::new(); + let settings = store().load(&tmp.root()).await.unwrap(); + assert_eq!(settings, ProjectPluginSettings::default()); +} + +#[tokio::test] +async fn settings_round_trip_under_project_ideai_directory() { + let tmp = TempDir::new(); + let plugin_id = PluginId::new("dev.acme.tools").unwrap(); + let agent_id = AgentId::from_uuid(Uuid::from_u128(42)); + let settings = ProjectPluginSettings { + version: 1, + plugins: vec![ProjectPluginActivation { + plugin_id: plugin_id.clone(), + enabled: true, + }], + agents: vec![AgentPluginAssignments { + agent_id, + skills: vec![PluginSkillRef { + plugin_id, + skill_id: PluginSkillId::new("build-debug").unwrap(), + }], + tools: Vec::new(), + }], + }; + + store().save(&tmp.root(), &settings).await.unwrap(); + + assert_eq!(store().load(&tmp.root()).await.unwrap(), settings); + assert!(tmp.0.join(".ideai/project-plugins.json").is_file()); +} + +#[tokio::test] +async fn unsupported_document_version_is_rejected() { + let tmp = TempDir::new(); + std::fs::create_dir_all(tmp.0.join(".ideai")).unwrap(); + std::fs::write( + tmp.0.join(".ideai/project-plugins.json"), + br#"{"version":2,"plugins":[],"agents":[]}"#, + ) + .unwrap(); + + let error = store().load(&tmp.root()).await.unwrap_err(); + assert!(error + .to_string() + .contains("unsupported project plugin settings version 2")); +} diff --git a/frontend/src/adapters/http/unsupported.ts b/frontend/src/adapters/http/unsupported.ts index 71427229..9259c47e 100644 --- a/frontend/src/adapters/http/unsupported.ts +++ b/frontend/src/adapters/http/unsupported.ts @@ -24,6 +24,8 @@ import type { PluginProjectStructure, PluginReview, PluginRuntimeContributionCatalog, + ProjectPluginSettings, + ProjectPluginSettingsView, PluginUninstallResult, PluginWorkspaceBinaryFile, PluginWorkspaceDirectoryListing, @@ -45,6 +47,8 @@ import type { PluginEventSubscribeInput, PluginEventUnsubscribeInput, PluginGateway, + MutateAgentPluginSkillInput, + MutateAgentPluginToolInput, PluginStorageGateway, PluginStorageGetInput, PluginStorageSetInput, @@ -60,6 +64,7 @@ import type { PluginWorkspaceWriteTextInput, RemoteGateway, ReviewPluginPackageInput, + SetProjectPluginEnabledInput, ViewWindowClosed, ViewWindowSnapshot, WindowGateway, @@ -191,6 +196,24 @@ export class WebPluginGateway implements PluginGateway { // lets the bootstrap loader run unconditionally without a transport check. return { plugins: [] }; } + async listProjectSettings(_projectId: string): Promise { + return unsupportedOnWeb("Project plugin settings"); + } + async setProjectEnabled(_input: SetProjectPluginEnabledInput): Promise { + return unsupportedOnWeb("Project plugin settings"); + } + async assignAgentSkill(_input: MutateAgentPluginSkillInput): Promise { + return unsupportedOnWeb("Project plugin settings"); + } + async unassignAgentSkill(_input: MutateAgentPluginSkillInput): Promise { + return unsupportedOnWeb("Project plugin settings"); + } + async assignAgentTool(_input: MutateAgentPluginToolInput): Promise { + return unsupportedOnWeb("Project plugin settings"); + } + async unassignAgentTool(_input: MutateAgentPluginToolInput): Promise { + return unsupportedOnWeb("Project plugin settings"); + } async openPluginsFolder(_pluginId?: string): Promise { return unsupportedOnWeb("Plugin management"); } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 487627ae..30fa6713 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -57,6 +57,9 @@ import type { PluginProjectStructureEntry, PluginReview, PluginRuntimeContributionCatalog, + ProjectPluginManifest, + ProjectPluginSettings, + ProjectPluginSettingsView, PluginToolchainDiagnostic, PluginUninstallResult, PluginPublicEvent, @@ -138,6 +141,8 @@ import type { PluginEventSubscribeInput, PluginEventUnsubscribeInput, PluginGateway, + MutateAgentPluginSkillInput, + MutateAgentPluginToolInput, PluginStorageGateway, PluginStorageGetInput, PluginStorageSetInput, @@ -155,6 +160,7 @@ import type { ReattachAgentChatResult, RemoteGateway, ReviewPluginPackageInput, + SetProjectPluginEnabledInput, SaveOpenCodeProviderProfileInput, SendAgentChatOptions, SkillGateway, @@ -4004,6 +4010,8 @@ function isBlanketRule(rule: PermissionSet["rules"][number]) { */ export class MockPluginGateway implements PluginGateway { private plugins: PluginAdmin[] = []; + private projectManifests: ProjectPluginManifest[] = []; + private projectSettings = new Map(); private seq = 0; private summaryFor(_pluginId: string): PluginContributionSummary { @@ -4015,6 +4023,24 @@ export class MockPluginGateway implements PluginGateway { this.plugins.push(plugin); } + /** Test/dev helper: seed a full manifest for project capability settings. */ + _seedProjectPluginManifest(manifest: ProjectPluginManifest): void { + this.projectManifests.push(structuredClone(manifest)); + } + + /** Test/dev helper: seed activation/assignment tombstones. */ + _seedProjectPluginSettings(projectId: string, settings: ProjectPluginSettings): void { + this.projectSettings.set(projectId, structuredClone(settings)); + } + + private settingsFor(projectId: string): ProjectPluginSettings { + const existing = this.projectSettings.get(projectId); + if (existing) return existing; + const created: ProjectPluginSettings = { version: 1, plugins: [], agents: [] }; + this.projectSettings.set(projectId, created); + return created; + } + async listPlugins(): Promise { return structuredClone(this.plugins); } @@ -4099,6 +4125,68 @@ export class MockPluginGateway implements PluginGateway { return { plugins: [] }; } + async listProjectSettings(projectId: string): Promise { + return { + settings: structuredClone(this.settingsFor(projectId)), + installed: structuredClone(this.projectManifests), + }; + } + + async setProjectEnabled(input: SetProjectPluginEnabledInput): Promise { + if (input.enabled && !this.projectManifests.some((plugin) => plugin.id === input.pluginId)) { + throw { code: "NOT_FOUND", message: `plugin ${input.pluginId} not found` } satisfies GatewayError; + } + const settings = this.settingsFor(input.projectId); + const activation = settings.plugins.find((entry) => entry.pluginId === input.pluginId); + if (activation) activation.enabled = input.enabled; + else settings.plugins.push({ pluginId: input.pluginId, enabled: input.enabled }); + return structuredClone(settings); + } + + private assignmentsFor(projectId: string, agentId: string) { + const settings = this.settingsFor(projectId); + let assignments = settings.agents.find((entry) => entry.agentId === agentId); + if (!assignments) { + assignments = { agentId, skills: [], tools: [] }; + settings.agents.push(assignments); + } + return assignments; + } + + async assignAgentSkill(input: MutateAgentPluginSkillInput): Promise { + const assignments = this.assignmentsFor(input.projectId, input.agentId); + if (!assignments.skills.some((ref) => ref.pluginId === input.pluginId && ref.skillId === input.skillId)) { + assignments.skills.push({ pluginId: input.pluginId, skillId: input.skillId }); + } + return structuredClone(this.settingsFor(input.projectId)); + } + + async unassignAgentSkill(input: MutateAgentPluginSkillInput): Promise { + const assignments = this.assignmentsFor(input.projectId, input.agentId); + assignments.skills = assignments.skills.filter( + (ref) => ref.pluginId !== input.pluginId || ref.skillId !== input.skillId, + ); + return structuredClone(this.settingsFor(input.projectId)); + } + + async assignAgentTool(input: MutateAgentPluginToolInput): Promise { + const assignments = this.assignmentsFor(input.projectId, input.agentId); + if (!assignments.tools.some((ref) => + ref.pluginId === input.pluginId && ref.serverId === input.serverId && ref.toolName === input.toolName + )) { + assignments.tools.push({ pluginId: input.pluginId, serverId: input.serverId, toolName: input.toolName }); + } + return structuredClone(this.settingsFor(input.projectId)); + } + + async unassignAgentTool(input: MutateAgentPluginToolInput): Promise { + const assignments = this.assignmentsFor(input.projectId, input.agentId); + assignments.tools = assignments.tools.filter((ref) => + ref.pluginId !== input.pluginId || ref.serverId !== input.serverId || ref.toolName !== input.toolName + ); + return structuredClone(this.settingsFor(input.projectId)); + } + async openPluginsFolder(_pluginId?: string): Promise { // No filesystem in the mock — no-op. } diff --git a/frontend/src/adapters/plugin.test.ts b/frontend/src/adapters/plugin.test.ts index f215873f..fdf0e3f2 100644 --- a/frontend/src/adapters/plugin.test.ts +++ b/frontend/src/adapters/plugin.test.ts @@ -47,4 +47,39 @@ describe("TauriPluginGateway invoke payloads", () => { expect(review.issues).toEqual([]); expect(review.installable).toBe(true); }); + + it("uses the project-plugin command envelopes", async () => { + const gateway = new TauriPluginGateway(); + await gateway.listProjectSettings("project-1"); + expect(invoke).toHaveBeenLastCalledWith("plugin_list_project_settings", { + projectId: "project-1", + }); + + const projectInput = { projectId: "project-1", pluginId: "dev.acme.unity", enabled: true }; + await gateway.setProjectEnabled(projectInput); + expect(invoke).toHaveBeenLastCalledWith("plugin_set_project_enabled", { input: projectInput }); + + const skillInput = { + projectId: "project-1", + agentId: "agent-1", + pluginId: "dev.acme.unity", + skillId: "unity.debug", + }; + await gateway.assignAgentSkill(skillInput); + expect(invoke).toHaveBeenLastCalledWith("plugin_assign_agent_skill", { input: skillInput }); + await gateway.unassignAgentSkill(skillInput); + expect(invoke).toHaveBeenLastCalledWith("plugin_unassign_agent_skill", { input: skillInput }); + + const toolInput = { + projectId: "project-1", + agentId: "agent-1", + pluginId: "dev.acme.unity", + serverId: "unity-tools", + toolName: "unity_build", + }; + await gateway.assignAgentTool(toolInput); + expect(invoke).toHaveBeenLastCalledWith("plugin_assign_agent_tool", { input: toolInput }); + await gateway.unassignAgentTool(toolInput); + expect(invoke).toHaveBeenLastCalledWith("plugin_unassign_agent_tool", { input: toolInput }); + }); }); diff --git a/frontend/src/adapters/plugin.ts b/frontend/src/adapters/plugin.ts index af577f25..0bb27cdb 100644 --- a/frontend/src/adapters/plugin.ts +++ b/frontend/src/adapters/plugin.ts @@ -17,9 +17,17 @@ import type { PluginInstallResult, PluginReview, PluginRuntimeContributionCatalog, + ProjectPluginSettings, + ProjectPluginSettingsView, PluginUninstallResult, } from "@/domain"; -import type { PluginGateway, ReviewPluginPackageInput } from "@/ports"; +import type { + MutateAgentPluginSkillInput, + MutateAgentPluginToolInput, + PluginGateway, + ReviewPluginPackageInput, + SetProjectPluginEnabledInput, +} from "@/ports"; type TauriPluginReviewDto = Omit & { issues?: PluginReview["issues"]; @@ -66,6 +74,30 @@ export class TauriPluginGateway implements PluginGateway { return invoke("plugin_list_runtime_contributions"); } + listProjectSettings(projectId: string): Promise { + return invoke("plugin_list_project_settings", { projectId }); + } + + setProjectEnabled(input: SetProjectPluginEnabledInput): Promise { + return invoke("plugin_set_project_enabled", { input }); + } + + assignAgentSkill(input: MutateAgentPluginSkillInput): Promise { + return invoke("plugin_assign_agent_skill", { input }); + } + + unassignAgentSkill(input: MutateAgentPluginSkillInput): Promise { + return invoke("plugin_unassign_agent_skill", { input }); + } + + assignAgentTool(input: MutateAgentPluginToolInput): Promise { + return invoke("plugin_assign_agent_tool", { input }); + } + + unassignAgentTool(input: MutateAgentPluginToolInput): Promise { + return invoke("plugin_unassign_agent_tool", { input }); + } + async openPluginsFolder(pluginId?: string): Promise { await invoke("plugin_open_plugins_folder", { pluginId: pluginId ?? null }); } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 0f702d3d..a4f61a0b 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -1856,6 +1856,8 @@ export type PluginLifecycleState = /** Counts of what a plugin declares, shown in the admin list (carnet §5). */ export interface PluginContributionSummary { + /** Agent skills shipped by the plugin. Absent on older backend DTOs. */ + skills?: number; topLevelMenus: number; menuItems: number; layouts: number; @@ -1917,6 +1919,8 @@ export interface PluginUninstallResult { /** Contribution declarations for one plugin, as consumed by the runtime loader. */ export interface PluginContributionDto { + /** Read-only, package-relative skills exposed to project agents. */ + skills?: PluginSkillContribution[]; menus: PluginTopLevelMenuContribution[]; menuItems: PluginMenuItemContribution[]; slashCommands?: PluginSlashCommandContribution[]; @@ -1924,6 +1928,66 @@ export interface PluginContributionDto { mcpServers: PluginMcpServerSummary[]; } +/** One agent skill declared by an IdeA plugin manifest. */ +export interface PluginSkillContribution { + id: string; + name: string; + description: string; + kind: SkillKind; + path: string; +} + +/** Validated plugin manifest returned by the project-plugin settings catalogue. */ +export interface ProjectPluginManifest { + ideaPluginManifestVersion: number; + id: string; + displayName: string; + publisher?: string; + version: string; + description?: string; + engineIdea?: string; + main: string; + icon?: string; + trustLevel: "full"; + capabilities: Array<"ui" | "mcp" | "tooling">; + activationScope: "app" | "project"; + contributes: PluginContributionDto; +} + +export interface ProjectPluginActivation { + pluginId: string; + enabled: boolean; +} + +export interface PluginSkillRef { + pluginId: string; + skillId: string; +} + +export interface PluginToolRef { + pluginId: string; + serverId: string; + toolName: string; +} + +export interface AgentPluginAssignments { + agentId: string; + skills: PluginSkillRef[]; + tools: PluginToolRef[]; +} + +export interface ProjectPluginSettings { + version: number; + plugins: ProjectPluginActivation[]; + agents: AgentPluginAssignments[]; +} + +/** Project settings plus the currently installed global plugin manifests. */ +export interface ProjectPluginSettingsView { + settings: ProjectPluginSettings; + installed: ProjectPluginManifest[]; +} + /** One entry of `plugin_list_runtime_contributions` (mirror of `PluginRuntimePluginDto`). */ export interface PluginRuntimePlugin { id: string; @@ -2266,5 +2330,11 @@ export interface CustomPluginLayoutCell { export interface PluginMcpServerSummary { id: string; displayName: string; + command?: string; + args?: string[]; + env?: Array<[string, string]>; + cwd?: string; + transport?: "stdio"; autoStart?: boolean; + allowAbsoluteCommand?: boolean; } diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index 7a761598..c0738fa5 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -19,6 +19,7 @@ import { useEffect, useState } from "react"; import { Button, Input, Panel, SmallDropdown, Spinner, cn } from "@/shared"; import { TerminalView } from "@/features/terminals/TerminalView"; import { AnnouncementsPreview } from "@/features/announcements"; +import { AgentPluginCapabilities } from "@/features/plugins/AgentPluginCapabilities"; import { useDrift } from "@/features/templates/useDrift"; import { templateProfileLabel } from "@/features/templates/templatePresentation"; import { useGateways } from "@/app/di"; @@ -737,6 +738,16 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { )} + {selectedAgent && ( +
+ +
+ )} + {/* ── Context editor ── */} {selectedAgent && (
diff --git a/frontend/src/features/plugins/AgentPluginCapabilities.tsx b/frontend/src/features/plugins/AgentPluginCapabilities.tsx new file mode 100644 index 00000000..cdd20585 --- /dev/null +++ b/frontend/src/features/plugins/AgentPluginCapabilities.tsx @@ -0,0 +1,148 @@ +import { useMemo, useState } from "react"; + +import { Button, Input, Panel, SmallDropdown, Spinner } from "@/shared"; +import type { PluginSkillRef, PluginToolRef } from "@/domain"; +import { useProjectPlugins } from "./useProjectPlugins"; + +interface AgentPluginCapabilitiesProps { + projectId: string; + agentId: string; + agentName: string; +} + +function skillKey(ref: PluginSkillRef): string { + return `${ref.pluginId}:${ref.skillId}`; +} + +function toolKey(ref: PluginToolRef): string { + return `${ref.pluginId}:${ref.serverId}:${ref.toolName}`; +} + +export function AgentPluginCapabilities({ + projectId, + agentId, + agentName, +}: AgentPluginCapabilitiesProps) { + const vm = useProjectPlugins(projectId); + const [serverChoice, setServerChoice] = useState(""); + const [toolName, setToolName] = useState(""); + const assignments = vm.settings?.agents.find((entry) => entry.agentId === agentId); + const assignedSkills = new Set((assignments?.skills ?? []).map(skillKey)); + const assignedTools = assignments?.tools ?? []; + const enabledIds = new Set( + (vm.settings?.plugins ?? []).filter((entry) => entry.enabled).map((entry) => entry.pluginId), + ); + const enabledPlugins = vm.installed.filter((plugin) => enabledIds.has(plugin.id)); + const serverOptions = useMemo( + () => enabledPlugins.flatMap((plugin) => + (plugin.contributes.mcpServers ?? []).map((server) => ({ + value: `${plugin.id}\u0000${server.id}`, + label: `${plugin.displayName} — ${server.displayName}`, + })), + ), + [enabledPlugins], + ); + + const knownSkillKeys = new Set(vm.installed.flatMap((plugin) => + (plugin.contributes.skills ?? []).map((skill) => `${plugin.id}:${skill.id}`), + )); + const knownServerKeys = new Set(vm.installed.flatMap((plugin) => + (plugin.contributes.mcpServers ?? []).map((server) => `${plugin.id}:${server.id}`), + )); + const staleSkills = (assignments?.skills ?? []).filter((ref) => !knownSkillKeys.has(skillKey(ref))); + const staleTools = assignedTools.filter( + (ref) => !knownServerKeys.has(`${ref.pluginId}:${ref.serverId}`), + ); + + async function assignTool() { + const [pluginId, serverId] = serverChoice.split("\u0000"); + const name = toolName.trim(); + if (!pluginId || !serverId || !name) return; + await vm.setToolAssigned({ agentId, pluginId, serverId, toolName: name }, true); + setToolName(""); + } + + if (vm.loading && !vm.settings) { + return
Chargement des capabilities de plugins…
; + } + + return ( +
+ {vm.error &&

{vm.error}

} + +
+

Skills de plugins

+ {enabledPlugins.every((plugin) => (plugin.contributes.skills ?? []).length === 0) ? ( +

Aucun skill fourni par les plugins activés pour ce projet.

+ ) : ( + enabledPlugins.map((plugin) => { + const skills = plugin.contributes.skills ?? []; + if (skills.length === 0) return null; + return ( + +
{plugin.displayName}
+ {skills.map((skill) => { + const assigned = assignedSkills.has(`${plugin.id}:${skill.id}`); + return ( + + ); + })} +
+ ); + }) + )} + {staleSkills.map((ref) => ( +
+ {ref.pluginId} · {ref.skillId} — Obsolète ou manquant + +
+ ))} +
+ +
+

Tools MCP de plugins

+ {assignedTools.length === 0 ? ( +

Aucun tool MCP de plugin attribué à l’agent.

+ ) : ( +
    + {assignedTools.map((ref) => { + const stale = staleTools.some((candidate) => toolKey(candidate) === toolKey(ref)); + return ( +
  • + {ref.pluginId} · {ref.serverId} · {ref.toolName}{stale && — Obsolète ou manquant} + +
  • + ); + })} +
+ )} + + {serverOptions.length > 0 && ( +
+ + setToolName(event.target.value)} placeholder="Nom du tool" disabled={vm.busy} /> + +
+ )} +
+
+ ); +} diff --git a/frontend/src/features/plugins/PluginsPanel.tsx b/frontend/src/features/plugins/PluginsPanel.tsx index 0026b735..57d98f2b 100644 --- a/frontend/src/features/plugins/PluginsPanel.tsx +++ b/frontend/src/features/plugins/PluginsPanel.tsx @@ -15,6 +15,7 @@ import { Button, Panel } from "@/shared"; import { PluginConfirmDialog as ConfirmDialog } from "./PluginConfirmDialog"; import { usePluginRuntime } from "./PluginRuntimeProvider"; import { usePlugins } from "./usePlugins"; +import { ProjectPluginsPanel } from "./ProjectPluginsPanel"; const STATE_LABEL: Record = { enabled: "Activé", @@ -35,7 +36,11 @@ interface InstallFlow { review: PluginReview; } -export function PluginsPanel() { +export interface PluginsPanelProps { + projectId?: string; +} + +export function PluginsPanel({ projectId }: PluginsPanelProps = {}) { const vm = usePlugins(); const pluginRuntime = usePluginRuntime(); const [pendingAction, setPendingAction] = useState(null); @@ -87,8 +92,19 @@ export function PluginsPanel() { return (
+ {projectId ? ( + + ) : ( + +

+ Ouvrez un projet pour configurer les plugins utilisés par ce projet. +

+
+ )} + +
-

Plugins

+

Installation globale

+
{(vm.error || installFlowError) && (

diff --git a/frontend/src/features/plugins/ProjectPluginSettings.test.tsx b/frontend/src/features/plugins/ProjectPluginSettings.test.tsx new file mode 100644 index 00000000..41b4f58e --- /dev/null +++ b/frontend/src/features/plugins/ProjectPluginSettings.test.tsx @@ -0,0 +1,105 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { DIProvider } from "@/app/di"; +import { createMockGateways, MockPluginGateway } from "@/adapters/mock"; +import type { ProjectPluginManifest } from "@/domain"; +import { AgentPluginCapabilities } from "./AgentPluginCapabilities"; +import { ProjectPluginsPanel } from "./ProjectPluginsPanel"; + +function manifest(): ProjectPluginManifest { + return { + ideaPluginManifestVersion: 1, + id: "dev.acme.unity", + displayName: "Unity Plugin", + publisher: "Acme", + version: "1.2.3", + main: "dist/index.js", + trustLevel: "full", + capabilities: ["ui", "mcp"], + activationScope: "app", + contributes: { + skills: [{ + id: "unity.debug", + name: "unity-debug", + description: "Diagnostiquer les builds Unity.", + kind: "workflow", + path: "skills/unity-debug/SKILL.md", + }], + menus: [], + menuItems: [], + layouts: [], + mcpServers: [{ id: "unity-tools", displayName: "Unity Tools", transport: "stdio" }], + }, + }; +} + +function gatewaysWithPlugin() { + const plugin = new MockPluginGateway(); + plugin._seedProjectPluginManifest(manifest()); + return { ...createMockGateways(), plugin }; +} + +describe("project plugin settings #282", () => { + it("lists globally installed plugins, details their capabilities and toggles project activation", async () => { + const gateways = gatewaysWithPlugin(); + render( + + + , + ); + + expect(await screen.findByText("Unity Plugin")).toBeTruthy(); + expect(screen.getByText("Installé globalement")).toBeTruthy(); + fireEvent.click(screen.getByText("Voir les capabilities")); + expect(screen.getByText("unity-debug")).toBeTruthy(); + expect(screen.getByText("Unity Tools")).toBeTruthy(); + + const toggle = screen.getByRole("checkbox", { name: "Activé pour ce projet" }); + fireEvent.click(toggle); + await waitFor(() => expect((toggle as HTMLInputElement).checked).toBe(true)); + expect((await gateways.plugin.listProjectSettings("project-1")).settings.plugins).toEqual([ + { pluginId: "dev.acme.unity", enabled: true }, + ]); + }); + + it("assigns plugin skills and MCP tools to an agent and keeps stale references removable", async () => { + const gateways = gatewaysWithPlugin(); + gateways.plugin._seedProjectPluginSettings("project-1", { + version: 1, + plugins: [{ pluginId: "dev.acme.unity", enabled: true }], + agents: [{ + agentId: "agent-1", + skills: [{ pluginId: "missing.plugin", skillId: "removed-skill" }], + tools: [], + }], + }); + render( + + + , + ); + + const skill = await screen.findByRole("checkbox", { name: /unity-debug/ }); + fireEvent.click(skill); + await waitFor(() => expect((skill as HTMLInputElement).checked).toBe(true)); + expect(screen.getByText(/removed-skill/).textContent).toContain("Obsolète ou manquant"); + + fireEvent.click(screen.getByLabelText("serveur MCP de plugin")); + fireEvent.click(screen.getByRole("option", { name: "Unity Plugin — Unity Tools" })); + fireEvent.change(screen.getByLabelText("nom du tool MCP"), { + target: { value: "unity_build" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Attribuer" })); + expect(await screen.findByText("unity_build")).toBeTruthy(); + + const settings = (await gateways.plugin.listProjectSettings("project-1")).settings; + const agent = settings.agents.find((entry) => entry.agentId === "agent-1"); + expect(agent?.skills).toContainEqual({ pluginId: "dev.acme.unity", skillId: "unity.debug" }); + expect(agent?.tools).toContainEqual({ + pluginId: "dev.acme.unity", + serverId: "unity-tools", + toolName: "unity_build", + }); + }); +}); diff --git a/frontend/src/features/plugins/ProjectPluginsPanel.tsx b/frontend/src/features/plugins/ProjectPluginsPanel.tsx new file mode 100644 index 00000000..1096af07 --- /dev/null +++ b/frontend/src/features/plugins/ProjectPluginsPanel.tsx @@ -0,0 +1,143 @@ +import { Panel, Spinner } from "@/shared"; +import { useProjectPlugins } from "./useProjectPlugins"; + +export interface ProjectPluginsPanelProps { + projectId: string; +} + +function countLabel(count: number, singular: string, plural: string): string { + return `${count} ${count === 1 ? singular : plural}`; +} + +export function ProjectPluginsPanel({ projectId }: ProjectPluginsPanelProps) { + const vm = useProjectPlugins(projectId); + + if (vm.loading && !vm.settings) { + return ( + + + Chargement des plugins du projet… + + ); + } + + const installedIds = new Set(vm.installed.map((plugin) => plugin.id)); + const missing = (vm.settings?.plugins ?? []).filter( + (activation) => !installedIds.has(activation.pluginId), + ); + + return ( +

+
+

Plugins du projet

+

+ Un plugin reste installé globalement. Activez-le ici avant d’attribuer ses + capabilities aux agents de ce projet. +

+
+ + {vm.error &&

{vm.error}

} + + {vm.installed.length === 0 && missing.length === 0 ? ( +

Aucun plugin installé globalement.

+ ) : ( +
    + {vm.installed.map((plugin) => { + const enabled = vm.settings?.plugins.some( + (entry) => entry.pluginId === plugin.id && entry.enabled, + ) ?? false; + const skills = plugin.contributes.skills ?? []; + const servers = plugin.contributes.mcpServers ?? []; + return ( +
  • + +
    +
    +
    + {plugin.displayName} + v{plugin.version} + + Installé globalement + +
    +

    + {countLabel(skills.length, "skill", "skills")} · {countLabel(servers.length, "serveur MCP", "serveurs MCP")} +

    +
    + +
    + +
    + + Voir les capabilities + +
    +
    +

    Skills fournis

    + {skills.length === 0 ? ( +

    Aucun skill fourni.

    + ) : ( +
      + {skills.map((skill) => ( +
    • + {skill.name} + {skill.description ? ` — ${skill.description}` : ""} +
    • + ))} +
    + )} +
    +
    +

    Tools MCP fournis

    + {servers.length === 0 ? ( +

    Aucun serveur MCP fourni.

    + ) : ( +
      + {servers.map((server) => ( +
    • + {server.displayName} + · {server.id} +
    • + ))} +
    + )} +
    +
    +
    +
    +
  • + ); + })} + + {missing.map((activation) => ( +
  • + +
    + {activation.pluginId} +

    Plugin manquant · configuration conservée

    +
    + +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/features/plugins/index.ts b/frontend/src/features/plugins/index.ts index b54b3690..9761c798 100644 --- a/frontend/src/features/plugins/index.ts +++ b/frontend/src/features/plugins/index.ts @@ -1,5 +1,7 @@ export { PluginRuntimeProvider, usePluginRuntime, type PluginRuntimeContextValue } from "./PluginRuntimeProvider"; export { PluginsPanel } from "./PluginsPanel"; +export { ProjectPluginsPanel } from "./ProjectPluginsPanel"; +export { AgentPluginCapabilities } from "./AgentPluginCapabilities"; export { usePlugins, type PluginsViewModel } from "./usePlugins"; export { resolveMenuItems, resolveTopLevelMenus, type ResolvedTopLevelMenu } from "./menus"; export { usePluginMenus, type UsePluginMenusResult } from "./usePluginMenus"; diff --git a/frontend/src/features/plugins/useProjectPlugins.ts b/frontend/src/features/plugins/useProjectPlugins.ts new file mode 100644 index 00000000..a3040a30 --- /dev/null +++ b/frontend/src/features/plugins/useProjectPlugins.ts @@ -0,0 +1,105 @@ +import { useCallback, useEffect, useState } from "react"; + +import { useGateways } from "@/app/di"; +import type { + GatewayError, + ProjectPluginManifest, + ProjectPluginSettings, +} from "@/domain"; +import type { MutateAgentPluginToolInput } from "@/ports"; + +function describe(error: unknown): string { + if (error && typeof error === "object" && "message" in error) { + return String((error as GatewayError).message); + } + return String(error); +} + +export interface ProjectPluginsViewModel { + installed: ProjectPluginManifest[]; + settings: ProjectPluginSettings | null; + loading: boolean; + busy: boolean; + error: string | null; + refresh(): Promise; + setEnabled(pluginId: string, enabled: boolean): Promise; + setSkillAssigned(agentId: string, pluginId: string, skillId: string, assigned: boolean): Promise; + setToolAssigned(input: Omit, assigned: boolean): Promise; +} + +export function useProjectPlugins(projectId: string): ProjectPluginsViewModel { + const { plugin } = useGateways(); + const [installed, setInstalled] = useState([]); + const [settings, setSettings] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const view = await plugin.listProjectSettings(projectId); + setInstalled(view.installed); + setSettings(view.settings); + } catch (cause) { + setError(describe(cause)); + } finally { + setLoading(false); + } + }, [plugin, projectId]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const mutate = useCallback(async (operation: () => Promise) => { + setBusy(true); + setError(null); + try { + setSettings(await operation()); + } catch (cause) { + setError(describe(cause)); + } finally { + setBusy(false); + } + }, []); + + const setEnabled = useCallback( + (pluginId: string, enabled: boolean) => + mutate(() => plugin.setProjectEnabled({ projectId, pluginId, enabled })), + [mutate, plugin, projectId], + ); + + const setSkillAssigned = useCallback( + (agentId: string, pluginId: string, skillId: string, assigned: boolean) => { + const input = { projectId, agentId, pluginId, skillId }; + return mutate(() => + assigned ? plugin.assignAgentSkill(input) : plugin.unassignAgentSkill(input), + ); + }, + [mutate, plugin, projectId], + ); + + const setToolAssigned = useCallback( + (input: Omit, assigned: boolean) => { + const scoped = { projectId, ...input }; + return mutate(() => + assigned ? plugin.assignAgentTool(scoped) : plugin.unassignAgentTool(scoped), + ); + }, + [mutate, plugin, projectId], + ); + + return { + installed, + settings, + loading, + busy, + error, + refresh, + setEnabled, + setSkillAssigned, + setToolAssigned, + }; +} diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index 992fb02f..68e5aa05 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -908,6 +908,7 @@ export function ProjectsView() { // while the menu bar above stays visible. setSettingsSection(null)} /> diff --git a/frontend/src/features/settings/SettingsView.tsx b/frontend/src/features/settings/SettingsView.tsx index 97255b83..f87e7667 100644 --- a/frontend/src/features/settings/SettingsView.tsx +++ b/frontend/src/features/settings/SettingsView.tsx @@ -48,12 +48,14 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [ interface SettingsViewProps { section: SettingsSection; + projectId?: string; onSectionChange: (section: SettingsSection) => void; onClose: () => void; } export function SettingsView({ section, + projectId, onSectionChange, onClose, }: SettingsViewProps) { @@ -97,7 +99,7 @@ export function SettingsView({ ) : section === "deployment" ? ( ) : section === "plugins" ? ( - + ) : ( // No `onSessionEnded`: the desktop app hosts the server and is never // itself a paired device, so it cannot revoke its own session. diff --git a/frontend/src/plugins/runtime/loader.ts b/frontend/src/plugins/runtime/loader.ts index 2914071f..21767ab4 100644 --- a/frontend/src/plugins/runtime/loader.ts +++ b/frontend/src/plugins/runtime/loader.ts @@ -218,6 +218,7 @@ function layoutTypesFromContributes(contributes: PluginContributionDto): Set; return { + skills: arrayOrEmpty(contributes?.skills), menus: arrayOrEmpty(contributes?.menus), menuItems: arrayOrEmpty(contributes?.menuItems), slashCommands: arrayOrEmpty(contributes?.slashCommands), diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 59035eb8..cddc4b47 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -59,6 +59,8 @@ import type { PluginProjectStructure, PluginReview, PluginRuntimeContributionCatalog, + ProjectPluginSettings, + ProjectPluginSettingsView, PluginSourceKind, PluginUninstallResult, PluginWorkspaceBinaryFile, @@ -1549,6 +1551,27 @@ export interface ReviewPluginPackageInput { path: string; } +export interface SetProjectPluginEnabledInput { + projectId: string; + pluginId: string; + enabled: boolean; +} + +export interface MutateAgentPluginSkillInput { + projectId: string; + agentId: string; + pluginId: string; + skillId: string; +} + +export interface MutateAgentPluginToolInput { + projectId: string; + agentId: string; + pluginId: string; + serverId: string; + toolName: string; +} + /** * Plugin system gateway (ticket #43, F1) — admin CRUD + the bootstrap catalog * the runtime loader consumes. Mirrors the Tauri commands in carnet §5 @@ -1562,6 +1585,12 @@ export interface PluginGateway { setEnabled(pluginId: string, enabled: boolean): Promise; uninstall(pluginId: string): Promise; listRuntimeContributions(): Promise; + listProjectSettings(projectId: string): Promise; + setProjectEnabled(input: SetProjectPluginEnabledInput): Promise; + assignAgentSkill(input: MutateAgentPluginSkillInput): Promise; + unassignAgentSkill(input: MutateAgentPluginSkillInput): Promise; + assignAgentTool(input: MutateAgentPluginToolInput): Promise; + unassignAgentTool(input: MutateAgentPluginToolInput): Promise; openPluginsFolder(pluginId?: string): Promise; } diff --git a/sdk/IdeaSDK b/sdk/IdeaSDK index 1239104b..6eb8db37 160000 --- a/sdk/IdeaSDK +++ b/sdk/IdeaSDK @@ -1 +1 @@ -Subproject commit 1239104b0f3985e95ddd338b150fd1f66a3c84d4 +Subproject commit 6eb8db3791b4377dd0ede89410fc540835c5d4d1