@grknbyk/agent-wire 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/protocol.mjs CHANGED
@@ -1,68 +1,77 @@
1
- // The wire format is two parts that serve different readers. The header line is
2
- // for the humans scrolling the channel: "<mark> <from> => <to>", one line, so a
3
- // busy channel scans down the left edge by sender. The signature and routing
4
- // fields ride in Slack's message metadata, which the UI never renders.
5
- //
6
- // The header is DECORATION. Anyone in the channel can type it, so nothing trusts
7
- // it — identity comes from the signature (see identity.mjs). It stays because a
8
- // message no human can follow is a message nobody will keep in their workspace.
9
- import { randomBytes } from 'node:crypto';
10
-
11
- export const METADATA_EVENT = 'agent_wire_message';
12
-
13
- // Slack splits a message past ~4000 characters, and the tail arrives with no
14
- // header, so the receiver drops half an answer while the sender is told it was
15
- // delivered. Anything longer goes as a file instead.
16
- export const TEXT_MAX = 3500;
17
- export const HUMAN_TEXT_CAP = 1000;
18
- export const MAX_HOPS = 8;
19
-
20
- // Slack escapes these three on the way in, so they are escaped on the way out and
21
- // restored on the way in. &amp; is decoded last: decoding it first would turn a
22
- // literal "&amp;lt;" into "<".
23
- export const toSlackText = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
24
-
25
- // Slack rewrites a bare URL to <url> or <url|label>. Scheme-anchored on purpose:
26
- // stripping every <...> would eat <div> out of a code block, which is exactly the
27
- // content this has to survive.
28
- const unlinkify = (s) => s.replace(/<((?:https?:\/\/|mailto:)[^|>]+)(\|[^>]*)?>/g, '$1');
29
-
30
- export const fromSlackText = (s) => unlinkify(String(s))
31
- .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
32
-
33
- export const formatMessage = ({ mark, from, to, text }) =>
34
- `${mark ? `${mark} ` : ''}${from} => ${to}\n${toSlackText(text)}\n`;
35
-
36
- // Rejects "*" as a sender so a bold-wrapped line cannot file a message under "*".
37
- const HEADER = /^(?:(\S+)\s+)?([^\s=*]+)\s*=>\s*(\S+)$/;
38
-
39
- export function parseMessage(raw) {
40
- const lines = fromSlackText(String(raw ?? '').replace(/\r\n/g, '\n')).trim().split('\n');
41
- const header = HEADER.exec((lines[0] ?? '').trim());
42
- if (!header) return null;
43
-
44
- return { mark: header[1] ?? '', from: header[2], to: header[3], text: lines.slice(1).join('\n').trim() };
45
- }
46
-
47
- // Minted once per server process, never written to Slack and never logged, so its
48
- // only home is the agent's own context. A payload can imitate the fence but cannot
49
- // produce the marker that closes it.
50
- export const mintNonce = () => randomBytes(12).toString('hex');
51
-
52
- // Reflection is the one realistic way the nonce escapes — an agent quoting its own
53
- // inbox back into a reply. Redacting it makes that a visible event instead of a
54
- // silently broken fence.
55
- const redactFence = (text, nonce) => String(text).split(nonce).join('[FENCE-ECHO REDACTED]');
56
-
57
- export function renderEnvelope(nonce, item) {
58
- const fenceHeader = [
59
- `<<<WIRE:${nonce} UNTRUSTED`,
60
- `from=${item.from}`,
61
- `kind=${item.kind}`,
62
- `authorship=${item.authorship}`,
63
- `channel=${item.channel}`,
64
- `ts=${item.ts}`,
65
- `hop=${item.hop ?? 1}>>>`,
66
- ].join(' ');
67
- return `${fenceHeader}\n${redactFence(item.text, nonce)}\n<<<END:${nonce}>>>`;
68
- }
1
+ // The wire format is two parts that serve different readers. The header line is
2
+ // for the humans scrolling the channel: "<mark> <from> => <to>", one line, so a
3
+ // busy channel scans down the left edge by sender. The signature and routing
4
+ // fields ride in Slack's message metadata, which the UI never renders.
5
+ //
6
+ // The header is DECORATION. Anyone in the channel can type it, so nothing trusts
7
+ // it — identity comes from the signature (see identity.mjs). It stays because a
8
+ // message no human can follow is a message nobody will keep in their workspace.
9
+ import { randomBytes } from 'node:crypto';
10
+
11
+ export const METADATA_EVENT = 'agent_wire_message';
12
+
13
+ // Slack splits a message past ~4000 characters, and the tail arrives with no
14
+ // header, so the receiver drops half an answer while the sender is told it was
15
+ // delivered. Anything longer goes as a file instead.
16
+ export const TEXT_MAX = 3500;
17
+ export const HUMAN_TEXT_CAP = 1000;
18
+ export const MAX_HOPS = 8;
19
+
20
+ // Slack escapes these three on the way in, so they are escaped on the way out and
21
+ // restored on the way in. &amp; is decoded last: decoding it first would turn a
22
+ // literal "&amp;lt;" into "<".
23
+ export const toSlackText = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
24
+
25
+ // Slack rewrites a bare URL to <url> or <url|label>. Scheme-anchored on purpose:
26
+ // stripping every <...> would eat <div> out of a code block, which is exactly the
27
+ // content this has to survive.
28
+ const unlinkify = (s) => s.replace(/<((?:https?:\/\/|mailto:)[^|>]+)(\|[^>]*)?>/g, '$1');
29
+
30
+ export const fromSlackText = (s) => unlinkify(String(s))
31
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
32
+
33
+ export const formatMessage = ({ mark, from, to, text }) =>
34
+ `${mark ? `${mark} ` : ''}${from} => ${to}\n${toSlackText(text)}\n`;
35
+
36
+ // Rejects "*" as a sender so a bold-wrapped line cannot file a message under "*".
37
+ const HEADER = /^(?:(\S+)\s+)?([^\s=*]+)\s*=>\s*(\S+)$/;
38
+
39
+ export function parseMessage(raw) {
40
+ const lines = fromSlackText(String(raw ?? '').replace(/\r\n/g, '\n')).trim().split('\n');
41
+ const header = HEADER.exec((lines[0] ?? '').trim());
42
+ if (!header) return null;
43
+
44
+ return { mark: header[1] ?? '', from: header[2], to: header[3], text: lines.slice(1).join('\n').trim() };
45
+ }
46
+
47
+ // Minted once per server process, never written to Slack and never logged, so its
48
+ // only home is the agent's own context. A payload can imitate the fence but cannot
49
+ // produce the marker that closes it.
50
+ export const mintNonce = () => randomBytes(12).toString('hex');
51
+
52
+ // Reflection is the one realistic way the nonce escapes — an agent quoting its own
53
+ // inbox back into a reply. Redacting it makes that a visible event instead of a
54
+ // silently broken fence.
55
+ const redactFence = (text, nonce) => String(text).split(nonce).join('[FENCE-ECHO REDACTED]');
56
+
57
+ // An attached file is named in the header, never in the body. The path is one of
58
+ // the few things here the receiver produced itself, and putting it inside the
59
+ // fence would file it under "data written by someone else" along with the text.
60
+ const attachmentNote = (files) => (files ?? [])
61
+ .map((file) => (file.path ? file.path : `${file.name} — not downloaded, ${file.skipped}`))
62
+ .join(' | ');
63
+
64
+ export function renderEnvelope(nonce, item) {
65
+ const attachments = attachmentNote(item.files);
66
+ const fenceHeader = [
67
+ `<<<WIRE:${nonce} UNTRUSTED`,
68
+ `from=${item.from}`,
69
+ `kind=${item.kind}`,
70
+ `authorship=${item.authorship}`,
71
+ `channel=${item.channel}`,
72
+ `ts=${item.ts}`,
73
+ `hop=${item.hop ?? 1}`,
74
+ ...(attachments ? [`files=${attachments}`] : []),
75
+ ].join(' ');
76
+ return `${fenceHeader}>>>\n${redactFence(item.text, nonce)}\n<<<END:${nonce}>>>`;
77
+ }
package/src/setup.mjs CHANGED
@@ -1,243 +1,171 @@
1
- // Setup is a checklist, not an interrogation. Every step that Slack can confirm
2
- // is confirmed by asking Slack, never by asking the human "did you do it? (y/n)".
3
- // Each completed step is written to the config immediately, so the config file is
4
- // the resume point and quitting halfway costs nothing.
5
- import { createInterface } from 'node:readline/promises';
6
- import { createServer } from 'node:http';
7
- import { execFile } from 'node:child_process';
8
- import { readFileSync } from 'node:fs';
9
- import { fileURLToPath } from 'node:url';
10
- import { dirname, join } from 'node:path';
11
-
12
- import { loadConfig, patchConfig, paths } from './config.mjs';
13
- import { ensureChannel, probeToken, slackClient } from './slack.mjs';
14
- import { generateKeypair } from './identity.mjs';
15
- import { formatMessage } from './protocol.mjs';
16
-
17
- const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
18
- const MANIFEST_PATH = join(PACKAGE_ROOT, 'manifest.json');
19
- const CALLBACK_PORT = 32771;
20
- const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/callback`;
21
- const OAUTH_TIMEOUT_MS = 300000;
22
-
23
- const MARKS = ['🔥', '⚡', '🌊', '🌱', '🛰️', '🧭', '🪐', '🦉', '🐙', '🦊', '🐝', '🍀'];
24
-
25
- const manifest = () => JSON.parse(readFileSync(MANIFEST_PATH, 'utf8'));
26
-
27
- const scopeList = () => manifest().oauth_config.scopes.bot.join(',');
28
-
29
- // Slack names a failure but not what to do about it. Each cause a real install
30
- // hits gets the sentence the user actually needs.
31
- const EXPLANATIONS = {
32
- invalid_auth: 'that token was rejected — check you copied the Bot User OAuth Token (starts with xoxb-), not the App-Level or Configuration token',
33
- account_inactive: 'the token belongs to a deactivated app or workspace',
34
- token_revoked: 'that token has been revoked; reinstall the app to get a fresh one',
35
- missing_scope: 'the app is installed but lacks a scope it needs reinstall it after updating the manifest',
36
- not_in_channel: 'the bot is not in that channel yet',
37
- needs_invite: 'this is a private channel, so no app can add itself — type "/invite @agent-wire" in it',
38
- channel_not_found: 'no channel with that name is visible to the app',
39
- name_taken: 'a channel with that name already exists but the app cannot see it — invite the bot to it instead',
40
- restricted_action: 'your workspace does not allow apps to create channels create it yourself, then re-run setup',
41
- };
42
-
43
- const explain = (reason) => EXPLANATIONS[reason] ?? `Slack said: ${reason}`;
44
-
45
- function openBrowser(url) {
46
- const [command, args] = process.platform === 'win32'
47
- ? ['cmd', ['/c', 'start', '', url]]
48
- : process.platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]];
49
- execFile(command, args, () => {
50
- // No browser is a normal state on a remote box; the URL is printed anyway.
51
- });
52
- }
53
-
54
- // The install step confirms itself: Slack redirects to a server we are already
55
- // listening on, so nothing has to be polled and nothing has to be pasted.
56
- function awaitOAuthCode() {
57
- return new Promise((resolve) => {
58
- const server = createServer((request, response) => {
59
- const url = new URL(request.url, CALLBACK_URL);
60
- const code = url.searchParams.get('code');
61
- response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
62
- response.end(`<html><body style="font-family:system-ui;background:#af3c02;color:#fff;padding:3rem">
63
- <h2>${code ? 'agent-wire is connected.' : 'Authorization was cancelled.'}</h2>
64
- <p>You can close this tab and return to the terminal.</p></body></html>`);
65
- server.close();
66
- resolve(code);
67
- });
68
- server.listen(CALLBACK_PORT);
69
- server.on('error', () => resolve(null));
70
- setTimeout(() => { server.close(); resolve(null); }, OAUTH_TIMEOUT_MS).unref();
71
- });
72
- }
73
-
74
- async function createAppFromManifest(configToken) {
75
- const client = slackClient(configToken);
76
- const created = await client.form('apps.manifest.create', { manifest: JSON.stringify(manifest()) });
77
- if (!created.ok) return { ok: false, reason: created.error };
78
- return { ok: true, appId: created.app_id, clientId: created.credentials.client_id, clientSecret: created.credentials.client_secret };
79
- }
80
-
81
- async function installApp({ clientId, clientSecret }) {
82
- const authorizeUrl = `https://slack.com/oauth/v2/authorize?client_id=${clientId}`
83
- + `&scope=${encodeURIComponent(scopeList())}&redirect_uri=${encodeURIComponent(CALLBACK_URL)}`;
84
- console.log('\nOpening Slack so you can approve the install. If nothing opens, paste this:');
85
- console.log(` ${authorizeUrl}\n`);
86
- openBrowser(authorizeUrl);
87
-
88
- const code = await awaitOAuthCode();
89
- if (!code) return { ok: false, reason: 'authorization_timeout' };
90
-
91
- // oauth.v2.access takes the client id and secret in the body, so this is the
92
- // one call that carries no bearer token and cannot go through slackClient.
93
- const exchanged = await (await fetch('https://slack.com/api/oauth.v2.access', {
94
- method: 'POST',
95
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
96
- body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, code, redirect_uri: CALLBACK_URL }),
97
- })).json();
98
- if (!exchanged.ok) return { ok: false, reason: exchanged.error };
99
- return { ok: true, botToken: exchanged.access_token };
100
- }
101
-
102
- async function obtainBotToken(ask) {
103
- console.log('\nHow do you want to connect?');
104
- console.log(' 1 I already have a bot token (xoxb-...)');
105
- console.log(' 2 Create the Slack app for me (needs an App Configuration Token)');
106
- console.log(' 3 I will create the app by hand (you paste the manifest into Slack)');
107
- const choice = (await ask('Choose 1, 2 or 3: ')).trim();
108
-
109
- if (choice === '1') return (await ask('Paste the Bot User OAuth Token: ')).trim();
110
-
111
- if (choice === '2') {
112
- console.log('\nOpen https://api.slack.com/apps and scroll to "Your App Configuration Tokens".');
113
- console.log('Generate one, then paste the Access Token (starts with xoxe-) here.');
114
- const configToken = (await ask('App Configuration Token: ')).trim();
115
- const app = await createAppFromManifest(configToken);
116
- if (!app.ok) {
117
- console.log(`\nCould not create the app: ${explain(app.reason)}`);
118
- return null;
119
- }
120
- console.log(`App created (${app.appId}).`);
121
- const installed = await installApp(app);
122
- if (!installed.ok) {
123
- console.log(`\nInstall did not complete: ${explain(installed.reason)}`);
124
- return null;
125
- }
126
- return installed.botToken;
127
- }
128
-
129
- console.log(`\nManifest to paste: ${MANIFEST_PATH}`);
130
- console.log(' 1. Open https://api.slack.com/apps/new and choose "From an app manifest"');
131
- console.log(' 2. Pick your workspace, paste that file, confirm');
132
- console.log(' 3. Open "Install App" in the left sidebar and install it');
133
- console.log(' 4. Copy the Bot User OAuth Token\n');
134
- return (await ask('Paste the Bot User OAuth Token: ')).trim();
135
- }
136
-
137
- function defaultNickname() {
138
- const folder = process.cwd().split(/[\\/]/).filter(Boolean).pop() ?? 'agent';
139
- return folder.toLowerCase().replace(/[^a-z0-9_-]/g, '-').slice(0, 20);
140
- }
141
-
142
- const markFor = (nickname) => {
143
- const total = [...nickname].reduce((sum, character) => sum + character.codePointAt(0), 0);
144
- return MARKS[total % MARKS.length];
145
- };
146
-
147
- export async function runSetup() {
148
- const rl = createInterface({ input: process.stdin, output: process.stdout });
149
- const ask = (question) => rl.question(question);
150
-
151
- try {
152
- console.log('agent-wire setup\n');
153
- const existing = loadConfig();
154
- if (existing?.bot_token) console.log(`Found an existing config at ${paths.config} — re-running will update it.\n`);
155
-
156
- const botToken = await obtainBotToken(ask);
157
- if (!botToken) return 1;
158
-
159
- const client = slackClient(botToken);
160
- const token = await probeToken(client);
161
- if (!token.ok) {
162
- console.log(`\nToken check failed: ${explain(token.reason)}`);
163
- return 1;
164
- }
165
- console.log(`Connected to ${token.team}.`);
166
- patchConfig({
167
- bot_token: botToken,
168
- team: token.team,
169
- team_id: token.teamId,
170
- bot_user_id: token.botUserId,
171
- installed_at: new Date().toISOString(),
172
- });
173
-
174
- const answer = await ask('\nChannel for this project [agent-wire]: ');
175
- const channelName = (answer.trim() || 'agent-wire').replace(/^#/, '');
176
- const channel = await ensureChannel(client, channelName);
177
- if (!channel.ok) {
178
- console.log(`\nChannel not ready: ${explain(channel.reason)}`);
179
- console.log('Fix that, then run `npx @grknbyk/agent-wire setup` again — it resumes here.');
180
- return 1;
181
- }
182
- console.log(channel.created ? `Created and joined #${channel.name}.` : `Joined #${channel.name}.`);
183
-
184
- const suggested = defaultNickname();
185
- const nicknameAnswer = await ask(`\nThis agent's name [${suggested}]: `);
186
- const nickname = (nicknameAnswer.trim() || suggested).toLowerCase();
187
- const markAnswer = await ask(`Emoji shown before the name [${markFor(nickname)}]: `);
188
-
189
- const keypair = existing?.private_key
190
- ? { privateKey: existing.private_key, publicKey: existing.public_key }
191
- : generateKeypair();
192
-
193
- const config = patchConfig({
194
- nickname,
195
- mark: markAnswer.trim() || markFor(nickname),
196
- private_key: keypair.privateKey,
197
- public_key: keypair.publicKey,
198
- channels: [{ id: channel.id, name: channel.name }],
199
- });
200
-
201
- const hello = formatMessage({
202
- mark: config.mark,
203
- from: config.nickname,
204
- to: 'all',
205
- text: `joined from ${process.platform}. Key ${config.public_key.slice(0, 12)}…`,
206
- });
207
- await client.json('chat.postMessage', { channel: channel.id, text: hello });
208
-
209
- console.log(`\nDone. You are ${config.mark} ${config.nickname} in #${channel.name}.`);
210
- console.log(`Config: ${paths.config}`);
211
- console.log('\nAdd this to your MCP client (Claude Code: `claude mcp add agent-wire -- npx -y @grknbyk/agent-wire serve`):');
212
- console.log(JSON.stringify({ mcpServers: { 'agent-wire': { command: 'npx', args: ['-y', 'agent-wire', 'serve'] } } }, null, 2));
213
- console.log(`\nUpload assets/agent-wire.png as the app icon at https://api.slack.com/apps (Basic Information → Display Information).`);
214
- return 0;
215
- } finally {
216
- rl.close();
217
- }
218
- }
219
-
220
- // The same three probes setup used, read at a later date: a bot kicked from the
221
- // channel, a revoked token and an uninstalled app all surface here.
222
- export async function runDoctor() {
223
- const config = loadConfig();
224
- if (!config?.bot_token) {
225
- console.log('not configured — run `npx @grknbyk/agent-wire setup`');
226
- return 1;
227
- }
228
-
229
- const client = slackClient(config.bot_token);
230
- const token = await probeToken(client);
231
- console.log(token.ok ? `token ok (${token.team})` : `token FAILED — ${explain(token.reason)}`);
232
- if (!token.ok) return 1;
233
-
234
- console.log(`identity ${config.mark} ${config.nickname}, key ${config.public_key.slice(0, 12)}…`);
235
-
236
- let failures = 0;
237
- for (const channel of config.channels ?? []) {
238
- const probe = await ensureChannel(client, channel.name);
239
- console.log(probe.ok ? `channel #${channel.name} ok` : `channel #${channel.name} FAILED — ${explain(probe.reason)}`);
240
- if (!probe.ok) failures++;
241
- }
242
- return failures === 0 ? 0 : 1;
243
- }
1
+ // Setup is a checklist, not an interrogation. Every step that Slack can confirm
2
+ // is confirmed by asking Slack, never by asking the human "did you do it? (y/n)".
3
+ // Each completed step is written to the config immediately, so the config file is
4
+ // the resume point and quitting halfway costs nothing.
5
+ import { createInterface } from 'node:readline/promises';
6
+ import { readFileSync } from 'node:fs';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { dirname, join } from 'node:path';
9
+
10
+ import { loadConfig, patchConfig, paths } from './config.mjs';
11
+ import { probeChannel, probeToken, slackClient } from './slack.mjs';
12
+ import { FINGERPRINT_CHARS, generateKeypair } from './identity.mjs';
13
+ import { formatMessage } from './protocol.mjs';
14
+
15
+ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
16
+ const MANIFEST_PATH = join(PACKAGE_ROOT, 'manifest.json');
17
+
18
+ const MARKS = ['🔥', '', '🌊', '🌱', '🛰️', '🧭', '🪐', '🦉', '🐙', '🦊', '🐝', '🍀'];
19
+
20
+ // Slack names a failure but not what to do about it. Each cause a real install
21
+ // hits gets the sentence the user actually needs.
22
+ const EXPLANATIONS = {
23
+ invalid_auth: 'that token was rejected check you copied the Bot User OAuth Token (starts with xoxb-), not the App-Level or Configuration token',
24
+ account_inactive: 'the token belongs to a deactivated app or workspace',
25
+ token_revoked: 'that token has been revoked; reinstall the app to get a fresh one',
26
+ missing_scope: 'the app is installed but lacks a scope it needs — reinstall it after updating the manifest',
27
+ not_in_channel: 'the bot is not in that channel yet — type "/invite @agent-wire" in it',
28
+ needs_invite: 'the bot is in no channel by that name — create it in Slack if it does not exist, then type "/invite @agent-wire" in it',
29
+ };
30
+
31
+ const explain = (reason) => EXPLANATIONS[reason] ?? `Slack said: ${reason}`;
32
+
33
+ // One path, by hand. Slack's own OAuth redirect needs a localhost listener, and a
34
+ // listener is the part that breaks: a busy port, a firewall prompt, a headless
35
+ // box. Pasting a token you can see beats a handshake you cannot debug.
36
+ async function obtainBotToken(ask) {
37
+ console.log(`\nManifest to paste: ${MANIFEST_PATH}`);
38
+ console.log(' 1. Open https://api.slack.com/apps/new and choose "From an app manifest"');
39
+ console.log(' 2. Pick your workspace, paste that file, confirm');
40
+ console.log(' 3. Open "Install App" in the left sidebar and install it');
41
+ console.log(' 4. Copy the Bot User OAuth Token');
42
+ console.log(' 5. Create the channel in Slack and type "/invite @agent-wire" in it\n');
43
+ return (await ask('Paste the Bot User OAuth Token: ')).trim();
44
+ }
45
+
46
+ function defaultNickname() {
47
+ const folder = process.cwd().split(/[\\/]/).filter(Boolean).pop() ?? 'agent';
48
+ return folder.toLowerCase().replace(/[^a-z0-9_-]/g, '-').slice(0, 20);
49
+ }
50
+
51
+ const markFor = (nickname) => {
52
+ const total = [...nickname].reduce((sum, character) => sum + character.codePointAt(0), 0);
53
+ return MARKS[total % MARKS.length];
54
+ };
55
+
56
+ export async function runSetup() {
57
+ // Setup is a conversation, so it needs a terminal on the other end. Without
58
+ // one, stdin reaches end of file before the first answer and rl.question()
59
+ // waits for a line that can never arrive: the process hangs with no output.
60
+ if (!process.stdin.isTTY) {
61
+ console.log('agent-wire setup needs an interactive terminal.');
62
+ console.log('Run it directly in your shell, not through a pipe, a script or an editor task.');
63
+ return 1;
64
+ }
65
+
66
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
67
+ // A terminal can still close mid-answer, on Ctrl-D or a lost session. Race
68
+ // the question against that, or the same silent hang comes back.
69
+ const ask = (question) => Promise.race([
70
+ rl.question(question),
71
+ new Promise((_, reject) => rl.once('close', () => reject(new Error('input closed')))),
72
+ ]);
73
+
74
+ try {
75
+ console.log('agent-wire setup\n');
76
+ const existing = loadConfig();
77
+ if (existing?.bot_token) console.log(`Found an existing config at ${paths.config} — re-running will update it.\n`);
78
+
79
+ const botToken = await obtainBotToken(ask);
80
+ if (!botToken) return 1;
81
+
82
+ const client = slackClient(botToken);
83
+ const token = await probeToken(client);
84
+ if (!token.ok) {
85
+ console.log(`\nToken check failed: ${explain(token.reason)}`);
86
+ return 1;
87
+ }
88
+ console.log(`Connected to ${token.team}.`);
89
+ patchConfig({
90
+ bot_token: botToken,
91
+ team: token.team,
92
+ team_id: token.teamId,
93
+ bot_user_id: token.botUserId,
94
+ installed_at: new Date().toISOString(),
95
+ });
96
+
97
+ const answer = await ask('\nChannel for this project [agent-wire]: ');
98
+ const channelName = (answer.trim() || 'agent-wire').replace(/^#/, '');
99
+ const channel = await probeChannel(client, channelName);
100
+ if (!channel.ok) {
101
+ console.log(`\nChannel not ready: ${explain(channel.reason)}`);
102
+ console.log('Fix that, then run `npx @grknbyk/agent-wire setup` again — it resumes here.');
103
+ return 1;
104
+ }
105
+ console.log(`Found #${channel.name}, the bot is in it.`);
106
+
107
+ const suggested = defaultNickname();
108
+ const nicknameAnswer = await ask(`\nThis agent's name [${suggested}]: `);
109
+ const nickname = (nicknameAnswer.trim() || suggested).toLowerCase();
110
+ const markAnswer = await ask(`Emoji shown before the name [${markFor(nickname)}]: `);
111
+
112
+ const keypair = existing?.private_key
113
+ ? { privateKey: existing.private_key, publicKey: existing.public_key }
114
+ : generateKeypair();
115
+
116
+ const config = patchConfig({
117
+ nickname,
118
+ mark: markAnswer.trim() || markFor(nickname),
119
+ private_key: keypair.privateKey,
120
+ public_key: keypair.publicKey,
121
+ channels: [{ id: channel.id, name: channel.name }],
122
+ });
123
+
124
+ const hello = formatMessage({
125
+ mark: config.mark,
126
+ from: config.nickname,
127
+ to: 'all',
128
+ text: `joined from ${process.platform}. Key ${config.public_key.slice(0, FINGERPRINT_CHARS)}…`,
129
+ });
130
+ await client.json('chat.postMessage', { channel: channel.id, text: hello });
131
+
132
+ console.log(`\nDone. You are ${config.mark} ${config.nickname} in #${channel.name}.`);
133
+ console.log(`Config: ${paths.config}`);
134
+ console.log('\nAdd this to your MCP client (Claude Code: `claude mcp add agent-wire -- npx -y @grknbyk/agent-wire serve`):');
135
+ console.log(JSON.stringify({ mcpServers: { 'agent-wire': { command: 'npx', args: ['-y', '@grknbyk/agent-wire', 'serve'] } } }, null, 2));
136
+ console.log(`\nUpload assets/agent-wire.png as the app icon at https://api.slack.com/apps (Basic Information → Display Information).`);
137
+ return 0;
138
+ } catch (error) {
139
+ if (error.message !== 'input closed') throw error;
140
+
141
+ console.log('\nStopped: no more input. Re-run setup — it resumes where it left off.');
142
+ return 1;
143
+ } finally {
144
+ rl.close();
145
+ }
146
+ }
147
+
148
+ // The same three probes setup used, read at a later date: a bot kicked from the
149
+ // channel, a revoked token and an uninstalled app all surface here.
150
+ export async function runDoctor() {
151
+ const config = loadConfig();
152
+ if (!config?.bot_token) {
153
+ console.log('not configured run `npx @grknbyk/agent-wire setup`');
154
+ return 1;
155
+ }
156
+
157
+ const client = slackClient(config.bot_token);
158
+ const token = await probeToken(client);
159
+ console.log(token.ok ? `token ok (${token.team})` : `token FAILED — ${explain(token.reason)}`);
160
+ if (!token.ok) return 1;
161
+
162
+ console.log(`identity ${config.mark} ${config.nickname}, key ${config.public_key.slice(0, FINGERPRINT_CHARS)}…`);
163
+
164
+ let failures = 0;
165
+ for (const channel of config.channels ?? []) {
166
+ const probe = await probeChannel(client, channel.name);
167
+ console.log(probe.ok ? `channel #${channel.name} ok` : `channel #${channel.name} FAILED — ${explain(probe.reason)}`);
168
+ if (!probe.ok) failures++;
169
+ }
170
+ return failures === 0 ? 0 : 1;
171
+ }