feat(plugins): paramètres projet plugins + attribution skills/tools aux agents — #282 (manifeste accepte contributes.skills IdeA-native multi-harness; store .ideai/project-plugins.json v1: enable plugin par projet + assignments skills/tools par agent; refs + use cases settings/assign/unassign; resolver skills pour idea_skill_read et injection catalogue lancement agent; permissions MCP dynamiques projetées par harness Claude Code/Codex/OpenCode; tombstones uninstall, hot reload recalcule catalogue et marque assignments cassés; frontend: Settings projet Plugins, détail capabilities, attribution agent, catalogue permissions; bump pointeur IdeaSDK 6eb8db3; QA verte: cargo test -p application, project_plugin_settings 3/3, dto_plugins 2/2, structured_launch_d3 26/26, catalogue mcp 7/7, frontend 5 tests ProjectPluginSettings/plugin, npm build frontend)
This commit is contained in:
@ -1800,6 +1800,17 @@ impl LaunchAgent {
|
||||
self
|
||||
}
|
||||
|
||||
/// Replaces the default native-skill resolver with the shared effective
|
||||
/// resolver used by runtime context injection and `idea_skill_read`.
|
||||
#[must_use]
|
||||
pub fn with_assigned_skill_resolver(mut self, resolver: Arc<AssignedSkillResolver>) -> Self {
|
||||
self.agent_capabilities = Arc::new(ResolveAgentCapabilities::with_resolver(Arc::clone(
|
||||
&resolver,
|
||||
)));
|
||||
self.assigned_skills = resolver;
|
||||
self
|
||||
}
|
||||
|
||||
/// Injects the local model-server ensure use case used by OpenCode profiles.
|
||||
#[must_use]
|
||||
pub fn with_local_model_server(mut self, ensure: Arc<EnsureLocalModelServer>) -> Self {
|
||||
@ -4364,6 +4375,7 @@ mod tests {
|
||||
snapshot: domain::AssignedSkillSnapshot {
|
||||
skill_id: skill.id,
|
||||
scope: skill.scope,
|
||||
plugin: None,
|
||||
name: skill.name.clone(),
|
||||
description: skill.effective_description(),
|
||||
kind: skill.kind,
|
||||
|
||||
@ -33,6 +33,7 @@ pub mod orchestrator;
|
||||
pub mod permission;
|
||||
pub mod plugin;
|
||||
pub mod project;
|
||||
pub mod project_plugin;
|
||||
pub mod remote;
|
||||
pub mod skill;
|
||||
pub mod slash_command;
|
||||
@ -204,6 +205,11 @@ pub use project::{
|
||||
ReadProjectContextOutput, UpdateProjectContext, UpdateProjectContextInput,
|
||||
PROJECT_CONTEXT_FILE,
|
||||
};
|
||||
pub use project_plugin::{
|
||||
plugin_skill_ref, plugin_tool_ref, AssignmentMutation, ListProjectPluginSettings,
|
||||
MutateAgentPluginSkill, MutateAgentPluginTool, ProjectPluginResolver,
|
||||
ProjectPluginSettingsView, SetProjectPluginEnabled,
|
||||
};
|
||||
pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};
|
||||
pub use skill::{
|
||||
AgentCapability, AssignSkillToAgent, AssignSkillToAgentInput, AssignedSkillResolver,
|
||||
|
||||
@ -27,6 +27,8 @@ use crate::{AppError, SpawnBackgroundCommand, SpawnBackgroundCommandInput};
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginContributionSummary {
|
||||
/// Agent skill count.
|
||||
pub skills: usize,
|
||||
/// Top-level menu count.
|
||||
pub top_level_menus: usize,
|
||||
/// Menu item count.
|
||||
@ -42,6 +44,7 @@ pub struct PluginContributionSummary {
|
||||
impl From<&PluginContributionSet> for PluginContributionSummary {
|
||||
fn from(c: &PluginContributionSet) -> Self {
|
||||
Self {
|
||||
skills: c.skills.len(),
|
||||
top_level_menus: c.menus.len(),
|
||||
menu_items: c.menu_items.len(),
|
||||
slash_commands: c.slash_commands.len(),
|
||||
@ -3310,6 +3313,8 @@ struct RawEngines {
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawContributes {
|
||||
#[serde(default)]
|
||||
skills: Vec<RawPluginSkill>,
|
||||
#[serde(default)]
|
||||
menus: Vec<RawMenu>,
|
||||
#[serde(default)]
|
||||
@ -3322,6 +3327,17 @@ struct RawContributes {
|
||||
mcp_servers: Vec<RawMcpServer>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawPluginSkill {
|
||||
id: String,
|
||||
name: String,
|
||||
description: String,
|
||||
#[serde(default)]
|
||||
kind: domain::SkillKind,
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawMenu {
|
||||
@ -3480,6 +3496,33 @@ fn validate_contributes(raw: RawContributes) -> Result<PluginContributionSet, Pl
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
let skills = raw
|
||||
.skills
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
insert(&s.id)?;
|
||||
if s.name.trim().is_empty() || s.description.trim().is_empty() {
|
||||
return Err(PluginManifestError::Invalid(
|
||||
"skills[].name and skills[].description are required".to_owned(),
|
||||
));
|
||||
}
|
||||
let path = domain::RelativePath::new(s.path)
|
||||
.map_err(|e| PluginManifestError::Invalid(e.to_string()))?;
|
||||
if !path.as_str().ends_with(".md") {
|
||||
return Err(PluginManifestError::Invalid(
|
||||
"skills[].path must point to a .md file".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(domain::PluginSkillContribution {
|
||||
id: domain::PluginSkillId::new(s.id)
|
||||
.map_err(|e| PluginManifestError::Invalid(e.to_string()))?,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
kind: s.kind,
|
||||
path,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let menus = raw
|
||||
.menus
|
||||
.into_iter()
|
||||
@ -3617,6 +3660,7 @@ fn validate_contributes(raw: RawContributes) -> Result<PluginContributionSet, Pl
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(PluginContributionSet {
|
||||
skills,
|
||||
menus,
|
||||
menu_items,
|
||||
slash_commands,
|
||||
@ -3688,6 +3732,7 @@ mod tests {
|
||||
"trustLevel": "full",
|
||||
"capabilities": ["ui", "mcp", "tooling"],
|
||||
"contributes": {
|
||||
"skills": [{"id":"build-debug","name":"build-debug","description":"Diagnose build failures.","kind":"workflow","path":"skills/build-debug/SKILL.md"}],
|
||||
"menus": [{"id":"dev.acme.menu","label":"Graph","topLevel":true}],
|
||||
"menuItems": [{"id":"dev.acme.open","targetMenuId":"panels","label":"Open","command":"dev.acme.open"}],
|
||||
"slashCommands": [{"name":"/graph","shortDescription":"Open graph tools","command":"dev.acme.open","requiresConfirmation":true}],
|
||||
@ -4020,12 +4065,35 @@ mod tests {
|
||||
]
|
||||
);
|
||||
assert_eq!(m.contributes.layouts.len(), 1);
|
||||
assert_eq!(m.contributes.skills.len(), 1);
|
||||
assert_eq!(m.contributes.skills[0].id.as_str(), "build-debug");
|
||||
assert_eq!(m.contributes.skills[0].name, "build-debug");
|
||||
assert_eq!(
|
||||
m.contributes.skills[0].path.as_str(),
|
||||
"skills/build-debug/SKILL.md"
|
||||
);
|
||||
assert_eq!(m.contributes.mcp_servers.len(), 1);
|
||||
assert_eq!(m.contributes.slash_commands.len(), 1);
|
||||
assert_eq!(m.contributes.slash_commands[0].name, "/graph");
|
||||
assert!(m.contributes.slash_commands[0].requires_confirmation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_plugin_skill_outside_markdown_contract() {
|
||||
let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();
|
||||
value["contributes"]["skills"][0]["path"] = serde_json::json!("skills/build.txt");
|
||||
let error = validator()
|
||||
.validate(
|
||||
&serde_json::to_vec(&value).unwrap(),
|
||||
&domain::PluginPackageRef {
|
||||
plugin_id: None,
|
||||
root: "x".into(),
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("must point to a .md file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_manifest_capability() {
|
||||
let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();
|
||||
|
||||
439
crates/application/src/project_plugin.rs
Normal file
439
crates/application/src/project_plugin.rs
Normal file
@ -0,0 +1,439 @@
|
||||
//! Project-scoped plugin activation and per-agent capability assignments.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
AgentContextStore, AgentId, AgentPluginAssignments, MarkdownDoc, PluginId, PluginManifest,
|
||||
PluginManifestValidator, PluginMcpServerId, PluginPackageStore, PluginRegistryStore,
|
||||
PluginSkillId, PluginSkillRef, PluginStoreError, PluginToolRef, Project,
|
||||
ProjectPluginActivation, ProjectPluginSettings, ProjectPluginSettingsStore,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::AppError;
|
||||
|
||||
fn package_error(error: PluginStoreError) -> AppError {
|
||||
match error {
|
||||
PluginStoreError::NotFound => AppError::NotFound("plugin package".to_owned()),
|
||||
PluginStoreError::Invalid(message) => AppError::Invalid(message),
|
||||
other => AppError::Store(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared resolver for installed, runtime-active plugin manifests.
|
||||
pub struct ProjectPluginResolver {
|
||||
settings: Arc<dyn ProjectPluginSettingsStore>,
|
||||
packages: Arc<dyn PluginPackageStore>,
|
||||
registry: Arc<dyn PluginRegistryStore>,
|
||||
validator: Arc<dyn PluginManifestValidator>,
|
||||
}
|
||||
|
||||
impl ProjectPluginResolver {
|
||||
/// Builds the resolver from replaceable ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
settings: Arc<dyn ProjectPluginSettingsStore>,
|
||||
packages: Arc<dyn PluginPackageStore>,
|
||||
registry: Arc<dyn PluginRegistryStore>,
|
||||
validator: Arc<dyn PluginManifestValidator>,
|
||||
) -> Self {
|
||||
Self {
|
||||
settings,
|
||||
packages,
|
||||
registry,
|
||||
validator,
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the persisted project document.
|
||||
pub async fn settings(
|
||||
&self,
|
||||
project_root: &domain::ProjectPath,
|
||||
) -> Result<ProjectPluginSettings, AppError> {
|
||||
self.settings
|
||||
.load(project_root)
|
||||
.await
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
|
||||
/// Resolves one plugin only when enabled for the project and runtime-active
|
||||
/// globally. Missing/uninstalled plugins yield `None`, preserving tombstones.
|
||||
pub async fn enabled_manifest(
|
||||
&self,
|
||||
project_root: &domain::ProjectPath,
|
||||
plugin_id: &PluginId,
|
||||
) -> Result<Option<PluginManifest>, AppError> {
|
||||
let settings = self.settings(project_root).await?;
|
||||
if !settings.is_enabled(plugin_id) {
|
||||
return Ok(None);
|
||||
}
|
||||
let registry = self
|
||||
.registry
|
||||
.load_registry()
|
||||
.await
|
||||
.map_err(|e| AppError::Store(e.to_string()))?;
|
||||
let Some(entry) = registry.find(plugin_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !entry.lifecycle_state.is_runtime_active() {
|
||||
return Ok(None);
|
||||
}
|
||||
let package = self
|
||||
.packages
|
||||
.list_installed()
|
||||
.await
|
||||
.map_err(package_error)?
|
||||
.into_iter()
|
||||
.find(|p| p.plugin_id.as_ref() == Some(plugin_id));
|
||||
let Some(package) = package else {
|
||||
return Ok(None);
|
||||
};
|
||||
let bytes = self
|
||||
.packages
|
||||
.read_manifest(&package)
|
||||
.await
|
||||
.map_err(package_error)?;
|
||||
self.validator
|
||||
.validate(&bytes.bytes, &package)
|
||||
.map(Some)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))
|
||||
}
|
||||
|
||||
/// Reads a contributed Markdown body through the confined package port.
|
||||
pub async fn read_skill_body(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
path: &domain::RelativePath,
|
||||
) -> Result<MarkdownDoc, AppError> {
|
||||
let bytes = self
|
||||
.packages
|
||||
.read_installed_file(plugin_id, path)
|
||||
.await
|
||||
.map_err(package_error)?;
|
||||
let body = String::from_utf8(bytes)
|
||||
.map_err(|_| AppError::Invalid("plugin skill must be UTF-8 Markdown".to_owned()))?;
|
||||
if body.trim().is_empty() {
|
||||
return Err(AppError::Invalid(
|
||||
"plugin skill body must not be empty".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(MarkdownDoc::new(body))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read model consumed by project settings surfaces.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectPluginSettingsView {
|
||||
/// Persisted activation and assignment document.
|
||||
pub settings: ProjectPluginSettings,
|
||||
/// Installed plugin manifests. Runtime failures are omitted from this
|
||||
/// catalogue but their settings remain in `settings` as tombstones.
|
||||
pub installed: Vec<PluginManifest>,
|
||||
}
|
||||
|
||||
/// Lists project plugin settings and the globally installed catalogue.
|
||||
pub struct ListProjectPluginSettings {
|
||||
resolver: Arc<ProjectPluginResolver>,
|
||||
packages: Arc<dyn PluginPackageStore>,
|
||||
validator: Arc<dyn PluginManifestValidator>,
|
||||
}
|
||||
|
||||
impl ListProjectPluginSettings {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
resolver: Arc<ProjectPluginResolver>,
|
||||
packages: Arc<dyn PluginPackageStore>,
|
||||
validator: Arc<dyn PluginManifestValidator>,
|
||||
) -> Self {
|
||||
Self {
|
||||
resolver,
|
||||
packages,
|
||||
validator,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes the read.
|
||||
pub async fn execute(&self, project: Project) -> Result<ProjectPluginSettingsView, AppError> {
|
||||
let settings = self.resolver.settings(&project.root).await?;
|
||||
let mut installed = Vec::new();
|
||||
for package in self
|
||||
.packages
|
||||
.list_installed()
|
||||
.await
|
||||
.map_err(package_error)?
|
||||
{
|
||||
let bytes = self
|
||||
.packages
|
||||
.read_manifest(&package)
|
||||
.await
|
||||
.map_err(package_error)?;
|
||||
if let Ok(manifest) = self.validator.validate(&bytes.bytes, &package) {
|
||||
installed.push(manifest);
|
||||
}
|
||||
}
|
||||
installed.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str()));
|
||||
Ok(ProjectPluginSettingsView {
|
||||
settings,
|
||||
installed,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggles one globally installed plugin for a project.
|
||||
pub struct SetProjectPluginEnabled {
|
||||
store: Arc<dyn ProjectPluginSettingsStore>,
|
||||
packages: Arc<dyn PluginPackageStore>,
|
||||
}
|
||||
|
||||
impl SetProjectPluginEnabled {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn ProjectPluginSettingsStore>,
|
||||
packages: Arc<dyn PluginPackageStore>,
|
||||
) -> Self {
|
||||
Self { store, packages }
|
||||
}
|
||||
|
||||
/// Persists activation. Enabling requires a currently installed plugin;
|
||||
/// disabling a missing plugin remains valid and keeps assignments intact.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
project: Project,
|
||||
plugin_id: PluginId,
|
||||
enabled: bool,
|
||||
) -> Result<ProjectPluginSettings, AppError> {
|
||||
if enabled {
|
||||
let exists = self
|
||||
.packages
|
||||
.list_installed()
|
||||
.await
|
||||
.map_err(package_error)?
|
||||
.iter()
|
||||
.any(|p| p.plugin_id.as_ref() == Some(&plugin_id));
|
||||
if !exists {
|
||||
return Err(AppError::NotFound(format!("plugin {}", plugin_id.as_str())));
|
||||
}
|
||||
}
|
||||
let mut settings = self.store.load(&project.root).await?;
|
||||
match settings
|
||||
.plugins
|
||||
.iter_mut()
|
||||
.find(|p| p.plugin_id == plugin_id)
|
||||
{
|
||||
Some(entry) => entry.enabled = enabled,
|
||||
None => settings
|
||||
.plugins
|
||||
.push(ProjectPluginActivation { plugin_id, enabled }),
|
||||
}
|
||||
self.store.save(&project.root, &settings).await?;
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutation kind shared by skill/tool assignment use cases.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AssignmentMutation {
|
||||
/// Add the capability when it is not already assigned.
|
||||
Assign,
|
||||
/// Remove the capability when it is currently assigned.
|
||||
Unassign,
|
||||
}
|
||||
|
||||
/// Assigns or unassigns one plugin skill while retaining dangling tombstones.
|
||||
pub struct MutateAgentPluginSkill {
|
||||
store: Arc<dyn ProjectPluginSettingsStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
resolver: Arc<ProjectPluginResolver>,
|
||||
}
|
||||
|
||||
impl MutateAgentPluginSkill {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn ProjectPluginSettingsStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
resolver: Arc<ProjectPluginResolver>,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
contexts,
|
||||
resolver,
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies an idempotent mutation.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
project: Project,
|
||||
agent_id: AgentId,
|
||||
reference: PluginSkillRef,
|
||||
mutation: AssignmentMutation,
|
||||
) -> Result<ProjectPluginSettings, AppError> {
|
||||
ensure_agent(&*self.contexts, &project, agent_id).await?;
|
||||
if mutation == AssignmentMutation::Assign {
|
||||
let manifest = self
|
||||
.resolver
|
||||
.enabled_manifest(&project.root, &reference.plugin_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::Invalid(
|
||||
"plugin is not enabled and runtime-active for this project".to_owned(),
|
||||
)
|
||||
})?;
|
||||
if !manifest
|
||||
.contributes
|
||||
.skills
|
||||
.iter()
|
||||
.any(|s| s.id == reference.skill_id)
|
||||
{
|
||||
return Err(AppError::NotFound(format!(
|
||||
"plugin skill {}",
|
||||
reference.skill_id.as_str()
|
||||
)));
|
||||
}
|
||||
}
|
||||
let mut settings = self.store.load(&project.root).await?;
|
||||
let assignments = assignments_mut(&mut settings, agent_id);
|
||||
match mutation {
|
||||
AssignmentMutation::Assign if !assignments.skills.contains(&reference) => {
|
||||
assignments.skills.push(reference)
|
||||
}
|
||||
AssignmentMutation::Unassign => assignments.skills.retain(|r| r != &reference),
|
||||
_ => {}
|
||||
}
|
||||
self.store.save(&project.root, &settings).await?;
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
|
||||
/// Assigns or unassigns one namespaced external MCP tool.
|
||||
pub struct MutateAgentPluginTool {
|
||||
store: Arc<dyn ProjectPluginSettingsStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
resolver: Arc<ProjectPluginResolver>,
|
||||
}
|
||||
|
||||
impl MutateAgentPluginTool {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn ProjectPluginSettingsStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
resolver: Arc<ProjectPluginResolver>,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
contexts,
|
||||
resolver,
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies an idempotent mutation. Assignment validates the enabled server;
|
||||
/// the dynamic tool name is validated when its MCP catalogue is available.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
project: Project,
|
||||
agent_id: AgentId,
|
||||
reference: PluginToolRef,
|
||||
mutation: AssignmentMutation,
|
||||
) -> Result<ProjectPluginSettings, AppError> {
|
||||
ensure_agent(&*self.contexts, &project, agent_id).await?;
|
||||
if reference.tool_name.trim().is_empty() {
|
||||
return Err(AppError::Invalid("plugin tool name is required".to_owned()));
|
||||
}
|
||||
if mutation == AssignmentMutation::Assign {
|
||||
let manifest = self
|
||||
.resolver
|
||||
.enabled_manifest(&project.root, &reference.plugin_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::Invalid(
|
||||
"plugin is not enabled and runtime-active for this project".to_owned(),
|
||||
)
|
||||
})?;
|
||||
if !manifest
|
||||
.contributes
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.any(|s| s.id == reference.server_id)
|
||||
{
|
||||
return Err(AppError::NotFound(format!(
|
||||
"plugin MCP server {}",
|
||||
reference.server_id.as_str()
|
||||
)));
|
||||
}
|
||||
}
|
||||
let mut settings = self.store.load(&project.root).await?;
|
||||
let assignments = assignments_mut(&mut settings, agent_id);
|
||||
match mutation {
|
||||
AssignmentMutation::Assign if !assignments.tools.contains(&reference) => {
|
||||
assignments.tools.push(reference)
|
||||
}
|
||||
AssignmentMutation::Unassign => assignments.tools.retain(|r| r != &reference),
|
||||
_ => {}
|
||||
}
|
||||
self.store.save(&project.root, &settings).await?;
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_agent(
|
||||
store: &dyn AgentContextStore,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
) -> Result<(), AppError> {
|
||||
if store
|
||||
.load_manifest(project)
|
||||
.await?
|
||||
.entries
|
||||
.iter()
|
||||
.any(|e| e.agent_id == agent_id)
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::NotFound(format!("agent {agent_id}")))
|
||||
}
|
||||
}
|
||||
|
||||
fn assignments_mut(
|
||||
settings: &mut ProjectPluginSettings,
|
||||
agent_id: AgentId,
|
||||
) -> &mut AgentPluginAssignments {
|
||||
if let Some(index) = settings.agents.iter().position(|a| a.agent_id == agent_id) {
|
||||
return &mut settings.agents[index];
|
||||
}
|
||||
settings.agents.push(AgentPluginAssignments {
|
||||
agent_id,
|
||||
skills: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
});
|
||||
settings
|
||||
.agents
|
||||
.last_mut()
|
||||
.expect("assignment was just inserted")
|
||||
}
|
||||
|
||||
/// Convenience constructors for driving adapters accepting string ids.
|
||||
pub fn plugin_skill_ref(plugin_id: String, skill_id: String) -> Result<PluginSkillRef, AppError> {
|
||||
Ok(PluginSkillRef {
|
||||
plugin_id: PluginId::new(plugin_id).map_err(|e| AppError::Invalid(e.to_string()))?,
|
||||
skill_id: PluginSkillId::new(skill_id).map_err(|e| AppError::Invalid(e.to_string()))?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convenience constructor for a namespaced plugin tool reference.
|
||||
pub fn plugin_tool_ref(
|
||||
plugin_id: String,
|
||||
server_id: String,
|
||||
tool_name: String,
|
||||
) -> Result<PluginToolRef, AppError> {
|
||||
Ok(PluginToolRef {
|
||||
plugin_id: PluginId::new(plugin_id).map_err(|e| AppError::Invalid(e.to_string()))?,
|
||||
server_id: PluginMcpServerId::new(server_id)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?,
|
||||
tool_name,
|
||||
})
|
||||
}
|
||||
@ -16,6 +16,7 @@ use domain::{
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::ProjectPluginResolver;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateSkill
|
||||
@ -293,13 +294,24 @@ impl ResolveAgentCapabilities {
|
||||
/// context rendering and `idea_skill_read` authorization.
|
||||
pub struct AssignedSkillResolver {
|
||||
skills: Arc<dyn SkillStore>,
|
||||
plugin_skills: Option<Arc<ProjectPluginResolver>>,
|
||||
}
|
||||
|
||||
impl AssignedSkillResolver {
|
||||
/// Builds the resolver from the existing skill store port.
|
||||
#[must_use]
|
||||
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
|
||||
Self { skills }
|
||||
Self {
|
||||
skills,
|
||||
plugin_skills: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the provider-agnostic project-plugin resolver.
|
||||
#[must_use]
|
||||
pub fn with_plugin_skills(mut self, plugin_skills: Arc<ProjectPluginResolver>) -> Self {
|
||||
self.plugin_skills = Some(plugin_skills);
|
||||
self
|
||||
}
|
||||
|
||||
/// Resolves the assigned skills for `agent` in manifest order.
|
||||
@ -342,6 +354,7 @@ impl AssignedSkillResolver {
|
||||
let snapshot = AssignedSkillSnapshot {
|
||||
skill_id: skill.id,
|
||||
scope: skill.scope,
|
||||
plugin: None,
|
||||
name: skill.name,
|
||||
description,
|
||||
kind: skill.kind,
|
||||
@ -353,6 +366,52 @@ impl AssignedSkillResolver {
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
if let Some(resolver) = &self.plugin_skills {
|
||||
let settings = resolver.settings(root).await?;
|
||||
if let Some(assignments) = settings.assignments_for(agent.id) {
|
||||
for (offset, reference) in assignments.skills.iter().enumerate() {
|
||||
let Some(manifest) = resolver
|
||||
.enabled_manifest(root, &reference.plugin_id)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(skill) = manifest
|
||||
.contributes
|
||||
.skills
|
||||
.iter()
|
||||
.find(|s| s.id == reference.skill_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let content = match resolver
|
||||
.read_skill_body(&reference.plugin_id, &skill.path)
|
||||
.await
|
||||
{
|
||||
Ok(content) => content,
|
||||
Err(AppError::NotFound(_)) => continue,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
assigned.push((
|
||||
AssignedSkillSnapshot {
|
||||
// Native fields remain populated for backwards wire compatibility;
|
||||
// plugin identity is authoritative when `plugin` is present.
|
||||
skill_id: SkillId::from_uuid(uuid::Uuid::nil()),
|
||||
scope: SkillScope::Project,
|
||||
plugin: Some(domain::orchestrator::AssignedPluginSkillSource {
|
||||
plugin_id: reference.plugin_id.clone(),
|
||||
skill_id: reference.skill_id.clone(),
|
||||
}),
|
||||
name: skill.name.clone(),
|
||||
description: skill.description.clone(),
|
||||
kind: skill.kind,
|
||||
assignment_index: (agent.skills.len() + offset) as u32,
|
||||
},
|
||||
content,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(assigned)
|
||||
}
|
||||
|
||||
@ -382,6 +441,25 @@ impl AssignedSkillResolver {
|
||||
)))
|
||||
}
|
||||
};
|
||||
if let Some(plugin) = &selected.plugin {
|
||||
let resolver = self
|
||||
.plugin_skills
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::NotFound(format!("assigned plugin skill `{name}`")))?;
|
||||
let manifest = resolver
|
||||
.enabled_manifest(root, &plugin.plugin_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("assigned plugin skill `{name}`")))?;
|
||||
let contribution = manifest
|
||||
.contributes
|
||||
.skills
|
||||
.iter()
|
||||
.find(|s| s.id == plugin.skill_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("assigned plugin skill `{name}`")))?;
|
||||
return resolver
|
||||
.read_skill_body(&plugin.plugin_id, &contribution.path)
|
||||
.await;
|
||||
}
|
||||
let skill = self
|
||||
.skills
|
||||
.get(selected.scope, root, selected.skill_id)
|
||||
|
||||
Reference in New Issue
Block a user