claude-threads 0.13.0 → 0.14.1

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +78 -28
  3. package/dist/claude/cli.d.ts +8 -0
  4. package/dist/claude/cli.js +16 -8
  5. package/dist/config/migration.d.ts +45 -0
  6. package/dist/config/migration.js +35 -0
  7. package/dist/config.d.ts +12 -18
  8. package/dist/config.js +7 -94
  9. package/dist/git/worktree.d.ts +0 -4
  10. package/dist/git/worktree.js +1 -1
  11. package/dist/index.js +31 -13
  12. package/dist/logo.d.ts +3 -20
  13. package/dist/logo.js +7 -23
  14. package/dist/mcp/permission-server.js +61 -112
  15. package/dist/onboarding.js +262 -137
  16. package/dist/persistence/session-store.d.ts +8 -2
  17. package/dist/persistence/session-store.js +41 -16
  18. package/dist/platform/client.d.ts +140 -0
  19. package/dist/platform/formatter.d.ts +74 -0
  20. package/dist/platform/index.d.ts +11 -0
  21. package/dist/platform/index.js +8 -0
  22. package/dist/platform/mattermost/client.d.ts +70 -0
  23. package/dist/{mattermost → platform/mattermost}/client.js +117 -34
  24. package/dist/platform/mattermost/formatter.d.ts +20 -0
  25. package/dist/platform/mattermost/formatter.js +46 -0
  26. package/dist/platform/mattermost/permission-api.d.ts +10 -0
  27. package/dist/platform/mattermost/permission-api.js +139 -0
  28. package/dist/platform/mattermost/types.js +1 -0
  29. package/dist/platform/permission-api-factory.d.ts +11 -0
  30. package/dist/platform/permission-api-factory.js +21 -0
  31. package/dist/platform/permission-api.d.ts +67 -0
  32. package/dist/platform/permission-api.js +8 -0
  33. package/dist/platform/types.d.ts +70 -0
  34. package/dist/platform/types.js +7 -0
  35. package/dist/session/commands.d.ts +52 -0
  36. package/dist/session/commands.js +323 -0
  37. package/dist/session/events.d.ts +25 -0
  38. package/dist/session/events.js +368 -0
  39. package/dist/session/index.d.ts +7 -0
  40. package/dist/session/index.js +6 -0
  41. package/dist/session/lifecycle.d.ts +70 -0
  42. package/dist/session/lifecycle.js +456 -0
  43. package/dist/session/manager.d.ts +96 -0
  44. package/dist/session/manager.js +537 -0
  45. package/dist/session/reactions.d.ts +25 -0
  46. package/dist/session/reactions.js +151 -0
  47. package/dist/session/streaming.d.ts +47 -0
  48. package/dist/session/streaming.js +152 -0
  49. package/dist/session/types.d.ts +78 -0
  50. package/dist/session/types.js +9 -0
  51. package/dist/session/worktree.d.ts +56 -0
  52. package/dist/session/worktree.js +339 -0
  53. package/dist/update-notifier.js +10 -0
  54. package/dist/{mattermost → utils}/emoji.d.ts +3 -3
  55. package/dist/{mattermost → utils}/emoji.js +3 -3
  56. package/dist/utils/emoji.test.d.ts +1 -0
  57. package/dist/utils/tool-formatter.d.ts +10 -13
  58. package/dist/utils/tool-formatter.js +48 -43
  59. package/dist/utils/tool-formatter.test.js +67 -52
  60. package/package.json +4 -3
  61. package/dist/claude/session.d.ts +0 -256
  62. package/dist/claude/session.js +0 -1964
  63. package/dist/mattermost/client.d.ts +0 -56
  64. /package/dist/{mattermost/emoji.test.d.ts → platform/client.js} +0 -0
  65. /package/dist/{mattermost/types.js → platform/formatter.js} +0 -0
  66. /package/dist/{mattermost → platform/mattermost}/types.d.ts +0 -0
  67. /package/dist/{mattermost → utils}/emoji.test.js +0 -0
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Git worktree management utilities
3
+ *
4
+ * Handles worktree prompts, creation, switching, and cleanup.
5
+ */
6
+ import { isGitRepository, getRepositoryRoot, hasUncommittedChanges, listWorktrees as listGitWorktrees, createWorktree as createGitWorktree, removeWorktree as removeGitWorktree, getWorktreeDir, findWorktreeByBranch, isValidBranchName, } from '../git/worktree.js';
7
+ import { ClaudeCli } from '../claude/cli.js';
8
+ import { randomUUID } from 'crypto';
9
+ /**
10
+ * Check if we should prompt the user to create a worktree.
11
+ * Returns the reason for prompting, or null if we shouldn't prompt.
12
+ */
13
+ export async function shouldPromptForWorktree(session, worktreeMode, hasOtherSessionInRepo) {
14
+ // Skip if worktree mode is off
15
+ if (worktreeMode === 'off')
16
+ return null;
17
+ // Skip if user disabled prompts for this session
18
+ if (session.worktreePromptDisabled)
19
+ return null;
20
+ // Skip if already in a worktree
21
+ if (session.worktreeInfo)
22
+ return null;
23
+ // Check if we're in a git repository
24
+ const isRepo = await isGitRepository(session.workingDir);
25
+ if (!isRepo)
26
+ return null;
27
+ // For 'require' mode, always prompt
28
+ if (worktreeMode === 'require') {
29
+ return 'require';
30
+ }
31
+ // For 'prompt' mode, check conditions
32
+ // Condition 1: uncommitted changes
33
+ const hasChanges = await hasUncommittedChanges(session.workingDir);
34
+ if (hasChanges)
35
+ return 'uncommitted';
36
+ // Condition 2: another session using the same repo
37
+ const repoRoot = await getRepositoryRoot(session.workingDir);
38
+ const hasConcurrent = hasOtherSessionInRepo(repoRoot, session.threadId);
39
+ if (hasConcurrent)
40
+ return 'concurrent';
41
+ return null;
42
+ }
43
+ /**
44
+ * Post the worktree prompt message to the user.
45
+ */
46
+ export async function postWorktreePrompt(session, reason, registerPost) {
47
+ let message;
48
+ switch (reason) {
49
+ case 'uncommitted':
50
+ message = `🌿 **This repo has uncommitted changes.**\n` +
51
+ `Reply with a branch name to work in an isolated worktree, or react with ❌ to continue in the main repo.`;
52
+ break;
53
+ case 'concurrent':
54
+ message = `⚠️ **Another session is already using this repo.**\n` +
55
+ `Reply with a branch name to work in an isolated worktree, or react with ❌ to continue anyway.`;
56
+ break;
57
+ case 'require':
58
+ message = `🌿 **This deployment requires working in a worktree.**\n` +
59
+ `Please reply with a branch name to continue.`;
60
+ break;
61
+ default:
62
+ message = `🌿 **Would you like to work in an isolated worktree?**\n` +
63
+ `Reply with a branch name, or react with ❌ to continue in the main repo.`;
64
+ }
65
+ // Create post with ❌ reaction option (except for 'require' mode)
66
+ // Use 'x' emoji name, not Unicode ❌ character
67
+ const reactionOptions = reason === 'require' ? [] : ['x'];
68
+ const post = await session.platform.createInteractivePost(message, reactionOptions, session.threadId);
69
+ // Track the post for reaction handling
70
+ session.worktreePromptPostId = post.id;
71
+ registerPost(post.id, session.threadId);
72
+ }
73
+ /**
74
+ * Handle user providing a branch name in response to worktree prompt.
75
+ * Returns true if handled (whether successful or not).
76
+ */
77
+ export async function handleWorktreeBranchResponse(session, branchName, username, createAndSwitch) {
78
+ if (!session.pendingWorktreePrompt)
79
+ return false;
80
+ // Only session owner can respond
81
+ if (session.startedBy !== username && !session.platform.isUserAllowed(username)) {
82
+ return false;
83
+ }
84
+ // Validate branch name
85
+ if (!isValidBranchName(branchName)) {
86
+ await session.platform.createPost(`❌ Invalid branch name: \`${branchName}\`. Please provide a valid git branch name.`, session.threadId);
87
+ return true; // We handled it, but need another response
88
+ }
89
+ // Create and switch to worktree
90
+ await createAndSwitch(session.threadId, branchName, username);
91
+ return true;
92
+ }
93
+ /**
94
+ * Handle ❌ reaction on worktree prompt - skip worktree and continue in main repo.
95
+ */
96
+ export async function handleWorktreeSkip(session, username, persistSession, startTyping) {
97
+ if (!session.pendingWorktreePrompt)
98
+ return;
99
+ // Only session owner can skip
100
+ if (session.startedBy !== username && !session.platform.isUserAllowed(username)) {
101
+ return;
102
+ }
103
+ // Update the prompt post
104
+ if (session.worktreePromptPostId) {
105
+ try {
106
+ await session.platform.updatePost(session.worktreePromptPostId, `✅ Continuing in main repo (skipped by @${username})`);
107
+ }
108
+ catch (err) {
109
+ console.error(' ⚠️ Failed to update worktree prompt:', err);
110
+ }
111
+ }
112
+ // Clear pending state
113
+ session.pendingWorktreePrompt = false;
114
+ session.worktreePromptPostId = undefined;
115
+ const queuedPrompt = session.queuedPrompt;
116
+ session.queuedPrompt = undefined;
117
+ // Persist updated state
118
+ persistSession(session);
119
+ // Now send the queued message to Claude
120
+ if (queuedPrompt && session.claude.isRunning()) {
121
+ session.claude.sendMessage(queuedPrompt);
122
+ startTyping(session);
123
+ }
124
+ }
125
+ /**
126
+ * Create a new worktree and switch the session to it.
127
+ */
128
+ export async function createAndSwitchToWorktree(session, branch, username, options) {
129
+ // Only session owner or admins can manage worktrees
130
+ if (session.startedBy !== username && !session.platform.isUserAllowed(username)) {
131
+ await session.platform.createPost(`⚠️ Only @${session.startedBy} or allowed users can manage worktrees`, session.threadId);
132
+ return;
133
+ }
134
+ // Check if we're in a git repo
135
+ const isRepo = await isGitRepository(session.workingDir);
136
+ if (!isRepo) {
137
+ await session.platform.createPost(`❌ Current directory is not a git repository`, session.threadId);
138
+ return;
139
+ }
140
+ // Get repo root
141
+ const repoRoot = await getRepositoryRoot(session.workingDir);
142
+ // Check if worktree already exists for this branch
143
+ const existing = await findWorktreeByBranch(repoRoot, branch);
144
+ if (existing && !existing.isMain) {
145
+ await session.platform.createPost(`⚠️ Worktree for branch \`${branch}\` already exists at \`${existing.path}\`. Use \`!worktree switch ${branch}\` to switch to it.`, session.threadId);
146
+ return;
147
+ }
148
+ const shortId = session.threadId.substring(0, 8);
149
+ console.log(` 🌿 Session (${shortId}…) creating worktree for branch ${branch}`);
150
+ // Generate worktree path
151
+ const worktreePath = getWorktreeDir(repoRoot, branch);
152
+ try {
153
+ // Create the worktree
154
+ await createGitWorktree(repoRoot, branch, worktreePath);
155
+ // Update the prompt post if it exists
156
+ if (session.worktreePromptPostId) {
157
+ try {
158
+ await session.platform.updatePost(session.worktreePromptPostId, `✅ Created worktree for \`${branch}\``);
159
+ }
160
+ catch (err) {
161
+ console.error(' ⚠️ Failed to update worktree prompt:', err);
162
+ }
163
+ }
164
+ // Clear pending state
165
+ const wasPending = session.pendingWorktreePrompt;
166
+ session.pendingWorktreePrompt = false;
167
+ session.worktreePromptPostId = undefined;
168
+ const queuedPrompt = session.queuedPrompt;
169
+ session.queuedPrompt = undefined;
170
+ // Store worktree info
171
+ session.worktreeInfo = {
172
+ repoRoot,
173
+ worktreePath,
174
+ branch,
175
+ };
176
+ // Update working directory
177
+ session.workingDir = worktreePath;
178
+ // If Claude is already running, restart it in the new directory
179
+ if (session.claude.isRunning()) {
180
+ options.stopTyping(session);
181
+ session.isRestarting = true;
182
+ session.claude.kill();
183
+ // Flush any pending content
184
+ await options.flush(session);
185
+ session.currentPostId = null;
186
+ session.pendingContent = '';
187
+ // Generate new session ID for fresh start in new directory
188
+ // (Claude CLI sessions are tied to working directory, can't resume across directories)
189
+ const newSessionId = randomUUID();
190
+ session.claudeSessionId = newSessionId;
191
+ // Create new CLI with new working directory
192
+ const cliOptions = {
193
+ workingDir: worktreePath,
194
+ threadId: session.threadId,
195
+ skipPermissions: options.skipPermissions || !session.forceInteractivePermissions,
196
+ sessionId: newSessionId,
197
+ resume: false, // Fresh start - can't resume across directories
198
+ chrome: options.chromeEnabled,
199
+ platformConfig: session.platform.getMcpConfig(),
200
+ };
201
+ session.claude = new ClaudeCli(cliOptions);
202
+ // Rebind event handlers (use sessionId which is the composite key)
203
+ session.claude.on('event', (e) => options.handleEvent(session.sessionId, e));
204
+ session.claude.on('exit', (code) => options.handleExit(session.sessionId, code));
205
+ // Start the new CLI
206
+ session.claude.start();
207
+ }
208
+ // Update session header
209
+ await options.updateSessionHeader(session);
210
+ // Post confirmation
211
+ const shortWorktreePath = worktreePath.replace(process.env.HOME || '', '~');
212
+ await session.platform.createPost(`✅ **Created worktree** for branch \`${branch}\`\n📁 Working directory: \`${shortWorktreePath}\`\n*Claude Code restarted in the new worktree*`, session.threadId);
213
+ // Update activity and persist
214
+ session.lastActivityAt = new Date();
215
+ session.timeoutWarningPosted = false;
216
+ options.persistSession(session);
217
+ // If there was a queued prompt (from initial session start), send it now
218
+ if (wasPending && queuedPrompt && session.claude.isRunning()) {
219
+ session.claude.sendMessage(queuedPrompt);
220
+ options.startTyping(session);
221
+ }
222
+ console.log(` 🌿 Session (${shortId}…) switched to worktree ${branch} at ${shortWorktreePath}`);
223
+ }
224
+ catch (err) {
225
+ console.error(` ❌ Failed to create worktree:`, err);
226
+ await session.platform.createPost(`❌ Failed to create worktree: ${err instanceof Error ? err.message : String(err)}`, session.threadId);
227
+ }
228
+ }
229
+ /**
230
+ * Switch to an existing worktree.
231
+ */
232
+ export async function switchToWorktree(session, branchOrPath, username, changeDirectory) {
233
+ // Only session owner or admins can manage worktrees
234
+ if (session.startedBy !== username && !session.platform.isUserAllowed(username)) {
235
+ await session.platform.createPost(`⚠️ Only @${session.startedBy} or allowed users can manage worktrees`, session.threadId);
236
+ return;
237
+ }
238
+ // Get current repo root
239
+ const repoRoot = session.worktreeInfo?.repoRoot || await getRepositoryRoot(session.workingDir);
240
+ // Find the worktree
241
+ const worktrees = await listGitWorktrees(repoRoot);
242
+ const target = worktrees.find(wt => wt.branch === branchOrPath ||
243
+ wt.path === branchOrPath ||
244
+ wt.path.endsWith(branchOrPath));
245
+ if (!target) {
246
+ await session.platform.createPost(`❌ Worktree not found: \`${branchOrPath}\`. Use \`!worktree list\` to see available worktrees.`, session.threadId);
247
+ return;
248
+ }
249
+ // Use changeDirectory logic to switch
250
+ await changeDirectory(session.threadId, target.path, username);
251
+ // Update worktree info
252
+ session.worktreeInfo = {
253
+ repoRoot,
254
+ worktreePath: target.path,
255
+ branch: target.branch,
256
+ };
257
+ }
258
+ /**
259
+ * List all worktrees for the current repository.
260
+ */
261
+ export async function listWorktreesCommand(session) {
262
+ // Check if we're in a git repo
263
+ const isRepo = await isGitRepository(session.workingDir);
264
+ if (!isRepo) {
265
+ await session.platform.createPost(`❌ Current directory is not a git repository`, session.threadId);
266
+ return;
267
+ }
268
+ // Get repo root (either from worktree info or current dir)
269
+ const repoRoot = session.worktreeInfo?.repoRoot || await getRepositoryRoot(session.workingDir);
270
+ const worktrees = await listGitWorktrees(repoRoot);
271
+ if (worktrees.length === 0) {
272
+ await session.platform.createPost(`📋 No worktrees found for this repository`, session.threadId);
273
+ return;
274
+ }
275
+ const shortRepoRoot = repoRoot.replace(process.env.HOME || '', '~');
276
+ let message = `📋 **Worktrees for** \`${shortRepoRoot}\`:\n\n`;
277
+ for (const wt of worktrees) {
278
+ const shortPath = wt.path.replace(process.env.HOME || '', '~');
279
+ const isCurrent = session.workingDir === wt.path;
280
+ const marker = isCurrent ? ' ← current' : '';
281
+ const label = wt.isMain ? '(main repository)' : '';
282
+ message += `• \`${wt.branch}\` → \`${shortPath}\` ${label}${marker}\n`;
283
+ }
284
+ await session.platform.createPost(message, session.threadId);
285
+ }
286
+ /**
287
+ * Remove a worktree.
288
+ */
289
+ export async function removeWorktreeCommand(session, branchOrPath, username) {
290
+ // Only session owner or admins can manage worktrees
291
+ if (session.startedBy !== username && !session.platform.isUserAllowed(username)) {
292
+ await session.platform.createPost(`⚠️ Only @${session.startedBy} or allowed users can manage worktrees`, session.threadId);
293
+ return;
294
+ }
295
+ // Get current repo root
296
+ const repoRoot = session.worktreeInfo?.repoRoot || await getRepositoryRoot(session.workingDir);
297
+ // Find the worktree
298
+ const worktrees = await listGitWorktrees(repoRoot);
299
+ const target = worktrees.find(wt => wt.branch === branchOrPath ||
300
+ wt.path === branchOrPath ||
301
+ wt.path.endsWith(branchOrPath));
302
+ if (!target) {
303
+ await session.platform.createPost(`❌ Worktree not found: \`${branchOrPath}\`. Use \`!worktree list\` to see available worktrees.`, session.threadId);
304
+ return;
305
+ }
306
+ // Can't remove the main repository
307
+ if (target.isMain) {
308
+ await session.platform.createPost(`❌ Cannot remove the main repository. Use \`!worktree remove\` only for worktrees.`, session.threadId);
309
+ return;
310
+ }
311
+ // Can't remove the current working directory
312
+ if (session.workingDir === target.path) {
313
+ await session.platform.createPost(`❌ Cannot remove the current working directory. Switch to another worktree first.`, session.threadId);
314
+ return;
315
+ }
316
+ try {
317
+ await removeGitWorktree(repoRoot, target.path);
318
+ const shortPath = target.path.replace(process.env.HOME || '', '~');
319
+ await session.platform.createPost(`✅ Removed worktree \`${target.branch}\` at \`${shortPath}\``, session.threadId);
320
+ console.log(` 🗑️ Removed worktree ${target.branch} at ${shortPath}`);
321
+ }
322
+ catch (err) {
323
+ console.error(` ❌ Failed to remove worktree:`, err);
324
+ await session.platform.createPost(`❌ Failed to remove worktree: ${err instanceof Error ? err.message : String(err)}`, session.threadId);
325
+ }
326
+ }
327
+ /**
328
+ * Disable worktree prompts for a session.
329
+ */
330
+ export async function disableWorktreePrompt(session, username, persistSession) {
331
+ // Only session owner or admins can manage worktrees
332
+ if (session.startedBy !== username && !session.platform.isUserAllowed(username)) {
333
+ await session.platform.createPost(`⚠️ Only @${session.startedBy} or allowed users can manage worktrees`, session.threadId);
334
+ return;
335
+ }
336
+ session.worktreePromptDisabled = true;
337
+ persistSession(session);
338
+ await session.platform.createPost(`✅ Worktree prompts disabled for this session`, session.threadId);
339
+ }
@@ -2,6 +2,7 @@ import updateNotifier from 'update-notifier';
2
2
  import { readFileSync } from 'fs';
