feat(cli-registry): generate a CLI catalogue for install.sh and the Docker build

Two consumers of the registry cannot import TypeScript: `install.sh`, which runs
via `curl | bash` before any checkout exists, and `scripts/build-agent-image.mjs`.
Both currently hand-maintain their own CLI lists, and both have already drifted.

`scripts/generate-cli-catalog.mts` (`npm run generate:cli-catalog`, plus a
`--check` mode) emits from `STOCK_CLIS`:

- `config/clis.stock.json` for the `.mjs` and the tests. It carries `enabled` —
  the field the earlier attempt omitted, which is how a disabled CLI's npm
  package still got baked into every agent image.
- a marker-delimited block inside `install.sh`, embedded rather than fetched.
  The embedded copy is the FULL catalogue on purpose: the earlier design fetched
  it and fell back to a hardcoded two-CLI list, degrading silently on an empty
  response. There is no degraded mode to fall into now.

The block is bash 3.2 safe: parallel indexed arrays, no associative arrays, no
namerefs, no mapfile. Variable-length lists use OFFSET/LENGTH windows into one
flat array rather than a delimiter, so a $HOME containing a space needs no IFS
handling and `shell` (no binaries) gets length 0 and is never iterated. Search
paths are emitted dir-major, matching the probe order the hand-written arrays
use and `test/install-sh-detection-parity.test.ts` pins.

Only fields the two consumers need are exported. `launch`/`env`/`capabilities`/
`overlays` are spawn-time concerns the server alone interprets, and a test
asserts they never leak into the artifact.

`main()` sits behind an `isMainModule()` guard so the sync test can import the
renderers. Without it, importing the module would rewrite the artifacts as a
side effect of checking them — passing always, guarding never.

