Skip to main content

sparrow/
commands.rs

1use crate::capabilities::{Skill, SkillLibrary};
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.all().into_iter().map(skill_to_command).collect()
287}
288
289pub fn all_commands(
290    project_root: &Path,
291    config_dir: &Path,
292    skills: Option<&dyn SkillLibrary>,
293) -> Vec<SlashCommand> {
294    let mut by_name = BTreeMap::new();
295    for cmd in builtin_commands() {
296        by_name.insert(cmd.name.clone(), cmd);
297    }
298    if let Some(skills) = skills {
299        for cmd in skill_commands(skills) {
300            by_name.insert(cmd.name.clone(), cmd);
301        }
302    }
303    for cmd in plugin_commands(project_root, config_dir) {
304        by_name.insert(cmd.name.clone(), cmd);
305    }
306    for cmd in load_markdown_commands(project_root, config_dir) {
307        by_name.insert(cmd.name.clone(), cmd);
308    }
309    by_name.into_values().collect()
310}
311
312pub fn plugin_commands(project_root: &Path, config_dir: &Path) -> Vec<SlashCommand> {
313    let dirs = [
314        project_root.join(".sparrow").join("plugins"),
315        config_dir.join("plugins"),
316    ];
317    let mut out = Vec::new();
318    for dir in dirs {
319        let registry = crate::capabilities::plugin::PluginRegistry::new(dir);
320        for plugin in registry.scan() {
321            let audit = registry.audit(&plugin);
322            if !audit.allowed {
323                continue;
324            }
325            for command in &plugin.manifest.commands {
326                out.push(SlashCommand {
327                    name: crate::capabilities::plugin::namespace(
328                        &plugin.manifest.name,
329                        &command.name,
330                    ),
331                    description: if command.description.is_empty() {
332                        format!("Plugin command from {}", plugin.manifest.name)
333                    } else {
334                        command.description.clone()
335                    },
336                    body: command.body.clone(),
337                    source: SlashCommandSource::Plugin(plugin.manifest.name.clone()),
338                });
339            }
340            for skill in &plugin.manifest.skills {
341                out.push(SlashCommand {
342                    name: crate::capabilities::plugin::namespace(
343                        &plugin.manifest.name,
344                        &skill.name,
345                    ),
346                    description: format!("Plugin skill from {}", plugin.manifest.name),
347                    body: format!("Invoke plugin skill '{}'.", skill.name),
348                    source: SlashCommandSource::Plugin(plugin.manifest.name.clone()),
349                });
350            }
351        }
352    }
353    out
354}
355
356fn skill_to_command(skill: Skill) -> SlashCommand {
357    SlashCommand {
358        name: slug(&skill.name),
359        description: if skill.description.is_empty() {
360            format!("Invoke skill '{}'.", skill.name)
361        } else {
362            skill.description.clone()
363        },
364        body: skill.body.clone(),
365        source: SlashCommandSource::Skill(skill.name),
366    }
367}
368
369fn parse_markdown_command(
370    path: &Path,
371    content: &str,
372    source: SlashCommandSource,
373) -> Option<SlashCommand> {
374    let stem = path.file_stem()?.to_string_lossy();
375    let mut name = slug(&stem);
376    let mut description = String::new();
377    let mut body = String::new();
378
379    for line in content.lines() {
380        let trimmed = line.trim();
381        if trimmed.starts_with("# ") && description.is_empty() {
382            description = trimmed.trim_start_matches("# ").trim().to_string();
383            continue;
384        }
385        if let Some(rest) = trimmed.strip_prefix("name:") {
386            name = slug(rest.trim().trim_start_matches('/'));
387            continue;
388        }
389        if let Some(rest) = trimmed.strip_prefix("description:") {
390            description = rest.trim().to_string();
391            continue;
392        }
393        if trimmed.starts_with("---") {
394            continue;
395        }
396        body.push_str(line);
397        body.push('\n');
398    }
399
400    if name.is_empty() {
401        return None;
402    }
403    Some(SlashCommand {
404        name,
405        description: if description.is_empty() {
406            format!("Project command from {}", path.display())
407        } else {
408            description
409        },
410        body: body.trim().to_string(),
411        source,
412    })
413}
414
415fn slug(input: &str) -> String {
416    let mut out = String::new();
417    let mut last_dash = false;
418    for ch in input.trim().trim_start_matches('/').chars() {
419        if ch.is_ascii_alphanumeric() || ch == '_' {
420            out.push(ch.to_ascii_lowercase());
421            last_dash = false;
422        } else if !last_dash {
423            out.push('-');
424            last_dash = true;
425        }
426    }
427    out.trim_matches('-').to_string()
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use crate::capabilities::{Skill, SkillLibrary};
434    use std::sync::Mutex;
435
436    struct TestSkills(Mutex<Vec<Skill>>);
437
438    impl SkillLibrary for TestSkills {
439        fn relevant(&self, _ctx: &str, _limit: usize) -> Vec<Skill> {
440            vec![]
441        }
442        fn add(&self, skill: Skill) -> anyhow::Result<()> {
443            self.0.lock().unwrap().push(skill);
444            Ok(())
445        }
446        fn all(&self) -> Vec<Skill> {
447            self.0.lock().unwrap().clone()
448        }
449        fn curate(&self) -> anyhow::Result<()> {
450            Ok(())
451        }
452        fn prune(&self, _min_score: f64) -> anyhow::Result<usize> {
453            Ok(0)
454        }
455        fn get(&self, _name: &str) -> Option<Skill> {
456            None
457        }
458        fn invoke(
459            &self,
460            _name: &str,
461        ) -> anyhow::Result<Option<crate::capabilities::SkillInvocation>> {
462            Ok(None)
463        }
464        fn remove(&self, _name: &str) -> anyhow::Result<bool> {
465            Ok(false)
466        }
467    }
468
469    #[test]
470    fn user_command_overrides_builtin_and_project() {
471        let base =
472            std::env::temp_dir().join(format!("sparrow-command-test-{}", std::process::id()));
473        let root = base.join("project");
474        let config = base.join("config");
475        let _ = std::fs::remove_dir_all(&base);
476        std::fs::create_dir_all(root.join(".sparrow/commands")).unwrap();
477        std::fs::create_dir_all(config.join("commands")).unwrap();
478        std::fs::write(
479            config.join("commands/plan.md"),
480            "description: user plan\nuser",
481        )
482        .unwrap();
483        std::fs::write(
484            root.join(".sparrow/commands/plan.md"),
485            "description: project plan\nproject",
486        )
487        .unwrap();
488
489        let commands = all_commands(&root, &config, None);
490        let plan = commands.iter().find(|c| c.name == "plan").unwrap();
491        assert_eq!(plan.description, "user plan");
492        assert_eq!(plan.body, "user");
493        let _ = std::fs::remove_dir_all(&base);
494    }
495
496    #[test]
497    fn skill_is_exposed_as_slash_command() {
498        let skills = TestSkills(Mutex::new(vec![Skill {
499            name: "Fix CI".into(),
500            description: "Repair CI failures.".into(),
501            trigger: vec!["ci".into()],
502            body: "inspect logs".into(),
503            source_file: "fix-ci/SKILL.md".into(),
504            usage_count: 0,
505            created_at: "2026-06-02".into(),
506            score: 0.8,
507            auto_generated: false,
508            references: Vec::new(),
509            templates: Vec::new(),
510            scripts: Vec::new(),
511            assets: Vec::new(),
512        }]));
513        let commands = all_commands(Path::new("."), Path::new("."), Some(&skills));
514        assert!(commands.iter().any(|c| c.name == "fix-ci"));
515    }
516
517    #[test]
518    fn webview_catalog_exposes_cli_top_level_commands_with_usage() {
519        let commands = builtin_commands();
520        for name in [
521            "doctor", "setup", "launch", "init", "profile", "import", "agent",
522        ] {
523            let cmd = commands
524                .iter()
525                .find(|cmd| cmd.name == name)
526                .unwrap_or_else(|| panic!("missing builtin slash command `{name}`"));
527            assert!(!cmd.description.trim().is_empty());
528            assert!(!cmd.body.trim().is_empty());
529        }
530    }
531}