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/// Generate flodl API reference.
100#[derive(crate::FdlArgs, Debug)]
101pub struct ApiRefArgs {
102    /// Emit machine-readable JSON.
103    #[option]
104    pub json: bool,
105    /// Explicit flodl source path (defaults to detected project root).
106    #[option]
107    pub path: Option<String>,
108}
109
110/// Scaffold a new floDl project.
111///
112/// Three modes, mutually exclusive:
113///   default (no flag) — Docker with host-mounted libtorch (recommended)
114///   --docker          — Docker with libtorch baked into the image
115///   --native          — no Docker, host-provided libtorch + cargo
116#[derive(crate::FdlArgs, Debug)]
117pub struct InitArgs {
118    /// New project directory name.
119    #[arg]
120    pub name: Option<String>,
121    /// Generate a Docker scaffold with libtorch baked into the image.
122    #[option]
123    pub docker: bool,
124    /// Generate a native scaffold (no Docker; libtorch provided on the host).
125    #[option]
126    pub native: bool,
127    /// Also scaffold the flodl-hf HuggingFace playground (skips the prompt).
128    #[option]
129    pub with_hf: bool,
130}
131
132/// Add a flodl ecosystem crate to the current flodl project.
133///
134/// Currently supports `flodl-hf` (alias: `hf`). Two modes (combinable):
135///
136/// - `--playground`: drops a standalone cargo crate under `./flodl-hf/`
137///   with pinned deps and a one-file AutoModel example, plus a
138///   `flodl-hf:` entry in the root `fdl.yml` so `fdl flodl-hf <cmd>`
139///   routes into it. Try-it-out path; doesn't touch `Cargo.toml`.
140/// - `--install`: appends `flodl-hf = "=X.Y.Z"` (default features) to
141///   the root `Cargo.toml` `[dependencies]`. Wires the crate into the
142///   user's own code; doesn't create a subdir.
143///
144/// With neither flag, an interactive prompt asks. Non-tty stdin errors
145/// loudly rather than silently picking a default.
146#[derive(crate::FdlArgs, Debug)]
147pub struct AddArgs {
148    /// Target to scaffold (currently: `flodl-hf` or the alias `hf`).
149    #[arg]
150    pub target: Option<String>,
151    /// Drop a sandbox playground under `./flodl-hf/`.
152    #[option]
153    pub playground: bool,
154    /// Add as a dependency in the root `Cargo.toml`.
155    #[option]
156    pub install: bool,
157}
158
159/// Install or update fdl globally (~/.local/bin/fdl).
160#[derive(crate::FdlArgs, Debug)]
161pub struct InstallArgs {
162    /// Check for updates without installing.
163    #[option]
164    pub check: bool,
165    /// Symlink to the current binary (tracks local builds).
166    #[option]
167    pub dev: bool,
168}
169
170/// List installed libtorch variants.
171#[derive(crate::FdlArgs, Debug)]
172pub struct LibtorchListArgs {
173    /// Emit machine-readable JSON.
174    #[option]
175    pub json: bool,
176}
177
178/// Activate a libtorch variant.
179#[derive(crate::FdlArgs, Debug)]
180pub struct LibtorchActivateArgs {
181    /// Variant to activate (as shown by `fdl libtorch list`).
182    #[arg]
183    pub variant: Option<String>,
184}
185
186/// Remove a libtorch variant.
187#[derive(crate::FdlArgs, Debug)]
188pub struct LibtorchRemoveArgs {
189    /// Variant to remove (as shown by `fdl libtorch list`).
190    #[arg]
191    pub variant: Option<String>,
192}
193
194/// Download a pre-built libtorch variant.
195#[derive(crate::FdlArgs, Debug)]
196pub struct LibtorchDownloadArgs {
197    /// Force the CPU variant.
198    #[option]
199    pub cpu: bool,
200    /// Pick a specific CUDA version (instead of auto-detect).
201    #[option(choices = &["12.6", "12.8"])]
202    pub cuda: Option<String>,
203    /// Install libtorch to this directory (default: project libtorch/).
204    #[option]
205    pub path: Option<String>,
206    /// Do not activate after download.
207    #[option]
208    pub no_activate: bool,
209    /// Show what would happen without downloading.
210    #[option]
211    pub dry_run: bool,
212}
213
214/// Build libtorch from source.
215#[derive(crate::FdlArgs, Debug)]
216pub struct LibtorchBuildArgs {
217    /// Override CUDA architectures (semicolon-separated, e.g. "6.1;12.0").
218    #[option]
219    pub archs: Option<String>,
220    /// Parallel compilation jobs.
221    #[option(default = "6")]
222    pub jobs: usize,
223    /// Force Docker build (isolated, reproducible).
224    #[option]
225    pub docker: bool,
226    /// Force native build (faster, requires host toolchain).
227    #[option]
228    pub native: bool,
229    /// Show what would happen without building.
230    #[option]
231    pub dry_run: bool,
232}
233
234/// Build NCCL from NVIDIA source for a heterogeneous-arch rig.
235#[derive(crate::FdlArgs, Debug)]
236pub struct NcclBuildArgs {
237    /// NCCL git tag to build (e.g. "v2.27.5-1"). Default: infer from the
238    /// active libtorch's bundled NCCL version string (the version we must
239    /// match for cross-rank handshake).
240    #[option]
241    pub tag: Option<String>,
242    /// Override CUDA architectures (semicolon-separated, e.g. "6.1;12.0").
243    /// Default: auto-detect from local GPUs.
244    #[option]
245    pub archs: Option<String>,
246    /// Parallel compilation jobs.
247    #[option(default = "6")]
248    pub jobs: usize,
249    /// Show what would happen without building.
250    #[option]
251    pub dry_run: bool,
252}
253
254/// Install AI coding assistant skills.
255#[derive(crate::FdlArgs, Debug)]
256pub struct SkillInstallArgs {
257    /// Target tool (defaults to auto-detect).
258    #[option]
259    pub tool: Option<String>,
260    /// Specific skill name (defaults to all detected skills).
261    #[option]
262    pub skill: Option<String>,
263}
264
265/// List cached `--fdl-schema` outputs.
266#[derive(crate::FdlArgs, Debug)]
267pub struct SchemaListArgs {
268    /// Emit machine-readable JSON.
269    #[option]
270    pub json: bool,
271}
272
273/// Clear cached schemas. No command name clears all.
274#[derive(crate::FdlArgs, Debug)]
275pub struct SchemaClearArgs {
276    /// Command name to clear (defaults to all).
277    #[arg]
278    pub cmd: Option<String>,
279}
280
281/// Re-probe each entry and rewrite the cache.
282#[derive(crate::FdlArgs, Debug)]
283pub struct SchemaRefreshArgs {
284    /// Command name to refresh (defaults to all).
285    #[arg]
286    pub cmd: Option<String>,
287}
288
289// ---------------------------------------------------------------------------
290// Registry
291// ---------------------------------------------------------------------------
292
293/// One built-in command (or sub-command) slot.
294pub struct BuiltinSpec {
295    /// Path from the top-level command name. `["install"]`,
296    /// `["libtorch", "download"]`.
297    pub path: &'static [&'static str],
298    /// One-line description for `fdl -h` listing. `None` = hidden
299    /// (reserved for collision detection but not shown in help).
300    pub description: Option<&'static str>,
301    /// Constructor for the command's schema. `None` for parent commands
302    /// that only group sub-commands (e.g. `libtorch` itself has no args)
303    /// or for leaves whose argv is parsed by hand (`config show`,
304    /// `completions`, `autocomplete`).
305    pub schema_fn: Option<fn() -> Schema>,
306}
307
308/// Ordered registry of every built-in. Order drives `fdl -h` and the
309/// top-level completion word list, so it mirrors today's `BUILTINS`
310/// const in `main.rs`.
311pub fn registry() -> &'static [BuiltinSpec] {
312    static REG: &[BuiltinSpec] = &[
313        BuiltinSpec {
314            path: &["setup"],
315            description: Some("Interactive guided setup"),
316            schema_fn: Some(SetupArgs::schema),
317        },
318        BuiltinSpec {
319            path: &["libtorch"],
320            description: Some("Manage libtorch installations"),
321            schema_fn: None,
322        },
323        BuiltinSpec {
324            path: &["libtorch", "download"],
325            description: Some("Download pre-built libtorch"),
326            schema_fn: Some(LibtorchDownloadArgs::schema),
327        },
328        BuiltinSpec {
329            path: &["libtorch", "build"],
330            description: Some("Build libtorch from source"),
331            schema_fn: Some(LibtorchBuildArgs::schema),
332        },
333        BuiltinSpec {
334            path: &["libtorch", "list"],
335            description: Some("Show installed variants"),
336            schema_fn: Some(LibtorchListArgs::schema),
337        },
338        BuiltinSpec {
339            path: &["libtorch", "activate"],
340            description: Some("Set active variant"),
341            schema_fn: Some(LibtorchActivateArgs::schema),
342        },
343        BuiltinSpec {
344            path: &["libtorch", "remove"],
345            description: Some("Remove a variant"),
346            schema_fn: Some(LibtorchRemoveArgs::schema),
347        },
348        BuiltinSpec {
349            path: &["libtorch", "info"],
350            description: Some("Show active variant details"),
351            schema_fn: None,
352        },
353        BuiltinSpec {
354            path: &["nccl"],
355            description: Some("Build NCCL from source (heterogeneous-arch bridge)"),
356            schema_fn: None,
357        },
358        BuiltinSpec {
359            path: &["nccl", "build"],
360            description: Some("Compile libnccl for the local GPU arch"),
361            schema_fn: Some(NcclBuildArgs::schema),
362        },
363        BuiltinSpec {
364            path: &["init"],
365            description: Some("Scaffold a new floDl project"),
366            schema_fn: Some(InitArgs::schema),
367        },
368        BuiltinSpec {
369            path: &["add"],
370            description: Some("Add a flodl ecosystem crate (currently: flodl-hf)"),
371            schema_fn: Some(AddArgs::schema),
372        },
373        BuiltinSpec {
374            path: &["diagnose"],
375            description: Some("System and GPU diagnostics"),
376            schema_fn: Some(DiagnoseArgs::schema),
377        },
378        BuiltinSpec {
379            path: &["probe"],
380            description: Some(
381                "Cluster readiness probe (GPU + libtorch + data mount)",
382            ),
383            schema_fn: Some(ProbeArgs::schema),
384        },
385        BuiltinSpec {
386            path: &["status"],
387            description: Some(
388                "Live cluster run status (membership, lifecycle phase)",
389            ),
390            schema_fn: Some(StatusArgs::schema),
391        },
392        BuiltinSpec {
393            path: &["install"],
394            description: Some("Install or update fdl globally"),
395            schema_fn: Some(InstallArgs::schema),
396        },
397        BuiltinSpec {
398            path: &["skill"],
399            description: Some("Manage AI coding assistant skills"),
400            schema_fn: None,
401        },
402        BuiltinSpec {
403            path: &["skill", "install"],
404            description: Some("Install skills for the detected tool"),
405            schema_fn: Some(SkillInstallArgs::schema),
406        },
407        BuiltinSpec {
408            path: &["skill", "list"],
409            description: Some("Show available skills"),
410            schema_fn: None,
411        },
412        BuiltinSpec {
413            path: &["api-ref"],
414            description: Some("Generate flodl API reference"),
415            schema_fn: Some(ApiRefArgs::schema),
416        },
417        BuiltinSpec {
418            path: &["config"],
419            description: Some("Inspect resolved project configuration"),
420            schema_fn: None,
421        },
422        BuiltinSpec {
423            path: &["config", "show"],
424            description: Some("Print the resolved merged config"),
425            schema_fn: None,
426        },
427        BuiltinSpec {
428            path: &["schema"],
429            description: Some("Inspect, clear, or refresh cached --fdl-schema outputs"),
430            schema_fn: None,
431        },
432        BuiltinSpec {
433            path: &["schema", "list"],
434            description: Some("Show every cached schema with status"),
435            schema_fn: Some(SchemaListArgs::schema),
436        },
437        BuiltinSpec {
438            path: &["schema", "clear"],
439            description: Some("Delete cached schema(s)"),
440            schema_fn: Some(SchemaClearArgs::schema),
441        },
442        BuiltinSpec {
443            path: &["schema", "refresh"],
444            description: Some("Re-probe each entry and rewrite the cache"),
445            schema_fn: Some(SchemaRefreshArgs::schema),
446        },
447        BuiltinSpec {
448            path: &["completions"],
449            description: Some("Emit shell completion script (bash|zsh|fish)"),
450            schema_fn: None,
451        },
452        BuiltinSpec {
453            path: &["autocomplete"],
454            description: Some("Install completions into the detected shell"),
455            schema_fn: None,
456        },
457        // Hidden: `version` is covered by `-V` / `--version` but still
458        // reserved as a top-level built-in name.
459        BuiltinSpec {
460            path: &["version"],
461            description: None,
462            schema_fn: None,
463        },
464    ];
465    REG
466}
467
468/// True when `name` is a reserved top-level built-in (visible or hidden).
469pub fn is_builtin_name(name: &str) -> bool {
470    registry()
471        .iter()
472        .any(|s| s.path.len() == 1 && s.path[0] == name)
473}
474
475/// Visible top-level built-ins as `(name, description)` pairs, in
476/// registry order. Feeds `run::print_project_help` and the fallback
477/// `print_usage`.
478pub fn visible_top_level() -> Vec<(&'static str, &'static str)> {
479    registry()
480        .iter()
481        .filter(|s| s.path.len() == 1)
482        .filter_map(|s| s.description.map(|d| (s.path[0], d)))
483        .collect()
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use std::collections::HashSet;
490
491
492
493    #[test]
494    fn registry_has_no_duplicate_paths() {
495        let mut seen = HashSet::new();
496        for s in registry() {
497            let key = s.path.join(" ");
498            assert!(
499                seen.insert(key.clone()),
500                "duplicate registry path: {key}"
501            );
502        }
503    }
504
505    #[test]
506    fn hidden_entries_have_no_description() {
507        for s in registry() {
508            if s.path == ["version"] {
509                assert!(s.description.is_none(),
510                    "`version` is hidden but carries a description");
511            }
512        }
513    }
514
515    #[test]
516    fn every_parent_has_at_least_one_child() {
517        let parents: HashSet<&str> = registry()
518            .iter()
519            .filter(|s| s.path.len() == 1 && s.schema_fn.is_none()
520                && s.description.is_some())
521            .map(|s| s.path[0])
522            .collect();
523
524        // `completions`, `autocomplete` are leaves with no schema — exclude
525        // them by checking that parents have at least one 2-path child.
526        for parent in &parents {
527            let has_child = registry().iter().any(|s| s.path.len() == 2 && s.path[0] == *parent);
528            if !has_child {
529                // `completions` / `autocomplete` / `version` end up here by
530                // virtue of having no children; they are leaf built-ins.
531                continue;
532            }
533            assert!(has_child, "parent `{parent}` has no child entries");
534        }
535    }
536
537    #[test]
538    fn top_level_dispatched_by_main_is_in_registry() {
539        // Compile-time guard: every match arm target in main.rs is listed
540        // here. Keeping the list local (rather than introspecting main.rs)
541        // documents the coupling explicitly.
542        let dispatched = [
543            "setup", "libtorch", "nccl", "diagnose", "probe", "status",
544            "api-ref", "init", "add", "install", "skill", "schema",
545            "completions", "autocomplete", "config", "version",
546        ];
547        for name in &dispatched {
548            assert!(
549                is_builtin_name(name),
550                "`{name}` dispatched by main.rs but missing from registry"
551            );
552        }
553    }
554
555    #[test]
556    fn visible_top_level_matches_help_ordering() {
557        let top = visible_top_level();
558        let names: Vec<&str> = top.iter().map(|(n, _)| *n).collect();
559        // Lock in the order that `fdl -h` depends on.
560        assert_eq!(
561            names,
562            vec![
563                "setup", "libtorch", "nccl", "init", "add", "diagnose",
564                "probe", "status", "install", "skill", "api-ref", "config",
565                "schema", "completions", "autocomplete",
566            ]
567        );
568    }
569
570    #[test]
571    fn libtorch_download_schema_carries_cuda_choices() {
572        let spec = registry()
573            .iter()
574            .find(|s| s.path == ["libtorch", "download"])
575            .expect("libtorch download entry present");
576        let schema = (spec.schema_fn.expect("download has schema"))();
577        let cuda = schema
578            .options
579            .get("cuda")
580            .expect("`--cuda` option declared");
581        let choices = cuda.choices.as_ref().expect("--cuda has choices");
582        let values: Vec<String> = choices
583            .iter()
584            .filter_map(|v| v.as_str().map(str::to_string))
585            .collect();
586        assert_eq!(values, vec!["12.6".to_string(), "12.8".into()]);
587    }
588}