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}
71
72#[derive(Args, Debug)]
73pub struct InitArgs {}
74
75#[derive(Args, Debug)]
76pub struct AddArgs {
77    /// Git URL of the pack repo.
78    pub url: String,
79    /// Optional local path (defaults to repo name).
80    pub path: Option<String>,
81}
82
83#[derive(Args, Debug)]
84pub struct RmArgs {
85    /// Local path of the pack to remove.
86    pub path: String,
87}
88
89#[derive(Args, Debug)]
90pub struct LsArgs {
91    /// Pack root. Directory holding `.grex/pack.yaml`, or the YAML file
92    /// itself. Defaults to the current working directory.
93    pub pack_root: Option<std::path::PathBuf>,
94}
95
96#[derive(Args, Debug)]
97pub struct StatusArgs {}
98
99#[derive(Args, Debug)]
100pub struct SyncArgs {
101    /// Recurse into child packs.
102    #[arg(long, default_value_t = true)]
103    pub recursive: bool,
104
105    /// Pack root. Directory holding `.grex/pack.yaml`, or the YAML file
106    /// itself. When omitted, `sync` prints the legacy M1 stub and exits 0.
107    pub pack_root: Option<std::path::PathBuf>,
108
109    /// Override the workspace root. Defaults to the parent pack's root
110    /// directory; children resolve as flat siblings.
111    #[arg(long)]
112    pub workspace: Option<std::path::PathBuf>,
113
114    /// Plan actions without touching the filesystem.
115    #[arg(long, short = 'n')]
116    pub dry_run: bool,
117
118    /// Suppress per-action log lines.
119    #[arg(long, short = 'q')]
120    pub quiet: bool,
121
122    /// Skip plan-phase validators. Debug-only escape hatch.
123    #[arg(long)]
124    pub no_validate: bool,
125
126    /// Override the default ref for every pack in this sync invocation.
127    /// Accepts a branch, tag, or commit SHA. Empty strings are rejected.
128    #[arg(long = "ref", value_name = "REF", value_parser = non_empty_string)]
129    pub ref_override: Option<String>,
130
131    /// Restrict sync to packs whose workspace-relative path (or name)
132    /// matches the glob. Repeat the flag to OR-combine multiple patterns
133    /// (standard `*`/`**`/`?` semantics). Non-matching packs are skipped
134    /// entirely — no action execution, no lockfile write.
135    #[arg(long = "only", value_name = "GLOB", value_parser = non_empty_string)]
136    pub only: Vec<String>,
137
138    /// Re-execute every pack even when its `actions_hash` is unchanged
139    /// from the prior lockfile. Overrides the M4-B skip-on-hash
140    /// short-circuit; dry-run semantics are unchanged.
141    #[arg(long)]
142    pub force: bool,
143
144    /// v1.2.0 Stage 1.l — Override Phase 2 prune-safety refusal for
145    /// dirty (tracked or untracked-non-ignored) working trees. Still
146    /// refuses ignored content unless `--force-prune-with-ignored` is
147    /// also set. Never overrides `GitInProgress` (mid-rebase / merge /
148    /// cherry-pick / revert / bisect).
149    #[arg(long = "force-prune")]
150    pub force_prune: bool,
151
152    /// v1.2.0 Stage 1.l — Strongest prune override. Implies
153    /// `--force-prune` and additionally drops trees whose only dirt is
154    /// in `--ignored` paths (build artefacts, `target/`, `node_modules/`).
155    /// Never overrides `GitInProgress`.
156    #[arg(long = "force-prune-with-ignored")]
157    pub force_prune_with_ignored: bool,
158
159    /// Max parallel pack ops during this sync run (feat-m6-1).
160    ///
161    /// Semantics:
162    /// * Absent → default `num_cpus::get()` resolved in `verbs::sync`.
163    /// * `0` → unbounded (`Semaphore::MAX_PERMITS`).
164    /// * `1` → serial fast-path (preserves pre-M6 wall-order).
165    /// * `2..=1024` → bounded parallel.
166    ///
167    /// Env fallback: `GREX_PARALLEL` is honoured only when the flag is
168    /// absent. Clap reads the env var automatically via `env`.
169    ///
170    /// Distinct from the global `--parallel` on [`GlobalFlags`]; that
171    /// knob is documented as the harness-level worker cap and rejects
172    /// `0`. Sync parallelism uses `0` as the "unbounded" sentinel per
173    /// `.omne/cfg/concurrency.md`.
174    #[arg(
175        long = "parallel",
176        env = "GREX_PARALLEL",
177        value_parser = clap::value_parser!(u32).range(0..=1024),
178    )]
179    pub parallel: Option<u32>,
180}
181
182/// Clap `value_parser` that rejects empty or whitespace-only strings.
183/// Keeps `--ref ""`, `--ref " "`, `--only ""`, `--only "\t"` off the
184/// fast path. Whitespace-only values are rejected because they
185/// degrade silently inside the walker / globset layers rather than
186/// producing a useful error.
187fn non_empty_string(s: &str) -> Result<String, String> {
188    if s.trim().is_empty() {
189        Err("value must not be empty or whitespace-only".to_string())
190    } else {
191        Ok(s.to_string())
192    }
193}
194
195#[derive(Args, Debug)]
196pub struct UpdateArgs {
197    /// Optional pack path; if omitted, update all.
198    pub pack: Option<String>,
199}
200
201#[derive(Args, Debug)]
202pub struct DoctorArgs {
203    /// Heal gitignore drift by re-emitting the managed block. Safety:
204    /// NEVER touches the manifest or the filesystem on other checks.
205    #[arg(long)]
206    pub fix: bool,
207
208    /// Run the opt-in config-lint check (`openspec/config.yaml` +
209    /// `.omne/cfg/*.md`). Skipped by default.
210    #[arg(long = "lint-config")]
211    pub lint_config: bool,
212
213    /// v1.2.0 Stage 1.j — bound the recursive ManifestTree walk.
214    /// Omitted: walk every nested meta exhaustively (default).
215    /// `--shallow 0`: root meta only.
216    /// `--shallow N`: recurse up to `N` levels of nesting (root is
217    /// depth 0; depth-`N` metas are visited but their children are
218    /// not). The walk is read-only at every frame.
219    #[arg(long = "shallow", value_name = "N")]
220    pub shallow: Option<usize>,
221}
222
223#[derive(Args, Debug)]
224pub struct ServeArgs {
225    /// Path to the `.grex/events.jsonl` event log. Captured at server
226    /// launch and immutable for the session (per spec §"Manifest binding").
227    /// Defaults to `<workspace>/.grex/events.jsonl` when omitted, where
228    /// `<workspace>` is resolved by walking up from cwd to the nearest
229    /// `.grex/` marker. v1.x `<workspace>/grex.jsonl` event logs are
230    /// auto-migrated to the canonical location on first access.
231    #[arg(long, value_name = "PATH")]
232    pub manifest: Option<std::path::PathBuf>,
233
234    /// Workspace root the MCP server resolves relative paths against.
235    /// Defaults to the current working directory when omitted.
236    #[arg(long, value_name = "PATH")]
237    pub workspace: Option<std::path::PathBuf>,
238
239    /// Harness-level worker cap inherited by the MCP server's
240    /// `Scheduler` (feat-m7-1 stage 8.3). `1` = serial; range `1..=1024`.
241    /// Defaults to `std::thread::available_parallelism()` when omitted.
242    /// Distinct from `sync --parallel` which uses `0` = unbounded.
243    #[arg(
244        long = "parallel",
245        value_parser = clap::value_parser!(u32).range(1..=1024),
246    )]
247    pub parallel: Option<u32>,
248}
249
250#[derive(Args, Debug)]
251pub struct ImportArgs {
252    /// Path to a legacy REPOS.json file.
253    #[arg(long)]
254    pub from_repos_json: Option<std::path::PathBuf>,
255
256    /// Target event log (`.grex/events.jsonl`). Defaults to
257    /// `<workspace>/.grex/events.jsonl` where `<workspace>` is resolved
258    /// by walking up from cwd to the nearest `.grex/` marker.
259    #[arg(long, value_name = "PATH")]
260    pub manifest: Option<std::path::PathBuf>,
261
262    /// Verb-scoped dry-run. Alias for the global `--dry-run`; either
263    /// flag short-circuits before any manifest write.
264    #[arg(long = "dry-run", short = 'n')]
265    pub dry_run: bool,
266}
267
268#[derive(Args, Debug)]
269pub struct RunArgs {
270    /// Action name to run.
271    pub action: String,
272}
273
274#[derive(Args, Debug)]
275pub struct ExecArgs {
276    /// Shell command and args to execute.
277    #[arg(trailing_var_arg = true)]
278    pub cmd: Vec<String>,
279}
280
281#[derive(Args, Debug)]
282pub struct TeardownArgs {
283    /// Pack root. Directory holding `.grex/pack.yaml`, or the YAML file
284    /// itself. When omitted, `teardown` prints a usage stub and exits 0.
285    pub pack_root: Option<std::path::PathBuf>,
286
287    /// Override the workspace root. Defaults to the parent pack's root
288    /// directory; children resolve as flat siblings.
289    #[arg(long)]
290    pub workspace: Option<std::path::PathBuf>,
291
292    /// Suppress per-action log lines.
293    #[arg(long, short = 'q')]
294    pub quiet: bool,
295
296    /// Skip plan-phase validators. Debug-only escape hatch.
297    #[arg(long)]
298    pub no_validate: bool,
299}
300
301#[cfg(test)]
302mod tests {
303    //! Direct-parse unit tests. These bypass the spawned binary and hit
304    //! `Cli::try_parse_from` in-process — much faster than `assert_cmd`.
305    use super::*;
306    use clap::Parser;
307
308    fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
309        // clap's `try_parse_from` expects argv[0] to be the binary name.
310        let mut full = vec!["grex"];
311        full.extend_from_slice(args);
312        Cli::try_parse_from(full)
313    }
314
315    #[test]
316    fn init_parses_to_init_variant() {
317        let cli = parse(&["init"]).expect("init parses");
318        assert!(matches!(cli.verb, Verb::Init(_)));
319    }
320
321    #[test]
322    fn add_parses_url_and_optional_path() {
323        let cli = parse(&["add", "https://example.com/repo.git"]).expect("add url parses");
324        match cli.verb {
325            Verb::Add(a) => {
326                assert_eq!(a.url, "https://example.com/repo.git");
327                assert!(a.path.is_none());
328            }
329            _ => panic!("expected Add variant"),
330        }
331
332        let cli = parse(&["add", "https://example.com/repo.git", "local"])
333            .expect("add url + path parses");
334        match cli.verb {
335            Verb::Add(a) => {
336                assert_eq!(a.url, "https://example.com/repo.git");
337                assert_eq!(a.path.as_deref(), Some("local"));
338            }
339            _ => panic!("expected Add variant"),
340        }
341    }
342
343    #[test]
344    fn rm_parses_path() {
345        let cli = parse(&["rm", "pack-a"]).expect("rm parses");
346        match cli.verb {
347            Verb::Rm(a) => assert_eq!(a.path, "pack-a"),
348            _ => panic!("expected Rm variant"),
349        }
350    }
351
352    #[test]
353    fn sync_recursive_defaults_to_true() {
354        let cli = parse(&["sync"]).expect("sync parses");
355        match cli.verb {
356            Verb::Sync(a) => assert!(a.recursive, "sync should default to recursive=true"),
357            _ => panic!("expected Sync variant"),
358        }
359    }
360
361    #[test]
362    fn update_pack_is_optional() {
363        let cli = parse(&["update"]).expect("update parses bare");
364        match cli.verb {
365            Verb::Update(a) => assert!(a.pack.is_none()),
366            _ => panic!("expected Update variant"),
367        }
368
369        let cli = parse(&["update", "mypack"]).expect("update parses w/ pack");
370        match cli.verb {
371            Verb::Update(a) => assert_eq!(a.pack.as_deref(), Some("mypack")),
372            _ => panic!("expected Update variant"),
373        }
374    }
375
376    #[test]
377    fn exec_collects_trailing_args() {
378        let cli = parse(&["exec", "echo", "hi", "there"]).expect("exec parses");
379        match cli.verb {
380            Verb::Exec(a) => assert_eq!(a.cmd, vec!["echo", "hi", "there"]),
381            _ => panic!("expected Exec variant"),
382        }
383    }
384
385    #[test]
386    fn universal_flags_populate_on_any_verb() {
387        // `--json` and `--plain` are mutually exclusive, so split into two
388        // parses to exercise the remaining flags on both modes.
389        let cli = parse(&["ls", "--json", "--dry-run", "--filter", "kind=git"])
390            .expect("ls w/ json+dry-run+filter parses");
391        assert!(cli.global.json);
392        assert!(!cli.global.plain);
393        assert!(cli.global.dry_run);
394        assert_eq!(cli.global.filter.as_deref(), Some("kind=git"));
395
396        let cli = parse(&["ls", "--plain", "--dry-run"]).expect("ls w/ plain+dry-run parses");
397        assert!(!cli.global.json);
398        assert!(cli.global.plain);
399    }
400
401    #[test]
402    fn json_and_plain_conflict() {
403        let err =
404            parse(&["init", "--json", "--plain"]).expect_err("--json and --plain must conflict");
405        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
406    }
407
408    #[test]
409    fn parallel_not_global_rejected_on_non_sync_verb() {
410        // feat-m6 B2 — `--parallel` is sync-scoped only; it must NOT
411        // be accepted as a global flag on verbs like `init`/`ls`.
412        let err =
413            parse(&["init", "--parallel", "1"]).expect_err("--parallel on non-sync verb must fail");
414        assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
415    }
416
417    #[test]
418    fn sync_parallel_one_accepted() {
419        let cli = parse(&["sync", "--parallel", "1"]).expect("sync --parallel 1 parses");
420        match cli.verb {
421            Verb::Sync(a) => assert_eq!(a.parallel, Some(1)),
422            _ => panic!("expected Sync variant"),
423        }
424    }
425
426    #[test]
427    fn sync_parallel_max_accepted() {
428        let cli = parse(&["sync", "--parallel", "1024"]).expect("sync --parallel 1024 parses");
429        match cli.verb {
430            Verb::Sync(a) => assert_eq!(a.parallel, Some(1024)),
431            _ => panic!("expected Sync variant"),
432        }
433    }
434
435    #[test]
436    fn sync_parallel_over_max_rejected() {
437        let err =
438            parse(&["sync", "--parallel", "1025"]).expect_err("sync --parallel 1025 must fail");
439        assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
440    }
441
442    #[test]
443    fn import_from_repos_json_parses_as_pathbuf() {
444        let cli =
445            parse(&["import", "--from-repos-json", "./REPOS.json"]).expect("import parses path");
446        match cli.verb {
447            Verb::Import(a) => {
448                assert_eq!(
449                    a.from_repos_json.as_deref(),
450                    Some(std::path::Path::new("./REPOS.json"))
451                );
452            }
453            _ => panic!("expected Import variant"),
454        }
455    }
456
457    #[test]
458    fn run_requires_action() {
459        let err = parse(&["run"]).expect_err("run w/o action must fail");
460        assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
461    }
462
463    #[test]
464    fn unknown_verb_fails() {
465        let err = parse(&["nope"]).expect_err("unknown verb must fail");
466        assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand);
467    }
468
469    #[test]
470    fn unknown_flag_fails() {
471        let err = parse(&["init", "--not-a-flag"]).expect_err("unknown flag must fail");
472        assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
473    }
474
475    #[test]
476    fn test_cli_force_prune_flag_parsed() {
477        // v1.2.0 Stage 1.l — `--force-prune` toggles the SyncArgs
478        // bool. Default is `false`.
479        let cli = parse(&["sync", "."]).expect("sync . parses");
480        match cli.verb {
481            Verb::Sync(ref a) => {
482                assert!(!a.force_prune, "default --force-prune must be false");
483                assert!(
484                    !a.force_prune_with_ignored,
485                    "default --force-prune-with-ignored must be false"
486                );
487            }
488            _ => panic!("expected Sync variant"),
489        }
490        let cli = parse(&["sync", ".", "--force-prune"]).expect("sync --force-prune parses");
491        match cli.verb {
492            Verb::Sync(a) => {
493                assert!(a.force_prune, "--force-prune must set true");
494                assert!(
495                    !a.force_prune_with_ignored,
496                    "--force-prune-with-ignored stays default false"
497                );
498            }
499            _ => panic!("expected Sync variant"),
500        }
501    }
502
503    #[test]
504    fn test_cli_force_prune_with_ignored_flag_parsed() {
505        // v1.2.0 Stage 1.l — `--force-prune-with-ignored` toggles
506        // independently of `--force-prune`. Walker layer interprets the
507        // matrix; CLI just parses the bools.
508        let cli = parse(&["sync", ".", "--force-prune-with-ignored"])
509            .expect("sync --force-prune-with-ignored parses");
510        match cli.verb {
511            Verb::Sync(a) => {
512                assert!(
513                    !a.force_prune,
514                    "--force-prune is independent of --force-prune-with-ignored at parse layer"
515                );
516                assert!(a.force_prune_with_ignored, "--force-prune-with-ignored must set true");
517            }
518            _ => panic!("expected Sync variant"),
519        }
520        // Both flags together: caller's documented "stronger" combo.
521        let cli = parse(&["sync", ".", "--force-prune", "--force-prune-with-ignored"])
522            .expect("sync --force-prune --force-prune-with-ignored parses");
523        match cli.verb {
524            Verb::Sync(a) => {
525                assert!(a.force_prune);
526                assert!(a.force_prune_with_ignored);
527            }
528            _ => panic!("expected Sync variant"),
529        }
530    }
531
532    #[test]
533    fn cli_non_empty_string_rejects_whitespace() {
534        // F8: `--ref " "` / `--only "\t"` must be rejected by the value
535        // parser, not silently threaded into the walker / globset layer
536        // where they degrade into useless errors.
537        for bad in ["", " ", "\t", "  ", "\n"] {
538            let err =
539                parse(&["sync", ".", "--ref", bad]).expect_err("whitespace --ref must be rejected");
540            assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation, "for {bad:?}");
541
542            let err = parse(&["sync", ".", "--only", bad])
543                .expect_err("whitespace --only must be rejected");
544            assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation, "for {bad:?}");
545        }
546    }
547}