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.
- package/CHANGELOG.md +473 -0
- package/LICENSE +21 -0
- package/README.md +303 -0
- package/dist/changelog.d.ts +20 -0
- package/dist/changelog.js +134 -0
- package/dist/claude/cli.d.ts +42 -0
- package/dist/claude/cli.js +173 -0
- package/dist/claude/session.d.ts +256 -0
- package/dist/claude/session.js +1964 -0
- package/dist/config.d.ts +27 -0
- package/dist/config.js +94 -0
- package/dist/git/worktree.d.ts +50 -0
- package/dist/git/worktree.js +228 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +371 -0
- package/dist/logo.d.ts +31 -0
- package/dist/logo.js +57 -0
- package/dist/mattermost/api.d.ts +85 -0
- package/dist/mattermost/api.js +124 -0
- package/dist/mattermost/api.test.d.ts +1 -0
- package/dist/mattermost/api.test.js +319 -0
- package/dist/mattermost/client.d.ts +56 -0
- package/dist/mattermost/client.js +321 -0
- package/dist/mattermost/emoji.d.ts +43 -0
- package/dist/mattermost/emoji.js +65 -0
- package/dist/mattermost/emoji.test.d.ts +1 -0
- package/dist/mattermost/emoji.test.js +131 -0
- package/dist/mattermost/types.d.ts +71 -0
- package/dist/mattermost/types.js +1 -0
- package/dist/mcp/permission-server.d.ts +2 -0
- package/dist/mcp/permission-server.js +201 -0
- package/dist/onboarding.d.ts +1 -0
- package/dist/onboarding.js +116 -0
- package/dist/persistence/session-store.d.ts +65 -0
- package/dist/persistence/session-store.js +127 -0
- package/dist/update-notifier.d.ts +3 -0
- package/dist/update-notifier.js +31 -0
- package/dist/utils/logger.d.ts +34 -0
- package/dist/utils/logger.js +42 -0
- package/dist/utils/logger.test.d.ts +1 -0
- package/dist/utils/logger.test.js +121 -0
- package/dist/utils/tool-formatter.d.ts +56 -0
- package/dist/utils/tool-formatter.js +247 -0
- package/dist/utils/tool-formatter.test.d.ts +1 -0
- package/dist/utils/tool-formatter.test.js +357 -0
- 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 {};
|