skillcellar 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +74 -2
- package/bin/skillcellar.mjs +4 -0
- package/package.json +26 -4
- package/skill/SKILL.md +78 -0
- package/src/api.mjs +41 -0
- package/src/backup.mjs +557 -0
- package/src/check.mjs +59 -0
- package/src/cli.mjs +525 -0
- package/src/config.mjs +35 -0
- package/src/files.mjs +92 -0
- package/src/hash.mjs +38 -0
- package/src/mcp.mjs +104 -0
- package/src/places.mjs +76 -0
- package/src/plan.mjs +160 -0
- package/src/playbook.mjs +164 -0
- package/src/project.mjs +77 -0
- package/src/prompt.mjs +55 -0
- package/src/pull.mjs +95 -0
- package/src/restore.mjs +345 -0
- package/src/scan.mjs +297 -0
- package/src/secrets.mjs +65 -0
- package/src/skill.mjs +21 -0
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { homedir, hostname } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { ApiError, call } from "./api.mjs";
|
|
6
|
+
import { parseBackupRepo, runBackup } from "./backup.mjs";
|
|
7
|
+
import { describeCheck } from "./check.mjs";
|
|
8
|
+
import { configPath, readConfig, writeConfig } from "./config.mjs";
|
|
9
|
+
import { findProjects } from "./places.mjs";
|
|
10
|
+
import { describePlan, localPlan } from "./plan.mjs";
|
|
11
|
+
import { applyPlaybook, cautionLine, describeStatus, readPlaybook } from "./playbook.mjs";
|
|
12
|
+
import { findProject, trackedFiles } from "./project.mjs";
|
|
13
|
+
import { ask, askSecret, firstLine, interactive } from "./prompt.mjs";
|
|
14
|
+
import { checkProjectSkills, installPulled, PULL_REF_RE, readPulled } from "./pull.mjs";
|
|
15
|
+
import { runRestore } from "./restore.mjs";
|
|
16
|
+
import { readJson, scanGlobal, scanProject } from "./scan.mjs";
|
|
17
|
+
import { installSkill, skillFolder, skillState } from "./skill.mjs";
|
|
18
|
+
|
|
19
|
+
const DEFAULT_SITE = "https://skillcellar.com";
|
|
20
|
+
const VERSION = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
21
|
+
// One sync takes the global place and up to this many projects, and this many items in all: the
|
|
22
|
+
// site's SYNC_PLACES_MAX less one, and its SYNC_ITEMS_TOTAL_MAX.
|
|
23
|
+
const PROJECTS_PER_SYNC = 299;
|
|
24
|
+
const ITEMS_PER_SYNC = 5000;
|
|
25
|
+
|
|
26
|
+
const help = () => `skillcellar ${VERSION}: keeps your SkillCellar library's list of what this computer has installed,
|
|
27
|
+
and hands Claude Code the tasks you queue for it on the site.
|
|
28
|
+
|
|
29
|
+
Usage:
|
|
30
|
+
skillcellar login [--device <name>] [--site <url>]
|
|
31
|
+
Sign in with an API key from <site>/dashboard/keys, and choose which of your devices this
|
|
32
|
+
computer is, or name a new one. Then offers to install Claude Code's skillcellar skill.
|
|
33
|
+
skillcellar sync [--project <name>] [--dry-run]
|
|
34
|
+
Report what this computer has, globally and in every project Claude Code knows: skills,
|
|
35
|
+
plugins, MCP servers, memory files and agents, by name and hash, in one request. The site
|
|
36
|
+
closes the tasks it then finds done.
|
|
37
|
+
--project <name> report only the project you are in, under this name
|
|
38
|
+
--dry-run print what would be sent, and send nothing
|
|
39
|
+
skillcellar plan [--json]
|
|
40
|
+
List the tasks waiting for this computer, each with how to carry it out and, for a project,
|
|
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.
|
|
47
|
+
skillcellar pull <username>/<name> [--scope global|project] [--force]
|
|
48
|
+
Install a skill from the site: one you wrote (<name> alone will do), one you bought, or a free
|
|
49
|
+
Community skill, into ~/.claude/skills/<name>, or with --scope project into the skills folder
|
|
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.
|
|
64
|
+
skillcellar skill
|
|
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.
|
|
78
|
+
|
|
79
|
+
login reads the key from SKILLCELLAR_API_KEY when it is set, else from the terminal or the first
|
|
80
|
+
line piped in. The key and the device are kept in ${configPath()}, written with mode 0600.`;
|
|
81
|
+
|
|
82
|
+
/** A mistake in how the command was called: printed with the usage, exit code 2. */
|
|
83
|
+
class UsageError extends Error {}
|
|
84
|
+
|
|
85
|
+
/** The site's origin: an https address, or http on this computer for development. */
|
|
86
|
+
function siteOrigin(value) {
|
|
87
|
+
let url;
|
|
88
|
+
try {
|
|
89
|
+
url = new URL(value);
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const local = url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
94
|
+
return (url.protocol === "https:" || local) && !url.username && !url.password ? url.origin : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The device a name gives: a new one, or the account's device of that name. */
|
|
98
|
+
async function namedDevice(site, key, name) {
|
|
99
|
+
try {
|
|
100
|
+
return (await call(site, key, "POST", "/api/v1/devices", { name }, VERSION)).device;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error instanceof ApiError && error.status === 409 && error.body?.device) return error.body.device;
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Asks which of the account's devices this computer is, or for a new one's name, offering the computer's own. */
|
|
108
|
+
async function chooseDevice(site, key, devices, print) {
|
|
109
|
+
if (!interactive()) throw new UsageError("Choose this computer's device with --device <name>.");
|
|
110
|
+
let choice = devices.length + 1;
|
|
111
|
+
if (devices.length) {
|
|
112
|
+
print("Which of your devices is this computer?");
|
|
113
|
+
devices.forEach((device, index) => print(` ${index + 1}) ${device.name}`));
|
|
114
|
+
print(` ${devices.length + 1}) A new device`);
|
|
115
|
+
choice = Number(await ask(`Choose 1-${devices.length + 1}: `));
|
|
116
|
+
if (!Number.isInteger(choice) || choice < 1 || choice > devices.length + 1) throw new UsageError("Choose one of the numbers listed.");
|
|
117
|
+
}
|
|
118
|
+
if (choice <= devices.length) return devices[choice - 1];
|
|
119
|
+
// The computer's name is sent only when the user takes it.
|
|
120
|
+
const suggested = plain(hostname());
|
|
121
|
+
const name = (await ask(`Name this computer (Enter for "${suggested}"): `)) || suggested;
|
|
122
|
+
return (await call(site, key, "POST", "/api/v1/devices", { name }, VERSION)).device;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Claude Code's own folder: CLAUDE_CONFIG_DIR when it is set, else ~/.claude. */
|
|
126
|
+
const claudeFolder = (home) => process.env.CLAUDE_CONFIG_DIR?.trim() || join(home, ".claude");
|
|
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
|
+
|
|
135
|
+
/** The saved login and its site, for a command that calls the site. */
|
|
136
|
+
async function signedIn() {
|
|
137
|
+
const config = await readConfig();
|
|
138
|
+
if (!config) throw new Error("Run skillcellar login first.");
|
|
139
|
+
const site = siteOrigin(config.site);
|
|
140
|
+
if (!site) throw new Error(`The saved site, ${config.site}, is not an https address. Run skillcellar login again.`);
|
|
141
|
+
return { config, site };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Offers to install or update the skillcellar skill, which only a yes at the terminal writes. */
|
|
145
|
+
async function offerSkill(print) {
|
|
146
|
+
const claudeDir = claudeFolder(homedir());
|
|
147
|
+
const state = await skillState(claudeDir);
|
|
148
|
+
if (state === "current") return;
|
|
149
|
+
const verb = state === "missing" ? "Install" : "Update";
|
|
150
|
+
const yes = interactive() && /^y(es)?$/i.test(await ask(`${verb} the skillcellar skill, so Claude Code can carry out the tasks you queue on the site? It goes in ${skillFolder(claudeDir)}. [y/N] `));
|
|
151
|
+
if (yes) print(`${verb === "Install" ? "Installed" : "Updated"} the skillcellar skill in ${await installSkill(claudeDir)}.`);
|
|
152
|
+
else print(`The skillcellar skill is not ${state === "missing" ? "installed" : "up to date"}; run skillcellar skill to ${verb.toLowerCase()} it.`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function login(flags, print) {
|
|
156
|
+
const site = siteOrigin(flags.site ?? process.env.SKILLCELLAR_URL ?? DEFAULT_SITE);
|
|
157
|
+
if (!site) throw new UsageError("--site must be an https address, such as https://skillcellar.com, or http on this computer for development.");
|
|
158
|
+
const key = process.env.SKILLCELLAR_API_KEY?.trim() || (interactive() ? await askSecret(`API key (create one at ${site}/dashboard/keys): `) : await firstLine());
|
|
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.`);
|
|
160
|
+
const library = await call(site, key, "GET", "/api/v1/library", undefined, VERSION);
|
|
161
|
+
const device = flags.device !== undefined ? await namedDevice(site, key, flags.device) : await chooseDevice(site, key, library.devices ?? [], print);
|
|
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 });
|
|
166
|
+
const who = library.account?.email ? ` as ${library.account.email}` : "";
|
|
167
|
+
print(`Signed in to ${new URL(site).host}${who}. This computer syncs as ${device.name}.`);
|
|
168
|
+
await offerSkill(print);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const plural = (n, word, words = `${word}s`) => `${n} ${n === 1 ? word : words}`;
|
|
172
|
+
|
|
173
|
+
const KINDS = [
|
|
174
|
+
["skill", "skill"],
|
|
175
|
+
["plugin", "plugin"],
|
|
176
|
+
["mcp", "MCP server"],
|
|
177
|
+
["memory", "memory file"],
|
|
178
|
+
["agent", "agent"],
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
/** One line on what a sync reported and changed. */
|
|
182
|
+
function summary(payload, result, deviceName, whole) {
|
|
183
|
+
const items = payload.places.flatMap((place) => place.items);
|
|
184
|
+
const counts = KINDS.flatMap(([kind, word]) => {
|
|
185
|
+
const n = items.filter((item) => item.kind === kind).length;
|
|
186
|
+
return n ? [plural(n, word)] : [];
|
|
187
|
+
});
|
|
188
|
+
const projects = whole ? plural(result.projects.length, "project") : (result.projects[0] ?? "no project");
|
|
189
|
+
const changes = [result.added && `${result.added} new`, result.removed && `${result.removed} no longer there`].filter(Boolean);
|
|
190
|
+
const manual =
|
|
191
|
+
result.skipped === 1
|
|
192
|
+
? " 1 was marked by hand already; that mark stays as it is."
|
|
193
|
+
: result.skipped
|
|
194
|
+
? ` ${result.skipped} were marked by hand already; those marks stay as they are.`
|
|
195
|
+
: "";
|
|
196
|
+
return `Synced ${deviceName}, globally and in ${projects}: ${counts.join(", ") || "nothing installed"}.${changes.length ? ` Since the last sync: ${changes.join(", ")}.` : ""}${manual}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Where Claude Code keeps its user settings, MCP servers and the projects it knows: ~/.claude.json,
|
|
201
|
+
* or .claude.json in CLAUDE_CONFIG_DIR when that is set.
|
|
202
|
+
*/
|
|
203
|
+
const claudeJsonPath = (home) => (process.env.CLAUDE_CONFIG_DIR?.trim() ? join(process.env.CLAUDE_CONFIG_DIR.trim(), ".claude.json") : join(home, ".claude.json"));
|
|
204
|
+
|
|
205
|
+
/** What makes checkouts one project on the site: their GitHub repository, else their name. */
|
|
206
|
+
const identity = (project) => project.repo ?? project.name.toLowerCase();
|
|
207
|
+
|
|
208
|
+
/** The tasks a sync closed, in one line. */
|
|
209
|
+
function closedLine(closed) {
|
|
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"}`);
|
|
212
|
+
return `Closed ${plural(closed.length, "task")}: ${done.join("; ")}.`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function sync(flags, print, warn) {
|
|
216
|
+
const home = homedir();
|
|
217
|
+
const claudeDir = claudeFolder(home);
|
|
218
|
+
const dryRun = flags["dry-run"] === true;
|
|
219
|
+
const config = await readConfig();
|
|
220
|
+
if (!config && !dryRun) throw new Error("Run skillcellar login first.");
|
|
221
|
+
const site = config ? siteOrigin(config.site) : null;
|
|
222
|
+
if (config && !site) throw new Error(`The saved site, ${config.site}, is not an https address. Run skillcellar login again.`);
|
|
223
|
+
// Read from the wrong folder, a sync would report nothing and remove what the last one reported.
|
|
224
|
+
await checkClaudeFolder(claudeDir);
|
|
225
|
+
|
|
226
|
+
const claudeJson = (await readJson(claudeJsonPath(home))) ?? {};
|
|
227
|
+
const cwd = process.cwd();
|
|
228
|
+
const whole = flags.project === undefined;
|
|
229
|
+
let projects = await findProjects({ home, claudeDir, claudeJson, cwd });
|
|
230
|
+
if (!whole) {
|
|
231
|
+
const here = await findProject(cwd, home);
|
|
232
|
+
if (!here) throw new UsageError("Run sync --project inside the project's folder, not in your home folder.");
|
|
233
|
+
if (!/[A-Za-z0-9]/.test(flags.project)) throw new UsageError("--project needs a letter a–z or a digit 0–9.");
|
|
234
|
+
// Every checkout of the same repository is one project on the site, so all of them go together.
|
|
235
|
+
const same = projects.filter((known) => known.root === here.root || (here.remote && known.remote && identity(known) === identity(here)));
|
|
236
|
+
projects = (same.length ? same : [{ ...here, folders: new Set([cwd]) }]).map((known) => ({ ...known, name: flags.project }));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const global = await scanGlobal({ home, claudeDir, claudeJson });
|
|
240
|
+
const skipped = [...global.skipped];
|
|
241
|
+
let places = [];
|
|
242
|
+
const empty = [];
|
|
243
|
+
const unread = new Set();
|
|
244
|
+
for (const project of projects) {
|
|
245
|
+
if (!/[A-Za-z0-9]/.test(project.name)) {
|
|
246
|
+
warn(`Not reported: ${project.name} has no letter a–z or digit 0–9 to name its project by. Sync it from its folder with --project <name>.`);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
let scanned;
|
|
250
|
+
try {
|
|
251
|
+
scanned = await scanProject({ root: project.root, folders: [...project.folders], claudeDir, claudeJson, tracked: await trackedFiles(project.root) });
|
|
252
|
+
} catch (error) {
|
|
253
|
+
// One project's broken file stops only that project in a sync of the whole computer.
|
|
254
|
+
if (!whole) throw error;
|
|
255
|
+
unread.add(identity(project));
|
|
256
|
+
warn(`Not reported: ${project.name}, because ${error.message} The site keeps what it had there until a sync can read it.`);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
skipped.push(...scanned.skipped);
|
|
260
|
+
const place = { scope: "project", project: { name: project.name, repo: project.repo, remote: project.remote }, items: scanned.items };
|
|
261
|
+
// A complete sync replaces every place on the device, so a project with nothing in it needs no place.
|
|
262
|
+
if (whole && !scanned.items.length) empty.push(place);
|
|
263
|
+
else places.push(place);
|
|
264
|
+
}
|
|
265
|
+
// With a project unread, the sync replaces only the places it sends, so the site keeps the unread
|
|
266
|
+
// one as it was and closes no task there; an empty place is then sent, to empty it. Another
|
|
267
|
+
// checkout of the unread project is left out too, or it would replace the project on its own.
|
|
268
|
+
const complete = whole && !unread.size;
|
|
269
|
+
if (!complete) places = [...places, ...empty].filter((place) => !unread.has(identity(place.project)));
|
|
270
|
+
if (places.length > PROJECTS_PER_SYNC) {
|
|
271
|
+
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>.`);
|
|
272
|
+
}
|
|
273
|
+
places.sort((a, b) => (a.project.name < b.project.name ? -1 : a.project.name > b.project.name ? 1 : 0));
|
|
274
|
+
if (skipped.length) {
|
|
275
|
+
warn(
|
|
276
|
+
`Not reported, because its name cannot be sent as it is (up to 128 letters, digits and . _ @ + -, starting with a letter or digit; a plugin's name reads <plugin>@<marketplace>) or its MCP transport is not stdio, http or sse: ${skipped.join(", ")}.`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const device = config?.device.id ?? null;
|
|
281
|
+
const payload = { device, complete, places: [{ scope: "global", project: null, items: global.items }, ...places] };
|
|
282
|
+
const items = payload.places.reduce((total, place) => total + place.items.length, 0);
|
|
283
|
+
if (items > ITEMS_PER_SYNC) {
|
|
284
|
+
throw new Error(`This computer has ${items} items, and one sync takes ${ITEMS_PER_SYNC}. Sync the projects you need from their folders with --project <name>.`);
|
|
285
|
+
}
|
|
286
|
+
if (dryRun) {
|
|
287
|
+
if (!device) warn("Not signed in: the device is filled in once you run skillcellar login.");
|
|
288
|
+
print(JSON.stringify(payload, null, 2));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const result = await call(site, config.key, "POST", "/api/v1/sync", payload, VERSION);
|
|
292
|
+
print(summary(payload, result, config.device.name, whole));
|
|
293
|
+
for (const note of Array.isArray(result.left) ? result.left : []) warn(`Left out ${note}`);
|
|
294
|
+
const closed = Array.isArray(result.closed) ? result.closed : [];
|
|
295
|
+
if (closed.length) print(closedLine(closed));
|
|
296
|
+
if (result.open > 0) print(`${plural(result.open, "task")} still ${result.open === 1 ? "waits" : "wait"} for ${config.device.name}: run skillcellar plan to see ${result.open === 1 ? "it" : "them"}.`);
|
|
297
|
+
else if (closed.length) print(`No task waits for ${config.device.name} now.`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Prints this computer's open tasks, with each project task's folder here. */
|
|
301
|
+
async function plan(flags, print) {
|
|
302
|
+
const { config, site } = await signedIn();
|
|
303
|
+
const answer = await call(site, config.key, "GET", `/api/v1/plan?device=${encodeURIComponent(config.device.id)}`, undefined, VERSION);
|
|
304
|
+
const home = homedir();
|
|
305
|
+
const claudeDir = claudeFolder(home);
|
|
306
|
+
const projects = (answer.tasks ?? []).some((task) => task.project) ? await knownProjects(home, claudeDir) : [];
|
|
307
|
+
// The device's slug as the site has it now: a rename changes the slugs of its folders' projects.
|
|
308
|
+
const local = await localPlan(answer, { claudeDir, projects, deviceSlug: answer.device?.slug ?? config.device.slug });
|
|
309
|
+
if (flags.json) print(JSON.stringify(local, null, 2));
|
|
310
|
+
else for (const line of describePlan(local)) print(line);
|
|
311
|
+
}
|
|
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
|
+
|
|
321
|
+
/** Installs a skill from the site into the global skills folder, or the project's. */
|
|
322
|
+
async function pull(flags, ref, print) {
|
|
323
|
+
if (!ref || !PULL_REF_RE.test(ref)) throw new UsageError("Name the skill as <username>/<name>, or <name> for one of your own.");
|
|
324
|
+
const scope = flags.scope ?? "global";
|
|
325
|
+
if (scope !== "global" && scope !== "project") throw new UsageError("--scope is global or project.");
|
|
326
|
+
const { config, site } = await signedIn();
|
|
327
|
+
const home = homedir();
|
|
328
|
+
let skills = join(claudeFolder(home), "skills");
|
|
329
|
+
if (scope === "project") {
|
|
330
|
+
const here = await findProject(process.cwd(), home);
|
|
331
|
+
if (!here) throw new UsageError("Run pull --scope project inside the project's folder, not in your home folder.");
|
|
332
|
+
await checkProjectSkills(here.root);
|
|
333
|
+
skills = join(here.root, ".claude", "skills");
|
|
334
|
+
}
|
|
335
|
+
const pulled = readPulled(await call(site, config.key, "GET", `/api/v1/pull?ref=${encodeURIComponent(ref)}`, undefined, VERSION));
|
|
336
|
+
print(`Pulled ${ref}, version ${pulled.version}, into ${await installPulled(skills, pulled, flags.force === true)}.`);
|
|
337
|
+
}
|
|
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
|
+
|
|
438
|
+
/** Installs or updates the skillcellar skill without asking: the command itself is the yes. */
|
|
439
|
+
async function skill(print) {
|
|
440
|
+
const claudeDir = claudeFolder(homedir());
|
|
441
|
+
const state = await skillState(claudeDir);
|
|
442
|
+
if (state === "current") print(`The skillcellar skill in ${skillFolder(claudeDir)} is up to date.`);
|
|
443
|
+
else print(`${state === "missing" ? "Installed" : "Updated"} the skillcellar skill in ${await installSkill(claudeDir)}.`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
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.
|
|
450
|
+
*/
|
|
451
|
+
const FORMAT_RE = /\p{Cf}|[\u2028\u2029]/u;
|
|
452
|
+
const plain = (text) =>
|
|
453
|
+
Array.from(String(text))
|
|
454
|
+
.filter((char) => {
|
|
455
|
+
const code = char.codePointAt(0);
|
|
456
|
+
return code === 9 || code === 10 || (code >= 32 && code !== 127 && (code < 128 || code >= 160) && !FORMAT_RE.test(char));
|
|
457
|
+
})
|
|
458
|
+
.join("");
|
|
459
|
+
|
|
460
|
+
/** Runs one command; the exit code: 0 done, 1 failed, 2 called wrongly. */
|
|
461
|
+
export async function main(argv, toOut = console.log, toErr = console.error) {
|
|
462
|
+
const print = (text) => toOut(plain(text));
|
|
463
|
+
const warn = (text) => toErr(plain(text));
|
|
464
|
+
let parsed;
|
|
465
|
+
try {
|
|
466
|
+
parsed = parseArgs({
|
|
467
|
+
args: argv,
|
|
468
|
+
allowPositionals: true,
|
|
469
|
+
options: {
|
|
470
|
+
site: { type: "string" },
|
|
471
|
+
device: { type: "string" },
|
|
472
|
+
project: { type: "string" },
|
|
473
|
+
"dry-run": { type: "boolean" },
|
|
474
|
+
json: { type: "boolean" },
|
|
475
|
+
scope: { type: "string" },
|
|
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 },
|
|
483
|
+
help: { type: "boolean", short: "h" },
|
|
484
|
+
version: { type: "boolean", short: "v" },
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
} catch (error) {
|
|
488
|
+
warn(`skillcellar: ${error.message}\n\n${help()}`);
|
|
489
|
+
return 2;
|
|
490
|
+
}
|
|
491
|
+
const { values: flags, positionals } = parsed;
|
|
492
|
+
if (flags.version) {
|
|
493
|
+
print(VERSION);
|
|
494
|
+
return 0;
|
|
495
|
+
}
|
|
496
|
+
const [command, ...extra] = positionals;
|
|
497
|
+
if (flags.help || !command) {
|
|
498
|
+
print(help());
|
|
499
|
+
return flags.help ? 0 : 2;
|
|
500
|
+
}
|
|
501
|
+
try {
|
|
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];
|
|
504
|
+
if (rest.length) throw new UsageError(`Unexpected ${rest.join(" ")}.`);
|
|
505
|
+
if (command === "login") await login(flags, print);
|
|
506
|
+
else if (command === "sync") await sync(flags, print, warn);
|
|
507
|
+
else if (command === "plan") await plan(flags, print);
|
|
508
|
+
else if (command === "check") await check(flags, print);
|
|
509
|
+
else if (command === "pull") await pull(flags, ref, print);
|
|
510
|
+
else if (command === "playbook") await playbook(flags, action, ref, print, warn);
|
|
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);
|
|
514
|
+
else throw new UsageError(`Unknown command ${command}.`);
|
|
515
|
+
return 0;
|
|
516
|
+
} catch (error) {
|
|
517
|
+
if (error instanceof UsageError) {
|
|
518
|
+
warn(`skillcellar: ${error.message}\n\n${help()}`);
|
|
519
|
+
return 2;
|
|
520
|
+
}
|
|
521
|
+
const hint = error instanceof ApiError && error.status === 401 ? " If you revoked it or reset your password, create a key at the site's /dashboard/keys and run skillcellar login again." : "";
|
|
522
|
+
warn(`skillcellar: ${error.message}${hint}`);
|
|
523
|
+
return 1;
|
|
524
|
+
}
|
|
525
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { readJson } from "./scan.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where the key and this computer's device are kept: SKILLCELLAR_CONFIG, else the platform's config
|
|
8
|
+
* folder. A relative XDG_CONFIG_HOME is ignored, as the XDG spec says.
|
|
9
|
+
*/
|
|
10
|
+
export function configPath(env = process.env) {
|
|
11
|
+
if (env.SKILLCELLAR_CONFIG) return resolve(env.SKILLCELLAR_CONFIG);
|
|
12
|
+
const xdg = env.XDG_CONFIG_HOME && isAbsolute(env.XDG_CONFIG_HOME) ? env.XDG_CONFIG_HOME : null;
|
|
13
|
+
const base = process.platform === "win32" ? env.APPDATA || join(homedir(), "AppData", "Roaming") : xdg || join(homedir(), ".config");
|
|
14
|
+
return join(base, "skillcellar", "config.json");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The saved login, or null before the first one. A saved backup repository rides along when it is there. */
|
|
18
|
+
export async function readConfig(path = configPath()) {
|
|
19
|
+
const config = await readJson(path).catch((error) => {
|
|
20
|
+
throw new Error(`${error.message} Run skillcellar login to save it again.`);
|
|
21
|
+
});
|
|
22
|
+
return typeof config?.site === "string" && typeof config.key === "string" && typeof config.device?.id === "string" ? config : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Saves the login readable by its owner only (mode 0600, in a 0700 folder): written to a new file
|
|
27
|
+
* first, then moved over the old one, so the key never sits in a file anyone else can read.
|
|
28
|
+
*/
|
|
29
|
+
export async function writeConfig(config, path = configPath()) {
|
|
30
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
31
|
+
const next = `${path}.${process.pid}.tmp`;
|
|
32
|
+
await writeFile(next, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
33
|
+
await chmod(next, 0o600);
|
|
34
|
+
await rename(next, path);
|
|
35
|
+
}
|
package/src/files.mjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { join, relative } from "node:path";
|
|
4
|
+
|
|
5
|
+
// A name the site takes: segments of letters, digits and ._@+-, each starting with a letter or
|
|
6
|
+
// digit, joined by slashes for a file in a folder.
|
|
7
|
+
const SEGMENT_RE = /^[A-Za-z0-9][A-Za-z0-9._@+-]*$/;
|
|
8
|
+
const NAME_MAX = 128;
|
|
9
|
+
// ponytail: a file past this is reported without a hash; memory and agents are small text.
|
|
10
|
+
const HASH_BYTES_MAX = 1024 * 1024;
|
|
11
|
+
// How deep a rules folder is read, so a link back to a folder above cannot loop.
|
|
12
|
+
const DEPTH_MAX = 8;
|
|
13
|
+
|
|
14
|
+
const sendable = (name) => name.length <= NAME_MAX && name.split("/").every((segment) => SEGMENT_RE.test(segment));
|
|
15
|
+
|
|
16
|
+
/** The SHA-256 of a file's bytes, or null for one too large or gone meanwhile. */
|
|
17
|
+
async function fileHash(path) {
|
|
18
|
+
const info = await stat(path).catch(() => null);
|
|
19
|
+
if (!info?.isFile() || info.size > HASH_BYTES_MAX) return null;
|
|
20
|
+
const content = await readFile(path).catch(() => null);
|
|
21
|
+
return content ? createHash("sha256").update(content).digest("hex") : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
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, tracked) {
|
|
26
|
+
const hash = await fileHash(path);
|
|
27
|
+
return { kind, name, source: null, skillPath: null, hash, hashKind: hash ? "sha256" : null, version: null, tracked };
|
|
28
|
+
}
|
|
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
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The `.md` files in a folder and, with `deep`, in the folders inside it, by their path from it.
|
|
35
|
+
* Each folder is read once, so a link back to one above cannot loop. A folder that is not there
|
|
36
|
+
* holds none; one that cannot be read stops the sync, rather than report less than is there.
|
|
37
|
+
*/
|
|
38
|
+
async function markdownFiles(dir, deep, prefix = "", depth = 0, seen = new Set()) {
|
|
39
|
+
const real = await realpath(dir).catch(() => dir);
|
|
40
|
+
if (seen.has(real)) return [];
|
|
41
|
+
seen.add(real);
|
|
42
|
+
let entries;
|
|
43
|
+
try {
|
|
44
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return [];
|
|
47
|
+
throw new Error(`Could not read ${dir}: ${error?.code ?? error}.`);
|
|
48
|
+
}
|
|
49
|
+
const files = [];
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (entry.name.startsWith(".")) continue;
|
|
52
|
+
const path = join(dir, entry.name);
|
|
53
|
+
const info = entry.isSymbolicLink() ? await stat(path).catch(() => null) : entry;
|
|
54
|
+
if (info?.isDirectory() && deep && depth < DEPTH_MAX) files.push(...(await markdownFiles(path, deep, `${prefix}${entry.name}/`, depth + 1, seen)));
|
|
55
|
+
else if (info?.isFile() && entry.name.endsWith(".md")) files.push({ name: `${prefix}${entry.name}`, path });
|
|
56
|
+
}
|
|
57
|
+
return files.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Memory files, each named by the label of the folder it comes from and its path there, such as
|
|
62
|
+
* rules/common/testing.md; a single file is named as it is, such as CLAUDE.md.
|
|
63
|
+
*/
|
|
64
|
+
export async function memoryItems(sources, skipped, own = null) {
|
|
65
|
+
const items = [];
|
|
66
|
+
for (const source of sources) {
|
|
67
|
+
const files = source.file
|
|
68
|
+
? (await stat(source.file).catch(() => null))?.isFile()
|
|
69
|
+
? [{ name: source.name, path: source.file }]
|
|
70
|
+
: []
|
|
71
|
+
: (await markdownFiles(source.dir, source.deep)).map((file) => ({ name: `${source.name}/${file.name}`, path: file.path }));
|
|
72
|
+
for (const file of files) {
|
|
73
|
+
if (sendable(file.name)) items.push(await fileItem("memory", file.name, file.path, trackedBy(own, file.path)));
|
|
74
|
+
else skipped.push(file.name);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return items;
|
|
78
|
+
}
|
|
79
|
+
|
|
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`. */
|
|
81
|
+
export async function agentItems(dir, skipped, own = null) {
|
|
82
|
+
const items = [];
|
|
83
|
+
for (const file of await markdownFiles(dir, true)) {
|
|
84
|
+
const name = file.name.slice(0, -".md".length);
|
|
85
|
+
if (sendable(name)) items.push(await fileItem("agent", name, file.path, trackedBy(own, file.path)));
|
|
86
|
+
else skipped.push(file.name);
|
|
87
|
+
}
|
|
88
|
+
return items;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The `.md` files of a folder, for a backup: the same files a sync names, with their paths. */
|
|
92
|
+
export { markdownFiles };
|