@luchenglong/cli-bridge 0.2.13 → 0.2.14
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.
- package/CHANGELOG.md +40 -0
- package/PROVIDER-CONTRACT.md +31 -0
- package/mcp/cli-bridge-mcp.mjs +1 -1
- package/mcp/providers/agy.mjs +10 -4
- package/mcp/providers/claude.mjs +10 -4
- package/mcp/providers/codebuddy.mjs +7 -1
- package/mcp/providers/codex.mjs +10 -4
- package/mcp/providers/mimo.mjs +10 -4
- package/mcp/providers/opencode.mjs +10 -4
- package/mcp/session-cwd.mjs +123 -0
- package/package.json +1 -1
- package/scripts/register-global-mcp.mjs +189 -0
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,46 @@ GitHub Release(`scripts/verify.mjs` 在 CI 与 prepack 双重锁一致)。
|
|
|
9
9
|
> - CodeBuddy 线:[docs/history/codebuddy/CHANGELOG.md](docs/history/codebuddy/CHANGELOG.md)(终版 1.6.2)
|
|
10
10
|
> - MiMo 线:[docs/history/mimo/CHANGELOG.md](docs/history/mimo/CHANGELOG.md)(终版 1.2.9)
|
|
11
11
|
|
|
12
|
+
## [0.2.14] - 2026-10-23
|
|
13
|
+
|
|
14
|
+
> 用户反馈:「agent 委派任务时 agy/opencode/claude/codex 部分 CLI 报错:无权编辑工作区外的内容」。
|
|
15
|
+
> 根因不是 CLI 的沙箱太严,而是**工作区边界一开始就定错了**。
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- **MCP 形态下所有 provider 的工作目录都可能是错的**(这是本次报错的根因)。
|
|
19
|
+
链路:`@deepseek-ai/dsh-mcp-client` 启动 stdio server 时 `config.cwd` 默认是**空字符串**
|
|
20
|
+
(源码里 `cwd: z.string().default("")`),于是 MCP 进程的 `process.cwd()` 是 **DSH 主进程
|
|
21
|
+
的目录**(dsh-home / 安装目录),而不是用户的会话目录。而各 provider 的 cwd 兜底恰好是
|
|
22
|
+
`process.env.<ID>_MCP_CWD || process.cwd()` —— 于是 CLI 认为"工作区"是那个无关目录,
|
|
23
|
+
用户项目里的一切都成了"工作区外":
|
|
24
|
+
- **codex**:`-s workspace-write` 直接拒绝写入
|
|
25
|
+
- **claude / opencode / agy**:按各自的 project 边界拒绝
|
|
26
|
+
实测复现(同一组任务,cwd 正确前的表现):agy 能写区外、codex/claude/opencode 失败。
|
|
27
|
+
|
|
28
|
+
修复:新增 `mcp/session-cwd.mjs`,用 DSH 提供给子进程的 `DSH_SESSION_ID` 从 projcache 的
|
|
29
|
+
`record.identity.cwd` 读出**会话真实目录**,把兜底链补成:
|
|
30
|
+
`args.cwd`(调用方显式传)→ `<ID>_MCP_CWD`(用户 env)→ **会话真实目录** → `process.cwd()`。
|
|
31
|
+
六家全部接入(agy / codebuddy / mimo / opencode / claude / codex)。
|
|
32
|
+
读取全程容错:任何失败都返回空串继续往下兜底,绝不打断工具调用;结果按会话 id 缓存 60s。
|
|
33
|
+
|
|
34
|
+
验证(`tests/e2e/session-cwd-fix.mjs`):故意把 MCP 进程 cwd 设成一个无关目录、会话目录
|
|
35
|
+
设为 `my-project`,调用时不传 cwd —— 状态文件记录的 cwd 是 `my-project`,即用的是会话目录
|
|
36
|
+
而非进程目录。
|
|
37
|
+
|
|
38
|
+
### 说明(不是缺陷,但会继续表现为「无权编辑」)
|
|
39
|
+
- **codex 的 `-s workspace-write` 只能写 cwd 之内**,这是它自身的安全设计。cwd 修好后,
|
|
40
|
+
常规"在项目内改文件"已经正常;要**跨项目/写工作区外**仍会被拒,需要显式放宽:
|
|
41
|
+
- 传 `sandboxMode: 'danger-full-access'`(或在设置面板改「沙箱模式」)
|
|
42
|
+
- 或传 `addDirs: [...]` 追加可写目录(claude/agy 走 `--add-dir`;codex exec 亦支持)
|
|
43
|
+
- claude / agy 用 `--dangerously-skip-permissions`,opencode 用 `--auto`,本就没有沙箱限制;
|
|
44
|
+
它们此前的失败来自 cwd 错位(本次修复),以及排查过程中观察到的**免费额度限流**
|
|
45
|
+
(`429 free-models-per-day-high-balance`)与 cc-switch 代理未启动(见下)。
|
|
46
|
+
|
|
47
|
+
### 排查环境记录(避免误判为插件缺陷)
|
|
48
|
+
- 复现过程中 codex / opencode 返回 `429 Rate limit exceeded: free-models-per-day-high-balance`,
|
|
49
|
+
claude 返回 `API Error: Connection error` —— 后者根因是 **cc-switch 本地代理 15721 未运行**
|
|
50
|
+
(`ECONNREFUSED`),并非插件问题。claude 依赖该代理补 session 头(见 0.2.11)。
|
|
51
|
+
|
|
12
52
|
## [0.2.13] - 2026-10-20
|
|
13
53
|
|
|
14
54
|
> 用户反馈「通道可能通了,但是我没有看到状态灯」。查证后:**灯没坏,是显示条件没被满足**
|
package/PROVIDER-CONTRACT.md
CHANGED
|
@@ -212,6 +212,37 @@ export function check(env) → true | throw // --che
|
|
|
212
212
|
`{homeDir}/cli-bridge-mcp-<id>.json`**(用 bridge-kit 的 buildMcpBridgePayload + 原子写
|
|
213
213
|
`tmp→rename`)。允许 `import node:*`(MCP 是真 Node 进程)。
|
|
214
214
|
|
|
215
|
+
### 4.1 cwd 解析(**必读**,0.2.14 事故)
|
|
216
|
+
|
|
217
|
+
MCP 形态**没有** `getCwdFallback`(那是 preset/dynamic 才有的 hostOpts)。cwd 兜底链必须走
|
|
218
|
+
`mcp/session-cwd.mjs`:
|
|
219
|
+
|
|
220
|
+
```
|
|
221
|
+
args.cwd(调用方显式传,最可靠)
|
|
222
|
+
→ env <ID>_MCP_CWD(用户覆盖)
|
|
223
|
+
→ resolveSessionCwd(env) ← 会话真实目录,经 DSH_SESSION_ID 从 projcache 读
|
|
224
|
+
→ process.cwd() ← 最后兜底
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
**为什么不能用 `|| process.cwd()` 直接兜底**(这正是 0.2.14 修的 bug):
|
|
228
|
+
`@deepseek-ai/dsh-mcp-client` 启动 stdio server 时 `config.cwd` 默认是**空字符串**
|
|
229
|
+
(源码 `cwd: z.string().default("")`),所以 MCP 进程的 `process.cwd()` 是 **DSH 主进程的
|
|
230
|
+
目录**(dsh-home / 安装目录),不是用户会话目录。CLI 据此把整个用户项目判成"工作区外":
|
|
231
|
+
|
|
232
|
+
- codex `-s workspace-write` → 直接拒绝写入
|
|
233
|
+
- claude / opencode / agy → 按各自 project 边界拒绝
|
|
234
|
+
|
|
235
|
+
DSH 会给子进程 `DSH_SESSION_ID`(实测存在),但**不给**会话目录的环境变量 —— 所以必须自己
|
|
236
|
+
从 `{DSH_HOME}/storages/session_projcache/sessions/<sessionId>.json` 的
|
|
237
|
+
`record.identity.cwd` 读。读取全程容错(失败返回空串继续兜底),结果按会话 id 缓存 60s。
|
|
238
|
+
|
|
239
|
+
回归防线:`tests/keystone/session-cwd.test.mjs` 会检查每个 provider 都 import 了
|
|
240
|
+
`session-cwd.mjs`,且**不再出现裸 `|| process.cwd()` 兜底**;
|
|
241
|
+
`tests/e2e/session-cwd-fix.mjs` 在"MCP 进程 cwd 故意设错"的条件下验证实际用的是会话目录。
|
|
242
|
+
|
|
243
|
+
注意:cwd 修好只代表"工作区对了"。codex 的 `workspace-write` 仍不允许写 cwd 之外 ——
|
|
244
|
+
那是它的安全设计,跨项目任务需显式传 `sandboxMode: 'danger-full-access'` 或 `addDirs`。
|
|
245
|
+
|
|
215
246
|
## 5. 交付文件清单(每个 provider 完全一致,路径冻结)
|
|
216
247
|
|
|
217
248
|
| 文件 | 形态 | 说明 |
|
package/mcp/cli-bridge-mcp.mjs
CHANGED
|
@@ -33,7 +33,7 @@ import * as claudeMcp from './providers/claude.mjs'
|
|
|
33
33
|
import * as codexMcp from './providers/codex.mjs'
|
|
34
34
|
|
|
35
35
|
const SERVER_NAME = 'cli-bridge-mcp'
|
|
36
|
-
const SERVER_VERSION = '0.2.
|
|
36
|
+
const SERVER_VERSION = '0.2.14'
|
|
37
37
|
const PROTOCOL = '2024-11-05'
|
|
38
38
|
|
|
39
39
|
const PROVIDERS = [agyMcp, codebuddyMcp, mimoMcp, opencodeMcp, claudeMcp, codexMcp]
|
package/mcp/providers/agy.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// - 无 ctx.emit:状态走快照文件 {homeDir}/cli-bridge-mcp-agy.json
|
|
13
13
|
// (bridge-kit buildMcpBridgePayload + 原子写 tmp→rename;写失败静默)。
|
|
14
14
|
import { spawn as nodeSpawn } from 'node:child_process'
|
|
15
|
+
import { resolveCwdWithSession } from '../session-cwd.mjs'
|
|
15
16
|
import { existsSync } from 'node:fs'
|
|
16
17
|
import { resolve as resolvePath } from 'node:path'
|
|
17
18
|
import { fileURLToPath } from 'node:url'
|
|
@@ -57,10 +58,15 @@ function exePrefix() {
|
|
|
57
58
|
}
|
|
58
59
|
return [process.platform === 'win32' ? 'agy.exe' : 'agy']
|
|
59
60
|
}
|
|
60
|
-
function resolveCwd(a) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
function resolveCwd(a) {
|
|
62
|
+
if (a && a.cwd) return resolvePath(String(a.cwd))
|
|
63
|
+
// 兜底链:用户 env 覆盖 → **会话真实目录** → process.cwd()。末两级很关键:
|
|
64
|
+
// MCP server 由 DSH 以空 cwd 启动,process.cwd() 是 DSH 主进程目录而非用户项目,
|
|
65
|
+
// 直接用会让 CLI 把整个项目判成「工作区外」(agy/claude/opencode/codex 都踩过)。
|
|
66
|
+
const envCwd = process.env.AGY_MCP_CWD
|
|
67
|
+
if (envCwd) return resolvePath(String(envCwd))
|
|
68
|
+
return resolveCwdWithSession(null, '', resolvePath)
|
|
69
|
+
}
|
|
64
70
|
|
|
65
71
|
// ── spawn(旧 runAgy verbatim 语义;实时折叠走 bridge-kit 行流)────────────────
|
|
66
72
|
function runAgy(args) {
|
package/mcp/providers/claude.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// 工具名/schema 从描述符取(单一来源,防两处漂移),描述与 background 语义按 MCP 面重写:
|
|
10
10
|
// MCP 没有 DSH jobs,background 参数在该形态无意义,故 schema 里裁掉。
|
|
11
11
|
import { spawn as nodeSpawn } from 'node:child_process'
|
|
12
|
+
import { resolveCwdWithSession } from '../session-cwd.mjs'
|
|
12
13
|
import { readFileSync } from 'node:fs'
|
|
13
14
|
import { resolve as resolvePath, dirname } from 'node:path'
|
|
14
15
|
|
|
@@ -67,10 +68,15 @@ const engine = createStatusEngine({ provider, publish: publishSnapshot })
|
|
|
67
68
|
provider.engine = engine
|
|
68
69
|
|
|
69
70
|
function exePrefix() { return resolveExePrefix(process.env, '') }
|
|
70
|
-
function resolveCwd(a) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
function resolveCwd(a) {
|
|
72
|
+
if (a && a.cwd) return resolvePath(String(a.cwd))
|
|
73
|
+
// 兜底链:用户 env 覆盖 → **会话真实目录** → process.cwd()。末两级很关键:
|
|
74
|
+
// MCP server 由 DSH 以空 cwd 启动,process.cwd() 是 DSH 主进程目录而非用户项目,
|
|
75
|
+
// 直接用会让 CLI 把整个项目判成「工作区外」(agy/claude/opencode/codex 都踩过)。
|
|
76
|
+
const envCwd = process.env.CLAUDE_MCP_CWD
|
|
77
|
+
if (envCwd) return resolvePath(String(envCwd))
|
|
78
|
+
return resolveCwdWithSession(null, '', resolvePath)
|
|
79
|
+
}
|
|
74
80
|
|
|
75
81
|
// ── spawn(实时折叠走 bridge-kit 行流;.cmd 走 shell)─────────────────────────
|
|
76
82
|
function runClaude(args) {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// - 设置:统一文件 cli-bridge-settings.json 的 codebuddy 分区(5s 缓存),旧扁平
|
|
20
20
|
// 文件的字段级迁移在 DSH 面板侧由 keystone 完成;这里读不到即用默认值。
|
|
21
21
|
import { spawn as nodeSpawn } from 'node:child_process'
|
|
22
|
+
import { resolveSessionCwd } from '../session-cwd.mjs'
|
|
22
23
|
import { existsSync, readFileSync } from 'node:fs'
|
|
23
24
|
import { resolve as resolvePath, join as nodePathJoin } from 'node:path'
|
|
24
25
|
|
|
@@ -127,7 +128,12 @@ function cwdBlockedReason(cwd, io) {
|
|
|
127
128
|
function runCodebuddy(args, io) {
|
|
128
129
|
return new Promise((resolve) => {
|
|
129
130
|
const settings = readSettings(io)
|
|
130
|
-
|
|
131
|
+
// cwd 兜底链:用户 env 覆盖 → **会话真实目录** → process.cwd()。
|
|
132
|
+
// 末两级很关键:MCP server 由 DSH 以空 cwd 启动,process.cwd() 是 DSH 主进程目录,
|
|
133
|
+
// 而非用户项目;直接用会让 CLI 把整个项目判成「工作区外」(或撞 cwd 白名单)。
|
|
134
|
+
const cwdFallback = (io.env || {}).CODEBUDDY_MCP_CWD
|
|
135
|
+
|| resolveSessionCwd(io.env)
|
|
136
|
+
|| process.cwd()
|
|
131
137
|
const target = engine.resolveTarget(args, cwdFallback)
|
|
132
138
|
const backend = provider.resolveBackend(args, target, settings.preferredBackend)
|
|
133
139
|
// 端点/凭据:端点按登录域自动对齐(修 npm 面 401);凭据仅 codebuddy-en 需要
|
package/mcp/providers/codex.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// • 模型清单没有 CLI 子命令,只能走 HTTP /models(descriptor 的 codex_models 负责),
|
|
12
12
|
// 这里只做工具面转接,不复制解析逻辑。
|
|
13
13
|
import { spawn as nodeSpawn } from 'node:child_process'
|
|
14
|
+
import { resolveCwdWithSession } from '../session-cwd.mjs'
|
|
14
15
|
import { resolve as resolvePath } from 'node:path'
|
|
15
16
|
import { readFileSync } from 'node:fs'
|
|
16
17
|
import { dirname } from 'node:path'
|
|
@@ -78,10 +79,15 @@ function exePrefix() {
|
|
|
78
79
|
return codexResolveExePrefix(process.env, '')
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
function resolveCwd(a) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
function resolveCwd(a) {
|
|
83
|
+
if (a && a.cwd) return resolvePath(String(a.cwd))
|
|
84
|
+
// 兜底链:用户 env 覆盖 → **会话真实目录** → process.cwd()。末两级很关键:
|
|
85
|
+
// MCP server 由 DSH 以空 cwd 启动,process.cwd() 是 DSH 主进程目录而非用户项目,
|
|
86
|
+
// 直接用会让 CLI 把整个项目判成「工作区外」(agy/claude/opencode/codex 都踩过)。
|
|
87
|
+
const envCwd = process.env.CODEX_BRIDGE_CWD
|
|
88
|
+
if (envCwd) return resolvePath(String(envCwd))
|
|
89
|
+
return resolveCwdWithSession(null, '', resolvePath)
|
|
90
|
+
}
|
|
85
91
|
|
|
86
92
|
function baseResult(over) {
|
|
87
93
|
return Object.assign({
|
package/mcp/providers/mimo.mjs
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
// 登录由用户在 CLI 侧 `mimo auth login` 自理。
|
|
22
22
|
|
|
23
23
|
import { spawn as nodeSpawn } from 'node:child_process'
|
|
24
|
+
import { resolveCwdWithSession } from '../session-cwd.mjs'
|
|
24
25
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs'
|
|
25
26
|
import { dirname, join as joinPath, resolve as resolvePath } from 'node:path'
|
|
26
27
|
|
|
@@ -90,10 +91,15 @@ provider.engine = engine
|
|
|
90
91
|
function exePrefix() {
|
|
91
92
|
return resolveMimoExePrefix(process.env)
|
|
92
93
|
}
|
|
93
|
-
function resolveCwd(a) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
function resolveCwd(a) {
|
|
95
|
+
if (a && a.cwd) return resolvePath(String(a.cwd))
|
|
96
|
+
// 兜底链:用户 env 覆盖 → **会话真实目录** → process.cwd()。末两级很关键:
|
|
97
|
+
// MCP server 由 DSH 以空 cwd 启动,process.cwd() 是 DSH 主进程目录而非用户项目,
|
|
98
|
+
// 直接用会让 CLI 把整个项目判成「工作区外」(agy/claude/opencode/codex 都踩过)。
|
|
99
|
+
const envCwd = process.env.MIMO_MCP_CWD || process.env.MIMO_BRIDGE_CWD
|
|
100
|
+
if (envCwd) return resolvePath(String(envCwd))
|
|
101
|
+
return resolveCwdWithSession(null, '', resolvePath)
|
|
102
|
+
}
|
|
97
103
|
|
|
98
104
|
// ── spawn(旧 runMimo verbatim 语义;实时折叠走 bridge-kit 行流)────────────────
|
|
99
105
|
function runMimo(args) {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// opencode 无 quota/额度门禁(凭据与额度由 opencode 自身与 provider 决定),
|
|
10
10
|
// 因此本面只有三件套。
|
|
11
11
|
import { spawn as nodeSpawn } from 'node:child_process'
|
|
12
|
+
import { resolveCwdWithSession } from '../session-cwd.mjs'
|
|
12
13
|
import { resolve as resolvePath } from 'node:path'
|
|
13
14
|
|
|
14
15
|
import defineProvider, { resolveExePrefix } from '../../providers/opencode.mjs'
|
|
@@ -73,10 +74,15 @@ provider.engine = engine
|
|
|
73
74
|
function exePrefix() {
|
|
74
75
|
return resolveExePrefix(process.env, '')
|
|
75
76
|
}
|
|
76
|
-
function resolveCwd(a) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
function resolveCwd(a) {
|
|
78
|
+
if (a && a.cwd) return resolvePath(String(a.cwd))
|
|
79
|
+
// 兜底链:用户 env 覆盖 → **会话真实目录** → process.cwd()。末两级很关键:
|
|
80
|
+
// MCP server 由 DSH 以空 cwd 启动,process.cwd() 是 DSH 主进程目录而非用户项目,
|
|
81
|
+
// 直接用会让 CLI 把整个项目判成「工作区外」(agy/claude/opencode/codex 都踩过)。
|
|
82
|
+
const envCwd = process.env.OPENCODE_MCP_CWD
|
|
83
|
+
if (envCwd) return resolvePath(String(envCwd))
|
|
84
|
+
return resolveCwdWithSession(null, '', resolvePath)
|
|
85
|
+
}
|
|
80
86
|
|
|
81
87
|
// ── spawn(实时折叠走 bridge-kit 行流)────────────────────────────────────────
|
|
82
88
|
function runOpencode(args) {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// mcp/session-cwd.mjs — MCP 形态下解析「会话工作区」。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要(真实事故,2026-10-23):
|
|
4
|
+
// 用户反馈「agent 委派任务时 agy/opencode/claude/codex 报错:无权编辑工作区外的内容」。
|
|
5
|
+
// 根因不在 CLI 沙箱本身,而在**工作区边界被定错了**:
|
|
6
|
+
// MCP 各 provider 的 cwd 兜底是 `process.env.<ID>_MCP_CWD || process.cwd()`,
|
|
7
|
+
// 而 DSH 的 mcp-client 启动 stdio server 时 `config.cwd` 默认是空字符串
|
|
8
|
+
// (@deepseek-ai/dsh-mcp-client 里 `cwd: z.string().default("")`),
|
|
9
|
+
// 于是 MCP 进程的 cwd = **DSH 主进程的目录**(dsh-home / 安装目录),而不是用户的会话目录。
|
|
10
|
+
// 结果:CLI 认为"工作区"是那个目录,用户项目里的一切都在"工作区外"——
|
|
11
|
+
// codex 的 `-s workspace-write` 直接拒绝写入,其余各家也按各自的 project 边界拒绝。
|
|
12
|
+
//
|
|
13
|
+
// 修法:DSH 给子进程提供了 `DSH_SESSION_ID`(实测存在),会话的 cwd 存在
|
|
14
|
+
// projcache 的 `record.identity.cwd` 里。据此把兜底链补成:
|
|
15
|
+
// args.cwd(调用方显式传,最可靠)→ <ID>_MCP_CWD(用户配置)
|
|
16
|
+
// → 本模块(会话真实目录)→ process.cwd()(最后兜底)
|
|
17
|
+
//
|
|
18
|
+
// 约束:本模块零依赖(只用 node:fs / node:path),且必须容错——任何读取失败都返回 '',
|
|
19
|
+
// 由调用方继续往下兜底,绝不抛异常打断工具调用。
|
|
20
|
+
import { readFileSync, existsSync, readdirSync } from 'node:fs'
|
|
21
|
+
import { join } from 'node:path'
|
|
22
|
+
|
|
23
|
+
const CACHE_TTL_MS = 60 * 1000
|
|
24
|
+
let cache = { at: 0, sid: '', cwd: '' }
|
|
25
|
+
|
|
26
|
+
/** 从 projcache 里按会话 id 取 cwd。读不到返回 ''。 */
|
|
27
|
+
function cwdFromProjcache(dshHome, sessionId) {
|
|
28
|
+
if (!dshHome || !sessionId) return ''
|
|
29
|
+
const base = join(dshHome, 'storages', 'session_projcache', 'sessions')
|
|
30
|
+
// 会话 id 在磁盘上可能是 `<id>.json` 或 `session-<uuid>.json` 两种形态
|
|
31
|
+
const plain = sessionId.replace(/^session-/, '')
|
|
32
|
+
const cands = [
|
|
33
|
+
join(base, sessionId + '.json'),
|
|
34
|
+
join(base, 'session-' + plain + '.json'),
|
|
35
|
+
join(base, plain + '.json')
|
|
36
|
+
]
|
|
37
|
+
for (const f of cands) {
|
|
38
|
+
try {
|
|
39
|
+
if (!existsSync(f)) continue
|
|
40
|
+
const j = JSON.parse(readFileSync(f, 'utf8'))
|
|
41
|
+
const c = j && j.record && j.record.identity && j.record.identity.cwd
|
|
42
|
+
if (typeof c === 'string' && c) return c
|
|
43
|
+
} catch (e) { /* 单文件失败继续试下一个 */ }
|
|
44
|
+
}
|
|
45
|
+
return ''
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 解析当前 DSH 会话的工作目录。
|
|
50
|
+
* @param {object} [env] 默认 process.env(便于测试注入)
|
|
51
|
+
* @param {number} [nowMs] 便于测试注入时间
|
|
52
|
+
* @returns {string} 会话 cwd;无法确定时返回 ''
|
|
53
|
+
*/
|
|
54
|
+
export function resolveSessionCwd(env, nowMs) {
|
|
55
|
+
const e = env || (typeof process !== 'undefined' ? process.env : {}) || {}
|
|
56
|
+
const sid = String(e.DSH_SESSION_ID || '')
|
|
57
|
+
if (!sid) return ''
|
|
58
|
+
const at = Number(nowMs) || Date.now()
|
|
59
|
+
if (cache.sid === sid && at - cache.at < CACHE_TTL_MS) return cache.cwd
|
|
60
|
+
|
|
61
|
+
const home = String(e.DSH_HOME || '')
|
|
62
|
+
let cwd = cwdFromProjcache(home, sid)
|
|
63
|
+
// projcache 没命中时退一步:会话存储目录名里编码了 cwd(--C-Users-...--),
|
|
64
|
+
// 但它只覆盖有事件落盘的会话,且解码易错 —— 仅在 projcache 缺失时尝试。
|
|
65
|
+
if (!cwd && home) cwd = cwdFromSessionDirs(home, sid)
|
|
66
|
+
|
|
67
|
+
cache = { at, sid, cwd }
|
|
68
|
+
return cwd
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 会话事件目录名把 cwd 编码成 `--C-Users-lcl-Desktop-Foo--`。仅在 projcache 缺失时用。 */
|
|
72
|
+
function cwdFromSessionDirs(dshHome, sessionId) {
|
|
73
|
+
const base = join(dshHome, 'sessions')
|
|
74
|
+
try {
|
|
75
|
+
if (!existsSync(base)) return ''
|
|
76
|
+
for (const dirName of readdirSync(base)) {
|
|
77
|
+
if (!dirName.startsWith('--') || !dirName.endsWith('--')) continue
|
|
78
|
+
const inner = join(base, dirName)
|
|
79
|
+
let entries = []
|
|
80
|
+
try { entries = readdirSync(inner) } catch (e) { continue }
|
|
81
|
+
if (!entries.includes(sessionId)) continue
|
|
82
|
+
// `--C-Users-lcl-Desktop-Foo-Bar--` → C:\Users\lcl\Desktop\Foo\Bar
|
|
83
|
+
// 编码规则:盘符与反斜杠被替换成 '-', 空格等特殊字符被编码成 ~XXXX~(此处不做还原)
|
|
84
|
+
const raw = dirName.slice(2, -2)
|
|
85
|
+
const decoded = decodeSessionDirName(raw)
|
|
86
|
+
if (decoded) return decoded
|
|
87
|
+
}
|
|
88
|
+
} catch (e) { }
|
|
89
|
+
return ''
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 解码 DSH 会话目录名。已知编码:`C:` → `C`,`\` 与 `/` → `-`,特殊字符 → `~HEX~`。
|
|
94
|
+
* 反解有歧义(`-` 既是分隔符也可能属于目录名),故只在能明确判定盘符时返回,否则放弃。
|
|
95
|
+
*/
|
|
96
|
+
function decodeSessionDirName(raw) {
|
|
97
|
+
const m = /^([A-Za-z])-(.+)$/.exec(raw)
|
|
98
|
+
if (!m) return ''
|
|
99
|
+
const drive = m[1].toUpperCase() + ':\\'
|
|
100
|
+
const rest = m[2].replace(/~([0-9A-Fa-f]{4})~/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
101
|
+
// 剩余的 '-' 视作路径分隔符(与 DSH 编码一致)
|
|
102
|
+
return drive + rest.split('-').join('\\')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 供测试重置缓存。 */
|
|
106
|
+
export function resetSessionCwdCache() { cache = { at: 0, sid: '', cwd: '' } }
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 各 MCP provider 共用的 cwd 兜底链。
|
|
110
|
+
* 顺序:调用方显式 args.cwd → 用户 env 覆盖 → 会话真实目录 → process.cwd()。
|
|
111
|
+
* @param {object} a 工具入参
|
|
112
|
+
* @param {string} envKey 该 provider 的 cwd 覆盖环境变量名(如 CLAUDE_MCP_CWD)
|
|
113
|
+
* @param {(p: string) => string} [resolvePathFn] 路径规范化(各 provider 自带)
|
|
114
|
+
*/
|
|
115
|
+
export function resolveCwdWithSession(a, envKey, resolvePathFn) {
|
|
116
|
+
const rp = typeof resolvePathFn === 'function' ? resolvePathFn : (x) => x
|
|
117
|
+
if (a && a.cwd) return rp(String(a.cwd))
|
|
118
|
+
const env = (typeof process !== 'undefined' && process.env) || {}
|
|
119
|
+
if (envKey && env[envKey]) return rp(String(env[envKey]))
|
|
120
|
+
const sessionCwd = resolveSessionCwd(env)
|
|
121
|
+
if (sessionCwd) return rp(sessionCwd)
|
|
122
|
+
return (typeof process !== 'undefined' && process.cwd) ? process.cwd() : ''
|
|
123
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luchenglong/cli-bridge",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.14",
|
|
4
4
|
"description": "CLI Bridge for DeepSeek Harness (DSH): one package bridging six local coding-CLI agents — agy (Google Antigravity CLI), Tencent CodeBuddy Code, Xiaomi MiMo (MiMoCode), OpenCode, Claude Code, and OpenAI Codex CLI. Each provider keeps its own agent preset (cordis-agy / codebuddy-first / cordis-mimo / opencode-first / claude-first / codex-first), tools (<id>_run / <id>_continue / <id>_status, plus per-provider model listing), rate-limit/network fallback dialog, and zero-dependency MCP server, unified under a single home-level status light, one settings file, and one bundle patch. Merges agy-first-bridge, codebuddy-first-bridge and mimo-first-bridge.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/register-global-mcp.mjs — 把 CLI Bridge 的聚合 MCP 注册为 DSH **家级**(所有会话可见)。
|
|
3
|
+
//
|
|
4
|
+
// 为什么需要这一步:本包的 provider 工具只在两种情况下存在 ——
|
|
5
|
+
// ① 用了本包的 agent preset(claude-first 等);② 注册了 MCP。
|
|
6
|
+
// 普通会话(standard preset)两者都没有,于是「装了插件却看不到任何工具」,状态灯也永远不亮。
|
|
7
|
+
// 这就是本脚本存在的理由。
|
|
8
|
+
//
|
|
9
|
+
// 设计取舍:
|
|
10
|
+
// • 直接指向 npm 已安装包内的 mcp/cli-bridge-mcp.mjs,**不复制副本** —— 升级包后重启即生效,
|
|
11
|
+
// 不会出现「bin 里是旧副本、包是新版」的版本漂移。
|
|
12
|
+
// • command 用 DSH 自带 node.exe 的绝对路径,不依赖 PATH。
|
|
13
|
+
// • 写入前备份、写入后立即用 yaml 解析校验,校验失败自动回滚(家级补丁写错会让所有会话起不来)。
|
|
14
|
+
// • 幂等:已存在同 id 条目则跳过。
|
|
15
|
+
//
|
|
16
|
+
// 用法:
|
|
17
|
+
// node scripts/register-global-mcp.mjs # 注册(或报告已注册)
|
|
18
|
+
// node scripts/register-global-mcp.mjs --dry-run # 只报告将要做什么
|
|
19
|
+
// node scripts/register-global-mcp.mjs --remove # 撤销注册
|
|
20
|
+
//
|
|
21
|
+
// 之后必须重启 DSH(host 组合变更热加载不可靠)。
|
|
22
|
+
import fs from 'node:fs'
|
|
23
|
+
import path from 'node:path'
|
|
24
|
+
import os from 'node:os'
|
|
25
|
+
import { fileURLToPath } from 'node:url'
|
|
26
|
+
|
|
27
|
+
const ENTRY_ID = 'mcp-cli-bridge-global'
|
|
28
|
+
const SERVER_NAME = 'cli'
|
|
29
|
+
const TOOL_TIMEOUT_MS = 900000
|
|
30
|
+
|
|
31
|
+
const args = new Set(process.argv.slice(2))
|
|
32
|
+
const DRY = args.has('--dry-run')
|
|
33
|
+
const REMOVE = args.has('--remove')
|
|
34
|
+
|
|
35
|
+
function fail(msg) { console.error('✗ ' + msg); process.exit(1) }
|
|
36
|
+
function info(msg) { console.log(' ' + msg) }
|
|
37
|
+
|
|
38
|
+
/** 定位 dsh-home:优先 DSH_HOME,其次 Windows 默认位置。 */
|
|
39
|
+
export function detectDshHome(env = process.env) {
|
|
40
|
+
const cands = []
|
|
41
|
+
if (env.DSH_HOME) cands.push(env.DSH_HOME)
|
|
42
|
+
if (env.APPDATA) cands.push(path.join(env.APPDATA, 'DSH Desktop', 'dsh-home'))
|
|
43
|
+
if (env.HOME) cands.push(path.join(env.HOME, '.dsh'))
|
|
44
|
+
for (const c of cands) { try { if (fs.existsSync(c)) return c } catch (e) { } }
|
|
45
|
+
return ''
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const dshHome = detectDshHome()
|
|
49
|
+
if (!dshHome) fail('找不到 dsh-home(试过 DSH_HOME / %APPDATA%\\DSH Desktop\\dsh-home / ~/.dsh)')
|
|
50
|
+
info('dsh-home: ' + dshHome)
|
|
51
|
+
|
|
52
|
+
const patchFile = path.join(dshHome, 'cordis.patch.yml')
|
|
53
|
+
if (!fs.existsSync(patchFile)) fail('找不到家级补丁: ' + patchFile)
|
|
54
|
+
|
|
55
|
+
// 已安装包内的 MCP 入口。**优先用 DSH 里已安装的那份**(稳定路径,随 npm 升级生效);
|
|
56
|
+
// 只有找不到安装包时才回落到本脚本所在仓库(开发态)。若直接写仓库路径,一旦仓库被移动
|
|
57
|
+
// 或换台机器就会失效 —— 家级配置必须指向稳定位置。
|
|
58
|
+
function findInstalledEntry() {
|
|
59
|
+
const profilesDir = path.join(dshHome, 'profiles')
|
|
60
|
+
const cands = []
|
|
61
|
+
try {
|
|
62
|
+
for (const p of fs.readdirSync(profilesDir)) {
|
|
63
|
+
cands.push(path.join(profilesDir, p, 'node_modules', '@luchenglong', 'cli-bridge', 'mcp', 'cli-bridge-mcp.mjs'))
|
|
64
|
+
}
|
|
65
|
+
} catch (e) { }
|
|
66
|
+
for (const c of cands) { try { if (fs.existsSync(c)) return c } catch (e) { } }
|
|
67
|
+
return ''
|
|
68
|
+
}
|
|
69
|
+
const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
70
|
+
const repoEntry = path.join(pkgRoot, 'mcp', 'cli-bridge-mcp.mjs')
|
|
71
|
+
const mcpEntry = findInstalledEntry() || repoEntry
|
|
72
|
+
if (!fs.existsSync(mcpEntry)) fail('找不到 MCP 入口(安装包与本仓库都没有): ' + mcpEntry)
|
|
73
|
+
info('MCP 入口: ' + mcpEntry + (mcpEntry === repoEntry ? ' ⚠ 取的是仓库路径(未在 dsh-home 里找到已安装包)' : ' (已安装包)'))
|
|
74
|
+
|
|
75
|
+
// DSH 自带 node(避免 PATH 依赖);找不到就回落到 process.execPath
|
|
76
|
+
const dshNode = path.join(path.dirname(dshHome), 'backend', 'node.exe')
|
|
77
|
+
const nodeExe = fs.existsSync(dshNode) ? dshNode : process.execPath
|
|
78
|
+
info('node: ' + nodeExe)
|
|
79
|
+
|
|
80
|
+
// yaml 解析器:优先用 DSH 自带依赖,其次包内 peer
|
|
81
|
+
async function loadYaml() {
|
|
82
|
+
const cands = [
|
|
83
|
+
path.join(path.dirname(dshHome), 'backend', 'dsh', 'node_modules', 'yaml', 'index.js'),
|
|
84
|
+
'yaml'
|
|
85
|
+
]
|
|
86
|
+
for (const c of cands) {
|
|
87
|
+
try { const m = await import(c.startsWith('/') || /^[A-Za-z]:/.test(c) ? 'file:///' + c.replace(/\\/g, '/') : c); return m.default || m } catch (e) { }
|
|
88
|
+
}
|
|
89
|
+
try { return await import('yaml') } catch (e) { }
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
const YAML = await loadYaml()
|
|
93
|
+
|
|
94
|
+
const original = fs.readFileSync(patchFile, 'utf8')
|
|
95
|
+
const already = original.includes(ENTRY_ID)
|
|
96
|
+
|
|
97
|
+
if (REMOVE) {
|
|
98
|
+
if (!already) { info('未注册,无需撤销'); process.exit(0) }
|
|
99
|
+
// 删除从注释块起到条目结束(以 "- id: <ENTRY_ID>" 所在 insert 块为单位)
|
|
100
|
+
const lines = original.split('\n')
|
|
101
|
+
const out = []
|
|
102
|
+
let skipping = false
|
|
103
|
+
for (let i = 0; i < lines.length; i++) {
|
|
104
|
+
const ln = lines[i]
|
|
105
|
+
if (!skipping && ln.includes('- id: ' + ENTRY_ID)) {
|
|
106
|
+
// 往回删掉本块已有的 "- insert:" 行
|
|
107
|
+
while (out.length && /^\s*-\s*insert:\s*$/.test(out[out.length - 1])) out.pop()
|
|
108
|
+
skipping = true
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
if (skipping) {
|
|
112
|
+
if (/^\s{0,6}\S/.test(ln) && !/^\s*$/.test(ln) && !/^\s+(config|failOnStartupError|name|args|command|serverName|transport|toolCallTimeoutMs)/.test(ln) && !/^\s{8,}/.test(ln) && !/^\s*#/.test(ln)) skipping = false
|
|
113
|
+
else { continue }
|
|
114
|
+
}
|
|
115
|
+
out.push(ln)
|
|
116
|
+
}
|
|
117
|
+
const nextText = out.join('\n').replace(/\n{3,}/g, '\n\n')
|
|
118
|
+
if (DRY) { info('[dry-run] 将删除条目 ' + ENTRY_ID); process.exit(0) }
|
|
119
|
+
fs.writeFileSync(patchFile + '.bak-remove-' + Date.now(), original, 'utf8')
|
|
120
|
+
fs.writeFileSync(patchFile, nextText, 'utf8')
|
|
121
|
+
info('✓ 已撤销注册(已备份)')
|
|
122
|
+
info(' ⚠ 需重启 DSH 生效')
|
|
123
|
+
process.exit(0)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (already) {
|
|
127
|
+
info('✓ 已注册 ' + ENTRY_ID + '(幂等,未重复写入)')
|
|
128
|
+
info(' ⚠ 若刚改过包版本,重启 DSH 即可加载新版')
|
|
129
|
+
process.exit(0)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const block = [
|
|
133
|
+
'',
|
|
134
|
+
'# ══════════════════════════════════════════════════════════════════════════',
|
|
135
|
+
'# CLI Bridge 全局 MCP(家级/host 层)—— 由 scripts/register-global-mcp.mjs 写入',
|
|
136
|
+
'# 把六家 CLI 以【一个聚合 MCP】接进所有会话:',
|
|
137
|
+
'# mcp__' + SERVER_NAME + '__{agy,codebuddy,mimo,opencode,claude,codex}_{run,continue,status}',
|
|
138
|
+
'# 直接指向安装包内的入口,不复制副本 —— 升级包后重启 DSH 即生效,无版本漂移。',
|
|
139
|
+
'# ⚠ DSH 自带的 subagent_claude_code / subagent_codex 不走本 MCP,',
|
|
140
|
+
'# 它们的调用不会点亮 CLI Bridge 的状态灯(两套实现彼此独立)。',
|
|
141
|
+
'# ⚠ 改此条目后必须重启 DSH(host 组合变更热加载不可靠)。',
|
|
142
|
+
'# ══════════════════════════════════════════════════════════════════════════',
|
|
143
|
+
'- insert:',
|
|
144
|
+
' - id: ' + ENTRY_ID,
|
|
145
|
+
" name: '@deepseek-ai/dsh-mcp-client'",
|
|
146
|
+
' config:',
|
|
147
|
+
' serverName: ' + SERVER_NAME,
|
|
148
|
+
' transport: stdio',
|
|
149
|
+
" command: '" + nodeExe + "'",
|
|
150
|
+
" args: ['" + mcpEntry + "']",
|
|
151
|
+
' toolCallTimeoutMs: ' + TOOL_TIMEOUT_MS,
|
|
152
|
+
' failOnStartupError: false',
|
|
153
|
+
''
|
|
154
|
+
].join('\n')
|
|
155
|
+
|
|
156
|
+
if (DRY) { info('[dry-run] 将追加注册块到 ' + patchFile); process.exit(0) }
|
|
157
|
+
|
|
158
|
+
const bak = patchFile + '.bak-before-cli-bridge-mcp-' + new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)
|
|
159
|
+
fs.writeFileSync(bak, original, 'utf8')
|
|
160
|
+
info('已备份: ' + path.basename(bak))
|
|
161
|
+
|
|
162
|
+
const nextText = original.replace(/\s*$/, '\n') + block
|
|
163
|
+
fs.writeFileSync(patchFile, nextText, 'utf8')
|
|
164
|
+
|
|
165
|
+
// 校验:解析失败或找不到条目 → 回滚(家级补丁坏了会导致所有会话启动失败)
|
|
166
|
+
if (YAML) {
|
|
167
|
+
try {
|
|
168
|
+
const doc = YAML.parse(fs.readFileSync(patchFile, 'utf8'))
|
|
169
|
+
let found = null
|
|
170
|
+
const walk = (o) => {
|
|
171
|
+
if (!o || found) return
|
|
172
|
+
if (Array.isArray(o)) return o.forEach(walk)
|
|
173
|
+
if (typeof o === 'object') { if (o.id === ENTRY_ID) { found = o; return } Object.values(o).forEach(walk) }
|
|
174
|
+
}
|
|
175
|
+
walk(doc)
|
|
176
|
+
if (!found) throw new Error('解析后找不到 ' + ENTRY_ID)
|
|
177
|
+
info('✓ YAML 校验通过,条目已就位(serverName=' + found.config.serverName + ')')
|
|
178
|
+
} catch (e) {
|
|
179
|
+
fs.writeFileSync(patchFile, original, 'utf8')
|
|
180
|
+
fail('YAML 校验失败,已回滚: ' + e.message)
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
info('⚠ 未找到 yaml 解析器,跳过结构校验(已保留备份)')
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
info('')
|
|
187
|
+
info('✓ 注册完成。现在重启 DSH 使家级组合生效。')
|
|
188
|
+
info(' 重启后任意会话应能看到 20+ 个 mcp__' + SERVER_NAME + '__* 工具')
|
|
189
|
+
info(' (被调度禁用的 provider 不会出现 —— 见设置面板「CLI 调度」)')
|