Merge branch 'next' into kimi-runtime-support
This commit is contained in:
@@ -232,6 +232,29 @@ describe('AGENT: required frontmatter fields', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Color Value Validation ──────────────────────────────────────────────────
|
||||
|
||||
const VALID_AGENT_COLORS = new Set(['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'cyan']);
|
||||
|
||||
describe('COLOR: color frontmatter must be a documented named color', () => {
|
||||
for (const agent of ALL_AGENTS) {
|
||||
test(`${agent} color: is a documented named color`, () => {
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, agent + '.md'), 'utf-8');
|
||||
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const frontmatter = fmMatch ? fmMatch[1] : '';
|
||||
const colorMatch = frontmatter.match(/^color:\s*(.+)$/m);
|
||||
assert.ok(colorMatch, `${agent} missing color: field in frontmatter`);
|
||||
const rawValue = colorMatch[1].trim();
|
||||
// Strip surrounding quotes (single or double) before validating
|
||||
const colorValue = rawValue.replace(/^["']|["']$/g, '');
|
||||
assert.ok(
|
||||
VALID_AGENT_COLORS.has(colorValue),
|
||||
`${agent} has invalid color: "${colorValue}" — must be one of: ${[...VALID_AGENT_COLORS].join(', ')}`
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── CLAUDE.md Compliance ───────────────────────────────────────────────────
|
||||
|
||||
describe('CLAUDEMD: CLAUDE.md compliance enforcement', () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const jsYaml = require('js-yaml');
|
||||
|
||||
// #2153 follow-up: ensure hooks/dist/ exists before any install integration
|
||||
// test runs. The Codex install path copies hook files from hooks/dist/, which
|
||||
@@ -44,6 +45,8 @@ const {
|
||||
convertClaudeAgentToCodexAgent,
|
||||
convertClaudeCommandToCodexSkill,
|
||||
generateCodexAgentToml,
|
||||
generateCodexSkillMetadataYaml,
|
||||
writeCodexSkillMetadataFiles,
|
||||
generateCodexConfigBlock,
|
||||
stripGsdFromCodexConfig,
|
||||
migrateCodexHooksMapFormat,
|
||||
@@ -401,6 +404,74 @@ tools: Read, Grep, Glob
|
||||
assert.ok(instrIdx !== -1, 'developer_instructions present');
|
||||
assert.ok(modelIdx < instrIdx, 'model field must appear before developer_instructions');
|
||||
});
|
||||
|
||||
// ─── #774: service_tier / model_verbosity for light-tier agents ───────────────
|
||||
|
||||
test('emits service_tier="flex" and model_verbosity="low" for light-tier agents (#774)', () => {
|
||||
// gsd-plan-checker has routingTier:"light" in model-catalog.json
|
||||
const lightAgent = `---
|
||||
name: gsd-plan-checker
|
||||
description: Checks plans quickly
|
||||
tools: Read, Grep
|
||||
---
|
||||
|
||||
<role>You check plans.</role>`;
|
||||
const result = generateCodexAgentToml('gsd-plan-checker', lightAgent);
|
||||
assert.ok(result.includes('service_tier = "flex"'), 'light-tier agent must have service_tier = "flex"');
|
||||
assert.ok(result.includes('model_verbosity = "low"'), 'light-tier agent must have model_verbosity = "low"');
|
||||
});
|
||||
|
||||
test('does not emit service_tier or model_verbosity for standard-tier agents (#774)', () => {
|
||||
// gsd-executor has routingTier:"standard" in model-catalog.json
|
||||
const result = generateCodexAgentToml('gsd-executor', sampleAgent);
|
||||
assert.ok(!result.includes('service_tier'), 'standard-tier agent must not have service_tier');
|
||||
assert.ok(!result.includes('model_verbosity'), 'standard-tier agent must not have model_verbosity');
|
||||
});
|
||||
|
||||
test('does not emit service_tier or model_verbosity for heavy-tier agents (#774)', () => {
|
||||
// gsd-planner has routingTier:"heavy" in model-catalog.json
|
||||
const heavyAgent = `---
|
||||
name: gsd-planner
|
||||
description: Creates plans
|
||||
tools: Read, Write, Edit
|
||||
---
|
||||
|
||||
<role>You plan.</role>`;
|
||||
const result = generateCodexAgentToml('gsd-planner', heavyAgent);
|
||||
assert.ok(!result.includes('service_tier'), 'heavy-tier agent must not have service_tier');
|
||||
assert.ok(!result.includes('model_verbosity'), 'heavy-tier agent must not have model_verbosity');
|
||||
});
|
||||
|
||||
test('service_tier and model_verbosity appear before developer_instructions (#774)', () => {
|
||||
const lightAgent = `---
|
||||
name: gsd-plan-checker
|
||||
description: Checks plans
|
||||
---
|
||||
|
||||
<role>You check plans.</role>`;
|
||||
const result = generateCodexAgentToml('gsd-plan-checker', lightAgent);
|
||||
const stIdx = result.indexOf('service_tier = "flex"');
|
||||
const mvIdx = result.indexOf('model_verbosity = "low"');
|
||||
const instrIdx = result.indexOf("developer_instructions = '''");
|
||||
assert.ok(stIdx !== -1, 'service_tier present');
|
||||
assert.ok(mvIdx !== -1, 'model_verbosity present');
|
||||
assert.ok(instrIdx !== -1, 'developer_instructions present');
|
||||
assert.ok(stIdx < instrIdx, 'service_tier must appear before developer_instructions');
|
||||
assert.ok(mvIdx < instrIdx, 'model_verbosity must appear before developer_instructions');
|
||||
});
|
||||
|
||||
test('emitted TOML is parseable and contains correct field values for light-tier agents (#774)', () => {
|
||||
const lightAgent = `---
|
||||
name: gsd-codebase-mapper
|
||||
description: Maps the codebase
|
||||
---
|
||||
|
||||
<role>You map the codebase.</role>`;
|
||||
const toml = generateCodexAgentToml('gsd-codebase-mapper', lightAgent);
|
||||
const parsed = parseTomlToObject(toml);
|
||||
assert.strictEqual(parsed.service_tier, 'flex', 'service_tier must parse to "flex"');
|
||||
assert.strictEqual(parsed.model_verbosity, 'low', 'model_verbosity must parse to "low"');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CODEX_AGENT_SANDBOX mapping ────────────────────────────────────────────────
|
||||
@@ -2302,3 +2373,215 @@ describe('Codex uninstall symmetry for hook-enabled configs', () => {
|
||||
assert.strictEqual(countMatches(cleaned, /\[agents\.gsd-/g), 0, 'removes managed GSD agent sections');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #774: generateCodexSkillMetadataYaml ────────────────────────────────────────
|
||||
|
||||
describe('generateCodexSkillMetadataYaml', () => {
|
||||
test('emits valid parseable YAML with interface section (#774)', () => {
|
||||
const yaml = generateCodexSkillMetadataYaml('gsd-plan-phase', 'Plan and structure the next development phase.');
|
||||
// Must start with interface: and be parseable YAML
|
||||
assert.ok(yaml.startsWith('interface:'), 'must start with interface: key');
|
||||
const parsed = jsYaml.load(yaml);
|
||||
assert.ok(parsed && typeof parsed === 'object', 'must be parseable YAML object');
|
||||
assert.ok(parsed.interface, 'must have interface key');
|
||||
assert.ok('display_name' in parsed.interface, 'must include display_name');
|
||||
assert.ok('short_description' in parsed.interface, 'must include short_description');
|
||||
});
|
||||
|
||||
test('strips gsd- prefix from display_name and converts hyphens to spaces (#774)', () => {
|
||||
const yaml = generateCodexSkillMetadataYaml('gsd-plan-phase', 'Plan and structure the next development phase.');
|
||||
const parsed = jsYaml.load(yaml);
|
||||
assert.strictEqual(parsed.interface.display_name, 'plan phase',
|
||||
'display_name must be "plan phase" (gsd- stripped, hyphens→spaces)');
|
||||
});
|
||||
|
||||
test('embeds the short_description text as decoded string (#774)', () => {
|
||||
const desc = 'Run GSD workflow gsd-test.';
|
||||
const yaml = generateCodexSkillMetadataYaml('gsd-test', desc);
|
||||
const parsed = jsYaml.load(yaml);
|
||||
assert.strictEqual(parsed.interface.short_description, desc,
|
||||
'short_description must round-trip through YAML correctly');
|
||||
});
|
||||
|
||||
test('escapes double-quotes in description so parsed value is correct (#774)', () => {
|
||||
const desc = 'Run "special" workflow.';
|
||||
const yaml = generateCodexSkillMetadataYaml('gsd-test', desc);
|
||||
const parsed = jsYaml.load(yaml);
|
||||
assert.strictEqual(parsed.interface.short_description, desc,
|
||||
'double-quotes in description must round-trip correctly through YAML');
|
||||
});
|
||||
|
||||
test('escapes backslashes in description so parsed value is correct (#774)', () => {
|
||||
const desc = 'Run C:\\path\\to workflow.';
|
||||
const yaml = generateCodexSkillMetadataYaml('gsd-test', desc);
|
||||
const parsed = jsYaml.load(yaml);
|
||||
assert.strictEqual(parsed.interface.short_description, desc,
|
||||
'backslashes in description must round-trip correctly through YAML');
|
||||
});
|
||||
|
||||
test('output ends with a newline (#774)', () => {
|
||||
const yaml = generateCodexSkillMetadataYaml('gsd-test', 'Test skill.');
|
||||
assert.ok(yaml.endsWith('\n'), 'output must end with a newline');
|
||||
});
|
||||
|
||||
test('works for skill without gsd- prefix (#774)', () => {
|
||||
const yaml = generateCodexSkillMetadataYaml('my-skill', 'A custom skill.');
|
||||
const parsed = jsYaml.load(yaml);
|
||||
assert.strictEqual(parsed.interface.display_name, 'my skill',
|
||||
'display_name must convert hyphens to spaces even without gsd- prefix');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #774: writeCodexSkillMetadataFiles ─────────────────────────────────────────
|
||||
|
||||
describe('writeCodexSkillMetadataFiles', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-skill-meta-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('writes agents/openai.yaml for each gsd-* skill directory (#774)', () => {
|
||||
// Set up a synthetic skills directory with two gsd-* skill dirs
|
||||
const skills = [
|
||||
{ name: 'gsd-plan-phase', desc: 'Plan and structure the next development phase.' },
|
||||
{ name: 'gsd-execute-phase', desc: 'Execute the current phase.' },
|
||||
];
|
||||
for (const { name, desc } of skills) {
|
||||
const skillDir = path.join(tmpDir, name);
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
const skillMd = `---\nname: ${name}\ndescription: "${desc}"\nmetadata:\n short-description: "${desc}"\n---\n\nBody text.\n`;
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillMd);
|
||||
}
|
||||
|
||||
writeCodexSkillMetadataFiles(tmpDir);
|
||||
|
||||
for (const { name, desc } of skills) {
|
||||
const yamlPath = path.join(tmpDir, name, 'agents', 'openai.yaml');
|
||||
assert.ok(fs.existsSync(yamlPath), `agents/openai.yaml must exist for ${name}`);
|
||||
const content = fs.readFileSync(yamlPath, 'utf8');
|
||||
// Verify it's parseable YAML with the correct structure
|
||||
const parsed = jsYaml.load(content);
|
||||
assert.ok(parsed && parsed.interface, `${name}/agents/openai.yaml must parse to object with interface:`);
|
||||
assert.ok('display_name' in parsed.interface, `${name}/agents/openai.yaml must have display_name`);
|
||||
assert.strictEqual(parsed.interface.short_description, desc,
|
||||
`${name}/agents/openai.yaml short_description must match source description`);
|
||||
}
|
||||
});
|
||||
|
||||
test('ignores non-gsd directories (#774)', () => {
|
||||
// Non-gsd-* dir should not get agents/openai.yaml
|
||||
const nonGsdDir = path.join(tmpDir, 'custom-skill');
|
||||
fs.mkdirSync(nonGsdDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(nonGsdDir, 'SKILL.md'), '---\nname: custom\n---\nBody.\n');
|
||||
|
||||
writeCodexSkillMetadataFiles(tmpDir);
|
||||
|
||||
assert.ok(!fs.existsSync(path.join(nonGsdDir, 'agents', 'openai.yaml')),
|
||||
'non-gsd-* dirs must not get agents/openai.yaml');
|
||||
});
|
||||
|
||||
test('does not overwrite user-owned gsd-dev-preferences/agents/openai.yaml (#774)', () => {
|
||||
// gsd-dev-preferences is user-owned and must never be modified by GSD install
|
||||
const userOwnedDir = path.join(tmpDir, 'gsd-dev-preferences');
|
||||
const agentsSubdir = path.join(userOwnedDir, 'agents');
|
||||
fs.mkdirSync(agentsSubdir, { recursive: true });
|
||||
fs.writeFileSync(path.join(userOwnedDir, 'SKILL.md'), '---\nname: gsd-dev-preferences\ndescription: "User pref"\n---\nBody.\n');
|
||||
const userYaml = 'interface:\n display_name: "my preferences"\n short_description: "User-authored"\n';
|
||||
fs.writeFileSync(path.join(agentsSubdir, 'openai.yaml'), userYaml);
|
||||
|
||||
writeCodexSkillMetadataFiles(tmpDir);
|
||||
|
||||
// User-authored file must remain unchanged
|
||||
const after = fs.readFileSync(path.join(agentsSubdir, 'openai.yaml'), 'utf8');
|
||||
assert.strictEqual(after, userYaml, 'user-owned gsd-dev-preferences/agents/openai.yaml must not be overwritten');
|
||||
});
|
||||
|
||||
test('does not create agents/openai.yaml for gsd-dev-preferences if absent (#774)', () => {
|
||||
// Even if gsd-dev-preferences exists without an openai.yaml, we must not create one
|
||||
const userOwnedDir = path.join(tmpDir, 'gsd-dev-preferences');
|
||||
fs.mkdirSync(userOwnedDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(userOwnedDir, 'SKILL.md'), '---\nname: gsd-dev-preferences\n---\nBody.\n');
|
||||
|
||||
writeCodexSkillMetadataFiles(tmpDir);
|
||||
|
||||
assert.ok(!fs.existsSync(path.join(userOwnedDir, 'agents', 'openai.yaml')),
|
||||
'gsd-dev-preferences must not get agents/openai.yaml even if it was absent');
|
||||
});
|
||||
|
||||
test('is a no-op when skillsDir does not exist (#774)', () => {
|
||||
// Should not throw when the directory doesn't exist
|
||||
assert.doesNotThrow(() => {
|
||||
writeCodexSkillMetadataFiles(path.join(tmpDir, 'nonexistent'));
|
||||
}, 'must not throw when skillsDir does not exist');
|
||||
});
|
||||
|
||||
test('skips skill dirs with missing SKILL.md without throwing (#774)', () => {
|
||||
// Create a gsd-* dir with no SKILL.md — should fail open
|
||||
const emptySkillDir = path.join(tmpDir, 'gsd-empty');
|
||||
fs.mkdirSync(emptySkillDir, { recursive: true });
|
||||
|
||||
assert.doesNotThrow(() => {
|
||||
writeCodexSkillMetadataFiles(tmpDir);
|
||||
}, 'must not throw when SKILL.md is missing');
|
||||
});
|
||||
|
||||
test('display_name in agents/openai.yaml has gsd- prefix stripped (#774)', () => {
|
||||
const skillDir = path.join(tmpDir, 'gsd-plan-phase');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
const skillMd = `---\nname: gsd-plan-phase\ndescription: "Plan the phase."\nmetadata:\n short-description: "Plan the phase."\n---\n\nBody.\n`;
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillMd);
|
||||
|
||||
writeCodexSkillMetadataFiles(tmpDir);
|
||||
|
||||
const yamlContent = fs.readFileSync(path.join(tmpDir, 'gsd-plan-phase', 'agents', 'openai.yaml'), 'utf8');
|
||||
const parsed = jsYaml.load(yamlContent);
|
||||
assert.strictEqual(parsed.interface.display_name, 'plan phase',
|
||||
'display_name must have gsd- stripped and hyphens→spaces');
|
||||
});
|
||||
|
||||
test('correctly unescapes YAML-quoted short-description from SKILL.md (#774)', () => {
|
||||
// convertClaudeCommandToCodexSkill emits double-quoted YAML for descriptions.
|
||||
// Verify that escape sequences like \" in the YAML source round-trip to literal " in output.
|
||||
const skillDir = path.join(tmpDir, 'gsd-test-esc');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
// SKILL.md has a YAML-escaped double-quote in short-description
|
||||
const skillMd = '---\nname: gsd-test-esc\ndescription: "Normal"\nmetadata:\n short-description: "Run \\"special\\" workflow."\n---\n\nBody.\n';
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillMd);
|
||||
|
||||
writeCodexSkillMetadataFiles(tmpDir);
|
||||
|
||||
const yamlContent = fs.readFileSync(path.join(tmpDir, 'gsd-test-esc', 'agents', 'openai.yaml'), 'utf8');
|
||||
const parsed = jsYaml.load(yamlContent);
|
||||
assert.strictEqual(parsed.interface.short_description, 'Run "special" workflow.',
|
||||
'YAML-escaped quotes in SKILL.md must round-trip to literal quotes in agents/openai.yaml');
|
||||
});
|
||||
|
||||
test('Codex install emits agents/openai.yaml for each skill (#774)', () => {
|
||||
// Integration test: run a full Codex install and verify agents/openai.yaml is written.
|
||||
// Use runCodexInstall so CODEX_HOME is saved and restored correctly even if it was
|
||||
// already set in the environment before this test ran.
|
||||
const codexHome = path.join(tmpDir, 'codex-home');
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
runCodexInstall(codexHome);
|
||||
const skillsDir = path.join(codexHome, 'skills');
|
||||
// Assert that the install actually created a skills directory
|
||||
assert.ok(fs.existsSync(skillsDir), 'Codex install must create a skills/ directory');
|
||||
const gsdSkillDirs = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
|
||||
// At least some skills should be present
|
||||
assert.ok(gsdSkillDirs.length > 0, 'install must create at least one gsd-* skill directory');
|
||||
// Each skill directory must have agents/openai.yaml with valid YAML
|
||||
for (const skillEntry of gsdSkillDirs) {
|
||||
const yamlPath = path.join(skillsDir, skillEntry.name, 'agents', 'openai.yaml');
|
||||
assert.ok(fs.existsSync(yamlPath), `${skillEntry.name}/agents/openai.yaml must exist after install`);
|
||||
const content = fs.readFileSync(yamlPath, 'utf8');
|
||||
const parsed = jsYaml.load(content);
|
||||
assert.ok(parsed && parsed.interface, `${skillEntry.name}/agents/openai.yaml must parse to object with interface:`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
317
tests/enh-769-context-fork-effort.install.test.cjs
Normal file
317
tests/enh-769-context-fork-effort.install.test.cjs
Normal file
@@ -0,0 +1,317 @@
|
||||
// allow-test-rule: integration-test-input
|
||||
// Exercises install() as a black-box by inspecting produced SKILL.md output
|
||||
// in a temp dir. Source command .md files are inputs whose installed
|
||||
// transformation is asserted — not inspected for string presence.
|
||||
|
||||
/**
|
||||
* #769 — context:fork + effort: frontmatter on heavy workflow skills.
|
||||
*
|
||||
* Verifies:
|
||||
* 1. Source commands/gsd/autonomous.md has context: fork and effort: xhigh
|
||||
* 2. Source commands/gsd/execute-phase.md has context: fork and effort: xhigh
|
||||
* 3. Source commands/gsd/plan-phase.md has context: fork and effort: xhigh
|
||||
* 4. Source commands/gsd/progress.md has effort: low
|
||||
* 5. Source commands/gsd/stats.md has effort: low
|
||||
* 6. Claude global install: SKILL.md for autonomous has context: fork and effort: xhigh
|
||||
* 7. Claude global install: SKILL.md for execute-phase has context: fork and effort: xhigh
|
||||
* 8. Claude global install: SKILL.md for plan-phase has context: fork and effort: xhigh
|
||||
* 9. Claude global install: SKILL.md for progress has effort: low
|
||||
* 10. Claude global install: SKILL.md for stats has effort: low
|
||||
* 11. convertClaudeCommandToClaudeSkill preserves context: fork field
|
||||
* 12. convertClaudeCommandToClaudeSkill preserves effort: field
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const { install, convertClaudeCommandToClaudeSkill } = require('../bin/install.js');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const SOURCE_COMMANDS_DIR = path.join(REPO_ROOT, 'commands', 'gsd');
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeTmpDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function readFrontmatter(mdPath) {
|
||||
const content = fs.readFileSync(mdPath, 'utf8');
|
||||
if (!content.startsWith('---')) return '';
|
||||
const end = content.indexOf('---', 3);
|
||||
if (end === -1) return '';
|
||||
return content.substring(3, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a global install for Claude, redirecting its home dir to tmpHome.
|
||||
* Returns the tmpHome for inspection.
|
||||
*/
|
||||
function runClaudeGlobalInstall(claudeHome) {
|
||||
const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-769-home-'));
|
||||
|
||||
const prevCwd = process.cwd();
|
||||
const prevClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
const prevHome = process.env.HOME;
|
||||
const prevUserProfile = process.env.USERPROFILE;
|
||||
const prevSkipStale = process.env.GSD_SKIP_STALE_SDK_CHECK;
|
||||
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeHome;
|
||||
process.env.HOME = isolatedHome;
|
||||
process.env.USERPROFILE = isolatedHome;
|
||||
process.env.GSD_SKIP_STALE_SDK_CHECK = '1';
|
||||
process.chdir(REPO_ROOT);
|
||||
|
||||
try {
|
||||
install(true, 'claude');
|
||||
} finally {
|
||||
process.chdir(prevCwd);
|
||||
if (prevClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
||||
else process.env.CLAUDE_CONFIG_DIR = prevClaudeConfigDir;
|
||||
if (prevHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = prevHome;
|
||||
if (prevUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = prevUserProfile;
|
||||
if (prevSkipStale === undefined) delete process.env.GSD_SKIP_STALE_SDK_CHECK;
|
||||
else process.env.GSD_SKIP_STALE_SDK_CHECK = prevSkipStale;
|
||||
cleanup(isolatedHome);
|
||||
}
|
||||
|
||||
return claudeHome;
|
||||
}
|
||||
|
||||
// ─── describe 1: Source command files have correct frontmatter ────────────────
|
||||
|
||||
describe('#769 source commands: heavy skills have context: fork and effort: xhigh', () => {
|
||||
test('commands/gsd/autonomous.md has context: fork', () => {
|
||||
const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'autonomous.md'));
|
||||
assert.match(fm, /^context:[ \t]*fork$/m,
|
||||
`autonomous.md frontmatter must have context: fork\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('commands/gsd/autonomous.md has effort: xhigh', () => {
|
||||
const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'autonomous.md'));
|
||||
assert.match(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`autonomous.md frontmatter must have effort: xhigh\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('commands/gsd/execute-phase.md has context: fork', () => {
|
||||
const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'execute-phase.md'));
|
||||
assert.match(fm, /^context:[ \t]*fork$/m,
|
||||
`execute-phase.md frontmatter must have context: fork\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('commands/gsd/execute-phase.md has effort: xhigh', () => {
|
||||
const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'execute-phase.md'));
|
||||
assert.match(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`execute-phase.md frontmatter must have effort: xhigh\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('commands/gsd/plan-phase.md has context: fork', () => {
|
||||
const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'plan-phase.md'));
|
||||
assert.match(fm, /^context:[ \t]*fork$/m,
|
||||
`plan-phase.md frontmatter must have context: fork\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('commands/gsd/plan-phase.md has effort: xhigh', () => {
|
||||
const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'plan-phase.md'));
|
||||
assert.match(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`plan-phase.md frontmatter must have effort: xhigh\nActual:\n${fm}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#769 source commands: quick-status skills have effort: low', () => {
|
||||
test('commands/gsd/progress.md has effort: low', () => {
|
||||
const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'progress.md'));
|
||||
assert.match(fm, /^effort:[ \t]*low$/m,
|
||||
`progress.md frontmatter must have effort: low\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('commands/gsd/stats.md has effort: low', () => {
|
||||
const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'stats.md'));
|
||||
assert.match(fm, /^effort:[ \t]*low$/m,
|
||||
`stats.md frontmatter must have effort: low\nActual:\n${fm}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── describe 2: convertClaudeCommandToClaudeSkill preserves new fields ───────
|
||||
|
||||
describe('#769 convertClaudeCommandToClaudeSkill: preserves context and effort fields', () => {
|
||||
test('preserves context: fork in emitted SKILL.md frontmatter', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:test-heavy',
|
||||
'description: Test heavy skill',
|
||||
'context: fork',
|
||||
'effort: xhigh',
|
||||
'allowed-tools:',
|
||||
' - Read',
|
||||
' - Bash',
|
||||
'---',
|
||||
'',
|
||||
'Heavy skill body.',
|
||||
].join('\n');
|
||||
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'test-heavy');
|
||||
const end = result.indexOf('---', 3);
|
||||
const fm = result.substring(3, end);
|
||||
|
||||
assert.match(fm, /^context:[ \t]*fork$/m,
|
||||
`SKILL.md frontmatter must include context: fork\nActual frontmatter:\n${fm}`);
|
||||
});
|
||||
|
||||
test('preserves effort: xhigh in emitted SKILL.md frontmatter', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:test-heavy',
|
||||
'description: Test heavy skill',
|
||||
'context: fork',
|
||||
'effort: xhigh',
|
||||
'allowed-tools:',
|
||||
' - Read',
|
||||
' - Bash',
|
||||
'---',
|
||||
'',
|
||||
'Heavy skill body.',
|
||||
].join('\n');
|
||||
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'test-heavy');
|
||||
const end = result.indexOf('---', 3);
|
||||
const fm = result.substring(3, end);
|
||||
|
||||
assert.match(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`SKILL.md frontmatter must include effort: xhigh\nActual frontmatter:\n${fm}`);
|
||||
});
|
||||
|
||||
test('preserves effort: low in emitted SKILL.md frontmatter', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:test-light',
|
||||
'description: Test light skill',
|
||||
'effort: low',
|
||||
'allowed-tools:',
|
||||
' - Read',
|
||||
'---',
|
||||
'',
|
||||
'Light skill body.',
|
||||
].join('\n');
|
||||
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'test-light');
|
||||
const end = result.indexOf('---', 3);
|
||||
const fm = result.substring(3, end);
|
||||
|
||||
assert.match(fm, /^effort:[ \t]*low$/m,
|
||||
`SKILL.md frontmatter must include effort: low\nActual frontmatter:\n${fm}`);
|
||||
});
|
||||
|
||||
test('does NOT emit context: or effort: when absent from source', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:test-plain',
|
||||
'description: Plain skill without context or effort',
|
||||
'allowed-tools:',
|
||||
' - Read',
|
||||
'---',
|
||||
'',
|
||||
'Plain skill body.',
|
||||
].join('\n');
|
||||
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'test-plain');
|
||||
const end = result.indexOf('---', 3);
|
||||
const fm = result.substring(3, end);
|
||||
|
||||
assert.doesNotMatch(fm, /^context:/m,
|
||||
`SKILL.md must not emit context: when absent from source\nActual:\n${fm}`);
|
||||
assert.doesNotMatch(fm, /^effort:/m,
|
||||
`SKILL.md must not emit effort: when absent from source\nActual:\n${fm}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── describe 3: Claude global install — SKILL.md files include new fields ────
|
||||
|
||||
describe('#769 Claude global install: SKILL.md files preserve context: fork and effort:', () => {
|
||||
let tmpDir;
|
||||
let claudeHome;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmpDir('gsd-769-claude-');
|
||||
claudeHome = path.join(tmpDir, 'claude-home');
|
||||
fs.mkdirSync(claudeHome, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('gsd-autonomous SKILL.md has context: fork after global install', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = path.join(claudeHome, 'skills', 'gsd-autonomous', 'SKILL.md');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.match(fm, /^context:[ \t]*fork$/m,
|
||||
`gsd-autonomous SKILL.md must have context: fork\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('gsd-autonomous SKILL.md has effort: xhigh after global install', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = path.join(claudeHome, 'skills', 'gsd-autonomous', 'SKILL.md');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.match(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`gsd-autonomous SKILL.md must have effort: xhigh\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('gsd-execute-phase SKILL.md has context: fork after global install', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = path.join(claudeHome, 'skills', 'gsd-execute-phase', 'SKILL.md');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.match(fm, /^context:[ \t]*fork$/m,
|
||||
`gsd-execute-phase SKILL.md must have context: fork\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('gsd-execute-phase SKILL.md has effort: xhigh after global install', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = path.join(claudeHome, 'skills', 'gsd-execute-phase', 'SKILL.md');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.match(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`gsd-execute-phase SKILL.md must have effort: xhigh\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('gsd-plan-phase SKILL.md has context: fork after global install', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = path.join(claudeHome, 'skills', 'gsd-plan-phase', 'SKILL.md');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.match(fm, /^context:[ \t]*fork$/m,
|
||||
`gsd-plan-phase SKILL.md must have context: fork\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('gsd-plan-phase SKILL.md has effort: xhigh after global install', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = path.join(claudeHome, 'skills', 'gsd-plan-phase', 'SKILL.md');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.match(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`gsd-plan-phase SKILL.md must have effort: xhigh\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('gsd-progress SKILL.md has effort: low after global install', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = path.join(claudeHome, 'skills', 'gsd-progress', 'SKILL.md');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.match(fm, /^effort:[ \t]*low$/m,
|
||||
`gsd-progress SKILL.md must have effort: low\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('gsd-stats SKILL.md has effort: low after global install', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = path.join(claudeHome, 'skills', 'gsd-stats', 'SKILL.md');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.match(fm, /^effort:[ \t]*low$/m,
|
||||
`gsd-stats SKILL.md must have effort: low\nActual:\n${fm}`);
|
||||
});
|
||||
});
|
||||
411
tests/enh-772-codex-hook-events.test.cjs
Normal file
411
tests/enh-772-codex-hook-events.test.cjs
Normal file
@@ -0,0 +1,411 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Enhancement #772: Adopt new stable Codex hook events + commandWindows for
|
||||
* Windows parity.
|
||||
*
|
||||
* Codex CLI (rust-v0.137.0) stabilised the full hook-event set. This suite
|
||||
* asserts that a Codex install:
|
||||
*
|
||||
* (a) Registers the 3 new high-value hook events in hooks.json:
|
||||
* - SubagentStart — inject context / GSD_AGENT_NAME awareness at subagent open
|
||||
* - Stop — post-session context headroom tracking
|
||||
* - PostToolUse — mirror the Claude Code PostToolUse context monitor
|
||||
*
|
||||
* (b) Emits `commandWindows` in the SessionStart hooks.json entry so that
|
||||
* Windows users get the .cmd shim path and non-Windows users get the POSIX
|
||||
* node runner command. Both fields are present in the same entry; Codex picks
|
||||
* the right one per its HookHandlerConfig schema
|
||||
* (codex-rs/config/src/hook_config.rs: commandWindows / command_windows alias).
|
||||
*
|
||||
* Note: UserPromptSubmit is NOT wired (same rationale as Qwen #788 — the
|
||||
* gsd-prompt-guard handler exits unless tool_name is Write|Edit, so it would be
|
||||
* a silent no-op for the UserPromptSubmit payload shape).
|
||||
*
|
||||
* Test strategy:
|
||||
* - Test new event registration via ensureCodexHooksJsonEvent() directly
|
||||
* (mirrors the #3426 pattern of testing ensureCodexHooksJsonSessionStart
|
||||
* directly with a stub hook file — avoids full install() migration dance).
|
||||
* - Test commandWindows via ensureCodexHooksJsonSessionStart() directly.
|
||||
* - IR-first discipline: assert on the structured result, not rendered text.
|
||||
*
|
||||
* Verified hook event schema:
|
||||
* https://github.com/openai/codex/blob/main/codex-rs/protocol/src/protocol.rs
|
||||
* https://github.com/openai/codex/blob/main/codex/codex-rs/config/src/hook_config.rs
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const INSTALL = require('../bin/install.js');
|
||||
const {
|
||||
ensureCodexHooksJsonSessionStart,
|
||||
ensureCodexHooksJsonEvent,
|
||||
removeCodexHooksJsonEvent,
|
||||
reconcileCodexHooksJsonEvent,
|
||||
} = INSTALL;
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract all hook handler entries (full objects with type/command/etc.) for
|
||||
* `eventName` from a hooks.json object (flat or nested-hooks shape).
|
||||
*/
|
||||
function hooksJsonHandlersForEvent(hooksJson, eventName) {
|
||||
if (!hooksJson || typeof hooksJson !== 'object') return [];
|
||||
const table =
|
||||
hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks)
|
||||
? hooksJson.hooks
|
||||
: hooksJson;
|
||||
if (!Array.isArray(table[eventName])) return [];
|
||||
return table[eventName].flatMap(entry =>
|
||||
Array.isArray(entry && entry.hooks) ? entry.hooks : []
|
||||
);
|
||||
}
|
||||
|
||||
function readHooksJson(targetDir) {
|
||||
const p = path.join(targetDir, 'hooks.json');
|
||||
if (!fs.existsSync(p)) return null;
|
||||
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
||||
}
|
||||
|
||||
function stubHookFile(targetDir, hookName) {
|
||||
const hooksDest = path.join(targetDir, 'hooks');
|
||||
fs.mkdirSync(hooksDest, { recursive: true });
|
||||
const dest = path.join(hooksDest, hookName);
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.writeFileSync(dest, '#!/usr/bin/env node\n// stub\n');
|
||||
try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Suite 1: ensureCodexHooksJsonEvent export surface ───────────────────────
|
||||
|
||||
describe('enh-772: export surface — new functions are exported', () => {
|
||||
test('ensureCodexHooksJsonEvent is a function', () => {
|
||||
assert.strictEqual(typeof ensureCodexHooksJsonEvent, 'function',
|
||||
'ensureCodexHooksJsonEvent must be exported from bin/install.js');
|
||||
});
|
||||
|
||||
test('removeCodexHooksJsonEvent is a function', () => {
|
||||
assert.strictEqual(typeof removeCodexHooksJsonEvent, 'function',
|
||||
'removeCodexHooksJsonEvent must be exported from bin/install.js');
|
||||
});
|
||||
|
||||
test('reconcileCodexHooksJsonEvent is a function', () => {
|
||||
assert.strictEqual(typeof reconcileCodexHooksJsonEvent, 'function',
|
||||
'reconcileCodexHooksJsonEvent must be exported from bin/install.js');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 2: ensureCodexHooksJsonEvent registers new events ─────────────────
|
||||
|
||||
describe('enh-772: ensureCodexHooksJsonEvent registers SubagentStart, Stop, PostToolUse', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-772-events-');
|
||||
stubHookFile(tmpDir, 'gsd-context-monitor.js');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) {
|
||||
test(`${eventName}: ensureCodexHooksJsonEvent writes hooks.json`, () => {
|
||||
const fakeRunner = '"/usr/local/bin/node"';
|
||||
const result = ensureCodexHooksJsonEvent(tmpDir, eventName, {
|
||||
absoluteRunner: fakeRunner,
|
||||
platform: 'linux',
|
||||
});
|
||||
assert.ok(result && result.path, `result must have path for ${eventName}`);
|
||||
assert.ok(result.wrote || result.changed,
|
||||
`ensureCodexHooksJsonEvent must write or change hooks.json for ${eventName}`);
|
||||
assert.ok(fs.existsSync(path.join(tmpDir, 'hooks.json')),
|
||||
`hooks.json must exist after registering ${eventName}`);
|
||||
});
|
||||
|
||||
test(`${eventName}: hooks.json contains the event entry`, () => {
|
||||
const fakeRunner = '"/usr/local/bin/node"';
|
||||
ensureCodexHooksJsonEvent(tmpDir, eventName, {
|
||||
absoluteRunner: fakeRunner,
|
||||
platform: 'linux',
|
||||
});
|
||||
const hooksJson = readHooksJson(tmpDir);
|
||||
const handlers = hooksJsonHandlersForEvent(hooksJson, eventName);
|
||||
assert.ok(handlers.length > 0,
|
||||
`Expected ${eventName} entry in hooks.json; got: ${JSON.stringify(hooksJson)}`);
|
||||
});
|
||||
|
||||
test(`${eventName}: hook entry uses gsd-context-monitor`, () => {
|
||||
const fakeRunner = '"/usr/local/bin/node"';
|
||||
ensureCodexHooksJsonEvent(tmpDir, eventName, {
|
||||
absoluteRunner: fakeRunner,
|
||||
platform: 'linux',
|
||||
});
|
||||
const hooksJson = readHooksJson(tmpDir);
|
||||
const handlers = hooksJsonHandlersForEvent(hooksJson, eventName);
|
||||
assert.ok(
|
||||
handlers.some(h => h.command && h.command.includes('gsd-context-monitor')),
|
||||
`${eventName} hook must use gsd-context-monitor; got: ${JSON.stringify(handlers)}`
|
||||
);
|
||||
});
|
||||
|
||||
test(`${eventName}: hook entry has type: 'command'`, () => {
|
||||
const fakeRunner = '"/usr/local/bin/node"';
|
||||
ensureCodexHooksJsonEvent(tmpDir, eventName, {
|
||||
absoluteRunner: fakeRunner,
|
||||
platform: 'linux',
|
||||
});
|
||||
const hooksJson = readHooksJson(tmpDir);
|
||||
const handlers = hooksJsonHandlersForEvent(hooksJson, eventName);
|
||||
const entry = handlers.find(h => h.command && h.command.includes('gsd-context-monitor'));
|
||||
assert.strictEqual(entry && entry.type, 'command',
|
||||
`${eventName} hook entry must have type 'command'`);
|
||||
});
|
||||
|
||||
test(`${eventName}: hook entry has timeout: 10`, () => {
|
||||
const fakeRunner = '"/usr/local/bin/node"';
|
||||
ensureCodexHooksJsonEvent(tmpDir, eventName, {
|
||||
absoluteRunner: fakeRunner,
|
||||
platform: 'linux',
|
||||
});
|
||||
const hooksJson = readHooksJson(tmpDir);
|
||||
const handlers = hooksJsonHandlersForEvent(hooksJson, eventName);
|
||||
const entry = handlers.find(h => h.command && h.command.includes('gsd-context-monitor'));
|
||||
assert.strictEqual(entry && entry.timeout, 10,
|
||||
`${eventName} hook entry must have timeout 10`);
|
||||
});
|
||||
}
|
||||
|
||||
test('null absoluteRunner returns unchanged result without writing', () => {
|
||||
const result = ensureCodexHooksJsonEvent(tmpDir, 'SubagentStart', {
|
||||
absoluteRunner: null,
|
||||
platform: 'linux',
|
||||
});
|
||||
assert.strictEqual(result.changed, false,
|
||||
'null runner must return changed: false');
|
||||
assert.ok(!fs.existsSync(path.join(tmpDir, 'hooks.json')),
|
||||
'hooks.json must NOT be written when runner is null');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 3: commandWindows parity in SessionStart ──────────────────────────
|
||||
|
||||
describe('enh-772: commandWindows parity — ensureCodexHooksJsonSessionStart emits commandWindows', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-772-cmdwin-');
|
||||
stubHookFile(tmpDir, 'gsd-check-update.js');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
// commandWindows is ONLY emitted on win32 platform (where the .cmd shim is also
|
||||
// written). On POSIX platforms, commandWindows is omitted to avoid pointing Windows
|
||||
// Codex at a non-existent .cmd file (the shim is only present after a native Windows
|
||||
// install that runs buildCodexHookWindowsShimIR and atomicWriteFileSync).
|
||||
|
||||
test('POSIX platform: commandWindows is NOT emitted (shim not written on POSIX)', () => {
|
||||
const fakeRunner = '"/usr/local/bin/node"';
|
||||
const result = ensureCodexHooksJsonSessionStart(tmpDir, {
|
||||
absoluteRunner: fakeRunner,
|
||||
platform: 'linux',
|
||||
});
|
||||
assert.ok(result && result.wrote, 'must write hooks.json on linux');
|
||||
|
||||
const hooksJson = readHooksJson(tmpDir);
|
||||
const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart');
|
||||
assert.ok(handlers.length > 0, `Expected SessionStart handlers; got: ${JSON.stringify(hooksJson)}`);
|
||||
|
||||
const entry = handlers[0];
|
||||
assert.ok(
|
||||
entry.commandWindows === undefined,
|
||||
`commandWindows must NOT be emitted on POSIX (shim not written); got: ${JSON.stringify(entry)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('POSIX platform: command references gsd-check-update.js (not .cmd)', () => {
|
||||
const fakeRunner = '"/usr/local/bin/node"';
|
||||
ensureCodexHooksJsonSessionStart(tmpDir, {
|
||||
absoluteRunner: fakeRunner,
|
||||
platform: 'linux',
|
||||
});
|
||||
const hooksJson = readHooksJson(tmpDir);
|
||||
const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart');
|
||||
const entry = handlers[0];
|
||||
assert.ok(
|
||||
entry.command && entry.command.includes('gsd-check-update'),
|
||||
`POSIX command must reference gsd-check-update; got: ${entry.command}`
|
||||
);
|
||||
assert.ok(
|
||||
!entry.command.endsWith('.cmd') && !entry.command.endsWith('.cmd"'),
|
||||
`POSIX command must not end with .cmd; got: ${entry.command}`
|
||||
);
|
||||
});
|
||||
|
||||
test('null absoluteRunner: no commandWindows emitted, no write', () => {
|
||||
const result = ensureCodexHooksJsonSessionStart(tmpDir, {
|
||||
absoluteRunner: null,
|
||||
platform: 'linux',
|
||||
});
|
||||
assert.strictEqual(result.changed, false, 'null runner must return changed: false');
|
||||
const hooksJson = readHooksJson(tmpDir);
|
||||
if (hooksJson) {
|
||||
const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart');
|
||||
for (const h of handlers) {
|
||||
assert.ok(!h.commandWindows,
|
||||
`commandWindows must not be present when runner is null; got: ${JSON.stringify(h)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows platform: SessionStart hook is written with commandWindows pointing to .cmd shim', () => {
|
||||
// On win32, both `command` and `commandWindows` use the .cmd shim path
|
||||
// (because managedCommand = shimIR.hookCommand = .cmd path, and
|
||||
// commandWindows = same .cmd path). This ensures Codex picks the .cmd
|
||||
// on Windows regardless of which field it reads.
|
||||
const fakeRunner = '"C:/Program Files/nodejs/node.exe"';
|
||||
const result = ensureCodexHooksJsonSessionStart(tmpDir, {
|
||||
absoluteRunner: fakeRunner,
|
||||
platform: 'win32',
|
||||
});
|
||||
// The shim write and hooks.json write should succeed in the tmp dir.
|
||||
if (result.wrote) {
|
||||
const hooksJson = readHooksJson(tmpDir);
|
||||
const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart');
|
||||
assert.ok(handlers.length > 0,
|
||||
`SessionStart must be registered on Windows path; got: ${JSON.stringify(hooksJson)}`);
|
||||
const entry = handlers[0];
|
||||
assert.ok(typeof entry.commandWindows === 'string',
|
||||
`commandWindows must be present on Windows path; got: ${JSON.stringify(entry)}`);
|
||||
// commandWindows should reference the .cmd shim
|
||||
assert.ok(
|
||||
entry.commandWindows.includes('gsd-check-update') && entry.commandWindows.includes('.cmd'),
|
||||
`commandWindows must reference gsd-check-update.cmd on win32; got: ${entry.commandWindows}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 4: idempotency ────────────────────────────────────────────────────
|
||||
|
||||
describe('enh-772: ensureCodexHooksJsonEvent is idempotent', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-772-idem-');
|
||||
stubHookFile(tmpDir, 'gsd-context-monitor.js');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) {
|
||||
test(`${eventName}: calling twice does not duplicate hook entries`, () => {
|
||||
const fakeRunner = '"/usr/local/bin/node"';
|
||||
const opts = { absoluteRunner: fakeRunner, platform: 'linux' };
|
||||
|
||||
ensureCodexHooksJsonEvent(tmpDir, eventName, opts);
|
||||
ensureCodexHooksJsonEvent(tmpDir, eventName, opts);
|
||||
|
||||
const hooksJson = readHooksJson(tmpDir);
|
||||
const handlers = hooksJsonHandlersForEvent(hooksJson, eventName);
|
||||
assert.strictEqual(handlers.length, 1,
|
||||
`${eventName} should have exactly 1 hook handler after idempotent re-register; got ${handlers.length}: ${JSON.stringify(handlers)}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Suite 5: removeCodexHooksJsonEvent ──────────────────────────────────────
|
||||
|
||||
describe('enh-772: removeCodexHooksJsonEvent removes managed entries', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-772-remove-');
|
||||
stubHookFile(tmpDir, 'gsd-context-monitor.js');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) {
|
||||
test(`${eventName}: removeCodexHooksJsonEvent removes the managed entry`, () => {
|
||||
const fakeRunner = '"/usr/local/bin/node"';
|
||||
ensureCodexHooksJsonEvent(tmpDir, eventName, {
|
||||
absoluteRunner: fakeRunner,
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
// Verify it was registered
|
||||
let hooksJson = readHooksJson(tmpDir);
|
||||
let handlers = hooksJsonHandlersForEvent(hooksJson, eventName);
|
||||
assert.ok(handlers.length > 0, `${eventName} must be registered before removal`);
|
||||
|
||||
// Remove
|
||||
const result = removeCodexHooksJsonEvent(tmpDir, eventName);
|
||||
assert.ok(result.changed || result.wrote,
|
||||
`removeCodexHooksJsonEvent must change hooks.json for ${eventName}`);
|
||||
|
||||
hooksJson = readHooksJson(tmpDir);
|
||||
if (hooksJson) {
|
||||
handlers = hooksJsonHandlersForEvent(hooksJson, eventName);
|
||||
assert.strictEqual(handlers.length, 0,
|
||||
`After removal, ${eventName} should have 0 handlers; got: ${JSON.stringify(handlers)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Suite 6: reconcileCodexHooksJsonEvent preserves user entries ─────────────
|
||||
|
||||
describe('enh-772: reconcileCodexHooksJsonEvent preserves user-owned entries', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-772-preserve-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('user-owned SubagentStart entry is preserved when GSD entry is registered', () => {
|
||||
const hooksJsonPath = path.join(tmpDir, 'hooks.json');
|
||||
const userEntry = {
|
||||
hooks: [{ type: 'command', command: 'my-custom-hook.sh' }]
|
||||
};
|
||||
fs.writeFileSync(hooksJsonPath, JSON.stringify({
|
||||
SubagentStart: [userEntry]
|
||||
}, null, 2) + '\n');
|
||||
|
||||
reconcileCodexHooksJsonEvent(tmpDir, 'SubagentStart', {
|
||||
managedCommand: '"/usr/local/bin/node" "/home/me/.codex/hooks/gsd-context-monitor.js"',
|
||||
});
|
||||
|
||||
const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
||||
const table = hooksJson.hooks || hooksJson;
|
||||
const entries = Array.isArray(table.SubagentStart) ? table.SubagentStart : [];
|
||||
// Should have 2 entries: user entry + GSD entry
|
||||
assert.ok(entries.length >= 2,
|
||||
`User entry must be preserved; got entries: ${JSON.stringify(entries)}`);
|
||||
// User entry must still be present
|
||||
const userEntryStillPresent = entries.some(e =>
|
||||
Array.isArray(e.hooks) && e.hooks.some(h => h.command === 'my-custom-hook.sh')
|
||||
);
|
||||
assert.ok(userEntryStillPresent,
|
||||
`User entry must survive GSD registration; entries: ${JSON.stringify(entries)}`);
|
||||
});
|
||||
});
|
||||
68
tests/enh-773-codex-exec-automation-flags.test.cjs
Normal file
68
tests/enh-773-codex-exec-automation-flags.test.cjs
Normal file
@@ -0,0 +1,68 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow markdown is runtime contract; these assertions verify that
|
||||
// automated codex exec invocations carry the correct automation flags.
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
describe('enh-773: automated codex exec invocations include --ephemeral and --dangerously-bypass-hook-trust', () => {
|
||||
const workflow = fs.readFileSync(
|
||||
path.join(process.cwd(), 'gsd-core', 'workflows', 'review.md'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
// Extract all codex exec invocation lines from code fences
|
||||
const codexExecLines = workflow
|
||||
.split('\n')
|
||||
.filter((line) => line.includes('codex exec'));
|
||||
|
||||
test('review.md contains at least one codex exec invocation', () => {
|
||||
assert.ok(
|
||||
codexExecLines.length > 0,
|
||||
'review.md must contain at least one codex exec invocation'
|
||||
);
|
||||
});
|
||||
|
||||
test('every codex exec invocation includes --ephemeral', () => {
|
||||
for (const line of codexExecLines) {
|
||||
assert.ok(
|
||||
line.includes('--ephemeral'),
|
||||
`codex exec invocation is missing --ephemeral:\n ${line.trim()}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('every codex exec invocation includes --dangerously-bypass-hook-trust', () => {
|
||||
for (const line of codexExecLines) {
|
||||
assert.ok(
|
||||
line.includes('--dangerously-bypass-hook-trust'),
|
||||
`codex exec invocation is missing --dangerously-bypass-hook-trust:\n ${line.trim()}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('--ephemeral appears before the prompt argument (flag ordering)', () => {
|
||||
for (const line of codexExecLines) {
|
||||
const ephemeralPos = line.indexOf('--ephemeral');
|
||||
const promptPos = line.indexOf(' - ');
|
||||
if (promptPos === -1) continue; // no stdin prompt arg on this line
|
||||
assert.ok(
|
||||
ephemeralPos < promptPos,
|
||||
`--ephemeral must appear before the stdin prompt argument:\n ${line.trim()}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('--skip-git-repo-check is preserved alongside automation flags', () => {
|
||||
for (const line of codexExecLines) {
|
||||
assert.ok(
|
||||
line.includes('--skip-git-repo-check'),
|
||||
`codex exec invocation lost --skip-git-repo-check:\n ${line.trim()}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
227
tests/enh-778-cross-runtime-command-enrichment.test.cjs
Normal file
227
tests/enh-778-cross-runtime-command-enrichment.test.cjs
Normal file
@@ -0,0 +1,227 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Reads .md/SKILL.md/.toml product files whose deployed text IS what the
|
||||
// runtime loads — testing text content tests the deployed contract.
|
||||
|
||||
/**
|
||||
* GSD Tools Tests — #778 cross-runtime command enrichment.
|
||||
*
|
||||
* Two independently-verified, additive sub-features:
|
||||
* (b) Qwen Code skills: numeric `priority` field (higher sorts earlier in the
|
||||
* /skills TUI listing per the Qwen skills spec). Scoped to runtime='qwen'.
|
||||
* (c) Gemini custom-command TOML: $ARGUMENTS → {{args}} interpolation, and a
|
||||
* fixed `!{cat .planning/STATE.md}` live-state injection on the
|
||||
* situational `progress` command (injection-safe — no interpolated input).
|
||||
*
|
||||
* The OpenCode sub-feature (per-command model/agent/subtask/variant) is
|
||||
* intentionally NOT implemented — see PR description: `model` reintroduces the
|
||||
* #1156 ProviderModelNotFoundError regression for non-Anthropic OpenCode users,
|
||||
* `subtask`/`agent` change execution semantics for GSD's interactive commands,
|
||||
* and `variant` is not in the OpenCode command schema.
|
||||
*
|
||||
* Uses node:test and node:assert (NOT Jest).
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
convertClaudeCommandToClaudeSkill,
|
||||
convertClaudeToGeminiMarkdown,
|
||||
install,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
// ─── (b) Qwen Code: priority ordering ───────────────────────────────────────
|
||||
|
||||
describe('#778 (b) Qwen skills priority', () => {
|
||||
const mk = (name, desc, body) =>
|
||||
['---', `name: gsd:${name}`, `description: ${desc}`, '---', '', body].join('\n');
|
||||
|
||||
test('emits numeric priority for a core-loop command (runtime=qwen)', () => {
|
||||
const result = convertClaudeCommandToClaudeSkill(
|
||||
mk('plan-phase', 'Plan a phase', 'Body.'),
|
||||
'gsd-plan-phase',
|
||||
'qwen',
|
||||
[]
|
||||
);
|
||||
const m = result.match(/^priority:\s*(\d+)\s*$/m);
|
||||
assert.ok(m, 'priority field present for gsd-plan-phase');
|
||||
assert.equal(Number(m[1]) > 0, true, 'priority is a positive number');
|
||||
});
|
||||
|
||||
test('core loop ranks higher than mid-tier (higher = earlier per spec)', () => {
|
||||
const np = convertClaudeCommandToClaudeSkill(
|
||||
mk('new-project', 'Start a project', 'Body.'), 'gsd-new-project', 'qwen', []
|
||||
).match(/^priority:\s*(\d+)/m);
|
||||
const help = convertClaudeCommandToClaudeSkill(
|
||||
mk('help', 'Help', 'Body.'), 'gsd-help', 'qwen', []
|
||||
).match(/^priority:\s*(\d+)/m);
|
||||
assert.ok(np && help, 'both core and mid-tier get a priority');
|
||||
assert.ok(
|
||||
Number(np[1]) > Number(help[1]),
|
||||
'new-project (core) sorts earlier than help (utility) — higher value'
|
||||
);
|
||||
});
|
||||
|
||||
test('utility command NOT in the priority map gets no priority field', () => {
|
||||
const result = convertClaudeCommandToClaudeSkill(
|
||||
mk('stats', 'Show stats', 'Body.'), 'gsd-stats', 'qwen', []
|
||||
);
|
||||
assert.ok(!/^priority:/m.test(result), 'no priority emitted for unmapped utility');
|
||||
});
|
||||
|
||||
test('does NOT emit priority for non-qwen runtimes (scoped to qwen)', () => {
|
||||
for (const rt of [null, 'claude', 'hermes']) {
|
||||
const result = convertClaudeCommandToClaudeSkill(
|
||||
mk('plan-phase', 'Plan a phase', 'Body.'), 'gsd-plan-phase', rt, []
|
||||
);
|
||||
assert.ok(!/^priority:/m.test(result), `no priority for runtime=${rt}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── (c) Gemini: {{args}} interpolation ─────────────────────────────────────
|
||||
|
||||
describe('#778 (c) Gemini {{args}} interpolation', () => {
|
||||
const cmd = (body) =>
|
||||
['---', 'name: gsd:demo', 'description: Demo', '---', '', body].join('\n');
|
||||
|
||||
test('maps $ARGUMENTS to {{args}} in the TOML prompt', () => {
|
||||
const out = convertClaudeToGeminiMarkdown(
|
||||
cmd('Operate on $ARGUMENTS now.'),
|
||||
{ isCommand: true, commandName: 'demo' }
|
||||
);
|
||||
assert.ok(out.includes('{{args}}'), '{{args}} present');
|
||||
assert.ok(!out.includes('$ARGUMENTS'), 'literal $ARGUMENTS removed');
|
||||
assert.ok(out.startsWith('description =') || out.includes('prompt ='), 'TOML shape');
|
||||
});
|
||||
|
||||
test('command without $ARGUMENTS gets no injected {{args}}', () => {
|
||||
const out = convertClaudeToGeminiMarkdown(
|
||||
cmd('No arguments referenced here.'),
|
||||
{ isCommand: true, commandName: 'demo' }
|
||||
);
|
||||
assert.ok(!out.includes('{{args}}'), 'no spurious {{args}}');
|
||||
});
|
||||
|
||||
test('non-command Gemini content is not TOML-converted and keeps $ARGUMENTS', () => {
|
||||
const out = convertClaudeToGeminiMarkdown(
|
||||
cmd('Reference $ARGUMENTS.'),
|
||||
{ isCommand: false }
|
||||
);
|
||||
// isCommand:false keeps markdown — no TOML wrap and no {{args}} mapping
|
||||
// (the $ARGUMENTS→{{args}} translation is scoped to the TOML command path).
|
||||
assert.ok(!out.startsWith('prompt ='), 'not wrapped as TOML prompt');
|
||||
assert.ok(out.includes('$ARGUMENTS'), '$ARGUMENTS left intact for non-command content');
|
||||
assert.ok(!out.includes('{{args}}'), 'no {{args}} injected outside the command path');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── (c) Gemini: end-to-end install wiring ──────────────────────────────────
|
||||
// Proves the install path derives the per-command name from the file stem so a
|
||||
// regression in the call-site wiring (not just the converter) is caught.
|
||||
|
||||
describe('#778 (c) Gemini install wiring (end-to-end)', () => {
|
||||
let tmpDir;
|
||||
let tmpHome;
|
||||
let prevCwd;
|
||||
let prevHome;
|
||||
let prevUserprofile;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-enh778-gem-');
|
||||
tmpHome = createTempDir('gsd-enh778-home-');
|
||||
prevCwd = process.cwd();
|
||||
prevHome = process.env.HOME;
|
||||
prevUserprofile = process.env.USERPROFILE;
|
||||
process.chdir(tmpDir);
|
||||
// Isolate HOME so a real ~/.gemini/commands/gsd/ doesn't trigger the #3037
|
||||
// local-install conflict-skip path.
|
||||
process.env.HOME = tmpHome;
|
||||
process.env.USERPROFILE = tmpHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(prevCwd);
|
||||
if (prevHome === undefined) delete process.env.HOME; else process.env.HOME = prevHome;
|
||||
if (prevUserprofile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = prevUserprofile;
|
||||
cleanup(tmpDir);
|
||||
cleanup(tmpHome);
|
||||
});
|
||||
|
||||
test('installed progress.toml carries the !{} block; arg-bearing commands get {{args}}', () => {
|
||||
const oldLog = console.log;
|
||||
console.log = () => {};
|
||||
try {
|
||||
install(false, 'gemini');
|
||||
} finally {
|
||||
console.log = oldLog;
|
||||
}
|
||||
|
||||
const commandsDir = path.join(tmpDir, '.gemini', 'commands', 'gsd');
|
||||
const progressToml = path.join(commandsDir, 'progress.toml');
|
||||
assert.ok(fs.existsSync(progressToml), 'progress.toml installed');
|
||||
const progress = fs.readFileSync(progressToml, 'utf8');
|
||||
// Proves commandName was derived as 'progress' from the file stem.
|
||||
assert.ok(
|
||||
progress.includes('!{cat .planning/STATE.md 2>/dev/null}'),
|
||||
'progress.toml has the live-state shell block'
|
||||
);
|
||||
|
||||
// A non-situational command must NOT receive the shell block.
|
||||
const helpToml = path.join(commandsDir, 'help.toml');
|
||||
if (fs.existsSync(helpToml)) {
|
||||
assert.ok(!fs.readFileSync(helpToml, 'utf8').includes('!{'), 'help.toml has no shell block');
|
||||
}
|
||||
|
||||
// At least one installed command must use {{args}} and none may retain a
|
||||
// literal $ARGUMENTS (every command body's $ARGUMENTS is translated).
|
||||
const tomls = fs.readdirSync(commandsDir).filter((f) => f.endsWith('.toml'));
|
||||
const withArgs = tomls.filter((f) =>
|
||||
fs.readFileSync(path.join(commandsDir, f), 'utf8').includes('{{args}}'));
|
||||
const withLiteral = tomls.filter((f) =>
|
||||
fs.readFileSync(path.join(commandsDir, f), 'utf8').includes('$ARGUMENTS'));
|
||||
assert.ok(withArgs.length > 0, 'at least one installed command interpolates {{args}}');
|
||||
assert.equal(withLiteral.length, 0, 'no installed command retains literal $ARGUMENTS');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── (c) Gemini: !{...} live-state injection (progress) ─────────────────────
|
||||
|
||||
describe('#778 (c) Gemini !{} live-state injection', () => {
|
||||
const cmd = (name) =>
|
||||
['---', `name: gsd:${name}`, `description: ${name}`, '---', '', 'Workflow body.'].join('\n');
|
||||
|
||||
test('progress command injects a fixed !{cat .planning/STATE.md} block', () => {
|
||||
const out = convertClaudeToGeminiMarkdown(
|
||||
cmd('progress'),
|
||||
{ isCommand: true, commandName: 'progress' }
|
||||
);
|
||||
assert.ok(out.includes('!{cat .planning/STATE.md'), 'STATE.md injection present');
|
||||
});
|
||||
|
||||
test('non-progress commands get no !{} shell block', () => {
|
||||
const out = convertClaudeToGeminiMarkdown(
|
||||
cmd('help'),
|
||||
{ isCommand: true, commandName: 'help' }
|
||||
);
|
||||
assert.ok(!out.includes('!{'), 'no shell block for non-situational command');
|
||||
});
|
||||
|
||||
test('SECURITY: the !{} block interpolates NO user input ({{args}})', () => {
|
||||
const out = convertClaudeToGeminiMarkdown(
|
||||
['---', 'name: gsd:progress', 'description: progress', '---', '',
|
||||
'Body uses $ARGUMENTS too.'].join('\n'),
|
||||
{ isCommand: true, commandName: 'progress' }
|
||||
);
|
||||
const blocks = out.match(/!\{([^}]*)\}/g) || [];
|
||||
assert.equal(blocks.length, 1, 'exactly one shell block');
|
||||
assert.ok(!/\{\{args\}\}/.test(blocks[0]), 'no {{args}} inside the shell block');
|
||||
assert.ok(/^!\{cat \.planning\/STATE\.md/.test(blocks[0]), 'fixed cat command only');
|
||||
});
|
||||
});
|
||||
259
tests/enh-789-codebuddy-commands.test.cjs
Normal file
259
tests/enh-789-codebuddy-commands.test.cjs
Normal file
@@ -0,0 +1,259 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / command .md / SKILL.md files — their text IS what the runtime
|
||||
// loads. Testing emitted text tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression guard — enh(#789): elevate CodeBuddy slash-command surface.
|
||||
*
|
||||
* CodeBuddy (Tencent, @tencent-ai/codebuddy-code) reads user-level surfaces
|
||||
* (https://www.codebuddy.ai/docs/cli/slash-commands, /skills):
|
||||
* - commands/gsd-<stem>.md — slash commands shown in the '/' menu
|
||||
* - skills/gsd-<stem>/SKILL.md — model-invocable skills
|
||||
*
|
||||
* Before #789 gsd emitted only skills/. Because CodeBuddy skills default to
|
||||
* user-invocable:true (appear in '/'), emitting a commands/ surface AND leaving
|
||||
* skills user-invocable would duplicate every /gsd-* entry. #789 therefore:
|
||||
* 1. emits commands/gsd-<stem>.md (the '/' surface, peer-consistent with
|
||||
* Cursor #785 and Augment #790),
|
||||
* 2. marks skills user-invocable:false so they become model-invocable
|
||||
* background knowledge and the commands/ surface is the sole '/' surface.
|
||||
*
|
||||
* Subagents are already emitted via the generic agents block + convertClaude
|
||||
* AgentToCodebuddyAgent (~/.codebuddy/agents/), so #789 adds no agents change.
|
||||
*
|
||||
* mcp.json is intentionally NOT written: gsd ships no MCP server, and CodeBuddy's
|
||||
* mcp.json holds an `mcpServers` map of *external* servers to connect to —
|
||||
* there is nothing for gsd to register. Same exclusion as #784/#785/#790.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
installRuntimeArtifacts,
|
||||
uninstallRuntimeArtifacts,
|
||||
convertClaudeCommandToCodebuddyCommand,
|
||||
convertClaudeCommandToCodebuddySkill,
|
||||
} = require('../bin/install.js');
|
||||
const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
||||
const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs');
|
||||
|
||||
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST });
|
||||
|
||||
// ─── Layout contract ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('enh-789 — codebuddy layout has commands + skills kinds', () => {
|
||||
test('resolveRuntimeArtifactLayout codebuddy returns 2 kinds', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir');
|
||||
assert.strictEqual(layout.kinds.length, 2, 'codebuddy must have exactly 2 artifact kinds');
|
||||
const kindNames = layout.kinds.map(k => k.kind).sort();
|
||||
assert.deepStrictEqual(kindNames, ['commands', 'skills']);
|
||||
});
|
||||
|
||||
test('codebuddy commands kind targets commands/ with gsd- prefix', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir');
|
||||
const commandsKind = layout.kinds.find(k => k.kind === 'commands');
|
||||
assert.ok(commandsKind, 'must have commands kind');
|
||||
assert.strictEqual(commandsKind.destSubpath, 'commands');
|
||||
assert.strictEqual(commandsKind.prefix, 'gsd-');
|
||||
assert.strictEqual(typeof commandsKind.stage, 'function');
|
||||
});
|
||||
|
||||
test('codebuddy skills kind targets skills/ with gsd- prefix', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir');
|
||||
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
|
||||
assert.ok(skillsKind, 'must have skills kind');
|
||||
assert.strictEqual(skillsKind.destSubpath, 'skills');
|
||||
assert.strictEqual(skillsKind.prefix, 'gsd-');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Command converter contract ──────────────────────────────────────────────
|
||||
|
||||
describe('enh-789 — convertClaudeCommandToCodebuddyCommand', () => {
|
||||
const SRC = [
|
||||
'---',
|
||||
'name: gsd:new-project',
|
||||
'description: Initialize a project',
|
||||
'argument-hint: "[name]"',
|
||||
'allowed-tools:',
|
||||
' - Read',
|
||||
'---',
|
||||
'',
|
||||
'Use .claude/skills/ and run /gsd:help. Claude Code reads CLAUDE.md.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
test('emits a description-only frontmatter (no Claude-specific name: gsd:)', () => {
|
||||
const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project');
|
||||
assert.ok(out.startsWith('---\n'), 'must begin with frontmatter');
|
||||
assert.ok(/^description:/m.test(out), 'must carry a description field');
|
||||
assert.ok(!out.includes('name: gsd:new-project'), 'must drop Claude colon-form name field');
|
||||
});
|
||||
|
||||
test('preserves a present argument-hint (CodeBuddy supports it)', () => {
|
||||
const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project');
|
||||
assert.ok(/^argument-hint:\s*["']?\[name\]["']?\s*$/m.test(out),
|
||||
`argument-hint must be carried through when present in source. Got:\n${out}`);
|
||||
});
|
||||
|
||||
test('converts body Claude-isms to CodeBuddy equivalents', () => {
|
||||
const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project');
|
||||
assert.ok(out.includes('.codebuddy/skills/'), out);
|
||||
assert.ok(out.includes('/gsd-help'), out);
|
||||
assert.ok(out.includes('CODEBUDDY.md'), out);
|
||||
assert.ok(!/\bClaude Code\b/.test(out), 'must rebrand "Claude Code"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('enh-789 — skills marked user-invocable:false', () => {
|
||||
test('convertClaudeCommandToCodebuddySkill emits user-invocable: false', () => {
|
||||
const src = [
|
||||
'---',
|
||||
'name: gsd:help',
|
||||
'description: Show help',
|
||||
'---',
|
||||
'',
|
||||
'# body',
|
||||
'',
|
||||
].join('\n');
|
||||
const out = convertClaudeCommandToCodebuddySkill(src, 'gsd-help');
|
||||
assert.ok(/^user-invocable:\s*false\s*$/m.test(out),
|
||||
`SKILL.md frontmatter must hide skill from '/' menu (user-invocable: false). Got:\n${out}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Install contract ────────────────────────────────────────────────────────
|
||||
|
||||
describe('enh-789 — installRuntimeArtifacts codebuddy emits commands and skills', () => {
|
||||
test('global codebuddy install: commands/gsd-help.md and skills/gsd-help/SKILL.md exist', (t) => {
|
||||
const configDir = createTempDir('gsd-enh789-codebuddy-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const commandsDir = path.join(configDir, 'commands');
|
||||
assert.ok(fs.existsSync(commandsDir), 'commands/ dir must exist');
|
||||
const cmdFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
|
||||
assert.ok(cmdFiles.length > 0, 'at least one gsd-*.md command file must be installed');
|
||||
assert.ok(fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'commands/gsd-help.md must exist');
|
||||
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
assert.ok(fs.existsSync(skillsDir), 'skills/ dir must exist');
|
||||
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-help', 'SKILL.md')), 'skills/gsd-help/SKILL.md must exist');
|
||||
});
|
||||
|
||||
test('installed commands/gsd-help.md is CodeBuddy-compatible (no raw ~/.claude/, rebranded)', (t) => {
|
||||
const configDir = createTempDir('gsd-enh789-content-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const helpCmd = path.join(configDir, 'commands', 'gsd-help.md');
|
||||
const content = fs.readFileSync(helpCmd, 'utf8');
|
||||
assert.ok(!content.includes('~/.claude/'), 'commands must not contain raw ~/.claude/ refs');
|
||||
assert.ok(content.startsWith('---'), 'commands must carry frontmatter');
|
||||
});
|
||||
|
||||
test('installed skills/gsd-help/SKILL.md is hidden from the / menu', (t) => {
|
||||
const configDir = createTempDir('gsd-enh789-skillhide-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const skill = fs.readFileSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md'), 'utf8');
|
||||
assert.ok(/^user-invocable:\s*false\s*$/m.test(skill),
|
||||
'installed SKILL.md must set user-invocable: false');
|
||||
});
|
||||
|
||||
test('command count matches skill count (profile parity)', (t) => {
|
||||
const configDir = createTempDir('gsd-enh789-parity-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const cmdCount = fs.readdirSync(path.join(configDir, 'commands'))
|
||||
.filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length;
|
||||
const skillCount = fs.readdirSync(path.join(configDir, 'skills'), { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length;
|
||||
assert.strictEqual(cmdCount, skillCount, 'command count must equal skill count for same profile');
|
||||
});
|
||||
|
||||
test('full profile install: no $HOME/.codebuddy or ~/.codebuddy leak in any command', (t) => {
|
||||
// The codebuddy converter rewrites `.claude/` → `.codebuddy/`, so source
|
||||
// refs like `@$HOME/.claude/gsd-core/...` (e.g. plan-review-convergence.md)
|
||||
// must be normalized to the install target — not left as $HOME/.codebuddy.
|
||||
const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST });
|
||||
const configDir = createTempDir('gsd-enh789-noleak-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_FULL);
|
||||
|
||||
const commandsDir = path.join(configDir, 'commands');
|
||||
for (const f of fs.readdirSync(commandsDir).filter(n => n.endsWith('.md'))) {
|
||||
const content = fs.readFileSync(path.join(commandsDir, f), 'utf8');
|
||||
assert.ok(!content.includes('$HOME/.codebuddy'), `${f} must not leak $HOME/.codebuddy`);
|
||||
assert.ok(!content.includes('~/.codebuddy'), `${f} must not leak ~/.codebuddy`);
|
||||
assert.ok(!content.includes('.claude/'), `${f} must not retain raw .claude/ refs`);
|
||||
}
|
||||
});
|
||||
|
||||
test('full profile install does NOT mutate source commands/gsd/ files', (t) => {
|
||||
const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST });
|
||||
assert.strictEqual(RESOLVED_FULL.skills, '*', 'full profile must have skills === "*"');
|
||||
|
||||
const configDir = createTempDir('gsd-enh789-full-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const srcHelpPath = path.join(REAL_COMMANDS_DIR, 'help.md');
|
||||
const before = fs.readFileSync(srcHelpPath, 'utf8');
|
||||
|
||||
installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_FULL);
|
||||
|
||||
const after = fs.readFileSync(srcHelpPath, 'utf8');
|
||||
assert.strictEqual(before, after, 'source commands/gsd/help.md must not be mutated by the install');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Uninstall contract ──────────────────────────────────────────────────────
|
||||
|
||||
describe('enh-789 — uninstallRuntimeArtifacts removes codebuddy commands', () => {
|
||||
test('uninstall removes gsd-* commands but preserves user commands', (t) => {
|
||||
const configDir = createTempDir('gsd-enh789-uninstall-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const commandsDir = path.join(configDir, 'commands');
|
||||
fs.mkdirSync(commandsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(commandsDir, 'gsd-help.md'), '# help\n');
|
||||
fs.writeFileSync(path.join(commandsDir, 'user-custom.md'), '# user\n');
|
||||
|
||||
uninstallRuntimeArtifacts('codebuddy', configDir, 'global');
|
||||
|
||||
assert.ok(!fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'gsd-help.md must be removed');
|
||||
assert.ok(fs.existsSync(path.join(commandsDir, 'user-custom.md')), 'user-custom.md must be preserved');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── mcp.json exclusion ──────────────────────────────────────────────────────
|
||||
|
||||
describe('enh-789 — mcp.json excluded (gsd ships no MCP server)', () => {
|
||||
test('codebuddy install does not write mcp.json / .mcp.json', (t) => {
|
||||
const configDir = createTempDir('gsd-enh789-mcp-excluded-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
assert.ok(!fs.existsSync(path.join(configDir, 'mcp.json')), 'must not write mcp.json');
|
||||
assert.ok(!fs.existsSync(path.join(configDir, '.mcp.json')), 'must not write .mcp.json');
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,7 @@ try {
|
||||
else process.env.GSD_TEST_MODE = savedTestMode;
|
||||
}
|
||||
|
||||
const { installRuntimeArtifacts, uninstallRuntimeArtifacts } = installExports || {};
|
||||
const { installRuntimeArtifacts, uninstallRuntimeArtifacts, mergeClaudePermissions, GSD_CLAUDE_ALLOW_PERMISSIONS, GSD_CLAUDE_DENY_PERMISSIONS } = installExports || {};
|
||||
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
@@ -304,3 +304,298 @@ describe('U3 (#2973): uninstallRuntimeArtifacts hermes migrates dev-preferences
|
||||
assert.strictEqual(fs.readFileSync(skillFile, 'utf8'), '# my hermes prefs\n');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #768 — mergeClaudePermissions: pre-populate permissions.allow/deny ──────
|
||||
|
||||
describe('mergeClaudePermissions (#768): exports and permission constants', () => {
|
||||
test('mergeClaudePermissions is exported', () => {
|
||||
assert.strictEqual(typeof mergeClaudePermissions, 'function',
|
||||
'mergeClaudePermissions must be exported from bin/install.js');
|
||||
});
|
||||
|
||||
test('GSD_CLAUDE_ALLOW_PERMISSIONS is a non-empty array of strings', () => {
|
||||
assert.ok(Array.isArray(GSD_CLAUDE_ALLOW_PERMISSIONS),
|
||||
'GSD_CLAUDE_ALLOW_PERMISSIONS must be an array');
|
||||
assert.ok(GSD_CLAUDE_ALLOW_PERMISSIONS.length > 0,
|
||||
'GSD_CLAUDE_ALLOW_PERMISSIONS must not be empty');
|
||||
for (const entry of GSD_CLAUDE_ALLOW_PERMISSIONS) {
|
||||
assert.strictEqual(typeof entry, 'string', `allow entry must be a string, got: ${JSON.stringify(entry)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('GSD_CLAUDE_DENY_PERMISSIONS is a non-empty array of strings', () => {
|
||||
assert.ok(Array.isArray(GSD_CLAUDE_DENY_PERMISSIONS),
|
||||
'GSD_CLAUDE_DENY_PERMISSIONS must be an array');
|
||||
assert.ok(GSD_CLAUDE_DENY_PERMISSIONS.length > 0,
|
||||
'GSD_CLAUDE_DENY_PERMISSIONS must not be empty');
|
||||
for (const entry of GSD_CLAUDE_DENY_PERMISSIONS) {
|
||||
assert.strictEqual(typeof entry, 'string', `deny entry must be a string, got: ${JSON.stringify(entry)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeClaudePermissions (#768): fresh settings object', () => {
|
||||
test('populates permissions.allow and permissions.deny on empty settings', () => {
|
||||
const settings = {};
|
||||
mergeClaudePermissions(settings);
|
||||
assert.ok(Array.isArray(settings.permissions?.allow), 'permissions.allow must be an array');
|
||||
assert.ok(Array.isArray(settings.permissions?.deny), 'permissions.deny must be an array');
|
||||
for (const entry of GSD_CLAUDE_ALLOW_PERMISSIONS) {
|
||||
assert.ok(settings.permissions.allow.includes(entry),
|
||||
`permissions.allow must contain "${entry}"`);
|
||||
}
|
||||
for (const entry of GSD_CLAUDE_DENY_PERMISSIONS) {
|
||||
assert.ok(settings.permissions.deny.includes(entry),
|
||||
`permissions.deny must contain "${entry}"`);
|
||||
}
|
||||
});
|
||||
|
||||
test('includes Bash(npx gsd-core *) in allow', () => {
|
||||
const settings = {};
|
||||
mergeClaudePermissions(settings);
|
||||
assert.ok(settings.permissions.allow.includes('Bash(npx gsd-core *)'),
|
||||
'permissions.allow must contain Bash(npx gsd-core *)');
|
||||
});
|
||||
|
||||
test('includes planning path entries in allow', () => {
|
||||
const settings = {};
|
||||
mergeClaudePermissions(settings);
|
||||
assert.ok(settings.permissions.allow.includes('Read(.planning/*)'),
|
||||
'permissions.allow must contain Read(.planning/*)');
|
||||
assert.ok(settings.permissions.allow.includes('Write(.planning/*)'),
|
||||
'permissions.allow must contain Write(.planning/*)');
|
||||
});
|
||||
|
||||
test('includes STATE.md entries in allow', () => {
|
||||
const settings = {};
|
||||
mergeClaudePermissions(settings);
|
||||
assert.ok(settings.permissions.allow.includes('Read(STATE.md)'),
|
||||
'permissions.allow must contain Read(STATE.md)');
|
||||
assert.ok(settings.permissions.allow.includes('Write(STATE.md)'),
|
||||
'permissions.allow must contain Write(STATE.md)');
|
||||
});
|
||||
|
||||
test('includes .env denial entries in deny', () => {
|
||||
const settings = {};
|
||||
mergeClaudePermissions(settings);
|
||||
assert.ok(settings.permissions.deny.includes('Read(.env)'),
|
||||
'permissions.deny must contain Read(.env)');
|
||||
assert.ok(settings.permissions.deny.includes('Read(.env.*)'),
|
||||
'permissions.deny must contain Read(.env.*)');
|
||||
assert.ok(settings.permissions.deny.includes('Read(.secrets)'),
|
||||
'permissions.deny must contain Read(.secrets)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeClaudePermissions (#768): non-destructive merge', () => {
|
||||
test('appends to existing allow/deny arrays without overwriting user entries', () => {
|
||||
const settings = {
|
||||
permissions: {
|
||||
allow: ['Bash(git *)'],
|
||||
deny: ['WebSearch'],
|
||||
},
|
||||
};
|
||||
mergeClaudePermissions(settings);
|
||||
// User entries must be preserved
|
||||
assert.ok(settings.permissions.allow.includes('Bash(git *)'),
|
||||
'existing allow entries must be preserved');
|
||||
assert.ok(settings.permissions.deny.includes('WebSearch'),
|
||||
'existing deny entries must be preserved');
|
||||
// GSD entries must be added
|
||||
assert.ok(settings.permissions.allow.includes('Bash(npx gsd-core *)'),
|
||||
'GSD allow entry must be added');
|
||||
assert.ok(settings.permissions.deny.includes('Read(.env)'),
|
||||
'GSD deny entry must be added');
|
||||
});
|
||||
|
||||
test('does not duplicate entries on repeated calls (idempotent)', () => {
|
||||
const settings = {};
|
||||
mergeClaudePermissions(settings);
|
||||
mergeClaudePermissions(settings);
|
||||
for (const entry of GSD_CLAUDE_ALLOW_PERMISSIONS) {
|
||||
const count = settings.permissions.allow.filter((e) => e === entry).length;
|
||||
assert.strictEqual(count, 1, `allow entry "${entry}" must appear exactly once after two merges`);
|
||||
}
|
||||
for (const entry of GSD_CLAUDE_DENY_PERMISSIONS) {
|
||||
const count = settings.permissions.deny.filter((e) => e === entry).length;
|
||||
assert.strictEqual(count, 1, `deny entry "${entry}" must appear exactly once after two merges`);
|
||||
}
|
||||
});
|
||||
|
||||
test('preserves other permission sub-keys (ask, disableBypassPermissionsMode)', () => {
|
||||
const settings = {
|
||||
permissions: {
|
||||
ask: ['Bash'],
|
||||
disableBypassPermissionsMode: 'disable',
|
||||
allow: [],
|
||||
deny: [],
|
||||
},
|
||||
};
|
||||
mergeClaudePermissions(settings);
|
||||
assert.deepStrictEqual(settings.permissions.ask, ['Bash'],
|
||||
'permissions.ask must be preserved');
|
||||
assert.strictEqual(settings.permissions.disableBypassPermissionsMode, 'disable',
|
||||
'permissions.disableBypassPermissionsMode must be preserved');
|
||||
});
|
||||
|
||||
test('handles permissions with non-array allow/deny gracefully (replaces with array)', () => {
|
||||
// If allow/deny exist but are not arrays (malformed settings), must not crash
|
||||
// and must result in valid arrays.
|
||||
const settings = { permissions: { allow: null, deny: null } };
|
||||
mergeClaudePermissions(settings);
|
||||
assert.ok(Array.isArray(settings.permissions.allow));
|
||||
assert.ok(Array.isArray(settings.permissions.deny));
|
||||
assert.ok(settings.permissions.allow.includes('Bash(npx gsd-core *)'));
|
||||
});
|
||||
|
||||
test('handles settings that are not plain objects (returns unchanged)', () => {
|
||||
// Guard: if settings is not a plain object, do nothing
|
||||
const badInputs = [null, undefined, [], 'string', 42];
|
||||
for (const bad of badInputs) {
|
||||
// Must not throw
|
||||
assert.doesNotThrow(() => mergeClaudePermissions(bad),
|
||||
`mergeClaudePermissions must not throw on: ${JSON.stringify(bad)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeClaudePermissions (#768): end-to-end install writes permissions to settings.json', () => {
|
||||
test('--claude --global install writes GSD allow/deny entries to settings.json', (t) => {
|
||||
const root = createTempDir('gsd-claude-perm-install-');
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL_SCRIPT, '--claude', '--global', '--config-dir', root],
|
||||
{ encoding: 'utf8', env: { ...process.env, HOME: root, USERPROFILE: root } },
|
||||
);
|
||||
|
||||
assert.strictEqual(result.status, 0,
|
||||
`installer exited ${result.status}\n${result.stdout}\n${result.stderr}`);
|
||||
|
||||
const settingsPath = path.join(root, 'settings.json');
|
||||
assert.ok(fs.existsSync(settingsPath), 'settings.json must exist after claude install');
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
assert.ok(Array.isArray(settings.permissions?.allow),
|
||||
'settings.json must have permissions.allow array');
|
||||
assert.ok(Array.isArray(settings.permissions?.deny),
|
||||
'settings.json must have permissions.deny array');
|
||||
|
||||
assert.ok(settings.permissions.allow.includes('Bash(npx gsd-core *)'),
|
||||
'settings.json permissions.allow must include Bash(npx gsd-core *)');
|
||||
assert.ok(settings.permissions.allow.includes('Read(.planning/*)'),
|
||||
'settings.json permissions.allow must include Read(.planning/*)');
|
||||
assert.ok(settings.permissions.deny.includes('Read(.env)'),
|
||||
'settings.json permissions.deny must include Read(.env)');
|
||||
});
|
||||
|
||||
test('non-claude runtime (gemini) does NOT write GSD allow/deny permissions to settings.json', (t) => {
|
||||
const root = createTempDir('gsd-gemini-perm-install-');
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL_SCRIPT, '--gemini', '--global', '--config-dir', root],
|
||||
{ encoding: 'utf8', env: { ...process.env, HOME: root, USERPROFILE: root } },
|
||||
);
|
||||
|
||||
assert.strictEqual(result.status, 0,
|
||||
`installer exited ${result.status}\n${result.stdout}\n${result.stderr}`);
|
||||
|
||||
const settingsPath = path.join(root, 'settings.json');
|
||||
// If settings.json doesn't exist, permissions are definitely not written — pass.
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
const allow = settings.permissions?.allow ?? [];
|
||||
assert.ok(!allow.includes('Bash(npx gsd-core *)'),
|
||||
'Gemini settings.json must NOT include Bash(npx gsd-core *) in permissions.allow');
|
||||
}
|
||||
});
|
||||
|
||||
test('--claude --global reinstall is idempotent (no duplicate permission entries)', (t) => {
|
||||
const root = createTempDir('gsd-claude-perm-idempotent-');
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
const spawnOpts = {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, HOME: root, USERPROFILE: root },
|
||||
};
|
||||
const args = [INSTALL_SCRIPT, '--claude', '--global', '--config-dir', root];
|
||||
|
||||
// First install
|
||||
const r1 = spawnSync(process.execPath, args, spawnOpts);
|
||||
assert.strictEqual(r1.status, 0, `first install failed: ${r1.stderr}`);
|
||||
|
||||
// Second install (reinstall)
|
||||
const r2 = spawnSync(process.execPath, args, spawnOpts);
|
||||
assert.strictEqual(r2.status, 0, `reinstall failed: ${r2.stderr}`);
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(path.join(root, 'settings.json'), 'utf8'));
|
||||
for (const entry of GSD_CLAUDE_ALLOW_PERMISSIONS) {
|
||||
const count = (settings.permissions?.allow ?? []).filter((e) => e === entry).length;
|
||||
assert.strictEqual(count, 1,
|
||||
`allow entry "${entry}" must appear exactly once after two installs`);
|
||||
}
|
||||
for (const entry of GSD_CLAUDE_DENY_PERMISSIONS) {
|
||||
const count = (settings.permissions?.deny ?? []).filter((e) => e === entry).length;
|
||||
assert.strictEqual(count, 1,
|
||||
`deny entry "${entry}" must appear exactly once after two installs`);
|
||||
}
|
||||
});
|
||||
|
||||
test('--claude --global uninstall removes GSD permission entries from settings.json', (t) => {
|
||||
const root = createTempDir('gsd-claude-perm-uninstall-');
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
const spawnOpts = {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, HOME: root, USERPROFILE: root },
|
||||
};
|
||||
|
||||
// Install first
|
||||
const r1 = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL_SCRIPT, '--claude', '--global', '--config-dir', root],
|
||||
spawnOpts,
|
||||
);
|
||||
assert.strictEqual(r1.status, 0, `install failed: ${r1.stderr}`);
|
||||
|
||||
// Verify permissions were written
|
||||
const settingsPath = path.join(root, 'settings.json');
|
||||
const afterInstall = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
assert.ok((afterInstall.permissions?.allow ?? []).includes('Bash(npx gsd-core *)'),
|
||||
'permissions.allow must contain GSD entry after install');
|
||||
|
||||
// Now add a user permission to make sure we don't nuke it
|
||||
afterInstall.permissions.allow.push('Bash(git *)');
|
||||
afterInstall.permissions.deny.push('WebSearch');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(afterInstall, null, 2) + '\n');
|
||||
|
||||
// Uninstall
|
||||
const r2 = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL_SCRIPT, '--claude', '--global', '--config-dir', root, '--uninstall'],
|
||||
spawnOpts,
|
||||
);
|
||||
assert.strictEqual(r2.status, 0, `uninstall failed: ${r2.stderr}`);
|
||||
|
||||
const afterUninstall = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
const allow = afterUninstall.permissions?.allow ?? [];
|
||||
const deny = afterUninstall.permissions?.deny ?? [];
|
||||
|
||||
// GSD entries must be removed
|
||||
assert.ok(!allow.includes('Bash(npx gsd-core *)'),
|
||||
'GSD Bash allow entry must be removed by uninstall');
|
||||
assert.ok(!allow.includes('Read(.planning/*)'),
|
||||
'GSD Read(.planning/*) allow entry must be removed by uninstall');
|
||||
assert.ok(!deny.includes('Read(.env)'),
|
||||
'GSD Read(.env) deny entry must be removed by uninstall');
|
||||
|
||||
// User entries must survive
|
||||
assert.ok(allow.includes('Bash(git *)'),
|
||||
'user Bash(git *) allow entry must survive uninstall');
|
||||
assert.ok(deny.includes('WebSearch'),
|
||||
'user WebSearch deny entry must survive uninstall');
|
||||
});
|
||||
});
|
||||
|
||||
119
tests/issue-775-gemini-extension.test.cjs
Normal file
119
tests/issue-775-gemini-extension.test.cjs
Normal file
@@ -0,0 +1,119 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression tests for issue #775: additive Gemini CLI extension manifest.
|
||||
*
|
||||
* Asserts structural and semantic correctness of the Gemini Extension Package:
|
||||
* gemini-extension.json — extension manifest (consumed by
|
||||
* `gemini extensions install <git-url>`)
|
||||
* GEMINI.md — the extension's context-file payload, referenced
|
||||
* by the manifest's `contextFileName` field.
|
||||
*
|
||||
* This mirrors tests/issue-766-plugin-manifest.test.cjs (the parallel Claude
|
||||
* Code plugin manifest) — the Gemini extension is the same artifact-surface
|
||||
* projection onto Gemini CLI's package contract.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const identity = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'package-identity.cjs'));
|
||||
const pkg = require(path.join(ROOT, 'package.json'));
|
||||
|
||||
const MANIFEST_PATH = path.join(ROOT, 'gemini-extension.json');
|
||||
|
||||
// ─── Section A: gemini-extension.json ────────────────────────────────────────
|
||||
describe('A: gemini-extension.json', () => {
|
||||
|
||||
let manifest;
|
||||
|
||||
test('exists and is valid JSON', () => {
|
||||
assert.ok(fs.existsSync(MANIFEST_PATH), 'gemini-extension.json must exist at repo root');
|
||||
const raw = fs.readFileSync(MANIFEST_PATH, 'utf-8');
|
||||
manifest = JSON.parse(raw); // throws on invalid JSON
|
||||
assert.ok(typeof manifest === 'object' && manifest !== null, 'manifest must be a JSON object');
|
||||
});
|
||||
|
||||
test('name equals identity.binName ("gsd-core")', (t) => {
|
||||
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
||||
assert.equal(manifest.name, identity.binName, `name should be "${identity.binName}"`);
|
||||
});
|
||||
|
||||
test('name is lowercase/dashes only (Gemini extension naming rule)', (t) => {
|
||||
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
||||
// Gemini reference: "lowercase or numbers and use dashes instead of
|
||||
// underscores or spaces".
|
||||
assert.match(
|
||||
manifest.name,
|
||||
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
||||
'name must be lowercase letters/numbers with dashes (no underscores, spaces, or uppercase)'
|
||||
);
|
||||
});
|
||||
|
||||
test('version is a non-empty string matching package.json version', (t) => {
|
||||
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
||||
assert.equal(
|
||||
manifest.version,
|
||||
pkg.version,
|
||||
`gemini-extension.json version (${manifest.version}) must match package.json version (${pkg.version}). ` +
|
||||
`When bumping the package version, update gemini-extension.json \`version\` to match — ` +
|
||||
`Gemini CLI's \`gemini extensions update\` keys off the manifest version field. (#775)`
|
||||
);
|
||||
});
|
||||
|
||||
test('description is a non-empty string (required by Gemini manifest schema)', (t) => {
|
||||
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
||||
assert.ok(
|
||||
typeof manifest.description === 'string' && manifest.description.trim().length > 0,
|
||||
'description must be a non-empty string'
|
||||
);
|
||||
});
|
||||
|
||||
test('contextFileName points to an existing repo-root file', (t) => {
|
||||
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
||||
assert.equal(manifest.contextFileName, 'GEMINI.md', 'contextFileName must be "GEMINI.md"');
|
||||
const ctx = path.join(ROOT, manifest.contextFileName);
|
||||
assert.ok(fs.existsSync(ctx), `context file must exist on disk: ${ctx}`);
|
||||
const body = fs.readFileSync(ctx, 'utf-8');
|
||||
assert.ok(body.trim().length > 0, 'context file must be non-empty');
|
||||
});
|
||||
|
||||
test('only declares schema-known top-level keys', (t) => {
|
||||
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
||||
// Per Gemini extension reference. We intentionally ship the minimal
|
||||
// context-loading subset; this guards against typos / unknown keys that
|
||||
// would fail `gemini extensions install` manifest validation.
|
||||
const ALLOWED = new Set([
|
||||
'name', 'version', 'description', 'contextFileName',
|
||||
'mcpServers', 'excludeTools', 'migratedTo', 'plan', 'settings', 'themes',
|
||||
]);
|
||||
for (const key of Object.keys(manifest)) {
|
||||
assert.ok(ALLOWED.has(key), `Unknown top-level manifest key "${key}" is not in the Gemini extension schema`);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not declare mcpServers (gsd-core ships no MCP server)', (t) => {
|
||||
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
||||
assert.ok(
|
||||
!Object.prototype.hasOwnProperty.call(manifest, 'mcpServers'),
|
||||
'manifest must NOT declare mcpServers — gsd-core has no MCP server'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Section B: package publication ──────────────────────────────────────────
|
||||
describe('B: package.json publication', () => {
|
||||
|
||||
test('files[] includes gemini-extension.json and GEMINI.md', () => {
|
||||
const files = pkg.files || [];
|
||||
for (const required of ['gemini-extension.json', 'GEMINI.md']) {
|
||||
assert.ok(
|
||||
files.includes(required),
|
||||
`package.json "files" must include "${required}" so it ships to npm consumers`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -226,15 +226,21 @@ describe('resolveRuntimeArtifactLayout — hermes', () => {
|
||||
});
|
||||
|
||||
describe('resolveRuntimeArtifactLayout — codebuddy', () => {
|
||||
test('returns correct layout for codebuddy', () => {
|
||||
test('returns correct layout for codebuddy (commands + skills — #789)', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('codebuddy', FAKE_DIR);
|
||||
assert.strictEqual(layout.runtime, 'codebuddy');
|
||||
assert.strictEqual(layout.configDir, FAKE_DIR);
|
||||
assert.strictEqual(layout.kinds.length, 1);
|
||||
assert.strictEqual(layout.kinds[0].kind, 'skills');
|
||||
assert.strictEqual(layout.kinds[0].destSubpath, 'skills');
|
||||
assert.strictEqual(layout.kinds.length, 2);
|
||||
// commands kind first
|
||||
assert.strictEqual(layout.kinds[0].kind, 'commands');
|
||||
assert.strictEqual(layout.kinds[0].destSubpath, 'commands');
|
||||
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
|
||||
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
||||
// skills kind second
|
||||
assert.strictEqual(layout.kinds[1].kind, 'skills');
|
||||
assert.strictEqual(layout.kinds[1].destSubpath, 'skills');
|
||||
assert.strictEqual(layout.kinds[1].prefix, 'gsd-');
|
||||
assert.strictEqual(typeof layout.kinds[1].stage, 'function');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user