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}