feat(slash): fondation du système de commandes slash pour la CLI custom — #162 (QA verte)

Introduit le contrat unifié des commandes slash consommable par le frontend,
indépendant de la source (native, puis plugin plus tard) :

- domain: modèle pur SlashCommand / SlashCommandSource / SlashCommandAvailability,
  métadonnées UI (nom, description, disponibilité, confirmation requise) et
  validation des noms/préfixes.
- application: registry + list/filter par préfixe + plan d'exécution natif ;
  commandes natives /help et /clean (/clean = nettoyage de la vue de session
  courante ; pas de /reset distinct tant qu'aucune utilité produit ne le justifie).
- backend + app-tauri: exposition du contrat via DTO/transport + tests DTO.

Source neutre : les commandes contribuées par plugins (#165) transiteront par
le même registry sans changer le contrat frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 14:52:36 +02:00
parent 4e57161e1d
commit 6997138a71
11 changed files with 793 additions and 49 deletions

View File

@ -32,6 +32,7 @@ pub mod plugin;
pub mod project;
pub mod remote;
pub mod skill;
pub mod slash_command;
pub mod sprints;
pub mod system_permissions;
pub mod template;
@ -194,6 +195,10 @@ pub use skill::{
ResolveAgentCapabilitiesInput, UnassignSkillFromAgent, UnassignSkillFromAgentInput,
UpdateSkill, UpdateSkillInput, UpdateSkillOutput,
};
pub use slash_command::{
ExecuteSlashCommand, ExecuteSlashCommandInput, ExecuteSlashCommandOutput, ListSlashCommands,
ListSlashCommandsInput, ListSlashCommandsOutput, SlashCommandEffect, SlashCommandRegistry,
};
pub use sprints::{
normalized_reorder, AssignTicketToSprint, AssignTicketToSprintInput,
AssignTicketToSprintOutput, CreateSprint, CreateSprintInput, CreateSprintOutput, DeleteSprint,

View File

@ -0,0 +1,256 @@
//! Slash-command registry and native execution planning.
//!
//! The registry is transport-neutral and can later receive plugin-provided
//! commands without changing the frontend-facing list/filter contract.
use domain::{
filter_slash_commands, native_slash_commands, NativeSlashCommand, SessionId, SlashCommand,
SlashCommandAvailability,
};
use crate::AppError;
/// Lists slash commands.
#[derive(Debug, Clone, Default)]
pub struct ListSlashCommandsInput {
/// Optional typed prefix, with or without the leading `/`.
pub prefix: Option<String>,
}
/// Slash-command list output.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListSlashCommandsOutput {
/// Filtered commands in deterministic UI order.
pub commands: Vec<SlashCommand>,
}
/// Executes a native slash command.
#[derive(Debug, Clone)]
pub struct ExecuteSlashCommandInput {
/// Command name, with or without the leading `/`.
pub name: String,
/// Current structured chat session, required by conversation-local commands.
pub session_id: Option<SessionId>,
}
/// Execution output: application decides intent; adapters perform transport-only
/// side effects when needed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExecuteSlashCommandOutput {
/// Canonical command name.
pub command: SlashCommand,
/// Planned effect.
pub effect: SlashCommandEffect,
}
/// Native command effects understood by driving adapters and the UI.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SlashCommandEffect {
/// Return the unified command list.
Help {
/// Commands to display.
commands: Vec<SlashCommand>,
},
/// Clear one live structured conversation.
CleanConversation {
/// Session whose retained conversation view must be cleared.
session_id: SessionId,
},
/// Placeholder for ticket #164.
ProfileSwitch {
/// Session whose agent/profile flow is targeted.
session_id: SessionId,
},
}
/// In-memory slash-command registry.
#[derive(Debug, Clone, Default)]
pub struct SlashCommandRegistry {
plugin_commands: Vec<SlashCommand>,
}
impl SlashCommandRegistry {
/// Creates an empty registry with IdeA native commands.
#[must_use]
pub const fn new() -> Self {
Self {
plugin_commands: Vec::new(),
}
}
/// Returns all commands from all sources in deterministic order.
#[must_use]
pub fn all(&self) -> Vec<SlashCommand> {
let mut commands = native_slash_commands();
commands.extend(self.plugin_commands.clone());
commands
}
/// Returns commands filtered by prefix.
#[must_use]
pub fn list(&self, input: ListSlashCommandsInput) -> ListSlashCommandsOutput {
ListSlashCommandsOutput {
commands: filter_slash_commands(self.all(), input.prefix.as_deref()),
}
}
/// Plans execution for a command.
///
/// # Errors
/// Returns [`AppError::NotFound`] for an unknown command, [`AppError::Invalid`]
/// for unavailable commands or missing command-local context.
pub fn execute(
&self,
input: ExecuteSlashCommandInput,
) -> Result<ExecuteSlashCommandOutput, AppError> {
let name = normalize_command_name(&input.name)?;
let command = self
.all()
.into_iter()
.find(|command| command.name == name)
.ok_or_else(|| AppError::NotFound(format!("slash command {name}")))?;
if let SlashCommandAvailability::Unavailable { reason } = &command.availability {
return Err(AppError::Invalid(format!(
"slash command {name} unavailable: {reason}"
)));
}
let Some(native) = command.native else {
return Err(AppError::Invalid(format!(
"slash command {name} has no executable adapter"
)));
};
let effect = match native {
NativeSlashCommand::Help => SlashCommandEffect::Help {
commands: self.all(),
},
NativeSlashCommand::Clean => SlashCommandEffect::CleanConversation {
session_id: input.session_id.ok_or_else(|| {
AppError::Invalid("/clean requires a current session id".to_owned())
})?,
},
NativeSlashCommand::Profile => SlashCommandEffect::ProfileSwitch {
session_id: input.session_id.ok_or_else(|| {
AppError::Invalid("/profile requires a current session id".to_owned())
})?,
},
};
Ok(ExecuteSlashCommandOutput { command, effect })
}
}
fn normalize_command_name(name: &str) -> Result<String, AppError> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err(AppError::Invalid("slash command name is empty".to_owned()));
}
Ok(if trimmed.starts_with('/') {
trimmed.to_owned()
} else {
format!("/{trimmed}")
})
}
/// Use case wrapper for list/filter.
#[derive(Debug, Clone, Default)]
pub struct ListSlashCommands {
registry: SlashCommandRegistry,
}
impl ListSlashCommands {
/// Builds the use case.
#[must_use]
pub const fn new(registry: SlashCommandRegistry) -> Self {
Self { registry }
}
/// Executes the use case.
#[must_use]
pub fn execute(&self, input: ListSlashCommandsInput) -> ListSlashCommandsOutput {
self.registry.list(input)
}
}
/// Use case wrapper for execution planning.
#[derive(Debug, Clone, Default)]
pub struct ExecuteSlashCommand {
registry: SlashCommandRegistry,
}
impl ExecuteSlashCommand {
/// Builds the use case.
#[must_use]
pub const fn new(registry: SlashCommandRegistry) -> Self {
Self { registry }
}
/// Executes the use case.
pub fn execute(
&self,
input: ExecuteSlashCommandInput,
) -> Result<ExecuteSlashCommandOutput, AppError> {
self.registry.execute(input)
}
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
fn session() -> SessionId {
SessionId::from_uuid(Uuid::from_u128(42))
}
#[test]
fn list_filters_native_commands_by_prefix() {
let out =
ListSlashCommands::new(SlashCommandRegistry::new()).execute(ListSlashCommandsInput {
prefix: Some("he".to_owned()),
});
assert_eq!(out.commands.len(), 1);
assert_eq!(out.commands[0].name, "/help");
}
#[test]
fn help_returns_unified_command_list() {
let out = ExecuteSlashCommand::new(SlashCommandRegistry::new())
.execute(ExecuteSlashCommandInput {
name: "help".to_owned(),
session_id: None,
})
.unwrap();
assert_eq!(out.command.name, "/help");
let SlashCommandEffect::Help { commands } = out.effect else {
panic!("help effect expected");
};
assert!(commands.iter().any(|command| command.name == "/clean"));
}
#[test]
fn clean_requires_and_returns_current_session() {
let sid = session();
let out = ExecuteSlashCommand::new(SlashCommandRegistry::new())
.execute(ExecuteSlashCommandInput {
name: "/clean".to_owned(),
session_id: Some(sid),
})
.unwrap();
assert_eq!(
out.effect,
SlashCommandEffect::CleanConversation { session_id: sid }
);
}
#[test]
fn profile_is_known_but_not_executable_yet() {
let err = ExecuteSlashCommand::new(SlashCommandRegistry::new())
.execute(ExecuteSlashCommandInput {
name: "/profile".to_owned(),
session_id: Some(session()),
})
.unwrap_err();
assert!(matches!(err, AppError::Invalid(message) if message.contains("unavailable")));
}
}