diff --git a/.changeset/774-codex-skill-metadata-service-tier.md b/.changeset/774-codex-skill-metadata-service-tier.md new file mode 100644 index 000000000..2f20d6218 --- /dev/null +++ b/.changeset/774-codex-skill-metadata-service-tier.md @@ -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) diff --git a/bin/install.js b/bin/install.js index 8e83c2eba..52367f341 100755 --- a/bin/install.js +++ b/bin/install.js @@ -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 — "flex" | "fast" (legacy) + // model_verbosity: Option — "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 /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 /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 + // /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, diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 64f9e928c..feb800d66 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -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 diff --git a/tests/codex-config.test.cjs b/tests/codex-config.test.cjs index 5ef505db8..3e1e955fe 100644 --- a/tests/codex-config.test.cjs +++ b/tests/codex-config.test.cjs @@ -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 +--- + +You check plans.`; + 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 +--- + +You plan.`; + 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 +--- + +You check plans.`; + 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 +--- + +You map the codebase.`; + 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:`); + } + }); +});