@kamel-ahmed/proxy-claude 1.0.3 → 1.0.5

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.
Files changed (3) hide show
  1. package/bin/cli.js +301 -109
  2. package/package.json +2 -2
  3. package/src/cli/onboard.js +580 -0
package/bin/cli.js CHANGED
@@ -1,12 +1,31 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ /**
4
+ * Proxy Claude CLI
5
+ *
6
+ * Cross-platform CLI tool for running Claude Code with Antigravity proxy.
7
+ * Works on Windows, macOS, and Linux.
8
+ */
9
+
3
10
  import { fileURLToPath } from 'url';
4
11
  import { dirname, join } from 'path';
5
- import { readFileSync } from 'fs';
12
+ import { readFileSync, existsSync } from 'fs';
13
+ import { spawn, execSync } from 'child_process';
14
+ import os from 'os';
15
+ import http from 'http';
6
16
 
7
17
  const __filename = fileURLToPath(import.meta.url);
8
18
  const __dirname = dirname(__filename);
9
19
 
20
+ // Platform detection
21
+ const IS_WINDOWS = process.platform === 'win32';
22
+ const HOME_DIR = os.homedir();
23
+
24
+ // Config paths
25
+ const CONFIG_DIR = join(HOME_DIR, '.config', 'antigravity-proxy');
26
+ const ACCOUNTS_FILE = join(CONFIG_DIR, 'accounts.json');
27
+ const CLAUDE_SETTINGS = join(HOME_DIR, '.claude', 'settings.json');
28
+
10
29
  // Read package.json for version
