@shiyuphan/follow-me 0.1.0 → 0.1.1

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.
@@ -0,0 +1,74 @@
1
+ import { join } from "node:path";
2
+ export const SKILL_NAME = "follow-me";
3
+ export const HARNESSES = [
4
+ {
5
+ id: "claude",
6
+ name: "Claude Code",
7
+ projectDir: ".claude/skills",
8
+ userDir: ".claude/skills",
9
+ format: "skill",
10
+ probes: [".claude"],
11
+ docs: "https://code.claude.com/docs/en/skills",
12
+ },
13
+ {
14
+ id: "opencode",
15
+ name: "OpenCode",
16
+ projectDir: ".opencode/skills",
17
+ userDir: ".config/opencode/skills",
18
+ format: "skill",
19
+ probes: [".config/opencode", ".opencode"],
20
+ docs: "https://opencode.ai/docs/skills/",
21
+ },
22
+ {
23
+ id: "codex",
24
+ name: "Codex CLI",
25
+ projectDir: ".agents/skills",
26
+ userDir: ".agents/skills",
27
+ format: "skill",
28
+ probes: [".codex"],
29
+ docs: "https://developers.openai.com/codex/skills",
30
+ },
31
+ {
32
+ id: "cursor",
33
+ name: "Cursor",
34
+ projectDir: ".cursor/skills",
35
+ userDir: ".cursor/skills",
36
+ format: "skill",
37
+ probes: [".cursor"],
38
+ docs: "https://cursor.com/docs/context/skills",
39
+ },
40
+ {
41
+ id: "gemini",
42
+ name: "Gemini CLI",
43
+ projectDir: ".gemini/skills",
44
+ userDir: ".gemini/skills",
45
+ format: "skill",
46
+ probes: [".gemini"],
47
+ docs: "https://geminicli.com/docs/cli/skills/",
48
+ },
49
+ {
50
+ id: "copilot",
51
+ name: "GitHub Copilot CLI",
52
+ projectDir: ".github/skills",
53
+ userDir: ".copilot/skills",
54
+ format: "skill",
55
+ probes: [".copilot"],
56
+ docs: "https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-skills",
57
+ },
58
+ {
59
+ id: "agents",
60
+ name: "Any Agent Skills client",
61
+ projectDir: ".agents/skills",
62
+ userDir: ".agents/skills",
63
+ format: "skill",
64
+ probes: [],
65
+ docs: "https://agentskills.io/client-implementation/adding-skills-support",
66
+ },
67
+ ];
68
+ export function findHarness(id) {
69
+ return HARNESSES.find((h) => h.id === id);
70
+ }
71
+ export function plannedInstall(harness, scope, home, repoRoot) {
72
+ const root = scope === "user" ? join(home, harness.userDir) : join(repoRoot, harness.projectDir);
73
+ return [{ path: join(root, SKILL_NAME, "SKILL.md"), source: "SKILL.md" }];
74
+ }
@@ -0,0 +1,293 @@
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 { isPlanVersion, serializePlanFile, validatePlan } from "./plan.js";
7
+ import { HEALTH_APP, isSessionId, loadDraftComments, PortInUseError, RegistrationError, startServer, } from "./server.js";
8
+ import { BranchMovedError, isAlive, openSessionAt, SessionFileError, SessionLockedError, } from "./session.js";
9
+ const RECORD = "server.json";
10
+ const DEFAULT_IDLE_MS = 30_000;
11
+ const START_TIMEOUT_MS = 10_000;
12
+ const STOP_TIMEOUT_MS = 5_000;
13
+ /** `FOLLOW_ME_HOME`, else `$XDG_STATE_HOME/follow-me`, else `~/.local/state/follow-me`. */
14
+ export function stateDir(env = process.env, home = homedir()) {
15
+ const own = env["FOLLOW_ME_HOME"];
16
+ if (own)
17
+ return own;
18
+ const xdg = env["XDG_STATE_HOME"];
19
+ return join(xdg ? xdg : join(home, ".local", "state"), "follow-me");
20
+ }
21
+ /** How long a server waits for a new session after its last one ended. */
22
+ export function idleMs(env = process.env) {
23
+ const raw = Number(env["FOLLOW_ME_IDLE_MS"]);
24
+ return Number.isInteger(raw) && raw >= 0 ? raw : DEFAULT_IDLE_MS;
25
+ }
26
+ function isPort(value) {
27
+ return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535;
28
+ }
29
+ function isFields(value) {
30
+ return typeof value === "object" && value !== null && !Array.isArray(value);
31
+ }
32
+ /** The record as written; a missing or unreadable one reads as no server. */
33
+ export function readServerRecord(dir) {
34
+ let raw;
35
+ try {
36
+ raw = JSON.parse(readFileSync(join(dir, RECORD), "utf8"));
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ if (!isFields(raw))
42
+ return null;
43
+ const { pid, port, startedAt } = raw;
44
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
45
+ return null;
46
+ if (!isPort(port) || typeof startedAt !== "string")
47
+ return null;
48
+ return { pid, port, startedAt };
49
+ }
50
+ /** The pid of the follow-me server answering on this port, or null for anything else. */
51
+ export async function probeHealth(port) {
52
+ try {
53
+ const res = await fetch(`http://127.0.0.1:${port}/api/health`, {
54
+ signal: AbortSignal.timeout(1_000),
55
+ });
56
+ const body = await res.json();
57
+ if (!isFields(body) || body["app"] !== HEALTH_APP)
58
+ return null;
59
+ const pid = body["pid"];
60
+ return typeof pid === "number" && Number.isInteger(pid) ? pid : null;
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ /** The user's server if its record names a live process that answers as ours. */
67
+ export async function findServer(dir) {
68
+ const record = readServerRecord(dir);
69
+ if (record === null || !isAlive(record.pid))
70
+ return null;
71
+ return (await probeHealth(record.port)) === record.pid ? record : null;
72
+ }
73
+ /**
74
+ * Writes server.json unless a live server already owns it, and returns the record that holds.
75
+ * Linking a complete temp file fails when the record exists, so two servers starting at once
76
+ * cannot both claim it; a record left by a dead server is replaced.
77
+ */
78
+ async function claimRecord(dir, mine) {
79
+ mkdirSync(dir, { recursive: true });
80
+ const path = join(dir, RECORD);
81
+ for (let attempt = 0; attempt < 3; attempt++) {
82
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
83
+ writeFileSync(temp, `${JSON.stringify(mine, null, 2)}\n`);
84
+ try {
85
+ linkSync(temp, path);
86
+ return mine;
87
+ }
88
+ catch (error) {
89
+ if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
90
+ throw error;
91
+ }
92
+ finally {
93
+ rmSync(temp, { force: true });
94
+ }
95
+ const live = await findServer(dir);
96
+ if (live !== null)
97
+ return live;
98
+ rmSync(path, { force: true });
99
+ }
100
+ throw new Error(`could not claim ${path}`);
101
+ }
102
+ function removeRecord(dir, pid) {
103
+ if (readServerRecord(dir)?.pid === pid)
104
+ rmSync(join(dir, RECORD), { force: true });
105
+ }
106
+ async function loadHosted(registration) {
107
+ const { dir, mode, version, hash, lockToken } = registration;
108
+ try {
109
+ const session = await openSessionAt(dir);
110
+ const branch = session.anchor.branch;
111
+ const stored = session.plans().find((p) => p.version === version);
112
+ if (stored === undefined || stored.hash !== hash) {
113
+ throw new RegistrationError(`${branch} has no plan v${version} with hash ${hash}`);
114
+ }
115
+ if (session.reviews().some((r) => r.version === version)) {
116
+ throw new RegistrationError(`plan v${version} of ${branch} already has a review`);
117
+ }
118
+ const verdict = validatePlan(session.diff, serializePlanFile(stored.file), {
119
+ lastReview: session.lastReview(),
120
+ });
121
+ if (!verdict.ok) {
122
+ throw new RegistrationError(`plan v${version} no longer fits ${branch}; check it again`);
123
+ }
124
+ const reviewsDir = join(session.dir, "reviews");
125
+ const comments = await loadDraftComments(reviewsDir, version);
126
+ const command = mode === "walk" ? "walk" : "review";
127
+ const lock = lockToken === null ? session.lock(command) : session.adoptLock(lockToken, command);
128
+ return {
129
+ repo: session.root,
130
+ reviewsDir,
131
+ release: lock.release,
132
+ state: {
133
+ mode,
134
+ anchor: session.anchor,
135
+ version,
136
+ planHash: verdict.plan.hash,
137
+ commits: verdict.plan.commits,
138
+ units: [...session.diff.units.values()],
139
+ comments,
140
+ },
141
+ };
142
+ }
143
+ catch (error) {
144
+ const refusal = error instanceof SessionLockedError ||
145
+ error instanceof BranchMovedError ||
146
+ error instanceof SessionFileError;
147
+ if (refusal)
148
+ throw new RegistrationError(error.message);
149
+ throw error;
150
+ }
151
+ }
152
+ export function portFallbackMessage(requested, used) {
153
+ return `port ${requested} is in use by another program, using ${used}`;
154
+ }
155
+ /**
156
+ * Starts this process as the user's server, or finds that one is already up. A requested port
157
+ * held by our own server means attach; held by anything else, the server takes a free port.
158
+ */
159
+ export async function startOrAttach(dir, port, say) {
160
+ const requested = port ?? 0;
161
+ const options = { idleMs: idleMs(), load: loadHosted };
162
+ let host;
163
+ try {
164
+ host = await startServer({ ...options, port: requested });
165
+ }
166
+ catch (error) {
167
+ if (!(error instanceof PortInUseError))
168
+ throw error;
169
+ if ((await probeHealth(requested)) !== null)
170
+ return { kind: "attached", port: requested };
171
+ host = await startServer({ ...options, port: 0 });
172
+ say(portFallbackMessage(requested, host.port));
173
+ }
174
+ const mine = { pid: process.pid, port: host.port, startedAt: new Date().toISOString() };
175
+ const owner = await claimRecord(dir, mine);
176
+ if (owner.pid !== process.pid) {
177
+ await host.close();
178
+ return { kind: "attached", port: owner.port };
179
+ }
180
+ const stop = () => void host.close();
181
+ process.once("SIGINT", stop);
182
+ process.once("SIGTERM", stop);
183
+ void host.closed.then(() => {
184
+ process.off("SIGINT", stop);
185
+ process.off("SIGTERM", stop);
186
+ removeRecord(dir, process.pid);
187
+ });
188
+ return { kind: "started", host };
189
+ }
190
+ /**
191
+ * Starts the server as a detached `follow-me server run` and waits until it answers. Its output
192
+ * goes to server.log in the state dir.
193
+ */
194
+ export async function spawnDetached(dir, port) {
195
+ mkdirSync(dir, { recursive: true });
196
+ const logPath = join(dir, "server.log");
197
+ const log = openSync(logPath, "a");
198
+ const script = process.argv[1];
199
+ if (script === undefined)
200
+ throw new Error("cannot tell which script runs follow-me");
201
+ const args = [...process.execArgv, script, "server", "run"];
202
+ if (port !== undefined)
203
+ args.push("--port", String(port));
204
+ const child = spawn(process.execPath, args, {
205
+ detached: true,
206
+ stdio: ["ignore", log, log],
207
+ env: process.env,
208
+ });
209
+ closeSync(log);
210
+ child.unref();
211
+ const deadline = Date.now() + START_TIMEOUT_MS;
212
+ while (Date.now() < deadline) {
213
+ const live = await findServer(dir);
214
+ if (live !== null)
215
+ return live;
216
+ if (child.exitCode !== null)
217
+ break;
218
+ await new Promise((resolve) => setTimeout(resolve, 50));
219
+ }
220
+ throw new Error(`the review server did not start; see ${logPath}`);
221
+ }
222
+ async function call(port, method, path, body) {
223
+ const res = await fetch(`http://127.0.0.1:${port}${path}`, {
224
+ method,
225
+ headers: { "content-type": "application/json" },
226
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
227
+ });
228
+ const payload = await res.json();
229
+ if (!res.ok) {
230
+ const reason = isFields(payload) ? payload["error"] : undefined;
231
+ throw new RegistrationError(typeof reason === "string" ? reason : `${method} ${path} failed with ${res.status}`);
232
+ }
233
+ return payload;
234
+ }
235
+ function parseSummary(raw) {
236
+ if (!isFields(raw))
237
+ return null;
238
+ const { id, url, repo, branch, mode, version, comments } = raw;
239
+ if (typeof url !== "string" || typeof repo !== "string")
240
+ return null;
241
+ if (typeof branch !== "string" || (mode !== "split" && mode !== "walk"))
242
+ return null;
243
+ if (!isSessionId(id) || !isPlanVersion(version) || typeof comments !== "number")
244
+ return null;
245
+ return { id, url, repo, branch, mode, version, comments };
246
+ }
247
+ /** Registers a session with the server on this port; the same session yields the same URL. */
248
+ export async function registerWith(port, registration) {
249
+ const summary = parseSummary(await call(port, "POST", "/api/sessions", registration));
250
+ if (summary === null)
251
+ throw new RegistrationError("the server answered with a malformed session");
252
+ return summary;
253
+ }
254
+ export async function listSessions(port) {
255
+ const raw = await call(port, "GET", "/api/sessions");
256
+ if (!Array.isArray(raw))
257
+ throw new RegistrationError("the server listed malformed sessions");
258
+ return raw.flatMap((item) => {
259
+ const summary = parseSummary(item);
260
+ return summary === null ? [] : [summary];
261
+ });
262
+ }
263
+ export function sessionUrl(port, summary) {
264
+ return `http://127.0.0.1:${port}${summary.url}`;
265
+ }
266
+ /**
267
+ * Stops the recorded server and waits for it to exit. Only a pid that answers as our server on
268
+ * the recorded port is signalled, so a reused pid is never killed. A stale record is removed.
269
+ */
270
+ export async function stopServer(dir) {
271
+ const live = await findServer(dir);
272
+ if (live === null) {
273
+ const record = readServerRecord(dir);
274
+ if (record !== null)
275
+ removeRecord(dir, record.pid);
276
+ return null;
277
+ }
278
+ process.kill(live.pid, "SIGTERM");
279
+ const deadline = Date.now() + STOP_TIMEOUT_MS;
280
+ while (isAlive(live.pid)) {
281
+ if (Date.now() > deadline)
282
+ throw new Error(`server pid ${live.pid} did not stop`);
283
+ await new Promise((resolve) => setTimeout(resolve, 50));
284
+ }
285
+ removeRecord(dir, live.pid);
286
+ return live;
287
+ }
288
+ export function openBrowser(url) {
289
+ const command = process.platform === "darwin" ? "open" : "xdg-open";
290
+ const child = spawn(command, [url], { stdio: "ignore", detached: true });
291
+ child.on("error", (error) => console.error(`could not open a browser: ${error.message}`));
292
+ child.unref();
293
+ }
@@ -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.user ? "user" : "project";
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
+ }