mattermost-claude-code 0.3.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/claude/session.d.ts +26 -0
- package/dist/claude/session.js +171 -20
- package/dist/config.d.ts +12 -1
- package/dist/config.js +35 -22
- package/dist/index.js +65 -14
- package/dist/onboarding.d.ts +1 -0
- package/dist/onboarding.js +117 -0
- package/package.json +4 -1
package/dist/claude/session.d.ts
CHANGED
|
@@ -19,6 +19,14 @@ interface PendingApproval {
|
|
|
19
19
|
postId: string;
|
|
20
20
|
type: 'plan' | 'action';
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Pending message from unauthorized user awaiting approval
|
|
24
|
+
*/
|
|
25
|
+
interface PendingMessageApproval {
|
|
26
|
+
postId: string;
|
|
27
|
+
originalMessage: string;
|
|
28
|
+
fromUser: string;
|
|
29
|
+
}
|
|
22
30
|
/**
|
|
23
31
|
* Represents a single Claude Code session tied to a Mattermost thread.
|
|
24
32
|
* Each session has its own Claude CLI process and state.
|
|
@@ -28,12 +36,16 @@ interface Session {
|
|
|
28
36
|
startedBy: string;
|
|
29
37
|
startedAt: Date;
|
|
30
38
|
lastActivityAt: Date;
|
|
39
|
+
sessionNumber: number;
|
|
31
40
|
claude: ClaudeCli;
|
|
32
41
|
currentPostId: string | null;
|
|
33
42
|
pendingContent: string;
|
|
34
43
|
pendingApproval: PendingApproval | null;
|
|
35
44
|
pendingQuestionSet: PendingQuestionSet | null;
|
|
45
|
+
pendingMessageApproval: PendingMessageApproval | null;
|
|
36
46
|
planApproved: boolean;
|
|
47
|
+
sessionAllowedUsers: Set<string>;
|
|
48
|
+
sessionStartPostId: string | null;
|
|
37
49
|
tasksPostId: string | null;
|
|
38
50
|
activeSubagents: Map<string, string>;
|
|
39
51
|
updateTimer: ReturnType<typeof setTimeout> | null;
|
|
@@ -58,6 +70,11 @@ export declare class SessionManager {
|
|
|
58
70
|
private registerPost;
|
|
59
71
|
/** Find session by post ID (for reaction routing) */
|
|
60
72
|
private getSessionByPost;
|
|
73
|
+
/**
|
|
74
|
+
* Check if a user is allowed in a specific session.
|
|
75
|
+
* Checks global allowlist first, then session-specific allowlist.
|
|
76
|
+
*/
|
|
77
|
+
isUserAllowedInSession(threadId: string, username: string): boolean;
|
|
61
78
|
startSession(options: {
|
|
62
79
|
prompt: string;
|
|
63
80
|
}, username: string, replyToPostId?: string): Promise<void>;
|
|
@@ -71,6 +88,7 @@ export declare class SessionManager {
|
|
|
71
88
|
private handleReaction;
|
|
72
89
|
private handleQuestionReaction;
|
|
73
90
|
private handleApprovalReaction;
|
|
91
|
+
private handleMessageApprovalReaction;
|
|
74
92
|
private formatEvent;
|
|
75
93
|
private formatToolUse;
|
|
76
94
|
private appendContent;
|
|
@@ -89,6 +107,14 @@ export declare class SessionManager {
|
|
|
89
107
|
killSession(threadId: string): void;
|
|
90
108
|
/** Cancel a session with user feedback */
|
|
91
109
|
cancelSession(threadId: string, username: string): Promise<void>;
|
|
110
|
+
/** Invite a user to participate in a specific session */
|
|
111
|
+
inviteUser(threadId: string, invitedUser: string, invitedBy: string): Promise<void>;
|
|
112
|
+
/** Kick a user from a specific session */
|
|
113
|
+
kickUser(threadId: string, kickedUser: string, kickedBy: string): Promise<void>;
|
|
114
|
+
/** Update the session header post with current participants */
|
|
115
|
+
private updateSessionHeader;
|
|
116
|
+
/** Request approval for a message from an unauthorized user */
|
|
117
|
+
requestMessageApproval(threadId: string, username: string, message: string): Promise<void>;
|
|
92
118
|
/** Kill all active sessions (for graceful shutdown) */
|
|
93
119
|
killAllSessions(): void;
|
|
94
120
|
/** Cleanup idle sessions that have exceeded timeout */
|
package/dist/claude/session.js
CHANGED
|
@@ -65,6 +65,20 @@ export class SessionManager {
|
|
|
65
65
|
const threadId = this.postIndex.get(postId);
|
|
66
66
|
return threadId ? this.sessions.get(threadId) : undefined;
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Check if a user is allowed in a specific session.
|
|
70
|
+
* Checks global allowlist first, then session-specific allowlist.
|
|
71
|
+
*/
|
|
72
|
+
isUserAllowedInSession(threadId, username) {
|
|
73
|
+
// Check global allowlist first
|
|
74
|
+
if (this.mattermost.isUserAllowed(username))
|
|
75
|
+
return true;
|
|
76
|
+
// Check session-specific allowlist
|
|
77
|
+
const session = this.sessions.get(threadId);
|
|
78
|
+
if (session?.sessionAllowedUsers.has(username))
|
|
79
|
+
return true;
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
68
82
|
// ---------------------------------------------------------------------------
|
|
69
83
|
// Session Lifecycle
|
|
70
84
|
// ---------------------------------------------------------------------------
|
|
@@ -82,26 +96,8 @@ export class SessionManager {
|
|
|
82
96
|
await this.mattermost.createPost(`⚠️ **Too busy** - ${this.sessions.size} sessions active. Please try again later.`, replyToPostId);
|
|
83
97
|
return;
|
|
84
98
|
}
|
|
85
|
-
// Post session
|
|
86
|
-
const
|
|
87
|
-
const sessionNum = this.sessions.size + 1;
|
|
88
|
-
const permMode = this.skipPermissions ? '⚡ Auto' : '🔐 Interactive';
|
|
89
|
-
const promptPreview = options.prompt.length > 60
|
|
90
|
-
? options.prompt.substring(0, 60) + '…'
|
|
91
|
-
: options.prompt;
|
|
92
|
-
const msg = [
|
|
93
|
-
`### 🤖 mm-claude \`v${pkg.version}\``,
|
|
94
|
-
``,
|
|
95
|
-
`| | |`,
|
|
96
|
-
`|:--|:--|`,
|
|
97
|
-
`| 📂 **Directory** | \`${shortDir}\` |`,
|
|
98
|
-
`| 👤 **Started by** | @${username} |`,
|
|
99
|
-
`| 🔢 **Session** | #${sessionNum} of ${MAX_SESSIONS} max |`,
|
|
100
|
-
`| ${permMode.split(' ')[0]} **Permissions** | ${permMode.split(' ')[1]} |`,
|
|
101
|
-
``,
|
|
102
|
-
`> ${promptPreview}`,
|
|
103
|
-
].join('\n');
|
|
104
|
-
const post = await this.mattermost.createPost(msg, replyToPostId);
|
|
99
|
+
// Post initial session message (will be updated by updateSessionHeader)
|
|
100
|
+
const post = await this.mattermost.createPost(`### 🤖 mm-claude \`v${pkg.version}\`\n\n*Starting session...*`, replyToPostId);
|
|
105
101
|
const actualThreadId = replyToPostId || post.id;
|
|
106
102
|
// Create Claude CLI with options
|
|
107
103
|
const cliOptions = {
|
|
@@ -116,12 +112,16 @@ export class SessionManager {
|
|
|
116
112
|
startedBy: username,
|
|
117
113
|
startedAt: new Date(),
|
|
118
114
|
lastActivityAt: new Date(),
|
|
115
|
+
sessionNumber: this.sessions.size + 1,
|
|
119
116
|
claude,
|
|
120
117
|
currentPostId: null,
|
|
121
118
|
pendingContent: '',
|
|
122
119
|
pendingApproval: null,
|
|
123
120
|
pendingQuestionSet: null,
|
|
121
|
+
pendingMessageApproval: null,
|
|
124
122
|
planApproved: false,
|
|
123
|
+
sessionAllowedUsers: new Set([username]), // Owner is always allowed
|
|
124
|
+
sessionStartPostId: post.id, // Track for updating participants
|
|
125
125
|
tasksPostId: null,
|
|
126
126
|
activeSubagents: new Map(),
|
|
127
127
|
updateTimer: null,
|
|
@@ -132,6 +132,8 @@ export class SessionManager {
|
|
|
132
132
|
this.registerPost(post.id, actualThreadId); // For cancel reactions on session start post
|
|
133
133
|
const shortId = actualThreadId.substring(0, 8);
|
|
134
134
|
console.log(` ▶ Session #${this.sessions.size} started (${shortId}…) by @${username}`);
|
|
135
|
+
// Update the header with full session info
|
|
136
|
+
await this.updateSessionHeader(session);
|
|
135
137
|
// Start typing indicator immediately so user sees activity
|
|
136
138
|
this.startTyping(session);
|
|
137
139
|
// Bind event handlers with closure over threadId
|
|
@@ -404,6 +406,11 @@ export class SessionManager {
|
|
|
404
406
|
await this.handleQuestionReaction(session, postId, emojiName, username);
|
|
405
407
|
return;
|
|
406
408
|
}
|
|
409
|
+
// Handle message approval reactions
|
|
410
|
+
if (session.pendingMessageApproval && session.pendingMessageApproval.postId === postId) {
|
|
411
|
+
await this.handleMessageApprovalReaction(session, emojiName, username);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
407
414
|
}
|
|
408
415
|
async handleQuestionReaction(session, postId, emojiName, username) {
|
|
409
416
|
if (!session.pendingQuestionSet)
|
|
@@ -479,6 +486,44 @@ export class SessionManager {
|
|
|
479
486
|
this.startTyping(session);
|
|
480
487
|
}
|
|
481
488
|
}
|
|
489
|
+
async handleMessageApprovalReaction(session, emoji, approver) {
|
|
490
|
+
const pending = session.pendingMessageApproval;
|
|
491
|
+
if (!pending)
|
|
492
|
+
return;
|
|
493
|
+
// Only session owner or globally allowed users can approve
|
|
494
|
+
if (session.startedBy !== approver && !this.mattermost.isUserAllowed(approver)) {
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const isAllow = emoji === '+1' || emoji === 'thumbsup';
|
|
498
|
+
const isInvite = emoji === 'white_check_mark' || emoji === 'heavy_check_mark';
|
|
499
|
+
const isDeny = emoji === '-1' || emoji === 'thumbsdown';
|
|
500
|
+
if (!isAllow && !isInvite && !isDeny)
|
|
501
|
+
return;
|
|
502
|
+
if (isAllow) {
|
|
503
|
+
// Allow this single message
|
|
504
|
+
await this.mattermost.updatePost(pending.postId, `✅ Message from @${pending.fromUser} approved by @${approver}`);
|
|
505
|
+
session.claude.sendMessage(pending.originalMessage);
|
|
506
|
+
session.lastActivityAt = new Date();
|
|
507
|
+
this.startTyping(session);
|
|
508
|
+
console.log(` ✅ Message from @${pending.fromUser} approved by @${approver}`);
|
|
509
|
+
}
|
|
510
|
+
else if (isInvite) {
|
|
511
|
+
// Invite user to session
|
|
512
|
+
session.sessionAllowedUsers.add(pending.fromUser);
|
|
513
|
+
await this.mattermost.updatePost(pending.postId, `✅ @${pending.fromUser} invited to session by @${approver}`);
|
|
514
|
+
await this.updateSessionHeader(session);
|
|
515
|
+
session.claude.sendMessage(pending.originalMessage);
|
|
516
|
+
session.lastActivityAt = new Date();
|
|
517
|
+
this.startTyping(session);
|
|
518
|
+
console.log(` 👋 @${pending.fromUser} invited to session by @${approver}`);
|
|
519
|
+
}
|
|
520
|
+
else if (isDeny) {
|
|
521
|
+
// Deny
|
|
522
|
+
await this.mattermost.updatePost(pending.postId, `❌ Message from @${pending.fromUser} denied by @${approver}`);
|
|
523
|
+
console.log(` ❌ Message from @${pending.fromUser} denied by @${approver}`);
|
|
524
|
+
}
|
|
525
|
+
session.pendingMessageApproval = null;
|
|
526
|
+
}
|
|
482
527
|
formatEvent(session, e) {
|
|
483
528
|
switch (e.type) {
|
|
484
529
|
case 'assistant': {
|
|
@@ -724,6 +769,112 @@ export class SessionManager {
|
|
|
724
769
|
await this.mattermost.createPost(`🛑 **Session cancelled** by @${username}`, threadId);
|
|
725
770
|
this.killSession(threadId);
|
|
726
771
|
}
|
|
772
|
+
/** Invite a user to participate in a specific session */
|
|
773
|
+
async inviteUser(threadId, invitedUser, invitedBy) {
|
|
774
|
+
const session = this.sessions.get(threadId);
|
|
775
|
+
if (!session)
|
|
776
|
+
return;
|
|
777
|
+
// Only session owner or globally allowed users can invite
|
|
778
|
+
if (session.startedBy !== invitedBy && !this.mattermost.isUserAllowed(invitedBy)) {
|
|
779
|
+
await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can invite others`, threadId);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
session.sessionAllowedUsers.add(invitedUser);
|
|
783
|
+
await this.mattermost.createPost(`✅ @${invitedUser} can now participate in this session (invited by @${invitedBy})`, threadId);
|
|
784
|
+
console.log(` 👋 @${invitedUser} invited to session by @${invitedBy}`);
|
|
785
|
+
await this.updateSessionHeader(session);
|
|
786
|
+
}
|
|
787
|
+
/** Kick a user from a specific session */
|
|
788
|
+
async kickUser(threadId, kickedUser, kickedBy) {
|
|
789
|
+
const session = this.sessions.get(threadId);
|
|
790
|
+
if (!session)
|
|
791
|
+
return;
|
|
792
|
+
// Only session owner or globally allowed users can kick
|
|
793
|
+
if (session.startedBy !== kickedBy && !this.mattermost.isUserAllowed(kickedBy)) {
|
|
794
|
+
await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can kick others`, threadId);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
// Can't kick session owner
|
|
798
|
+
if (kickedUser === session.startedBy) {
|
|
799
|
+
await this.mattermost.createPost(`⚠️ Cannot kick session owner @${session.startedBy}`, threadId);
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
// Can't kick globally allowed users (they'll still have access)
|
|
803
|
+
if (this.mattermost.isUserAllowed(kickedUser)) {
|
|
804
|
+
await this.mattermost.createPost(`⚠️ @${kickedUser} is globally allowed and cannot be kicked from individual sessions`, threadId);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
if (session.sessionAllowedUsers.delete(kickedUser)) {
|
|
808
|
+
await this.mattermost.createPost(`🚫 @${kickedUser} removed from this session by @${kickedBy}`, threadId);
|
|
809
|
+
console.log(` 🚫 @${kickedUser} kicked from session by @${kickedBy}`);
|
|
810
|
+
await this.updateSessionHeader(session);
|
|
811
|
+
}
|
|
812
|
+
else {
|
|
813
|
+
await this.mattermost.createPost(`⚠️ @${kickedUser} was not in this session`, threadId);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
/** Update the session header post with current participants */
|
|
817
|
+
async updateSessionHeader(session) {
|
|
818
|
+
if (!session.sessionStartPostId)
|
|
819
|
+
return;
|
|
820
|
+
const shortDir = this.workingDir.replace(process.env.HOME || '', '~');
|
|
821
|
+
const permMode = this.skipPermissions ? '⚡ Auto' : '🔐 Interactive';
|
|
822
|
+
// Build participants list (excluding owner who is shown in "Started by")
|
|
823
|
+
const otherParticipants = [...session.sessionAllowedUsers]
|
|
824
|
+
.filter(u => u !== session.startedBy)
|
|
825
|
+
.map(u => `@${u}`)
|
|
826
|
+
.join(', ');
|
|
827
|
+
const rows = [
|
|
828
|
+
`| 📂 **Directory** | \`${shortDir}\` |`,
|
|
829
|
+
`| 👤 **Started by** | @${session.startedBy} |`,
|
|
830
|
+
];
|
|
831
|
+
if (otherParticipants) {
|
|
832
|
+
rows.push(`| 👥 **Participants** | ${otherParticipants} |`);
|
|
833
|
+
}
|
|
834
|
+
rows.push(`| 🔢 **Session** | #${session.sessionNumber} of ${MAX_SESSIONS} max |`);
|
|
835
|
+
rows.push(`| ${permMode.split(' ')[0]} **Permissions** | ${permMode.split(' ')[1]} |`);
|
|
836
|
+
const msg = [
|
|
837
|
+
`### 🤖 mm-claude \`v${pkg.version}\``,
|
|
838
|
+
``,
|
|
839
|
+
`| | |`,
|
|
840
|
+
`|:--|:--|`,
|
|
841
|
+
...rows,
|
|
842
|
+
].join('\n');
|
|
843
|
+
try {
|
|
844
|
+
await this.mattermost.updatePost(session.sessionStartPostId, msg);
|
|
845
|
+
}
|
|
846
|
+
catch (err) {
|
|
847
|
+
console.error('[Session] Failed to update session header:', err);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
/** Request approval for a message from an unauthorized user */
|
|
851
|
+
async requestMessageApproval(threadId, username, message) {
|
|
852
|
+
const session = this.sessions.get(threadId);
|
|
853
|
+
if (!session)
|
|
854
|
+
return;
|
|
855
|
+
// If there's already a pending message approval, ignore
|
|
856
|
+
if (session.pendingMessageApproval) {
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
const preview = message.length > 100 ? message.substring(0, 100) + '…' : message;
|
|
860
|
+
const post = await this.mattermost.createPost(`🔒 **@${username}** wants to send a message:\n> ${preview}\n\n` +
|
|
861
|
+
`React 👍 to allow this message, ✅ to invite them to the session, 👎 to deny`, threadId);
|
|
862
|
+
session.pendingMessageApproval = {
|
|
863
|
+
postId: post.id,
|
|
864
|
+
originalMessage: message,
|
|
865
|
+
fromUser: username,
|
|
866
|
+
};
|
|
867
|
+
this.registerPost(post.id, threadId);
|
|
868
|
+
// Add reaction options
|
|
869
|
+
try {
|
|
870
|
+
await this.mattermost.addReaction(post.id, '+1');
|
|
871
|
+
await this.mattermost.addReaction(post.id, 'white_check_mark');
|
|
872
|
+
await this.mattermost.addReaction(post.id, '-1');
|
|
873
|
+
}
|
|
874
|
+
catch (err) {
|
|
875
|
+
console.error('[Session] Failed to add message approval reactions:', err);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
727
878
|
/** Kill all active sessions (for graceful shutdown) */
|
|
728
879
|
killAllSessions() {
|
|
729
880
|
const count = this.sessions.size;
|
package/dist/config.d.ts
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/** Check if any .env config file exists */
|
|
2
|
+
export declare function configExists(): boolean;
|
|
3
|
+
/** CLI arguments that can override config */
|
|
4
|
+
export interface CliArgs {
|
|
5
|
+
url?: string;
|
|
6
|
+
token?: string;
|
|
7
|
+
channel?: string;
|
|
8
|
+
botName?: string;
|
|
9
|
+
allowedUsers?: string;
|
|
10
|
+
skipPermissions?: boolean;
|
|
11
|
+
}
|
|
1
12
|
export interface Config {
|
|
2
13
|
mattermost: {
|
|
3
14
|
url: string;
|
|
@@ -8,4 +19,4 @@ export interface Config {
|
|
|
8
19
|
allowedUsers: string[];
|
|
9
20
|
skipPermissions: boolean;
|
|
10
21
|
}
|
|
11
|
-
export declare function loadConfig(): Config;
|
|
22
|
+
export declare function loadConfig(cliArgs?: CliArgs): Config;
|
package/dist/config.js
CHANGED
|
@@ -3,17 +3,17 @@ import { resolve } from 'path';
|
|
|
3
3
|
import { existsSync } from 'fs';
|
|
4
4
|
import { homedir } from 'os';
|
|
5
5
|
let envLoaded = false;
|
|
6
|
+
// Paths to search for .env files (in order of priority)
|
|
7
|
+
const ENV_PATHS = [
|
|
8
|
+
resolve(process.cwd(), '.env'), // Current directory
|
|
9
|
+
resolve(homedir(), '.config', 'mm-claude', '.env'), // ~/.config/mm-claude/.env
|
|
10
|
+
resolve(homedir(), '.mm-claude.env'), // ~/.mm-claude.env
|
|
11
|
+
];
|
|
6
12
|
function loadEnv() {
|
|
7
13
|
if (envLoaded)
|
|
8
14
|
return;
|
|
9
15
|
envLoaded = true;
|
|
10
|
-
|
|
11
|
-
const envPaths = [
|
|
12
|
-
resolve(process.cwd(), '.env'), // Current directory
|
|
13
|
-
resolve(homedir(), '.config', 'mm-claude', '.env'), // ~/.config/mm-claude/.env
|
|
14
|
-
resolve(homedir(), '.mm-claude.env'), // ~/.mm-claude.env
|
|
15
|
-
];
|
|
16
|
-
for (const envPath of envPaths) {
|
|
16
|
+
for (const envPath of ENV_PATHS) {
|
|
17
17
|
if (existsSync(envPath)) {
|
|
18
18
|
if (process.env.DEBUG === '1' || process.argv.includes('--debug')) {
|
|
19
19
|
console.log(` [config] Loading from: ${envPath}`);
|
|
@@ -23,28 +23,41 @@ function loadEnv() {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
/** Check if any .env config file exists */
|
|
27
|
+
export function configExists() {
|
|
28
|
+
return ENV_PATHS.some(p => existsSync(p));
|
|
29
|
+
}
|
|
30
|
+
function getRequired(cliValue, envName, name) {
|
|
31
|
+
const value = cliValue || process.env[envName];
|
|
28
32
|
if (!value) {
|
|
29
|
-
throw new Error(`Missing required
|
|
33
|
+
throw new Error(`Missing required config: ${name}. Set ${envName} in .env or use --${name.toLowerCase().replace(/ /g, '-')} flag.`);
|
|
30
34
|
}
|
|
31
35
|
return value;
|
|
32
36
|
}
|
|
33
|
-
export function loadConfig() {
|
|
37
|
+
export function loadConfig(cliArgs) {
|
|
34
38
|
loadEnv();
|
|
39
|
+
// CLI args take priority over env vars
|
|
40
|
+
const url = getRequired(cliArgs?.url, 'MATTERMOST_URL', 'url');
|
|
41
|
+
const token = getRequired(cliArgs?.token, 'MATTERMOST_TOKEN', 'token');
|
|
42
|
+
const channelId = getRequired(cliArgs?.channel, 'MATTERMOST_CHANNEL_ID', 'channel');
|
|
43
|
+
const botName = cliArgs?.botName || process.env.MATTERMOST_BOT_NAME || 'claude-code';
|
|
44
|
+
const allowedUsersStr = cliArgs?.allowedUsers || process.env.ALLOWED_USERS || '';
|
|
45
|
+
const allowedUsers = allowedUsersStr
|
|
46
|
+
.split(',')
|
|
47
|
+
.map(u => u.trim())
|
|
48
|
+
.filter(u => u.length > 0);
|
|
49
|
+
// CLI --skip-permissions, env SKIP_PERMISSIONS=true, or legacy flag
|
|
50
|
+
const skipPermissions = cliArgs?.skipPermissions ||
|
|
51
|
+
process.env.SKIP_PERMISSIONS === 'true' ||
|
|
52
|
+
process.argv.includes('--dangerously-skip-permissions');
|
|
35
53
|
return {
|
|
36
54
|
mattermost: {
|
|
37
|
-
url:
|
|
38
|
-
token
|
|
39
|
-
channelId
|
|
40
|
-
botName
|
|
55
|
+
url: url.replace(/\/$/, ''), // Remove trailing slash
|
|
56
|
+
token,
|
|
57
|
+
channelId,
|
|
58
|
+
botName,
|
|
41
59
|
},
|
|
42
|
-
allowedUsers
|
|
43
|
-
|
|
44
|
-
.map(u => u.trim())
|
|
45
|
-
.filter(u => u.length > 0),
|
|
46
|
-
// SKIP_PERMISSIONS=true or --dangerously-skip-permissions flag
|
|
47
|
-
skipPermissions: process.env.SKIP_PERMISSIONS === 'true' ||
|
|
48
|
-
process.argv.includes('--dangerously-skip-permissions'),
|
|
60
|
+
allowedUsers,
|
|
61
|
+
skipPermissions,
|
|
49
62
|
};
|
|
50
63
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { program } from 'commander';
|
|
3
|
+
import { loadConfig, configExists } from './config.js';
|
|
4
|
+
import { runOnboarding } from './onboarding.js';
|
|
3
5
|
import { MattermostClient } from './mattermost/client.js';
|
|
4
6
|
import { SessionManager } from './claude/session.js';
|
|
5
7
|
import { readFileSync } from 'fs';
|
|
@@ -10,19 +12,44 @@ const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'u
|
|
|
10
12
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
11
13
|
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
12
14
|
const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
|
|
15
|
+
// Define CLI options
|
|
16
|
+
program
|
|
17
|
+
.name('mm-claude')
|
|
18
|
+
.version(pkg.version)
|
|
19
|
+
.description('Share Claude Code sessions in Mattermost')
|
|
20
|
+
.option('--url <url>', 'Mattermost server URL')
|
|
21
|
+
.option('--token <token>', 'Mattermost bot token')
|
|
22
|
+
.option('--channel <id>', 'Mattermost channel ID')
|
|
23
|
+
.option('--bot-name <name>', 'Bot mention name (default: claude-code)')
|
|
24
|
+
.option('--allowed-users <users>', 'Comma-separated allowed usernames')
|
|
25
|
+
.option('--skip-permissions', 'Skip interactive permission prompts')
|
|
26
|
+
.option('--debug', 'Enable debug logging')
|
|
27
|
+
.parse();
|
|
28
|
+
const opts = program.opts();
|
|
29
|
+
// Check if required args are provided via CLI
|
|
30
|
+
function hasRequiredCliArgs(args) {
|
|
31
|
+
return !!(args.url && args.token && args.channel);
|
|
32
|
+
}
|
|
13
33
|
async function main() {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
process.
|
|
34
|
+
// Set debug mode from CLI flag
|
|
35
|
+
if (opts.debug) {
|
|
36
|
+
process.env.DEBUG = '1';
|
|
17
37
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
38
|
+
// Build CLI args object
|
|
39
|
+
const cliArgs = {
|
|
40
|
+
url: opts.url,
|
|
41
|
+
token: opts.token,
|
|
42
|
+
channel: opts.channel,
|
|
43
|
+
botName: opts.botName,
|
|
44
|
+
allowedUsers: opts.allowedUsers,
|
|
45
|
+
skipPermissions: opts.skipPermissions,
|
|
46
|
+
};
|
|
47
|
+
// Check if we need onboarding
|
|
48
|
+
if (!configExists() && !hasRequiredCliArgs(opts)) {
|
|
49
|
+
await runOnboarding();
|
|
23
50
|
}
|
|
24
51
|
const workingDir = process.cwd();
|
|
25
|
-
const config = loadConfig();
|
|
52
|
+
const config = loadConfig(cliArgs);
|
|
26
53
|
// Nice startup banner
|
|
27
54
|
console.log('');
|
|
28
55
|
console.log(bold(` 🤖 mm-claude v${pkg.version}`));
|
|
@@ -45,16 +72,40 @@ Usage: cd /your/project && mm-claude`);
|
|
|
45
72
|
const threadRoot = post.root_id || post.id;
|
|
46
73
|
// Follow-up in active thread
|
|
47
74
|
if (session.isInSessionThread(threadRoot)) {
|
|
48
|
-
|
|
49
|
-
|
|
75
|
+
// If message starts with @mention to someone else, ignore it (side conversation)
|
|
76
|
+
const mentionMatch = message.trim().match(/^@(\w+)/);
|
|
77
|
+
if (mentionMatch && mentionMatch[1].toLowerCase() !== mattermost.getBotName().toLowerCase()) {
|
|
78
|
+
return; // Side conversation, don't interrupt
|
|
79
|
+
}
|
|
50
80
|
const content = mattermost.isBotMentioned(message)
|
|
51
81
|
? mattermost.extractPrompt(message)
|
|
52
82
|
: message.trim();
|
|
53
|
-
// Check for stop/cancel commands
|
|
54
83
|
const lowerContent = content.toLowerCase();
|
|
84
|
+
// Check for stop/cancel commands (only from allowed users)
|
|
55
85
|
if (lowerContent === '/stop' || lowerContent === 'stop' ||
|
|
56
86
|
lowerContent === '/cancel' || lowerContent === 'cancel') {
|
|
57
|
-
|
|
87
|
+
if (session.isUserAllowedInSession(threadRoot, username)) {
|
|
88
|
+
await session.cancelSession(threadRoot, username);
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// Check for /invite command
|
|
93
|
+
const inviteMatch = content.match(/^\/invite\s+@?(\w+)/i);
|
|
94
|
+
if (inviteMatch) {
|
|
95
|
+
await session.inviteUser(threadRoot, inviteMatch[1], username);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// Check for /kick command
|
|
99
|
+
const kickMatch = content.match(/^\/kick\s+@?(\w+)/i);
|
|
100
|
+
if (kickMatch) {
|
|
101
|
+
await session.kickUser(threadRoot, kickMatch[1], username);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
// Check if user is allowed in this session
|
|
105
|
+
if (!session.isUserAllowedInSession(threadRoot, username)) {
|
|
106
|
+
// Request approval for their message
|
|
107
|
+
if (content)
|
|
108
|
+
await session.requestMessageApproval(threadRoot, username, content);
|
|
58
109
|
return;
|
|
59
110
|
}
|
|
60
111
|
if (content)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runOnboarding(): Promise<void>;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import prompts from 'prompts';
|
|
2
|
+
import { writeFileSync, mkdirSync } from 'fs';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { resolve } from 'path';
|
|
5
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
6
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
7
|
+
const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
|
|
8
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
9
|
+
export async function runOnboarding() {
|
|
10
|
+
console.log('');
|
|
11
|
+
console.log(bold(' Welcome to mm-claude!'));
|
|
12
|
+
console.log(dim(' ─────────────────────────────────'));
|
|
13
|
+
console.log('');
|
|
14
|
+
console.log(' No configuration found. Let\'s set things up.');
|
|
15
|
+
console.log('');
|
|
16
|
+
console.log(dim(' You\'ll need:'));
|
|
17
|
+
console.log(dim(' • A Mattermost bot account with a token'));
|
|
18
|
+
console.log(dim(' • A channel ID where the bot will listen'));
|
|
19
|
+
console.log('');
|
|
20
|
+
// Handle Ctrl+C gracefully
|
|
21
|
+
prompts.override({});
|
|
22
|
+
const onCancel = () => {
|
|
23
|
+
console.log('');
|
|
24
|
+
console.log(dim(' Setup cancelled.'));
|
|
25
|
+
process.exit(0);
|
|
26
|
+
};
|
|
27
|
+
const response = await prompts([
|
|
28
|
+
{
|
|
29
|
+
type: 'text',
|
|
30
|
+
name: 'url',
|
|
31
|
+
message: 'Mattermost URL',
|
|
32
|
+
initial: 'https://your-mattermost-server.com',
|
|
33
|
+
validate: (v) => v.startsWith('http') ? true : 'URL must start with http:// or https://',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
type: 'password',
|
|
37
|
+
name: 'token',
|
|
38
|
+
message: 'Bot token',
|
|
39
|
+
hint: 'Create at: Integrations > Bot Accounts > Add Bot Account',
|
|
40
|
+
validate: (v) => v.length > 0 ? true : 'Token is required',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
type: 'text',
|
|
44
|
+
name: 'channelId',
|
|
45
|
+
message: 'Channel ID',
|
|
46
|
+
hint: 'Click channel name > View Info > copy ID from URL',
|
|
47
|
+
validate: (v) => v.length > 0 ? true : 'Channel ID is required',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
type: 'text',
|
|
51
|
+
name: 'botName',
|
|
52
|
+
message: 'Bot mention name',
|
|
53
|
+
initial: 'claude-code',
|
|
54
|
+
hint: 'Users will @mention this name',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
type: 'text',
|
|
58
|
+
name: 'allowedUsers',
|
|
59
|
+
message: 'Allowed usernames',
|
|
60
|
+
initial: '',
|
|
61
|
+
hint: 'Comma-separated, or empty for all users',
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
type: 'confirm',
|
|
65
|
+
name: 'skipPermissions',
|
|
66
|
+
message: 'Skip permission prompts?',
|
|
67
|
+
initial: true,
|
|
68
|
+
hint: 'If no, you\'ll approve each action via emoji reactions',
|
|
69
|
+
},
|
|
70
|
+
], { onCancel });
|
|
71
|
+
// Check if user cancelled
|
|
72
|
+
if (!response.url || !response.token || !response.channelId) {
|
|
73
|
+
console.log('');
|
|
74
|
+
console.log(dim(' Setup incomplete. Run mm-claude again to retry.'));
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
// Build .env content
|
|
78
|
+
const envContent = `# mm-claude configuration
|
|
79
|
+
# Generated by mm-claude onboarding
|
|
80
|
+
|
|
81
|
+
# Mattermost server URL
|
|
82
|
+
MATTERMOST_URL=${response.url}
|
|
83
|
+
|
|
84
|
+
# Bot token (from Integrations > Bot Accounts)
|
|
85
|
+
MATTERMOST_TOKEN=${response.token}
|
|
86
|
+
|
|
87
|
+
# Channel ID where the bot listens
|
|
88
|
+
MATTERMOST_CHANNEL_ID=${response.channelId}
|
|
89
|
+
|
|
90
|
+
# Bot mention name (users @mention this)
|
|
91
|
+
MATTERMOST_BOT_NAME=${response.botName || 'claude-code'}
|
|
92
|
+
|
|
93
|
+
# Allowed usernames (comma-separated, empty = all users)
|
|
94
|
+
ALLOWED_USERS=${response.allowedUsers || ''}
|
|
95
|
+
|
|
96
|
+
# Skip permission prompts (true = auto-approve, false = require emoji approval)
|
|
97
|
+
SKIP_PERMISSIONS=${response.skipPermissions ? 'true' : 'false'}
|
|
98
|
+
`;
|
|
99
|
+
// Save to ~/.config/mm-claude/.env
|
|
100
|
+
const configDir = resolve(homedir(), '.config', 'mm-claude');
|
|
101
|
+
const envPath = resolve(configDir, '.env');
|
|
102
|
+
try {
|
|
103
|
+
mkdirSync(configDir, { recursive: true });
|
|
104
|
+
writeFileSync(envPath, envContent, { mode: 0o600 }); // Secure permissions
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
console.error('');
|
|
108
|
+
console.error(` Failed to save config: ${err}`);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
console.log('');
|
|
112
|
+
console.log(green(' ✓ Configuration saved!'));
|
|
113
|
+
console.log(dim(` ${envPath}`));
|
|
114
|
+
console.log('');
|
|
115
|
+
console.log(dim(' Starting mm-claude...'));
|
|
116
|
+
console.log('');
|
|
117
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mattermost-claude-code",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Share Claude Code sessions live in a Mattermost channel with interactive features",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -41,12 +41,15 @@
|
|
|
41
41
|
],
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@modelcontextprotocol/sdk": "^1.25.1",
|
|
44
|
+
"commander": "^14.0.2",
|
|
44
45
|
"dotenv": "^16.4.7",
|
|
46
|
+
"prompts": "^2.4.2",
|
|
45
47
|
"ws": "^8.18.0",
|
|
46
48
|
"zod": "^4.2.1"
|
|
47
49
|
},
|
|
48
50
|
"devDependencies": {
|
|
49
51
|
"@types/node": "^22.10.2",
|
|
52
|
+
"@types/prompts": "^2.4.9",
|
|
50
53
|
"@types/ws": "^8.5.13",
|
|
51
54
|
"tsx": "^4.19.2",
|
|
52
55
|
"typescript": "^5.7.2"
|