mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-01 19:25:10 +02:00
feat: multi-agent support — gstack works on Codex, Gemini CLI, and Cursor (v0.9.0) (#226)
* refactor: host-aware gen-skill-docs + --host codex generation Refactor gen-skill-docs.ts for multi-agent support: - Add Host type, HostPaths interface, HOST_PATHS config - Decompose generatePreamble() into 7 composable sub-functions - Replace all hardcoded .claude/skills/gstack paths with ctx.paths - Replace static findTemplates() list with dynamic filesystem scan - Add --host codex|agents flag (aliases, same output) - Add processTemplate host routing to .agents/skills/gstack-*/ - Add codexSkillName() with double-prefix prevention - Add transformFrontmatter() — keeps only name + description for Codex - Add extractHookSafetyProse() — converts hooks to inline advisory - Add body text path rewriting for remaining hardcoded paths - Exclude /codex skill from Codex generation (self-referential) Claude output is unchanged (verified via --dry-run). SKILL.md is an open standard: .agents/skills/ works on Codex, Gemini CLI, and Cursor. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: generate Codex/Gemini/Cursor skills into .agents/skills/ Generated 21 skill files for the open SKILL.md standard: - Output: .agents/skills/gstack-*/SKILL.md (one per skill) - Frontmatter: name + description only (no allowed-tools/version) - No .claude/skills/ paths in any generated file - /codex skill excluded (Claude wrapper, self-referential on Codex) - Hook skills (careful/freeze/guard) get inline safety prose - Build script generates both hosts: bun run build Supported agents (all read .agents/skills/): - Codex CLI - Gemini CLI - Cursor Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: dual-host setup + find-browse for Codex/Gemini/Cursor - setup: add --host codex|claude|auto flag, install to ~/.codex/skills/ when targeting Codex, auto-detect installed agents - find-browse: priority chain .codex > .agents > .claude (both workspace-local and global) - dev-setup/teardown: create .agents/skills/gstack symlinks for dev mode Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: Codex generation tests + CI + docs for multi-agent support Tests (28 new): - Codex output path routing, frontmatter validation (name+description only) - No .claude/skills/ path leaks in Codex output (regression guard) - /codex skill exclusion, hook→prose conversion, multiline YAML - --host agents alias, dynamic template discovery - Codex skill validation + $B command validation - find-browse priority chain verification - Replace static ALL_SKILLS list with dynamic filesystem scan CI: - Add Codex freshness check to skill-docs workflow Docs: - AGENTS.md: Codex-facing project instructions - README: multi-agent installation section - CONTRIBUTING: dual-host development workflow - CHANGELOG: v0.9.0 multi-agent support entry Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: Codex E2E test harness — verify skills work on Codex CLI New test infrastructure: - CodexSessionRunner: spawns codex exec, parses JSONL stream, returns structured results (output, reasoning, toolCalls, tokens) - JSONL parser ported from Python (codex/SKILL.md.tmpl) to TypeScript - Temp HOME skill installation for Codex discovery testing E2E tests (gated behind EVALS=1 + codex + OPENAI_API_KEY): - codex-discover-skill: installs skill, verifies Codex finds it - codex-review-findings: runs gstack-review via Codex, validates output Integrates with existing eval infrastructure: - Diff-based test selection via touchfiles - Eval persistence via EvalCollector - bun run test:codex / test:codex:all convenience scripts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: bump VERSION to 0.9.0 to match CHANGELOG Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: Codex sidecar paths + setup installs generated skills Two bugs found by Codex adversarial review: 1. Sidecar path mismatch: generated Codex skills referenced .agents/skills/gstack-review/checklist.md but setup creates sidecars at .agents/skills/gstack/review/. Fixed path rewriter to emit .agents/skills/gstack/review/ (matching setup layout). 2. Setup installed Claude-format source dirs for Codex global install instead of the generated Codex-format skills. Split link_skill_dirs into link_claude_skill_dirs (source dirs for Claude) and link_codex_skill_dirs (generated .agents/skills/ gstack-* dirs for Codex). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: comprehensive Codex path rewriting + setup install tests 17 new tests covering: - Sidecar path rewriting: .claude/skills/review → .agents/skills/gstack/review/ (catches the bug where checklist.md was unreachable at gstack-review/) - All 4 path rewrite rules tested individually across all skills - Greptile triage sidecar path correctness - Ship skill sidecar paths for pre-landing review - Claude output regression guard: zero Codex paths in any Claude skill - Setup script validation: separate link functions for Claude vs Codex, link_codex_skill_dirs reads from .agents/skills/, create_agents_sidecar links runtime assets (bin, browse, review, qa) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: regenerate Codex skills after investigate rename merge Remove stale gstack-debug, add gstack-investigate, regenerate all Codex skills to pick up changes merged from main (investigate rename, platform-agnostic templates, review helpers). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: Codex E2E uses ~/.codex/ auth, not OPENAI_API_KEY - Remove OPENAI_API_KEY gate from test prerequisites - Copy real ~/.codex/ auth config into temp HOME so codex can authenticate - Increase review test timeout to 540s (codex does thorough 60+ tool call reviews) - Document in CLAUDE.md that Codex uses its own auth config Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+232
-54
@@ -19,9 +19,44 @@ const DRY_RUN = process.argv.includes('--dry-run');
|
||||
|
||||
// ─── Template Context ───────────────────────────────────────
|
||||
|
||||
type Host = 'claude' | 'codex';
|
||||
|
||||
const HOST_ARG = process.argv.find(a => a.startsWith('--host'));
|
||||
const HOST: Host = (() => {
|
||||
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 === 'claude') return 'claude';
|
||||
throw new Error(`Unknown host: ${val}. Use claude, codex, or agents.`);
|
||||
})();
|
||||
|
||||
interface HostPaths {
|
||||
skillRoot: string;
|
||||
localSkillRoot: string;
|
||||
binDir: string;
|
||||
browseDir: string;
|
||||
}
|
||||
|
||||
const HOST_PATHS: Record<Host, HostPaths> = {
|
||||
claude: {
|
||||
skillRoot: '~/.claude/skills/gstack',
|
||||
localSkillRoot: '.claude/skills/gstack',
|
||||
binDir: '~/.claude/skills/gstack/bin',
|
||||
browseDir: '~/.claude/skills/gstack/browse/dist',
|
||||
},
|
||||
codex: {
|
||||
skillRoot: '~/.codex/skills/gstack',
|
||||
localSkillRoot: '.agents/skills/gstack',
|
||||
binDir: '~/.codex/skills/gstack/bin',
|
||||
browseDir: '~/.codex/skills/gstack/browse/dist',
|
||||
},
|
||||
};
|
||||
|
||||
interface TemplateContext {
|
||||
skillName: string;
|
||||
tmplPath: string;
|
||||
host: Host;
|
||||
paths: HostPaths;
|
||||
}
|
||||
|
||||
// ─── Placeholder Resolvers ──────────────────────────────────
|
||||
@@ -101,18 +136,18 @@ function generateSnapshotFlags(_ctx: TemplateContext): string {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function generatePreamble(ctx: TemplateContext): string {
|
||||
function generatePreambleBash(ctx: TemplateContext): string {
|
||||
return `## Preamble (run first)
|
||||
|
||||
\`\`\`bash
|
||||
_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true)
|
||||
_UPD=$(${ctx.paths.binDir}/gstack-update-check 2>/dev/null || ${ctx.paths.localSkillRoot}/bin/gstack-update-check 2>/dev/null || true)
|
||||
[ -n "$_UPD" ] && echo "$_UPD" || true
|
||||
mkdir -p ~/.gstack/sessions
|
||||
touch ~/.gstack/sessions/"$PPID"
|
||||
_SESSIONS=$(find ~/.gstack/sessions -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' ')
|
||||
find ~/.gstack/sessions -mmin +120 -type f -delete 2>/dev/null || true
|
||||
_CONTRIB=$(~/.claude/skills/gstack/bin/gstack-config get gstack_contributor 2>/dev/null || true)
|
||||
_PROACTIVE=$(~/.claude/skills/gstack/bin/gstack-config get proactive 2>/dev/null || echo "true")
|
||||
_CONTRIB=$(${ctx.paths.binDir}/gstack-config get gstack_contributor 2>/dev/null || true)
|
||||
_PROACTIVE=$(${ctx.paths.binDir}/gstack-config get proactive 2>/dev/null || echo "true")
|
||||
_BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
|
||||
echo "BRANCH: $_BRANCH"
|
||||
echo "PROACTIVE: $_PROACTIVE"
|
||||
@@ -126,15 +161,19 @@ echo "TELEMETRY: \${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
echo '{"skill":"${ctx.skillName}","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
for _PF in ~/.gstack/analytics/.pending-*; do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done
|
||||
\`\`\`
|
||||
for _PF in ~/.gstack/analytics/.pending-*; do [ -f "$_PF" ] && ${ctx.paths.binDir}/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done
|
||||
\`\`\``;
|
||||
}
|
||||
|
||||
If \`PROACTIVE\` is \`"false"\`, do not proactively suggest gstack skills — only invoke
|
||||
function generateUpgradeCheck(ctx: TemplateContext): string {
|
||||
return `If \`PROACTIVE\` is \`"false"\`, do not proactively suggest gstack skills — only invoke
|
||||
them when the user explicitly asks. The user opted out of proactive suggestions.
|
||||
|
||||
If output shows \`UPGRADE_AVAILABLE <old> <new>\`: read \`~/.claude/skills/gstack/gstack-upgrade/SKILL.md\` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). If \`JUST_UPGRADED <from> <to>\`: tell user "Running gstack v{to} (just updated!)" and continue.
|
||||
If output shows \`UPGRADE_AVAILABLE <old> <new>\`: read \`${ctx.paths.skillRoot}/gstack-upgrade/SKILL.md\` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). If \`JUST_UPGRADED <from> <to>\`: tell user "Running gstack v{to} (just updated!)" and continue.`;
|
||||
}
|
||||
|
||||
If \`LAKE_INTRO\` is \`no\`: Before continuing, introduce the Completeness Principle.
|
||||
function generateLakeIntro(): string {
|
||||
return `If \`LAKE_INTRO\` is \`no\`: Before continuing, introduce the Completeness Principle.
|
||||
Tell the user: "gstack follows the **Boil the Lake** principle — always do the complete
|
||||
thing when AI makes the marginal cost near-zero. Read more: https://garryslist.org/posts/boil-the-ocean"
|
||||
Then offer to open the essay in their default browser:
|
||||
@@ -144,9 +183,11 @@ open https://garryslist.org/posts/boil-the-ocean
|
||||
touch ~/.gstack/.completeness-intro-seen
|
||||
\`\`\`
|
||||
|
||||
Only run \`open\` if the user says yes. Always run \`touch\` to mark as seen. This only happens once.
|
||||
Only run \`open\` if the user says yes. Always run \`touch\` to mark as seen. This only happens once.`;
|
||||
}
|
||||
|
||||
If \`TEL_PROMPTED\` is \`no\` AND \`LAKE_INTRO\` is \`yes\`: After the lake intro is handled,
|
||||
function generateTelemetryPrompt(ctx: TemplateContext): string {
|
||||
return `If \`TEL_PROMPTED\` is \`no\` AND \`LAKE_INTRO\` is \`yes\`: After the lake intro is handled,
|
||||
ask the user about telemetry. Use AskUserQuestion:
|
||||
|
||||
> gstack can share anonymous usage data (which skills you use, how long they take, crash info)
|
||||
@@ -157,17 +198,19 @@ Options:
|
||||
- A) Yes, share anonymous data (recommended)
|
||||
- B) No thanks
|
||||
|
||||
If A: run \`~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous\`
|
||||
If B: run \`~/.claude/skills/gstack/bin/gstack-config set telemetry off\`
|
||||
If A: run \`${ctx.paths.binDir}/gstack-config set telemetry anonymous\`
|
||||
If B: run \`${ctx.paths.binDir}/gstack-config set telemetry off\`
|
||||
|
||||
Always run:
|
||||
\`\`\`bash
|
||||
touch ~/.gstack/.telemetry-prompted
|
||||
\`\`\`
|
||||
|
||||
This only happens once. If \`TEL_PROMPTED\` is \`yes\`, skip this entirely.
|
||||
This only happens once. If \`TEL_PROMPTED\` is \`yes\`, skip this entirely.`;
|
||||
}
|
||||
|
||||
## AskUserQuestion Format
|
||||
function generateAskUserFormat(_ctx: TemplateContext): string {
|
||||
return `## AskUserQuestion Format
|
||||
|
||||
**ALWAYS follow this structure for every AskUserQuestion call:**
|
||||
1. **Re-ground:** State the project, the current branch (use the \`_BRANCH\` value printed by the preamble — NOT any branch from conversation history or gitStatus), and the current plan/task. (1-2 sentences)
|
||||
@@ -177,9 +220,11 @@ This only happens once. If \`TEL_PROMPTED\` is \`yes\`, skip this entirely.
|
||||
|
||||
Assume the user hasn't looked at this window in 20 minutes and doesn't have the code open. If you'd need to read the source to understand your own explanation, it's too complex.
|
||||
|
||||
Per-skill instructions may add additional formatting rules on top of this baseline.
|
||||
Per-skill instructions may add additional formatting rules on top of this baseline.`;
|
||||
}
|
||||
|
||||
## Completeness Principle — Boil the Lake
|
||||
function generateCompletenessSection(): string {
|
||||
return `## Completeness Principle — Boil the Lake
|
||||
|
||||
AI-assisted coding makes the marginal cost of completeness near-zero. When you present options:
|
||||
|
||||
@@ -202,9 +247,11 @@ AI-assisted coding makes the marginal cost of completeness near-zero. When you p
|
||||
- BAD: "Choose B — it covers 90% of the value with less code." (If A is only 70 lines more, choose A.)
|
||||
- BAD: "We can skip edge case handling to save time." (Edge case handling costs minutes with CC.)
|
||||
- BAD: "Let's defer test coverage to a follow-up PR." (Tests are the cheapest lake to boil.)
|
||||
- BAD: Quoting only human-team effort: "This would take 2 weeks." (Say: "2 weeks human / ~1 hour CC.")
|
||||
- BAD: Quoting only human-team effort: "This would take 2 weeks." (Say: "2 weeks human / ~1 hour CC.")`;
|
||||
}
|
||||
|
||||
## Contributor Mode
|
||||
function generateContributorMode(): string {
|
||||
return `## Contributor Mode
|
||||
|
||||
If \`_CONTRIB\` is \`true\`: you are in **contributor mode**. You're a gstack user who also helps make it better.
|
||||
|
||||
@@ -239,9 +286,11 @@ Hey gstack team — ran into this while using /{skill-name}:
|
||||
**Date:** {YYYY-MM-DD} | **Version:** {gstack version} | **Skill:** /{skill}
|
||||
\`\`\`
|
||||
|
||||
Slug: lowercase, hyphens, max 60 chars (e.g. \`browse-js-no-await\`). Skip if file already exists. Max 3 reports per session. File inline and continue — don't stop the workflow. Tell user: "Filed gstack field report: {title}"
|
||||
Slug: lowercase, hyphens, max 60 chars (e.g. \`browse-js-no-await\`). Skip if file already exists. Max 3 reports per session. File inline and continue — don't stop the workflow. Tell user: "Filed gstack field report: {title}"`;
|
||||
}
|
||||
|
||||
## Completion Status Protocol
|
||||
function generateCompletionStatus(): string {
|
||||
return `## Completion Status Protocol
|
||||
|
||||
When completing a skill workflow, report status using one of:
|
||||
- **DONE** — All steps completed successfully. Evidence provided for each claim.
|
||||
@@ -288,14 +337,27 @@ If you cannot determine the outcome, use "unknown". This runs in the background
|
||||
never blocks the user.`;
|
||||
}
|
||||
|
||||
function generateBrowseSetup(_ctx: TemplateContext): string {
|
||||
function generatePreamble(ctx: TemplateContext): string {
|
||||
return [
|
||||
generatePreambleBash(ctx),
|
||||
generateUpgradeCheck(ctx),
|
||||
generateLakeIntro(),
|
||||
generateTelemetryPrompt(ctx),
|
||||
generateAskUserFormat(ctx),
|
||||
generateCompletenessSection(),
|
||||
generateContributorMode(),
|
||||
generateCompletionStatus(),
|
||||
].join('\n\n');
|
||||
}
|
||||
|
||||
function generateBrowseSetup(ctx: TemplateContext): string {
|
||||
return `## SETUP (run this check BEFORE any browse command)
|
||||
|
||||
\`\`\`bash
|
||||
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
B=""
|
||||
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse"
|
||||
[ -z "$B" ] && B=~/.claude/skills/gstack/browse/dist/browse
|
||||
[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse" ] && B="$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse"
|
||||
[ -z "$B" ] && B=${ctx.paths.browseDir}/browse
|
||||
if [ -x "$B" ]; then
|
||||
echo "READY: $B"
|
||||
else
|
||||
@@ -1193,19 +1255,127 @@ const RESOLVERS: Record<string, (ctx: TemplateContext) => string> = {
|
||||
TEST_BOOTSTRAP: generateTestBootstrap,
|
||||
};
|
||||
|
||||
// ─── Codex Helpers ───────────────────────────────────────────
|
||||
|
||||
function codexSkillName(skillDir: string): string {
|
||||
if (skillDir === '.' || skillDir === '') return 'gstack';
|
||||
// Don't double-prefix: gstack-upgrade → gstack-upgrade (not gstack-gstack-upgrade)
|
||||
if (skillDir.startsWith('gstack-')) return skillDir;
|
||||
return `gstack-${skillDir}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform frontmatter for Codex: keep only name + description.
|
||||
* Strips allowed-tools, hooks, version, and all other fields.
|
||||
* Handles multiline block scalar descriptions (YAML | syntax).
|
||||
*/
|
||||
function transformFrontmatter(content: string, host: Host): string {
|
||||
if (host === 'claude') return content;
|
||||
|
||||
// Find frontmatter boundaries
|
||||
const fmStart = content.indexOf('---\n');
|
||||
if (fmStart !== 0) return content; // frontmatter must be at the start
|
||||
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 ---
|
||||
|
||||
// Parse name
|
||||
const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
|
||||
const name = nameMatch ? nameMatch[1].trim() : '';
|
||||
|
||||
// Parse description — handle both simple and block scalar (|) formats
|
||||
let description = '';
|
||||
const lines = frontmatter.split('\n');
|
||||
let inDescription = false;
|
||||
const descLines: string[] = [];
|
||||
for (const line of lines) {
|
||||
if (line.match(/^description:\s*\|?\s*$/)) {
|
||||
// Block scalar start: "description: |" or "description:"
|
||||
inDescription = true;
|
||||
continue;
|
||||
}
|
||||
if (line.match(/^description:\s*\S/)) {
|
||||
// Simple inline: "description: some text"
|
||||
description = line.replace(/^description:\s*/, '').trim();
|
||||
break;
|
||||
}
|
||||
if (inDescription) {
|
||||
// Block scalar continuation — indented lines (2 spaces) or blank lines
|
||||
if (line === '' || line.match(/^\s/)) {
|
||||
descLines.push(line.replace(/^ /, ''));
|
||||
} else {
|
||||
// End of block scalar — hit a non-indented, non-blank line
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (descLines.length > 0) {
|
||||
description = descLines.join('\n').trim();
|
||||
}
|
||||
|
||||
// Re-emit Codex frontmatter (name + description only)
|
||||
const indentedDesc = description.split('\n').map(l => ` ${l}`).join('\n');
|
||||
const codexFm = `---\nname: ${name}\ndescription: |\n${indentedDesc}\n---`;
|
||||
return codexFm + 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.`;
|
||||
}
|
||||
|
||||
// ─── Template Processing ────────────────────────────────────
|
||||
|
||||
const GENERATED_HEADER = `<!-- AUTO-GENERATED from {{SOURCE}} — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n`;
|
||||
|
||||
function processTemplate(tmplPath: string): { outputPath: string; content: string } {
|
||||
function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath: string; content: string } {
|
||||
const tmplContent = fs.readFileSync(tmplPath, 'utf-8');
|
||||
const relTmplPath = path.relative(ROOT, tmplPath);
|
||||
const outputPath = tmplPath.replace(/\.tmpl$/, '');
|
||||
let outputPath = tmplPath.replace(/\.tmpl$/, '');
|
||||
|
||||
// Determine skill directory relative to ROOT
|
||||
const skillDir = path.relative(ROOT, path.dirname(tmplPath));
|
||||
|
||||
// For codex host, route output to .agents/skills/{codexSkillName}/SKILL.md
|
||||
if (host === 'codex') {
|
||||
const codexName = codexSkillName(skillDir === '.' ? '' : skillDir);
|
||||
const outputDir = path.join(ROOT, '.agents', 'skills', codexName);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
outputPath = path.join(outputDir, 'SKILL.md');
|
||||
}
|
||||
|
||||
// Extract skill name from frontmatter for TemplateContext
|
||||
const nameMatch = tmplContent.match(/^name:\s*(.+)$/m);
|
||||
const skillName = nameMatch ? nameMatch[1].trim() : path.basename(path.dirname(tmplPath));
|
||||
const ctx: TemplateContext = { skillName, tmplPath };
|
||||
const ctx: TemplateContext = { skillName, tmplPath, host, paths: HOST_PATHS[host] };
|
||||
|
||||
// Replace placeholders
|
||||
let content = tmplContent.replace(/\{\{(\w+)\}\}/g, (match, name) => {
|
||||
@@ -1220,6 +1390,27 @@ function processTemplate(tmplPath: string): { outputPath: string; content: strin
|
||||
throw new Error(`Unresolved placeholders in ${relTmplPath}: ${remaining.join(', ')}`);
|
||||
}
|
||||
|
||||
// For codex host: transform frontmatter and replace Claude-specific paths
|
||||
if (host === 'codex') {
|
||||
// Extract hook safety prose BEFORE transforming frontmatter (which strips hooks)
|
||||
const safetyProse = extractHookSafetyProse(tmplContent);
|
||||
|
||||
// Transform frontmatter: keep only name + description
|
||||
content = transformFrontmatter(content, host);
|
||||
|
||||
// Insert safety advisory at the top of the body (after frontmatter)
|
||||
if (safetyProse) {
|
||||
const bodyStart = content.indexOf('\n---') + 4;
|
||||
content = content.slice(0, bodyStart) + '\n' + safetyProse + '\n' + content.slice(bodyStart);
|
||||
}
|
||||
|
||||
// Replace remaining hardcoded Claude paths with host-appropriate paths
|
||||
content = content.replace(/~\/\.claude\/skills\/gstack/g, ctx.paths.skillRoot);
|
||||
content = content.replace(/\.claude\/skills\/gstack/g, ctx.paths.localSkillRoot);
|
||||
content = content.replace(/\.claude\/skills\/review/g, '.agents/skills/gstack/review');
|
||||
content = content.replace(/\.claude\/skills/g, '.agents/skills');
|
||||
}
|
||||
|
||||
// Prepend generated header (after frontmatter)
|
||||
const header = GENERATED_HEADER.replace('{{SOURCE}}', path.basename(tmplPath));
|
||||
const fmEnd = content.indexOf('---', content.indexOf('---') + 3);
|
||||
@@ -1237,32 +1428,13 @@ function processTemplate(tmplPath: string): { outputPath: string; content: strin
|
||||
|
||||
function findTemplates(): string[] {
|
||||
const templates: string[] = [];
|
||||
const candidates = [
|
||||
path.join(ROOT, 'SKILL.md.tmpl'),
|
||||
path.join(ROOT, 'browse', 'SKILL.md.tmpl'),
|
||||
path.join(ROOT, 'qa', 'SKILL.md.tmpl'),
|
||||
path.join(ROOT, 'qa-only', 'SKILL.md.tmpl'),
|
||||
path.join(ROOT, 'setup-browser-cookies', 'SKILL.md.tmpl'),
|
||||
path.join(ROOT, 'ship', 'SKILL.md.tmpl'),
|
||||
path.join(ROOT, 'review', 'SKILL.md.tmpl'),
|
||||
path.join(ROOT, 'plan-ceo-review', 'SKILL.md.tmpl'),
|
||||
path.join(ROOT, 'plan-eng-review', 'SKILL.md.tmpl'),
|
||||
path.join(ROOT, 'retro', 'SKILL.md.tmpl'),
|
||||
path.join(ROOT, 'office-hours', 'SKILL.md.tmpl'),
|
||||
path.join(ROOT, 'investigate', 'SKILL.md.tmpl'),
|
||||
path.join(ROOT, 'gstack-upgrade', 'SKILL.md.tmpl'),
|
||||
path.join(ROOT, 'plan-design-review', 'SKILL.md.tmpl'),
|
||||
path.join(ROOT, 'design-review', 'SKILL.md.tmpl'),
|
||||
path.join(ROOT, 'design-consultation', 'SKILL.md.tmpl'),
|
||||
path.join(ROOT, 'document-release', 'SKILL.md.tmpl'),
|
||||
path.join(ROOT, 'codex', 'SKILL.md.tmpl'),
|
||||
path.join(ROOT, 'careful', 'SKILL.md.tmpl'),
|
||||
path.join(ROOT, 'freeze', 'SKILL.md.tmpl'),
|
||||
path.join(ROOT, 'guard', 'SKILL.md.tmpl'),
|
||||
path.join(ROOT, 'unfreeze', 'SKILL.md.tmpl'),
|
||||
];
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) templates.push(p);
|
||||
const rootTmpl = path.join(ROOT, 'SKILL.md.tmpl');
|
||||
if (fs.existsSync(rootTmpl)) templates.push(rootTmpl);
|
||||
|
||||
for (const entry of fs.readdirSync(ROOT, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
||||
const tmpl = path.join(ROOT, entry.name, 'SKILL.md.tmpl');
|
||||
if (fs.existsSync(tmpl)) templates.push(tmpl);
|
||||
}
|
||||
return templates;
|
||||
}
|
||||
@@ -1270,7 +1442,13 @@ function findTemplates(): string[] {
|
||||
let hasChanges = false;
|
||||
|
||||
for (const tmplPath of findTemplates()) {
|
||||
const { outputPath, content } = processTemplate(tmplPath);
|
||||
// Skip /codex skill for codex host (self-referential — it's a Claude wrapper around codex exec)
|
||||
if (HOST === 'codex') {
|
||||
const dir = path.basename(path.dirname(tmplPath));
|
||||
if (dir === 'codex') continue;
|
||||
}
|
||||
|
||||
const { outputPath, content } = processTemplate(tmplPath, HOST);
|
||||
const relOutput = path.relative(ROOT, outputPath);
|
||||
|
||||
if (DRY_RUN) {
|
||||
|
||||
+49
-3
@@ -96,21 +96,67 @@ for (const file of SKILL_FILES) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Codex Skills ───────────────────────────────────────────
|
||||
|
||||
const AGENTS_DIR = path.join(ROOT, '.agents', 'skills');
|
||||
if (fs.existsSync(AGENTS_DIR)) {
|
||||
console.log('\n Codex Skills (.agents/skills/):');
|
||||
const codexDirs = fs.readdirSync(AGENTS_DIR).sort();
|
||||
let codexCount = 0;
|
||||
let codexMissing = 0;
|
||||
for (const dir of codexDirs) {
|
||||
const skillMd = path.join(AGENTS_DIR, dir, 'SKILL.md');
|
||||
if (fs.existsSync(skillMd)) {
|
||||
codexCount++;
|
||||
const content = fs.readFileSync(skillMd, 'utf-8');
|
||||
// Quick validation: must have frontmatter with name + description only
|
||||
const hasClaude = content.includes('.claude/skills');
|
||||
if (hasClaude) {
|
||||
hasErrors = true;
|
||||
console.log(` \u274c ${dir.padEnd(30)} — contains .claude/skills reference`);
|
||||
} else {
|
||||
console.log(` \u2705 ${dir.padEnd(30)} — OK`);
|
||||
}
|
||||
} else {
|
||||
codexMissing++;
|
||||
hasErrors = true;
|
||||
console.log(` \u274c ${dir.padEnd(30)} — SKILL.md missing`);
|
||||
}
|
||||
}
|
||||
console.log(` Total: ${codexCount} skills, ${codexMissing} missing`);
|
||||
} else {
|
||||
console.log('\n Codex Skills: .agents/skills/ not found (run: bun run gen:skill-docs --host codex)');
|
||||
}
|
||||
|
||||
// ─── Freshness ──────────────────────────────────────────────
|
||||
|
||||
console.log('\n Freshness:');
|
||||
console.log('\n Freshness (Claude):');
|
||||
try {
|
||||
execSync('bun run scripts/gen-skill-docs.ts --dry-run', { cwd: ROOT, stdio: 'pipe' });
|
||||
console.log(' \u2705 All generated files are fresh');
|
||||
console.log(' \u2705 All Claude generated files are fresh');
|
||||
} catch (err: any) {
|
||||
hasErrors = true;
|
||||
const output = err.stdout?.toString() || '';
|
||||
console.log(' \u274c Generated files are stale:');
|
||||
console.log(' \u274c Claude generated files are stale:');
|
||||
for (const line of output.split('\n').filter((l: string) => l.startsWith('STALE'))) {
|
||||
console.log(` ${line}`);
|
||||
}
|
||||
console.log(' Run: bun run gen:skill-docs');
|
||||
}
|
||||
|
||||
console.log('\n Freshness (Codex):');
|
||||
try {
|
||||
execSync('bun run scripts/gen-skill-docs.ts --host codex --dry-run', { cwd: ROOT, stdio: 'pipe' });
|
||||
console.log(' \u2705 All Codex generated files are fresh');
|
||||
} catch (err: any) {
|
||||
hasErrors = true;
|
||||
const output = err.stdout?.toString() || '';
|
||||
console.log(' \u274c Codex generated files are stale:');
|
||||
for (const line of output.split('\n').filter((l: string) => l.startsWith('STALE'))) {
|
||||
console.log(` ${line}`);
|
||||
}
|
||||
console.log(' Run: bun run gen:skill-docs --host codex');
|
||||
}
|
||||
|
||||
console.log('');
|
||||
process.exit(hasErrors ? 1 : 0);
|
||||
|
||||
Reference in New Issue
Block a user