dsh-artcraft-plugin-soundcraft 0.1.2 → 0.1.3

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 CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "dsh-artcraft-plugin-soundcraft",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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.",
7
7
  "license": "MIT OR Apache-2.0",
8
8
  "exports": {
9
- ".": "./src/index.ts",
9
+ ".": "./src/index.js",
10
10
  "./package.json": "./package.json"
11
11
  },
12
12
  "peerDependencies": {
@@ -23,8 +23,7 @@
23
23
  },
24
24
  "files": [
25
25
  "src",
26
- "cordis.patch.yml",
27
- "README.md"
26
+ "cordis.patch.yml"
28
27
  ],
29
28
  "dsh": {
30
29
  "bundle": {
@@ -44,4 +43,4 @@
44
43
  "publishConfig": {
45
44
  "access": "public"
46
45
  }
47
- }
46
+ }
package/src/index.js ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * SoundCraft as a DSH plugin.
3
+ *
4
+ * Registers model-callable tools that drive the `soundcraft-cli` binary this machine
5
+ * already has — both its generic `--cmd` surface and its own MCP server. The
6
+ * SoundCraft source tree is never touched: the CLI is an external process, located
7
+ * at call time so installing it mid-session works.
8
+ *
9
+ * The kernel under `./kit/` is generated from `dsh-plugins/craft-kit/src` by
10
+ * `scripts/build-kit.mjs` and inlined so this package installs standalone.
11
+ * @module dsh-artcraft-plugin-soundcraft
12
+ */
13
+
14
+ import { defineTool } from '@deepseek-ai/dsh-tools'
15
+
16
+ import { applyCraftPlugin, profileOf } from './kit/index.js'
17
+
18
+ import { PLUGIN_ROOT } from './root.js'
19
+
20
+ export const name = 'dsh-artcraft-soundcraft'
21
+ export const inject = ['tools', 'systemPrompt']
22
+
23
+ /** The craft this plugin exposes. */
24
+ export const craft = profileOf('soundcraft')
25
+
26
+ /** Configuration is the shared craft config. */
27
+
28
+
29
+ export function apply(ctx , config = {}) {
30
+ if (craft === undefined) throw new Error('dsh-artcraft-soundcraft: profile "soundcraft" is missing from the inlined kernel')
31
+ // `ctx.effect` ties the registration and the MCP server to this plugin's fiber.
32
+ ctx.effect(() => applyCraftPlugin(ctx, {
33
+ profile: craft,
34
+ packageRoot: PLUGIN_ROOT,
35
+ config,
36
+ defineTool: defineTool ,
37
+ }))
38
+ }
@@ -20,20 +20,36 @@ import { execFileSync } from 'node:child_process'
20
20
  import { fileURLToPath } from 'node:url'
21
21
 
22
22
 
23
- /** True when `path` is an executable regular file (or a symlink to one). */
23
+ /**
24
+ * True when `path` is an executable regular file (or a symlink to one).
25
+ *
26
+ * `X_OK` is a no-op on Windows — it succeeds for any file that exists — so on
27
+ * that platform the name is additionally required to carry an executable
28
+ * extension (`PATHEXT`), which stops a stray text file called
29
+ * `gridcraft-cli` from being adopted as the binary.
30
+ */
24
31
  export function isExecutable(path ) {
25
32
  try {
26
33
  if (!statSync(path).isFile()) return false
27
34
  accessSync(path, constants.X_OK)
35
+ if (process.platform === 'win32') {
36
+ const extensions = (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD')
37
+ .split(';').map(extension => extension.trim().toLowerCase()).filter(extension => extension.length > 0)
38
+ const lower = path.toLowerCase()
39
+ return extensions.some(extension => lower.endsWith(extension))
40
+ }
28
41
  return true
29
42
  } catch {
30
43
  return false
31
44
  }
32
45
  }
33
46
 
34
- /** Every directory on `PATH`, in order. */
47
+ /** Every directory on `PATH`, in order, with quoting removed. */
35
48
  function pathDirs() {
36
- return (process.env.PATH ?? '').split(delimiter).filter(entry => entry.length > 0)
49
+ return (process.env.PATH ?? '')
50
+ .split(delimiter)
51
+ .map(entry => entry.trim().replace(/^"(.*)"$/, '$1'))
52
+ .filter(entry => entry.length > 0)
37
53
  }
38
54
 
39
55
  /** Directory holding this kernel (`<workspace>/dsh-plugins/craft-kit/src`). */
@@ -111,16 +127,39 @@ export function candidates(profile , configured , packageRo
111
127
  const resolved = isAbsolute(dir) ? dir : resolve(base, dir)
112
128
  if (seen.has(resolved)) continue
113
129
  seen.add(resolved)
114
- out.push({ path: join(resolved, profile.bin), source: `search-dir:${resolved}` })
130
+ // Windows installs the CLI as `<craft>-cli.exe`, so the platform's own
131
+ // name has to be tried before the bare one; otherwise a workspace
132
+ // `crafts-bin/` copy is never found there.
133
+ for (const name of candidateNames(profile)) {
134
+ out.push({ path: join(resolved, name), source: `search-dir:${resolved}` })
135
+ }
115
136
  }
116
137
  }
