diff --git a/README.md b/README.md index 9f2a84f..45d5ded 100644 --- a/README.md +++ b/README.md @@ -70,22 +70,50 @@ downloads the release tarball and checks the published sha256, and if any of that fails it warns and moves on rather than failing an install that otherwise worked. Authenticate it once with `stripe login`. +It also installs the **companions** — two commands this set ships but does not +implement, because they are published on npm in their own right: + +| | | +| --- | --- | +| `timer` | [`@profullstack/timer`](https://github.com/profullstack/timer) — track time against projects, for people and for agents | +| `billing` | [`@profullstack/billing`](https://github.com/profullstack/billing) — clients, rates and invoices from the hours the timer tracked | + +They are not `bin/*.ts` like everything else here for a reason: they run on +Windows, which this install cannot (it is symlinks into a git checkout executed +through an `npx tsx` shebang), and they are useful with no checkout at all — +under any agentic CLI, from a Dockerfile, on a box that has never heard of this +repository. Vendoring them to make one list tidier would cost them all of that. +So `cli-tools` is their front door, not their implementation. + +`npm install -g` is idempotent, which is what lets install, re-install and +update be the same command. `CLI_TOOLS_NO_COMPANIONS=1` skips them, and an npm +failure warns rather than failing the install. + With moshcode on the box, the same thing: ```sh moshcode install cli-tools # then /cli-tools … in the pit ``` +That is now the one-liner that also gets you `/timer` and `/billing` in the +pit: moshcode hands both straight to these CLIs once they are installed. + Check what landed, and wire up the pit aliases: ```sh cli-tools list # * runs from here, ! is shadowed by another copy +cli-tools companions # the two from npm, and whether they are on PATH +cli-tools companions --install # install the missing ones (--force updates all) cli-tools aliases --install # /aff /blog /free /merge /prs /speak /web /whois cli-tools config # API keys: what is set, and where it came from -cli-tools update # git pull, reinstall, relink +cli-tools update # git pull, reinstall, relink, update companions cli-tools autoupdate --install # …or have a timer do that daily ``` +`cli-tools unlink` deliberately leaves the companions installed: they are +ordinary global npm packages that work without this checkout, so unlinking the +repository is no reason to take them off the machine. + ### Keeping it current `cli-tools autoupdate --install` writes a systemd **user** timer that runs diff --git a/bin/cli-tools.ts b/bin/cli-tools.ts index f0ca1c5..6261032 100755 --- a/bin/cli-tools.ts +++ b/bin/cli-tools.ts @@ -49,14 +49,17 @@ import { PIT_ALIASES, repoRoot, resolveCommand, + whichOnPath, } from '../src/registry.ts'; +import { COMPANIONS, ensure as ensureCompanions, statuses as companionStatuses } from '../src/companions.ts'; -const USAGE = `Usage: +export const USAGE = `Usage: cli-tools list cli-tools update [--auto] cli-tools autoupdate [--install [--hours N] | --remove] cli-tools link [--force] cli-tools unlink + cli-tools companions [--install [--force]] cli-tools aliases [--install] cli-tools config [pull | set [value] | unset ] cli-tools [args…] @@ -67,8 +70,10 @@ Commands: "--auto" is the unattended form: at most once a day, and only on a clean checkout of the default branch with nothing unpushed autoupdate A systemd user timer that runs "update --auto" for you - link Symlink the commands into ~/.local/bin - unlink Remove the symlinks we own + link Symlink the commands into ~/.local/bin, and install the companions + unlink Remove the symlinks we own (companions are left installed) + companions The commands that come from npm rather than this checkout + "--install" installs the missing ones, "--force" updates them all aliases Print the moshcode pit aliases, or write them with --install config API keys: what is set, where it came from, and how to change it "config pull" imports them from the logicsrc team vault @@ -98,11 +103,61 @@ const SPEC = { string: ['--hours'], } as const; +/** + * The dispatcher's own verbs. Anything else is one of the commands. + * + * Exported so a test can hold it against USAGE. This list going stale is a real + * failure mode with a quiet symptom: a verb documented in the usage text but + * missing here falls through to the passthrough and answers "unknown command" + * while the help says it exists. That is exactly what happened to `help`, and + * then again to `companions`. + */ +export const KNOWN_VERBS = new Set([ + 'help', 'list', 'update', 'autoupdate', 'link', 'unlink', 'companions', 'aliases', 'config', + 'where', +]); + function runLinks(root: string, args: readonly string[]): number { const script = join(root, 'scripts', 'install-links.mjs'); return spawnSync(process.execPath, [script, ...args], { cwd: root, stdio: 'inherit' }).status ?? 1; } +/** + * Install the npm-backed companions, and say what happened to each. + * + * Never fails the caller. `npm install -g` fails for ordinary reasons — no npm + * on the box, a read-only prefix, no network — and none of them are a reason + * for `cli-tools link` to report that the linking did not happen. The line is + * printed to stderr so it stays out of anything reading stdout. + */ +function installCompanions({ latest = false, quiet = false } = {}): ReturnType { + const results = ensureCompanions({ + onPath: (name) => whichOnPath(name), + run: (args) => { + const out = spawnSync('npm', args, { encoding: 'utf8' }); + if (out.error) return { status: 1, stderr: `npm is not available: ${out.error.message}` }; + return { status: out.status, stderr: out.stderr }; + }, + latest, + }); + + if (quiet) return results; + for (const entry of results) { + if (entry.action === 'present') continue; + if (entry.action === 'installed') { + process.stderr.write( + `${entry.name}: ${entry.message ? `${entry.package} ${entry.message}` : `installed ${entry.package}`}\n`, + ); + continue; + } + process.stderr.write( + `${entry.name}: could not install ${entry.package} — ${entry.message}\n` + + ` install it yourself with: npm install -g ${entry.package}\n`, + ); + } + return results; +} + /** Pull and relink. Dependencies come first so a new one is present before use. */ function update(root: string): number { const git = spawnSync('git', ['pull', '--ff-only'], { cwd: root, stdio: 'inherit' }); @@ -125,7 +180,12 @@ function update(root: string): number { return pnpm.status ?? 1; } - return runLinks(root, []); + const linked = runLinks(root, []); + // After the links, so a failed npm never hides a failed relink. `--latest` + // here is what makes `update` mean update for the companions too: a bare + // `npm install -g ` leaves an already-satisfied version in place. + installCompanions({ latest: true }); + return linked; } /** Where the last automatic check is remembered. */ @@ -549,9 +609,7 @@ export async function run(argv: readonly string[]): Promise { // `help` is here because it is what people type. Without it the word fell // through to the passthrough below, which reported "unknown command: help" // and exited 1 — before printing the usage that answers the question. - const known = new Set([ - 'help', 'list', 'update', 'autoupdate', 'link', 'unlink', 'aliases', 'config', 'where', - ]); + const known = KNOWN_VERBS; if (!known.has(command)) { const match = commands(root).find((entry) => entry.name === command); if (!match) { @@ -591,8 +649,10 @@ export async function run(argv: readonly string[]): Promise { ...resolveCommand(entry.name, binDir), })); + const companions = companionStatuses((name) => whichOnPath(name)); + if (options.flags.has('--json')) { - process.stdout.write(`${JSON.stringify({ root, commands: all }, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ root, commands: all, companions }, null, 2)}\n`); return 0; } @@ -608,10 +668,25 @@ export async function run(argv: readonly string[]): Promise { } } + // A separate block, because they are a different kind of thing: these + // come from npm and run with no checkout, so the *-ours / !-shadowed + // marks above would be answering a question that does not apply. + process.stdout.write('\nFrom npm:\n'); + for (const entry of companions) { + const mark = entry.state === 'installed' ? '*' : ' '; + process.stdout.write(`${mark} ${entry.name.padEnd(16)} ${entry.summary}\n`); + } + const other = all.filter((entry) => entry.status === 'other'); const missing = all.filter((entry) => entry.status === 'missing'); + const absent = companions.filter((entry) => entry.state === 'missing'); process.stdout.write('\n'); + if (absent.length > 0) { + process.stdout.write( + `${absent.length} companion${absent.length === 1 ? '' : 's'} not installed — run \`cli-tools companions --install\`.\n`, + ); + } if (other.length === 0 && missing.length === 0) { process.stdout.write('All running from this checkout.\n'); return 0; @@ -649,12 +724,41 @@ export async function run(argv: readonly string[]): Promise { integer(options.values, '--hours', 24, { min: 1, max: 24 * 30 }), ); - case 'link': - return runLinks(root, options.flags.has('--force') ? ['--force'] : []); + case 'link': { + const linked = runLinks(root, options.flags.has('--force') ? ['--force'] : []); + installCompanions(); + return linked; + } case 'unlink': + // Deliberately not uninstalling the companions. They are ordinary global + // npm packages that work with no checkout at all, so unlinking this + // repository is no reason to take them off the machine — and `npm rm -g` + // is not a decision to make on somebody's behalf. return runLinks(root, ['--remove']); + case 'companions': { + if (options.flags.has('--json')) { + const rows = options.flags.has('--install') + ? installCompanions({ latest: options.flags.has('--force'), quiet: true }) + : companionStatuses((name) => whichOnPath(name)); + process.stdout.write(`${JSON.stringify({ companions: rows }, null, 2)}\n`); + return 0; + } + if (options.flags.has('--install')) { + installCompanions({ latest: options.flags.has('--force') }); + return 0; + } + process.stdout.write('Published separately, installed from npm:\n\n'); + for (const entry of companionStatuses((name) => whichOnPath(name))) { + const mark = entry.state === 'installed' ? '*' : ' '; + process.stdout.write(`${mark} ${entry.name.padEnd(16)} ${entry.summary}\n`); + process.stdout.write(`${' '.repeat(19)}${entry.package}\n`); + } + process.stdout.write('\nInstall or update them with `cli-tools companions --install`.\n'); + return 0; + } + case 'aliases': { if (options.flags.has('--install')) return writeAliases(); if (options.flags.has('--json')) { diff --git a/install.sh b/install.sh index 7aa65a5..aece8ab 100755 --- a/install.sh +++ b/install.sh @@ -103,6 +103,26 @@ LINK_ARGS="" # shellcheck disable=SC2086 CLI_TOOLS_PREFIX="$PREFIX" node "$HOME_DIR/scripts/install-links.mjs" $LINK_ARGS +# ── Companions ─────────────────────────────────────────────────────────────── +# +# Commands this set ships but does not implement: published npm packages that +# bring their own binary. The list lives in src/companions.ts and is read from +# there rather than repeated here, so adding one is a single-file change. +# +# Run through the checkout's own dispatcher rather than $PREFIX/cli-tools: the +# link above is refused when another checkout already owns that name, and this +# should still work on such a box. +# +# Warns rather than dying, like the Stripe block below. npm being absent or a +# prefix being read-only should not fail an install that has otherwise +# succeeded — and CLI_TOOLS_NO_COMPANIONS=1 skips it entirely for anyone who +# would rather manage those packages themselves. +if [ "${CLI_TOOLS_NO_COMPANIONS:-0}" != "1" ]; then + say "Installing npm companions (timer, billing)" + "$HOME_DIR/bin/cli-tools.ts" companions --install || + printf 'cli-tools: companions skipped. Install them later with: cli-tools companions --install\n' >&2 +fi + # ── Stripe CLI ─────────────────────────────────────────────────────────────── # # Not one of this repo's commands: it is the official binary from diff --git a/package.json b/package.json index 8c394e4..f4ece2b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/cli-tools", - "version": "0.11.0", + "version": "0.12.0", "private": true, "description": "Local command-line tools, in TypeScript, exposed on PATH.", "type": "module", diff --git a/src/companions.ts b/src/companions.ts new file mode 100644 index 0000000..71ea0b4 --- /dev/null +++ b/src/companions.ts @@ -0,0 +1,149 @@ +/** + * Commands this set ships but does not implement. + * + * Everything in `bin/` is a TypeScript file symlinked onto PATH. A companion is + * the other kind: a published npm package that installs its own binary, which + * `cli-tools` installs, reports on and updates alongside its own commands so + * that one install brings the whole set. + * + * Why they are not `bin/*.ts` like everything else. These two run on Windows, + * and the install here cannot: it is symlinks into a git checkout executed + * through an `npx --yes tsx` shebang. They are also useful with no checkout at + * all — under any agentic CLI, from a Dockerfile, on a box that has never heard + * of this repository — which is what being on npm buys. Vendoring them here to + * make one list tidier would cost them all of that. + * + * So the relationship is the same one `cli-tools` has with the pit: this is the + * front door, not the implementation. `npm install -g` is idempotent, which is + * what lets install, re-install and update all be the same command. + */ + +export interface Companion { + /** The binary the package puts on PATH. */ + name: string; + /** What to hand `npm install -g`. */ + package: string; + summary: string; +} + +export const COMPANIONS: readonly Companion[] = [ + { + name: 'timer', + package: '@profullstack/timer', + summary: 'Track time against projects, for people and for agents', + }, + { + name: 'billing', + package: '@profullstack/billing', + summary: 'Clients, rates and invoices from the hours the timer tracked', + }, +]; + +export function findCompanion(name: string): Companion | null { + const key = String(name ?? '').trim().toLowerCase(); + return COMPANIONS.find((entry) => entry.name === key) ?? null; +} + +/** + * What `npm install -g` should be handed. + * + * `@latest` is explicit on an update because a bare `npm install -g ` will + * happily leave an already-satisfied version in place; naming the tag is what + * makes "update" mean it. + */ +export function installArgs(companion: Companion, { latest = false } = {}): string[] { + return ['install', '-g', latest ? `${companion.package}@latest` : companion.package]; +} + +export type CompanionState = 'installed' | 'missing'; + +export interface CompanionStatus extends Companion { + state: CompanionState; + /** Where the binary was found, or null. */ + path: string | null; +} + +/** + * Whether each companion is on PATH, and where. + * + * `onPath` is injected rather than imported so the tests can describe a machine + * instead of arranging one — installing a global npm package inside a test is + * not a thing a test gets to do. + */ +export function statuses( + onPath: (name: string) => string | null, + list: readonly Companion[] = COMPANIONS, +): CompanionStatus[] { + return list.map((companion) => { + const found = onPath(companion.name); + return { ...companion, state: found ? 'installed' : 'missing', path: found }; + }); +} + +export interface EnsureResult extends CompanionStatus { + /** What happened: it was already there, we installed it, or the install failed. */ + action: 'present' | 'installed' | 'failed'; + message?: string; +} + +/** + * Install the companions that are missing. + * + * Two rules, both about not being destructive on somebody else's machine: + * + * A companion already on PATH is left alone unless `latest` is set. It may be + * a newer version, a local build, or a fork someone is testing, and silently + * reinstalling over it is exactly the surprise `link` refuses for symlinks. + * + * A failure is reported and the loop continues. `npm install -g` fails for + * ordinary reasons — no npm, a read-only prefix, no network — and none of + * them are a reason for the rest of `cli-tools link` to have not happened. + */ +export function ensure( + { + onPath, + run, + latest = false, + list = COMPANIONS, + }: { + onPath: (name: string) => string | null; + run: (args: string[]) => { status: number | null; stderr?: string }; + latest?: boolean; + list?: readonly Companion[]; + }, +): EnsureResult[] { + const results: EnsureResult[] = []; + for (const companion of list) { + const found = onPath(companion.name); + if (found && !latest) { + results.push({ ...companion, state: 'installed', path: found, action: 'present' }); + continue; + } + const outcome = run(installArgs(companion, { latest })); + if (outcome.status === 0) { + const after = onPath(companion.name); + results.push({ + ...companion, + state: after ? 'installed' : 'missing', + path: after, + action: 'installed', + // npm can exit 0 having installed into a prefix that is not on PATH. + // Saying so beats reporting success for a command the operator cannot + // then run — the same gap turso and gradient have in moshcode. + // + // Spread rather than `message: undefined`: exactOptionalPropertyTypes + // is on, so an explicit undefined is not the same as an absent key. + ...(after ? {} : { message: 'installed, but its bin directory is not on PATH' }), + }); + continue; + } + results.push({ + ...companion, + state: found ? 'installed' : 'missing', + path: found, + action: 'failed', + message: (outcome.stderr ?? '').trim().split('\n').at(-1) || `npm exited ${outcome.status}`, + }); + } + return results; +} diff --git a/src/registry.ts b/src/registry.ts index 4ef2a1f..ed44aa3 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -73,6 +73,18 @@ export function onPath(name: string, env: NodeJS.ProcessEnv = process.env): bool return firstOnPath(name, env) !== null; } +/** + * Where this name resolves on PATH, or null. + * + * The same lookup as `onPath`, returning the path instead of a boolean, because + * a companion that is installed somewhere unexpected is worth naming — "npm + * says it installed it and it is not on your PATH" is a different problem from + * "it is not installed". + */ +export function whichOnPath(name: string, env: NodeJS.ProcessEnv = process.env): string | null { + return firstOnPath(name, env); +} + /** The first executable of this name on PATH, or null. */ function firstOnPath(name: string, env: NodeJS.ProcessEnv): string | null { for (const dir of (env.PATH ?? '').split(':').filter(Boolean)) { diff --git a/test/companions.test.ts b/test/companions.test.ts new file mode 100644 index 0000000..879a464 --- /dev/null +++ b/test/companions.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest'; + +import { + COMPANIONS, + ensure, + findCompanion, + installArgs, + statuses, + type Companion, +} from '../src/companions.ts'; + +const present = (...names: string[]) => (name: string) => + names.includes(name) ? `/usr/local/bin/${name}` : null; +const nothing = () => null; + +describe('the companion list', () => { + it('names the timer and billing packages', () => { + expect(COMPANIONS.map((c) => c.name)).toEqual(['timer', 'billing']); + expect(COMPANIONS.map((c) => c.package)).toEqual([ + '@profullstack/timer', + '@profullstack/billing', + ]); + }); + + it('gives every companion a scoped package and a summary', () => { + for (const companion of COMPANIONS) { + expect(companion.package.startsWith('@profullstack/'), companion.name).toBe(true); + expect(companion.summary.length, companion.name).toBeGreaterThan(0); + // The binary name is not derivable from the package name in general, so + // it is stated; this holds it to the one case we actually ship. + expect(companion.package.endsWith(`/${companion.name}`), companion.name).toBe(true); + } + }); + + it('resolves a companion by name, case-insensitively', () => { + expect(findCompanion('timer')?.package).toBe('@profullstack/timer'); + expect(findCompanion('BILLING')?.package).toBe('@profullstack/billing'); + expect(findCompanion('nonsense')).toBeNull(); + expect(findCompanion('')).toBeNull(); + }); +}); + +describe('installArgs', () => { + const timer = COMPANIONS[0] as Companion; + + it('installs the package globally', () => { + expect(installArgs(timer)).toEqual(['install', '-g', '@profullstack/timer']); + }); + + it('names @latest on an update, because a bare install would be a no-op', () => { + // `npm install -g ` leaves an already-satisfied version alone, so + // without the tag `cli-tools update` would silently never move them. + expect(installArgs(timer, { latest: true })).toEqual([ + 'install', + '-g', + '@profullstack/timer@latest', + ]); + }); +}); + +describe('statuses', () => { + it('reports what is on PATH and where', () => { + const rows = statuses(present('timer')); + expect(rows.map((r) => [r.name, r.state])).toEqual([ + ['timer', 'installed'], + ['billing', 'missing'], + ]); + expect(rows[0]?.path).toBe('/usr/local/bin/timer'); + expect(rows[1]?.path).toBeNull(); + }); +}); + +describe('ensure', () => { + it('leaves an installed companion alone', () => { + // It may be a newer version, a local build, or a fork somebody is testing. + // Reinstalling over it is the surprise `link` refuses for symlinks. + const calls: string[][] = []; + const results = ensure({ + onPath: present('timer', 'billing'), + run: (args) => { + calls.push(args); + return { status: 0 }; + }, + }); + expect(calls).toEqual([]); + expect(results.every((r) => r.action === 'present')).toBe(true); + }); + + it('installs only what is missing', () => { + const calls: string[][] = []; + const installed = new Set(['timer']); + ensure({ + onPath: (name) => (installed.has(name) ? `/usr/local/bin/${name}` : null), + run: (args) => { + calls.push(args); + installed.add('billing'); + return { status: 0 }; + }, + }); + expect(calls).toEqual([['install', '-g', '@profullstack/billing']]); + }); + + it('reinstalls everything at @latest when asked', () => { + const calls: string[][] = []; + ensure({ + onPath: present('timer', 'billing'), + run: (args) => { + calls.push(args); + return { status: 0 }; + }, + latest: true, + }); + expect(calls).toEqual([ + ['install', '-g', '@profullstack/timer@latest'], + ['install', '-g', '@profullstack/billing@latest'], + ]); + }); + + it('keeps going after a failure, and says which package and why', () => { + // npm fails for ordinary reasons — no npm, a read-only prefix, no network — + // and none of them are a reason for the rest of `cli-tools link` to stop. + const attempted: string[][] = []; + const results = ensure({ + onPath: nothing, + run: (args) => { + attempted.push(args); + return { status: 1, stderr: 'npm ERR! code EACCES\nnpm ERR! permission denied' }; + }, + }); + expect(attempted).toHaveLength(2); + expect(results.every((r) => r.action === 'failed')).toBe(true); + expect(results[0]?.message).toBe('npm ERR! permission denied'); + expect(results[0]?.state).toBe('missing'); + }); + + it('does not call a zero exit a success when the binary is still not on PATH', () => { + // npm can install into a prefix that is not on PATH and exit 0. Reporting + // that as installed sends someone to a command they cannot run. + const results = ensure({ + onPath: nothing, + run: () => ({ status: 0 }), + }); + expect(results[0]?.action).toBe('installed'); + expect(results[0]?.state).toBe('missing'); + expect(results[0]?.message).toMatch(/not on PATH/); + }); + + it('reports a missing npm as a failure rather than throwing', () => { + const results = ensure({ + onPath: nothing, + run: () => ({ status: 1, stderr: 'npm is not available: spawnSync npm ENOENT' }), + }); + expect(results[0]?.action).toBe('failed'); + expect(results[0]?.message).toMatch(/npm is not available/); + }); +}); diff --git a/test/dispatcher.test.ts b/test/dispatcher.test.ts new file mode 100644 index 0000000..a52d78a --- /dev/null +++ b/test/dispatcher.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; + +import { KNOWN_VERBS, USAGE } from '../bin/cli-tools.ts'; +import { COMPANIONS } from '../src/companions.ts'; +import { commands } from '../src/registry.ts'; + +/** + * The verbs the usage text claims exist. + * + * Both halves count. The synopsis carries the invocation, the `Commands:` block + * carries the explanation, and a verb documented in either is one somebody can + * find — `help` and `where` are only in the second, which is fine. + */ +function documentedVerbs(): string[] { + const found = new Set(); + let inCommands = false; + for (const line of USAGE.split('\n')) { + const synopsis = /^\s{2}cli-tools ([a-z-]+)/.exec(line); + if (synopsis?.[1] && synopsis[1] !== '') found.add(synopsis[1]); + + if (/^Commands:/.test(line)) { inCommands = true; continue; } + // The block ends at the next unindented heading (`Keys (config set …)`). + if (inCommands && line.trim() && !/^\s/.test(line)) inCommands = false; + if (!inCommands) continue; + // A verb line is ` name description`; a continuation line is indented + // further and has no name of its own. + const entry = /^\s{2}([a-z-]+)\s{2,}\S/.exec(line); + if (entry?.[1]) found.add(entry[1]); + } + return [...found]; +} + +describe('the dispatcher verb list', () => { + it('knows every verb the usage text documents', () => { + // The quiet failure this catches: a verb documented in USAGE but missing + // from KNOWN_VERBS falls through to the command passthrough and answers + // "unknown command" while the help insists it exists. It happened to + // `help`, and then again to `companions`. + for (const verb of documentedVerbs()) { + expect(KNOWN_VERBS.has(verb), `${verb} is documented but not dispatched`).toBe(true); + } + }); + + it('documents every verb it dispatches', () => { + // The other direction: an undocumented verb is one nobody can find. + const documented = new Set(documentedVerbs()); + for (const verb of KNOWN_VERBS) { + expect(documented.has(verb), `${verb} is dispatched but not in the usage text`).toBe(true); + } + }); + + it('never shadows one of the commands with a verb', () => { + // A verb wins over the passthrough, so a name in both lists makes the + // command unreachable through the dispatcher. + const names = new Set(commands().map((entry) => entry.name)); + for (const verb of KNOWN_VERBS) { + if (verb === 'cli-tools') continue; + expect(names.has(verb), `${verb} is both a verb and a command`).toBe(false); + } + }); + + it('never shadows a companion either', () => { + // `cli-tools timer …` has to reach the timer, not a dispatcher verb. + for (const companion of COMPANIONS) { + expect(KNOWN_VERBS.has(companion.name), `${companion.name} is shadowed by a verb`).toBe(false); + } + }); +});