@chrisromp/copilot-bridge 0.7.0 → 0.8.1

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 (47) hide show
  1. package/config.sample.json +9 -1
  2. package/dist/channels/slack/adapter.d.ts +62 -0
  3. package/dist/channels/slack/adapter.d.ts.map +1 -0
  4. package/dist/channels/slack/adapter.js +382 -0
  5. package/dist/channels/slack/adapter.js.map +1 -0
  6. package/dist/channels/slack/mrkdwn.d.ts +22 -0
  7. package/dist/channels/slack/mrkdwn.d.ts.map +1 -0
  8. package/dist/channels/slack/mrkdwn.js +120 -0
  9. package/dist/channels/slack/mrkdwn.js.map +1 -0
  10. package/dist/config.d.ts +5 -1
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js +63 -7
  13. package/dist/config.js.map +1 -1
  14. package/dist/core/access-control.d.ts +32 -0
  15. package/dist/core/access-control.d.ts.map +1 -0
  16. package/dist/core/access-control.js +59 -0
  17. package/dist/core/access-control.js.map +1 -0
  18. package/dist/core/command-handler.d.ts +2 -0
  19. package/dist/core/command-handler.d.ts.map +1 -1
  20. package/dist/core/command-handler.js +75 -1
  21. package/dist/core/command-handler.js.map +1 -1
  22. package/dist/core/inter-agent.d.ts +9 -2
  23. package/dist/core/inter-agent.d.ts.map +1 -1
  24. package/dist/core/inter-agent.js +87 -22
  25. package/dist/core/inter-agent.js.map +1 -1
  26. package/dist/core/model-fallback.js +1 -1
  27. package/dist/core/model-fallback.js.map +1 -1
  28. package/dist/core/session-manager.d.ts +3 -0
  29. package/dist/core/session-manager.d.ts.map +1 -1
  30. package/dist/core/session-manager.js +51 -13
  31. package/dist/core/session-manager.js.map +1 -1
  32. package/dist/index.js +207 -30
  33. package/dist/index.js.map +1 -1
  34. package/dist/types.d.ts +10 -1
  35. package/dist/types.d.ts.map +1 -1
  36. package/package.json +2 -1
  37. package/scripts/check.ts +54 -1
  38. package/scripts/com.copilot-bridge.plist +5 -2
  39. package/scripts/init.ts +322 -117
  40. package/scripts/install-service.ts +32 -1
  41. package/scripts/lib/config-gen.ts +74 -10
  42. package/scripts/lib/prerequisites.ts +17 -5
  43. package/scripts/lib/prompts.ts +4 -0
  44. package/scripts/lib/service.ts +27 -3
  45. package/scripts/lib/slack.ts +190 -0
  46. package/templates/admin/AGENTS.md +5 -5
  47. package/templates/agents/AGENTS.md +1 -1
@@ -12,6 +12,8 @@ export interface BotEntry {
12
12
  token: string;
13
13
  admin: boolean;
14
14
  agent?: string;
15
+ appToken?: string; // Slack Socket Mode app-level token
16
+ access?: { mode: 'allowlist' | 'blocklist' | 'open'; users?: string[] };
15
17
  }
16
18
 
