Files
gstack/scripts/gen-skill-docs.ts
T
Garry Tan 04b709d91a feat: declarative multi-host platform + OpenCode, Slate, Cursor, OpenClaw (v0.15.5.0) (#793)
* test: add golden-file baselines for host config refactor

Snapshot generated SKILL.md output for ship skill across all 3 existing
hosts (Claude, Codex, Factory). These baselines verify the config-driven
refactor produces identical output to the current hardcoded system.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add HostConfig interface and validator for declarative host system

New scripts/host-config.ts defines the typed HostConfig interface that
captures all per-host variation: paths, frontmatter rules, path/tool
rewrites, suppressed resolvers, runtime root symlinks, install strategy,
and behavioral config (co-author trailer, learnings mode, boundary
instruction). Includes validateHostConfig() and validateAllConfigs() with
regex-based security validation and cross-config uniqueness checks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add typed host configs for Claude, Codex, Factory, and Kiro

Extract all hardcoded host-specific values from gen-skill-docs.ts,
types.ts, preamble.ts, review.ts, and setup into typed HostConfig
objects. Each host is a single file in hosts/ with its paths, frontmatter
rules, path/tool rewrites, runtime root manifest, and install behavior.

hosts/index.ts exports all configs, derives the Host type, and provides
resolveHostArg() for CLI alias handling (e.g., 'agents' -> 'codex',
'droid' -> 'factory').

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: derive Host type and HOST_PATHS from host configs

types.ts no longer hardcodes host names or paths. The Host type is
derived from ALL_HOST_CONFIGS in hosts/index.ts, and HOST_PATHS is
built dynamically from each config's globalRoot/localSkillRoot/usesEnvVars.
Adding a new host to hosts/index.ts automatically extends the type system.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 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>

* refactor: preamble, co-author trailer, and resolver suppression use host configs

- preamble.ts: hostConfigDir derived from config.globalRoot instead of
  hardcoded Record
- utility.ts: generateCoAuthorTrailer reads from config.coAuthorTrailer
  instead of host switch statement
- gen-skill-docs.ts: suppressedResolvers from config skip resolver
  execution at placeholder replacement time (belt+suspenders with
  existing ctx.host checks in individual resolvers)

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

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

* refactor: setup tooling uses config-driven host detection

- host-config-export.ts: new CLI that exposes host configs to bash
  (list, get, detect, validate, symlinks commands)
- bin/gstack-platform-detect: reads host configs instead of hardcoded
  binary/path mapping
- scripts/skill-check.ts: iterates host configs for skill validation
  and freshness checks instead of separate Codex/Factory blocks
- lib/worktree.ts: iterates host configs for directory copy instead
  of hardcoded .agents

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

* feat: add OpenCode, Slate, and Cursor host configs

Three new hosts added to the declarative config system. Each is a typed
HostConfig object with paths, frontmatter rules, and path rewrites.
All generate valid SKILL.md output with zero .claude/skills path leakage.

- hosts/opencode.ts: OpenCode (opencode.ai), skills at ~/.config/opencode/
- hosts/slate.ts: Slate (Random Labs), skills at ~/.slate/
- hosts/cursor.ts: Cursor, skills at ~/.cursor/
- .gitignore: add .kiro/, .opencode/, .slate/, .cursor/, .openclaw/

Zero code changes needed — just config files + re-export in index.ts.

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

* feat: add OpenClaw host config with adapter for tool mapping

OpenClaw gets a hybrid approach: typed config for paths/frontmatter/
detection + a post-processing adapter for semantic tool rewrites.

Config handles: path rewrites, frontmatter (name+description+version),
CLAUDE.md→AGENTS.md, tool name rewrites (Bash→exec, Read→read, etc.),
suppressed resolvers, SOUL.md via staticFiles.

Adapter handles: AskUserQuestion→prose, Agent→sessions_spawn, $B→exec $B.

Zero .claude/skills path leakage. Zero hardcoded tool references remaining.

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

* feat: contributor add-host skill + fix version sync

- contrib/add-host/SKILL.md.tmpl: contributor-only skill that guides
  new host config creation. Lives in contrib/, excluded from user installs.
- package.json: sync version with VERSION file (0.15.2.1)

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

* test: add parameterized host smoke tests for all hosts

35 new tests covering all 7 external hosts (Codex, Factory, Kiro,
OpenCode, Slate, Cursor, OpenClaw). Each host gets 4-5 tests:
- output exists on disk with SKILL.md files
- no .claude/skills path leakage in non-root skills
- frontmatter has name + description fields
- --dry-run freshness check passes
- /codex skill excluded (for hosts with skipSkills: ['codex'])

Tests are parameterized over ALL_HOST_CONFIGS so adding a new host
automatically gets smoke-tested with zero new test code.

Also updates --host all test to verify all registered hosts generate.

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

* test: 100% coverage for host config system

71 new tests in test/host-config.test.ts covering:
- hosts/index.ts: ALL_HOST_CONFIGS, getHostConfig, resolveHostArg (aliases),
  getExternalHosts, uniqueness checks
- host-config.ts validateHostConfig: name regex, displayName, cliCommand,
  cliAliases, globalRoot, localSkillRoot, hostSubdir, frontmatter.mode,
  linkingStrategy, shell injection attempts, paths with $ and ~
- host-config.ts validateAllConfigs: duplicate name/hostSubdir/globalRoot
  detection, error prefix format, real configs pass
- HOST_PATHS derivation: env vars for external hosts, literal paths for
  Claude, localSkillRoot matches config, every host has entry
- host-config-export.ts CLI: list, get (string/boolean/array), detect,
  validate, symlinks, error cases (missing args, unknown field/host)
- Golden-file regression: claude/codex/factory ship SKILL.md vs baselines
- Individual host config correctness: prefixable, linkingStrategy,
  usesEnvVars, description limits, metadata, sidecar, tool rewrites,
  conditional fields, suppressed resolvers, boundary instruction,
  co-author trailers, skip rules, path rewrites, runtime root assets

Combined with the 35 parameterized smoke tests from gen-skill-docs.test.ts,
total new test coverage for multi-host: 106 tests.

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

* chore: update golden baselines and sync version after merge from main

Golden files refreshed to match post-merge generated output. package.json
version synced to VERSION file (0.15.4.0).

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

* chore: bump version and changelog (v0.15.5.0)

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

* fix: sidebar E2E tests now self-contained and passing

- sidebar-url-accuracy: fix stale assertion that expected extensionUrl
  in prompt text (prompt format changed, URL is now in pageUrl field)
- sidebar-css-interaction: simplify task from multi-step HN comment
  navigation to single-page example.com style injection (faster, more
  reliable, still exercises goto + style + completion flow)
- Update golden baselines after merge from main

All 3 sidebar tests now pass: 3/3, 0 fail, ~36s total.

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

* docs: add ADDING_A_HOST.md guide + update docs for multi-host system

- docs/ADDING_A_HOST.md: step-by-step guide for adding a new host
  (create config, register, gitignore, generate, test). Covers the
  full HostConfig interface, adapter pattern, and validation.
- CONTRIBUTING.md: replace stale "Dual-host development" section with
  "Multi-host development" covering all 8 hosts and linking to the guide.
- README.md: consolidate Codex/Factory install sections into one
  "Other AI Agents" section listing all supported hosts with auto-detect.
- CLAUDE.md: add hosts/, host-config.ts, host-adapters/, contrib/ to
  project structure tree.

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

* docs: README per-host install instructions for all 8 agents

Each supported agent now has its own copy-paste install block with
the exact command and where skills end up on disk. Includes: auto-detect,
Codex, OpenCode, Cursor, Factory, OpenClaw, Slate, and Kiro.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 15:32:20 -07:00

591 lines
24 KiB
TypeScript

#!/usr/bin/env bun
/**
* Generate SKILL.md files from .tmpl templates.
*
* Pipeline:
* read .tmpl → find {{PLACEHOLDERS}} → resolve from source → format → write .md
*
* Supports --dry-run: generate to memory, exit 1 if different from committed file.
* Used by skill:check and CI freshness checks.
*/
import { COMMAND_DESCRIPTIONS } from '../browse/src/commands';
import { SNAPSHOT_FLAGS } from '../browse/src/snapshot';
import { discoverTemplates } from './discover-skills';
import * as fs from 'fs';
import * as path from 'path';
import type { Host, TemplateContext } from './resolvers/types';
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 (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 === 'all') return '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.
let HOST: Host = HOST_ARG_VAL === 'all' ? 'claude' : HOST_ARG_VAL;
// HostPaths, HOST_PATHS, and TemplateContext imported from ./resolvers/types (line 7-8)
// ─── Shared Design Constants ────────────────────────────────
/** gstack's 10 AI slop anti-patterns — shared between DESIGN_METHODOLOGY and DESIGN_HARD_RULES */
const AI_SLOP_BLACKLIST = [
'Purple/violet/indigo gradient backgrounds or blue-to-purple color schemes',
'**The 3-column feature grid:** icon-in-colored-circle + bold title + 2-line description, repeated 3x symmetrically. THE most recognizable AI layout.',
'Icons in colored circles as section decoration (SaaS starter template look)',
'Centered everything (`text-align: center` on all headings, descriptions, cards)',
'Uniform bubbly border-radius on every element (same large radius on everything)',
'Decorative blobs, floating circles, wavy SVG dividers (if a section feels empty, it needs better content, not decoration)',
'Emoji as design elements (rockets in headings, emoji as bullet points)',
'Colored left-border on cards (`border-left: 3px solid <accent>`)',
'Generic hero copy ("Welcome to [X]", "Unlock the power of...", "Your all-in-one solution for...")',
'Cookie-cutter section rhythm (hero → 3 features → testimonials → pricing → CTA, every section same height)',
];
/** OpenAI hard rejection criteria (from "Designing Delightful Frontends with GPT-5.4", Mar 2026) */
const OPENAI_HARD_REJECTIONS = [
'Generic SaaS card grid as first impression',
'Beautiful image with weak brand',
'Strong headline with no clear action',
'Busy imagery behind text',
'Sections repeating same mood statement',
'Carousel with no narrative purpose',
'App UI made of stacked cards instead of layout',
];
/** OpenAI litmus checks — 7 yes/no tests for cross-model consensus scoring */
const OPENAI_LITMUS_CHECKS = [
'Brand/product unmistakable in first screen?',
'One strong visual anchor present?',
'Page understandable by scanning headlines only?',
'Each section has one job?',
'Are cards actually necessary?',
'Does motion improve hierarchy or atmosphere?',
'Would design feel premium with all decorative shadows removed?',
];
// ─── External Host Helpers ───────────────────────────────────
// Re-export local copy for use in this file (matches codex-helpers.ts)
// Accepts optional frontmatter name to support directory/invocation name divergence
function externalSkillName(skillDir: string, frontmatterName?: string): string {
// Root skill (skillDir === '' or '.') always maps to 'gstack' regardless of frontmatter
if (skillDir === '.' || skillDir === '') return 'gstack';
// Use frontmatter name when it differs from directory name (e.g., run-tests/ with name: test)
const baseName = frontmatterName && frontmatterName !== skillDir ? frontmatterName : skillDir;
// Don't double-prefix: gstack-upgrade → gstack-upgrade (not gstack-gstack-upgrade)
if (baseName.startsWith('gstack-')) return baseName;
return `gstack-${baseName}`;
}
function extractNameAndDescription(content: string): { name: string; description: string } {
const fmStart = content.indexOf('---\n');
if (fmStart !== 0) return { name: '', description: '' };
const fmEnd = content.indexOf('\n---', fmStart + 4);
if (fmEnd === -1) return { name: '', description: '' };
const frontmatter = content.slice(fmStart + 4, fmEnd);
const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
const name = nameMatch ? nameMatch[1].trim() : '';
let description = '';
const lines = frontmatter.split('\n');
let inDescription = false;
const descLines: string[] = [];
for (const line of lines) {
if (line.match(/^description:\s*\|?\s*$/)) {
inDescription = true;
continue;
}
if (line.match(/^description:\s*\S/)) {
description = line.replace(/^description:\s*/, '').trim();
break;
}
if (inDescription) {
if (line === '' || line.match(/^\s/)) {
descLines.push(line.replace(/^ /, ''));
} else {
break;
}
}
}
if (descLines.length > 0) {
description = descLines.join('\n').trim();
}
return { name, description };
}
// ─── Voice Trigger Processing ────────────────────────────────
/**
* Extract voice-triggers YAML list from frontmatter.
* Returns an array of trigger strings, or [] if no voice-triggers field.
*/
function extractVoiceTriggers(content: string): string[] {
const fmStart = content.indexOf('---\n');
if (fmStart !== 0) return [];
const fmEnd = content.indexOf('\n---', fmStart + 4);
if (fmEnd === -1) return [];
const frontmatter = content.slice(fmStart + 4, fmEnd);
const triggers: string[] = [];
let inVoice = false;
for (const line of frontmatter.split('\n')) {
if (/^voice-triggers:/.test(line)) { inVoice = true; continue; }
if (inVoice) {
const m = line.match(/^\s+-\s+"(.+)"$/);
if (m) triggers.push(m[1]);
else if (!/^\s/.test(line)) break;
}
}
return triggers;
}
/**
* Preprocess voice triggers: fold voice-triggers YAML field into description,
* then strip the field from frontmatter. Must run BEFORE transformFrontmatter
* and extractNameAndDescription so all hosts see the updated description.
*/
function processVoiceTriggers(content: string): string {
const triggers = extractVoiceTriggers(content);
if (triggers.length === 0) return content;
// Strip voice-triggers block from frontmatter
content = content.replace(/^voice-triggers:\n(?:\s+-\s+"[^"]*"\n?)*/m, '');
// Get current description (after stripping voice-triggers, so it's clean)
const { description } = extractNameAndDescription(content);
if (!description) return content;
// Build new description with voice triggers appended
const voiceLine = `Voice triggers (speech-to-text aliases): ${triggers.map(t => `"${t}"`).join(', ')}.`;
const newDescription = description + '\n' + voiceLine;
// Replace old indented description with new in frontmatter
const oldIndented = description.split('\n').map(l => ` ${l}`).join('\n');
const newIndented = newDescription.split('\n').map(l => ` ${l}`).join('\n');
content = content.replace(oldIndented, newIndented);
return content;
}
// Export for testing
export { extractVoiceTriggers, processVoiceTriggers };
const OPENAI_SHORT_DESCRIPTION_LIMIT = 120;
function condenseOpenAIShortDescription(description: string): string {
const firstParagraph = description.split(/\n\s*\n/)[0] || description;
const collapsed = firstParagraph.replace(/\s+/g, ' ').trim();
if (collapsed.length <= OPENAI_SHORT_DESCRIPTION_LIMIT) return collapsed;
const truncated = collapsed.slice(0, OPENAI_SHORT_DESCRIPTION_LIMIT - 3);
const lastSpace = truncated.lastIndexOf(' ');
const safe = lastSpace > 40 ? truncated.slice(0, lastSpace) : truncated;
return `${safe}...`;
}
function generateOpenAIYaml(displayName: string, shortDescription: string): string {
return `interface:
display_name: ${JSON.stringify(displayName)}
short_description: ${JSON.stringify(shortDescription)}
default_prompt: ${JSON.stringify(`Use ${displayName} for this task.`)}
policy:
allow_implicit_invocation: true
`;
}
/**
* Transform frontmatter for external hosts.
* Claude: strips `sensitive:` field (only Factory uses it).
* Codex: keeps name + description only, enforces 1024-char limit.
* Factory: keeps name + description + user-invocable, conditionally adds disable-model-invocation.
*/
function transformFrontmatter(content: string, host: Host): string {
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);
const { name, description } = extractNameAndDescription(content);
// 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
}
}
// 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`;
}
}
}
// 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;
}
/**
* Extract hook descriptions from frontmatter for inline safety prose.
* Returns a description of what the hooks do, or null if no hooks.
*/
function extractHookSafetyProse(tmplContent: string): string | null {
if (!tmplContent.match(/^hooks:/m)) return null;
// Parse the hook matchers to build a human-readable safety description
const matchers: string[] = [];
const matcherRegex = /matcher:\s*"(\w+)"/g;
let m;
while ((m = matcherRegex.exec(tmplContent)) !== null) {
if (!matchers.includes(m[1])) matchers.push(m[1]);
}
if (matchers.length === 0) return null;
// Build safety prose based on what tools are hooked
const toolDescriptions: Record<string, string> = {
Bash: 'check bash commands for destructive operations (rm -rf, DROP TABLE, force-push, git reset --hard, etc.) before execution',
Edit: 'verify file edits are within the allowed scope boundary before applying',
Write: 'verify file writes are within the allowed scope boundary before applying',
};
const safetyChecks = matchers
.map(t => toolDescriptions[t] || `check ${t} operations for safety`)
.join(', and ');
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 (now derived from hosts/*.ts) ──────
// EXTERNAL_HOST_CONFIG replaced by getHostConfig() from hosts/index.ts
// ─── Template Processing ────────────────────────────────────
const GENERATED_HEADER = `<!-- AUTO-GENERATED from {{SOURCE}} — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n`;
/**
* Process external host output: routing, frontmatter, path rewrites, metadata.
* Shared between Codex and Factory (and future external hosts).
*/
function processExternalHost(
content: string,
tmplContent: string,
host: Host,
skillDir: string,
extractedDescription: string,
ctx: TemplateContext,
frontmatterName?: string,
): { content: string; outputPath: string; outputDir: string; symlinkLoop: boolean } {
const hostConfig = getHostConfig(host);
const name = externalSkillName(skillDir === '.' ? '' : skillDir, frontmatterName);
const outputDir = path.join(ROOT, hostConfig.hostSubdir, 'skills', name);
fs.mkdirSync(outputDir, { recursive: true });
const outputPath = path.join(outputDir, 'SKILL.md');
// Guard against symlink loops
let symlinkLoop = false;
const claudePath = ctx.tmplPath.replace(/\.tmpl$/, '');
try {
const resolvedClaude = fs.realpathSync(claudePath);
const resolvedExternal = fs.realpathSync(path.dirname(outputPath)) + '/' + path.basename(outputPath);
if (resolvedClaude === resolvedExternal) {
symlinkLoop = true;
}
} catch {
// realpathSync fails if file doesn't exist yet — no symlink loop
}
// Extract hook safety prose BEFORE transforming frontmatter (which strips hooks)
const safetyProse = extractHookSafetyProse(tmplContent);
// Transform frontmatter (host-aware)
let result = transformFrontmatter(content, host);
// Insert safety advisory at the top of the body (after frontmatter)
if (safetyProse) {
const bodyStart = result.indexOf('\n---') + 4;
result = result.slice(0, bodyStart) + '\n' + safetyProse + '\n' + result.slice(bodyStart);
}
// Config-driven path rewrites (order matters, replaceAll)
for (const rewrite of hostConfig.pathRewrites) {
result = result.replaceAll(rewrite.from, rewrite.to);
}
// 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);
fs.writeFileSync(path.join(agentsDir, 'openai.yaml'), generateOpenAIYaml(name, shortDescription));
}
return { content: result, outputPath, outputDir, symlinkLoop };
}
function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath: string; content: string; symlinkLoop?: boolean } {
const tmplContent = fs.readFileSync(tmplPath, 'utf-8');
const relTmplPath = path.relative(ROOT, tmplPath);
let outputPath = tmplPath.replace(/\.tmpl$/, '');
// Determine skill directory relative to ROOT
const skillDir = path.relative(ROOT, path.dirname(tmplPath));
// Extract skill name from frontmatter early — needed for both TemplateContext and external host output paths.
// When frontmatter name: differs from directory name (e.g., run-tests/ with name: test),
// the frontmatter name is used for external skill naming and setup script symlinks.
const { name: extractedName, description: extractedDescription } = extractNameAndDescription(tmplContent);
const skillName = extractedName || path.basename(path.dirname(tmplPath));
// Extract benefits-from list from frontmatter (inline YAML: benefits-from: [a, b])
const benefitsMatch = tmplContent.match(/^benefits-from:\s*\[([^\]]*)\]/m);
const benefitsFrom = benefitsMatch
? benefitsMatch[1].split(',').map(s => s.trim()).filter(Boolean)
: undefined;
// Extract preamble-tier from frontmatter (1-4, controls which preamble sections are included)
const tierMatch = tmplContent.match(/^preamble-tier:\s*(\d+)$/m);
const preambleTier = tierMatch ? parseInt(tierMatch[1], 10) : undefined;
const ctx: TemplateContext = { skillName, tmplPath, benefitsFrom, host, paths: HOST_PATHS[host], preambleTier };
// Replace placeholders (supports parameterized: {{NAME:arg1:arg2}})
// Config-driven: suppressedResolvers return empty string for this host
const currentHostConfig = getHostConfig(host);
const suppressed = new Set(currentHostConfig.suppressedResolvers || []);
let content = tmplContent.replace(/\{\{(\w+(?::[^}]+)?)\}\}/g, (match, fullKey) => {
const parts = fullKey.split(':');
const resolverName = parts[0];
const args = parts.slice(1);
if (suppressed.has(resolverName)) return '';
const resolver = RESOLVERS[resolverName];
if (!resolver) throw new Error(`Unknown placeholder {{${resolverName}}} in ${relTmplPath}`);
return args.length > 0 ? resolver(ctx, args) : resolver(ctx);
});
// Check for any remaining unresolved placeholders
const remaining = content.match(/\{\{(\w+(?::[^}]+)?)\}\}/g);
if (remaining) {
throw new Error(`Unresolved placeholders in ${relTmplPath}: ${remaining.join(', ')}`);
}
// Preprocess voice triggers: fold into description, strip field from frontmatter.
// Must run BEFORE transformFrontmatter so all hosts see the updated description,
// and BEFORE extractedDescription is used by external host metadata.
content = processVoiceTriggers(content);
// Re-extract description AFTER voice trigger preprocessing so Codex openai.yaml
// metadata gets the updated description with voice triggers included.
const postProcessDescription = extractNameAndDescription(content).description;
// For Claude: strip sensitive: field (only Factory uses it)
// For external hosts: route output, transform frontmatter, rewrite paths
let symlinkLoop = false;
if (host === 'claude') {
content = transformFrontmatter(content, host);
} else {
const result = processExternalHost(content, tmplContent, host, skillDir, postProcessDescription, ctx, extractedName || undefined);
content = result.content;
outputPath = result.outputPath;
symlinkLoop = result.symlinkLoop;
}
// Prepend generated header (after frontmatter)
const header = GENERATED_HEADER.replace('{{SOURCE}}', path.basename(tmplPath));
const fmEnd = content.indexOf('---', content.indexOf('---') + 3);
if (fmEnd !== -1) {
const insertAt = content.indexOf('\n', fmEnd) + 1;
content = content.slice(0, insertAt) + header + content.slice(insertAt);
} else {
content = header + content;
}
return { outputPath, content, symlinkLoop };
}
// ─── Main ───────────────────────────────────────────────────
function findTemplates(): string[] {
return discoverTemplates(ROOT).map(t => path.join(ROOT, t.tmpl));
}
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 }[] = [];
for (const currentHost of hostsToRun) {
HOST = currentHost;
try {
let hasChanges = false;
const tokenBudget: Array<{ skill: string; lines: number; tokens: number }> = [];
for (const tmplPath of findTemplates()) {
// 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 (currentHostConfig.generation.skipSkills.includes(dir)) continue;
}
const { outputPath, content, symlinkLoop } = processTemplate(tmplPath, currentHost);
const relOutput = path.relative(ROOT, outputPath);
if (symlinkLoop) {
console.log(`SKIPPED (symlink loop): ${relOutput}`);
} else if (DRY_RUN) {
const existing = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf-8') : '';
if (existing !== content) {
console.log(`STALE: ${relOutput}`);
hasChanges = true;
} else {
console.log(`FRESH: ${relOutput}`);
}
} else {
fs.writeFileSync(outputPath, content);
console.log(`GENERATED: ${relOutput}`);
}
// Track token budget
const lines = content.split('\n').length;
const tokens = Math.round(content.length / 4); // ~4 chars per token
tokenBudget.push({ skill: relOutput, lines, tokens });
}
if (DRY_RUN && hasChanges) {
console.error(`\nGenerated SKILL.md files are stale (${currentHost} host). Run: bun run gen:skill-docs --host ${currentHost}`);
if (HOST_ARG_VAL !== 'all') process.exit(1);
failures.push({ host: currentHost, error: new Error('Stale files detected') });
}
// Print token budget summary
if (!DRY_RUN && tokenBudget.length > 0) {
tokenBudget.sort((a, b) => b.lines - a.lines);
const totalLines = tokenBudget.reduce((s, t) => s + t.lines, 0);
const totalTokens = tokenBudget.reduce((s, t) => s + t.tokens, 0);
console.log('');
console.log(`Token Budget (${currentHost} host)`);
console.log('═'.repeat(60));
for (const t of tokenBudget) {
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));
console.log(` ${'TOTAL'.padEnd(30)} ${String(totalLines).padStart(5)} lines ~${String(totalTokens).padStart(6)} tokens`);
console.log('');
}
} catch (e) {
failures.push({ host: currentHost, error: e as Error });
console.error(`WARNING: ${currentHost} generation failed: ${(e as Error).message}`);
}
}
// --host all: report failures. Only exit(1) if claude failed.
if (failures.length > 0 && HOST_ARG_VAL === 'all') {
console.error(`\n${failures.length} host(s) failed: ${failures.map(f => f.host).join(', ')}`);
if (failures.some(f => f.host === 'claude')) process.exit(1);
}
// Single host dry-run failure already handled above
// After all hosts processed, warn if prefix patches may need re-applying
if (!DRY_RUN) {
try {
const configPath = path.join(process.env.HOME || '', '.gstack', 'config.yaml');
if (fs.existsSync(configPath)) {
const config = fs.readFileSync(configPath, 'utf-8');
if (/^skill_prefix:\s*true/m.test(config)) {
console.log('\nNote: skill_prefix is true. Run gstack-relink to re-apply name: patches.');
}
}
} catch { /* non-fatal */ }
}