@luchenglong/cli-bridge 0.2.5 → 0.2.6

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.
@@ -230,6 +230,206 @@ export const BACKEND_ENUM = ['codebuddy', 'codebuddy-intl', 'codebuddy-ioa', 'co
230
230
  return v && v.length ? v.slice() : null
231
231
  }
232
232
 
233
+ // ── 实时模型清单(CLI 是一手权威来源,对齐 workbuddy-auth 的「权威白名单」思路)──
234
+ //
235
+ // 为什么不再以静态表/磁盘描述文件为主:实测本机 codebuddy CLI --
236
+ // • 账号实时清单(用无效模型触发 400 报文)= 19 项
237
+ // • CLI `--help` 声明的 roster = 19 项,与账号清单完全一致
238
+ // • 我们原先读的 product.ioa.json 静态表 = 16 项,且 2 个 id 已过时
239
+ // (minimax-m3 → minimax-m3-pay、kimi-k3-1 → kimi-k3-2),另缺 4 个新模型。
240
+ // 静态表是「随包冻结」的,必然落后于 CLI,故只保留作倍率元数据与最后兜底。
241
+ //
242
+ // 两级实时来源(都不静默冒充权威数据,各带 source 标签):
243
+ // A. account-live —— 起 CLI 用无效模型探测「该账号当前支持」的清单(需该面已登录,
244
+ // 一次网络往返;对照 workbuddy-auth 的 agents[cli].models 白名单语义)。
245
+ // B. cli-help —— 解析 `--help` 的 "Currently supported: (...)"(零网络、随 CLI
246
+ // 版本自动更新;产品面级 roster)。
247
+ // 静态表仅作 credit 元数据与 descriptor-static 兜底标签,绝不冒充实时。
248
+
249
+ /** 解析 CLI `--help` 里声明的模型 roster。空/畸形输入返回 []。
250
+ * ⚠️ 名册很长时 yargs 会在任意列**折行**(实测把 `minimax-m3-pay` 从中间断成
251
+ * `minimax-` + `m3-pay`),若按行/按逗号直接切会丢项或产生残片(同一命令两次运行
252
+ * 给出 18/19 两种结果)。故先去掉括号内所有空白再按逗号切:逗号是唯一分隔符,
253
+ * 折行插入的空白一律视为噪声。 */
254
+ export function parseHelpModels(helpText) {
255
+ const m = /Currently supported:\s*\(([^)]+)\)/.exec(String(helpText || ''))
256
+ if (!m) return []
257
+ const out = []
258
+ for (const raw of m[1].replace(/\s+/g, '').split(',')) {
259
+ const id = raw.trim()
260
+ if (id && /^[A-Za-z0-9_.\-]+$/.test(id) && out.indexOf(id) < 0) out.push(id)
261
+ }
262
+ return out
263
+ }
264
+
265
+ /** 解析 codebuddy CLI 的可执行候选(env > npm 全局 shim > PATH 名)。 */
266
+ export function cbCatalogCliCandidates(env) {
267
+ const e = env || cbCatalogEnv || {}
268
+ const out = []
269
+ const push = (p) => { if (p && out.indexOf(p) < 0) out.push(p) }
270
+ if (e.CODEBUDDY_BIN) push(String(e.CODEBUDDY_BIN))
271
+ const joinP = (a, b) => cbCatalogPathJoin(a, b)
272
+ if (e.APPDATA) push(joinP(joinP(e.APPDATA, 'npm'), 'codebuddy.cmd'))
273
+ if (e.APPDATA) push(joinP(joinP(e.APPDATA, 'npm'), 'codebuddy'))
274
+ push('codebuddy')
275
+ return out
276
+ }
277
+
278
+ const CLI_MODEL_CACHE = new Map() // backend → { at, mode, ids }
279
+ const CLI_MODEL_TTL_MS = 5 * 60 * 1000
280
+
281
+ // Windows .cmd/.bat 需要经 shell(Node 安全限制),但 `shell:true` + args 数组会触发
282
+ // DEP0190(参数不转义)。这里统一做法:把命令与参数拼成【一个】字符串交给 shell,
283
+ // 参数各自按 Windows 规则加引号 —— 既避开 DEP0190,又保证含空格参数不被拆开。
284
+ function quoteWinArg(a) {
285
+ const s = String(a)
286
+ if (s === '') return '""'
287
+ if (!/[\s"&|<>^]/.test(s)) return s
288
+ return '"' + s.replace(/"/g, '\\"') + '"'
289
+ }
290
+ function cliCommandLine(exe, args) {
291
+ return [exe].concat(args || []).map(quoteWinArg).join(' ')
292
+ }
293
+
294
+ /** 把候选拆成 { cmd, prefixArgs }:.mjs/.js/.cjs 目标经 node 包装(测试夹具与自定义
295
+ * 入口都靠这条),其余原样。 */
296
+ function splitExeSpec(exe) {
297
+ if (/\.(mjs|cjs|js)$/i.test(String(exe))) {
298
+ const proc = globalThis.process
299
+ return { cmd: (proc && proc.execPath) ? proc.execPath : 'node', prefixArgs: [String(exe)] }
300
+ }
301
+ return { cmd: String(exe), prefixArgs: [] }
302
+ }
303
+
304
+ function cliSpawnSync(exe, args, opts) {
305
+ const proc = globalThis.process
306
+ const child = (proc && typeof proc.getBuiltinModule === 'function') ? proc.getBuiltinModule('node:child_process') : null
307
+ if (!child || typeof child.spawnSync !== 'function') return null
308
+ const base = Object.assign({
309
+ encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe']
310
+ }, opts || {})
311
+ const spec = splitExeSpec(exe)
312
+ const argv = spec.prefixArgs.concat(args || [])
313
+ const isCmd = /\.(cmd|bat)$/i.test(spec.cmd)
314
+ if (isCmd) base.shell = true
315
+ try {
316
+ // shell 形态不带 args 数组(否则 DEP0190);非 shell 形态正常传数组。
317
+ return isCmd
318
+ ? child.spawnSync(cliCommandLine(spec.cmd, argv), base)
319
+ : child.spawnSync(spec.cmd, argv, base)
320
+ } catch (e) { return null }
321
+ }
322
+
323
+ /**
324
+ * 同步取「CLI 声明的 roster」(零网络,随 CLI 版本走)。
325
+ * @returns {{ok:boolean, ids:string[], mode:string, error?:string}}
326
+ */
327
+ export function fetchHelpModelsSync(env, opts) {
328
+ const o = opts || {}
329
+ const cands = cbCatalogCliCandidates(env)
330
+ let lastErr = null
331
+ for (const exe of cands) {
332
+ const r = cliSpawnSync(exe, ['--help'], { timeout: Math.max(1000, Math.min(o.timeoutMs || 12000, 30000)) })
333
+ if (!r || r.error) { lastErr = 'spawn failed: ' + String((r && r.error && r.error.message) || 'unavailable'); continue }
334
+ const text = String(r.stdout || '') + String(r.stderr || '')
335
+ const ids = parseHelpModels(text)
336
+ if (ids.length) return { ok: true, ids, mode: 'cli-help', exe }
337
+ lastErr = 'no roster in --help output'
338
+ }
339
+ return { ok: false, ids: [], mode: 'cli-help', error: lastErr || 'codebuddy CLI not found (set CODEBUDDY_BIN)' }
340
+ }
341
+
342
+ /**
343
+ * 异步探测「该账号当前支持」的清单:用无效模型触发 CLI 的 400 报文。
344
+ * 对齐 workbuddy-auth 的权威白名单语义(服务器告诉我们这个账号能用什么)。
345
+ * @returns {Promise<{ok:boolean, ids:string[], mode:string, error?:string}>}
346
+ */
347
+ export function probeAccountModels(env, opts) {
348
+ const o = opts || {}
349
+ const cands = cbCatalogCliCandidates(env)
350
+ const proc = globalThis.process
351
+ const child = (proc && typeof proc.getBuiltinModule === 'function') ? proc.getBuiltinModule('node:child_process') : null
352
+ if (!child || typeof child.spawn !== 'function') {
353
+ return Promise.resolve({ ok: false, ids: [], mode: 'account-live', error: 'no spawn capability in this host form' })
354
+ }
355
+ const extraEnv = o.envExtra || {}
356
+ return new Promise((resolve) => {
357
+ let idx = 0
358
+ // 记录每个候选的真实结局:区分「压根没找到 CLI」与「CLI 跑了但没吐账号名录」
359
+ // (后者通常是该面未登录 / 端点拒了)—— 报错文案必须能指导下一步,不能一律
360
+ // 说 "CLI not found"(本轮就踩到:codebuddy-en 无 token 时被误报成找不到 CLI)。
361
+ const reasons = []
362
+ const attempt = () => {
363
+ if (idx >= cands.length) {
364
+ resolve({
365
+ ok: false, ids: [], mode: 'account-live',
366
+ error: reasons.length ? reasons.join('; ') : 'no CLI candidate to probe',
367
+ notFound: reasons.every((r) => /not found|ENOENT|spawn/i.test(r))
368
+ })
369
+ return
370
+ }
371
+ const exe = cands[idx++]
372
+ let c
373
+ try {
374
+ // .cmd/.bat 需经 shell(Windows 安全限制);.mjs/.js 经 node 包装。
375
+ // 与 cliSpawnSync 同一规则:shell 形态把命令拼成单字符串(避免 DEP0190)。
376
+ const spec = splitExeSpec(exe)
377
+ const isCmd = /\.(cmd|bat)$/i.test(spec.cmd)
378
+ const probeArgs = spec.prefixArgs.concat(['-p', 'hi', '--model', '__clb_probe_invalid__', '--output-format', 'text'])
379
+ const opts = {
380
+ stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
381
+ env: Object.assign({}, proc.env, extraEnv)
382
+ }
383
+ c = isCmd
384
+ ? child.spawn(cliCommandLine(spec.cmd, probeArgs), Object.assign({ shell: true }, opts))
385
+ : child.spawn(spec.cmd, probeArgs, opts)
386
+ } catch (e) { reasons.push(exe + ': spawn threw ' + String(e && e.message || e)); attempt(); return }
387
+ let out = ''
388
+ const timer = setTimeout(() => { try { c.kill() } catch (e) { } }, Math.max(5000, Math.min(o.timeoutMs || 30000, 90000)))
389
+ const onData = (d) => { out += d; if (out.length > 400000) out = out.slice(-200000) }
390
+ c.stdout.on('data', onData)
391
+ c.stderr.on('data', onData)
392
+ c.on('error', (e) => {
393
+ clearTimeout(timer)
394
+ reasons.push(exe + ': ' + String((e && e.code) || (e && e.message) || 'spawn error'))
395
+ attempt()
396
+ })
397
+ c.on('close', (code) => {
398
+ clearTimeout(timer)
399
+ const ids = parseAccountModels(out)
400
+ if (ids && ids.length) { resolve({ ok: true, ids, mode: 'account-live', exe }); return }
401
+ const head = String(out).replace(/\s+/g, ' ').trim().slice(0, 160)
402
+ reasons.push(exe + ': exit ' + code + ', no account roster' + (head ? ' — ' + head : ''))
403
+ attempt()
404
+ })
405
+ }
406
+ attempt()
407
+ })
408
+ }
409
+
410
+ /** 缓存读写(面板每次渲染都会取,避免反复起进程)。 */
411
+ export function cachedCliModels(backend, load, nowMs) {
412
+ const at = Number(nowMs) || Date.now()
413
+ const hit = CLI_MODEL_CACHE.get(backend)
414
+ if (hit && at - hit.at < CLI_MODEL_TTL_MS) return hit
415
+ const v = typeof load === 'function' ? load() : load
416
+ if (v && v.ok && v.ids && v.ids.length) CLI_MODEL_CACHE.set(backend, { at, mode: v.mode, ids: v.ids.slice() })
417
+ return v
418
+ }
419
+
420
+ /** 读统一设置文件里 codebuddy 分区的 preferredBackend(meta 需要它才知道列哪个面)。 */
421
+ export function currentPreferredBackend() {
422
+ try {
423
+ const e = cbCatalogEnv || {}
424
+ const p = cbCatalogRead(e.CLI_BRIDGE_SETTINGS_FILE || cbCatalogPathJoin(
425
+ e.DSH_HOME || (e.APPDATA ? cbCatalogPathJoin(e.APPDATA, 'DSH Desktop', 'dsh-home') : ''), 'cli-bridge-settings.json'))
426
+ if (!p) return null
427
+ const all = JSON.parse(p)
428
+ const v = all && all.codebuddy && all.codebuddy.preferredBackend
429
+ return typeof v === 'string' && v ? v : null
430
+ } catch (err) { return null }
431
+ }
432
+
233
433
  // CLI 报错里解析「该账号当前支持的模型」(旧 core parseAccountModels verbatim)。
234
434
  export function parseAccountModels(text) {
235
435
  const s = String(text || '')
@@ -365,68 +565,41 @@ export const CREDENTIAL_KEYS = ['WORKBUDDY_TOKEN', 'WORKBUDDY_AI_TOKEN', 'CODEBU
365
565
  return { token: null, source: null }
366
566
  }
367
567
 
368
- // ── codebuddy_models 工具定义(搬自主文件;纯逻辑,依赖经 ctx 注入)────────────
369
- // ctx = { env, deps, resolveBackend, modelCatalogDetailed, getAccountCatalog }
370
- export function codebuddyModelsTool(ctx) {
371
- const c = ctx || {}
372
- const deps = c.deps || {}
373
- const env = c.env || {}
374
- return {
375
- name: 'codebuddy_models',
376
- description: 'List the models available to the local codebuddy CLI for a given backend. Merges three layers: the built-in product descriptor id table, any descriptor file on disk, and the account catalogue harvested from a previous CLI run (authorized ids first). Each item carries its credit multiplier (x0.00 = free). Use it to pick a model before codebuddy_run, or to see which ids are free. Returns ok:false with an actionable error when no catalogue can be read — it never invents a model list.',
377
- inputSchema: {
378
- type: 'object', additionalProperties: false, required: [],
379
- properties: {
380
- backend: { type: 'string', enum: c.BACKEND_ENUM, description: 'Which product face to list (default: the preferred backend).' },
381
- freeOnly: { type: 'boolean', description: 'Return only x0.00 (free) models.' },
382
- authorizedOnly: { type: 'boolean', description: 'Return only ids the current account is licensed for (harvested from a prior run).' }
383
- }
384
- },
385
- render: (args, value) => {
386
- const v = value || {}
387
- if (!v.ok) return [{ type: 'text', text: 'codebuddy_models failed: ' + String(v.error || 'unknown') }]
388
- const list = Array.isArray(v.models) ? v.models : []
389
- if (!list.length) return [{ type: 'text', text: 'codebuddy_models: no models matched (backend=' + (v.backend || '?') + ')' }]
390
- const lines = ['codebuddy models [' + v.backend + '] (' + list.length + '):']
391
- for (const m of list.slice(0, 40)) {
392
- const flags = []
393
- if (m.authorized) flags.push('authorized')
394
- if (m.credits === 0) flags.push('free')
395
- lines.push(' ' + m.id + (m.creditLabel ? ' ' + m.creditLabel : '') + (flags.length ? ' [' + flags.join(',') + ']' : ''))
396
- }
397
- if (list.length > 40) lines.push(' … and ' + (list.length - 40) + ' more')
398
- lines.push('source: ' + (v.source || 'unknown') + (v.accountCatalogue ? ' · account catalogue available' : ' · no account catalogue yet (run once to harvest)'))
399
- return [{ type: 'text', text: lines.join('\n') }]
400
- },
401
- async logic(args) {
402
- const a = args || {}
403
- const preferred = (deps.settings && deps.settings.preferredBackend) || null
404
- const backend = c.normalizeBackend(a.backend) || c.resolveBackend({}, null, preferred)
405
- let items = []
406
- try {
407
- items = c.modelCatalogDetailed(backend, { env }) || []
408
- } catch (e) {
409
- return { ok: false, backend, error: 'catalogue read failed: ' + String(e && e.message || e) }
410
- }
411
- if (!Array.isArray(items) || !items.length) {
412
- return {
413
- ok: false, backend,
414
- error: 'no model catalogue could be read for backend "' + backend + '". Check that the codebuddy CLI is installed (CODEBUDDY_BIN) and that its product descriptor exists, or run the CLI once so the account catalogue can be harvested.'
415
- }
416
- }
417
- const acctIds = c.getAccountCatalog(backend)
418
- const accountCatalogue = !!(acctIds && acctIds.length)
419
- let list = items.map((x) => ({
420
- id: x.id, authorized: !!x.authorized, credits: typeof x.credits === 'number' ? x.credits : null,
421
- creditLabel: x.creditLabel || x.label || '', label: x.label || x.id
422
- }))
423
- if (a.authorizedOnly) list = list.filter((m) => m.authorized)
424
- if (a.freeOnly) list = list.filter((m) => m.credits === 0)
425
- return {
426
- ok: true, backend, models: list, count: list.length,
427
- source: accountCatalogue ? 'account+descriptor' : 'descriptor',
428
- accountCatalogue
429
- }
430
- }
568
+ // ── 三工具 schema(旧 preset/codebuddy-first verbatim;契约 §8 红线)────────────
569
+ // 纯数据,无依赖 —— 放这里是为了让主描述符留在 600 行红线内(规则 §26)。
570
+ export const RUN_PARAMS = {
571
+ type: 'object',
572
+ additionalProperties: false,
573
+ required: ['prompt'],
574
+ properties: {
575
+ prompt: { type: 'string', description: 'The full task/instruction for codebuddy. Be complete and self-contained.' },
576
+ backend: { type: 'string', enum: BACKEND_ENUM, description: 'Which CLI face to dispatch to. codebuddy (default; domestic CodeBuddy npm CLI, product endpoint www.codebuddy.ai) for coding work; codebuddy-intl = CodeBuddy INTERNATIONAL face of the same npm CLI (product.ioa.json catalogue: claude-sonnet-5 / claude-opus-5 / gemini-3.1-pro / gpt-6-astra / hy3-ioa ...; aliases codebuddy-ioa / codebuddy-international) when the account is an international CodeBuddy account; codebuddy-en = WorkBuddy international edition (the WorkBuddyAI desktop bundled CLI at C:\\Program Files\\WorkBuddyAI, product endpoint www.workbuddy.ai; needs codebuddyEnToken unless DSH already holds a workbuddy key) — aliases workbuddy-en / workbuddy-ai also accepted; workbuddy = WorkBuddy domestic desktop CLI (product endpoint copilot.tencent.com, zero config), the office-scenario face: documents/slides/spreadsheets, knowledge-base lookups, image/video generation, WeChat/WeCom replies. New calls without a backend follow the user-preferred default backend (plugin settings). Continuing a session routes back to its owning backend automatically.' },
577
+ mode: { type: 'string', enum: ['auto', 'plan', 'accept-edits'], description: 'auto follows DSH plan state; plan = no writes; accept-edits = allow edits. Default auto.' },
578
+ model: { type: 'string', description: MODEL_DESCRIPTION },
579
+ effort: { type: 'string', enum: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'], description: 'Optional reasoning effort.' },
580
+ maxTurns: { type: 'integer', description: 'Optional max agentic turns (1-500, default unlimited).' },
581
+ cwd: { type: 'string', description: 'Working directory for codebuddy. Defaults to the DSH workspace root.' },
582
+ addDirs: { type: 'array', items: { type: 'string' }, description: 'Extra directories to add to codebuddy workspace.' },
583
+ timeoutSec: { type: 'integer', description: 'Run timeout seconds (10-3600, default 300); a DSH-side hang guard force-terminates at timeout+60s.' },
584
+ background: { type: 'boolean', description: 'Run as a background job and return a jobId immediately.' }
431
585
  }
586
+ }
587
+ export const CONTINUE_PARAMS = {
588
+ type: 'object',
589
+ additionalProperties: false,
590
+ required: ['prompt'],
591
+ properties: {
592
+ prompt: { type: 'string', description: 'Follow-up instruction for the ongoing codebuddy conversation.' },
593
+ sessionId: { type: 'string', description: 'codebuddy session id to resume (from a prior codebuddy_run result).' },
594
+ latest: { type: 'boolean', description: 'Continue the most recent codebuddy conversation instead of a specific id.' },
595
+ backend: { type: 'string', enum: BACKEND_ENUM, description: 'Which CLI face to resume on. codebuddy-en = WorkBuddy international (WorkBuddyAI desktop CLI; aliases workbuddy-en / workbuddy-ai accepted), workbuddy = WorkBuddy domestic desktop CLI. When omitted, the backend that owns the sessionId is used automatically; brand-new conversations follow the user-preferred default backend (plugin settings).' },
596
+ mode: { type: 'string', enum: ['auto', 'plan', 'accept-edits'], description: 'Execution mode; default auto.' },
597
+ model: { type: 'string', description: 'Optional model id (two layers: product face carries it, account licenses it; the credit multiplier of each id comes from the CLI descriptor — see codebuddy_run).' },
598
+ effort: { type: 'string', enum: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'], description: 'Optional reasoning effort.' },
599
+ maxTurns: { type: 'integer', description: 'Optional max agentic turns.' },
600
+ cwd: { type: 'string', description: "Working directory for codebuddy; when resuming, defaults to the resumed session's project directory." },
601
+ timeoutSec: { type: 'integer', description: 'Run timeout seconds (10-3600, default 300); a DSH-side hang guard force-terminates at timeout+60s.' },
602
+ background: { type: 'boolean', description: 'Run as a background job and return a jobId immediately.' }
432
603
  }
604
+ }
605
+ export const STATUS_PARAMS = { type: 'object', additionalProperties: false, required: [], properties: { cwd: { type: 'string', description: 'Optional: filter the snapshot to a single project (working directory).' } } }
@@ -0,0 +1,106 @@
1
+ // providers/codebuddy-models-tool.mjs — codebuddy_models 工具定义(纯逻辑)。
2
+ //
3
+ // 从 codebuddy-catalog.mjs 再拆出:两个文件都要留在 600 行红线内(规则 §26)。
4
+ // scripts/build.mjs 会把 providers/codebuddy-*.mjs 全部内联进 dynamic 沙箱的同一个
5
+ // 作用域,所以这里仍可引用 catalog 的函数(沙箱内天然可见);ESM 形态走本文件的 import。
6
+ // ⚠️ 注释里绝不写出口令标记字面量(build 全局替换会劈开注释 → 生成物语法崩)。
7
+
8
+ import { currentPreferredBackend, probeAccountModels, fetchHelpModelsSync } from './codebuddy-catalog.mjs'
9
+
10
+ // ── codebuddy_models 工具定义(搬自主文件;纯逻辑,依赖经 ctx 注入)────────────
11
+ // ctx = { env, deps, resolveBackend, modelCatalogDetailed, getAccountCatalog }
12
+ export function codebuddyModelsTool(ctx) {
13
+ const c = ctx || {}
14
+ const deps = c.deps || {}
15
+ const env = c.env || {}
16
+ return {
17
+ name: 'codebuddy_models',
18
+ description: 'List the models the local codebuddy CLI can use for a given backend, read LIVE from the CLI. Primary source: an account probe (runs the CLI with a deliberately invalid model id, which makes the server print "Currently supported models for your account") — this is the authoritative licensed list for THIS account. If that face is not logged in, it falls back to the roster the installed CLI declares in --help and says so in the source field. Credit multipliers (x0.00 = free) come from the product descriptor as metadata. Returns ok:false with an actionable error when no live roster can be read — it never presents a frozen static table as if it were live.',
19
+ inputSchema: {
20
+ type: 'object', additionalProperties: false, required: [],
21
+ properties: {
22
+ backend: { type: 'string', enum: c.BACKEND_ENUM, description: 'Which product face to list (default: the preferred backend).' },
23
+ freeOnly: { type: 'boolean', description: 'Return only x0.00 (free) models.' },
24
+ authorizedOnly: { type: 'boolean', description: 'Return only ids the current account is licensed for (harvested from a prior run).' }
25
+ }
26
+ },
27
+ render: (args, value) => {
28
+ const v = value || {}
29
+ if (!v.ok) return [{ type: 'text', text: 'codebuddy_models failed: ' + String(v.error || 'unknown') }]
30
+ const list = Array.isArray(v.models) ? v.models : []
31
+ if (!list.length) return [{ type: 'text', text: 'codebuddy_models: no models matched (backend=' + (v.backend || '?') + ')' }]
32
+ const lines = ['codebuddy models [' + v.backend + '] (' + list.length + '):']
33
+ for (const m of list.slice(0, 40)) {
34
+ const flags = []
35
+ if (m.authorized) flags.push('authorized')
36
+ if (m.credits === 0) flags.push('free')
37
+ lines.push(' ' + m.id + (m.creditLabel ? ' ' + m.creditLabel : '') + (flags.length ? ' [' + flags.join(',') + ']' : ''))
38
+ }
39
+ if (list.length > 40) lines.push(' … and ' + (list.length - 40) + ' more')
40
+ const srcLabel = v.source === 'account-live'
41
+ ? 'account-live (authoritative for this account)'
42
+ : 'cli-help (installed CLI roster, not the licensed subset)'
43
+ lines.push('source: ' + srcLabel)
44
+ if (v.note) lines.push('note: ' + v.note)
45
+ return [{ type: 'text', text: lines.join('\n') }]
46
+ },
47
+ async logic(args) {
48
+ const a = args || {}
49
+ const preferred = currentPreferredBackend() || (deps.settings && deps.settings.preferredBackend) || null
50
+ const backend = c.normalizeBackend(a.backend) || c.resolveBackend({}, null, preferred)
51
+
52
+ // ① 账号实时(最权威,对照 workbuddy-auth 的 agents[cli] 白名单语义)。
53
+ // 该面未登录/无 token 时探测会失败 —— 失败就**明确降级到 CLI 名册并标注**,
54
+ // 绝不把静态表冒充成账号可用清单。
55
+ const extraEnv = (c.faceEnv && c.faceEnv(backend)) || {}
56
+ const probe = await probeAccountModels(env, { envExtra: extraEnv, timeoutMs: a.timeoutMs })
57
+ let ids = null
58
+ let source = null
59
+ let note = ''
60
+ if (probe.ok && probe.ids.length) {
61
+ ids = probe.ids
62
+ source = 'account-live'
63
+ } else {
64
+ // ② CLI 声明的 roster(零网络,随 CLI 版本更新)。
65
+ const help = fetchHelpModelsSync(env, { timeoutMs: a.timeoutMs })
66
+ if (help.ok && help.ids.length) {
67
+ ids = help.ids
68
+ source = 'cli-help'
69
+ note = 'account probe unavailable (' + String(probe.error || 'not logged in for this face') +
70
+ '); showing the roster the installed CLI declares. It reflects the CLI version, not the licensed subset of this account.'
71
+ }
72
+ }
73
+ if (!ids || !ids.length) {
74
+ return {
75
+ ok: false, backend,
76
+ error: 'no live model roster could be read for backend "' + backend + '". ' +
77
+ 'Account probe: ' + String(probe.error || 'failed') + '. ' +
78
+ 'Install/fix the codebuddy CLI (CODEBUDDY_BIN) and make sure this face is logged in, then retry.'
79
+ }
80
+ }
81
+
82
+ // ③ 倍率/授权元数据(来自描述文件;仅作标注,不参与「是否可用」判定)。
83
+ let detail = []
84
+ try { detail = c.modelCatalogDetailed(backend, { env, accountModels: ids }) || [] } catch (e) { detail = [] }
85
+ const byId = new Map(detail.map((x) => [x.id, x]))
86
+ let list = ids.map((id) => {
87
+ const d = byId.get(id)
88
+ return {
89
+ id,
90
+ authorized: d ? !!d.authorized : (source === 'account-live'),
91
+ credits: d && typeof d.credits === 'number' ? d.credits : null,
92
+ creditLabel: (d && (d.creditLabel || d.label)) || '',
93
+ label: (d && d.label) || id
94
+ }
95
+ })
96
+ if (a.authorizedOnly) list = list.filter((m) => m.authorized)
97
+ if (a.freeOnly) list = list.filter((m) => m.credits === 0)
98
+ return {
99
+ ok: true, backend, models: list, count: list.length,
100
+ source, note: note || undefined,
101
+ probedAt: Date.now()
102
+ }
103
+ }
104
+ }
105
+ }
106
+
@@ -98,46 +98,13 @@ import {
98
98
  extractModelIds, parseCreditValue, formatCreditLabel, extractModelCredits, lookupModelCredits,
99
99
  backendModelCatalog, rememberAccountCatalog, getAccountCatalog, parseAccountModels,
100
100
  modelCatalogDetailed, formatModelCatalogText, cbCatalogSetHost,
101
- BACKEND_AUTH_IDS, CREDENTIAL_KEYS, readBackendAuthDomain, readDshWorkbuddyToken, resolveEnToken, codebuddyModelsTool
101
+ BACKEND_AUTH_IDS, CREDENTIAL_KEYS, readBackendAuthDomain,
102
+ readDshWorkbuddyToken, resolveEnToken,
103
+ fetchHelpModelsSync, cachedCliModels, currentPreferredBackend, parseHelpModels,
104
+ RUN_PARAMS, CONTINUE_PARAMS, STATUS_PARAMS
102
105
  } from './codebuddy-catalog.mjs'
106
+ import { codebuddyModelsTool } from './codebuddy-models-tool.mjs'
103
107
 
104
- // ── 三工具 schema(旧 preset/codebuddy-first verbatim;契约 §8)────────────────
105
- const RUN_PARAMS = {
106
- type: 'object',
107
- additionalProperties: false,
108
- required: ['prompt'],
109
- properties: {
110
- prompt: { type: 'string', description: 'The full task/instruction for codebuddy. Be complete and self-contained.' },
111
- backend: { type: 'string', enum: BACKEND_ENUM, description: 'Which CLI face to dispatch to. codebuddy (default; domestic CodeBuddy npm CLI, product endpoint www.codebuddy.ai) for coding work; codebuddy-intl = CodeBuddy INTERNATIONAL face of the same npm CLI (product.ioa.json catalogue: claude-sonnet-5 / claude-opus-5 / gemini-3.1-pro / gpt-6-astra / hy3-ioa ...; aliases codebuddy-ioa / codebuddy-international) when the account is an international CodeBuddy account; codebuddy-en = WorkBuddy international edition (the WorkBuddyAI desktop bundled CLI at C:\\Program Files\\WorkBuddyAI, product endpoint www.workbuddy.ai; needs codebuddyEnToken unless DSH already holds a workbuddy key) — aliases workbuddy-en / workbuddy-ai also accepted; workbuddy = WorkBuddy domestic desktop CLI (product endpoint copilot.tencent.com, zero config), the office-scenario face: documents/slides/spreadsheets, knowledge-base lookups, image/video generation, WeChat/WeCom replies. New calls without a backend follow the user-preferred default backend (plugin settings). Continuing a session routes back to its owning backend automatically.' },
112
- mode: { type: 'string', enum: ['auto', 'plan', 'accept-edits'], description: 'auto follows DSH plan state; plan = no writes; accept-edits = allow edits. Default auto.' },
113
- model: { type: 'string', description: MODEL_DESCRIPTION },
114
- effort: { type: 'string', enum: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'], description: 'Optional reasoning effort.' },
115
- maxTurns: { type: 'integer', description: 'Optional max agentic turns (1-500, default unlimited).' },
116
- cwd: { type: 'string', description: 'Working directory for codebuddy. Defaults to the DSH workspace root.' },
117
- addDirs: { type: 'array', items: { type: 'string' }, description: 'Extra directories to add to codebuddy workspace.' },
118
- timeoutSec: { type: 'integer', description: 'Run timeout seconds (10-3600, default 300); a DSH-side hang guard force-terminates at timeout+60s.' },
119
- background: { type: 'boolean', description: 'Run as a background job and return a jobId immediately.' }
120
- }
121
- }
122
- const CONTINUE_PARAMS = {
123
- type: 'object',
124
- additionalProperties: false,
125
- required: ['prompt'],
126
- properties: {
127
- prompt: { type: 'string', description: 'Follow-up instruction for the ongoing codebuddy conversation.' },
128
- sessionId: { type: 'string', description: 'codebuddy session id to resume (from a prior codebuddy_run result).' },
129
- latest: { type: 'boolean', description: 'Continue the most recent codebuddy conversation instead of a specific id.' },
130
- backend: { type: 'string', enum: BACKEND_ENUM, description: 'Which CLI face to resume on. codebuddy-en = WorkBuddy international (WorkBuddyAI desktop CLI; aliases workbuddy-en / workbuddy-ai accepted), workbuddy = WorkBuddy domestic desktop CLI. When omitted, the backend that owns the sessionId is used automatically; brand-new conversations follow the user-preferred default backend (plugin settings).' },
131
- mode: { type: 'string', enum: ['auto', 'plan', 'accept-edits'], description: 'Execution mode; default auto.' },
132
- model: { type: 'string', description: 'Optional model id (two layers: product face carries it, account licenses it; the credit multiplier of each id comes from the CLI descriptor — see codebuddy_run).' },
133
- effort: { type: 'string', enum: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'], description: 'Optional reasoning effort.' },
134
- maxTurns: { type: 'integer', description: 'Optional max agentic turns.' },
135
- cwd: { type: 'string', description: "Working directory for codebuddy; when resuming, defaults to the resumed session's project directory." },
136
- timeoutSec: { type: 'integer', description: 'Run timeout seconds (10-3600, default 300); a DSH-side hang guard force-terminates at timeout+60s.' },
137
- background: { type: 'boolean', description: 'Run as a background job and return a jobId immediately.' }
138
- }
139
- }
140
- const STATUS_PARAMS = { type: 'object', additionalProperties: false, required: [], properties: { cwd: { type: 'string', description: 'Optional: filter the snapshot to a single project (working directory).' } } }
141
108
 
142
109
  export default function defineProvider(deps = {}) {
143
110
  const env = deps.env || {}
@@ -525,12 +492,23 @@ export default function defineProvider(deps = {}) {
525
492
  }
526
493
  },
527
494
 
528
- // codebuddy_models:列出本机 codebuddy 可用模型(静态表 ∪ 描述文件 ∪ 账号清单)。
495
+ // codebuddy_models:实时列出本机 codebuddy 可用模型(账号探测优先,回落 CLI 名册)。
529
496
  // 只在宿主有 spawn/fs 能力时注册(preset 注入 deps.process / MCP 注入 deps.nodeChild;
530
497
  // dynamic 沙箱形态省略)。**失败必须 ok:false + 可操作错误**,绝不静默返回空列表或
531
498
  // 伪造模型(工作手册红线 §7;对照 workbuddy-auth 的 catalog-store 静默降级 —— 不照抄)。
532
499
  extraTools: (deps.process || deps.nodeChild || deps.nodeFs) ? [codebuddyModelsTool({
533
- env, deps, BACKEND_ENUM, normalizeBackend, resolveBackend, modelCatalogDetailed, getAccountCatalog
500
+ env, deps, BACKEND_ENUM, normalizeBackend, resolveBackend, modelCatalogDetailed, getAccountCatalog,
501
+ // faceEnv:让账号探测打到**请求的那个产品面**(与 buildArgv 完全同一套端点/凭据
502
+ // 注入规则)。缺了它,探测会走 CLI 自身默认登录面 —— 标着 codebuddy-en 实际却
503
+ // 问的是 codebuddy 面,得出的清单看着对其实是另一个面的(本轮修的就是这类偏差)。
504
+ faceEnv: (backend) => {
505
+ try {
506
+ const settings = (deps.settings && typeof deps.settings === 'object') ? deps.settings : {}
507
+ const authDomain = readBackendAuthDomain(backend, { env })
508
+ const tok = resolveEnToken(backend === 'codebuddy-en' ? settings.codebuddyEnToken : null, { env })
509
+ return endpointEnv(backend, settings.endpointOverride, authDomain, tok.token) || {}
510
+ } catch (e) { return {} }
511
+ }
534
512
  })] : [],
535
513
 
536
514
  // 设置分区(契约 §3):keystone 统一文件 {codebuddy:{...}};旧扁平文件的字段
@@ -541,10 +519,27 @@ export default function defineProvider(deps = {}) {
541
519
  defaults: () => normalizeSettings(null),
542
520
  normalize: (raw) => normalizeSettings(raw),
543
521
  meta() {
544
- // defaultModel 下拉 = 默认后端(codebuddy 面)的带倍率目录(升级 CLI 后
545
- // 自动同步);其余三面目录不同,字段允许自定义输入即可覆盖。促销角标为
546
- // 0.1.0 有意省略(见头注 TODO)。
547
- const options = modelCatalogDetailed(DEFAULT_BACKEND, {}).map((x) => ({ value: x.id, label: x.label }))
522
+ // defaultModel 下拉 = **实时**取自 CLI(parseHelpModels 解析 `--help` 的
523
+ // "Currently supported: (...)")。此前读磁盘 product.ioa.json 静态表,实测
524
+ // 落后于 CLI(16 项 vs CLI 19 项,且 2 个 id 已改名),是「列表不对」的根因。
525
+ // 面板必须同步返回,故用零网络的 cli-help roster;账号级权威清单由
526
+ // codebuddy_models 工具(异步探测)提供。
527
+ // 面跟随 preferredBackend(旧实现硬编码 codebuddy 面,无视用户设置)。
528
+ const face = (currentPreferredBackend() && normalizeBackend(currentPreferredBackend())) || DEFAULT_BACKEND
529
+ const live = cachedCliModels(face, () => fetchHelpModelsSync(env))
530
+ let options = []
531
+ let note = '账号实际可用性以服务端实时裁决;日常任务优先免费(x0.00)。'
532
+ if (live && live.ok && live.ids.length) {
533
+ const detail = modelCatalogDetailed(face, { env, accountModels: live.ids })
534
+ const byId = new Map(detail.map((x) => [x.id, x]))
535
+ options = live.ids.map((id) => {
536
+ const d = byId.get(id)
537
+ return { value: id, label: (d && d.label) || id }
538
+ })
539
+ note = '实时来自本机 codebuddy CLI 的声明名册(' + live.ids.length + ' 项,随 CLI 版本更新);当前面:' + face + '。账号级权威清单见 codebuddy_models。'
540
+ } else {
541
+ note = '未能从 CLI 读取模型名册(' + String((live && live.error) || 'unknown') + ')—— 下拉为空,可手动输入模型 id。'
542
+ }
548
543
  return {
549
544
  titleSuffix: 'CodeBuddy',
550
545
  fields: [
@@ -556,7 +551,7 @@ export default function defineProvider(deps = {}) {
556
551
  {
557
552
  key: 'defaultModel', label: '默认模型', type: 'select', allowCustom: true, placeholder: '(CLI 默认)',
558
553
  options,
559
- note: '账号实际可用性以服务端实时裁决(400 报文回吐权威清单,桥接自动缓存)。优先选能完成任务里最便宜的,日常任务优先免费(x0.00)。'
554
+ note
560
555
  },
561
556
  { key: 'codebuddyEnToken', label: 'WorkBuddy 国际版 Token', type: 'secret', note: '仅 backend="codebuddy-en" 需要。国际版登录 token 被 protector key 封装不落盘,只能手工提供;留空则自动尝试 DSH 凭据库的 WORKBUDDY_TOKEN。' },
562
557
  { key: 'endpointOverride', label: '端点覆盖', type: 'text', placeholder: 'https://…/v2', note: '一般留空:端点按登录域自动对齐(www.codebuddy.cn 等)。仅在需要强制指定 API 端点时填写,且必须自带 /v2。' }