1pub 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 #[arg(long, global = true)]
99 pub force: bool,
100
101 #[arg(short, long, global = true)]
103 pub verbose: bool,
104
105 #[arg(long, value_enum, default_value = "auto", global = true)]
107 pub color: ColorArg,
108
109 #[arg(long, global = true)]
112 pub no_color: bool,
113
114 #[arg(long, global = true, conflicts_with = "no_sanity_checks")]
117 pub auto_fix: bool,
118
119 #[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 App(app::AppArgs),
134 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[command(after_long_help = "Examples:\n ralph version\n ralph version --verbose")]
176 Version(version::VersionArgs),
177 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[command(name = "cli-spec", hide = true, alias = "__cli-spec")]
220 CliSpec(CliSpecArgs),
221}
222
223#[derive(Args, Debug, Clone)]
224pub struct CliSpecArgs {
225 #[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}