Merge branch 'next' into kimi-runtime-support

This commit is contained in:
Viktorplus
2026-06-08 01:08:28 +02:00
committed by GitHub
53 changed files with 2934 additions and 78 deletions

View File

@@ -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', () => {

View File

@@ -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:`);
}
});
});

View 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}`);
});
});

View 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)}`);
});
});

View 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()}`
);
}
});
});

View 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');
});
});

View 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');
});
});

View File

@@ -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');
});
});

View 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`
);
}
});
});

View File

@@ -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');
});
});