Skip to main content

grex_cli/cli/
args.rs

1use clap::{Args, Parser, Subcommand};
2
3#[derive(Parser, Debug)]
4#[command(
5    name = "grex",
6    version,
7    about = "grex — nested meta-repo manager. Pack-based, agent-native, Rust-fast.",
8    long_about = "grex manages trees of git repositories as a single addressable graph. \
9        Each node is a \"pack\" — a plain git repo plus a `.grex/` contract — and every \
10        pack is a meta-pack by construction (zero children = leaf, N children = orchestrator \
11        of N more packs, recursively). One uniform command surface (`sync`, `add`, `rm`, \
12        `update`, `status`, `import`, `doctor`, `teardown`, `exec`, `run`, `serve`) operates \
13        over the whole graph regardless of depth."
14)]
15pub struct Cli {
16    #[command(flatten)]
17    pub global: GlobalFlags,
18
19    #[command(subcommand)]
20    pub verb: Verb,
21}
22
23#[derive(Args, Debug)]
24pub struct GlobalFlags {
25    /// Emit output as JSON.
26    #[arg(long, global = true, conflicts_with = "plain")]
27    pub json: bool,
28
29    /// Emit plain (non-color, non-table) output.
30    #[arg(long, global = true)]
31    pub plain: bool,
32
33    /// Show planned actions without executing them.
34    #[arg(long, global = true)]
35    pub dry_run: bool,
36
37    /// Filter packs by expression.
38    #[arg(long, global = true)]
39    pub filter: Option<String>,
40}
41
42#[derive(Subcommand, Debug)]
43pub enum Verb {
44    /// Initialize a grex workspace.
45    Init(InitArgs),
46    /// Register and clone a pack.
47    Add(AddArgs),
48    /// Teardown and remove a pack.
49    Rm(RmArgs),
50    /// List registered packs.
51    Ls(LsArgs),
52    /// Report drift vs lockfile.
53    Status(StatusArgs),
54    /// Git fetch and pull (recurse by default).
55    Sync(SyncArgs),
56    /// Sync plus re-run install on lock change.
57    Update(UpdateArgs),
58    /// Run integrity checks.
59    Doctor(DoctorArgs),
60    /// Start MCP stdio server.
61    Serve(ServeArgs),
62    /// Import legacy REPOS.json.
63    Import(ImportArgs),
64    /// Run a named action across packs.
65    Run(RunArgs),
66    /// Execute a shell command in pack context.
67    Exec(ExecArgs),
68    /// Tear down a pack tree (reverse of `sync`/`install`).
69    Teardown(TeardownArgs),
70    /// Migrate a v1.1.x lockfile in place to the v1.2.0 schema (opt-in,
71    /// idempotent). Thin shim over the v1.2.0 Stage 1.h library
72    /// migrator (`grex_core::lockfile::migrate_v1_1_1`).
73    #[command(name = "migrate-lockfile")]
74    MigrateLockfile(MigrateLockfileArgs),
75}
76
77#[derive(Args, Debug)]
78pub struct InitArgs {}
79
80#[derive(Args, Debug)]
81pub struct AddArgs {
82    /// Git URL of the pack repo.
83    pub url: String,
84    /// Optional local path (defaults to repo name).
85    pub path: Option<String>,
86}
87
88#[derive(Args, Debug)]
89pub struct RmArgs {
90    /// Local path of the pack to remove.
91    pub path: String,
92}
93
94#[derive(Args, Debug)]
95pub struct LsArgs {
96    /// Pack root. Directory holding `.grex/pack.yaml`, or the YAML file
97    /// itself. Defaults to the current working directory.
98    pub pack_root: Option<std::path::PathBuf>,
99}
100
101#[derive(Args, Debug)]
102pub struct StatusArgs {}
103
104#[derive(Args, Debug)]
105pub struct SyncArgs {
106    /// Recurse into child packs.
107    #[arg(long, default_value_t = true)]
108    pub recursive: bool,
109
110    /// Pack root. Directory holding `.grex/pack.yaml`, or the YAML file
111    /// itself. When omitted, `sync` prints the legacy M1 stub and exits 0.
112    pub pack_root: Option<std::path::PathBuf>,
113
114    /// Override the workspace root. Defaults to the pack root directory
115    /// (where `.grex/pack.yaml` lives). When set, this path becomes the
116    /// canonical meta directory: children resolve parent-relatively as
117    /// `<workspace>/<child.path>`. The path MUST exist; symlinks are
118    /// resolved to their canonical inode (logged as `workspace: <input>
119    /// → <canonical>` when it differs).
120    #[arg(long)]
121    pub workspace: Option<std::path::PathBuf>,
122
123    /// Plan actions without touching the filesystem.
124    #[arg(long, short = 'n')]
125    pub dry_run: bool,
126
127    /// Suppress per-action log lines.
128    #[arg(long, short = 'q')]
129    pub quiet: bool,
130
131    /// Skip plan-phase validators. Debug-only escape hatch.
132    #[arg(long)]
133    pub no_validate: bool,
134
135    /// Override the default ref for every pack in this sync invocation.
136    /// Accepts a branch, tag, or commit SHA. Empty strings are rejected.
137    #[arg(long = "ref", value_name = "REF", value_parser = non_empty_string)]
138    pub ref_override: Option<String>,
139
140    /// Restrict sync to packs whose workspace-relative path (or name)
141    /// matches the glob. Repeat the flag to OR-combine multiple patterns
142    /// (standard `*`/`**`/`?` semantics). Non-matching packs are skipped
143    /// entirely — no action execution, no lockfile write.
144    #[arg(long = "only", value_name = "GLOB", value_parser = non_empty_string)]
145    pub only: Vec<String>,
146
147    /// Re-execute every pack even when its `actions_hash` is unchanged
148    /// from the prior lockfile. Overrides the M4-B skip-on-hash
149    /// short-circuit; dry-run semantics are unchanged.
150    #[arg(long)]
151    pub force: bool,
152
153    /// v1.2.0 Stage 1.l — Override Phase 2 prune-safety refusal for
154    /// dirty (tracked or untracked-non-ignored) working trees. Still
155    /// refuses ignored content unless `--force-prune-with-ignored` is
156    /// also set. Never overrides `GitInProgress` (mid-rebase / merge /
157    /// cherry-pick / revert / bisect).
158    #[arg(long = "force-prune")]
159    pub force_prune: bool,
160
161    /// v1.2.0 Stage 1.l — Strongest prune override. Implies
162    /// `--force-prune` and additionally drops trees whose only dirt is
163    /// in `--ignored` paths (build artefacts, `target/`, `node_modules/`).
164    /// Never overrides `GitInProgress`.
165    #[arg(long = "force-prune-with-ignored")]
166    pub force_prune_with_ignored: bool,
167
168    /// v1.2.1 Item 5b — Recursively snapshot Phase 2 prune targets to
169    /// `<meta>/.grex/trash/<ISO8601>/<basename>/` BEFORE deletion.
170    /// Audit log entry (`QuarantineStart`) is appended + fsync'd
171    /// before any byte is copied; on snapshot failure the prune
172    /// aborts and the original dest is left intact for forensics.
173    /// Requires `--force-prune` or `--force-prune-with-ignored` —
174    /// quarantine only applies to overridden prunes; clean-consent
175    /// prunes still go through the direct-unlink fast path. The
176    /// "requires one of" check is enforced in the verb handler
177    /// (see `crates/grex/src/cli/verbs/sync.rs`) since clap's
178    /// `requires`/`required_unless_present_any` semantics don't
179    /// model "X requires (A or B)" cleanly without an `ArgGroup`.
180    /// Matches Lean theorem `quarantine_snapshot_precedes_delete`.
181    #[arg(long = "quarantine")]
182    pub quarantine: bool,
183
184    /// Max parallel pack ops during this sync run (feat-m6-1).
185    ///
186    /// Semantics:
187    /// * Absent → default `num_cpus::get()` resolved in `verbs::sync`.
188    /// * `0` → unbounded (`Semaphore::MAX_PERMITS`).
189    /// * `1` → serial fast-path (preserves pre-M6 wall-order).
190    /// * `2..=1024` → bounded parallel.
191    ///
192    /// Env fallback: `GREX_PARALLEL` is honoured only when the flag is
193    /// absent. Clap reads the env var automatically via `env`.
194    ///
195    /// Distinct from the global `--parallel` on [`GlobalFlags`]; that
196    /// knob is documented as the harness-level worker cap and rejects
197    /// `0`. Sync parallelism uses `0` as the "unbounded" sentinel per
198    /// `.omne/cfg/concurrency.md`.
199    #[arg(
200        long = "parallel",
201        env = "GREX_PARALLEL",
202        value_parser = clap::value_parser!(u32).range(0..=1024),
203    )]
204    pub parallel: Option<u32>,
205}
206
207/// Clap `value_parser` that rejects empty or whitespace-only strings.
208/// Keeps `--ref ""`, `--ref " "`, `--only ""`, `--only "\t"` off the
209/// fast path. Whitespace-only values are rejected because they
210/// degrade silently inside the walker / globset layers rather than
211/// producing a useful error.
212fn non_empty_string(s: &str) -> Result<String, String> {
213    if s.trim().is_empty() {
214        Err("value must not be empty or whitespace-only".to_string())
215    } else {
216        Ok(s.to_string())
217    }
218}
219
220#[derive(Args, Debug)]
221pub struct UpdateArgs {
222    /// Optional pack path; if omitted, update all.
223    pub pack: Option<String>,
224}
225
226#[derive(Args, Debug)]
227pub struct DoctorArgs {
228    /// Heal gitignore drift by re-emitting the managed block. Safety:
229    /// NEVER touches the manifest or the filesystem on other checks.
230    #[arg(long)]
231    pub fix: bool,
232
233    /// Run the opt-in config-lint check (`openspec/config.yaml` +
234    /// `.omne/cfg/*.md`). Skipped by default.
235    #[arg(long = "lint-config")]
236    pub lint_config: bool,
237
238    /// v1.2.0 Stage 1.j — bound the recursive ManifestTree walk.
239    /// Omitted: walk every nested meta exhaustively (default).
240    /// `--shallow 0`: root meta only.
241    /// `--shallow N`: recurse up to `N` levels of nesting (root is
242    /// depth 0; depth-`N` metas are visited but their children are
243    /// not). The walk is read-only at every frame.
244    #[arg(long = "shallow", value_name = "N")]
245    pub shallow: Option<usize>,
246
247    /// v1.2.1 item 4 — opt-in full-filesystem scan for `.git/`
248    /// directories that are not registered in the manifest tree.
249    /// Read-only audit; complements the manifest-driven default walk.
250    /// Composes with `--shallow` (which bounds the manifest walk).
251    /// Use `--depth N` to bound the filesystem scan independently.
252    #[arg(long = "scan-undeclared")]
253    pub scan_undeclared: bool,
254
255    /// v1.2.1 item 4 — bound the `--scan-undeclared` filesystem walk.
256    /// Omitted: scan every level under the workspace (default).
257    /// `--depth 0`: workspace root only.
258    /// `--depth N`: descend up to `N` directory levels below the
259    /// workspace root. Has no effect unless `--scan-undeclared` is
260    /// also set.
261    #[arg(long = "depth", value_name = "N", requires = "scan_undeclared")]
262    pub depth: Option<usize>,
263}
264
265#[derive(Args, Debug)]
266pub struct ServeArgs {
267    /// Path to the `.grex/events.jsonl` event log. Captured at server
268    /// launch and immutable for the session (per spec §"Manifest binding").
269    /// Defaults to `<workspace>/.grex/events.jsonl` when omitted, where
270    /// `<workspace>` is resolved by walking up from cwd to the nearest
271    /// `.grex/` marker. v1.x `<workspace>/grex.jsonl` event logs are
272    /// auto-migrated to the canonical location on first access.
273    #[arg(long, value_name = "PATH")]
274    pub manifest: Option<std::path::PathBuf>,
275
276    /// Workspace root the MCP server resolves relative paths against.
277    /// Defaults to the current working directory when omitted.
278    #[arg(long, value_name = "PATH")]
279    pub workspace: Option<std::path::PathBuf>,
280
281    /// Harness-level worker cap inherited by the MCP server's
282    /// `Scheduler` (feat-m7-1 stage 8.3). `1` = serial; range `1..=1024`.
283    /// Defaults to `std::thread::available_parallelism()` when omitted.
284    /// Distinct from `sync --parallel` which uses `0` = unbounded.
285    #[arg(
286        long = "parallel",
287        value_parser = clap::value_parser!(u32).range(1..=1024),
288    )]
289    pub parallel: Option<u32>,
290}
291
292#[derive(Args, Debug)]
293pub struct ImportArgs {
294    /// Path to a legacy REPOS.json file.
295    #[arg(long)]
296    pub from_repos_json: Option<std::path::PathBuf>,
297
298    /// Target event log (`.grex/events.jsonl`). Defaults to
299    /// `<workspace>/.grex/events.jsonl` where `<workspace>` is resolved
300    /// by walking up from cwd to the nearest `.grex/` marker.
301    #[arg(long, value_name = "PATH")]
302    pub manifest: Option<std::path::PathBuf>,
303
304    /// Verb-scoped dry-run. Alias for the global `--dry-run`; either
305    /// flag short-circuits before any manifest write.
306    #[arg(long = "dry-run", short = 'n')]
307    pub dry_run: bool,
308}
309
310#[derive(Args, Debug)]
311pub struct RunArgs {
312    /// Action name to run.
313    pub action: String,
314}
315
316#[derive(Args, Debug)]
317pub struct ExecArgs {
318    /// Shell command and args to execute.
319    #[arg(trailing_var_arg = true)]
320    pub cmd: Vec<String>,
321}
322
323#[derive(Args, Debug)]
324pub struct MigrateLockfileArgs {
325    /// Workspace root (the meta whose `.grex/grex.lock.jsonl` to
326    /// migrate). Defaults to the current working directory.
327    #[arg(long, value_name = "PATH")]
328    pub workspace: Option<std::path::PathBuf>,
329
330    /// Inspect-only: detect schema version and report what would happen
331    /// without writing. Lockfile bytes are unchanged.
332    #[arg(long = "dry-run", short = 'n')]
333    pub dry_run: bool,
334}
335
336#[derive(Args, Debug)]
337pub struct TeardownArgs {
338    /// Pack root. Directory holding `.grex/pack.yaml`, or the YAML file
339    /// itself. When omitted, `teardown` prints a usage stub and exits 0.
340    pub pack_root: Option<std::path::PathBuf>,
341
342    /// Override the workspace root. Defaults to the pack root directory
343    /// (where `.grex/pack.yaml` lives). When set, this path becomes the
344    /// canonical meta directory: children resolve parent-relatively as
345    /// `<workspace>/<child.path>`. The path MUST exist; symlinks are
346    /// resolved to their canonical inode (logged as `workspace: <input>
347    /// → <canonical>` when it differs).
348    #[arg(long)]
349    pub workspace: Option<std::path::PathBuf>,
350
351    /// Suppress per-action log lines.
352    #[arg(long, short = 'q')]
353    pub quiet: bool,
354
355    /// Skip plan-phase validators. Debug-only escape hatch.
356    #[arg(long)]
357    pub no_validate: bool,
358}
359
360#[cfg(test)]
361mod tests {
362    //! Direct-parse unit tests. These bypass the spawned binary and hit
363    //! `Cli::try_parse_from` in-process — much faster than `assert_cmd`.
364    use super::*;
365    use clap::Parser;
366
367    fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
368        // clap's `try_parse_from` expects argv[0] to be the binary name.
369        let mut full = vec!["grex"];
370        full.extend_from_slice(args);
371        Cli::try_parse_from(full)
372    }
373
374    #[test]
375    fn init_parses_to_init_variant() {
376        let cli = parse(&["init"]).expect("init parses");
377        assert!(matches!(cli.verb, Verb::Init(_)));
378    }
379
380    #[test]
381    fn add_parses_url_and_optional_path() {
382        let cli = parse(&["add", "https://example.com/repo.git"]).expect("add url parses");
383        match cli.verb {
384            Verb::Add(a) => {
385                assert_eq!(a.url, "https://example.com/repo.git");
386                assert!(a.path.is_none());
387            }
388            _ => panic!("expected Add variant"),
389        }
390
391        let cli = parse(&["add", "https://example.com/repo.git", "local"])
392            .expect("add url + path parses");
393        match cli.verb {
394            Verb::Add(a) => {
395                assert_eq!(a.url, "https://example.com/repo.git");
396                assert_eq!(a.path.as_deref(), Some("local"));
397            }
398            _ => panic!("expected Add variant"),
399        }
400    }
401
402    #[test]
403    fn rm_parses_path() {
404        let cli = parse(&["rm", "pack-a"]).expect("rm parses");
405        match cli.verb {
406            Verb::Rm(a) => assert_eq!(a.path, "pack-a"),
407            _ => panic!("expected Rm variant"),
408        }
409    }
410
411    #[test]
412    fn sync_recursive_defaults_to_true() {
413        let cli = parse(&["sync"]).expect("sync parses");
414        match cli.verb {
415            Verb::Sync(a) => assert!(a.recursive, "sync should default to recursive=true"),
416            _ => panic!("expected Sync variant"),
417        }
418    }
419
420    #[test]
421    fn update_pack_is_optional() {
422        let cli = parse(&["update"]).expect("update parses bare");
423        match cli.verb {
424            Verb::Update(a) => assert!(a.pack.is_none()),
425            _ => panic!("expected Update variant"),
426        }
427
428        let cli = parse(&["update", "mypack"]).expect("update parses w/ pack");
429        match cli.verb {
430            Verb::Update(a) => assert_eq!(a.pack.as_deref(), Some("mypack")),
431            _ => panic!("expected Update variant"),
432        }
433    }
434
435    #[test]
436    fn exec_collects_trailing_args() {
437        let cli = parse(&["exec", "echo", "hi", "there"]).expect("exec parses");
438        match cli.verb {
439            Verb::Exec(a) => assert_eq!(a.cmd, vec!["echo", "hi", "there"]),
440            _ => panic!("expected Exec variant"),
441        }
442    }
443
444    #[test]
445    fn universal_flags_populate_on_any_verb() {
446        // `--json` and `--plain` are mutually exclusive, so split into two
447        // parses to exercise the remaining flags on both modes.
448        let cli = parse(&["ls", "--json", "--dry-run", "--filter", "kind=git"])
449            .expect("ls w/ json+dry-run+filter parses");
450        assert!(cli.global.json);
451        assert!(!cli.global.plain);
452        assert!(cli.global.dry_run);
453        assert_eq!(cli.global.filter.as_deref(), Some("kind=git"));
454
455        let cli = parse(&["ls", "--plain", "--dry-run"]).expect("ls w/ plain+dry-run parses");
456        assert!(!cli.global.json);
457        assert!(cli.global.plain);
458    }
459
460    #[test]
461    fn json_and_plain_conflict() {
462        let err =
463            parse(&["init", "--json", "--plain"]).expect_err("--json and --plain must conflict");
464        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
465    }
466
467    #[test]
468    fn parallel_not_global_rejected_on_non_sync_verb() {
469        // feat-m6 B2 — `--parallel` is sync-scoped only; it must NOT
470        // be accepted as a global flag on verbs like `init`/`ls`.
471        let err =
472            parse(&["init", "--parallel", "1"]).expect_err("--parallel on non-sync verb must fail");
473        assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
474    }
475
476    #[test]
477    fn sync_parallel_one_accepted() {
478        let cli = parse(&["sync", "--parallel", "1"]).expect("sync --parallel 1 parses");
479        match cli.verb {
480            Verb::Sync(a) => assert_eq!(a.parallel, Some(1)),
481            _ => panic!("expected Sync variant"),
482        }
483    }
484
485    #[test]
486    fn sync_parallel_max_accepted() {
487        let cli = parse(&["sync", "--parallel", "1024"]).expect("sync --parallel 1024 parses");
488        match cli.verb {
489            Verb::Sync(a) => assert_eq!(a.parallel, Some(1024)),
490            _ => panic!("expected Sync variant"),
491        }
492    }
493
494    #[test]
495    fn sync_parallel_over_max_rejected() {
496        let err =
497            parse(&["sync", "--parallel", "1025"]).expect_err("sync --parallel 1025 must fail");
498        assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
499    }
500
501    #[test]
502    fn import_from_repos_json_parses_as_pathbuf() {
503        let cli =
504            parse(&["import", "--from-repos-json", "./REPOS.json"]).expect("import parses path");
505        match cli.verb {
506            Verb::Import(a) => {
507                assert_eq!(
508                    a.from_repos_json.as_deref(),
509                    Some(std::path::Path::new("./REPOS.json"))
510                );
511            }
512            _ => panic!("expected Import variant"),
513        }
514    }
515
516    #[test]
517    fn run_requires_action() {
518        let err = parse(&["run"]).expect_err("run w/o action must fail");
519        assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
520    }
521
522    #[test]
523    fn unknown_verb_fails() {
524        let err = parse(&["nope"]).expect_err("unknown verb must fail");
525        assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand);
526    }
527
528    #[test]
529    fn unknown_flag_fails() {
530        let err = parse(&["init", "--not-a-flag"]).expect_err("unknown flag must fail");
531        assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
532    }
533
534    #[test]
535    fn test_cli_force_prune_flag_parsed() {
536        // v1.2.0 Stage 1.l — `--force-prune` toggles the SyncArgs
537        // bool. Default is `false`.
538        let cli = parse(&["sync", "."]).expect("sync . parses");
539        match cli.verb {
540            Verb::Sync(ref a) => {
541                assert!(!a.force_prune, "default --force-prune must be false");
542                assert!(
543                    !a.force_prune_with_ignored,
544                    "default --force-prune-with-ignored must be false"
545                );
546            }
547            _ => panic!("expected Sync variant"),
548        }
549        let cli = parse(&["sync", ".", "--force-prune"]).expect("sync --force-prune parses");
550        match cli.verb {
551            Verb::Sync(a) => {
552                assert!(a.force_prune, "--force-prune must set true");
553                assert!(
554                    !a.force_prune_with_ignored,
555                    "--force-prune-with-ignored stays default false"
556                );
557            }
558            _ => panic!("expected Sync variant"),
559        }
560    }
561
562    #[test]
563    fn test_cli_force_prune_with_ignored_flag_parsed() {
564        // v1.2.0 Stage 1.l — `--force-prune-with-ignored` toggles
565        // independently of `--force-prune`. Walker layer interprets the
566        // matrix; CLI just parses the bools.
567        let cli = parse(&["sync", ".", "--force-prune-with-ignored"])
568            .expect("sync --force-prune-with-ignored parses");
569        match cli.verb {
570            Verb::Sync(a) => {
571                assert!(
572                    !a.force_prune,
573                    "--force-prune is independent of --force-prune-with-ignored at parse layer"
574                );
575                assert!(a.force_prune_with_ignored, "--force-prune-with-ignored must set true");
576            }
577            _ => panic!("expected Sync variant"),
578        }
579        // Both flags together: caller's documented "stronger" combo.
580        let cli = parse(&["sync", ".", "--force-prune", "--force-prune-with-ignored"])
581            .expect("sync --force-prune --force-prune-with-ignored parses");
582        match cli.verb {
583            Verb::Sync(a) => {
584                assert!(a.force_prune);
585                assert!(a.force_prune_with_ignored);
586            }
587            _ => panic!("expected Sync variant"),
588        }
589    }
590
591    #[test]
592    fn cli_non_empty_string_rejects_whitespace() {
593        // F8: `--ref " "` / `--only "\t"` must be rejected by the value
594        // parser, not silently threaded into the walker / globset layer
595        // where they degrade into useless errors.
596        for bad in ["", " ", "\t", "  ", "\n"] {
597            let err =
598                parse(&["sync", ".", "--ref", bad]).expect_err("whitespace --ref must be rejected");
599            assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation, "for {bad:?}");
600
601            let err = parse(&["sync", ".", "--only", bad])
602                .expect_err("whitespace --only must be rejected");
603            assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation, "for {bad:?}");
604        }
605    }
606}