agent-canvas 0.1.5 → 0.1.7

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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +71 -0
  3. package/bin/agent-canvas.ts +26 -353
  4. package/bin/lib/commands/daemon.ts +51 -0
  5. package/bin/lib/commands/fetch.ts +20 -0
  6. package/bin/lib/commands/install.ts +48 -0
  7. package/bin/lib/commands/push.ts +66 -0
  8. package/bin/lib/commands/watch.ts +23 -0
  9. package/bin/lib/config.ts +10 -0
  10. package/bin/lib/daemon-lifecycle.ts +74 -0
  11. package/bin/lib/helpers.ts +78 -0
  12. package/daemon/build.ts +27 -61
  13. package/daemon/client/AnnotationProvider.tsx +2 -2
  14. package/daemon/client/AnnotationSidebar.tsx +199 -31
  15. package/daemon/client/App.tsx +4 -3
  16. package/daemon/client/FileBrowser.tsx +8 -7
  17. package/daemon/client/FileViewer.tsx +153 -16
  18. package/daemon/client/PlanRenderer.tsx +6 -3
  19. package/daemon/client/ResponsePreview.tsx +5 -116
  20. package/daemon/client/SessionSwitcher.tsx +21 -10
  21. package/daemon/client/compat/preact-all.ts +4 -0
  22. package/daemon/client/components/Markdown.tsx +44 -0
  23. package/daemon/client/components/ResponseNote.tsx +3 -8
  24. package/daemon/client/components/UserInput.tsx +2 -4
  25. package/daemon/client/components/index.ts +1 -0
  26. package/daemon/client/domDiff.ts +82 -2
  27. package/daemon/client/generateMarkdown.ts +46 -9
  28. package/daemon/client/highlightRange.ts +8 -6
  29. package/daemon/client/runtime.ts +2 -0
  30. package/daemon/client/styles.css +126 -0
  31. package/daemon/client/unifiedDiff.ts +155 -2
  32. package/daemon/client/utils.ts +10 -0
  33. package/daemon/dist/client.css +1 -1
  34. package/daemon/dist/client.js +83 -36
  35. package/daemon/dist/components.js +63 -5
  36. package/daemon/dist/index.html +5 -12
  37. package/daemon/dist/jsx-runtime.js +3 -0
  38. package/daemon/dist/preact-compat.js +1 -0
  39. package/daemon/src/compiler.ts +28 -2
  40. package/daemon/src/handlers/api.ts +150 -0
  41. package/daemon/src/handlers/files.ts +114 -0
  42. package/daemon/src/handlers/static.ts +61 -0
  43. package/daemon/src/handlers/utils.ts +14 -0
  44. package/daemon/src/router.ts +16 -0
  45. package/daemon/src/server.ts +28 -403
  46. package/daemon/src/session.ts +41 -23
  47. package/daemon/src/websocket.ts +83 -0
  48. package/package.json +3 -3
  49. package/skills/canvas/SKILL.md +32 -22
  50. package/skills/canvas/components.md +31 -0
  51. package/skills/canvas/flows.md +2 -2
  52. package/daemon/dist/jsx-dev-runtime-shim.js +0 -17
  53. package/daemon/dist/jsx-runtime-shim.js +0 -17
  54. package/daemon/dist/react-dom-client-shim.js +0 -4
  55. package/daemon/dist/react-dom-shim.js +0 -4
  56. package/daemon/dist/react-shim.js +0 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Contember Limited
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # Agent Canvas
2
+
3
+ Interactive browser-based visual canvas for [Claude Code](https://docs.anthropic.com/en/docs/claude-code). Create rich, annotatable JSX documents that render in the browser — with revision history, inline annotations, and structured feedback that flows back to Claude.
4
+
5
+ ![License](https://img.shields.io/npm/l/agent-canvas)
6
+ ![npm](https://img.shields.io/npm/v/agent-canvas)
7
+
8
+ ## What it does
9
+
10
+ When Claude needs to present a plan, review, explanation, or decision — instead of dumping markdown in the terminal — it renders a full interactive canvas in your browser. You can:
11
+
12
+ - **Annotate** any element with inline comments
13
+ - **Choose options**, fill inputs, rate with sliders
14
+ - **Browse files** referenced in the canvas
15
+ - **Review revisions** as Claude iterates
16
+ - **Send structured feedback** back to Claude in one click
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ # Register the skill and hooks in Claude Code
22
+ bunx agent-canvas install global # for all projects
23
+ bunx agent-canvas install local # for current project only
24
+ ```
25
+
26
+ This installs:
27
+ - The `/canvas` skill so Claude knows how to use it
28
+ - A `SessionStart` hook that sets up session IDs automatically
29
+
30
+ **Requires [Bun](https://bun.sh/) runtime.**
31
+
32
+ ## Usage in Claude Code
33
+
34
+ Once installed, use the `/canvas` command to create a canvas:
35
+
36
+ ```
37
+ > /canvas Plan the authentication refactor
38
+ > /canvas Show me an architecture overview
39
+ > /canvas Review this PR
40
+ ```
41
+
42
+ Claude creates a JSX document, pushes it to a local daemon, and opens it in your browser at `http://localhost:19400/s/<session-id>`.
43
+
44
+ ### Flow patterns
45
+
46
+ Claude uses different canvas flows depending on the task:
47
+
48
+ | Flow | When | Rounds |
49
+ |------|------|--------|
50
+ | **Feature** | Vague requests ("add auth") | Discovery → Requirements → Plan → Implementation → Summary |
51
+ | **Plan** | Clear requirements | Plan → Implementation → Summary |
52
+ | **Explain** | "How does X work?" | Single canvas |
53
+ | **Review** | Code/architecture review | Findings → Fix Plan → Summary |
54
+ | **Decision** | Choosing between options | Single canvas with comparison |
55
+
56
+ ## Development
57
+
58
+ ```bash
59
+ bun install
60
+ bun run build # build client assets
61
+ bun daemon/build.ts --watch # watch mode
62
+
63
+ # Test with demo canvas
64
+ bun bin/agent-canvas.ts daemon start
65
+ CANVAS_SESSION_ID=demo bun bin/agent-canvas.ts push example/plan.jsx --label "Demo"
66
+ # Open http://localhost:19400/s/demo
67
+ ```
68
+
69
+ ## License
70
+
71
+ MIT
@@ -1,377 +1,50 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { spawn } from "child_process";
4
- import { readFileSync, existsSync, writeFileSync, mkdirSync, unlinkSync, cpSync } from "fs";
5
- import { join, resolve, dirname } from "path";
6
- import { homedir, tmpdir } from "os";
7
- import { randomUUID } from "crypto";
8
-
9
- const PACKAGE_ROOT = resolve(join(dirname(import.meta.path), ".."));
10
- const DATA_DIR = join(homedir(), ".claude", "agent-canvas");
11
- const TEMP_DIR = join(tmpdir(), "agent-canvas");
12
- const DAEMON_PORT = parseInt(process.env.CANVAS_PORT || "19400", 10);
13
- const BASE_URL = `http://localhost:${DAEMON_PORT}`;
14
- const WS_URL = `ws://localhost:${DAEMON_PORT}`;
15
- const TIMEOUT_MS = parseInt(process.env.CANVAS_TIMEOUT || String(60 * 60 * 1000), 10);
16
- const PID_FILE = join(TEMP_DIR, "daemon.pid");
17
-
18
- function getSessionId(args?: string[]): string {
19
- // Check --session flag first
20
- if (args) {
21
- const idx = args.indexOf("--session");
22
- if (idx !== -1 && args[idx + 1]) {
23
- return args[idx + 1];
24
- }
25
- }
26
- return process.env.CANVAS_SESSION_ID || (() => {
27
- const id = randomUUID();
28
- console.error(`Warning: CANVAS_SESSION_ID not set, using generated ID: ${id}`);
29
- return id;
30
- })();
31
- }
32
-
33
- async function main() {
34
- const args = process.argv.slice(2);
35
-
36
- if (args.length === 0) {
37
- printUsage();
38
- process.exit(1);
39
- }
40
-
41
- const command = args[0];
42
-
43
- switch (command) {
44
- case "push": return handlePush(args.slice(1));
45
- case "wait": return handleWait(args.slice(1));
46
- case "install": return handleInstall(args.slice(1));
47
- case "daemon": return handleDaemon(args.slice(1));
48
- default:
49
- console.error(`Unknown command: ${command}`);
50
- printUsage();
51
- process.exit(1);
52
- }
53
- }
3
+ import { handlePush } from "./lib/commands/push.ts";
4
+ import { handleFetch } from "./lib/commands/fetch.ts";
5
+ import { handleWatch } from "./lib/commands/watch.ts";
6
+ import { handleDaemon } from "./lib/commands/daemon.ts";
7
+ import { handleInstall } from "./lib/commands/install.ts";
54
8
 
55
9
  function printUsage() {
56
10
  console.error(`agent-canvas — Interactive visual canvas for Claude Code
57
11
 
58
12
  Commands:
59
- agent-canvas install [local|global] Install skill & hooks for Claude Code
13
+ agent-canvas install [local|global] Install skill for Claude Code
60
14
  agent-canvas push <file.jsx> Push a canvas, open browser
61
- agent-canvas wait [--session <id>] Wait for user feedback (prints to stdout)
15
+ agent-canvas fetch [--session <id>] Check for feedback (returns immediately)
16
+ agent-canvas watch [--session <id>] Wait for user feedback (blocks until submitted)
62
17
  agent-canvas daemon status Show daemon status
63
18
  agent-canvas daemon stop Stop the daemon
19
+ agent-canvas daemon start Start the daemon
20
+ agent-canvas daemon restart Restart the daemon
64
21
 
65
22
  Environment:
66
- CANVAS_SESSION_ID Session ID (set automatically by hooks)
67
- CANVAS_PROJECT_ROOT Project root (set automatically by hooks)
68
23
  CANVAS_PORT Daemon port (default: 19400)`);
69
24
  }
70
25
 
71
- // ── install ──
72
-
73
- async function handleInstall(args: string[]) {
74
- let mode = args[0] as "local" | "global" | undefined;
75
-
76
- if (!mode) {
77
- // Interactive prompt
78
- process.stdout.write("Install canvas skill and hooks for Claude Code.\n\n");
79
- process.stdout.write(" local — install to .claude/ in current project\n");
80
- process.stdout.write(" global — install to ~/.claude/ for all projects\n\n");
81
- process.stdout.write("Choose [local/global]: ");
82
-
83
- const input = await readLine();
84
- mode = input.trim().toLowerCase() as "local" | "global";
85
- }
86
-
87
- if (mode !== "local" && mode !== "global") {
88
- console.error("Error: specify 'local' or 'global'");
89
- process.exit(1);
90
- }
91
-
92
- const targetBase = mode === "global"
93
- ? join(homedir(), ".claude")
94
- : join(process.cwd(), ".claude");
95
-
96
- // Install skill
97
- const skillTarget = join(targetBase, "skills", "canvas");
98
- mkdirSync(skillTarget, { recursive: true });
99
-
100
- const skillSrc = join(PACKAGE_ROOT, "skills", "canvas");
101
- for (const file of ["SKILL.md", "components.md", "flows.md"]) {
102
- const src = join(skillSrc, file);
103
- if (existsSync(src)) {
104
- cpSync(src, join(skillTarget, file));
105
- }
106
- }
107
-
108
- console.log(` Skill installed to ${skillTarget}`);
109
-
110
- // Install hook script
111
- const hooksDir = join(targetBase, "agent-canvas");
112
- mkdirSync(hooksDir, { recursive: true });
113
- const hookSrc = join(PACKAGE_ROOT, "hooks", "session-start.sh");
114
- const hookDest = join(hooksDir, "session-start.sh");
115
- cpSync(hookSrc, hookDest);
116
- try { require("fs").chmodSync(hookDest, 0o755); } catch {}
117
- console.log(` Hook script installed to ${hookDest}`);
118
-
119
- // Register hook in settings.json
120
- const settingsPath = join(targetBase, "settings.json");
121
- let settings: any = {};
122
- if (existsSync(settingsPath)) {
123
- try { settings = JSON.parse(readFileSync(settingsPath, "utf-8")); } catch {}
124
- }
125
-
126
- if (!settings.hooks) settings.hooks = {};
127
- if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
128
-
129
- const hasHook = settings.hooks.SessionStart.some((h: any) =>
130
- h.hooks?.some((hh: any) => hh.command?.includes("CANVAS_SESSION_ID"))
131
- );
132
-
133
- if (!hasHook) {
134
- settings.hooks.SessionStart.push({
135
- matcher: "*",
136
- hooks: [{
137
- type: "command",
138
- command: `bash "${hookDest}"`,
139
- }],
140
- });
141
-
142
- mkdirSync(dirname(settingsPath), { recursive: true });
143
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
144
- console.log(` Hook registered in ${settingsPath}`);
145
- } else {
146
- console.log(` Hook already registered in ${settingsPath}`);
147
- }
148
-
149
- console.log(`\nInstalled! The /canvas command is now available in Claude Code.`);
150
- }
151
-
152
- // ── push ──
153
-
154
- async function handlePush(args: string[]) {
155
- const fromHook = args.includes("--from-hook");
156
- let jsx: string;
157
- let filePath: string | undefined;
158
-
159
- if (fromHook) {
160
- jsx = await readStdin();
161
- } else {
162
- filePath = args.find((a) => !a.startsWith("--"));
163
- if (!filePath) {
164
- console.error("Error: No file specified. Usage: agent-canvas push <file.jsx>");
165
- process.exit(1);
166
- }
167
- filePath = resolve(filePath);
168
- if (!existsSync(filePath)) {
169
- console.error(`Error: File not found: ${filePath}`);
170
- process.exit(1);
171
- }
172
- jsx = readFileSync(filePath, "utf-8");
173
- }
174
-
175
- const sessionId = getSessionId(args);
176
- const projectRoot = process.env.CANVAS_PROJECT_ROOT || process.cwd();
177
-
178
- // Extract --label flag
179
- const labelIdx = args.indexOf("--label");
180
- const label = labelIdx !== -1 && args[labelIdx + 1] ? args[labelIdx + 1] : undefined;
181
- const autoLabel = !label && filePath ? filePath.split("/").pop()?.replace(/\.jsx$/, "") : undefined;
182
-
183
- await ensureDaemon();
184
-
185
- const response = await fetch(`${BASE_URL}/api/session/${sessionId}/plan`, {
186
- method: "POST",
187
- headers: { "Content-Type": "application/json" },
188
- body: JSON.stringify({ jsx, projectRoot, label: label || autoLabel, sourceFile: filePath ? filePath.split("/").pop() : undefined }),
189
- });
190
-
191
- const result = await response.json() as any;
192
-
193
- if (!result.ok) {
194
- console.error(`Compilation error:\n${result.error}`);
195
- process.exit(1);
196
- }
197
-
198
- if (result.isNew) {
199
- openBrowser(result.browserUrl);
200
- }
201
-
202
- console.log(JSON.stringify({
203
- ok: true,
204
- browserUrl: result.browserUrl,
205
- revision: result.revision,
206
- sessionId,
207
- }));
208
- }
209
-
210
- // ── wait ──
211
-
212
- async function handleWait(args: string[]) {
213
- const sessionId = getSessionId(args);
214
-
215
- await ensureDaemon();
216
-
217
- // Check if feedback already exists
218
- try {
219
- const metaRes = await fetch(`${BASE_URL}/api/session/${sessionId}/meta`);
220
- const meta = await metaRes.json() as any;
221
- if (meta.currentRevision && meta.revisions) {
222
- const currentRev = meta.revisions.find((r: any) => r.revision === meta.currentRevision);
223
- if (currentRev?.hasFeedback) {
224
- const fbRes = await fetch(`${BASE_URL}/api/session/${sessionId}/revision/${meta.currentRevision}/feedback`);
225
- const fbData = await fbRes.json() as any;
226
- if (fbData.feedback) {
227
- process.stdout.write(fbData.feedback);
228
- process.exit(0);
229
- }
230
- }
231
- }
232
- } catch {}
233
-
234
- await waitForFeedback(sessionId);
235
- }
236
-
237
- // ── daemon ──
238
-
239
- async function handleDaemon(args: string[]) {
240
- const subcommand = args[0];
241
-
242
- if (subcommand === "status") {
243
- try {
244
- const res = await fetch(`${BASE_URL}/health`, { signal: AbortSignal.timeout(2000) });
245
- const data = await res.json() as any;
246
- console.log(`Daemon: running on port ${DAEMON_PORT}`);
247
- console.log(`Sessions: ${data.sessions.length > 0 ? data.sessions.join(", ") : "none"}`);
248
- } catch {
249
- console.log("Daemon: not running");
250
- }
251
- } else if (subcommand === "stop") {
252
- if (existsSync(PID_FILE)) {
253
- const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
254
- try {
255
- process.kill(pid, "SIGTERM");
256
- console.log("Daemon stopped.");
257
- } catch {
258
- console.log("Daemon was not running (stale PID file).");
259
- }
260
- try { unlinkSync(PID_FILE); } catch {}
261
- } else {
262
- console.log("No daemon PID file found.");
263
- }
264
- } else {
265
- console.error("Usage: agent-canvas daemon [status|stop]");
266
- process.exit(1);
267
- }
268
- }
269
-
270
- // ── helpers ──
271
-
272
- async function ensureDaemon(): Promise<void> {
273
- if (await isDaemonRunning()) return;
274
-
275
- console.error("Starting canvas daemon...");
276
- mkdirSync(TEMP_DIR, { recursive: true });
277
-
278
- const daemonScript = join(PACKAGE_ROOT, "daemon", "src", "server.ts");
279
-
280
- const child = spawn("bun", ["run", daemonScript], {
281
- detached: true,
282
- stdio: "ignore",
283
- cwd: join(PACKAGE_ROOT, "daemon"),
284
- });
285
-
286
- child.unref();
287
-
288
- for (let i = 0; i < 30; i++) {
289
- await new Promise((r) => setTimeout(r, 100));
290
- if (await isDaemonRunning()) {
291
- console.error("Daemon started.");
292
- return;
293
- }
294
- }
295
-
296
- console.error("Error: Daemon failed to start within 3 seconds.");
297
- process.exit(1);
298
- }
299
-
300
- async function isDaemonRunning(): Promise<boolean> {
301
- try {
302
- const res = await fetch(`${BASE_URL}/health`, { signal: AbortSignal.timeout(1000) });
303
- const data = await res.json() as any;
304
- return data.ok === true;
305
- } catch {
306
- return false;
307
- }
308
- }
309
-
310
- function openBrowser(url: string) {
311
- const candidates = process.platform === "darwin"
312
- ? ["open"]
313
- : ["xdg-open", "wslview", "sensible-browser"];
26
+ async function main() {
27
+ const args = process.argv.slice(2);
314
28
 
315
- for (const cmd of candidates) {
316
- const which = Bun.spawnSync(["which", cmd]);
317
- if (which.exitCode === 0) {
318
- spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
319
- return;
320
- }
29
+ if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
30
+ printUsage();
31
+ process.exit(args.length === 0 ? 1 : 0);
321
32
  }
322
- console.error(`Open this URL in your browser: ${url}`);
323
- }
324
33
 
325
- async function waitForFeedback(sessionId: string): Promise<void> {
326
- return new Promise((resolveP, rejectP) => {
327
- const ws = new WebSocket(`${WS_URL}/ws/wait/${sessionId}`);
328
- const timeout = setTimeout(() => {
329
- ws.close();
330
- console.error("Error: Timeout waiting for feedback.");
331
- process.exit(1);
332
- }, TIMEOUT_MS);
333
-
334
- ws.onmessage = (event) => {
335
- try {
336
- const data = JSON.parse(typeof event.data === "string" ? event.data : "");
337
- if (data.type === "submit") {
338
- clearTimeout(timeout);
339
- process.stdout.write(data.feedback);
340
- ws.close();
341
- process.exit(0);
342
- }
343
- } catch {}
344
- };
34
+ const command = args[0];
35
+ const rest = args.slice(1);
345
36
 
346
- ws.onerror = () => {
347
- clearTimeout(timeout);
348
- console.error("Error: WebSocket connection failed.");
37
+ switch (command) {
38
+ case "push": return handlePush(rest);
39
+ case "fetch": return handleFetch(rest);
40
+ case "watch": return handleWatch(rest);
41
+ case "install": return handleInstall(rest);
42
+ case "daemon": return handleDaemon(rest);
43
+ default:
44
+ console.error(`Unknown command: ${command}`);
45
+ printUsage();
349
46
  process.exit(1);
350
- };
351
-
352
- ws.onclose = () => {
353
- clearTimeout(timeout);
354
- };
355
- });
356
- }
357
-
358
- async function readStdin(): Promise<string> {
359
- const chunks: Buffer[] = [];
360
- for await (const chunk of Bun.stdin.stream()) {
361
- chunks.push(Buffer.from(chunk));
362
- }
363
- return Buffer.concat(chunks).toString("utf-8");
364
- }
365
-
366
- async function readLine(): Promise<string> {
367
- const buf: number[] = [];
368
- for await (const chunk of Bun.stdin.stream()) {
369
- for (const byte of chunk) {
370
- if (byte === 10) return Buffer.from(buf).toString("utf-8");
371
- buf.push(byte);
372
- }
373
47
  }
374
- return Buffer.from(buf).toString("utf-8");
375
48
  }
376
49
 
377
50
  main().catch((e) => {
@@ -0,0 +1,51 @@
1
+ import { parseArgs } from "util";
2
+ import { DAEMON_PORT, BASE_URL } from "../config.ts";
3
+ import { isDaemonRunning, startDaemon, stopDaemon } from "../daemon-lifecycle.ts";
4
+
5
+ export async function handleDaemon(args: string[]) {
6
+ const { positionals } = parseArgs({
7
+ args,
8
+ allowPositionals: true,
9
+ });
10
+
11
+ const subcommand = positionals[0];
12
+
13
+ switch (subcommand) {
14
+ case "status": {
15
+ try {
16
+ const res = await fetch(`${BASE_URL}/health`, { signal: AbortSignal.timeout(2000) });
17
+ const data = await res.json() as any;
18
+ console.log(`Daemon: running on port ${DAEMON_PORT}`);
19
+ console.log(`Sessions: ${data.sessions.length > 0 ? data.sessions.join(", ") : "none"}`);
20
+ } catch {
21
+ console.log("Daemon: not running");
22
+ }
23
+ break;
24
+ }
25
+ case "stop": {
26
+ stopDaemon();
27
+ break;
28
+ }
29
+ case "start": {
30
+ if (await isDaemonRunning()) {
31
+ console.log("Daemon is already running.");
32
+ return;
33
+ }
34
+ await startDaemon();
35
+ break;
36
+ }
37
+ case "restart": {
38
+ stopDaemon();
39
+ for (let i = 0; i < 10; i++) {
40
+ if (!(await isDaemonRunning())) break;
41
+ await new Promise((r) => setTimeout(r, 100));
42
+ }
43
+ await startDaemon();
44
+ break;
45
+ }
46
+ default: {
47
+ console.error("Usage: agent-canvas daemon [status|stop|start|restart]");
48
+ process.exit(1);
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,20 @@
1
+ import { parseArgs } from "util";
2
+ import { ensureDaemon } from "../daemon-lifecycle.ts";
3
+ import { getSessionId, consumeFeedback } from "../helpers.ts";
4
+
5
+ export async function handleFetch(args: string[]) {
6
+ const { values } = parseArgs({
7
+ args,
8
+ options: {
9
+ session: { type: "string" },
10
+ },
11
+ });
12
+
13
+ const sessionId = getSessionId(values.session);
14
+ await ensureDaemon();
15
+
16
+ const feedback = await consumeFeedback(sessionId);
17
+ if (feedback) {
18
+ process.stdout.write(feedback);
19
+ }
20
+ }
@@ -0,0 +1,48 @@
1
+ import { parseArgs } from "util";
2
+ import { existsSync, mkdirSync, cpSync } from "fs";
3
+ import { join } from "path";
4
+ import { homedir } from "os";
5
+ import { PACKAGE_ROOT } from "../config.ts";
6
+ import { readLine } from "../helpers.ts";
7
+
8
+ export async function handleInstall(args: string[]) {
9
+ const { positionals } = parseArgs({
10
+ args,
11
+ allowPositionals: true,
12
+ });
13
+
14
+ let mode = positionals[0] as "local" | "global" | undefined;
15
+
16
+ if (!mode) {
17
+ process.stdout.write("Install canvas skill for Claude Code.\n\n");
18
+ process.stdout.write(" local — install to .claude/ in current project\n");
19
+ process.stdout.write(" global — install to ~/.claude/ for all projects\n\n");
20
+ process.stdout.write("Choose [local/global]: ");
21
+
22
+ const input = await readLine();
23
+ mode = input.trim().toLowerCase() as "local" | "global";
24
+ }
25
+
26
+ if (mode !== "local" && mode !== "global") {
27
+ console.error("Error: specify 'local' or 'global'");
28
+ process.exit(1);
29
+ }
30
+
31
+ const targetBase = mode === "global"
32
+ ? join(homedir(), ".claude")
33
+ : join(process.cwd(), ".claude");
34
+
35
+ const skillTarget = join(targetBase, "skills", "canvas");
36
+ mkdirSync(skillTarget, { recursive: true });
37
+
38
+ const skillSrc = join(PACKAGE_ROOT, "skills", "canvas");
39
+ for (const file of ["SKILL.md", "components.md", "flows.md"]) {
40
+ const src = join(skillSrc, file);
41
+ if (existsSync(src)) {
42
+ cpSync(src, join(skillTarget, file));
43
+ }
44
+ }
45
+
46
+ console.log(` Skill installed to ${skillTarget}`);
47
+ console.log(`\nInstalled! The /canvas command is now available in Claude Code.`);
48
+ }
@@ -0,0 +1,66 @@
1
+ import { parseArgs } from "util";
2
+ import { readFileSync, existsSync } from "fs";
3
+ import { resolve } from "path";
4
+ import { BASE_URL } from "../config.ts";
5
+ import { ensureDaemon } from "../daemon-lifecycle.ts";
6
+ import { getSessionId, openBrowser } from "../helpers.ts";
7
+
8
+ export async function handlePush(args: string[]) {
9
+ const { values, positionals } = parseArgs({
10
+ args,
11
+ options: {
12
+ session: { type: "string" },
13
+ label: { type: "string" },
14
+ },
15
+ allowPositionals: true,
16
+ });
17
+
18
+ const filePath = positionals[0];
19
+ if (!filePath) {
20
+ console.error("Error: No file specified. Usage: agent-canvas push <file.jsx>");
21
+ process.exit(1);
22
+ }
23
+
24
+ const resolvedPath = resolve(filePath);
25
+ if (!existsSync(resolvedPath)) {
26
+ console.error(`Error: File not found: ${resolvedPath}`);
27
+ process.exit(1);
28
+ }
29
+ const jsx = readFileSync(resolvedPath, "utf-8");
30
+
31
+ const sessionId = getSessionId(values.session);
32
+ const projectRoot = process.env.CANVAS_PROJECT_ROOT || process.cwd();
33
+ const label = values.label;
34
+ const autoLabel = !label ? resolvedPath.split("/").pop()?.replace(/\.jsx$/, "") : undefined;
35
+
36
+ await ensureDaemon();
37
+
38
+ const response = await fetch(`${BASE_URL}/api/session/${sessionId}/plan`, {
39
+ method: "POST",
40
+ headers: { "Content-Type": "application/json" },
41
+ body: JSON.stringify({ jsx, projectRoot, label: label || autoLabel, sourceFile: resolvedPath.split("/").pop() }),
42
+ });
43
+
44
+ const result = await response.json() as any;
45
+
46
+ if (!result.ok) {
47
+ if (result.unconsumedFeedback) {
48
+ console.error(`Error: ${result.error}`);
49
+ console.log(result.unconsumedFeedback);
50
+ } else {
51
+ console.error(`Compilation error:\n${result.error}`);
52
+ }
53
+ process.exit(1);
54
+ }
55
+
56
+ if (result.isNew) {
57
+ openBrowser(result.browserUrl);
58
+ }
59
+
60
+ console.log(JSON.stringify({
61
+ ok: true,
62
+ browserUrl: result.browserUrl,
63
+ revision: result.revision,
64
+ sessionId,
65
+ }));
66
+ }