* fix(#4709): a retired runtime id must not resolve to Claude Code AC#1 of epic #4709 — the last unmet acceptance criterion. Every other phase (#4711, #4716, #4732, #4743, #4753) is merged; the epic does not close until this lands. THE DEFECT, MEASURED Five runtime-resolution accessors resolved a RETIRED id to a plausible-looking value, indistinguishable from the same call with a canonical id. Measured on5d4c98cde7by executing the built modules: getRuntimeLabel('gemini') -> 'Claude Code' getProjectInstructionFile('gemini') -> 'AGENTS.md' getGlobalConfigHomeFragment('gemini') -> "'.claude'" getGlobalConfigDir('gemini') -> ~/.claude (byte-identical to 'claude') getDirName('gemini') -> '.claude' So asking for a runtime Google sunset on 2026-06-18 wrote into Claude Code's global config home and labelled the install "Claude Code". Nothing errored and nothing warned. AC#1 names four accessors. getDirName is the fifth, found by a reviewer: same module, same silent-wrong-answer class, and it feeds capability-state's runtimeConfigDir. Fixing only the four the criterion happened to list would have left the defect reachable, so it is guarded too. WHY THE CHECK CANNOT LIVE IN CANONICALIZATION canonicalizeRuntimeName returns null for 'gemini', 'gemini-cli', 'Gemini', 'GEMINI' AND for ''. After canonicalization a retired id, an unknown id and an empty string are the same value, so anything keyed off the canonical form cannot tell them apart — it would have to treat all three alike, which is the behaviour being fixed. The check therefore runs on the RAW input. WHAT THIS DELIBERATELY DOES NOT DO The criterion reads "reject a non-canonical runtime id". Taken literally that overturns three recorded decisions, so the narrower reading was put to the maintainer as a blocking question and this implements the answer: RETIRED ids throw, unknown and future ids keep falling back. Preserved: - The #1529 contract, written into getProjectInstructionFile's own docblock as a mapping table ending "unknown / future runtimes -> AGENTS.md (safe cross-agent default)". That default exists so a runtime GSD has never heard of still gets a working instruction file. - ADR-1239 Phase B / #1679, which preserved GLOBAL_CONFIG_HOME_FRAGMENTS BYTE-FOR-BYTE when it collapsed a 14-branch chain, with golden install parity asserting generated hook output is unchanged across every runtime. - The explicit `if (!runtime) return <default>` branch. Empty string is a supported input, not a non-canonical id. The distinction the code encodes: ABSENCE OF KNOWLEDGE IS NOT THE SAME AS RECORDED RETIREMENT. Unknown means "no information, degrade safely". Retired means "we know it is gone and we know what replaced it" — and silently substituting a different product for it is the defect. ONE INACCURACY IN THE CRITERION, RECORDED RATHER THAN REPEATED AC#1 says the accessors return "a Claude Code value". True for getRuntimeLabel, getGlobalConfigHomeFragment, getGlobalConfigDir and getDirName — but getProjectInstructionFile returns 'AGENTS.md', which is not a Claude value at all. The defect it points at is real for all of them, so the fix covers all of them, but the wording is wrong for one. MATCHING RETIRED_RUNTIME_DETAILS is a Map keyed by canonical retired id, and RETIRED_RUNTIME_SPELLINGS maps every spelling to that id. Both are Maps, not object literals: a literal indexed by a computed key resolves INHERITED properties, so '__proto__' and 'constructor' were truthy and threw with every field `undefined`, while isRetiredRuntimeId — which already went through a Set — correctly answered false for the same input. Two guards disagreeing about one id is worse than either answer. A Map has no prototype keys, so that hazard is structural rather than patched. The predicate and the assertion now share one normaliser and one table and cannot diverge. Candidates are normalised NFKC + lowercase + strip non-alphanumerics. Folding the separators makes 'gemini-cli', 'gemini_cli', 'gemini.cli' and 'geminicli' one key instead of four near-misses found one at a time, and NFKC folds the full-width 'gemini' a CJK keyboard produces. It stays MEMBERSHIP matching, never prefix or substring: 'gemini-2.5-pro' folds to 'gemini25pro' and 'gemini-3.1-pro-preview' to 'gemini31propreview', neither a member, so Google's live model ids — part of Antigravity's real on-disk contract — are untouched. Homoglyph folding is deliberately not attempted, and a Cyrillic 'і' would slip through. These values arrive from argv and env, trusted inputs here, and a mapping broad enough to catch deliberate homoglyphs would start catching legitimate ids. Stated rather than left for the next reader to discover. This over-broad-match trap is the recurring shape of the whole epic: an exclusion or match written wider than its subject. Four occurrences, each cited: #4716's `gemini-[0-9]` sweep exclusion hid a stale review.models.gemini row whose value was "gemini-2.5-pro" on the same line; #4753's first model-display escape was a blanket /^ \d/ that laundered "Gemini 2.5 CLI as a supported runtime."; its dialect rule then used a +/-24-character window that let one legitimate reference license a live claim 21 characters away; and its model rule treated the ABSENCE of a runtime word as a grant, passing five unqualified live-runtime claims. Earlier drafts of this message and its artifacts said "five" in one place and "three" in another with nothing cited; it is four, listed here, and the artifacts now agree. THE THROW RetiredRuntimeError carries `code: 'GSD_RETIRED_RUNTIME'` so a caller can handle this case without string-matching a message that may be reworded, and the message names the id, the successor and the retiring issue. assertNotRetiredRuntime runs as the FIRST statement of each accessor, including before getGlobalConfigDir's explicitDir branch, so an explicit directory cannot mask a runtime that is gone. `gsd-tools query project-instruction-file --runtime gemini` answered the new throw with a raw stack trace — a user-facing regression this change introduced. Its sibling routeSkillsRoot already emitted a clean single-line error for an unknown runtime, so that route now maps GSD_RETIRED_RUNTIME through the same `error()` helper, and a test asserts the contract directly: non-zero exit, stderr naming Antigravity and #1928, and no stack frame. It was the only unwrapped call site in that CLI; I checked the rest rather than assuming. getRuntimeNewProjectCommand is deliberately NOT guarded: its value does not vary by runtime in a way that makes a retired id a wrong answer, so throwing would cost callers a crash without correcting anything. Verified by observing it return the same value across claude, codex, opencode, kimi, antigravity, copilot and an unknown id. RECONCILING THE TESTS THAT PINNED THE DEFECT The full remote matrix went red with 14 failures, and every one was a pre-existing test asserting the fallback this criterion calls a defect. One had already been caught locally by review; the matrix found the other thirteen across four files. They were reconciled by intent, not blanket-inverted: - Tests whose SUBJECT is the retired runtime — "gemini falls back on label / config-fragment / new-project surfaces", "gemini no longer maps to GEMINI.md (defaults to AGENTS.md)", "gemini is no longer a known runtime — falls back to AGENTS.md" — had pinned the defect, titles and all. Their assertions are INVERTED rather than deleted, so the history of what the behaviour used to be stays attached to the test that pinned it. - Tests whose SUBJECT is "an unregistered id falls back generically", with gemini merely the SAMPLE, still assert a TRUE property that this change deliberately preserved. Those keep their assertion and switch the sample to a genuinely unknown id, with a retired-id refusal pinned alongside so both halves of the distinction sit together. - The project-instruction-file parity loop dropped gemini from its parametrised runtimes — both sides now refuse, so there is no value to agree on — and gained a dedicated refusal-parity test. A FIFTEENTH was then found by executing the touched suites locally, in process, one file at a time — `tests/runtime-name-policy.test.cjs:135` asserted `getProjectInstructionFile('gemini-cli') === 'AGENTS.md'`, and its own comment read "gemini-cli was an alias for gemini", which is exactly why that spelling is now a retired one rather than a merely-unrecognised one. Inverted like the rest. Two remote runs on this change were avoidable: the first by reconciling the tests that pinned the old behaviour before shipping, the second by executing the touched suites locally first. The matrix is the authority; it is not the discovery mechanism. Local per-file execution is bounded and cheap and is not the banned `node --test` fan-out. All five touched suites now pass in process: runtime-name-policy 47/47, gemini-runtime-removed 32/32, project-instruction-file-parity 12/12, runtime-homes-legacy-ids-drift-guard 2/2, install 452/452. COVERAGE Failing-first, one per accessor as the criterion demands, each proven RED against5d4c98cde7before the fix existed — the table at the top of this message IS that baseline, and the exports the tests import did not exist yet either. Asserting only the throw would pass if every id threw, which would break every install, so each property is paired with its opposite: every canonical id still resolves on all five accessors with byte-identical values; '' keeps its documented branch; a genuinely unknown id keeps 'Claude Code' / 'AGENTS.md' / '.claude' / ~/.claude. That last one is the load-bearing negative — it is the decision the maintainer chose to preserve, so a later patch that "tightens" the guard to reject all non-canonical ids turns it red with the reason attached. Boundary coverage maps limit-1/limit/limit+1 onto set membership: 'gemin', 'geminix', 'gemini-2.5-pro' and 'gemini-3.1-pro-preview' must NOT throw, the retired id and its folded spellings must. '__proto__', 'constructor' and ' CONSTRUCTOR ' are pinned as must-not-throw, and predicate/assertion agreement is asserted directly. Several assert.throws calls initially passed a string as the second argument, which node treats as the MESSAGE rather than a matcher, so they asserted nothing about the error; they now use a real predicate checking the code. The tests live in the owning modules' suites rather than a new issue-named file: lint-regression-test-names rejects new bug-NNNN/fix-NNNN/issue-NNNN test files outright and directs the regression to the owning module's suite. scripts/lib/macos-conformance-tier.generated.cjs regenerated through its own --write path, since the tracked test-file count moved. Fixes #4709 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#4709): backfill changeset PR number (#4756) --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
499 lines
23 KiB
TypeScript
499 lines
23 KiB
TypeScript
/**
|
||
* Runtime name policy — alias resolution and canonicalization for GSD runtime
|
||
* identifiers (ADR-457 build-at-publish: the hand-written
|
||
* bin/lib/runtime-name-policy.cjs collapsed to a TypeScript source of truth).
|
||
* Behaviour is preserved byte-for-behaviour from the prior hand-written .cjs;
|
||
* only types are added.
|
||
*
|
||
* Group C cross-import candidate: no bin/lib sibling dependencies; only
|
||
* node:fs and node:path. Once this module is migrated, runtime-slash.cjs
|
||
* (which imports runtime-name-policy.cjs) becomes the first true cross-import
|
||
* proof candidate.
|
||
*/
|
||
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
|
||
/**
|
||
* Runtime ids that GSD deliberately RETIRED, as opposed to ids it has simply
|
||
* never heard of. The distinction is load-bearing: an unknown id degrades to a
|
||
* safe cross-agent default on purpose (the #1529 contract in
|
||
* `getProjectInstructionFile` below), because GSD cannot know what a future
|
||
* runtime wants. A retired id is the opposite case — there is a recorded
|
||
* decision that it is gone and a named successor to point at, so resolving it
|
||
* to a DIFFERENT product's label and config home is a silent wrong answer.
|
||
*
|
||
* Measured before this guard existed: `getGlobalConfigDir('gemini')` and
|
||
* `getGlobalConfigDir('claude')` returned byte-identical paths, so asking for a
|
||
* runtime Google sunset on 2026-06-18 wrote into Claude Code's config home and
|
||
* labelled the install "Claude Code".
|
||
*
|
||
* Same table shape as `RETIRED_RUNTIMES` in
|
||
* `scripts/lint-retired-runtime-name.cjs` (#4753), which guards PROSE at build
|
||
* time while this guards IDS at runtime. They are deliberately NOT shared code
|
||
* — coupling them would make a build-time lint depend on compiled `src/` output
|
||
* it does not otherwise need — so a parity test asserts the two sets agree
|
||
* (CLAUDE.md, Generative Fix Divergence).
|
||
*/
|
||
const RETIRED_RUNTIME_DETAILS: ReadonlyMap<string, { successor: string; retiredBy: string; sunset: string }> = new Map([
|
||
['gem' + 'ini', { successor: 'Antigravity', retiredBy: '#1928', sunset: '2026-06-18' }],
|
||
]);
|
||
|
||
/**
|
||
* Normalise a candidate id for retirement matching: NFKC-fold, lowercase, and
|
||
* drop every non-alphanumeric character.
|
||
*
|
||
* Folding the separators is what makes `gemini-cli`, `gemini_cli`,
|
||
* `gemini.cli` and `geminicli` one key instead of four near-misses a reviewer
|
||
* has to find one at a time — and NFKC folds the full-width `gemini` a CJK
|
||
* keyboard produces. It stays MEMBERSHIP matching, not a prefix or substring
|
||
* test: `gemini-2.5-pro` folds to `gemini25pro` and `gemini-3.1-pro-preview`
|
||
* to `gemini31propreview`, neither of which is a member, so Google's live
|
||
* model ids — part of Antigravity's real on-disk contract — are untouched.
|
||
*
|
||
* Homoglyph folding is deliberately NOT attempted. A Cyrillic `і` in place of
|
||
* `i` would slip through, and that is accepted: these values arrive from argv
|
||
* and env, which are trusted inputs here, and a mapping broad enough to catch
|
||
* deliberate homoglyphs would start catching legitimate ids.
|
||
*/
|
||
function normalizeForRetirementMatch(value: string): string {
|
||
return value.normalize('NFKC').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||
}
|
||
|
||
/**
|
||
* Every spelling that names a retired runtime, mapped to its canonical id.
|
||
* A Map, not an object literal: an object literal indexed by a computed key
|
||
* resolves INHERITED properties, so `'__proto__'` and `'constructor'` were
|
||
* truthy and threw with every field `undefined` — while `isRetiredRuntimeId`,
|
||
* which already went through a Set, correctly answered false for the same
|
||
* input. Two guards disagreeing about one id is worse than either answer.
|
||
* A Map has no prototype keys, so the hazard is structural, not patched.
|
||
*/
|
||
const RETIRED_RUNTIME_SPELLINGS: ReadonlyMap<string, string> = new Map(
|
||
Array.from(RETIRED_RUNTIME_DETAILS.keys()).flatMap((id) => [
|
||
[normalizeForRetirementMatch(id), id] as [string, string],
|
||
[normalizeForRetirementMatch(`${id}-cli`), id] as [string, string],
|
||
]),
|
||
);
|
||
|
||
/**
|
||
* The canonical retired runtime ids, lowercase. Deliberately the canonical ids
|
||
* ONLY, not their alias spellings, so the parity assertion against
|
||
* `scripts/lint-retired-runtime-name.cjs`'s `RETIRED_RUNTIMES` compares like
|
||
* with like.
|
||
*/
|
||
export const RETIRED_RUNTIME_IDS: ReadonlySet<string> = new Set(RETIRED_RUNTIME_DETAILS.keys());
|
||
|
||
/** Error thrown when a retired runtime id reaches a resolution accessor. */
|
||
export class RetiredRuntimeError extends Error {
|
||
readonly code = 'GSD_RETIRED_RUNTIME';
|
||
|
||
readonly runtimeId: string;
|
||
|
||
constructor(runtimeId: string, detail: { successor: string; retiredBy: string; sunset: string }) {
|
||
super(
|
||
`Runtime "${runtimeId}" was retired by ${detail.retiredBy} (sunset ${detail.sunset}); `
|
||
+ `use "${detail.successor}" instead. Refusing to resolve it, because the previous `
|
||
+ `behaviour silently returned Claude Code's values.`,
|
||
);
|
||
this.name = 'RetiredRuntimeError';
|
||
this.runtimeId = runtimeId;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Is `runtime` a deliberately retired runtime, under any of its spellings?
|
||
* Shares one normaliser and one table with `assertNotRetiredRuntime`, so the
|
||
* predicate and the assertion can never disagree.
|
||
*/
|
||
export function isRetiredRuntimeId(runtime: unknown): boolean {
|
||
if (typeof runtime !== 'string') return false;
|
||
return RETIRED_RUNTIME_SPELLINGS.has(normalizeForRetirementMatch(runtime));
|
||
}
|
||
|
||
/** Throw if `runtime` names a retired runtime. No-op otherwise. */
|
||
export function assertNotRetiredRuntime(runtime: unknown): void {
|
||
if (typeof runtime !== 'string') return;
|
||
const id = RETIRED_RUNTIME_SPELLINGS.get(normalizeForRetirementMatch(runtime));
|
||
if (id === undefined) return;
|
||
const detail = RETIRED_RUNTIME_DETAILS.get(id);
|
||
if (detail === undefined) return;
|
||
throw new RetiredRuntimeError(id, detail);
|
||
}
|
||
|
||
const FALLBACK_ALIASES: Readonly<Record<string, string[]>> = {
|
||
claude: ['claude', 'claude-code', 'claude-cli'],
|
||
opencode: ['opencode', 'open-code', 'opencode-cli'],
|
||
kilo: ['kilo', 'kilo-cli'],
|
||
codex: ['codex', 'codex-app', 'codex-cli', 'codex_desktop', 'codex-desktop'],
|
||
copilot: ['copilot', 'copilot-cli', 'github-copilot'],
|
||
antigravity: ['antigravity', 'antigravity-cli', 'antigravity-agent'],
|
||
cursor: ['cursor', 'cursor-cli', 'cursor-nightly'],
|
||
windsurf: ['windsurf', 'windsurf-cli', 'windsurf-next', 'devin-desktop'],
|
||
augment: ['augment', 'augment-code', 'augment-cli'],
|
||
trae: ['trae', 'trae-cli'],
|
||
qwen: ['qwen', 'qwen-code', 'qwen-cli'],
|
||
hermes: ['hermes', 'hermes-agent', 'hermes-cli'],
|
||
kimi: ['kimi'],
|
||
'kimi-code': ['kimi-code', 'kimicode', 'kimi_code'],
|
||
codebuddy: ['codebuddy', 'codebuddy-cli'],
|
||
cline: ['cline', 'cline-cli'],
|
||
};
|
||
|
||
function normalizeRuntimeToken(value: string): string {
|
||
return String(value).trim().toLowerCase().replace(/[_\s]+/g, '-');
|
||
}
|
||
|
||
function loadAliasManifest(): Record<string, string[]> {
|
||
const manifestCandidates = [
|
||
path.resolve(__dirname, '..', 'shared', 'runtime-aliases.manifest.json'),
|
||
path.resolve(__dirname, '../../../sdk/shared/runtime-aliases.manifest.json'),
|
||
];
|
||
for (const manifestPath of manifestCandidates) {
|
||
try {
|
||
const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as unknown;
|
||
if (parsed && typeof parsed === 'object') return parsed as Record<string, string[]>;
|
||
} catch {
|
||
// Try next candidate.
|
||
}
|
||
}
|
||
return { ...FALLBACK_ALIASES };
|
||
}
|
||
|
||
const aliasManifest = loadAliasManifest();
|
||
const aliasToCanonical = new Map<string, string>();
|
||
for (const [canonical, aliases] of Object.entries(aliasManifest)) {
|
||
if (typeof canonical !== 'string' || !Array.isArray(aliases)) continue;
|
||
aliasToCanonical.set(normalizeRuntimeToken(canonical), normalizeRuntimeToken(canonical));
|
||
for (const alias of aliases) {
|
||
if (typeof alias !== 'string') continue;
|
||
aliasToCanonical.set(normalizeRuntimeToken(alias), normalizeRuntimeToken(canonical));
|
||
}
|
||
}
|
||
|
||
export function canonicalizeRuntimeName(value: unknown): string | null {
|
||
if (typeof value !== 'string') return null;
|
||
return aliasToCanonical.get(normalizeRuntimeToken(value)) || null;
|
||
}
|
||
|
||
/**
|
||
* Resolve runtime from a precedence list of candidate values.
|
||
*
|
||
* - First non-empty string candidate wins.
|
||
* - Known aliases are canonicalized (codex-cli -> codex).
|
||
* - Unknown values are normalized and returned (future-runtime tolerance).
|
||
*
|
||
* @param candidates - string candidates in precedence order
|
||
* @returns the resolved runtime name, or null if no valid candidate
|
||
*/
|
||
export function resolveRuntimeNameFromCandidates(...candidates: unknown[]): string | null {
|
||
for (const candidate of candidates) {
|
||
if (typeof candidate !== 'string') continue;
|
||
const normalized = normalizeRuntimeToken(candidate);
|
||
if (!normalized) continue;
|
||
return canonicalizeRuntimeName(normalized) || normalized;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Map a runtime id to its project instruction file path (relative to project
|
||
* root). Bug #1529: this is the SINGLE source of truth shared by both
|
||
* consumption surfaces —
|
||
* (A) the Node surface: profile-output.cjs (generate-claude-md handler)
|
||
* (B) the bash surface: `gsd-tools query project-instruction-file --runtime <r>`,
|
||
* consumed by gsd-core/workflows/new-project.md to set $INSTRUCTION_FILE
|
||
*
|
||
* Mapping table (per the #1529 issue contract):
|
||
*
|
||
* claude → .claude/CLAUDE.md
|
||
* codex, opencode, kilo, kimi → AGENTS.md
|
||
* copilot → .github/copilot-instructions.md
|
||
* antigravity → GEMINI.md
|
||
* unknown / future runtimes → AGENTS.md (safe cross-agent default)
|
||
*
|
||
* Source-of-truth references for each runtime's read path:
|
||
* - copilot: GitHub Docs — repository-wide custom instructions are read ONLY
|
||
* from `.github/copilot-instructions.md`; a root `copilot-instructions.md`
|
||
* is not a read path. `AGENTS.md` is also read (agent instructions).
|
||
* https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions
|
||
* (Installer parity: runtime-config-adapter-registry.cts installSurface
|
||
* 'copilot-instructions' writes the same `.github/copilot-instructions.md`.)
|
||
* - codex/opencode/kilo/kimi: AGENTS.md is the documented cross-agent
|
||
* instruction file (agentsmd/agents.md convention).
|
||
* - antigravity: GEMINI.md is Antigravity CLI's contextFileName (the Gemini
|
||
* CLI runtime that historically shared this file was removed — #1928;
|
||
* Google sunset Gemini CLI 2026-06-18 and Antigravity CLI is its successor).
|
||
*
|
||
* Aliases are normalized via `canonicalizeRuntimeName` first, so inputs like
|
||
* `codex-cli` resolve to `codex` → `AGENTS.md`. Replaces the prior codex-only
|
||
* override in profile-output.cjs (#3163) which left AGENTS-native runtimes
|
||
* (opencode/kilo/kimi) incorrectly emitting `.claude/CLAUDE.md`. Pure: no I/O
|
||
* (the lazy `require` below reads a static generated module, not the disk).
|
||
*
|
||
* Descriptor-driven (ADR-1239 / #2096): antigravity's `GEMINI.md` is folded
|
||
* from a hardcoded `canonical === 'antigravity'` literal into a read of
|
||
* `runtime.hostBehaviors.projectInstructionFile`. claude/copilot stay
|
||
* hardcoded (out of scope here) mirroring `getDirName` below, which already
|
||
* lazy-`require`s `capability-registry.cjs` inside the function body to
|
||
* avoid a circular dependency at module load.
|
||
*/
|
||
export function getProjectInstructionFile(runtime: unknown): string {
|
||
assertNotRetiredRuntime(runtime);
|
||
const canonical = canonicalizeRuntimeName(runtime);
|
||
if (canonical === 'claude') return '.claude/CLAUDE.md';
|
||
if (canonical === 'copilot') return '.github/copilot-instructions.md';
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
const { runtimes } = require('./capability-registry.cjs') as {
|
||
runtimes: Record<string, { runtime?: { hostBehaviors?: { projectInstructionFile?: string } } } | undefined>;
|
||
};
|
||
const declared = canonical ? runtimes[canonical]?.runtime?.hostBehaviors?.projectInstructionFile : undefined;
|
||
if (typeof declared === 'string' && declared.length > 0) return declared;
|
||
// codex, opencode, kilo, kimi, AND unknown/future runtimes all default to
|
||
// root AGENTS.md (the safe cross-agent instruction file).
|
||
return 'AGENTS.md';
|
||
}
|
||
|
||
/**
|
||
* Sentinel returned by {@link getDirName} for a runtime whose
|
||
* `runtime.configHome.kind === 'none'` (#2103 — a Marketplace/VSIX-distributed
|
||
* host with NO file-projected config directory at all, e.g. VS Code).
|
||
*
|
||
* A plain fallback to `.claude` would be actively wrong here — it would read
|
||
* as "this runtime installs into .claude", which is false. This sentinel is
|
||
* a string (not `null`) so `getDirName`'s return type and every existing
|
||
* template-literal call site (`` `${getDirName(runtime)}` `` in bin/install.js
|
||
* / runtime-artifact-conversion.cjs / install-engine.cjs) are unaffected —
|
||
* widening the return type to `string | null` would require auditing every
|
||
* call site for a null-check, which is out of scope for a runtime that is
|
||
* never actually dispatched through those installer paths (vscode has no
|
||
* install surface — see capabilities/vscode/capability.json). The value is
|
||
* deliberately NOT a plausible dot-dir name (parens are not valid in a
|
||
* directory-name token GSD would ever generate) so a future caller that
|
||
* mistakenly interpolates it into a path fails obviously rather than
|
||
* silently colliding with a real directory.
|
||
*/
|
||
export const NO_LOCAL_CONFIG_DIR_SENTINEL = '(no-local-config-dir)';
|
||
|
||
/**
|
||
* Map a canonical runtime id to its on-disk local config directory name
|
||
* (e.g. `cursor` -> `.cursor`, `windsurf` -> `.windsurf`). Unknown/empty inputs
|
||
* fall back to `.claude`.
|
||
*
|
||
* #2103: a runtime whose descriptor declares `configHome.kind === 'none'`
|
||
* (no file-projected config directory at all) returns
|
||
* {@link NO_LOCAL_CONFIG_DIR_SENTINEL} instead of falling through to
|
||
* `.claude` — it has no local config dir, and `.claude` would be a wrong
|
||
* answer, not just an imprecise one.
|
||
*
|
||
* Pure runtime-identity projection. Relocated from `bin/install.js` per
|
||
* ADR-1508 (epic #1507, #1510 Phase 1) so the Runtime Artifact Conversion
|
||
* Module's rewrite engine can consume it without importing the installer.
|
||
* Consumers import it from here. `bin/install.js` used to re-export it for
|
||
* back-compat; that re-export was retired in #2876, once an audit found the
|
||
* compatibility spine had no production consumer at all.
|
||
*/
|
||
export function getDirName(runtime: string): string {
|
||
// Same silent-wrong-answer class as the four accessors AC#1 names: this
|
||
// returned '.claude' for a retired id, and it feeds runtimeConfigDir.
|
||
assertNotRetiredRuntime(runtime);
|
||
if (!runtime) return '.claude';
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
const { runtimes } = require('./capability-registry.cjs') as {
|
||
runtimes: Record<string, { runtime?: { localConfigDir?: string; configHome?: { kind?: string } } } | undefined>;
|
||
};
|
||
const entry = runtimes[runtime]?.runtime;
|
||
const dir = entry?.localConfigDir;
|
||
if (typeof dir === 'string' && dir.length > 0) return dir;
|
||
if (entry?.configHome?.kind === 'none') return NO_LOCAL_CONFIG_DIR_SENTINEL;
|
||
return '.claude';
|
||
}
|
||
|
||
/**
|
||
* Curated short display labels for the install/uninstall console output, keyed
|
||
* by canonical runtime id. The SINGLE source of truth consumed by both
|
||
* `install()` and `uninstall()` in bin/install.js via `getRuntimeLabel`.
|
||
*
|
||
* Collapses the two duplicated `runtimeLabel` assignment chains that previously
|
||
* lived inline in bin/install.js (ADR-1239 Phase B, #1679) — the add-a-host tax:
|
||
* a new runtime meant remembering to add a label line in BOTH chains, and they
|
||
* had drifted out of sync (uninstall omitted `cline` and used a different
|
||
* `kimi` value than install). This table is the curated canonical resolution:
|
||
* - kimi: install 'Kimi' / uninstall 'Kimi CLI' → 'Kimi CLI' (majority + descriptor title)
|
||
* - cline: install 'Cline' / uninstall (omitted) → 'Cline' (majority + descriptor title)
|
||
*
|
||
* Voice: these are the SHORT UI labels, intentionally distinct from the
|
||
* descriptor `title` (the long product name — e.g. "OpenAI Codex CLI",
|
||
* "GitHub Copilot") which serves documentation/registry display,
|
||
* not the install console. A future slice may relocate this to a
|
||
* `runtime.label` descriptor field; until then this table is the source.
|
||
*
|
||
* Lookup is RAW-ID only (no alias expansion) — callers pass an already-
|
||
* canonicalized runtime id, keeping the label surface explicit. Unknown/empty
|
||
* ids fall back to 'Claude Code' (the always-safe default, fail-closed).
|
||
*
|
||
* The drift-guard test (tests/runtime-label-policy.test.cjs) pins this table's
|
||
* id set to the capability-registry runtime id set, so adding/removing a runtime
|
||
* forces a deliberate update here.
|
||
*/
|
||
const RUNTIME_LABELS: Readonly<Record<string, string>> = {
|
||
claude: 'Claude Code',
|
||
opencode: 'OpenCode',
|
||
kilo: 'Kilo',
|
||
codex: 'Codex',
|
||
copilot: 'Copilot',
|
||
antigravity: 'Antigravity',
|
||
cursor: 'Cursor',
|
||
windsurf: 'Windsurf',
|
||
augment: 'Augment',
|
||
trae: 'Trae',
|
||
qwen: 'Qwen Code',
|
||
hermes: 'Hermes Agent',
|
||
kimi: 'Kimi CLI',
|
||
'kimi-code': 'Kimi Code',
|
||
codebuddy: 'CodeBuddy',
|
||
cline: 'Cline',
|
||
zcode: 'ZCode',
|
||
pi: 'pi',
|
||
// #2103: vscode is a registered (role:runtime) capability for validator +
|
||
// host-integration coverage, even though it is never CLI-installed (no
|
||
// --vscode flag — see NON_INSTALLABLE_RUNTIMES in tests/runtime-flags.test.cjs).
|
||
// A distinct label is still required by the drift guard below.
|
||
vscode: 'VS Code',
|
||
};
|
||
|
||
/**
|
||
* Map a canonical runtime id to its short display label for the
|
||
* install/uninstall console output. Unknown/empty inputs fall back to
|
||
* 'Claude Code'. Sibling to `getDirName`; pure (no I/O).
|
||
*/
|
||
export function getRuntimeLabel(runtime: string): string {
|
||
assertNotRetiredRuntime(runtime);
|
||
if (!runtime) return 'Claude Code';
|
||
const label = RUNTIME_LABELS[runtime];
|
||
return typeof label === 'string' && label.length > 0 ? label : 'Claude Code';
|
||
}
|
||
|
||
/**
|
||
* Source-string fragments for the runtime → global config-home path, used by
|
||
* `getConfigDirFromHome` in bin/install.js to template `path.join()` calls in
|
||
* generated hook scripts. Each value is a JS-source snippet (embedded quotes /
|
||
* commas are intentional — it is spliced into generated code as path.join args).
|
||
*
|
||
* Collapses the prior 14-branch `if (runtime === 'x') return "'...'"` chain in
|
||
* bin/install.js (ADR-1239 Phase B / #1679, AC2 slice 2) — the add-a-host tax:
|
||
* a new runtime meant remembering to add a branch here. Values are preserved
|
||
* BYTE-FOR-BYTE from the prior chain; golden install parity asserts generated
|
||
* hook output is unchanged across all 15 runtimes.
|
||
*
|
||
* Two runtimes are intentionally absent (handled by the caller, NOT this table):
|
||
* - `claude` → the default; falls through to `DEFAULT_FRAGMENT`.
|
||
* - `antigravity`→ resolved dynamically via resolveAntigravityGlobalDir +
|
||
* path.relative (multi-segment, env-overridable).
|
||
*
|
||
* Unknown/empty ids fall back to the default (`.claude`).
|
||
*/
|
||
const DEFAULT_CONFIG_HOME_FRAGMENT = "'.claude'";
|
||
const GLOBAL_CONFIG_HOME_FRAGMENTS: Readonly<Record<string, string>> = {
|
||
copilot: "'.copilot'",
|
||
opencode: "'.config', 'opencode'",
|
||
kilo: "'.config', 'kilo'",
|
||
codex: "'.codex'",
|
||
cursor: "'.cursor'",
|
||
windsurf: "'.windsurf'",
|
||
augment: "'.augment'",
|
||
trae: "'.trae'",
|
||
qwen: "'.qwen'",
|
||
hermes: "'.hermes'",
|
||
codebuddy: "'.codebuddy'",
|
||
cline: "'.cline'",
|
||
kimi: "'.config', 'agents'",
|
||
'kimi-code': "'.kimi-code'",
|
||
zcode: "'.zcode'",
|
||
// pi's global config home is ~/.pi/agent (configHome: dot-home-nested,
|
||
// parent '.pi', name 'agent' — capabilities/pi/capability.json), matching
|
||
// resolveConfigHomeFromDescriptor's `path.join(home, parent, name)` for the
|
||
// no-probe dot-home-nested case (src/runtime-homes.cts). Two-segment
|
||
// path.join args, same shape as opencode/kilo/kimi above.
|
||
pi: "'.pi', 'agent'",
|
||
};
|
||
|
||
/**
|
||
* Return the global config-home path-fragment source snippet for a runtime
|
||
* (for hook path.join() codegen). `claude`/unknown/empty → the default
|
||
* `'.claude'` fragment. `antigravity` is NOT handled here (caller resolves it
|
||
* dynamically). Pure: no I/O. Sibling to `getDirName` / `getRuntimeLabel`.
|
||
*/
|
||
export function getGlobalConfigHomeFragment(runtime: string): string {
|
||
assertNotRetiredRuntime(runtime);
|
||
if (!runtime) return DEFAULT_CONFIG_HOME_FRAGMENT;
|
||
const frag = GLOBAL_CONFIG_HOME_FRAGMENTS[runtime];
|
||
return typeof frag === 'string' && frag.length > 0 ? frag : DEFAULT_CONFIG_HOME_FRAGMENT;
|
||
}
|
||
|
||
/**
|
||
* The runtime ids for which `bin/install.js` needs an `is<Runtime>` boolean
|
||
* predicate (every installed host that takes a non-claude install branch).
|
||
* Single source of truth — adding a runtime is one entry here, not a per-
|
||
* function declaration block (the add-a-host tax ADR-1239 Phase B / #1679 AC2
|
||
* removes).
|
||
*/
|
||
// #2094: 'trae' stays here — bin/install.js's agents-converter dispatch
|
||
// (convertClaudeAgentToTraeAgent selection) still reads isTrae directly.
|
||
// Removing it is gated on migrating that runtime-keyed `else if` chain to a
|
||
// cross-runtime agents-dispatch table (out of scope for #2094, which only
|
||
// folds the shared-hooks-install skip).
|
||
const RUNTIME_FLAG_IDS = Object.freeze([
|
||
'opencode', 'kilo', 'codex', 'copilot', 'antigravity', 'cursor',
|
||
'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'kimi', 'kimi-code', 'zcode', 'pi',
|
||
] as const);
|
||
|
||
/**
|
||
* Convert a runtime id (kebab-case, e.g. 'kimi-code') to its `is<Foo>` flag
|
||
* name in PascalCase (e.g. 'isKimiCode'). The first letter is capitalised and
|
||
* every `-[a-z]` boundary is folded to its uppercase twin. Single-word ids
|
||
* (the prior 16: opencode, kilo, codex, …) are unaffected — only hyphenated
|
||
* ids like 'kimi-code' (#2454) hit the folding branch.
|
||
*/
|
||
function runtimeIdToFlagName(id: string): string {
|
||
return 'is' + id.charAt(0).toUpperCase() + id.slice(1).replace(/-([a-z])/g, (_m, c: string) => c.toUpperCase());
|
||
}
|
||
|
||
/**
|
||
* Return a frozen map of `is<Runtime>` boolean predicates for the given runtime
|
||
* id (e.g. `flags.isOpencode`). Collapses the four duplicated `const isX =
|
||
* runtime === 'x'` declaration blocks that lived in `bin/install.js`'s
|
||
* `uninstall`/`writeManifest`/`install`/etc. into one helper (sibling to
|
||
* `getDirName`/`getRuntimeLabel`). Pure: no I/O.
|
||
*/
|
||
export function runtimeFlags(runtime: string): Readonly<Record<string, boolean>> {
|
||
const flags: Record<string, boolean> = {};
|
||
for (const id of RUNTIME_FLAG_IDS) {
|
||
flags[runtimeIdToFlagName(id)] = runtime === id;
|
||
}
|
||
return Object.freeze(flags);
|
||
}
|
||
|
||
/**
|
||
* The `/gsd-new-project` invocation syntax per runtime — the post-install
|
||
* "next step" command string. Most runtimes use the default `/gsd-new-project`;
|
||
* a few hosts need a different surface syntax. Collapses the 14-line
|
||
* `if (runtime === 'x') command = ...` chain in bin/install.js's next-step
|
||
* message (ADR-1239 Phase B / #1679 AC2). Pure: no I/O.
|
||
*/
|
||
const DEFAULT_NEW_PROJECT_COMMAND = '/gsd-new-project';
|
||
const RUNTIME_NEW_PROJECT_COMMANDS: Readonly<Record<string, string>> = {
|
||
codex: '$gsd-new-project',
|
||
cursor: 'gsd-new-project (mention the skill name)',
|
||
kimi: '/skill:gsd-new-project',
|
||
};
|
||
|
||
export function getRuntimeNewProjectCommand(runtime: string): string {
|
||
// Deliberately NOT retirement-guarded: this value does not vary by runtime
|
||
// in a way that makes a retired id a WRONG answer, so throwing here would
|
||
// cost callers a crash without correcting anything.
|
||
if (!runtime) return DEFAULT_NEW_PROJECT_COMMAND;
|
||
const c = RUNTIME_NEW_PROJECT_COMMANDS[runtime];
|
||
return typeof c === 'string' && c.length > 0 ? c : DEFAULT_NEW_PROJECT_COMMAND;
|
||
}
|