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 +78 -14
- package/dist/info.d.ts +1 -1
- package/dist/info.js +1 -1
- package/dist/info.js.map +1 -1
- package/dist/node/index.d.ts +2 -0
- package/dist/node/index.js +7 -0
- package/dist/node/index.js.map +1 -0
- package/dist/node/render.js +197 -99
- package/dist/node/render.js.map +1 -1
- package/dist/node/resources.js +37 -23
- package/dist/node/resources.js.map +1 -1
- package/dist/server.js +9 -1
- package/dist/server.js.map +1 -1
- package/dist/stdio.js +2 -0
- package/dist/stdio.js.map +1 -1
- package/dist/templates.js +143 -27
- package/dist/templates.js.map +1 -1
- package/dist/tools.js +53 -5
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +11 -1
- package/dist/types.js.map +1 -1
- package/package.json +9 -4
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
|
-
|
|
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": {
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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,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"]}
|
package/dist/node/render.js
CHANGED
|
@@ -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
|
-
'
|
|
34
|
-
'
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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}`) :
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
'
|
|
114
|
-
|
|
115
|
-
'
|
|
116
|
-
|
|
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('
|
|
221
|
+
argv.push(...flag('seed', args.seed));
|
|
123
222
|
if (typeof args.fit === 'string')
|
|
124
|
-
push('
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
229
|
+
if (options)
|
|
230
|
+
argv.push(...flag('options', options));
|
|
139
231
|
try {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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}
|
|
293
|
+
text(`Rendered ${slug} (${width}x${height} @${scale}x).` +
|
|
181
294
|
(notes ? `\n${notes}` : '')),
|
|
182
|
-
|
|
295
|
+
{ type: 'image', data, mimeType: 'image/png' },
|
|
183
296
|
],
|
|
184
297
|
};
|
|
185
298
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
}
|