@ljwei-stak/dsh-model-router 0.16.3 → 0.17.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.
@@ -1,16 +1,65 @@
1
1
  /**
2
2
  * Fixed registry of official model CLI tools, shared by the Host runtime
3
- * (probe + install) and the Desktop panel (display). Pure data: no Node or
4
- * browser APIs, so both sides and the test suite import it unchanged.
3
+ * (probe + install + uninstall + repair) and the Desktop panel (display).
4
+ * Pure data: no Node or browser APIs, so both sides and the test suite import
5
+ * it unchanged.
5
6
  *
6
7
  * The registry is fail-closed by design: installer sources are fixed (the
7
- * official npm registry, the vendor's signed installer) and clients can only
8
- * name a registry id — never an arbitrary package or command. Versions are not
9
- * pinned: installs take each vendor's latest release (`@latest`), and the
10
- * executor trusts entries by publisher signature or registry-attested digest
11
- * rather than by version.
8
+ * official npm registry, the vendor's published installer script) and clients
9
+ * can only name a registry id and one of that tool's declared install methods
10
+ * — never an arbitrary package, URL or command. Versions are not pinned:
11
+ * installs take each vendor's latest release (`@latest`, the vendor script's
12
+ * own `latest`), and the executor trusts entries by publisher signature or
13
+ * registry-attested digest rather than by version.
14
+ *
15
+ * Every tool declares `installMethods`: the complete, closed list of ways this
16
+ * plugin may obtain the tool. `manager` / `installArgs` / `uninstallArgs` on the
17
+ * tool itself stay the default (first) method, so existing callers and tests
18
+ * keep working. Script methods are never piped straight into an interpreter:
19
+ * the runtime downloads the vendor script, checks it still is that script, and
20
+ * runs the staged copy (same pattern as the MiniMax Windows installer).
12
21
  */
13
22
 
