From 04a813e21ff302a761216a9b00b2f6ba27bb7711 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 7 May 2026 13:35:49 -0700 Subject: [PATCH] =?UTF-8?q?feat(gstack):=20generate=20llms.txt=20=E2=80=94?= =?UTF-8?q?=20single-file=20capability=20index=20for=20AI=20agents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds scripts/gen-llms-txt.ts: produces gstack/llms.txt at repo root, indexing every skill (47), every browse command (75), and design commands when the design CLI is present. Per the llmstxt.org convention, agents can read one file to learn what gstack offers instead of crawling 47 SKILL.md files. Sources: - skill SKILL.md.tmpl frontmatter (name + description block scalar) - browse/src/commands.ts COMMAND_DESCRIPTIONS (sorted by category) - design/src/commands.ts COMMAND_DESCRIPTIONS if present (best-effort) Wired into scripts/gen-skill-docs.ts as a post-step so it regenerates on every `bun run gen:skill-docs` (the same script that re-emits all SKILL.md files). Failures are non-fatal warnings, not build breaks — the generator never blocks SKILL.md regen. Strict mode (--strict, also used by tests) throws when a skill is missing name or description in its frontmatter, catching missing metadata before it ships. Tests: shape (top-level sections, sort order, single-line summary discipline), every-skill-and-command-appears, strict-mode rejection of incomplete frontmatter, and freshness check that the committed gstack/llms.txt matches what the generator produces now. Co-Authored-By: Claude Opus 4.7 (1M context) --- gstack/llms.txt | 165 +++++++++++++++++++++++ scripts/gen-llms-txt.ts | 253 ++++++++++++++++++++++++++++++++++++ scripts/gen-skill-docs.ts | 18 +++ test/llms-txt-shape.test.ts | 102 +++++++++++++++ 4 files changed, 538 insertions(+) create mode 100644 gstack/llms.txt create mode 100644 scripts/gen-llms-txt.ts create mode 100644 test/llms-txt-shape.test.ts 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); + }); +});