magic-agent 1.0.2 → 1.0.3

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.
@@ -18,10 +18,17 @@ package agent
18
18
  // 归谁所有:models.json 是 **CLI 自己的**配置文件(CLI 的 models 控制器会读写它)。
19
19
  // 所以这里只做**合并**:只补缺失的 id,不删、不改、不动其它字段;无新增时不落盘
20
20
  //(避免无谓写盘与 mtime 抖动)。App 缓存一律只读。
21
+ //
22
+ // ⚠️ 硬约束(2026-09-30 补):目标文件**必须是顶层 JSON 对象**才动它。桌面端
23
+ // WorkBuddy 自己那份 `models.json` 顶层是**数组**(LanguageModel[]),与 CLI 的
24
+ // `{availableModels, models}` 不是同一个格式。若把数组当"解析失败的空文件"整体写回,
25
+ // 就是把对方的模型清单替换成只剩 custom-local 几条 —— 官方模型全丢且不可回滚。
26
+ // 撞上这种文件一律**跳过并在 stderr 说明**;覆盖写之前另留 `.magic-agent.bak` 原文。
21
27
 
22
28
  import (
23
29
  "encoding/json"
24
30
  "errors"
31
+ "fmt"
25
32
  "io/fs"
26
33
  "os"
27
34
  "path/filepath"
@@ -32,6 +39,10 @@ import (
32
39
  // customModelsFileName CLI 读自定义模型端点的文件名(见 CLI 的 getModelsPath)。
33
40
  const customModelsFileName = "models.json"
34
41
 
42
+ // customModelsBackupSuffix 覆盖写之前留的原文备份后缀。
43
+ // 一次误写就能把模型清单清空,留一份可回滚的东西。
44
+ const customModelsBackupSuffix = ".magic-agent.bak"
45
+
35
46
  // customModelIDPrefix 自定义模型 id 前缀(App 与 CLI 共用这个约定)。
36
47
  const customModelIDPrefix = "custom-local:"
37
48
 
@@ -53,15 +64,29 @@ func syncCustomModelsFromApp(accConfigPath, configDir string) (int, error) {
53
64
  }
54
65
 
55
66
  path := filepath.Join(configDir, customModelsFileName)
67
+ var raw0 []byte // 改动前的原文(覆盖写之前留备份)
56
68
  root := map[string]any{}
57
69
  switch b, err := os.ReadFile(path); {
58
70
  case err == nil:
59
- // 解析失败就当空文件:CLI 自己也会这么兜(readModelsJson 失败返回 {models:[]})。
60
- // 但**不覆盖**未识别的字段 —— 解析失败时下面只会写回 models/availableModels。
61
- _ = json.Unmarshal(b, &root)
62
- if root == nil {
63
- root = map[string]any{}
71
+ raw0 = b
72
+ /* ⚠️ 只有**顶层是 JSON 对象**才认作 CLI 的 models.json。
73
+ 桌面端(WorkBuddy)自己那份 models.json 顶层是**数组**(LanguageModel[]),
74
+ 数组解析不进 map[string]any。以前这里把"解析失败"当空文件继续写回
75
+ {models, availableModels} —— 对数组格式来说那等于把对方的模型清单**整体替换**掉:
76
+ 官方模型全没了,且不可恢复(原注释里"解析失败时不覆盖未识别的字段"对数组不成立,
77
+ 数组根本装不下未知字段)。这种文件不是 CLI 的格式,一律不动。 */
78
+ var doc any
79
+ if err := json.Unmarshal(b, &doc); err != nil {
80
+ fmt.Fprintf(os.Stderr, "magic-agent: 跳过自定义模型端点同步:%s 不是合法 JSON(保持原文件不动)\n", path)
81
+ return 0, nil
82
+ }
83
+ m, ok := doc.(map[string]any)
84
+ if !ok {
85
+ fmt.Fprintf(os.Stderr, "magic-agent: 跳过自定义模型端点同步:%s 顶层不是 JSON 对象"+
86
+ "(该文件不是 CLI 的 models.json 格式,覆盖写会毁掉里面的模型清单;保持原文件不动)\n", path)
87
+ return 0, nil
64
88
  }
89
+ root = m
65
90
  case errors.Is(err, fs.ErrNotExist):
66
91
  // 首次同步:新建
67
92
  default:
@@ -126,6 +151,10 @@ func syncCustomModelsFromApp(accConfigPath, configDir string) (int, error) {
126
151
  return 0, err
127
152
  }
128
153
  // 含第三方 apiKey:与 CLI 自身凭据同级(仅属主可读)。
154
+ // 覆盖写之前留一份原文备份:一次误写就能清空模型清单,必须可回滚。
155
+ if len(raw0) > 0 {
156
+ _ = os.WriteFile(path+customModelsBackupSuffix, raw0, 0o600)
157
+ }
129
158
  if err := os.WriteFile(path, b, 0o600); err != nil {
130
159
  return 0, err
131
160
  }
@@ -195,3 +224,120 @@ func (c codebuddyCore) effectiveConfigDir() string {
195
224
  }
196
225
  return c.configDir
197
226
  }
227
+
228
+ // ── 事故还原:把被覆盖写坏的桌面端 models.json 修回来 ──────────
229
+ //
230
+ // 背景(2026-09-30 Windows 事故):Windows 上 CODEBUDDY_CONFIG_DIR 可能指向桌面端
231
+ // 数据目录,于是上面的同步把桌面端那份**数组格式**的 models.json 覆盖成了
232
+ // `{availableModels, models}` 对象格式。桌面端解析不了 → 模型清单整个为空,
233
+ // 用户看到「WorkBuddy 的模型都丢失了」。
234
+ //
235
+ // 还原依据(实测比对同一台机器上的两份文件):
236
+ //
237
+ // 桌面端数组条目 {id:"MiniMax-M3", name, vendor, url, apiKey, ...}
238
+ // 我们写的对象条目 {id:"custom-local:MiniMax-M3", name, vendor, url, apiKey, ...}
239
+ //
240
+ // 即**同一批条目、id 差一个 custom-local: 前缀**。所以还原 = 把对象里的 models[]
241
+ // 原样搬成数组,只去掉 id 前缀。官方模型清单不在这个文件里(那是 product.json /
242
+ // acc 缓存的事,桌面端自己拉),所以这里不需要、也不该造官方条目。
243
+
244
+ // DesktopAppHomeOf 返回引擎对应的桌面端数据目录名(空 = 该引擎没有桌面端)。
245
+ func DesktopAppHomeOf(engine string) string {
246
+ switch strings.ToLower(strings.TrimSpace(engine)) {
247
+ case "codebuddy":
248
+ return ".workbuddy"
249
+ case "codebuddy-ai":
250
+ return ".workbuddy-ai"
251
+ }
252
+ return ""
253
+ }
254
+
255
+ // DesktopEngines 列出有桌面端数据目录的引擎(`--repair-models` 的可用值,供提示与校验共用)。
256
+ func DesktopEngines() []string { return []string{"codebuddy", "codebuddy-ai"} }
257
+
258
+ // RepairDesktopModels 修复被反向同步写坏的桌面端 models.json。
259
+ // 返回(是否真的做了修复, 给人看的说明, 错误)。没有可修的东西时返回 (false, 说明, nil)。
260
+ func RepairDesktopModels(engine string) (bool, string, error) {
261
+ home := DesktopAppHomeOf(engine)
262
+ if home == "" {
263
+ return false, "", fmt.Errorf(
264
+ "引擎 %q 没有对应的桌面端数据目录,没有可修的 models.json(支持:%s)",
265
+ strings.TrimSpace(engine), strings.Join(DesktopEngines(), " / "))
266
+ }
267
+ dir, err := os.UserHomeDir()
268
+ if err != nil || dir == "" {
269
+ dir = os.Getenv("HOME")
270
+ }
271
+ if dir == "" {
272
+ return false, "", fmt.Errorf("定位用户目录失败,无法找到桌面端 models.json")
273
+ }
274
+ return repairModelsFile(filepath.Join(dir, home))
275
+ }
276
+
277
+ // repairModelsFile 就地把 appHome/models.json 从「被覆盖写的对象」还原成桌面端数组。
278
+ //
279
+ // 只认**明确的坏形状**(顶层对象 + models[] 全是 custom-local:* 条目)才动手:
280
+ // 已经是数组、不是合法 JSON、或形状不认识,一律原样不动并说明原因。
281
+ func repairModelsFile(appHome string) (bool, string, error) {
282
+ path := filepath.Join(appHome, customModelsFileName)
283
+ b, err := os.ReadFile(path)
284
+ switch {
285
+ case errors.Is(err, fs.ErrNotExist):
286
+ return false, fmt.Sprintf("%s 不存在,无需修复", path), nil
287
+ case err != nil:
288
+ return false, "", err
289
+ }
290
+
291
+ var doc any
292
+ if uerr := json.Unmarshal(b, &doc); uerr != nil {
293
+ return false, fmt.Sprintf("%s 不是合法 JSON,形状对不上,未改动", path), nil
294
+ }
295
+ if _, isArr := doc.([]any); isArr {
296
+ return false, fmt.Sprintf("%s 已是桌面端的数组格式,无需修复", path), nil
297
+ }
298
+ root, ok := doc.(map[string]any)
299
+ if !ok {
300
+ return false, fmt.Sprintf("%s 顶层既不是数组也不是对象,未改动", path), nil
301
+ }
302
+ raw, _ := root["models"].([]any)
303
+ if len(raw) == 0 {
304
+ return false, fmt.Sprintf("%s 里没有 models 数组,不像被我们覆盖写的形状,未改动", path), nil
305
+ }
306
+
307
+ out := make([]any, 0, len(raw))
308
+ for _, it := range raw {
309
+ m, ok := it.(map[string]any)
310
+ if !ok {
311
+ return false, fmt.Sprintf("%s 的 models 里混有非对象条目,未改动", path), nil
312
+ }
313
+ id, _ := m["id"].(string)
314
+ if !strings.HasPrefix(id, customModelIDPrefix) {
315
+ return false, fmt.Sprintf("%s 的 models 里混有非 %s 条目(可能不是我们写坏的),未改动",
316
+ path, customModelIDPrefix), nil
317
+ }
318
+ cp := make(map[string]any, len(m))
319
+ for k, v := range m {
320
+ cp[k] = v
321
+ }
322
+ cp["id"] = strings.TrimPrefix(id, customModelIDPrefix)
323
+ out = append(out, cp)
324
+ }
325
+
326
+ nb, merr := json.MarshalIndent(out, "", " ")
327
+ if merr != nil {
328
+ return false, "", merr
329
+ }
330
+ nb = append(nb, '\n')
331
+
332
+ // 先把现场(被写坏的那份)留一份,再落盘。
333
+ broken := path + ".broken.bak"
334
+ if werr := os.WriteFile(broken, b, 0o600); werr != nil {
335
+ return false, "", fmt.Errorf("写备份 %s 失败: %w", broken, werr)
336
+ }
337
+ if werr := os.WriteFile(path, nb, 0o600); werr != nil {
338
+ return false, "", werr
339
+ }
340
+ return true, fmt.Sprintf(
341
+ "已还原 %s:%d 条自定义模型改回桌面端的数组格式(id 去掉 %s 前缀);原文件备份在 %s",
342
+ path, len(out), customModelIDPrefix, broken), nil
343
+ }
@@ -9,8 +9,11 @@ package agent
9
9
  // 2. 幂等:无新增时**不落盘**(内容逐字节不变)
10
10
  // 3. 只增不改:既有条目与未识别字段原样保留;availableModels 追加不丢
11
11
  // 4. 安全兜底:App 缓存缺失 / 条目 disabled → 不建文件、不报错
12
+ // 5. 陌生文件一律不碰:顶层不是 JSON 对象(桌面端是数组)/ 坏 JSON → 逐字节保持原样,
13
+ // 且覆盖写之前一定留 .magic-agent.bak(见 2026-09-30 Windows 事故用例)
12
14
 
13
15
  import (
16
+ "bytes"
14
17
  "encoding/json"
15
18
  "os"
16
19
  "path/filepath"
@@ -182,11 +185,112 @@ func TestSyncCustomModelsFromApp_IdempotentAndMergeOnly(t *testing.T) {
182
185
  }
183
186
  }
184
187
 
185
- func TestSyncCustomModelsFromApp_NoSourceNoFile(t *testing.T) {
188
+ // 目标文件顶层不是 JSON 对象(桌面端 WorkBuddy 的 models.json 就是**数组**)时,
189
+ // 必须**一个字都不动**、也不留备份(因为压根没写)。
190
+ //
191
+ // 为什么是关键回归:以前把"解析不进 map"当空文件继续写回 {models, availableModels},
192
+ // 等于把对方的模型清单整体替换成只剩 custom-local 几条 —— 官方模型全丢且不可回滚。
193
+ func TestSyncCustomModelsFromApp_RefusesNonObjectFile(t *testing.T) {
194
+ // 桌面端 models.json 的真实形状:顶层数组,条目是 LanguageModel。
195
+ desktop := []byte(`[
196
+ {
197
+ "id": "MiniMax-M3",
198
+ "name": "MiniMax-M3",
199
+ "vendor": "MiniMax",
200
+ "url": "https://api.minimaxi.com/v1/chat/completions",
201
+ "apiKey": "sk-user-own"
202
+ },
203
+ {
204
+ "id": "glm-5.3",
205
+ "name": "glm-5.3",
206
+ "url": "https://open.bigmodel.cn/api/paas/v4",
207
+ "apiKey": "sk-user-own-2"
208
+ }
209
+ ]`)
210
+
211
+ cases := []struct {
212
+ name string
213
+ content []byte
214
+ }{
215
+ {"顶层是数组(桌面端格式)", desktop},
216
+ {"坏 JSON", []byte("{oops")},
217
+ {"顶层是字符串", []byte(`"just a string"`)},
218
+ {"顶层是数字", []byte(`42`)},
219
+ }
220
+
221
+ for _, tc := range cases {
222
+ t.Run(tc.name, func(t *testing.T) {
223
+ dir := t.TempDir()
224
+ acc := filepath.Join(dir, "acc.json")
225
+ writeJSON(t, acc, appCacheFixture())
226
+ cfg := filepath.Join(dir, "config")
227
+ if err := os.MkdirAll(cfg, 0o700); err != nil {
228
+ t.Fatal(err)
229
+ }
230
+ path := filepath.Join(cfg, customModelsFileName)
231
+ if err := os.WriteFile(path, tc.content, 0o600); err != nil {
232
+ t.Fatal(err)
233
+ }
234
+
235
+ added, err := syncCustomModelsFromApp(acc, cfg)
236
+ if err != nil {
237
+ t.Errorf("应静默跳过,不该报错: %v", err)
238
+ }
239
+ if added != 0 {
240
+ t.Errorf("added = %d, want 0", added)
241
+ }
242
+
243
+ after, err := os.ReadFile(path)
244
+ if err != nil {
245
+ t.Fatalf("原文件必须还在: %v", err)
246
+ }
247
+ if !bytes.Equal(after, tc.content) {
248
+ t.Errorf("原文件被改写了!\nbefore=%s\nafter =%s", tc.content, after)
249
+ }
250
+ if _, err := os.Stat(path + customModelsBackupSuffix); !os.IsNotExist(err) {
251
+ t.Errorf("没写就不该留备份")
252
+ }
253
+ })
254
+ }
255
+ }
256
+
257
+ // 真的要覆盖写时,必须先把原文留成 <models.json>.magic-agent.bak(可回滚)。
258
+ func TestSyncCustomModelsFromApp_BacksUpBeforeOverwrite(t *testing.T) {
186
259
  dir := t.TempDir()
260
+ acc := filepath.Join(dir, "acc.json")
261
+ writeJSON(t, acc, appCacheFixture())
187
262
  cfg := filepath.Join(dir, "config")
263
+ if err := os.MkdirAll(cfg, 0o700); err != nil {
264
+ t.Fatal(err)
265
+ }
188
266
  path := filepath.Join(cfg, customModelsFileName)
189
267
 
268
+ before := []byte("{\n \"models\": [],\n \"availableModels\": [\"hy3\"]\n}\n")
269
+ if err := os.WriteFile(path, before, 0o600); err != nil {
270
+ t.Fatal(err)
271
+ }
272
+
273
+ added, err := syncCustomModelsFromApp(acc, cfg)
274
+ if err != nil {
275
+ t.Fatal(err)
276
+ }
277
+ if added == 0 {
278
+ t.Fatal("本次应有新增(触发落盘)")
279
+ }
280
+
281
+ bak, err := os.ReadFile(path + customModelsBackupSuffix)
282
+ if err != nil {
283
+ t.Fatalf("覆盖写之前应留备份: %v", err)
284
+ }
285
+ if !bytes.Equal(bak, before) {
286
+ t.Errorf("备份内容应为改动前的原文\nwant=%s\ngot =%s", before, bak)
287
+ }
288
+ }
289
+
290
+ func TestSyncCustomModelsFromApp_NoSourceNoFile(t *testing.T) {
291
+ dir := t.TempDir()
292
+ cfg := filepath.Join(dir, "config")
293
+ path := filepath.Join(cfg, customModelsFileName)
190
294
  cases := []struct{ name, acc string }{
191
295
  {"acc 路径为空", ""},
192
296
  {"acc 文件不存在", filepath.Join(dir, "nope.json")},
@@ -221,7 +325,17 @@ func TestSyncCustomModelsFromApp_NoSourceNoFile(t *testing.T) {
221
325
  }
222
326
  }
223
327
 
224
- func TestSyncCustomModelsFromApp_CorruptExistingFileRebuilt(t *testing.T) {
328
+ /* 既有 models.json 无法确认为「CLI 自己的格式」时,一律**不碰**,而不是重建。
329
+ *
330
+ * 这条契约是 2026-09-30 改的(Windows 事故)。原行为是「解析失败当空文件,重建」,
331
+ * 理由看起来无害(CLI 自己也这么兜底)。实际后果是把**别人的**文件整体替换掉:
332
+ * 桌面端 WorkBuddy 的 models.json 顶层是数组,数组解析不进 map[string]any,
333
+ * 于是被当成「坏文件」重建 → 里面 61 条官方模型全没了,只剩 8 条 custom-local,
334
+ * 而且没有备份,不可回滚。
335
+ *
336
+ * 宁可少同步一次端点(CLI 会自己报 "has no endpoint url configured"),
337
+ * 也不要毁掉一份不是我们管的配置。 */
338
+ func TestSyncCustomModelsFromApp_CorruptExistingFileLeftUntouched(t *testing.T) {
225
339
  dir := t.TempDir()
226
340
  acc := filepath.Join(dir, "acc.json")
227
341
  writeJSON(t, acc, appCacheFixture())
@@ -229,16 +343,25 @@ func TestSyncCustomModelsFromApp_CorruptExistingFileRebuilt(t *testing.T) {
229
343
  if err := os.MkdirAll(cfg, 0o700); err != nil {
230
344
  t.Fatal(err)
231
345
  }
232
- // 既有 models.json 坏了:应重建(CLI 自己也这么兜:读失败当空),且不崩
233
- if err := os.WriteFile(filepath.Join(cfg, customModelsFileName), []byte("{oops"), 0o600); err != nil {
346
+ path := filepath.Join(cfg, customModelsFileName)
347
+ broken := []byte("{oops")
348
+ if err := os.WriteFile(path, broken, 0o600); err != nil {
234
349
  t.Fatal(err)
235
350
  }
351
+
236
352
  added, err := syncCustomModelsFromApp(acc, cfg)
237
353
  if err != nil {
238
- t.Fatalf("坏文件应可恢复: %v", err)
354
+ t.Fatalf("不认识的文件应静默跳过,不该报错: %v", err)
239
355
  }
240
- if added != 2 {
241
- t.Errorf("added = %d, want 2", added)
356
+ if added != 0 {
357
+ t.Errorf("added = %d, want 0(不碰未知格式)", added)
358
+ }
359
+ after, rerr := os.ReadFile(path)
360
+ if rerr != nil {
361
+ t.Fatalf("原文件必须还在: %v", rerr)
362
+ }
363
+ if !bytes.Equal(after, broken) {
364
+ t.Errorf("原文件被改写了:%s", after)
242
365
  }
243
366
  }
244
367
 
@@ -80,9 +80,17 @@ func ChildEnvWith(extraEnv []string) []string {
80
80
  }
81
81
 
82
82
  // envDenied 判断某环境变量是否不允许传给子 CLI。
83
+ //
84
+ // ⚠️ 大小写不敏感(2026-09-30 修,Windows 事故):环境变量名在 **Windows 上本身
85
+ // 不区分大小写**,父会话用 `server__port` / `Server__Port` 这类大小写写出来时,
86
+ // 原来的逐字符比较会**漏剔除** —— 子 CLI 于是仍读到父进程的监听端口,再起一个服务
87
+ // 撞上 EADDRINUSE,表现为「一进登录界面就卡死 / 嵌套调用永远没结果」。
88
+ // Unix 上变量名区分大小写,折叠大小写只会**更保守**(多剔除几个父会话风格的名字),
89
+ // 不会少剔除,故这里两个平台统一按大写比较。
83
90
  func envDenied(name string) bool {
84
- if strings.HasPrefix(name, serverEnvPrefix) {
91
+ upper := strings.ToUpper(name)
92
+ if strings.HasPrefix(upper, serverEnvPrefix) {
85
93
  return true
86
94
  }
87
- return envDenylist[name]
95
+ return envDenylist[upper]
88
96
  }
@@ -86,6 +86,43 @@ func TestEnvironStripsPythonVars(t *testing.T) {
86
86
  }
87
87
  }
88
88
 
89
+ // Windows 的变量名不区分大小写:父会话若写成混合大小写,也必须被剔除。
90
+ // 2026-09-30 事故:漏剔除 → 子 CLI 仍读到父进程端口 → EADDRINUSE 永久挂起,
91
+ // 表现就是「一进登录界面就上不去」。
92
+ func TestEnvDeniedCaseInsensitive(t *testing.T) {
93
+ cases := map[string]bool{
94
+ "server__port": true,
95
+ "Server__Port": true,
96
+ "server__host": true,
97
+ "codebuddy_session_id": true,
98
+ "Codebuddy_Session_Id": true,
99
+ "pythonhome": true,
100
+ "PATH": false,
101
+ "HOME": false,
102
+ // 仍必须放行:引擎靠它选配置目录(见 codebuddyAccountEnv)
103
+ "codebuddy_config_dir": false,
104
+ }
105
+ for name, want := range cases {
106
+ if got := envDenied(name); got != want {
107
+ t.Errorf("envDenied(%q) = %v, want %v", name, got, want)
108
+ }
109
+ }
110
+ }
111
+
112
+ // 混合大小写的父会话变量在 environ() 里也必须不见。
113
+ func TestEnvironStripsMixedCaseServerPrefix(t *testing.T) {
114
+ t.Setenv("server__port", "58311")
115
+ t.Setenv("Server__Host", "127.0.0.1")
116
+ t.Setenv("codebuddy_session_id", "should-be-stripped")
117
+
118
+ for _, kv := range environ() {
119
+ name, _, _ := strings.Cut(kv, "=")
120
+ if envDenied(name) {
121
+ t.Errorf("被拒绝的变量仍出现在子 CLI 环境里: %s", kv)
122
+ }
123
+ }
124
+ }
125
+
89
126
  func TestEnvDenied(t *testing.T) {
90
127
  cases := map[string]bool{
91
128
  "SERVER__PORT": true,
@@ -0,0 +1,194 @@
1
+ package agent
2
+
3
+ // repair_models_test.go - 事故还原(--repair-models 的落地逻辑,见 custom_models.go)。
4
+ //
5
+ // 场景来自 2026-09-30 Windows 事故:桌面端 models.json 是**数组**,被反向同步覆盖成
6
+ // `{availableModels, models}` 对象后桌面端解析不了,模型清单整个为空。
7
+ // 这里锁住三件事:
8
+ // 1. 明确的坏形状 → 还原成数组,id 去掉 custom-local: 前缀,且先留 .broken.bak
9
+ // 2. 已经是数组 → 一个字都不动
10
+ // 3. 形状对不上(坏 JSON / 空对象 / models 里混着非 custom-local 条目)→ 一律不动
11
+
12
+ import (
13
+ "bytes"
14
+ "encoding/json"
15
+ "os"
16
+ "path/filepath"
17
+ "strings"
18
+ "testing"
19
+ )
20
+
21
+ // clobberedFixture 造一份「被我们覆盖写坏」的 models.json:顶层对象 + 全 custom-local 条目。
22
+ func clobberedFixture() []byte {
23
+ return []byte(`{
24
+ "availableModels": [
25
+ "custom-local:MiniMax-M3",
26
+ "custom-local:glm-5.3"
27
+ ],
28
+ "models": [
29
+ {
30
+ "id": "custom-local:MiniMax-M3",
31
+ "name": "MiniMax-M3",
32
+ "vendor": "MiniMax",
33
+ "url": "https://api.minimaxi.com/v1/chat/completions",
34
+ "apiKey": "sk-user-own",
35
+ "supportsImages": false
36
+ },
37
+ {
38
+ "id": "custom-local:glm-5.3",
39
+ "name": "glm-5.3",
40
+ "vendor": "Zhipu",
41
+ "url": "https://open.bigmodel.cn/api/paas/v4",
42
+ "apiKey": "sk-user-own-2"
43
+ }
44
+ ]
45
+ }
46
+ `)
47
+ }
48
+
49
+ func TestRepairModelsFile_RestoresArrayFormat(t *testing.T) {
50
+ appHome := t.TempDir()
51
+ path := filepath.Join(appHome, customModelsFileName)
52
+ if err := os.WriteFile(path, clobberedFixture(), 0o600); err != nil {
53
+ t.Fatal(err)
54
+ }
55
+
56
+ repaired, note, err := repairModelsFile(appHome)
57
+ if err != nil {
58
+ t.Fatalf("repair: %v", err)
59
+ }
60
+ if !repaired {
61
+ t.Fatalf("坏形状应被还原,note=%q", note)
62
+ }
63
+
64
+ b, err := os.ReadFile(path)
65
+ if err != nil {
66
+ t.Fatal(err)
67
+ }
68
+ var arr []map[string]any
69
+ if err := json.Unmarshal(b, &arr); err != nil {
70
+ t.Fatalf("还原后应是数组: %v(内容 %s)", err, b)
71
+ }
72
+ if len(arr) != 2 {
73
+ t.Fatalf("条数 = %d, want 2", len(arr))
74
+ }
75
+ for _, m := range arr {
76
+ id, _ := m["id"].(string)
77
+ if id == "" || strings.HasPrefix(id, customModelIDPrefix) {
78
+ t.Errorf("id 必须去掉 %s 前缀,实际 %q", customModelIDPrefix, id)
79
+ }
80
+ }
81
+ if arr[0]["id"] != "MiniMax-M3" {
82
+ t.Errorf("首条 id = %v, want MiniMax-M3", arr[0]["id"])
83
+ }
84
+ // 端点与密钥必须原样保留(丢了就只能手抄)
85
+ if arr[0]["url"] != "https://api.minimaxi.com/v1/chat/completions" || arr[0]["apiKey"] != "sk-user-own" {
86
+ t.Errorf("端点/密钥没保留: %v", arr[0])
87
+ }
88
+
89
+ // 现场必须留备份,且备份是坏的那份原文
90
+ bak, err := os.ReadFile(path + ".broken.bak")
91
+ if err != nil {
92
+ t.Fatalf("应留 .broken.bak: %v", err)
93
+ }
94
+ if !bytes.Equal(bak, clobberedFixture()) {
95
+ t.Error(".broken.bak 应为被写坏的原文")
96
+ }
97
+ }
98
+
99
+ func TestRepairModelsFile_LeavesGoodShapesAlone(t *testing.T) {
100
+ // 桌面端原生数组格式
101
+ native := []byte("[\n {\n \"id\": \"MiniMax-M3\",\n \"url\": \"https://x/v1\"\n }\n]\n")
102
+
103
+ cases := []struct {
104
+ name string
105
+ content []byte
106
+ }{
107
+ {"已是数组(桌面端原生格式)", native},
108
+ {"坏 JSON", []byte("{oops")},
109
+ {"空 JSON 对象", []byte("{}")},
110
+ {"顶层是字符串", []byte(`"nope"`)},
111
+ {"models 里混着非 custom-local 条目", []byte(`{"models":[{"id":"hy3","url":"https://x"}]}`)},
112
+ {"models 是空数组", []byte(`{"models":[],"availableModels":[]}`)},
113
+ }
114
+
115
+ for _, tc := range cases {
116
+ t.Run(tc.name, func(t *testing.T) {
117
+ appHome := t.TempDir()
118
+ path := filepath.Join(appHome, customModelsFileName)
119
+ if err := os.WriteFile(path, tc.content, 0o600); err != nil {
120
+ t.Fatal(err)
121
+ }
122
+
123
+ repaired, _, err := repairModelsFile(appHome)
124
+ if err != nil {
125
+ t.Fatalf("形状对不上应静默不改,不该报错: %v", err)
126
+ }
127
+ if repaired {
128
+ t.Errorf("形状对不上不该动手")
129
+ }
130
+ after, rerr := os.ReadFile(path)
131
+ if rerr != nil {
132
+ t.Fatal(rerr)
133
+ }
134
+ if !bytes.Equal(after, tc.content) {
135
+ t.Errorf("原文件被改写了\nbefore=%s\nafter =%s", tc.content, after)
136
+ }
137
+ if _, serr := os.Stat(path + ".broken.bak"); !os.IsNotExist(serr) {
138
+ t.Errorf("没还原就不该留 .broken.bak")
139
+ }
140
+ })
141
+ }
142
+ }
143
+
144
+ func TestRepairModelsFile_MissingFileIsNoop(t *testing.T) {
145
+ appHome := t.TempDir()
146
+ repaired, note, err := repairModelsFile(appHome)
147
+ if err != nil {
148
+ t.Fatalf("没有文件不该报错: %v", err)
149
+ }
150
+ if repaired {
151
+ t.Error("没有文件时不谈还原")
152
+ }
153
+ if note == "" {
154
+ t.Error("应给出「不存在、无需修复」的说明,不静默")
155
+ }
156
+ }
157
+
158
+ // 幂等:还原过一次之后,再跑一次必须什么都不做(已经是数组)。
159
+ func TestRepairModelsFile_Idempotent(t *testing.T) {
160
+ appHome := t.TempDir()
161
+ path := filepath.Join(appHome, customModelsFileName)
162
+ if err := os.WriteFile(path, clobberedFixture(), 0o600); err != nil {
163
+ t.Fatal(err)
164
+ }
165
+ if repaired, _, err := repairModelsFile(appHome); err != nil || !repaired {
166
+ t.Fatalf("首次应还原: repaired=%v err=%v", repaired, err)
167
+ }
168
+ first, err := os.ReadFile(path)
169
+ if err != nil {
170
+ t.Fatal(err)
171
+ }
172
+
173
+ repaired, _, err := repairModelsFile(appHome)
174
+ if err != nil {
175
+ t.Fatal(err)
176
+ }
177
+ if repaired {
178
+ t.Error("第二次不该再动手")
179
+ }
180
+ second, err := os.ReadFile(path)
181
+ if err != nil {
182
+ t.Fatal(err)
183
+ }
184
+ if !bytes.Equal(first, second) {
185
+ t.Error("第二次不该改动文件")
186
+ }
187
+ }
188
+
189
+ // 引擎名到桌面端数据目录的映射:不认识的名字必须报错,而不是猜一个目录去写。
190
+ func TestRepairDesktopModels_UnknownEngine(t *testing.T) {
191
+ if _, _, err := RepairDesktopModels("no-such-engine"); err == nil {
192
+ t.Fatal("没有桌面端目录的引擎应报错")
193
+ }
194
+ }
@@ -44,24 +44,27 @@ import (
44
44
 
45
45
  // askOptions 根命令的全部参数。
46
46
  type askOptions struct {
47
- engine string
48
- model string
49
- system string
50
- prompt string
51
- file string
52
- attach []string // 附件(截图/图片)路径;与提示词一起发给引擎
53
- tools string
54
- timeout time.Duration
55
- retries int
56
- backoff time.Duration
57
- output string
58
- verbose bool
59
- engines bool
60
- contract bool // 输出带版本的契约 envelope(--contract;客户端启动校验用)
61
- noModels bool
62
- login string // 拉起某引擎自己的交互式登录会话(--login <engine>)
63
- events bool // --stream 的事件流带契约版本与行号(--events;客户端消费用)
64
- control bool // 从 stdin 读 NDJSON 控制命令(--control;打断 / 收工 / 回审批)
47
+ engine string
48
+ model string
49
+ system string
50
+ prompt string
51
+ file string
52
+ attach []string // 附件(截图/图片)路径;与提示词一起发给引擎
53
+ tools string
54
+ timeout time.Duration
55
+ retries int
56
+ backoff time.Duration
57
+ output string
58
+ verbose bool
59
+ engines bool
60
+ contract bool // 输出带版本的契约 envelope(--contract;客户端启动校验用)
61
+ noModels bool
62
+ login string // 拉起某引擎自己的交互式登录会话(--login <engine>)
63
+ // repairModels 还原被反向同步写坏的桌面端 models.json(--repair-models <engine>):
64
+ // 2026-09-30 Windows 事故的收尾手段,见 internal/agent/custom_models.go 的事故还原一节。
65
+ repairModels string
66
+ events bool // --stream 的事件流带契约版本与行号(--events;客户端消费用)
67
+ control bool // 从 stdin 读 NDJSON 控制命令(--control;打断 / 收工 / 回审批)
65
68
  jsonOut bool
66
69
  stream bool
67
70
  noThinking bool
@@ -145,6 +148,7 @@ func bindAskFlags(cmd *cobra.Command, opts *askOptions) {
145
148
  f.BoolVar(&opts.contract, "contract", false, "输出桌面/移动客户端契约:{\"contractVersion\":N,\"engines\":[...]}(engines 与 --engines 同构,另含 capabilities 静态能力字段)。默认不探测模型(快);要模型写 --no-models=false")
146
149
  f.BoolVar(&opts.noModels, "no-models", false, "配合 --engines / --contract:跳过各引擎的模型探测(只列引擎与可用性,不启动 CLI)")
147
150
  f.StringVar(&opts.login, "login", "", "拉起指定引擎自己的交互式登录会话(如 codebuddy / codebuddy-ai):自动带上该引擎的账号环境,进去执行 /login 即可;登录态落在该引擎自己的票据上,两个账号互不顶号")
151
+ f.StringVar(&opts.repairModels, "repair-models", "", "还原被覆盖写坏的桌面端 models.json(如 codebuddy / codebuddy-ai):把对象格式改回桌面端的数组格式,官方模型清单不动;只在形状确实是坏的时候才改,且先留 .broken.bak 备份")
148
152
  f.BoolVar(&opts.jsonOut, "json", false, "兼容保留:--engines 已默认 JSON,本 flag 不再需要")
149
153
  f.BoolVar(&opts.stream, "stream", false, "流式输出:正文/思考增量实时打到 stdout(text 模式思考走 stderr)")
150
154
  f.BoolVar(&opts.events, "events", false, "配合 --stream:事件流带契约版本与行号(每行加 v / seq,并先发一行 ready),供客户端消费;老消费者不要开(形状与 --stream 不同)")
@@ -14,6 +14,7 @@ import (
14
14
  "os"
15
15
  "os/exec"
16
16
  "strings"
17
+ "sync"
17
18
 
18
19
  "github.com/darren/magic-agent/internal/agent"
19
20
  "github.com/spf13/cobra"
@@ -40,7 +41,11 @@ func runLogin(cmd *cobra.Command, opts *askOptions) error {
40
41
  return err
41
42
  }
42
43
 
43
- // stdio 直通:交互式界面必须拿到真的 TTY。
44
+ /* stdio 直通:交互式界面必须拿到真的 TTY。
45
+ ⚠️ 这里**刻意不设进程组**(普通引擎调用走 configureProcAttr 自成一组):
46
+ 交互式 TUI 必须在**前台进程组**里,否则它从 tty 读输入会被 SIGTTIN 停住 ——
47
+ 表现就是「一进登录界面就卡住」。代之以「登记子进程 + 退出前带走」的清理
48
+ (见 loginChild / stopLoginChild),保证它不会变成占着账号的孤儿。 */
44
49
  c := exec.Command(bin, args...)
45
50
  c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
46
51
  c.Env = agent.ChildEnvWith(extraEnv)
@@ -49,20 +54,72 @@ func runLogin(cmd *cobra.Command, opts *askOptions) error {
49
54
  }
50
55
 
51
56
  // 提示走 stderr:stdout 留给子进程的交互界面,不被污染。
57
+ // 第三行是给「上一次登录没成功」的情形:残留的登录态得先清掉,否则会一直登不上。
52
58
  fmt.Fprintf(cmd.ErrOrStderr(),
53
59
  "magic-agent: 已按 %s 的账号环境启动 %s\n"+
54
- " 在里面执行 /login 完成登录(退出后本命令结束)\n", eng.Name(), bin)
60
+ " 在里面执行 /login 完成登录(退出后本命令结束)\n"+
61
+ " 若上一次登录没成功、这次进去仍登不上:先执行 /logout 清掉残留登录态,再执行 /login\n",
62
+ eng.Name(), bin)
55
63
 
56
- if runErr := c.Run(); runErr != nil {
64
+ if startErr := c.Start(); startErr != nil {
65
+ return fmt.Errorf("启动 %s 失败: %w", bin, startErr)
66
+ }
67
+ setLoginChild(c)
68
+ defer clearLoginChild()
69
+
70
+ if runErr := c.Wait(); runErr != nil {
57
71
  var ee *exec.ExitError
58
72
  if errors.As(runErr, &ee) {
59
73
  return fmt.Errorf("%s 会话退出码 %d", eng.Name(), ee.ExitCode())
60
74
  }
61
- return fmt.Errorf("启动 %s 失败: %w", bin, runErr)
75
+ return fmt.Errorf("%s 会话异常: %w", eng.Name(), runErr)
62
76
  }
63
77
  return nil
64
78
  }
65
79
 
80
+ /* ── 交互式登录子进程的清理登记 ──────────────────────────────
81
+ *
82
+ * 为什么单独记一笔:登录是**唯一**不走进程组管理的子进程(理由见上面 runLogin 的
83
+ * 注释 —— 交互式 TUI 必须留在前台组里)。因此它既不在会话登记表(--stop 管不到),
84
+ * 也不会被 installSignalStop 的「杀会话」路径带走。一旦 magic-agent 被 SIGTERM
85
+ * (桌面壳的「停止」按钮、`kill <pid>`)终止,这个 CLI 就成孤儿,继续占着该账号的
86
+ * 配置目录 / 端口,下一次 --login 起来会撞上残留状态 —— 用户看到的就是
87
+ * 「登录失败后再也登不上」。所以退出前必须补一刀。
88
+ */
89
+ var (
90
+ loginMu sync.Mutex
91
+ loginChild *exec.Cmd
92
+ )
93
+
94
+ // setLoginChild 登记正在跑的登录子进程。
95
+ func setLoginChild(c *exec.Cmd) {
96
+ loginMu.Lock()
97
+ loginChild = c
98
+ loginMu.Unlock()
99
+ }
100
+
101
+ // clearLoginChild 注销(登录会话正常结束 / 启动失败时调用)。
102
+ func clearLoginChild() {
103
+ setLoginChild(nil)
104
+ }
105
+
106
+ /* stopLoginChild 杀掉正在跑的登录子进程(若在),返回是否真的动了手。
107
+ *
108
+ * 用**单进程**信号(group=false):它本来就与 magic-agent 同组,负 pid 会把
109
+ * magic-agent 自己一起杀掉。
110
+ */
111
+ func stopLoginChild() bool {
112
+ loginMu.Lock()
113
+ c := loginChild
114
+ loginChild = nil
115
+ loginMu.Unlock()
116
+ if c == nil || c.Process == nil {
117
+ return false
118
+ }
119
+ _ = agent.KillPID(c.Process.Pid, false)
120
+ return true
121
+ }
122
+
66
123
  // loginCapableEngines 列出实现了 LoginRunner 的引擎名(报错提示用)。
67
124
  func loginCapableEngines() string {
68
125
  var names []string
@@ -0,0 +1,99 @@
1
+ package cli
2
+
3
+ // login_test.go - `--login` 的参数校验与「退出前带走登录子进程」的清理契约。
4
+ //
5
+ // 这两件事都是「登录失败后再也登不上」那个用户问题的直接成因:
6
+ // - 参数写错时给一句看得懂的中文(而不是 cobra 的英文裸报错,且退出码要对);
7
+ // - 登录 TUI 必须留在前台进程组里(否则读 tty 被 SIGTTIN 停住),代价是
8
+ // magic-agent 被 SIGTERM / 关终端时必须主动把它带走,不能留成占着账号的孤儿。
9
+
10
+ import (
11
+ "errors"
12
+ "os/exec"
13
+ "runtime"
14
+ "strings"
15
+ "testing"
16
+ "time"
17
+ )
18
+
19
+ // `--login` 后面没跟引擎名:必须给出中文 usage(点名可用引擎),且算参数错(exit 2)。
20
+ // cobra 在解析阶段就报错(走不到 runLogin),所以这条靠 SetFlagErrorFunc 兜住。
21
+ func TestLoginWithoutEngineNameIsUsageError(t *testing.T) {
22
+ _, _, err := runAskCmd(t, "", "--login")
23
+ if err == nil {
24
+ t.Fatal("--login 缺引擎名应报错")
25
+ }
26
+ msg := err.Error()
27
+ if !strings.Contains(msg, "需要引擎名") {
28
+ t.Errorf("应是中文用法提示,实际 %q", msg)
29
+ }
30
+ /* ⚠️ 不断言「提示里点名了 codebuddy」:包内用例会在 Engines() 首次调用前
31
+ agent.Register 假引擎,触发 registry 的已知脆弱点(内置引擎整体不注册,
32
+ 见 agent/engine.go 的守卫注释),那时可用引擎列表本来就是空的。
33
+ 这里只锚定稳定契约:中文提示 + usage(exit 2)。 */
34
+ if _, ok := err.(*usageError); !ok {
35
+ t.Errorf("应是 usageError(exit 2),实际 %T", err)
36
+ }
37
+ }
38
+
39
+ // 未知引擎名:usage 错误 + 列出可用引擎,不静默启一个空 bin。
40
+ func TestLoginUnknownEngine(t *testing.T) {
41
+ _, _, err := runAskCmd(t, "", "--login", "no-such-engine")
42
+ if err == nil {
43
+ t.Fatal("未知引擎应报错")
44
+ }
45
+ if !strings.Contains(err.Error(), "未知引擎") {
46
+ t.Errorf("错误应点名未知引擎,实际 %q", err.Error())
47
+ }
48
+ if _, ok := err.(*usageError); !ok {
49
+ t.Errorf("应是 usageError(exit 2),实际 %T", err)
50
+ }
51
+ }
52
+
53
+ // 不支持交互式登录的引擎:明确说不必用 --login,而不是硬造一条命令。
54
+ func TestLoginEngineWithoutLoginRunner(t *testing.T) {
55
+ _, _, err := runAskCmd(t, "", "--login", "claude")
56
+ if err == nil {
57
+ t.Fatal("claude 不支持 --login,应报错")
58
+ }
59
+ if !strings.Contains(err.Error(), "不需要 --login") {
60
+ t.Errorf("错误应说明该引擎不需要 --login,实际 %q", err.Error())
61
+ }
62
+ }
63
+
64
+ // 登录子进程必须能被 stopLoginChild 收掉(进程真死)。
65
+ // 为什么值得测:登录是唯一不进进程组的子进程,孤儿化正是「登录失败后再也登不上」的成因。
66
+ //
67
+ // 判据用 c.Wait()(真回收)而不是 PIDAlive —— 被 SIGKILL 的子进程在没人 Wait 之前
68
+ // 是僵尸,仍然会被 kill(pid, 0) 判成「活着」,拿它当判据会假红。
69
+ func TestStopLoginChildKillsProcess(t *testing.T) {
70
+ if runtime.GOOS == "windows" {
71
+ t.Skip("用 sleep 当靶子,Windows 另论")
72
+ }
73
+ c := exec.Command("sleep", "60")
74
+ if err := c.Start(); err != nil {
75
+ t.Fatalf("起靶子进程失败: %v", err)
76
+ }
77
+ setLoginChild(c)
78
+
79
+ if !stopLoginChild() {
80
+ t.Fatal("stopLoginChild 应报告动了手")
81
+ }
82
+
83
+ done := make(chan error, 1)
84
+ go func() { done <- c.Wait() }()
85
+ select {
86
+ case werr := <-done:
87
+ var ee *exec.ExitError
88
+ if !errors.As(werr, &ee) {
89
+ t.Fatalf("靶子应被信号杀掉,实际 err=%v", werr)
90
+ }
91
+ case <-time.After(5 * time.Second):
92
+ t.Fatal("登录子进程未被收掉(5s 内没退出)")
93
+ }
94
+
95
+ // 已经清空:再调一次不应再动手(幂等,避免误杀复用的 pid)。
96
+ if stopLoginChild() {
97
+ t.Error("注销后再调 stopLoginChild 不应再动手")
98
+ }
99
+ }
@@ -0,0 +1,60 @@
1
+ package cli
2
+
3
+ // repair.go - `--repair-models <engine>`:还原被反向同步写坏的桌面端 models.json。
4
+ //
5
+ // 为什么要有它:2026-09-30 的 Windows 事故里,magic-agent 把桌面端(WorkBuddy)那份
6
+ // **数组格式**的 models.json 覆盖成了 `{availableModels, models}` 对象格式,桌面端
7
+ // 解析不了 → 模型清单整个为空。同步那一侧已经加了「陌生形状一律不动」的硬约束,
8
+ // 但**已经坏掉的现场**需要一条能自己走回来的路 —— 否则用户只能手抄 apiKey。
9
+ //
10
+ // 还原逻辑在 agent.RepairDesktopModels(见 custom_models.go 的事故还原一节):
11
+ // 只认明确的坏形状才动手,动手前先留 .broken.bak。
12
+
13
+ import (
14
+ "encoding/json"
15
+ "fmt"
16
+ "strings"
17
+
18
+ "github.com/darren/magic-agent/internal/agent"
19
+ "github.com/spf13/cobra"
20
+ )
21
+
22
+ // runRepairModels 处理 `--repair-models <engine>`。
23
+ func runRepairModels(cmd *cobra.Command, opts *askOptions) error {
24
+ name := strings.TrimSpace(opts.repairModels)
25
+ if name == "" {
26
+ return &usageError{fmt.Errorf(
27
+ "--repair-models 需要引擎名:magic-agent --repair-models <engine>(可用:%s)",
28
+ strings.Join(agent.DesktopEngines(), " / "))}
29
+ }
30
+
31
+ // 引擎名先在这里校验:这是**参数错**(exit 2),而不是运行期失败(exit 1)。
32
+ // agent 层还会再兜一次,但那属于「不该发生」的安全网。
33
+ if agent.DesktopAppHomeOf(name) == "" {
34
+ return &usageError{fmt.Errorf("引擎 %q 没有对应的桌面端数据目录,没有可修的 models.json(可用:%s)",
35
+ name, strings.Join(agent.DesktopEngines(), " / "))}
36
+ }
37
+
38
+ repaired, note, err := agent.RepairDesktopModels(name)
39
+ if err != nil {
40
+ return err
41
+ }
42
+
43
+ // 机器可读的一行结果走 stdout(与 --sessions / --stop 同一族);人看的说明走 stderr。
44
+ if eerr := json.NewEncoder(cmd.OutOrStdout()).Encode(map[string]any{
45
+ "engine": name,
46
+ "repaired": repaired,
47
+ "note": note,
48
+ }); eerr != nil {
49
+ return eerr
50
+ }
51
+ out := cmd.ErrOrStderr()
52
+ fmt.Fprintf(out, "magic-agent: %s\n", note)
53
+ if !repaired {
54
+ // 没改动不是失败,但必须说清「没动」,不静默(本项目铁律)。
55
+ fmt.Fprintf(out, "magic-agent: 未改动任何文件。形状对不上就不动,避免二次破坏。\n")
56
+ return nil
57
+ }
58
+ fmt.Fprintf(out, "magic-agent: 已还原。请重开桌面端确认模型清单;官方模型由桌面端自己拉取,不在这个文件里。\n")
59
+ return nil
60
+ }
@@ -0,0 +1,51 @@
1
+ package cli
2
+
3
+ // repair_test.go - `--repair-models` 的参数校验。
4
+ //
5
+ // 落地逻辑(形状识别 / 还原 / 备份 / 幂等)在 internal/agent/repair_models_test.go,
6
+ // 这里只锁 CLI 层的契约:引擎名缺失或不支持桌面端时是**参数错**(exit 2),
7
+ // 而不是被当成 prompt 发给引擎。
8
+
9
+ import (
10
+ "strings"
11
+ "testing"
12
+ )
13
+
14
+ func TestRepairModelsWithoutEngineNameIsUsageError(t *testing.T) {
15
+ _, _, err := runAskCmd(t, "", "--repair-models")
16
+ if err == nil {
17
+ t.Fatal("--repair-models 缺引擎名应报错")
18
+ }
19
+ if !strings.Contains(err.Error(), "需要引擎名") {
20
+ t.Errorf("应是中文用法提示,实际 %q", err.Error())
21
+ }
22
+ if _, ok := err.(*usageError); !ok {
23
+ t.Errorf("应是 usageError(exit 2),实际 %T", err)
24
+ }
25
+ }
26
+
27
+ // 显式给空串同样走校验(与 --login 同一取舍:用「传过没有」而不是「值非空」判断)。
28
+ func TestRepairModelsEmptyEngineIsUsageError(t *testing.T) {
29
+ _, _, err := runAskCmd(t, "", "--repair-models", "")
30
+ if err == nil {
31
+ t.Fatal("空引擎名应报错")
32
+ }
33
+ if _, ok := err.(*usageError); !ok {
34
+ t.Errorf("应是 usageError(exit 2),实际 %T", err)
35
+ }
36
+ }
37
+
38
+ // 没有桌面端数据目录的引擎:参数错,并点名可用值。
39
+ func TestRepairModelsUnsupportedEngine(t *testing.T) {
40
+ _, _, err := runAskCmd(t, "", "--repair-models", "claude")
41
+ if err == nil {
42
+ t.Fatal("claude 没有桌面端 models.json,应报错")
43
+ }
44
+ msg := err.Error()
45
+ if !strings.Contains(msg, "没有对应的桌面端数据目录") {
46
+ t.Errorf("错误应说明该引擎没有桌面端目录,实际 %q", msg)
47
+ }
48
+ if _, ok := err.(*usageError); !ok {
49
+ t.Errorf("应是 usageError(exit 2),实际 %T", err)
50
+ }
51
+ }
@@ -129,6 +129,7 @@ func NewRootCommand() *cobra.Command {
129
129
  magic-agent --engines # 列出引擎、可用性与各引擎支持的模型(JSON 数组)
130
130
  magic-agent --engines --no-models # 只列引擎与可用性(跳过模型探测,不启动 CLI)
131
131
  magic-agent --engines --json # 等价(--json 为兼容保留)
132
+ magic-agent --repair-models codebuddy # 还原被覆盖写坏的桌面端 models.json(事故收尾,先留 .broken.bak)
132
133
 
133
134
  四档权限模型(--permission,仅 claude / codebuddy / codebuddy-ai 生效):
134
135
  把「哪些动作自动放行 + 放行不了时由谁裁决」收敛成四档,映射到 Claude Code 的参数:
@@ -399,6 +400,9 @@ codebuddy-gateway 引擎(CodeBuddy Code HTTP 网关,走 webhook + SSE;凭
399
400
  case flagChanged(cmd, "login"):
400
401
  // 用「传过没有」而不是「值非空」判断:`--login ""` 也要走参数校验(提示要引擎名)
401
402
  return runLogin(cmd, opts)
403
+ case flagChanged(cmd, "repair-models"):
404
+ // 同上:`--repair-models ""` 要走参数校验,而不是掉进「提问」分支报 empty prompt。
405
+ return runRepairModels(cmd, opts)
402
406
  case opts.listSessions:
403
407
  return runSessions(cmd, opts)
404
408
  case flagChanged(cmd, "session-log"):
@@ -426,6 +430,23 @@ codebuddy-gateway 引擎(CodeBuddy Code HTTP 网关,走 webhook + SSE;凭
426
430
  },
427
431
  }
428
432
  bindAskFlags(root, opts)
433
+ /* `--login` / `--repair-models` 忘带引擎名时,cobra 在解析阶段就报错,**走不到**
434
+ runLogin / runRepairModels 的校验分支,默认文案是英文的
435
+ "flag needs an argument: ..."(且退出码 1)。这里翻成与传空串一致的中文
436
+ usage(exit 2),免得用户只看到一句看不懂的报错。 */
437
+ root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error {
438
+ if err == nil || !strings.Contains(err.Error(), "flag needs an argument") {
439
+ return err
440
+ }
441
+ switch {
442
+ case strings.Contains(err.Error(), "login"):
443
+ return &usageError{fmt.Errorf("--login 需要引擎名:magic-agent --login <engine>(可用:%s)", loginCapableEngines())}
444
+ case strings.Contains(err.Error(), "repair-models"):
445
+ return &usageError{fmt.Errorf("--repair-models 需要引擎名:magic-agent --repair-models <engine>(可用:%s)",
446
+ strings.Join(agent.DesktopEngines(), " / "))}
447
+ }
448
+ return err
449
+ })
429
450
  // 无子命令设计:禁用 cobra 自动注入的 completion 命令,
430
451
  // 保证 "magic-agent completion" 也只是被当作 prompt 而非隐藏子命令。
431
452
  root.CompletionOptions.DisableDefaultCmd = true
@@ -72,11 +72,18 @@ func stopActiveSession() {
72
72
  // 为什么必须做:引擎 CLI 在**独立进程组**里(Setpgid),调用方对 magic-agent
73
73
  // 发 SIGTERM / kill(-magic-agent-pid) 时,引擎子进程不在那一组里 → 会变成孤儿继续跑
74
74
  // (观物台「停止」按钮踩的就是这个)。这里在退出前补一刀。
75
+ //
76
+ // 覆盖三处孤儿来源:
77
+ // - 当前活动会话(stopActiveSession);
78
+ // - 交互式登录子进程(stopLoginChild)—— 它必须待在前台组里读 tty,因而
79
+ // 比普通引擎更「自己管不了」,只能在这里记一笔带走(见 login.go);
80
+ // - 关终端(SIGHUP):与 SIGTERM 同路。
75
81
  func installSignalStop() {
76
82
  ch := make(chan os.Signal, 1)
77
- signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
83
+ signal.Notify(ch, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
78
84
  go func() {
79
85
  <-ch
86
+ stopLoginChild()
80
87
  stopActiveSession()
81
88
  os.Exit(exitInterrupted)
82
89
  }()
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magic-agent",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "专业的 agent CLI 代理工具 - 统一 claude / codebuddy / trae / llm / codex / openclaw 引擎",
5
5
  "license": "MIT",
6
6
  "bin": {