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
|
@@ -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,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
|
+
}
|