merge(plugins): paramètres projet plugins + attribution skills/tools agents multi-harness — #282 (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, npm build frontend; branche feature/282-project-plugin-settings-multi-harness)
This commit is contained in:
@ -408,6 +408,12 @@ pub fn run() {
|
|||||||
commands::attach_background_task,
|
commands::attach_background_task,
|
||||||
commands::list_background_tasks,
|
commands::list_background_tasks,
|
||||||
plugins::plugin_list_plugins,
|
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_review_package,
|
||||||
plugins::plugin_install_from_archive,
|
plugins::plugin_install_from_archive,
|
||||||
plugins::plugin_install_from_directory,
|
plugins::plugin_install_from_directory,
|
||||||
|
|||||||
@ -3,19 +3,24 @@
|
|||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::path::{Path, PathBuf};
|
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::{
|
use backend::dto::{
|
||||||
ErrorDto, PluginAdminDto, PluginConfigDocumentDto, PluginConfigDocumentReadDto,
|
parse_agent_id, parse_project_id, ErrorDto, MutateAgentPluginSkillRequestDto,
|
||||||
PluginConfigDocumentUpdateDto, PluginConfigDocumentWriteResultDto, PluginEventBatchDto,
|
MutateAgentPluginToolRequestDto, PluginAdminDto, PluginConfigDocumentDto,
|
||||||
PluginEventPollDto, PluginEventSubscribeDto, PluginEventSubscriptionDto,
|
PluginConfigDocumentReadDto, PluginConfigDocumentUpdateDto, PluginConfigDocumentWriteResultDto,
|
||||||
|
PluginEventBatchDto, PluginEventPollDto, PluginEventSubscribeDto, PluginEventSubscriptionDto,
|
||||||
PluginEventUnsubscribeDto, PluginInstallResultDto, PluginProjectStructureDto,
|
PluginEventUnsubscribeDto, PluginInstallResultDto, PluginProjectStructureDto,
|
||||||
PluginProjectStructureQueryDto, PluginReloadResultDto, PluginReviewDto, PluginRunCommandDto,
|
PluginProjectStructureQueryDto, PluginReloadResultDto, PluginReviewDto, PluginRunCommandDto,
|
||||||
PluginRuntimeContributionCatalogDto, PluginStorageGetDto, PluginStorageSetDto, PluginTaskDto,
|
PluginRuntimeContributionCatalogDto, PluginStorageGetDto, PluginStorageSetDto, PluginTaskDto,
|
||||||
PluginTaskStatusDto, PluginToolchainDiagnosticDto, PluginToolchainDiagnosticRequestDto,
|
PluginTaskStatusDto, PluginToolchainDiagnosticDto, PluginToolchainDiagnosticRequestDto,
|
||||||
PluginUninstallResultDto, PluginWorkspaceBinaryFileDto, PluginWorkspaceDirectoryListingDto,
|
PluginUninstallResultDto, PluginWorkspaceBinaryFileDto, PluginWorkspaceDirectoryListingDto,
|
||||||
PluginWorkspacePathDto, PluginWorkspaceStatDto, PluginWorkspaceTextFileDto,
|
PluginWorkspacePathDto, PluginWorkspaceStatDto, PluginWorkspaceTextFileDto,
|
||||||
PluginWorkspaceWriteBinaryDto, PluginWorkspaceWriteTextDto, ReloadPluginDto,
|
PluginWorkspaceWriteBinaryDto, PluginWorkspaceWriteTextDto, ProjectPluginSettingsDto,
|
||||||
ReviewPluginPackageDto,
|
ProjectPluginSettingsViewDto, ReloadPluginDto, ReviewPluginPackageDto,
|
||||||
|
SetProjectPluginEnabledRequestDto,
|
||||||
};
|
};
|
||||||
use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore};
|
use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore};
|
||||||
use domain::{PluginId, RelativePath};
|
use domain::{PluginId, RelativePath};
|
||||||
@ -27,6 +32,14 @@ use crate::state::AppState;
|
|||||||
const PLUGIN_ASSET_CORS_ORIGIN: &str = "*";
|
const PLUGIN_ASSET_CORS_ORIGIN: &str = "*";
|
||||||
const PLUGIN_ASSET_CORS_METHODS: &str = "GET";
|
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.
|
/// Lists installed plugins for the admin surface.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn plugin_list_plugins(
|
pub async fn plugin_list_plugins(
|
||||||
@ -40,6 +53,107 @@ pub async fn plugin_list_plugins(
|
|||||||
.map_err(ErrorDto::from)
|
.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.
|
/// Reviews a local plugin package without committing it.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn plugin_review_package(
|
pub async fn plugin_review_package(
|
||||||
|
|||||||
@ -22,6 +22,7 @@ fn plugin_admin_dto_serialises_exact_contract_shape() {
|
|||||||
restart_required: true,
|
restart_required: true,
|
||||||
trust_level: PluginTrustLevel::Full,
|
trust_level: PluginTrustLevel::Full,
|
||||||
contribution_summary: PluginContributionSummaryDto {
|
contribution_summary: PluginContributionSummaryDto {
|
||||||
|
skills: 5,
|
||||||
top_level_menus: 1,
|
top_level_menus: 1,
|
||||||
menu_items: 2,
|
menu_items: 2,
|
||||||
slash_commands: 3,
|
slash_commands: 3,
|
||||||
@ -36,6 +37,7 @@ fn plugin_admin_dto_serialises_exact_contract_shape() {
|
|||||||
assert_eq!(value["lifecycleState"], "pending-disable");
|
assert_eq!(value["lifecycleState"], "pending-disable");
|
||||||
assert_eq!(value["trustLevel"], "full");
|
assert_eq!(value["trustLevel"], "full");
|
||||||
assert_eq!(value["contributionSummary"]["topLevelMenus"], 1);
|
assert_eq!(value["contributionSummary"]["topLevelMenus"], 1);
|
||||||
|
assert_eq!(value["contributionSummary"]["skills"], 5);
|
||||||
assert_eq!(value["contributionSummary"]["slashCommands"], 3);
|
assert_eq!(value["contributionSummary"]["slashCommands"], 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1800,6 +1800,17 @@ impl LaunchAgent {
|
|||||||
self
|
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.
|
/// Injects the local model-server ensure use case used by OpenCode profiles.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_local_model_server(mut self, ensure: Arc<EnsureLocalModelServer>) -> Self {
|
pub fn with_local_model_server(mut self, ensure: Arc<EnsureLocalModelServer>) -> Self {
|
||||||
@ -4364,6 +4375,7 @@ mod tests {
|
|||||||
snapshot: domain::AssignedSkillSnapshot {
|
snapshot: domain::AssignedSkillSnapshot {
|
||||||
skill_id: skill.id,
|
skill_id: skill.id,
|
||||||
scope: skill.scope,
|
scope: skill.scope,
|
||||||
|
plugin: None,
|
||||||
name: skill.name.clone(),
|
name: skill.name.clone(),
|
||||||
description: skill.effective_description(),
|
description: skill.effective_description(),
|
||||||
kind: skill.kind,
|
kind: skill.kind,
|
||||||
|
|||||||
@ -33,6 +33,7 @@ pub mod orchestrator;
|
|||||||
pub mod permission;
|
pub mod permission;
|
||||||
pub mod plugin;
|
pub mod plugin;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
|
pub mod project_plugin;
|
||||||
pub mod remote;
|
pub mod remote;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
pub mod slash_command;
|
pub mod slash_command;
|
||||||
@ -204,6 +205,11 @@ pub use project::{
|
|||||||
ReadProjectContextOutput, UpdateProjectContext, UpdateProjectContextInput,
|
ReadProjectContextOutput, UpdateProjectContext, UpdateProjectContextInput,
|
||||||
PROJECT_CONTEXT_FILE,
|
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 remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};
|
||||||
pub use skill::{
|
pub use skill::{
|
||||||
AgentCapability, AssignSkillToAgent, AssignSkillToAgentInput, AssignedSkillResolver,
|
AgentCapability, AssignSkillToAgent, AssignSkillToAgentInput, AssignedSkillResolver,
|
||||||
|
|||||||
@ -27,6 +27,8 @@ use crate::{AppError, SpawnBackgroundCommand, SpawnBackgroundCommandInput};
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PluginContributionSummary {
|
pub struct PluginContributionSummary {
|
||||||
|
/// Agent skill count.
|
||||||
|
pub skills: usize,
|
||||||
/// Top-level menu count.
|
/// Top-level menu count.
|
||||||
pub top_level_menus: usize,
|
pub top_level_menus: usize,
|
||||||
/// Menu item count.
|
/// Menu item count.
|
||||||
@ -42,6 +44,7 @@ pub struct PluginContributionSummary {
|
|||||||
impl From<&PluginContributionSet> for PluginContributionSummary {
|
impl From<&PluginContributionSet> for PluginContributionSummary {
|
||||||
fn from(c: &PluginContributionSet) -> Self {
|
fn from(c: &PluginContributionSet) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
skills: c.skills.len(),
|
||||||
top_level_menus: c.menus.len(),
|
top_level_menus: c.menus.len(),
|
||||||
menu_items: c.menu_items.len(),
|
menu_items: c.menu_items.len(),
|
||||||
slash_commands: c.slash_commands.len(),
|
slash_commands: c.slash_commands.len(),
|
||||||
@ -3310,6 +3313,8 @@ struct RawEngines {
|
|||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct RawContributes {
|
struct RawContributes {
|
||||||
|
#[serde(default)]
|
||||||
|
skills: Vec<RawPluginSkill>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
menus: Vec<RawMenu>,
|
menus: Vec<RawMenu>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@ -3322,6 +3327,17 @@ struct RawContributes {
|
|||||||
mcp_servers: Vec<RawMcpServer>,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct RawMenu {
|
struct RawMenu {
|
||||||
@ -3480,6 +3496,33 @@ fn validate_contributes(raw: RawContributes) -> Result<PluginContributionSet, Pl
|
|||||||
Ok(())
|
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
|
let menus = raw
|
||||||
.menus
|
.menus
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@ -3617,6 +3660,7 @@ fn validate_contributes(raw: RawContributes) -> Result<PluginContributionSet, Pl
|
|||||||
})
|
})
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
Ok(PluginContributionSet {
|
Ok(PluginContributionSet {
|
||||||
|
skills,
|
||||||
menus,
|
menus,
|
||||||
menu_items,
|
menu_items,
|
||||||
slash_commands,
|
slash_commands,
|
||||||
@ -3688,6 +3732,7 @@ mod tests {
|
|||||||
"trustLevel": "full",
|
"trustLevel": "full",
|
||||||
"capabilities": ["ui", "mcp", "tooling"],
|
"capabilities": ["ui", "mcp", "tooling"],
|
||||||
"contributes": {
|
"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}],
|
"menus": [{"id":"dev.acme.menu","label":"Graph","topLevel":true}],
|
||||||
"menuItems": [{"id":"dev.acme.open","targetMenuId":"panels","label":"Open","command":"dev.acme.open"}],
|
"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}],
|
"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.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.mcp_servers.len(), 1);
|
||||||
assert_eq!(m.contributes.slash_commands.len(), 1);
|
assert_eq!(m.contributes.slash_commands.len(), 1);
|
||||||
assert_eq!(m.contributes.slash_commands[0].name, "/graph");
|
assert_eq!(m.contributes.slash_commands[0].name, "/graph");
|
||||||
assert!(m.contributes.slash_commands[0].requires_confirmation);
|
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]
|
#[test]
|
||||||
fn rejects_unknown_manifest_capability() {
|
fn rejects_unknown_manifest_capability() {
|
||||||
let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();
|
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::error::AppError;
|
||||||
|
use crate::ProjectPluginResolver;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// CreateSkill
|
// CreateSkill
|
||||||
@ -293,13 +294,24 @@ impl ResolveAgentCapabilities {
|
|||||||
/// context rendering and `idea_skill_read` authorization.
|
/// context rendering and `idea_skill_read` authorization.
|
||||||
pub struct AssignedSkillResolver {
|
pub struct AssignedSkillResolver {
|
||||||
skills: Arc<dyn SkillStore>,
|
skills: Arc<dyn SkillStore>,
|
||||||
|
plugin_skills: Option<Arc<ProjectPluginResolver>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AssignedSkillResolver {
|
impl AssignedSkillResolver {
|
||||||
/// Builds the resolver from the existing skill store port.
|
/// Builds the resolver from the existing skill store port.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
|
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.
|
/// Resolves the assigned skills for `agent` in manifest order.
|
||||||
@ -342,6 +354,7 @@ impl AssignedSkillResolver {
|
|||||||
let snapshot = AssignedSkillSnapshot {
|
let snapshot = AssignedSkillSnapshot {
|
||||||
skill_id: skill.id,
|
skill_id: skill.id,
|
||||||
scope: skill.scope,
|
scope: skill.scope,
|
||||||
|
plugin: None,
|
||||||
name: skill.name,
|
name: skill.name,
|
||||||
description,
|
description,
|
||||||
kind: skill.kind,
|
kind: skill.kind,
|
||||||
@ -353,6 +366,52 @@ impl AssignedSkillResolver {
|
|||||||
Err(e) => return Err(e.into()),
|
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)
|
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
|
let skill = self
|
||||||
.skills
|
.skills
|
||||||
.get(selected.scope, root, selected.skill_id)
|
.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]
|
#[tokio::test]
|
||||||
async fn launch_conventionfile_injects_assigned_skills_in_order() {
|
async fn launch_conventionfile_injects_assigned_skills_in_order() {
|
||||||
// An agent with two assigned global skills; the generated convention file must
|
// 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_id = |n: u128| SkillId::from_uuid(Uuid::from_u128(n));
|
||||||
let skill = |n: u128, name: &str, body: &str| {
|
let skill = |n: u128, name: &str, body: &str| {
|
||||||
Skill::new(
|
Skill::new(
|
||||||
@ -2244,14 +2245,20 @@ async fn launch_conventionfile_injects_assigned_skills_in_order() {
|
|||||||
assert_eq!(writes.len(), 1);
|
assert_eq!(writes.len(), 1);
|
||||||
let doc = String::from_utf8(writes[0].1.clone()).unwrap();
|
let doc = String::from_utf8(writes[0].1.clone()).unwrap();
|
||||||
assert!(doc.contains("# persona"), "persona present: {doc}");
|
assert!(doc.contains("# persona"), "persona present: {doc}");
|
||||||
assert!(doc.contains("REFAC_BODY"), "first skill body present");
|
assert!(
|
||||||
assert!(doc.contains("REVIEW_BODY"), "second skill body present");
|
doc.contains("**refactor** — REFAC_BODY"),
|
||||||
// Deterministic order: persona before skills, skill 1 before skill 2.
|
"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 persona_at = doc.find("# persona").unwrap();
|
||||||
let refac_at = doc.find("REFAC_BODY").unwrap();
|
let refac_at = doc.find("REFAC_BODY").unwrap();
|
||||||
let review_at = doc.find("REVIEW_BODY").unwrap();
|
let review_at = doc.find("REVIEW_BODY").unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
persona_at < refac_at && refac_at < review_at,
|
refac_at < review_at && review_at < persona_at,
|
||||||
"ordering: {doc}"
|
"ordering: {doc}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1216,7 +1216,12 @@ fn swap_fixture(agent: &Agent, target_structured: bool, factory: FakeFactory) ->
|
|||||||
Arc::new(FakeRecall),
|
Arc::new(FakeRecall),
|
||||||
None,
|
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(
|
let swap = ChangeAgentProfile::new(
|
||||||
Arc::new(contexts.clone()),
|
Arc::new(contexts.clone()),
|
||||||
@ -1582,7 +1587,8 @@ fn launch_fixture_p8b(
|
|||||||
Arc::new(FakeRecall),
|
Arc::new(FakeRecall),
|
||||||
None,
|
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 {
|
if let Some(store) = store {
|
||||||
let provider =
|
let provider =
|
||||||
Arc::new(FakeProviderSessionProvider(store)) as Arc<dyn ProviderSessionProvider>;
|
Arc::new(FakeProviderSessionProvider(store)) as Arc<dyn ProviderSessionProvider>;
|
||||||
@ -1798,6 +1804,7 @@ fn launch_fixture_with_provider(
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.with_structured(Arc::new(factory), Arc::clone(&structured))
|
.with_structured(Arc::new(factory), Arc::clone(&structured))
|
||||||
|
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
|
||||||
.with_provider_session_provider(provider);
|
.with_provider_session_provider(provider);
|
||||||
(Arc::new(launch), agent, observed)
|
(Arc::new(launch), agent, observed)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,8 @@ pub use crate::ticket_dto::*;
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PluginContributionSummaryDto {
|
pub struct PluginContributionSummaryDto {
|
||||||
|
/// Agent skills count.
|
||||||
|
pub skills: usize,
|
||||||
/// Top-level menus count.
|
/// Top-level menus count.
|
||||||
pub top_level_menus: usize,
|
pub top_level_menus: usize,
|
||||||
/// Menu items count.
|
/// Menu items count.
|
||||||
@ -51,6 +53,7 @@ pub struct PluginContributionSummaryDto {
|
|||||||
impl From<application::PluginContributionSummary> for PluginContributionSummaryDto {
|
impl From<application::PluginContributionSummary> for PluginContributionSummaryDto {
|
||||||
fn from(value: application::PluginContributionSummary) -> Self {
|
fn from(value: application::PluginContributionSummary) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
skills: value.skills,
|
||||||
top_level_menus: value.top_level_menus,
|
top_level_menus: value.top_level_menus,
|
||||||
menu_items: value.menu_items,
|
menu_items: value.menu_items,
|
||||||
slash_commands: value.slash_commands,
|
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.
|
/// Admin plugin DTO.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
@ -14,52 +14,54 @@ use std::sync::{Arc, Mutex};
|
|||||||
use application::orchestrator::UpdateProjectContext as GuardedUpdateProjectContext;
|
use application::orchestrator::UpdateProjectContext as GuardedUpdateProjectContext;
|
||||||
use application::{
|
use application::{
|
||||||
AddIssueAttachment, AgentResumer, AgentWakeService, AllocateManagedTemp, AppError,
|
AddIssueAttachment, AgentResumer, AgentWakeService, AllocateManagedTemp, AppError,
|
||||||
AssignIssueAgent, AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent,
|
AssignIssueAgent, AssignSkillToAgent, AssignTicketToSprint, AssignedSkillResolver,
|
||||||
AuthenticateSession, BackgroundCommandArchive, BulkDeleteIssues, BulkUpdateIssuePriority,
|
AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive, BulkDeleteIssues,
|
||||||
BulkUpdateIssueStatus, CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion,
|
BulkUpdateIssuePriority, BulkUpdateIssueStatus, CancelBackgroundTask, ChangeAgentProfile,
|
||||||
ClearChatDraft, CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab,
|
CheckEmbedderSuggestion, ClearChatDraft, CloneOpenCodeProfileFromSeed, CloneProfileFromSeed,
|
||||||
CloseTerminal, CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases,
|
CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant, ConfigureProfiles,
|
||||||
CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory,
|
ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue,
|
||||||
CreateProject, CreateSkill, CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile,
|
CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint, CreateTemplate,
|
||||||
DeleteIssue, DeleteLayout, DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile,
|
DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory,
|
||||||
DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift,
|
DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint,
|
||||||
DetectProfiles, DismissEmbedderSuggestion, EnsureLocalModelServer, ExecuteSlashCommand,
|
DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
|
||||||
FirstRunState, GcManagedTemp, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory,
|
DismissEmbedderSuggestion, EnsureLocalModelServer, ExecuteSlashCommand, FirstRunState,
|
||||||
GetProjectPermissions, GetProjectSystemPermissions, GetProjectWorkState, GitBranches,
|
GcManagedTemp, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, GetProjectPermissions,
|
||||||
GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage,
|
GetProjectSystemPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit,
|
||||||
HarvestMemoryFromTurn, HealthUseCase, ImportChatAttachments, InspectConversation,
|
GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn,
|
||||||
InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent,
|
HealthUseCase, ImportChatAttachments, InspectConversation, InstallPluginFromArchive,
|
||||||
LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels,
|
InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput,
|
||||||
ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
|
LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels, ListDevices,
|
||||||
|
ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
|
||||||
ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles,
|
ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles,
|
||||||
ListProjectFiles, ListProjects, ListResumableAgents, ListSkills, ListSlashCommands,
|
ListProjectFiles, ListProjectPluginSettings, ListProjects, ListResumableAgents, ListSkills,
|
||||||
ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider,
|
ListSlashCommands, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
|
||||||
LiveStateProvider, LiveStateReadProvider, LoadLayout, MarkIssueAttachmentSummarized,
|
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout,
|
||||||
McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView,
|
MarkIssueAttachmentSummarized, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow,
|
||||||
|
MutateAgentPluginSkill, MutateAgentPluginTool, MutateLayout, OnnxModelView,
|
||||||
OpenPluginLayoutWindow, OpenProject, OpenTerminal, OpenTicketAssistant, OrchestratorService,
|
OpenPluginLayoutWindow, OpenProject, OpenTerminal, OpenTicketAssistant, OrchestratorService,
|
||||||
PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, PluginCommandTasks,
|
PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, PluginCommandTasks,
|
||||||
PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess,
|
PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess,
|
||||||
PluginToolchainDiagnostics, PluginWorkspaceAccess, ProposeContext, PurgeManagedTemp,
|
PluginToolchainDiagnostics, PluginWorkspaceAccess, ProjectPluginResolver, ProposeContext,
|
||||||
QueryProjectStructure, ReadAgentContext, ReadChatDraft, ReadContext, ReadConversationPage,
|
PurgeManagedTemp, QueryProjectStructure, ReadAgentContext, ReadChatDraft, ReadContext,
|
||||||
ReadIssue, ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory,
|
ReadConversationPage, ReadIssue, ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions,
|
||||||
ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
|
ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory,
|
||||||
ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn,
|
ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers,
|
||||||
RecordTurnProvider, ReferenceProfiles, ReleaseManagedTemp, ReloadPlugin, ReloadPluginInput,
|
RecordTurn, RecordTurnProvider, ReferenceProfiles, ReleaseManagedTemp, ReloadPlugin,
|
||||||
RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal,
|
ReloadPluginInput, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal,
|
||||||
ResolveAgentCapabilities, ResolveAgentPermissions, ResolveAgentSystemPermissions,
|
ResolveAgentCapabilities, ResolveAgentPermissions, ResolveAgentSystemPermissions,
|
||||||
ResolveManagedTemp, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask,
|
ResolveManagedTemp, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask,
|
||||||
ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft,
|
ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft,
|
||||||
SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile,
|
SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile,
|
||||||
SessionLimitService, SetActiveLayout, SetPluginEnabled, SlashCommandRegistry,
|
SessionLimitService, SetActiveLayout, SetPluginEnabled, SetProjectPluginEnabled,
|
||||||
SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent,
|
SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand,
|
||||||
StructuredRoutingMode, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate,
|
StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
|
||||||
TerminalSessions, TouchDevice, UnassignSkillFromAgent, UnassignTicketFromSprint,
|
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
|
||||||
UninstallPlugin, UnlinkIssues, UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort,
|
UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContactable,
|
||||||
UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions,
|
UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions,
|
||||||
UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
|
UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
|
||||||
UpdateProjectMcpToolPermissions, UpdateProjectOrchestrator, UpdateProjectPermissions,
|
UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectOrchestrator,
|
||||||
UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory,
|
UpdateProjectPermissions, UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate,
|
||||||
WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
@ -70,10 +72,10 @@ use domain::ports::{
|
|||||||
FileSystem, GitPort, IdGenerator, IssueNumberAllocator, IssueStore, ManagedTempStore,
|
FileSystem, GitPort, IdGenerator, IssueNumberAllocator, IssueStore, ManagedTempStore,
|
||||||
McpToolPermissionStore, MemoryRecall, MemoryStore, ModelArtifactDownloader, PermissionStore,
|
McpToolPermissionStore, MemoryRecall, MemoryStore, ModelArtifactDownloader, PermissionStore,
|
||||||
PluginManifestValidator, PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore,
|
PluginManifestValidator, PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore,
|
||||||
PluginStorageStore, ProcessSpawner, ProfileStore, ProjectStore, PtyHandle, PtyPort,
|
PluginStorageStore, ProcessSpawner, ProfileStore, ProjectPluginSettingsStore, ProjectStore,
|
||||||
RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore,
|
PtyHandle, PtyPort, RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore,
|
||||||
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, ToolInvoker,
|
SprintStore, StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore,
|
||||||
WakeError, WakeReason, WindowStateStore,
|
ToolInvoker, WakeError, WakeReason, WindowStateStore,
|
||||||
};
|
};
|
||||||
use domain::profile::{
|
use domain::profile::{
|
||||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||||
@ -98,18 +100,18 @@ use infrastructure::{
|
|||||||
FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsManagedTempStore,
|
FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsManagedTempStore,
|
||||||
FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher,
|
FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher,
|
||||||
FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore,
|
FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore,
|
||||||
FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore,
|
FsProfileStore, FsProjectPluginSettingsStore, FsProjectStore, FsProviderSessionStore,
|
||||||
FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, Git2Repository,
|
FsSecretStore, FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore,
|
||||||
HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe,
|
FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
|
||||||
HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore,
|
||||||
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem,
|
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
|
||||||
LocalManagedProcess, LocalProcessSpawner, ManagedTempToolProvider, McpServer, MediatedInbox,
|
LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner,
|
||||||
NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader,
|
ManagedTempToolProvider, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle,
|
||||||
ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock,
|
PortablePtyAdapter, ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard,
|
||||||
SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
|
StructuredSessionFactory, SystemClock, SystemMillisClock, TemplateToolProvider,
|
||||||
TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
|
TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler,
|
||||||
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
|
ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
|
||||||
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod dto;
|
pub mod dto;
|
||||||
@ -1552,6 +1554,14 @@ pub struct BackendCore {
|
|||||||
pub plugin_registry_store: Arc<dyn PluginRegistryStore>,
|
pub plugin_registry_store: Arc<dyn PluginRegistryStore>,
|
||||||
/// Manifest validator exposed for the Tauri asset protocol adapter.
|
/// Manifest validator exposed for the Tauri asset protocol adapter.
|
||||||
pub plugin_manifest_validator: Arc<dyn PluginManifestValidator>,
|
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) ---
|
// --- Templates & sync (L7) ---
|
||||||
/// Create a template in the global store.
|
/// Create a template in the global store.
|
||||||
pub create_template: Arc<CreateTemplate>,
|
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_storage_store = Arc::clone(&plugin_storage) as Arc<dyn PluginStorageStore>;
|
||||||
let plugin_manifest_validator =
|
let plugin_manifest_validator =
|
||||||
Arc::clone(&plugin_validator) as Arc<dyn PluginManifestValidator>;
|
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 =
|
let plugin_mcp_supervisor_port =
|
||||||
Arc::clone(&plugin_mcp_supervisor) as Arc<dyn PluginMcpSupervisor>;
|
Arc::clone(&plugin_mcp_supervisor) as Arc<dyn PluginMcpSupervisor>;
|
||||||
|
|
||||||
@ -2355,6 +2375,10 @@ impl BackendCore {
|
|||||||
app_data_dir.to_string_lossy().into_owned(),
|
app_data_dir.to_string_lossy().into_owned(),
|
||||||
));
|
));
|
||||||
let skill_store_port = Arc::clone(&skill_store) as Arc<dyn SkillStore>;
|
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
|
// 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
|
// recall port can be injected into LaunchAgent below (it composes the project
|
||||||
@ -2455,8 +2479,9 @@ impl BackendCore {
|
|||||||
&prompt_store_port,
|
&prompt_store_port,
|
||||||
)));
|
)));
|
||||||
|
|
||||||
let agent_capabilities =
|
let agent_capabilities = Arc::new(ResolveAgentCapabilities::with_resolver(Arc::clone(
|
||||||
Arc::new(ResolveAgentCapabilities::new(Arc::clone(&skill_store_port)));
|
&assigned_skill_resolver,
|
||||||
|
)));
|
||||||
|
|
||||||
let create_agent = Arc::new(CreateAgentFromScratch::new(
|
let create_agent = Arc::new(CreateAgentFromScratch::new(
|
||||||
Arc::clone(&contexts_port),
|
Arc::clone(&contexts_port),
|
||||||
@ -2505,6 +2530,7 @@ impl BackendCore {
|
|||||||
Arc::clone(&memory_recall_port),
|
Arc::clone(&memory_recall_port),
|
||||||
Some(Arc::clone(&check_embedder_suggestion)),
|
Some(Arc::clone(&check_embedder_suggestion)),
|
||||||
)
|
)
|
||||||
|
.with_assigned_skill_resolver(Arc::clone(&assigned_skill_resolver))
|
||||||
.with_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback)
|
.with_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback)
|
||||||
.with_permission_store(Arc::clone(&permission_store_port))
|
.with_permission_store(Arc::clone(&permission_store_port))
|
||||||
.with_system_permission_store(Arc::clone(&system_permission_store_port))
|
.with_system_permission_store(Arc::clone(&system_permission_store_port))
|
||||||
@ -2562,6 +2588,7 @@ impl BackendCore {
|
|||||||
Arc::clone(&memory_recall_port),
|
Arc::clone(&memory_recall_port),
|
||||||
Some(Arc::clone(&check_embedder_suggestion)),
|
Some(Arc::clone(&check_embedder_suggestion)),
|
||||||
)
|
)
|
||||||
|
.with_assigned_skill_resolver(Arc::clone(&assigned_skill_resolver))
|
||||||
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
|
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
|
||||||
.with_permission_store(Arc::clone(&permission_store_port))
|
.with_permission_store(Arc::clone(&permission_store_port))
|
||||||
.with_system_permission_store(Arc::clone(&system_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
|
// 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
|
// le SkillStore et le manifeste agent afin d'autoriser uniquement les
|
||||||
// skills assignés au requester courant.
|
// 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(&contexts_port),
|
||||||
Arc::clone(&skill_store_port),
|
Arc::clone(&assigned_skill_resolver),
|
||||||
));
|
));
|
||||||
let assign_skill = Arc::new(AssignSkillToAgent::new(
|
let assign_skill = Arc::new(AssignSkillToAgent::new(
|
||||||
Arc::clone(&contexts_port),
|
Arc::clone(&contexts_port),
|
||||||
@ -2751,6 +2778,25 @@ impl BackendCore {
|
|||||||
Arc::clone(&contexts_port),
|
Arc::clone(&contexts_port),
|
||||||
Arc::clone(&events_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 use cases (LOT A — §14.5.1) ---
|
||||||
// `memory_store` / `memory_store_port` / `memory_recall_port` are built
|
// `memory_store` / `memory_store_port` / `memory_recall_port` are built
|
||||||
@ -3595,6 +3641,10 @@ impl BackendCore {
|
|||||||
plugin_package_store: Arc::clone(&plugin_packages),
|
plugin_package_store: Arc::clone(&plugin_packages),
|
||||||
plugin_registry_store: Arc::clone(&plugin_registry_store),
|
plugin_registry_store: Arc::clone(&plugin_registry_store),
|
||||||
plugin_manifest_validator: Arc::clone(&plugin_manifest_validator),
|
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,
|
ticket_tool_binder,
|
||||||
template_tool_binder,
|
template_tool_binder,
|
||||||
managed_temp_tool_binder,
|
managed_temp_tool_binder,
|
||||||
|
|||||||
@ -236,14 +236,15 @@ pub use system_permissions::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub use plugin::{
|
pub use plugin::{
|
||||||
ContentHash, CustomPluginLayout, PluginActivationScope, PluginBundleUrl, PluginCapability,
|
AgentPluginAssignments, ContentHash, CustomPluginLayout, PluginActivationScope,
|
||||||
PluginCommandId, PluginContributionSet, PluginDescriptor, PluginError, PluginId,
|
PluginBundleUrl, PluginCapability, PluginCommandId, PluginContributionSet, PluginDescriptor,
|
||||||
PluginInstallSource, PluginLayoutContribution, PluginLayoutType, PluginLifecycleState,
|
PluginError, PluginId, PluginInstallSource, PluginLayoutContribution, PluginLayoutType,
|
||||||
PluginManifest, PluginMcpServerContribution, PluginMcpServerId, PluginMcpServerSpec,
|
PluginLifecycleState, PluginManifest, PluginMcpServerContribution, PluginMcpServerId,
|
||||||
PluginMcpStatus, PluginMcpStatusSet, PluginMenuItemContribution, PluginPackageRef,
|
PluginMcpServerSpec, PluginMcpStatus, PluginMcpStatusSet, PluginMenuItemContribution,
|
||||||
PluginRegistry, PluginRegistryEntry, PluginSlashCommandContribution,
|
PluginPackageRef, PluginRegistry, PluginRegistryEntry, PluginSkillContribution, PluginSkillId,
|
||||||
PluginTopLevelMenuContribution, PluginTrustLevel, PluginVersion, RelativePath, RemovalOutcome,
|
PluginSkillRef, PluginSlashCommandContribution, PluginToolRef, PluginTopLevelMenuContribution,
|
||||||
StagedPluginPackage,
|
PluginTrustLevel, PluginVersion, ProjectPluginActivation, ProjectPluginSettings, RelativePath,
|
||||||
|
RemovalOutcome, StagedPluginPackage,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use sandbox::{
|
pub use sandbox::{
|
||||||
@ -270,8 +271,9 @@ pub use ports::{
|
|||||||
PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor,
|
PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor,
|
||||||
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStorageError,
|
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStorageError,
|
||||||
PluginStorageStore, PluginStoreError, PreparedContext, ProcessError, ProcessSpawner,
|
PluginStorageStore, PluginStoreError, PreparedContext, ProcessError, ProcessSpawner,
|
||||||
ProfileStore, ProjectStore, ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError,
|
ProfileStore, ProjectPluginSettingsStore, ProjectStore, ProviderModelCatalogue, PtyError,
|
||||||
RemoteHost, RemotePath, RuntimeError, RuntimePermissionProbe, ScheduledTask, Scheduler,
|
PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, RuntimePermissionProbe,
|
||||||
SpawnSpec, SprintStore, SprintStoreError, StoreError, StructuredSessionEnvironment,
|
ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError,
|
||||||
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, WindowStateStore,
|
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore,
|
||||||
|
TemplateStore, WindowStateStore,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -17,8 +17,20 @@ use crate::conversation::ConversationParty;
|
|||||||
use crate::ids::{AgentId, NodeId, SkillId};
|
use crate::ids::{AgentId, NodeId, SkillId};
|
||||||
use crate::live_state::WorkStatus;
|
use crate::live_state::WorkStatus;
|
||||||
use crate::mailbox::TicketId;
|
use crate::mailbox::TicketId;
|
||||||
|
use crate::plugin::{PluginId, PluginSkillId};
|
||||||
use crate::skill::{SkillKind, SkillScope};
|
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
|
/// Current schema version for orchestration capabilities injected into an
|
||||||
/// agent's effective runtime context.
|
/// agent's effective runtime context.
|
||||||
pub const ORCHESTRATION_CAPABILITY_SNAPSHOT_VERSION: u32 = 1;
|
pub const ORCHESTRATION_CAPABILITY_SNAPSHOT_VERSION: u32 = 1;
|
||||||
@ -35,6 +47,9 @@ pub struct AssignedSkillSnapshot {
|
|||||||
pub skill_id: SkillId,
|
pub skill_id: SkillId,
|
||||||
/// Store scope used to resolve the skill body.
|
/// Store scope used to resolve the skill body.
|
||||||
pub scope: SkillScope,
|
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`.
|
/// Display name shown to the model and accepted by `idea_skill_read`.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// One-line affordance description shown in the model context.
|
/// 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.
|
/// Top-level menu contribution.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -484,6 +519,9 @@ pub struct PluginMcpServerContribution {
|
|||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PluginContributionSet {
|
pub struct PluginContributionSet {
|
||||||
|
/// Read-only agent skills shipped in the package.
|
||||||
|
#[serde(default)]
|
||||||
|
pub skills: Vec<PluginSkillContribution>,
|
||||||
/// Top-level menus.
|
/// Top-level menus.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub menus: Vec<PluginTopLevelMenuContribution>,
|
pub menus: Vec<PluginTopLevelMenuContribution>,
|
||||||
@ -501,6 +539,94 @@ pub struct PluginContributionSet {
|
|||||||
pub mcp_servers: Vec<PluginMcpServerContribution>,
|
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.
|
/// Validated plugin manifest.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
@ -446,6 +446,19 @@ pub trait PluginPackageStore: Send + Sync {
|
|||||||
package: &PluginPackageRef,
|
package: &PluginPackageRef,
|
||||||
) -> Result<PluginManifestBytes, PluginStoreError>;
|
) -> 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.
|
/// Stages an archive for validation.
|
||||||
async fn install_from_archive(
|
async fn install_from_archive(
|
||||||
&self,
|
&self,
|
||||||
@ -496,6 +509,23 @@ pub trait PluginRegistryStore: Send + Sync {
|
|||||||
async fn save_registry(&self, registry: &PluginRegistry) -> Result<(), PluginRegistryError>;
|
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.
|
/// Manifest parser and validator.
|
||||||
pub trait PluginManifestValidator: Send + Sync {
|
pub trait PluginManifestValidator: Send + Sync {
|
||||||
/// Validates raw manifest bytes.
|
/// Validates raw manifest bytes.
|
||||||
|
|||||||
@ -110,6 +110,7 @@ pub use sandbox::{default_enforcer, NoopSandbox};
|
|||||||
pub use scheduler::TokioScheduler;
|
pub use scheduler::TokioScheduler;
|
||||||
pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory};
|
pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory};
|
||||||
pub use sprints::FsSprintStore;
|
pub use sprints::FsSprintStore;
|
||||||
|
pub use store::FsProjectPluginSettingsStore;
|
||||||
#[cfg(feature = "vector-onnx")]
|
#[cfg(feature = "vector-onnx")]
|
||||||
pub use store::OnnxEmbedder;
|
pub use store::OnnxEmbedder;
|
||||||
#[cfg(feature = "vector-http")]
|
#[cfg(feature = "vector-http")]
|
||||||
|
|||||||
@ -155,6 +155,21 @@ impl PluginPackageStore for FsPluginPackageStore {
|
|||||||
Ok(PluginManifestBytes { bytes })
|
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(
|
async fn install_from_archive(
|
||||||
&self,
|
&self,
|
||||||
archive: &LocalPath,
|
archive: &LocalPath,
|
||||||
|
|||||||
@ -14,6 +14,7 @@ mod memory;
|
|||||||
mod permission;
|
mod permission;
|
||||||
mod profile;
|
mod profile;
|
||||||
mod project;
|
mod project;
|
||||||
|
mod project_plugin;
|
||||||
mod secrets;
|
mod secrets;
|
||||||
mod skill;
|
mod skill;
|
||||||
mod system_permission;
|
mod system_permission;
|
||||||
@ -39,6 +40,7 @@ pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall};
|
|||||||
pub use permission::FsPermissionStore;
|
pub use permission::FsPermissionStore;
|
||||||
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
|
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
|
||||||
pub use project::FsProjectStore;
|
pub use project::FsProjectStore;
|
||||||
|
pub use project_plugin::FsProjectPluginSettingsStore;
|
||||||
pub use secrets::FsSecretStore;
|
pub use secrets::FsSecretStore;
|
||||||
pub use skill::FsSkillStore;
|
pub use skill::FsSkillStore;
|
||||||
pub use system_permission::FsSystemPermissionStore;
|
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,
|
PluginProjectStructure,
|
||||||
PluginReview,
|
PluginReview,
|
||||||
PluginRuntimeContributionCatalog,
|
PluginRuntimeContributionCatalog,
|
||||||
|
ProjectPluginSettings,
|
||||||
|
ProjectPluginSettingsView,
|
||||||
PluginUninstallResult,
|
PluginUninstallResult,
|
||||||
PluginWorkspaceBinaryFile,
|
PluginWorkspaceBinaryFile,
|
||||||
PluginWorkspaceDirectoryListing,
|
PluginWorkspaceDirectoryListing,
|
||||||
@ -45,6 +47,8 @@ import type {
|
|||||||
PluginEventSubscribeInput,
|
PluginEventSubscribeInput,
|
||||||
PluginEventUnsubscribeInput,
|
PluginEventUnsubscribeInput,
|
||||||
PluginGateway,
|
PluginGateway,
|
||||||
|
MutateAgentPluginSkillInput,
|
||||||
|
MutateAgentPluginToolInput,
|
||||||
PluginStorageGateway,
|
PluginStorageGateway,
|
||||||
PluginStorageGetInput,
|
PluginStorageGetInput,
|
||||||
PluginStorageSetInput,
|
PluginStorageSetInput,
|
||||||
@ -60,6 +64,7 @@ import type {
|
|||||||
PluginWorkspaceWriteTextInput,
|
PluginWorkspaceWriteTextInput,
|
||||||
RemoteGateway,
|
RemoteGateway,
|
||||||
ReviewPluginPackageInput,
|
ReviewPluginPackageInput,
|
||||||
|
SetProjectPluginEnabledInput,
|
||||||
ViewWindowClosed,
|
ViewWindowClosed,
|
||||||
ViewWindowSnapshot,
|
ViewWindowSnapshot,
|
||||||
WindowGateway,
|
WindowGateway,
|
||||||
@ -191,6 +196,24 @@ export class WebPluginGateway implements PluginGateway {
|
|||||||
// lets the bootstrap loader run unconditionally without a transport check.
|
// lets the bootstrap loader run unconditionally without a transport check.
|
||||||
return { plugins: [] };
|
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> {
|
async openPluginsFolder(_pluginId?: string): Promise<void> {
|
||||||
return unsupportedOnWeb("Plugin management");
|
return unsupportedOnWeb("Plugin management");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -57,6 +57,9 @@ import type {
|
|||||||
PluginProjectStructureEntry,
|
PluginProjectStructureEntry,
|
||||||
PluginReview,
|
PluginReview,
|
||||||
PluginRuntimeContributionCatalog,
|
PluginRuntimeContributionCatalog,
|
||||||
|
ProjectPluginManifest,
|
||||||
|
ProjectPluginSettings,
|
||||||
|
ProjectPluginSettingsView,
|
||||||
PluginToolchainDiagnostic,
|
PluginToolchainDiagnostic,
|
||||||
PluginUninstallResult,
|
PluginUninstallResult,
|
||||||
PluginPublicEvent,
|
PluginPublicEvent,
|
||||||
@ -138,6 +141,8 @@ import type {
|
|||||||
PluginEventSubscribeInput,
|
PluginEventSubscribeInput,
|
||||||
PluginEventUnsubscribeInput,
|
PluginEventUnsubscribeInput,
|
||||||
PluginGateway,
|
PluginGateway,
|
||||||
|
MutateAgentPluginSkillInput,
|
||||||
|
MutateAgentPluginToolInput,
|
||||||
PluginStorageGateway,
|
PluginStorageGateway,
|
||||||
PluginStorageGetInput,
|
PluginStorageGetInput,
|
||||||
PluginStorageSetInput,
|
PluginStorageSetInput,
|
||||||
@ -155,6 +160,7 @@ import type {
|
|||||||
ReattachAgentChatResult,
|
ReattachAgentChatResult,
|
||||||
RemoteGateway,
|
RemoteGateway,
|
||||||
ReviewPluginPackageInput,
|
ReviewPluginPackageInput,
|
||||||
|
SetProjectPluginEnabledInput,
|
||||||
SaveOpenCodeProviderProfileInput,
|
SaveOpenCodeProviderProfileInput,
|
||||||
SendAgentChatOptions,
|
SendAgentChatOptions,
|
||||||
SkillGateway,
|
SkillGateway,
|
||||||
@ -4004,6 +4010,8 @@ function isBlanketRule(rule: PermissionSet["rules"][number]) {
|
|||||||
*/
|
*/
|
||||||
export class MockPluginGateway implements PluginGateway {
|
export class MockPluginGateway implements PluginGateway {
|
||||||
private plugins: PluginAdmin[] = [];
|
private plugins: PluginAdmin[] = [];
|
||||||
|
private projectManifests: ProjectPluginManifest[] = [];
|
||||||
|
private projectSettings = new Map<string, ProjectPluginSettings>();
|
||||||
private seq = 0;
|
private seq = 0;
|
||||||
|
|
||||||
private summaryFor(_pluginId: string): PluginContributionSummary {
|
private summaryFor(_pluginId: string): PluginContributionSummary {
|
||||||
@ -4015,6 +4023,24 @@ export class MockPluginGateway implements PluginGateway {
|
|||||||
this.plugins.push(plugin);
|
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[]> {
|
async listPlugins(): Promise<PluginAdmin[]> {
|
||||||
return structuredClone(this.plugins);
|
return structuredClone(this.plugins);
|
||||||
}
|
}
|
||||||
@ -4099,6 +4125,68 @@ export class MockPluginGateway implements PluginGateway {
|
|||||||
return { plugins: [] };
|
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> {
|
async openPluginsFolder(_pluginId?: string): Promise<void> {
|
||||||
// No filesystem in the mock — no-op.
|
// No filesystem in the mock — no-op.
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,4 +47,39 @@ describe("TauriPluginGateway invoke payloads", () => {
|
|||||||
expect(review.issues).toEqual([]);
|
expect(review.issues).toEqual([]);
|
||||||
expect(review.installable).toBe(true);
|
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,
|
PluginInstallResult,
|
||||||
PluginReview,
|
PluginReview,
|
||||||
PluginRuntimeContributionCatalog,
|
PluginRuntimeContributionCatalog,
|
||||||
|
ProjectPluginSettings,
|
||||||
|
ProjectPluginSettingsView,
|
||||||
PluginUninstallResult,
|
PluginUninstallResult,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import type { PluginGateway, ReviewPluginPackageInput } from "@/ports";
|
import type {
|
||||||
|
MutateAgentPluginSkillInput,
|
||||||
|
MutateAgentPluginToolInput,
|
||||||
|
PluginGateway,
|
||||||
|
ReviewPluginPackageInput,
|
||||||
|
SetProjectPluginEnabledInput,
|
||||||
|
} from "@/ports";
|
||||||
|
|
||||||
type TauriPluginReviewDto = Omit<PluginReview, "issues" | "installable"> & {
|
type TauriPluginReviewDto = Omit<PluginReview, "issues" | "installable"> & {
|
||||||
issues?: PluginReview["issues"];
|
issues?: PluginReview["issues"];
|
||||||
@ -66,6 +74,30 @@ export class TauriPluginGateway implements PluginGateway {
|
|||||||
return invoke<PluginRuntimeContributionCatalog>("plugin_list_runtime_contributions");
|
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> {
|
async openPluginsFolder(pluginId?: string): Promise<void> {
|
||||||
await invoke("plugin_open_plugins_folder", { pluginId: pluginId ?? null });
|
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). */
|
/** Counts of what a plugin declares, shown in the admin list (carnet §5). */
|
||||||
export interface PluginContributionSummary {
|
export interface PluginContributionSummary {
|
||||||
|
/** Agent skills shipped by the plugin. Absent on older backend DTOs. */
|
||||||
|
skills?: number;
|
||||||
topLevelMenus: number;
|
topLevelMenus: number;
|
||||||
menuItems: number;
|
menuItems: number;
|
||||||
layouts: number;
|
layouts: number;
|
||||||
@ -1917,6 +1919,8 @@ export interface PluginUninstallResult {
|
|||||||
|
|
||||||
/** Contribution declarations for one plugin, as consumed by the runtime loader. */
|
/** Contribution declarations for one plugin, as consumed by the runtime loader. */
|
||||||
export interface PluginContributionDto {
|
export interface PluginContributionDto {
|
||||||
|
/** Read-only, package-relative skills exposed to project agents. */
|
||||||
|
skills?: PluginSkillContribution[];
|
||||||
menus: PluginTopLevelMenuContribution[];
|
menus: PluginTopLevelMenuContribution[];
|
||||||
menuItems: PluginMenuItemContribution[];
|
menuItems: PluginMenuItemContribution[];
|
||||||
slashCommands?: PluginSlashCommandContribution[];
|
slashCommands?: PluginSlashCommandContribution[];
|
||||||
@ -1924,6 +1928,66 @@ export interface PluginContributionDto {
|
|||||||
mcpServers: PluginMcpServerSummary[];
|
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`). */
|
/** One entry of `plugin_list_runtime_contributions` (mirror of `PluginRuntimePluginDto`). */
|
||||||
export interface PluginRuntimePlugin {
|
export interface PluginRuntimePlugin {
|
||||||
id: string;
|
id: string;
|
||||||
@ -2266,5 +2330,11 @@ export interface CustomPluginLayoutCell {
|
|||||||
export interface PluginMcpServerSummary {
|
export interface PluginMcpServerSummary {
|
||||||
id: string;
|
id: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
command?: string;
|
||||||
|
args?: string[];
|
||||||
|
env?: Array<[string, string]>;
|
||||||
|
cwd?: string;
|
||||||
|
transport?: "stdio";
|
||||||
autoStart?: boolean;
|
autoStart?: boolean;
|
||||||
|
allowAbsoluteCommand?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { Button, Input, Panel, SmallDropdown, Spinner, cn } from "@/shared";
|
import { Button, Input, Panel, SmallDropdown, Spinner, cn } from "@/shared";
|
||||||
import { TerminalView } from "@/features/terminals/TerminalView";
|
import { TerminalView } from "@/features/terminals/TerminalView";
|
||||||
import { AnnouncementsPreview } from "@/features/announcements";
|
import { AnnouncementsPreview } from "@/features/announcements";
|
||||||
|
import { AgentPluginCapabilities } from "@/features/plugins/AgentPluginCapabilities";
|
||||||
import { useDrift } from "@/features/templates/useDrift";
|
import { useDrift } from "@/features/templates/useDrift";
|
||||||
import { templateProfileLabel } from "@/features/templates/templatePresentation";
|
import { templateProfileLabel } from "@/features/templates/templatePresentation";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
@ -737,6 +738,16 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedAgent && (
|
||||||
|
<div className="border-t border-border p-4">
|
||||||
|
<AgentPluginCapabilities
|
||||||
|
projectId={projectId}
|
||||||
|
agentId={selectedAgent.id}
|
||||||
|
agentName={selectedAgent.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Context editor ── */}
|
{/* ── Context editor ── */}
|
||||||
{selectedAgent && (
|
{selectedAgent && (
|
||||||
<div className="flex flex-col gap-2 border-t border-border p-4">
|
<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 { PluginConfirmDialog as ConfirmDialog } from "./PluginConfirmDialog";
|
||||||
import { usePluginRuntime } from "./PluginRuntimeProvider";
|
import { usePluginRuntime } from "./PluginRuntimeProvider";
|
||||||
import { usePlugins } from "./usePlugins";
|
import { usePlugins } from "./usePlugins";
|
||||||
|
import { ProjectPluginsPanel } from "./ProjectPluginsPanel";
|
||||||
|
|
||||||
const STATE_LABEL: Record<PluginLifecycleState, string> = {
|
const STATE_LABEL: Record<PluginLifecycleState, string> = {
|
||||||
enabled: "Activé",
|
enabled: "Activé",
|
||||||
@ -35,7 +36,11 @@ interface InstallFlow {
|
|||||||
review: PluginReview;
|
review: PluginReview;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PluginsPanel() {
|
export interface PluginsPanelProps {
|
||||||
|
projectId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PluginsPanel({ projectId }: PluginsPanelProps = {}) {
|
||||||
const vm = usePlugins();
|
const vm = usePlugins();
|
||||||
const pluginRuntime = usePluginRuntime();
|
const pluginRuntime = usePluginRuntime();
|
||||||
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
||||||
@ -87,8 +92,19 @@ export function PluginsPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<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">
|
<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">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" onClick={() => void startInstallFromArchive()} disabled={vm.busy}>
|
<Button size="sm" onClick={() => void startInstallFromArchive()} disabled={vm.busy}>
|
||||||
Installer depuis une archive…
|
Installer depuis une archive…
|
||||||
@ -103,6 +119,7 @@ export function PluginsPanel() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{(vm.error || installFlowError) && (
|
{(vm.error || installFlowError) && (
|
||||||
<p role="alert" className="text-sm text-danger">
|
<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 { PluginRuntimeProvider, usePluginRuntime, type PluginRuntimeContextValue } from "./PluginRuntimeProvider";
|
||||||
export { PluginsPanel } from "./PluginsPanel";
|
export { PluginsPanel } from "./PluginsPanel";
|
||||||
|
export { ProjectPluginsPanel } from "./ProjectPluginsPanel";
|
||||||
|
export { AgentPluginCapabilities } from "./AgentPluginCapabilities";
|
||||||
export { usePlugins, type PluginsViewModel } from "./usePlugins";
|
export { usePlugins, type PluginsViewModel } from "./usePlugins";
|
||||||
export { resolveMenuItems, resolveTopLevelMenus, type ResolvedTopLevelMenu } from "./menus";
|
export { resolveMenuItems, resolveTopLevelMenus, type ResolvedTopLevelMenu } from "./menus";
|
||||||
export { usePluginMenus, type UsePluginMenusResult } from "./usePluginMenus";
|
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.
|
// while the menu bar above stays visible.
|
||||||
<SettingsView
|
<SettingsView
|
||||||
section={settingsSection}
|
section={settingsSection}
|
||||||
|
projectId={active?.id}
|
||||||
onSectionChange={setSettingsSection}
|
onSectionChange={setSettingsSection}
|
||||||
onClose={() => setSettingsSection(null)}
|
onClose={() => setSettingsSection(null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -48,12 +48,14 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [
|
|||||||
|
|
||||||
interface SettingsViewProps {
|
interface SettingsViewProps {
|
||||||
section: SettingsSection;
|
section: SettingsSection;
|
||||||
|
projectId?: string;
|
||||||
onSectionChange: (section: SettingsSection) => void;
|
onSectionChange: (section: SettingsSection) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsView({
|
export function SettingsView({
|
||||||
section,
|
section,
|
||||||
|
projectId,
|
||||||
onSectionChange,
|
onSectionChange,
|
||||||
onClose,
|
onClose,
|
||||||
}: SettingsViewProps) {
|
}: SettingsViewProps) {
|
||||||
@ -97,7 +99,7 @@ export function SettingsView({
|
|||||||
) : section === "deployment" ? (
|
) : section === "deployment" ? (
|
||||||
<DeploymentSettings />
|
<DeploymentSettings />
|
||||||
) : section === "plugins" ? (
|
) : section === "plugins" ? (
|
||||||
<PluginsPanel />
|
<PluginsPanel projectId={projectId} />
|
||||||
) : (
|
) : (
|
||||||
// No `onSessionEnded`: the desktop app hosts the server and is never
|
// No `onSessionEnded`: the desktop app hosts the server and is never
|
||||||
// itself a paired device, so it cannot revoke its own session.
|
// 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 {
|
function normalizeContributes(entry: PluginRuntimePlugin): PluginContributionDto {
|
||||||
const contributes = objectOrEmpty(entry.contributes) as Partial<PluginContributionDto>;
|
const contributes = objectOrEmpty(entry.contributes) as Partial<PluginContributionDto>;
|
||||||
return {
|
return {
|
||||||
|
skills: arrayOrEmpty(contributes?.skills),
|
||||||
menus: arrayOrEmpty(contributes?.menus),
|
menus: arrayOrEmpty(contributes?.menus),
|
||||||
menuItems: arrayOrEmpty(contributes?.menuItems),
|
menuItems: arrayOrEmpty(contributes?.menuItems),
|
||||||
slashCommands: arrayOrEmpty(contributes?.slashCommands),
|
slashCommands: arrayOrEmpty(contributes?.slashCommands),
|
||||||
|
|||||||
@ -59,6 +59,8 @@ import type {
|
|||||||
PluginProjectStructure,
|
PluginProjectStructure,
|
||||||
PluginReview,
|
PluginReview,
|
||||||
PluginRuntimeContributionCatalog,
|
PluginRuntimeContributionCatalog,
|
||||||
|
ProjectPluginSettings,
|
||||||
|
ProjectPluginSettingsView,
|
||||||
PluginSourceKind,
|
PluginSourceKind,
|
||||||
PluginUninstallResult,
|
PluginUninstallResult,
|
||||||
PluginWorkspaceBinaryFile,
|
PluginWorkspaceBinaryFile,
|
||||||
@ -1549,6 +1551,27 @@ export interface ReviewPluginPackageInput {
|
|||||||
path: string;
|
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
|
* Plugin system gateway (ticket #43, F1) — admin CRUD + the bootstrap catalog
|
||||||
* the runtime loader consumes. Mirrors the Tauri commands in carnet §5
|
* 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>;
|
setEnabled(pluginId: string, enabled: boolean): Promise<PluginAdmin>;
|
||||||
uninstall(pluginId: string): Promise<PluginUninstallResult>;
|
uninstall(pluginId: string): Promise<PluginUninstallResult>;
|
||||||
listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog>;
|
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>;
|
openPluginsFolder(pluginId?: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Submodule sdk/IdeaSDK updated: 1239104b0f...6eb8db3791
Reference in New Issue
Block a user