@chrisromp/copilot-bridge 0.7.0 → 0.8.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 (38) hide show
  1. package/config.sample.json +8 -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 +55 -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.map +1 -1
  19. package/dist/core/command-handler.js +75 -1
  20. package/dist/core/command-handler.js.map +1 -1
  21. package/dist/core/inter-agent.d.ts +9 -2
  22. package/dist/core/inter-agent.d.ts.map +1 -1
  23. package/dist/core/inter-agent.js +87 -22
  24. package/dist/core/inter-agent.js.map +1 -1
  25. package/dist/core/model-fallback.js +1 -1
  26. package/dist/core/model-fallback.js.map +1 -1
  27. package/dist/index.js +182 -14
  28. package/dist/index.js.map +1 -1
  29. package/dist/types.d.ts +9 -1
  30. package/dist/types.d.ts.map +1 -1
  31. package/package.json +2 -1
  32. package/scripts/init.ts +322 -117
  33. package/scripts/lib/config-gen.ts +74 -10
  34. package/scripts/lib/prerequisites.ts +17 -5
  35. package/scripts/lib/prompts.ts +4 -0
  36. package/scripts/lib/slack.ts +190 -0
  37. package/templates/admin/AGENTS.md +5 -5
  38. package/templates/agents/AGENTS.md +1 -1
package/scripts/init.ts CHANGED
@@ -9,12 +9,34 @@
9
9
  import * as fs from 'node:fs';
10
10
  import * as path from 'node:path';
11
11
  import { heading, success, warn, fail, info, dim, blank, printCheck } from './lib/output.js';
12
- import { askRequired, askSecret, confirm, choose, closePrompts } from './lib/prompts.js';
12
+ import { askRequired, askSecret, confirm, choose, pressEnter, closePrompts } from './lib/prompts.js';
13
13
  import { runAllPrereqs, checkNodeVersion } from './lib/prerequisites.js';
14
14
  import { pingServer, validateBotToken, checkChannelAccess, getChannelInfo } from './lib/mattermost.js';
15
- import { buildConfig, writeConfig, configExists, getConfigPath, getConfigDir, type BotEntry, type ChannelEntry, type ConfigDefaults } from './lib/config-gen.js';
15
+ import { buildConfig, writeConfig, configExists, getConfigPath, getConfigDir, readExistingConfig, mergeConfig, type BotEntry, type ChannelEntry, type ConfigDefaults } from './lib/config-gen.js';
16
+ import { generateManifestUrl, validateSlackToken, validateAppToken, resolveSlackUser } from './lib/slack.js';
16
17
  import { detectPlatform, getServiceStatus } from './lib/service.js';
17
18
 
