@rwoll/canvas-kit 0.0.0-placeholder → 0.1.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.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +481 -1
  3. package/cli/canvas-kit.mjs +148 -0
  4. package/cli/lib.mjs +697 -0
  5. package/cli/release.json +4 -0
  6. package/dist/client.d.ts +72 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/client.js +156 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/define.d.ts +249 -0
  11. package/dist/define.d.ts.map +1 -0
  12. package/dist/define.js +197 -0
  13. package/dist/define.js.map +1 -0
  14. package/dist/errors.d.ts +12 -0
  15. package/dist/errors.d.ts.map +1 -0
  16. package/dist/errors.js +39 -0
  17. package/dist/errors.js.map +1 -0
  18. package/dist/extension.d.ts +25 -0
  19. package/dist/extension.d.ts.map +1 -0
  20. package/dist/extension.js +70 -0
  21. package/dist/extension.js.map +1 -0
  22. package/dist/index.d.ts +5 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +4 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/jobs.d.ts +56 -0
  27. package/dist/jobs.d.ts.map +1 -0
  28. package/dist/jobs.js +33 -0
  29. package/dist/jobs.js.map +1 -0
  30. package/dist/playwright.d.ts +79 -0
  31. package/dist/playwright.d.ts.map +1 -0
  32. package/dist/playwright.js +124 -0
  33. package/dist/playwright.js.map +1 -0
  34. package/dist/react.d.ts +55 -0
  35. package/dist/react.d.ts.map +1 -0
  36. package/dist/react.js +72 -0
  37. package/dist/react.js.map +1 -0
  38. package/dist/server/headless.d.ts +20 -0
  39. package/dist/server/headless.d.ts.map +1 -0
  40. package/dist/server/headless.js +35 -0
  41. package/dist/server/headless.js.map +1 -0
  42. package/dist/server/host.d.ts +146 -0
  43. package/dist/server/host.d.ts.map +1 -0
  44. package/dist/server/host.js +1078 -0
  45. package/dist/server/host.js.map +1 -0
  46. package/dist/server/index.d.ts +9 -0
  47. package/dist/server/index.d.ts.map +1 -0
  48. package/dist/server/index.js +5 -0
  49. package/dist/server/index.js.map +1 -0
  50. package/dist/server/release.d.ts +30 -0
  51. package/dist/server/release.d.ts.map +1 -0
  52. package/dist/server/release.js +71 -0
  53. package/dist/server/release.js.map +1 -0
  54. package/dist/server/storage.d.ts +63 -0
  55. package/dist/server/storage.d.ts.map +1 -0
  56. package/dist/server/storage.js +253 -0
  57. package/dist/server/storage.js.map +1 -0
  58. package/dist/testing.d.ts +49 -0
  59. package/dist/testing.d.ts.map +1 -0
  60. package/dist/testing.js +42 -0
  61. package/dist/testing.js.map +1 -0
  62. package/dist/vite.d.ts +15 -0
  63. package/dist/vite.d.ts.map +1 -0
  64. package/dist/vite.js +213 -0
  65. package/dist/vite.js.map +1 -0
  66. package/package.json +111 -5
  67. package/styles.css +122 -0
  68. package/templates/canvas/README.md +19 -0
  69. package/templates/canvas/e2e/canvas.spec.ts +15 -0
  70. package/templates/canvas/index.html +13 -0
  71. package/templates/canvas/package.json +29 -0
  72. package/templates/canvas/playwright.config.ts +5 -0
  73. package/templates/canvas/server/canvas.test.ts +22 -0
  74. package/templates/canvas/server/canvas.ts +27 -0
  75. package/templates/canvas/src/App.css +21 -0
  76. package/templates/canvas/src/App.tsx +31 -0
  77. package/templates/canvas/src/index.css +3 -0
  78. package/templates/canvas/src/main.tsx +11 -0
  79. package/templates/canvas/tsconfig.app.json +1 -0
  80. package/templates/canvas/tsconfig.json +7 -0
  81. package/templates/canvas/tsconfig.node.json +1 -0
  82. package/templates/canvas/vite.config.ts +7 -0
  83. package/templates/ui/README.md +18 -0
  84. package/templates/ui/package.json +22 -0
  85. package/templates/ui/src/Checklist.tsx +42 -0
  86. package/templates/ui/src/Header.tsx +17 -0
  87. package/templates/ui/src/Markdown.tsx +140 -0
  88. package/templates/ui/src/TextButton.tsx +6 -0
  89. package/templates/ui/src/index.ts +4 -0
  90. package/templates/ui/theme.css +143 -0
  91. package/templates/ui/tsconfig.json +18 -0
  92. package/tsconfig/canvas.json +27 -0
  93. package/tsconfig/node.json +24 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ross A. Wollman
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 CHANGED
@@ -1,3 +1,483 @@
1
1
  # @rwoll/canvas-kit
2
2
 
