feat(plugins): paramètres projet plugins + attribution skills/tools aux agents — #282 (manifeste accepte contributes.skills IdeA-native multi-harness; store .ideai/project-plugins.json v1: enable plugin par projet + assignments skills/tools par agent; refs + use cases settings/assign/unassign; resolver skills pour idea_skill_read et injection catalogue lancement agent; permissions MCP dynamiques projetées par harness Claude Code/Codex/OpenCode; tombstones uninstall, hot reload recalcule catalogue et marque assignments cassés; frontend: Settings projet Plugins, détail capabilities, attribution agent, catalogue permissions; bump pointeur IdeaSDK 6eb8db3; QA verte: cargo test -p application, project_plugin_settings 3/3, dto_plugins 2/2, structured_launch_d3 26/26, catalogue mcp 7/7, frontend 5 tests ProjectPluginSettings/plugin, npm build frontend)

This commit is contained in:
2026-09-08 15:48:57 +02:00
parent bdc20a134a
commit 86ed0aa00b
38 changed files with 2127 additions and 89 deletions

View File

@ -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,

View File

@ -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<domain::Project, ErrorDto> {
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<ProjectPluginSettingsViewDto, ErrorDto> {
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<ProjectPluginSettingsDto, ErrorDto> {
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<ProjectPluginSettingsDto, ErrorDto> {
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<ProjectPluginSettingsDto, ErrorDto> {
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<ProjectPluginSettingsDto, ErrorDto> {
mutate_project_skill(input, AssignmentMutation::Unassign, &state).await
}
async fn mutate_project_tool(
input: MutateAgentPluginToolRequestDto,
mutation: AssignmentMutation,
state: &AppState,
) -> Result<ProjectPluginSettingsDto, ErrorDto> {
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<ProjectPluginSettingsDto, ErrorDto> {
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<ProjectPluginSettingsDto, ErrorDto> {
mutate_project_tool(input, AssignmentMutation::Unassign, &state).await
}
/// Reviews a local plugin package without committing it.
#[tauri::command]
pub async fn plugin_review_package(

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,439 @@
//! Project-scoped plugin activation and per-agent capability assignments.
use std::sync::Arc;
use domain::{
AgentContextStore, AgentId, AgentPluginAssignments, MarkdownDoc, PluginId, PluginManifest,
PluginManifestValidator, PluginMcpServerId, PluginPackageStore, PluginRegistryStore,
PluginSkillId, PluginSkillRef, PluginStoreError, PluginToolRef, Project,
ProjectPluginActivation, ProjectPluginSettings, ProjectPluginSettingsStore,
};
use serde::{Deserialize, Serialize};
use crate::AppError;
fn package_error(error: PluginStoreError) -> AppError {
match error {
PluginStoreError::NotFound => AppError::NotFound("plugin package".to_owned()),
PluginStoreError::Invalid(message) => AppError::Invalid(message),
other => AppError::Store(other.to_string()),
}
}
/// Shared resolver for installed, runtime-active plugin manifests.
pub struct ProjectPluginResolver {
settings: Arc<dyn ProjectPluginSettingsStore>,
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
}
impl ProjectPluginResolver {
/// Builds the resolver from replaceable ports.
#[must_use]
pub fn new(
settings: Arc<dyn ProjectPluginSettingsStore>,
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
) -> Self {
Self {
settings,
packages,
registry,
validator,
}
}
/// Loads the persisted project document.
pub async fn settings(
&self,
project_root: &domain::ProjectPath,
) -> Result<ProjectPluginSettings, AppError> {
self.settings
.load(project_root)
.await
.map_err(AppError::from)
}
/// Resolves one plugin only when enabled for the project and runtime-active
/// globally. Missing/uninstalled plugins yield `None`, preserving tombstones.
pub async fn enabled_manifest(
&self,
project_root: &domain::ProjectPath,
plugin_id: &PluginId,
) -> Result<Option<PluginManifest>, AppError> {
let settings = self.settings(project_root).await?;
if !settings.is_enabled(plugin_id) {
return Ok(None);
}
let registry = self
.registry
.load_registry()
.await
.map_err(|e| AppError::Store(e.to_string()))?;
let Some(entry) = registry.find(plugin_id) else {
return Ok(None);
};
if !entry.lifecycle_state.is_runtime_active() {
return Ok(None);
}
let package = self
.packages
.list_installed()
.await
.map_err(package_error)?
.into_iter()
.find(|p| p.plugin_id.as_ref() == Some(plugin_id));
let Some(package) = package else {
return Ok(None);
};
let bytes = self
.packages
.read_manifest(&package)
.await
.map_err(package_error)?;
self.validator
.validate(&bytes.bytes, &package)
.map(Some)
.map_err(|e| AppError::Invalid(e.to_string()))
}
/// Reads a contributed Markdown body through the confined package port.
pub async fn read_skill_body(
&self,
plugin_id: &PluginId,
path: &domain::RelativePath,
) -> Result<MarkdownDoc, AppError> {
let bytes = self
.packages
.read_installed_file(plugin_id, path)
.await
.map_err(package_error)?;
let body = String::from_utf8(bytes)
.map_err(|_| AppError::Invalid("plugin skill must be UTF-8 Markdown".to_owned()))?;
if body.trim().is_empty() {
return Err(AppError::Invalid(
"plugin skill body must not be empty".to_owned(),
));
}
Ok(MarkdownDoc::new(body))
}
}
/// Read model consumed by project settings surfaces.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectPluginSettingsView {
/// Persisted activation and assignment document.
pub settings: ProjectPluginSettings,
/// Installed plugin manifests. Runtime failures are omitted from this
/// catalogue but their settings remain in `settings` as tombstones.
pub installed: Vec<PluginManifest>,
}
/// Lists project plugin settings and the globally installed catalogue.
pub struct ListProjectPluginSettings {
resolver: Arc<ProjectPluginResolver>,
packages: Arc<dyn PluginPackageStore>,
validator: Arc<dyn PluginManifestValidator>,
}
impl ListProjectPluginSettings {
/// Builds the use case.
#[must_use]
pub fn new(
resolver: Arc<ProjectPluginResolver>,
packages: Arc<dyn PluginPackageStore>,
validator: Arc<dyn PluginManifestValidator>,
) -> Self {
Self {
resolver,
packages,
validator,
}
}
/// Executes the read.
pub async fn execute(&self, project: Project) -> Result<ProjectPluginSettingsView, AppError> {
let settings = self.resolver.settings(&project.root).await?;
let mut installed = Vec::new();
for package in self
.packages
.list_installed()
.await
.map_err(package_error)?
{
let bytes = self
.packages
.read_manifest(&package)
.await
.map_err(package_error)?;
if let Ok(manifest) = self.validator.validate(&bytes.bytes, &package) {
installed.push(manifest);
}
}
installed.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str()));
Ok(ProjectPluginSettingsView {
settings,
installed,
})
}
}
/// Toggles one globally installed plugin for a project.
pub struct SetProjectPluginEnabled {
store: Arc<dyn ProjectPluginSettingsStore>,
packages: Arc<dyn PluginPackageStore>,
}
impl SetProjectPluginEnabled {
/// Builds the use case.
#[must_use]
pub fn new(
store: Arc<dyn ProjectPluginSettingsStore>,
packages: Arc<dyn PluginPackageStore>,
) -> Self {
Self { store, packages }
}
/// Persists activation. Enabling requires a currently installed plugin;
/// disabling a missing plugin remains valid and keeps assignments intact.
pub async fn execute(
&self,
project: Project,
plugin_id: PluginId,
enabled: bool,
) -> Result<ProjectPluginSettings, AppError> {
if enabled {
let exists = self
.packages
.list_installed()
.await
.map_err(package_error)?
.iter()
.any(|p| p.plugin_id.as_ref() == Some(&plugin_id));
if !exists {
return Err(AppError::NotFound(format!("plugin {}", plugin_id.as_str())));
}
}
let mut settings = self.store.load(&project.root).await?;
match settings
.plugins
.iter_mut()
.find(|p| p.plugin_id == plugin_id)
{
Some(entry) => entry.enabled = enabled,
None => settings
.plugins
.push(ProjectPluginActivation { plugin_id, enabled }),
}
self.store.save(&project.root, &settings).await?;
Ok(settings)
}
}
/// Mutation kind shared by skill/tool assignment use cases.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssignmentMutation {
/// Add the capability when it is not already assigned.
Assign,
/// Remove the capability when it is currently assigned.
Unassign,
}
/// Assigns or unassigns one plugin skill while retaining dangling tombstones.
pub struct MutateAgentPluginSkill {
store: Arc<dyn ProjectPluginSettingsStore>,
contexts: Arc<dyn AgentContextStore>,
resolver: Arc<ProjectPluginResolver>,
}
impl MutateAgentPluginSkill {
/// Builds the use case.
#[must_use]
pub fn new(
store: Arc<dyn ProjectPluginSettingsStore>,
contexts: Arc<dyn AgentContextStore>,
resolver: Arc<ProjectPluginResolver>,
) -> Self {
Self {
store,
contexts,
resolver,
}
}
/// Applies an idempotent mutation.
pub async fn execute(
&self,
project: Project,
agent_id: AgentId,
reference: PluginSkillRef,
mutation: AssignmentMutation,
) -> Result<ProjectPluginSettings, AppError> {
ensure_agent(&*self.contexts, &project, agent_id).await?;
if mutation == AssignmentMutation::Assign {
let manifest = self
.resolver
.enabled_manifest(&project.root, &reference.plugin_id)
.await?
.ok_or_else(|| {
AppError::Invalid(
"plugin is not enabled and runtime-active for this project".to_owned(),
)
})?;
if !manifest
.contributes
.skills
.iter()
.any(|s| s.id == reference.skill_id)
{
return Err(AppError::NotFound(format!(
"plugin skill {}",
reference.skill_id.as_str()
)));
}
}
let mut settings = self.store.load(&project.root).await?;
let assignments = assignments_mut(&mut settings, agent_id);
match mutation {
AssignmentMutation::Assign if !assignments.skills.contains(&reference) => {
assignments.skills.push(reference)
}
AssignmentMutation::Unassign => assignments.skills.retain(|r| r != &reference),
_ => {}
}
self.store.save(&project.root, &settings).await?;
Ok(settings)
}
}
/// Assigns or unassigns one namespaced external MCP tool.
pub struct MutateAgentPluginTool {
store: Arc<dyn ProjectPluginSettingsStore>,
contexts: Arc<dyn AgentContextStore>,
resolver: Arc<ProjectPluginResolver>,
}
impl MutateAgentPluginTool {
/// Builds the use case.
#[must_use]
pub fn new(
store: Arc<dyn ProjectPluginSettingsStore>,
contexts: Arc<dyn AgentContextStore>,
resolver: Arc<ProjectPluginResolver>,
) -> Self {
Self {
store,
contexts,
resolver,
}
}
/// Applies an idempotent mutation. Assignment validates the enabled server;
/// the dynamic tool name is validated when its MCP catalogue is available.
pub async fn execute(
&self,
project: Project,
agent_id: AgentId,
reference: PluginToolRef,
mutation: AssignmentMutation,
) -> Result<ProjectPluginSettings, AppError> {
ensure_agent(&*self.contexts, &project, agent_id).await?;
if reference.tool_name.trim().is_empty() {
return Err(AppError::Invalid("plugin tool name is required".to_owned()));
}
if mutation == AssignmentMutation::Assign {
let manifest = self
.resolver
.enabled_manifest(&project.root, &reference.plugin_id)
.await?
.ok_or_else(|| {
AppError::Invalid(
"plugin is not enabled and runtime-active for this project".to_owned(),
)
})?;
if !manifest
.contributes
.mcp_servers
.iter()
.any(|s| s.id == reference.server_id)
{
return Err(AppError::NotFound(format!(
"plugin MCP server {}",
reference.server_id.as_str()
)));
}
}
let mut settings = self.store.load(&project.root).await?;
let assignments = assignments_mut(&mut settings, agent_id);
match mutation {
AssignmentMutation::Assign if !assignments.tools.contains(&reference) => {
assignments.tools.push(reference)
}
AssignmentMutation::Unassign => assignments.tools.retain(|r| r != &reference),
_ => {}
}
self.store.save(&project.root, &settings).await?;
Ok(settings)
}
}
async fn ensure_agent(
store: &dyn AgentContextStore,
project: &Project,
agent_id: AgentId,
) -> Result<(), AppError> {
if store
.load_manifest(project)
.await?
.entries
.iter()
.any(|e| e.agent_id == agent_id)
{
Ok(())
} else {
Err(AppError::NotFound(format!("agent {agent_id}")))
}
}
fn assignments_mut(
settings: &mut ProjectPluginSettings,
agent_id: AgentId,
) -> &mut AgentPluginAssignments {
if let Some(index) = settings.agents.iter().position(|a| a.agent_id == agent_id) {
return &mut settings.agents[index];
}
settings.agents.push(AgentPluginAssignments {
agent_id,
skills: Vec::new(),
tools: Vec::new(),
});
settings
.agents
.last_mut()
.expect("assignment was just inserted")
}
/// Convenience constructors for driving adapters accepting string ids.
pub fn plugin_skill_ref(plugin_id: String, skill_id: String) -> Result<PluginSkillRef, AppError> {
Ok(PluginSkillRef {
plugin_id: PluginId::new(plugin_id).map_err(|e| AppError::Invalid(e.to_string()))?,
skill_id: PluginSkillId::new(skill_id).map_err(|e| AppError::Invalid(e.to_string()))?,
})
}
/// Convenience constructor for a namespaced plugin tool reference.
pub fn plugin_tool_ref(
plugin_id: String,
server_id: String,
tool_name: String,
) -> Result<PluginToolRef, AppError> {
Ok(PluginToolRef {
plugin_id: PluginId::new(plugin_id).map_err(|e| AppError::Invalid(e.to_string()))?,
server_id: PluginMcpServerId::new(server_id)
.map_err(|e| AppError::Invalid(e.to_string()))?,
tool_name,
})
}

View File

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

View File

@ -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}"
);
}

View File

@ -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<dyn ProviderSessionProvider>;
@ -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)
}

