mslxdff 0.1.136 → 0.1.138

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.
Files changed (38) hide show
  1. package/package.json +2 -2
  2. package/src/chat/gateway.js +1 -1
  3. package/src/chat/prompt.js +1 -1
  4. package/src/chat/terminal.js +1 -1
  5. package/src/cli/commands/model/list-providers.js +1 -1
  6. package/src/cli/commands/model/list-render.js +2 -2
  7. package/src/cli/commands/provider/add.js +2 -1
  8. package/src/cli/commands/provider/bench-via.js +1 -1
  9. package/src/cli/commands/provider/cline-free.js +94 -0
  10. package/src/cli/commands/provider/cline-login.js +20 -15
  11. package/src/cli/commands/provider/index.js +22 -0
  12. package/src/cli/commands/provider/models.js +4 -1
  13. package/src/cli/commands/provider/workbuddy-login.js +1 -1
  14. package/src/cli/commands/workbuddy.js +14 -21
  15. package/src/cli/index.js +3 -0
  16. package/src/compat.js +23 -5
  17. package/src/model-capabilities/merge.js +2 -2
  18. package/src/providers/classify.js +1 -1
  19. package/src/providers/cline/auth.js +1 -1
  20. package/src/providers/cline/chat.js +14 -11
  21. package/src/providers/cline/free-catalog.js +71 -0
  22. package/src/providers/cline/index.js +2 -16
  23. package/src/providers/cline/models.js +28 -51
  24. package/src/providers/model-id.js +4 -4
  25. package/src/providers/registry.js +1 -1
  26. package/src/providers/share-keys.js +6 -1
  27. package/src/providers/workbuddy/account-store.js +73 -7
  28. package/src/providers/workbuddy/chat.js +1 -1
  29. package/src/providers/workbuddy/index.js +10 -20
  30. package/src/readline-compat.js +9 -10
  31. package/src/runtime/providers-setup.js +9 -0
  32. package/src/runtime/server-lifecycle.js +2 -2
  33. package/src/state/migrations/cline-unify.js +94 -0
  34. package/src/state/migrations.js +33 -0
  35. package/src/state/schemas/token.js +6 -2
  36. package/src/state/schemas/use-group.js +4 -4
  37. package/src/sync-workbuddy.js +1 -1
  38. package/src/upstream-engine/index.js +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.136",
3
+ "version": "0.1.138",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,7 @@
12
12
  "docs:check": "node scripts/docs-check.js"
13
13
  },
14
14
  "engines": {
15
- "node": ">=16"
15
+ "node": ">=18"
16
16
  },
