@jive-sh/skill-sync 0.3.2-latest-darwin-x64 → 0.3.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.
Files changed (40) hide show
  1. package/README.md +60 -0
  2. package/bin/package.json +1 -0
  3. package/bin/skill-sync +571 -0
  4. package/bin/skill-sync-daemon +571 -0
  5. package/dist/daemons/skill-sync-daemon.js +401 -0
  6. package/dist/daemons/skill-sync-daemon.js.map +1 -0
  7. package/dist/entrypoints/skill-sync.js +114 -0
  8. package/dist/entrypoints/skill-sync.js.map +1 -0
  9. package/dist/index.js +10 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/internal/config.js +134 -0
  12. package/dist/internal/config.js.map +1 -0
  13. package/dist/internal/control.js +101 -0
  14. package/dist/internal/control.js.map +1 -0
  15. package/dist/internal/folders.js +60 -0
  16. package/dist/internal/folders.js.map +1 -0
  17. package/dist/internal/notify.js +50 -0
  18. package/dist/internal/notify.js.map +1 -0
  19. package/dist/internal/path.js +36 -0
  20. package/dist/internal/path.js.map +1 -0
  21. package/dist/internal/reconcile.js +124 -0
  22. package/dist/internal/reconcile.js.map +1 -0
  23. package/dist/internal/sync.js +241 -0
  24. package/dist/internal/sync.js.map +1 -0
  25. package/dist/internal/wire.js +13 -0
  26. package/dist/internal/wire.js.map +1 -0
  27. package/install.cjs +571 -0
  28. package/package.json +39 -11
  29. package/src/daemons/skill-sync-daemon.ts +397 -0
  30. package/src/entrypoints/skill-sync.ts +116 -0
  31. package/src/index.ts +15 -0
  32. package/src/internal/config.ts +171 -0
  33. package/src/internal/control.ts +123 -0
  34. package/src/internal/folders.ts +64 -0
  35. package/src/internal/notify.ts +53 -0
  36. package/src/internal/path.ts +32 -0
  37. package/src/internal/reconcile.ts +166 -0
  38. package/src/internal/sync.ts +304 -0
  39. package/src/internal/wire.ts +162 -0
  40. package/multi-call-binary +0 -4
