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        /// Show a read-only plan first; continue only with `--yes`.
99        #[arg(long)]
100        plan_first: bool,
101
102        /// Read-only dry run: propose actions/diffs, but deny mutating tools.
103        #[arg(long)]
104        dry_run: bool,
105
106        /// Patch mode: ask for a unified diff only and deny mutating tools.
107        #[arg(long)]
108        patch: bool,
109    },
110
111    /// Create a read-only execution plan for a task
112    Plan {
113        /// Task description
114        task: String,
115
116        /// Emit JSON instead of Markdown
117        #[arg(long)]
118        json: bool,
119    },
120
121    /// Audit the current repository: architecture map, stubs, TODO/FIXME, and
122    /// suspicious Rust files. Writes `./artifacts/audit-<timestamp>.md`.
123    Audit {
124        /// Emit JSON instead of Markdown path output
125        #[arg(long)]
126        json: bool,
127    },
128
129    /// Detect and run the project test suite (`cargo`, `npm`, or `pytest`).
130    Test {
131        /// If tests fail, hand the failure to Sparrow's repair loop.
132        #[arg(long)]
133        fix: bool,
134
135        /// Emit JSON instead of human-readable output.
136        #[arg(long)]
137        json: bool,
138    },
139
140    /// Adversarial review of the current local diff (uncommitted, staged,
141    /// and commits ahead of `--base`). Read-only — no edits, no commits, no
142    /// network beyond the model call. Findings are structured around
143    /// security, correctness, regressions, performance, and readability.
144    Review {
145        /// Base ref to diff against (defaults to `origin/main`, then `main`,
146        /// then `HEAD~1`).
147        #[arg(long)]
148        base: Option<String>,
149
150        /// Only review changes touching these path globs (repeatable).
151        #[arg(long)]
152        paths: Vec<String>,
153
154        /// Print the prompt the model will see and exit (no model call).
155        #[arg(long)]
156        dry_run: bool,
157    },
158
159    /// Interactive multi-turn chat
160    Chat,
161
162    /// Launch TUI
163    Tui,
164
165    /// Launch first-run setup, then the WebView cockpit
166    Launch {
167        /// TCP port for the WebView HTTP/WS server
168        #[arg(long, default_value = "9339")]
169        port: u16,
170
171        /// Launch the terminal TUI instead of the WebView cockpit
172        #[arg(long)]
173        tui: bool,
174
175        /// Use the older expert setup wizard before opening the surface
176        #[arg(long)]
177        pro: bool,
178    },
179
180    /// Create a clean git commit from staged changes after a secret scan.
181    Commit {
182        /// Commit message. If omitted, Sparrow generates a conservative one
183        /// from the staged diff stat.
184        #[arg(short, long)]
185        message: Option<String>,
186
187        /// Show what would be committed without running `git commit`.
188        #[arg(long)]
189        dry_run: bool,
190    },
191
192    /// Release workflow helpers.
193    Release {
194        #[command(subcommand)]
195        action: ReleaseAction,
196    },
197
198    /// Public release intelligence (opt-in network scan, local cache reports).
199    Intel {
200        #[command(subcommand)]
201        action: IntelAction,
202    },
203
204    /// Launch webview console (HTTP + WebSocket)
205    #[command(visible_aliases = ["montre", "show"])]
206    Console {
207        /// TCP port for the webview HTTP/WS server
208        #[arg(long, default_value = "9339")]
209        port: u16,
210
211        /// Fast start: skip boot animation, eager panel preloads, and boot-time
212        /// provider discovery. Panels still load lazily when opened.
213        #[arg(long)]
214        fast: bool,
215    },
216
217    /// Réparer un problème — décris ce qui ne va pas, Sparrow diagnostique
218    /// puis corrige (avec ton accord). « sparrow fix "message d'erreur" »,
219    /// ou sans argument pour scanner le dossier courant.
220    #[command(visible_aliases = ["repare", "répare"])]
221    Fix {
222        /// Le problème, avec tes mots, ou une erreur collée (entre guillemets
223        /// si elle contient des espaces). Optionnel : sans argument, Sparrow
224        /// inspecte le dossier courant.
225        problem: Vec<String>,
226    },
227
228    /// Expliquer un fichier, une erreur ou un concept en langage simple.
229    /// « sparrow explique src/main.rs » · « sparrow explique "borrow checker" »
230    #[command(visible_aliases = ["explain"])]
231    Explique {
232        /// Ce qu'il faut expliquer : un chemin de fichier, une erreur, ou un
233        /// mot (entre guillemets si plusieurs mots).
234        target: Vec<String>,
235    },
236
237    /// Annuler la dernière action de Sparrow — revient au dernier point de
238    /// sauvegarde, rien n'est perdu. « sparrow annule » · « sparrow annule
239    /// --tout » pour revenir au début de la session.
240    #[command(visible_aliases = ["undo"])]
241    Annule {
242        /// Point de sauvegarde précis (sinon : le tout dernier).
243        id: Option<String>,
244
245        /// Revenir au tout premier point de sauvegarde de la session.
246        #[arg(long, visible_alias = "all")]
247        tout: bool,
248    },
249
250    /// Dire bonjour — l'accueil chaleureux : Sparrow regarde ton dossier et
251    /// te propose quoi faire. Parfait pour un premier contact.
252    #[command(visible_aliases = ["hello", "salut"])]
253    Bonjour,
254
255    /// Voir ou changer le plafond de dépense par session. « sparrow budget »
256    /// affiche le réglage actuel ; « sparrow budget 2€ » le change.
257    Budget {
258        /// Le montant max par session (ex. « 2€ », « $0.50 », « 1.5 »).
259        /// Vide : affiche le réglage actuel.
260        amount: Option<String>,
261    },
262
263    /// Des idées de ce que tu peux faire avec Sparrow, classées par profil.
264    /// « sparrow idees » · « sparrow idees enseignant » · « sparrow idees
265    /// "factures" ».
266    #[command(visible_aliases = ["ideas", "idées"])]
267    Idees {
268        /// Filtre : un profil (enseignant, developpeur, …) ou un mot-clé.
269        filter: Vec<String>,
270    },
271
272    /// C'est quoi ce mot ? — définition instantanée d'un terme de Sparrow,
273    /// en deux phrases simples, sans appel modèle. « sparrow whatis token ».
274    #[command(name = "whatis", visible_aliases = ["c-est-quoi", "cest-quoi", "glossaire"])]
275    Whatis {
276        /// Le terme à définir (ex. checkpoint, token, swarm). Vide : liste les
277        /// mots connus.
278        term: Vec<String>,
279    },
280
281    /// Choisir comment Sparrow te parle : simple (langage clair), builder
282    /// (workflows build), pro (sortie technique complète) ou auto. Sans
283    /// argument : affiche le mode actuel.
284    Mode {
285        /// « simple », « builder », « pro » ou « auto ».
286        mode: Option<String>,
287    },
288
289    /// Run headless Sparrow runtime daemon
290    Daemon,
291
292    /// Manage persistent agents
293    Agent {
294        #[command(subcommand)]
295        action: AgentAction,
296    },
297
298    /// Run swarm: planner → coder → verifier
299    Swarm {
300        /// Task or plan file
301        task: String,
302    },
303
304    /// Schedule periodic jobs
305    Schedule {
306        /// Task description
307        task: String,
308
309        /// Cron expression
310        #[arg(long)]
311        cron: String,
312
313        /// Autonomy level for scheduled jobs
314        #[arg(long)]
315        autonomy: Option<String>,
316
317        /// Report to surfaces
318        #[arg(long)]
319        report: Vec<String>,
320    },
321
322    /// Manage model routing
323    Model {
324        /// Set active route
325        #[arg(long)]
326        set: Option<String>,
327
328        /// List available models
329        #[arg(long)]
330        list: bool,
331    },
332
333    /// Configure intelligent auto-routing provider
334    Route {
335        #[command(subcommand)]
336        action: RouteAction,
337    },
338
339    /// Manage provider credentials
340    Auth {
341        #[command(subcommand)]
342        action: AuthAction,
343    },
344
345    /// Manage skill library
346    Skills {
347        #[command(subcommand)]
348        action: SkillsAction,
349    },
350
351    /// Manage local Sparrow plugins
352    Plugins {
353        #[command(subcommand)]
354        action: PluginsAction,
355    },
356
357    /// Inspect and gate toolsets
358    Tools {
359        #[command(subcommand)]
360        action: ToolsAction,
361    },
362
363    /// Security audit of config, permissions, plugins, hooks, secrets
364    Security {
365        #[command(subcommand)]
366        action: SecurityAction,
367    },
368
369    /// GitHub Action / remote PR workflow
370    Github {
371        #[command(subcommand)]
372        action: GithubAction,
373    },
374
375    /// Compact context and write a durable handoff doc
376    Compact {
377        /// Task description (recorded in the handoff)
378        #[arg(long)]
379        task: Option<String>,
380        /// Output path (default: .sparrow/handoff/<timestamp>.md)
381        #[arg(long)]
382        out: Option<PathBuf>,
383        /// Emit JSON instead of Markdown to stdout (the file is always Markdown)
384        #[arg(long)]
385        json: bool,
386    },
387
388    /// Manage MCP connectors
389    Mcp {
390        #[command(subcommand)]
391        action: McpAction,
392    },
393
394    /// List checkpoints
395    Checkpoint {
396        #[command(subcommand)]
397        action: CheckpointAction,
398    },
399
400    /// Rewind to a checkpoint
401    Rewind {
402        /// Checkpoint ID or number
403        id: String,
404    },
405
406    /// Replay a transcript
407    Replay {
408        /// Run ID to replay
409        run_id: String,
410        /// Open an interactive TUI scrubber (←/→ to step through events)
411        #[arg(long)]
412        scrub: bool,
413    },
414
415    /// Start/stop gateway daemon
416    Gateway {
417        #[command(subcommand)]
418        action: GatewayAction,
419    },
420
421    /// Manage saved sessions
422    Sessions {
423        #[command(subcommand)]
424        action: SessionAction,
425    },
426
427    /// Interactive tutorial
428    Learn,
429
430    /// Initialize a project with .sparrow/ config
431    Init,
432
433    /// Show live status (active runs, budget, session)
434    Status,
435
436    /// Manage persistent memory
437    Memory {
438        #[command(subcommand)]
439        action: MemoryAction,
440    },
441
442    /// Inspect and update permission policy
443    Permissions {
444        #[command(subcommand)]
445        action: PermissionAction,
446    },
447
448    /// Profile management
449    Profile {
450        #[command(subcommand)]
451        action: ProfileAction,
452    },
453
454    /// Import config from another tool (claude-code, codex, opencode, openclaw)
455    Import {
456        #[command(subcommand)]
457        source: ImportSource,
458    },
459
460    /// Edit configuration
461    Config {
462        /// Open config.toml in editor
463        #[arg(short)]
464        edit: bool,
465    },
466
467    /// Self-update
468    Update,
469
470    /// Run diagnostics
471    Doctor,
472
473    /// (Re)run conversational setup
474    Setup,
475
476    /// Run a self-contained demo (snake game)
477    Demo,
478
479    /// Share latest session as GitHub Gist
480    Share,
481
482    /// Install or scan security pre-commit hooks
483    Hook {
484        #[command(subcommand)]
485        action: HookAction,
486    },
487
488    /// Voice commands (speak, transcribe, providers)
489    Voice {
490        #[command(subcommand)]
491        action: VoiceAction,
492    },
493
494    /// Test browser/vision (screenshot, navigate)
495    Browser {
496        /// URL to test
497        #[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    /// Authenticate a provider via OAuth device flow (github/google/microsoft).
522    Login {
523        provider: String,
524        /// OAuth client id (or set <PROVIDER>_CLIENT_ID env var)
525        #[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 a skill from GitHub (gh:user/repo[/path]), a git URL, or a
540    /// local path to a SKILL.md
541    Install {
542        source: String,
543    },
544    Update {
545        name: String,
546    },
547    Prune,
548    /// Remove a skill by name (e.g. to delete junk auto-learned skills)
549    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 a pull request: fetch diff via `gh`, run a read-only review prompt
570    Review {
571        /// PR number
572        pr: u64,
573        /// Print the review plan without invoking the model or posting comments
574        #[arg(long)]
575        dry_run: bool,
576        /// Override the model id
577        #[arg(long)]
578        model: Option<String>,
579        /// Restrict tool allow-list (comma-separated). Empty = inherit config.
580        #[arg(long)]
581        allowed_tools: Option<String>,
582    },
583    /// Show CI status for the current branch (via `gh run list`)
584    Status,
585    /// Fetch CI logs for a workflow run id (via `gh run view --log`)
586    Logs { run_id: String },
587}
588
589#[derive(Subcommand)]
590pub enum ReleaseAction {
591    /// Prepare launch notes and migration notes from local artifacts.
592    Prep {
593        /// Show the target files without writing them.
594        #[arg(long)]
595        dry_run: bool,
596    },
597}
598
599#[derive(Subcommand)]
600pub enum IntelAction {
601    /// Fetch configured or explicit public sources into the local intel cache.
602    Scan {
603        /// TOML file containing [[source]] entries.
604        #[arg(long)]
605        config: Option<PathBuf>,
606
607        /// Explicit source as kind:name:url, e.g.
608        /// github_releases:Codex:https://github.com/openai/codex
609        #[arg(long)]
610        source: Vec<String>,
611
612        /// Max releases per GitHub source.
613        #[arg(long, default_value_t = 5)]
614        limit: usize,
615
616        /// Emit JSON instead of a human summary.
617        #[arg(long)]
618        json: bool,
619    },
620
621    /// Show cached release digests without network access.
622    Report {
623        #[arg(long, default_value_t = 20)]
624        limit: usize,
625        #[arg(long)]
626        json: bool,
627    },
628
629    /// Show cached scored backlog tickets without network access.
630    Backlog {
631        #[arg(long, default_value_t = 20)]
632        limit: usize,
633        #[arg(long)]
634        json: bool,
635    },
636
637    /// Repeated opt-in scan loop. Requires intel.enabled=true or explicit sources.
638    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    /// Run a full security audit
651    Audit {
652        /// Emit JSON instead of human-readable summary
653        #[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        /// Command to launch the MCP server
678        #[arg(long)]
679        command: Option<String>,
680
681        /// Command arguments, either repeated or space-delimited
682        #[arg(long, value_delimiter = ' ', allow_hyphen_values = true)]
683        args: Vec<String>,
684
685        /// Transport backend: stdio, sse, or url
686        #[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 all checkpoints
698    List,
699    /// Show diff between HEAD and a checkpoint
700    Diff {
701        /// Checkpoint ID
702        id: String,
703    },
704    /// Delete checkpoints older than N days (default: 30)
705    Prune {
706        /// Remove checkpoints older than this many days
707        #[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    /// Full-text search across sessions
733    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    /// Import from Claude Code (~/.claude/)
750    ClaudeCode {
751        /// Path to project with .claude/ directory (defaults to cwd)
752        path: Option<PathBuf>,
753    },
754    /// Import from OpenAI Codex CLI (~/.codex/)
755    Codex {
756        /// Path to project with codex config (defaults to cwd)
757        path: Option<PathBuf>,
758    },
759    /// Import from OpenCode (~/.config/opencode/)
760    #[command(name = "opencode")]
761    OpenCode {
762        /// Path to project with opencode.json (defaults to cwd)
763        path: Option<PathBuf>,
764    },
765    /// Import from OpenClaw (~/.openclaw/)
766    Openclaw {
767        /// Path to the OpenClaw config directory (defaults to ~/.openclaw)
768        path: Option<PathBuf>,
769    },
770    /// Auto-detect installed tools and import each one
771    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    /// Show current permission mode and rules
865    List,
866    /// Set permission mode (read-only|plan|supervised|trusted|autonomous|emergency-stop)
867    Set { mode: String },
868    /// Add an explicitly allowed tool pattern
869    AllowTool { tool: String },
870    /// Add a tool pattern that always asks for approval
871    AskTool { tool: String },
872    /// Add an explicitly denied tool pattern
873    DenyTool { tool: String },
874    /// Add an allowed path boundary
875    AllowPath { path: PathBuf },
876    /// Add a denied path boundary
877    DenyPath { path: PathBuf },
878}
879
880#[derive(Subcommand)]
881pub enum RouteAction {
882    /// Pin routing to a specific provider or provider/model.
883    /// Examples: sparrow route set deepseek | sparrow route set deepseek/deepseek-v4-pro
884    Set {
885        /// Provider ID, or provider/model (e.g. \"deepseek/deepseek-v4-pro\")
886        provider: String,
887    },
888    /// Clear the pinned provider/model — let the multi-tier policy decide per task.
889    Clear,
890    /// Show the current routing config (preferred provider + per-tier policy).
891    Show,
892    /// Switch to manual mode — always use the chosen provider/model, never fall back.
893    Manual,
894    /// Switch to auto mode — tier-based policy + free_first fallback (default).
895    Auto,
896}
897
898#[derive(Subcommand)]
899pub enum HookAction {
900    /// Install pre-commit security hook
901    Install,
902    /// Scan staged files (or all files with --all) for secrets
903    Scan {
904        /// Scan entire working tree instead of just staged files
905        #[arg(long)]
906        all: bool,
907    },
908}
909
910#[derive(Subcommand)]
911pub enum VoiceAction {
912    /// Convert text to speech
913    Speak { text: String },
914    /// Transcribe audio file
915    Transcribe { file: String },
916    /// List available voice providers
917    Providers,
918}
919
920#[cfg(test)]
921mod tests {
922    use super::*;
923    use clap::Parser;
924
925    // v0.9 Pilier 1: the human front-door commands must collect their
926    // free-text argument WITHOUT swallowing global flags like --yes. A first
927    // implementation used `trailing_var_arg` and captured "--yes" into the
928    // problem text — the model then complained about the stray flags.
929    #[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        // repare → Fix, explain → Explique, montre → Console, undo → Annule.
965        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        // No id → latest (None); --tout → whole session.
1097        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}