17
17
  "files": [
18
18
  "bin/",
@@ -138,7 +138,7 @@ export function createGatewayClient({
138
138
  }
139
139
  if (provider === "opencode" && rawModel.includes("/")) {
140
140
  const maybe = rawModel.split("/")[0];
141
- if (["workbuddy", "clinebot", "sensenova", "openrouter", "generic"].includes(maybe)) provider = maybe;
141
+ if (["workbuddy", "cline", "sensenova", "openrouter", "generic"].includes(maybe)) provider = maybe;
142
142
  }
143
143
  } catch {}
144
144
  if (TRACE) {
@@ -58,7 +58,7 @@ ${mini}
58
58
  - 需要执行命令时调用 run_command,需要看文件时调用 read_file,需要检查网络/服务可用性时调用 curl。
59
59
  - curl 简写:upstream(=上游 https://opencode.ai/zen/v1/models)、local/health(=本机 /health)、local/models(=本机 /v1/models),也支持完整 http(s) URL;会自动补上游头、本机 token 与已配置供应商 key(直连 https://api.b.ai/v1/models 会自动带 bai 的 key,无需手动加头)。
60
60
  - 查“某供应商有哪些模型”**优先用 CLI 直查**:若上方“可用模型”已能回答,直接前缀过滤回答(如 workbuddy/ 即 workbuddy);需实时拉取时调用 run_command: "-provider <id> models"(如 -provider workbuddy models)或 "-model list --provider <id>",按 allowlist 过滤,--json 供脚本。**禁止**调 -provider <id> list(这是查配置,不是查模型!)。**错误示例**:workbuddy有哪些模型 → 调 -provider workbuddy list → 错。**正确**:-provider workbuddy models。禁止为此调用 -showtoken。
61
- - 严禁幻觉命令:mslxdff "hi" --model X / mslxdff --model X "hi" / mslxdff -chat --model X 都不存在,输出只会是 status 页。探活任意模型(含 clinebot/*、workbuddy/*、bai/*)必须用 curl POST http://localhost:8989/v1/chat/completions,body 为 {"model":"<前缀/模型>","messages":[{"role":"user","content":"hi"}],"stream":false},成功 200 + x-mslxdff-via:local 即通;401 代表本机 token 陈旧需提示 mslxdff -stop && mslxdff;403 + x-mslxdff-allowlist:1 代表白名单未放行需 allowlist add。
61
+ - 严禁幻觉命令:mslxdff "hi" --model X / mslxdff --model X "hi" / mslxdff -chat --model X 都不存在,输出只会是 status 页。探活任意模型(含 cline/*、workbuddy/*、bai/*)必须用 curl POST http://localhost:8989/v1/chat/completions,body 为 {"model":"<前缀/模型>","messages":[{"role":"user","content":"hi"}],"stream":false},成功 200 + x-mslxdff-via:local 即通;401 代表本机 token 陈旧需提示 mslxdff -stop && mslxdff;403 + x-mslxdff-allowlist:1 代表白名单未放行需 allowlist add。
62
62
  - **禁止重复调用(最高优先级)**:同一 run_command/curl/read_file 在本轮只执行一次,重复会被工具侧 SKIPPED_DUP 拦截;查询类(-showtoken/-status/-provider list/-providers list/-model list/-group list/-log 等)**调用一次即答案**,拿到 OK 结果后必须**立即用中文直接回答用户**,禁止再发起任何工具调用。收到 SKIPPED_DUP 或“请直接回答/禁止再调用”提示时,必须 0 工具直接回答。
63
63
  - 禁止调用 -uninstall,包含即拒绝;-showtoken 仅在用户明确要求查看/调试本机 token 时才用,查模型/查供应商严禁调用。
64
64
  - 回复风格:简洁友好,执行前后用中文说明你在做什么。
@@ -96,7 +96,7 @@ export function handleSlash(line, ctx) {
96
96
  const id = arg;
97
97
  if (id.includes("/")) {
98
98
  console.log(`\x1b[31m[拒绝] ${id} 带供应商前缀 — -chat 只支持 opencode 上游模型(裸 id,直连 opencode.ai 免费池)\x1b[0m`);
99
- console.log(`\x1b[90m其他供应商(deepseek/ workbuddy/ clinebot/ 等)请走网关:mslxdff -model set <id> 或 curl 本机 /v1/chat/completions\x1b[0m`);
99
+ console.log(`\x1b[90m其他供应商(deepseek/ workbuddy/ cline/ 等)请走网关:mslxdff -model set <id> 或 curl 本机 /v1/chat/completions\x1b[0m`);
100
100
  return { handled: true };
101
101
  }
102
102
  if (!isFreeModel(id) || !freePoolNow().includes(id)) {
@@ -8,7 +8,7 @@ export async function renderOtherProviders({ pickedIds, ids, fullAliases }) {
8
8
  try { _la2(); } catch {}
9
9
  const configs = loadProviderConfigs();
10
10
  const otherIds = Object.keys(configs).filter((k) => String(k).toLowerCase() !== "opencode");
11
- const order2 = ["workbuddy", "clinebot", "openrouter", "bai"];
11
+ const order2 = ["workbuddy", "cline", "openrouter", "bai"];
12
12
  otherIds.sort((a, b) => {
13
13
  const ia = order2.indexOf(a), ib = order2.indexOf(b);
14
14
  if (ia !== -1 || ib !== -1) {
@@ -57,7 +57,7 @@ export function renderFreeList({ ids, at, pickedIds, sortedProvs, groups, aliasM
57
57
  }
58
58
  }
59
59
 
60
- /** 按供应商分组 + 排序(opencode/workbuddy/clinebot/openrouter 优先) */
60
+ /** 按供应商分组 + 排序(opencode/workbuddy/cline/openrouter 优先) */
61
61
  export function groupByProvider(ids) {
62
62
  const groups = {};
63
63
  for (const id of ids) {
@@ -65,7 +65,7 @@ export function groupByProvider(ids) {
65
65
  if (!groups[prov]) groups[prov] = [];
66
66
  groups[prov].push(id);
67
67
  }
68
- const order = ["opencode", "workbuddy", "clinebot", "openrouter"];
68
+ const order = ["opencode", "workbuddy", "cline", "openrouter"];
69
69
  const sortedProvs = Object.keys(groups).sort((a, b) => {
70
70
  const ia = order.indexOf(a), ib = order.indexOf(b);
71
71
  if (ia !== -1 || ib !== -1) {
@@ -69,7 +69,8 @@ export async function handleProviderAdd(id, sub, rest) {
69
69
  try {
70
70
  const { writeFileSync, mkdirSync } = await import("node:fs");
71
71
  const { join } = await import("node:path");
72
- const dir = process.env.WORKBUDDY_AUTH_DIR || join(process.cwd(), "auths");
72
+ const { resolveAuthDir } = await import("../../providers/workbuddy/account-store.js");
73
+ const dir = resolveAuthDir();
73
74
  mkdirSync(dir, { recursive: true });
74
75
  const fp = join(dir, `workbuddy-${uid}.json`);
75
76
  const doc = { account: { uid, enterpriseId: "", nickname: "" }, auth: { accessToken: token, refreshToken: "", expiresAt: Math.floor(Date.now()/1000)+3600, domain: "www.codebuddy.cn" } };
@@ -120,7 +120,7 @@ export async function handleVia({ providerId, opts, fetchImpl, loadConfigs, load
120
120
  for (const k of Object.keys(raw.providerConfigs || {})) if (!targetIds.includes(k) && (loadAllowed(k) || []).length) targetIds.push(k);
121
121
  for (const k of Object.keys(raw.providerKeys || {})) if (!targetIds.includes(k) && (loadAllowed(k) || []).length) targetIds.push(k);
122
122
  } catch {}
123
- if (!targetIds.length) targetIds = ["openrouter", "workbuddy", "clinebot"].filter((p) => (loadAllowed(p) || []).length);
123
+ if (!targetIds.length) targetIds = ["openrouter", "workbuddy", "cline"].filter((p) => (loadAllowed(p) || []).length);
124
124
  // opencode 默认 allowAny 但 allowlist 常空:有裸 picks 且确认包含 opencode 时纳入
125
125
  if (includeOpencode && !targetIds.includes("opencode") && picks.some((p) => !String(p).includes("/") || String(p).toLowerCase().startsWith("opencode/"))) {
126
126
  targetIds.push("opencode");
@@ -0,0 +1,94 @@
1
+ /**
2
+ * mslxdff -provider cline free [sync] [--yes] [--json] [--keep-extra]
3
+ * 只读:直查上游免费目录(公开接口,无凭据)并与当前 allowlist 求差;只有 `free sync --yes` 才写 allowlist。
4
+ * allowlist 存裸 id(不带 cline/ 前缀),与 normalizeAllowedModel 语义一致;落盘走 saveProviderAllowedModels 保留 keys/baseUrl。
5
+ */
6
+ import { normalizeProviderId } from "../../../providers/model-id.js";
7
+ import { fetchFreeCatalog, FALLBACK_FREE, FREE_CATALOG_PATH } from "../../../providers/cline/free-catalog.js";
8
+
9
+ /** 上游不可达时退回内置兜底目录(标注 source=fallback,供人判断是否该落盘) */
10
+ async function loadCatalog() {
11
+ const cat = await fetchFreeCatalog({});
12
+ if (cat.ok) return { ...cat, source: "upstream" };
13
+ return {
14
+ ...cat,
15
+ source: "fallback",
16
+ models: FALLBACK_FREE.map((m) => ({ ...m })),
17
+ ids: FALLBACK_FREE.map((m) => m.id),
18
+ };
19
+ }
20
+
21
+ function computeNext(current, freeIds, keepExtra) {
22
+ const next = keepExtra ? [...new Set([...current, ...freeIds])] : [...freeIds];
23
+ return {
24
+ next,
25
+ added: next.filter((m) => !current.includes(m)),
26
+ removed: current.filter((m) => !next.includes(m)),
27
+ kept: next.filter((m) => current.includes(m)),
28
+ };
29
+ }
30
+
31
+ function renderList(cat, current, next, plan) {
32
+ console.log(`cline free 目录(GET ${FREE_CATALOG_PATH} → free):${cat.ids.length} 个${cat.source === "fallback" ? "(内置兜底,上游不可达)" : ""}`);
33
+ const w = Math.max(20, ...cat.ids.map((s) => s.length)) + 2;
34
+ for (const m of cat.models) console.log(` + ${String(m.id).padEnd(w)}${m.name && m.name !== m.id ? m.name : ""}`);
35
+ console.log(`当前 allowlist:${current.length} 个`);
36
+ for (const id of current) console.log(` ${next.includes(id) ? "✓" : "-"} ${id}${next.includes(id) ? "" : " (不在 free 目录)"}`);
37
+ console.log(`结果:allowlist ${current.length} → ${next.length}(+${plan.added.length} 新增 / -${plan.removed.length} 移除 / ${plan.kept.length} 保留)`);
38
+ }
39
+
40
+ export async function handleClineFree(id, sub, rest = [], args = []) {
41
+ if (normalizeProviderId(id) !== "cline") return false;
42
+ if (String(sub || "").toLowerCase() !== "free") return false;
43
+ const isSync = String(rest[1] || "").toLowerCase() === "sync";
44
+ const wantsJson = args.includes("--json") || args.includes("-json");
45
+ const yes = args.includes("--yes") || args.includes("-y");
46
+ const keepExtra = args.includes("--keep-extra");
47
+
48
+ const { loadProviderAllowedModels, saveProviderAllowedModels } = await import("../../../state.js");
49
+ const cat = await loadCatalog();
50
+ const current = loadProviderAllowedModels("cline");
51
+ const plan = computeNext(current, cat.ids, keepExtra);
52
+ const dryRun = !(isSync && yes);
53
+ let written = [];
54
+ if (isSync && !dryRun) {
55
+ try {
56
+ written = saveProviderAllowedModels("cline", plan.next);
57
+ } catch (e) {
58
+ console.error(`❌ 写入 allowlist 失败: ${String(e?.message || e)}`);
59
+ process.exit(1);
60
+ }
61
+ }
62
+ const warning = cat.source === "fallback" ? `上游目录不可达(${cat.error || cat.status}),使用内置兜底目录,可能过期` : null;
63
+
64
+ if (wantsJson) {
65
+ console.log(JSON.stringify({
66
+ provider: "cline",
67
+ mode: isSync ? "sync" : "list",
68
+ source: cat.source,
69
+ url: cat.url,
70
+ free: cat.ids,
71
+ current,
72
+ next: plan.next,
73
+ added: plan.added,
74
+ removed: plan.removed,
75
+ kept: plan.kept,
76
+ dryRun,
77
+ written,
78
+ ...(warning ? { warning } : {}),
79
+ }, null, 2));
80
+ process.exit(0);
81
+ }
82
+
83
+ if (warning) console.error(`⚠️ ${warning}`);
84
+ renderList(cat, current, plan.next, plan);
85
+ if (!isSync) {
86
+ console.log(`\n预览(只读)。一键同步:mslxdff -provider cline free sync --yes · 只增不删:加 --keep-extra`);
87
+ } else if (dryRun) {
88
+ console.log(`\n预览模式,未写入。执行:mslxdff -provider cline free sync --yes${keepExtra ? " --keep-extra" : ""}`);
89
+ } else {
90
+ console.log(`\n✅ 已写入 allowlist:${written.length} 个(providerConfigs.cline.allowedModels)`);
91
+ console.log(` 生效:mslxdff -restart(daemon 的 /v1/models 聚合有 10 分钟缓存,重启即刷新)`);
92
+ }
93
+ process.exit(0);
94
+ }
@@ -1,7 +1,8 @@
1
1
  import { compatFetch, getUndici, timeoutSignal } from "../../../compat.js";
2
+ import { normalizeProviderId } from "../../../providers/model-id.js";
2
3
  export async function handleClineLogin(id, sub) {
3
4
  if (sub !== "login" && sub !== "auth" && sub !== "oauth") return false;
4
- if (id !== "cline" && id !== "clinebot" && id !== "cline-bot") return false;
5
+ if (normalizeProviderId(id) !== "cline") return false;
5
6
 
6
7
  const CLIENT_ID = "client_01K3A541FN8TA3EPPHTD2325AR";
7
8
  const WORKOS_DEVICE = "https://api.workos.com/user_management/authorize/device";
@@ -97,7 +98,7 @@ export async function handleClineLogin(id, sub) {
97
98
  }
98
99
  }
99
100
  if (!workos) {
100
- console.error("\n❌ 授权超时,请重新运行 mslxdff -provider clinebot login");
101
+ console.error("\n❌ 授权超时,请重新运行 mslxdff -provider cline login");
101
102
  process.exit(1);
102
103
  }
103
104
  console.log("\n✅ WorkOS 授权成功!\n🔗 用 WorkOS token 在 Cline 注册...");
@@ -117,28 +118,32 @@ export async function handleClineLogin(id, sub) {
117
118
  console.log("=".repeat(60));
118
119
  console.log(`✅ 登录成功! 账号: ${email}`);
119
120
  console.log(`🔑 refreshToken: ${rt.slice(0, 8)}…${rt.slice(-8)} (${rt.length} 字符)`);
120
- // 落盘到 state(同时写 cline 与 clinebot 两个 id,兼容)
121
- const { loadProviderKeys, saveProviderConfig, loadProviderConfig } = await import("../../../state.js");
122
- for (const pid of ["cline", "clinebot"]) {
123
- try {
124
- const cur = loadProviderKeys(pid);
125
- if (cur.includes(rt)) {
126
- console.log(` ℹ️ ${pid} 已存在相同 token,跳过`);
127
- continue;
128
- }
121
+ // 落盘到 state(供应商 id 恒为 cline;写盘前先跑一次性迁移,保证老用户不会再写出 clinebot)
122
+ const { defaultStateFile, loadProviderKeys, saveProviderConfig, loadProviderConfig } = await import("../../../state.js");
123
+ try {
124
+ const { runStateMigrations } = await import("../../../state/migrations.js");
125
+ const mig = await runStateMigrations({ file: defaultStateFile() });
126
+ if (mig.applied.length) console.log(` ℹ️ 已合并遗留供应商配置: ${mig.applied.join(", ")}`);
127
+ } catch {}
128
+ try {
129
+ const pid = "cline";
130
+ const cur = loadProviderKeys(pid);
131
+ if (cur.includes(rt)) {
132
+ console.log(` ℹ️ ${pid} 已存在相同 token,跳过`);
133
+ } else {
129
134
  const cfg = loadProviderConfig(pid) || { baseUrl: "", keys: [] };
130
135
  const nextKeys = [...new Set([...(cfg.keys || cur), rt].filter(Boolean))];
131
136
  saveProviderConfig(pid, { baseUrl: cfg.baseUrl || "https://api.cline.bot", keys: nextKeys });
132
137
  console.log(` ✅ 已写入 ${pid}(现 ${nextKeys.length} 个账号)`);
133
- } catch (e) {
134
- console.log(` ⚠️ 写入 ${pid} 失败: ${e.message}`);
135
138
  }
139
+ } catch (e) {
140
+ console.log(` ⚠️ 写入 cline 失败: ${e.message}`);
136
141
  }
137
142
  console.log("=".repeat(60));
138
143
  console.log("\n下一步:");
139
144
  console.log(" mslxdff -restart 重启网关使新账号生效");
140
- console.log(" mslxdff -provider clinebot bench --json 测速 deepseek 是否 200");
145
+ console.log(" mslxdff -provider cline bench --json 测速 deepseek 是否 200");
141
146
  console.log(" mslxdff -chat 直接对话,模型选 deepseek/deepseek-v4-flash");
142
- console.log("\n多账号:重复 `mslxdff -provider clinebot login` 追加,二号自动做后备");
147
+ console.log("\n多账号:重复 `mslxdff -provider cline login` 追加,二号自动做后备");
143
148
  process.exit(0);
144
149
  }
@@ -1,4 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
+ import { normalizeProviderId } from "../../../providers/model-id.js";
2
3
  import { defaultStateFile } from "../../../state.js";
3
4
 
4
5
  export async function handleProviders(args) {
@@ -74,6 +75,27 @@ export async function handleProvider(args) {
74
75
  if (await handleProviderModels(id, sub, args, rest)) return true;
75
76
  const { handleProviderBench } = await import("./bench.js");
76
77
  if (await handleProviderBench(id, sub, rest, args)) return true;
78
+ const { handleClineFree } = await import("./cline-free.js");
79
+ if (await handleClineFree(id, sub, rest, args)) return true;
80
+ if (normalizeProviderId(id) === "cline" && sub === "migrate") {
81
+ const { runStateMigrations } = await import("../../../state/migrations.js");
82
+ const dryRun = args.includes("--dry-run") || args.includes("--dryrun");
83
+ const r = await runStateMigrations({ dryRun });
84
+ const d = r.details["cline-unify"] || {};
85
+ if (r.errors.length) console.error(`❌ 迁移失败: ${r.errors.map((e) => `${e.id}: ${e.error}`).join("; ")}`);
86
+ if (dryRun) {
87
+ console.log(d.changed
88
+ ? `cline migrate 干跑:将合并遗留 cline id 配置(keys ${d.before.keys} → ${d.after.keys} / allowlist ${d.before.allowedModels} → ${d.after.allowedModels}),未写入`
89
+ : "cline migrate 干跑:无需迁移(未发现 providerConfigs.clinebot / cline-bot)");
90
+ } else if (d.applied) {
91
+ console.log(`✅ cline migrate 完成:keys ${d.before.keys} → ${d.after.keys} / allowlist ${d.before.allowedModels} → ${d.after.allowedModels}`);
92
+ if (d.backup) console.log(` 备份:${d.backup}`);
93
+ console.log(" 生效:mslxdff -restart");
94
+ } else {
95
+ console.log("cline migrate:无需迁移(未发现 providerConfigs.clinebot / cline-bot)");
96
+ }
97
+ process.exit(r.errors.length ? 1 : 0);
98
+ }
77
99
  const { handleProviderKeys } = await import("./keys.js");
78
100
  await handleProviderKeys(id, sub, rest, args);
79
101
  return true;
@@ -1,9 +1,12 @@
1
1
  import { join } from "node:path";
2
2
  import { defaultStateFile } from "../../../state.js";
3
3
  import { logDir } from "../../../logs.js";
4
+ import { normalizeProviderId } from "../../../providers/model-id.js";
4
5
 
5
6
  export async function handleProviderModels(id, sub, args, rest) {
6
7
  if (!(sub === "models" || sub === "show-models" || sub === "list-models" || sub === "ls")) return false;
8
+ // 供应商 id 归一(clinebot/cline-bot → cline):老脚本 `-provider clinebot models` 仍走 cline 直查
9
+ id = normalizeProviderId(id);
7
10
  const { loadProviderConfig, loadProviderKeys, isModelAllowed } = await import("../../../state.js");
8
11
  const wantsJson = args.includes("--json") || args.includes("-json");
9
12
  const cfg = loadProviderConfig(id);
@@ -34,7 +37,7 @@ export async function handleProviderModels(id, sub, args, rest) {
34
37
  let provider;
35
38
  if (id === "workbuddy") {
36
39
  provider = createWorkbuddyProvider({ baseUrl, apiKeys: keys, auths, file: defaultStateFile() });
37
- } else if (id === "cline" || id === "clinebot") {
40
+ } else if (id === "cline") {
38
41
  // 与聚合目录同源:recommended-models 的 free(generic 的 /api/v1/models 是全量目录,含大量不可用条目)
39
42
  const { createClineProvider } = await import("../../../providers/cline.js");
40
43
  provider = createClineProvider({ id, baseUrl: baseUrl || undefined, apiKeys: keys, file: defaultStateFile() });
@@ -1,5 +1,5 @@
1
1
  import { compatFetch, timeoutSignal } from "../../../compat.js";
2
- // mslxdff -provider workbuddy login — WorkBuddy 官方设备授权流(对标 clinebot login)。
2
+ // mslxdff -provider workbuddy login — WorkBuddy 官方设备授权流(对标 cline login)。
3
3
  // 学自 Sliverkiss/workbuddy2api(cmd/login):POST /v2/plugin/auth/state 拿 state+authUrl →
4
4
  // 浏览器登录 → GET /v2/plugin/auth/token?state= 轮询 → GET /v2/plugin/login/account?state= 拿 uid。
5
5
  // 不走 whistle/MITM,不依赖 codebuddy CLI,自然支持多账号追加。
@@ -126,12 +126,15 @@ export async function handleWorkbuddy(args) {
126
126
  auths.splice(rmIdx,1); keys.splice(rmIdx,1);
127
127
  saveProviderConfig("workbuddy", { baseUrl: cfg.baseUrl||"https://copilot.tencent.com", keys, auths });
128
128
  if (!keep) {
129
+ // 旧实现只删 cwd/auths 那一份,留下的旧副本会被读取兜底"复活"账号 → 扫所有候选目录
129
130
  try {
130
131
  const { existsSync, unlinkSync } = await import("node:fs");
131
132
  const { join } = await import("node:path");
132
- const dir = process.env.WORKBUDDY_AUTH_DIR || join(process.cwd(), "auths");
133
- const fp = join(dir, `workbuddy-${uid}.json`);
134
- if (existsSync(fp)) { unlinkSync(fp); console.log(`removed file ${fp}`); }
133
+ const { resolveAuthDirs } = await import("../../providers/workbuddy/account-store.js");
134
+ for (const dir of resolveAuthDirs()) {
135
+ const fp = join(dir, `workbuddy-${uid}.json`);
136
+ if (existsSync(fp)) { unlinkSync(fp); console.log(`removed file ${fp}`); }
137
+ }
135
138
  } catch {}
136
139
  }
137
140
  try { const { getBalanceCache } = await import("../../providers/workbuddy-balance.js"); getBalanceCache().delete(uid); } catch {}
@@ -155,24 +158,14 @@ async function loadWorkbuddyAccounts() {
155
158
  })).filter((it) => it.uid && it.key);
156
159
  if (items.length) return items;
157
160
  try {
158
- const { readdirSync, readFileSync, existsSync } = await import("node:fs");
159
- const { join } = await import("node:path");
160
- const { resolveAuthDir } = await import("../../providers/workbuddy/account-store.js");
161
- const dir = resolveAuthDir();
162
- if (existsSync(dir)) {
163
- for (const f of readdirSync(dir).filter((x) => x.startsWith("workbuddy-") && x.endsWith(".json"))) {
164
- try {
165
- const j = JSON.parse(readFileSync(join(dir, f), "utf8"));
166
- if (j?.account?.uid && j?.auth?.accessToken) {
167
- const domain = j.auth.domain || "www.codebuddy.cn";
168
- items.push({
169
- uid: j.account.uid, domain, enterpriseId: j.account.enterpriseId || "",
170
- key: j.auth.accessToken,
171
- auth: { uid: j.account.uid, domain, enterpriseId: j.account.enterpriseId || "", refreshToken: j.auth.refreshToken || "" },
172
- });
173
- }
174
- } catch {}
175
- }
161
+ const { listAccountDocs } = await import("../../providers/workbuddy/account-store.js");
162
+ for (const { uid, doc } of listAccountDocs()) {
163
+ const domain = doc.auth.domain || "www.codebuddy.cn";
164
+ items.push({
165
+ uid, domain, enterpriseId: doc.account.enterpriseId || "",
166
+ key: doc.auth.accessToken,
167
+ auth: { uid, domain, enterpriseId: doc.account.enterpriseId || "", refreshToken: doc.auth.refreshToken || "" },
168
+ });
176
169
  }
177
170
  } catch {}
178
171
  return items;
package/src/cli/index.js CHANGED
@@ -1,12 +1,15 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join, dirname } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { assertMinNode } from "../compat.js";
4
5
 
5
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
7
  const VERSION = JSON.parse(readFileSync(join(__dirname, "..", "..", "package.json"), "utf8")).version;
7
8
 
8
9
  // deep CLI entry — single inlet: run(args) — hides all command branching inside
9
10
  export async function run(args = process.argv.slice(2)) {
11
+ // 运行环境门(强制):项目要求 Node >=18(ADR-0024),不满足则人话退出
12
+ if (!assertMinNode()) process.exit(1);
10
13
  const { handleHelp, handleUpdate, handleRefreshToken, handleShowToken, handleUninstall, handleLog, handlePlugins, handleChat, handleStatus, handleFree, handleAutostart } = await import("./commands/system.js");
11
14
  if (await handleHelp(args, VERSION)) return;
12
15
  if (await handleUpdate(args, VERSION)) return;
package/src/compat.js CHANGED
@@ -1,8 +1,8 @@
1
- // 老 Node 兼容层(engines >=16):Node 16 缺 globalThis.fetch(18+)、
2
- // AbortSignal.timeout(17.3+)、structuredClone(17+);裸全局 crypto 是
3
- // WebCrypto,其 randomUUID 要 19+,只有 node:crypto 的 14.17+ 可用。
4
- // undici 8 需 Node 22+,依赖已锁 5.x(老 Node 可跑,API 兼容我们用到的面)。
5
- // 统一从这里取,源码里勿直接用这些全局。
1
+ // 运行时兼容层(项目要求 Node >=18,见 ADR-0024;源码里勿直接用裸 fetch/globalThis.fetch)。
2
+ // 仍保留本层的原因:① undici 是显式依赖(Agent 连接池/keep-alive 要用),
3
+ // 各家 fetch 实现统一从这里取,避免 globalThis.fetch 与 undici.fetch 混用;
4
+ // ② AbortSignal.timeout/structuredClone 在 18+ 已原生,这里的兜底只为可读报错,
5
+ // 不再承诺 <18 可用(<18 请走 assertMinNode 的人话提示)。
6
6
  import { randomUUID } from "node:crypto";
7
7
 
8
8
  let _undici = null;
@@ -33,3 +33,21 @@ export function clone(v) {
33
33
  }
34
34
 
35
35
  export const uuid = () => randomUUID();
36
+
37
+ // ---- 运行环境门(强制):项目要求 Node >=18(ADR-0024)----
38
+ // Node <18 缺 Response/Headers/ReadableStream/TransformStream 等 Web 全局,
39
+ // 上游适配层有 29 处裸用,跑起来必 ReferenceError;与其半途炸掉,不如入口给人话。
40
+ export const MIN_NODE_MAJOR = 18;
41
+
42
+ export function nodeMajor() {
43
+ return Number(String(process.versions?.node || "0").split(".")[0]) || 0;
44
+ }
45
+
46
+ export function assertMinNode({ min = MIN_NODE_MAJOR, label = "mslxdff" } = {}) {
47
+ const major = nodeMajor();
48
+ if (major >= min) return true;
49
+ console.error(`[运行环境不满足] ${label} 需要 Node ${min}+,当前 v${process.versions.node}。`);
50
+ console.error("原因:Node <18 没有 Response/Headers/ReadableStream 等全局对象,上游转发必然失败。");
51
+ console.error("升级方式:nvm install 20 && nvm use 20,或到 https://nodejs.org/ 装 LTS(推荐 20/22)。");
52
+ return false;
53
+ }
@@ -4,7 +4,7 @@
4
4
  // 全程 best-effort:目录不可用 → 原样返回,绝不让 /models 因富化失败挂掉。
5
5
  import { globalCapabilities } from "./index.js";
6
6
 
7
- // "clinebot/deepseek/deepseek-v4-flash" → { provider: "clinebot", raw: "deepseek/deepseek-v4-flash" };裸 id 归 opencode
7
+ // "cline/deepseek/deepseek-v4-flash" → { provider: "cline", raw: "deepseek/deepseek-v4-flash" };裸 id 归 opencode
8
8
  export function splitModelId(id) {
9
9
  const s = String(id || "").trim();
10
10
  const i = s.indexOf("/");
@@ -55,7 +55,7 @@ export function capsPayloadFor(caps, raw) {
55
55
  return payload;
56
56
  }
57
57
 
58
- // 单条 id 的目录匹配:精确裸 id → 剥 -free 后缀 → 二级厂商前缀(clinebot/deepseek/x → deepseek/x)
58
+ // 单条 id 的目录匹配:精确裸 id → 剥 -free 后缀 → 二级厂商前缀(cline/deepseek/x → deepseek/x)
59
59
  function lookupCaps(svc, provider, raw) {
60
60
  return (
61
61
  svc.get(provider, raw) ||
@@ -4,7 +4,7 @@
4
4
  // latency-compare:key/token 类,direct vs link+remote 比延迟
5
5
  export function classifyProvider(id) {
6
6
  const s = String(id || "").trim().toLowerCase();
7
- if (s === "workbuddy" || s === "cline" || s === "clinebot") return "local-only";
7
+ if (s === "workbuddy" || s === "cline") return "local-only";
8
8
  if (s === "opencode") return "quota-pool";
9
9
  return "latency-compare";
10
10
  }
@@ -120,7 +120,7 @@ export function createAuthPool({
120
120
  if (account.dead) throw new Error("invalid_grant");
121
121
  if (account.cooldownUntil > now) throw new Error("account_cooldown");
122
122
  if (account.accessToken && now < account.expiry) return account.accessToken;
123
- // base 可能已含 /api/v1(state 里 clinebot baseUrl 就是 …/api/v1),直接拼会 double 成
123
+ // base 可能已含 /api/v1(老 clinebot 配置就是 …/api/v1),直接拼会 double 成
124
124
  // …/api/v1/api/v1/auth/refresh → 上游回 401 Unauthorized(版本/重认证提示),绝不能当 token 死。
125
125
  const baseNoV1 = resolvedBase.replace(/\/api\/v1\/?$/, "");
126
126
  const url = joinUrl(baseNoV1, "/api/v1/auth/refresh");
@@ -1,20 +1,20 @@
1
1
  import { joinUrl, sleep } from "../base.js";
2
2
  import { clineHeaders } from "./headers.js";
3
3
  import { createTransport } from "../../transport/index.js";
4
+ import { normalizeProviderId } from "../model-id.js";
4
5
  import { createSdkDispatch } from "../../upstream-engine/sdk/dispatch.js";
5
6
 
6
7
  function genSessionId() { return `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; }
7
8
 
8
- // stripProviderPrefix 去掉本地供应商前缀,发上游用裸模型 id。
9
- // 上游(对标 cline2api-workers MODELS)只认裸 id:"deepseek/deepseek-v4-flash"、
10
- // "poolside/laguna-s-2.1:free";本地 "clinebot/deepseek/..." 需取后两段。
11
- // 两段及以内视为已是裸 id,原样透传。
12
- function stripProviderPrefix(id) {
13
- const parts = String(id || "").split("/").filter(Boolean);
14
- if (parts.length > 2) return parts.slice(1).join("/");
15
- return String(id || "");
9
+ // stripProviderPrefix 只剥「本供应商」前缀(cline/<裸 id> → 裸 id),非本前缀(含上游自带多段 id
10
+ // 如 meta/vendor/x)原样透传,不误削首段(旧实现按段数剥曾是潜在 bug)。
11
+ function stripProviderPrefix(model, providerId) {
12
+ const s = String(model || "");
13
+ const i = s.indexOf("/");
14
+ if (i <= 0) return s;
15
+ const low = (v) => normalizeProviderId(String(v || "").toLowerCase());
16
+ return low(s.slice(0, i)) === low(providerId) ? s.slice(i + 1) : s;
16
17
  }
17
-
18
18
  function unwrapData(obj) {
19
19
  if (obj && obj.data && typeof obj.data === "object") {
20
20
  const d = obj.data;
@@ -169,7 +169,7 @@ export function createChatService({
169
169
  const model = body?.model || "deepseek/deepseek-v4-flash";
170
170
  const sessionId = genSessionId();
171
171
  const isStream = body?.stream === true;
172
- const upstreamModel = stripProviderPrefix(model);
172
+ const upstreamModel = stripProviderPrefix(model, id);
173
173
  // token 口径双写:对标官方 withMaxCompletionTokensForReasoningModels——
174
174
  // cline 上游默认 reasoning_effort high,推理模型认 max_completion_tokens,
175
175
  // 只发 max_tokens 会被部分通道拒;双写兼容最稳。
@@ -182,7 +182,10 @@ export function createChatService({
182
182
  reasoning_effort: body?.reasoning_effort || body?.reasoningEffort || "high",
183
183
  messages: body?.messages || [],
184
184
  };
185
- const forceStream = !isStream && String(upstreamModel).startsWith("deepseek/");
185
+ // 部分免费通道(deepseek 家族,含 cline-free/deepseek-*)原生非流式不可靠
186
+ //(500 empty response):这类模型在内部走 stream+聚合成 JSON,对外仍按请求方
187
+ // stream 标志返回(true 直透,false 聚合)。其它模型完全尊重请求方,不强制。
188
+ const forceStream = !isStream && String(upstreamModel).toLowerCase().includes("deepseek");
186
189
  if (isStream || forceStream) upstreamBody.stream = true;
187
190
  for (const k of ["temperature", "top_p", "tools", "tool_choice", "stop", "presence_penalty", "frequency_penalty", "response_format", "user", "n", "seed"]) {
188
191
  if (body[k] !== undefined) upstreamBody[k] = body[k];
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Cline 免费目录深模块:只认上游 `free` 数组(公开接口,无需鉴权、无凭据)。
3
+ * GET {bareHost}/api/v1/ai/cline/recommended-models → { recommended, free, clinePass, clineCloud }
4
+ * 供 cline/models.js(聚合目录 + 启动自检)与 `-provider cline free` CLI 共用;缓存由调用方持有。
5
+ */
6
+ import { compatFetch } from "../../compat.js";
7
+ import { joinUrl } from "../base.js";
8
+
9
+ export const FREE_CATALOG_PATH = "/api/v1/ai/cline/recommended-models";
10
+ export const DEFAULT_CLINE_BASE = "https://api.cline.bot";
11
+
12
+ // 上游不可达时的内置兜底(2026-09-20 实测 5 个;只有 id 与短名,无凭据)
13
+ export const FALLBACK_FREE = Object.freeze([
14
+ { id: "cline-free/deepseek-v4.1-flash", name: "deepseek-v4.1-flash" },
15
+ { id: "cline-free/muse-spark-1.3-contributor", name: "muse-spark-1.3-contributor" },
16
+ { id: "z-ai/glm-5.3-flash", name: "glm-5.3-flash" },
17
+ { id: "cline-free/solar-pro4", name: "solar-pro4" },
18
+ { id: "poolside/laguna-s-2.1:free", name: "laguna-s-2.1:free" },
19
+ ]);
20
+ export const FALLBACK_FREE_IDS = FALLBACK_FREE.map((m) => m.id);
21
+
22
+ /** 目录 URL 归一:baseUrl 可能是裸 host 或带 /api/v1 的老 clinebot 形态;customPath 尊重用户自定义端点 */
23
+ export function catalogUrl(baseUrl, customPath) {
24
+ const raw = String(baseUrl || "").trim() || DEFAULT_CLINE_BASE;
25
+ const bare = raw.replace(/\/+$/, "").replace(/\/api\/v1\/?$/, "") || DEFAULT_CLINE_BASE;
26
+ const p = String(customPath || "").trim();
27
+ if (p && p !== "/models" && p !== FREE_CATALOG_PATH) return joinUrl(bare, p);
28
+ return joinUrl(bare, FREE_CATALOG_PATH);
29
+ }
30
+
31
+ /** 只取 json.free(对象 {id,name,...} 或裸字符串两种形态都容忍) */
32
+ function pickFree(json) {
33
+ const arr = Array.isArray(json?.free) ? json.free : [];
34
+ const out = [];
35
+ for (const m of arr) {
36
+ if (typeof m === "string" && m.trim()) out.push({ id: m.trim() });
37
+ else if (m && typeof m.id === "string" && m.id.trim()) out.push({ ...m, id: m.id.trim(), name: String(m.name || "") });
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /**
43
+ * 拉免费目录。
44
+ * @returns {Promise<{ok:boolean, status:number, url:string, models:object[], ids:string[], error?:string}>}
45
+ * ok=false 时 models/ids 为空(兜底由调用方决定);authorization 供私有/自定义目录可选透传。
46
+ */
47
+ export async function fetchFreeCatalog({ baseUrl, customPath, fetchImpl, dispatcher, authorization, timeoutMs = 15_000 } = {}) {
48
+ const url = catalogUrl(baseUrl, customPath);
49
+ const impl = fetchImpl || compatFetch;
50
+ const headers = { Accept: "application/json" };
51
+ if (authorization) headers.Authorization = authorization;
52
+ const opts = { headers };
53
+ if (dispatcher) opts.dispatcher = dispatcher;
54
+ const controller = new AbortController();
55
+ const timer = setTimeout(() => controller.abort(new Error("cline free catalog timed out")), timeoutMs);
56
+ timer.unref?.();
57
+ opts.signal = controller.signal;
58
+ try {
59
+ const res = await impl(url, opts);
60
+ const status = Number(res?.status) || 0;
61
+ if (!res?.ok) return { ok: false, status, url, models: [], ids: [], error: `HTTP ${status}` };
62
+ const json = await res.json().catch(() => null);
63
+ const models = pickFree(json);
64
+ if (!models.length) return { ok: false, status, url, models: [], ids: [], error: "empty free list" };
65
+ return { ok: true, status, url, models, ids: models.map((m) => m.id) };
66
+ } catch (err) {
67
+ return { ok: false, status: 0, url, models: [], ids: [], error: String(err?.message || err) };
68
+ } finally {
69
+ clearTimeout(timer);
70
+ }
71
+ }