diff --git a/gstack/llms.txt b/gstack/llms.txt new file mode 100644 index 00000000..7fb00400 --- /dev/null +++ b/gstack/llms.txt @@ -0,0 +1,165 @@ +# gstack + +> gstack is Garry's Stack: AI coding skills + a fast headless browser binary + a design CLI. This file indexes every capability so agents can discover and invoke them without crawling individual SKILL.md files. + +Conventions: +- Skills are invoked by name (e.g. `/ship`, `/plan-ceo-review`). +- Browse commands run as `browse [args]` (or `$B` shorthand). +- Design commands run as `design [args]` (or `$D`). +- Project-specific config lives in `CLAUDE.md`. Always read it first. + +## Skills + +- [/autoplan](autoplan/SKILL.md): Auto-review pipeline — reads the full CEO, design, eng, and DX review skills from disk and runs them sequentially with auto-decisions using 6 decision principles. +- [/benchmark](benchmark/SKILL.md): Performance regression detection using the browse daemon. +- [/benchmark-models](benchmark-models/SKILL.md): Cross-model benchmark for gstack skills. +- [/browse](browse/SKILL.md): Fast headless browser for QA testing and site dogfooding. +- [/canary](canary/SKILL.md): Post-deploy canary monitoring. +- [/careful](careful/SKILL.md): Safety guardrails for destructive commands. +- [/claude](claude/SKILL.md): Claude Code CLI wrapper for non-Claude hosts - three modes. +- [/codex](codex/SKILL.md): OpenAI Codex CLI wrapper — three modes. +- [/context-restore](context-restore/SKILL.md): Restore working context saved earlier by /context-save. +- [/context-save](context-save/SKILL.md): Save working context. +- [/cso](cso/SKILL.md): Chief Security Officer mode. +- [/design-consultation](design-consultation/SKILL.md): Design consultation: understands your product, researches the landscape, proposes a complete design system (aesthetic, typography, color, layout, spacing, motion), and generates font+color preview pages. +- [/design-html](design-html/SKILL.md): Design finalization: generates production-quality Pretext-native HTML/CSS. +- [/design-review](design-review/SKILL.md): Designer's eye QA: finds visual inconsistency, spacing issues, hierarchy problems, AI slop patterns, and slow interactions — then fixes them. +- [/design-shotgun](design-shotgun/SKILL.md): Design shotgun: generate multiple AI design variants, open a comparison board, collect structured feedback, and iterate. +- [/devex-review](devex-review/SKILL.md): Live developer experience audit. +- [/document-release](document-release/SKILL.md): Post-ship documentation update. +- [/freeze](freeze/SKILL.md): Restrict file edits to a specific directory for the session. +- [/gstack](gstack/SKILL.md): Fast headless browser for QA testing and site dogfooding. +- [/gstack-upgrade](gstack-upgrade/SKILL.md): Upgrade gstack to the latest version. +- [/guard](guard/SKILL.md): Full safety mode: destructive command warnings + directory-scoped edits. +- [/health](health/SKILL.md): Code quality dashboard. +- [/investigate](investigate/SKILL.md): Systematic debugging with root cause investigation. +- [/land-and-deploy](land-and-deploy/SKILL.md): Land and deploy workflow. +- [/landing-report](landing-report/SKILL.md): Read-only queue dashboard for workspace-aware ship. +- [/learn](learn/SKILL.md): Manage project learnings. +- [/make-pdf](make-pdf/SKILL.md): Turn any markdown file into a publication-quality PDF. +- [/office-hours](office-hours/SKILL.md): YC Office Hours — two modes. +- [/open-gstack-browser](open-gstack-browser/SKILL.md): Launch GStack Browser — AI-controlled Chromium with the sidebar extension baked in. +- [/pair-agent](pair-agent/SKILL.md): Pair a remote AI agent with your browser. +- [/plan-ceo-review](plan-ceo-review/SKILL.md): CEO/founder-mode plan review. +- [/plan-design-review](plan-design-review/SKILL.md): Designer's eye plan review — interactive, like CEO and Eng review. +- [/plan-devex-review](plan-devex-review/SKILL.md): Interactive developer experience plan review. +- [/plan-eng-review](plan-eng-review/SKILL.md): Eng manager-mode plan review. +- [/plan-tune](plan-tune/SKILL.md): Self-tuning question sensitivity + developer psychographic for gstack (v1: observational). +- [/qa](qa/SKILL.md): Systematically QA test a web application and fix bugs found. +- [/qa-only](qa-only/SKILL.md): Report-only QA testing. +- [/retro](retro/SKILL.md): Weekly engineering retrospective. +- [/review](review/SKILL.md): Pre-landing PR review. +- [/scrape](scrape/SKILL.md): Pull data from a web page. +- [/setup-browser-cookies](setup-browser-cookies/SKILL.md): Import cookies from your real Chromium browser into the headless browse session. +- [/setup-deploy](setup-deploy/SKILL.md): Configure deployment settings for /land-and-deploy. +- [/setup-gbrain](setup-gbrain/SKILL.md): Set up gbrain for this coding agent: install the CLI, initialize a local PGLite or Supabase brain, register MCP, capture per-remote trust policy. +- [/ship](ship/SKILL.md): Ship workflow: detect + merge base branch, run tests, review diff, bump VERSION, update CHANGELOG, commit, push, create PR. +- [/skillify](skillify/SKILL.md): Codify the most recent successful /scrape flow into a permanent browser-skill on disk. +- [/sync-gbrain](sync-gbrain/SKILL.md): Keep gbrain current with this repo's code and refresh agent search guidance in CLAUDE.md. +- [/unfreeze](unfreeze/SKILL.md): Clear the freeze boundary set by /freeze, allowing edits to all directories again. + +## Browse Commands + +Run with `browse [args]`. Full reference: `browse/SKILL.md`. + +### Extraction +- `archive [path]`: Save complete page as MHTML via CDP +- `download [path] [--base64]`: Download URL or media element to disk using browser cookies +- `scrape [--selector sel] [--dir path] [--limit N]`: Bulk download all media from page. + +### Inspection +- `attrs `: Element attributes as JSON +- `cdp [json-params]`: Raw Chrome DevTools Protocol method dispatch. +- `console [--clear|--errors]`: Console messages (--errors filters to error/warning) +- `cookies`: All cookies as JSON +- `css `: Computed CSS value +- `dialog [--clear]`: Dialog messages +- `eval `: Run JavaScript from a file in the page context and return result as string. +- `inspect [selector] [--all] [--history]`: Deep CSS inspection via CDP — full rule cascade, box model, computed styles +- `is `: State check on element. +- `js `: Run inline JavaScript expression in the page context and return result as string. +- `network [--clear]`: Network requests +- `perf`: Page load timings +- `storage | storage set `: Read both localStorage and sessionStorage as JSON. +- `ux-audit`: Extract page structure for UX behavioral analysis — site ID, nav, headings, text blocks, interactive elements. + +### Interaction +- `cleanup [--ads] [--cookies] [--sticky] [--social] [--all]`: Remove page clutter (ads, cookie banners, sticky elements, social widgets) +- `click `: Click element +- `cookie =`: Set cookie on current page domain +- `cookie-import `: Import cookies from JSON file +- `cookie-import-browser [browser] [--domain d]`: Import cookies from installed Chromium browsers (opens picker, or use --domain for direct import) +- `dialog-accept [text]`: Auto-accept next alert/confirm/prompt. +- `dialog-dismiss`: Auto-dismiss next dialog +- `fill `: Fill input +- `header :`: Set custom request header (colon-separated, sensitive values auto-redacted) +- `hover `: Hover element +- `press `: Press a Playwright keyboard key against the focused element. +- `scroll [sel|@ref]`: With a selector, smooth-scrolls the element into view. +- `select `: Select dropdown option by value, label, or visible text +- `style | style --undo [N]`: Modify CSS property on element (with undo support) +- `type `: Type into focused element +- `upload [file2...]`: Upload file(s) +- `useragent `: Set user agent +- `viewport [] [--scale ]`: Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). +- `wait `: Wait for element, network idle, or page load (timeout: 15s) + +### Meta +- `chain (JSON via stdin)`: Run a sequence of commands from JSON on stdin. +- `domain-skill save|list|show|edit|promote-to-global|rollback|rm `: Per-site notes the agent writes for itself. +- `frame `: Switch to iframe context (or main to return) +- `inbox [--clear]`: List messages from sidebar scout inbox +- `skill list|show|run|test|rm [--arg k=v]... [--timeout=Ns]`: Run a browser-skill: deterministic Playwright script that drives the daemon over loopback HTTP. +- `watch [stop]`: Passive observation — periodic snapshots while user browses + +### Navigation +- `back`: History back +- `forward`: History forward +- `goto `: Navigate to URL (http://, https://, or file:// scoped to cwd/TEMP_DIR) +- `load-html [--wait-until load|domcontentloaded|networkidle] [--tab-id ] | load-html --from-file [--tab-id ]`: Load HTML via setContent. +- `reload`: Reload page +- `url`: Print current URL + +### Reading +- `accessibility`: Full ARIA tree +- `data [--jsonld|--og|--meta|--twitter]`: Structured data: JSON-LD, Open Graph, Twitter Cards, meta tags +- `forms`: Form fields as JSON +- `html [selector]`: innerHTML of selector (throws if not found), or full page HTML if no selector given +- `links`: All links as "text → href" +- `media [--images|--videos|--audio] [selector]`: All media elements (images, videos, audio) with URLs, dimensions, types +- `text`: Cleaned page text + +### Server +- `connect`: Launch headed Chromium with Chrome extension +- `disconnect`: Disconnect headed browser, return to headless mode +- `focus [@ref]`: Bring headed browser window to foreground (macOS) +- `handoff [message]`: Open visible Chrome at current page for user takeover +- `restart`: Restart server +- `resume`: Re-snapshot after user takeover, return control to AI +- `state save|load `: Save/load browser state (cookies + URLs) +- `status`: Health check +- `stop`: Shutdown server + +### Snapshot +- `snapshot [flags]`: Accessibility tree with @e refs for element selection. + +### Tabs +- `closetab [id]`: Close tab +- `newtab [url] [--json]`: Open new tab. +- `tab `: Switch to tab +- `tab-each [args...]`: Run a command on every open tab. +- `tabs`: List open tabs + +### Visual +- `diff `: Text diff between pages +- `pdf [path] [--format letter|a4|legal] [--width --height ] [--margins ] [--margin-top --margin-right --margin-bottom --margin-left ] [--header-template ] [--footer-template ] [--page-numbers] [--tagged] [--outline] [--print-background] [--prefer-css-page-size] [--toc] [--tab-id ] | pdf --from-file [--tab-id ]`: Save the current page as PDF. +- `prettyscreenshot [--scroll-to sel|text] [--cleanup] [--hide sel...] [--width px] [path]`: Clean screenshot with optional cleanup, scroll positioning, and element hiding +- `responsive [prefix]`: Screenshots at mobile (375x812), tablet (768x1024), desktop (1280x720). +- `screenshot [--selector ] [--viewport] [--clip x,y,w,h] [--base64] [selector|@ref] [path]`: Save screenshot. + +## More + +- Repository: https://github.com/garrytan/gstack +- Top-level guide: `SKILL.md` +- Project ethos: `ETHOS.md` +- This file is auto-generated by `bun run gen:skill-docs`. diff --git a/scripts/gen-llms-txt.ts b/scripts/gen-llms-txt.ts new file mode 100644 index 00000000..1cc5cbf3 --- /dev/null +++ b/scripts/gen-llms-txt.ts @@ -0,0 +1,253 @@ +#!/usr/bin/env bun +/** + * Generate gstack/llms.txt — a single discoverable index of every gstack + * capability for AI agents. + * + * Inputs: + * - Skill SKILL.md.tmpl frontmatter (name, description) at root and one + * level deep, via scripts/discover-skills.ts + * - browse/src/commands.ts COMMAND_DESCRIPTIONS + * - design/src/commands.ts COMMAND_DESCRIPTIONS (if present) + * + * Output: gstack/llms.txt at repo root. + * + * Refresh: invoked from scripts/gen-skill-docs.ts after SKILL.md generation + * so it regenerates automatically on every skill change. + * + * Convention: https://llmstxt.org/ (single-file index agents can crawl). + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { discoverTemplates } from './discover-skills'; +import { COMMAND_DESCRIPTIONS as BROWSE_COMMANDS } from '../browse/src/commands'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const OUTPUT = path.join(ROOT, 'gstack', 'llms.txt'); + +interface SkillEntry { + name: string; + description: string; +} + +/** + * Parse YAML frontmatter at the top of a SKILL.md.tmpl file. We only need + * `name` and `description`. description: | followed by indented lines is + * the gstack convention; we collapse those into a single paragraph. + */ +function parseSkillFrontmatter(filePath: string): SkillEntry | null { + const content = fs.readFileSync(filePath, 'utf-8'); + if (!content.startsWith('---')) return null; + const end = content.indexOf('\n---', 3); + if (end < 0) return null; + const frontmatter = content.slice(3, end).split('\n'); + + let name = ''; + let description = ''; + let inDescription = false; + let descriptionLines: string[] = []; + + for (const rawLine of frontmatter) { + const line = rawLine.replace(/\r$/, ''); + if (inDescription) { + // Block-scalar continues until a non-indented (or differently-keyed) line. + if (line.startsWith(' ') || line === '') { + descriptionLines.push(line.replace(/^ /, '')); + continue; + } + inDescription = false; + // Fall through to normal key parsing for this line. + } + const m = line.match(/^([a-zA-Z_-]+):\s*(.*)$/); + if (!m) continue; + const key = m[1]; + const value = m[2]; + if (key === 'name') { + name = value.trim(); + } else if (key === 'description') { + if (value === '|' || value === '|-' || value === '>' || value === '>-') { + inDescription = true; + descriptionLines = []; + } else { + description = value.trim(); + } + } + } + + if (!description && descriptionLines.length) { + description = descriptionLines + .map((l) => l.trim()) + .filter(Boolean) + .join(' ') + .trim(); + } + + if (!name) return null; + if (!description) return null; + return { name, description }; +} + +/** + * Best-effort import of the design CLI's COMMAND_DESCRIPTIONS. Only present + * in a full gstack checkout; absent on minimal installs. Returns {} if the + * module isn't found rather than throwing. + */ +async function readDesignCommands(): Promise> { + const designCommandsPath = path.join(ROOT, 'design', 'src', 'commands.ts'); + if (!fs.existsSync(designCommandsPath)) return {}; + try { + const mod: unknown = await import(designCommandsPath); + const m = mod as { COMMAND_DESCRIPTIONS?: Record }; + return m.COMMAND_DESCRIPTIONS ?? {}; + } catch { + return {}; + } +} + +/** + * Render a one-line summary from a multi-paragraph description: take the + * first sentence (up to '.', '!', or '?') and trim. Keeps llms.txt scannable. + */ +function oneLine(text: string): string { + const first = text.split(/(?<=[.!?])\s/)[0] ?? text; + return first.replace(/\s+/g, ' ').trim(); +} + +interface GenerateOptions { + /** Override repo root (for tests). */ + root?: string; + /** When true, missing skill description should fail the build. */ + strict?: boolean; +} + +export interface GenerateResult { + content: string; + skills: SkillEntry[]; + browseCommands: string[]; + designCommands: string[]; + warnings: string[]; +} + +export async function generateLlmsTxt(opts: GenerateOptions = {}): Promise { + const root = opts.root ?? ROOT; + const warnings: string[] = []; + + const templates = discoverTemplates(root); + const skills: SkillEntry[] = []; + for (const t of templates) { + const filePath = path.join(root, t.tmpl); + const entry = parseSkillFrontmatter(filePath); + if (!entry) { + warnings.push(`skill ${t.tmpl}: missing name or description in frontmatter`); + if (opts.strict) { + throw new Error(`gen-llms-txt: ${t.tmpl} is missing name or description in frontmatter`); + } + continue; + } + skills.push(entry); + } + skills.sort((a, b) => a.name.localeCompare(b.name)); + + const browseCommands = Object.keys(BROWSE_COMMANDS).sort(); + const designCommands = Object.keys(await readDesignCommands()).sort(); + + const lines: string[] = []; + lines.push('# gstack'); + lines.push(''); + lines.push("> gstack is Garry's Stack: AI coding skills + a fast headless browser binary + a design CLI. This file indexes every capability so agents can discover and invoke them without crawling individual SKILL.md files."); + lines.push(''); + lines.push('Conventions:'); + lines.push('- Skills are invoked by name (e.g. `/ship`, `/plan-ceo-review`).'); + lines.push('- Browse commands run as `browse [args]` (or `$B` shorthand).'); + lines.push('- Design commands run as `design [args]` (or `$D`).'); + lines.push('- Project-specific config lives in `CLAUDE.md`. Always read it first.'); + lines.push(''); + + lines.push('## Skills'); + lines.push(''); + for (const skill of skills) { + const summary = oneLine(skill.description); + lines.push(`- [/${skill.name}](${skill.name}/SKILL.md): ${summary}`); + } + lines.push(''); + + lines.push('## Browse Commands'); + lines.push(''); + lines.push('Run with `browse [args]`. Full reference: `browse/SKILL.md`.'); + lines.push(''); + const byCategory: Record> = {}; + for (const cmd of browseCommands) { + const meta = BROWSE_COMMANDS[cmd]; + const cat = meta.category || 'Other'; + if (!byCategory[cat]) byCategory[cat] = []; + byCategory[cat].push({ name: cmd, description: meta.description, usage: meta.usage }); + } + for (const cat of Object.keys(byCategory).sort()) { + lines.push(`### ${cat}`); + for (const cmd of byCategory[cat]) { + const usage = cmd.usage ? `\`${cmd.usage}\`` : `\`${cmd.name}\``; + lines.push(`- ${usage}: ${oneLine(cmd.description)}`); + } + lines.push(''); + } + + if (designCommands.length > 0) { + lines.push('## Design Commands'); + lines.push(''); + lines.push('Run with `design [args]`. Full reference: `design/SKILL.md`.'); + lines.push(''); + const designMeta = await readDesignCommands(); + for (const cmd of designCommands) { + const meta = designMeta[cmd]; + lines.push(`- \`${cmd}\`: ${oneLine(meta.description)}`); + } + lines.push(''); + } + + lines.push('## More'); + lines.push(''); + lines.push('- Repository: https://github.com/garrytan/gstack'); + lines.push('- Top-level guide: `SKILL.md`'); + lines.push('- Project ethos: `ETHOS.md`'); + lines.push('- This file is auto-generated by `bun run gen:skill-docs`.'); + lines.push(''); + + return { + content: lines.join('\n'), + skills, + browseCommands, + designCommands, + warnings, + }; +} + +export async function writeLlmsTxt(opts: GenerateOptions & { outputPath?: string } = {}): Promise { + const result = await generateLlmsTxt(opts); + const outputPath = opts.outputPath ?? OUTPUT; + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, result.content, { encoding: 'utf-8' }); + return result; +} + +// ─── CLI entry ────────────────────────────────────────────── +if (import.meta.main) { + const strict = process.argv.includes('--strict'); + const dryRun = process.argv.includes('--dry-run'); + const result = dryRun + ? await generateLlmsTxt({ strict }) + : await writeLlmsTxt({ strict }); + + for (const w of result.warnings) console.error(`[gen-llms-txt] WARN: ${w}`); + + if (dryRun) { + const existing = fs.existsSync(OUTPUT) ? fs.readFileSync(OUTPUT, 'utf-8') : ''; + if (existing !== result.content) { + console.error('[gen-llms-txt] OUT OF DATE — run `bun run gen:skill-docs` to regenerate gstack/llms.txt'); + process.exit(1); + } + console.log('[gen-llms-txt] up to date'); + } else { + console.log(`[gen-llms-txt] wrote ${OUTPUT}`); + console.log(`[gen-llms-txt] skills=${result.skills.length} browse=${result.browseCommands.length} design=${result.designCommands.length}`); + } +} diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index c801af08..2f23c26a 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -662,3 +662,21 @@ if (!DRY_RUN) { } } catch { /* non-fatal */ } } + +// Regenerate gstack/llms.txt — single-file capability index for AI agents. +// Runs after SKILL.md generation so it sees current skill descriptions and +// browse command list. Freshness is asserted in test/llms-txt-shape.test.ts. +if (!DRY_RUN) { + try { + const { writeLlmsTxt } = await import('./gen-llms-txt'); + const result = await writeLlmsTxt(); + if (result.warnings.length > 0) { + for (const w of result.warnings) console.error(`[gen-llms-txt] WARN: ${w}`); + } else { + console.log(`[gen-llms-txt] gstack/llms.txt: ${result.skills.length} skills, ${result.browseCommands.length} browse commands`); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[gen-llms-txt] FAILED: ${msg}`); + } +} diff --git a/test/llms-txt-shape.test.ts b/test/llms-txt-shape.test.ts new file mode 100644 index 00000000..3cbebb42 --- /dev/null +++ b/test/llms-txt-shape.test.ts @@ -0,0 +1,102 @@ +import { describe, test, expect, beforeAll } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { generateLlmsTxt } from '../scripts/gen-llms-txt'; +import { discoverTemplates } from '../scripts/discover-skills'; + +const ROOT = path.resolve(import.meta.dir, '..'); + +let generated: Awaited>; + +beforeAll(async () => { + generated = await generateLlmsTxt({ root: ROOT }); +}); + +describe('gen-llms-txt — shape', () => { + test('emits required top-level sections', () => { + expect(generated.content).toContain('# gstack'); + expect(generated.content).toContain('## Skills'); + expect(generated.content).toContain('## Browse Commands'); + // Convention block + expect(generated.content).toContain('Skills are invoked by name'); + expect(generated.content).toContain('Browse commands run as'); + // Footer + expect(generated.content).toContain('## More'); + expect(generated.content).toContain('auto-generated'); + }); + + test('every skill .tmpl in the repo appears in the index', () => { + const templates = discoverTemplates(ROOT); + // Filter to those that successfully parsed (have name + description). + expect(generated.skills.length).toBeGreaterThan(0); + expect(generated.skills.length).toBeLessThanOrEqual(templates.length); + + for (const skill of generated.skills) { + expect(generated.content).toMatch(new RegExp(`/${skill.name}\\b`)); + } + }); + + test('every browse command in COMMAND_DESCRIPTIONS appears in the index', () => { + expect(generated.browseCommands.length).toBeGreaterThan(0); + for (const cmd of generated.browseCommands) { + // Use word boundaries; backtick-wrapped command name OR usage. + expect(generated.content).toContain(cmd); + } + }); + + test('skills are sorted alphabetically', () => { + const names = generated.skills.map((s) => s.name); + const sorted = [...names].sort((a, b) => a.localeCompare(b)); + expect(names).toEqual(sorted); + }); + + test('description is collapsed to a single line per entry', () => { + // Find the Skills section and assert no entry contains a literal newline + // mid-bullet (descriptions can be multi-paragraph in frontmatter; oneLine + // collapses them). + const skillsSection = generated.content.split('## Skills')[1].split('## Browse Commands')[0]; + const bullets = skillsSection.split('\n').filter((l) => l.startsWith('- [')); + for (const b of bullets) { + // No mid-bullet newline inside the bullet. + expect(b).not.toMatch(/\n/); + } + }); +}); + +describe('gen-llms-txt — strict mode', () => { + test('does NOT throw on the live skill set (every gstack skill has name + description)', async () => { + // The point of strict mode: catch missing-frontmatter skills before they + // sneak past gen-skill-docs. The current repo state should pass strict. + await expect(generateLlmsTxt({ root: ROOT, strict: true })).resolves.toBeDefined(); + }); + + test('throws on a synthesized skill missing description', async () => { + // Set up a temp repo-shaped tree with one skill that has only a name. + const tmp = fs.mkdtempSync(path.join(require('os').tmpdir(), 'llms-txt-strict-')); + try { + fs.mkdirSync(path.join(tmp, 'badskill')); + // Frontmatter has name but no description. + fs.writeFileSync( + path.join(tmp, 'badskill', 'SKILL.md.tmpl'), + '---\nname: badskill\n---\nbody\n', + ); + // Need a dummy browse/src/commands.ts shape — but we read from real + // ROOT for browse commands. The strict failure should fire on the + // skill before that. So we point at the real browse/src indirectly + // through the absolute import in gen-llms-txt.ts (already imported + // at module load). That's fine — strict throws on parsing, before + // browse commands are read. But the real ROOT includes valid skills + // too. Use the temp tree as `root` to isolate. + await expect(generateLlmsTxt({ root: tmp, strict: true })).rejects.toThrow(/missing name or description/); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); + +describe('gen-llms-txt — generated file is fresh', () => { + test('committed gstack/llms.txt matches what the generator produces now', () => { + const committed = fs.readFileSync(path.join(ROOT, 'gstack', 'llms.txt'), 'utf-8'); + expect(committed).toBe(generated.content); + }); +});