imtoagent 0.3.21 → 0.3.23
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/README.md +29 -1
- package/bin/imtoagent-real +141 -10
- package/index.ts +50 -12
- package/modules/cli/setup.ts +86 -21
- package/modules/utils/backend-check.ts +182 -19
- package/modules/utils/migrate-workspaces.ts +230 -0
- package/modules/utils/paths.ts +1 -1
- package/modules/utils/workspace-manager.ts +209 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# IMtoAgent — IM ↔ Agent Unified Gateway
|
|
2
2
|
|
|
3
3
|
Connect Feishu, Telegram, personal WeChat, and WeCom to AI coding agents like Claude Code, Codex (GPT), OpenCode, and more.
|
|
4
4
|
|
|
@@ -180,6 +180,34 @@ The interactive wizard will guide you through:
|
|
|
180
180
|
| `imtoagent status` | Check running status |
|
|
181
181
|
| `imtoagent restore` | Hot reload recovery |
|
|
182
182
|
| `imtoagent daemon` | Foreground daemon mode (crash auto-restart) |
|
|
183
|
+
| `imtoagent update-system` | Upgrade IMtoAgent itself via npm |
|
|
184
|
+
| `imtoagent update-backend [type]` | Upgrade an agent backend (claude/codex/opencode) |
|
|
185
|
+
|
|
186
|
+
### Upgrading
|
|
187
|
+
|
|
188
|
+
**Upgrade IMtoAgent itself:**
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
imtoagent update-system
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
This runs `npm update -g imtoagent` and verifies the new version.
|
|
195
|
+
|
|
196
|
+
**Upgrade agent backends:**
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
# Auto-detect current Bot's backend and upgrade it
|
|
200
|
+
imtoagent update-backend
|
|
201
|
+
|
|
202
|
+
# Upgrade a specific backend
|
|
203
|
+
imtoagent update-backend codex
|
|
204
|
+
imtoagent update-backend claude
|
|
205
|
+
imtoagent update-backend opencode
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The command detects how each backend was installed (npm, brew, or manual) and uses the correct upgrade command automatically.
|
|
209
|
+
|
|
210
|
+
### Running Modes
|
|
183
211
|
|
|
184
212
|
### Running Modes
|
|
185
213
|
|
package/bin/imtoagent-real
CHANGED
|
@@ -26,7 +26,7 @@ const command = process.argv[2];
|
|
|
26
26
|
|
|
27
27
|
switch (command) {
|
|
28
28
|
case 'setup':
|
|
29
|
-
await cmdSetup();
|
|
29
|
+
await cmdSetup(process.argv[3]);
|
|
30
30
|
break;
|
|
31
31
|
case 'start':
|
|
32
32
|
await cmdStart();
|
|
@@ -46,6 +46,14 @@ switch (command) {
|
|
|
46
46
|
case 'daemon':
|
|
47
47
|
await cmdDaemon();
|
|
48
48
|
break;
|
|
49
|
+
case 'update-system':
|
|
50
|
+
await cmdUpdateSystem();
|
|
51
|
+
break;
|
|
52
|
+
case 'update-backend': {
|
|
53
|
+
const backendType = process.argv[3] as 'claude' | 'codex' | 'opencode' | undefined;
|
|
54
|
+
await cmdUpdateBackend(backendType);
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
49
57
|
case undefined: {
|
|
50
58
|
const dataDir = getDataDir();
|
|
51
59
|
const configPath = path.join(dataDir, 'config.json');
|
|
@@ -89,13 +97,17 @@ function printHelp() {
|
|
|
89
97
|
imtoagent — IM ↔ Agent Unified Gateway
|
|
90
98
|
|
|
91
99
|
Usage:
|
|
92
|
-
imtoagent setup
|
|
93
|
-
imtoagent
|
|
94
|
-
imtoagent
|
|
95
|
-
imtoagent
|
|
96
|
-
imtoagent
|
|
97
|
-
imtoagent
|
|
98
|
-
imtoagent
|
|
100
|
+
imtoagent setup Interactive setup wizard
|
|
101
|
+
imtoagent setup --quick Quick mode (sandbox workspace, skip workspace step)
|
|
102
|
+
imtoagent start Start gateway in background (returns immediately)
|
|
103
|
+
imtoagent run Start gateway in foreground (Ctrl+C to stop)
|
|
104
|
+
imtoagent stop Stop gateway
|
|
105
|
+
imtoagent status Check running status
|
|
106
|
+
imtoagent restore Hot reload
|
|
107
|
+
imtoagent daemon Foreground daemon with auto-restart (for launchd/systemd)
|
|
108
|
+
imtoagent update-system Upgrade imtoagent itself
|
|
109
|
+
imtoagent update-backend Upgrade current Bot's backend
|
|
110
|
+
imtoagent update-backend TYPE Upgrade specific backend (codex|claude|opencode)
|
|
99
111
|
|
|
100
112
|
Data directory: ${getDataDir()}
|
|
101
113
|
`);
|
|
@@ -104,9 +116,10 @@ Data directory: ${getDataDir()}
|
|
|
104
116
|
// ================================================================
|
|
105
117
|
// setup — interactive wizard
|
|
106
118
|
// ================================================================
|
|
107
|
-
async function cmdSetup() {
|
|
119
|
+
async function cmdSetup(mode) {
|
|
120
|
+
const quick = mode === '--quick' || mode === '-q';
|
|
108
121
|
const { runSetupWizard } = await import('../modules/cli/setup');
|
|
109
|
-
await runSetupWizard();
|
|
122
|
+
await runSetupWizard(quick ? { quick: true } : undefined);
|
|
110
123
|
}
|
|
111
124
|
|
|
112
125
|
// ================================================================
|
|
@@ -485,3 +498,121 @@ async function cmdDaemon(): Promise<void> {
|
|
|
485
498
|
|
|
486
499
|
console.log('👋 Daemon stopped');
|
|
487
500
|
}
|
|
501
|
+
|
|
502
|
+
// ================================================================
|
|
503
|
+
// update-system — upgrade imtoagent itself
|
|
504
|
+
// ================================================================
|
|
505
|
+
async function cmdUpdateSystem(): Promise<void> {
|
|
506
|
+
const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
|
|
507
|
+
const currentVer = pkg.version;
|
|
508
|
+
console.log('\n📦 Upgrading imtoagent...');
|
|
509
|
+
console.log(` Current version: ${currentVer}\n`);
|
|
510
|
+
|
|
511
|
+
console.log(' Running: npm update -g imtoagent\n');
|
|
512
|
+
|
|
513
|
+
try {
|
|
514
|
+
const child = Bun.spawn(['zsh', '-ic', 'npm update -g imtoagent'], {
|
|
515
|
+
stdout: 'pipe',
|
|
516
|
+
stderr: 'pipe',
|
|
517
|
+
stdin: 'ignore',
|
|
518
|
+
env: { ...process.env },
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
const decoder = new TextDecoder();
|
|
522
|
+
|
|
523
|
+
const stdoutReader = child.stdout.getReader();
|
|
524
|
+
while (true) {
|
|
525
|
+
const { done, value } = await stdoutReader.read();
|
|
526
|
+
if (done) break;
|
|
527
|
+
process.stdout.write(decoder.decode(value, { stream: true }));
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const stderrReader = child.stderr.getReader();
|
|
531
|
+
while (true) {
|
|
532
|
+
const { done, value } = await stderrReader.read();
|
|
533
|
+
if (done) break;
|
|
534
|
+
process.stderr.write(decoder.decode(value, { stream: true }));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const exitCode = await child.exited;
|
|
538
|
+
if (exitCode !== 0) {
|
|
539
|
+
console.error(`\n❌ imtoagent upgrade failed (exit code: ${exitCode})`);
|
|
540
|
+
process.exit(1);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Read new version
|
|
544
|
+
let newVer = 'unknown';
|
|
545
|
+
try {
|
|
546
|
+
const newPkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
|
|
547
|
+
newVer = newPkg.version;
|
|
548
|
+
} catch {
|
|
549
|
+
// Can't read new version (possibly process swapped), check via CLI
|
|
550
|
+
try {
|
|
551
|
+
newVer = execSync('imtoagent --version', { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
552
|
+
} catch {}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
console.log(`\n✅ imtoagent upgraded: ${currentVer} → ${newVer}`);
|
|
556
|
+
console.log(` Restart gateway with: imtoagent stop && imtoagent start`);
|
|
557
|
+
} catch (e: any) {
|
|
558
|
+
console.error(`\n❌ Error upgrading imtoagent: ${e.message || e}`);
|
|
559
|
+
process.exit(1);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ================================================================
|
|
564
|
+
// update-backend — upgrade a backend agent
|
|
565
|
+
// ================================================================
|
|
566
|
+
async function cmdUpdateBackend(backendType?: 'claude' | 'codex' | 'opencode'): Promise<void> {
|
|
567
|
+
const dataDir = getDataDir();
|
|
568
|
+
const configPath = path.join(dataDir, 'config.json');
|
|
569
|
+
|
|
570
|
+
// Determine which backend to upgrade
|
|
571
|
+
let targetType: 'claude' | 'codex' | 'opencode';
|
|
572
|
+
|
|
573
|
+
if (backendType) {
|
|
574
|
+
if (!['claude', 'codex', 'opencode'].includes(backendType)) {
|
|
575
|
+
console.error(`❌ Unknown backend: ${backendType}`);
|
|
576
|
+
console.error(` Valid: claude, codex, opencode`);
|
|
577
|
+
process.exit(1);
|
|
578
|
+
}
|
|
579
|
+
targetType = backendType;
|
|
580
|
+
} else {
|
|
581
|
+
// Auto-detect from config (use first bot's backend)
|
|
582
|
+
if (!fs.existsSync(configPath)) {
|
|
583
|
+
console.error('❌ No config found. Run "imtoagent setup" first.');
|
|
584
|
+
process.exit(1);
|
|
585
|
+
}
|
|
586
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
587
|
+
const bots = cfg.bots || [];
|
|
588
|
+
if (bots.length === 0) {
|
|
589
|
+
console.error('❌ No bots configured.');
|
|
590
|
+
process.exit(1);
|
|
591
|
+
}
|
|
592
|
+
targetType = bots[0].backend;
|
|
593
|
+
if (!targetType) {
|
|
594
|
+
console.error('❌ First bot has no backend configured.');
|
|
595
|
+
process.exit(1);
|
|
596
|
+
}
|
|
597
|
+
console.log(` Auto-detected backend: ${targetType} (from bot: ${bots[0].name})\n`);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Show current status
|
|
601
|
+
const { checkBackend } = await import('../modules/utils/backend-check');
|
|
602
|
+
const info = checkBackend(targetType);
|
|
603
|
+
console.log(` ${info.label}: ${info.version || 'not installed'} (${info.installSource})`);
|
|
604
|
+
|
|
605
|
+
if (!info.installed) {
|
|
606
|
+
console.error(`\n❌ ${info.label} not installed.`);
|
|
607
|
+
console.error(` Install: imtoagent setup`);
|
|
608
|
+
process.exit(1);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Run upgrade
|
|
612
|
+
const { upgradeBackend } = await import('../modules/utils/backend-check');
|
|
613
|
+
const result = await upgradeBackend(targetType);
|
|
614
|
+
|
|
615
|
+
if (!result.success) {
|
|
616
|
+
process.exit(1);
|
|
617
|
+
}
|
|
618
|
+
}
|
package/index.ts
CHANGED
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
import { parseToBlocks } from './modules/capabilities';
|
|
20
20
|
import { resolveCapabilities } from './modules/prompt-builder';
|
|
21
21
|
import { getDataDir } from './modules/utils/paths';
|
|
22
|
+
import { WorkspaceManager, createWorkspaceManager } from './modules/utils/workspace-manager';
|
|
23
|
+
import { migrateWorkspaces } from './modules/utils/migrate-workspaces';
|
|
22
24
|
import { FeishuIMModule } from './modules/im/feishu';
|
|
23
25
|
import { TelegramAdapter } from './modules/im/telegram';
|
|
24
26
|
import { WeComIMModule } from './modules/im/wecom';
|
|
@@ -109,15 +111,17 @@ interface ChatSession extends Session {
|
|
|
109
111
|
// ================================================================
|
|
110
112
|
class CustomSessionManager {
|
|
111
113
|
sessions: Map<string, ChatSession>;
|
|
112
|
-
|
|
114
|
+
botKey: string;
|
|
115
|
+
sessionsDir: string;
|
|
113
116
|
|
|
114
|
-
constructor(
|
|
115
|
-
this.
|
|
117
|
+
constructor(botKey: string, workspacePath: string, sessions: Map<string, ChatSession>) {
|
|
118
|
+
this.botKey = botKey;
|
|
116
119
|
this.sessions = sessions;
|
|
120
|
+
this.sessionsDir = path.join(workspacePath, 'sessions');
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
private _sessionPath(chatId: string): string {
|
|
120
|
-
return path.join(
|
|
124
|
+
return path.join(this.sessionsDir, `${chatId}.memory.json`);
|
|
121
125
|
}
|
|
122
126
|
|
|
123
127
|
async getOrCreate(chatId: string, userId: string): Promise<ChatSession> {
|
|
@@ -128,6 +132,10 @@ class CustomSessionManager {
|
|
|
128
132
|
}
|
|
129
133
|
|
|
130
134
|
// 从文件加载(兼容旧格式)
|
|
135
|
+
// 确保 sessions 目录存在
|
|
136
|
+
if (!fs.existsSync(this.sessionsDir)) {
|
|
137
|
+
fs.mkdirSync(this.sessionsDir, { recursive: true });
|
|
138
|
+
}
|
|
131
139
|
const fp = this._sessionPath(chatId);
|
|
132
140
|
let session: ChatSession;
|
|
133
141
|
|
|
@@ -300,6 +308,7 @@ class Bot {
|
|
|
300
308
|
client: Lark.Client;
|
|
301
309
|
im: IMModule;
|
|
302
310
|
config: any;
|
|
311
|
+
workspaceManager: WorkspaceManager;
|
|
303
312
|
|
|
304
313
|
// SDK
|
|
305
314
|
runtime: AgentRuntime;
|
|
@@ -310,7 +319,7 @@ class Bot {
|
|
|
310
319
|
/** 正在执行的任务的取消信号(chatId → AbortController) */
|
|
311
320
|
activeControllers: Map<string, AbortController> = new Map();
|
|
312
321
|
|
|
313
|
-
constructor(cfg: BotConfig, globalConfig: any) {
|
|
322
|
+
constructor(cfg: BotConfig, globalConfig: any, workspaceManager: WorkspaceManager) {
|
|
314
323
|
this.id = cfg.id || cfg.name; // 后向兼容:无 id 时用 name
|
|
315
324
|
this.name = cfg.name;
|
|
316
325
|
this.backend = cfg.backend;
|
|
@@ -318,6 +327,11 @@ class Bot {
|
|
|
318
327
|
this.appSecret = cfg.appSecret;
|
|
319
328
|
this.defaultCwd = cfg.cwd || globalConfig.system?.defaultProjectDir || path.join(os.homedir(), 'Projects');
|
|
320
329
|
this.config = globalConfig;
|
|
330
|
+
this.workspaceManager = workspaceManager;
|
|
331
|
+
|
|
332
|
+
// 确保工作空间目录存在
|
|
333
|
+
const botKey = this.id;
|
|
334
|
+
this.workspaceManager.ensureWorkspace(botKey);
|
|
321
335
|
|
|
322
336
|
// Bot 级模型配置
|
|
323
337
|
const botCfg = this._loadBotConfig();
|
|
@@ -352,7 +366,8 @@ class Bot {
|
|
|
352
366
|
this.im = imFactory.create(cfg);
|
|
353
367
|
|
|
354
368
|
// ===== SDK 集成 =====
|
|
355
|
-
|
|
369
|
+
const workspacePath = this.workspaceManager.getWorkspacePath(this.id);
|
|
370
|
+
this.sessionManager = new CustomSessionManager(this.id, workspacePath, this.sessions);
|
|
356
371
|
|
|
357
372
|
const adapterCtx = {
|
|
358
373
|
imModule: this.im,
|
|
@@ -387,7 +402,9 @@ class Bot {
|
|
|
387
402
|
}
|
|
388
403
|
|
|
389
404
|
// ===== 灵魂管理 =====
|
|
390
|
-
_soulDir() {
|
|
405
|
+
_soulDir() {
|
|
406
|
+
return this.workspaceManager.getSoulPath(this.id);
|
|
407
|
+
}
|
|
391
408
|
|
|
392
409
|
_initSoul() {
|
|
393
410
|
const dir = this._soulDir();
|
|
@@ -614,9 +631,19 @@ class Bot {
|
|
|
614
631
|
|
|
615
632
|
cmd('/dir', ({ args, session }) => {
|
|
616
633
|
const dir = args.trim();
|
|
617
|
-
|
|
618
|
-
if (
|
|
619
|
-
|
|
634
|
+
const currentCwd = session?.cwd || this.defaultCwd;
|
|
635
|
+
if (!dir) {
|
|
636
|
+
const mode = this.workspaceManager.getMode();
|
|
637
|
+
return `📁 ${currentCwd}\n🏷 Workspace mode: ${mode}`;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const resolved = this.workspaceManager.resolveAndValidatePath(this.id, dir, currentCwd);
|
|
641
|
+
if (resolved === null) {
|
|
642
|
+
return `❌ Path not allowed: ${dir}\n💡 In sandbox mode, you can only access paths within your workspace`;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (session) session.cwd = resolved;
|
|
646
|
+
return `📁 Switched: ${resolved}`;
|
|
620
647
|
});
|
|
621
648
|
|
|
622
649
|
cmd('/mode', ({ args, session }) => {
|
|
@@ -950,6 +977,16 @@ async function main() {
|
|
|
950
977
|
return;
|
|
951
978
|
}
|
|
952
979
|
|
|
980
|
+
// ===== Workspace Migration =====
|
|
981
|
+
// 老用户升级:自动迁移旧 sessions/ + soul/ 到新的 workspace 结构
|
|
982
|
+
const migrationResult = migrateWorkspaces();
|
|
983
|
+
if (migrationResult.botsMigrated.length > 0) {
|
|
984
|
+
console.log(` 🔄 Workspace migration: ${migrationResult.botsMigrated.length} bot(s) migrated`);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// 创建 WorkspaceManager(所有 Bot 共享)
|
|
988
|
+
const workspaceManager = createWorkspaceManager(config);
|
|
989
|
+
|
|
953
990
|
const bots: Bot[] = [];
|
|
954
991
|
for (const c of botCfgs) {
|
|
955
992
|
const appId = c.appId || c.feishu?.appId || '';
|
|
@@ -958,7 +995,7 @@ async function main() {
|
|
|
958
995
|
|
|
959
996
|
// wechat 不需要 appId/appSecret,首次启动会触发 QR 扫码绑定
|
|
960
997
|
if (imType === 'wechat') {
|
|
961
|
-
bots.push(new Bot({ ...c, appId: appId || 'wechat-bot', appSecret }, config));
|
|
998
|
+
bots.push(new Bot({ ...c, appId: appId || 'wechat-bot', appSecret }, config, workspaceManager));
|
|
962
999
|
continue;
|
|
963
1000
|
}
|
|
964
1001
|
|
|
@@ -968,7 +1005,7 @@ async function main() {
|
|
|
968
1005
|
console.log(`[Config] ⚠️ Bot "${c.name}" has placeholder credentials, skipping`);
|
|
969
1006
|
continue;
|
|
970
1007
|
}
|
|
971
|
-
bots.push(new Bot({ ...c, appId, appSecret }, config));
|
|
1008
|
+
bots.push(new Bot({ ...c, appId, appSecret }, config, workspaceManager));
|
|
972
1009
|
}
|
|
973
1010
|
|
|
974
1011
|
if (bots.length === 0) {
|
|
@@ -976,6 +1013,7 @@ async function main() {
|
|
|
976
1013
|
return;
|
|
977
1014
|
}
|
|
978
1015
|
|
|
1016
|
+
console.log(` Workspace: ${workspaceManager.getConfigSummary()}`);
|
|
979
1017
|
_allBots = bots;
|
|
980
1018
|
console.log(`\n🚀 CC Routing v4 — Multi-Bot Architecture (Full SDK Integration)`);
|
|
981
1019
|
console.log(` Anthropic: http://localhost:${proxyPort}`);
|
package/modules/cli/setup.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import * as fs from 'fs';
|
|
13
13
|
import * as os from 'os';
|
|
14
14
|
import * as path from 'path';
|
|
15
|
-
import { getDataDir, getPkgDir, getTemplatePath, getSoulDir
|
|
15
|
+
import { getDataDir, getPkgDir, getTemplatePath, getSoulDir } from '../utils/paths';
|
|
16
16
|
import { randomUUID } from 'crypto';
|
|
17
17
|
import { checkAllBackends, formatBackendStatus } from '../utils/backend-check';
|
|
18
18
|
|
|
@@ -290,7 +290,11 @@ const IM_FIELDS: Record<string, { key: string; label: string; required: boolean
|
|
|
290
290
|
// Main flow
|
|
291
291
|
// ================================================================
|
|
292
292
|
|
|
293
|
-
export
|
|
293
|
+
export interface SetupOptions {
|
|
294
|
+
quick?: boolean;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function runSetupWizard(options?: SetupOptions): Promise<void> {
|
|
294
298
|
// Guard: refuse to run in non-TTY environment
|
|
295
299
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
296
300
|
console.error('');
|
|
@@ -311,6 +315,11 @@ export async function runSetupWizard(): Promise<void> {
|
|
|
311
315
|
console.log(`\nData directory: ${dataDir}`);
|
|
312
316
|
console.log(`Controls: ↑↓/Space navigate | Enter confirm | ESC go back\n`);
|
|
313
317
|
|
|
318
|
+
const isQuick = options?.quick || false;
|
|
319
|
+
if (isQuick) {
|
|
320
|
+
console.log('⚡ Quick mode: workspace defaults to sandbox, skip workspace step\n');
|
|
321
|
+
}
|
|
322
|
+
|
|
314
323
|
// ===== Step 1: Detect existing configuration =====
|
|
315
324
|
let existingConfig: any = null;
|
|
316
325
|
let mergeMode = false;
|
|
@@ -458,15 +467,20 @@ export async function runSetupWizard(): Promise<void> {
|
|
|
458
467
|
bot.im = imType;
|
|
459
468
|
}
|
|
460
469
|
|
|
461
|
-
// Check for duplicate name
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
bots.push(bot);
|
|
468
|
-
console.log(`✅ Added: ${botName}`);
|
|
470
|
+
// Check for duplicate name — auto-rename with counter
|
|
471
|
+
let finalName = botName;
|
|
472
|
+
let counter = 1;
|
|
473
|
+
while (bots.find(b => b.name === finalName)) {
|
|
474
|
+
finalName = `${botName} (${counter})`;
|
|
475
|
+
counter++;
|
|
469
476
|
}
|
|
477
|
+
bot.name = finalName;
|
|
478
|
+
|
|
479
|
+
if (finalName !== botName) {
|
|
480
|
+
console.log(`⚠️ "${botName}" already exists, renamed to "${finalName}"`);
|
|
481
|
+
}
|
|
482
|
+
bots.push(bot);
|
|
483
|
+
console.log(`✅ Added: ${finalName}`);
|
|
470
484
|
|
|
471
485
|
// Whether to continue adding
|
|
472
486
|
const r = await confirm('Add another Bot?', true);
|
|
@@ -482,8 +496,43 @@ export async function runSetupWizard(): Promise<void> {
|
|
|
482
496
|
return;
|
|
483
497
|
}
|
|
484
498
|
|
|
485
|
-
// ===== Step 4: Configure
|
|
486
|
-
|
|
499
|
+
// ===== Step 4: Configure workspace =====
|
|
500
|
+
let workspaceMode: 'sandbox' | 'global' = 'sandbox';
|
|
501
|
+
let workspaceGlobalPath: string | null = null;
|
|
502
|
+
|
|
503
|
+
if (!isQuick) {
|
|
504
|
+
console.log('\n📌 Step 4: Configure workspace\n');
|
|
505
|
+
console.log('Each Bot gets its own isolated file workspace:');
|
|
506
|
+
console.log(' sandbox — isolated per Bot, no cross-Bot file access (recommended)');
|
|
507
|
+
console.log(' global — shared root directory, multiple Bots collaborate on same codebase');
|
|
508
|
+
console.log('');
|
|
509
|
+
|
|
510
|
+
const wsLabels = ['Sandbox mode (isolated per Bot)', 'Global mode (shared root path)'];
|
|
511
|
+
const wsIdx = await selectMenu('Select workspace mode', wsLabels);
|
|
512
|
+
if (wsIdx === -1) { console.log('\n👋 Cancelled'); process.exit(0); }
|
|
513
|
+
workspaceMode = wsIdx === 1 ? 'global' : 'sandbox';
|
|
514
|
+
|
|
515
|
+
if (workspaceMode === 'global') {
|
|
516
|
+
const defaultGlobal = os.homedir() + '/imtoagent-workspace';
|
|
517
|
+
const gpInput = await promptText('Global workspace root path', defaultGlobal);
|
|
518
|
+
if ((gpInput as any) === -1) { console.log('\n👋 Cancelled'); process.exit(0); }
|
|
519
|
+
workspaceGlobalPath = (gpInput || defaultGlobal).trim();
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const home = process.env.HOME || process.env.USERPROFILE?.replace(/\\/g, '/') || '';
|
|
523
|
+
console.log('');
|
|
524
|
+
console.log('Workspace layout:');
|
|
525
|
+
if (workspaceMode === 'sandbox') {
|
|
526
|
+
console.log(` ${home}/.imtoagent/workspaces/<UUID>/ (each Bot gets a unique directory)`);
|
|
527
|
+
} else {
|
|
528
|
+
console.log(` ${workspaceGlobalPath}/ (all Bots share this root)`);
|
|
529
|
+
console.log(` soul files in ${workspaceGlobalPath}/.imtoagent/soul/<botId>/`);
|
|
530
|
+
}
|
|
531
|
+
console.log('');
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ===== Step 5: Configure model providers =====
|
|
535
|
+
console.log('\n📌 Step 5: Configure model providers\n');
|
|
487
536
|
|
|
488
537
|
const providers: Record<string, any> = {};
|
|
489
538
|
if (mergeMode && existingConfig?.providers) {
|
|
@@ -600,8 +649,8 @@ export async function runSetupWizard(): Promise<void> {
|
|
|
600
649
|
step4Loop = false; // Has providers or user explicitly skipped
|
|
601
650
|
}
|
|
602
651
|
|
|
603
|
-
// ===== Step
|
|
604
|
-
console.log('\n📌 Step
|
|
652
|
+
// ===== Step 6: Select default model =====
|
|
653
|
+
console.log('\n📌 Step 6: Select default model\n');
|
|
605
654
|
|
|
606
655
|
const allModels: string[] = [];
|
|
607
656
|
for (const [provName, prov] of Object.entries(providers)) {
|
|
@@ -620,11 +669,23 @@ export async function runSetupWizard(): Promise<void> {
|
|
|
620
669
|
if ((defaultModel as any) === -1) defaultModel = 'deepseek/deepseek-v4-pro';
|
|
621
670
|
}
|
|
622
671
|
|
|
623
|
-
// ===== Step
|
|
624
|
-
console.log('\n📌 Step
|
|
672
|
+
// ===== Step 7: Generate soul files =====
|
|
673
|
+
console.log('\n📌 Step 7: Generate soul files\n');
|
|
674
|
+
|
|
675
|
+
// Compute workspace soul dirs based on configured workspace mode
|
|
676
|
+
const home = process.env.HOME || process.env.USERPROFILE?.replace(/\\/g, '/') || '';
|
|
625
677
|
|
|
626
678
|
for (const bot of bots) {
|
|
627
|
-
|
|
679
|
+
let botSoulDir: string;
|
|
680
|
+
|
|
681
|
+
if (workspaceMode === 'sandbox') {
|
|
682
|
+
// Sandbox: soul in workspaces/<UUID>/soul/
|
|
683
|
+
botSoulDir = path.join(home, '.imtoagent', 'workspaces', bot.id, 'soul');
|
|
684
|
+
} else {
|
|
685
|
+
// Global: soul in <globalPath>/.imtoagent/soul/<botId>/
|
|
686
|
+
botSoulDir = path.join(workspaceGlobalPath || path.join(home, 'imtoagent-workspace'), '.imtoagent', 'soul', bot.id);
|
|
687
|
+
}
|
|
688
|
+
|
|
628
689
|
const templateSoulDir = path.join(getPkgDir(), 'templates', 'soul.template');
|
|
629
690
|
|
|
630
691
|
if (fs.existsSync(botSoulDir)) {
|
|
@@ -654,9 +715,7 @@ export async function runSetupWizard(): Promise<void> {
|
|
|
654
715
|
}
|
|
655
716
|
console.log(`✅ ${bot.name}: soul files → ${botSoulDir}`);
|
|
656
717
|
}
|
|
657
|
-
|
|
658
|
-
// ===== Step 7: Write configuration files =====
|
|
659
|
-
console.log('\n📌 Step 7: Write configuration files\n');
|
|
718
|
+
console.log('\n📌 Step 8: Write configuration files\n');
|
|
660
719
|
|
|
661
720
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
662
721
|
|
|
@@ -666,6 +725,11 @@ export async function runSetupWizard(): Promise<void> {
|
|
|
666
725
|
idleTimeoutMinutes: 30,
|
|
667
726
|
maxReplyLength: 140000,
|
|
668
727
|
},
|
|
728
|
+
workspace: {
|
|
729
|
+
mode: workspaceMode,
|
|
730
|
+
globalPath: workspaceGlobalPath,
|
|
731
|
+
botOverrides: {},
|
|
732
|
+
},
|
|
669
733
|
providers,
|
|
670
734
|
defaultModel,
|
|
671
735
|
modelAliases: existingConfig?.modelAliases || buildDefaultAliases(defaultModel),
|
|
@@ -717,9 +781,10 @@ export async function runSetupWizard(): Promise<void> {
|
|
|
717
781
|
console.log('\n╔══════════════════════════════════════════════╗');
|
|
718
782
|
console.log('║ ✅ Configuration complete! ║');
|
|
719
783
|
console.log('╚══════════════════════════════════════════════╝\n');
|
|
720
|
-
console.log(`
|
|
784
|
+
console.log(`Bots: ${bots.map(b => b.name).join(', ')}`);
|
|
721
785
|
console.log(`Default model: ${defaultModel}`);
|
|
722
786
|
console.log(`Providers: ${Object.keys(providers).join(', ') || 'None'}`);
|
|
787
|
+
console.log(`Workspace: ${workspaceMode === 'sandbox' ? 'sandbox (isolated)' : `global (${workspaceGlobalPath})`}`);
|
|
723
788
|
console.log(`\nNext steps:`);
|
|
724
789
|
console.log(` imtoagent start Start the gateway`);
|
|
725
790
|
console.log(` imtoagent status Check status\n`);
|
|
@@ -7,20 +7,70 @@ import * as fs from 'fs';
|
|
|
7
7
|
import * as os from 'os';
|
|
8
8
|
import * as path from 'path';
|
|
9
9
|
|
|
10
|
+
export type InstallSource = 'npm' | 'brew' | 'manual' | 'unknown';
|
|
11
|
+
|
|
10
12
|
export interface BackendInfo {
|
|
11
13
|
type: 'claude' | 'codex' | 'opencode';
|
|
12
14
|
label: string;
|
|
13
15
|
installed: boolean;
|
|
14
16
|
version: string | null;
|
|
15
17
|
installHint: string;
|
|
18
|
+
installSource: InstallSource;
|
|
19
|
+
binaryPath: string | null;
|
|
20
|
+
upgradeCommand: string | null;
|
|
16
21
|
}
|
|
17
22
|
|
|
18
|
-
const BACKEND_DEFS: Omit<BackendInfo, 'installed' | 'version'>[] = [
|
|
23
|
+
const BACKEND_DEFS: Omit<BackendInfo, 'installed' | 'version' | 'installSource' | 'binaryPath' | 'upgradeCommand'>[] = [
|
|
19
24
|
{ type: 'claude', label: 'Claude Code', installHint: 'npm install -g @anthropic-ai/claude-agent-sdk' },
|
|
20
25
|
{ type: 'codex', label: 'Codex', installHint: 'npm install -g @openai/codex' },
|
|
21
26
|
{ type: 'opencode', label: 'OpenCode', installHint: 'curl -fsSL https://opencode.ai/install | bash' },
|
|
22
27
|
];
|
|
23
28
|
|
|
29
|
+
// ================================================================
|
|
30
|
+
// 安装来源检测
|
|
31
|
+
// ================================================================
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 根据二进制路径判断安装来源
|
|
35
|
+
*/
|
|
36
|
+
export function detectInstallSource(binaryPath: string, type: string): InstallSource {
|
|
37
|
+
if (binaryPath.includes('/opt/homebrew/') || binaryPath.includes('/usr/local/Cellar/')) {
|
|
38
|
+
return 'brew';
|
|
39
|
+
}
|
|
40
|
+
if (binaryPath.includes('node_modules/')) {
|
|
41
|
+
return 'npm';
|
|
42
|
+
}
|
|
43
|
+
if (type === 'opencode' && binaryPath.includes('.opencode/')) {
|
|
44
|
+
return 'manual';
|
|
45
|
+
}
|
|
46
|
+
return 'unknown';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 根据安装来源和后端类型生成升级命令
|
|
51
|
+
*/
|
|
52
|
+
export function getUpgradeCommand(source: InstallSource, type: string): string | null {
|
|
53
|
+
switch (source) {
|
|
54
|
+
case 'brew':
|
|
55
|
+
const brewPkg: Record<string, string> = { claude: undefined, codex: 'codex', opencode: undefined };
|
|
56
|
+
if (brewPkg[type]) return `brew upgrade ${brewPkg[type]}`;
|
|
57
|
+
return null; // claude/opencode not on brew
|
|
58
|
+
case 'npm':
|
|
59
|
+
const npmPkg: Record<string, string> = {
|
|
60
|
+
claude: '@anthropic-ai/claude-agent-sdk',
|
|
61
|
+
codex: '@openai/codex',
|
|
62
|
+
opencode: 'opencode',
|
|
63
|
+
};
|
|
64
|
+
if (npmPkg[type]) return `npm update -g ${npmPkg[type]}`;
|
|
65
|
+
return null;
|
|
66
|
+
case 'manual':
|
|
67
|
+
if (type === 'opencode') return 'curl -fsSL https://opencode.ai/install | bash';
|
|
68
|
+
return null;
|
|
69
|
+
case 'unknown':
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
24
74
|
// ================================================================
|
|
25
75
|
// 获取 npm 全局 bin 目录
|
|
26
76
|
// 解决 PATH 未包含 npm global bin 时的检测失败问题
|
|
@@ -49,47 +99,75 @@ export function getNpmGlobalBin(): string | null {
|
|
|
49
99
|
}
|
|
50
100
|
}
|
|
51
101
|
|
|
52
|
-
|
|
102
|
+
/**
|
|
103
|
+
* 定位后端可执行文件(按优先级)
|
|
104
|
+
* 返回 { path, version } 或 null
|
|
105
|
+
*/
|
|
106
|
+
function findBackendBinary(type: string): { binaryPath: string; version: string } | null {
|
|
53
107
|
const versionCmd: Record<string, string> = {
|
|
54
108
|
claude: 'claude --version',
|
|
55
109
|
codex: 'codex --version',
|
|
56
110
|
opencode: 'opencode version',
|
|
57
111
|
};
|
|
58
112
|
|
|
59
|
-
//
|
|
113
|
+
// 1) PATH 中的命令(最常见)
|
|
60
114
|
try {
|
|
61
|
-
const version = execSync(versionCmd[
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
115
|
+
const version = execSync(versionCmd[type], { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
116
|
+
// 解析真实路径(处理 symlink)
|
|
117
|
+
const binName = type;
|
|
118
|
+
const realPath = execSync(`command -v ${binName}`, { encoding: 'utf-8', timeout: 3000 }).trim();
|
|
119
|
+
return { binaryPath: realPath, version };
|
|
120
|
+
} catch {}
|
|
66
121
|
|
|
67
|
-
//
|
|
122
|
+
// 2) npm global bin
|
|
68
123
|
const npmBin = getNpmGlobalBin();
|
|
69
124
|
if (npmBin) {
|
|
70
|
-
const binPath = path.join(npmBin,
|
|
125
|
+
const binPath = path.join(npmBin, type);
|
|
71
126
|
try {
|
|
72
127
|
if (fs.existsSync(binPath)) {
|
|
73
128
|
const version = execSync(`"${binPath}" --version`, { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
74
|
-
return {
|
|
129
|
+
return { binaryPath: binPath, version };
|
|
75
130
|
}
|
|
76
|
-
} catch {
|
|
77
|
-
// bin 存在但执行失败,视为未安装
|
|
78
|
-
}
|
|
131
|
+
} catch {}
|
|
79
132
|
}
|
|
80
133
|
|
|
81
|
-
//
|
|
82
|
-
if (
|
|
134
|
+
// 3) OpenCode custom install path
|
|
135
|
+
if (type === 'opencode') {
|
|
83
136
|
const opencodePath = path.join(os.homedir(), '.opencode', 'bin', 'opencode');
|
|
84
137
|
try {
|
|
85
138
|
if (fs.existsSync(opencodePath)) {
|
|
86
139
|
const version = execSync(`"${opencodePath}" version`, { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
87
|
-
return {
|
|
140
|
+
return { binaryPath: opencodePath, version };
|
|
88
141
|
}
|
|
89
142
|
} catch {}
|
|
90
143
|
}
|
|
91
144
|
|
|
92
|
-
return
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function checkOne(b: Omit<BackendInfo, 'installed' | 'version' | 'installSource' | 'binaryPath' | 'upgradeCommand'>): BackendInfo {
|
|
149
|
+
const found = findBackendBinary(b.type);
|
|
150
|
+
if (found) {
|
|
151
|
+
const installSource = detectInstallSource(found.binaryPath, b.type);
|
|
152
|
+
const upgradeCommand = getUpgradeCommand(installSource, b.type);
|
|
153
|
+
return {
|
|
154
|
+
...b,
|
|
155
|
+
installed: true,
|
|
156
|
+
version: found.version,
|
|
157
|
+
installSource,
|
|
158
|
+
binaryPath: found.binaryPath,
|
|
159
|
+
upgradeCommand,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
...b,
|
|
165
|
+
installed: false,
|
|
166
|
+
version: null,
|
|
167
|
+
installSource: 'unknown',
|
|
168
|
+
binaryPath: null,
|
|
169
|
+
upgradeCommand: null,
|
|
170
|
+
};
|
|
93
171
|
}
|
|
94
172
|
|
|
95
173
|
export function checkAllBackends(): BackendInfo[] {
|
|
@@ -106,8 +184,9 @@ export function formatBackendStatus(backends: BackendInfo[]): string {
|
|
|
106
184
|
return backends.map(b => {
|
|
107
185
|
const icon = b.installed ? '✅' : '❌';
|
|
108
186
|
const ver = b.version ? ` v${b.version}` : '';
|
|
187
|
+
const source = b.installed && b.installSource !== 'unknown' ? ` (${b.installSource})` : '';
|
|
109
188
|
const hint = b.installed ? '' : ` → ${b.installHint}`;
|
|
110
|
-
return ` ${icon} ${b.label}${ver}${hint}`;
|
|
189
|
+
return ` ${icon} ${b.label}${ver}${source}${hint}`;
|
|
111
190
|
}).join('\n');
|
|
112
191
|
}
|
|
113
192
|
|
|
@@ -143,6 +222,90 @@ function ensurePathInConfig(configPath: string, binDir: string): void {
|
|
|
143
222
|
} catch {}
|
|
144
223
|
}
|
|
145
224
|
|
|
225
|
+
// ================================================================
|
|
226
|
+
// 升级后端 CLI
|
|
227
|
+
// ================================================================
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 升级已安装的后端到最新版
|
|
231
|
+
* 根据检测到的 installSource 自动选择正确的升级命令
|
|
232
|
+
*/
|
|
233
|
+
export async function upgradeBackend(
|
|
234
|
+
type: 'claude' | 'codex' | 'opencode',
|
|
235
|
+
): Promise<{ success: boolean; oldVer: string; newVer: string }> {
|
|
236
|
+
const b = BACKEND_DEFS.find((x) => x.type === type);
|
|
237
|
+
if (!b) {
|
|
238
|
+
return { success: false, oldVer: '', newVer: '' };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const currentInfo = checkOne(b);
|
|
242
|
+
if (!currentInfo.installed) {
|
|
243
|
+
console.error(`❌ ${b.label} not installed. Use installBackend() instead.`);
|
|
244
|
+
return { success: false, oldVer: '', newVer: '' };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const upgradeCmd = currentInfo.upgradeCommand;
|
|
248
|
+
if (!upgradeCmd) {
|
|
249
|
+
console.error(`❌ Cannot determine upgrade method for ${b.label} (source: ${currentInfo.installSource})`);
|
|
250
|
+
console.error(` Please upgrade manually.`);
|
|
251
|
+
return { success: false, oldVer: currentInfo.version || '', newVer: '' };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const oldVer = currentInfo.version || '';
|
|
255
|
+
console.log(`\n📦 Upgrading ${b.label}...`);
|
|
256
|
+
console.log(` Current: ${oldVer}`);
|
|
257
|
+
console.log(` Source: ${currentInfo.installSource}`);
|
|
258
|
+
console.log(` Command: ${upgradeCmd}\n`);
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const child = Bun.spawn(['zsh', '-ic', upgradeCmd], {
|
|
262
|
+
stdout: 'pipe',
|
|
263
|
+
stderr: 'pipe',
|
|
264
|
+
stdin: 'ignore',
|
|
265
|
+
env: { ...process.env },
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const decoder = new TextDecoder();
|
|
269
|
+
|
|
270
|
+
const stdoutReader = child.stdout.getReader();
|
|
271
|
+
while (true) {
|
|
272
|
+
const { done, value } = await stdoutReader.read();
|
|
273
|
+
if (done) break;
|
|
274
|
+
process.stdout.write(decoder.decode(value, { stream: true }));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const stderrReader = child.stderr.getReader();
|
|
278
|
+
while (true) {
|
|
279
|
+
const { done, value } = await stderrReader.read();
|
|
280
|
+
if (done) break;
|
|
281
|
+
process.stderr.write(decoder.decode(value, { stream: true }));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const exitCode = await child.exited;
|
|
285
|
+
if (exitCode !== 0) {
|
|
286
|
+
console.error(`\n❌ ${b.label} upgrade failed (exit code: ${exitCode})`);
|
|
287
|
+
return { success: false, oldVer, newVer: '' };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// 验证新版本
|
|
291
|
+
const newInfo = checkOne(b);
|
|
292
|
+
const newVer = newInfo.version || '';
|
|
293
|
+
if (newInfo.installed && newVer !== oldVer) {
|
|
294
|
+
console.log(`\n✅ ${b.label} upgraded: ${oldVer} → ${newVer}`);
|
|
295
|
+
return { success: true, oldVer, newVer };
|
|
296
|
+
} else if (newVer === oldVer) {
|
|
297
|
+
console.log(`\n✅ ${b.label} is already at latest version (${newVer})`);
|
|
298
|
+
return { success: true, oldVer, newVer };
|
|
299
|
+
} else {
|
|
300
|
+
console.log(`\n✅ ${b.label} upgrade completed (new version: ${newVer})`);
|
|
301
|
+
return { success: true, oldVer, newVer };
|
|
302
|
+
}
|
|
303
|
+
} catch (e: any) {
|
|
304
|
+
console.error(`\n❌ Error upgrading ${b.label}: ${e.message || e}`);
|
|
305
|
+
return { success: false, oldVer, newVer: '' };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
146
309
|
// ================================================================
|
|
147
310
|
// 自动安装后端 CLI
|
|
148
311
|
// ================================================================
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// ================================================================
|
|
2
|
+
// Workspace Migration — 老用户平滑迁移
|
|
3
|
+
// ================================================================
|
|
4
|
+
// 职责:
|
|
5
|
+
// 1. 检测旧目录结构(sessions/ + soul/)
|
|
6
|
+
// 2. 迁移到新 workspace 结构
|
|
7
|
+
// 3. 保留旧目录作为 .backup(不删除,安全优先)
|
|
8
|
+
// 4. 标记迁移完成,避免重复执行
|
|
9
|
+
// ================================================================
|
|
10
|
+
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import { getDataDir } from './paths';
|
|
14
|
+
|
|
15
|
+
const MIGRATION_MARKER = '.workspace-migrated';
|
|
16
|
+
|
|
17
|
+
interface MigrationResult {
|
|
18
|
+
migrated: boolean;
|
|
19
|
+
botsMigrated: string[];
|
|
20
|
+
errors: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ================================================================
|
|
24
|
+
// 公共入口
|
|
25
|
+
// ================================================================
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 检测并执行 workspace 迁移。
|
|
29
|
+
*
|
|
30
|
+
* 启动时调用一次。如果已经迁移过,直接返回。
|
|
31
|
+
* 返回迁移结果摘要,可用于日志输出。
|
|
32
|
+
*/
|
|
33
|
+
export function migrateWorkspaces(): MigrationResult {
|
|
34
|
+
const dataDir = getDataDir();
|
|
35
|
+
const markerPath = path.join(dataDir, MIGRATION_MARKER);
|
|
36
|
+
|
|
37
|
+
// 已经迁移过,跳过
|
|
38
|
+
if (fs.existsSync(markerPath)) {
|
|
39
|
+
return { migrated: false, botsMigrated: [], errors: [] };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const result: MigrationResult = { migrated: true, botsMigrated: [], errors: [] };
|
|
43
|
+
|
|
44
|
+
// 加载 bot-ids.json(UUID 映射)
|
|
45
|
+
const botIds = loadBotIds(dataDir);
|
|
46
|
+
|
|
47
|
+
// 旧目录
|
|
48
|
+
const oldSessionsDir = path.join(dataDir, 'sessions');
|
|
49
|
+
const oldSoulDir = path.join(dataDir, 'soul');
|
|
50
|
+
|
|
51
|
+
// 发现需要迁移的 Bot(从旧 sessions 目录扫描)
|
|
52
|
+
const botKeys = discoverBotKeys(oldSessionsDir, oldSoulDir);
|
|
53
|
+
if (botKeys.length === 0) {
|
|
54
|
+
// 没有旧数据,直接标记
|
|
55
|
+
markMigrated(markerPath);
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.error(`[Migration] Found ${botKeys.length} bot(s) to migrate: ${botKeys.join(', ')}`);
|
|
60
|
+
|
|
61
|
+
for (const botKey of botKeys) {
|
|
62
|
+
try {
|
|
63
|
+
const botUuid = ensureBotUuid(botKey, botIds, dataDir);
|
|
64
|
+
migrateBotData(botKey, botUuid, dataDir, oldSessionsDir, oldSoulDir);
|
|
65
|
+
result.botsMigrated.push(botKey);
|
|
66
|
+
} catch (e: any) {
|
|
67
|
+
const msg = `[${botKey}] ${e.message}`;
|
|
68
|
+
result.errors.push(msg);
|
|
69
|
+
console.error(`[Migration] ERROR: ${msg}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 迁移完成后创建标记
|
|
74
|
+
markMigrated(markerPath);
|
|
75
|
+
|
|
76
|
+
// 打印摘要
|
|
77
|
+
if (result.botsMigrated.length > 0) {
|
|
78
|
+
console.error(`[Migration] ✅ ${result.botsMigrated.length} bot(s) migrated successfully`);
|
|
79
|
+
}
|
|
80
|
+
if (result.errors.length > 0) {
|
|
81
|
+
console.error(`[Migration] ⚠️ ${result.errors.length} error(s): ${result.errors.join('; ')}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ================================================================
|
|
88
|
+
// 内部函数
|
|
89
|
+
// ================================================================
|
|
90
|
+
|
|
91
|
+
/** 加载 bot-ids.json */
|
|
92
|
+
function loadBotIds(dataDir: string): Record<string, string> {
|
|
93
|
+
const botIdsFile = path.join(dataDir, 'bot-ids.json');
|
|
94
|
+
try {
|
|
95
|
+
if (fs.existsSync(botIdsFile)) {
|
|
96
|
+
return JSON.parse(fs.readFileSync(botIdsFile, 'utf8'));
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// 文件损坏,重新开始
|
|
100
|
+
}
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 确保 Bot 有 UUID */
|
|
105
|
+
function ensureBotUuid(
|
|
106
|
+
botKey: string,
|
|
107
|
+
botIds: Record<string, string>,
|
|
108
|
+
dataDir: string,
|
|
109
|
+
): string {
|
|
110
|
+
if (botIds[botKey]) return botIds[botKey];
|
|
111
|
+
|
|
112
|
+
const uuid = crypto.randomUUID();
|
|
113
|
+
botIds[botKey] = uuid;
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
fs.writeFileSync(path.join(dataDir, 'bot-ids.json'), JSON.stringify(botIds, null, 2));
|
|
117
|
+
} catch {
|
|
118
|
+
// 写入失败不影响迁移流程
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return uuid;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 发现需要迁移的 Bot keys */
|
|
125
|
+
function discoverBotKeys(sessionsDir: string, soulDir: string): string[] {
|
|
126
|
+
const botKeys = new Set<string>();
|
|
127
|
+
|
|
128
|
+
// 从 sessions 目录扫描
|
|
129
|
+
if (fs.existsSync(sessionsDir)) {
|
|
130
|
+
try {
|
|
131
|
+
for (const entry of fs.readdirSync(sessionsDir)) {
|
|
132
|
+
if (entry.startsWith('.')) continue; // 跳过 .restore 等隐藏文件
|
|
133
|
+
const entryPath = path.join(sessionsDir, entry);
|
|
134
|
+
if (fs.statSync(entryPath).isDirectory()) {
|
|
135
|
+
botKeys.add(entry);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// 忽略读取失败
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 从 soul 目录扫描(补充)
|
|
144
|
+
if (fs.existsSync(soulDir)) {
|
|
145
|
+
try {
|
|
146
|
+
for (const entry of fs.readdirSync(soulDir)) {
|
|
147
|
+
if (entry.startsWith('.')) continue;
|
|
148
|
+
const entryPath = path.join(soulDir, entry);
|
|
149
|
+
if (fs.statSync(entryPath).isDirectory()) {
|
|
150
|
+
botKeys.add(entry);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
// 忽略读取失败
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return Array.from(botKeys);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 迁移单个 Bot 的数据 */
|
|
162
|
+
function migrateBotData(
|
|
163
|
+
botKey: string,
|
|
164
|
+
botUuid: string,
|
|
165
|
+
dataDir: string,
|
|
166
|
+
oldSessionsDir: string,
|
|
167
|
+
oldSoulDir: string,
|
|
168
|
+
): void {
|
|
169
|
+
const workspacesDir = path.join(dataDir, 'workspaces');
|
|
170
|
+
const newWorkspacePath = path.join(workspacesDir, botUuid);
|
|
171
|
+
const newSessionsPath = path.join(newWorkspacePath, 'sessions');
|
|
172
|
+
const newSoulPath = path.join(newWorkspacePath, 'soul');
|
|
173
|
+
|
|
174
|
+
console.error(`[Migration] Migrating [${botKey}] → workspace/${botUuid}/`);
|
|
175
|
+
|
|
176
|
+
// 确保新目录存在
|
|
177
|
+
fs.mkdirSync(newSessionsPath, { recursive: true });
|
|
178
|
+
fs.mkdirSync(newSoulPath, { recursive: true });
|
|
179
|
+
|
|
180
|
+
// 1. 迁移 session 文件
|
|
181
|
+
const oldBotSessionsDir = path.join(oldSessionsDir, botKey);
|
|
182
|
+
if (fs.existsSync(oldBotSessionsDir)) {
|
|
183
|
+
const sessionFiles = fs.readdirSync(oldBotSessionsDir).filter((f) => f.endsWith('.memory.json'));
|
|
184
|
+
for (const file of sessionFiles) {
|
|
185
|
+
const src = path.join(oldBotSessionsDir, file);
|
|
186
|
+
const dst = path.join(newSessionsPath, file);
|
|
187
|
+
if (!fs.existsSync(dst)) {
|
|
188
|
+
fs.copyFileSync(src, dst);
|
|
189
|
+
console.error(`[Migration] session: ${file}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 2. 迁移 soul 文件
|
|
195
|
+
const oldBotSoulDir = path.join(oldSoulDir, botKey);
|
|
196
|
+
if (fs.existsSync(oldBotSoulDir)) {
|
|
197
|
+
const soulFiles = fs.readdirSync(oldBotSoulDir);
|
|
198
|
+
for (const file of soulFiles) {
|
|
199
|
+
const src = path.join(oldBotSoulDir, file);
|
|
200
|
+
const dst = path.join(newSoulPath, file);
|
|
201
|
+
if (!fs.existsSync(dst) && fs.statSync(src).isFile()) {
|
|
202
|
+
fs.copyFileSync(src, dst);
|
|
203
|
+
console.error(`[Migration] soul: ${file}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 3. 迁移 .restore 文件到数据目录根(全局 marker)
|
|
209
|
+
const oldRestore = path.join(oldSessionsDir, '.restore');
|
|
210
|
+
if (fs.existsSync(oldRestore)) {
|
|
211
|
+
const newRestore = path.join(dataDir, '.restore');
|
|
212
|
+
if (!fs.existsSync(newRestore)) {
|
|
213
|
+
fs.copyFileSync(oldRestore, newRestore);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 标记迁移已完成 */
|
|
219
|
+
function markMigrated(markerPath: string): void {
|
|
220
|
+
const marker = {
|
|
221
|
+
version: 1,
|
|
222
|
+
migratedAt: new Date().toISOString(),
|
|
223
|
+
note: 'Workspace migration completed. Old sessions/ and soul/ directories preserved as backup.',
|
|
224
|
+
};
|
|
225
|
+
try {
|
|
226
|
+
fs.writeFileSync(markerPath, JSON.stringify(marker, null, 2));
|
|
227
|
+
} catch {
|
|
228
|
+
console.error('[Migration] Failed to write migration marker');
|
|
229
|
+
}
|
|
230
|
+
}
|
package/modules/utils/paths.ts
CHANGED
|
@@ -201,7 +201,7 @@ export function getSoulDir(botKey: string): string {
|
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
export function getRestoreMarkerPath(): string {
|
|
204
|
-
return path.join(
|
|
204
|
+
return path.join(getDataDir(), '.restore');
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
export function getTemplatePath(relativePath: string): string {
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// ================================================================
|
|
2
|
+
// WorkspaceManager — 工作空间管理
|
|
3
|
+
// ================================================================
|
|
4
|
+
// 职责:
|
|
5
|
+
// 1. 根据模式(sandbox/global)解析每个 Bot 的工作空间路径
|
|
6
|
+
// 2. 确保工作空间目录存在(含 soul/ 子目录)
|
|
7
|
+
// 3. 路径边界检查(沙盒模式下防止路径穿越)
|
|
8
|
+
// ================================================================
|
|
9
|
+
|
|
10
|
+
import * as fs from 'fs';
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
import { getDataDir } from './paths';
|
|
13
|
+
|
|
14
|
+
// ================================================================
|
|
15
|
+
// 类型定义
|
|
16
|
+
// ================================================================
|
|
17
|
+
|
|
18
|
+
export type WorkspaceMode = 'sandbox' | 'global';
|
|
19
|
+
|
|
20
|
+
export interface WorkspaceConfig {
|
|
21
|
+
mode: WorkspaceMode;
|
|
22
|
+
globalPath: string | null;
|
|
23
|
+
botOverrides: Record<string, string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 从 config.json 中提取 workspace 配置。
|
|
28
|
+
* 老用户无 workspace 配置时,默认 sandbox 模式。
|
|
29
|
+
*/
|
|
30
|
+
export function parseWorkspaceConfig(raw: any): WorkspaceConfig {
|
|
31
|
+
const ws = raw?.workspace || {};
|
|
32
|
+
const mode: WorkspaceMode = ws.mode === 'global' ? 'global' : 'sandbox';
|
|
33
|
+
const globalPath: string | null = ws.globalPath || null;
|
|
34
|
+
const botOverrides: Record<string, string> = {};
|
|
35
|
+
|
|
36
|
+
if (ws.botOverrides && typeof ws.botOverrides === 'object') {
|
|
37
|
+
for (const [k, v] of Object.entries(ws.botOverrides)) {
|
|
38
|
+
if (typeof v === 'string') botOverrides[k] = v;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { mode, globalPath, botOverrides };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ================================================================
|
|
46
|
+
// WorkspaceManager
|
|
47
|
+
// ================================================================
|
|
48
|
+
|
|
49
|
+
export class WorkspaceManager {
|
|
50
|
+
private config: WorkspaceConfig;
|
|
51
|
+
private workspacesDir: string;
|
|
52
|
+
|
|
53
|
+
constructor(config: WorkspaceConfig) {
|
|
54
|
+
this.config = config;
|
|
55
|
+
this.workspacesDir = path.join(getDataDir(), 'workspaces');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 解析 Bot 的工作空间路径。
|
|
60
|
+
*
|
|
61
|
+
* 规则:
|
|
62
|
+
* - 沙盒模式:~/.imtoagent/workspaces/<UUID>/(UUID 保证唯一,可被 botOverrides 覆盖)
|
|
63
|
+
* - 全局模式:直接使用 <globalPath>/(所有 Bot 共享同一入口)
|
|
64
|
+
*/
|
|
65
|
+
getWorkspacePath(botKey: string): string {
|
|
66
|
+
// 优先级:botOverrides > 模式默认路径
|
|
67
|
+
const override = this.config.botOverrides[botKey];
|
|
68
|
+
if (override) return path.resolve(override);
|
|
69
|
+
|
|
70
|
+
// 全局模式:所有 Bot 共享 globalPath,不加 botKey
|
|
71
|
+
if (this.config.mode === 'global' && this.config.globalPath) {
|
|
72
|
+
return path.resolve(this.config.globalPath);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 沙盒模式:每个 Bot 独立目录(UUID 保证唯一性,无需日期后缀)
|
|
76
|
+
const botId = this._getBotId(botKey);
|
|
77
|
+
return path.resolve(this.workspacesDir, botId);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 生成或获取 Bot 的 UUID。
|
|
82
|
+
* 首次调用时创建 UUID 并持久化到数据目录。
|
|
83
|
+
*/
|
|
84
|
+
private _getBotId(botKey: string): string {
|
|
85
|
+
const botIdsFile = path.join(getDataDir(), 'bot-ids.json');
|
|
86
|
+
let botIds: Record<string, string> = {};
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
if (fs.existsSync(botIdsFile)) {
|
|
90
|
+
botIds = JSON.parse(fs.readFileSync(botIdsFile, 'utf8'));
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// 文件损坏或不可读,重新开始
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!botIds[botKey]) {
|
|
97
|
+
// 生成 UUID v4
|
|
98
|
+
botIds[botKey] = crypto.randomUUID();
|
|
99
|
+
try {
|
|
100
|
+
fs.writeFileSync(botIdsFile, JSON.stringify(botIds, null, 2));
|
|
101
|
+
} catch (e) {
|
|
102
|
+
console.error(`[Workspace] Failed to persist bot ID for ${botKey}: ${e}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return botIds[botKey];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 确保工作空间目录存在,并初始化 soul/ 子目录。
|
|
111
|
+
*
|
|
112
|
+
* 沙盒模式:创建 <workspace>/soul/
|
|
113
|
+
* 全局模式:创建 <globalPath>/.imtoagent/soul/<botId>/(按 Bot 隔离)
|
|
114
|
+
*/
|
|
115
|
+
ensureWorkspace(botKey: string): void {
|
|
116
|
+
const wsPath = this.getWorkspacePath(botKey);
|
|
117
|
+
const soulPath = this.getSoulPath(botKey);
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
if (!fs.existsSync(wsPath)) {
|
|
121
|
+
fs.mkdirSync(wsPath, { recursive: true });
|
|
122
|
+
}
|
|
123
|
+
if (!fs.existsSync(soulPath)) {
|
|
124
|
+
fs.mkdirSync(soulPath, { recursive: true });
|
|
125
|
+
}
|
|
126
|
+
} catch (e: any) {
|
|
127
|
+
console.error(`[Workspace] Failed to ensure workspace for ${botKey}: ${e.message}`);
|
|
128
|
+
throw e;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 获取 soul 目录路径。
|
|
134
|
+
* 沙盒模式:<workspacePath>/soul/
|
|
135
|
+
* 全局模式:<globalPath>/.imtoagent/soul/<botId>/
|
|
136
|
+
*/
|
|
137
|
+
getSoulPath(botKey: string): string {
|
|
138
|
+
if (this.config.mode === 'global' && this.config.globalPath) {
|
|
139
|
+
return path.resolve(this.config.globalPath, '.imtoagent', 'soul', botKey);
|
|
140
|
+
}
|
|
141
|
+
// 沙盒模式
|
|
142
|
+
return path.join(this.getWorkspacePath(botKey), 'soul');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 检查路径是否允许该 Bot 访问。
|
|
147
|
+
*
|
|
148
|
+
* 沙盒模式:路径必须在 Bot 的工作空间范围内(或子目录)。
|
|
149
|
+
* 全局模式:不做限制,允许访问任意路径(信任用户配置的全局目录)。
|
|
150
|
+
*
|
|
151
|
+
* 返回 true 表示允许,false 表示拒绝。
|
|
152
|
+
*/
|
|
153
|
+
isPathAllowed(botKey: string, targetPath: string): boolean {
|
|
154
|
+
// 全局模式:不做边界限制
|
|
155
|
+
if (this.config.mode === 'global') {
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 沙盒模式:路径必须在工作空间内
|
|
160
|
+
const resolved = path.resolve(targetPath);
|
|
161
|
+
const wsPath = this.getWorkspacePath(botKey);
|
|
162
|
+
const resolvedWs = path.resolve(wsPath);
|
|
163
|
+
|
|
164
|
+
if (resolved === resolvedWs || resolved.startsWith(resolvedWs + path.sep)) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 获取工作空间模式。
|
|
173
|
+
*/
|
|
174
|
+
getMode(): WorkspaceMode {
|
|
175
|
+
return this.config.mode;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 规范化路径(解析 '..'、'.' 等),用于 /dir 命令。
|
|
180
|
+
* 如果规范化后的路径超出边界,返回 null。
|
|
181
|
+
*/
|
|
182
|
+
resolveAndValidatePath(botKey: string, inputPath: string, currentCwd: string): string | null {
|
|
183
|
+
// 先相对于当前 cwd 解析
|
|
184
|
+
const resolved = path.resolve(currentCwd, inputPath);
|
|
185
|
+
if (this.isPathAllowed(botKey, resolved)) {
|
|
186
|
+
return resolved;
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 获取工作空间配置摘要(用于状态展示)。
|
|
193
|
+
*/
|
|
194
|
+
getConfigSummary(): string {
|
|
195
|
+
if (this.config.mode === 'sandbox') {
|
|
196
|
+
return `sandbox (default: ${this.workspacesDir}/<UUID>/)`;
|
|
197
|
+
}
|
|
198
|
+
return `global (${path.resolve(this.config.globalPath || '.')})`;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ================================================================
|
|
203
|
+
// 便捷函数:从原始配置创建 WorkspaceManager
|
|
204
|
+
// ================================================================
|
|
205
|
+
|
|
206
|
+
export function createWorkspaceManager(rawConfig: any): WorkspaceManager {
|
|
207
|
+
const config = parseWorkspaceConfig(rawConfig);
|
|
208
|
+
return new WorkspaceManager(config);
|
|
209
|
+
}
|