@luchenglong/cli-bridge 0.2.4 → 0.2.5

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.
@@ -0,0 +1,432 @@
1
+ // providers/codebuddy-catalog.mjs — codebuddy 模型目录/积分/描述文件解析(纯逻辑)。
2
+ //
3
+ // 从 providers/codebuddy.mjs 拆出:主文件原 864 行远超 600 行红线(规则 §26)。
4
+ // 只放「模型目录相关」纯逻辑:静态表、描述文件候选路径、模型 id 抽取、积分倍率、
5
+ // 账号清单收割、目录渲染。不含 CLI 调用、状态机、凭据链。
6
+ //
7
+ // 沙箱纪律(与 providers/*.mjs 同):本文件会被 scripts/build.mjs 经模板顶部的
8
+ // 第三个占位标记内联进同一个 dynamic 沙箱脚本作用域 —— 所以:
9
+ // 1) 不得 import 任何模块(import 行会被 stripModuleSyntax 剥掉,函数体留下但
10
+ // 依赖丢失 → 运行期 ReferenceError);node 内建走 cbCatalog* 守卫;
11
+ // 2) 顶层标识符不得与 bridge-kit / codebuddy.mjs 剩余部分撞名(同一作用域重复
12
+ // 声明 = 构建期 Identifier already declared);
13
+ // 3) 不得出现 import.meta 字面量;
14
+ // 4) ⚠️ 注释里绝不写出那个占位标记的字面量:build 做全局文本替换,注释中出现
15
+ // 同样会被替换成正文代码,把注释从中间劈开 → 生成物语法崩。
16
+
17
+ // ── 宿主能力注入(env / fs / path / sep)───────────────────────────────────
18
+ // 本模块函数搬自主文件的闭包,拿不到那边的 env / fsRead / pathJoin / pathSep。
19
+ // 由 codebuddy.mjs 在 defineProvider 里调 cbCatalogSetHost() 注入;未注入时退到
20
+ // getBuiltinModule 守卫(沙箱里没有 → 返回 null / 默认分隔符,绝不抛)。
21
+ // 名字一律带 cbCatalog_ 前缀:内联后与主文件的 fsRead/pathJoin 同处一个作用域。
22
+ let cbCatalogEnv = {}
23
+ let cbCatalogFs = null
24
+ let cbCatalogPathSep = "/"
25
+
26
+ export function cbCatalogSetHost(envObj, pathMod, fsMod) {
27
+ if (envObj && typeof envObj === "object") cbCatalogEnv = envObj
28
+ if (fsMod && typeof fsMod.readFileSync === "function") cbCatalogFs = fsMod
29
+ if (pathMod && typeof pathMod.sep === "string") cbCatalogPathSep = pathMod.sep
30
+ else if (/[A-Za-z]:[\\/]/.test(String((cbCatalogEnv && (cbCatalogEnv.USERPROFILE || cbCatalogEnv.APPDATA)) || ""))) cbCatalogPathSep = "\\"
31
+ }
32
+
33
+ export function cbCatalogRead(p, enc) {
34
+ if (!p) return null
35
+ try {
36
+ if (cbCatalogFs && typeof cbCatalogFs.readFileSync === "function") return cbCatalogFs.readFileSync(p, enc || "utf8")
37
+ const proc = globalThis.process
38
+ const nf = (proc && typeof proc.getBuiltinModule === "function") ? proc.getBuiltinModule("node:fs") : null
39
+ if (!nf || typeof nf.readFileSync !== "function") return null
40
+ return nf.readFileSync(p, enc || "utf8")
41
+ } catch (e) { return null }
42
+ }
43
+
44
+ export function cbCatalogPathJoin(...parts) {
45
+ try {
46
+ const proc = globalThis.process
47
+ const np = (proc && typeof proc.getBuiltinModule === "function") ? proc.getBuiltinModule("node:path") : null
48
+ if (np && typeof np.join === "function") return np.join(...parts)
49
+ } catch (e) { }
50
+ return parts.join("/")
51
+ }
52
+
53
+ // ── 以下常量与函数原样搬自 providers/codebuddy.mjs(行为 zero-change)────────
54
+
55
+ export const BACKEND_MODEL_IDS = {
56
+ 'codebuddy': ['hy4-preview', 'hy3', 'hy3-x', 'deepseek-v4.1-flash', 'glm-5.3', 'glm-5.3-flash', 'glm-5.2', 'glm-5.1', 'glm-5v-turbo', 'minimax-m3', 'minimax-m2.7', 'kimi-k3-1', 'kimi-k2.8-preview', 'kimi-k2.7', 'kimi-k2.6', 'deepseek-v4-pro'],
57
+ 'codebuddy-intl': ['claude-sonnet-5', 'claude-sonnet-5-1m', 'claude-opus-5', 'claude-opus-4.8', 'claude-opus-4.8-1m', 'gemini-3.1-pro', 'gemini-3.5-flash', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5', 'gpt-5.4', 'glm-5.3-ioa', 'glm-5.3-flash-ioa', 'glm-5.2-ioa', 'kimi-k3-ioa', 'kimi-k2.8-preview', 'kimi-k2.6-ioa', 'minimax-m3-ioa', 'minimax-m2.7-ioa', 'deepseek-v4.1-flash', 'deepseek-v4-pro-ioa'],
58
+ // WorkBuddyAI 自制目录(product.json 顶层 37 项,按其 credits 排序:免费的在前)
59
+ 'codebuddy-en': ['hy3', 'gpt-5.1-codex-mini', 'gemini-3.1-flash-lite', 'gemini-2.5-flash', 'minimax-m3', 'gemini-3.0-flash', 'fast-model', 'deepseek-v3-2-volc', 'balanced-model', 'kimi-k2.5', 'kimi-k2.6', 'default-model-lite', 'glm-5.3', 'glm-5.2', 'glm-5.0', 'gpt-5.1-codex', 'gemini-2.5-pro', 'gemini-3.5-flash', 'gpt-5.3-codex', 'gemini-3.1-pro', 'gpt-5.6-terra', 'kimi-k3', 'gpt-5.4', 'primary-model', 'default-model', 'deep-model', 'gpt-5.5', 'gpt-5.6-sol', 'gpt-5.6-luna'],
60
+ // WorkBuddy 国内桌面自制目录(product.json:applicationName=WorkBuddy)
61
+ 'workbuddy': ['auto', 'hy4-preview', 'hy3', 'hy3-x', 'deepseek-v4.1-flash', 'glm-5.3', 'glm-5.3-flash', 'glm-5.2', 'glm-5.1', 'glm-5v-turbo', 'minimax-m3', 'minimax-m2.7', 'kimi-k3-1', 'kimi-k2.8-preview', 'kimi-k2.7', 'kimi-k2.6', 'deepseek-v4-pro']
62
+ }
63
+
64
+ // 各后端真正生效的 product 描述文件位置(旧 core v1.6.1 verbatim)。
65
+ export const PRODUCT_DESCRIPTOR_ENV = {
66
+ 'codebuddy': { env: 'CODEBUDDY_MODELS_FILE', rel: 'product.ioa.json', fallbackRel: 'product.json', accountDir: null },
67
+ 'codebuddy-intl': { env: 'CODEBUDDY_INTL_MODELS_FILE', rel: 'product.ioa.json', fallbackRel: 'product.json', accountDir: null },
68
+ 'codebuddy-en': { env: 'CODEBUDDY_EN_MODELS_FILE', rel: 'product.ioa.json', fallbackRel: 'product.json', accountDir: '.workbuddy-ai' },
69
+ 'workbuddy': { env: 'WORKBUDDY_MODELS_FILE', rel: 'product.ioa.json', fallbackRel: 'product.json', accountDir: '.workbuddy' }
70
+ }
71
+
72
+ // 倍率跨命名匹配(账号清单 id → 描述文件 id,旧 core verbatim)。
73
+ export const CREDIT_ID_ALIASES = {
74
+ 'kimi-k3-1': ['kimi-k3', 'kimi-k3-ioa'],
75
+ 'hy3-x': ['hy3', 'hy3-ioa'],
76
+ 'default': ['codewise-default-model-v2'],
77
+ 'auto': ['hy3', 'hy3-ioa']
78
+ }
79
+
80
+ export const MODEL_DESCRIPTION = 'Optional model id. When unspecified, the user-preferred default model (plugin settings) is used if set, else the CLI default. Two layers decide: the PRODUCT FACE decides whether the id exists in its catalogue, then the ACCOUNT decides whether it is licensed. The free tier (hy3, deepseek-v4.1-flash at x0.00 credits; hy4-preview at x0.29) belongs to the npm CLI faces codebuddy/codebuddy-intl: hy4-preview, hy3, hy3-x, deepseek-v4.1-flash, glm-5.3, glm-5.3-flash, glm-5.2, glm-5.1, glm-5v-turbo, minimax-m3, minimax-m2.7, kimi-k3-1, kimi-k2.8-preview, kimi-k2.7, kimi-k2.6, deepseek-v4-pro. codebuddy-en (WorkBuddyAI) carries NO hy4-preview and NO deepseek-v4.1-flash; its only free model is hy3. International ids (claude-sonnet-5, gemini-3.1-pro, ...) are account-gated. Availability is per ACCOUNT: the CLI rejects an id outside the account catalogue with "400 service info not found" and one inside it but unlicensed with "400 only available for authorized users" (many catalogue flagships such as claude-opus-4.8, gpt-5.4 and gpt-6-astra are in this second group for a domestic account). Run the CLI with a deliberately invalid model id to print "Currently supported models for your account:" and get the authoritative list; the plugin caches that result and the settings panel lists exactly those ids with their credit multiplier (x0.00 = free), read live from the CLI descriptor so it stays in sync with CLI upgrades. Prefer the cheapest model that can do the job, and prefer a free (x0.00) one for routine work. Pass a model only when the task clearly benefits from a specific one.'
81
+
82
+ export const BACKEND_ENUM = ['codebuddy', 'codebuddy-intl', 'codebuddy-ioa', 'codebuddy-international', 'codebuddy-en', 'workbuddy-en', 'workbuddy-ai', 'workbuddy']
83
+
84
+ export function accountConfigCandidates(backend, io) {
85
+ const e = (io && io.env) || cbCatalogEnv
86
+ const spec = PRODUCT_DESCRIPTOR_ENV[backend]
87
+ if (!spec || !spec.accountDir) return []
88
+ const home = e.USERPROFILE || e.HOME
89
+ if (!home) return []
90
+ const base = cbCatalogPathJoin(home, spec.accountDir.split('/').join(cbCatalogPathSep))
91
+ return [
92
+ cbCatalogPathJoin(base, 'cache', 'acc-product-config-v3.json'),
93
+ cbCatalogPathJoin(base, 'local_storage', 'entry_d43e96994f944cfb77961c2ea7d04605.info'),
94
+ cbCatalogPathJoin(base, 'local_storage', 'wb_entry_d43e96994f944cfb77961c2ea7d04605.info')
95
+ ]
96
+ }
97
+
98
+ export function productDescriptorCandidates(backend, io) {
99
+ const e = (io && io.env) || cbCatalogEnv
100
+ const spec = PRODUCT_DESCRIPTOR_ENV[backend]
101
+ if (!spec) return []
102
+ const out = []
103
+ for (const p of accountConfigCandidates(backend, io)) out.push(p)
104
+ if (e[spec.env]) out.push(e[spec.env])
105
+ const rels = spec.fallbackRel ? [spec.rel, spec.fallbackRel] : [spec.rel]
106
+ const push = (root) => { if (root) for (const r of rels) out.push(cbCatalogPathJoin(root, ...r.split('/'))) }
107
+ if (backend === 'codebuddy' || backend === 'codebuddy-intl') {
108
+ push(e.APPDATA ? cbCatalogPathJoin(e.APPDATA, 'npm', 'node_modules', '@tencent-ai', 'codebuddy-code') : null)
109
+ } else if (backend === 'codebuddy-en') {
110
+ push('C:\\Program Files\\WorkBuddyAI\\resources\\app.asar.unpacked\\cli')
111
+ } else if (backend === 'workbuddy') {
112
+ push('C:\\Program Files\\WorkBuddy\\resources\\app.asar.unpacked\\cli')
113
+ }
114
+ return out
115
+ }
116
+
117
+ export function normalizeProductConfig(raw) {
118
+ try {
119
+ const obj = typeof raw === 'string' ? JSON.parse(raw) : raw
120
+ if (!obj) return null
121
+ if (Array.isArray(obj)) {
122
+ // 数组形态:从后往前找第一个带 data 的条目(末尾最新)。
123
+ for (let i = obj.length - 1; i >= 0; i--) {
124
+ const e = obj[i]
125
+ if (e && typeof e === 'object' && e.data && typeof e.data === 'object') return e.data
126
+ }
127
+ return null
128
+ }
129
+ if (typeof obj !== 'object') return null
130
+ if (obj.data && typeof obj.data === 'object' && !Array.isArray(obj.models) && !Array.isArray(obj.agents)) return obj.data
131
+ return obj
132
+ } catch (e) { return null }
133
+ }
134
+
135
+ export function extractModelIds(productJson) {
136
+ try {
137
+ const obj = normalizeProductConfig(productJson)
138
+ if (!obj || typeof obj !== 'object') return null
139
+ const pick = (arr) => {
140
+ if (!Array.isArray(arr) || !arr.length) return null
141
+ const ids = arr.map((m) => (typeof m === 'string' ? m : m && m.id)).filter((s) => typeof s === 'string' && s.trim())
142
+ return ids.length ? [...new Set(ids)] : null
143
+ }
144
+ const agents = Array.isArray(obj.agents) ? obj.agents : []
145
+ const cli = agents.find((a) => a && a.name === 'cli')
146
+ const fromCli = cli ? pick(cli.models) : null
147
+ const fromTop = pick(obj.models)
148
+ if (fromCli && fromTop) return fromTop.length > fromCli.length ? fromTop : fromCli
149
+ return fromCli || fromTop || null
150
+ } catch (e) { return null }
151
+ }
152
+
153
+ export function parseCreditValue(raw) {
154
+ if (typeof raw !== 'string') return null
155
+ const s = raw.trim()
156
+ let m = s.match(/^[xX×]\s*([0-9]+(?:\.[0-9]+)?)/)
157
+ if (!m) m = s.match(/^([0-9]+(?:\.[0-9]+)?)\s*[xX×]/)
158
+ if (!m) return null
159
+ const n = Number(m[1])
160
+ return Number.isFinite(n) ? n : null
161
+ }
162
+
163
+ export function formatCreditLabel(credits) {
164
+ if (typeof credits !== 'number' || !Number.isFinite(credits)) return ''
165
+ if (credits === 0) return '免费'
166
+ return `x${credits.toFixed(2)}`
167
+ }
168
+
169
+ export function extractModelCredits(productJson) {
170
+ try {
171
+ const obj = normalizeProductConfig(productJson)
172
+ if (!obj || typeof obj !== 'object' || !Array.isArray(obj.models)) return null
173
+ const map = new Map()
174
+ for (const m of obj.models) {
175
+ if (!m || typeof m !== 'object') continue
176
+ const id = typeof m.id === 'string' ? m.id.trim() : ''
177
+ if (!id) continue
178
+ const n = parseCreditValue(m.credits)
179
+ if (n !== null) map.set(id, n)
180
+ }
181
+ return map.size ? map : null
182
+ } catch (e) { return null }
183
+ }
184
+
185
+ export function lookupModelCredits(creditsMap, id) {
186
+ if (!creditsMap || typeof id !== 'string') return null
187
+ const norm = id.trim()
188
+ const cands = [norm]
189
+ if (norm.endsWith('-ioa')) cands.push(norm.slice(0, -4))
190
+ else cands.push(norm + '-ioa')
191
+ if (CREDIT_ID_ALIASES[norm]) cands.push(...CREDIT_ID_ALIASES[norm])
192
+ for (const c of cands) {
193
+ if (creditsMap.has(c)) {
194
+ const v = creditsMap.get(c)
195
+ if (typeof v === 'number') return v
196
+ }
197
+ }
198
+ return null
199
+ }
200
+
201
+ export function backendModelCatalog(backend, io) {
202
+ const known = BACKEND_MODEL_IDS[backend] || []
203
+ const read = (io && io.readFileSync) || cbCatalogRead
204
+ let fromFile = null
205
+ let fromFileRich = null
206
+ // 旧 v1.6.1 语义:第一份有内容的 + 第一份带促销的,优先后者。
207
+ for (const p of productDescriptorCandidates(backend, io)) {
208
+ let txt = null
209
+ try { txt = read ? read(p, 'utf8') : null } catch (e) { continue }
210
+ if (txt == null) continue
211
+ const ids = extractModelIds(txt)
212
+ if (ids && ids.length) {
213
+ if (!fromFile) fromFile = ids
214
+ const obj = normalizeProductConfig(txt)
215
+ if (!fromFileRich && obj && Array.isArray(obj.modelPromotions)) fromFileRich = ids
216
+ }
217
+ if (fromFileRich) break
218
+ }
219
+ if (!fromFile) return known
220
+ return fromFileRich || fromFile
221
+ }
222
+
223
+ // 服务端回吐的账号清单缓存(进程内,旧 core verbatim)。
224
+ const ACCOUNT_CATALOG_CACHE = new Map()
225
+ export function rememberAccountCatalog(backend, ids) {
226
+ if (Array.isArray(ids) && ids.length) ACCOUNT_CATALOG_CACHE.set(backend, ids.slice())
227
+ }
228
+ export function getAccountCatalog(backend) {
229
+ const v = ACCOUNT_CATALOG_CACHE.get(backend)
230
+ return v && v.length ? v.slice() : null
231
+ }
232
+
233
+ // CLI 报错里解析「该账号当前支持的模型」(旧 core parseAccountModels verbatim)。
234
+ export function parseAccountModels(text) {
235
+ const s = String(text || '')
236
+ const anchor = s.indexOf('Currently supported models for your account')
237
+ if (anchor < 0) return null
238
+ const tail = s.slice(anchor)
239
+ const ids = []
240
+ for (const line of tail.split(/\r?\n/)) {
241
+ const m = /^\s*[-*]\s+(\S+)\s*$/.exec(line)
242
+ if (!m) {
243
+ if (ids.length) break
244
+ continue
245
+ }
246
+ if (!ids.includes(m[1])) ids.push(m[1])
247
+ }
248
+ return ids.length ? ids : null
249
+ }
250
+
251
+ // 带倍率/授权的详细清单(无促销角标,旧 v1.6.1 的 0.1.0 子集;排序规则 verbatim:
252
+ // 已授权在前 → 有倍率升序 → 无倍率殿后)。
253
+ export function modelCatalogDetailed(backend, io) {
254
+ const o = io || {}
255
+ const fromFile = backendModelCatalog(backend, o)
256
+ const acct = (Array.isArray(o.accountModels) && o.accountModels.length) ? o.accountModels : getAccountCatalog(backend)
257
+ const ids = []
258
+ const authorized = new Set()
259
+ if (acct) for (const id of acct) { if (!ids.includes(id)) ids.push(id); authorized.add(id) }
260
+ for (const id of fromFile) if (!ids.includes(id)) ids.push(id)
261
+ const read = o.readFileSync || cbCatalogRead
262
+ let creditsMap = null
263
+ for (const p of productDescriptorCandidates(backend, io)) {
264
+ let txt = null
265
+ try { txt = read ? read(p, 'utf8') : null } catch (e) { continue }
266
+ if (txt == null) continue
267
+ const map = extractModelCredits(txt)
268
+ if (map) { creditsMap = map; break }
269
+ }
270
+ const items = ids.map((id) => {
271
+ const base = lookupModelCredits(creditsMap, id)
272
+ const credits = base
273
+ const hasCredits = typeof credits === 'number'
274
+ const suffix = formatCreditLabel(credits)
275
+ const marks = []
276
+ if (suffix) marks.push(suffix)
277
+ if (acct && !authorized.has(id)) marks.push('未授权')
278
+ return {
279
+ id,
280
+ credits,
281
+ baseCredits: base,
282
+ free: hasCredits && credits === 0,
283
+ hasCredits,
284
+ authorized: acct ? authorized.has(id) : null,
285
+ promotion: null,
286
+ label: marks.length ? `${id} · ${marks.join(' · ')}` : id
287
+ }
288
+ })
289
+ const rank = (x) => (x.authorized === false ? 1 : 0)
290
+ const withC = items.filter((x) => x.hasCredits).sort((a, b) => rank(a) - rank(b) || a.credits - b.credits)
291
+ const withoutC = items.filter((x) => !x.hasCredits).sort((a, b) => rank(a) - rank(b))
292
+ return [...withC, ...withoutC]
293
+ }
294
+
295
+ export function formatModelCatalogText(backend, io) {
296
+ return modelCatalogDetailed(backend, io).map((x) => x.label).join(', ')
297
+ }
298
+
299
+
300
+ export const BACKEND_AUTH_IDS = {
301
+ 'codebuddy': 'Tencent-Cloud.coding-copilot',
302
+ 'codebuddy-intl': 'Tencent-Cloud.coding-copilot',
303
+ 'codebuddy-en': 'workbuddy-desktop-ai',
304
+ 'workbuddy': 'workbuddy-desktop'
305
+ }
306
+
307
+ export const CREDENTIAL_KEYS = ['WORKBUDDY_TOKEN', 'WORKBUDDY_AI_TOKEN', 'CODEBUDDY_EN_TOKEN']
308
+
309
+ // ── 凭据读取(搬自 codebuddy.mjs;fs/path 走 cbCatalog* 守卫,env 经 io 或注入)
310
+ export function readBackendAuthDomain(backend, io) {
311
+ const o = io || {}
312
+ const id = BACKEND_AUTH_IDS[backend]
313
+ if (!id) return null
314
+ try {
315
+ const read = o.readFileSync || cbCatalogRead
316
+ if (typeof read !== 'function') return null
317
+ const e = o.env || env
318
+ let dir = o.dir
319
+ if (!dir) {
320
+ dir = e.CODEBUDDY_AUTH_DIR ||
321
+ cbCatalogPathJoin(e.LOCALAPPDATA || 'C:\\Users\\lcl\\AppData\\Local', 'CodeBuddyExtension', 'Data', 'Public', 'auth')
322
+ }
323
+ const sep = String(dir).indexOf('\\') >= 0 ? '\\' : '/'
324
+ const txt = read(dir + sep + id + '.info', 'utf8')
325
+ if (txt == null) return null
326
+ const raw = JSON.parse(txt)
327
+ const d = raw && raw.auth && raw.auth.domain
328
+ return typeof d === 'string' && d.trim() ? d.trim() : null
329
+ } catch (e) { return null }
330
+ }
331
+
332
+ // DSH 凭据库第三通道(旧 core readDshWorkbuddyToken verbatim 语义;CODEBUDDY_
333
+ // CREDENTIALS_DIR 覆盖测试夹具;fs 经 deps 注入)。
334
+ export function readDshWorkbuddyToken(io) {
335
+ const e = (io && io.env) || cbCatalogEnv
336
+ const read = (io && io.readFileSync) || cbCatalogRead
337
+ const overrideDir = e.CODEBUDDY_CREDENTIALS_DIR || null
338
+ const home = overrideDir || e.DSH_HOME || null
339
+ if (!home) return null
340
+ const files = [home + '\\.credentials.yaml', home + '\\.env', home + '/.credentials.yaml', home + '/.env']
341
+ for (const f of files) {
342
+ const text = read(f)
343
+ if (!text) continue
344
+ for (const key of CREDENTIAL_KEYS) {
345
+ const m = text.match(new RegExp('^[ \\t]*' + key + '[ \\t]*[:=][ \\t]*(.+)$', 'm'))
346
+ if (!m) continue
347
+ const v = m[1].trim().replace(/\s+#.*$/, '').replace(/^['"]|['"]$/g, '').trim()
348
+ // 只接受看起来是真实 token 的值(避免把 $VAR 引用或占位符当凭据)。
349
+ if (v.length >= 40 && !v.startsWith('$') && !/^<.*>$/.test(v)) return v
350
+ }
351
+ }
352
+ return null
353
+ }
354
+
355
+ // codebuddy-en 凭据链(旧 core verbatim):设置面板 > CODEBUDDY_AUTH_TOKEN >
356
+ // DSH 凭据库。dynamic 沙箱无 fs → 第三通道自然缺席(旧 dynamic 本也有限)。
357
+ export function resolveEnToken(settingToken, io) {
358
+ const e = (io && io.env) || cbCatalogEnv
359
+ const s = String(settingToken || '').trim()
360
+ if (s) return { token: s, source: 'setting' }
361
+ const envTok = e.CODEBUDDY_AUTH_TOKEN || ''
362
+ if (String(envTok).trim()) return { token: String(envTok).trim(), source: 'env' }
363
+ const fromStore = readDshWorkbuddyToken(io)
364
+ if (fromStore) return { token: fromStore, source: 'dsh-store' }
365
+ return { token: null, source: null }
366
+ }
367
+
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
+ }
431
+ }
432
+ }