@itsthecsguy/linework 0.1.4 → 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/README.md CHANGED
@@ -159,6 +159,8 @@ Linework sizes every box from the real width of its text in the font it renders
159
159
 
160
160
  Linework needs Node 20+ and a Chromium-based browser, which it uses headless to measure text and render. It tries Playwright's Chromium, then Chrome, then Edge. If none is found, run `linework install-browser`, or set `LINEWORK_CHROME` to a browser binary. If a browser is found but crashes on launch, an agent's sandbox is usually blocking it. Linework then retries Playwright's Chromium in single-process mode, which starts inside Codex's sandbox.
161
161
 
162
+ The first drawing command starts a background process that keeps Chromium running, and later commands hand their work to it. A build of the checkout example takes about 1.0s the first time and 0.16s after that. The process listens on a Unix socket in a folder only you can open, exits after 5 idle minutes, and is off on Windows and in CI. `LINEWORK_NO_DAEMON=1` or `--no-daemon` turns it off, and `linework daemon stop` ends it.
163
+
162
164
  Linework keeps the font subsets it embeds in your cache folder (`~/Library/Caches/linework` on macOS, `~/.cache/linework` on Linux, `%LOCALAPPDATA%\linework` on Windows), so rebuilding a diagram doesn't compress the same fonts again. Set `LINEWORK_NO_CACHE=1` to turn that off.
163
165
 
164
166
  ## Connect over MCP
@@ -202,6 +204,20 @@ npx -y @itsthecsguy/linework skill install .claude/skills # Claude Code
202
204
  npx -y @itsthecsguy/linework skill install .grok/skills # Grok Build
