Skill Tracer
Skills › Superpowers › writing-skills › render-graphs.js

render-graphs.js

169 lines · skills/writing-skills/render-graphs.js at 2ce8aa1

1#!/usr/bin/env node
2
3/**
4 * Render graphviz diagrams from a skill's SKILL.md to SVG files.
5 *
6 * Usage:
7 * ./render-graphs.js <skill-directory> # Render each diagram separately
8 * ./render-graphs.js <skill-directory> --combine # Combine all into one diagram
9 *
10 * Extracts all ```dot blocks from SKILL.md and renders to SVG.
11 * Useful for helping your human partner visualize the process flows.
12 *
13 * Requires: graphviz (dot) installed on system
14 */
16import * as fs from 'fs';
17import * as path from 'path';
18import { execFileSync } from 'child_process';
20function extractDotBlocks(markdown) {
21 const blocks = [];
22 const regex = /```dot\n([\s\S]*?)```/g;
23 let match;
25 while ((match = regex.exec(markdown)) !== null) {
26 const content = match[1].trim();
28 // Extract digraph name
29 const nameMatch = content.match(/digraph\s+(\w+)/);
30 const name = nameMatch ? nameMatch[1] : `graph_${blocks.length + 1}`;
32 blocks.push({ name, content });
33 }
35 return blocks;
38function extractGraphBody(dotContent) {
39 // Extract just the body (nodes and edges) from a digraph
40 const match = dotContent.match(/digraph\s+\w+\s*\{([\s\S]*)\}/);
41 if (!match) return '';
43 let body = match[1];
45 // Remove rankdir (we'll set it once at the top level)
46 body = body.replace(/^\s*rankdir\s*=\s*\w+\s*;?\s*$/gm, '');
48 return body.trim();
51function combineGraphs(blocks, skillName) {
52 const bodies = blocks.map((block, i) => {
53 const body = extractGraphBody(block.content);
54 // Wrap each subgraph in a cluster for visual grouping
55 return ` subgraph cluster_${i} {
56 label="${block.name}";
57 ${body.split('\n').map(line => ' ' + line).join('\n')}
58 }`;
59 });
61 return `digraph ${skillName}_combined {
62 rankdir=TB;
63 compound=true;
64 newrank=true;
66${bodies.join('\n\n')}
67}`;
70function renderToSvg(dotContent) {
71 try {
72 return execFileSync('dot', ['-Tsvg'], {
73 input: dotContent,
74 encoding: 'utf-8',
75 maxBuffer: 10 * 1024 * 1024
76 });
77 } catch (err) {
78 console.error('Error running dot:', err.message);
79 if (err.stderr) console.error(err.stderr.toString());
80 return null;
81 }
84function main() {
85 const args = process.argv.slice(2);
86 const combine = args.includes('--combine');
87 const skillDirArg = args.find(a => !a.startsWith('--'));
89 if (!skillDirArg) {
90 console.error('Usage: render-graphs.js <skill-directory> [--combine]');
91 console.error('');
92 console.error('Options:');
93 console.error(' --combine Combine all diagrams into one SVG');
94 console.error('');
95 console.error('Example:');
96 console.error(' ./render-graphs.js ../subagent-driven-development');
97 console.error(' ./render-graphs.js ../subagent-driven-development --combine');
98 process.exit(1);
99 }
101 const skillDir = path.resolve(skillDirArg);
102 const skillFile = path.join(skillDir, 'SKILL.md');
103 const skillName = path.basename(skillDir).replace(/-/g, '_');
105 if (!fs.existsSync(skillFile)) {
106 console.error(`Error: ${skillFile} not found`);
107 process.exit(1);
108 }
110 // Check if dot is available. Run the binary directly rather than probing
111 // with `which`, which is not a command on Windows.
112 try {
113 execFileSync('dot', ['-V'], { stdio: 'ignore' });
114 } catch {
115 console.error('Error: graphviz (dot) not found. Install with:');
116 console.error(' brew install graphviz # macOS');
117 console.error(' apt install graphviz # Linux');
118 process.exit(1);
119 }
121 const markdown = fs.readFileSync(skillFile, 'utf-8');
122 const blocks = extractDotBlocks(markdown);
124 if (blocks.length === 0) {
125 console.log('No ```dot blocks found in', skillFile);
126 process.exit(0);
127 }
129 console.log(`Found ${blocks.length} diagram(s) in ${path.basename(skillDir)}/SKILL.md`);
131 const outputDir = path.join(skillDir, 'diagrams');
132 if (!fs.existsSync(outputDir)) {
133 fs.mkdirSync(outputDir);
134 }
136 if (combine) {
137 // Combine all graphs into one
138 const combined = combineGraphs(blocks, skillName);
139 const svg = renderToSvg(combined);
140 if (svg) {
141 const outputPath = path.join(outputDir, `${skillName}_combined.svg`);
142 fs.writeFileSync(outputPath, svg);
143 console.log(` Rendered: ${skillName}_combined.svg`);
145 // Also write the dot source for debugging
146 const dotPath = path.join(outputDir, `${skillName}_combined.dot`);
147 fs.writeFileSync(dotPath, combined);
148 console.log(` Source: ${skillName}_combined.dot`);
149 } else {
150 console.error(' Failed to render combined diagram');
151 }
152 } else {
153 // Render each separately
154 for (const block of blocks) {
155 const svg = renderToSvg(block.content);
156 if (svg) {
157 const outputPath = path.join(outputDir, `${block.name}.svg`);
158 fs.writeFileSync(outputPath, svg);
159 console.log(` Rendered: ${block.name}.svg`);
160 } else {
161 console.error(` Failed: ${block.name}`);
162 }
163 }
164 }
166 console.log(`\nOutput: ${outputDir}/`);
169main();