fix(#1326): stop emitting Codex agents/openai.yaml sidecars; clean up stale ones (#1360)

* fix(#1326): stop emitting Codex agents/openai.yaml sidecars; clean up stale ones

Codex installs wrote an agents/openai.yaml sidecar under every managed gsd-*
skill dir. Recent Codex builds index both SKILL.md and the sidecar, so each
GSD skill appeared twice in autocomplete (canonical gsd-* name + humanized
display_name).

- Replace writeCodexSkillMetadataFiles / generateCodexSkillMetadataYaml with
  cleanupCodexSkillMetadataSidecars: Codex-only (if isCodex), removes stale
  managed gsd-*/agents/openai.yaml and prunes the now-empty agents/ dir.
- Preserve user-owned dirs (gsd-dev-preferences), non-empty agents/ dirs, and
  non-gsd dirs; lstat-guard against symlinked agents/ so a delete can never
  escape the skills tree; fail-open per directory.
- Codex relies on SKILL.md alone for /skills discovery.
- Update USER-GUIDE/FEATURES docs and rewrite the #774 emission tests into
  cleanup tests.

Scope: the sidecar duplicate only. The separate multi-root (~/.agents/skills
shared-skills) duplicate facet is a distinct concern, not addressed here.

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

* chore(#1326): add changeset for Codex sidecar cleanup

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-16 21:55:49 -04:00
committed by GitHub
parent 1a678eb0e9
commit f3c06f59df
5 changed files with 148 additions and 279 deletions

View File

@@ -3198,110 +3198,55 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
}
/**
* Generate the agents/openai.yaml TUI chip metadata content for a Codex skill.
* Remove stale agents/openai.yaml sidecar files from GSD-managed Codex skill dirs.
*
* 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).
* Prior to #1326, GSD's Codex install path wrote an agents/openai.yaml file
* alongside each gsd-* SKILL.md. Recent Codex builds index BOTH SKILL.md and
* the sidecar, causing each GSD skill to appear twice in autocomplete. This
* function removes those stale sidecars and — if the agents/ subdirectory is
* now empty — prunes it too.
*
* 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.
* Behaviour:
* - Returns immediately if skillsDir does not exist (fails open).
* - Only touches directories whose names start with "gsd-".
* - Skips user-owned dirs (gsd-dev-preferences) — their agents/ content is
* never modified, mirroring the same USER_OWNED_SKILL_DIRS guard used by
* installOpencodeFamilySkills.
* - For each managed gsd-* dir, if agents/openai.yaml exists, deletes it.
* - If agents/ is now empty, removes the directory; if it still contains
* other files (e.g. user-added content), leaves it in place.
* - Non-gsd-* dirs and their agents/ content are never touched.
* - Individual failures are caught and swallowed so a single bad dir cannot
* block the install (fail-open, matching the original design).
*
* @param {string} skillsDir - Path to the skills/ directory (e.g. ~/.codex/skills)
*/
function writeCodexSkillMetadataFiles(skillsDir) {
function cleanupCodexSkillMetadataSidecars(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.
// never be modified 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');
const agentsSubdir = path.join(skillsDir, entry.name, 'agents');
const sidecarPath = path.join(agentsSubdir, 'openai.yaml');
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') || '';
}
// Symlink guard: if agents/ is a symlink pointing outside the skills tree,
// deleting through it could escape the tree. Skip this dir entirely.
let agentsStat;
try { agentsStat = fs.lstatSync(agentsSubdir); } catch (_e) { continue; }
if (agentsStat.isSymbolicLink()) continue;
if (fs.existsSync(sidecarPath)) {
fs.rmSync(sidecarPath);
}
if (!shortDesc) {
shortDesc = `Run GSD workflow ${entry.name}.`;
// Prune the agents/ dir only if it is now empty (leave it if other files remain).
if (fs.existsSync(agentsSubdir) && fs.readdirSync(agentsSubdir).length === 0) {
fs.rmdirSync(agentsSubdir);
}
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.
// Fail open — a single bad dir must not block the install.
}
}
}
@@ -9841,14 +9786,14 @@ 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.
// #1326 — Codex only: remove stale agents/openai.yaml sidecars from managed
// gsd-* skill dirs. Prior installs wrote these files so Codex would show a
// display name and description in the /skills TUI popup. Recent Codex builds
// index BOTH SKILL.md and the sidecar, causing each GSD skill to appear twice
// in autocomplete. Cleaning them up fixes the duplication; SKILL.md alone is
// sufficient for Codex discovery. User-owned dirs are never touched.
if (isCodex) {
writeCodexSkillMetadataFiles(path.join(targetDir, 'skills'));
cleanupCodexSkillMetadataSidecars(path.join(targetDir, 'skills'));
}
// Hermes only: write DESCRIPTION.md for the gsd/ category after layout install
@@ -12130,8 +12075,7 @@ module.exports = {
convertClaudeToGeminiAgent,
convertClaudeAgentToCodexAgent,
generateCodexAgentToml,
generateCodexSkillMetadataYaml,
writeCodexSkillMetadataFiles,
cleanupCodexSkillMetadataSidecars,
generateCodexConfigBlock,
stripGsdFromCodexConfig,
migrateCodexHooksMapFormat,