This commit adds the block; it does not yet delete the hand-written arrays, so
the detection pin keeps measuring both against each other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015EMxQreQUZX5ZyybxAGh12
This commit is contained in:
Devvyn
2026-09-13 17:43:13 +08:00
co-authored by Claude Opus 5
parent 84f71e5704
commit 7d6f612ef5
5 changed files with 630 additions and 0 deletions
+273
View File
@@ -0,0 +1,273 @@
[
{
"id": "claude",
"label": "Claude",
"shortBadge": "CC",
"enabled": true,
"order": 0,
"kind": "agent",
"discovery": {
"binaries": [
"claude"
],
"searchDirs": [
"~/.local/bin",
"~/.claude/local",
"/usr/local/bin",
"~/.npm-global/bin",
"~/bin"
],
"install": {
"command": {
"linux": "curl -fsSL https://claude.ai/install.sh | bash",
"darwin": "curl -fsSL https://claude.ai/install.sh | bash",
"wsl": "curl -fsSL https://claude.ai/install.sh | bash"
},
"npmPackage": "@anthropic-ai/claude-code",
"docsUrl": "https://docs.claude.com/claude-code"
}
}
},
{
"id": "shell",
"label": "Shell",
"shortBadge": "SH",
"enabled": true,
"order": 1,
"kind": "shell",
"discovery": {
"binaries": [],
"searchDirs": [],
"install": {
"command": {}
}
}
},
{
"id": "opencode",
"label": "OpenCode",
"shortBadge": "OC",
"enabled": true,
"order": 10,
"kind": "agent",
"discovery": {
"binaries": [
"opencode"
],
"searchDirs": [
"~/.opencode/bin",
"~/.local/bin",
"/usr/local/bin",
"~/go/bin",
"~/.bun/bin",
"~/.npm-global/bin",
"~/bin"
],
"install": {
"command": {
"linux": "curl -fsSL https://opencode.ai/install | bash",
"darwin": "curl -fsSL https://opencode.ai/install | bash"
},
"npmPackage": "opencode-ai",
"docsUrl": "https://opencode.ai/docs"
}
}
},
{
"id": "codex",
"label": "Codex",
"shortBadge": "CX",
"enabled": true,
"order": 20,
"kind": "agent",
"discovery": {
"binaries": [
"codex"
],
"searchDirs": [
"~/.codex/bin",
"~/.local/bin",
"/usr/local/bin",
"~/.bun/bin",
"~/.npm-global/bin",
"~/bin"
],
"install": {
"command": {
"linux": "npm install -g @openai/codex",
"darwin": "npm install -g @openai/codex"
},
"npmPackage": "@openai/codex",
"docsUrl": "https://developers.openai.com/codex/cli"
}
}
},
{
"id": "gemini",
"label": "Gemini",
"shortBadge": "GM",
"enabled": true,
"order": 30,
"kind": "agent",
"discovery": {
"binaries": [
"gemini"
],
"searchDirs": [
"~/.gemini/bin",
"~/.local/bin",
"/usr/local/bin",
"~/.bun/bin",
"~/.npm-global/bin",
"~/bin"
],
"install": {
"command": {
"linux": "npm install -g @google/gemini-cli",
"darwin": "npm install -g @google/gemini-cli"
},
"npmPackage": "@google/gemini-cli",
"docsUrl": "https://github.com/google-gemini/gemini-cli"
}
}
},
{
"id": "antigravity",
"label": "Antigravity",
"shortBadge": "AG",
"enabled": true,
"order": 40,
"kind": "agent",
"discovery": {
"binaries": [
"agy"
],
"searchDirs": [
"~/.local/bin",
"~/.antigravity/bin",
"/usr/local/bin",
"~/bin"
],
"install": {
"command": {
"linux": "curl -fsSL https://antigravity.google/cli/install.sh | bash",
"darwin": "curl -fsSL https://antigravity.google/cli/install.sh | bash"
},
"docsUrl": "https://antigravity.google/cli"
}
}
},
{
"id": "pi",
"label": "Pi",
"shortBadge": "PI",
"enabled": true,
"order": 50,
"kind": "agent",
"discovery": {
"binaries": [
"pi"
],
"searchDirs": [
"~/.local/bin",
"/usr/local/bin",
"~/.bun/bin",
"~/.npm-global/bin",
"~/bin"
],
"install": {
"command": {
"linux": "npm install -g --ignore-scripts @earendil-works/pi-coding-agent",
"darwin": "npm install -g --ignore-scripts @earendil-works/pi-coding-agent"
},
"npmPackage": "@earendil-works/pi-coding-agent",
"docsUrl": "https://pi.dev"
}
}
},
{
"id": "grok",
"label": "Grok",
"shortBadge": "GK",
"enabled": true,
"order": 70,
"kind": "agent",
"discovery": {
"binaries": [
"grok"
],
"searchDirs": [
"~/.grok/bin",
"~/.local/bin",
"/usr/local/bin",
"~/bin"
],
"install": {
"command": {
"linux": "curl -fsSL https://x.ai/cli/install.sh | bash",
"darwin": "curl -fsSL https://x.ai/cli/install.sh | bash"
},
"docsUrl": "https://github.com/xai-org/grok-build"
}
}
},
{
"id": "deepseek",
"label": "DeepSeek",
"shortBadge": "DS",
"enabled": true,
"order": 80,
"kind": "agent",
"discovery": {
"binaries": [
"dsh"
],
"searchDirs": [
"~/.local/bin",
"/usr/local/bin",
"~/.npm-global/bin",
"~/bin"
],
"identity": {
"arg": "--help",
"regex": "DeepSeek\\s+Harness"
},
"install": {
"command": {
"linux": "npm install -g @deepseek-ai/dsh",
"darwin": "npm install -g @deepseek-ai/dsh"
},
"npmPackage": "@deepseek-ai/dsh",
"docsUrl": "https://github.com/deepseek-ai/deepseek-harness"
}
}
},
{
"id": "omp",
"label": "OMP",
"shortBadge": "OM",
"enabled": true,
"order": 90,
"kind": "agent",
"discovery": {
"binaries": [
"omp"
],
"searchDirs": [
"~/.local/bin",
"~/.omp/bin",
"/usr/local/bin",
"~/.bun/bin",
"~/.npm-global/bin",
"~/bin"
],
"install": {
"command": {
"linux": "curl -fsSL https://omp.sh/install | sh",
"darwin": "brew install can1357/tap/omp"
},
"docsUrl": "https://omp.sh"
}
}
}
]
+29
View File
@@ -160,6 +160,35 @@ OMP_SEARCH_PATHS=(
"$HOME/bin/omp"
)
# >>> BEGIN GENERATED CLI CATALOGUE
# Generated from src/config/cli-registry/stock.ts by scripts/generate-cli-catalog.mts.
# Do not edit by hand: run `npm run generate:cli-catalog` and commit the result.
#
# Parallel indexed arrays, bash 3.2 safe (no associative arrays, no nameref, no mapfile).
# The variable-length lists use OFFSET/LENGTH windows into one flat array rather than a
# delimiter, so a $HOME containing a space needs no IFS handling and an entry with nothing
# to contribute (shell has no binaries) gets length 0 and is simply never iterated.
#
# ⚠️ TRUST BOUNDARY: CLI_CMD_LINUX/CLI_CMD_DARWIN are the ONLY source of a command this
# script will ever execute, and they arrive embedded in this file — same TLS fetch, same
# commit as the script itself. Nothing fetched at install time may write them; the
# refresh may only touch the *_DISPLAY copy. See cli_catalog_select_platform below.
CLI_IDS=('claude' 'shell' 'opencode' 'codex' 'gemini' 'antigravity' 'pi' 'grok' 'deepseek' 'omp')
CLI_LABELS=('Claude' 'Shell' 'OpenCode' 'Codex' 'Gemini' 'Antigravity' 'Pi' 'Grok' 'DeepSeek' 'OMP')
CLI_ENABLED=(1 1 1 1 1 1 1 1 1 1)
CLI_KIND=('agent' 'shell' 'agent' 'agent' 'agent' 'agent' 'agent' 'agent' 'agent' 'agent')
CLI_NPM=('@anthropic-ai/claude-code' '' 'opencode-ai' '@openai/codex' '@google/gemini-cli' '' '@earendil-works/pi-coding-agent' '' '@deepseek-ai/dsh' '')
CLI_DOCS=('https://docs.claude.com/claude-code' '' 'https://opencode.ai/docs' 'https://developers.openai.com/codex/cli' 'https://github.com/google-gemini/gemini-cli' 'https://antigravity.google/cli' 'https://pi.dev' 'https://github.com/xai-org/grok-build' 'https://github.com/deepseek-ai/deepseek-harness' 'https://omp.sh')
CLI_CMD_LINUX=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' 'npm install -g @deepseek-ai/dsh' 'curl -fsSL https://omp.sh/install | sh')
CLI_CMD_DARWIN=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' 'npm install -g @deepseek-ai/dsh' 'brew install can1357/tap/omp')
CLI_ALL_BINS=('claude' 'opencode' 'codex' 'gemini' 'agy' 'pi' 'grok' 'dsh' 'omp')
CLI_BIN_OFF=(0 1 1 2 3 4 5 6 7 8)
CLI_BIN_LEN=(1 0 1 1 1 1 1 1 1 1)
CLI_ALL_PATHS=("$HOME/.local/bin/claude" "$HOME/.claude/local/claude" "/usr/local/bin/claude" "$HOME/.npm-global/bin/claude" "$HOME/bin/claude" "$HOME/.opencode/bin/opencode" "$HOME/.local/bin/opencode" "/usr/local/bin/opencode" "$HOME/go/bin/opencode" "$HOME/.bun/bin/opencode" "$HOME/.npm-global/bin/opencode" "$HOME/bin/opencode" "$HOME/.codex/bin/codex" "$HOME/.local/bin/codex" "/usr/local/bin/codex" "$HOME/.bun/bin/codex" "$HOME/.npm-global/bin/codex" "$HOME/bin/codex" "$HOME/.gemini/bin/gemini" "$HOME/.local/bin/gemini" "/usr/local/bin/gemini" "$HOME/.bun/bin/gemini" "$HOME/.npm-global/bin/gemini" "$HOME/bin/gemini" "$HOME/.local/bin/agy" "$HOME/.antigravity/bin/agy" "/usr/local/bin/agy" "$HOME/bin/agy" "$HOME/.local/bin/pi" "/usr/local/bin/pi" "$HOME/.bun/bin/pi" "$HOME/.npm-global/bin/pi" "$HOME/bin/pi" "$HOME/.grok/bin/grok" "$HOME/.local/bin/grok" "/usr/local/bin/grok" "$HOME/bin/grok" "$HOME/.local/bin/dsh" "/usr/local/bin/dsh" "$HOME/.npm-global/bin/dsh" "$HOME/bin/dsh" "$HOME/.local/bin/omp" "$HOME/.omp/bin/omp" "/usr/local/bin/omp" "$HOME/.bun/bin/omp" "$HOME/.npm-global/bin/omp" "$HOME/bin/omp")
CLI_PATH_OFF=(0 5 5 12 18 24 28 33 37 41)
CLI_PATH_LEN=(5 0 7 6 6 4 5 4 4 6)
# <<< END GENERATED CLI CATALOGUE
# ============================================================================
# Color Output
# ============================================================================
+1
View File
@@ -13,6 +13,7 @@
"postinstall": "node scripts/postinstall.js",
"build": "node scripts/build.mjs",
"build:gesture": "node scripts/build-gesture-bundle.mjs",
"generate:cli-catalog": "tsx scripts/generate-cli-catalog.mts",
"start": "NODE_COMPILE_CACHE=${HOME}/.codeman/compile-cache node dist/index.js",
"dev": "tsx src/index.ts web",
"web": "node dist/index.js web",
+247
View File
@@ -0,0 +1,247 @@
/**
* Regenerates the two CLI-catalogue artifacts from `src/config/cli-registry/stock.ts`,
* which stays the single source of truth.
*
* npm run generate:cli-catalog # rewrite both artifacts
* npm run generate:cli-catalog -- --check # exit 1 on drift, write nothing
*
* The artifacts exist because two consumers cannot import TypeScript:
*
* - `config/clis.stock.json` — read by `scripts/lib/cli-catalog.mjs` (a `.mjs` that feeds
* the Docker build args) and by the tests.
* - a generated block inside `install.sh` — the installer runs via `curl | bash` BEFORE any
* checkout exists, so it can read neither the registry nor the JSON. Its copy is embedded.
*
* ⚠️ The embedded copy is the FULL catalogue, deliberately. An earlier design fetched the
* JSON at install time and fell back to a hardcoded two-CLI list, which degraded silently on
* an empty response. There is no degraded mode to fall into now.
*
* ⚠️ Only fields the two consumers actually need are exported. `launch`, `env`, `capabilities`
* and `overlays` are spawn-time concerns the server alone interprets, and exporting them would
* invite a second implementation of the launch model outside the process that owns it.
*
* `test/cli-catalog-sync.test.ts` pins both artifacts against a fresh generation.
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
import type { CliEntry } from '../src/config/cli-registry/types.js';
const JSON_PATH = fileURLToPath(new URL('../config/clis.stock.json', import.meta.url));
const INSTALL_SH_PATH = fileURLToPath(new URL('../install.sh', import.meta.url));
const BEGIN_MARKER = '# >>> BEGIN GENERATED CLI CATALOGUE';
const END_MARKER = '# <<< END GENERATED CLI CATALOGUE';
/** Platforms install.sh can be running on. `wsl`/`win32` resolve through the linux arm. */
type InstallPlatform = 'linux' | 'darwin';
// ---------------------------------------------------------------------------
// config/clis.stock.json
// ---------------------------------------------------------------------------
interface CatalogEntry {
id: string;
label: string;
shortBadge: string;
enabled: boolean;
order: number;
kind: string;
discovery: {
binaries: string[];
searchDirs: string[];
identity?: { arg: string; regex: string };
install: { command: Record<string, string>; npmPackage?: string; docsUrl?: string };
};
}
function toCatalogEntry(entry: CliEntry): CatalogEntry {
const { binaries, searchDirs, identity, install } = entry.discovery;
return {
id: entry.id as string,
label: entry.label,
shortBadge: entry.shortBadge,
// ⚠️ The field the previous attempt omitted, which is how a disabled CLI's npm package
// still got baked into every agent image. Every consumer filters on it.
enabled: entry.enabled,
order: entry.order,
kind: entry.kind,
discovery: {
binaries: [...binaries],
searchDirs: [...searchDirs],
...(identity ? { identity: { arg: identity.arg, regex: identity.regex } } : {}),
install: {
command: { ...install.command } as Record<string, string>,
...(install.npmPackage ? { npmPackage: install.npmPackage } : {}),
...(install.docsUrl ? { docsUrl: install.docsUrl } : {}),
},
},
};
}
export function renderCatalogJson(entries: CliEntry[] = STOCK_CLIS): string {
return `${JSON.stringify(entries.map(toCatalogEntry), null, 2)}\n`;
}
// ---------------------------------------------------------------------------
// The install.sh block
// ---------------------------------------------------------------------------
/** Single-quote a value for bash, escaping any embedded single quote. */
function shQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
/**
* A search dir as install.sh spells it. `~` becomes `$HOME` inside DOUBLE quotes so the shell
* expands it at load time, exactly as the hand-written arrays did; everything else is
* absolute and needs no expansion.
*/
function shPath(dir: string, binary: string): string {
const expanded = dir.startsWith('~/') ? `$HOME/${dir.slice(2)}` : dir;
return `"${expanded}/${binary}"`;
}
/**
* The install command to run on `platform`, mirroring `resolveInstallCommandForPlatform()`:
* the exact platform, else linux, else whatever is declared. Resolved HERE, at generation
* time, so that fallback logic stays in tested TypeScript instead of being reimplemented in
* bash against an array the script would have to index by platform anyway.
*/
function installCommandFor(entry: CliEntry, platform: InstallPlatform): string {
const { command } = entry.discovery.install;
return command[platform] ?? command.linux ?? Object.values(command)[0] ?? '';
}
export function renderInstallShBlock(entries: CliEntry[] = STOCK_CLIS): string {
const ids: string[] = [];
const labels: string[] = [];
const enabled: string[] = [];
const kinds: string[] = [];
const npm: string[] = [];
const docs: string[] = [];
const cmdLinux: string[] = [];
const cmdDarwin: string[] = [];
const allBins: string[] = [];
const binOff: number[] = [];
const binLen: number[] = [];
const allPaths: string[] = [];
const pathOff: number[] = [];
const pathLen: number[] = [];
for (const entry of entries) {
ids.push(shQuote(entry.id as string));
labels.push(shQuote(entry.label));
enabled.push(entry.enabled ? '1' : '0');
kinds.push(shQuote(entry.kind));
npm.push(shQuote(entry.discovery.install.npmPackage ?? ''));
docs.push(shQuote(entry.discovery.install.docsUrl ?? ''));
cmdLinux.push(shQuote(installCommandFor(entry, 'linux')));
cmdDarwin.push(shQuote(installCommandFor(entry, 'darwin')));
const { binaries, searchDirs } = entry.discovery;
binOff.push(allBins.length);
binLen.push(binaries.length);
for (const bin of binaries) allBins.push(shQuote(bin));
// Dir-major, matching the probe order the hand-written arrays used and
// `test/install-sh-detection-parity.test.ts` pins.
pathOff.push(allPaths.length);
let count = 0;
for (const dir of searchDirs) {
for (const bin of binaries) {
allPaths.push(shPath(dir, bin));
count++;
}
}
pathLen.push(count);
}
const arr = (name: string, values: Array<string | number>): string =>
values.length === 0 ? `${name}=()` : `${name}=(${values.join(' ')})`;
return [
BEGIN_MARKER,
'# Generated from src/config/cli-registry/stock.ts by scripts/generate-cli-catalog.mts.',
'# Do not edit by hand: run `npm run generate:cli-catalog` and commit the result.',
'#',
'# Parallel indexed arrays, bash 3.2 safe (no associative arrays, no nameref, no mapfile).',
'# The variable-length lists use OFFSET/LENGTH windows into one flat array rather than a',
'# delimiter, so a $HOME containing a space needs no IFS handling and an entry with nothing',
'# to contribute (shell has no binaries) gets length 0 and is simply never iterated.',
'#',
'# ⚠️ TRUST BOUNDARY: CLI_CMD_LINUX/CLI_CMD_DARWIN are the ONLY source of a command this',
'# script will ever execute, and they arrive embedded in this file — same TLS fetch, same',
'# commit as the script itself. Nothing fetched at install time may write them; the',
'# refresh may only touch the *_DISPLAY copy. See cli_catalog_select_platform below.',
arr('CLI_IDS', ids),
arr('CLI_LABELS', labels),
arr('CLI_ENABLED', enabled),
arr('CLI_KIND', kinds),
arr('CLI_NPM', npm),
arr('CLI_DOCS', docs),
arr('CLI_CMD_LINUX', cmdLinux),
arr('CLI_CMD_DARWIN', cmdDarwin),
arr('CLI_ALL_BINS', allBins),
arr('CLI_BIN_OFF', binOff),
arr('CLI_BIN_LEN', binLen),
arr('CLI_ALL_PATHS', allPaths),
arr('CLI_PATH_OFF', pathOff),
arr('CLI_PATH_LEN', pathLen),
END_MARKER,
].join('\n');
}
/** Replace the marked block in `source`, or throw if the markers are missing/malformed. */
export function spliceInstallShBlock(source: string, block: string): string {
const begin = source.indexOf(BEGIN_MARKER);
const end = source.indexOf(END_MARKER);
if (begin === -1 || end === -1) {
throw new Error(
`install.sh is missing the generated-catalogue markers (${BEGIN_MARKER} / ${END_MARKER}). ` +
'Add them once by hand; the generator only rewrites between them.'
);
}
if (end < begin) throw new Error('install.sh has the catalogue markers in the wrong order.');
return source.slice(0, begin) + block + source.slice(end + END_MARKER.length);
}
// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------
/**
* ⚠️ Guarded so the module can be IMPORTED for its pure renderers without running.
* `test/cli-catalog-sync.test.ts` imports them, and an unguarded main would have that test
* rewrite the very artifacts it is supposed to be checking — passing always, guarding never.
*/
function isMainModule(): boolean {
const invoked = process.argv[1];
if (!invoked) return false;
return fileURLToPath(import.meta.url) === resolve(invoked);
}
function main(): void {
const check = process.argv.includes('--check');
const wantJson = renderCatalogJson();
const wantInstallSh = spliceInstallShBlock(readFileSync(INSTALL_SH_PATH, 'utf-8'), renderInstallShBlock());
if (check) {
const drift: string[] = [];
if (readFileSync(JSON_PATH, 'utf-8') !== wantJson) drift.push('config/clis.stock.json');
if (readFileSync(INSTALL_SH_PATH, 'utf-8') !== wantInstallSh) drift.push('install.sh');
if (drift.length > 0) {
console.error(`Out of date with stock.ts: ${drift.join(', ')}`);
console.error('Run `npm run generate:cli-catalog` and commit the result.');
process.exit(1);
}
console.log('CLI catalogue artifacts are in sync with stock.ts.');
} else {
writeFileSync(JSON_PATH, wantJson, 'utf-8');
writeFileSync(INSTALL_SH_PATH, wantInstallSh, 'utf-8');
console.log(`Wrote config/clis.stock.json and install.sh's catalogue block (${STOCK_CLIS.length} entries).`);
}
}
if (isMainModule()) main();
+80
View File
@@ -0,0 +1,80 @@
/**
* @fileoverview Pins the two generated CLI-catalogue artifacts against a fresh generation.
*
* `config/clis.stock.json` and the marked block inside `install.sh` are both derived from
* `src/config/cli-registry/stock.ts`. Generated files that are committed rot the moment
* someone edits the source and forgets the generator, and the failure is silent in the worst
* possible way: the installer keeps detecting the OLD set of CLIs while the server offers the
* new one. Same class as the drift this whole change exists to remove, just moved one level
* out.
*
* ⚠️ The renderers are imported from the generator, which means the generator's `main()` must
* stay behind its `isMainModule()` guard. Without it, importing this module would rewrite the
* artifacts as a side effect of checking them — the test would pass unconditionally and
* guard nothing.
*
* Port: none (pure, over two files and the registry).
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { renderCatalogJson, renderInstallShBlock, spliceInstallShBlock } from '../scripts/generate-cli-catalog.mts';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
const REGENERATE = 'Run `npm run generate:cli-catalog` and commit the result.';
const jsonPath = fileURLToPath(new URL('../config/clis.stock.json', import.meta.url));
const installShPath = fileURLToPath(new URL('../install.sh', import.meta.url));
describe('generated CLI catalogue artifacts', () => {
it('config/clis.stock.json matches a fresh generation', () => {
expect(readFileSync(jsonPath, 'utf-8'), `config/clis.stock.json is stale. ${REGENERATE}`).toBe(renderCatalogJson());
});
it("install.sh's generated block matches a fresh generation", () => {
const current = readFileSync(installShPath, 'utf-8');
expect(current, `install.sh's catalogue block is stale. ${REGENERATE}`).toBe(
spliceInstallShBlock(current, renderInstallShBlock())
);
});
it('exports every stock CLI, carrying the enabled flag', () => {
const exported = JSON.parse(readFileSync(jsonPath, 'utf-8')) as Array<{ id: string; enabled: boolean }>;
expect(exported.map((e) => e.id)).toEqual(STOCK_CLIS.map((e) => e.id as string));
// The field the previous attempt omitted, which let a disabled CLI's npm package be baked
// into every agent image. Its PRESENCE is the contract; its value is whatever stock says.
for (const entry of exported) {
expect(typeof entry.enabled, `${entry.id} has no enabled flag`).toBe('boolean');
}
});
it('exports no spawn-time fields', () => {
// launch/env/capabilities/overlays are the server's alone. Exporting them would invite a
// second reading of the launch model in a consumer that cannot be tested against a spawn.
const raw = readFileSync(jsonPath, 'utf-8');
for (const forbidden of ['"launch"', '"env"', '"capabilities"', '"overlays"']) {
expect(raw.includes(forbidden), `${forbidden} leaked into the exported catalogue`).toBe(false);
}
});
it('splices only between the markers (anti-clobber)', () => {
// The generator rewrites a window, not the file. If the splice ever widened, it would eat
// hand-written installer code on the next run and nothing else here would notice.
const current = readFileSync(installShPath, 'utf-8');
const spliced = spliceInstallShBlock(
current,
'# >>> BEGIN GENERATED CLI CATALOGUE\n# <<< END GENERATED CLI CATALOGUE'
);
expect(spliced.startsWith(current.slice(0, current.indexOf('# >>> BEGIN GENERATED CLI CATALOGUE')))).toBe(true);
expect(
spliced.endsWith(
current.slice(current.indexOf('# <<< END GENERATED CLI CATALOGUE') + '# <<< END GENERATED CLI CATALOGUE'.length)
)
).toBe(true);
});
it('refuses a file with no markers rather than appending', () => {
expect(() => spliceInstallShBlock('#!/usr/bin/env bash\necho hi\n', 'block')).toThrow(/markers/);
});
});