203
205
  ```
204
206
 
207
+ ## Small changes
208
+
209
+ Once a diagram exists, a change to it keeps everything else where it is. That holds whether a person arranged the layout in draw.io or nobody has touched it.
210
+
211
+ ```bash
212
+ linework set docs/checkout.drawio.svg api.label="Checkout API v2" api.highlight=true
213
+ linework add docs/checkout.drawio.svg "fraud: { label: Fraud check, in: vpc }" "api -> fraud: score"
214
+ linework rm docs/checkout.drawio.svg cache "api -> stripe"
215
+ ```
216
+
217
+ A renamed box grows in place. A new box goes next to the one it connects to, and its group grows to hold it without covering boxes outside the group. A removed box takes its edges with it, and its group closes up. `linework build` with an edited spec follows the same rules. It lays out from scratch only for a new file, a changed direction or theme on a layout nobody arranged, or `--relayout`.
218
+
219
+ After dragging boxes around in draw.io, `linework tidy <file>` reroutes the edges left behind and moves labels off whatever they landed on. Boxes stay where you put them.
220
+
205
221
  ## Editing by hand
206
222
 
207
223
  Any draw.io editor works on these files: draw.io desktop, VS Code with the Draw.io Integration extension, or app.diagrams.net. Or:
@@ -221,11 +237,14 @@ Docs can't hold an SVG, so the diagram lives in Google Drive and draw.io's Googl
221
237
  | command | does |
222
238
  |---|---|
223
239
  | `linework build <spec\|-> -o out.drawio.svg [--png p.png] [--relayout]` | layout (or merge into an existing file), write, lint |
240
+ | `linework set <file> id.field=value...` | change a label, kind, color, theme or title in place |
241
+ | `linework add <file> "id: {...}" "a -> b: label"...` | add boxes, groups and edges in place |
242
+ | `linework rm <file> id "a -> b"...` | remove boxes, groups and edges in place |
224
243
  | `linework show <file> [--geometry]` | print the spec of a diagram, including hand edits |
225
244
  | `linework edit <file> [--no-open]` | edit in the bundled draw.io, in your browser; saves write the file |
226
245
  | `linework check <file>` | lint: overlaps, edges through nodes, labels that don't fit, too wide |
227
246
  | `linework render <file> -o out.png [--scale 2]` | preview image |
228
- | `linework tidy <file>` | reroute edges that pass through nodes, change nothing else |
247
+ | `linework tidy <file>` | after hand edits: reroute stale edges and move colliding labels; boxes stay |
229
248
  | `linework import <file.mmd\|doc.md> [-o out.drawio.svg]` | convert a Mermaid diagram |
230
249
  | `linework publish <file...> [--to dir] [--pull]` | copy diagrams into Google Drive for the Docs add-on |
231
250
  | `linework icons <aws\|gcp\|k8s\|azure> [filter]` | list icon names |
@@ -234,6 +253,7 @@ Docs can't hold an SVG, so the diagram lives in Google Drive and draw.io's Googl
234
253
  | `linework mcp [--root dir]` | MCP server over stdio |
235
254
  | `linework kinds [type]` | node kinds per diagram type |
236
255
  | `linework schema` | JSON Schema of the spec |
256
+ | `linework daemon [status\|stop]` | the background process that keeps the browser running between builds |
237
257
  | `linework install-browser` | download the headless Chromium build this version uses |
238
258
 
239
259
  ## Status
package/dist/api.js CHANGED
@@ -36,8 +36,13 @@ export async function build(specText, opts) {
36
36
  }
37
37
  }
38
38
  const edits = old ? manualEdits(old) : [];
39
- // Once a person has touched the layout, every later build merges into it.
40
- const mode = old && !opts.relayout && (old.meta.aiviz_layout === 'manual' || edits.length > 0) ? 'merge' : 'fresh';
39
+ // Once a person has touched the layout, every later build merges into it. A
40
+ // layout nobody touched is also kept when the spec changed a little: a renamed
41
+ // label or one new box shouldn't move the rest.
42
+ const touched = !!old && (old.meta.aiviz_layout === 'manual' || edits.length > 0);
43
+ let mode = 'fresh';
44
+ if (old && !opts.relayout)
45
+ mode = touched || opts.keepLayout || smallChange(old, diagram) ? 'merge' : 'fresh';
41
46
  return withBrowser(opts.browser, async (b) => {
42
47
  const measure = (r) => b.measure(r);
43
48
  for (const n of diagram.nodes) {
@@ -82,26 +87,45 @@ export async function build(specText, opts) {
82
87
  const l = await layout(diagram, measure);
83
88
  let xml;
84
89
  let stats;
90
+ let freshXml;
85
91
  if (mode === 'merge') {
86
- const m = merge(diagram, old, l, { specIsCurrent: specBase(specText) === fingerprint(old) });
92
+ const m = merge(diagram, old, l, { specIsCurrent: specBase(specText) === fingerprint(old), touched });
87
93
  stats = m.stats;
88
94
  xml = serialize(m.scene);
89
95
  if (m.reroute.length)
90
96
  xml = await rerouteInto(b, xml, m.reroute);
91
97
  // Kept routes can already be broken, e.g. stale waypoints after someone moved a node.
92
- const broken = brokenEdges(check(await b.inspect(xml)));
98
+ const after = check(await b.inspect(xml));
99
+ const broken = brokenEdges(after);
93
100
  if (broken.length) {
94
101
  xml = await rerouteInto(b, xml, broken);
95
102
  stats.rerouted += broken.filter((id) => !m.reroute.includes(id)).length;
96
103
  }
104
+ // Nobody arranged this layout, so there is nothing to protect: when the
105
+ // change doesn't fit into it cleanly, lay it out again.
106
+ if (!touched && !opts.keepLayout) {
107
+ const merged = check(await b.inspect(xml));
108
+ const bad = (c) => c.findings.filter((f) => f.level === 'error').length;
109
+ let redo = bad(merged) > 0;
110
+ if (!redo && (stats.placed.length || merged.stats.crossings)) {
111
+ freshXml = await straightenDetours(b, toMxfile(diagram, l), detourEdges(diagram, l));
112
+ redo = merged.stats.crossings > check(await b.inspect(freshXml)).stats.crossings + 1;
113
+ }
114
+ if (redo) {
115
+ mode = 'fresh';
116
+ stats = undefined;
117
+ }
118
+ }
97
119
  }
120
+ if (mode === 'fresh')
121
+ xml = freshXml ?? (await straightenDetours(b, toMxfile(diagram, l), detourEdges(diagram, l)));
98
122
  else
99
- xml = await straightenDetours(b, toMxfile(diagram, l), detourEdges(diagram, l));
123
+ xml = xml;
100
124
  const fixed = await fixLabels(b, xml);
101
125
  xml = fixed.xml;
102
126
  xml = stampEdgeRoutes(xml);
103
127
  // Stamping routes changes attributes, not geometry: the cells fixLabels read still hold.
104
- return finish(b, xml, { mode, warnings, direction: mode === 'merge' ? (old.meta.aiviz_direction ?? l.direction) : l.direction, edits, old, stats, cells: fixed.cells });
128
+ return finish(b, xml, { mode, warnings, direction: mode === 'merge' ? (old.meta.aiviz_direction ?? l.direction) : l.direction, edits, old, stats, cells: fixed.cells, labelsMoved: fixed.moved });
105
129
  });
106
130
  async function finish(b, xml, r) {
107
131
  fs.mkdirSync(path.dirname(path.resolve(opts.out)), { recursive: true });
@@ -116,10 +140,33 @@ export async function build(specText, opts) {
116
140
  direction: r.direction,
117
141
  mode: r.mode,
118
142
  merge: r.stats,
143
+ labelsMoved: r.labelsMoved ?? 0,
119
144
  discardedLayout: !!(opts.relayout && r.old && (r.old.meta.aiviz_layout === 'manual' || r.edits.length > 0)),
120
145
  };
121
146
  }
122
147
  }
148
+ /**
149
+ * Whether the new spec is close enough to an untouched file to keep its layout:
150
+ * same type, direction and theme, and only a few new boxes. Sequence diagrams
151
+ * always lay out again; their rows follow message order either way.
152
+ */
153
+ function smallChange(old, d) {
154
+ if (d.type === 'sequence')
155
+ return false;
156
+ let prev;
157
+ try {
158
+ prev = toDiagram(old).diagram;
159
+ }
160
+ catch {
161
+ return false;
162
+ }
163
+ if (prev.type !== d.type || prev.direction !== d.direction || prev.theme !== d.theme)
164
+ return false;
165
+ const had = new Set(prev.nodes.map((n) => n.id));
166
+ const added = d.nodes.filter((n) => !had.has(n.id)).length;
167
+ const kept = d.nodes.length - added;
168
+ return kept > 0 && added <= Math.max(2, Math.ceil(d.nodes.length / 4));
169
+ }
123
170
  /**
124
171
  * Edges whose route is far longer than the gap between their ends. Layered layout
125
172
  * draws one edge of every cycle backwards, so the reply in a call-and-callback pair
@@ -160,17 +207,27 @@ async function straightenDetours(b, xml, ids) {
160
207
  async function rerouteInto(b, xml, edgeIds) {
161
208
  return patchEdges(xml, await b.reroute(xml, edgeIds), edgeIds);
162
209
  }
163
- /** Reroute only the edges check flags as broken. Nothing else changes. */
210
+ /**
211
+ * Repair a diagram after hand edits: reroute edges that are broken or were left
212
+ * behind when someone moved a box, and move labels off whatever they landed on.
213
+ * Boxes, groups and routes a person drew stay where they are.
214
+ */
164
215
  export async function tidy(file, browser) {
165
216
  const text = fs.readFileSync(file, 'utf8');
166
217
  const m = readDrawio(text);
218
+ if (m.meta.aiviz_type && m.meta.aiviz_type !== 'sequence') {
219
+ // The file's own spec merged into itself: positions stay, hand moves become
220
+ // part of the layout, and only stale routes and colliding labels change.
221
+ const res = await build(show(file).yaml, { out: file, keepLayout: true, browser });
222
+ return { fixed: res.merge?.rerouted ?? 0, labels: res.labelsMoved, check: res.check };
223
+ }
167
224
  return withBrowser(browser, async (b) => {
168
225
  const broken = brokenEdges(check(await b.inspect(m.xml)));
169
226
  if (!broken.length)
170
- return { fixed: 0, check: check(await b.inspect(m.xml)) };
227
+ return { fixed: 0, labels: 0, check: check(await b.inspect(m.xml)) };
171
228
  const xml = stampEdgeRoutes(await rerouteInto(b, m.xml, broken));
172
229
  fs.writeFileSync(file, file.endsWith('.svg') ? await b.renderSvg(xml) : xml);
173
- return { fixed: broken.length, check: check(await b.inspect(xml)) };
230
+ return { fixed: broken.length, labels: 0, check: check(await b.inspect(xml)) };
174
231
  });
175
232
  }
176
233
  export function show(file, opts = {}) {
package/dist/cli.js CHANGED
@@ -1,320 +1,87 @@
1
1
  #!/usr/bin/env node
2
- import fs from 'node:fs';
3
2
  import module from 'node:module';
4
- import path from 'node:path';
5
3
  import { parseArgs } from 'node:util';
6
- const USAGE = `linework: AI-written, hand-editable diagrams (YAML spec -> .drawio.svg)
7
-
8
- Usage:
9
- linework build <spec.yaml|-> -o <out.drawio.svg> [--png preview.png] [--relayout]
10
- Lay out and write the diagram, then lint it. "-" reads the spec from stdin.
11
- If the file exists and someone edited it by hand, the spec is merged in:
12
- existing positions stay, new nodes are placed next to their neighbours.
13
- --relayout throws the existing layout away (hand edits included).
14
- linework show <file.drawio.svg> [--geometry]
15
- Print the spec of an existing diagram (including hand edits) as YAML.
16
- --geometry appends node positions as comments.
17
- linework render <file.drawio.svg> -o <out.png|out.svg> [--scale 2]
18
- Render a preview image.
19
- linework check <file.drawio.svg>
20
- Lint layout: overlaps, edges through nodes, labels that don't fit.
21
- linework tidy <file.drawio.svg>
22
- Reroute edges that check flags as broken (e.g. after moving nodes by hand).
23
- Nothing else in the file changes.
24
- linework edit <file.drawio.svg> [--no-open] [--port N]
25
- Open the diagram in draw.io (bundled, offline) in your browser. Saving in
26
- the editor writes the file. Stops when you exit the editor or close the tab.
27
- linework import <file.mmd|doc.md|-> [--block N] [-o out.drawio.svg] [--png p.png]
28
- Convert a Mermaid diagram (flowchart, state, sequence, ER) to an linework spec.
29
- Prints the spec, or builds it straight away with -o.
30
- linework icons <aws|gcp|k8s|azure> [filter]
31
- List icon names, for "icon: aws.lambda" on architecture nodes.
32
- linework publish <file.drawio.svg...> [--to DIR] [--pull] [--force]
33
- Copy diagrams into your Google Drive folder (Google Drive for desktop) so
34
- draw.io's Google Docs add-on can insert them and refresh them later.
35
- --pull copies back a diagram someone edited from Docs or Drive.
36
- linework skill [install <dir>]
37
- Print where the bundled agent skill is, or copy it to <dir>/linework
38
- (e.g. .claude/skills, ~/.agents/skills).
39
- linework mcp [--root DIR]
40
- Run as an MCP server over stdio, for chat apps and agents without a shell.
41
- Relative paths resolve against --root (default: the current folder).
42
- linework git-setup
43
- Make git diff show .drawio.svg changes as spec changes (this repo only).
44
- linework kinds [architecture|flow|state]
45
- List node kinds for each diagram type.
46
- linework schema
47
- Print the spec's JSON Schema.
48
-
49
- linework install-browser
50
- Download the headless Chromium build that matches this version. Needed only
51
- when no Chrome or Edge is installed, or to run inside an agent's sandbox.
52
- `;
53
4
  // Node 22.1+ can keep compiled bytecode for linework's modules on disk, which
54
5
  // takes about a quarter off loading them on every run after the first. Older
55
6
  // Node has no such call. NODE_DISABLE_COMPILE_CACHE=1 turns it off.
56
7
  module.enableCompileCache?.();
57
8
  async function main(argv) {
9
+ const { OPTIONS, USAGE, needsBrowser, run, report, processIO } = await import('./commands.js');
58
10
  const [cmd, ...rest] = argv;
59
- const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: OPTIONS });
11
+ if (cmd === 'daemon')
12
+ return daemon(rest[0]);
13
+ let parsed;
14
+ try {
15
+ parsed = parseArgs({ args: rest, allowPositionals: true, options: OPTIONS });
16
+ }
17
+ catch (e) {
18
+ process.exitCode = report(e);
19
+ return;
20
+ }
21
+ const { values, positionals } = parsed;
60
22
  if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h' || values.help)
61
23
  return void process.stdout.write(USAGE);
24
+ const draws = needsBrowser(cmd, values);
25
+ // A custom render scale needs its own page, so that one runs here.
26
+ const shareable = draws && !(cmd === 'render' && values.scale) && !values['no-daemon'];
27
+ if (shareable) {
28
+ // Chromium, draw.io and ELK may already be warm in the background process.
29
+ const { viaDaemon } = await import('./daemon.js');
30
+ const stdin = positionals[0] === '-' && (cmd === 'build' || cmd === 'import') ? processIO.stdin() : undefined;
31
+ const code = await viaDaemon({ argv, cwd: process.cwd(), stdin }, processIO.out, processIO.err);
32
+ if (code !== undefined)
33
+ return exitWhenFlushed(code);
34
+ if (stdin !== undefined)
35
+ processIO.stdin = () => stdin;
36
+ }
62
37
  // Commands that draw start Chromium first and load the rest of linework while it
63
38
  // boots and loads draw.io: the two take about the same time, so they overlap.
64
- const needsBrowser = ['build', 'render', 'check', 'tidy', 'icons'].includes(cmd) || (cmd === 'import' && !!values.out);
65
- const launching = needsBrowser ? import('./render/browser.js').then((m) => m.DrawioBrowser.launch({ scale: cmd === 'render' && values.scale ? Number(values.scale) : 2 })) : undefined;
39
+ const launching = draws ? import('./render/browser.js').then((m) => m.DrawioBrowser.launch({ scale: cmd === 'render' && values.scale ? Number(values.scale) : 2 })) : undefined;
66
40
  // A failure here surfaces where the browser is awaited; don't report it twice.
67
41
  launching?.catch(() => { });
42
+ let failed = false;
68
43
  try {
69
44
  await run(cmd, values, positionals, launching);
70
45
  }
46
+ catch (e) {
47
+ failed = true;
48
+ process.exitCode = report(e);
49
+ }
71
50
  finally {
72
51
  if (launching)
73
52
  await (await launching.catch(() => undefined))?.close();
74
53
  }
54
+ // The next drawing command finds the browser already running.
55
+ if (shareable && !failed)
56
+ (await import('./daemon.js')).startDaemon();
75
57
  // The font subsetter's WebAssembly keeps node alive for ~100ms after the work
76
58
  // is done, with nothing left to run. A drawing command is finished here.
77
59
  if (launching)
78
- await exitWhenFlushed();
60
+ await exitWhenFlushed(Number(process.exitCode ?? 0));
79
61
  }
80
- /** A small spec with a group, labels and a cycle: enough to exercise ELK's main paths. */
81
- const WARM_SPEC = 'type: architecture\ngroups: { g: G }\nnodes:\n a: { in: g }\n b: { in: g }\n c: {}\nedges:\n - a -> b: x\n - b -> c: y\n - c -> a\n';
82
- async function warmUp() {
83
- const [{ parseSpecText }, { layout }] = await Promise.all([import('./spec/parse.js'), import('./layout/layout.js')]);
84
- await layout(parseSpecText(WARM_SPEC).diagram);
62
+ async function daemon(sub) {
63
+ const d = await import('./daemon.js');
64
+ if (sub === 'run')
65
+ return d.serve();
66
+ if (sub === 'stop')
67
+ return void console.log((await d.stopDaemon()) ? 'stopped' : 'not running');
68
+ if (sub === 'status' || !sub) {
69
+ const sock = d.daemonAllowed() ? d.socketPath() : undefined;
70
+ const fs = await import('node:fs');
71
+ return void console.log(!sock ? 'off (LINEWORK_NO_DAEMON, CI or Windows)' : fs.existsSync(sock) ? `running at ${sock}` : 'not running; the next build starts it');
72
+ }
73
+ console.error('usage: linework daemon [status|stop]');
74
+ process.exitCode = 2;
85
75
  }
86
- function exitWhenFlushed() {
76
+ function exitWhenFlushed(code) {
87
77
  return new Promise(() => {
88
78
  let pending = 2;
89
- const done = () => --pending === 0 && process.exit();
79
+ const done = () => --pending === 0 && process.exit(code);
90
80
  process.stdout.write('', done);
91
81
  process.stderr.write('', done);
92
82
  });
93
83
  }
94
- const OPTIONS = {
95
- out: { type: 'string', short: 'o' },
96
- png: { type: 'string' },
97
- force: { type: 'boolean', default: false },
98
- relayout: { type: 'boolean', default: false },
99
- geometry: { type: 'boolean', default: false },
100
- quiet: { type: 'boolean', short: 'q', default: false },
101
- block: { type: 'string' },
102
- scale: { type: 'string' },
103
- 'no-open': { type: 'boolean', default: false },
104
- port: { type: 'string' },
105
- root: { type: 'string' },
106
- to: { type: 'string' },
107
- pull: { type: 'boolean', default: false },
108
- help: { type: 'boolean', short: 'h' },
109
- };
110
- async function run(cmd, values, positionals, launching) {
111
- const browser = async () => {
112
- // ELK compiles its code on first use. Laying out a tiny graph while Chromium
113
- // is still starting takes that cost off the real layout.
114
- if (launching && ['build', 'import'].includes(cmd))
115
- await warmUp();
116
- return launching;
117
- };
118
- const { buildLines } = await import('./report.js');
119
- switch (cmd) {
120
- case 'build': {
121
- const src = positionals[0];
122
- if (!src || !values.out)
123
- fail('usage: linework build <spec.yaml|-> -o <out.drawio.svg>');
124
- const text = src === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(src, 'utf8');
125
- const { build } = await import('./api.js');
126
- // Spec errors first: they shouldn't wait on, or hide behind, a browser problem.
127
- (await import('./spec/parse.js')).parseSpecText(text);
128
- const res = await build(text, { out: values.out, png: values.png, force: values.force, relayout: values.relayout, browser: await browser() });
129
- for (const line of buildLines(res, values.png))
130
- console.log(line);
131
- return;
132
- }
133
- case 'show': {
134
- const file = positionals[0] ?? fail('usage: linework show <file.drawio.svg>');
135
- const { show } = await import('./api.js');
136
- const res = show(file, { geometry: values.geometry });
137
- process.stdout.write(res.yaml);
138
- if (values.quiet)
139
- return;
140
- for (const n of res.notes)
141
- console.error(`note: ${n}`);
142
- if (res.edits.length) {
143
- const moved = res.edits.filter((e) => e.what !== 'added').length;
144
- const added = res.edits.filter((e) => e.what === 'added').length;
145
- console.error(`note: edited by hand since the last build (${moved} moved/resized, ${added} added). build keeps those positions and merges your changes in.`);
146
- }
147
- return;
148
- }
149
- case 'render': {
150
- const file = positionals[0];
151
- if (!file || !values.out)
152
- fail('usage: linework render <file.drawio.svg> -o <out.png>');
153
- const { render } = await import('./api.js');
154
- await render(file, values.out, await browser());
155
- console.log(`wrote ${values.out}`);
156
- return;
157
- }
158
- case 'check': {
159
- const file = positionals[0] ?? fail('usage: linework check <file.drawio.svg>');
160
- const { checkFile } = await import('./api.js');
161
- const res = await checkFile(file, await browser());
162
- await printCheck(res);
163
- if (res.findings.some((f) => f.level === 'error'))
164
- process.exitCode = 1;
165
- return;
166
- }
167
- case 'tidy': {
168
- const file = positionals[0] ?? fail('usage: linework tidy <file.drawio.svg>');
169
- const { tidy } = await import('./api.js');
170
- const res = await tidy(file, await browser());
171
- console.log(res.fixed ? `rerouted ${res.fixed} edges in ${file}` : 'nothing to fix');
172
- await printCheck(res.check);
173
- return;
174
- }
175
- case 'edit': {
176
- const file = positionals[0] ?? fail('usage: linework edit <file.drawio.svg>');
177
- const { startEditor, openInBrowser } = await import('./edit/server.js');
178
- const session = await startEditor(file, { port: values.port ? Number(values.port) : undefined });
179
- const opened = !values['no-open'] && openInBrowser(session.url);
180
- console.log(`${opened ? 'opened' : 'open'} ${session.url}`);
181
- console.log(`editing ${file}. Save in the editor (Ctrl/Cmd+S) to write the file. Exit the editor, close the tab or press Ctrl+C to stop.`);
182
- process.once('SIGINT', () => void session.close());
183
- const res = await session.done;
184
- console.log(res.saves ? `saved ${file} ${res.saves - res.conflicts.length} time(s)` : 'closed without saving');
185
- for (const c of res.conflicts)
186
- console.log(`note: ${file} changed on disk while it was open, so one save went to ${c}`);
187
- return;
188
- }
189
- case 'import': {
190
- const src = positionals[0] ?? fail('usage: linework import <file.mmd|doc.md|-> [-o out.drawio.svg]');
191
- const { importMermaid, mermaidBlocks } = await import('./import/mermaid.js');
192
- const { toYaml } = await import('./drawio/show.js');
193
- let text = src === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(src, 'utf8');
194
- if (/\.(md|markdown|mdx)$/i.test(src)) {
195
- const blocks = mermaidBlocks(text);
196
- if (!blocks.length)
197
- fail(`${src} has no \`\`\`mermaid blocks`);
198
- const n = values.block ? Number(values.block) : blocks.length === 1 ? 1 : 0;
199
- if (!n || !blocks[n - 1]) {
200
- const first = blocks.map((b, i) => ` ${i + 1}: ${b.split('\n')[0]}`).join('\n');
201
- fail(`${src} has ${blocks.length} mermaid blocks; pick one with --block N:\n${first}`);
202
- }
203
- text = blocks[n - 1];
204
- }
205
- const res = importMermaid(text);
206
- for (const w of res.warnings)
207
- console.error(`import: ${w}`);
208
- const yaml = toYaml(res.diagram, 'imported from Mermaid');
209
- if (!values.out)
210
- return void process.stdout.write(yaml);
211
- const { build } = await import('./api.js');
212
- const built = await build(yaml, { out: values.out, png: values.png, relayout: values.relayout, force: values.force, browser: await browser() });
213
- console.log(`wrote ${built.out} (${built.check.stats.nodes} nodes, ${built.check.stats.edges} edges, ${built.direction})`);
214
- await printCheck(built.check);
215
- return;
216
- }
217
- case 'icons': {
218
- const provider = positionals[0];
219
- if (!provider || !['aws', 'gcp', 'k8s', 'azure'].includes(provider))
220
- fail('usage: linework icons <aws|gcp|k8s|azure> [filter]');
221
- const { listIcons } = await import('./api.js');
222
- const names = await listIcons(provider, positionals[1], await browser());
223
- console.log(names.map((n) => `${provider}.${n}`).join('\n') || 'no matches');
224
- return;
225
- }
226
- case 'publish': {
227
- if (!positionals.length)
228
- fail('usage: linework publish <file.drawio.svg...> [--to DIR]');
229
- const { publish } = await import('./publish.js');
230
- const items = publish(positionals, { to: values.to, pull: values.pull, force: values.force });
231
- for (const i of items) {
232
- if (i.action === 'published')
233
- console.log(`published ${path.relative(process.cwd(), i.file)} -> ${i.dest}`);
234
- else if (i.action === 'unchanged')
235
- console.log(`unchanged ${i.dest}`);
236
- else if (i.action === 'pulled')
237
- console.log(`pulled ${i.dest} -> ${path.relative(process.cwd(), i.file)}`);
238
- else
239
- console.log(`skipped ${path.relative(process.cwd(), i.file)}: the Drive copy was edited after the last publish. Run with --pull to take that version, or --force to overwrite it.`);
240
- }
241
- if (items.some((i) => i.action === 'published') && !values.pull) {
242
- console.log('\nNot in the doc yet? In Google Docs: Extensions > draw.io > Insert diagrams, then pick the file.');
243
- console.log('Already in the doc? After Drive syncs: Extensions > draw.io > Update diagrams.');
244
- }
245
- if (items.some((i) => i.action === 'conflict'))
246
- process.exitCode = 1;
247
- return;
248
- }
249
- case 'skill': {
250
- const { fileURLToPath } = await import('node:url');
251
- const src = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'skills', 'linework');
252
- if (positionals[0] !== 'install')
253
- return void console.log(src);
254
- const dir = positionals[1] ?? fail('usage: linework skill install <skills-dir> e.g. .claude/skills');
255
- const dest = path.join(dir.replace(/^~(?=$|\/)/, process.env.HOME ?? '~'), 'linework');
256
- fs.mkdirSync(dest, { recursive: true });
257
- fs.cpSync(src, dest, { recursive: true });
258
- console.log(`installed the linework skill to ${dest}`);
259
- return;
260
- }
261
- case 'mcp': {
262
- const { runStdio } = await import('./mcp/server.js');
263
- await runStdio({ root: path.resolve(values.root ?? process.env.LINEWORK_ROOT ?? process.cwd()) });
264
- return;
265
- }
266
- case 'git-setup': {
267
- const { gitSetup } = await import('./git.js');
268
- for (const line of gitSetup())
269
- console.log(line);
270
- return;
271
- }
272
- case 'kinds': {
273
- const { KINDS, DIAGRAM_TYPES } = await import('./spec/model.js');
274
- const types = (positionals.length ? positionals : DIAGRAM_TYPES);
275
- for (const t of types) {
276
- if (!KINDS[t])
277
- fail(`unknown type ${t}`);
278
- console.log(`${t}:`);
279
- for (const [k, d] of Object.entries(KINDS[t]))
280
- console.log(` ${k.padEnd(10)} ${d.description}`);
281
- }
282
- return;
283
- }
284
- case 'schema': {
285
- const { jsonSchema } = await import('./spec/parse.js');
286
- console.log(JSON.stringify(jsonSchema(), null, 2));
287
- return;
288
- }
289
- case 'install-browser': {
290
- const { browserInstallCommand } = await import('./render/browser.js');
291
- const { spawnSync } = await import('node:child_process');
292
- const [bin, ...args] = browserInstallCommand();
293
- console.log('downloading headless Chromium for linework...');
294
- const r = spawnSync(bin, args, { stdio: 'inherit' });
295
- process.exitCode = r.status ?? 1;
296
- return;
297
- }
298
- default:
299
- fail(`unknown command "${cmd}"\n\n${USAGE}`);
300
- }
301
- }
302
- async function printCheck(res) {
303
- const { checkLines } = await import('./report.js');
304
- for (const line of checkLines(res))
305
- console.log(line);
306
- }
307
- function fail(msg) {
308
- console.error(msg);
309
- process.exit(2);
310
- }
311
- main(process.argv.slice(2)).catch((e) => {
312
- if (e?.name === 'SpecError') {
313
- console.error('spec error:');
314
- for (const i of e.issues)
315
- console.error(` ${i.path ? i.path + ': ' : ''}${i.message}`);
316
- process.exit(1);
317
- }
318
- console.error(`error: ${e.message}`);
319
- process.exit(1);
84
+ main(process.argv.slice(2)).catch(async (e) => {
85
+ const { report } = await import('./commands.js');
86
+ process.exit(report(e));
320
87
  });