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:
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
439
crates/application/src/project_plugin.rs
Normal file
439
crates/application/src/project_plugin.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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")]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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")]
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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")]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
76
crates/infrastructure/src/store/project_plugin.rs
Normal file
76
crates/infrastructure/src/store/project_plugin.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
84
crates/infrastructure/tests/project_plugin_settings.rs
Normal file
84
crates/infrastructure/tests/project_plugin_settings.rs
Normal 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"));
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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.
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
148
frontend/src/features/plugins/AgentPluginCapabilities.tsx
Normal file
148
frontend/src/features/plugins/AgentPluginCapabilities.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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">
|
||||
|
||||
105
frontend/src/features/plugins/ProjectPluginSettings.test.tsx
Normal file
105
frontend/src/features/plugins/ProjectPluginSettings.test.tsx
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
143
frontend/src/features/plugins/ProjectPluginsPanel.tsx
Normal file
143
frontend/src/features/plugins/ProjectPluginsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
105
frontend/src/features/plugins/useProjectPlugins.ts
Normal file
105
frontend/src/features/plugins/useProjectPlugins.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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)}
|
||||
/>
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
|
||||
Submodule sdk/IdeaSDK updated: 1239104b0f...6eb8db3791
Reference in New Issue
Block a user