View File

@ -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<application::PluginContributionSummary> 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<application::PluginContributionSummary> 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<domain::ProjectPluginActivation>,
/// Per-agent plugin capability assignments.
pub agents: Vec<domain::AgentPluginAssignments>,
}
impl From<domain::ProjectPluginSettings> 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<domain::PluginManifest>,
}
impl From<application::ProjectPluginSettingsView> 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")]

View File

@ -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<dyn PluginRegistryStore>,
/// Manifest validator exposed for the Tauri asset protocol adapter.
pub plugin_manifest_validator: Arc<dyn PluginManifestValidator>,
/// Read project-scoped plugin activation and assignment settings.
pub list_project_plugin_settings: Arc<ListProjectPluginSettings>,
/// Enable or disable an installed plugin for one project.
pub set_project_plugin_enabled: Arc<SetProjectPluginEnabled>,
/// Assign or unassign a plugin skill for a project agent.
pub mutate_agent_plugin_skill: Arc<MutateAgentPluginSkill>,
/// Assign or unassign a plugin MCP tool for a project agent.
pub mutate_agent_plugin_tool: Arc<MutateAgentPluginTool>,
// --- Templates & sync (L7) ---
/// Create a template in the global store.
pub create_template: Arc<CreateTemplate>,
@ -1827,6 +1837,16 @@ impl BackendCore {
let plugin_storage_store = Arc::clone(&plugin_storage) as Arc<dyn PluginStorageStore>;
let plugin_manifest_validator =
Arc::clone(&plugin_validator) as Arc<dyn PluginManifestValidator>;
let project_plugin_settings =
Arc::new(FsProjectPluginSettingsStore::new(Arc::clone(&fs_port)));
let project_plugin_settings_store =
Arc::clone(&project_plugin_settings) as Arc<dyn ProjectPluginSettingsStore>;
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<dyn PluginMcpSupervisor>;
@ -2355,6 +2375,10 @@ impl BackendCore {
app_data_dir.to_string_lossy().into_owned(),
));
let skill_store_port = Arc::clone(&skill_store) as Arc<dyn SkillStore>;
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,

