refactor: gen-skill-docs.ts consumes typed host configs

Replace hardcoded EXTERNAL_HOST_CONFIG, transformFrontmatter host
branches, path/tool rewrite if-chains, and ALL_HOSTS array with
config-driven lookups from hosts/*.ts.

- Host detection uses resolveHostArg() (handles aliases like agents/droid)
- transformFrontmatter uses config's allowlist/denylist mode, extraFields,
  conditionalFields, renameFields, and descriptionLimitBehavior
- Path rewrites use config's pathRewrites array (replaceAll, order matters)
- Tool rewrites use config's toolRewrites object
- Skill skipping uses config's generation.skipSkills
- ALL_HOSTS derived from ALL_HOST_NAMES
- Token budget display regex derived from host configs

Golden-file comparison: all 3 hosts produce IDENTICAL output to baselines.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-03 15:55:43 -07:00
parent 4a1a70c2c7
commit 9612b1c82e
3 changed files with 97 additions and 66 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ const factory: HostConfig = {
generation: {
generateMetadata: false,
skipSkills: [],
skipSkills: ['codex'], // Codex skill is a Claude wrapper around codex exec
},
pathRewrites: [
+1 -1
View File
@@ -19,7 +19,7 @@ const kiro: HostConfig = {
generation: {
generateMetadata: false,
skipSkills: ['codex'],
skipSkills: ['codex'], // Codex skill is a Claude wrapper around codex exec
},
pathRewrites: [
+95 -64
View File
@@ -19,22 +19,25 @@ import { HOST_PATHS } from './resolvers/types';
import { RESOLVERS } from './resolvers/index';
import { externalSkillName, extractHookSafetyProse as _extractHookSafetyProse, extractNameAndDescription as _extractNameAndDescription, condenseOpenAIShortDescription as _condenseOpenAIShortDescription, generateOpenAIYaml as _generateOpenAIYaml } from './resolvers/codex-helpers';
import { generatePlanCompletionAuditShip, generatePlanCompletionAuditReview, generatePlanVerificationExec } from './resolvers/review';
import { ALL_HOST_CONFIGS, ALL_HOST_NAMES, resolveHostArg, getHostConfig } from '../hosts/index';
import type { HostConfig } from './host-config';
const ROOT = path.resolve(import.meta.dir, '..');
const DRY_RUN = process.argv.includes('--dry-run');
// ─── Host Detection ─────────────────────────────────────────
// ─── Host Detection (config-driven) ─────────────────────────
const HOST_ARG = process.argv.find(a => a.startsWith('--host'));
type HostArg = Host | 'all';
const HOST_ARG_VAL: HostArg = (() => {
if (!HOST_ARG) return 'claude';
const val = HOST_ARG.includes('=') ? HOST_ARG.split('=')[1] : process.argv[process.argv.indexOf(HOST_ARG) + 1];
if (val === 'codex' || val === 'agents') return 'codex';
if (val === 'factory' || val === 'droid') return 'factory';
if (val === 'claude') return 'claude';
if (val === 'all') return 'all';
throw new Error(`Unknown host: ${val}. Use claude, codex, factory, droid, agents, or all.`);
try {
return resolveHostArg(val) as Host;
} catch {
throw new Error(`Unknown host: ${val}. Use ${ALL_HOST_NAMES.join(', ')}, or all.`);
}
})();
// For single-host mode, HOST is the host. For --host all, it's set per iteration below.
@@ -219,44 +222,85 @@ policy:
* Factory: keeps name + description + user-invocable, conditionally adds disable-model-invocation.
*/
function transformFrontmatter(content: string, host: Host): string {
if (host === 'claude') {
// Strip fields not used by Claude: sensitive (Factory-only), voice-triggers (folded into description by preprocessing)
content = content.replace(/^sensitive:\s*true\n/m, '');
content = content.replace(/^voice-triggers:\n(?:\s+-\s+"[^"]*"\n?)*/m, '');
const hostConfig = getHostConfig(host);
const fm = hostConfig.frontmatter;
if (fm.mode === 'denylist') {
// Denylist mode: strip listed fields, keep everything else
for (const field of fm.stripFields || []) {
if (field === 'voice-triggers') {
content = content.replace(/^voice-triggers:\n(?:\s+-\s+"[^"]*"\n?)*/m, '');
} else {
content = content.replace(new RegExp(`^${field}:\\s*.*\\n`, 'm'), '');
}
}
return content;
}
// Allowlist mode: reconstruct frontmatter with only allowed fields
const fmStart = content.indexOf('---\n');
if (fmStart !== 0) return content;
const fmEnd = content.indexOf('\n---', fmStart + 4);
if (fmEnd === -1) return content;
const frontmatter = content.slice(fmStart + 4, fmEnd);
const body = content.slice(fmEnd + 4); // includes the leading \n after ---
const body = content.slice(fmEnd + 4);
const { name, description } = extractNameAndDescription(content);
if (host === 'codex') {
// Codex 1024-char description limit — fail build, don't ship broken skills
const MAX_DESC = 1024;
if (description.length > MAX_DESC) {
throw new Error(
`Codex description for "${name}" is ${description.length} chars (max ${MAX_DESC}). ` +
`Compress the description in the .tmpl file.`
);
// Description limit enforcement
if (fm.descriptionLimit) {
const behavior = fm.descriptionLimitBehavior || 'error';
if (description.length > fm.descriptionLimit) {
if (behavior === 'error') {
throw new Error(
`${hostConfig.displayName} description for "${name}" is ${description.length} chars (max ${fm.descriptionLimit}). ` +
`Compress the description in the .tmpl file.`
);
} else if (behavior === 'warn') {
console.warn(`WARNING: ${hostConfig.displayName} description for "${name}" exceeds ${fm.descriptionLimit} chars`);
}
// 'truncate' — silently proceed
}
const indentedDesc = description.split('\n').map(l => ` ${l}`).join('\n');
return `---\nname: ${name}\ndescription: |\n${indentedDesc}\n---` + body;
}
if (host === 'factory') {
const sensitive = /^sensitive:\s*true/m.test(frontmatter);
const indentedDesc = description.split('\n').map(l => ` ${l}`).join('\n');
let fm = `---\nname: ${name}\ndescription: |\n${indentedDesc}\nuser-invocable: true\n`;
if (sensitive) fm += `disable-model-invocation: true\n`;
fm += '---';
return fm + body;
// Build frontmatter with allowed fields
const indentedDesc = description.split('\n').map(l => ` ${l}`).join('\n');
let newFm = `---\nname: ${name}\ndescription: |\n${indentedDesc}\n`;
// Add extra fields (host-wide)
if (fm.extraFields) {
for (const [key, value] of Object.entries(fm.extraFields)) {
if (key !== 'name' && key !== 'description') {
newFm += `${key}: ${value}\n`;
}
}
}
return content; // unknown host: passthrough
// Add conditional fields
if (fm.conditionalFields) {
for (const rule of fm.conditionalFields) {
const match = Object.entries(rule.if).every(([k, v]) =>
new RegExp(`^${k}:\\s*${v}`, 'm').test(frontmatter)
);
if (match) {
for (const [key, value] of Object.entries(rule.add)) {
newFm += `${key}: ${value}\n`;
}
}
}
}
// Rename fields (copy values from template frontmatter with new keys)
if (fm.renameFields) {
for (const [oldName, newName] of Object.entries(fm.renameFields)) {
const fieldMatch = frontmatter.match(new RegExp(`^${oldName}:(.+(?:\\n(?:\\s+.+)*)?)`, 'm'));
if (fieldMatch) {
newFm += `${newName}:${fieldMatch[1]}\n`;
}
}
}
newFm += '---';
return newFm + body;
}
/**
@@ -290,18 +334,8 @@ function extractHookSafetyProse(tmplContent: string): string | null {
return `> **Safety Advisory:** This skill includes safety checks that ${safetyChecks}. When using this skill, always pause and verify before executing potentially destructive operations. If uncertain about a command's safety, ask the user for confirmation before proceeding.`;
}
// ─── External Host Config ────────────────────────────────────
interface ExternalHostConfig {
hostSubdir: string; // '.agents' | '.factory'
generateMetadata: boolean; // true for codex (openai.yaml), false for factory
descriptionLimit?: number; // 1024 for codex, undefined for factory
}
const EXTERNAL_HOST_CONFIG: Record<string, ExternalHostConfig> = {
codex: { hostSubdir: '.agents', generateMetadata: true, descriptionLimit: 1024 },
factory: { hostSubdir: '.factory', generateMetadata: false },
};
// ─── External Host Config (now derived from hosts/*.ts) ──────
// EXTERNAL_HOST_CONFIG replaced by getHostConfig() from hosts/index.ts
// ─── Template Processing ────────────────────────────────────
@@ -320,11 +354,10 @@ function processExternalHost(
ctx: TemplateContext,
frontmatterName?: string,
): { content: string; outputPath: string; outputDir: string; symlinkLoop: boolean } {
const config = EXTERNAL_HOST_CONFIG[host];
if (!config) throw new Error(`No external host config for: ${host}`);
const hostConfig = getHostConfig(host);
const name = externalSkillName(skillDir === '.' ? '' : skillDir, frontmatterName);
const outputDir = path.join(ROOT, config.hostSubdir, 'skills', name);
const outputDir = path.join(ROOT, hostConfig.hostSubdir, 'skills', name);
fs.mkdirSync(outputDir, { recursive: true });
const outputPath = path.join(outputDir, 'SKILL.md');
@@ -353,24 +386,20 @@ function processExternalHost(
result = result.slice(0, bodyStart) + '\n' + safetyProse + '\n' + result.slice(bodyStart);
}
// Replace hardcoded Claude paths with host-appropriate paths
result = result.replace(/~\/\.claude\/skills\/gstack/g, ctx.paths.skillRoot);
result = result.replace(/\.claude\/skills\/gstack/g, ctx.paths.localSkillRoot);
result = result.replace(/\.claude\/skills\/review/g, `${config.hostSubdir}/skills/gstack/review`);
result = result.replace(/\.claude\/skills/g, `${config.hostSubdir}/skills`);
// Factory-only: translate Claude Code tool names to generic phrasing
if (host === 'factory') {
result = result.replace(/use the Bash tool/g, 'run this command');
result = result.replace(/use the Write tool/g, 'create this file');
result = result.replace(/use the Read tool/g, 'read the file');
result = result.replace(/use the Agent tool/g, 'dispatch a subagent');
result = result.replace(/use the Grep tool/g, 'search for');
result = result.replace(/use the Glob tool/g, 'find files matching');
// Config-driven path rewrites (order matters, replaceAll)
for (const rewrite of hostConfig.pathRewrites) {
result = result.replaceAll(rewrite.from, rewrite.to);
}
// Codex-only: generate openai.yaml metadata
if (config.generateMetadata && !symlinkLoop) {
// Config-driven tool rewrites
if (hostConfig.toolRewrites) {
for (const [from, to] of Object.entries(hostConfig.toolRewrites)) {
result = result.replaceAll(from, to);
}
}
// Config-driven: generate metadata (e.g., openai.yaml for Codex)
if (hostConfig.generation.generateMetadata && !symlinkLoop) {
const agentsDir = path.join(outputDir, 'agents');
fs.mkdirSync(agentsDir, { recursive: true });
const shortDescription = condenseOpenAIShortDescription(extractedDescription);
@@ -463,7 +492,7 @@ function findTemplates(): string[] {
return discoverTemplates(ROOT).map(t => path.join(ROOT, t.tmpl));
}
const ALL_HOSTS: Host[] = ['claude', 'codex', 'factory'];
const ALL_HOSTS: Host[] = ALL_HOST_NAMES as Host[];
const hostsToRun: Host[] = HOST_ARG_VAL === 'all' ? ALL_HOSTS : [HOST];
const failures: { host: string; error: Error }[] = [];
@@ -475,10 +504,11 @@ for (const currentHost of hostsToRun) {
const tokenBudget: Array<{ skill: string; lines: number; tokens: number }> = [];
for (const tmplPath of findTemplates()) {
// Skip /codex skill for non-Claude hosts (it's a Claude wrapper around codex exec)
if (currentHost !== 'claude') {
// Skip skills listed in host config's generation.skipSkills
const currentHostConfig = getHostConfig(currentHost);
if (currentHostConfig.generation.skipSkills?.length) {
const dir = path.basename(path.dirname(tmplPath));
if (dir === 'codex') continue;
if (currentHostConfig.generation.skipSkills.includes(dir)) continue;
}
const { outputPath, content, symlinkLoop } = processTemplate(tmplPath, currentHost);
@@ -521,7 +551,8 @@ for (const currentHost of hostsToRun) {
console.log(`Token Budget (${currentHost} host)`);
console.log('═'.repeat(60));
for (const t of tokenBudget) {
const name = t.skill.replace(/\/SKILL\.md$/, '').replace(/^\.(agents|factory)\/skills\//, '');
const hostSubdirs = ALL_HOST_CONFIGS.map(c => c.hostSubdir.replace('.', '\\.')).join('|');
const name = t.skill.replace(/\/SKILL\.md$/, '').replace(new RegExp(`^\\.(${hostSubdirs})\\/skills\\/`), '');
console.log(` ${name.padEnd(30)} ${String(t.lines).padStart(5)} lines ~${String(t.tokens).padStart(6)} tokens`);
}
console.log('─'.repeat(60));