23
+ /** npm registry every fixed npm method installs from unless the user overrides it. */
24
+ export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org/'
25
+
26
+ /** Shared shape of a "package manager" method: argv only, no script staging. */
27
+ function packageManagerMethod(id, label, manager, spec, { registryFlag = '--registry=' } = {}) {
28
+ return Object.freeze({
29
+ id,
30
+ label,
31
+ kind: 'package-manager',
32
+ manager,
33
+ spec,
34
+ registryFlag,
35
+ installArgs: Object.freeze(['install', '-g', spec]),
36
+ uninstallArgs: Object.freeze(['uninstall', '-g', spec.replace(/@latest$/, '')]),
37
+ /** npm/pnpm both accept --prefix to place a global install off the default. */
38
+ supportsInstallDir: true,
39
+ supportsRegistry: true,
40
+ })
41
+ }
42
+
43
+ /** Vendor installer script run from a verified local copy. */
44
+ function scriptMethod(id, label, { shell, scriptUrl, verify, installArg = [], env = [], platforms, supportsInstallDir = false }) {
45
+ return Object.freeze({
46
+ id,
47
+ label,
48
+ kind: 'script',
49
+ shell,
50
+ scriptUrl,
51
+ verify,
52
+ /** Passed after the staged script path; empty means the script takes no argument. */
53
+ installArgs: Object.freeze(installArg),
54
+ /** Environment the vendor script itself documents for this install. */
55
+ env: Object.freeze(env),
56
+ platforms: Object.freeze(platforms),
57
+ /** Whether the vendor script honours a caller-chosen directory at all. */
58
+ supportsInstallDir,
59
+ supportsRegistry: false,
60
+ })
61
+ }
62
+
14
63
  export const OFFICIAL_TOOLS = Object.freeze([
15
64
  Object.freeze({
16
65
  id: 'kimi-code',
@@ -20,9 +69,14 @@ export const OFFICIAL_TOOLS = Object.freeze([
20
69
  package: '@moonshot-ai/kimi-code',
21
70
  manager: 'npm',
22
71
  installArgs: ['install', '-g', '@moonshot-ai/kimi-code@latest', '--registry=https://registry.npmjs.org/'],
72
+ uninstallArgs: ['uninstall', '-g', '@moonshot-ai/kimi-code'],
23
73
  probeExecutables: ['kimi'],
24
74
  probeNote: 'kimi 与旧 Python 版 kimi-cli 同名;请核对可执行文件来源和版本。',
25
75
  providerHints: ['moonshot', 'kimi'],
76
+ installMethods: Object.freeze([
77
+ packageManagerMethod('npm', 'npm', 'npm', '@moonshot-ai/kimi-code@latest'),
78
+ packageManagerMethod('pnpm', 'pnpm', 'pnpm', '@moonshot-ai/kimi-code@latest'),
79
+ ]),
26
80
  }),
27
81
  Object.freeze({
28
82
  id: 'claude-code',
@@ -32,8 +86,13 @@ export const OFFICIAL_TOOLS = Object.freeze([
32
86
  package: '@anthropic-ai/claude-code',
33
87
  manager: 'npm',
34
88
  installArgs: ['install', '-g', '@anthropic-ai/claude-code@latest', '--registry=https://registry.npmjs.org/'],
89
+ uninstallArgs: ['uninstall', '-g', '@anthropic-ai/claude-code'],
35
90
  probeExecutables: ['claude'],
36
91
  providerHints: ['anthropic', 'claude'],
92
+ installMethods: Object.freeze([
93
+ packageManagerMethod('npm', 'npm', 'npm', '@anthropic-ai/claude-code@latest'),
94
+ packageManagerMethod('pnpm', 'pnpm', 'pnpm', '@anthropic-ai/claude-code@latest'),
95
+ ]),
37
96
  }),
38
97
  Object.freeze({
39
98
  id: 'codex',
@@ -41,11 +100,16 @@ export const OFFICIAL_TOOLS = Object.freeze([
41
100
  vendor: 'OpenAI',
42
101
  purpose: 'OpenAI 官方编程 CLI,提供 codex 命令。',
43
102
  package: '@openai/codex',
44
- installArgs: ['install', '-g', '@openai/codex@latest', '--registry=https://registry.npmjs.org/'],
45
103
  manager: 'npm',
104
+ installArgs: ['install', '-g', '@openai/codex@latest', '--registry=https://registry.npmjs.org/'],
105
+ uninstallArgs: ['uninstall', '-g', '@openai/codex'],
46
106
  probeExecutables: ['codex'],
47
107
  probeNote: 'Codex 版本横幅由适配层宽匹配;安装时取 npm 最新版。',
48
108
  providerHints: ['openai', 'gpt', 'codex'],
109
+ installMethods: Object.freeze([
110
+ packageManagerMethod('npm', 'npm', 'npm', '@openai/codex@latest'),
111
+ packageManagerMethod('pnpm', 'pnpm', 'pnpm', '@openai/codex@latest'),
112
+ ]),
49
113
  }),
50
114
  Object.freeze({
51
115
  id: 'minimax-code',
@@ -55,8 +119,13 @@ export const OFFICIAL_TOOLS = Object.freeze([
55
119
  package: '@minimax-ai/code',
56
120
  manager: 'npm',
57
121
  installArgs: ['install', '-g', '@minimax-ai/code@latest', '--registry=https://registry.npmjs.org/', '--ignore-scripts=false', '--include=optional', '--allow-scripts=@minimax-ai/code,better-sqlite3'],
122
+ uninstallArgs: ['uninstall', '-g', '@minimax-ai/code'],
58
123
  probeExecutables: ['mcode'],
59
124
  providerHints: ['minimax'],
125
+ installMethods: Object.freeze([
126
+ packageManagerMethod('npm', 'npm', 'npm', '@minimax-ai/code@latest'),
127
+ packageManagerMethod('pnpm', 'pnpm', 'pnpm', '@minimax-ai/code@latest'),
128
+ ]),
60
129
  }),
61
130
  Object.freeze({
62
131
  id: 'mimo-code',
@@ -66,8 +135,13 @@ export const OFFICIAL_TOOLS = Object.freeze([
66
135
  package: '@mimo-ai/cli',
67
136
  manager: 'npm',
68
137
  installArgs: ['install', '-g', '@mimo-ai/cli@latest', '--registry=https://registry.npmjs.org/'],
138
+ uninstallArgs: ['uninstall', '-g', '@mimo-ai/cli'],
69
139
  probeExecutables: ['mimo'],
70
140
  providerHints: ['mimo', 'xiaomi'],
141
+ installMethods: Object.freeze([
142
+ packageManagerMethod('npm', 'npm', 'npm', '@mimo-ai/cli@latest'),
143
+ packageManagerMethod('pnpm', 'pnpm', 'pnpm', '@mimo-ai/cli@latest'),
144
+ ]),
71
145
  }),
72
146
  Object.freeze({
73
147
  id: 'grok-build',
@@ -77,8 +151,13 @@ export const OFFICIAL_TOOLS = Object.freeze([
77
151
  package: '@xai-official/grok',
78
152
  manager: 'npm',
79
153
  installArgs: ['install', '-g', '@xai-official/grok@latest', '--registry=https://registry.npmjs.org/'],
154
+ uninstallArgs: ['uninstall', '-g', '@xai-official/grok'],
80
155
  probeExecutables: ['grok'],
81
156
  providerHints: ['xai', 'grok'],
157
+ installMethods: Object.freeze([
158
+ packageManagerMethod('npm', 'npm', 'npm', '@xai-official/grok@latest'),
159
+ packageManagerMethod('pnpm', 'pnpm', 'pnpm', '@xai-official/grok@latest'),
160
+ ]),
82
161
  }),
83
162
  Object.freeze({
84
163
  id: 'gemini',
@@ -88,11 +167,93 @@ export const OFFICIAL_TOOLS = Object.freeze([
88
167
  package: '@google/gemini-cli',
89
168
  manager: 'npm',
90
169
  installArgs: ['install', '-g', '@google/gemini-cli@latest', '--registry=https://registry.npmjs.org/'],
170
+ uninstallArgs: ['uninstall', '-g', '@google/gemini-cli'],
91
171
  probeExecutables: ['gemini'],
92
172
  providerHints: ['gemini', 'google'],
93
173
  // Headless runs go through the task adapter. The signed sandbox runner does
94
174
  // not launch this CLI; a missing or failed process falls back to the API.
95
175
  headlessAdapter: true,
176
+ installMethods: Object.freeze([
177
+ packageManagerMethod('npm', 'npm', 'npm', '@google/gemini-cli@latest'),
178
+ packageManagerMethod('pnpm', 'pnpm', 'pnpm', '@google/gemini-cli@latest'),
179
+ ]),
180
+ }),
181
+ Object.freeze({
182
+ id: 'opencode',
183
+ label: 'OpenCode',
184
+ vendor: 'anomalyco / SST',
185
+ purpose: '开源终端编程代理,提供 opencode 命令;可用 npm/pnpm 或官方安装脚本获取。',
186
+ package: 'opencode-ai',
187
+ manager: 'npm',
188
+ installArgs: ['install', '-g', 'opencode-ai@latest', '--registry=https://registry.npmjs.org/'],
189
+ uninstallArgs: ['uninstall', '-g', 'opencode-ai'],
190
+ probeExecutables: ['opencode'],
191
+ probeNote: 'opencode 命令由 npm 全局包或官方脚本安装的二进制提供;脚本安装固定落在 ~/.opencode/bin。',
192
+ providerHints: ['opencode', 'anomalyco'],
193
+ /** File an uninstall may delete, when the copy came from a script install. */
194
+ scriptBinaryNames: Object.freeze(['opencode', 'opencode.exe']),
195
+ /** Where the vendor script puts the binary when the user sets no directory. */
196
+ defaultScriptInstallDir: '.opencode/bin',
197
+ installMethods: Object.freeze([
198
+ packageManagerMethod('npm', 'npm', 'npm', 'opencode-ai@latest'),
199
+ packageManagerMethod('pnpm', 'pnpm', 'pnpm', 'opencode-ai@latest'),
200
+ // The published script hardcodes $HOME/.opencode/bin, so it cannot honour
201
+ // a custom install directory; the npm/pnpm methods above can.
202
+ scriptMethod('script-bash', 'curl | bash', {
203
+ shell: 'bash',
204
+ scriptUrl: 'https://opencode.ai/install',
205
+ platforms: ['linux', 'darwin'],
206
+ // The published script hardcodes $HOME/.opencode/bin, so it cannot honour
207
+ // a custom install directory; the npm/pnpm methods above can.
208
+ supportsInstallDir: false,
209
+ verify: Object.freeze({
210
+ mustInclude: ['anomalyco/opencode', 'opencode.ai'],
211
+ mustNotInclude: ['static-openapi.stepfun.com'],
212
+ }),
213
+ }),
214
+ ]),
215
+ }),
216
+ Object.freeze({
217
+ id: 'stepcode',
218
+ label: 'Step Code',
219
+ vendor: 'StepFun 阶跃星辰',
220
+ purpose: '阶跃星辰开源终端编程代理,提供 step 命令;官方只发布安装脚本,没有 npm 包。',
221
+ manager: 'script-installer',
222
+ installArgs: [],
223
+ uninstallArgs: [],
224
+ probeExecutables: ['step'],
225
+ probeNote: 'step 命令来自阶跃官方安装脚本,校验和写入 ~/.stepcode;配置与凭据同在该目录,卸载只删除可执行文件。',
226
+ providerHints: ['stepfun', 'stepcode'],
227
+ scriptBinaryNames: Object.freeze(['step', 'step.exe']),
228
+ defaultScriptInstallDir: '.stepcode/bin',
229
+ /** The vendor installer writes this marked block into the shell profile. */
230
+ pathProfileMarkers: Object.freeze(['# stepcode']),
231
+ installMethods: Object.freeze([
232
+ scriptMethod('script-bash', 'curl | bash', {
233
+ shell: 'bash',
234
+ scriptUrl: 'https://static-openapi.stepfun.com/stepcode/install.sh',
235
+ platforms: ['linux', 'darwin'],
236
+ installArg: ['--install-dir'],
237
+ env: ['STEP_INSTALL_DIR', 'STEP_RELEASE_BASE_URL'],
238
+ supportsInstallDir: true,
239
+ verify: Object.freeze({
240
+ mustInclude: ['static-openapi.stepfun.com', 'stepcode installer'],
241
+ mustNotInclude: ['anomalyco/opencode'],
242
+ }),
243
+ }),
244
+ scriptMethod('script-powershell', 'irm | iex', {
245
+ shell: 'powershell',
246
+ scriptUrl: 'https://static-openapi.stepfun.com/stepcode/install.ps1',
247
+ platforms: ['win32'],
248
+ installArg: ['-InstallDir'],
249
+ env: ['STEP_INSTALL_DIR', 'STEP_RELEASE_BASE_URL'],
250
+ supportsInstallDir: true,
251
+ verify: Object.freeze({
252
+ mustInclude: ['static-openapi.stepfun.com', 'stepcode'],
253
+ mustNotInclude: ['anomalyco/opencode'],
254
+ }),
255
+ }),
256
+ ]),
96
257
  }),
97
258
  Object.freeze({
98
259
  id: 'zcode',
@@ -101,6 +262,7 @@ export const OFFICIAL_TOOLS = Object.freeze([
101
262
  purpose: '智谱官方 ZCode 桌面版,内含 GLM 编程代理。Windows 安装器可选择 D 盘目录。',
102
263
  manager: 'signed-windows-installer',
103
264
  installArgs: [],
265
+ uninstallArgs: [],
104
266
  probeExecutables: [],
105
267
  probeNote: '检测经过有效签名的 ZCode.exe 和同目录 GLM 资源;桌面安装器需人工选择安装位置。',
106
268
  providerHints: ['zai', 'z.ai', 'zcode', 'glm', 'zhipu', 'bigmodel'],
@@ -123,11 +285,60 @@ export function toolForProvider(provider) {
123
285
  return null
124
286
  }
125
287
 
126
- /** Human-readable install command line for one registry entry. */
288
+ /** Every install method a tool declares, or an empty list for the desktop installer. */
289
+ export function installMethodsFor(tool) {
290
+ return Array.isArray(tool?.installMethods) ? tool.installMethods : []
291
+ }
292
+
293
+ /** One closed method id of one tool; null when the tool does not offer it. */
294
+ export function installMethodFor(tool, methodId) {
295
+ const wanted = String(methodId ?? '').trim()
296
+ if (!wanted) return null
297
+ return installMethodsFor(tool).find(method => method.id === wanted) ?? null
298
+ }
299
+
300
+ /** The tool's default method, kept identical to `manager` / `installArgs`. */
301
+ export function defaultInstallMethod(tool) {
302
+ return installMethodsFor(tool)[0] ?? null
303
+ }
304
+
305
+ /** Why a method cannot run here, or null when the current platform can run it. */
306
+ export function installMethodBlockReason(method, platform = 'linux') {
307
+ if (!method?.platforms) return null
308
+ return method.platforms.includes(platform) ? null : `此安装方式仅支持 ${method.platforms.join(' / ')}。`
309
+ }
310
+
311
+ /**
312
+ * A method able to place the tool in the user's chosen directory. npm and pnpm
313
+ * take --prefix; the opencode script hardcodes its own path and cannot.
314
+ */
315
+ export function methodHonoursInstallDir(method) {
316
+ return method?.supportsInstallDir === true
317
+ }
318
+
319
+ /** Human-readable install command line for one registry entry (default method). */
127
320
  export function installCommandLine(tool) {
128
321
  if (!tool || tool.unsupported) return null
322
+ // The signed desktop installer is not a command; it is its own thing.
129
323
  if (tool.manager === 'signed-windows-installer') return '打开官方签名安装器(选择安装目录)'
130
- return tool.manager === 'npm'
131
- ? `npm ${tool.installArgs.join(' ')}`
132
- : `${tool.manager} ${tool.installArgs.join(' ')}`
324
+ return installCommandLineFor(tool, defaultInstallMethod(tool))
325
+ }
326
+
327
+ /** Human-readable command line for one specific method of one tool. */
328
+ export function installCommandLineFor(tool, method) {
329
+ if (!tool || tool.unsupported) return null
330
+ if (!method) return null
331
+ if (method.kind === 'script') {
332
+ const piped = method.shell === 'powershell' ? `irm ${method.scriptUrl} | iex` : `curl -fsSL ${method.scriptUrl} | bash`
333
+ return method.installArgs.length ? `${piped} ${method.installArgs[0]} <安装目录>` : piped
334
+ }
335
+ return `${method.manager} ${method.installArgs.join(' ')}`
336
+ }
337
+
338
+ /** Human-readable uninstall command line, or null when removal is not a command. */
339
+ export function uninstallCommandLineFor(tool, method) {
340
+ if (!tool || tool.unsupported || !method) return null
341
+ if (tool.manager === 'signed-windows-installer') return null
342
+ if (method.kind === 'script') return '删除官方安装的可执行文件(保留配置与登录信息)'
343
+ return `${method.manager} ${method.uninstallArgs.join(' ')}`
133
344
  }
@@ -5,7 +5,7 @@
5
5
  * the Host Typert registry owns the matching strict descriptors. No endpoint
6
6
  * accepts a command, package name, URL, argument vector, or executable path.
7
7
  */
8
- import { getOfficialTool } from './official-tool-registry.mjs'
8
+ import { getOfficialTool, installMethodsFor } from './official-tool-registry.mjs'
9
9
  import {
10
10
  parseTerminalRead,
11
11
  parseTerminalResize,
@@ -35,6 +35,40 @@ const toolIdCodec = strictCodec(`${OFFICIAL_TOOLS_REMOTE_PACKAGE}#OfficialToolId
35
35
  return value
36
36
  })
37
37
 
38
+ const MAX_METHOD_ID_CHARS = 40
39
+
40
+ const text = value => (typeof value === 'string' ? value.trim() : '')
41
+
42
+ /**
43
+ * One install/uninstall request: a registry id plus, optionally, which of that
44
+ * tool's declared install methods to use. The install directory, the npm source
45
+ * and the installer-script source are Host settings and never travel on this
46
+ * call, so a compromised client cannot redirect an install at a package or URL
47
+ * of its choosing.
48
+ */
49
+ const toolActionCodec = strictCodec(`${OFFICIAL_TOOLS_REMOTE_PACKAGE}#OfficialToolAction`, value => {
50
+ const request = plainObject(value, 'tool action request')
51
+ // Closed shape: an unknown key is refused rather than ignored, so a client
52
+ // cannot smuggle a command, URL or argument list past the codec.
53
+ for (const key of Object.keys(request)) {
54
+ if (key !== 'tool' && key !== 'method') throw new TypeError(`unknown field ${key} in tool action request`)
55
+ }
56
+ const tool = getOfficialTool(text(request.tool))
57
+ if (!tool) throw new TypeError('tool must name a fixed official tool')
58
+ const method = request.method === undefined || request.method === null || request.method === ''
59
+ ? null
60
+ : text(request.method)
61
+ if (method !== null) {
62
+ if (method.length > MAX_METHOD_ID_CHARS || !/^[A-Za-z0-9-]+$/.test(method)) {
63
+ throw new TypeError('method must be one of the tool\'s declared install methods')
64
+ }
65
+ if (!installMethodsFor(tool).some(item => item.id === method)) {
66
+ throw new TypeError(`${tool.label} does not offer the install method ${method}`)
67
+ }
68
+ }
69
+ return method === null ? { tool: tool.id } : { tool: tool.id, method }
70
+ })
71
+
38
72
  const listResultCodec = strictCodec(`${OFFICIAL_TOOLS_REMOTE_PACKAGE}#OfficialToolList`, value => {
39
73
  const result = plainObject(value, 'tool list result')
40
74
  if (!Array.isArray(result.tools)) throw new TypeError('tool list result needs a tools array')
@@ -159,7 +193,9 @@ const jsonParameter = (name, codec) => Object.freeze({ name, wire: name, source:
159
193
 
160
194
  export const OFFICIAL_TOOLS_REMOTE_DESCRIPTORS = Object.freeze([
161
195
  descriptor('list', [], listResultCodec),
162
- descriptor('installTool', [toolIdParameter], installResultCodec),
196
+ descriptor('installTool', [jsonParameter('request', toolActionCodec)], installResultCodec),
197
+ descriptor('uninstallTool', [jsonParameter('request', toolActionCodec)], installResultCodec),
198
+ descriptor('repairTool', [jsonParameter('request', toolActionCodec)], installResultCodec),
163
199
  descriptor('cancel', [toolIdParameter], installResultCodec),
164
200
  descriptor('status', [toolIdParameter], statusResultCodec),
165
201
  // Workbench: onboarding health check, run ledger, ratings, step retry, security boundaries.