View File

@ -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,
};

View File

@ -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<AssignedPluginSkillSource>,
/// Display name shown to the model and accepted by `idea_skill_read`.
pub name: String,
/// One-line affordance description shown in the model context.

View File

@ -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<String>) -> Result<Self, PluginError> {
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<PluginSkillContribution>,
/// Top-level menus.
#[serde(default)]
pub menus: Vec<PluginTopLevelMenuContribution>,
@ -501,6 +539,94 @@ pub struct PluginContributionSet {
pub mcp_servers: Vec<PluginMcpServerContribution>,
}
/// 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<PluginSkillRef>,
/// Assigned external MCP tools.
#[serde(default)]
pub tools: Vec<PluginToolRef>,
}
/// 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<ProjectPluginActivation>,
/// Per-agent assignments. Dangling entries are intentional tombstones.
#[serde(default)]
pub agents: Vec<AgentPluginAssignments>,
}
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")]

View File

@ -446,6 +446,19 @@ pub trait PluginPackageStore: Send + Sync {
package: &PluginPackageRef,
) -> Result<PluginManifestBytes, PluginStoreError>;
/// 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<Vec<u8>, 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<crate::ProjectPluginSettings, StoreError>;
/// 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.

View File

@ -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")]

View File

@ -155,6 +155,21 @@ impl PluginPackageStore for FsPluginPackageStore {
Ok(PluginManifestBytes { bytes })
}
async fn read_installed_file(
&self,
plugin_id: &PluginId,
path: &RelativePath,
) -> Result<Vec<u8>, 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,

