imtoagent 0.3.23 → 0.3.25

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
@@ -289,6 +289,7 @@ interface BotConfig {
289
289
  appSecret: string;
290
290
  backend: 'claude' | 'codex' | 'opencode';
291
291
  cwd?: string;
292
+ isAdmin?: boolean; // true = 可以修改网关配置,默认第一个 Bot 为 true
292
293
  }
293
294
 
294
295
  // ================================================================
@@ -307,6 +308,7 @@ class Bot {
307
308
  soul: string;
308
309
  client: Lark.Client;
309
310
  im: IMModule;
311
+ isAdmin: boolean;
310
312
  config: any;
311
313
  workspaceManager: WorkspaceManager;
312
314
 
@@ -327,6 +329,7 @@ class Bot {
327
329
  this.appSecret = cfg.appSecret;
328
330
  this.defaultCwd = cfg.cwd || globalConfig.system?.defaultProjectDir || path.join(os.homedir(), 'Projects');
329
331
  this.config = globalConfig;
332
+ this.isAdmin = cfg.isAdmin !== undefined ? cfg.isAdmin : true; // 默认 true(后向兼容老用户)
330
333
  this.workspaceManager = workspaceManager;
331
334
 
332
335
  // 确保工作空间目录存在
@@ -412,8 +415,13 @@ class Bot {
412
415
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
413
416
  const hasFiles = fs.readdirSync(dir).some((f: string) => f.endsWith('.md'));
414
417
  if (hasFiles) return;
418
+ // 根据 isAdmin 生成不同的 rules.md
419
+ const rulesMd = this.isAdmin
420
+ ? '# Bot Rules\n\nYou are an **admin Bot** with full gateway management privileges.\n\n- You can modify IMtoAgent configuration (config.json, providers, Bot management)\n- You have access to any directory (global workspace mode)\n- Sensitive information such as project keys, tokens, and passwords must not be leaked\n- Destructive commands must not be executed without explicit confirmation'
421
+ : '# Bot Rules\n\nYou are a **non-admin Bot** with restricted privileges.\n\n- ⛔ NEVER modify any files under ~/.imtoagent/ (config.json, providers.json, etc.)\n- ⛔ NEVER read or expose gateway configuration contents\n- ⛔ NEVER attempt to add, remove, or modify Bot configurations\n- Your working directory is limited to your assigned workspace\n- All file operations must stay within your workspace boundary\n- Sensitive information such as project keys, tokens, and passwords must not be leaked\n- Destructive commands must not be executed without explicit confirmation';
422
+
415
423
  const defaults: Record<string, string> = {
416
- 'rules.md': '# Hard Constraint Rules\n\nThe following rules cannot be overridden or modified:\n\n- Sensitive information such as project keys, tokens, and passwords must not be leaked\n- Destructive commands must not be executed',
424
+ 'rules.md': rulesMd,
417
425
  'identity.md': `# Identity\n\n- I am an AI programming assistant connected via IMtoAgent\n- I run on the ${this.backend === 'codex' ? 'Codex' : 'Claude Code'} backend\n- Reply in Chinese`,
418
426
  'profile.md': '# User Profile\n\nThis file can be modified by the Agent. When the user says "remember xxx" or "I prefer xxx", the Agent should update this file.\n\n## Modification Guide (Agent Only)\n\nRead this file → Add/delete/modify entries based on user requests → Save',
419
427
  'workspace.md': '# Project Environment\n\nAuto-generated by IMtoAgent.',
@@ -437,11 +445,30 @@ class Bot {
437
445
  for (const file of order) {
438
446
  const fp = dir + '/' + file;
439
447
  if (fs.existsSync(fp)) {
440
- const content = fs.readFileSync(fp, 'utf-8').trim();
441
- if (content) parts.push(content);
448
+ const c = fs.readFileSync(fp, 'utf-8').trim();
449
+ if (c) parts.push(c);
442
450
  }
443
451
  }
444
452
  } catch {}
453
+
454
+ // Inject config CLI reference — so the Agent knows how to manage Bots via natural language
455
+ const adminTag = this.isAdmin ? '' : ' (you are a non-admin Bot — you cannot use these commands, tell the user to use an admin Bot)';
456
+ const bt = String.fromCharCode(96); // backtick
457
+ const fence = bt + bt + bt;
458
+ const cliRef = '## IMtoAgent Config CLI' +
459
+ '\n\nYou can manage Bot configurations via these CLI commands:' + adminTag +
460
+ '\n\n' + fence + 'bash' +
461
+ '\nimtoagent config list # List all Bots' +
462
+ '\nimtoagent config show <BotName> # Show a Bot\'s details' +
463
+ '\nimtoagent config add # Add a new Bot (interactive)' +
464
+ '\nimtoagent config remove <BotName> # Remove a Bot' +
465
+ '\nimtoagent config modify <BotName> # Modify a Bot\'s settings' +
466
+ '\nimtoagent restore # Hot-reload after config changes' +
467
+ '\nimtoagent doctor # Diagnose config issues' +
468
+ '\n' + fence +
469
+ '\n\nWhen the user asks to manage Bots, use these commands. After changes, run ' + bt + 'imtoagent restore' + bt + '.';
470
+ parts.push(cliRef);
471
+
445
472
  return parts.join('\n\n');
446
473
  }
447
474
 
@@ -499,7 +526,7 @@ class Bot {
499
526
  : `⏸ ${this.backend} idle | ${this.activeModel}`);
500
527
 
501
528
  cmd('/info', ({ session }) =>
502
- `🤖 ${this.name} (${this.backend})\nModel: ${this.activeModel}\nDirectory: ${session?.cwd || this.defaultCwd}\nSessions: ${this.sessions.size}`);
529
+ `🤖 ${this.name} (${this.backend})${this.isAdmin ? ' ⭐' : ''}\nModel: ${this.activeModel}\nDirectory: ${session?.cwd || this.defaultCwd}\nSessions: ${this.sessions.size}`);
503
530
 
504
531
  cmd('/stats', ({ session }) => {
505
532
  if (!session || session.stats.calls === 0) return '📊 No calls yet';
@@ -91,10 +91,14 @@ async function selectMenu(title: string, options: string[]): Promise<number> {
91
91
  }
92
92
 
93
93
  // ================================================================
94
- // Text input (Enter confirm, ESC returns -1)
94
+ // Text input (Enter confirm, ESC returns null)
95
95
  // ================================================================
96
96
 
97
- async function promptText(label: string, defaultValue = ''): Promise<string> {
97
+ /**
98
+ * Prompt the user for text input.
99
+ * @returns The entered string, or `null` if user pressed ESC.
100
+ */
101
+ async function promptText(label: string, defaultValue = ''): Promise<string | null> {
98
102
  const buf: string[] = [];
99
103
  const defaultHint = defaultValue ? ` [${defaultValue}]` : '';
100
104
 
@@ -110,7 +114,7 @@ async function promptText(label: string, defaultValue = ''): Promise<string> {
110
114
  break;
111
115
  } else if (key === KEY.ESC) {
112
116
  process.stdout.write('\x1B[0K\n');
113
- return -1 as unknown as string; // Special return value for ESC
117
+ return null;
114
118
  } else if (key === KEY.BACKSPACE) {
115
119
  if (buf.length > 0) {
116
120
  buf.pop();
@@ -377,8 +381,18 @@ export async function runSetupWizard(options?: SetupOptions): Promise<void> {
377
381
  // 3b: Auto-generate Bot name, customizable
378
382
  const defaultName = IM_PLATFORMS[imIdx].label + 'Bot';
379
383
  const nameInput = await promptText('Bot name', defaultName);
380
- if ((nameInput as any) === -1) { if (bots.length === 0) return; break; } // ESC
381
- const botName = nameInput || defaultName; // Use default if empty
384
+ if (nameInput === null) { if (bots.length === 0) return; break; } // ESC
385
+ const botName = nameInput || defaultName;
386
+
387
+ // Validate Bot name — no whitespace-only, no dangerous characters
388
+ if (!botName || !/^\S/.test(botName)) {
389
+ console.log('⚠️ Bot name must not be empty or whitespace-only. Using default.');
390
+ }
391
+ // Sanitize: remove characters that would break directory names
392
+ const sanitized = botName.replace(/[^\w\s.-]/g, '');
393
+ if (sanitized !== botName) {
394
+ console.log(`⚠️ Bot name sanitized: "${botName}" → "${sanitized}"`);
395
+ }
382
396
 
383
397
  // 3c: Select backend
384
398
  const backendLabels = backendStatus.map(b =>
@@ -419,14 +433,24 @@ export async function runSetupWizard(options?: SetupOptions): Promise<void> {
419
433
 
420
434
  for (const field of fields) {
421
435
  const val = await promptText(field.label + (field.required ? '' : ' (optional)'));
422
- if ((val as any) === -1) { credentials._escaped = 'true'; break; } // ESC
436
+ if (val === null) { credentials._escaped = 'true'; break; } // ESC
423
437
  credentials[field.key] = val;
424
438
  }
425
439
  if (credentials._escaped) continue; // ESC go back and re-select backend
426
440
 
427
- // 3e: Working directory
428
- const cwd = await promptText('Working directory', os.homedir());
429
- if ((cwd as any) === -1) continue;
441
+ // 3e: Working directory (validated)
442
+ let cwd = await promptText('Working directory', os.homedir());
443
+ if (cwd === null) continue;
444
+ cwd = cwd.trim() || os.homedir();
445
+
446
+ // Validate path — reject obviously invalid / dangerous paths
447
+ const badPaths = ['/dev/null', '/dev/zero', '/dev/random', '/etc/passwd', '/etc/shadow', '/System'];
448
+ if (badPaths.some(bp => cwd === bp || cwd.startsWith(bp + '/'))) {
449
+ console.log(`⚠️ Invalid path "${cwd}". Using home directory instead.`);
450
+ cwd = os.homedir();
451
+ }
452
+ // Resolve to absolute path
453
+ cwd = path.resolve(cwd);
430
454
 
431
455
  // Generate unique ID (UUID, for directory isolation, renaming doesn't affect it)
432
456
  const botId = randomUUID();
@@ -437,6 +461,7 @@ export async function runSetupWizard(options?: SetupOptions): Promise<void> {
437
461
  name: botName,
438
462
  backend,
439
463
  cwd: cwd || os.homedir(),
464
+ isAdmin: bots.length === 0, // 第一个 Bot 为 admin,后续为 false
440
465
  };
441
466
 
442
467
  // Feishu needs appId + appSecret
@@ -509,14 +534,22 @@ export async function runSetupWizard(options?: SetupOptions): Promise<void> {
509
534
 
510
535
  const wsLabels = ['Sandbox mode (isolated per Bot)', 'Global mode (shared root path)'];
511
536
  const wsIdx = await selectMenu('Select workspace mode', wsLabels);
512
- if (wsIdx === -1) { console.log('\n👋 Cancelled'); process.exit(0); }
537
+ if (wsIdx === -1) { console.log('\n👋 Cancelled'); process.exit(0); }
513
538
  workspaceMode = wsIdx === 1 ? 'global' : 'sandbox';
514
539
 
515
540
  if (workspaceMode === 'global') {
516
541
  const defaultGlobal = os.homedir() + '/imtoagent-workspace';
517
542
  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();
543
+ if (gpInput === null) { console.log('\n👋 Cancelled'); process.exit(0); }
544
+ let resolved = (gpInput || defaultGlobal).trim();
545
+
546
+ // Validate path
547
+ const badPaths = ['/dev/null', '/dev/zero', '/dev/random', '/etc', '/System', '/usr'];
548
+ if (badPaths.some(bp => resolved === bp || resolved.startsWith(bp + '/'))) {
549
+ console.log(`⚠️ Invalid path "${resolved}". Using default instead.`);
550
+ resolved = defaultGlobal;
551
+ }
552
+ workspaceGlobalPath = path.resolve(resolved);
520
553
  }
521
554
 
522
555
  const home = process.env.HOME || process.env.USERPROFILE?.replace(/\\/g, '/') || '';
@@ -573,19 +606,27 @@ export async function runSetupWizard(options?: SetupOptions): Promise<void> {
573
606
  console.log(` Format: ${preset.format}`);
574
607
  console.log(` Models: ${preset.models.join(', ')}\n`);
575
608
 
576
- // Confirm/edit short name
609
+ // Confirm/edit short name (validate — must be safe JSON key)
577
610
  const nameEdit = await promptText('Provider name (leave blank to confirm)', provName);
578
- if ((nameEdit as any) === -1) continue;
611
+ if (nameEdit === null) continue;
579
612
  provName = nameEdit || provName;
613
+ // Sanitize provider name: only alphanumeric, hyphens, underscores
614
+ const sanitizedProv = provName.replace(/[^a-zA-Z0-9_-]/g, '');
615
+ if (!sanitizedProv) {
616
+ console.log('⚠️ Invalid provider name. Using original.');
617
+ } else if (sanitizedProv !== provName) {
618
+ console.log(`⚠️ Provider name sanitized: "${provName}" → "${sanitizedProv}"`);
619
+ provName = sanitizedProv;
620
+ }
580
621
 
581
622
  // Confirm/edit Base URL
582
623
  const urlEdit = await promptText('Base URL', baseUrl);
583
- if ((urlEdit as any) === -1) continue;
624
+ if (urlEdit === null) continue;
584
625
  baseUrl = urlEdit || baseUrl;
585
626
 
586
627
  // Confirm/edit model list
587
628
  const modelsEdit = await promptText('Model list (comma-separated)', models.join(', '));
588
- if ((modelsEdit as any) === -1) continue;
629
+ if (modelsEdit === null) continue;
589
630
  if (modelsEdit) models = modelsEdit.split(',').map(s => s.trim()).filter(Boolean);
590
631
 
591
632
  if (providers[provName]) {
@@ -593,17 +634,18 @@ export async function runSetupWizard(options?: SetupOptions): Promise<void> {
593
634
  }
594
635
  } else {
595
636
  // Custom
596
- provName = await promptText('Provider name (e.g. deepseek, dashscope)');
597
- if ((provName as any) === -1) { addingProviders = false; continue; }
637
+ let customName = await promptText('Provider name (e.g. deepseek, dashscope)');
638
+ if (customName === null) { addingProviders = false; continue; }
639
+ provName = customName.trim().toLowerCase().replace(/[^a-zA-Z0-9_-]/g, '');
598
640
  if (!provName) { addingProviders = false; continue; }
599
641
  if (providers[provName]) {
600
642
  console.log(`⚠️ Provider "${provName}" already exists, will overwrite\n`);
601
643
  }
602
644
 
603
645
  baseUrl = await promptText('Base URL (e.g. https://api.deepseek.com/v1)');
604
- if ((baseUrl as any) === -1) continue;
646
+ if (baseUrl === null) continue;
605
647
  const modelsStr = await promptText('Model list (comma-separated)');
606
- if ((modelsStr as any) === -1) continue;
648
+ if (modelsStr === null) continue;
607
649
  models = (modelsStr || '').split(',').map(s => s.trim()).filter(Boolean);
608
650
 
609
651
  const formatIdx = await selectMenu('API format', ['openai', 'anthropic']);
@@ -613,14 +655,14 @@ export async function runSetupWizard(options?: SetupOptions): Promise<void> {
613
655
 
614
656
  // API Key (required for all providers)
615
657
  const apiKey = await promptText('API Key');
616
- if ((apiKey as any) === -1) continue;
658
+ if (apiKey === null) continue;
617
659
  if (!apiKey) {
618
660
  console.log('⚠️ API Key is empty, this provider will be temporarily unavailable\n');
619
661
  }
620
662
 
621
663
  // Pricing (optional)
622
664
  const priceInput = await promptText('Pricing (in/out per million tokens, e.g. 0.55,2.19, leave blank to skip)');
623
- if ((priceInput as any) === -1) continue;
665
+ if (priceInput === null) continue;
624
666
 
625
667
  const pricing: any = {};
626
668
  if (priceInput) {
@@ -663,10 +705,10 @@ export async function runSetupWizard(options?: SetupOptions): Promise<void> {
663
705
  if (allModels.length > 0) {
664
706
  const existingDefault = existingConfig?.defaultModel || allModels[0];
665
707
  const val = await promptText('Default model', existingDefault);
666
- defaultModel = (val as any) === -1 ? existingDefault : (val || existingDefault);
708
+ defaultModel = val === null ? existingDefault : (val || existingDefault);
667
709
  } else {
668
- defaultModel = await promptText('Default model (provider/model)') || 'deepseek/deepseek-v4-pro';
669
- if ((defaultModel as any) === -1) defaultModel = 'deepseek/deepseek-v4-pro';
710
+ const val = await promptText('Default model (provider/model)');
711
+ defaultModel = val === null ? 'deepseek/deepseek-v4-pro' : (val || 'deepseek/deepseek-v4-pro');
670
712
  }
671
713
 
672
714
  // ===== Step 7: Generate soul files =====
@@ -719,6 +761,8 @@ export async function runSetupWizard(options?: SetupOptions): Promise<void> {
719
761
 
720
762
  fs.mkdirSync(dataDir, { recursive: true });
721
763
 
764
+ // Atomic config write: write to temp file first, then rename.
765
+ // If the write fails, the original config (if any) is preserved.
722
766
  const config: any = {
723
767
  system: existingConfig?.system || {
724
768
  defaultProjectDir: os.homedir(),
@@ -758,19 +802,42 @@ export async function runSetupWizard(options?: SetupOptions): Promise<void> {
758
802
  bots,
759
803
  };
760
804
 
761
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
762
- console.log(`✅ ${configPath}`);
805
+ // Write to temp file first for atomicity
806
+ const configTmpPath = configPath + '.tmp';
807
+ const providersTmpPath = path.join(dataDir, 'providers.json.tmp');
808
+ let writeOk = true;
763
809
 
764
- const providersFile: any = { providers, defaultModel, modelAliases: config.modelAliases };
765
- const providersPath = path.join(dataDir, 'providers.json');
766
- fs.writeFileSync(providersPath, JSON.stringify(providersFile, null, 2) + '\n');
767
- console.log(`✅ ${providersPath}`);
810
+ try {
811
+ fs.writeFileSync(configTmpPath, JSON.stringify(config, null, 2) + '\n');
812
+ fs.renameSync(configTmpPath, configPath);
813
+ console.log(`✅ ${configPath}`);
814
+ } catch (e: any) {
815
+ console.error(`❌ Failed to write config.json: ${e.message}`);
816
+ writeOk = false;
817
+ }
768
818
 
769
- const opencodePath = path.join(dataDir, 'opencode.json');
770
- const opencodeTemplate = getTemplatePath('opencode.template.json');
771
- if (fs.existsSync(opencodeTemplate)) {
772
- fs.writeFileSync(opencodePath, fs.readFileSync(opencodeTemplate, 'utf-8'));
773
- console.log(`✅ ${opencodePath}`);
819
+ if (writeOk) {
820
+ try {
821
+ const providersFile: any = { providers, defaultModel, modelAliases: config.modelAliases };
822
+ const providersPath = path.join(dataDir, 'providers.json');
823
+ fs.writeFileSync(providersTmpPath, JSON.stringify(providersFile, null, 2) + '\n');
824
+ fs.renameSync(providersTmpPath, providersPath);
825
+ console.log(`✅ ${providersPath}`);
826
+ } catch (e: any) {
827
+ console.error(`❌ Failed to write providers.json: ${e.message}`);
828
+ console.error('⚠️ config.json was written successfully, but providers.json failed.');
829
+ console.error(' Please re-run "imtoagent setup" to fix.');
830
+ writeOk = false;
831
+ }
832
+ }
833
+
834
+ if (writeOk) {
835
+ const opencodePath = path.join(dataDir, 'opencode.json');
836
+ const opencodeTemplate = getTemplatePath('opencode.template.json');
837
+ if (fs.existsSync(opencodeTemplate)) {
838
+ fs.writeFileSync(opencodePath, fs.readFileSync(opencodeTemplate, 'utf-8'));
839
+ console.log(`✅ ${opencodePath}`);
840
+ }
774
841
  }
775
842
 
776
843
  fs.mkdirSync(path.join(dataDir, 'sessions'), { recursive: true });
@@ -242,6 +242,7 @@ export interface BotConfig {
242
242
  /** 唯一标识(UUID,用于目录/文件隔离,改名不影响) */
243
243
  id?: string;
244
244
  name: string;
245
+ im: string; // 'feishu' | 'telegram' | 'wecom' | 'wechat'
245
246
  backend: string; // 'claude' | 'codex' | 'opencode'
246
247
  appId: string;
247
248
  appSecret: string;