dsh-runnpu 0.1.0-rc.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.

Potentially problematic release.


This version of dsh-runnpu might be problematic. Click here for more details.

Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +89 -0
  3. package/bin/cli-deps.mjs +384 -0
  4. package/bin/dsh-runnpu.mjs +255 -0
  5. package/bin/presets.mjs +167 -0
  6. package/compute-mode/node/index.mjs +527 -0
  7. package/compute-mode/node/tools.mjs +1440 -0
  8. package/compute-mode/preset/agent.cordis.yml +366 -0
  9. package/compute-mode/preset/preset.yml +3 -0
  10. package/compute-mode/preset/skills/runnpu/SKILL.md +233 -0
  11. package/compute-mode/preset/skills/runnpu/VENDOR.txt +17 -0
  12. package/compute-mode/preset/skills/runnpu/examples/01-dev-environment.sh +26 -0
  13. package/compute-mode/preset/skills/runnpu/examples/02-training-job.sh +26 -0
  14. package/compute-mode/preset/skills/runnpu/examples/03-inference-service.sh +24 -0
  15. package/compute-mode/preset/skills/runnpu/examples/04-fractional-vnpu.sh +14 -0
  16. package/compute-mode/preset/skills/runnpu/examples/05-troubleshoot.sh +17 -0
  17. package/compute-mode/preset/skills/runnpu/references/admin.md +60 -0
  18. package/compute-mode/preset/skills/runnpu/references/data-sources.md +35 -0
  19. package/compute-mode/preset/skills/runnpu/references/templates-assets.md +61 -0
  20. package/compute-mode/preset/skills/runnpu/references/training-runs.md +56 -0
  21. package/compute-mode/preset/skills/runnpu/references/troubleshooting.md +75 -0
  22. package/compute-mode/preset/skills/runnpu/references/workload-types.md +47 -0
  23. package/cordis.patch.yml +80 -0
  24. package/lib/client.js +189 -0
  25. package/lib/client.js.map +7 -0
  26. package/package.json +106 -0
  27. package/shared/config.mjs +496 -0
  28. package/shared/connectors.mjs +322 -0
  29. package/shared/http.mjs +179 -0
  30. package/shared/insights.mjs +250 -0
  31. package/shared/json.mjs +28 -0
  32. package/shared/nonce.mjs +109 -0
  33. package/shared/operations.mjs +427 -0
  34. package/shared/pins.mjs +570 -0
  35. package/shared/reports.mjs +187 -0
  36. package/shared/research.mjs +91 -0
  37. package/shared/runnpu.mjs +527 -0
  38. package/shared/series.mjs +208 -0
  39. package/shared/sse-host.mjs +172 -0
  40. package/shared/swanlab.mjs +523 -0
  41. package/shared/ui-host.mjs +21 -0
  42. package/shared/upload.mjs +928 -0
  43. package/shared/views.mjs +520 -0
  44. package/training-mode/node/index.mjs +750 -0
  45. package/training-mode/node/tools.mjs +1268 -0
  46. package/training-mode/preset/agent.cordis.yml +341 -0
  47. package/training-mode/preset/preset.yml +3 -0
  48. package/training-mode/preset/skills/runnpu/SKILL.md +233 -0
  49. package/training-mode/preset/skills/runnpu/VENDOR.txt +17 -0
  50. package/training-mode/preset/skills/runnpu/examples/01-dev-environment.sh +26 -0
  51. package/training-mode/preset/skills/runnpu/examples/02-training-job.sh +26 -0
  52. package/training-mode/preset/skills/runnpu/examples/03-inference-service.sh +24 -0
  53. package/training-mode/preset/skills/runnpu/examples/04-fractional-vnpu.sh +14 -0
  54. package/training-mode/preset/skills/runnpu/examples/05-troubleshoot.sh +17 -0
  55. package/training-mode/preset/skills/runnpu/references/admin.md +60 -0
  56. package/training-mode/preset/skills/runnpu/references/data-sources.md +35 -0
  57. package/training-mode/preset/skills/runnpu/references/templates-assets.md +61 -0
  58. package/training-mode/preset/skills/runnpu/references/training-runs.md +56 -0
  59. package/training-mode/preset/skills/runnpu/references/troubleshooting.md +75 -0
  60. package/training-mode/preset/skills/runnpu/references/workload-types.md +47 -0
  61. package/training-mode/preset/skills/swanlab-skill/LICENSE +21 -0
  62. package/training-mode/preset/skills/swanlab-skill/SKILL.md +163 -0
  63. package/training-mode/preset/skills/swanlab-skill/VENDOR.txt +9 -0
  64. package/training-mode/preset/skills/swanlab-skill/references/ANALYSIS_GUIDE.md +83 -0
  65. package/training-mode/preset/skills/swanlab-skill/references/CLI_REFERENCE.md +510 -0
  66. package/training-mode/preset/skills/swanlab-skill/references/SDK_QUICKSTART.md +341 -0
  67. package/training-mode/preset/skills/swanlab-skill/references/SWANLAB_CONCEPTS.md +523 -0
  68. package/training-mode/preset/skills/swanlab-skill/scripts/plot_metrics.py +353 -0
  69. package/training-mode/preset/skills/swanlab-skill/scripts/runs_benchmark.py +550 -0
  70. package/training-mode/preset/skills/training-research/SKILL.md +33 -0
  71. package/training-mode/preset/skills/training-research/references/pipeline.md +45 -0
  72. package/training-mode/preset/skills/training-research/references/research.md +76 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 EM-GeekLab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # dsh-runnpu