View File

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

View File

@ -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<dyn FileSystem>,
}
impl FsProjectPluginSettingsStore {
/// Builds the adapter.
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>) -> 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<ProjectPluginSettings, StoreError> {
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()))
}
}

View File

@ -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<dyn FileSystem> = 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"));
}

View File

@ -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<ProjectPluginSettingsView> {
return unsupportedOnWeb("Project plugin settings");
}
async setProjectEnabled(_input: SetProjectPluginEnabledInput): Promise<ProjectPluginSettings> {
return unsupportedOnWeb("Project plugin settings");
}
async assignAgentSkill(_input: MutateAgentPluginSkillInput): Promise<ProjectPluginSettings> {
return unsupportedOnWeb("Project plugin settings");
}
async unassignAgentSkill(_input: MutateAgentPluginSkillInput): Promise<ProjectPluginSettings> {
return unsupportedOnWeb("Project plugin settings");
}
async assignAgentTool(_input: MutateAgentPluginToolInput): Promise<ProjectPluginSettings> {
return unsupportedOnWeb("Project plugin settings");
}
async unassignAgentTool(_input: MutateAgentPluginToolInput): Promise<ProjectPluginSettings> {
return unsupportedOnWeb("Project plugin settings");
}
async openPluginsFolder(_pluginId?: string): Promise<void> {
return unsupportedOnWeb("Plugin management");
}

