@skillsmanager/cli 0.0.2 → 0.0.5

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.
@@ -1,55 +1,64 @@
1
1
  import chalk from "chalk";
2
2
  import ora from "ora";
3
3
  import fs from "fs";
4
- import { readConfig, writeConfig, mergeRegistries, CONFIG_PATH } from "../config.js";
5
- import { ensureAuth } from "../auth.js";
6
- import { GDriveBackend } from "../backends/gdrive.js";
4
+ import { readConfig, writeConfig, mergeRegistries, CONFIG_PATH, CACHE_DIR } from "../config.js";
5
+ import { GithubBackend } from "../backends/github.js";
7
6
  import { LocalBackend } from "../backends/local.js";
7
+ import { resolveBackend } from "../backends/resolve.js";
8
8
  export async function registryCreateCommand(options) {
9
9
  const backend = options.backend ?? "local";
10
- if (backend === "local") {
11
- const local = new LocalBackend();
12
- const spinner = ora("Creating local registry...").start();
13
- try {
14
- const registry = await local.createRegistry();
15
- spinner.succeed(`Local registry created`);
16
- let config = { registries: [], collections: [], skills: {}, discoveredAt: new Date().toISOString() };
17
- if (fs.existsSync(CONFIG_PATH)) {
18
- try {
19
- config = readConfig();
20
- }
21
- catch { /* use default */ }
22
- }
23
- config.registries.push(registry);
24
- writeConfig(config);
10
+ const supported = ["local", "gdrive", "github"];
11
+ if (!supported.includes(backend)) {
12
+ console.log(chalk.red(`Unknown backend "${backend}". Supported: ${supported.join(", ")}`));
13
+ return;
14
+ }
15
+ if (backend === "github" && !options.repo) {
16
+ console.log(chalk.red("GitHub backend requires --repo <owner/repo>"));
17
+ console.log(chalk.dim(" Example: skillsmanager registry create --backend github --repo owner/my-repo"));
18
+ return;
19
+ }
20
+ const label = backend === "local" ? "locally" : `in ${backend}`;
21
+ const spinner = ora(`Creating registry ${label}...`).start();
22
+ try {
23
+ let registry;
24
+ if (backend === "github") {
25
+ registry = await new GithubBackend().createRegistry(undefined, options.repo);
25
26
  }
26
- catch (err) {
27
- spinner.fail(`Failed: ${err.message}`);
27
+ else {
28
+ registry = await (await resolveBackend(backend)).createRegistry();
28
29
  }
29
- }
30
- else if (backend === "gdrive") {
31
- const auth = await ensureAuth();
32
- const gdrive = new GDriveBackend(auth);
33
- const spinner = ora("Creating registry in Google Drive...").start();
34
- try {
35
- const registry = await gdrive.createRegistry();
36
- spinner.succeed(`Registry created in Google Drive`);
37
- let config = { registries: [], collections: [], skills: {}, discoveredAt: new Date().toISOString() };
38
- if (fs.existsSync(CONFIG_PATH)) {
30
+ spinner.succeed(`Registry created ${label}`);
31
+ let config = { registries: [], collections: [], skills: {}, discoveredAt: new Date().toISOString() };
32
+ if (fs.existsSync(CONFIG_PATH)) {
33
+ try {
34
+ config = readConfig();
35
+ }
36
+ catch { /* use default */ }
37
+ }
38
+ config.registries.push(registry);
39
+ writeConfig(config);
40
+ if (backend !== "local") {
41
+ const localReg = config.registries.find((r) => r.backend === "local");
42
+ if (localReg) {
43
+ const local = new LocalBackend();
39
44
  try {
40
- config = readConfig();
45
+ const localData = await local.readRegistry(localReg);
46
+ const localCollections = localData.collections.filter((c) => c.backend === "local");
47
+ if (localCollections.length > 0) {
48
+ const names = localCollections.map((c) => chalk.cyan(c.name)).join(", ");
49
+ console.log(chalk.yellow(`\n Found local registry with ${localCollections.length} collection(s): ${names}`));
50
+ const pushCmd = backend === "github"
51
+ ? `skillsmanager registry push --backend github --repo ${options.repo}`
52
+ : `skillsmanager registry push --backend ${backend}`;
53
+ console.log(chalk.dim(` Run ${chalk.white(pushCmd)} to back them up to ${backend}.\n`));
54
+ }
41
55
  }
42
- catch { /* use default */ }
56
+ catch { /* local registry unreadable, skip hint */ }
43
57
  }
44
- config.registries.push(registry);
45
- writeConfig(config);
46
- }
47
- catch (err) {
48
- spinner.fail(`Failed: ${err.message}`);
49
58
  }
50
59
  }
51
- else {
52
- console.log(chalk.red(`Unknown backend "${backend}". Supported: local, gdrive`));
60
+ catch (err) {
61
+ spinner.fail(`Failed: ${err.message}`);
53
62
  }
54
63
  }
55
64
  export async function registryListCommand() {
@@ -69,9 +78,7 @@ export async function registryListCommand() {
69
78
  for (const reg of config.registries) {
70
79
  console.log(`\n${chalk.bold(reg.name)} ${chalk.dim(`(${reg.backend})`)}`);
71
80
  try {
72
- const backend = reg.backend === "gdrive"
73
- ? new GDriveBackend(await ensureAuth())
74
- : new LocalBackend();
81
+ const backend = await resolveBackend(reg.backend);
75
82
  const data = await backend.readRegistry(reg);
76
83
  if (data.collections.length === 0) {
77
84
  console.log(chalk.dim(" No collections"));
@@ -92,9 +99,7 @@ export async function registryDiscoverCommand(options) {
92
99
  const backendName = options.backend ?? "local";
93
100
  const spinner = ora(`Discovering registries in ${backendName}...`).start();
94
101
  try {
95
- const backend = backendName === "gdrive"
96
- ? new GDriveBackend(await ensureAuth())
97
- : new LocalBackend();
102
+ const backend = await resolveBackend(backendName);
98
103
  const fresh = await backend.discoverRegistries();
99
104
  let config = { registries: [], collections: [], skills: {}, discoveredAt: new Date().toISOString() };
100
105
  if (fs.existsSync(CONFIG_PATH)) {
@@ -135,9 +140,7 @@ export async function registryAddCollectionCommand(collectionName, options) {
135
140
  }
136
141
  // Use first registry
137
142
  const reg = config.registries[0];
138
- const backend = reg.backend === "gdrive"
139
- ? new GDriveBackend(await ensureAuth())
140
- : new LocalBackend();
143
+ const backend = await resolveBackend(reg.backend);
141
144
  const data = await backend.readRegistry(reg);
142
145
  const existing = data.collections.find((c) => c.name === collectionName);
143
146
  if (existing) {
@@ -152,10 +155,106 @@ export async function registryAddCollectionCommand(collectionName, options) {
152
155
  await backend.writeRegistry(reg, data);
153
156
  console.log(chalk.green(`Added "${collectionName}" to registry "${reg.name}".`));
154
157
  }
158
+ export async function registryRemoveCollectionCommand(collectionName, options) {
159
+ let config;
160
+ try {
161
+ config = readConfig();
162
+ }
163
+ catch {
164
+ console.log(chalk.red("No config found."));
165
+ return;
166
+ }
167
+ if (config.registries.length === 0) {
168
+ console.log(chalk.red("No registries configured."));
169
+ return;
170
+ }
171
+ // Search all registries for the collection; prefer one matching --backend if given
172
+ let reg = config.registries[0];
173
+ let data = null;
174
+ let backend = await resolveBackend(reg.backend);
175
+ for (const r of config.registries) {
176
+ if (options.backend && r.backend !== options.backend)
177
+ continue;
178
+ try {
179
+ const b = await resolveBackend(r.backend);
180
+ const d = await b.readRegistry(r);
181
+ if (d.collections.find((c) => c.name === collectionName)) {
182
+ reg = r;
183
+ backend = b;
184
+ data = d;
185
+ break;
186
+ }
187
+ }
188
+ catch { /* skip unreadable registries */ }
189
+ }
190
+ if (!data) {
191
+ // Fall back to reading the first (or backend-matched) registry for the error message
192
+ try {
193
+ data = await backend.readRegistry(reg);
194
+ }
195
+ catch { /**/ }
196
+ }
197
+ const ref = data?.collections.find((c) => c.name === collectionName);
198
+ if (!ref) {
199
+ console.log(chalk.yellow(`Collection "${collectionName}" not found in any registry.`));
200
+ return;
201
+ }
202
+ // If --delete, delete the actual collection and skills from the backend
203
+ if (options.delete) {
204
+ const collectionInConfig = config.collections.find((c) => c.name === collectionName);
205
+ if (collectionInConfig) {
206
+ const collBackend = await resolveBackend(collectionInConfig.backend);
207
+ const spinner = ora(`Deleting collection "${collectionName}" from ${collectionInConfig.backend}...`).start();
208
+ try {
209
+ await collBackend.deleteCollection(collectionInConfig);
210
+ spinner.succeed(`Deleted collection "${collectionName}" from ${collectionInConfig.backend}`);
211
+ }
212
+ catch (err) {
213
+ spinner.fail(`Failed to delete: ${err.message}`);
214
+ return;
215
+ }
216
+ }
217
+ // Clean up local cache
218
+ if (collectionInConfig) {
219
+ const cachePath = path.join(CACHE_DIR, collectionInConfig.id);
220
+ if (fs.existsSync(cachePath)) {
221
+ fs.rmSync(cachePath, { recursive: true, force: true });
222
+ }
223
+ }
224
+ }
225
+ // Remove ref from registry
226
+ data.collections = data.collections.filter((c) => c.name !== collectionName);
227
+ await backend.writeRegistry(reg, data);
228
+ // Remove from local config — capture ID before removal for skills cleanup
229
+ const removedColId = config.collections.find((c) => c.name === collectionName)?.id;
230
+ config.collections = config.collections.filter((c) => c.name !== collectionName);
231
+ // Remove skills index entries for this collection
232
+ if (removedColId) {
233
+ for (const [skillName, locations] of Object.entries(config.skills)) {
234
+ config.skills[skillName] = locations.filter((l) => l.collectionId !== removedColId);
235
+ if (config.skills[skillName].length === 0)
236
+ delete config.skills[skillName];
237
+ }
238
+ }
239
+ writeConfig(config);
240
+ if (options.delete) {
241
+ console.log(chalk.green(`Removed and deleted "${collectionName}" from registry "${reg.name}".`));
242
+ }
243
+ else {
244
+ console.log(chalk.green(`Removed "${collectionName}" from registry "${reg.name}".`));
245
+ console.log(chalk.dim(` Collection data was kept. Use --delete to permanently remove it.`));
246
+ }
247
+ }
155
248
  export async function registryPushCommand(options) {
156
249
  const targetBackend = options.backend ?? "gdrive";
157
- if (targetBackend !== "gdrive") {
158
- console.log(chalk.red(`Push to "${targetBackend}" not yet supported. Use: --backend gdrive`));
250
+ const supportedPush = ["gdrive", "github"];
251
+ if (!supportedPush.includes(targetBackend)) {
252
+ console.log(chalk.red(`Push to "${targetBackend}" not yet supported. Use: --backend gdrive or --backend github`));
253
+ return;
254
+ }
255
+ if (targetBackend === "github" && !options.repo) {
256
+ console.log(chalk.red("GitHub backend requires --repo <owner/repo>"));
257
+ console.log(chalk.dim(" Example: skillsmanager registry push --backend github --repo owner/my-repo"));
159
258
  return;
160
259
  }
161
260
  let config;
@@ -166,7 +265,6 @@ export async function registryPushCommand(options) {
166
265
  console.log(chalk.red("No config found."));
167
266
  return;
168
267
  }
169
- // Find local registry
170
268
  const localReg = config.registries.find((r) => r.backend === "local");
171
269
  if (!localReg) {
172
270
  console.log(chalk.yellow("No local registry to push."));
@@ -179,39 +277,57 @@ export async function registryPushCommand(options) {
179
277
  console.log(chalk.yellow("No local collections to push."));
180
278
  return;
181
279
  }
182
- const auth = await ensureAuth();
183
- const gdrive = new GDriveBackend(auth);
184
- // ── Phase 1: Upload all collections (no state changes yet) ─────────────
185
- // If any collection fails, we abort and nothing is committed.
186
- const spinner = ora("Pushing collections to Google Drive...").start();
187
- // Discover or create gdrive registry (this is safe — an empty registry is harmless)
188
- let gdriveReg = config.registries.find((r) => r.backend === "gdrive");
189
- if (!gdriveReg) {
190
- spinner.text = "Creating registry in Google Drive...";
191
- gdriveReg = await gdrive.createRegistry();
192
- }
193
- // Accumulate results — only commit if ALL succeed
280
+ const remote = await resolveBackend(targetBackend);
281
+ const spinner = ora(`Pushing collections to ${targetBackend}...`).start();
282
+ // Find or create target registry
283
+ let targetReg = config.registries.find((r) => r.backend === targetBackend);
284
+ if (!targetReg) {
285
+ spinner.text = `Creating registry in ${targetBackend}...`;
286
+ targetReg = await remote.createRegistry();
287
+ }
288
+ // Read remote registry upfront to know what's already synced
289
+ let remoteData;
290
+ try {
291
+ remoteData = await remote.readRegistry(targetReg);
292
+ }
293
+ catch {
294
+ remoteData = { name: targetReg.name, owner: await remote.getOwner(), source: targetBackend, collections: [] };
295
+ }
296
+ const alreadySynced = new Set(remoteData.collections.map((c) => c.name));
297
+ // Skip collections already present in the remote registry
298
+ const toUpload = localCollectionRefs.filter((ref) => !alreadySynced.has(ref.name));
299
+ if (toUpload.length === 0) {
300
+ spinner.succeed("Remote registry is already up to date.");
301
+ return;
302
+ }
303
+ // Phase 1: Upload new collections — abort on any failure
194
304
  const pushed = [];
195
305
  try {
196
- for (const ref of localCollectionRefs) {
306
+ for (const ref of toUpload) {
197
307
  spinner.text = `Uploading collection "${ref.name}"...`;
198
308
  const collInfo = await local.resolveCollectionRef(ref);
199
- if (!collInfo) {
309
+ if (!collInfo)
200
310
  throw new Error(`Collection "${ref.name}" not found locally`);
311
+ let remoteCol;
312
+ if (targetBackend === "gdrive") {
313
+ const gdrive = remote;
314
+ const folderName = `SKILLS_${ref.name.toUpperCase()}`;
315
+ remoteCol = await gdrive.createCollection(folderName);
316
+ }
317
+ else {
318
+ const github = remote;
319
+ remoteCol = await github.createCollection(ref.name, options.repo);
201
320
  }
202
- const PREFIX = "SKILLS_";
203
- const folderName = `${PREFIX}${ref.name.toUpperCase()}`;
204
- const driveCol = await gdrive.createCollection(folderName);
205
321
  const colData = await local.readCollection({ ...collInfo, id: "temp" });
206
322
  for (const skill of colData.skills) {
207
323
  const localSkillPath = path.join(collInfo.folderId, skill.name);
208
324
  if (fs.existsSync(localSkillPath)) {
209
325
  spinner.text = `Uploading ${ref.name}/${skill.name}...`;
210
- await gdrive.uploadSkill(driveCol, localSkillPath, skill.name);
326
+ await remote.uploadSkill(remoteCol, localSkillPath, skill.name);
211
327
  }
212
328
  }
213
- await gdrive.writeCollection(driveCol, colData);
214
- pushed.push({ ref, driveCol, folderName });
329
+ await remote.writeCollection(remoteCol, colData);
330
+ pushed.push({ ref, remoteCol });
215
331
  }
216
332
  }
217
333
  catch (err) {
@@ -219,39 +335,31 @@ export async function registryPushCommand(options) {
219
335
  console.log(chalk.dim(" No changes were committed. Local state is unchanged."));
220
336
  return;
221
337
  }
222
- // ── Phase 2: Commit — update registry and config atomically ────────────
338
+ // Phase 2: Commit — update registry and config atomically
223
339
  spinner.text = "Updating registry...";
224
340
  try {
225
- // Read current gdrive registry (may already have entries)
226
- let gdriveData;
227
- try {
228
- gdriveData = await gdrive.readRegistry(gdriveReg);
229
- }
230
- catch {
231
- gdriveData = { name: gdriveReg.name, owner: await gdrive.getOwner(), source: "gdrive", collections: [] };
232
- }
233
- // Add all pushed collections at once
234
- for (const { ref, folderName } of pushed) {
235
- gdriveData.collections.push({ name: ref.name, backend: "gdrive", ref: folderName });
341
+ for (const { ref, remoteCol } of pushed) {
342
+ if (!remoteData.collections.find((c) => c.name === ref.name)) {
343
+ remoteData.collections.push({ name: ref.name, backend: targetBackend, ref: remoteCol.folderId });
344
+ }
236
345
  }
237
- await gdrive.writeRegistry(gdriveReg, gdriveData);
238
- // Update local config
239
- if (!config.registries.find((r) => r.id === gdriveReg.id)) {
240
- config.registries.push(gdriveReg);
346
+ await remote.writeRegistry(targetReg, remoteData);
347
+ if (!config.registries.find((r) => r.id === targetReg.id)) {
348
+ config.registries.push(targetReg);
241
349
  }
242
- for (const { driveCol } of pushed) {
243
- config.collections.push(driveCol);
350
+ for (const { remoteCol } of pushed) {
351
+ config.collections.push(remoteCol);
244
352
  }
245
353
  writeConfig(config);
246
- spinner.succeed(`Pushed ${pushed.length} collection(s) to Google Drive`);
354
+ spinner.succeed(`Pushed ${pushed.length} collection(s) to ${targetBackend}`);
247
355
  for (const { ref } of pushed) {
248
- console.log(chalk.dim(` ${ref.name} → gdrive`));
356
+ console.log(chalk.dim(` ${ref.name} → ${targetBackend}`));
249
357
  }
250
358
  }
251
359
  catch (err) {
252
360
  spinner.fail(`Failed to update registry: ${err.message}`);
253
361
  console.log(chalk.dim(" Collections were uploaded but the registry was not updated."));
254
- console.log(chalk.dim(" Run 'skillsmanager registry push' again to retry."));
362
+ console.log(chalk.dim(` Run 'skillsmanager registry push --backend ${targetBackend}' again to retry.`));
255
363
  }
256
364
  }
257
365
  // Need path import for push command
@@ -0,0 +1,4 @@
1
+ export declare function ghInstalled(): boolean;
2
+ export declare function ghAuthed(): boolean;
3
+ export declare function ghGetLogin(): string;
4
+ export declare function setupGithubCommand(): Promise<void>;
@@ -0,0 +1,91 @@
1
+ import { spawnSync } from "child_process";
2
+ import chalk from "chalk";
3
+ // ── helpers ───────────────────────────────────────────────────────────────────
4
+ export function ghInstalled() {
5
+ return spawnSync("gh", ["--version"], { stdio: "pipe" }).status === 0;
6
+ }
7
+ export function ghAuthed() {
8
+ return spawnSync("gh", ["auth", "status"], { stdio: "pipe" }).status === 0;
9
+ }
10
+ export function ghGetLogin() {
11
+ const r = spawnSync("gh", ["api", "user", "--jq", ".login"], {
12
+ encoding: "utf-8", stdio: "pipe",
13
+ });
14
+ return r.status === 0 ? (r.stdout?.trim() ?? "") : "";
15
+ }
16
+ async function installGh() {
17
+ if (process.platform !== "darwin") {
18
+ console.log(chalk.yellow(" Auto-install is only supported on macOS."));
19
+ console.log(chalk.dim(" Install manually: https://cli.github.com/manual/installation"));
20
+ return false;
21
+ }
22
+ const brewCheck = spawnSync("brew", ["--version"], { stdio: "pipe" });
23
+ if (brewCheck.status !== 0) {
24
+ console.log(chalk.yellow(" Homebrew not found. Install it from https://brew.sh then re-run."));
25
+ return false;
26
+ }
27
+ console.log(chalk.dim(" Installing gh via Homebrew..."));
28
+ const r = spawnSync("brew", ["install", "gh"], { stdio: "inherit" });
29
+ if (r.status !== 0) {
30
+ console.log(chalk.red(" Install failed. Try manually: brew install gh"));
31
+ return false;
32
+ }
33
+ return ghInstalled();
34
+ }
35
+ // ── main command ──────────────────────────────────────────────────────────────
36
+ export async function setupGithubCommand() {
37
+ console.log(chalk.bold("\nSkills Manager — GitHub Setup\n"));
38
+ // Step 1: Check gh CLI
39
+ console.log(chalk.bold("Step 1 — gh CLI\n"));
40
+ if (!ghInstalled()) {
41
+ console.log(chalk.yellow(" gh CLI is not installed."));
42
+ const readline = await import("readline");
43
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
44
+ const ans = await new Promise((resolve) => {
45
+ rl.question(` Install it now via Homebrew? ${chalk.dim("[y/n]")} `, (a) => {
46
+ rl.close();
47
+ resolve(a.trim().toLowerCase());
48
+ });
49
+ });
50
+ if (!ans.startsWith("y")) {
51
+ console.log(chalk.dim(" Install manually: https://cli.github.com/manual/installation"));
52
+ console.log(chalk.dim(" Then re-run: skillsmanager setup github"));
53
+ return;
54
+ }
55
+ const ok = await installGh();
56
+ if (!ok)
57
+ return;
58
+ console.log(chalk.green(" ✓ gh installed"));
59
+ }
60
+ else {
61
+ console.log(chalk.green(" ✓ gh is installed"));
62
+ }
63
+ // Step 2: Check auth
64
+ console.log(chalk.bold("\nStep 2 — GitHub Authentication\n"));
65
+ if (ghAuthed()) {
66
+ const r = spawnSync("gh", ["api", "user", "--jq", ".login"], {
67
+ encoding: "utf-8", stdio: "pipe",
68
+ });
69
+ const login = r.stdout?.trim() ?? "";
70
+ console.log(chalk.green(` ✓ Already authenticated${login ? ` as ${chalk.white(login)}` : ""}`));
71
+ }
72
+ else {
73
+ console.log(chalk.dim(" Running gh auth login..."));
74
+ const r = spawnSync("gh", ["auth", "login"], { stdio: "inherit" });
75
+ if (r.status !== 0) {
76
+ console.log(chalk.red(" Authentication failed. Please try manually: gh auth login"));
77
+ return;
78
+ }
79
+ const r2 = spawnSync("gh", ["api", "user", "--jq", ".login"], {
80
+ encoding: "utf-8", stdio: "pipe",
81
+ });
82
+ const login = r2.stdout?.trim() ?? "";
83
+ if (!login) {
84
+ console.log(chalk.red(" Could not verify authentication."));
85
+ return;
86
+ }
87
+ console.log(chalk.green(` ✓ Authenticated as ${chalk.white(login)}`));
88
+ }
89
+ console.log(chalk.green("\n ✓ GitHub setup complete!"));
90
+ console.log(`\nRun ${chalk.bold("skillsmanager collection create --backend github")} to create a collection.\n`);
91
+ }