Skip to main content

sparrow/cli/
mod.rs

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    /// Launch terminal TUI (native)
11    #[arg(long)]
12    pub tui: bool,
13
14    /// Launch webview console (HTTP + WebSocket)
15    #[arg(long)]
16    pub web: bool,
17
18    /// JSON output (NDJSON event stream)
19    #[arg(long)]
20    pub json: bool,
21
22    /// Override autonomy level
23    #[arg(long)]
24    pub autonomy: Option<String>,
25
26    /// Force a specific model
27    #[arg(long)]
28    pub model: Option<String>,
29
30    /// Prefer local/offline models
31    #[arg(long, global = true)]
32    pub local: bool,
33
34    /// Session budget cap (USD)
35    #[arg(long, global = true)]
36    pub budget: Option<f64>,
37
38    /// Hard stop on cumulative USD spent in this run (alias for --budget,
39    /// kept separately to match competitor tools' UX).
40    #[arg(long, global = true)]
41    pub max_cost_usd: Option<f64>,
42
43    /// Hard stop on wall-clock seconds elapsed in this run.
44    #[arg(long, global = true)]
45    pub max_wall_secs: Option<u64>,
46
47    /// Hard stop on total tokens consumed in this run.
48    #[arg(long, global = true)]
49    pub max_tokens: Option<u64>,
50
51    /// Bind address for daemon / cockpit servers (default 127.0.0.1).
52    /// Use 0.0.0.0 when running under WSL or in a container.
53    #[arg(long, global = true)]
54    pub bind: Option<String>,
55
56    /// Sandbox backend
57    #[arg(long, global = true)]
58    pub sandbox: Option<String>,
59
60    /// Profile name
61    #[arg(long, global = true)]
62    pub profile: Option<String>,
63
64    /// Disable checkpointing
65    #[arg(long, global = true)]
66    pub no_checkpoint: bool,
67
68    /// Run as a named agent
69    #[arg(long)]
70    pub agent: Option<String>,
71
72    /// Continue the most recent session (any surface) instead of this
73    /// directory's session
74    #[arg(long = "continue", global = true)]
75    pub continue_last: bool,
76
77    /// Start with a fresh context (ignore this directory's saved session)
78    #[arg(long, global = true)]
79    pub fresh: bool,
80
81    /// Skip the pre-run quote confirmation
82    #[arg(long, global = true)]
83    pub yes: bool,
84}
85
86#[derive(Subcommand)]
87pub enum Commands {
88    /// Run a single agentic task
89    Run {
90        /// Task description
91        task: String,
92
93        /// Emit NDJSON event stream (same as the global --json flag, but may
94        /// follow the task: `sparrow run "..." --json`)
95        #[arg(long)]
96        json: bool,
97    },
98
99    /// Create a read-only execution plan for a task
100    Plan {
101        /// Task description
102        task: String,
103
104        /// Emit JSON instead of Markdown
105        #[arg(long)]
106        json: bool,
107    },
108
109    /// Adversarial review of the current local diff (uncommitted, staged,
110    /// and commits ahead of `--base`). Read-only — no edits, no commits, no
111    /// network beyond the model call. Findings are structured around
112    /// security, correctness, regressions, performance, and readability.
113    Review {
114        /// Base ref to diff against (defaults to `origin/main`, then `main`,
115        /// then `HEAD~1`).
116        #[arg(long)]
117        base: Option<String>,
118
119        /// Only review changes touching these path globs (repeatable).
120        #[arg(long)]
121        paths: Vec<String>,
122
123        /// Print the prompt the model will see and exit (no model call).
124        #[arg(long)]
125        dry_run: bool,
126    },
127
128    /// Interactive multi-turn chat
129    Chat,
130
131    /// Launch TUI
132    Tui,
133
134    /// Launch first-run setup, then the WebView cockpit
135    Launch {
136        /// TCP port for the WebView HTTP/WS server
137        #[arg(long, default_value = "9339")]
138        port: u16,
139
140        /// Launch the terminal TUI instead of the WebView cockpit
141        #[arg(long)]
142        tui: bool,
143
144        /// Use the older expert setup wizard before opening the surface
145        #[arg(long)]
146        pro: bool,
147    },
148
149    /// Launch webview console (HTTP + WebSocket)
150    #[command(visible_aliases = ["montre", "show"])]
151    Console {
152        /// TCP port for the webview HTTP/WS server
153        #[arg(long, default_value = "9339")]
154        port: u16,
155    },
156
157    /// Réparer un problème — décris ce qui ne va pas, Sparrow diagnostique
158    /// puis corrige (avec ton accord). « sparrow fix "message d'erreur" »,
159    /// ou sans argument pour scanner le dossier courant.
160    #[command(visible_aliases = ["repare", "répare"])]
161    Fix {
162        /// Le problème, avec tes mots, ou une erreur collée (entre guillemets
163        /// si elle contient des espaces). Optionnel : sans argument, Sparrow
164        /// inspecte le dossier courant.
165        problem: Vec<String>,
166    },
167
168    /// Expliquer un fichier, une erreur ou un concept en langage simple.
169    /// « sparrow explique src/main.rs » · « sparrow explique "borrow checker" »
170    #[command(visible_aliases = ["explain"])]
171    Explique {
172        /// Ce qu'il faut expliquer : un chemin de fichier, une erreur, ou un
173        /// mot (entre guillemets si plusieurs mots).
174        target: Vec<String>,
175    },
176
177    /// Annuler la dernière action de Sparrow — revient au dernier point de
178    /// sauvegarde, rien n'est perdu. « sparrow annule » · « sparrow annule
179    /// --tout » pour revenir au début de la session.
180    #[command(visible_aliases = ["undo"])]
181    Annule {
182        /// Point de sauvegarde précis (sinon : le tout dernier).
183        id: Option<String>,
184
185        /// Revenir au tout premier point de sauvegarde de la session.
186        #[arg(long, visible_alias = "all")]
187        tout: bool,
188    },
189
190    /// Dire bonjour — l'accueil chaleureux : Sparrow regarde ton dossier et
191    /// te propose quoi faire. Parfait pour un premier contact.
192    #[command(visible_aliases = ["hello", "salut"])]
193    Bonjour,
194
195    /// Voir ou changer le plafond de dépense par session. « sparrow budget »
196    /// affiche le réglage actuel ; « sparrow budget 2€ » le change.
197    Budget {
198        /// Le montant max par session (ex. « 2€ », « $0.50 », « 1.5 »).
199        /// Vide : affiche le réglage actuel.
200        amount: Option<String>,
201    },
202
203    /// Des idées de ce que tu peux faire avec Sparrow, classées par profil.
204    /// « sparrow idees » · « sparrow idees enseignant » · « sparrow idees
205    /// "factures" ».
206    #[command(visible_aliases = ["ideas", "idées"])]
207    Idees {
208        /// Filtre : un profil (enseignant, developpeur, …) ou un mot-clé.
209        filter: Vec<String>,
210    },
211
212    /// C'est quoi ce mot ? — définition instantanée d'un terme de Sparrow,
213    /// en deux phrases simples, sans appel modèle. « sparrow whatis token ».
214    #[command(name = "whatis", visible_aliases = ["c-est-quoi", "cest-quoi", "glossaire"])]
215    Whatis {
216        /// Le terme à définir (ex. checkpoint, token, swarm). Vide : liste les
217        /// mots connus.
218        term: Vec<String>,
219    },
220
221    /// Choisir comment Sparrow te parle : simple (langage clair, zéro jargon),
222    /// pro (sortie technique complète) ou auto. Sans argument : affiche le
223    /// mode actuel.
224    Mode {
225        /// « simple », « pro » ou « auto ».
226        mode: Option<String>,
227    },
228
229    /// Run headless Sparrow runtime daemon
230    Daemon,
231
232    /// Manage persistent agents
233    Agent {
234        #[command(subcommand)]
235        action: AgentAction,
236    },
237
238    /// Run swarm: planner → coder → verifier
239    Swarm {
240        /// Task or plan file
241        task: String,
242    },
243
244    /// Schedule periodic jobs
245    Schedule {
246        /// Task description
247        task: String,
248
249        /// Cron expression
250        #[arg(long)]
251        cron: String,
252
253        /// Autonomy level for scheduled jobs
254        #[arg(long)]
255        autonomy: Option<String>,
256
257        /// Report to surfaces
258        #[arg(long)]
259        report: Vec<String>,
260    },
261
262    /// Manage model routing
263    Model {
264        /// Set active route
265        #[arg(long)]
266        set: Option<String>,
267
268        /// List available models
269        #[arg(long)]
270        list: bool,
271    },
272
273    /// Configure intelligent auto-routing provider
274    Route {
275        #[command(subcommand)]
276        action: RouteAction,
277    },
278
279    /// Manage provider credentials
280    Auth {
281        #[command(subcommand)]
282        action: AuthAction,
283    },
284
285    /// Manage skill library
286    Skills {
287        #[command(subcommand)]
288        action: SkillsAction,
289    },
290
291    /// Manage local Sparrow plugins
292    Plugins {
293        #[command(subcommand)]
294        action: PluginsAction,
295    },
296
297    /// Inspect and gate toolsets
298    Tools {
299        #[command(subcommand)]
300        action: ToolsAction,
301    },
302
303    /// Security audit of config, permissions, plugins, hooks, secrets
304    Security {
305        #[command(subcommand)]
306        action: SecurityAction,
307    },
308
309    /// GitHub Action / remote PR workflow
310    Github {
311        #[command(subcommand)]
312        action: GithubAction,
313    },
314
315    /// Compact context and write a durable handoff doc
316    Compact {
317        /// Task description (recorded in the handoff)
318        #[arg(long)]
319        task: Option<String>,
320        /// Output path (default: .sparrow/handoff/<timestamp>.md)
321        #[arg(long)]
322        out: Option<PathBuf>,
323        /// Emit JSON instead of Markdown to stdout (the file is always Markdown)
324        #[arg(long)]
325        json: bool,
326    },
327
328    /// Manage MCP connectors
329    Mcp {
330        #[command(subcommand)]
331        action: McpAction,
332    },
333
334    /// List checkpoints
335    Checkpoint {
336        #[command(subcommand)]
337        action: CheckpointAction,
338    },
339
340    /// Rewind to a checkpoint
341    Rewind {
342        /// Checkpoint ID or number
343        id: String,
344    },
345
346    /// Replay a transcript
347    Replay {
348        /// Run ID to replay
349        run_id: String,
350        /// Open an interactive TUI scrubber (←/→ to step through events)
351        #[arg(long)]
352        scrub: bool,
353    },
354
355    /// Start/stop gateway daemon
356    Gateway {
357        #[command(subcommand)]
358        action: GatewayAction,
359    },
360
361    /// Manage saved sessions
362    Sessions {
363        #[command(subcommand)]
364        action: SessionAction,
365    },
366
367    /// Interactive tutorial
368    Learn,
369
370    /// Initialize a project with .sparrow/ config
371    Init,
372
373    /// Show live status (active runs, budget, session)
374    Status,
375
376    /// Manage persistent memory
377    Memory {
378        #[command(subcommand)]
379        action: MemoryAction,
380    },
381
382    /// Inspect and update permission policy
383    Permissions {
384        #[command(subcommand)]
385        action: PermissionAction,
386    },
387
388    /// Profile management
389    Profile {
390        #[command(subcommand)]
391        action: ProfileAction,
392    },
393
394    /// Import config from another tool (claude-code, codex, opencode, openclaw)
395    Import {
396        #[command(subcommand)]
397        source: ImportSource,
398    },
399
400    /// Edit configuration
401    Config {
402        /// Open config.toml in editor
403        #[arg(short)]
404        edit: bool,
405    },
406
407    /// Self-update
408    Update,
409
410    /// Run diagnostics
411    Doctor,
412
413    /// (Re)run conversational setup
414    Setup,
415
416    /// Run a self-contained demo (snake game)
417    Demo,
418
419    /// Share latest session as GitHub Gist
420    Share,
421
422    /// Install or scan security pre-commit hooks
423    Hook {
424        #[command(subcommand)]
425        action: HookAction,
426    },
427
428    /// Voice commands (speak, transcribe, providers)
429    Voice {
430        #[command(subcommand)]
431        action: VoiceAction,
432    },
433
434    /// Test browser/vision (screenshot, navigate)
435    Browser {
436        /// URL to test
437        #[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    /// Authenticate a provider via OAuth device flow (github/google/microsoft).
462    Login {
463        provider: String,
464        /// OAuth client id (or set <PROVIDER>_CLIENT_ID env var)
465        #[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 a skill from GitHub (gh:user/repo[/path]), a git URL, or a
480    /// local path to a SKILL.md
481    Install {
482        source: String,
483    },
484    Update {
485        name: String,
486    },
487    Prune,
488    /// Remove a skill by name (e.g. to delete junk auto-learned skills)
489    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 a pull request: fetch diff via `gh`, run a read-only review prompt
510    Review {
511        /// PR number
512        pr: u64,
513        /// Print the review plan without invoking the model or posting comments
514        #[arg(long)]
515        dry_run: bool,
516        /// Override the model id
517        #[arg(long)]
518        model: Option<String>,
519        /// Restrict tool allow-list (comma-separated). Empty = inherit config.
520        #[arg(long)]
521        allowed_tools: Option<String>,
522    },
523    /// Show CI status for the current branch (via `gh run list`)
524    Status,
525    /// Fetch CI logs for a workflow run id (via `gh run view --log`)
526    Logs { run_id: String },
527}
528
529#[derive(Subcommand)]
530pub enum SecurityAction {
531    /// Run a full security audit
532    Audit {
533        /// Emit JSON instead of human-readable summary
534        #[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        /// Command to launch the MCP server
559        #[arg(long)]
560        command: Option<String>,
561
562        /// Command arguments, either repeated or space-delimited
563        #[arg(long, value_delimiter = ' ', allow_hyphen_values = true)]
564        args: Vec<String>,
565
566        /// Transport backend: stdio, sse, or url
567        #[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 all checkpoints
579    List,
580    /// Show diff between HEAD and a checkpoint
581    Diff {
582        /// Checkpoint ID
583        id: String,
584    },
585    /// Delete checkpoints older than N days (default: 30)
586    Prune {
587        /// Remove checkpoints older than this many days
588        #[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    /// Full-text search across sessions
614    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    /// Import from Claude Code (~/.claude/)
631    ClaudeCode {
632        /// Path to project with .claude/ directory (defaults to cwd)
633        path: Option<PathBuf>,
634    },
635    /// Import from OpenAI Codex CLI (~/.codex/)
636    Codex {
637        /// Path to project with codex config (defaults to cwd)
638        path: Option<PathBuf>,
639    },
640    /// Import from OpenCode (~/.config/opencode/)
641    #[command(name = "opencode")]
642    OpenCode {
643        /// Path to project with opencode.json (defaults to cwd)
644        path: Option<PathBuf>,
645    },
646    /// Import from OpenClaw (~/.openclaw/)
647    Openclaw {
648        /// Path to the OpenClaw config directory (defaults to ~/.openclaw)
649        path: Option<PathBuf>,
650    },
651    /// Auto-detect installed tools and import each one
652    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    /// Show current permission mode and rules
746    List,
747    /// Set permission mode (read-only|plan|supervised|trusted|autonomous|emergency-stop)
748    Set { mode: String },
749    /// Add an explicitly allowed tool pattern
750    AllowTool { tool: String },
751    /// Add a tool pattern that always asks for approval
752    AskTool { tool: String },
753    /// Add an explicitly denied tool pattern
754    DenyTool { tool: String },
755    /// Add an allowed path boundary
756    AllowPath { path: PathBuf },
757    /// Add a denied path boundary
758    DenyPath { path: PathBuf },
759}
760
761#[derive(Subcommand)]
762pub enum RouteAction {
763    /// Pin routing to a specific provider or provider/model.
764    /// Examples: sparrow route set deepseek | sparrow route set deepseek/deepseek-v4-pro
765    Set {
766        /// Provider ID, or provider/model (e.g. \"deepseek/deepseek-v4-pro\")
767        provider: String,
768    },
769    /// Clear the pinned provider/model — let the multi-tier policy decide per task.
770    Clear,
771    /// Show the current routing config (preferred provider + per-tier policy).
772    Show,
773    /// Switch to manual mode — always use the chosen provider/model, never fall back.
774    Manual,
775    /// Switch to auto mode — tier-based policy + free_first fallback (default).
776    Auto,
777}
778
779#[derive(Subcommand)]
780pub enum HookAction {
781    /// Install pre-commit security hook
782    Install,
783    /// Scan staged files (or all files with --all) for secrets
784    Scan {
785        /// Scan entire working tree instead of just staged files
786        #[arg(long)]
787        all: bool,
788    },
789}
790
791#[derive(Subcommand)]
792pub enum VoiceAction {
793    /// Convert text to speech
794    Speak { text: String },
795    /// Transcribe audio file
796    Transcribe { file: String },
797    /// List available voice providers
798    Providers,
799}
800
801#[cfg(test)]
802mod tests {
803    use super::*;
804    use clap::Parser;
805
806    // v0.9 Pilier 1: the human front-door commands must collect their
807    // free-text argument WITHOUT swallowing global flags like --yes. A first
808    // implementation used `trailing_var_arg` and captured "--yes" into the
809    // problem text — the model then complained about the stray flags.
810    #[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        // repare → Fix, explain → Explique, montre → Console, undo → Annule.
846        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        // No id → latest (None); --tout → whole session.
903        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}