1use crate::capabilities::{Skill, SkillLibrary, is_unfit_for_skill};
2use std::collections::BTreeMap;
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum SlashCommandSource {
7 Builtin,
8 Project(PathBuf),
9 User(PathBuf),
10 Skill(String),
11 Plugin(String),
12}
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct SlashCommand {
16 pub name: String,
17 pub description: String,
18 pub body: String,
19 pub source: SlashCommandSource,
20}
21
22const BUILTINS: &[(&str, &str, &str)] = &[
23 (
24 "help",
25 "Show available Sparrow slash commands.",
26 "List commands and short usage.",
27 ),
28 (
29 "plan",
30 "Create a read-only plan before running a task.",
31 "Usage: /plan <task>",
32 ),
33 (
34 "permissions",
35 "Inspect or change permission policy.",
36 "Open permissions workflow.",
37 ),
38 (
39 "memory",
40 "Inspect or manage persistent memory.",
41 "Open memory workflow.",
42 ),
43 (
44 "compact",
45 "Compact current context into a durable handoff.",
46 "Create a session summary.",
47 ),
48 (
49 "model",
50 "Inspect or change routing/model configuration.",
51 "Open model workflow.",
52 ),
53 (
54 "agents",
55 "List and mention configured agents.",
56 "Open agent workflow.",
57 ),
58 (
59 "agent",
60 "Manage persistent Sparrow agents.",
61 "Usage: /agent <create|list|show|delete|edit|export|import|default|route|doctor|materialize> ...",
62 ),
63 (
64 "sessions",
65 "List or resume saved sessions.",
66 "Open session workflow.",
67 ),
68 (
69 "export",
70 "Export transcript, events, and artifacts.",
71 "Export current run/session.",
72 ),
73 (
74 "run",
75 "Run an agentic task from the WebView.",
76 "Usage: /run <task>",
77 ),
78 (
79 "launch",
80 "Start the first-run setup if needed, then open the WebView cockpit.",
81 "Terminal: sparrow launch [--port 9339] [--tui]",
82 ),
83 (
84 "models",
85 "List configured providers and discovered models.",
86 "Usage: /models",
87 ),
88 (
89 "config",
90 "Open provider and routing configuration.",
91 "Usage: /config",
92 ),
93 (
94 "tools",
95 "List available toolsets and tool schemas.",
96 "Usage: /tools",
97 ),
98 (
99 "security",
100 "Show the current security and audit state.",
101 "Usage: /security",
102 ),
103 (
104 "status",
105 "Show active run, budget, and session status.",
106 "Usage: /status",
107 ),
108 (
109 "plugins",
110 "List installed Sparrow plugins.",
111 "Usage: /plugins",
112 ),
113 (
114 "skills",
115 "List or manage reusable Sparrow skills.",
116 "Usage: /skills list",
117 ),
118 (
119 "agents",
120 "List and mention configured agents.",
121 "Usage: /agents",
122 ),
123 (
124 "sessions",
125 "List or resume saved sessions.",
126 "Usage: /sessions",
127 ),
128 (
129 "routing",
130 "Inspect routing preferences and fallbacks.",
131 "Usage: /routing",
132 ),
133 (
134 "route",
135 "Configure intelligent auto-routing.",
136 "Usage: /route <show|set|reset|prefer|discover>",
137 ),
138 ("auth", "Manage provider credentials.", "Usage: /auth list"),
139 (
140 "schedule",
141 "Schedule a periodic Sparrow task.",
142 "Usage: /schedule <task> --cron <expr>",
143 ),
144 (
145 "github",
146 "Run GitHub workflow helpers.",
147 "Usage: /github <action>",
148 ),
149 (
150 "checkpoint",
151 "List available rollback checkpoints.",
152 "Usage: /checkpoint list",
153 ),
154 (
155 "rewind",
156 "Rewind the workspace to a checkpoint.",
157 "Usage: /rewind <checkpoint-id>",
158 ),
159 (
160 "replay",
161 "Replay a previous Sparrow run transcript.",
162 "Usage: /replay <run-id>",
163 ),
164 ("mcp", "Manage MCP connectors.", "Usage: /mcp <action>"),
165 (
166 "profile",
167 "Manage Sparrow profiles.",
168 "Usage: /profile <list|show|switch|create|delete> ...",
169 ),
170 (
171 "import",
172 "Import configuration from another agent CLI.",
173 "Usage: /import <openclaw>",
174 ),
175 (
176 "learn",
177 "Open the interactive Sparrow tutorial.",
178 "Usage: /learn",
179 ),
180 (
181 "init",
182 "Initialize .sparrow configuration in this project.",
183 "Usage: /init",
184 ),
185 (
186 "doctor",
187 "Run diagnostics for providers, config, tools, and workspace.",
188 "Usage: /doctor",
189 ),
190 (
191 "update",
192 "Check for a Sparrow self-update.",
193 "Usage: /update",
194 ),
195 (
196 "setup",
197 "Run the first-launch provider and routing setup.",
198 "Usage: /setup",
199 ),
200 ("clear", "Clear the WebView transcript.", "Usage: /clear"),
201 (
202 "reset",
203 "Reset the current WebView conversation.",
204 "Usage: /reset",
205 ),
206 ("stop", "Stop the current run.", "Usage: /stop"),
207 (
208 "upload",
209 "Attach files to the next message.",
210 "Use the paperclip button or drag files into the WebView.",
211 ),
212 (
213 "console",
214 "Launch the WebView console from a terminal.",
215 "Terminal only: `/console` is blocked inside the WebView to avoid nesting.",
216 ),
217 (
218 "tui",
219 "Launch the terminal TUI.",
220 "Terminal only: `/tui` is blocked inside the WebView because it is interactive.",
221 ),
222 (
223 "chat",
224 "Launch interactive multi-turn terminal chat.",
225 "Terminal only: `/chat` is blocked inside the WebView because it is interactive.",
226 ),
227 (
228 "daemon",
229 "Run the headless Sparrow runtime daemon.",
230 "Terminal only: `/daemon` is blocked inside the WebView because it keeps running.",
231 ),
232];
233
234pub fn builtin_commands() -> Vec<SlashCommand> {
235 BUILTINS
236 .iter()
237 .map(|(name, description, body)| SlashCommand {
238 name: (*name).into(),
239 description: (*description).into(),
240 body: (*body).into(),
241 source: SlashCommandSource::Builtin,
242 })
243 .collect()
244}
245
246pub fn command_dirs(project_root: &Path, config_dir: &Path) -> Vec<(PathBuf, SlashCommandSource)> {
247 vec![
248 (
249 project_root.join(".sparrow").join("commands"),
250 SlashCommandSource::Project(project_root.join(".sparrow").join("commands")),
251 ),
252 (
253 config_dir.join("commands"),
254 SlashCommandSource::User(config_dir.join("commands")),
255 ),
256 ]
257}
258
259pub fn load_markdown_commands(project_root: &Path, config_dir: &Path) -> Vec<SlashCommand> {
260 let mut out = Vec::new();
261 for (dir, source) in command_dirs(project_root, config_dir) {
262 let Ok(entries) = std::fs::read_dir(&dir) else {
263 continue;
264 };
265 for entry in entries.flatten() {
266 let path = entry.path();
267 if !path
268 .extension()
269 .map(|ext| ext.eq_ignore_ascii_case("md"))
270 .unwrap_or(false)
271 {
272 continue;
273 }
274 let Ok(content) = std::fs::read_to_string(&path) else {
275 continue;
276 };
277 if let Some(cmd) = parse_markdown_command(&path, &content, source.clone()) {
278 out.push(cmd);
279 }
280 }
281 }
282 out
283}
284
285pub fn skill_commands(library: &dyn SkillLibrary) -> Vec<SlashCommand> {
286 library
287 .all()
288 .into_iter()
289 .filter(|skill| {
290 !is_unfit_for_skill(&skill.name)
291 && !is_unfit_for_skill(&skill.description)
292 && !is_unfit_for_skill(&skill.body)
293 })
294 .map(skill_to_command)
295 .collect()
296}
297
298pub fn all_commands(
299 project_root: &Path,
300 config_dir: &Path,
301 skills: Option<&dyn SkillLibrary>,
302) -> Vec<SlashCommand> {
303 let mut by_name = BTreeMap::new();
304 for cmd in builtin_commands() {
305 by_name.insert(cmd.name.clone(), cmd);
306 }
307 if let Some(skills) = skills {
308 for cmd in skill_commands(skills) {
309 by_name.insert(cmd.name.clone(), cmd);
310 }
311 }
312 for cmd in plugin_commands(project_root, config_dir) {
313 by_name.insert(cmd.name.clone(), cmd);
314 }
315 for cmd in load_markdown_commands(project_root, config_dir) {
316 by_name.insert(cmd.name.clone(), cmd);
317 }
318 by_name.into_values().collect()
319}
320
321pub fn plugin_commands(project_root: &Path, config_dir: &Path) -> Vec<SlashCommand> {
322 let dirs = [
323 project_root.join(".sparrow").join("plugins"),
324 config_dir.join("plugins"),
325 ];
326 let mut out = Vec::new();
327 for dir in dirs {
328 let registry = crate::capabilities::plugin::PluginRegistry::new(dir);
329 for plugin in registry.scan() {
330 let audit = registry.audit(&plugin);
331 if !audit.allowed {
332 continue;
333 }
334 for command in &plugin.manifest.commands {
335 out.push(SlashCommand {
336 name: crate::capabilities::plugin::namespace(
337 &plugin.manifest.name,
338 &command.name,
339 ),
340 description: if command.description.is_empty() {
341 format!("Plugin command from {}", plugin.manifest.name)
342 } else {
343 command.description.clone()
344 },
345 body: command.body.clone(),
346 source: SlashCommandSource::Plugin(plugin.manifest.name.clone()),
347 });
348 }
349 for skill in &plugin.manifest.skills {
350 out.push(SlashCommand {
351 name: crate::capabilities::plugin::namespace(
352 &plugin.manifest.name,
353 &skill.name,
354 ),
355 description: format!("Plugin skill from {}", plugin.manifest.name),
356 body: format!("Invoke plugin skill '{}'.", skill.name),
357 source: SlashCommandSource::Plugin(plugin.manifest.name.clone()),
358 });
359 }
360 }
361 }
362 out
363}
364
365fn skill_to_command(skill: Skill) -> SlashCommand {
366 SlashCommand {
367 name: slug(&skill.name),
368 description: if skill.description.is_empty() {
369 format!("Invoke skill '{}'.", skill.name)
370 } else {
371 skill.description.clone()
372 },
373 body: skill.body.clone(),
374 source: SlashCommandSource::Skill(skill.name),
375 }
376}
377
378fn parse_markdown_command(
379 path: &Path,
380 content: &str,
381 source: SlashCommandSource,
382) -> Option<SlashCommand> {
383 let stem = path.file_stem()?.to_string_lossy();
384 let mut name = slug(&stem);
385 let mut description = String::new();
386 let mut body = String::new();
387
388 for line in content.lines() {
389 let trimmed = line.trim();
390 if trimmed.starts_with("# ") && description.is_empty() {
391 description = trimmed.trim_start_matches("# ").trim().to_string();
392 continue;
393 }
394 if let Some(rest) = trimmed.strip_prefix("name:") {
395 name = slug(rest.trim().trim_start_matches('/'));
396 continue;
397 }
398 if let Some(rest) = trimmed.strip_prefix("description:") {
399 description = rest.trim().to_string();
400 continue;
401 }
402 if trimmed.starts_with("---") {
403 continue;
404 }
405 body.push_str(line);
406 body.push('\n');
407 }
408
409 if name.is_empty() {
410 return None;
411 }
412 Some(SlashCommand {
413 name,
414 description: if description.is_empty() {
415 format!("Project command from {}", path.display())
416 } else {
417 description
418 },
419 body: body.trim().to_string(),
420 source,
421 })
422}
423
424fn slug(input: &str) -> String {
425 let mut out = String::new();
426 let mut last_dash = false;
427 for ch in input.trim().trim_start_matches('/').chars() {
428 if ch.is_ascii_alphanumeric() || ch == '_' {
429 out.push(ch.to_ascii_lowercase());
430 last_dash = false;
431 } else if !last_dash {
432 out.push('-');
433 last_dash = true;
434 }
435 }
436 out.trim_matches('-').to_string()
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 use crate::capabilities::{Skill, SkillLibrary};
443 use std::sync::Mutex;
444
445 struct TestSkills(Mutex<Vec<Skill>>);
446
447 impl SkillLibrary for TestSkills {
448 fn relevant(&self, _ctx: &str, _limit: usize) -> Vec<Skill> {
449 vec![]
450 }
451 fn add(&self, skill: Skill) -> anyhow::Result<()> {
452 self.0.lock().unwrap().push(skill);
453 Ok(())
454 }
455 fn all(&self) -> Vec<Skill> {
456 self.0.lock().unwrap().clone()
457 }
458 fn curate(&self) -> anyhow::Result<()> {
459 Ok(())
460 }
461 fn prune(&self, _min_score: f64) -> anyhow::Result<usize> {
462 Ok(0)
463 }
464 fn get(&self, _name: &str) -> Option<Skill> {
465 None
466 }
467 fn invoke(
468 &self,
469 _name: &str,
470 ) -> anyhow::Result<Option<crate::capabilities::SkillInvocation>> {
471 Ok(None)
472 }
473 fn remove(&self, _name: &str) -> anyhow::Result<bool> {
474 Ok(false)
475 }
476 }
477
478 #[test]
479 fn user_command_overrides_builtin_and_project() {
480 let base =
481 std::env::temp_dir().join(format!("sparrow-command-test-{}", std::process::id()));
482 let root = base.join("project");
483 let config = base.join("config");
484 let _ = std::fs::remove_dir_all(&base);
485 std::fs::create_dir_all(root.join(".sparrow/commands")).unwrap();
486 std::fs::create_dir_all(config.join("commands")).unwrap();
487 std::fs::write(
488 config.join("commands/plan.md"),
489 "description: user plan\nuser",
490 )
491 .unwrap();
492 std::fs::write(
493 root.join(".sparrow/commands/plan.md"),
494 "description: project plan\nproject",
495 )
496 .unwrap();
497
498 let commands = all_commands(&root, &config, None);
499 let plan = commands.iter().find(|c| c.name == "plan").unwrap();
500 assert_eq!(plan.description, "user plan");
501 assert_eq!(plan.body, "user");
502 let _ = std::fs::remove_dir_all(&base);
503 }
504
505 #[test]
506 fn skill_is_exposed_as_slash_command() {
507 let skills = TestSkills(Mutex::new(vec![Skill {
508 name: "Fix CI".into(),
509 description: "Repair CI failures.".into(),
510 trigger: vec!["ci".into()],
511 body: "inspect logs".into(),
512 source_file: "fix-ci/SKILL.md".into(),
513 usage_count: 0,
514 created_at: "2026-06-02".into(),
515 score: 0.8,
516 auto_generated: false,
517 references: Vec::new(),
518 templates: Vec::new(),
519 scripts: Vec::new(),
520 assets: Vec::new(),
521 manifest_version: None,
522 allowed_tools: Vec::new(),
523 }]));
524 let commands = all_commands(Path::new("."), Path::new("."), Some(&skills));
525 assert!(commands.iter().any(|c| c.name == "fix-ci"));
526 }
527
528 #[test]
529 fn poisoned_skill_is_not_exposed_as_slash_command() {
530 let skills = TestSkills(Mutex::new(vec![
531 Skill {
532 name: "code-review".into(),
533 description: "Reusable pattern learned from: compte les fichiers .rs du projet"
534 .into(),
535 trigger: vec!["review".into()],
536 body: "## Approach\n.claude/worktrees/tmp/src/main.rs".into(),
537 source_file: "code-review/SKILL.md".into(),
538 usage_count: 0,
539 created_at: "2026-06-15".into(),
540 score: 0.3,
541 auto_generated: true,
542 references: Vec::new(),
543 templates: Vec::new(),
544 scripts: Vec::new(),
545 assets: Vec::new(),
546 manifest_version: None,
547 allowed_tools: Vec::new(),
548 },
549 Skill {
550 name: "Fix CI".into(),
551 description: "Repair CI failures.".into(),
552 trigger: vec!["ci".into()],
553 body: "inspect logs".into(),
554 source_file: "fix-ci/SKILL.md".into(),
555 usage_count: 0,
556 created_at: "2026-06-02".into(),
557 score: 0.8,
558 auto_generated: false,
559 references: Vec::new(),
560 templates: Vec::new(),
561 scripts: Vec::new(),
562 assets: Vec::new(),
563 manifest_version: None,
564 allowed_tools: Vec::new(),
565 },
566 ]));
567 let commands = all_commands(Path::new("."), Path::new("."), Some(&skills));
568 assert!(!commands.iter().any(|c| c.name == "code-review"));
569 assert!(commands.iter().any(|c| c.name == "fix-ci"));
570 }
571
572 #[test]
573 fn webview_catalog_exposes_cli_top_level_commands_with_usage() {
574 let commands = builtin_commands();
575 for name in [
576 "doctor", "setup", "launch", "init", "profile", "import", "agent",
577 ] {
578 let cmd = commands
579 .iter()
580 .find(|cmd| cmd.name == name)
581 .unwrap_or_else(|| panic!("missing builtin slash command `{name}`"));
582 assert!(!cmd.description.trim().is_empty());
583 assert!(!cmd.body.trim().is_empty());
584 }
585 }
586}