@kikkimo/claude-launcher 1.0.0 → 2.1.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/claude-launcher CHANGED
@@ -1,679 +1,1174 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const { spawn } = require('child_process');
3
+ /**
4
+ * Claude Launcher - Refactored modular version
5
+ * A launcher for Claude Code with third-party API support
6
+ */
7
+
8
+ /**
9
+ * Force complete stdin reset to prevent navigation issues
10
+ * Note: This function should only reset state, not remove listeners
11
+ * that might be used by other modules
12
+ */
13
+ function forceStdinCleanup() {
14
+ try {
15
+ if (process.stdin.isTTY) {
16
+ // Only reset mode, don't remove listeners that might be in use
17
+ process.stdin.setRawMode(false);
18
+ // NOTE: Removed removeAllListeners to prevent conflicts
19
+ // Each module should manage its own listeners
20
+ // Only pause if not already paused (isPaused is a method, not a property)
21
+ if (!process.stdin.isPaused()) {
22
+ process.stdin.pause();
23
+ }
24
+ }
25
+ } catch (error) {
26
+ // Ignore cleanup errors but log for debugging
27
+ if (process.env.DEBUG_STDIN) {
28
+ console.error('[DEBUG] forceStdinCleanup error:', error.message);
29
+ }
30
+ }
31
+ }
32
+
33
+
34
+ const ApiManager = require('./lib/api-manager');
35
+ const Menu = require('./lib/ui/menu');
36
+ const colors = require('./lib/ui/colors');
37
+ const { checkForUpdates, forceCheckForUpdates } = require('./lib/utils/version-checker');
38
+ const {
39
+ waitForKey,
40
+ promptForThirdPartyApi,
41
+ confirmAction,
42
+ showSuccess,
43
+ showError,
44
+ showInfo
45
+ } = require('./lib/ui/prompts');
46
+ const {
47
+ launchClaudeDefault,
48
+ launchClaudeSkipPermissions,
49
+ launchClaudeWithApi
50
+ } = require('./lib/launcher');
51
+ const { getPasswordInput } = require('./lib/auth/password-input');
52
+ const { verifyExportPassword, setupNewPassword, changePassword: changePasswordModule } = require('./lib/auth/password-validator');
53
+ const { maskApiToken } = require('./lib/validators');
54
+ const { showApiSelectionTable, confirmDeletion } = require('./lib/ui/interactive-table');
55
+ const i18n = require('./lib/i18n');
4
56
  const fs = require('fs');
5
57
  const path = require('path');
