feat(#774): emit service_tier/model_verbosity in Codex agent TOML + agents/openai.yaml skill chip (#828)

* feat(#774): emit service_tier/model_verbosity in Codex agent TOML + agents/openai.yaml skill chip

- Add service_tier = "flex" and model_verbosity = "low" to the Codex
  ConfigProfile TOML for light-tier agents (gsd-research-synthesizer,
  gsd-codebase-mapper, gsd-plan-checker, and 8 others identified via
  AGENT_DEFAULT_TIERS). Field names/values verified against Codex schema
  (profile_toml.rs / config_types.rs Verbosity enum). Non-light agents
  are unaffected.

- Add generateCodexSkillMetadataYaml() and writeCodexSkillMetadataFiles():
  after installRuntimeArtifacts, iterate every gsd-* skill directory,
  read the short-description already emitted in the SKILL.md frontmatter
  by convertClaudeCommandToCodexSkill, and write agents/openai.yaml with
  interface.display_name and interface.short_description for the Codex
  TUI skill picker chip.
  - yamlQuote (JSON.stringify) handles all YAML-unsafe chars.
  - User-owned gsd-dev-preferences dir is never overwritten.
  - Errors per-skill are swallowed so a bad SKILL.md can't abort install.
  - agents/openai.yaml is covered by the snapshot/rollback system and
    manifest hash (writeManifest hashes skill dirs recursively).
  - Uninstall symmetry: _removeGsdEntries removes whole gsd-* dirs.

- 21 new tests in codex-config.test.cjs covering service_tier/verbosity
  TOML emission, YAML generation (round-trip via js-yaml), and
  writeCodexSkillMetadataFiles including an e2e integration test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#774): correct docs-lint coverage — proper changeset format + USER-GUIDE entry

Rewrite the changeset fragment from old @opengsd/gsd-core:patch format to the
required type:/pr: schema so the docs-lint parser can consume it.  Add a new
"Codex skill picker and agent scheduling (#774)" section to docs/USER-GUIDE.md
describing the flex-tier scheduling and /skills TUI chip enrichments — both are
user-visible and belong in docs rather than behind a docs-exempt marker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-07 18:56:25 -04:00
committed by GitHub
parent dc7f1557c6
commit 19280510fe
4 changed files with 433 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 828
---
Codex CLI installs now emit two enrichments per agent and skill. **Agent TOML enrichment:** light-tier agents (haiku-equivalent, `routingTier: "light"` in model-catalog.json) get `service_tier = "flex"` and `model_verbosity = "low"` appended to their agent TOML, telling the Codex scheduler to use the flex tier (lower cost, background processing) and suppress verbose token output. **Skill TUI chip:** each installed `gsd-*` skill directory now receives an `agents/openai.yaml` file with `interface.display_name` and `interface.short_description`, making the skill appear in the Codex `/skills` picker with a human-readable name and description drawn from the skill's existing short-description frontmatter. Both enrichments are additive and backward-compatible with Codex CLI ≥ 0.130.0. (#774)

View File

@@ -2952,6 +2952,20 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
const _renderedEffortCodex = _getGsdEffortCatalog().renderEffortForRuntime('codex', _universalEffortCodex).value;
lines.push(`model_reasoning_effort = ${JSON.stringify(_renderedEffortCodex)}`);
// #774 — Emit service_tier and model_verbosity for light-tier agents.
// Light-tier agents (routingTier: "light" in model-catalog.json) are haiku-equivalent
// and benefit from Codex's "flex" service tier (lower cost, background processing)
// and "low" verbosity (reduced token output). Both fields are validated against the
// Codex ConfigProfile schema (codex-rs/config/src/profile_toml.rs):
// service_tier: Option<String> — "flex" | "fast" (legacy)
// model_verbosity: Option<Verbosity> — "low" | "medium" | "high"
const { AGENT_DEFAULT_TIERS: _agentTiers } = _getGsdEffortCatalog();
const _agentRoutingTier = _agentTiers?.[resolvedName] || _agentTiers?.[agentName];
if (_agentRoutingTier === 'light') {
lines.push(`service_tier = "flex"`);
lines.push(`model_verbosity = "low"`);
}
// Agent prompts contain raw backslashes in regexes and shell snippets.
// TOML literal multiline strings preserve them without escape parsing.
lines.push(`developer_instructions = '''`);
@@ -2961,6 +2975,115 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
return lines.join('\n') + '\n';
}
/**
* Generate the agents/openai.yaml TUI chip metadata content for a Codex skill.
*
* This file is written alongside SKILL.md as <skill-dir>/agents/openai.yaml.
* Codex loads it as a SkillMetadataFile (codex-rs/core-skills/src/loader.rs),
* making the skill discoverable in the /skills TUI popup with a display name
* and short description. If the file is absent, Codex silently skips it (fails open).
*
* Schema (interface section):
* display_name: short human-readable skill name (strip gsd- prefix)
* short_description: 1-2 sentence description for TUI chip, ≤180 chars
*
* @param {string} skillName - Full skill name e.g. "gsd-plan-phase"
* @param {string} shortDescription - Description text (already truncated by caller)
* @returns {string} YAML content for agents/openai.yaml
*/
function generateCodexSkillMetadataYaml(skillName, shortDescription) {
// Display name: strip "gsd-" prefix and convert hyphens to spaces for readability.
const displayName = skillName.replace(/^gsd-/, '').replace(/-/g, ' ');
// yamlQuote (= JSON.stringify) handles all YAML-unsafe chars: backslashes,
// quotes, newlines, control characters, and Unicode escapes.
return [
'interface:',
` display_name: ${yamlQuote(displayName)}`,
` short_description: ${yamlQuote(shortDescription)}`,
'',
].join('\n');
}
/**
* Write agents/openai.yaml TUI chip metadata for each gsd-* skill directory.
*
* Called after layout-driven skill install for Codex. Iterates every gsd-*
* skill directory in skillsDir, reads the SKILL.md frontmatter to extract the
* short-description already emitted by convertClaudeCommandToCodexSkill, then
* writes <skill-dir>/agents/openai.yaml using generateCodexSkillMetadataYaml.
*
* Fails open: individual skill directories that cannot be processed are silently
* skipped so a single malformed SKILL.md cannot block the whole install.
*
* User-owned skill directories (e.g. gsd-dev-preferences) are explicitly
* skipped so existing user-authored agents/openai.yaml files are never
* overwritten. These dirs are listed in the same USER_OWNED_SKILL_DIRS
* constant used by installOpencodeFamilySkills.
*
* The YAML-quoted description value is unescaped before embedding so that
* YAML escape sequences (e.g. \" in a double-quoted scalar) become the
* literal characters they represent rather than being double-escaped in the
* output.
*
* @param {string} skillsDir - Path to the skills/ directory (e.g. ~/.codex/skills)
*/
function writeCodexSkillMetadataFiles(skillsDir) {
if (!fs.existsSync(skillsDir)) return;
// Mirror the user-owned list from installOpencodeFamilySkills (#2973).
// We MUST skip these dirs — their contents are user-generated and must
// never be overwritten by GSD's install path.
const _userOwnedSkillDirs = new Set(['gsd-dev-preferences']);
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
if (!entry.isDirectory() || !entry.name.startsWith('gsd-')) continue;
if (_userOwnedSkillDirs.has(entry.name)) continue; // preserve user content
const skillDir = path.join(skillsDir, entry.name);
const skillMdPath = path.join(skillDir, 'SKILL.md');
try {
const content = fs.readFileSync(skillMdPath, 'utf8');
const { frontmatter } = extractFrontmatterAndBody(content);
// Prefer the short-description field emitted by convertClaudeCommandToCodexSkill;
// fall back to description, then a synthetic label from the skill name.
let shortDesc = '';
if (frontmatter) {
// SKILL.md uses YAML frontmatter with a nested metadata.short-description key.
// extractFrontmatterField handles only top-level keys; parse the metadata block
// by looking for " short-description:" directly.
const metaMatch = frontmatter.match(/^[ \t]*metadata\s*:\s*\n((?:[ \t]+.*\n?)*)/m);
if (metaMatch) {
const metaBlock = metaMatch[1];
const sdMatch = metaBlock.match(/^[ \t]+short-description\s*:\s*(.+)$/m);
if (sdMatch) {
// Unescape YAML double-quoted scalar escapes before embedding.
// convertClaudeCommandToCodexSkill always emits a double-quoted
// value (via yamlQuote) so only double-quote unescaping is needed.
let raw = sdMatch[1].trim();
if (raw.startsWith('"') && raw.endsWith('"')) {
// Strip outer double-quotes and decode \" → " and \\ → \
raw = raw.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
} else {
// Single-quoted or unquoted: strip surrounding quotes/whitespace
raw = raw.replace(/^["']|["']$/g, '');
}
shortDesc = raw;
}
}
if (!shortDesc) {
shortDesc = extractFrontmatterField(frontmatter, 'description') || '';
}
}
if (!shortDesc) {
shortDesc = `Run GSD workflow ${entry.name}.`;
}
const yamlContent = generateCodexSkillMetadataYaml(entry.name, shortDesc);
const agentsSubdir = path.join(skillDir, 'agents');
fs.mkdirSync(agentsSubdir, { recursive: true });
fs.writeFileSync(path.join(agentsSubdir, 'openai.yaml'), yamlContent);
} catch (_err) {
// Fail open — missing or unreadable SKILL.md must not block the install.
}
}
}
/**
* Generate the GSD config block for Codex config.toml.
* @param {Array<{name: string, description: string}>} agents
@@ -9227,6 +9350,16 @@ function install(isGlobal, runtime = 'claude', options = {}) {
const scope = isGlobal ? 'global' : 'local';
installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile);
// #774 — Codex only: write agents/openai.yaml TUI chip metadata alongside each
// installed skill so the /skills popup shows name + description for each gsd-* skill.
// The SkillMetadataFile is loaded by codex-rs/core-skills/src/loader.rs from
// <skill-dir>/agents/openai.yaml; absence is silently tolerated (fails open).
// We parse the SKILL.md frontmatter to extract short-description already emitted
// by convertClaudeCommandToCodexSkill and use it as the TUI chip description.
if (isCodex) {
writeCodexSkillMetadataFiles(path.join(targetDir, 'skills'));
}
// Hermes only: write DESCRIPTION.md for the gsd/ category after layout install
if (isHermes) {
writeHermesCategoryDescription(path.join(targetDir, 'skills', 'gsd'));
@@ -11649,6 +11782,8 @@ module.exports = {
convertClaudeToGeminiAgent,
convertClaudeAgentToCodexAgent,
generateCodexAgentToml,
generateCodexSkillMetadataYaml,
writeCodexSkillMetadataFiles,
generateCodexConfigBlock,
stripGsdFromCodexConfig,
migrateCodexHooksMapFormat,

View File

@@ -688,6 +688,16 @@ To assign different models on a non-Claude runtime:
}
```
#### Codex skill picker and agent scheduling (#774)
GSD enriches each Codex install with two additional artifacts:
- **Skill TUI chip** — each installed `gsd-*` skill directory contains an `agents/openai.yaml` file that populates the Codex `/skills` picker with a human-readable display name and a short description, so you can browse and invoke GSD skills from the Codex TUI without typing the full skill name.
- **Flex-tier scheduling** — light-tier agents (haiku-equivalent) emit `service_tier = "flex"` and `model_verbosity = "low"` in their agent TOML. The Codex scheduler routes these agents to the flex tier (lower cost, background processing) and suppresses verbose token output.
Both enrichments are written automatically at install time and require no manual configuration. Requires Codex CLI ≥ 0.130.0.
#### Switching from Claude to Codex with one config change (#2517)
```json

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