@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.
- package/README.md +60 -0
- package/bin/package.json +1 -0
- package/bin/skill-sync +571 -0
- package/bin/skill-sync-daemon +571 -0
- package/dist/daemons/skill-sync-daemon.js +401 -0
- package/dist/daemons/skill-sync-daemon.js.map +1 -0
- package/dist/entrypoints/skill-sync.js +114 -0
- package/dist/entrypoints/skill-sync.js.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/config.js +134 -0
- package/dist/internal/config.js.map +1 -0
- package/dist/internal/control.js +101 -0
- package/dist/internal/control.js.map +1 -0
- package/dist/internal/folders.js +60 -0
- package/dist/internal/folders.js.map +1 -0
- package/dist/internal/notify.js +50 -0
- package/dist/internal/notify.js.map +1 -0
- package/dist/internal/path.js +36 -0
- package/dist/internal/path.js.map +1 -0
- package/dist/internal/reconcile.js +124 -0
- package/dist/internal/reconcile.js.map +1 -0
- package/dist/internal/sync.js +241 -0
- package/dist/internal/sync.js.map +1 -0
- package/dist/internal/wire.js +13 -0
- package/dist/internal/wire.js.map +1 -0
- package/install.cjs +571 -0
- package/package.json +39 -11
- package/src/daemons/skill-sync-daemon.ts +397 -0
- package/src/entrypoints/skill-sync.ts +116 -0
- package/src/index.ts +15 -0
- package/src/internal/config.ts +171 -0
- package/src/internal/control.ts +123 -0
- package/src/internal/folders.ts +64 -0
- package/src/internal/notify.ts +53 -0
- package/src/internal/path.ts +32 -0
- package/src/internal/reconcile.ts +166 -0
- package/src/internal/sync.ts +304 -0
- package/src/internal/wire.ts +162 -0
- package/multi-call-binary +0 -4
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { homedir, hostname, userInfo } from "node:os";
|
|
4
|
+
import { join, relative, sep } from "node:path";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Per-machine settings and sync state, both under `~/.jive/`. The sync token is not here: it lives only in
|
|
8
|
+
* the running daemon's memory.
|
|
9
|
+
*
|
|
10
|
+
* A file rather than environment variables: the endpoint and which machine this is differ per computer
|
|
11
|
+
* and have to survive a login, which is exactly what a config file is for and exactly what an environment
|
|
12
|
+
* variable is not. Which folders to sync is not in here: they are detected, and the choice to leave one
|
|
13
|
+
* out is kept on the server so the dashboard can change it and a reinstalled daemon still remembers it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const JIVE_HOME = join(homedir(), ".jive");
|
|
17
|
+
export const CONFIG_PATH = join(JIVE_HOME, "skill-sync.json");
|
|
18
|
+
const STATE_PATH = join(JIVE_HOME, "skill-sync-state.json");
|
|
19
|
+
/**
|
|
20
|
+
* Where versions before 0.3 kept the sync token. The token now lives only in the running daemon's memory,
|
|
21
|
+
* so the daemon deletes this file whenever it finds one: a credential left on disk is one anything that
|
|
22
|
+
* can read the home directory can copy.
|
|
23
|
+
*/
|
|
24
|
+
export const LEGACY_TOKEN_PATH = join(homedir(), ".skill-sync-token.json");
|
|
25
|
+
/**
|
|
26
|
+
* How the CLI reaches the running daemon: a Unix socket in `~/.jive/`, or a named pipe on Windows. Only
|
|
27
|
+
* this user can open either, which is what lets the CLI hand the daemon work that involves the token.
|
|
28
|
+
*/
|
|
29
|
+
export const CONTROL_SOCKET = process.platform === "win32"
|
|
30
|
+
? `\\\\.\\pipe\\jive-skill-sync-${userInfo().username}`
|
|
31
|
+
: join(JIVE_HOME, "skill-sync.sock");
|
|
32
|
+
|
|
33
|
+
/** Managed by Claude's own cloud sync; see `Settings.exclude`. */
|
|
34
|
+
const DEFAULT_EXCLUDE = ["synced"] as const;
|
|
35
|
+
|
|
36
|
+
/** What `~/.jive/skill-sync.json` holds: nothing secret, so it can live in a file. */
|
|
37
|
+
export interface Settings {
|
|
38
|
+
readonly endpoint: string;
|
|
39
|
+
/**
|
|
40
|
+
* Top-level directory names under each synced folder to leave alone.
|
|
41
|
+
*
|
|
42
|
+
* `synced` holds the skills Claude already pulls from the cloud itself. Syncing them here would mean
|
|
43
|
+
* two systems writing the same files, each undoing the other, and none of them are yours to edit.
|
|
44
|
+
*/
|
|
45
|
+
readonly exclude: ReadonlyArray<string>;
|
|
46
|
+
/** Names this machine in conflict messages, so "who overwrote this" has an answer. */
|
|
47
|
+
readonly machine: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Settings plus the token, which exists only in the daemon's memory after a sign-in. */
|
|
51
|
+
export interface Config extends Settings {
|
|
52
|
+
readonly token: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Sync progress for one folder. Each folder syncs with the server on its own, so each has its own. */
|
|
56
|
+
export interface State {
|
|
57
|
+
/** Highest server sequence number already applied. */
|
|
58
|
+
cursor: number;
|
|
59
|
+
/** Last hash this machine agreed with the server on, per relative path. Absent means never synced. */
|
|
60
|
+
known: Record<string, string>;
|
|
61
|
+
/** The synced list as of last cycle, to notice a skill being added to it. */
|
|
62
|
+
skills?: string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Every folder's `State`, keyed by the folder's absolute path. */
|
|
66
|
+
export type States = Record<string, State>;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The settings, writing a template on first run. Undefined when the file lacks an endpoint or a machine
|
|
70
|
+
* name, which only happens if someone edited it.
|
|
71
|
+
*/
|
|
72
|
+
export function loadSettings(): Settings | undefined {
|
|
73
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
74
|
+
mkdirSync(JIVE_HOME, { recursive: true });
|
|
75
|
+
writeFileSync(CONFIG_PATH, `${JSON.stringify({
|
|
76
|
+
endpoint: "https://skill-sync.jive.sh",
|
|
77
|
+
machine: machineName(),
|
|
78
|
+
exclude: DEFAULT_EXCLUDE,
|
|
79
|
+
}, null, 2)}\n`);
|
|
80
|
+
}
|
|
81
|
+
const settings = readJson<Partial<Settings>>(CONFIG_PATH);
|
|
82
|
+
if (!settings.endpoint || !settings.machine) return undefined;
|
|
83
|
+
return { endpoint: settings.endpoint, machine: settings.machine, exclude: settings.exclude ?? DEFAULT_EXCLUDE };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readJson<T>(path: string): T {
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(readFileSync(path, "utf-8")) as T;
|
|
89
|
+
} catch {
|
|
90
|
+
return {} as T;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Every folder's sync state.
|
|
96
|
+
*
|
|
97
|
+
* Before folders were detected the file held one folder's `{ cursor, known }`, for the folder then named
|
|
98
|
+
* by `skillsDir` in the settings. That state is carried over to that folder: dropping it would make the
|
|
99
|
+
* folder look never-synced, and every file in it that differs from the server would become a conflict.
|
|
100
|
+
*/
|
|
101
|
+
export function loadStates(): States {
|
|
102
|
+
const stored = readJson<Partial<State> & { folders?: States }>(STATE_PATH);
|
|
103
|
+
if (stored.folders !== undefined) return stored.folders;
|
|
104
|
+
const legacyFolder = readJson<{ skillsDir?: string }>(CONFIG_PATH).skillsDir;
|
|
105
|
+
if (legacyFolder !== undefined && stored.cursor !== undefined && stored.known !== undefined) {
|
|
106
|
+
return { [legacyFolder]: { cursor: stored.cursor, known: stored.known } };
|
|
107
|
+
}
|
|
108
|
+
return {};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function stateFor(states: States, folder: string): State {
|
|
112
|
+
states[folder] ??= { cursor: 0, known: {} };
|
|
113
|
+
return states[folder];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function saveStates(states: States): void {
|
|
117
|
+
mkdirSync(JIVE_HOME, { recursive: true });
|
|
118
|
+
writeFileSync(STATE_PATH, `${JSON.stringify({ folders: states }, null, 2)}\n`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `HOSTNAME` is not set on macOS, so the template was writing "unknown" on exactly this machine. */
|
|
122
|
+
function machineName(): string {
|
|
123
|
+
return hostname().replace(/\.local$/, "").replace(/[^A-Za-z0-9-]+/g, "-") || "unknown";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The `skills` folder of every Claude home on this machine: `~/.claude` and each `~/.claude-<name>`.
|
|
128
|
+
*
|
|
129
|
+
* Every home is a candidate, whether or not it has a `skills` folder yet, so a Claude account added
|
|
130
|
+
* later joins without anyone listing it. Which of them actually sync is the user's choice on the
|
|
131
|
+
* dashboard; a folder that is synced and missing is created.
|
|
132
|
+
*/
|
|
133
|
+
export function detectFolders(): string[] {
|
|
134
|
+
return readdirSync(homedir())
|
|
135
|
+
.filter((entry) => entry === ".claude" || entry.startsWith(".claude-"))
|
|
136
|
+
.filter((entry) => statSync(join(homedir(), entry)).isDirectory())
|
|
137
|
+
.map((entry) => join(homedir(), entry, "skills"))
|
|
138
|
+
.sort();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function hashBytes(bytes: Uint8Array): string {
|
|
142
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Suffix for the copy kept when an incoming change would have overwritten a local edit. */
|
|
146
|
+
export const CONFLICT_SUFFIX = ".local";
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Every file under a synced folder, as paths relative to it with forward slashes.
|
|
150
|
+
*
|
|
151
|
+
* Normalised because the same skill synced from Windows and macOS must be one row, not two.
|
|
152
|
+
*
|
|
153
|
+
* Conflict copies are skipped. They are this machine's evidence of a collision, not content anyone else
|
|
154
|
+
* asked for, and syncing them spreads one machine's mess to every other machine.
|
|
155
|
+
*/
|
|
156
|
+
export function listLocalFiles(folder: string, exclude: ReadonlyArray<string> = DEFAULT_EXCLUDE): string[] {
|
|
157
|
+
if (!existsSync(folder)) return [];
|
|
158
|
+
|
|
159
|
+
const found: string[] = [];
|
|
160
|
+
const walk = (dir: string, top: boolean): void => {
|
|
161
|
+
for (const entry of readdirSync(dir)) {
|
|
162
|
+
if (entry.startsWith(".")) continue;
|
|
163
|
+
if (top && exclude.includes(entry)) continue;
|
|
164
|
+
const full = join(dir, entry);
|
|
165
|
+
if (statSync(full).isDirectory()) walk(full, false);
|
|
166
|
+
else if (!entry.endsWith(CONFLICT_SUFFIX)) found.push(relative(folder, full).split(sep).join("/"));
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
walk(folder, true);
|
|
170
|
+
return found.sort();
|
|
171
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createConnection, createServer, type Server, type Socket } from "node:net";
|
|
2
|
+
import { chmodSync, rmSync } from "node:fs";
|
|
3
|
+
import { CONTROL_SOCKET } from "./config";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The channel between the CLI and the running daemon.
|
|
7
|
+
*
|
|
8
|
+
* The daemon owns the sync token: it receives it from the browser and keeps it only in memory, so it is
|
|
9
|
+
* never in a file and never passes through the CLI. The CLI's `login` and `logout` are requests to the
|
|
10
|
+
* daemon over this channel. Messages are newline-delimited JSON; each request gets events until one that
|
|
11
|
+
* ends it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type ControlRequest =
|
|
15
|
+
| { readonly type: "login" }
|
|
16
|
+
| { readonly type: "logout" };
|
|
17
|
+
|
|
18
|
+
export type ControlEvent =
|
|
19
|
+
/** Sign-in has started: the CLI opens this URL in the browser, where the passkey is verified. */
|
|
20
|
+
| { readonly type: "open"; readonly url: string }
|
|
21
|
+
| { readonly type: "signedIn"; readonly machine: string }
|
|
22
|
+
| { readonly type: "signedOut"; readonly revoked: boolean }
|
|
23
|
+
| { readonly type: "error"; readonly message: string };
|
|
24
|
+
|
|
25
|
+
const isFinal = (event: ControlEvent): boolean => event.type !== "open";
|
|
26
|
+
|
|
27
|
+
/** The daemon is not running, as opposed to running and refusing the request. */
|
|
28
|
+
export class DaemonUnreachable extends Error {}
|
|
29
|
+
|
|
30
|
+
/** Sends one request and calls `onEvent` for each event until the final one, which it resolves with. */
|
|
31
|
+
export function request(message: ControlRequest, onEvent: (event: ControlEvent) => void): Promise<ControlEvent> {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
let settled = false;
|
|
34
|
+
const socket = createConnection(CONTROL_SOCKET);
|
|
35
|
+
socket.on("connect", () => socket.write(`${JSON.stringify(message)}\n`));
|
|
36
|
+
socket.on("error", (cause: NodeJS.ErrnoException) => {
|
|
37
|
+
if (settled) return;
|
|
38
|
+
settled = true;
|
|
39
|
+
reject(cause.code === "ENOENT" || cause.code === "ECONNREFUSED"
|
|
40
|
+
? new DaemonUnreachable(`the skill-sync daemon is not running (${cause.code} on ${CONTROL_SOCKET})`)
|
|
41
|
+
: cause);
|
|
42
|
+
});
|
|
43
|
+
readLines(socket, (line) => {
|
|
44
|
+
const event = JSON.parse(line) as ControlEvent;
|
|
45
|
+
onEvent(event);
|
|
46
|
+
if (isFinal(event) && !settled) {
|
|
47
|
+
settled = true;
|
|
48
|
+
socket.end();
|
|
49
|
+
resolve(event);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
socket.on("close", () => {
|
|
53
|
+
if (!settled) {
|
|
54
|
+
settled = true;
|
|
55
|
+
reject(new Error("the daemon closed the connection without answering"));
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One request being answered: send events, and end the conversation with a final one. */
|
|
62
|
+
export interface Reply {
|
|
63
|
+
readonly send: (event: ControlEvent) => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Listens on the control socket. Resolves false, without listening, when another live daemon already
|
|
68
|
+
* holds it: two installs of skill-sync mean two daemons, and the second stands down rather than fight.
|
|
69
|
+
*/
|
|
70
|
+
export async function serve(handle: (message: ControlRequest, reply: Reply) => void): Promise<Server | false> {
|
|
71
|
+
if (await answers()) return false;
|
|
72
|
+
// Nothing answered, so a socket file left here is from a daemon that died; it would block the listen.
|
|
73
|
+
if (process.platform !== "win32") rmSync(CONTROL_SOCKET, { force: true });
|
|
74
|
+
|
|
75
|
+
const server = createServer((socket) => {
|
|
76
|
+
const reply: Reply = {
|
|
77
|
+
send: (event) => {
|
|
78
|
+
if (socket.writable) socket.write(`${JSON.stringify(event)}\n`);
|
|
79
|
+
if (isFinal(event)) socket.end();
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
readLines(socket, (line) => {
|
|
83
|
+
try {
|
|
84
|
+
handle(JSON.parse(line) as ControlRequest, reply);
|
|
85
|
+
} catch (cause) {
|
|
86
|
+
reply.send({ type: "error", message: cause instanceof Error ? cause.message : String(cause) });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
socket.on("error", () => undefined); // A CLI that went away mid-request; nothing to tell it.
|
|
90
|
+
});
|
|
91
|
+
await new Promise<void>((resolve, reject) => {
|
|
92
|
+
server.once("error", reject);
|
|
93
|
+
server.listen(CONTROL_SOCKET, () => resolve());
|
|
94
|
+
});
|
|
95
|
+
// Only this user may talk to the daemon that holds their token.
|
|
96
|
+
if (process.platform !== "win32") chmodSync(CONTROL_SOCKET, 0o600);
|
|
97
|
+
return server;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Whether a live daemon is listening on the control socket. */
|
|
101
|
+
function answers(): Promise<boolean> {
|
|
102
|
+
return new Promise((resolve) => {
|
|
103
|
+
const socket = createConnection(CONTROL_SOCKET);
|
|
104
|
+
socket.on("connect", () => {
|
|
105
|
+
socket.end();
|
|
106
|
+
resolve(true);
|
|
107
|
+
});
|
|
108
|
+
socket.on("error", () => resolve(false));
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readLines(socket: Socket, onLine: (line: string) => void): void {
|
|
113
|
+
let buffer = "";
|
|
114
|
+
socket.on("data", (chunk) => {
|
|
115
|
+
buffer += chunk.toString();
|
|
116
|
+
let newline: number;
|
|
117
|
+
while ((newline = buffer.indexOf("\n")) >= 0) {
|
|
118
|
+
const line = buffer.slice(0, newline).trim();
|
|
119
|
+
buffer = buffer.slice(newline + 1);
|
|
120
|
+
if (line !== "") onLine(line);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { detectFolders, hashBytes, listLocalFiles, type Config } 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): LocalSkill[] {
|
|
20
|
+
const byName = new Map<string, FileHash[]>();
|
|
21
|
+
for (const path of listLocalFiles(folder, config.exclude)) {
|
|
22
|
+
const name = skillOf(path);
|
|
23
|
+
let hash: string;
|
|
24
|
+
try {
|
|
25
|
+
hash = hashBytes(readFileSync(join(folder, path)));
|
|
26
|
+
} catch {
|
|
27
|
+
continue; // Deleted between listing and reading; the next check reports the folder as it is.
|
|
28
|
+
}
|
|
29
|
+
byName.set(name, [...(byName.get(name) ?? []), { path, hash }]);
|
|
30
|
+
}
|
|
31
|
+
return [...byName].map(([name, files]) => ({ name, files }));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Everything registration sends, so a caller can tell when it has changed and is worth re-sending. */
|
|
35
|
+
export function describe(config: Config): FoldersRequest {
|
|
36
|
+
return { folders: detectFolders().map((path) => ({ path, skills: localSkills(path, config) })) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Registers this machine's folders and skills; returns the folders to sync and the skills in each. */
|
|
40
|
+
export async function register(config: Config, request: FoldersRequest): Promise<ReadonlyArray<FolderPrefs>> {
|
|
41
|
+
const response = await fetch(`${config.endpoint}/folders`, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: authHeaders(config, { "content-type": "application/json" }),
|
|
44
|
+
body: JSON.stringify(request),
|
|
45
|
+
});
|
|
46
|
+
if (!response.ok) throw new Error(`POST /folders: ${response.status} ${await response.text()}`);
|
|
47
|
+
return (await response.json() as FoldersResponse).folders;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Sends merges to the audit log. A failure here is logged by the caller, never fatal to a sync. */
|
|
51
|
+
export async function logReconciliations(config: Config, entries: ReadonlyArray<ReconciliationRequest>): Promise<void> {
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
const response = await fetch(`${config.endpoint}/reconciliations`, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: authHeaders(config, { "content-type": "application/json" }),
|
|
56
|
+
body: JSON.stringify(entry),
|
|
57
|
+
});
|
|
58
|
+
if (!response.ok) throw new Error(`POST /reconciliations: ${response.status} ${await response.text()}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function authHeaders(config: Config, extra: Record<string, string>): Record<string, string> {
|
|
63
|
+
return { authorization: `Bearer ${config.token}`, "x-skill-sync-machine": config.machine, ...extra };
|
|
64
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A desktop notification, best effort.
|
|
5
|
+
*
|
|
6
|
+
* Best effort is the whole contract: a daemon that cannot tell you about a conflict is worse than one
|
|
7
|
+
* that cannot sync, but a daemon that *dies* because a notification helper is missing is worse than
|
|
8
|
+
* both. Every failure here is swallowed.
|
|
9
|
+
*
|
|
10
|
+
* This works because these daemons are registered per-user and run inside the logged-in session — a
|
|
11
|
+
* LaunchAgent, a `systemctl --user` unit, a scheduled task. A system-wide service would have no desktop
|
|
12
|
+
* to post to.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** No shell anywhere below: the arguments carry file paths, and a path is not a safe thing to interpolate. */
|
|
16
|
+
function run(command: string, args: ReadonlyArray<string>): void {
|
|
17
|
+
try {
|
|
18
|
+
spawn(command, [...args], { stdio: "ignore", detached: true }).unref();
|
|
19
|
+
} catch {
|
|
20
|
+
// Swallowed on purpose; see above.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function notify(title: string, body: string): void {
|
|
25
|
+
switch (process.platform) {
|
|
26
|
+
case "darwin":
|
|
27
|
+
// AppleScript string literals take double quotes and backslashes as escapes, and nothing else.
|
|
28
|
+
return run("osascript", [
|
|
29
|
+
"-e",
|
|
30
|
+
`display notification "${escapeAppleScript(body)}" with title "${escapeAppleScript(title)}"`,
|
|
31
|
+
]);
|
|
32
|
+
case "linux":
|
|
33
|
+
return run("notify-send", [title, body]);
|
|
34
|
+
case "win32":
|
|
35
|
+
return run("powershell", [
|
|
36
|
+
"-NoProfile", "-NonInteractive", "-Command",
|
|
37
|
+
"[reflection.assembly]::LoadWithPartialName('System.Windows.Forms') > $null;"
|
|
38
|
+
+ "$n = New-Object System.Windows.Forms.NotifyIcon;"
|
|
39
|
+
+ "$n.Icon = [System.Drawing.SystemIcons]::Warning; $n.Visible = $true;"
|
|
40
|
+
+ `$n.ShowBalloonTip(10000, '${escapeSingleQuotes(title)}', '${escapeSingleQuotes(body)}', 'Warning')`,
|
|
41
|
+
]);
|
|
42
|
+
default:
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function escapeAppleScript(value: string): string {
|
|
48
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function escapeSingleQuotes(value: string): string {
|
|
52
|
+
return value.replace(/'/g, "''");
|
|
53
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation for the one piece of untrusted input that becomes a filesystem path.
|
|
3
|
+
*
|
|
4
|
+
* Shared by the Worker and the daemon on purpose, and deliberately free of any Node import so the Worker
|
|
5
|
+
* can bundle it. Both ends check: the server so one compromised client cannot poison what every machine
|
|
6
|
+
* pulls, and the daemon because a server it does not control is not a thing to take paths from either.
|
|
7
|
+
*
|
|
8
|
+
* Without this, a pushed path of `../../.ssh/authorized_keys` is joined to the skills directory and
|
|
9
|
+
* written wherever the daemon's user can write.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Long enough for any real nesting, short enough that nothing can be used to exhaust anything. */
|
|
13
|
+
const MAX_LENGTH = 512;
|
|
14
|
+
|
|
15
|
+
export function invalidPathReason(path: string): string | undefined {
|
|
16
|
+
if (path.length === 0 || path.length > MAX_LENGTH) return "path is empty or too long";
|
|
17
|
+
if (path.includes("\0")) return "path contains a null byte";
|
|
18
|
+
if (path.includes("\\")) return "path must use forward slashes";
|
|
19
|
+
if (path.startsWith("/")) return "path must be relative";
|
|
20
|
+
// A Windows drive letter is absolute on the machine that matters even though it has no leading slash.
|
|
21
|
+
if (/^[A-Za-z]:/.test(path)) return "path must be relative";
|
|
22
|
+
|
|
23
|
+
for (const segment of path.split("/")) {
|
|
24
|
+
if (segment === "") return "path has an empty segment";
|
|
25
|
+
if (segment === "." || segment === "..") return "path must not contain . or .. segments";
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isValidPath(path: string): boolean {
|
|
31
|
+
return invalidPathReason(path) === undefined;
|
|
32
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, delimiter, dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Merges two versions of one skill file with Claude, on this machine.
|
|
8
|
+
*
|
|
9
|
+
* A conflict used to mean "the server's copy wins and yours is set aside as `.local`", which left the
|
|
10
|
+
* merge to a person who might not notice for days, while every machine ran the losing half. Skills are
|
|
11
|
+
* prose that Claude reads, so Claude is the right one to merge them: it keeps every rule either side
|
|
12
|
+
* added and resolves the ones that disagree.
|
|
13
|
+
*
|
|
14
|
+
* Claude runs with no tools, no MCP servers and no settings: it receives the versions on stdin and
|
|
15
|
+
* answers with the merged file, so it cannot touch anything else. It runs as the Claude account whose
|
|
16
|
+
* folder is being merged (`CLAUDE_CONFIG_DIR` set to that folder's home), so it never creates or writes
|
|
17
|
+
* `~/.claude` — a home the user may have deleted on purpose.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface Version {
|
|
21
|
+
readonly content: string;
|
|
22
|
+
/** Where this version came from, for the prompt: "this machine, edited …" or "MacBookPro, uploaded …". */
|
|
23
|
+
readonly label: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The merged file, and Claude's account of it for the audit log. */
|
|
27
|
+
export interface Resolution {
|
|
28
|
+
readonly merged: string;
|
|
29
|
+
/** What differed between the two versions. */
|
|
30
|
+
readonly conflict: string;
|
|
31
|
+
/** What the merge kept, dropped or reworded. */
|
|
32
|
+
readonly resolution: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Conflict {
|
|
36
|
+
readonly folder: string;
|
|
37
|
+
readonly path: string;
|
|
38
|
+
readonly local: Version;
|
|
39
|
+
readonly remote: Version;
|
|
40
|
+
/** The last version both sides agreed on, when there is one; it lets Claude see what each side changed. */
|
|
41
|
+
readonly base: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const BACKUP_ROOT = join(homedir(), ".jive", "skill-sync", "reconciled");
|
|
45
|
+
/** A long skill is tens of thousands of tokens to write back out. */
|
|
46
|
+
const TIMEOUT_MS = 15 * 60 * 1000;
|
|
47
|
+
/**
|
|
48
|
+
* A merge shorter than this fraction of the longer input is refused as probably truncated: a model that
|
|
49
|
+
* stops early produces a file that looks fine and silently drops half the rules.
|
|
50
|
+
*/
|
|
51
|
+
const MIN_LENGTH_RATIO = 0.6;
|
|
52
|
+
|
|
53
|
+
const SYSTEM_PROMPT = `You merge two versions of one file from a user's Claude skills folder into one.
|
|
54
|
+
Answer in exactly this form and nothing else:
|
|
55
|
+
<conflict>One or two plain sentences: what differs between the versions.</conflict>
|
|
56
|
+
<resolution>One or two plain sentences: what the merge kept from each, and anything dropped or reworded.</resolution>
|
|
57
|
+
<merged>
|
|
58
|
+
The complete merged file, exactly as it should be saved. No code fences.
|
|
59
|
+
</merged>
|
|
60
|
+
Rules for the merge:
|
|
61
|
+
- Keep every rule, example and fact that either version has, unless the other version clearly replaces it.
|
|
62
|
+
- Where the two disagree, prefer the newer version's wording, and keep anything only the older one has.
|
|
63
|
+
- If a base version is given, it is what both started from: use it to tell additions from deletions, and
|
|
64
|
+
honour a deletion made on one side when the other side did not edit that part.
|
|
65
|
+
- Keep the file's frontmatter and structure.
|
|
66
|
+
In <conflict> and <resolution>, call the two versions by the names they are given (for example "the copy in
|
|
67
|
+
~/.claude-chaine/skills on MacBookPro" and "the synced copy"), never "Version A" or "Version B": these are
|
|
68
|
+
read later by a person auditing the merge, who never saw this prompt.`;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Both versions are copied out of the synced folder before anything is written, so a bad merge costs a
|
|
72
|
+
* file copy, not the user's work. Outside the folder so the copies are never synced or read as skills.
|
|
73
|
+
*/
|
|
74
|
+
export function backUp(conflict: Conflict): string {
|
|
75
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
76
|
+
const dir = join(BACKUP_ROOT, stamp, basename(dirname(conflict.folder)), conflict.path);
|
|
77
|
+
mkdirSync(dir, { recursive: true });
|
|
78
|
+
writeFileSync(join(dir, "local"), conflict.local.content);
|
|
79
|
+
writeFileSync(join(dir, "remote"), conflict.remote.content);
|
|
80
|
+
if (conflict.base !== null) writeFileSync(join(dir, "base"), conflict.base);
|
|
81
|
+
return dir;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function reconcile(conflict: Conflict): Promise<Resolution> {
|
|
85
|
+
const sections = [
|
|
86
|
+
`File: ${conflict.path}`,
|
|
87
|
+
conflict.base === null ? "No base version: these two have never been in sync." : `=== the base version both started from:\n${conflict.base}`,
|
|
88
|
+
`=== ${conflict.local.label}:\n${conflict.local.content}`,
|
|
89
|
+
`=== ${conflict.remote.label}:\n${conflict.remote.content}`,
|
|
90
|
+
"Write the merged file now.",
|
|
91
|
+
];
|
|
92
|
+
const answer = await runClaude(sections.join("\n\n-----\n\n"), dirname(conflict.folder));
|
|
93
|
+
const start = answer.indexOf("<merged>");
|
|
94
|
+
const end = answer.lastIndexOf("</merged>");
|
|
95
|
+
if (start === -1 || end <= start) throw new Error("Claude's answer had no <merged> section");
|
|
96
|
+
// The file is everything between the tags, minus the newline that follows the opening one.
|
|
97
|
+
const merged = answer.slice(start + "<merged>".length, end).replace(/^\r?\n/, "");
|
|
98
|
+
const conflictText = /<conflict>([\s\S]*?)<\/conflict>/.exec(answer)?.[1]?.trim() ?? "";
|
|
99
|
+
const resolutionText = /<resolution>([\s\S]*?)<\/resolution>/.exec(answer)?.[1]?.trim() ?? "";
|
|
100
|
+
|
|
101
|
+
const longest = Math.max(conflict.local.content.length, conflict.remote.content.length);
|
|
102
|
+
if (merged.trim().length === 0) throw new Error("Claude returned an empty merge");
|
|
103
|
+
if (merged.length < longest * MIN_LENGTH_RATIO) {
|
|
104
|
+
throw new Error(`the merge is ${merged.length} characters against ${longest}; refusing it as probably truncated`);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
merged: merged.endsWith("\n") ? merged : `${merged}\n`,
|
|
108
|
+
conflict: conflictText || "Claude did not describe the conflict.",
|
|
109
|
+
resolution: resolutionText || "Claude did not describe the resolution.",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** `configDir` is the Claude home that owns the folder, e.g. `~/.claude-chaine` for `~/.claude-chaine/skills`. */
|
|
114
|
+
function runClaude(input: string, configDir: string): Promise<string> {
|
|
115
|
+
const claude = findClaude();
|
|
116
|
+
if (claude === undefined) return Promise.reject(new Error("the claude CLI is not installed"));
|
|
117
|
+
|
|
118
|
+
// Always set, never inherited or left unset: unset, `claude` falls back to `~/.claude` and writes its
|
|
119
|
+
// session files, backups and caches there, recreating that home every time it is deleted.
|
|
120
|
+
const env = { ...process.env, CLAUDE_CONFIG_DIR: configDir };
|
|
121
|
+
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
const child = spawn(claude, [
|
|
124
|
+
"-p", "--output-format", "json", "--tools", "", "--strict-mcp-config", "--setting-sources", "",
|
|
125
|
+
"--no-session-persistence", "--system-prompt", SYSTEM_PROMPT,
|
|
126
|
+
], { env, cwd: homedir(), stdio: ["pipe", "pipe", "pipe"] });
|
|
127
|
+
|
|
128
|
+
let stdout = "";
|
|
129
|
+
let stderr = "";
|
|
130
|
+
child.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString(); });
|
|
131
|
+
child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
|
|
132
|
+
const timer = setTimeout(() => child.kill("SIGTERM"), TIMEOUT_MS);
|
|
133
|
+
|
|
134
|
+
child.on("error", (cause) => { clearTimeout(timer); reject(cause); });
|
|
135
|
+
child.on("close", (code) => {
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
try {
|
|
138
|
+
const result = JSON.parse(stdout) as { is_error?: boolean; result?: string };
|
|
139
|
+
if (code !== 0 || result.is_error || typeof result.result !== "string") {
|
|
140
|
+
reject(new Error(`claude failed (exit ${code}): ${result.result ?? stderr.trim()}`));
|
|
141
|
+
} else {
|
|
142
|
+
resolve(result.result);
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
reject(new Error(`claude failed (exit ${code}): ${stderr.trim() || stdout.slice(0, 300)}`));
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
child.stdin.end(input);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The daemon starts at login with the service manager's minimal PATH, which rarely includes where the
|
|
154
|
+
* Claude installer puts `claude`, so the usual install locations are checked too.
|
|
155
|
+
*/
|
|
156
|
+
function findClaude(): string | undefined {
|
|
157
|
+
const name = process.platform === "win32" ? "claude.exe" : "claude";
|
|
158
|
+
const fromPath = (process.env["PATH"] ?? "").split(delimiter).filter(Boolean).map((dir) => join(dir, name));
|
|
159
|
+
const known = [
|
|
160
|
+
join(homedir(), ".local", "bin", name),
|
|
161
|
+
join(homedir(), ".claude", "local", "claude"),
|
|
162
|
+
"/opt/homebrew/bin/claude",
|
|
163
|
+
"/usr/local/bin/claude",
|
|
164
|
+
];
|
|
165
|
+
return [...fromPath, ...known].find((candidate) => existsSync(candidate));
|
|
166
|
+
}
|