Skip to main content

sparrow/
commands.rs

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}