117
138
  for (const dir of pathDirs()) {
118
- out.push({ path: join(dir, profile.bin), source: 'PATH' })
119
- if (process.platform === 'win32') out.push({ path: join(dir, `${profile.bin}.exe`), source: 'PATH' })
139
+ for (const name of candidateNames(profile)) {
140
+ out.push({ path: join(dir, name), source: 'PATH' })
141
+ }
120
142
  }
121
143
  return out
122
144
  }
123
145
 
146
+ /**
147
+ * Names a craft CLI may have on this platform, most likely first.
148
+ *
149
+ * Windows installs `<craft>-cli.exe`, so the extension is what a `PATH` or
150
+ * `crafts-bin/` lookup has to try; `PATHEXT` is honoured rather than assumed
151
+ * because a machine may have narrowed it. The bare name comes last, and only on
152
+ * Windows, for a CLI a user renamed themselves.
153
+ * @param profile - the craft being located.
154
+ * @returns candidate file names, deduplicated.
155
+ */
156
+ function candidateNames(profile ) {
157
+ if (process.platform !== 'win32') return [profile.bin]
158
+ const extensions = (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD')
159
+ .split(';').map(extension => extension.trim().toLowerCase()).filter(extension => extension.length > 0)
160
+ return [...new Set([platformBinaryName(profile), ...extensions.map(extension => `${profile.bin}${extension}`), profile.bin])]
161
+ }
162
+
124
163
  /** The CLI's executable name on this platform (`-cli.exe` on Windows). */
125
164
  export function platformBinaryName(profile ) {
126
165
  return process.platform === 'win32' ? `${profile.bin}.exe` : profile.bin
@@ -178,6 +217,13 @@ export function craftVersion(path , timeoutMs = 5000)
178
217
  */
179
218
  export function missingBinaryMessage(profile , configured , packageRoot ) {
180
219
  const tried = candidates(profile, configured, packageRoot)
220
+ const executable = `${profile.bin}${process.platform === 'win32' ? '.exe' : ''}`
221
+ const example = process.platform === 'win32'
222
+ ? `C:\\path\\to\\${executable}`
223
+ : `/absolute/path/to/${executable}`
224
+ const setEnv = process.platform === 'win32'
225
+ ? `$env:${profile.envVar}="${example}" (PowerShell; set ${profile.envVar}=${example} in cmd.exe)`
226
+ : `export ${profile.envVar}=${example}`
181
227
  const lines = [
182
228
  `${profile.product}: '${profile.bin}' was not found, so the ${profile.id} tools cannot run.`,
183
229
  `Searched, in order:`,
@@ -186,9 +232,12 @@ export function missingBinaryMessage(profile , configured ,
186
232
  lines.push(
187
233
  `Fix it by any of:`,
188
234
  ` - run \`node scripts/install-craft.mjs ${profile.id}\` (macOS, Windows and Linux)`,
189
- ` - set ${profile.envVar}=/absolute/path/to/${profile.bin}`,
235
+ ` - set ${setEnv}`,
190
236
  ` - download the CLI from https://github.com/storytold/${profile.id}/releases/latest and put it on PATH`,
191
- ` - pass { "path": "/absolute/path/to/${profile.bin}" } in this plugin's config`,
237
+ ` - pass { "path": "${example.replace(/\\/g, '\\\\')}" } in this plugin's config`,
192
238
  )
239
+ if (process.platform === 'win32') {
240
+ lines.push(`The Windows build is the portable archive's \`${executable}\`; no Rust toolchain is needed.`)
241
+ }
193
242
  return lines.join('\n')
194
243
  }
package/src/kit/exec.js CHANGED
@@ -7,10 +7,36 @@
7
7
  * @module @dsh-artcraft/craft-kit/exec
8
8
  */
9
9
 
10
- import { spawn } from 'node:child_process'
10
+ import { spawn, spawnSync } from 'node:child_process'
11
+
11
12
  import { appendFileSync } from 'node:fs'
12
13
 
13
14
 
15
+ /**
16
+ * Terminate a craft process and anything it started.
17
+ *
18
+ * Windows has no process groups and no signals, so `child.kill` reaches only
19
+ * the direct child: a craft engine that spawned helpers would leave them
20
+ * running. `taskkill /T` walks the child tree instead. Both steps are
21
+ * best-effort, because by the time this runs the process may already be gone.
22
+ * @param child - the process to terminate.
23
+ */
24
+ export function killTree(child ) {
25
+ if (child.exitCode !== null || child.signalCode !== null) return
26
+ try {
27
+ child.kill('SIGKILL')
28
+ } catch {
29
+ // Already exited.
30
+ }
31
+ // Re-check before taskkill: a recycled pid would otherwise be killed.
32
+ if (process.platform !== 'win32' || child.pid === undefined || child.exitCode !== null) return
33
+ try {
34
+ spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' })
35
+ } catch {
36
+ // taskkill is absent on some minimal images; the direct kill still ran.
37
+ }
38
+ }
39
+
14
40
  /** Default cap on captured stdout+stderr, so a runaway export cannot flood the model. */
15
41
  export const MAX_CAPTURE_BYTES = 256 * 1024
16
42
 
@@ -63,6 +89,8 @@ export async function runCraft(options )
63
89
  cwd,
64
90
  stdio: ['ignore', 'pipe', 'pipe'],
65
91
  env: { ...process.env, NO_COLOR: '1', TERM: 'dumb' },
92
+ // Without this Windows flashes a console window for every craft run.
93
+ windowsHide: true,
66
94
  })
67
95
 
68
96
  const stdout = []
@@ -89,10 +117,10 @@ export async function runCraft(options )
89
117
  let timedOut = false
90
118
  const timer = setTimeout(() => {
91
119
  timedOut = true
92
- child.kill('SIGKILL')
120
+ killTree(child)
93
121
  }, timeoutMs)
94
122
 
95
- const onAbort = () => { child.kill('SIGKILL') }
123
+ const onAbort = () => { killTree(child) }
96
124
  options.signal?.addEventListener('abort', onAbort, { once: true })
97
125
 
98
126
  const cleanup = () => {
package/src/kit/mcp.js CHANGED
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { spawn } from 'node:child_process'
16
16
 
17
+ import { killTree } from './exec.js'
17
18
 
18
19
 
19
20
  /** One tool as the MCP server declares it. */
@@ -81,6 +82,8 @@ export class CraftMcpClient {
81
82
  const child = spawn(this.#binary.path, [...(this.#profile.mcpArgs ?? ['mcp']), ...this.#args], {
82
83
  stdio: ['pipe', 'pipe', 'pipe'],
83
84
  env: { ...process.env, NO_COLOR: '1', TERM: 'dumb' },
85
+ // Keeps the long-lived server from flashing a console window on Windows.
86
+ windowsHide: true,
84
87
  })
85
88
  this.#child = child
86
89
  this.#buffer = ''
@@ -153,7 +156,9 @@ export class CraftMcpClient {
153
156
  if (this.#idleTimer !== undefined) clearTimeout(this.#idleTimer)
154
157
  const child = this.#child
155
158
  this.#child = undefined
156
- if (child !== undefined && child.exitCode === null) child.kill('SIGKILL')
159
+ // `killTree` rather than `child.kill`: on Windows the craft server's own
160
+ // helper processes are not in a process group and would otherwise survive.
161
+ if (child !== undefined && child.exitCode === null) killTree(child)
157
162
  }
158
163
 
159
164
  /** Buffer stdout and resolve one response per complete line. */
package/src/root.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Resolve this plugin package's root directory at runtime.
3
+ *
4
+ * Kept in its own module so every craft plugin can share the same two lines
5
+ * while the kernel stays free of `import.meta` path arithmetic.
6
+ * @module @dsh-artcraft/plugin-soundcraft/root
7
+ */
8
+
9
+ import { dirname } from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
+
12
+ /** Absolute path of this plugin package (`<root>/src/root.ts` → `<root>`). */
13
+ export const PLUGIN_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))