tabbied-mcp 0.2.3 → 0.2.4

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 CHANGED
@@ -7,12 +7,24 @@ or any other MCP client.
7
7
 
8
8
  ## Use it without installing anything
9
9
 
10
- The same server runs at `https://tabbied.com/mcp`:
10
+ The same server runs at `https://tabbied.com/mcp`, with no account or key:
11
11
 
12
12
  ```bash
13
- claude mcp add --transport http tabbied https://tabbied.com/mcp
13
+ claude mcp add --transport http tabbied https://tabbied.com/mcp # Claude Code
14
+ codex mcp add tabbied --url https://tabbied.com/mcp # Codex
14
15
  ```
15
16
 
17
+ - **Claude** (web and desktop): Customize, Connectors, + Add, Add custom
18
+ connector; enter the URL and choose "No sign in".
19
+ - **ChatGPT**: turn on Developer mode (Settings, Security and login), then add
20
+ it under [Plugins](https://chatgpt.com/plugins) with the URL as a public
21
+ endpoint. ChatGPT reaches hosted servers only.
22
+ - **Codex**: the command above, or `[mcp_servers.tabbied]` with
23
+ `url = "https://tabbied.com/mcp"` in `~/.codex/config.toml`, which the CLI,
24
+ the IDE extension and the ChatGPT desktop app share.
25
+ - **Cursor** and other clients that read an `mcpServers` JSON file (VS Code's
26
+ `.vscode/mcp.json` uses `servers` with `"type": "http"` instead):
27
+
16
28
  ```jsonc
17
29
  {
18
30
  "mcpServers": {
@@ -23,22 +35,36 @@ claude mcp add --transport http tabbied https://tabbied.com/mcp
23
35
 
24
36
  ## Or run it locally, and render real files
25
37
 
38
+ The local server adds `render_design`, which the remote one cannot offer:
39
+ rendering a css-doodle pattern needs a real browser. It drives Chromium through
40
+ Playwright, which this package does not install, so start the server with
41
+ Playwright beside it, and download the browser once:
42
+
26
43
  ```bash
27
- claude mcp add tabbied -- npx -y tabbied-mcp
44
+ npx playwright install chromium
45
+
46
+ claude mcp add tabbied -- npx -y -p tabbied-mcp -p playwright tabbied-mcp # Claude Code
47
+ codex mcp add tabbied -- npx -y -p tabbied-mcp -p playwright tabbied-mcp # Codex
28
48
  ```
29
49
 
50
+ Claude Desktop (`claude_desktop_config.json`), Cursor and other clients that
51
+ start local servers from JSON:
52
+
30
53
  ```jsonc
31
54
  {
32
55
  "mcpServers": {
33
- "tabbied": { "command": "npx", "args": ["-y", "tabbied-mcp"] }
56
+ "tabbied": {
57
+ "command": "npx",
58
+ "args": ["-y", "-p", "tabbied-mcp", "-p", "playwright", "tabbied-mcp"]
59
+ }
34
60
  }
35
61
  }
36
62
  ```
37
63
 
38
- The local server adds `render_design`, which the remote one cannot offer -
39
- rendering a css-doodle pattern needs a real browser. Install a Playwright
40
- alongside it (`npm i -D playwright`) or point `TABBIED_CHROMIUM` at a Chromium
41
- binary.
64
+ A Playwright installed in the project the server starts in is found too
65
+ (`npm i -D playwright`), and `TABBIED_CHROMIUM` points it at a Chromium binary
66
+ of your own. Without either, `npx -y tabbied-mcp` serves the other six tools,
67
+ and `render_design` says what to install. Needs Node 20 or later.
42
68
 
43
69
  ## Tools
44
70
 
@@ -48,24 +74,49 @@ binary.
48
74
  | `get_design` | The full record for one slug, plus ready-to-paste snippets. |
49
75
  | `preview_design` | The rendered preview image for up to six designs, so the model can *look*. |
50
76
  | `get_docs` | The complete API reference (`llms-full.txt`). |
77
+ | `list_templates` | The Tabbied website templates, a page at a time, filtered by category or by words. |
78
+ | `get_template` | One template's editable-section spec, its download links, and how to edit each format. |
51
79
  | `render_design` | SVG or PNG at any size, seed, palette, and option set. **Local only.** |
52
80
 
53
81
  Slugs are opaque - `cleat`, `karst`, `radius` - so the intended flow is
54
82
  `search_designs` to narrow, `preview_design` to look, then `get_design` for the
55
83
  options. Choosing off tags alone is the main way this goes wrong.
56
84
 
85
+ The website templates are licensed per Tabbied account, not with this package:
86
+ downloading one needs an account that has chosen it, under the
87
+ [Template License](https://tabbied.com/terms-of-service/#template-license).
88
+
89
+ Setup for each client, the tools and example prompts are also on
90
+ [tabbied.com/docs/mcp](https://tabbied.com/docs/mcp/).
91
+
57
92
  ## Programmatic use
58
93
 
59
- The package's main entry point is runtime-agnostic (no node imports), so it can
60
- be embedded in a Worker or any Web-standard server. It exposes the tools and an
61
- `McpServer` factory; the transport is the MCP SDK's:
94
+ The package is ESM only (the `tabbied-mcp` bin is unaffected). Its main entry
95
+ point is runtime-agnostic (no node imports), so it can be embedded in a Worker
96
+ or any Web-standard server. It exposes the tools and an `McpServer` factory;
97
+ the transport is the MCP SDK's. On Node, `tabbied-mcp/node` has the readers the
98
+ bin itself uses:
62
99
 
63
100
  ```ts
64
101
  import { createMcpHandler } from '@modelcontextprotocol/server';
65
102
  import { buildServer, catalogTools } from 'tabbied-mcp';
66
-
67
- const tools = catalogTools({ catalog, fetchPreview, fetchDocs });
68
-
103
+ import {
104
+ fetchDocs,
105
+ fetchPreview,
106
+ fetchTemplate,
107
+ fetchTemplateCatalog,
108
+ loadCatalog,
109
+ } from 'tabbied-mcp/node';
110
+
111
+ const tools = catalogTools({
112
+ catalog: await loadCatalog(),
113
+ fetchPreview,
114
+ fetchDocs,
115
+ fetchTemplateCatalog,
116
+ fetchTemplate,
117
+ });
118
+
119
+ // A Web-standard fetch handler, for Bun.serve, Deno.serve or a Node adapter.
69
120
  export default {
70
121
  fetch: createMcpHandler(() => buildServer(tools)).fetch,
71
122
  };
@@ -74,6 +125,19 @@ export default {
74
125
  Pass the *factory*, not a built server: MCP v2 is stateless and the handler
75
126
  constructs one server per request.
76
127
 
128
+ Only `catalog` is required. Each fetcher adds tools, and one left out drops
129
+ them: `fetchPreview` gives `preview_design`, `fetchDocs` gives `get_docs`,
130
+ `fetchTemplateCatalog` gives `list_templates`, and with `fetchTemplate` as
131
+ well, `get_template`. With all five, as above, it serves the same six tools as
132
+ the hosted server at `https://tabbied.com/mcp`. `renderTool(catalog)`, also
133
+ from `tabbied-mcp/node`, is `render_design`, for a host with a browser.
134
+
135
+ The Node readers take the catalog and the docs from the installed `tabbied`
136
+ package and the rest from tabbied.com. A Worker cannot use them, so it passes
137
+ its own: each is an async function returning the same JSON (`Catalog`,
138
+ `TemplateCatalog` and the rest are exported types), and the hosted server's
139
+ read its own static assets.
140
+
77
141
  Built on [`@modelcontextprotocol/server`](https://www.npmjs.com/package/@modelcontextprotocol/server)
78
142
  v2, so it speaks the stateless `2026-07-28` revision and still serves 2025-era
79
143
  clients. `docs/mcp-server.md` in the repository has the details.
package/dist/info.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export declare const SERVER_NAME = "tabbied";
2
- export declare const VERSION = "0.2.3";
2
+ export declare const VERSION = "0.2.4";
3
3
  /**
4
4
  * Shown to the model as a preamble. It carries what no tool description can,
5
5
  * because they are properties of the *set* rather than of one call: slugs
package/dist/info.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // `version-packages` step rewrites it from package.json
6
6
  // (scripts/sync-version.mjs), and test/info.test.mjs pins the two together.
7
7
  export const SERVER_NAME = 'tabbied';
8
- export const VERSION = '0.2.3';
8
+ export const VERSION = '0.2.4';
9
9
  /**
10
10
  * Shown to the model as a preamble. It carries what no tool description can,
11
11
  * because they are properties of the *set* rather than of one call: slugs
package/dist/info.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"info.js","sourceRoot":"","sources":["../src/info.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,8EAA8E;AAC9E,2EAA2E;AAC3E,wDAAwD;AACxD,4EAA4E;AAC5E,MAAM,CAAC,MAAM,WAAW,GAAG,SAAS,CAAC;AACrC,MAAM,CAAC,MAAM,OAAO,GAAG,OAAO,CAAC;AAE/B;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG;;;;;;6QAMiP,CAAC","sourcesContent":["// Server identity and the instructions clients hand to the model.\n//\n// VERSION is a literal rather than a package.json read because this module is\n// bundled into a Cloudflare Worker, which has no filesystem. The release's\n// `version-packages` step rewrites it from package.json\n// (scripts/sync-version.mjs), and test/info.test.mjs pins the two together.\nexport const SERVER_NAME = 'tabbied';\nexport const VERSION = '0.2.3';\n\n/**\n * Shown to the model as a preamble. It carries what no tool description can,\n * because they are properties of the *set* rather than of one call: slugs\n * cannot be guessed, the previews are what a choice should rest on, and the\n * sizing rule is the most common way a correct-looking integration renders as\n * nothing.\n */\nexport const INSTRUCTIONS = `Tabbied is a catalog of generative patterns (css-doodle) usable as backgrounds, textures, posters, and exported SVG/PNG assets.\n\nDesigns are addressed by slug, and slugs are opaque - \"cleat\", \"karst\", \"radius\" say nothing about what they look like. Never guess one: call search_designs, which filters on a closed vocabulary of motifs, moods, density, and intended use.\n\nMetadata narrows the field; it does not settle it. These are pictures, so call preview_design on your shortlist and look before you commit. Choosing off tags alone is the main way this goes wrong.\n\nWhen you write integration code, remember a pattern has no intrinsic size: it fills its parent and collapses to nothing in a parent that sizes to content. Pass height or aspectRatio unless the parent is definitely sized. get_docs has the full API contract and recipes.`;\n"]}
1
+ {"version":3,"file":"info.js","sourceRoot":"","sources":["../src/info.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,8EAA8E;AAC9E,2EAA2E;AAC3E,wDAAwD;AACxD,4EAA4E;AAC5E,MAAM,CAAC,MAAM,WAAW,GAAG,SAAS,CAAC;AACrC,MAAM,CAAC,MAAM,OAAO,GAAG,OAAO,CAAC;AAE/B;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG;;;;;;6QAMiP,CAAC","sourcesContent":["// Server identity and the instructions clients hand to the model.\n//\n// VERSION is a literal rather than a package.json read because this module is\n// bundled into a Cloudflare Worker, which has no filesystem. The release's\n// `version-packages` step rewrites it from package.json\n// (scripts/sync-version.mjs), and test/info.test.mjs pins the two together.\nexport const SERVER_NAME = 'tabbied';\nexport const VERSION = '0.2.4';\n\n/**\n * Shown to the model as a preamble. It carries what no tool description can,\n * because they are properties of the *set* rather than of one call: slugs\n * cannot be guessed, the previews are what a choice should rest on, and the\n * sizing rule is the most common way a correct-looking integration renders as\n * nothing.\n */\nexport const INSTRUCTIONS = `Tabbied is a catalog of generative patterns (css-doodle) usable as backgrounds, textures, posters, and exported SVG/PNG assets.\n\nDesigns are addressed by slug, and slugs are opaque - \"cleat\", \"karst\", \"radius\" say nothing about what they look like. Never guess one: call search_designs, which filters on a closed vocabulary of motifs, moods, density, and intended use.\n\nMetadata narrows the field; it does not settle it. These are pictures, so call preview_design on your shortlist and look before you commit. Choosing off tags alone is the main way this goes wrong.\n\nWhen you write integration code, remember a pattern has no intrinsic size: it fills its parent and collapses to nothing in a parent that sizes to content. Pass height or aspectRatio unless the parent is definitely sized. get_docs has the full API contract and recipes.`;\n"]}
@@ -0,0 +1,2 @@
1
+ export { fetchDocs, fetchPreview, fetchTemplate, fetchTemplateCatalog, loadCatalog, tabbiedRoot, } from './resources.js';
2
+ export { renderTool } from './render.js';
@@ -0,0 +1,7 @@
1
+ // The Node half, for hosts that run on Node rather than a Worker: what the
2
+ // `tabbied-mcp` bin itself passes to `catalogTools`, plus `render_design`.
3
+ // Kept off the main entry point, which a Worker bundles and which must stay
4
+ // free of node imports (see ../index.ts).
5
+ export { fetchDocs, fetchPreview, fetchTemplate, fetchTemplateCatalog, loadCatalog, tabbiedRoot, } from './resources.js';
6
+ export { renderTool } from './render.js';
7
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/node/index.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,2EAA2E;AAC3E,4EAA4E;AAC5E,0CAA0C;AAC1C,OAAO,EACL,SAAS,EACT,YAAY,EACZ,aAAa,EACb,oBAAoB,EACpB,WAAW,EACX,WAAW,GACZ,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC","sourcesContent":["// The Node half, for hosts that run on Node rather than a Worker: what the\n// `tabbied-mcp` bin itself passes to `catalogTools`, plus `render_design`.\n// Kept off the main entry point, which a Worker bundles and which must stay\n// free of node imports (see ../index.ts).\nexport {\n fetchDocs,\n fetchPreview,\n fetchTemplate,\n fetchTemplateCatalog,\n loadCatalog,\n tabbiedRoot,\n} from './resources.js';\nexport { renderTool } from './render.js';\n"]}
@@ -16,11 +16,49 @@ const RENDER_TIMEOUT_MS = 180_000;
16
16
  // A PNG returned inline is spent context. Past this the file stays on disk and
17
17
  // the agent gets the path - better a usable pointer than a truncated image.
18
18
  const INLINE_BYTE_BUDGET = 1_500_000;
19
+ // Past these a PNG is tens of megapixels (or a browser canvas limit), which
20
+ // no asset this tool exists for needs.
21
+ const SIZE_MAX = 4096;
22
+ const SCALE_MAX = 4;
19
23
  const text = (value) => ({ type: 'text', text: value });
20
24
  const toolError = (message) => ({
21
25
  content: [text(message)],
22
26
  isError: true,
23
27
  });
28
+ /** A whole number in [min, max], the default when absent, or null when invalid. */
29
+ function integerArg(value, fallback, min, max) {
30
+ if (value === undefined)
31
+ return fallback;
32
+ return typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max
33
+ ? value
34
+ : null;
35
+ }
36
+ /**
37
+ * The CLI's own message out of its stderr: each `tabbied:` line and the
38
+ * indented lines that continue it. Anything else there (Node's warnings, a
39
+ * deprecation notice from a dependency) is noise to the agent. When there is
40
+ * no such line, the CLI died some other way, and the tail is the best guess.
41
+ */
42
+ function cliMessage(stderr) {
43
+ const lines = stderr.split('\n');
44
+ const kept = [];
45
+ let continuing = false;
46
+ for (const line of lines) {
47
+ if (line.startsWith('tabbied:')) {
48
+ kept.push(line);
49
+ continuing = true;
50
+ }
51
+ else if (continuing && /^\s+\S/.test(line)) {
52
+ kept.push(line);
53
+ }
54
+ else {
55
+ continuing = false;
56
+ }
57
+ }
58
+ return kept.length > 0
59
+ ? kept.join('\n')
60
+ : lines.filter((line) => line.trim()).slice(-5).join('\n');
61
+ }
24
62
  export function renderTool(catalog) {
25
63
  return {
26
64
  definition: {
@@ -30,12 +68,14 @@ export function renderTool(catalog) {
30
68
  'palette, and option set. Use this to produce an actual asset - or to ' +
31
69
  'preview a *customized* configuration, which preview_design cannot show ' +
32
70
  'you (it only has the stock palette and defaults). Needs Playwright ' +
33
- 'installed alongside this server. Without "out" the image comes back ' +
34
- 'inline; with it, the file is written where you ask.',
71
+ 'where this server runs (start it as `npx -y -p tabbied-mcp -p ' +
72
+ 'playwright tabbied-mcp`, with `npx playwright install chromium` once). ' +
73
+ 'Without "out" the image comes back inline; with it, the file is ' +
74
+ 'written where you ask, replacing any file already there.',
35
75
  inputSchema: {
36
76
  type: 'object',
37
77
  properties: {
38
- slug: { type: 'string', description: 'The design to render.' },
78
+ slug: { type: 'string', minLength: 1, description: 'The design to render.' },
39
79
  format: {
40
80
  type: 'string',
41
81
  enum: ['svg', 'png'],
@@ -44,26 +84,40 @@ export function renderTool(catalog) {
44
84
  },
45
85
  out: {
46
86
  type: 'string',
47
- description: 'Absolute path to write to. Omit to get the image back inline ' +
48
- 'instead of on disk.',
87
+ minLength: 1,
88
+ description: 'Absolute path to write to, ending in .svg or .png to match ' +
89
+ '"format". Omit to get the image back inline instead of on disk.',
49
90
  },
50
91
  seed: {
51
92
  type: 'string',
93
+ minLength: 1,
52
94
  description: 'Fixed seed - the same seed always gives the same image. Random ' +
53
95
  'when omitted.',
54
96
  },
55
97
  palette: {
56
98
  type: 'array',
57
- items: { type: 'string' },
99
+ items: { type: 'string', minLength: 1 },
100
+ minItems: 1,
58
101
  description: "CSS colors, background first. Omit for the design's authored palette.",
59
102
  },
60
103
  options: {
61
104
  type: 'object',
62
- description: 'Option values keyed by option id, e.g. {"scale": 3}. Read the ' +
63
- 'ids and ranges off get_design.',
105
+ additionalProperties: { type: ['string', 'number', 'boolean'] },
106
+ description: 'Option values keyed by option id, e.g. {"frequency": 0.6}. Read ' +
107
+ 'the ids, ranges, and choices off get_design.',
108
+ },
109
+ width: {
110
+ type: 'integer',
111
+ minimum: 1,
112
+ maximum: SIZE_MAX,
113
+ description: 'Pixels wide (default 960).',
114
+ },
115
+ height: {
116
+ type: 'integer',
117
+ minimum: 1,
118
+ maximum: SIZE_MAX,
119
+ description: 'Pixels tall (default 960).',
64
120
  },
65
- width: { type: 'integer', description: 'Pixels wide (default 960).' },
66
- height: { type: 'integer', description: 'Pixels tall (default 960).' },
67
121
  fit: {
68
122
  type: 'string',
69
123
  enum: ['grid', 'cover', 'fixed'],
@@ -71,6 +125,8 @@ export function renderTool(catalog) {
71
125
  },
72
126
  scale: {
73
127
  type: 'integer',
128
+ minimum: 1,
129
+ maximum: SCALE_MAX,
74
130
  description: 'PNG device-scale factor. Defaults to 2 when writing a file and ' +
75
131
  '1 when returning inline, to keep the response small.',
76
132
  },
@@ -78,6 +134,9 @@ export function renderTool(catalog) {
78
134
  required: ['slug', 'format'],
79
135
  additionalProperties: false,
80
136
  },
137
+ // It writes a file, so it is not read-only; it only ever adds or
138
+ // replaces the one file it was asked for.
139
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
81
140
  },
82
141
  async run(args) {
83
142
  const slug = typeof args.slug === 'string' ? args.slug : '';
@@ -90,118 +149,157 @@ export function renderTool(catalog) {
90
149
  return toolError(`"${slug}" paints effects SVG cannot represent, so it has no vector ` +
91
150
  'export. Render it as PNG instead.');
92
151
  }
152
+ // The schema states these, but a host that skips validation (or calls
153
+ // run() directly) must not hand the CLI a path it would resolve against
154
+ // whatever directory the server happened to start in.
155
+ const out = typeof args.out === 'string' && args.out.length > 0 ? args.out : null;
156
+ if (out !== null && !path.isAbsolute(out)) {
157
+ return toolError(`"out" must be an absolute path, got "${out}". A relative one would ` +
158
+ "land in the server's working directory, not yours.");
159
+ }
160
+ if (out !== null && path.extname(out).toLowerCase() !== `.${format}`) {
161
+ return toolError(`"out" must end in .${format} to match format "${format}", got "${out}".`);
162
+ }
163
+ const inline = out === null;
164
+ const width = integerArg(args.width, 960, 1, SIZE_MAX);
165
+ const height = integerArg(args.height, 960, 1, SIZE_MAX);
166
+ const scale = integerArg(args.scale, inline ? 1 : 2, 1, SCALE_MAX);
167
+ if (width === null || height === null) {
168
+ return toolError(`"width" and "height" must be whole pixels from 1 to ${SIZE_MAX}.`);
169
+ }
170
+ if (scale === null) {
171
+ return toolError(`"scale" must be a whole number from 1 to ${SCALE_MAX}.`);
172
+ }
173
+ // Options reach the CLI as one "id: value; id: value" string, so a `;`
174
+ // inside a value would end it and start an option of its own, past
175
+ // every check on the first. Ranges and choices are the CLI's to check.
176
+ let options = null;
177
+ if (args.options !== undefined) {
178
+ if (!args.options || typeof args.options !== 'object' || Array.isArray(args.options)) {
179
+ return toolError('"options" must be an object of option values keyed by id.');
180
+ }
181
+ const ids = design.options.map((option) => option.id);
182
+ const pairs = [];
183
+ for (const [id, value] of Object.entries(args.options)) {
184
+ if (!ids.includes(id)) {
185
+ return toolError(`"${slug}" has no option "${id}" (has: ${ids.join(', ') || 'none'}).`);
186
+ }
187
+ if (!['string', 'number', 'boolean'].includes(typeof value) || /[;\n]/.test(String(value))) {
188
+ return toolError(`Option "${id}" must be a single string, number, or boolean value ` +
189
+ '(no ";" or line breaks).');
190
+ }
191
+ pairs.push(`${id}: ${String(value)}`);
192
+ }
193
+ if (pairs.length > 0)
194
+ options = pairs.join('; ');
195
+ }
93
196
  const root = tabbiedRoot();
94
197
  if (!root) {
95
198
  return toolError('The `tabbied` package is not installed next to this server, so the ' +
96
199
  'renderer is unavailable. Run `npm install tabbied`.');
97
200
  }
98
- const inline = typeof args.out !== 'string' || args.out.length === 0;
99
201
  // An inline render gets a scratch directory, removed once its bytes
100
- // have been read back.
202
+ // have been read back, or once the render has failed.
101
203
  const scratch = inline ? await mkdtemp(path.join(tmpdir(), 'tabbied-')) : null;
102
- const outPath = scratch ? path.join(scratch, `${slug}.${format}`) : args.out;
103
- const discard = () => (scratch ? rm(scratch, { recursive: true, force: true }) : Promise.resolve());
104
- const scale = typeof args.scale === 'number' && Number.isFinite(args.scale)
105
- ? Math.trunc(args.scale)
106
- : inline
107
- ? 1
108
- : 2;
204
+ const outPath = scratch ? path.join(scratch, `${slug}.${format}`) : out;
205
+ let keepScratch = false;
206
+ // A value that starts with "-" (a seed of "--browser", say) would read
207
+ // as the next flag, so it goes as --flag=value, which the CLI takes
208
+ // from tabbied 0.7.1. Everything else stays two arguments, which every
209
+ // version of the CLI reads.
210
+ const flag = (name, value) => value.startsWith('-') ? [`--${name}=${value}`] : [`--${name}`, value];
109
211
  const argv = [
110
212
  path.join(root, 'dist', 'cli.js'),
111
213
  'render',
112
214
  slug,
113
- '--out',
114
- outPath,
115
- '--format',
116
- format,
117
- '--scale',
118
- String(scale),
215
+ ...flag('out', outPath),
216
+ ...flag('format', format),
217
+ ...flag('scale', String(scale)),
218
+ ...flag('size', `${width}x${height}`),
119
219
  ];
120
- const push = (flag, value) => argv.push(flag, value);
121
220
  if (typeof args.seed === 'string' && args.seed)
122
- push('--seed', args.seed);
221
+ argv.push(...flag('seed', args.seed));
123
222
  if (typeof args.fit === 'string')
124
- push('--fit', args.fit);
125
- const width = typeof args.width === 'number' ? Math.trunc(args.width) : 960;
126
- const height = typeof args.height === 'number' ? Math.trunc(args.height) : 960;
127
- push('--size', `${width}x${height}`);
128
- if (Array.isArray(args.palette) && args.palette.length) {
129
- push('--palette', args.palette.filter(Boolean).join(','));
130
- }
131
- if (args.options && typeof args.options === 'object') {
132
- const pairs = Object.entries(args.options)
133
- .map(([id, value]) => `${id}: ${String(value)}`)
134
- .join('; ');
135
- if (pairs)
136
- push('--options', pairs);
223
+ argv.push(...flag('fit', args.fit));
224
+ if (Array.isArray(args.palette)) {
225
+ const colors = args.palette.filter((color) => typeof color === 'string' && color.trim().length > 0);
226
+ if (colors.length > 0)
227
+ argv.push(...flag('palette', colors.join(',')));
137
228
  }
138
- let stderr = '';
229
+ if (options)
230
+ argv.push(...flag('options', options));
139
231
  try {
140
- // No shell: argv goes straight to node, so a palette or option value
141
- // can't be read as shell syntax.
142
- ({ stderr } = await run(process.execPath, argv, {
143
- timeout: RENDER_TIMEOUT_MS,
144
- maxBuffer: 8 * 1024 * 1024,
145
- }));
146
- }
147
- catch (error) {
148
- const detail = error && typeof error === 'object' && 'stderr' in error
149
- ? String(error.stderr).trim()
150
- : error instanceof Error
151
- ? error.message
152
- : String(error);
153
- return toolError(`Rendering failed: ${detail || 'the CLI exited non-zero.'}\n\n` +
154
- 'If this says a browser is missing, install one alongside the ' +
155
- 'server (`npm i -D playwright`) or point TABBIED_CHROMIUM at a ' +
156
- 'Chromium binary.');
157
- }
158
- // The CLI reports SVG-fidelity caveats on stderr; they matter enough to
159
- // pass through rather than swallow.
160
- const notes = stderr
161
- .split('\n')
162
- .filter((line) => line.trim().startsWith('note:'))
163
- .join('\n');
164
- if (!inline) {
165
- const { size } = await stat(outPath);
166
- return {
167
- content: [
168
- text(`Rendered ${slug} to ${outPath} (${width}x${height}` +
169
- (format === 'png' ? ` @${scale}x` : '') +
170
- `, ${Math.round(size / 1024)} KB).` +
171
- (notes ? `\n${notes}` : '')),
172
- ],
173
- };
174
- }
175
- if (format === 'svg') {
176
- const svg = await readFile(outPath, 'utf-8');
177
- await discard();
232
+ let stderr = '';
233
+ try {
234
+ // No shell: argv goes straight to node, so a palette or option value
235
+ // can't be read as shell syntax.
236
+ ({ stderr } = await run(process.execPath, argv, {
237
+ timeout: RENDER_TIMEOUT_MS,
238
+ maxBuffer: 8 * 1024 * 1024,
239
+ }));
240
+ }
241
+ catch (error) {
242
+ const failure = error;
243
+ if (failure.killed) {
244
+ return toolError(`Rendering timed out after ${RENDER_TIMEOUT_MS / 1000}s. Try a smaller ` +
245
+ 'width/height or scale.');
246
+ }
247
+ const detail = typeof failure.stderr === 'string' && failure.stderr.trim()
248
+ ? cliMessage(failure.stderr)
249
+ : (failure.message ?? String(error));
250
+ return toolError(`Rendering failed:\n${detail}`);
251
+ }
252
+ // The CLI reports SVG-fidelity caveats on stderr; they matter enough to
253
+ // pass through rather than swallow.
254
+ const notes = stderr
255
+ .split('\n')
256
+ .filter((line) => line.trim().startsWith('note:'))
257
+ .join('\n');
258
+ if (!inline) {
259
+ const { size } = await stat(outPath);
260
+ return {
261
+ content: [
262
+ text(`Rendered ${slug} to ${outPath} (${width}x${height}` +
263
+ (format === 'png' ? ` @${scale}x` : '') +
264
+ `, ${Math.round(size / 1024)} KB).` +
265
+ (notes ? `\n${notes}` : '')),
266
+ ],
267
+ };
268
+ }
269
+ if (format === 'svg') {
270
+ const svg = await readFile(outPath, 'utf-8');
271
+ return {
272
+ content: [
273
+ text(`Rendered ${slug} as SVG (${width}x${height}).` +
274
+ (notes ? `\n${notes}` : '')),
275
+ text(svg),
276
+ ],
277
+ };
278
+ }
279
+ const png = await readFile(outPath);
280
+ const data = png.toString('base64');
281
+ if (data.length > INLINE_BYTE_BUDGET) {
282
+ // Kept: the answer tells the agent to read it from that path.
283
+ keepScratch = true;
284
+ return {
285
+ content: [
286
+ text(`Rendered ${slug} to ${outPath} - ${Math.round(png.byteLength / 1024)} KB, too large to return inline. Read it from that path, or ` +
287
+ 're-render smaller (lower "scale", or a smaller width/height).'),
288
+ ],
289
+ };
290
+ }
178
291
  return {
179
292
  content: [
180
- text(`Rendered ${slug} as SVG (${width}x${height}).` +
293
+ text(`Rendered ${slug} (${width}x${height} @${scale}x).` +
181
294
  (notes ? `\n${notes}` : '')),
182
- text(svg),
295
+ { type: 'image', data, mimeType: 'image/png' },
183
296
  ],
184
297
  };
185
298
  }
186
- const png = await readFile(outPath);
187
- const data = png.toString('base64');
188
- if (data.length > INLINE_BYTE_BUDGET) {
189
- // Kept: the answer tells the agent to read it from that path.
190
- return {
191
- content: [
192
- text(`Rendered ${slug} to ${outPath} - ${Math.round(png.byteLength / 1024)} KB, too large to return inline. Read it from that path, or ` +
193
- 're-render smaller (lower "scale", or a smaller width/height).'),
194
- ],
195
- };
299
+ finally {
300
+ if (scratch && !keepScratch)
301
+ await rm(scratch, { recursive: true, force: true });
196
302
  }
197
- await discard();
198
- return {
199
- content: [
200
- text(`Rendered ${slug} (${width}x${height} @${scale}x).` +
201
- (notes ? `\n${notes}` : '')),
202
- { type: 'image', data, mimeType: 'image/png' },
203
- ],
204
- };
205
303
  },
206
304
  };
207
305
  }