claude-threads 0.12.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +473 -0
  2. package/LICENSE +21 -0
  3. package/README.md +303 -0
  4. package/dist/changelog.d.ts +20 -0
  5. package/dist/changelog.js +134 -0
  6. package/dist/claude/cli.d.ts +42 -0
  7. package/dist/claude/cli.js +173 -0
  8. package/dist/claude/session.d.ts +256 -0
  9. package/dist/claude/session.js +1964 -0
  10. package/dist/config.d.ts +27 -0
  11. package/dist/config.js +94 -0
  12. package/dist/git/worktree.d.ts +50 -0
  13. package/dist/git/worktree.js +228 -0
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +371 -0
  16. package/dist/logo.d.ts +31 -0
  17. package/dist/logo.js +57 -0
  18. package/dist/mattermost/api.d.ts +85 -0
  19. package/dist/mattermost/api.js +124 -0
  20. package/dist/mattermost/api.test.d.ts +1 -0
  21. package/dist/mattermost/api.test.js +319 -0
  22. package/dist/mattermost/client.d.ts +56 -0
  23. package/dist/mattermost/client.js +321 -0
  24. package/dist/mattermost/emoji.d.ts +43 -0
  25. package/dist/mattermost/emoji.js +65 -0
  26. package/dist/mattermost/emoji.test.d.ts +1 -0
  27. package/dist/mattermost/emoji.test.js +131 -0
  28. package/dist/mattermost/types.d.ts +71 -0
  29. package/dist/mattermost/types.js +1 -0
  30. package/dist/mcp/permission-server.d.ts +2 -0
  31. package/dist/mcp/permission-server.js +201 -0
  32. package/dist/onboarding.d.ts +1 -0
  33. package/dist/onboarding.js +116 -0
  34. package/dist/persistence/session-store.d.ts +65 -0
  35. package/dist/persistence/session-store.js +127 -0
  36. package/dist/update-notifier.d.ts +3 -0
  37. package/dist/update-notifier.js +31 -0
  38. package/dist/utils/logger.d.ts +34 -0
  39. package/dist/utils/logger.js +42 -0
  40. package/dist/utils/logger.test.d.ts +1 -0
  41. package/dist/utils/logger.test.js +121 -0
  42. package/dist/utils/tool-formatter.d.ts +56 -0
  43. package/dist/utils/tool-formatter.js +247 -0
  44. package/dist/utils/tool-formatter.test.d.ts +1 -0
  45. package/dist/utils/tool-formatter.test.js +357 -0
  46. package/package.json +85 -0
