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:
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user