@kikkimo/claude-launcher 3.0.0 → 3.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.
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Shared i18n label resolver for config field display names.
3
+ */
4
+ const i18n = require('../i18n');
5
+
6
+ /**
7
+ * Resolve a config label via i18n first, fall back to constant map.
8
+ */
9
+ function i18nLabel(section, key, fallbackMap) {
10
+ const i18nKey = 'config_labels.' + section + '.' + key;
11
+ const val = i18n.tSync(i18nKey);
12
+ if (val !== i18nKey) return val;
13
+ return fallbackMap[key] || key;
14
+ }
15
+
16
+ module.exports = { i18nLabel };
package/lib/ui/menu.js CHANGED
@@ -61,8 +61,11 @@ class Menu {
61
61
  * Display menu with current selection
62
62
  * @param {string} versionInfo - Optional version info to display between banner and navigation
63
63
  * @param {Function|null} hintCallback - Optional sync callback(selectedIndex) returning hint string or null
64
+ * @param {string} navigationKey - i18n key for navigation hint (default: 'navigation.use_arrows')
64
65
  */
65
- displayMenu(versionInfo = null, hintCallback = null) {
66
+ displayMenu(versionInfo = null, hintCallback = null, navigationKey = 'navigation.use_arrows') {
67
+ this._navigationKey = navigationKey;
68
+ const isTTY = process.stdin.isTTY;
66
69
  const lines = [];
67
70
  lines.push('');
68
71
  lines.push(colors.orange + ' ┌──────────────────────────────────────────────────────┐' + colors.reset);
@@ -75,16 +78,18 @@ class Menu {
75
78
  lines.push('');
76
79
  }
77
80
 
78
- lines.push(colors.gray + ' ' + i18n.tSync('navigation.use_arrows') + colors.reset);
81
+ lines.push(colors.gray + ' ' + i18n.tSync(navigationKey) + colors.reset);
79
82
  lines.push('');
80
83
 
81
84
  this.menuOptions.forEach((option, index) => {
85
+ const prefix = isTTY ? '' : (index + 1) + '. ';
82
86
  if (index === this.selectedIndex) {
83
- const displayWidth = getStringWidth(option);
84
- const paddedOption = padStringToWidth(option, Math.max(40, displayWidth + 2));
87
+ const prefixedOption = prefix + option;
88
+ const displayWidth = getStringWidth(prefixedOption);
89
+ const paddedOption = padStringToWidth(prefixedOption, Math.max(40, displayWidth + 2));
85
90
  lines.push(colors.orange + ' → ' + colors.black + colors.bgAmber + paddedOption + colors.reset);
86
91
  } else {
87
- lines.push(colors.gray + ' ' + option + colors.reset);
92
+ lines.push(colors.gray + ' ' + prefix + option + colors.reset);
88
93
  }
89
94
  });
90
95
 
@@ -127,8 +132,9 @@ class Menu {
127
132
  * Handle keyboard navigation
128
133
  * @param {string} versionInfo - Optional version info to display
129
134
  * @param {Function|null} hintCallback - Optional sync callback(selectedIndex) returning hint string or null
135
+ * @param {string} navigationKey - i18n key for navigation hint (default: 'navigation.use_arrows')
130
136
  */
131
- async navigate(versionInfo = null, hintCallback = null) {
137
+ async navigate(versionInfo = null, hintCallback = null, navigationKey = 'navigation.use_arrows') {
132
138
  // Guard against empty menu to prevent NaN from modulo operations
133
139
  if (!this.menuOptions || this.menuOptions.length === 0) {
134
140
  screen.write(colors.yellow + ' Warning: No menu options available' + colors.reset + '\n');
@@ -139,7 +145,7 @@ class Menu {
139
145
  this.hintCallback = hintCallback; // Store for redrawing
140
146
 
141
147
  return new Promise((resolve, reject) => {
142
- this.displayMenu(versionInfo, hintCallback);
148
+ this.displayMenu(versionInfo, hintCallback, navigationKey);
143
149
 
144
150
  if (process.stdin.isTTY) {
145
151
  const scope = stdinManager.acquire('raw', {
@@ -192,12 +198,12 @@ class Menu {
192
198
  switch (key) {
193
199
  case '\u001b[A': // Up arrow
194
200
  this.selectedIndex = (this.selectedIndex - 1 + this.menuOptions.length) % this.menuOptions.length;
195
- this.displayMenu(this.versionInfo, this.hintCallback);
201
+ this.displayMenu(this.versionInfo, this.hintCallback, this._navigationKey);
196
202
  break;
197
203
 
198
204
  case '\u001b[B': // Down arrow
199
205
  this.selectedIndex = (this.selectedIndex + 1) % this.menuOptions.length;
200
- this.displayMenu(this.versionInfo, this.hintCallback);
206
+ this.displayMenu(this.versionInfo, this.hintCallback, this._navigationKey);
201
207
  break;
202
208
 
203
209
  case '\r': // Enter
@@ -242,7 +248,7 @@ class Menu {
242
248
  lineScope.release();
243
249
  resolve(-1);
244
250
  } else {
245
- screen.write(colors.red + ' Invalid selection. Please enter 1-' + this.menuOptions.length + '.' + colors.reset + '\n');
251
+ screen.write(colors.red + ' ' + i18n.tSync('navigation.invalid_selection', this.menuOptions.length) + colors.reset + '\n');
246
252
  }
247
253
  });
248
254
  }
@@ -265,7 +271,7 @@ class Menu {
265
271
  lines.push('');
266
272
  lines.push(colors.bright + colors.orange + `[*] ${title}` + colors.reset);
267
273
  lines.push('');
268
- lines.push(colors.gray + ' Use ↑↓ arrow keys to navigate, Enter to select, Esc to cancel' + colors.reset);
274
+ lines.push(colors.gray + ' ' + i18n.tSync('navigation.use_arrows_esc', i18n.tSync('navigation.action.select')) + colors.reset);
269
275
  lines.push('');
270
276
 
271
277
  items.forEach((item, index) => {
@@ -381,7 +387,7 @@ class Menu {
381
387
 
382
388
  const rl = lineScope.createReadline();
383
389
 
384
- screen.write(colors.yellow + ' Arrow keys not available. Enter item number (1-' + items.length + ') or q to cancel:' + colors.reset + '\n');
390
+ screen.write(colors.yellow + ' ' + i18n.tSync('navigation.arrow_keys_not_available', items.length) + colors.reset + '\n');
385
391
 
386
392
  rl.on('line', (input) => {
387
393
  const choice = parseInt(input.trim());
@@ -394,7 +400,7 @@ class Menu {
394
400
  lineScope.release();
395
401
  resolve(null);
396
402
  } else {
397
- screen.write(colors.red + ' Invalid selection. Please enter 1-' + items.length + '.' + colors.reset + '\n');
403
+ screen.write(colors.red + ' ' + i18n.tSync('navigation.invalid_selection', items.length) + colors.reset + '\n');
398
404
  }
399
405
  });
400
406
  }
package/lib/ui/screen.js CHANGED
@@ -1,125 +1,125 @@
1
- /**
2
- * Screen Singleton - ANSI terminal rendering layer
3
- *
4
- * All UI output goes through this module. Eliminates position drift
5
- * by using absolute cursor positioning (cursorHome + clearScreen)
6
- * and alternate screen buffer for isolation.
7
- */
8
-
9
- const ANSI = {
10
- enterAltScreen: '\x1b[?1049h',
11
- exitAltScreen: '\x1b[?1049l',
12
- cursorHome: '\x1b[H',
13
- clearScreen: '\x1b[2J',
14
- cursorHide: '\x1b[?25l',
15
- cursorShow: '\x1b[?25h',
16
- reset: '\x1b[0m',
17
- };
18
-
19
- class Screen {
20
- constructor() {
21
- this.inAltScreen = false;
22
- this.isTTY = process.stdout.isTTY || false;
23
- this.noAlt = process.env.SCREEN_NO_ALT === '1';
24
- this.testMode = process.env.SCREEN_TEST === '1';
25
- this.readlineActive = false;
26
- this.currentTag = null;
27
- this._log = [];
28
- }
29
-
30
- enter() {
31
- if (!this.isTTY) return;
32
- if (this.inAltScreen) return;
33
- if (!this.noAlt) {
34
- this._rawWrite(ANSI.enterAltScreen);
35
- }
36
- this._rawWrite(ANSI.cursorHide);
37
- this.inAltScreen = true;
38
- }
39
-
40
- exit() {
41
- if (!this.inAltScreen) return;
42
- this._rawWrite(ANSI.cursorShow);
43
- if (!this.noAlt) {
44
- this._rawWrite(ANSI.exitAltScreen);
45
- }
46
- this.inAltScreen = false;
47
- }
48
-
49
- exitForHandoff() {
50
- if (!this.inAltScreen) return;
51
- this._rawWrite(ANSI.cursorShow);
52
- this._rawWrite(ANSI.reset);
53
- if (!this.noAlt) {
54
- this._rawWrite(ANSI.exitAltScreen);
55
- }
56
- if (this.isTTY && process.stdin.isTTY) {
57
- try { process.stdin.setRawMode(false); } catch (_) {}
58
- }
59
- this.inAltScreen = false;
60
- }
61
-
62
- render(lines) {
63
- this.currentTag = 'render';
64
- if (this.isTTY) {
65
- this._rawWrite(ANSI.cursorHome + ANSI.clearScreen);
66
- }
67
- for (const line of lines) {
68
- this._rawWrite(line + '\n');
69
- }
70
- this.currentTag = null;
71
- }
72
-
73
- write(text) {
74
- this.currentTag = 'write';
75
- this._rawWrite(text);
76
- this.currentTag = null;
77
- }
78
-
79
- showCursor() {
80
- if (this.isTTY) {
81
- this._rawWrite(ANSI.cursorShow);
82
- }
83
- }
84
-
85
- hideCursor() {
86
- if (this.isTTY) {
87
- this._rawWrite(ANSI.cursorHide);
88
- }
89
- }
90
-
91
- isActive() {
92
- return this.inAltScreen;
93
- }
94
-
95
- debug(message) {
96
- if (this.inAltScreen) return; // Suppress during alt-screen
97
- this._rawStderr(message + '\n');
98
- }
99
-
100
- setReadlineActive(active) {
101
- this.readlineActive = active;
102
- this.currentTag = active ? 'readline' : null;
103
- }
104
-
105
- getLog() {
106
- return this._log;
107
- }
108
-
109
- _rawWrite(data) {
110
- if (this.testMode) {
111
- const tag = this.readlineActive ? 'readline' : (this.currentTag || 'untagged');
112
- this._log.push({ channel: 'stdout', tag, data: data.substring(0, 80), time: Date.now() });
113
- }
114
- process.stdout.write(data);
115
- }
116
-
117
- _rawStderr(data) {
118
- if (this.testMode) {
119
- this._log.push({ channel: 'stderr', tag: 'debug', data: data.substring(0, 80), time: Date.now() });
120
- }
121
- process.stderr.write(data);
122
- }
123
- }
124
-
125
- module.exports = new Screen();
1
+ /**
2
+ * Screen Singleton - ANSI terminal rendering layer
3
+ *
4
+ * All UI output goes through this module. Eliminates position drift
5
+ * by using absolute cursor positioning (cursorHome + clearScreen)
6
+ * and alternate screen buffer for isolation.
7
+ */
8
+
9
+ const ANSI = {
10
+ enterAltScreen: '\x1b[?1049h',
11
+ exitAltScreen: '\x1b[?1049l',
12
+ cursorHome: '\x1b[H',
13
+ clearScreen: '\x1b[2J',
14
+ cursorHide: '\x1b[?25l',
15
+ cursorShow: '\x1b[?25h',
16
+ reset: '\x1b[0m',
17
+ };
18
+
19
+ class Screen {
20
+ constructor() {
21
+ this.inAltScreen = false;
22
+ this.isTTY = process.stdout.isTTY || false;
23
+ this.noAlt = process.env.SCREEN_NO_ALT === '1';
24
+ this.testMode = process.env.SCREEN_TEST === '1';
25
+ this.readlineActive = false;
26
+ this.currentTag = null;
27
+ this._log = [];
28
+ }
29
+
30
+ enter() {
31
+ if (!this.isTTY) return;
32
+ if (this.inAltScreen) return;
33
+ if (!this.noAlt) {
34
+ this._rawWrite(ANSI.enterAltScreen);
35
+ }
36
+ this._rawWrite(ANSI.cursorHide);
37
+ this.inAltScreen = true;
38
+ }
39
+
40
+ exit() {
41
+ if (!this.inAltScreen) return;
42
+ this._rawWrite(ANSI.cursorShow);
43
+ if (!this.noAlt) {
44
+ this._rawWrite(ANSI.exitAltScreen);
45
+ }
46
+ this.inAltScreen = false;
47
+ }
48
+
49
+ exitForHandoff() {
50
+ if (!this.inAltScreen) return;
51
+ this._rawWrite(ANSI.cursorShow);
52
+ this._rawWrite(ANSI.reset);
53
+ if (!this.noAlt) {
54
+ this._rawWrite(ANSI.exitAltScreen);
55
+ }
56
+ if (this.isTTY && process.stdin.isTTY) {
57
+ try { process.stdin.setRawMode(false); } catch (_) {}
58
+ }
59
+ this.inAltScreen = false;
60
+ }
61
+
62
+ render(lines) {
63
+ this.currentTag = 'render';
64
+ if (this.isTTY) {
65
+ this._rawWrite(ANSI.cursorHome + ANSI.clearScreen);
66
+ }
67
+ for (const line of lines) {
68
+ this._rawWrite(line + '\n');
69
+ }
70
+ this.currentTag = null;
71
+ }
72
+
73
+ write(text) {
74
+ this.currentTag = 'write';
75
+ this._rawWrite(text);
76
+ this.currentTag = null;
77
+ }
78
+
79
+ showCursor() {
80
+ if (this.isTTY) {
81
+ this._rawWrite(ANSI.cursorShow);
82
+ }
83
+ }
84
+
85
+ hideCursor() {
86
+ if (this.isTTY) {
87
+ this._rawWrite(ANSI.cursorHide);
88
+ }
89
+ }
90
+
91
+ isActive() {
92
+ return this.inAltScreen;
93
+ }
94
+
95
+ debug(message) {
96
+ if (this.inAltScreen) return; // Suppress during alt-screen
97
+ this._rawStderr(message + '\n');
98
+ }
99
+
100
+ setReadlineActive(active) {
101
+ this.readlineActive = active;
102
+ this.currentTag = active ? 'readline' : null;
103
+ }
104
+
105
+ getLog() {
106
+ return this._log;
107
+ }
108
+
109
+ _rawWrite(data) {
110
+ if (this.testMode) {
111
+ const tag = this.readlineActive ? 'readline' : (this.currentTag || 'untagged');
112
+ this._log.push({ channel: 'stdout', tag, data: data.substring(0, 80), time: Date.now() });
113
+ }
114
+ process.stdout.write(data);
115
+ }
116
+
117
+ _rawStderr(data) {
118
+ if (this.testMode) {
119
+ this._log.push({ channel: 'stderr', tag: 'debug', data: data.substring(0, 80), time: Date.now() });
120
+ }
121
+ process.stderr.write(data);
122
+ }
123
+ }
124
+
125
+ module.exports = new Screen();
@@ -30,11 +30,10 @@ async function loadConfig() {
30
30
  if (config.disableTelemetry === undefined) config.disableTelemetry = true;
31
31
  if (config.showModelUpgradeNotification === undefined) config.showModelUpgradeNotification = true;
32
32
  if (config.apiLaunchMode === undefined) config.apiLaunchMode = 'direct';
33
+ if (config.noFlicker === undefined) config.noFlicker = true;
33
34
 
34
35
  return config;
35
36
  } catch (error) {
36
- // Return default config if file doesn't exist
37
- // Default language must be 'en' to match LanguageManager default
38
37
  const defaultConfig = {
39
38
  language: 'en',
40
39
  lastVersionCheck: 0,
@@ -43,7 +42,8 @@ async function loadConfig() {
43
42
  lastModelUpgradeCheck: 0,
44
43
  disableTelemetry: true,
45
44
  showModelUpgradeNotification: true,
46
- apiLaunchMode: 'direct'
45
+ apiLaunchMode: 'direct',
46
+ noFlicker: false,
47
47
  };
48
48
 
49
49
  // Write default config on first run so subsequent reads are consistent
@@ -281,10 +281,10 @@ function loadConfigSync() {
281
281
  if (config.disableTelemetry === undefined) config.disableTelemetry = true;
282
282
  if (config.showModelUpgradeNotification === undefined) config.showModelUpgradeNotification = true;
283
283
  if (config.apiLaunchMode === undefined) config.apiLaunchMode = 'direct';
284
+ if (config.noFlicker === undefined) config.noFlicker = true;
284
285
 
285
286
  return config;
286
287
  } catch (_) {
287
- // Default language must be 'en' to match LanguageManager default
288
288
  const defaultConfig = {
289
289
  language: 'en',
290
290
  lastVersionCheck: 0,
@@ -293,7 +293,8 @@ function loadConfigSync() {
293
293
  lastModelUpgradeCheck: 0,
294
294
  disableTelemetry: true,
295
295
  showModelUpgradeNotification: true,
296
- apiLaunchMode: 'direct'
296
+ apiLaunchMode: 'direct',
297
+ noFlicker: false,
297
298
  };
298
299
 
299
300
  // Write default config on first run so subsequent reads are consistent
package/lib/validators.js CHANGED
@@ -120,11 +120,112 @@ function maskApiToken(token) {
120
120
  }
121
121
  }
122
122
 
123
+ // ============================================================
124
+ // Environment variable constants & validators
125
+ // ============================================================
126
+
127
+ const RESERVED_ENV_KEYS = [
128
+ 'ANTHROPIC_BASE_URL',
129
+ 'ANTHROPIC_AUTH_TOKEN',
130
+ 'ANTHROPIC_API_KEY',
131
+ 'ANTHROPIC_MODEL',
132
+ 'ANTHROPIC_SMALL_FAST_MODEL',
133
+ 'CLAUDE_CODE_OAUTH_TOKEN',
134
+ 'DISABLE_TELEMETRY',
135
+ 'CLAUDE_CODE_NO_FLICKER',
136
+ ];
137
+
138
+ const PREDEFINED_RUNTIME_KEYS = [
139
+ 'API_TIMEOUT_MS',
140
+ 'CLAUDE_CODE_ATTRIBUTION_HEADER',
141
+ 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
142
+ 'CLAUDE_CODE_EFFORT_LEVEL',
143
+ 'CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS',
144
+ 'CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK',
145
+ ];
146
+
147
+ const PREDEFINED_MODEL_ENV_KEYS = [
148
+ 'ANTHROPIC_CUSTOM_MODEL_OPTION',
149
+ 'ANTHROPIC_CUSTOM_MODEL_OPTION_NAME',
150
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL',
151
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL',
152
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
153
+ 'CLAUDE_CODE_SUBAGENT_MODEL',
154
+ ];
155
+
156
+ const TYPE_A_FIELDS = [
157
+ 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
158
+ 'CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS',
159
+ 'CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK',
160
+ ];
161
+
162
+ const TYPE_B_FIELDS = [
163
+ 'CLAUDE_CODE_ATTRIBUTION_HEADER',
164
+ ];
165
+
166
+ const ALL_PREDEFINED_KEYS = new Set([
167
+ ...RESERVED_ENV_KEYS,
168
+ ...PREDEFINED_RUNTIME_KEYS,
169
+ ...PREDEFINED_MODEL_ENV_KEYS,
170
+ ]);
171
+
172
+ function validateEnvKey(key) {
173
+ if (typeof key !== 'string' || key.trim() === '') {
174
+ return { valid: false, error: 'custom_env_key_empty' };
175
+ }
176
+ if (ALL_PREDEFINED_KEYS.has(key.trim())) {
177
+ return { valid: false, error: 'custom_env_key_reserved' };
178
+ }
179
+ return { valid: true, value: key.trim() };
180
+ }
181
+
182
+ function validateTypeATriState(value) {
183
+ if (value === '' || value === '1' || value === 'off') {
184
+ return { valid: true, value };
185
+ }
186
+ return { valid: false, error: 'tri_state_type_a_invalid' };
187
+ }
188
+
189
+ function validateTypeBTriState(value) {
190
+ if (value === '' || value === '1' || value === '0') {
191
+ return { valid: true, value };
192
+ }
193
+ return { valid: false, error: 'tri_state_type_b_invalid' };
194
+ }
195
+
196
+ function validateRuntimeEnvValue(key, value) {
197
+ if (typeof value !== 'string') {
198
+ return { valid: false, error: 'env_value_not_string' };
199
+ }
200
+ if (TYPE_A_FIELDS.includes(key)) return validateTypeATriState(value);
201
+ if (TYPE_B_FIELDS.includes(key)) return validateTypeBTriState(value);
202
+ if (key === 'API_TIMEOUT_MS') {
203
+ if (value === '') return { valid: true, value };
204
+ if (/^\d+$/.test(value) && parseInt(value, 10) > 0) return { valid: true, value };
205
+ return { valid: false, error: 'env_value_timeout_invalid' };
206
+ }
207
+ if (key === 'CLAUDE_CODE_EFFORT_LEVEL') {
208
+ if (value === '') return { valid: true, value };
209
+ if (['low','medium','high','xhigh','max','auto'].includes(value)) return { valid: true, value };
210
+ return { valid: false, error: 'env_value_effort_invalid' };
211
+ }
212
+ return { valid: true, value };
213
+ }
214
+
123
215
  module.exports = {
124
216
  validateBaseUrl,
125
217
  validateAuthToken,
126
218
  validateModel,
127
219
  validateApiName,
128
220
  maskSensitiveData,
129
- maskApiToken
221
+ maskApiToken,
222
+ RESERVED_ENV_KEYS,
223
+ PREDEFINED_RUNTIME_KEYS,
224
+ PREDEFINED_MODEL_ENV_KEYS,
225
+ TYPE_A_FIELDS,
226
+ TYPE_B_FIELDS,
227
+ validateEnvKey,
228
+ validateTypeATriState,
229
+ validateTypeBTriState,
230
+ validateRuntimeEnvValue,
130
231
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kikkimo/claude-launcher",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Interactive launcher for Claude Code with beautiful Claude-style interface",
5
5
  "main": "claude-launcher",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node claude-launcher",
11
- "test": "node test/providers.test.js && node test/menu-hints.test.js && node test/version-checker.test.js && node test/api-manager.test.js && node test/launcher.test.js && node test/config-management.test.js && node test/api-select.test.js",
11
+ "test": "node test/providers.test.js && node test/menu-hints.test.js && node test/version-checker.test.js && node test/api-manager.test.js && node test/launcher.test.js && node test/config-management.test.js && node test/api-select.test.js && node test/env-vars-validators.test.js && node test/env-vars-providers.test.js && node test/env-vars-config.test.js && node test/env-vars-migration.test.js && node test/env-vars-add-api.test.js && node test/env-vars-write-interfaces.test.js && node test/env-vars-import-export.test.js && node test/env-vars-ui.test.js",
12
12
  "prepublishOnly": "echo \"Publishing claude-launcher...\"",
13
13
  "postinstall": "echo \"Claude Launcher installed successfully! Run 'claude-launcher' to start.\"",
14
14
  "publish:public": "npm publish --access public"