@vrs-soft/wecom-aibot-mcp 1.5.0 → 2.3.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.
@@ -12,17 +12,19 @@ import * as fs from 'fs';
12
12
  import * as path from 'path';
13
13
  import * as os from 'os';
14
14
  import { fileURLToPath } from 'url';
15
+ import { logger } from './logger.js';
15
16
  const CONFIG_DIR = path.join(os.homedir(), '.wecom-aibot-mcp');
16
17
  const BOT_CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
17
18
  const VERSION_FILE = path.join(CONFIG_DIR, 'version.json');
18
19
  const CLAUDE_CONFIG_FILE = path.join(os.homedir(), '.claude.json');
19
20
  const CLAUDE_SETTINGS_FILE = path.join(os.homedir(), '.claude', 'settings.local.json');
20
21
  const HOOK_SCRIPT_PATH = path.join(CONFIG_DIR, 'permission-hook.sh');
21
- // 版本号(与 package.json 同步)
22
- const VERSION = '1.4.2';
22
+ const TASK_COMPLETED_HOOK_SCRIPT_PATH = path.join(CONFIG_DIR, 'task-completed-hook.sh');
23
23
  // Skill 模板路径(包内)- 使用 fileURLToPath 确保跨平台兼容
24
24
  const __filename = fileURLToPath(import.meta.url);
25
25
  const __dirname = path.dirname(__filename);
26
+ // 版本号(从 package.json 读取)
27
+ const VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8')).version;
26
28
  const SKILL_TEMPLATE_DIR = path.join(__dirname, '..', 'skills', 'headless-mode');
27
29
  const SKILL_TEMPLATE_FILE = path.join(SKILL_TEMPLATE_DIR, 'SKILL.md');
28
30
  // MCP 工具权限列表(需要预授权以避免 headless 模式阻断)
@@ -52,7 +54,7 @@ export function loadConfig() {
52
54
  }
53
55
  }
54
56
  catch (err) {
55
- console.error('[config] 读取配置失败:', err);
57
+ logger.error('[config] 读取配置失败:', err);
56
58
  }
57
59
  return null;
58
60
  }
@@ -79,7 +81,7 @@ export function deleteConfig() {
79
81
  }
80
82
  }
81
83
  catch (err) {
82
- console.error('[config] 删除配置失败:', err);
84
+ logger.error('[config] 删除配置失败:', err);
83
85
  }
84
86
  }
85
87
  // 删除 PermissionRequest hook(从 ~/.claude/settings.local.json)
@@ -105,7 +107,7 @@ export function deleteHook() {
105
107
  }
106
108
  }
107
109
  catch (err) {
108
- console.error('[config] 删除 hook 失败:', err);
110
+ logger.error('[config] 删除 hook 失败:', err);
109
111
  }
110
112
  }
111
113
  // 删除 skill 文件
@@ -118,10 +120,80 @@ export function deleteSkills() {
118
120
  }
119
121
  }
120
122
  catch (err) {
121
- console.error('[config] 删除 skill 失败:', err);
123
+ logger.error('[config] 删除 skill 失败:', err);
122
124
  }
123
125
  }
