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 #[arg(long)]
100 plan_first: bool,
101
102 #[arg(long)]
104 dry_run: bool,
105
106 #[arg(long)]
108 patch: bool,
109 },
110
111 Plan {
113 task: String,
115
116 #[arg(long)]
118 json: bool,
119 },
120
121 Audit {
124 #[arg(long)]
126 json: bool,
127 },
128
129 Test {
131 #[arg(long)]
133 fix: bool,
134
135 #[arg(long)]
137 json: bool,
138 },
139
140 Review {
145 #[arg(long)]
148 base: Option<String>,
149
150 #[arg(long)]
152 paths: Vec<String>,
153
154 #[arg(long)]
156 dry_run: bool,
157 },
158
159 Chat,
161
162 Tui,
164
165 Launch {
167 #[arg(long, default_value = "9339")]
169 port: u16,
170
171 #[arg(long)]
173 tui: bool,
174
175 #[arg(long)]
177 pro: bool,
178 },
179
180 Commit {
182 #[arg(short, long)]
185 message: Option<String>,
186
187 #[arg(long)]
189 dry_run: bool,
190 },
191
192 Release {
194 #[command(subcommand)]
195 action: ReleaseAction,
196 },
197
198 Intel {
200 #[command(subcommand)]
201 action: IntelAction,
202 },
203
204 #[command(visible_aliases = ["montre", "show"])]
206 Console {
207 #[arg(long, default_value = "9339")]
209 port: u16,
210
211 #[arg(long)]
214 fast: bool,
215 },
216
217 #[command(visible_aliases = ["repare", "répare"])]
221 Fix {
222 problem: Vec<String>,
226 },
227
228 #[command(visible_aliases = ["explain"])]
231 Explique {
232 target: Vec<String>,
235 },
236
237 #[command(visible_aliases = ["undo"])]
241 Annule {
242 id: Option<String>,
244
245 #[arg(long, visible_alias = "all")]
247 tout: bool,
248 },
249
250 #[command(visible_aliases = ["hello", "salut"])]
253 Bonjour,
254
255 Budget {
258 amount: Option<String>,
261 },
262
263 #[command(visible_aliases = ["ideas", "idées"])]
267 Idees {
268 filter: Vec<String>,
270 },
271
272 #[command(name = "whatis", visible_aliases = ["c-est-quoi", "cest-quoi", "glossaire"])]
275 Whatis {
276 term: Vec<String>,
279 },
280
281 Mode {
285 mode: Option<String>,
287 },
288
289 Daemon,
291
292 Agent {
294 #[command(subcommand)]
295 action: AgentAction,
296 },
297
298 Swarm {
300 task: String,
302 },
303
304 Schedule {
306 task: String,
308
309 #[arg(long)]
311 cron: String,
312
313 #[arg(long)]
315 autonomy: Option<String>,
316
317 #[arg(long)]
319 report: Vec<String>,
320 },
321
322 Model {
324 #[arg(long)]
326 set: Option<String>,
327
328 #[arg(long)]
330 list: bool,
331 },
332
333 Route {
335 #[command(subcommand)]
336 action: RouteAction,
337 },
338
339 Auth {
341 #[command(subcommand)]
342 action: AuthAction,
343 },
344
345 Skills {
347 #[command(subcommand)]
348 action: SkillsAction,
349 },
350
351 Plugins {
353 #[command(subcommand)]
354 action: PluginsAction,
355 },
356
357 Tools {
359 #[command(subcommand)]
360 action: ToolsAction,
361 },
362
363 Security {
365 #[command(subcommand)]
366 action: SecurityAction,
367 },
368
369 Github {
371 #[command(subcommand)]
372 action: GithubAction,
373 },
374
375 Compact {
377 #[arg(long)]
379 task: Option<String>,
380 #[arg(long)]
382 out: Option<PathBuf>,
383 #[arg(long)]
385 json: bool,
386 },
387
388 Mcp {
390 #[command(subcommand)]
391 action: McpAction,
392 },
393
394 Checkpoint {
396 #[command(subcommand)]
397 action: CheckpointAction,
398 },
399
400 Rewind {
402 id: String,
404 },
405
406 Replay {
408 run_id: String,
410 #[arg(long)]
412 scrub: bool,
413 },
414
415 Gateway {
417 #[command(subcommand)]
418 action: GatewayAction,
419 },
420
421 Sessions {
423 #[command(subcommand)]
424 action: SessionAction,
425 },
426
427 Learn,
429
430 Init,
432
433 Status,
435
436 Memory {
438 #[command(subcommand)]
439 action: MemoryAction,
440 },
441
442 Permissions {
444 #[command(subcommand)]
445 action: PermissionAction,
446 },
447
448 Profile {
450 #[command(subcommand)]
451 action: ProfileAction,
452 },
453
454 Import {
456 #[command(subcommand)]
457 source: ImportSource,
458 },
459
460 Config {
462 #[arg(short)]
464 edit: bool,
465 },
466
467 Update,
469
470 Doctor,
472
473 Setup,
475
476 Demo,
478
479 Share,
481
482 Hook {
484 #[command(subcommand)]
485 action: HookAction,
486 },
487
488 Voice {
490 #[command(subcommand)]
491 action: VoiceAction,
492 },
493
494 Browser {
496 #[arg(default_value = "https://example.com")]
498 url: String,
499 },
500}
501
502#[derive(Subcommand)]
503pub enum AgentAction {
504 Create { name: String },
505 List,
506 Edit { name: String },
507 Rm { name: String },
508 Run { name: String, task: String },
509 Mention { name: String, message: String },
510}
511
512#[derive(Subcommand)]
513pub enum AuthAction {
514 Add {
515 provider: String,
516 },
517 List,
518 Rm {
519 provider: String,
520 },
521 Login {
523 provider: String,
524 #[arg(long)]
526 client_id: Option<String>,
527 },
528}
529
530#[derive(Subcommand)]
531pub enum SkillsAction {
532 List,
533 View {
534 name: String,
535 },
536 Create {
537 name: String,
538 },
539 Install {
542 source: String,
543 },
544 Update {
545 name: String,
546 },
547 Prune,
548 Rm {
550 name: String,
551 },
552}
553
554#[derive(Subcommand)]
555pub enum PluginsAction {
556 List,
557 Install {
558 source: String,
559 #[arg(long)]
560 allow: bool,
561 },
562 Rm {
563 name: String,
564 },
565}
566
567#[derive(Subcommand)]
568pub enum GithubAction {
569 Review {
571 pr: u64,
573 #[arg(long)]
575 dry_run: bool,
576 #[arg(long)]
578 model: Option<String>,
579 #[arg(long)]
581 allowed_tools: Option<String>,
582 },
583 Status,
585 Logs { run_id: String },
587}
588
589#[derive(Subcommand)]
590pub enum ReleaseAction {
591 Prep {
593 #[arg(long)]
595 dry_run: bool,
596 },
597}
598
599#[derive(Subcommand)]
600pub enum IntelAction {
601 Scan {
603 #[arg(long)]
605 config: Option<PathBuf>,
606
607 #[arg(long)]
610 source: Vec<String>,
611
612 #[arg(long, default_value_t = 5)]
614 limit: usize,
615
616 #[arg(long)]
618 json: bool,
619 },
620
621 Report {
623 #[arg(long, default_value_t = 20)]
624 limit: usize,
625 #[arg(long)]
626 json: bool,
627 },
628
629 Backlog {
631 #[arg(long, default_value_t = 20)]
632 limit: usize,
633 #[arg(long)]
634 json: bool,
635 },
636
637 Watch {
639 #[arg(long, default_value_t = 3600)]
640 interval: u64,
641 #[arg(long)]
642 config: Option<PathBuf>,
643 #[arg(long)]
644 source: Vec<String>,
645 },
646}
647
648#[derive(Subcommand)]
649pub enum SecurityAction {
650 Audit {
652 #[arg(long)]
654 json: bool,
655 },
656}
657
658#[derive(Subcommand)]
659pub enum ToolsAction {
660 List {
661 #[arg(long)]
662 surface: Option<String>,
663 },
664 Enable {
665 tool: String,
666 },
667 Disable {
668 tool: String,
669 },
670}
671
672#[derive(Subcommand)]
673pub enum McpAction {
674 Add {
675 server: String,
676
677 #[arg(long)]
679 command: Option<String>,
680
681 #[arg(long, value_delimiter = ' ', allow_hyphen_values = true)]
683 args: Vec<String>,
684
685 #[arg(long)]
687 transport: Option<String>,
688 },
689 List,
690 Rm {
691 server: String,
692 },
693}
694
695#[derive(Subcommand)]
696pub enum CheckpointAction {
697 List,
699 Diff {
701 id: String,
703 },
704 Prune {
706 #[arg(long, default_value = "30")]
708 older_than_days: u64,
709 },
710}
711
712#[derive(Subcommand)]
713pub enum GatewayAction {
714 Start,
715 Status,
716 Health,
717 Abort { run: String },
718 Stop,
719}
720
721#[derive(Subcommand)]
722pub enum SessionAction {
723 List,
724 Export {
725 id: String,
726 path: Option<PathBuf>,
727 },
728 Cleanup {
729 #[arg(long, default_value_t = 30)]
730 older_than_days: u64,
731 },
732 Search {
734 query: String,
735 #[arg(long, default_value_t = 10)]
736 limit: usize,
737 },
738}
739
740#[derive(Subcommand)]
741pub enum ProfileAction {
742 Create { name: String },
743 List,
744 Use { name: String },
745}
746
747#[derive(Subcommand)]
748pub enum ImportSource {
749 ClaudeCode {
751 path: Option<PathBuf>,
753 },
754 Codex {
756 path: Option<PathBuf>,
758 },
759 #[command(name = "opencode")]
761 OpenCode {
762 path: Option<PathBuf>,
764 },
765 Openclaw {
767 path: Option<PathBuf>,
769 },
770 Auto,
772}
773
774#[derive(Subcommand)]
775pub enum MemoryAction {
776 List,
777 Forget {
778 id: String,
779 },
780 Add {
781 key: String,
782 value: String,
783 },
784 Replace {
785 id: String,
786 key: String,
787 value: String,
788 },
789 Recall {
790 query: String,
791 #[arg(long, default_value_t = 10)]
792 limit: usize,
793 },
794 Consolidate,
795 Docs,
796 Search {
797 query: String,
798 #[arg(long, default_value_t = 10)]
799 limit: usize,
800 },
801 Scroll {
802 session: String,
803 #[arg(long, default_value_t = 0)]
804 around: usize,
805 #[arg(long, default_value_t = 3)]
806 before: usize,
807 #[arg(long, default_value_t = 3)]
808 after: usize,
809 },
810 Graph {
811 #[command(subcommand)]
812 action: GraphAction,
813 },
814}
815
816#[derive(Subcommand)]
817pub enum GraphAction {
818 UpsertNode {
819 id: String,
820 label: String,
821 #[arg(long, default_value = "entity")]
822 kind: String,
823 #[arg(long, default_value = "{}")]
824 properties: String,
825 },
826 UpsertEdge {
827 from_id: String,
828 relation: String,
829 to_id: String,
830 #[arg(long)]
831 id: Option<String>,
832 #[arg(long, default_value_t = 1.0)]
833 weight: f64,
834 #[arg(long, default_value = "{}")]
835 properties: String,
836 },
837 Get {
838 id: String,
839 },
840 Neighbors {
841 id: String,
842 #[arg(long, default_value = "both")]
843 direction: String,
844 #[arg(long, default_value_t = 20)]
845 limit: usize,
846 },
847 Search {
848 query: String,
849 #[arg(long, default_value_t = 20)]
850 limit: usize,
851 },
852 Export,
853 DeleteNode {
854 id: String,
855 },
856 DeleteEdge {
857 id: String,
858 },
859 SyncNeo4j,
860}
861
862#[derive(Subcommand)]
863pub enum PermissionAction {
864 List,
866 Set { mode: String },
868 AllowTool { tool: String },
870 AskTool { tool: String },
872 DenyTool { tool: String },
874 AllowPath { path: PathBuf },
876 DenyPath { path: PathBuf },
878}
879
880#[derive(Subcommand)]
881pub enum RouteAction {
882 Set {
885 provider: String,
887 },
888 Clear,
890 Show,
892 Manual,
894 Auto,
896}
897
898#[derive(Subcommand)]
899pub enum HookAction {
900 Install,
902 Scan {
904 #[arg(long)]
906 all: bool,
907 },
908}
909
910#[derive(Subcommand)]
911pub enum VoiceAction {
912 Speak { text: String },
914 Transcribe { file: String },
916 Providers,
918}
919
920#[cfg(test)]
921mod tests {
922 use super::*;
923 use clap::Parser;
924
925 #[test]
930 fn explique_does_not_swallow_global_flags() {
931 let cli = Cli::parse_from(["sparrow", "explique", "borrow checker", "--yes"]);
932 assert!(cli.yes, "--yes must be parsed as a flag, not text");
933 match cli.command {
934 Some(Commands::Explique { target }) => {
935 assert_eq!(target, vec!["borrow checker".to_string()]);
936 }
937 _ => panic!("expected Explique"),
938 }
939 }
940
941 #[test]
942 fn fix_collects_words_and_respects_flags() {
943 let cli = Cli::parse_from(["sparrow", "fix", "le", "build", "casse", "--yes"]);
944 assert!(cli.yes);
945 match cli.command {
946 Some(Commands::Fix { problem }) => {
947 assert_eq!(problem, vec!["le", "build", "casse"]);
948 }
949 _ => panic!("expected Fix"),
950 }
951 }
952
953 #[test]
954 fn fix_accepts_no_argument() {
955 let cli = Cli::parse_from(["sparrow", "fix"]);
956 match cli.command {
957 Some(Commands::Fix { problem }) => assert!(problem.is_empty()),
958 _ => panic!("expected Fix"),
959 }
960 }
961
962 #[test]
963 fn human_aliases_resolve() {
964 assert!(matches!(
966 Cli::parse_from(["sparrow", "repare", "x"]).command,
967 Some(Commands::Fix { .. })
968 ));
969 assert!(matches!(
970 Cli::parse_from(["sparrow", "explain", "x"]).command,
971 Some(Commands::Explique { .. })
972 ));
973 assert!(matches!(
974 Cli::parse_from(["sparrow", "montre"]).command,
975 Some(Commands::Console { .. })
976 ));
977 assert!(matches!(
978 Cli::parse_from(["sparrow", "undo"]).command,
979 Some(Commands::Annule { .. })
980 ));
981 }
982
983 #[test]
984 fn console_fast_flag_parses() {
985 match Cli::parse_from(["sparrow", "console", "--fast"]).command {
986 Some(Commands::Console { port, fast }) => {
987 assert_eq!(port, 9339);
988 assert!(fast);
989 }
990 _ => panic!("expected Console"),
991 }
992 }
993
994 #[test]
995 fn v092_audit_and_test_commands_parse() {
996 assert!(matches!(
997 Cli::parse_from(["sparrow", "audit", "--json"]).command,
998 Some(Commands::Audit { json: true })
999 ));
1000 assert!(matches!(
1001 Cli::parse_from(["sparrow", "test", "--fix"]).command,
1002 Some(Commands::Test {
1003 fix: true,
1004 json: false
1005 })
1006 ));
1007 assert!(matches!(
1008 Cli::parse_from(["sparrow", "commit", "--dry-run", "-m", "feat: x"]).command,
1009 Some(Commands::Commit {
1010 dry_run: true,
1011 message: Some(_)
1012 })
1013 ));
1014 assert!(matches!(
1015 Cli::parse_from(["sparrow", "release", "prep"]).command,
1016 Some(Commands::Release {
1017 action: ReleaseAction::Prep { dry_run: false }
1018 })
1019 ));
1020 assert!(matches!(
1021 Cli::parse_from([
1022 "sparrow",
1023 "intel",
1024 "scan",
1025 "--source",
1026 "github_releases:Codex:https://github.com/openai/codex",
1027 "--limit",
1028 "2"
1029 ])
1030 .command,
1031 Some(Commands::Intel {
1032 action: IntelAction::Scan { limit: 2, .. }
1033 })
1034 ));
1035 assert!(matches!(
1036 Cli::parse_from([
1037 "sparrow",
1038 "run",
1039 "fix it",
1040 "--plan-first",
1041 "--dry-run",
1042 "--patch"
1043 ])
1044 .command,
1045 Some(Commands::Run {
1046 plan_first: true,
1047 dry_run: true,
1048 patch: true,
1049 ..
1050 })
1051 ));
1052 }
1053
1054 #[test]
1055 fn v09_human_commands_parse() {
1056 assert!(matches!(
1057 Cli::parse_from(["sparrow", "idees", "enseignant"]).command,
1058 Some(Commands::Idees { .. })
1059 ));
1060 assert!(matches!(
1061 Cli::parse_from(["sparrow", "ideas"]).command,
1062 Some(Commands::Idees { .. })
1063 ));
1064 assert!(matches!(
1065 Cli::parse_from(["sparrow", "whatis", "token"]).command,
1066 Some(Commands::Whatis { .. })
1067 ));
1068 assert!(matches!(
1069 Cli::parse_from(["sparrow", "c-est-quoi", "checkpoint"]).command,
1070 Some(Commands::Whatis { .. })
1071 ));
1072 match Cli::parse_from(["sparrow", "budget", "2€"]).command {
1073 Some(Commands::Budget { amount }) => assert_eq!(amount.as_deref(), Some("2€")),
1074 _ => panic!("expected Budget"),
1075 }
1076 }
1077
1078 #[test]
1079 fn mode_command_parses_optional_argument() {
1080 match Cli::parse_from(["sparrow", "mode"]).command {
1081 Some(Commands::Mode { mode }) => assert!(mode.is_none()),
1082 _ => panic!("expected Mode"),
1083 }
1084 match Cli::parse_from(["sparrow", "mode", "pro"]).command {
1085 Some(Commands::Mode { mode }) => assert_eq!(mode.as_deref(), Some("pro")),
1086 _ => panic!("expected Mode"),
1087 }
1088 match Cli::parse_from(["sparrow", "mode", "builder"]).command {
1089 Some(Commands::Mode { mode }) => assert_eq!(mode.as_deref(), Some("builder")),
1090 _ => panic!("expected Mode"),
1091 }
1092 }
1093
1094 #[test]
1095 fn annule_defaults_and_flags() {
1096 match Cli::parse_from(["sparrow", "annule"]).command {
1098 Some(Commands::Annule { id, tout }) => {
1099 assert!(id.is_none());
1100 assert!(!tout);
1101 }
1102 _ => panic!("expected Annule"),
1103 }
1104 match Cli::parse_from(["sparrow", "annule", "--tout"]).command {
1105 Some(Commands::Annule { id, tout }) => {
1106 assert!(id.is_none());
1107 assert!(tout);
1108 }
1109 _ => panic!("expected Annule"),
1110 }
1111 match Cli::parse_from(["sparrow", "annule", "cp-123"]).command {
1112 Some(Commands::Annule { id, .. }) => assert_eq!(id.as_deref(), Some("cp-123")),
1113 _ => panic!("expected Annule"),
1114 }
1115 }
1116}