pmx-canvas 0.3.3 → 0.4.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/CHANGELOG.md +99 -0
- package/Readme.md +13 -4
- package/dist/canvas/index.js +71 -71
- package/dist/json-render/index.js +47 -47
- package/dist/types/cli/agent.d.ts +13 -8
- package/dist/types/cli/commands/apps.d.ts +1 -0
- package/dist/types/cli/commands/ax.d.ts +1 -0
- package/dist/types/cli/commands/copilot.d.ts +1 -0
- package/dist/types/cli/commands/edges.d.ts +1 -0
- package/dist/types/cli/commands/groups.d.ts +1 -0
- package/dist/types/cli/commands/history.d.ts +1 -0
- package/dist/types/cli/commands/nodes.d.ts +1 -0
- package/dist/types/cli/commands/pins.d.ts +1 -0
- package/dist/types/cli/commands/query.d.ts +1 -0
- package/dist/types/cli/commands/view.d.ts +1 -0
- package/dist/types/cli/commands/webview.d.ts +1 -0
- package/dist/types/cli/shared.d.ts +73 -0
- package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -1
- package/dist/types/client/index.d.ts +1 -1
- package/dist/types/client/nodes/ExtAppFrame.d.ts +10 -0
- package/dist/types/client/nodes/HtmlNode.d.ts +1 -1
- package/dist/types/client/nodes/McpAppNode.d.ts +1 -1
- package/dist/types/client/nodes/use-ax-surface-bridge.d.ts +20 -0
- package/dist/types/client/state/node-factory.d.ts +29 -0
- package/dist/types/client/uuid-polyfill.d.ts +0 -0
- package/dist/types/server/canvas-db.d.ts +0 -1
- package/dist/types/server/canvas-operations.d.ts +2 -0
- package/dist/types/server/canvas-state.d.ts +14 -15
- package/dist/types/server/diagram-presets.d.ts +8 -0
- package/dist/types/server/operations/composites.d.ts +6 -30
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/shared/ax-surface-protocol.d.ts +18 -0
- package/docs/bun-webview-integration.md +5 -0
- package/docs/cli.md +5 -0
- package/docs/environment.md +48 -0
- package/docs/http-api.md +25 -1
- package/docs/mcp.md +21 -30
- package/docs/node-types.md +5 -0
- package/docs/plans/plan-009-tech-debt-backlog.md +34 -15
- package/docs/screenshot.png +0 -0
- package/docs/tech-debt-assessment-2026-06.md +5 -5
- package/package.json +13 -10
- package/skills/pmx-canvas/SKILL.md +16 -6
- package/skills/pmx-canvas/evals/evals.json +7 -7
- package/skills/pmx-canvas/references/full-reference.md +25 -16
- package/src/cli/agent.ts +22 -3512
- package/src/cli/commands/apps.ts +165 -0
- package/src/cli/commands/ax.ts +604 -0
- package/src/cli/commands/copilot.ts +43 -0
- package/src/cli/commands/edges.ts +73 -0
- package/src/cli/commands/groups.ts +81 -0
- package/src/cli/commands/history.ts +220 -0
- package/src/cli/commands/nodes.ts +984 -0
- package/src/cli/commands/pins.ts +42 -0
- package/src/cli/commands/query.ts +223 -0
- package/src/cli/commands/view.ts +186 -0
- package/src/cli/commands/webview.ts +227 -0
- package/src/cli/index.ts +8 -2
- package/src/cli/shared.ts +861 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -2
- package/src/client/index.tsx +2 -0
- package/src/client/nodes/ExtAppFrame.tsx +70 -51
- package/src/client/nodes/HtmlNode.tsx +8 -51
- package/src/client/nodes/McpAppNode.tsx +12 -47
- package/src/client/nodes/use-ax-surface-bridge.ts +71 -0
- package/src/client/state/node-factory.ts +72 -0
- package/src/client/state/sse-bridge.ts +209 -86
- package/src/client/uuid-polyfill.ts +14 -0
- package/src/json-render/renderer/index.tsx +3 -2
- package/src/mcp/server.ts +0 -47
- package/src/server/canvas-db.ts +0 -8
- package/src/server/canvas-operations.ts +8 -0
- package/src/server/canvas-schema.ts +2 -8
- package/src/server/canvas-state.ts +51 -151
- package/src/server/diagram-presets.ts +15 -1
- package/src/server/html-surface.ts +11 -6
- package/src/server/index.ts +3 -9
- package/src/server/operations/composites.ts +25 -34
- package/src/server/operations/invoker.ts +10 -3
- package/src/server/operations/mcp.ts +51 -1
- package/src/server/operations/ops/app.ts +5 -0
- package/src/server/operations/ops/snapshots.ts +6 -7
- package/src/server/operations/registry.ts +4 -1
- package/src/server/server.ts +172 -74
- package/src/shared/ax-surface-protocol.ts +22 -0
- package/dist/types/client/canvas/ContextPinHud.d.ts +0 -1
- package/src/client/canvas/ContextPinHud.tsx +0 -17
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Context pin commands: pin.
|
|
2
|
+
|
|
3
|
+
import { cmd, invokeOperation, output, parseFlags, showCommandHelp } from '../shared.js';
|
|
4
|
+
|
|
5
|
+
// ── pin ──────────────────────────────────────────────────────
|
|
6
|
+
cmd(
|
|
7
|
+
'pin',
|
|
8
|
+
'Manage context pins',
|
|
9
|
+
[
|
|
10
|
+
'pmx-canvas pin node1 node2 node3',
|
|
11
|
+
'pmx-canvas pin --set node1 node2 node3',
|
|
12
|
+
'pmx-canvas pin --list',
|
|
13
|
+
'pmx-canvas pin --clear',
|
|
14
|
+
],
|
|
15
|
+
async (args) => {
|
|
16
|
+
const { positional, flags } = parseFlags(args);
|
|
17
|
+
if (flags.help || flags.h) return showCommandHelp('pin');
|
|
18
|
+
|
|
19
|
+
if (flags.list) {
|
|
20
|
+
const result = await invokeOperation('pinned-context.get', {});
|
|
21
|
+
output(result);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (flags.clear) {
|
|
26
|
+
const result = await invokeOperation('pin.set', { nodeIds: [] });
|
|
27
|
+
output(result);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// --set: positional args are node IDs
|
|
32
|
+
if (positional.length > 0 || flags.set) {
|
|
33
|
+
const result = await invokeOperation('pin.set', { nodeIds: positional });
|
|
34
|
+
output(result);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Default: list
|
|
39
|
+
const result = await invokeOperation('pinned-context.get', {});
|
|
40
|
+
output(result);
|
|
41
|
+
},
|
|
42
|
+
);
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Query/analysis commands: search, validate, validate spec, code-graph,
|
|
2
|
+
// spatial, and watch.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildGraphRequestBody,
|
|
6
|
+
buildHtmlPrimitiveRequestBody,
|
|
7
|
+
buildJsonRenderRequestBody,
|
|
8
|
+
cmd,
|
|
9
|
+
die,
|
|
10
|
+
getBaseUrl,
|
|
11
|
+
getStringFlag,
|
|
12
|
+
invokeOperation,
|
|
13
|
+
isRecord,
|
|
14
|
+
optionalNumberFlag,
|
|
15
|
+
output,
|
|
16
|
+
parseFlags,
|
|
17
|
+
showCommandHelp,
|
|
18
|
+
} from '../shared.js';
|
|
19
|
+
import {
|
|
20
|
+
ALL_SEMANTIC_WATCH_EVENT_TYPES,
|
|
21
|
+
formatCompactWatchEvent,
|
|
22
|
+
parseSemanticEventFilter,
|
|
23
|
+
parseSseStream,
|
|
24
|
+
SemanticWatchReducer,
|
|
25
|
+
} from '../watch.js';
|
|
26
|
+
|
|
27
|
+
// ── search ───────────────────────────────────────────────────
|
|
28
|
+
cmd(
|
|
29
|
+
'search',
|
|
30
|
+
'Search nodes by title or content',
|
|
31
|
+
['pmx-canvas search "design doc"', 'pmx-canvas search --query "TODO"'],
|
|
32
|
+
async (args) => {
|
|
33
|
+
const { positional, flags } = parseFlags(args);
|
|
34
|
+
if (flags.help || flags.h) return showCommandHelp('search');
|
|
35
|
+
|
|
36
|
+
const query = positional[0] || (typeof flags.query === 'string' ? flags.query : '');
|
|
37
|
+
if (!query) die('Missing search query', 'pmx-canvas search "query"');
|
|
38
|
+
|
|
39
|
+
const result = await invokeOperation('search', { q: query });
|
|
40
|
+
output(result);
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// ── validate ─────────────────────────────────────────────────
|
|
45
|
+
cmd(
|
|
46
|
+
'validate',
|
|
47
|
+
'Validate the current layout for collisions and missing edge endpoints',
|
|
48
|
+
['pmx-canvas validate'],
|
|
49
|
+
async (args) => {
|
|
50
|
+
const { flags } = parseFlags(args);
|
|
51
|
+
if (flags.help || flags.h) return showCommandHelp('validate');
|
|
52
|
+
|
|
53
|
+
const result = await invokeOperation('validate.get', {});
|
|
54
|
+
output(result);
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
cmd(
|
|
59
|
+
'validate spec',
|
|
60
|
+
'Validate a json-render spec or graph payload without creating a node',
|
|
61
|
+
[
|
|
62
|
+
'pmx-canvas validate spec --type json-render --spec-file ./dashboard.json',
|
|
63
|
+
'pmx-canvas validate spec --type graph --graph-type bar --data-file ./metrics.json --x-key label --y-key value',
|
|
64
|
+
'pmx-canvas validate spec --type html-primitive --kind choice-grid --data-file ./options.json',
|
|
65
|
+
'pmx-canvas validate spec --type json-render --spec-file ./dashboard.json --summary',
|
|
66
|
+
],
|
|
67
|
+
async (args) => {
|
|
68
|
+
const { flags } = parseFlags(args);
|
|
69
|
+
if (flags.help || flags.h) return showCommandHelp('validate spec');
|
|
70
|
+
|
|
71
|
+
const type = getStringFlag(flags, 'type');
|
|
72
|
+
if (type !== 'json-render' && type !== 'graph' && type !== 'html-primitive') {
|
|
73
|
+
die('validate spec requires --type json-render, --type graph, or --type html-primitive.');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let body: Record<string, unknown>;
|
|
77
|
+
if (type === 'json-render') {
|
|
78
|
+
body = {
|
|
79
|
+
type,
|
|
80
|
+
spec: (await buildJsonRenderRequestBody({ ...flags, title: String(flags.title ?? 'Validation') })).spec,
|
|
81
|
+
};
|
|
82
|
+
} else if (type === 'html-primitive') {
|
|
83
|
+
const primitiveBody = await buildHtmlPrimitiveRequestBody(flags);
|
|
84
|
+
body = {
|
|
85
|
+
type,
|
|
86
|
+
kind: primitiveBody.primitive,
|
|
87
|
+
...(typeof primitiveBody.title === 'string' ? { title: primitiveBody.title } : {}),
|
|
88
|
+
...(isRecord(primitiveBody.data) ? { data: primitiveBody.data } : {}),
|
|
89
|
+
};
|
|
90
|
+
} else {
|
|
91
|
+
body = { type, ...(await buildGraphRequestBody(flags)) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const result = (await invokeOperation('spec.validate', body)) as Record<string, unknown>;
|
|
95
|
+
if (flags.summary) {
|
|
96
|
+
output({
|
|
97
|
+
ok: result.ok,
|
|
98
|
+
type: result.type,
|
|
99
|
+
summary: result.summary,
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
output(result);
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// ── code-graph ───────────────────────────────────────────────
|
|
108
|
+
cmd('code-graph', 'Show auto-detected file dependency graph', ['pmx-canvas code-graph'], async (args) => {
|
|
109
|
+
const { flags } = parseFlags(args);
|
|
110
|
+
if (flags.help || flags.h) return showCommandHelp('code-graph');
|
|
111
|
+
|
|
112
|
+
const result = await invokeOperation('code-graph.get', {});
|
|
113
|
+
output(result);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ── spatial ──────────────────────────────────────────────────
|
|
117
|
+
cmd('spatial', 'Spatial analysis: clusters, reading order, neighborhoods', ['pmx-canvas spatial'], async (args) => {
|
|
118
|
+
const { flags } = parseFlags(args);
|
|
119
|
+
if (flags.help || flags.h) return showCommandHelp('spatial');
|
|
120
|
+
|
|
121
|
+
const result = await invokeOperation('spatial.get', {});
|
|
122
|
+
output(result);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ── watch ────────────────────────────────────────────────────
|
|
126
|
+
cmd(
|
|
127
|
+
'watch',
|
|
128
|
+
'Watch low-token semantic canvas changes over the existing SSE stream',
|
|
129
|
+
[
|
|
130
|
+
'pmx-canvas watch',
|
|
131
|
+
'pmx-canvas watch --json',
|
|
132
|
+
'pmx-canvas watch --events context-pin,move-end',
|
|
133
|
+
'pmx-canvas watch --json --events connect --max-events 1',
|
|
134
|
+
],
|
|
135
|
+
async (args) => {
|
|
136
|
+
const { flags } = parseFlags(args);
|
|
137
|
+
if (flags.help || flags.h) return showCommandHelp('watch');
|
|
138
|
+
|
|
139
|
+
if (flags.json && flags.compact) {
|
|
140
|
+
die('Use either --json or --compact, not both.');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const filtersRaw = typeof flags.events === 'string' ? flags.events : undefined;
|
|
144
|
+
const requestedFilters = filtersRaw
|
|
145
|
+
? Array.from(
|
|
146
|
+
new Set(
|
|
147
|
+
filtersRaw
|
|
148
|
+
.split(',')
|
|
149
|
+
.map((value) => value.trim())
|
|
150
|
+
.filter((value) => value.length > 0),
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
: [];
|
|
154
|
+
const invalidFilter = requestedFilters.find(
|
|
155
|
+
(value) => !ALL_SEMANTIC_WATCH_EVENT_TYPES.includes(value as (typeof ALL_SEMANTIC_WATCH_EVENT_TYPES)[number]),
|
|
156
|
+
);
|
|
157
|
+
if (invalidFilter) {
|
|
158
|
+
die(
|
|
159
|
+
`Invalid value in --events: ${invalidFilter}`,
|
|
160
|
+
'Use a comma-separated subset of: context-pin,move-end,group,connect,remove',
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
const filters = parseSemanticEventFilter(filtersRaw);
|
|
164
|
+
if (filtersRaw && filters.size === 0) {
|
|
165
|
+
die(
|
|
166
|
+
`Invalid value for --events: ${filtersRaw}`,
|
|
167
|
+
'Use a comma-separated subset of: context-pin,move-end,group,connect,remove',
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const maxEvents = optionalNumberFlag(flags, 'max-events', 'Use a positive integer, e.g. --max-events 1');
|
|
172
|
+
const jsonMode = Boolean(flags.json);
|
|
173
|
+
const reducer = new SemanticWatchReducer();
|
|
174
|
+
const pinned = (await invokeOperation('pinned-context.get', {})) as { nodeIds?: string[] };
|
|
175
|
+
reducer.setInitialPins(Array.isArray(pinned.nodeIds) ? pinned.nodeIds : []);
|
|
176
|
+
|
|
177
|
+
const base = getBaseUrl();
|
|
178
|
+
const controller = new AbortController();
|
|
179
|
+
let response: Response;
|
|
180
|
+
try {
|
|
181
|
+
response = await fetch(`${base}/api/workbench/events`, {
|
|
182
|
+
method: 'GET',
|
|
183
|
+
headers: { Accept: 'text/event-stream' },
|
|
184
|
+
signal: controller.signal,
|
|
185
|
+
});
|
|
186
|
+
} catch (error) {
|
|
187
|
+
die(
|
|
188
|
+
`Cannot connect to pmx-canvas event stream at ${base}: ${error instanceof Error ? error.message : String(error)}`,
|
|
189
|
+
'Start the server first: pmx-canvas --no-open',
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!response.ok) {
|
|
194
|
+
const text = await response.text();
|
|
195
|
+
die(`Failed to open event stream: HTTP ${response.status}`, text);
|
|
196
|
+
}
|
|
197
|
+
if (!response.body) {
|
|
198
|
+
die('Workbench event stream did not return a readable body.');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let emitted = 0;
|
|
202
|
+
try {
|
|
203
|
+
for await (const message of parseSseStream(response.body)) {
|
|
204
|
+
const semanticEvents = reducer.handleMessage(message).filter((event) => filters.has(event.type));
|
|
205
|
+
|
|
206
|
+
for (const event of semanticEvents) {
|
|
207
|
+
console.log(jsonMode ? JSON.stringify(event) : formatCompactWatchEvent(event));
|
|
208
|
+
emitted++;
|
|
209
|
+
if (maxEvents !== undefined && emitted >= maxEvents) {
|
|
210
|
+
controller.abort();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (controller.signal.aborted) return;
|
|
217
|
+
die(
|
|
218
|
+
`Watch stream failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
219
|
+
'Ensure the server is still running and reachable.',
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
);
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Viewport/canvas commands: open, layout, status, arrange, focus, fit, clear,
|
|
2
|
+
// and the serve usage stub.
|
|
3
|
+
|
|
4
|
+
import { openUrlInExternalBrowser } from '../../server/server.js';
|
|
5
|
+
import {
|
|
6
|
+
cmd,
|
|
7
|
+
die,
|
|
8
|
+
getBaseUrl,
|
|
9
|
+
invokeOperation,
|
|
10
|
+
isRecord,
|
|
11
|
+
optionalPositiveFiniteFlag,
|
|
12
|
+
output,
|
|
13
|
+
parseFlags,
|
|
14
|
+
showCommandHelp,
|
|
15
|
+
} from '../shared.js';
|
|
16
|
+
|
|
17
|
+
cmd('open', 'Open the current workbench in the browser', ['pmx-canvas open'], async (args) => {
|
|
18
|
+
const { flags } = parseFlags(args);
|
|
19
|
+
if (flags.help || flags.h) return showCommandHelp('open');
|
|
20
|
+
|
|
21
|
+
const base = getBaseUrl();
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetch(`${base}/health`);
|
|
24
|
+
if (!response.ok) {
|
|
25
|
+
die(`Cannot reach pmx-canvas health endpoint at ${base}: HTTP ${response.status}`);
|
|
26
|
+
}
|
|
27
|
+
} catch (error) {
|
|
28
|
+
die(
|
|
29
|
+
`Cannot connect to pmx-canvas at ${base}: ${error instanceof Error ? error.message : String(error)}`,
|
|
30
|
+
'Start the server first: pmx-canvas --no-open',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const url = `${base}/workbench`;
|
|
35
|
+
if (!openUrlInExternalBrowser(url)) {
|
|
36
|
+
die(`Failed to open browser for ${url}`);
|
|
37
|
+
}
|
|
38
|
+
output({ ok: true, url });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ── layout ───────────────────────────────────────────────────
|
|
42
|
+
cmd(
|
|
43
|
+
'layout',
|
|
44
|
+
'Get the full canvas layout (nodes, edges, viewport)',
|
|
45
|
+
['pmx-canvas layout', 'pmx-canvas layout --summary'],
|
|
46
|
+
async (args) => {
|
|
47
|
+
const { flags } = parseFlags(args);
|
|
48
|
+
if (flags.help || flags.h) return showCommandHelp('layout');
|
|
49
|
+
|
|
50
|
+
if (flags.summary || flags.compact) {
|
|
51
|
+
output(await invokeOperation('summary.get', {}));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const result = await invokeOperation('layout.get', {});
|
|
55
|
+
output(result);
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
// ── status ───────────────────────────────────────────────────
|
|
60
|
+
cmd('status', 'Quick canvas summary', ['pmx-canvas status'], async (args) => {
|
|
61
|
+
const { flags } = parseFlags(args);
|
|
62
|
+
if (flags.help || flags.h) return showCommandHelp('status');
|
|
63
|
+
|
|
64
|
+
const layout = (await invokeOperation('layout.get', {})) as {
|
|
65
|
+
nodes: Array<Record<string, unknown>>;
|
|
66
|
+
edges: unknown[];
|
|
67
|
+
viewport: unknown;
|
|
68
|
+
};
|
|
69
|
+
const pinned = (await invokeOperation('pinned-context.get', {})) as { count: number; nodeIds: string[] };
|
|
70
|
+
|
|
71
|
+
const typeCounts: Record<string, number> = {};
|
|
72
|
+
for (const n of layout.nodes) {
|
|
73
|
+
const data = isRecord(n.data) ? n.data : {};
|
|
74
|
+
const t =
|
|
75
|
+
typeof n.kind === 'string'
|
|
76
|
+
? n.kind
|
|
77
|
+
: n.type === 'mcp-app' && data.hostMode === 'hosted' && typeof data.path === 'string'
|
|
78
|
+
? 'web-artifact'
|
|
79
|
+
: (n.type as string);
|
|
80
|
+
typeCounts[t] = (typeCounts[t] || 0) + 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
output({
|
|
84
|
+
nodes: layout.nodes.length,
|
|
85
|
+
edges: layout.edges.length,
|
|
86
|
+
pinned: pinned.count,
|
|
87
|
+
types: typeCounts,
|
|
88
|
+
viewport: layout.viewport,
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ── arrange ──────────────────────────────────────────────────
|
|
93
|
+
cmd(
|
|
94
|
+
'arrange',
|
|
95
|
+
'Auto-arrange nodes on the canvas',
|
|
96
|
+
['pmx-canvas arrange', 'pmx-canvas arrange --layout column', 'pmx-canvas arrange --layout flow'],
|
|
97
|
+
async (args) => {
|
|
98
|
+
const { flags } = parseFlags(args);
|
|
99
|
+
if (flags.help || flags.h) return showCommandHelp('arrange');
|
|
100
|
+
|
|
101
|
+
const body: Record<string, unknown> = {};
|
|
102
|
+
if (flags.layout && flags.layout !== true) body.layout = flags.layout;
|
|
103
|
+
|
|
104
|
+
const result = await invokeOperation('arrange', body);
|
|
105
|
+
output(result);
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// ── focus ────────────────────────────────────────────────────
|
|
110
|
+
cmd('focus', 'Pan viewport to center on a node', ['pmx-canvas focus <node-id>'], async (args) => {
|
|
111
|
+
const { positional, flags } = parseFlags(args);
|
|
112
|
+
if (flags.help || flags.h) return showCommandHelp('focus');
|
|
113
|
+
|
|
114
|
+
const id = positional[0];
|
|
115
|
+
if (!id) die('Missing node ID', 'pmx-canvas focus <node-id>');
|
|
116
|
+
|
|
117
|
+
const result = await invokeOperation('node.focus', { id, ...(flags['no-pan'] ? { noPan: true } : {}) });
|
|
118
|
+
output(result);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
cmd(
|
|
122
|
+
'fit',
|
|
123
|
+
'Fit the viewport to all nodes or a selected subset',
|
|
124
|
+
['pmx-canvas fit', 'pmx-canvas fit --width 1440 --height 900 --padding 80', 'pmx-canvas fit node-a node-b'],
|
|
125
|
+
async (args) => {
|
|
126
|
+
const { positional, flags } = parseFlags(args);
|
|
127
|
+
if (flags.help || flags.h) return showCommandHelp('fit');
|
|
128
|
+
|
|
129
|
+
const body: Record<string, unknown> = {};
|
|
130
|
+
const width = optionalPositiveFiniteFlag(flags, 'width', 'Use a positive number, e.g. --width 1440');
|
|
131
|
+
const height = optionalPositiveFiniteFlag(flags, 'height', 'Use a positive number, e.g. --height 900');
|
|
132
|
+
const padding = optionalPositiveFiniteFlag(flags, 'padding', 'Use a positive number, e.g. --padding 80');
|
|
133
|
+
const maxScale = optionalPositiveFiniteFlag(flags, 'max-scale', 'Use a positive number, e.g. --max-scale 1');
|
|
134
|
+
if (width !== undefined) body.width = width;
|
|
135
|
+
if (height !== undefined) body.height = height;
|
|
136
|
+
if (padding !== undefined) body.padding = padding;
|
|
137
|
+
if (maxScale !== undefined) body.maxScale = maxScale;
|
|
138
|
+
if (positional.length > 0) body.nodeIds = positional;
|
|
139
|
+
|
|
140
|
+
const result = await invokeOperation('view.fit', body);
|
|
141
|
+
output(result);
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// ── clear ────────────────────────────────────────────────────
|
|
146
|
+
cmd(
|
|
147
|
+
'clear',
|
|
148
|
+
'Remove all nodes and edges from the canvas',
|
|
149
|
+
['pmx-canvas clear --yes', 'pmx-canvas clear --dry-run'],
|
|
150
|
+
async (args) => {
|
|
151
|
+
const { flags } = parseFlags(args);
|
|
152
|
+
if (flags.help || flags.h) return showCommandHelp('clear');
|
|
153
|
+
|
|
154
|
+
if (flags['dry-run']) {
|
|
155
|
+
const layout = (await invokeOperation('layout.get', {})) as { nodes: unknown[]; edges: unknown[] };
|
|
156
|
+
output({
|
|
157
|
+
dry_run: true,
|
|
158
|
+
would_remove: { nodes: layout.nodes.length, edges: layout.edges.length },
|
|
159
|
+
message: 'No changes made. Pass --yes to confirm.',
|
|
160
|
+
});
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!flags.yes) {
|
|
165
|
+
die('Destructive operation requires --yes flag', 'pmx-canvas clear --yes (or preview with --dry-run)');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const result = await invokeOperation('canvas.clear', {});
|
|
169
|
+
output(result);
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// ── serve (delegates back to original CLI) ───────────────────
|
|
174
|
+
cmd(
|
|
175
|
+
'serve',
|
|
176
|
+
'Start the canvas server',
|
|
177
|
+
[
|
|
178
|
+
'pmx-canvas serve',
|
|
179
|
+
'pmx-canvas serve --port=8080 --no-open',
|
|
180
|
+
'pmx-canvas serve --demo --theme=light',
|
|
181
|
+
'pmx-canvas --no-open --webview-automation',
|
|
182
|
+
],
|
|
183
|
+
async (_args) => {
|
|
184
|
+
console.log('Use: pmx-canvas [--port=PORT] [--demo] [--no-open] [--theme=THEME] [--webview-automation]');
|
|
185
|
+
},
|
|
186
|
+
);
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// WebView automation commands: webview status|start|stop|evaluate|resize|
|
|
2
|
+
// screenshot and the top-level screenshot alias.
|
|
3
|
+
|
|
4
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { wrapCanvasAutomationScript } from '../../server/server.js';
|
|
6
|
+
import {
|
|
7
|
+
cmd,
|
|
8
|
+
COMMANDS,
|
|
9
|
+
die,
|
|
10
|
+
getBaseUrl,
|
|
11
|
+
invokeOperation,
|
|
12
|
+
optionalNumberFlag,
|
|
13
|
+
output,
|
|
14
|
+
parseFlags,
|
|
15
|
+
requireFlag,
|
|
16
|
+
showCommandHelp,
|
|
17
|
+
} from '../shared.js';
|
|
18
|
+
|
|
19
|
+
// ── webview status ────────────────────────────────────────────
|
|
20
|
+
cmd('webview status', 'Show Bun.WebView automation status', ['pmx-canvas webview status'], async (args) => {
|
|
21
|
+
const { flags } = parseFlags(args);
|
|
22
|
+
if (flags.help || flags.h) return showCommandHelp('webview status');
|
|
23
|
+
|
|
24
|
+
const result = await invokeOperation('webview.status', {});
|
|
25
|
+
output(result);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// ── webview start ─────────────────────────────────────────────
|
|
29
|
+
cmd(
|
|
30
|
+
'webview start',
|
|
31
|
+
'Start or replace the Bun.WebView automation session',
|
|
32
|
+
[
|
|
33
|
+
'pmx-canvas webview start',
|
|
34
|
+
'pmx-canvas webview start --backend chrome --width 1440 --height 900',
|
|
35
|
+
'pmx-canvas webview start --chrome-path /Applications/Google\\ Chrome.app/.../Google\\ Chrome',
|
|
36
|
+
],
|
|
37
|
+
async (args) => {
|
|
38
|
+
const { flags } = parseFlags(args);
|
|
39
|
+
if (flags.help || flags.h) return showCommandHelp('webview start');
|
|
40
|
+
|
|
41
|
+
const backend = flags.backend;
|
|
42
|
+
if (backend && backend !== true && backend !== 'chrome' && backend !== 'webkit') {
|
|
43
|
+
die('Invalid value for --backend', 'Use: --backend chrome or --backend webkit');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const body: Record<string, unknown> = {};
|
|
47
|
+
if (backend && backend !== true) body.backend = backend;
|
|
48
|
+
|
|
49
|
+
const width = optionalNumberFlag(flags, 'width', 'Use a positive integer width, e.g. --width 1440');
|
|
50
|
+
const height = optionalNumberFlag(flags, 'height', 'Use a positive integer height, e.g. --height 900');
|
|
51
|
+
if (width !== undefined) body.width = width;
|
|
52
|
+
if (height !== undefined) body.height = height;
|
|
53
|
+
|
|
54
|
+
if (flags['chrome-path'] && flags['chrome-path'] !== true) {
|
|
55
|
+
body.chromePath = flags['chrome-path'];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (flags['data-dir'] && flags['data-dir'] !== true) {
|
|
59
|
+
body.dataStoreDir = flags['data-dir'];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (flags['chrome-argv'] && flags['chrome-argv'] !== true) {
|
|
63
|
+
const chromeArgv = String(flags['chrome-argv'])
|
|
64
|
+
.split(',')
|
|
65
|
+
.map((value) => value.trim())
|
|
66
|
+
.filter((value) => value.length > 0);
|
|
67
|
+
if (chromeArgv.length > 0) body.chromeArgv = chromeArgv;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// webview.start declares errorBodyAsResult, so failure envelopes
|
|
71
|
+
// ({ ok:false, error, webview? }) are returned and printed like before.
|
|
72
|
+
const result = await invokeOperation('webview.start', body);
|
|
73
|
+
output(result);
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// ── webview stop ──────────────────────────────────────────────
|
|
78
|
+
cmd('webview stop', 'Stop the active Bun.WebView automation session', ['pmx-canvas webview stop'], async (args) => {
|
|
79
|
+
const { flags } = parseFlags(args);
|
|
80
|
+
if (flags.help || flags.h) return showCommandHelp('webview stop');
|
|
81
|
+
|
|
82
|
+
const result = await invokeOperation('webview.stop', {});
|
|
83
|
+
output(result);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ── webview evaluate ──────────────────────────────────────────
|
|
87
|
+
cmd(
|
|
88
|
+
'webview evaluate',
|
|
89
|
+
'Evaluate JavaScript in the active Bun.WebView automation session',
|
|
90
|
+
[
|
|
91
|
+
'pmx-canvas webview evaluate --expression "document.title"',
|
|
92
|
+
'pmx-canvas webview evaluate --script "const title = document.title; return title.toUpperCase()"',
|
|
93
|
+
'pmx-canvas webview evaluate --file ./probe.js',
|
|
94
|
+
],
|
|
95
|
+
async (args) => {
|
|
96
|
+
const { flags } = parseFlags(args);
|
|
97
|
+
if (flags.help || flags.h) return showCommandHelp('webview evaluate');
|
|
98
|
+
|
|
99
|
+
const sourceCount = [flags.expression, flags.script, flags.file].filter(Boolean).length;
|
|
100
|
+
if (sourceCount > 1) {
|
|
101
|
+
die(
|
|
102
|
+
'Use only one of --expression, --script, or --file.',
|
|
103
|
+
'pmx-canvas webview evaluate --expression "document.title"',
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let expression = '';
|
|
108
|
+
if (typeof flags.file === 'string') {
|
|
109
|
+
let script = '';
|
|
110
|
+
try {
|
|
111
|
+
script = readFileSync(flags.file, 'utf-8');
|
|
112
|
+
} catch (error) {
|
|
113
|
+
die(
|
|
114
|
+
`Unable to read --file: ${error instanceof Error ? error.message : String(error)}`,
|
|
115
|
+
'pmx-canvas webview evaluate --file ./probe.js',
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
expression = wrapCanvasAutomationScript(script);
|
|
119
|
+
} else if (typeof flags.script === 'string') {
|
|
120
|
+
expression = wrapCanvasAutomationScript(flags.script);
|
|
121
|
+
} else {
|
|
122
|
+
expression = requireFlag(flags, 'expression', 'pmx-canvas webview evaluate --expression "document.title"');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Send ONLY expression — the CLI already wraps --script/--file into an
|
|
126
|
+
// expression; passing the op's `script` input would double-wrap server-side.
|
|
127
|
+
const result = await invokeOperation('webview.evaluate', { expression });
|
|
128
|
+
output(result);
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// ── webview resize ────────────────────────────────────────────
|
|
133
|
+
cmd(
|
|
134
|
+
'webview resize',
|
|
135
|
+
'Resize the active Bun.WebView automation session viewport',
|
|
136
|
+
['pmx-canvas webview resize --width 1280 --height 800'],
|
|
137
|
+
async (args) => {
|
|
138
|
+
const { flags } = parseFlags(args);
|
|
139
|
+
if (flags.help || flags.h) return showCommandHelp('webview resize');
|
|
140
|
+
|
|
141
|
+
const width = optionalNumberFlag(flags, 'width', 'Use: pmx-canvas webview resize --width 1280 --height 800');
|
|
142
|
+
const height = optionalNumberFlag(flags, 'height', 'Use: pmx-canvas webview resize --width 1280 --height 800');
|
|
143
|
+
if (width === undefined || height === undefined) {
|
|
144
|
+
die('Missing required flags: --width, --height', 'Use: pmx-canvas webview resize --width 1280 --height 800');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const result = await invokeOperation('webview.resize', { width, height });
|
|
148
|
+
output(result);
|
|
149
|
+
},
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// ── webview screenshot ────────────────────────────────────────
|
|
153
|
+
cmd(
|
|
154
|
+
'webview screenshot',
|
|
155
|
+
'Capture a screenshot from the active Bun.WebView automation session',
|
|
156
|
+
[
|
|
157
|
+
'pmx-canvas webview screenshot --output ./canvas.png',
|
|
158
|
+
'pmx-canvas webview screenshot --output ./canvas.webp --format webp --quality 80',
|
|
159
|
+
],
|
|
160
|
+
async (args) => {
|
|
161
|
+
const { flags } = parseFlags(args);
|
|
162
|
+
if (flags.help || flags.h) return showCommandHelp('webview screenshot');
|
|
163
|
+
|
|
164
|
+
const outputPath = requireFlag(flags, 'output', 'pmx-canvas webview screenshot --output ./canvas.png');
|
|
165
|
+
|
|
166
|
+
const body: Record<string, unknown> = {};
|
|
167
|
+
if (flags.format && flags.format !== true) {
|
|
168
|
+
const format = String(flags.format);
|
|
169
|
+
if (format !== 'png' && format !== 'jpeg' && format !== 'webp') {
|
|
170
|
+
die('Invalid value for --format', 'Use: --format png, jpeg, or webp');
|
|
171
|
+
}
|
|
172
|
+
body.format = format;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (flags.quality && flags.quality !== true) {
|
|
176
|
+
const quality = Number(flags.quality);
|
|
177
|
+
if (!Number.isFinite(quality)) {
|
|
178
|
+
die(`Invalid value for --quality: ${String(flags.quality)}`, 'Use a numeric quality, e.g. --quality 80');
|
|
179
|
+
}
|
|
180
|
+
body.quality = quality;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const base = getBaseUrl();
|
|
184
|
+
const response = await fetch(`${base}/api/workbench/webview/screenshot`, {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers: { 'Content-Type': 'application/json' },
|
|
187
|
+
body: JSON.stringify(body),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
if (!response.ok) {
|
|
191
|
+
const text = await response.text();
|
|
192
|
+
try {
|
|
193
|
+
const json = JSON.parse(text) as Record<string, unknown>;
|
|
194
|
+
die(
|
|
195
|
+
json.error ? String(json.error) : `HTTP ${response.status}`,
|
|
196
|
+
typeof json.hint === 'string' ? json.hint : undefined,
|
|
197
|
+
);
|
|
198
|
+
} catch {
|
|
199
|
+
die(`HTTP ${response.status}: ${text}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
204
|
+
writeFileSync(outputPath, bytes);
|
|
205
|
+
output({
|
|
206
|
+
ok: true,
|
|
207
|
+
output: outputPath,
|
|
208
|
+
bytes: bytes.byteLength,
|
|
209
|
+
mimeType: response.headers.get('Content-Type') ?? 'application/octet-stream',
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
cmd(
|
|
215
|
+
'screenshot',
|
|
216
|
+
'Capture a screenshot from the active Bun.WebView automation session',
|
|
217
|
+
[
|
|
218
|
+
'pmx-canvas screenshot --output ./canvas.png',
|
|
219
|
+
'pmx-canvas screenshot --output ./canvas.webp --format webp --quality 80',
|
|
220
|
+
],
|
|
221
|
+
async (args) => {
|
|
222
|
+
if (args.includes('--help') || args.includes('-h')) return showCommandHelp('screenshot');
|
|
223
|
+
const screenshotCommand = COMMANDS['webview screenshot'];
|
|
224
|
+
if (!screenshotCommand) die('Internal error: webview screenshot command is unavailable.');
|
|
225
|
+
await screenshotCommand.run(args);
|
|
226
|
+
},
|
|
227
|
+
);
|