3
3
  import { resolve } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
+ import semver from 'semver';
5
6
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
6
7
  let cachedUpdateInfo;
7
8
  export function checkForUpdates() {
@@ -26,6 +27,15 @@ Run: npm install -g claude-threads`,
26
27
  }
27
28
  }
28
29
  // Returns update info if available, for posting to Mattermost
30
+ // Only returns if latest > current (handles stale cache edge case)
29
31
  export function getUpdateInfo() {
32
+ if (!cachedUpdateInfo)
33
+ return undefined;
34
+ // Sanity check: only show update if latest is actually newer
35
+ const current = cachedUpdateInfo.current;
36
+ const latest = cachedUpdateInfo.latest;
37
+ if (current && latest && semver.gte(current, latest)) {
38
+ return undefined; // Current is same or newer, no update needed
39
+ }
30
40
  return cachedUpdateInfo;
31
41
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Emoji constants and helpers for Mattermost reactions
2
+ * Emoji constants and helpers for chat platform reactions
3
3
  *
4
- * Centralized place for all emoji-related logic to avoid duplication
5
- * across session.ts and permission-server.ts
4
+ * Platform-agnostic emoji utilities used across session management,
5
+ * permission handling, and user interactions.
6
6
  */
7
7
  /** Emoji names that indicate approval */
8
8
  export declare const APPROVAL_EMOJIS: readonly ["+1", "thumbsup"];
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Emoji constants and helpers for Mattermost reactions
2
+ * Emoji constants and helpers for chat platform reactions
3
3
  *
4
- * Centralized place for all emoji-related logic to avoid duplication
5
- * across session.ts and permission-server.ts
4
+ * Platform-agnostic emoji utilities used across session management,
5
+ * permission handling, and user interactions.
6
6
  */
7
7
  /** Emoji names that indicate approval */
8
8
  export const APPROVAL_EMOJIS = ['+1', 'thumbsup'];
@@ -0,0 +1 @@
1
+ export {};
@@ -1,10 +1,14 @@
1
1
  /**
2
- * Tool formatting utilities for displaying Claude tool calls in Mattermost
2
+ * Tool formatting utilities for displaying Claude tool calls in chat platforms
3
3
  *
4
4
  * This module provides shared formatting logic used by both:
5
- * - src/claude/session.ts (main bot)
5
+ * - src/session/events.ts (main bot)
6
6
  * - src/mcp/permission-server.ts (MCP permission handler)
7
+ *
8
+ * Uses PlatformFormatter abstraction to support different markdown dialects
9
+ * (e.g., Mattermost standard markdown vs Slack mrkdwn).
7
10
  */
11
+ import type { PlatformFormatter } from '../platform/formatter.js';
8
12
  export interface ToolInput {
9
13
  [key: string]: unknown;
10
14
  }
@@ -30,27 +34,20 @@ export declare function parseMcpToolName(toolName: string): {
30
34
  tool: string;
31
35
  } | null;
32
36
  /**
33
- * Format a tool use for display in Mattermost
37
+ * Format a tool use for display in chat platforms
34
38
  *
35
39
  * @param toolName - The name of the tool being called
36
40
  * @param input - The tool input parameters
37
41
  * @param options - Formatting options
38
42
  * @returns Formatted string or null if the tool should not be displayed
39
43
  */
40
- export declare function formatToolUse(toolName: string, input: ToolInput, options?: FormatOptions): string | null;
44
+ export declare function formatToolUse(toolName: string, input: ToolInput, formatter: PlatformFormatter, options?: FormatOptions): string | null;
41
45
  /**
42
46
  * Format tool info for permission prompts (simpler format)
43
47
  *
44
48
  * @param toolName - The name of the tool
45
49
  * @param input - The tool input parameters
50
+ * @param formatter - Platform-specific markdown formatter
46
51
  * @returns Formatted string for permission prompts
47
52
  */
48
- export declare function formatToolForPermission(toolName: string, input: ToolInput): string;
49
- /**
50
- * Format Claude in Chrome tool calls
51
- *
52
- * @param tool - The Chrome tool name (after mcp__claude-in-chrome__)
53
- * @param input - The tool input parameters
54
- * @returns Formatted string for display
55
- */
56
- export declare function formatChromeToolUse(tool: string, input: ToolInput): string;
53
+ export declare function formatToolForPermission(toolName: string, input: ToolInput, formatter: PlatformFormatter): string;
@@ -1,9 +1,12 @@
1
1
  /**
2
- * Tool formatting utilities for displaying Claude tool calls in Mattermost
2
+ * Tool formatting utilities for displaying Claude tool calls in chat platforms
3
3
  *
4
4
  * This module provides shared formatting logic used by both:
5
- * - src/claude/session.ts (main bot)
5
+ * - src/session/events.ts (main bot)
6
6
  * - src/mcp/permission-server.ts (MCP permission handler)
7
+ *
8
+ * Uses PlatformFormatter abstraction to support different markdown dialects
9
+ * (e.g., Mattermost standard markdown vs Slack mrkdwn).
7
10
  */
8
11
  import * as Diff from 'diff';
9
12
  const DEFAULT_OPTIONS = {
@@ -39,19 +42,19 @@ export function parseMcpToolName(toolName) {
39
42
  };
40
43
  }
41
44
  /**
42
- * Format a tool use for display in Mattermost
45
+ * Format a tool use for display in chat platforms
43
46
  *
44
47
  * @param toolName - The name of the tool being called
45
48
  * @param input - The tool input parameters
46
49
  * @param options - Formatting options
47
50
  * @returns Formatted string or null if the tool should not be displayed
48
51
  */
49
- export function formatToolUse(toolName, input, options = {}) {
52
+ export function formatToolUse(toolName, input, formatter, options = {}) {
50
53
  const opts = { ...DEFAULT_OPTIONS, ...options };
51
54
  const short = (p) => shortenPath(p);
52
55
  switch (toolName) {
53
56
  case 'Read':
54
- return `📄 **Read** \`${short(input.file_path)}\``;
57
+ return `📄 ${formatter.formatBold('Read')} ${formatter.formatCode(short(input.file_path))}`;
55
58
  case 'Edit': {
56
59
  const filePath = short(input.file_path);
57
60
  const oldStr = input.old_string || '';
@@ -84,15 +87,14 @@ export function formatToolUse(toolName, input, options = {}) {
84
87
  break;
85
88
  }
86
89
  const totalLines = changes.reduce((sum, c) => sum + c.value.split('\n').length - 1, 0);
87
- let diff = `✏️ **Edit** \`${filePath}\`\n\`\`\`diff\n`;
88
- diff += diffLines.join('\n');
90
+ let diff = `✏️ ${formatter.formatBold('Edit')} ${formatter.formatCode(filePath)}\n${formatter.formatCodeBlock(diffLines.join('\n'), 'diff')}`;
89
91
  if (totalLines > maxLines) {
90
- diff += `\n... (+${totalLines - maxLines} more lines)`;
92
+ diff = `✏️ ${formatter.formatBold('Edit')} ${formatter.formatCode(filePath)}\n`;
93
+ diff += formatter.formatCodeBlock(diffLines.join('\n') + `\n... (+${totalLines - maxLines} more lines)`, 'diff');
91
94
  }
92
- diff += '\n```';
93
95
  return diff;
94
96
  }
95
- return `✏️ **Edit** \`${filePath}\``;
97
+ return `✏️ ${formatter.formatBold('Edit')} ${formatter.formatCode(filePath)}`;
96
98
  }
