ai-account-switch 1.9.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1593 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ // Constants for wire API modes
6
+ const WIRE_API_MODES = {
7
+ CHAT: 'chat',
8
+ RESPONSES: 'responses',
9
+ ENV: 'env'
10
+ };
11
+
12
+ const DEFAULT_WIRE_API = WIRE_API_MODES.CHAT;
13
+
14
+ // Constants for account types
15
+ const ACCOUNT_TYPES = {
16
+ CLAUDE: 'Claude',
17
+ CODEX: 'Codex',
18
+ CCR: 'CCR',
19
+ DROIDS: 'Droids'
20
+ };
21
+
22
+ // Constants for MCP server scopes
23
+ const MCP_SCOPES = {
24
+ LOCAL: 'local', // Only available in current project
25
+ PROJECT: 'project', // Shared with project members via .mcp.json
26
+ USER: 'user' // Available to all projects for current user (global)
27
+ };
28
+
29
+ const DEFAULT_MCP_SCOPE = MCP_SCOPES.LOCAL;
30
+
31
+ /**
32
+ * Cross-platform configuration manager
33
+ * Stores global accounts in user home directory
34
+ * Stores project-specific configuration in project directory
35
+ */
36
+ class ConfigManager {
37
+ constructor() {
38
+ // Global config path (stores all accounts)
39
+ this.globalConfigDir = path.join(os.homedir(), '.ai-account-switch');
40
+ this.globalConfigFile = path.join(this.globalConfigDir, 'config.json');
41
+
42
+ // Project config filename
43
+ this.projectConfigFilename = '.ais-project-config';
44
+
45
+ this.ensureConfigExists();
46
+ }
47
+
48
+ /**
49
+ * Ensure configuration directories and files exist
50
+ */
51
+ ensureConfigExists() {
52
+ // Create global config directory
53
+ if (!fs.existsSync(this.globalConfigDir)) {
54
+ fs.mkdirSync(this.globalConfigDir, { recursive: true });
55
+ }
56
+
57
+ // Create global config file if it doesn't exist
58
+ if (!fs.existsSync(this.globalConfigFile)) {
59
+ this.saveGlobalConfig({ accounts: {}, mcpServers: {}, nextAccountId: 1 });
60
+ }
61
+
62
+ // Migrate existing accounts to have IDs
63
+ this.migrateAccountIds();
64
+ }
65
+
66
+ /**
67
+ * Find project root by searching upwards for .ais-project-config file
68
+ * Similar to how git finds .git directory
69
+ */
70
+ findProjectRoot(startDir = process.cwd()) {
71
+ let currentDir = path.resolve(startDir);
72
+ const rootDir = path.parse(currentDir).root;
73
+
74
+ while (currentDir !== rootDir) {
75
+ const configPath = path.join(currentDir, this.projectConfigFilename);
76
+ if (fs.existsSync(configPath)) {
77
+ return currentDir;
78
+ }
79
+ // Move up one directory
80
+ currentDir = path.dirname(currentDir);
81
+ }
82
+
83
+ // Check root directory as well
84
+ const configPath = path.join(rootDir, this.projectConfigFilename);
85
+ if (fs.existsSync(configPath)) {
86
+ return rootDir;
87
+ }
88
+
89
+ return null;
90
+ }
91
+
92
+ /**
93
+ * Read global configuration
94
+ */
95
+ readGlobalConfig() {
96
+ try {
97
+ const data = fs.readFileSync(this.globalConfigFile, 'utf8');
98
+ const config = JSON.parse(data);
99
+ // Ensure nextAccountId exists
100
+ if (!config.nextAccountId) {
101
+ config.nextAccountId = 1;
102
+ }
103
+ return config;
104
+ } catch (error) {
105
+ return { accounts: {}, mcpServers: {}, nextAccountId: 1 };
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Save global configuration
111
+ */
112
+ saveGlobalConfig(config) {
113
+ fs.writeFileSync(this.globalConfigFile, JSON.stringify(config, null, 2), 'utf8');
114
+ }
115
+
116
+ /**
117
+ * Migrate existing accounts to have IDs
118
+ * This ensures backward compatibility by assigning IDs to accounts that don't have one
119
+ */
120
+ migrateAccountIds() {
121
+ const config = this.readGlobalConfig();
122
+ let needsSave = false;
123
+
124
+ // Ensure nextAccountId exists
125
+ if (!config.nextAccountId) {
126
+ config.nextAccountId = 1;
127
+ needsSave = true;
128
+ }
129
+
130
+ // Assign IDs to accounts that don't have one
131
+ Object.keys(config.accounts || {}).forEach(name => {
132
+ if (!config.accounts[name].id) {
133
+ config.accounts[name].id = config.nextAccountId;
134
+ config.nextAccountId++;
135
+ needsSave = true;
136
+ }
137
+ });
138
+
139
+ if (needsSave) {
140
+ this.saveGlobalConfig(config);
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Get account by ID or name
146
+ * @param {string|number} idOrName - Account ID or name
147
+ * @returns {Object|null} - Account object with name property, or null if not found
148
+ */
149
+ getAccountByIdOrName(idOrName) {
150
+ const accounts = this.getAllAccounts();
151
+
152
+ // Try to parse as ID (number)
153
+ const id = parseInt(idOrName, 10);
154
+ if (!isNaN(id)) {
155
+ // Search by ID
156
+ for (const [name, account] of Object.entries(accounts)) {
157
+ if (account.id === id) {
158
+ return { name, ...account };
159
+ }
160
+ }
161
+ }
162
+
163
+ // Search by name
164
+ const account = accounts[idOrName];
165
+ if (account) {
166
+ return { name: idOrName, ...account };
167
+ }
168
+
169
+ return null;
170
+ }
171
+
172
+ /**
173
+ * Add or update an account
174
+ */
175
+ addAccount(name, accountData) {
176
+ const config = this.readGlobalConfig();
177
+
178
+ // Assign ID for new accounts
179
+ const isNewAccount = !config.accounts[name];
180
+ const accountId = isNewAccount ? config.nextAccountId : config.accounts[name].id;
181
+
182
+ config.accounts[name] = {
183
+ ...accountData,
184
+ id: accountId,
185
+ createdAt: config.accounts[name]?.createdAt || new Date().toISOString(),
186
+ updatedAt: new Date().toISOString()
187
+ };
188
+
189
+ // Increment nextAccountId only for new accounts
190
+ if (isNewAccount) {
191
+ config.nextAccountId++;
192
+ }
193
+
194
+ this.saveGlobalConfig(config);
195
+ return true;
196
+ }
197
+
198
+ /**
199
+ * Get all accounts
200
+ */
201
+ getAllAccounts() {
202
+ const config = this.readGlobalConfig();
203
+ return config.accounts || {};
204
+ }
205
+
206
+ /**
207
+ * Get a specific account
208
+ */
209
+ getAccount(name) {
210
+ const accounts = this.getAllAccounts();
211
+ return accounts[name] || null;
212
+ }
213
+
214
+ /**
215
+ * Remove an account
216
+ */
217
+ removeAccount(name) {
218
+ const config = this.readGlobalConfig();
219
+ if (config.accounts[name]) {
220
+ delete config.accounts[name];
221
+ this.saveGlobalConfig(config);
222
+ return true;
223
+ }
224
+ return false;
225
+ }
226
+
227
+ /**
228
+ * Set current project's active account
229
+ */
230
+ setProjectAccount(accountName) {
231
+ const account = this.getAccount(accountName);
232
+ if (!account) {
233
+ return false;
234
+ }
235
+
236
+ const projectRoot = process.cwd();
237
+ const projectConfigFile = path.join(projectRoot, this.projectConfigFilename);
238
+
239
+ // Read existing project config to preserve enabledMcpServers
240
+ let existingConfig = {};
241
+ if (fs.existsSync(projectConfigFile)) {
242
+ try {
243
+ const data = fs.readFileSync(projectConfigFile, 'utf8');
244
+ existingConfig = JSON.parse(data);
245
+ } catch (error) {
246
+ // If parsing fails, start fresh
247
+ }
248
+ }
249
+
250
+ const projectConfig = {
251
+ activeAccount: accountName,
252
+ projectPath: projectRoot,
253
+ setAt: new Date().toISOString(),
254
+ enabledMcpServers: existingConfig.enabledMcpServers || []
255
+ };
256
+
257
+ fs.writeFileSync(projectConfigFile, JSON.stringify(projectConfig, null, 2), 'utf8');
258
+
259
+ // Generate configuration based on account type
260
+ if (account.type === 'Codex') {
261
+ // Codex type accounts only need Codex configuration
262
+ this.generateCodexConfig(account, projectRoot);
263
+ } else if (account.type === 'Droids') {
264
+ // Droids type accounts only need Droids configuration
265
+ this.generateDroidsConfig(account, projectRoot);
266
+ } else if (account.type === 'CCR') {
267
+ // CCR type accounts need both CCR and Claude configuration
268
+ this.generateCCRConfig(account, projectRoot);
269
+ this.generateClaudeConfigForCCR(account, projectRoot);
270
+ } else {
271
+ // Claude and other types need Claude Code configuration
272
+ this.generateClaudeConfigWithMcp(account, projectRoot);
273
+ }
274
+
275
+ // Add to .gitignore if git is initialized
276
+ this.addToGitignore(projectRoot);
277
+
278
+ return true;
279
+ }
280
+
281
+ /**
282
+ * Add AIS config files to .gitignore if git repository exists
283
+ */
284
+ addToGitignore(projectRoot = process.cwd()) {
285
+ const gitDir = path.join(projectRoot, '.git');
286
+ const gitignorePath = path.join(projectRoot, '.gitignore');
287
+
288
+ // Check if this is a git repository
289
+ if (!fs.existsSync(gitDir)) {
290
+ return false;
291
+ }
292
+
293
+ // Files to add to .gitignore
294
+ const filesToIgnore = [
295
+ this.projectConfigFilename,
296
+ '.claude/settings.local.json',
297
+ '.codex-profile',
298
+ '.droids/config.json'
299
+ ];
300
+
301
+ let gitignoreContent = '';
302
+ let needsUpdate = false;
303
+
304
+ // Read existing .gitignore if it exists
305
+ if (fs.existsSync(gitignorePath)) {
306
+ gitignoreContent = fs.readFileSync(gitignorePath, 'utf8');
307
+ }
308
+
309
+ // Split into lines for easier processing
310
+ const lines = gitignoreContent.split('\n');
311
+ const existingEntries = new Set(lines.map(line => line.trim()));
312
+
313
+ // Check which files need to be added
314
+ const entriesToAdd = [];
315
+ for (const file of filesToIgnore) {
316
+ if (!existingEntries.has(file)) {
317
+ entriesToAdd.push(file);
318
+ needsUpdate = true;
319
+ }
320
+ }
321
+
322
+ if (!needsUpdate) {
323
+ return false;
324
+ }
325
+
326
+ // Add AIS section header if adding new entries
327
+ let newContent = gitignoreContent;
328
+
329
+ // Ensure file ends with newline if it has content
330
+ if (newContent.length > 0 && !newContent.endsWith('\n')) {
331
+ newContent += '\n';
332
+ }
333
+
334
+ // Add section header and entries
335
+ if (entriesToAdd.length > 0) {
336
+ // Add blank line before section if file has content
337
+ if (newContent.length > 0) {
338
+ newContent += '\n';
339
+ }
340
+
341
+ newContent += '# AIS (AI Account Switch) - Local configuration files\n';
342
+ entriesToAdd.forEach(entry => {
343
+ newContent += entry + '\n';
344
+ });
345
+ }
346
+
347
+ // Write updated .gitignore
348
+ fs.writeFileSync(gitignorePath, newContent, 'utf8');
349
+ return true;
350
+ }
351
+
352
+ /**
353
+ * Generate Claude Code .claude/settings.local.json configuration
354
+ */
355
+ generateClaudeConfig(account, projectRoot = process.cwd()) {
356
+ const claudeDir = path.join(projectRoot, '.claude');
357
+ const claudeConfigFile = path.join(claudeDir, 'settings.local.json');
358
+
359
+ // Create .claude directory if it doesn't exist
360
+ if (!fs.existsSync(claudeDir)) {
361
+ fs.mkdirSync(claudeDir, { recursive: true });
362
+ }
363
+
364
+ // Read existing config if it exists
365
+ let existingConfig = {};
366
+ if (fs.existsSync(claudeConfigFile)) {
367
+ try {
368
+ const data = fs.readFileSync(claudeConfigFile, 'utf8');
369
+ existingConfig = JSON.parse(data);
370
+ } catch (error) {
371
+ // If parsing fails, start fresh
372
+ existingConfig = {};
373
+ }
374
+ }
375
+
376
+ // List of model-related environment variable keys that should be cleared
377
+ const modelKeys = [
378
+ 'DEFAULT_MODEL',
379
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL',
380
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL',
381
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
382
+ 'CLAUDE_CODE_SUBAGENT_MODEL',
383
+ 'ANTHROPIC_MODEL'
384
+ ];
385
+
386
+ // Build Claude configuration - preserve existing env but clear model configs
387
+ const existingEnv = existingConfig.env || {};
388
+ const cleanedEnv = {};
389
+
390
+ // Copy all existing env vars except model-related ones
391
+ Object.keys(existingEnv).forEach(key => {
392
+ if (!modelKeys.includes(key)) {
393
+ cleanedEnv[key] = existingEnv[key];
394
+ }
395
+ });
396
+
397
+ const claudeConfig = {
398
+ ...existingConfig,
399
+ env: {
400
+ ...cleanedEnv,
401
+ ANTHROPIC_AUTH_TOKEN: account.apiKey
402
+ }
403
+ };
404
+
405
+ // Add API URL if specified
406
+ if (account.apiUrl) {
407
+ claudeConfig.env.ANTHROPIC_BASE_URL = account.apiUrl;
408
+ }
409
+
410
+ // Add custom environment variables if specified
411
+ if (account.customEnv && typeof account.customEnv === 'object') {
412
+ Object.keys(account.customEnv).forEach(key => {
413
+ claudeConfig.env[key] = account.customEnv[key];
414
+ });
415
+ }
416
+
417
+ // Add model configuration from active model group
418
+ if (account.modelGroups && account.activeModelGroup) {
419
+ const activeGroup = account.modelGroups[account.activeModelGroup];
420
+
421
+ if (activeGroup && typeof activeGroup === 'object') {
422
+ const defaultModel = activeGroup.DEFAULT_MODEL;
423
+
424
+ // Set DEFAULT_MODEL if specified
425
+ if (defaultModel) {
426
+ claudeConfig.env.DEFAULT_MODEL = defaultModel;
427
+ }
428
+
429
+ // Set other model configs, using DEFAULT_MODEL as fallback if they're not specified
430
+ modelKeys.slice(1).forEach(key => { // Skip DEFAULT_MODEL as it's already set
431
+ if (activeGroup[key]) {
432
+ // If the specific model is configured, use it
433
+ claudeConfig.env[key] = activeGroup[key];
434
+ } else if (defaultModel) {
435
+ // If not configured but DEFAULT_MODEL exists, use DEFAULT_MODEL as fallback
436
+ claudeConfig.env[key] = defaultModel;
437
+ }
438
+ });
439
+ }
440
+ }
441
+ // Backward compatibility: support old modelConfig structure
442
+ else if (account.modelConfig && typeof account.modelConfig === 'object') {
443
+ const defaultModel = account.modelConfig.DEFAULT_MODEL;
444
+
445
+ if (defaultModel) {
446
+ claudeConfig.env.DEFAULT_MODEL = defaultModel;
447
+ }
448
+
449
+ modelKeys.slice(1).forEach(key => {
450
+ if (account.modelConfig[key]) {
451
+ claudeConfig.env[key] = account.modelConfig[key];
452
+ } else if (defaultModel) {
453
+ claudeConfig.env[key] = defaultModel;
454
+ }
455
+ });
456
+ }
457
+
458
+ // Preserve existing permissions if any
459
+ if (!claudeConfig.permissions) {
460
+ claudeConfig.permissions = existingConfig.permissions || {
461
+ allow: [],
462
+ deny: [],
463
+ ask: []
464
+ };
465
+ }
466
+
467
+ // Write Claude configuration
468
+ fs.writeFileSync(claudeConfigFile, JSON.stringify(claudeConfig, null, 2), 'utf8');
469
+ }
470
+
471
+ /**
472
+ * Generate Droids configuration in .droids/config.json
473
+ */
474
+ generateDroidsConfig(account, projectRoot = process.cwd()) {
475
+ const droidsDir = path.join(projectRoot, '.droids');
476
+ const droidsConfigFile = path.join(droidsDir, 'config.json');
477
+
478
+ // Create .droids directory if it doesn't exist
479
+ if (!fs.existsSync(droidsDir)) {
480
+ fs.mkdirSync(droidsDir, { recursive: true });
481
+ }
482
+
483
+ // Build Droids configuration
484
+ const droidsConfig = {
485
+ apiKey: account.apiKey
486
+ };
487
+
488
+ // Add API URL if specified
489
+ if (account.apiUrl) {
490
+ droidsConfig.baseUrl = account.apiUrl;
491
+ }
492
+
493
+ // Add model configuration - Droids uses simple model field
494
+ if (account.model) {
495
+ droidsConfig.model = account.model;
496
+ }
497
+
498
+ // Add custom environment variables as customSettings
499
+ if (account.customEnv && typeof account.customEnv === 'object') {
500
+ droidsConfig.customSettings = account.customEnv;
501
+ }
502
+
503
+ // Write Droids configuration
504
+ fs.writeFileSync(droidsConfigFile, JSON.stringify(droidsConfig, null, 2), 'utf8');
505
+ }
506
+
507
+ /**
508
+ * Generate CCR configuration in ~/.claude-code-router/config.json
509
+ */
510
+ generateCCRConfig(account, projectRoot = process.cwd()) {
511
+ const ccrConfigDir = path.join(os.homedir(), '.claude-code-router');
512
+ const ccrConfigFile = path.join(ccrConfigDir, 'config.json');
513
+
514
+ // Read existing config
515
+ let config = {};
516
+ if (fs.existsSync(ccrConfigFile)) {
517
+ const data = fs.readFileSync(ccrConfigFile, 'utf8');
518
+ config = JSON.parse(data);
519
+ }
520
+
521
+ if (!account.ccrConfig) return;
522
+
523
+ const { providerName, models, defaultModel, backgroundModel, thinkModel } = account.ccrConfig;
524
+
525
+ // Check if provider exists
526
+ const providerIndex = config.Providers?.findIndex(p => p.name === providerName);
527
+
528
+ const provider = {
529
+ api_base_url: account.apiUrl || '',
530
+ api_key: account.apiKey,
531
+ models: models,
532
+ name: providerName
533
+ };
534
+
535
+ if (providerIndex >= 0) {
536
+ config.Providers[providerIndex] = provider;
537
+ } else {
538
+ if (!config.Providers) config.Providers = [];
539
+ config.Providers.push(provider);
540
+ }
541
+
542
+ // Update Router configuration
543
+ if (!config.Router) config.Router = {};
544
+ config.Router.default = `${providerName},${defaultModel}`;
545
+ config.Router.background = `${providerName},${backgroundModel}`;
546
+ config.Router.think = `${providerName},${thinkModel}`;
547
+
548
+ fs.writeFileSync(ccrConfigFile, JSON.stringify(config, null, 2), 'utf8');
549
+ }
550
+
551
+ /**
552
+ * Generate Claude configuration for CCR type accounts
553
+ */
554
+ generateClaudeConfigForCCR(account, projectRoot = process.cwd()) {
555
+ const claudeDir = path.join(projectRoot, '.claude');
556
+ const claudeConfigFile = path.join(claudeDir, 'settings.local.json');
557
+ const ccrConfigFile = path.join(os.homedir(), '.claude-code-router', 'config.json');
558
+
559
+ // Create .claude directory if it doesn't exist
560
+ if (!fs.existsSync(claudeDir)) {
561
+ fs.mkdirSync(claudeDir, { recursive: true });
562
+ }
563
+
564
+ // Read CCR config to get PORT
565
+ let port = 3456; // default port
566
+ if (fs.existsSync(ccrConfigFile)) {
567
+ try {
568
+ const ccrConfig = JSON.parse(fs.readFileSync(ccrConfigFile, 'utf8'));
569
+ if (ccrConfig.PORT) {
570
+ port = ccrConfig.PORT;
571
+ }
572
+ } catch (e) {
573
+ // Use default port if reading fails
574
+ }
575
+ }
576
+
577
+ // Read existing config if it exists
578
+ let existingConfig = {};
579
+ if (fs.existsSync(claudeConfigFile)) {
580
+ try {
581
+ const data = fs.readFileSync(claudeConfigFile, 'utf8');
582
+ existingConfig = JSON.parse(data);
583
+ } catch (error) {
584
+ existingConfig = {};
585
+ }
586
+ }
587
+
588
+ const claudeConfig = {
589
+ ...existingConfig,
590
+ env: {
591
+ ...(existingConfig.env || {}),
592
+ ANTHROPIC_AUTH_TOKEN: account.apiKey,
593
+ ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`
594
+ }
595
+ };
596
+
597
+ // Add custom environment variables if specified
598
+ if (account.customEnv && typeof account.customEnv === 'object') {
599
+ Object.keys(account.customEnv).forEach(key => {
600
+ claudeConfig.env[key] = account.customEnv[key];
601
+ });
602
+ }
603
+
604
+ // Preserve existing permissions if any
605
+ if (!claudeConfig.permissions) {
606
+ claudeConfig.permissions = existingConfig.permissions || {
607
+ allow: [],
608
+ deny: [],
609
+ ask: []
610
+ };
611
+ }
612
+
613
+ // Write Claude configuration
614
+ fs.writeFileSync(claudeConfigFile, JSON.stringify(claudeConfig, null, 2), 'utf8');
615
+ }
616
+
617
+ /**
618
+ * Generate Codex profile in global ~/.codex/config.toml
619
+ */
620
+ generateCodexConfig(account, projectRoot = process.cwd()) {
621
+ const codexConfigDir = path.join(os.homedir(), '.codex');
622
+ const codexConfigFile = path.join(codexConfigDir, 'config.toml');
623
+
624
+ // Create .codex directory if it doesn't exist
625
+ if (!fs.existsSync(codexConfigDir)) {
626
+ fs.mkdirSync(codexConfigDir, { recursive: true });
627
+ }
628
+
629
+ // Read existing config if it exists
630
+ let existingConfig = '';
631
+ if (fs.existsSync(codexConfigFile)) {
632
+ existingConfig = fs.readFileSync(codexConfigFile, 'utf8');
633
+ }
634
+
635
+ // Generate profile name based on project path
636
+ const projectName = path.basename(projectRoot);
637
+ const profileName = `ais_${projectName}`;
638
+
639
+ // Escape special regex characters in names
640
+ const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
641
+ const escapedProjectName = escapeRegex(projectName);
642
+ const escapedProfileName = escapeRegex(profileName);
643
+
644
+ // Build profile configuration
645
+ let profileConfig = `\n# AIS Profile for project: ${projectRoot}\n`;
646
+ profileConfig += `[profiles.${profileName}]\n`;
647
+
648
+ // Determine model provider and model based on account type
649
+ if (account.type === 'Codex') {
650
+ // For Codex type accounts, use custom provider configuration
651
+ const providerName = `ais_${account.name || 'provider'}`;
652
+ const escapedProviderName = escapeRegex(providerName);
653
+
654
+ profileConfig += `model_provider = "${providerName}"\n`;
655
+
656
+ // Add model configuration - Codex uses simple model field
657
+ if (account.model) {
658
+ profileConfig += `model = "${account.model}"\n`;
659
+ }
660
+
661
+ // Smart /v1 path handling
662
+ let baseUrl = account.apiUrl || '';
663
+ if (baseUrl) {
664
+ // Remove trailing slashes
665
+ baseUrl = baseUrl.replace(/\/+$/, '');
666
+
667
+ // Check if URL already has a path beyond the domain
668
+ // Pattern: protocol://domain or protocol://domain:port (no path)
669
+ const isDomainOnly = baseUrl.match(/^https?:\/\/[^\/]+$/);
670
+
671
+ // Only add /v1 if:
672
+ // 1. URL is domain-only (no path), OR
673
+ // 2. URL explicitly ends with /v1 already (ensure consistency)
674
+ if (isDomainOnly) {
675
+ baseUrl += '/v1';
676
+ }
677
+ // If URL has a path (e.g., /v2, /custom, /api), keep it as is
678
+ }
679
+
680
+ // Remove existing provider if it exists (simpler than updating)
681
+ const providerPattern = new RegExp(`\\[model_providers\\.${escapedProviderName}\\][\\s\\S]*?(?=\\n\\[|$)`, 'g');
682
+ existingConfig = existingConfig.replace(providerPattern, '');
683
+
684
+ // Add new provider details
685
+ profileConfig += `\n[model_providers.${providerName}]\n`;
686
+ profileConfig += `name = "${providerName}"\n`;
687
+
688
+ if (baseUrl) {
689
+ profileConfig += `base_url = "${baseUrl}"\n`;
690
+ }
691
+
692
+ // Determine wire_api based on account configuration (default to chat for backward compatibility)
693
+ const wireApi = account.wireApi || DEFAULT_WIRE_API;
694
+
695
+ if (wireApi === WIRE_API_MODES.CHAT) {
696
+ // Chat mode: use HTTP headers for authentication
697
+ profileConfig += `wire_api = "${WIRE_API_MODES.CHAT}"\n`;
698
+ profileConfig += `http_headers = { "Authorization" = "Bearer ${account.apiKey}" }\n`;
699
+
700
+ // Note: We do NOT clear auth.json here because:
701
+ // 1. auth.json is a global file shared by all projects
702
+ // 2. Other projects may be using responses mode and need the API key
703
+ // 3. Chat mode doesn't use auth.json anyway, so no conflict exists
704
+ } else if (wireApi === WIRE_API_MODES.RESPONSES) {
705
+ // Responses mode: use auth.json for authentication
706
+ profileConfig += `wire_api = "${WIRE_API_MODES.RESPONSES}"\n`;
707
+ profileConfig += `requires_openai_auth = true\n`;
708
+
709
+ // Update auth.json with API key
710
+ this.updateCodexAuthJson(account.apiKey);
711
+ } else if (wireApi === WIRE_API_MODES.ENV) {
712
+ // Env mode: use environment variable for authentication
713
+ profileConfig += `wire_api = "${WIRE_API_MODES.CHAT}"\n`;
714
+ const envKey = account.envKey || 'AIS_USER_API_KEY';
715
+ profileConfig += `env_key = "${envKey}"\n`;
716
+
717
+ // Clear auth.json to ensure env mode is used
718
+ this.clearCodexAuthJson();
719
+ }
720
+ }
721
+
722
+ // Remove all old profiles with the same name (including duplicates)
723
+ // Use line-by-line parsing for more reliable cleanup
724
+ existingConfig = this._removeProfileFromConfig(existingConfig, profileName);
725
+
726
+ // Append new profile
727
+ const newConfig = existingConfig.trimEnd() + '\n' + profileConfig;
728
+
729
+ // Write Codex configuration
730
+ fs.writeFileSync(codexConfigFile, newConfig, 'utf8');
731
+
732
+ // Create a helper script in project directory
733
+ const helperScript = path.join(projectRoot, '.codex-profile');
734
+ fs.writeFileSync(helperScript, profileName, 'utf8');
735
+ }
736
+
737
+ /**
738
+ * Read auth.json file
739
+ * @private
740
+ * @returns {Object} Parsed auth data or empty object
741
+ */
742
+ _readAuthJson(authJsonFile) {
743
+ if (!fs.existsSync(authJsonFile)) {
744
+ return {};
745
+ }
746
+
747
+ try {
748
+ const content = fs.readFileSync(authJsonFile, 'utf8');
749
+ return JSON.parse(content);
750
+ } catch (parseError) {
751
+ const chalk = require('chalk');
752
+ console.warn(
753
+ chalk.yellow(
754
+ `⚠ Warning: Could not parse existing auth.json, will create new file (警告: 无法解析现有 auth.json,将创建新文件)`
755
+ )
756
+ );
757
+ return {};
758
+ }
759
+ }
760
+
761
+ /**
762
+ * Write auth.json file atomically with proper permissions
763
+ * Uses atomic write (temp file + rename) to prevent corruption from concurrent access
764
+ * @private
765
+ * @param {string} authJsonFile - Path to auth.json
766
+ * @param {Object} authData - Auth data to write
767
+ */
768
+ _writeAuthJson(authJsonFile, authData) {
769
+ const chalk = require('chalk');
770
+ const tempFile = `${authJsonFile}.tmp.${process.pid}`;
771
+
772
+ try {
773
+ // Write to temporary file first (atomic operation)
774
+ fs.writeFileSync(tempFile, JSON.stringify(authData, null, 2), 'utf8');
775
+
776
+ // Set file permissions to 600 (owner read/write only) for security
777
+ if (process.platform !== 'win32') {
778
+ fs.chmodSync(tempFile, 0o600);
779
+ }
780
+
781
+ // Atomically rename temp file to actual file
782
+ // This is atomic on POSIX systems and prevents corruption
783
+ fs.renameSync(tempFile, authJsonFile);
784
+ } catch (error) {
785
+ // Clean up temp file if it exists
786
+ if (fs.existsSync(tempFile)) {
787
+ try {
788
+ fs.unlinkSync(tempFile);
789
+ } catch (cleanupError) {
790
+ // Ignore cleanup errors
791
+ }
792
+ }
793
+ throw error;
794
+ }
795
+ }
796
+
797
+ /**
798
+ * Remove a profile from TOML config string
799
+ * Uses line-by-line parsing for reliable removal of all instances
800
+ * @private
801
+ * @param {string} configContent - The TOML config content
802
+ * @param {string} profileName - The profile name to remove (e.g., "ais_myproject")
803
+ * @returns {string} Cleaned config content
804
+ */
805
+ _removeProfileFromConfig(configContent, profileName) {
806
+ const lines = configContent.split('\n');
807
+ const cleanedLines = [];
808
+ let skipUntilNextSection = false;
809
+ const profileSectionHeader = `[profiles.${profileName}]`;
810
+
811
+ for (let i = 0; i < lines.length; i++) {
812
+ const line = lines[i];
813
+ const trimmedLine = line.trim();
814
+
815
+ // Check if this is the profile section we want to remove
816
+ if (trimmedLine === profileSectionHeader) {
817
+ skipUntilNextSection = true;
818
+
819
+ // Remove the AIS comment line before it if present
820
+ if (cleanedLines.length > 0) {
821
+ const lastLine = cleanedLines[cleanedLines.length - 1].trim();
822
+ if (lastLine.startsWith('# AIS Profile for project:')) {
823
+ cleanedLines.pop();
824
+ }
825
+ }
826
+
827
+ // Remove trailing empty lines before the profile
828
+ while (cleanedLines.length > 0 && cleanedLines[cleanedLines.length - 1].trim() === '') {
829
+ cleanedLines.pop();
830
+ }
831
+
832
+ continue; // Skip the profile header line
833
+ }
834
+
835
+ // If we're in skip mode, check if we've reached the next section
836
+ if (skipUntilNextSection) {
837
+ // A new section starts with '[' at the beginning (after trimming)
838
+ if (trimmedLine.startsWith('[')) {
839
+ skipUntilNextSection = false;
840
+ // Don't skip this line - it's the start of a new section
841
+ } else {
842
+ // Skip this line as it belongs to the profile we're removing
843
+ continue;
844
+ }
845
+ }
846
+
847
+ cleanedLines.push(line);
848
+ }
849
+
850
+ // Join lines and clean up excessive empty lines
851
+ let result = cleanedLines.join('\n');
852
+
853
+ // Replace 3+ consecutive newlines with just 2 (one blank line)
854
+ result = result.replace(/\n{3,}/g, '\n\n');
855
+
856
+ return result;
857
+ }
858
+
859
+ /**
860
+ * Clear OPENAI_API_KEY in ~/.codex/auth.json for chat mode
861
+ * @deprecated This method is no longer called automatically.
862
+ * Chat mode doesn't require clearing auth.json since it doesn't use it.
863
+ */
864
+ clearCodexAuthJson() {
865
+ const chalk = require('chalk');
866
+ const codexDir = path.join(os.homedir(), '.codex');
867
+ const authJsonFile = path.join(codexDir, 'auth.json');
868
+
869
+ try {
870
+ // Ensure .codex directory exists
871
+ if (!fs.existsSync(codexDir)) {
872
+ fs.mkdirSync(codexDir, { recursive: true });
873
+ }
874
+
875
+ // Read existing auth data
876
+ const authData = this._readAuthJson(authJsonFile);
877
+
878
+ // Clear OPENAI_API_KEY (set to empty string)
879
+ authData.OPENAI_API_KEY = "";
880
+
881
+ // Write atomically with proper permissions
882
+ this._writeAuthJson(authJsonFile, authData);
883
+
884
+ console.log(
885
+ chalk.cyan(
886
+ `✓ Cleared OPENAI_API_KEY in auth.json (chat mode) (已清空 auth.json 中的 OPENAI_API_KEY)`
887
+ )
888
+ );
889
+ } catch (error) {
890
+ console.error(
891
+ chalk.yellow(
892
+ `⚠ Warning: Failed to clear auth.json: ${error.message} (警告: 清空 auth.json 失败)`
893
+ )
894
+ );
895
+ // Don't throw error, just warn - this is not critical for chat mode
896
+ }
897
+ }
898
+
899
+ /**
900
+ * Update ~/.codex/auth.json with API key for responses mode
901
+ * @param {string} apiKey - API key to store in auth.json
902
+ * @throws {Error} If file operations fail
903
+ */
904
+ updateCodexAuthJson(apiKey) {
905
+ const chalk = require('chalk');
906
+ const codexDir = path.join(os.homedir(), '.codex');
907
+ const authJsonFile = path.join(codexDir, 'auth.json');
908
+
909
+ try {
910
+ // Ensure .codex directory exists
911
+ if (!fs.existsSync(codexDir)) {
912
+ fs.mkdirSync(codexDir, { recursive: true });
913
+ }
914
+
915
+ // Read existing auth data
916
+ const authData = this._readAuthJson(authJsonFile);
917
+
918
+ // Update OPENAI_API_KEY
919
+ authData.OPENAI_API_KEY = apiKey;
920
+
921
+ // Write atomically with proper permissions
922
+ this._writeAuthJson(authJsonFile, authData);
923
+
924
+ console.log(
925
+ chalk.green(
926
+ `✓ Updated auth.json at: ${authJsonFile} (已更新 auth.json)`
927
+ )
928
+ );
929
+ } catch (error) {
930
+ console.error(
931
+ chalk.red(
932
+ `✗ Failed to update auth.json: ${error.message} (更新 auth.json 失败)`
933
+ )
934
+ );
935
+ throw error;
936
+ }
937
+ }
938
+
939
+ /**
940
+ * Get current project's active account
941
+ * Searches upwards from current directory to find project root
942
+ */
943
+ getProjectAccount() {
944
+ try {
945
+ const projectRoot = this.findProjectRoot();
946
+ if (!projectRoot) {
947
+ return null;
948
+ }
949
+
950
+ const projectConfigFile = path.join(projectRoot, this.projectConfigFilename);
951
+ const data = fs.readFileSync(projectConfigFile, 'utf8');
952
+ const projectConfig = JSON.parse(data);
953
+
954
+ // Get the full account details
955
+ const account = this.getAccount(projectConfig.activeAccount);
956
+ if (account) {
957
+ return {
958
+ name: projectConfig.activeAccount,
959
+ ...account,
960
+ setAt: projectConfig.setAt,
961
+ projectRoot: projectRoot
962
+ };
963
+ }
964
+ return null;
965
+ } catch (error) {
966
+ return null;
967
+ }
968
+ }
969
+
970
+ /**
971
+ * Check if an account exists
972
+ */
973
+ accountExists(name) {
974
+ const accounts = this.getAllAccounts();
975
+ return !!accounts[name];
976
+ }
977
+
978
+ /**
979
+ * Get configuration file paths (for display purposes)
980
+ */
981
+ getConfigPaths() {
982
+ return {
983
+ global: this.globalConfigFile,
984
+ project: this.projectConfigFile,
985
+ globalDir: this.globalConfigDir
986
+ };
987
+ }
988
+
989
+ /**
990
+ * Add or update an MCP server
991
+ */
992
+ addMcpServer(name, serverData) {
993
+ const config = this.readGlobalConfig();
994
+ if (!config.mcpServers) config.mcpServers = {};
995
+
996
+ // Set default scope if not specified
997
+ if (!serverData.scope) {
998
+ serverData.scope = DEFAULT_MCP_SCOPE;
999
+ }
1000
+
1001
+ config.mcpServers[name] = {
1002
+ ...serverData,
1003
+ createdAt: config.mcpServers[name]?.createdAt || new Date().toISOString(),
1004
+ updatedAt: new Date().toISOString()
1005
+ };
1006
+ this.saveGlobalConfig(config);
1007
+ return true;
1008
+ }
1009
+
1010
+ /**
1011
+ * Get all MCP servers
1012
+ */
1013
+ getAllMcpServers() {
1014
+ const config = this.readGlobalConfig();
1015
+ return config.mcpServers || {};
1016
+ }
1017
+
1018
+ /**
1019
+ * Get a specific MCP server
1020
+ */
1021
+ getMcpServer(name) {
1022
+ const servers = this.getAllMcpServers();
1023
+ return servers[name] || null;
1024
+ }
1025
+
1026
+ /**
1027
+ * Update an MCP server
1028
+ */
1029
+ updateMcpServer(name, serverData) {
1030
+ const config = this.readGlobalConfig();
1031
+ if (!config.mcpServers || !config.mcpServers[name]) return false;
1032
+ config.mcpServers[name] = {
1033
+ ...serverData,
1034
+ createdAt: config.mcpServers[name].createdAt,
1035
+ updatedAt: new Date().toISOString()
1036
+ };
1037
+ this.saveGlobalConfig(config);
1038
+ return true;
1039
+ }
1040
+
1041
+ /**
1042
+ * Check if MCP server is enabled in current project
1043
+ */
1044
+ isMcpServerEnabledInCurrentProject(serverName) {
1045
+ try {
1046
+ const projectRoot = this.findProjectRoot();
1047
+ if (!projectRoot) return false;
1048
+
1049
+ const projectConfigFile = path.join(projectRoot, this.projectConfigFilename);
1050
+ if (!fs.existsSync(projectConfigFile)) return false;
1051
+
1052
+ const data = fs.readFileSync(projectConfigFile, 'utf8');
1053
+ const projectConfig = JSON.parse(data);
1054
+
1055
+ return projectConfig.enabledMcpServers &&
1056
+ projectConfig.enabledMcpServers.includes(serverName);
1057
+ } catch (error) {
1058
+ return false;
1059
+ }
1060
+ }
1061
+
1062
+ /**
1063
+ * Remove MCP server from current project's enabled list
1064
+ */
1065
+ removeMcpServerFromCurrentProject(serverName) {
1066
+ try {
1067
+ const projectRoot = this.findProjectRoot();
1068
+ if (!projectRoot) return false;
1069
+
1070
+ const projectConfigFile = path.join(projectRoot, this.projectConfigFilename);
1071
+ if (!fs.existsSync(projectConfigFile)) return false;
1072
+
1073
+ const data = fs.readFileSync(projectConfigFile, 'utf8');
1074
+ const projectConfig = JSON.parse(data);
1075
+
1076
+ if (!projectConfig.enabledMcpServers) return false;
1077
+
1078
+ const index = projectConfig.enabledMcpServers.indexOf(serverName);
1079
+ if (index > -1) {
1080
+ projectConfig.enabledMcpServers.splice(index, 1);
1081
+ fs.writeFileSync(projectConfigFile, JSON.stringify(projectConfig, null, 2), 'utf8');
1082
+ return true;
1083
+ }
1084
+ return false;
1085
+ } catch (error) {
1086
+ return false;
1087
+ }
1088
+ }
1089
+
1090
+ /**
1091
+ * Remove an MCP server
1092
+ */
1093
+ removeMcpServer(name) {
1094
+ const config = this.readGlobalConfig();
1095
+ if (config.mcpServers && config.mcpServers[name]) {
1096
+ delete config.mcpServers[name];
1097
+ this.saveGlobalConfig(config);
1098
+ return true;
1099
+ }
1100
+ return false;
1101
+ }
1102
+
1103
+ /**
1104
+ * Get project MCP configuration
1105
+ */
1106
+ getProjectMcpServers() {
1107
+ try {
1108
+ const projectRoot = this.findProjectRoot();
1109
+ if (!projectRoot) return [];
1110
+ const projectConfigFile = path.join(projectRoot, this.projectConfigFilename);
1111
+ if (!fs.existsSync(projectConfigFile)) return [];
1112
+ const data = fs.readFileSync(projectConfigFile, 'utf8');
1113
+ const projectConfig = JSON.parse(data);
1114
+ return projectConfig.enabledMcpServers || [];
1115
+ } catch (error) {
1116
+ return [];
1117
+ }
1118
+ }
1119
+
1120
+ /**
1121
+ * Enable MCP server for current project with scope
1122
+ * @param {string} serverName - Name of the MCP server
1123
+ * @param {string} scope - Scope: 'local', 'project', or 'user'
1124
+ */
1125
+ enableProjectMcpServer(serverName, scope = DEFAULT_MCP_SCOPE) {
1126
+ const server = this.getMcpServer(serverName);
1127
+ if (!server) return false;
1128
+
1129
+ const projectRoot = this.findProjectRoot();
1130
+ if (!projectRoot) {
1131
+ throw new Error('Not in a configured project directory. Run "ais use" first.');
1132
+ }
1133
+
1134
+ const projectConfigFile = path.join(projectRoot, this.projectConfigFilename);
1135
+ if (!fs.existsSync(projectConfigFile)) {
1136
+ throw new Error('Project not configured. Run "ais use" first.');
1137
+ }
1138
+
1139
+ try {
1140
+ const data = fs.readFileSync(projectConfigFile, 'utf8');
1141
+ const projectConfig = JSON.parse(data);
1142
+
1143
+ // Update server scope in global config
1144
+ const globalConfig = this.readGlobalConfig();
1145
+ if (globalConfig.mcpServers[serverName]) {
1146
+ globalConfig.mcpServers[serverName].scope = scope;
1147
+ this.saveGlobalConfig(globalConfig);
1148
+ }
1149
+
1150
+ // Handle different scopes
1151
+ if (scope === MCP_SCOPES.LOCAL) {
1152
+ // Local scope: only enable for current project
1153
+ if (!projectConfig.enabledMcpServers) projectConfig.enabledMcpServers = [];
1154
+ if (!projectConfig.enabledMcpServers.includes(serverName)) {
1155
+ projectConfig.enabledMcpServers.push(serverName);
1156
+ }
1157
+ } else if (scope === MCP_SCOPES.PROJECT) {
1158
+ // Project scope: store in project config for sharing
1159
+ if (!projectConfig.projectMcpServers) projectConfig.projectMcpServers = {};
1160
+ projectConfig.projectMcpServers[serverName] = {
1161
+ ...server,
1162
+ scope: MCP_SCOPES.PROJECT
1163
+ };
1164
+
1165
+ // Also add to enabled list
1166
+ if (!projectConfig.enabledMcpServers) projectConfig.enabledMcpServers = [];
1167
+ if (!projectConfig.enabledMcpServers.includes(serverName)) {
1168
+ projectConfig.enabledMcpServers.push(serverName);
1169
+ }
1170
+ } else if (scope === MCP_SCOPES.USER) {
1171
+ // User scope: mark as globally enabled
1172
+ if (!projectConfig.enabledMcpServers) projectConfig.enabledMcpServers = [];
1173
+ if (!projectConfig.enabledMcpServers.includes(serverName)) {
1174
+ projectConfig.enabledMcpServers.push(serverName);
1175
+ }
1176
+ }
1177
+
1178
+ fs.writeFileSync(projectConfigFile, JSON.stringify(projectConfig, null, 2), 'utf8');
1179
+ return true;
1180
+ } catch (error) {
1181
+ throw new Error(`Failed to enable MCP server: ${error.message}`);
1182
+ }
1183
+ }
1184
+
1185
+ /**
1186
+ * Disable MCP server for current project
1187
+ */
1188
+ disableProjectMcpServer(serverName) {
1189
+ const projectRoot = this.findProjectRoot();
1190
+ if (!projectRoot) {
1191
+ throw new Error('Not in a configured project directory.');
1192
+ }
1193
+
1194
+ const projectConfigFile = path.join(projectRoot, this.projectConfigFilename);
1195
+ if (!fs.existsSync(projectConfigFile)) {
1196
+ throw new Error('Project not configured. Run "ais use" first.');
1197
+ }
1198
+
1199
+ try {
1200
+ const data = fs.readFileSync(projectConfigFile, 'utf8');
1201
+ const projectConfig = JSON.parse(data);
1202
+
1203
+ if (!projectConfig.enabledMcpServers) return false;
1204
+
1205
+ const index = projectConfig.enabledMcpServers.indexOf(serverName);
1206
+ if (index > -1) {
1207
+ projectConfig.enabledMcpServers.splice(index, 1);
1208
+ fs.writeFileSync(projectConfigFile, JSON.stringify(projectConfig, null, 2), 'utf8');
1209
+ return true;
1210
+ }
1211
+ return false;
1212
+ } catch (error) {
1213
+ throw new Error(`Failed to disable MCP server: ${error.message}`);
1214
+ }
1215
+ }
1216
+
1217
+ /**
1218
+ * Get enabled MCP servers for current project
1219
+ * Includes local, project, and user-scoped servers
1220
+ */
1221
+ getEnabledMcpServers() {
1222
+ const projectServers = this.getProjectMcpServers();
1223
+ const globalServers = this.getAllMcpServers();
1224
+
1225
+ // Add user-scoped servers that are globally enabled
1226
+ const userScopedServers = Object.keys(globalServers).filter(name =>
1227
+ globalServers[name].scope === MCP_SCOPES.USER && !projectServers.includes(name)
1228
+ );
1229
+
1230
+ return [...projectServers, ...userScopedServers];
1231
+ }
1232
+
1233
+ /**
1234
+ * Get all available MCP servers including project-scoped ones
1235
+ */
1236
+ getAllAvailableMcpServers() {
1237
+ const globalServers = this.getAllMcpServers();
1238
+ const projectRoot = this.findProjectRoot();
1239
+
1240
+ if (!projectRoot) {
1241
+ return globalServers;
1242
+ }
1243
+
1244
+ try {
1245
+ const projectConfigFile = path.join(projectRoot, this.projectConfigFilename);
1246
+ if (!fs.existsSync(projectConfigFile)) {
1247
+ return globalServers;
1248
+ }
1249
+
1250
+ const data = fs.readFileSync(projectConfigFile, 'utf8');
1251
+ const projectConfig = JSON.parse(data);
1252
+
1253
+ // Merge global and project servers
1254
+ const allServers = { ...globalServers };
1255
+
1256
+ if (projectConfig.projectMcpServers) {
1257
+ Object.entries(projectConfig.projectMcpServers).forEach(([name, server]) => {
1258
+ allServers[name] = server;
1259
+ });
1260
+ }
1261
+
1262
+ return allServers;
1263
+ } catch (error) {
1264
+ return globalServers;
1265
+ }
1266
+ }
1267
+
1268
+ /**
1269
+ * Get Claude Code user config path (cross-platform)
1270
+ * Priority: ~/.claude/settings.json > platform-specific paths > legacy paths
1271
+ */
1272
+ getClaudeUserConfigPath() {
1273
+ const platform = process.platform;
1274
+ const home = process.env.HOME || process.env.USERPROFILE;
1275
+
1276
+ if (!home) return null;
1277
+
1278
+ // Priority order for Claude user config
1279
+ const locations = [];
1280
+
1281
+ // Primary location: ~/.claude/settings.json (modern Claude Code)
1282
+ locations.push(path.join(home, '.claude', 'settings.json'));
1283
+
1284
+ // Platform-specific locations
1285
+ if (platform === 'win32') {
1286
+ // Windows: %APPDATA%\claude\settings.json
1287
+ const appData = process.env.APPDATA;
1288
+ if (appData) {
1289
+ locations.push(path.join(appData, 'claude', 'settings.json'));
1290
+ locations.push(path.join(appData, 'claude', 'config.json'));
1291
+ }
1292
+ } else {
1293
+ // macOS/Linux: ~/.config/claude/settings.json
1294
+ locations.push(path.join(home, '.config', 'claude', 'settings.json'));
1295
+ locations.push(path.join(home, '.config', 'claude', 'config.json'));
1296
+ }
1297
+
1298
+ // Legacy fallback: ~/.claude.json
1299
+ locations.push(path.join(home, '.claude.json'));
1300
+
1301
+ // Return first existing location
1302
+ for (const loc of locations) {
1303
+ if (fs.existsSync(loc)) {
1304
+ return loc;
1305
+ }
1306
+ }
1307
+
1308
+ return null;
1309
+ }
1310
+
1311
+ /**
1312
+ * Import MCP servers from Claude user config (~/.claude.json)
1313
+ */
1314
+ importMcpServersFromClaudeConfig(projectRoot) {
1315
+ const claudeConfigPath = this.getClaudeUserConfigPath();
1316
+ if (!claudeConfigPath) {
1317
+ return { imported: [], fromUserConfig: [], fromProjectConfig: [] };
1318
+ }
1319
+
1320
+ try {
1321
+ const data = fs.readFileSync(claudeConfigPath, 'utf8');
1322
+ const claudeConfig = JSON.parse(data);
1323
+
1324
+ const imported = [];
1325
+ const fromUserConfig = [];
1326
+ const fromProjectConfig = [];
1327
+ const allServers = this.getAllMcpServers();
1328
+
1329
+ // Import from user-level MCP servers
1330
+ if (claudeConfig.mcpServers && typeof claudeConfig.mcpServers === 'object') {
1331
+ Object.entries(claudeConfig.mcpServers).forEach(([name, serverConfig]) => {
1332
+ if (!allServers[name]) {
1333
+ const aisServerData = {
1334
+ name: name,
1335
+ ...serverConfig,
1336
+ description: serverConfig.description || 'Imported from Claude user config'
1337
+ };
1338
+
1339
+ // Ensure type is set
1340
+ if (!aisServerData.type) {
1341
+ if (aisServerData.command) {
1342
+ aisServerData.type = 'stdio';
1343
+ } else if (aisServerData.url) {
1344
+ aisServerData.type = 'http';
1345
+ }
1346
+ }
1347
+
1348
+ this.addMcpServer(name, aisServerData);
1349
+ imported.push(name);
1350
+ fromUserConfig.push(name);
1351
+ }
1352
+ });
1353
+ }
1354
+
1355
+ // Import from project-specific MCP servers in Claude config
1356
+ if (claudeConfig.projects && projectRoot) {
1357
+ const projectConfig = claudeConfig.projects[projectRoot];
1358
+ if (projectConfig && projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') {
1359
+ Object.entries(projectConfig.mcpServers).forEach(([name, serverConfig]) => {
1360
+ if (!allServers[name]) {
1361
+ const aisServerData = {
1362
+ name: name,
1363
+ ...serverConfig,
1364
+ description: serverConfig.description || 'Imported from Claude project config'
1365
+ };
1366
+
1367
+ // Ensure type is set
1368
+ if (!aisServerData.type) {
1369
+ if (aisServerData.command) {
1370
+ aisServerData.type = 'stdio';
1371
+ } else if (aisServerData.url) {
1372
+ aisServerData.type = 'http';
1373
+ }
1374
+ }
1375
+
1376
+ this.addMcpServer(name, aisServerData);
1377
+ imported.push(name);
1378
+ fromProjectConfig.push(name);
1379
+ }
1380
+ });
1381
+ }
1382
+ }
1383
+
1384
+ return { imported, fromUserConfig, fromProjectConfig };
1385
+ } catch (error) {
1386
+ console.warn(`Warning: Could not import from Claude config: ${error.message}`);
1387
+ return { imported: [], fromUserConfig: [], fromProjectConfig: [] };
1388
+ }
1389
+ }
1390
+
1391
+ /**
1392
+ * Import MCP servers from .mcp.json to AIS global config
1393
+ * Returns array of imported server names
1394
+ */
1395
+ importMcpServersFromFile(projectRoot) {
1396
+ const mcpJsonFile = path.join(projectRoot, '.mcp.json');
1397
+ if (!fs.existsSync(mcpJsonFile)) {
1398
+ return { imported: [], enabled: [] };
1399
+ }
1400
+
1401
+ try {
1402
+ const data = fs.readFileSync(mcpJsonFile, 'utf8');
1403
+ const mcpJson = JSON.parse(data);
1404
+
1405
+ if (!mcpJson.mcpServers || typeof mcpJson.mcpServers !== 'object') {
1406
+ return { imported: [], enabled: [] };
1407
+ }
1408
+
1409
+ const imported = [];
1410
+ const enabled = [];
1411
+ const allServers = this.getAllMcpServers();
1412
+
1413
+ Object.entries(mcpJson.mcpServers).forEach(([name, serverConfig]) => {
1414
+ // Check if server already exists in AIS config
1415
+ if (!allServers[name]) {
1416
+ // Import server to AIS config
1417
+ const aisServerData = {
1418
+ name: name,
1419
+ ...serverConfig,
1420
+ description: serverConfig.description || 'Imported from .mcp.json'
1421
+ };
1422
+
1423
+ // Ensure type is set
1424
+ if (!aisServerData.type) {
1425
+ if (aisServerData.command) {
1426
+ aisServerData.type = 'stdio';
1427
+ } else if (aisServerData.url) {
1428
+ aisServerData.type = 'http';
1429
+ }
1430
+ }
1431
+
1432
+ this.addMcpServer(name, aisServerData);
1433
+ imported.push(name);
1434
+ }
1435
+ enabled.push(name);
1436
+ });
1437
+
1438
+ // Update project's enabled servers list
1439
+ if (enabled.length > 0) {
1440
+ const projectConfigFile = path.join(projectRoot, this.projectConfigFilename);
1441
+ const projectData = fs.readFileSync(projectConfigFile, 'utf8');
1442
+ const projectConfig = JSON.parse(projectData);
1443
+
1444
+ // Merge with existing enabled servers (deduplicate)
1445
+ const currentEnabled = projectConfig.enabledMcpServers || [];
1446
+ const mergedEnabled = [...new Set([...currentEnabled, ...enabled])];
1447
+
1448
+ if (JSON.stringify(currentEnabled.sort()) !== JSON.stringify(mergedEnabled.sort())) {
1449
+ projectConfig.enabledMcpServers = mergedEnabled;
1450
+ fs.writeFileSync(projectConfigFile, JSON.stringify(projectConfig, null, 2), 'utf8');
1451
+ }
1452
+ }
1453
+
1454
+ return { imported, enabled };
1455
+ } catch (error) {
1456
+ console.warn(`Warning: Could not import from .mcp.json: ${error.message}`);
1457
+ return { imported: [], enabled: [] };
1458
+ }
1459
+ }
1460
+
1461
+ /**
1462
+ * Sync MCP configuration (bidirectional)
1463
+ * - Import servers from Claude user config (~/.claude.json) to AIS config
1464
+ * - Import servers from .mcp.json to AIS config
1465
+ * - Export enabled servers from AIS config to .mcp.json
1466
+ */
1467
+ syncMcpConfig() {
1468
+ const projectRoot = this.findProjectRoot();
1469
+ if (!projectRoot) {
1470
+ throw new Error('Not in a project directory');
1471
+ }
1472
+
1473
+ const projectConfigFile = path.join(projectRoot, this.projectConfigFilename);
1474
+ if (!fs.existsSync(projectConfigFile)) {
1475
+ throw new Error('Project not configured. Run "ais use" first');
1476
+ }
1477
+
1478
+ try {
1479
+ // Step 1: Import servers from Claude user config
1480
+ const claudeImport = this.importMcpServersFromClaudeConfig(projectRoot);
1481
+
1482
+ // Step 2: Import servers from .mcp.json to AIS config
1483
+ const fileImport = this.importMcpServersFromFile(projectRoot);
1484
+
1485
+ // Step 3: Get account and generate .mcp.json
1486
+ const projectData = fs.readFileSync(projectConfigFile, 'utf8');
1487
+ const projectConfig = JSON.parse(projectData);
1488
+ const account = this.getAccount(projectConfig.activeAccount);
1489
+
1490
+ if (!account) {
1491
+ throw new Error('Account not found');
1492
+ }
1493
+
1494
+ this.generateClaudeConfigWithMcp(account, projectRoot);
1495
+
1496
+ // Combine results
1497
+ const allImported = [
1498
+ ...claudeImport.imported,
1499
+ ...fileImport.imported
1500
+ ];
1501
+
1502
+ return {
1503
+ imported: allImported,
1504
+ fromClaudeUserConfig: claudeImport.fromUserConfig,
1505
+ fromClaudeProjectConfig: claudeImport.fromProjectConfig,
1506
+ fromMcpJson: fileImport.imported,
1507
+ enabled: fileImport.enabled
1508
+ };
1509
+ } catch (error) {
1510
+ throw new Error(`Failed to sync MCP configuration: ${error.message}`);
1511
+ }
1512
+ }
1513
+
1514
+ /**
1515
+ * Generate Claude Code configuration with MCP servers
1516
+ */
1517
+ generateClaudeConfigWithMcp(account, projectRoot = process.cwd()) {
1518
+ try {
1519
+ // First generate base Claude configuration
1520
+ this.generateClaudeConfig(account, projectRoot);
1521
+
1522
+ // Then generate .mcp.json for MCP servers configuration
1523
+ const mcpConfigFile = path.join(projectRoot, '.mcp.json');
1524
+
1525
+ // Get enabled MCP servers
1526
+ const enabledServers = this.getEnabledMcpServers();
1527
+ const allServers = this.getAllAvailableMcpServers();
1528
+
1529
+ // Filter servers by scope:
1530
+ // - Only 'project' scoped servers should be in .mcp.json (shared with team)
1531
+ // - 'local' and 'user' scoped servers should NOT be in .mcp.json
1532
+ const projectScopedServers = enabledServers.filter(serverName => {
1533
+ const server = allServers[serverName];
1534
+ return server && server.scope === MCP_SCOPES.PROJECT;
1535
+ });
1536
+
1537
+ if (projectScopedServers.length > 0) {
1538
+ const mcpConfig = {
1539
+ mcpServers: {}
1540
+ };
1541
+
1542
+ projectScopedServers.forEach(serverName => {
1543
+ const server = allServers[serverName];
1544
+ if (server) {
1545
+ const serverConfig = {};
1546
+
1547
+ // For stdio type MCP servers
1548
+ if (server.type === 'stdio' && server.command) {
1549
+ serverConfig.command = server.command;
1550
+ if (server.args) serverConfig.args = server.args;
1551
+ if (server.env) serverConfig.env = server.env;
1552
+ }
1553
+ // For http/sse type MCP servers
1554
+ else if ((server.type === 'http' || server.type === 'sse') && server.url) {
1555
+ serverConfig.type = server.type;
1556
+ serverConfig.url = server.url;
1557
+ if (server.headers) serverConfig.headers = server.headers;
1558
+ }
1559
+ // Legacy support: infer type from fields
1560
+ else if (server.command) {
1561
+ serverConfig.command = server.command;
1562
+ if (server.args) serverConfig.args = server.args;
1563
+ if (server.env) serverConfig.env = server.env;
1564
+ } else if (server.url) {
1565
+ // Default to http if type not specified
1566
+ serverConfig.type = server.type || 'http';
1567
+ serverConfig.url = server.url;
1568
+ if (server.headers) serverConfig.headers = server.headers;
1569
+ }
1570
+
1571
+ mcpConfig.mcpServers[serverName] = serverConfig;
1572
+ }
1573
+ });
1574
+
1575
+ fs.writeFileSync(mcpConfigFile, JSON.stringify(mcpConfig, null, 2), 'utf8');
1576
+ } else {
1577
+ // Remove .mcp.json if no project-scoped servers are enabled
1578
+ if (fs.existsSync(mcpConfigFile)) {
1579
+ fs.unlinkSync(mcpConfigFile);
1580
+ }
1581
+ }
1582
+ } catch (error) {
1583
+ throw new Error(`Failed to generate Claude config with MCP: ${error.message}`);
1584
+ }
1585
+ }
1586
+ }
1587
+
1588
+ module.exports = ConfigManager;
1589
+ module.exports.WIRE_API_MODES = WIRE_API_MODES;
1590
+ module.exports.DEFAULT_WIRE_API = DEFAULT_WIRE_API;
1591
+ module.exports.ACCOUNT_TYPES = ACCOUNT_TYPES;
1592
+ module.exports.MCP_SCOPES = MCP_SCOPES;
1593
+ module.exports.DEFAULT_MCP_SCOPE = DEFAULT_MCP_SCOPE;