2
+
3
+ Two agent modes for DeepSeek Harness over a RunNPU Ascend NPU cluster.
4
+
5
+ - **算力管理模式 (`compute`)** — a cluster console beside the conversation: connection
6
+ status + login form, and a pinned-chart column that keeps each chart's *query* and
7
+ refreshes it, not a screenshot.
8
+ - **训练模式 (`training`)** — the workspace's training project on the cluster with SwanLab
9
+ instrumentation: key-metric strip above the composer, a version card from `.train/`,
10
+ and SwanLab connection settings that follow the run into the container as env vars.
11
+
12
+ Each mode is an agent preset (`*/preset/`) carrying its own skills; the UI and the HTTP
13
+ surface ship as one plugin bundle mounted on the host plane.
14
+
15
+ ## Install
16
+
17
+ Published on npm as [`dsh-runnpu`](https://www.npmjs.com/package/dsh-runnpu). In the dsh
18
+ profile you run (usually `~/.dsh/profiles/web`):
19
+
20
+ ```bash
21
+ cd ~/.dsh/profiles/web
22
+ pnpm add dsh-runnpu
23
+ npx dsh-runnpu install all --profile web --with-cli # or: compute | training
24
+ pnpm install
25
+ ```
26
+
27
+ `--with-cli` installs the host CLIs the mode drives — `runnpu` (the pinned release of
28
+ [EM-GeekLab/run-npu-skill](https://github.com/EM-GeekLab/run-npu-skill), sha256-checked, or
29
+ `--from <archive>` offline) and, for training, `swanlab` (via `uv tool` / `pipx` / `pip`). `npx dsh-runnpu doctor` checks them any time; the `runnpu`
30
+ build must match the vendored skill (`package.json` → `cliDeps`), because the skill
31
+ documents that binary's parameters.
32
+
33
+ then restart dsh and pick the mode in the New Session picker. `dsh-runnpu install`
34
+ copies the preset(s) into `~/.dsh/.agent-presets/{compute,training}` (they must be
35
+ real directories — npm cannot put them there) and `--profile` adds `"dsh-runnpu"` to
36
+ the profile's `dsh.profile.bundles`. `dsh-runnpu status` / `uninstall` round it out.
37
+ Host prerequisites: the `runnpu` CLI, and `swanlab` ≥ 0.9.0 for training mode.
38
+ Full guide: [`docs/RELEASE-INSTALL.md`](docs/RELEASE-INSTALL.md).
39
+
40
+ ## Develop
41
+
42
+ ```bash
43
+ npm install
44
+ npm run build # client/src → lib/client.js
45
+ node scripts/install-presets.mjs # = dsh-runnpu install all, from this checkout
46
+ ```
47
+
48
+ Development commands — all four run in CI (`.github/workflows/ci.yml`):
49
+
50
+ ```bash
51
+ npm test # node:test over shared/ and bin/ — one case per bug that actually shipped
52
+ npm run lint # oxlint
53
+ npm run typecheck # tsc --checkJs over shared/'s JSDoc (jsconfig.json); no TypeScript in the repo
54
+ npm run build # the bundle, plus the guard that keeps node-only code out of it
55
+ ```
56
+
57
+ To run the checkout instead of the npm release, point the profile at it:
58
+
59
+ ```jsonc
60
+ "dependencies": { "dsh-runnpu": "file:/path/to/this/repo" },
61
+ "dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "dsh-runnpu"] } }
62
+ ```
63
+
64
+ `pnpm install` in the profile, restart dsh. Releasing: `npm version` + `npm publish`
65
+ (`prepublishOnly` runs build / test / lint / typecheck), see the guide above.
66
+
67
+ ## Skills
68
+
69
+ `skills/` is the canonical copy of the three skills the modes carry — `runnpu` (vendored
70
+ from run-npu-skill, same release as the pinned CLI), `swanlab-skill` (vendored from
71
+ SwanHubX/SwanLab-Skill) and `training-research` (ours). Each preset holds a byte-identical
72
+ copy that `node scripts/sync-skills.mjs` refreshes and `tests/skills-sync.test.mjs` pins,
73
+ so edit or re-vendor under `skills/` only. The same layout is what `npx skills add` and
74
+ skill hubs discover, for use outside dsh.
75
+
76
+ ## Layout
77
+
78
+ | Path | Role |
79
+ |---|---|
80
+ | `shared/` | RunNPU REST client, SwanLab CLI wrapper, config/secret resolution, route helpers |
81
+ | `compute-mode/` | compute preset + node half (`/runnpu-api`) |
82
+ | `training-mode/` | training preset + node half (`/training-api`) |
83
+ | `client/src/` | the single browser bundle; both modes branch on the session's `agentPreset` |
84
+ | `skills/` | canonical skills; presets carry pinned copies (`scripts/sync-skills.mjs`) |
85
+ | `bin/` | the `dsh-runnpu` installer: presets, profile mounting, host CLIs |
86
+ | `cordis.patch.yml` | host-plane rows: connectors, the two node halves, the client roster row |
87
+
88
+ `compute-mode/CLAUDE.md` and `training-mode/CLAUDE.md` are the design contracts — read
89
+ them before changing anything; they record the harness constraints this was built against.
@@ -0,0 +1,384 @@
1
+ /**
2
+ * The two host CLIs the modes drive — `runnpu` (RunNPU control panel) and
3
+ * `swanlab` (SwanLab, Python) — are NOT npm packages, so this module is what
4
+ * `dsh-runnpu doctor` / `dsh-runnpu cli` use to check and install them.
5
+ *
6
+ * Pins live in package.json `cliDeps`:
7
+ * runnpu.version a release tag of EM-GeekLab/run-npu-skill (e.g.
8
+ * `v0.1.0-rc.1`). CLI and skill ship together there, so
9
+ * this is also the version of the vendored `runnpu`
10
+ * skill — see skills/runnpu/VENDOR.txt.
11
+ * runnpu.releaseBase `<repo>/releases/download`; under `<version>/` sit
12
+ * `runnpu_<ver>_<os>_<arch>.tar.gz|zip` (the binary at
13
+ * the archive root) and `checksums.txt` (sha256).
14
+ * swanlab.min the SDK/CLI floor the training mode was verified on.
15
+ *
16
+ * Everything that touches the network or the PATH takes its inputs as
17
+ * parameters (`env`, `fetchImpl`, `from`) so tests can drive it with stub
18
+ * executables and a local archive, never the real machine.
19
+ *
20
+ * @module dsh-runnpu/bin/cli-deps
21
+ */
22
+
23
+ import { execFile } from 'node:child_process'
24
+ import { createHash } from 'node:crypto'
25
+ import { access, chmod, copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
26
+ import { constants } from 'node:fs'
27
+ import { homedir, tmpdir } from 'node:os'
28
+ import { delimiter, dirname, join, resolve } from 'node:path'
29
+ import { fileURLToPath } from 'node:url'
30
+ import { promisify } from 'node:util'
31
+
32
+ const execFileAsync = promisify(execFile)
33
+
34
+ export const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
35
+
36
+ /** @typedef {{ version: string, releaseBase: string, tag?: string }} RunnpuPin */
37
+ /** @typedef {{ min: string, package: string }} SwanlabPin */
38
+ /** @typedef {{ runnpu: RunnpuPin, swanlab: SwanlabPin }} CliPins */
39
+
40
+ /** @returns {Promise<CliPins>} */
41
+ export async function readPins(root = packageRoot) {
42
+ const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
43
+ const pins = pkg.cliDeps
44
+ if (!pins?.runnpu?.version || !pins?.swanlab?.min) throw new Error('package.json cliDeps is missing the runnpu / swanlab pins')
45
+ return pins
46
+ }
47
+
48
+ // ── platform ────────────────────────────────────────────────────────────────
49
+
50
+ /**
51
+ * Map Node's platform/arch to the release's archive naming (`GOOS`/`GOARCH`
52
+ * + archive type, exactly what run-npu-skill's install.sh / install.ps1 use).
53
+ * @param {string} platform process.platform
54
+ * @param {string} arch process.arch
55
+ */
56
+ export function platformKey(platform = process.platform, arch = process.arch) {
57
+ const os = { darwin: 'darwin', linux: 'linux', win32: 'windows' }[platform]
58
+ const goarch = { arm64: 'arm64', x64: 'amd64' }[arch]
59
+ if (!os || !goarch) throw new Error(`no runnpu build for ${platform}/${arch} (have darwin/linux/windows × arm64/amd64)`)
60
+ return { os, arch: goarch, ext: os === 'windows' ? 'zip' : 'tar.gz', exe: os === 'windows' ? '.exe' : '' }
61
+ }
62
+
63
+ /** `v0.1.0-rc.1` → `0.1.0-rc.1`: the archive names carry the bare version, the tag the `v`. */
64
+ export function bareVersion(version) {
65
+ return String(version).replace(/^v/, '')
66
+ }
67
+
68
+ /** @param {string} version @param {ReturnType<typeof platformKey>} plat */
69
+ export function runnpuArchiveName(version, plat) {
70
+ return `runnpu_${bareVersion(version)}_${plat.os}_${plat.arch}.${plat.ext}`
71
+ }
72
+
73
+ /** Directory of one release's assets. */
74
+ export function runnpuReleaseDir(pin) {
75
+ return `${pin.releaseBase.replace(/\/+$/, '')}/${pin.tag ?? pin.version}`
76
+ }
77
+
78
+ /** @param {RunnpuPin} pin @param {ReturnType<typeof platformKey>} plat */
79
+ export function runnpuDownloadUrl(pin, plat) {
80
+ return `${runnpuReleaseDir(pin)}/${runnpuArchiveName(pin.version, plat)}`
81
+ }
82
+
83
+ /** @param {RunnpuPin} pin */
84
+ export function runnpuChecksumsUrl(pin) {
85
+ return `${runnpuReleaseDir(pin)}/checksums.txt`
86
+ }
87
+
88
+ /**
89
+ * Find `<sha256> <file>` for `file` in a checksums.txt; undefined when absent.
90
+ * @param {string} text
91
+ * @param {string} file
92
+ */
93
+ export function checksumFor(text, file) {
94
+ for (const line of String(text).split('\n')) {
95
+ const match = line.trim().match(/^([0-9a-f]{64})\s+\*?(.+)$/i)
96
+ if (match && match[2].trim() === file) return match[1].toLowerCase()
97
+ }
98
+ return undefined
99
+ }
100
+
101
+ // ── versions ────────────────────────────────────────────────────────────────
102
+
103
+ /** `runnpu --version` prints `runnpu version <tag or git describe>`; returns that string. */
104
+ export function parseRunnpuVersion(stdout) {
105
+ const match = String(stdout).match(/runnpu version\s+(\S+)/)
106
+ return match ? match[1] : undefined
107
+ }
108
+
109
+ /**
110
+ * Does an installed build satisfy the pin? The same version with or without
111
+ * the `v` prefix, optionally carrying a `-dirty` suffix (a local build of the
112
+ * pinned tag). Anything else — an older tag, a bare commit — is a mismatch,
113
+ * because the skill documents this exact CLI.
114
+ */
115
+ export function runnpuVersionMatches(actual, pinned) {
116
+ if (!actual || !pinned) return false
117
+ const norm = v => bareVersion(String(v).replace(/-dirty$/, ''))
118
+ return norm(actual) === norm(pinned)
119
+ }
120
+
121
+ /** `swanlab --version` prints `SwanLab 0.9.8`. */
122
+ export function parseSwanlabVersion(stdout) {
123
+ const match = String(stdout).match(/(\d+\.\d+\.\d+)/)
124
+ return match ? match[1] : undefined
125
+ }
126
+
127
+ /** Numeric dotted compare; returns <0, 0, >0. */
128
+ export function compareVersions(a, b) {
129
+ const pa = String(a).split('.').map(Number)
130
+ const pb = String(b).split('.').map(Number)
131
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
132
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0)
133
+ if (d !== 0) return d
134
+ }
135
+ return 0
136
+ }
137
+
138
+ // ── PATH probing ────────────────────────────────────────────────────────────
139
+
140
+ /**
141
+ * Locate an executable on `env.PATH` without shelling out to `which`.
142
+ * @param {string} name
143
+ * @param {NodeJS.ProcessEnv} [env]
144
+ * @returns {Promise<string | undefined>}
145
+ */
146
+ export async function which(name, env = process.env) {
147
+ const exts = process.platform === 'win32' ? (env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';') : ['']
148
+ for (const dir of (env.PATH ?? '').split(delimiter).filter(Boolean)) {
149
+ for (const ext of exts) {
150
+ const candidate = join(dir, name + ext)
151
+ try {
152
+ await access(candidate, constants.X_OK)
153
+ if ((await stat(candidate)).isFile()) return candidate
154
+ } catch {
155
+ /* keep looking */
156
+ }
157
+ }
158
+ }
159
+ return undefined
160
+ }
161
+
162
+ /** Run `<cmd> <args>` and capture stdout+stderr; never throws. */
163
+ export async function probe(cmd, args, env = process.env) {
164
+ try {
165
+ const { stdout, stderr } = await execFileAsync(cmd, args, { env, timeout: 15_000 })
166
+ return { ok: true, output: `${stdout}${stderr}` }
167
+ } catch (error) {
168
+ const e = /** @type {any} */ (error)
169
+ return { ok: false, output: `${e.stdout ?? ''}${e.stderr ?? ''}${e.message ?? ''}` }
170
+ }
171
+ }
172
+
173
+ // ── doctor ──────────────────────────────────────────────────────────────────
174
+
175
+ /**
176
+ * @typedef {object} Finding
177
+ * @property {'runnpu' | 'swanlab'} name
178
+ * @property {string | undefined} path
179
+ * @property {string | undefined} version
180
+ * @property {string} want
181
+ * @property {boolean} ok
182
+ * @property {string} detail
183
+ */
184
+
185
+ /**
186
+ * Check both CLIs against the pins. Pure reporting — installs nothing.
187
+ * @param {CliPins} pins
188
+ * @param {NodeJS.ProcessEnv} [env]
189
+ * @returns {Promise<Finding[]>}
190
+ */
191
+ export async function doctor(pins, env = process.env) {
192
+ /** @type {Finding[]} */
193
+ const findings = []
194
+
195
+ const runnpuPath = await which('runnpu', env)
196
+ const runnpuVersion = runnpuPath ? parseRunnpuVersion((await probe(runnpuPath, ['--version'], env)).output) : undefined
197
+ const runnpuOk = runnpuVersionMatches(runnpuVersion, pins.runnpu.version)
198
+ findings.push({
199
+ name: 'runnpu',
200
+ path: runnpuPath,
201
+ version: runnpuVersion,
202
+ want: pins.runnpu.version,
203
+ ok: runnpuOk,
204
+ detail: !runnpuPath
205
+ ? 'not on PATH'
206
+ : !runnpuVersion
207
+ ? '`runnpu --version` did not answer'
208
+ : runnpuOk
209
+ ? 'matches the vendored runnpu skill'
210
+ : `build ${runnpuVersion} does not match the skill (${pins.runnpu.version}) — parameters may disagree`,
211
+ })
212
+
213
+ const swanlabPath = await which('swanlab', env)
214
+ const swanlabVersion = swanlabPath ? parseSwanlabVersion((await probe(swanlabPath, ['--version'], env)).output) : undefined
215
+ const swanlabOk = Boolean(swanlabVersion) && compareVersions(swanlabVersion, pins.swanlab.min) >= 0
216
+ findings.push({
217
+ name: 'swanlab',
218
+ path: swanlabPath,
219
+ version: swanlabVersion,
220
+ want: `>=${pins.swanlab.min}`,
221
+ ok: swanlabOk,
222
+ detail: !swanlabPath
223
+ ? 'not on PATH (training mode needs it; compute mode does not)'
224
+ : !swanlabVersion
225
+ ? '`swanlab --version` did not answer'
226
+ : swanlabOk
227
+ ? 'ok'
228
+ : `${swanlabVersion} is below ${pins.swanlab.min} — \`swanlab api\` output contract differs`,
229
+ })
230
+
231
+ return findings
232
+ }
233
+
234
+ // ── runnpu install ──────────────────────────────────────────────────────────
235
+
236
+ /** Default install dir — same as the control panel's own install.sh. */
237
+ export function defaultBinDir(env = process.env) {
238
+ return env.RUNNPU_BIN_DIR || join(homedir(), '.local', 'bin')
239
+ }
240
+
241
+ /**
242
+ * Fetch `url` as bytes. Sends a GitHub token when one is in the environment
243
+ * (a private mirror's release assets need it; the public repo ignores it).
244
+ */
245
+ async function fetchBytes(url, env, fetchImpl) {
246
+ const headers = { 'user-agent': 'dsh-runnpu' }
247
+ const token = env.GITHUB_TOKEN || env.GH_TOKEN
248
+ if (token && /github\.com|githubusercontent\.com/.test(url)) headers.authorization = `Bearer ${token}`
249
+ const response = await fetchImpl(url, { headers, redirect: 'follow' })
250
+ if (!response.ok) throw new Error(`download failed: ${response.status} ${response.statusText} — ${url}`)
251
+ return Buffer.from(await response.arrayBuffer())
252
+ }
253
+
254
+ /** First `runnpu` / `runnpu.exe` found under `dir` (the archive nests it as `<name>/bin/runnpu`). */
255
+ async function findBinary(dir, exe) {
256
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
257
+ const full = join(dir, entry.name)
258
+ if (entry.isDirectory()) {
259
+ const found = await findBinary(full, exe)
260
+ if (found) return found
261
+ } else if (entry.name === `runnpu${exe}`) return full
262
+ }
263
+ return undefined
264
+ }
265
+
266
+ /**
267
+ * Install the pinned `runnpu` build into `binDir`.
268
+ *
269
+ * Source resolution, in order: `from` (a local archive path, a local
270
+ * directory holding an extracted package, or a URL), else the pin's release
271
+ * URL for this platform — and in that default case the archive is checked
272
+ * against the release's checksums.txt before it is opened. Extraction shells
273
+ * out to `tar`, which reads both .tar.gz and (bsdtar on macOS / Windows 10+)
274
+ * .zip; the binary sits at the archive root.
275
+ *
276
+ * @param {object} options
277
+ * @param {RunnpuPin} options.pin
278
+ * @param {string} [options.from]
279
+ * @param {string} [options.binDir]
280
+ * @param {NodeJS.ProcessEnv} [options.env]
281
+ * @param {typeof fetch} [options.fetchImpl]
282
+ * @param {ReturnType<typeof platformKey>} [options.plat]
283
+ * @returns {Promise<{ path: string, version: string | undefined, source: string }>}
284
+ */
285
+ export async function installRunnpu({ pin, from, binDir, env = process.env, fetchImpl = fetch, plat = platformKey() }) {
286
+ const dir = binDir ?? defaultBinDir(env)
287
+ const work = await mkdtemp(join(tmpdir(), 'dsh-runnpu-cli-'))
288
+ try {
289
+ let source = from ?? runnpuDownloadUrl(pin, plat)
290
+ let archive
291
+ if (/^https?:\/\//.test(source)) {
292
+ const name = runnpuArchiveName(pin.version, plat)
293
+ archive = join(work, name)
294
+ const bytes = await fetchBytes(source, env, fetchImpl)
295
+ if (!from) {
296
+ // Only the pinned release has a checksums.txt we know the shape of; a
297
+ // user-supplied URL is trusted the way a user-supplied file is.
298
+ const sums = (await fetchBytes(runnpuChecksumsUrl(pin), env, fetchImpl)).toString('utf8')
299
+ const want = checksumFor(sums, name)
300
+ if (!want) throw new Error(`checksums.txt of ${pin.version} has no entry for ${name}`)
301
+ const got = createHash('sha256').update(bytes).digest('hex')
302
+ if (got !== want) throw new Error(`sha256 mismatch for ${name}: got ${got}, checksums.txt says ${want}`)
303
+ }
304
+ await writeFile(archive, bytes)
305
+ } else {
306
+ source = resolve(source)
307
+ const info = await stat(source)
308
+ archive = info.isDirectory() ? undefined : source
309
+ }
310
+
311
+ let extracted = source
312
+ if (archive) {
313
+ extracted = join(work, 'x')
314
+ await mkdir(extracted)
315
+ await execFileAsync('tar', ['-xf', archive, '-C', extracted])
316
+ }
317
+
318
+ const binary = await findBinary(extracted, plat.exe)
319
+ if (!binary) throw new Error(`no runnpu${plat.exe} binary inside ${source}`)
320
+
321
+ await mkdir(dir, { recursive: true })
322
+ const target = join(dir, `runnpu${plat.exe}`)
323
+ await copyFile(binary, target)
324
+ await chmod(target, 0o755)
325
+
326
+ const version = parseRunnpuVersion((await probe(target, ['--version'], env)).output)
327
+ if (version && !runnpuVersionMatches(version, pin.version)) {
328
+ throw new Error(`installed ${target} reports ${version}, expected ${pin.version} — wrong archive for this skill`)
329
+ }
330
+ return { path: target, version, source }
331
+ } finally {
332
+ await rm(work, { recursive: true, force: true })
333
+ }
334
+ }
335
+
336
+ /** Is `dir` on `env.PATH`? */
337
+ export function onPath(dir, env = process.env) {
338
+ const want = resolve(dir)
339
+ return (env.PATH ?? '')
340
+ .split(delimiter)
341
+ .filter(Boolean)
342
+ .some(entry => resolve(entry) === want)
343
+ }
344
+
345
+ // ── swanlab install ─────────────────────────────────────────────────────────
346
+
347
+ /**
348
+ * Pick how to install `swanlab` on this machine: an isolated tool install
349
+ * when `uv` or `pipx` is present (no PEP 668 fight with the system Python),
350
+ * else `python -m pip install --user`. `python` forces the interpreter, which
351
+ * is also how a specific venv or Homebrew python is targeted.
352
+ *
353
+ * @param {object} options
354
+ * @param {SwanlabPin} options.pin
355
+ * @param {(name: string) => Promise<string | undefined>} options.which
356
+ * @param {string} [options.python]
357
+ * @returns {Promise<{ tool: 'python' | 'uv' | 'pipx', argv: string[] }>}
358
+ */
359
+ export async function planSwanlabInstall({ pin, which: lookup, python }) {
360
+ const spec = `${pin.package}>=${pin.min}`
361
+ if (python) return { tool: 'python', argv: [python, '-m', 'pip', 'install', '--upgrade', spec] }
362
+ const uv = await lookup('uv')
363
+ if (uv) return { tool: 'uv', argv: [uv, 'tool', 'install', '--upgrade', spec] }
364
+ const pipx = await lookup('pipx')
365
+ if (pipx) return { tool: 'pipx', argv: [pipx, 'install', '--force', spec] }
366
+ const py = (await lookup('python3')) ?? (await lookup('python'))
367
+ if (!py) throw new Error('no python3 on PATH — install Python ≥ 3.9 (or uv / pipx) first, then re-run')
368
+ return { tool: 'python', argv: [py, '-m', 'pip', 'install', '--user', '--upgrade', spec] }
369
+ }
370
+
371
+ /**
372
+ * Install or upgrade `swanlab`. Returns the plan that ran and its output.
373
+ * @param {object} options
374
+ * @param {SwanlabPin} options.pin
375
+ * @param {NodeJS.ProcessEnv} [options.env]
376
+ * @param {string} [options.python]
377
+ * @param {boolean} [options.dryRun]
378
+ */
379
+ export async function installSwanlab({ pin, env = process.env, python, dryRun = false }) {
380
+ const plan = await planSwanlabInstall({ pin, which: name => which(name, env), python })
381
+ if (dryRun) return { plan, ok: true, output: '' }
382
+ const result = await probe(plan.argv[0], plan.argv.slice(1), env)
383
+ return { plan, ...result }
384
+ }