feat(plugins): seam slash-commands contribuées par plugin via callback — #165 (QA verte)

Permet à un plugin de contribuer des commandes slash adossées à une callback,
transitant par le registry/contrat unifié (#162) :

- sdk/IdeaSDK: manifeste contributes.slashCommands (déclaratif) — pointer bumpé.
- domain: source Plugin étendue + effet PluginCallback (la commande ne
  s'exécute pas directement : elle renvoie une référence à la callback du plugin).
- application: registration des commandes plugin dans le registry + exécution
  renvoyant l'effet PluginCallback ; métadonnées UI suffisantes pour l'autocomplete.
- backend + app-tauri: DTO + commandes Tauri listant/invoquant les commandes plugin.
- frontend (contrat): types adapters/domain/ports/mock pour pluginCallback et
  l'invocation avec arguments.

L'exécution effective de la callback côté runtime UI est livrée par #166.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 15:46:52 +02:00
parent ff7696b724
commit fbaca9d5cc
17 changed files with 514 additions and 34 deletions

View File

@ -196,8 +196,9 @@ pub use skill::{
UpdateSkill, UpdateSkillInput, UpdateSkillOutput,
};
pub use slash_command::{
ExecuteSlashCommand, ExecuteSlashCommandInput, ExecuteSlashCommandOutput, ListSlashCommands,
ListSlashCommandsInput, ListSlashCommandsOutput, SlashCommandEffect, SlashCommandRegistry,
plugin_slash_commands_from_runtime_catalog, ExecuteSlashCommand, ExecuteSlashCommandInput,
ExecuteSlashCommandOutput, ListSlashCommands, ListSlashCommandsInput, ListSlashCommandsOutput,
SlashCommandEffect, SlashCommandRegistry,
};
pub use sprints::{
normalized_reorder, AssignTicketToSprint, AssignTicketToSprintInput,

View File

@ -31,6 +31,8 @@ pub struct PluginContributionSummary {
pub top_level_menus: usize,
/// Menu item count.
pub menu_items: usize,
/// Slash-command count.
pub slash_commands: usize,
/// Layout count.
pub layouts: usize,
/// MCP server count.
@ -42,6 +44,7 @@ impl From<&PluginContributionSet> for PluginContributionSummary {
Self {
top_level_menus: c.menus.len(),
menu_items: c.menu_items.len(),
slash_commands: c.slash_commands.len(),
layouts: c.layouts.len(),
mcp_servers: c.mcp_servers.len(),
}
@ -3088,6 +3091,8 @@ struct RawContributes {
#[serde(default)]
menu_items: Vec<RawMenuItem>,
#[serde(default)]
slash_commands: Vec<RawSlashCommand>,
#[serde(default)]
layouts: Vec<RawLayout>,
#[serde(default)]
mcp_servers: Vec<RawMcpServer>,
@ -3120,6 +3125,18 @@ struct RawMenuItem {
when: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawSlashCommand {
name: String,
short_description: String,
command: String,
#[serde(default)]
requires_confirmation: bool,
#[serde(default)]
when: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawLayout {
@ -3283,6 +3300,31 @@ fn validate_contributes(raw: RawContributes) -> Result<PluginContributionSet, Pl
})
})
.collect::<Result<Vec<_>, _>>()?;
let slash_commands = raw
.slash_commands
.into_iter()
.map(|s| {
insert(&s.name)?;
if !s.name.starts_with('/') {
return Err(PluginManifestError::Invalid(
"slashCommands[].name must start with '/'".to_owned(),
));
}
if s.short_description.trim().is_empty() {
return Err(PluginManifestError::Invalid(
"slashCommands[].shortDescription is required".to_owned(),
));
}
Ok(domain::PluginSlashCommandContribution {
name: s.name,
short_description: s.short_description,
command: domain::PluginCommandId::new(s.command)
.map_err(|e| PluginManifestError::Invalid(e.to_string()))?,
requires_confirmation: s.requires_confirmation,
when: s.when,
})
})
.collect::<Result<Vec<_>, _>>()?;
let layouts = raw
.layouts
.into_iter()
@ -3353,6 +3395,7 @@ fn validate_contributes(raw: RawContributes) -> Result<PluginContributionSet, Pl
Ok(PluginContributionSet {
menus,
menu_items,
slash_commands,
layouts,
mcp_servers,
})
@ -3423,6 +3466,7 @@ mod tests {
"contributes": {
"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}],
"layouts": [{"type":"dev.acme.layout","label":"Graph","component":"Graph"}],
"mcpServers": [{"id":"dev.acme.mcp","displayName":"Tools","command":"servers/tool","transport":"stdio","autoStart":true}]
}
@ -3745,6 +3789,9 @@ mod tests {
);
assert_eq!(m.contributes.layouts.len(), 1);
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]
@ -3809,6 +3856,21 @@ mod tests {
.is_err());
}
#[test]
fn rejects_slash_command_without_leading_slash() {
let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();
value["contributes"]["slashCommands"][0]["name"] = serde_json::json!("graph");
assert!(validator()
.validate(
&serde_json::to_vec(&value).unwrap(),
&domain::PluginPackageRef {
plugin_id: None,
root: "x".into()
}
)
.is_err());
}
#[tokio::test]
async fn runtime_catalog_excludes_disabled_and_pending_uninstall_plugins() {
for state in [
@ -3849,6 +3911,7 @@ mod tests {
domain::PluginCapability::Tooling,
]
);
assert_eq!(catalog.plugins[0].contributes.slash_commands.len(), 1);
}
#[tokio::test]

View File

@ -4,11 +4,12 @@
//! commands without changing the frontend-facing list/filter contract.
use domain::{
filter_slash_commands, native_slash_commands, NativeSlashCommand, SessionId, SlashCommand,
SlashCommandAvailability,
filter_slash_commands, native_slash_commands, plugin_slash_command, NativeSlashCommand,
SessionId, SlashCommand, SlashCommandAvailability, SlashCommandSource,
};
use serde_json::Value;
use crate::AppError;
use crate::{AppError, PluginRuntimeCatalog};
/// Lists slash commands.
#[derive(Debug, Clone, Default)]
@ -31,6 +32,8 @@ pub struct ExecuteSlashCommandInput {
pub name: String,
/// Current structured chat session, required by conversation-local commands.
pub session_id: Option<SessionId>,
/// Opaque arguments forwarded to plugin callbacks.
pub arguments: Vec<Value>,
}
/// Execution output: application decides intent; adapters perform transport-only
@ -61,6 +64,17 @@ pub enum SlashCommandEffect {
/// Session whose agent/profile flow is targeted.
session_id: SessionId,
},
/// Dispatch to a plugin-owned callback. The plugin decides what the callback does.
PluginCallback {
/// Plugin id owning the callback.
plugin_id: String,
/// SDK command callback id.
command_id: String,
/// Current structured chat session, when available.
session_id: Option<SessionId>,
/// Opaque arguments supplied by the caller.
arguments: Vec<Value>,
},
}
/// In-memory slash-command registry.
@ -78,6 +92,12 @@ impl SlashCommandRegistry {
}
}
/// Creates a registry with already-projected plugin commands.
#[must_use]
pub fn with_plugin_commands(plugin_commands: Vec<SlashCommand>) -> Self {
Self { plugin_commands }
}
/// Returns all commands from all sources in deterministic order.
#[must_use]
pub fn all(&self) -> Vec<SlashCommand> {
@ -115,6 +135,18 @@ impl SlashCommandRegistry {
)));
}
if let Some(plugin) = command.plugin.clone() {
return Ok(ExecuteSlashCommandOutput {
command,
effect: SlashCommandEffect::PluginCallback {
plugin_id: plugin.plugin_id,
command_id: plugin.command_id,
session_id: input.session_id,
arguments: input.arguments,
},
});
}
let Some(native) = command.native else {
return Err(AppError::Invalid(format!(
"slash command {name} has no executable adapter"
@ -170,6 +202,16 @@ impl ListSlashCommands {
pub fn execute(&self, input: ListSlashCommandsInput) -> ListSlashCommandsOutput {
self.registry.list(input)
}
/// Executes the use case with runtime plugin commands merged into the registry.
#[must_use]
pub fn execute_with_plugins(
&self,
input: ListSlashCommandsInput,
plugin_commands: Vec<SlashCommand>,
) -> ListSlashCommandsOutput {
SlashCommandRegistry::with_plugin_commands(plugin_commands).list(input)
}
}
/// Use case wrapper for execution planning.
@ -192,6 +234,51 @@ impl ExecuteSlashCommand {
) -> Result<ExecuteSlashCommandOutput, AppError> {
self.registry.execute(input)
}
/// Executes the use case with runtime plugin commands merged into the registry.
pub fn execute_with_plugins(
&self,
input: ExecuteSlashCommandInput,
plugin_commands: Vec<SlashCommand>,
) -> Result<ExecuteSlashCommandOutput, AppError> {
SlashCommandRegistry::with_plugin_commands(plugin_commands).execute(input)
}
}
/// Projects active plugin runtime contributions into slash-command metadata.
///
/// The callback is not executed here. Execution returns [`SlashCommandEffect::PluginCallback`]
/// so the host SDK runtime can dispatch the plugin-owned handler.
pub fn plugin_slash_commands_from_runtime_catalog(
catalog: &PluginRuntimeCatalog,
) -> Result<Vec<SlashCommand>, AppError> {
let mut commands = Vec::new();
for plugin in &catalog.plugins {
for slash in &plugin.contributes.slash_commands {
let command = plugin_slash_command(
plugin.id.clone(),
slash.command.as_str().to_owned(),
slash.name.clone(),
slash.short_description.clone(),
slash.requires_confirmation,
if slash.when.as_deref().is_some_and(str::is_empty) {
SlashCommandAvailability::Unavailable {
reason: "Plugin slash command condition is empty".to_owned(),
}
} else {
SlashCommandAvailability::Available
},
)
.map_err(|e| AppError::Invalid(e.to_string()))?;
if !matches!(command.source, SlashCommandSource::Plugin { .. }) {
return Err(AppError::Invalid(
"plugin slash command must use plugin source".to_owned(),
));
}
commands.push(command);
}
}
Ok(commands)
}
#[cfg(test)]
@ -219,6 +306,7 @@ mod tests {
.execute(ExecuteSlashCommandInput {
name: "help".to_owned(),
session_id: None,
arguments: Vec::new(),
})
.unwrap();
assert_eq!(out.command.name, "/help");
@ -235,6 +323,7 @@ mod tests {
.execute(ExecuteSlashCommandInput {
name: "/clean".to_owned(),
session_id: Some(sid),
arguments: Vec::new(),
})
.unwrap();
assert_eq!(
@ -250,6 +339,7 @@ mod tests {
.execute(ExecuteSlashCommandInput {
name: "/profile".to_owned(),
session_id: Some(sid),
arguments: Vec::new(),
})
.unwrap();
assert_eq!(
@ -257,4 +347,85 @@ mod tests {
SlashCommandEffect::ProfileSwitch { session_id: sid }
);
}
#[test]
fn plugin_command_execution_returns_callback_effect() {
let command = plugin_slash_command(
"dev.acme.plugin",
"dev.acme.plugin.explain",
"/explain",
"Explain selection",
false,
SlashCommandAvailability::Available,
)
.unwrap();
let out = ExecuteSlashCommand::new(SlashCommandRegistry::new())
.execute_with_plugins(
ExecuteSlashCommandInput {
name: "/explain".to_owned(),
session_id: Some(session()),
arguments: vec![serde_json::json!({ "selection": "abc" })],
},
vec![command],
)
.unwrap();
let SlashCommandEffect::PluginCallback {
plugin_id,
command_id,
session_id,
arguments,
} = out.effect
else {
panic!("plugin callback effect expected");
};
assert_eq!(plugin_id, "dev.acme.plugin");
assert_eq!(command_id, "dev.acme.plugin.explain");
assert_eq!(session_id, Some(session()));
assert_eq!(arguments, vec![serde_json::json!({ "selection": "abc" })]);
}
#[test]
fn runtime_catalog_plugin_slash_commands_feed_unified_registry() {
let catalog = PluginRuntimeCatalog {
plugins: vec![crate::PluginRuntimePlugin {
id: "dev.acme.plugin".to_owned(),
display_name: "Acme".to_owned(),
publisher: None,
version: "1.0.0".to_owned(),
bundle_url: "idea-plugin://dev.acme.plugin/1.0.0/hash/dist/index.js".to_owned(),
icon_url: None,
content_hash: "abc".to_owned(),
capabilities: vec![domain::PluginCapability::Ui],
activation_scope: domain::PluginActivationScope::App,
contributes: domain::PluginContributionSet {
slash_commands: vec![domain::PluginSlashCommandContribution {
name: "/explain".to_owned(),
short_description: "Explain selection".to_owned(),
command: domain::PluginCommandId::new("dev.acme.plugin.explain").unwrap(),
requires_confirmation: true,
when: None,
}],
..domain::PluginContributionSet::default()
},
}],
};
let commands = plugin_slash_commands_from_runtime_catalog(&catalog).unwrap();
let listed = ListSlashCommands::new(SlashCommandRegistry::new()).execute_with_plugins(
ListSlashCommandsInput {
prefix: Some("exp".to_owned()),
},
commands,
);
assert_eq!(listed.commands.len(), 1);
assert_eq!(listed.commands[0].name, "/explain");
assert_eq!(
listed.commands[0].plugin.as_ref().unwrap().command_id,
"dev.acme.plugin.explain"
);
assert!(listed.commands[0].requires_confirmation);
}
}