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,439 @@
1
+ // Package config - magic-agent 本地配置文件。
2
+ //
3
+ // 有些引擎不靠本机 CLI,而靠远端网关:凭据(url / key / id)既不适合写进
4
+ // 代码,也不适合每次调用都敲一遍 flag。本包提供统一的本地配置文件读取。
5
+ //
6
+ // 文件位置(按优先级):
7
+ //
8
+ // 1. 环境变量 MAGIC_AGENT_CONFIG 指定的路径(~ 前缀会展开)
9
+ // 2. $XDG_CONFIG_HOME/magic-agent/config.json
10
+ // 3. ~/.config/magic-agent/config.json
11
+ //
12
+ // 文件不存在 / 为空 **不是错误**:返回零值配置 + nil。调用方按「必需字段
13
+ // 是否齐备」报错,这样纯环境变量或纯 flag 的用法同样能工作。
14
+ //
15
+ // 格式(JSON):
16
+ //
17
+ // {
18
+ // "systemPrompt": "你是一个中文助手,始终用中文回答所有问题。",
19
+ // "arkclaw": {
20
+ // "url": "https://<host>/a2a/jsonrpc",
21
+ // "key": "<apikey>",
22
+ // "claw_id": "ci-xxxxxxxx"
23
+ // },
24
+ // "codebuddyGateway": {
25
+ // "url": "http://127.0.0.1:8399"
26
+ // }
27
+ // }
28
+ //
29
+ // 字段名做了宽松兼容(等价写法任选其一):
30
+ //
31
+ // url ← url | endpoint
32
+ // key ← key | apikey | api_key
33
+ // claw_id ← claw_id | clawId | clawID
34
+ // systemPrompt ← systemPrompt | system_prompt | system
35
+ // password ← password | token | key | secret(codebuddyGateway 节)
36
+ //
37
+ // 环境变量覆盖(优先级高于文件内容):
38
+ //
39
+ // MAGIC_AGENT_ARKCLAW_URL
40
+ // MAGIC_AGENT_ARKCLAW_KEY
41
+ // MAGIC_AGENT_ARKCLAW_CLAW_ID
42
+ // MAGIC_AGENT_SYSTEM_PROMPT
43
+ // MAGIC_AGENT_CBGW_URL
44
+ // MAGIC_AGENT_CBGW_PASSWORD
45
+ //
46
+ // 本包只负责「读到值」,不做网络校验,也不缓存(每次调用重新读,便于
47
+ // 测试与运行期改配置)。
48
+ package config
49
+
50
+ import (
51
+ "encoding/json"
52
+ "fmt"
53
+ "os"
54
+ "path/filepath"
55
+ "strings"
56
+ )
57
+
58
+ // EnvPath 指定配置文件路径的环境变量名。
59
+ const EnvPath = "MAGIC_AGENT_CONFIG"
60
+
61
+ // arkclaw 一节支持的环境变量覆盖名。
62
+ const (
63
+ EnvArkClawURL = "MAGIC_AGENT_ARKCLAW_URL"
64
+ EnvArkClawKey = "MAGIC_AGENT_ARKCLAW_KEY"
65
+ EnvArkClawClawID = "MAGIC_AGENT_ARKCLAW_CLAW_ID"
66
+ )
67
+
68
+ // codebuddyGateway 一节支持的环境变量覆盖名。
69
+ const (
70
+ EnvCBGWURL = "MAGIC_AGENT_CBGW_URL"
71
+ EnvCBGWPassword = "MAGIC_AGENT_CBGW_PASSWORD"
72
+ )
73
+
74
+ // EnvSystemPrompt 默认系统提示词的环境变量覆盖名。
75
+ const EnvSystemPrompt = "MAGIC_AGENT_SYSTEM_PROMPT"
76
+
77
+ // Config 是 magic-agent 的全部本地配置。
78
+ // arkclaw 一节给远端网关凭据;systemPrompt 是全局默认系统提示词;
79
+ // agents 是**具名 A2A agent 清单**(每个条目 = 一个独立引擎,见 AgentConfig)。
80
+ type Config struct {
81
+ ArkClaw ArkClawConfig `json:"arkclaw"`
82
+
83
+ // CodeBuddyGateway CodeBuddy Code HTTP 网关(`codebuddy --serve` / `/gateway`
84
+ // 远程控制模式)的连接配置,见 CBGatewayConfig。
85
+ CodeBuddyGateway CBGatewayConfig `json:"codebuddyGateway"`
86
+
87
+ // SystemPrompt 默认系统提示词:调用方(CLI)未显式给 -s/--system 时注入,
88
+ // 对所有引擎生效。空 = 不注入(保持原行为)。
89
+ SystemPrompt string `json:"systemPrompt"`
90
+
91
+ /* Agents 具名 A2A agent 清单(2026-09-23 加)。
92
+ *
93
+ * 为什么需要它:arkclaw 那个引擎**只有一个**(名字写死在注册表里),而模型是
94
+ * **由 claw_id 在网关侧绑死**的 —— 想同时用两个 claw(或想给同一个 claw 一个
95
+ * 好认的名字)就没有位置放。这一节就是那个位置:每个条目注册成一个独立引擎,
96
+ * 名字取条目里的 `name`,于是 `-e MagicAI` 与 `-e arkclaw` 可以并列存在。
97
+ *
98
+ * ⚠️ 与 `arkclaw` 节的分工:`arkclaw` 是**历史保留的单实例**(老配置照旧生效,
99
+ * 行为一个字不改);`agents` 是新增的**多实例**通道。两者互不影响。 */
100
+ Agents []AgentConfig `json:"agents"`
101
+ }
102
+
103
+ // UnmarshalJSON 宽松解析顶层键:systemPrompt / system_prompt / system 任选其一。
104
+ func (c *Config) UnmarshalJSON(b []byte) error {
105
+ var raw struct {
106
+ ArkClaw ArkClawConfig `json:"arkclaw"`
107
+ CBGW CBGatewayConfig `json:"codebuddyGateway"`
108
+ CBGWAlt CBGatewayConfig `json:"codebuddy_gateway"`
109
+ CBGWAlt2 CBGatewayConfig `json:"codebuddy-gateway"`
110
+ CBGWAlt3 CBGatewayConfig `json:"cbgw"`
111
+ SystemPrompt string `json:"systemPrompt"`
112
+ SystemPromptAlt string `json:"system_prompt"`
113
+ SystemPromptAlt2 string `json:"system"`
114
+ Agents []AgentConfig `json:"agents"`
115
+ }
116
+ if err := json.Unmarshal(b, &raw); err != nil {
117
+ return err
118
+ }
119
+ c.ArkClaw = raw.ArkClaw
120
+ // 四个键名任选其一(同一语义的多种写法;都写时按上面的优先级取第一个非空节)。
121
+ c.CodeBuddyGateway = firstNonEmptySection(raw.CBGW, raw.CBGWAlt, raw.CBGWAlt2, raw.CBGWAlt3)
122
+ c.SystemPrompt = firstNonEmpty(raw.SystemPrompt, raw.SystemPromptAlt, raw.SystemPromptAlt2)
123
+ c.Agents = raw.Agents
124
+ return nil
125
+ }
126
+
127
+ // firstNonEmptySection 返回第一个「有内容」的配置节(全空则返回零值)。
128
+ //
129
+ // 为什么不像字符串那样简单 firstNonEmpty:节是结构体,多个别名键同时出现时
130
+ // 要挑**配了东西**的那个,而不是「第一个出现的」。判据用 URL 非空 ——
131
+ // 它是这一节唯一的必需字段(见 CBGatewayConfig.Missing)。
132
+ func firstNonEmptySection(sections ...CBGatewayConfig) CBGatewayConfig {
133
+ for _, s := range sections {
134
+ if strings.TrimSpace(s.URL) != "" {
135
+ return s
136
+ }
137
+ }
138
+ return CBGatewayConfig{}
139
+ }
140
+
141
+ /* AgentConfig 是 agents 数组里的一条:一个**具名 A2A agent**(走 arkclaw 同一条
142
+ * JSON-RPC 协议,见 internal/agent/arkclaw.go)。
143
+ *
144
+ * 两种写法都支持(凭据可以内嵌在 URL 里,也可以分开写):
145
+ *
146
+ * { "name": "MagicAI", "url": "https://host/a2a/jsonrpc?apikey=…&clawId=…" }
147
+ * { "name": "MagicAI", "url": "https://host/a2a/jsonrpc", "key": "…", "claw_id": "ci-…" }
148
+ *
149
+ * ⚠️ URL 里已经带了 `apikey`/`clawId` 时**不必**再写 key/claw_id —— 拼接时会用
150
+ * `q.Set` 保留既有 query(同值覆盖,不重复追加),所以两种写法等价、也不怕写重。
151
+ * 键名与 arkclaw 节同一套宽松别名(url/endpoint、key/apikey/api_key、claw_id/clawId/clawID)。 */
152
+ type AgentConfig struct {
153
+ // Name 引擎名(`-e <Name>` 用它,`--engines` 也用它)。空 → 该条目被跳过。
154
+ Name string `json:"name"`
155
+
156
+ // URL A2A JSON-RPC 端点(可自带 apikey / clawId query 参数)。
157
+ URL string `json:"url"`
158
+
159
+ // Key 网关鉴权 apikey(URL 里已带时可留空)。
160
+ Key string `json:"key"`
161
+
162
+ // ClawID 目标 agent 标识(URL 里已带时可留空)。
163
+ ClawID string `json:"claw_id"`
164
+ }
165
+
166
+ // UnmarshalJSON 宽松解析(键名别名与 ArkClawConfig 完全一致,两处别各写一套)。
167
+ func (a *AgentConfig) UnmarshalJSON(b []byte) error {
168
+ var raw struct {
169
+ Name string `json:"name"`
170
+ URL string `json:"url"`
171
+ Endpoint string `json:"endpoint"`
172
+ Key string `json:"key"`
173
+ APIKey string `json:"apikey"`
174
+ APIKeyAlt string `json:"api_key"`
175
+ ClawID string `json:"claw_id"`
176
+ ClawIDAlt string `json:"clawId"`
177
+ ClawIDAlt2 string `json:"clawID"`
178
+ }
179
+ if err := json.Unmarshal(b, &raw); err != nil {
180
+ return err
181
+ }
182
+ a.Name = strings.TrimSpace(raw.Name)
183
+ a.URL = firstNonEmpty(raw.URL, raw.Endpoint)
184
+ a.Key = firstNonEmpty(raw.Key, raw.APIKey, raw.APIKeyAlt)
185
+ a.ClawID = firstNonEmpty(raw.ClawID, raw.ClawIDAlt, raw.ClawIDAlt2)
186
+ return nil
187
+ }
188
+
189
+ // ArkClawConfig 是 arkclaw 引擎(A2A JSON-RPC 网关)的连接配置。
190
+ type ArkClawConfig struct {
191
+ // URL A2A JSON-RPC 端点,如 https://<host>/a2a/jsonrpc
192
+ URL string `json:"url"`
193
+
194
+ // Key 网关鉴权 apikey(作为 query 参数 apikey 发送)
195
+ Key string `json:"key"`
196
+
197
+ // ClawID 目标 agent 标识(作为 query 参数 clawId 发送)
198
+ ClawID string `json:"claw_id"`
199
+ }
200
+
201
+ // UnmarshalJSON 宽松解析:同一语义的多种键名任选其一。
202
+ // 之所以不用单靠 struct tag —— encoding/json 的大小写不敏感回退无法跨过
203
+ // 下划线("claw_id" 与 "clawId" 视为不同键)。
204
+ func (a *ArkClawConfig) UnmarshalJSON(b []byte) error {
205
+ var raw struct {
206
+ URL string `json:"url"`
207
+ Endpoint string `json:"endpoint"`
208
+ Key string `json:"key"`
209
+ APIKey string `json:"apikey"`
210
+ APIKeyAlt string `json:"api_key"`
211
+ ClawID string `json:"claw_id"`
212
+ ClawIDAlt string `json:"clawId"`
213
+ ClawIDAlt2 string `json:"clawID"`
214
+ }
215
+ if err := json.Unmarshal(b, &raw); err != nil {
216
+ return err
217
+ }
218
+ a.URL = firstNonEmpty(raw.URL, raw.Endpoint)
219
+ a.Key = firstNonEmpty(raw.Key, raw.APIKey, raw.APIKeyAlt)
220
+ a.ClawID = firstNonEmpty(raw.ClawID, raw.ClawIDAlt, raw.ClawIDAlt2)
221
+ return nil
222
+ }
223
+
224
+ // Ready 报告三项必需配置是否都非空。
225
+ func (a ArkClawConfig) Ready() bool {
226
+ return len(a.Missing()) == 0
227
+ }
228
+
229
+ // Missing 返回缺失的字段名(用于报错文案)。
230
+ func (a ArkClawConfig) Missing() []string {
231
+ var out []string
232
+ if strings.TrimSpace(a.URL) == "" {
233
+ out = append(out, "url")
234
+ }
235
+ if strings.TrimSpace(a.Key) == "" {
236
+ out = append(out, "key")
237
+ }
238
+ if strings.TrimSpace(a.ClawID) == "" {
239
+ out = append(out, "claw_id")
240
+ }
241
+ return out
242
+ }
243
+
244
+ /* CBGatewayConfig 是 codebuddy-gateway 引擎的连接配置。
245
+ *
246
+ * 这个引擎不跑本机 CLI,也不走远端 A2A 网关,而是接**本机(或可达的)CodeBuddy Code
247
+ * HTTP 网关** —— 也就是 `codebuddy --serve` / 交互会话里的 `/gateway` 那一套远程控制
248
+ * 服务:magic-agent 把 prompt 以 `POST /api/v1/webhooks/{platform}` 投递进去(网关
249
+ * 立即回 202 + runId),再用 `GET /api/v1/runs/{runId}/stream` 收 SSE 增量。
250
+ *
251
+ * 为什么凭据放配置文件而不是 flag:网关地址与访问 token 是**环境相关**的(本机端口
252
+ * 每次都可能不同、公网 Tunnel 域名更是每次重启都换),写进命令行既难记也容易进 shell
253
+ * 历史;与 arkclaw 同一取舍。
254
+ *
255
+ * 两种等价写法(token 也可以内嵌在 URL 的 query 里,见 CBGatewayConfig.Token):
256
+ *
257
+ * { "codebuddyGateway": { "url": "http://127.0.0.1:8399" } }
258
+ * { "codebuddyGateway": { "url": "https://xxx.trycloudflare.com", "password": "<token>" } }
259
+ *
260
+ * 节名别名:codebuddyGateway | codebuddy_gateway | codebuddy-gateway | cbgw。
261
+ * 字段别名:url | endpoint;password | token | key | secret。 */
262
+ type CBGatewayConfig struct {
263
+ // URL 网关根地址(不带 /api/v1 前缀),如 http://127.0.0.1:8399。
264
+ URL string `json:"url"`
265
+
266
+ // Password 网关访问口令。`--serve` 默认开密码认证(`--auth password`),
267
+ // 口令在启动日志里打印;`--auth none` 时留空即可。
268
+ Password string `json:"password"`
269
+
270
+ // Platform 平台标识(webhooks 路径段):generic | wecom | wechat-kf。
271
+ // 空 = generic(本引擎唯一能自洽收流的通道,见 codebuddy_gateway.go 文件头)。
272
+ Platform string `json:"platform"`
273
+
274
+ // Sender 发送者标识(网关按它做限流与来源归属)。空 = "magic-agent"。
275
+ Sender string `json:"sender"`
276
+
277
+ // Conversation 会话标识(**续接的锚点**:网关按它 getOrCreateSession)。
278
+ // 空 = 每次调用新开一个(即无上下文续接)。非空时同一 id 复用同一会话。
279
+ Conversation string `json:"conversation"`
280
+ }
281
+
282
+ // UnmarshalJSON 宽松解析:同一语义的多种键名任选其一。
283
+ // 与 ArkClawConfig 同一理由 —— encoding/json 的大小写不敏感回退跨不过下划线。
284
+ func (c *CBGatewayConfig) UnmarshalJSON(b []byte) error {
285
+ var raw struct {
286
+ URL string `json:"url"`
287
+ Endpoint string `json:"endpoint"`
288
+ Password string `json:"password"`
289
+ Token string `json:"token"`
290
+ Key string `json:"key"`
291
+ Secret string `json:"secret"`
292
+ Platform string `json:"platform"`
293
+ Sender string `json:"sender"`
294
+ SenderAlt string `json:"sender_id"`
295
+ Conv string `json:"conversation"`
296
+ ConvAlt string `json:"conversation_id"`
297
+ ConvAlt2 string `json:"conversationId"`
298
+ }
299
+ if err := json.Unmarshal(b, &raw); err != nil {
300
+ return err
301
+ }
302
+ c.URL = firstNonEmpty(raw.URL, raw.Endpoint)
303
+ c.Password = firstNonEmpty(raw.Password, raw.Token, raw.Key, raw.Secret)
304
+ c.Platform = firstNonEmpty(raw.Platform)
305
+ c.Sender = firstNonEmpty(raw.Sender, raw.SenderAlt)
306
+ c.Conversation = firstNonEmpty(raw.Conv, raw.ConvAlt, raw.ConvAlt2)
307
+ return nil
308
+ }
309
+
310
+ // Ready 报告必需配置是否齐备(只有 url 是必需的:`--auth none` 的网关没有口令)。
311
+ func (c CBGatewayConfig) Ready() bool { return len(c.Missing()) == 0 }
312
+
313
+ // Missing 返回缺失的字段名(用于报错文案)。
314
+ func (c CBGatewayConfig) Missing() []string {
315
+ if strings.TrimSpace(c.URL) == "" {
316
+ return []string{"url"}
317
+ }
318
+ return nil
319
+ }
320
+
321
+ // Path 返回生效的配置文件路径(无法确定时返回空串)。
322
+ //
323
+ // 探测顺序(2026-09-23 起 `~/.magic-agent/config.json` 提到最前):
324
+ //
325
+ // 1. MAGIC_AGENT_CONFIG —— 显式指定,永远最高优先
326
+ // 2. ~/.magic-agent/config.json —— **存在时**用它(用户 2026-09-23 定稿:
327
+ // 「应该放在 ~/.magic-agent/ 下」;与 llm 的 models.json 同目录)
328
+ // 3. $XDG_CONFIG_HOME/magic-agent/config.json
329
+ // 4. ~/.config/magic-agent/config.json —— 历史默认位置
330
+ //
331
+ // ⚠️ 第 2 条**带存在性判断**是刻意的:新位置没建文件时不能把老位置顶掉,
332
+ // 否则老配置会被静默忽略 —— 那正是「配置改了没生效」这类报障的来源。
333
+ func Path() string {
334
+ if v := strings.TrimSpace(os.Getenv(EnvPath)); v != "" {
335
+ return expandHome(v)
336
+ }
337
+ home, err := os.UserHomeDir()
338
+ if err == nil && home != "" {
339
+ if p := filepath.Join(home, ".magic-agent", "config.json"); fileExists(p) {
340
+ return p
341
+ }
342
+ }
343
+ if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" {
344
+ return filepath.Join(xdg, "magic-agent", "config.json")
345
+ }
346
+ if err != nil || home == "" {
347
+ return ""
348
+ }
349
+ return filepath.Join(home, ".config", "magic-agent", "config.json")
350
+ }
351
+
352
+ // fileExists 报告路径是否是一个存在的普通文件(探测链用)。
353
+ // 权限错误 / 是个目录都按「不是配置文件」处理 —— 探测链要的是「能不能读它」。
354
+ func fileExists(p string) bool {
355
+ st, err := os.Stat(p)
356
+ return err == nil && !st.IsDir()
357
+ }
358
+
359
+ // Load 读取默认位置的配置文件,并叠加环境变量覆盖。
360
+ func Load() (Config, error) {
361
+ return LoadFrom(Path())
362
+ }
363
+
364
+ // LoadFrom 读取指定路径的配置文件,并叠加环境变量覆盖。
365
+ //
366
+ // path 为空、文件不存在、或文件内容全为空白时,返回零值配置 + nil
367
+ // (「没配置」是合法状态,由调用方决定缺字段是否致命)。
368
+ // 其它错误(读失败 / JSON 语法错)原样上报 —— 静默吞掉畸形配置会让
369
+ // 使用者排查不到根因。
370
+ func LoadFrom(path string) (Config, error) {
371
+ var c Config
372
+ if strings.TrimSpace(path) != "" {
373
+ b, err := os.ReadFile(path)
374
+ switch {
375
+ case err == nil:
376
+ if len(strings.TrimSpace(string(b))) > 0 {
377
+ if jerr := json.Unmarshal(b, &c); jerr != nil {
378
+ return Config{}, fmt.Errorf("parse config %s: %w", path, jerr)
379
+ }
380
+ }
381
+ case os.IsNotExist(err):
382
+ // 未创建配置文件:走零值 + 环境变量。
383
+ default:
384
+ return Config{}, fmt.Errorf("read config %s: %w", path, err)
385
+ }
386
+ }
387
+ return applyEnv(c), nil
388
+ }
389
+
390
+ // applyEnv 用环境变量覆盖配置值(环境变量优先级高于文件)。
391
+ func applyEnv(c Config) Config {
392
+ if v := strings.TrimSpace(os.Getenv(EnvArkClawURL)); v != "" {
393
+ c.ArkClaw.URL = v
394
+ }
395
+ if v := strings.TrimSpace(os.Getenv(EnvArkClawKey)); v != "" {
396
+ c.ArkClaw.Key = v
397
+ }
398
+ if v := strings.TrimSpace(os.Getenv(EnvArkClawClawID)); v != "" {
399
+ c.ArkClaw.ClawID = v
400
+ }
401
+ if v := strings.TrimSpace(os.Getenv(EnvSystemPrompt)); v != "" {
402
+ c.SystemPrompt = v
403
+ }
404
+ if v := strings.TrimSpace(os.Getenv(EnvCBGWURL)); v != "" {
405
+ c.CodeBuddyGateway.URL = v
406
+ }
407
+ if v := strings.TrimSpace(os.Getenv(EnvCBGWPassword)); v != "" {
408
+ c.CodeBuddyGateway.Password = v
409
+ }
410
+ return c
411
+ }
412
+
413
+ // firstNonEmpty 返回第一个去空白后非空的实参。
414
+ func firstNonEmpty(vals ...string) string {
415
+ for _, v := range vals {
416
+ if strings.TrimSpace(v) != "" {
417
+ return strings.TrimSpace(v)
418
+ }
419
+ }
420
+ return ""
421
+ }
422
+
423
+ // expandHome 展开路径开头的 ~(仅支持 ~ 与 ~/ 形式)。
424
+ func expandHome(p string) string {
425
+ if p == "" || p[0] != '~' {
426
+ return p
427
+ }
428
+ if len(p) > 1 && p[1] != '/' && p[1] != filepath.Separator {
429
+ return p
430
+ }
431
+ home, err := os.UserHomeDir()
432
+ if err != nil || home == "" {
433
+ return p
434
+ }
435
+ if len(p) == 1 {
436
+ return home
437
+ }
438
+ return filepath.Join(home, p[2:])
439
+ }