Skip to main content

flodl_cli/
builtins.rs

1//! Registry of built-in commands. Single source of truth for dispatch,
2//! help listing, collision detection, and shell completion.
3//!
4//! Each leaf sub-command owns a `#[derive(FdlArgs)]` struct that carries
5//! the canonical flag set. `BuiltinSpec::schema_fn` returns the
6//! `Schema` derived from that struct, so completion rules
7//! (`--cuda <TAB>` → `12.6 12.8`, etc.) flow through the same pipeline
8//! as project commands rather than a hand-mirrored flag table.
9
10use crate::args::FdlArgsTrait;
11use crate::config::Schema;
12
13// ---------------------------------------------------------------------------
14// FdlArgs structs (one per leaf sub-command)
15//
16// These dogfood the derive macro across flodl-cli itself. Each is parsed
17// with `parse_or_schema_from(&argv)` from a sliced argv tail; the derive
18// handles argv, `--help`, and `--fdl-schema` uniformly.
19// ---------------------------------------------------------------------------
20
21/// Interactive guided setup wizard.
22#[derive(crate::FdlArgs, Debug)]
23pub struct SetupArgs {
24    /// Skip all prompts and use auto-detected defaults.
25    #[option(short = 'y')]
26    pub non_interactive: bool,
27    /// Re-download or rebuild even if libtorch exists.
28    #[option]
29    pub force: bool,
30}
31
32/// System and GPU diagnostics.
33#[derive(crate::FdlArgs, Debug)]
34pub struct DiagnoseArgs {
35    /// Emit machine-readable JSON.
36    #[option]
37    pub json: bool,
38}
39
40/// Cluster readiness probe.
41///
42/// Default (single-host): probes the local box for GPU + libtorch arch
43/// match + shared-data path + NCCL availability. Cluster context
44/// (`fdl @cluster probe` / `FDL_ENV=cluster`): probes every host in
45/// `fdl.cluster.yml` via SSH and aggregates the report.
46///
47/// Exit code: 0 when every checked component is green; 1 when any
48/// issue was surfaced. `fdl deploy` and CI consume the `--json` shape.
49#[derive(crate::FdlArgs, Debug)]
50pub struct ProbeArgs {
51    /// Emit machine-readable JSON.
52    #[option]
53    pub json: bool,
54    /// Skip the shared-data-mount visibility check. Useful for
55    /// single-host setups without a shared filesystem configured.
56    #[option]
57    pub skip_mount: bool,
58    /// Override the shared-data path (default: cluster.yml's
59    /// per-host `data_path:`, or the convention default
60    /// `/flodl/data` when unset).
61    #[option]
62    pub data_path: Option<std::path::PathBuf>,
63    /// Override the libtorch directory. Default walks up from cwd
64    /// for `libtorch/.active`. Use this when the libtorch install
65    /// lives outside the project tree (e.g. a separate virtiofs
66    /// share mounted at a known path on a worker node).
67    #[option]
68    pub libtorch_path: Option<std::path::PathBuf>,
69    /// Treat NCCL as provided by a Docker image (compose service name
70    /// from `fdl.yml`, e.g. `cuda`). Suppresses host-level NCCL
71    /// discovery and reports "via Docker image `<svc>`" instead. In
72    /// cluster mode, this is auto-derived from each host's `docker:`
73    /// field in `fdl.cluster.yml`.
74    #[option]
75    pub docker: Option<String>,
76}
77
78/// Live cluster run status.
79///
80/// Fetches the controller's `state.json` (membership + lifecycle
81/// phase, served on the training port itself) and pretty-prints it.
82/// Live for the whole run, join window included: shows who has joined
83/// while the world is still forming.
84///
85/// Exit code: 0 when the state was fetched; 1 when no endpoint
86/// answered (usually: no run is up).
87#[derive(crate::FdlArgs, Debug)]
88pub struct StatusArgs {
89    /// Emit the raw state.json body instead of the human summary.
90    #[option]
91    pub json: bool,
92    /// Controller address to query, `host[:port]` (default port 1337).
93    /// Overrides the active env's `cluster.controller`. This is all a
94    /// self-deployed worker's operator needs to watch a run.
95    #[option]
96    pub addr: Option<String>,
97}
98
99/// Fire the operator start switch of a staging cluster run.
100///
101/// A join window opened with `controller.join.start: manual` (or
102/// `hybrid`) holds the roster once quorum is met instead of forming on
103/// the clock — inspect it with `fdl status`, then fire the topology
104/// freeze with this command. Refusals name their reason (auto mode,
105/// quorum not met, window already closed).
106///
107/// Trust mirrors join admission: fired from the controller host (or
108/// through the sshd tunnel) no credential is needed; from anywhere
109/// else pass `--token` (the run's `controller.join.token`).
110///
111/// Exit code: 0 when the start was armed; 1 otherwise.
112#[derive(crate::FdlArgs, Debug)]
113pub struct StartArgs {
114    /// Controller address, `host[:port]` (default port 1337).
115    /// Overrides the active env's `cluster.controller`.
116    #[option]
117    pub addr: Option<String>,
118    /// Session credential for a non-loopback fire (the run's
119    /// `controller.join.token`).
120    #[option]
121    pub token: Option<String>,
122}
123
124/// Join a cluster run's window as a self-deployed worker.
125///
126/// Dials the controller's join channel, offers this box's GPUs, and
127/// runs the training binary (`--bin`) in agent role: the binary joins,
128/// then spawns and supervises this host's relay and rank children
129/// itself. Every flag defaults from the `join:` block of fdl.yml when
130/// present; flags win. Arguments after a standalone `--` go to the
131/// training binary verbatim (they must match the run — rank children
132/// re-enter the binary with them).
133///
134/// Trust, mirroring join admission: `--token` presents the run's
135/// pre-shared credential; `--ssh` reaches a loopback-bound controller
136/// through its guardrailed sshd (reachability = authentication); with
137/// neither, the controller must run open admission.
138///
139/// Before dialing, the box is prepared: the GPU stack is checked, the
140/// dataset source root is put where the ranks will look for it
141/// (`--data-source` mounts it read-only when it is not already there),
142/// the directories the data plane writes are proven writable, and
143/// anything this box does not have yet is acquired — a libtorch variant
144/// (`--libtorch`) and the training binary itself (`--source`, fetched to
145/// local disk and built here, which is what makes its ABI match the
146/// libtorch it holds). Preparation re-runs per attempt, so a `--persist`
147/// box picks up a changed source on its next re-dial.
148///
149/// Exit code: the agent's exit code (0 = this host finished cleanly);
150/// 2 for a failure retrying cannot fix (no GPU, a spec that does not
151/// parse, a missing binary or toolchain), which `--persist` does not
152/// re-dial; 1 for a transient one, which it does — the systemd /
153/// golden-image mode. Source that does not compile counts as transient
154/// on purpose: the fix is a push away at the source, and a box that
155/// stopped permanently over a typo would be powered off by the systemd
156/// recipe. Exception: a compile failure with the vendor toolkit headers
157/// missing is permanent, since re-dialing cannot install a package.
158#[derive(crate::FdlArgs, Debug)]
159pub struct JoinArgs {
160    /// Controller mux address, `host[:port]` (default port 1337).
161    /// With `--ssh`, the address as seen FROM the ssh host — the
162    /// default `127.0.0.1:1337` is the sshd-on-the-controller-box
163    /// convention.
164    #[arg]
165    pub controller: Option<String>,
166    /// SSH tunnel hop, `[user@]host[:port]`: brings up a local `-L`
167    /// forward of the controller port and dials through it.
168    #[option]
169    pub ssh: Option<String>,
170    /// Identity file for the tunnel (`ssh -i`).
171    #[option]
172    pub identity: Option<String>,
173    /// Pre-shared session credential (the run's
174    /// `controller.join.token`).
175    #[option]
176    pub token: Option<String>,
177    /// Training binary to run in agent role, as a path on this box.
178    /// Mutually exclusive with `--source`.
179    #[option]
180    pub bin: Option<String>,
181    /// Build the training binary here instead: a source spec, one of
182    /// `file:///abs/path`, `rsync://[user@]host[:port]:/abs/path` or
183    /// `git+https://host/owner/repo#<tag|branch|sha>`. Fetched to local
184    /// disk, then built against this box's libtorch.
185    #[option]
186    pub source: Option<String>,
187    /// Project directory inside the fetched source tree (default: its
188    /// root). Governs the build and the run both.
189    #[option]
190    pub source_cwd: Option<String>,
191    /// Build recipe for the fetched source, a shell line (default:
192    /// `cargo build --release`). Gets `LIBTORCH_PATH`,
193    /// `FDL_GPU_FEATURE` and `LD_LIBRARY_PATH`.
194    #[option]
195    pub source_build: Option<String>,
196    /// Built artifact, relative to the project directory, e.g.
197    /// `target/release/train`.
198    #[option]
199    pub source_bin: Option<String>,
200    /// libtorch variant to acquire into `~/.flodl/libtorch/` before
201    /// building or running: `auto`, `cpu`, `cu126`, `cu128`, `rocm7.0`,
202    /// `rocm7.1`.
203    /// `auto` routes on this box's own devices, so one image serves
204    /// both vendors. Default: whatever is already active here.
205    #[option]
206    pub libtorch: Option<String>,
207    /// Logical host name in the roster (default: this machine's
208    /// hostname).
209    #[option]
210    pub host: Option<String>,
211    /// GPU device ids to offer, comma-separated (default: all GPUs
212    /// on this host).
213    #[option]
214    pub devices: Option<String>,
215    /// Keep re-dialing across runs with backoff instead of exiting
216    /// when the agent does.
217    #[option]
218    pub persist: bool,
219    /// Dataset source root on this box, shipped to this host's ranks
220    /// (with `--data-source`, the mountpoint; default `/flodl/data`).
221    #[option]
222    pub data_path: Option<String>,
223    /// Transport that establishes the source root when it is not
224    /// already mounted, e.g. `sshfs://user@ctrl:/flodl/data`.
225    #[option]
226    pub data_source: Option<String>,
227    /// Integrated-GPU host-RAM share of this box, a fraction of its
228    /// physical RAM, e.g. 0.5 (APU boxes only; discrete GPUs ignore
229    /// it). Ships to this host's ranks like `--data-path` does.
230    #[option]
231    pub gpu_ram_share: Option<f64>,
232    /// Skip the pre-dial model-signature probe (a short CPU-only
233    /// re-run of the training binary; admission uses the signature to
234    /// refuse a box building a different model). Skip it for a binary
235    /// whose startup is too heavy to run twice per dial.
236    #[option]
237    pub no_sig_probe: bool,
238}
239
240/// Provision a walk-in farm in one pass: the farm overlay
241/// (`fdl.<label>.yml`, token stamped), an ed25519 join key born in
242/// `./.fdl/<label>/` so it cannot be shared across farms by
243/// construction, the guardrailed `authorized_keys` line for the chosen
244/// door, the paste-ready worker yml, a publish recipe derived from the
245/// training crate's own manifest, and a build-freshness report. A farm
246/// IS an env overlay: `fdl @<label> <cmd>` targets it afterwards.
247/// Regenerating credentials for a new farm instantiation is
248/// `fdl join-config <label> --regen`.
249#[derive(crate::FdlArgs, Debug)]
250pub struct JoinConfigArgs {
251    /// Farm label — names `fdl.<label>.yml` and `.fdl/<label>/`.
252    /// Defaults to the active env (`fdl @<label> join-config`).
253    #[arg]
254    pub label: Option<String>,
255    /// How workers reach the join sshd: `[user@]host[:port]` (default:
256    /// this box's hostname, the invoking user, port 22).
257    #[option]
258    pub controller: Option<String>,
259    /// Guardrail door the join key opens: `b` (rrsync source pull, the
260    /// publish-then-join default), `a` (read-only sftp data mount), or
261    /// `nologin` (tunnel only).
262    #[option(choices = &["b", "a", "nologin"])]
263    pub door: Option<String>,
264    /// Training crate to derive the publish recipe from (default: the
265    /// current directory; no crate there is fine — a farm can be
266    /// config-only).
267    #[option]
268    pub crate_dir: Option<String>,
269    /// Dataset source root workers read; under door `a` it is also the
270    /// sshfs mountpoint (default /flodl/data there).
271    #[option]
272    pub data_path: Option<String>,
273    /// Integrated-GPU host-RAM share stamped into the worker yml
274    /// (APU fleets; discrete GPUs ignore it).
275    #[option]
276    pub gpu_ram_share: Option<f64>,
277    /// Regenerate credentials (key and token) without asking. Workers
278    /// holding the old ones stop being admitted.
279    #[option]
280    pub regen: bool,
281    /// Install the guardrailed line into this user's own
282    /// `~/.ssh/authorized_keys` without asking (the wizard's default
283    /// offer). Only the wizard's own line is ever touched; `/etc/ssh`
284    /// never is.
285    #[option]
286    pub install_key: bool,
287    /// Also emit a cloud-init user-data file: the worker yml, private
288    /// key and systemd unit embedded, so a cloud instance boots
289    /// straight into `fdl join`. A SECRET artifact (key + token
290    /// inside).
291    #[option]
292    pub cloud_init: bool,
293    /// The instance user the cloud-init variant provisions for
294    /// (default: ubuntu). Images that log in as root want `root`;
295    /// DigitalOcean and the AMD Developer Cloud are that shape.
296    #[option]
297    pub cloud_init_user: Option<String>,
298    /// Skip the authorized_keys install (print + notes only).
299    #[option]
300    pub no_install_key: bool,
301    /// Install into this authorized_keys file instead of the invoking
302    /// user's `~/.ssh/authorized_keys` — for a door the default cannot
303    /// reach, such as an sshd in a container (its key file is a bind
304    /// mount) or a host using `AuthorizedKeysFile
305    /// /etc/ssh/authorized_keys.d/%u`. Still consent-gated, still
306    /// touches only the wizard's own line; `/etc/ssh` is refused.
307    #[option]
308    pub authorized_keys: Option<String>,
309    /// Accept every default without prompting (non-interactive; reuses
310    /// existing credentials unless `--regen`). Never consents to the
311    /// authorized_keys install: that takes the prompt or `--install-key`.
312    #[option]
313    pub yes: bool,
314    /// Report as JSON on stdout. Secrets appear as file paths, never
315    /// payloads.
316    #[option]
317    pub json: bool,
318}
319
320/// Publish a training run for a fleet to pull.
321///
322/// The controller side of compiling on the node: resolves a source spec
323/// into a served directory, builds it once as a gate, and writes the run
324/// manifest workers read. Chaining runs on a standing fleet is then one
325/// command — publish again and every box picks the new run up on its next
326/// dial, with nothing to edit on any worker.
327///
328/// Arguments after a standalone `--` are the training binary's own, and
329/// they go in the manifest rather than into any worker's config: they must
330/// match the run, because rank children re-enter the binary with them, so
331/// a fleet carrying its own copy would train the next run with the
332/// previous one's hyperparameters.
333///
334/// The build proves the tree for THIS box's libtorch variant, and one
335/// build is all it is: every worker still compiles its own, since a
336/// controller producing binaries for N variants is a build matrix. A gate
337/// needs no GPU libtorch — `fdl libtorch download --cpu` is enough —
338/// because it is catching user-code errors, not shipping an artifact.
339///
340/// Exit code: 0 when the run is published; 1 otherwise, and a failed gate
341/// publishes nothing, so the fleet keeps running whatever it had.
342#[derive(crate::FdlArgs, Debug)]
343pub struct PublishArgs {
344    /// Source to publish: `file:///abs/path`,
345    /// `rsync://[user@]host[:port]:/abs/path`, or
346    /// `git+https://host/owner/repo#<tag|branch|sha>`.
347    #[arg]
348    pub source: Option<String>,
349    /// Built artifact, relative to the project directory — what workers
350    /// run. Required: a workspace member's build lands in the WORKSPACE
351    /// `target/`, so no rule fdl invented would be right for everyone.
352    #[option]
353    pub bin: Option<String>,
354    /// Project directory inside the tree (default: its root). Governs
355    /// the build and the run both.
356    #[option]
357    pub cwd: Option<String>,
358    /// Build recipe, a shell line (default: `cargo build --release`).
359    /// Gets `LIBTORCH_PATH`, `FDL_GPU_FEATURE` and `LD_LIBRARY_PATH`.
360    #[option]
361    pub build: Option<String>,
362    /// Served directory (default: `~/.flodl/run`). The tree lands in
363    /// `<dir>/tree`, which is what a worker's source spec points at.
364    #[option]
365    pub to: Option<String>,
366    /// Skip the build gate. Publishes source nothing has compiled, so
367    /// the first worker to fetch it is where a broken build surfaces.
368    #[option]
369    pub no_build: bool,
370    /// Identity file for a source pulled over ssh (`rsync -e ssh -i`).
371    #[option]
372    pub identity: Option<String>,
373    /// Emit the report as JSON on stdout (notes stay on stderr). The
374    /// machine twin of the human report, for dashboards and scripts.
375    #[option]
376    pub json: bool,
377    /// Extra check-build against another libtorch variant (a subpath
378    /// under `<project>/libtorch/`, e.g. `precompiled/rocm70`).
379    /// Repeatable. The primary gate proves only this box's variant, so
380    /// a break that exists only under the other vendor's feature would
381    /// land on a worker. Needs no GPU; a flodl-linking crate still
382    /// needs that vendor's dev headers here (a package install, and
383    /// the failure names the exact one).
384    #[option]
385    pub gate: Vec<String>,
386}
387
388impl PublishArgs {
389    /// Credentials for a source pulled over ssh. The spec itself carries
390    /// user, host and port, so only the key can be missing.
391    pub fn ssh_config(&self) -> Option<crate::config::SshConfig> {
392        self.identity.as_ref().map(|id| crate::config::SshConfig {
393            identity_file: Some(id.clone()),
394            ..Default::default()
395        })
396    }
397}
398
399/// Generate flodl API reference.
400#[derive(crate::FdlArgs, Debug)]
401pub struct ApiRefArgs {
402    /// Emit machine-readable JSON.
403    #[option]
404    pub json: bool,
405    /// Explicit flodl source path (defaults to detected project root).
406    #[option]
407    pub path: Option<String>,
408}
409
410/// Scaffold a new floDl project.
411///
412/// Three modes, mutually exclusive:
413///   default (no flag) — Docker with host-mounted libtorch (recommended)
414///   --docker          — Docker with libtorch baked into the image
415///   --native          — no Docker, host-provided libtorch + cargo
416#[derive(crate::FdlArgs, Debug)]
417pub struct InitArgs {
418    /// New project directory name.
419    #[arg]
420    pub name: Option<String>,
421    /// Generate a Docker scaffold with libtorch baked into the image.
422    #[option]
423    pub docker: bool,
424    /// Generate a native scaffold (no Docker; libtorch provided on the host).
425    #[option]
426    pub native: bool,
427    /// Also scaffold the flodl-hf HuggingFace playground (skips the prompt).
428    #[option]
429    pub with_hf: bool,
430}
431
432/// Add a flodl ecosystem crate to the current flodl project.
433///
434/// Currently supports `flodl-hf` (alias: `hf`). Two modes (combinable):
435///
436/// - `--playground`: drops a standalone cargo crate under `./flodl-hf/`
437///   with pinned deps and a one-file AutoModel example, plus a
438///   `flodl-hf:` entry in the root `fdl.yml` so `fdl flodl-hf <cmd>`
439///   routes into it. Try-it-out path; doesn't touch `Cargo.toml`.
440/// - `--install`: appends `flodl-hf = "=X.Y.Z"` (default features) to
441///   the root `Cargo.toml` `[dependencies]`. Wires the crate into the
442///   user's own code; doesn't create a subdir.
443///
444/// With neither flag, an interactive prompt asks. Non-tty stdin errors
445/// loudly rather than silently picking a default.
446#[derive(crate::FdlArgs, Debug)]
447pub struct AddArgs {
448    /// Target to scaffold (currently: `flodl-hf` or the alias `hf`).
449    #[arg]
450    pub target: Option<String>,
451    /// Drop a sandbox playground under `./flodl-hf/`.
452    #[option]
453    pub playground: bool,
454    /// Add as a dependency in the root `Cargo.toml`.
455    #[option]
456    pub install: bool,
457}
458
459/// Install or update fdl globally (~/.local/bin/fdl).
460#[derive(crate::FdlArgs, Debug)]
461pub struct InstallArgs {
462    /// Check for updates without installing.
463    #[option]
464    pub check: bool,
465    /// Symlink to the current binary (tracks local builds).
466    #[option]
467    pub dev: bool,
468}
469
470/// List installed libtorch variants.
471#[derive(crate::FdlArgs, Debug)]
472pub struct LibtorchListArgs {
473    /// Emit machine-readable JSON.
474    #[option]
475    pub json: bool,
476}
477
478/// Activate a libtorch variant.
479#[derive(crate::FdlArgs, Debug)]
480pub struct LibtorchActivateArgs {
481    /// Variant to activate (as shown by `fdl libtorch list`).
482    #[arg]
483    pub variant: Option<String>,
484}
485
486/// Remove a libtorch variant.
487#[derive(crate::FdlArgs, Debug)]
488pub struct LibtorchRemoveArgs {
489    /// Variant to remove (as shown by `fdl libtorch list`).
490    #[arg]
491    pub variant: Option<String>,
492}
493
494/// Download a pre-built libtorch variant.
495#[derive(crate::FdlArgs, Debug)]
496pub struct LibtorchDownloadArgs {
497    /// Force the CPU variant.
498    #[option]
499    pub cpu: bool,
500    /// Pick a specific CUDA version (instead of auto-detect).
501    #[option(choices = &["12.6", "12.8"])]
502    pub cuda: Option<String>,
503    /// Pick an AMD ROCm build instead of CUDA. Both cover the same gfx
504    /// targets; pick the one matching this host's own ROCm, since the
505    /// host runtime loads ahead of the bundled one.
506    #[option(choices = &["7.0", "7.1"])]
507    pub rocm: Option<String>,
508    /// Install libtorch to this directory (default: project libtorch/).
509    #[option]
510    pub path: Option<String>,
511    /// Do not activate after download.
512    #[option]
513    pub no_activate: bool,
514    /// Show what would happen without downloading.
515    #[option]
516    pub dry_run: bool,
517}
518
519/// Build libtorch from source.
520#[derive(crate::FdlArgs, Debug)]
521pub struct LibtorchBuildArgs {
522    /// Override CUDA architectures (semicolon-separated, e.g. "6.1;12.0").
523    #[option]
524    pub archs: Option<String>,
525    /// Parallel compilation jobs.
526    #[option(default = "6")]
527    pub jobs: usize,
528    /// Force Docker build (isolated, reproducible).
529    #[option]
530    pub docker: bool,
531    /// Force native build (faster, requires host toolchain).
532    #[option]
533    pub native: bool,
534    /// Show what would happen without building.
535    #[option]
536    pub dry_run: bool,
537}
538
539/// Build NCCL from NVIDIA source for a heterogeneous-arch rig.
540#[derive(crate::FdlArgs, Debug)]
541pub struct NcclBuildArgs {
542    /// NCCL git tag to build (e.g. "v2.27.5-1"). Default: infer from the
543    /// active libtorch's bundled NCCL version string (the version we must
544    /// match for cross-rank handshake).
545    #[option]
546    pub tag: Option<String>,
547    /// Override CUDA architectures (semicolon-separated, e.g. "6.1;12.0").
548    /// Default: auto-detect from local GPUs.
549    #[option]
550    pub archs: Option<String>,
551    /// Parallel compilation jobs.
552    #[option(default = "6")]
553    pub jobs: usize,
554    /// Show what would happen without building.
555    #[option]
556    pub dry_run: bool,
557}
558
559/// Install AI coding assistant skills.
560#[derive(crate::FdlArgs, Debug)]
561pub struct SkillInstallArgs {
562    /// Target tool (defaults to auto-detect).
563    #[option]
564    pub tool: Option<String>,
565    /// Specific skill name (defaults to all detected skills).
566    #[option]
567    pub skill: Option<String>,
568}
569
570/// List cached `--fdl-schema` outputs.
571#[derive(crate::FdlArgs, Debug)]
572pub struct SchemaListArgs {
573    /// Emit machine-readable JSON.
574    #[option]
575    pub json: bool,
576}
577
578/// Clear cached schemas. No command name clears all.
579#[derive(crate::FdlArgs, Debug)]
580pub struct SchemaClearArgs {
581    /// Command name to clear (defaults to all).
582    #[arg]
583    pub cmd: Option<String>,
584}
585
586/// Re-probe each entry and rewrite the cache.
587#[derive(crate::FdlArgs, Debug)]
588pub struct SchemaRefreshArgs {
589    /// Command name to refresh (defaults to all).
590    #[arg]
591    pub cmd: Option<String>,
592}
593
594// ---------------------------------------------------------------------------
595// Registry
596// ---------------------------------------------------------------------------
597
598/// One built-in command (or sub-command) slot.
599pub struct BuiltinSpec {
600    /// Path from the top-level command name. `["install"]`,
601    /// `["libtorch", "download"]`.
602    pub path: &'static [&'static str],
603    /// One-line description for `fdl -h` listing. `None` = hidden
604    /// (reserved for collision detection but not shown in help).
605    pub description: Option<&'static str>,
606    /// Constructor for the command's schema. `None` for parent commands
607    /// that only group sub-commands (e.g. `libtorch` itself has no args)
608    /// or for leaves whose argv is parsed by hand (`config show`,
609    /// `completions`, `autocomplete`).
610    pub schema_fn: Option<fn() -> Schema>,
611}
612
613/// Ordered registry of every built-in. Order drives `fdl -h` and the
614/// top-level completion word list, so it mirrors today's `BUILTINS`
615/// const in `main.rs`.
616pub fn registry() -> &'static [BuiltinSpec] {
617    static REG: &[BuiltinSpec] = &[
618        BuiltinSpec {
619            path: &["setup"],
620            description: Some("Interactive guided setup"),
621            schema_fn: Some(SetupArgs::schema),
622        },
623        BuiltinSpec {
624            path: &["libtorch"],
625            description: Some("Manage libtorch installations"),
626            schema_fn: None,
627        },
628        BuiltinSpec {
629            path: &["libtorch", "download"],
630            description: Some("Download pre-built libtorch"),
631            schema_fn: Some(LibtorchDownloadArgs::schema),
632        },
633        BuiltinSpec {
634            path: &["libtorch", "build"],
635            description: Some("Build libtorch from source"),
636            schema_fn: Some(LibtorchBuildArgs::schema),
637        },
638        BuiltinSpec {
639            path: &["libtorch", "list"],
640            description: Some("Show installed variants"),
641            schema_fn: Some(LibtorchListArgs::schema),
642        },
643        BuiltinSpec {
644            path: &["libtorch", "activate"],
645            description: Some("Set active variant"),
646            schema_fn: Some(LibtorchActivateArgs::schema),
647        },
648        BuiltinSpec {
649            path: &["libtorch", "remove"],
650            description: Some("Remove a variant"),
651            schema_fn: Some(LibtorchRemoveArgs::schema),
652        },
653        BuiltinSpec {
654            path: &["libtorch", "info"],
655            description: Some("Show active variant details"),
656            schema_fn: None,
657        },
658        BuiltinSpec {
659            path: &["nccl"],
660            description: Some("Build NCCL from source (heterogeneous-arch bridge)"),
661            schema_fn: None,
662        },
663        BuiltinSpec {
664            path: &["nccl", "build"],
665            description: Some("Compile libnccl for the local GPU arch"),
666            schema_fn: Some(NcclBuildArgs::schema),
667        },
668        BuiltinSpec {
669            path: &["init"],
670            description: Some("Scaffold a new floDl project"),
671            schema_fn: Some(InitArgs::schema),
672        },
673        BuiltinSpec {
674            path: &["add"],
675            description: Some("Add a flodl ecosystem crate (currently: flodl-hf)"),
676            schema_fn: Some(AddArgs::schema),
677        },
678        BuiltinSpec {
679            path: &["diagnose"],
680            description: Some("System and GPU diagnostics"),
681            schema_fn: Some(DiagnoseArgs::schema),
682        },
683        BuiltinSpec {
684            path: &["probe"],
685            description: Some("Cluster readiness probe (GPU + libtorch + data mount)"),
686            schema_fn: Some(ProbeArgs::schema),
687        },
688        BuiltinSpec {
689            path: &["status"],
690            description: Some("Live cluster run status (membership, lifecycle phase)"),
691            schema_fn: Some(StatusArgs::schema),
692        },
693        BuiltinSpec {
694            path: &["start"],
695            description: Some("Fire the start switch of a staging cluster run"),
696            schema_fn: Some(StartArgs::schema),
697        },
698        BuiltinSpec {
699            path: &["publish"],
700            description: Some("Publish a training run for a fleet to pull"),
701            schema_fn: Some(PublishArgs::schema),
702        },
703        BuiltinSpec {
704            path: &["join"],
705            description: Some("Join a cluster run's window as a self-deployed worker"),
706            schema_fn: Some(JoinArgs::schema),
707        },
708        BuiltinSpec {
709            path: &["join-config"],
710            description: Some("Provision a walk-in farm: overlay, credentials, worker yml"),
711            schema_fn: Some(JoinConfigArgs::schema),
712        },
713        BuiltinSpec {
714            path: &["install"],
715            description: Some("Install or update fdl globally"),
716            schema_fn: Some(InstallArgs::schema),
717        },
718        BuiltinSpec {
719            path: &["skill"],
720            description: Some("Manage AI coding assistant skills"),
721            schema_fn: None,
722        },
723        BuiltinSpec {
724            path: &["skill", "install"],
725            description: Some("Install skills for the detected tool"),
726            schema_fn: Some(SkillInstallArgs::schema),
727        },
728        BuiltinSpec {
729            path: &["skill", "list"],
730            description: Some("Show available skills"),
731            schema_fn: None,
732        },
733        BuiltinSpec {
734            path: &["api-ref"],
735            description: Some("Generate flodl API reference"),
736            schema_fn: Some(ApiRefArgs::schema),
737        },
738        BuiltinSpec {
739            path: &["config"],
740            description: Some("Inspect resolved project configuration"),
741            schema_fn: None,
742        },
743        BuiltinSpec {
744            path: &["config", "show"],
745            description: Some("Print the resolved merged config"),
746            schema_fn: None,
747        },
748        BuiltinSpec {
749            path: &["schema"],
750            description: Some("Inspect, clear, or refresh cached --fdl-schema outputs"),
751            schema_fn: None,
752        },
753        BuiltinSpec {
754            path: &["schema", "list"],
755            description: Some("Show every cached schema with status"),
756            schema_fn: Some(SchemaListArgs::schema),
757        },
758        BuiltinSpec {
759            path: &["schema", "clear"],
760            description: Some("Delete cached schema(s)"),
761            schema_fn: Some(SchemaClearArgs::schema),
762        },
763        BuiltinSpec {
764            path: &["schema", "refresh"],
765            description: Some("Re-probe each entry and rewrite the cache"),
766            schema_fn: Some(SchemaRefreshArgs::schema),
767        },
768        BuiltinSpec {
769            path: &["completions"],
770            description: Some("Emit shell completion script (bash|zsh|fish)"),
771            schema_fn: None,
772        },
773        BuiltinSpec {
774            path: &["autocomplete"],
775            description: Some("Install completions into the detected shell"),
776            schema_fn: None,
777        },
778        // Hidden: `version` is covered by `-V` / `--version` but still
779        // reserved as a top-level built-in name.
780        BuiltinSpec {
781            path: &["version"],
782            description: None,
783            schema_fn: None,
784        },
785    ];
786    REG
787}
788
789/// True when `name` is a reserved top-level built-in (visible or hidden).
790pub fn is_builtin_name(name: &str) -> bool {
791    registry()
792        .iter()
793        .any(|s| s.path.len() == 1 && s.path[0] == name)
794}
795
796/// Visible top-level built-ins as `(name, description)` pairs, in
797/// registry order. Feeds `run::print_project_help` and the fallback
798/// `print_usage`.
799pub fn visible_top_level() -> Vec<(&'static str, &'static str)> {
800    registry()
801        .iter()
802        .filter(|s| s.path.len() == 1)
803        .filter_map(|s| s.description.map(|d| (s.path[0], d)))
804        .collect()
805}
806
807#[cfg(test)]
808mod tests {
809    use super::*;
810    use std::collections::HashSet;
811
812    #[test]
813    fn registry_has_no_duplicate_paths() {
814        let mut seen = HashSet::new();
815        for s in registry() {
816            let key = s.path.join(" ");
817            assert!(seen.insert(key.clone()), "duplicate registry path: {key}");
818        }
819    }
820
821    #[test]
822    fn hidden_entries_have_no_description() {
823        for s in registry() {
824            if s.path == ["version"] {
825                assert!(
826                    s.description.is_none(),
827                    "`version` is hidden but carries a description"
828                );
829            }
830        }
831    }
832
833    #[test]
834    fn every_parent_has_at_least_one_child() {
835        let parents: HashSet<&str> = registry()
836            .iter()
837            .filter(|s| s.path.len() == 1 && s.schema_fn.is_none() && s.description.is_some())
838            .map(|s| s.path[0])
839            .collect();
840
841        // `completions`, `autocomplete` are leaves with no schema — exclude
842        // them by checking that parents have at least one 2-path child.
843        for parent in &parents {
844            let has_child = registry()
845                .iter()
846                .any(|s| s.path.len() == 2 && s.path[0] == *parent);
847            if !has_child {
848                // `completions` / `autocomplete` / `version` end up here by
849                // virtue of having no children; they are leaf built-ins.
850                continue;
851            }
852            assert!(has_child, "parent `{parent}` has no child entries");
853        }
854    }
855
856    #[test]
857    fn top_level_dispatched_by_main_is_in_registry() {
858        // Compile-time guard: every match arm target in main.rs is listed
859        // here. Keeping the list local (rather than introspecting main.rs)
860        // documents the coupling explicitly.
861        let dispatched = [
862            "setup",
863            "libtorch",
864            "nccl",
865            "diagnose",
866            "probe",
867            "status",
868            "start",
869            "publish",
870            "join",
871            "join-config",
872            "api-ref",
873            "init",
874            "add",
875            "install",
876            "skill",
877            "schema",
878            "completions",
879            "autocomplete",
880            "config",
881            "version",
882        ];
883        for name in &dispatched {
884            assert!(
885                is_builtin_name(name),
886                "`{name}` dispatched by main.rs but missing from registry"
887            );
888        }
889    }
890
891    #[test]
892    fn visible_top_level_matches_help_ordering() {
893        let top = visible_top_level();
894        let names: Vec<&str> = top.iter().map(|(n, _)| *n).collect();
895        // Lock in the order that `fdl -h` depends on.
896        assert_eq!(
897            names,
898            vec![
899                "setup",
900                "libtorch",
901                "nccl",
902                "init",
903                "add",
904                "diagnose",
905                "probe",
906                "status",
907                "start",
908                "publish",
909                "join",
910                "join-config",
911                "install",
912                "skill",
913                "api-ref",
914                "config",
915                "schema",
916                "completions",
917                "autocomplete",
918            ]
919        );
920    }
921
922    #[test]
923    fn libtorch_download_schema_carries_cuda_choices() {
924        let spec = registry()
925            .iter()
926            .find(|s| s.path == ["libtorch", "download"])
927            .expect("libtorch download entry present");
928        let schema = (spec.schema_fn.expect("download has schema"))();
929        let cuda = schema
930            .options
931            .get("cuda")
932            .expect("`--cuda` option declared");
933        let choices = cuda.choices.as_ref().expect("--cuda has choices");
934        let values: Vec<String> = choices
935            .iter()
936            .filter_map(|v| v.as_str().map(str::to_string))
937            .collect();
938        assert_eq!(values, vec!["12.6".to_string(), "12.8".into()]);
939    }
940}