diff --git a/README.md b/README.md index 71caaa3..836ef0a 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,12 @@ cd fid && pnpm install pnpm dev list ``` +shell completions (zsh/bash/fish/powershell) — `fid check ` completes real knot hostnames: + +```sh +echo 'source <(fid complete zsh)' >> ~/.zshrc +``` + ## commands ``` diff --git a/package.json b/package.json index 29d27b9..9d3926f 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@bomb.sh/args": "^0.3.1", + "@bomb.sh/tab": "^0.0.21", "@bomb.sh/tty": "^0.8.0", "@clack/prompts": "^0.11.0", "zod": "^4.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0938586..cc9b23f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@bomb.sh/args': specifier: ^0.3.1 version: 0.3.1 + '@bomb.sh/tab': + specifier: ^0.0.21 + version: 0.0.21(cac@6.7.14) '@bomb.sh/tty': specifier: ^0.8.0 version: 0.8.0 @@ -99,6 +102,21 @@ packages: '@bomb.sh/args@0.3.1': resolution: {integrity: sha512-CwxKrfgcorUPP6KfYD59aRdBYWBTsfsxT+GmoLVnKo5Tmyoqbpo0UNcjngRMyU+6tiPbd18RuIYxhgAn44wU/Q==} + '@bomb.sh/tab@0.0.21': + resolution: {integrity: sha512-L9tqUOFS9GFJu+EWaoN6EvNAFKHtROJ3VmKP0AKGN4AnR/JXZbNPC9TXJhrNdTJISiUN7ZU0uJCq64fvyUVSvg==} + hasBin: true + peerDependencies: + cac: ^6.7.14 + citty: ^0.1.6 || ^0.2.0 + commander: ^13.1.0 || ^14.0.0 || ^15.0.0 + peerDependenciesMeta: + cac: + optional: true + citty: + optional: true + commander: + optional: true + '@bomb.sh/tty@0.8.0': resolution: {integrity: sha512-tEE0mtqnEp2uJt4kQS5AjdghCD5KN4BobqHOlfhaiNyrigxFBKk61XkDy//4D7V/AejrTvubh44B4NV35lsnxw==} engines: {node: '>= 22'} @@ -719,6 +737,10 @@ snapshots: '@bomb.sh/args@0.3.1': {} + '@bomb.sh/tab@0.0.21(cac@6.7.14)': + optionalDependencies: + cac: 6.7.14 + '@bomb.sh/tty@0.8.0': {} '@clack/core@0.5.0': diff --git a/src/cli.ts b/src/cli.ts index 500cd60..18d1d03 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,13 @@ import { styleText } from "node:util"; import { parse } from "@bomb.sh/args"; import { animateBanner } from "./banner.js"; +// completion calls answer before anything else — no banner, no arg parsing +if (process.argv[2] === "complete") { + const { runComplete } = await import("./completions.js"); + runComplete(process.argv.slice(3)); + process.exit(0); +} + const args = parse(process.argv.slice(2), { // note: args parses `--no-repos` as minimist-style negation → `repos: false` boolean: [ @@ -41,6 +48,7 @@ const usage = `usage: fid spindles the ci runner fleet fid find search repos across the network fid watch live network board (q to quit) + fid complete print completion script (zsh/bash/fish/powershell) fid --version print the version `; diff --git a/src/completions.ts b/src/completions.ts new file mode 100644 index 0000000..1f061cc --- /dev/null +++ b/src/completions.ts @@ -0,0 +1,78 @@ +import { readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import t, { type ArgumentHandler } from "@bomb.sh/tab"; +import { cacheDir } from "./util.js"; + +// completions must answer instantly, so hosts come from the newest cached +// snapshot (full or lite) — never the network. cold cache → no suggestions. +const hostHandler: ArgumentHandler = (complete) => { + const snapshots = ["network3", "network3-lite"] + .map((key) => join(cacheDir, `${key}.json`)) + .map((file) => { + try { + return { file, mtime: statSync(file).mtimeMs }; + } catch { + return null; + } + }) + .filter((s) => s !== null) + .sort((a, b) => b.mtime - a.mtime); + if (!snapshots[0]) return; + try { + const network = JSON.parse(readFileSync(snapshots[0].file, "utf8")) as { + knots: { + host: string; + local: boolean; + repoCount: number; + version: string; + }[]; + }; + for (const k of network.knots) { + if (!k.local) complete(k.host, `${k.repoCount} repos · ${k.version}`); + } + } catch { + // unreadable snapshot — no suggestions + } +}; + +const list = t.command("list", "every knot on the network"); +list.option("json", "machine-readable output"); +list.option("all", "include localhost/dev junk knots"); +list.option("fresh", "skip the 5-minute cache"); +list.option("no-repos", "skip the slow repo sweep (no counts, knots-only)"); +list.option("down", "only unreachable knots"); +list.option("stale", "only pre-v1.15 knots (the silent-data-loss cohort)"); +list.option("managed", "only tangled-hosted knots"); +list.option("self-hosted", "only self-hosted knots"); + +const check = t.command("check", "health-check one knot"); +check.option("mine", "check every knot you own instead"); +check.option("as", "who you are (handle or did)"); +check.option("json", "machine-readable output"); +check.argument("host", hostHandler); + +t.command("stats", "network aggregates").option( + "json", + "machine-readable output", +); + +const repos = t.command("repos", "what repos live on a knot"); +repos.option("json", "machine-readable output"); +repos.argument("host", hostHandler); + +t.command("spindles", "the ci runner fleet").option( + "json", + "machine-readable output", +); + +t.command("find", "search repos across the network").option( + "json", + "machine-readable output", +); + +t.command("watch", "live network board"); + +export function runComplete(argv: string[]) { + if (argv[0] === "--") t.parse(argv.slice(1)); + else t.setup("fid", "fid", argv[0] ?? "zsh"); +}