@shiyuphan/follow-me 0.1.0 → 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/README.md +128 -56
- package/dist/src/anthropic.js +41 -9
- package/dist/src/cli.js +836 -82
- package/dist/src/comments.js +300 -0
- package/dist/src/git.js +103 -24
- package/dist/src/harness.js +74 -0
- package/dist/src/host.js +379 -0
- package/dist/src/install.js +131 -0
- package/dist/src/naive.js +26 -0
- package/dist/src/plan.js +281 -25
- package/dist/src/prompt.js +391 -82
- package/dist/src/server.js +710 -160
- package/dist/src/session.js +311 -104
- package/dist/src/syntax.js +199 -0
- package/dist/src/types.js +5 -2
- package/dist/ui/app.js +1703 -272
- package/dist/ui/concerns.js +165 -0
- package/dist/ui/highlight.js +221 -0
- package/dist/ui/index.html +62 -7
- package/dist/ui/live.js +184 -0
- package/dist/ui/playback.js +480 -76
- package/dist/ui/questions.js +189 -0
- package/dist/ui/style.css +975 -75
- package/dist/ui/summary.js +45 -0
- package/package.json +4 -2
- package/skills/follow-me/SKILL.md +34 -16
package/dist/src/host.js
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { closeSync, linkSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { parseEvent, parseEventBatch } from "./comments.js";
|
|
7
|
+
import { isPlanVersion, serializePlanFile, validatePlan } from "./plan.js";
|
|
8
|
+
import { HEALTH_APP, isSessionId, loadDraftComments, PortInUseError, RegistrationError, startServer, } from "./server.js";
|
|
9
|
+
import { BranchMovedError, isAlive, openSessionAt, SessionFileError, SessionLockedError, } from "./session.js";
|
|
10
|
+
const RECORD = "server.json";
|
|
11
|
+
const DEFAULT_IDLE_MS = 30_000;
|
|
12
|
+
const START_TIMEOUT_MS = 10_000;
|
|
13
|
+
const STOP_TIMEOUT_MS = 5_000;
|
|
14
|
+
/** `FOLLOW_ME_HOME`, else `$XDG_STATE_HOME/follow-me`, else `~/.local/state/follow-me`. */
|
|
15
|
+
export function stateDir(env = process.env, home = homedir()) {
|
|
16
|
+
const own = env["FOLLOW_ME_HOME"];
|
|
17
|
+
if (own)
|
|
18
|
+
return own;
|
|
19
|
+
const xdg = env["XDG_STATE_HOME"];
|
|
20
|
+
return join(xdg ? xdg : join(home, ".local", "state"), "follow-me");
|
|
21
|
+
}
|
|
22
|
+
/** How long a server waits for a new session after its last one ended. */
|
|
23
|
+
export function idleMs(env = process.env) {
|
|
24
|
+
const raw = Number(env["FOLLOW_ME_IDLE_MS"]);
|
|
25
|
+
return Number.isInteger(raw) && raw >= 0 ? raw : DEFAULT_IDLE_MS;
|
|
26
|
+
}
|
|
27
|
+
function isPort(value) {
|
|
28
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535;
|
|
29
|
+
}
|
|
30
|
+
function isFields(value) {
|
|
31
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
32
|
+
}
|
|
33
|
+
/** The record as written; a missing or unreadable one reads as no server. */
|
|
34
|
+
export function readServerRecord(dir) {
|
|
35
|
+
let raw;
|
|
36
|
+
try {
|
|
37
|
+
raw = JSON.parse(readFileSync(join(dir, RECORD), "utf8"));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (!isFields(raw))
|
|
43
|
+
return null;
|
|
44
|
+
const { pid, port, startedAt } = raw;
|
|
45
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
|
|
46
|
+
return null;
|
|
47
|
+
if (!isPort(port) || typeof startedAt !== "string")
|
|
48
|
+
return null;
|
|
49
|
+
return { pid, port, startedAt };
|
|
50
|
+
}
|
|
51
|
+
/** The pid of the follow-me server answering on this port, or null for anything else. */
|
|
52
|
+
export async function probeHealth(port) {
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/health`, {
|
|
55
|
+
signal: AbortSignal.timeout(1_000),
|
|
56
|
+
});
|
|
57
|
+
const body = await res.json();
|
|
58
|
+
if (!isFields(body) || body["app"] !== HEALTH_APP)
|
|
59
|
+
return null;
|
|
60
|
+
const pid = body["pid"];
|
|
61
|
+
return typeof pid === "number" && Number.isInteger(pid) ? pid : null;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** The user's server if its record names a live process that answers as ours. */
|
|
68
|
+
export async function findServer(dir) {
|
|
69
|
+
const record = readServerRecord(dir);
|
|
70
|
+
if (record === null || !isAlive(record.pid))
|
|
71
|
+
return null;
|
|
72
|
+
return (await probeHealth(record.port)) === record.pid ? record : null;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Writes server.json unless a live server already owns it, and returns the record that holds.
|
|
76
|
+
* Linking a complete temp file fails when the record exists, so two servers starting at once
|
|
77
|
+
* cannot both claim it; a record left by a dead server is replaced.
|
|
78
|
+
*/
|
|
79
|
+
async function claimRecord(dir, mine) {
|
|
80
|
+
mkdirSync(dir, { recursive: true });
|
|
81
|
+
const path = join(dir, RECORD);
|
|
82
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
83
|
+
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
84
|
+
writeFileSync(temp, `${JSON.stringify(mine, null, 2)}\n`);
|
|
85
|
+
try {
|
|
86
|
+
linkSync(temp, path);
|
|
87
|
+
return mine;
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
rmSync(temp, { force: true });
|
|
95
|
+
}
|
|
96
|
+
const live = await findServer(dir);
|
|
97
|
+
if (live !== null)
|
|
98
|
+
return live;
|
|
99
|
+
rmSync(path, { force: true });
|
|
100
|
+
}
|
|
101
|
+
throw new Error(`could not claim ${path}`);
|
|
102
|
+
}
|
|
103
|
+
function removeRecord(dir, pid) {
|
|
104
|
+
if (readServerRecord(dir)?.pid === pid)
|
|
105
|
+
rmSync(join(dir, RECORD), { force: true });
|
|
106
|
+
}
|
|
107
|
+
async function loadHosted(registration, held) {
|
|
108
|
+
const { dir, mode, version, hash, lockToken } = registration;
|
|
109
|
+
try {
|
|
110
|
+
const session = await openSessionAt(dir);
|
|
111
|
+
const branch = session.anchor.branch;
|
|
112
|
+
const stored = session.plans().find((p) => p.version === version);
|
|
113
|
+
if (stored === undefined || stored.hash !== hash) {
|
|
114
|
+
throw new RegistrationError(`${branch} has no plan v${version} with hash ${hash}`);
|
|
115
|
+
}
|
|
116
|
+
// Each mode has its own slot, so a walk can follow the author's review of the same plan.
|
|
117
|
+
const reviewed = mode === "walk" ? session.walkReviews() : session.reviews();
|
|
118
|
+
if (reviewed.some((r) => r.version === version)) {
|
|
119
|
+
const what = mode === "walk" ? "code review" : "review";
|
|
120
|
+
throw new RegistrationError(`plan v${version} of ${branch} already has a ${what}`);
|
|
121
|
+
}
|
|
122
|
+
const verdict = validatePlan(session.diff, serializePlanFile(stored.file), {
|
|
123
|
+
lastReview: session.lastReview(),
|
|
124
|
+
});
|
|
125
|
+
if (!verdict.ok) {
|
|
126
|
+
throw new RegistrationError(`plan v${version} no longer fits ${branch}; check it again`);
|
|
127
|
+
}
|
|
128
|
+
const reviewsDir = session.reviewsDir(mode);
|
|
129
|
+
const comments = await loadDraftComments(reviewsDir, version);
|
|
130
|
+
const prior = session.priorReview(mode, version);
|
|
131
|
+
const previous = prior === null || prior.comments.length === 0
|
|
132
|
+
? null
|
|
133
|
+
: {
|
|
134
|
+
version: prior.planVersion,
|
|
135
|
+
comments: prior.comments,
|
|
136
|
+
responses: stored.file.responses,
|
|
137
|
+
};
|
|
138
|
+
const command = mode === "walk" ? "walk" : "review";
|
|
139
|
+
// A version swap runs inside the server that already owns this session's lock.
|
|
140
|
+
const release = held ??
|
|
141
|
+
(lockToken === null ? session.lock(command) : session.adoptLock(lockToken, command)).release;
|
|
142
|
+
return {
|
|
143
|
+
repo: session.root,
|
|
144
|
+
reviewsDir,
|
|
145
|
+
release,
|
|
146
|
+
previous,
|
|
147
|
+
state: {
|
|
148
|
+
mode,
|
|
149
|
+
anchor: session.anchor,
|
|
150
|
+
version,
|
|
151
|
+
planHash: verdict.plan.hash,
|
|
152
|
+
commits: verdict.plan.commits,
|
|
153
|
+
units: [...session.diff.units.values()],
|
|
154
|
+
comments,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
const refusal = error instanceof SessionLockedError ||
|
|
160
|
+
error instanceof BranchMovedError ||
|
|
161
|
+
error instanceof SessionFileError;
|
|
162
|
+
if (refusal)
|
|
163
|
+
throw new RegistrationError(error.message);
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export function portFallbackMessage(requested, used) {
|
|
168
|
+
return `port ${requested} is in use by another program, using ${used}`;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Starts this process as the user's server, or finds that one is already up. A requested port
|
|
172
|
+
* held by our own server means attach; held by anything else, the server takes a free port.
|
|
173
|
+
*/
|
|
174
|
+
export async function startOrAttach(dir, port, say) {
|
|
175
|
+
const requested = port ?? 0;
|
|
176
|
+
const options = { idleMs: idleMs(), load: loadHosted };
|
|
177
|
+
let host;
|
|
178
|
+
try {
|
|
179
|
+
host = await startServer({ ...options, port: requested });
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
if (!(error instanceof PortInUseError))
|
|
183
|
+
throw error;
|
|
184
|
+
if ((await probeHealth(requested)) !== null)
|
|
185
|
+
return { kind: "attached", port: requested };
|
|
186
|
+
host = await startServer({ ...options, port: 0 });
|
|
187
|
+
say(portFallbackMessage(requested, host.port));
|
|
188
|
+
}
|
|
189
|
+
const mine = { pid: process.pid, port: host.port, startedAt: new Date().toISOString() };
|
|
190
|
+
const owner = await claimRecord(dir, mine);
|
|
191
|
+
if (owner.pid !== process.pid) {
|
|
192
|
+
await host.close();
|
|
193
|
+
return { kind: "attached", port: owner.port };
|
|
194
|
+
}
|
|
195
|
+
const stop = () => void host.close();
|
|
196
|
+
process.once("SIGINT", stop);
|
|
197
|
+
process.once("SIGTERM", stop);
|
|
198
|
+
void host.closed.then(() => {
|
|
199
|
+
process.off("SIGINT", stop);
|
|
200
|
+
process.off("SIGTERM", stop);
|
|
201
|
+
removeRecord(dir, process.pid);
|
|
202
|
+
});
|
|
203
|
+
return { kind: "started", host };
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Starts the server as a detached `follow-me server run` and waits until it answers. Its output
|
|
207
|
+
* goes to server.log in the state dir.
|
|
208
|
+
*/
|
|
209
|
+
export async function spawnDetached(dir, port) {
|
|
210
|
+
mkdirSync(dir, { recursive: true });
|
|
211
|
+
const logPath = join(dir, "server.log");
|
|
212
|
+
const log = openSync(logPath, "a");
|
|
213
|
+
const script = process.argv[1];
|
|
214
|
+
if (script === undefined)
|
|
215
|
+
throw new Error("cannot tell which script runs follow-me");
|
|
216
|
+
const args = [...process.execArgv, script, "server", "run"];
|
|
217
|
+
if (port !== undefined)
|
|
218
|
+
args.push("--port", String(port));
|
|
219
|
+
const child = spawn(process.execPath, args, {
|
|
220
|
+
detached: true,
|
|
221
|
+
stdio: ["ignore", log, log],
|
|
222
|
+
env: process.env,
|
|
223
|
+
});
|
|
224
|
+
closeSync(log);
|
|
225
|
+
child.unref();
|
|
226
|
+
const deadline = Date.now() + START_TIMEOUT_MS;
|
|
227
|
+
while (Date.now() < deadline) {
|
|
228
|
+
const live = await findServer(dir);
|
|
229
|
+
if (live !== null)
|
|
230
|
+
return live;
|
|
231
|
+
if (child.exitCode !== null)
|
|
232
|
+
break;
|
|
233
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
234
|
+
}
|
|
235
|
+
throw new Error(`the review server did not start; see ${logPath}`);
|
|
236
|
+
}
|
|
237
|
+
async function call(port, method, path, body) {
|
|
238
|
+
const res = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
239
|
+
method,
|
|
240
|
+
headers: { "content-type": "application/json" },
|
|
241
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
242
|
+
});
|
|
243
|
+
const payload = await res.json();
|
|
244
|
+
if (!res.ok) {
|
|
245
|
+
const reason = isFields(payload) ? payload["error"] : undefined;
|
|
246
|
+
throw new RegistrationError(typeof reason === "string" ? reason : `${method} ${path} failed with ${res.status}`);
|
|
247
|
+
}
|
|
248
|
+
return payload;
|
|
249
|
+
}
|
|
250
|
+
function parseSummary(raw) {
|
|
251
|
+
if (!isFields(raw))
|
|
252
|
+
return null;
|
|
253
|
+
const { id, url, repo, branch, mode, version, comments } = raw;
|
|
254
|
+
if (typeof url !== "string" || typeof repo !== "string")
|
|
255
|
+
return null;
|
|
256
|
+
if (typeof branch !== "string" || (mode !== "split" && mode !== "walk"))
|
|
257
|
+
return null;
|
|
258
|
+
if (!isSessionId(id) || !isPlanVersion(version) || typeof comments !== "number")
|
|
259
|
+
return null;
|
|
260
|
+
return { id, url, repo, branch, mode, version, comments };
|
|
261
|
+
}
|
|
262
|
+
/** Registers a session with the server on this port; the same session yields the same URL. */
|
|
263
|
+
export async function registerWith(port, registration) {
|
|
264
|
+
const summary = parseSummary(await call(port, "POST", "/api/sessions", registration));
|
|
265
|
+
if (summary === null)
|
|
266
|
+
throw new RegistrationError("the server answered with a malformed session");
|
|
267
|
+
return summary;
|
|
268
|
+
}
|
|
269
|
+
/** Moves the hosted session to a newer frozen plan; its id and URL stay. */
|
|
270
|
+
export async function swapVersion(port, summary, version, hash) {
|
|
271
|
+
const swapped = parseSummary(await call(port, "POST", `${summary.url}api/version`, { version, hash }));
|
|
272
|
+
if (swapped === null)
|
|
273
|
+
throw new RegistrationError("the server answered with a malformed session");
|
|
274
|
+
return swapped;
|
|
275
|
+
}
|
|
276
|
+
/** One long poll of the session's event log: what happened after `since`, or nothing at timeout. */
|
|
277
|
+
export async function pollEvents(port, summary, since, timeoutMs) {
|
|
278
|
+
const path = `${summary.url}api/events?since=${since}&timeout=${timeoutMs}`;
|
|
279
|
+
const batch = parseEventBatch(await call(port, "GET", path));
|
|
280
|
+
if (typeof batch === "string")
|
|
281
|
+
throw new RegistrationError(`the server sent bad events: ${batch}`);
|
|
282
|
+
return batch;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Follows the session's event stream from `since`, calling `onEvent` for each event. Resolves when
|
|
286
|
+
* the server closes the stream, which it does after the `ended` event.
|
|
287
|
+
*/
|
|
288
|
+
export async function followEvents(port, summary, since, onEvent) {
|
|
289
|
+
const res = await fetch(`${sessionUrl(port, summary)}api/events/stream?since=${since}`);
|
|
290
|
+
if (!res.ok || res.body === null) {
|
|
291
|
+
throw new RegistrationError(`GET api/events/stream failed with ${res.status}`);
|
|
292
|
+
}
|
|
293
|
+
const decoder = new TextDecoder();
|
|
294
|
+
const gap = "\n\n";
|
|
295
|
+
let buffer = "";
|
|
296
|
+
for await (const chunk of res.body) {
|
|
297
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
298
|
+
for (let end = buffer.indexOf(gap); end !== -1; end = buffer.indexOf(gap)) {
|
|
299
|
+
const frame = buffer.slice(0, end);
|
|
300
|
+
buffer = buffer.slice(end + 2);
|
|
301
|
+
const data = frame.split("\n").find((l) => l.startsWith("data: "));
|
|
302
|
+
if (data === undefined)
|
|
303
|
+
continue;
|
|
304
|
+
const event = parseEvent(JSON.parse(data.slice(6)));
|
|
305
|
+
if (typeof event === "string")
|
|
306
|
+
throw new RegistrationError(`bad event on the stream: ${event}`);
|
|
307
|
+
onEvent(event);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/** Posts the agent's reply under a comment and returns the new reply's id. */
|
|
312
|
+
export async function postReply(port, summary, commentId, reply) {
|
|
313
|
+
const path = `${summary.url}api/comments/${encodeURIComponent(commentId)}/replies`;
|
|
314
|
+
const body = await call(port, "POST", path, reply);
|
|
315
|
+
const id = isFields(body) ? body["id"] : undefined;
|
|
316
|
+
if (typeof id !== "string")
|
|
317
|
+
throw new RegistrationError("the server answered without a reply id");
|
|
318
|
+
return id;
|
|
319
|
+
}
|
|
320
|
+
/** The consolidated report of the hosted session as it stands. */
|
|
321
|
+
export async function fetchReport(port, summary) {
|
|
322
|
+
const res = await fetch(`${sessionUrl(port, summary)}api/report`);
|
|
323
|
+
if (!res.ok)
|
|
324
|
+
throw new RegistrationError(`GET api/report failed with ${res.status}`);
|
|
325
|
+
return res.text();
|
|
326
|
+
}
|
|
327
|
+
export async function listSessions(port) {
|
|
328
|
+
const raw = await call(port, "GET", "/api/sessions");
|
|
329
|
+
if (!Array.isArray(raw))
|
|
330
|
+
throw new RegistrationError("the server listed malformed sessions");
|
|
331
|
+
return raw.flatMap((item) => {
|
|
332
|
+
const summary = parseSummary(item);
|
|
333
|
+
return summary === null ? [] : [summary];
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
export function sessionUrl(port, summary) {
|
|
337
|
+
return `http://127.0.0.1:${port}${summary.url}`;
|
|
338
|
+
}
|
|
339
|
+
/** Whether the server on this port still serves the session's state; a gone server serves none. */
|
|
340
|
+
export async function stillHosted(port, summary) {
|
|
341
|
+
try {
|
|
342
|
+
const res = await fetch(`${sessionUrl(port, summary)}api/state`, {
|
|
343
|
+
signal: AbortSignal.timeout(2_000),
|
|
344
|
+
});
|
|
345
|
+
await res.body?.cancel();
|
|
346
|
+
return res.ok;
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Stops the recorded server and waits for it to exit. Only a pid that answers as our server on
|
|
354
|
+
* the recorded port is signalled, so a reused pid is never killed. A stale record is removed.
|
|
355
|
+
*/
|
|
356
|
+
export async function stopServer(dir) {
|
|
357
|
+
const live = await findServer(dir);
|
|
358
|
+
if (live === null) {
|
|
359
|
+
const record = readServerRecord(dir);
|
|
360
|
+
if (record !== null)
|
|
361
|
+
removeRecord(dir, record.pid);
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
process.kill(live.pid, "SIGTERM");
|
|
365
|
+
const deadline = Date.now() + STOP_TIMEOUT_MS;
|
|
366
|
+
while (isAlive(live.pid)) {
|
|
367
|
+
if (Date.now() > deadline)
|
|
368
|
+
throw new Error(`server pid ${live.pid} did not stop`);
|
|
369
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
370
|
+
}
|
|
371
|
+
removeRecord(dir, live.pid);
|
|
372
|
+
return live;
|
|
373
|
+
}
|
|
374
|
+
export function openBrowser(url) {
|
|
375
|
+
const command = process.platform === "darwin" ? "open" : "xdg-open";
|
|
376
|
+
const child = spawn(command, [url], { stdio: "ignore", detached: true });
|
|
377
|
+
child.on("error", (error) => console.error(`could not open a browser: ${error.message}`));
|
|
378
|
+
child.unref();
|
|
379
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { findHarness, HARNESSES, plannedInstall, SKILL_NAME, } from "./harness.js";
|
|
5
|
+
class InstallError extends Error {
|
|
6
|
+
name = "InstallError";
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The skill folder ships next to `dist/` in the package and next to `src/` in a checkout, so
|
|
10
|
+
* walk up from this module until a folder holds it.
|
|
11
|
+
*/
|
|
12
|
+
export function findSkillDir(from = import.meta.url) {
|
|
13
|
+
let dir = dirname(fileURLToPath(from));
|
|
14
|
+
for (;;) {
|
|
15
|
+
const candidate = join(dir, "skills", SKILL_NAME);
|
|
16
|
+
if (existsSync(join(candidate, "SKILL.md")))
|
|
17
|
+
return candidate;
|
|
18
|
+
const parent = dirname(dir);
|
|
19
|
+
if (parent === dir)
|
|
20
|
+
throw new InstallError(`cannot find skills/${SKILL_NAME} next to the package`);
|
|
21
|
+
dir = parent;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function detectHarnesses(home) {
|
|
25
|
+
return HARNESSES.filter((h) => h.probes.some((probe) => existsSync(join(home, probe))));
|
|
26
|
+
}
|
|
27
|
+
function chooseHarnesses(options, home) {
|
|
28
|
+
if (options.all && options.harness !== undefined) {
|
|
29
|
+
throw new InstallError("use --all or --harness, not both");
|
|
30
|
+
}
|
|
31
|
+
if (options.all)
|
|
32
|
+
return HARNESSES;
|
|
33
|
+
if (options.harness !== undefined) {
|
|
34
|
+
return options.harness.split(",").map((id) => {
|
|
35
|
+
const found = findHarness(id.trim());
|
|
36
|
+
if (!found) {
|
|
37
|
+
const known = HARNESSES.map((h) => h.id).join(", ");
|
|
38
|
+
throw new InstallError(`unknown harness "${id}" (known: ${known})`);
|
|
39
|
+
}
|
|
40
|
+
return found;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const found = detectHarnesses(home);
|
|
44
|
+
if (found.length === 0) {
|
|
45
|
+
throw new InstallError("no harness config directory found; pass --harness <id> or --all (see --list)");
|
|
46
|
+
}
|
|
47
|
+
return found;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The files install writes and uninstall removes. Harnesses that share a skills directory (Codex
|
|
51
|
+
* and the generic one) plan the same file once.
|
|
52
|
+
*/
|
|
53
|
+
export function planSkillFiles(options, home, repoRoot) {
|
|
54
|
+
const scope = options.project ? "project" : "user";
|
|
55
|
+
const byPath = new Map();
|
|
56
|
+
for (const harness of chooseHarnesses(options, home)) {
|
|
57
|
+
for (const file of plannedInstall(harness, scope, home, repoRoot)) {
|
|
58
|
+
byPath.set(file.path, file);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return [...byPath.values()];
|
|
62
|
+
}
|
|
63
|
+
function writeSkill(skillDir, file, dryRun) {
|
|
64
|
+
const content = readFileSync(join(skillDir, file.source));
|
|
65
|
+
const current = existsSync(file.path) ? readFileSync(file.path) : null;
|
|
66
|
+
if (current?.equals(content))
|
|
67
|
+
return `unchanged ${file.path}`;
|
|
68
|
+
if (dryRun)
|
|
69
|
+
return `would write ${file.path}`;
|
|
70
|
+
mkdirSync(dirname(file.path), { recursive: true });
|
|
71
|
+
writeFileSync(file.path, content);
|
|
72
|
+
return `wrote ${file.path}`;
|
|
73
|
+
}
|
|
74
|
+
/** Removes the file and then its follow-me folder if that is left empty, never a parent. */
|
|
75
|
+
function removeSkill(file, dryRun) {
|
|
76
|
+
if (!existsSync(file.path))
|
|
77
|
+
return `absent ${file.path}`;
|
|
78
|
+
if (dryRun)
|
|
79
|
+
return `would remove ${file.path}`;
|
|
80
|
+
rmSync(file.path);
|
|
81
|
+
const folder = dirname(file.path);
|
|
82
|
+
if (basename(folder) === SKILL_NAME) {
|
|
83
|
+
try {
|
|
84
|
+
rmdirSync(folder);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOTEMPTY"))
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return `removed ${file.path}`;
|
|
92
|
+
}
|
|
93
|
+
function listTable() {
|
|
94
|
+
return HARNESSES.flatMap((h) => [
|
|
95
|
+
`${h.id} ${h.name}`,
|
|
96
|
+
` project ${h.projectDir}/${SKILL_NAME}/SKILL.md`,
|
|
97
|
+
` user ~/${h.userDir}/${SKILL_NAME}/SKILL.md`,
|
|
98
|
+
` docs ${h.docs}`,
|
|
99
|
+
]);
|
|
100
|
+
}
|
|
101
|
+
function reportingErrors(run) {
|
|
102
|
+
try {
|
|
103
|
+
run();
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (!(error instanceof InstallError))
|
|
108
|
+
throw error;
|
|
109
|
+
process.stderr.write(`follow-me: ${error.message}\n`);
|
|
110
|
+
return 1;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export function runInstall(options, home, repoRoot, say, skillDir = findSkillDir()) {
|
|
114
|
+
return reportingErrors(() => {
|
|
115
|
+
if (options.list) {
|
|
116
|
+
for (const line of listTable())
|
|
117
|
+
say(line);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
for (const file of planSkillFiles(options, home, repoRoot)) {
|
|
121
|
+
say(writeSkill(skillDir, file, options.dryRun));
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
export function runUninstall(options, home, repoRoot, say) {
|
|
126
|
+
return reportingErrors(() => {
|
|
127
|
+
for (const file of planSkillFiles(options, home, repoRoot)) {
|
|
128
|
+
say(removeSkill(file, options.dryRun));
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { PLAN_SCHEMA } from "./plan.js";
|
|
2
|
+
/**
|
|
3
|
+
* The plan that needs no planner: one commit per touched file in diff order, holding every unit
|
|
4
|
+
* of that file. It tells no story, but it runs the whole pipeline on a real branch. Returns the
|
|
5
|
+
* draft as the wire format, for validatePlan to judge like any other draft.
|
|
6
|
+
*/
|
|
7
|
+
export function naiveDraft(diff) {
|
|
8
|
+
const byPath = new Map();
|
|
9
|
+
for (const unit of diff.units.values()) {
|
|
10
|
+
byPath.set(unit.path, [...(byPath.get(unit.path) ?? []), unit]);
|
|
11
|
+
}
|
|
12
|
+
const commits = [...byPath].map(([path, units]) => ({
|
|
13
|
+
message: `wip: ${path}${suffix(units)}`,
|
|
14
|
+
units: units.map((u) => u.id),
|
|
15
|
+
}));
|
|
16
|
+
return { schema: PLAN_SCHEMA, base: diff.base, tip: diff.tip, commits, responses: [] };
|
|
17
|
+
}
|
|
18
|
+
/** Names what a file with no text hunk is, so `wip: logo.bin (binary)` says why it has no lines. */
|
|
19
|
+
function suffix(units) {
|
|
20
|
+
if (units.some((u) => u.kind === "hunk"))
|
|
21
|
+
return "";
|
|
22
|
+
const first = units[0];
|
|
23
|
+
if (first === undefined)
|
|
24
|
+
return "";
|
|
25
|
+
return first.kind === "mode" ? " (mode)" : first.kind === "file" ? ` (${first.reason})` : "";
|
|
26
|
+
}
|