forked from garrytan/gstack
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgen-llms-txt.ts
More file actions
259 lines (234 loc) · 8.99 KB
/
Copy pathgen-llms-txt.ts
File metadata and controls
259 lines (234 loc) · 8.99 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
#!/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<Record<string, { category: string; description: string; usage?: string }>> {
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<string, { category: string; description: string; usage?: string }> };
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<GenerateResult> {
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 <command> [args]` (or `$B` shorthand).');
lines.push('- Design commands run as `design <command> [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 <command> [args]`. Full reference: `browse/SKILL.md`.');
lines.push('');
const byCategory: Record<string, Array<{ name: string; description: string; usage?: string }>> = {};
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 <command> [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<GenerateResult> {
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 ──────────────────────────────────────────────
// Wrapped in an IIFE so top-level await doesn't make this module async-by-
// import (which would break require() consumers like
// test/gen-skill-docs.test.ts that pull writeLlmsTxt indirectly via
// gen-skill-docs).
if (import.meta.main) {
void (async () => {
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}`);
}
})();
}