@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/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,216 @@
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
+ };
80
+ if (event === 'prompt' && a.title)
81
+ return { hookSpecificOutput: { hookEventName: 'UserPromptSubmit', sessionTitle: a.title } };
82
+ // The nudge rides back from PostToolUse as context the agent reads before its next step
83
+ if (event === 'tool' && a.nudge)
84
+ return { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: a.nudge } };
85
+ if (event === 'closing' && a.ask)
86
+ return {
87
+ hookSpecificOutput: {
88
+ hookEventName: 'PreToolUse',
89
+ permissionDecision: 'ask',
90
+ permissionDecisionReason: a.ask
91
+ }
92
+ };
93
+ if (event === 'stop' && a.block)
94
+ return { decision: 'block', reason: a.block };
95
+ return {};
96
+ }
97
+ };
98
+ // -- ANTIGRAVITY AND agy -- //
99
+ const AGY_EDIT = /^(write_to_file|replace_file_content|multi_replace_file_content|edit_file|create_file|delete_file)$/;
100
+ export const antigravity = {
101
+ tool: 'antigravity',
102
+ toEvent(name, p) {
103
+ const conversation = text(p.conversationId);
104
+ if (!/^[A-Za-z0-9-]{8,64}$/.test(conversation))
105
+ return null;
106
+ const base = {
107
+ tool: 'antigravity',
108
+ // The prefix keeps a conversation id apart from the ids other tools use
109
+ externalId: `agy-${conversation}`,
110
+ cwd: (Array.isArray(p.workspacePaths) ? text(p.workspacePaths[0]) : '') || undefined
111
+ };
112
+ if (name === 'PreInvocation')
113
+ return Number(p.invocationNum ?? 0) === 0 ? { ...base, event: 'start' } : null;
114
+ if (name === 'Stop')
115
+ return { ...base, event: 'stop' };
116
+ if (name !== 'PostToolUse')
117
+ return null;
118
+ const call = obj(p.toolCall);
119
+ const args = obj(call.args);
120
+ const failed = text(p.error).trim() !== '';
121
+ const tool = text(call.name);
122
+ let action = { kind: 'read' };
123
+ if (AGY_EDIT.test(tool) || typeof args.TargetFile === 'string')
124
+ action = { kind: 'edit', file: text(args.TargetFile) || text(args.AbsolutePath) };
125
+ else if (tool === 'run_command')
126
+ action = { kind: 'shell', command: text(args.CommandLine) };
127
+ else if (tool === 'call_mcp_tool' && BOARD_SERVER.test(text(args.ServerName)))
128
+ action = { kind: 'board', name: text(args.ToolName) };
129
+ return { ...base, event: 'tool', failed, action };
130
+ },
131
+ toOutput(event, a) {
132
+ if (!a)
133
+ return {};
134
+ // Antigravity has no hook output that names a conversation, so the board label carries the title instead
135
+ if (event === 'start' && a.context)
136
+ return { injectSteps: [{ ephemeralMessage: a.context }] };
137
+ // GAP: no PostToolUse output was captured for Antigravity, so the nudge is not sent here. Its sessions still hear the
138
+ // progress line in every board reply and the Stop reminder, which name the steps that are not recorded.
139
+ // A Stop "continue" re-enters the agent loop. The server sends at most one per distinct code change.
140
+ if (event === 'stop' && a.block)
141
+ return { decision: 'continue', reason: a.block };
142
+ return {};
143
+ }
144
+ };
145
+ // -- GEMINI CLI -- //
146
+ export const gemini = {
147
+ tool: 'gemini',
148
+ toEvent(_name, p) {
149
+ const sessionId = text(p.session_id);
150
+ if (!sessionId)
151
+ return null;
152
+ const base = { tool: 'gemini', externalId: sessionId, cwd: text(p.cwd) || undefined };
153
+ const hook = text(p.hook_event_name);
154
+ if (hook === 'SessionStart')
155
+ return { ...base, event: 'start' };
156
+ if (hook === 'BeforeAgent')
157
+ return { ...base, event: 'prompt' };
158
+ if (hook === 'AfterAgent')
159
+ return { ...base, event: 'stop', stopActive: p.stop_hook_active === true };
160
+ const tool = text(p.tool_name);
161
+ const input = obj(p.tool_input);
162
+ if (hook === 'BeforeTool') {
163
+ if (/^mcp_.*(item_status|flow_set_status)$/.test(tool) && BOARD_SERVER.test(tool)) {
164
+ const closing = closingFrom(input);
165
+ return closing.length ? { ...base, event: 'closing', closing } : null;
166
+ }
167
+ return null;
168
+ }
169
+ if (hook !== 'AfterTool')
170
+ return null;
171
+ let action = { kind: 'read' };
172
+ if (/^(write_file|replace|edit)$/.test(tool))
173
+ action = { kind: 'edit', file: text(input.file_path) || text(input.path) };
174
+ else if (tool === 'run_shell_command')
175
+ action = { kind: 'shell', command: text(input.command) };
176
+ else if (tool.startsWith('mcp_') && BOARD_SERVER.test(tool))
177
+ action = { kind: 'board', name: tool.split('_').pop() ?? '' };
178
+ return { ...base, event: 'tool', action };
179
+ },
180
+ toOutput(event, a) {
181
+ if (!a)
182
+ return {};
183
+ if (event === 'start')
184
+ return {
185
+ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: a.context ?? '' }
186
+ };
187
+ // Not captured on a real install: AfterTool takes additionalContext in the documented protocol
188
+ if (event === 'tool' && a.nudge)
189
+ return { hookSpecificOutput: { hookEventName: 'AfterTool', additionalContext: a.nudge } };
190
+ // Gemini cannot ask before a tool call, so the close question is a message, never a block
191
+ if (event === 'closing' && a.ask)
192
+ return { systemMessage: a.ask };
193
+ if (event === 'stop' && a.block)
194
+ return { decision: 'deny', reason: a.block };
195
+ return {};
196
+ }
197
+ };
198
+ export const ADAPTERS = {
199
+ claude,
200
+ 'claude-code': claude,
201
+ antigravity,
202
+ agy: antigravity,
203
+ gemini
204
+ };
205
+ /** Runs one hook: payload in, one JSON object out. Never throws, never blocks. */
206
+ export async function runHook(adapter, name, payload, call) {
207
+ try {
208
+ const event = adapter.toEvent(name, payload);
209
+ if (!event)
210
+ return {};
211
+ return adapter.toOutput(event.event, await call(event));
212
+ }
213
+ catch {
214
+ return {};
215
+ }
216
+ }
@@ -0,0 +1,74 @@
1
+ // CALLING THE SERVER FROM A HOOK. A HOOK MUST BE QUICK AND MUST NEVER BREAK THE TOOL, SO EVERY FAILURE BECOMES AN EMPTY ANSWER.
2
+ // -- DEP-MODULES -- //
3
+ import { createHash } from 'node:crypto';
4
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { configDir } from '../tokenStore.js';
7
+ // -- CONSTANTS -- //
8
+ const TIMEOUT_MS = 4000;
9
+ // -- FUNCTIONS -- //
10
+ /** The slug of the org the token belongs to, remembered in the bridge's folder so each hook costs one request. */
11
+ async function orgOf(server, token, fetchImpl) {
12
+ // Named by the token too, so signing in again as another organisation does not keep sending to the old one
13
+ const who = createHash('sha256').update(token).digest('hex').slice(0, 12);
14
+ const file = join(configDir(), `org-${new URL(server).host.replace(/[^A-Za-z0-9.-]/g, '_')}-${who}.txt`);
15
+ try {
16
+ const cached = readFileSync(file, 'utf8').trim();
17
+ if (cached)
18
+ return cached;
19
+ }
20
+ catch {
21
+ /* Not remembered yet */
22
+ }
23
+ const res = await fetchImpl(`${server}/api/v1/me`, {
24
+ headers: { authorization: `Bearer ${token}` },
25
+ signal: AbortSignal.timeout(TIMEOUT_MS)
26
+ });
27
+ if (!res.ok)
28
+ return null;
29
+ const me = (await res.json());
30
+ const slug = me.orgs?.[0]?.slug ?? null;
31
+ if (slug) {
32
+ mkdirSync(configDir(), { recursive: true, mode: 0o700 });
33
+ writeFileSync(file, slug, { mode: 0o600 });
34
+ }
35
+ return slug;
36
+ }
37
+ // -- STATES -- //
38
+ let warned = false;
39
+ // -- FUNCTIONS -- //
40
+ /** A hook still answers empty when it cannot reach the board, but says why once on stderr so the failure is not silent. */
41
+ function warnOnce(reason) {
42
+ if (warned)
43
+ return;
44
+ warned = true;
45
+ process.stderr.write(`agent-flow-board hook: ${reason}
46
+ `);
47
+ }
48
+ /** Sends one event and returns the answer, or null on any problem (no token, offline, a refusal, a slow server). */
49
+ export async function callHook(o, event) {
50
+ if (!o.token) {
51
+ warnOnce('no sign-in found, so no progress reminder or claim release will reach the board. Run: agent-flow-board login');
52
+ return null;
53
+ }
54
+ try {
55
+ const org = await orgOf(o.server, o.token, o.fetch);
56
+ if (!org) {
57
+ warnOnce(`the server ${o.server} did not give an organisation for this sign-in.`);
58
+ return null;
59
+ }
60
+ const res = await o.fetch(`${o.server}/api/v1/orgs/${org}/hooks`, {
61
+ method: 'POST',
62
+ headers: { authorization: `Bearer ${o.token}`, 'content-type': 'application/json' },
63
+ body: JSON.stringify(event),
64
+ signal: AbortSignal.timeout(TIMEOUT_MS)
65
+ });
66
+ if (!res.ok)
67
+ warnOnce(`the server answered ${res.status} to a hook event.`);
68
+ return res.ok ? (await res.json()) : null;
69
+ }
70
+ catch {
71
+ warnOnce(`the server ${o.server} could not be reached, so the board is not told about this session.`);
72
+ return null;
73
+ }
74
+ }
@@ -0,0 +1,56 @@
1
+ // THE LOCAL GIT HOOK: A POST-COMMIT WARNING WHEN A COMMIT NAMES NO BOARD ITEM. WARN ONLY, IT NEVER BLOCKS A COMMIT (ADR-014)
2
+ // The hook is a short block between markers, so it can sit beside a hook the person already has and be removed cleanly.
3
+ // -- DEP-MODULES -- //
4
+ import { execFileSync } from 'node:child_process';
5
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { dirname, resolve } from 'node:path';
7
+ // -- CONSTANTS -- //
8
+ const START = '# >>> agent-flow-board (warn only) >>>';
9
+ const END = '# <<< agent-flow-board <<<';
10
+ const BLOCK = `${START}
11
+ # Warns when a commit names no board item. It never blocks, and it does nothing when the tool is not installed.
12
+ if command -v agent-flow-board >/dev/null 2>&1; then agent-flow-board git-hook check || true; fi
13
+ ${END}`;
14
+ const TRAILER = /^(?:board-item|items?|refs?|closes|fixes)\s*:\s*\S/im;
15
+ const SUBJECT_REF = /\([A-Za-z][A-Za-z0-9]{1,9}-[A-Za-z0-9][A-Za-z0-9-]{0,30}\)\s*$/;
16
+ // -- FUNCTIONS -- //
17
+ function hookPath(repo) {
18
+ const rel = execFileSync('git', ['-C', repo, 'rev-parse', '--git-path', 'hooks/post-commit'], {
19
+ encoding: 'utf8'
20
+ }).trim();
21
+ return resolve(repo, rel);
22
+ }
23
+ /** Adds the warning to the repository's post-commit hook, next to anything already there. Returns the file. */
24
+ export function installGitHook(repo) {
25
+ const path = hookPath(repo);
26
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : null;
27
+ if (existing?.includes(START))
28
+ return { path, created: false };
29
+ mkdirSync(dirname(path), { recursive: true });
30
+ const head = existing ?? '#!/bin/sh\n';
31
+ writeFileSync(path, `${head.replace(/\n*$/, '\n')}\n${BLOCK}\n`);
32
+ chmodSync(path, 0o755);
33
+ return { path, created: existing === null };
34
+ }
35
+ /** Removes only the block this tool wrote. A hook that held nothing else is left empty of it. */
36
+ export function uninstallGitHook(repo) {
37
+ const path = hookPath(repo);
38
+ if (!existsSync(path))
39
+ return false;
40
+ const text = readFileSync(path, 'utf8');
41
+ if (!text.includes(START))
42
+ return false;
43
+ const start = text.indexOf(START);
44
+ const end = text.indexOf(END, start);
45
+ writeFileSync(path, (text.slice(0, start) + text.slice(end + END.length)).replace(/\n{3,}/g, '\n\n'));
46
+ return true;
47
+ }
48
+ /** The warning for a commit message, or null when it names an item. */
49
+ export function checkCommit(message) {
50
+ if (TRAILER.test(message) || SUBJECT_REF.test((message.split('\n')[0] ?? '').trim()))
51
+ return null;
52
+ return 'This commit names no board item. Add a line such as "Item: CKT-20261017-0950" to the message, or ignore this when the work belongs to no item. The board only warns.';
53
+ }
54
+ export function lastCommitMessage(repo) {
55
+ return execFileSync('git', ['-C', repo, 'log', '-1', '--format=%B'], { encoding: 'utf8' });
56
+ }
@@ -0,0 +1,49 @@
1
+ // THE HOOK SETTINGS EACH TOOL NEEDS, PRINTED FOR THE PERSON TO PASTE. THE BRIDGE NEVER EDITS A TOOL'S SETTINGS BY ITSELF.
2
+ // Hook commands carry no quotes around their paths, so they work where a shell strips them (cmd /c on Windows).
3
+ // -- FUNCTIONS -- //
4
+ /** The settings block for one tool, or null for a name that is not known. */
5
+ export function hookSettings(tool, server, org) {
6
+ // With an organisation the hook reads that organisation's sign in
7
+ const bin = (adapter, event = '') => `agent-flow-board hook ${adapter}${event ? ` ${event}` : ''} --server ${server}${org ? ` --org ${org}` : ''}`;
8
+ if (tool === 'claude' || tool === 'claude-code') {
9
+ const command = (timeout) => [{ type: 'command', command: bin('claude'), timeout }];
10
+ return JSON.stringify({
11
+ hooks: {
12
+ SessionStart: [{ hooks: command(15) }],
13
+ UserPromptSubmit: [{ hooks: command(10) }],
14
+ PreToolUse: [{ matcher: '^mcp__.*__(item_status|flow_set_status)$', hooks: command(10) }],
15
+ PostToolUse: [{ matcher: '.*', hooks: command(10) }],
16
+ Stop: [{ hooks: command(20) }],
17
+ // The conversation ended: the server lets go of this session's claim
18
+ SessionEnd: [{ hooks: command(10) }]
19
+ }
20
+ }, null, 2);
21
+ }
22
+ if (tool === 'antigravity' || tool === 'agy') {
23
+ const at = (event, timeout) => ({
24
+ type: 'command',
25
+ command: bin('antigravity', event),
26
+ timeout
27
+ });
28
+ return JSON.stringify({
29
+ 'agent-flow-board': {
30
+ PostToolUse: [{ matcher: '*', hooks: [at('PostToolUse', 15)] }],
31
+ PreInvocation: [at('PreInvocation', 25)],
32
+ Stop: [at('Stop', 25)]
33
+ }
34
+ }, null, 2);
35
+ }
36
+ if (tool === 'gemini') {
37
+ const command = (timeout) => [{ type: 'command', command: bin('gemini'), timeout }];
38
+ return JSON.stringify({
39
+ hooks: {
40
+ SessionStart: [{ hooks: command(15000) }],
41
+ BeforeAgent: [{ hooks: command(10000) }],
42
+ BeforeTool: [{ matcher: 'mcp_.*(item_status|flow_set_status)', hooks: command(10000) }],
43
+ AfterTool: [{ matcher: '.*', hooks: command(10000) }],
44
+ AfterAgent: [{ hooks: command(20000) }]
45
+ }
46
+ }, null, 2);
47
+ }
48
+ return null;
49
+ }
@@ -0,0 +1,124 @@
1
+ // THE SETUP COMMAND: PUT THE BRIDGE'S CLAUDE CODE HOOKS INTO A REPO'S SETTINGS FILE WITHOUT TOUCHING ANYTHING ELSE IN IT.
2
+ // An earlier rule said the bridge never edits a tool's settings by itself (settings.ts). This command is the one place that does, and
3
+ // only when the person or an agent runs it on purpose: it merges, it keeps every hook that is not the bridge's, it backs the file up
4
+ // outside the repo first, and it refuses a file it cannot read as plain JSON instead of guessing.
5
+ // -- DEP-MODULES -- //
6
+ import { createHash } from 'node:crypto';
7
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
8
+ import { dirname, join, resolve } from 'node:path';
9
+ import { hookSettings } from './settings.js';
10
+ // -- CONSTANTS -- //
11
+ /** A command of the bridge's Claude hook, whatever its server, organisation or timeout. */
12
+ const BRIDGE_HOOK = /(^|[\\/\s"'])agent-flow-board(\.cmd|\.exe)?["']?\s+hook\s+claude\b/;
13
+ // -- FUNCTIONS -- //
14
+ const isBridge = (entry) => Array.isArray(entry.hooks) &&
15
+ entry.hooks.length > 0 &&
16
+ entry.hooks.every((h) => typeof h.command === 'string' && BRIDGE_HOOK.test(h.command));
17
+ /**
18
+ * What the settings file should hold after setup. `existing` is the file's text, or null when there is none. Hooks of the bridge are
19
+ * replaced (so a run with another organisation changes them, and a second run changes nothing); every other hook, key and entry stays.
20
+ */
21
+ export function planSetup(existing, server, org) {
22
+ const generated = hookSettings('claude', server, org);
23
+ if (!generated)
24
+ return { ok: false, error: 'No hook settings for Claude Code.' };
25
+ const wanted = JSON.parse(generated).hooks ?? {};
26
+ let settings = {};
27
+ if (existing !== null && existing.trim() !== '') {
28
+ try {
29
+ const parsed = JSON.parse(existing);
30
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
31
+ return { ok: false, error: 'The settings file is not a JSON object.' };
32
+ settings = parsed;
33
+ }
34
+ catch {
35
+ return {
36
+ ok: false,
37
+ error: 'The settings file is not plain JSON (a comment or a trailing comma?), so it was not changed. Add the hooks by hand: agent-flow-board hooks print claude --server <address> --org <slug>'
38
+ };
39
+ }
40
+ }
41
+ if (settings.hooks !== undefined &&
42
+ (typeof settings.hooks !== 'object' || Array.isArray(settings.hooks)))
43
+ return { ok: false, error: 'The "hooks" key of the settings file is not an object.' };
44
+ const hooks = { ...(settings.hooks ?? {}) };
45
+ let added = 0;
46
+ let replaced = 0;
47
+ let kept = 0;
48
+ for (const [event, entries] of Object.entries(wanted)) {
49
+ const current = Array.isArray(hooks[event]) ? hooks[event] : [];
50
+ const others = current.filter((e) => !isBridge(e));
51
+ const ours = current.filter(isBridge);
52
+ kept += others.length;
53
+ // The bridge's entries go last and in a fixed order, so running it again gives the same file
54
+ hooks[event] = [...others, ...entries];
55
+ const same = JSON.stringify(ours) === JSON.stringify(entries);
56
+ if (ours.length === 0)
57
+ added += entries.length;
58
+ else if (!same)
59
+ replaced += entries.length;
60
+ }
61
+ // Events the file has that the bridge does not use stay as they are
62
+ for (const [event, entries] of Object.entries(hooks))
63
+ if (!(event in wanted) && Array.isArray(entries))
64
+ kept += entries.length;
65
+ const next = { ...settings, hooks };
66
+ // Compared as data, so a file that only differs in how it is spaced is left as the person wrote it
67
+ const changed = existing === null || JSON.stringify(settings) !== JSON.stringify(next);
68
+ return {
69
+ ok: true,
70
+ text: JSON.stringify(next, null, '\t') + '\n',
71
+ added,
72
+ replaced,
73
+ kept,
74
+ changed
75
+ };
76
+ }
77
+ /** Where the backup of a repo's settings file goes: the bridge's own folder, never the repo, so it cannot be committed by accident. */
78
+ export function backupPath(configDir, file, now = Date.now()) {
79
+ const key = createHash('sha256').update(resolve(file)).digest('hex').slice(0, 12);
80
+ const stamp = new Date(now).toISOString().replace(/[:.]/g, '-');
81
+ return join(configDir, 'backups', `${key}-${stamp}.bak`);
82
+ }
83
+ /** Runs the setup for one repo folder. Writes the file atomically; backs the old one up first. Never throws. */
84
+ export function runSetup(options) {
85
+ try {
86
+ const file = join(options.repo, '.claude', options.shared ? 'settings.json' : 'settings.local.json');
87
+ const existing = existsSync(file) ? readFileSync(file, 'utf8') : null;
88
+ const plan = planSetup(existing, options.server, options.org);
89
+ if (!plan.ok)
90
+ return { ok: false, error: `${file}: ${plan.error}` };
91
+ if (!plan.changed)
92
+ return {
93
+ ok: true,
94
+ file,
95
+ changed: false,
96
+ added: 0,
97
+ replaced: 0,
98
+ kept: plan.kept,
99
+ backup: null
100
+ };
101
+ let backup = null;
102
+ if (existing !== null) {
103
+ backup = backupPath(options.configDir, file, options.now);
104
+ mkdirSync(dirname(backup), { recursive: true, mode: 0o700 });
105
+ writeFileSync(backup, existing, { mode: 0o600 });
106
+ }
107
+ mkdirSync(dirname(file), { recursive: true });
108
+ const temp = `${file}.${process.pid}.tmp`;
109
+ writeFileSync(temp, plan.text);
110
+ renameSync(temp, file);
111
+ return {
112
+ ok: true,
113
+ file,
114
+ changed: true,
115
+ added: plan.added,
116
+ replaced: plan.replaced,
117
+ kept: plan.kept,
118
+ backup
119
+ };
120
+ }
121
+ catch (error) {
122
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
123
+ }
124
+ }