agent-canvas 0.2.0 → 0.6.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 +42 -17
- package/bin/agent-canvas.js +7 -7
- package/bin/agent-canvas.ts +48 -37
- package/bin/lib/commands/daemon.ts +46 -43
- package/bin/lib/commands/exec.ts +100 -100
- package/bin/lib/commands/fetch.ts +15 -15
- package/bin/lib/commands/install.ts +36 -36
- package/bin/lib/commands/instructions.ts +51 -51
- package/bin/lib/commands/push.ts +115 -97
- package/bin/lib/commands/review.ts +190 -0
- package/bin/lib/commands/watch.ts +24 -24
- package/bin/lib/config.ts +12 -12
- package/bin/lib/daemon-lifecycle.ts +47 -51
- package/bin/lib/helpers.ts +82 -76
- package/bin/lib/instructions/app-review.md +88 -0
- package/bin/lib/instructions/component-checklist.md +7 -5
- package/bin/lib/instructions/component-custom.md +20 -14
- package/bin/lib/instructions/component-diff.md +4 -3
- package/bin/lib/instructions/component-item.md +18 -8
- package/bin/lib/instructions/component-markdown.md +4 -2
- package/bin/lib/instructions/component-mermaid.md +12 -6
- package/bin/lib/instructions/component-multichoice.md +6 -2
- package/bin/lib/instructions/component-note.md +1 -1
- package/bin/lib/instructions/component-priority.md +1 -1
- package/bin/lib/instructions/component-rangeinput.md +9 -2
- package/bin/lib/instructions/component-secretinput.md +12 -11
- package/bin/lib/instructions/component-svg.md +62 -16
- package/bin/lib/instructions/component-table.md +6 -6
- package/bin/lib/instructions/component-usefeedback.md +8 -8
- package/bin/lib/instructions/component-userinput.md +6 -2
- package/bin/lib/instructions/flow-feature.md +22 -5
- package/bin/lib/instructions/flow-plan.md +5 -5
- package/bin/lib/instructions/flow-runbook.md +28 -25
- package/bin/lib/instructions/overview.md +27 -18
- package/bin/lib/instructions/theming.md +5 -0
- package/daemon/build.ts +171 -152
- package/daemon/client/App.tsx +1205 -1077
- package/daemon/client/CompareView.tsx +478 -458
- package/daemon/client/FileBrowser.tsx +241 -203
- package/daemon/client/FileViewer.tsx +220 -211
- package/daemon/client/OverviewView.tsx +283 -267
- package/daemon/client/ReviewerIdentityDialog.tsx +72 -71
- package/daemon/client/RevisionSelect.tsx +111 -95
- package/daemon/client/SessionSwitcher.tsx +81 -81
- package/daemon/client/ShareDialog.tsx +211 -212
- package/daemon/client/apiSchemas.ts +78 -0
- package/daemon/client/canvas-runtime.ts +1 -1
- package/daemon/client/clientApi.ts +194 -206
- package/daemon/client/components/SecretInput.tsx +138 -138
- package/daemon/client/components/index.ts +2 -2
- package/daemon/client/domDiff.ts +341 -333
- package/daemon/client/errorMessage.ts +3 -0
- package/daemon/client/exportMarkdown.ts +222 -201
- package/daemon/client/lcs.ts +19 -14
- package/daemon/client/shareCrypto.ts +57 -57
- package/daemon/client/unifiedDiff.ts +660 -649
- package/daemon/dist/annotations-runtime.js +1 -1
- package/daemon/dist/client.css +2 -2
- package/daemon/dist/client.js +77 -80
- package/daemon/dist/components.js +59 -59
- package/daemon/dist/preact-compat.js +1 -1
- package/daemon/dist/review.js +361 -0
- package/daemon/dist/runtime.js +1 -1
- package/daemon/langMap.ts +24 -6
- package/daemon/review-widget/anchor.ts +187 -0
- package/daemon/review-widget/client.ts +95 -0
- package/daemon/review-widget/index.tsx +36 -0
- package/daemon/review-widget/markup.ts +71 -0
- package/daemon/review-widget/screenshot.ts +91 -0
- package/daemon/review-widget/source.ts +103 -0
- package/daemon/review-widget/styles.css +359 -0
- package/daemon/review-widget/text-modules.d.ts +4 -0
- package/daemon/review-widget/tsconfig.json +16 -0
- package/daemon/review-widget/widget.tsx +436 -0
- package/daemon/src/components.ts +3 -3
- package/daemon/src/handlers/api.ts +330 -328
- package/daemon/src/handlers/files.ts +112 -105
- package/daemon/src/handlers/static.ts +47 -46
- package/daemon/src/handlers/uploads.ts +61 -56
- package/daemon/src/paths.ts +13 -10
- package/daemon/src/remote-feedback.ts +192 -192
- package/daemon/src/review/markdown.ts +52 -0
- package/daemon/src/review/model.ts +132 -0
- package/daemon/src/review/proxy.ts +177 -0
- package/daemon/src/review/server.ts +199 -0
- package/daemon/src/review/store.ts +151 -0
- package/daemon/src/server.ts +127 -86
- package/daemon/src/share.ts +163 -169
- package/daemon/src/stubFetch.ts +7 -0
- package/daemon/tsconfig.json +19 -19
- package/package.json +47 -47
- package/skills/canvas/SKILL.md +9 -1
- package/bin/lib/commands/exec.test.ts +0 -27
- package/bin/lib/helpers.test.ts +0 -79
- package/daemon/src/remote-feedback.test.ts +0 -165
- package/daemon/src/secrets-api.test.ts +0 -123
- package/daemon/src/share-e2e.test.ts +0 -277
- package/daemon/src/share.test.ts +0 -289
|
@@ -1,48 +1,48 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { join } from
|
|
4
|
-
import {
|
|
5
|
-
import { PACKAGE_ROOT } from
|
|
6
|
-
import { readLine } from
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, unlinkSync } from 'fs'
|
|
2
|
+
import { homedir } from 'os'
|
|
3
|
+
import { join } from 'path'
|
|
4
|
+
import { parseArgs } from 'util'
|
|
5
|
+
import { PACKAGE_ROOT } from '../config.ts'
|
|
6
|
+
import { readLine } from '../helpers.ts'
|
|
7
7
|
|
|
8
8
|
export async function handleInstall(args: string[]) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
const { positionals } = parseArgs({
|
|
10
|
+
args,
|
|
11
|
+
allowPositionals: true,
|
|
12
|
+
})
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
let mode = positionals[0] as 'local' | 'global' | undefined
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
const input = await readLine()
|
|
23
|
+
mode = input.trim().toLowerCase() as 'local' | 'global'
|
|
24
|
+
}
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
if (mode !== 'local' && mode !== 'global') {
|
|
27
|
+
console.error("Error: specify 'local' or 'global'")
|
|
28
|
+
process.exit(1)
|
|
29
|
+
}
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
const targetBase = mode === 'global'
|
|
32
|
+
? join(homedir(), '.claude')
|
|
33
|
+
: join(process.cwd(), '.claude')
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
const skillTarget = join(targetBase, 'skills', 'canvas')
|
|
36
|
+
mkdirSync(skillTarget, { recursive: true })
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
cpSync(join(PACKAGE_ROOT, 'skills', 'canvas', 'SKILL.md'), join(skillTarget, 'SKILL.md'))
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
// Clean up files from older versions
|
|
41
|
+
for (const old of ['components.md', 'flows.md']) {
|
|
42
|
+
const p = join(skillTarget, old)
|
|
43
|
+
if (existsSync(p)) unlinkSync(p)
|
|
44
|
+
}
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
console.log(` Skill installed to ${skillTarget}`)
|
|
47
|
+
console.log(`\nInstalled! The /canvas command is now available in Claude Code.`)
|
|
48
48
|
}
|
|
@@ -1,66 +1,66 @@
|
|
|
1
|
-
import { existsSync,
|
|
2
|
-
import { join } from
|
|
3
|
-
import { PACKAGE_ROOT } from
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'fs'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
import { PACKAGE_ROOT } from '../config.ts'
|
|
4
4
|
|
|
5
|
-
const INSTRUCTIONS_DIR = join(PACKAGE_ROOT,
|
|
5
|
+
const INSTRUCTIONS_DIR = join(PACKAGE_ROOT, 'bin', 'lib', 'instructions')
|
|
6
6
|
|
|
7
7
|
export async function handleInstructions(args: string[]) {
|
|
8
|
-
|
|
8
|
+
const topic = args[0]
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
if (!topic) {
|
|
11
|
+
// Base instructions — overview
|
|
12
|
+
const overview = readFileSync(join(INSTRUCTIONS_DIR, 'overview.md'), 'utf-8')
|
|
13
|
+
process.stdout.write(overview)
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
if (topic === '--list') {
|
|
18
|
+
printAvailableTopics(process.stdout)
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
// Try exact match: topic.md
|
|
23
|
+
const file = join(INSTRUCTIONS_DIR, `${topic}.md`)
|
|
24
|
+
if (existsSync(file)) {
|
|
25
|
+
const content = readFileSync(file, 'utf-8')
|
|
26
|
+
process.stdout.write(content)
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
// Not found
|
|
31
|
+
console.error(`Unknown topic: ${topic}\n`)
|
|
32
|
+
printAvailableTopics(process.stderr)
|
|
33
|
+
process.exit(1)
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
function printAvailableTopics(out: NodeJS.WriteStream) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
const files = readdirSync(INSTRUCTIONS_DIR)
|
|
38
|
+
.filter(f => f.endsWith('.md') && f !== 'overview.md')
|
|
39
|
+
.map(f => f.replace('.md', ''))
|
|
40
|
+
.sort()
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
const components = files.filter(f => f.startsWith('component-'))
|
|
43
|
+
const flows = files.filter(f => f.startsWith('flow-'))
|
|
44
|
+
const other = files.filter(f => !f.startsWith('component-') && !f.startsWith('flow-'))
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
const lines: string[] = ['## Available Detail Topics', '']
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
48
|
+
if (components.length) {
|
|
49
|
+
lines.push('### Components')
|
|
50
|
+
for (const c of components) lines.push(`- \`${c}\``)
|
|
51
|
+
lines.push('')
|
|
52
|
+
}
|
|
53
|
+
if (flows.length) {
|
|
54
|
+
lines.push('### Flows')
|
|
55
|
+
for (const f of flows) lines.push(`- \`${f}\``)
|
|
56
|
+
lines.push('')
|
|
57
|
+
}
|
|
58
|
+
if (other.length) {
|
|
59
|
+
lines.push('### Other')
|
|
60
|
+
for (const o of other) lines.push(`- \`${o}\``)
|
|
61
|
+
lines.push('')
|
|
62
|
+
}
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
lines.push('Usage: `bunx agent-canvas instructions <topic>`')
|
|
65
|
+
out.write(lines.join('\n') + '\n')
|
|
66
66
|
}
|
package/bin/lib/commands/push.ts
CHANGED
|
@@ -1,111 +1,129 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
1
|
+
import { existsSync, statSync } from 'fs'
|
|
2
|
+
import { basename, resolve } from 'path'
|
|
3
|
+
import { parseArgs } from 'util'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import { BASE_URL } from '../config.ts'
|
|
6
|
+
import { ensureDaemon, startDaemon, stopDaemon } from '../daemon-lifecycle.ts'
|
|
7
|
+
import { getSessionId, openBrowser } from '../helpers.ts'
|
|
8
|
+
|
|
9
|
+
const pushResponseSchema = z.union([
|
|
10
|
+
z.object({
|
|
11
|
+
ok: z.literal(true),
|
|
12
|
+
browserUrl: z.string(),
|
|
13
|
+
isNew: z.boolean(),
|
|
14
|
+
revision: z.number(),
|
|
15
|
+
canvasFiles: z.array(z.string()),
|
|
16
|
+
}),
|
|
17
|
+
z.object({
|
|
18
|
+
ok: z.literal(false).optional(),
|
|
19
|
+
error: z.string().optional(),
|
|
20
|
+
errors: z.record(z.unknown()).optional(),
|
|
21
|
+
unconsumedFeedback: z.string().optional(),
|
|
22
|
+
}),
|
|
23
|
+
])
|
|
7
24
|
|
|
8
25
|
export async function handlePush(args: string[]) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
26
|
+
const { values, positionals } = parseArgs({
|
|
27
|
+
args,
|
|
28
|
+
options: {
|
|
29
|
+
session: { type: 'string' },
|
|
30
|
+
label: { type: 'string' },
|
|
31
|
+
response: { type: 'string' },
|
|
32
|
+
},
|
|
33
|
+
allowPositionals: true,
|
|
34
|
+
})
|
|
18
35
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
36
|
+
const target = positionals[0]
|
|
37
|
+
if (!target) {
|
|
38
|
+
console.error('Error: No directory specified. Usage: agent-canvas push <directory>')
|
|
39
|
+
process.exit(1)
|
|
40
|
+
}
|
|
24
41
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
42
|
+
const resolvedPath = resolve(target)
|
|
43
|
+
if (!existsSync(resolvedPath)) {
|
|
44
|
+
console.error(`Error: Path not found: ${resolvedPath}`)
|
|
45
|
+
process.exit(1)
|
|
46
|
+
}
|
|
30
47
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
48
|
+
if (statSync(resolvedPath).isFile()) {
|
|
49
|
+
console.error(`Error: Expected a directory, got a file: ${resolvedPath}`)
|
|
50
|
+
console.error('Usage: agent-canvas push <directory>')
|
|
51
|
+
process.exit(1)
|
|
52
|
+
}
|
|
36
53
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
54
|
+
const sessionId = getSessionId(values.session)
|
|
55
|
+
const projectRoot = process.env.CANVAS_PROJECT_ROOT || process.cwd()
|
|
56
|
+
const label = values.label
|
|
57
|
+
const autoLabel = !label ? basename(resolvedPath) : undefined
|
|
41
58
|
|
|
42
|
-
|
|
59
|
+
await ensureDaemon()
|
|
43
60
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
61
|
+
const response = await fetch(`${BASE_URL}/api/session/${sessionId}/plan`, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { 'Content-Type': 'application/json' },
|
|
64
|
+
body: JSON.stringify({
|
|
65
|
+
directory: resolvedPath,
|
|
66
|
+
projectRoot,
|
|
67
|
+
label: label || autoLabel,
|
|
68
|
+
...(values.response ? { response: values.response } : {}),
|
|
69
|
+
}),
|
|
70
|
+
})
|
|
54
71
|
|
|
55
|
-
|
|
72
|
+
let result = pushResponseSchema.parse(await response.json())
|
|
56
73
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
74
|
+
// Bun.build() / Bun.Transpiler can fail in long-running daemons due to
|
|
75
|
+
// internal state corruption, CWD unlinking, etc. Restart and retry once.
|
|
76
|
+
// Skip restart only for clear user errors (syntax errors with line/col,
|
|
77
|
+
// runtime validation errors) — those won't be fixed by a restart.
|
|
78
|
+
const isUserError = !result.ok && result.errors && Object.values(result.errors).every((e) =>
|
|
79
|
+
typeof e === 'string' && (
|
|
80
|
+
/\(line \d+, col \d+\)/.test(e)
|
|
81
|
+
|| /Syntax Error|Parse error|Mermaid syntax error/i.test(e)
|
|
82
|
+
|| e.startsWith('Runtime error:')
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
const shouldRestart = !result.ok && result.error === 'Canvas compilation failed' && !isUserError
|
|
86
|
+
if (shouldRestart) {
|
|
87
|
+
console.error('Compilation failed, restarting daemon and retrying...')
|
|
88
|
+
await stopDaemon()
|
|
89
|
+
await startDaemon()
|
|
90
|
+
const retryResponse = await fetch(`${BASE_URL}/api/session/${sessionId}/plan`, {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: { 'Content-Type': 'application/json' },
|
|
93
|
+
body: JSON.stringify({
|
|
94
|
+
directory: resolvedPath,
|
|
95
|
+
projectRoot,
|
|
96
|
+
label: label || autoLabel,
|
|
97
|
+
...(values.response ? { response: values.response } : {}),
|
|
98
|
+
}),
|
|
99
|
+
})
|
|
100
|
+
result = pushResponseSchema.parse(await retryResponse.json())
|
|
101
|
+
}
|
|
84
102
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
103
|
+
if (!result.ok) {
|
|
104
|
+
if (result.unconsumedFeedback) {
|
|
105
|
+
console.error(`Error: ${result.error}`)
|
|
106
|
+
console.log(result.unconsumedFeedback)
|
|
107
|
+
} else {
|
|
108
|
+
console.error(`Compilation error:\n${result.error}`)
|
|
109
|
+
if (result.errors) {
|
|
110
|
+
for (const [file, err] of Object.entries(result.errors)) {
|
|
111
|
+
console.error(` ${file}: ${err}`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
process.exit(1)
|
|
116
|
+
}
|
|
99
117
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
118
|
+
if (result.isNew) {
|
|
119
|
+
openBrowser(result.browserUrl)
|
|
120
|
+
}
|
|
103
121
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
122
|
+
console.log(JSON.stringify({
|
|
123
|
+
ok: true,
|
|
124
|
+
browserUrl: result.browserUrl,
|
|
125
|
+
revision: result.revision,
|
|
126
|
+
sessionId,
|
|
127
|
+
canvasFiles: result.canvasFiles,
|
|
128
|
+
}))
|
|
111
129
|
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { waitForEvent } from '@fabrika/daemon-kit'
|
|
2
|
+
import { execFileSync } from 'child_process'
|
|
3
|
+
import { basename } from 'path'
|
|
4
|
+
import { parseArgs } from 'util'
|
|
5
|
+
import { z } from 'zod'
|
|
6
|
+
import { PROJECT_RE, reviewThreadSchema } from '../../../daemon/src/review/model.ts'
|
|
7
|
+
import { BASE_URL, TIMEOUT_MS, WS_URL } from '../config.ts'
|
|
8
|
+
import { ensureDaemon } from '../daemon-lifecycle.ts'
|
|
9
|
+
import { openBrowser } from '../helpers.ts'
|
|
10
|
+
|
|
11
|
+
const USAGE = `agent-canvas review — comments on a running application
|
|
12
|
+
|
|
13
|
+
agent-canvas review open <url> [--follow] [--no-open]
|
|
14
|
+
Serve the app at <url> with the review widget, open it in the
|
|
15
|
+
browser and print the address; --follow then watches as below
|
|
16
|
+
agent-canvas review snippet Print the <script> tag that adds the review widget to the app
|
|
17
|
+
agent-canvas review watch Wait for threads the reviewer sends (blocks)
|
|
18
|
+
agent-canvas review watch --follow Keep waiting; print every batch the reviewer sends
|
|
19
|
+
agent-canvas review fetch Take the threads already sent (returns immediately)
|
|
20
|
+
agent-canvas review reply <thread> <text> [--resolve]
|
|
21
|
+
Answer a thread; --resolve closes it
|
|
22
|
+
agent-canvas review list Show every thread that is not resolved
|
|
23
|
+
|
|
24
|
+
Every command takes --project <name>; it defaults to CANVAS_REVIEW_PROJECT, then to the git repository's directory name.`
|
|
25
|
+
|
|
26
|
+
const claimSchema = z.union([z.object({ found: z.literal(false) }), z.object({ found: z.literal(true), markdown: z.string() })])
|
|
27
|
+
const threadsSchema = z.object({ threads: z.array(reviewThreadSchema) })
|
|
28
|
+
const proxySchema = z.object({ url: z.string() })
|
|
29
|
+
const errorSchema = z.object({ error: z.string() })
|
|
30
|
+
|
|
31
|
+
function defaultProject(): string {
|
|
32
|
+
const fromEnv = process.env.CANVAS_REVIEW_PROJECT
|
|
33
|
+
if (fromEnv !== undefined && fromEnv !== '') return fromEnv
|
|
34
|
+
let root = process.cwd()
|
|
35
|
+
try {
|
|
36
|
+
root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
37
|
+
} catch {}
|
|
38
|
+
return basename(root).replace(/[^A-Za-z0-9._-]/g, '-')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function call(project: string, method: string, path: string, body?: unknown): Promise<unknown> {
|
|
42
|
+
const response = await fetch(`${BASE_URL}/api/review/${encodeURIComponent(project)}${path}`, {
|
|
43
|
+
method,
|
|
44
|
+
...(body === undefined ? {} : { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
|
|
45
|
+
})
|
|
46
|
+
const result: unknown = await response.json()
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
const error = errorSchema.safeParse(result)
|
|
49
|
+
throw new Error(error.success ? error.data.error : `Daemon answered ${response.status}`)
|
|
50
|
+
}
|
|
51
|
+
return result
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function claim(project: string): Promise<string | undefined> {
|
|
55
|
+
const result = claimSchema.parse(await call(project, 'POST', '/claim'))
|
|
56
|
+
return result.found ? result.markdown : undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function waitForRequests(project: string): Promise<string> {
|
|
60
|
+
return waitForEvent<string>({
|
|
61
|
+
url: `${WS_URL}/ws/review-wait/${encodeURIComponent(project)}`,
|
|
62
|
+
// The frame only announces new threads; claiming them is what hands them over, exactly once.
|
|
63
|
+
onFrame: (frame) => (typeof frame === 'object' && frame !== null && 'type' in frame && frame.type === 'queued' ? { type: 'recheck' } : undefined),
|
|
64
|
+
probe: async () => {
|
|
65
|
+
const markdown = await claim(project)
|
|
66
|
+
return markdown === undefined ? undefined : { value: markdown }
|
|
67
|
+
},
|
|
68
|
+
alreadyProbed: true,
|
|
69
|
+
timeoutMs: TIMEOUT_MS,
|
|
70
|
+
reconnect: { attempts: 3, delayMs: 1_000 },
|
|
71
|
+
messages: {
|
|
72
|
+
timeout: 'Timeout waiting for review requests.',
|
|
73
|
+
closed: 'WebSocket closed before the reviewer sent anything.',
|
|
74
|
+
connectionFailed: 'WebSocket connection failed.',
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const FOLLOW_RECONNECT_MS = 2_000
|
|
80
|
+
const FOLLOW_GIVE_UP_MS = 60_000
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The streaming watcher for hosts that turn each output line into an event (Claude Code's Monitor). One socket stays
|
|
84
|
+
* open, so the reviewer keeps seeing an agent listening between batches. Status goes to stderr; stdout carries only
|
|
85
|
+
* batches and the line that ends the watch, so every stdout event is worth acting on.
|
|
86
|
+
*/
|
|
87
|
+
function followRequests(project: string): Promise<never> {
|
|
88
|
+
let claiming = Promise.resolve()
|
|
89
|
+
let unreachableSince: number | null = null
|
|
90
|
+
|
|
91
|
+
const claimAndPrint = () => {
|
|
92
|
+
claiming = claiming.then(async () => {
|
|
93
|
+
const markdown = await claim(project)
|
|
94
|
+
if (markdown !== undefined) process.stdout.write(`${markdown}\n`)
|
|
95
|
+
}).catch((error: unknown) => {
|
|
96
|
+
console.error(`review watch: claim failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const connect = () => {
|
|
101
|
+
const socket = new WebSocket(`${WS_URL}/ws/review-wait/${encodeURIComponent(project)}`)
|
|
102
|
+
socket.onopen = () => {
|
|
103
|
+
unreachableSince = null
|
|
104
|
+
console.error(`review watch: listening for ${project}`)
|
|
105
|
+
claimAndPrint()
|
|
106
|
+
}
|
|
107
|
+
socket.onmessage = (event) => {
|
|
108
|
+
// Any frame on the wait socket means "something was queued"; the claim reads what, so the frame needs no parsing.
|
|
109
|
+
if (typeof event.data === 'string') claimAndPrint()
|
|
110
|
+
}
|
|
111
|
+
socket.onclose = () => {
|
|
112
|
+
unreachableSince ??= Date.now()
|
|
113
|
+
if (Date.now() - unreachableSince > FOLLOW_GIVE_UP_MS) {
|
|
114
|
+
console.log(`review watch: the daemon at ${BASE_URL} has been unreachable for a minute; stopped watching ${project}.`)
|
|
115
|
+
process.exit(1)
|
|
116
|
+
}
|
|
117
|
+
setTimeout(connect, FOLLOW_RECONNECT_MS)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
connect()
|
|
122
|
+
return new Promise<never>(() => {})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function handleReview(args: string[]) {
|
|
126
|
+
const [subcommand, ...rest] = args
|
|
127
|
+
const { values, positionals } = parseArgs({
|
|
128
|
+
args: rest,
|
|
129
|
+
allowPositionals: true,
|
|
130
|
+
options: {
|
|
131
|
+
project: { type: 'string' },
|
|
132
|
+
resolve: { type: 'boolean', default: false },
|
|
133
|
+
follow: { type: 'boolean', default: false },
|
|
134
|
+
'no-open': { type: 'boolean', default: false },
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
const project = values.project ?? defaultProject()
|
|
138
|
+
if (!PROJECT_RE.test(project)) throw new Error(`Invalid project name "${project}"; use letters, digits, ".", "_" and "-"`)
|
|
139
|
+
|
|
140
|
+
switch (subcommand) {
|
|
141
|
+
case 'open': {
|
|
142
|
+
const [target] = positionals
|
|
143
|
+
if (target === undefined) throw new Error('Usage: agent-canvas review open <url> [--follow] [--no-open]')
|
|
144
|
+
await ensureDaemon()
|
|
145
|
+
const { url } = proxySchema.parse(await call(project, 'POST', '/proxy', { target: new URL(target).href }))
|
|
146
|
+
console.log(`Review ${target} at ${url}`)
|
|
147
|
+
if (!values['no-open']) openBrowser(url)
|
|
148
|
+
if (values.follow) return followRequests(project)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
case 'snippet':
|
|
152
|
+
console.log(`<script src="${BASE_URL}/assets/review.js" data-project="${project}"></script>`)
|
|
153
|
+
return
|
|
154
|
+
case 'watch': {
|
|
155
|
+
await ensureDaemon()
|
|
156
|
+
if (values.follow) return followRequests(project)
|
|
157
|
+
process.stdout.write((await claim(project)) ?? (await waitForRequests(project)))
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
case 'fetch': {
|
|
161
|
+
await ensureDaemon()
|
|
162
|
+
const markdown = await claim(project)
|
|
163
|
+
process.stdout.write(markdown ?? 'No review requests are waiting.\n')
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
case 'reply': {
|
|
167
|
+
const [threadId, ...words] = positionals
|
|
168
|
+
const body = words.join(' ')
|
|
169
|
+
if (threadId === undefined || body.trim() === '') throw new Error('Usage: agent-canvas review reply <thread> <text> [--resolve]')
|
|
170
|
+
await ensureDaemon()
|
|
171
|
+
await call(project, 'POST', `/threads/${encodeURIComponent(threadId)}/reply`, { body, resolve: values.resolve })
|
|
172
|
+
console.log(values.resolve ? `Replied to ${threadId} and resolved it.` : `Replied to ${threadId}.`)
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
case 'list': {
|
|
176
|
+
await ensureDaemon()
|
|
177
|
+
const { threads } = threadsSchema.parse(await call(project, 'GET', '/threads'))
|
|
178
|
+
const pending = threads.filter((thread) => thread.state !== 'resolved')
|
|
179
|
+
if (pending.length === 0) console.log('No unresolved threads.')
|
|
180
|
+
for (const thread of pending) {
|
|
181
|
+
const first = thread.comments[0]?.body.split('\n')[0] ?? ''
|
|
182
|
+
console.log(`${thread.id} ${thread.state.padEnd(8)} ${thread.anchor.url}\n ${first}`)
|
|
183
|
+
}
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
default:
|
|
187
|
+
console.log(USAGE)
|
|
188
|
+
process.exit(subcommand === undefined || subcommand === '--help' ? 0 : 1)
|
|
189
|
+
}
|
|
190
|
+
}
|