View File

@ -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<string, ProjectPluginSettings>();
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<PluginAdmin[]> {
return structuredClone(this.plugins);
}
@ -4099,6 +4125,68 @@ export class MockPluginGateway implements PluginGateway {
return { plugins: [] };
}
async listProjectSettings(projectId: string): Promise<ProjectPluginSettingsView> {
return {
settings: structuredClone(this.settingsFor(projectId)),
installed: structuredClone(this.projectManifests),
};
}
async setProjectEnabled(input: SetProjectPluginEnabledInput): Promise<ProjectPluginSettings> {
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<ProjectPluginSettings> {
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<ProjectPluginSettings> {
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<ProjectPluginSettings> {
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<ProjectPluginSettings> {
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<void> {
// No filesystem in the mock — no-op.
}

View File

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

View File

@ -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<PluginReview, "issues" | "installable"> & {
issues?: PluginReview["issues"];
@ -66,6 +74,30 @@ export class TauriPluginGateway implements PluginGateway {
return invoke<PluginRuntimeContributionCatalog>("plugin_list_runtime_contributions");
}
listProjectSettings(projectId: string): Promise<ProjectPluginSettingsView> {
return invoke<ProjectPluginSettingsView>("plugin_list_project_settings", { projectId });
}
setProjectEnabled(input: SetProjectPluginEnabledInput): Promise<ProjectPluginSettings> {
return invoke<ProjectPluginSettings>("plugin_set_project_enabled", { input });
}
assignAgentSkill(input: MutateAgentPluginSkillInput): Promise<ProjectPluginSettings> {
return invoke<ProjectPluginSettings>("plugin_assign_agent_skill", { input });
}
unassignAgentSkill(input: MutateAgentPluginSkillInput): Promise<ProjectPluginSettings> {
return invoke<ProjectPluginSettings>("plugin_unassign_agent_skill", { input });
}
assignAgentTool(input: MutateAgentPluginToolInput): Promise<ProjectPluginSettings> {
return invoke<ProjectPluginSettings>("plugin_assign_agent_tool", { input });
}
unassignAgentTool(input: MutateAgentPluginToolInput): Promise<ProjectPluginSettings> {
return invoke<ProjectPluginSettings>("plugin_unassign_agent_tool", { input });
}
async openPluginsFolder(pluginId?: string): Promise<void> {
await invoke("plugin_open_plugins_folder", { pluginId: pluginId ?? null });
}

View File

@ -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;
}

View File

@ -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) {
</div>
)}
{selectedAgent && (
<div className="border-t border-border p-4">
<AgentPluginCapabilities
projectId={projectId}
agentId={selectedAgent.id}
agentName={selectedAgent.name}
/>
</div>
)}
{/* ── Context editor ── */}
{selectedAgent && (
<div className="flex flex-col gap-2 border-t border-border p-4">

View File

@ -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 <div className="flex items-center gap-2 text-xs text-muted"><Spinner size={12} /> Chargement des capabilities de plugins…</div>;
}
return (
<div className="flex flex-col gap-4">
{vm.error && <p role="alert" className="text-xs text-danger">{vm.error}</p>}
<section aria-label={`Skills de plugins — ${agentName}`} className="flex flex-col gap-2">
<h4 className="text-sm font-semibold text-content">Skills de plugins</h4>
{enabledPlugins.every((plugin) => (plugin.contributes.skills ?? []).length === 0) ? (
<p className="text-xs text-muted">Aucun skill fourni par les plugins activés pour ce projet.</p>
) : (
enabledPlugins.map((plugin) => {
const skills = plugin.contributes.skills ?? [];
if (skills.length === 0) return null;
return (
<Panel key={plugin.id} className="flex flex-col gap-2">
<h5 className="text-xs font-semibold text-content">{plugin.displayName}</h5>
{skills.map((skill) => {
const assigned = assignedSkills.has(`${plugin.id}:${skill.id}`);
return (
<label key={skill.id} className="flex items-start gap-2 text-xs text-content">
<input
type="checkbox"
checked={assigned}
disabled={vm.busy}
onChange={(event) => void vm.setSkillAssigned(
agentId, plugin.id, skill.id, event.target.checked,
)}
/>
<span><span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}</span>
</label>
);
})}
</Panel>
);
})
)}
{staleSkills.map((ref) => (
<div key={skillKey(ref)} className="flex items-center justify-between gap-2 rounded border border-warning/40 px-3 py-2 text-xs">
<span>{ref.pluginId} · {ref.skillId} <span className="text-warning">— Obsolète ou manquant</span></span>
<Button size="sm" variant="ghost" disabled={vm.busy} onClick={() => void vm.setSkillAssigned(agentId, ref.pluginId, ref.skillId, false)}>Retirer</Button>
</div>
))}
</section>
<section aria-label={`Tools MCP de plugins — ${agentName}`} className="flex flex-col gap-2">
<h4 className="text-sm font-semibold text-content">Tools MCP de plugins</h4>
{assignedTools.length === 0 ? (
<p className="text-xs text-muted">Aucun tool MCP de plugin attribué à l’agent.</p>
) : (
<ul className="flex flex-col gap-1">
{assignedTools.map((ref) => {
const stale = staleTools.some((candidate) => toolKey(candidate) === toolKey(ref));
return (
<li key={toolKey(ref)} className="flex items-center justify-between gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
<span>{ref.pluginId} · {ref.serverId} · <span className="font-medium">{ref.toolName}</span>{stale && <span className="text-warning"> — Obsolète ou manquant</span>}</span>
<Button size="sm" variant="ghost" disabled={vm.busy} onClick={() => void vm.setToolAssigned({ agentId, ...ref }, false)}>Retirer</Button>
</li>
);
})}
</ul>
)}
{serverOptions.length > 0 && (
<div className="flex items-end gap-2">
<SmallDropdown
aria-label="serveur MCP de plugin"
value={serverChoice}
onChange={setServerChoice}
disabled={vm.busy}
className="min-w-0 flex-1"
size="md"
options={[{ value: "", label: "— choisir un serveur MCP —" }, ...serverOptions]}
/>
<Input aria-label="nom du tool MCP" value={toolName} onChange={(event) => setToolName(event.target.value)} placeholder="Nom du tool" disabled={vm.busy} />
<Button variant="primary" disabled={vm.busy || !serverChoice || !toolName.trim()} onClick={() => void assignTool()}>Attribuer</Button>
</div>
)}
</section>
</div>
);
}

View File

@ -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<PluginLifecycleState, string> = {
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<PendingAction | null>(null);
@ -87,8 +92,19 @@ export function PluginsPanel() {
return (
<div className="flex flex-col gap-4">
{projectId ? (
<ProjectPluginsPanel projectId={projectId} />
) : (
<Panel>
<p className="text-sm text-muted">
Ouvrez un projet pour configurer les plugins utilisés par ce projet.
</p>
</Panel>
)}
<div className="border-t border-border pt-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-content">Plugins</h2>
<h2 className="text-sm font-semibold text-content">Installation globale</h2>
<div className="flex gap-2">
<Button size="sm" onClick={() => void startInstallFromArchive()} disabled={vm.busy}>
Installer depuis une archive…
@ -103,6 +119,7 @@ export function PluginsPanel() {
</Button>
</div>
</div>
</div>
{(vm.error || installFlowError) && (
<p role="alert" className="text-sm text-danger">

View File

@ -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(
<DIProvider gateways={gateways}>
<ProjectPluginsPanel projectId="project-1" />
</DIProvider>,
);
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(
<DIProvider gateways={gateways}>
<AgentPluginCapabilities projectId="project-1" agentId="agent-1" agentName="Dev" />
</DIProvider>,
);
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",
});
});
});

View File

@ -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 (
<Panel aria-label="plugins du projet" className="flex items-center gap-2">
<Spinner size={14} />
<span className="text-sm text-muted">Chargement des plugins du projet…</span>
</Panel>
);
}
const installedIds = new Set(vm.installed.map((plugin) => plugin.id));
const missing = (vm.settings?.plugins ?? []).filter(
(activation) => !installedIds.has(activation.pluginId),
);
return (
<section aria-label="plugins du projet" className="flex flex-col gap-3">
<div>
<h2 className="text-sm font-semibold text-content">Plugins du projet</h2>
<p className="mt-1 text-xs text-muted">
Un plugin reste installé globalement. Activez-le ici avant d’attribuer ses
capabilities aux agents de ce projet.
</p>
</div>
{vm.error && <p role="alert" className="text-sm text-danger">{vm.error}</p>}
{vm.installed.length === 0 && missing.length === 0 ? (
<Panel><p className="text-sm text-muted">Aucun plugin installé globalement.</p></Panel>
) : (
<ul className="flex flex-col gap-2">
{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 (
<li key={plugin.id}>
<Panel className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-content">{plugin.displayName}</span>
<span className="text-xs text-faint">v{plugin.version}</span>
<span className="rounded bg-raised px-1.5 py-0.5 text-[0.65rem] text-muted">
Installé globalement
</span>
</div>
<p className="mt-1 text-xs text-muted">
{countLabel(skills.length, "skill", "skills")} · {countLabel(servers.length, "serveur MCP", "serveurs MCP")}
</p>
</div>
<label className="flex shrink-0 items-center gap-2 text-xs text-content">
<input
type="checkbox"
checked={enabled}
disabled={vm.busy}
onChange={(event) => void vm.setEnabled(plugin.id, event.target.checked)}
/>
Activé pour ce projet
</label>
</div>
<details>
<summary className="cursor-pointer text-xs font-medium text-primary">
Voir les capabilities
</summary>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<div>
<h3 className="text-xs font-semibold text-content">Skills fournis</h3>
{skills.length === 0 ? (
<p className="mt-1 text-xs text-muted">Aucun skill fourni.</p>
) : (
<ul className="mt-1 flex flex-col gap-1">
{skills.map((skill) => (
<li key={skill.id} className="text-xs text-muted">
<span className="font-medium text-content">{skill.name}</span>
{skill.description ? ` — ${skill.description}` : ""}
</li>
))}
</ul>
)}
</div>
<div>
<h3 className="text-xs font-semibold text-content">Tools MCP fournis</h3>
{servers.length === 0 ? (
<p className="mt-1 text-xs text-muted">Aucun serveur MCP fourni.</p>
) : (
<ul className="mt-1 flex flex-col gap-1">
{servers.map((server) => (
<li key={server.id} className="text-xs text-muted">
<span className="font-medium text-content">{server.displayName}</span>
<span className="text-faint"> · {server.id}</span>
</li>
))}
</ul>
)}
</div>
</div>
</details>
</Panel>
</li>
);
})}
{missing.map((activation) => (
<li key={activation.pluginId}>
<Panel className="flex items-center justify-between gap-4 border-warning/40">
<div>
<span className="text-sm font-semibold text-content">{activation.pluginId}</span>
<p className="text-xs text-warning">Plugin manquant · configuration conservée</p>
</div>
<label className="flex items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={activation.enabled}
disabled={vm.busy || !activation.enabled}
onChange={(event) => void vm.setEnabled(activation.pluginId, event.target.checked)}
/>
Activé pour ce projet
</label>
</Panel>
</li>
))}
</ul>
)}
</section>
);
}

