* feat(#2454): add kimi-code as an EoS capability (Node Kimi Code CLI) PR 1 of N for #2454. Establishes the EoS descriptor foundation for splitting GSD's kimi support into two distinct products per the user's directive: - kimi (existing): Moonshot's Python kimi-cli (~/.kimi, runtime: python) - kimi-code (new): Moonshot's Node Kimi Code CLI (~/.kimi-code, runtime: node, KIMI_CODE_HOME env) Per ADR-1239 EoS, runtime behavior is driven by capabilities/<id>/capability.json descriptors, not hardcoded branches in install.js. The new descriptor uses the existing primitives (dot-home configHome, skills artifactLayout, kimi-hooks-toml hooksSurface — same TOML [[hooks]] format Kimi Code reads per its docs). Critical Kimi Code constraint reflected in the descriptor: hostIntegration.dispatch.namedDispatch: false hostIntegration.dispatch.builtInSubagents: ['coder', 'explore', 'plan'] hostBehaviors.namedSubagentsSupported: false Kimi Code's official docs confirm only 3 built-in subagents with NO custom- subagent registration (the [subagent] table only has timeout_ms). The kimi-agents YAML layout (used by Python kimi-cli) is therefore NOT in kimi-code's artifactLayout. Schema adjustments: - subagentToolkit set to 'undocumented' (the existing escape hatch); the schema enum (full/read-only) lacks a 'limited'/'built-in-only' value. A follow-up PR can extend the schema enum to add 'built-in-only' as a first-class axis value reflecting Kimi Code's documented model. Registration: - capabilities/kimi-code/capability.json (new descriptor, modeled on codex) - bin/install.js: allRuntimes array + --all list + --kimi-code flag - gsd-core/bin/shared/runtime-aliases.manifest.json: kimi-code aliases (kimi-code, kimicode, kimi_code) - src/runtime-name-policy.cts: FALLBACK_ALIASES map - gsd-core/bin/lib/capability-registry.cjs: regenerated via scripts/gen-capability-registry.cjs --write Tests: - tests/multi-runtime-select.test.cjs updated for the new runtime count (18) + new --kimi-code flag test + 'All' shortcut renumbered 18 → 19. Out of scope for PR 1 (follow-up PRs in the sequence): - Install-time decision logic (kimi vs kimi-code detection / prompt) - agent-install-check semantics for kimi-code (verify Agent Skills presence) - cmdAgentSkills fallback returning subagent prompt content - Workflow template mapping (named agents → built-in coder/explore/plan) - Migration guidance for users currently on 'kimi' who are actually on Kimi Code - Schema enum extension for subagentToolkit: 'built-in-only' Refs #2454, #2095 (EoS/kimi migration epic), ADR-1239 (EoS). * fix(#2454): complete drift-guard registrations for kimi-code runtime The drift guards caught every surface that pins runtime enumeration. Each update is mechanical, driven by the guard's named failure mode: - src/runtime-name-policy.cts RUNTIME_LABELS: 'Kimi Code' label for kimi-code - src/runtime-name-policy.cts RUNTIME_FLAG_IDS: add kimi-code to the isKimiCode predicate generator - bin/install.js runtimeMap: option '11' → 'kimi-code', renumber downstream entries (11..17 → 12..18), ALL_RUNTIMES_OPTION 18 → 19 - gsd-core/bin/shared/model-catalog.json runtimeTierDefaults: kimi-code entry (null/null/null — same as kimi, no model tier defaults until configured) - docs/reference/capability-matrix.md: regenerated via scripts/gen-capability-matrix.cjs --write (kimi-code row added) - tests/global-config-home-fragment.test.cjs GOLDEN_FRAGMENT_MAP: kimi-code → '.kimi-code' - tests/fixtures/golden-install-parity/*.json: regenerated via npm run gen:golden (the runtime-aliases.manifest.json hash changed; all 17 runtime fixtures updated) The capability-registry is already regenerated from the prior commit. * test(#2454): update drift-guard tests for kimi-code runtime registration Multiple drift guards pin runtime enumeration counts and option numbering. Each update is mechanical, driven by the guard's named failure mode: - tests/runtime-flags.test.cjs: EXPECTED_FLAGS gains isKimiCode (16 → 17); 'all 16 flags' → 'all 17 flags' in test names + messages. - tests/multi-runtime-select.test.cjs: parseRuntimeInput option renumbering cascade — kilo moves 11→12, opencode 12→13, pi 13→14, qwen 14→15, trae 15→16, windsurf 16→17, zcode 17→18, All 18→19. New single-choice test for kimi-code (option 11). Prompt test updated for new numbering. - tests/host-integration-descriptors.test.cjs: EXPECTED_PROFILES gains kimi-code → 'programmatic-cli' (terminal CLI per Kimi Code docs); EXPECTED_FLATTEN gains kimi-code → false (backgroundDispatch:true per docs, same as Python kimi/opencode). - tests/global-config-home-fragment.test.cjs: table-count test renamed 13 → 14 table runtimes (kimi-code added to GOLDEN_FRAGMENT_MAP earlier). * fix(#2454): empty artifactLayout for kimi-code (PR 1 scope) The skills kind requires a converter (existing converters are per-runtime like convertClaudeCommandToKimiSkill). PR 1 of this multi-PR sequence only registers the descriptor; the actual Agent Skills converter (and a new 'convertClaudeCommandToKimiCodeSkill' function) lands in PR 2 alongside the install-time decision logic. Empty artifactLayout.global is valid and means 'nothing to install yet via the layout seam'. Also: added kimi-code to RUNTIME_META in tests/helpers/install-shared.cjs (localDir .kimi-code, globalSuffix .kimi-code), and added Kimi Code as option 11 in install.js's buildRuntimePromptText (renumbered downstream options 11..17 → 12..18, All 18 → 19). * fix(#2454): camelCase runtimeFlags for hyphenated ids (kimi-code → isKimiCode) The runtimeFlags generator previously produced 'isKimi-code' (hyphen preserved) for the new kimi-code runtime id. Property names with hyphens are awkward for consumers (flags['isKimi-code'] instead of flags.isKimiCode). The new runtimeIdToFlagName helper folds -[a-z] boundaries to uppercase, producing the conventional PascalCase flag name. The 16 prior single-word runtime ids are unaffected (the regex finds no hyphens). * fix(#2454): update remaining drift-guard tests + gen kimi-code fixtures - tests/runtime-flags.test.cjs drift guard: use proper kebab-case conversion (isKimiCode → kimi-code, not 'kimicode') so the registry comparison doesn't false-positive on hyphenated runtime ids. - tests/multi-runtime-select.test.cjs: fix kilo/opencode/pi/qwen/trae single-choice tests for the renumbered options (kilo 11→12, opencode 12→13, pi 13→14, qwen 14→15, trae 15→16). - tests/install.test.cjs: Kilo integration option 11→12, prompt test regex updated. - tests/fixtures/golden-install-parity/kimi-code.json + install-tree/ kimi-code.json: generated via UPDATE_GOLDEN=1 + UPDATE_INSTALL_TREE=1. The kimi-code install produces the standard GSD install layout (skills, contexts, references, etc.) — 436 paths, same shape as other runtimes that have no custom converter yet. * fix(#2454): add kimi-code install contract + global config home fragment - src/runtime-name-policy.cts GLOBAL_CONFIG_HOME_FRAGMENTS: add kimi-code → '.kimi-code' so getGlobalConfigHomeFragment returns the correct path instead of falling through to the default '.claude'. - tests/installer-migration-install.integration.test.cjs RUNTIME_INSTALL_CONTRACTS: kimi-code entry (same surface as kimi for PR 1; PR 2 will specialize once the Agent Skills converter lands). - tests/multi-runtime-select.test.cjs: fix space-separated-choices test for the renumbered kilo option (11 → 12). - tests/fixtures/golden-install-parity/kimi-code.json + install-tree/ kimi-code.json: regenerated after rebasing onto current next (new planner-reversibility.md from #2471 etc. now included). * test(#2454): skip kimi-code install contract until PR 2 ships install layout The end-to-end install test (tests/installer-migration-install.integration .test.cjs) asserts every allRuntimes entry installs a runtime-specific artifact surface. PR 1 of #2454 registers kimi-code in allRuntimes + the capability descriptor + flags + labels, but the install LAYOUT (Agent Skills converter + global AGENTS.md at $KIMI_CODE_HOME/AGENTS.md) lands in PR 2. The SKIP_INSTALL_CONTRACT set marks this exclusion explicit and self-removing — PR 2 removes the entry alongside adding the install surface, restoring the contract loop to full coverage. * fix(#2454): restore compact model-catalog.json format (M1 review) Per code-review M1: my prior 'fix(#2454): complete drift-guard registrations' commit used python json.dump(indent=2) which inflated the file from 165→607 lines (every nested entry got expanded) and lost the trailing newline. The semantic change was just a 3-line kimi-code entry. Restored the original hybrid format (top-level indent=2 + inner entries' one-line style) and added kimi-code in matching form. Regenerated golden install parity + install tree fixtures since the model-catalog.json hash changed. * fix(#2454): update CONTEXT.md allRuntimes glossary (17 → 18, add kimi-code) CI lint-tests job failed on the glossary drift guard (scripts/check-glossary-refs.cjs --check): ✗ CONTEXT.md's allRuntimes enum-count sentence claims 17 values but bin/install.js's allRuntimes array has 18. ✗ CONTEXT.md's allRuntimes member list has drifted from bin/install.js (missing from CONTEXT.md's list: kimi-code). Missed in the prior commits because gsd-test does not run the glossary check (it's a CI lint-tests-only check). Updating CONTEXT.md's two claims to 18 values + kimi-code in the member list. * chore(#2505): regen capability-registry + stamp kimi-code version 1.8.0 (#2511) * docs(changeset): Phase 1 kimi-code runtime Added (#2511) * test(#2511): regen kimi-code golden parity fixture after Phase 0 guard normalization lands * docs(changeset): backfill PR #2519 for Phase 1 (#2511)
416 lines
18 KiB
JavaScript
416 lines
18 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Shared helpers and constants for the install test suites and the
|
|
* golden-install-parity harness. Provides the install/uninstall drivers
|
|
* (walk, runMinimalInstall, RUNTIME_META, BUILD_SCRIPT) and the single
|
|
* canonical golden-parity manifest builder (buildParityManifest) plus its
|
|
* exclusion constants (VOLATILE_FILES, HOOK_CONFIG_FILES,
|
|
* HOOK_CONFIG_RELATIVE_PATHS, EXCLUDED_PREFIXES). Imported by many
|
|
* tests/*.test.cjs and by scripts/gen-golden-install-parity-zcode.cjs — do
|
|
* NOT re-declare the builder/constants inline (enforced by
|
|
* tests/golden-parity-single-source.test.cjs, #2266).
|
|
*/
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const crypto = require('node:crypto');
|
|
const { spawnSync } = require('node:child_process');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const {
|
|
resolveRuntimeArtifactLayout,
|
|
} = require('../../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
|
|
|
const INSTALL_SCRIPT = path.join(__dirname, '..', '..', 'bin', 'install.js');
|
|
const MANIFEST_NAME = 'gsd-file-manifest.json';
|
|
|
|
const BUILD_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'build-hooks.js');
|
|
const HOOKS_DIST = path.join(__dirname, '..', '..', 'hooks', 'dist');
|
|
|
|
const EXPECTED_SH_HOOKS = [
|
|
'gsd-session-state.sh',
|
|
'gsd-validate-commit.sh',
|
|
'gsd-phase-boundary.sh',
|
|
];
|
|
|
|
const EXPECTED_ALL_HOOKS = [
|
|
'gsd-check-update.js',
|
|
'gsd-config-reload.js',
|
|
'gsd-context-monitor.js',
|
|
// #997: SessionStart canonical-path bootstrap for plugin installs.
|
|
'gsd-ensure-canonical-path.js',
|
|
'gsd-prompt-guard.js',
|
|
'gsd-read-guard.js',
|
|
'gsd-read-injection-scanner.js',
|
|
'gsd-statusline.js',
|
|
'gsd-workflow-guard.js',
|
|
...EXPECTED_SH_HOOKS,
|
|
];
|
|
|
|
// ─── Runtime metadata table ───────────────────────────────────────────────────
|
|
|
|
const RUNTIME_META = {
|
|
claude: { localDir: '.claude', globalSuffix: '.claude' },
|
|
antigravity: { localDir: '.agents', globalSuffix: path.join('.gemini', 'antigravity') },
|
|
augment: { localDir: '.augment', globalSuffix: '.augment' },
|
|
cline: { localDir: '.cline', globalSuffix: '.cline' },
|
|
codebuddy: { localDir: '.codebuddy', globalSuffix: '.codebuddy' },
|
|
codex: { localDir: '.codex', globalSuffix: '.codex' },
|
|
copilot: { localDir: '.github', globalSuffix: '.copilot' },
|
|
cursor: { localDir: '.cursor', globalSuffix: '.cursor' },
|
|
hermes: { localDir: '.hermes', globalSuffix: '.hermes' },
|
|
kimi: { localDir: '.kimi-code', globalSuffix: path.join('.config', 'agents') },
|
|
'kimi-code': { localDir: '.kimi-code', globalSuffix: '.kimi-code' },
|
|
kilo: { localDir: '.kilo', globalSuffix: path.join('.config', 'kilo') },
|
|
opencode: { localDir: '.opencode', globalSuffix: path.join('.config', 'opencode') },
|
|
pi: { localDir: '.pi', globalSuffix: path.join('.pi', 'agent') },
|
|
qwen: { localDir: '.qwen', globalSuffix: '.qwen' },
|
|
trae: { localDir: '.trae', globalSuffix: '.trae' },
|
|
windsurf: { localDir: '.windsurf', globalSuffix: path.join('.codeium', 'windsurf') },
|
|
zcode: { localDir: '.zcode', globalSuffix: '.zcode' },
|
|
};
|
|
|
|
// Runtimes that emit per-skill files under skills/ (not rules-based or commands-based)
|
|
const SKILL_RUNTIMES = [
|
|
'claude', 'opencode', 'kilo', 'codex', 'copilot', 'antigravity',
|
|
'cursor', 'augment', 'trae', 'qwen', 'codebuddy',
|
|
];
|
|
|
|
// ─── Golden install-parity manifest (canonical — issue #2266) ────────────────
|
|
//
|
|
// Single source of truth for the parity-manifest exclusion rules and hash
|
|
// formula. Both tests/golden-install-parity.test.cjs (the test harness) and
|
|
// scripts/gen-golden-install-parity-zcode.cjs (the build-time fixture
|
|
// generator) import buildParityManifest + these constants from here instead
|
|
// of each re-declaring their own copy — the prior duplication had drifted
|
|
// (the generator's copy was missing the realpath normalization below) and
|
|
// shipped broken fixtures three times (#2086, #2095, #2100).
|
|
|
|
// The installed package version, normalized to '<VERSION>' in hash computation so
|
|
// the golden is stable across version bumps (the rc step runs `npm version X.Y.Z-rc.N`
|
|
// before tests, which rebakes the version into hook files and gsd-core/VERSION).
|
|
const PKG_VERSION = require('../../package.json').version;
|
|
|
|
// Volatile metadata files always excluded from the parity manifest.
|
|
// .gsd-source (#1477, claude-global only) records the install-time absolute path
|
|
// to the package's commands/gsd source tree, which is the checkout/CI workspace
|
|
// path — NOT the temp HOME root, so it is never normalized to '<HOME>' and its
|
|
// hash varies by environment. Excluded for the same reason as gsd-install-state.json.
|
|
// gsd-core/CHANGELOG.md is excluded because it contains historical version strings
|
|
// that cause hash drift between local (PKG_VERSION=1.x.x) and CI (PKG_VERSION=1.x.x-rc.N):
|
|
// the PKG_VERSION normalization below replaces only the *current* version, but
|
|
// CHANGELOG.md references prior-release versions, so the normalized hash diverges.
|
|
const VOLATILE_FILES = new Set([
|
|
'gsd-file-manifest.json',
|
|
'gsd-install-state.json',
|
|
'.gsd-source',
|
|
'gsd-core/CHANGELOG.md',
|
|
]);
|
|
|
|
// Hook-registration config files excluded from the parity manifest. These are
|
|
// written by the hook/permission install path (applySettingsJsonHooks /
|
|
// finishInstall) — NOT by installRuntimeArtifacts, so they are outside the scope
|
|
// of the engine deep-move this harness guards. They also embed the resolved
|
|
// node-runner invocation, whose FORM (absolute-quoted "/abs/bin/node" on macOS
|
|
// vs bare `node` resolved from PATH on Linux/CI) — not just the binary path —
|
|
// varies by platform and cannot be normalized to a single sentinel reliably.
|
|
// Their content is asserted directly by the dedicated hook tests
|
|
// (install-minimal-hooks, sh-hook-paths, codex-config, etc.). Matched by basename.
|
|
// settings.json = Claude/Antigravity/Augment/etc. hook surface; hooks.json =
|
|
// Codex/Cursor hook surface — both embed the platform-varying node-runner command.
|
|
// settings.local.json = Claude LOCAL hook surface (#338): same platform-varying
|
|
// node-runner command as settings.json, so excluded for the same reason (#2086).
|
|
const HOOK_CONFIG_FILES = new Set(['settings.json', 'settings.local.json', 'hooks.json']);
|
|
|
|
// Kimi's native config.toml (#2095 EoS/kimi Upgrade 1) embeds the same
|
|
// platform-varying node-runner command as the HOOK_CONFIG_FILES above (via the
|
|
// same buildHookCommand/projectManagedHookCommand machinery), so it needs the
|
|
// same exclusion — but it is NOT matched by basename like HOOK_CONFIG_FILES:
|
|
// Codex's OWN config.toml (installSurface 'codex-toml') is a stable, tracked
|
|
// top-level `config.toml` entry in its golden fixture (it only ever gets a
|
|
// platform-stable `[features] hooks = true` flag — the real hook commands
|
|
// live in Codex's separate hooks.json, already excluded above). Blanket-
|
|
// excluding the 'config.toml' basename would silently blind Codex's fixture
|
|
// to any future regression there. Kimi's config.toml instead lives OUTSIDE
|
|
// its GSD configDir at runtime (resolveKimiHooksTomlDir resolves ~/.kimi, a
|
|
// sibling of the configDir ~/.config/agents) — it only appears inside this
|
|
// harness's walked tree at all because runMinimalInstall sets HOME to the
|
|
// same temp root used as --config-dir, collapsing the two into one directory
|
|
// for the isolated test run. So it is excluded by its exact relative path
|
|
// under that collapsed root, not by basename.
|
|
const HOOK_CONFIG_RELATIVE_PATHS = new Set(['.kimi/config.toml']);
|
|
|
|
// Path prefixes excluded from the parity manifest. `gsd-core/bin/lib/` holds the
|
|
// tsc-built runtime artifacts (compiled from src/*.cts) that the install COPIES
|
|
// verbatim — they are NOT produced by installRuntimeArtifacts (the move's parity
|
|
// scope), and their exact bytes depend on the BUILD environment (a clean tsc
|
|
// build vs a stale incremental one yields different output for unchanged sources).
|
|
// Including them made the golden non-portable: CI's clean build legitimately
|
|
// differs from a local incremental build for modules the PR never touched
|
|
// (e.g. milestone.cjs, roadmap.cjs). The .cts sources are type-checked + drift-
|
|
// guarded + coverage-gated elsewhere; this harness asserts the CONVERTED artifact
|
|
// output (skills/commands/agents) that the engine actually emits.
|
|
const EXCLUDED_PREFIXES = ['gsd-core/bin/lib/'];
|
|
|
|
// ─── Helper functions ─────────────────────────────────────────────────────────
|
|
|
|
function stripAnsi(str) {
|
|
|
|
return str.replace(/\x1b\[[0-9;]*m/g, '');
|
|
}
|
|
|
|
function walk(dir) {
|
|
const results = [];
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) results.push(...walk(full));
|
|
else results.push(full);
|
|
}
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Build a deterministic hash-map of all non-volatile files under configDir.
|
|
*
|
|
* For each file:
|
|
* - rel = POSIX-slash relative path from configDir
|
|
* - hash = sha256(content with root replaced by '<HOME>').slice(0,16)
|
|
*
|
|
* Returns a plain object with sorted keys for stable JSON comparison.
|
|
*
|
|
* @param {string} configDir - absolute path to the installed runtime config dir
|
|
* @param {string} root - temp root path to replace with '<HOME>'
|
|
* @returns {{ [rel: string]: string }}
|
|
*/
|
|
function buildParityManifest(configDir, root) {
|
|
const allFiles = walk(configDir);
|
|
const unsorted = {};
|
|
|
|
// The claude LOCAL install resolves its config dir via realpath, which on macOS
|
|
// prepends `/private` to the temp root (`/var/folders/…` -> `/private/var/folders/…`)
|
|
// and embeds that resolved path in the projected agents/commands/workflows (`@…`
|
|
// references). On Linux the temp root has no `/private` symlink, so normalizing
|
|
// ONLY `root` left the `/private` prefix on macOS and produced platform-divergent
|
|
// hashes (#2086). Normalize the realpath form FIRST (it is the longer, `/private`-
|
|
// prefixed string) so both platforms collapse to `<HOME>`. No-op for the global
|
|
// fixtures (global install uses the literal `--config-dir`, never realpath-resolved).
|
|
let realRoot = root;
|
|
try { realRoot = fs.realpathSync(root); } catch { /* root already gone / not resolvable */ }
|
|
|
|
for (const full of allFiles) {
|
|
// Build POSIX-style relative path for cross-platform stability
|
|
const rel = path.relative(configDir, full).split(path.sep).join('/');
|
|
|
|
if (VOLATILE_FILES.has(rel)) continue;
|
|
if (HOOK_CONFIG_FILES.has(path.basename(rel))) continue;
|
|
if (HOOK_CONFIG_RELATIVE_PATHS.has(rel)) continue;
|
|
if (EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue;
|
|
|
|
const content = fs.readFileSync(full);
|
|
// Normalize every occurrence of the temp root so hashes are stable across runs.
|
|
// Also normalize the package version so the golden survives `npm version` bumps
|
|
// (the rc release step bakes the new version into hook files before running tests).
|
|
const normalized = content.toString('utf8')
|
|
.split(realRoot).join('<HOME>')
|
|
.split(root).join('<HOME>')
|
|
.split(PKG_VERSION).join('<VERSION>');
|
|
const hash = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 16);
|
|
unsorted[rel] = hash;
|
|
}
|
|
|
|
// Reconstruct with sorted keys for stable JSON serialisation
|
|
const sorted = {};
|
|
for (const key of Object.keys(unsorted).sort()) {
|
|
sorted[key] = unsorted[key];
|
|
}
|
|
return sorted;
|
|
}
|
|
|
|
/** Sorted list of emitted relative paths for a runtime install (file-set snapshot,
|
|
* #2267). Reuses buildParityManifest's exact exclusion set so the tree and the
|
|
* content manifest never diverge on which files they cover. */
|
|
function buildInstallTree(configDir, root) {
|
|
return Object.keys(buildParityManifest(configDir, root)).sort();
|
|
}
|
|
|
|
function simulateHookCopy(hooksSrc, hooksDest) {
|
|
fs.mkdirSync(hooksDest, { recursive: true });
|
|
for (const entry of fs.readdirSync(hooksSrc)) {
|
|
const srcFile = path.join(hooksSrc, entry);
|
|
if (!fs.statSync(srcFile).isFile()) continue;
|
|
const destFile = path.join(hooksDest, entry);
|
|
if (entry.endsWith('.js')) {
|
|
fs.writeFileSync(destFile, fs.readFileSync(srcFile, 'utf8'));
|
|
try { fs.chmodSync(destFile, 0o755); } catch { /* Windows */ }
|
|
} else {
|
|
fs.copyFileSync(srcFile, destFile);
|
|
if (entry.endsWith('.sh')) {
|
|
try { fs.chmodSync(destFile, 0o755); } catch { /* Windows */ }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Build a clean env for spawned installer processes.
|
|
* Must strip GSD_TEST_MODE so the child runs the real install, not the no-op guard. */
|
|
function installerEnv(overrides = {}) {
|
|
const env = { ...process.env, ...overrides };
|
|
delete env.GSD_TEST_MODE;
|
|
return env;
|
|
}
|
|
|
|
function runMinimalInstall({ runtime, scope, extraArgs = [] }) {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-${runtime}-${scope}-`));
|
|
try {
|
|
const LOCAL_DIR_NAME = {
|
|
claude: '.claude', opencode: '.opencode', kilo: '.kilo',
|
|
codex: '.codex', copilot: '.github', antigravity: '.agents', cursor: '.cursor',
|
|
windsurf: '.windsurf', augment: '.augment', trae: '.trae', qwen: '.qwen',
|
|
codebuddy: '.codebuddy', cline: '.',
|
|
};
|
|
let configDir;
|
|
let cwd = process.cwd();
|
|
const args = [INSTALL_SCRIPT, `--${runtime}`];
|
|
if (scope === 'global') {
|
|
args.push('--global', '--config-dir', root);
|
|
configDir = root;
|
|
} else {
|
|
args.push('--local');
|
|
cwd = root;
|
|
configDir = runtime === 'cline' ? root : path.join(root, LOCAL_DIR_NAME[runtime]);
|
|
}
|
|
args.push(...extraArgs);
|
|
const result = spawnSync(process.execPath, args, {
|
|
cwd, encoding: 'utf8',
|
|
env: installerEnv({ HOME: root, USERPROFILE: root }),
|
|
});
|
|
assert.strictEqual(result.status, 0,
|
|
`installer exited with status ${result.status} for ${runtime} --${scope}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
|
const manifestPath = path.join(configDir, MANIFEST_NAME);
|
|
const manifest = fs.existsSync(manifestPath)
|
|
? JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
|
: null;
|
|
return { manifest, configDir, root, stdout: result.stdout, stderr: result.stderr };
|
|
} catch (err) {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function manifestSkillSet(manifest) {
|
|
if (!manifest || !manifest.files) return new Set();
|
|
const out = new Set();
|
|
for (const key of Object.keys(manifest.files)) {
|
|
if (key.startsWith('skills/')) {
|
|
const seg = key.split('/')[1].replace(/^gsd-/, '').replace(/\.md$/, '');
|
|
out.add(seg);
|
|
} else if (key.startsWith('command/')) {
|
|
// OpenCode/Kilo: command/gsd-<cmd>.md
|
|
const file = key.split('/')[1];
|
|
out.add(file.replace(/^gsd-/, '').replace(/\.md$/, ''));
|
|
} else if (key.startsWith('commands/gsd/')) {
|
|
// Gemini: commands/gsd/<cmd>.toml (nested, colon-namespaced)
|
|
const file = key.split('/')[2];
|
|
out.add(file.replace(/\.(md|toml)$/, ''));
|
|
} else if (key.startsWith('commands/') && key.split('/').length === 2) {
|
|
// Claude local (#1367 fix): flat commands/gsd-<cmd>.md
|
|
const file = key.split('/')[1];
|
|
if (file.startsWith('gsd-') && file.endsWith('.md')) {
|
|
out.add(file.replace(/^gsd-/, '').replace(/\.md$/, ''));
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function manifestAgentCount(manifest) {
|
|
if (!manifest || !manifest.files) return 0;
|
|
return Object.keys(manifest.files).filter((k) => k.startsWith('agents/')).length;
|
|
}
|
|
|
|
/**
|
|
* Collect gsd-* skill/command basenames actually present on disk under configDir.
|
|
*
|
|
* @param {string} configDir
|
|
* @param {string} [runtime] - when provided, the skills-kind destination is
|
|
* resolved via resolveRuntimeArtifactLayout so a skills-kind `home` override
|
|
* (Codex only, ADR-1239 upgrade 3 / #2088: skills -> $HOME/.agents/skills
|
|
* instead of configDir/skills) is honored. Omitted callers keep the prior
|
|
* configDir/skills default.
|
|
* @param {string} [scope='global']
|
|
*/
|
|
function collectSkillBasenamesOnDisk(configDir, runtime, scope = 'global') {
|
|
const out = new Set();
|
|
let skillsDir = path.join(configDir, 'skills');
|
|
if (runtime) {
|
|
try {
|
|
const layout = resolveRuntimeArtifactLayout(runtime, configDir, scope);
|
|
const skillsKind = layout.kinds.find((k) => k.kind === 'skills');
|
|
if (skillsKind) skillsDir = path.join(skillsKind.home || configDir, skillsKind.destSubpath);
|
|
} catch { /* fall back to configDir/skills */ }
|
|
}
|
|
if (fs.existsSync(skillsDir)) {
|
|
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
|
|
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
|
|
out.add(entry.name.replace(/^gsd-/, ''));
|
|
} else if (entry.isFile() && entry.name.startsWith('gsd-') && entry.name.endsWith('.md')) {
|
|
out.add(entry.name.replace(/^gsd-/, '').replace(/\.md$/, ''));
|
|
}
|
|
}
|
|
}
|
|
const commandDir = path.join(configDir, 'command');
|
|
if (fs.existsSync(commandDir)) {
|
|
for (const file of fs.readdirSync(commandDir)) {
|
|
if (file.startsWith('gsd-') && file.endsWith('.md')) {
|
|
out.add(file.replace(/^gsd-/, '').replace(/\.md$/, ''));
|
|
}
|
|
}
|
|
}
|
|
const commandsGsdDir = path.join(configDir, 'commands', 'gsd');
|
|
if (fs.existsSync(commandsGsdDir)) {
|
|
for (const file of fs.readdirSync(commandsGsdDir)) {
|
|
if (file.endsWith('.md') || file.endsWith('.toml')) {
|
|
out.add(file.replace(/\.(md|toml)$/, ''));
|
|
}
|
|
}
|
|
}
|
|
// Claude local (#1367 fix): flat gsd-*.md files at commands/ level
|
|
const flatCommandsDir = path.join(configDir, 'commands');
|
|
if (fs.existsSync(flatCommandsDir)) {
|
|
for (const file of fs.readdirSync(flatCommandsDir)) {
|
|
if (file.startsWith('gsd-') && file.endsWith('.md')) {
|
|
out.add(file.replace(/^gsd-/, '').replace(/\.md$/, ''));
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
module.exports = {
|
|
INSTALL_SCRIPT,
|
|
MANIFEST_NAME,
|
|
BUILD_SCRIPT,
|
|
HOOKS_DIST,
|
|
EXPECTED_SH_HOOKS,
|
|
EXPECTED_ALL_HOOKS,
|
|
RUNTIME_META,
|
|
SKILL_RUNTIMES,
|
|
PKG_VERSION,
|
|
VOLATILE_FILES,
|
|
HOOK_CONFIG_FILES,
|
|
HOOK_CONFIG_RELATIVE_PATHS,
|
|
EXCLUDED_PREFIXES,
|
|
stripAnsi,
|
|
walk,
|
|
buildParityManifest,
|
|
buildInstallTree,
|
|
simulateHookCopy,
|
|
installerEnv,
|
|
runMinimalInstall,
|
|
manifestSkillSet,
|
|
manifestAgentCount,
|
|
collectSkillBasenamesOnDisk,
|
|
};
|