@jive-sh/skill-sync 0.1.2-latest-linux-arm64 → 0.1.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 +29 -0
- package/bin/skill-sync +568 -0
- package/bin/skill-sync-daemon +568 -0
- package/dist/daemons/skill-sync-daemon.js +99 -0
- package/dist/daemons/skill-sync-daemon.js.map +1 -0
- package/dist/entrypoints/skill-sync.js +135 -0
- package/dist/entrypoints/skill-sync.js.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/config.js +135 -0
- package/dist/internal/config.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/sync.js +131 -0
- package/dist/internal/sync.js.map +1 -0
- package/dist/internal/wire.js +7 -0
- package/dist/internal/wire.js.map +1 -0
- package/install.js +568 -0
- package/package.json +39 -11
- package/src/daemons/skill-sync-daemon.ts +104 -0
- package/src/entrypoints/skill-sync.ts +140 -0
- package/src/index.ts +10 -0
- package/src/internal/config.ts +170 -0
- package/src/internal/notify.ts +53 -0
- package/src/internal/path.ts +32 -0
- package/src/internal/sync.ts +165 -0
- package/src/internal/wire.ts +32 -0
- package/multi-call-binary +0 -4
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { watch } from "node:fs";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { exitWhenUninstalled } from "jive-abstract-library";
|
|
4
|
+
import { loadConfig, loadState, saveState } from "../internal/config";
|
|
5
|
+
import { notify } from "../internal/notify";
|
|
6
|
+
import { syncOnce } from "../internal/sync";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Keeps this machine's skills directory identical to every other machine's.
|
|
10
|
+
*
|
|
11
|
+
* Polls rather than subscribes: 30 seconds of latency on a file you edit a few times a day is not worth
|
|
12
|
+
* a durable connection, and a poll recovers from a dropped network by itself. A local change does not
|
|
13
|
+
* wait for the next poll — the watcher below brings the next cycle forward.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const POLL_INTERVAL_MS = 30_000;
|
|
17
|
+
/** Long enough that an editor's write-rename-write lands as one cycle, short enough to feel immediate. */
|
|
18
|
+
const SETTLE_MS = 1_000;
|
|
19
|
+
|
|
20
|
+
exitWhenUninstalled();
|
|
21
|
+
|
|
22
|
+
const loaded = loadConfig();
|
|
23
|
+
if (!loaded.ok) {
|
|
24
|
+
console.error(`skill-sync is not configured: ${loaded.reason}`);
|
|
25
|
+
// Zero, not one: the service manager restarts a daemon that fails, and retrying every few seconds
|
|
26
|
+
// against a config nobody has filled in yet would just fill the log.
|
|
27
|
+
process.exit(0);
|
|
28
|
+
}
|
|
29
|
+
const config = loaded.config;
|
|
30
|
+
|
|
31
|
+
const state = loadState();
|
|
32
|
+
let cycleQueued = false;
|
|
33
|
+
let running = false;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Conflicts already reported to the desktop.
|
|
37
|
+
*
|
|
38
|
+
* A conflict persists until a human resolves it, so every cycle keeps finding it — notifying each time
|
|
39
|
+
* would put a popup on screen twice a minute until they gave up and turned notifications off, which
|
|
40
|
+
* costs them the one notification that mattered. Only newly-conflicting paths are announced, and a path
|
|
41
|
+
* drops out of the set once it stops conflicting so a later recurrence is announced again.
|
|
42
|
+
*
|
|
43
|
+
* Held in memory, not in the state file: a restart re-announcing an unresolved conflict is correct.
|
|
44
|
+
*/
|
|
45
|
+
const announced = new Set<string>();
|
|
46
|
+
|
|
47
|
+
function announceConflicts(conflicts: ReadonlyArray<string>): void {
|
|
48
|
+
for (const path of [...announced]) {
|
|
49
|
+
if (!conflicts.includes(path)) announced.delete(path);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const fresh = conflicts.filter((path) => !announced.has(path));
|
|
53
|
+
if (fresh.length === 0) return;
|
|
54
|
+
for (const path of fresh) announced.add(path);
|
|
55
|
+
|
|
56
|
+
notify(
|
|
57
|
+
"Skill sync conflict",
|
|
58
|
+
fresh.length === 1
|
|
59
|
+
? `${fresh[0]} changed on two machines. Your version is saved as ${fresh[0]}.local`
|
|
60
|
+
: `${fresh.length} skills changed on two machines. Your versions are saved alongside them as .local`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function runCycle(): Promise<void> {
|
|
65
|
+
if (running) {
|
|
66
|
+
cycleQueued = true;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
running = true;
|
|
70
|
+
try {
|
|
71
|
+
const report = await syncOnce(config, state);
|
|
72
|
+
saveState(state);
|
|
73
|
+
|
|
74
|
+
for (const path of report.pulled) console.log(`pulled ${path}`);
|
|
75
|
+
for (const path of report.pushed) console.log(`pushed ${path}`);
|
|
76
|
+
for (const path of report.deletedLocally) console.log(`deleted ${path}`);
|
|
77
|
+
for (const path of report.conflicts) {
|
|
78
|
+
console.error(`conflict on ${path} — another machine changed it too; your copy is at ${path}.local`);
|
|
79
|
+
}
|
|
80
|
+
for (const path of report.rejected) console.error(`refused a path the server sent: ${path}`);
|
|
81
|
+
announceConflicts(report.conflicts);
|
|
82
|
+
} catch (cause) {
|
|
83
|
+
// One failed cycle must not end the daemon: the usual cause is the network, and the next poll retries.
|
|
84
|
+
console.error(`sync failed: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
85
|
+
} finally {
|
|
86
|
+
running = false;
|
|
87
|
+
if (cycleQueued) {
|
|
88
|
+
cycleQueued = false;
|
|
89
|
+
setTimeout(runCycle, SETTLE_MS);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (existsSync(config.skillsDir)) {
|
|
95
|
+
let settle: ReturnType<typeof setTimeout> | undefined;
|
|
96
|
+
watch(config.skillsDir, { recursive: true }, () => {
|
|
97
|
+
if (settle !== undefined) clearTimeout(settle);
|
|
98
|
+
settle = setTimeout(runCycle, SETTLE_MS);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
console.log(`watching ${config.skillsDir}, syncing with ${config.endpoint} as ${config.machine}`);
|
|
103
|
+
await runCycle();
|
|
104
|
+
setInterval(runCycle, POLL_INTERVAL_MS);
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { CONFIG_PATH, TOKEN_PATH, loadConfig, loadSettings, loadState, saveState } from "../internal/config";
|
|
6
|
+
import { syncOnce } from "../internal/sync";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The CLI that accompanies the daemon.
|
|
10
|
+
*
|
|
11
|
+
* The daemon never authenticates anyone — it reads a token the CLI left behind and nothing more. Signing
|
|
12
|
+
* in needs a browser and a person, which a process started at login has neither of, and a daemon that
|
|
13
|
+
* could prompt for credentials would be a daemon that prompts you at every login.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const program = new Command("skill-sync")
|
|
17
|
+
.description("keep one skills directory identical across every machine you work on");
|
|
18
|
+
|
|
19
|
+
program
|
|
20
|
+
.command("login")
|
|
21
|
+
.description("verify with your passkey in the browser and store this machine's sync token")
|
|
22
|
+
.action(async () => {
|
|
23
|
+
// A missing token is exactly what we are here to fix, so only the settings have to be usable.
|
|
24
|
+
const settings = loadSettings();
|
|
25
|
+
if (settings === undefined) {
|
|
26
|
+
console.error(`Fill in ${CONFIG_PATH} with an endpoint, a machine name and a skillsDir first.`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const token = await captureToken(settings.endpoint, settings.machine);
|
|
31
|
+
writeFileSync(TOKEN_PATH, `${JSON.stringify({ token }, null, 2)}\n`, { mode: 0o600 });
|
|
32
|
+
console.log(`Signed in. Token saved to ${TOKEN_PATH}`);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
program
|
|
36
|
+
.command("logout")
|
|
37
|
+
.description("forget this machine's sync token")
|
|
38
|
+
.action(async () => {
|
|
39
|
+
const loaded = loadConfig();
|
|
40
|
+
if (loaded.ok) {
|
|
41
|
+
// Revoke first: if this fails, the token is still on disk and you can try again. Deleting it first
|
|
42
|
+
// would leave a live token on the server that nobody can revoke any more.
|
|
43
|
+
const response = await fetch(`${loaded.config.endpoint}/cli/token`, {
|
|
44
|
+
method: "DELETE",
|
|
45
|
+
headers: { authorization: `Bearer ${loaded.config.token}` },
|
|
46
|
+
});
|
|
47
|
+
console.log(response.ok ? "Revoked on the server." : `Server revoke failed (${response.status}).`);
|
|
48
|
+
}
|
|
49
|
+
rmSync(TOKEN_PATH, { force: true });
|
|
50
|
+
console.log(`Removed ${TOKEN_PATH}`);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
program
|
|
54
|
+
.command("status")
|
|
55
|
+
.description("show what this machine is configured to sync, and whether it can")
|
|
56
|
+
.action(async () => {
|
|
57
|
+
const loaded = loadConfig();
|
|
58
|
+
if (!loaded.ok) {
|
|
59
|
+
console.log(`Not ready: ${loaded.reason}`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const { endpoint, skillsDir, machine } = loaded.config;
|
|
63
|
+
console.log(`machine ${machine}`);
|
|
64
|
+
console.log(`skills ${skillsDir}${existsSync(skillsDir) ? "" : " (does not exist)"}`);
|
|
65
|
+
console.log(`endpoint ${endpoint}`);
|
|
66
|
+
console.log(`settings ${CONFIG_PATH}`);
|
|
67
|
+
console.log(`cursor ${loadState().cursor}`);
|
|
68
|
+
|
|
69
|
+
const response = await fetch(`${endpoint}/files?since=0`, {
|
|
70
|
+
headers: { authorization: `Bearer ${loaded.config.token}` },
|
|
71
|
+
});
|
|
72
|
+
console.log(response.ok
|
|
73
|
+
? `server reachable, ${((await response.json()) as { files: unknown[] }).files.length} file(s)`
|
|
74
|
+
: `server ${response.status} ${response.statusText} — try \`skill-sync login\``);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
program
|
|
78
|
+
.command("sync")
|
|
79
|
+
.description("run one sync cycle now instead of waiting for the daemon")
|
|
80
|
+
.action(async () => {
|
|
81
|
+
const loaded = loadConfig();
|
|
82
|
+
if (!loaded.ok) {
|
|
83
|
+
console.error(`Not ready: ${loaded.reason}`);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
const state = loadState();
|
|
87
|
+
const report = await syncOnce(loaded.config, state);
|
|
88
|
+
saveState(state);
|
|
89
|
+
for (const path of report.pulled) console.log(`pulled ${path}`);
|
|
90
|
+
for (const path of report.pushed) console.log(`pushed ${path}`);
|
|
91
|
+
for (const path of report.deletedLocally) console.log(`deleted ${path}`);
|
|
92
|
+
for (const path of report.conflicts) console.log(`CONFLICT ${path} — your copy is at ${path}.local`);
|
|
93
|
+
for (const path of report.rejected) console.log(`REJECTED ${path}`);
|
|
94
|
+
if (Object.values(report).every((list) => list.length === 0)) console.log("already in sync");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
program.parse();
|
|
98
|
+
if (process.argv.length <= 2) program.help();
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Opens the browser at the service's login endpoint and waits for it to come back with a token.
|
|
102
|
+
*
|
|
103
|
+
* The browser does the authenticating, with a passkey for the service's domain; this only has to be
|
|
104
|
+
* listening when it finishes. The listener is bound to 127.0.0.1 so nothing off this machine can receive
|
|
105
|
+
* the redirect, and the port is whatever the OS hands out so two machines — or two attempts — never
|
|
106
|
+
* contend for one.
|
|
107
|
+
*/
|
|
108
|
+
async function captureToken(endpoint: string, machine: string): Promise<string> {
|
|
109
|
+
return await new Promise<string>((resolve, reject) => {
|
|
110
|
+
const server = createServer((request, response) => {
|
|
111
|
+
const token = new URL(request.url ?? "/", "http://127.0.0.1").searchParams.get("token");
|
|
112
|
+
response.writeHead(200, { "content-type": "text/html" });
|
|
113
|
+
response.end(token
|
|
114
|
+
? "<p>Signed in. You can close this tab.</p>"
|
|
115
|
+
: "<p>No token in the redirect. Check the service logs.</p>");
|
|
116
|
+
server.close();
|
|
117
|
+
token ? resolve(token) : reject(new Error("the redirect carried no token"));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
server.listen(0, "127.0.0.1", () => {
|
|
121
|
+
const port = (server.address() as { port: number }).port;
|
|
122
|
+
const url = `${endpoint}/cli/login?port=${port}&machine=${encodeURIComponent(machine)}`;
|
|
123
|
+
console.log(`Opening ${url}`);
|
|
124
|
+
openBrowser(url);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
setTimeout(() => {
|
|
128
|
+
server.close();
|
|
129
|
+
reject(new Error("timed out waiting for the browser"));
|
|
130
|
+
}, 180_000).unref();
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function openBrowser(url: string): void {
|
|
135
|
+
const command = process.platform === "darwin" ? "open"
|
|
136
|
+
: process.platform === "win32" ? "cmd"
|
|
137
|
+
: "xdg-open";
|
|
138
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
139
|
+
spawn(command, args, { stdio: "ignore", detached: true }).unref();
|
|
140
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
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 type { ListFilesResponse, PutFileRequest, RemoteFile } from "./internal/wire";
|
|
@@ -0,0 +1,170 @@
|
|
|
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: which directory holds this machine's skills, and which
|
|
10
|
+
* machine this is, differ per computer and have to survive a login, which is exactly what a config file
|
|
11
|
+
* is for and exactly what an environment variable is not.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const JIVE_HOME = join(homedir(), ".jive");
|
|
15
|
+
export const CONFIG_PATH = join(JIVE_HOME, "skill-sync.json");
|
|
16
|
+
/**
|
|
17
|
+
* The token lives on its own, outside `~/.jive/`, because it is the one part that must not travel with
|
|
18
|
+
* the rest. Endpoint, machine name and skills directory are settings you could paste into a gist; this
|
|
19
|
+
* is a credential, and keeping it in a separate file makes "do not copy this one" a property of the
|
|
20
|
+
* filesystem rather than a thing you have to remember.
|
|
21
|
+
*/
|
|
22
|
+
export const TOKEN_PATH = join(homedir(), ".skill-sync-token.json");
|
|
23
|
+
const STATE_PATH = join(JIVE_HOME, "skill-sync-state.json");
|
|
24
|
+
|
|
25
|
+
/** Managed by Claude's own cloud sync; see `Config.exclude`. */
|
|
26
|
+
const DEFAULT_EXCLUDE = ["synced"] as const;
|
|
27
|
+
|
|
28
|
+
export interface Config {
|
|
29
|
+
readonly endpoint: string;
|
|
30
|
+
/**
|
|
31
|
+
* Top-level directory names under `skillsDir` to leave alone.
|
|
32
|
+
*
|
|
33
|
+
* `synced` holds the skills Claude already pulls from the cloud itself. Syncing them here would mean
|
|
34
|
+
* two systems writing the same files, each undoing the other, and none of them are yours to edit.
|
|
35
|
+
*/
|
|
36
|
+
readonly exclude: ReadonlyArray<string>;
|
|
37
|
+
readonly token: string;
|
|
38
|
+
readonly skillsDir: 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
|
+
export interface State {
|
|
49
|
+
/** Highest server sequence number already applied. */
|
|
50
|
+
cursor: number;
|
|
51
|
+
/** Last hash this machine agreed with the server on, per relative path. Absent means never synced. */
|
|
52
|
+
known: Record<string, string>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The settings alone, with no token required.
|
|
57
|
+
*
|
|
58
|
+
* `login` needs these precisely when the token is missing, so it cannot go through `loadConfig` — that
|
|
59
|
+
* reports "not configured" for a missing token and would refuse to run the command that supplies one.
|
|
60
|
+
*/
|
|
61
|
+
export function loadSettings(): { endpoint: string; machine: string; skillsDir: string } | undefined {
|
|
62
|
+
loadConfig();
|
|
63
|
+
const settings = readJson<Partial<Config>>(CONFIG_PATH);
|
|
64
|
+
if (!settings.endpoint || !settings.machine || !settings.skillsDir) return undefined;
|
|
65
|
+
return { endpoint: settings.endpoint, machine: settings.machine, skillsDir: settings.skillsDir };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Reads both files, writing a template for whichever is missing, and says what is still needed. */
|
|
69
|
+
export function loadConfig(): ConfigResult {
|
|
70
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
71
|
+
mkdirSync(JIVE_HOME, { recursive: true });
|
|
72
|
+
writeFileSync(CONFIG_PATH, `${JSON.stringify({
|
|
73
|
+
endpoint: "https://skill-sync.jive.sh",
|
|
74
|
+
skillsDir: detectSkillsDir(),
|
|
75
|
+
machine: machineName(),
|
|
76
|
+
exclude: DEFAULT_EXCLUDE,
|
|
77
|
+
}, null, 2)}\n`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!existsSync(TOKEN_PATH)) {
|
|
81
|
+
// 0600 from the moment it exists, rather than written open and tightened afterwards — there is no
|
|
82
|
+
// window in between for anything else on the machine to read it.
|
|
83
|
+
writeFileSync(TOKEN_PATH, `${JSON.stringify({ token: "" }, null, 2)}\n`, { mode: 0o600 });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const settings = readJson<Partial<Config>>(CONFIG_PATH);
|
|
87
|
+
const token = readJson<{ token?: string }>(TOKEN_PATH).token;
|
|
88
|
+
|
|
89
|
+
if (!settings.endpoint || !settings.skillsDir || !settings.machine) {
|
|
90
|
+
return { ok: false, reason: `${CONFIG_PATH} needs an endpoint, a skillsDir and a machine name` };
|
|
91
|
+
}
|
|
92
|
+
if (!token) {
|
|
93
|
+
return { ok: false, reason: `${TOKEN_PATH} needs the sync token` };
|
|
94
|
+
}
|
|
95
|
+
return { ok: true, config: { exclude: DEFAULT_EXCLUDE, ...settings, token } as Config };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readJson<T>(path: string): T {
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(readFileSync(path, "utf-8")) as T;
|
|
101
|
+
} catch {
|
|
102
|
+
return {} as T;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function loadState(): State {
|
|
107
|
+
try {
|
|
108
|
+
return JSON.parse(readFileSync(STATE_PATH, "utf-8")) as State;
|
|
109
|
+
} catch {
|
|
110
|
+
return { cursor: 0, known: {} };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function saveState(state: State): void {
|
|
115
|
+
mkdirSync(JIVE_HOME, { recursive: true });
|
|
116
|
+
writeFileSync(STATE_PATH, `${JSON.stringify(state, null, 2)}\n`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `HOSTNAME` is not set on macOS, so the template was writing "unknown" on exactly this machine. */
|
|
120
|
+
function machineName(): string {
|
|
121
|
+
return hostname().replace(/\.local$/, "").replace(/[^A-Za-z0-9-]+/g, "-") || "unknown";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The skills directory this machine actually has, rather than the one it ought to have.
|
|
126
|
+
*
|
|
127
|
+
* Claude's home is not always `~/.claude` — an account with a suffixed home (`~/.claude-alex`) keeps its
|
|
128
|
+
* skills there, and writing the conventional path into the template just hands the user a wrong value to
|
|
129
|
+
* notice and correct. Pick the one that exists; fall back to the conventional path only if none does.
|
|
130
|
+
*/
|
|
131
|
+
function detectSkillsDir(): string {
|
|
132
|
+
const candidates = readdirSync(homedir())
|
|
133
|
+
.filter((entry) => entry === ".claude" || entry.startsWith(".claude-"))
|
|
134
|
+
.map((entry) => join(homedir(), entry, "skills"))
|
|
135
|
+
.filter((dir) => existsSync(dir))
|
|
136
|
+
.sort();
|
|
137
|
+
return candidates[0] ?? join(homedir(), ".claude", "skills");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function hashBytes(bytes: Uint8Array): string {
|
|
141
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Suffix for the copy kept when an incoming change would have overwritten a local edit. */
|
|
145
|
+
export const CONFLICT_SUFFIX = ".local";
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Every file under the skills directory, as paths relative to it with forward slashes.
|
|
149
|
+
*
|
|
150
|
+
* Normalised because the same skill synced from Windows and macOS must be one row, not two.
|
|
151
|
+
*
|
|
152
|
+
* Conflict copies are skipped. They are this machine's evidence of a collision, not content anyone else
|
|
153
|
+
* asked for, and syncing them spreads one machine's mess to every other machine.
|
|
154
|
+
*/
|
|
155
|
+
export function listLocalFiles(skillsDir: string, exclude: ReadonlyArray<string> = DEFAULT_EXCLUDE): string[] {
|
|
156
|
+
if (!existsSync(skillsDir)) return [];
|
|
157
|
+
|
|
158
|
+
const found: string[] = [];
|
|
159
|
+
const walk = (dir: string, top: boolean): void => {
|
|
160
|
+
for (const entry of readdirSync(dir)) {
|
|
161
|
+
if (entry.startsWith(".")) continue;
|
|
162
|
+
if (top && exclude.includes(entry)) continue;
|
|
163
|
+
const full = join(dir, entry);
|
|
164
|
+
if (statSync(full).isDirectory()) walk(full, false);
|
|
165
|
+
else if (!entry.endsWith(CONFLICT_SUFFIX)) found.push(relative(skillsDir, full).split(sep).join("/"));
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
walk(skillsDir, true);
|
|
169
|
+
return found.sort();
|
|
170
|
+
}
|
|
@@ -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,165 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, rmSync, 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
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One sync cycle: take everything the server has that we do not, then offer everything we have that it
|
|
8
|
+
* does not.
|
|
9
|
+
*
|
|
10
|
+
* Pull before push, deliberately. Pushing first would send a file the server already changed, get it
|
|
11
|
+
* refused, and report a conflict that pulling would have resolved on its own.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
interface RemoteFile {
|
|
15
|
+
readonly path: string;
|
|
16
|
+
readonly hash: string | null;
|
|
17
|
+
readonly deleted: boolean;
|
|
18
|
+
readonly updatedBy: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Report {
|
|
22
|
+
readonly pulled: string[];
|
|
23
|
+
readonly pushed: string[];
|
|
24
|
+
readonly deletedLocally: string[];
|
|
25
|
+
readonly conflicts: string[];
|
|
26
|
+
/** Paths the server offered that this machine refused to write. */
|
|
27
|
+
readonly rejected: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function syncOnce(config: Config, state: State): Promise<Report> {
|
|
31
|
+
const report: Report = { pulled: [], pushed: [], deletedLocally: [], conflicts: [], rejected: [] };
|
|
32
|
+
const present = new Set(listLocalFiles(config.skillsDir, config.exclude));
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Files deleted here since the last cycle, worked out before anything is fetched.
|
|
36
|
+
*
|
|
37
|
+
* A push does not advance this machine's cursor, so the next pull sees its own earlier write as news.
|
|
38
|
+
* With the file now gone from disk that looked like "remote has it, we do not" and the pull put it
|
|
39
|
+
* straight back, undoing the delete every time. Deciding what was deleted up front, and refusing to
|
|
40
|
+
* recreate those paths, is what lets the push below turn them into tombstones.
|
|
41
|
+
*/
|
|
42
|
+
const pendingDeletes = new Set(Object.keys(state.known).filter((path) => !present.has(path)));
|
|
43
|
+
|
|
44
|
+
// `present` is passed through both passes and kept current: the pull removes tombstoned files and adds
|
|
45
|
+
// new ones, and a push that read a stale snapshot would try to open a file the pull had just deleted.
|
|
46
|
+
await pull(config, state, report, pendingDeletes, present);
|
|
47
|
+
await push(config, state, report, present);
|
|
48
|
+
return report;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function pull(
|
|
52
|
+
config: Config, state: State, report: Report, pendingDeletes: Set<string>, present: Set<string>,
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
const response = await call(config, `/files?since=${state.cursor}`);
|
|
55
|
+
const body = await response.json() as { cursor: number; files: RemoteFile[] };
|
|
56
|
+
|
|
57
|
+
for (const file of body.files) {
|
|
58
|
+
// The server is not trusted to supply a path either: one bad row would otherwise write outside the
|
|
59
|
+
// skills directory on every machine that syncs.
|
|
60
|
+
const badPath = invalidPathReason(file.path);
|
|
61
|
+
if (badPath) {
|
|
62
|
+
report.rejected.push(`${file.path} (${badPath})`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const local = join(config.skillsDir, file.path);
|
|
66
|
+
// Deleted here and not yet reported; the push pass owns this path for the rest of the cycle.
|
|
67
|
+
if (pendingDeletes.has(file.path)) continue;
|
|
68
|
+
|
|
69
|
+
if (file.deleted) {
|
|
70
|
+
if (existsSync(local)) {
|
|
71
|
+
// Never destroy content this machine never agreed with the server on. A file written here that
|
|
72
|
+
// the server happens to have tombstoned — a fresh machine, or one that was offline through the
|
|
73
|
+
// delete — is not the same file, and deleting it silently would lose work nobody could recover.
|
|
74
|
+
const localHash = hashBytes(readFileSync(local));
|
|
75
|
+
if (localHash !== state.known[file.path]) {
|
|
76
|
+
writeFileSync(`${local}${CONFLICT_SUFFIX}`, readFileSync(local));
|
|
77
|
+
report.conflicts.push(file.path);
|
|
78
|
+
}
|
|
79
|
+
rmSync(local, { force: true });
|
|
80
|
+
report.deletedLocally.push(file.path);
|
|
81
|
+
}
|
|
82
|
+
present.delete(file.path);
|
|
83
|
+
delete state.known[file.path];
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const localHash = existsSync(local) ? hashBytes(readFileSync(local)) : undefined;
|
|
88
|
+
if (localHash === file.hash) {
|
|
89
|
+
// Already identical — record agreement so the push pass does not treat it as a local edit.
|
|
90
|
+
state.known[file.path] = file.hash!;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A local edit we have not pushed yet would be destroyed by overwriting. Keep it beside the incoming
|
|
95
|
+
// copy and let the push pass raise the conflict rather than deciding which one wins here.
|
|
96
|
+
if (localHash !== undefined && localHash !== state.known[file.path]) {
|
|
97
|
+
writeFileSync(`${local}${CONFLICT_SUFFIX}`, readFileSync(local));
|
|
98
|
+
report.conflicts.push(file.path);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const content = new Uint8Array(await (await call(config, `/content/${file.hash}`)).arrayBuffer());
|
|
102
|
+
mkdirSync(dirname(local), { recursive: true });
|
|
103
|
+
writeFileSync(local, content);
|
|
104
|
+
state.known[file.path] = file.hash!;
|
|
105
|
+
present.add(file.path);
|
|
106
|
+
report.pulled.push(file.path);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
state.cursor = body.cursor;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function push(config: Config, state: State, report: Report, present: Set<string>): Promise<void> {
|
|
113
|
+
for (const path of present) {
|
|
114
|
+
const bytes = readFileSync(join(config.skillsDir, path));
|
|
115
|
+
const hash = hashBytes(bytes);
|
|
116
|
+
if (state.known[path] === hash) continue;
|
|
117
|
+
|
|
118
|
+
const accepted = await put(config, {
|
|
119
|
+
path,
|
|
120
|
+
baseHash: state.known[path] ?? null,
|
|
121
|
+
hash,
|
|
122
|
+
content: Buffer.from(bytes).toString("base64"),
|
|
123
|
+
});
|
|
124
|
+
if (accepted) {
|
|
125
|
+
state.known[path] = hash;
|
|
126
|
+
report.pushed.push(path);
|
|
127
|
+
} else {
|
|
128
|
+
report.conflicts.push(path);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// A path we previously agreed on that is no longer on disk was deleted here, and the server needs the
|
|
133
|
+
// tombstone or the next machine to sync will upload it back.
|
|
134
|
+
for (const path of Object.keys(state.known)) {
|
|
135
|
+
if (present.has(path)) continue;
|
|
136
|
+
if (await put(config, { path, baseHash: state.known[path]!, hash: null, content: null })) {
|
|
137
|
+
delete state.known[path];
|
|
138
|
+
report.pushed.push(`${path} (deleted)`);
|
|
139
|
+
} else {
|
|
140
|
+
report.conflicts.push(path);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** True when the server accepted the write; false means another machine got there first. */
|
|
146
|
+
async function put(config: Config, body: {
|
|
147
|
+
path: string; baseHash: string | null; hash: string | null; content: string | null;
|
|
148
|
+
}): Promise<boolean> {
|
|
149
|
+
const response = await fetch(`${config.endpoint}/files`, {
|
|
150
|
+
method: "PUT",
|
|
151
|
+
headers: { authorization: `Bearer ${config.token}`, "content-type": "application/json" },
|
|
152
|
+
body: JSON.stringify({ ...body, machine: config.machine }),
|
|
153
|
+
});
|
|
154
|
+
if (response.status === 409) return false;
|
|
155
|
+
if (!response.ok) throw new Error(`PUT /files ${body.path}: ${response.status} ${await response.text()}`);
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function call(config: Config, path: string): Promise<Response> {
|
|
160
|
+
const response = await fetch(`${config.endpoint}${path}`, {
|
|
161
|
+
headers: { authorization: `Bearer ${config.token}` },
|
|
162
|
+
});
|
|
163
|
+
if (!response.ok) throw new Error(`GET ${path}: ${response.status} ${await response.text()}`);
|
|
164
|
+
return response;
|
|
165
|
+
}
|