imtoagent 0.2.5 → 0.3.1

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.
package/index.ts CHANGED
@@ -51,18 +51,12 @@ registerIM('telegram', {
51
51
  },
52
52
  });
53
53
 
54
- // 注册企业微信
54
+ // 注册企业微信(扫码绑定,无需预填凭证)
55
55
  registerIM('wecom', {
56
56
  create(cfg: BotConfig) {
57
57
  return new WeComIMModule({
58
- corpId: cfg.appId,
59
- corpSecret: cfg.appSecret,
60
- agentId: (cfg as any).agentId,
61
- token: (cfg as any).token,
62
- encodingAESKey: (cfg as any).encodingAESKey,
63
- receiveId: (cfg as any).receiveId,
64
- webhookPort: (cfg as any).webhookPort,
65
- webhookPath: (cfg as any).webhookPath,
58
+ botId: (cfg as any).botId,
59
+ secret: (cfg as any).secret,
66
60
  });
67
61
  },
68
62
  });
@@ -82,7 +76,7 @@ import { getProxyUsage, resetProxyUsage, initCodexProxyConfig } from './modules/
82
76
  import { initOpenCodeConfig } from './modules/agent/opencode';
83
77
  import { checkRateLimit, setRateLimitConfig } from './modules/rate-limiter';
84
78
  import { setCurrentBot } from './modules/bot-context';
85
- import { getDataDir, getSessionsDir, getSoulDir, getRestoreMarkerPath } from './modules/utils/paths';
79
+ import { getDataDir, getSessionsDir, getSoulDir, getBotKey, getRestoreMarkerPath } from './modules/utils/paths';
86
80
 
87
81
  // ===== SDK 核心 =====
88
82
  import { AgentRuntime, FileSessionManager, DefaultErrorHandler, DefaultStatsTracker } from './modules/core';
@@ -281,6 +275,7 @@ type CommandHandler = (ctx: CommandCtx) => Promise<string> | string;
281
275
  // BotConfig
282
276
  // ================================================================
