Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,8 @@ ALLOWED_USER_ID=your_discord_user_id

# Base folder path where Claude Code will operate
# Channel names will be appended to this path
BASE_FOLDER=/Users/your-user-name/repos
BASE_FOLDER=/Users/your-user-name/repos

# MCP Server Port (optional, defaults to 3001)
# Change this if port 3001 is already in use
MCP_SERVER_PORT=3001
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add blank line at end of file.

The dotenv-linter correctly flags a missing blank line at the end of the file, which is a common convention for text files.

Apply this diff:

 MCP_SERVER_PORT=3001
+
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
MCP_SERVER_PORT=3001
MCP_SERVER_PORT=3001
🧰 Tools
🪛 dotenv-linter (4.0.0)

[warning] 14-14: [EndingBlankLine] No blank line at the end of the file

(EndingBlankLine)

🤖 Prompt for AI Agents
In .env.example around line 14, the file is missing a trailing blank line; add a
single newline character at the end of the file so the file terminates with a
blank line (save the file with an ending newline).

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ coverage
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
log.txt

# dotenv environment variable files
.env
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,29 @@ Bot: 🔧 LS (path: .)
- Shows real-time tool usage and responses
- Only responds to the configured `ALLOWED_USER_ID`

## Troubleshooting

### Claude Code Hooks

If you have Claude Code hooks configured (e.g., audio notifications in `~/.claude/settings.json`), they may interfere with the Discord bot. The bot automatically sets `CLAUDE_DISABLE_HOOKS=1` when spawning Claude processes.

To make your hooks respect this, add a check at the beginning of your hook scripts:

```bash
#!/bin/bash
# Example: ~/.claude/claude-sound-notification.sh

# Skip hook if running from Discord bot
if [ "$CLAUDE_DISABLE_HOOKS" = "1" ]; then
exit 0
fi

# Your existing hook code (e.g., play sound)
mplayer -really-quiet /path/to/sound.mp3
```

This ensures hooks run normally in your terminal but are skipped when using the Discord bot.

For detailed setup instructions, troubleshooting, and development information, see [CONTRIBUTING.md](CONTRIBUTING.md).

## License
Expand Down
5 changes: 3 additions & 2 deletions mcp-bridge.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ const { Transform } = require('stream');
// Debug: Log environment variables at startup
console.error(`MCP Bridge startup: DISCORD_CHANNEL_ID=${process.env.DISCORD_CHANNEL_ID}, DISCORD_CHANNEL_NAME=${process.env.DISCORD_CHANNEL_NAME}, DISCORD_USER_ID=${process.env.DISCORD_USER_ID}`);

const MCP_SERVER_URL = 'http://localhost:3001/mcp';
const MCP_SERVER_PORT = process.env.MCP_SERVER_PORT || '3001';
const MCP_SERVER_URL = `http://localhost:${MCP_SERVER_PORT}/mcp`;

