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/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
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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
+ }