6
- const readline = require('readline');
7
- const crypto = require('crypto');
8
-
9
- // Get config file location - check multiple locations for global installation
10
- function getConfigPath() {
11
- const locations = [
12
- // Current working directory (highest priority)
13
- path.join(process.cwd(), '.claude-launcher.env'),
14
- // User home directory
15
- path.join(require('os').homedir(), '.claude-launcher.env'),
16
- // Script directory (for local installation)
17
- path.join(__dirname, '.claude-launcher.env')
18
- ];
19
-
20
- // Return the first existing config file, or the home directory path as default
21
- for (const location of locations) {
22
- if (fs.existsSync(location)) {
23
- return location;
24
- }
58
+ const os = require('os');
59
+ const { exec } = require('child_process');
60
+
61
+ // Initialize components
62
+ const apiManager = new ApiManager();
63
+
64
+ // Global menu objects to prevent screen flickering during navigation
65
+ let globalMainMenu = null;
66
+ let globalConfirmMenu = null;
67
+ let globalApiManagementMenu = null;
68
+
69
+ /**
70
+ * Initialize global menu objects to prevent recreation and screen flickering
71
+ */
72
+ function initializeGlobalMenus() {
73
+ if (!globalMainMenu) {
74
+ globalMainMenu = new Menu();
75
+ }
76
+ if (!globalConfirmMenu) {
77
+ globalConfirmMenu = new Menu();
78
+ }
79
+ if (!globalApiManagementMenu) {
80
+ globalApiManagementMenu = new Menu();
25
81
  }
26
-
27
- // Default to home directory if no config exists
28
- return path.join(require('os').homedir(), '.claude-launcher.env');
29
82
  }
30
83
 
31
- const CONFIG_FILE = getConfigPath();
32
-
33
- // ANSI color codes for Claude-style theming
34
- const colors = {
35
- reset: '\x1b[0m',
36
- bright: '\x1b[1m',
37
- orange: '\x1b[38;5;208m', // Claude brand orange
38
- amber: '\x1b[38;5;214m', // Amber/yellow-orange
39
- white: '\x1b[37m',
40
- gray: '\x1b[90m',
41
- green: '\x1b[32m',
42
- red: '\x1b[31m',
43
- yellow: '\x1b[33m',
44
- black: '\x1b[30m',
45
- bgOrange: '\x1b[48;5;208m', // Background orange
46
- bgAmber: '\x1b[48;5;214m' // Background amber
47
- };
48
-
49
- // Generate encryption key from machine-specific data
50
- function getEncryptionKey() {
51
- const os = require('os');
52
- // Use a combination of hostname and user info to create a machine-specific key
53
- const machineId = os.hostname() + os.userInfo().username + os.platform();
54
- // Derive a 32-byte key using PBKDF2
55
- return crypto.pbkdf2Sync(machineId, 'claude-launcher-salt', 10000, 32, 'sha256');
84
+ /**
85
+ * Get export directory path and create if not exists
86
+ * @returns {string} - Export directory path
87
+ */
88
+ function getExportDirectory() {
89
+ const userHome = os.homedir();
90
+ const exportDir = path.join(userHome, 'claude-launcher');
91
+
92
+ // Create directory if it doesn't exist
93
+ if (!fs.existsSync(exportDir)) {
94
+ fs.mkdirSync(exportDir, { recursive: true });
95
+ }
96
+
97
+ return exportDir;
98
+ }
99
+
100
+ /**
101
+ * Generate timestamp-based filename for export
102
+ * @returns {string} - Filename with timestamp
103
+ */
104
+ function generateExportFilename() {
105
+ const now = new Date();
106
+ const timestamp = now.getFullYear().toString() +
107
+ (now.getMonth() + 1).toString().padStart(2, '0') +
108
+ now.getDate().toString().padStart(2, '0') +
109
+ now.getHours().toString().padStart(2, '0') +
110
+ now.getMinutes().toString().padStart(2, '0') +
111
+ now.getSeconds().toString().padStart(2, '0');
112
+
113
+ return `claude-launcher-export-${timestamp}.json`;
114
+ }
115
+
116
+ /**
117
+ * Open file with system default application
118
+ * @param {string} filePath - Path to the file to open
119
+ */
120
+ function openFileWithDefault(filePath) {
121
+ const platform = process.platform;
122
+ let command;
123
+
124
+ if (platform === 'win32') {
125
+ command = `start "" "${filePath}"`;
126
+ } else if (platform === 'darwin') {
127
+ command = `open "${filePath}"`;
128
+ } else {
129
+ command = `xdg-open "${filePath}"`;
130
+ }
131
+
132
+ exec(command, (error) => {
133
+ if (error) {
134
+ console.log(colors.yellow + `Could not open file automatically: ${error.message}` + colors.reset);
135
+ }
136
+ });
56
137
  }
57
138
 
58
- // Encrypt API key using AES-256-CBC
59
- function encryptApiKey(plaintext) {
139
+ /**
140
+ * Validate import file path and JSON format
141
+ * @param {string} filePath - Path to the JSON file
142
+ * @returns {Object} - Validation result with success status and data/error
143
+ */
144
+ function validateImportFile(filePath) {
145
+ const result = {
146
+ valid: false,
147
+ data: null,
148
+ error: null
149
+ };
150
+
60
151
  try {
61
- const key = getEncryptionKey();
62
- const iv = crypto.randomBytes(16); // Generate random IV
63
- const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
64
-
65
- let encrypted = cipher.update(plaintext, 'utf8', 'hex');
66
- encrypted += cipher.final('hex');
67
-
68
- // Combine IV and encrypted data
69
- const result = iv.toString('hex') + ':' + encrypted;
70
-
71
- return {
72
- success: true,
73
- value: result,
74
- error: null
75
- };
152
+ // Check if file path is provided
153
+ if (!filePath || filePath.trim() === '') {
154
+ result.error = 'File path cannot be empty';
155
+ return result;
156
+ }
157
+
158
+ // Normalize and resolve path
159
+ const normalizedPath = path.resolve(filePath.trim());
160
+
161
+ // Check if file exists
162
+ if (!fs.existsSync(normalizedPath)) {
163
+ result.error = i18n.tSync('errors.file.file_not_found', normalizedPath);
164
+ return result;
165
+ }
166
+
167
+ // Check if it's a file (not directory)
168
+ const stats = fs.statSync(normalizedPath);
169
+ if (!stats.isFile()) {
170
+ result.error = `Path is not a file: ${normalizedPath}`;
171
+ return result;
172
+ }
173
+
174
+ // Check file extension
175
+ if (path.extname(normalizedPath).toLowerCase() !== '.json') {
176
+ result.error = `File must have .json extension: ${normalizedPath}`;
177
+ return result;
178
+ }
179
+
180
+ // Read and parse JSON file
181
+ const fileContent = fs.readFileSync(normalizedPath, 'utf8');
182
+
183
+ // Validate JSON format
184
+ let jsonData;
185
+ try {
186
+ jsonData = JSON.parse(fileContent);
187
+ } catch (parseError) {
188
+ result.error = `Invalid JSON format: ${parseError.message}`;
189
+ return result;
190
+ }
191
+
192
+ // Basic structure validation for claude-launcher config
193
+ if (!jsonData || typeof jsonData !== 'object') {
194
+ result.error = 'JSON file must contain a valid configuration object';
195
+ return result;
196
+ }
197
+
198
+ // Check for required fields (basic validation)
199
+ if (!jsonData.hasOwnProperty('apis') || !Array.isArray(jsonData.apis)) {
200
+ result.error = i18n.tSync('errors.file.invalid_format', 'JSON file must contain an "apis" array');
201
+ return result;
202
+ }
203
+
204
+ result.valid = true;
205
+ result.data = fileContent; // Return raw JSON string for compatibility
206
+ return result;
207
+
76
208
  } catch (error) {
77
- return {
78
- success: false,
79
- value: null,
80
- error: error.message
81
- };
209
+ result.error = `File validation error: ${error.message}`;
210
+ return result;
82
211
  }
83
212
  }
84
213
 
85
- // Decrypt API key using AES-256-CBC
86
- function decryptApiKey(encryptedData) {
214
+ // Main menu options - will be populated dynamically with i18n
215
+ let menuOptions = [];
216
+
217
+ /**
218
+ * Add new third-party API
219
+ */
220
+ async function addNewThirdPartyApi() {
87
221
  try {
88
- const key = getEncryptionKey();
89
-
90
- // Split IV and encrypted data
91
- const parts = encryptedData.split(':');
92
- if (parts.length !== 2) {
93
- throw new Error('Invalid encrypted data format');
222
+ const apiData = await promptForThirdPartyApi();
223
+
224
+ // Check if this is the first API
225
+ const isFirstApi = apiManager.getApis().length === 0;
226
+ const hasExportPassword = apiManager.hasExportPassword();
227
+
228
+ const newApi = apiManager.addApi(
229
+ apiData.baseUrl,
230
+ apiData.authToken,
231
+ apiData.model,
232
+ apiData.name,
233
+ apiData.provider
234
+ );
235
+
236
+ showSuccess(await i18n.t('messages.success.api_added'), [
237
+ `Name: ${newApi.name}`,
238
+ `${await i18n.t('api.details.provider')}: ${newApi.provider}`,
239
+ `${await i18n.t('api.details.url')}: ${newApi.baseUrl}`,
240
+ `${await i18n.t('api.details.model')}: ${newApi.model}`
241
+ ]);
242
+
243
+ if (isFirstApi) {
244
+ showInfo(await i18n.t('messages.info.first_time_usage'));
94
245
  }
95
-
96
- const iv = Buffer.from(parts[0], 'hex');
97
- const encrypted = parts[1];
98
-
99
- const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
100
-
101
- let decrypted = decipher.update(encrypted, 'hex', 'utf8');
102
- decrypted += decipher.final('utf8');
103
-
104
- return {
105
- success: true,
106
- value: decrypted,
107
- error: null
108
- };
246
+
109
247
  } catch (error) {
110
- return {
111
- success: false,
112
- value: null,
113
- error: error.message
114
- };
248
+ // Force cleanup stdin state to prevent navigation issues
249
+ forceStdinCleanup();
250
+
251
+ // Check if user cancelled the operation
252
+ const cancelledMessage = await i18n.t('errors.general.cancelled_by_user');
253
+ if (error.message === cancelledMessage) {
254
+ // User cancelled - show neutral message instead of error
255
+ console.log(colors.yellow + await i18n.t('messages.info.operation_cancelled') + colors.reset);
256
+ } else {
257
+ // Actual error occurred
258
+ showError(await i18n.t('errors.api.failed_add', error.message));
259
+ }
115
260
  }
116
- }
117
261
 
118
- // Validate API key format
119
- function validateApiKey(apiKey) {
120
- if (!apiKey || apiKey.trim() === '') {
121
- return {
122
- valid: false,
123
- error: 'API key is empty or missing'
124
- };
125
- }
126
-
127
- if (!apiKey.startsWith('sk-')) {
128
- return {
129
- valid: false,
130
- error: 'API key must start with "sk-"'
131
- };
132
- }
133
-
134
- if (apiKey.length < 20) {
135
- return {
136
- valid: false,
137
- error: 'API key appears to be too short'
138
- };
139
- }
140
-
141
- return {
142
- valid: true,
143
- error: null,
144
- value: apiKey
145
- };
262
+ await waitForKey(await i18n.t('messages.prompts.press_any_key'));
146
263
  }
147
264
 
148
- // Load configuration from .env file
149
- function loadConfig() {
150
- const config = {};
151
-
265
+ /**
266
+ * Remove third-party API
267
+ */
268
+ async function removeThirdPartyApi() {
152
269
  try {
153
- if (fs.existsSync(CONFIG_FILE)) {
154
- const envContent = fs.readFileSync(CONFIG_FILE, 'utf8');
155
- const lines = envContent.split('\n');
156
-
157
- lines.forEach(line => {
158
- const match = line.match(/^([^=]+)=(.*)$/);
159
- if (match) {
160
- const key = match[1];
161
- let value = match[2];
162
-
163
- // Store raw value for KIMI_API_KEY (validation will be done later)
164
- // Don't decrypt here to allow proper error handling
165
-
166
- config[key] = value;
167
- }
168
- });
169
- } else {
170
- console.log(colors.yellow + 'Warning: .claude-launcher.env file not found!' + colors.reset);
171
- console.log(colors.gray + `Searched locations:` + colors.reset);
172
- console.log(colors.gray + ` - ${path.join(process.cwd(), '.claude-launcher.env')} (current directory)` + colors.reset);
173
- console.log(colors.gray + ` - ${path.join(require('os').homedir(), '.claude-launcher.env')} (home directory)` + colors.reset);
174
- console.log(colors.gray + `Creating default config at: ${CONFIG_FILE}` + colors.reset);
175
-
176
- // Try to read template file, fallback to hardcoded defaults
177
- let defaultConfig = `KIMI_API_KEY=Your_Double_Base64_Encoded_Api_Key
178
- KIMI_BASE_URL=https://api.moonshot.cn/anthropic/`;
179
-
180
- const templatePath = path.join(__dirname, 'claude-launcher-template.env');
270
+ // Get API list
271
+ const apis = apiManager.getApis();
272
+ const activeApi = apiManager.getActiveApi();
273
+ const activeIndex = activeApi ? apis.findIndex(api => api.id === activeApi.id) : -1;
274
+
275
+ // Show API selection table (handles no APIs case internally)
276
+ const selectedApi = await showApiSelectionTable(
277
+ apis,
278
+ i18n.tSync('ui.general.select_api_remove'),
279
+ 'remove',
280
+ activeIndex
281
+ );
282
+
283
+ if (!selectedApi) {
284
+ return showMenu();
285
+ }
286
+
287
+ // Show confirmation dialog
288
+ const confirmed = await confirmDeletion(selectedApi);
289
+
290
+ if (confirmed) {
181
291
  try {
182
- if (fs.existsSync(templatePath)) {
183
- defaultConfig = fs.readFileSync(templatePath, 'utf8');
184
- console.log(colors.gray + `Using template from: ${templatePath}` + colors.reset);
292
+ // Find the index of the selected API in the current list
293
+ const selectedIndex = apis.findIndex(api => api.id === selectedApi.id);
294
+ apiManager.removeApi(selectedIndex);
295
+
296
+ console.clear();
297
+ showSuccess(await i18n.t('messages.success.api_removed'), [
298
+ `${await i18n.t('api.actions.removed_info', selectedApi.name)}`,
299
+ `${await i18n.t('api.details.provider')}: ${selectedApi.provider}`
300
+ ]);
301
+
302
+ // Show success message and return to main menu
303
+ const remainingApis = apiManager.getApis();
304
+ if (remainingApis.length === 0) {
305
+ showInfo(await i18n.t('messages.info.all_apis_removed'));
185
306
  }
186
- } catch (templateError) {
187
- console.log(colors.yellow + 'Template file not found, using defaults' + colors.reset);
188
- }
189
-
190
- try {
191
- fs.writeFileSync(CONFIG_FILE, defaultConfig);
192
- console.log(colors.green + 'Default configuration file created successfully!' + colors.reset);
193
- console.log(colors.gray + 'Please edit the file to add your actual API credentials.' + colors.reset);
194
- } catch (error) {
195
- console.log(colors.red + 'Failed to create config file: ' + error.message + colors.reset);
307
+ await waitForKey(await i18n.t('messages.prompts.press_any_key'));
308
+ return showMenu();
309
+
310
+ } catch (removeError) {
311
+ showError(await i18n.t('errors.api.failed_remove', removeError.message));
312
+ await waitForKey(await i18n.t('messages.prompts.press_any_key'));
313
+ return showMenu();
196
314
  }
197
-
198
- config.KIMI_API_KEY = 'Your_Double_Base64_Encoded_Api_Key';
199
- config.KIMI_BASE_URL = 'https://api.moonshot.cn/anthropic/';
315
+ } else {
316
+ showInfo(await i18n.t('messages.info.removal_cancelled'));
317
+ await waitForKey(await i18n.t('messages.prompts.press_any_key'));
318
+ return showMenu();
200
319
  }
320
+
201
321
  } catch (error) {
202
- console.log(colors.red + 'Error loading configuration: ' + error.message + colors.reset);
203
- config.KIMI_API_KEY = 'Your_Double_Base64_Encoded_Api_Key';
204
- config.KIMI_BASE_URL = 'https://api.moonshot.cn/anthropic/';
322
+ forceStdinCleanup();
323
+ showError('Failed to remove API', [error.message]);
324
+ await waitForKey(i18n.tSync('messages.prompts.press_any_key'));
205
325
  }
206
-
207
- return config;
208
326
  }
209
327
 
210
- // Launch Claude Code function with clean environment handoff
211
- function launchClaude(command, envVars = {}, disableAuthTokens = false) {
212
- console.log('');
213
- console.log(colors.yellow + 'Starting Claude Code...' + colors.reset);
214
- console.log(colors.gray + `Command: ${command}` + colors.reset);
215
-
216
- if (Object.keys(envVars).length > 0) {
217
- console.log(colors.gray + 'Environment variables:' + colors.reset);
218
- for (const [key, value] of Object.entries(envVars)) {
219
- if (key === 'ANTHROPIC_API_KEY') {
220
- console.log(colors.gray + ` ${key}=***` + colors.reset);
221
- } else {
222
- console.log(colors.gray + ` ${key}=${value}` + colors.reset);
223
- }
224
- }
225
- }
226
-
227
- console.log('');
228
- console.log(colors.green + '[+] Claude will run in current terminal.' + colors.reset);
229
- console.log(colors.gray + ' Launcher will exit to transfer control to Claude.' + colors.reset);
230
- console.log('');
231
-
232
- // Prepare clean environment
233
- const env = { ...process.env, ...envVars };
234
-
235
- // Disable conflicting auth tokens when using Kimi API
236
- if (disableAuthTokens) {
237
- delete env.ANTHROPIC_AUTH_TOKEN;
238
- delete env.CLAUDE_CODE_OAUTH_TOKEN;
239
- console.log(colors.gray + ' Disabled: ANTHROPIC_AUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN' + colors.reset);
240
- }
241
-
242
- // Parse command and arguments
243
- const args = command.split(' ');
244
- const cmd = args.shift();
245
-
328
+ /**
329
+ * Switch active third-party API
330
+ */
331
+ async function switchThirdPartyApi() {
246
332
  try {
247
- // Clean up terminal state before launching Claude
248
- if (process.stdin.isTTY) {
249
- process.stdin.setRawMode(false);
250
- process.stdin.pause();
251
- }
252
-
253
- // Remove all event listeners to avoid conflicts
254
- process.stdin.removeAllListeners('data');
255
- process.stdin.removeAllListeners('keypress');
256
- process.removeAllListeners('SIGINT');
257
- process.removeAllListeners('SIGTERM');
258
-
259
- // Launch Claude in current terminal, let it inherit everything
260
- const child = spawn(cmd, args, {
261
- stdio: 'inherit', // Claude takes over current terminal I/O
262
- env: env,
263
- cwd: process.cwd(),
264
- shell: true // Use shell to find the command
265
- });
266
-
267
- // Don't exit immediately, wait for Claude to exit then exit launcher
268
- child.on('close', (code) => {
269
- process.exit(code || 0);
270
- });
271
-
272
- child.on('error', (error) => {
273
- console.log(colors.red + 'Error running Claude: ' + error.message + colors.reset);
274
- process.exit(1);
275
- });
276
-
333
+ const apis = apiManager.getApis();
334
+ const activeApi = apiManager.getActiveApi();
335
+ const activeIndex = activeApi ? apis.findIndex(api => api.id === activeApi.id) : -1;
336
+
337
+ // 现在表格函数内部处理整个切换流程
338
+ const selectedApi = await showApiSelectionTable(
339
+ apis,
340
+ i18n.tSync('api.actions.select_to_switch'),
341
+ 'switch',
342
+ activeIndex,
343
+ apiManager // 传递 apiManager 让表格函数处理切换逻辑
344
+ );
345
+
346
+ // 表格函数已经处理了所有显示和切换逻辑,直接返回主菜单
347
+ return showMenu();
348
+
277
349
  } catch (error) {
278
- console.log(colors.red + 'Error launching Claude Code: ' + error.message + colors.reset);
279
- console.log(colors.gray + 'Press any key to return to menu...' + colors.reset);
280
- process.stdin.setRawMode(true);
281
- process.stdin.resume();
282
- process.stdin.once('data', () => {
283
- process.stdin.setRawMode(false);
284
- showMenu();
285
- });
350
+ forceStdinCleanup();
351
+ showError(await i18n.t('errors.api.failed_switch', error.message));
352
+ await waitForKey(await i18n.t('messages.prompts.press_any_key'));
353
+ return showMenu();
286
354
  }
287
355
  }
288
356
 
289
- // Simple input using readline (supports paste naturally)
290
- function simpleInput(prompt) {
291
- return new Promise((resolve) => {
292
- const rl = readline.createInterface({
293
- input: process.stdin,
294
- output: process.stdout
295
- });
296
-
297
- rl.question(prompt, (answer) => {
298
- rl.close();
299
- resolve(answer.trim());
357
+ /**
358
+ * View API statistics
359
+ */
360
+ async function viewStatistics() {
361
+ console.clear();
362
+ console.log('');
363
+ console.log(colors.bright + colors.orange + '📊 ' + await i18n.t('statistics.title') + colors.reset);
364
+ console.log('');
365
+
366
+ const stats = apiManager.getStatistics();
367
+ const apis = apiManager.getApis();
368
+
369
+ console.log(colors.cyan + ' ' + i18n.tSync('ui.general.summary') + colors.reset);
370
+ console.log(colors.gray + ` ${await i18n.t('statistics.total_apis', stats.totalApis)}` + colors.reset);
371
+ console.log(colors.gray + ` ${await i18n.t('statistics.active_api', stats.activeApiName)}` + colors.reset);
372
+ console.log(colors.gray + ` ${await i18n.t('statistics.most_used', stats.mostUsedApi)}` + colors.reset);
373
+ console.log(colors.gray + ` ${await i18n.t('statistics.total_usage', stats.totalUsage)}` + colors.reset);
374
+ console.log('');
375
+
376
+ if (apis.length > 0) {
377
+ console.log(colors.cyan + ' ' + i18n.tSync('ui.general.configured_apis') + colors.reset);
378
+
379
+ // Pre-fetch translations to avoid await in forEach
380
+ const currentlyActiveText = await i18n.t('api.details.currently_active');
381
+ const providerText = await i18n.t('api.details.provider');
382
+ const usageText = await i18n.t('api.details.usage');
383
+ const timesSuffixText = await i18n.t('api.details.times_suffix');
384
+ const createdAtText = await i18n.t('api.details.created_at');
385
+
386
+ apis.forEach((api, index) => {
387
+ const isActive = apiManager.getActiveApi()?.id === api.id;
388
+ const activeText = isActive ? ` (${currentlyActiveText})` : '';
389
+ console.log(colors.gray + ` ${index + 1}. ${api.name}${activeText}` + colors.reset);
390
+ console.log(colors.dim + ` ${providerText}: ${api.provider}` + colors.reset);
391
+ console.log(colors.dim + ` ${usageText}: ${api.usageCount || 0} ${timesSuffixText}` + colors.reset);
392
+ console.log(colors.dim + ` ${createdAtText}: ${api.createdAt}` + colors.reset);
300
393
  });
301
- });
394
+ }
395
+
396
+ console.log('');
397
+ await waitForKey(await i18n.t('messages.prompts.press_any_key'));
302
398
  }
303
399
 
304
- // Prompt user to input API key with simple readline
305
- async function promptForApiKey() {
306
- try {
400
+
401
+ /**
402
+ * Handle first time password setup
403
+ */
404
+ async function handleFirstTimePasswordSetup() {
405
+ while (true) {
406
+ // Clear screen and show header
307
407
  console.clear();
308
408
  console.log('');
309
- console.log(colors.bright + colors.orange + '[*] Kimi API Key Setup' + colors.reset);
409
+ console.log(colors.bright + colors.yellow + '🔐 ' + i18n.tSync('password.setup.first_time_title') + colors.reset);
310
410
  console.log('');
311
- console.log(colors.yellow + '[!] This message appears because you have not set up a Kimi API key,' + colors.reset);
312
- console.log(colors.yellow + ' or the API key decrypted from your local config file is invalid.' + colors.reset);
411
+
412
+ // Show information
413
+ console.log(colors.cyan + i18n.tSync('password.setup.why_needed') + colors.reset);
414
+ const whyNeededItems = i18n.tSync('password.setup.why_needed_items');
415
+ if (Array.isArray(whyNeededItems)) {
416
+ whyNeededItems.forEach(item => {
417
+ console.log(colors.gray + '• ' + item + colors.reset);
418
+ });
419
+ }
313
420
  console.log('');
314
- console.log(colors.yellow + '[?] Why do you need to enter your API key?' + colors.reset);
315
- console.log(colors.gray + ' • Kimi K2 API provides Claude-compatible interface' + colors.reset);
316
- console.log(colors.gray + ' • Your API key enables access to Kimi\'s AI services' + colors.reset);
317
- console.log(colors.bright + colors.green + ' • The key will be encrypted and stored locally' + colors.reset);
318
- console.log(colors.bright + colors.green + ' Only accessible on this machine' + colors.reset);
421
+ console.log(colors.cyan + '🔒 ' + i18n.tSync('password.setup.new_security_title') + colors.reset);
422
+ const securityItems = i18n.tSync('password.setup.security_items');
423
+ if (Array.isArray(securityItems)) {
424
+ securityItems.forEach(item => {
425
+ console.log(colors.gray + '• ' + item + colors.reset);
426
+ });
427
+ }
319
428
  console.log('');
320
- console.log(colors.yellow + '[!] Security:' + colors.reset);
321
- console.log(colors.gray + ' API key is encrypted using AES-256-CBC' + colors.reset);
322
- console.log(colors.gray + ' Encryption key derived from machine-specific data' + colors.reset);
323
- console.log(colors.gray + ' • Key cannot be decrypted on other machines' + colors.reset);
429
+ console.log(colors.yellow + i18n.tSync('password.setup.options_title') + colors.reset);
430
+ console.log(colors.gray + '• ' + i18n.tSync('password.setup.option_set') + colors.reset);
431
+ console.log(colors.gray + '• ' + i18n.tSync('password.setup.option_skip') + colors.reset);
324
432
  console.log('');
325
-
326
- // Show input box (fixed spacing)
327
- console.log(colors.orange + '┌─────────────────────────────────────────────────────────┐' + colors.reset);
328
- console.log(colors.orange + '│' + colors.reset + ' ' + colors.bright + 'Enter your Kimi API key' + ' ' + colors.reset + colors.orange + '│' + colors.reset);
329
- console.log(colors.orange + '├─────────────────────────────────────────────────────────┤' + colors.reset);
330
- console.log(colors.orange + '│' + colors.reset + ' ' + colors.gray + 'Format: sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' + ' ' + colors.reset + colors.orange + '│' + colors.reset);
331
- console.log(colors.orange + '│' + colors.reset + ' ' + colors.gray + 'You can copy and paste your API key here' + ' ' + colors.reset + colors.orange + '│' + colors.reset);
332
- console.log(colors.orange + '│' + colors.reset + ' ' + colors.bright + colors.yellow + 'After entering, press ENTER to continue' + ' ' + colors.reset + colors.orange + '│' + colors.reset);
333
- console.log(colors.orange + '│' + colors.reset + ' ' + colors.gray + 'Type "exit" or "quit" to return to menu' + ' ' + colors.reset + colors.orange + '│' + colors.reset);
334
- console.log(colors.orange + '└─────────────────────────────────────────────────────────┘' + colors.reset);
433
+ console.log(colors.red + i18n.tSync('password.setup.warning_skip') + colors.reset);
335
434
  console.log('');
336
-
337
- // Clean up any existing listeners once before the input loop
338
- process.stdin.removeAllListeners('data');
339
- process.stdin.removeAllListeners('keypress');
340
-
341
- // Input loop - allow retry on invalid input
342
- while (true) {
343
- const apiKey = await simpleInput(colors.green + '[>] API Key (press ENTER after input): ' + colors.reset);
344
-
345
- // Check for exit commands
346
- if (apiKey.toLowerCase() === 'exit' || apiKey.toLowerCase() === 'quit') {
347
- console.log('');
348
- console.log(colors.yellow + '[!] Setup cancelled by user.' + colors.reset);
349
- throw new Error('User cancelled setup');
350
- }
351
-
352
- // Validate the entered API key
353
- const validation = validateApiKey(apiKey);
354
-
355
- if (!validation.valid) {
356
- console.log(colors.red + '[X] Invalid API key: ' + validation.error + colors.reset);
357
- console.log(colors.yellow + '[!] Please try again with a valid API key.' + colors.reset);
358
- console.log('');
359
- continue; // Ask again
360
- }
361
-
362
- // Encrypt and save the API key
363
- const encrypted = encryptApiKey(apiKey);
364
-
365
- if (!encrypted.success) {
366
- console.log(colors.red + '[X] Failed to encrypt API key: ' + encrypted.error + colors.reset);
367
- console.log(colors.yellow + '[!] Please try again.' + colors.reset);
368
- console.log('');
369
- continue; // Ask again
435
+
436
+ console.log(colors.gray + '按任意键继续...' + colors.reset);
437
+
438
+ // Wait for user to read the information
439
+ await new Promise((resolve) => {
440
+ if (process.stdin.isTTY) {
441
+ process.stdin.setRawMode(true);
442
+ process.stdin.resume();
443
+ process.stdin.once('data', () => {
444
+ try {
445
+ process.stdin.setRawMode(false);
446
+ process.stdin.pause();
447
+ } catch (error) {
448
+ // Ignore cleanup errors
449
+ }
450
+ resolve();
451
+ });
452
+ } else {
453
+ resolve();
370
454
  }
371
-
372
- // Update configuration file
373
- updateConfigFile('KIMI_API_KEY', encrypted.value);
374
-
375
- console.log('');
376
- console.log(colors.green + '[+] API key encrypted and saved successfully!' + colors.reset);
377
- console.log(colors.gray + ' Configuration saved to: ' + CONFIG_FILE + colors.reset);
378
- console.log('');
379
-
380
- return apiKey;
455
+ });
456
+
457
+ // Now show the menu for selection
458
+ const menuOptions = [
459
+ i18n.tSync('password.setup.menu_set_password'),
460
+ i18n.tSync('password.setup.menu_skip_setup'),
461
+ i18n.tSync('menu.api_management.back')
462
+ ];
463
+
464
+ // Ensure global menus are initialized
465
+ initializeGlobalMenus();
466
+ globalConfirmMenu.setOptions(menuOptions);
467
+
468
+ const choice = await globalConfirmMenu.navigate();
469
+
470
+ switch (choice) {
471
+ case 0: // Set password
472
+ const passwordResult = await promptForPasswordSetup();
473
+ if (passwordResult) {
474
+ return true; // Password set successfully
475
+ }
476
+ // If password setup failed, continue loop to show menu again
477
+ break;
478
+
479
+ case 1: // Skip setup
480
+ const skipResult = await confirmSkipPassword();
481
+ if (skipResult === true) {
482
+ return true; // Skip confirmed
483
+ } else if (skipResult === 'reconsider') {
484
+ continue; // Return to main setup menu
485
+ }
486
+ // If skip was canceled, continue loop
487
+ break;
488
+
489
+ case 2: // Back to main menu
490
+ case -1: // ESC pressed
491
+ default:
492
+ return false; // Exit setup
381
493
  }
382
-
383
- } catch (error) {
384
- console.log(colors.red + '[X] Setup failed: ' + error.message + colors.reset);
385
- process.exit(1);
386
494
  }
387
495
  }
388
496
 
389
- // Update configuration file with new value
390
- function updateConfigFile(key, value) {
391
- try {
392
- let configContent = '';
393
- let keyExists = false;
394
-
395
- // Read existing config if it exists
396
- if (fs.existsSync(CONFIG_FILE)) {
397
- configContent = fs.readFileSync(CONFIG_FILE, 'utf8');
398
- const lines = configContent.split('\n');
399
-
400
- // Update existing key or add new one
401
- for (let i = 0; i < lines.length; i++) {
402
- const match = lines[i].match(/^([^=]+)=(.*)$/);
403
- if (match && match[1] === key) {
404
- lines[i] = `${key}=${value}`;
405
- keyExists = true;
406
- break;
407
- }
408
- }
409
-
410
- if (keyExists) {
411
- configContent = lines.join('\n');
412
- } else {
413
- // Add new key at the end
414
- configContent += `\n${key}=${value}`;
415
- }
416
- } else {
417
- // Create new config file
418
- configContent = `# Claude Launcher Configuration\n# Generated automatically\n\n${key}=${value}\nKIMI_BASE_URL=https://api.moonshot.cn/anthropic/\n`;
419
- }
420
-
421
- fs.writeFileSync(CONFIG_FILE, configContent);
422
- } catch (error) {
423
- console.log(colors.red + 'Error updating config file: ' + error.message + colors.reset);
424
- throw error;
497
+ /**
498
+ * Prompt user to set password
499
+ */
500
+ async function promptForPasswordSetup() {
501
+ const result = await setupNewPassword(apiManager, true);
502
+ if (result) {
503
+ await waitForKey(i18n.tSync('messages.prompts.press_any_key'));
425
504
  }
505
+ return result;
426
506
  }
427
507
 
428
- // Get Kimi configuration with validation and setup
429
- async function getKimiConfig() {
430
- try {
431
- const config = loadConfig();
432
- const rawApiKey = config.KIMI_API_KEY;
433
- const baseUrl = config.KIMI_BASE_URL || 'https://api.moonshot.cn/anthropic/';
434
-
435
- console.log('');
436
- console.log(colors.bright + colors.orange + '[*] Validating Kimi API Configuration...' + colors.reset);
437
- console.log('');
438
-
439
- // Check if API key is configured and valid
440
- if (!rawApiKey || rawApiKey === 'Your_Double_Base64_Encoded_Api_Key' || rawApiKey === 'your_kimi_api_key_here') {
441
- console.log(colors.yellow + '[!] No API key configured. Starting first-time setup...' + colors.reset);
442
- console.log('');
443
-
444
- const apiKey = await promptForApiKey();
445
- return {
446
- ANTHROPIC_BASE_URL: baseUrl,
447
- ANTHROPIC_API_KEY: apiKey
448
- };
449
- }
450
-
451
- // Try to decrypt existing API key
452
- const decrypted = decryptApiKey(rawApiKey);
453
-
454
- if (!decrypted.success) {
455
- console.log(colors.red + '[X] Failed to decrypt stored API key: ' + decrypted.error + colors.reset);
456
- console.log(colors.yellow + '[!] This might happen if the key was encrypted on a different machine.' + colors.reset);
457
- console.log(colors.yellow + ' Please re-enter your API key...' + colors.reset);
458
- console.log('');
459
-
460
- const apiKey = await promptForApiKey();
461
- return {
462
- ANTHROPIC_BASE_URL: baseUrl,
463
- ANTHROPIC_API_KEY: apiKey
464
- };
465
- }
466
-
467
- // Validate decrypted API key
468
- const validation = validateApiKey(decrypted.value);
469
-
470
- if (!validation.valid) {
471
- console.log(colors.red + '[X] Stored API key is invalid: ' + validation.error + colors.reset);
472
- console.log('');
473
-
474
- const apiKey = await promptForApiKey();
475
- return {
476
- ANTHROPIC_BASE_URL: baseUrl,
477
- ANTHROPIC_API_KEY: apiKey
478
- };
508
+ /**
509
+ * Confirm skip password setup
510
+ */
511
+ async function confirmSkipPassword() {
512
+ console.log('');
513
+ console.log(colors.bright + colors.red + '⚠️ ' + i18n.tSync('errors.password.confirm_skip_title') + colors.reset);
514
+ console.log('');
515
+ console.log(colors.gray + i18n.tSync('ui.general.after_skipping_password_setup') + colors.reset);
516
+ console.log(colors.gray + '• ' + i18n.tSync('ui.general.password_skip_consequences')[0] + colors.reset);
517
+ console.log(colors.gray + '' + i18n.tSync('ui.general.password_skip_consequences')[1] + colors.reset);
518
+ console.log(colors.gray + '• ' + i18n.tSync('ui.general.password_skip_consequences')[2] + colors.reset);
519
+ console.log('');
520
+
521
+ // Ensure global menus are initialized
522
+ initializeGlobalMenus();
523
+
524
+ globalConfirmMenu.setOptions([
525
+ i18n.tSync('ui.general.confirm_skip_option'),
526
+ i18n.tSync('ui.general.reconsider_option')
527
+ ]);
528
+
529
+ const choice = await globalConfirmMenu.navigate();
530
+
531
+ if (choice === 0) {
532
+ try {
533
+ apiManager.skipPasswordSetup();
534
+ console.log(colors.yellow + i18n.tSync('errors.password.setup_skipped') + colors.reset);
535
+ await waitForKey(i18n.tSync('messages.prompts.press_any_key'));
536
+ return true;
537
+ } catch (error) {
538
+ forceStdinCleanup();
539
+ console.log(colors.red + `Operation failed: ${error.message}` + colors.reset);
540
+ await waitForKey(i18n.tSync('messages.prompts.press_any_key'));
541
+ return false;
479
542
  }
480
-
481
- // Success case
482
- console.log(colors.green + '[+] API Key validation successful!' + colors.reset);
483
- console.log(colors.gray + ` Decrypted key starts with: ${validation.value.substring(0, 8)}...` + colors.reset);
484
- console.log(colors.gray + ` Base URL: ${baseUrl}` + colors.reset);
485
- console.log('');
486
-
487
- return {
488
- ANTHROPIC_BASE_URL: baseUrl,
489
- ANTHROPIC_API_KEY: validation.value
490
- };
491
-
492
- } catch (error) {
493
- console.log(colors.red + '[X] Configuration error: ' + error.message + colors.reset);
494
- throw error;
495
543
  }
544
+
545
+ return 'reconsider'; // Return to password setup main menu
496
546
  }
497
547
 
498
- // Display Claude Code style header
499
- function displayHeader() {
548
+ /**
549
+ * Show API Management Menu
550
+ */
551
+ async function showApiManagementMenu() {
552
+ // Force cleanup stdin state before showing API management menu
553
+ forceStdinCleanup();
554
+
500
555
  console.clear();
501
556
  console.log('');
502
-
503
- // Claude-style orange/amber border with Unicode box drawing characters
504
- const border = colors.orange;
505
- const title = colors.white + colors.bright;
506
-
507
- console.log(border + ' ┌────────────────────────────────────────┐' + colors.reset);
508
- console.log(border + ' │' + title + ' Claude Code Launcher ' + border + '│' + colors.reset);
509
- console.log(border + ' └────────────────────────────────────────┘' + colors.reset);
510
- console.log('');
511
- console.log(colors.gray + ' Use ↑↓ arrow keys to navigate, Enter to select' + colors.reset);
557
+ console.log(colors.bright + colors.orange + '📋 ' + await i18n.t('menu.api_management.title') + colors.reset);
512
558
  console.log('');
513
- }
514
559
 
515
- // Menu options
516
- const menuOptions = [
517
- 'Launch Claude Code',
518
- 'Launch Claude Code (Skip Permissions)',
519
- 'Launch Claude Code with Kimi K2 API',
520
- 'Launch Claude Code with Kimi K2 API (Skip Permissions)',
521
- 'Exit'
522
- ];
523
-
524
- let selectedIndex = 0;
525
-
526
- // Display menu with current selection
527
- function displayMenu() {
528
- displayHeader();
529
-
530
- menuOptions.forEach((option, index) => {
531
- if (index === selectedIndex) {
532
- // Selected item with Claude-style highlighting
533
- console.log(colors.orange + ' → ' + colors.black + colors.bgAmber + option + colors.reset);
534
- } else {
535
- // Normal item
536
- console.log(colors.gray + ' ' + option + colors.reset);
560
+ // Check if this is first time usage and prompt for password setup
561
+ if (apiManager.isFirstTimeUsage()) {
562
+ const passwordChoice = await handleFirstTimePasswordSetup();
563
+ if (!passwordChoice) {
564
+ // User chose to skip or canceled, return to main menu
565
+ return showMenu();
537
566
  }
538
- });
539
-
540
- console.log('');
541
- }
567
+ }
542
568
 
543
- // Handle key press
544
- function handleKeyPress(key) {
545
- switch (key) {
546
- case '\u001b[A': // Up arrow
547
- selectedIndex = (selectedIndex - 1 + menuOptions.length) % menuOptions.length;
548
- displayMenu();
549
- break;
550
-
551
- case '\u001b[B': // Down arrow
552
- selectedIndex = (selectedIndex + 1) % menuOptions.length;
553
- displayMenu();
554
- break;
555
-
556
- case '\r': // Enter
557
- executeSelection();
558
- break;
559
-
560
- case '\u001b': // Escape
561
- case 'q':
562
- case 'Q':
563
- console.log('');
564
- console.log(colors.green + 'Goodbye!' + colors.reset);
565
- process.exit(0);
566
- break;
569
+ // Build menu options based on password setup status
570
+ const menuOptions = [
571
+ await i18n.t('menu.api_management.add_new'),
572
+ await i18n.t('menu.api_management.remove'),
573
+ await i18n.t('menu.api_management.switch'),
574
+ await i18n.t('menu.api_management.statistics')
575
+ ];
576
+
577
+ // Add import/export options only if password is set
578
+ if (apiManager.canUseImportExport()) {
579
+ menuOptions.push(await i18n.t('menu.api_management.export'));
580
+ menuOptions.push(await i18n.t('menu.api_management.import'));
581
+ menuOptions.push(await i18n.t('menu.api_management.change_password'));
582
+ }
583
+
584
+ menuOptions.push(await i18n.t('menu.api_management.back'));
585
+
586
+ // Ensure global menus are initialized
587
+ initializeGlobalMenus();
588
+
589
+ globalApiManagementMenu.setOptions(menuOptions);
590
+
591
+ const choice = await globalApiManagementMenu.navigate();
592
+
593
+ // Handle menu choices based on current menu options
594
+ if (choice === 0) { // Add New API
595
+ await addNewThirdPartyApi();
596
+ return showMenu();
597
+ } else if (choice === 1) { // Remove API
598
+ await removeThirdPartyApi();
599
+ return showMenu();
600
+ } else if (choice === 2) { // Switch Active API
601
+ await switchThirdPartyApi();
602
+ return showMenu();
603
+ } else if (choice === 3) { // View API Statistics
604
+ await viewStatistics();
605
+ return showMenu();
606
+ } else if (apiManager.canUseImportExport()) {
607
+ // If import/export is available, handle those options
608
+ if (choice === 4) { // Export Configuration
609
+ await exportConfiguration();
610
+ return showMenu();
611
+ } else if (choice === 5) { // Import Configuration
612
+ await importConfiguration();
613
+ return showMenu();
614
+ } else if (choice === 6) { // Change Password
615
+ await changePassword();
616
+ return showMenu();
617
+ } else if (choice === 7) { // Back to Main Menu
618
+ return showMenu();
619
+ }
620
+ } else {
621
+ // If import/export is not available, only Back to Main Menu
622
+ if (choice === 4) { // Back to Main Menu
623
+ return showMenu();
624
+ }
567
625
  }
626
+
627
+ // Default fallback to main menu
628
+ return showMenu();
568
629
  }
569
630
 
570
- // Handle Kimi API launches with async configuration
571
- async function handleKimiLaunch(command) {
631
+ /**
632
+ * Handle third-party API launch
633
+ */
634
+ async function handleThirdPartyApiLaunch(skipPermissions = false) {
572
635
  try {
573
- // Clean up existing listeners before API key input
574
- if (process.stdin.isTTY) {
575
- process.stdin.setRawMode(false);
576
- process.stdin.removeAllListeners('data');
577
- process.stdin.pause();
578
- }
579
-
580
- const kimiConfig = await getKimiConfig();
581
- if (kimiConfig) {
582
- launchClaude(command, kimiConfig, true); // true to disable auth tokens
636
+ const activeApi = apiManager.getActiveApi();
637
+
638
+ if (!activeApi) {
639
+ console.clear();
640
+ showInfo(i18n.tSync('launch.no_active_api'), [
641
+ i18n.tSync('launch.no_active_api_desc'),
642
+ i18n.tSync('launch.add_configure_first')
643
+ ]);
644
+
645
+ await waitForKey(i18n.tSync('launch.press_key_return'));
646
+ return showMenu();
583
647
  }
648
+
649
+ // Increment usage count for the active API since we're actually using it
650
+ apiManager.incrementActiveApiUsage();
651
+
652
+ launchClaudeWithApi(activeApi, skipPermissions);
653
+
584
654
  } catch (error) {
585
- console.log('');
586
- console.log(colors.red + '[X] Failed to configure Kimi API: ' + error.message + colors.reset);
587
- console.log(colors.gray + 'Returning to menu...' + colors.reset);
588
- console.log('');
589
-
590
- // Simple delay before returning to menu
655
+ showError('Failed to launch with third-party API', [error.message]);
656
+
591
657
  setTimeout(() => {
592
658
  showMenu();
593
659
  }, 2000);
594
660
  }
595
661
  }
596
662
 
597
- // Execute selected menu item
598
- function executeSelection() {
663
+ /**
664
+ * Execute selected menu item
665
+ */
666
+ async function executeSelection(selectedIndex) {
599
667
  switch (selectedIndex) {
600
668
  case 0: // Launch Claude Code
601
- launchClaude('claude');
669
+ launchClaudeDefault();
602
670
  break;
603
-
671
+
604
672
  case 1: // Launch Claude Code (Skip Permissions)
605
- launchClaude('claude --dangerously-skip-permissions');
673
+ launchClaudeSkipPermissions();
606
674
  break;
607
-
608
- case 2: // Launch Claude Code with Kimi K2 API
609
- handleKimiLaunch('claude');
675
+
676
+ case 2: // Launch Claude Code with 3rd-party API
677
+ await handleThirdPartyApiLaunch(false);
610
678
  break;
611
-
612
- case 3: // Launch Claude Code with Kimi K2 API (Skip Permissions)
613
- handleKimiLaunch('claude --dangerously-skip-permissions');
679
+
680
+ case 3: // Launch Claude Code with 3rd-party API (Skip Permissions)
681
+ await handleThirdPartyApiLaunch(true);
614
682
  break;
615
-
616
- case 4: // Exit
683
+
684
+ case 4: // 3rd-party API Management
685
+ return await showApiManagementMenu();
686
+
687
+ case 5: // Language Settings
688
+ return await showLanguageSettings();
689
+
690
+ case 6: // Version Update Check
691
+ return await showVersionUpdateCheck();
692
+
693
+ case 7: // Exit
617
694
  console.log('');
618
- console.log(colors.green + 'Goodbye!' + colors.reset);
695
+ console.log(colors.green + '👋 ' + await i18n.t('menu.main.exit') + '!' + colors.reset);
619
696
  process.exit(0);
620
697
  break;
698
+
699
+ default:
700
+ showMenu();
701
+ break;
621
702
  }
622
703
  }
623
704
 
624
- // Initialize menu
625
- function showMenu() {
626
- displayMenu();
627
-
628
- // Check if we're in a TTY environment
629
- if (process.stdin.isTTY) {
630
- // Set up raw mode for capturing arrow keys
631
- process.stdin.setRawMode(true);
632
- process.stdin.resume();
633
- process.stdin.setEncoding('utf8');
634
-
635
- process.stdin.on('data', (key) => {
636
- handleKeyPress(key);
637
- });
705
+ /**
706
+ * Show main menu
707
+ */
708
+ async function showMenu() {
709
+ // Force cleanup stdin state before showing main menu
710
+ forceStdinCleanup();
711
+
712
+ // Ensure global menus are initialized
713
+ initializeGlobalMenus();
714
+
715
+ // ========================================
716
+ // Version check configuration (hardcoded, modify as needed)
717
+ // ========================================
718
+ const ALWAYS_SHOW_VERSION = false; // Version display toggle: true=always show, false=only show updates
719
+ const VERSION_CHECK_INTERVAL_HOURS = 12; // Version check interval (hours): modify this to adjust check frequency
720
+
721
+ // Check for updates and prepare version info string
722
+ let versionInfo = null;
723
+ try {
724
+ const updateInfo = await checkForUpdates(false, VERSION_CHECK_INTERVAL_HOURS);
725
+
726
+ // Use hardcoded switch instead of config file settings
727
+ const shouldShowVersion = ALWAYS_SHOW_VERSION || updateInfo.available;
728
+
729
+ if (shouldShowVersion) {
730
+ if (updateInfo.available) {
731
+ // Display update notification (Claude Code style)
732
+ versionInfo = colors.yellow + ' ⚠️ ' + i18n.tSync('version.update_available', updateInfo.latestVersion, updateInfo.currentVersion) + colors.reset + '\n' +
733
+ colors.yellow + ' ' + i18n.tSync('version.install_command') + colors.reset;
734
+ } else if (ALWAYS_SHOW_VERSION) {
735
+ // Display current version info (always show mode)
736
+ versionInfo = colors.cyan + ' ℹ️ ' + i18n.tSync('version.current_version_info', updateInfo.currentVersion, updateInfo.latestVersion) + colors.reset + '\n' +
737
+ colors.yellow + ' ' + i18n.tSync('version.install_command') + colors.reset;
738
+ }
739
+ }
740
+ } catch (error) {
741
+ // Silently ignore update check errors
742
+ }
743
+
744
+ // Populate menu options dynamically with i18n translations
745
+ menuOptions = [
746
+ await i18n.t('menu.main.launch_default'),
747
+ await i18n.t('menu.main.launch_skip'),
748
+ await i18n.t('menu.main.launch_api'),
749
+ await i18n.t('menu.main.launch_api_skip'),
750
+ await i18n.t('menu.main.api_management'),
751
+ await i18n.t('menu.main.language_settings'),
752
+ await i18n.t('menu.main.version_check'),
753
+ await i18n.t('menu.main.exit')
754
+ ];
755
+
756
+ globalMainMenu.setOptions(menuOptions);
757
+ const selection = await globalMainMenu.navigate(false, versionInfo); // Pass version info to display between banner and nav
758
+
759
+ if (selection === -1) {
760
+ console.log('');
761
+ console.log(colors.green + '👋 ' + await i18n.t('menu.main.exit') + '!' + colors.reset);
762
+ process.exit(0);
638
763
  } else {
639
- // Fallback for non-TTY environments - use readline
640
- const rl = readline.createInterface({
641
- input: process.stdin,
642
- output: process.stdout
643
- });
644
-
645
- console.log(colors.yellow + ' Arrow keys not available. Enter selection number (1-5): ' + colors.reset);
646
-
647
- rl.on('line', (input) => {
648
- const choice = parseInt(input.trim());
649
- if (choice >= 1 && choice <= menuOptions.length) {
650
- selectedIndex = choice - 1;
651
- rl.close();
652
- executeSelection();
653
- } else if (input.toLowerCase() === 'q' || input.toLowerCase() === 'exit') {
654
- rl.close();
764
+ await executeSelection(selection);
765
+ }
766
+ }
767
+
768
+
769
+
770
+
771
+
772
+ /**
773
+ * Export configuration with password encryption
774
+ */
775
+ async function exportConfiguration() {
776
+ console.clear();
777
+ console.log('');
778
+ console.log(colors.bright + colors.orange + '💾 ' + await i18n.t('import_export.export.title') + colors.reset);
779
+ console.log('');
780
+
781
+ // Add export function description
782
+ console.log(colors.cyan + '📄 ' + i18n.tSync('import_export.export.description_title') + colors.reset);
783
+ console.log(colors.gray + ' • ' + i18n.tSync('import_export.export.description_items')[0] + colors.reset);
784
+ console.log(colors.gray + ' • ' + i18n.tSync('import_export.export.description_items')[1] + colors.reset);
785
+ console.log(colors.gray + ' • ' + i18n.tSync('import_export.export.description_items')[2] + colors.reset);
786
+ console.log(colors.gray + ' • ' + i18n.tSync('import_export.export.description_items')[3] + colors.reset);
787
+ console.log('');
788
+
789
+ // Verify password before export
790
+ const verified = await verifyExportPassword(apiManager, 'export');
791
+ if (!verified) {
792
+ console.log('');
793
+ await waitForKey(i18n.tSync('messages.prompts.press_any_key_menu'));
794
+ return;
795
+ }
796
+
797
+ try {
798
+ // Get export data
799
+ const exportData = apiManager.exportConfigAuthenticated();
800
+
801
+ // Prepare file path
802
+ const exportDir = getExportDirectory();
803
+ const filename = generateExportFilename();
804
+ const filePath = path.join(exportDir, filename);
805
+
806
+ // Write JSON file
807
+ fs.writeFileSync(filePath, exportData, 'utf8');
808
+
809
+ console.log(colors.green + '✓ ' + i18n.tSync('import_export.export.success_title') + colors.reset);
810
+ console.log('');
811
+ console.log(colors.cyan + '📁 ' + i18n.tSync('import_export.export.details_title') + colors.reset);
812
+ console.log(colors.gray + ` • ` + i18n.tSync('import_export.export.details_file_saved', filePath) + colors.reset);
813
+ console.log(colors.gray + ` • ` + i18n.tSync('import_export.export.details_export_dir', exportDir) + colors.reset);
814
+ console.log(colors.gray + ` • ` + i18n.tSync('import_export.export.details_filename', filename) + colors.reset);
815
+ console.log('');
816
+
817
+ // Open file with default application
818
+ console.log(colors.yellow + '🔍 ' + i18n.tSync('import_export.export.opening_file') + colors.reset);
819
+ openFileWithDefault(filePath);
820
+
821
+ console.log('');
822
+ console.log(colors.cyan + '💡 ' + i18n.tSync('import_export.export.tips_title') + colors.reset);
823
+ console.log(colors.gray + ' • ' + i18n.tSync('import_export.export.tips_items')[0] + colors.reset);
824
+ console.log(colors.gray + ' • ' + i18n.tSync('import_export.export.tips_items')[1] + colors.reset);
825
+
826
+ } catch (error) {
827
+ forceStdinCleanup();
828
+ console.log(colors.red + `❌ Export failed: ${error.message}` + colors.reset);
829
+ }
830
+
831
+ console.log('');
832
+ await waitForKey(i18n.tSync('messages.prompts.press_any_key_menu'));
833
+ }
834
+
835
+ /**
836
+ * Import configuration from plaintext JSON
837
+ */
838
+ async function importConfiguration() {
839
+ console.clear();
840
+ console.log('');
841
+ console.log(colors.bright + colors.orange + '📥 ' + await i18n.t('import_export.import.title') + colors.reset);
842
+ console.log('');
843
+
844
+ // Add import function description
845
+ console.log(colors.cyan + '📄 ' + i18n.tSync('ui.general.import_function_description') + colors.reset);
846
+ console.log(colors.gray + ' • ' + i18n.tSync('import_export.export.description_items')[0] + colors.reset);
847
+ console.log(colors.gray + ' • ' + i18n.tSync('ui.general.import_description_items')[0] + colors.reset);
848
+ console.log(colors.gray + ' • ' + i18n.tSync('ui.general.import_description_items')[1] + colors.reset);
849
+ console.log(colors.gray + ' • ' + i18n.tSync('ui.general.import_description_items')[2] + colors.reset);
850
+ console.log('');
851
+
852
+ // Verify password identity
853
+ const passwordVerified = await verifyExportPassword(apiManager, 'import');
854
+ if (!passwordVerified) {
855
+ await waitForKey(i18n.tSync('messages.prompts.press_any_key_menu'));
856
+ return;
857
+ }
858
+
859
+ console.log('');
860
+ console.log(colors.cyan + '📁 ' + i18n.tSync('ui.general.file_input_required') + colors.reset);
861
+ console.log(colors.gray + ' • ' + i18n.tSync('ui.general.file_input_items')[0] + colors.reset);
862
+ console.log(colors.gray + ' • ' + i18n.tSync('ui.general.file_input_items')[1] + colors.reset);
863
+ console.log(colors.gray + ' • ' + i18n.tSync('ui.general.file_input_items')[2] + colors.reset);
864
+ console.log('');
865
+
866
+ const { simpleInput } = require('./lib/ui/prompts');
867
+
868
+ let attempts = 0;
869
+ const maxAttempts = 3;
870
+
871
+ while (attempts < maxAttempts) {
872
+ attempts++;
873
+
874
+ // Get file path from user
875
+ const filePrompt = i18n.tSync('ui.general.enter_json_file_path_attempt', attempts, maxAttempts);
876
+ const filePath = await simpleInput(colors.green + filePrompt + colors.reset);
877
+
878
+ if (!filePath) {
879
+ console.log(colors.red + i18n.tSync('ui.general.file_path_empty') + colors.reset);
880
+ if (attempts < maxAttempts) {
655
881
  console.log('');
656
- console.log(colors.green + 'Goodbye!' + colors.reset);
657
- process.exit(0);
882
+ continue;
658
883
  } else {
659
- console.log(colors.red + ' Invalid selection. Please enter 1-5.' + colors.reset);
884
+ console.log(colors.red + i18n.tSync('ui.general.max_attempts_import_cancelled') + colors.reset);
885
+ await waitForKey(i18n.tSync('messages.prompts.press_any_key_menu'));
886
+ return;
660
887
  }
661
- });
888
+ }
889
+
890
+ // Validate file
891
+ console.log('');
892
+ console.log(colors.yellow + i18n.tSync('ui.general.validating_file') + colors.reset);
893
+ const validation = validateImportFile(filePath);
894
+
895
+ if (!validation.valid) {
896
+ console.log(colors.red + '❌ ' + i18n.tSync('ui.general.file_validation_failed', validation.error) + colors.reset);
897
+ if (attempts < maxAttempts) {
898
+ console.log(colors.yellow + i18n.tSync('ui.general.check_file_path_json') + colors.reset);
899
+ console.log('');
900
+ continue;
901
+ } else {
902
+ console.log(colors.red + i18n.tSync('ui.general.max_attempts_import_cancelled') + colors.reset);
903
+ await waitForKey(i18n.tSync('messages.prompts.press_any_key_menu'));
904
+ return;
905
+ }
906
+ }
907
+
908
+ // File is valid, proceed with import
909
+ console.log(colors.green + i18n.tSync('ui.general.file_validation_successful') + colors.reset);
910
+ console.log('');
911
+
912
+ try {
913
+ // Import the validated configuration data
914
+ const result = apiManager.importConfigAuthenticated(validation.data);
915
+
916
+ console.log(colors.green + i18n.tSync('ui.general.import_successful') + colors.reset);
917
+ console.log('');
918
+ console.log(colors.cyan + i18n.tSync('ui.general.import_statistics') + colors.reset);
919
+ const importItems = i18n.tSync('ui.general.import_stats_items');
920
+ console.log(colors.gray + ` • ` + importItems[0].replace('{0}', result.imported) + colors.reset);
921
+ console.log(colors.gray + ` • ` + importItems[1].replace('{1}', result.skipped) + colors.reset);
922
+ console.log(colors.gray + ` • ` + importItems[2] + colors.reset);
923
+ console.log(colors.gray + ` • ` + importItems[3].replace('{0}', path.resolve(filePath)) + colors.reset);
924
+
925
+ break; // Success, exit the loop
926
+
927
+ } catch (error) {
928
+ forceStdinCleanup();
929
+ console.log(colors.red + `❌ Import failed: ${error.message}` + colors.reset);
930
+ if (attempts < maxAttempts) {
931
+ console.log(colors.yellow + i18n.tSync('ui.general.import_tips')[0] + colors.reset);
932
+ console.log('');
933
+ continue;
934
+ } else {
935
+ console.log(colors.red + i18n.tSync('ui.general.max_attempts_import_failed') + colors.reset);
936
+ }
937
+ }
662
938
  }
939
+
940
+ console.log('');
941
+ await waitForKey(i18n.tSync('messages.prompts.press_any_key_menu'));
663
942
  }
664
943
 
665
- // Handle process termination
666
- process.on('SIGINT', () => {
944
+ /**
945
+ * Change password
946
+ */
947
+ async function changePassword() {
948
+ console.clear();
667
949
  console.log('');
668
- console.log(colors.green + 'Goodbye!' + colors.reset);
669
- process.exit(0);
670
- });
950
+ console.log(colors.bright + colors.orange + '🔑 ' + i18n.tSync('errors.password.change_password_title') + colors.reset);
951
+ console.log('');
952
+
953
+ // Use unified password change module
954
+ const success = await changePasswordModule(apiManager);
955
+
956
+ if (success) {
957
+ console.log('');
958
+ }
959
+
960
+ await waitForKey(i18n.tSync('messages.prompts.press_any_key_menu'));
961
+ }
962
+
963
+ /**
964
+ * Show language settings menu
965
+ */
966
+ async function showLanguageSettings() {
967
+ try {
968
+ const supportedLanguages = i18n.getSupportedLanguages();
969
+ const currentLanguage = i18n.getCurrentLanguage();
970
+ const currentLanguageName = i18n.getCurrentLanguageName();
971
+
972
+ console.clear();
973
+ console.log('');
974
+ console.log(colors.bright + colors.orange + '🌍 ' + await i18n.t('menu.language.title') + colors.reset);
975
+ console.log('');
976
+
977
+ // Show current language
978
+ console.log(colors.cyan + await i18n.t('menu.language.current', currentLanguageName) + colors.reset);
979
+ console.log('');
980
+ console.log(colors.yellow + await i18n.t('menu.language.select_prompt') + colors.reset);
981
+ console.log('');
982
+
983
+ // Create menu options
984
+ const languageOptions = [];
985
+ const languageCodes = Object.keys(supportedLanguages);
986
+
987
+ languageCodes.forEach(langCode => {
988
+ const isActive = langCode === currentLanguage;
989
+ const displayName = supportedLanguages[langCode];
990
+ languageOptions.push(isActive ? `● ${displayName}` : ` ${displayName}`);
991
+ });
992
+
993
+ languageOptions.push(await i18n.t('menu.language.back'));
994
+
995
+ // Ensure global menus are initialized
996
+ initializeGlobalMenus();
997
+
998
+ globalApiManagementMenu.setOptions(languageOptions);
999
+ const choice = await globalApiManagementMenu.navigate();
1000
+
1001
+ if (choice === -1 || choice === languageOptions.length - 1) {
1002
+ // Back to main menu
1003
+ return showMenu();
1004
+ }
1005
+
1006
+ // Check if user selected current language
1007
+ const selectedLangCode = languageCodes[choice];
1008
+ if (selectedLangCode === currentLanguage) {
1009
+ // Already current language, return to main menu
1010
+ return showMenu();
1011
+ }
1012
+
1013
+ // Switch language
1014
+ console.clear();
1015
+ console.log('');
1016
+ console.log(colors.yellow + await i18n.t('status.switching_language') + colors.reset);
1017
+
1018
+ try {
1019
+ await i18n.setLanguage(selectedLangCode);
1020
+
1021
+ console.clear();
1022
+ const newLanguageName = i18n.getCurrentLanguageName();
1023
+ showSuccess(await i18n.t('messages.success.language_changed'), [
1024
+ await i18n.t('menu.language.changed_success', newLanguageName)
1025
+ ]);
1026
+
1027
+ await waitForKey(await i18n.t('messages.prompts.press_any_key'));
1028
+
1029
+ // Return to main menu after successful language change
1030
+ return showMenu();
1031
+
1032
+ } catch (error) {
1033
+ showError(await i18n.t('errors.general.operation_failed', error.message));
1034
+ await waitForKey(await i18n.t('messages.prompts.press_any_key'));
1035
+ return showMenu();
1036
+ }
1037
+
1038
+ } catch (error) {
1039
+ showError('Failed to show language settings', [error.message]);
1040
+ await waitForKey(i18n.tSync('messages.prompts.press_any_key_menu'));
1041
+ return showMenu();
1042
+ }
1043
+ }
1044
+
1045
+ /**
1046
+ * Show version update check screen
1047
+ */
1048
+ async function showVersionUpdateCheck() {
1049
+ try {
1050
+ console.clear();
1051
+ console.log('');
1052
+ console.log(colors.bright + colors.orange + '🔄 ' + await i18n.t('version_check.title') + colors.reset);
1053
+ console.log('');
671
1054
 
1055
+ console.log(colors.cyan + await i18n.t('version_check.checking') + colors.reset);
1056
+ console.log(colors.gray + await i18n.t('version_check.please_wait') + colors.reset);
1057
+ console.log('');
1058
+
1059
+ // Show progress indicator
1060
+ const progressInterval = setInterval(() => {
1061
+ process.stdout.write('.');
1062
+ }, 500);
1063
+
1064
+ try {
1065
+ // Force check with 15 second timeout
1066
+ const result = await forceCheckForUpdates(15000);
1067
+
1068
+ // Stop progress indicator
1069
+ clearInterval(progressInterval);
1070
+ console.log('\n');
1071
+
1072
+ if (result.error) {
1073
+ // Handle errors (timeout, network, etc.)
1074
+ console.log(colors.red + '❌ ' + await i18n.t('version_check.error') + colors.reset);
1075
+ console.log(colors.red + ' ' + result.error + colors.reset);
1076
+ console.log('');
1077
+ console.log(colors.gray + await i18n.t('version_check.error_tips') + colors.reset);
1078
+ } else if (result.available) {
1079
+ // Update available
1080
+ console.log(colors.yellow + '🎉 ' + await i18n.t('version_check.update_available') + colors.reset);
1081
+ console.log('');
1082
+ console.log(colors.cyan + ' ' + await i18n.t('version_check.current_version', result.currentVersion) + colors.reset);
1083
+ console.log(colors.green + ' ' + await i18n.t('version_check.latest_version', result.latestVersion) + colors.reset);
1084
+ console.log('');
1085
+ console.log(colors.yellow + '💡 ' + await i18n.t('version_check.update_command') + colors.reset);
1086
+ console.log(colors.yellow + ' npm update -g @kikkimo/claude-launcher' + colors.reset);
1087
+ } else {
1088
+ // Already up to date
1089
+ console.log(colors.green + '✅ ' + await i18n.t('version_check.up_to_date') + colors.reset);
1090
+ console.log('');
1091
+ console.log(colors.cyan + ' ' + await i18n.t('version_check.current_version', result.currentVersion) + colors.reset);
1092
+ console.log(colors.cyan + ' ' + await i18n.t('version_check.latest_version', result.latestVersion) + colors.reset);
1093
+ }
1094
+
1095
+ } catch (error) {
1096
+ // Stop progress indicator
1097
+ clearInterval(progressInterval);
1098
+ console.log('\n');
1099
+
1100
+ console.log(colors.red + '❌ ' + await i18n.t('version_check.unexpected_error') + colors.reset);
1101
+ console.log(colors.red + ' ' + error.message + colors.reset);
1102
+ }
1103
+
1104
+ console.log('');
1105
+ await waitForKey(await i18n.t('messages.prompts.press_any_key'));
1106
+ return showMenu();
1107
+
1108
+ } catch (error) {
1109
+ console.log(colors.red + '❌ Failed to check version: ' + error.message + colors.reset);
1110
+ await waitForKey(i18n.tSync('messages.prompts.press_any_key_menu'));
1111
+ return showMenu();
1112
+ }
1113
+ }
1114
+
1115
+ // End of API Management functions
1116
+
1117
+ /**
1118
+ * Graceful shutdown handlers
1119
+ */
672
1120
  process.on('SIGTERM', () => {
673
1121
  console.log('');
674
- console.log(colors.green + 'Goodbye!' + colors.reset);
1122
+ console.log(colors.green + i18n.tSync('ui.general.goodbye') + colors.reset);
675
1123
  process.exit(0);
676
1124
  });
677
1125
 
1126
+ // Global SIGINT handler (Ctrl+C) - fallback for non-raw mode Ctrl+C
1127
+ // In raw mode, Ctrl+C is handled by StdinManager directly
1128
+ // This handler catches Ctrl+C in line mode (e.g., during readline input)
1129
+ const stdinManager = require('./lib/utils/stdin-manager');
1130
+
1131
+ // Flag to prevent re-entrance of SIGINT handler
1132
+ let exiting = false;
1133
+
1134
+ process.on('SIGINT', () => {
1135
+ // During Claude run, ignore in launcher so child handles it
1136
+ // Check this BEFORE setting exiting flag to avoid breaking reentrancy protection
1137
+ if (stdinManager.isSuspended && stdinManager.isSuspended()) {
1138
+ return;
1139
+ }
1140
+
1141
+ // Prevent re-entrance - ensure cleanup runs only once
1142
+ if (exiting) {
1143
+ return;
1144
+ }
1145
+ exiting = true;
1146
+
1147
+ // Try to reset stdin state before handling
1148
+ try {
1149
+ if (process.stdin.isTTY) {
1150
+ process.stdin.setRawMode(false);
1151
+ process.stdin.pause();
1152
+ }
1153
+ } catch (_) {
1154
+ // Ignore errors during emergency cleanup
1155
+ }
1156
+
1157
+ // Use unified Ctrl+C handler from StdinManager (synchronous)
1158
+ try {
1159
+ stdinManager.handleCtrlC();
1160
+ } catch (_) {
1161
+ // Ignore errors during Ctrl+C handling
1162
+ }
1163
+
1164
+ // Exit with standard SIGINT exit code (128 + 2 = 130)
1165
+ // Note: If handleCtrlC() calls process.exit(0) for second Ctrl+C,
1166
+ // this line won't be reached, which is expected behavior
1167
+ process.exit(130);
1168
+ });
1169
+
1170
+ // Initialize global menus and start the application
1171
+ initializeGlobalMenus();
1172
+
678
1173
  // Start the application
679
- showMenu();
1174
+ showMenu();