97
99
  case 'Write': {
98
100
  const filePath = short(input.file_path);
@@ -103,29 +105,30 @@ export function formatToolUse(toolName, input, options = {}) {
103
105
  if (opts.detailed && content && lineCount > 0) {
104
106
  const maxLines = 6;
105
107
  const previewLines = lines.slice(0, maxLines);
106
- let preview = `📝 **Write** \`${filePath}\` *(${lineCount} lines)*\n\`\`\`\n`;
107
- preview += previewLines.join('\n');
108
+ let preview = `📝 ${formatter.formatBold('Write')} ${formatter.formatCode(filePath)} ${formatter.formatItalic(`(${lineCount} lines)`)}\n`;
108
109
  if (lineCount > maxLines) {
109
- preview += `\n... (${lineCount - maxLines} more lines)`;
110
+ preview += formatter.formatCodeBlock(previewLines.join('\n') + `\n... (${lineCount - maxLines} more lines)`);
111
+ }
112
+ else {
113
+ preview += formatter.formatCodeBlock(previewLines.join('\n'));
110
114
  }
111
- preview += '\n```';
112
115
  return preview;
113
116
  }
114
- return `📝 **Write** \`${filePath}\``;
117
+ return `📝 ${formatter.formatBold('Write')} ${formatter.formatCode(filePath)}`;
115
118
  }
116
119
  case 'Bash': {
117
120
  const cmd = (input.command || '').substring(0, opts.maxCommandLength);
118
121
  const truncated = cmd.length >= opts.maxCommandLength;
119
- return `💻 **Bash** \`${cmd}${truncated ? '...' : ''}\``;
122
+ return `💻 ${formatter.formatBold('Bash')} ${formatter.formatCode(cmd + (truncated ? '...' : ''))}`;
120
123
  }
121
124
  case 'Glob':
122
- return `🔍 **Glob** \`${input.pattern}\``;
125
+ return `🔍 ${formatter.formatBold('Glob')} ${formatter.formatCode(input.pattern)}`;
123
126
  case 'Grep':
124
- return `🔎 **Grep** \`${input.pattern}\``;
127
+ return `🔎 ${formatter.formatBold('Grep')} ${formatter.formatCode(input.pattern)}`;
125
128
  case 'Task':
126
129
  return null; // Handled specially with subagent display
127
130
  case 'EnterPlanMode':
128
- return `📋 **Planning...**`;
131
+ return `📋 ${formatter.formatBold('Planning...')}`;
129
132
  case 'ExitPlanMode':
130
133
  return null; // Handled specially with approval buttons
131
134
  case 'AskUserQuestion':
@@ -134,21 +137,21 @@ export function formatToolUse(toolName, input, options = {}) {
134
137
  return null; // Handled specially with task list display
135
138
  case 'WebFetch': {
136
139
  const url = (input.url || '').substring(0, 40);
137
- return `🌐 **Fetching** \`${url}\``;
140
+ return `🌐 ${formatter.formatBold('Fetching')} ${formatter.formatCode(url)}`;
138
141
  }
139
142
  case 'WebSearch':
140
- return `🔍 **Searching** \`${input.query}\``;
143
+ return `🔍 ${formatter.formatBold('Searching')} ${formatter.formatCode(input.query)}`;
141
144
  default: {
142
145
  // Handle MCP tools: mcp__server__tool
143
146
  const mcpParts = parseMcpToolName(toolName);
144
147
  if (mcpParts) {
145
148
  // Special formatting for Claude in Chrome tools
146
149
  if (mcpParts.server === 'claude-in-chrome') {
147
- return formatChromeToolUse(mcpParts.tool, input);
150
+ return formatChromeToolUse(mcpParts.tool, input, formatter);
148
151
  }
149
- return `🔌 **${mcpParts.tool}** *(${mcpParts.server})*`;
152
+ return `🔌 ${formatter.formatBold(mcpParts.tool)} ${formatter.formatItalic(`(${mcpParts.server})`)}`;
150
153
  }
151
- return `● **${toolName}**`;
154
+ return `● ${formatter.formatBold(toolName)}`;
152
155
  }
153
156
  }
154
157
  }
