Explain and diagram architectures, processes, decisions, data and plans: Mermaid in Markdown, pitched to the reader's level, drawn from real Quest and lore…

Claude Code skills that explain and diagram architectures, processes, decisions, plans and data. Every diagram is Mermaid in Markdown, pitched to the reader's level, drawn from the real repository, Quest tracker and lore docs, and checked before it ships: it parses under the Mermaid version GitHub renders, it follows an accessible profile, and every box traces to a real source.
Models write diagram syntax well enough; a parse-and-repair loop fixes most of the rest. What they get wrong is truth. In a 2026 study, node accuracy was about 0.5–0.67 against edge accuracy of about 0.09–0.18, and models drew the architecture they expected instead of the one in front of them. No prior-art diagramming skill checked a diagram against its source or against its own prose (research).
This suite reads before it draws, cites every node, and checks the picture against the records. It also never ships a diagram without a plain-English explanation for the reader it was drawn for.
| Skill | Answers | Draws from |
|---|---|---|
diagram-architecture | What is this system, and what are its parts? | Repository tree, manifests, compose files, lore docs |
diagram-process | What happens, in what order, and who does it? | Code, runbooks, specs |
diagram-decision | What did we decide, against which options? | ADRs, Quest decisions |
diagram-plan | What blocks what, and where are we? | Quest tasks and milestones (generated) |
diagram-data | What is the shape of the data? | Migrations, DDL, ORM models (generated from DDL) |
diagram-review | Is this diagram valid, legible and true? | The engine; also owns the shared rules |
What does every skill do between a request and a published diagram?
flowchart LR
accTitle: How a diagram-skills diagram is made
accDescr: A skill picks the reader level and the question, reads the source of truth, drafts the diagram and its explanation, and runs the parse, lint and truth checks, looping back on any error before publishing.
%% level: intermediate
level["Pick reader level and question"] --> read["Read source of truth"]
read --> draft["Draft diagram and explanation"]
draft --> check{"Parse, lint and truth checks pass?"}
check -- "no: error fed back" --> draft
check -- "yes" --> publish["Publish to lore doc or reply"]
%% ref level = path:skills/diagram-review/references/levels.md
%% ref read = path:skills/diagram-review/references/truth.md#Read the source first
%% ref draft = path:skills/diagram-review/references/mermaid-profile.md
%% ref check = path:skills/diagram-review/references/checks.md
%% ref publish = path:skills/diagram-review/references/persisting.md
Every skill follows this loop. The question comes first, so each diagram answers one thing. Reading comes before drawing, so boxes come from records rather than memory. The three checks loop back with the exact error until they pass, and the diagram is published only with its explanation.
The levels follow the plain-english-styles output styles. A skill uses the level the user names, else the active plain-english-* style, else intermediate.
| Beginner | Intermediate | Advanced | |
|---|---|---|---|
| Node cap | 7 | 12 | 20, then split |
| Labels | Plain words, no ids or jargon | Real names, glossed once | Ids and precise terms |
| Explanation | Conclusion, then one line per unclear box | What it shows and how we know | Claim, mechanism, action |
It lives in skills/diagram-review/scripts/. The other skills reach it through ${CLAUDE_SKILL_DIR}/../diagram-review/scripts.
| Script | Does |
|---|---|
mermaid-check.mjs | Parses every Mermaid fence with mermaid.parse() under jsdom, pinned to GitHub's Mermaid (11.17.2). No browser; about 40 ms per diagram |
diagram_lint.py | The profile, accTitle/accDescr, node caps, a legend wherever colour is used, an explanation next to the fence |
diagram_truth.py | Resolves each node's %% ref (path, schema, quest, lore, ext). Generated plans must match Quest's dependencies exactly |
quest_graph.py, schema_er.py, repo_inventory.py | Generate plan and ER diagrams from records; list a repository's real parts |
diagram_export.py | Opt-in SVG/PNG export through mmdc, about 650 MB, installed only on request |
lore check never looks inside a Mermaid fence. CI therefore runs these checks on every pull request, plus a self-test that proves the parser still fails broken diagrams.
diagram-skills is listed in the opum-marketplace, which pins a release tag:
/plugin marketplace add opum-ai/opum-marketplace
/plugin install diagram-skills@opum
The marketplace pin is the v0.1.0 release, which has the skills but not the diagram pane. To get the pane before the pin moves, load the plugin from a checkout:
git clone https://github.com/opum-ai/diagram-skills
npm ci --prefix diagram-skills/skills/diagram-review/scripts # the parse check, about 180 MB, no browser
claude --plugin-dir ./diagram-skills
The engine needs Node 20+ and Python 3.9+. Plan diagrams need the Quest CLI; persisting to docs uses lore.
The skills trigger on ordinary requests:
I'm new to this repo. Give me an overview of how it fits together, with a diagram for our docs.
What states can an order go through? Include what happens when payment fails.
Make a diagram for ADR 0003 showing the options and which one we picked, for the steering group.
What's still blocking the launch? Show me the dependencies.
Our CFO isn't technical. Explain what data we keep about customers, with a simple picture.
Review the diagram in docs/architecture.md before I merge it.
A Claude Code mod (hooks/register.tsx) draws a Mermaid diagram in a pane:
/diagram <path> draws the first closed mermaid fence of a Markdown file, or the whole of a .mmd or .mermaid file.mcp__diagram-skills__show_diagram (source, optional title and alt) lets a skill push its own diagram into the same pane.node is on PATH and jsdom resolves (run npm ci --prefix skills/diagram-review/scripts, as for the parse check), a source that does not parse is refused with Mermaid's own message and the pane keeps what it showed. Where it does not, the diagram is drawn as an unvalidated preview, labelled "Open diagram to check", and the tool result says validated: false. The gate runs under node only; the pane prefers node over bun (bun installs a missing package from the network).The terminal draws Unicode art when it fits the pane; desktop, VS Code and mobile draw an SVG. Anything else (a type the renderer does not draw, art wider or taller than the pane, a source over 20,000 characters, no bun or node on PATH) keeps the source, says why in one line, and offers an "Open diagram" button that writes a self-contained HTML page (vendored mermaid.js, strict security level, no network) and opens it. A file with several diagrams draws up to 8, with previous and next buttons.
The renderer in renderer/ is a byte-identical copy of plain-english-styles', with its licences; see RENDERER-PROVENANCE.md. Transcript rendering of Mermaid fences stays with plain-english-styles.
Iteration 1 used 12 cases, two per skill, each run once with the skill and once without (claude-opus-5-5). A grader re-derived the truth from the fixture's real code, schema and Quest records (benchmark, notes).
| With skill | Without skill | |
|---|---|---|
| Expectations passed | 77 of 77 (100%) | 60 of 77 (78%) |
| Tokens per run | about 39k | about 28k |
| Time per run | about 78 s | about 62 s |
12 of the baseline's 17 failures were missing accessibility fields. The others were plan views that showed finished or out-of-scope tasks, and decision diagrams that did not mark or did not draw the options. On this small fixture the baseline invented no components either, so the truth checks did not yet separate the two. See Known limitations.
.claude-plugin/plugin.json plugin manifest
hooks/ the diagram pane mod (register.tsx) and its tests
types/ the pane's state contract
renderer/ the copied Mermaid renderer, vendored files and licences
.claude/skills/ symlinks to skills/ for local development
.github/workflows/ci.yml parse, self-test, lint, pytest, lore check, truth
skills/<name>/ SKILL.md, examples/; diagram-review also has references/ and scripts/
evals/ evals.json, fixtures/, setup_ws.sh, grade.py, benchmarks/
tests/ pytest for the engine; gate and export self-tests; parser fixtures
docs/ lore bundle: research, conventions, ADRs, spec, epic, stories
.quest/, .lore/ tracker and lore state
npm ci --prefix skills/diagram-review/scripts
node skills/diagram-review/scripts/mermaid-check.mjs docs skills README.md
python3 skills/diagram-review/scripts/diagram_lint.py docs skills README.md
python3 skills/diagram-review/scripts/diagram_truth.py --root . docs skills README.md
tests/mermaid_selftest.sh
python3 -m pytest -q
lore check
quest board
Evals: build workspaces with evals/setup_ws.sh <dir>, run each case with and without the skill, then python3 evals/grade.py <run-dir> --eval <id>.
%% ref proves each box exists. Edges are checked exactly only for generated plan diagrams, and the prose is not machine-checked: one with-skill eval answer named the wrong migration file for a dropped table.skills/diagram-review/scripts/package.json deliberately.repo_inventory.py misses services called by raw URL.lore query in a repository without a lore bundle leaves a cache directory behind.MIT AND EPL-2.0 AND BSD-2-Clause. The plugin's own code is MIT (LICENSE); the copied renderer carries beautiful-mermaid and mermaid (MIT), entities (BSD-2-Clause) and the unmodified elkjs file (EPL-2.0). The licence texts and notices are in renderer/licenses/.
hooks/register.tsx 529 lines1/**
2 * The diagram pane mod (DSKI-18; design opum-doc DEC-221, ODOC-494).
3 *
4 * `/diagram <path>` opens a pane and draws the first closed mermaid fence of a
5 * file (the whole file for .mmd and .mermaid). The tool
6 * `mcp__diagram-skills__show_diagram` lets the skill push its own diagram into
7 * the same pane. Transcript rendering stays plain-english-styles' job.
8 *
9 * Drawing, per surface:
10 * - terminal: Unicode art, when it fits the pane's own `bodyColumns` and
11 * `bodyRows` (art that does not fit does not wrap; it falls back);
12 * - desktop, VS Code, mobile: `Svg` (at most 131,072 characters, `alt` required);
13 * - every other case: the source plus a one-line reason and an "Open diagram"
14 * Button, which writes a self-contained HTML page (vendored mermaid.js, strict
15 * security level, a CSP that allows no network) and opens it.
16 * No PNG and no Chromium.
17 *
18 * The renderer, `renderer/render.mjs`, is a byte-identical copy of
19 * plain-english-styles' (see RENDERER-PROVENANCE.md). The render pass never
20 * waits on it: the first draw shows the source, the helper runs off the render
21 * path, and its result - a failure too - is cached by the source's SHA-256, so
22 * a broken diagram is never re-run on a redraw.
23 *
24 * Every function that takes `$` is a top-level declaration: the plugin
25 * validator accepts `$` only as an argument to one.
26 */
27import { atom, read, update } from 'claude-code'
28import type { EngineInterface, Register, RenderNode } from 'claude-code'
29
30import type { View } from '../types'
31import { htmlPage } from './page'
32
33export const PANE = 'diagram'
34export const TOOL = 'show_diagram'
35export const MAX_SOURCE = 20000 // a longer source is not rendered
36export const MAX_SVG = 131072 // the `Svg` element's own cap
37export const MAX_DIAGRAMS = 8 // at most this many fences of one document
38const HELPER_TIMEOUT_MS = 5000
39const CACHE_LIMIT = 512
40
41export type Rendered =
42 | { ok: true; kind: string; unicode: string; svg: string }
43 | { ok: false; reason: string; kind?: string; message?: string }
44
45/**
46 * What the syntax gate (validate/validate.mjs) said: the source parsed (`validated: true`), it
47 * failed with mermaid's own message, or nothing was checked (`validated: false`: no jsdom, or no
48 * node to run the gate under).
49 */
50export type Checked =
51 | { ok: true; validated: boolean }
52 | { ok: false; reason: 'syntax'; message: string }
53
54/** One drawing of a rendered diagram on a surface. */
55export type Drawing = 'art' | 'svg' | 'fallback'
56
57const view = atom({ plugin: 'diagram-skills', key: 'view' } as const, null)
58
59// Module-level, so it survives every redraw of a session (a reload starts it
60// over, which costs only a re-render). Bounded so a long session cannot grow it.
61const cache = new Map<string, Rendered>()
62const checks = new Map<string, Checked>()
63const inflight = new Set<string>()
64let runtimeChoice: string | null | undefined
65let mermaidJsCache: string | undefined
66let systemChoice: string | undefined
67
68/** Forget every cached result and the runtime choice (the tests start from a clean module). */
69export function resetCaches (): void {
70 cache.clear()
71 checks.clear()
72 inflight.clear()
73 runtimeChoice = undefined
74 mermaidJsCache = undefined
75 systemChoice = undefined
76}
77
78/** The SHA-256 of a string, lowercase hex. */
79async function sha256 (text: string): Promise<string> {
80 const bytes = new TextEncoder().encode(text)
81 const digest = await crypto.subtle.digest('SHA-256', bytes)
82 return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('')
83}
84
85/** Remember one result, evicting the oldest when the cache is full. */
86function remember (key: string, value: Rendered): void {
87 if (cache.size >= CACHE_LIMIT) {
88 const oldest = cache.keys().next().value
89 if (oldest !== undefined) cache.delete(oldest)
90 }
91 cache.set(key, value)
92}
93
94/** The closed ```mermaid fences of a markdown text, in order (any other fence is skipped whole). */
95export function scanFences (text: string): string[] {
96 const lines = text.split('\n')
97 const isClose = (line: string): boolean => /^[ \t]*```[ \t]*$/.test(line)
98 const sources: string[] = []
99 for (let i = 0; i < lines.length; i++) {
100 const open = /^[ \t]*```[ \t]*([^\s`]*)/.exec(lines[i] as string)
101 if (!open) continue
102 const info = (open[1] ?? '').toLowerCase()
103 let j = i + 1
104 while (j < lines.length && !isClose(lines[j] as string)) j++
105 if (j >= lines.length) break // still open: nothing after it is a closed fence
106 if (info === 'mermaid') sources.push(lines.slice(i + 1, j).join('\n'))
107 i = j
108 }
109 return sources
110}
111
112/** The diagram sources of a file: the whole of a .mmd/.mermaid file, else its closed fences. */
113export function sourcesOf (path: string, text: string): string[] {
114 if (/\.(mmd|mermaid)$/i.test(path)) return text.trim() === '' ? [] : [text]
115 return scanFences(text)
116}
117
118const HEADERS = [
119 'graph', 'flowchart', 'sequenceDiagram', 'classDiagram', 'stateDiagram', 'erDiagram', 'journey', 'gantt', 'pie',
120 'quadrantChart', 'requirementDiagram', 'gitGraph', 'C4Context', 'C4Container', 'C4Component', 'C4Dynamic',
121 'C4Deployment', 'mindmap', 'timeline', 'zenuml', 'sankey', 'xychart', 'block', 'packet', 'kanban', 'architecture',
122 'radar', 'treemap',
123]
124
125/**
126 * Whether a source opens with a Mermaid diagram header. A leading frontmatter
127 * block, `%%` comment lines and blank lines are skipped. The bundled helper is
128 * lenient (it draws an empty or partial picture for some malformed sources and
129 * reports every unknown header as "unsupported"), so the tool checks this first
130 * to tell text that is not Mermaid from a type the browser view handles.
131 */
132export function hasMermaidHeader (source: string): boolean {
133 const lines = source.split('\n')
134 let i = 0
135 if ((lines[0] ?? '').trim() === '---') {
136 i = 1
137 while (i < lines.length && (lines[i] ?? '').trim() !== '---') i++
138 i++
139 }
140 for (; i < lines.length; i++) {
141 const line = (lines[i] as string).trim()
142 if (line === '' || line.startsWith('%%')) continue
143 const word = /^[A-Za-z0-9-]+/.exec(line)?.[0] ?? ''
144 return HEADERS.some(header => word === header || word.startsWith(`${header}-`))
145 }
146 return false
147}
148
149/** True when a successful render drew nothing a reader could use (the helper's lenient output). */
150export function isEmptyDrawing (result: Rendered): boolean {
151 if (!result.ok) return false
152 return result.unicode.trim() === '' || /viewBox="0 0 0 0"/.test(result.svg)
153}
154
155/** The widest line of a string, in characters. */
156export function widest (text: string): number {
157 let max = 0
158 for (const line of text.split('\n')) max = Math.max(max, [...line].length)
159 return max
160}
161
162/** The rows of a string. */
163export function rows (text: string): number {
164 return text.split('\n').length
165}
166
167/** The one-line text equivalent of a diagram, for `Svg`'s alt. */
168export function altFor (kind: string, alt?: string): string {
169 return alt !== undefined && alt.trim() !== '' ? alt : `Mermaid ${kind} diagram`
170}
171
172/** The drawing and, for a fallback, the one-line reason. */
173export type Decision = { drawing: 'art' | 'svg'; isUnvalidated: boolean } | { drawing: 'fallback'; why: string }
174
175/**
176 * Which drawing one source gets on a surface. Unicode art on the terminal when
177 * it fits `bodyColumns` x `bodyRows`; `Svg` on every other surface when it is
178 * within the element's cap; the source-plus-button fallback otherwise.
179 * `runtime` is what the helper runs under, `null` when neither bun nor node is on PATH.
180 */
181export function decide (
182 source: string,
183 result: Rendered | undefined,
184 surface: string,
185 columns: number,
186 bodyRows: number,
187 runtime: string | null,
188 checked: Checked | undefined,
189): Decision {
190 if (source.length > MAX_SOURCE) {
191 return { drawing: 'fallback', why: `This diagram is over ${MAX_SOURCE.toLocaleString('en-US')} characters, so it is not drawn here; open it in the browser.` }
192 }
193 if (runtime === null) return { drawing: 'fallback', why: 'Diagrams need bun or node on PATH; neither was found.' }
194 if (checked !== undefined && !checked.ok) {
195 return { drawing: 'fallback', why: `This diagram has a syntax error: ${checked.message.split('\n')[0] ?? ''}; open it in the browser.` }
196 }
197 if (result === undefined || checked === undefined) return { drawing: 'fallback', why: 'Drawing…' }
198 if (!result.ok) {
199 return {
200 drawing: 'fallback',
201 why: result.reason === 'unsupported-type'
202 ? 'This diagram type needs the browser view.'
203 : result.reason === 'too-large'
204 ? 'This diagram is too large; open it in the browser.'
205 : `This diagram could not be drawn${result.message !== undefined ? `: ${result.message}` : ''}; open it in the browser.`,
206 }
207 }
208 if (isEmptyDrawing(result)) {
209 return { drawing: 'fallback', why: 'This diagram drew nothing; check its source or open it in the browser.' }
210 }
211 if (surface === 'terminal') {
212 if (columns > 0 && widest(result.unicode) > columns) {
213 return { drawing: 'fallback', why: 'This diagram is wider than the pane; open it in the browser.' }
214 }
215 if (bodyRows > 0 && rows(result.unicode) > bodyRows) {
216 return { drawing: 'fallback', why: 'This diagram is taller than the pane; open it in the browser.' }
217 }
218 return { drawing: 'art', isUnvalidated: !checked.validated }
219 }
220 return result.svg.length <= MAX_SVG ? { drawing: 'svg', isUnvalidated: !checked.validated } : { drawing: 'fallback', why: 'This diagram is too large to draw here; open it in the browser.' }
221}
222
223/** The runtime to run the helper with: `node`, then `bun`, else null. */
224async function chooseRuntime ($: EngineInterface): Promise<string | null> {
225 if (runtimeChoice !== undefined) return runtimeChoice
226 for (const runtime of ['node', 'bun']) {
227 try {
228 const { exitCode } = await $.process.run([runtime, '--version'], { timeoutMs: 2000 })
229 if (exitCode === 0) { runtimeChoice = runtime; return runtime }
230 } catch { /* not on PATH */ }
231 }
232 runtimeChoice = null
233 return null
234}
235
236/** Run the bundled helper on one source. Never throws. */
237async function renderSource ($: EngineInterface, runtime: string, source: string): Promise<Rendered> {
238 try {
239 const { exitCode, stdout } = await $.process.run(
240 [runtime, `${$.plugin.root}/renderer/render.mjs`],
241 // PES10_STDIN makes the helper run: its own "am I the entry point" test compares
242 // import.meta.url with the path and fails on a plugin path holding a space, # or %.
243 { stdin: source, timeoutMs: HELPER_TIMEOUT_MS, env: { PES10_STDIN: '1' } },
244 )
245 if (exitCode !== 0) return { ok: false, reason: 'helper-exit', message: `exit ${exitCode}` }
246 return JSON.parse(stdout) as Rendered
247 } catch (error) {
248 const message = error instanceof Error ? error.message : String(error)
249 return { ok: false, reason: 'helper-error', message }
250 }
251}
252
253/** Render one source once: a cached result (a failure too) is never re-run. */
254async function renderOnce ($: EngineInterface, runtime: string, source: string): Promise<Rendered> {
255 const key = await sha256(source)
256 const hit = cache.get(key)
257 if (hit !== undefined) return hit
258 const result = await renderSource($, runtime, source)
259 remember(key, result)
260 return result
261}
262
263/**
264 * Run the syntax gate on one source. Only `node` runs it: bun resolves a package it cannot
265 * find by installing it from the network, which a gate must never do. Never throws.
266 */
267async function checkSource ($: EngineInterface, runtime: string, source: string): Promise<Checked> {
268 if (runtime !== 'node') return { ok: true, validated: false }
269 try {
270 const { exitCode, stdout } = await $.process.run(
271 [runtime, `${$.plugin.root}/validate/validate.mjs`],
272 { stdin: source, timeoutMs: HELPER_TIMEOUT_MS },
273 )
274 if (exitCode !== 0) return { ok: true, validated: false }
275 const parsed = JSON.parse(stdout) as { ok: boolean; validated?: boolean; message?: string }
276 if (parsed.ok) return { ok: true, validated: parsed.validated === true }
277 return { ok: false, reason: 'syntax', message: parsed.message ?? 'syntax error' }
278 } catch {
279 return { ok: true, validated: false }
280 }
281}
282
283/** The gate's answer for a source, asked once: a cached answer (a failure too) is never re-run. */
284async function checkOnce ($: EngineInterface, runtime: string, source: string): Promise<Checked> {
285 const key = await sha256(source)
286 const hit = checks.get(key)
287 if (hit !== undefined) return hit
288 const result = await checkSource($, runtime, source)
289 if (checks.size >= CACHE_LIMIT) {
290 const oldest = checks.keys().next().value
291 if (oldest !== undefined) checks.delete(oldest)
292 }
293 checks.set(key, result)
294 return result
295}
296
297/** Say something in the pane, under the drawing. */
298async function note ($: EngineInterface, text: string): Promise<void> {
299 await update($, view, current => (current === null ? current : { ...current, note: text }))
300}
301
302/** The operating system's name (`uname -s`), asked once; empty when it cannot be read. */
303async function systemName ($: EngineInterface): Promise<string> {
304 if (systemChoice !== undefined) return systemChoice
305 try {
306 const { stdout } = await $.process.run(['uname', '-s'], { timeoutMs: 2000 })
307 systemChoice = stdout.trim()
308 } catch {
309 systemChoice = ''
310 }
311 return systemChoice
312}
313
314/**
315 * Write the self-contained HTML page and open it in the browser. A symbolic link at the cache
316 * directory is refused (a write through it would land somewhere the plugin did not choose).
317 * Only macOS has `open`; anywhere else the pane says where the page is and runs nothing.
318 */
319async function openInBrowser ($: EngineInterface, source: string, title: string): Promise<void> {
320 const key = await sha256(source)
321 const base = `${$.plugin.root}/.cache`
322 const dir = `${base}/diagram-pane`
323 for (const part of [base, dir]) {
324 if (await $.fs.exists(part) && (await $.fs.stat(part)).isLink) {
325 await note($, `Not written: ${part} is a symbolic link.`)
326 return
327 }
328 }
329 const path = `${dir}/${key}.html`
330 let js = mermaidJsCache
331 if (js === undefined) {
332 const text = await $.fs.read(`${$.plugin.root}/renderer/vendor/mermaid.min.js`)
333 js = typeof text === 'string' ? text : new TextDecoder().decode(text)
334 mermaidJsCache = js
335 }
336 await $.fs.write(path, htmlPage(source, js, title))
337 if ((await systemName($)) === 'Darwin') {
338 await $.process.run(['open', path], { timeoutMs: HELPER_TIMEOUT_MS })
339 } else {
340 await note($, `Open this file in a browser: ${path}`)
341 }
342}
343
344/** Open the pane. */
345async function openPane ($: EngineInterface): Promise<void> {
346 await $.ui.open({ id: PANE, title: 'Diagram' })
347}
348
349/** Show these sources in the pane, the first drawn. */
350async function show ($: EngineInterface, sources: string[], title: string, alt: string | undefined): Promise<View> {
351 const next: View = {
352 title,
353 alt,
354 sources: sources.slice(0, MAX_DIAGRAMS),
355 hidden: Math.max(0, sources.length - MAX_DIAGRAMS),
356 index: 0,
357 }
358 await update($, view, () => next)
359 await openPane($)
360 return next
361}
362
363/** Move to another fence of the shown document. */
364async function step ($: EngineInterface, by: number): Promise<void> {
365 await update($, view, current => {
366 if (current === null) return current
367 const index = Math.min(current.sources.length - 1, Math.max(0, current.index + by))
368 return { ...current, index }
369 })
370}
371
372/** Start the helper for a source off the render path and redraw when it is done. */
373function schedule ($: EngineInterface, runtime: string, source: string, key: string): void {
374 if ((cache.has(key) && checks.has(key)) || inflight.has(key) || source.length > MAX_SOURCE) return
375 inflight.add(key)
376 void Promise.all([renderSource($, runtime, source), checkOnce($, runtime, source)])
377 .then(([result]) => { remember(key, result) })
378 .catch(() => { remember(key, { ok: false, reason: 'helper-error' }) })
379 .finally(() => { inflight.delete(key); $.ui.invalidate('ui.render') })
380}
381
382export const register: Register = (on, options) => {
383 void options
384
385 on('session.start', async ($, e, next) => {
386 await $.command.register({
387 name: 'diagram',
388 description: 'Draw a Mermaid diagram from a file in a pane',
389 argumentHint: '<path>',
390 })
391 await $.tool.register({
392 name: TOOL,
393 description:
394 'Draw a Mermaid diagram in the diagram pane. Give the Mermaid source (without the code fence), '
395 + 'an optional title and an optional one-sentence alt text. Where the syntax gate can run (it needs node on PATH as well as the installed jsdom), a source that does not '
396 + 'parse is refused with Mermaid\'s own message and the pane keeps what it showed; where it cannot, the diagram is drawn as an '
397 + 'unvalidated preview and the result says validated: false.',
398 inputSchema: {
399 type: 'object',
400 properties: {
401 source: { type: 'string', description: 'The Mermaid source, without the ```mermaid fence.' },
402 title: { type: 'string', description: 'A short title for the pane.' },
403 alt: { type: 'string', description: 'A one-sentence text equivalent of the diagram.' },
404 },
405 required: ['source'],
406 },
407 })
408 return next(e)
409 })
410
411 on('command.run', { command: 'diagram' }, async ($, e) => {
412 const path = e.args.trim()
413 if (path === '') return { text: 'Usage: /diagram <path to a .md, .mmd or .mermaid file>' }
414 let text: string
415 try {
416 const read = await $.fs.read(path)
417 text = typeof read === 'string' ? read : new TextDecoder().decode(read)
418 } catch (error) {
419 const message = error instanceof Error ? error.message : String(error)
420 return { text: `Could not read ${path}: ${message}` }
421 }
422 const sources = sourcesOf(path, text)
423 if (sources.length === 0) return { text: `No closed mermaid fence found in ${path}.` }
424 await show($, sources, path, undefined)
425 const hidden = sources.length - Math.min(sources.length, MAX_DIAGRAMS)
426 return {
427 text: hidden > 0
428 ? `Drew diagram 1 of ${MAX_DIAGRAMS} from ${path}; ${hidden} more in the file are not shown.`
429 : `Drew ${sources.length === 1 ? 'the diagram' : `diagram 1 of ${sources.length}`} from ${path}.`,
430 }
431 })
432
433 on('tool.call', { tool: `mcp__diagram-skills__${TOOL}` }, async ($, e) => {
434 const input = e as unknown as { source?: unknown; title?: unknown; alt?: unknown }
435 const source = typeof input.source === 'string' ? input.source : ''
436 const title = typeof input.title === 'string' && input.title.trim() !== '' ? input.title : 'Diagram'
437 const alt = typeof input.alt === 'string' ? input.alt : undefined
438 const refuse = (reason: string): { result: { ok: false; reason: string } } => ({ result: { ok: false, reason } })
439
440 if (source.trim() === '') return refuse('empty-source')
441 if (source.length > MAX_SOURCE) return refuse(`too-large: over ${MAX_SOURCE} characters`)
442
443 // The strict gate runs when jsdom resolves (under node); a source it refuses is refused here
444 // with mermaid's own message. Without it nothing was parsed, so the source is drawn labelled
445 // unvalidated and the result says `validated: false`; the header check is the only guard then.
446 const runtime = await chooseRuntime($)
447 let validated = false
448 if (runtime !== null) {
449 const checked = await checkOnce($, runtime, source)
450 if (!checked.ok) return refuse(`syntax: ${checked.message}`)
451 validated = checked.validated
452 }
453 if (!validated && !hasMermaidHeader(source)) return refuse('not-mermaid: the source does not start with a Mermaid diagram header')
454
455 if (runtime !== null) {
456 const result = await renderOnce($, runtime, source)
457 if (!result.ok && (result.reason === 'render-error' || result.reason === 'helper-error' || result.reason === 'helper-exit')) {
458 return refuse(`${result.reason}${result.message !== undefined ? `: ${result.message}` : ''}`)
459 }
460 if (isEmptyDrawing(result)) return refuse('render-error: the diagram drew nothing')
461 }
462 await show($, [source], title, alt)
463 return { result: { ok: true, validated } }
464 })
465
466 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
467 const { Box, Text, Code, Button } = $.ui.resolve(e)
468 const elements = $.ui.resolve(e)
469 const SvgEl = 'Svg' in elements ? elements.Svg : undefined
470 const current = await read($, view)
471
472 if (current === null) {
473 return <Box flexDirection="column"><Text dimColor>No diagram yet. Run /diagram {'<path>'} to draw one.</Text></Box>
474 }
475
476 const source = current.sources[current.index] as string
477 const runtime = await chooseRuntime($)
478 const key = await sha256(source)
479 if (runtime !== null) schedule($, runtime, source, key)
480
481 const columns = e.props.bodyColumns
482 const bodyRows = e.props.scroll?.bodyRows ?? 0
483 const checked: Checked | undefined = runtime === null
484 ? undefined
485 : runtime === 'node' ? checks.get(key) : { ok: true, validated: false }
486 const decision = decide(source, cache.get(key), e.surface, columns, bodyRows, runtime, checked)
487 const result = cache.get(key)
488
489 const nodes: RenderNode[] = []
490 if (current.title !== '') nodes.push(<Text key="title" bold>{current.title}</Text>)
491 if (current.sources.length > 1) {
492 nodes.push(
493 <Box key="nav" flexDirection="row">
494 <Button key="prev" label="Previous" onPress={() => step($, -1)} />
495 <Text> {current.index + 1} of {current.sources.length} </Text>
496 <Button key="next" label="Next" onPress={() => step($, 1)} />
497 </Box>,
498 )
499 }
500 if (current.hidden > 0) nodes.push(<Text key="hidden" dimColor>{current.hidden} more diagram{current.hidden === 1 ? '' : 's'} in the file not shown (the pane draws {MAX_DIAGRAMS}).</Text>)
501
502 const unvalidated = (
503 <Box key="unvalidated" flexDirection="column">
504 <Text dimColor>Unvalidated preview: nothing parsed this source. Open diagram to check.</Text>
505 <Button key="open" label="Open diagram" onPress={() => openInBrowser($, source, current.title)} />
506 </Box>
507 )
508 if (decision.drawing === 'art' && result !== undefined && result.ok) {
509 nodes.push(<Text key="art">{result.unicode}</Text>)
510 if (decision.isUnvalidated) nodes.push(unvalidated)
511 } else if (decision.drawing === 'svg' && result !== undefined && result.ok && SvgEl !== undefined) {
512 nodes.push(<SvgEl key="svg" source={result.svg} alt={altFor(result.kind, current.alt)} />)
513 if (decision.isUnvalidated) nodes.push(unvalidated)
514 } else {
515 nodes.push(
516 <Box key="fallback" flexDirection="column">
517 <Code source={source} language="mermaid" />
518 <Text dimColor>{decision.drawing === 'fallback' ? decision.why : 'This diagram could not be drawn here; open it in the browser.'}</Text>
519 {decision.drawing === 'fallback' && decision.why === 'Drawing…'
520 ? null
521 : <Button key="open" label="Open diagram" onPress={() => openInBrowser($, source, current.title)} />}
522 </Box>,
523 )
524 }
525 if (current.note !== undefined) nodes.push(<Text key="note" dimColor>{current.note}</Text>)
526 return <Box flexDirection="column">{nodes}</Box>
527 })
528}
529hooks/page.ts 32 lines1/**
2 * The self-contained HTML page the "Open diagram" button writes. Plain TypeScript with no
3 * imports, so the engine loads it as part of the mod and Node 24 can import it directly, which
4 * is how tests/test_pane_page.py checks the page built around the SHIPPED mermaid.js.
5 */
6
7/** Escape the five characters that matter inside the HTML page. */
8export function escapeHtml (text: string): string {
9 const table: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
10 return text.replace(/[&<>"']/g, c => table[c] ?? c)
11}
12
13/** A self-contained HTML page that draws one Mermaid source with the vendored mermaid.js. */
14export function htmlPage (source: string, mermaidJs: string, title: string): string {
15 const csp = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:"
16 return `<!doctype html>
17<html lang="en"><head><meta charset="utf-8">
18<meta http-equiv="Content-Security-Policy" content="${csp}">
19<meta name="viewport" content="width=device-width,initial-scale=1">
20<title>${escapeHtml(title)}</title>
21<style>body{margin:0;padding:24px;background:#fff;color:#18181b;font:14px system-ui,sans-serif}#d{display:flex;justify-content:center}</style>
22</head><body><div id="d"></div>
23<script>${mermaidJs.replace(/<\/script/gi, '<\\/script')}</script>
24<script>
25 mermaid.initialize({ startOnLoad: false, securityLevel: 'strict' });
26 mermaid.render('d0', ${JSON.stringify(source).replace(/</g, '\\u003c')}).then(function (r) { document.getElementById('d').innerHTML = r.svg; })
27 .catch(function (e) { document.getElementById('d').textContent = 'Could not draw this diagram: ' + e.message; });
28</script>
29</body></html>
30`
31}
32types/index.d.ts 22 lines1/** What the diagram pane is showing. */
2export type View = {
3 /** The pane's heading: the file path, or the title the tool call gave. */
4 title: string
5 /** The tool call's one-sentence text equivalent, when it gave one. */
6 alt?: string | undefined
7 /** The diagram sources of the document, at most 8. */
8 sources: string[]
9 /** How many more fences the document had than the pane draws. */
10 hidden: number
11 /** Which source is drawn now. */
12 index: number
13 /** One line under the drawing: where the HTML page was written, or why it was not. */
14 note?: string | undefined
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'diagram-skills': { view: View | null }
20 }
21}
22