Initial commit: Discord-Claude Gateway with event-driven agent runtime
This commit is contained in:
26
README.md
26
README.md
@@ -19,8 +19,8 @@ All inputs — Discord messages, heartbeat timers, cron jobs, lifecycle hooks
|
|||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **Node.js** 18+
|
- **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
|
- **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
|
## Quick Start
|
||||||
|
|
||||||
@@ -28,9 +28,11 @@ All inputs — Discord messages, heartbeat timers, cron jobs, lifecycle hooks
|
|||||||
# Install dependencies
|
# Install dependencies
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
|
# Make sure Claude Code CLI is installed and you're signed in
|
||||||
|
claude --version
|
||||||
|
|
||||||
# Set required environment variables
|
# Set required environment variables
|
||||||
export DISCORD_BOT_TOKEN=your-discord-bot-token
|
export DISCORD_BOT_TOKEN=your-discord-bot-token
|
||||||
export ANTHROPIC_API_KEY=your-anthropic-api-key
|
|
||||||
|
|
||||||
# Create config directory with persona files
|
# Create config directory with persona files
|
||||||
mkdir config
|
mkdir config
|
||||||
@@ -65,11 +67,11 @@ All settings are via environment variables:
|
|||||||
| Variable | Required | Default | Description |
|
| Variable | Required | Default | Description |
|
||||||
|----------|----------|---------|-------------|
|
|----------|----------|---------|-------------|
|
||||||
| `DISCORD_BOT_TOKEN` | Yes | — | Discord bot token |
|
| `DISCORD_BOT_TOKEN` | Yes | — | Discord bot token |
|
||||||
| `ANTHROPIC_API_KEY` | Yes | — | Anthropic API key from [console.anthropic.com](https://console.anthropic.com/) |
|
| `CLAUDE_CLI_PATH` | No | `claude` | Path to the Claude Code CLI binary |
|
||||||
| `ALLOWED_TOOLS` | No | `Read,Write,Edit,Glob,Grep,WebSearch,WebFetch` | Comma-separated Agent SDK tools |
|
| `ALLOWED_TOOLS` | No | `Read,Write,Edit,Glob,Grep,WebSearch,WebFetch` | Comma-separated Claude Code tools |
|
||||||
| `PERMISSION_MODE` | No | `bypassPermissions` | Agent SDK permission mode |
|
| `PERMISSION_MODE` | No | `bypassPermissions` | Claude Code permission mode |
|
||||||
| `QUERY_TIMEOUT_MS` | No | `120000` | Query timeout in milliseconds |
|
| `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 |
|
| `CONFIG_DIR` | No | `./config` | Path to markdown config directory |
|
||||||
| `MAX_QUEUE_DEPTH` | No | `100` | Max events in the queue |
|
| `MAX_QUEUE_DEPTH` | No | `100` | Max events in the queue |
|
||||||
| `OUTPUT_CHANNEL_ID` | No | — | Discord channel for heartbeat/cron output |
|
| `OUTPUT_CHANNEL_ID` | No | — | Discord channel for heartbeat/cron output |
|
||||||
@@ -196,14 +198,14 @@ npm run build
|
|||||||
npm start
|
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:
|
- You use your existing **Claude Code subscription** — no separate API key needed
|
||||||
- **Amazon Bedrock**: set `CLAUDE_CODE_USE_BEDROCK=1` + AWS credentials
|
- Just sign in with `claude` in your terminal and you're good to go
|
||||||
- **Google Vertex AI**: set `CLAUDE_CODE_USE_VERTEX=1` + GCP credentials
|
- The gateway shells out to `claude -p "prompt" --output-format json` for each query
|
||||||
- **Azure AI Foundry**: set `CLAUDE_CODE_USE_FOUNDRY=1` + Azure credentials
|
- Set `CLAUDE_CLI_PATH` if `claude` isn't in your PATH
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.50",
|
|
||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.25.1",
|
||||||
"node-cron": "^4.2.1"
|
"node-cron": "^4.2.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 { Event, MessagePayload, HeartbeatPayload, CronPayload, HookPayload } from "./event-queue.js";
|
||||||
import type { MarkdownConfigLoader } from "./markdown-config-loader.js";
|
import type { MarkdownConfigLoader } from "./markdown-config-loader.js";
|
||||||
import type { SystemPromptAssembler } from "./system-prompt-assembler.js";
|
import type { SystemPromptAssembler } from "./system-prompt-assembler.js";
|
||||||
@@ -14,6 +14,18 @@ export interface EventResult {
|
|||||||
error?: string;
|
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 {
|
export class AgentRuntime {
|
||||||
private config: GatewayConfig;
|
private config: GatewayConfig;
|
||||||
private sessionManager: SessionManager;
|
private sessionManager: SessionManager;
|
||||||
@@ -36,25 +48,19 @@ export class AgentRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async processEvent(event: Event): Promise<EventResult> {
|
async processEvent(event: Event): Promise<EventResult> {
|
||||||
// Fire agent_begin inline hook
|
|
||||||
await this.hookManager.fireInline("agent_begin", this);
|
await this.hookManager.fireInline("agent_begin", this);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.processEventCore(event);
|
const result = await this.processEventCore(event);
|
||||||
|
|
||||||
// Fire agent_stop inline hook
|
|
||||||
await this.hookManager.fireInline("agent_stop", this);
|
await this.hookManager.fireInline("agent_stop", this);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Fire agent_stop even on error
|
|
||||||
await this.hookManager.fireInline("agent_stop", this);
|
await this.hookManager.fireInline("agent_stop", this);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async processEventCore(event: Event): Promise<EventResult> {
|
private async processEventCore(event: Event): Promise<EventResult> {
|
||||||
// Read all markdown configs fresh
|
|
||||||
const configs = await this.markdownConfigLoader.loadAll(this.config.configDir);
|
const configs = await this.markdownConfigLoader.loadAll(this.config.configDir);
|
||||||
const systemPrompt = this.systemPromptAssembler.assemble(configs);
|
const systemPrompt = this.systemPromptAssembler.assemble(configs);
|
||||||
|
|
||||||
@@ -79,147 +85,128 @@ export class AgentRuntime {
|
|||||||
const existingSessionId = this.sessionManager.getSessionId(channelId);
|
const existingSessionId = this.sessionManager.getSessionId(channelId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const responseText = await this.executeQuery(
|
const response = await this.executeClaude(promptText, systemPrompt, existingSessionId);
|
||||||
promptText,
|
|
||||||
systemPrompt,
|
if (response.session_id && channelId) {
|
||||||
channelId,
|
this.sessionManager.setSessionId(channelId, response.session_id);
|
||||||
existingSessionId,
|
}
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
responseText,
|
responseText: response.result,
|
||||||
targetChannelId: channelId,
|
targetChannelId: channelId,
|
||||||
sessionId: this.sessionManager.getSessionId(channelId),
|
sessionId: response.session_id,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If session is corrupted, remove the binding
|
|
||||||
if (this.isSessionCorrupted(error)) {
|
if (this.isSessionCorrupted(error)) {
|
||||||
this.sessionManager.removeSession(channelId);
|
this.sessionManager.removeSession(channelId);
|
||||||
}
|
}
|
||||||
|
return { error: formatErrorForUser(error), targetChannelId: channelId };
|
||||||
const errorMessage = formatErrorForUser(error);
|
|
||||||
return {
|
|
||||||
error: errorMessage,
|
|
||||||
targetChannelId: channelId,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async processHeartbeat(event: Event, systemPrompt: string): Promise<EventResult> {
|
private async processHeartbeat(event: Event, systemPrompt: string): Promise<EventResult> {
|
||||||
const payload = event.payload as HeartbeatPayload;
|
const payload = event.payload as HeartbeatPayload;
|
||||||
const targetChannelId = this.config.outputChannelId;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const responseText = await this.executeQuery(
|
const response = await this.executeClaude(payload.instruction, systemPrompt);
|
||||||
payload.instruction,
|
return { responseText: response.result, targetChannelId: this.config.outputChannelId };
|
||||||
systemPrompt,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
responseText,
|
|
||||||
targetChannelId,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = formatErrorForUser(error);
|
return { error: formatErrorForUser(error), targetChannelId: this.config.outputChannelId };
|
||||||
return { error: errorMessage, targetChannelId };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async processCron(event: Event, systemPrompt: string): Promise<EventResult> {
|
private async processCron(event: Event, systemPrompt: string): Promise<EventResult> {
|
||||||
const payload = event.payload as CronPayload;
|
const payload = event.payload as CronPayload;
|
||||||
const targetChannelId = this.config.outputChannelId;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const responseText = await this.executeQuery(
|
const response = await this.executeClaude(payload.instruction, systemPrompt);
|
||||||
payload.instruction,
|
return { responseText: response.result, targetChannelId: this.config.outputChannelId };
|
||||||
systemPrompt,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
responseText,
|
|
||||||
targetChannelId,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = formatErrorForUser(error);
|
return { error: formatErrorForUser(error), targetChannelId: this.config.outputChannelId };
|
||||||
return { error: errorMessage, targetChannelId };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async processHook(event: Event, systemPrompt: string): Promise<EventResult> {
|
private async processHook(event: Event, systemPrompt: string): Promise<EventResult> {
|
||||||
const payload = event.payload as HookPayload;
|
const payload = event.payload as HookPayload;
|
||||||
|
if (!payload.instruction) return {};
|
||||||
// If no instruction, return empty result (no query needed)
|
|
||||||
if (!payload.instruction) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetChannelId = this.config.outputChannelId;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const responseText = await this.executeQuery(
|
const response = await this.executeClaude(payload.instruction, systemPrompt);
|
||||||
payload.instruction,
|
return { responseText: response.result, targetChannelId: this.config.outputChannelId };
|
||||||
systemPrompt,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
responseText,
|
|
||||||
targetChannelId,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = formatErrorForUser(error);
|
return { error: formatErrorForUser(error), targetChannelId: this.config.outputChannelId };
|
||||||
return { error: errorMessage, targetChannelId };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async executeQuery(
|
private executeClaude(
|
||||||
promptText: string,
|
promptText: string,
|
||||||
systemPrompt: string,
|
systemPrompt: string,
|
||||||
channelId?: string,
|
sessionId?: string,
|
||||||
existingSessionId?: string,
|
): Promise<ClaudeJsonResponse> {
|
||||||
): Promise<string> {
|
return new Promise((resolve, reject) => {
|
||||||
const options: Record<string, unknown> = {
|
const args: string[] = [
|
||||||
allowedTools: this.config.allowedTools,
|
"-p", promptText,
|
||||||
permissionMode: this.config.permissionMode,
|
"--output-format", "json",
|
||||||
systemPrompt,
|
"--system-prompt", systemPrompt,
|
||||||
};
|
"--dangerously-skip-permissions",
|
||||||
|
];
|
||||||
|
|
||||||
if (existingSessionId) {
|
// Resume existing session
|
||||||
options.resume = existingSessionId;
|
if (sessionId) {
|
||||||
|
args.push("--resume", sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryPromise = this.consumeStream(
|
// Allowed tools
|
||||||
query({ prompt: promptText, options }),
|
if (this.config.allowedTools.length > 0) {
|
||||||
channelId,
|
args.push("--allowedTools", ...this.config.allowedTools);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
// Handle child process errors
|
||||||
setTimeout(() => {
|
child.on("error", (err) => {
|
||||||
reject(new Error("Query timed out"));
|
reject(new Error(`Failed to spawn Claude CLI: ${err.message}`));
|
||||||
}, this.config.queryTimeoutMs);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return Promise.race([queryPromise, timeoutPromise]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async consumeStream(
|
|
||||||
stream: AsyncIterable<any>,
|
|
||||||
channelId?: string,
|
|
||||||
): Promise<string> {
|
|
||||||
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 {
|
private isSessionCorrupted(error: unknown): boolean {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export interface GatewayConfig {
|
export interface GatewayConfig {
|
||||||
discordBotToken: string;
|
discordBotToken: string;
|
||||||
anthropicApiKey: string;
|
claudeCliPath: string;
|
||||||
allowedTools: string[];
|
allowedTools: string[];
|
||||||
permissionMode: string;
|
permissionMode: string;
|
||||||
queryTimeoutMs: number;
|
queryTimeoutMs: number;
|
||||||
@@ -16,15 +16,14 @@ const DEFAULT_QUERY_TIMEOUT_MS = 120_000;
|
|||||||
const DEFAULT_MAX_CONCURRENT_QUERIES = 5;
|
const DEFAULT_MAX_CONCURRENT_QUERIES = 5;
|
||||||
const DEFAULT_CONFIG_DIR = "./config";
|
const DEFAULT_CONFIG_DIR = "./config";
|
||||||
const DEFAULT_MAX_QUEUE_DEPTH = 100;
|
const DEFAULT_MAX_QUEUE_DEPTH = 100;
|
||||||
|
const DEFAULT_CLAUDE_CLI_PATH = "claude";
|
||||||
|
|
||||||
export function loadConfig(): GatewayConfig {
|
export function loadConfig(): GatewayConfig {
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
|
|
||||||
const discordBotToken = process.env.DISCORD_BOT_TOKEN;
|
const discordBotToken = process.env.DISCORD_BOT_TOKEN;
|
||||||
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
|
||||||
|
|
||||||
if (!discordBotToken) missing.push("DISCORD_BOT_TOKEN");
|
if (!discordBotToken) missing.push("DISCORD_BOT_TOKEN");
|
||||||
if (!anthropicApiKey) missing.push("ANTHROPIC_API_KEY");
|
|
||||||
|
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
throw new Error(
|
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 allowedToolsRaw = process.env.ALLOWED_TOOLS;
|
||||||
const allowedTools = allowedToolsRaw
|
const allowedTools = allowedToolsRaw
|
||||||
? allowedToolsRaw.split(",").map((t) => t.trim())
|
? allowedToolsRaw.split(",").map((t) => t.trim())
|
||||||
@@ -57,7 +58,7 @@ export function loadConfig(): GatewayConfig {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
discordBotToken: discordBotToken!,
|
discordBotToken: discordBotToken!,
|
||||||
anthropicApiKey: anthropicApiKey!,
|
claudeCliPath,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
queryTimeoutMs,
|
queryTimeoutMs,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ describe("loadConfig", () => {
|
|||||||
process.env = { ...originalEnv };
|
process.env = { ...originalEnv };
|
||||||
// Set required vars by default
|
// Set required vars by default
|
||||||
process.env.DISCORD_BOT_TOKEN = "test-discord-token";
|
process.env.DISCORD_BOT_TOKEN = "test-discord-token";
|
||||||
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -18,11 +17,11 @@ describe("loadConfig", () => {
|
|||||||
it("should load required environment variables", () => {
|
it("should load required environment variables", () => {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
expect(config.discordBotToken).toBe("test-discord-token");
|
expect(config.discordBotToken).toBe("test-discord-token");
|
||||||
expect(config.anthropicApiKey).toBe("test-anthropic-key");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should apply default values for optional config", () => {
|
it("should apply default values for optional config", () => {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
expect(config.claudeCliPath).toBe("claude");
|
||||||
expect(config.allowedTools).toEqual(["Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "WebFetch"]);
|
expect(config.allowedTools).toEqual(["Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "WebFetch"]);
|
||||||
expect(config.permissionMode).toBe("bypassPermissions");
|
expect(config.permissionMode).toBe("bypassPermissions");
|
||||||
expect(config.queryTimeoutMs).toBe(120_000);
|
expect(config.queryTimeoutMs).toBe(120_000);
|
||||||
@@ -51,6 +50,7 @@ describe("loadConfig", () => {
|
|||||||
process.env.CONFIG_DIR = "/custom/config";
|
process.env.CONFIG_DIR = "/custom/config";
|
||||||
process.env.MAX_QUEUE_DEPTH = "200";
|
process.env.MAX_QUEUE_DEPTH = "200";
|
||||||
process.env.OUTPUT_CHANNEL_ID = "123456789";
|
process.env.OUTPUT_CHANNEL_ID = "123456789";
|
||||||
|
process.env.CLAUDE_CLI_PATH = "/usr/local/bin/claude";
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
expect(config.permissionMode).toBe("default");
|
expect(config.permissionMode).toBe("default");
|
||||||
@@ -59,6 +59,7 @@ describe("loadConfig", () => {
|
|||||||
expect(config.configDir).toBe("/custom/config");
|
expect(config.configDir).toBe("/custom/config");
|
||||||
expect(config.maxQueueDepth).toBe(200);
|
expect(config.maxQueueDepth).toBe(200);
|
||||||
expect(config.outputChannelId).toBe("123456789");
|
expect(config.outputChannelId).toBe("123456789");
|
||||||
|
expect(config.claudeCliPath).toBe("/usr/local/bin/claude");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw when DISCORD_BOT_TOKEN is missing", () => {
|
it("should throw when DISCORD_BOT_TOKEN is missing", () => {
|
||||||
@@ -66,16 +67,10 @@ describe("loadConfig", () => {
|
|||||||
expect(() => loadConfig()).toThrow("DISCORD_BOT_TOKEN");
|
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", () => {
|
it("should list all missing required variables in error message", () => {
|
||||||
delete process.env.DISCORD_BOT_TOKEN;
|
delete process.env.DISCORD_BOT_TOKEN;
|
||||||
delete process.env.ANTHROPIC_API_KEY;
|
|
||||||
expect(() => loadConfig()).toThrow(
|
expect(() => loadConfig()).toThrow(
|
||||||
"Missing required environment variables: DISCORD_BOT_TOKEN, ANTHROPIC_API_KEY"
|
"Missing required environment variables: DISCORD_BOT_TOKEN"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user