Nicholai dc0cd40b13
feat(agent): add Claude Code bridge integration (#60)
Add local daemon that routes inference through user's own Anthropic
API key with filesystem and terminal access. Includes WebSocket
transport, MCP tool adapter, and API key auth.

Key components:
- compass-bridge package: local daemon with tool registry
- WebSocket transport for agent communication
- MCP API key management with HMAC auth and scoped permissions
- Usage tracking (tool calls, duration, success/failure)
- Settings UI for Claude Code configuration
- Migration 0019: mcp_api_keys and mcp_usage tables
- Test suite for auth and transport layers

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
2026-02-09 00:29:00 -07:00

408 lines
9.5 KiB
TypeScript

#!/usr/bin/env bun
// compass-bridge CLI entry point
import {
loadConfig,
saveConfig,
isConfigured,
hasAnthropicKey,
loadClaudeCredentials,
CONFIG_PATH,
type BridgeConfig,
} from "./config"
import { registerWithCompass } from "./auth"
import { startServer } from "./server"
import { login as oauthLogin } from "./oauth"
import { startProxy, PROXY_PORT } from "./proxy"
const args = process.argv.slice(2)
const command = args[0] ?? "help"
const flags = new Set(args.slice(1))
async function promptInput(
question: string,
): Promise<string> {
process.stdout.write(question)
for await (const line of console) {
return line.trim()
}
return ""
}
async function promptYesNo(
question: string,
defaultYes = true,
): Promise<boolean> {
const hint = defaultYes ? "Y/n" : "y/N"
const answer = await promptInput(
`${question} (${hint}): `,
)
if (answer === "") return defaultYes
return answer.toLowerCase().startsWith("y")
}
async function init(): Promise<void> {
console.log("compass-bridge setup\n")
const existing = await loadConfig()
const compassUrl = await promptInput(
`Compass URL [${existing.compassUrl || "https://your-compass.example.com"}]: `,
)
const apiKey = await promptInput(
"Compass API key (ck_...): ",
)
const portStr = await promptInput(
`Port [${existing.port || 18789}]: `,
)
const config: BridgeConfig = {
compassUrl:
compassUrl || existing.compassUrl || "",
apiKey: apiKey || existing.apiKey || "",
anthropicApiKey: existing.anthropicApiKey,
oauthCredentials: existing.oauthCredentials,
port: portStr
? parseInt(portStr, 10)
: existing.port || 18789,
allowedOrigins: existing.allowedOrigins,
}
await saveConfig(config)
console.log(`\nConfig saved to ${CONFIG_PATH}`)
// verify compass connection
if (isConfigured(config)) {
console.log("\nVerifying connection...")
try {
const result =
await registerWithCompass(config)
console.log(
`Connected as ${result.user.name} (${result.user.role})`,
)
console.log(
`${result.tools.length} tools available`,
)
} catch (err) {
const msg =
err instanceof Error
? err.message
: "unknown error"
console.error(`Connection failed: ${msg}`)
console.error(
"Check your Compass URL and API key.",
)
}
}
// anthropic auth
if (config.oauthCredentials) {
console.log(
"\nAnthropic: authenticated via OAuth",
)
} else if (config.anthropicApiKey) {
const kind = config.anthropicApiKey.startsWith(
"sk-ant-oat",
)
? "setup-token"
: "API key"
console.log(`\nAnthropic: ${kind} configured`)
} else if (!hasAnthropicKey(config)) {
console.log("")
const wantsAuth = await promptYesNo(
"Authenticate with Anthropic now?",
)
if (wantsAuth) {
await runLogin(config)
} else {
console.log(
"\nYou can authenticate later with " +
"'compass-bridge login'\n" +
"or set the ANTHROPIC_API_KEY env var.",
)
}
} else {
console.log(
"\nAnthropic: env var configured",
)
}
console.log(
"\nRun 'compass-bridge start' to launch the daemon.",
)
}
async function runLogin(
existingConfig?: BridgeConfig,
): Promise<void> {
const config =
existingConfig ?? (await loadConfig())
console.log("\nAuthenticate with Anthropic:\n")
console.log(
" 1. OAuth (browser login)" +
" - opens anthropic.com in your browser",
)
console.log(
" 2. Setup token" +
" - run 'claude setup-token' and paste",
)
console.log(
" 3. API key" +
" - paste key from console.anthropic.com",
)
const choice = await promptInput("\nChoice [1]: ")
const method = choice === "2"
? "token"
: choice === "3"
? "apikey"
: "oauth"
if (method === "oauth") {
try {
const result = await oauthLogin({
manual: flags.has("--manual"),
})
const updated: BridgeConfig = {
...config,
oauthCredentials: result,
// ensure OAuth takes effect by clearing stale keys
anthropicApiKey: undefined,
}
await saveConfig(updated)
console.log("\nauthenticated with Anthropic.")
console.log(
`token: ${result.access.slice(0, 15)}...`,
)
} catch (err) {
const msg =
err instanceof Error
? err.message
: "unknown error"
console.error(`\nOAuth login failed: ${msg}`)
console.error(
"try again with --manual, or use option " +
"2 (setup-token) or 3 (API key).",
)
}
return
}
if (method === "token") {
console.log(
"\nrun 'claude setup-token' in your " +
"terminal, then paste below.",
)
} else {
console.log(
"\npaste your API key from " +
"console.anthropic.com/settings/keys",
)
}
const key = await promptInput(
"\ntoken (sk-ant-...): ",
)
if (!key || !key.startsWith("sk-ant-")) {
console.error(
"invalid token. expected format: sk-ant-...",
)
return
}
const updated: BridgeConfig = {
...config,
anthropicApiKey: key,
// clear stale oauth credentials
oauthCredentials: undefined,
}
await saveConfig(updated)
const kind = key.startsWith("sk-ant-oat")
? "setup-token"
: "API key"
console.log(
`\n${kind} saved: ${key.slice(0, 15)}...`,
)
}
async function start(): Promise<void> {
const config = await loadConfig()
if (!isConfigured(config)) {
console.error(
"Not configured. " +
"Run 'compass-bridge init' first.",
)
process.exit(1)
}
if (!hasAnthropicKey(config)) {
console.error(
"No Anthropic API key. " +
"Run 'compass-bridge login' " +
"or set ANTHROPIC_API_KEY env var.",
)
process.exit(1)
}
if (flags.has("--proxy")) {
const portEnv = process.env.COMPASS_BRIDGE_PROXY_PORT
const port = portEnv
? parseInt(portEnv, 10)
: PROXY_PORT
const baseUrlEnv =
process.env.COMPASS_BRIDGE_ANTHROPIC_BASE_URL
if (!baseUrlEnv) {
process.env.COMPASS_BRIDGE_ANTHROPIC_BASE_URL =
`http://127.0.0.1:${port}`
}
startProxy(port)
console.log(
`[bridge] proxy enabled for start (port ${port})`,
)
}
console.log("[bridge] registering with Compass...")
try {
const registration =
await registerWithCompass(config)
startServer(config, registration)
} catch (err) {
const msg =
err instanceof Error
? err.message
: "unknown error"
console.error(
`[bridge] failed to start: ${msg}`,
)
process.exit(1)
}
}
async function status(): Promise<void> {
const config = await loadConfig()
if (!isConfigured(config)) {
console.log("Status: not configured")
console.log(
"Run 'compass-bridge init' to set up.",
)
return
}
console.log(`Compass URL: ${config.compassUrl}`)
console.log(`Port: ${config.port}`)
console.log(
`API key: ${config.apiKey.slice(0, 11)}...`,
)
const envKey = process.env.ANTHROPIC_API_KEY
const creds = loadClaudeCredentials()
if (envKey) {
console.log(
"Anthropic key: env var (ANTHROPIC_API_KEY)",
)
if (config.oauthCredentials) {
console.log(
" note: OAuth credentials present but env var takes precedence",
)
}
if (config.anthropicApiKey) {
console.log(
" note: bridge config key present but env var takes precedence",
)
}
} else if (config.anthropicApiKey) {
console.log("Anthropic key: bridge config")
if (config.oauthCredentials) {
console.log(
" note: OAuth credentials present but bridge config takes precedence",
)
}
} else if (config.oauthCredentials) {
const expired =
Date.now() > config.oauthCredentials.expires
console.log(
`Anthropic key: OAuth credentials` +
(expired
? " (token expired, will refresh)"
: ""),
)
} else if (creds) {
const expired = Date.now() > creds.expiresAt
console.log(
`Anthropic key: Claude Code credentials` +
(expired
? " (token expired, will refresh)"
: ""),
)
} else {
console.log("Anthropic key: not set")
}
// check if daemon is running
try {
const res = await fetch(
`http://127.0.0.1:${config.port}/health`,
)
if (res.ok) {
console.log("Daemon: running")
} else {
console.log("Daemon: not running")
}
} catch {
console.log("Daemon: not running")
}
}
async function proxy(): Promise<void> {
const portEnv = process.env.COMPASS_BRIDGE_PROXY_PORT
const port = portEnv
? parseInt(portEnv, 10)
: PROXY_PORT
startProxy(port)
}
switch (command) {
case "init":
await init()
break
case "login":
await runLogin()
break
case "start":
await start()
break
case "status":
await status()
break
case "proxy":
await proxy()
break
case "help":
default:
console.log(`compass-bridge - local daemon for Claude Code + Compass
Usage:
compass-bridge init Configure Compass URL and API keys
compass-bridge login Authenticate with Anthropic (OAuth, setup-token, or API key)
compass-bridge start Start the bridge daemon
compass-bridge status Check daemon status
compass-bridge proxy Start the Claude Code auth proxy
compass-bridge help Show this help message
Options:
--manual Use manual OAuth flow (for headless environments)
--proxy Start Claude Code proxy with the daemon
`)
break
}