package/dist/index.js ADDED
@@ -0,0 +1,371 @@
1
+ #!/usr/bin/env node
2
+ import { program } from 'commander';
3
+ import { loadConfig, configExists } from './config.js';
4
+ import { runOnboarding } from './onboarding.js';
5
+ import { MattermostClient } from './mattermost/client.js';
6
+ import { SessionManager } from './claude/session.js';
7
+ import { readFileSync } from 'fs';
8
+ import { dirname, resolve } from 'path';
9
+ import { fileURLToPath } from 'url';
10
+ import { checkForUpdates } from './update-notifier.js';
11
+ import { getReleaseNotes, formatReleaseNotes } from './changelog.js';
12
+ import { printLogo } from './logo.js';
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8'));
15
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
16
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
17
+ const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
18
+ // Define CLI options
19
+ program
20
+ .name('claude-threads')
21
+ .version(pkg.version)
22
+ .description('Share Claude Code sessions in Mattermost')
23
+ .option('--url <url>', 'Mattermost server URL')
24
+ .option('--token <token>', 'Mattermost bot token')
25
+ .option('--channel <id>', 'Mattermost channel ID')
26
+ .option('--bot-name <name>', 'Bot mention name (default: claude-code)')
27
+ .option('--allowed-users <users>', 'Comma-separated allowed usernames')
28
+ .option('--skip-permissions', 'Skip interactive permission prompts')
29
+ .option('--no-skip-permissions', 'Enable interactive permission prompts (override env)')
30
+ .option('--chrome', 'Enable Claude in Chrome integration')
31
+ .option('--no-chrome', 'Disable Claude in Chrome integration')
32
+ .option('--debug', 'Enable debug logging')
33
+ .parse();
34
+ const opts = program.opts();
35
+ // Check if required args are provided via CLI
36
+ function hasRequiredCliArgs(args) {
37
+ return !!(args.url && args.token && args.channel);
38
+ }
39
+ async function main() {
40
+ // Check for updates (non-blocking, shows notification if available)
41
+ checkForUpdates();
42
+ // Set debug mode from CLI flag
43
+ if (opts.debug) {
44
+ process.env.DEBUG = '1';
45
+ }
46
+ // Build CLI args object
47
+ const cliArgs = {
48
+ url: opts.url,
49
+ token: opts.token,
50
+ channel: opts.channel,
51
+ botName: opts.botName,
52
+ allowedUsers: opts.allowedUsers,
53
+ skipPermissions: opts.skipPermissions,
54
+ chrome: opts.chrome,
55
+ };
56
+ // Check if we need onboarding
57
+ if (!configExists() && !hasRequiredCliArgs(opts)) {
58
+ await runOnboarding();
59
+ }
60
+ const workingDir = process.cwd();
61
+ const config = loadConfig(cliArgs);
62
+ // Print ASCII logo
63
+ printLogo();
64
+ // Startup info
65
+ console.log(dim(` v${pkg.version}`));
66
+ console.log('');
67
+ console.log(` 📂 ${cyan(workingDir)}`);
68
+ console.log(` 💬 ${cyan('@' + config.mattermost.botName)}`);
69
+ console.log(` 🌐 ${dim(config.mattermost.url)}`);
70
+ if (config.skipPermissions) {
71
+ console.log(` ⚠️ ${dim('Permissions disabled')}`);
72
+ }
73
+ else {
74
+ console.log(` 🔐 ${dim('Interactive permissions')}`);
75
+ }
76
+ if (config.chrome) {
77
+ console.log(` 🌐 ${dim('Chrome integration enabled')}`);
78
+ }
79
+ console.log('');
80
+ const mattermost = new MattermostClient(config);
81
+ const session = new SessionManager(mattermost, workingDir, config.skipPermissions, config.chrome, config.worktreeMode);
82
+ mattermost.on('message', async (post, user) => {
83
+ try {
84
+ const username = user?.username || 'unknown';
85
+ const message = post.message;
86
+ const threadRoot = post.root_id || post.id;
87
+ // Check for !kill command FIRST - works anywhere, even as the first message
88
+ const lowerMessage = message.trim().toLowerCase();
89
+ if (lowerMessage === '!kill' || (mattermost.isBotMentioned(message) && mattermost.extractPrompt(message).toLowerCase() === '!kill')) {
90
+ if (!mattermost.isUserAllowed(username)) {
91
+ await mattermost.createPost('⛔ Only authorized users can use `!kill`', threadRoot);
92
+ return;
93
+ }
94
+ // Notify all active sessions before killing
95
+ for (const tid of session.getActiveThreadIds()) {
96
+ try {
97
+ await mattermost.createPost(`🔴 **EMERGENCY SHUTDOWN** by @${username}`, tid);
98
+ }
99
+ catch { /* ignore */ }
100
+ }
101
+ console.log(` 🔴 EMERGENCY SHUTDOWN initiated by @${username}`);
102
+ session.killAllSessionsAndUnpersist();
103
+ mattermost.disconnect();
104
+ process.exit(1);
105
+ }
106
+ // Follow-up in active thread
107
+ if (session.isInSessionThread(threadRoot)) {
108
+ // If message starts with @mention to someone else, ignore it (side conversation)
109
+ // Note: Mattermost usernames can contain letters, numbers, hyphens, periods, and underscores
110
+ const mentionMatch = message.trim().match(/^@([\w.-]+)/);
111
+ if (mentionMatch && mentionMatch[1].toLowerCase() !== mattermost.getBotName().toLowerCase()) {
112
+ return; // Side conversation, don't interrupt
113
+ }
114
+ const content = mattermost.isBotMentioned(message)
115
+ ? mattermost.extractPrompt(message)
116
+ : message.trim();
117
+ const lowerContent = content.toLowerCase();
118
+ // Check for stop/cancel commands (only from allowed users)
119
+ // Note: Using ! prefix instead of / to avoid Mattermost slash command interception
120
+ if (lowerContent === '!stop' || lowerContent === 'stop' ||
121
+ lowerContent === '!cancel' || lowerContent === 'cancel') {
122
+ if (session.isUserAllowedInSession(threadRoot, username)) {
123
+ await session.cancelSession(threadRoot, username);
124
+ }
125
+ return;
126
+ }
127
+ // Check for !escape/!interrupt commands (soft interrupt, keeps session alive)
128
+ if (lowerContent === '!escape' || lowerContent === '!interrupt') {
129
+ if (session.isUserAllowedInSession(threadRoot, username)) {
130
+ await session.interruptSession(threadRoot, username);
131
+ }
132
+ return;
133
+ }
134
+ // Note: !kill is handled at the top level, before session thread check
135
+ // Check for !help command
136
+ if (lowerContent === '!help' || lowerContent === 'help') {
137
+ await mattermost.createPost(`**Available commands:**\n\n` +
138
+ `| Command | Description |\n` +
139
+ `|:--------|:------------|\n` +
140
+ `| \`!help\` | Show this help message |\n` +
141
+ `| \`!release-notes\` | Show release notes for current version |\n` +
142
+ `| \`!context\` | Show context usage (tokens used/remaining) |\n` +
143
+ `| \`!cost\` | Show token usage and cost for this session |\n` +
144
+ `| \`!compact\` | Compress context to free up space |\n` +
145
+ `| \`!cd <path>\` | Change working directory (restarts Claude) |\n` +
146
+ `| \`!worktree <branch>\` | Create and switch to a git worktree |\n` +
147
+ `| \`!worktree list\` | List all worktrees for the repo |\n` +
148
+ `| \`!worktree switch <branch>\` | Switch to an existing worktree |\n` +
149
+ `| \`!worktree remove <branch>\` | Remove a worktree |\n` +
150
+ `| \`!worktree off\` | Disable worktree prompts for this session |\n` +
151
+ `| \`!invite @user\` | Invite a user to this session |\n` +
152
+ `| \`!kick @user\` | Remove an invited user |\n` +
153
+ `| \`!permissions interactive\` | Enable interactive permissions |\n` +
154
+ `| \`!escape\` | Interrupt current task (session stays active) |\n` +
155
+ `| \`!stop\` | Stop this session |\n` +
156
+ `| \`!kill\` | Emergency shutdown (kills ALL sessions, exits bot) |\n\n` +
157
+ `**Reactions:**\n` +
158
+ `- 👍 Approve action · ✅ Approve all · 👎 Deny\n` +
159
+ `- ⏸️ Interrupt current task (session stays active)\n` +
160
+ `- ❌ or 🛑 Stop session`, threadRoot);
161
+ return;
162
+ }
163
+ // Check for !release-notes command
164
+ if (lowerContent === '!release-notes' || lowerContent === '!changelog') {
165
+ const notes = getReleaseNotes(pkg.version);
166
+ if (notes) {
167
+ await mattermost.createPost(formatReleaseNotes(notes), threadRoot);
168
+ }
169
+ else {
170
+ await mattermost.createPost(`📋 **claude-threads v${pkg.version}**\n\nRelease notes not available. See [GitHub releases](https://github.com/anneschuth/claude-threads/releases).`, threadRoot);
171
+ }
172
+ return;
173
+ }
174
+ // Check for !invite command
175
+ const inviteMatch = content.match(/^!invite\s+@?([\w.-]+)/i);
176
+ if (inviteMatch) {
177
+ await session.inviteUser(threadRoot, inviteMatch[1], username);
178
+ return;
179
+ }
180
+ // Check for !kick command
181
+ const kickMatch = content.match(/^!kick\s+@?([\w.-]+)/i);
182
+ if (kickMatch) {
183
+ await session.kickUser(threadRoot, kickMatch[1], username);
184
+ return;
185
+ }
186
+ // Check for !permissions command
187
+ const permMatch = content.match(/^!permissions?\s+(interactive|auto)/i);
188
+ if (permMatch) {
189
+ const mode = permMatch[1].toLowerCase();
190
+ if (mode === 'interactive') {
191
+ await session.enableInteractivePermissions(threadRoot, username);
192
+ }
193
+ else {
194
+ // Can't upgrade to auto - that would be less secure
195
+ await mattermost.createPost(`⚠️ Cannot upgrade to auto permissions - can only downgrade to interactive`, threadRoot);
196
+ }
197
+ return;
198
+ }
199
+ // Check for !cd command
200
+ const cdMatch = content.match(/^!cd\s+(.+)/i);
201
+ if (cdMatch) {
202
+ await session.changeDirectory(threadRoot, cdMatch[1].trim(), username);
203
+ return;
204
+ }
205
+ // Check for !worktree command
206
+ const worktreeMatch = content.match(/^!worktree\s+(\S+)(?:\s+(.*))?$/i);
207
+ if (worktreeMatch) {
208
+ const subcommand = worktreeMatch[1].toLowerCase();
209
+ const args = worktreeMatch[2]?.trim();
210
+ switch (subcommand) {
211
+ case 'list':
212
+ await session.listWorktreesCommand(threadRoot, username);
213
+ break;
214
+ case 'switch':
215
+ if (!args) {
216
+ await mattermost.createPost('❌ Usage: `!worktree switch <branch>`', threadRoot);
217
+ }
218
+ else {
219
+ await session.switchToWorktree(threadRoot, args, username);
220
+ }
221
+ break;
222
+ case 'remove':
223
+ if (!args) {
224
+ await mattermost.createPost('❌ Usage: `!worktree remove <branch>`', threadRoot);
225
+ }
226
+ else {
227
+ await session.removeWorktreeCommand(threadRoot, args, username);
228
+ }
229
+ break;
230
+ case 'off':
231
+ await session.disableWorktreePrompt(threadRoot, username);
232
+ break;
233
+ default:
234
+ // Treat as branch name: !worktree feature/foo
235
+ await session.createAndSwitchToWorktree(threadRoot, subcommand, username);
236
+ }
237
+ return;
238
+ }
239
+ // Check for pending worktree prompt - treat message as branch name response
240
+ if (session.hasPendingWorktreePrompt(threadRoot)) {
241
+ // Only session owner can respond
242
+ if (session.isUserAllowedInSession(threadRoot, username)) {
243
+ const handled = await session.handleWorktreeBranchResponse(threadRoot, content, username);
244
+ if (handled)
245
+ return;
246
+ }
247
+ }
248
+ // Check for Claude Code slash commands (translate ! to /)
249
+ // These are sent directly to Claude Code as /commands
250
+ if (lowerContent === '!context' || lowerContent === '!cost' || lowerContent === '!compact') {
251
+ if (session.isUserAllowedInSession(threadRoot, username)) {
252
+ // Translate !command to /command for Claude Code
253
+ const claudeCommand = '/' + lowerContent.substring(1);
254
+ await session.sendFollowUp(threadRoot, claudeCommand);
255
+ }
256
+ return;
257
+ }
258
+ // Check if user is allowed in this session
259
+ if (!session.isUserAllowedInSession(threadRoot, username)) {
260
+ // Request approval for their message
261
+ if (content)
262
+ await session.requestMessageApproval(threadRoot, username, content);
263
+ return;
264
+ }
265
+ // Get any attached files (images)
266
+ const files = post.metadata?.files;
267
+ if (content || files?.length)
268
+ await session.sendFollowUp(threadRoot, content, files);
269
+ return;
270
+ }
271
+ // Check for paused session that can be resumed
272
+ if (session.hasPausedSession(threadRoot)) {
273
+ // If message starts with @mention to someone else, ignore it (side conversation)
274
+ const mentionMatch = message.trim().match(/^@([\w.-]+)/);
275
+ if (mentionMatch && mentionMatch[1].toLowerCase() !== mattermost.getBotName().toLowerCase()) {
276
+ return; // Side conversation, don't interrupt
277
+ }
278
+ const content = mattermost.isBotMentioned(message)
279
+ ? mattermost.extractPrompt(message)
280
+ : message.trim();
281
+ // Check if user is allowed in the paused session
282
+ const persistedSession = session.getPersistedSession(threadRoot);
283
+ if (persistedSession) {
284
+ const allowedUsers = new Set(persistedSession.sessionAllowedUsers);
285
+ if (!allowedUsers.has(username) && !mattermost.isUserAllowed(username)) {
286
+ // Not allowed - could request approval but that would require the session to be active
287
+ await mattermost.createPost(`⚠️ @${username} is not authorized to resume this session`, threadRoot);
288
+ return;
289
+ }
290
+ }
291
+ // Get any attached files (images)
292
+ const files = post.metadata?.files;
293
+ if (content || files?.length) {
294
+ await session.resumePausedSession(threadRoot, content, files);
295
+ }
296
+ return;
297
+ }
298
+ // New session requires @mention
299
+ if (!mattermost.isBotMentioned(message))
300
+ return;
301
+ if (!mattermost.isUserAllowed(username)) {
302
+ await mattermost.createPost(`⚠️ @${username} is not authorized`, threadRoot);
303
+ return;
304
+ }
305
+ const prompt = mattermost.extractPrompt(message);
306
+ const files = post.metadata?.files;
307
+ if (!prompt && !files?.length) {
308
+ await mattermost.createPost(`Mention me with your request`, threadRoot);
309
+ return;
310
+ }
311
+ // Check for inline branch syntax: "on branch X" or "!worktree X"
312
+ const branchMatch = prompt.match(/(?:on branch|!worktree)\s+(\S+)/i);
313
+ if (branchMatch) {
314
+ const branch = branchMatch[1];
315
+ // Remove the branch specification from the prompt
316
+ const cleanedPrompt = prompt.replace(/(?:on branch|!worktree)\s+\S+/i, '').trim();
317
+ await session.startSessionWithWorktree({ prompt: cleanedPrompt || prompt, files }, branch, username, threadRoot);
318
+ return;
319
+ }
320
+ await session.startSession({ prompt, files }, username, threadRoot);
321
+ }
322
+ catch (err) {
323
+ console.error(' ❌ Error handling message:', err);
324
+ // Try to notify user if possible
325
+ try {
326
+ const threadRoot = post.root_id || post.id;
327
+ await mattermost.createPost(`⚠️ An error occurred. Please try again.`, threadRoot);
328
+ }
329
+ catch {
330
+ // Ignore if we can't post the error message
331
+ }
332
+ }
333
+ });
334
+ mattermost.on('connected', () => { });
335
+ mattermost.on('error', (e) => console.error(' ❌ Error:', e));
336
+ await mattermost.connect();
337
+ // Resume any persisted sessions from before restart
338
+ await session.initialize();
339
+ console.log(` ✅ ${bold('Ready!')} Waiting for @${config.mattermost.botName} mentions...`);
340
+ console.log('');
341
+ let isShuttingDown = false;
342
+ const shutdown = async () => {
343
+ // Guard against multiple shutdown calls (SIGINT + SIGTERM)
344
+ if (isShuttingDown)
345
+ return;
346
+ isShuttingDown = true;
347
+ console.log('');
348
+ console.log(` 👋 ${dim('Shutting down...')}`);
349
+ // Set shutdown flag FIRST to prevent race conditions with exit events
350
+ session.setShuttingDown();
351
+ // Post shutdown message to active sessions
352
+ const activeThreads = session.getActiveThreadIds();
353
+ if (activeThreads.length > 0) {
354
+ console.log(` 📤 Notifying ${activeThreads.length} active session(s)...`);
355
+ for (const threadId of activeThreads) {
356
+ try {
357
+ await mattermost.createPost(`⏸️ **Bot shutting down** - session will resume on restart`, threadId);
358
+ }
359
+ catch {
360
+ // Ignore errors, we're shutting down
361
+ }
362
+ }
363
+ }
364
+ session.killAllSessions();
365
+ mattermost.disconnect();
366
+ process.exit(0);
367
+ };
368
+ process.on('SIGINT', () => { shutdown(); });
369
+ process.on('SIGTERM', () => { shutdown(); });
370
+ }
371
+ main().catch(e => { console.error(e); process.exit(1); });
package/dist/logo.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * ASCII Art Logo for Claude Threads
3
+ *
4
+ * Stylized CT in Claude Code's block character style.
5
+ */
6
+ /**
7
+ * ASCII logo for CLI display (with ANSI colors)
8
+ * Stylized CT in block characters
9
+ */
10
+ export declare const CLI_LOGO: string;
11
+ /**
12
+ * ASCII logo for Mattermost (plain text, no ANSI codes)
13
+ * Use getMattermostLogo(version) instead to include version
14
+ */
15
+ export declare const MATTERMOST_LOGO = "```\n \u2734 \u2584\u2588\u2580 \u2588\u2588\u2588 \u2734 claude-threads\n\u2734 \u2588\u2580 \u2588 \u2734 Mattermost \u00D7 Claude Code\n\u2734 \u2580\u2588\u2584 \u2588 \u2734\n```";
16
+ /**
17
+ * Get ASCII logo for Mattermost with version included
18
+ */
19
+ export declare function getMattermostLogo(version: string): string;
20
+ /**
21
+ * Compact inline logo for Mattermost headers
22
+ */
23
+ export declare const MATTERMOST_LOGO_INLINE = "`\u2584\u2588\u2580T` **claude-threads**";
24
+ /**
25
+ * Very compact logo for space-constrained contexts
26
+ */
27
+ export declare const LOGO_COMPACT = "\u2584\u2588\u2580T claude-threads";
28
+ /**
29
+ * Print CLI logo to stdout
30
+ */
31
+ export declare function printLogo(): void;
package/dist/logo.js ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * ASCII Art Logo for Claude Threads
3
+ *
4
+ * Stylized CT in Claude Code's block character style.
5
+ */
6
+ // ANSI color codes for terminal
7
+ const colors = {
8
+ reset: '\x1b[0m',
9
+ bold: '\x1b[1m',
10
+ dim: '\x1b[2m',
11
+ // Mattermost blue (#1C58D9)
12
+ blue: '\x1b[38;5;27m',
13
+ // Claude orange/coral
14
+ orange: '\x1b[38;5;209m',
15
+ };
16
+ /**
17
+ * ASCII logo for CLI display (with ANSI colors)
18
+ * Stylized CT in block characters
19
+ */
20
+ export const CLI_LOGO = `
21
+ ${colors.orange} ✴${colors.reset} ${colors.blue}▄█▀ ███${colors.reset} ${colors.orange}✴${colors.reset} ${colors.bold}claude-threads${colors.reset}
22
+ ${colors.orange}✴${colors.reset} ${colors.blue}█▀ █${colors.reset} ${colors.orange}✴${colors.reset} ${colors.dim}Mattermost × Claude Code${colors.reset}
23
+ ${colors.orange}✴${colors.reset} ${colors.blue}▀█▄ █${colors.reset} ${colors.orange}✴${colors.reset}
24
+ `;
25
+ /**
26
+ * ASCII logo for Mattermost (plain text, no ANSI codes)
27
+ * Use getMattermostLogo(version) instead to include version
28
+ */
29
+ export const MATTERMOST_LOGO = `\`\`\`
30
+ ✴ ▄█▀ ███ ✴ claude-threads
31
+ ✴ █▀ █ ✴ Mattermost × Claude Code
32
+ ✴ ▀█▄ █ ✴
33
+ \`\`\``;
34
+ /**
35
+ * Get ASCII logo for Mattermost with version included
36
+ */
37
+ export function getMattermostLogo(version) {
38
+ return `\`\`\`
39
+ ✴ ▄█▀ ███ ✴ claude-threads v${version}
40
+ ✴ █▀ █ ✴ Mattermost × Claude Code
41
+ ✴ ▀█▄ █ ✴
42
+ \`\`\``;
43
+ }
44
+ /**
45
+ * Compact inline logo for Mattermost headers
46
+ */
47
+ export const MATTERMOST_LOGO_INLINE = '`▄█▀T` **claude-threads**';
48
+ /**
49
+ * Very compact logo for space-constrained contexts
50
+ */
51
+ export const LOGO_COMPACT = '▄█▀T claude-threads';
52
+ /**
53
+ * Print CLI logo to stdout
54
+ */
55
+ export function printLogo() {
56
+ console.log(CLI_LOGO);
57
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Shared Mattermost REST API layer
3
+ *
4
+ * Provides standalone API functions that can be used by both:
5
+ * - src/mattermost/client.ts (main bot with WebSocket)
6
+ * - src/mcp/permission-server.ts (MCP subprocess)
7
+ *
8
+ * These functions take config as parameters (not from global state)
9
+ * to support the MCP server running as a separate process.
10
+ */
11
+ export interface MattermostApiConfig {
12
+ url: string;
13
+ token: string;
14
+ }
15
+ export interface MattermostApiPost {
16
+ id: string;
17
+ channel_id: string;
18
+ message: string;
19
+ root_id?: string;
20
+ user_id?: string;
21
+ create_at?: number;
22
+ }
23
+ export interface MattermostApiUser {
24
+ id: string;
25
+ username: string;
26
+ email?: string;
27
+ first_name?: string;
28
+ last_name?: string;
29
+ }
30
+ /**
31
+ * Make a request to the Mattermost REST API
32
+ *
33
+ * @param config - API configuration (url and token)
34
+ * @param method - HTTP method
35
+ * @param path - API path (starting with /)
36
+ * @param body - Optional request body
37
+ * @returns Promise with the response data
38
+ */
39
+ export declare function mattermostApi<T>(config: MattermostApiConfig, method: string, path: string, body?: unknown): Promise<T>;
40
+ /**
41
+ * Get the current authenticated user (bot user)
42
+ */
43
+ export declare function getMe(config: MattermostApiConfig): Promise<MattermostApiUser>;
44
+ /**
45
+ * Get a user by ID
46
+ */
47
+ export declare function getUser(config: MattermostApiConfig, userId: string): Promise<MattermostApiUser | null>;
48
+ /**
49
+ * Create a new post in a channel
50
+ */
51
+ export declare function createPost(config: MattermostApiConfig, channelId: string, message: string, rootId?: string): Promise<MattermostApiPost>;
52
+ /**
53
+ * Update an existing post
54
+ */
55
+ export declare function updatePost(config: MattermostApiConfig, postId: string, message: string): Promise<MattermostApiPost>;
56
+ /**
57
+ * Add a reaction to a post
58
+ */
59
+ export declare function addReaction(config: MattermostApiConfig, postId: string, userId: string, emojiName: string): Promise<void>;
60
+ /**
61
+ * Check if a user is allowed based on an allowlist
62
+ *
63
+ * @param username - Username to check
64
+ * @param allowList - List of allowed usernames (empty = all allowed)
65
+ * @returns true if user is allowed
66
+ */
67
+ export declare function isUserAllowed(username: string, allowList: string[]): boolean;
68
+ /**
69
+ * Create a post with reaction options for user interaction
70
+ *
71
+ * This is a common pattern used for:
72
+ * - Permission prompts (approve/deny/allow-all)
73
+ * - Plan approval (approve/deny)
74
+ * - Question answering (numbered options)
75
+ * - Message approval (approve/allow-all/deny)
76
+ *
77
+ * @param config - API configuration
78
+ * @param channelId - Channel to post in
79
+ * @param message - Post message content
80
+ * @param reactions - Array of emoji names to add as reaction options
81
+ * @param rootId - Optional thread root ID
82
+ * @param botUserId - Bot user ID (required for adding reactions)
83
+ * @returns The created post
84
+ */
85
+ export declare function createInteractivePost(config: MattermostApiConfig, channelId: string, message: string, reactions: string[], rootId: string | undefined, botUserId: string): Promise<MattermostApiPost>;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Shared Mattermost REST API layer
3
+ *
4
+ * Provides standalone API functions that can be used by both:
5
+ * - src/mattermost/client.ts (main bot with WebSocket)
6
+ * - src/mcp/permission-server.ts (MCP subprocess)
7
+ *
8
+ * These functions take config as parameters (not from global state)
9
+ * to support the MCP server running as a separate process.
10
+ */
11
+ /**
12
+ * Make a request to the Mattermost REST API
13
+ *
14
+ * @param config - API configuration (url and token)
15
+ * @param method - HTTP method
16
+ * @param path - API path (starting with /)
17
+ * @param body - Optional request body
18
+ * @returns Promise with the response data
19
+ */
20
+ export async function mattermostApi(config, method, path, body) {
21
+ const url = `${config.url}/api/v4${path}`;
22
+ const response = await fetch(url, {
23
+ method,
24
+ headers: {
25
+ Authorization: `Bearer ${config.token}`,
26
+ 'Content-Type': 'application/json',
27
+ },
28
+ body: body ? JSON.stringify(body) : undefined,
29
+ });
30
+ if (!response.ok) {
31
+ const text = await response.text();
32
+ throw new Error(`Mattermost API error ${response.status}: ${text}`);
33
+ }
34
+ return response.json();
35
+ }
36
+ /**
37
+ * Get the current authenticated user (bot user)
38
+ */
39
+ export async function getMe(config) {
40
+ return mattermostApi(config, 'GET', '/users/me');
41
+ }
42
+ /**
43
+ * Get a user by ID
44
+ */
45
+ export async function getUser(config, userId) {
46
+ try {
47
+ return await mattermostApi(config, 'GET', `/users/${userId}`);
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ /**
54
+ * Create a new post in a channel
55
+ */
56
+ export async function createPost(config, channelId, message, rootId) {
57
+ return mattermostApi(config, 'POST', '/posts', {
58
+ channel_id: channelId,
59
+ message,
60
+ root_id: rootId,
61
+ });
62
+ }
63
+ /**
64
+ * Update an existing post
65
+ */
66
+ export async function updatePost(config, postId, message) {
67
+ return mattermostApi(config, 'PUT', `/posts/${postId}`, {
68
+ id: postId,
69
+ message,
70
+ });
71
+ }
72
+ /**
73
+ * Add a reaction to a post
74
+ */
75
+ export async function addReaction(config, postId, userId, emojiName) {
76
+ await mattermostApi(config, 'POST', '/reactions', {
77
+ user_id: userId,
78
+ post_id: postId,
79
+ emoji_name: emojiName,
80
+ });
81
+ }
82
+ /**
83
+ * Check if a user is allowed based on an allowlist
84
+ *
85
+ * @param username - Username to check
86
+ * @param allowList - List of allowed usernames (empty = all allowed)
87
+ * @returns true if user is allowed
88
+ */
89
+ export function isUserAllowed(username, allowList) {
90
+ if (allowList.length === 0)
91
+ return true;
92
+ return allowList.includes(username);
93
+ }
94
+ /**
95
+ * Create a post with reaction options for user interaction
96
+ *
97
+ * This is a common pattern used for:
98
+ * - Permission prompts (approve/deny/allow-all)
99
+ * - Plan approval (approve/deny)
100
+ * - Question answering (numbered options)
101
+ * - Message approval (approve/allow-all/deny)
102
+ *
103
+ * @param config - API configuration
104
+ * @param channelId - Channel to post in
105
+ * @param message - Post message content
106
+ * @param reactions - Array of emoji names to add as reaction options
107
+ * @param rootId - Optional thread root ID
108
+ * @param botUserId - Bot user ID (required for adding reactions)
109
+ * @returns The created post
110
+ */
111
+ export async function createInteractivePost(config, channelId, message, reactions, rootId, botUserId) {
112
+ const post = await createPost(config, channelId, message, rootId);
113
+ // Add each reaction option, continuing even if some fail
114
+ for (const emoji of reactions) {
115
+ try {
116
+ await addReaction(config, post.id, botUserId, emoji);
117
+ }
118
+ catch (err) {
119
+ // Log error but continue - the post was created successfully
120
+ console.error(` ⚠️ Failed to add reaction ${emoji}:`, err);
121
+ }
122
+ }
123
+ return post;
124
+ }
@@ -0,0 +1 @@
1
+ export {};