mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-11 23:17:26 +08:00
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) <noreply@anthropic.com>
103 lines
4.3 KiB
TypeScript
103 lines
4.3 KiB
TypeScript
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<ReturnType<typeof generateLlmsTxt>>;
|
|
|
|
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);
|
|
});
|
|
});
|