render-graphs.js 4.7 KB

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