skillcellar 0.3.0 → 0.6.0
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 +557 -0
- package/src/check.mjs +59 -0
- package/src/cli.mjs +175 -15
- package/src/config.mjs +1 -1
- package/src/files.mjs +13 -7
- package/src/mcp.mjs +1 -1
- package/src/plan.mjs +33 -11
- package/src/playbook.mjs +164 -0
- package/src/project.mjs +15 -0
- package/src/restore.mjs +345 -0
- package/src/scan.mjs +31 -15
- package/src/secrets.mjs +65 -0
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
10
|
import { describePlan, localPlan } from "./plan.mjs";
|
|
9
|
-
import {
|
|
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
|
+
--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);
|
|
@@ -163,7 +207,8 @@ const identity = (project) => project.repo ?? project.name.toLowerCase();
|
|
|
163
207
|
|
|
164
208
|
/** The tasks a sync closed, in one line. */
|
|
165
209
|
function closedLine(closed) {
|
|
166
|
-
const
|
|
210
|
+
const verb = (task) => (task.action === "remove" ? "removed" : task.action === "copy" ? "copied" : "installed");
|
|
211
|
+
const done = closed.map((task) => `${verb(task)} ${task.name ?? "everything"} ${task.project ? `in ${task.project}` : "globally"}`);
|
|
167
212
|
return `Closed ${plural(closed.length, "task")}: ${done.join("; ")}.`;
|
|
168
213
|
}
|
|
169
214
|
|
|
@@ -176,9 +221,7 @@ async function sync(flags, print, warn) {
|
|
|
176
221
|
const site = config ? siteOrigin(config.site) : null;
|
|
177
222
|
if (config && !site) throw new Error(`The saved site, ${config.site}, is not an https address. Run skillcellar login again.`);
|
|
178
223
|
// 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
|
-
}
|
|
224
|
+
await checkClaudeFolder(claudeDir);
|
|
182
225
|
|
|
183
226
|
const claudeJson = (await readJson(claudeJsonPath(home))) ?? {};
|
|
184
227
|
const cwd = process.cwd();
|
|
@@ -205,7 +248,7 @@ async function sync(flags, print, warn) {
|
|
|
205
248
|
}
|
|
206
249
|
let scanned;
|
|
207
250
|
try {
|
|
208
|
-
scanned = await scanProject({ root: project.root, folders: [...project.folders], claudeDir, claudeJson });
|
|
251
|
+
scanned = await scanProject({ root: project.root, folders: [...project.folders], claudeDir, claudeJson, tracked: await trackedFiles(project.root) });
|
|
209
252
|
} catch (error) {
|
|
210
253
|
// One project's broken file stops only that project in a sync of the whole computer.
|
|
211
254
|
if (!whole) throw error;
|
|
@@ -260,15 +303,21 @@ async function plan(flags, print) {
|
|
|
260
303
|
const answer = await call(site, config.key, "GET", `/api/v1/plan?device=${encodeURIComponent(config.device.id)}`, undefined, VERSION);
|
|
261
304
|
const home = homedir();
|
|
262
305
|
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
|
-
: [];
|
|
306
|
+
const projects = (answer.tasks ?? []).some((task) => task.project) ? await knownProjects(home, claudeDir) : [];
|
|
266
307
|
// The device's slug as the site has it now: a rename changes the slugs of its folders' projects.
|
|
267
308
|
const local = await localPlan(answer, { claudeDir, projects, deviceSlug: answer.device?.slug ?? config.device.slug });
|
|
268
309
|
if (flags.json) print(JSON.stringify(local, null, 2));
|
|
269
310
|
else for (const line of describePlan(local)) print(line);
|
|
270
311
|
}
|
|
271
312
|
|
|
313
|
+
/** Prints which of this computer's skills and plugins their sources have moved past, as the site checked them. */
|
|
314
|
+
async function check(flags, print) {
|
|
315
|
+
const { config, site } = await signedIn();
|
|
316
|
+
const answer = await call(site, config.key, "GET", `/api/v1/check?device=${encodeURIComponent(config.device.id)}`, undefined, VERSION);
|
|
317
|
+
if (flags.json) print(JSON.stringify(answer, null, 2));
|
|
318
|
+
else for (const line of describeCheck(answer)) print(line);
|
|
319
|
+
}
|
|
320
|
+
|
|
272
321
|
/** Installs a skill from the site into the global skills folder, or the project's. */
|
|
273
322
|
async function pull(flags, ref, print) {
|
|
274
323
|
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 +336,105 @@ async function pull(flags, ref, print) {
|
|
|
287
336
|
print(`Pulled ${ref}, version ${pulled.version}, into ${await installPulled(skills, pulled, flags.force === true)}.`);
|
|
288
337
|
}
|
|
289
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Applies a playbook to the project the command runs in, or globally, or prints where this
|
|
341
|
+
* computer stands with it. Either way the site checks it against what this computer has now,
|
|
342
|
+
* scanned as a sync scans it, and keeps none of it.
|
|
343
|
+
*/
|
|
344
|
+
async function playbook(flags, action, ref, print, warn) {
|
|
345
|
+
if (action !== "apply" && action !== "status") throw new UsageError("Run playbook apply <username>/<name>, or playbook status <username>/<name>.");
|
|
346
|
+
if (!ref || !PULL_REF_RE.test(ref)) throw new UsageError("Name the playbook as <username>/<name>, or <name> for one of your own.");
|
|
347
|
+
const { config, site } = await signedIn();
|
|
348
|
+
const home = homedir();
|
|
349
|
+
const claudeDir = claudeFolder(home);
|
|
350
|
+
await checkClaudeFolder(claudeDir);
|
|
351
|
+
const here = flags.global === true ? null : await findProject(process.cwd(), home);
|
|
352
|
+
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.");
|
|
353
|
+
if (action === "apply" && here) await checkProjectSkills(here.root);
|
|
354
|
+
|
|
355
|
+
const claudeJson = (await readJson(claudeJsonPath(home))) ?? {};
|
|
356
|
+
const global = await scanGlobal({ home, claudeDir, claudeJson });
|
|
357
|
+
// The project as a sync reads it: every folder in it Claude Code ran in, as well as this one.
|
|
358
|
+
const known = here ? (await findProjects({ home, claudeDir, claudeJson, cwd: process.cwd() })).find((entry) => entry.root === here.root) : null;
|
|
359
|
+
const folders = here ? [...new Set([here.root, ...(known?.folders ?? []), process.cwd()])] : [];
|
|
360
|
+
const project = here ? await scanProject({ root: here.root, folders, claudeDir, claudeJson, tracked: await trackedFiles(here.root) }) : null;
|
|
361
|
+
const body = { ref, global: global.items, project: project?.items ?? null, updates: action === "status" };
|
|
362
|
+
const answer = readPlaybook(await call(site, config.key, "POST", "/api/v1/playbook", body, VERSION));
|
|
363
|
+
|
|
364
|
+
if (action === "status") {
|
|
365
|
+
if (flags.json) print(JSON.stringify(answer, null, 2));
|
|
366
|
+
else for (const line of describeStatus(answer, here?.name ?? null)) print(line);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const caution = cautionLine(answer);
|
|
370
|
+
if (caution) warn(caution);
|
|
371
|
+
const skills = here ? join(here.root, ".claude", "skills") : join(claudeDir, "skills");
|
|
372
|
+
const fetchPull = (pullRef) => call(site, config.key, "GET", `/api/v1/pull?ref=${encodeURIComponent(pullRef)}`, undefined, VERSION);
|
|
373
|
+
// Someone else's playbook writes what its author chose, scripts included: only after a yes.
|
|
374
|
+
const confirm = answer.playbook?.own
|
|
375
|
+
? null
|
|
376
|
+
: async (files) => {
|
|
377
|
+
if (flags.yes === true) return true;
|
|
378
|
+
if (!interactive()) throw new UsageError("This playbook is someone else's: read what --dry-run lists, then run apply again with --yes.");
|
|
379
|
+
print(`It would write ${files.length === 1 ? "1 file" : `${files.length} files`}:`);
|
|
380
|
+
for (const { path, executable } of files) print(` ${path}${executable ? " (a program it can run)" : ""}`);
|
|
381
|
+
return /^y(es)?$/i.test(await ask("Write them? [y/N] "));
|
|
382
|
+
};
|
|
383
|
+
const { lines, failed } = await applyPlaybook({ answer, skills, force: flags.force === true, dryRun: flags["dry-run"] === true, fetchPull, confirm });
|
|
384
|
+
for (const line of lines) print(line);
|
|
385
|
+
for (const note of failed) warn(`Not written: ${note}`);
|
|
386
|
+
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.`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** This computer's projects, as the sync finds them. */
|
|
390
|
+
async function knownProjects(home, claudeDir) {
|
|
391
|
+
return findProjects({ home, claudeDir, claudeJson: (await readJson(claudeJsonPath(home))) ?? {}, cwd: process.cwd() });
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Backs up this computer to a private GitHub repository the user owns, with their own git, and
|
|
396
|
+
* reports the commit to the site. The repository is remembered from one run to the next.
|
|
397
|
+
*/
|
|
398
|
+
async function backup(flags, print, warn) {
|
|
399
|
+
const home = homedir();
|
|
400
|
+
const claudeDir = claudeFolder(home);
|
|
401
|
+
const dryRun = flags["dry-run"] === true;
|
|
402
|
+
const config = await readConfig();
|
|
403
|
+
if (!config && !dryRun) throw new Error("Run skillcellar login first.");
|
|
404
|
+
const site = config ? siteOrigin(config.site) : null;
|
|
405
|
+
if (config && !site) throw new Error(`The saved site, ${config.site}, is not an https address. Run skillcellar login again.`);
|
|
406
|
+
await checkClaudeFolder(claudeDir);
|
|
407
|
+
const repoValue = flags.repo ?? config?.backup?.repo;
|
|
408
|
+
if (repoValue === undefined) throw new UsageError("Name the private repository the backup goes to with --repo <owner>/<repo>, once; it is remembered.");
|
|
409
|
+
const repo = parseBackupRepo(repoValue, flags.ssh === true || (flags.repo === undefined && config?.backup?.ssh === true));
|
|
410
|
+
if (!repo) throw new UsageError("--repo takes a GitHub repository as <owner>/<repo>, its https address, or its git@github.com address.");
|
|
411
|
+
// The device's slug as the site has it now: a rename changes the folder the backup goes under.
|
|
412
|
+
const device = config
|
|
413
|
+
? ((await call(site, config.key, "GET", `/api/v1/plan?device=${encodeURIComponent(config.device.id)}`, undefined, VERSION)).device ?? config.device)
|
|
414
|
+
: { id: null, slug: "this-computer", name: "this computer" };
|
|
415
|
+
const claudeJson = (await readJson(claudeJsonPath(home))) ?? {};
|
|
416
|
+
const projects = await knownProjects(home, claudeDir);
|
|
417
|
+
const allowed = (Array.isArray(flags.allow) ? flags.allow : []).map((path) => path.trim()).filter(Boolean);
|
|
418
|
+
// The repository is remembered as soon as a push has gone through, whatever the site answers after.
|
|
419
|
+
const remember = () => (config ? writeConfig({ ...config, device: { id: device.id, slug: device.slug, name: device.name }, backup: { repo: repo.name, ssh: repo.ssh } }) : Promise.resolve());
|
|
420
|
+
const commit = await runBackup({ repo, device, claudeDir, claudeJson, projects, trackedOf: trackedFiles, version: VERSION, dryRun, allowed, print, onPushed: remember });
|
|
421
|
+
if (dryRun || !commit) return;
|
|
422
|
+
const recorded = await call(site, config.key, "POST", "/api/v1/backup", { device: device.id, repo: repo.name, commit }, VERSION);
|
|
423
|
+
const slug = recorded.device?.slug ?? device.slug;
|
|
424
|
+
if (slug !== device.slug) warn(`The site now calls this computer ${recorded.device.name}; the next backup goes under devices/${slug}.`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Carries out this computer's copy tasks from the other computers' backups. */
|
|
428
|
+
async function restore(flags, print, warn) {
|
|
429
|
+
const { config, site } = await signedIn();
|
|
430
|
+
const home = homedir();
|
|
431
|
+
const claudeDir = claudeFolder(home);
|
|
432
|
+
const answer = await call(site, config.key, "GET", `/api/v1/plan?device=${encodeURIComponent(config.device.id)}`, undefined, VERSION);
|
|
433
|
+
const projects = (answer.tasks ?? []).some((task) => task.project) ? await knownProjects(home, claudeDir) : [];
|
|
434
|
+
const plan = await localPlan(answer, { claudeDir, projects, deviceSlug: answer.device?.slug ?? config.device.slug });
|
|
435
|
+
await runRestore({ plan, claudeDir, config, flags, version: VERSION, print, warn });
|
|
436
|
+
}
|
|
437
|
+
|
|
290
438
|
/** Installs or updates the skillcellar skill without asking: the command itself is the yes. */
|
|
291
439
|
async function skill(print) {
|
|
292
440
|
const claudeDir = claudeFolder(homedir());
|
|
@@ -296,14 +444,16 @@ async function skill(print) {
|
|
|
296
444
|
}
|
|
297
445
|
|
|
298
446
|
/**
|
|
299
|
-
* Text as the terminal gets it: without control characters, which a site's answer
|
|
300
|
-
* name could use to move the cursor or
|
|
447
|
+
* Text as the terminal gets it: without control or format characters, which a site's answer, a
|
|
448
|
+
* folder's name or a backup's file could use to move the cursor, recolour or reorder what follows.
|
|
449
|
+
* Line breaks and tabs stay.
|
|
301
450
|
*/
|
|
451
|
+
const FORMAT_RE = /\p{Cf}|[\u2028\u2029]/u;
|
|
302
452
|
const plain = (text) =>
|
|
303
453
|
Array.from(String(text))
|
|
304
454
|
.filter((char) => {
|
|
305
455
|
const code = char.codePointAt(0);
|
|
306
|
-
return code === 9 || code === 10 || (code >= 32 && code !== 127 && (code < 128 || code >= 160));
|
|
456
|
+
return code === 9 || code === 10 || (code >= 32 && code !== 127 && (code < 128 || code >= 160) && !FORMAT_RE.test(char));
|
|
307
457
|
})
|
|
308
458
|
.join("");
|
|
309
459
|
|
|
@@ -324,6 +474,12 @@ export async function main(argv, toOut = console.log, toErr = console.error) {
|
|
|
324
474
|
json: { type: "boolean" },
|
|
325
475
|
scope: { type: "string" },
|
|
326
476
|
force: { type: "boolean" },
|
|
477
|
+
global: { type: "boolean" },
|
|
478
|
+
yes: { type: "boolean" },
|
|
479
|
+
repo: { type: "string" },
|
|
480
|
+
ssh: { type: "boolean" },
|
|
481
|
+
from: { type: "string" },
|
|
482
|
+
allow: { type: "string", multiple: true },
|
|
327
483
|
help: { type: "boolean", short: "h" },
|
|
328
484
|
version: { type: "boolean", short: "v" },
|
|
329
485
|
},
|
|
@@ -343,14 +499,18 @@ export async function main(argv, toOut = console.log, toErr = console.error) {
|
|
|
343
499
|
return flags.help ? 0 : 2;
|
|
344
500
|
}
|
|
345
501
|
try {
|
|
346
|
-
// Only pull takes an argument
|
|
347
|
-
const [ref, ...rest] = command === "pull" ? extra : [undefined, ...extra];
|
|
502
|
+
// Only pull takes an argument, the skill it installs, and playbook two: what to do, and with which playbook.
|
|
503
|
+
const [action, ref, ...rest] = command === "playbook" ? extra : command === "pull" ? [undefined, ...extra] : [undefined, undefined, ...extra];
|
|
348
504
|
if (rest.length) throw new UsageError(`Unexpected ${rest.join(" ")}.`);
|
|
349
505
|
if (command === "login") await login(flags, print);
|
|
350
506
|
else if (command === "sync") await sync(flags, print, warn);
|
|
351
507
|
else if (command === "plan") await plan(flags, print);
|
|
508
|
+
else if (command === "check") await check(flags, print);
|
|
352
509
|
else if (command === "pull") await pull(flags, ref, print);
|
|
510
|
+
else if (command === "playbook") await playbook(flags, action, ref, print, warn);
|
|
353
511
|
else if (command === "skill") await skill(print);
|
|
512
|
+
else if (command === "backup") await backup(flags, print, warn);
|
|
513
|
+
else if (command === "restore") await restore(flags, print, warn);
|
|
354
514
|
else throw new UsageError(`Unknown command ${command}.`);
|
|
355
515
|
return 0;
|
|
356
516
|
} 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,16 @@ 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
|
+
|
|
15
18
|
/**
|
|
16
19
|
* The folder on this computer of a task's project, from the projects the sync finds: one with a
|
|
17
20
|
* GitHub repository by that repository, any other by its slug, which for a folder with no git remote
|
|
18
21
|
* ends with this device's. With several checkouts, the first; null when this computer has none.
|
|
19
22
|
*/
|
|
20
23
|
export function projectFolder(project, projects, deviceSlug) {
|
|
21
|
-
const
|
|
22
|
-
const matches = projects.filter((known) => (project.repo ? known.repo === project.repo : slug(known) === project.slug));
|
|
24
|
+
const matches = projects.filter((known) => (project.repo ? known.repo === project.repo : projectSlug(known, deviceSlug) === project.slug));
|
|
23
25
|
return matches.map((known) => known.root).sort()[0] ?? null;
|
|
24
26
|
}
|
|
25
27
|
|
|
@@ -43,11 +45,11 @@ function inside(base, path) {
|
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
/**
|
|
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.
|
|
48
|
+
* Whether a removal or a copy names an item the way the sync sends it from that place (scan.mjs): a
|
|
49
|
+
* skill, plugin or MCP server by one name, an agent by its path in its folder, and a memory file only
|
|
50
|
+
* from the places the sync reads. A plan naming anything else did not come from a sync of this computer.
|
|
49
51
|
*/
|
|
50
|
-
function sentName(kind, name, project) {
|
|
52
|
+
export function sentName(kind, name, project) {
|
|
51
53
|
if (!SENT_NAME_RE.test(name)) return false;
|
|
52
54
|
if (kind === "agent") return true;
|
|
53
55
|
if (kind !== "memory") return !name.includes("/");
|
|
@@ -114,24 +116,44 @@ export async function localPlan(plan, { claudeDir, projects, deviceSlug }) {
|
|
|
114
116
|
|
|
115
117
|
const placeOf = (task) => (task.project ? `in ${task.project.name}${task.folder ? ` (${task.folder})` : ""}` : "globally");
|
|
116
118
|
|
|
119
|
+
/** One line per item a copy still needs: from the backup's files, a command, or the repository a clone brings it with. */
|
|
120
|
+
function copyLines(copy) {
|
|
121
|
+
return copy.items.map((item) => {
|
|
122
|
+
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";
|
|
123
|
+
return ` - ${item.kind} ${item.name}: ${how}`;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
117
127
|
/** The plan as lines a person reads. */
|
|
118
128
|
export function describePlan(plan) {
|
|
119
129
|
const { tasks } = plan;
|
|
120
130
|
if (!tasks.length) return [`Nothing waits for ${plan.device.name}.`];
|
|
121
131
|
const lines = [`${tasks.length === 1 ? "1 task waits" : `${tasks.length} tasks wait`} for ${plan.device.name}:`, ""];
|
|
122
132
|
tasks.forEach((task, index) => {
|
|
123
|
-
const verb = task.action === "remove" ? "Remove" : "Install";
|
|
124
|
-
|
|
133
|
+
const verb = task.action === "remove" ? "Remove" : task.action === "copy" ? "Copy" : "Install";
|
|
134
|
+
const what = task.item ? `${task.item.name} (${task.item.origin})` : `everything from ${task.copy?.from.device.name ?? "another device"}`;
|
|
135
|
+
lines.push(`${index + 1}. ${verb} ${what}${task.action === "copy" && task.item ? ` from ${task.copy?.from.device.name ?? "another device"}` : ""}, ${placeOf(task)}`);
|
|
125
136
|
const detail = [];
|
|
126
|
-
if (task.project && !task.folder)
|
|
137
|
+
if (task.project && !task.folder) {
|
|
138
|
+
detail.push(
|
|
139
|
+
task.action === "copy"
|
|
140
|
+
? `This computer has no folder of ${task.project.name}: clone it, then run skillcellar restore from inside it; or dismiss it on the site.`
|
|
141
|
+
: `This computer has no folder of ${task.project.name}: skip it, or dismiss it on the site.`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
127
144
|
else if (task.problem) detail.push(`Cannot be carried out: ${task.problem}`);
|
|
128
145
|
else if (task.action === "remove") {
|
|
129
146
|
if (task.path) detail.push(`Path: ${task.path}`);
|
|
130
147
|
if (task.claudeScope) detail.push(`--scope ${task.claudeScope}`);
|
|
148
|
+
} else if (task.action === "copy" && task.copy) {
|
|
149
|
+
const { from, items } = task.copy;
|
|
150
|
+
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.`);
|
|
151
|
+
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
152
|
} 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
153
|
else if (task.install) detail.push(`${task.install.method}: ${task.install.command}`);
|
|
133
|
-
if (task.item
|
|
154
|
+
if (task.item?.page && !task.link) detail.push(`Page: ${task.item.page}`);
|
|
134
155
|
lines.push(...detail.map((line) => ` ${line}`));
|
|
156
|
+
if (task.action === "copy" && task.copy && !(task.project && !task.folder)) lines.push(...copyLines(task.copy));
|
|
135
157
|
});
|
|
136
158
|
lines.push("", "Ask Claude Code to carry them out with the skillcellar skill, or do them yourself, then run skillcellar sync.");
|
|
137
159
|
return lines;
|
package/src/playbook.mjs
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { lstat, readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { field } from "./check.mjs";
|
|
4
|
+
import { installPulled, PULL_REF_RE, readPulled } from "./pull.mjs";
|
|
5
|
+
|
|
6
|
+
// The playbook skill's folder, as the site names it: playbook-<slug>, up to a skill name's 64 characters.
|
|
7
|
+
const SKILL_NAME_RE = /^playbook-[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
8
|
+
const SKILL_BYTES_MAX = 1024 * 1024;
|
|
9
|
+
const STATUSES = new Set(["installed", "outdated", "missing"]);
|
|
10
|
+
const KINDS = { catalog: "catalog", skill: "this site", github: "GitHub", web: "web page" };
|
|
11
|
+
|
|
12
|
+
const plural = (n, word) => `${n} ${n === 1 ? word : `${word}s`}`;
|
|
13
|
+
const label = (skill) => `${field(skill.name, 80)} (${KINDS[skill.kind] ?? "skill"})`;
|
|
14
|
+
const placeOf = (skill) => (skill.where === "project" ? "in the project" : "globally");
|
|
15
|
+
|
|
16
|
+
/** The site's answer, checked before anything is written or printed: the playbook skill's name and text, and its skills. */
|
|
17
|
+
export function readPlaybook(answer) {
|
|
18
|
+
const skill = answer?.skill;
|
|
19
|
+
const skills = Array.isArray(answer?.skills) ? answer.skills : null;
|
|
20
|
+
const valid =
|
|
21
|
+
typeof skill?.name === "string" &&
|
|
22
|
+
skill.name.length <= 64 &&
|
|
23
|
+
SKILL_NAME_RE.test(skill.name) &&
|
|
24
|
+
typeof skill.content === "string" &&
|
|
25
|
+
Buffer.byteLength(skill.content) <= SKILL_BYTES_MAX &&
|
|
26
|
+
(answer.installPrompt === null || typeof answer.installPrompt === "string") &&
|
|
27
|
+
skills?.every((entry) => entry && STATUSES.has(entry.status) && (entry.pull === null || (typeof entry.pull === "string" && PULL_REF_RE.test(entry.pull))));
|
|
28
|
+
if (!valid) throw new Error("The site's answer is not a playbook this command can apply.");
|
|
29
|
+
return answer;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** For someone else's playbook, the line that says nobody has reviewed it; null for one's own. */
|
|
33
|
+
export function cautionLine(answer) {
|
|
34
|
+
if (answer.playbook?.own) return null;
|
|
35
|
+
return `${field(answer.playbook?.name, 80)} is published by ${field(answer.playbook?.author, 40)}, not curated: nobody at the site has reviewed it. Read each step before you let Claude Code run it.`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Applies a playbook into the skills folder `skills`: writes the playbook skill, and pulls the
|
|
40
|
+
* skills of the site it uses that the computer lacks, each checked as `pull` checks it and written
|
|
41
|
+
* into a hidden folder first, then moved into place. A folder already there is kept unless `force`.
|
|
42
|
+
* Nothing it writes leaves `skills`. With `dryRun`, it lists the files it would write and writes
|
|
43
|
+
* none; otherwise `confirm`, when given, sees each file first and can refuse them all. Answers the
|
|
44
|
+
* lines to print and the pulls and writes that failed.
|
|
45
|
+
*/
|
|
46
|
+
export async function applyPlaybook({ answer, skills, force, dryRun, fetchPull, confirm = null }) {
|
|
47
|
+
const lines = [];
|
|
48
|
+
const writes = [];
|
|
49
|
+
const kept = [];
|
|
50
|
+
const failed = [];
|
|
51
|
+
|
|
52
|
+
const folder = join(skills, answer.skill.name);
|
|
53
|
+
const there = await lstat(folder).catch(() => null);
|
|
54
|
+
// Up to date is the folder apply writes, as status hashes it: that SKILL.md alone, read only as a
|
|
55
|
+
// file of a skill's size, since a checkout can put a link to a device or a pipe there.
|
|
56
|
+
const only = there?.isDirectory() ? (await readdir(folder).catch(() => [])).join("/") === "SKILL.md" : false;
|
|
57
|
+
const file = only ? await lstat(join(folder, "SKILL.md")).catch(() => null) : null;
|
|
58
|
+
const current = file?.isFile() && file.size <= SKILL_BYTES_MAX ? await readFile(join(folder, "SKILL.md"), "utf8").catch(() => null) : null;
|
|
59
|
+
if (current === answer.skill.content) lines.push(`The playbook skill in ${folder} is up to date.`);
|
|
60
|
+
else if (there && !force) kept.push(`${folder}, which differs from the playbook on the site; run playbook apply again with --force to replace it`);
|
|
61
|
+
else {
|
|
62
|
+
const files = [{ segments: ["SKILL.md"], content: Buffer.from(answer.skill.content), executable: false }];
|
|
63
|
+
writes.push({ ref: null, pulled: { slug: answer.skill.name, version: null, files }, replace: !!there });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Each folder is written once: a pulled skill never takes the playbook skill's place, or another's.
|
|
67
|
+
const folders = new Set([answer.skill.name]);
|
|
68
|
+
for (const skill of answer.skills) {
|
|
69
|
+
if (skill.status !== "missing" || !skill.pull || skill.forSale) continue;
|
|
70
|
+
let pulled;
|
|
71
|
+
try {
|
|
72
|
+
pulled = readPulled(await fetchPull(skill.pull));
|
|
73
|
+
} catch (error) {
|
|
74
|
+
failed.push(`${skill.pull}: ${field(error.message, 300)}`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (folders.has(pulled.slug)) {
|
|
78
|
+
failed.push(`${skill.pull}: its folder, ${pulled.slug}, is another of this playbook's.`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
folders.add(pulled.slug);
|
|
82
|
+
const target = join(skills, pulled.slug);
|
|
83
|
+
const exists = await lstat(target).catch(() => null);
|
|
84
|
+
if (exists && !force) kept.push(`${target}, which is there already; run playbook apply again with --force to replace it`);
|
|
85
|
+
else writes.push({ ref: skill.pull, pulled, replace: !!exists });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const files = writes.flatMap(({ pulled }) => pulled.files.map((entry) => ({ path: join(skills, pulled.slug, ...entry.segments), executable: entry.executable })));
|
|
89
|
+
if (dryRun) {
|
|
90
|
+
for (const { pulled, replace } of writes) if (replace) lines.push(`Would replace ${join(skills, pulled.slug)} and everything in it.`);
|
|
91
|
+
lines.push(files.length ? `Would write ${plural(files.length, "file")}:` : "Would write nothing.", ...files.map(({ path }) => ` ${path}`));
|
|
92
|
+
} else if (files.length && confirm && !(await confirm(files))) {
|
|
93
|
+
return { lines: ["Nothing written."], failed };
|
|
94
|
+
} else {
|
|
95
|
+
for (const { ref, pulled, replace } of writes) {
|
|
96
|
+
try {
|
|
97
|
+
const target = await installPulled(skills, pulled, replace);
|
|
98
|
+
lines.push(ref ? `Pulled ${ref}, version ${field(pulled.version, 20)}, into ${target}.` : `Wrote the playbook skill into ${target}.`);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
failed.push(`${ref ?? "the playbook skill"}: ${field(error.message, 300)}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
for (const note of kept) lines.push(`Kept ${note}.`);
|
|
105
|
+
|
|
106
|
+
const here = answer.skills.filter((skill) => skill.status !== "missing");
|
|
107
|
+
if (here.length) lines.push(`Already on this computer: ${here.map((skill) => `${field(skill.name, 80)}${skill.where === "global" ? " (globally)" : ""}`).join(", ")}.`);
|
|
108
|
+
for (const skill of answer.skills.filter((entry) => entry.status === "missing" && entry.kind === "skill" && !entry.pull)) {
|
|
109
|
+
lines.push(
|
|
110
|
+
skill.forSale
|
|
111
|
+
? `Requires purchase: ${field(skill.name, 80)}. Buy it at ${field(skill.page, 200) || "its page"}, then run playbook apply again.`
|
|
112
|
+
: `Not pulled: ${field(skill.name, 80)}, which the site names no way to pull. ${field(skill.install, 300)}.`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const elsewhere = answer.skills.filter((skill) => skill.status === "missing" && skill.kind !== "skill").length;
|
|
116
|
+
if (answer.installPrompt && elsewhere) {
|
|
117
|
+
lines.push(
|
|
118
|
+
"",
|
|
119
|
+
`${elsewhere === 1 ? "1 of its skills comes" : `${elsewhere} of its skills come`} from elsewhere, and this command never runs their installers. Paste this prompt into Claude Code to install ${elsewhere === 1 ? "it" : "them"}:`,
|
|
120
|
+
"",
|
|
121
|
+
answer.installPrompt,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return { lines, failed };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The site's check of a playbook against this computer, as lines a person reads. `project` names the project the command ran in. */
|
|
128
|
+
export function describeStatus(answer, project) {
|
|
129
|
+
const skills = answer.skills;
|
|
130
|
+
const ref = field(answer.playbook?.ref, 120);
|
|
131
|
+
const outdated = skills.filter((skill) => skill.status === "outdated");
|
|
132
|
+
const missing = skills.filter((skill) => skill.status === "missing");
|
|
133
|
+
const current = skills.filter((skill) => skill.status === "installed");
|
|
134
|
+
const where = project ? `in ${field(project, 80)} and globally` : "globally";
|
|
135
|
+
const lines = [
|
|
136
|
+
`${field(answer.playbook?.name, 80)} (${ref}) on this computer, ${where}: ${skills.length - missing.length} of ${plural(skills.length, "skill")} installed${outdated.length ? `, ${outdated.length} of them outdated` : ""}.`,
|
|
137
|
+
];
|
|
138
|
+
const state = answer.skill?.state;
|
|
139
|
+
lines.push(
|
|
140
|
+
state === "current"
|
|
141
|
+
? "The playbook skill is installed."
|
|
142
|
+
: state === "other"
|
|
143
|
+
? `The playbook skill here is another copy, older or edited: skillcellar playbook apply ${ref} --force replaces it.`
|
|
144
|
+
: `The playbook skill is not installed: skillcellar playbook apply ${ref} writes it.`,
|
|
145
|
+
);
|
|
146
|
+
if (current.length) lines.push("", "Installed:", ...current.map((skill) => `- ${label(skill)}, ${placeOf(skill)}`));
|
|
147
|
+
if (outdated.length) {
|
|
148
|
+
lines.push("", "Outdated:", ...outdated.map((skill) => `- ${label(skill)}, ${placeOf(skill)}${skill.update ? `: ${field(skill.update, 300)}` : ""}`));
|
|
149
|
+
}
|
|
150
|
+
if (missing.length) {
|
|
151
|
+
lines.push(
|
|
152
|
+
"",
|
|
153
|
+
"Missing:",
|
|
154
|
+
...missing.map((skill) =>
|
|
155
|
+
skill.forSale
|
|
156
|
+
? `- ${label(skill)}: requires purchase at ${field(skill.page, 200) || "its page"}`
|
|
157
|
+
: skill.pull
|
|
158
|
+
? `- ${label(skill)}: skillcellar playbook apply ${ref} pulls it`
|
|
159
|
+
: `- ${label(skill)}: ${field(skill.install, 300)}`,
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return lines;
|
|
164
|
+
}
|