@grknbyk/agent-wire 0.4.1 → 0.6.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/README.md +110 -24
- package/bin/agent-wire.mjs +148 -115
- package/manifest.json +31 -38
- package/package.json +3 -2
- package/src/config.mjs +182 -68
- package/src/drain.mjs +86 -0
- package/src/identity.mjs +101 -73
- package/src/inbox.mjs +107 -76
- package/src/mcp.mjs +382 -309
- package/src/protocol.mjs +77 -68
- package/src/setup.mjs +171 -262
- package/src/slack.mjs +322 -236
- package/src/status.mjs +9 -4
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. & is decoded last: decoding it first would turn a
|
|
22
|
-
// literal "&lt;" into "<".
|
|
23
|
-
export const toSlackText = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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(/</g, '<').replace(/>/g, '>').replace(/&/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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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. & is decoded last: decoding it first would turn a
|
|
22
|
+
// literal "&lt;" into "<".
|
|
23
|
+
export const toSlackText = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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(/</g, '<').replace(/>/g, '>').replace(/&/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,262 +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 {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
console.log('
|
|
154
|
-
return 1;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
if (
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const client = slackClient(botToken);
|
|
174
|
-
const token = await probeToken(client);
|
|
175
|
-
if (!token.ok) {
|
|
176
|
-
console.log(`\nToken check failed: ${explain(token.reason)}`);
|
|
177
|
-
return 1;
|
|
178
|
-
}
|
|
179
|
-
console.log(`Connected to ${token.team}.`);
|
|
180
|
-
patchConfig({
|
|
181
|
-
bot_token: botToken,
|
|
182
|
-
team: token.team,
|
|
183
|
-
team_id: token.teamId,
|
|
184
|
-
bot_user_id: token.botUserId,
|
|
185
|
-
installed_at: new Date().toISOString(),
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
const answer = await ask('\nChannel for this project [agent-wire]: ');
|
|
189
|
-
const channelName = (answer.trim() || 'agent-wire').replace(/^#/, '');
|
|
190
|
-
const channel = await ensureChannel(client, channelName);
|
|
191
|
-
if (!channel.ok) {
|
|
192
|
-
console.log(`\nChannel not ready: ${explain(channel.reason)}`);
|
|
193
|
-
console.log('Fix that, then run `npx @grknbyk/agent-wire setup` again — it resumes here.');
|
|
194
|
-
return 1;
|
|
195
|
-
}
|
|
196
|
-
console.log(channel.created ? `Created and joined #${channel.name}.` : `Joined #${channel.name}.`);
|
|
197
|
-
|
|
198
|
-
const suggested = defaultNickname();
|
|
199
|
-
const nicknameAnswer = await ask(`\nThis agent's name [${suggested}]: `);
|
|
200
|
-
const nickname = (nicknameAnswer.trim() || suggested).toLowerCase();
|
|
201
|
-
const markAnswer = await ask(`Emoji shown before the name [${markFor(nickname)}]: `);
|
|
202
|
-
|
|
203
|
-
const keypair = existing?.private_key
|
|
204
|
-
? { privateKey: existing.private_key, publicKey: existing.public_key }
|
|
205
|
-
: generateKeypair();
|
|
206
|
-
|
|
207
|
-
const config = patchConfig({
|
|
208
|
-
nickname,
|
|
209
|
-
mark: markAnswer.trim() || markFor(nickname),
|
|
210
|
-
private_key: keypair.privateKey,
|
|
211
|
-
public_key: keypair.publicKey,
|
|
212
|
-
channels: [{ id: channel.id, name: channel.name }],
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
const hello = formatMessage({
|
|
216
|
-
mark: config.mark,
|
|
217
|
-
from: config.nickname,
|
|
218
|
-
to: 'all',
|
|
219
|
-
text: `joined from ${process.platform}. Key ${config.public_key.slice(0, 12)}…`,
|
|
220
|
-
});
|
|
221
|
-
await client.json('chat.postMessage', { channel: channel.id, text: hello });
|
|
222
|
-
|
|
223
|
-
console.log(`\nDone. You are ${config.mark} ${config.nickname} in #${channel.name}.`);
|
|
224
|
-
console.log(`Config: ${paths.config}`);
|
|
225
|
-
console.log('\nAdd this to your MCP client (Claude Code: `claude mcp add agent-wire -- npx -y @grknbyk/agent-wire serve`):');
|
|
226
|
-
console.log(JSON.stringify({ mcpServers: { 'agent-wire': { command: 'npx', args: ['-y', '@grknbyk/agent-wire', 'serve'] } } }, null, 2));
|
|
227
|
-
console.log(`\nUpload assets/agent-wire.png as the app icon at https://api.slack.com/apps (Basic Information → Display Information).`);
|
|
228
|
-
return 0;
|
|
229
|
-
} catch (error) {
|
|
230
|
-
if (error.message !== 'input closed') throw error;
|
|
231
|
-
|
|
232
|
-
console.log('\nStopped: no more input. Re-run setup — it resumes where it left off.');
|
|
233
|
-
return 1;
|
|
234
|
-
} finally {
|
|
235
|
-
rl.close();
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// The same three probes setup used, read at a later date: a bot kicked from the
|
|
240
|
-
// channel, a revoked token and an uninstalled app all surface here.
|
|
241
|
-
export async function runDoctor() {
|
|
242
|
-
const config = loadConfig();
|
|
243
|
-
if (!config?.bot_token) {
|
|
244
|
-
console.log('not configured — run `npx @grknbyk/agent-wire setup`');
|
|
245
|
-
return 1;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
const client = slackClient(config.bot_token);
|
|
249
|
-
const token = await probeToken(client);
|
|
250
|
-
console.log(token.ok ? `token ok (${token.team})` : `token FAILED — ${explain(token.reason)}`);
|
|
251
|
-
if (!token.ok) return 1;
|
|
252
|
-
|
|
253
|
-
console.log(`identity ${config.mark} ${config.nickname}, key ${config.public_key.slice(0, 12)}…`);
|
|
254
|
-
|
|
255
|
-
let failures = 0;
|
|
256
|
-
for (const channel of config.channels ?? []) {
|
|
257
|
-
const probe = await ensureChannel(client, channel.name);
|
|
258
|
-
console.log(probe.ok ? `channel #${channel.name} ok` : `channel #${channel.name} FAILED — ${explain(probe.reason)}`);
|
|
259
|
-
if (!probe.ok) failures++;
|
|
260
|
-
}
|
|
261
|
-
return failures === 0 ? 0 : 1;
|
|
262
|
-
}
|
|
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
|
+
}
|