1use clap::{Parser, Subcommand};
2use std::path::PathBuf;
3
4#[derive(Parser)]
5#[command(name = "sparrow", about = "one cli · grows with you", version)]
6pub struct Cli {
7 #[command(subcommand)]
8 pub command: Option<Commands>,
9
10 #[arg(long)]
12 pub tui: bool,
13
14 #[arg(long)]
16 pub web: bool,
17
18 #[arg(long)]
20 pub json: bool,
21
22 #[arg(long)]
24 pub autonomy: Option<String>,
25
26 #[arg(long)]
28 pub model: Option<String>,
29
30 #[arg(long, global = true)]
32 pub local: bool,
33
34 #[arg(long, global = true)]
36 pub budget: Option<f64>,
37
38 #[arg(long, global = true)]
41 pub max_cost_usd: Option<f64>,
42
43 #[arg(long, global = true)]
45 pub max_wall_secs: Option<u64>,
46
47 #[arg(long, global = true)]
49 pub max_tokens: Option<u64>,
50
51 #[arg(long, global = true)]
54 pub bind: Option<String>,
55
56 #[arg(long, global = true)]
58 pub sandbox: Option<String>,
59
60 #[arg(long, global = true)]
62 pub profile: Option<String>,
63
64 #[arg(long, global = true)]
66 pub no_checkpoint: bool,
67
68 #[arg(long)]
70 pub agent: Option<String>,
71
72 #[arg(long = "continue", global = true)]
75 pub continue_last: bool,
76
77 #[arg(long, global = true)]
79 pub fresh: bool,
80
81 #[arg(long, global = true)]
83 pub yes: bool,
84}
85
86#[derive(Subcommand)]
87pub enum Commands {
88 Run {
90 task: String,
92
93 #[arg(long)]
96 json: bool,
97 },
98
99 Plan {
101 task: String,
103
104 #[arg(long)]
106 json: bool,
107 },
108
109 Review {
114 #[arg(long)]
117 base: Option<String>,
118
119 #[arg(long)]
121 paths: Vec<String>,
122
123 #[arg(long)]
125 dry_run: bool,
126 },
127
128 Chat,
130
131 Tui,
133
134 Launch {
136 #[arg(long, default_value = "9339")]
138 port: u16,
139
140 #[arg(long)]
142 tui: bool,
143
144 #[arg(long)]
146 pro: bool,
147 },
148
149 #[command(visible_aliases = ["montre", "show"])]
151 Console {
152 #[arg(long, default_value = "9339")]
154 port: u16,
155 },
156
157 #[command(visible_aliases = ["repare", "répare"])]
161 Fix {
162 problem: Vec<String>,
166 },
167
168 #[command(visible_aliases = ["explain"])]
171 Explique {
172 target: Vec<String>,
175 },
176
177 #[command(visible_aliases = ["undo"])]
181 Annule {
182 id: Option<String>,
184
185 #[arg(long, visible_alias = "all")]
187 tout: bool,
188 },
189
190 #[command(visible_aliases = ["hello", "salut"])]
193 Bonjour,
194
195 Budget {
198 amount: Option<String>,
201 },
202
203 #[command(visible_aliases = ["ideas", "idées"])]
207 Idees {
208 filter: Vec<String>,
210 },
211
212 #[command(name = "whatis", visible_aliases = ["c-est-quoi", "cest-quoi", "glossaire"])]
215 Whatis {
216 term: Vec<String>,
219 },
220
221 Mode {
225 mode: Option<String>,
227 },
228
229 Daemon,
231
232 Agent {
234 #[command(subcommand)]
235 action: AgentAction,
236 },
237
238 Swarm {
240 task: String,
242 },
243
244 Schedule {
246 task: String,
248
249 #[arg(long)]
251 cron: String,
252
253 #[arg(long)]
255 autonomy: Option<String>,
256
257 #[arg(long)]
259 report: Vec<String>,
260 },
261
262 Model {
264 #[arg(long)]
266 set: Option<String>,
267
268 #[arg(long)]
270 list: bool,
271 },
272
273 Route {
275 #[command(subcommand)]
276 action: RouteAction,
277 },
278
279 Auth {
281 #[command(subcommand)]
282 action: AuthAction,
283 },
284
285 Skills {
287 #[command(subcommand)]
288 action: SkillsAction,
289 },
290
291 Plugins {
293 #[command(subcommand)]
294 action: PluginsAction,
295 },
296
297 Tools {
299 #[command(subcommand)]
300 action: ToolsAction,
301 },
302
303 Security {
305 #[command(subcommand)]
306 action: SecurityAction,
307 },
308
309 Github {
311 #[command(subcommand)]
312 action: GithubAction,
313 },
314
315 Compact {
317 #[arg(long)]
319 task: Option<String>,
320 #[arg(long)]
322 out: Option<PathBuf>,
323 #[arg(long)]
325 json: bool,
326 },
327
328 Mcp {
330 #[command(subcommand)]
331 action: McpAction,
332 },
333
334 Checkpoint {
336 #[command(subcommand)]
337 action: CheckpointAction,
338 },
339
340 Rewind {
342 id: String,
344 },
345
346 Replay {
348 run_id: String,
350 #[arg(long)]
352 scrub: bool,
353 },
354
355 Gateway {
357 #[command(subcommand)]
358 action: GatewayAction,
359 },
360
361 Sessions {
363 #[command(subcommand)]
364 action: SessionAction,
365 },
366
367 Learn,
369
370 Init,
372
373 Status,
375
376 Memory {
378 #[command(subcommand)]
379 action: MemoryAction,
380 },
381
382 Permissions {
384 #[command(subcommand)]
385 action: PermissionAction,
386 },
387
388 Profile {
390 #[command(subcommand)]
391 action: ProfileAction,
392 },
393
394 Import {
396 #[command(subcommand)]
397 source: ImportSource,
398 },
399
400 Config {
402 #[arg(short)]
404 edit: bool,
405 },
406
407 Update,
409
410 Doctor,
412
413 Setup,
415
416 Demo,
418
419 Share,
421
422 Hook {
424 #[command(subcommand)]
425 action: HookAction,
426 },
427
428 Voice {
430 #[command(subcommand)]
431 action: VoiceAction,
432 },
433
434 Browser {
436 #[arg(default_value = "https://example.com")]
438 url: String,
439 },
440}
441
442#[derive(Subcommand)]
443pub enum AgentAction {
444 Create { name: String },
445 List,
446 Edit { name: String },
447 Rm { name: String },
448 Run { name: String, task: String },
449 Mention { name: String, message: String },
450}
451
452#[derive(Subcommand)]
453pub enum AuthAction {
454 Add {
455 provider: String,
456 },
457 List,
458 Rm {
459 provider: String,
460 },
461 Login {
463 provider: String,
464 #[arg(long)]
466 client_id: Option<String>,
467 },
468}
469
470#[derive(Subcommand)]
471pub enum SkillsAction {
472 List,
473 View {
474 name: String,
475 },
476 Create {
477 name: String,
478 },
479 Install {
482 source: String,
483 },
484 Update {
485 name: String,
486 },
487 Prune,
488 Rm {
490 name: String,
491 },
492}
493
494#[derive(Subcommand)]
495pub enum PluginsAction {
496 List,
497 Install {
498 source: String,
499 #[arg(long)]
500 allow: bool,
501 },
502 Rm {
503 name: String,
504 },
505}
506
507#[derive(Subcommand)]
508pub enum GithubAction {
509 Review {
511 pr: u64,
513 #[arg(long)]
515 dry_run: bool,
516 #[arg(long)]
518 model: Option<String>,
519 #[arg(long)]
521 allowed_tools: Option<String>,
522 },
523 Status,
525 Logs { run_id: String },
527}
528
529#[derive(Subcommand)]
530pub enum SecurityAction {
531 Audit {
533 #[arg(long)]
535 json: bool,
536 },
537}
538
539#[derive(Subcommand)]
540pub enum ToolsAction {
541 List {
542 #[arg(long)]
543 surface: Option<String>,
544 },
545 Enable {
546 tool: String,
547 },
548 Disable {
549 tool: String,
550 },
551}
552
553#[derive(Subcommand)]
554pub enum McpAction {
555 Add {
556 server: String,
557
558 #[arg(long)]
560 command: Option<String>,
561
562 #[arg(long, value_delimiter = ' ', allow_hyphen_values = true)]
564 args: Vec<String>,
565
566 #[arg(long)]
568 transport: Option<String>,
569 },
570 List,
571 Rm {
572 server: String,
573 },
574}
575
576#[derive(Subcommand)]
577pub enum CheckpointAction {
578 List,
580 Diff {
582 id: String,
584 },
585 Prune {
587 #[arg(long, default_value = "30")]
589 older_than_days: u64,
590 },
591}
592
593#[derive(Subcommand)]
594pub enum GatewayAction {
595 Start,
596 Status,
597 Health,
598 Abort { run: String },
599 Stop,
600}
601
602#[derive(Subcommand)]
603pub enum SessionAction {
604 List,
605 Export {
606 id: String,
607 path: Option<PathBuf>,
608 },
609 Cleanup {
610 #[arg(long, default_value_t = 30)]
611 older_than_days: u64,
612 },
613 Search {
615 query: String,
616 #[arg(long, default_value_t = 10)]
617 limit: usize,
618 },
619}
620
621#[derive(Subcommand)]
622pub enum ProfileAction {
623 Create { name: String },
624 List,
625 Use { name: String },
626}
627
628#[derive(Subcommand)]
629pub enum ImportSource {
630 ClaudeCode {
632 path: Option<PathBuf>,
634 },
635 Codex {
637 path: Option<PathBuf>,
639 },
640 #[command(name = "opencode")]
642 OpenCode {
643 path: Option<PathBuf>,
645 },
646 Openclaw {
648 path: Option<PathBuf>,
650 },
651 Auto,
653}
654
655#[derive(Subcommand)]
656pub enum MemoryAction {
657 List,
658 Forget {
659 id: String,
660 },
661 Add {
662 key: String,
663 value: String,
664 },
665 Replace {
666 id: String,
667 key: String,
668 value: String,
669 },
670 Recall {
671 query: String,
672 #[arg(long, default_value_t = 10)]
673 limit: usize,
674 },
675 Consolidate,
676 Docs,
677 Search {
678 query: String,
679 #[arg(long, default_value_t = 10)]
680 limit: usize,
681 },
682 Scroll {
683 session: String,
684 #[arg(long, default_value_t = 0)]
685 around: usize,
686 #[arg(long, default_value_t = 3)]
687 before: usize,
688 #[arg(long, default_value_t = 3)]
689 after: usize,
690 },
691 Graph {
692 #[command(subcommand)]
693 action: GraphAction,
694 },
695}
696
697#[derive(Subcommand)]
698pub enum GraphAction {
699 UpsertNode {
700 id: String,
701 label: String,
702 #[arg(long, default_value = "entity")]
703 kind: String,
704 #[arg(long, default_value = "{}")]
705 properties: String,
706 },
707 UpsertEdge {
708 from_id: String,
709 relation: String,
710 to_id: String,
711 #[arg(long)]
712 id: Option<String>,
713 #[arg(long, default_value_t = 1.0)]
714 weight: f64,
715 #[arg(long, default_value = "{}")]
716 properties: String,
717 },
718 Get {
719 id: String,
720 },
721 Neighbors {
722 id: String,
723 #[arg(long, default_value = "both")]
724 direction: String,
725 #[arg(long, default_value_t = 20)]
726 limit: usize,
727 },
728 Search {
729 query: String,
730 #[arg(long, default_value_t = 20)]
731 limit: usize,
732 },
733 Export,
734 DeleteNode {
735 id: String,
736 },
737 DeleteEdge {
738 id: String,
739 },
740 SyncNeo4j,
741}
742
743#[derive(Subcommand)]
744pub enum PermissionAction {
745 List,
747 Set { mode: String },
749 AllowTool { tool: String },
751 AskTool { tool: String },
753 DenyTool { tool: String },
755 AllowPath { path: PathBuf },
757 DenyPath { path: PathBuf },
759}
760
761#[derive(Subcommand)]
762pub enum RouteAction {
763 Set {
766 provider: String,
768 },
769 Clear,
771 Show,
773 Manual,
775 Auto,
777}
778
779#[derive(Subcommand)]
780pub enum HookAction {
781 Install,
783 Scan {
785 #[arg(long)]
787 all: bool,
788 },
789}
790
791#[derive(Subcommand)]
792pub enum VoiceAction {
793 Speak { text: String },
795 Transcribe { file: String },
797 Providers,
799}
800
801#[cfg(test)]
802mod tests {
803 use super::*;
804 use clap::Parser;
805
806 #[test]
811 fn explique_does_not_swallow_global_flags() {
812 let cli = Cli::parse_from(["sparrow", "explique", "borrow checker", "--yes"]);
813 assert!(cli.yes, "--yes must be parsed as a flag, not text");
814 match cli.command {
815 Some(Commands::Explique { target }) => {
816 assert_eq!(target, vec!["borrow checker".to_string()]);
817 }
818 _ => panic!("expected Explique"),
819 }
820 }
821
822 #[test]
823 fn fix_collects_words_and_respects_flags() {
824 let cli = Cli::parse_from(["sparrow", "fix", "le", "build", "casse", "--yes"]);
825 assert!(cli.yes);
826 match cli.command {
827 Some(Commands::Fix { problem }) => {
828 assert_eq!(problem, vec!["le", "build", "casse"]);
829 }
830 _ => panic!("expected Fix"),
831 }
832 }
833
834 #[test]
835 fn fix_accepts_no_argument() {
836 let cli = Cli::parse_from(["sparrow", "fix"]);
837 match cli.command {
838 Some(Commands::Fix { problem }) => assert!(problem.is_empty()),
839 _ => panic!("expected Fix"),
840 }
841 }
842
843 #[test]
844 fn human_aliases_resolve() {
845 assert!(matches!(
847 Cli::parse_from(["sparrow", "repare", "x"]).command,
848 Some(Commands::Fix { .. })
849 ));
850 assert!(matches!(
851 Cli::parse_from(["sparrow", "explain", "x"]).command,
852 Some(Commands::Explique { .. })
853 ));
854 assert!(matches!(
855 Cli::parse_from(["sparrow", "montre"]).command,
856 Some(Commands::Console { .. })
857 ));
858 assert!(matches!(
859 Cli::parse_from(["sparrow", "undo"]).command,
860 Some(Commands::Annule { .. })
861 ));
862 }
863
864 #[test]
865 fn v09_human_commands_parse() {
866 assert!(matches!(
867 Cli::parse_from(["sparrow", "idees", "enseignant"]).command,
868 Some(Commands::Idees { .. })
869 ));
870 assert!(matches!(
871 Cli::parse_from(["sparrow", "ideas"]).command,
872 Some(Commands::Idees { .. })
873 ));
874 assert!(matches!(
875 Cli::parse_from(["sparrow", "whatis", "token"]).command,
876 Some(Commands::Whatis { .. })
877 ));
878 assert!(matches!(
879 Cli::parse_from(["sparrow", "c-est-quoi", "checkpoint"]).command,
880 Some(Commands::Whatis { .. })
881 ));
882 match Cli::parse_from(["sparrow", "budget", "2€"]).command {
883 Some(Commands::Budget { amount }) => assert_eq!(amount.as_deref(), Some("2€")),
884 _ => panic!("expected Budget"),
885 }
886 }
887
888 #[test]
889 fn mode_command_parses_optional_argument() {
890 match Cli::parse_from(["sparrow", "mode"]).command {
891 Some(Commands::Mode { mode }) => assert!(mode.is_none()),
892 _ => panic!("expected Mode"),
893 }
894 match Cli::parse_from(["sparrow", "mode", "pro"]).command {
895 Some(Commands::Mode { mode }) => assert_eq!(mode.as_deref(), Some("pro")),
896 _ => panic!("expected Mode"),
897 }
898 }
899
900 #[test]
901 fn annule_defaults_and_flags() {
902 match Cli::parse_from(["sparrow", "annule"]).command {
904 Some(Commands::Annule { id, tout }) => {
905 assert!(id.is_none());
906 assert!(!tout);
907 }
908 _ => panic!("expected Annule"),
909 }
910 match Cli::parse_from(["sparrow", "annule", "--tout"]).command {
911 Some(Commands::Annule { id, tout }) => {
912 assert!(id.is_none());
913 assert!(tout);
914 }
915 _ => panic!("expected Annule"),
916 }
917 match Cli::parse_from(["sparrow", "annule", "cp-123"]).command {
918 Some(Commands::Annule { id, .. }) => assert_eq!(id.as_deref(), Some("cp-123")),
919 _ => panic!("expected Annule"),
920 }
921 }
922}