mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-04 12:45:10 +02:00
266 lines
11 KiB
TypeScript
266 lines
11 KiB
TypeScript
#!/usr/bin/env bun
|
|
/**
|
|
* Generate SKILL.md files from .tmpl templates.
|
|
*
|
|
* Pipeline:
|
|
* read .tmpl → find {{PLACEHOLDERS}} → resolve from source → format → write .md
|
|
*
|
|
* Supports --dry-run: generate to memory, exit 1 if different from committed file.
|
|
* Used by skill:check and CI freshness checks.
|
|
*/
|
|
|
|
import { COMMAND_DESCRIPTIONS } from '../browse/src/commands';
|
|
import { SNAPSHOT_FLAGS } from '../browse/src/snapshot';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const DRY_RUN = process.argv.includes('--dry-run');
|
|
|
|
// ─── Placeholder Resolvers ──────────────────────────────────
|
|
|
|
function generateCommandReference(): string {
|
|
// Group commands by category
|
|
const groups = new Map<string, Array<{ command: string; description: string; usage?: string }>>();
|
|
for (const [cmd, meta] of Object.entries(COMMAND_DESCRIPTIONS)) {
|
|
const list = groups.get(meta.category) || [];
|
|
list.push({ command: cmd, description: meta.description, usage: meta.usage });
|
|
groups.set(meta.category, list);
|
|
}
|
|
|
|
// Category display order
|
|
const categoryOrder = [
|
|
'Navigation', 'Reading', 'Interaction', 'Inspection',
|
|
'Visual', 'Snapshot', 'Meta', 'Tabs', 'Server',
|
|
];
|
|
|
|
const sections: string[] = [];
|
|
for (const category of categoryOrder) {
|
|
const commands = groups.get(category);
|
|
if (!commands || commands.length === 0) continue;
|
|
|
|
// Sort alphabetically within category
|
|
commands.sort((a, b) => a.command.localeCompare(b.command));
|
|
|
|
sections.push(`### ${category}`);
|
|
sections.push('| Command | Description |');
|
|
sections.push('|---------|-------------|');
|
|
for (const cmd of commands) {
|
|
const display = cmd.usage ? `\`${cmd.usage}\`` : `\`${cmd.command}\``;
|
|
sections.push(`| ${display} | ${cmd.description} |`);
|
|
}
|
|
sections.push('');
|
|
}
|
|
|
|
return sections.join('\n').trimEnd();
|
|
}
|
|
|
|
function generateSnapshotFlags(): string {
|
|
const lines: string[] = [
|
|
'The snapshot is your primary tool for understanding and interacting with pages.',
|
|
'',
|
|
'```',
|
|
];
|
|
|
|
for (const flag of SNAPSHOT_FLAGS) {
|
|
const label = flag.valueHint ? `${flag.short} ${flag.valueHint}` : flag.short;
|
|
lines.push(`${label.padEnd(10)}${flag.long.padEnd(24)}${flag.description}`);
|
|
}
|
|
|
|
lines.push('```');
|
|
lines.push('');
|
|
lines.push('All flags can be combined freely. `-o` only applies when `-a` is also used.');
|
|
lines.push('Example: `$B snapshot -i -a -C -o /tmp/annotated.png`');
|
|
lines.push('');
|
|
lines.push('**Ref numbering:** @e refs are assigned sequentially (@e1, @e2, ...) in tree order.');
|
|
lines.push('@c refs from `-C` are numbered separately (@c1, @c2, ...).');
|
|
lines.push('');
|
|
lines.push('After snapshot, use @refs as selectors in any command:');
|
|
lines.push('```bash');
|
|
lines.push('$B click @e3 $B fill @e4 "value" $B hover @e1');
|
|
lines.push('$B html @e2 $B css @e5 "color" $B attrs @e6');
|
|
lines.push('$B click @c1 # cursor-interactive ref (from -C)');
|
|
lines.push('```');
|
|
lines.push('');
|
|
lines.push('**Output format:** indented accessibility tree with @ref IDs, one element per line.');
|
|
lines.push('```');
|
|
lines.push(' @e1 [heading] "Welcome" [level=1]');
|
|
lines.push(' @e2 [textbox] "Email"');
|
|
lines.push(' @e3 [button] "Submit"');
|
|
lines.push('```');
|
|
lines.push('');
|
|
lines.push('Refs are invalidated on navigation — run `snapshot` again after `goto`.');
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function generatePreamble(): string {
|
|
return `## Preamble (run first)
|
|
|
|
\`\`\`bash
|
|
_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true)
|
|
[ -n "$_UPD" ] && echo "$_UPD" || true
|
|
mkdir -p ~/.gstack/sessions
|
|
touch ~/.gstack/sessions/"$PPID"
|
|
_SESSIONS=$(find ~/.gstack/sessions -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' ')
|
|
find ~/.gstack/sessions -mmin +120 -type f -delete 2>/dev/null || true
|
|
_CONTRIB=$(~/.claude/skills/gstack/bin/gstack-config get gstack_contributor 2>/dev/null || true)
|
|
\`\`\`
|
|
|
|
If output shows \`UPGRADE_AVAILABLE <old> <new>\`: read \`~/.claude/skills/gstack/gstack-upgrade/SKILL.md\` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). If \`JUST_UPGRADED <from> <to>\`: tell user "Running gstack v{to} (just updated!)" and continue.
|
|
|
|
## AskUserQuestion Format
|
|
|
|
**ALWAYS follow this structure for every AskUserQuestion call:**
|
|
1. Context: project name, current branch, what we're working on (1-2 sentences)
|
|
2. The specific question or decision point
|
|
3. \`RECOMMENDATION: Choose [X] because [one-line reason]\`
|
|
4. Lettered options: \`A) ... B) ... C) ...\`
|
|
|
|
If \`_SESSIONS\` is 3 or more: the user is juggling multiple gstack sessions and context-switching heavily. **ELI16 mode** — they may not remember what this conversation is about. Every AskUserQuestion MUST re-ground them: state the project, the branch, the current plan/task, then the specific problem, THEN the recommendation and options. Be extra clear and self-contained — assume they haven't looked at this window in 20 minutes.
|
|
|
|
Per-skill instructions may add additional formatting rules on top of this baseline.
|
|
|
|
## Contributor Mode
|
|
|
|
If \`_CONTRIB\` is \`true\`: you are in **contributor mode**. When you hit friction with **gstack itself** (not the user's app), file a field report. Think: "hey, I was trying to do X with gstack and it didn't work / was confusing / was annoying. Here's what happened."
|
|
|
|
**gstack issues:** browse command fails/wrong output, snapshot missing elements, skill instructions unclear or misleading, binary crash/hang, unhelpful error message, any rough edge or annoyance — even minor stuff.
|
|
**NOT gstack issues:** user's app bugs, network errors to user's URL, auth failures on user's site.
|
|
|
|
**To file:** write \`~/.gstack/contributor-logs/{slug}.md\` with this structure:
|
|
|
|
\`\`\`
|
|
# {Title}
|
|
|
|
Hey gstack team — ran into this while using /{skill-name}:
|
|
|
|
**What I was trying to do:** {what the user/agent was attempting}
|
|
**What happened instead:** {what actually happened}
|
|
**How annoying (1-5):** {1=meh, 3=friction, 5=blocker}
|
|
|
|
## Steps to reproduce
|
|
1. {step}
|
|
|
|
## Raw output
|
|
(wrap any error messages or unexpected output in a markdown code block)
|
|
|
|
**Date:** {YYYY-MM-DD} | **Version:** {gstack version} | **Skill:** /{skill}
|
|
\`\`\`
|
|
|
|
Then run: \`mkdir -p ~/.gstack/contributor-logs && open ~/.gstack/contributor-logs/{slug}.md\`
|
|
|
|
Slug: lowercase, hyphens, max 60 chars (e.g. \`browse-snapshot-ref-gap\`). Skip if file already exists. Max 3 reports per session. File inline and continue — don't stop the workflow. Tell user: "Filed gstack field report: {title}"`;
|
|
}
|
|
|
|
function generateBrowseSetup(): string {
|
|
return `## SETUP (run this check BEFORE any browse command)
|
|
|
|
\`\`\`bash
|
|
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
|
B=""
|
|
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse"
|
|
[ -z "$B" ] && B=~/.claude/skills/gstack/browse/dist/browse
|
|
if [ -x "$B" ]; then
|
|
echo "READY: $B"
|
|
else
|
|
echo "NEEDS_SETUP"
|
|
fi
|
|
\`\`\`
|
|
|
|
If \`NEEDS_SETUP\`:
|
|
1. Tell the user: "gstack browse needs a one-time build (~10 seconds). OK to proceed?" Then STOP and wait.
|
|
2. Run: \`cd <SKILL_DIR> && ./setup\`
|
|
3. If \`bun\` is not installed: \`curl -fsSL https://bun.sh/install | bash\``;
|
|
}
|
|
|
|
const RESOLVERS: Record<string, () => string> = {
|
|
COMMAND_REFERENCE: generateCommandReference,
|
|
SNAPSHOT_FLAGS: generateSnapshotFlags,
|
|
PREAMBLE: generatePreamble,
|
|
BROWSE_SETUP: generateBrowseSetup,
|
|
};
|
|
|
|
// ─── Template Processing ────────────────────────────────────
|
|
|
|
const GENERATED_HEADER = `<!-- AUTO-GENERATED from {{SOURCE}} — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n`;
|
|
|
|
function processTemplate(tmplPath: string): { outputPath: string; content: string } {
|
|
const tmplContent = fs.readFileSync(tmplPath, 'utf-8');
|
|
const relTmplPath = path.relative(ROOT, tmplPath);
|
|
const outputPath = tmplPath.replace(/\.tmpl$/, '');
|
|
|
|
// Replace placeholders
|
|
let content = tmplContent.replace(/\{\{(\w+)\}\}/g, (match, name) => {
|
|
const resolver = RESOLVERS[name];
|
|
if (!resolver) throw new Error(`Unknown placeholder {{${name}}} in ${relTmplPath}`);
|
|
return resolver();
|
|
});
|
|
|
|
// Check for any remaining unresolved placeholders
|
|
const remaining = content.match(/\{\{(\w+)\}\}/g);
|
|
if (remaining) {
|
|
throw new Error(`Unresolved placeholders in ${relTmplPath}: ${remaining.join(', ')}`);
|
|
}
|
|
|
|
// Prepend generated header (after frontmatter)
|
|
const header = GENERATED_HEADER.replace('{{SOURCE}}', path.basename(tmplPath));
|
|
const fmEnd = content.indexOf('---', content.indexOf('---') + 3);
|
|
if (fmEnd !== -1) {
|
|
const insertAt = content.indexOf('\n', fmEnd) + 1;
|
|
content = content.slice(0, insertAt) + header + content.slice(insertAt);
|
|
} else {
|
|
content = header + content;
|
|
}
|
|
|
|
return { outputPath, content };
|
|
}
|
|
|
|
// ─── Main ───────────────────────────────────────────────────
|
|
|
|
function findTemplates(): string[] {
|
|
const templates: string[] = [];
|
|
const candidates = [
|
|
path.join(ROOT, 'SKILL.md.tmpl'),
|
|
path.join(ROOT, 'browse', 'SKILL.md.tmpl'),
|
|
path.join(ROOT, 'qa', 'SKILL.md.tmpl'),
|
|
path.join(ROOT, 'setup-browser-cookies', 'SKILL.md.tmpl'),
|
|
path.join(ROOT, 'ship', 'SKILL.md.tmpl'),
|
|
path.join(ROOT, 'review', 'SKILL.md.tmpl'),
|
|
path.join(ROOT, 'plan-ceo-review', 'SKILL.md.tmpl'),
|
|
path.join(ROOT, 'plan-eng-review', 'SKILL.md.tmpl'),
|
|
path.join(ROOT, 'retro', 'SKILL.md.tmpl'),
|
|
path.join(ROOT, 'gstack-upgrade', 'SKILL.md.tmpl'),
|
|
path.join(ROOT, 'setup-team-sync', 'SKILL.md.tmpl'),
|
|
];
|
|
for (const p of candidates) {
|
|
if (fs.existsSync(p)) templates.push(p);
|
|
}
|
|
return templates;
|
|
}
|
|
|
|
let hasChanges = false;
|
|
|
|
for (const tmplPath of findTemplates()) {
|
|
const { outputPath, content } = processTemplate(tmplPath);
|
|
const relOutput = path.relative(ROOT, outputPath);
|
|
|
|
if (DRY_RUN) {
|
|
const existing = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf-8') : '';
|
|
if (existing !== content) {
|
|
console.log(`STALE: ${relOutput}`);
|
|
hasChanges = true;
|
|
} else {
|
|
console.log(`FRESH: ${relOutput}`);
|
|
}
|
|
} else {
|
|
fs.writeFileSync(outputPath, content);
|
|
console.log(`GENERATED: ${relOutput}`);
|
|
}
|
|
}
|
|
|
|
if (DRY_RUN && hasChanges) {
|
|
console.error('\nGenerated SKILL.md files are stale. Run: bun run gen:skill-docs');
|
|
process.exit(1);
|
|
}
|