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.
- package/cordis.patch.yml +5 -0
- package/package.json +45 -4
- package/src/index.ts +38 -0
- package/src/kit/catalog.js +230 -0
- package/src/kit/discover.js +194 -0
- package/src/kit/exec.js +242 -0
- package/src/kit/index.js +16 -0
- package/src/kit/mcp.js +231 -0
- package/src/kit/platform.js +82 -0
- package/src/kit/profiles.mjs +371 -0
- package/src/kit/tools.js +789 -0
- package/src/kit/types.js +197 -0
- package/src/root.ts +13 -0
- package/README.md +0 -3
package/src/kit/exec.js
ADDED
|
@@ -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
|
+
}
|
package/src/kit/index.js
ADDED
|
@@ -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
|
+
}
|