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
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Emoji constants and helpers for Mattermost reactions
3
+ *
4
+ * Centralized place for all emoji-related logic to avoid duplication
5
+ * across session.ts and permission-server.ts
6
+ */
7
+ /** Emoji names that indicate approval */
8
+ export declare const APPROVAL_EMOJIS: readonly ["+1", "thumbsup"];
9
+ /** Emoji names that indicate denial */
10
+ export declare const DENIAL_EMOJIS: readonly ["-1", "thumbsdown"];
11
+ /** Emoji names that indicate "allow all" / invite / session-wide approval */
12
+ export declare const ALLOW_ALL_EMOJIS: readonly ["white_check_mark", "heavy_check_mark"];
13
+ /** Number emojis for multi-choice questions (1-4) */
14
+ export declare const NUMBER_EMOJIS: readonly ["one", "two", "three", "four"];
15
+ /** Emojis for canceling/killing a session */
16
+ export declare const CANCEL_EMOJIS: readonly ["x", "octagonal_sign", "stop_sign"];
17
+ /** Emojis for escaping/pausing a session */
18
+ export declare const ESCAPE_EMOJIS: readonly ["double_vertical_bar", "pause_button"];
19
+ /**
20
+ * Check if the emoji indicates approval (thumbs up)
21
+ */
22
+ export declare function isApprovalEmoji(emoji: string): boolean;
23
+ /**
24
+ * Check if the emoji indicates denial (thumbs down)
25
+ */
26
+ export declare function isDenialEmoji(emoji: string): boolean;
27
+ /**
28
+ * Check if the emoji indicates "allow all" or invitation
29
+ */
30
+ export declare function isAllowAllEmoji(emoji: string): boolean;
31
+ /**
32
+ * Check if the emoji indicates session cancellation
33
+ */
34
+ export declare function isCancelEmoji(emoji: string): boolean;
35
+ /**
36
+ * Check if the emoji indicates escape/pause
37
+ */
38
+ export declare function isEscapeEmoji(emoji: string): boolean;
39
+ /**
40
+ * Get the index (0-based) for a number emoji, or -1 if not a number emoji
41
+ * Handles both text names ('one', 'two') and unicode variants ('1️⃣', '2️⃣')
42
+ */
43
+ export declare function getNumberEmojiIndex(emoji: string): number;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Emoji constants and helpers for Mattermost reactions
3
+ *
4
+ * Centralized place for all emoji-related logic to avoid duplication
5
+ * across session.ts and permission-server.ts
6
+ */
7
+ /** Emoji names that indicate approval */
8
+ export const APPROVAL_EMOJIS = ['+1', 'thumbsup'];
9
+ /** Emoji names that indicate denial */
10
+ export const DENIAL_EMOJIS = ['-1', 'thumbsdown'];
11
+ /** Emoji names that indicate "allow all" / invite / session-wide approval */
12
+ export const ALLOW_ALL_EMOJIS = ['white_check_mark', 'heavy_check_mark'];
13
+ /** Number emojis for multi-choice questions (1-4) */
14
+ export const NUMBER_EMOJIS = ['one', 'two', 'three', 'four'];
15
+ /** Emojis for canceling/killing a session */
16
+ export const CANCEL_EMOJIS = ['x', 'octagonal_sign', 'stop_sign'];
17
+ /** Emojis for escaping/pausing a session */
18
+ export const ESCAPE_EMOJIS = ['double_vertical_bar', 'pause_button'];
19
+ /**
20
+ * Check if the emoji indicates approval (thumbs up)
21
+ */
22
+ export function isApprovalEmoji(emoji) {
23
+ return APPROVAL_EMOJIS.includes(emoji);
24
+ }
25
+ /**
26
+ * Check if the emoji indicates denial (thumbs down)
27
+ */
28
+ export function isDenialEmoji(emoji) {
29
+ return DENIAL_EMOJIS.includes(emoji);
30
+ }
31
+ /**
32
+ * Check if the emoji indicates "allow all" or invitation
33
+ */
34
+ export function isAllowAllEmoji(emoji) {
35
+ return ALLOW_ALL_EMOJIS.includes(emoji);
36
+ }
37
+ /**
38
+ * Check if the emoji indicates session cancellation
39
+ */
40
+ export function isCancelEmoji(emoji) {
41
+ return CANCEL_EMOJIS.includes(emoji);
42
+ }
43
+ /**
44
+ * Check if the emoji indicates escape/pause
45
+ */
46
+ export function isEscapeEmoji(emoji) {
47
+ return ESCAPE_EMOJIS.includes(emoji);
48
+ }
49
+ /** Unicode number emoji variants that also map to indices */
50
+ const UNICODE_NUMBER_EMOJIS = {
51
+ '1️⃣': 0,
52
+ '2️⃣': 1,
53
+ '3️⃣': 2,
54
+ '4️⃣': 3,
55
+ };
56
+ /**
57
+ * Get the index (0-based) for a number emoji, or -1 if not a number emoji
58
+ * Handles both text names ('one', 'two') and unicode variants ('1️⃣', '2️⃣')
59
+ */
60
+ export function getNumberEmojiIndex(emoji) {
61
+ const textIndex = NUMBER_EMOJIS.indexOf(emoji);
62
+ if (textIndex >= 0)
63
+ return textIndex;
64
+ return UNICODE_NUMBER_EMOJIS[emoji] ?? -1;
65
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,131 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { isApprovalEmoji, isDenialEmoji, isAllowAllEmoji, isCancelEmoji, isEscapeEmoji, getNumberEmojiIndex, APPROVAL_EMOJIS, DENIAL_EMOJIS, ALLOW_ALL_EMOJIS, NUMBER_EMOJIS, CANCEL_EMOJIS, ESCAPE_EMOJIS, } from './emoji.js';
3
+ describe('emoji helpers', () => {
4
+ describe('isApprovalEmoji', () => {
5
+ it('returns true for +1', () => {
6
+ expect(isApprovalEmoji('+1')).toBe(true);
7
+ });
8
+ it('returns true for thumbsup', () => {
9
+ expect(isApprovalEmoji('thumbsup')).toBe(true);
10
+ });
11
+ it('returns false for other emojis', () => {
12
+ expect(isApprovalEmoji('heart')).toBe(false);
13
+ expect(isApprovalEmoji('-1')).toBe(false);
14
+ expect(isApprovalEmoji('x')).toBe(false);
15
+ });
16
+ it('matches all APPROVAL_EMOJIS', () => {
17
+ for (const emoji of APPROVAL_EMOJIS) {
18
+ expect(isApprovalEmoji(emoji)).toBe(true);
19
+ }
20
+ });
21
+ });
22
+ describe('isDenialEmoji', () => {
23
+ it('returns true for -1', () => {
24
+ expect(isDenialEmoji('-1')).toBe(true);
25
+ });
26
+ it('returns true for thumbsdown', () => {
27
+ expect(isDenialEmoji('thumbsdown')).toBe(true);
28
+ });
29
+ it('returns false for other emojis', () => {
30
+ expect(isDenialEmoji('heart')).toBe(false);
31
+ expect(isDenialEmoji('+1')).toBe(false);
32
+ expect(isDenialEmoji('thumbsup')).toBe(false);
33
+ });
34
+ it('matches all DENIAL_EMOJIS', () => {
35
+ for (const emoji of DENIAL_EMOJIS) {
36
+ expect(isDenialEmoji(emoji)).toBe(true);
37
+ }
38
+ });
39
+ });
40
+ describe('isAllowAllEmoji', () => {
41
+ it('returns true for white_check_mark', () => {
42
+ expect(isAllowAllEmoji('white_check_mark')).toBe(true);
43
+ });
44
+ it('returns true for heavy_check_mark', () => {
45
+ expect(isAllowAllEmoji('heavy_check_mark')).toBe(true);
46
+ });
47
+ it('returns false for other emojis', () => {
48
+ expect(isAllowAllEmoji('heart')).toBe(false);
49
+ expect(isAllowAllEmoji('+1')).toBe(false);
50
+ expect(isAllowAllEmoji('thumbsup')).toBe(false);
51
+ });
52
+ it('matches all ALLOW_ALL_EMOJIS', () => {
53
+ for (const emoji of ALLOW_ALL_EMOJIS) {
54
+ expect(isAllowAllEmoji(emoji)).toBe(true);
55
+ }
56
+ });
57
+ });
58
+ describe('isCancelEmoji', () => {
59
+ it('returns true for x', () => {
60
+ expect(isCancelEmoji('x')).toBe(true);
61
+ });
62
+ it('returns true for octagonal_sign', () => {
63
+ expect(isCancelEmoji('octagonal_sign')).toBe(true);
64
+ });
65
+ it('returns true for stop_sign', () => {
66
+ expect(isCancelEmoji('stop_sign')).toBe(true);
67
+ });
68
+ it('returns false for other emojis', () => {
69
+ expect(isCancelEmoji('heart')).toBe(false);
70
+ expect(isCancelEmoji('-1')).toBe(false);
71
+ });
72
+ it('matches all CANCEL_EMOJIS', () => {
73
+ for (const emoji of CANCEL_EMOJIS) {
74
+ expect(isCancelEmoji(emoji)).toBe(true);
75
+ }
76
+ });
77
+ });
78
+ describe('isEscapeEmoji', () => {
79
+ it('returns true for double_vertical_bar', () => {
80
+ expect(isEscapeEmoji('double_vertical_bar')).toBe(true);
81
+ });
82
+ it('returns true for pause_button', () => {
83
+ expect(isEscapeEmoji('pause_button')).toBe(true);
84
+ });
85
+ it('returns false for other emojis', () => {
86
+ expect(isEscapeEmoji('heart')).toBe(false);
87
+ expect(isEscapeEmoji('x')).toBe(false);
88
+ });
89
+ it('matches all ESCAPE_EMOJIS', () => {
90
+ for (const emoji of ESCAPE_EMOJIS) {
91
+ expect(isEscapeEmoji(emoji)).toBe(true);
92
+ }
93
+ });
94
+ });
95
+ describe('getNumberEmojiIndex', () => {
96
+ it('returns 0 for "one"', () => {
97
+ expect(getNumberEmojiIndex('one')).toBe(0);
98
+ });
99
+ it('returns 1 for "two"', () => {
100
+ expect(getNumberEmojiIndex('two')).toBe(1);
101
+ });
102
+ it('returns 2 for "three"', () => {
103
+ expect(getNumberEmojiIndex('three')).toBe(2);
104
+ });
105
+ it('returns 3 for "four"', () => {
106
+ expect(getNumberEmojiIndex('four')).toBe(3);
107
+ });
108
+ it('returns 0 for "1️⃣" (unicode)', () => {
109
+ expect(getNumberEmojiIndex('1️⃣')).toBe(0);
110
+ });
111
+ it('returns 1 for "2️⃣" (unicode)', () => {
112
+ expect(getNumberEmojiIndex('2️⃣')).toBe(1);
113
+ });
114
+ it('returns 2 for "3️⃣" (unicode)', () => {
115
+ expect(getNumberEmojiIndex('3️⃣')).toBe(2);
116
+ });
117
+ it('returns 3 for "4️⃣" (unicode)', () => {
118
+ expect(getNumberEmojiIndex('4️⃣')).toBe(3);
119
+ });
120
+ it('returns -1 for non-number emojis', () => {
121
+ expect(getNumberEmojiIndex('heart')).toBe(-1);
122
+ expect(getNumberEmojiIndex('five')).toBe(-1);
123
+ expect(getNumberEmojiIndex('+1')).toBe(-1);
124
+ });
125
+ it('returns correct index for all NUMBER_EMOJIS', () => {
126
+ for (let i = 0; i < NUMBER_EMOJIS.length; i++) {
127
+ expect(getNumberEmojiIndex(NUMBER_EMOJIS[i])).toBe(i);
128
+ }
129
+ });
130
+ });
131
+ });
@@ -0,0 +1,71 @@
1
+ export interface MattermostWebSocketEvent {
2
+ event: string;
3
+ data: Record<string, unknown>;
4
+ broadcast: {
5
+ channel_id?: string;
6
+ user_id?: string;
7
+ team_id?: string;
8
+ };
9
+ seq: number;
10
+ }
11
+ export interface MattermostFile {
12
+ id: string;
13
+ name: string;
14
+ size: number;
15
+ mime_type: string;
16
+ extension: string;
17
+ width?: number;
18
+ height?: number;
19
+ }
20
+ export interface MattermostPost {
21
+ id: string;
22
+ create_at: number;
23
+ update_at: number;
24
+ delete_at: number;
25
+ user_id: string;
26
+ channel_id: string;
27
+ root_id: string;
28
+ message: string;
29
+ type: string;
30
+ props: Record<string, unknown>;
31
+ metadata?: {
32
+ embeds?: unknown[];
33
+ files?: MattermostFile[];
34
+ };
35
+ }
36
+ export interface MattermostUser {
37
+ id: string;
38
+ username: string;
39
+ email: string;
40
+ first_name: string;
41
+ last_name: string;
42
+ nickname: string;
43
+ }
44
+ export interface PostedEventData {
45
+ channel_display_name: string;
46
+ channel_name: string;
47
+ channel_type: string;
48
+ post: string;
49
+ sender_name: string;
50
+ team_id: string;
51
+ }
52
+ export interface ReactionAddedEventData {
53
+ reaction: string;
54
+ }
55
+ export interface MattermostReaction {
56
+ user_id: string;
57
+ post_id: string;
58
+ emoji_name: string;
59
+ create_at: number;
60
+ }
61
+ export interface CreatePostRequest {
62
+ channel_id: string;
63
+ message: string;
64
+ root_id?: string;
65
+ props?: Record<string, unknown>;
66
+ }
67
+ export interface UpdatePostRequest {
68
+ id: string;
69
+ message: string;
70
+ props?: Record<string, unknown>;
71
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MCP Permission Server for Mattermost
4
+ *
5
+ * This server handles Claude Code's permission prompts by forwarding them to
6
+ * Mattermost for user approval via emoji reactions.
7
+ *
8
+ * It is spawned by Claude Code when using --permission-prompt-tool and
9
+ * communicates via stdio (MCP protocol).
10
+ *
11
+ * Approval options:
12
+ * - 👍 (+1) Allow this tool use
13
+ * - ✅ (white_check_mark) Allow all future tool uses in this session
14
+ * - 👎 (-1) Deny this tool use
15
+ *
16
+ * Environment variables (passed by claude-threads):
17
+ * - MATTERMOST_URL: Mattermost server URL
18
+ * - MATTERMOST_TOKEN: Bot access token
19
+ * - MATTERMOST_CHANNEL_ID: Channel to post permission requests
20
+ * - MM_THREAD_ID: Thread ID for the current session
21
+ * - ALLOWED_USERS: Comma-separated list of authorized usernames
22
+ * - DEBUG: Set to '1' for debug logging
23
+ */
24
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
25
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
26
+ import { z } from 'zod';
27
+ import WebSocket from 'ws';
28
+ import { isApprovalEmoji, isAllowAllEmoji, APPROVAL_EMOJIS, ALLOW_ALL_EMOJIS, DENIAL_EMOJIS } from '../mattermost/emoji.js';
29
+ import { formatToolForPermission } from '../utils/tool-formatter.js';
30
+ import { mcpLogger } from '../utils/logger.js';
31
+ import { getMe, getUser, createInteractivePost, updatePost, isUserAllowed, } from '../mattermost/api.js';
32
+ // =============================================================================
33
+ // Configuration
34
+ // =============================================================================
35
+ const MM_URL = process.env.MATTERMOST_URL || '';
36
+ const MM_TOKEN = process.env.MATTERMOST_TOKEN || '';
37
+ const MM_CHANNEL_ID = process.env.MATTERMOST_CHANNEL_ID || '';
38
+ const MM_THREAD_ID = process.env.MM_THREAD_ID || '';
39
+ const ALLOWED_USERS = (process.env.ALLOWED_USERS || '')
40
+ .split(',')
41
+ .map(u => u.trim())
42
+ .filter(u => u.length > 0);
43
+ const PERMISSION_TIMEOUT_MS = 120000; // 2 minutes
44
+ // API configuration (created from environment variables)
45
+ const apiConfig = {
46
+ url: MM_URL,
47
+ token: MM_TOKEN,
48
+ };
49
+ // Session state
50
+ let allowAllSession = false;
51
+ let botUserId = null;
52
+ // =============================================================================
53
+ // Mattermost API Helpers (using shared API layer)
54
+ // =============================================================================
55
+ async function getBotUserId() {
56
+ if (botUserId)
57
+ return botUserId;
58
+ const me = await getMe(apiConfig);
59
+ botUserId = me.id;
60
+ return botUserId;
61
+ }
62
+ async function getUserById(userId) {
63
+ const user = await getUser(apiConfig, userId);
64
+ return user?.username || null;
65
+ }
66
+ function checkUserAllowed(username) {
67
+ return isUserAllowed(username, ALLOWED_USERS);
68
+ }
69
+ // =============================================================================
70
+ // Reaction Handling
71
+ // =============================================================================
72
+ function waitForReaction(postId) {
73
+ return new Promise((resolve, reject) => {
74
+ const wsUrl = MM_URL.replace(/^http/, 'ws') + '/api/v4/websocket';
75
+ mcpLogger.debug(`Connecting to WebSocket: ${wsUrl}`);
76
+ const ws = new WebSocket(wsUrl);
77
+ const timeout = setTimeout(() => {
78
+ mcpLogger.debug(`Timeout waiting for reaction on ${postId}`);
79
+ ws.close();
80
+ reject(new Error('Permission request timed out'));
81
+ }, PERMISSION_TIMEOUT_MS);
82
+ ws.on('open', () => {
83
+ mcpLogger.debug(`WebSocket connected, authenticating...`);
84
+ ws.send(JSON.stringify({
85
+ seq: 1,
86
+ action: 'authentication_challenge',
87
+ data: { token: MM_TOKEN },
88
+ }));
89
+ });
90
+ ws.on('message', async (data) => {
91
+ try {
92
+ const event = JSON.parse(data.toString());
93
+ mcpLogger.debug(`WS event: ${event.event || event.status || 'unknown'}`);
94
+ if (event.event === 'reaction_added') {
95
+ const reactionData = event.data;
96
+ // Mattermost sends reaction as JSON string
97
+ const reaction = typeof reactionData.reaction === 'string'
98
+ ? JSON.parse(reactionData.reaction)
99
+ : reactionData.reaction;
100
+ mcpLogger.debug(`Reaction on post ${reaction?.post_id}, looking for ${postId}`);
101
+ if (reaction?.post_id === postId) {
102
+ const userId = reaction.user_id;
103
+ mcpLogger.debug(`Reaction from user ${userId}, emoji: ${reaction.emoji_name}`);
104
+ // Ignore bot's own reactions (from adding reaction options)
105
+ const myId = await getBotUserId();
106
+ if (userId === myId) {
107
+ mcpLogger.debug(`Ignoring bot's own reaction`);
108
+ return;
109
+ }
110
+ // Check if user is authorized
111
+ const username = await getUserById(userId);
112
+ mcpLogger.debug(`Username: ${username}, allowed: ${ALLOWED_USERS.join(',') || '(all)'}`);
113
+ if (!username || !checkUserAllowed(username)) {
114
+ mcpLogger.debug(`Ignoring unauthorized user: ${username || userId}`);
115
+ return;
116
+ }
117
+ mcpLogger.debug(`Accepting reaction ${reaction.emoji_name} from ${username}`);
118
+ clearTimeout(timeout);
119
+ ws.close();
120
+ resolve({ emoji: reaction.emoji_name, username });
121
+ }
122
+ }
123
+ }
124
+ catch (e) {
125
+ mcpLogger.debug(`Parse error: ${e}`);
126
+ }
127
+ });
128
+ ws.on('error', (err) => {
129
+ mcpLogger.debug(`WebSocket error: ${err}`);
130
+ clearTimeout(timeout);
131
+ reject(err);
132
+ });
133
+ });
134
+ }
135
+ async function handlePermission(toolName, toolInput) {
136
+ mcpLogger.debug(`handlePermission called for ${toolName}`);
137
+ // Auto-approve if "allow all" was selected earlier
138
+ if (allowAllSession) {
139
+ mcpLogger.debug(`Auto-allowing ${toolName} (allow all active)`);
140
+ return { behavior: 'allow', updatedInput: toolInput };
141
+ }
142
+ if (!MM_URL || !MM_TOKEN || !MM_CHANNEL_ID) {
143
+ mcpLogger.error('Missing Mattermost config');
144
+ return { behavior: 'deny', message: 'Permission service not configured' };
145
+ }
146
+ try {
147
+ // Post permission request to Mattermost with reaction options
148
+ const toolInfo = formatToolForPermission(toolName, toolInput);
149
+ const message = `⚠️ **Permission requested**\n\n${toolInfo}\n\n` +
150
+ `👍 Allow | ✅ Allow all | 👎 Deny`;
151
+ const userId = await getBotUserId();
152
+ const post = await createInteractivePost(apiConfig, MM_CHANNEL_ID, message, [APPROVAL_EMOJIS[0], ALLOW_ALL_EMOJIS[0], DENIAL_EMOJIS[0]], MM_THREAD_ID || undefined, userId);
153
+ // Wait for user's reaction
154
+ const { emoji, username } = await waitForReaction(post.id);
155
+ if (isApprovalEmoji(emoji)) {
156
+ await updatePost(apiConfig, post.id, `✅ **Allowed** by @${username}\n\n${toolInfo}`);
157
+ mcpLogger.info(`Allowed: ${toolName}`);
158
+ return { behavior: 'allow', updatedInput: toolInput };
159
+ }
160
+ else if (isAllowAllEmoji(emoji)) {
161
+ allowAllSession = true;
162
+ await updatePost(apiConfig, post.id, `✅ **Allowed all** by @${username}\n\n${toolInfo}`);
163
+ mcpLogger.info(`Allowed all: ${toolName}`);
164
+ return { behavior: 'allow', updatedInput: toolInput };
165
+ }
166
+ else {
167
+ await updatePost(apiConfig, post.id, `❌ **Denied** by @${username}\n\n${toolInfo}`);
168
+ mcpLogger.info(`Denied: ${toolName}`);
169
+ return { behavior: 'deny', message: 'User denied permission' };
170
+ }
171
+ }
172
+ catch (error) {
173
+ mcpLogger.error(`Permission error: ${error}`);
174
+ return { behavior: 'deny', message: String(error) };
175
+ }
176
+ }
177
+ // =============================================================================
178
+ // MCP Server Setup
179
+ // =============================================================================
180
+ async function main() {
181
+ const server = new McpServer({
182
+ name: 'claude-threads-permissions',
183
+ version: '1.0.0',
184
+ });
185
+ server.tool('permission_prompt', 'Handle permission requests via Mattermost reactions', {
186
+ tool_name: z.string().describe('Name of the tool requesting permission'),
187
+ input: z.record(z.string(), z.unknown()).describe('Tool input parameters'),
188
+ }, async ({ tool_name, input }) => {
189
+ const result = await handlePermission(tool_name, input);
190
+ return {
191
+ content: [{ type: 'text', text: JSON.stringify(result) }],
192
+ };
193
+ });
194
+ const transport = new StdioServerTransport();
195
+ await server.connect(transport);
196
+ mcpLogger.info('Permission server ready');
197
+ }
198
+ main().catch((err) => {
199
+ mcpLogger.error(`Fatal: ${err}`);
200
+ process.exit(1);
201
+ });
@@ -0,0 +1 @@
1
+ export declare function runOnboarding(): Promise<void>;
@@ -0,0 +1,116 @@
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 green = (s) => `\x1b[32m${s}\x1b[0m`;
8
+ export async function runOnboarding() {
9
+ console.log('');
10
+ console.log(bold(' Welcome to claude-threads!'));
11
+ console.log(dim(' ─────────────────────────────────'));
12
+ console.log('');
13
+ console.log(' No configuration found. Let\'s set things up.');
14
+ console.log('');
15
+ console.log(dim(' You\'ll need:'));
16
+ console.log(dim(' • A Mattermost bot account with a token'));
17
+ console.log(dim(' • A channel ID where the bot will listen'));
18
+ console.log('');
19
+ // Handle Ctrl+C gracefully
20
+ prompts.override({});
21
+ const onCancel = () => {
22
+ console.log('');
23
+ console.log(dim(' Setup cancelled.'));
24
+ process.exit(0);
25
+ };
26
+ const response = await prompts([
27
+ {
28
+ type: 'text',
29
+ name: 'url',
30
+ message: 'Mattermost URL',
31
+ initial: 'https://your-mattermost-server.com',
32
+ validate: (v) => v.startsWith('http') ? true : 'URL must start with http:// or https://',
33
+ },
34
+ {
35
+ type: 'password',
36
+ name: 'token',
37
+ message: 'Bot token',
38
+ hint: 'Create at: Integrations > Bot Accounts > Add Bot Account',
39
+ validate: (v) => v.length > 0 ? true : 'Token is required',
40
+ },
41
+ {
42
+ type: 'text',
43
+ name: 'channelId',
44
+ message: 'Channel ID',
45
+ hint: 'Click channel name > View Info > copy ID from URL',
46
+ validate: (v) => v.length > 0 ? true : 'Channel ID is required',
47
+ },
48
+ {
49
+ type: 'text',
50
+ name: 'botName',
51
+ message: 'Bot mention name',
52
+ initial: 'claude-code',
53
+ hint: 'Users will @mention this name',
54
+ },
55
+ {
56
+ type: 'text',
57
+ name: 'allowedUsers',
58
+ message: 'Allowed usernames',
59
+ initial: '',
60
+ hint: 'Comma-separated, or empty for all users',
61
+ },
62
+ {
63
+ type: 'confirm',
64
+ name: 'skipPermissions',
65
+ message: 'Skip permission prompts?',
66
+ initial: true,
67
+ hint: 'If no, you\'ll approve each action via emoji reactions',
68
+ },
69
+ ], { onCancel });
70
+ // Check if user cancelled
71
+ if (!response.url || !response.token || !response.channelId) {
72
+ console.log('');
73
+ console.log(dim(' Setup incomplete. Run claude-threads again to retry.'));
74
+ process.exit(1);
75
+ }
76
+ // Build .env content
77
+ const envContent = `# claude-threads configuration
78
+ # Generated by claude-threads onboarding
79
+
80
+ # Mattermost server URL
81
+ MATTERMOST_URL=${response.url}
82
+
83
+ # Bot token (from Integrations > Bot Accounts)
84
+ MATTERMOST_TOKEN=${response.token}
85
+
86
+ # Channel ID where the bot listens
87
+ MATTERMOST_CHANNEL_ID=${response.channelId}
88
+
89
+ # Bot mention name (users @mention this)
90
+ MATTERMOST_BOT_NAME=${response.botName || 'claude-code'}
91
+
92
+ # Allowed usernames (comma-separated, empty = all users)
93
+ ALLOWED_USERS=${response.allowedUsers || ''}
94
+
95
+ # Skip permission prompts (true = auto-approve, false = require emoji approval)
96
+ SKIP_PERMISSIONS=${response.skipPermissions ? 'true' : 'false'}
97
+ `;
98
+ // Save to ~/.config/claude-threads/.env
99
+ const configDir = resolve(homedir(), '.config', 'claude-threads');
100
+ const envPath = resolve(configDir, '.env');
101
+ try {
102
+ mkdirSync(configDir, { recursive: true });
103
+ writeFileSync(envPath, envContent, { mode: 0o600 }); // Secure permissions
104
+ }
105
+ catch (err) {
106
+ console.error('');
107
+ console.error(` Failed to save config: ${err}`);
108
+ process.exit(1);
109
+ }
110
+ console.log('');
111
+ console.log(green(' ✓ Configuration saved!'));
112
+ console.log(dim(` ${envPath}`));
113
+ console.log('');
114
+ console.log(dim(' Starting claude-threads...'));
115
+ console.log('');
116
+ }