@warren-dean/code-kit 0.19.0 → 0.20.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +10 -0
- package/README.md +4 -1
- package/bin/code-kit.mjs +145 -4
- package/hooks/hooks.json +22 -0
- package/hooks/lib/adapters/hub/client.mjs +708 -0
- package/hooks/lib/adapters/hub/index.mjs +25 -0
- package/hooks/lib/adapters/index.mjs +35 -0
- package/hooks/lib/plan.mjs +24 -0
- package/hooks/lib/snapshot.mjs +352 -0
- package/hooks/lib/team.mjs +46 -7
- package/hooks/lib/teamfacts.mjs +10 -6
- package/hooks/mod/register.mjs +35 -0
- package/hooks/mod/register.test.ts +45 -4
- package/hooks/mod/teamview.mjs +8 -2
- package/hooks/mod/view.mjs +36 -0
- package/hooks/team-session.mjs +57 -0
- package/package.json +1 -1
- package/skills/team-init/SKILL.md +3 -3
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The code-kit hub client (code-kit spec 21, HUBR-6): a hosted team provider's adapter. Plain Node, no
|
|
3
|
+
// dependencies. Every command takes the hub's address as `--url <https address>`, from the config.
|
|
4
|
+
//
|
|
5
|
+
// client.mjs signin sign this machine in to the hub (GitHub's device flow)
|
|
6
|
+
// client.mjs signout | status
|
|
7
|
+
// client.mjs connect set this repository's code-kit config to use the hub
|
|
8
|
+
// client.mjs drift-model hub|own choose the drift agent's model (own: the key on stdin)
|
|
9
|
+
// client.mjs <op> code-kit's team provider command (TEAM-2, HUBR-1): the
|
|
10
|
+
// operation's input as JSON on stdin, its answer on stdout
|
|
11
|
+
// client.mjs daemon --root <dir> --session <id> [--watch <pid>]
|
|
12
|
+
// holds the event stream, writes each event for code-kit's
|
|
13
|
+
// mod and the team's state for its hooks, reports presence
|
|
14
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
15
|
+
import {
|
|
16
|
+
appendFileSync,
|
|
17
|
+
chmodSync,
|
|
18
|
+
existsSync,
|
|
19
|
+
mkdirSync,
|
|
20
|
+
readFileSync,
|
|
21
|
+
renameSync,
|
|
22
|
+
statSync,
|
|
23
|
+
unlinkSync,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
} from 'node:fs';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import { dirname, join } from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
|
|
30
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const HOME = process.env.CODE_KIT_HUB_HOME ?? join(homedir(), '.config', 'code-kit-hub');
|
|
32
|
+
const OPS = [
|
|
33
|
+
'whoami',
|
|
34
|
+
'claim',
|
|
35
|
+
'release',
|
|
36
|
+
'state',
|
|
37
|
+
'intend',
|
|
38
|
+
'settle',
|
|
39
|
+
'reassign',
|
|
40
|
+
'intent',
|
|
41
|
+
'since',
|
|
42
|
+
'report',
|
|
43
|
+
];
|
|
44
|
+
/** What to run again to sign in: code-kit's own command for the client. */
|
|
45
|
+
const SIGN_IN = 'code-kit team provider signin';
|
|
46
|
+
const DOWN_AFTER_MS = 60_000;
|
|
47
|
+
const PRESENCE_MS = 30_000;
|
|
48
|
+
const EVENTS_KEPT = 500;
|
|
49
|
+
|
|
50
|
+
// --- where things are kept ---------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
const readJson = (file, fallback = null) => {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
55
|
+
} catch {
|
|
56
|
+
return fallback;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const writeJson = (file, value, mode) => {
|
|
60
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
61
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
62
|
+
writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', mode ? { mode } : undefined);
|
|
63
|
+
renameSync(tmp, file);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** The hub's address and this machine's sign-in. */
|
|
67
|
+
let given = null; // the hub's address from --url
|
|
68
|
+
const hubUrl = () => {
|
|
69
|
+
const url = given ?? process.env.CODE_KIT_HUB_URL ?? credentials()?.url;
|
|
70
|
+
if (!url)
|
|
71
|
+
throw new Error(
|
|
72
|
+
'No hub address: the config\'s team.provider names it, as { "hub": "<https address>" }.',
|
|
73
|
+
);
|
|
74
|
+
return url.replace(/\/$/, '');
|
|
75
|
+
};
|
|
76
|
+
const credentials = () => readJson(join(HOME, 'credentials.json'));
|
|
77
|
+
|
|
78
|
+
const git = (cwd, ...args) => {
|
|
79
|
+
try {
|
|
80
|
+
return execFileSync('git', ['-C', cwd, ...args], {
|
|
81
|
+
encoding: 'utf8',
|
|
82
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
83
|
+
}).trim();
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** The origin's address as configured (git's `insteadOf` rewriting left out). */
|
|
90
|
+
const originOf = (cwd) => git(cwd, 'config', '--get', 'remote.origin.url');
|
|
91
|
+
|
|
92
|
+
/** owner/name of the repository's GitHub origin. */
|
|
93
|
+
export function repoOf(remote) {
|
|
94
|
+
const m = String(remote ?? '').match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
|
95
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The main checkout: state is shared by the lanes' worktrees. */
|
|
99
|
+
const mainRoot = (cwd) => {
|
|
100
|
+
const common = git(cwd, 'rev-parse', '--path-format=absolute', '--git-common-dir');
|
|
101
|
+
return common ? dirname(common) : cwd;
|
|
102
|
+
};
|
|
103
|
+
const stateDir = (root) => join(mainRoot(root), '.claude', 'state');
|
|
104
|
+
const hubDown = (root) => readJson(join(stateDir(root), 'hub-down.json'), {});
|
|
105
|
+
|
|
106
|
+
// --- talking to the hub ------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
async function hub(method, path, body, { timeoutMs = 15_000 } = {}) {
|
|
109
|
+
const cred = credentials();
|
|
110
|
+
const res = await fetch(`${hubUrl()}${path}`, {
|
|
111
|
+
method,
|
|
112
|
+
headers: {
|
|
113
|
+
'content-type': 'application/json',
|
|
114
|
+
...(cred?.token ? { authorization: `Bearer ${cred.token}` } : {}),
|
|
115
|
+
},
|
|
116
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
117
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
118
|
+
});
|
|
119
|
+
const text = await res.text();
|
|
120
|
+
let json;
|
|
121
|
+
try {
|
|
122
|
+
json = JSON.parse(text);
|
|
123
|
+
} catch {
|
|
124
|
+
json = { ok: false, why: `The hub answered ${res.status}.` };
|
|
125
|
+
}
|
|
126
|
+
return { status: res.status, json };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const unreachable = (why) => ({ ok: false, unreachable: true, why });
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* code-kit's provider command: one operation. The hub is down when it hasn't answered for a minute, and
|
|
133
|
+
* then every operation answers `unreachable` at once, so code-kit falls back to its GitHub provider
|
|
134
|
+
* (team.provider.fallback) without waiting on the network.
|
|
135
|
+
*/
|
|
136
|
+
export async function provide(op, input, root = process.cwd()) {
|
|
137
|
+
if (!OPS.includes(op)) return { ok: false, why: `The hub has no operation "${op}".` };
|
|
138
|
+
const repo = repoOf(originOf(root));
|
|
139
|
+
if (!repo)
|
|
140
|
+
return unreachable("This repository has no GitHub origin, so the hub can't be used here.");
|
|
141
|
+
if (!credentials()?.token)
|
|
142
|
+
return unreachable(`This machine isn't signed in to code-kit hub: run ${SIGN_IN}.`);
|
|
143
|
+
const down = hubDown(root);
|
|
144
|
+
if (
|
|
145
|
+
down.since &&
|
|
146
|
+
Date.now() - down.since > DOWN_AFTER_MS &&
|
|
147
|
+
Date.now() - (down.tried ?? 0) < 15_000
|
|
148
|
+
)
|
|
149
|
+
return unreachable(
|
|
150
|
+
`code-kit hub hasn't answered since ${new Date(down.since).toLocaleTimeString()}.`,
|
|
151
|
+
);
|
|
152
|
+
// The daemon keeps the state fresh from the stream: read it from disk unless asked for fresh.
|
|
153
|
+
if (op === 'state' && !input.fresh) {
|
|
154
|
+
const kept = readJson(join(stateDir(root), 'team.json'));
|
|
155
|
+
const live = readJson(join(stateDir(root), 'hub-stream.json'), {});
|
|
156
|
+
if (kept?.hub && live.connected && Date.now() - Date.parse(kept.at) < 5 * 60_000)
|
|
157
|
+
return named(kept, root);
|
|
158
|
+
}
|
|
159
|
+
const { team: _team, ...body } = input;
|
|
160
|
+
try {
|
|
161
|
+
const { status, json } = await hub('POST', `/v1/repos/${repo}/${op}`, body);
|
|
162
|
+
markUp(root);
|
|
163
|
+
if (status === 401) return unreachable(json.why ?? `Sign in to the hub again: ${SIGN_IN}.`);
|
|
164
|
+
if (status >= 500 && json?.unreachable) return unreachable(json.why);
|
|
165
|
+
if ((op === 'claim' || op === 'release' || op === 'reassign') && json?.ok)
|
|
166
|
+
mirrorClaim(root, op, input, json);
|
|
167
|
+
return op === 'state' && json?.hub ? named(json, root) : json;
|
|
168
|
+
} catch (err) {
|
|
169
|
+
markDown(root);
|
|
170
|
+
return unreachable(
|
|
171
|
+
`code-kit hub can't be reached (${err.name === 'TimeoutError' ? 'timed out' : err.message}).`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The state names what coordinates the team, for code-kit's Team pane: the hub, whether this session's
|
|
178
|
+
* stream is live, and who this machine is signed in as.
|
|
179
|
+
*/
|
|
180
|
+
function named(state, root) {
|
|
181
|
+
const live = readJson(join(stateDir(root), 'hub-stream.json'), {});
|
|
182
|
+
return {
|
|
183
|
+
...state,
|
|
184
|
+
provider: {
|
|
185
|
+
name: 'code-kit hub',
|
|
186
|
+
live: Boolean(live.connected),
|
|
187
|
+
account: credentials()?.login ?? null,
|
|
188
|
+
detail: live.connected
|
|
189
|
+
? 'claims, leases, intents and presence as they happen; every push checked for drift'
|
|
190
|
+
: (live.why ?? 'not connected to its live updates; read on each refresh'),
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function markDown(root) {
|
|
196
|
+
const file = join(stateDir(root), 'hub-down.json');
|
|
197
|
+
const d = readJson(file, {});
|
|
198
|
+
writeJson(file, { since: d.since ?? Date.now(), tried: Date.now() });
|
|
199
|
+
}
|
|
200
|
+
function markUp(root) {
|
|
201
|
+
const file = join(stateDir(root), 'hub-down.json');
|
|
202
|
+
if (existsSync(file)) writeJson(file, {});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The hub's claims, mirrored as code-kit's claim refs, so code-kit's GitHub provider sees the same
|
|
207
|
+
* claims if the hub goes down. Best effort: the hub stays the authority.
|
|
208
|
+
*/
|
|
209
|
+
function mirrorClaim(root, op, input, result) {
|
|
210
|
+
const claim = op === 'claim' ? result.claim : op === 'reassign' ? null : result.claim;
|
|
211
|
+
const story = claim?.story ?? input.story;
|
|
212
|
+
if (!/^[A-Za-z][A-Za-z0-9]*-\d+$/.test(String(story))) return;
|
|
213
|
+
const ref = `refs/code-kit/claims/${story}`;
|
|
214
|
+
try {
|
|
215
|
+
if (op === 'release') {
|
|
216
|
+
if (result.released)
|
|
217
|
+
execFileSync('git', ['-C', root, 'push', '--quiet', 'origin', `:${ref}`], {
|
|
218
|
+
stdio: 'ignore',
|
|
219
|
+
timeout: 20_000,
|
|
220
|
+
});
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const c =
|
|
224
|
+
op === 'claim' ? claim : { story, by: input.to, at: new Date().toISOString(), lease: [] };
|
|
225
|
+
const email = `${c.by}@users.noreply.github.com`;
|
|
226
|
+
const env = {
|
|
227
|
+
...process.env,
|
|
228
|
+
GIT_AUTHOR_NAME: c.by,
|
|
229
|
+
GIT_AUTHOR_EMAIL: email,
|
|
230
|
+
GIT_COMMITTER_NAME: c.by,
|
|
231
|
+
GIT_COMMITTER_EMAIL: email,
|
|
232
|
+
};
|
|
233
|
+
const tree = execFileSync('git', ['-C', root, 'mktree'], {
|
|
234
|
+
input: '',
|
|
235
|
+
encoding: 'utf8',
|
|
236
|
+
}).trim();
|
|
237
|
+
const sha = execFileSync(
|
|
238
|
+
'git',
|
|
239
|
+
[
|
|
240
|
+
'-C',
|
|
241
|
+
root,
|
|
242
|
+
'commit-tree',
|
|
243
|
+
tree,
|
|
244
|
+
'-m',
|
|
245
|
+
JSON.stringify({ story: c.story, by: c.by, at: c.at, lease: c.lease }),
|
|
246
|
+
],
|
|
247
|
+
{ encoding: 'utf8', env },
|
|
248
|
+
).trim();
|
|
249
|
+
execFileSync('git', ['-C', root, 'push', '--quiet', '--force', 'origin', `${sha}:${ref}`], {
|
|
250
|
+
stdio: 'ignore',
|
|
251
|
+
timeout: 20_000,
|
|
252
|
+
});
|
|
253
|
+
} catch {
|
|
254
|
+
// the mirror is a fallback's convenience; the hub has the claim
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --- sign-in (HUB-1): GitHub's device flow, through the App ------------------------------------
|
|
259
|
+
|
|
260
|
+
async function signin() {
|
|
261
|
+
const meta = await hub('GET', '/v1/meta');
|
|
262
|
+
const { clientId, github } = meta.json;
|
|
263
|
+
const form = (o) => new URLSearchParams(o).toString();
|
|
264
|
+
const started = await (
|
|
265
|
+
await fetch(`${github}/login/device/code`, {
|
|
266
|
+
method: 'POST',
|
|
267
|
+
headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' },
|
|
268
|
+
body: form({ client_id: clientId }),
|
|
269
|
+
})
|
|
270
|
+
).json();
|
|
271
|
+
if (!started.device_code)
|
|
272
|
+
throw new Error(
|
|
273
|
+
`GitHub didn't start the sign-in: ${started.error_description ?? JSON.stringify(started)}`,
|
|
274
|
+
);
|
|
275
|
+
console.log(
|
|
276
|
+
`Open ${started.verification_uri} and enter the code ${started.user_code} (within ${Math.round(started.expires_in / 60)} minutes).`,
|
|
277
|
+
);
|
|
278
|
+
let interval = (started.interval ?? 5) * 1000;
|
|
279
|
+
const until = Date.now() + started.expires_in * 1000;
|
|
280
|
+
while (Date.now() < until) {
|
|
281
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
282
|
+
const polled = await (
|
|
283
|
+
await fetch(`${github}/login/oauth/access_token`, {
|
|
284
|
+
method: 'POST',
|
|
285
|
+
headers: {
|
|
286
|
+
accept: 'application/json',
|
|
287
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
288
|
+
},
|
|
289
|
+
body: form({
|
|
290
|
+
client_id: clientId,
|
|
291
|
+
device_code: started.device_code,
|
|
292
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
293
|
+
}),
|
|
294
|
+
})
|
|
295
|
+
).json();
|
|
296
|
+
if (polled.error === 'authorization_pending') continue;
|
|
297
|
+
if (polled.error === 'slow_down') {
|
|
298
|
+
interval += 5000;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (!polled.access_token)
|
|
302
|
+
throw new Error(`GitHub refused the sign-in: ${polled.error_description ?? polled.error}`);
|
|
303
|
+
const signed = await hub('POST', '/v1/auth/github', { token: polled.access_token });
|
|
304
|
+
if (!signed.json.token) throw new Error(signed.json.why ?? 'The hub refused the sign-in.');
|
|
305
|
+
writeJson(
|
|
306
|
+
join(HOME, 'credentials.json'),
|
|
307
|
+
{ url: hubUrl(), login: signed.json.login, token: signed.json.token },
|
|
308
|
+
0o600,
|
|
309
|
+
);
|
|
310
|
+
chmodSync(join(HOME, 'credentials.json'), 0o600);
|
|
311
|
+
console.log(`Signed in to code-kit hub as ${signed.json.login}.`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
throw new Error('The sign-in code expired; run signin again.');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// --- connect: this repository's code-kit config uses the hub -----------------------------------
|
|
318
|
+
|
|
319
|
+
// --- the config's team provider, changed in place -------------------------------------------------
|
|
320
|
+
|
|
321
|
+
const space = (text, i) => {
|
|
322
|
+
while (/\s/.test(text[i] ?? '')) i++;
|
|
323
|
+
return i;
|
|
324
|
+
};
|
|
325
|
+
const stringEnd = (text, i) => {
|
|
326
|
+
for (i++; i < text.length; i++) {
|
|
327
|
+
if (text[i] === '\\') i++;
|
|
328
|
+
else if (text[i] === '"') return i + 1;
|
|
329
|
+
}
|
|
330
|
+
throw new Error('unterminated string');
|
|
331
|
+
};
|
|
332
|
+
/** Where the JSON value starting at `i` ends. */
|
|
333
|
+
const valueEnd = (text, i) => {
|
|
334
|
+
if (text[i] === '"') return stringEnd(text, i);
|
|
335
|
+
if (text[i] !== '{' && text[i] !== '[') {
|
|
336
|
+
while (i < text.length && !/[\s,\]}]/.test(text[i])) i++;
|
|
337
|
+
return i;
|
|
338
|
+
}
|
|
339
|
+
let depth = 0;
|
|
340
|
+
for (; i < text.length; i++) {
|
|
341
|
+
if (text[i] === '"') i = stringEnd(text, i) - 1;
|
|
342
|
+
else if (text[i] === '{' || text[i] === '[') depth++;
|
|
343
|
+
else if ((text[i] === '}' || text[i] === ']') && --depth === 0) return i + 1;
|
|
344
|
+
}
|
|
345
|
+
throw new Error('unterminated value');
|
|
346
|
+
};
|
|
347
|
+
/** An object's members (key, where its line starts, where its value starts and ends), from its `{` at `open`. */
|
|
348
|
+
const members = (text, open) => {
|
|
349
|
+
const out = [];
|
|
350
|
+
let i = space(text, open + 1);
|
|
351
|
+
while (text[i] === '"') {
|
|
352
|
+
const keyEnd = stringEnd(text, i);
|
|
353
|
+
const key = JSON.parse(text.slice(i, keyEnd));
|
|
354
|
+
const start = space(text, space(text, keyEnd) + 1);
|
|
355
|
+
const end = valueEnd(text, start);
|
|
356
|
+
out.push({ key, at: i, start, end });
|
|
357
|
+
i = space(text, end);
|
|
358
|
+
if (text[i] === ',') i = space(text, i + 1);
|
|
359
|
+
}
|
|
360
|
+
return out;
|
|
361
|
+
};
|
|
362
|
+
const indentAt = (text, i) => text.slice(text.lastIndexOf('\n', i - 1) + 1, i).match(/^[ \t]*/)[0];
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The config's text with `team.provider` set, everything else as it was: its order, its indentation and
|
|
366
|
+
* how its arrays are laid out, so committing it shows only the provider.
|
|
367
|
+
*/
|
|
368
|
+
export function withTeamProvider(text, provider) {
|
|
369
|
+
const top = members(text, space(text, 0));
|
|
370
|
+
const team = top.find((m) => m.key === 'team');
|
|
371
|
+
if (!team || text[team.start] !== '{') throw new Error('The config has no "team" object.');
|
|
372
|
+
const inside = members(text, team.start);
|
|
373
|
+
const first = inside[0];
|
|
374
|
+
const multiline = first ? text.slice(team.start, first.at).includes('\n') : true;
|
|
375
|
+
const inner = first && multiline ? indentAt(text, first.at) : `${indentAt(text, team.at)} `;
|
|
376
|
+
const unit = inner.slice(indentAt(text, team.at).length) || ' ';
|
|
377
|
+
const value = multiline
|
|
378
|
+
? `{\n${Object.entries(provider)
|
|
379
|
+
.map(([k, v]) => `${inner}${unit}${JSON.stringify(k)}: ${JSON.stringify(v)}`)
|
|
380
|
+
.join(',\n')}\n${inner}}`
|
|
381
|
+
: `{ ${Object.entries(provider)
|
|
382
|
+
.map(([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`)
|
|
383
|
+
.join(', ')} }`;
|
|
384
|
+
const had = inside.find((m) => m.key === 'provider');
|
|
385
|
+
const last = inside.at(-1);
|
|
386
|
+
const out = had
|
|
387
|
+
? text.slice(0, had.start) + value + text.slice(had.end)
|
|
388
|
+
: last
|
|
389
|
+
? `${text.slice(0, last.end)},${multiline ? `\n${inner}` : ' '}"provider": ${value}${text.slice(last.end)}`
|
|
390
|
+
: `${text.slice(0, team.start + 1)}\n${inner}"provider": ${value}\n${indentAt(text, team.at)}${text.slice(team.start + 1)}`;
|
|
391
|
+
// What was meant, and nothing else: the parsed config is the old one with the provider set.
|
|
392
|
+
const want = JSON.parse(text);
|
|
393
|
+
want.team.provider = provider;
|
|
394
|
+
if (JSON.stringify(JSON.parse(out)) !== JSON.stringify(want))
|
|
395
|
+
throw new Error(
|
|
396
|
+
'Setting the team provider would change more of .claude/code-kit.json than it should.',
|
|
397
|
+
);
|
|
398
|
+
return out;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** The project's MCP servers with the hub's added, as text; null when it's already there. */
|
|
402
|
+
export function withHubMcp(text, url) {
|
|
403
|
+
const config = text.trim() ? JSON.parse(text) : {};
|
|
404
|
+
const server = { type: 'http', url: `${url}/mcp` };
|
|
405
|
+
if (JSON.stringify(config.mcpServers?.['code-kit-hub']) === JSON.stringify(server)) return null;
|
|
406
|
+
config.mcpServers = { ...(config.mcpServers ?? {}), 'code-kit-hub': server };
|
|
407
|
+
return `${JSON.stringify(config, null, 2)}\n`;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function connect(root) {
|
|
411
|
+
const url = hubUrl();
|
|
412
|
+
const file = join(root, '.claude', 'code-kit.json');
|
|
413
|
+
const config = readJson(file);
|
|
414
|
+
if (!config)
|
|
415
|
+
throw new Error(
|
|
416
|
+
'This repository has no .claude/code-kit.json: set up code-kit first (/code-kit:init).',
|
|
417
|
+
);
|
|
418
|
+
if (!config.team?.techLead)
|
|
419
|
+
throw new Error(
|
|
420
|
+
'Name the tech lead first: "team": { "techLead": "<GitHub handle>" } in .claude/code-kit.json (code-kit spec 16).',
|
|
421
|
+
);
|
|
422
|
+
const provider = { hub: url };
|
|
423
|
+
if (JSON.stringify(config.team.provider) !== JSON.stringify(provider))
|
|
424
|
+
writeFileSync(file, withTeamProvider(readFileSync(file, 'utf8'), provider));
|
|
425
|
+
// Agents reach the hub over MCP: the project's MCP servers name it, for every teammate.
|
|
426
|
+
const mcpFile = join(root, '.mcp.json');
|
|
427
|
+
const mcp = withHubMcp(existsSync(mcpFile) ? readFileSync(mcpFile, 'utf8') : '', url);
|
|
428
|
+
if (mcp) writeFileSync(mcpFile, mcp);
|
|
429
|
+
const state = await provide('state', { fresh: true }, root);
|
|
430
|
+
if (state.unreachable || state.ok === false) {
|
|
431
|
+
console.log(`The config now uses the hub, but the hub says: ${state.why}`);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
console.log(
|
|
435
|
+
`This repository now coordinates through the hub (${state.claims.length} claims, ${state.intents.length} open intents). Commit .claude/code-kit.json and .mcp.json so the team does too.`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// --- the daemon (HUB-5): the stream, the team's state on disk, presence --------------------------
|
|
440
|
+
|
|
441
|
+
async function daemon({ root, session, watch }) {
|
|
442
|
+
const dir = stateDir(root);
|
|
443
|
+
const repo = repoOf(originOf(root));
|
|
444
|
+
if (!repo || !credentials()?.token) return;
|
|
445
|
+
const streamFile = join(dir, 'hub-stream.json');
|
|
446
|
+
const eventsFile = join(dir, 'team-events.jsonl');
|
|
447
|
+
writeJson(join(dir, `hub-daemon-${session}.json`), { pid: process.pid, started: Date.now() });
|
|
448
|
+
let lastId = readJson(streamFile, {}).lastId ?? 0;
|
|
449
|
+
let refreshing = null;
|
|
450
|
+
const refresh = () => {
|
|
451
|
+
refreshing ??= (async () => {
|
|
452
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
453
|
+
try {
|
|
454
|
+
const { status, json } = await hub('POST', `/v1/repos/${repo}/state`, {});
|
|
455
|
+
if (status === 200 && json.hub) writeJson(join(dir, 'team.json'), json);
|
|
456
|
+
} catch {
|
|
457
|
+
// the stream's reconnect reports the hub down
|
|
458
|
+
}
|
|
459
|
+
refreshing = null;
|
|
460
|
+
})();
|
|
461
|
+
};
|
|
462
|
+
const presence = async () => {
|
|
463
|
+
const agents = Object.values(
|
|
464
|
+
readJson(join(dir, `hub-presence-${session}.json`), {}).agents ?? {},
|
|
465
|
+
).filter((a) => Date.now() - a.at < 2 * 60_000);
|
|
466
|
+
try {
|
|
467
|
+
await hub('POST', `/v1/repos/${repo}/presence`, {
|
|
468
|
+
session,
|
|
469
|
+
branch: git(root, 'symbolic-ref', '--short', '-q', 'HEAD'),
|
|
470
|
+
agents: agents.map(({ type, tool, branch }) => ({ type, tool, branch })),
|
|
471
|
+
});
|
|
472
|
+
} catch {
|
|
473
|
+
// reported on the next try
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
const tick = setInterval(() => {
|
|
477
|
+
if (watch && !alive(watch)) stop();
|
|
478
|
+
else presence();
|
|
479
|
+
}, PRESENCE_MS);
|
|
480
|
+
let stopping = false;
|
|
481
|
+
const stop = async () => {
|
|
482
|
+
if (stopping) return;
|
|
483
|
+
stopping = true;
|
|
484
|
+
clearInterval(tick);
|
|
485
|
+
await hub('POST', `/v1/repos/${repo}/leave`, { session }, { timeoutMs: 3000 }).catch(() => {});
|
|
486
|
+
writeJson(streamFile, { lastId, connected: false });
|
|
487
|
+
try {
|
|
488
|
+
unlinkSync(join(dir, `hub-daemon-${session}.json`));
|
|
489
|
+
} catch {}
|
|
490
|
+
process.exit(0);
|
|
491
|
+
};
|
|
492
|
+
process.on('SIGTERM', stop);
|
|
493
|
+
process.on('SIGINT', stop);
|
|
494
|
+
await presence();
|
|
495
|
+
refresh();
|
|
496
|
+
|
|
497
|
+
let backoff = 1000;
|
|
498
|
+
while (!stopping) {
|
|
499
|
+
try {
|
|
500
|
+
const res = await fetch(`${hubUrl()}/v1/repos/${repo}/stream`, {
|
|
501
|
+
headers: {
|
|
502
|
+
authorization: `Bearer ${credentials()?.token}`,
|
|
503
|
+
...(lastId ? { 'last-event-id': String(lastId) } : {}),
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
if (res.status === 401 || res.status === 403 || res.status === 404) {
|
|
507
|
+
writeJson(streamFile, {
|
|
508
|
+
lastId,
|
|
509
|
+
connected: false,
|
|
510
|
+
why: (await res.json().catch(() => ({}))).why,
|
|
511
|
+
});
|
|
512
|
+
await new Promise((r) => setTimeout(r, 60_000));
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if (!res.ok || !res.body) throw new Error(`hub answered ${res.status}`);
|
|
516
|
+
markUp(root);
|
|
517
|
+
writeJson(streamFile, { lastId, connected: true, at: Date.now() });
|
|
518
|
+
backoff = 1000;
|
|
519
|
+
const reader = res.body.getReader();
|
|
520
|
+
const decoder = new TextDecoder();
|
|
521
|
+
let buffer = '';
|
|
522
|
+
for (;;) {
|
|
523
|
+
const { value, done } = await reader.read();
|
|
524
|
+
if (done) break;
|
|
525
|
+
buffer += decoder.decode(value, { stream: true });
|
|
526
|
+
let i;
|
|
527
|
+
while ((i = buffer.indexOf('\n\n')) >= 0) {
|
|
528
|
+
const block = buffer.slice(0, i);
|
|
529
|
+
buffer = buffer.slice(i + 2);
|
|
530
|
+
const data = block.match(/^data: (.*)$/m)?.[1];
|
|
531
|
+
if (!data) continue;
|
|
532
|
+
const event = JSON.parse(data);
|
|
533
|
+
if (event.id) lastId = event.id;
|
|
534
|
+
// Each event, a line code-kit's mod reads; and the state on disk, for code-kit's hooks.
|
|
535
|
+
appendFileSync(eventsFile, JSON.stringify(event) + '\n');
|
|
536
|
+
writeJson(streamFile, { lastId, connected: true, at: Date.now() });
|
|
537
|
+
refresh();
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
} catch {
|
|
541
|
+
markDown(root);
|
|
542
|
+
writeJson(streamFile, { lastId, connected: false, at: Date.now() });
|
|
543
|
+
}
|
|
544
|
+
trim(eventsFile);
|
|
545
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
546
|
+
backoff = Math.min(backoff * 2, 30_000);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const alive = (pid) => {
|
|
551
|
+
try {
|
|
552
|
+
process.kill(pid, 0);
|
|
553
|
+
return true;
|
|
554
|
+
} catch {
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
function trim(file) {
|
|
560
|
+
try {
|
|
561
|
+
if (statSync(file).size < 512 * 1024) return;
|
|
562
|
+
const lines = readFileSync(file, 'utf8').trim().split('\n');
|
|
563
|
+
writeFileSync(file, lines.slice(-EVENTS_KEPT).join('\n') + '\n');
|
|
564
|
+
} catch {}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Starts the daemon for a session, detached; one per session. */
|
|
568
|
+
export function startDaemon(root, session, watch, url) {
|
|
569
|
+
const dir = stateDir(root);
|
|
570
|
+
const had = readJson(join(dir, `hub-daemon-${session}.json`));
|
|
571
|
+
if (had?.pid && alive(had.pid)) return had.pid;
|
|
572
|
+
const child = spawn(
|
|
573
|
+
process.execPath,
|
|
574
|
+
[
|
|
575
|
+
join(HERE, 'client.mjs'),
|
|
576
|
+
'--url',
|
|
577
|
+
url,
|
|
578
|
+
'daemon',
|
|
579
|
+
'--root',
|
|
580
|
+
root,
|
|
581
|
+
'--session',
|
|
582
|
+
session,
|
|
583
|
+
...(watch ? ['--watch', String(watch)] : []),
|
|
584
|
+
],
|
|
585
|
+
{
|
|
586
|
+
detached: true,
|
|
587
|
+
stdio: 'ignore',
|
|
588
|
+
cwd: root,
|
|
589
|
+
},
|
|
590
|
+
);
|
|
591
|
+
child.unref();
|
|
592
|
+
return child.pid;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* The session's agents as the mod last saw them (HUB-4): { [agent id]: { type, tool, branch, at } }, by
|
|
597
|
+
* tool name and branch only, for the daemon to report every 30 seconds.
|
|
598
|
+
*/
|
|
599
|
+
export function recordPresence(root, session, agents) {
|
|
600
|
+
const id = String(session).replace(/[^\w-]/g, '');
|
|
601
|
+
const now = Date.now();
|
|
602
|
+
const kept = Object.fromEntries(
|
|
603
|
+
Object.entries(agents ?? {})
|
|
604
|
+
.filter(([, a]) => a && now - Number(a.at ?? 0) < 10 * 60_000)
|
|
605
|
+
.map(([k, a]) => [
|
|
606
|
+
k,
|
|
607
|
+
{
|
|
608
|
+
type: typeof a.type === 'string' ? a.type : null,
|
|
609
|
+
tool: typeof a.tool === 'string' ? a.tool : null,
|
|
610
|
+
branch: typeof a.branch === 'string' ? a.branch : null,
|
|
611
|
+
at: Number(a.at),
|
|
612
|
+
},
|
|
613
|
+
]),
|
|
614
|
+
);
|
|
615
|
+
writeJson(join(stateDir(root), `hub-presence-${id}.json`), { agents: kept });
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export function stopDaemon(root, session) {
|
|
619
|
+
const had = readJson(join(stateDir(root), `hub-daemon-${session}.json`));
|
|
620
|
+
if (had?.pid && alive(had.pid)) process.kill(had.pid, 'SIGTERM');
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// --- the command line --------------------------------------------------------------------------
|
|
624
|
+
|
|
625
|
+
const flag = (args, name) => {
|
|
626
|
+
const i = args.indexOf(`--${name}`);
|
|
627
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
async function main(argv) {
|
|
631
|
+
const at = argv.indexOf('--url');
|
|
632
|
+
if (at >= 0) {
|
|
633
|
+
given = argv[at + 1] ?? null;
|
|
634
|
+
argv = [...argv.slice(0, at), ...argv.slice(at + 2)];
|
|
635
|
+
}
|
|
636
|
+
const [cmd, ...args] = argv;
|
|
637
|
+
if (OPS.includes(cmd)) {
|
|
638
|
+
let raw = '';
|
|
639
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
640
|
+
let input = {};
|
|
641
|
+
try {
|
|
642
|
+
input = raw.trim() ? JSON.parse(raw) : {};
|
|
643
|
+
} catch {
|
|
644
|
+
input = {};
|
|
645
|
+
}
|
|
646
|
+
process.stdout.write(JSON.stringify(await provide(cmd, input)) + '\n');
|
|
647
|
+
return 0;
|
|
648
|
+
}
|
|
649
|
+
if (cmd === 'signin') return signin().then(() => 0);
|
|
650
|
+
if (cmd === 'signout') {
|
|
651
|
+
await hub('DELETE', '/v1/auth').catch(() => {});
|
|
652
|
+
try {
|
|
653
|
+
unlinkSync(join(HOME, 'credentials.json'));
|
|
654
|
+
} catch {}
|
|
655
|
+
console.log('Signed out of code-kit hub on this machine.');
|
|
656
|
+
return 0;
|
|
657
|
+
}
|
|
658
|
+
if (cmd === 'status') {
|
|
659
|
+
const cred = credentials();
|
|
660
|
+
console.log(
|
|
661
|
+
cred?.token ? `Signed in to ${cred.url} as ${cred.login}.` : `Not signed in: run ${SIGN_IN}.`,
|
|
662
|
+
);
|
|
663
|
+
const repo = repoOf(originOf(process.cwd()));
|
|
664
|
+
if (repo && cred?.token) {
|
|
665
|
+
const live = readJson(join(stateDir(process.cwd()), 'hub-stream.json'), {});
|
|
666
|
+
console.log(`${repo}: ${live.connected ? 'live' : (live.why ?? 'not connected')}`);
|
|
667
|
+
}
|
|
668
|
+
return 0;
|
|
669
|
+
}
|
|
670
|
+
if (cmd === 'connect') return connect(process.cwd()).then(() => 0);
|
|
671
|
+
if (cmd === 'drift-model') {
|
|
672
|
+
const choice = args[0];
|
|
673
|
+
let key = '';
|
|
674
|
+
if (choice === 'own') for await (const chunk of process.stdin) key += chunk;
|
|
675
|
+
const repo = repoOf(originOf(process.cwd()));
|
|
676
|
+
const { json } = await hub('PUT', `/v1/repos/${repo}/drift-model`, {
|
|
677
|
+
model: choice,
|
|
678
|
+
...(key.trim() ? { key: key.trim() } : {}),
|
|
679
|
+
});
|
|
680
|
+
console.log(
|
|
681
|
+
json.ok
|
|
682
|
+
? `Drift checks for ${repo} now use ${choice === 'own' ? "your team's Anthropic key" : "the hub's model"}.`
|
|
683
|
+
: json.why,
|
|
684
|
+
);
|
|
685
|
+
return json.ok ? 0 : 1;
|
|
686
|
+
}
|
|
687
|
+
if (cmd === 'daemon') {
|
|
688
|
+
await daemon({
|
|
689
|
+
root: flag(args, 'root') ?? process.cwd(),
|
|
690
|
+
session: flag(args, 'session') ?? 'cli',
|
|
691
|
+
watch: Number(flag(args, 'watch')) || null,
|
|
692
|
+
});
|
|
693
|
+
return 0;
|
|
694
|
+
}
|
|
695
|
+
console.error('code-kit team provider signin | signout | status | connect | drift-model hub|own');
|
|
696
|
+
return 2;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Run as a command: code-kit's provider command, its daemon, or `code-kit team provider …`.
|
|
700
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
701
|
+
main(process.argv.slice(2)).then(
|
|
702
|
+
(code) => process.exit(code),
|
|
703
|
+
(err) => {
|
|
704
|
+
console.error(err.message);
|
|
705
|
+
process.exit(1);
|
|
706
|
+
},
|
|
707
|
+
);
|
|
708
|
+
}
|