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
JavaScript
;
/**
* 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