diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index f5dd794b2..5ec443398 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -146,13 +146,18 @@ const EXPLAIN_LEVEL: 'default' | 'terse' = (() => { })(); // ─── Out-dir (dev workspace render isolation) ─────────────── -// --out-dir redirects Claude SKILL.md + section output to a separate -// (untracked) directory instead of writing in place, AND rewrites the literal -// section-base path (`~/.claude/skills/gstack//sections/`) inside the -// generated content to point at the out-dir, so section Reads resolve to the -// rendered copy rather than the global install. Used by bin/dev-setup to render -// the gbrain `:user` variant for a Conductor workspace without dirtying tracked -// source. Default (unset) = in-place, behavior unchanged. Claude host only. +// --out-dir redirects ALL generated output (Claude SKILL.md + +// sections, external-host trees like .agents/.factory, openclaw docs, +// gstack/llms.txt) into a separate (untracked) directory instead of writing +// in place. OUTPUTS ONLY: inputs (templates, sections/, host configs) are +// always read from ROOT. For the Claude host it ALSO rewrites the literal +// section-base path (`~/.claude/skills/gstack//sections/`) inside +// generated content so section Reads resolve to the rendered copy — that +// rewrite stays Claude-only (external hosts have their own path grammar). +// Consumers: bin/dev-setup (renders the gbrain `:user` variant for a +// Conductor workspace — byte-compat pinned by gen-skill-docs-out-dir tests) +// and the former TREE_MUTATING tests, which render into a mkdtemp instead +// of mutating the live tree. Default (unset) = in-place, unchanged. const OUT_DIR_ARG = process.argv.find(a => a.startsWith('--out-dir')); const OUT_DIR: string | null = (() => { if (!OUT_DIR_ARG) return null; @@ -778,7 +783,8 @@ function processExternalHost( const hostConfig = getHostConfig(host); const name = externalSkillName(skillDir === '.' ? '' : skillDir, frontmatterName); - const outputDir = path.join(ROOT, hostConfig.hostSubdir, 'skills', name); + // --out-dir mirrors the host tree (outputs only; inputs read from ROOT). + const outputDir = path.join(OUT_DIR ?? ROOT, hostConfig.hostSubdir, 'skills', name); fs.mkdirSync(outputDir, { recursive: true }); const outputPath = path.join(outputDir, 'SKILL.md'); @@ -837,8 +843,8 @@ function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath: // Determine skill directory relative to ROOT const skillDir = path.relative(ROOT, path.dirname(tmplPath)); - // --out-dir (Claude only): mirror the skill tree into the out-dir instead of - // writing in place. External hosts compute their own paths below. + // --out-dir: mirror the skill tree into the out-dir instead of writing in + // place (external hosts compute their own OUT_DIR-aware paths below). if (OUT_DIR && host === 'claude') { outputPath = path.join(OUT_DIR, skillDir, path.basename(tmplPath).replace(/\.tmpl$/, '')); } @@ -949,7 +955,7 @@ function processSectionTemplate( outputPath = path.join(OUT_DIR || ROOT, skillDir, 'sections', fileName); } else { const externalName = externalSkillName(skillDir, parentName); - outputPath = path.join(ROOT, hostConfig.hostSubdir, 'skills', externalName, 'sections', fileName); + outputPath = path.join(OUT_DIR ?? ROOT, hostConfig.hostSubdir, 'skills', externalName, 'sections', fileName); } if (!DRY_RUN) fs.mkdirSync(path.dirname(outputPath), { recursive: true }); return { outputPath, content }; @@ -1063,6 +1069,7 @@ for (const currentHost of hostsToRun) { console.log(`FRESH: ${relOutput}`); } } else { + if (OUT_DIR) fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, content); console.log(`GENERATED: ${relOutput}`); } @@ -1079,12 +1086,14 @@ for (const currentHost of hostsToRun) { // plain markdown, no placeholder resolution — and are copied byte-for-byte // to openclaw/ at gen time. if (currentHost === 'openclaw' && !DRY_RUN) { - const openclawDir = path.join(ROOT, 'openclaw'); - const openclawTemplatesDir = path.join(openclawDir, 'templates'); + // Inputs from ROOT, outputs into OUT_DIR when set (outputs-only rule). + const openclawTemplatesDir = path.join(ROOT, 'openclaw', 'templates'); + const openclawOutDir = path.join(OUT_DIR ?? ROOT, 'openclaw'); + if (OUT_DIR) fs.mkdirSync(openclawOutDir, { recursive: true }); for (const variant of ['lite', 'full', 'plan'] as const) { const fileName = `gstack-${variant}-CLAUDE.md`; const content = fs.readFileSync(path.join(openclawTemplatesDir, fileName), 'utf-8'); - fs.writeFileSync(path.join(openclawDir, fileName), content); + fs.writeFileSync(path.join(openclawOutDir, fileName), content); console.log(`GENERATED: openclaw/${fileName}`); } } @@ -1152,7 +1161,11 @@ if (!DRY_RUN) { if (!DRY_RUN) { void (async () => { try { - const result = await writeLlmsTxt(); + const result = await writeLlmsTxt( + // Outputs-only rule: under --out-dir even this index lands there + // (a catalog-mode render must never rewrite the tracked llms.txt). + OUT_DIR ? { outputPath: path.join(OUT_DIR, 'gstack', 'llms.txt') } : {}, + ); if (result.warnings.length > 0) { for (const w of result.warnings) console.error(`[gen-llms-txt] WARN: ${w}`); } else {