diff --git a/hosts/factory.ts b/hosts/factory.ts index 411bd830..b57e3426 100644 --- a/hosts/factory.ts +++ b/hosts/factory.ts @@ -25,7 +25,7 @@ const factory: HostConfig = { generation: { generateMetadata: false, - skipSkills: [], + skipSkills: ['codex'], // Codex skill is a Claude wrapper around codex exec }, pathRewrites: [ diff --git a/hosts/kiro.ts b/hosts/kiro.ts index a3647f00..f79cbbca 100644 --- a/hosts/kiro.ts +++ b/hosts/kiro.ts @@ -19,7 +19,7 @@ const kiro: HostConfig = { generation: { generateMetadata: false, - skipSkills: ['codex'], + skipSkills: ['codex'], // Codex skill is a Claude wrapper around codex exec }, pathRewrites: [ diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index 32162a33..f6730729 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -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 = { - 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));