@agent-flow-board/bridge 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +46 -2
- package/dist/bridge.js +256 -0
- package/dist/checks/audit.js +125 -0
- package/dist/checks/bind.js +99 -0
- package/dist/checks/board.js +107 -0
- package/dist/checks/browser.js +170 -0
- package/dist/checks/child.js +211 -0
- package/dist/checks/command.js +280 -0
- package/dist/checks/connect.js +79 -0
- package/dist/checks/connections.js +41 -0
- package/dist/checks/envPolicy.js +163 -0
- package/dist/checks/guard.js +188 -0
- package/dist/checks/guardrails.js +155 -0
- package/dist/checks/lock.js +183 -0
- package/dist/checks/paths.js +61 -0
- package/dist/checks/preview.js +350 -0
- package/dist/checks/provision.js +175 -0
- package/dist/checks/recipe.js +232 -0
- package/dist/checks/redact.js +52 -0
- package/dist/checks/run.js +253 -0
- package/dist/checks/sandbox.js +186 -0
- package/dist/checks/scope.js +294 -0
- package/dist/checks/signin.js +73 -0
- package/dist/checks/types.js +2 -0
- package/dist/checks/watch.js +101 -0
- package/dist/cli.js +329 -0
- package/dist/device.js +61 -0
- package/dist/hooks/adapters.js +218 -0
- package/dist/hooks/call.js +79 -0
- package/dist/hooks/gitHook.js +56 -0
- package/dist/hooks/modVersion.js +26 -0
- package/dist/hooks/settings.js +49 -0
- package/dist/hooks/setup.js +124 -0
- package/dist/hooks/statusFile.js +116 -0
- package/dist/mockup.js +145 -0
- package/dist/queue.js +175 -0
- package/dist/tokenStore.js +177 -0
- package/package.json +36 -5
package/dist/cli.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// agent-flow-board mcp --server <url> [--org <slug>] forward MCP over stdio to the server (to /mcp/<slug> with --org)
|
|
3
|
+
// agent-flow-board login --server <url> [--org <slug>] sign in by device code
|
|
4
|
+
// agent-flow-board login --json --server <url> --org <slug> for an agent: line 1 holds userCode and verifier, it approves through MCP
|
|
5
|
+
// agent-flow-board setup --org <slug> --server <url> [--dir <repo>] [--shared] write the Claude hooks into the repo's .claude/settings.local.json
|
|
6
|
+
// agent-flow-board logout --server <url> [--org <slug>]
|
|
7
|
+
// agent-flow-board queue list | retry <id> | drop <id>
|
|
8
|
+
// agent-flow-board hook claude|antigravity|gemini [event] one hook event from stdin, one JSON object to stdout
|
|
9
|
+
// agent-flow-board hooks print claude|antigravity|gemini [--org <slug>] the settings to paste into the tool
|
|
10
|
+
// agent-flow-board git-hook install|uninstall|check the warn-only post-commit hook
|
|
11
|
+
// agent-flow-board check connect|run|pull|bind|watch|status|doctor|clear|setup|disconnect browser checks on this machine (see check usage)
|
|
12
|
+
// agent-flow-board mockup push <file.html> --server <url> --org <slug> --item <ref> upload one mockup file (never queued)
|
|
13
|
+
// agent-flow-board mockup kit <file.html> --server <url> --org <slug> --project <key> upload the project's kit (colours, fonts, icons)
|
|
14
|
+
// -- DEP-MODULES -- //
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { runBridge } from './bridge.js';
|
|
18
|
+
import { ADAPTERS, runHook } from './hooks/adapters.js';
|
|
19
|
+
import { callHook } from './hooks/call.js';
|
|
20
|
+
import { checkCommit, installGitHook, lastCommitMessage, uninstallGitHook } from './hooks/gitHook.js';
|
|
21
|
+
import { hookSettings } from './hooks/settings.js';
|
|
22
|
+
import { runSetup } from './hooks/setup.js';
|
|
23
|
+
import { applyStatusFile } from './hooks/statusFile.js';
|
|
24
|
+
import { deviceLogin } from './device.js';
|
|
25
|
+
import { pushKit, pushMockup } from './mockup.js';
|
|
26
|
+
import { Queue } from './queue.js';
|
|
27
|
+
import { accountKey, configDir, pickStore } from './tokenStore.js';
|
|
28
|
+
// -- FUNCTIONS -- //
|
|
29
|
+
const args = process.argv.slice(2);
|
|
30
|
+
const flag = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);
|
|
31
|
+
const cmd = args[0];
|
|
32
|
+
const log = (s) => process.stderr.write(s + '\n');
|
|
33
|
+
function serverUrl() {
|
|
34
|
+
const s = flag('--server') ?? process.env.AFB_SERVER;
|
|
35
|
+
if (!s) {
|
|
36
|
+
log('Say which server to use: --server https://board.example.com (or set AFB_SERVER).');
|
|
37
|
+
process.exit(2);
|
|
38
|
+
}
|
|
39
|
+
return s.replace(/\/$/, '');
|
|
40
|
+
}
|
|
41
|
+
/** An organisation slug as the app writes it. Anything else is ignored by a hook and refused by the other commands. */
|
|
42
|
+
const ORG_SLUG = /^[a-z0-9][a-z0-9-]{1,38}$/;
|
|
43
|
+
/** The organisation this run is for (--org), or undefined for the plain server sign in as before. */
|
|
44
|
+
function orgFlag(strict = true) {
|
|
45
|
+
const o = flag('--org');
|
|
46
|
+
if (o === undefined)
|
|
47
|
+
return undefined;
|
|
48
|
+
if (ORG_SLUG.test(o))
|
|
49
|
+
return o;
|
|
50
|
+
if (strict) {
|
|
51
|
+
log('--org takes an organisation slug: lowercase letters, digits and dashes.');
|
|
52
|
+
process.exit(2);
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
const store = pickStore();
|
|
57
|
+
/**
|
|
58
|
+
* The saved sign in for a server and organisation. Only the MCP bridge (legacy true) also tries an older sign in saved under the plain
|
|
59
|
+
* host, because /mcp/<slug> answers 403 if it is the wrong organisation. Hooks have no such check, so they never borrow it.
|
|
60
|
+
*/
|
|
61
|
+
const tokenFor = (server, org, legacy = false) => process.env.AFB_TOKEN ??
|
|
62
|
+
store.get(accountKey(server, org)) ??
|
|
63
|
+
(org && legacy ? store.get(accountKey(server)) : null);
|
|
64
|
+
const queue = new Queue(join(configDir(), 'queue.json'));
|
|
65
|
+
const fetchImpl = (input, init) => fetch(input, init);
|
|
66
|
+
async function readStdin() {
|
|
67
|
+
const chunks = [];
|
|
68
|
+
for await (const c of process.stdin)
|
|
69
|
+
chunks.push(c);
|
|
70
|
+
const text = Buffer.concat(chunks).toString('utf8').trim();
|
|
71
|
+
try {
|
|
72
|
+
return text ? JSON.parse(text) : {};
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return {};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Asks the organisation's own address whether this sign in belongs to it. Only a 403 means another organisation. */
|
|
79
|
+
async function belongsToOrg(server, org, token) {
|
|
80
|
+
try {
|
|
81
|
+
const res = await fetchImpl(`${server}/mcp/${org}`, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: {
|
|
84
|
+
'content-type': 'application/json',
|
|
85
|
+
accept: 'application/json, text/event-stream',
|
|
86
|
+
authorization: `Bearer ${token}`
|
|
87
|
+
},
|
|
88
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' })
|
|
89
|
+
});
|
|
90
|
+
// Only a refusal means another organisation, or no valid sign in
|
|
91
|
+
return res.status !== 403 && res.status !== 401;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// The board cannot be reached right now: keep the sign in, the first call checks again
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function main() {
|
|
99
|
+
if (cmd === 'check') {
|
|
100
|
+
// Loaded only here, so the hooks and the MCP forwarder never load the check code or Playwright
|
|
101
|
+
const { checkMain } = await import('./checks/command.js');
|
|
102
|
+
const code = await checkMain({
|
|
103
|
+
args: args.slice(1),
|
|
104
|
+
store,
|
|
105
|
+
fetch: fetchImpl,
|
|
106
|
+
personalToken: (server, org) => tokenFor(server.replace(/\/$/, ''), org, true),
|
|
107
|
+
log,
|
|
108
|
+
out: (line) => process.stdout.write(line + '\n')
|
|
109
|
+
});
|
|
110
|
+
// A browser or a preview may still hold a handle open: the exit code is what the caller reads, so end now
|
|
111
|
+
process.exitCode = code;
|
|
112
|
+
setTimeout(() => process.exit(code), 300).unref();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (cmd === 'hook') {
|
|
116
|
+
// A hook prints exactly one JSON object and always exits 0, so it can never break the tool that runs it
|
|
117
|
+
let out = {};
|
|
118
|
+
try {
|
|
119
|
+
const adapter = ADAPTERS[args[1] ?? ''];
|
|
120
|
+
const server = (flag('--server') ?? process.env.AFB_SERVER ?? '').replace(/\/$/, '');
|
|
121
|
+
if (adapter && server) {
|
|
122
|
+
const token = tokenFor(server, orgFlag(false));
|
|
123
|
+
out = await runHook(adapter, args[2] ?? '', await readStdin(), async (event) => {
|
|
124
|
+
const asked = Date.now();
|
|
125
|
+
const answer = await callHook({ server, token, fetch: fetchImpl }, event);
|
|
126
|
+
// The status goes to this conversation's file, never to the tool. A failed write never fails the hook.
|
|
127
|
+
applyStatusFile(join(configDir(), 'status'), event, answer, asked);
|
|
128
|
+
return answer;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
out = {};
|
|
134
|
+
}
|
|
135
|
+
process.stdout.write(JSON.stringify(out));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (cmd === 'hooks' && args[1] === 'print') {
|
|
139
|
+
const text = hookSettings(args[2] ?? '', serverUrl());
|
|
140
|
+
if (!text) {
|
|
141
|
+
log('Say which tool: claude, antigravity or gemini.');
|
|
142
|
+
process.exit(2);
|
|
143
|
+
}
|
|
144
|
+
process.stdout.write(text + '\n');
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (cmd === 'git-hook') {
|
|
148
|
+
const repo = flag('--dir') ?? process.cwd();
|
|
149
|
+
if (args[1] === 'install') {
|
|
150
|
+
const r = installGitHook(repo);
|
|
151
|
+
log(r.created ? `Installed ${r.path}.` : `Already installed in ${r.path}.`);
|
|
152
|
+
}
|
|
153
|
+
else if (args[1] === 'uninstall') {
|
|
154
|
+
log(uninstallGitHook(repo) ? 'Removed.' : 'Nothing to remove.');
|
|
155
|
+
}
|
|
156
|
+
else if (args[1] === 'check') {
|
|
157
|
+
// Warn only: print the warning to the terminal and never fail the commit
|
|
158
|
+
const warning = checkCommit(lastCommitMessage(repo));
|
|
159
|
+
if (warning)
|
|
160
|
+
log(warning);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
log('Usage: agent-flow-board git-hook install|uninstall|check [--dir <repo>]');
|
|
164
|
+
process.exit(2);
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (cmd === 'mockup') {
|
|
169
|
+
// One upload over /api/v1 with this machine's own sign in. It is never queued, so a failure is said out loud and run again
|
|
170
|
+
if (!['push', 'kit'].includes(args[1] ?? '') || !args[2] || args[2].startsWith('--')) {
|
|
171
|
+
log('Usage: agent-flow-board mockup push <file.html> --server <url> --org <slug> (--item <ref> | --project <key> | --mockup <id>) [--another] [--title <text>] [--keep <text> | --keep-file <path>] [--free <text> | --free-file <path>] [--screenshot <file.png>]\n agent-flow-board mockup kit <file.html> --server <url> --org <slug> --project <key> [--commit <hash>] [--summary <text>]');
|
|
172
|
+
process.exit(2);
|
|
173
|
+
}
|
|
174
|
+
const server = serverUrl();
|
|
175
|
+
const org = orgFlag();
|
|
176
|
+
if (!org) {
|
|
177
|
+
log('Say which organisation: --org <slug>.');
|
|
178
|
+
process.exit(2);
|
|
179
|
+
}
|
|
180
|
+
if (args[1] === 'kit') {
|
|
181
|
+
const project = flag('--project');
|
|
182
|
+
if (!project) {
|
|
183
|
+
log('Say which project: --project <key>.');
|
|
184
|
+
process.exit(2);
|
|
185
|
+
}
|
|
186
|
+
const kit = await pushKit({
|
|
187
|
+
server,
|
|
188
|
+
org,
|
|
189
|
+
token: tokenFor(server, org, true) ?? '',
|
|
190
|
+
fetch: fetchImpl,
|
|
191
|
+
file: args[2],
|
|
192
|
+
project,
|
|
193
|
+
commit: flag('--commit'),
|
|
194
|
+
summary: flag('--summary'),
|
|
195
|
+
log
|
|
196
|
+
});
|
|
197
|
+
process.stdout.write(JSON.stringify(kit) + '\n');
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const text = (inline, file) => {
|
|
201
|
+
const path = flag(file);
|
|
202
|
+
return path ? readFileSync(path, 'utf8') : flag(inline);
|
|
203
|
+
};
|
|
204
|
+
const result = await pushMockup({
|
|
205
|
+
server,
|
|
206
|
+
org,
|
|
207
|
+
token: tokenFor(server, org, true) ?? '',
|
|
208
|
+
fetch: fetchImpl,
|
|
209
|
+
file: args[2],
|
|
210
|
+
item: flag('--item'),
|
|
211
|
+
project: flag('--project'),
|
|
212
|
+
mockup: flag('--mockup'),
|
|
213
|
+
another: args.includes('--another'),
|
|
214
|
+
title: flag('--title'),
|
|
215
|
+
keepNotes: text('--keep', '--keep-file'),
|
|
216
|
+
freeNotes: text('--free', '--free-file'),
|
|
217
|
+
screenshot: flag('--screenshot'),
|
|
218
|
+
log
|
|
219
|
+
});
|
|
220
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (cmd === 'queue') {
|
|
224
|
+
const sub = args[1];
|
|
225
|
+
if (sub === 'retry' && args[2])
|
|
226
|
+
log(queue.retry(args[2]) ? 'Queued again.' : 'No such queued write.');
|
|
227
|
+
else if (sub === 'drop' && args[2])
|
|
228
|
+
log(queue.drop(args[2]) ? 'Dropped.' : 'No such queued write.');
|
|
229
|
+
else
|
|
230
|
+
for (const e of queue.list())
|
|
231
|
+
log(`${e.id} ${e.state.padEnd(6)} ${e.name} ${e.key}${e.reason ? ` held: ${e.reason}` : ''}`);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (cmd === 'logout') {
|
|
235
|
+
const server = serverUrl();
|
|
236
|
+
const org = orgFlag();
|
|
237
|
+
store.delete(accountKey(server, org));
|
|
238
|
+
// An older sign in under the plain host may be what this organisation was using, so it goes too
|
|
239
|
+
if (org)
|
|
240
|
+
store.delete(accountKey(server));
|
|
241
|
+
log('Signed out.');
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (cmd === 'setup') {
|
|
245
|
+
const server = serverUrl();
|
|
246
|
+
const org = orgFlag();
|
|
247
|
+
if (!org) {
|
|
248
|
+
log('setup needs --org <slug>: the organisation the hooks send to.');
|
|
249
|
+
process.exit(2);
|
|
250
|
+
}
|
|
251
|
+
const token = tokenFor(server, org);
|
|
252
|
+
if (!token) {
|
|
253
|
+
log(`Not signed in to ${org} on ${server}. Run: agent-flow-board login --json --server ${server} --org ${org}`);
|
|
254
|
+
process.exit(1);
|
|
255
|
+
}
|
|
256
|
+
if (!(await belongsToOrg(server, org, token))) {
|
|
257
|
+
log(`The saved sign in is not for ${org}. Run the login for ${org} again.`);
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
260
|
+
const result = runSetup({
|
|
261
|
+
repo: flag('--dir') ?? process.cwd(),
|
|
262
|
+
server,
|
|
263
|
+
org,
|
|
264
|
+
shared: args.includes('--shared'),
|
|
265
|
+
configDir: configDir()
|
|
266
|
+
});
|
|
267
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
268
|
+
if (!result.ok) {
|
|
269
|
+
log(`Setup failed: ${result.error}`);
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
log(result.changed
|
|
273
|
+
? `Wrote ${result.file}: ${result.added} hook(s) added, ${result.replaced} replaced, ${result.kept} other hook(s) kept.${result.backup ? ` The old file is backed up at ${result.backup}.` : ''}`
|
|
274
|
+
: `${result.file} already has these hooks. Nothing was changed.`);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (cmd !== 'mcp' && cmd !== 'login') {
|
|
278
|
+
log('Usage: agent-flow-board mcp|login|logout --server <url>, setup --org <slug>, queue list|retry <id>|drop <id>, mockup push <file.html>, or check connect|run|watch|status');
|
|
279
|
+
process.exit(2);
|
|
280
|
+
}
|
|
281
|
+
const server = serverUrl();
|
|
282
|
+
const org = orgFlag();
|
|
283
|
+
const account = accountKey(server, org);
|
|
284
|
+
// login --json is for an agent: the first line on stdout holds the code and the verifier it needs to approve the sign in through MCP
|
|
285
|
+
const asJson = cmd === 'login' && args.includes('--json');
|
|
286
|
+
if (asJson && !org) {
|
|
287
|
+
log('login --json needs --org <slug>: the agent approves the sign in for that organisation.');
|
|
288
|
+
process.exit(2);
|
|
289
|
+
}
|
|
290
|
+
const login = async () => {
|
|
291
|
+
const r = await deviceLogin(server, fetchImpl, log, asJson
|
|
292
|
+
? {
|
|
293
|
+
pkce: true,
|
|
294
|
+
onCode: (c) => process.stdout.write(JSON.stringify(c) + '\n')
|
|
295
|
+
}
|
|
296
|
+
: {});
|
|
297
|
+
// The device approval lets the person choose the organisation, so it is checked against the one asked for before it is kept
|
|
298
|
+
if (org && !(await belongsToOrg(server, org, r.token))) {
|
|
299
|
+
throw new Error(`That sign in is for a different organisation than ${org}. It was not saved. Run the sign in again and choose ${org} when the approval page asks.`);
|
|
300
|
+
}
|
|
301
|
+
store.set(account, r.token);
|
|
302
|
+
log('Signed in.');
|
|
303
|
+
return r.token;
|
|
304
|
+
};
|
|
305
|
+
if (cmd === 'login') {
|
|
306
|
+
await login();
|
|
307
|
+
// The second line says it worked, with no token
|
|
308
|
+
if (asJson)
|
|
309
|
+
process.stdout.write(JSON.stringify({ signedIn: true, server, org }) + '\n');
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
await runBridge({
|
|
313
|
+
server,
|
|
314
|
+
org,
|
|
315
|
+
fetch: fetchImpl,
|
|
316
|
+
getToken: () => tokenFor(server, org, true),
|
|
317
|
+
login,
|
|
318
|
+
queue,
|
|
319
|
+
stdin: process.stdin,
|
|
320
|
+
stdout: process.stdout,
|
|
321
|
+
log
|
|
322
|
+
});
|
|
323
|
+
// A background sign in may still be polling when the tool closes the bridge, and it must not keep this process alive
|
|
324
|
+
setTimeout(() => process.exit(0), 500).unref();
|
|
325
|
+
}
|
|
326
|
+
main().catch((e) => {
|
|
327
|
+
log(String(e instanceof Error ? e.message : e));
|
|
328
|
+
process.exit(1);
|
|
329
|
+
});
|
package/dist/device.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// DEVICE CODE SIGN IN FOR THE BRIDGE (RFC 8628). THE PERSON TYPES THE CODE ON THE SERVER'S /device PAGE; NOTHING IS PRE FILLED.
|
|
2
|
+
// -- DEP-MODULES -- //
|
|
3
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
4
|
+
// -- FUNCTIONS -- //
|
|
5
|
+
/** How long one sign in call may take. The wait between polls is separate. */
|
|
6
|
+
export const DEVICE_CALL_TIMEOUT_MS = 30_000;
|
|
7
|
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
8
|
+
export async function deviceLogin(server, fetchImpl, log, options = {}) {
|
|
9
|
+
const wait = options.wait ?? sleep;
|
|
10
|
+
const verifier = options.pkce ? randomBytes(32).toString('base64url') : null;
|
|
11
|
+
const start = await fetchImpl(`${server}/oauth/device/code`, {
|
|
12
|
+
method: 'POST',
|
|
13
|
+
headers: { 'content-type': 'application/json' },
|
|
14
|
+
body: JSON.stringify({
|
|
15
|
+
client_name: options.name ?? 'agent-flow-board bridge',
|
|
16
|
+
...(verifier
|
|
17
|
+
? {
|
|
18
|
+
code_challenge: createHash('sha256').update(verifier).digest('base64url'),
|
|
19
|
+
code_challenge_method: 'S256'
|
|
20
|
+
}
|
|
21
|
+
: {})
|
|
22
|
+
}),
|
|
23
|
+
signal: AbortSignal.timeout(DEVICE_CALL_TIMEOUT_MS)
|
|
24
|
+
});
|
|
25
|
+
if (!start.ok)
|
|
26
|
+
throw new Error(`The server refused the sign in (${start.status}).`);
|
|
27
|
+
const d = (await start.json());
|
|
28
|
+
options.onCode?.({
|
|
29
|
+
userCode: d.user_code,
|
|
30
|
+
verifier,
|
|
31
|
+
verificationUri: d.verification_uri,
|
|
32
|
+
expiresInSeconds: d.expires_in
|
|
33
|
+
});
|
|
34
|
+
log(`To sign in, open ${d.verification_uri} and type this code: ${d.user_code}`);
|
|
35
|
+
log(`The code works for ${Math.floor(d.expires_in / 60)} minutes. Waiting for you to approve it...`);
|
|
36
|
+
let interval = d.interval * 1000;
|
|
37
|
+
const deadline = Date.now() + d.expires_in * 1000;
|
|
38
|
+
while (Date.now() < deadline) {
|
|
39
|
+
await wait(interval);
|
|
40
|
+
const res = await fetchImpl(`${server}/oauth/token`, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
43
|
+
body: new URLSearchParams({
|
|
44
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
45
|
+
device_code: d.device_code
|
|
46
|
+
}).toString(),
|
|
47
|
+
signal: AbortSignal.timeout(DEVICE_CALL_TIMEOUT_MS)
|
|
48
|
+
});
|
|
49
|
+
const body = (await res.json());
|
|
50
|
+
if (body.access_token)
|
|
51
|
+
return { token: body.access_token, expiresInSeconds: body.expires_in ?? 0 };
|
|
52
|
+
if (body.error === 'authorization_pending')
|
|
53
|
+
continue;
|
|
54
|
+
if (body.error === 'slow_down') {
|
|
55
|
+
interval += 5000;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
throw new Error(body.error_description ?? body.error ?? 'The sign in failed.');
|
|
59
|
+
}
|
|
60
|
+
throw new Error('The code expired before it was approved. Run the command again.');
|
|
61
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// THE THREE HOOK ADAPTERS: EACH TOOL'S OWN PAYLOAD TO ONE SMALL EVENT, AND THE SERVER'S ANSWER BACK IN THE TOOL'S OWN SHAPE (SPEC 9.3)
|
|
2
|
+
// Claude Code and Antigravity payloads follow what was captured on real runs. The Gemini CLI mapping follows its documented hook
|
|
3
|
+
// protocol and has not been captured on a real install. Every adapter prints one JSON object, `{}` when there is nothing to say.
|
|
4
|
+
// -- CONSTANTS -- //
|
|
5
|
+
// The board under its older name, and under a per organisation connection name (afb-<org-slug>)
|
|
6
|
+
const BOARD_SERVER = /agent[-_]flow[-_]board|^(?:mcp_+)?afb-[a-z0-9]/i;
|
|
7
|
+
// -- FUNCTIONS -- //
|
|
8
|
+
const text = (v) => (typeof v === 'string' ? v : '');
|
|
9
|
+
const obj = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
10
|
+
/** The item a close call names, when the call is a close at all. */
|
|
11
|
+
function closingFrom(args) {
|
|
12
|
+
const status = text(args.status).toUpperCase();
|
|
13
|
+
const item = text(args.item) || text(args.id);
|
|
14
|
+
return item && (status === 'COMPLETED' || status === 'REMOVED') && args.dryRun !== true
|
|
15
|
+
? [{ item, status }]
|
|
16
|
+
: [];
|
|
17
|
+
}
|
|
18
|
+
// -- CLAUDE CODE -- //
|
|
19
|
+
export const claude = {
|
|
20
|
+
tool: 'claude-code',
|
|
21
|
+
toEvent(_name, p) {
|
|
22
|
+
const sessionId = text(p.session_id);
|
|
23
|
+
if (!sessionId)
|
|
24
|
+
return null;
|
|
25
|
+
const base = {
|
|
26
|
+
tool: 'claude-code',
|
|
27
|
+
externalId: sessionId,
|
|
28
|
+
cwd: text(p.cwd) || undefined
|
|
29
|
+
};
|
|
30
|
+
const hook = text(p.hook_event_name);
|
|
31
|
+
// The editor mod reads the status file, so the events that can change what a conversation works on ask for the status:
|
|
32
|
+
// the start, each prompt, the stop, and a call of the board's own tools (a claim, a step, a release). Plain edits and reads do not.
|
|
33
|
+
if (hook === 'SessionStart')
|
|
34
|
+
return { ...base, event: 'start', status: true };
|
|
35
|
+
if (hook === 'UserPromptSubmit')
|
|
36
|
+
return { ...base, event: 'prompt', status: true };
|
|
37
|
+
// The conversation ended: the server lets go of this session's claim
|
|
38
|
+
if (hook === 'SessionEnd')
|
|
39
|
+
return { ...base, event: 'end' };
|
|
40
|
+
if (hook === 'Stop')
|
|
41
|
+
return { ...base, event: 'stop', stopActive: p.stop_hook_active === true, status: true };
|
|
42
|
+
const tool = text(p.tool_name);
|
|
43
|
+
const input = obj(p.tool_input);
|
|
44
|
+
if (hook === 'PreToolUse') {
|
|
45
|
+
if (/^mcp__.*__(item_status|flow_set_status)$/.test(tool) && BOARD_SERVER.test(tool)) {
|
|
46
|
+
const closing = closingFrom(input);
|
|
47
|
+
return closing.length ? { ...base, event: 'closing', closing } : null;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
if (hook !== 'PostToolUse')
|
|
52
|
+
return null;
|
|
53
|
+
const subagent = Boolean(p.agent_id);
|
|
54
|
+
let action = { kind: 'read' };
|
|
55
|
+
if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(tool))
|
|
56
|
+
action = { kind: 'edit', file: text(input.file_path) || text(input.notebook_path) };
|
|
57
|
+
else if (/^(Bash|PowerShell)$/.test(tool))
|
|
58
|
+
action = { kind: 'shell', command: text(input.command) };
|
|
59
|
+
else if (tool.startsWith('mcp__') && BOARD_SERVER.test(tool))
|
|
60
|
+
action = { kind: 'board', name: tool.split('__').pop() ?? '' };
|
|
61
|
+
return {
|
|
62
|
+
...base,
|
|
63
|
+
event: 'tool',
|
|
64
|
+
subagent,
|
|
65
|
+
action,
|
|
66
|
+
...(action.kind === 'board' ? { status: true } : {})
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
toOutput(event, a) {
|
|
70
|
+
if (!a)
|
|
71
|
+
return {};
|
|
72
|
+
if (event === 'start')
|
|
73
|
+
return {
|
|
74
|
+
hookSpecificOutput: {
|
|
75
|
+
hookEventName: 'SessionStart',
|
|
76
|
+
additionalContext: a.context ?? '',
|
|
77
|
+
...(a.title ? { sessionTitle: a.title } : {})
|
|
78
|
+
},
|
|
79
|
+
// The one line for the person (an older editor status mod), shown once as the conversation starts
|
|
80
|
+
...(a.notice ? { systemMessage: a.notice } : {})
|
|
81
|
+
};
|
|
82
|
+
if (event === 'prompt' && a.title)
|
|
83
|
+
return { hookSpecificOutput: { hookEventName: 'UserPromptSubmit', sessionTitle: a.title } };
|
|
84
|
+
// The nudge rides back from PostToolUse as context the agent reads before its next step
|
|
85
|
+
if (event === 'tool' && a.nudge)
|
|
86
|
+
return { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: a.nudge } };
|
|
87
|
+
if (event === 'closing' && a.ask)
|
|
88
|
+
return {
|
|
89
|
+
hookSpecificOutput: {
|
|
90
|
+
hookEventName: 'PreToolUse',
|
|
91
|
+
permissionDecision: 'ask',
|
|
92
|
+
permissionDecisionReason: a.ask
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
if (event === 'stop' && a.block)
|
|
96
|
+
return { decision: 'block', reason: a.block };
|
|
97
|
+
return {};
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
// -- ANTIGRAVITY AND agy -- //
|
|
101
|
+
const AGY_EDIT = /^(write_to_file|replace_file_content|multi_replace_file_content|edit_file|create_file|delete_file)$/;
|
|
102
|
+
export const antigravity = {
|
|
103
|
+
tool: 'antigravity',
|
|
104
|
+
toEvent(name, p) {
|
|
105
|
+
const conversation = text(p.conversationId);
|
|
106
|
+
if (!/^[A-Za-z0-9-]{8,64}$/.test(conversation))
|
|
107
|
+
return null;
|
|
108
|
+
const base = {
|
|
109
|
+
tool: 'antigravity',
|
|
110
|
+
// The prefix keeps a conversation id apart from the ids other tools use
|
|
111
|
+
externalId: `agy-${conversation}`,
|
|
112
|
+
cwd: (Array.isArray(p.workspacePaths) ? text(p.workspacePaths[0]) : '') || undefined
|
|
113
|
+
};
|
|
114
|
+
if (name === 'PreInvocation')
|
|
115
|
+
return Number(p.invocationNum ?? 0) === 0 ? { ...base, event: 'start' } : null;
|
|
116
|
+
if (name === 'Stop')
|
|
117
|
+
return { ...base, event: 'stop' };
|
|
118
|
+
if (name !== 'PostToolUse')
|
|
119
|
+
return null;
|
|
120
|
+
const call = obj(p.toolCall);
|
|
121
|
+
const args = obj(call.args);
|
|
122
|
+
const failed = text(p.error).trim() !== '';
|
|
123
|
+
const tool = text(call.name);
|
|
124
|
+
let action = { kind: 'read' };
|
|
125
|
+
if (AGY_EDIT.test(tool) || typeof args.TargetFile === 'string')
|
|
126
|
+
action = { kind: 'edit', file: text(args.TargetFile) || text(args.AbsolutePath) };
|
|
127
|
+
else if (tool === 'run_command')
|
|
128
|
+
action = { kind: 'shell', command: text(args.CommandLine) };
|
|
129
|
+
else if (tool === 'call_mcp_tool' && BOARD_SERVER.test(text(args.ServerName)))
|
|
130
|
+
action = { kind: 'board', name: text(args.ToolName) };
|
|
131
|
+
return { ...base, event: 'tool', failed, action };
|
|
132
|
+
},
|
|
133
|
+
toOutput(event, a) {
|
|
134
|
+
if (!a)
|
|
135
|
+
return {};
|
|
136
|
+
// Antigravity has no hook output that names a conversation, so the board label carries the title instead
|
|
137
|
+
if (event === 'start' && a.context)
|
|
138
|
+
return { injectSteps: [{ ephemeralMessage: a.context }] };
|
|
139
|
+
// GAP: no PostToolUse output was captured for Antigravity, so the nudge is not sent here. Its sessions still hear the
|
|
140
|
+
// progress line in every board reply and the Stop reminder, which name the steps that are not recorded.
|
|
141
|
+
// A Stop "continue" re-enters the agent loop. The server sends at most one per distinct code change.
|
|
142
|
+
if (event === 'stop' && a.block)
|
|
143
|
+
return { decision: 'continue', reason: a.block };
|
|
144
|
+
return {};
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
// -- GEMINI CLI -- //
|
|
148
|
+
export const gemini = {
|
|
149
|
+
tool: 'gemini',
|
|
150
|
+
toEvent(_name, p) {
|
|
151
|
+
const sessionId = text(p.session_id);
|
|
152
|
+
if (!sessionId)
|
|
153
|
+
return null;
|
|
154
|
+
const base = { tool: 'gemini', externalId: sessionId, cwd: text(p.cwd) || undefined };
|
|
155
|
+
const hook = text(p.hook_event_name);
|
|
156
|
+
if (hook === 'SessionStart')
|
|
157
|
+
return { ...base, event: 'start' };
|
|
158
|
+
if (hook === 'BeforeAgent')
|
|
159
|
+
return { ...base, event: 'prompt' };
|
|
160
|
+
if (hook === 'AfterAgent')
|
|
161
|
+
return { ...base, event: 'stop', stopActive: p.stop_hook_active === true };
|
|
162
|
+
const tool = text(p.tool_name);
|
|
163
|
+
const input = obj(p.tool_input);
|
|
164
|
+
if (hook === 'BeforeTool') {
|
|
165
|
+
if (/^mcp_.*(item_status|flow_set_status)$/.test(tool) && BOARD_SERVER.test(tool)) {
|
|
166
|
+
const closing = closingFrom(input);
|
|
167
|
+
return closing.length ? { ...base, event: 'closing', closing } : null;
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
if (hook !== 'AfterTool')
|
|
172
|
+
return null;
|
|
173
|
+
let action = { kind: 'read' };
|
|
174
|
+
if (/^(write_file|replace|edit)$/.test(tool))
|
|
175
|
+
action = { kind: 'edit', file: text(input.file_path) || text(input.path) };
|
|
176
|
+
else if (tool === 'run_shell_command')
|
|
177
|
+
action = { kind: 'shell', command: text(input.command) };
|
|
178
|
+
else if (tool.startsWith('mcp_') && BOARD_SERVER.test(tool))
|
|
179
|
+
action = { kind: 'board', name: tool.split('_').pop() ?? '' };
|
|
180
|
+
return { ...base, event: 'tool', action };
|
|
181
|
+
},
|
|
182
|
+
toOutput(event, a) {
|
|
183
|
+
if (!a)
|
|
184
|
+
return {};
|
|
185
|
+
if (event === 'start')
|
|
186
|
+
return {
|
|
187
|
+
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: a.context ?? '' }
|
|
188
|
+
};
|
|
189
|
+
// Not captured on a real install: AfterTool takes additionalContext in the documented protocol
|
|
190
|
+
if (event === 'tool' && a.nudge)
|
|
191
|
+
return { hookSpecificOutput: { hookEventName: 'AfterTool', additionalContext: a.nudge } };
|
|
192
|
+
// Gemini cannot ask before a tool call, so the close question is a message, never a block
|
|
193
|
+
if (event === 'closing' && a.ask)
|
|
194
|
+
return { systemMessage: a.ask };
|
|
195
|
+
if (event === 'stop' && a.block)
|
|
196
|
+
return { decision: 'deny', reason: a.block };
|
|
197
|
+
return {};
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
export const ADAPTERS = {
|
|
201
|
+
claude,
|
|
202
|
+
'claude-code': claude,
|
|
203
|
+
antigravity,
|
|
204
|
+
agy: antigravity,
|
|
205
|
+
gemini
|
|
206
|
+
};
|
|
207
|
+
/** Runs one hook: payload in, one JSON object out. Never throws, never blocks. */
|
|
208
|
+
export async function runHook(adapter, name, payload, call) {
|
|
209
|
+
try {
|
|
210
|
+
const event = adapter.toEvent(name, payload);
|
|
211
|
+
if (!event)
|
|
212
|
+
return {};
|
|
213
|
+
return adapter.toOutput(event.event, await call(event));
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return {};
|
|
217
|
+
}
|
|
218
|
+
}
|