@skillsmanager/cli 0.0.5 → 0.0.6

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 CHANGED
@@ -4,7 +4,7 @@
4
4
  [![CI](https://github.com/talktoajayprakash/skillsmanager/actions/workflows/release.yml/badge.svg)](https://github.com/talktoajayprakash/skillsmanager/actions)
5
5
  [![Docs](https://img.shields.io/badge/docs-skillsmanager-blue)](https://talktoajayprakash.github.io/skillsmanager)
6
6
 
7
- # Skills Manager
7
+ # Skills Manager CLI
8
8
 
9
9
  **One place to manage, sync, and share all your AI agent skills — across every agent you use.**
10
10
 
@@ -72,10 +72,13 @@ skillsmanager refresh # discovers collections in your Drive
72
72
  | `skillsmanager search <query>` | Search skills by name or description |
73
73
  | `skillsmanager fetch <name> --agent <agent>` | Download and install a skill for an agent |
74
74
  | `skillsmanager add <path>` | Upload a local skill to a collection |
75
+ | `skillsmanager add --remote-path <path> --name <n> --description <d>` | Register a remote skill path (no upload) |
75
76
  | `skillsmanager update <path>` | Push local changes back to remote storage |
76
77
  | `skillsmanager refresh` | Re-discover collections from remote |
77
- | `skillsmanager collection create` | Create a new skill collection |
78
+ | `skillsmanager collection create [name] --backend github --repo <owner/repo>` | Create a collection in a GitHub repo |
79
+ | `skillsmanager collection create [name] --skills-repo <owner/repo>` | Create a collection with skills in a separate GitHub repo |
78
80
  | `skillsmanager registry push --backend gdrive` | Push local registry to Google Drive |
81
+ | `skillsmanager registry push --backend github --repo <owner/repo>` | Push local registry to GitHub |
79
82
 
80
83
  ## Local Development
81
84
 
@@ -1,25 +1,26 @@
1
1
  import type { OAuth2Client } from "google-auth-library";
2
- import type { StorageBackend } from "./interface.js";
2
+ import type { BackendStatus, CreateRegistryOptions, StorageBackend } from "./interface.js";
3
3
  import type { CollectionFile, CollectionInfo, RegistryCollectionRef, RegistryFile, RegistryInfo } from "../types.js";
4
4
  export declare class GDriveBackend implements StorageBackend {
5
5
  private drive;
6
6
  constructor(auth: OAuth2Client);
7
7
  getOwner(): Promise<string>;
8
8
  getOwnerEmail(): Promise<string>;
9
+ getStatus(): Promise<BackendStatus>;
9
10
  discoverCollections(): Promise<Omit<CollectionInfo, "id">[]>;
10
11
  readCollection(collection: CollectionInfo): Promise<CollectionFile>;
11
12
  writeCollection(collection: CollectionInfo, data: CollectionFile): Promise<void>;
12
13
  downloadSkill(collection: CollectionInfo, skillName: string, destDir: string): Promise<void>;
13
14
  private downloadFolder;
14
- createCollection(folderName: string): Promise<CollectionInfo>;
15
+ createCollection({ name, skillsRepo }: import("./interface.js").CreateCollectionOptions): Promise<CollectionInfo>;
15
16
  deleteCollection(collection: CollectionInfo): Promise<void>;
16
17
  deleteSkill(collection: CollectionInfo, skillName: string): Promise<void>;
17
- uploadSkill(collection: CollectionInfo, localPath: string, skillName: string): Promise<void>;
18
+ uploadSkill(collection: CollectionInfo, localPath: string, skillName: string): Promise<string>;
18
19
  discoverRegistries(): Promise<Omit<RegistryInfo, "id">[]>;
19
20
  readRegistry(registry: RegistryInfo): Promise<RegistryFile>;
20
21
  writeRegistry(registry: RegistryInfo, data: RegistryFile): Promise<void>;
21
22
  resolveCollectionRef(ref: RegistryCollectionRef): Promise<Omit<CollectionInfo, "id"> | null>;
22
- createRegistry(name?: string): Promise<RegistryInfo>;
23
+ createRegistry(options?: CreateRegistryOptions): Promise<RegistryInfo>;
23
24
  private findFolder;
24
25
  private uploadFolder;
25
26
  }
@@ -18,6 +18,14 @@ export class GDriveBackend {
18
18
  async getOwnerEmail() {
19
19
  return this.getOwner();
20
20
  }
21
+ async getStatus() {
22
+ try {
23
+ return { loggedIn: true, identity: await this.getOwner() };
24
+ }
25
+ catch {
26
+ return { loggedIn: false, identity: "", hint: "run: skillsmanager setup google" };
27
+ }
28
+ }
21
29
  // ── Collection operations ────────────────────────────────────────────────
22
30
  async discoverCollections() {
23
31
  const collections = [];
@@ -149,30 +157,28 @@ export class GDriveBackend {
149
157
  }
150
158
  } while (pageToken);
151
159
  }
152
- async createCollection(folderName) {
160
+ async createCollection({ name, skillsRepo }) {
161
+ const folderName = `SKILLS_${name.toUpperCase()}`;
153
162
  const folderRes = await this.drive.files.create({
154
- requestBody: {
155
- name: folderName,
156
- mimeType: FOLDER_MIME,
157
- },
163
+ requestBody: { name: folderName, mimeType: FOLDER_MIME },
158
164
  fields: "id, name",
159
165
  });
160
166
  const folderId = folderRes.data.id;
161
167
  const owner = await this.getOwnerEmail();
162
- const logicalName = folderName.replace(/^SKILLS_/i, "");
163
- const emptyCollection = { name: logicalName, owner, skills: [] };
168
+ const emptyCollection = { name, owner, skills: [] };
169
+ if (skillsRepo) {
170
+ emptyCollection.type = "github";
171
+ emptyCollection.metadata = { repo: skillsRepo };
172
+ }
164
173
  const content = serializeCollection(emptyCollection);
165
174
  const fileRes = await this.drive.files.create({
166
- requestBody: {
167
- name: COLLECTION_FILENAME,
168
- parents: [folderId],
169
- },
175
+ requestBody: { name: COLLECTION_FILENAME, parents: [folderId] },
170
176
  media: { mimeType: "text/yaml", body: Readable.from(content) },
171
177
  fields: "id",
172
178
  });
173
179
  return {
174
180
  id: randomUUID(),
175
- name: logicalName,
181
+ name,
176
182
  backend: "gdrive",
177
183
  folderId,
178
184
  registryFileId: fileRes.data.id ?? undefined,
@@ -205,6 +211,7 @@ export class GDriveBackend {
205
211
  folderId = res.data.id;
206
212
  }
207
213
  await this.uploadFolder(localPath, folderId);
214
+ return `${skillName}/`;
208
215
  }
209
216
  // ── Registry operations ──────────────────────────────────────────────────
210
217
  async discoverRegistries() {
@@ -298,7 +305,8 @@ export class GDriveBackend {
298
305
  }
299
306
  return null;
300
307
  }
301
- async createRegistry(name) {
308
+ async createRegistry(options) {
309
+ const name = options?.name;
302
310
  const folderName = name ? `SKILLS_REGISTRY_${name}` : "SKILLS_REGISTRY";
303
311
  const folderRes = await this.drive.files.create({
304
312
  requestBody: { name: folderName, mimeType: FOLDER_MIME },
@@ -1,7 +1,8 @@
1
- import type { StorageBackend } from "./interface.js";
1
+ import type { BackendStatus, CreateRegistryOptions, StorageBackend } from "./interface.js";
2
2
  import type { CollectionFile, CollectionInfo, RegistryCollectionRef, RegistryFile, RegistryInfo } from "../types.js";
3
3
  export declare class GithubBackend implements StorageBackend {
4
4
  getOwner(): Promise<string>;
5
+ getStatus(): Promise<BackendStatus>;
5
6
  ensureRepo(repo: string): Promise<void>;
6
7
  private ensureWorkdir;
7
8
  private gitPushOrPR;
@@ -9,19 +10,18 @@ export declare class GithubBackend implements StorageBackend {
9
10
  discoverCollections(): Promise<Omit<CollectionInfo, "id">[]>;
10
11
  readCollection(collection: CollectionInfo): Promise<CollectionFile>;
11
12
  writeCollection(collection: CollectionInfo, data: CollectionFile): Promise<void>;
13
+ /** Clone/pull repo and copy relPath to destDir. Usable by other backends for cross-backend routing. */
14
+ downloadSkillFromRepo(repo: string, relPath: string, destDir: string): Promise<void>;
15
+ /** Clone/pull repo and delete relPath. Usable by other backends for cross-backend routing. */
16
+ deleteSkillFromRepo(repo: string, relPath: string): Promise<void>;
12
17
  downloadSkill(collection: CollectionInfo, skillName: string, destDir: string): Promise<void>;
13
- uploadSkill(collection: CollectionInfo, localPath: string, skillName: string): Promise<void>;
18
+ uploadSkill(collection: CollectionInfo, localPath: string, skillName: string): Promise<string>;
14
19
  deleteCollection(collection: CollectionInfo): Promise<void>;
15
20
  deleteSkill(collection: CollectionInfo, skillName: string): Promise<void>;
16
21
  discoverRegistries(): Promise<Omit<RegistryInfo, "id">[]>;
17
22
  readRegistry(registry: RegistryInfo): Promise<RegistryFile>;
18
23
  writeRegistry(registry: RegistryInfo, data: RegistryFile): Promise<void>;
19
24
  resolveCollectionRef(ref: RegistryCollectionRef): Promise<Omit<CollectionInfo, "id"> | null>;
20
- createRegistry(name?: string, repoRef?: string): Promise<RegistryInfo>;
21
- createCollection(collectionName: string, repoRef?: string, skillsRepoRef?: string): Promise<CollectionInfo>;
22
- static detectRepoContext(absPath: string): {
23
- repo: string;
24
- repoRoot: string;
25
- relPath: string;
26
- } | null;
25
+ createRegistry(options?: CreateRegistryOptions): Promise<RegistryInfo>;
26
+ createCollection({ name, repo, skillsRepo }: import("./interface.js").CreateCollectionOptions): Promise<CollectionInfo>;
27
27
  }
@@ -3,6 +3,7 @@ import path from "path";
3
3
  import { randomUUID } from "crypto";
4
4
  import { spawnSync } from "child_process";
5
5
  import chalk from "chalk";
6
+ import { ghInstalled, ghAuthed, ghGetLogin } from "../commands/setup/github.js";
6
7
  import { parseCollection, serializeCollection, parseRegistryFile, serializeRegistryFile, COLLECTION_FILENAME, REGISTRY_FILENAME, } from "../registry.js";
7
8
  import { CONFIG_DIR } from "../config.js";
8
9
  const GITHUB_WORKDIR = path.join(CONFIG_DIR, "github-workdir");
@@ -20,9 +21,12 @@ function parseRef(folderId) {
20
21
  function workdirFor(repo) {
21
22
  return path.join(GITHUB_WORKDIR, repo.replace("/", "_"));
22
23
  }
23
- /** Returns the repo where skill files live — defaults to the collection host repo. */
24
+ /** Returns the repo where skill files live — respects col.type + metadata.repo; defaults to host repo. */
24
25
  function skillsRepo(col, hostRepo) {
25
- return col.metadata?.repo ?? hostRepo;
26
+ if (col.type === "github" || col.type === undefined) {
27
+ return col.metadata?.repo ?? hostRepo;
28
+ }
29
+ return hostRepo;
26
30
  }
27
31
  // ── CLI helpers ───────────────────────────────────────────────────────────────
28
32
  function ghExec(args, opts) {
@@ -68,6 +72,13 @@ export class GithubBackend {
68
72
  }
69
73
  return r.stdout;
70
74
  }
75
+ async getStatus() {
76
+ if (!ghInstalled())
77
+ return { loggedIn: false, identity: "", hint: "install gh CLI first" };
78
+ if (!ghAuthed())
79
+ return { loggedIn: false, identity: "", hint: "run: skillsmanager setup github" };
80
+ return { loggedIn: true, identity: ghGetLogin() };
81
+ }
71
82
  // ── Ensure repo exists (create private if not) ───────────────────────────────
72
83
  async ensureRepo(repo) {
73
84
  const check = ghExec(["api", `repos/${repo}`]);
@@ -209,6 +220,29 @@ export class GithubBackend {
209
220
  gitExec(["add", path.join(metaDir, COLLECTION_FILENAME)], workdir);
210
221
  await this.commitAndPush(workdir, `chore: update ${COLLECTION_FILENAME} for ${collection.name}`);
211
222
  }
223
+ /** Clone/pull repo and copy relPath to destDir. Usable by other backends for cross-backend routing. */
224
+ async downloadSkillFromRepo(repo, relPath, destDir) {
225
+ const workdir = this.ensureWorkdir(repo);
226
+ gitExec(["pull", "--ff-only"], workdir);
227
+ const skillPath = path.join(workdir, relPath);
228
+ if (!fs.existsSync(skillPath)) {
229
+ throw new Error(`Skill directory not found at "${relPath}" in repo "${repo}"`);
230
+ }
231
+ if (path.resolve(skillPath) !== path.resolve(destDir)) {
232
+ copyDirSync(skillPath, destDir);
233
+ }
234
+ }
235
+ /** Clone/pull repo and delete relPath. Usable by other backends for cross-backend routing. */
236
+ async deleteSkillFromRepo(repo, relPath) {
237
+ const workdir = this.ensureWorkdir(repo);
238
+ gitExec(["pull", "--ff-only"], workdir);
239
+ const skillPath = path.join(workdir, relPath);
240
+ if (!fs.existsSync(skillPath))
241
+ return;
242
+ fs.rmSync(skillPath, { recursive: true, force: true });
243
+ gitExec(["add", "-A"], workdir);
244
+ await this.commitAndPush(workdir, `chore: remove skill at ${relPath}`);
245
+ }
212
246
  async downloadSkill(collection, skillName, destDir) {
213
247
  const { repo: hostRepo } = parseRef(collection.folderId);
214
248
  const col = await this.readCollection(collection);
@@ -217,38 +251,24 @@ export class GithubBackend {
217
251
  throw new Error(`Skill "${skillName}" not found in collection "${collection.name}"`);
218
252
  }
219
253
  const srcRepo = skillsRepo(col, hostRepo);
220
- const workdir = this.ensureWorkdir(srcRepo);
221
- gitExec(["pull", "--ff-only"], workdir);
222
- const skillPath = path.join(workdir, entry.path);
223
- if (!fs.existsSync(skillPath)) {
224
- throw new Error(`Skill directory not found at "${entry.path}" in repo "${srcRepo}"`);
225
- }
226
- if (path.resolve(skillPath) !== path.resolve(destDir)) {
227
- copyDirSync(skillPath, destDir);
228
- }
254
+ await this.downloadSkillFromRepo(srcRepo, entry.path, destDir);
229
255
  }
230
256
  async uploadSkill(collection, localPath, skillName) {
231
257
  const { repo: hostRepo } = parseRef(collection.folderId);
232
- // If collection points to a foreign skills repo, we can't upload there
233
- const col = await this.readCollection(collection);
234
- const foreign = col.metadata?.repo;
235
- if (foreign && foreign !== hostRepo) {
236
- throw new Error(`Cannot upload skill to collection "${collection.name}": its skills source is "${foreign}" (a repo you may not own). ` +
237
- `Use --remote-path to register a skill path from that repo instead.`);
238
- }
239
258
  const workdir = this.ensureWorkdir(hostRepo);
240
259
  const resolvedLocal = path.resolve(localPath);
241
260
  const resolvedWorkdir = path.resolve(workdir);
242
261
  if (resolvedLocal.startsWith(resolvedWorkdir + path.sep) ||
243
262
  resolvedLocal === resolvedWorkdir) {
244
- // Already in the repo — no copy needed
245
- return;
263
+ // Already in the repo — no copy needed; return relative path from workdir
264
+ return path.relative(workdir, resolvedLocal).replace(/\\/g, "/");
246
265
  }
247
266
  // External skill: copy into .agentskills/<skillName>/ in the repo
248
267
  const dest = path.join(workdir, ".agentskills", skillName);
249
268
  copyDirSync(localPath, dest);
250
269
  gitExec(["add", path.join(".agentskills", skillName)], workdir);
251
270
  await this.commitAndPush(workdir, `chore: add skill ${skillName}`);
271
+ return `.agentskills/${skillName}`;
252
272
  }
253
273
  async deleteCollection(collection) {
254
274
  const { repo, metaDir } = parseRef(collection.folderId);
@@ -267,13 +287,7 @@ export class GithubBackend {
267
287
  if (!entry)
268
288
  return;
269
289
  const srcRepo = skillsRepo(col, hostRepo);
270
- const workdir = this.ensureWorkdir(srcRepo);
271
- const skillPath = path.join(workdir, entry.path);
272
- if (!fs.existsSync(skillPath))
273
- return;
274
- fs.rmSync(skillPath, { recursive: true, force: true });
275
- gitExec(["add", "-A"], workdir);
276
- await this.commitAndPush(workdir, `chore: remove skill ${skillName}`);
290
+ await this.deleteSkillFromRepo(srcRepo, entry.path);
277
291
  }
278
292
  // ── Registry operations ───────────────────────────────────────────────────────
279
293
  async discoverRegistries() {
@@ -331,17 +345,18 @@ export class GithubBackend {
331
345
  return null;
332
346
  return { name: ref.name, backend: "github", folderId: ref.ref };
333
347
  }
334
- async createRegistry(name, repoRef) {
335
- if (!repoRef)
348
+ async createRegistry(options) {
349
+ const { name, repo } = options ?? {};
350
+ if (!repo)
336
351
  throw new Error("GitHub backend requires --repo <owner/repo>");
337
- await this.ensureRepo(repoRef);
338
- const workdir = this.ensureWorkdir(repoRef);
352
+ await this.ensureRepo(repo);
353
+ const workdir = this.ensureWorkdir(repo);
339
354
  const metaDir = SKILLSMANAGER_DIR;
340
355
  const filePath = path.join(workdir, metaDir, REGISTRY_FILENAME);
341
356
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
342
357
  const owner = await this.getOwner();
343
358
  const registryData = {
344
- name: name ?? (repoRef.split("/")[1] ?? "default"),
359
+ name: name ?? (repo.split("/")[1] ?? "default"),
345
360
  owner,
346
361
  source: "github",
347
362
  collections: [],
@@ -353,55 +368,32 @@ export class GithubBackend {
353
368
  id: randomUUID(),
354
369
  name: registryData.name,
355
370
  backend: "github",
356
- folderId: `${repoRef}:${metaDir}`,
357
- fileId: `${repoRef}:${metaDir}/${REGISTRY_FILENAME}`,
371
+ folderId: `${repo}:${metaDir}`,
372
+ fileId: `${repo}:${metaDir}/${REGISTRY_FILENAME}`,
358
373
  };
359
374
  }
360
375
  // ── createCollection ─────────────────────────────────────────────────────────
361
- async createCollection(collectionName, repoRef, skillsRepoRef) {
362
- if (!repoRef)
376
+ async createCollection({ name, repo, skillsRepo }) {
377
+ if (!repo)
363
378
  throw new Error("GitHub backend requires --repo <owner/repo>");
364
- const repo = repoRef;
365
379
  await this.ensureRepo(repo);
366
380
  const workdir = this.ensureWorkdir(repo);
367
- const metaDir = `${SKILLSMANAGER_DIR}/${collectionName}`;
381
+ const metaDir = `${SKILLSMANAGER_DIR}/${name}`;
368
382
  const filePath = path.join(workdir, metaDir, COLLECTION_FILENAME);
369
383
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
370
384
  const owner = await this.getOwner();
371
- const colData = { name: collectionName, owner, skills: [] };
372
- if (skillsRepoRef && skillsRepoRef !== repo) {
373
- colData.metadata = { repo: skillsRepoRef };
385
+ const colData = { name, owner, skills: [] };
386
+ if (skillsRepo && skillsRepo !== repo) {
387
+ colData.metadata = { repo: skillsRepo };
374
388
  }
375
389
  fs.writeFileSync(filePath, serializeCollection(colData));
376
390
  gitExec(["add", path.join(metaDir, COLLECTION_FILENAME)], workdir);
377
- await this.commitAndPush(workdir, `chore: init collection ${collectionName}`);
391
+ await this.commitAndPush(workdir, `chore: init collection ${name}`);
378
392
  return {
379
393
  id: randomUUID(),
380
- name: collectionName,
394
+ name,
381
395
  backend: "github",
382
396
  folderId: `${repo}:${metaDir}`,
383
397
  };
384
398
  }
385
- // ── Static: detect if a path is inside a GitHub-tracked git repo ──────────────
386
- static detectRepoContext(absPath) {
387
- const rootResult = spawnSync("git", ["-C", absPath, "rev-parse", "--show-toplevel"], {
388
- encoding: "utf-8", stdio: "pipe",
389
- });
390
- if (rootResult.status !== 0)
391
- return null;
392
- const repoRoot = rootResult.stdout.trim();
393
- const remoteResult = spawnSync("git", ["-C", repoRoot, "remote", "get-url", "origin"], {
394
- encoding: "utf-8", stdio: "pipe",
395
- });
396
- if (remoteResult.status !== 0)
397
- return null;
398
- const remoteUrl = remoteResult.stdout.trim();
399
- const match = remoteUrl.match(/github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?$/) ??
400
- remoteUrl.match(/github\.com\/([^/]+\/[^/]+)/);
401
- if (!match)
402
- return null;
403
- const repo = match[1].replace(/\.git$/, "");
404
- const relPath = path.relative(repoRoot, absPath).replace(/\\/g, "/");
405
- return { repo, repoRoot, relPath };
406
- }
407
399
  }
@@ -1,16 +1,32 @@
1
1
  import type { CollectionFile, CollectionInfo, RegistryCollectionRef, RegistryFile, RegistryInfo } from "../types.js";
2
+ export interface CreateCollectionOptions {
3
+ name: string;
4
+ repo?: string;
5
+ skillsRepo?: string;
6
+ }
7
+ export interface CreateRegistryOptions {
8
+ name?: string;
9
+ repo?: string;
10
+ }
11
+ export interface BackendStatus {
12
+ loggedIn: boolean;
13
+ identity: string;
14
+ hint?: string;
15
+ }
2
16
  export interface StorageBackend {
3
17
  getOwner(): Promise<string>;
18
+ getStatus(): Promise<BackendStatus>;
4
19
  discoverCollections(): Promise<Omit<CollectionInfo, "id">[]>;
5
20
  readCollection(collection: CollectionInfo): Promise<CollectionFile>;
6
21
  writeCollection(collection: CollectionInfo, data: CollectionFile): Promise<void>;
7
22
  downloadSkill(collection: CollectionInfo, skillName: string, destDir: string): Promise<void>;
8
- uploadSkill(collection: CollectionInfo, localPath: string, skillName: string): Promise<void>;
23
+ uploadSkill(collection: CollectionInfo, localPath: string, skillName: string): Promise<string>;
9
24
  deleteCollection(collection: CollectionInfo): Promise<void>;
10
25
  deleteSkill(collection: CollectionInfo, skillName: string): Promise<void>;
11
26
  discoverRegistries(): Promise<Omit<RegistryInfo, "id">[]>;
12
27
  readRegistry(registry: RegistryInfo): Promise<RegistryFile>;
13
28
  writeRegistry(registry: RegistryInfo, data: RegistryFile): Promise<void>;
14
29
  resolveCollectionRef(ref: RegistryCollectionRef): Promise<Omit<CollectionInfo, "id"> | null>;
15
- createRegistry(name?: string): Promise<RegistryInfo>;
30
+ createRegistry(options?: CreateRegistryOptions): Promise<RegistryInfo>;
31
+ createCollection(options: CreateCollectionOptions): Promise<CollectionInfo>;
16
32
  }
@@ -1,4 +1,4 @@
1
- import type { StorageBackend } from "./interface.js";
1
+ import type { BackendStatus, CreateRegistryOptions, StorageBackend } from "./interface.js";
2
2
  import type { CollectionFile, CollectionInfo, RegistryCollectionRef, RegistryFile, RegistryInfo } from "../types.js";
3
3
  /**
4
4
  * Local filesystem backend — stores collections and the registry under ~/.skillsmanager/.
@@ -6,17 +6,18 @@ import type { CollectionFile, CollectionInfo, RegistryCollectionRef, RegistryFil
6
6
  */
7
7
  export declare class LocalBackend implements StorageBackend {
8
8
  getOwner(): Promise<string>;
9
+ getStatus(): Promise<BackendStatus>;
9
10
  discoverCollections(): Promise<Omit<CollectionInfo, "id">[]>;
10
11
  readCollection(collection: CollectionInfo): Promise<CollectionFile>;
11
12
  writeCollection(collection: CollectionInfo, data: CollectionFile): Promise<void>;
12
13
  downloadSkill(collection: CollectionInfo, skillName: string, destDir: string): Promise<void>;
13
- uploadSkill(collection: CollectionInfo, localPath: string, skillName: string): Promise<void>;
14
+ uploadSkill(collection: CollectionInfo, localPath: string, skillName: string): Promise<string>;
14
15
  deleteCollection(collection: CollectionInfo): Promise<void>;
15
16
  deleteSkill(collection: CollectionInfo, skillName: string): Promise<void>;
16
17
  discoverRegistries(): Promise<Omit<RegistryInfo, "id">[]>;
17
18
  readRegistry(registry: RegistryInfo): Promise<RegistryFile>;
18
19
  writeRegistry(registry: RegistryInfo, data: RegistryFile): Promise<void>;
19
20
  resolveCollectionRef(ref: RegistryCollectionRef): Promise<Omit<CollectionInfo, "id"> | null>;
20
- createRegistry(name?: string): Promise<RegistryInfo>;
21
- createCollection(name: string): Promise<CollectionInfo>;
21
+ createRegistry(options?: CreateRegistryOptions): Promise<RegistryInfo>;
22
+ createCollection({ name }: import("./interface.js").CreateCollectionOptions): Promise<CollectionInfo>;
22
23
  }
@@ -20,6 +20,9 @@ export class LocalBackend {
20
20
  }
21
21
  return process.env.USER ?? process.env.USERNAME ?? "unknown";
22
22
  }
23
+ async getStatus() {
24
+ return { loggedIn: true, identity: await this.getOwner() };
25
+ }
23
26
  // ── Collection operations ────────────────────────────────────────────────
24
27
  async discoverCollections() {
25
28
  if (!fs.existsSync(COLLECTIONS_DIR))
@@ -68,10 +71,10 @@ export class LocalBackend {
68
71
  }
69
72
  async uploadSkill(collection, localPath, skillName) {
70
73
  const dest = path.join(collection.folderId, skillName);
71
- // If source and dest are the same, no-op
72
- if (path.resolve(localPath) === path.resolve(dest))
73
- return;
74
- copyDirSync(localPath, dest);
74
+ if (path.resolve(localPath) !== path.resolve(dest)) {
75
+ copyDirSync(localPath, dest);
76
+ }
77
+ return `${skillName}/`;
75
78
  }
76
79
  async deleteCollection(collection) {
77
80
  if (fs.existsSync(collection.folderId)) {
@@ -121,26 +124,22 @@ export class LocalBackend {
121
124
  folderId: dir,
122
125
  };
123
126
  }
124
- async createRegistry(name) {
127
+ async createRegistry(options) {
128
+ const name = options?.name ?? "local";
125
129
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
126
130
  const owner = await this.getOwner();
127
- const data = {
128
- name: name ?? "local",
129
- owner,
130
- source: "local",
131
- collections: [],
132
- };
131
+ const data = { name, owner, source: "local", collections: [] };
133
132
  fs.writeFileSync(LOCAL_REGISTRY_PATH, serializeRegistryFile(data));
134
133
  return {
135
134
  id: randomUUID(),
136
- name: name ?? "local",
135
+ name,
137
136
  backend: "local",
138
137
  folderId: CONFIG_DIR,
139
138
  fileId: LOCAL_REGISTRY_PATH,
140
139
  };
141
140
  }
142
141
  // ── Convenience: create a local collection ───────────────────────────────
143
- async createCollection(name) {
142
+ async createCollection({ name }) {
144
143
  const dir = path.join(COLLECTIONS_DIR, name);
145
144
  fs.mkdirSync(dir, { recursive: true });
146
145
  const owner = await this.getOwner();
@@ -1,2 +1,4 @@
1
1
  import type { StorageBackend } from "./interface.js";
2
2
  export declare function resolveBackend(backendName: string): Promise<StorageBackend>;
3
+ /** Like resolveBackend but never triggers auth flows — returns null for unconfigured backends. */
4
+ export declare function tryResolveBackend(backendName: string): Promise<StorageBackend | null>;
@@ -1,11 +1,32 @@
1
- import { ensureAuth } from "../auth.js";
1
+ import { ensureAuth, getAuthClient, hasToken } from "../auth.js";
2
+ import { credentialsExist } from "../config.js";
2
3
  import { GDriveBackend } from "./gdrive.js";
3
4
  import { GithubBackend } from "./github.js";
4
5
  import { LocalBackend } from "./local.js";
6
+ import { RoutingBackend } from "./routing.js";
5
7
  export async function resolveBackend(backendName) {
8
+ let inner;
6
9
  if (backendName === "gdrive")
7
- return new GDriveBackend(await ensureAuth());
10
+ inner = new GDriveBackend(await ensureAuth());
11
+ else if (backendName === "github")
12
+ inner = new GithubBackend();
13
+ else
14
+ inner = new LocalBackend();
15
+ return new RoutingBackend(inner);
16
+ }
17
+ /** Like resolveBackend but never triggers auth flows — returns null for unconfigured backends. */
18
+ export async function tryResolveBackend(backendName) {
19
+ if (backendName === "gdrive") {
20
+ if (!credentialsExist() || !hasToken())
21
+ return null;
22
+ try {
23
+ return new RoutingBackend(new GDriveBackend(getAuthClient()));
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
8
29
  if (backendName === "github")
9
- return new GithubBackend();
10
- return new LocalBackend();
30
+ return new RoutingBackend(new GithubBackend());
31
+ return new RoutingBackend(new LocalBackend());
11
32
  }
@@ -0,0 +1,38 @@
1
+ import type { BackendStatus, CreateCollectionOptions, CreateRegistryOptions, StorageBackend } from "./interface.js";
2
+ import type { CollectionFile, CollectionInfo, RegistryCollectionRef, RegistryFile, RegistryInfo } from "../types.js";
3
+ /**
4
+ * RoutingBackend — a transparent decorator over any StorageBackend.
5
+ *
6
+ * Collection-metadata operations (readCollection, writeCollection, registry ops, etc.)
7
+ * pass straight through to the inner backend — the YAML always lives where the
8
+ * collection was declared.
9
+ *
10
+ * Skill-file operations (downloadSkill, uploadSkill, deleteSkill) inspect col.type
11
+ * and dispatch to the appropriate handler:
12
+ * - col.type === "github" → GithubBackend helpers (clone/pull the skills repo)
13
+ * - col.type absent/same → inner backend (normal behaviour, no change)
14
+ *
15
+ * This means every backend gets cross-backend routing for free without any
16
+ * per-backend col.type checks.
17
+ */
18
+ export declare class RoutingBackend implements StorageBackend {
19
+ private readonly inner;
20
+ constructor(inner: StorageBackend);
21
+ getOwner(): Promise<string>;
22
+ getStatus(): Promise<BackendStatus>;
23
+ discoverCollections(): Promise<Omit<CollectionInfo, "id">[]>;
24
+ readCollection(collection: CollectionInfo): Promise<CollectionFile>;
25
+ writeCollection(collection: CollectionInfo, data: CollectionFile): Promise<void>;
26
+ deleteCollection(collection: CollectionInfo): Promise<void>;
27
+ discoverRegistries(): Promise<Omit<RegistryInfo, "id">[]>;
28
+ readRegistry(registry: RegistryInfo): Promise<RegistryFile>;
29
+ writeRegistry(registry: RegistryInfo, data: RegistryFile): Promise<void>;
30
+ resolveCollectionRef(ref: RegistryCollectionRef): Promise<Omit<CollectionInfo, "id"> | null>;
31
+ createRegistry(options?: CreateRegistryOptions): Promise<RegistryInfo>;
32
+ createCollection(options: CreateCollectionOptions): Promise<CollectionInfo>;
33
+ downloadSkill(collection: CollectionInfo, skillName: string, destDir: string): Promise<void>;
34
+ uploadSkill(collection: CollectionInfo, localPath: string, skillName: string): Promise<string>;
35
+ deleteSkill(collection: CollectionInfo, skillName: string): Promise<void>;
36
+ private requireRepo;
37
+ private requireEntry;
38
+ }