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.
@@ -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 */
@@ -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 start message
86
- const shortDir = this.workingDir.replace(process.env.HOME || '', '~');
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
- // Load .env file from multiple locations (in order of priority)
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
- function requireEnv(name) {
27
- const value = process.env[name];
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 environment variable: ${name}`);
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: requireEnv('MATTERMOST_URL').replace(/\/$/, ''), // Remove trailing slash
38
- token: requireEnv('MATTERMOST_TOKEN'),
39
- channelId: requireEnv('MATTERMOST_CHANNEL_ID'),
40
- botName: process.env.MATTERMOST_BOT_NAME || 'claude-code',
55
+ url: url.replace(/\/$/, ''), // Remove trailing slash
56
+ token,
57
+ channelId,
58
+ botName,
41
59
  },
42
- allowedUsers: (process.env.ALLOWED_USERS || '')
43
- .split(',')
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 { loadConfig } from './config.js';
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
- if (process.argv.includes('--version') || process.argv.includes('-v')) {
15
- console.log(pkg.version);
16
- process.exit(0);
34
+ // Set debug mode from CLI flag
35
+ if (opts.debug) {
36
+ process.env.DEBUG = '1';
17
37
  }
18
- if (process.argv.includes('--help') || process.argv.includes('-h')) {
19
- console.log(`mm-claude v${pkg.version} - Share Claude Code sessions in Mattermost
20
-
21
- Usage: cd /your/project && mm-claude`);
22
- process.exit(0);
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
- if (!mattermost.isUserAllowed(username))
49
- return;
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
- await session.cancelSession(threadRoot, username);
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.4",
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"