Files
msd-core/src/runtime-slash.cts
Tom Boucher 96a82bbffb enhance(#3245): report the detected host runtime in init (#3307)
* test(#3245): failing-first coverage for host runtime detection in init

Locks the behavior epic #2313 Phase 5 must produce before any of it exists: init reports the detected host, explicit GSD_RUNTIME and config runtime still outrank detection, non-Codex sessions are untouched, and nothing is ever written to shared defaults (#2297).

* enhance(#3245): report the detected host runtime in init

init reported agent_runtime: claude inside a Codex session, and resolved agents_dir to the Claude agents root with agents_installed: true — a spuriously healthy triple. Runtime identity was only ever read from GSD_RUNTIME or an explicit runtime in .planning/config.json.

Adds a detection rung beneath both explicit sources, in a new pure module. Codex is identified from its own documented session environment (CODEX_SANDBOX / CODEX_SANDBOX_NETWORK_DISABLED), else an explicitly exported CODEX_HOME whose config.toml exists. The default ~/.codex is never probed: that file exists on every machine that has run Codex, so probing it would misreport other runtimes' sessions.

resolveRuntime keeps its exact contract and all 71 dependents, including formatGsdSlash command-style emission; only withProjectRoot consumes the new rung. Nothing is written on any path (#2297). Explicit config still wins (#2517).

* fix(#3245): make the parity guard real and single-source the marker

Four independent review passes found the generative-fix-divergence guard was vacuous: it asserted agreement at the one input where inferPreferredRuntime and detectHostRuntime do not differ, so it could not fail. It now pins the actual divergence point (CODEX_HOME set, config.toml absent) and records that the asymmetry is deliberate.

The config.toml marker is now single-sourced from update-context.cts and imported, rather than carried independently by two surfaces. tests/helpers.cjs now scrubs CODEX_SANDBOX and CODEX_SANDBOX_NETWORK_DISABLED: GSD reads them, so an ambient Codex session would otherwise make the non-codex control test fail non-deterministically.

Also: detection is throw-safe end to end rather than only around the fs probe; the Windows-join test is replaced with one that can actually fail (trailing-separator, catches hand-rolled concatenation); the #2297 no-write proof now wraps resolveReportedRuntime, the function that ships, across all three ladder outcomes.

* chore(#3245): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
2026-08-10 09:48:26 -04:00

143 lines
6.6 KiB
TypeScript

/**
* runtime-slash.cts — single source of truth for emitting GSD slash-command
* references in user-facing runtime output (recommended-actions JSON, persisted
* ROADMAP.md entries, verify/validate fix hints, error messages, etc.).
*
* ADR-457 build-at-publish: the hand-written bin/lib/runtime-slash.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour from
* the prior hand-written .cjs; only types are added.
*
* Background: #2808 unified all GSD skill installs to register under the hyphen
* form (`name: gsd-<cmd>`). The legacy colon form `/gsd:<cmd>` is no longer
* routable by Claude Code skill installs, but ~50 runtime emissions in
* bin/lib/*.cjs still hardcoded it (#3584). Codex installs need the shell-var
* `$gsd-<cmd>` form. This module is the only place the runtime should decide
* which shape to emit.
*
* - codex: $gsd-<cmd> (shell-var syntax)
* - claude, cursor, opencode, kilo, etc.: /gsd-<cmd>
*
* The colon form is never emitted.
*
* Cross-import proof candidate (ADR-457): this is the first TS source that
* imports a sibling TS-migrated module. The import specifier uses the .cjs
* extension per nodenext convention; tsc resolves it to src/runtime-name-policy.cts.
*/
import fs from 'node:fs';
import path from 'node:path';
import { canonicalizeRuntimeName, resolveRuntimeNameFromCandidates } from './runtime-name-policy.cjs';
export function formatGsdSlash(commandName: unknown, runtime: unknown): unknown {
if (typeof commandName !== 'string') return commandName;
if (commandName === '') return commandName;
// Strip any existing leading prefix so the helper is idempotent and accepts
// both legacy `/gsd:<name>` and canonical hyphen-form input (plus the bare
// `gsd:<name>` shorthand and codex `$gsd-<name>` shell-var input).
const stripped = commandName.replace(/^[/$]?gsd[-:]/i, '');
// If the regex matched nothing (no prefix), the input is already a bare name.
const bare = stripped === commandName ? commandName : stripped;
// Defensive: a degenerate input like `/gsd:`, `gsd-`, or whitespace-only
// normalizes to empty. Returning the original colon-form would re-emit the
// deprecated shape that this module exists to suppress (#3584). Return an
// empty string so callers see "no command" rather than the broken input.
if (bare === '' || bare.trim() === '') return '';
// Split on the first whitespace so only the command token is rewritten —
// anything after the first space is caller-supplied arguments (phase
// numbers, --flags, --paths C:\\Users\\Me, etc.) that must round-trip
// untouched. Codex lowercases only the command token; preserving the
// argument tail prevents path/flag corruption on case-sensitive systems.
const wsMatch = bare.match(/^(\S+)(\s[\s\S]*)?$/);
const token = wsMatch ? wsMatch[1] : bare;
const tail = wsMatch && wsMatch[2] ? wsMatch[2] : '';
const runtimeText = (typeof runtime === 'string' && runtime ? runtime : 'claude').toLowerCase();
const rt = canonicalizeRuntimeName(runtimeText) || runtimeText;
// Descriptor-driven: look up commandStyle from the capability registry.
// Mirrors the lazy-require pattern from runtime-homes.cts §getGlobalConfigDir.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { runtimes } = require('./capability-registry.cjs') as {
runtimes: Record<string, { runtime?: { commandStyle?: string } }>;
};
const style = runtimes[rt]?.runtime?.commandStyle;
if (style === 'shell-var') {
// shell-var runtimes (currently: codex) use $gsd-<cmd> syntax. The command
// token is lowercased because shell-var identifiers are conventionally
// lowercase; matches the convertCodexSlash() projection in bin/install.js.
return `$gsd-${token.toLowerCase()}${tail}`;
}
return `/gsd-${token}${tail}`;
}
/**
* Resolve the explicit runtime for a project directory, from the two
* explicit sources only — no default is applied.
*
* env.GSD_RUNTIME > config.runtime > null
*
* Returns null when neither explicit source is set, so a caller can
* distinguish "config said claude" from "nothing was set" (needed by
* #3245's host-detection rung, which must only run when this returns null).
*
* @param projectDir - path to the project directory, or null/undefined
* @param env - environment variables to read GSD_RUNTIME from; defaults to process.env
* @returns the resolved runtime name, or null if neither source is set
*/
export function resolveExplicitRuntime(
projectDir: string | null | undefined,
env: Record<string, string | undefined> = process.env,
): string | null {
const envRuntime = resolveRuntimeNameFromCandidates(env['GSD_RUNTIME']);
if (envRuntime) return envRuntime;
if (projectDir) {
try {
// Read config.json directly (not via loadConfig). loadConfig has a side
// effect of normalizing and re-writing legacy keys back to disk, which
// would mutate the project file just to read the runtime name. We only
// need the literal `runtime:` value, so a plain JSON read is sufficient
// and side-effect-free.
const configPath = path.join(projectDir, '.planning', 'config.json');
if (fs.existsSync(configPath)) {
const raw = fs.readFileSync(configPath, 'utf-8');
const parsed: unknown = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && 'runtime' in parsed) {
const configRuntime = resolveRuntimeNameFromCandidates((parsed as Record<string, unknown>)['runtime']);
if (configRuntime) return configRuntime;
}
}
} catch {
// Fall through to default — a missing/broken config must not crash
// runtime output formatting.
}
}
return null;
}
/**
* Resolve the effective runtime for a project directory.
*
* process.env.GSD_RUNTIME > config.runtime > 'claude'
*
* Mirrors the precedence already used by profile-output.cjs and the rest of
* the runtime resolution chain. Returns a lowercased string so downstream
* comparisons can be case-blind.
*
* @param projectDir - path to the project directory, or null/undefined
* @returns the resolved runtime name
*/
export function resolveRuntime(projectDir: string | null | undefined): string {
return resolveExplicitRuntime(projectDir) ?? 'claude';
}
/**
* Convenience: format using the runtime resolved from a project directory.
* Equivalent to `formatGsdSlash(name, resolveRuntime(projectDir))`.
*/
export function formatGsdSlashFor(projectDir: string | null | undefined, commandName: unknown): unknown {
return formatGsdSlash(commandName, resolveRuntime(projectDir));
}