@neurosquad/card-sdk 1.0.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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +431 -0
  3. package/dist/card-sdk.js +5266 -0
  4. package/dist/cli.js +2838 -0
  5. package/dist/react.js +257 -0
  6. package/dist/testing.js +1150 -0
  7. package/dist/types/client/base64.d.ts +7 -0
  8. package/dist/types/client/card.d.ts +653 -0
  9. package/dist/types/client/channel.d.ts +49 -0
  10. package/dist/types/client/coalesce.d.ts +14 -0
  11. package/dist/types/client/connect.d.ts +39 -0
  12. package/dist/types/client/errors.d.ts +46 -0
  13. package/dist/types/client/helpers.d.ts +25 -0
  14. package/dist/types/client/net.d.ts +111 -0
  15. package/dist/types/client/theme.d.ts +31 -0
  16. package/dist/types/client/toolResult.d.ts +16 -0
  17. package/dist/types/contract/api.d.ts +812 -0
  18. package/dist/types/contract/index.d.ts +11 -0
  19. package/dist/types/contract/jsonSchema.d.ts +88 -0
  20. package/dist/types/contract/localized.d.ts +10 -0
  21. package/dist/types/contract/manifest.d.ts +179 -0
  22. package/dist/types/contract/network.d.ts +48 -0
  23. package/dist/types/contract/permissions.d.ts +180 -0
  24. package/dist/types/contract/ports.d.ts +237 -0
  25. package/dist/types/contract/protocol.d.ts +74 -0
  26. package/dist/types/contract/source.d.ts +111 -0
  27. package/dist/types/contract/theme.d.ts +21 -0
  28. package/dist/types/contract/version.d.ts +126 -0
  29. package/dist/types/i18n.d.ts +50 -0
  30. package/dist/types/index.d.ts +28 -0
  31. package/dist/types/react/index.d.ts +132 -0
  32. package/dist/types/testing/index.d.ts +7 -0
  33. package/dist/types/testing/mockHost.d.ts +315 -0
  34. package/dist/types/version.d.ts +2 -0
  35. package/dist/ui.css +581 -0
  36. package/package.json +77 -0
  37. package/schema/neurosquad-card.v1.json +434 -0
  38. package/templates/react/README.md +34 -0
  39. package/templates/react/_gitignore +10 -0
  40. package/templates/react/icon.png +0 -0
  41. package/templates/react/index.html +12 -0
  42. package/templates/react/neurosquad-card.json +94 -0
  43. package/templates/react/package.json +25 -0
  44. package/templates/react/src/App.tsx +131 -0
  45. package/templates/react/src/i18n.ts +61 -0
  46. package/templates/react/src/main.tsx +77 -0
  47. package/templates/react/src/styles.css +42 -0
  48. package/templates/react/tsconfig.json +18 -0
  49. package/templates/react/vite.config.ts +19 -0
  50. package/templates/vanilla/README.md +37 -0
  51. package/templates/vanilla/_gitignore +6 -0
  52. package/templates/vanilla/icon.png +0 -0
  53. package/templates/vanilla/index.html +41 -0
  54. package/templates/vanilla/main.js +256 -0
  55. package/templates/vanilla/neurosquad-card.json +94 -0
  56. package/templates/vanilla/style.css +41 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NeuroSquad
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,431 @@
1
+ # @neurosquad/card-sdk
2
+
3
+ Build **custom cards** for [NeuroSquad](https://neurosquad.ai) — the desktop
4
+ canvas for CLI coding agents. A card is a small web app that lives on the
5
+ canvas next to agents, terminals and notes. It can watch and prompt the agents
6
+ it is connected to, exchange typed data with other cards over arrows, offer
7
+ tools to agents over MCP, keep its own storage and settings, and reach the
8
+ network through the app's proxy.
9
+
10
+ This package has everything for it:
11
+
12
+ | Import | What |
13
+ | --- | --- |
14
+ | `@neurosquad/card-sdk` | `connect()` and the typed `Card` client, errors, theme, i18n helper, the full contract (types, validators) |
15
+ | `@neurosquad/card-sdk/react` | `CardProvider` and hooks |
16
+ | `@neurosquad/card-sdk/testing` | `createMockHost()` — an in-memory host for unit tests and browser previews |
17
+ | `@neurosquad/card-sdk/ui.css` | optional dark UI kit on the app's live theme |
18
+ | `@neurosquad/card-sdk/schema.json` | JSON Schema of `neurosquad-card.json` |
19
+ | `neurosquad-card` (bin) | `create`, `dev`, `validate`, `pack` |
20
+
21
+ Zero runtime dependencies. React is an optional peer (18.2+ or 19).
22
+
23
+ ## Quick start
24
+
25
+ ```sh
26
+ npx @neurosquad/card-sdk create my-card # no build step
27
+ npx @neurosquad/card-sdk create my-card --template react
28
+ cd my-card
29
+ npx @neurosquad/card-sdk dev # live in the app
30
+ ```
31
+
32
+ `dev` needs NeuroSquad running with **Settings → Custom cards → Developer
33
+ mode** on. It links the folder (you confirm in the app), reloads the card when
34
+ you save, and prints the card's log. Add the card from the canvas: **+ →
35
+ Custom card…**.
36
+
37
+ Share a card by pushing its folder to GitHub. Users install it by pasting
38
+ `owner/repo` (or `owner/repo/sub/folder`, `…@tag`) into **Settings → Custom
39
+ cards**; they see the permissions it asks for and approve them. Updates are
40
+ never automatic: every new commit is shown with the permission changes and
41
+ applied by a click.
42
+
43
+ ## The card and the manifest
44
+
45
+ A card folder has a `neurosquad-card.json` at its root:
46
+
47
+ ```json
48
+ {
49
+ "$schema": "./node_modules/@neurosquad/card-sdk/schema/neurosquad-card.v1.json",
50
+ "manifestVersion": 1,
51
+ "name": "test-radar",
52
+ "displayName": { "en": "Test radar", "ru": "Радар тестов" },
53
+ "version": "1.0.0",
54
+ "protocol": 1,
55
+ "icon": "icon.png",
56
+ "entry": "dist/index.html",
57
+ "card": { "defaultSize": { "w": 460, "h": 340 } },
58
+ "permissions": ["agents.read", "terminals.write", { "id": "network", "hosts": ["api.github.com"] }],
59
+ "settings": [{ "key": "command", "type": "string", "label": "Test command", "default": "npm test" }],
60
+ "ports": {
61
+ "inputs": [{ "id": "run", "label": "Run", "type": "ns:trigger" }],
62
+ "outputs": [{ "id": "failures", "label": "Failures", "type": "ns:tasks", "retain": true }]
63
+ },
64
+ "tools": [
65
+ {
66
+ "name": "run_tests",
67
+ "description": "Runs the project's tests and returns the failing ones.",
68
+ "inputSchema": { "type": "object", "properties": { "filter": { "type": "string" } } }
69
+ }
70
+ ]
71
+ }
72
+ ```
73
+
74
+ `npx @neurosquad/card-sdk validate` checks it with the app's own validators and
75
+ shows the install dialog your users will see.
76
+
77
+ Rules the app enforces on the manifest:
78
+
79
+ - **Text** (names, descriptions, labels, reasons, tool descriptions) may not
80
+ contain control characters (tab and newline only in descriptions), bidi
81
+ overrides or isolates, zero-width characters or Unicode tag characters.
82
+ Only cards from the official organization may call themselves "NeuroSquad",
83
+ "official" or "verified" (`validate` warns; the app refuses).
84
+ - **Reserved names**: `name` may not be a built-in card or tool family
85
+ (`telegram`, `squad`, `ports`, `watch`, `reference`, `timer`, `sticky`,
86
+ `standup`, `image`, `mcp`, `skill`, `canvas`, `browser`, `terminal`,
87
+ `agent`, `note`, `todo`, `kanban`, …). A tool's exposed name
88
+ (`<name with - → _>_<tool>`) may not equal a built-in tool
89
+ (`canvas_spawn_card`, `terminal_send_keys`, …) and may not contain `__`
90
+ (reserved for installed MCP servers).
91
+ - **Schemas** (ports, tools): `enum` at most 256 options and 16 KB in total,
92
+ `const` at most 4 KB.
93
+ - **Updates**: a new version that adds or rewords MCP tools, adds or retypes
94
+ ports, adds secret settings or changes `displayName`/`author`/`homepage`
95
+ asks the user again, like a new permission does.
96
+
97
+ ### The sandbox
98
+
99
+ The card runs in a sandboxed frame with its own opaque origin:
100
+
101
+ - no `window.api`, no Node, no access to the app, other cards, `localStorage`
102
+ or cookies — use `card.storage`;
103
+ - **no inline scripts** and **no external resources**: ship scripts, styles,
104
+ fonts and images inside the package (data: and blob: images work);
105
+ - no popups, `alert`/`confirm`, downloads or fullscreen — use `card.ui.confirm`,
106
+ `card.openLink`;
107
+ - `<form>` submit events fire (the frame has `allow-forms`), but submission
108
+ itself never navigates: the host cancels every submission and makes
109
+ `form.submit()` a no-op (CSP `form-action 'none'` backs it up). Call
110
+ `event.preventDefault()` in your submit handler anyway and do the work in JS;
111
+ - **never trust `window` `message` events.** Any other card on the canvas can
112
+ `postMessage` your frame. The SDK's port (handed over by the app from
113
+ `window.parent`) is the only trusted channel;
114
+ - copying: `copy`/`cut` event handlers and `document.execCommand('copy')` do
115
+ nothing (a card could otherwise replace the clipboard on a click). Write
116
+ text with `card.copyText` (needs `clipboard.write`); the user's own copy of
117
+ selected text still works;
118
+ - no nested frames (`iframe`, `object`, `embed` are removed), and only blob
119
+ workers: `new Worker('x.js')` fails in an opaque origin, so fetch the script,
120
+ wrap it in a `Blob` and start the worker from `URL.createObjectURL`;
121
+ - host prompts (`card.ui.confirm`, permission requests, link confirmations)
122
+ are drawn by the app **outside** your card, over a window-wide backdrop. The
123
+ confirm dialog shows your `title`/`message` as quoted card text under the
124
+ app's own heading, and the cancel button is always the app's. **Secrets are
125
+ never typed inside a card**: the user enters them in the app's own settings
126
+ dialog, and your card only sees `settings.secrets[key] === true`. Never
127
+ draw a field that asks for a key — users are told a real prompt dims the
128
+ whole window;
129
+ - network only through `card.net.fetch`, only to hosts in your `network`
130
+ permission (or localhost with `network.local`);
131
+ - the frame is paused and eventually unloaded when nobody sees it — save state
132
+ in `card.lifecycle.onSuspend`.
133
+
134
+ ## `connect()`
135
+
136
+ ```ts
137
+ import { connect } from '@neurosquad/card-sdk'
138
+
139
+ const card = await connect()
140
+ ```
141
+
142
+ Import the SDK from your entry script: it listens for the app's handshake from
143
+ the moment it loads. `connect()` also applies the app theme to `<html>` as
144
+ `--ns-*` CSS variables and keeps `<html lang>` equal to the app language
145
+ (`connect({ theme: false, syncLang: false })` to opt out).
146
+
147
+ Everything the host knows about the card is in `card.context` (kept current):
148
+ `instance`, `workspace`, `visibility`, `expanded`, `theme`, `i18n`,
149
+ `settings`, `permissions`, `ports`, `peers`, `launch`, `spawnInit`, `limits`,
150
+ and `chrome` — the title, status, badge, overview and attention the app still
151
+ shows for this card. Chrome survives frame reloads (dev reload, update,
152
+ resume), so after a reload check `card.context.chrome?.attention` and clear a
153
+ stale pulse with `card.attention('none')`.
154
+
155
+ Chrome updates are throttled by the app (`card.setStatus`/`setBadge`/
156
+ `setOverview` 120 per minute, `setTitle` and `ui.setMenu` 30, `attention`
157
+ once per 10 s, `settings.set` 60, `tools.setEnabled` 30, `ui.confirm` 10,
158
+ `usage.summary` 6). For `setTitle`/`setStatus`/`setBadge`/`setOverview`/
159
+ `attention` the SDK sends one call at a time per method, folds calls made
160
+ meanwhile into one with the latest value, and when the app answers
161
+ `RATE_LIMITED` it retries the latest value after `retryAfterMs` — so calling
162
+ them on every render is fine and never throws for rate. The other methods
163
+ reject with `RATE_LIMITED` (`error.retryAfterMs`).
164
+
165
+ ### Any method, any event
166
+
167
+ ```ts
168
+ const info = await card.call('card.getInfo') // typed params and result
169
+ const off = card.on('agents.turn', (turn) => console.log(turn)) // typed payload
170
+ ```
171
+
172
+ Topics (`agents.status`, `agents.turn`, `agents.changed`, `storage.changed`) are
173
+ subscribed with the host automatically while a listener exists.
174
+
175
+ ### Namespaces
176
+
177
+ | | |
178
+ | --- | --- |
179
+ | Card chrome | `setTitle`, `setStatus(text, { tone, busy })`, `setBadge(3)`, `setOverview({ primary, secondary, progress, tone, icon })`, `attention('needs-input', msg)`, `requestResize`, `openLink`, `focusCard`, `spawn('note')` |
180
+ | `card.ui` | `toast`, `confirm` → boolean, `setMenu([{ id, label, icon, onSelect }])`, `onMenu` |
181
+ | `card.storage` | `get(key, fallback)`, `set`, `delete`, `keys(prefix)`, `clear`, `usage`; `card.storage.package.*` shared by every card of the package; `onChange` |
182
+ | `card.settings` | `values`, `value(key)`, `hasSecret(key)`, `set(values)`, `open()`, `onChange` |
183
+ | `card.agents` | `list`, `get`, `readScreen`, `lastReply`, `prompt(id, text, { whenBusy, submit })`, `onStatus`, `onTurn`, `onChanged`, `onOutput(ids, handler)` |
184
+ | `card.terminals` | `run(id, command)` → `{ exitCode, output }`, `write(id, text, { submit })` |
185
+ | `card.ports` | `inputs`, `outputs`, `peers`, `emit(output, data)`, `send`, `request`, `read`, `onMessage`, `onRequest(input, handler)`, `onPeersChanged` |
186
+ | `card.tools` | `handle(name, handler)`, `setEnabled` |
187
+ | `card.net` | `fetch(url, init)` → `CardResponse` (`ok`, `status`, `headers`, `text()`, `json()`, `bytes()`, `chunks()`, `lines()`) |
188
+ | `card.fs` | `stat`, `list`, `readText`, `readBytes`, `writeText`, `writeBytes`, `mkdir`, `trash`, `watch` — paths relative to the workspace folder |
189
+ | `card.permissions` | `all`, `has(id)`, `request(...ids)` for optional ones, `onChange` |
190
+ | `card.lifecycle` | `onVisibility`, `onSuspend`, `onExpanded`, `onResized` |
191
+ | `card.log` | `debug/info/warn/error` → the package log and the `dev` console |
192
+ | Other | `usage(period)`, `copyText`, `getWorkspace`, `host.capabilities()`, `host.supports(method)`, `waitFor(event)`, `close()` |
193
+
194
+ ### Tools for agents
195
+
196
+ Declare the tool in the manifest, implement it in the card. Connected agents
197
+ see it as `<card_name>_<tool>` over the app's MCP server.
198
+
199
+ ```ts
200
+ card.tools.handle<{ filter?: string }>('run_tests', async ({ filter }, call) => {
201
+ call.progress('running…') // shown on the arrow
202
+ const result = await card.terminals.run(shellId, `npm test -- ${filter ?? ''}`, { timeoutMs: 60_000 })
203
+ return result.exitCode === 0 ? 'All tests passed' : result.output
204
+ })
205
+ ```
206
+
207
+ Return a string, any JSON value, or a `ToolResultPayload` (`toolText`,
208
+ `toolImage`, `toolError` help). Throwing sends an error to the agent. Calls
209
+ that arrive before `handle()` is registered wait for it.
210
+
211
+ ### Ports
212
+
213
+ An arrow from card A to card B carries A's outputs to B's inputs. Types are
214
+ `ns:*` well-known types (`ns:text`, `ns:markdown`, `ns:tasks`, `ns:table`,
215
+ `ns:event`, `ns:trigger`, …) or `<your-card>/<type>` with a schema. Built-in
216
+ cards take part too: a note accepts `ns:markdown` (append), a checklist
217
+ `ns:tasks`, an agent a prompt, a terminal a command.
218
+
219
+ ```ts
220
+ await card.ports.emit('failures', [{ text: 'auth › logs in', status: 'error' }]) // a note gets a Markdown list
221
+ card.ports.onMessage((text) => render(text), { input: 'text' })
222
+ card.ports.onRequest<string>('lookup', async (query) => search(query))
223
+ ```
224
+
225
+ `ports.message` carries the input's `type` (after conversion) and the
226
+ sender's declared output type in `sourceType`. Generic parameters accept your
227
+ own interfaces (`card.ports.onMessage<Task>(…)`).
228
+
229
+ Sending to a built-in card needs a permission (`PortInfo.permission`, e.g.
230
+ `cards.connected` for a note). Helpers:
231
+
232
+ ```ts
233
+ import { hasDownstreamPeer, permissionForPeer, requestPermissions } from '@neurosquad/card-sdk'
234
+
235
+ if (!hasDownstreamPeer(card.ports.peers)) return hint('Draw an arrow to a note')
236
+ const needed = card.ports.peers.map((p) => permissionForPeer(p, { outputType: 'ns:markdown' }))
237
+ await requestPermissions(card, ...needed.filter((id) => id !== null)) // never throws
238
+ await card.ports.emit('summary', text)
239
+ ```
240
+
241
+ ### Network
242
+
243
+ ```ts
244
+ const res = await card.net.fetch('https://api.github.com/repos/acme/app/issues', {
245
+ headers: { authorization: 'Bearer {{secret:githubToken}}' }, // a "secret" setting, filled in by the user
246
+ responseType: 'json'
247
+ })
248
+ if (!res.ok) throw new Error(`GitHub said ${res.status}`)
249
+ const issues = await res.json<{ title: string }[]>()
250
+
251
+ const stream = await card.net.fetch(url, { responseType: 'stream' })
252
+ for await (const line of stream.lines()) handle(line)
253
+ ```
254
+
255
+ The card never sees secret values: the host puts them into headers for granted
256
+ hosts only.
257
+
258
+ Conditional requests work: `If-None-Match` and `If-Modified-Since` are
259
+ forwarded, and every response header except `set-cookie` comes back
260
+ (`etag`, `last-modified`, `x-ratelimit-*`). A `204`/`304` (any empty body)
261
+ with `responseType: 'json'` gives `json() === null`.
262
+
263
+ ### Files
264
+
265
+ `card.fs` (`fs.read`/`fs.write`) reaches only the workspace folder. Writes,
266
+ folders and trash are refused for paths that would let a card run code or
267
+ steer agents: any `.git` path segment (nested repositories, submodule and
268
+ worktree pointer files), `.gitmodules`, `.gitattributes`, `.claude/`,
269
+ `.codex/`, `.cursor/`, `.vscode/`, `.idea/`, `.husky/`, `.github/workflows/`,
270
+ `.gemini/`, `.qwen/`, `.opencode/`, `.mcp.json`, `CLAUDE.md`,
271
+ `CLAUDE.local.md`, `AGENTS.md`, `GEMINI.md`, `QWEN.md`, `.cursorrules`,
272
+ `.windsurfrules`, `opencode.json`, `.envrc`, `.npmrc`, `.yarnrc`, `.yarnrc.yml`
273
+ (`FS_DENIED`). All of `fs.*` is refused when the workspace folder is the home
274
+ folder, a drive root, or contains the app's own data folder.
275
+
276
+ ### Errors
277
+
278
+ Every refusal is a `CardSdkError` with a `code` (`PERMISSION_DENIED`,
279
+ `NOT_CONNECTED`, `NOT_VISIBLE`, `RATE_LIMITED`, `HOST_NOT_ALLOWED`,
280
+ `BUDGET_PAUSED`, …), the failing `method`, `data` and a `hint`:
281
+
282
+ ```ts
283
+ try {
284
+ await card.agents.prompt(agentId, 'Run the tests')
285
+ } catch (error) {
286
+ if (isCardSdkError(error, 'NOT_CONNECTED')) card.ui.toast('Connect me to an agent first')
287
+ else throw error
288
+ }
289
+ ```
290
+
291
+ Params are checked locally against the same schemas the app uses, so mistakes
292
+ fail fast with `INVALID_PARAMS` and the exact path.
293
+
294
+ ## React
295
+
296
+ ```tsx
297
+ import { CardProvider, useAgents, useStorage, useTool, useTranslator, usePaused } from '@neurosquad/card-sdk/react'
298
+
299
+ function App() {
300
+ const { agents } = useAgents()
301
+ const [notes, setNotes] = useStorage('notes', '')
302
+ useTool('read_notes', () => notes)
303
+ return <textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
304
+ }
305
+
306
+ createRoot(root).render(<CardProvider fallback={<p>Connecting…</p>}><App /></CardProvider>)
307
+ ```
308
+
309
+ Hooks: `useCard`, `useCardContext`, `useSettings`, `useStorage`, `useAgents`,
310
+ `useAgentStatus`, `usePort`, `useEmit`, `usePeers`, `useTheme`, `useLanguage`,
311
+ `useVisibility`, `useExpanded`, `usePaused`, `useTool`, `usePortRequest`,
312
+ `useCardEvent`, `useTranslator`.
313
+
314
+ ## Languages
315
+
316
+ The app speaks English, Russian and Chinese and switches live.
317
+
318
+ ```ts
319
+ const t = createTranslator({
320
+ en: { failed_one: '{{count}} test failed', failed_other: '{{count}} tests failed' },
321
+ ru: { failed_one: '{{count}} тест упал', failed_few: '{{count}} теста упало', failed_many: '{{count}} тестов упало', failed_other: '{{count}} теста упало' }
322
+ }, card)
323
+ t('failed', { count: 3 })
324
+ ```
325
+
326
+ Manifest texts (`displayName`, labels, descriptions) take `"text"` or
327
+ `{ "en": …, "ru": …, "zh": … }`.
328
+
329
+ ## Styling
330
+
331
+ Cards style themselves any way they like. For a native look, the optional kit
332
+ reads the app's live theme:
333
+
334
+ ```html
335
+ <link rel="stylesheet" href="vendor/ui.css" /> <!-- or import '@neurosquad/card-sdk/ui.css' -->
336
+ <body class="ns-kit">
337
+ <button class="ns-btn ns-btn--primary">Run</button>
338
+ <input class="ns-input" placeholder="Filter" />
339
+ <span class="ns-badge ns-badge--success">passing</span>
340
+ </body>
341
+ ```
342
+
343
+ Classes: `ns-kit`, `ns-stack`, `ns-row`, `ns-spread`, `ns-scroll`, `ns-surface`,
344
+ `ns-title`, `ns-subtitle`, `ns-muted`, `ns-mono`, `ns-btn` (`--primary`,
345
+ `--secondary`, `--outline`, `--ghost`, `--danger`, `--sm`, `--lg`, `--icon`),
346
+ `ns-field`, `ns-label`, `ns-input`, `ns-textarea`, `ns-select`, `ns-switch`,
347
+ `ns-list`, `ns-list-item`, `ns-badge--*`, `ns-dot--*`, `ns-spinner`,
348
+ `ns-progress`, `ns-empty`, `ns-callout`. Variables: `--ns-<token>` for every
349
+ `THEME_TOKENS` entry plus `--ns-font-sans`, `--ns-font-mono`.
350
+
351
+ ## Testing
352
+
353
+ ```ts
354
+ import { createMockHost } from '@neurosquad/card-sdk/testing'
355
+
356
+ const host = createMockHost({ manifest, agents: [...], peers: [...], fetch: (req) => ({ body: { ok: true } }) })
357
+ const card = await host.connect()
358
+
359
+ await card.ports.emit('summary', '# Done')
360
+ expect(host.deliveries).toEqual([{ to: 'note-1', output: 'summary', input: 'append', data: '# Done' }])
361
+ await expect(host.callTool('run_tests', {})).resolves.toEqual({ content: [{ type: 'text', text: 'All tests passed' }] })
362
+ ```
363
+
364
+ The mock applies the app's checks in the app's order (params schemas,
365
+ permissions, visibility, optionally rate limits) and records everything:
366
+ `calls`, `storage`, `deliveries`, `prompts`, `logs`, `toasts`, `chrome`
367
+ (title/status/badge/overview/menu). Drive the card with `setVisibility`,
368
+ `setLanguage`, `setSettings`, `setSecret`, `sendPortMessage`, `requestPort`,
369
+ `callTool`, `setAgentStatus`, `agentOutput`, `suspend`, `touchFile`. The same
370
+ mock powers the templates' browser preview when the page is opened outside the
371
+ app.
372
+
373
+ It behaves like the app where it matters: `setAgentStatus` also emits
374
+ `agents.turn` (`start` on entering `working`, `end` on leaving it for
375
+ `finished`/`needs-input`); `callTool` returns a rejected promise for bad
376
+ arguments; `sendPortMessage` checks that `from.cardId` is an upstream peer and
377
+ that the value fits the input's schema; `permissions.request` fails with
378
+ `NOT_VISIBLE` off screen; `fs.write` refuses the protected paths above. Options:
379
+ `settings` (initial values), `secrets` (`{ key: value }` for `secret`
380
+ settings — `{{secret:key}}` headers are filled, a missing one is
381
+ `INVALID_PARAMS`), `enforceRateLimits` (per-method throttles; off by default so
382
+ tests stay deterministic).
383
+
384
+ **Test in the app too.** The mock runs in your page, not in the sandboxed
385
+ frame, so CSP and sandbox differences (inline scripts, external resources)
386
+ only show up in NeuroSquad (`neurosquad-card dev`).
387
+
388
+ ## CLI
389
+
390
+ ```
391
+ neurosquad-card create <folder> [--template vanilla|react] [--name <slug>] [--display-name <text>] [--force]
392
+ neurosquad-card dev [folder] [--user-data-dir <dir>] [--no-build] [--no-logs]
393
+ neurosquad-card validate [folder] [--json] [--app-version <x.y.z>]
394
+ neurosquad-card pack [folder] [--out <file.tgz>] [--dry-run] [--json] [--allow-secret-files]
395
+ ```
396
+
397
+ - `validate` runs the manifest through the app's validators, applies the
398
+ installer's file rules (paths, sizes, counts, icon format and squareness),
399
+ lints the entry HTML for things the sandbox blocks, and previews the install
400
+ dialog.
401
+ - `pack` lists what would be installed (what git tracks plus untracked files
402
+ that are not ignored — the GitHub tarball), prints the **tree hash** the app
403
+ records, and writes a reproducible `.tgz`. It refuses files that look like
404
+ credentials (`.env*` except `.env.example`, `*.pem`, `*.key`, `*.p12`,
405
+ `*.pfx`, `id_rsa*`, `.npmrc`, `.git-credentials`, `.netrc`) unless you pass
406
+ `--allow-secret-files`, and skips anything reached through a symbolic link
407
+ or junction (it would be read from outside the folder).
408
+ - `validate` and `pack` are safe to run on a folder someone sent you: git
409
+ runs with the folder's own hooks and `core.fsmonitor` disabled, and card
410
+ text is printed with escape sequences and bidi characters removed.
411
+ - `dev` finds the app's data folder (`%APPDATA%/NeuroSquad`,
412
+ `~/Library/Application Support/NeuroSquad`, `~/.config/NeuroSquad`, or
413
+ `--user-data-dir` / `NEUROSQUAD_USER_DATA_DIR`), links the folder, runs
414
+ `npm run watch` (or `npm run build -- --watch`) when present, and streams the
415
+ card's log. Press `r` to reload, `q` to quit. **`dev` runs the folder's own
416
+ npm scripts** — only use it on folders you trust, or pass `--no-build`.
417
+ - Linking a local SDK checkout (`"@neurosquad/card-sdk": "file:…"`) makes a
418
+ symlink, and Vite can then load a second React next to the SDK ("invalid
419
+ hook call"). The React template sets `resolve.dedupe: ['react', 'react-dom']`;
420
+ `install-links=true` in `.npmrc` also works.
421
+
422
+ ## Versioning
423
+
424
+ The wire protocol is versioned separately (`CARD_PROTOCOL_VERSION`, now 1).
425
+ New methods and events arrive within a protocol version; check with
426
+ `card.host.supports('method.name')`. A card built for a newer protocol is
427
+ refused by an older app with `PROTOCOL_MISMATCH`.
428
+
429
+ ## License
430
+
431
+ MIT