@jive-sh/skill-sync 0.2.2-latest-linux-x64 → 0.2.2

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,276 @@
1
+ import { mkdirSync, watch, type FSWatcher } from "node:fs";
2
+ import { exitWhenUninstalled } from "jive-abstract-library";
3
+ import { detectFolders, loadConfig, loadStates, saveStates, stateFor } from "../internal/config";
4
+ import { authHeaders, describe, logReconciliations, register } from "../internal/folders";
5
+ import { notify } from "../internal/notify";
6
+ import { reconcile } from "../internal/reconcile";
7
+ import { isQuiet, syncOnce, type Report } from "../internal/sync";
8
+ import type { DaemonMessage, FolderPrefs, FolderState, HubToDaemon } from "../internal/wire";
9
+
10
+ /**
11
+ * Keeps every enabled skills folder on this machine identical to every other machine's.
12
+ *
13
+ * Folders are detected (`~/.claude*` homes); which of them sync, and which skills in each, are
14
+ * preferences held by the server and changed from the dashboard, so they survive a reinstall and apply
15
+ * the moment they change. A skill not opted in stays local to its folder.
16
+ *
17
+ * The daemon holds a WebSocket to the service's hub. That connection is how the dashboard knows this
18
+ * machine is running, how a preference change arrives, and how a write from another machine triggers a
19
+ * cycle here at once. Polling stays as the fallback: every 5 minutes while connected, every 30 seconds
20
+ * while not, so a dropped connection costs latency and never correctness.
21
+ */
22
+
23
+ const POLL_CONNECTED_MS = 5 * 60_000;
24
+ const POLL_DISCONNECTED_MS = 30_000;
25
+ /** Long enough that an editor's write-rename-write lands as one cycle, short enough to feel immediate. */
26
+ const SETTLE_MS = 1_000;
27
+ const PING_MS = 30_000;
28
+ /** No pong for this long means the connection is dead even if the socket has not noticed. */
29
+ const PONG_TIMEOUT_MS = 75_000;
30
+ const RECONNECT_MIN_MS = 5_000;
31
+ const RECONNECT_MAX_MS = 5 * 60_000;
32
+
33
+ exitWhenUninstalled();
34
+
35
+ const loaded = loadConfig();
36
+ if (!loaded.ok) {
37
+ console.error(`skill-sync is not configured: ${loaded.reason}`);
38
+ // Zero, not one: the service manager restarts a daemon that fails, and retrying every few seconds
39
+ // against a config nobody has filled in yet would just fill the log.
40
+ process.exit(0);
41
+ }
42
+ const config = loaded.config;
43
+ const states = loadStates();
44
+
45
+ /** What was last registered, so registration is repeated only when a folder or skill appears or goes. */
46
+ let registered = "";
47
+ /** The folders to sync, each with the skills that sync there. */
48
+ let prefs = new Map<string, ReadonlySet<string>>();
49
+ const watchers = new Map<string, FSWatcher>();
50
+
51
+ // ── Folders ─────────────────────────────────────────────────────────────────────────────────────────
52
+
53
+ /** Reports this machine's folders and skills, and adopts the server's answer for what syncs. */
54
+ async function registerFolders(): Promise<void> {
55
+ const request = describe(config, states);
56
+ applyPrefs(await register(config, request));
57
+ registered = JSON.stringify(request);
58
+ }
59
+
60
+ /** True when a folder or a skill has appeared or gone since the last registration. */
61
+ const registrationStale = (): boolean => JSON.stringify(describe(config, states)) !== registered;
62
+
63
+ function applyPrefs(folders: ReadonlyArray<FolderPrefs>): void {
64
+ // Only folders this machine still has. The hub can hold a stale list — it answers a reconnect before
65
+ // the next registration — and creating a listed folder whose Claude home is gone would bring it back.
66
+ const present = new Set(detectFolders());
67
+ const next = new Map(folders.filter((folder) => present.has(folder.path))
68
+ .map((folder) => [folder.path, new Set(folder.skills)] as const));
69
+ for (const [folder, watcher] of watchers) {
70
+ if (!next.has(folder)) {
71
+ watcher.close();
72
+ watchers.delete(folder);
73
+ }
74
+ }
75
+ // An unticked folder leaves no trace of syncing: its state goes, so the state file lists only folders
76
+ // that sync, and ticking it again starts it fresh — any copy that differs is merged, not overwritten.
77
+ // Covers folders with state from an earlier run too, not only ones this run was syncing.
78
+ const stopped = [...new Set([...prefs.keys(), ...Object.keys(states)])].filter((folder) => !next.has(folder));
79
+ for (const folder of stopped) {
80
+ delete states[folder];
81
+ console.log(`stopped syncing ${folder}`);
82
+ }
83
+ if (stopped.length > 0) saveStates(states);
84
+ for (const folder of next.keys()) {
85
+ if (watchers.has(folder)) continue;
86
+ mkdirSync(folder, { recursive: true });
87
+ let settle: ReturnType<typeof setTimeout> | undefined;
88
+ watchers.set(folder, watch(folder, { recursive: true }, () => {
89
+ if (settle !== undefined) clearTimeout(settle);
90
+ settle = setTimeout(runCycle, SETTLE_MS);
91
+ }));
92
+ }
93
+ const changed = JSON.stringify([...next].map(([path, skills]) => [path, [...skills].sort()]))
94
+ !== JSON.stringify([...prefs].map(([path, skills]) => [path, [...skills].sort()]));
95
+ prefs = next;
96
+ if (!changed) return;
97
+ for (const [folder, skills] of prefs) console.log(`syncing ${folder}: ${[...skills].sort().join(", ") || "no skills"}`);
98
+ if (prefs.size === 0) console.log("syncing no folders");
99
+ void runCycle();
100
+ }
101
+
102
+ // ── Hub connection ──────────────────────────────────────────────────────────────────────────────────
103
+
104
+ let socket: WebSocket | undefined;
105
+ let lastPong = 0;
106
+ let reconnectDelay = RECONNECT_MIN_MS;
107
+ /** What the hub was last told about each folder (state and detail), so only changes are sent. */
108
+ const reported = new Map<string, string>();
109
+
110
+ function connect(): void {
111
+ const url = `${config.endpoint.replace(/^http/, "ws")}/daemon`;
112
+ // Bun's WebSocket takes headers; the browser's does not, which is why the token can travel this way.
113
+ const ws = new WebSocket(url, { headers: authHeaders(config, {}) } as unknown as string[]);
114
+ ws.onopen = () => {
115
+ socket = ws;
116
+ lastPong = Date.now();
117
+ reconnectDelay = RECONNECT_MIN_MS;
118
+ console.log("connected to the hub");
119
+ // A previous run may have died mid-merge; whatever the hub last heard is stale until re-sent.
120
+ reported.clear();
121
+ for (const folder of prefs.keys()) report(folder, "idle");
122
+ };
123
+ ws.onmessage = (event) => {
124
+ const data = String(event.data);
125
+ if (data === "pong") {
126
+ lastPong = Date.now();
127
+ return;
128
+ }
129
+ const message = JSON.parse(data) as HubToDaemon;
130
+ if (message.type === "prefs") applyPrefs(message.folders);
131
+ if (message.type === "changed") void runCycle();
132
+ };
133
+ ws.onclose = () => {
134
+ if (socket === ws) socket = undefined;
135
+ setTimeout(connect, reconnectDelay);
136
+ reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
137
+ };
138
+ ws.onerror = () => {
139
+ // `onclose` follows and schedules the reconnect.
140
+ };
141
+ }
142
+
143
+ setInterval(() => {
144
+ if (socket === undefined) return;
145
+ if (Date.now() - lastPong > PONG_TIMEOUT_MS) {
146
+ console.error("hub connection went quiet; reconnecting");
147
+ socket.close();
148
+ return;
149
+ }
150
+ socket.send("ping");
151
+ }, PING_MS);
152
+
153
+ function report(folder: string, state: FolderState, detail?: string, activity?: string): void {
154
+ const key = `${state}|${detail ?? ""}`;
155
+ if (reported.get(folder) === key && activity === undefined) return;
156
+ if (socket === undefined) return;
157
+ const message: DaemonMessage = { type: "status", folder, state, detail, activity };
158
+ socket.send(JSON.stringify(message));
159
+ reported.set(folder, key);
160
+ }
161
+
162
+ // ── Sync cycles ─────────────────────────────────────────────────────────────────────────────────────
163
+
164
+ let running = false;
165
+ let cycleQueued = false;
166
+
167
+ /**
168
+ * Conflicts already reported to the desktop.
169
+ *
170
+ * A conflict persists until a human resolves it, so every cycle keeps finding it — notifying each time
171
+ * would put a popup on screen every cycle until they gave up and turned notifications off, which costs
172
+ * them the one notification that mattered. Only newly-conflicting paths are announced, and a path drops
173
+ * out of the set once it stops conflicting so a later recurrence is announced again.
174
+ */
175
+ const announced = new Set<string>();
176
+
177
+ function announceConflicts(conflicts: ReadonlyArray<string>): void {
178
+ for (const path of [...announced]) {
179
+ if (!conflicts.includes(path)) announced.delete(path);
180
+ }
181
+ const fresh = conflicts.filter((path) => !announced.has(path));
182
+ if (fresh.length === 0) return;
183
+ for (const path of fresh) announced.add(path);
184
+ notify(
185
+ "Skill sync conflict",
186
+ fresh.length === 1
187
+ ? `${fresh[0]} changed on two machines and could not be merged. Your version is saved as ${fresh[0]}.local`
188
+ : `${fresh.length} skills changed on two machines and could not be merged. Your versions are saved as .local`,
189
+ );
190
+ }
191
+
192
+ function summarise(result: Report): string {
193
+ const parts = [
194
+ result.pulled.length > 0 ? `pulled ${result.pulled.join(", ")}` : "",
195
+ result.pushed.length > 0 ? `pushed ${result.pushed.join(", ")}` : "",
196
+ result.deletedLocally.length > 0 ? `deleted ${result.deletedLocally.join(", ")}` : "",
197
+ result.merged.length > 0 ? `merged ${result.merged.join(", ")}` : "",
198
+ ];
199
+ return parts.filter(Boolean).join("; ");
200
+ }
201
+
202
+ async function runCycle(): Promise<void> {
203
+ if (running) {
204
+ cycleQueued = true;
205
+ return;
206
+ }
207
+ running = true;
208
+ const conflicts: string[] = [];
209
+ try {
210
+ // Folders sync one after another against the same server, so a change pushed from one folder is
211
+ // pulled into the others on the follow-up pass this triggers.
212
+ for (const [folder, skills] of [...prefs]) {
213
+ try {
214
+ const result = await syncOnce(config, folder, stateFor(states, folder), skills, {
215
+ merge: reconcile,
216
+ onMerging: (path) => {
217
+ console.log(`${folder}: merging ${path} with Claude`);
218
+ report(folder, "reconciling", path);
219
+ },
220
+ });
221
+ saveStates(states);
222
+ log(folder, result);
223
+ if (result.reconciliations.length > 0) {
224
+ await logReconciliations(config, result.reconciliations.map((entry) => ({ ...entry, folder })))
225
+ .catch((cause) => console.error(`could not log a merge: ${cause instanceof Error ? cause.message : String(cause)}`));
226
+ }
227
+ conflicts.push(...result.conflicts.map((path) => `${folder}/${path}`));
228
+
229
+ const problems = [...result.mergeFailures, ...result.conflicts.map((path) => `${path} kept as .local`), ...result.rejected];
230
+ if (problems.length > 0) report(folder, "attention", problems.join("; "), summarise(result) || undefined);
231
+ else report(folder, "idle", undefined, isQuiet(result) ? undefined : summarise(result));
232
+
233
+ if (result.pushed.length > 0 || result.merged.length > 0 || result.retry) cycleQueued = true;
234
+ } catch (cause) {
235
+ // One failed folder must not end the daemon or the other folders: the usual cause is the network.
236
+ const reason = cause instanceof Error ? cause.message : String(cause);
237
+ console.error(`${folder}: sync failed: ${reason}`);
238
+ report(folder, "attention", `sync failed: ${reason}`);
239
+ }
240
+ }
241
+ announceConflicts(conflicts);
242
+ // A skill created or removed locally changes what the dashboard should list.
243
+ if (registrationStale()) await registerFolders().catch(() => undefined);
244
+ } finally {
245
+ running = false;
246
+ if (cycleQueued) {
247
+ cycleQueued = false;
248
+ setTimeout(runCycle, SETTLE_MS);
249
+ }
250
+ }
251
+ }
252
+
253
+ function log(folder: string, result: Report): void {
254
+ for (const path of result.pulled) console.log(`${folder}: pulled ${path}`);
255
+ for (const path of result.pushed) console.log(`${folder}: pushed ${path}`);
256
+ for (const path of result.deletedLocally) console.log(`${folder}: deleted ${path}`);
257
+ for (const path of result.merged) console.log(`${folder}: merged ${path}`);
258
+ for (const failure of result.mergeFailures) console.error(`${folder}: merge failed for ${failure}`);
259
+ for (const path of result.conflicts) console.error(`${folder}: conflict on ${path}; your copy is at ${path}.local`);
260
+ for (const path of result.rejected) console.error(`${folder}: refused a path the server sent: ${path}`);
261
+ }
262
+
263
+ /** Re-detects folders each poll: a Claude home created since the last one should join without a restart. */
264
+ async function poll(): Promise<void> {
265
+ try {
266
+ if (registered === "" || registrationStale()) await registerFolders();
267
+ } catch (cause) {
268
+ console.error(`could not register folders: ${cause instanceof Error ? cause.message : String(cause)}`);
269
+ }
270
+ await runCycle();
271
+ setTimeout(poll, socket === undefined ? POLL_DISCONNECTED_MS : POLL_CONNECTED_MS);
272
+ }
273
+
274
+ console.log(`skill-sync daemon for ${config.machine}, syncing with ${config.endpoint}`);
275
+ connect();
276
+ await poll();
@@ -0,0 +1,109 @@
1
+ import { createServer } from "node:http";
2
+ import { spawn } from "node:child_process";
3
+ import { rmSync, writeFileSync } from "node:fs";
4
+ import { Command } from "commander";
5
+ import { Effect } from "effect";
6
+ import { upgrade } from "jive-abstract-library";
7
+ import { name } from "../../package.json";
8
+ import { CONFIG_PATH, TOKEN_PATH, loadConfig, loadSettings } from "../internal/config";
9
+
10
+ /**
11
+ * The CLI that accompanies the daemon.
12
+ *
13
+ * The daemon never authenticates anyone — it reads a token the CLI left behind and nothing more. Signing
14
+ * in needs a browser and a person, which a process started at login has neither of, and a daemon that
15
+ * could prompt for credentials would be a daemon that prompts you at every login.
16
+ */
17
+
18
+ const program = new Command("skill-sync")
19
+ .description("keep the skills you choose identical across every Claude folder on every machine; choose them at https://skill-sync.jive.sh/");
20
+
21
+ program
22
+ .command("login")
23
+ .description("verify with your passkey in the browser and store this machine's sync token")
24
+ .action(async () => {
25
+ // A missing token is exactly what we are here to fix, so only the settings have to be usable.
26
+ const settings = loadSettings();
27
+ if (settings === undefined) {
28
+ console.error(`Fill in ${CONFIG_PATH} with an endpoint and a machine name first.`);
29
+ process.exit(1);
30
+ }
31
+
32
+ const token = await captureToken(settings.endpoint, settings.machine);
33
+ writeFileSync(TOKEN_PATH, `${JSON.stringify({ token }, null, 2)}\n`, { mode: 0o600 });
34
+ console.log(`Signed in. Token saved to ${TOKEN_PATH}`);
35
+ console.log(`Choose which folders sync at ${settings.endpoint}/`);
36
+ });
37
+
38
+ program
39
+ .command("logout")
40
+ .description("forget this machine's sync token")
41
+ .action(async () => {
42
+ const loaded = loadConfig();
43
+ if (loaded.ok) {
44
+ // Revoke first: if this fails, the token is still on disk and you can try again. Deleting it first
45
+ // would leave a live token on the server that nobody can revoke any more.
46
+ const response = await fetch(`${loaded.config.endpoint}/cli/token`, {
47
+ method: "DELETE",
48
+ headers: { authorization: `Bearer ${loaded.config.token}` },
49
+ });
50
+ console.log(response.ok ? "Revoked on the server." : `Server revoke failed (${response.status}).`);
51
+ }
52
+ rmSync(TOKEN_PATH, { force: true });
53
+ console.log(`Removed ${TOKEN_PATH}`);
54
+ });
55
+
56
+ program
57
+ .command("upgrade")
58
+ .description("upgrade skill-sync to the latest version with the package manager that installed it")
59
+ .action(async () => {
60
+ await Effect.runPromise(upgrade({ packageName: name })).catch((cause: unknown) => {
61
+ console.error(`Upgrade failed: ${cause instanceof Error ? cause.message : String(cause)}`);
62
+ process.exit(1);
63
+ });
64
+ });
65
+
66
+ program.parse();
67
+ if (process.argv.length <= 2) program.help();
68
+
69
+ /**
70
+ * Opens the browser at the service's login endpoint and waits for it to come back with a token.
71
+ *
72
+ * The browser does the authenticating, with a passkey for the service's domain; this only has to be
73
+ * listening when it finishes. The listener is bound to 127.0.0.1 so nothing off this machine can receive
74
+ * the redirect, and the port is whatever the OS hands out so two machines — or two attempts — never
75
+ * contend for one.
76
+ */
77
+ async function captureToken(endpoint: string, machine: string): Promise<string> {
78
+ return await new Promise<string>((resolve, reject) => {
79
+ const server = createServer((request, response) => {
80
+ const token = new URL(request.url ?? "/", "http://127.0.0.1").searchParams.get("token");
81
+ response.writeHead(200, { "content-type": "text/html" });
82
+ response.end(token
83
+ ? "<p>Signed in. You can close this tab.</p>"
84
+ : "<p>No token in the redirect. Check the service logs.</p>");
85
+ server.close();
86
+ token ? resolve(token) : reject(new Error("the redirect carried no token"));
87
+ });
88
+
89
+ server.listen(0, "127.0.0.1", () => {
90
+ const port = (server.address() as { port: number }).port;
91
+ const url = `${endpoint}/cli/login?port=${port}&machine=${encodeURIComponent(machine)}`;
92
+ console.log(`Opening ${url}`);
93
+ openBrowser(url);
94
+ });
95
+
96
+ setTimeout(() => {
97
+ server.close();
98
+ reject(new Error("timed out waiting for the browser"));
99
+ }, 180_000).unref();
100
+ });
101
+ }
102
+
103
+ function openBrowser(url: string): void {
104
+ const command = process.platform === "darwin" ? "open"
105
+ : process.platform === "win32" ? "cmd"
106
+ : "xdg-open";
107
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
108
+ spawn(command, args, { stdio: "ignore", detached: true }).unref();
109
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * What the service needs from this package.
3
+ *
4
+ * The daemon and CLI are the product; this entrypoint exists so the service can share the path rules
5
+ * and wire types rather than restate them. A rule restated is a rule that drifts, and these two are
6
+ * exactly the pair that must not: one validates what the other writes to disk.
7
+ */
8
+
9
+ export { invalidPathReason, isValidPath } from "./internal/path";
10
+ export { skillOf } from "./internal/wire";
11
+ export type {
12
+ DaemonMessage, DashboardMessage, FileHash, FolderPrefs, FolderState, FolderView, FoldersRequest, FoldersResponse,
13
+ HubToDaemon, ListFilesResponse, LocalSkill, MachineView, PutFileRequest, ReconciliationRequest,
14
+ ReconciliationView, RemoteFile, SkillView, Snapshot, SyncedSkill,
15
+ } from "./internal/wire";
@@ -0,0 +1,191 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "node:fs";
3
+ import { homedir, hostname } from "node:os";
4
+ import { join, relative, sep } from "node:path";
5
+
6
+ /**
7
+ * Per-machine settings and sync state, both under `~/.jive/`.
8
+ *
9
+ * A file rather than environment variables: the endpoint and which machine this is differ per computer
10
+ * and have to survive a login, which is exactly what a config file is for and exactly what an environment
11
+ * variable is not. Which folders to sync is not in here: they are detected, and the choice to leave one
12
+ * out is kept on the server so the dashboard can change it and a reinstalled daemon still remembers it.
13
+ */
14
+
15
+ const JIVE_HOME = join(homedir(), ".jive");
16
+ export const CONFIG_PATH = join(JIVE_HOME, "skill-sync.json");
17
+ /**
18
+ * The token lives on its own, outside `~/.jive/`, because it is the one part that must not travel with
19
+ * the rest. Endpoint and machine name are settings you could paste into a gist; this
20
+ * is a credential, and keeping it in a separate file makes "do not copy this one" a property of the
21
+ * filesystem rather than a thing you have to remember.
22
+ */
23
+ export const TOKEN_PATH = join(homedir(), ".skill-sync-token.json");
24
+ const STATE_PATH = join(JIVE_HOME, "skill-sync-state.json");
25
+
26
+ /** Managed by Claude's own cloud sync; see `Config.exclude`. */
27
+ const DEFAULT_EXCLUDE = ["synced"] as const;
28
+
29
+ export interface Config {
30
+ readonly endpoint: string;
31
+ /**
32
+ * Top-level directory names under each synced folder to leave alone.
33
+ *
34
+ * `synced` holds the skills Claude already pulls from the cloud itself. Syncing them here would mean
35
+ * two systems writing the same files, each undoing the other, and none of them are yours to edit.
36
+ */
37
+ readonly exclude: ReadonlyArray<string>;
38
+ readonly token: string;
39
+ /** Names this machine in conflict messages, so "who overwrote this" has an answer. */
40
+ readonly machine: string;
41
+ }
42
+
43
+ /** Either the settings, or the one sentence telling the user what to go and fill in. */
44
+ export type ConfigResult =
45
+ | { readonly ok: true; readonly config: Config }
46
+ | { readonly ok: false; readonly reason: string };
47
+
48
+ /** Sync progress for one folder. Each folder syncs with the server on its own, so each has its own. */
49
+ export interface State {
50
+ /** Highest server sequence number already applied. */
51
+ cursor: number;
52
+ /** Last hash this machine agreed with the server on, per relative path. Absent means never synced. */
53
+ known: Record<string, string>;
54
+ /** The skills this folder synced last cycle, to notice one being opted in. */
55
+ skills?: string[];
56
+ }
57
+
58
+ /** Every folder's `State`, keyed by the folder's absolute path. */
59
+ export type States = Record<string, State>;
60
+
61
+ /**
62
+ * The settings alone, with no token required.
63
+ *
64
+ * `login` needs these precisely when the token is missing, so it cannot go through `loadConfig` — that
65
+ * reports "not configured" for a missing token and would refuse to run the command that supplies one.
66
+ */
67
+ export function loadSettings(): { endpoint: string; machine: string } | undefined {
68
+ loadConfig();
69
+ const settings = readJson<Partial<Config>>(CONFIG_PATH);
70
+ if (!settings.endpoint || !settings.machine) return undefined;
71
+ return { endpoint: settings.endpoint, machine: settings.machine };
72
+ }
73
+
74
+ /** Reads both files, writing a template for whichever is missing, and says what is still needed. */
75
+ export function loadConfig(): ConfigResult {
76
+ if (!existsSync(CONFIG_PATH)) {
77
+ mkdirSync(JIVE_HOME, { recursive: true });
78
+ writeFileSync(CONFIG_PATH, `${JSON.stringify({
79
+ endpoint: "https://skill-sync.jive.sh",
80
+ machine: machineName(),
81
+ exclude: DEFAULT_EXCLUDE,
82
+ }, null, 2)}\n`);
83
+ }
84
+
85
+ if (!existsSync(TOKEN_PATH)) {
86
+ // 0600 from the moment it exists, rather than written open and tightened afterwards — there is no
87
+ // window in between for anything else on the machine to read it.
88
+ writeFileSync(TOKEN_PATH, `${JSON.stringify({ token: "" }, null, 2)}\n`, { mode: 0o600 });
89
+ }
90
+
91
+ const settings = readJson<Partial<Config>>(CONFIG_PATH);
92
+ const token = readJson<{ token?: string }>(TOKEN_PATH).token;
93
+
94
+ if (!settings.endpoint || !settings.machine) {
95
+ return { ok: false, reason: `${CONFIG_PATH} needs an endpoint and a machine name` };
96
+ }
97
+ if (!token) {
98
+ return { ok: false, reason: `${TOKEN_PATH} needs the sync token` };
99
+ }
100
+ return {
101
+ ok: true,
102
+ config: { endpoint: settings.endpoint, machine: settings.machine, exclude: settings.exclude ?? DEFAULT_EXCLUDE, token },
103
+ };
104
+ }
105
+
106
+ function readJson<T>(path: string): T {
107
+ try {
108
+ return JSON.parse(readFileSync(path, "utf-8")) as T;
109
+ } catch {
110
+ return {} as T;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Every folder's sync state.
116
+ *
117
+ * Before folders were detected the file held one folder's `{ cursor, known }`, for the folder then named
118
+ * by `skillsDir` in the settings. That state is carried over to that folder: dropping it would make the
119
+ * folder look never-synced, and every file in it that differs from the server would become a conflict.
120
+ */
121
+ export function loadStates(): States {
122
+ const stored = readJson<Partial<State> & { folders?: States }>(STATE_PATH);
123
+ if (stored.folders !== undefined) return stored.folders;
124
+ const legacyFolder = readJson<{ skillsDir?: string }>(CONFIG_PATH).skillsDir;
125
+ if (legacyFolder !== undefined && stored.cursor !== undefined && stored.known !== undefined) {
126
+ return { [legacyFolder]: { cursor: stored.cursor, known: stored.known } };
127
+ }
128
+ return {};
129
+ }
130
+
131
+ export function stateFor(states: States, folder: string): State {
132
+ states[folder] ??= { cursor: 0, known: {} };
133
+ return states[folder];
134
+ }
135
+
136
+ export function saveStates(states: States): void {
137
+ mkdirSync(JIVE_HOME, { recursive: true });
138
+ writeFileSync(STATE_PATH, `${JSON.stringify({ folders: states }, null, 2)}\n`);
139
+ }
140
+
141
+ /** `HOSTNAME` is not set on macOS, so the template was writing "unknown" on exactly this machine. */
142
+ function machineName(): string {
143
+ return hostname().replace(/\.local$/, "").replace(/[^A-Za-z0-9-]+/g, "-") || "unknown";
144
+ }
145
+
146
+ /**
147
+ * The `skills` folder of every Claude home on this machine: `~/.claude` and each `~/.claude-<name>`.
148
+ *
149
+ * Every home is a candidate, whether or not it has a `skills` folder yet, so a Claude account added
150
+ * later joins without anyone listing it. Which of them actually sync is the user's choice on the
151
+ * dashboard; a folder that is synced and missing is created.
152
+ */
153
+ export function detectFolders(): string[] {
154
+ return readdirSync(homedir())
155
+ .filter((entry) => entry === ".claude" || entry.startsWith(".claude-"))
156
+ .filter((entry) => statSync(join(homedir(), entry)).isDirectory())
157
+ .map((entry) => join(homedir(), entry, "skills"))
158
+ .sort();
159
+ }
160
+
161
+ export function hashBytes(bytes: Uint8Array): string {
162
+ return createHash("sha256").update(bytes).digest("hex");
163
+ }
164
+
165
+ /** Suffix for the copy kept when an incoming change would have overwritten a local edit. */
166
+ export const CONFLICT_SUFFIX = ".local";
167
+
168
+ /**
169
+ * Every file under a synced folder, as paths relative to it with forward slashes.
170
+ *
171
+ * Normalised because the same skill synced from Windows and macOS must be one row, not two.
172
+ *
173
+ * Conflict copies are skipped. They are this machine's evidence of a collision, not content anyone else
174
+ * asked for, and syncing them spreads one machine's mess to every other machine.
175
+ */
176
+ export function listLocalFiles(folder: string, exclude: ReadonlyArray<string> = DEFAULT_EXCLUDE): string[] {
177
+ if (!existsSync(folder)) return [];
178
+
179
+ const found: string[] = [];
180
+ const walk = (dir: string, top: boolean): void => {
181
+ for (const entry of readdirSync(dir)) {
182
+ if (entry.startsWith(".")) continue;
183
+ if (top && exclude.includes(entry)) continue;
184
+ const full = join(dir, entry);
185
+ if (statSync(full).isDirectory()) walk(full, false);
186
+ else if (!entry.endsWith(CONFLICT_SUFFIX)) found.push(relative(folder, full).split(sep).join("/"));
187
+ }
188
+ };
189
+ walk(folder, true);
190
+ return found.sort();
191
+ }
@@ -0,0 +1,69 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { detectFolders, hashBytes, listLocalFiles, type Config, type States } from "./config";
4
+ import {
5
+ skillOf, type FileHash, type FolderPrefs, type FoldersRequest, type FoldersResponse, type LocalSkill, type ReconciliationRequest,
6
+ } from "./wire";
7
+
8
+ /**
9
+ * What this machine tells the service about its folders, and what it hears back.
10
+ *
11
+ * The daemon and the CLI both register, so this lives in one place: they must describe a folder the same
12
+ * way, or the dashboard would show whichever of them spoke last.
13
+ */
14
+
15
+ /**
16
+ * The skills on disk in one folder, with each file's hash. Hashing every file on each check is cheap at
17
+ * skills' size, and it is what lets the dashboard show whether this folder's copy is the synced one.
18
+ */
19
+ export function localSkills(folder: string, config: Config, states: States): LocalSkill[] {
20
+ const known = Object.keys(states[folder]?.known ?? {});
21
+ const byName = new Map<string, FileHash[]>();
22
+ for (const path of listLocalFiles(folder, config.exclude)) {
23
+ const name = skillOf(path);
24
+ let hash: string;
25
+ try {
26
+ hash = hashBytes(readFileSync(join(folder, path)));
27
+ } catch {
28
+ continue; // Deleted between listing and reading; the next check reports the folder as it is.
29
+ }
30
+ byName.set(name, [...(byName.get(name) ?? []), { path, hash }]);
31
+ }
32
+ return [...byName].map(([name, files]) => ({
33
+ name,
34
+ files,
35
+ tracked: known.some((path) => skillOf(path) === name),
36
+ }));
37
+ }
38
+
39
+ /** Everything registration sends, so a caller can tell when it has changed and is worth re-sending. */
40
+ export function describe(config: Config, states: States): FoldersRequest {
41
+ return { folders: detectFolders().map((path) => ({ path, skills: localSkills(path, config, states) })) };
42
+ }
43
+
44
+ /** Registers this machine's folders and skills; returns the folders to sync and the skills in each. */
45
+ export async function register(config: Config, request: FoldersRequest): Promise<ReadonlyArray<FolderPrefs>> {
46
+ const response = await fetch(`${config.endpoint}/folders`, {
47
+ method: "POST",
48
+ headers: authHeaders(config, { "content-type": "application/json" }),
49
+ body: JSON.stringify(request),
50
+ });
51
+ if (!response.ok) throw new Error(`POST /folders: ${response.status} ${await response.text()}`);
52
+ return (await response.json() as FoldersResponse).folders;
53
+ }
54
+
55
+ /** Sends merges to the audit log. A failure here is logged by the caller, never fatal to a sync. */
56
+ export async function logReconciliations(config: Config, entries: ReadonlyArray<ReconciliationRequest>): Promise<void> {
57
+ for (const entry of entries) {
58
+ const response = await fetch(`${config.endpoint}/reconciliations`, {
59
+ method: "POST",
60
+ headers: authHeaders(config, { "content-type": "application/json" }),
61
+ body: JSON.stringify(entry),
62
+ });
63
+ if (!response.ok) throw new Error(`POST /reconciliations: ${response.status} ${await response.text()}`);
64
+ }
65
+ }
66
+
67
+ export function authHeaders(config: Config, extra: Record<string, string>): Record<string, string> {
68
+ return { authorization: `Bearer ${config.token}`, "x-skill-sync-machine": config.machine, ...extra };
69
+ }