UNPKG

unbound-claude-code

Version:

Claude Code with Unbound integration - Drop-in replacement for Claude Code with multi-provider routing and cost optimization

546 lines (539 loc) • 23.3 kB
#!/usr/bin/env node "use strict"; /** * Unbound Code CLI * * A Claude CLI wrapper that acts as a proxy to Unbound AI. * It intercepts API calls to Anthropic and sends them to Unbound AI. * It also provides a CLI interface for managing the Unbound AI API key and model. */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.UnboundCli = void 0; const child_process_1 = require("child_process"); const fs_1 = require("fs"); const path_1 = require("path"); const os_1 = require("os"); const readline = __importStar(require("readline")); const auth_1 = require("./auth"); const storage_1 = require("./storage"); const global_1 = require("./global"); class UnboundCli { constructor() { this.auth = new auth_1.UnboundAuth(); this.storage = new storage_1.UnboundStorage(); } parseArgs(args) { const options = {}; const claudeArgs = []; let i = 0; while (i < args.length) { const arg = args[i]; switch (arg) { case '--help': case '-h': options.help = true; break; case '--version': case '-v': options.version = true; break; case '--debug': options.debug = true; break; case '--skip-auth': options.skipAuth = true; break; case '--api-key': if (i + 1 < args.length) { options.apiKey = args[i + 1]; i++; // Skip next arg } break; case '--clear-config': options.clearConfig = true; break; case '--show-config': options.showConfig = true; break; case '--configure': options.configure = true; break; default: // Pass through to Claude Code claudeArgs.push(arg); break; } i++; } return { options, claudeArgs }; } showHelp() { console.log(` Unbound Code - Claude with Unbound AI USAGE: unbound-claude-code [OPTIONS] [CLAUDE_ARGS...] OPTIONS: -h, --help Show this help message -v, --version Show version information --debug Enable debug logging --skip-auth Skip API key authentication (use UNBOUND_API_KEY env var) --api-key KEY Provide API key directly (will be stored securely) --clear-config Clear stored configuration --show-config Show current configuration --configure Configure all preferences (model, vertex, etc.) EXAMPLES: unbound-claude-code # Start Claude with Unbound AI unbound-claude-code chat # Start Claude in chat mode unbound-claude-code --debug # Start with debug logging unbound-claude-code --api-key YOUR_KEY # Set and store API key securely unbound-claude-code --show-config # Show stored configuration unbound-claude-code --clear-config # Clear stored credentials unbound-claude-code --configure # Configure all preferences CONFIGURATION COMMANDS: --configure Interactively configure all preferences ENVIRONMENT VARIABLES: UNBOUND_API_KEY # Your Unbound API key UNBOUND_LOG_LEVEL # Log level (debug, info, warn, error) SECURITY: API keys are stored securely in your system's keychain only. Claude will be launched with ANTHROPIC_BASE_URL set to https://api.getunbound.ai and ANTHROPIC_API_KEY set to your Unbound API key. `); } showVersion() { const packageJson = require('../package.json'); console.log(`unbound-claude-code v${packageJson.version}`); } /** * Read the model configuration from Claude Code * This reads from ~/.claude/settings.json and environment variables * to determine what model Claude Code is using */ async getClaudeCodeModel() { const envPrimaryModel = process.env.ANTHROPIC_MODEL; const envSmallModel = process.env.ANTHROPIC_SMALL_FAST_MODEL; const claudeDir = (0, path_1.join)((0, os_1.homedir)(), '.claude'); const settingsPath = (0, path_1.join)(claudeDir, 'settings.json'); let settingsModel; try { const settingsContent = await fs_1.promises.readFile(settingsPath, 'utf8'); const settings = JSON.parse(settingsContent); settingsModel = settings.model || settings.defaultModel; } catch (error) { // File doesn't exist or can't be read, that's okay } const primaryModel = envPrimaryModel || settingsModel || 'claude-sonnet-4-20250514'; const smallModel = envSmallModel || 'claude-3-5-haiku-20241022'; return { primaryModel, smallModel }; } async showConfig() { const config = await this.storage.getFullConfig(); const vertexConfig = await this.storage.getVertexConfig(); console.log('Current Unbound Code Configuration:'); console.log('═'.repeat(50)); console.log(''); if (vertexConfig.useVertex) { console.log('šŸ”„ Mode: Vertex AI'); console.log(` Primary model: ${vertexConfig.model}`); console.log(` Small/fast model: ${vertexConfig.smallModel}`); } else { console.log('šŸ”— Mode: Standard Anthropic'); // Get the actual model from Claude Code configuration const claudeCodeModel = await this.getClaudeCodeModel(); console.log(` Model: ${claudeCodeModel.primaryModel}`); } if (config.lastUsed) { console.log(` Last Used: ${new Date(config.lastUsed).toLocaleString()}`); } console.log(''); console.log('šŸ“ Storage Locations:'); console.log(` Config: ~/.unbound-claude-code/config.json (non-sensitive data only)`); console.log(` API key: System keychain (secure storage)`); console.log(''); console.log('šŸ’” Management Commands:'); console.log(' --configure # Reconfigure all preferences'); console.log(' --clear-config # Clear all configuration'); } async clearConfig() { await this.storage.clearConfig(); } async configure() { console.log('\nšŸ”§ Unbound Code Configuration Setup'); console.log('═'.repeat(50)); console.log('This will configure your preferences for Unbound Code.'); console.log(''); // First, configure vertex preferences const vertexConfig = await this.promptVertexConfiguration(); await this.storage.setVertexConfig(vertexConfig); console.log('\nāœ“ Configuration completed successfully!'); console.log('Use --show-config to view your current settings.'); } async displayStartupConfig() { const config = await this.storage.getStartupConfig(); console.log('\nšŸ”§ Unbound Code Configuration'); console.log('═'.repeat(40)); if (config.useVertex) { console.log('Mode: Vertex AI'); console.log(` āœ“ Primary model: ${config.vertexPrimaryModel}`); console.log(` āœ“ Small/fast model: ${config.vertexSmallModel}`); } else { console.log('Mode: Standard Anthropic'); console.log(` āœ“ Model: ${config.model}`); } console.log(''); console.log('šŸ’” Configuration Commands:'); console.log(' unbound-claude-code --configure # Configure all preferences'); console.log(' unbound-claude-code --show-config # Show detailed configuration'); console.log(''); } async checkClaudeCode() { // Check if claude command is available (cross-platform) try { const command = process.platform === 'win32' ? 'where claude' : 'which claude'; (0, child_process_1.execSync)(command, { stdio: 'ignore' }); } catch (error) { // Try to auto-install Claude CLI await this.checkAndInstallClaudeCode(); } } async checkAndInstallClaudeCode() { console.log('Claude CLI not found. Installing automatically...'); try { // Attempt to install Claude CLI using npm with more verbose output for debugging const installCommand = 'npm install -g @anthropic-ai/claude-code'; console.log(`Running: ${installCommand}`); try { (0, child_process_1.execSync)(installCommand, { stdio: 'pipe', timeout: 60000 // 60 second timeout }); } catch (installError) { // More detailed error reporting console.error('Installation error details:', installError.message); if (installError.stderr) { console.error('Installation stderr:', installError.stderr.toString()); } throw new Error(`Failed to install Claude CLI automatically. Error: ${installError.message}. Please install manually with: npm install -g @anthropic-ai/claude-code`); } } catch (error) { throw new Error(`Failed to install Claude CLI automatically. ${error.message}`); } // Check if installation was successful (cross-platform) try { const command = process.platform === 'win32' ? 'where claude' : 'which claude'; (0, child_process_1.execSync)(command, { stdio: 'ignore' }); console.log('āœ“ Claude CLI installed successfully'); } catch (checkError) { // Try alternative check methods try { // Try running claude directly to see if it's available (0, child_process_1.execSync)('claude --version', { stdio: 'ignore', timeout: 5000 }); console.log('āœ“ Claude CLI installed successfully'); } catch (versionError) { throw new Error('Claude CLI installation completed but command still not found in PATH. Please restart your terminal or check your PATH configuration.'); } } } /** * Sets up Claude configuration files to use Unbound API key * Creates ~/.claude/settings.json with apiKeyHelper configuration * Creates ~/.claude/anthropic_key.sh with the API key */ async setupClaudeConfig(apiKey) { const claudeDir = (0, path_1.join)((0, os_1.homedir)(), '.claude'); const settingsPath = (0, path_1.join)(claudeDir, 'settings.json'); const keyHelperPath = (0, path_1.join)(claudeDir, 'anthropic_key.sh'); try { // Ensure ~/.claude directory exists await fs_1.promises.mkdir(claudeDir, { recursive: true }); // Handle settings.json let settings = {}; try { // Check if settings.json exists await fs_1.promises.access(settingsPath); // File exists, read and parse it const existingContent = await fs_1.promises.readFile(settingsPath, 'utf8'); try { settings = JSON.parse(existingContent); } catch (parseError) { // Invalid JSON, start with empty object settings = {}; } } catch (accessError) { // File doesn't exist, we'll create it with empty object settings = {}; } // Add/update apiKeyHelper configuration settings.apiKeyHelper = '~/.claude/anthropic_key.sh'; // Write updated settings.json await fs_1.promises.writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf8'); // Create anthropic_key.sh with the API key await fs_1.promises.writeFile(keyHelperPath, `echo $UNBOUND_API_KEY`, 'utf8'); // Make anthropic_key.sh executable (equivalent to chmod +x) const stats = await fs_1.promises.stat(keyHelperPath); await fs_1.promises.chmod(keyHelperPath, stats.mode | 0o111); } catch (error) { throw new Error(`Failed to setup Claude configuration: ${error.message}`); } } async promptVertexConfiguration() { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const question = (query) => { return new Promise((resolve) => { rl.question(query, resolve); }); }; try { console.log('\nšŸ”„ Vertex AI Configuration'); console.log('─'.repeat(40)); const useVertexAnswer = await question('Do you want to use Vertex AI models? (y/n): '); const useVertex = useVertexAnswer.toLowerCase().trim() === 'y' || useVertexAnswer.toLowerCase().trim() === 'yes'; if (!useVertex) { return { useVertex: false }; } console.log('\nšŸ“ Vertex AI Model Configuration:'); console.log('Default models:'); console.log(' • Primary model: claude-sonnet-4@20250514'); console.log(' • Small/fast model: claude-3-5-haiku@20241022'); console.log(''); const useDefaultsAnswer = await question('Would you like to proceed with the default models? (y/n): '); const useDefaults = useDefaultsAnswer.toLowerCase().trim() === 'y' || useDefaultsAnswer.toLowerCase().trim() === 'yes'; let primaryModel = 'anthropic.claude-sonnet-4@20250514'; let smallModel = 'anthropic.claude-3-5-haiku@20241022'; if (!useDefaults) { console.log('\nšŸ“ Enter custom Vertex AI model IDs:'); const customPrimary = await question(`Primary model (default: ${primaryModel}): `); if (customPrimary.trim()) { primaryModel = customPrimary.trim(); } const customSmall = await question(`Small/fast model (default: ${smallModel}): `); if (customSmall.trim()) { smallModel = customSmall.trim(); } } return { useVertex: true, model: primaryModel, smallModel: smallModel }; } finally { rl.close(); } } /** * Store API key only (for --api-key flag) */ async storeApiKey(apiKey) { console.log('Verifying provided API key...'); const isValid = await this.auth.verifyApiKey(apiKey); if (!isValid) { throw new Error('Provided API key is invalid'); } console.log('āœ“ API key verified and stored securely'); // Store the API key for future use await this.storage.setApiKey(apiKey, false); } async authenticateUser(options) { if (options.skipAuth) { const apiKey = process.env.UNBOUND_API_KEY; if (!apiKey) { throw new Error('UNBOUND_API_KEY environment variable required when using --skip-auth'); } return apiKey; } if (options.apiKey) { // Verify and store the provided API key await this.storeApiKey(options.apiKey); return options.apiKey; } // Check if we have a stored API key first const storedApiKey = await this.storage.getApiKey(); if (storedApiKey) { console.log('Using stored API key...'); const isValid = await this.auth.verifyApiKey(storedApiKey); if (isValid) { console.log('āœ“ API key verified successfully'); return storedApiKey; } else { await this.storage.clearConfigDueToInvalidKey(); } } // Get API key (from env or prompt) - verification happens during prompt if needed const apiKey = await this.auth.getApiKeyOnly(); // Only verify if key came from environment (prompt already verifies) const envApiKey = process.env.UNBOUND_API_KEY; if (envApiKey && envApiKey.trim() === apiKey) { const isValid = await this.auth.verifyApiKey(apiKey); if (!isValid) { await this.storage.clearConfigDueToInvalidKey(); throw new Error('Invalid API key. Please check your Unbound API key and try again.'); } } // Store the API key for future use (suppress duplicate message) await this.storage.setApiKey(apiKey, true); return apiKey; } async launchClaudeCode(apiKey, claudeArgs, debug, vertexConfig) { // Set environment variables to redirect Claude to Unbound gateway // Don't set ANTHROPIC_API_KEY in env to avoid the confirmation prompt, api key is set in anthropic_key.sh const env = { ...process.env, UNBOUND_LOG_LEVEL: debug ? 'debug' : 'info', }; if (vertexConfig?.useVertex) { // Configure Vertex AI to use Unbound gateway env.ANTHROPIC_VERTEX_BASE_URL = `${global_1.UNBOUND_BASE_URL}/v1`; env.ANTHROPIC_VERTEX_PROJECT_ID = 'unbound-gateway'; env.CLAUDE_CODE_SKIP_VERTEX_AUTH = '1'; // Skip GCP authentication, let Unbound handle it env.CLAUDE_CODE_USE_VERTEX = '1'; env.CLOUD_ML_REGION = 'unbound-gateway'; // Dummy region, Unbound will handle actual routing env.ANTHROPIC_MODEL = vertexConfig.model; env.ANTHROPIC_SMALL_FAST_MODEL = vertexConfig.smallModel; } else { // Standard Unbound proxy configuration env.ANTHROPIC_BASE_URL = global_1.UNBOUND_BASE_URL; } // Set up NODE_OPTIONS to inject our interceptor into Claude Code process const interceptorPath = (0, path_1.join)(__dirname, 'interceptor-loader.js'); env.NODE_OPTIONS = `--require "${interceptorPath}"`; env.UNBOUND_API_KEY = apiKey; // Pass API key to interceptor // Launch claude directly - it will use the API key from the helper script we set up const claudeProcess = (0, child_process_1.spawn)('claude', claudeArgs, { stdio: 'inherit', env: env }); // Handle process events claudeProcess.on('close', (code) => { process.exit(code || 0); }); claudeProcess.on('error', (error) => { console.error('Failed to start Claude:', error); console.error('Make sure Claude is installed and available in your PATH'); process.exit(1); }); // Handle Ctrl+C gracefully process.on('SIGINT', () => { claudeProcess.kill('SIGINT'); }); process.on('SIGTERM', () => { claudeProcess.kill('SIGTERM'); }); } async run(args) { try { const { options, claudeArgs } = this.parseArgs(args); if (options.help) { this.showHelp(); return; } if (options.version) { this.showVersion(); return; } if (options.showConfig) { await this.showConfig(); return; } if (options.clearConfig) { await this.clearConfig(); return; } if (options.configure) { await this.configure(); return; } if (options.apiKey) { await this.storeApiKey(options.apiKey); return; } // Check if Claude Code is available await this.checkClaudeCode(); // Authenticate user const apiKey = await this.authenticateUser(options); // Check if user has completed initial configuration const isConfigured = await this.storage.isConfigured(); let vertexConfig; if (!isConfigured) { // Prompt for initial configuration vertexConfig = await this.promptVertexConfiguration(); await this.storage.setVertexConfig(vertexConfig); } else { // Use stored configuration const storedVertexConfig = await this.storage.getVertexConfig(); vertexConfig = { useVertex: storedVertexConfig.useVertex, model: storedVertexConfig.model, smallModel: storedVertexConfig.smallModel }; // Display current configuration await this.displayStartupConfig(); } // Setup Claude configuration files await this.setupClaudeConfig(apiKey); // Launch Claude Code with interceptor await this.launchClaudeCode(apiKey, claudeArgs, options.debug || false, vertexConfig); } catch (error) { console.error('Error:', error.message); process.exit(1); } } } exports.UnboundCli = UnboundCli; // Run CLI if this file is executed directly if (require.main === module) { const cli = new UnboundCli(); cli.run(process.argv.slice(2)); } //# sourceMappingURL=cli.js.map