dsh-artcraft-plugin-soundcraft 0.1.3 → 0.2.1
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/package.json +2 -2
- package/src/kit/catalog.js +48 -3
- package/src/kit/mcp.js +87 -9
- package/src/kit/profiles.mjs +69 -0
- package/src/kit/tools.js +112 -21
- package/src/kit/types.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-artcraft-plugin-soundcraft",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "DSH plugin: drive SoundCraft (audio production) headlessly through its own CLI and MCP server.",
|
|
@@ -43,4 +43,4 @@
|
|
|
43
43
|
"publishConfig": {
|
|
44
44
|
"access": "public"
|
|
45
45
|
}
|
|
46
|
-
}
|
|
46
|
+
}
|
package/src/kit/catalog.js
CHANGED
|
@@ -92,9 +92,44 @@ export function parseCatalog(stdout , json ) {
|
|
|
92
92
|
// Fall through to the table parser: a CLI may ignore an unknown --json.
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
|
+
const emDash = parseEmDash(text)
|
|
96
|
+
if (emDash.length > 0) return emDash
|
|
95
97
|
return parseTable(text)
|
|
96
98
|
}
|
|
97
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Parse an `id — Label: {params}` catalogue (DeckCraft 0.14+).
|
|
102
|
+
*
|
|
103
|
+
* One command per line, single-spaced, with an em dash between the id and its
|
|
104
|
+
* label. `: {…}` introduces the parameter grammar and ` → …` describes what the
|
|
105
|
+
* command answers, which is not part of the grammar.
|
|
106
|
+
* @param text - raw `commands` output.
|
|
107
|
+
* @returns every command whose line has the `id — label` shape.
|
|
108
|
+
*/
|
|
109
|
+
export function parseEmDash(text ) {
|
|
110
|
+
const out = []
|
|
111
|
+
for (const raw of text.split('\n')) {
|
|
112
|
+
const line = raw.trimEnd()
|
|
113
|
+
if (line.length === 0 || line.startsWith('#')) continue
|
|
114
|
+
const match = /^([\w.-]+)\s+—\s+(.*)$/.exec(line)
|
|
115
|
+
if (match === null) continue
|
|
116
|
+
const id = match[1]
|
|
117
|
+
const rest = match[2]
|
|
118
|
+
const returnIndex = rest.indexOf(' → ')
|
|
119
|
+
const beforeReturn = returnIndex === -1 ? rest : rest.slice(0, returnIndex)
|
|
120
|
+
const brace = beforeReturn.indexOf(': {')
|
|
121
|
+
const label = brace === -1 ? beforeReturn.trim() : beforeReturn.slice(0, brace).trim()
|
|
122
|
+
const params = brace === -1 ? undefined : beforeReturn.slice(brace + 2).trim()
|
|
123
|
+
out.push({
|
|
124
|
+
id,
|
|
125
|
+
...label.length > 0 ? { label } : {},
|
|
126
|
+
...params !== undefined && params.length > 0 ? { params } : {},
|
|
127
|
+
group: groupOf(id),
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
return out
|
|
131
|
+
}
|
|
132
|
+
|
|
98
133
|
/**
|
|
99
134
|
* Parse a craft's fixed-column `commands` table.
|
|
100
135
|
*
|
|
@@ -111,7 +146,14 @@ export function parseTable(text ) {
|
|
|
111
146
|
const flush = () => {
|
|
112
147
|
if (current === undefined) return
|
|
113
148
|
const { id, columns } = current
|
|
114
|
-
|
|
149
|
+
let rest = columns.join(' ').trim()
|
|
150
|
+
// Some tables end with the grammar as its own column (`{name?, color?}`),
|
|
151
|
+
// with no `params:` label; take the last column when it is only an object.
|
|
152
|
+
let columnParams
|
|
153
|
+
if (columns.length > 1 && /^\{.*\}$/.test(columns[columns.length - 1].trim())) {
|
|
154
|
+
columnParams = columns[columns.length - 1].trim()
|
|
155
|
+
rest = columns.slice(0, -1).join(' ').trim()
|
|
156
|
+
}
|
|
115
157
|
const paramsMatch = /\bparams:\s*(\{.*)$/.exec(rest)
|
|
116
158
|
const labelPart = paramsMatch === null ? rest : rest.slice(0, paramsMatch.index).trim()
|
|
117
159
|
// Trailing `label [Shortcut]` and an interleaved menu column.
|
|
@@ -120,7 +162,9 @@ export function parseTable(text ) {
|
|
|
120
162
|
out.push({
|
|
121
163
|
id,
|
|
122
164
|
...label.length > 0 ? { label } : {},
|
|
123
|
-
...
|
|
165
|
+
...columnParams !== undefined
|
|
166
|
+
? { params: columnParams.replace(/\s+/g, ' ') }
|
|
167
|
+
: paramsMatch !== null ? { params: paramsMatch[1].replace(/\s+/g, ' ').trim() } : {},
|
|
124
168
|
...shortcutMatch !== null ? { shortcut: shortcutMatch[1] } : {},
|
|
125
169
|
group: groupOf(id),
|
|
126
170
|
})
|
|
@@ -136,7 +180,8 @@ export function parseTable(text ) {
|
|
|
136
180
|
}
|
|
137
181
|
flush()
|
|
138
182
|
const columns = raw.trimEnd().split(/\s{2,}/).map(part => part.trim()).filter(part => part.length > 0)
|
|
139
|
-
|
|
183
|
+
// Rust CLIs mark undoable commands with a trailing `*`; it is not the id.
|
|
184
|
+
const id = (columns[0] ?? '').replace(/\*$/, '')
|
|
140
185
|
if (id.length === 0 || /\s/.test(id) || !/^[\w.-]+$/.test(id)) continue
|
|
141
186
|
current = { id, columns: columns.slice(1) }
|
|
142
187
|
}
|
package/src/kit/mcp.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
|
|
15
15
|
import { spawn } from 'node:child_process'
|
|
16
16
|
|
|
17
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
18
|
+
import { join } from 'node:path'
|
|
17
19
|
import { killTree } from './exec.js'
|
|
18
20
|
|
|
19
21
|
|
|
@@ -31,6 +33,33 @@ import { killTree } from './exec.js'
|
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
|
|
36
|
+
/** Extensions for the image types an MCP tool can return. */
|
|
37
|
+
const IMAGE_EXTENSIONS = {
|
|
38
|
+
'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif', 'image/svg+xml': 'svg',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Write one base64 image from a tool result to disk.
|
|
43
|
+
* @param data - base64 payload.
|
|
44
|
+
* @param mimeType - media type the server declared.
|
|
45
|
+
* @param dir - directory to write into.
|
|
46
|
+
* @param craft - craft id, used in the file name.
|
|
47
|
+
* @param tool - tool name, used in the file name.
|
|
48
|
+
* @returns the absolute path, or `undefined` when the payload cannot be written.
|
|
49
|
+
*/
|
|
50
|
+
function saveImage(data , mimeType , dir , craft , tool ) {
|
|
51
|
+
try {
|
|
52
|
+
const extension = IMAGE_EXTENSIONS[mimeType ?? ''] ?? 'png'
|
|
53
|
+
const safeTool = tool.replace(/[^\w.-]+/g, '-')
|
|
54
|
+
mkdirSync(dir, { recursive: true })
|
|
55
|
+
const file = join(dir, `${craft}-${safeTool}-${Date.now()}.${extension}`)
|
|
56
|
+
writeFileSync(file, Buffer.from(data, 'base64'))
|
|
57
|
+
return file
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
34
63
|
/** Default MCP protocol revision; servers negotiate down if they need to. */
|
|
35
64
|
const PROTOCOL_VERSION = '2024-11-05'
|
|
36
65
|
|
|
@@ -52,6 +81,8 @@ export class CraftMcpClient {
|
|
|
52
81
|
#idleTimer
|
|
53
82
|
#handshake
|
|
54
83
|
#tools
|
|
84
|
+
/** Last stderr lines, so a failed attach can say why the app refused. */
|
|
85
|
+
#stderrTail = []
|
|
55
86
|
#closed = false
|
|
56
87
|
|
|
57
88
|
constructor(profile , binary , args = []) {
|
|
@@ -91,17 +122,26 @@ export class CraftMcpClient {
|
|
|
91
122
|
child.stdout.on('data', (chunk ) => { this.#onData(chunk) })
|
|
92
123
|
child.stderr.setEncoding('utf8')
|
|
93
124
|
// Craft servers announce themselves on stderr; it is not an error stream.
|
|
125
|
+
// The tail is kept so a refused attach can repeat the app's own reason
|
|
126
|
+
// ("no control token", "cannot connect to 127.0.0.1:7979") instead of a
|
|
127
|
+
// bare "server exited".
|
|
94
128
|
child.stderr.on('data', (chunk ) => {
|
|
95
129
|
const text = chunk.trim()
|
|
96
|
-
if (text.length
|
|
130
|
+
if (text.length === 0) return
|
|
131
|
+
for (const line of text.split('\n')) {
|
|
132
|
+
this.#stderrTail.push(line.trim())
|
|
133
|
+
if (this.#stderrTail.length > 8) this.#stderrTail.shift()
|
|
134
|
+
}
|
|
135
|
+
if (process.env.DSH_ARTCRAFT_TRACE !== undefined) {
|
|
97
136
|
process.stderr.write(`[${this.#profile.id} mcp] ${text}\n`)
|
|
98
137
|
}
|
|
99
138
|
})
|
|
100
139
|
child.on('exit', () => {
|
|
101
140
|
this.#child = undefined
|
|
141
|
+
const reason = this.#stderrTail.filter(line => line.length > 0).slice(-2).join(' ')
|
|
102
142
|
for (const [, pending] of this.#pending) {
|
|
103
143
|
clearTimeout(pending.timer)
|
|
104
|
-
pending.reject(new Error(`${this.#profile.product}: MCP server exited`))
|
|
144
|
+
pending.reject(new Error(`${this.#profile.product}: MCP server exited${reason.length > 0 ? ` (${reason})` : ''}`))
|
|
105
145
|
}
|
|
106
146
|
this.#pending.clear()
|
|
107
147
|
})
|
|
@@ -134,22 +174,60 @@ export class CraftMcpClient {
|
|
|
134
174
|
|
|
135
175
|
/**
|
|
136
176
|
* Call one tool.
|
|
177
|
+
*
|
|
178
|
+
* Image results — a render, a screenshot, a page thumbnail — are written to
|
|
179
|
+
* `artifactsDir` and referenced by path instead of being dropped: the caller
|
|
180
|
+
* can then look at them with `read_image`, which is the only way an agent
|
|
181
|
+
* verifies what it drew.
|
|
137
182
|
* @param name - tool name as declared by the server.
|
|
138
183
|
* @param args - the tool's JSON arguments.
|
|
139
|
-
* @
|
|
184
|
+
* @param artifactsDir - directory for returned images, when one is configured.
|
|
185
|
+
* @returns the text content, with any images saved and named by path.
|
|
140
186
|
*/
|
|
141
|
-
async callTool(name , args ) {
|
|
187
|
+
async callTool(name , args , artifactsDir ) {
|
|
142
188
|
const result = await this.#request('tools/call', { name, arguments: args })
|
|
143
|
-
|
|
189
|
+
|
|
144
190
|
|
|
145
191
|
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
.
|
|
149
|
-
|
|
192
|
+
const parts = []
|
|
193
|
+
for (const block of result.content ?? []) {
|
|
194
|
+
if (block.type === 'text') {
|
|
195
|
+
parts.push(block.text ?? '')
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
if (block.type === 'image' && typeof block.data === 'string' && artifactsDir !== undefined) {
|
|
199
|
+
const saved = saveImage(block.data, block.mimeType, artifactsDir, this.#profile.id, name)
|
|
200
|
+
parts.push(saved === undefined
|
|
201
|
+
? '[image content]'
|
|
202
|
+
: `[image] ${saved}\nUse read_image on that path to look at it.`)
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
parts.push(`[${block.type ?? 'unknown'} content]`)
|
|
206
|
+
}
|
|
207
|
+
const text = parts.join('\n').trim()
|
|
150
208
|
return `${text.length > 0 ? text : '(no content)'}${result.isError === true ? '\n[mcp: tool reported an error]' : ''}`
|
|
151
209
|
}
|
|
152
210
|
|
|
211
|
+
/** List the resources the server advertises (documents, command catalogues). */
|
|
212
|
+
async listResources() {
|
|
213
|
+
try {
|
|
214
|
+
const result = await this.#request('resources/list', {})
|
|
215
|
+
return Array.isArray(result.resources) ? result.resources : []
|
|
216
|
+
} catch {
|
|
217
|
+
return []
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** List the prompts the server advertises (curated multi-step workflows). */
|
|
222
|
+
async listPrompts() {
|
|
223
|
+
try {
|
|
224
|
+
const result = await this.#request('prompts/list', {})
|
|
225
|
+
return Array.isArray(result.prompts) ? result.prompts : []
|
|
226
|
+
} catch {
|
|
227
|
+
return []
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
153
231
|
/** Terminate the server process now. */
|
|
154
232
|
close() {
|
|
155
233
|
this.#closed = true
|
package/src/kit/profiles.mjs
CHANGED
|
@@ -54,6 +54,7 @@ export const CRAFT_PROFILES = [
|
|
|
54
54
|
catalogJson: true,
|
|
55
55
|
catalogArgs: ['commands'],
|
|
56
56
|
mcpArgs: ['mcp'],
|
|
57
|
+
mcpAttach: { flag: '--connect', format: 'hostPort' },
|
|
57
58
|
},
|
|
58
59
|
{
|
|
59
60
|
id: 'gridcraft',
|
|
@@ -80,6 +81,7 @@ export const CRAFT_PROFILES = [
|
|
|
80
81
|
catalogJson: true,
|
|
81
82
|
catalogArgs: ['commands', '--json'],
|
|
82
83
|
mcpArgs: ['mcp'],
|
|
84
|
+
mcpAttach: { flag: '--connect', format: 'port' },
|
|
83
85
|
},
|
|
84
86
|
{
|
|
85
87
|
id: 'soundcraft',
|
|
@@ -107,6 +109,7 @@ export const CRAFT_PROFILES = [
|
|
|
107
109
|
describeArgs: ['describe'],
|
|
108
110
|
catalogArgs: ['commands'],
|
|
109
111
|
mcpArgs: ['mcp'],
|
|
112
|
+
mcpAttach: { flag: '--connect', format: 'port' },
|
|
110
113
|
},
|
|
111
114
|
{
|
|
112
115
|
id: 'deckcraft',
|
|
@@ -133,6 +136,7 @@ export const CRAFT_PROFILES = [
|
|
|
133
136
|
catalogJson: true,
|
|
134
137
|
catalogArgs: ['commands'],
|
|
135
138
|
mcpArgs: ['mcp'],
|
|
139
|
+
mcpAttach: { flag: '--connect', format: 'port' },
|
|
136
140
|
},
|
|
137
141
|
{
|
|
138
142
|
id: 'wordcraft',
|
|
@@ -158,6 +162,7 @@ export const CRAFT_PROFILES = [
|
|
|
158
162
|
catalogJson: true,
|
|
159
163
|
catalogArgs: ['commands', '--json'],
|
|
160
164
|
mcpArgs: ['mcp'],
|
|
165
|
+
mcpAttach: { flag: '--connect', format: 'hostPort' },
|
|
161
166
|
},
|
|
162
167
|
{
|
|
163
168
|
id: 'photocraft',
|
|
@@ -186,6 +191,7 @@ export const CRAFT_PROFILES = [
|
|
|
186
191
|
catalogJson: true,
|
|
187
192
|
catalogArgs: ['commands', '--json'],
|
|
188
193
|
mcpArgs: ['mcp'],
|
|
194
|
+
mcpAttach: { flag: '--bridge', format: 'hostPort' },
|
|
189
195
|
},
|
|
190
196
|
{
|
|
191
197
|
id: 'lightcraft',
|
|
@@ -272,6 +278,7 @@ export const CRAFT_PROFILES = [
|
|
|
272
278
|
catalogJson: true,
|
|
273
279
|
catalogArgs: ['commands'],
|
|
274
280
|
mcpArgs: ['mcp'],
|
|
281
|
+
mcpAttach: { flag: '--connect', format: 'hostPort' },
|
|
275
282
|
},
|
|
276
283
|
{
|
|
277
284
|
id: 'effectcraft',
|
|
@@ -297,6 +304,7 @@ export const CRAFT_PROFILES = [
|
|
|
297
304
|
catalogJson: false,
|
|
298
305
|
catalogArgs: ['commands', '--json'],
|
|
299
306
|
mcpArgs: ['mcp'],
|
|
307
|
+
mcpAttach: { flag: '--bridge', format: 'port' },
|
|
300
308
|
},
|
|
301
309
|
{
|
|
302
310
|
id: 'filmcraft',
|
|
@@ -334,6 +342,7 @@ export const CRAFT_PROFILES = [
|
|
|
334
342
|
describeArgs: ['describe'],
|
|
335
343
|
catalogArgs: ['commands', '--json'],
|
|
336
344
|
mcpArgs: ['mcp'],
|
|
345
|
+
mcpAttach: { flag: '--bridge', format: 'hostPort' },
|
|
337
346
|
},
|
|
338
347
|
{
|
|
339
348
|
id: 'designcraft',
|
|
@@ -358,6 +367,66 @@ export const CRAFT_PROFILES = [
|
|
|
358
367
|
catalogJson: true,
|
|
359
368
|
catalogArgs: ['commands'],
|
|
360
369
|
mcpArgs: ['mcp'],
|
|
370
|
+
mcpAttach: { flag: '--connect', format: 'port' },
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
id: 'figcraft',
|
|
374
|
+
product: 'FigCraft',
|
|
375
|
+
bin: 'figcraft-cli',
|
|
376
|
+
envVar: 'FIGCRAFT_CLI',
|
|
377
|
+
searchDirs: [CRAFTS_BIN_DIR],
|
|
378
|
+
extensions: ['.figcraft', '.penpot', '.fig', '.sketch', '.svg'],
|
|
379
|
+
blurb: 'Interface design and prototyping (Figma-style): frames, components, auto layout, variables, prototyping and code export.',
|
|
380
|
+
run: {
|
|
381
|
+
verb: 'run',
|
|
382
|
+
inputMode: 'flag',
|
|
383
|
+
inFlag: '--in',
|
|
384
|
+
sampleFlag: '',
|
|
385
|
+
cmdFlag: '',
|
|
386
|
+
cmdSeparator: ' ',
|
|
387
|
+
// `run script.json` takes a generated script, one step per command.
|
|
388
|
+
scriptFile: { encoding: 'json-array', commandKey: 'command', paramsKey: 'params', inputFlag: '--in', saveFlag: '--out' },
|
|
389
|
+
saveFlag: '--out',
|
|
390
|
+
},
|
|
391
|
+
reads: [
|
|
392
|
+
{ name: 'inspect', verb: 'info', summary: 'Summarize a design file: pages, node counts, top-level frames, style and variable counts.' },
|
|
393
|
+
{ name: 'convert', verb: 'convert', summary: 'Convert a design file between .figcraft, .json, .svg, .png, .pdf and more.' },
|
|
394
|
+
],
|
|
395
|
+
catalogJson: true,
|
|
396
|
+
catalogArgs: ['commands', '--json'],
|
|
397
|
+
mcpArgs: ['mcp'],
|
|
398
|
+
mcpAttach: { flag: '--attach', format: 'port' },
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
id: 'texturecraft',
|
|
402
|
+
product: 'TextureCraft',
|
|
403
|
+
bin: 'texturecraft-cli',
|
|
404
|
+
envVar: 'TEXTURECRAFT_CLI',
|
|
405
|
+
searchDirs: [CRAFTS_BIN_DIR],
|
|
406
|
+
extensions: ['.tcraft'],
|
|
407
|
+
blurb: '3D texture painting: texture sets and layers over a mesh, baking, material channels and PBR export.',
|
|
408
|
+
run: {
|
|
409
|
+
verb: 'batch',
|
|
410
|
+
inputMode: 'flag',
|
|
411
|
+
inFlag: '',
|
|
412
|
+
sampleFlag: '',
|
|
413
|
+
cmdFlag: '',
|
|
414
|
+
cmdSeparator: ' ',
|
|
415
|
+
// `batch script.json` carries `{ open, steps, save }` in one file.
|
|
416
|
+
scriptFile: { encoding: 'json-array', commandKey: 'id', paramsKey: 'params', batch: { openKey: 'open', stepsKey: 'steps', saveKey: 'save' } },
|
|
417
|
+
saveMode: 'script',
|
|
418
|
+
saveFlag: '',
|
|
419
|
+
// A project is required before any command, so a bare run makes one.
|
|
420
|
+
sampleCreate: { verb: 'new', args: ['builtin:crate'], extension: '.tcraft' },
|
|
421
|
+
},
|
|
422
|
+
reads: [
|
|
423
|
+
{ name: 'inspect', verb: 'info', summary: 'Summarize a texture project: texture sets, layers, triangles, materials and the UV report.' },
|
|
424
|
+
{ name: 'convert', verb: 'export', summary: 'Export textures from a project: presets, formats, padding and size.' },
|
|
425
|
+
],
|
|
426
|
+
catalogJson: false,
|
|
427
|
+
catalogArgs: ['commands'],
|
|
428
|
+
mcpArgs: ['mcp'],
|
|
429
|
+
mcpAttach: { flag: '--bridge', format: 'port' },
|
|
361
430
|
},
|
|
362
431
|
]
|
|
363
432
|
|
package/src/kit/tools.js
CHANGED
|
@@ -39,6 +39,9 @@ function resolveSample(profile , args )
|
|
|
39
39
|
const requested = args.sample
|
|
40
40
|
if (typeof requested === 'string' && requested.length > 0) return requested
|
|
41
41
|
if (requested === true) {
|
|
42
|
+
// A craft that starts a new document by default, or creates one itself,
|
|
43
|
+
// has nothing to select; asking for the sample is a harmless no-op.
|
|
44
|
+
if (profile.run.sampleFlag.length === 0 && (profile.run.sampleCreate !== undefined || profile.run.scriptFile !== undefined)) return undefined
|
|
42
45
|
if (profile.run.sampleFlag.length === 0) {
|
|
43
46
|
throw new Error(
|
|
44
47
|
`${profile.product} has no "new document" flag: pass \`input\` to open an existing document, `
|
|
@@ -154,23 +157,37 @@ function craftWorkDir(profile ) {
|
|
|
154
157
|
*/
|
|
155
158
|
export function createRuntime(profile , packageRoot , config = {}) {
|
|
156
159
|
let catalogCache
|
|
157
|
-
|
|
160
|
+
const mcpClients = new Map ()
|
|
161
|
+
const artifacts = config.artifactsDir ?? join(tmpdir(), 'dsh-artcraft', profile.id)
|
|
158
162
|
return {
|
|
163
|
+
artifactsDir() {
|
|
164
|
+
return artifacts
|
|
165
|
+
},
|
|
159
166
|
profile,
|
|
160
167
|
config,
|
|
161
|
-
mcp() {
|
|
168
|
+
mcp(attach ) {
|
|
162
169
|
if (config.enableMcp === false) return undefined
|
|
163
170
|
if (profile.mcpArgs === undefined) return undefined
|
|
171
|
+
if (attach !== undefined && profile.mcpAttach === undefined) {
|
|
172
|
+
throw new Error(`${profile.product} cannot attach to a running app: its MCP server is headless only`)
|
|
173
|
+
}
|
|
164
174
|
const binary = this.binary()
|
|
165
175
|
if (binary === undefined) return undefined
|
|
176
|
+
const args = [...profile.mcpSessionArgs ?? []]
|
|
177
|
+
if (attach !== undefined && profile.mcpAttach !== undefined) {
|
|
178
|
+
args.push(profile.mcpAttach.flag, profile.mcpAttach.format === 'hostPort' ? `127.0.0.1:${attach}` : String(attach))
|
|
179
|
+
}
|
|
180
|
+
const key = String(attach ?? '')
|
|
181
|
+
const existing = mcpClients.get(key)
|
|
166
182
|
// A CLI upgrade mid-session invalidates the running server.
|
|
167
|
-
if (
|
|
168
|
-
|
|
169
|
-
|
|
183
|
+
if (existing !== undefined && !existing.running && existing.cachedTools !== undefined) mcpClients.delete(key)
|
|
184
|
+
const fresh = mcpClients.get(key) ?? new CraftMcpClient(profile, binary, args)
|
|
185
|
+
mcpClients.set(key, fresh)
|
|
186
|
+
return fresh
|
|
170
187
|
},
|
|
171
188
|
dispose() {
|
|
172
|
-
|
|
173
|
-
|
|
189
|
+
for (const client of mcpClients.values()) client.close()
|
|
190
|
+
mcpClients.clear()
|
|
174
191
|
},
|
|
175
192
|
binary() {
|
|
176
193
|
const found = discoverCraft(profile, config.path, packageRoot)
|
|
@@ -361,7 +378,7 @@ export function createToolSet(runtime ) {
|
|
|
361
378
|
description: 'Ordered commands to apply. Each is { "id": "cell.set", "json": { "cell": "A1", "input": "x" } }. An entry may also be a raw CLI string.',
|
|
362
379
|
items: { type: 'json' },
|
|
363
380
|
},
|
|
364
|
-
...(profile.run.saveMode ?? 'flag')
|
|
381
|
+
...(profile.run.saveMode ?? 'flag') !== 'none'
|
|
365
382
|
? { save: { type: 'string', description: `Path to write the edited document to (${profile.extensions.join('/')}).` } }
|
|
366
383
|
: {},
|
|
367
384
|
// `export` is always advertised when the craft can render one. Where the
|
|
@@ -410,6 +427,48 @@ export function createToolSet(runtime ) {
|
|
|
410
427
|
// Crafts whose headless entry point only accepts a script file get one
|
|
411
428
|
// written for this call; the file names the same commands the inline
|
|
412
429
|
// style would have carried.
|
|
430
|
+
// Crafts whose headless verb takes a generated script file instead of
|
|
431
|
+
// inline command flags: FigCraft's `run script.json` and TextureCraft's
|
|
432
|
+
// `batch script.json`. The kernel writes it, so the model still supplies
|
|
433
|
+
// an ordered list and never has to know the file format.
|
|
434
|
+
if (profile.run.scriptFile !== undefined) {
|
|
435
|
+
const spec = profile.run.scriptFile
|
|
436
|
+
const savePath = typeof args.save === 'string' ? args.save : undefined
|
|
437
|
+
let effectiveInput = input
|
|
438
|
+
if (effectiveInput === undefined && profile.run.sampleCreate !== undefined) {
|
|
439
|
+
const created = join(craftWorkDir(profile), `sample-${Date.now()}${profile.run.sampleCreate.extension}`)
|
|
440
|
+
mkdirSync(dirname(created), { recursive: true })
|
|
441
|
+
const createdResult = await runCraft({
|
|
442
|
+
profile, binary,
|
|
443
|
+
argv: [profile.run.sampleCreate.verb, ...profile.run.sampleCreate.args, created],
|
|
444
|
+
cwd: process.cwd(), timeoutMs,
|
|
445
|
+
})
|
|
446
|
+
if (!createdResult.ok) return renderRun(createdResult, profile)
|
|
447
|
+
effectiveInput = created
|
|
448
|
+
}
|
|
449
|
+
const steps = commands.map(command => ({ [spec.commandKey]: command.id, [spec.paramsKey]: command.json ?? {} }))
|
|
450
|
+
const scriptPath = join(craftWorkDir(profile), `run-${Date.now()}.${spec.encoding === 'jsonl' ? 'jsonl' : 'json'}`)
|
|
451
|
+
mkdirSync(dirname(scriptPath), { recursive: true })
|
|
452
|
+
const argv = [profile.run.verb]
|
|
453
|
+
let body
|
|
454
|
+
if (spec.batch !== undefined) {
|
|
455
|
+
body = {
|
|
456
|
+
...effectiveInput !== undefined ? { [spec.batch.openKey]: effectiveInput } : {},
|
|
457
|
+
[spec.batch.stepsKey]: steps,
|
|
458
|
+
...savePath !== undefined ? { [spec.batch.saveKey]: savePath } : {},
|
|
459
|
+
}
|
|
460
|
+
argv.push(scriptPath)
|
|
461
|
+
} else {
|
|
462
|
+
body = spec.encoding === 'jsonl' ? steps.map(step => JSON.stringify(step)).join('\n') + '\n' : steps
|
|
463
|
+
argv.push(scriptPath)
|
|
464
|
+
if (effectiveInput !== undefined && spec.inputFlag !== undefined) argv.push(spec.inputFlag, effectiveInput)
|
|
465
|
+
if (savePath !== undefined && spec.saveFlag !== undefined) argv.push(spec.saveFlag, savePath)
|
|
466
|
+
}
|
|
467
|
+
writeFileSync(scriptPath, typeof body === 'string' ? body : JSON.stringify(body, null, 2) + '\n')
|
|
468
|
+
const extra = Array.isArray(args.extraArgs) ? args.extraArgs.filter((a) => typeof a === 'string') : []
|
|
469
|
+
const scriptResult = await runCraft({ profile, binary, argv: [...argv, ...extra], cwd: process.cwd(), timeoutMs })
|
|
470
|
+
return renderRun(scriptResult, profile)
|
|
471
|
+
}
|
|
413
472
|
if (profile.run.scriptFlag !== undefined) {
|
|
414
473
|
const scriptPath = join(craftWorkDir(profile), `steps-${Date.now()}.json`)
|
|
415
474
|
mkdirSync(dirname(scriptPath), { recursive: true })
|
|
@@ -621,13 +680,19 @@ export function createToolSet(runtime ) {
|
|
|
621
680
|
`${profile.product}'s own MCP server tools, each with a real JSON Schema. `
|
|
622
681
|
+ `These are the modelled operations (think \`write_range\`, \`render\`), whereas \`${profile.id}_run\` takes generic `
|
|
623
682
|
+ `command strings. Use this to discover them, then call one with \`${profile.id}_mcp_call\`. `
|
|
624
|
-
+ `Search by name or description
|
|
683
|
+
+ `Search by name or description, page with \`offset\`/\`limit\` (large servers declare hundreds). `
|
|
684
|
+
+ `Also lists the server's MCP resources and prompts when it has them.`,
|
|
625
685
|
parameters: {
|
|
626
686
|
search: { type: 'string', description: 'Case-insensitive filter over tool names and descriptions.' },
|
|
627
|
-
limit: { type: 'number', description: 'Maximum tools to describe (default 30).' },
|
|
687
|
+
limit: { type: 'number', description: 'Maximum tools to describe (default 30, at most 200).' },
|
|
688
|
+
offset: { type: 'number', description: 'Skip this many matches, to page through a long list.' },
|
|
689
|
+
...profile.mcpAttach !== undefined
|
|
690
|
+
? { attach: { type: 'number', description: `Control-channel port of a running ${profile.product} app, to drive the user's open document instead of a headless one.` } }
|
|
691
|
+
: {},
|
|
628
692
|
},
|
|
629
693
|
async execute(args) {
|
|
630
|
-
const
|
|
694
|
+
const attach = typeof args.attach === 'number' ? Math.floor(args.attach) : undefined
|
|
695
|
+
const client = runtime.mcp(attach)
|
|
631
696
|
if (client === undefined) throw new Error(`${profile.product} has no MCP server configured`)
|
|
632
697
|
await client.start(idleMs)
|
|
633
698
|
const all = await client.listTools()
|
|
@@ -636,10 +701,11 @@ export function createToolSet(runtime ) {
|
|
|
636
701
|
? all
|
|
637
702
|
: all.filter(tool => tool.name.toLowerCase().includes(needle) || (tool.description ?? '').toLowerCase().includes(needle))
|
|
638
703
|
const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.min(Math.floor(args.limit), 200) : 30
|
|
639
|
-
const
|
|
704
|
+
const offset = typeof args.offset === 'number' && args.offset > 0 ? Math.floor(args.offset) : 0
|
|
705
|
+
const shown = matched.slice(offset, offset + limit)
|
|
640
706
|
const lines = [
|
|
641
707
|
`${profile.product} MCP server — ${matched.length} tool(s)${needle.length > 0 ? ` matching ${JSON.stringify(args.search)}` : ''}`
|
|
642
|
-
+ (matched.length > shown.length ? `, showing ${shown.length}` : ''),
|
|
708
|
+
+ (matched.length > offset + shown.length ? `, showing ${offset}–${offset + shown.length}` : ''),
|
|
643
709
|
`server: ${client.describe()}`,
|
|
644
710
|
'',
|
|
645
711
|
]
|
|
@@ -648,6 +714,17 @@ export function createToolSet(runtime ) {
|
|
|
648
714
|
lines.push(renderSchema(tool.inputSchema))
|
|
649
715
|
}
|
|
650
716
|
if (shown.length === 0) lines.push('(no matching tools)')
|
|
717
|
+
// Resources and prompts are the parts a tool-only bridge would miss: the
|
|
718
|
+
// document, the command catalogue, and curated multi-step workflows.
|
|
719
|
+
const [resources, prompts] = await Promise.all([client.listResources(), client.listPrompts()])
|
|
720
|
+
if (resources.length > 0) {
|
|
721
|
+
lines.push('', 'resources:')
|
|
722
|
+
for (const resource of resources) lines.push(` ${resource.uri}${resource.name !== undefined ? ` — ${resource.name}` : ''}`)
|
|
723
|
+
}
|
|
724
|
+
if (prompts.length > 0) {
|
|
725
|
+
lines.push('', 'prompts:')
|
|
726
|
+
for (const prompt of prompts) lines.push(` ${prompt.name}${prompt.description !== undefined ? ` — ${prompt.description}` : ''}`)
|
|
727
|
+
}
|
|
651
728
|
return lines.join('\n')
|
|
652
729
|
},
|
|
653
730
|
}
|
|
@@ -658,13 +735,18 @@ export function createToolSet(runtime ) {
|
|
|
658
735
|
description:
|
|
659
736
|
`Call one tool on ${profile.product}'s MCP server with JSON arguments. `
|
|
660
737
|
+ `Discover names and schemas with \`${profile.id}_mcp_tools\` first. `
|
|
661
|
-
+ `Use this for modelled operations; use \`${profile.id}_run\` for arbitrary command ids
|
|
738
|
+
+ `Use this for modelled operations; use \`${profile.id}_run\` for arbitrary command ids. `
|
|
739
|
+
+ `Images a tool returns (render, screenshot) are saved to disk and named by path — look at them with \`read_image\`.`,
|
|
662
740
|
parameters: {
|
|
663
741
|
tool: { type: 'string', required: true, description: 'MCP tool name, exactly as listed.' },
|
|
664
742
|
arguments: { type: 'object', additionalProperties: true, description: "The tool's arguments object; see its schema in the listing." },
|
|
743
|
+
...profile.mcpAttach !== undefined
|
|
744
|
+
? { attach: { type: 'number', description: `Control-channel port of a running ${profile.product} app, to drive the user's open document instead of a headless one.` } }
|
|
745
|
+
: {},
|
|
665
746
|
},
|
|
666
747
|
async execute(args) {
|
|
667
|
-
const
|
|
748
|
+
const attach = typeof args.attach === 'number' ? Math.floor(args.attach) : undefined
|
|
749
|
+
const client = runtime.mcp(attach)
|
|
668
750
|
if (client === undefined) throw new Error(`${profile.product} has no MCP server configured`)
|
|
669
751
|
const name = String(args.tool ?? '').trim()
|
|
670
752
|
if (name.length === 0) throw new Error('tool is required')
|
|
@@ -677,7 +759,7 @@ export function createToolSet(runtime ) {
|
|
|
677
759
|
}
|
|
678
760
|
const raw = args.arguments
|
|
679
761
|
const params = typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? raw : {}
|
|
680
|
-
return await client.callTool(name, params)
|
|
762
|
+
return await client.callTool(name, params, runtime.artifactsDir())
|
|
681
763
|
},
|
|
682
764
|
}
|
|
683
765
|
|
|
@@ -694,6 +776,9 @@ export function createToolSet(runtime ) {
|
|
|
694
776
|
`command: ${client.describe()}`,
|
|
695
777
|
`running: ${client.running}`,
|
|
696
778
|
`tools cached: ${client.cachedTools === undefined ? 'no' : client.cachedTools.length}`,
|
|
779
|
+
profile.mcpAttach === undefined
|
|
780
|
+
? 'attach: not supported (headless MCP server only)'
|
|
781
|
+
: `attach: pass \`attach: <port>\` to drive a running app (it must be started with its control channel, e.g. \`${profile.bin.replace(/-cli$/, '')} --control <port>\`)`,
|
|
697
782
|
].join('\n')
|
|
698
783
|
},
|
|
699
784
|
}
|
|
@@ -780,11 +865,17 @@ export function applyCraftPlugin(
|
|
|
780
865
|
}
|
|
781
866
|
ctx.systemPrompt.section({
|
|
782
867
|
name: `artcraft:${profile.id}`,
|
|
783
|
-
text:
|
|
784
|
-
|
|
785
|
-
+ `
|
|
786
|
-
+
|
|
787
|
-
+
|
|
868
|
+
text:
|
|
869
|
+
`${profile.product} is available through the ${profile.id}_* tools; documents are ${profile.extensions.join('/')}. `
|
|
870
|
+
+ `Two surfaces reach the same engine. Use \`${profile.id}_commands\` for the full command registry `
|
|
871
|
+
+ `(every menu action; fetch exact parameter grammar with \`${profile.id}_describe\`) and \`${profile.id}_run\` to apply `
|
|
872
|
+
+ `an ordered list. Use \`${profile.id}_mcp_tools\` for the app's modelled operations with JSON Schemas when you want `
|
|
873
|
+
+ `a safer first call, then \`${profile.id}_mcp_call\`. `
|
|
874
|
+
+ `Read a document back with \`${profile.id}_inspect\` before editing it. `
|
|
875
|
+
+ (profile.mcpAttach === undefined
|
|
876
|
+
? ''
|
|
877
|
+
: `When the user has ${profile.product} open, pass \`attach: <control port>\` to an mcp call to work in their window instead of a headless copy. `)
|
|
878
|
+
+ `Images returned by a render or screenshot are saved and named by path; look at them with \`read_image\`.`,
|
|
788
879
|
order: CRAFT_SECTION_ORDER,
|
|
789
880
|
})
|
|
790
881
|
return () => {
|