magic-agent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -0
  3. package/cmd/magic-agent/main.go +12 -0
  4. package/cmd/magic-client/main.go +144 -0
  5. package/go.mod +11 -0
  6. package/go.sum +12 -0
  7. package/internal/agent/arkclaw.go +587 -0
  8. package/internal/agent/arkclaw_stream.go +312 -0
  9. package/internal/agent/arkclaw_stream_test.go +428 -0
  10. package/internal/agent/arkclaw_test.go +827 -0
  11. package/internal/agent/ask.go +694 -0
  12. package/internal/agent/ask_test.go +587 -0
  13. package/internal/agent/attachments.go +200 -0
  14. package/internal/agent/attachments_test.go +481 -0
  15. package/internal/agent/capability.go +154 -0
  16. package/internal/agent/capability_test.go +190 -0
  17. package/internal/agent/claude.go +406 -0
  18. package/internal/agent/codebuddy.go +1044 -0
  19. package/internal/agent/codebuddy_account_test.go +128 -0
  20. package/internal/agent/codebuddy_gateway.go +479 -0
  21. package/internal/agent/codebuddy_gateway_stream.go +358 -0
  22. package/internal/agent/codebuddy_gateway_test.go +673 -0
  23. package/internal/agent/codex.go +599 -0
  24. package/internal/agent/codex_home.go +206 -0
  25. package/internal/agent/codex_home_test.go +130 -0
  26. package/internal/agent/codex_stream_test.go +97 -0
  27. package/internal/agent/codex_test.go +280 -0
  28. package/internal/agent/custom_models.go +197 -0
  29. package/internal/agent/custom_models_test.go +283 -0
  30. package/internal/agent/dsh.go +754 -0
  31. package/internal/agent/dsh_sdk.go +679 -0
  32. package/internal/agent/dsh_sdk_test.go +726 -0
  33. package/internal/agent/dsh_test.go +759 -0
  34. package/internal/agent/engine.go +469 -0
  35. package/internal/agent/engine_base.go +497 -0
  36. package/internal/agent/engine_base_test.go +308 -0
  37. package/internal/agent/engine_test.go +858 -0
  38. package/internal/agent/env.go +88 -0
  39. package/internal/agent/env_test.go +106 -0
  40. package/internal/agent/jsonextract.go +128 -0
  41. package/internal/agent/jsonextract_test.go +110 -0
  42. package/internal/agent/keepalive_test.go +216 -0
  43. package/internal/agent/llmengine.go +470 -0
  44. package/internal/agent/llmengine_test.go +551 -0
  45. package/internal/agent/llmhttp.go +437 -0
  46. package/internal/agent/llmhttp_test.go +304 -0
  47. package/internal/agent/llmmodels.go +205 -0
  48. package/internal/agent/login.go +27 -0
  49. package/internal/agent/main_test.go +23 -0
  50. package/internal/agent/models.go +686 -0
  51. package/internal/agent/models_test.go +877 -0
  52. package/internal/agent/openclaw.go +543 -0
  53. package/internal/agent/openclaw_acp.go +949 -0
  54. package/internal/agent/openclaw_acp_test.go +406 -0
  55. package/internal/agent/openclaw_test.go +451 -0
  56. package/internal/agent/output.go +191 -0
  57. package/internal/agent/permission.go +362 -0
  58. package/internal/agent/permission_test.go +526 -0
  59. package/internal/agent/prompt.go +65 -0
  60. package/internal/agent/runcmd.go +259 -0
  61. package/internal/agent/runcmd_test.go +81 -0
  62. package/internal/agent/runcmd_unix.go +56 -0
  63. package/internal/agent/runcmd_windows.go +66 -0
  64. package/internal/agent/runner.go +180 -0
  65. package/internal/agent/runner_test.go +263 -0
  66. package/internal/agent/spawnhook_test.go +96 -0
  67. package/internal/agent/stream.go +882 -0
  68. package/internal/agent/stream_test.go +565 -0
  69. package/internal/agent/streamjson.go +185 -0
  70. package/internal/agent/tags.go +52 -0
  71. package/internal/agent/toolkind.go +96 -0
  72. package/internal/agent/toolkind_test.go +70 -0
  73. package/internal/agent/tools_suffix_test.go +261 -0
  74. package/internal/agent/trae.go +342 -0
  75. package/internal/agent/workspace_test.go +166 -0
  76. package/internal/boundary/boundary_test.go +165 -0
  77. package/internal/cli/ask.go +1113 -0
  78. package/internal/cli/ask_test.go +1088 -0
  79. package/internal/cli/attach_test.go +239 -0
  80. package/internal/cli/contract_test.go +157 -0
  81. package/internal/cli/control.go +149 -0
  82. package/internal/cli/control_test.go +192 -0
  83. package/internal/cli/engines_test.go +205 -0
  84. package/internal/cli/events.go +198 -0
  85. package/internal/cli/events_test.go +166 -0
  86. package/internal/cli/keepalive_test.go +391 -0
  87. package/internal/cli/login.go +78 -0
  88. package/internal/cli/permission_test.go +295 -0
  89. package/internal/cli/prompt_source_test.go +222 -0
  90. package/internal/cli/root.go +752 -0
  91. package/internal/cli/session.go +486 -0
  92. package/internal/cli/session_test.go +202 -0
  93. package/internal/cli/sessionlog.go +325 -0
  94. package/internal/cli/sessionlog_command_test.go +204 -0
  95. package/internal/cli/sessionlog_test.go +262 -0
  96. package/internal/cli/sessionlog_user_test.go +129 -0
  97. package/internal/cli/toolkind_wire_test.go +84 -0
  98. package/internal/cli/workspace_test.go +188 -0
  99. package/internal/client/cors_test.go +136 -0
  100. package/internal/client/lan.go +68 -0
  101. package/internal/client/lan_test.go +53 -0
  102. package/internal/client/pair.go +163 -0
  103. package/internal/client/pair_test.go +109 -0
  104. package/internal/client/profile.go +184 -0
  105. package/internal/client/service.go +888 -0
  106. package/internal/client/service_test.go +654 -0
  107. package/internal/client/session_events_test.go +186 -0
  108. package/internal/client/session_messages_test.go +150 -0
  109. package/internal/config/config.go +439 -0
  110. package/internal/config/config_test.go +448 -0
  111. package/internal/session/session.go +447 -0
  112. package/internal/session/session_append.go +241 -0
  113. package/internal/session/session_append_test.go +161 -0
  114. package/internal/session/session_kill_unix_test.go +90 -0
  115. package/internal/session/session_mark_test.go +95 -0
  116. package/internal/session/session_test.go +237 -0
  117. package/internal/session/sessionlog.go +128 -0
  118. package/internal/session/sessionlog_read_test.go +157 -0
  119. package/npm/dist/darwin-arm64/magic-agent +0 -0
  120. package/npm/dist/darwin-x64/magic-agent +0 -0
  121. package/npm/dist/linux-arm64/magic-agent +0 -0
  122. package/npm/dist/linux-x64/magic-agent +0 -0
  123. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  124. package/npm/install.js +121 -0
  125. package/npm/lib/platform.js +78 -0
  126. package/package.json +36 -0
