dsh-artcraft-plugin-vectorcraft 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Run a craft CLI headlessly and collect a bounded, structured result.
3
+ *
4
+ * The craft processes are ordinary external programs: this module deliberately
5
+ * uses `node:child_process` rather than a harness capability seam so the same
6
+ * code works from a Cordis plugin, the MCP bridge, the CLI and the tests.
7
+ * @module @dsh-artcraft/craft-kit/exec
8
+ */
9
+
10
+ import { spawn } from 'node:child_process'
11
+ import { appendFileSync } from 'node:fs'
12
+
13
+
14
+ /** Default cap on captured stdout+stderr, so a runaway export cannot flood the model. */
15
+ export const MAX_CAPTURE_BYTES = 256 * 1024
16
+
17
+ /** Cap used for catalog reads, whose JSON is legitimately large (VectorCraft is ~350 KB). */
18
+ export const CATALOG_CAPTURE_BYTES = 16 * 1024 * 1024
19
+
20
+ /** Default wall-clock budget for one headless craft run. */
21
+ export const DEFAULT_TIMEOUT_MS = 120_000
22
+
23
+ /** Arguments accepted by {@link runCraft}. */
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+ /**
36
+ * Spawn a craft CLI, capture both streams, and settle on exit.
37
+ *
38
+ * Never rejects for a non-zero exit: the craft's own error text is the useful
39
+ * result, so it is returned as `ok: false` with the raw output attached.
40
+ * Rejects only when the process could not be spawned at all.
41
+ * @param options - profile, resolved binary, argv, cwd, and limits.
42
+ * @returns the structured run result.
43
+ */
44
+ export async function runCraft(options ) {
45
+ const { profile, binary, argv, cwd } = options
46
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
47
+ const started = Date.now()
48
+ const full = [...(profile.baseArgs ?? []), ...argv]
49
+ const cap = options.maxCaptureBytes ?? MAX_CAPTURE_BYTES
50
+ // DSH_ARTCRAFT_TRACE=<file> records every exact invocation, which is how a
51
+ // craft whose CLI is picky about argument shape gets debugged without
52
+ // guessing at what the plugin sent.
53
+ if (process.env.DSH_ARTCRAFT_TRACE !== undefined) {
54
+ try {
55
+ appendFileSync(process.env.DSH_ARTCRAFT_TRACE, `${binary.path} ${full.map(a => JSON.stringify(a)).join(' ')}\n`)
56
+ } catch {
57
+ // Tracing must never break a run.
58
+ }
59
+ }
60
+
61
+ return await new Promise ((resolve, reject) => {
62
+ const child = spawn(binary.path, full, {
63
+ cwd,
64
+ stdio: ['ignore', 'pipe', 'pipe'],
65
+ env: { ...process.env, NO_COLOR: '1', TERM: 'dumb' },
66
+ })
67
+
68
+ const stdout = []
69
+ const stderr = []
70
+ let captured = 0
71
+ let truncated = false
72
+
73
+ const collect = (sink ) => (chunk ) => {
74
+ if (captured >= cap) { truncated = true; return }
75
+ const room = cap - captured
76
+ if (chunk.length > room) {
77
+ sink.push(chunk.subarray(0, room))
78
+ captured = cap
79
+ truncated = true
80
+ } else {
81
+ sink.push(chunk)
82
+ captured += chunk.length
83
+ }
84
+ }
85
+
86
+ child.stdout.on('data', collect(stdout))
87
+ child.stderr.on('data', collect(stderr))
88
+
89
+ let timedOut = false
90
+ const timer = setTimeout(() => {
91
+ timedOut = true
92
+ child.kill('SIGKILL')
93
+ }, timeoutMs)
94
+
95
+ const onAbort = () => { child.kill('SIGKILL') }
96
+ options.signal?.addEventListener('abort', onAbort, { once: true })
97
+
98
+ const cleanup = () => {
99
+ clearTimeout(timer)
100
+ options.signal?.removeEventListener('abort', onAbort)
101
+ }
102
+
103
+ child.on('error', (error) => {
104
+ cleanup()
105
+ reject(new Error(`${profile.product}: failed to start ${binary.path}: ${error.message}`))
106
+ })
107
+
108
+ child.on('close', (code, signal) => {
109
+ cleanup()
110
+ const out = Buffer.concat(stdout).toString('utf8')
111
+ const err = Buffer.concat(stderr).toString('utf8')
112
+ const tail = timedOut ? `\n[timed out after ${timeoutMs}ms]` : ''
113
+ resolve({
114
+ ok: code === 0 && !timedOut,
115
+ bin: binary.path,
116
+ argv: full,
117
+ cwd,
118
+ exitCode: code,
119
+ signal,
120
+ stdout: out,
121
+ stderr: err + tail,
122
+ truncated,
123
+ durationMs: Date.now() - started,
124
+ artifacts: [],
125
+ })
126
+ })
127
+ })
128
+ }
129
+
130
+ /**
131
+ * Compose the argv for one headless `run` invocation.
132
+ *
133
+ * Each craft spells "run these commands" differently — repeated `--cmd id=json`
134
+ * (CADCraft, GridCraft), `--cmd id --params json` (PhotoCraft, VectorCraft),
135
+ * positional `id key=value` (LightCraft, EffectCraft), or a JSON-lines script
136
+ * file (FilmCraft). Profile fields select the spelling; this function owns the
137
+ * assembly so every plugin behaves identically.
138
+ * @param profile - the craft profile supplying flag spellings.
139
+ * @param options - document source, commands, outputs and extras.
140
+ * @returns the argv after the executable.
141
+ */
142
+ export function buildRunArgv(
143
+ profile ,
144
+ options
145
+
146
+
147
+
148
+
149
+
150
+
151
+
152
+
153
+
154
+
155
+ ,
156
+ ) {
157
+ const spec = profile.run
158
+ const inputMode = spec.inputMode ?? (spec.inFlag.length > 0 ? 'flag' : 'none')
159
+ // Some crafts take project-level flags before the subcommand, not after.
160
+ const argv = [...options.globalFlags ?? [], spec.verb]
161
+ // CADCraft takes the drawing as a positional argument to `run` itself.
162
+ if (options.input !== undefined && inputMode === 'positional') argv.push(options.input)
163
+ else if (options.input !== undefined && inputMode === 'flag' && spec.inFlag.length > 0) {
164
+ argv.push(spec.inFlag, options.input)
165
+ } else if (options.input === undefined && options.sample !== undefined && options.sample !== false) {
166
+ if (spec.sampleFlag.length > 0) {
167
+ argv.push(spec.sampleFlag)
168
+ if (typeof options.sample === 'string') argv.push(options.sample)
169
+ else if (spec.newSampleValue !== undefined) argv.push(spec.newSampleValue)
170
+ }
171
+ }
172
+ for (const command of options.commands ?? []) {
173
+ const style = spec.cmdStyle ?? 'flag'
174
+ const json = command.json === undefined || command.json === null || command.json === '' ? undefined : command.json
175
+ const jsonText = json === undefined ? undefined : typeof json === 'string' ? json : JSON.stringify(json)
176
+ switch (style) {
177
+ case 'flag':
178
+ argv.push(spec.cmdFlag, formatCommand(command.id, json, spec.cmdSeparator))
179
+ break
180
+ case 'flag-params':
181
+ argv.push(spec.cmdFlag, command.id)
182
+ if (spec.cmdParamsFlag !== undefined && jsonText !== undefined) argv.push(spec.cmdParamsFlag, jsonText)
183
+ break
184
+ case 'positional-pair':
185
+ argv.push(command.id)
186
+ if (jsonText !== undefined) argv.push(jsonText)
187
+ break
188
+ case 'positional-kv':
189
+ argv.push(command.id)
190
+ for (const [key, value] of Object.entries(asRecord(json))) {
191
+ argv.push(`${key}=${typeof value === 'string' ? value : JSON.stringify(value)}`)
192
+ }
193
+ break
194
+ }
195
+ }
196
+ if (options.save !== undefined && (spec.saveMode ?? 'flag') === 'flag') argv.push(spec.saveFlag, options.save)
197
+ if (options.export !== undefined && spec.exportFlag !== undefined) argv.push(spec.exportFlag, options.export)
198
+ if (options.exportAllPages !== undefined && spec.exportAllPagesFlag !== undefined) {
199
+ argv.push(spec.exportAllPagesFlag, options.exportAllPages)
200
+ }
201
+ if (options.print === true && spec.printFlag !== undefined) argv.push(spec.printFlag)
202
+ argv.push(...options.extra ?? [])
203
+ return argv
204
+ }
205
+
206
+ /** One command to apply, before it is spelled for a particular CLI. */
207
+
208
+
209
+
210
+
211
+
212
+ /** Coerce a model-supplied JSON argument into a flat parameter map. */
213
+ function asRecord(value ) {
214
+ if (typeof value === 'string' && value.trim().length > 0) {
215
+ try {
216
+ const parsed = JSON.parse(value)
217
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) return parsed
218
+ } catch {
219
+ return {}
220
+ }
221
+ }
222
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) return value
223
+ return {}
224
+ }
225
+
226
+ /**
227
+ * Normalize one model-supplied command into the CLI's `--cmd` value.
228
+ *
229
+ * The model writes `{ "id": "cell.set", "json": "{\"cell\":\"A1\"}" }` or a
230
+ * bare id; the craft CLI wants `id={...}` on GridCraft and `id {...}` on
231
+ * SoundCraft, so the separator comes from the profile.
232
+ * @param id - command id.
233
+ * @param json - optional JSON argument object.
234
+ * @param separator - the craft's separator between id and JSON.
235
+ * @returns the single argv value for `--cmd`.
236
+ */
237
+ export function formatCommand(id , json , separator ) {
238
+ if (json === undefined || json === null || json === '') return id
239
+ const text = typeof json === 'string' ? json : JSON.stringify(json)
240
+ if (text.length === 0) return id
241
+ return `${id}${separator}${text}`
242
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The ArtCraft DSH plugin kernel.
3
+ *
4
+ * Import this from a plugin package that does the actual
5
+ * `ctx.tools.register(defineTool(...))` calls; everything here is plain Node
6
+ * so it can also run from the MCP bridge, the CLI verifier and unit tests.
7
+ * @module @dsh-artcraft/craft-kit
8
+ */
9
+
10
+ export * from './types.js'
11
+ export * from './discover.js'
12
+ export * from './exec.js'
13
+ export * from './catalog.js'
14
+ export * from './tools.js'
15
+ export * from './mcp.js'
16
+ export { CRAFT_PROFILES, CRAFTS_BIN_DIR, PROFILES_BY_ID, REPOS_DIR, profileOf } from './profiles.mjs'
package/src/kit/mcp.js ADDED
@@ -0,0 +1,231 @@
1
+ /**
2
+ * An MCP (Model Context Protocol) client for a craft's own stdio server.
3
+ *
4
+ * Every craft ships `*-cli mcp`, a JSON-RPC 2.0 server with a modelled tool set
5
+ * (GridCraft 21 tools, SoundCraft more) alongside the generic command strings
6
+ * the CLI takes. Bridging to it gives the model tools with real JSON Schemas
7
+ * instead of one `--cmd` string, which is why the plugins expose both.
8
+ *
9
+ * The server is spawned once per runtime and kept warm: starting GridCraft's
10
+ * engine costs ~600 ms, which is too much to pay on every call. It is killed
11
+ * after `idleMs` without a request, and again when the plugin unloads.
12
+ * @module @dsh-artcraft/craft-kit/mcp
13
+ */
14
+
15
+ import { spawn } from 'node:child_process'
16
+
17
+
18
+
19
+ /** One tool as the MCP server declares it. */
20
+
21
+
22
+
23
+
24
+
25
+
26
+ /** A settled JSON-RPC response. */
27
+
28
+
29
+
30
+
31
+
32
+
33
+ /** Default MCP protocol revision; servers negotiate down if they need to. */
34
+ const PROTOCOL_VERSION = '2024-11-05'
35
+
36
+ /**
37
+ * One live MCP connection to a craft.
38
+ *
39
+ * Kept deliberately small: one process, one pending-request table, and a
40
+ * close path that always kills the child, because a craft's engine holds
41
+ * memory and a leaked server would outlive the session that started it.
42
+ */
43
+ export class CraftMcpClient {
44
+ #profile
45
+ #binary
46
+ #args
47
+ #child
48
+ #buffer = ''
49
+ #nextId = 1
50
+ #pending = new Map ()
51
+ #idleTimer
52
+ #handshake
53
+ #tools
54
+ #closed = false
55
+
56
+ constructor(profile , binary , args = []) {
57
+ this.#profile = profile
58
+ this.#binary = binary
59
+ this.#args = args
60
+ }
61
+
62
+ /** Whether a live server process is currently attached. */
63
+ get running() {
64
+ return this.#child !== undefined && this.#child.exitCode === null && !this.#closed
65
+ }
66
+
67
+ /** Tools the server declared, from the cached `tools/list` result. */
68
+ get cachedTools() {
69
+ return this.#tools
70
+ }
71
+
72
+ /** One-line description of how this client was started, for diagnostics. */
73
+ describe() {
74
+ return `${this.#binary.path} ${[...this.#args].join(' ')}`.trim()
75
+ }
76
+
77
+ /** Start the server if needed and complete the MCP handshake. */
78
+ async start(idleMs ) {
79
+ if (this.#closed) throw new Error(`${this.#profile.product}: MCP client is closed`)
80
+ if (this.running) return
81
+ const child = spawn(this.#binary.path, [...(this.#profile.mcpArgs ?? ['mcp']), ...this.#args], {
82
+ stdio: ['pipe', 'pipe', 'pipe'],
83
+ env: { ...process.env, NO_COLOR: '1', TERM: 'dumb' },
84
+ })
85
+ this.#child = child
86
+ this.#buffer = ''
87
+ child.stdout.setEncoding('utf8')
88
+ child.stdout.on('data', (chunk ) => { this.#onData(chunk) })
89
+ child.stderr.setEncoding('utf8')
90
+ // Craft servers announce themselves on stderr; it is not an error stream.
91
+ child.stderr.on('data', (chunk ) => {
92
+ const text = chunk.trim()
93
+ if (text.length > 0 && process.env.DSH_ARTCRAFT_TRACE !== undefined) {
94
+ process.stderr.write(`[${this.#profile.id} mcp] ${text}\n`)
95
+ }
96
+ })
97
+ child.on('exit', () => {
98
+ this.#child = undefined
99
+ for (const [, pending] of this.#pending) {
100
+ clearTimeout(pending.timer)
101
+ pending.reject(new Error(`${this.#profile.product}: MCP server exited`))
102
+ }
103
+ this.#pending.clear()
104
+ })
105
+ child.on('error', (error) => {
106
+ this.#child = undefined
107
+ for (const [, pending] of this.#pending) {
108
+ clearTimeout(pending.timer)
109
+ pending.reject(new Error(`${this.#profile.product}: MCP server failed to start: ${error.message}`))
110
+ }
111
+ this.#pending.clear()
112
+ })
113
+ this.#idleMs = idleMs
114
+ await this.#request('initialize', {
115
+ protocolVersion: PROTOCOL_VERSION,
116
+ capabilities: {},
117
+ clientInfo: { name: 'dsh-artcraft', version: '0.1.0' },
118
+ })
119
+ this.#notify('notifications/initialized', {})
120
+ }
121
+
122
+ #idleMs = 300_000
123
+
124
+ /** Walk the server's tool list, caching it for later calls. */
125
+ async listTools() {
126
+ if (this.#tools !== undefined) return [...this.#tools]
127
+ const result = await this.#request('tools/list', {})
128
+ this.#tools = Array.isArray(result.tools) ? result.tools : []
129
+ return [...this.#tools]
130
+ }
131
+
132
+ /**
133
+ * Call one tool.
134
+ * @param name - tool name as declared by the server.
135
+ * @param args - the tool's JSON arguments.
136
+ * @returns the concatenated text content of the result.
137
+ */
138
+ async callTool(name , args ) {
139
+ const result = await this.#request('tools/call', { name, arguments: args })
140
+
141
+
142
+
143
+ const text = (result.content ?? [])
144
+ .map(block => (block.type === 'text' ? block.text ?? '' : `[${block.type ?? 'unknown'} content]`))
145
+ .join('\n')
146
+ .trim()
147
+ return `${text.length > 0 ? text : '(no content)'}${result.isError === true ? '\n[mcp: tool reported an error]' : ''}`
148
+ }
149
+
150
+ /** Terminate the server process now. */
151
+ close() {
152
+ this.#closed = true
153
+ if (this.#idleTimer !== undefined) clearTimeout(this.#idleTimer)
154
+ const child = this.#child
155
+ this.#child = undefined
156
+ if (child !== undefined && child.exitCode === null) child.kill('SIGKILL')
157
+ }
158
+
159
+ /** Buffer stdout and resolve one response per complete line. */
160
+ #onData(chunk ) {
161
+ this.#buffer += chunk
162
+ let index
163
+ while ((index = this.#buffer.indexOf('\n')) !== -1) {
164
+ const line = this.#buffer.slice(0, index).trim()
165
+ this.#buffer = this.#buffer.slice(index + 1)
166
+ if (line.length === 0) continue
167
+ let message
168
+ try {
169
+ message = JSON.parse(line)
170
+ } catch {
171
+ continue // A craft may log to stdout; only JSON-RPC lines matter.
172
+ }
173
+ if (typeof message.id !== 'number') continue
174
+ const pending = this.#pending.get(message.id)
175
+ if (pending === undefined) continue
176
+ this.#pending.delete(message.id)
177
+ clearTimeout(pending.timer)
178
+ if (message.error !== undefined) pending.reject(new Error(message.error.message ?? 'MCP error'))
179
+ else pending.resolve(message.result)
180
+ }
181
+ }
182
+
183
+ /** Send one request and await its response, arming the idle timer. */
184
+ #request(method , params , timeoutMs = 60_000) {
185
+ const child = this.#child
186
+ if (child === undefined || child.exitCode !== null) {
187
+ return Promise.reject(new Error(`${this.#profile.product}: MCP server is not running`))
188
+ }
189
+ const id = this.#nextId++
190
+ return new Promise ((resolve, reject) => {
191
+ const timer = setTimeout(() => {
192
+ this.#pending.delete(id)
193
+ reject(new Error(`${this.#profile.product}: MCP ${method} timed out after ${timeoutMs}ms`))
194
+ }, timeoutMs)
195
+ this.#pending.set(id, { resolve, reject, timer })
196
+ child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`)
197
+ this.#armIdle()
198
+ })
199
+ }
200
+
201
+ /** Send a notification, which has no response. */
202
+ #notify(method , params ) {
203
+ this.#child?.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`)
204
+ this.#armIdle()
205
+ }
206
+
207
+ /** (Re)start the countdown that shuts an unused server down. */
208
+ #armIdle() {
209
+ if (this.#idleTimer !== undefined) clearTimeout(this.#idleTimer)
210
+ this.#idleTimer = setTimeout(() => { this.close() }, this.#idleMs)
211
+ this.#idleTimer.unref?.()
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Render an MCP tool's JSON Schema as a compact parameter grammar for the model.
217
+ * @param schema - the tool's `inputSchema`.
218
+ * @returns one line per property, or `(no parameters)`.
219
+ */
220
+ export function renderSchema(schema ) {
221
+ const properties = schema?.properties
222
+ if (typeof properties !== 'object' || properties === null) return '(no parameters)'
223
+ const required = new Set(Array.isArray(schema?.required) ? schema.required.filter((r) => typeof r === 'string') : [])
224
+ return Object.entries(properties )
225
+ .map(([name, node]) => {
226
+ const type = node.type ?? (Array.isArray(node.enum) ? 'enum' : 'value')
227
+ const enumText = Array.isArray(node.enum) ? ` (${node.enum.map(e => JSON.stringify(e)).join(' | ')})` : ''
228
+ return ` ${name}${required.has(name) ? '' : '?'}: ${type}${enumText}${node.description !== undefined ? ` — ${node.description}` : ''}`
229
+ })
230
+ .join('\n')
231
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Which release asset to install on this machine, and where the CLI sits inside it.
3
+ *
4
+ * Each craft publishes its CLI in a different wrapper per platform — a dedicated
5
+ * `-cli-` zip on macOS, but the full application archive on Windows and Linux.
6
+ * Everything below was measured against the real release assets:
7
+ *
8
+ * macOS <craft>-cli-<v>-macos-universal.zip → <craft>-cli
9
+ * Windows <craft>-<v>-windows-x64-portable.zip → <craft>-cli.exe
10
+ * Linux <craft>-<v>-linux-x86_64.tar.gz → bin/<craft>-cli
11
+ *
12
+ * The tables are data, not logic, so adding a craft or a platform is a one-line
13
+ * change and `scripts/install-craft.mjs --list` can print what would be fetched.
14
+ * @module @dsh-artcraft/craft-kit/platform
15
+ */
16
+
17
+ import { arch as nodeArch, platform as nodePlatform } from 'node:process'
18
+
19
+ /** A supported operating system, as Node names it. */
20
+
21
+
22
+ /** CPU families the crafts publish builds for. */
23
+
24
+
25
+ /** One downloadable archive plus how to get the CLI out of it. */
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+ /** Written version marker beside each installed binary. */
38
+ export const VERSION_MARKER = '.version.json'
39
+
40
+ /** Target for a craft `id` (product) on `platform`/`arch`, or `undefined` when unsupported. */
41
+ export function releaseTarget(
42
+ id ,
43
+ platform = nodePlatform ,
44
+ arch = nodeArch ,
45
+ ) {
46
+ if (platform === 'darwin') {
47
+ // One universal build covers Intel and Apple silicon.
48
+ return {
49
+ label: 'macOS (universal)',
50
+ asset: (craft, version) => `${craft}-cli-${version}-macos-universal.zip`,
51
+ // Release archives wrap everything in a versioned directory, so match on
52
+ // the final segment rather than the whole path.
53
+ match: (craft, path) => path === `${craft}-cli` || path.endsWith(`/${craft}-cli`),
54
+ container: 'zip',
55
+ }
56
+ }
57
+ if (platform === 'win32') {
58
+ if (arch !== 'x64' && arch !== 'arm64') return undefined
59
+ return {
60
+ label: `Windows ${arch} (portable)`,
61
+ asset: (craft, version) => `${craft}-${version}-windows-${arch}-portable.zip`,
62
+ match: (craft, path) => path.endsWith(`${craft}-cli.exe`),
63
+ container: 'zip',
64
+ }
65
+ }
66
+ if (platform === 'linux') {
67
+ if (arch !== 'x64' && arch !== 'arm64') return undefined
68
+ return {
69
+ label: `Linux ${arch} (tarball)`,
70
+ // riscv64 builds exist for some crafts but are not a Node platform here.
71
+ asset: (craft, version) => `${craft}-${version}-linux-${arch === 'x64' ? 'x86_64' : 'aarch64'}.tar.gz`,
72
+ match: (craft, path) => path === `bin/${craft}-cli` || path.endsWith(`/${craft}-cli`),
73
+ container: 'tar.gz',
74
+ }
75
+ }
76
+ return undefined
77
+ }
78
+
79
+ /** The executable name a craft's CLI has on this platform. */
80
+ export function binaryName(craft , platform = nodePlatform ) {
81
+ return platform === 'win32' ? `${craft}-cli.exe` : `${craft}-cli`
82
+ }