Skip to main content

ralph/cli/
mod.rs

1//! Ralph CLI surface (Clap types) and shared CLI helpers.
2//!
3//! Responsibilities:
4//! - Centralize the top-level `Cli` and `Command` definitions for Clap.
5//! - Delegate command-group logic to submodules (queue/run/task/scan/etc.).
6//! - Provide small shared CLI helpers used across command groups.
7//!
8//! Not handled here:
9//! - Command execution logic (see submodules).
10//! - Queue persistence or lock management.
11//! - Prompt rendering or runner execution.
12//!
13//! Invariants/assumptions:
14//! - Subcommands validate their own inputs and config dependencies.
15//! - CLI parsing happens after argument normalization in `main`.
16
17pub mod app;
18pub mod cleanup;
19pub mod color;
20pub mod completions;
21pub mod config;
22pub mod context;
23pub mod daemon;
24pub mod doctor;
25pub mod init;
26pub mod migrate;
27pub mod plugin;
28pub mod prd;
29pub mod productivity;
30pub mod prompt;
31pub mod queue;
32pub mod run;
33pub mod runner;
34pub mod scan;
35pub mod task;
36pub mod tutorial;
37pub mod undo;
38pub mod version;
39pub mod watch;
40pub mod webhook;
41
42use anyhow::Result;
43use clap::{Args, Parser, Subcommand, ValueEnum};
44
45use crate::contracts::QueueFile;
46
47pub use color::ColorArg;
48
49#[derive(Parser)]
50#[command(name = "ralph")]
51#[command(about = "Ralph")]
52#[command(version)]
53#[command(after_long_help = r#"Runner selection:
54  - CLI flags override project config, which overrides global config, which overrides built-in defaults.
55  - Default runner/model come from config files: project config (.ralph/config.jsonc) > global config (~/.config/ralph/config.jsonc, with .json fallback) > built-in.
56  - `task` and `scan` accept --runner/--model/--effort as one-off overrides.
57  - `run one` and `run loop` accept --runner/--model/--effort as one-off overrides; otherwise they use task.agent overrides when present; otherwise config agent defaults.
58
59Config example (.ralph/config.jsonc):
60  {
61    "version": 1,
62    "agent": {
63      "runner": "codex",
64      "model": "gpt-5.4",
65      "codex_bin": "codex",
66      "gemini_bin": "gemini",
67      "claude_bin": "claude"
68    }
69  }
70
71Notes:
72  - Allowed runners: codex, opencode, gemini, claude, cursor, kimi, pi
73  - Allowed models: gpt-5.4, gpt-5.3-codex, gpt-5.3-codex-spark, gpt-5.3, gpt-5.2-codex, gpt-5.2, zai-coding-plan/glm-4.7, gemini-3-pro-preview, gemini-3-flash-preview, sonnet, opus, kimi-for-coding (codex supports only gpt-5.4 + gpt-5.3-codex + gpt-5.3-codex-spark + gpt-5.3 + gpt-5.2-codex + gpt-5.2; opencode/gemini/claude/cursor/kimi/pi accept arbitrary model ids))
74  - On macOS: use `ralph app open` to launch the GUI (requires an installed Ralph.app)
75
76Examples:
77  ralph app open
78  ralph queue list
79  ralph queue show RQ-0008
80  ralph queue next --with-title
81  ralph scan --runner opencode --model gpt-5.2 --focus "CI gaps"
82  ralph task --runner codex --model gpt-5.4 --effort high "Fix the flaky test"
83  ralph scan --runner gemini --model gemini-3-flash-preview --focus "risk audit"
84  ralph scan --runner claude --model sonnet --focus "risk audit"
85  ralph task --runner claude --model opus "Add tests for X"
86  ralph scan --runner cursor --model claude-opus-4-5-20251101 --focus "risk audit"
87  ralph task --runner cursor --model claude-opus-4-5-20251101 "Add tests for X"
88  ralph scan --runner kimi --focus "risk audit"
89  ralph task --runner kimi --model kimi-for-coding "Add tests for X"
90  ralph run one
91  ralph run loop --max-tasks 1
92  ralph run loop"#)]
93pub struct Cli {
94    #[command(subcommand)]
95    pub command: Command,
96
97    /// Force operations (e.g., bypass stale queue locks; bypass clean-repo safety checks for commands that enforce them, e.g. `run one`, `run loop`, and `scan`).
98    #[arg(long, global = true)]
99    pub force: bool,
100
101    /// Increase output verbosity (sets log level to info).
102    #[arg(short, long, global = true)]
103    pub verbose: bool,
104
105    /// Color output control.
106    #[arg(long, value_enum, default_value = "auto", global = true)]
107    pub color: ColorArg,
108
109    /// Disable colored output (alias for `--color never`).
110    /// Also respects the NO_COLOR environment variable.
111    #[arg(long, global = true)]
112    pub no_color: bool,
113
114    /// Automatically approve all migrations and fixes without prompting.
115    /// Useful for CI/scripting environments.
116    #[arg(long, global = true, conflicts_with = "no_sanity_checks")]
117    pub auto_fix: bool,
118
119    /// Skip startup sanity checks (migrations and unknown-key prompts).
120    #[arg(long, global = true, conflicts_with = "auto_fix")]
121    pub no_sanity_checks: bool,
122}
123
124#[derive(Subcommand)]
125pub enum Command {
126    Queue(queue::QueueArgs),
127    Config(config::ConfigArgs),
128    Run(Box<run::RunArgs>),
129    Task(Box<task::TaskArgs>),
130    Scan(scan::ScanArgs),
131    Init(init::InitArgs),
132    /// macOS app integration commands.
133    App(app::AppArgs),
134    /// Render and print the final compiled prompts used by Ralph (for debugging/auditing).
135    #[command(
136        after_long_help = "Examples:\n  ralph prompt worker --phase 1 --repo-prompt plan\n  ralph prompt worker --phase 2 --task-id RQ-0001 --plan-file .ralph/cache/plans/RQ-0001.md\n  ralph prompt scan --focus \"CI gaps\" --repo-prompt off\n  ralph prompt task-builder --request \"Add tests\" --tags rust,tests --scope crates/ralph --repo-prompt tools\n"
137    )]
138    Prompt(prompt::PromptArgs),
139    /// Verify environment readiness and configuration.
140    #[command(
141        after_long_help = "Examples:\n  ralph doctor\n  ralph doctor --auto-fix\n  ralph doctor --no-sanity-checks\n  ralph doctor --format json\n  ralph doctor --format json --auto-fix"
142    )]
143    Doctor(doctor::DoctorArgs),
144    /// Manage project context (AGENTS.md) for AI agents.
145    #[command(
146        after_long_help = "Examples:\n  ralph context init\n  ralph context init --project-type rust\n  ralph context update --section troubleshooting\n  ralph context validate\n  ralph context update --dry-run"
147    )]
148    Context(context::ContextArgs),
149    /// Manage Ralph daemon (background service).
150    #[command(
151        after_long_help = "Examples:\n  ralph daemon start\n  ralph daemon start --empty-poll-ms 5000\n  ralph daemon stop\n  ralph daemon status"
152    )]
153    Daemon(daemon::DaemonArgs),
154    /// Convert PRD (Product Requirements Document) markdown to tasks.
155    #[command(
156        after_long_help = "Examples:\n  ralph prd create docs/prd/new-feature.md\n  ralph prd create docs/prd/new-feature.md --multi\n  ralph prd create docs/prd/new-feature.md --dry-run\n  ralph prd create docs/prd/new-feature.md --priority high --tag feature\n  ralph prd create docs/prd/new-feature.md --draft"
157    )]
158    Prd(prd::PrdArgs),
159    /// Generate shell completion scripts.
160    #[command(
161        after_long_help = "Examples:\n  ralph completions bash\n  ralph completions bash > ~/.local/share/bash-completion/completions/ralph\n  ralph completions zsh > ~/.zfunc/_ralph\n  ralph completions fish > ~/.config/fish/completions/ralph.fish\n  ralph completions powershell\n\nInstallation locations by shell:\n  Bash:   ~/.local/share/bash-completion/completions/ralph\n  Zsh:    ~/.zfunc/_ralph (and add 'fpath+=~/.zfunc' to ~/.zshrc)\n  Fish:   ~/.config/fish/completions/ralph.fish\n  PowerShell: Add to $PROFILE (see: $PROFILE | Get-Member -Type NoteProperty)"
162    )]
163    Completions(completions::CompletionsArgs),
164    /// Check and apply migrations for config and project files.
165    #[command(
166        after_long_help = "Examples:\n  ralph migrate              # Check for pending migrations\n  ralph migrate --check      # Exit with error code if migrations pending (CI)\n  ralph migrate --apply      # Apply all pending migrations\n  ralph migrate --list       # List all migrations and their status\n  ralph migrate status       # Show detailed migration status"
167    )]
168    Migrate(migrate::MigrateArgs),
169    /// Clean up temporary files created by Ralph.
170    #[command(
171        after_long_help = "Examples:\n  ralph cleanup              # Clean temp files older than 7 days\n  ralph cleanup --force      # Clean all ralph temp files\n  ralph cleanup --dry-run    # Show what would be deleted without deleting"
172    )]
173    Cleanup(cleanup::CleanupArgs),
174    /// Display version information.
175    #[command(after_long_help = "Examples:\n  ralph version\n  ralph version --verbose")]
176    Version(version::VersionArgs),
177    /// Watch files for changes and auto-detect tasks from TODO/FIXME/HACK/XXX comments.
178    #[command(
179        after_long_help = "Examples:\n  ralph watch\n  ralph watch src/\n  ralph watch --patterns \"*.rs,*.toml\"\n  ralph watch --auto-queue\n  ralph watch --notify\n  ralph watch --comments todo,fixme\n  ralph watch --debounce-ms 1000\n  ralph watch --ignore-patterns \"vendor/,target/,node_modules/\""
180    )]
181    Watch(watch::WatchArgs),
182    /// Webhook management commands.
183    #[command(
184        after_long_help = "Examples:\n  ralph webhook test\n  ralph webhook test --event task_completed\n  ralph webhook status --format json\n  ralph webhook replay --dry-run --id wf-1700000000-1"
185    )]
186    Webhook(webhook::WebhookArgs),
187
188    /// Productivity analytics (streaks, velocity, milestones).
189    #[command(
190        after_long_help = "Examples:\n  ralph productivity summary\n  ralph productivity velocity\n  ralph productivity streak"
191    )]
192    Productivity(productivity::ProductivityArgs),
193
194    /// Plugin management commands.
195    #[command(
196        after_long_help = "Examples:\n  ralph plugin init my.plugin\n  ralph plugin init my.plugin --scope global\n  ralph plugin list\n  ralph plugin validate\n  ralph plugin install ./my-plugin --scope project\n  ralph plugin uninstall my.plugin --scope project"
197    )]
198    Plugin(plugin::PluginArgs),
199
200    /// Runner management commands (capabilities, list).
201    #[command(
202        after_long_help = "Examples:\n  ralph runner capabilities codex\n  ralph runner capabilities claude --format json\n  ralph runner list\n  ralph runner list --format json"
203    )]
204    Runner(runner::RunnerArgs),
205
206    /// Run interactive tutorial for Ralph onboarding.
207    #[command(
208        after_long_help = "Examples:\n  ralph tutorial\n  ralph tutorial --keep-sandbox\n  ralph tutorial --non-interactive"
209    )]
210    Tutorial(tutorial::TutorialArgs),
211
212    /// Undo the most recent queue-modifying operation.
213    #[command(
214        after_long_help = "Examples:\n  ralph undo\n  ralph undo --list\n  ralph undo --dry-run\n  ralph undo --id undo-20260215073000000000\n\nSnapshots are created automatically before queue mutations such as:\n  - ralph task done/reject/start/ready/schedule\n  - ralph task edit/field/clone/split\n  - ralph task relate/blocks/mark-duplicate\n  - ralph queue archive/prune/sort/import\n  - ralph queue issue publish/publish-many\n  - ralph task batch operations"
215    )]
216    Undo(undo::UndoArgs),
217
218    /// Internal: Emit a machine-readable CLI specification (JSON) for tooling and GUI clients.
219    #[command(name = "cli-spec", hide = true, alias = "__cli-spec")]
220    CliSpec(CliSpecArgs),
221}
222
223#[derive(Args, Debug, Clone)]
224pub struct CliSpecArgs {
225    /// Output format.
226    #[arg(long, value_enum, default_value_t = CliSpecFormatArg::Json)]
227    pub format: CliSpecFormatArg,
228}
229
230#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq)]
231pub enum CliSpecFormatArg {
232    Json,
233}
234
235pub fn handle_cli_spec(args: CliSpecArgs) -> Result<()> {
236    match args.format {
237        CliSpecFormatArg::Json => {
238            let json = crate::commands::cli_spec::emit_cli_spec_json_pretty()?;
239            use std::io::{self, Write};
240            let mut stdout = io::stdout().lock();
241            if let Err(err) = writeln!(stdout, "{json}") {
242                if err.kind() == io::ErrorKind::BrokenPipe {
243                    return Ok(());
244                }
245                return Err(err.into());
246            }
247            Ok(())
248        }
249    }
250}
251
252pub(crate) fn load_and_validate_queues_read_only(
253    resolved: &crate::config::Resolved,
254    include_done: bool,
255) -> Result<(QueueFile, Option<QueueFile>)> {
256    crate::queue::load_and_validate_queues(resolved, include_done)
257}
258
259pub(crate) fn resolve_list_limit(limit: u32, all: bool) -> Option<usize> {
260    if all || limit == 0 {
261        None
262    } else {
263        Some(limit as usize)
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::{Cli, Command, run};
270    use crate::cli::{queue, task};
271    use clap::Parser;
272    use clap::error::ErrorKind;
273
274    #[test]
275    fn cli_parses_queue_list_smoke() {
276        let cli = Cli::try_parse_from(["ralph", "queue", "list"]).expect("parse");
277        match cli.command {
278            Command::Queue(_) => {}
279            other => panic!(
280                "expected queue command, got {:?}",
281                std::mem::discriminant(&other)
282            ),
283        }
284    }
285
286    #[test]
287    fn cli_parses_queue_archive_subcommand() {
288        let cli = Cli::try_parse_from(["ralph", "queue", "archive"]).expect("parse");
289        match cli.command {
290            Command::Queue(queue::QueueArgs { command }) => match command {
291                queue::QueueCommand::Archive(_) => {}
292                _ => panic!("expected queue archive command"),
293            },
294            _ => panic!("expected queue command"),
295        }
296    }
297
298    #[test]
299    fn cli_rejects_invalid_prompt_phase() {
300        let err = Cli::try_parse_from(["ralph", "prompt", "worker", "--phase", "4"])
301            .err()
302            .expect("parse failure");
303        let msg = err.to_string();
304        assert!(msg.contains("invalid phase"), "unexpected error: {msg}");
305    }
306
307    #[test]
308    fn cli_parses_run_git_revert_mode() {
309        let cli = Cli::try_parse_from(["ralph", "run", "one", "--git-revert-mode", "disabled"])
310            .expect("parse");
311        match cli.command {
312            Command::Run(args) => match args.command {
313                run::RunCommand::One(args) => {
314                    assert_eq!(args.agent.git_revert_mode.as_deref(), Some("disabled"));
315                }
316                _ => panic!("expected run one command"),
317            },
318            _ => panic!("expected run command"),
319        }
320    }
321
322    #[test]
323    fn cli_parses_run_git_commit_push_off() {
324        let cli =
325            Cli::try_parse_from(["ralph", "run", "one", "--git-commit-push-off"]).expect("parse");
326        match cli.command {
327            Command::Run(args) => match args.command {
328                run::RunCommand::One(args) => {
329                    assert!(args.agent.git_commit_push_off);
330                    assert!(!args.agent.git_commit_push_on);
331                }
332                _ => panic!("expected run one command"),
333            },
334            _ => panic!("expected run command"),
335        }
336    }
337
338    #[test]
339    fn cli_parses_run_include_draft() {
340        let cli = Cli::try_parse_from(["ralph", "run", "one", "--include-draft"]).expect("parse");
341        match cli.command {
342            Command::Run(args) => match args.command {
343                run::RunCommand::One(args) => {
344                    assert!(args.agent.include_draft);
345                }
346                _ => panic!("expected run one command"),
347            },
348            _ => panic!("expected run command"),
349        }
350    }
351
352    #[test]
353    fn cli_parses_run_one_debug() {
354        let cli = Cli::try_parse_from(["ralph", "run", "one", "--debug"]).expect("parse");
355        match cli.command {
356            Command::Run(args) => match args.command {
357                run::RunCommand::One(args) => {
358                    assert!(args.debug);
359                }
360                _ => panic!("expected run one command"),
361            },
362            _ => panic!("expected run command"),
363        }
364    }
365
366    #[test]
367    fn cli_parses_run_loop_debug() {
368        let cli = Cli::try_parse_from(["ralph", "run", "loop", "--debug"]).expect("parse");
369        match cli.command {
370            Command::Run(args) => match args.command {
371                run::RunCommand::Loop(args) => {
372                    assert!(args.debug);
373                }
374                _ => panic!("expected run loop command"),
375            },
376            _ => panic!("expected run command"),
377        }
378    }
379
380    #[test]
381    fn cli_parses_run_one_id() {
382        let cli = Cli::try_parse_from(["ralph", "run", "one", "--id", "RQ-0001"]).expect("parse");
383        match cli.command {
384            Command::Run(args) => match args.command {
385                run::RunCommand::One(args) => {
386                    assert_eq!(args.id.as_deref(), Some("RQ-0001"));
387                }
388                _ => panic!("expected run one command"),
389            },
390            _ => panic!("expected run command"),
391        }
392    }
393
394    #[test]
395    fn cli_parses_task_update_without_id() {
396        let cli = Cli::try_parse_from(["ralph", "task", "update"]).expect("parse");
397        match cli.command {
398            Command::Task(args) => match args.command {
399                Some(task::TaskCommand::Update(args)) => {
400                    assert!(args.task_id.is_none());
401                }
402                _ => panic!("expected task update command"),
403            },
404            _ => panic!("expected task command"),
405        }
406    }
407
408    #[test]
409    fn cli_parses_task_update_with_id() {
410        let cli = Cli::try_parse_from(["ralph", "task", "update", "RQ-0001"]).expect("parse");
411        match cli.command {
412            Command::Task(args) => match args.command {
413                Some(task::TaskCommand::Update(args)) => {
414                    assert_eq!(args.task_id.as_deref(), Some("RQ-0001"));
415                }
416                _ => panic!("expected task update command"),
417            },
418            _ => panic!("expected task command"),
419        }
420    }
421
422    #[test]
423    fn cli_rejects_removed_run_one_interactive_flag_short() {
424        let err = Cli::try_parse_from(["ralph", "run", "one", "-i"])
425            .err()
426            .expect("parse failure");
427        let msg = err.to_string().to_lowercase();
428        assert!(
429            msg.contains("unexpected") || msg.contains("unrecognized") || msg.contains("unknown"),
430            "unexpected error: {msg}"
431        );
432    }
433
434    #[test]
435    fn cli_rejects_removed_run_one_interactive_flag_long() {
436        let err = Cli::try_parse_from(["ralph", "run", "one", "--interactive"])
437            .err()
438            .expect("parse failure");
439        let msg = err.to_string().to_lowercase();
440        assert!(
441            msg.contains("unexpected") || msg.contains("unrecognized") || msg.contains("unknown"),
442            "unexpected error: {msg}"
443        );
444    }
445
446    #[test]
447    fn cli_parses_task_default_subcommand() {
448        let cli = Cli::try_parse_from(["ralph", "task", "Add", "tests"]).expect("parse");
449        match cli.command {
450            Command::Task(args) => {
451                assert!(args.command.is_none(), "expected implicit build subcommand");
452                assert_eq!(
453                    args.build.request,
454                    vec!["Add".to_string(), "tests".to_string()]
455                );
456            }
457            _ => panic!("expected task command"),
458        }
459    }
460
461    #[test]
462    fn cli_parses_task_ready_subcommand() {
463        let cli = Cli::try_parse_from(["ralph", "task", "ready", "RQ-0005"]).expect("parse");
464        match cli.command {
465            Command::Task(args) => match args.command {
466                Some(task::TaskCommand::Ready(args)) => {
467                    assert_eq!(args.task_id, "RQ-0005");
468                }
469                _ => panic!("expected task ready command"),
470            },
471            _ => panic!("expected task command"),
472        }
473    }
474
475    #[test]
476    fn cli_parses_task_done_subcommand() {
477        let cli = Cli::try_parse_from(["ralph", "task", "done", "RQ-0001"]).expect("parse");
478        match cli.command {
479            Command::Task(args) => match args.command {
480                Some(task::TaskCommand::Done(args)) => {
481                    assert_eq!(args.task_id, "RQ-0001");
482                }
483                _ => panic!("expected task done command"),
484            },
485            _ => panic!("expected task command"),
486        }
487    }
488
489    #[test]
490    fn cli_parses_task_reject_subcommand() {
491        let cli = Cli::try_parse_from(["ralph", "task", "reject", "RQ-0002"]).expect("parse");
492        match cli.command {
493            Command::Task(args) => match args.command {
494                Some(task::TaskCommand::Reject(args)) => {
495                    assert_eq!(args.task_id, "RQ-0002");
496                }
497                _ => panic!("expected task reject command"),
498            },
499            _ => panic!("expected task command"),
500        }
501    }
502
503    #[test]
504    fn cli_rejects_queue_set_status_subcommand() {
505        let result = Cli::try_parse_from(["ralph", "queue", "set-status", "RQ-0001", "doing"]);
506        assert!(result.is_err(), "expected queue set-status to be rejected");
507        let msg = result.err().unwrap().to_string().to_lowercase();
508        assert!(
509            msg.contains("unrecognized") || msg.contains("unexpected") || msg.contains("unknown"),
510            "unexpected error: {msg}"
511        );
512    }
513
514    #[test]
515    fn cli_rejects_removed_run_loop_interactive_flag_short() {
516        let err = Cli::try_parse_from(["ralph", "run", "loop", "-i"])
517            .err()
518            .expect("parse failure");
519        let msg = err.to_string().to_lowercase();
520        assert!(
521            msg.contains("unexpected") || msg.contains("unrecognized") || msg.contains("unknown"),
522            "unexpected error: {msg}"
523        );
524    }
525
526    #[test]
527    fn cli_rejects_removed_run_loop_interactive_flag_long() {
528        let err = Cli::try_parse_from(["ralph", "run", "loop", "--interactive"])
529            .err()
530            .expect("parse failure");
531        let msg = err.to_string().to_lowercase();
532        assert!(
533            msg.contains("unexpected") || msg.contains("unrecognized") || msg.contains("unknown"),
534            "unexpected error: {msg}"
535        );
536    }
537
538    #[test]
539    fn cli_rejects_removed_tui_command() {
540        let err = Cli::try_parse_from(["ralph", "tui"])
541            .err()
542            .expect("parse failure");
543        let msg = err.to_string().to_lowercase();
544        assert!(
545            msg.contains("unexpected") || msg.contains("unrecognized") || msg.contains("unknown"),
546            "unexpected error: {msg}"
547        );
548    }
549
550    #[test]
551    fn cli_rejects_run_loop_with_id_flag() {
552        let err = Cli::try_parse_from(["ralph", "run", "loop", "--id", "RQ-0001"])
553            .err()
554            .expect("parse failure");
555        let msg = err.to_string();
556        assert!(
557            msg.contains("unexpected") || msg.contains("unrecognized") || msg.contains("unknown"),
558            "unexpected error: {msg}"
559        );
560    }
561
562    #[test]
563    fn cli_supports_top_level_version_flag_long() {
564        let err = Cli::try_parse_from(["ralph", "--version"])
565            .err()
566            .expect("expected clap to render version and exit");
567        assert_eq!(err.kind(), ErrorKind::DisplayVersion);
568        let rendered = err.to_string();
569        assert!(rendered.contains("ralph"));
570        assert!(rendered.contains(env!("CARGO_PKG_VERSION")));
571    }
572
573    #[test]
574    fn cli_supports_top_level_version_flag_short() {
575        let err = Cli::try_parse_from(["ralph", "-V"])
576            .err()
577            .expect("expected clap to render version and exit");
578        assert_eq!(err.kind(), ErrorKind::DisplayVersion);
579        let rendered = err.to_string();
580        assert!(rendered.contains("ralph"));
581        assert!(rendered.contains(env!("CARGO_PKG_VERSION")));
582    }
583}