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.
- package/.playwright-mcp/grid-view-before.png +0 -0
- package/.playwright-mcp/list-view.png +0 -0
- package/CLAUDE.md +338 -0
- package/README.md +3 -1
- package/package.json +45 -45
- package/src/accounts/base-account.js +39 -0
- package/src/accounts/ccr-account.js +118 -0
- package/src/accounts/claude-account.js +62 -0
- package/src/accounts/codex-account.js +192 -0
- package/src/accounts/droids-account.js +80 -0
- package/src/accounts/index.js +29 -0
- package/src/commands/account.js +68 -0
- package/src/commands/env.js +728 -0
- package/src/commands/helpers.js +32 -0
- package/src/commands/index.js +22 -1
- package/src/commands/mcp.js +71 -13
- package/src/config/global-config.js +266 -0
- package/src/config/project-config.js +255 -0
- package/src/config.js +129 -1300
- package/src/config.js.bak +1593 -0
- package/src/constants.js +86 -0
- package/src/generators/base-generator.js +124 -0
- package/src/generators/ccr-generator.js +113 -0
- package/src/generators/claude-generator.js +124 -0
- package/src/generators/codex-generator.js +207 -0
- package/src/generators/droids-generator.js +49 -0
- package/src/generators/index.js +29 -0
- package/src/index.js +63 -1
- package/src/mcp/mcp-manager.js +309 -0
- package/src/ui-server.js +1093 -9
|
@@ -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;
|