124
- // 删除单个 MCP 配置(按实例名)
126
+ // 删除单个机器人配置(按名称)
127
+ export function deleteRobotConfig(robotName) {
128
+ try {
129
+ const robots = listAllRobots();
130
+ const robot = robots.find(r => r.name === robotName);
131
+ if (!robot) {
132
+ console.log(`[config] 机器人 "${robotName}" 不存在`);
133
+ return false;
134
+ }
135
+ // 查找机器人对应的配置文件
136
+ let configFile = null;
137
+ let isDefault = false;
138
+ // 检查是否是默认机器人(config.json)
139
+ if (fs.existsSync(BOT_CONFIG_FILE)) {
140
+ const config = JSON.parse(fs.readFileSync(BOT_CONFIG_FILE, 'utf-8'));
141
+ const name = config.nameTag || `机器人-${config.botId?.slice(0, 8) || 'unknown'}`;
142
+ if (name === robotName) {
143
+ configFile = BOT_CONFIG_FILE;
144
+ isDefault = true;
145
+ }
146
+ }
147
+ // 检查其他机器人配置文件
148
+ if (!configFile && fs.existsSync(CONFIG_DIR)) {
149
+ const files = fs.readdirSync(CONFIG_DIR).filter(f => f.startsWith('robot-') && f.endsWith('.json'));
150
+ for (const file of files) {
151
+ const filePath = path.join(CONFIG_DIR, file);
152
+ const config = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
153
+ const name = config.nameTag || file.replace('.json', '');
154
+ if (name === robotName) {
155
+ configFile = filePath;
156
+ break;
157
+ }
158
+ }
159
+ }
160
+ if (!configFile) {
161
+ console.log(`[config] 未找到机器人 "${robotName}" 的配置文件`);
162
+ return false;
163
+ }
164
+ // 如果是默认机器人,需要处理迁移
165
+ if (isDefault) {
166
+ // 查找其他机器人配置文件
167
+ const otherRobotFiles = fs.existsSync(CONFIG_DIR)
168
+ ? fs.readdirSync(CONFIG_DIR).filter(f => f.startsWith('robot-') && f.endsWith('.json'))
169
+ : [];
170
+ if (otherRobotFiles.length > 0) {
171
+ // 将第一个其他机器人提升为默认
172
+ const newDefaultFile = path.join(CONFIG_DIR, otherRobotFiles[0]);
173
+ const newDefaultConfig = JSON.parse(fs.readFileSync(newDefaultFile, 'utf-8'));
174
+ fs.writeFileSync(BOT_CONFIG_FILE, JSON.stringify(newDefaultConfig, null, 2));
175
+ fs.unlinkSync(newDefaultFile);
176
+ console.log(`[config] 已将 "${newDefaultConfig.nameTag || otherRobotFiles[0]}" 提升为默认机器人`);
177
+ }
178
+ else {
179
+ // 没有其他机器人,直接删除默认配置
180
+ fs.unlinkSync(BOT_CONFIG_FILE);
181
+ console.log('[config] 已删除最后一个机器人配置');
182
+ }
183
+ }
184
+ else {
185
+ // 不是默认机器人,直接删除
186
+ fs.unlinkSync(configFile);
187
+ }
188
+ console.log(`[config] 已删除机器人: ${robotName}`);
189
+ return true;
190
+ }
191
+ catch (err) {
192
+ logger.error('[config] 删除机器人配置失败:', err);
193
+ return false;
194
+ }
195
+ }
196
+ // 删除单个 MCP 配置(按实例名)- 已弃用,保留用于 --uninstall
125
197
  export function deleteMcpConfig(instanceName) {
126
198
  try {
127
199
  if (!fs.existsSync(CLAUDE_CONFIG_FILE)) {
@@ -146,45 +218,54 @@ export function deleteMcpConfig(instanceName) {
146
218
  return true;
147
219
  }
148
220
  catch (err) {
149
- console.error('[config] 删除配置失败:', err);
221
+ logger.error('[config] 删除配置失败:', err);
150
222
  return false;
151
223
  }
152
224
  }
153
225
  // 交互式删除机器人配置
154
- export async function deleteMcpConfigInteractive(instanceName) {
155
- const instances = listAllMcpInstances();
156
- if (instances.length === 0) {
226
+ export async function deleteRobotConfigInteractive(robotName) {
227
+ const robots = listAllRobots();
228
+ if (robots.length === 0) {
157
229
  console.log('[config] 没有找到任何企业微信机器人配置');
158
230
  return;
159
231
  }
160
- // 如果提供了实例名,直接删除
161
- if (instanceName) {
162
- deleteMcpConfig(instanceName);
163
- return;
164
- }
165
- // 否则显示列表让用户选择
232
+ // 始终显示列表
166
233
  console.log('\n企业微信机器人配置列表:\n');
167
- instances.forEach((inst, idx) => {
168
- console.log(` ${idx + 1}. ${inst.name} (Bot ID: ${inst.config.botId.slice(0, 12)}..., 用户: ${inst.config.targetUserId})`);
234
+ robots.forEach((robot, idx) => {
235
+ const isDefault = idx === 0;
236
+ const defaultTag = isDefault ? ' [默认]' : '';
237
+ console.log(` ${idx + 1}. ${robot.name}${defaultTag} (Bot ID: ${robot.botId.slice(0, 12)}..., 用户: ${robot.targetUserId})`);
169
238
  });
239
+ // 如果提供了机器人名称,验证并删除
240
+ if (robotName) {
241
+ const found = robots.find(r => r.name === robotName);
242
+ if (!found) {
243
+ console.log(`\n[config] 未找到名为 "${robotName}" 的机器人`);
244
+ return;
245
+ }
246
+ console.log(`\n[config] 将删除机器人: ${robotName}`);
247
+ deleteRobotConfig(robotName);
248
+ return;
249
+ }
250
+ // 没有提供名称,让用户选择
170
251
  console.log(` 0. 取消\n`);
171
252
  const rl = createRL();
172
253
  try {
173
- const choice = await question(rl, '请选择要删除的配置序号: ');
254
+ const choice = await question(rl, '请选择要删除的机器人序号: ');
174
255
  const choiceNum = parseInt(choice);
175
256
  if (choiceNum === 0) {
176
257
  console.log('[config] 已取消');
177
258
  return;
178
259
  }
179
- if (choiceNum < 1 || choiceNum > instances.length) {
260
+ if (choiceNum < 1 || choiceNum > robots.length) {
180
261
  console.log('[config] 无效选择');
181
262
  return;
182
263
  }
183
- const selected = instances[choiceNum - 1];
184
- const confirm = await question(rl, `确认删除 "${selected.name}"?(y/N): `);
264
+ const selected = robots[choiceNum - 1];
265
+ const confirm = await question(rl, `确认删除机器人 "${selected.name}"?(y/N): `);
185
266
  if (confirm.toLowerCase() === 'y') {
186
- deleteMcpConfig(selected.name);
187
- console.log(`[config] 请重启 Claude Code 以生效\n`);
267
+ deleteRobotConfig(selected.name);
268
+ console.log(`[config] MCP 配置保留,其他机器人仍可正常使用\n`);
188
269
  }
189
270
  else {
190
271
  console.log('[config] 已取消');
@@ -194,6 +275,11 @@ export async function deleteMcpConfigInteractive(instanceName) {
194
275
  rl.close();
195
276
  }
196
277
  }
278
+ // 交互式删除 MCP 配置(已弃用,保留用于兼容)
279
+ export async function deleteMcpConfigInteractive(instanceName) {
280
+ // 转换为删除机器人配置
281
+ await deleteRobotConfigInteractive(instanceName);
282
+ }
197
283
  // 完全卸载(删除所有相关配置)
198
284
  export function uninstall() {
199
285
  console.log('\n[config] 开始卸载 wecom-aibot-mcp...\n');
@@ -208,7 +294,7 @@ export function uninstall() {
208
294
  console.log('[config] 已删除 headless 状态索引');
209
295
  }
210
296
  catch (err) {
211
- console.error('[config] 删除 headless 状态索引失败:', err);
297
+ logger.error('[config] 删除 headless 状态索引失败:', err);
212
298
  }
213
299
  }
214
300
  // 删除整个配置目录(包括 config.json、robot-*.json、hook 脚本、日志等)
@@ -242,7 +328,7 @@ export function uninstall() {
242
328
  }
243
329
  }
244
330
  catch (err) {
245
- console.error('[config] 删除配置目录失败:', err);
331
+ logger.error('[config] 删除配置目录失败:', err);
246
332
  }
247
333
  }
248
334
  console.log('\n[config] 卸载完成');
@@ -471,11 +557,91 @@ fi
471
557
  fs.writeFileSync(HOOK_SCRIPT_PATH, script, { mode: 0o755 });
472
558
  console.log(`[config] Hook 脚本已写入: ${HOOK_SCRIPT_PATH}`);
473
559
  }
560
+ // 生成并写入 TaskCompleted hook 脚本
561
+ // 用于任务完成后自动恢复微信消息轮询
562
+ function writeTaskCompletedHookScript() {
563
+ const script = `#!/bin/bash
564
+ # wecom-aibot-mcp TaskCompleted hook
565
+ # 任务完成后检查是否需要恢复微信消息轮询
566
+ #
567
+ # 固定端口: 18963
568
+ # 检查 $(pwd)/.claude/wecom-aibot.json 的 wechatMode 和 autoApprove 字段
569
+
570
+ MCP_PORT=18963
571
+
572
+ # 先保存输入(TaskCompleted 事件数据)
573
+ INPUT=$(cat)
574
+
575
+ # 日志输出:--debug 模式下输出到 stderr,否则静默
576
+ DEBUG_FILE="$HOME/.wecom-aibot-mcp/debug"
577
+ log_debug() {
578
+ if [[ -f "$DEBUG_FILE" ]]; then
579
+ echo "$1" >&2
580
+ fi
581
+ }
582
+
583
+ log_debug "[$(date)] TaskCompleted hook called. INPUT: \${INPUT:0:200}"
584
+
585
+ # 检查项目目录的微信模式配置文件
586
+ PROJECT_DIR=$(pwd)
587
+ CONFIG_FILE="$PROJECT_DIR/.claude/wecom-aibot.json"
588
+
589
+ log_debug "[$(date)] Checking config: $CONFIG_FILE"
590
+
591
+ # 配置文件不存在,不在微信模式
592
+ if [[ ! -f "$CONFIG_FILE" ]]; then
593
+ log_debug "[$(date)] No config file, exit 0 (allow complete)"
594
+ exit 0
595
+ fi
596
+
597
+ # 检查 wechatMode 是否为 true(微信模式开关)
598
+ WECHAT_MODE=$(jq -r '.wechatMode // false' "$CONFIG_FILE" 2>/dev/null)
599
+ log_debug "[$(date)] wechatMode: $WECHAT_MODE"
600
+ if [[ "$WECHAT_MODE" != "true" ]]; then
601
+ log_debug "[$(date)] wechatMode not true, exit 0 (allow complete)"
602
+ exit 0
603
+ fi
604
+
605
+ # 检查 autoApprove 是否为 true(需要恢复轮询的模式)
606
+ AUTO_APPROVE=$(jq -r '.autoApprove // false' "$CONFIG_FILE" 2>/dev/null)
607
+ log_debug "[$(date)] autoApprove: $AUTO_APPROVE"
608
+ if [[ "$AUTO_APPROVE" != "true" ]]; then
609
+ log_debug "[$(date)] autoApprove not true, exit 0 (allow complete)"
610
+ exit 0
611
+ fi
612
+
613
+ # 检查 MCP Server 是否在线
614
+ HEALTH=$(curl -s -m 2 "http://127.0.0.1:$MCP_PORT/health" 2>/dev/null)
615
+ log_debug "[$(date)] Health check: $HEALTH"
616
+ if ! echo "$HEALTH" | jq -e '.status == "ok"' > /dev/null 2>&1; then
617
+ log_debug "[$(date)] MCP Server offline, exit 0 (allow complete)"
618
+ exit 0
619
+ fi
620
+
621
+ # 获取 ccId
622
+ CC_ID=$(jq -r '.ccId // empty' "$CONFIG_FILE" 2>/dev/null)
623
+ log_debug "[$(date)] ccId: $CC_ID"
624
+ if [[ -z "$CC_ID" ]]; then
625
+ log_debug "[$(date)] No ccId in config, exit 0 (allow complete)"
626
+ exit 0
627
+ fi
628
+
629
+ # 处于微信模式且 autoApprove 为 true,需要恢复轮询
630
+ # 使用 exit code 2 阻止任务完成,并提示 Claude 调用 MCP 工具
631
+ log_debug "[$(date)] ✅ WeChat mode active, blocking completion to resume polling"
632
+ log_debug "[$(date)] ccId=$CC_ID, will prompt Claude to call get_pending_messages"
633
+ echo "任务已完成,请调用 mcp__wecom-aibot__get_pending_messages(cc_id=\"$CC_ID\", timeout_ms=30000) 恢复微信消息轮询" >&2
634
+ exit 2
635
+ `;
636
+ ensureConfigDir();
637
+ fs.writeFileSync(TASK_COMPLETED_HOOK_SCRIPT_PATH, script, { mode: 0o755 });
638
+ console.log(`[config] TaskCompleted Hook 脚本已写入: ${TASK_COMPLETED_HOOK_SCRIPT_PATH}`);
639
+ }
474
640
  // 写入 MCP Server 配置到 ~/.claude.json
475
641
  function writeMcpServerConfig(config, instanceName) {
476
642
  try {
477
- // 1. 写入机器人配置到 ~/.wecom-aibot-mcp/config.json
478
643
  ensureConfigDir();
644
+ // 构建机器人配置对象
479
645
  const botConfig = {
480
646
  botId: config.botId,
481
647
  secret: config.secret,
@@ -484,8 +650,33 @@ function writeMcpServerConfig(config, instanceName) {
484
650
  if (config.nameTag) {
485
651
  botConfig.nameTag = config.nameTag;
486
652
  }
487
- fs.writeFileSync(BOT_CONFIG_FILE, JSON.stringify(botConfig, null, 2));
488
- console.log('[config] 机器人配置已写入 ~/.wecom-aibot-mcp/config.json');
653
+ // 检查名称唯一性(如果设置了新名称)
654
+ if (config.nameTag && isRobotNameExists(config.nameTag, config.botId)) {
655
+ console.log(`[config] ❌ 机器人名称 "${config.nameTag}" 已被其他机器人使用`);
656
+ console.log('[config] 请使用不同的名称');
657
+ return false;
658
+ }
659
+ // 按 botId 查找现有配置文件
660
+ const existingConfigFile = findRobotConfigFileByBotId(config.botId);
661
+ if (existingConfigFile) {
662
+ // 更新现有配置文件
663
+ fs.writeFileSync(existingConfigFile, JSON.stringify(botConfig, null, 2));
664
+ console.log(`[config] 已更新机器人配置: ${existingConfigFile}`);
665
+ }
666
+ else {
667
+ // 新配置:检查是否有默认配置文件
668
+ if (fs.existsSync(BOT_CONFIG_FILE)) {
669
+ // 有默认配置,创建新的 robot-*.json 文件
670
+ const newConfigPath = path.join(CONFIG_DIR, `robot-${Date.now()}.json`);
671
+ fs.writeFileSync(newConfigPath, JSON.stringify(botConfig, null, 2));
672
+ console.log(`[config] 已添加新机器人配置: ${newConfigPath}`);
673
+ }
674
+ else {
675
+ // 没有默认配置,写入 config.json
676
+ fs.writeFileSync(BOT_CONFIG_FILE, JSON.stringify(botConfig, null, 2));
677
+ console.log('[config] 已写入机器人配置 ~/.wecom-aibot-mcp/config.json');
678
+ }
679
+ }
489
680
  // 2. 写入 MCP 配置到 ~/.claude.json(仅 URL)
490
681
  let claudeConfig = {};
491
682
  if (fs.existsSync(CLAUDE_CONFIG_FILE)) {
@@ -505,7 +696,7 @@ function writeMcpServerConfig(config, instanceName) {
505
696
  return true;
506
697
  }
507
698
  catch (err) {
508
- console.error('[config] 写入配置失败:', err);
699
+ logger.error('[config] 写入配置失败:', err);
509
700
  console.log('[config] ⚠️ 请手动配置:');
510
701
  console.log('');
511
702
  console.log('~/.wecom-aibot-mcp/config.json:');
@@ -562,11 +753,16 @@ export async function addMcpConfig() {
562
753
  console.log(`[config] 如需更新配置,请使用 --config 命令`);
563
754
  return;
564
755
  }
565
- // 检查名称是否重复(仅提示,不阻止)
756
+ // 检查名称是否重复(阻止)
566
757
  const duplicateName = existingRobots.find(r => r.name === robotName);
567
758
  if (duplicateName) {
568
- console.log(`\n[config] ⚠️ 注意:名称 "${robotName}" 已被使用`);
569
- console.log(`[config] 建议使用不同的名称以方便识别`);
759
+ console.log(`\n[config] 名称 "${robotName}" 已被使用`);
760
+ console.log(`[config] 请使用不同的名称以方便识别`);
761
+ console.log(`[config] 当前已配置的机器人:`);
762
+ existingRobots.forEach((r, i) => {
763
+ console.log(` ${i + 1}. ${r.name}`);
764
+ });
765
+ return;
570
766
  }
571
767
  // 先连接验证凭证
572
768
  console.log('\n[config] 正在连接企业微信...');
@@ -631,7 +827,7 @@ export async function addMcpConfig() {
631
827
  console.log('\n[config] MCP 配置无需修改,多个机器人共享同一个 HTTP 服务');
632
828
  }
633
829
  catch (err) {
634
- console.error('[config] 添加配置失败:', err);
830
+ logger.error('[config] 添加配置失败:', err);
635
831
  rl.close();
636
832
  }
637
833
  }
@@ -673,44 +869,6 @@ export function listAllRobots() {
673
869
  }
674
870
  return robots;
675
871
  }
676
- // 安装 skill 文件到 ~/.claude/skills/
677
- function installSkills() {
678
- try {
679
- const claudeSkillsDir = path.join(os.homedir(), '.claude', 'skills', 'headless-mode');
680
- const skillFile = path.join(claudeSkillsDir, 'SKILL.md');
681
- // 确保目录存在
682
- if (!fs.existsSync(claudeSkillsDir)) {
683
- fs.mkdirSync(claudeSkillsDir, { recursive: true });
684
- }
685
- // 尝试多个可能的源路径(支持 npm 安装和开发模式)
686
- const possibleSources = [
687
- // 1. 包内路径(npm 安装后的位置)
688
- SKILL_TEMPLATE_FILE,
689
- // 2. 开发模式路径
690
- path.join(process.cwd(), 'skills', 'headless-mode', 'SKILL.md'),
691
- // 3. 相对于 dist 目录的路径
692
- path.join(__dirname, '..', 'skills', 'headless-mode', 'SKILL.md'),
693
- ];
694
- let sourceFile = null;
695
- for (const src of possibleSources) {
696
- if (fs.existsSync(src)) {
697
- sourceFile = src;
698
- break;
699
- }
700
- }
701
- if (sourceFile) {
702
- fs.copyFileSync(sourceFile, skillFile);
703
- console.log(`[config] skill 文件已安装: ${skillFile}`);
704
- }
705
- else {
706
- console.log('[config] ⚠️ skill 文件未找到,请手动创建 ~/.claude/skills/headless-mode/SKILL.md');
707
- console.log('[config] 查找路径:', possibleSources.join(', '));
708
- }
709
- }
710
- catch (err) {
711
- console.error('[config] 安装 skill 文件失败:', err);
712
- }
713
- }
714
872
  // 写入 MCP 工具权限 + 注册 PermissionRequest hook 到 Claude settings
715
873
  function writeMcpPermissions() {
716
874
  try {
@@ -751,17 +909,17 @@ function writeMcpPermissions() {
751
909
  writeHookScript();
752
910
  }
753
911
  catch (err) {
754
- console.error('[config] 写入配置失败:', err);
912
+ logger.error('[config] 写入配置失败:', err);
755
913
  console.log('[config] ⚠️ 请手动配置,详见 README');
756
914
  }
757
915
  }
758
916
  // 确保 hook 已安装(幂等,可多次调用)
759
917
  export function ensureHookInstalled() {
760
918
  writeMcpPermissions();
761
- installSkills();
919
+ writeTaskCompletedHookScript();
762
920
  }
763
921
  // 确保所有全局配置已写入(强制覆盖,不依赖智能体)
764
- export function ensureGlobalConfigs() {
922
+ export function ensureGlobalConfigs(mode = 'full') {
765
923
  ensureConfigDir();
766
924
  // 读取已安装版本
767
925
  let previousVersion;
@@ -775,7 +933,16 @@ export function ensureGlobalConfigs() {
775
933
  upgraded = true;
776
934
  console.log(`[config] 版本升级: ${previousVersion || '未安装'} -> ${VERSION}`);
777
935
  }
778
- // 1. 强制写入 MCP 配置到 ~/.claude.json(覆盖)
936
+ // http-only 模式:不写入 MCP 配置(远程部署场景)
937
+ if (mode === 'http-only') {
938
+ console.log('[config] HTTP-only 模式:跳过 MCP 配置写入');
939
+ // 只写权限配置和 Hook(可选,用于本地调试)
940
+ writeMcpPermissions();
941
+ console.log('[config] 已写入权限配置到 ~/.claude/settings.local.json');
942
+ fs.writeFileSync(VERSION_FILE, JSON.stringify({ version: VERSION, installedAt: Date.now() }, null, 2));
943
+ return { upgraded, previousVersion };
944
+ }
945
+ // 1. 强制写入 MCP 配置到 ~/.claude.json
779
946
  let claudeConfig = {};
780
947
  if (fs.existsSync(CLAUDE_CONFIG_FILE)) {
781
948
  const content = fs.readFileSync(CLAUDE_CONFIG_FILE, 'utf-8');
@@ -783,20 +950,42 @@ export function ensureGlobalConfigs() {
783
950
  }
784
951
  if (!claudeConfig.mcpServers)
785
952
  claudeConfig.mcpServers = {};
786
- // 强制覆盖(不检查是否存在)
787
- claudeConfig.mcpServers['wecom-aibot'] = {
788
- type: 'http',
789
- url: 'http://127.0.0.1:18963/mcp',
790
- };
953
+ if (mode === 'channel-only') {
954
+ // Channel-only 模式:必须通过 MCP_URL 指定远程地址
955
+ const mcpUrl = process.env.MCP_URL;
956
+ if (!mcpUrl) {
957
+ console.log('[config] ❌ Channel-only 模式需要指定 MCP_URL');
958
+ console.log('[config] 请设置环境变量: MCP_URL=http://远程IP:18963');
959
+ return { upgraded: false, previousVersion };
960
+ }
961
+ // Channel MCP 配置:硬编码本地路径
962
+ claudeConfig.mcpServers['wecom-aibot-channel'] = {
963
+ command: 'node',
964
+ args: ['/Volumes/Mac_Data/VScode/wecom-aibot-mcp/dist/bin.js', '--channel'],
965
+ env: { MCP_URL: mcpUrl },
966
+ };
967
+ console.log(`[config] Channel-only 模式:Channel MCP 使用本地路径`);
968
+ }
969
+ else {
970
+ // full 模式:同时写入 HTTP MCP 和 Channel MCP 配置
971
+ claudeConfig.mcpServers['wecom-aibot'] = {
972
+ type: 'http',
973
+ url: 'http://127.0.0.1:18963/mcp',
974
+ };
975
+ // Channel MCP 配置:硬编码本地路径
976
+ claudeConfig.mcpServers['wecom-aibot-channel'] = {
977
+ command: 'node',
978
+ args: ['/Volumes/Mac_Data/VScode/wecom-aibot-mcp/dist/bin.js', '--channel'],
979
+ env: { MCP_URL: 'http://127.0.0.1:18963' },
980
+ };
981
+ console.log(`[config] full 模式:Channel MCP 使用本地路径`);
982
+ }
791
983
  fs.writeFileSync(CLAUDE_CONFIG_FILE, JSON.stringify(claudeConfig, null, 2));
792
984
  console.log('[config] 已写入 MCP 配置到 ~/.claude.json');
793
985
  // 2. 强制写入权限配置和 Hook
794
986
  writeMcpPermissions();
795
987
  console.log('[config] 已写入权限配置到 ~/.claude/settings.local.json');
796
- // 3. 强制安装 skill
797
- installSkills();
798
- console.log('[config] 已安装 skill 到 ~/.claude/skills/');
799
- // 4. 写入版本号
988
+ // 3. 写入版本号
800
989
  fs.writeFileSync(VERSION_FILE, JSON.stringify({ version: VERSION, installedAt: Date.now() }, null, 2));
801
990
  console.log(`[config] 已记录版本号: ${VERSION}`);
802
991
  return { upgraded, previousVersion };
@@ -805,16 +994,19 @@ export function ensureGlobalConfigs() {
805
994
  export function saveConfig(config, instanceName) {
806
995
  ensureConfigDir(); // 确保运行时文件目录存在
807
996
  // 写入 MCP Server 配置到 ~/.claude.json
808
- writeMcpServerConfig(config, instanceName);
997
+ const success = writeMcpServerConfig(config, instanceName);
998
+ if (!success) {
999
+ return false;
1000
+ }
809
1001
  // 写入 MCP 工具权限和 Hook 到 ~/.claude/settings.local.json
810
1002
  writeMcpPermissions();
811
- // 安装 skill 到全局 ~/.claude/skills/(用户级别)
812
- installSkills();
813
- // 安装 skill 到项目目录(项目级别)
1003
+ // 安装 skill 到项目目录(项目级别,支持远程部署)
814
1004
  installSkill(process.cwd());
1005
+ return true;
815
1006
  }
816
1007
  /**
817
1008
  * 安装 headless-mode skill 到项目目录
1009
+ * 返回:{ success, skillUrl? } - 如果模板不存在,返回 HTTP endpoint URL
818
1010
  */
819
1011
  export function installSkill(projectDir) {
820
1012
  const skillDir = path.join(projectDir, '.claude', 'skills', 'headless-mode');
@@ -825,12 +1017,18 @@ export function installSkill(projectDir) {
825
1017
  }
826
1018
  // 检查模板文件是否存在
827
1019
  if (!fs.existsSync(SKILL_TEMPLATE_FILE)) {
828
- console.log('[config] Skill 模板文件不存在,跳过安装');
829
- return;
1020
+ console.log('[config] Skill 模板文件不存在,返回 HTTP endpoint URL');
1021
+ // 返回 HTTP endpoint URL,让 agent 通过 WebFetch 下载
1022
+ return {
1023
+ success: false,
1024
+ skillUrl: `${process.env.MCP_URL || 'http://127.0.0.1:18963'}/skill`,
1025
+ message: '请通过 skillUrl 下载 skill 文件并写入本地',
1026
+ };
830
1027
  }
831
1028
  // 写入 skill 文件
832
1029
  fs.copyFileSync(SKILL_TEMPLATE_FILE, skillFile);
833
1030
  console.log(`[config] 已安装 skill 到 ${skillFile}`);
1031
+ return { success: true };
834
1032
  }
835
1033
  // 创建 readline 接口
836
1034
  function createRL() {
@@ -866,69 +1064,96 @@ export async function runConfigWizard() {
866
1064
  console.log('╚════════════════════════════════════════════════════════════╝');
867
1065
  const rl = createRL();
868
1066
  try {
869
- // 检查是否有多个机器人配置
870
- const instances = listAllMcpInstances();
871
- let instanceName = 'wecom-aibot';
872
- let robotName = '';
873
- if (instances.length > 1) {
874
- // 多个机器人,让用户选择要修改哪个
875
- console.log('\n检测到多个机器人配置,请选择要修改的:\n');
876
- instances.forEach((inst, idx) => {
877
- console.log(` ${idx + 1}. ${inst.name} (Bot ID: ${inst.config.botId.slice(0, 12)}...)`);
1067
+ const robots = listAllRobots();
1068
+ let targetRobot = null;
1069
+ let isNewRobot = false;
1070
+ // 第一步:选择要修改的机器人
1071
+ if (robots.length === 0) {
1072
+ console.log('\n首次配置,将创建新机器人\n');
1073
+ isNewRobot = true;
1074
+ }
1075
+ else {
1076
+ console.log('\n请选择要操作的机器人:\n');
1077
+ robots.forEach((robot, idx) => {
1078
+ console.log(` ${idx + 1}. ${robot.name} (Bot ID: ${robot.botId.slice(0, 12)}...)`);
878
1079
  });
879
- console.log(` ${instances.length + 1}. 添加新机器人\n`);
1080
+ console.log(` ${robots.length + 1}. 添加新机器人\n`);
880
1081
  const choice = await question(rl, '请输入序号: ');
881
1082
  const choiceNum = parseInt(choice);
882
- if (choiceNum >= 1 && choiceNum <= instances.length) {
883
- instanceName = instances[choiceNum - 1].name;
884
- console.log(`\n已选择修改: ${instanceName}\n`);
1083
+ if (choiceNum >= 1 && choiceNum <= robots.length) {
1084
+ targetRobot = robots[choiceNum - 1];
1085
+ console.log(`\n已选择修改: ${targetRobot.name}\n`);
885
1086
  }
886
- else if (choiceNum === instances.length + 1) {
887
- // 添加新机器人
888
- const newName = await question(rl, '请输入新机器人名称: ');
889
- if (!newName) {
890
- console.log('[config] 机器人名称不能为空');
891
- process.exit(1);
892
- }
893
- robotName = newName;
894
- console.log(`\n将创建新机器人: ${robotName}\n`);
1087
+ else if (choiceNum === robots.length + 1) {
1088
+ isNewRobot = true;
1089
+ console.log('\n将创建新机器人\n');
895
1090
  }
896
1091
  else {
897
1092
  console.log('[config] 无效选择');
898
1093
  process.exit(1);
899
1094
  }
900
1095
  }
901
- else if (instances.length === 1) {
902
- instanceName = instances[0].name;
903
- console.log(`\n将修改现有配置: ${instanceName}\n`);
904
- }
905
- else {
906
- // 首次配置,要求输入机器人名称
907
- console.log('\n首次配置,请输入机器人信息:\n');
908
- const newName = await question(rl, '机器人名称(用于识别,如"工作机器人"): ');
909
- if (!newName) {
1096
+ // 第二步:输入机器人名称
1097
+ let robotName = await question(rl, `机器人名称(${targetRobot ? `当前: ${targetRobot.name}` : '用于识别'}): `);
1098
+ if (!robotName) {
1099
+ if (targetRobot) {
1100
+ robotName = targetRobot.name; // 保持原名称
1101
+ console.log(`[config] 保持原名称: ${robotName}`);
1102
+ }
1103
+ else {
910
1104
  console.log('[config] 机器人名称不能为空');
911
1105
  process.exit(1);
912
1106
  }
913
- robotName = newName;
914
- console.log(`\n将创建机器人: ${robotName}\n`);
915
1107
  }
916
- // 1. 获取 Bot ID
917
- let botId = await question(rl, 'Bot ID: ');
918
- while (!botId) {
919
- console.log('Bot ID 不能为空');
920
- botId = await question(rl, 'Bot ID: ');
1108
+ // 检查名称是否与其他机器人重复
1109
+ if (isNewRobot || (targetRobot && robotName !== targetRobot.name)) {
1110
+ const duplicateName = robots.find(r => r.name === robotName && r !== targetRobot);
1111
+ if (duplicateName) {
1112
+ console.log(`[config] 名称 "${robotName}" 已被使用`);
1113
+ process.exit(1);
1114
+ }
921
1115
  }
922
- // 2. 获取 Secret
923
- let secret = await question(rl, 'Secret: ');
924
- while (!secret) {
925
- console.log('Secret 不能为空');
926
- secret = await question(rl, 'Secret: ');
1116
+ // 第三步:输入 Bot ID
1117
+ let botId = await question(rl, `Bot ID(${targetRobot ? `当前: ${targetRobot.botId.slice(0, 12)}...` : '必填'}): `);
1118
+ if (!botId) {
1119
+ if (targetRobot) {
1120
+ botId = targetRobot.botId; // 保持原 Bot ID
1121
+ console.log(`[config] 保持原 Bot ID`);
1122
+ }
1123
+ else {
1124
+ console.log('Bot ID 不能为空');
1125
+ botId = await question(rl, 'Bot ID: ');
1126
+ if (!botId) {
1127
+ console.log('[config] Bot ID 不能为空');
1128
+ process.exit(1);
1129
+ }
1130
+ }
927
1131
  }
928
- // 3. 目标用户 ID 稍后通过消息自动识别
1132
+ // 第四步:输入 Secret
1133
+ let secret = await question(rl, `Secret(${targetRobot ? `当前: ${targetRobot.botId.slice(0, 8)}...` : '必填'}): `);
1134
+ if (!secret) {
1135
+ if (targetRobot) {
1136
+ // 读取原 Secret
1137
+ const configFile = findRobotConfigFile(targetRobot.name);
1138
+ if (configFile) {
1139
+ const config = JSON.parse(fs.readFileSync(configFile, 'utf-8'));
1140
+ secret = config.secret;
1141
+ console.log(`[config] 保持原 Secret`);
1142
+ }
1143
+ }
1144
+ else {
1145
+ console.log('Secret 不能为空');
1146
+ secret = await question(rl, 'Secret: ');
1147
+ if (!secret) {
1148
+ console.log('[config] Secret 不能为空');
1149
+ process.exit(1);
1150
+ }
1151
+ }
1152
+ }
1153
+ // 第五步:目标用户(稍后通过消息自动识别)
929
1154
  console.log('\n─────────────────────────────────────');
930
1155
  console.log('配置确认:');
931
- console.log(` 机器人名称: ${robotName || instanceName}`);
1156
+ console.log(` 机器人名称: ${robotName}`);
932
1157
  console.log(` Bot ID: ${botId}`);
933
1158
  console.log(` Secret: ${secret.slice(0, 8)}...${secret.slice(-4)}`);
934
1159
  console.log(` 目标用户: (将通过消息自动识别)`);
@@ -943,14 +1168,72 @@ export async function runConfigWizard() {
943
1168
  botId,
944
1169
  secret,
945
1170
  targetUserId: '', // 稍后通过消息识别
946
- nameTag: robotName || undefined,
1171
+ nameTag: robotName,
947
1172
  };
1173
+ // 如果是修改现有机器人,返回其 instanceName(用于删除旧配置)
1174
+ const instanceName = targetRobot ? targetRobot.name : 'wecom-aibot';
948
1175
  return { config, instanceName };
949
1176
  }
950
1177
  finally {
951
1178
  rl.close();
952
1179
  }
953
1180
  }
1181
+ // 查找机器人配置文件路径(按名称)
1182
+ export function findRobotConfigFile(robotName) {
1183
+ // 检查默认配置文件
1184
+ if (fs.existsSync(BOT_CONFIG_FILE)) {
1185
+ const config = JSON.parse(fs.readFileSync(BOT_CONFIG_FILE, 'utf-8'));
1186
+ const name = config.nameTag || `机器人-${config.botId?.slice(0, 8) || 'unknown'}`;
1187
+ if (name === robotName) {
1188
+ return BOT_CONFIG_FILE;
1189
+ }
1190
+ }
1191
+ // 检查其他机器人配置文件
1192
+ if (fs.existsSync(CONFIG_DIR)) {
1193
+ const files = fs.readdirSync(CONFIG_DIR).filter(f => f.startsWith('robot-') && f.endsWith('.json'));
1194
+ for (const file of files) {
1195
+ const filePath = path.join(CONFIG_DIR, file);
1196
+ const config = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
1197
+ const name = config.nameTag || file.replace('.json', '');
1198
+ if (name === robotName) {
1199
+ return filePath;
1200
+ }
1201
+ }
1202
+ }
1203
+ return null;
1204
+ }
1205
+ // 查找机器人配置文件路径(按 botId)
1206
+ export function findRobotConfigFileByBotId(botId) {
1207
+ // 检查默认配置文件
1208
+ if (fs.existsSync(BOT_CONFIG_FILE)) {
1209
+ const config = JSON.parse(fs.readFileSync(BOT_CONFIG_FILE, 'utf-8'));
1210
+ if (config.botId === botId) {
1211
+ return BOT_CONFIG_FILE;
1212
+ }
1213
+ }
1214
+ // 检查其他机器人配置文件
1215
+ if (fs.existsSync(CONFIG_DIR)) {
1216
+ const files = fs.readdirSync(CONFIG_DIR).filter(f => f.startsWith('robot-') && f.endsWith('.json'));
1217
+ for (const file of files) {
1218
+ const filePath = path.join(CONFIG_DIR, file);
1219
+ const config = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
1220
+ if (config.botId === botId) {
1221
+ return filePath;
1222
+ }
1223
+ }
1224
+ }
1225
+ return null;
1226
+ }
1227
+ // 检查机器人名称是否已存在(排除指定 botId)
1228
+ export function isRobotNameExists(name, excludeBotId) {
1229
+ const robots = listAllRobots();
1230
+ for (const robot of robots) {
1231
+ if (robot.name === name && robot.botId !== excludeBotId) {
1232
+ return true;
1233
+ }
1234
+ }
1235
+ return false;
1236
+ }
954
1237
  /**
955
1238
  * 通过等待用户消息来识别用户 ID(使用已有的 client)
956
1239
  */
@@ -1029,9 +1312,9 @@ export async function getOrInitConfig() {
1029
1312
  }
1030
1313
  // 3. 非 TTY(MCP stdio 模式)不能启动交互向导
1031
1314
  if (!process.stdin.isTTY) {
1032
- console.error('[config] 未找到配置,且当前为非交互模式。');
1033
- console.error('[config] 请在 ~/.claude.json 的 mcpServers 中设置环境变量:');
1034
- console.error('[config] WECOM_BOT_ID, WECOM_SECRET, WECOM_TARGET_USER');
1315
+ logger.error('[config] 未找到配置,且当前为非交互模式。');
1316
+ logger.error('[config] 请在 ~/.claude.json 的 mcpServers 中设置环境变量:');
1317
+ logger.error('[config] WECOM_BOT_ID, WECOM_SECRET, WECOM_TARGET_USER');
1035
1318
  process.exit(1);
1036
1319
  }
1037
1320
  // 4. TTY 模式下运行配置向导