Drive Codex install/uninstall through the descriptor-driven Host-Integration Interface (declarative embedding adapter → engine surface dispatch) and fold every positive `runtime === 'codex'` / `isCodex` projection into descriptor-driven `runtime.hostBehaviors`. Install/uninstall output stays byte-parity-gated (tests/fixtures/golden-install-parity/codex.json); no other runtime changes. Three Context7-verified upgrades, each with a test on the user-reachable surface: - Skill root → canonical $HOME/.agents/skills via a skills-kind `home` override, with pre-move migration cleanup (stale ~/.codex/skills/gsd-* removed on install and uninstall; user content preserved). Fixes getGlobalSkillsBase, writeManifest, and the skill-manifest inventory to honor the override so --skills-root / sync-skills / the manifest report the real location. - Six new hooks.json lifecycle events (PreToolUse, PermissionRequest, PreCompact, PostCompact, SubagentStop, UserPromptSubmit) shared by install + uninstall; extendedHookEvents reconciled [] -> the schema-valid wired subset. - Explicit `[agents] max_depth = 1` in the managed config.toml block, pinning the negotiated dispatch.maxDepth:1 axis. validateCodexConfigSchema now permits a known-scalar-only bare `[agents]` AgentsToml table (still rejects [[agents]] and unknown-key break-forms, #2760); mergeCodexConfig preserves the user's own AgentsToml scalars (max_threads etc.) instead of dropping them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
260 lines
9.7 KiB
JavaScript
260 lines
9.7 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Shared helpers and constants for install test suite.
|
|
* Used by install.test.cjs, install-runtime-artifacts.test.cjs,
|
|
* and install-minimal-hooks.test.cjs.
|
|
*/
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
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') },
|
|
kilo: { localDir: '.kilo', globalSuffix: path.join('.config', 'kilo') },
|
|
opencode: { localDir: '.opencode', globalSuffix: path.join('.config', 'opencode') },
|
|
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',
|
|
];
|
|
|
|
// ─── 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;
|
|
}
|
|
|
|
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,
|
|
stripAnsi,
|
|
walk,
|
|
simulateHookCopy,
|
|
installerEnv,
|
|
runMinimalInstall,
|
|
manifestSkillSet,
|
|
manifestAgentCount,
|
|
collectSkillBasenamesOnDisk,
|
|
};
|