From 3555d9f1b9215b4754bcbe5b8c2c4d3c43fb0f0f Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Mon, 24 Aug 2026 11:12:46 +0200 Subject: [PATCH 1/3] feat(completion): Tab completion by calling `ct` back, not by generating a script (#132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ct completion zsh|bash|fish` prints a ~15-line hook that hands the command line back to `ct` on every Tab; the candidates are computed live from the Commander tree of the binary that is actually installed. The per-shell dialect (zsh `compdef`/`compadd`, bash `complete`/`compgen`, fish `complete -a`) comes from omelette, so this repo owns no shell syntax of its own and nothing has to be re-emitted when a command is added. Delegating at runtime is what makes dynamic candidates possible at all — a generated script structurally cannot know them: - `--env` completes the environments this config repo declares in `ct.envs.json` - `ct state rm ` completes the registry's types and the keys actually under management, following the `--env`/`--state` already on the line - path-taking options (`--config`, `--state`, `--backup-dir`) complete from disk Completion is offline by construction: it reads local, non-secret files and nothing else — no client, no token store, no `prepareEnv`. Every source is wrapped so a missing, malformed or slow file yields no candidates rather than an error or a hang; a completion that spills a stack trace into the command line is worse than one that offers nothing. omelette over `@pnpm/tabtab`: tabtab reads its hook templates off disk at runtime, which in the `bun build --compile` binaries this project releases resolves to the *build machine's* `node_modules` and fails with ENOENT on every user's machine (verified). omelette inlines its hooks as strings and has no dependencies, so the same code path works from npm, from `dist/`, and from a standalone binary with no `node_modules` in sight. Claude-Session: https://claude.ai/code/session_018XbTXWQnBB5rgbXwJYRFHM --- README.md | 27 +++++ package-lock.json | 26 ++++- package.json | 2 + src/commands/completion.ts | 13 +++ src/completion/candidates.ts | 201 +++++++++++++++++++++++++++++++++ src/completion/shell.ts | 85 ++++++++++++++ src/completion/sources.ts | 81 ++++++++++++++ src/index.ts | 9 ++ tests/cli.test.ts | 4 +- tests/completion.test.ts | 211 +++++++++++++++++++++++++++++++++++ 10 files changed, 654 insertions(+), 5 deletions(-) create mode 100644 src/commands/completion.ts create mode 100644 src/completion/candidates.ts create mode 100644 src/completion/shell.ts create mode 100644 src/completion/sources.ts create mode 100644 tests/completion.test.ts diff --git a/README.md b/README.md index 1b59c0d..5541540 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,33 @@ You also need a ChurchTools **personal login token** (ChurchTools → your user settings). Each release additionally attaches an `INSTALL.md` with the exact commands. +### Tab completion + +`ct completion ` prints a small hook that calls `ct` back on every Tab, so the +candidates always match the binary you actually have — including things a generated +script could not know: the environments in your `ct.envs.json`, the keys in your state +file, and paths for options like `--config`. It only ever reads local files: completion +never contacts ChurchTools and never touches your token. + +Add the hook to your shell startup file: + +```zsh +# zsh — after `compinit` has run +echo '. <(ct completion zsh)' >> ~/.zshrc +``` + +```bash +# bash +echo '. <(ct completion bash)' >> ~/.bashrc # macOS: ~/.bash_profile +``` + +```fish +# fish +ct completion fish > ~/.config/fish/completions/ct.fish +``` + +Open a new shell, then try `ct sta`, `ct state rm ` or `ct plan --env `. + ## First run ```bash diff --git a/package-lock.json b/package-lock.json index e1a0d42..cede731 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,17 @@ { - "name": "ct-cli", - "version": "0.0.0", + "name": "@eqrm/ct-cli", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "ct-cli", - "version": "0.0.0", + "name": "@eqrm/ct-cli", + "version": "0.1.0", + "license": "MIT", "dependencies": { "commander": "^12.1.0", "jiti": "^2.4.0", + "omelette": "^0.4.17", "openapi-fetch": "^0.13.0", "picocolors": "^1.1.0" }, @@ -19,6 +21,7 @@ "devDependencies": { "@eslint/js": "^9.0.0", "@types/node": "^22.0.0", + "@types/omelette": "^0.4.5", "eslint": "^9.0.0", "openapi-typescript": "^7.4.0", "prettier": "^3.3.0", @@ -1221,6 +1224,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/omelette": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@types/omelette/-/omelette-0.4.5.tgz", + "integrity": "sha512-zUCJpVRwfMcZfkxSCGp73mgd3/xesvPz5tQJIORlfP/zkYEyp9KUfF7IP3RRjyZR3DwxkPs96/IFf70GmYZYHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.63.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", @@ -2699,6 +2709,14 @@ "node": ">=0.10.0" } }, + "node_modules/omelette": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/omelette/-/omelette-0.4.17.tgz", + "integrity": "sha512-UlU69G6Bhu0XFjw3tjFZ0qyiMUjAOR+rdzblA1nLQ8xiqFtxOVlkhM39BlgTpLFx9fxkm6rnxNNRsS5GxE/yww==", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/openapi-fetch": { "version": "0.13.8", "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.13.8.tgz", diff --git a/package.json b/package.json index c37f8f7..050e34f 100644 --- a/package.json +++ b/package.json @@ -38,12 +38,14 @@ "dependencies": { "commander": "^12.1.0", "jiti": "^2.4.0", + "omelette": "^0.4.17", "openapi-fetch": "^0.13.0", "picocolors": "^1.1.0" }, "devDependencies": { "@eslint/js": "^9.0.0", "@types/node": "^22.0.0", + "@types/omelette": "^0.4.5", "eslint": "^9.0.0", "openapi-typescript": "^7.4.0", "prettier": "^3.3.0", diff --git a/src/commands/completion.ts b/src/commands/completion.ts new file mode 100644 index 0000000..6bfeae5 --- /dev/null +++ b/src/commands/completion.ts @@ -0,0 +1,13 @@ +import { Argument, Command } from "commander"; +import { COMPLETION_SHELLS, completionScript, type CompletionShell } from "../completion/shell.js"; + +export function completionCommand(): Command { + return new Command("completion") + .description("Print the shell hook that makes Tab complete `ct` (zsh, bash, fish)") + .addArgument(new Argument("", "shell to print the hook for").choices([...COMPLETION_SHELLS])) + .action((shell: CompletionShell, _options: unknown, command: Command) => { + // The hook completes the program it is attached to, so it is generated from the + // root program rather than from this subcommand. + process.stdout.write(completionScript(command.parent ?? command, shell)); + }); +} diff --git a/src/completion/candidates.ts b/src/completion/candidates.ts new file mode 100644 index 0000000..40bdf16 --- /dev/null +++ b/src/completion/candidates.ts @@ -0,0 +1,201 @@ +/** + * What `ct` offers the shell when Tab is pressed (#132). + * + * The shell hook installed by `ct completion ` is a stub: on every Tab it + * hands the command line back to this process, and this module answers. Nothing is + * baked into a generated script, so the candidates are always exactly what the + * Commander tree in this build declares — and they can be *dynamic*, which a static + * script structurally cannot be: real environment names out of `ct.envs.json`, real + * keys out of the state file, real paths out of the working directory. + * + * Everything here is pure command-tree introspection plus the offline sources in + * `./sources.js`; see that module for the never-contact-ChurchTools, never-throw, + * never-hang guarantees. + */ +import type { Argument, Command, Option } from "commander"; +import { defaultEnvStatePath, resolveEnvsPath } from "../env/envs.js"; +import { resolveStatePath } from "../state/state.js"; +import { envNames, paths, resourceTypes, stateKeys } from "./sources.js"; + +/** Where the cursor is: the resolved command plus the words already typed for it. */ +interface Position { + /** The deepest subcommand the typed words resolve to. */ + command: Command; + /** Full path of that command, e.g. `ct state rm`. */ + path: string; + /** Positional words typed for `command` so far. */ + positionals: string[]; + /** Values of the value-taking options typed so far, keyed by long flag. */ + options: Map; +} + +/** A value slot whose candidates come from a local file rather than the command tree. */ +type DynamicSource = (position: Position, partial: string) => Promise | string[]; + +/** + * Positional arguments whose values live in a local file, keyed by ` + * `. Keeping this a table rather than sprinkling special cases through + * the walker keeps the dynamic surface reviewable in one place. + */ +const DYNAMIC_ARGUMENTS: Record = { + "ct state rm type": () => resourceTypes(), + "ct state rm key": (position) => stateKeys(statePathFor(position)), +}; + +/** The state file the typed `--state`/`--env` words point at (same precedence as the commands). */ +function statePathFor(position: Position): string { + const env = position.options.get("--env"); + return resolveStatePath( + position.options.get("--state"), + process.env, + env ? defaultEnvStatePath(env) : undefined, + ); +} + +function takesValue(option: Option): boolean { + return option.required || option.optional; +} + +function visibleOptions(command: Command): Option[] { + return command.createHelp().visibleOptions(command); +} + +function flagsOf(option: Option): string[] { + return [option.short, option.long].filter((flag): flag is string => Boolean(flag)); +} + +function findOption(command: Command, flag: string): Option | undefined { + return visibleOptions(command).find((option) => flagsOf(option).includes(flag)); +} + +function findSubcommand(command: Command, word: string): Command | undefined { + return command.commands.find((child) => child.name() === word || child.aliases().includes(word)); +} + +function commandPath(command: Command): string { + const names: string[] = []; + for (let node: Command | null = command; node; node = node.parent) names.unshift(node.name()); + return names.join(" "); +} + +/** + * Replay the typed words against the command tree. + * + * Unknown words are positionals, known subcommand names descend (and reset the + * positional count), and a value-taking option swallows the word after it so that + * `ct plan --env dev ` completes a positional rather than treating `dev` as one. + */ +function walk(program: Command, words: string[]): Position { + let command = program; + let positionals: string[] = []; + const options = new Map(); + + for (let i = 0; i < words.length; i++) { + const word = words[i] ?? ""; + if (word.startsWith("-")) { + const split = word.indexOf("="); + const flag = split === -1 ? word : word.slice(0, split); + const option = findOption(command, flag) ?? findOption(program, flag); + if (!option || !takesValue(option)) continue; + const value = split === -1 ? words[++i] : word.slice(split + 1); + if (option.long && value !== undefined) options.set(option.long, value); + continue; + } + const child = findSubcommand(command, word); + if (child) { + command = child; + positionals = []; + } else { + positionals.push(word); + } + } + + return { command, path: commandPath(command), positionals, options }; +} + +/** + * The kind of value an option or argument wants, inferred from its placeholder and + * description. Commander has no "this is a path" metadata, and every path-taking flag + * in this CLI says so in its own help text (`--state `, `--backup-dir `), + * so the help text is the metadata. + */ +function pathKind(name: string, description: string): "file" | "directory" | undefined { + const hint = `${name} ${description}`.toLowerCase(); + if (/\b(dir|directory|folder)\b/.test(hint)) return "directory"; + if (/\b(path|file|filename)\b/.test(hint)) return "file"; + return undefined; +} + +/** The placeholder inside an option's flags, e.g. `path` for `-s, --state `. */ +function placeholder(option: Option): string { + return option.flags.match(/[<[]([^>\]]+)[>\]]/)?.[1]?.replace(/\.{3}$/, "") ?? "value"; +} + +async function optionValues(option: Option, position: Position, partial: string): Promise { + if (option.argChoices?.length) return [...option.argChoices]; + // The one flag every command shares, and the reason runtime completion is worth it: + // these are the environments this config repo actually defines. + if (option.long === "--env") return envNames(resolveEnvsPath()); + const kind = pathKind(placeholder(option), option.description); + return kind ? paths(partial, kind) : []; +} + +async function argumentValues(position: Position, partial: string): Promise { + const args = position.command.registeredArguments; + const argument: Argument | undefined = + args[position.positionals.length] ?? (args.at(-1)?.variadic ? args.at(-1) : undefined); + if (!argument) return []; + if (argument.argChoices?.length) return [...argument.argChoices]; + const dynamic = DYNAMIC_ARGUMENTS[`${position.path} ${argument.name()}`]; + if (dynamic) return dynamic(position, partial); + const kind = pathKind(argument.name(), argument.description); + return kind ? paths(partial, kind) : []; +} + +/** The option that the word before the cursor is still waiting for a value for, if any. */ +function pendingOption(program: Command, position: Position, previous: string): Option | undefined { + if (!previous.startsWith("-") || previous.includes("=")) return undefined; + const option = findOption(position.command, previous) ?? findOption(program, previous); + return option && takesValue(option) ? option : undefined; +} + +/** + * Candidates for the word being typed. + * + * `words` are the words already completed (including the program name); `partial` is + * the word under the cursor, or `""` when the cursor sits after a space. The shells + * do the prefix filtering themselves, so the full candidate set for the position is + * returned — `partial` is only consulted where the candidates depend on it (paths). + */ +export async function completionCandidates( + program: Command, + words: string[], + partial: string, +): Promise { + const position = walk(program, words.slice(1)); + + const pending = pendingOption(program, position, words.at(-1) ?? ""); + if (pending) return optionValues(pending, position, partial); + + if (partial.startsWith("-")) return visibleOptions(position.command).flatMap(flagsOf); + + const subcommands = position.command + .createHelp() + .visibleCommands(position.command) + .map((child) => child.name()); + return [...subcommands, ...(await argumentValues(position, partial))]; +} + +/** + * Split a shell command line into the completed words plus the word under the cursor. + * + * `fragment` is the shell's index of the word being completed, which is what + * distinguishes `ct plan` (completing `plan`) from `ct plan ` (completing a + * new, empty word) without having to trust that a trailing space survived the trip + * through three different shell quoting regimes. + */ +export function splitCompletionLine(line: string, fragment: number): { words: string[]; partial: string } { + const tokens = line.split(/\s+/).filter(Boolean); + if (tokens.length > fragment) return { words: tokens.slice(0, -1), partial: tokens.at(-1) ?? "" }; + return { words: tokens, partial: "" }; +} diff --git a/src/completion/shell.ts b/src/completion/shell.ts new file mode 100644 index 0000000..daabcdc --- /dev/null +++ b/src/completion/shell.ts @@ -0,0 +1,85 @@ +/** + * The shell side of tab completion (#132). + * + * `ct` does not generate a completion script. It installs a ~15-line hook — supplied + * by [omelette](https://github.com/f/omelette), not written here — that calls `ct` + * back on every Tab with the current command line, and answers with the candidates + * from {@link completionCandidates}. Two consequences are the whole point of doing it + * this way: + * + * - The per-shell syntax (zsh `compdef`/`compadd`, bash `complete`/`compgen`, fish + * `complete -a`) lives in the library. This repo owns no shell dialect. + * - The candidates are computed live, so they follow the command tree of the binary + * that is actually installed and can include things a static script could never + * know — the environments in *this* `ct.envs.json`, the keys in *this* state file. + * + * omelette was picked over `@pnpm/tabtab` because it has no dependencies and inlines + * its hooks as strings, whereas tabtab reads its templates off disk at runtime: in + * the `bun build --compile` binaries this project releases, that resolves to the + * build machine's `node_modules` and fails with ENOENT on every user's machine. + * + * omelette is effectively frozen — last release 0.4.17 in September 2021, last commit + * January 2022 — which is accepted deliberately. It is ~350 lines of MIT-licensed, + * dependency-free CommonJS whose entire job is emitting three static hook strings, so + * the realistic failure mode is "never gains a feature", not "breaks". If it ever does + * break, vendoring it into this repo is an afternoon's work and the licence allows it. + * The tests here cover the hook shape per shell, so a regression surfaces in CI. + */ +import type { Command } from "commander"; +import omelette from "omelette"; +import { completionCandidates, splitCompletionLine } from "./candidates.js"; + +export const COMPLETION_SHELLS = ["zsh", "bash", "fish"] as const; +export type CompletionShell = (typeof COMPLETION_SHELLS)[number]; + +/** The plumbing flag omelette's hooks pass when the shell is asking for candidates. */ +const COMPGEN_FLAG = "--compgen"; + +/** What omelette hands a `complete` handler. Narrower than `@types/omelette`, which + * does not model the two-argument `complete` event or a promise-returning reply. */ +interface CompletionRequest { + /** The full command line as the shell has it, e.g. `ct state rm ca`. */ + line: string; + /** The shell's index of the word being completed. */ + fragment: number; + reply: (words: string[] | Promise) => void; +} + +interface CompletionHook { + onAsync(event: "complete", handler: (fragment: string, request: CompletionRequest) => void): void; + init(): void; + generateCompletionCode(): string; + generateCompletionCodeFish(): string; +} + +function hook(program: Command): CompletionHook { + return omelette(program.name()) as unknown as CompletionHook; +} + +/** The hook to paste into a shell startup file. zsh and bash share one (it branches itself). */ +export function completionScript(program: Command, shell: CompletionShell): string { + const instance = hook(program); + const script = shell === "fish" ? instance.generateCompletionCodeFish() : instance.generateCompletionCode(); + return `${script}\n`; +} + +/** True when this invocation is a shell asking for candidates rather than a user running a command. */ +export function isCompletionRequest(argv: readonly string[]): boolean { + return argv.includes(COMPGEN_FLAG); +} + +/** + * Answer one completion request and exit. + * + * A completion that errors is worse than one that offers nothing — a rejected promise + * here would spill a stack trace into the user's command line — so failures collapse + * to an empty candidate list. `reply` writes the candidates and exits the process. + */ +export function serveCompletionRequest(program: Command): void { + const instance = hook(program); + instance.onAsync("complete", (_fragment, request) => { + const { words, partial } = splitCompletionLine(request.line, request.fragment); + request.reply(completionCandidates(program, words, partial).catch(() => [])); + }); + instance.init(); +} diff --git a/src/completion/sources.ts b/src/completion/sources.ts new file mode 100644 index 0000000..3e07d50 --- /dev/null +++ b/src/completion/sources.ts @@ -0,0 +1,81 @@ +/** + * Offline data sources for tab completion (#132). + * + * Everything here runs on a Tab keypress, which imposes two hard rules: + * + * - **Nothing contacts ChurchTools and nothing reads a credential.** Completion is + * allowed to look at the local, non-secret config repo (`ct.envs.json`, the state + * file, the working directory) and at nothing else. There is no client, no token + * store and no `prepareEnv` in this module, on purpose. + * - **A failure yields no candidates, never an error and never a hang.** A missing, + * unreadable, malformed or slow file is the normal case while a config repo is + * being edited; the shell must simply offer nothing instead of printing a stack + * trace into the command line. So every read is wrapped in {@link offline}. + * + * That is also why these read the files directly rather than going through + * `loadEnvProfile`/`loadState`: those validate and throw friendly errors, which is + * right for a command and wrong for a keypress. + */ +import { readdir, readFile } from "node:fs/promises"; +import { RESOURCES } from "../resources/registry.js"; + +/** A source slower than this is abandoned — the shell must never wait on `ct`. */ +const SOURCE_TIMEOUT_MS = 150; + +/** Run a source with the two guarantees above: no throw, no hang, empty on failure. */ +async function offline(read: () => Promise): Promise { + let timer: ReturnType | undefined; + const abandon = new Promise((resolve) => { + timer = setTimeout(() => resolve([]), SOURCE_TIMEOUT_MS); + timer.unref?.(); + }); + try { + return await Promise.race([read().catch(() => []), abandon]); + } catch { + return []; + } finally { + if (timer) clearTimeout(timer); + } +} + +function objectKeys(value: unknown, field: string): string[] { + if (typeof value !== "object" || value === null || Array.isArray(value)) return []; + const nested = (value as Record)[field]; + if (typeof nested !== "object" || nested === null || Array.isArray(nested)) return []; + return Object.keys(nested as Record); +} + +/** The environment names declared in the profile file — the values `--env` accepts. */ +export function envNames(path: string): Promise { + return offline(async () => objectKeys(JSON.parse(await readFile(path, "utf8")), "environments")); +} + +/** The logical keys under management in a state file — the values `ct state rm` accepts. */ +export function stateKeys(path: string): Promise { + return offline(async () => objectKeys(JSON.parse(await readFile(path, "utf8")), "resources")); +} + +/** The resource types the registry knows, straight from the registry so it cannot drift. */ +export function resourceTypes(): string[] { + return Object.keys(RESOURCES); +} + +/** + * Filesystem candidates for a partially typed path. + * + * The shells filter the returned list against the word being typed, so the entries + * must carry the directory prefix the user already typed. Dot-entries are offered + * only once the user has typed a dot, matching what every shell does by default. + */ +export function paths(partial: string, kind: "file" | "directory"): Promise { + return offline(async () => { + const cut = partial.lastIndexOf("/"); + const prefix = cut === -1 ? "" : partial.slice(0, cut + 1); + const base = partial.slice(cut + 1); + const entries = await readdir(prefix === "" ? "." : prefix, { withFileTypes: true }); + return entries + .filter((entry) => base.startsWith(".") || !entry.name.startsWith(".")) + .filter((entry) => kind === "file" || entry.isDirectory()) + .map((entry) => `${prefix}${entry.name}${entry.isDirectory() ? "/" : ""}`); + }); +} diff --git a/src/index.ts b/src/index.ts index eab581c..4b11694 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,9 @@ import { refreshCommand } from "./commands/refresh.js"; import { planCommand } from "./commands/plan.js"; import { applyCommand } from "./commands/apply.js"; import { destroyCommand } from "./commands/destroy.js"; +import { completionCommand } from "./commands/completion.js"; import { plannedCommands } from "./commands/placeholders.js"; +import { isCompletionRequest, serveCompletionRequest } from "./completion/shell.js"; import { isMainModule } from "./isMain.js"; import { error, formatError } from "./ui.js"; @@ -34,6 +36,7 @@ export function buildProgram(): Command { program.addCommand(planCommand()); program.addCommand(applyCommand()); program.addCommand(destroyCommand()); + program.addCommand(completionCommand()); for (const cmd of plannedCommands()) { program.addCommand(cmd); } @@ -43,6 +46,12 @@ export function buildProgram(): Command { async function main(): Promise { const program = buildProgram(); + // A Tab keypress re-enters `ct` with the shell hook's plumbing flags (#132). Answer + // from the command tree and exit before Commander ever sees them. + if (isCompletionRequest(process.argv)) { + serveCompletionRequest(program); + return; + } try { await program.parseAsync(process.argv); } catch (err) { diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 7917e96..79dc4eb 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -4,7 +4,9 @@ import { buildProgram } from "../src/index.js"; describe("ct program", () => { it("registers the core command surface", () => { const names = buildProgram().commands.map((c) => c.name()); - expect(names).toEqual(expect.arrayContaining(["auth", "get", "adopt", "plan", "apply", "destroy"])); + expect(names).toEqual( + expect.arrayContaining(["auth", "get", "adopt", "plan", "apply", "destroy", "completion"]), + ); }); it("exposes auth subcommands", () => { diff --git a/tests/completion.test.ts b/tests/completion.test.ts new file mode 100644 index 0000000..dd4a523 --- /dev/null +++ b/tests/completion.test.ts @@ -0,0 +1,211 @@ +/** + * Shell completion (#132). + * + * `ct` installs a shell hook that calls back into `ct` on every Tab, so the thing + * worth testing is not a generated script but the answer this process gives for a + * command line. Two properties are load-bearing: + * + * - **Completion is offline.** It may read the local config repo and nothing else — + * no ChurchTools call, no credential, no `prepareEnv`. The session module is mocked + * to throw so that any accidental route to the network fails the suite. + * - **Completion never errors and never hangs.** A missing or malformed file is the + * normal state of a config repo mid-edit; it must degrade to no candidates. + */ +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Command, Option } from "commander"; + +const authedSession = vi.fn(async () => { + throw new Error("completion must never contact ChurchTools"); +}); +vi.mock("../src/api/session.js", () => ({ authedSession })); + +const { completionCandidates, splitCompletionLine } = await import("../src/completion/candidates.js"); +const { COMPLETION_SHELLS, completionScript, isCompletionRequest } = + await import("../src/completion/shell.js"); +const { buildProgram } = await import("../src/index.js"); + +/** A stand-in for the real tree, so the structural assertions do not move with the CLI. */ +function representativeProgram(): Command { + const permissions = new Command("permissions") + .description("Render permission reports") + .option("--by-subject", "group permissions by subject") + .option("-o, --output ", "write the report to a file") + .addOption(new Option("--format ", "output format").choices(["json", "markdown"])); + const program = new Command("ct").option("-e, --env ", "environment profile from ct.envs.json"); + program.addCommand(new Command("report").description("Generate reports").addCommand(permissions)); + return program; +} + +/** Complete the given command line, splitting it the way a shell hook would. */ +async function complete(program: Command, line: string): Promise { + const tokens = line.split(/\s+/).filter(Boolean); + // The shells report the index of the word under the cursor; a trailing space means + // a new, empty word, which is one index past the last typed token. + const fragment = /\s$/.test(line) ? tokens.length : tokens.length - 1; + const { words, partial } = splitCompletionLine(line, fragment); + return completionCandidates(program, words, partial); +} + +describe("completion candidates", () => { + it("offers top-level commands", async () => { + expect(await complete(representativeProgram(), "ct ")).toEqual(["report", "help"]); + }); + + it("descends into nested subcommands", async () => { + expect(await complete(representativeProgram(), "ct report ")).toContain("permissions"); + }); + + it("offers a command's options once a dash is typed", async () => { + const candidates = await complete(representativeProgram(), "ct report permissions -"); + expect(candidates).toEqual(expect.arrayContaining(["--by-subject", "-o", "--output", "--format"])); + }); + + it("offers the enumerated values of an option", async () => { + const candidates = await complete(representativeProgram(), "ct report permissions --format "); + expect(candidates).toEqual(["json", "markdown"]); + }); + + it("offers the enumerated values of an argument", async () => { + expect(await complete(buildProgram(), "ct completion ")).toEqual([...COMPLETION_SHELLS]); + }); + + it("does not mistake an option value for a positional word", async () => { + // `markdown` must be consumed as --format's value, so this still completes the + // command's own arguments rather than treating it as one. + const candidates = await complete(representativeProgram(), "ct report permissions --format markdown "); + expect(candidates).toEqual([]); + }); + + it("reflects the real command tree, nested commands included", async () => { + const program = buildProgram(); + expect(await complete(program, "ct ")).toEqual( + expect.arrayContaining(["auth", "state", "plan", "apply", "destroy", "completion"]), + ); + expect(await complete(program, "ct auth ")).toEqual(expect.arrayContaining(["login", "logout"])); + expect(await complete(program, "ct state ")).toEqual(expect.arrayContaining(["list", "rm"])); + expect(await complete(program, "ct apply -")).toEqual(expect.arrayContaining(["--auto-approve"])); + }); +}); + +describe("dynamic completion", () => { + let dir: string; + const originalEnvs = process.env.CT_ENVS; + const originalState = process.env.CT_STATE; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "ct-completion-")); + delete process.env.CT_ENVS; + delete process.env.CT_STATE; + }); + + afterEach(async () => { + process.env.CT_ENVS = originalEnvs; + process.env.CT_STATE = originalState; + if (originalEnvs === undefined) delete process.env.CT_ENVS; + if (originalState === undefined) delete process.env.CT_STATE; + await rm(dir, { recursive: true, force: true }); + }); + + it("completes --env with the environments this config repo actually declares", async () => { + process.env.CT_ENVS = join(dir, "ct.envs.json"); + await writeFile( + process.env.CT_ENVS, + JSON.stringify({ + environments: { + dev: { host: "https://x-dev.church.tools" }, + prod: { host: "https://x.church.tools" }, + }, + }), + ); + expect(await complete(buildProgram(), "ct plan --env ")).toEqual(["dev", "prod"]); + }); + + it("completes `state rm` with the keys actually under management", async () => { + process.env.CT_STATE = join(dir, "ct-state.json"); + await writeFile( + process.env.CT_STATE, + JSON.stringify({ + version: 1, + host: "https://x.church.tools", + resources: { mainz: { type: "campus", id: 1 }, youth: { type: "group", id: 2 } }, + }), + ); + expect(await complete(buildProgram(), "ct state rm campus ")).toEqual(["mainz", "youth"]); + }); + + it("completes `state rm` with the resource types the registry knows", async () => { + expect(await complete(buildProgram(), "ct state rm ")).toEqual( + expect.arrayContaining(["campus", "group", "group-role"]), + ); + }); + + it("completes a path option from the filesystem", async () => { + await writeFile(join(dir, "ct.config.ts"), ""); + const candidates = await complete(buildProgram(), `ct plan --config ${dir}/ct`); + expect(candidates).toContain(join(dir, "ct.config.ts")); + }); + + it("degrades to no candidates when a source file is missing or malformed", async () => { + process.env.CT_ENVS = join(dir, "absent.json"); + expect(await complete(buildProgram(), "ct plan --env ")).toEqual([]); + + process.env.CT_ENVS = join(dir, "broken.json"); + await writeFile(process.env.CT_ENVS, "{ not json"); + expect(await complete(buildProgram(), "ct plan --env ")).toEqual([]); + }); + + it("never contacts ChurchTools", () => { + expect(authedSession).not.toHaveBeenCalled(); + }); +}); + +describe("splitCompletionLine", () => { + it("treats the last word as partial when the cursor sits on it", () => { + expect(splitCompletionLine("ct sta", 1)).toEqual({ words: ["ct"], partial: "sta" }); + }); + + it("treats the cursor after a space as a new, empty word", () => { + expect(splitCompletionLine("ct state ", 2)).toEqual({ words: ["ct", "state"], partial: "" }); + }); +}); + +describe("the installed shell hook", () => { + it("delegates back to ct rather than baking in a generated script", () => { + for (const shell of COMPLETION_SHELLS) { + const script = completionScript(buildProgram(), shell); + expect(script).toContain("--compgen"); + expect(script).toContain("ct"); + // The whole hook is small because the shell dialect lives in the library, not here. + expect(script.split("\n").length).toBeLessThan(40); + } + }); + + it("uses each shell's own registration syntax", () => { + expect(completionScript(buildProgram(), "zsh")).toContain("compdef"); + expect(completionScript(buildProgram(), "bash")).toContain("complete -F"); + expect(completionScript(buildProgram(), "fish")).toContain("complete -f -c ct"); + }); + + it("recognises a Tab keypress by the hook's plumbing flag", () => { + expect(isCompletionRequest(["node", "ct", "--compbash", "--compgen", "1", "ct", "ct "])).toBe(true); + expect(isCompletionRequest(["node", "ct", "plan"])).toBe(false); + }); + + it("prints the hook without credentials or a ChurchTools request", async () => { + let output = ""; + const write = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + output += String(chunk); + return true; + }); + try { + await buildProgram().parseAsync(["completion", "bash"], { from: "user" }); + } finally { + write.mockRestore(); + } + expect(output).toContain("complete -F _ct_completion ct"); + expect(authedSession).not.toHaveBeenCalled(); + }); +}); From 56ac14b1d8e8bd5c2e562807670954a29762ce33 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Mon, 24 Aug 2026 16:20:51 +0200 Subject: [PATCH 2/3] fix(completion): honour the profile state file, the cursor position and stock bash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #134 turned up six ways the completion answers the wrong thing, or nothing at all: - `splitCompletionLine` sliced the last token off the line rather than the token the cursor is on, so Tab anywhere but at the end of the line completed the wrong word. Split at `fragment` instead, keeping the old shape-of-the-line behaviour as the fallback for the one case it is still needed: bash derives the index from `COMP_CWORD`, whose word breaks the hook's colon fudge does not fully account for, so it can arrive past the end of the line. - `statePathFor` hardcoded the `ct-state..json` convention and ignored a profile's own `state` field, which `prepareEnv` does honour — so in any repo that overrides it, `ct state rm --env ` offered nothing at all. Read the field offline, with the same precedence the commands use. - `state rm` key candidates were not narrowed by the `` already typed, so completion happily offered a key the command then refuses. - omelette's bash branch calls two helpers from the `bash-completion` package. Stock macOS bash 3.2 — the one the README's `~/.bash_profile` line gets you — has neither, so every Tab printed two "command not found" lines. Prepend minimal stand-ins, defined only when the real ones are absent. - `paths()` never expanded `~`, and the hooks turn off the shell's own filename fallback, so a tilde path completed to nothing. - `ct adopt ` takes the same registry-typed argument as `ct state rm ` but had no entry in the dynamic table. Claude-Session: https://claude.ai/code/session_018XbTXWQnBB5rgbXwJYRFHM --- README.md | 3 ++ src/completion/candidates.ts | 43 +++++++++++++----- src/completion/shell.ts | 31 +++++++++++-- src/completion/sources.ts | 76 ++++++++++++++++++++++++-------- tests/completion.test.ts | 84 ++++++++++++++++++++++++++++++++++-- 5 files changed, 202 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 5541540..60de2cd 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,9 @@ echo '. <(ct completion zsh)' >> ~/.zshrc echo '. <(ct completion bash)' >> ~/.bashrc # macOS: ~/.bash_profile ``` +The bash hook runs on the bash macOS ships (3.2); the `bash-completion` package is +not required. + ```fish # fish ct completion fish > ~/.config/fish/completions/ct.fish diff --git a/src/completion/candidates.ts b/src/completion/candidates.ts index 40bdf16..dd27a88 100644 --- a/src/completion/candidates.ts +++ b/src/completion/candidates.ts @@ -15,7 +15,7 @@ import type { Argument, Command, Option } from "commander"; import { defaultEnvStatePath, resolveEnvsPath } from "../env/envs.js"; import { resolveStatePath } from "../state/state.js"; -import { envNames, paths, resourceTypes, stateKeys } from "./sources.js"; +import { envNames, envStatePath, paths, resourceTypes, stateKeys } from "./sources.js"; /** Where the cursor is: the resolved command plus the words already typed for it. */ interface Position { @@ -38,17 +38,28 @@ type DynamicSource = (position: Position, partial: string) => Promise * the walker keeps the dynamic surface reviewable in one place. */ const DYNAMIC_ARGUMENTS: Record = { + "ct adopt type": () => resourceTypes(), "ct state rm type": () => resourceTypes(), - "ct state rm key": (position) => stateKeys(statePathFor(position)), + // `ct state rm ` refuses a key belonging to another type, so the type + // already typed narrows the keys — completing into a guaranteed error helps nobody. + "ct state rm key": async (position) => + stateKeys(await statePathFor(position), position.positionals[0]), }; -/** The state file the typed `--state`/`--env` words point at (same precedence as the commands). */ -function statePathFor(position: Position): string { +/** + * The state file the typed `--state`/`--env` words point at, with the same precedence + * the commands use (`prepareEnv` → `resolveStatePath`): explicit `--state`, then + * `CT_STATE`, then the env profile's own `state` field, then the `ct-state..json` + * convention. Reading the profile's override matters — a repo that sets it would + * otherwise be offered the keys of a state file it does not use. + */ +async function statePathFor(position: Position): Promise { const env = position.options.get("--env"); + const declared = env ? await envStatePath(resolveEnvsPath(), env) : undefined; return resolveStatePath( position.options.get("--state"), process.env, - env ? defaultEnvStatePath(env) : undefined, + env ? (declared ?? defaultEnvStatePath(env)) : undefined, ); } @@ -189,13 +200,23 @@ export async function completionCandidates( /** * Split a shell command line into the completed words plus the word under the cursor. * - * `fragment` is the shell's index of the word being completed, which is what - * distinguishes `ct plan` (completing `plan`) from `ct plan ` (completing a - * new, empty word) without having to trust that a trailing space survived the trip - * through three different shell quoting regimes. + * `fragment` is the shell's index of the word under the cursor. It is what locates the + * cursor at all: `line` is the whole buffer, so it also holds whatever the user has + * typed *after* the cursor, and a trailing space in it says nothing about where the + * cursor sits. Splitting at `fragment` is therefore what makes Tab in the middle of a + * line complete the word it is actually on. + * + * The index is trustworthy in zsh and fish. bash's hook derives it from `COMP_CWORD` + * minus a fudge for colons, and `COMP_WORDBREAKS` breaks on more than colons (`=`, `:` + * after the cursor), so it can come in inflated past the end of the line. That case is + * detectable — the index points past the last token — and falls back to the shape of + * the line itself, which is what the previous behaviour did for every line. */ export function splitCompletionLine(line: string, fragment: number): { words: string[]; partial: string } { const tokens = line.split(/\s+/).filter(Boolean); - if (tokens.length > fragment) return { words: tokens.slice(0, -1), partial: tokens.at(-1) ?? "" }; - return { words: tokens, partial: "" }; + if (fragment >= 0 && fragment < tokens.length) { + return { words: tokens.slice(0, fragment), partial: tokens[fragment] ?? "" }; + } + if (tokens.length === 0 || /\s$/.test(line)) return { words: tokens, partial: "" }; + return { words: tokens.slice(0, -1), partial: tokens.at(-1) ?? "" }; } diff --git a/src/completion/shell.ts b/src/completion/shell.ts index daabcdc..0b975c8 100644 --- a/src/completion/shell.ts +++ b/src/completion/shell.ts @@ -8,7 +8,9 @@ * this way: * * - The per-shell syntax (zsh `compdef`/`compadd`, bash `complete`/`compgen`, fish - * `complete -a`) lives in the library. This repo owns no shell dialect. + * `complete -a`) lives in the library. The only dialect this repo writes itself is + * {@link BASH_COMPLETION_FALLBACKS}, and only because omelette's bash branch depends + * on a package stock macOS bash does not ship. * - The candidates are computed live, so they follow the command tree of the binary * that is actually installed and can include things a static script could never * know — the environments in *this* `ct.envs.json`, the keys in *this* state file. @@ -56,11 +58,34 @@ function hook(program: Command): CompletionHook { return omelette(program.name()) as unknown as CompletionHook; } +/** + * The one piece of shell dialect this repo does own. + * + * omelette's `complete`-based branch calls `_get_comp_words_by_ref` and + * `__ltrim_colon_completions`, which ship with the `bash-completion` package rather + * than with bash. Stock macOS bash (3.2, the one `~/.bash_profile` in the README gets + * you) has neither, so without this every single Tab prints two `command not found` + * lines into the command line — exactly the failure mode the rest of this module goes + * out of its way to avoid. These stand-ins do what that one call site asks for and + * nothing more, and they only ever define a name that is not already defined, so a + * machine that does have bash-completion keeps the real ones. + */ +const BASH_COMPLETION_FALLBACKS = `### ct completion fallbacks - begin ### +if ! type compdef >/dev/null 2>&1 && type complete >/dev/null 2>&1; then + if ! declare -F _get_comp_words_by_ref >/dev/null 2>&1; then + _get_comp_words_by_ref() { cur=\${COMP_WORDS[COMP_CWORD]}; prev=\${COMP_WORDS[COMP_CWORD-1]}; } + fi + if ! declare -F __ltrim_colon_completions >/dev/null 2>&1; then + __ltrim_colon_completions() { :; } + fi +fi +### ct completion fallbacks - end ###`; + /** The hook to paste into a shell startup file. zsh and bash share one (it branches itself). */ export function completionScript(program: Command, shell: CompletionShell): string { const instance = hook(program); - const script = shell === "fish" ? instance.generateCompletionCodeFish() : instance.generateCompletionCode(); - return `${script}\n`; + if (shell === "fish") return `${instance.generateCompletionCodeFish()}\n`; + return `${BASH_COMPLETION_FALLBACKS}\n${instance.generateCompletionCode()}\n`; } /** True when this invocation is a shell asking for candidates rather than a user running a command. */ diff --git a/src/completion/sources.ts b/src/completion/sources.ts index 3e07d50..b10677e 100644 --- a/src/completion/sources.ts +++ b/src/completion/sources.ts @@ -17,42 +17,72 @@ * right for a command and wrong for a keypress. */ import { readdir, readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { RESOURCES } from "../resources/registry.js"; /** A source slower than this is abandoned — the shell must never wait on `ct`. */ const SOURCE_TIMEOUT_MS = 150; -/** Run a source with the two guarantees above: no throw, no hang, empty on failure. */ -async function offline(read: () => Promise): Promise { +/** Run a source with the two guarantees above: no throw, no hang, `fallback` on failure. */ +async function offline(read: () => Promise, fallback: T): Promise { let timer: ReturnType | undefined; - const abandon = new Promise((resolve) => { - timer = setTimeout(() => resolve([]), SOURCE_TIMEOUT_MS); + const abandon = new Promise((resolve) => { + timer = setTimeout(() => resolve(fallback), SOURCE_TIMEOUT_MS); timer.unref?.(); }); try { - return await Promise.race([read().catch(() => []), abandon]); + return await Promise.race([read().catch(() => fallback), abandon]); } catch { - return []; + return fallback; } finally { if (timer) clearTimeout(timer); } } -function objectKeys(value: unknown, field: string): string[] { - if (typeof value !== "object" || value === null || Array.isArray(value)) return []; - const nested = (value as Record)[field]; - if (typeof nested !== "object" || nested === null || Array.isArray(nested)) return []; - return Object.keys(nested as Record); +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** The object under `field`, or an empty one for anything that is not a JSON object. */ +function objectField(value: unknown, field: string): Record { + if (!isObject(value)) return {}; + const nested = value[field]; + return isObject(nested) ? nested : {}; } /** The environment names declared in the profile file — the values `--env` accepts. */ export function envNames(path: string): Promise { - return offline(async () => objectKeys(JSON.parse(await readFile(path, "utf8")), "environments")); + return offline(async () => Object.keys(objectField(JSON.parse(await readFile(path, "utf8")), "environments")), []); +} + +/** + * The state file a named environment declares, or `undefined` when it leaves the + * `ct-state..json` convention alone. This is the same `state` field the commands + * read through `loadEnvProfile`; completion has to honour it too, or it answers about + * a different state file than the one `ct state rm` will edit. + */ +export function envStatePath(path: string, name: string): Promise { + return offline(async () => { + const profile = objectField(JSON.parse(await readFile(path, "utf8")), "environments")[name]; + const declared = isObject(profile) ? profile.state : undefined; + return typeof declared === "string" && declared !== "" ? declared : undefined; + }, undefined); } -/** The logical keys under management in a state file — the values `ct state rm` accepts. */ -export function stateKeys(path: string): Promise { - return offline(async () => objectKeys(JSON.parse(await readFile(path, "utf8")), "resources")); +/** + * The logical keys under management in a state file — the values `ct state rm` accepts. + * + * `type` narrows them to the keys of that resource type, because `ct state rm + * ` rejects a key of any other type: offering it would only complete into an error. + */ +export function stateKeys(path: string, type?: string): Promise { + return offline(async () => { + const resources = objectField(JSON.parse(await readFile(path, "utf8")), "resources"); + return Object.entries(resources) + .filter(([, entry]) => type === undefined || (isObject(entry) && entry.type === type)) + .map(([key]) => key); + }, []); } /** The resource types the registry knows, straight from the registry so it cannot drift. */ @@ -60,6 +90,18 @@ export function resourceTypes(): string[] { return Object.keys(RESOURCES); } +/** + * `~` is the shell's notion, not the filesystem's. The installed hooks turn off the + * shell's own filename fallback (`complete -F` without `-o default`, `complete -f`), so + * if `ct` does not resolve the tilde itself, `--backup-dir ~/b` offers nothing at + * all. Only the directory being read is expanded — the candidates keep the `~/` the + * user typed, so the shell's prefix filter still matches them. + */ +function expandTilde(path: string): string { + if (path === "~") return homedir(); + return path.startsWith("~/") ? join(homedir(), path.slice(2)) : path; +} + /** * Filesystem candidates for a partially typed path. * @@ -72,10 +114,10 @@ export function paths(partial: string, kind: "file" | "directory"): Promise base.startsWith(".") || !entry.name.startsWith(".")) .filter((entry) => kind === "file" || entry.isDirectory()) .map((entry) => `${prefix}${entry.name}${entry.isDirectory() ? "/" : ""}`); - }); + }, []); } diff --git a/tests/completion.test.ts b/tests/completion.test.ts index dd4a523..baf36c4 100644 --- a/tests/completion.test.ts +++ b/tests/completion.test.ts @@ -123,7 +123,7 @@ describe("dynamic completion", () => { expect(await complete(buildProgram(), "ct plan --env ")).toEqual(["dev", "prod"]); }); - it("completes `state rm` with the keys actually under management", async () => { + it("completes `state rm` with the keys actually under management, of the typed type", async () => { process.env.CT_STATE = join(dir, "ct-state.json"); await writeFile( process.env.CT_STATE, @@ -133,7 +133,50 @@ describe("dynamic completion", () => { resources: { mainz: { type: "campus", id: 1 }, youth: { type: "group", id: 2 } }, }), ); - expect(await complete(buildProgram(), "ct state rm campus ")).toEqual(["mainz", "youth"]); + // `youth` is a group; `state rm campus youth` is refused, so it is not offered. + expect(await complete(buildProgram(), "ct state rm campus ")).toEqual(["mainz"]); + expect(await complete(buildProgram(), "ct state rm group ")).toEqual(["youth"]); + }); + + it("completes `state rm` from the state file the env profile declares", async () => { + // The profile's own `state` field is what the command will edit, so it is what + // completion has to read — not the `ct-state..json` convention it overrides. + process.env.CT_ENVS = join(dir, "ct.envs.json"); + await writeFile( + process.env.CT_ENVS, + JSON.stringify({ + environments: { dev: { host: "https://x-dev.church.tools", state: "declared.json" } }, + }), + ); + await writeFile( + join(dir, "declared.json"), + JSON.stringify({ + version: 1, + host: "https://x-dev.church.tools", + resources: { mainz: { type: "campus", id: 1 } }, + }), + ); + const cwd = process.cwd(); + process.chdir(dir); + try { + expect(await complete(buildProgram(), "ct state rm --env dev campus ")).toEqual(["mainz"]); + } finally { + process.chdir(cwd); + } + }); + + it("completes `adopt` with the resource types the registry knows", async () => { + expect(await complete(buildProgram(), "ct adopt ")).toEqual( + expect.arrayContaining(["campus", "group", "group-role"]), + ); + }); + + it("completes a path option under the home directory", async () => { + // The hooks turn off the shell's own filename fallback, so `ct` must expand `~` + // itself or a tilde path completes to nothing at all. + const candidates = await complete(buildProgram(), "ct apply --backup-dir ~/"); + expect(candidates.length).toBeGreaterThan(0); + for (const candidate of candidates) expect(candidate.startsWith("~/")).toBe(true); }); it("completes `state rm` with the resource types the registry knows", async () => { @@ -170,6 +213,28 @@ describe("splitCompletionLine", () => { it("treats the cursor after a space as a new, empty word", () => { expect(splitCompletionLine("ct state ", 2)).toEqual({ words: ["ct", "state"], partial: "" }); }); + + it("splits at the cursor, not at the end of the line", () => { + // The line the shells hand over is the whole buffer, including what is typed after + // the cursor; only `fragment` says which word Tab was pressed on. + expect(splitCompletionLine("ct state rm campus mainz", 3)).toEqual({ + words: ["ct", "state", "rm"], + partial: "campus", + }); + }); + + it("falls back to the shape of the line when the index overshoots it", () => { + // bash derives the index from COMP_CWORD, which breaks on more characters than the + // hook's colon fudge accounts for, so it can arrive past the end of the line. + expect(splitCompletionLine("ct plan --config a", 9)).toEqual({ + words: ["ct", "plan", "--config"], + partial: "a", + }); + expect(splitCompletionLine("ct plan --config a ", 9)).toEqual({ + words: ["ct", "plan", "--config", "a"], + partial: "", + }); + }); }); describe("the installed shell hook", () => { @@ -178,8 +243,9 @@ describe("the installed shell hook", () => { const script = completionScript(buildProgram(), shell); expect(script).toContain("--compgen"); expect(script).toContain("ct"); - // The whole hook is small because the shell dialect lives in the library, not here. - expect(script.split("\n").length).toBeLessThan(40); + // The whole hook stays small because the shell dialect lives in the library — + // apart from the handful of bash-completion stand-ins prepended for stock bash. + expect(script.split("\n").length).toBeLessThan(60); } }); @@ -189,6 +255,16 @@ describe("the installed shell hook", () => { expect(completionScript(buildProgram(), "fish")).toContain("complete -f -c ct"); }); + it("does not depend on the bash-completion package being installed", () => { + // Stock macOS bash has neither helper; without stand-ins every Tab would print + // two "command not found" lines into the command line. + const script = completionScript(buildProgram(), "bash"); + for (const helper of ["_get_comp_words_by_ref", "__ltrim_colon_completions"]) { + // Defined only when absent, so a machine with bash-completion keeps the real one. + expect(script).toContain(`if ! declare -F ${helper} >/dev/null 2>&1; then`); + } + }); + it("recognises a Tab keypress by the hook's plumbing flag", () => { expect(isCompletionRequest(["node", "ct", "--compbash", "--compgen", "1", "ct", "ct "])).toBe(true); expect(isCompletionRequest(["node", "ct", "plan"])).toBe(false); From be7b753a952a07e5b92fa5f66e63f838d71d2d11 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Mon, 24 Aug 2026 16:28:23 +0200 Subject: [PATCH 3/3] style: prettier Claude-Session: https://claude.ai/code/session_018XbTXWQnBB5rgbXwJYRFHM --- src/completion/candidates.ts | 3 +-- src/completion/sources.ts | 5 ++++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/completion/candidates.ts b/src/completion/candidates.ts index dd27a88..801750a 100644 --- a/src/completion/candidates.ts +++ b/src/completion/candidates.ts @@ -42,8 +42,7 @@ const DYNAMIC_ARGUMENTS: Record = { "ct state rm type": () => resourceTypes(), // `ct state rm ` refuses a key belonging to another type, so the type // already typed narrows the keys — completing into a guaranteed error helps nobody. - "ct state rm key": async (position) => - stateKeys(await statePathFor(position), position.positionals[0]), + "ct state rm key": async (position) => stateKeys(await statePathFor(position), position.positionals[0]), }; /** diff --git a/src/completion/sources.ts b/src/completion/sources.ts index b10677e..75803b0 100644 --- a/src/completion/sources.ts +++ b/src/completion/sources.ts @@ -53,7 +53,10 @@ function objectField(value: unknown, field: string): Record { /** The environment names declared in the profile file — the values `--env` accepts. */ export function envNames(path: string): Promise { - return offline(async () => Object.keys(objectField(JSON.parse(await readFile(path, "utf8")), "environments")), []); + return offline( + async () => Object.keys(objectField(JSON.parse(await readFile(path, "utf8")), "environments")), + [], + ); } /**