3
- This version only reserves the name. The first release is coming soon.
3
+ Build Copilot canvas extensions with TypeScript and React. A canvas is an
4
+ interactive panel that the agent opens with `open_canvas` and drives with
5
+ `invoke_canvas_action`. A canvas package declares its state and actions once, and
6
+ the kit supplies everything else.
7
+
8
+ ## Quickstart
9
+
10
+ ```sh
11
+ npm create @rwoll/canvas my-marketplace -- --repository me/my-marketplace --canvas release-board
12
+ cd my-marketplace
13
+ pnpm install
14
+ pnpm --filter release-board dev # the canvas in a browser, with live actions
15
+ pnpm check # build, typecheck, unit tests
16
+ ```
17
+
18
+ Push it to GitHub and every push to `main` publishes the marketplace to the
19
+ `stable` branch. Install it with
20
+ `copilot plugin marketplace add me/my-marketplace#stable`.
21
+
22
+ Add more canvases with `pnpm new-canvas <name>` (`canvas-kit new`). You edit two
23
+ files per canvas: `server/canvas.ts` for state and actions, and `src/App.tsx` for
24
+ the UI.
25
+
26
+ ## What the kit provides
27
+
28
+ - **Copilot SDK registration**: `joinSession`/`createCanvas` wiring, a built-in
29
+ `get_state` action, JSON Schemas generated from Zod, and `CanvasError` mapping.
30
+ - **Loopback server**: one `127.0.0.1` server per extension on an ephemeral port,
31
+ stopped when the last instance closes. Pages get a per-instance token embedded in
32
+ the HTML (never the URL), and the server checks `Host`, `Origin`, and the token.
33
+ - **Live state**: every `setState` is validated and pushed to open pages over
34
+ server-sent events. Actions on one document run one at a time; if an action
35
+ throws or its state cannot be saved, the state rolls back.
36
+ - **Persistence (optional)**: atomic, validated JSON files under
37
+ `<copilot-home>/extension-data/<canvas-id>/`, keyed by a domain identity. They
38
+ outlive the agent, and every chat that opens the same key shares one document:
39
+ changes apply one at a time under a cross-process lock, and open pages in other
40
+ chats update live.
41
+ - **Scheduled jobs (optional)**: `jobs` run on an interval while a chat has the
42
+ canvas open, once per interval across chats, and `canvas-kit run` runs them with
43
+ no chat open, from launchd or cron. Users and the agent can pause, retime, run,
44
+ and add jobs when the canvas allows it.
45
+ - **Build**: one `vite build` writes an installable plugin to `dist/plugin/`:
46
+ the React UI, a single bundled `extension.mjs` (dependencies included; only the
47
+ host-provided `@github/copilot-sdk` and Node.js built-ins stay external), the
48
+ plugin manifest generated from `package.json`, and `canvas.mjs`, the definition
49
+ without the SDK, for `canvas-kit run`.
50
+ - **Dev server**: `vite` serves the UI with the canvas definition running
51
+ in-process, so actions and live updates work in a normal browser. To preview
52
+ what only the agent can do, such as a tour, run an agent action with a
53
+ same-origin `POST /__canvas/dev/agent/<action>` and a JSON body. This route
54
+ exists only in the dev server.
55
+ - **Version footer**: every page has a small footer pinned to the bottom of the
56
+ viewport, such as
57
+ `changeset-review v0.3.0 · Published Oct 22, 2026, 7:41 PM`. The version comes from
58
+ `package.json`, and the publish time and source commit (in the tooltip) come from
59
+ the `release.json` stamp that `canvas-kit release` writes. Unpublished builds say
60
+ `Local build, not published`; the dev server says `Dev server`.
61
+ - **Tests**: `createTestCanvas` for unit tests and `canvasTest` fixtures for
62
+ Playwright browser tests against the built canvas.
63
+ - **Marketplace tooling**: the `canvas-kit` command scaffolds canvases, assembles a
64
+ plugin marketplace, and publishes it to a branch. A reusable GitHub Actions
65
+ workflow runs it on every push.
66
+
67
+ ## Canvas package layout
68
+
69
+ ```text
70
+ canvases/<name>/
71
+ package.json name, version, and description become the plugin manifest
72
+ index.html Vite entry
73
+ vite.config.ts plugins: [canvasKit()]
74
+ server/canvas.ts export default defineCanvas({...}) (Node.js)
75
+ server/*.test.ts unit tests (Vitest)
76
+ src/ React UI; import only types from server/
77
+ e2e/*.spec.ts browser tests (Playwright)
78
+ playwright.config.ts export default defineCanvasConfig()
79
+ ```
80
+
81
+ The folder name is the plugin name and extension folder name.
82
+
83
+ ## Defining a canvas
84
+
85
+ ```ts
86
+ import { defineCanvas, z } from '@rwoll/canvas-kit'
87
+
88
+ export default defineCanvas({
89
+ id: 'octo-release-board', // stable, distinctive, kebab-case
90
+ title: 'Release board',
91
+ description: 'Track release blockers for a repository.',
92
+ state: z.object({ repo: z.string(), blockers: z.array(z.string()) }),
93
+ input: z.object({ repo: z.string() }), // optional open_canvas input
94
+ init: ({ input }) => ({ repo: input.repo, blockers: [] }),
95
+ persist: { key: ({ input }) => input.repo }, // optional private persistence
96
+ actions: ({ action }) => ({
97
+ add_blocker: action({
98
+ description: 'Add a release blocker.',
99
+ input: z.object({ text: z.string().min(1).max(500) }),
100
+ run: (ctx, { text }) => ctx.setState(s => ({ ...s, blockers: [...s.blockers, text] })),
101
+ }),
102
+ clear: action({ description: 'Clear all blockers.', expose: 'agent', run: ctx => ctx.setState(s => ({ ...s, blockers: [] })) }),
103
+ }),
104
+ })
105
+ ```
106
+
107
+ - `expose` is `'both'` (default), `'agent'`, or `'ui'`.
108
+ - `reopen({ instanceId, input, state })` runs when an open instance or a saved
109
+ document is opened again; return new state, or nothing to keep it. Opening an
110
+ instance with input that maps to a different `persist.key` switches it to that
111
+ document.
112
+ - `ctx.source` tells an action whether the agent or the UI called it; `ctx.log()`
113
+ writes to the session log. Never use `console.log` in extension code.
114
+ - `ctx.sendToAgent({ prompt, displayPrompt?, mode? })` messages the agent, for
115
+ example when the user submits feedback from the canvas. Keep the prompt short and
116
+ put bulky details in a file under `ctx.dataDir`, the canvas's private folder, so
117
+ the agent reads them only when needed. In the dev server, messages are printed to
118
+ the Vite log; in tests they are recorded in `harness.sentToAgent`.
119
+ - Throw `new CanvasKitError(code, message)` for expected failures.
120
+ - `get_state` is added automatically unless you define your own.
121
+ - Set `versionFooter: false` to hide the version footer. The same information is
122
+ available to the UI as `getCanvasClient().release` if you render your own.
123
+ - Pages get a strict Content-Security-Policy: `default-src 'none'`, with scripts and
124
+ connections limited to the canvas's own origin. A canvas that must reach a
125
+ local service can add `scheme://host[:port]` sources to `connect-src` with
126
+ `csp: { connectSrc: ['ws://127.0.0.1:*'] }`. The sources apply to that canvas only;
127
+ keywords, paths, and bare wildcards are rejected. The dev server sets no CSP, so
128
+ check such a canvas in a built plugin too.
129
+ - When `persist` is set, the saved file carries a format marker and version. A
130
+ corrupt file or one with a different `persist.version` is reported, never
131
+ overwritten. Bump the version when the state shape changes incompatibly.
132
+ - `ctx.now()` is the current time in milliseconds. Use it instead of `Date.now()`
133
+ so tests can control the clock.
134
+
135
+ ## Shared documents and scheduled jobs
136
+
137
+ With `persist`, a document belongs to its key, not to a chat. It stays on disk
138
+ after the agent and the session end, and every chat that opens the same key works
139
+ on the same document:
140
+
141
+ - Each action, reopen, and job takes a lock on the document's file, reloads the
142
+ latest saved revision, runs, and saves the next revision. Changes from different
143
+ chats apply one at a time, so none is lost.
144
+ - Pages in every chat with the document open update live when another chat
145
+ changes it. The host watches the saved files, and also checks them every 2
146
+ seconds in case the operating system drops a change notification.
147
+ - The first open creates the document with `init()`, under the same lock, so
148
+ chats that open a new key at the same time start from the same state.
149
+ - A lock held by a process that exited is taken over at once; one that stops
150
+ being refreshed for 15 seconds is taken over as stale. A change that waits more
151
+ than 30 seconds for the lock fails with `busy`.
152
+
153
+ Jobs keep a document up to date on a schedule:
154
+
155
+ ```ts
156
+ export default defineCanvas({
157
+ // ...
158
+ persist: { key: ({ input }) => input.repo },
159
+ jobs: ({ job }) => ({
160
+ prune_triaged: job({
161
+ every: '1h', // 30s, 15m, 6h, 1d, or milliseconds
162
+ description: 'Remove tests triaged more than a week ago.',
163
+ run: (ctx) => {
164
+ const cutoff = ctx.now() - 7 * 24 * 60 * 60 * 1000
165
+ ctx.setState((s) => ({ ...s, tests: s.tests.filter((t) => !t.triagedAt || Date.parse(t.triagedAt) > cutoff) }))
166
+ },
167
+ }),
168
+ nudge: job({
169
+ every: '1d',
170
+ run: async (ctx) => {
171
+ if (ctx.headless) return // no agent under `canvas-kit run`
172
+ await ctx.sendToAgent({ prompt: `${ctx.state.tests.length} flaky tests are open in ${ctx.key}` })
173
+ },
174
+ }),
175
+ }),
176
+ })
177
+ ```
178
+
179
+ - While any chat has the canvas open, its due jobs run for each open document,
180
+ soon after opening and then on a timer. The last run time is saved in the
181
+ document and checked under the lock, so with several chats open a job still runs
182
+ once per interval, and an overdue job runs once to catch up.
183
+ - `ctx` has the state, `setState`, `sendToAgent`, `log`, `dataDir`, `now()`, the
184
+ document `key`, and `lastRunAt`. A job that throws has its changes rolled back
185
+ and its error saved with the run time; it runs again at the next interval.
186
+ - A job holds the document's lock while it runs, so actions in every chat wait
187
+ for it. Keep jobs short: fetch slow data before changing state, or do it in
188
+ several runs.
189
+ - Without `persist`, jobs run per instance while it is open.
190
+ - Job names are snake_case, and `every` is at least one second.
191
+
192
+ ### Letting people control jobs
193
+
194
+ Jobs are code, so users cannot write new ones. With `controls`, the UI, the agent,
195
+ or both can pause, resume, retime, and run a job, and add jobs from a job that
196
+ takes `input`:
197
+
198
+ ```ts
199
+ jobs: ({ job }) => ({
200
+ prune_triaged: job({
201
+ every: '1h',
202
+ controls: 'both', // 'ui', 'agent', or 'both'; omit to leave it to code
203
+ minEvery: '15m', // the range users may pick from; defaults to every..30d
204
+ maxEvery: '7d',
205
+ run: (ctx) => { /* ... */ },
206
+ }),
207
+ recurring_check: job({ // with input, users add jobs from it; it never runs on its own
208
+ every: '7d', // the default for added jobs
209
+ controls: 'both',
210
+ input: z.object({ text: z.string().min(1).max(300) }),
211
+ maxJobs: 10, // per document
212
+ run: (ctx, { text }) => { /* ... */ },
213
+ }),
214
+ }),
215
+ ```
216
+
217
+ ```tsx
218
+ const { useJobs } = createCanvasHooks<typeof canvas>()
219
+
220
+ function Schedule() {
221
+ const { jobs, definitions, update, run, add, remove, pending, error } = useJobs()
222
+ // jobs: [{ id: 'prune_triaged', every: 3600000, enabled: true, lastRunAt, nextRunAt, lastError, ... },
223
+ // { id: 'recurring_check-3f9a2c', job: 'recurring_check', input: { text }, added: true, ... }]
224
+ update('prune_triaged', { enabled: false }) // pause; { enabled: true } resumes
225
+ update('prune_triaged', { every: '6h' }) // within minEvery..maxEvery
226
+ run('prune_triaged') // now, even if paused
227
+ add('recurring_check', { text: 'Check security sign-off' }, { every: '1d' }) // typed input
228
+ remove('recurring_check-3f9a2c')
229
+ }
230
+ ```
231
+
232
+ - Settings and added jobs are saved in the document with the run history, so they
233
+ are shared live by every chat and used by `canvas-kit run`.
234
+ - A job's ID is its name, or `<name>-<hex>` for an added job. An added job runs
235
+ soon after it is added, then on its interval. Its input is checked against the
236
+ schema when it is added and before every run.
237
+ - The agent gets `list_jobs`, `update_job`, and `run_job` actions, plus `add_job`
238
+ and `remove_job` when a job takes input, covering only the jobs whose `controls`
239
+ include it. The UI calls the same actions through `useJobs()`.
240
+ - `formatInterval(ms)` turns an interval into text such as `6h`, and
241
+ `intervalMs('6h')` turns it back.
242
+
243
+ ### Running jobs with no chat open
244
+
245
+ Nothing in the session runs after it ends. To run jobs with no chat open, run
246
+ `canvas-kit run` on a schedule. It runs every job that is due and not paused, for
247
+ every saved document, including jobs users added. Name a job or ID to run only
248
+ it, pass `--key` for one document, or `--force` to run even if not due or paused.
249
+ Jobs run with `ctx.headless` set, where `sendToAgent` fails. It uses the same lock,
250
+ so it is safe while chats have the canvas open.
251
+
252
+ ```sh
253
+ # In a canvas package after `pnpm build`, or with --plugin for an installed plugin
254
+ npx canvas-kit run
255
+ npx canvas-kit run prune_triaged --plugin ~/.copilot/installed-plugins/my-marketplace/flaky-tests
256
+ ```
257
+
258
+ Run it at least as often as the shortest interval users may pick. For example,
259
+ every 15 minutes with cron (`crontab -e`):
260
+
261
+ ```text
262
+ */15 * * * * cd ~/canvases/flaky-tests && npx canvas-kit run >> /tmp/flaky-tests-jobs.log 2>&1
263
+ ```
264
+
265
+ Or with a launchd agent on macOS, in `~/Library/LaunchAgents/com.example.flaky-tests-jobs.plist`, loaded
266
+ with `launchctl load ~/Library/LaunchAgents/com.example.flaky-tests-jobs.plist`:
267
+
268
+ ```xml
269
+ <?xml version="1.0" encoding="UTF-8"?>
270
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
271
+ <plist version="1.0">
272
+ <dict>
273
+ <key>Label</key><string>com.example.flaky-tests-jobs</string>
274
+ <key>WorkingDirectory</key><string>/Users/me/canvases/flaky-tests</string>
275
+ <key>ProgramArguments</key>
276
+ <array><string>/opt/homebrew/bin/npx</string><string>canvas-kit</string><string>run</string></array>
277
+ <key>StartInterval</key><integer>900</integer>
278
+ </dict>
279
+ </plist>
280
+ ```
281
+
282
+ The job runs on your machine because the documents are there; hosted CI such as
283
+ GitHub Actions cannot reach them. Work that needs the agent needs a live chat.
284
+
285
+ ## Using it from React
286
+
287
+ ```tsx
288
+ import { createCanvasHooks } from '@rwoll/canvas-kit/react'
289
+ import type canvas from '../server/canvas.ts'
290
+
291
+ const { useCanvas, useAction } = createCanvasHooks<typeof canvas>()
292
+
293
+ function App() {
294
+ const { state, status } = useCanvas() // typed state, 'connecting' | 'live' | 'offline'
295
+ const [addBlocker, { pending, error }] = useAction('add_blocker')
296
+ // addBlocker({ text }) is type-checked against the action's Zod input
297
+ }
298
+ ```
299
+
300
+ `state` is available on the first render because the server embeds it in the
301
+ page. `useJobs()` shows and controls scheduled jobs; see
302
+ [Letting people control jobs](#letting-people-control-jobs). Import `@rwoll/canvas-kit/styles.css` for the shared light/dark theme.
303
+
304
+ UI code runs in the browser. The build fails if `src/` imports a Node.js module;
305
+ do that work in an action instead.
306
+
307
+ Keep canvas UIs flat and quiet, in the style of the Session summary canvas.
308
+ Separate sections with spacing or a single hairline instead of decorative
309
+ cards: no bordered or rounded containers, shadows, or outlined pills. Use plain
310
+ or muted text for metadata, and text links for secondary actions. A highlight
311
+ that helps people find the most important content, such as Session summary's
312
+ tinted executive summary, is the exception.
313
+
314
+ ## Sharing components and a theme
315
+
316
+ A marketplace's `ui/` package holds the React components and the theme its canvases
317
+ share. `canvas-kit init` creates it (`@<marketplace>/ui`), `canvas-kit ui` adds it to
318
+ an existing marketplace, and `canvas-kit new` makes each new canvas depend on it,
319
+ import its `theme.css`, and use its `Header`.
320
+
321
+ ```text
322
+ ui/package.json "name": "@my-canvases/ui", "exports": { ".": "./src/index.ts", "./theme.css": "./theme.css" }
323
+ ui/theme.css brand variables (--accent, --accent-contrast, ...) and component styles
324
+ ui/src/ Header, Markdown, Checklist, TextButton; add your own
325
+ ```
326
+
327
+ `theme.css` overrides the variables of `@rwoll/canvas-kit/styles.css`, so import it
328
+ second. It is plain TypeScript source in the workspace: each canvas's Vite build
329
+ bundles it, so the published plugins need nothing extra at run time.
330
+
331
+ ## Testing
332
+
333
+ Unit tests run the canvas on a real local server with a temporary data folder:
334
+
335
+ ```ts
336
+ import { createTestCanvas } from '@rwoll/canvas-kit/testing'
337
+
338
+ const harness = await createTestCanvas(canvas)
339
+ await harness.open('test', { repo: 'octo/app' })
340
+ await harness.invoke('test', 'add_blocker', { text: 'CI red' }, 'ui')
341
+ expect(harness.state('test')?.blockers).toEqual(['CI red'])
342
+ expect(harness.sentToAgent).toEqual([]) // what ctx.sendToAgent() sent
343
+ await harness.runJob('prune_triaged') // run a job now; tickJobs() runs only due ones
344
+ await harness.invoke('test', 'update_job', { id: 'prune_triaged', enabled: false }, 'ui') // as useJobs() does
345
+ expect(harness.jobs('test')?.[0]?.enabled).toBe(false)
346
+ await harness.close()
347
+ ```
348
+
349
+ Browser tests use Playwright fixtures against the **built** canvas, so they run
350
+ with the same Content-Security-Policy and bundling as the installed plugin.
351
+ Build first (`pnpm build`) and install Chromium once (`pnpm -r --workspace-concurrency=1 run playwright:install`).
352
+
353
+ ```ts
354
+ // e2e/board.spec.ts
355
+ import { canvasTest, expect } from '@rwoll/canvas-kit/playwright'
356
+ import canvas from '../server/canvas.ts'
357
+
358
+ const test = canvasTest(canvas)
359
+
360
+ test('the user and the agent share the board', async ({ openCanvas, agent, seed }) => {
361
+ await seed('octo/app', { repo: 'octo/app', blockers: [] }) // saved state, before opening
362
+ const { page, state, openAnotherPage } = await openCanvas({ repo: 'octo/app' })
363
+
364
+ await page.getByLabel('New blocker').fill('CI red')
365
+ await page.getByRole('button', { name: 'Add' }).click()
366
+ await agent.invoke('clear_resolved') // act as the agent
367
+ expect(agent.messages).toEqual([]) // what the canvas sent the agent
368
+ agent.failNextSend() // the next send fails
369
+ const other = await openAnotherPage() // a second view of the instance
370
+ })
371
+ ```
372
+
373
+ | Fixture | What it does |
374
+ | --- | --- |
375
+ | `openCanvas(input?, { instanceId? })` | Opens an instance, points `page` at it, and waits until it is live (`html[data-canvas-status="live"]`). Returns `page`, `url`, `state()`, and `openAnotherPage()`. |
376
+ | `agent` | `invoke(action, input?)` as the agent, `messages` sent with `ctx.sendToAgent()`, and `failNextSend()`. |
377
+ | `seed(key, state)` | Saves state for a `persist` key before the canvas opens. |
378
+ | `runJob(id, { key?, force?, headless? })` | Runs a job now, by name or added job ID, for the latest instance or a saved document with `key`. |
379
+ | `canvasHarness` | The underlying `createTestCanvas` harness. |
380
+
381
+ Jobs run only through `runJob` and `harness.tickJobs()` in tests. To test
382
+ time-based jobs, pass a clock to both harnesses, `createTestCanvas(canvas, { now })`
383
+ and `canvasTest(canvas, { now })`, and move it forward between steps.
384
+
385
+ `defineCanvasConfig(overrides?)` in `playwright.config.ts` runs `e2e/` in light,
386
+ dark, and high-contrast Chromium. Canvas UIs show server state, so after a click,
387
+ assert with web-first assertions such as `toBeChecked()` rather than `check()`,
388
+ which expects the change at the moment of the click. Vitest skips `e2e/`.
389
+
390
+ ## The canvas-kit command
391
+
392
+ A marketplace repository has `marketplace.config.json`, content-only plugins in
393
+ `plugins/<name>/` (skills, agents, MCP servers), and canvases in `canvases/<name>/`.
394
+
395
+ ```json
396
+ {
397
+ "name": "my-marketplace",
398
+ "owner": { "name": "me" },
399
+ "repository": "me/my-marketplace",
400
+ "publishBranch": "stable",
401
+ "sourceBranch": "main"
402
+ }
403
+ ```
404
+
405
+ | Command | What it does |
406
+ | --- | --- |
407
+ | `canvas-kit init <dir> --repository owner/name [--canvas name]` | Creates a marketplace repository with a shared `ui/` package (what `npm create @rwoll/canvas` runs). |
408
+ | `canvas-kit new <name> [--title] [--description] [--id] [--catalog] [--kit <range>]` | Adds a canvas package, wired to the shared UI package when the config names one. `--catalog` writes pnpm `catalog:` ranges. |
409
+ | `canvas-kit ui <package-name> [--catalog]` | Adds a shared UI package (`ui/`) to an existing marketplace and records it as `ui`. |
410
+ | `canvas-kit build` | Assembles `dist/marketplace` from `plugins/` and each canvas's `dist/plugin`, with a catalog generated from the plugin manifests. |
411
+ | `canvas-kit run [job] [--plugin] [--canvas] [--key] [--force] [--data-root]` | Runs a canvas's due jobs (or one job by name or ID) for its saved documents with no chat open, from a built plugin (`./dist/plugin` by default). Exits non-zero when a run fails. |
412
+ | `canvas-kit release [--dry-run] [--skip-checks]` | Builds the source branch in a clean worktree with a frozen-lockfile install, runs the `check` script (or `build`), and commits the output to the publish branch only when it changed. Each plugin gets a `release.json` stamp that the version footer shows. |
413
+
414
+ Optional config: `ui` (the shared UI package new canvases use), `pluginsDir`,
415
+ `canvasesDir`, `outDir`, `checkScript`, and
416
+ `installCommand`, `checkCommand`, or `buildCommand` to replace the detected
417
+ package manager commands. The library behind the command is
418
+ `@rwoll/canvas-kit/cli`.
419
+
420
+ ## GitHub Actions
421
+
422
+ Publish on every push with the reusable workflow, pinned to a release commit.
423
+ `canvas-kit init` writes this for you with the commit your CLI was released from:
424
+
425
+ ```yaml
426
+ # .github/workflows/publish.yml
427
+ on:
428
+ push:
429
+ branches: [main]
430
+ permissions: {}
431
+ jobs:
432
+ publish:
433
+ permissions:
434
+ contents: write
435
+ uses: rwoll/canvas-kit/.github/workflows/marketplace.yml@<commit> # v0.1.0
436
+ with:
437
+ playwright: true # when your check script runs browser tests
438
+ environment: stable # optional: approve each publish
439
+ ```
440
+
441
+ It runs two jobs, so your dependencies never see a token that can write:
442
+
443
+ - **build** (read-only token) installs with the frozen lockfile, runs your
444
+ `check` script in a clean worktree, and prepares the next publish-branch commit
445
+ as a git bundle when the output changed.
446
+ - **push** (`contents: write`) runs no project code. It checks that the bundle is
447
+ one commit on top of the publish branch, built from this run's commit on your
448
+ source branch and newer than what is published, then fast-forwards the branch.
449
+
450
+ Inputs: `working-directory` (the folder with `marketplace.config.json`),
451
+ `node-version`, `playwright`, `dry-run`, `environment` (a GitHub environment for
452
+ the push job; give it required reviewers and limit it to your source branch),
453
+ `source-branch` (defaults to the repository's default branch), and
454
+ `publish-branch` (defaults to `stable`; the push job writes only that branch). Neither job
455
+ restores a dependency cache. Pin the workflow to a full commit SHA and let
456
+ Dependabot update it; `canvas-kit init` adds a `dependabot.yml` that does. The
457
+ workflow's own actions are locked in this repository's `actions.lock`, so it runs
458
+ under a **Require lockfile** policy.
459
+
460
+ Lock your marketplace's actions too: run
461
+ [`gh actions-lock`](https://github.com/github/gh-actions-lock) after `canvas-kit init`
462
+ and whenever you change a `uses:`, and commit `.github/workflows/actions.lock`. The
463
+ scaffolded `check.yml` verifies it on every pull request once it exists.
464
+
465
+ ## Entry points
466
+
467
+ | Import | Runs in | Purpose |
468
+ | --- | --- | --- |
469
+ | `@rwoll/canvas-kit` | Node.js | `defineCanvas`, `z`, `CanvasKitError`, types |
470
+ | `@rwoll/canvas-kit/react` | Browser | `useCanvas`, `useAction`, `useJobs`, `createCanvasHooks`, `formatInterval` |
471
+ | `@rwoll/canvas-kit/client` | Browser | Framework-free `CanvasClient` |
472
+ | `@rwoll/canvas-kit/testing` | Node.js | `createTestCanvas` |
473
+ | `@rwoll/canvas-kit/playwright` | Playwright | `canvasTest`, `defineCanvasConfig`, `expect` |
474
+ | `@rwoll/canvas-kit/cli` | Node.js | `initMarketplace`, `newCanvas`, `buildMarketplace`, `release`, `runJob` |
475
+ | `@rwoll/canvas-kit/server` | Node.js | `CanvasHost`, `StateStore`, `runCanvasJob`, `copilotHome` |
476
+ | `@rwoll/canvas-kit/extension` | Node.js | SDK adapter used by the generated entry |
477
+ | `@rwoll/canvas-kit/vite` | Build | `canvasKit()` Vite plugin |
478
+ | `@rwoll/canvas-kit/styles.css` | Browser | Base theme |
479
+ | `@rwoll/canvas-kit/tsconfig/*.json` | Build | Shared TypeScript settings |
480
+
481
+ `react`, `vite`, `@playwright/test`, and `@github/copilot-sdk` are peer
482
+ dependencies; install the ones you use. The Copilot host supplies the SDK at run
483
+ time, so it is needed only for types.
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ import { basename, resolve } from 'node:path';
3
+ import { parseArgs } from 'node:util';
4
+ import { CliError, addUiPackage, buildMarketplace, initMarketplace, kitVersion, newCanvas, release, runJob } from './lib.mjs';
5
+
6
+ const USAGE = `Usage: canvas-kit <command> [options]
7
+
8
+ Commands:
9
+ init <dir> --repository <owner/name> [--name <name>] [--owner <name>] [--canvas <name>]
10
+ Create a plugin marketplace repository with a shared ui/ package, optionally with a first canvas.
11
+ new <name> [--title <text>] [--description <text>] [--id <canvas-id>] [--catalog] [--kit <range>]
12
+ Add a canvas package under canvases/. --catalog writes pnpm catalog: ranges;
13
+ --kit sets the @rwoll/canvas-kit range, for example workspace:*. New canvases use the
14
+ marketplace's shared UI package when marketplace.config.json names one.
15
+ ui <package-name> [--catalog]
16
+ Add a shared package of React components and a theme (ui/) that new canvases use.
17
+ build
18
+ Assemble dist/marketplace from plugins/ and the built canvases.
19
+ release [--dry-run] [--skip-checks] [--footer <text>]... [--bundle <file> [--github-output]]
20
+ Build the source branch in a clean worktree and publish it to the publish branch.
21
+ With --bundle, write the prepared commit to a git bundle instead of pushing it;
22
+ --github-output also writes the commit and branch step outputs.
23
+ run [job] [--plugin <folder>] [--canvas <name>] [--key <key>] [--force] [--data-root <folder>]
24
+ Run a canvas's due jobs for its saved documents, with no chat open: every job,
25
+ or one job by name or ID. For launchd, cron, or Task Scheduler. --plugin
26
+ defaults to ./dist/plugin.
27
+
28
+ Options:
29
+ --cwd <dir> Marketplace folder (default: current folder)
30
+ -h, --help Show this help
31
+ -v, --version Show the canvas-kit version
32
+
33
+ The marketplace folder holds marketplace.config.json.`;
34
+
35
+ async function main(argv) {
36
+ const { values, positionals } = parseArgs({
37
+ args: argv,
38
+ allowPositionals: true,
39
+ options: {
40
+ cwd: { type: 'string' },
41
+ help: { type: 'boolean', short: 'h' },
42
+ version: { type: 'boolean', short: 'v' },
43
+ title: { type: 'string' },
44
+ description: { type: 'string' },
45
+ id: { type: 'string' },
46
+ catalog: { type: 'boolean' },
47
+ kit: { type: 'string' },
48
+ name: { type: 'string' },
49
+ owner: { type: 'string' },
50
+ repository: { type: 'string' },
51
+ canvas: { type: 'string' },
52
+ 'dry-run': { type: 'boolean' },
53
+ 'skip-checks': { type: 'boolean' },
54
+ footer: { type: 'string', multiple: true },
55
+ bundle: { type: 'string' },
56
+ 'github-output': { type: 'boolean' },
57
+ plugin: { type: 'string' },
58
+ key: { type: 'string' },
59
+ force: { type: 'boolean' },
60
+ 'data-root': { type: 'string' },
61
+ },
62
+ });
63
+ const [command, ...rest] = positionals;
64
+ if (values.version) return console.log(await kitVersion());
65
+ if (values.help || !command) return console.log(USAGE);
66
+ const dir = resolve(values.cwd ?? '.');
67
+
68
+ switch (command) {
69
+ case 'init': {
70
+ const [target] = rest;
71
+ if (!target) throw new CliError('init needs a folder, for example `canvas-kit init my-marketplace --repository me/my-marketplace`');
72
+ if (!values.repository) throw new CliError('init needs --repository <owner/name>');
73
+ const folder = resolve(dir, target);
74
+ const name = values.name ?? basename(folder);
75
+ const owner = values.owner ?? values.repository.split('/')[0];
76
+ const { canvas } = await initMarketplace(folder, { name, owner, repository: values.repository, canvas: values.canvas });
77
+ console.log(`Created ${name} in ${folder}${canvas ? ` with the ${canvas.name} canvas` : ''}.\n\nNext:\n cd ${target}\n pnpm install\n pnpm check`);
78
+ return;
79
+ }
80
+ case 'new': {
81
+ const [name] = rest;
82
+ if (!name) throw new CliError('new needs a canvas name, for example `canvas-kit new release-board`');
83
+ const canvas = await newCanvas(dir, name, {
84
+ title: values.title,
85
+ description: values.description,
86
+ id: values.id,
87
+ dependencies: values.catalog ? 'catalog' : 'ranges',
88
+ kitRange: values.kit,
89
+ });
90
+ console.log(`Created ${canvas.dir} (canvas id "${canvas.id}"). Run your package manager's install, then its dev script.`);
91
+ return;
92
+ }
93
+ case 'ui': {
94
+ const [name] = rest;
95
+ if (!name) throw new CliError('ui needs a package name, for example `canvas-kit ui @acme/ui`');
96
+ const ui = await addUiPackage(dir, name, { dependencies: values.catalog ? 'catalog' : 'ranges' });
97
+ console.log(
98
+ `Created ${ui.dir} (${ui.name}) and recorded it in marketplace.config.json.` +
99
+ (ui.workspaceUpdated ? ' Added it to pnpm-workspace.yaml.' : ' Add its folder to your workspace packages.') +
100
+ `\nImport ${ui.name}/theme.css in each canvas's src/main.tsx; new canvases do this for you.`,
101
+ );
102
+ return;
103
+ }
104
+ case 'build': {
105
+ const { catalog, outDir } = await buildMarketplace(dir);
106
+ console.log(`Built ${catalog.name} with ${catalog.plugins.length} plugins in ${outDir}:`);
107
+ for (const plugin of catalog.plugins) console.log(` ${plugin.name}@${plugin.version}`);
108
+ return;
109
+ }
110
+ case 'run': {
111
+ const [job] = rest;
112
+ const outcomes = await runJob(dir, job, {
113
+ plugin: values.plugin,
114
+ canvas: values.canvas,
115
+ key: values.key,
116
+ force: values.force,
117
+ dataRoot: values['data-root'],
118
+ log: (message, level) => console.error(`[${level}] ${message}`),
119
+ });
120
+ if (outcomes.length === 0) console.log('Nothing to run: no saved document has this job yet.');
121
+ for (const outcome of outcomes) {
122
+ const result = outcome.error ? `failed: ${outcome.error}` : outcome.ran ? 'ran' : outcome.paused ? 'paused' : 'not due';
123
+ console.log(`${outcome.key} ${outcome.id ?? outcome.job}: ${result}`);
124
+ }
125
+ if (outcomes.some(outcome => outcome.error)) process.exitCode = 1;
126
+ return;
127
+ }
128
+ case 'release':
129
+ await release(dir, {
130
+ dryRun: values['dry-run'],
131
+ skipChecks: values['skip-checks'],
132
+ footer: values.footer ?? [],
133
+ bundle: values.bundle,
134
+ githubOutput: values['github-output'],
135
+ });
136
+ return;
137
+ default:
138
+ throw new CliError(`unknown command "${command}"\n\n${USAGE}`);
139
+ }
140
+ }
141
+
142
+ main(process.argv.slice(2)).catch(error => {
143
+ if (error instanceof CliError || error?.code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION') {
144
+ console.error(`canvas-kit: ${error.message}`);
145
+ process.exit(1);
146
+ }
147
+ throw error;
148
+ });