@@ -0,0 +1,497 @@
1
+ package agent
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "os"
7
+ "os/exec"
8
+ "path/filepath"
9
+ "regexp"
10
+ "strconv"
11
+ "strings"
12
+ "time"
13
+ )
14
+
15
+ // 本文件是七个 CLI 引擎(claude / codebuddy / trae / llm / codex / openclaw / dsh)
16
+ // 的公共基座:统一可执行文件探测链、路径展开,以及跨引擎共享的参数构造辅助函数。
17
+ // (arkclaw 是 HTTP 网关、不走本机 CLI,其参数映射见 arkclaw.go;dsh 的完整映射
18
+ // 与依据见 dsh.go。)
19
+ //
20
+ // 统一参数矩阵(Request 字段 × 引擎的实际映射):
21
+ //
22
+ // ┌──────────────┬──────────────────────────┬───────────────────────────┬────────────────────────────┬──────────────────┬───────────────────────┬─────────────────────┬──────────────────────┐
23
+ // │ Request 字段 │ claude │ codebuddy │ trae │ llm │ codex │ openclaw │ dsh │
24
+ // ├──────────────┼──────────────────────────┼───────────────────────────┼────────────────────────────┼──────────────────┼───────────────────────┼─────────────────────┼──────────────────────┤
25
+ // │ Model │ --model <m> │ --model <m> │ -c model.name=<m> │ -m <m> │ -m <m> │ --model <m> │ 不支持(配置层)⑨ │
26
+ // │ SessionID │ --resume <id> │ --resume <id> │ --resume=<id> │ --cid <id> │ exec resume <id> │ --session-id <id> │ 不支持(显式报错)⑨ │
27
+ // │ Continue │ --continue │ --continue │ --resume(自动选最近会话) │ -c │ resume --last │ sessions 查最新 id │ 不支持(显式报错)⑨ │
28
+ // │ MaxTokens │ --settings(env 注入)① │ --settings(env 注入)① │ 不支持(静默忽略)② │ -o max_tokens │ 不支持(静默忽略)② │ 静默忽略② │ 静默忽略② │
29
+ // │ Temperature │ 不支持 │ 不支持 │ 不支持(静默忽略)② │ -o temperature │ 不支持(静默忽略)② │ 静默忽略② │ 静默忽略② │
30
+ // │ Tools │ --tools / 档位③ │ --tools / 档位③ │ --disallowed-tool / 全放行③│ 不支持 │ -s 沙箱档位④ │ 内嵌 agent(忽略) │ 内嵌 agent(忽略)⑨ │
31
+ // │ Permission │ --permission-mode⑧ │ --permission-mode⑧ │ 未接线(yaml 配置) │ 不支持 │ 未接线(沙箱×审批两轴)│ 未接线(exec.mode) │ 未接线(配置层预设)⑨│
32
+ // │ SystemPrompt │ --append-system-prompt │ --append-system-prompt │ 展平进 prompt 头部 │ -s │ -c developer_instr.⑤ │ 展平进 prompt 头部 │ 展平进 prompt 头部 │
33
+ // │ Timeout │ 进程级超时 │ 进程级超时 │ + --query-timeout │ 进程级超时 │ 进程级超时 │ + --timeout <sec> │ 进程级超时 │
34
+ // │ JSONSchema │ 不支持 │ 不支持 │ 不支持 │ 输出后处理抽 JSON│ --output-schema 原生 │ 输出后处理抽 JSON │ 输出后处理抽 JSON │
35
+ // │ Workspace │ 子进程 cwd⑥ │ 子进程 cwd⑥ │ 子进程 cwd⑥ │ 不支持⑦ │ -C/--cd + cwd⑥ │ 不支持⑦ │ 子进程 cwd⑥ │
36
+ // └──────────────┴──────────────────────────┴───────────────────────────┴────────────────────────────┴──────────────────┴───────────────────────┴─────────────────────┴──────────────────────┘
37
+ //
38
+ // 注:
39
+ // ① claude / codebuddy 均支持 `--settings <file-or-json>`,通过 {"env":{"CLAUDE_CODE_MAX_OUTPUT_TOKENS":"<n>"}}
40
+ //
41
+ // 注入输出上限(已用真实 CLI --help 验证);两者无 temperature 原生参数,也无对应 settings 键。
42
+ //
43
+ // ② trae 的 `-c <k>=<v>` 会整体覆盖模型配置块(实测设置 model.max_tokens 后模型名漂移并触发配额错误),
44
+ //
45
+ // 因此 MaxTokens / Temperature 在 trae 上明确不支持;codex 实测 `-c model_max_output_tokens=` /
46
+ // `-c temperature=` 会被 CLI 静默忽略(无对应生效键);openclaw agent 无相关 flag ——
47
+ // 三者构造参数时统一静默忽略。
48
+ //
49
+ // ③ Tools 语义:关闭 = claude/codebuddy `--tools ""`;trae `--disallowed-tool`
50
+ //
51
+ // 逐个列出内置实体工具(Bash/Edit/Write/Glob/Grep/Read)。
52
+ // 开启 = claude/codebuddy `--permission-mode <档位>`(四档模型,见 ⑧ 与 permission.go;
53
+ // 改造前恒传 `--dangerously-skip-permissions` / `-y` = 第 4 档);trae `-y`。
54
+ // 白名单 = claude/codebuddy `--tools a,b` + `--permission-mode <档位>`;trae **无收窄能力**
55
+ // (`--allowed-tool` 只做自动批准、不裁剪工具集,实测仍下发全部 18 个工具),
56
+ // 故白名单降级为「默认开启所有工具」= 全放行 `-y`,与 codex 的白名单降级同构。
57
+ // llm 无工具概念。
58
+ //
59
+ // ④ codex 无逐工具白名单/禁用能力:off → `-s read-only`(只读沙箱,尽力压制写入与执行);
60
+ //
61
+ // on / 白名单 → `--dangerously-bypass-approvals-and-sandbox`(白名单降级为全放行)。
62
+ //
63
+ // ⑤ codex 的 system prompt 经 `-c developer_instructions=<TOML 字符串>` 原生注入;
64
+ //
65
+ // 任意文本用 json.Marshal 转义(其转义集与 TOML basic string 兼容)。
66
+ //
67
+ // ⑥ Workspace(工作目录)落地方式 —— 能用 CLI 原生能力的就用原生:
68
+ //
69
+ // codex 原生 `-C/--cd <dir>`("working root",同时决定 sandbox 可写根)+ 子进程 cwd;
70
+ // claude / codebuddy / trae **没有**工作目录 flag(`--add-dir` 是「追加额外可访问目录」),
71
+ // 其原生方式就是**在目标目录里启动进程**(cwd)——相对路径解析、CLAUDE.md/AGENTS.md
72
+ // 发现、git 上下文都跟着 cwd,故一律用子进程 cwd 实现(2026-09-17 真机验证:
73
+ // codex/claude/codebuddy/trae 跑 `pwd` 都落在指定目录)。
74
+ // dsh 也走这条路:官方语义就是「调用时所在目录即默认 workspace 根」,
75
+ // 子进程 cwd 正是它的原生方式。
76
+ //
77
+ // ⑦ **不支持**(-w 会被忽略,CLI 层打一行提示):
78
+ //
79
+ // llm chat CLI,模型不直接读写文件,无文件系统语义;
80
+ // arkclaw 远端网关,workspace 由 claw_id 绑定,客户端无法指定;
81
+ // openclaw workspace 与 agent 绑定(`openclaw agents add` 时分配),无 per-call flag ——
82
+ // **实测子进程 cwd 被忽略**(agent 报的仍是自己的 ~/.openclaw/workspace),
83
+ // 要换目录得配一个绑定该目录的 agent。
84
+ // 机器可读的能力表见 WorkspaceSupportOf(--engines 每行的 workspace 字段)。
85
+ //
86
+ // ⑧ Permission(四档权限模型,2026-09-18 新增,详见 permission.go):
87
+ //
88
+ // 档位 manual / accept-edits / auto / full 映射到 claude、codebuddy 的
89
+ // `--permission-mode default / acceptEdits / auto / bypassPermissions`。
90
+ // 沙箱没有 CLI flag(claude 的 flags 表里没有 --sandbox),只能经 `--settings`
91
+ // 的 sandbox.* 注入;而 `--settings` 不是可重复 flag,故与 MaxTokens 的 env 注入
92
+ // 合并进同一份 JSON(agentSettingsPayload)。第 3 档还会注入 settings.autoMode。
93
+ // 默认档 full = 保持改造前的行为(恒传 --dangerously-skip-permissions / -y)。
94
+ // 未接线的引擎(trae/llm/codex/openclaw/dsh/arkclaw)传 --permission 会在 CLI 层
95
+ // exit 2 明确报错 —— 安全设置不做静默忽略。
96
+ // 机器可读的能力表见 PermissionSupportOf(--engines 每行的 permission 字段)。
97
+ //
98
+ // ⑨ dsh(DeepSeek Harness,headless profile)—— 依据其官方 CLI 行为参考,完整映射与理由见 dsh.go:
99
+ // Model:headless **没有 --model 参数**,模型由配置层决定($DSH_HOME/settings.yaml 的 agent-default-model 段 / Settings → Models),故 -m 不透传、构造参数时忽略并打一行告警(同 arkclaw)。
100
+ // SessionID / Continue:headless 一次调用 = 一个**全新的持久化 Agent**,官方 app 参数表里只有任务文本、没有续接参数 → **显式报错**(静默开新会话会让调用方以为上下文接上了)。
101
+ // MaxTokens / Temperature:无对应 flag(超时是 provider 级 settings 的 streamIdleTimeoutMs,不是 CLI flag)→ 静默忽略②。
102
+ // Tools:dsh 自带 agent 工具循环(base bundle 的 read/write/edit + Bash),增删工具只能改 profile bundle / cordis.patch.yml,**无命令行级逐工具开关** → 整体忽略(与 openclaw 的「内嵌 agent」同构)。
103
+ // Permission:新会话默认 workspace-write 预设(写入限工作区 + 平台临时目录),另有 read-only 预设,进程回退由环境变量 DSH_PERMISSION_MODE 决定;两个预设无法表达四档模型里的「沙箱关闭 / 无审批」(第 4 档)→ 不接线(none),显式传 --permission 在 CLI 层 exit 2。
104
+ // SystemPrompt:无独立 system 注入 flag → 展平进 prompt 头部(同 trae/openclaw)。
105
+ // Workspace:官方语义就是「调用时所在目录即默认 workspace 根」→ 子进程 cwd⑥。
106
+ // JSONSchema:无原生约束 → 输出后处理抽 JSON(同 llm/openclaw/arkclaw)。
107
+ // Attachments:无原生输入通道 → 绝对路径写进提示词(同 trae/openclaw)。
108
+ // Stream:实现了,但**只流推理** —— 推理增量走 stderr("dsh: reasoning:" 起头)→ 逐条 thinking 事件;正文在 turn 结束时一次性给出(一条 text 事件)。正文级流式需 --profile sdk 的 JSON-RPC,不在本引擎范围。
109
+ //
110
+ // cliBase 描述一个 CLI 引擎的可执行文件探测配置。
111
+ // 各引擎保留顶层 BinPath 字段与一行式 Name(),以保证测试中零值/复合字面量构造的兼容性;
112
+ // 本结构体仅承载探测链配置,由各引擎的 bin()/Detect() 委托调用。
113
+ type cliBase struct {
114
+ binName string // CLI 名称(LookPath 兜底用)
115
+ envVar string // 覆盖二进制路径的环境变量
116
+ candidates []string // 常见安装路径(支持 ~ 前缀,按序 os.Stat 探测)
117
+ notFound string // 全部探测失败时的提示文案
118
+ }
119
+
120
+ var (
121
+ claudeBase = cliBase{
122
+ binName: "claude",
123
+ envVar: "MAGIC_AGENT_CLAUDE_BIN",
124
+ candidates: []string{
125
+ "/opt/homebrew/bin/claude",
126
+ "/usr/local/bin/claude",
127
+ },
128
+ notFound: "claude CLI not found (install Claude Code or set MAGIC_AGENT_CLAUDE_BIN)",
129
+ }
130
+ // codebuddyBase / codebuddyAIBase 都指向**独立安装**的 CodeBuddy Code CLI
131
+ // (`npm i -g @tencent-ai/codebuddy-code`),不再用桌面 App 内置的那一份。
132
+ //
133
+ // 为什么换掉 App 内置 CLI(2026-09-28 实测根因):
134
+ // 桌面 App(WorkBuddy / WorkBuddy AI)的凭据由 App 通过 sidecar 通道用
135
+ // **受管密钥**做静态加密(CODEBUDDY_SIDECAR_CREDENTIAL_BOOTSTRAP_SOCKET),
136
+ // 密钥只发给 App 自己的子进程;独立起的 CLI 解不开,
137
+ // at-rest 登记表里 `auth/<id>.info` 的 read/write 全是 `missing-key`
138
+ // → 引擎每次调用只拿到 "Authentication required" 的空输出。
139
+ // 而独立安装的 CLI 自带 TUI(可 `/login`)、自带可读写的凭据库,
140
+ // 并且能用 ACC_PRODUCT_CONFIG_V3 切 authentication.id —— 一个 CLI 并存多个账号。
141
+ //
142
+ // 探测链(见 cliBase.resolve):MAGIC_AGENT_CODEBUDDY_BIN →
143
+ // PATH 上的 `codebuddy` → npm 全局 bin 目录。candidates 留空 =
144
+ // 不再探测 App 包内路径(那是导致 missing-key 的那一份)。
145
+ codebuddyBase = cliBase{
146
+ binName: "codebuddy",
147
+ envVar: "MAGIC_AGENT_CODEBUDDY_BIN",
148
+ notFound: "codebuddy CLI not found (npm install -g @tencent-ai/codebuddy-code, or set MAGIC_AGENT_CODEBUDDY_BIN)",
149
+ }
150
+ // codebuddyAIBase 与 codebuddyBase 用**同一个二进制**,差异只在账号:
151
+ // 两个引擎各自注入不同的 ACC_PRODUCT_CONFIG_V3(authentication.id),
152
+ // CLI 据此读不同的票据文件 sharedDataPath/auth/<id>.info
153
+ //(2026-09-28 实测验证)。账号标识见 codebuddy.go 的 codebuddyAuthID / codebuddyAIAuthID。
154
+ codebuddyAIBase = cliBase{
155
+ binName: "codebuddy",
156
+ envVar: "MAGIC_AGENT_CODEBUDDY_AI_BIN",
157
+ notFound: "codebuddy CLI not found for codebuddy-ai (npm install -g @tencent-ai/codebuddy-code, or set MAGIC_AGENT_CODEBUDDY_AI_BIN)",
158
+ }
159
+ traeBase = cliBase{
160
+ binName: "trae-cli",
161
+ envVar: "MAGIC_AGENT_TRAE_BIN",
162
+ candidates: []string{
163
+ "~/.local/bin/trae-cli",
164
+ "~/bin/trae-cli",
165
+ },
166
+ notFound: "trae-cli not found (install trae-cli or set MAGIC_AGENT_TRAE_BIN)",
167
+ }
168
+ llmBase = cliBase{
169
+ binName: "llm",
170
+ envVar: "MAGIC_AGENT_LLM_BIN",
171
+ candidates: []string{
172
+ // venv 优先:绕开 Homebrew Python 3.14 的 pip truststore 兼容问题
173
+ "~/.llm-venv/bin/llm",
174
+ "/opt/homebrew/bin/llm",
175
+ "/usr/local/bin/llm",
176
+ "~/.local/bin/llm",
177
+ },
178
+ notFound: "llm CLI not found (pip install llm / brew, or set MAGIC_AGENT_LLM_BIN)",
179
+ }
180
+ codexBase = cliBase{
181
+ binName: "codex",
182
+ envVar: "MAGIC_AGENT_CODEX_BIN",
183
+ candidates: []string{
184
+ "/opt/homebrew/bin/codex",
185
+ "/usr/local/bin/codex",
186
+ },
187
+ notFound: "codex CLI not found (npm install -g @openai/codex or set MAGIC_AGENT_CODEX_BIN)",
188
+ }
189
+ openclawBase = cliBase{
190
+ binName: "openclaw",
191
+ envVar: "MAGIC_AGENT_OPENCLAW_BIN",
192
+ candidates: []string{
193
+ "/opt/homebrew/bin/openclaw",
194
+ "/usr/local/bin/openclaw",
195
+ "~/.local/bin/openclaw",
196
+ },
197
+ notFound: "openclaw CLI not found (install openclaw or set MAGIC_AGENT_OPENCLAW_BIN)",
198
+ }
199
+ // dsh 是 Node 启动器(npm 包 @deepseek-ai/dsh),常见落点是 npm 全局 bin 目录;
200
+ // PATH 查找(exec.LookPath)才是主路径,下面的候选只是「PATH 不干净」时的兜底。
201
+ dshBase = cliBase{
202
+ binName: "dsh",
203
+ envVar: "MAGIC_AGENT_DSH_BIN",
204
+ candidates: []string{
205
+ "~/.local/bin/dsh",
206
+ "~/.npm-global/bin/dsh",
207
+ "/opt/homebrew/bin/dsh",
208
+ "/usr/local/bin/dsh",
209
+ "~/.bun/bin/dsh",
210
+ },
211
+ notFound: "dsh CLI not found (npm i -g @deepseek-ai/dsh, or set MAGIC_AGENT_DSH_BIN)",
212
+ }
213
+ )
214
+
215
+ // resolve 解析引擎二进制路径:显式 BinPath → 环境变量 → 候选路径(os.Stat)→
216
+ // PATH 查找 → npm 全局前缀(见 npmGlobalBinDirs)。
217
+ // 显式路径与环境变量不做存在性校验(与原实现一致,llm 的 Detect 会自行 stat 校验显式路径)。
218
+ //
219
+ // npm 全局前缀放在**最后**是刻意的:它只做「补漏」,不改变既有环境的解析结果
220
+ // (有 PATH 命中的仍按 PATH 走),只让「PATH 被裁剪、但 CLI 装在 npm 全局前缀里」
221
+ // 的环境也能探测到 —— 例如沙箱把 `npm i -g` 的落点设成私有目录时。
222
+ func (b cliBase) resolve(binPath string) string {
223
+ if binPath != "" {
224
+ return binPath
225
+ }
226
+ if v := os.Getenv(b.envVar); v != "" {
227
+ return v
228
+ }
229
+ for _, cand := range b.candidates {
230
+ p := expandHome(cand)
231
+ if p != "" {
232
+ if _, err := os.Stat(p); err == nil {
233
+ return p
234
+ }
235
+ }
236
+ }
237
+ if path, err := exec.LookPath(b.binName); err == nil {
238
+ return path
239
+ }
240
+ for _, dir := range npmGlobalBinDirs() {
241
+ p := filepath.Join(dir, b.binName)
242
+ if _, err := os.Stat(p); err == nil {
243
+ return p
244
+ }
245
+ }
246
+ return ""
247
+ }
248
+
249
+ // npmGlobalBinDirs 返回 npm 全局 bin 目录,用于发现 `npm i -g` 装出来的 CLI。
250
+ //
251
+ // 两段,按优先级:
252
+ //
253
+ // 1. 环境变量 `NPM_CONFIG_PREFIX`(或小写 `npm_config_prefix`)给出的前缀
254
+ // —— 依据:npm 把可执行文件放在 `<prefix>/bin`。本机实测沙箱就把它指向一个
255
+ // **不在 PATH 里**的私有前缀,导致 `npm i -g @deepseek-ai/dsh` 明明成功、探测却报 not found。
256
+ // 2. 标准全局前缀兜底 `/opt/homebrew/bin`、`/usr/local/bin`。
257
+ // 为什么需要兜底(2026-09-28):GUI 启动的进程(magic-test 等)PATH 往往很窄,
258
+ // 既没有 `npm i -g` 的落点、也可能没有 Homebrew 的 bin;而 codebuddy 系现在
259
+ // 依赖**独立安装**的 CodeBuddy Code CLI(`npm i -g @tencent-ai/codebuddy-code`
260
+ // → 默认落在 `/opt/homebrew/bin`),探测不能把这件事寄托在调用方的 PATH 上。
261
+ //
262
+ // 只读环境变量、不起子进程:探测链会被 `--engines` 对每个引擎调用一次,跑 `npm prefix -g`
263
+ // 会让列表慢上秒级;而「npm 在 PATH 上」的场景本来就能被 LookPath 命中,无需兜底。
264
+ func npmGlobalBinDirs() []string {
265
+ var dirs []string
266
+ seen := map[string]bool{}
267
+ add := func(d string) {
268
+ if d == "" || seen[d] {
269
+ return
270
+ }
271
+ seen[d] = true
272
+ dirs = append(dirs, d)
273
+ }
274
+ for _, k := range []string{"NPM_CONFIG_PREFIX", "npm_config_prefix"} {
275
+ if p := strings.TrimSpace(os.Getenv(k)); p != "" {
276
+ add(filepath.Join(p, "bin"))
277
+ }
278
+ }
279
+ add("/opt/homebrew/bin") // Apple Silicon Homebrew
280
+ add("/usr/local/bin") // Intel Homebrew / Node 官方安装包
281
+ return dirs
282
+ }
283
+
284
+ // detect 返回(是否可用,提示信息)。可用时提示信息为空字符串。
285
+ func (b cliBase) detect(binPath string) (bool, string) {
286
+ if b.resolve(binPath) == "" {
287
+ return false, b.notFound
288
+ }
289
+ return true, ""
290
+ }
291
+
292
+ // expandHome 将路径开头的 ~ 展开为用户主目录。
293
+ func expandHome(p string) string {
294
+ if p == "" || p[0] != '~' {
295
+ return p
296
+ }
297
+ if len(p) > 1 && p[1] != '/' && p[1] != filepath.Separator {
298
+ // 仅支持 ~ 或 ~/ 开头,不支持 ~user 形式
299
+ return p
300
+ }
301
+ home, err := os.UserHomeDir()
302
+ if err != nil {
303
+ return p
304
+ }
305
+ if len(p) == 1 {
306
+ return home
307
+ }
308
+ return filepath.Join(home, p[2:])
309
+ }
310
+
311
+ // ExpandHome 导出给 CLI 层做参数路径展开(-w/--workspace 等)。
312
+ func ExpandHome(p string) string { return expandHome(p) }
313
+
314
+ // WorkspaceSupportOf 返回某引擎「指定工作目录(workspace)」的落地方式(机器可读):
315
+ //
316
+ // "flag:-C" CLI 有原生工作目录 flag(codex 的 -C/--cd)
317
+ // "cwd" 没有 flag,但**子进程 cwd 就是它的原生方式**(相对路径 / CLAUDE.md 发现 /
318
+ // git 上下文都跟着 cwd)—— 工作目录真的会生效
319
+ // (claude / codebuddy / trae / dsh:dsh 官方语义即「调用目录 = workspace 根」)
320
+ // "none" 改不动:llm / arkclaw 没有文件系统语义;openclaw 的 workspace 与 agent
321
+ // 绑定(`openclaw agents`),**实测子进程 cwd 被忽略**(它的 agent 在自己的
322
+ // workspace 里跑)→ 参数被忽略,CLI 层会打一行提示
323
+ //
324
+ // 该值同时出现在 `--engines` 每行的 workspace 字段里,供调用方(如观物台)决定是否下发。
325
+ func WorkspaceSupportOf(engine string) string {
326
+ // 具名 agent(如 MagicAI)先归到它协议的家族名,再查表 —— 见 CapabilityFamilyOf。
327
+ engine = CapabilityFamilyOf(engine)
328
+ switch engine {
329
+ case "codex":
330
+ return "flag:-C"
331
+ case "llm", "arkclaw", "openclaw", "codebuddy-gateway":
332
+ // codebuddy-gateway 也在这里:网关在**它自己的 cwd** 里跑 agent,
333
+ // webhook 协议没有工作目录字段 → per-call 无法指定(要换目录得改网关进程的启动目录)。
334
+ return "none"
335
+ default:
336
+ return "cwd" // claude / codebuddy / trae / dsh(以及测试用假引擎)
337
+ }
338
+ }
339
+
340
+ // InstallCommandOf 返回某引擎的「一键安装命令」(shell 一行,可直接执行)。
341
+ //
342
+ // 为什么需要它:`--engines` 报某个引擎 `ok:false` 时,调用方(观物台等)需要一条
343
+ // **可执行的自救路径**,而不是只有一句 "not found"。命令一律取自各 CLI 的官方安装方式
344
+ // (括号里是官方另提供的等价方式,不塞进字段以免命令过长):
345
+ //
346
+ // claude npm install -g @anthropic-ai/claude-code
347
+ // (官方另有 curl -fsSL https://claude.ai/install.sh | bash、brew install --cask claude-code)
348
+ // codebuddy npm install -g @tencent-ai/codebuddy-code
349
+ // codebuddy-ai 同上 —— 两个引擎跑的是**同一个独立 CLI**,只是账号不同
350
+ // (靠 ACC_PRODUCT_CONFIG_V3 切 authentication.id,见 codebuddy.go)。
351
+ // 2026-09-28 起不再使用桌面 App(WorkBuddy.app / WorkBuddy AI.app)内置的那份:
352
+ // 那份的凭据由 App 用受管密钥静态加密,独立进程解不开(missing-key),
353
+ // 只会拿到空输出 —— 见 codebuddyBase 注释。
354
+ // codex npm install -g @openai/codex
355
+ // openclaw npm install -g openclaw@latest(官方文档;装完通常还要 openclaw onboard --install-daemon)
356
+ // dsh npm i -g @deepseek-ai/dsh
357
+ // trae sh -c "$(curl -L https://trae.cn/trae-cli/install.sh)"
358
+ // (官方 TraeCode CLI 安装脚本,落在 ~/.local/bin —— 正是本包探测链的候选位,
359
+ // 故不必在同一行里 export PATH)
360
+ // llm python3 -m venv ~/.llm-venv && ~/.llm-venv/bin/pip install llm
361
+ // (与 npm postinstall 同一条隔离安装路径,避开系统 Python;另有 pip / pipx / brew)
362
+ //
363
+ // 返回空串 = **没有可执行的一键安装命令**(而不是「不知道」):
364
+ //
365
+ // arkclaw 没有二进制也没有 CLI —— 凭据与端点在配置文件里
366
+ // codebuddy-gateway 同上:它要的不是安装,而是**把网关跑起来**
367
+ // (`codebuddy --serve` 或交互会话里的 `/gateway`),
368
+ // 那是一句启动命令、不是安装命令,写进 install 字段会误导
369
+ //
370
+ // 这些引擎「怎么才能用」的原因仍写在 --engines 的 note 字段(人读),install 字段如实留空,
371
+ // 不编一条跑不通的命令。
372
+ func InstallCommandOf(engine string) string {
373
+ // 具名 agent 先归到它协议的家族名,再查表 —— 见 CapabilityFamilyOf。
374
+ engine = CapabilityFamilyOf(engine)
375
+ switch engine {
376
+ case "claude":
377
+ return "npm install -g @anthropic-ai/claude-code"
378
+ case "codebuddy", "codebuddy-ai":
379
+ return "npm install -g @tencent-ai/codebuddy-code"
380
+ case "codex":
381
+ return "npm install -g @openai/codex"
382
+ case "openclaw":
383
+ return "npm install -g openclaw@latest"
384
+ case "dsh":
385
+ return "npm i -g @deepseek-ai/dsh"
386
+ case "trae":
387
+ return `sh -c "$(curl -L https://trae.cn/trae-cli/install.sh)"`
388
+ case "llm":
389
+ return "python3 -m venv ~/.llm-venv && ~/.llm-venv/bin/pip install llm"
390
+ }
391
+ return ""
392
+ }
393
+
394
+ /* EngineVersionOf 尽力读出某引擎 CLI 的**当前版本**(读不到就空串 —— 绝不编)。
395
+ *
396
+ * 为什么要它(2026-09-23 用户:「引擎检测除了 a2a 的 其他也要支持有升级」):
397
+ * 有了版本号,「升级」这件事才**可验证** —— 点完升级重探一次,调用方才能如实说
398
+ * 「已升级:旧 → 新」或「版本未变(多半已是最新)」。没有它就只能说「命令跑完了」,
399
+ * 而「跑完了」既可能真升级了、也可能什么都没发生 —— 用户侧看起来一模一样
400
+ * (2026-09-23 在 magic-agent 那一区就踩过这个:用户报「升级完也没变化」)。
401
+ *
402
+ * 判据(都取自 CLI 自己的输出,本工程**不维护任何版本表** —— 抄一份必然漂移):
403
+ * 1. 跑 `<bin> --version`(5s 超时),取输出里**第一个 x.y.z 形态**的串;
404
+ * 2. 拿不到(老 CLI 不认 --version / 输出里没有版本号 / 进程起不来)→ 空串。
405
+ * 实测样例(2026-09-23 本机):claude → `2.1.146 (Claude Code)`;codex → `codex-cli 0.154.0`;
406
+ * openclaw → `OpenClaw 2026.6.11 (e085fa1)`;dsh → `0.1.5-rc.2`;trae → `0.120.52`。
407
+ * 读不到的三类,都**如实留空**(不编):
408
+ * · arkclaw / 具名 A2A agent —— HTTP 网关,本机没有二进制(bin 是 URL,这里直接跳过);
409
+ * · codebuddy / codebuddy-ai —— 桌面端 GUI 应用,其 CLI 不提供 --version 语义
410
+ * (⚠️ 实测 codebuddy-ai 偶尔能吐一个版本号,能读就读、读不到就空,不做特判);
411
+ * · llm —— 它的 `--version` 要跑自己 venv 里的 python;venv 的 python 不可用
412
+ * (如环境里带了 PYTHONHOME / 解释器被换过)时读不到 → 空串。 */
413
+ func EngineVersionOf(ctx context.Context, bin string) string {
414
+ b := strings.TrimSpace(bin)
415
+ if b == "" || strings.Contains(b, "://") {
416
+ return ""
417
+ }
418
+ vctx, cancel := context.WithTimeout(ctx, engineVersionTimeout)
419
+ defer cancel()
420
+ out, errOut, _ := runCLI(vctx, b, "--version")
421
+ if v := firstSemver(out); v != "" {
422
+ return v
423
+ }
424
+ /* 少数 CLI 把版本打到 stderr(stdout 只剩日志)→ 兜一次。
425
+ 「只看第一行」这条同样适用,所以不会从报错堆栈里挑出无关数字。 */
426
+ return firstSemver(errOut)
427
+ }
428
+
429
+ // engineVersionTimeout 单次 `--version` 的超时。比模型探测(30s)短得多:
430
+ // 它只打印一行,正常在 100ms 内返回;给 5s 是留给冷启动 / 慢盘。
431
+ const engineVersionTimeout = 5 * time.Second
432
+
433
+ // semverRe 匹配 x.y.z 形态(数字段),允许 `-rc.1` / `+build` 这类后缀。
434
+ // ⚠️ 只认**三段数字**:两段的(如 `1.0`)在 CLI 输出里常是别的东西(协议版本、窗口尺寸)。
435
+ var semverRe = regexp.MustCompile(`\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.\-]+)?`)
436
+
437
+ // firstSemver 取文本里第一个版本号形态的串;没有则空串。
438
+ //
439
+ // ⚠️ **只看第一行**:版本输出都在第一行,后面的行可能是路径或别的程序的版本 ——
440
+ // 实测 llm 的 venv python 坏掉时,输出里满是 `/…/python3.12.13/…`、`/…/3.10.20_3/…`
441
+ // 这类路径,按「全文第一个 x.y.z」会挑中 `3.12.13` 这种**根本不是引擎版本**的数字。
442
+ // 只认三段数字的理由同 semverRe 的注释(两段的常是协议版本 / 窗口尺寸)。
443
+ func firstSemver(s string) string {
444
+ if i := strings.IndexByte(s, '\n'); i >= 0 {
445
+ s = s[:i]
446
+ }
447
+ return semverRe.FindString(s)
448
+ }
449
+
450
+ // ToolsSwitchableOf 报告某引擎的 `--tools` **是否真的有落地通道**(见矩阵 ③④⑨):
451
+ //
452
+ // true claude / codebuddy / codebuddy-ai(`--tools ""` / `--tools a,b`)
453
+ // trae(`--disallowed-tool` 真减法 / `-y` 全放行)
454
+ // codex(`-s read-only` ↔ 旁路,无逐工具白名单但 off/on 确实有区别)
455
+ // false llm(无工具概念);openclaw / dsh / arkclaw(自带工具循环或网关侧决定,
456
+ // 命令行层没有逐工具开关 → 参数被整体忽略)
457
+ // codebuddy-gateway(工具集由**网关进程**的 `--agent` 模式决定,
458
+ // webhook 协议里没有工具字段 → per-call 传了也不改变行为)
459
+ //
460
+ // 用途:CLI 层据此决定「附件降级提示」里要不要提 `--tools on` —— 对没有落地通道的
461
+ // 引擎说「请改用 --tools on」是误导(传了也不改变行为)。
462
+ func ToolsSwitchableOf(engine string) bool {
463
+ // 具名 agent 先归到它协议的家族名,再查表 —— 见 CapabilityFamilyOf。
464
+ engine = CapabilityFamilyOf(engine)
465
+ switch engine {
466
+ case "claude", "codebuddy", "codebuddy-ai", "trae", "codex":
467
+ return true
468
+ default:
469
+ return false
470
+ }
471
+ }
472
+
473
+ // maxTokensEnv 返回 claude / codebuddy `--settings` 里 env 段的取值,
474
+ // 通过 CLAUDE_CODE_MAX_OUTPUT_TOKENS 注入输出上限。n<=0 时返回 nil 表示不注入。
475
+ func maxTokensEnv(n int) map[string]string {
476
+ if n <= 0 {
477
+ return nil
478
+ }
479
+ return map[string]string{"CLAUDE_CODE_MAX_OUTPUT_TOKENS": strconv.Itoa(n)}
480
+ }
481
+
482
+ // maxTokensSettings 构造只含 env 的 `--settings` JSON 载荷(历史入口,保留兼容)。
483
+ //
484
+ // 注意:claude / codebuddy 实际走 agentSettingsPayload —— 因为 `--settings` 只接受
485
+ // 一份载荷,MaxTokens 的 env、沙箱配置、autoMode、权限规则必须合并进同一个 JSON
486
+ // (见 permission.go)。本函数保留给「只要 env」的调用方与既有测试。
487
+ func maxTokensSettings(n int) (string, bool) {
488
+ env := maxTokensEnv(n)
489
+ if env == nil {
490
+ return "", false
491
+ }
492
+ payload, err := json.Marshal(map[string]any{"env": env})
493
+ if err != nil {
494
+ return "", false
495
+ }
496
+ return string(payload), true
497
+ }