11
30
  const packageJson = JSON.parse(
12
31
  readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')
@@ -15,50 +34,164 @@ const packageJson = JSON.parse(
15
34
  const args = process.argv.slice(2);
16
35
  const command = args[0];
17
36
 
37
+ // Colors (cross-platform)
38
+ const C = {
39
+ reset: '\x1b[0m',
40
+ bold: '\x1b[1m',
41
+ dim: '\x1b[2m',
42
+ red: '\x1b[31m',
43
+ green: '\x1b[32m',
44
+ yellow: '\x1b[33m',
45
+ blue: '\x1b[34m',
46
+ cyan: '\x1b[36m',
47
+ };
48
+
49
+ /**
50
+ * Check if first run (no accounts or settings configured)
51
+ */
52
+ function isFirstRun() {
53
+ try {
54
+ if (!existsSync(ACCOUNTS_FILE)) return true;
55
+ const accounts = JSON.parse(readFileSync(ACCOUNTS_FILE, 'utf-8'));
56
+ if (!accounts.accounts || accounts.accounts.length === 0) return true;
57
+
58
+ if (!existsSync(CLAUDE_SETTINGS)) return true;
59
+ const settings = JSON.parse(readFileSync(CLAUDE_SETTINGS, 'utf-8'));
60
+ if (!settings.env?.ANTHROPIC_BASE_URL) return true;
61
+
62
+ return false;
63
+ } catch {
64
+ return true;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Check if command exists (cross-platform)
70
+ */
71
+ function commandExists(cmd) {
72
+ try {
73
+ const checkCmd = IS_WINDOWS ? `where ${cmd}` : `which ${cmd}`;
74
+ execSync(checkCmd, { stdio: 'ignore' });
75
+ return true;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Check if port is in use using Node.js (cross-platform)
83
+ */
84
+ function checkPort(port) {
85
+ return new Promise((resolve) => {
86
+ const req = http.get(`http://localhost:${port}/health`, (res) => {
87
+ resolve(true);
88
+ res.resume();
89
+ });
90
+ req.on('error', () => resolve(false));
91
+ req.setTimeout(1000, () => {
92
+ req.destroy();
93
+ resolve(false);
94
+ });
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Wait for proxy to be ready
100
+ */
101
+ async function waitForProxy(port, maxAttempts = 60) {
102
+ for (let i = 0; i < maxAttempts; i++) {
103
+ if (await checkPort(port)) return true;
104
+ await new Promise(r => setTimeout(r, 500));
105
+ }
106
+ return false;
107
+ }
108
+
109
+ /**
110
+ * Stop proxy server (cross-platform)
111
+ */
112
+ async function stopProxy(port) {
113
+ if (IS_WINDOWS) {
114
+ try {
115
+ // Find PID using netstat on Windows
116
+ const output = execSync(`netstat -ano | findstr :${port} | findstr LISTENING`, {
117
+ encoding: 'utf-8',
118
+ stdio: ['pipe', 'pipe', 'ignore']
119
+ });
120
+ const lines = output.trim().split('\n');
121
+ for (const line of lines) {
122
+ const parts = line.trim().split(/\s+/);
123
+ const pid = parts[parts.length - 1];
124
+ if (pid && pid !== '0') {
125
+ try {
126
+ execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' });
127
+ } catch {}
128
+ }
129
+ }
130
+ return true;
131
+ } catch {
132
+ return false;
133
+ }
134
+ } else {
135
+ try {
136
+ execSync(`lsof -ti tcp:${port} | xargs kill 2>/dev/null`, {
137
+ stdio: 'ignore',
138
+ shell: true
139
+ });
140
+ return true;
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Get port from args or env
149
+ */
150
+ function getPort() {
151
+ const portIndex = args.findIndex(a => a === '--port' || a === '-p');
152
+ if (portIndex !== -1 && args[portIndex + 1]) {
153
+ return parseInt(args[portIndex + 1], 10);
154
+ }
155
+ return parseInt(process.env.PORT || '8080', 10);
156
+ }
157
+
18
158
  function showHelp() {
19
159
  console.log(`
20
- proxy-claude v${packageJson.version}
160
+ ${C.cyan}proxy-claude${C.reset} v${packageJson.version}
21
161
 
22
162
  Proxy server for using Antigravity's Claude models with Claude Code CLI.
23
163
 
24
- USAGE:
164
+ ${C.bold}USAGE:${C.reset}
25
165
  proxy-claude [command] [options]
26
166
 
27
- COMMANDS:
28
- (default) Start proxy + launch Claude Code (interactive)
29
- run Same as default - start proxy + Claude Code
30
- start Start the proxy server only (foreground)
31
- web Same as start - server with web dashboard
32
- accounts Manage Google accounts (interactive)
33
- accounts add Add a new Google account via OAuth
34
- accounts list List all configured accounts
35
- accounts remove Remove accounts interactively
36
- accounts verify Verify account tokens are valid
37
- accounts clear Remove all accounts
38
- refresh Check and refresh account tokens
39
- setup Install Claude Code CLI (if needed)
40
-
41
- OPTIONS:
167
+ ${C.bold}COMMANDS:${C.reset}
168
+ ${C.cyan}(default)${C.reset} Start proxy + launch Claude Code
169
+ ${C.cyan}init${C.reset} Run setup wizard (configure models & accounts)
170
+ ${C.cyan}start${C.reset} Start the proxy server only
171
+ ${C.cyan}stop${C.reset} Stop running proxy server
172
+ ${C.cyan}status${C.reset} Check if proxy is running
173
+ ${C.cyan}accounts${C.reset} Manage Google accounts (add/list/remove/verify)
174
+ ${C.cyan}refresh${C.reset} Check and refresh account tokens
175
+
176
+ ${C.bold}OPTIONS:${C.reset}
42
177
  --help, -h Show this help message
43
178
  --version, -v Show version number
179
+ --port, -p <port> Set custom port (default: 8080)
180
+ --force Force reconfigure (with init)
44
181
 
45
- ENVIRONMENT:
182
+ ${C.bold}ENVIRONMENT:${C.reset}
46
183
  PORT Server port (default: 8080)
47
184
 
48
- EXAMPLES:
185
+ ${C.bold}EXAMPLES:${C.reset}
49
186
  proxy-claude # Start proxy + Claude Code
50
- proxy-claude run # Same as above
187
+ proxy-claude init # Run setup wizard
51
188
  proxy-claude start # Start proxy server only
189
+ proxy-claude stop # Stop proxy server
52
190
  PORT=3000 proxy-claude # Use custom port
53
191
  proxy-claude accounts add # Add Google account
54
192
 
55
- CONFIGURATION:
56
- Claude Code CLI (~/.claude/settings.json):
57
- {
58
- "env": {
59
- "ANTHROPIC_BASE_URL": "http://localhost:8080"
60
- }
61
- }
193
+ ${C.bold}FIRST TIME?${C.reset}
194
+ Run ${C.cyan}proxy-claude init${C.reset} to configure everything.
62
195
  `);
63
196
  }
64
197
 
@@ -66,6 +199,9 @@ function showVersion() {
66
199
  console.log(packageJson.version);
67
200
  }
68
201
 
202
+ /**
203
+ * Main CLI handler
204
+ */
69
205
  async function main() {
70
206
  // Handle flags
71
207
  if (args.includes('--help') || args.includes('-h')) {
@@ -78,98 +214,52 @@ async function main() {
78
214
  process.exit(0);
79
215
  }
80
216
 
217
+ const port = getPort();
218
+
81
219
  // Handle commands
82
220
  switch (command) {
83
- case 'setup':
84
- await import('../src/cli/setup.js').then(module => module.runSetup());
221
+ case 'init':
222
+ case 'setup': {
223
+ // Run onboarding wizard
224
+ const force = args.includes('--force') || args.includes('-f');
225
+ const { runOnboarding } = await import('../src/cli/onboard.js');
226
+ const success = await runOnboarding({ skipIfConfigured: !force });
227
+ process.exit(success ? 0 : 1);
85
228
  break;
229
+ }
86
230
 
87
231
  case 'start':
232
+ case 'web': {
88
233
  // Start the server only
89
234
  await import('../src/index.js');
90
235
  break;
236
+ }
91
237
 
92
- case 'web':
93
- // Alias for start (server with web dashboard)
94
- await import('../src/index.js');
238
+ case 'stop': {
239
+ // Stop running proxy
240
+ console.log(`${C.yellow}Stopping proxy on port ${port}...${C.reset}`);
241
+ const stopped = await stopProxy(port);
242
+ if (stopped) {
243
+ console.log(`${C.green}✓ Proxy stopped${C.reset}`);
244
+ } else {
245
+ console.log(`${C.dim}No proxy found running on port ${port}${C.reset}`);
246
+ }
95
247
  break;
248
+ }
96
249
 
97
- case undefined:
98
- case 'run': {
99
- // Default: Start proxy in background + launch Claude Code
100
- const { spawn, execSync } = await import('child_process');
101
- const port = process.env.PORT || 8080;
102
-
103
- console.log(`\x1b[34mStarting Antigravity Claude Proxy on port ${port}...\x1b[0m`);
104
-
105
- // Start proxy in background
106
- const proxyProcess = spawn('node', [join(__dirname, '..', 'src', 'index.js')], {
107
- detached: true,
108
- stdio: 'ignore',
109
- env: { ...process.env, PORT: port }
110
- });
111
- proxyProcess.unref();
112
-
113
- // Wait for proxy to be ready
114
- console.log('Waiting for proxy to be ready...');
115
- let ready = false;
116
- for (let i = 0; i < 60; i++) {
250
+ case 'status': {
251
+ // Check if proxy is running
252
+ const running = await checkPort(port);
253
+ if (running) {
254
+ console.log(`${C.green}✓ Proxy is running on port ${port}${C.reset}`);
117
255
  try {
118
- execSync(`curl -s http://localhost:${port}/health`, { stdio: 'ignore' });
119
- ready = true;
120
- break;
121
- } catch {
122
- await new Promise(r => setTimeout(r, 500));
123
- }
124
- }
125
-
126
- if (!ready) {
127
- console.error('\x1b[31mError: Proxy failed to start within 30 seconds.\x1b[0m');
128
- process.exit(1);
129
- }
130
-
131
- console.log(`\x1b[32m✓ Proxy is ready on port ${port}!\x1b[0m\n`);
132
-
133
- // Check if claude is installed
134
- try {
135
- execSync('which claude', { stdio: 'ignore' });
136
- } catch {
137
- console.error('\x1b[31mError: Claude Code CLI not found.\x1b[0m');
138
- console.error('Install it with: npm install -g @anthropic-ai/claude-code');
139
- process.exit(1);
140
- }
141
-
142
- // Launch Claude with proxy config
143
- const claudeArgs = args.slice(command === 'run' ? 1 : 0);
144
- const claudeProcess = spawn('claude', claudeArgs, {
145
- stdio: 'inherit',
146
- env: {
147
- ...process.env,
148
- ANTHROPIC_BASE_URL: `http://localhost:${port}`,
149
- ANTHROPIC_API_KEY: 'dummy'
150
- }
151
- });
152
-
153
- // Cleanup on exit
154
- const cleanup = () => {
155
- console.log('\n\x1b[33mStopping Antigravity Claude Proxy...\x1b[0m');
156
- try {
157
- execSync(`lsof -ti tcp:${port} | xargs kill 2>/dev/null`, { stdio: 'ignore' });
256
+ const res = await fetch(`http://localhost:${port}/health`);
257
+ const data = await res.json();
258
+ console.log(` ${C.dim}Status: ${data.summary}${C.reset}`);
158
259
  } catch {}
159
- console.log('\x1b[32m✓ Proxy stopped\x1b[0m');
160
- };
161
-
162
- claudeProcess.on('close', (code) => {
163
- cleanup();
164
- process.exit(code || 0);
165
- });
166
-
167
- process.on('SIGINT', () => {
168
- claudeProcess.kill('SIGINT');
169
- });
170
- process.on('SIGTERM', () => {
171
- claudeProcess.kill('SIGTERM');
172
- });
260
+ } else {
261
+ console.log(`${C.yellow}✗ Proxy is not running on port ${port}${C.reset}`);
262
+ }
173
263
  break;
174
264
  }
175
265
 
@@ -198,14 +288,116 @@ async function main() {
198
288
  showVersion();
199
289
  break;
200
290
 
291
+ case undefined:
292
+ case 'run': {
293
+ // Check if first run - prompt for setup
294
+ if (isFirstRun()) {
295
+ console.log(`${C.cyan}Welcome to Proxy Claude!${C.reset}`);
296
+ console.log(`${C.dim}It looks like this is your first time running.${C.reset}`);
297
+ console.log('');
298
+ console.log(`Running setup wizard...`);
299
+ console.log('');
300
+
301
+ const { runOnboarding } = await import('../src/cli/onboard.js');
302
+ const success = await runOnboarding();
303
+ if (!success) {
304
+ process.exit(1);
305
+ }
306
+ console.log('');
307
+ }
308
+
309
+ // Default: Start proxy in background + launch Claude Code
310
+ console.log(`${C.blue}Starting Antigravity Claude Proxy on port ${port}...${C.reset}`);
311
+
312
+ // Check if already running
313
+ if (await checkPort(port)) {
314
+ console.log(`${C.green}✓ Proxy already running on port ${port}${C.reset}`);
315
+ } else {
316
+ // Start proxy in background
317
+ const proxyScript = join(__dirname, '..', 'src', 'index.js');
318
+ const proxyProcess = spawn(process.execPath, [proxyScript], {
319
+ detached: true,
320
+ stdio: 'ignore',
321
+ env: { ...process.env, PORT: String(port) },
322
+ shell: false,
323
+ windowsHide: true,
324
+ });
325
+ proxyProcess.unref();
326
+
327
+ // Wait for proxy to be ready
328
+ console.log('Waiting for proxy to be ready...');
329
+ const ready = await waitForProxy(port);
330
+
331
+ if (!ready) {
332
+ console.error(`${C.red}Error: Proxy failed to start within 30 seconds.${C.reset}`);
333
+ process.exit(1);
334
+ }
335
+
336
+ console.log(`${C.green}✓ Proxy is ready on port ${port}!${C.reset}`);
337
+ }
338
+ console.log('');
339
+
340
+ // Check if claude is installed
341
+ if (!commandExists('claude')) {
342
+ console.error(`${C.red}Error: Claude Code CLI not found.${C.reset}`);
343
+ console.error('Install it with: npm install -g @anthropic-ai/claude-code');
344
+ console.error('Or run: proxy-claude init');
345
+ process.exit(1);
346
+ }
347
+
348
+ // Launch Claude with proxy config
349
+ const claudeArgs = args.slice(command === 'run' ? 1 : 0).filter(a =>
350
+ a !== '--port' && a !== '-p' && !args[args.indexOf('--port') + 1]?.includes(a)
351
+ );
352
+
353
+ const claudeProcess = spawn('claude', claudeArgs, {
354
+ stdio: 'inherit',
355
+ env: {
356
+ ...process.env,
357
+ ANTHROPIC_BASE_URL: `http://localhost:${port}`,
358
+ ANTHROPIC_API_KEY: 'proxy-claude',
359
+ },
360
+ shell: IS_WINDOWS,
361
+ });
362
+
363
+ // Cleanup on exit
364
+ const cleanup = async () => {
365
+ console.log(`\n${C.yellow}Stopping Antigravity Claude Proxy...${C.reset}`);
366
+ await stopProxy(port);
367
+ console.log(`${C.green}✓ Proxy stopped${C.reset}`);
368
+ };
369
+
370
+ claudeProcess.on('close', async (code) => {
371
+ await cleanup();
372
+ process.exit(code || 0);
373
+ });
374
+
375
+ // Handle signals
376
+ const handleSignal = (signal) => {
377
+ claudeProcess.kill(signal);
378
+ };
379
+
380
+ process.on('SIGINT', () => handleSignal('SIGINT'));
381
+ process.on('SIGTERM', () => handleSignal('SIGTERM'));
382
+
383
+ // Windows-specific handling
384
+ if (IS_WINDOWS) {
385
+ process.on('SIGHUP', () => handleSignal('SIGHUP'));
386
+ }
387
+ break;
388
+ }
389
+
201
390
  default:
202
- console.error(`Unknown command: ${command}`);
391
+ console.error(`${C.red}Unknown command: ${command}${C.reset}`);
203
392
  console.error('Run "proxy-claude --help" for usage information.');
204
393
  process.exit(1);
205
394
  }
206
395
  }
207
396
 
208
397
  main().catch((err) => {
209
- console.error('Error:', err.message);
398
+ console.error(`${C.red}Error:${C.reset}`, err.message);
399
+ if (process.env.DEBUG) {
400
+ console.error(err.stack);
401
+ }
210
402
  process.exit(1);
211
403
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kamel-ahmed/proxy-claude",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Proxy server to use Antigravity's Claude models with Claude Code CLI - run 'proxy-claude' to start",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -26,7 +26,7 @@
26
26
  "accounts:verify": "node src/cli/accounts.js verify",
27
27
  "refresh": "node src/cli/refresh.js",
28
28
  "refresh:force": "node src/cli/refresh.js --force",
29
- "setup": "node bin/cli.js setup",
29
+ "setup": "node bin/cli.js init",
30
30
  "test": "node tests/run-all.cjs",
31
31
  "test:signatures": "node tests/test-thinking-signatures.cjs",
32
32
  "test:multiturn": "node tests/test-multiturn-thinking-tools.cjs",
@@ -0,0 +1,580 @@
1
+ /**
2
+ * Professional CLI Onboarding
3
+ *
4
+ * Cross-platform setup wizard that configures everything needed
5
+ * to run Claude Code with the Antigravity proxy.
6
+ */
7
+
8
+ import { execSync, spawn } from 'child_process';
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import os from 'os';
12
+ import readline from 'readline';
13
+ import { fileURLToPath } from 'url';
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = path.dirname(__filename);
17
+
18
+ // Colors (cross-platform safe)
19
+ const supportsColor = process.stdout.isTTY && (
20
+ process.env.COLORTERM === 'truecolor' ||
21
+ process.env.TERM?.includes('256color') ||
22
+ process.platform === 'win32'
23
+ );
24
+
25
+ const COLORS = {
26
+ reset: supportsColor ? '\x1b[0m' : '',
27
+ bold: supportsColor ? '\x1b[1m' : '',
28
+ dim: supportsColor ? '\x1b[2m' : '',
29
+ red: supportsColor ? '\x1b[31m' : '',
30
+ green: supportsColor ? '\x1b[32m' : '',
31
+ yellow: supportsColor ? '\x1b[33m' : '',
32
+ blue: supportsColor ? '\x1b[34m' : '',
33
+ magenta: supportsColor ? '\x1b[35m' : '',
34
+ cyan: supportsColor ? '\x1b[36m' : '',
35
+ white: supportsColor ? '\x1b[37m' : '',
36
+ };
37
+
38
+ // Platform detection
39
+ const PLATFORM = {
40
+ isWindows: process.platform === 'win32',
41
+ isMac: process.platform === 'darwin',
42
+ isLinux: process.platform === 'linux',
43
+ homeDir: os.homedir(),
44
+ pathSep: path.sep,
45
+ };
46
+
47
+ // Paths
48
+ const CONFIG_DIR = path.join(PLATFORM.homeDir, '.config', 'antigravity-proxy');
49
+ const ACCOUNTS_FILE = path.join(CONFIG_DIR, 'accounts.json');
50
+ const CLAUDE_CONFIG_DIR = path.join(PLATFORM.homeDir, '.claude');
51
+ const CLAUDE_SETTINGS_FILE = path.join(CLAUDE_CONFIG_DIR, 'settings.json');
52
+
53
+ // All available models in Antigravity (fetched dynamically, but this is a fallback)
54
+ const ALL_MODELS = [
55
+ // Claude models
56
+ { id: 'claude-opus-4-5-thinking', family: 'claude', tier: 'opus', description: 'Claude Opus 4.5 with thinking' },
57
+ { id: 'claude-sonnet-4-5-thinking', family: 'claude', tier: 'sonnet', description: 'Claude Sonnet 4.5 with thinking' },
58
+ { id: 'claude-sonnet-4-5', family: 'claude', tier: 'sonnet', description: 'Claude Sonnet 4.5' },
59
+ // Gemini models
60
+ { id: 'gemini-3-pro-high', family: 'gemini', tier: 'opus', description: 'Gemini 3 Pro High (best quality)' },
61
+ { id: 'gemini-3-pro-low', family: 'gemini', tier: 'sonnet', description: 'Gemini 3 Pro Low' },
62
+ { id: 'gemini-3-flash', family: 'gemini', tier: 'sonnet', description: 'Gemini 3 Flash (fast)' },
63
+ { id: 'gemini-2.5-flash-lite', family: 'gemini', tier: 'haiku', description: 'Gemini 2.5 Flash Lite (fastest)' },
64
+ ];
65
+
66
+ // Model tier descriptions
67
+ const TIER_INFO = {
68
+ opus: {
69
+ name: 'Opus (Primary)',
70
+ description: 'Main model for complex tasks',
71
+ envKey: 'ANTHROPIC_DEFAULT_OPUS_MODEL',
72
+ },
73
+ sonnet: {
74
+ name: 'Sonnet (Default)',
75
+ description: 'Balanced model for most tasks',
76
+ envKey: 'ANTHROPIC_DEFAULT_SONNET_MODEL',
77
+ },
78
+ haiku: {
79
+ name: 'Haiku (Fast)',
80
+ description: 'Quick model for simple tasks & background',
81
+ envKey: 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
82
+ },
83
+ };
84
+
85
+ /**
86
+ * Create readline interface for user input
87
+ */
88
+ function createPrompt() {
89
+ return readline.createInterface({
90
+ input: process.stdin,
91
+ output: process.stdout,
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Prompt user for input
97
+ */
98
+ async function prompt(rl, question, defaultValue = '') {
99
+ return new Promise((resolve) => {
100
+ const defaultText = defaultValue ? ` ${COLORS.dim}(${defaultValue})${COLORS.reset}` : '';
101
+ rl.question(`${question}${defaultText}: `, (answer) => {
102
+ resolve(answer.trim() || defaultValue);
103
+ });
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Prompt for yes/no
109
+ */
110
+ async function confirm(rl, question, defaultYes = true) {
111
+ const hint = defaultYes ? 'Y/n' : 'y/N';
112
+ const answer = await prompt(rl, `${question} [${hint}]`);
113
+ if (!answer) return defaultYes;
114
+ return answer.toLowerCase().startsWith('y');
115
+ }
116
+
117
+ /**
118
+ * Print styled message
119
+ */
120
+ function print(message, type = 'info') {
121
+ const icons = {
122
+ info: `${COLORS.blue}ℹ${COLORS.reset}`,
123
+ success: `${COLORS.green}✓${COLORS.reset}`,
124
+ warn: `${COLORS.yellow}⚠${COLORS.reset}`,
125
+ error: `${COLORS.red}✗${COLORS.reset}`,
126
+ step: `${COLORS.cyan}▸${COLORS.reset}`,
127
+ };
128
+ console.log(`${icons[type] || ''} ${message}`);
129
+ }
130
+
131
+ /**
132
+ * Print header
133
+ */
134
+ function printHeader() {
135
+ console.log('');
136
+ console.log(`${COLORS.cyan}╔══════════════════════════════════════════════════════════╗${COLORS.reset}`);
137
+ console.log(`${COLORS.cyan}║${COLORS.reset} ${COLORS.bold}Proxy Claude - Setup Wizard${COLORS.reset} ${COLORS.cyan}║${COLORS.reset}`);
138
+ console.log(`${COLORS.cyan}║${COLORS.reset} ${COLORS.dim}Configure everything in one go${COLORS.reset} ${COLORS.cyan}║${COLORS.reset}`);
139
+ console.log(`${COLORS.cyan}╚══════════════════════════════════════════════════════════╝${COLORS.reset}`);
140
+ console.log('');
141
+ }
142
+
143
+ /**
144
+ * Print step header
145
+ */
146
+ function printStep(step, total, title) {
147
+ console.log('');
148
+ console.log(`${COLORS.bold}${COLORS.blue}[${step}/${total}]${COLORS.reset} ${COLORS.bold}${title}${COLORS.reset}`);
149
+ console.log(`${COLORS.dim}${'─'.repeat(50)}${COLORS.reset}`);
150
+ }
151
+
152
+ /**
153
+ * Check if a command exists
154
+ */
155
+ function commandExists(cmd) {
156
+ try {
157
+ const checkCmd = PLATFORM.isWindows ? `where ${cmd}` : `which ${cmd}`;
158
+ execSync(checkCmd, { stdio: 'ignore' });
159
+ return true;
160
+ } catch {
161
+ return false;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Check Node.js version
167
+ */
168
+ function checkNodeVersion() {
169
+ const version = process.version;
170
+ const major = parseInt(version.slice(1).split('.')[0], 10);
171
+ return { version, major, ok: major >= 18 };
172
+ }
173
+
174
+ /**
175
+ * Check if Claude Code CLI is installed
176
+ */
177
+ function checkClaudeCli() {
178
+ return commandExists('claude');
179
+ }
180
+
181
+ /**
182
+ * Install Claude Code CLI
183
+ */
184
+ async function installClaudeCli() {
185
+ print('Installing Claude Code CLI...', 'step');
186
+ try {
187
+ execSync('npm install -g @anthropic-ai/claude-code', {
188
+ stdio: 'inherit',
189
+ shell: true
190
+ });
191
+ print('Claude Code CLI installed successfully', 'success');
192
+ return true;
193
+ } catch (error) {
194
+ print(`Failed to install Claude Code CLI: ${error.message}`, 'error');
195
+ print('Please install manually: npm install -g @anthropic-ai/claude-code', 'info');
196
+ return false;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Check for existing accounts
202
+ */
203
+ function getExistingAccounts() {
204
+ try {
205
+ if (fs.existsSync(ACCOUNTS_FILE)) {
206
+ const data = JSON.parse(fs.readFileSync(ACCOUNTS_FILE, 'utf-8'));
207
+ return data.accounts || [];
208
+ }
209
+ } catch {}
210
+ return [];
211
+ }
212
+
213
+ /**
214
+ * Fetch available models from the API using account token
215
+ */
216
+ async function fetchAvailableModels(accounts) {
217
+ if (!accounts || accounts.length === 0) {
218
+ return ALL_MODELS;
219
+ }
220
+
221
+ try {
222
+ // Get token from first account
223
+ const account = accounts[0];
224
+ const token = account.accessToken;
225
+
226
+ if (!token) {
227
+ return ALL_MODELS;
228
+ }
229
+
230
+ // Import the model API
231
+ const { fetchAvailableModels: fetchModels } = await import('../cloudcode/model-api.js');
232
+ const data = await fetchModels(token);
233
+
234
+ if (data && data.models) {
235
+ const models = [];
236
+ for (const [modelId, modelData] of Object.entries(data.models)) {
237
+ // Only include Claude and Gemini models
238
+ if (!modelId.includes('claude') && !modelId.includes('gemini')) continue;
239
+
240
+ // Determine family
241
+ const family = modelId.includes('claude') ? 'claude' : 'gemini';
242
+
243
+ // Determine tier based on model name
244
+ let tier = 'sonnet';
245
+ if (modelId.includes('opus') || modelId.includes('pro-high')) tier = 'opus';
246
+ else if (modelId.includes('haiku') || modelId.includes('flash-lite')) tier = 'haiku';
247
+
248
+ // Check if model has quota (remaining > 0)
249
+ const hasQuota = modelData.remainingFraction === undefined || modelData.remainingFraction > 0;
250
+
251
+ models.push({
252
+ id: modelId,
253
+ family,
254
+ tier,
255
+ description: modelData.displayName || modelId,
256
+ hasQuota,
257
+ remainingFraction: modelData.remainingFraction,
258
+ });
259
+ }
260
+
261
+ if (models.length > 0) {
262
+ return models;
263
+ }
264
+ }
265
+ } catch (error) {
266
+ print(`Could not fetch models from API: ${error.message}`, 'warn');
267
+ }
268
+
269
+ return ALL_MODELS;
270
+ }
271
+
272
+ /**
273
+ * Add Google account via OAuth
274
+ */
275
+ async function addGoogleAccount() {
276
+ print('Starting Google OAuth flow...', 'step');
277
+ print('A browser window will open for authentication.', 'info');
278
+ console.log('');
279
+
280
+ return new Promise((resolve) => {
281
+ const accountsScript = path.join(__dirname, 'accounts.js');
282
+ const child = spawn('node', [accountsScript, 'add'], {
283
+ stdio: 'inherit',
284
+ shell: true,
285
+ });
286
+
287
+ child.on('close', (code) => {
288
+ resolve(code === 0);
289
+ });
290
+ });
291
+ }
292
+
293
+ /**
294
+ * Load existing Claude settings
295
+ */
296
+ function loadClaudeSettings() {
297
+ try {
298
+ if (fs.existsSync(CLAUDE_SETTINGS_FILE)) {
299
+ return JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_FILE, 'utf-8'));
300
+ }
301
+ } catch {}
302
+ return {};
303
+ }
304
+
305
+ /**
306
+ * Save Claude settings
307
+ */
308
+ function saveClaudeSettings(settings) {
309
+ // Ensure directory exists
310
+ if (!fs.existsSync(CLAUDE_CONFIG_DIR)) {
311
+ fs.mkdirSync(CLAUDE_CONFIG_DIR, { recursive: true });
312
+ }
313
+ fs.writeFileSync(CLAUDE_SETTINGS_FILE, JSON.stringify(settings, null, 2));
314
+ }
315
+
316
+ /**
317
+ * Select a model for a specific tier
318
+ */
319
+ async function selectModelForTier(rl, tier, availableModels) {
320
+ const tierInfo = TIER_INFO[tier];
321
+ console.log('');
322
+ console.log(`${COLORS.bold}${tierInfo.name}${COLORS.reset} - ${COLORS.dim}${tierInfo.description}${COLORS.reset}`);
323
+ console.log('');
324
+
325
+ // Filter models that are suitable for this tier or higher
326
+ // opus can use any model, sonnet can use sonnet/haiku, haiku uses haiku
327
+ const tierPriority = { opus: 3, sonnet: 2, haiku: 1 };
328
+ const minTier = tierPriority[tier];
329
+
330
+ const suitableModels = availableModels.filter(m => {
331
+ const modelTier = tierPriority[m.tier] || 2;
332
+ return modelTier >= minTier - 1; // Allow one tier lower
333
+ });
334
+
335
+ // Sort: models with quota first, then by family (claude first), then by tier
336
+ suitableModels.sort((a, b) => {
337
+ // Quota status
338
+ if (a.hasQuota !== b.hasQuota) return a.hasQuota ? -1 : 1;
339
+ // Family (claude first for opus/sonnet, gemini first for haiku)
340
+ if (tier === 'haiku') {
341
+ if (a.family !== b.family) return a.family === 'gemini' ? -1 : 1;
342
+ } else {
343
+ if (a.family !== b.family) return a.family === 'claude' ? -1 : 1;
344
+ }
345
+ // Tier priority
346
+ return (tierPriority[b.tier] || 0) - (tierPriority[a.tier] || 0);
347
+ });
348
+
349
+ // Display models
350
+ console.log(`${COLORS.dim}Available models:${COLORS.reset}`);
351
+ suitableModels.forEach((model, index) => {
352
+ const quotaStatus = model.hasQuota === false
353
+ ? `${COLORS.red}(no quota)${COLORS.reset}`
354
+ : model.remainingFraction !== undefined
355
+ ? `${COLORS.green}(${Math.round(model.remainingFraction * 100)}% quota)${COLORS.reset}`
356
+ : '';
357
+ const familyBadge = model.family === 'claude'
358
+ ? `${COLORS.magenta}[Claude]${COLORS.reset}`
359
+ : `${COLORS.blue}[Gemini]${COLORS.reset}`;
360
+ console.log(` ${COLORS.cyan}${index + 1}.${COLORS.reset} ${model.id} ${familyBadge} ${quotaStatus}`);
361
+ });
362
+ console.log('');
363
+
364
+ // Default to first model with quota
365
+ const defaultIndex = suitableModels.findIndex(m => m.hasQuota !== false) + 1 || 1;
366
+
367
+ const choice = await prompt(rl, `Select model for ${tier} (1-${suitableModels.length})`, String(defaultIndex));
368
+ const index = parseInt(choice, 10) - 1;
369
+
370
+ if (index >= 0 && index < suitableModels.length) {
371
+ return suitableModels[index].id;
372
+ }
373
+ return suitableModels[0]?.id || ALL_MODELS.find(m => m.tier === tier)?.id;
374
+ }
375
+
376
+ /**
377
+ * Configure Claude Code settings with selected models
378
+ */
379
+ function configureClaudeSettingsWithModels(modelConfig, port = 8080) {
380
+ const existing = loadClaudeSettings();
381
+
382
+ const newSettings = {
383
+ ...existing,
384
+ env: {
385
+ ...(existing.env || {}),
386
+ ANTHROPIC_AUTH_TOKEN: 'proxy-claude',
387
+ ANTHROPIC_BASE_URL: `http://localhost:${port}`,
388
+ ANTHROPIC_MODEL: modelConfig.sonnet, // Default model is sonnet
389
+ ANTHROPIC_DEFAULT_OPUS_MODEL: modelConfig.opus,
390
+ ANTHROPIC_DEFAULT_SONNET_MODEL: modelConfig.sonnet,
391
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: modelConfig.haiku,
392
+ CLAUDE_CODE_SUBAGENT_MODEL: modelConfig.haiku, // Subagent uses haiku for efficiency
393
+ ENABLE_EXPERIMENTAL_MCP_CLI: 'true',
394
+ }
395
+ };
396
+
397
+ // Also set hasCompletedOnboarding to skip Claude's own onboarding
398
+ if (!existing.hasCompletedOnboarding) {
399
+ newSettings.hasCompletedOnboarding = true;
400
+ }
401
+
402
+ saveClaudeSettings(newSettings);
403
+ return newSettings;
404
+ }
405
+
406
+ /**
407
+ * Print summary with selected models
408
+ */
409
+ function printSummary(accounts, modelConfig, port) {
410
+ console.log('');
411
+ console.log(`${COLORS.green}╔══════════════════════════════════════════════════════════╗${COLORS.reset}`);
412
+ console.log(`${COLORS.green}║${COLORS.reset} ${COLORS.bold}${COLORS.green}Setup Complete!${COLORS.reset} ${COLORS.green}║${COLORS.reset}`);
413
+ console.log(`${COLORS.green}╚══════════════════════════════════════════════════════════╝${COLORS.reset}`);
414
+ console.log('');
415
+
416
+ console.log(`${COLORS.bold}Configuration Summary:${COLORS.reset}`);
417
+ console.log(` ${COLORS.dim}•${COLORS.reset} Accounts: ${accounts.length} configured`);
418
+ console.log(` ${COLORS.dim}•${COLORS.reset} Opus model: ${COLORS.cyan}${modelConfig.opus}${COLORS.reset}`);
419
+ console.log(` ${COLORS.dim}•${COLORS.reset} Sonnet model: ${COLORS.cyan}${modelConfig.sonnet}${COLORS.reset}`);
420
+ console.log(` ${COLORS.dim}•${COLORS.reset} Haiku model: ${COLORS.cyan}${modelConfig.haiku}${COLORS.reset}`);
421
+ console.log(` ${COLORS.dim}•${COLORS.reset} Proxy port: ${port}`);
422
+ console.log(` ${COLORS.dim}•${COLORS.reset} Settings saved to: ${CLAUDE_SETTINGS_FILE}`);
423
+ console.log('');
424
+
425
+ console.log(`${COLORS.bold}To start using Claude with the proxy:${COLORS.reset}`);
426
+ console.log('');
427
+ console.log(` ${COLORS.cyan}proxy-claude${COLORS.reset}`);
428
+ console.log('');
429
+ console.log(`${COLORS.dim}Or start components separately:${COLORS.reset}`);
430
+ console.log(` ${COLORS.dim}proxy-claude start${COLORS.reset} - Start proxy server only`);
431
+ console.log(` ${COLORS.dim}proxy-claude web${COLORS.reset} - Start with web dashboard`);
432
+ console.log('');
433
+ }
434
+
435
+ /**
436
+ * Main onboarding flow
437
+ */
438
+ export async function runOnboarding(options = {}) {
439
+ const { skipIfConfigured = false, quiet = false } = options;
440
+
441
+ // Check if already configured
442
+ const existingAccounts = getExistingAccounts();
443
+ const existingSettings = loadClaudeSettings();
444
+ const isConfigured = existingAccounts.length > 0 && existingSettings.env?.ANTHROPIC_BASE_URL;
445
+
446
+ if (skipIfConfigured && isConfigured) {
447
+ if (!quiet) {
448
+ print('Already configured. Use --force to reconfigure.', 'info');
449
+ }
450
+ return true;
451
+ }
452
+
453
+ printHeader();
454
+
455
+ const rl = createPrompt();
456
+ const totalSteps = 4;
457
+ let currentStep = 0;
458
+
459
+ try {
460
+ // Step 1: Check prerequisites
461
+ printStep(++currentStep, totalSteps, 'Checking Prerequisites');
462
+
463
+ // Check Node.js
464
+ const nodeCheck = checkNodeVersion();
465
+ if (nodeCheck.ok) {
466
+ print(`Node.js ${nodeCheck.version} ✓`, 'success');
467
+ } else {
468
+ print(`Node.js ${nodeCheck.version} - version 18+ required`, 'error');
469
+ rl.close();
470
+ return false;
471
+ }
472
+
473
+ // Check/Install Claude CLI
474
+ if (checkClaudeCli()) {
475
+ print('Claude Code CLI ✓', 'success');
476
+ } else {
477
+ print('Claude Code CLI not found', 'warn');
478
+ const install = await confirm(rl, 'Install Claude Code CLI now?');
479
+ if (install) {
480
+ const installed = await installClaudeCli();
481
+ if (!installed) {
482
+ rl.close();
483
+ return false;
484
+ }
485
+ } else {
486
+ print('Claude Code CLI is required. Please install it first.', 'error');
487
+ rl.close();
488
+ return false;
489
+ }
490
+ }
491
+
492
+ // Step 2: Account Setup
493
+ printStep(++currentStep, totalSteps, 'Account Configuration');
494
+
495
+ if (existingAccounts.length > 0) {
496
+ print(`Found ${existingAccounts.length} existing account(s):`, 'info');
497
+ existingAccounts.forEach((acc, i) => {
498
+ console.log(` ${COLORS.dim}${i + 1}.${COLORS.reset} ${acc.email}`);
499
+ });
500
+
501
+ const addMore = await confirm(rl, 'Add another account?', false);
502
+ if (addMore) {
503
+ await addGoogleAccount();
504
+ }
505
+ } else {
506
+ print('No accounts configured yet.', 'info');
507
+ const addAccount = await confirm(rl, 'Add a Google account now?');
508
+ if (addAccount) {
509
+ await addGoogleAccount();
510
+ } else {
511
+ print('You can add accounts later with: proxy-claude accounts add', 'info');
512
+ }
513
+ }
514
+
515
+ // Reload accounts after potential additions
516
+ const accounts = getExistingAccounts();
517
+
518
+ // Step 3: Model Configuration
519
+ printStep(++currentStep, totalSteps, 'Model Configuration');
520
+
521
+ print('Fetching available models from your account...', 'step');
522
+ const availableModels = await fetchAvailableModels(accounts);
523
+ print(`Found ${availableModels.length} models available`, 'success');
524
+
525
+ console.log('');
526
+ print('Now select a model for each tier. Models with quota are shown first.', 'info');
527
+ print(`${COLORS.dim}Tip: Use Gemini for Haiku to save your Claude quota!${COLORS.reset}`, 'info');
528
+
529
+ // Select model for each tier
530
+ const modelConfig = {
531
+ opus: await selectModelForTier(rl, 'opus', availableModels),
532
+ sonnet: await selectModelForTier(rl, 'sonnet', availableModels),
533
+ haiku: await selectModelForTier(rl, 'haiku', availableModels),
534
+ };
535
+
536
+ console.log('');
537
+ print('Model configuration:', 'success');
538
+ console.log(` ${COLORS.dim}Opus:${COLORS.reset} ${COLORS.cyan}${modelConfig.opus}${COLORS.reset}`);
539
+ console.log(` ${COLORS.dim}Sonnet:${COLORS.reset} ${COLORS.cyan}${modelConfig.sonnet}${COLORS.reset}`);
540
+ console.log(` ${COLORS.dim}Haiku:${COLORS.reset} ${COLORS.cyan}${modelConfig.haiku}${COLORS.reset}`);
541
+
542
+ // Step 4: Apply Configuration
543
+ printStep(++currentStep, totalSteps, 'Applying Configuration');
544
+
545
+ const port = parseInt(process.env.PORT || '8080', 10);
546
+
547
+ // Configure Claude Code settings
548
+ print('Configuring Claude Code settings...', 'step');
549
+ configureClaudeSettingsWithModels(modelConfig, port);
550
+ print(`Settings saved to ${CLAUDE_SETTINGS_FILE}`, 'success');
551
+
552
+ // Print summary
553
+ printSummary(accounts, modelConfig, port);
554
+
555
+ rl.close();
556
+ return true;
557
+
558
+ } catch (error) {
559
+ print(`Setup error: ${error.message}`, 'error');
560
+ rl.close();
561
+ return false;
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Check if first run (no config exists)
567
+ */
568
+ export function isFirstRun() {
569
+ const accounts = getExistingAccounts();
570
+ const settings = loadClaudeSettings();
571
+ return accounts.length === 0 || !settings.env?.ANTHROPIC_BASE_URL;
572
+ }
573
+
574
+ // CLI entry point
575
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
576
+ const force = process.argv.includes('--force') || process.argv.includes('-f');
577
+ runOnboarding({ skipIfConfigured: !force }).then((success) => {
578
+ process.exit(success ? 0 : 1);
579
+ });
580
+ }