@skillsmanager/cli 0.0.1 → 0.0.4

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/dist/auth.js CHANGED
@@ -1,26 +1,48 @@
1
1
  import fs from "fs";
2
- import readline from "readline";
2
+ import http from "http";
3
3
  import { google } from "googleapis";
4
4
  import { TOKEN_PATH, ensureConfigDir, readCredentials, credentialsExist } from "./config.js";
5
5
  const SCOPES = ["https://www.googleapis.com/auth/drive"];
6
+ const LOOPBACK_PORT = 3847;
7
+ const REDIRECT_URI = `http://localhost:${LOOPBACK_PORT}`;
6
8
  function createOAuth2Client() {
7
9
  const { client_id, client_secret } = readCredentials();
8
- return new google.auth.OAuth2(client_id, client_secret, "urn:ietf:wg:oauth:2.0:oob");
10
+ return new google.auth.OAuth2(client_id, client_secret, REDIRECT_URI);
9
11
  }
10
12
  function saveToken(client) {
11
13
  ensureConfigDir();
12
14
  fs.writeFileSync(TOKEN_PATH, JSON.stringify(client.credentials, null, 2));
13
15
  }
14
- function prompt(question) {
15
- const rl = readline.createInterface({
16
- input: process.stdin,
17
- output: process.stdout,
18
- });
19
- return new Promise((resolve) => {
20
- rl.question(question, (answer) => {
21
- rl.close();
22
- resolve(answer.trim());
16
+ /**
17
+ * Starts a temporary local HTTP server to receive the OAuth redirect,
18
+ * extracts the authorization code, and resolves the promise.
19
+ */
20
+ function waitForAuthCode() {
21
+ return new Promise((resolve, reject) => {
22
+ const server = http.createServer((req, res) => {
23
+ const url = new URL(req.url ?? "/", `http://localhost:${LOOPBACK_PORT}`);
24
+ const code = url.searchParams.get("code");
25
+ const error = url.searchParams.get("error");
26
+ if (error) {
27
+ res.writeHead(200, { "Content-Type": "text/html" });
28
+ res.end("<h2>Authorization failed.</h2><p>You can close this tab.</p>");
29
+ server.close();
30
+ reject(new Error(`OAuth error: ${error}`));
31
+ return;
32
+ }
33
+ if (code) {
34
+ res.writeHead(200, { "Content-Type": "text/html" });
35
+ res.end("<h2>Authorization successful!</h2><p>You can close this tab and return to the terminal.</p>");
36
+ server.close();
37
+ resolve(code);
38
+ }
39
+ else {
40
+ res.writeHead(400, { "Content-Type": "text/html" });
41
+ res.end("<h2>Missing authorization code.</h2>");
42
+ }
23
43
  });
44
+ server.listen(LOOPBACK_PORT, () => { });
45
+ server.on("error", (err) => reject(new Error(`Could not start auth server on port ${LOOPBACK_PORT}: ${err.message}`)));
24
46
  });
25
47
  }
26
48
  export async function runAuthFlow() {
@@ -32,11 +54,12 @@ export async function runAuthFlow() {
32
54
  });
33
55
  console.log("\nOpen this URL in your browser to authorize Skills Manager:\n");
34
56
  console.log(authUrl);
35
- console.log();
36
- const code = await prompt("Paste the authorization code here: ");
57
+ console.log("\nWaiting for authorization...");
58
+ const code = await waitForAuthCode();
37
59
  const { tokens } = await client.getToken(code);
38
60
  client.setCredentials(tokens);
39
61
  saveToken(client);
62
+ console.log("Authorization successful.");
40
63
  return client;
41
64
  }
42
65
  export function getAuthClient() {
@@ -3,7 +3,6 @@ import type { 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
- private oauth2;
7
6
  constructor(auth: OAuth2Client);
8
7
  getOwner(): Promise<string>;
9
8
  getOwnerEmail(): Promise<string>;
@@ -13,6 +12,7 @@ export declare class GDriveBackend implements StorageBackend {
13
12
  downloadSkill(collection: CollectionInfo, skillName: string, destDir: string): Promise<void>;
14
13
  private downloadFolder;
15
14
  createCollection(folderName: string): Promise<CollectionInfo>;
15
+ deleteCollection(collection: CollectionInfo): Promise<void>;
16
16
  uploadSkill(collection: CollectionInfo, localPath: string, skillName: string): Promise<void>;
17
17
  discoverRegistries(): Promise<Omit<RegistryInfo, "id">[]>;
18
18
  readRegistry(registry: RegistryInfo): Promise<RegistryFile>;
@@ -7,14 +7,12 @@ import { randomUUID } from "crypto";
7
7
  const FOLDER_MIME = "application/vnd.google-apps.folder";
8
8
  export class GDriveBackend {
9
9
  drive;
10
- oauth2;
11
10
  constructor(auth) {
12
11
  this.drive = google.drive({ version: "v3", auth });
13
- this.oauth2 = google.oauth2({ version: "v2", auth });
14
12
  }
15
13
  async getOwner() {
16
- const res = await this.oauth2.userinfo.get();
17
- return res.data.email ?? "";
14
+ const res = await this.drive.about.get({ fields: "user(emailAddress)" });
15
+ return res.data.user?.emailAddress ?? "";
18
16
  }
19
17
  // Alias for backwards compat
20
18
  async getOwnerEmail() {
@@ -180,6 +178,13 @@ export class GDriveBackend {
180
178
  registryFileId: fileRes.data.id ?? undefined,
181
179
  };
182
180
  }
181
+ async deleteCollection(collection) {
182
+ // Trash the collection folder on Drive (moves to trash, not permanent delete)
183
+ await this.drive.files.update({
184
+ fileId: collection.folderId,
185
+ requestBody: { trashed: true },
186
+ });
187
+ }
183
188
  async uploadSkill(collection, localPath, skillName) {
184
189
  let folderId = await this.findFolder(skillName, collection.folderId);
185
190
  if (!folderId) {
@@ -0,0 +1,26 @@
1
+ import type { StorageBackend } from "./interface.js";
2
+ import type { CollectionFile, CollectionInfo, RegistryCollectionRef, RegistryFile, RegistryInfo } from "../types.js";
3
+ export declare class GithubBackend implements StorageBackend {
4
+ getOwner(): Promise<string>;
5
+ ensureRepo(repo: string): Promise<void>;
6
+ private ensureWorkdir;
7
+ private gitPushOrPR;
8
+ private commitAndPush;
9
+ discoverCollections(): Promise<Omit<CollectionInfo, "id">[]>;
10
+ readCollection(collection: CollectionInfo): Promise<CollectionFile>;
11
+ writeCollection(collection: CollectionInfo, data: CollectionFile): Promise<void>;
12
+ downloadSkill(collection: CollectionInfo, skillName: string, destDir: string): Promise<void>;
13
+ uploadSkill(collection: CollectionInfo, localPath: string, skillName: string): Promise<void>;
14
+ deleteCollection(collection: CollectionInfo): Promise<void>;
15
+ discoverRegistries(): Promise<Omit<RegistryInfo, "id">[]>;
16
+ readRegistry(registry: RegistryInfo): Promise<RegistryFile>;
17
+ writeRegistry(registry: RegistryInfo, data: RegistryFile): Promise<void>;
18
+ resolveCollectionRef(ref: RegistryCollectionRef): Promise<Omit<CollectionInfo, "id"> | null>;
19
+ createRegistry(name?: string, repoRef?: string): Promise<RegistryInfo>;
20
+ createCollection(collectionName: string, repoRef?: string): Promise<CollectionInfo>;
21
+ static detectRepoContext(absPath: string): {
22
+ repo: string;
23
+ repoRoot: string;
24
+ relPath: string;
25
+ } | null;
26
+ }
@@ -0,0 +1,378 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { randomUUID } from "crypto";
4
+ import { spawnSync } from "child_process";
5
+ import chalk from "chalk";
6
+ import { parseCollection, serializeCollection, parseRegistryFile, serializeRegistryFile, COLLECTION_FILENAME, REGISTRY_FILENAME, } from "../registry.js";
7
+ import { CONFIG_DIR } from "../config.js";
8
+ const GITHUB_WORKDIR = path.join(CONFIG_DIR, "github-workdir");
9
+ const SKILLSMANAGER_DIR = ".skillsmanager";
10
+ // ── folderId format: "owner/repo:.skillsmanager/collection-name" ──────────────
11
+ function parseRef(folderId) {
12
+ const colonIdx = folderId.indexOf(":");
13
+ if (colonIdx === -1)
14
+ return { repo: folderId, metaDir: SKILLSMANAGER_DIR };
15
+ return {
16
+ repo: folderId.slice(0, colonIdx),
17
+ metaDir: folderId.slice(colonIdx + 1) || SKILLSMANAGER_DIR,
18
+ };
19
+ }
20
+ function workdirFor(repo) {
21
+ return path.join(GITHUB_WORKDIR, repo.replace("/", "_"));
22
+ }
23
+ // ── CLI helpers ───────────────────────────────────────────────────────────────
24
+ function ghExec(args, opts) {
25
+ const r = spawnSync("gh", args, {
26
+ cwd: opts?.cwd,
27
+ encoding: "utf-8",
28
+ stdio: ["pipe", "pipe", "pipe"],
29
+ });
30
+ return {
31
+ ok: r.status === 0,
32
+ stdout: (r.stdout ?? "").trim(),
33
+ stderr: (r.stderr ?? "").trim(),
34
+ };
35
+ }
36
+ function gitExec(args, cwd) {
37
+ const r = spawnSync("git", args, { cwd, encoding: "utf-8", stdio: "pipe" });
38
+ return {
39
+ ok: r.status === 0,
40
+ stdout: (r.stdout ?? "").trim(),
41
+ stderr: (r.stderr ?? "").trim(),
42
+ };
43
+ }
44
+ function copyDirSync(src, dest) {
45
+ fs.mkdirSync(dest, { recursive: true });
46
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
47
+ const srcPath = path.join(src, entry.name);
48
+ const destPath = path.join(dest, entry.name);
49
+ if (entry.isDirectory()) {
50
+ copyDirSync(srcPath, destPath);
51
+ }
52
+ else {
53
+ fs.copyFileSync(srcPath, destPath);
54
+ }
55
+ }
56
+ }
57
+ // ── GithubBackend ─────────────────────────────────────────────────────────────
58
+ export class GithubBackend {
59
+ // ── Identity ─────────────────────────────────────────────────────────────────
60
+ async getOwner() {
61
+ const r = ghExec(["api", "user", "--jq", ".login"]);
62
+ if (!r.ok || !r.stdout) {
63
+ throw new Error("GitHub auth failed. Run: skillsmanager setup github");
64
+ }
65
+ return r.stdout;
66
+ }
67
+ // ── Ensure repo exists (create private if not) ───────────────────────────────
68
+ async ensureRepo(repo) {
69
+ const check = ghExec(["api", `repos/${repo}`]);
70
+ if (!check.ok) {
71
+ console.log(chalk.dim(` Repo ${repo} not found — creating private repo...`));
72
+ const name = repo.split("/")[1];
73
+ const create = ghExec(["repo", "create", name, "--private", "--confirm"]);
74
+ if (!create.ok)
75
+ throw new Error(`Failed to create repo ${repo}: ${create.stderr}`);
76
+ console.log(chalk.green(` Created private repo: ${repo}`));
77
+ }
78
+ }
79
+ // ── Private: workdir management ───────────────────────────────────────────────
80
+ ensureWorkdir(repo) {
81
+ const dir = workdirFor(repo);
82
+ if (!fs.existsSync(dir)) {
83
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
84
+ const r = spawnSync("gh", ["repo", "clone", repo, dir], { stdio: "inherit" });
85
+ if (r.status !== 0)
86
+ throw new Error(`Failed to clone ${repo}`);
87
+ }
88
+ else {
89
+ gitExec(["pull", "--ff-only"], dir);
90
+ }
91
+ return dir;
92
+ }
93
+ // ── Private: push with PR fallback ───────────────────────────────────────────
94
+ async gitPushOrPR(workdir, title) {
95
+ const pushResult = gitExec(["push", "origin", "HEAD"], workdir);
96
+ if (pushResult.ok)
97
+ return;
98
+ // Direct push blocked — create a PR
99
+ console.log(chalk.yellow("\n Direct push blocked (branch protection). Creating a PR..."));
100
+ const branch = `skillsmanager-update-${Date.now()}`;
101
+ const checkout = gitExec(["checkout", "-b", branch], workdir);
102
+ if (!checkout.ok)
103
+ throw new Error(`Failed to create branch: ${checkout.stderr}`);
104
+ const pushBranch = gitExec(["push", "-u", "origin", branch], workdir);
105
+ if (!pushBranch.ok)
106
+ throw new Error(`Failed to push branch: ${pushBranch.stderr}`);
107
+ const prResult = ghExec(["pr", "create", "--title", title, "--body", "Created by skillsmanager", "--fill"], { cwd: workdir });
108
+ if (!prResult.ok)
109
+ throw new Error(`Failed to create PR: ${prResult.stderr}`);
110
+ const prUrl = prResult.stdout.split("\n").find((l) => l.startsWith("https://")) ?? prResult.stdout;
111
+ console.log(chalk.cyan(`\n PR created: ${prUrl}`));
112
+ console.log(chalk.dim(" Waiting for merge (up to 5 minutes)..."));
113
+ const timeout = Date.now() + 5 * 60 * 1000;
114
+ let merged = false;
115
+ while (Date.now() < timeout) {
116
+ await new Promise((r) => setTimeout(r, 10_000));
117
+ const stateResult = ghExec(["pr", "view", prUrl, "--json", "state", "--jq", ".state"], { cwd: workdir });
118
+ if (stateResult.ok && stateResult.stdout.trim() === "MERGED") {
119
+ merged = true;
120
+ break;
121
+ }
122
+ }
123
+ if (!merged) {
124
+ console.log(chalk.yellow(`\n PR not merged within timeout. Branch "${branch}" is still open.`));
125
+ console.log(chalk.dim(" Merge it manually, then run: skillsmanager refresh"));
126
+ return;
127
+ }
128
+ // Back to default branch, pull
129
+ const headRef = gitExec(["rev-parse", "--abbrev-ref", "origin/HEAD"], workdir);
130
+ const base = headRef.stdout.replace("origin/", "") || "main";
131
+ gitExec(["checkout", base], workdir);
132
+ gitExec(["pull", "--ff-only"], workdir);
133
+ console.log(chalk.green(" ✓ PR merged and changes pulled."));
134
+ }
135
+ async commitAndPush(workdir, message) {
136
+ const commit = gitExec(["commit", "-m", message], workdir);
137
+ const nothingToCommit = commit.stdout.includes("nothing to commit") ||
138
+ commit.stderr.includes("nothing to commit");
139
+ if (!commit.ok && nothingToCommit)
140
+ return;
141
+ if (!commit.ok)
142
+ throw new Error(`Git commit failed: ${commit.stderr || commit.stdout}`);
143
+ await this.gitPushOrPR(workdir, message);
144
+ }
145
+ // ── Collection operations ─────────────────────────────────────────────────────
146
+ async discoverCollections() {
147
+ const r = ghExec(["repo", "list", "--json", "nameWithOwner", "--limit", "100"]);
148
+ if (!r.ok)
149
+ return [];
150
+ let repos = [];
151
+ try {
152
+ repos = JSON.parse(r.stdout);
153
+ }
154
+ catch {
155
+ return [];
156
+ }
157
+ const collections = [];
158
+ for (const { nameWithOwner } of repos) {
159
+ const dirCheck = ghExec([
160
+ "api", `repos/${nameWithOwner}/contents/${SKILLSMANAGER_DIR}`,
161
+ ]);
162
+ if (!dirCheck.ok)
163
+ continue;
164
+ let entries = [];
165
+ try {
166
+ entries = JSON.parse(dirCheck.stdout);
167
+ }
168
+ catch {
169
+ continue;
170
+ }
171
+ for (const entry of entries) {
172
+ if (entry.type !== "dir")
173
+ continue;
174
+ const fileCheck = ghExec([
175
+ "api",
176
+ `repos/${nameWithOwner}/contents/${SKILLSMANAGER_DIR}/${entry.name}/${COLLECTION_FILENAME}`,
177
+ ]);
178
+ if (!fileCheck.ok)
179
+ continue;
180
+ collections.push({
181
+ name: entry.name,
182
+ backend: "github",
183
+ folderId: `${nameWithOwner}:${SKILLSMANAGER_DIR}/${entry.name}`,
184
+ });
185
+ }
186
+ }
187
+ return collections;
188
+ }
189
+ async readCollection(collection) {
190
+ const { repo, metaDir } = parseRef(collection.folderId);
191
+ const r = ghExec([
192
+ "api", `repos/${repo}/contents/${metaDir}/${COLLECTION_FILENAME}`, "--jq", ".content",
193
+ ]);
194
+ if (!r.ok)
195
+ throw new Error(`Collection file not found in "${collection.name}"`);
196
+ const content = Buffer.from(r.stdout.replace(/\s/g, ""), "base64").toString("utf-8");
197
+ return parseCollection(content);
198
+ }
199
+ async writeCollection(collection, data) {
200
+ const { repo, metaDir } = parseRef(collection.folderId);
201
+ const workdir = this.ensureWorkdir(repo);
202
+ const filePath = path.join(workdir, metaDir, COLLECTION_FILENAME);
203
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
204
+ fs.writeFileSync(filePath, serializeCollection(data));
205
+ gitExec(["add", path.join(metaDir, COLLECTION_FILENAME)], workdir);
206
+ await this.commitAndPush(workdir, `chore: update ${COLLECTION_FILENAME} for ${collection.name}`);
207
+ }
208
+ async downloadSkill(collection, skillName, destDir) {
209
+ const { repo } = parseRef(collection.folderId);
210
+ const workdir = this.ensureWorkdir(repo);
211
+ // Refresh to get latest
212
+ gitExec(["pull", "--ff-only"], workdir);
213
+ const col = await this.readCollection(collection);
214
+ const entry = col.skills.find((s) => s.name === skillName);
215
+ if (!entry) {
216
+ throw new Error(`Skill "${skillName}" not found in collection "${collection.name}"`);
217
+ }
218
+ const skillPath = path.join(workdir, entry.path);
219
+ if (!fs.existsSync(skillPath)) {
220
+ throw new Error(`Skill directory not found at "${entry.path}" in repo "${repo}"`);
221
+ }
222
+ if (path.resolve(skillPath) !== path.resolve(destDir)) {
223
+ copyDirSync(skillPath, destDir);
224
+ }
225
+ }
226
+ async uploadSkill(collection, localPath, skillName) {
227
+ const { repo } = parseRef(collection.folderId);
228
+ const workdir = this.ensureWorkdir(repo);
229
+ const resolvedLocal = path.resolve(localPath);
230
+ const resolvedWorkdir = path.resolve(workdir);
231
+ if (resolvedLocal.startsWith(resolvedWorkdir + path.sep) ||
232
+ resolvedLocal === resolvedWorkdir) {
233
+ // Already in the repo — no copy needed
234
+ return;
235
+ }
236
+ // External skill: copy into .agentskills/<skillName>/ in the repo
237
+ const dest = path.join(workdir, ".agentskills", skillName);
238
+ copyDirSync(localPath, dest);
239
+ gitExec(["add", path.join(".agentskills", skillName)], workdir);
240
+ await this.commitAndPush(workdir, `chore: add skill ${skillName}`);
241
+ }
242
+ async deleteCollection(collection) {
243
+ const { repo, metaDir } = parseRef(collection.folderId);
244
+ const workdir = this.ensureWorkdir(repo);
245
+ const metaDirPath = path.join(workdir, metaDir);
246
+ if (!fs.existsSync(metaDirPath))
247
+ return;
248
+ fs.rmSync(metaDirPath, { recursive: true, force: true });
249
+ gitExec(["add", "-A"], workdir);
250
+ await this.commitAndPush(workdir, `chore: remove collection ${collection.name}`);
251
+ }
252
+ // ── Registry operations ───────────────────────────────────────────────────────
253
+ async discoverRegistries() {
254
+ const r = ghExec(["repo", "list", "--json", "nameWithOwner", "--limit", "100"]);
255
+ if (!r.ok)
256
+ return [];
257
+ let repos = [];
258
+ try {
259
+ repos = JSON.parse(r.stdout);
260
+ }
261
+ catch {
262
+ return [];
263
+ }
264
+ const registries = [];
265
+ for (const { nameWithOwner } of repos) {
266
+ const check = ghExec([
267
+ "api", `repos/${nameWithOwner}/contents/${SKILLSMANAGER_DIR}/${REGISTRY_FILENAME}`,
268
+ ]);
269
+ if (!check.ok)
270
+ continue;
271
+ registries.push({
272
+ name: nameWithOwner.split("/")[1] ?? nameWithOwner,
273
+ backend: "github",
274
+ folderId: `${nameWithOwner}:${SKILLSMANAGER_DIR}`,
275
+ fileId: `${nameWithOwner}:${SKILLSMANAGER_DIR}/${REGISTRY_FILENAME}`,
276
+ });
277
+ }
278
+ return registries;
279
+ }
280
+ async readRegistry(registry) {
281
+ const { repo, metaDir } = parseRef(registry.folderId);
282
+ const r = ghExec([
283
+ "api", `repos/${repo}/contents/${metaDir}/${REGISTRY_FILENAME}`, "--jq", ".content",
284
+ ]);
285
+ if (!r.ok)
286
+ throw new Error(`Registry file not found for "${registry.name}"`);
287
+ const content = Buffer.from(r.stdout.replace(/\s/g, ""), "base64").toString("utf-8");
288
+ return parseRegistryFile(content);
289
+ }
290
+ async writeRegistry(registry, data) {
291
+ const { repo, metaDir } = parseRef(registry.folderId);
292
+ const workdir = this.ensureWorkdir(repo);
293
+ const filePath = path.join(workdir, metaDir, REGISTRY_FILENAME);
294
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
295
+ fs.writeFileSync(filePath, serializeRegistryFile(data));
296
+ gitExec(["add", path.join(metaDir, REGISTRY_FILENAME)], workdir);
297
+ await this.commitAndPush(workdir, `chore: update ${REGISTRY_FILENAME}`);
298
+ }
299
+ async resolveCollectionRef(ref) {
300
+ if (ref.backend !== "github")
301
+ return null;
302
+ const { repo, metaDir } = parseRef(ref.ref);
303
+ const check = ghExec(["api", `repos/${repo}/contents/${metaDir}/${COLLECTION_FILENAME}`]);
304
+ if (!check.ok)
305
+ return null;
306
+ return { name: ref.name, backend: "github", folderId: ref.ref };
307
+ }
308
+ async createRegistry(name, repoRef) {
309
+ if (!repoRef)
310
+ throw new Error("GitHub backend requires --repo <owner/repo>");
311
+ await this.ensureRepo(repoRef);
312
+ const workdir = this.ensureWorkdir(repoRef);
313
+ const metaDir = SKILLSMANAGER_DIR;
314
+ const filePath = path.join(workdir, metaDir, REGISTRY_FILENAME);
315
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
316
+ const owner = await this.getOwner();
317
+ const registryData = {
318
+ name: name ?? (repoRef.split("/")[1] ?? "default"),
319
+ owner,
320
+ source: "github",
321
+ collections: [],
322
+ };
323
+ fs.writeFileSync(filePath, serializeRegistryFile(registryData));
324
+ gitExec(["add", path.join(metaDir, REGISTRY_FILENAME)], workdir);
325
+ await this.commitAndPush(workdir, "chore: init SKILLS_REGISTRY");
326
+ return {
327
+ id: randomUUID(),
328
+ name: registryData.name,
329
+ backend: "github",
330
+ folderId: `${repoRef}:${metaDir}`,
331
+ fileId: `${repoRef}:${metaDir}/${REGISTRY_FILENAME}`,
332
+ };
333
+ }
334
+ // ── createCollection ─────────────────────────────────────────────────────────
335
+ async createCollection(collectionName, repoRef) {
336
+ if (!repoRef)
337
+ throw new Error("GitHub backend requires --repo <owner/repo>");
338
+ const repo = repoRef;
339
+ await this.ensureRepo(repo);
340
+ const workdir = this.ensureWorkdir(repo);
341
+ const metaDir = `${SKILLSMANAGER_DIR}/${collectionName}`;
342
+ const filePath = path.join(workdir, metaDir, COLLECTION_FILENAME);
343
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
344
+ const owner = await this.getOwner();
345
+ const colData = { name: collectionName, owner, skills: [] };
346
+ fs.writeFileSync(filePath, serializeCollection(colData));
347
+ gitExec(["add", path.join(metaDir, COLLECTION_FILENAME)], workdir);
348
+ await this.commitAndPush(workdir, `chore: init collection ${collectionName}`);
349
+ return {
350
+ id: randomUUID(),
351
+ name: collectionName,
352
+ backend: "github",
353
+ folderId: `${repo}:${metaDir}`,
354
+ };
355
+ }
356
+ // ── Static: detect if a path is inside a GitHub-tracked git repo ──────────────
357
+ static detectRepoContext(absPath) {
358
+ const rootResult = spawnSync("git", ["-C", absPath, "rev-parse", "--show-toplevel"], {
359
+ encoding: "utf-8", stdio: "pipe",
360
+ });
361
+ if (rootResult.status !== 0)
362
+ return null;
363
+ const repoRoot = rootResult.stdout.trim();
364
+ const remoteResult = spawnSync("git", ["-C", repoRoot, "remote", "get-url", "origin"], {
365
+ encoding: "utf-8", stdio: "pipe",
366
+ });
367
+ if (remoteResult.status !== 0)
368
+ return null;
369
+ const remoteUrl = remoteResult.stdout.trim();
370
+ const match = remoteUrl.match(/github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?$/) ??
371
+ remoteUrl.match(/github\.com\/([^/]+\/[^/]+)/);
372
+ if (!match)
373
+ return null;
374
+ const repo = match[1].replace(/\.git$/, "");
375
+ const relPath = path.relative(repoRoot, absPath).replace(/\\/g, "/");
376
+ return { repo, repoRoot, relPath };
377
+ }
378
+ }
@@ -6,6 +6,7 @@ export interface StorageBackend {
6
6
  writeCollection(collection: CollectionInfo, data: CollectionFile): Promise<void>;
7
7
  downloadSkill(collection: CollectionInfo, skillName: string, destDir: string): Promise<void>;
8
8
  uploadSkill(collection: CollectionInfo, localPath: string, skillName: string): Promise<void>;
9
+ deleteCollection(collection: CollectionInfo): Promise<void>;
9
10
  discoverRegistries(): Promise<Omit<RegistryInfo, "id">[]>;
10
11
  readRegistry(registry: RegistryInfo): Promise<RegistryFile>;
11
12
  writeRegistry(registry: RegistryInfo, data: RegistryFile): Promise<void>;
@@ -11,6 +11,7 @@ export declare class LocalBackend implements StorageBackend {
11
11
  writeCollection(collection: CollectionInfo, data: CollectionFile): Promise<void>;
12
12
  downloadSkill(collection: CollectionInfo, skillName: string, destDir: string): Promise<void>;
13
13
  uploadSkill(collection: CollectionInfo, localPath: string, skillName: string): Promise<void>;
14
+ deleteCollection(collection: CollectionInfo): Promise<void>;
14
15
  discoverRegistries(): Promise<Omit<RegistryInfo, "id">[]>;
15
16
  readRegistry(registry: RegistryInfo): Promise<RegistryFile>;
16
17
  writeRegistry(registry: RegistryInfo, data: RegistryFile): Promise<void>;
@@ -73,6 +73,11 @@ export class LocalBackend {
73
73
  return;
74
74
  copyDirSync(localPath, dest);
75
75
  }
76
+ async deleteCollection(collection) {
77
+ if (fs.existsSync(collection.folderId)) {
78
+ fs.rmSync(collection.folderId, { recursive: true, force: true });
79
+ }
80
+ }
76
81
  // ── Registry operations ──────────────────────────────────────────────────
77
82
  async discoverRegistries() {
78
83
  if (!fs.existsSync(LOCAL_REGISTRY_PATH))
@@ -0,0 +1,2 @@
1
+ import type { StorageBackend } from "./interface.js";
2
+ export declare function resolveBackend(backendName: string): Promise<StorageBackend>;
@@ -0,0 +1,11 @@
1
+ import { ensureAuth } from "../auth.js";
2
+ import { GDriveBackend } from "./gdrive.js";
3
+ import { GithubBackend } from "./github.js";
4
+ import { LocalBackend } from "./local.js";
5
+ export async function resolveBackend(backendName) {
6
+ if (backendName === "gdrive")
7
+ return new GDriveBackend(await ensureAuth());
8
+ if (backendName === "github")
9
+ return new GithubBackend();
10
+ return new LocalBackend();
11
+ }