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

@ -110,8 +110,8 @@ pub use chat_attachment::{
pub use skill::{Skill, SkillKind, SkillRef, SkillScope};
pub use slash_command::{
filter_slash_commands, native_slash_commands, NativeSlashCommand, SlashCommand,
SlashCommandAvailability, SlashCommandSource,
filter_slash_commands, native_slash_commands, plugin_slash_command, NativeSlashCommand,
PluginSlashCommandCallback, SlashCommand, SlashCommandAvailability, SlashCommandSource,
};
pub use template::{AgentTemplate, TemplateVersion};
@ -234,8 +234,9 @@ pub use plugin::{
PluginInstallSource, PluginLayoutContribution, PluginLayoutType, PluginLifecycleState,
PluginManifest, PluginMcpServerContribution, PluginMcpServerId, PluginMcpServerSpec,
PluginMcpStatus, PluginMcpStatusSet, PluginMenuItemContribution, PluginPackageRef,
PluginRegistry, PluginRegistryEntry, PluginTopLevelMenuContribution, PluginTrustLevel,
PluginVersion, RelativePath, RemovalOutcome, StagedPluginPackage,
PluginRegistry, PluginRegistryEntry, PluginSlashCommandContribution,
PluginTopLevelMenuContribution, PluginTrustLevel, PluginVersion, RelativePath, RemovalOutcome,
StagedPluginPackage,
};
pub use sandbox::{

View File

@ -411,6 +411,24 @@ pub struct PluginMenuItemContribution {
pub when: Option<String>,
}
/// Slash-command contribution backed by a plugin command callback.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginSlashCommandContribution {
/// Slash name, including the leading `/`.
pub name: String,
/// Short autocomplete/help description.
pub short_description: String,
/// Plugin command callback id registered through the SDK command registry.
pub command: PluginCommandId,
/// Whether the host should ask for confirmation before dispatching callback.
#[serde(default)]
pub requires_confirmation: bool,
/// Optional declarative condition reserved for host-side availability.
#[serde(default)]
pub when: Option<String>,
}
/// Layout contribution.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -472,6 +490,9 @@ pub struct PluginContributionSet {
/// Menu items.
#[serde(default)]
pub menu_items: Vec<PluginMenuItemContribution>,
/// Slash commands.
#[serde(default)]
pub slash_commands: Vec<PluginSlashCommandContribution>,
/// Layout contributions.
#[serde(default)]
pub layouts: Vec<PluginLayoutContribution>,

View File

@ -17,6 +17,7 @@ pub enum SlashCommandSource {
/// Contributed by a plugin.
Plugin {
/// Opaque plugin id.
#[serde(rename = "pluginId")]
plugin_id: String,
},
}
@ -54,6 +55,16 @@ pub enum NativeSlashCommand {
Profile,
}
/// Plugin callback identity for a contributed slash command.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginSlashCommandCallback {
/// Opaque plugin id.
pub plugin_id: String,
/// SDK command callback id registered by the plugin.
pub command_id: String,
}
/// UI-facing slash-command metadata.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -71,6 +82,9 @@ pub struct SlashCommand {
/// Built-in command discriminator when `source == native`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub native: Option<NativeSlashCommand>,
/// Plugin callback identity when `source == plugin`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugin: Option<PluginSlashCommandCallback>,
}
impl SlashCommand {
@ -86,6 +100,7 @@ impl SlashCommand {
availability: SlashCommandAvailability,
source: SlashCommandSource,
native: Option<NativeSlashCommand>,
plugin: Option<PluginSlashCommandCallback>,
) -> Result<Self, DomainError> {
let name = name.into();
let short_description = short_description.into();
@ -103,6 +118,7 @@ impl SlashCommand {
availability,
source,
native,
plugin,
})
}
}
@ -118,6 +134,7 @@ pub fn native_slash_commands() -> Vec<SlashCommand> {
SlashCommandAvailability::Available,
SlashCommandSource::Native,
Some(NativeSlashCommand::Help),
None,
)
.expect("valid native slash command"),
SlashCommand::new(
@ -127,6 +144,7 @@ pub fn native_slash_commands() -> Vec<SlashCommand> {
SlashCommandAvailability::Available,
SlashCommandSource::Native,
Some(NativeSlashCommand::Clean),
None,
)
.expect("valid native slash command"),
SlashCommand::new(
@ -136,11 +154,41 @@ pub fn native_slash_commands() -> Vec<SlashCommand> {
SlashCommandAvailability::Available,
SlashCommandSource::Native,
Some(NativeSlashCommand::Profile),
None,
)
.expect("valid native slash command"),
]
}
/// Builds validated metadata for a plugin-contributed slash command.
pub fn plugin_slash_command(
plugin_id: impl Into<String>,
command_id: impl Into<String>,
name: impl Into<String>,
short_description: impl Into<String>,
requires_confirmation: bool,
availability: SlashCommandAvailability,
) -> Result<SlashCommand, DomainError> {
let plugin_id = plugin_id.into();
let command_id = command_id.into();
crate::validation::non_empty(&plugin_id, "slash_command.plugin_id")?;
crate::validation::non_empty(&command_id, "slash_command.command_id")?;
SlashCommand::new(
name,
short_description,
requires_confirmation,
availability,
SlashCommandSource::Plugin {
plugin_id: plugin_id.clone(),
},
None,
Some(PluginSlashCommandCallback {
plugin_id,
command_id,
}),
)
}
/// Filters commands by a user-entered slash prefix.
///
/// Blank prefixes return every command. Prefixes without a leading `/` are
@ -180,6 +228,7 @@ mod tests {
assert!(commands[1].availability.is_available());
assert!(commands[2].availability.is_available());
assert!(commands[2].requires_confirmation);
assert!(commands.iter().all(|c| c.plugin.is_none()));
}
#[test]
@ -196,4 +245,24 @@ mod tests {
.collect::<Vec<_>>();
assert_eq!(names, vec!["/profile"]);
}
#[test]
fn plugin_command_carries_callback_identity() {
let command = plugin_slash_command(
"dev.acme.plugin",
"dev.acme.plugin.explain",
"/explain",
"Explain selection",
true,
SlashCommandAvailability::Available,
)
.unwrap();
assert_eq!(command.name, "/explain");
assert!(command.native.is_none());
assert_eq!(
command.plugin.unwrap().command_id,
"dev.acme.plugin.explain"
);
}
}