17
19
  export interface ChannelEntry {
@@ -20,6 +22,8 @@ export interface ChannelEntry {
20
22
  platform: string;
21
23
  bot: string;
22
24
  workingDirectory: string;
25
+ triggerMode?: 'all' | 'mention';
26
+ threadedReplies?: boolean;
23
27
  }
24
28
 
25
29
  export interface ConfigDefaults {
@@ -31,9 +35,12 @@ export interface ConfigDefaults {
31
35
 
32
36
  export interface GeneratedConfig {
33
37
  platforms: {
34
- mattermost: {
38
+ mattermost?: {
35
39
  url: string;
36
- bots?: Record<string, { token: string; admin?: boolean; agent?: string }>;
40
+ bots?: Record<string, { token: string; admin?: boolean; agent?: string; access?: { mode: string; users: string[] } }>;
41
+ };
42
+ slack?: {
43
+ bots?: Record<string, { token: string; appToken: string; admin?: boolean; agent?: string; access?: { mode: string; users: string[] } }>;
37
44
  };
38
45
  };
39
46
  channels: Array<{
@@ -42,33 +49,51 @@ export interface GeneratedConfig {
42
49
  platform: string;
43
50
  bot?: string;
44
51
  workingDirectory: string;
52
+ triggerMode?: string;
53
+ threadedReplies?: boolean;
45
54
  }>;
46
55
  defaults?: ConfigDefaults;
47
56
  }
48
57
 
49
58
  export function buildConfig(opts: {
50
- mmUrl: string;
59
+ mmUrl?: string;
51
60
  bots: BotEntry[];
52
61
  channels: ChannelEntry[];
53
62
  defaults?: ConfigDefaults;
63
+ slackBots?: BotEntry[];
54
64
  }): GeneratedConfig {
55
65
  const config: GeneratedConfig = {
56
- platforms: {
57
- mattermost: {
58
- url: opts.mmUrl,
59
- },
60
- },
66
+ platforms: {},
61
67
  channels: [],
62
68
  };
63
69
 
64
- // Always use named bots object (clearer schema, supports admin flag and multi-bot)
65
- if (opts.bots.length > 0) {
70
+ // Mattermost platform
71
+ if (opts.mmUrl && opts.bots.length > 0) {
72
+ config.platforms.mattermost = { url: opts.mmUrl };
66
73
  config.platforms.mattermost.bots = {};
67
74
  for (const bot of opts.bots) {
68
75
  config.platforms.mattermost.bots[bot.name] = {
69
76
  token: bot.token,
70
77
  ...(bot.admin ? { admin: true } : {}),
71
78
  ...(bot.agent ? { agent: bot.agent } : {}),
79
+ ...(bot.access ? { access: bot.access } : {}),
80
+ };
81
+ }
82
+ }
83
+
84
+ // Slack platform
85
+ if (opts.slackBots && opts.slackBots.length > 0) {
86
+ config.platforms.slack = { bots: {} };
87
+ for (const bot of opts.slackBots) {
88
+ if (!bot.appToken) {
89
+ throw new Error(`Slack bot "${bot.name}" is missing required appToken`);
90
+ }
91
+ config.platforms.slack!.bots![bot.name] = {
92
+ token: bot.token,
93
+ appToken: bot.appToken,
94
+ ...(bot.admin ? { admin: true } : {}),
95
+ ...(bot.agent ? { agent: bot.agent } : {}),
96
+ ...(bot.access ? { access: bot.access } : {}),
72
97
  };
73
98
  }
74
99
  }
@@ -80,6 +105,8 @@ export function buildConfig(opts: {
80
105
  platform: ch.platform,
81
106
  bot: ch.bot,
82
107
  workingDirectory: ch.workingDirectory,
108
+ ...(ch.triggerMode ? { triggerMode: ch.triggerMode } : {}),
109
+ ...(ch.threadedReplies !== undefined ? { threadedReplies: ch.threadedReplies } : {}),
83
110
  });
84
111
  }
85
112
 
@@ -111,6 +138,14 @@ export function writeConfig(config: GeneratedConfig): string {
111
138
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
112
139
 
113
140
  const configPath = getConfigPath();
141
+
142
+ // Back up existing config before overwriting
143
+ if (fs.existsSync(configPath)) {
144
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
145
+ const backupPath = `${configPath}.${timestamp}.bak`;
146
+ fs.copyFileSync(configPath, backupPath);
147
+ }
148
+
114
149
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
115
150
  return configPath;
116
151
  }
@@ -124,3 +159,32 @@ export function readExistingConfig(): GeneratedConfig | null {
124
159
  return null;
125
160
  }
126
161
  }
162
+
163
+ /**
164
+ * Merge a new platform's config into an existing config.
165
+ * Preserves all existing platforms, channels, and defaults.
166
+ */
167
+ export function mergeConfig(existing: GeneratedConfig, addition: GeneratedConfig): GeneratedConfig {
168
+ const merged: GeneratedConfig = {
169
+ platforms: { ...existing.platforms },
170
+ channels: [...(existing.channels ?? [])],
171
+ defaults: existing.defaults ?? addition.defaults,
172
+ };
173
+
174
+ // Merge new platforms (don't overwrite existing ones)
175
+ for (const [name, config] of Object.entries(addition.platforms)) {
176
+ if (!merged.platforms[name as keyof typeof merged.platforms]) {
177
+ (merged.platforms as any)[name] = config;
178
+ }
179
+ }
180
+
181
+ // Append new channels (skip duplicates by id)
182
+ const existingIds = new Set(merged.channels.map(c => c.id));
183
+ for (const ch of addition.channels ?? []) {
184
+ if (!existingIds.has(ch.id)) {
185
+ merged.channels.push(ch);
186
+ }
187
+ }
188
+
189
+ return merged;
190
+ }
@@ -4,6 +4,9 @@
4
4
  */
5
5
 
6
6
  import { execSync } from 'node:child_process';
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { join } from 'node:path';
7
10
  import type { CheckResult } from './output.js';
8
11
 
9
12
  export function checkNodeVersion(): CheckResult {
@@ -64,16 +67,25 @@ export function checkGitHubAuth(): CheckResult {
64
67
  return { status: 'pass', label: 'GitHub authenticated', detail: 'via gh CLI' };
65
68
  }
66
69
 
67
- // Check if Copilot CLI has stored credentials
68
- const copilotAuth = tryCommand('copilot auth status 2>&1');
69
- if (copilotAuth && !copilotAuth.includes('not logged in')) {
70
- return { status: 'pass', label: 'GitHub authenticated', detail: 'via Copilot CLI' };
70
+ // Check if Copilot CLI has stored credentials (~/.copilot/config.json)
71
+ try {
72
+ const copilotConfig = join(homedir(), '.copilot', 'config.json');
73
+ if (existsSync(copilotConfig)) {
74
+ const data = JSON.parse(readFileSync(copilotConfig, 'utf-8'));
75
+ const users = data.logged_in_users;
76
+ if (Array.isArray(users) && users.length > 0) {
77
+ const login = users[0].login ?? 'unknown';
78
+ return { status: 'pass', label: 'GitHub authenticated', detail: `via Copilot CLI (${login})` };
79
+ }
80
+ }
81
+ } catch {
82
+ // Fall through
71
83
  }
72
84
 
73
85
  return {
74
86
  status: 'warn',
75
87
  label: 'GitHub authentication',
76
- detail: 'no token found — set COPILOT_GITHUB_TOKEN, run gh auth login, or run copilot auth login',
88
+ detail: 'no token found — set COPILOT_GITHUB_TOKEN, run gh auth login, or run copilot login',
77
89
  };
78
90
  }
79
91
 
@@ -46,6 +46,10 @@ export async function confirm(question: string, defaultYes = true): Promise<bool
46
46
  return answer.toLowerCase().startsWith('y');
47
47
  }
48
48
 
49
+ export async function pressEnter(message = 'Press Enter to continue...'): Promise<void> {
50
+ await ask(message);
51
+ }
52
+
49
53
  export async function choose(question: string, options: string[], defaultIndex = 0): Promise<number> {
50
54
  console.log(`\n${question}`);
51
55
  for (let i = 0; i < options.length; i++) {
@@ -38,6 +38,10 @@ export function getSystemPath(): string {
38
38
  return `${nodeBinDir}:${basePath}`;
39
39
  }
40
40
 
41
+ export function getLogPath(homePath: string): string {
42
+ return path.join(homePath, '.copilot-bridge', 'copilot-bridge.log');
43
+ }
44
+
41
45
  // --- launchd (macOS) ---
42
46
 
43
47
  export interface LaunchdConfig {
@@ -49,6 +53,7 @@ export interface LaunchdConfig {
49
53
  export function generateLaunchdPlist(config: LaunchdConfig): string {
50
54
  const nodePath = getNodePath();
51
55
  const tsxPath = path.join(config.bridgePath, 'node_modules', '.bin', 'tsx');
56
+ const logPath = getLogPath(config.homePath);
52
57
  return `<?xml version="1.0" encoding="UTF-8"?>
53
58
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
54
59
  <plist version="1.0">
@@ -83,11 +88,14 @@ export function generateLaunchdPlist(config: LaunchdConfig): string {
83
88
  <key>ThrottleInterval</key>
84
89
  <integer>10</integer>
85
90
 
91
+ <key>Umask</key>
92
+ <integer>63</integer>
93
+
86
94
  <key>StandardOutPath</key>
87
- <string>/tmp/copilot-bridge.log</string>
95
+ <string>${logPath}</string>
88
96
 
89
97
  <key>StandardErrorPath</key>
90
- <string>/tmp/copilot-bridge.log</string>
98
+ <string>${logPath}</string>
91
99
  </dict>
92
100
  </plist>`;
93
101
  }
@@ -111,6 +119,21 @@ export function installLaunchd(plistContent: string): { installed: boolean; path
111
119
  }
112
120
  }
113
121
 
122
+ export function generateNewsyslogConfig(logPath: string, user: string): string {
123
+ let group = 'staff';
124
+ try { group = execSync('id -gn', { encoding: 'utf-8' }).trim(); } catch { /* default */ }
125
+ // N=no signal, C=create new file after rotation, Z=gzip compress
126
+ // Rotates at 10 MB, keeps 3 archives
127
+ return `# Copilot Bridge log rotation — installed by copilot-bridge install-service
128
+ # logfilename owner:group mode count size(KB) when flags
129
+ ${logPath} ${user}:${group} 600 3 10240 * NCZ
130
+ `;
131
+ }
132
+
133
+ export function getNewsyslogInstallPath(): string {
134
+ return '/etc/newsyslog.d/copilot-bridge.conf';
135
+ }
136
+
114
137
  // --- systemd (Linux) ---
115
138
 
116
139
  export interface SystemdConfig {
@@ -129,12 +152,13 @@ After=network.target
129
152
  [Service]
130
153
  Type=simple
131
154
  User=${config.user}
132
- ExecStart=${nodePath} ${tsxPath} ${config.bridgePath}/dist/index.js
155
+ ExecStart="${nodePath}" "${tsxPath}" "${config.bridgePath}/dist/index.js"
133
156
  WorkingDirectory=${config.bridgePath}
134
157
  Environment=HOME=${config.homePath}
135
158
  Environment=PATH=${getSystemPath()}
136
159
  Restart=always
137
160
  RestartSec=10
161
+ UMask=0077
138
162
 
139
163
  [Install]
140
164
  WantedBy=multi-user.target`;
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Slack setup helpers for the init wizard.
3
+ * Generates app manifests and validates tokens.
4
+ */
5
+
6
+ /**
7
+ * Generate a Slack App Manifest for copilot-bridge.
8
+ * This pre-configures all required scopes, events, and Socket Mode.
9
+ */
10
+ export function generateManifest(botName: string): object {
11
+ return {
12
+ display_information: {
13
+ name: botName,
14
+ description: 'GitHub Copilot bridge for Slack',
15
+ background_color: '#1a1a2e',
16
+ },
17
+ features: {
18
+ app_home: {
19
+ messages_tab_enabled: true,
20
+ messages_tab_read_only_enabled: false,
21
+ },
22
+ bot_user: {
23
+ display_name: botName,
24
+ always_online: true,
25
+ },
26
+ },
27
+ oauth_config: {
28
+ scopes: {
29
+ bot: [
30
+ 'chat:write',
31
+ 'chat:write.public',
32
+ 'channels:history',
33
+ 'channels:read',
34
+ 'groups:read',
35
+ 'groups:history',
36
+ 'im:history',
37
+ 'im:read',
38
+ 'im:write',
39
+ 'files:read',
40
+ 'files:write',
41
+ 'reactions:read',
42
+ 'reactions:write',
43
+ 'users:read',
44
+ ],
45
+ },
46
+ },
47
+ settings: {
48
+ event_subscriptions: {
49
+ bot_events: [
50
+ 'message.channels',
51
+ 'message.groups',
52
+ 'message.im',
53
+ 'reaction_added',
54
+ 'reaction_removed',
55
+ ],
56
+ },
57
+ interactivity: {
58
+ is_enabled: false,
59
+ },
60
+ org_deploy_enabled: false,
61
+ socket_mode_enabled: true,
62
+ token_rotation_enabled: false,
63
+ },
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Generate a URL that opens Slack's "Create App" page with the manifest pre-filled.
69
+ */
70
+ export function generateManifestUrl(botName: string): string {
71
+ const manifest = generateManifest(botName);
72
+ const encoded = encodeURIComponent(JSON.stringify(manifest));
73
+ return `https://api.slack.com/apps?new_app=1&manifest_json=${encoded}`;
74
+ }
75
+
76
+ /**
77
+ * Validate a Slack bot token by calling auth.test.
78
+ * Returns bot info on success or null on failure.
79
+ */
80
+ export async function validateSlackToken(token: string): Promise<{
81
+ ok: boolean;
82
+ userId?: string;
83
+ botName?: string;
84
+ teamName?: string;
85
+ error?: string;
86
+ }> {
87
+ try {
88
+ const resp = await fetch('https://slack.com/api/auth.test', {
89
+ method: 'POST',
90
+ headers: {
91
+ 'Authorization': `Bearer ${token}`,
92
+ 'Content-Type': 'application/x-www-form-urlencoded',
93
+ },
94
+ });
95
+ if (!resp.ok) return { ok: false, error: `HTTP ${resp.status}` };
96
+
97
+ const data = await resp.json() as any;
98
+ if (!data.ok) return { ok: false, error: data.error };
99
+
100
+ return {
101
+ ok: true,
102
+ userId: data.user_id,
103
+ botName: data.user,
104
+ teamName: data.team,
105
+ };
106
+ } catch (err: any) {
107
+ return { ok: false, error: err.message };
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Validate a Slack app-level token by attempting a connections.open.
113
+ * This confirms Socket Mode will work.
114
+ */
115
+ export async function validateAppToken(appToken: string): Promise<{
116
+ ok: boolean;
117
+ error?: string;
118
+ }> {
119
+ try {
120
+ const resp = await fetch('https://slack.com/api/apps.connections.open', {
121
+ method: 'POST',
122
+ headers: {
123
+ 'Authorization': `Bearer ${appToken}`,
124
+ 'Content-Type': 'application/x-www-form-urlencoded',
125
+ },
126
+ });
127
+ if (!resp.ok) return { ok: false, error: `HTTP ${resp.status}` };
128
+
129
+ const data = await resp.json() as any;
130
+ if (!data.ok) return { ok: false, error: data.error };
131
+
132
+ return { ok: true };
133
+ } catch (err: any) {
134
+ return { ok: false, error: err.message };
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Resolve a Slack username/handle to a user ID.
140
+ * Uses the users.list API with pagination to find a matching user.
141
+ * Handle is case-insensitive with leading @ stripped.
142
+ */
143
+ export async function resolveSlackUser(botToken: string, handle: string): Promise<{
144
+ userId: string | null;
145
+ displayName?: string;
146
+ error?: string;
147
+ }> {
148
+ const normalized = handle.replace(/^@/, '').toLowerCase();
149
+ let cursor: string | undefined;
150
+
151
+ try {
152
+ do {
153
+ const params = new URLSearchParams({ limit: '200' });
154
+ if (cursor) params.set('cursor', cursor);
155
+
156
+ const resp = await fetch(`https://slack.com/api/users.list?${params}`, {
157
+ headers: { 'Authorization': `Bearer ${botToken}` },
158
+ });
159
+ if (!resp.ok) return { userId: null, error: `HTTP ${resp.status}` };
160
+
161
+ const data = await resp.json() as any;
162
+ if (!data.ok) return { userId: null, error: data.error };
163
+
164
+ for (const member of data.members ?? []) {
165
+ if (member.deleted || member.is_bot) continue;
166
+ const name = (member.name ?? '').toLowerCase();
167
+ // Prefer unique handle (member.name) over display/real name for security
168
+ if (name === normalized) {
169
+ return { userId: member.id, displayName: member.profile?.display_name || member.real_name || member.name };
170
+ }
171
+ }
172
+
173
+ // Second pass: try display_name and real_name (less reliable, not unique)
174
+ for (const member of data.members ?? []) {
175
+ if (member.deleted || member.is_bot) continue;
176
+ const displayName = member.profile?.display_name_normalized?.toLowerCase() ?? '';
177
+ const realName = member.profile?.real_name_normalized?.toLowerCase() ?? '';
178
+ if (displayName === normalized || realName === normalized) {
179
+ return { userId: member.id, displayName: member.profile?.display_name || member.real_name || member.name };
180
+ }
181
+ }
182
+
183
+ cursor = data.response_metadata?.next_cursor || undefined;
184
+ } while (cursor);
185
+
186
+ return { userId: null, error: `User "${handle}" not found` };
187
+ } catch (err: any) {
188
+ return { userId: null, error: err.message };
189
+ }
190
+ }
@@ -1,6 +1,6 @@
1
1
  # Admin Agent — copilot-bridge
2
2
 
3
- You are the **admin agent** for copilot-bridge, a service that bridges GitHub Copilot CLI to messaging platforms (e.g., Mattermost).
3
+ You are the **admin agent** for copilot-bridge, a service that bridges GitHub Copilot CLI to messaging platforms (e.g., Mattermost, Slack).
4
4
 
5
5
  **Source repo**: https://github.com/ChrisRomp/copilot-bridge
6
6
  **Bridge config**: `~/.copilot-bridge/config.json` (resolution: `COPILOT_BRIDGE_CONFIG` env → `~/.copilot-bridge/config.json` → `cwd/config.json`)
@@ -12,7 +12,7 @@ You are a bot — use **it/its** pronouns when referring to yourself or other bo
12
12
 
13
13
  ## How You Communicate
14
14
 
15
- - You receive messages from a chat platform (Mattermost)
15
+ - You receive messages from a chat platform (e.g., Mattermost, Slack)
16
16
  - Your responses are streamed back to the same channel
17
17
  - Slash commands (e.g., `/new`, `/model`, `/verbose`) are intercepted by the bridge — you won't see them
18
18
  - The user may be on mobile; keep responses concise when possible
@@ -181,9 +181,9 @@ A bridge restart is needed for removals to take effect.
181
181
  ## Bridge Architecture (Reference)
182
182
 
183
183
  ```
184
- Mattermost Channel → copilot-bridge → @github/copilot-sdk → Copilot CLI
185
-
186
- └──────────── streaming response (edit-in-place) ←─────────┘
184
+ Chat Platform (Mattermost/Slack) → copilot-bridge → @github/copilot-sdk → Copilot CLI
185
+
186
+ └──────────── streaming response (edit-in-place) ←───────────────────┘
187
187
  ```
188
188
 
189
189
  - Each channel maps to a Copilot session with a working directory, model, and optional agent
@@ -14,7 +14,7 @@ You are a bot — use **it/its** pronouns when referring to yourself or other bo
14
14
 
15
15
  ## How You Communicate
16
16
 
17
- - You receive messages from a chat platform (Mattermost)
17
+ - You receive messages from a chat platform (e.g., Mattermost, Slack)
18
18
  - Your responses are streamed back to the same channel
19
19
  - Slash commands (e.g., `/new`, `/model`, `/verbose`) are intercepted by the bridge — you won't see them
20
20
  - The user may be on mobile; keep responses concise when possible