skillcellar 0.3.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -2
- package/package.json +2 -2
- package/skill/SKILL.md +18 -9
- package/src/backup.mjs +609 -0
- package/src/check.mjs +59 -0
- package/src/cli.mjs +185 -22
- package/src/config.mjs +1 -1
- package/src/files.mjs +13 -7
- package/src/mcp.mjs +1 -1
- package/src/plan.mjs +36 -11
- package/src/playbook.mjs +164 -0
- package/src/project.mjs +15 -0
- package/src/restore.mjs +352 -0
- package/src/scan.mjs +31 -15
- package/src/secrets.mjs +75 -0
package/src/check.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A field of the site's answer as one line of the terminal: whatever the site, or a marketplace
|
|
3
|
+
* through it, put there, it cannot forge a line of this output. Line breaks become spaces.
|
|
4
|
+
*/
|
|
5
|
+
export const field = (value, max = 160) =>
|
|
6
|
+
String(value ?? "")
|
|
7
|
+
.replace(/\s+/g, " ")
|
|
8
|
+
.trim()
|
|
9
|
+
.slice(0, max);
|
|
10
|
+
|
|
11
|
+
const placeOf = (item) => (item.project ? `in ${field(item.project.name, 80) || "a project"}` : "globally");
|
|
12
|
+
|
|
13
|
+
/** A hash cut to what tells it apart in a line; a version as it is. */
|
|
14
|
+
const short = (value) => (typeof value === "string" && /^[0-9a-f]{40,64}$/.test(value) ? value.slice(0, 7) : field(value, 64));
|
|
15
|
+
|
|
16
|
+
/** What moved, in a few words: a folder's tree, a marketplace's version, or a version on the site. */
|
|
17
|
+
function moved(item) {
|
|
18
|
+
if (item.kind === "plugin") return `${short(item.installed)} here, ${short(item.latest)} in its marketplace`;
|
|
19
|
+
if (item.update?.method === "pull") return "an older version of the skill on the site";
|
|
20
|
+
return `${short(item.installed)} here, ${short(item.latest)} in ${field(item.source) || "its repository"}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** When the site last heard from the computer, as the check reads it, to the minute. */
|
|
24
|
+
const syncedLine = (check) => {
|
|
25
|
+
const at = typeof check.syncedAt === "string" ? new Date(check.syncedAt) : null;
|
|
26
|
+
return at && !Number.isNaN(at.getTime()) ? `As of the last sync, ${at.toISOString().slice(0, 16).replace("T", " ")} UTC; run skillcellar sync first for the computer as it is now.` : null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** The site's check as lines a person reads: what is outdated, with its command, then what could not be checked. */
|
|
30
|
+
export function describeCheck(check) {
|
|
31
|
+
const items = Array.isArray(check.items) ? check.items : [];
|
|
32
|
+
const name = field(check.device?.name, 80) || "this computer";
|
|
33
|
+
if (!items.length) return [`Nothing on ${name} to check: no skill or plugin whose source the site can read.`];
|
|
34
|
+
const outdated = items.filter((item) => item?.status === "outdated");
|
|
35
|
+
const unknown = items.filter((item) => item?.status === "unknown");
|
|
36
|
+
const current = items.filter((item) => item?.status === "current").length;
|
|
37
|
+
const lines = [];
|
|
38
|
+
if (!outdated.length) lines.push(`Nothing outdated on ${name}: ${current} ${current === 1 ? "item is" : "items are"} current.`);
|
|
39
|
+
else {
|
|
40
|
+
lines.push(`${outdated.length} of ${items.length} checked ${items.length === 1 ? "item" : "items"} on ${name} ${outdated.length === 1 ? "is" : "are"} outdated:`, "");
|
|
41
|
+
outdated.forEach((item, index) => {
|
|
42
|
+
lines.push(`${index + 1}. ${field(item.kind, 20)} ${field(item.name)}, ${placeOf(item)}: ${moved(item)}`);
|
|
43
|
+
if (item.update?.command) {
|
|
44
|
+
const where = item.project ? `inside ${field(item.project.name, 80) || "the project"}` : "";
|
|
45
|
+
const warning = item.update.method === "pull" ? "replaces the folder with the site's latest version" : "";
|
|
46
|
+
const notes = [where, warning].filter(Boolean).join("; ");
|
|
47
|
+
lines.push(` ${field(item.update.command, 300)}${notes ? ` (${notes})` : ""}`);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
lines.push("", `${current} ${current === 1 ? "item is" : "items are"} current.`);
|
|
51
|
+
}
|
|
52
|
+
if (unknown.length) {
|
|
53
|
+
lines.push("", `${unknown.length} could not be checked:`);
|
|
54
|
+
for (const item of unknown) lines.push(`- ${field(item.kind, 20)} ${field(item.name)}, ${placeOf(item)}: ${field(item.note) || "no way to compare it"}`);
|
|
55
|
+
}
|
|
56
|
+
const synced = syncedLine(check);
|
|
57
|
+
if (synced) lines.push("", synced);
|
|
58
|
+
return lines;
|
|
59
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -3,12 +3,16 @@ import { homedir, hostname } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
5
|
import { ApiError, call } from "./api.mjs";
|
|
6
|
+
import { parseBackupRepo, runBackup } from "./backup.mjs";
|
|
7
|
+
import { describeCheck } from "./check.mjs";
|
|
6
8
|
import { configPath, readConfig, writeConfig } from "./config.mjs";
|
|
7
9
|
import { findProjects } from "./places.mjs";
|
|
8
|
-
import { describePlan, localPlan } from "./plan.mjs";
|
|
9
|
-
import {
|
|
10
|
+
import { describePlan, localPlan, projectIdentity } from "./plan.mjs";
|
|
11
|
+
import { applyPlaybook, cautionLine, describeStatus, readPlaybook } from "./playbook.mjs";
|
|
12
|
+
import { findProject, trackedFiles } from "./project.mjs";
|
|
10
13
|
import { ask, askSecret, firstLine, interactive } from "./prompt.mjs";
|
|
11
14
|
import { checkProjectSkills, installPulled, PULL_REF_RE, readPulled } from "./pull.mjs";
|
|
15
|
+
import { runRestore } from "./restore.mjs";
|
|
12
16
|
import { readJson, scanGlobal, scanProject } from "./scan.mjs";
|
|
13
17
|
import { installSkill, skillFolder, skillState } from "./skill.mjs";
|
|
14
18
|
|
|
@@ -35,12 +39,42 @@ Usage:
|
|
|
35
39
|
skillcellar plan [--json]
|
|
36
40
|
List the tasks waiting for this computer, each with how to carry it out and, for a project,
|
|
37
41
|
its folder here.
|
|
42
|
+
skillcellar check [--json]
|
|
43
|
+
List what this computer has that its source has moved past, as the site last synced it: a
|
|
44
|
+
skill npx skills installed globally whose folder changed in its repository, a plugin whose
|
|
45
|
+
marketplace declares another version, a skill from the site that is an older version of
|
|
46
|
+
it; each with the command that updates it, then what the site cannot check.
|
|
38
47
|
skillcellar pull <username>/<name> [--scope global|project] [--force]
|
|
39
48
|
Install a skill from the site: one you wrote (<name> alone will do), one you bought, or a free
|
|
40
49
|
Community skill, into ~/.claude/skills/<name>, or with --scope project into the skills folder
|
|
41
50
|
of the project you are in. --force replaces a folder of that name.
|
|
51
|
+
skillcellar playbook apply <username>/<name> [--global] [--dry-run] [--force] [--yes]
|
|
52
|
+
Apply a playbook: one of yours (<name> alone will do) or one published in Community. Writes
|
|
53
|
+
its skill into the project's .claude/skills/playbook-<name>, or ~/.claude/skills with --global,
|
|
54
|
+
pulls the skills it uses from the site that this computer lacks, and prints an install prompt
|
|
55
|
+
for the ones from elsewhere, whose installers it never runs. It writes nothing outside those
|
|
56
|
+
skills folders, and keeps a folder that is there already unless you add --force. Someone
|
|
57
|
+
else's playbook writes only after a yes at the terminal, or with --yes.
|
|
58
|
+
--dry-run list the files it would write, and write nothing
|
|
59
|
+
--yes write someone else's playbook without asking, once you have read what it writes
|
|
60
|
+
skillcellar playbook status <username>/<name> [--global] [--json]
|
|
61
|
+
List which of a playbook's skills this computer has, in the project you are in and globally
|
|
62
|
+
(only globally with --global): installed, outdated with the command that updates it, or
|
|
63
|
+
missing with how to install it. It reads this computer as sync does, and the site keeps none of it.
|
|
42
64
|
skillcellar skill
|
|
43
65
|
Install or update the skillcellar skill, which tells Claude Code how to carry out your tasks.
|
|
66
|
+
skillcellar backup [--repo <owner>/<repo>] [--ssh] [--allow <file>] [--dry-run]
|
|
67
|
+
Push this computer's memory files, rules, agents, MCP config (every env and header value,
|
|
68
|
+
address credential and key-like argument replaced by a \${NAME} placeholder) and skills'
|
|
69
|
+
source files to a private GitHub repository you own, with your own git. It stops on anything
|
|
70
|
+
that looks like a secret and refuses a public repository. --repo is remembered; --ssh clones
|
|
71
|
+
and pushes over SSH; --allow <file> pushes a file you have read despite a finding in it,
|
|
72
|
+
and once pushed is remembered while the file stays the same; --dry-run lists what would go.
|
|
73
|
+
skillcellar restore [--from <device>] [--repo <owner>/<repo>] [--ssh] [--force] [--dry-run]
|
|
74
|
+
Carry out the copy tasks waiting for this computer: fetch the other computer's backup, write
|
|
75
|
+
its skills, memory files and agents into place, and list the commands left to run. A backup
|
|
76
|
+
in a repository this computer does not back up to is taken only with --repo, or a yes at the
|
|
77
|
+
terminal. --force replaces a folder or file that is there already.
|
|
44
78
|
|
|
45
79
|
login reads the key from SKILLCELLAR_API_KEY when it is set, else from the terminal or the first
|
|
46
80
|
line piped in. The key and the device are kept in ${configPath()}, written with mode 0600.`;
|
|
@@ -91,6 +125,13 @@ async function chooseDevice(site, key, devices, print) {
|
|
|
91
125
|
/** Claude Code's own folder: CLAUDE_CONFIG_DIR when it is set, else ~/.claude. */
|
|
92
126
|
const claudeFolder = (home) => process.env.CLAUDE_CONFIG_DIR?.trim() || join(home, ".claude");
|
|
93
127
|
|
|
128
|
+
/** Stops a command that reads this computer when Claude Code's folder is not there: read from the wrong one, it would find nothing. */
|
|
129
|
+
async function checkClaudeFolder(claudeDir) {
|
|
130
|
+
if (!(await stat(claudeDir).catch(() => null))?.isDirectory()) {
|
|
131
|
+
throw new Error(`There is no Claude Code folder at ${claudeDir}. Set CLAUDE_CONFIG_DIR to the one Claude Code uses.`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
94
135
|
/** The saved login and its site, for a command that calls the site. */
|
|
95
136
|
async function signedIn() {
|
|
96
137
|
const config = await readConfig();
|
|
@@ -118,7 +159,10 @@ async function login(flags, print) {
|
|
|
118
159
|
if (!/^[A-Za-z0-9_]{1,200}$/.test(key)) throw new Error(`That is not an API key. Create one at ${site}/dashboard/keys.`);
|
|
119
160
|
const library = await call(site, key, "GET", "/api/v1/library", undefined, VERSION);
|
|
120
161
|
const device = flags.device !== undefined ? await namedDevice(site, key, flags.device) : await chooseDevice(site, key, library.devices ?? [], print);
|
|
121
|
-
|
|
162
|
+
// A backup repository remembered for this site stays remembered across sign-ins.
|
|
163
|
+
const earlier = await readConfig().catch(() => null);
|
|
164
|
+
const backup = earlier?.site === site && earlier.backup ? { backup: earlier.backup } : {};
|
|
165
|
+
await writeConfig({ site, key, device: { id: device.id, slug: device.slug, name: device.name }, ...backup });
|
|
122
166
|
const who = library.account?.email ? ` as ${library.account.email}` : "";
|
|
123
167
|
print(`Signed in to ${new URL(site).host}${who}. This computer syncs as ${device.name}.`);
|
|
124
168
|
await offerSkill(print);
|
|
@@ -158,12 +202,10 @@ function summary(payload, result, deviceName, whole) {
|
|
|
158
202
|
*/
|
|
159
203
|
const claudeJsonPath = (home) => (process.env.CLAUDE_CONFIG_DIR?.trim() ? join(process.env.CLAUDE_CONFIG_DIR.trim(), ".claude.json") : join(home, ".claude.json"));
|
|
160
204
|
|
|
161
|
-
/** What makes checkouts one project on the site: their GitHub repository, else their name. */
|
|
162
|
-
const identity = (project) => project.repo ?? project.name.toLowerCase();
|
|
163
|
-
|
|
164
205
|
/** The tasks a sync closed, in one line. */
|
|
165
206
|
function closedLine(closed) {
|
|
166
|
-
const
|
|
207
|
+
const verb = (task) => (task.action === "remove" ? "removed" : task.action === "copy" ? "copied" : "installed");
|
|
208
|
+
const done = closed.map((task) => `${verb(task)} ${task.name ?? "everything"} ${task.project ? `in ${task.project}` : "globally"}`);
|
|
167
209
|
return `Closed ${plural(closed.length, "task")}: ${done.join("; ")}.`;
|
|
168
210
|
}
|
|
169
211
|
|
|
@@ -176,9 +218,7 @@ async function sync(flags, print, warn) {
|
|
|
176
218
|
const site = config ? siteOrigin(config.site) : null;
|
|
177
219
|
if (config && !site) throw new Error(`The saved site, ${config.site}, is not an https address. Run skillcellar login again.`);
|
|
178
220
|
// Read from the wrong folder, a sync would report nothing and remove what the last one reported.
|
|
179
|
-
|
|
180
|
-
throw new Error(`There is no Claude Code folder at ${claudeDir}. Set CLAUDE_CONFIG_DIR to the one Claude Code uses.`);
|
|
181
|
-
}
|
|
221
|
+
await checkClaudeFolder(claudeDir);
|
|
182
222
|
|
|
183
223
|
const claudeJson = (await readJson(claudeJsonPath(home))) ?? {};
|
|
184
224
|
const cwd = process.cwd();
|
|
@@ -189,7 +229,7 @@ async function sync(flags, print, warn) {
|
|
|
189
229
|
if (!here) throw new UsageError("Run sync --project inside the project's folder, not in your home folder.");
|
|
190
230
|
if (!/[A-Za-z0-9]/.test(flags.project)) throw new UsageError("--project needs a letter a–z or a digit 0–9.");
|
|
191
231
|
// Every checkout of the same repository is one project on the site, so all of them go together.
|
|
192
|
-
const same = projects.filter((known) => known.root === here.root || (here.remote && known.remote &&
|
|
232
|
+
const same = projects.filter((known) => known.root === here.root || (here.remote && known.remote && projectIdentity(known) === projectIdentity(here)));
|
|
193
233
|
projects = (same.length ? same : [{ ...here, folders: new Set([cwd]) }]).map((known) => ({ ...known, name: flags.project }));
|
|
194
234
|
}
|
|
195
235
|
|
|
@@ -205,11 +245,11 @@ async function sync(flags, print, warn) {
|
|
|
205
245
|
}
|
|
206
246
|
let scanned;
|
|
207
247
|
try {
|
|
208
|
-
scanned = await scanProject({ root: project.root, folders: [...project.folders], claudeDir, claudeJson });
|
|
248
|
+
scanned = await scanProject({ root: project.root, folders: [...project.folders], claudeDir, claudeJson, tracked: await trackedFiles(project.root) });
|
|
209
249
|
} catch (error) {
|
|
210
250
|
// One project's broken file stops only that project in a sync of the whole computer.
|
|
211
251
|
if (!whole) throw error;
|
|
212
|
-
unread.add(
|
|
252
|
+
unread.add(projectIdentity(project));
|
|
213
253
|
warn(`Not reported: ${project.name}, because ${error.message} The site keeps what it had there until a sync can read it.`);
|
|
214
254
|
continue;
|
|
215
255
|
}
|
|
@@ -223,7 +263,7 @@ async function sync(flags, print, warn) {
|
|
|
223
263
|
// one as it was and closes no task there; an empty place is then sent, to empty it. Another
|
|
224
264
|
// checkout of the unread project is left out too, or it would replace the project on its own.
|
|
225
265
|
const complete = whole && !unread.size;
|
|
226
|
-
if (!complete) places = [...places, ...empty].filter((place) => !unread.has(
|
|
266
|
+
if (!complete) places = [...places, ...empty].filter((place) => !unread.has(projectIdentity(place.project)));
|
|
227
267
|
if (places.length > PROJECTS_PER_SYNC) {
|
|
228
268
|
throw new Error(`This computer has ${places.length} projects to send, and one sync takes ${PROJECTS_PER_SYNC}. Sync each one you need from its folder with --project <name>.`);
|
|
229
269
|
}
|
|
@@ -260,15 +300,21 @@ async function plan(flags, print) {
|
|
|
260
300
|
const answer = await call(site, config.key, "GET", `/api/v1/plan?device=${encodeURIComponent(config.device.id)}`, undefined, VERSION);
|
|
261
301
|
const home = homedir();
|
|
262
302
|
const claudeDir = claudeFolder(home);
|
|
263
|
-
const projects = (answer.tasks ?? []).some((task) => task.project)
|
|
264
|
-
? await findProjects({ home, claudeDir, claudeJson: (await readJson(claudeJsonPath(home))) ?? {}, cwd: process.cwd() })
|
|
265
|
-
: [];
|
|
303
|
+
const projects = (answer.tasks ?? []).some((task) => task.project) ? await knownProjects(home, claudeDir) : [];
|
|
266
304
|
// The device's slug as the site has it now: a rename changes the slugs of its folders' projects.
|
|
267
305
|
const local = await localPlan(answer, { claudeDir, projects, deviceSlug: answer.device?.slug ?? config.device.slug });
|
|
268
306
|
if (flags.json) print(JSON.stringify(local, null, 2));
|
|
269
307
|
else for (const line of describePlan(local)) print(line);
|
|
270
308
|
}
|
|
271
309
|
|
|
310
|
+
/** Prints which of this computer's skills and plugins their sources have moved past, as the site checked them. */
|
|
311
|
+
async function check(flags, print) {
|
|
312
|
+
const { config, site } = await signedIn();
|
|
313
|
+
const answer = await call(site, config.key, "GET", `/api/v1/check?device=${encodeURIComponent(config.device.id)}`, undefined, VERSION);
|
|
314
|
+
if (flags.json) print(JSON.stringify(answer, null, 2));
|
|
315
|
+
else for (const line of describeCheck(answer)) print(line);
|
|
316
|
+
}
|
|
317
|
+
|
|
272
318
|
/** Installs a skill from the site into the global skills folder, or the project's. */
|
|
273
319
|
async function pull(flags, ref, print) {
|
|
274
320
|
if (!ref || !PULL_REF_RE.test(ref)) throw new UsageError("Name the skill as <username>/<name>, or <name> for one of your own.");
|
|
@@ -287,6 +333,111 @@ async function pull(flags, ref, print) {
|
|
|
287
333
|
print(`Pulled ${ref}, version ${pulled.version}, into ${await installPulled(skills, pulled, flags.force === true)}.`);
|
|
288
334
|
}
|
|
289
335
|
|
|
336
|
+
/**
|
|
337
|
+
* Applies a playbook to the project the command runs in, or globally, or prints where this
|
|
338
|
+
* computer stands with it. Either way the site checks it against what this computer has now,
|
|
339
|
+
* scanned as a sync scans it, and keeps none of it.
|
|
340
|
+
*/
|
|
341
|
+
async function playbook(flags, action, ref, print, warn) {
|
|
342
|
+
if (action !== "apply" && action !== "status") throw new UsageError("Run playbook apply <username>/<name>, or playbook status <username>/<name>.");
|
|
343
|
+
if (!ref || !PULL_REF_RE.test(ref)) throw new UsageError("Name the playbook as <username>/<name>, or <name> for one of your own.");
|
|
344
|
+
const { config, site } = await signedIn();
|
|
345
|
+
const home = homedir();
|
|
346
|
+
const claudeDir = claudeFolder(home);
|
|
347
|
+
await checkClaudeFolder(claudeDir);
|
|
348
|
+
const here = flags.global === true ? null : await findProject(process.cwd(), home);
|
|
349
|
+
if (action === "apply" && flags.global !== true && !here) throw new UsageError("Run playbook apply inside the project's folder, or add --global to apply it in ~/.claude/skills.");
|
|
350
|
+
if (action === "apply" && here) await checkProjectSkills(here.root);
|
|
351
|
+
|
|
352
|
+
const claudeJson = (await readJson(claudeJsonPath(home))) ?? {};
|
|
353
|
+
const global = await scanGlobal({ home, claudeDir, claudeJson });
|
|
354
|
+
// The project as a sync reads it: every folder in it Claude Code ran in, as well as this one.
|
|
355
|
+
const known = here ? (await findProjects({ home, claudeDir, claudeJson, cwd: process.cwd() })).find((entry) => entry.root === here.root) : null;
|
|
356
|
+
const folders = here ? [...new Set([here.root, ...(known?.folders ?? []), process.cwd()])] : [];
|
|
357
|
+
const project = here ? await scanProject({ root: here.root, folders, claudeDir, claudeJson, tracked: await trackedFiles(here.root) }) : null;
|
|
358
|
+
const body = { ref, global: global.items, project: project?.items ?? null, updates: action === "status" };
|
|
359
|
+
const answer = readPlaybook(await call(site, config.key, "POST", "/api/v1/playbook", body, VERSION));
|
|
360
|
+
|
|
361
|
+
if (action === "status") {
|
|
362
|
+
if (flags.json) print(JSON.stringify(answer, null, 2));
|
|
363
|
+
else for (const line of describeStatus(answer, here?.name ?? null)) print(line);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const caution = cautionLine(answer);
|
|
367
|
+
if (caution) warn(caution);
|
|
368
|
+
const skills = here ? join(here.root, ".claude", "skills") : join(claudeDir, "skills");
|
|
369
|
+
const fetchPull = (pullRef) => call(site, config.key, "GET", `/api/v1/pull?ref=${encodeURIComponent(pullRef)}`, undefined, VERSION);
|
|
370
|
+
// Someone else's playbook writes what its author chose, scripts included: only after a yes.
|
|
371
|
+
const confirm = answer.playbook?.own
|
|
372
|
+
? null
|
|
373
|
+
: async (files) => {
|
|
374
|
+
if (flags.yes === true) return true;
|
|
375
|
+
if (!interactive()) throw new UsageError("This playbook is someone else's: read what --dry-run lists, then run apply again with --yes.");
|
|
376
|
+
print(`It would write ${files.length === 1 ? "1 file" : `${files.length} files`}:`);
|
|
377
|
+
for (const { path, executable } of files) print(` ${path}${executable ? " (a program it can run)" : ""}`);
|
|
378
|
+
return /^y(es)?$/i.test(await ask("Write them? [y/N] "));
|
|
379
|
+
};
|
|
380
|
+
const { lines, failed } = await applyPlaybook({ answer, skills, force: flags.force === true, dryRun: flags["dry-run"] === true, fetchPull, confirm });
|
|
381
|
+
for (const line of lines) print(line);
|
|
382
|
+
for (const note of failed) warn(`Not written: ${note}`);
|
|
383
|
+
if (failed.length) throw new Error(`${failed.length === 1 ? "1 skill was" : `${failed.length} skills were`} not written; run playbook apply again once the reason above is fixed.`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** This computer's projects, as the sync finds them. */
|
|
387
|
+
async function knownProjects(home, claudeDir) {
|
|
388
|
+
return findProjects({ home, claudeDir, claudeJson: (await readJson(claudeJsonPath(home))) ?? {}, cwd: process.cwd() });
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Backs up this computer to a private GitHub repository the user owns, with their own git, and
|
|
393
|
+
* reports the commit to the site. The repository is remembered from one run to the next.
|
|
394
|
+
*/
|
|
395
|
+
async function backup(flags, print, warn) {
|
|
396
|
+
const home = homedir();
|
|
397
|
+
const claudeDir = claudeFolder(home);
|
|
398
|
+
const dryRun = flags["dry-run"] === true;
|
|
399
|
+
const config = await readConfig();
|
|
400
|
+
if (!config && !dryRun) throw new Error("Run skillcellar login first.");
|
|
401
|
+
const site = config ? siteOrigin(config.site) : null;
|
|
402
|
+
if (config && !site) throw new Error(`The saved site, ${config.site}, is not an https address. Run skillcellar login again.`);
|
|
403
|
+
await checkClaudeFolder(claudeDir);
|
|
404
|
+
const repoValue = flags.repo ?? config?.backup?.repo;
|
|
405
|
+
if (repoValue === undefined) throw new UsageError("Name the private repository the backup goes to with --repo <owner>/<repo>, once; it is remembered.");
|
|
406
|
+
const repo = parseBackupRepo(repoValue, flags.ssh === true || (flags.repo === undefined && config?.backup?.ssh === true));
|
|
407
|
+
if (!repo) throw new UsageError("--repo takes a GitHub repository as <owner>/<repo>, its https address, or its git@github.com address.");
|
|
408
|
+
// The device's slug as the site has it now: a rename changes the folder the backup goes under.
|
|
409
|
+
const device = config
|
|
410
|
+
? ((await call(site, config.key, "GET", `/api/v1/plan?device=${encodeURIComponent(config.device.id)}`, undefined, VERSION)).device ?? config.device)
|
|
411
|
+
: { id: null, slug: "this-computer", name: "this computer" };
|
|
412
|
+
const claudeJson = (await readJson(claudeJsonPath(home))) ?? {};
|
|
413
|
+
const projects = await knownProjects(home, claudeDir);
|
|
414
|
+
const allowed = (Array.isArray(flags.allow) ? flags.allow : []).map((path) => path.trim()).filter(Boolean);
|
|
415
|
+
// The repository is remembered as soon as a push has gone through, whatever the site answers after,
|
|
416
|
+
// with the files the user allowed despite a finding, each by the hash of what was allowed.
|
|
417
|
+
const remember = (commit, passed = []) => {
|
|
418
|
+
if (!config) return Promise.resolve();
|
|
419
|
+
const kept = passed.length ? { allowed: passed.map(({ file, sha256 }) => ({ file, sha256 })) } : {};
|
|
420
|
+
return writeConfig({ ...config, device: { id: device.id, slug: device.slug, name: device.name }, backup: { repo: repo.name, ssh: repo.ssh, ...kept } });
|
|
421
|
+
};
|
|
422
|
+
const remembered = Array.isArray(config?.backup?.allowed) ? config.backup.allowed : [];
|
|
423
|
+
const commit = await runBackup({ repo, device, claudeDir, claudeJson, projects, trackedOf: trackedFiles, version: VERSION, dryRun, allowed, remembered, print, onPushed: remember });
|
|
424
|
+
if (dryRun || !commit) return;
|
|
425
|
+
const recorded = await call(site, config.key, "POST", "/api/v1/backup", { device: device.id, repo: repo.name, commit }, VERSION);
|
|
426
|
+
const slug = recorded.device?.slug ?? device.slug;
|
|
427
|
+
if (slug !== device.slug) warn(`The site now calls this computer ${recorded.device.name}; the next backup goes under devices/${slug}.`);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Carries out this computer's copy tasks from the other computers' backups. */
|
|
431
|
+
async function restore(flags, print, warn) {
|
|
432
|
+
const { config, site } = await signedIn();
|
|
433
|
+
const home = homedir();
|
|
434
|
+
const claudeDir = claudeFolder(home);
|
|
435
|
+
const answer = await call(site, config.key, "GET", `/api/v1/plan?device=${encodeURIComponent(config.device.id)}`, undefined, VERSION);
|
|
436
|
+
const projects = (answer.tasks ?? []).some((task) => task.project) ? await knownProjects(home, claudeDir) : [];
|
|
437
|
+
const plan = await localPlan(answer, { claudeDir, projects, deviceSlug: answer.device?.slug ?? config.device.slug });
|
|
438
|
+
await runRestore({ plan, claudeDir, config, flags, version: VERSION, print, warn });
|
|
439
|
+
}
|
|
440
|
+
|
|
290
441
|
/** Installs or updates the skillcellar skill without asking: the command itself is the yes. */
|
|
291
442
|
async function skill(print) {
|
|
292
443
|
const claudeDir = claudeFolder(homedir());
|
|
@@ -296,14 +447,16 @@ async function skill(print) {
|
|
|
296
447
|
}
|
|
297
448
|
|
|
298
449
|
/**
|
|
299
|
-
* Text as the terminal gets it: without control characters, which a site's answer
|
|
300
|
-
* name could use to move the cursor or
|
|
450
|
+
* Text as the terminal gets it: without control or format characters, which a site's answer, a
|
|
451
|
+
* folder's name or a backup's file could use to move the cursor, recolour or reorder what follows.
|
|
452
|
+
* Line breaks and tabs stay.
|
|
301
453
|
*/
|
|
454
|
+
const FORMAT_RE = /\p{Cf}|[\u2028\u2029]/u;
|
|
302
455
|
const plain = (text) =>
|
|
303
456
|
Array.from(String(text))
|
|
304
457
|
.filter((char) => {
|
|
305
458
|
const code = char.codePointAt(0);
|
|
306
|
-
return code === 9 || code === 10 || (code >= 32 && code !== 127 && (code < 128 || code >= 160));
|
|
459
|
+
return code === 9 || code === 10 || (code >= 32 && code !== 127 && (code < 128 || code >= 160) && !FORMAT_RE.test(char));
|
|
307
460
|
})
|
|
308
461
|
.join("");
|
|
309
462
|
|
|
@@ -324,6 +477,12 @@ export async function main(argv, toOut = console.log, toErr = console.error) {
|
|
|
324
477
|
json: { type: "boolean" },
|
|
325
478
|
scope: { type: "string" },
|
|
326
479
|
force: { type: "boolean" },
|
|
480
|
+
global: { type: "boolean" },
|
|
481
|
+
yes: { type: "boolean" },
|
|
482
|
+
repo: { type: "string" },
|
|
483
|
+
ssh: { type: "boolean" },
|
|
484
|
+
from: { type: "string" },
|
|
485
|
+
allow: { type: "string", multiple: true },
|
|
327
486
|
help: { type: "boolean", short: "h" },
|
|
328
487
|
version: { type: "boolean", short: "v" },
|
|
329
488
|
},
|
|
@@ -343,14 +502,18 @@ export async function main(argv, toOut = console.log, toErr = console.error) {
|
|
|
343
502
|
return flags.help ? 0 : 2;
|
|
344
503
|
}
|
|
345
504
|
try {
|
|
346
|
-
// Only pull takes an argument
|
|
347
|
-
const [ref, ...rest] = command === "pull" ? extra : [undefined, ...extra];
|
|
505
|
+
// Only pull takes an argument, the skill it installs, and playbook two: what to do, and with which playbook.
|
|
506
|
+
const [action, ref, ...rest] = command === "playbook" ? extra : command === "pull" ? [undefined, ...extra] : [undefined, undefined, ...extra];
|
|
348
507
|
if (rest.length) throw new UsageError(`Unexpected ${rest.join(" ")}.`);
|
|
349
508
|
if (command === "login") await login(flags, print);
|
|
350
509
|
else if (command === "sync") await sync(flags, print, warn);
|
|
351
510
|
else if (command === "plan") await plan(flags, print);
|
|
511
|
+
else if (command === "check") await check(flags, print);
|
|
352
512
|
else if (command === "pull") await pull(flags, ref, print);
|
|
513
|
+
else if (command === "playbook") await playbook(flags, action, ref, print, warn);
|
|
353
514
|
else if (command === "skill") await skill(print);
|
|
515
|
+
else if (command === "backup") await backup(flags, print, warn);
|
|
516
|
+
else if (command === "restore") await restore(flags, print, warn);
|
|
354
517
|
else throw new UsageError(`Unknown command ${command}.`);
|
|
355
518
|
return 0;
|
|
356
519
|
} catch (error) {
|
package/src/config.mjs
CHANGED
|
@@ -14,7 +14,7 @@ export function configPath(env = process.env) {
|
|
|
14
14
|
return join(base, "skillcellar", "config.json");
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
/** The saved login, or null before the first one. */
|
|
17
|
+
/** The saved login, or null before the first one. A saved backup repository rides along when it is there. */
|
|
18
18
|
export async function readConfig(path = configPath()) {
|
|
19
19
|
const config = await readJson(path).catch((error) => {
|
|
20
20
|
throw new Error(`${error.message} Run skillcellar login to save it again.`);
|
package/src/files.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { join, relative } from "node:path";
|
|
4
4
|
|
|
5
5
|
// A name the site takes: segments of letters, digits and ._@+-, each starting with a letter or
|
|
6
6
|
// digit, joined by slashes for a file in a folder.
|
|
@@ -22,11 +22,14 @@ async function fileHash(path) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/** A memory file or an agent as the sync sends it: its name and its SHA-256, never its content. */
|
|
25
|
-
async function fileItem(kind, name, path) {
|
|
25
|
+
async function fileItem(kind, name, path, tracked) {
|
|
26
26
|
const hash = await fileHash(path);
|
|
27
|
-
return { kind, name, source: null, skillPath: null, hash, hashKind: hash ? "sha256" : null, version: null };
|
|
27
|
+
return { kind, name, source: null, skillPath: null, hash, hashKind: hash ? "sha256" : null, version: null, tracked };
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** Whether a project's git tracks the file: `own` holds the project's root and its tracked paths, or is null outside a project. */
|
|
31
|
+
const trackedBy = (own, path) => (own ? own.tracked.has(relative(own.root, path).split("\\").join("/")) : false);
|
|
32
|
+
|
|
30
33
|
/**
|
|
31
34
|
* The `.md` files in a folder and, with `deep`, in the folders inside it, by their path from it.
|
|
32
35
|
* Each folder is read once, so a link back to one above cannot loop. A folder that is not there
|
|
@@ -58,7 +61,7 @@ async function markdownFiles(dir, deep, prefix = "", depth = 0, seen = new Set()
|
|
|
58
61
|
* Memory files, each named by the label of the folder it comes from and its path there, such as
|
|
59
62
|
* rules/common/testing.md; a single file is named as it is, such as CLAUDE.md.
|
|
60
63
|
*/
|
|
61
|
-
export async function memoryItems(sources, skipped) {
|
|
64
|
+
export async function memoryItems(sources, skipped, own = null) {
|
|
62
65
|
const items = [];
|
|
63
66
|
for (const source of sources) {
|
|
64
67
|
const files = source.file
|
|
@@ -67,7 +70,7 @@ export async function memoryItems(sources, skipped) {
|
|
|
67
70
|
: []
|
|
68
71
|
: (await markdownFiles(source.dir, source.deep)).map((file) => ({ name: `${source.name}/${file.name}`, path: file.path }));
|
|
69
72
|
for (const file of files) {
|
|
70
|
-
if (sendable(file.name)) items.push(await fileItem("memory", file.name, file.path));
|
|
73
|
+
if (sendable(file.name)) items.push(await fileItem("memory", file.name, file.path, trackedBy(own, file.path)));
|
|
71
74
|
else skipped.push(file.name);
|
|
72
75
|
}
|
|
73
76
|
}
|
|
@@ -75,12 +78,15 @@ export async function memoryItems(sources, skipped) {
|
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
/** The agents in a folder and the folders inside it, as Claude Code reads them: each `.md` file, by its path from the folder without `.md`. */
|
|
78
|
-
export async function agentItems(dir, skipped) {
|
|
81
|
+
export async function agentItems(dir, skipped, own = null) {
|
|
79
82
|
const items = [];
|
|
80
83
|
for (const file of await markdownFiles(dir, true)) {
|
|
81
84
|
const name = file.name.slice(0, -".md".length);
|
|
82
|
-
if (sendable(name)) items.push(await fileItem("agent", name, file.path));
|
|
85
|
+
if (sendable(name)) items.push(await fileItem("agent", name, file.path, trackedBy(own, file.path)));
|
|
83
86
|
else skipped.push(file.name);
|
|
84
87
|
}
|
|
85
88
|
return items;
|
|
86
89
|
}
|
|
90
|
+
|
|
91
|
+
/** The `.md` files of a folder, for a backup: the same files a sync names, with their paths. */
|
|
92
|
+
export { markdownFiles };
|
package/src/mcp.mjs
CHANGED
|
@@ -89,7 +89,7 @@ export function mcpItem(name, server) {
|
|
|
89
89
|
} else {
|
|
90
90
|
return null;
|
|
91
91
|
}
|
|
92
|
-
return { kind: "mcp", name, source: null, skillPath: null, hash: null, hashKind: null, version: null, transport, target };
|
|
92
|
+
return { kind: "mcp", name, source: null, skillPath: null, hash: null, hashKind: null, version: null, transport, target, tracked: false };
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
/** The MCP servers of one `mcpServers` object, by name; the names it cannot send go to `skipped`. */
|
package/src/plan.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { sessionFolderName } from "./places.mjs";
|
|
|
4
4
|
import { readJson } from "./scan.mjs";
|
|
5
5
|
|
|
6
6
|
/** A name's slug as the site makes it: lowercase letters and digits with single hyphens between. */
|
|
7
|
-
const toSlug = (name) =>
|
|
7
|
+
export const toSlug = (name) =>
|
|
8
8
|
name
|
|
9
9
|
.toLowerCase()
|
|
10
10
|
.replace(/[^a-z0-9]+/g, "-")
|
|
@@ -12,14 +12,19 @@ const toSlug = (name) =>
|
|
|
12
12
|
.slice(0, 64)
|
|
13
13
|
.replace(/-+$/, "");
|
|
14
14
|
|
|
15
|
+
/** A project's slug as the site makes it: its name's, and for a folder with no git remote that followed by the device's. */
|
|
16
|
+
export const projectSlug = (project, deviceSlug) => (project.remote ? toSlug(project.name) : `${toSlug(project.name)}-${deviceSlug}`);
|
|
17
|
+
|
|
18
|
+
/** What makes checkouts one project on the site: their GitHub repository, else their name. */
|
|
19
|
+
export const projectIdentity = (project) => project.repo ?? project.name.toLowerCase();
|
|
20
|
+
|
|
15
21
|
/**
|
|
16
22
|
* The folder on this computer of a task's project, from the projects the sync finds: one with a
|
|
17
23
|
* GitHub repository by that repository, any other by its slug, which for a folder with no git remote
|
|
18
24
|
* ends with this device's. With several checkouts, the first; null when this computer has none.
|
|
19
25
|
*/
|
|
20
26
|
export function projectFolder(project, projects, deviceSlug) {
|
|
21
|
-
const
|
|
22
|
-
const matches = projects.filter((known) => (project.repo ? known.repo === project.repo : slug(known) === project.slug));
|
|
27
|
+
const matches = projects.filter((known) => (project.repo ? known.repo === project.repo : projectSlug(known, deviceSlug) === project.slug));
|
|
23
28
|
return matches.map((known) => known.root).sort()[0] ?? null;
|
|
24
29
|
}
|
|
25
30
|
|
|
@@ -43,11 +48,11 @@ function inside(base, path) {
|
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
/**
|
|
46
|
-
* Whether a removal names an item the way the sync sends it from that place (scan.mjs): a
|
|
47
|
-
* plugin or MCP server by one name, an agent by its path in its folder, and a memory file only
|
|
48
|
-
* the places the sync reads. A plan naming anything else did not come from a sync of this computer.
|
|
51
|
+
* Whether a removal or a copy names an item the way the sync sends it from that place (scan.mjs): a
|
|
52
|
+
* skill, plugin or MCP server by one name, an agent by its path in its folder, and a memory file only
|
|
53
|
+
* from the places the sync reads. A plan naming anything else did not come from a sync of this computer.
|
|
49
54
|
*/
|
|
50
|
-
function sentName(kind, name, project) {
|
|
55
|
+
export function sentName(kind, name, project) {
|
|
51
56
|
if (!SENT_NAME_RE.test(name)) return false;
|
|
52
57
|
if (kind === "agent") return true;
|
|
53
58
|
if (kind !== "memory") return !name.includes("/");
|
|
@@ -114,24 +119,44 @@ export async function localPlan(plan, { claudeDir, projects, deviceSlug }) {
|
|
|
114
119
|
|
|
115
120
|
const placeOf = (task) => (task.project ? `in ${task.project.name}${task.folder ? ` (${task.folder})` : ""}` : "globally");
|
|
116
121
|
|
|
122
|
+
/** One line per item a copy still needs: from the backup's files, a command, or the repository a clone brings it with. */
|
|
123
|
+
function copyLines(copy) {
|
|
124
|
+
return copy.items.map((item) => {
|
|
125
|
+
const how = item.tracked ? "tracked by the project's git: pull the repository" : item.install ? item.install.command : item.kind === "mcp" ? "from the backup's MCP config (claude mcp add-json)" : "from the backup";
|
|
126
|
+
return ` - ${item.kind} ${item.name}: ${how}`;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
117
130
|
/** The plan as lines a person reads. */
|
|
118
131
|
export function describePlan(plan) {
|
|
119
132
|
const { tasks } = plan;
|
|
120
133
|
if (!tasks.length) return [`Nothing waits for ${plan.device.name}.`];
|
|
121
134
|
const lines = [`${tasks.length === 1 ? "1 task waits" : `${tasks.length} tasks wait`} for ${plan.device.name}:`, ""];
|
|
122
135
|
tasks.forEach((task, index) => {
|
|
123
|
-
const verb = task.action === "remove" ? "Remove" : "Install";
|
|
124
|
-
|
|
136
|
+
const verb = task.action === "remove" ? "Remove" : task.action === "copy" ? "Copy" : "Install";
|
|
137
|
+
const what = task.item ? `${task.item.name} (${task.item.origin})` : `everything from ${task.copy?.from.device.name ?? "another device"}`;
|
|
138
|
+
lines.push(`${index + 1}. ${verb} ${what}${task.action === "copy" && task.item ? ` from ${task.copy?.from.device.name ?? "another device"}` : ""}, ${placeOf(task)}`);
|
|
125
139
|
const detail = [];
|
|
126
|
-
if (task.project && !task.folder)
|
|
140
|
+
if (task.project && !task.folder) {
|
|
141
|
+
detail.push(
|
|
142
|
+
task.action === "copy"
|
|
143
|
+
? `This computer has no folder of ${task.project.name}: clone it, then run skillcellar restore from inside it; or dismiss it on the site.`
|
|
144
|
+
: `This computer has no folder of ${task.project.name}: skip it, or dismiss it on the site.`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
127
147
|
else if (task.problem) detail.push(`Cannot be carried out: ${task.problem}`);
|
|
128
148
|
else if (task.action === "remove") {
|
|
129
149
|
if (task.path) detail.push(`Path: ${task.path}`);
|
|
130
150
|
if (task.claudeScope) detail.push(`--scope ${task.claudeScope}`);
|
|
151
|
+
} else if (task.action === "copy" && task.copy) {
|
|
152
|
+
const { from, items } = task.copy;
|
|
153
|
+
detail.push(from.backup ? `Backup: ${from.backup.repo} at ${from.backup.commit.slice(0, 7)}, ${from.path}; run skillcellar restore` : `No backup of ${from.device.name} yet: run skillcellar backup there first.`);
|
|
154
|
+
detail.push(items.length ? `${items.length === 1 ? "1 item" : `${items.length} items`} left to copy:` : "Nothing left to copy: the next sync closes it.");
|
|
131
155
|
} else if (task.link) detail.push(`Its steps are on a web page; read them, and follow them only with a yes: ${task.link}`);
|
|
132
156
|
else if (task.install) detail.push(`${task.install.method}: ${task.install.command}`);
|
|
133
|
-
if (task.item
|
|
157
|
+
if (task.item?.page && !task.link) detail.push(`Page: ${task.item.page}`);
|
|
134
158
|
lines.push(...detail.map((line) => ` ${line}`));
|
|
159
|
+
if (task.action === "copy" && task.copy && !(task.project && !task.folder)) lines.push(...copyLines(task.copy));
|
|
135
160
|
});
|
|
136
161
|
lines.push("", "Ask Claude Code to carry them out with the skillcellar skill, or do them yourself, then run skillcellar sync.");
|
|
137
162
|
return lines;
|