19
+ async function promptAllowlist(botName: string): Promise<{ mode: 'allowlist'; users: string[] } | undefined> {
20
+ info(`\nAccess control: who can use "${botName}"?`);
21
+ dim('By default, only users you list here can interact with this bot.\n');
22
+
23
+ let yourHandle = await askRequired('Your username (you\'ll be added to the allowlist)');
24
+ yourHandle = yourHandle.replace(/^@/, '').trim();
25
+ const users = [yourHandle];
26
+
27
+ const extra = await confirm('Add more users to the allowlist?', false);
28
+ if (extra) {
29
+ const moreRaw = await askRequired('Additional usernames (comma-separated)');
30
+ for (const u of moreRaw.split(',')) {
31
+ const trimmed = u.replace(/^@/, '').trim();
32
+ if (trimmed.length > 0) users.push(trimmed);
33
+ }
34
+ }
35
+
36
+ success(`Allowlist: ${users.join(', ')}`);
37
+ return { mode: 'allowlist', users };
38
+ }
39
+
18
40
  async function main() {
19
41
  const isCli = process.env.COPILOT_BRIDGE_CLI === '1';
20
42
  console.log();
@@ -47,164 +69,344 @@ async function main() {
47
69
  }
48
70
 
49
71
  // --- Check for existing config ---
50
- if (configExists()) {
72
+ let existingConfig = configExists() ? readExistingConfig() : null;
73
+ let mergeMode = false;
74
+
75
+ if (existingConfig) {
51
76
  blank();
52
77
  warn(`Existing config found at ${getConfigPath()}`);
53
- if (!await confirm('Overwrite with a new config?', false)) {
78
+ const existingPlatforms = Object.keys(existingConfig.platforms ?? {});
79
+ info(`Current platforms: ${existingPlatforms.join(', ') || 'none'}`);
80
+
81
+ const action = await choose('What would you like to do?', [
82
+ 'Add a platform (keep existing config)',
83
+ 'Start fresh (overwrite)',
84
+ 'Cancel',
85
+ ]);
86
+
87
+ if (action === 2) {
54
88
  info(isCli
55
89
  ? 'Run "copilot-bridge check" to validate your existing config.'
56
90
  : 'Run "npm run check" to validate your existing config.');
57
91
  closePrompts();
58
92
  process.exit(0);
59
93
  }
94
+ mergeMode = action === 0;
95
+ if (!mergeMode) existingConfig = null;
60
96
  }
61
97
 
62
- // --- Step 2: Mattermost connection ---
63
- heading('Step 2: Mattermost Connection');
64
- info('Connect to your Mattermost instance. You\'ll need the URL and a bot token.');
65
- dim('Create bot accounts in Mattermost: System Console → Integrations → Bot Accounts\n');
98
+ // --- Step 2: Platform selection ---
99
+ heading('Step 2: Platform');
66
100
 
67
- let mmUrl = '';
68
- while (true) {
69
- mmUrl = await askRequired('Mattermost URL (e.g., https://chat.example.com)');
70
- mmUrl = mmUrl.replace(/\/+$/, '');
71
- if (!mmUrl.startsWith('http')) mmUrl = `https://${mmUrl}`;
72
-
73
- const ping = await pingServer(mmUrl);
74
- printCheck(ping);
75
- if (ping.status === 'pass' || ping.status === 'warn') break;
76
- if (!await confirm('Try a different URL?')) {
77
- warn('Continuing with unverified URL.');
78
- break;
101
+ let useMattermost = false;
102
+ let useSlack = false;
103
+
104
+ if (mergeMode && existingConfig) {
105
+ // In merge mode, only offer platforms not yet configured
106
+ const hasMattermost = !!existingConfig.platforms?.mattermost;
107
+ const hasSlack = !!existingConfig.platforms?.slack;
108
+ const available: string[] = [];
109
+ if (!hasMattermost) available.push('Mattermost');
110
+ if (!hasSlack) available.push('Slack');
111
+
112
+ if (available.length === 0) {
113
+ info('Both platforms are already configured.');
114
+ closePrompts();
115
+ process.exit(0);
116
+ } else if (available.length === 1) {
117
+ info(`Adding ${available[0]} to your existing config.`);
118
+ useMattermost = available[0] === 'Mattermost';
119
+ useSlack = available[0] === 'Slack';
120
+ } else {
121
+ const idx = await choose('Which platform to add?', [...available, 'Both']);
122
+ if (idx === available.length) {
123
+ useMattermost = true;
124
+ useSlack = true;
125
+ } else {
126
+ useMattermost = available[idx] === 'Mattermost';
127
+ useSlack = available[idx] === 'Slack';
128
+ }
79
129
  }
130
+ } else {
131
+ const platformChoice = await choose('Which chat platform(s)?', [
132
+ 'Mattermost',
133
+ 'Slack',
134
+ 'Both',
135
+ ]);
136
+ useMattermost = platformChoice === 0 || platformChoice === 2;
137
+ useSlack = platformChoice === 1 || platformChoice === 2;
80
138
  }
81
139
 
82
- // --- Step 3: Bot configuration ---
83
- heading('Step 3: Bot Configuration');
84
-
140
+ // --- Step 3: Platform-specific setup ---
141
+ let mmUrl = '';
85
142
  const bots: BotEntry[] = [];
86
- let addMore = true;
143
+ const slackBots: BotEntry[] = [];
144
+ const channels: ChannelEntry[] = [];
145
+
146
+ // ── Mattermost ──────────────────────────────────────────
147
+ if (useMattermost) {
148
+ heading('Step 3a: Mattermost Connection');
149
+ info('Connect to your Mattermost instance. You\'ll need the URL and a bot token.');
150
+ dim('Create bot accounts in Mattermost: System Console → Integrations → Bot Accounts\n');
151
+
152
+ while (true) {
153
+ mmUrl = await askRequired('Mattermost URL (e.g., https://chat.example.com)');
154
+ mmUrl = mmUrl.replace(/\/+$/, '');
155
+ if (!mmUrl.startsWith('http')) mmUrl = `https://${mmUrl}`;
156
+
157
+ const ping = await pingServer(mmUrl);
158
+ printCheck(ping);
159
+ if (ping.status === 'pass' || ping.status === 'warn') break;
160
+ if (!await confirm('Try a different URL?')) {
161
+ warn('Continuing with unverified URL.');
162
+ break;
163
+ }
164
+ }
165
+
166
+ heading('Mattermost Bots');
167
+ let addMore = true;
168
+ while (addMore) {
169
+ if (bots.length === 0) {
170
+ info('Enter the bot token from your Mattermost bot account.');
171
+ dim('You can add more bots later if you want multiple identities.\n');
172
+ }
173
+
174
+ const token = await askSecret(`Bot token${bots.length > 0 ? ' (for next bot)' : ''}`);
175
+ const validation = await validateBotToken(mmUrl, token);
176
+ printCheck(validation.result);
177
+
178
+ if (validation.result.status === 'pass' && validation.bot) {
179
+ const isAdmin = validation.bot.roles?.includes('system_admin')
180
+ || await confirm(`Is "${validation.bot.username}" an admin bot?`, false);
181
+
182
+ bots.push({
183
+ name: validation.bot.username,
184
+ token,
185
+ admin: !!isAdmin,
186
+ });
187
+ success(`Added bot "${validation.bot.username}"${isAdmin ? ' (admin)' : ''}`);
188
+ const access = await promptAllowlist(validation.bot.username);
189
+ if (access) bots[bots.length - 1].access = access;
190
+ } else {
191
+ warn(isCli
192
+ ? 'Token validation failed. The token was still added — verify it later with "copilot-bridge check".'
193
+ : 'Token validation failed. The token was still added — verify it later with "npm run check".');
194
+ let name = await askRequired('Bot username (for config)');
195
+ name = name.replace(/^@/, '');
196
+ bots.push({ name, token, admin: false });
197
+ const access = await promptAllowlist(name);
198
+ if (access) bots[bots.length - 1].access = access;
199
+ }
87
200
 
88
- while (addMore) {
89
- if (bots.length === 0) {
90
- info('Enter the bot token from your Mattermost bot account.');
91
- dim('You can add more bots later if you want multiple identities.\n');
201
+ if (bots.length >= 1) {
202
+ addMore = await confirm('Add another bot?', false);
203
+ }
92
204
  }
93
205
 
94
- const token = await askSecret(`Bot token${bots.length > 0 ? ' (for next bot)' : ''}`);
95
- const validation = await validateBotToken(mmUrl, token);
96
- printCheck(validation.result);
206
+ // Mattermost channels
207
+ heading('Mattermost Channels');
208
+ info('Direct messages work automatically — no config needed.');
209
+ info('Group channels need their channel ID and a working directory.\n');
97
210
 
98
- if (validation.result.status === 'pass' && validation.bot) {
99
- const isAdmin = validation.bot.roles?.includes('system_admin')
100
- || await confirm(`Is "${validation.bot.username}" an admin bot?`, false);
211
+ let addChannels = await confirm('Configure group channels now?', false);
212
+ while (addChannels) {
213
+ const channelId = await askRequired('Channel ID (from Mattermost channel settings → View Info)');
101
214
 
102
- bots.push({
103
- name: validation.bot.username,
104
- token,
105
- admin: !!isAdmin,
215
+ const primaryBot = bots[0];
216
+ const access = await checkChannelAccess(mmUrl, primaryBot.token, channelId);
217
+ printCheck(access);
218
+
219
+ let channelName: string | undefined;
220
+ if (access.status === 'pass') {
221
+ const chInfo = await getChannelInfo(mmUrl, primaryBot.token, channelId);
222
+ channelName = chInfo?.displayName || chInfo?.name;
223
+ }
224
+
225
+ const workDir = await askRequired('Working directory (absolute path for this channel\'s workspace)');
226
+ if (!fs.existsSync(workDir)) {
227
+ if (await confirm(`Directory "${workDir}" doesn't exist. Create it?`)) {
228
+ fs.mkdirSync(workDir, { recursive: true });
229
+ success(`Created ${workDir}`);
230
+ }
231
+ }
232
+
233
+ let botName = bots[0].name;
234
+ if (bots.length > 1) {
235
+ const idx = await choose('Which bot for this channel?', bots.map(b => b.name));
236
+ botName = bots[idx].name;
237
+ }
238
+
239
+ const triggerIdx = await choose('Trigger mode for this channel?', [
240
+ 'mention — respond only when @mentioned (recommended)',
241
+ 'all — respond to every message',
242
+ ]);
243
+ const triggerMode = triggerIdx === 0 ? 'mention' as const : 'all' as const;
244
+ const threadedReplies = await confirm('Reply in threads?', true);
245
+
246
+ channels.push({
247
+ id: channelId,
248
+ name: channelName,
249
+ platform: 'mattermost',
250
+ bot: botName,
251
+ workingDirectory: workDir,
252
+ triggerMode,
253
+ threadedReplies,
106
254
  });
107
- success(`Added bot "${validation.bot.username}"${isAdmin ? ' (admin)' : ''}`);
108
- } else {
109
- warn(isCli
110
- ? 'Token validation failed. The token was still added — verify it later with "copilot-bridge check".'
111
- : 'Token validation failed. The token was still added — verify it later with "npm run check".');
112
- let name = await askRequired('Bot username (for config)');
113
- name = name.replace(/^@/, '');
114
- bots.push({ name, token, admin: false });
255
+ success(`Added channel${channelName ? ` "${channelName}"` : ''}`);
256
+ addChannels = await confirm('Add another channel?', false);
115
257
  }
116
258
 
117
- if (bots.length >= 1) {
118
- addMore = await confirm('Add another bot?', false);
259
+ if (channels.length === 0) {
260
+ info('No group channels configured. DMs will still work automatically.');
119
261
  }
120
262
  }
121
263
 
122
- // --- Step 4: Channel configuration ---
123
- heading('Step 4: Channel Configuration');
124
- info('Direct messages work automatically no config needed.');
125
- info('Group channels need their channel ID and a working directory.\n');
264
+ // ── Slack ───────────────────────────────────────────────
265
+ if (useSlack) {
266
+ heading(useMattermost ? 'Step 3b: Slack Connection' : 'Step 3: Slack Connection');
267
+ info('We\'ll create a Slack app with the right permissions via a manifest URL.');
268
+ blank();
126
269
 
127
- const channels: ChannelEntry[] = [];
128
- let addChannels = await confirm('Configure group channels now?', false);
270
+ const botDisplayName = await askRequired('Bot display name for Slack (e.g., copilot)');
271
+ const manifestUrl = generateManifestUrl(botDisplayName);
129
272
 
130
- while (addChannels) {
131
- const channelId = await askRequired('Channel ID (from Mattermost channel settings → View Info)');
273
+ info('Open this URL in your browser to create the Slack app:');
274
+ blank();
275
+ console.log(` ${manifestUrl}`);
276
+ blank();
277
+ dim('Steps in Slack:');
278
+ dim(' 1. Click the link above → review the manifest → Create');
279
+ dim(' 2. On the app page, go to "OAuth & Permissions" → Install to Workspace');
280
+ dim(' 3. Copy the "Bot User OAuth Token" (starts with xoxb-)');
281
+ dim(' 4. Go to "Basic Information" → "App-Level Tokens" → Generate Token');
282
+ dim(' Name it anything, add the "connections:write" scope, then Generate');
283
+ dim(' 5. Copy the app-level token (starts with xapp-)');
284
+ blank();
285
+
286
+ await pressEnter('Press Enter when you\'re ready to paste the tokens...');
132
287
 
133
- // Validate channel access
134
- const primaryBot = bots[0];
135
- const access = await checkChannelAccess(mmUrl, primaryBot.token, channelId);
136
- printCheck(access);
288
+ // Bot token
289
+ const botToken = await askSecret('Bot User OAuth Token (xoxb-...)');
290
+ const tokenResult = await validateSlackToken(botToken);
291
+ if (tokenResult.ok) {
292
+ success(`Authenticated as @${tokenResult.botName} in ${tokenResult.teamName}`);
293
+ } else {
294
+ warn(`Token validation failed: ${tokenResult.error}. Added anyway — verify later.`);
295
+ }
137
296
 
138
- let channelName: string | undefined;
139
- if (access.status === 'pass') {
140
- const chInfo = await getChannelInfo(mmUrl, primaryBot.token, channelId);
141
- channelName = chInfo?.displayName || chInfo?.name;
297
+ // App token
298
+ const appToken = await askSecret('App-Level Token (xapp-...)');
299
+ const appResult = await validateAppToken(appToken);
300
+ if (appResult.ok) {
301
+ success('Socket Mode connection verified');
302
+ } else {
303
+ warn(`App token validation failed: ${appResult.error}. Added anyway — verify later.`);
142
304
  }
143
305
 
144
- const workDir = await askRequired('Working directory (absolute path for this channel\'s workspace)');
306
+ const slackBotName = tokenResult.botName ?? botDisplayName;
307
+ const isAdmin = await confirm(`Is "${slackBotName}" an admin bot?`, false);
145
308
 
146
- // Create working directory if it doesn't exist
147
- if (!fs.existsSync(workDir)) {
148
- if (await confirm(`Directory "${workDir}" doesn't exist. Create it?`)) {
149
- fs.mkdirSync(workDir, { recursive: true });
150
- success(`Created ${workDir}`);
309
+ slackBots.push({
310
+ name: slackBotName,
311
+ token: botToken,
312
+ appToken,
313
+ admin: isAdmin,
314
+ });
315
+ success(`Added Slack bot "${slackBotName}"${isAdmin ? ' (admin)' : ''}`);
316
+
317
+ // Allowlist — resolve Slack handles to UIDs
318
+ const access = await promptAllowlist(slackBotName);
319
+ if (access) {
320
+ const resolvedUsers: string[] = [];
321
+ for (const handle of access.users) {
322
+ const result = await resolveSlackUser(botToken, handle);
323
+ if (result.userId) {
324
+ success(` Resolved "${handle}" → ${result.userId}${result.displayName ? ` (${result.displayName})` : ''}`);
325
+ resolvedUsers.push(result.userId);
326
+ } else {
327
+ warn(` Could not resolve "${handle}"${result.error ? `: ${result.error}` : ''} — storing as-is`);
328
+ resolvedUsers.push(handle);
329
+ }
151
330
  }
331
+ slackBots[slackBots.length - 1].access = { mode: 'allowlist', users: resolvedUsers };
152
332
  }
333
+ // Slack channels (DMs auto-discovered, channels optional)
334
+ blank();
335
+ info('Slack DMs work automatically. You can optionally configure specific channels.');
336
+ let addSlackChannels = await confirm('Configure Slack channels now?', false);
337
+ while (addSlackChannels) {
338
+ const channelId = await askRequired('Slack channel ID (right-click channel → View channel details → copy ID at bottom)');
339
+ const workDir = await askRequired('Working directory (absolute path for this channel\'s workspace)');
340
+ if (!fs.existsSync(workDir)) {
341
+ if (await confirm(`Directory "${workDir}" doesn't exist. Create it?`)) {
342
+ fs.mkdirSync(workDir, { recursive: true });
343
+ success(`Created ${workDir}`);
344
+ }
345
+ }
153
346
 
154
- // If multiple bots, ask which one
155
- let botName = bots[0].name;
156
- if (bots.length > 1) {
157
- const idx = await choose('Which bot for this channel?', bots.map(b => b.name));
158
- botName = bots[idx].name;
347
+ const triggerIdx = await choose('Trigger mode for this channel?', [
348
+ 'mention respond only when @mentioned (recommended)',
349
+ 'all respond to every message',
350
+ ]);
351
+ const triggerMode = triggerIdx === 0 ? 'mention' as const : 'all' as const;
352
+ const threadedReplies = await confirm('Reply in threads?', true);
353
+
354
+ channels.push({
355
+ id: channelId,
356
+ platform: 'slack',
357
+ bot: slackBotName,
358
+ workingDirectory: workDir,
359
+ triggerMode,
360
+ threadedReplies,
361
+ });
362
+ success('Added Slack channel');
363
+ addSlackChannels = await confirm('Add another Slack channel?', false);
159
364
  }
365
+ }
160
366
 
161
- channels.push({
162
- id: channelId,
163
- name: channelName,
164
- platform: 'mattermost',
165
- bot: botName,
166
- workingDirectory: workDir,
167
- });
168
- success(`Added channel${channelName ? ` "${channelName}"` : ''}`);
367
+ // --- Step 4: Defaults (skip in merge mode — existing config has them) ---
368
+ const defaults: ConfigDefaults = {};
169
369
 
170
- addChannels = await confirm('Add another channel?', false);
171
- }
370
+ if (!mergeMode) {
371
+ heading('Step 4: Defaults');
372
+ dim('These can be changed later in config.json or via chat commands.\n');
373
+
374
+ const modelChoice = await choose('Default model?', [
375
+ 'claude-sonnet-4.6 (recommended)',
376
+ 'claude-opus-4.6 (premium)',
377
+ 'claude-haiku-4.5 (fast/cheap)',
378
+ 'Other (enter manually)',
379
+ ]);
380
+ if (modelChoice === 3) {
381
+ defaults.model = await askRequired('Model name');
382
+ } else {
383
+ defaults.model = ['claude-sonnet-4.6', 'claude-opus-4.6', 'claude-haiku-4.5'][modelChoice];
384
+ }
172
385
 
173
- if (channels.length === 0) {
174
- info('No group channels configured. DMs will still work automatically.');
386
+ const triggerChoice = await choose('Default trigger mode (for group channels DMs always respond)?', [
387
+ 'mention bot responds only when @mentioned (recommended)',
388
+ 'all — bot responds to every message in the channel',
389
+ ]);
390
+ defaults.triggerMode = triggerChoice === 0 ? 'mention' : 'all';
391
+ defaults.threadedReplies = await confirm('Reply in threads by default?', true);
392
+ defaults.verbose = await confirm('Verbose mode (show tool calls)?', false);
175
393
  }
176
394
 
177
- // --- Step 5: Defaults ---
178
- heading('Step 5: Defaults');
179
- dim('These can be changed later in config.json or via chat commands.\n');
395
+ // --- Step 5: Generate config ---
396
+ heading('Step 5: Generate Config');
180
397
 
181
- const defaults: ConfigDefaults = {};
398
+ let finalConfig = buildConfig({ mmUrl: mmUrl || undefined, bots, channels, defaults, slackBots });
182
399
 
183
- const modelChoice = await choose('Default model?', [
184
- 'claude-sonnet-4.6 (recommended)',
185
- 'claude-opus-4.6 (premium)',
186
- 'claude-haiku-4.5 (fast/cheap)',
187
- 'Other (enter manually)',
188
- ]);
189
- if (modelChoice === 3) {
190
- defaults.model = await askRequired('Model name');
191
- } else {
192
- defaults.model = ['claude-sonnet-4.6', 'claude-opus-4.6', 'claude-haiku-4.5'][modelChoice];
400
+ // Merge with existing config if in merge mode
401
+ if (mergeMode && existingConfig) {
402
+ finalConfig = mergeConfig(existingConfig, finalConfig);
403
+ info('Merged new platform into existing config.');
193
404
  }
194
405
 
195
- const triggerChoice = await choose('Default trigger mode (for group channels — DMs always respond)?', [
196
- 'mention bot responds only when @mentioned (recommended)',
197
- 'all — bot responds to every message in the channel',
198
- ]);
199
- defaults.triggerMode = triggerChoice === 0 ? 'mention' : 'all';
200
- defaults.threadedReplies = await confirm('Reply in threads by default?', true);
201
- defaults.verbose = await confirm('Verbose mode (show tool calls)?', false);
202
-
203
- // --- Step 6: Generate config ---
204
- heading('Step 6: Generate Config');
205
-
206
- const config = buildConfig({ mmUrl, bots, channels, defaults });
207
- const configPath = writeConfig(config);
406
+ if (configExists()) {
407
+ dim('Backing up existing config before writing...');
408
+ }
409
+ const configPath = writeConfig(finalConfig);
208
410
  success(`Config written to ${configPath}`);
209
411
 
210
412
  // Ensure workspaces dir exists
@@ -213,8 +415,8 @@ async function main() {
213
415
  fs.mkdirSync(workspacesDir, { recursive: true });
214
416
  }
215
417
 
216
- // --- Step 7: Service setup ---
217
- heading('Step 7: Service Setup (Optional)');
418
+ // --- Step 6: Service Setup (Optional) ---
419
+ heading('Step 6: Service Setup (Optional)');
218
420
 
219
421
  const osPlatform = detectPlatform();
220
422
  if (osPlatform === 'macos') {
@@ -235,7 +437,10 @@ async function main() {
235
437
  heading('✅ Setup Complete');
236
438
  blank();
237
439
  info(`Config: ${configPath}`);
238
- info(`Bots: ${bots.map(b => b.name).join(', ')}`);
440
+ const allBotNames = [...bots.map(b => b.name), ...slackBots.map(b => b.name)];
441
+ info(`Bots: ${allBotNames.join(', ')}`);
442
+ const platforms = [useMattermost ? 'Mattermost' : null, useSlack ? 'Slack' : null].filter(Boolean);
443
+ info(`Platforms: ${platforms.join(', ')}`);
239
444
  if (channels.length > 0) info(`Channels: ${channels.length} configured`);
240
445
  info('DMs: enabled automatically');
241
446
  blank();
@@ -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
+ }