diff --git a/README.md b/README.md index e64eb58..428b9ad 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,8 @@ All inputs — Discord messages, heartbeat timers, cron jobs, lifecycle hooks ## Prerequisites - **Node.js** 18+ +- **Claude Code CLI** — [Install Claude Code](https://docs.anthropic.com/en/docs/claude-code/getting-started) and sign in with your subscription - **Discord Bot Token** — [Create a bot](https://discord.com/developers/applications) with Message Content Intent enabled -- **Anthropic API Key** — Get one from [console.anthropic.com](https://console.anthropic.com/) (this is separate from a Claude Code CLI subscription) ## Quick Start @@ -28,9 +28,11 @@ All inputs — Discord messages, heartbeat timers, cron jobs, lifecycle hooks # Install dependencies npm install +# Make sure Claude Code CLI is installed and you're signed in +claude --version + # Set required environment variables export DISCORD_BOT_TOKEN=your-discord-bot-token -export ANTHROPIC_API_KEY=your-anthropic-api-key # Create config directory with persona files mkdir config @@ -65,11 +67,11 @@ All settings are via environment variables: | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `DISCORD_BOT_TOKEN` | Yes | — | Discord bot token | -| `ANTHROPIC_API_KEY` | Yes | — | Anthropic API key from [console.anthropic.com](https://console.anthropic.com/) | -| `ALLOWED_TOOLS` | No | `Read,Write,Edit,Glob,Grep,WebSearch,WebFetch` | Comma-separated Agent SDK tools | -| `PERMISSION_MODE` | No | `bypassPermissions` | Agent SDK permission mode | +| `CLAUDE_CLI_PATH` | No | `claude` | Path to the Claude Code CLI binary | +| `ALLOWED_TOOLS` | No | `Read,Write,Edit,Glob,Grep,WebSearch,WebFetch` | Comma-separated Claude Code tools | +| `PERMISSION_MODE` | No | `bypassPermissions` | Claude Code permission mode | | `QUERY_TIMEOUT_MS` | No | `120000` | Query timeout in milliseconds | -| `MAX_CONCURRENT_QUERIES` | No | `5` | Max simultaneous Agent SDK queries | +| `MAX_CONCURRENT_QUERIES` | No | `5` | Max simultaneous Claude queries | | `CONFIG_DIR` | No | `./config` | Path to markdown config directory | | `MAX_QUEUE_DEPTH` | No | `100` | Max events in the queue | | `OUTPUT_CHANNEL_ID` | No | — | Discord channel for heartbeat/cron output | @@ -196,14 +198,14 @@ npm run build npm start ``` -## API Key vs Claude Code Subscription +## Claude Code CLI vs API Key -The Claude Agent SDK requires an **Anthropic API key** (`ANTHROPIC_API_KEY`), which is separate from a Claude Code CLI subscription. Get one at [console.anthropic.com](https://console.anthropic.com/). API usage is billed per-token. +This gateway uses the **Claude Code CLI** (`claude -p`) instead of the Anthropic API directly. This means: -The SDK also supports: -- **Amazon Bedrock**: set `CLAUDE_CODE_USE_BEDROCK=1` + AWS credentials -- **Google Vertex AI**: set `CLAUDE_CODE_USE_VERTEX=1` + GCP credentials -- **Azure AI Foundry**: set `CLAUDE_CODE_USE_FOUNDRY=1` + Azure credentials +- You use your existing **Claude Code subscription** — no separate API key needed +- Just sign in with `claude` in your terminal and you're good to go +- The gateway shells out to `claude -p "prompt" --output-format json` for each query +- Set `CLAUDE_CLI_PATH` if `claude` isn't in your PATH ## License diff --git a/package.json b/package.json index 6f37123..1941777 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,6 @@ }, "license": "MIT", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.50", "discord.js": "^14.25.1", "node-cron": "^4.2.1" }, diff --git a/src/agent-runtime.ts b/src/agent-runtime.ts index 5f24db3..6be4187 100644 --- a/src/agent-runtime.ts +++ b/src/agent-runtime.ts @@ -1,4 +1,4 @@ -import { query } from "@anthropic-ai/claude-agent-sdk"; +import { execFile } from "node:child_process"; import type { Event, MessagePayload, HeartbeatPayload, CronPayload, HookPayload } from "./event-queue.js"; import type { MarkdownConfigLoader } from "./markdown-config-loader.js"; import type { SystemPromptAssembler } from "./system-prompt-assembler.js"; @@ -14,6 +14,18 @@ export interface EventResult { error?: string; } +interface ClaudeJsonResponse { + type: string; + subtype?: string; + session_id?: string; + result?: string; + is_error?: boolean; + duration_ms?: number; + duration_api_ms?: number; + num_turns?: number; + cost_usd?: number; +} + export class AgentRuntime { private config: GatewayConfig; private sessionManager: SessionManager; @@ -36,25 +48,19 @@ export class AgentRuntime { } async processEvent(event: Event): Promise { - // Fire agent_begin inline hook await this.hookManager.fireInline("agent_begin", this); try { const result = await this.processEventCore(event); - - // Fire agent_stop inline hook await this.hookManager.fireInline("agent_stop", this); - return result; } catch (error) { - // Fire agent_stop even on error await this.hookManager.fireInline("agent_stop", this); throw error; } } private async processEventCore(event: Event): Promise { - // Read all markdown configs fresh const configs = await this.markdownConfigLoader.loadAll(this.config.configDir); const systemPrompt = this.systemPromptAssembler.assemble(configs); @@ -79,147 +85,128 @@ export class AgentRuntime { const existingSessionId = this.sessionManager.getSessionId(channelId); try { - const responseText = await this.executeQuery( - promptText, - systemPrompt, - channelId, - existingSessionId, - ); + const response = await this.executeClaude(promptText, systemPrompt, existingSessionId); + + if (response.session_id && channelId) { + this.sessionManager.setSessionId(channelId, response.session_id); + } return { - responseText, + responseText: response.result, targetChannelId: channelId, - sessionId: this.sessionManager.getSessionId(channelId), + sessionId: response.session_id, }; } catch (error) { - // If session is corrupted, remove the binding if (this.isSessionCorrupted(error)) { this.sessionManager.removeSession(channelId); } - - const errorMessage = formatErrorForUser(error); - return { - error: errorMessage, - targetChannelId: channelId, - }; + return { error: formatErrorForUser(error), targetChannelId: channelId }; } } private async processHeartbeat(event: Event, systemPrompt: string): Promise { const payload = event.payload as HeartbeatPayload; - const targetChannelId = this.config.outputChannelId; - try { - const responseText = await this.executeQuery( - payload.instruction, - systemPrompt, - ); - - return { - responseText, - targetChannelId, - }; + const response = await this.executeClaude(payload.instruction, systemPrompt); + return { responseText: response.result, targetChannelId: this.config.outputChannelId }; } catch (error) { - const errorMessage = formatErrorForUser(error); - return { error: errorMessage, targetChannelId }; + return { error: formatErrorForUser(error), targetChannelId: this.config.outputChannelId }; } } private async processCron(event: Event, systemPrompt: string): Promise { const payload = event.payload as CronPayload; - const targetChannelId = this.config.outputChannelId; - try { - const responseText = await this.executeQuery( - payload.instruction, - systemPrompt, - ); - - return { - responseText, - targetChannelId, - }; + const response = await this.executeClaude(payload.instruction, systemPrompt); + return { responseText: response.result, targetChannelId: this.config.outputChannelId }; } catch (error) { - const errorMessage = formatErrorForUser(error); - return { error: errorMessage, targetChannelId }; + return { error: formatErrorForUser(error), targetChannelId: this.config.outputChannelId }; } } private async processHook(event: Event, systemPrompt: string): Promise { const payload = event.payload as HookPayload; - - // If no instruction, return empty result (no query needed) - if (!payload.instruction) { - return {}; - } - - const targetChannelId = this.config.outputChannelId; - + if (!payload.instruction) return {}; try { - const responseText = await this.executeQuery( - payload.instruction, - systemPrompt, - ); - - return { - responseText, - targetChannelId, - }; + const response = await this.executeClaude(payload.instruction, systemPrompt); + return { responseText: response.result, targetChannelId: this.config.outputChannelId }; } catch (error) { - const errorMessage = formatErrorForUser(error); - return { error: errorMessage, targetChannelId }; + return { error: formatErrorForUser(error), targetChannelId: this.config.outputChannelId }; } } - private async executeQuery( + private executeClaude( promptText: string, systemPrompt: string, - channelId?: string, - existingSessionId?: string, - ): Promise { - const options: Record = { - allowedTools: this.config.allowedTools, - permissionMode: this.config.permissionMode, - systemPrompt, - }; + sessionId?: string, + ): Promise { + return new Promise((resolve, reject) => { + const args: string[] = [ + "-p", promptText, + "--output-format", "json", + "--system-prompt", systemPrompt, + "--dangerously-skip-permissions", + ]; - if (existingSessionId) { - options.resume = existingSessionId; - } + // Resume existing session + if (sessionId) { + args.push("--resume", sessionId); + } - const queryPromise = this.consumeStream( - query({ prompt: promptText, options }), - channelId, - ); + // Allowed tools + if (this.config.allowedTools.length > 0) { + args.push("--allowedTools", ...this.config.allowedTools); + } - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { - reject(new Error("Query timed out")); - }, this.config.queryTimeoutMs); + // Max turns to prevent runaway loops + args.push("--max-turns", "25"); + + const child = execFile( + this.config.claudeCliPath, + args, + { + timeout: this.config.queryTimeoutMs, + maxBuffer: 10 * 1024 * 1024, // 10MB + encoding: "utf-8", + }, + (error, stdout, stderr) => { + if (error) { + if (error.killed || error.code === "ETIMEDOUT") { + reject(new Error("Query timed out")); + return; + } + reject(new Error(`Claude CLI error: ${stderr || error.message}`)); + return; + } + + try { + // The JSON output may contain multiple JSON objects (one per line for stream-json) + // With --output-format json, the last line is the final result + const lines = stdout.trim().split("\n"); + const lastLine = lines[lines.length - 1]; + const parsed = JSON.parse(lastLine) as ClaudeJsonResponse; + + if (parsed.is_error) { + reject(new Error(`Claude error: ${parsed.result ?? "Unknown error"}`)); + return; + } + + resolve(parsed); + } catch (parseError) { + // If JSON parsing fails, treat stdout as plain text result + resolve({ + type: "result", + result: stdout.trim(), + }); + } + }, + ); + + // Handle child process errors + child.on("error", (err) => { + reject(new Error(`Failed to spawn Claude CLI: ${err.message}`)); + }); }); - - return Promise.race([queryPromise, timeoutPromise]); - } - - private async consumeStream( - stream: AsyncIterable, - channelId?: string, - ): Promise { - let resultText = ""; - - for await (const message of stream) { - // Store session_id from init messages - if (message.type === "system" && message.subtype === "init" && channelId) { - this.sessionManager.setSessionId(channelId, message.session_id); - } - - // Collect result text - if ("result" in message && typeof message.result === "string") { - resultText += message.result; - } - } - - return resultText; } private isSessionCorrupted(error: unknown): boolean { diff --git a/src/config.ts b/src/config.ts index 2011c68..a138660 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,6 @@ export interface GatewayConfig { discordBotToken: string; - anthropicApiKey: string; + claudeCliPath: string; allowedTools: string[]; permissionMode: string; queryTimeoutMs: number; @@ -16,15 +16,14 @@ const DEFAULT_QUERY_TIMEOUT_MS = 120_000; const DEFAULT_MAX_CONCURRENT_QUERIES = 5; const DEFAULT_CONFIG_DIR = "./config"; const DEFAULT_MAX_QUEUE_DEPTH = 100; +const DEFAULT_CLAUDE_CLI_PATH = "claude"; export function loadConfig(): GatewayConfig { const missing: string[] = []; const discordBotToken = process.env.DISCORD_BOT_TOKEN; - const anthropicApiKey = process.env.ANTHROPIC_API_KEY; if (!discordBotToken) missing.push("DISCORD_BOT_TOKEN"); - if (!anthropicApiKey) missing.push("ANTHROPIC_API_KEY"); if (missing.length > 0) { throw new Error( @@ -32,6 +31,8 @@ export function loadConfig(): GatewayConfig { ); } + const claudeCliPath = process.env.CLAUDE_CLI_PATH ?? DEFAULT_CLAUDE_CLI_PATH; + const allowedToolsRaw = process.env.ALLOWED_TOOLS; const allowedTools = allowedToolsRaw ? allowedToolsRaw.split(",").map((t) => t.trim()) @@ -57,7 +58,7 @@ export function loadConfig(): GatewayConfig { return { discordBotToken: discordBotToken!, - anthropicApiKey: anthropicApiKey!, + claudeCliPath, allowedTools, permissionMode, queryTimeoutMs, diff --git a/tests/unit/config-loader.test.ts b/tests/unit/config-loader.test.ts index 7ec1acb..6c32c36 100644 --- a/tests/unit/config-loader.test.ts +++ b/tests/unit/config-loader.test.ts @@ -8,7 +8,6 @@ describe("loadConfig", () => { process.env = { ...originalEnv }; // Set required vars by default process.env.DISCORD_BOT_TOKEN = "test-discord-token"; - process.env.ANTHROPIC_API_KEY = "test-anthropic-key"; }); afterEach(() => { @@ -18,11 +17,11 @@ describe("loadConfig", () => { it("should load required environment variables", () => { const config = loadConfig(); expect(config.discordBotToken).toBe("test-discord-token"); - expect(config.anthropicApiKey).toBe("test-anthropic-key"); }); it("should apply default values for optional config", () => { const config = loadConfig(); + expect(config.claudeCliPath).toBe("claude"); expect(config.allowedTools).toEqual(["Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "WebFetch"]); expect(config.permissionMode).toBe("bypassPermissions"); expect(config.queryTimeoutMs).toBe(120_000); @@ -51,6 +50,7 @@ describe("loadConfig", () => { process.env.CONFIG_DIR = "/custom/config"; process.env.MAX_QUEUE_DEPTH = "200"; process.env.OUTPUT_CHANNEL_ID = "123456789"; + process.env.CLAUDE_CLI_PATH = "/usr/local/bin/claude"; const config = loadConfig(); expect(config.permissionMode).toBe("default"); @@ -59,6 +59,7 @@ describe("loadConfig", () => { expect(config.configDir).toBe("/custom/config"); expect(config.maxQueueDepth).toBe(200); expect(config.outputChannelId).toBe("123456789"); + expect(config.claudeCliPath).toBe("/usr/local/bin/claude"); }); it("should throw when DISCORD_BOT_TOKEN is missing", () => { @@ -66,16 +67,10 @@ describe("loadConfig", () => { expect(() => loadConfig()).toThrow("DISCORD_BOT_TOKEN"); }); - it("should throw when ANTHROPIC_API_KEY is missing", () => { - delete process.env.ANTHROPIC_API_KEY; - expect(() => loadConfig()).toThrow("ANTHROPIC_API_KEY"); - }); - it("should list all missing required variables in error message", () => { delete process.env.DISCORD_BOT_TOKEN; - delete process.env.ANTHROPIC_API_KEY; expect(() => loadConfig()).toThrow( - "Missing required environment variables: DISCORD_BOT_TOKEN, ANTHROPIC_API_KEY" + "Missing required environment variables: DISCORD_BOT_TOKEN" ); }); });