@agent-flow-board/bridge 0.0.0-stage → 0.1.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 +34 -2
- package/dist/bridge.js +256 -0
- package/dist/cli.js +312 -0
- package/dist/device.js +61 -0
- package/dist/hooks/adapters.js +216 -0
- package/dist/hooks/call.js +74 -0
- package/dist/hooks/gitHook.js +56 -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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arben Apura
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @agent-flow-board/bridge
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The local bridge to an [Agent Flow Board](https://afb.arbenger.com) server. It forwards the board's MCP over stdio, runs the hooks of your coding tool (Claude Code, Antigravity, Gemini CLI) so the board stays true, and writes the status file that the editor status line reads. It holds no tool code: every tool lives on the server.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
npm install -g @agent-flow-board/bridge
|
|
9
|
+
agent-flow-board
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Needs Node 20 or later. Run it with no arguments to see its usage; the list should include `setup`. Install it globally: the hooks call the command several times per tool call, and a command started through `npx` each time is too slow.
|
|
13
|
+
|
|
14
|
+
## Commands
|
|
15
|
+
|
|
16
|
+
| Command | What it does |
|
|
17
|
+
| ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
18
|
+
| `agent-flow-board login --server <url> --org <slug>` | Signs in with a device code. The sign in is kept in your keychain, one entry per server and organisation. |
|
|
19
|
+
| `agent-flow-board login --json --server <url> --org <slug>` | For an agent: line 1 holds the code and a verifier, so the agent can approve it through the board's MCP. It never prints a token. |
|
|
20
|
+
| `agent-flow-board setup --org <slug> --server <url>` | Writes the Claude Code hooks into this repository's `.claude/settings.local.json` (`--shared` writes `settings.json`). It merges, keeps your other hooks and backs the old file up outside the repository. |
|
|
21
|
+
| `agent-flow-board hooks print claude --server <url> --org <slug>` | Prints the hook settings to paste by hand. |
|
|
22
|
+
| `agent-flow-board mcp --server <url> --org <slug>` | Forwards MCP over stdio, for tools that only speak stdio. |
|
|
23
|
+
| `agent-flow-board queue list` | Shows writes held while the board could not be reached. |
|
|
24
|
+
|
|
25
|
+
The easy way to set everything up is the **Editor status** tab on the board's **Connect a tool** page: copy its prompt into Claude Code and the agent does the steps, asking you once before it writes anything.
|
|
26
|
+
|
|
27
|
+
## What it writes
|
|
28
|
+
|
|
29
|
+
- Your sign in: in the operating system keychain (an encrypted file where there is none).
|
|
30
|
+
- `~/.agent-flow-board/status/<conversation id>.json`: what the board says a conversation works on. It holds no token and is removed when the conversation ends.
|
|
31
|
+
- `~/.agent-flow-board/backups/`: backups of settings files that `setup` changed.
|
|
32
|
+
|
|
33
|
+
## Licence
|
|
34
|
+
|
|
35
|
+
MIT. See `LICENSE`.
|
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// THE BRIDGE CORE (ADR-030): READS JSON-RPC LINES FROM A TOOL'S STDIN, FORWARDS EACH TO THE SERVER'S /mcp, WRITES THE REPLIES TO STDOUT.
|
|
2
|
+
// It holds no tool code, schemas or workflow text, so a server update never needs a reconnect. It only adds three things:
|
|
3
|
+
// sign in by device code, an offline queue for writes, and a protocol version check on start.
|
|
4
|
+
// -- DEP-MODULES -- //
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
6
|
+
// -- CONSTANTS -- //
|
|
7
|
+
export const BRIDGE_PROTOCOL_MAX = 1;
|
|
8
|
+
/** The longest one request may take. A tool call can wait up to 50 seconds for a run, so this sits above that. */
|
|
9
|
+
export const POST_TIMEOUT_MS = 90_000;
|
|
10
|
+
const NETWORK_CODES = new Set([
|
|
11
|
+
'ECONNREFUSED',
|
|
12
|
+
'ECONNRESET',
|
|
13
|
+
'ENOTFOUND',
|
|
14
|
+
'EAI_AGAIN',
|
|
15
|
+
'ETIMEDOUT',
|
|
16
|
+
'ENETUNREACH',
|
|
17
|
+
'EHOSTUNREACH',
|
|
18
|
+
'EPIPE',
|
|
19
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
20
|
+
'UND_ERR_SOCKET'
|
|
21
|
+
]);
|
|
22
|
+
// -- FUNCTIONS -- //
|
|
23
|
+
/** A request that timed out: the board may or may not have taken it, so it is never treated as offline. */
|
|
24
|
+
const isTimeout = (e) => e?.name === 'TimeoutError' ||
|
|
25
|
+
e?.name === 'AbortError';
|
|
26
|
+
/** Only a real failure to reach the board counts. Any other TypeError is a bug and must be shown, not hidden as offline. */
|
|
27
|
+
export const isNetworkError = (e) => {
|
|
28
|
+
if (!e || typeof e !== 'object')
|
|
29
|
+
return false;
|
|
30
|
+
const err = e;
|
|
31
|
+
if (err.code && NETWORK_CODES.has(err.code))
|
|
32
|
+
return true;
|
|
33
|
+
if (err.cause?.code && NETWORK_CODES.has(err.cause.code))
|
|
34
|
+
return true;
|
|
35
|
+
// Node's fetch reports a failed connection as a TypeError whose message is exactly this
|
|
36
|
+
return (e instanceof TypeError &&
|
|
37
|
+
/^(fetch failed|failed to fetch|network request failed)/i.test(String(err.message)));
|
|
38
|
+
};
|
|
39
|
+
/** The board refused the saved sign in. The person has to sign in again, which the bridge starts in the background. */
|
|
40
|
+
class SignInNeeded extends Error {
|
|
41
|
+
}
|
|
42
|
+
/** Runs until stdin closes. */
|
|
43
|
+
export async function runBridge(o) {
|
|
44
|
+
let token = o.getToken();
|
|
45
|
+
const postTimeout = o.postTimeoutMs ?? POST_TIMEOUT_MS;
|
|
46
|
+
let loginRunning = null;
|
|
47
|
+
/** Starts the device code sign in once. It runs in the background, so no call waits for the person to type a code. */
|
|
48
|
+
const startLogin = () => {
|
|
49
|
+
if (loginRunning)
|
|
50
|
+
return;
|
|
51
|
+
loginRunning = o
|
|
52
|
+
.login()
|
|
53
|
+
.then((t) => {
|
|
54
|
+
token = t;
|
|
55
|
+
})
|
|
56
|
+
.catch((err) => o.log(`Sign in failed: ${String(err?.message ?? err)}`))
|
|
57
|
+
.finally(() => {
|
|
58
|
+
loginRunning = null;
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
if (!token) {
|
|
62
|
+
o.log('No saved sign in. Starting the sign in.');
|
|
63
|
+
startLogin();
|
|
64
|
+
}
|
|
65
|
+
const protocolMax = o.protocolMax ?? BRIDGE_PROTOCOL_MAX;
|
|
66
|
+
let warnedProtocol = false;
|
|
67
|
+
/** Tool names known to only read, from the server's own annotations. Anything else is treated as a write. */
|
|
68
|
+
let readOnly = new Set();
|
|
69
|
+
let toolsKnown = false;
|
|
70
|
+
const write = (msg) => o.stdout.write(JSON.stringify(msg) + '\n');
|
|
71
|
+
const post = async (body) => {
|
|
72
|
+
const send = () => o.fetch(`${o.server}/mcp${o.org ? `/${o.org}` : ''}`, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: {
|
|
75
|
+
'content-type': 'application/json',
|
|
76
|
+
accept: 'application/json, text/event-stream',
|
|
77
|
+
authorization: `Bearer ${token}`
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify(body),
|
|
80
|
+
signal: AbortSignal.timeout(postTimeout)
|
|
81
|
+
});
|
|
82
|
+
if (!token)
|
|
83
|
+
throw new SignInNeeded();
|
|
84
|
+
const res = await send();
|
|
85
|
+
if (res.status === 401) {
|
|
86
|
+
// The token expired or was revoked. The sign in starts in the background and this call says so, instead of waiting for it
|
|
87
|
+
o.log('The saved sign in is no longer valid. Starting the sign in.');
|
|
88
|
+
token = null;
|
|
89
|
+
startLogin();
|
|
90
|
+
throw new SignInNeeded();
|
|
91
|
+
}
|
|
92
|
+
return res;
|
|
93
|
+
};
|
|
94
|
+
const parse = async (res) => {
|
|
95
|
+
const text = await res.text();
|
|
96
|
+
if (!text.trim())
|
|
97
|
+
return null;
|
|
98
|
+
if ((res.headers.get('content-type') ?? '').includes('text/event-stream')) {
|
|
99
|
+
const line = text.split('\n').find((l) => l.startsWith('data:'));
|
|
100
|
+
return line ? JSON.parse(line.slice(5)) : null;
|
|
101
|
+
}
|
|
102
|
+
return JSON.parse(text);
|
|
103
|
+
};
|
|
104
|
+
const checkProtocol = (res) => {
|
|
105
|
+
const v = Number(res.headers.get('x-afb-protocol') ?? 0);
|
|
106
|
+
if (v > protocolMax && !warnedProtocol) {
|
|
107
|
+
warnedProtocol = true;
|
|
108
|
+
o.log(`This server speaks protocol ${v} and the bridge understands up to ${protocolMax}. Update it with: npx -y @agent-flow-board/bridge@latest`);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
/** Sends one queued write as a real tools/call and says whether the board took it. */
|
|
112
|
+
const sendQueued = async (e) => {
|
|
113
|
+
try {
|
|
114
|
+
const res = await post({
|
|
115
|
+
jsonrpc: '2.0',
|
|
116
|
+
id: `replay-${Date.now()}`,
|
|
117
|
+
method: 'tools/call',
|
|
118
|
+
params: { name: e.name, arguments: e.args }
|
|
119
|
+
});
|
|
120
|
+
const body = await parse(res);
|
|
121
|
+
const result = body?.result;
|
|
122
|
+
if (res.status >= 500)
|
|
123
|
+
return { kind: 'offline' };
|
|
124
|
+
if (!res.ok || body?.error || result?.isError)
|
|
125
|
+
return {
|
|
126
|
+
kind: 'refused',
|
|
127
|
+
reason: result?.structuredContent?.error?.message ?? `The board answered ${res.status}.`
|
|
128
|
+
};
|
|
129
|
+
return { kind: 'ok' };
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
if (err instanceof SignInNeeded || isTimeout(err) || isNetworkError(err))
|
|
133
|
+
return { kind: 'offline' };
|
|
134
|
+
return { kind: 'refused', reason: String(err.message) };
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
const replay = async () => {
|
|
138
|
+
if (o.queue.list().length === 0)
|
|
139
|
+
return;
|
|
140
|
+
const r = await o.queue.replay(sendQueued);
|
|
141
|
+
if (r.sent || r.held)
|
|
142
|
+
o.log(`Replayed ${r.sent} queued write${r.sent === 1 ? '' : 's'}${r.held ? `, ${r.held} held back (see: agent-flow-board queue list)` : ''}.`);
|
|
143
|
+
};
|
|
144
|
+
async function handle(msg) {
|
|
145
|
+
try {
|
|
146
|
+
const res = await post(msg);
|
|
147
|
+
checkProtocol(res);
|
|
148
|
+
const body = await parse(res);
|
|
149
|
+
if (msg.id === undefined)
|
|
150
|
+
return;
|
|
151
|
+
if (body) {
|
|
152
|
+
if (msg.method === 'tools/list') {
|
|
153
|
+
const tools = body.result?.tools;
|
|
154
|
+
if (tools) {
|
|
155
|
+
readOnly = new Set(tools.filter((t) => t.annotations?.readOnlyHint).map((t) => t.name));
|
|
156
|
+
toolsKnown = true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// An answer that failed before the server read the request (a refusal such as FORBIDDEN) carries no id: it answers this one
|
|
160
|
+
write(body.error && body.id == null ? { ...body, id: msg.id } : body);
|
|
161
|
+
void replay();
|
|
162
|
+
}
|
|
163
|
+
else
|
|
164
|
+
write({
|
|
165
|
+
jsonrpc: '2.0',
|
|
166
|
+
id: msg.id,
|
|
167
|
+
error: { code: -32603, message: `The board answered ${res.status} with no body.` }
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
if (msg.id === undefined)
|
|
172
|
+
return;
|
|
173
|
+
if (err instanceof SignInNeeded) {
|
|
174
|
+
const text = 'The board did not accept the saved sign in. A sign in was started: open the address printed by the agent-flow-board bridge (or run "agent-flow-board login" in a terminal), type the code, then try again.';
|
|
175
|
+
if (msg.method === 'tools/call')
|
|
176
|
+
write({
|
|
177
|
+
jsonrpc: '2.0',
|
|
178
|
+
id: msg.id,
|
|
179
|
+
result: {
|
|
180
|
+
isError: true,
|
|
181
|
+
content: [{ type: 'text', text }],
|
|
182
|
+
structuredContent: { error: { code: 'SIGN_IN_NEEDED', message: text } }
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
else
|
|
186
|
+
write({ jsonrpc: '2.0', id: msg.id, error: { code: -32001, message: text } });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (isTimeout(err)) {
|
|
190
|
+
write({
|
|
191
|
+
jsonrpc: '2.0',
|
|
192
|
+
id: msg.id,
|
|
193
|
+
error: {
|
|
194
|
+
code: -32000,
|
|
195
|
+
message: `The board did not answer within ${Math.round(postTimeout / 1000)} seconds. The request may or may not have been applied: read the item before sending it again.`
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (!isNetworkError(err)) {
|
|
201
|
+
write({
|
|
202
|
+
jsonrpc: '2.0',
|
|
203
|
+
id: msg.id,
|
|
204
|
+
error: { code: -32603, message: String(err.message) }
|
|
205
|
+
});
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// The board cannot be reached. Reads say so, writes are queued and say they are queued.
|
|
209
|
+
const name = msg.params?.name;
|
|
210
|
+
if (msg.method === 'tools/call' && name && toolsKnown && !readOnly.has(name)) {
|
|
211
|
+
const q = o.queue.enqueue(name, msg.params?.arguments ?? {});
|
|
212
|
+
const out = {
|
|
213
|
+
queued: true,
|
|
214
|
+
queueId: q.id,
|
|
215
|
+
message: 'The board cannot be reached. This write was saved on this computer and will be sent when it is back, in order. Nothing was recorded yet.'
|
|
216
|
+
};
|
|
217
|
+
write({
|
|
218
|
+
jsonrpc: '2.0',
|
|
219
|
+
id: msg.id,
|
|
220
|
+
result: { content: [{ type: 'text', text: JSON.stringify(out) }], structuredContent: out }
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
write({
|
|
225
|
+
jsonrpc: '2.0',
|
|
226
|
+
id: msg.id,
|
|
227
|
+
error: {
|
|
228
|
+
code: -32000,
|
|
229
|
+
message: 'offline: the board cannot be reached, so this cannot be read right now.'
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const rl = createInterface({ input: o.stdin });
|
|
236
|
+
let chain = Promise.resolve();
|
|
237
|
+
rl.on('line', (line) => {
|
|
238
|
+
if (!line.trim())
|
|
239
|
+
return;
|
|
240
|
+
let msg;
|
|
241
|
+
try {
|
|
242
|
+
msg = JSON.parse(line);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
write({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
// One message at a time keeps the order of writes exactly as the tool sent them
|
|
249
|
+
chain = chain.then(() => handle(msg));
|
|
250
|
+
});
|
|
251
|
+
const timer = o.replayEveryMs === 0 ? null : setInterval(() => void replay(), o.replayEveryMs ?? 15_000);
|
|
252
|
+
await new Promise((resolve) => rl.on('close', resolve));
|
|
253
|
+
await chain;
|
|
254
|
+
if (timer)
|
|
255
|
+
clearInterval(timer);
|
|
256
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
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 mockup push <file.html> --server <url> --org <slug> --item <ref> upload one mockup file (never queued)
|
|
12
|
+
// agent-flow-board mockup kit <file.html> --server <url> --org <slug> --project <key> upload the project's kit (colours, fonts, icons)
|
|
13
|
+
// -- DEP-MODULES -- //
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { runBridge } from './bridge.js';
|
|
17
|
+
import { ADAPTERS, runHook } from './hooks/adapters.js';
|
|
18
|
+
import { callHook } from './hooks/call.js';
|
|
19
|
+
import { checkCommit, installGitHook, lastCommitMessage, uninstallGitHook } from './hooks/gitHook.js';
|
|
20
|
+
import { hookSettings } from './hooks/settings.js';
|
|
21
|
+
import { runSetup } from './hooks/setup.js';
|
|
22
|
+
import { applyStatusFile } from './hooks/statusFile.js';
|
|
23
|
+
import { deviceLogin } from './device.js';
|
|
24
|
+
import { pushKit, pushMockup } from './mockup.js';
|
|
25
|
+
import { Queue } from './queue.js';
|
|
26
|
+
import { accountKey, configDir, pickStore } from './tokenStore.js';
|
|
27
|
+
// -- FUNCTIONS -- //
|
|
28
|
+
const args = process.argv.slice(2);
|
|
29
|
+
const flag = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);
|
|
30
|
+
const cmd = args[0];
|
|
31
|
+
const log = (s) => process.stderr.write(s + '\n');
|
|
32
|
+
function serverUrl() {
|
|
33
|
+
const s = flag('--server') ?? process.env.AFB_SERVER;
|
|
34
|
+
if (!s) {
|
|
35
|
+
log('Say which server to use: --server https://board.example.com (or set AFB_SERVER).');
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
38
|
+
return s.replace(/\/$/, '');
|
|
39
|
+
}
|
|
40
|
+
/** An organisation slug as the app writes it. Anything else is ignored by a hook and refused by the other commands. */
|
|
41
|
+
const ORG_SLUG = /^[a-z0-9][a-z0-9-]{1,38}$/;
|
|
42
|
+
/** The organisation this run is for (--org), or undefined for the plain server sign in as before. */
|
|
43
|
+
function orgFlag(strict = true) {
|
|
44
|
+
const o = flag('--org');
|
|
45
|
+
if (o === undefined)
|
|
46
|
+
return undefined;
|
|
47
|
+
if (ORG_SLUG.test(o))
|
|
48
|
+
return o;
|
|
49
|
+
if (strict) {
|
|
50
|
+
log('--org takes an organisation slug: lowercase letters, digits and dashes.');
|
|
51
|
+
process.exit(2);
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
const store = pickStore();
|
|
56
|
+
/**
|
|
57
|
+
* 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
|
|
58
|
+
* host, because /mcp/<slug> answers 403 if it is the wrong organisation. Hooks have no such check, so they never borrow it.
|
|
59
|
+
*/
|
|
60
|
+
const tokenFor = (server, org, legacy = false) => process.env.AFB_TOKEN ??
|
|
61
|
+
store.get(accountKey(server, org)) ??
|
|
62
|
+
(org && legacy ? store.get(accountKey(server)) : null);
|
|
63
|
+
const queue = new Queue(join(configDir(), 'queue.json'));
|
|
64
|
+
const fetchImpl = (input, init) => fetch(input, init);
|
|
65
|
+
async function readStdin() {
|
|
66
|
+
const chunks = [];
|
|
67
|
+
for await (const c of process.stdin)
|
|
68
|
+
chunks.push(c);
|
|
69
|
+
const text = Buffer.concat(chunks).toString('utf8').trim();
|
|
70
|
+
try {
|
|
71
|
+
return text ? JSON.parse(text) : {};
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return {};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Asks the organisation's own address whether this sign in belongs to it. Only a 403 means another organisation. */
|
|
78
|
+
async function belongsToOrg(server, org, token) {
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetchImpl(`${server}/mcp/${org}`, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: {
|
|
83
|
+
'content-type': 'application/json',
|
|
84
|
+
accept: 'application/json, text/event-stream',
|
|
85
|
+
authorization: `Bearer ${token}`
|
|
86
|
+
},
|
|
87
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' })
|
|
88
|
+
});
|
|
89
|
+
// Only a refusal means another organisation, or no valid sign in
|
|
90
|
+
return res.status !== 403 && res.status !== 401;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// The board cannot be reached right now: keep the sign in, the first call checks again
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function main() {
|
|
98
|
+
if (cmd === 'hook') {
|
|
99
|
+
// A hook prints exactly one JSON object and always exits 0, so it can never break the tool that runs it
|
|
100
|
+
let out = {};
|
|
101
|
+
try {
|
|
102
|
+
const adapter = ADAPTERS[args[1] ?? ''];
|
|
103
|
+
const server = (flag('--server') ?? process.env.AFB_SERVER ?? '').replace(/\/$/, '');
|
|
104
|
+
if (adapter && server) {
|
|
105
|
+
const token = tokenFor(server, orgFlag(false));
|
|
106
|
+
out = await runHook(adapter, args[2] ?? '', await readStdin(), async (event) => {
|
|
107
|
+
const asked = Date.now();
|
|
108
|
+
const answer = await callHook({ server, token, fetch: fetchImpl }, event);
|
|
109
|
+
// The status goes to this conversation's file, never to the tool. A failed write never fails the hook.
|
|
110
|
+
applyStatusFile(join(configDir(), 'status'), event, answer, asked);
|
|
111
|
+
return answer;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
out = {};
|
|
117
|
+
}
|
|
118
|
+
process.stdout.write(JSON.stringify(out));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (cmd === 'hooks' && args[1] === 'print') {
|
|
122
|
+
const text = hookSettings(args[2] ?? '', serverUrl());
|
|
123
|
+
if (!text) {
|
|
124
|
+
log('Say which tool: claude, antigravity or gemini.');
|
|
125
|
+
process.exit(2);
|
|
126
|
+
}
|
|
127
|
+
process.stdout.write(text + '\n');
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (cmd === 'git-hook') {
|
|
131
|
+
const repo = flag('--dir') ?? process.cwd();
|
|
132
|
+
if (args[1] === 'install') {
|
|
133
|
+
const r = installGitHook(repo);
|
|
134
|
+
log(r.created ? `Installed ${r.path}.` : `Already installed in ${r.path}.`);
|
|
135
|
+
}
|
|
136
|
+
else if (args[1] === 'uninstall') {
|
|
137
|
+
log(uninstallGitHook(repo) ? 'Removed.' : 'Nothing to remove.');
|
|
138
|
+
}
|
|
139
|
+
else if (args[1] === 'check') {
|
|
140
|
+
// Warn only: print the warning to the terminal and never fail the commit
|
|
141
|
+
const warning = checkCommit(lastCommitMessage(repo));
|
|
142
|
+
if (warning)
|
|
143
|
+
log(warning);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
log('Usage: agent-flow-board git-hook install|uninstall|check [--dir <repo>]');
|
|
147
|
+
process.exit(2);
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (cmd === 'mockup') {
|
|
152
|
+
// 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
|
|
153
|
+
if (!['push', 'kit'].includes(args[1] ?? '') || !args[2] || args[2].startsWith('--')) {
|
|
154
|
+
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>]');
|
|
155
|
+
process.exit(2);
|
|
156
|
+
}
|
|
157
|
+
const server = serverUrl();
|
|
158
|
+
const org = orgFlag();
|
|
159
|
+
if (!org) {
|
|
160
|
+
log('Say which organisation: --org <slug>.');
|
|
161
|
+
process.exit(2);
|
|
162
|
+
}
|
|
163
|
+
if (args[1] === 'kit') {
|
|
164
|
+
const project = flag('--project');
|
|
165
|
+
if (!project) {
|
|
166
|
+
log('Say which project: --project <key>.');
|
|
167
|
+
process.exit(2);
|
|
168
|
+
}
|
|
169
|
+
const kit = await pushKit({
|
|
170
|
+
server,
|
|
171
|
+
org,
|
|
172
|
+
token: tokenFor(server, org, true) ?? '',
|
|
173
|
+
fetch: fetchImpl,
|
|
174
|
+
file: args[2],
|
|
175
|
+
project,
|
|
176
|
+
commit: flag('--commit'),
|
|
177
|
+
summary: flag('--summary'),
|
|
178
|
+
log
|
|
179
|
+
});
|
|
180
|
+
process.stdout.write(JSON.stringify(kit) + '\n');
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const text = (inline, file) => {
|
|
184
|
+
const path = flag(file);
|
|
185
|
+
return path ? readFileSync(path, 'utf8') : flag(inline);
|
|
186
|
+
};
|
|
187
|
+
const result = await pushMockup({
|
|
188
|
+
server,
|
|
189
|
+
org,
|
|
190
|
+
token: tokenFor(server, org, true) ?? '',
|
|
191
|
+
fetch: fetchImpl,
|
|
192
|
+
file: args[2],
|
|
193
|
+
item: flag('--item'),
|
|
194
|
+
project: flag('--project'),
|
|
195
|
+
mockup: flag('--mockup'),
|
|
196
|
+
another: args.includes('--another'),
|
|
197
|
+
title: flag('--title'),
|
|
198
|
+
keepNotes: text('--keep', '--keep-file'),
|
|
199
|
+
freeNotes: text('--free', '--free-file'),
|
|
200
|
+
screenshot: flag('--screenshot'),
|
|
201
|
+
log
|
|
202
|
+
});
|
|
203
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (cmd === 'queue') {
|
|
207
|
+
const sub = args[1];
|
|
208
|
+
if (sub === 'retry' && args[2])
|
|
209
|
+
log(queue.retry(args[2]) ? 'Queued again.' : 'No such queued write.');
|
|
210
|
+
else if (sub === 'drop' && args[2])
|
|
211
|
+
log(queue.drop(args[2]) ? 'Dropped.' : 'No such queued write.');
|
|
212
|
+
else
|
|
213
|
+
for (const e of queue.list())
|
|
214
|
+
log(`${e.id} ${e.state.padEnd(6)} ${e.name} ${e.key}${e.reason ? ` held: ${e.reason}` : ''}`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (cmd === 'logout') {
|
|
218
|
+
const server = serverUrl();
|
|
219
|
+
const org = orgFlag();
|
|
220
|
+
store.delete(accountKey(server, org));
|
|
221
|
+
// An older sign in under the plain host may be what this organisation was using, so it goes too
|
|
222
|
+
if (org)
|
|
223
|
+
store.delete(accountKey(server));
|
|
224
|
+
log('Signed out.');
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (cmd === 'setup') {
|
|
228
|
+
const server = serverUrl();
|
|
229
|
+
const org = orgFlag();
|
|
230
|
+
if (!org) {
|
|
231
|
+
log('setup needs --org <slug>: the organisation the hooks send to.');
|
|
232
|
+
process.exit(2);
|
|
233
|
+
}
|
|
234
|
+
const token = tokenFor(server, org);
|
|
235
|
+
if (!token) {
|
|
236
|
+
log(`Not signed in to ${org} on ${server}. Run: agent-flow-board login --json --server ${server} --org ${org}`);
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
if (!(await belongsToOrg(server, org, token))) {
|
|
240
|
+
log(`The saved sign in is not for ${org}. Run the login for ${org} again.`);
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
const result = runSetup({
|
|
244
|
+
repo: flag('--dir') ?? process.cwd(),
|
|
245
|
+
server,
|
|
246
|
+
org,
|
|
247
|
+
shared: args.includes('--shared'),
|
|
248
|
+
configDir: configDir()
|
|
249
|
+
});
|
|
250
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
251
|
+
if (!result.ok) {
|
|
252
|
+
log(`Setup failed: ${result.error}`);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
log(result.changed
|
|
256
|
+
? `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}.` : ''}`
|
|
257
|
+
: `${result.file} already has these hooks. Nothing was changed.`);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (cmd !== 'mcp' && cmd !== 'login') {
|
|
261
|
+
log('Usage: agent-flow-board mcp|login|logout --server <url>, setup --org <slug>, queue list|retry <id>|drop <id>, or mockup push <file.html>');
|
|
262
|
+
process.exit(2);
|
|
263
|
+
}
|
|
264
|
+
const server = serverUrl();
|
|
265
|
+
const org = orgFlag();
|
|
266
|
+
const account = accountKey(server, org);
|
|
267
|
+
// 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
|
|
268
|
+
const asJson = cmd === 'login' && args.includes('--json');
|
|
269
|
+
if (asJson && !org) {
|
|
270
|
+
log('login --json needs --org <slug>: the agent approves the sign in for that organisation.');
|
|
271
|
+
process.exit(2);
|
|
272
|
+
}
|
|
273
|
+
const login = async () => {
|
|
274
|
+
const r = await deviceLogin(server, fetchImpl, log, asJson
|
|
275
|
+
? {
|
|
276
|
+
pkce: true,
|
|
277
|
+
onCode: (c) => process.stdout.write(JSON.stringify(c) + '\n')
|
|
278
|
+
}
|
|
279
|
+
: {});
|
|
280
|
+
// The device approval lets the person choose the organisation, so it is checked against the one asked for before it is kept
|
|
281
|
+
if (org && !(await belongsToOrg(server, org, r.token))) {
|
|
282
|
+
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.`);
|
|
283
|
+
}
|
|
284
|
+
store.set(account, r.token);
|
|
285
|
+
log('Signed in.');
|
|
286
|
+
return r.token;
|
|
287
|
+
};
|
|
288
|
+
if (cmd === 'login') {
|
|
289
|
+
await login();
|
|
290
|
+
// The second line says it worked, with no token
|
|
291
|
+
if (asJson)
|
|
292
|
+
process.stdout.write(JSON.stringify({ signedIn: true, server, org }) + '\n');
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
await runBridge({
|
|
296
|
+
server,
|
|
297
|
+
org,
|
|
298
|
+
fetch: fetchImpl,
|
|
299
|
+
getToken: () => tokenFor(server, org, true),
|
|
300
|
+
login,
|
|
301
|
+
queue,
|
|
302
|
+
stdin: process.stdin,
|
|
303
|
+
stdout: process.stdout,
|
|
304
|
+
log
|
|
305
|
+
});
|
|
306
|
+
// A background sign in may still be polling when the tool closes the bridge, and it must not keep this process alive
|
|
307
|
+
setTimeout(() => process.exit(0), 500).unref();
|
|
308
|
+
}
|
|
309
|
+
main().catch((e) => {
|
|
310
|
+
log(String(e instanceof Error ? e.message : e));
|
|
311
|
+
process.exit(1);
|
|
312
|
+
});
|