@@ -157,27 +160,28 @@ export function formatToolUse(toolName, input, options = {}) {
157
160
  *
158
161
  * @param toolName - The name of the tool
159
162
  * @param input - The tool input parameters
163
+ * @param formatter - Platform-specific markdown formatter
160
164
  * @returns Formatted string for permission prompts
161
165
  */
162
- export function formatToolForPermission(toolName, input) {
166
+ export function formatToolForPermission(toolName, input, formatter) {
163
167
  const short = (p) => shortenPath(p);
164
168
  switch (toolName) {
165
169
  case 'Read':
166
- return `📄 **Read** \`${short(input.file_path)}\``;
170
+ return `📄 ${formatter.formatBold('Read')} ${formatter.formatCode(short(input.file_path))}`;
167
171
  case 'Write':
168
- return `📝 **Write** \`${short(input.file_path)}\``;
172
+ return `📝 ${formatter.formatBold('Write')} ${formatter.formatCode(short(input.file_path))}`;
169
173
  case 'Edit':
170
- return `✏️ **Edit** \`${short(input.file_path)}\``;
174
+ return `✏️ ${formatter.formatBold('Edit')} ${formatter.formatCode(short(input.file_path))}`;
171
175
  case 'Bash': {
172
176
  const cmd = (input.command || '').substring(0, 100);
173
- return `💻 **Bash** \`${cmd}${cmd.length >= 100 ? '...' : ''}\``;
177
+ return `💻 ${formatter.formatBold('Bash')} ${formatter.formatCode(cmd + (cmd.length >= 100 ? '...' : ''))}`;
174
178
  }
175
179
  default: {
176
180
  const mcpParts = parseMcpToolName(toolName);
177
181
  if (mcpParts) {
178
- return `🔌 **${mcpParts.tool}** *(${mcpParts.server})*`;
182
+ return `🔌 ${formatter.formatBold(mcpParts.tool)} ${formatter.formatItalic(`(${mcpParts.server})`)}`;
179
183
  }
180
- return `● **${toolName}**`;
184
+ return `● ${formatter.formatBold(toolName)}`;
181
185
  }
182
186
  }
183
187
  }
@@ -186,9 +190,10 @@ export function formatToolForPermission(toolName, input) {
186
190
  *
187
191
  * @param tool - The Chrome tool name (after mcp__claude-in-chrome__)
188
192
  * @param input - The tool input parameters
193
+ * @param formatter - Platform-specific markdown formatter
189
194
  * @returns Formatted string for display
190
195
  */
191
- export function formatChromeToolUse(tool, input) {
196
+ function formatChromeToolUse(tool, input, formatter) {
192
197
  const action = input.action || '';
193
198
  const coord = input.coordinate;
194
199
  const url = input.url || '';
@@ -221,27 +226,27 @@ export function formatChromeToolUse(tool, input) {
221
226
  default:
222
227
  detail = action || 'action';
223
228
  }
224
- return `🌐 **Chrome**[computer] \`${detail}\``;
229
+ return `🌐 ${formatter.formatBold('Chrome')}[computer] ${formatter.formatCode(detail)}`;
225
230
  }
226
231
  case 'navigate':
227
- return `🌐 **Chrome**[navigate] \`${url.substring(0, 50)}${url.length > 50 ? '...' : ''}\``;
232
+ return `🌐 ${formatter.formatBold('Chrome')}[navigate] ${formatter.formatCode(url.substring(0, 50) + (url.length > 50 ? '...' : ''))}`;
228
233
  case 'tabs_context_mcp':
229
- return `🌐 **Chrome**[tabs] reading context`;
234
+ return `🌐 ${formatter.formatBold('Chrome')}[tabs] reading context`;
230
235
  case 'tabs_create_mcp':
231
- return `🌐 **Chrome**[tabs] creating new tab`;
236
+ return `🌐 ${formatter.formatBold('Chrome')}[tabs] creating new tab`;
232
237
  case 'read_page':
233
- return `🌐 **Chrome**[read_page] ${input.filter === 'interactive' ? 'interactive elements' : 'accessibility tree'}`;
238
+ return `🌐 ${formatter.formatBold('Chrome')}[read_page] ${input.filter === 'interactive' ? 'interactive elements' : 'accessibility tree'}`;
234
239
  case 'find':
235
- return `🌐 **Chrome**[find] \`${input.query || ''}\``;
240
+ return `🌐 ${formatter.formatBold('Chrome')}[find] ${formatter.formatCode(input.query || '')}`;
236
241
  case 'form_input':
237
- return `🌐 **Chrome**[form_input] setting value`;
242
+ return `🌐 ${formatter.formatBold('Chrome')}[form_input] setting value`;
238
243
  case 'get_page_text':
239
- return `🌐 **Chrome**[get_page_text] extracting content`;
244
+ return `🌐 ${formatter.formatBold('Chrome')}[get_page_text] extracting content`;
240
245
  case 'javascript_tool':
241
- return `🌐 **Chrome**[javascript] executing script`;
246
+ return `🌐 ${formatter.formatBold('Chrome')}[javascript] executing script`;
242
247
  case 'gif_creator':
243
- return `🌐 **Chrome**[gif] ${action}`;
248
+ return `🌐 ${formatter.formatBold('Chrome')}[gif] ${action}`;
244
249
  default:
245
- return `🌐 **Chrome**[${tool}]`;
250
+ return `🌐 ${formatter.formatBold('Chrome')}[${tool}]`;
246
251
  }
247
252
  }