283
277
  interface BotConfig {
278
+ id?: string;
284
279
  name: string;
285
280
  appId: string;
286
281
  appSecret: string;
@@ -292,6 +287,7 @@ interface BotConfig {
292
287
  // Bot 类 — SDK 完整接入版
293
288
  // ================================================================
294
289
  class Bot {
290
+ id: string;
295
291
  name: string;
296
292
  backend: 'claude' | 'codex' | 'opencode';
297
293
  appId: string;
@@ -313,6 +309,7 @@ class Bot {
313
309
  adapter: AgentAdapter;
314
310
 
315
311
  constructor(cfg: BotConfig, globalConfig: any) {
312
+ this.id = cfg.id || cfg.name; // 后向兼容:无 id 时用 name
316
313
  this.name = cfg.name;
317
314
  this.backend = cfg.backend;
318
315
  this.appId = cfg.appId;
@@ -353,7 +350,7 @@ class Bot {
353
350
  this.im = imFactory.create(cfg);
354
351
 
355
352
  // ===== SDK 集成 =====
356
- this.sessionManager = new CustomSessionManager(this.name, this.sessions);
353
+ this.sessionManager = new CustomSessionManager(this.id, this.sessions);
357
354
 
358
355
  const adapterCtx = {
359
356
  imModule: this.im,
@@ -388,7 +385,7 @@ class Bot {
388
385
  }
389
386
 
390
387
  // ===== 灵魂管理 =====
391
- _soulDir() { return getSoulDir(this.name); }
388
+ _soulDir() { return getSoulDir(this.id); }
392
389
 
393
390
  _initSoul() {
394
391
  const dir = this._soulDir();
@@ -438,7 +435,7 @@ class Bot {
438
435
  }
439
436
 
440
437
  // ===== Bot 配置 =====
441
- _botConfigPath() { return path.join(getSessionsDir(), this.name, '_bot.json'); }
438
+ _botConfigPath() { return path.join(getSessionsDir(), this.id, '_bot.json'); }
442
439
 
443
440
  _loadBotConfig() {
444
441
  try {
@@ -698,7 +695,7 @@ class Bot {
698
695
  const cmdResp = await this.tryHandleCommand(chatId, text, session);
699
696
  if (cmdResp !== null) {
700
697
  await this.reply(chatId, cmdResp);
701
- this.sessionManager.persist(this.name, session);
698
+ this.sessionManager.persist(this.id, session);
702
699
  return;
703
700
  }
704
701
 
@@ -719,7 +716,7 @@ class Bot {
719
716
  sendProgress: async (t: string) => this.sendProgress(chatId, t),
720
717
  sendBlocks: async (blocks) => this.sendFormattedReplyDirect(chatId, blocks),
721
718
  imCaps: this.im.getCapabilities(),
722
- }, this.adapter, this.name);
719
+ }, this.adapter, this.id);
723
720
 
724
721
  // Agent 自主重启信号检测
725
722
  if (result?.restart) {
@@ -1017,7 +1014,7 @@ async function main() {
1017
1014
  // 避免 --resume 恢复重启前残留的 Claude CLI 子进程 session
1018
1015
  for (const bot of bots) {
1019
1016
  if (bot.backend !== 'claude') continue;
1020
- const botDir = path.join(getSessionsDir(), bot.name);
1017
+ const botDir = path.join(getSessionsDir(), bot.id);
1021
1018
  try {
1022
1019
  if (fs.existsSync(botDir)) {
1023
1020
  for (const file of fs.readdirSync(botDir)) {
@@ -1052,7 +1049,7 @@ async function main() {
1052
1049
  const summary = `🔄 IMtoAgent 已重启\n原因: ${reason}\n耗时: ${(uptime / 1000).toFixed(1)}s`;
1053
1050
  let sent = 0;
1054
1051
  for (const bot of bots) {
1055
- const snap = data.bots?.[bot.name];
1052
+ const snap = data.bots?.[bot.id];
1056
1053
  if (!snap?.chats?.length) continue;
1057
1054
  for (const { chatId } of snap.chats) {
1058
1055
  try { await bot.reply(chatId, summary); sent++; break; }
@@ -1087,7 +1084,7 @@ async function main() {
1087
1084
  console.log('[Shutdown] 持久化所有 session...');
1088
1085
  for (const bot of bots) {
1089
1086
  for (const [chatId, session] of bot.sessions.entries()) {
1090
- try { bot.sessionManager.persist(bot.name, session); } catch {}
1087
+ try { bot.sessionManager.persist(bot.id, session); } catch {}
1091
1088
  }
1092
1089
  }
1093
1090
  const DRAIN_TIMEOUT = 10_000;
@@ -1,219 +1,428 @@
1
1
  // ================================================================
2
- // setup.ts — 交互式配置向导
2
+ // setup.ts — 交互式配置向导(v2)
3
3
  // ================================================================
4
- // 零依赖,使用 Bun 原生 prompt()
5
- // 通过 `imtoagent setup` 调用
4
+ // 交互方式:
5
+ // ↑↓ 空格 切换选项
6
+ // 回车 — 确认选择
7
+ // ESC — 返回上一步
8
+ //
9
+ // 支持的 IM 平台:飞书 / Telegram / 企业微信 / 个人微信
6
10
  // ================================================================
7
11
 
8
12
  import * as fs from 'fs';
9
13
  import * as os from 'os';
10
14
  import * as path from 'path';
11
- import * as readline from 'readline';
12
- import { getDataDir, getPkgDir, getTemplatePath, getTemplateSoulPath, getSoulDir } from '../utils/paths';
13
- import { checkAllBackends, formatBackendStatus, checkBackend } from '../utils/backend-check';
15
+ import { getDataDir, getPkgDir, getTemplatePath, getSoulDir, getBotKey } from '../utils/paths';
16
+ import { randomUUID } from 'crypto';
17
+ import { checkAllBackends, formatBackendStatus } from '../utils/backend-check';
18
+
19
+ // ================================================================
20
+ // 键盘输入(raw mode)
21
+ // ================================================================
22
+
23
+ const KEY = {
24
+ UP: '\x1b[A',
25
+ DOWN: '\x1b[B',
26
+ ENTER: '\r',
27
+ SPACE: ' ',
28
+ ESC: '\x1b',
29
+ BACKSPACE: '\x7f',
30
+ };
31
+
32
+ /** 读取单个按键 */
33
+ function readKey(): Promise<string> {
34
+ return new Promise((resolve) => {
35
+ const onData = (data: Buffer) => {
36
+ process.stdin.removeListener('data', onData);
37
+ const s = data.toString();
38
+ if (s === '\x03') process.exit(130); // Ctrl+C
39
+ resolve(s);
40
+ };
41
+ process.stdin.once('data', onData);
42
+ });
43
+ }
44
+
45
+ // ================================================================
46
+ // 菜单选择(↑↓/空格 切换,回车确认)
47
+ // ================================================================
48
+
49
+ async function selectMenu(title: string, options: string[]): Promise<number> {
50
+ let idx = 0;
51
+ const linesAbove = options.length + 2;
52
+
53
+ function render() {
54
+ // 清除之前的输出
55
+ process.stdout.write('\x1B[0G'); // 回到行首
56
+ options.forEach((opt, i) => {
57
+ const prefix = i === idx ? '▸ ' : ' ';
58
+ process.stdout.write(`\x1B[0G${prefix}${opt}\x1B[0K\n`);
59
+ });
60
+ }
61
+
62
+ // 显示标题
63
+ console.log(title);
64
+ render();
65
+
66
+ process.stdin.setRawMode(true);
67
+ process.stdin.resume();
68
+
69
+ try {
70
+ while (true) {
71
+ const key = await readKey();
72
+
73
+ if (key === KEY.UP || key === KEY.DOWN || key === KEY.SPACE) {
74
+ // 移动光标
75
+ idx = (idx + (key === KEY.UP ? -1 : 1) + options.length) % options.length;
76
+ // 重绘所有选项
77
+ process.stdout.write(`\x1B[${options.length}A`); // 上移 N 行
78
+ render();
79
+ } else if (key === KEY.ENTER) {
80
+ break;
81
+ } else if (key === KEY.ESC) {
82
+ return -1; // ESC = 返回上一步
83
+ }
84
+ }
85
+ } finally {
86
+ process.stdin.setRawMode(false);
87
+ process.stdin.pause();
88
+ }
89
+
90
+ console.log(`\x1B[0G▸ ${options[idx]} ✓\n`);
91
+ return idx;
92
+ }
93
+
94
+ // ================================================================
95
+ // 文本输入(回车确认,ESC 返回 -1)
96
+ // ================================================================
97
+
98
+ async function promptText(label: string, defaultValue = ''): Promise<string> {
99
+ const buf: string[] = [];
100
+ const defaultHint = defaultValue ? ` [${defaultValue}]` : '';
101
+
102
+ process.stdout.write(`${label}${defaultHint}: `);
103
+ process.stdin.setRawMode(true);
104
+ process.stdin.resume();
105
+
106
+ try {
107
+ while (true) {
108
+ const key = await readKey();
109
+
110
+ if (key === KEY.ENTER) {
111
+ break;
112
+ } else if (key === KEY.ESC) {
113
+ process.stdout.write('\x1B[0K\n');
114
+ return -1 as unknown as string; // 特殊返回值表示 ESC
115
+ } else if (key === KEY.BACKSPACE) {
116
+ if (buf.length > 0) {
117
+ buf.pop();
118
+ process.stdout.write('\x1B[1D \x1B[1D'); // 退格删除
119
+ }
120
+ } else if (key === KEY.UP || key === KEY.DOWN) {
121
+ // 忽略方向键
122
+ } else if (key.length === 1 && key !== KEY.SPACE) {
123
+ // 普通字符(空格单独处理)
124
+ buf.push(key);
125
+ process.stdout.write(key);
126
+ } else if (key === KEY.SPACE) {
127
+ buf.push(' ');
128
+ process.stdout.write(' ');
129
+ }
130
+ }
131
+ } finally {
132
+ process.stdin.setRawMode(false);
133
+ process.stdin.pause();
134
+ }
135
+
136
+ process.stdout.write('\n');
137
+ const result = buf.join('').trim();
138
+ return result || defaultValue;
139
+ }
140
+
141
+ // ================================================================
142
+ // 确认(Y/N,回车确认,ESC 返回 -1)
143
+ // ================================================================
144
+
145
+ async function confirm(label: string, defaultYes = true): Promise<boolean | -1> {
146
+ const hint = defaultYes ? '[Y/n]' : '[y/N]';
147
+ process.stdout.write(`${label} ${hint}: `);
148
+ process.stdin.setRawMode(true);
149
+ process.stdin.resume();
150
+
151
+ try {
152
+ while (true) {
153
+ const key = await readKey();
154
+ if (key === KEY.ENTER) {
155
+ process.stdout.write('\n');
156
+ return defaultYes;
157
+ } else if (key === KEY.ESC) {
158
+ process.stdout.write('\x1B[0K\n');
159
+ return -1;
160
+ } else if (key.toLowerCase() === 'y') {
161
+ process.stdout.write('Y\n');
162
+ return true;
163
+ } else if (key.toLowerCase() === 'n') {
164
+ process.stdout.write('N\n');
165
+ return false;
166
+ }
167
+ }
168
+ } finally {
169
+ process.stdin.setRawMode(false);
170
+ process.stdin.pause();
171
+ }
172
+ }
173
+
174
+ // ================================================================
175
+ // IM 平台配置定义
176
+ // ================================================================
177
+
178
+ const IM_PLATFORMS = [
179
+ { value: 'feishu', label: '飞书', desc: 'WebSocket 长连接' },
180
+ { value: 'telegram', label: 'Telegram', desc: '长轮询' },
181
+ { value: 'wecom', label: '企业微信', desc: '扫码绑定 + WebSocket' },
182
+ { value: 'wechat', label: '个人微信', desc: 'iLink + QR 扫码' },
183
+ ];
184
+
185
+ /** 每种 IM 需要的凭证字段 */
186
+ const IM_FIELDS: Record<string, { key: string; label: string; required: boolean }[]> = {
187
+ feishu: [
188
+ { key: 'appId', label: '飞书 App ID (cli_...)', required: true },
189
+ { key: 'appSecret', label: '飞书 App Secret', required: true },
190
+ ],
191
+ telegram: [
192
+ { key: 'appId', label: 'Bot Token', required: true },
193
+ { key: 'proxy', label: '代理地址(留空不使用)', required: false },
194
+ ],
195
+ wecom: [
196
+ // 企业微信使用扫码绑定,无需预填凭证,启动时自动触发 QR 扫码
197
+ ],
198
+ wechat: [
199
+ // 微信通过 iLink + QR 扫码认证,无需预填凭证
200
+ { key: 'botId', label: 'iLink Bot ID(留空使用 QR 扫码)', required: false },
201
+ { key: 'botToken', label: 'iLink Bot Token(留空使用 QR 扫码)', required: false },
202
+ ],
203
+ };
14
204
 
15
205
  // ================================================================
16
206
  // 主流程
17
207
  // ================================================================
208
+
18
209
  export async function runSetupWizard(): Promise<void> {
19
210
  const dataDir = getDataDir();
20
211
  const configPath = path.join(dataDir, 'config.json');
21
- const providersPath = path.join(dataDir, 'providers.json');
22
- const opencodePath = path.join(dataDir, 'opencode.json');
23
212
 
24
213
  console.log('\n╔══════════════════════════════════════════════╗');
25
214
  console.log('║ 🚀 imtoagent 配置向导 ║');
26
- console.log('╚══════════════════════════════════════════════╝\n');
27
- console.log(`数据目录: ${dataDir}\n`);
215
+ console.log('╚══════════════════════════════════════════════╝');
216
+ console.log(`\n数据目录: ${dataDir}`);
217
+ console.log(`操作提示: ↑↓/空格 切换 | 回车确认 | ESC 返回\n`);
28
218
 
29
219
  // ===== Step 1: 检测已有配置 =====
30
220
  let existingConfig: any = null;
221
+ let mergeMode = false;
222
+
31
223
  if (fs.existsSync(configPath)) {
32
- console.log('📋 检测到已有配置。');
33
- console.log(' [1] 覆盖现有配置');
34
- console.log(' [2] 合并(保留现有 bot,添加新的)');
35
- console.log(' [3] 退出');
36
-
37
- const choice = await prompt('请选择 (1/2/3): ');
38
- if (choice === '3' || choice.toLowerCase() === 'exit') {
39
- console.log('👋 已取消');
40
- process.exit(0);
41
- }
224
+ console.log('📋 检测到已有配置\n');
225
+ const idx = await selectMenu('选择操作', ['覆盖现有配置', '合并(保留现有 Bot)', '退出']);
226
+ if (idx === -1) return; // ESC
227
+ if (idx === 2) { console.log('👋 已取消'); process.exit(0); }
228
+ if (idx === 1) mergeMode = true;
42
229
 
43
230
  try {
44
231
  existingConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
45
232
  } catch {
46
233
  console.log('⚠️ 现有配置文件解析失败,将重新生成');
47
234
  existingConfig = null;
235
+ mergeMode = false;
48
236
  }
49
237
  }
50
238
 
51
- // ===== Step 1.5: 检测后端安装状态 =====
52
- console.log('📌 检测后端 Agent...\n');
239
+ // ===== Step 2: 检测后端 =====
240
+ console.log('\n📌 检测后端 Agent...\n');
53
241
  const backendStatus = checkAllBackends();
54
242
  console.log(formatBackendStatus(backendStatus));
55
243
 
56
244
  const installedBackends = backendStatus.filter(b => b.installed);
57
245
  if (installedBackends.length === 0) {
58
246
  console.log('\n⚠️ 未检测到任何后端 Agent。');
59
- console.log('你需要先安装至少一个后端 Agent 才能使用 imtoagent。');
60
- console.log('\n推荐安装:');
247
+ console.log('推荐安装:');
61
248
  console.log(' npm install -g @anthropic-ai/claude-agent-sdk # Claude Code');
62
249
  console.log(' npm install -g @openai/codex # Codex');
63
250
  console.log(' npm install -g opencode # OpenCode');
64
- const proceed = await promptChoice('暂不配置 Bot,先退出?', ['Y', 'N']);
65
- if (proceed === 'Y') {
66
- console.log('\n👋 安装后端后请重新运行 "imtoagent setup"');
67
- process.exit(0);
68
- }
69
- console.log('\n⚠️ 你可以继续配置 Bot,但启动网关后发消息会报错,直到后端安装完成。\n');
251
+ const r = await confirm('是否继续配置?(启动后发消息会报错,直到后端安装)', false);
252
+ if (r === false || r === -1) { console.log('\n👋 安装后端后请重新运行 "imtoagent setup"'); process.exit(0); }
253
+ console.log('\n⚠️ 已跳过,你可以稍后安装后端。\n');
70
254
  } else {
71
- console.log(`\n✅ 已安装 ${installedBackends.length} 个后端: ${installedBackends.map(b => b.label).join(', ')}\n`);
255
+ console.log(`\n✅ 已安装: ${installedBackends.map(b => b.label).join(', ')}\n`);
72
256
  }
73
257
 
74
- // ===== Step 2: 配置 Bot =====
75
- console.log('📌 Step 2: 配置 Bot\n');
76
-
77
- const bots: any[] = existingConfig?.bots && (await promptChoice('保留现有 Bot?', ['Y', 'N'])) !== 'N'
78
- ? [...existingConfig.bots]
79
- : [];
80
-
81
- let addMore = true;
82
- while (addMore) {
83
- console.log(`\n--- 添加新 Bot (已有 ${bots.length} 个) ---`);
84
-
85
- const name = await prompt('Bot 名称 (如 ClaudeBot): ');
86
- if (!name) { addMore = false; continue; }
87
-
88
- const imType = await promptChoice('IM 平台', ['feishu', 'telegram']);
89
-
90
- // 后端选项:优先推荐已安装的,未安装的标 ⚠️
91
- const backendOptions = backendStatus.map(b => {
92
- const label = b.installed ? `${b.label} (v${b.version})` : `${b.label} ⚠️ 未安装`;
93
- return { value: b.type, label };
94
- });
95
- const backendLabels = backendOptions.map(o => o.label);
96
- const backendChoice = await promptChoice('后端', backendLabels);
97
- const backend = backendOptions.find(o => o.label === backendChoice)?.value || 'claude';
98
- const isBackendInstalled = backendStatus.find(b => b.type === backend)?.installed;
99
-
258
+ // ===== Step 3: 配置 Bot =====
259
+ console.log('📌 Step 3: 配置 Bot\n');
260
+
261
+ const bots: any[] = (mergeMode && existingConfig?.bots) ? [...existingConfig.bots] : [];
262
+
263
+ let addingBots = true;
264
+ while (addingBots) {
265
+ console.log(`\n--- 添加新 Bot (已有 ${bots.length} 个) ---\n`);
266
+
267
+ // 3a: 选择 IM 平台
268
+ const imLabels = IM_PLATFORMS.map(p => `${p.label} ${p.desc}`);
269
+ const imIdx = await selectMenu('选择 IM 平台', imLabels);
270
+ if (imIdx === -1) { if (bots.length === 0) return; break; } // ESC
271
+ const imType = IM_PLATFORMS[imIdx].value;
272
+
273
+ // 3b: 自动生成 Bot 名称,可自定义
274
+ const defaultName = IM_PLATFORMS[imIdx].label + 'Bot';
275
+ const nameInput = await promptText('Bot 名称', defaultName);
276
+ if ((nameInput as any) === -1) { if (bots.length === 0) return; break; } // ESC
277
+ const botName = nameInput || defaultName; // 留空用默认
278
+
279
+ // 3c: 选择后端
280
+ const backendLabels = backendStatus.map(b =>
281
+ b.installed ? `${b.label} (v${b.version})` : `${b.label} ⚠️ 未安装`
282
+ );
283
+ const backendIdx = await selectMenu('选择后端', backendLabels);
284
+ if (backendIdx === -1) continue; // ESC 返回重新选 IM
285
+ const backend = backendStatus[backendIdx].type;
286
+ const isBackendInstalled = backendStatus[backendIdx].installed;
287
+
288
+ // 后端未安装 → 提示自动安装
100
289
  if (!isBackendInstalled) {
101
- const installCmd = backendStatus.find(b => b.type === backend)?.installHint || '';
102
- console.log(`\n⚠️ ${backend} 未安装。`);
103
- console.log(` 安装提示: ${installCmd}\n`);
104
-
105
- const rl = readline.createInterface({
106
- input: process.stdin,
107
- output: process.stdout,
108
- });
109
-
110
- const answer = await new Promise<string>((resolve) => {
111
- rl.question(`🔧 是否现在自动安装?[Y/n]: `, resolve);
112
- });
113
- rl.close();
114
-
115
- if (answer.trim().toLowerCase() !== 'n') {
290
+ const installCmd = backendStatus[backendIdx].installHint || '';
291
+ console.log(`\n⚠️ ${backend} 未安装`);
292
+ console.log(` ${installCmd}\n`);
293
+ const r = await confirm('是否现在自动安装?');
294
+ if (r === true) {
116
295
  const { installBackend } = await import('../utils/backend-check');
117
296
  const ok = await installBackend(backend as 'claude' | 'codex' | 'opencode');
118
- if (!ok) {
119
- console.log(`\n⚠️ 安装失败,你可以稍后手动运行: ${installCmd}`);
120
- const rl2 = readline.createInterface({
121
- input: process.stdin,
122
- output: process.stdout,
123
- });
124
- const retry = await new Promise<string>((resolve) => {
125
- rl2.question('是否仍然选择此 Bot?[y/N]: ', resolve);
126
- });
127
- rl2.close();
128
- if (retry.trim().toLowerCase() !== 'y') {
129
- addMore = (await promptChoice('继续添加其他 Bot?', ['Y', 'N'])) !== 'Y';
130
- continue;
131
- }
297
+ if (ok) {
298
+ console.log(`✅ ${backend} 已安装\n`);
132
299
  } else {
133
- console.log(`✅ ${backend} 已安装,继续配置。\n`);
300
+ console.log(`⚠️ 安装失败,可稍后手动运行: ${installCmd}\n`);
301
+ const r2 = await confirm('仍要继续配置此 Bot?');
302
+ if (r2 === false) continue;
134
303
  }
304
+ } else if (r === -1) {
305
+ continue; // ESC 返回
135
306
  } else {
136
- console.log(`跳过安装。你可以稍后手动运行: ${installCmd}\n`);
137
- const confirm = await promptChoice('仍要继续配置此 Bot?', ['Y', 'N']);
138
- if (confirm !== 'Y') {
139
- addMore = (await promptChoice('继续添加其他 Bot?', ['Y', 'N'])) !== 'Y';
140
- continue;
141
- }
307
+ console.log('跳过安装\n');
142
308
  }
143
309
  }
144
310
 
145
- let appId = '', appSecret = '', proxy = '', cwd = '';
311
+ // 3d: 根据 IM 类型收集凭证
312
+ console.log(`\n--- ${IM_PLATFORMS.find(p => p.value === imType)?.label} 凭证 ---`);
313
+ const fields = IM_FIELDS[imType] || [];
314
+ const credentials: Record<string, string> = {};
146
315
 
147
- if (imType === 'feishu') {
148
- appId = await prompt('飞书 App ID (cli_...): ');
149
- appSecret = await prompt('飞书 App Secret: ');
150
- } else {
151
- appId = await prompt('Telegram Bot Token: ');
152
- proxy = await prompt('代理地址 (留空不使用代理): ') || '';
316
+ for (const field of fields) {
317
+ const val = await promptText(field.label + (field.required ? '' : '(可选)'));
318
+ if ((val as any) === -1) { credentials._escaped = 'true'; break; } // ESC
319
+ credentials[field.key] = val;
153
320
  }
321
+ if (credentials._escaped) continue; // ESC 返回重新选后端
322
+
323
+ // 3e: 工作目录
324
+ const cwd = await promptText('工作目录', os.homedir());
325
+ if ((cwd as any) === -1) continue;
154
326
 
155
- cwd = await prompt('工作目录 (如 /Users/keyi/Desktop,留空用默认): ') || os.homedir();
327
+ // 生成唯一 ID(UUID,用于目录隔离,改名不影响)
328
+ const botId = randomUUID();
156
329
 
330
+ // 构建 Bot 配置(不同 IM 需要的字段不同)
157
331
  const bot: any = {
158
- name,
159
- appId,
160
- appSecret,
332
+ id: botId,
333
+ name: botName,
161
334
  backend,
162
- im: imType === 'feishu' ? undefined : 'telegram',
163
- cwd,
335
+ cwd: cwd || os.homedir(),
164
336
  };
165
- if (proxy) bot.proxy = proxy;
337
+
338
+ // 飞书需要 appId + appSecret
339
+ if (imType === 'feishu') {
340
+ bot.appId = credentials.appId || '';
341
+ bot.appSecret = credentials.appSecret || '';
342
+ }
343
+ // Telegram 需要 appId(Bot Token),可选 proxy
344
+ else if (imType === 'telegram') {
345
+ bot.appId = credentials.appId || '';
346
+ if (credentials.proxy) bot.proxy = credentials.proxy;
347
+ }
348
+ // 企业微信:扫码绑定,无需预填凭证(可选 botId/secret)
349
+ else if (imType === 'wecom') {
350
+ bot.im = 'wecom';
351
+ if (credentials.botId) bot.botId = credentials.botId;
352
+ if (credentials.secret) bot.secret = credentials.secret;
353
+ }
354
+ // 个人微信:可选 botId/botToken,留空则 QR 扫码
355
+ else if (imType === 'wechat') {
356
+ bot.im = 'wechat';
357
+ if (credentials.botId) bot.botId = credentials.botId;
358
+ if (credentials.botToken) bot.botToken = credentials.botToken;
359
+ if (credentials.ilinkUserId) bot.ilinkUserId = credentials.ilinkUserId;
360
+ }
361
+ // 默认:非飞书平台加 im 字段
362
+ else {
363
+ bot.im = imType;
364
+ }
166
365
 
167
366
  // 检查重名
168
- const existing = bots.findIndex(b => b.name === name);
169
- if (existing >= 0) {
170
- bots[existing] = bot;
367
+ const existingIdx = bots.findIndex(b => b.name === botName);
368
+ if (existingIdx >= 0) {
369
+ bots[existingIdx] = bot;
171
370
  console.log(`✅ 已替换: ${name}`);
172
371
  } else {
173
372
  bots.push(bot);
174
373
  console.log(`✅ 已添加: ${name}`);
175
374
  }
176
375
 
177
- addMore = (await promptChoice('继续添加 Bot?', ['Y', 'N'])) === 'Y';
376
+ // 是否继续添加
377
+ const r = await confirm('继续添加其他 Bot?', true);
378
+ if (r === -1) addingBots = false; // ESC = 不添加了,进入下一步
379
+ else addingBots = (r === true);
178
380
  }
179
381
 
180
382
  if (bots.length === 0) {
181
383
  console.log('\n⚠️ 未配置任何 Bot。');
182
- if ((await promptChoice('至少配置一个 Bot 吗?', ['Y', 'N'])) === 'Y') {
183
- return runSetupWizard(); // 重新开始
184
- }
384
+ const r = await confirm('至少配置一个 Bot 吗?');
385
+ if (r === true) return runSetupWizard();
386
+ console.log('\n⚠️ 至少需要一个 Bot,配置已取消');
387
+ return;
185
388
  }
186
389
 
187
- // ===== Step 3: 配置模型供应商 =====
188
- console.log('\n📌 Step 3: 配置模型供应商\n');
390
+ // ===== Step 4: 配置模型供应商 =====
391
+ console.log('\n📌 Step 4: 配置模型供应商\n');
189
392
 
190
393
  const providers: Record<string, any> = {};
191
- let existingProviders = existingConfig?.providers || {};
192
- const keepProviders = Object.keys(existingProviders).length > 0
193
- && (await promptChoice('保留现有供应商?', ['Y', 'N'])) !== 'N';
194
-
195
- if (keepProviders) {
196
- Object.assign(providers, existingProviders);
394
+ if (mergeMode && existingConfig?.providers) {
395
+ Object.assign(providers, existingConfig.providers);
396
+ console.log(`✅ 已保留 ${Object.keys(providers).length} 个现有供应商\n`);
197
397
  }
198
398
 
199
- let addProvider = true;
200
- while (addProvider) {
201
- console.log('\n--- 添加新供应商 ---');
202
- const provName = await prompt('供应商名称 (如 deepseek, dashscope): ');
203
- if (!provName) { addProvider = false; continue; }
399
+ let addingProviders = true;
400
+ while (addingProviders) {
401
+ console.log('--- 添加新供应商 ---\n');
402
+ const provName = await promptText('供应商名称 (如 deepseek, dashscope)');
403
+ if ((provName as any) === -1) { addingProviders = false; continue; }
404
+ if (!provName) { addingProviders = false; continue; }
204
405
 
205
406
  if (providers[provName]) {
206
- console.log(`⚠️ 供应商 "${provName}" 已存在,将覆盖`);
407
+ console.log(`⚠️ 供应商 "${provName}" 已存在,将覆盖\n`);
207
408
  }
208
409
 
209
- const baseUrl = await prompt('Base URL (如 https://api.deepseek.com/v1): ');
210
- const apiKey = await prompt('API Key: ');
211
- const modelsStr = await prompt('模型列表 (逗号分隔,如 deepseek-v4-pro,deepseek-v4-flash): ');
212
- const models = modelsStr.split(',').map(s => s.trim()).filter(Boolean);
213
- const format = await promptChoice('API 格式', ['openai', 'anthropic']);
410
+ const baseUrl = await promptText('Base URL (如 https://api.deepseek.com/v1)');
411
+ if ((baseUrl as any) === -1) continue;
412
+ const apiKey = await promptText('API Key');
413
+ if ((apiKey as any) === -1) continue;
414
+ const modelsStr = await promptText('模型列表 (逗号分隔,如 deepseek-v4-pro,deepseek-v4-flash)');
415
+ if ((modelsStr as any) === -1) continue;
416
+ const models = (modelsStr || '').split(',').map(s => s.trim()).filter(Boolean);
417
+
418
+ const formatIdx = await selectMenu('API 格式', ['openai', 'anthropic']);
419
+ if (formatIdx === -1) continue;
420
+ const format = ['openai', 'anthropic'][formatIdx];
421
+
422
+ const priceInput = await promptText('价格 (入/出 每百万 Token,如 0.55,2.19,留空跳过)');
423
+ if ((priceInput as any) === -1) continue;
214
424
 
215
425
  const pricing: any = {};
216
- const priceInput = await prompt('价格 (入/出 每百万 Token,如 0.55,2.19,留空跳过): ');
217
426
  if (priceInput) {
218
427
  const parts = priceInput.split(',').map(s => parseFloat(s.trim()));
219
428
  if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
@@ -224,13 +433,21 @@ export async function runSetupWizard(): Promise<void> {
224
433
  }
225
434
 
226
435
  providers[provName] = { baseUrl, apiKey, models, format, ...(Object.keys(pricing).length ? { pricing } : {}) };
227
- console.log(`✅ 已添加: ${provName}`);
436
+ console.log(`✅ 已添加: ${provName}\n`);
437
+
438
+ const r = await confirm('继续添加供应商?', false);
439
+ if (r === -1) addingProviders = false;
440
+ else addingProviders = (r === true);
441
+ }
228
442
 
229
- addProvider = (await promptChoice('继续添加供应商?', ['Y', 'N'])) === 'Y';
443
+ if (Object.keys(providers).length === 0) {
444
+ console.log('\n⚠️ 未配置任何供应商。');
445
+ const r = await confirm('至少配置一个供应商吗?');
446
+ if (r === true) { addingProviders = true; }
230
447
  }
231
448
 
232
- // ===== Step 4: 选择默认模型 =====
233
- console.log('\n📌 Step 4: 选择默认模型\n');
449
+ // ===== Step 5: 选择默认模型 =====
450
+ console.log('\n📌 Step 5: 选择默认模型\n');
234
451
 
235
452
  const allModels: string[] = [];
236
453
  for (const [provName, prov] of Object.entries(providers)) {
@@ -242,22 +459,26 @@ export async function runSetupWizard(): Promise<void> {
242
459
  let defaultModel = '';
243
460
  if (allModels.length > 0) {
244
461
  const existingDefault = existingConfig?.defaultModel || allModels[0];
245
- const defaultChoice = await prompt(`默认模型 [${existingDefault}]: `);
246
- defaultModel = defaultChoice || existingDefault;
462
+ const val = await promptText('默认模型', existingDefault);
463
+ defaultModel = (val as any) === -1 ? existingDefault : (val || existingDefault);
247
464
  } else {
248
- defaultModel = await prompt('默认模型 (供应商/模型名): ') || 'deepseek/deepseek-v4-pro';
465
+ defaultModel = await promptText('默认模型 (供应商/模型名)') || 'deepseek/deepseek-v4-pro';
466
+ if ((defaultModel as any) === -1) defaultModel = 'deepseek/deepseek-v4-pro';
249
467
  }
250
468
 
251
- // ===== Step 5: 生成灵魂文件 =====
252
- console.log('\n📌 Step 5: 生成灵魂文件\n');
469
+ // ===== Step 6: 生成灵魂文件 =====
470
+ console.log('\n📌 Step 6: 生成灵魂文件\n');
253
471
 
254
472
  for (const bot of bots) {
255
- const botSoulDir = getSoulDir(bot.name);
473
+ const botSoulDir = getSoulDir(getBotKey(bot));
256
474
  const templateSoulDir = path.join(getPkgDir(), 'templates', 'soul.template');
257
475
 
258
- if (fs.existsSync(botSoulDir) && (await promptChoice(`已存在 ${bot.name} 的灵魂文件,重新生成?`, ['Y', 'N'])) !== 'Y') {
259
- console.log(`⏭ 跳过: ${bot.name}`);
260
- continue;
476
+ if (fs.existsSync(botSoulDir)) {
477
+ const r = await confirm(`已存在 ${bot.name} 的灵魂文件,重新生成?`, false);
478
+ if (r === -1 || r === false) {
479
+ console.log(`⏭ 跳过: ${bot.name}`);
480
+ continue;
481
+ }
261
482
  }
262
483
 
263
484
  fs.mkdirSync(botSoulDir, { recursive: true });
@@ -267,29 +488,24 @@ export async function runSetupWizard(): Promise<void> {
267
488
  const tmplPath = path.join(templateSoulDir, sf);
268
489
  const destPath = path.join(botSoulDir, sf);
269
490
 
270
- if (fs.existsSync(destPath) && !fs.existsSync(tmplPath)) {
271
- continue; // 已有且无模板,保留
272
- }
491
+ if (fs.existsSync(destPath) && !fs.existsSync(tmplPath)) continue;
273
492
 
274
493
  if (fs.existsSync(tmplPath)) {
275
494
  let content = fs.readFileSync(tmplPath, 'utf-8');
276
- // 替换模板变量
277
495
  content = content.replace(/\{\{backend\}\}/g, bot.backend);
278
496
  content = content.replace(/\{\{cwd\}\}/g, bot.cwd || os.homedir());
279
497
  content = content.replace(/\{\{botName\}\}/g, bot.name);
280
498
  fs.writeFileSync(destPath, content);
281
499
  }
282
500
  }
283
- console.log(`✅ ${bot.name}: 灵魂文件已生成 → ${botSoulDir}`);
501
+ console.log(`✅ ${bot.name}: 灵魂文件 → ${botSoulDir}`);
284
502
  }
285
503
 
286
- // ===== Step 6: 写入配置文件 =====
287
- console.log('\n📌 Step 6: 写入配置文件\n');
504
+ // ===== Step 7: 写入配置文件 =====
505
+ console.log('\n📌 Step 7: 写入配置文件\n');
288
506
 
289
- // 确保数据目录存在
290
507
  fs.mkdirSync(dataDir, { recursive: true });
291
508
 
292
- // config.json
293
509
  const config: any = {
294
510
  system: existingConfig?.system || {
295
511
  defaultProjectDir: os.homedir(),
@@ -327,46 +543,38 @@ export async function runSetupWizard(): Promise<void> {
327
543
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
328
544
  console.log(`✅ ${configPath}`);
329
545
 
330
- // providers.json
331
- const providersFile: any = {
332
- providers,
333
- defaultModel,
334
- modelAliases: config.modelAliases,
335
- };
546
+ const providersFile: any = { providers, defaultModel, modelAliases: config.modelAliases };
547
+ const providersPath = path.join(dataDir, 'providers.json');
336
548
  fs.writeFileSync(providersPath, JSON.stringify(providersFile, null, 2) + '\n');
337
549
  console.log(`✅ ${providersPath}`);
338
550
 
339
- // opencode.json(从模板复制)
551
+ const opencodePath = path.join(dataDir, 'opencode.json');
340
552
  const opencodeTemplate = getTemplatePath('opencode.template.json');
341
553
  if (fs.existsSync(opencodeTemplate)) {
342
- const opencodeContent = fs.readFileSync(opencodeTemplate, 'utf-8');
343
- fs.writeFileSync(opencodePath, opencodeContent);
554
+ fs.writeFileSync(opencodePath, fs.readFileSync(opencodeTemplate, 'utf-8'));
344
555
  console.log(`✅ ${opencodePath}`);
345
556
  }
346
557
 
347
- // 创建必要的子目录
348
558
  fs.mkdirSync(path.join(dataDir, 'sessions'), { recursive: true });
349
559
  fs.mkdirSync(path.join(dataDir, 'logs'), { recursive: true });
350
560
  console.log('✅ 子目录已创建 (sessions/, logs/)');
351
561
 
352
- // ===== Step 7: 完成 =====
562
+ // ===== 完成 =====
353
563
  console.log('\n╔══════════════════════════════════════════════╗');
354
564
  console.log('║ ✅ 配置完成! ║');
355
565
  console.log('╚══════════════════════════════════════════════╝\n');
356
566
  console.log(`Bot: ${bots.map(b => b.name).join(', ')}`);
357
567
  console.log(`默认模型: ${defaultModel}`);
358
- console.log(`供应商: ${Object.keys(providers).join(', ')}`);
568
+ console.log(`供应商: ${Object.keys(providers).join(', ') || '无'}`);
359
569
  console.log(`\n下一步:`);
360
570
  console.log(` imtoagent start 启动网关`);
361
- console.log(` imtoagent status 查看状态`);
362
- console.log();
571
+ console.log(` imtoagent status 查看状态\n`);
363
572
  }
364
573
 
365
574
  // ================================================================
366
575
  // 工具函数
367
576
  // ================================================================
368
577
 
369
- /** 构建默认模型别名 */
370
578
  function buildDefaultAliases(defaultModel: string): Record<string, string> {
371
579
  return {
372
580
  default: defaultModel,
@@ -377,48 +585,3 @@ function buildDefaultAliases(defaultModel: string): Record<string, string> {
377
585
  opencode: defaultModel,
378
586
  };
379
587
  }
380
-
381
- /** 交互式 prompt(Bun 原生) */
382
- async function prompt(question: string): Promise<string> {
383
- // Bun 环境使用原生 prompt
384
- if (typeof Bun !== 'undefined' && typeof Bun.stdin !== 'undefined') {
385
- // 尝试使用 readline
386
- const readline = await import('readline');
387
- const rl = readline.createInterface({
388
- input: process.stdin,
389
- output: process.stdout,
390
- });
391
-
392
- return new Promise(resolve => {
393
- rl.question(question, answer => {
394
- rl.close();
395
- resolve(answer.trim());
396
- });
397
- });
398
- }
399
-
400
- // fallback
401
- console.error('⚠️ 无法读取用户输入,请检查运行环境');
402
- return '';
403
- }
404
-
405
- /** 选项选择(Y/N 或自定义选项) */
406
- async function promptChoice(question: string, options: string[]): Promise<string> {
407
- const optStr = options.join('/');
408
- const answer = await prompt(`${question} [${optStr}]: `);
409
- const upper = answer.toUpperCase();
410
-
411
- // 精确匹配(全大写比较,返回原始值)
412
- const exact = options.find(o => o.toUpperCase() === upper);
413
- if (exact) return exact;
414
-
415
- // 简写匹配(首字母)
416
- if (upper.length === 1) {
417
- const short = options.find(o => o[0].toUpperCase() === upper);
418
- if (short) return short;
419
- }
420
-
421
- // 默认返回第一个
422
- if (!answer && options.length > 0) return options[0];
423
- return upper || options[0];
424
- }
@@ -96,7 +96,7 @@ export class FileConfigManager implements ConfigManager {
96
96
  // 加载各 bot 的模型配置
97
97
  if (this.rawConfig?.bots) {
98
98
  for (const bot of this.rawConfig.bots) {
99
- this._loadBotConfig(bot.name);
99
+ this._loadBotConfig(botKey);
100
100
  }
101
101
  }
102
102
  }
@@ -118,36 +118,36 @@ export class FileConfigManager implements ConfigManager {
118
118
  }
119
119
 
120
120
  /** 加载 Bot 级别配置 */
121
- private _loadBotConfig(botName: string): void {
121
+ private _loadBotConfig(botKey: string): void {
122
122
  const sessionsDir = getSessionsDir();
123
- const configPath = path.join(sessionsDir, `${botName}_config.json`);
123
+ const configPath = path.join(sessionsDir, `${botKey}_config.json`);
124
124
 
125
125
  try {
126
126
  if (fs.existsSync(configPath)) {
127
127
  const raw = fs.readFileSync(configPath, 'utf-8');
128
- this.botConfigs.set(botName, JSON.parse(raw));
128
+ this.botConfigs.set(botKey, JSON.parse(raw));
129
129
  } else {
130
- this.botConfigs.set(botName, {});
130
+ this.botConfigs.set(botKey, {});
131
131
  }
132
132
  } catch (e: any) {
133
- console.error(`[Config] 加载 bot ${botName} 配置失败: ${e.message}`);
134
- this.botConfigs.set(botName, {});
133
+ console.error(`[Config] 加载 bot ${botKey} 配置失败: ${e.message}`);
134
+ this.botConfigs.set(botKey, {});
135
135
  }
136
136
  }
137
137
 
138
138
  /** 保存 Bot 级别配置 */
139
- private _saveBotConfig(botName: string, config: BotLevelConfig): void {
139
+ private _saveBotConfig(botKey: string, config: BotLevelConfig): void {
140
140
  const sessionsDir = getSessionsDir();
141
- const configPath = path.join(sessionsDir, `${botName}_config.json`);
141
+ const configPath = path.join(sessionsDir, `${botKey}_config.json`);
142
142
 
143
143
  try {
144
144
  if (!fs.existsSync(sessionsDir)) {
145
145
  fs.mkdirSync(sessionsDir, { recursive: true });
146
146
  }
147
- this.botConfigs.set(botName, config);
147
+ this.botConfigs.set(botKey, config);
148
148
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
149
149
  } catch (e: any) {
150
- console.error(`[Config] 保存 bot ${botName} 配置失败: ${e.message}`);
150
+ console.error(`[Config] 保存 bot ${botKey} 配置失败: ${e.message}`);
151
151
  }
152
152
  }
153
153
 
@@ -249,10 +249,10 @@ export class FileConfigManager implements ConfigManager {
249
249
  }
250
250
 
251
251
  /** 保存 Bot 活跃模型 */
252
- saveActiveModel(botName: string, modelSpec: string): void {
253
- const botLevel = this.botConfigs.get(botName) || {};
252
+ saveActiveModel(botKey: string, modelSpec: string): void {
253
+ const botLevel = this.botConfigs.get(botKey) || {};
254
254
  botLevel.activeModel = modelSpec;
255
- this._saveBotConfig(botName, botLevel);
255
+ this._saveBotConfig(botKey, botLevel);
256
256
 
257
257
  // 同时更新全局配置
258
258
  if (this.rawConfig) {
@@ -267,9 +267,9 @@ export class FileConfigManager implements ConfigManager {
267
267
  }
268
268
 
269
269
  /** 保存 Bot 模型别名 */
270
- saveModelAliases(botName: string, aliases: Record<string, string>): void {
271
- const botLevel = this.botConfigs.get(botName) || {};
270
+ saveModelAliases(botKey: string, aliases: Record<string, string>): void {
271
+ const botLevel = this.botConfigs.get(botKey) || {};
272
272
  botLevel.modelAliases = aliases;
273
- this._saveBotConfig(botName, botLevel);
273
+ this._saveBotConfig(botKey, botLevel);
274
274
  }
275
275
  }
@@ -85,24 +85,24 @@ export class FileSessionManager implements SessionManager {
85
85
  private cache = new Map<string, Map<string, Session>>();
86
86
 
87
87
  /** 获取 Session 文件路径 */
88
- private sessionPath(botName: string, chatId: string): string {
88
+ private sessionPath(botKey: string, chatId: string): string {
89
89
  const sessionsBase = getSessionsDir();
90
- const botDir = path.join(sessionsBase, botName);
90
+ const botDir = path.join(sessionsBase, botKey);
91
91
  return path.join(botDir, `${chatId}.memory.json`);
92
92
  }
93
93
 
94
94
  /** 确保目录存在 */
95
- private ensureDir(botName: string): void {
95
+ private ensureDir(botKey: string): void {
96
96
  const sessionsBase = getSessionsDir();
97
- const botDir = path.join(sessionsBase, botName);
97
+ const botDir = path.join(sessionsBase, botKey);
98
98
  if (!fs.existsSync(botDir)) {
99
99
  fs.mkdirSync(botDir, { recursive: true });
100
100
  }
101
101
  }
102
102
 
103
- async getOrCreate(botName: string, chatId: string, userId: string): Promise<Session> {
103
+ async getOrCreate(botKey: string, chatId: string, userId: string): Promise<Session> {
104
104
  // 先查缓存
105
- const botCache = this.cache.get(botName);
105
+ const botCache = this.cache.get(botKey);
106
106
  if (botCache) {
107
107
  const cached = botCache.get(chatId);
108
108
  if (cached) {
@@ -112,8 +112,8 @@ export class FileSessionManager implements SessionManager {
112
112
  }
113
113
 
114
114
  // 从文件加载
115
- const filePath = this.sessionPath(botName, chatId);
116
- this.ensureDir(botName);
115
+ const filePath = this.sessionPath(botKey, chatId);
116
+ this.ensureDir(botKey);
117
117
 
118
118
  let session: Session;
119
119
 
@@ -147,10 +147,10 @@ export class FileSessionManager implements SessionManager {
147
147
  }
148
148
 
149
149
  // 缓存
150
- if (!this.cache.has(botName)) {
151
- this.cache.set(botName, new Map());
150
+ if (!this.cache.has(botKey)) {
151
+ this.cache.set(botKey, new Map());
152
152
  }
153
- this.cache.get(botName)!.set(chatId, session);
153
+ this.cache.get(botKey)!.set(chatId, session);
154
154
 
155
155
  return session;
156
156
  }
@@ -170,8 +170,8 @@ export class FileSessionManager implements SessionManager {
170
170
  };
171
171
  }
172
172
 
173
- persist(botName: string, session: Session): void {
174
- this.ensureDir(botName);
173
+ persist(botKey: string, session: Session): void {
174
+ this.ensureDir(botKey);
175
175
 
176
176
  // 写入时保持旧格式兼容:将 metadata 中的旧 ID 也写入顶层
177
177
  const output: Record<string, any> = {
@@ -203,7 +203,7 @@ export class FileSessionManager implements SessionManager {
203
203
  // metadata 完整保存
204
204
  output.metadata = session.metadata;
205
205
 
206
- const filePath = this.sessionPath(botName, session.chatId);
206
+ const filePath = this.sessionPath(botKey, session.chatId);
207
207
  try {
208
208
  fs.writeFileSync(filePath, JSON.stringify(output, null, 2));
209
209
  } catch (e: any) {
@@ -211,15 +211,15 @@ export class FileSessionManager implements SessionManager {
211
211
  }
212
212
  }
213
213
 
214
- delete(botName: string, chatId: string): void {
214
+ delete(botKey: string, chatId: string): void {
215
215
  // 清除缓存
216
- const botCache = this.cache.get(botName);
216
+ const botCache = this.cache.get(botKey);
217
217
  if (botCache) {
218
218
  botCache.delete(chatId);
219
219
  }
220
220
 
221
221
  // 删除文件
222
- const filePath = this.sessionPath(botName, chatId);
222
+ const filePath = this.sessionPath(botKey, chatId);
223
223
  try {
224
224
  if (fs.existsSync(filePath)) {
225
225
  fs.unlinkSync(filePath);
@@ -229,8 +229,8 @@ export class FileSessionManager implements SessionManager {
229
229
  }
230
230
  }
231
231
 
232
- cleanupIdle(botName: string, timeoutMs: number): void {
233
- const botCache = this.cache.get(botName);
232
+ cleanupIdle(botKey: string, timeoutMs: number): void {
233
+ const botCache = this.cache.get(botKey);
234
234
  if (!botCache) return;
235
235
 
236
236
  const now = Date.now();
@@ -248,8 +248,8 @@ export class FileSessionManager implements SessionManager {
248
248
  }
249
249
  }
250
250
 
251
- listActive(botName: string): Session[] {
252
- const botCache = this.cache.get(botName);
251
+ listActive(botKey: string): Session[] {
252
+ const botCache = this.cache.get(botKey);
253
253
  if (!botCache) return [];
254
254
  return Array.from(botCache.values());
255
255
  }
@@ -170,15 +170,15 @@ export interface MessageContext {
170
170
  /** Session 管理器 */
171
171
  export interface SessionManager {
172
172
  /** 获取或创建 Session */
173
- getOrCreate(botName: string, chatId: string, userId: string): Promise<Session>;
173
+ getOrCreate(botKey: string, chatId: string, userId: string): Promise<Session>;
174
174
  /** 持久化 Session */
175
- persist(botName: string, session: Session): void;
175
+ persist(botKey: string, session: Session): void;
176
176
  /** 删除 Session */
177
- delete(botName: string, chatId: string): void;
177
+ delete(botKey: string, chatId: string): void;
178
178
  /** 清理空闲 Session */
179
- cleanupIdle(botName: string, timeoutMs: number): void;
179
+ cleanupIdle(botKey: string, timeoutMs: number): void;
180
180
  /** 列出所有活跃 Session */
181
- listActive(botName: string): Session[];
181
+ listActive(botKey: string): Session[];
182
182
  }
183
183
 
184
184
  /** 错误处理器 */
@@ -233,6 +233,8 @@ export interface ErrorContext {
233
233
  // ================================================================
234
234
 
235
235
  export interface BotConfig {
236
+ /** 唯一标识(UUID,用于目录/文件隔离,改名不影响) */
237
+ id?: string;
236
238
  name: string;
237
239
  backend: string; // 'claude' | 'codex' | 'opencode'
238
240
  appId: string;
@@ -37,8 +37,8 @@ export const DEFAULT_TERMINAL_CAPS: IMCapabilities = {
37
37
  // 从 ~/Desktop/imtoagent/soul/{botName}/ 按顺序加载
38
38
  // 加载顺序:rules → identity → profile → workspace → skills
39
39
  // ================================================================
40
- export function loadSoul(botName: string): string {
41
- const soulDir = getSoulDir(botName);
40
+ export function loadSoul(botKey: string): string {
41
+ const soulDir = getSoulDir(botKey);
42
42
  const soulOrder = ['rules.md', 'identity.md', 'profile.md', 'workspace.md', 'skills.md'];
43
43
  const parts: string[] = [];
44
44
  try {
@@ -64,7 +64,7 @@ export interface PromptBuilderContext {
64
64
  /** 当 imModule 不可用时,手动指定的能力 */
65
65
  caps?: IMCapabilities | null;
66
66
  /** Bot 名称,用于加载 Soul */
67
- botName: string;
67
+ botKey: string;
68
68
  /** Agent 特有的额外系统提示(如工具使用指南、工作目录约束等) */
69
69
  agentInstructions?: string;
70
70
  }
package/modules/types.ts CHANGED
@@ -134,6 +134,8 @@ export interface IMModule {
134
134
  // ================================================================
135
135
 
136
136
  export interface BotConfig {
137
+ /** 唯一标识(UUID,用于目录隔离,改名不影响) */
138
+ id?: string;
137
139
  name: string;
138
140
  backend: 'claude' | 'codex' | 'opencode';
139
141
  appId: string;
@@ -3,6 +3,8 @@
3
3
  // ================================================================
4
4
 
5
5
  import { execSync } from 'child_process';
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
6
8
 
7
9
  export interface BackendInfo {
8
10
  type: 'claude' | 'codex' | 'opencode';
@@ -18,20 +20,64 @@ const BACKEND_DEFS: Omit<BackendInfo, 'installed' | 'version'>[] = [
18
20
  { type: 'opencode', label: 'OpenCode', installHint: 'npm install -g opencode' },
19
21
  ];
20
22
 
21
- function checkOne(b: Omit<BackendInfo, 'installed' | 'version'>): BackendInfo {
23
+ // ================================================================
24
+ // 获取 npm 全局 bin 目录
25
+ // 解决 PATH 未包含 npm global bin 时的检测失败问题
26
+ // ================================================================
27
+
28
+ let _cachedNpmBin: string | null | undefined = undefined;
29
+
30
+ function getNpmGlobalBin(): string | null {
31
+ if (_cachedNpmBin !== undefined) return _cachedNpmBin;
22
32
  try {
23
- let version: string | null = null;
24
- if (b.type === 'claude') {
25
- version = execSync('claude --version', { encoding: 'utf-8', timeout: 5000 }).trim();
26
- } else if (b.type === 'codex') {
27
- version = execSync('codex --version', { encoding: 'utf-8', timeout: 5000 }).trim();
28
- } else {
29
- version = execSync('opencode version', { encoding: 'utf-8', timeout: 5000 }).trim();
33
+ const prefix = execSync('npm get prefix', { encoding: 'utf-8', timeout: 5000 }).trim();
34
+ if (!prefix) {
35
+ _cachedNpmBin = null;
36
+ return null;
30
37
  }
38
+ const binDir = path.join(prefix, 'bin');
39
+ if (fs.existsSync(binDir)) {
40
+ _cachedNpmBin = binDir;
41
+ return binDir;
42
+ }
43
+ _cachedNpmBin = null;
44
+ return null;
45
+ } catch {
46
+ _cachedNpmBin = null;
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function checkOne(b: Omit<BackendInfo, 'installed' | 'version'>): BackendInfo {
52
+ const versionCmd: Record<string, string> = {
53
+ claude: 'claude --version',
54
+ codex: 'codex --version',
55
+ opencode: 'opencode version',
56
+ };
57
+
58
+ // 先尝试 PATH 中的命令
59
+ try {
60
+ const version = execSync(versionCmd[b.type], { encoding: 'utf-8', timeout: 5000 }).trim();
31
61
  return { ...b, installed: true, version };
32
62
  } catch {
33
- return { ...b, installed: false, version: null };
63
+ // PATH 中找不到,继续尝试 npm global bin
64
+ }
65
+
66
+ // fallback:直接从 npm global bin 目录运行
67
+ const npmBin = getNpmGlobalBin();
68
+ if (npmBin) {
69
+ const binPath = path.join(npmBin, b.type);
70
+ try {
71
+ if (fs.existsSync(binPath)) {
72
+ const version = execSync(`"${binPath}" --version`, { encoding: 'utf-8', timeout: 5000 }).trim();
73
+ return { ...b, installed: true, version };
74
+ }
75
+ } catch {
76
+ // bin 存在但执行失败,视为未安装
77
+ }
34
78
  }
79
+
80
+ return { ...b, installed: false, version: null };
35
81
  }
36
82
 
37
83
  export function checkAllBackends(): BackendInfo[] {
@@ -59,7 +105,8 @@ export function formatBackendStatus(backends: BackendInfo[]): string {
59
105
 
60
106
  /**
61
107
  * 自动安装缺失的后端 CLI
62
- * 使用 Bun.spawn 流式输出 npm install -g 的进度
108
+ * 使用 bash -lc 加载用户 shell 环境,确保 npm PATH
109
+ * 流式输出安装进度,支持 Ctrl+C 中断
63
110
  */
64
111
  export async function installBackend(
65
112
  type: 'claude' | 'codex' | 'opencode',
@@ -74,10 +121,16 @@ export async function installBackend(
74
121
  console.log(` 命令: ${b.installHint}\n`);
75
122
 
76
123
  try {
77
- const child = Bun.spawn(b.installHint.split(' '), {
124
+ // 获取 npm 全局 bin 目录,用于安装后验证(复用 getNpmGlobalBin 缓存)
125
+ const npmBinDir = getNpmGlobalBin();
126
+
127
+ // 用 zsh -ic 加载用户 shell 环境(匹配 macOS 默认 shell)
128
+ // 传入当前 PATH 环境变量,确保 npm 可执行文件可访问
129
+ const child = Bun.spawn(['zsh', '-ic', b.installHint], {
78
130
  stdout: 'pipe',
79
131
  stderr: 'pipe',
80
- stdin: 'inherit',
132
+ stdin: 'ignore',
133
+ env: { ...process.env },
81
134
  });
82
135
 
83
136
  const decoder = new TextDecoder();
@@ -102,16 +155,35 @@ export async function installBackend(
102
155
 
103
156
  if (exitCode !== 0) {
104
157
  console.error(`\n❌ ${b.label} 安装失败 (退出码: ${exitCode})`);
158
+ console.error(` 可手动运行: ${b.installHint}`);
105
159
  return false;
106
160
  }
107
161
 
108
- // 安装完成后验证
162
+ // 安装完成后验证 — 优先用 npm bin 目录直接检查
163
+ if (npmBinDir) {
164
+ const binPath = path.join(npmBinDir, b.type);
165
+ try {
166
+ if (fs.existsSync(binPath)) {
167
+ const version = execSync(`"${binPath}" --version`, { encoding: 'utf-8', timeout: 5000 }).trim();
168
+ console.log(`\n✅ ${b.label} 安装成功! 版本: ${version}`);
169
+ return true;
170
+ }
171
+ } catch {}
172
+ }
173
+
174
+ // fallback: 通过 PATH 查找
109
175
  const info = checkOne(b);
110
176
  if (info.installed) {
111
177
  console.log(`\n✅ ${b.label} 安装成功! 版本: ${info.version}`);
112
178
  return true;
113
179
  } else {
114
- console.error(`\n❌ ${b.label} 安装后仍未检测到,请手动运行: ${b.installHint}`);
180
+ console.error(`\n❌ ${b.label} 安装后仍未检测到`);
181
+ if (npmBinDir) {
182
+ console.error(` npm 全局 bin 目录: ${npmBinDir}`);
183
+ console.error(` 建议将该目录添加到 PATH,或手动运行: ${b.installHint}`);
184
+ } else {
185
+ console.error(` 请手动运行: ${b.installHint}`);
186
+ }
115
187
  return false;
116
188
  }
117
189
  } catch (e: any) {
@@ -191,8 +191,13 @@ export function getLogsDir(): string {
191
191
  return path.join(getDataDir(), 'logs');
192
192
  }
193
193
 
194
- export function getSoulDir(botName: string): string {
195
- return path.join(getDataDir(), 'soul', botName);
194
+ /** 解析 Bot 唯一 key:优先用 id(UUID),后向兼容用 name */
195
+ export function getBotKey(bot: { id?: string; name: string }): string {
196
+ return bot.id || bot.name;
197
+ }
198
+
199
+ export function getSoulDir(botKey: string): string {
200
+ return path.join(getDataDir(), 'soul', botKey);
196
201
  }
197
202
 
198
203
  export function getRestoreMarkerPath(): string {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "imtoagent",
3
- "version": "0.2.5",
4
- "description": "IM \u2194 Agent \u7edf\u4e00\u7f51\u5173 \u2014 \u98de\u4e66/Telegram/\u5fae\u4fe1/\u4f01\u4e1a\u5fae\u4fe1\u5bf9\u63a5 Claude Code/Codex/OpenCode",
3
+ "version": "0.3.1",
4
+ "description": "IM Agent 统一网关 飞书/Telegram/微信/企业微信对接 Claude Code/Codex/OpenCode",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "imtoagent": "./bin/imtoagent.cjs"
@@ -53,4 +53,4 @@
53
53
  "url": "https://github.com/YOUR_USERNAME/imtoagent/issues"
54
54
  },
55
55
  "homepage": "https://github.com/YOUR_USERNAME/imtoagent#readme"
56
- }
56
+ }