mattermost-claude-code 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/dist/claude/cli.d.ts +24 -0
- package/dist/claude/cli.js +139 -0
- package/dist/claude/session.d.ts +41 -0
- package/dist/claude/session.js +582 -0
- package/dist/claude/types.d.ts +76 -0
- package/dist/claude/types.js +3 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.js +41 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +67 -0
- package/dist/mattermost/client.d.ts +35 -0
- package/dist/mattermost/client.js +226 -0
- package/dist/mattermost/message-formatter.d.ts +28 -0
- package/dist/mattermost/message-formatter.js +244 -0
- package/dist/mattermost/types.d.ts +62 -0
- package/dist/mattermost/types.js +1 -0
- package/dist/mcp/permission-server.d.ts +2 -0
- package/dist/mcp/permission-server.js +272 -0
- package/package.json +56 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { config } from 'dotenv';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
// Load .env file from multiple locations (in order of priority)
|
|
6
|
+
const envPaths = [
|
|
7
|
+
resolve(process.cwd(), '.env'), // Current directory
|
|
8
|
+
resolve(homedir(), '.config', 'mm-claude', '.env'), // ~/.config/mm-claude/.env
|
|
9
|
+
resolve(homedir(), '.mm-claude.env'), // ~/.mm-claude.env
|
|
10
|
+
];
|
|
11
|
+
for (const envPath of envPaths) {
|
|
12
|
+
if (existsSync(envPath)) {
|
|
13
|
+
console.log(`š Loading config from: ${envPath}`);
|
|
14
|
+
config({ path: envPath });
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function requireEnv(name) {
|
|
19
|
+
const value = process.env[name];
|
|
20
|
+
if (!value) {
|
|
21
|
+
throw new Error(`Missing required environment variable: ${name}`);
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
export function loadConfig() {
|
|
26
|
+
return {
|
|
27
|
+
mattermost: {
|
|
28
|
+
url: requireEnv('MATTERMOST_URL').replace(/\/$/, ''), // Remove trailing slash
|
|
29
|
+
token: requireEnv('MATTERMOST_TOKEN'),
|
|
30
|
+
channelId: requireEnv('MATTERMOST_CHANNEL_ID'),
|
|
31
|
+
botName: process.env.MATTERMOST_BOT_NAME || 'claude-code',
|
|
32
|
+
},
|
|
33
|
+
allowedUsers: (process.env.ALLOWED_USERS || '')
|
|
34
|
+
.split(',')
|
|
35
|
+
.map(u => u.trim())
|
|
36
|
+
.filter(u => u.length > 0),
|
|
37
|
+
// SKIP_PERMISSIONS=true or --dangerously-skip-permissions flag
|
|
38
|
+
skipPermissions: process.env.SKIP_PERMISSIONS === 'true' ||
|
|
39
|
+
process.argv.includes('--dangerously-skip-permissions'),
|
|
40
|
+
};
|
|
41
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { loadConfig } from './config.js';
|
|
3
|
+
import { MattermostClient } from './mattermost/client.js';
|
|
4
|
+
import { SessionManager } from './claude/session.js';
|
|
5
|
+
async function main() {
|
|
6
|
+
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
7
|
+
console.log(`mm-claude - Share Claude Code sessions in Mattermost
|
|
8
|
+
|
|
9
|
+
Usage: cd /your/project && mm-claude`);
|
|
10
|
+
process.exit(0);
|
|
11
|
+
}
|
|
12
|
+
const workingDir = process.cwd();
|
|
13
|
+
const config = loadConfig();
|
|
14
|
+
console.log(`š mm-claude starting...`);
|
|
15
|
+
console.log(`š ${workingDir}`);
|
|
16
|
+
console.log(`š @${config.mattermost.botName} on ${config.mattermost.url}`);
|
|
17
|
+
const mattermost = new MattermostClient(config);
|
|
18
|
+
const session = new SessionManager(mattermost, workingDir, config.skipPermissions);
|
|
19
|
+
if (config.skipPermissions) {
|
|
20
|
+
console.log('ā ļø Permissions skipped (--dangerously-skip-permissions)');
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
console.log('š Interactive permissions enabled');
|
|
24
|
+
}
|
|
25
|
+
mattermost.on('message', async (post, user) => {
|
|
26
|
+
const username = user?.username || 'unknown';
|
|
27
|
+
const message = post.message;
|
|
28
|
+
const threadRoot = post.root_id || post.id;
|
|
29
|
+
// Follow-up in active thread
|
|
30
|
+
if (session.isInCurrentSessionThread(threadRoot)) {
|
|
31
|
+
if (!mattermost.isUserAllowed(username))
|
|
32
|
+
return;
|
|
33
|
+
const content = mattermost.isBotMentioned(message)
|
|
34
|
+
? mattermost.extractPrompt(message)
|
|
35
|
+
: message.trim();
|
|
36
|
+
if (content)
|
|
37
|
+
await session.sendFollowUp(content);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
// New session requires @mention
|
|
41
|
+
if (!mattermost.isBotMentioned(message))
|
|
42
|
+
return;
|
|
43
|
+
if (!mattermost.isUserAllowed(username)) {
|
|
44
|
+
await mattermost.createPost(`ā ļø @${username} is not authorized`, threadRoot);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const prompt = mattermost.extractPrompt(message);
|
|
48
|
+
if (!prompt) {
|
|
49
|
+
await mattermost.createPost(`Mention me with your request`, threadRoot);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
await session.startSession({ prompt }, username, threadRoot);
|
|
53
|
+
});
|
|
54
|
+
mattermost.on('connected', () => console.log('ā
Connected'));
|
|
55
|
+
mattermost.on('error', (e) => console.error('ā', e));
|
|
56
|
+
await mattermost.connect();
|
|
57
|
+
console.log(`š Ready! @${config.mattermost.botName}`);
|
|
58
|
+
const shutdown = () => {
|
|
59
|
+
console.log('\nš Bye');
|
|
60
|
+
session.killSession();
|
|
61
|
+
mattermost.disconnect();
|
|
62
|
+
process.exit(0);
|
|
63
|
+
};
|
|
64
|
+
process.on('SIGINT', shutdown);
|
|
65
|
+
process.on('SIGTERM', shutdown);
|
|
66
|
+
}
|
|
67
|
+
main().catch(e => { console.error(e); process.exit(1); });
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
import type { Config } from '../config.js';
|
|
3
|
+
import type { MattermostPost, MattermostUser, MattermostReaction } from './types.js';
|
|
4
|
+
export interface MattermostClientEvents {
|
|
5
|
+
connected: () => void;
|
|
6
|
+
disconnected: () => void;
|
|
7
|
+
error: (error: Error) => void;
|
|
8
|
+
message: (post: MattermostPost, user: MattermostUser | null) => void;
|
|
9
|
+
reaction: (reaction: MattermostReaction, user: MattermostUser | null) => void;
|
|
10
|
+
}
|
|
11
|
+
export declare class MattermostClient extends EventEmitter {
|
|
12
|
+
private ws;
|
|
13
|
+
private config;
|
|
14
|
+
private reconnectAttempts;
|
|
15
|
+
private maxReconnectAttempts;
|
|
16
|
+
private reconnectDelay;
|
|
17
|
+
private userCache;
|
|
18
|
+
private botUserId;
|
|
19
|
+
constructor(config: Config);
|
|
20
|
+
private api;
|
|
21
|
+
getBotUser(): Promise<MattermostUser>;
|
|
22
|
+
getUser(userId: string): Promise<MattermostUser | null>;
|
|
23
|
+
createPost(message: string, threadId?: string): Promise<MattermostPost>;
|
|
24
|
+
updatePost(postId: string, message: string): Promise<MattermostPost>;
|
|
25
|
+
addReaction(postId: string, emojiName: string): Promise<void>;
|
|
26
|
+
connect(): Promise<void>;
|
|
27
|
+
private handleEvent;
|
|
28
|
+
private scheduleReconnect;
|
|
29
|
+
isUserAllowed(username: string): boolean;
|
|
30
|
+
isBotMentioned(message: string): boolean;
|
|
31
|
+
extractPrompt(message: string): string;
|
|
32
|
+
getBotName(): string;
|
|
33
|
+
sendTyping(parentId?: string): void;
|
|
34
|
+
disconnect(): void;
|
|
35
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
export class MattermostClient extends EventEmitter {
|
|
4
|
+
ws = null;
|
|
5
|
+
config;
|
|
6
|
+
reconnectAttempts = 0;
|
|
7
|
+
maxReconnectAttempts = 10;
|
|
8
|
+
reconnectDelay = 1000;
|
|
9
|
+
userCache = new Map();
|
|
10
|
+
botUserId = null;
|
|
11
|
+
constructor(config) {
|
|
12
|
+
super();
|
|
13
|
+
this.config = config;
|
|
14
|
+
}
|
|
15
|
+
// REST API helper
|
|
16
|
+
async api(method, path, body) {
|
|
17
|
+
const url = `${this.config.mattermost.url}/api/v4${path}`;
|
|
18
|
+
const response = await fetch(url, {
|
|
19
|
+
method,
|
|
20
|
+
headers: {
|
|
21
|
+
Authorization: `Bearer ${this.config.mattermost.token}`,
|
|
22
|
+
'Content-Type': 'application/json',
|
|
23
|
+
},
|
|
24
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
25
|
+
});
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
const text = await response.text();
|
|
28
|
+
throw new Error(`Mattermost API error ${response.status}: ${text}`);
|
|
29
|
+
}
|
|
30
|
+
return response.json();
|
|
31
|
+
}
|
|
32
|
+
// Get current bot user info
|
|
33
|
+
async getBotUser() {
|
|
34
|
+
const user = await this.api('GET', '/users/me');
|
|
35
|
+
this.botUserId = user.id;
|
|
36
|
+
return user;
|
|
37
|
+
}
|
|
38
|
+
// Get user by ID (cached)
|
|
39
|
+
async getUser(userId) {
|
|
40
|
+
if (this.userCache.has(userId)) {
|
|
41
|
+
return this.userCache.get(userId);
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const user = await this.api('GET', `/users/${userId}`);
|
|
45
|
+
this.userCache.set(userId, user);
|
|
46
|
+
return user;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Post a message
|
|
53
|
+
async createPost(message, threadId) {
|
|
54
|
+
const request = {
|
|
55
|
+
channel_id: this.config.mattermost.channelId,
|
|
56
|
+
message,
|
|
57
|
+
root_id: threadId,
|
|
58
|
+
};
|
|
59
|
+
return this.api('POST', '/posts', request);
|
|
60
|
+
}
|
|
61
|
+
// Update a message (for streaming updates)
|
|
62
|
+
async updatePost(postId, message) {
|
|
63
|
+
const request = {
|
|
64
|
+
id: postId,
|
|
65
|
+
message,
|
|
66
|
+
};
|
|
67
|
+
return this.api('PUT', `/posts/${postId}`, request);
|
|
68
|
+
}
|
|
69
|
+
// Add a reaction to a post
|
|
70
|
+
async addReaction(postId, emojiName) {
|
|
71
|
+
await this.api('POST', '/reactions', {
|
|
72
|
+
user_id: this.botUserId,
|
|
73
|
+
post_id: postId,
|
|
74
|
+
emoji_name: emojiName,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
// Connect to WebSocket
|
|
78
|
+
async connect() {
|
|
79
|
+
// Get bot user first
|
|
80
|
+
await this.getBotUser();
|
|
81
|
+
console.log(`[MM] Bot user ID: ${this.botUserId}`);
|
|
82
|
+
const wsUrl = this.config.mattermost.url
|
|
83
|
+
.replace(/^http/, 'ws')
|
|
84
|
+
.concat('/api/v4/websocket');
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
this.ws = new WebSocket(wsUrl);
|
|
87
|
+
this.ws.on('open', () => {
|
|
88
|
+
console.log('[MM] WebSocket connected');
|
|
89
|
+
// Authenticate
|
|
90
|
+
this.ws.send(JSON.stringify({
|
|
91
|
+
seq: 1,
|
|
92
|
+
action: 'authentication_challenge',
|
|
93
|
+
data: { token: this.config.mattermost.token },
|
|
94
|
+
}));
|
|
95
|
+
});
|
|
96
|
+
this.ws.on('message', (data) => {
|
|
97
|
+
try {
|
|
98
|
+
const event = JSON.parse(data.toString());
|
|
99
|
+
this.handleEvent(event);
|
|
100
|
+
// Authentication success
|
|
101
|
+
if (event.event === 'hello') {
|
|
102
|
+
this.reconnectAttempts = 0;
|
|
103
|
+
this.emit('connected');
|
|
104
|
+
resolve();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
console.error('[MM] Failed to parse WebSocket message:', err);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
this.ws.on('close', () => {
|
|
112
|
+
console.log('[MM] WebSocket disconnected');
|
|
113
|
+
this.emit('disconnected');
|
|
114
|
+
this.scheduleReconnect();
|
|
115
|
+
});
|
|
116
|
+
this.ws.on('error', (err) => {
|
|
117
|
+
console.error('[MM] WebSocket error:', err);
|
|
118
|
+
this.emit('error', err);
|
|
119
|
+
reject(err);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
handleEvent(event) {
|
|
124
|
+
// Handle posted events
|
|
125
|
+
if (event.event === 'posted') {
|
|
126
|
+
const data = event.data;
|
|
127
|
+
if (!data.post)
|
|
128
|
+
return;
|
|
129
|
+
try {
|
|
130
|
+
const post = JSON.parse(data.post);
|
|
131
|
+
// Ignore messages from ourselves
|
|
132
|
+
if (post.user_id === this.botUserId)
|
|
133
|
+
return;
|
|
134
|
+
// Only handle messages in our channel
|
|
135
|
+
if (post.channel_id !== this.config.mattermost.channelId)
|
|
136
|
+
return;
|
|
137
|
+
// Get user info and emit
|
|
138
|
+
this.getUser(post.user_id).then((user) => {
|
|
139
|
+
this.emit('message', post, user);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
console.error('[MM] Failed to parse post:', err);
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// Handle reaction_added events
|
|
148
|
+
if (event.event === 'reaction_added') {
|
|
149
|
+
const data = event.data;
|
|
150
|
+
if (!data.reaction)
|
|
151
|
+
return;
|
|
152
|
+
try {
|
|
153
|
+
const reaction = JSON.parse(data.reaction);
|
|
154
|
+
// Ignore reactions from ourselves
|
|
155
|
+
if (reaction.user_id === this.botUserId)
|
|
156
|
+
return;
|
|
157
|
+
// Get user info and emit
|
|
158
|
+
this.getUser(reaction.user_id).then((user) => {
|
|
159
|
+
this.emit('reaction', reaction, user);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
console.error('[MM] Failed to parse reaction:', err);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
scheduleReconnect() {
|
|
168
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
169
|
+
console.error('[MM] Max reconnection attempts reached');
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
this.reconnectAttempts++;
|
|
173
|
+
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
|
174
|
+
console.log(`[MM] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
|
175
|
+
setTimeout(() => {
|
|
176
|
+
this.connect().catch((err) => {
|
|
177
|
+
console.error('[MM] Reconnection failed:', err);
|
|
178
|
+
});
|
|
179
|
+
}, delay);
|
|
180
|
+
}
|
|
181
|
+
// Check if user is allowed to use the bot
|
|
182
|
+
isUserAllowed(username) {
|
|
183
|
+
if (this.config.allowedUsers.length === 0) {
|
|
184
|
+
// If no allowlist configured, allow all
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
return this.config.allowedUsers.includes(username);
|
|
188
|
+
}
|
|
189
|
+
// Check if message mentions the bot
|
|
190
|
+
isBotMentioned(message) {
|
|
191
|
+
const botName = this.config.mattermost.botName;
|
|
192
|
+
// Match @botname at start or with space before
|
|
193
|
+
const mentionPattern = new RegExp(`(^|\\s)@${botName}\\b`, 'i');
|
|
194
|
+
return mentionPattern.test(message);
|
|
195
|
+
}
|
|
196
|
+
// Extract prompt from message (remove bot mention)
|
|
197
|
+
extractPrompt(message) {
|
|
198
|
+
const botName = this.config.mattermost.botName;
|
|
199
|
+
return message
|
|
200
|
+
.replace(new RegExp(`(^|\\s)@${botName}\\b`, 'gi'), ' ')
|
|
201
|
+
.trim();
|
|
202
|
+
}
|
|
203
|
+
// Get the bot name
|
|
204
|
+
getBotName() {
|
|
205
|
+
return this.config.mattermost.botName;
|
|
206
|
+
}
|
|
207
|
+
// Send typing indicator via WebSocket
|
|
208
|
+
sendTyping(parentId) {
|
|
209
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
|
|
210
|
+
return;
|
|
211
|
+
this.ws.send(JSON.stringify({
|
|
212
|
+
action: 'user_typing',
|
|
213
|
+
seq: Date.now(),
|
|
214
|
+
data: {
|
|
215
|
+
channel_id: this.config.mattermost.channelId,
|
|
216
|
+
parent_id: parentId || '',
|
|
217
|
+
},
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
disconnect() {
|
|
221
|
+
if (this.ws) {
|
|
222
|
+
this.ws.close();
|
|
223
|
+
this.ws = null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AssistantEvent, ToolUseEvent, ToolResultEvent, ResultEvent } from '../claude/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Format Claude events into Mattermost-friendly messages
|
|
4
|
+
* Styled to look similar to Claude Code CLI output
|
|
5
|
+
*/
|
|
6
|
+
export declare class MessageFormatter {
|
|
7
|
+
private pendingToolUses;
|
|
8
|
+
formatUserPrompt(username: string, prompt: string): string;
|
|
9
|
+
formatSessionStart(workingDir: string): string;
|
|
10
|
+
formatSessionEnd(result?: ResultEvent): string;
|
|
11
|
+
formatAssistantMessage(event: AssistantEvent): string;
|
|
12
|
+
formatToolUse(event: ToolUseEvent): string;
|
|
13
|
+
private formatReadTool;
|
|
14
|
+
private formatEditTool;
|
|
15
|
+
private formatWriteTool;
|
|
16
|
+
private formatBashTool;
|
|
17
|
+
private formatGlobTool;
|
|
18
|
+
private formatGrepTool;
|
|
19
|
+
private formatTaskTool;
|
|
20
|
+
private formatWebSearchTool;
|
|
21
|
+
private formatWebFetchTool;
|
|
22
|
+
private formatTodoWriteTool;
|
|
23
|
+
formatToolResult(event: ToolResultEvent): string | null;
|
|
24
|
+
formatUnauthorized(username: string): string;
|
|
25
|
+
formatError(error: string): string;
|
|
26
|
+
private truncate;
|
|
27
|
+
private shortenPath;
|
|
28
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format Claude events into Mattermost-friendly messages
|
|
3
|
+
* Styled to look similar to Claude Code CLI output
|
|
4
|
+
*/
|
|
5
|
+
export class MessageFormatter {
|
|
6
|
+
// Store tool results to show line counts, etc.
|
|
7
|
+
pendingToolUses = new Map();
|
|
8
|
+
// Format a user's prompt for display (like Claude Code's user input)
|
|
9
|
+
formatUserPrompt(username, prompt) {
|
|
10
|
+
return `> **${username}:** ${prompt}`;
|
|
11
|
+
}
|
|
12
|
+
// Format session start
|
|
13
|
+
formatSessionStart(workingDir) {
|
|
14
|
+
const shortPath = this.shortenPath(workingDir);
|
|
15
|
+
return [
|
|
16
|
+
'```',
|
|
17
|
+
`š Session started`,
|
|
18
|
+
` Working directory: ${shortPath}`,
|
|
19
|
+
'```',
|
|
20
|
+
].join('\n');
|
|
21
|
+
}
|
|
22
|
+
// Format session end with stats
|
|
23
|
+
formatSessionEnd(result) {
|
|
24
|
+
if (result?.cost_usd) {
|
|
25
|
+
const duration = result.duration_ms
|
|
26
|
+
? `${(result.duration_ms / 1000).toFixed(1)}s`
|
|
27
|
+
: '';
|
|
28
|
+
const cost = `$${result.cost_usd.toFixed(4)}`;
|
|
29
|
+
return [
|
|
30
|
+
'```',
|
|
31
|
+
`ā Session completed`,
|
|
32
|
+
` Duration: ${duration} | Cost: ${cost}`,
|
|
33
|
+
'```',
|
|
34
|
+
].join('\n');
|
|
35
|
+
}
|
|
36
|
+
return '```\nā Session completed\n```';
|
|
37
|
+
}
|
|
38
|
+
// Format assistant message content
|
|
39
|
+
formatAssistantMessage(event) {
|
|
40
|
+
const content = event.message.content;
|
|
41
|
+
const parts = [];
|
|
42
|
+
for (const block of content) {
|
|
43
|
+
if (block.type === 'text') {
|
|
44
|
+
parts.push(block.text);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return parts.join('\n');
|
|
48
|
+
}
|
|
49
|
+
// Format tool use - Claude Code style: ā ToolName(args)
|
|
50
|
+
formatToolUse(event) {
|
|
51
|
+
const { id, name, input } = event.tool_use;
|
|
52
|
+
// Store for later when we get the result
|
|
53
|
+
this.pendingToolUses.set(id, event);
|
|
54
|
+
switch (name) {
|
|
55
|
+
case 'Read':
|
|
56
|
+
return this.formatReadTool(input);
|
|
57
|
+
case 'Edit':
|
|
58
|
+
return this.formatEditTool(input);
|
|
59
|
+
case 'Write':
|
|
60
|
+
return this.formatWriteTool(input);
|
|
61
|
+
case 'Bash':
|
|
62
|
+
return this.formatBashTool(input);
|
|
63
|
+
case 'Glob':
|
|
64
|
+
return this.formatGlobTool(input);
|
|
65
|
+
case 'Grep':
|
|
66
|
+
return this.formatGrepTool(input);
|
|
67
|
+
case 'Task':
|
|
68
|
+
return this.formatTaskTool(input);
|
|
69
|
+
case 'WebSearch':
|
|
70
|
+
return this.formatWebSearchTool(input);
|
|
71
|
+
case 'WebFetch':
|
|
72
|
+
return this.formatWebFetchTool(input);
|
|
73
|
+
case 'TodoWrite':
|
|
74
|
+
return this.formatTodoWriteTool(input);
|
|
75
|
+
default:
|
|
76
|
+
return `ā **${name}**`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
formatReadTool(input) {
|
|
80
|
+
const path = input.file_path;
|
|
81
|
+
const shortPath = this.shortenPath(path);
|
|
82
|
+
return `ā **Read**(\`${shortPath}\`)`;
|
|
83
|
+
}
|
|
84
|
+
formatEditTool(input) {
|
|
85
|
+
const path = input.file_path;
|
|
86
|
+
const shortPath = this.shortenPath(path);
|
|
87
|
+
const oldStr = input.old_string;
|
|
88
|
+
const newStr = input.new_string;
|
|
89
|
+
// Count lines changed
|
|
90
|
+
const oldLines = oldStr.split('\n').length;
|
|
91
|
+
const newLines = newStr.split('\n').length;
|
|
92
|
+
// Create diff preview
|
|
93
|
+
const diffLines = [];
|
|
94
|
+
const oldPreview = oldStr.split('\n').slice(0, 3);
|
|
95
|
+
const newPreview = newStr.split('\n').slice(0, 3);
|
|
96
|
+
for (const line of oldPreview) {
|
|
97
|
+
diffLines.push(`- ${line}`);
|
|
98
|
+
}
|
|
99
|
+
if (oldLines > 3)
|
|
100
|
+
diffLines.push(` ... (${oldLines - 3} more lines)`);
|
|
101
|
+
for (const line of newPreview) {
|
|
102
|
+
diffLines.push(`+ ${line}`);
|
|
103
|
+
}
|
|
104
|
+
if (newLines > 3)
|
|
105
|
+
diffLines.push(` ... (${newLines - 3} more lines)`);
|
|
106
|
+
return [
|
|
107
|
+
`ā **Edit**(\`${shortPath}\`)`,
|
|
108
|
+
'```diff',
|
|
109
|
+
...diffLines,
|
|
110
|
+
'```',
|
|
111
|
+
].join('\n');
|
|
112
|
+
}
|
|
113
|
+
formatWriteTool(input) {
|
|
114
|
+
const path = input.file_path;
|
|
115
|
+
const shortPath = this.shortenPath(path);
|
|
116
|
+
const content = input.content;
|
|
117
|
+
const lines = content.split('\n').length;
|
|
118
|
+
return `ā **Write**(\`${shortPath}\`) - ${lines} lines`;
|
|
119
|
+
}
|
|
120
|
+
formatBashTool(input) {
|
|
121
|
+
const command = input.command;
|
|
122
|
+
const desc = input.description;
|
|
123
|
+
// Truncate long commands
|
|
124
|
+
const cmdPreview = command.length > 80
|
|
125
|
+
? command.substring(0, 77) + '...'
|
|
126
|
+
: command;
|
|
127
|
+
if (desc) {
|
|
128
|
+
return [
|
|
129
|
+
`ā **Bash**(${desc})`,
|
|
130
|
+
'```bash',
|
|
131
|
+
cmdPreview,
|
|
132
|
+
'```',
|
|
133
|
+
].join('\n');
|
|
134
|
+
}
|
|
135
|
+
return [
|
|
136
|
+
`ā **Bash**`,
|
|
137
|
+
'```bash',
|
|
138
|
+
cmdPreview,
|
|
139
|
+
'```',
|
|
140
|
+
].join('\n');
|
|
141
|
+
}
|
|
142
|
+
formatGlobTool(input) {
|
|
143
|
+
const pattern = input.pattern;
|
|
144
|
+
return `ā **Glob**(\`${pattern}\`)`;
|
|
145
|
+
}
|
|
146
|
+
formatGrepTool(input) {
|
|
147
|
+
const pattern = input.pattern;
|
|
148
|
+
const path = input.path;
|
|
149
|
+
if (path) {
|
|
150
|
+
return `ā **Grep**(\`${pattern}\` in \`${this.shortenPath(path)}\`)`;
|
|
151
|
+
}
|
|
152
|
+
return `ā **Grep**(\`${pattern}\`)`;
|
|
153
|
+
}
|
|
154
|
+
formatTaskTool(input) {
|
|
155
|
+
const desc = input.description;
|
|
156
|
+
const type = input.subagent_type;
|
|
157
|
+
if (desc) {
|
|
158
|
+
return `ā **Task**(${type || 'agent'}: ${desc})`;
|
|
159
|
+
}
|
|
160
|
+
return `ā **Task**(${type || 'agent'})`;
|
|
161
|
+
}
|
|
162
|
+
formatWebSearchTool(input) {
|
|
163
|
+
const query = input.query;
|
|
164
|
+
return `ā **WebSearch**(\`${query}\`)`;
|
|
165
|
+
}
|
|
166
|
+
formatWebFetchTool(input) {
|
|
167
|
+
const url = input.url;
|
|
168
|
+
const shortUrl = url.length > 50 ? url.substring(0, 47) + '...' : url;
|
|
169
|
+
return `ā **WebFetch**(\`${shortUrl}\`)`;
|
|
170
|
+
}
|
|
171
|
+
formatTodoWriteTool(input) {
|
|
172
|
+
const todos = input.todos;
|
|
173
|
+
if (todos && todos.length > 0) {
|
|
174
|
+
const summary = todos.slice(0, 2).map(t => t.content).join(', ');
|
|
175
|
+
const more = todos.length > 2 ? ` +${todos.length - 2} more` : '';
|
|
176
|
+
return `ā **TodoWrite**(${summary}${more})`;
|
|
177
|
+
}
|
|
178
|
+
return `ā **TodoWrite**`;
|
|
179
|
+
}
|
|
180
|
+
// Format tool result with line counts, etc.
|
|
181
|
+
formatToolResult(event) {
|
|
182
|
+
const { tool_use_id, content, is_error } = event.tool_result;
|
|
183
|
+
const toolUse = this.pendingToolUses.get(tool_use_id);
|
|
184
|
+
if (is_error) {
|
|
185
|
+
const errorMsg = typeof content === 'string'
|
|
186
|
+
? content
|
|
187
|
+
: JSON.stringify(content);
|
|
188
|
+
return ` ā³ ā ${this.truncate(errorMsg, 150)}`;
|
|
189
|
+
}
|
|
190
|
+
// Get result as string
|
|
191
|
+
const resultStr = typeof content === 'string'
|
|
192
|
+
? content
|
|
193
|
+
: JSON.stringify(content, null, 2);
|
|
194
|
+
// For Read tool, show line count
|
|
195
|
+
if (toolUse?.tool_use.name === 'Read') {
|
|
196
|
+
const lines = resultStr.split('\n').length;
|
|
197
|
+
return ` ā³ Read ${lines} lines`;
|
|
198
|
+
}
|
|
199
|
+
// For Glob, show file count
|
|
200
|
+
if (toolUse?.tool_use.name === 'Glob') {
|
|
201
|
+
const files = resultStr.split('\n').filter(l => l.trim()).length;
|
|
202
|
+
return ` ā³ Found ${files} files`;
|
|
203
|
+
}
|
|
204
|
+
// For Grep, show match count
|
|
205
|
+
if (toolUse?.tool_use.name === 'Grep') {
|
|
206
|
+
const matches = resultStr.split('\n').filter(l => l.trim()).length;
|
|
207
|
+
return ` ā³ Found ${matches} matches`;
|
|
208
|
+
}
|
|
209
|
+
// For Bash, show truncated output if not too long
|
|
210
|
+
if (toolUse?.tool_use.name === 'Bash' && resultStr.length > 0) {
|
|
211
|
+
const lines = resultStr.split('\n');
|
|
212
|
+
if (lines.length <= 5 && resultStr.length < 300) {
|
|
213
|
+
return ' ā³ ```\n' + resultStr + '\n ```';
|
|
214
|
+
}
|
|
215
|
+
return ` ā³ Output: ${lines.length} lines`;
|
|
216
|
+
}
|
|
217
|
+
// Skip showing most results to avoid noise
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
// Format unauthorized user message
|
|
221
|
+
formatUnauthorized(username) {
|
|
222
|
+
return `ā ļø Sorry @${username}, you're not authorized to use this bot.`;
|
|
223
|
+
}
|
|
224
|
+
// Format error message
|
|
225
|
+
formatError(error) {
|
|
226
|
+
return `ā **Error:** ${error}`;
|
|
227
|
+
}
|
|
228
|
+
// Helper to truncate strings
|
|
229
|
+
truncate(str, maxLength) {
|
|
230
|
+
const clean = str.replace(/\n/g, ' ').trim();
|
|
231
|
+
if (clean.length <= maxLength)
|
|
232
|
+
return clean;
|
|
233
|
+
return clean.substring(0, maxLength - 3) + '...';
|
|
234
|
+
}
|
|
235
|
+
// Helper to shorten file paths
|
|
236
|
+
shortenPath(path) {
|
|
237
|
+
// Replace home directory with ~
|
|
238
|
+
const home = process.env.HOME || '/Users/anneschuth';
|
|
239
|
+
if (path.startsWith(home)) {
|
|
240
|
+
return '~' + path.substring(home.length);
|
|
241
|
+
}
|
|
242
|
+
return path;
|
|
243
|
+
}
|
|
244
|
+
}
|