View File

@ -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";

View File

@ -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<void>;
setEnabled(pluginId: string, enabled: boolean): Promise<void>;
setSkillAssigned(agentId: string, pluginId: string, skillId: string, assigned: boolean): Promise<void>;
setToolAssigned(input: Omit<MutateAgentPluginToolInput, "projectId">, assigned: boolean): Promise<void>;
}
export function useProjectPlugins(projectId: string): ProjectPluginsViewModel {
const { plugin } = useGateways();
const [installed, setInstalled] = useState<ProjectPluginManifest[]>([]);
const [settings, setSettings] = useState<ProjectPluginSettings | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<ProjectPluginSettings>) => {
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<MutateAgentPluginToolInput, "projectId">, 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,
};
}

View File

@ -908,6 +908,7 @@ export function ProjectsView() {
// while the menu bar above stays visible.
<SettingsView
section={settingsSection}
projectId={active?.id}
onSectionChange={setSettingsSection}
onClose={() => setSettingsSection(null)}
/>

View File

@ -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" ? (
<DeploymentSettings />
) : section === "plugins" ? (
<PluginsPanel />
<PluginsPanel projectId={projectId} />
) : (
// No `onSessionEnded`: the desktop app hosts the server and is never
// itself a paired device, so it cannot revoke its own session.

View File

@ -218,6 +218,7 @@ function layoutTypesFromContributes(contributes: PluginContributionDto): Set<str
function normalizeContributes(entry: PluginRuntimePlugin): PluginContributionDto {
const contributes = objectOrEmpty(entry.contributes) as Partial<PluginContributionDto>;
return {
skills: arrayOrEmpty(contributes?.skills),
menus: arrayOrEmpty(contributes?.menus),
menuItems: arrayOrEmpty(contributes?.menuItems),
slashCommands: arrayOrEmpty(contributes?.slashCommands),

View File

@ -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<PluginAdmin>;
uninstall(pluginId: string): Promise<PluginUninstallResult>;
listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog>;
listProjectSettings(projectId: string): Promise<ProjectPluginSettingsView>;
setProjectEnabled(input: SetProjectPluginEnabledInput): Promise<ProjectPluginSettings>;
assignAgentSkill(input: MutateAgentPluginSkillInput): Promise<ProjectPluginSettings>;
unassignAgentSkill(input: MutateAgentPluginSkillInput): Promise<ProjectPluginSettings>;
assignAgentTool(input: MutateAgentPluginToolInput): Promise<ProjectPluginSettings>;
unassignAgentTool(input: MutateAgentPluginToolInput): Promise<ProjectPluginSettings>;
openPluginsFolder(pluginId?: string): Promise<void>;
}