@@ -0,0 +1,304 @@
1
+ import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync, existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { CONFLICT_SUFFIX, hashBytes, listLocalFiles, type Config, type State } from "./config";
4
+ import { invalidPathReason } from "./path";
5
+ import { backUp, type Conflict, type Resolution } from "./reconcile";
6
+ import { skillOf, type ReconciliationRequest } from "./wire";
7
+
8
+ /**
9
+ * One sync cycle: take everything the server has that we do not, then offer everything we have that it
10
+ * does not.
11
+ *
12
+ * Pull before push, deliberately. Pushing first would send a file the server already changed, get it
13
+ * refused, and report a conflict that pulling would have resolved on its own.
14
+ */
15
+
16
+ interface RemoteFile {
17
+ readonly path: string;
18
+ readonly hash: string | null;
19
+ readonly deleted: boolean;
20
+ readonly updatedAt: number;
21
+ readonly updatedBy: string;
22
+ }
23
+
24
+ /**
25
+ * How a cycle resolves a file changed both here and on the server. Without a `merge`, the server's copy
26
+ * wins and this machine's is kept beside it as `.local`; with one, the two are merged and the result
27
+ * is pushed. `onMerging` fires before a merge starts, since one can take minutes.
28
+ */
29
+ export interface Reconciler {
30
+ readonly merge: (conflict: Conflict) => Promise<Resolution>;
31
+ readonly onMerging?: (path: string) => void;
32
+ }
33
+
34
+ export interface Report {
35
+ readonly pulled: string[];
36
+ readonly pushed: string[];
37
+ readonly deletedLocally: string[];
38
+ readonly conflicts: string[];
39
+ /** Paths changed on both sides and merged; the merge is pushed in the same cycle. */
40
+ readonly merged: string[];
41
+ /** Merges that failed, with the reason; those paths fell back to `.local`. */
42
+ readonly mergeFailures: string[];
43
+ /** Every merge attempted this cycle, for the audit log. */
44
+ readonly reconciliations: Omit<ReconciliationRequest, "folder">[];
45
+ /** Paths the server offered that this machine refused to write. */
46
+ readonly rejected: string[];
47
+ /** A push lost a race to another machine; the next cycle pulls and reconciles it. */
48
+ retry: boolean;
49
+ }
50
+
51
+ /** True when a report has nothing in it worth telling anyone. */
52
+ export function isQuiet(report: Report): boolean {
53
+ return [report.pulled, report.pushed, report.deletedLocally, report.conflicts, report.merged,
54
+ report.mergeFailures, report.rejected].every((list) => list.length === 0);
55
+ }
56
+
57
+ /**
58
+ * `skills` is the synced list. Every other skill in the folder is local to it: never pushed, never
59
+ * overwritten, never deleted by a sync.
60
+ */
61
+ export async function syncOnce(
62
+ config: Config, folder: string, state: State, skills: ReadonlySet<string>, reconciler?: Reconciler,
63
+ ): Promise<Report> {
64
+ const report: Report = {
65
+ pulled: [], pushed: [], deletedLocally: [], conflicts: [], merged: [], mergeFailures: [], reconciliations: [],
66
+ rejected: [], retry: false,
67
+ };
68
+ mkdirSync(folder, { recursive: true });
69
+
70
+ // A skill added to the list since last cycle may have server files older than the cursor; re-read from the start.
71
+ if ([...skills].some((skill) => !(state.skills ?? []).includes(skill))) state.cursor = 0;
72
+ state.skills = [...skills].sort();
73
+ // A skill removed from the list stops being tracked here. Left in `known`, its files would look deleted from this
74
+ // folder and the push would tombstone them on every machine.
75
+ for (const path of Object.keys(state.known)) {
76
+ if (!skills.has(skillOf(path))) delete state.known[path];
77
+ }
78
+
79
+ const present = new Set(listLocalFiles(folder, config.exclude).filter((path) => skills.has(skillOf(path))));
80
+ const ctx: Cycle = { config, folder, state, report, reconciler, skills };
81
+
82
+ /**
83
+ * Files deleted here since the last cycle, worked out before anything is fetched.
84
+ *
85
+ * A push does not advance this machine's cursor, so the next pull sees its own earlier write as news.
86
+ * With the file now gone from disk that looked like "remote has it, we do not" and the pull put it
87
+ * straight back, undoing the delete every time. Deciding what was deleted up front, and refusing to
88
+ * recreate those paths, is what lets the push below turn them into tombstones.
89
+ */
90
+ const pendingDeletes = new Set(Object.keys(state.known).filter((path) => !present.has(path)));
91
+
92
+ // `present` is passed through both passes and kept current: the pull removes tombstoned files and adds
93
+ // new ones, and a push that read a stale snapshot would try to open a file the pull had just deleted.
94
+ await pull(ctx, pendingDeletes, present);
95
+ await push(ctx, present);
96
+ return report;
97
+ }
98
+
99
+ interface Cycle {
100
+ readonly config: Config;
101
+ readonly folder: string;
102
+ readonly state: State;
103
+ readonly report: Report;
104
+ readonly reconciler: Reconciler | undefined;
105
+ readonly skills: ReadonlySet<string>;
106
+ }
107
+
108
+ async function pull(ctx: Cycle, pendingDeletes: Set<string>, present: Set<string>): Promise<void> {
109
+ const { config, folder, state, report } = ctx;
110
+ const response = await call(config, `/files?since=${state.cursor}`);
111
+ const body = await response.json() as { cursor: number; files: RemoteFile[] };
112
+
113
+ for (const file of body.files) {
114
+ // The server is not trusted to supply a path either: one bad row would otherwise write outside the
115
+ // skills directory on every machine that syncs.
116
+ const badPath = invalidPathReason(file.path);
117
+ if (badPath) {
118
+ report.rejected.push(`${file.path} (${badPath})`);
119
+ continue;
120
+ }
121
+ // Local to this folder: the server's version is not wanted here.
122
+ if (!ctx.skills.has(skillOf(file.path))) continue;
123
+ const local = join(folder, file.path);
124
+ // Deleted here and not yet reported; the push pass owns this path for the rest of the cycle.
125
+ if (pendingDeletes.has(file.path)) continue;
126
+
127
+ if (file.deleted) {
128
+ if (existsSync(local)) {
129
+ // Never destroy content this machine never agreed with the server on. A file written here that
130
+ // the server happens to have tombstoned — a fresh machine, or one that was offline through the
131
+ // delete — is not the same file, and deleting it silently would lose work nobody could recover.
132
+ const localHash = hashBytes(readFileSync(local));
133
+ if (localHash !== state.known[file.path]) {
134
+ writeFileSync(`${local}${CONFLICT_SUFFIX}`, readFileSync(local));
135
+ report.conflicts.push(file.path);
136
+ }
137
+ rmSync(local, { force: true });
138
+ report.deletedLocally.push(file.path);
139
+ }
140
+ present.delete(file.path);
141
+ delete state.known[file.path];
142
+ continue;
143
+ }
144
+
145
+ const localHash = existsSync(local) ? hashBytes(readFileSync(local)) : undefined;
146
+ if (localHash === file.hash) {
147
+ // Already identical — record agreement so the push pass does not treat it as a local edit.
148
+ state.known[file.path] = file.hash!;
149
+ continue;
150
+ }
151
+
152
+ const content = new Uint8Array(await (await call(config, `/content/${file.hash}`)).arrayBuffer());
153
+
154
+ // Changed here and on the server. Merge the two when possible: the merge is written here, recorded as
155
+ // based on the server's copy, and so goes out in this cycle's push.
156
+ if (localHash !== undefined && localHash !== state.known[file.path]) {
157
+ if (await mergeInto(ctx, file, local, content)) {
158
+ present.add(file.path);
159
+ continue;
160
+ }
161
+ // No merge: a local edit we have not pushed yet would be destroyed by overwriting, so it is kept
162
+ // beside the incoming copy.
163
+ writeFileSync(`${local}${CONFLICT_SUFFIX}`, readFileSync(local));
164
+ report.conflicts.push(file.path);
165
+ }
166
+
167
+ mkdirSync(dirname(local), { recursive: true });
168
+ writeFileSync(local, content);
169
+ state.known[file.path] = file.hash!;
170
+ present.add(file.path);
171
+ report.pulled.push(file.path);
172
+ }
173
+
174
+ state.cursor = body.cursor;
175
+ }
176
+
177
+ /**
178
+ * Merges this machine's copy with the server's and writes the result over the local file. False when
179
+ * there is no merger, either side is not text, or the merge failed — the caller then keeps `.local`.
180
+ */
181
+ async function mergeInto(ctx: Cycle, file: RemoteFile, local: string, remote: Uint8Array): Promise<boolean> {
182
+ const { config, folder, state, report, reconciler } = ctx;
183
+ if (reconciler === undefined) return false;
184
+
185
+ const mine = readFileSync(local);
186
+ const localText = asText(mine);
187
+ const remoteText = asText(remote);
188
+ if (localText === undefined || remoteText === undefined) return false;
189
+
190
+ const known = state.known[file.path];
191
+ const base = known === undefined ? null : await fetchText(config, known);
192
+ const conflict: Conflict = {
193
+ folder,
194
+ path: file.path,
195
+ local: {
196
+ content: localText,
197
+ label: `the copy in ${folder} on ${config.machine}, last edited ${statSync(local).mtime.toISOString()}`,
198
+ },
199
+ remote: {
200
+ content: remoteText,
201
+ label: `the synced copy, last uploaded by ${file.updatedBy} at ${new Date(file.updatedAt).toISOString()}`,
202
+ },
203
+ base,
204
+ };
205
+
206
+ backUp(conflict);
207
+ reconciler.onMerging?.(file.path);
208
+ let resolution: Resolution;
209
+ try {
210
+ resolution = await reconciler.merge(conflict);
211
+ } catch (cause) {
212
+ const reason = cause instanceof Error ? cause.message : String(cause);
213
+ report.mergeFailures.push(`${file.path}: ${reason}`);
214
+ report.reconciliations.push({
215
+ path: file.path, outcome: "failed",
216
+ conflict: "This machine's copy and the server's differ.",
217
+ resolution: `Not merged: ${reason}. The server's version was kept and this machine's saved as ${file.path}${CONFLICT_SUFFIX}.`,
218
+ });
219
+ return false;
220
+ }
221
+ writeFileSync(local, resolution.merged);
222
+ state.known[file.path] = file.hash!;
223
+ report.merged.push(file.path);
224
+ report.reconciliations.push({
225
+ path: file.path, outcome: "merged", conflict: resolution.conflict, resolution: resolution.resolution,
226
+ });
227
+ return true;
228
+ }
229
+
230
+ /** Text only: a merge by a language model is meaningless for an image or a binary, and harmful. */
231
+ function asText(bytes: Uint8Array): string | undefined {
232
+ if (bytes.length > 512 * 1024 || bytes.includes(0)) return undefined;
233
+ try {
234
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
235
+ } catch {
236
+ return undefined;
237
+ }
238
+ }
239
+
240
+ /** The base is a nicety for the merge; if the server no longer has it, merge without one. */
241
+ async function fetchText(config: Config, hash: string): Promise<string | null> {
242
+ try {
243
+ return asText(new Uint8Array(await (await call(config, `/content/${hash}`)).arrayBuffer())) ?? null;
244
+ } catch {
245
+ return null;
246
+ }
247
+ }
248
+
249
+ async function push(ctx: Cycle, present: Set<string>): Promise<void> {
250
+ const { config, folder, state, report } = ctx;
251
+ for (const path of present) {
252
+ const bytes = readFileSync(join(folder, path));
253
+ const hash = hashBytes(bytes);
254
+ if (state.known[path] === hash) continue;
255
+
256
+ const accepted = await put(config, {
257
+ path,
258
+ baseHash: state.known[path] ?? null,
259
+ hash,
260
+ content: Buffer.from(bytes).toString("base64"),
261
+ });
262
+ if (accepted) {
263
+ state.known[path] = hash;
264
+ report.pushed.push(path);
265
+ } else {
266
+ // Another machine wrote first. Not a conflict yet: the next cycle pulls its version and merges.
267
+ report.retry = true;
268
+ }
269
+ }
270
+
271
+ // A path we previously agreed on that is no longer on disk was deleted here, and the server needs the
272
+ // tombstone or the next machine to sync will upload it back.
273
+ for (const path of Object.keys(state.known)) {
274
+ if (present.has(path)) continue;
275
+ if (await put(config, { path, baseHash: state.known[path]!, hash: null, content: null })) {
276
+ delete state.known[path];
277
+ report.pushed.push(`${path} (deleted)`);
278
+ } else {
279
+ report.conflicts.push(path);
280
+ }
281
+ }
282
+ }
283
+
284
+ /** True when the server accepted the write; false means another machine got there first. */
285
+ async function put(config: Config, body: {
286
+ path: string; baseHash: string | null; hash: string | null; content: string | null;
287
+ }): Promise<boolean> {
288
+ const response = await fetch(`${config.endpoint}/files`, {
289
+ method: "PUT",
290
+ headers: { authorization: `Bearer ${config.token}`, "content-type": "application/json" },
291
+ body: JSON.stringify({ ...body, machine: config.machine }),
292
+ });
293
+ if (response.status === 409) return false;
294
+ if (!response.ok) throw new Error(`PUT /files ${body.path}: ${response.status} ${await response.text()}`);
295
+ return true;
296
+ }
297
+
298
+ async function call(config: Config, path: string): Promise<Response> {
299
+ const response = await fetch(`${config.endpoint}${path}`, {
300
+ headers: { authorization: `Bearer ${config.token}` },
301
+ });
302
+ if (!response.ok) throw new Error(`GET ${path}: ${response.status} ${await response.text()}`);
303
+ return response;
304
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * The shapes the daemon and the service exchange.
3
+ *
4
+ * Declared in the library and imported by the service so there is one definition rather than two that
5
+ * drift. The service depends on the library; nothing goes the other way.
6
+ */
7
+
8
+ export interface RemoteFile {
9
+ readonly path: string;
10
+ /** Null when the file is deleted; the row stays as a tombstone. */
11
+ readonly hash: string | null;
12
+ readonly deleted: boolean;
13
+ readonly updatedAt: number;
14
+ readonly updatedBy: string;
15
+ }
16
+
17
+ export interface ListFilesResponse {
18
+ /** Highest sequence number in this page; the caller sends it back as `since`. */
19
+ readonly cursor: number;
20
+ readonly files: ReadonlyArray<RemoteFile>;
21
+ }
22
+
23
+ export interface PutFileRequest {
24
+ readonly path: string;
25
+ /** What the client believes the server holds. The write is refused if that is no longer true. */
26
+ readonly baseHash: string | null;
27
+ /** Null to delete. */
28
+ readonly hash: string | null;
29
+ /** Base64, absent for a delete. */
30
+ readonly content: string | null;
31
+ readonly machine: string;
32
+ }
33
+
34
+ /**
35
+ * Where a folder's sync stands, as the dashboard shows it.
36
+ *
37
+ * `reconciling` means Claude is merging two versions of a file on that machine; `attention` means a cycle
38
+ * failed or a conflict could not be merged, and `detail` says which.
39
+ */
40
+ export type FolderState = "idle" | "reconciling" | "attention";
41
+
42
+ /**
43
+ * A skill is a top-level entry of a skills folder — usually a directory like `jive/` holding `SKILL.md`
44
+ * and its files — and it is the unit of opting in: all of its files sync, or none do, so a skill never
45
+ * arrives half-present on another machine.
46
+ */
47
+ export const skillOf = (path: string): string => path.split("/")[0]!;
48
+
49
+ /** A file and the SHA-256 of its content — the same hash the server stores, so copies can be compared. */
50
+ export interface FileHash {
51
+ readonly path: string;
52
+ readonly hash: string;
53
+ }
54
+
55
+ /** One skill as a folder holds it. */
56
+ export interface LocalSkill {
57
+ readonly name: string;
58
+ readonly files: ReadonlyArray<FileHash>;
59
+ }
60
+
61
+ /**
62
+ * `POST /folders`: every folder this machine found, with the skills in each, for the dashboard. Answered
63
+ * with the enabled folders, each with the synced list: the daemon pushes and pulls exactly those skills,
64
+ * and never touches any other.
65
+ */
66
+ export interface FoldersRequest {
67
+ readonly folders: ReadonlyArray<{ readonly path: string; readonly skills: ReadonlyArray<LocalSkill> }>;
68
+ }
69
+ export interface FolderPrefs {
70
+ readonly path: string;
71
+ readonly skills: ReadonlyArray<string>;
72
+ }
73
+ export interface FoldersResponse {
74
+ readonly folders: ReadonlyArray<FolderPrefs>;
75
+ }
76
+
77
+ /** `POST /reconciliations`: one merge Claude did, or failed to do, for the audit log. */
78
+ export interface ReconciliationRequest {
79
+ readonly folder: string;
80
+ readonly path: string;
81
+ readonly outcome: "merged" | "failed";
82
+ /** What differed between the two versions, in a sentence or two. */
83
+ readonly conflict: string;
84
+ /** What the merge kept, dropped or reworded — or why it failed. */
85
+ readonly resolution: string;
86
+ }
87
+
88
+ /** Daemon → hub, over the daemon's WebSocket. */
89
+ export type DaemonMessage = {
90
+ readonly type: "status";
91
+ readonly folder: string;
92
+ readonly state: FolderState;
93
+ readonly detail?: string;
94
+ /** A one-line summary of what the last cycle changed, when it changed anything. */
95
+ readonly activity?: string;
96
+ };
97
+
98
+ /** Hub → daemon. `prefs` replaces what syncs; `changed` means another write landed on the server. */
99
+ export type HubToDaemon =
100
+ | { readonly type: "prefs"; readonly folders: ReadonlyArray<FolderPrefs> }
101
+ | { readonly type: "changed" };
102
+
103
+ export interface SkillView {
104
+ readonly name: string;
105
+ /** On the list of synced skills, which every enabled folder sends and receives. */
106
+ readonly synced: boolean;
107
+ /** The folder has a copy on disk. */
108
+ readonly local: boolean;
109
+ readonly files: ReadonlyArray<FileHash>;
110
+ }
111
+
112
+ export interface FolderView {
113
+ readonly path: string;
114
+ readonly enabled: boolean;
115
+ readonly state: FolderState;
116
+ readonly detail: string | null;
117
+ readonly lastActivityAt: number | null;
118
+ readonly lastActivity: string | null;
119
+ readonly skills: ReadonlyArray<SkillView>;
120
+ }
121
+
122
+ export interface MachineView {
123
+ /** The name the machine's daemon uses; it identifies the machine and never changes. */
124
+ readonly machine: string;
125
+ /** What the dashboard calls it: renamed there, or the machine name until then. */
126
+ readonly name: string;
127
+ /** True while this machine's daemon holds a connection to the hub. */
128
+ readonly online: boolean;
129
+ readonly lastSeen: number | null;
130
+ readonly folders: ReadonlyArray<FolderView>;
131
+ }
132
+
133
+ /** A skill on the synced list, as the server holds it: the version every enabled folder converges on. */
134
+ export interface SyncedSkill {
135
+ readonly name: string;
136
+ readonly files: ReadonlyArray<FileHash & { readonly updatedBy: string; readonly updatedAt: number }>;
137
+ }
138
+
139
+ export interface ReconciliationView extends ReconciliationRequest {
140
+ readonly at: number;
141
+ readonly machine: string;
142
+ }
143
+
144
+ /** Hub → dashboard: the whole picture, resent on every change. It is small, and a diff protocol is not. */
145
+ export interface Snapshot {
146
+ readonly type: "snapshot";
147
+ /** The deployed service version. A dashboard built by another version reloads itself. */
148
+ readonly version: string;
149
+ /** Display name for every machine name that appears anywhere in the snapshot, including old log entries. */
150
+ readonly names: Readonly<Record<string, string>>;
151
+ readonly skills: ReadonlyArray<SyncedSkill>;
152
+ readonly reconciliations: ReadonlyArray<ReconciliationView>;
153
+ readonly machines: ReadonlyArray<MachineView>;
154
+ }
155
+
156
+ /** Dashboard → hub. */
157
+ export type DashboardMessage =
158
+ | { readonly type: "setFolder"; readonly machine: string; readonly path: string; readonly enabled: boolean }
159
+ /** Adds a skill to the synced list, or removes it: one list for every machine and folder. */
160
+ | { readonly type: "setSkill"; readonly skill: string; readonly synced: boolean }
161
+ /** An empty name restores the machine name. */
162
+ | { readonly type: "renameMachine"; readonly machine: string; readonly name: string };
package/multi-call-binary DELETED
@@ -1,4 +0,0 @@
1
- [diffend] Oversized file quarantined before diffing.
2
- name: package/multi-call-binary
3
- size: 100183520 bytes
4
- sha256: 98012a0df78fed960034a42125b16ee199dbf84a19d4cb13889fb037cd51ab13