agent-canvas 0.1.4 → 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.
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/bin/agent-canvas.ts +26 -353
- package/bin/lib/commands/daemon.ts +51 -0
- package/bin/lib/commands/fetch.ts +20 -0
- package/bin/lib/commands/install.ts +48 -0
- package/bin/lib/commands/push.ts +66 -0
- package/bin/lib/commands/watch.ts +23 -0
- package/bin/lib/config.ts +10 -0
- package/bin/lib/daemon-lifecycle.ts +74 -0
- package/bin/lib/helpers.ts +78 -0
- package/daemon/build.ts +27 -61
- package/daemon/client/AnnotationProvider.tsx +2 -2
- package/daemon/client/AnnotationSidebar.tsx +199 -31
- package/daemon/client/App.tsx +4 -3
- package/daemon/client/FileBrowser.tsx +8 -7
- package/daemon/client/FileViewer.tsx +153 -16
- package/daemon/client/PlanRenderer.tsx +6 -3
- package/daemon/client/ResponsePreview.tsx +5 -116
- package/daemon/client/SessionSwitcher.tsx +21 -10
- package/daemon/client/compat/preact-all.ts +4 -0
- package/daemon/client/components/Markdown.tsx +44 -0
- package/daemon/client/components/ResponseNote.tsx +3 -8
- package/daemon/client/components/UserInput.tsx +2 -4
- package/daemon/client/components/index.ts +1 -0
- package/daemon/client/domDiff.ts +82 -2
- package/daemon/client/generateMarkdown.ts +46 -9
- package/daemon/client/highlightRange.ts +8 -6
- package/daemon/client/runtime.ts +2 -0
- package/daemon/client/styles.css +126 -0
- package/daemon/client/unifiedDiff.ts +155 -2
- package/daemon/client/utils.ts +10 -0
- package/daemon/dist/client.css +1 -1
- package/daemon/dist/client.js +83 -36
- package/daemon/dist/components.js +63 -5
- package/daemon/dist/index.html +5 -12
- package/daemon/dist/jsx-runtime.js +3 -0
- package/daemon/dist/preact-compat.js +1 -0
- package/daemon/src/compiler.ts +28 -2
- package/daemon/src/handlers/api.ts +150 -0
- package/daemon/src/handlers/files.ts +114 -0
- package/daemon/src/handlers/static.ts +61 -0
- package/daemon/src/handlers/utils.ts +14 -0
- package/daemon/src/router.ts +16 -0
- package/daemon/src/server.ts +28 -403
- package/daemon/src/session.ts +41 -23
- package/daemon/src/websocket.ts +83 -0
- package/package.json +3 -3
- package/skills/canvas/SKILL.md +32 -22
- package/skills/canvas/components.md +31 -0
- package/skills/canvas/flows.md +2 -2
- package/daemon/dist/jsx-dev-runtime-shim.js +0 -17
- package/daemon/dist/jsx-runtime-shim.js +0 -17
- package/daemon/dist/react-dom-client-shim.js +0 -4
- package/daemon/dist/react-dom-shim.js +0 -4
- 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
|
+

|
|
6
|
+

|
|
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
|
package/bin/agent-canvas.ts
CHANGED
|
@@ -1,377 +1,50 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
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
|
|
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
|
|
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
|
-
|
|
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.stderr.write("Install canvas skill and hooks for Claude Code.\n\n");
|
|
79
|
-
process.stderr.write(" local — install to .claude/ in current project\n");
|
|
80
|
-
process.stderr.write(" global — install to ~/.claude/ for all projects\n\n");
|
|
81
|
-
process.stderr.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.error(` 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.error(` 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.error(` Hook registered in ${settingsPath}`);
|
|
145
|
-
} else {
|
|
146
|
-
console.error(` Hook already registered in ${settingsPath}`);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
console.error(`\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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
326
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
+
}
|