flowfig 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.js ADDED
@@ -0,0 +1,216 @@
1
+ // The MCP server: the check, render, verify, diff and docs tools over stdio JSON-RPC, for an agent with no shell.
2
+ // No SDK: the subset the clients need is small, and the package keeps its zero dependencies.
3
+ import { mkdirSync, writeFileSync } from 'node:fs';
4
+ import { dirname } from 'node:path';
5
+ import { createInterface } from 'node:readline';
6
+ import { diff, formatDiff } from './diff.js';
7
+ import { GUIDE } from './guide.js';
8
+ import { loadSpec, reportLines, sortFindings, svgWithSpec } from './load.js';
9
+ import { check } from './svg.js';
10
+ import { verify } from './verify.js';
11
+ import { VERSION } from './version.js';
12
+ const specSchema = {
13
+ description: 'The figure: the props object, { props }, or a path to a .json file or an SVG that flowfig wrote.',
14
+ anyOf: [{ type: 'object' }, { type: 'string' }],
15
+ };
16
+ const checkProps = {
17
+ spec: specSchema,
18
+ strict: { type: 'boolean', description: 'Warnings count as errors.' },
19
+ width: { type: 'number', description: 'The page width in px the reader sees. Default 830.' },
20
+ minText: { type: 'number', description: 'The smallest text in px at that width. Default 10.' },
21
+ };
22
+ export const TOOLS = [
23
+ { name: 'docs', description: 'The flowfig guide. Read it before the first render.', inputSchema: { type: 'object', properties: {} } },
24
+ {
25
+ name: 'check',
26
+ description: 'List the faults of a figure: unknown ids, text overflow, crossings, label overlap, small text, low contrast. Fix every error before render.',
27
+ inputSchema: { type: 'object', properties: checkProps, required: ['spec'] },
28
+ },
29
+ {
30
+ name: 'render',
31
+ description: 'Check a figure, then write it as one animated SVG file. Returns the check lines and the path. On an error, writes nothing.',
32
+ inputSchema: {
33
+ type: 'object',
34
+ properties: { ...checkProps, out: { type: 'string', description: 'The SVG path to write.' } },
35
+ required: ['spec', 'out'],
36
+ },
37
+ },
38
+ {
39
+ name: 'verify',
40
+ description: 'Check the code links (source) of one or more figures: the file and the symbol must exist under root.',
41
+ inputSchema: {
42
+ type: 'object',
43
+ properties: {
44
+ paths: { type: 'array', items: { type: 'string' }, description: 'Paths to .json files or SVGs.' },
45
+ root: { type: 'string', description: 'The folder the links resolve against. Default: the working directory.' },
46
+ strict: { type: 'boolean' },
47
+ },
48
+ required: ['paths'],
49
+ },
50
+ },
51
+ {
52
+ name: 'diff',
53
+ description: 'The spec changes between two figures, as a Markdown list.',
54
+ inputSchema: { type: 'object', properties: { old: specSchema, new: specSchema }, required: ['old', 'new'] },
55
+ },
56
+ ];
57
+ class Bad extends Error {
58
+ } // bad params: a JSON-RPC error, not a tool result
59
+ class Fault extends Error {
60
+ } // a path that cannot be read or written: a tool result with isError
61
+ const ok = (text, isError = false) => isError ? { content: [{ type: 'text', text }], isError: true } : { content: [{ type: 'text', text }] };
62
+ const spec = (v, name) => {
63
+ if (typeof v !== 'string' && (typeof v !== 'object' || v === null))
64
+ throw new Bad(`${name}: spec must be an object or a path`);
65
+ try {
66
+ return loadSpec(v);
67
+ }
68
+ catch (e) {
69
+ // The message of a path failure already starts with the path.
70
+ throw typeof v === 'string' ? new Fault(e.message) : new Bad(`${name}: ${e.message}`);
71
+ }
72
+ };
73
+ const need = (args, tool, keys) => {
74
+ for (const k of keys)
75
+ if (args[k] == null)
76
+ throw new Bad(`${tool}: ${k} is required`);
77
+ };
78
+ /** A present argument must have its type; a number must be positive. */
79
+ const types = (args, tool, want) => {
80
+ for (const [k, t] of Object.entries(want)) {
81
+ const v = args[k];
82
+ if (v == null)
83
+ continue;
84
+ if (typeof v !== t || (t === 'number' && !(v > 0 && Number.isFinite(v)))) {
85
+ throw new Bad(`${tool}: ${k} must be ${t === 'number' ? 'a positive number' : `a ${t}`}`);
86
+ }
87
+ }
88
+ };
89
+ function run(name, args) {
90
+ if (name === 'docs')
91
+ return ok(GUIDE);
92
+ if (name === 'check' || name === 'render') {
93
+ need(args, name, name === 'render' ? ['spec', 'out'] : ['spec']);
94
+ types(args, name, { width: 'number', minText: 'number', out: 'string', strict: 'boolean' });
95
+ const props = spec(args.spec, name);
96
+ const opts = { width: args.width, minText: args.minText };
97
+ const findings = sortFindings(check(props, opts), args.strict === true);
98
+ const errors = findings.some((f) => f.severity === 'error');
99
+ const lines = reportLines(props, findings);
100
+ if (name === 'check' || errors)
101
+ return ok(lines.join('\n'), errors);
102
+ const out = String(args.out);
103
+ const svg = svgWithSpec(props);
104
+ try {
105
+ mkdirSync(dirname(out), { recursive: true });
106
+ writeFileSync(out, svg);
107
+ }
108
+ catch (e) {
109
+ throw new Fault(`${out}: ${e.message}`);
110
+ }
111
+ return ok([...lines, `${out} — ${(svg.length / 1024).toFixed(1)} kB`].join('\n'));
112
+ }
113
+ if (name === 'verify') {
114
+ need(args, name, ['paths']);
115
+ if (!Array.isArray(args.paths) || args.paths.some((p) => typeof p !== 'string'))
116
+ throw new Bad('verify: paths must be an array of strings');
117
+ types(args, name, { root: 'string', strict: 'boolean' });
118
+ const root = args.root == null ? process.cwd() : String(args.root);
119
+ const lines = [];
120
+ let errors = 0;
121
+ for (const p of args.paths) {
122
+ let props;
123
+ try {
124
+ props = spec(p, 'verify');
125
+ }
126
+ catch (e) {
127
+ if (!(e instanceof Fault))
128
+ throw e;
129
+ errors++;
130
+ lines.push(`${'error'.padEnd(8)} ${'missing-file'.padEnd(18)} ${e.message}`);
131
+ continue;
132
+ }
133
+ const findings = sortFindings([...check(props), ...verify(props, { root })], args.strict === true);
134
+ errors += findings.filter((f) => f.severity === 'error').length;
135
+ lines.push(...findings.map((f) => `${f.severity.padEnd(8)} ${f.rule.padEnd(18)} ${p}: ${f.message}`));
136
+ }
137
+ const n = (k, w) => `${k} ${w}${k === 1 ? '' : 's'}`;
138
+ const total = lines.length;
139
+ lines.push(`${n(errors, 'error')}, ${n(total - errors, 'warning')}`);
140
+ return ok(lines.join('\n'), errors > 0);
141
+ }
142
+ if (name === 'diff') {
143
+ need(args, name, ['old', 'new']);
144
+ return ok(formatDiff(diff(spec(args.old, 'diff'), spec(args.new, 'diff')), 'md'));
145
+ }
146
+ throw new Bad(`unknown tool "${name}"`);
147
+ }
148
+ const reply = (id, result) => ({ jsonrpc: '2.0', id, result });
149
+ const fail = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
150
+ /** One JSON-RPC message in, one response out; `undefined` for a notification. Pure, so the tests call it directly. */
151
+ export function handle(message) {
152
+ if (typeof message !== 'object' || message === null)
153
+ return fail(null, -32600, 'invalid request');
154
+ const { id, method, params } = message;
155
+ const notification = id === undefined;
156
+ if (typeof method !== 'string')
157
+ return notification ? undefined : fail(id, -32600, 'invalid request');
158
+ if (notification)
159
+ return undefined; // a notification gets no reply, and a call sent as one does not run
160
+ const info = { name: 'flowfig', version: VERSION };
161
+ try {
162
+ switch (method) {
163
+ case 'initialize':
164
+ return reply(id, {
165
+ protocolVersion: typeof params?.protocolVersion === 'string' ? params.protocolVersion : '2025-11-25',
166
+ capabilities: { tools: {} },
167
+ serverInfo: info,
168
+ });
169
+ case 'server/discover':
170
+ return reply(id, {
171
+ resultType: 'complete',
172
+ supportedVersions: ['2026-07-28'],
173
+ capabilities: { tools: {} },
174
+ _meta: { 'io.modelcontextprotocol/serverInfo': info },
175
+ });
176
+ case 'ping':
177
+ return reply(id, {});
178
+ case 'tools/list':
179
+ return reply(id, { tools: TOOLS });
180
+ case 'tools/call': {
181
+ const name = String(params?.name ?? '');
182
+ if (!TOOLS.some((t) => t.name === name))
183
+ throw new Bad(`unknown tool "${name}"`);
184
+ return reply(id, run(name, params?.arguments ?? {}));
185
+ }
186
+ default:
187
+ return fail(id, -32601, `method not found: ${method}`);
188
+ }
189
+ }
190
+ catch (e) {
191
+ if (e instanceof Bad)
192
+ return fail(id, -32602, e.message);
193
+ if (e instanceof Fault)
194
+ return reply(id, ok(e.message, true));
195
+ return fail(id, -32603, e.message);
196
+ }
197
+ }
198
+ /** Read one message per line from `input`, write one response per line to `output`. Returns when `input` ends. */
199
+ export async function serve(input, output) {
200
+ const rl = createInterface({ input, crlfDelay: Infinity });
201
+ for await (const line of rl) {
202
+ if (!line.trim())
203
+ continue;
204
+ let message;
205
+ try {
206
+ message = JSON.parse(line);
207
+ }
208
+ catch {
209
+ output.write(JSON.stringify(fail(null, -32700, 'parse error')) + '\n');
210
+ continue;
211
+ }
212
+ const response = handle(message);
213
+ if (response)
214
+ output.write(JSON.stringify(response) + '\n');
215
+ }
216
+ }
package/dist/model.d.ts CHANGED
@@ -13,6 +13,8 @@ export type FigNode = {
13
13
  lines?: number;
14
14
  /** Width in px. Overrides the width the layout picks. */
15
15
  width?: number;
16
+ /** The code this draws, `path` or `path#symbol`, relative to the repo root. `flowfig verify` checks it. */
17
+ source?: string;
16
18
  };