// Transform stream to handle MCP messages
const mcpTransform = new Transform({
Expand Down Expand Up @@ -48,7 +49,7 @@ const mcpTransform = new Transform({

const options = {
hostname: 'localhost',
port: 3001,
port: parseInt(MCP_SERVER_PORT),
path: '/mcp',
method: 'POST',
headers
Expand Down
26 changes: 19 additions & 7 deletions src/claude/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ import type { SDKMessage } from "../types/index.js";
import { buildClaudeCommand, type DiscordContext } from "../utils/shell.js";
import { DatabaseManager } from "../db/database.js";

/**
* Truncate text to fit Discord embed description limit (4096 characters)
*/
function truncateForEmbed(text: string, maxLength: number = 4096): string {
if (text.length <= maxLength) {
return text;
}
return text.substring(0, maxLength - 50) + '\n...\n[Message truncated]';
}

export class ClaudeManager {
private db: DatabaseManager;
private channelMessages = new Map<string, any>();
Expand Down Expand Up @@ -103,6 +113,7 @@ export class ClaudeManager {
env: {
...process.env,
SHELL: "/bin/bash",
CLAUDE_DISABLE_HOOKS: "1", // Disable audio/hooks for Discord bot usage
},
});

Expand Down Expand Up @@ -174,8 +185,6 @@ export class ClaudeManager {
} else if (parsed.type === "result") {
this.handleResultMessage(channelId, parsed).then(() => {
clearTimeout(timeout);
claude.kill("SIGTERM");
this.channelProcesses.delete(channelId);
}).catch(console.error);
} else if (parsed.type === "system") {
console.log("System message:", parsed.subtype);
Expand All @@ -195,18 +204,21 @@ export class ClaudeManager {
claude.on("close", (code) => {
console.log(`Claude process exited with code ${code}`);
clearTimeout(timeout);

// Ensure cleanup on process close
this.channelProcesses.delete(channelId);

if (code !== 0 && code !== null) {
// Only show error for actual failure codes (not 0, not null, not 143)
// 143 = SIGTERM which can be normal shutdown
if (code !== 0 && code !== null && code !== 143) {
// Process failed - send error embed to Discord
const channel = this.channelMessages.get(channelId)?.channel;
if (channel) {
const errorEmbed = new EmbedBuilder()
.setTitle("❌ Claude Code Failed")
.setDescription(`Process exited with code: ${code}`)
.setColor(0xFF0000); // Red for error

channel.send({ embeds: [errorEmbed] }).catch(console.error);
}
}
Expand All @@ -226,9 +238,9 @@ export class ClaudeManager {
if (channel) {
const warningEmbed = new EmbedBuilder()
.setTitle("⚠️ Warning")
.setDescription(stderrOutput.trim())
.setDescription(truncateForEmbed(stderrOutput.trim()))
.setColor(0xFFA500); // Orange for warnings

channel.send({ embeds: [warningEmbed] }).catch(console.error);
}
}
Expand All @@ -246,7 +258,7 @@ export class ClaudeManager {
if (channel) {
const processErrorEmbed = new EmbedBuilder()
.setTitle("❌ Process Error")
.setDescription(error.message)
.setDescription(truncateForEmbed(error.message))
.setColor(0xFF0000); // Red for errors

channel.send({ embeds: [processErrorEmbed] }).catch(console.error);
Expand Down
9 changes: 6 additions & 3 deletions src/utils/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ export function buildClaudeCommand(
"--output-format",
"stream-json",
"--model",
"sonnet",
"claude-sonnet-4-5-20250929", // Latest Sonnet 4.5
"--permission-mode",
"acceptEdits", // Automatically accept edits without prompting
"-p",
escapedPrompt,
"--verbose",
Expand All @@ -41,7 +43,7 @@ export function buildClaudeCommand(
// Add session-specific MCP configuration
commandParts.push("--mcp-config", sessionMcpConfigPath);
commandParts.push("--permission-prompt-tool", "mcp__discord-permissions__approve_tool");

// Add allowed tools - we'll let the MCP server handle permissions
commandParts.push("--allowedTools", "mcp__discord-permissions");

Expand Down Expand Up @@ -70,8 +72,9 @@ function createSessionMcpConfig(discordContext?: DiscordContext): string {
command: "node",
args: [bridgeScriptPath],
env: {
MCP_SERVER_PORT: process.env.MCP_SERVER_PORT || "3001",
DISCORD_CHANNEL_ID: discordContext?.channelId || "unknown",
DISCORD_CHANNEL_NAME: discordContext?.channelName || "unknown",
DISCORD_CHANNEL_NAME: discordContext?.channelName || "unknown",
DISCORD_USER_ID: discordContext?.userId || "unknown",
DISCORD_MESSAGE_ID: discordContext?.messageId || ""
}
Expand Down
137 changes: 132 additions & 5 deletions test/claude/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ vi.mock('../../src/db/database.js', () => ({
DatabaseManager: vi.fn()
}));

vi.mock('../../src/utils/shell.js', () => ({
buildClaudeCommand: vi.fn((workingDir: string, prompt: string) =>
`cd ${workingDir} && claude -p "${prompt}"`
),
escapeShellString: vi.fn((str: string) => `'${str}'`)
}));

describe('ClaudeManager', () => {
let manager: ClaudeManager;
let mockDb: any;
Expand Down Expand Up @@ -91,15 +98,15 @@ describe('ClaudeManager', () => {
});

describe('setDiscordMessage', () => {
it('should set discord message and initialize responses', () => {
it('should set discord message and initialize tool calls', () => {
const mockMessage = { edit: vi.fn() };
manager.setDiscordMessage('channel-1', mockMessage);

const channelMessages = (manager as any).channelMessages;
const channelResponses = (manager as any).channelResponses;
const channelToolCalls = (manager as any).channelToolCalls;

expect(channelMessages.get('channel-1')).toBe(mockMessage);
expect(channelResponses.get('channel-1')).toEqual({ embeds: [], textContent: "" });
expect(channelToolCalls.get('channel-1')).toBeInstanceOf(Map);
});
});

Expand Down Expand Up @@ -198,4 +205,124 @@ describe('ClaudeManager', () => {
expect(mockDb.close).toHaveBeenCalled();
});
});

describe('process exit code handling', () => {
it('should not show error for exit code 0 (success)', async () => {
vi.mocked(fs.existsSync).mockReturnValue(true);

const mockChannel = { send: vi.fn() };
manager.setDiscordMessage('channel-1', { channel: mockChannel });

const mockProcess = {
pid: 12345,
stdin: { end: vi.fn() },
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn(),
kill: vi.fn()
};

const { spawn } = await import('child_process');
vi.mocked(spawn).mockReturnValue(mockProcess as any);

manager.reserveChannel('channel-1', undefined, {});
await manager.runClaudeCode('channel-1', 'test-channel', 'test prompt');

// Simulate process close with exit code 0
const closeHandler = mockProcess.on.mock.calls.find(call => call[0] === 'close')?.[1];
if (closeHandler) closeHandler(0);

// Should not send error message
expect(mockChannel.send).not.toHaveBeenCalledWith(
expect.objectContaining({
embeds: expect.arrayContaining([
expect.objectContaining({
data: expect.objectContaining({
title: '❌ Claude Code Failed'
})
})
])
})
);
});

it('should not show error for exit code 143 (SIGTERM)', async () => {
vi.mocked(fs.existsSync).mockReturnValue(true);

const mockChannel = { send: vi.fn() };
manager.setDiscordMessage('channel-1', { channel: mockChannel });

const mockProcess = {
pid: 12345,
stdin: { end: vi.fn() },
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn(),
kill: vi.fn()
};

const { spawn } = await import('child_process');
vi.mocked(spawn).mockReturnValue(mockProcess as any);

manager.reserveChannel('channel-1', undefined, {});
await manager.runClaudeCode('channel-1', 'test-channel', 'test prompt');

// Simulate process close with exit code 143 (SIGTERM)
const closeHandler = mockProcess.on.mock.calls.find(call => call[0] === 'close')?.[1];
if (closeHandler) closeHandler(143);

// Should not send error message for SIGTERM
expect(mockChannel.send).not.toHaveBeenCalledWith(
expect.objectContaining({
embeds: expect.arrayContaining([
expect.objectContaining({
data: expect.objectContaining({
title: '❌ Claude Code Failed'
})
})
])
})
);
});

it('should show error for actual failure codes', async () => {
vi.mocked(fs.existsSync).mockReturnValue(true);

const mockChannel = { send: vi.fn().mockResolvedValue({}) };
manager.setDiscordMessage('channel-1', { channel: mockChannel });

const mockProcess = {
pid: 12345,
stdin: { end: vi.fn() },
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn(),
kill: vi.fn()
};

const { spawn } = await import('child_process');
vi.mocked(spawn).mockReturnValue(mockProcess as any);

manager.reserveChannel('channel-1', undefined, {});
await manager.runClaudeCode('channel-1', 'test-channel', 'test prompt');

// Simulate process close with exit code 1 (error)
const closeHandler = mockProcess.on.mock.calls.find(call => call[0] === 'close')?.[1];
if (closeHandler) closeHandler(1);

// Should send error message for actual failures
expect(mockChannel.send).toHaveBeenCalledWith(
expect.objectContaining({
embeds: expect.arrayContaining([
expect.objectContaining({
data: expect.objectContaining({
title: '❌ Claude Code Failed',
description: 'Process exited with code: 1'
})
})
])
})
);
});
});
});