wechatbridge-cli 1.3.0__py3-none-any.whl

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.
@@ -0,0 +1,2 @@
1
+ """WeChatBridge — bridge WeChat messages to agy or Grok Build CLIs."""
2
+ __version__ = "1.3.0"
@@ -0,0 +1,4 @@
1
+ from wechatbridge.main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
wechatbridge/agy.py ADDED
@@ -0,0 +1,630 @@
1
+ """
2
+ agy CLI runner with per-user session isolation, output cleanup, and timeout protection.
3
+ """
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ import re
10
+ import shutil
11
+ import signal
12
+ import sys
13
+ import time
14
+
15
+ from .config import config
16
+ from .runner_common import (
17
+ sanitize_user_id, get_session_dir, ensure_session_dir, is_first_message, mark_initialized,
18
+ clean_output, load_prefs, save_prefs, is_dangerous, parse_model_effort,
19
+ sanitize_env, terminate_process, update_active_prefs,
20
+ format_error, format_cli_error, EMPTY_REPLY,
21
+ ANSI_RE, HTML_TAG_RE, validate_add_dir,
22
+ )
23
+
24
+ logger = logging.getLogger("agy_runner")
25
+
26
+
27
+ def extract_artifacts(text: str) -> list[tuple[str, str]]:
28
+ """Extract (name, absolute_path) tuples from markdown file:/// links.
29
+
30
+ Uses regex ``\\[([^\\]]+)\\](file:///([^)]+))`` to find agy-generated
31
+ artifact references in stdout. Returns deduplicated, order-preserved list.
32
+ """
33
+ if not text:
34
+ return []
35
+ seen = set()
36
+ result = []
37
+ for match in re.finditer(r"\[([^\]]+)\]\(file:///([^)]+)\)", text):
38
+ name = match.group(1).split("#")[0]
39
+ abs_path = "/" + match.group(2).split("#")[0]
40
+ key = (name, abs_path)
41
+ if key not in seen:
42
+ seen.add(key)
43
+ result.append(key)
44
+ if result:
45
+ logger.debug("Extracted %d artifacts: %s", len(result), [n for n, _ in result[:3]])
46
+ return result
47
+
48
+
49
+ def ensure_user_gemini(user_id: str) -> str:
50
+ """Ensure per-user .gemini directory with auth token and default persona.
51
+
52
+ Creates session/.gemini/antigravity-cli/ for agy auth and conversations.
53
+ Copies global auth token and default GEMINI.md (persona) on first use.
54
+ Returns session_dir path (for use as HOME when running agy).
55
+ """
56
+ session_dir = ensure_session_dir(user_id)
57
+ gemini_dir = os.path.join(session_dir, ".gemini")
58
+ antigravity_dir = os.path.join(gemini_dir, "antigravity-cli")
59
+ os.makedirs(antigravity_dir, exist_ok=True)
60
+ try:
61
+ os.chmod(gemini_dir, 0o700)
62
+ os.chmod(antigravity_dir, 0o700)
63
+ except OSError:
64
+ pass
65
+
66
+ # Copy global auth token if not yet present
67
+ # agy standard auth token path, managed by agy CLI
68
+ token_src = os.path.expanduser("~/.gemini/antigravity-cli/antigravity-oauth-token")
69
+ token_dst = os.path.join(antigravity_dir, "antigravity-oauth-token")
70
+ if not os.path.exists(token_dst) and os.path.exists(token_src):
71
+ try:
72
+ shutil.copy(token_src, token_dst)
73
+ os.chmod(token_dst, 0o600)
74
+ except OSError as e:
75
+ logger.warning("Failed to copy auth token for %s: %s", user_id, e)
76
+
77
+ # Copy global GEMINI.md persona as default if not yet present
78
+ # agy standard persona file path, managed by agy CLI
79
+ agents_src = os.path.expanduser("~/.gemini/GEMINI.md")
80
+ agents_dst = os.path.join(gemini_dir, "GEMINI.md")
81
+ if not os.path.exists(agents_dst) and os.path.exists(agents_src):
82
+ try:
83
+ shutil.copy(agents_src, agents_dst)
84
+ except OSError as e:
85
+ logger.warning("Failed to copy default GEMINI.md for %s: %s", user_id, e)
86
+
87
+ return session_dir
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # agy CLI execution
92
+ # ---------------------------------------------------------------------------
93
+
94
+ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str, list]:
95
+ """Execute agy CLI for a given user message.
96
+
97
+ - Creates per-user session directory under config.session_base_dir
98
+ - Applies per-user preferences (model, effort, mode, add_dirs) as CLI flags
99
+ - Runs ``agy [flags] -p <prompt>`` for first message,
100
+ ``agy [flags] -c -p <prompt>`` for subsequent messages
101
+ - Extracts artifacts (file:/// links) from stdout and scratch diff
102
+ - Cleans ANSI / HTML tags from display output
103
+ - Kills process group on timeout and returns a friendly message
104
+ - Never adds system prompts or personality instructions
105
+
106
+ Returns:
107
+ tuple[str, list]: (cleaned_display_text, list_of_(name, abs_path)_artifacts)
108
+ """
109
+ if timeout is None:
110
+ timeout = config.agy_timeout
111
+
112
+ t0 = time.time()
113
+ session_dir = ensure_user_gemini(user_id)
114
+
115
+ # Audit logging
116
+ logger.info("[AUDIT] user=%s prompt=%.200s", user_id, prompt)
117
+ if is_dangerous(prompt):
118
+ logger.warning(
119
+ "[AUDIT] dangerous keyword in prompt from user=%s", user_id
120
+ )
121
+
122
+ first = is_first_message(session_dir)
123
+
124
+ # Build command: agy [--model X] [--effort Y] [--mode Z] [--add-dir W ...] [-c] -p <prompt>
125
+ # --dangerously-skip-permissions 保留:可信小圈子用户需 agy 能自动调工具,风险由服务层(root)+输入来源(可信用户)承担
126
+ cmd = [config.agy_binary_path, "--dangerously-skip-permissions"]
127
+ prefs = load_prefs(user_id)
128
+ model = prefs.get("model")
129
+ effort = prefs.get("effort")
130
+ if model:
131
+ base_model, embedded_effort = parse_model_effort(model)
132
+ if embedded_effort and effort:
133
+ # model has effort suffix AND user wants a different effort
134
+ # -> use base model name + --effort from prefs (no conflict)
135
+ cmd += ["--model", base_model, "--effort", effort]
136
+ elif embedded_effort:
137
+ # model has effort suffix, no explicit effort -> model carries it
138
+ cmd += ["--model", model]
139
+ else:
140
+ # plain model name -> pass effort if set
141
+ cmd += ["--model", model]
142
+ if effort:
143
+ cmd += ["--effort", effort]
144
+ elif effort:
145
+ cmd += ["--effort", effort]
146
+ if prefs.get("mode"):
147
+ cmd += ["--mode", prefs["mode"]]
148
+ for d in prefs.get("add_dirs", []):
149
+ if not d:
150
+ continue
151
+ ok, resolved = validate_add_dir(d, user_id)
152
+ if ok:
153
+ cmd += ["--add-dir", resolved]
154
+ else:
155
+ logger.warning("Skipping disallowed add_dir for %s: %s (%s)", user_id, d, resolved)
156
+
157
+ if first:
158
+ logger.info(
159
+ "First message for user %s, running: agy -p ...", user_id
160
+ )
161
+ else:
162
+ cmd += ["-c"]
163
+ logger.info(
164
+ "Continuing conversation for user %s, running: agy -c -p ...",
165
+ user_id,
166
+ )
167
+
168
+ cmd += ["-p", prompt]
169
+
170
+ process = None
171
+ try:
172
+ env = sanitize_env(session_dir)
173
+ env["PAGER"] = "cat"
174
+ env["CI"] = "true"
175
+ env["NONINTERACTIVE"] = "1"
176
+ env["PYTHONUNBUFFERED"] = "1"
177
+ process = await asyncio.create_subprocess_exec(
178
+
179
+ *cmd,
180
+ stdout=asyncio.subprocess.PIPE,
181
+ stderr=asyncio.subprocess.PIPE,
182
+ cwd=session_dir,
183
+ env=env,
184
+ preexec_fn=os.setsid if hasattr(os, "setsid") else None,
185
+ )
186
+
187
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
188
+ process.communicate(),
189
+ timeout=float(timeout),
190
+ )
191
+
192
+ stdout_text = stdout_bytes.decode("utf-8", errors="replace").strip()
193
+ stderr_text = stderr_bytes.decode("utf-8", errors="replace").strip()
194
+
195
+ # A: Extract artifacts from raw stdout (before clean_output!)
196
+ artifacts = extract_artifacts(stdout_text)
197
+
198
+ # B: (disabled) Scratch diff via before/after snapshots is removed because
199
+ # multi-user shared scratch causes cross-user artifact leakage.
200
+ # agy-generated files always produce file:/// links in stdout, so
201
+ # extract_artifacts (A) above is sufficient.
202
+
203
+ # Clean display text
204
+ display = clean_output(stdout_text) or EMPTY_REPLY
205
+
206
+ # Strip file:/// links from display to avoid leaking server paths
207
+ display = re.sub(
208
+ r"\[([^\]]+)\]\(file:///[^)]+\)",
209
+ r"[\1]",
210
+ display,
211
+ )
212
+
213
+ if process.returncode != 0:
214
+ logger.warning(
215
+ "agy exited with code %s for user %s: %.200s",
216
+ process.returncode,
217
+ user_id,
218
+ stderr_text,
219
+ )
220
+ if not stdout_text and stderr_text:
221
+ if "timeout waiting for cascade" in stderr_text.lower() or "timeout waiting for response" in stderr_text.lower():
222
+ logger.warning("Cascade/API timeout detected for user %s, retrying once automatically", user_id)
223
+ retry_process = await asyncio.create_subprocess_exec(
224
+ *cmd,
225
+ stdout=asyncio.subprocess.PIPE,
226
+ stderr=asyncio.subprocess.PIPE,
227
+ cwd=session_dir,
228
+ env=env,
229
+ preexec_fn=os.setsid if hasattr(os, "setsid") else None,
230
+ )
231
+ r_stdout, r_stderr = await asyncio.wait_for(
232
+ retry_process.communicate(),
233
+ timeout=float(timeout),
234
+ )
235
+ r_stdout_text = r_stdout.decode("utf-8", errors="replace").strip()
236
+ r_stderr_text = r_stderr.decode("utf-8", errors="replace").strip()
237
+ # Any useful stdout after retry counts as recovered (agy may exit non-zero)
238
+ if r_stdout_text:
239
+ r_artifacts = extract_artifacts(r_stdout_text)
240
+ r_display = clean_output(r_stdout_text) or EMPTY_REPLY
241
+ r_display = re.sub(r"\[([^\]]+)\]\(file:///[^)]+\)", r"[\1]", r_display)
242
+ if first and r_display != EMPTY_REPLY and not r_display.startswith("❌"):
243
+ mark_initialized(session_dir)
244
+ return r_display, r_artifacts
245
+ return format_error(
246
+ "级联超时",
247
+ "模型 API 级联推理超时,自动重试仍失败。请稍后重试或简化指令。",
248
+ ), []
249
+ return format_cli_error(stderr_text, backend="agy"), []
250
+ # Non-zero exit: never treat raw stdout as a normal success reply
251
+ raw = stderr_text or stdout_text or display or "agy 进程异常退出"
252
+ return format_cli_error(raw, backend="agy"), []
253
+
254
+ # Success path only
255
+ if first and display != EMPTY_REPLY:
256
+ mark_initialized(session_dir)
257
+
258
+ elapsed = time.time() - t0
259
+ logger.info(
260
+ "agy done: user=%s elapsed=%.1fs artifacts=%d output=%d chars",
261
+ user_id, elapsed, len(artifacts), len(display),
262
+ )
263
+ return display, artifacts
264
+
265
+ except asyncio.TimeoutError:
266
+ logger.warning(
267
+ "agy execution timed out after %ss for user %s",
268
+ timeout,
269
+ user_id,
270
+ )
271
+ await terminate_process(process, graceful=True)
272
+ return format_error("处理超时", f"超过 {timeout} 秒未完成,已终止本次任务。"), []
273
+
274
+ except Exception as e:
275
+ logger.exception("Unexpected error running agy: %s", e)
276
+ await terminate_process(process, graceful=False)
277
+ return format_error("执行出错", str(e)), []
278
+
279
+
280
+ # ---------------------------------------------------------------------------
281
+ # Slash command support — per-user preference persistence & command dispatch
282
+ # ---------------------------------------------------------------------------
283
+
284
+
285
+ async def _run_agy_subcommand(subcmd_args: list, user_id: str) -> str:
286
+ """Run an agy subcommand (e.g., 'models', 'agents') and return cleaned output.
287
+
288
+ Timeout is fixed at 30 seconds.
289
+ Uses per-user session isolation matching run_agy.
290
+ """
291
+ session_dir = ensure_user_gemini(user_id)
292
+ cmd = [config.agy_binary_path] + subcmd_args
293
+ try:
294
+ env = sanitize_env(session_dir)
295
+ process = await asyncio.create_subprocess_exec(
296
+ *cmd,
297
+ stdout=asyncio.subprocess.PIPE,
298
+ stderr=asyncio.subprocess.PIPE,
299
+ cwd=session_dir,
300
+ env=env,
301
+ preexec_fn=os.setsid if hasattr(os, "setsid") else None,
302
+ )
303
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
304
+ process.communicate(), timeout=30.0
305
+ )
306
+ stdout_text = stdout_bytes.decode("utf-8", errors="replace").strip()
307
+ stderr_text = stderr_bytes.decode("utf-8", errors="replace").strip()
308
+
309
+ if process.returncode != 0:
310
+ logger.warning(
311
+ "agy %s exited with code %s",
312
+ " ".join(subcmd_args),
313
+ process.returncode,
314
+ )
315
+ return format_cli_error(
316
+ stderr_text or stdout_text or "终端指令执行失败",
317
+ backend="agy",
318
+ )
319
+
320
+ return clean_output(stdout_text) or EMPTY_REPLY
321
+
322
+ except asyncio.TimeoutError:
323
+ return format_error("指令超时", "子命令 30 秒内未完成。")
324
+ except Exception as e:
325
+ logger.exception("Subcommand error: %s", e)
326
+ return format_error("执行出错", str(e))
327
+
328
+
329
+ def _cmd_help() -> str:
330
+ """Build /help response listing all supported slash commands."""
331
+ lines = [
332
+ "📋 **wechatbridge 支持指令 (agy)** 📋",
333
+ "",
334
+ "**模型控制**",
335
+ "- `/model <名称>` — 切换模型(用 `/models` 查看可用列表)",
336
+ "- `/models` — 查看可用模型列表",
337
+ "- `/backend <agy|grok>` — 切换后端 CLI",
338
+ "",
339
+ "**对话控制**",
340
+ "- `/clear` 或 `/new` — 重置对话(开始新会话)",
341
+ "- `/fast` — 开启**快速模式**(低推理开销)",
342
+ "- `/planning` — 开启 **planning 模式**",
343
+ "",
344
+ "**工具**",
345
+ "- `/add-dir <路径>` — 添加工作目录",
346
+ "- `/agents` — 查看可用 agent",
347
+ "",
348
+ "**MCP & 子代理**",
349
+ "- `/mcp` — MCP 工具使用引导",
350
+ "- `/agent <名称> <任务>` — 调用子代理执行任务",
351
+ "",
352
+ "**人格**",
353
+ "- `/persona <内容>` — 设置你专属的人格文档(支持 show / clear / reset 子命令)",
354
+ "",
355
+ "**其他**",
356
+ "- `/help` — 显示本帮助",
357
+ "",
358
+ "提示:其他 `/` 指令(如 `/goal`、`/grill-me`、`/schedule` 等)会直接交给 agy 处理。",
359
+ ]
360
+ return "\n".join(lines)
361
+
362
+
363
+ def handle_persona(args: str, user_id: str) -> str:
364
+ """Handle /persona command: set, show, clear, reset per-user GEMINI.md.
365
+
366
+ Subcommands:
367
+ set <content> — write content as user's persona document
368
+ <content> — same as set (no subcommand)
369
+ show — display current persona content
370
+ clear — delete persona, restore default
371
+ reset — re-copy global GEMINI.md, overwriting local
372
+ """
373
+ session_dir = get_session_dir(user_id)
374
+ gemini_dir = os.path.join(session_dir, ".gemini")
375
+ gemini_path = os.path.join(gemini_dir, "GEMINI.md")
376
+
377
+ parts = args.strip().split(maxsplit=1)
378
+ subcmd = parts[0].lower() if parts else ""
379
+ rest = parts[1] if len(parts) > 1 else ""
380
+
381
+ # set or implicit content
382
+ if subcmd == "set" and rest:
383
+ os.makedirs(gemini_dir, exist_ok=True)
384
+ try:
385
+ with open(gemini_path, "w", encoding="utf-8") as f:
386
+ f.write(rest)
387
+ return "✅ **人格文档已更新** ✅"
388
+ except OSError as e:
389
+ logger.error("Failed to write persona for %s: %s", user_id, e)
390
+ return "❌ **写入人格文档失败** ❌"
391
+ elif subcmd and subcmd not in ("show", "clear", "reset", "set"):
392
+ # No subcommand → treat whole args as content
393
+ os.makedirs(gemini_dir, exist_ok=True)
394
+ try:
395
+ with open(gemini_path, "w", encoding="utf-8") as f:
396
+ f.write(args.strip())
397
+ return "✅ **人格文档已更新** ✅"
398
+ except OSError as e:
399
+ logger.error("Failed to write persona for %s: %s", user_id, e)
400
+ return "❌ **写入人格文档失败** ❌"
401
+
402
+ # show
403
+ if subcmd == "show":
404
+ if not os.path.exists(gemini_path):
405
+ return "(未设置人格文档)"
406
+ try:
407
+ with open(gemini_path, "r", encoding="utf-8") as f:
408
+ val = f.read()
409
+ if len(val) > 1500:
410
+ val = val[:1500] + "\n\n(已截断至前1500字符)"
411
+ return val or "(空文档)"
412
+ except OSError as e:
413
+ logger.error("Failed to read persona for %s: %s", user_id, e)
414
+ return "❌ **读取人格文档失败** ❌"
415
+
416
+ # clear
417
+ if subcmd == "clear":
418
+ if os.path.exists(gemini_path):
419
+ try:
420
+ os.remove(gemini_path)
421
+ return "✅ **人格文档已清除** ✅"
422
+ except OSError as e:
423
+ logger.error("Failed to clear persona for %s: %s", user_id, e)
424
+ return "❌ **清除人格文档失败** ❌"
425
+ return "ℹ️ **本就无人格文档** ℹ️"
426
+
427
+ # reset
428
+ if subcmd == "reset":
429
+ # agy standard persona file path, managed by agy CLI
430
+ agents_src = os.path.expanduser("~/.gemini/GEMINI.md")
431
+ if not os.path.exists(agents_src):
432
+ return "❌ **全局默认人格文档不存在** ❌"
433
+ os.makedirs(gemini_dir, exist_ok=True)
434
+ try:
435
+ shutil.copy(agents_src, gemini_path)
436
+ return "✅ **人格已重置为全局默认** ✅"
437
+ except OSError as e:
438
+ logger.error("Failed to reset persona for %s: %s", user_id, e)
439
+ return "❌ **重置人格文档失败** ❌"
440
+
441
+ # empty args
442
+ return "📋 **/persona 用法** 📋\n\n- `/persona <内容>` 设置\n- `/persona show` 查看\n- `/persona clear` 清除\n- `/persona reset` 重置默认"
443
+
444
+
445
+ def _cmd_clear(user_id: str) -> str:
446
+ """Handle /clear or /new: delete .initialized flag to start fresh."""
447
+ session_dir = get_session_dir(user_id)
448
+ flag_path = os.path.join(session_dir, ".initialized")
449
+ try:
450
+ if os.path.exists(flag_path):
451
+ os.remove(flag_path)
452
+ return "✅ **对话已重置** ✅"
453
+ except OSError as e:
454
+ logger.error("Failed to clear session for %s: %s", user_id, e)
455
+ return "❌ **重置失败** ❌"
456
+
457
+
458
+ def _cmd_fast(user_id: str) -> str:
459
+ """Handle /fast: set effort=low (scoped to current backend)."""
460
+ update_active_prefs(user_id, effort="low")
461
+ return "✅ **已开启 fast 模式** ✅"
462
+
463
+
464
+ def _cmd_planning(user_id: str) -> str:
465
+ """Handle /planning: set mode=plan (scoped to current backend)."""
466
+ update_active_prefs(user_id, mode="plan")
467
+ return "✅ **已开启 planning 模式** ✅"
468
+
469
+
470
+ def _cmd_add_dir(args: str, user_id: str) -> str:
471
+ """Handle /add-dir <path>: add path to add_dirs list (dedup, validated)."""
472
+ path = args.strip()
473
+ if not path:
474
+ return "❌ **缺少参数** ❌\n\n`/add-dir <路径>`"
475
+ ok, result = validate_add_dir(path, user_id)
476
+ if not ok:
477
+ return f"❌ **目录不允许** ❌\n\n{result}"
478
+ resolved = result
479
+ prefs = load_prefs(user_id)
480
+ dirs = prefs.get("add_dirs", [])
481
+ if resolved not in dirs:
482
+ dirs.append(resolved)
483
+ prefs["add_dirs"] = dirs
484
+ save_prefs(user_id, prefs)
485
+ return f"✅ **已添加工作目录** ✅\n\n```\n{resolved}\n```"
486
+
487
+
488
+ async def _cmd_model(args: str, user_id: str) -> str:
489
+ """Handle /model <name>: validate against agy models list, then save.
490
+
491
+ Matching order:
492
+ 1. Exact match against a model name
493
+ 2. Prefix match (name is a prefix of one or more model names → first hit)
494
+ """
495
+ name = args.strip()
496
+ if not name:
497
+ return "❌ **缺少参数** ❌\n\n`/model <名称>`"
498
+
499
+ output = await _run_agy_subcommand(["models"], user_id)
500
+ if output.startswith("[error]") or output.startswith("❌"):
501
+ return "❌ **无法获取模型列表** ❌"
502
+
503
+ models = [line.strip() for line in output.split("\n") if line.strip()]
504
+
505
+ # Exact match
506
+ if name in models:
507
+ # If model name carries an effort suffix, clear stored effort so
508
+ # run_agy doesn't pass a conflicting --effort flag
509
+ _, embedded = parse_model_effort(name)
510
+ if embedded:
511
+ update_active_prefs(user_id, model=name, effort="")
512
+ else:
513
+ update_active_prefs(user_id, model=name)
514
+ return f"✅ **模型已切换** ✅\n\n`{name}`"
515
+
516
+ # Prefix match
517
+ prefix_matches = [m for m in models if m.startswith(name)]
518
+ if prefix_matches:
519
+ matched = prefix_matches[0]
520
+ _, embedded = parse_model_effort(matched)
521
+ if embedded:
522
+ update_active_prefs(user_id, model=matched, effort="")
523
+ else:
524
+ update_active_prefs(user_id, model=matched)
525
+ return f"✅ **模型已切换** ✅\n\n`{matched}`"
526
+
527
+ return f"❌ **模型不存在** ❌\n\n`{name}`"
528
+
529
+
530
+ async def handle_slash_command(text: str, user_id: str) -> str | None:
531
+ """Handle /-slash commands from WeChat messages.
532
+
533
+ Parses the first whitespace-separated token as the command (lowercased),
534
+ and the remainder as arguments.
535
+
536
+ Classification:
537
+ A — implemented in wechatbridge (model, clear, fast, planning, add-dir, etc.)
538
+ B — dangerous (exit, quit, logout) → rejected with error message
539
+ C — TUI panels (config, settings, context, ...) → inform not supported on WeChat
540
+ D — passthrough to agy → returns None, caller runs run_agy() normally
541
+
542
+ Returns:
543
+ str: reply message for A/B/C classes
544
+ None: for D class — the caller should pass the original text to run_agy()
545
+ """
546
+ # Parse: first whitespace token = cmd, rest = args
547
+ parts = text.strip().split(maxsplit=1)
548
+ cmd = parts[0].lower() if parts else text.lower()
549
+ args = parts[1] if len(parts) > 1 else ""
550
+
551
+ # --- B class: dangerous / rejected ---
552
+ B_CMDS = frozenset({"/exit", "/quit", "/logout"})
553
+ if cmd in B_CMDS:
554
+ return (
555
+ "⛔ **该指令在微信端禁用** ⛔"
556
+ )
557
+
558
+ # --- C class: TUI panels (not supported on WeChat) ---
559
+ C_CMDS = frozenset({
560
+ "/config", "/settings", "/context", "/diff", "/artifact", "/tasks",
561
+ "/hooks", "/keybindings", "/permissions", "/statusline",
562
+ "/copy", "/open", "/rename", "/fork", "/branch", "/rewind", "/undo",
563
+ "/resume", "/switch", "/conversation", "/title", "/feedback",
564
+ "/usage", "/quota", "/credits", "/skills",
565
+ })
566
+ if cmd in C_CMDS:
567
+ return (
568
+ f"⚠️ **微信端不支持** ⚠️\n\n`{cmd}`"
569
+ )
570
+
571
+ # --- A class: implemented commands ---
572
+ if cmd == "/help":
573
+ return _cmd_help()
574
+
575
+ if cmd in ("/clear", "/new"):
576
+ return _cmd_clear(user_id)
577
+
578
+ if cmd == "/fast":
579
+ return _cmd_fast(user_id)
580
+
581
+ if cmd == "/planning":
582
+ return _cmd_planning(user_id)
583
+
584
+ if cmd == "/model":
585
+ return await _cmd_model(args, user_id)
586
+
587
+ if cmd == "/add-dir":
588
+ return _cmd_add_dir(args, user_id)
589
+
590
+ if cmd == "/agents":
591
+ output = await _run_agy_subcommand(["agents"], user_id)
592
+ if not output or output == "Available agents:":
593
+ output = "**Available agents**\n\n(当前没有自定义 agent。)"
594
+ return output
595
+
596
+ if cmd == "/models":
597
+ return await _run_agy_subcommand(["models"], user_id)
598
+
599
+ if cmd == "/persona":
600
+ return handle_persona(args, user_id)
601
+
602
+ # --- MCP & Subagent ---
603
+ if cmd == "/mcp":
604
+ if not config.enable_mcp:
605
+ return "ℹ️ **该功能已禁用** ℹ️"
606
+ return (
607
+ "ℹ️ **MCP 工具使用引导** ℹ️\n\n"
608
+ "agy 已配置 MCP server(ctxmode / codegraph)。\n\n"
609
+ "使用方法:用自然语言描述调用,格式为:\n"
610
+ "> 用 call_mcp_tool 调用 `<工具名>`,参数 `<json>`\n\n"
611
+ "示例:\n"
612
+ "> 用 codegraph 的 search 工具搜 ctxmode"
613
+ )
614
+
615
+ if cmd == "/agent":
616
+ if not config.enable_subagent:
617
+ return "ℹ️ **该功能已禁用** ℹ️"
618
+ if not args:
619
+ return "❌ **缺少参数** ❌\n\n`/agent <名称> <任务>`"
620
+ # Construct prompt and run through agy
621
+ agent_parts = args.split(maxsplit=1)
622
+ agent_name = agent_parts[0]
623
+ agent_task = agent_parts[1] if len(agent_parts) > 1 else ""
624
+ crafted = f"请用 invoke_subagent 调用 agent {agent_name} 执行任务:{agent_task}"
625
+ logger.info("Agent subcmd: user=%s agent=%s task=%.100s", user_id, agent_name, agent_task)
626
+ result_text, _ = await run_agy(crafted, user_id)
627
+ return result_text
628
+
629
+ # --- D class: passthrough to agy (return None so caller runs run_agy) ---
630
+ return None