17
19
  /** A frame that lays out its children in a row or a column. Groups can hold groups. */
18
20
  export type FigGroup = {
@@ -43,6 +45,8 @@ export type FigEdge = {
43
45
  around?: 'above' | 'below';
44
46
  /** Draws the edge only while a step uses it. Use it for long edges that would cut across everything. */
45
47
  quiet?: boolean;
48
+ /** The code this draws, `path` or `path#symbol`, relative to the repo root. `flowfig verify` checks it. */
49
+ source?: string;
46
50
  };
47
51
  /** One packet on one edge. A string is the edge `id`. */
48
52
  export type FigHop = string | {
@@ -54,6 +58,8 @@ export type FigHop = string | {
54
58
  data?: ReactNode;
55
59
  /** Marks a message that does not wait for an answer. The rail draws it dashed, with an `async` tag. Default: `false`. */
56
60
  async?: boolean;
61
+ /** The code this draws, `path` or `path#symbol`, relative to the repo root. `flowfig verify` checks it. */
62
+ source?: string;
57
63
  };
58
64
  /** One moment of a step. No edges means a pause. */
59
65
  export type FigBeat = {
@@ -132,6 +138,7 @@ export type Beat = Omit<FigBeat, 'edges'> & {
132
138
  back: boolean;
133
139
  data?: ReactNode;
134
140
  async?: boolean;
141
+ source?: string;
135
142
  }[];
136
143
  };
137
144
  export declare const toBeat: (b: FigHop | FigHop[] | FigBeat) => Beat;
package/dist/scene.d.ts CHANGED
@@ -12,12 +12,14 @@ export type SceneBox = {
12
12
  rect: Rect;
13
13
  texts: TextRun[];
14
14
  };
15
+ /** `step` marks a rail row: rows of two steps never show at the same time, so their labels cannot overlap. */
15
16
  export type SceneEdge = {
16
17
  id: string;
17
18
  from: string;
18
19
  to: string;
19
20
  curve: [Pt, Pt, Pt, Pt];
20
21
  label?: Rect;
22
+ step?: number;
21
23
  };
22
24
  /** `minFont` is the smallest reading text in px, before any scale. Group frames are not boxes. */
23
25
  export type Scene = {
@@ -0,0 +1,16 @@
1
+ import { type FlowProps } from './model.ts';
2
+ /** `path` or `path#symbol`. No spaces, one `#` at most, both parts non-empty. `null` for a bad form. */
3
+ export declare function parseSource(s: string): {
4
+ path: string;
5
+ symbol?: string;
6
+ } | null;
7
+ export type Link = {
8
+ owner: string;
9
+ source: string;
10
+ path: string;
11
+ symbol?: string;
12
+ };
13
+ /** Every box, edge and hop with its raw `source`, well-formed or not. The one owner list that `links` and `checkSpec` share. */
14
+ export declare function owners(fig: FlowProps): [string, string | undefined][];
15
+ /** Every well-formed link in the figure, with the box, edge or hop that carries it. `bad-source` covers the rest. */
16
+ export declare function links(fig: FlowProps): Link[];
package/dist/source.js ADDED
@@ -0,0 +1,24 @@
1
+ // Code links, pure: no Node import, so the React and SVG entries stay browser-safe. `verify` in verify.ts reads the disk.
2
+ import { edgeId, nodes, toBeat } from './model.js';
3
+ /** `path` or `path#symbol`. No spaces, one `#` at most, both parts non-empty. `null` for a bad form. */
4
+ export function parseSource(s) {
5
+ const m = /^([^\s#]+)(?:#([^\s#]+))?$/.exec(s);
6
+ if (!m)
7
+ return null;
8
+ return m[2] ? { path: m[1], symbol: m[2] } : { path: m[1] };
9
+ }
10
+ /** Every box, edge and hop with its raw `source`, well-formed or not. The one owner list that `links` and `checkSpec` share. */
11
+ export function owners(fig) {
12
+ return [
13
+ ...nodes(fig.layout).map((n) => [`box "${n.id}"`, n.source]),
14
+ ...fig.edges.map((e) => [`edge "${edgeId(e)}"`, e.source]),
15
+ ...(fig.steps ?? []).flatMap((s) => s.flow.flatMap((b) => toBeat(b).hops.map((h) => [`hop on "${h.edge}"`, h.source]))),
16
+ ];
17
+ }
18
+ /** Every well-formed link in the figure, with the box, edge or hop that carries it. `bad-source` covers the rest. */
19
+ export function links(fig) {
20
+ return owners(fig).flatMap(([owner, source]) => {
21
+ const p = source == null ? null : parseSource(source);
22
+ return p ? [{ owner, source: source, ...p }] : [];
23
+ });
24
+ }
package/dist/svg.d.ts CHANGED
@@ -3,6 +3,8 @@ import type { Finding, Scene } from './scene.ts';
3
3
  export type { CheckOptions } from './check.ts';
4
4
  export type { Finding, Scene } from './scene.ts';
5
5
  export type * from './model.ts';
6
+ export { parseSource, links, type Link } from './source.ts';
7
+ export { diff, formatDiff, type Change } from './diff.ts';
6
8
  import { type FigTheme, type FlowProps } from './model.ts';
7
9
  export { LIGHT, DARK } from './model.ts';
8
10
  /** Options for `toSvg`, `render` and `check`. */
package/dist/svg.js CHANGED
@@ -10,6 +10,8 @@ import { route } from './geometry.js';
10
10
  import { foldedLabel, groupBox, layoutRail, railState, RAIL } from './rail.js';
11
11
  import { textWidth } from './text.js';
12
12
  import { checkScene, checkSpec, checkTheme } from './check.js';
13
+ export { parseSource, links } from './source.js';
14
+ export { diff, formatDiff } from './diff.js';
13
15
  import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, DARK, EDGE_OFF, EDGE_ON, LIGHT, TONES, labelPillW, ON_ACCENT, str, edgeId, groupGap, isGroup, isRows, toBeat, } from './model.js';
14
16
  export { LIGHT, DARK } from './model.js';
15
17
  const LINE = CARD_LINE, CARD_SIDE = 8, ROW_GAP = 4;
@@ -541,6 +543,7 @@ ${said.join('\n')}
541
543
  return [
542
544
  {
543
545
  id: `rail:${row.n}`,
546
+ step: row.step,
544
547
  from: rail.columns[row.from].id,
545
548
  to: rail.columns[row.to].id,
546
549
  curve: [p, p, q, q],
@@ -0,0 +1,7 @@
1
+ import type { FlowProps } from './model.ts';
2
+ import type { Finding } from './scene.ts';
3
+ export { parseSource, owners, links, type Link } from './source.ts';
4
+ /** The links against the files under `root`. A path outside the root is a missing file: the figure names the repo, not the disk. */
5
+ export declare function verify(fig: FlowProps, { root }?: {
6
+ root?: string;
7
+ }): Finding[];
package/dist/verify.js ADDED
@@ -0,0 +1,36 @@
1
+ // `flowfig verify`: checks that the code a link names is still there. The link parsing lives in source.ts, which has no Node import.
2
+ import { readFileSync, statSync } from 'node:fs';
3
+ import { isAbsolute, relative, resolve } from 'node:path';
4
+ import { links } from './source.js';
5
+ export { parseSource, owners, links } from './source.js';
6
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
7
+ /** The file text, or null for a missing path, a folder or an unreadable file. */
8
+ function read(full) {
9
+ try {
10
+ return statSync(full).isFile() ? readFileSync(full, 'utf8') : null;
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
16
+ /** The links against the files under `root`. A path outside the root is a missing file: the figure names the repo, not the disk. */
17
+ export function verify(fig, { root = process.cwd() } = {}) {
18
+ const out = [];
19
+ const all = links(fig);
20
+ if (!all.length)
21
+ return [{ rule: 'no-source', severity: 'warning', ids: [], message: 'no box, edge or hop has a source' }];
22
+ const files = new Map();
23
+ for (const l of all) {
24
+ const full = resolve(root, l.path);
25
+ const rel = relative(root, full);
26
+ const outside = isAbsolute(l.path) || rel === '..' || rel.startsWith('../');
27
+ if (!files.has(full))
28
+ files.set(full, outside ? null : read(full));
29
+ const text = files.get(full);
30
+ if (text == null)
31
+ out.push({ rule: 'missing-file', severity: 'error', ids: [], message: `${l.owner} -> ${l.source}: file not found` });
32
+ else if (l.symbol && !new RegExp(`(^|[^\\w$])${escape(l.symbol)}(?![\\w$])`).test(text))
33
+ out.push({ rule: 'missing-symbol', severity: 'error', ids: [], message: `${l.owner} -> ${l.source}: symbol not found` });
34
+ }
35
+ return out;
36
+ }
@@ -0,0 +1,2 @@
1
+ /** The package version, for the MCP `serverInfo`. The release list bumps it with package.json. */
2
+ export declare const VERSION = "0.2.0";
@@ -0,0 +1,2 @@
1
+ /** The package version, for the MCP `serverInfo`. The release list bumps it with package.json. */
2
+ export const VERSION = '0.2.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowfig",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Your coding agent draws animated diagrams from your code, and flowfig checks them. One animated SVG for a GitHub README, PR or issue, and a React player. Set up Claude Code, Cursor, GitHub Copilot, Codex, Gemini CLI, Windsurf and Kiro with npx flowfig init.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,6 +40,10 @@
40
40
  "./svg": {
41
41
  "types": "./dist/svg.d.ts",
42
42
  "default": "./dist/svg.js"
43
+ },
44
+ "./verify": {
45
+ "types": "./dist/verify.d.ts",
46
+ "default": "./dist/verify.js"
43
47
  }
44
48
  },
45
49
  "bin": {