@skillsmanager/cli 0.0.4 → 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.
Files changed (42) hide show
  1. package/LICENSE +10 -18
  2. package/README.md +93 -36
  3. package/dist/auth.d.ts +1 -0
  4. package/dist/auth.js +19 -2
  5. package/dist/backends/gdrive.d.ts +6 -4
  6. package/dist/backends/gdrive.js +27 -13
  7. package/dist/backends/github.d.ts +10 -9
  8. package/dist/backends/github.js +71 -50
  9. package/dist/backends/interface.d.ts +19 -2
  10. package/dist/backends/local.d.ts +6 -4
  11. package/dist/backends/local.js +18 -13
  12. package/dist/backends/resolve.d.ts +2 -0
  13. package/dist/backends/resolve.js +25 -4
  14. package/dist/backends/routing.d.ts +38 -0
  15. package/dist/backends/routing.js +124 -0
  16. package/dist/commands/add.d.ts +3 -0
  17. package/dist/commands/add.js +130 -26
  18. package/dist/commands/collection.d.ts +1 -0
  19. package/dist/commands/collection.js +43 -37
  20. package/dist/commands/init.js +3 -3
  21. package/dist/commands/list.js +78 -8
  22. package/dist/commands/logout.d.ts +4 -0
  23. package/dist/commands/logout.js +35 -0
  24. package/dist/commands/refresh.js +1 -1
  25. package/dist/commands/registry.d.ts +1 -0
  26. package/dist/commands/registry.js +74 -36
  27. package/dist/commands/search.js +1 -1
  28. package/dist/commands/setup/github.d.ts +3 -0
  29. package/dist/commands/setup/github.js +8 -2
  30. package/dist/commands/setup/google.js +82 -42
  31. package/dist/commands/skill.d.ts +3 -0
  32. package/dist/commands/skill.js +76 -0
  33. package/dist/commands/status.d.ts +1 -0
  34. package/dist/commands/status.js +35 -0
  35. package/dist/config.js +6 -1
  36. package/dist/index.js +37 -3
  37. package/dist/registry.js +20 -8
  38. package/dist/types.d.ts +2 -0
  39. package/dist/utils/git.d.ts +10 -0
  40. package/dist/utils/git.js +27 -0
  41. package/package.json +2 -2
  42. package/skills/skillsmanager/SKILL.md +109 -6
@@ -1,15 +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>;
25
+ deleteSkill(collection: CollectionInfo, skillName: string): Promise<void>;
10
26
  discoverRegistries(): Promise<Omit<RegistryInfo, "id">[]>;
11
27
  readRegistry(registry: RegistryInfo): Promise<RegistryFile>;
12
28
  writeRegistry(registry: RegistryInfo, data: RegistryFile): Promise<void>;
13
29
  resolveCollectionRef(ref: RegistryCollectionRef): Promise<Omit<CollectionInfo, "id"> | null>;
14
- createRegistry(name?: string): Promise<RegistryInfo>;
30
+ createRegistry(options?: CreateRegistryOptions): Promise<RegistryInfo>;
31
+ createCollection(options: CreateCollectionOptions): Promise<CollectionInfo>;
15
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,16 +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>;
16
+ deleteSkill(collection: CollectionInfo, skillName: string): Promise<void>;
15
17
  discoverRegistries(): Promise<Omit<RegistryInfo, "id">[]>;
16
18
  readRegistry(registry: RegistryInfo): Promise<RegistryFile>;
17
19
  writeRegistry(registry: RegistryInfo, data: RegistryFile): Promise<void>;
18
20
  resolveCollectionRef(ref: RegistryCollectionRef): Promise<Omit<CollectionInfo, "id"> | null>;
19
- createRegistry(name?: string): Promise<RegistryInfo>;
20
- createCollection(name: string): Promise<CollectionInfo>;
21
+ createRegistry(options?: CreateRegistryOptions): Promise<RegistryInfo>;
22
+ createCollection({ name }: import("./interface.js").CreateCollectionOptions): Promise<CollectionInfo>;
21
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,16 +71,22 @@ 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)) {
78
81
  fs.rmSync(collection.folderId, { recursive: true, force: true });
79
82
  }
80
83
  }
84
+ async deleteSkill(collection, skillName) {
85
+ const skillPath = path.join(collection.folderId, skillName);
86
+ if (fs.existsSync(skillPath)) {
87
+ fs.rmSync(skillPath, { recursive: true, force: true });
88
+ }
89
+ }
81
90
  // ── Registry operations ──────────────────────────────────────────────────
82
91
  async discoverRegistries() {
83
92
  if (!fs.existsSync(LOCAL_REGISTRY_PATH))
@@ -115,26 +124,22 @@ export class LocalBackend {
115
124
  folderId: dir,
116
125
  };
117
126
  }
118
- async createRegistry(name) {
127
+ async createRegistry(options) {
128
+ const name = options?.name ?? "local";
119
129
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
120
130
  const owner = await this.getOwner();
121
- const data = {
122
- name: name ?? "local",
123
- owner,
124
- source: "local",
125
- collections: [],
126
- };
131
+ const data = { name, owner, source: "local", collections: [] };
127
132
  fs.writeFileSync(LOCAL_REGISTRY_PATH, serializeRegistryFile(data));
128
133
  return {
129
134
  id: randomUUID(),
130
- name: name ?? "local",
135
+ name,
131
136
  backend: "local",
132
137
  folderId: CONFIG_DIR,
133
138
  fileId: LOCAL_REGISTRY_PATH,
134
139
  };
135
140
  }
136
141
  // ── Convenience: create a local collection ───────────────────────────────
137
- async createCollection(name) {
142
+ async createCollection({ name }) {
138
143
  const dir = path.join(COLLECTIONS_DIR, name);
139
144
  fs.mkdirSync(dir, { recursive: true });
140
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
+ }
@@ -0,0 +1,124 @@
1
+ import { GithubBackend } from "./github.js";
2
+ /**
3
+ * RoutingBackend — a transparent decorator over any StorageBackend.
4
+ *
5
+ * Collection-metadata operations (readCollection, writeCollection, registry ops, etc.)
6
+ * pass straight through to the inner backend — the YAML always lives where the
7
+ * collection was declared.
8
+ *
9
+ * Skill-file operations (downloadSkill, uploadSkill, deleteSkill) inspect col.type
10
+ * and dispatch to the appropriate handler:
11
+ * - col.type === "github" → GithubBackend helpers (clone/pull the skills repo)
12
+ * - col.type absent/same → inner backend (normal behaviour, no change)
13
+ *
14
+ * This means every backend gets cross-backend routing for free without any
15
+ * per-backend col.type checks.
16
+ */
17
+ export class RoutingBackend {
18
+ inner;
19
+ constructor(inner) {
20
+ this.inner = inner;
21
+ }
22
+ // ── Pass-through: identity + collection metadata ───────────────────────────
23
+ getOwner() {
24
+ return this.inner.getOwner();
25
+ }
26
+ getStatus() {
27
+ return this.inner.getStatus();
28
+ }
29
+ discoverCollections() {
30
+ return this.inner.discoverCollections();
31
+ }
32
+ readCollection(collection) {
33
+ return this.inner.readCollection(collection);
34
+ }
35
+ writeCollection(collection, data) {
36
+ return this.inner.writeCollection(collection, data);
37
+ }
38
+ deleteCollection(collection) {
39
+ return this.inner.deleteCollection(collection);
40
+ }
41
+ // ── Pass-through: registry operations ─────────────────────────────────────
42
+ discoverRegistries() {
43
+ return this.inner.discoverRegistries();
44
+ }
45
+ readRegistry(registry) {
46
+ return this.inner.readRegistry(registry);
47
+ }
48
+ writeRegistry(registry, data) {
49
+ return this.inner.writeRegistry(registry, data);
50
+ }
51
+ resolveCollectionRef(ref) {
52
+ return this.inner.resolveCollectionRef(ref);
53
+ }
54
+ createRegistry(options) {
55
+ return this.inner.createRegistry(options);
56
+ }
57
+ createCollection(options) {
58
+ return this.inner.createCollection(options);
59
+ }
60
+ // ── Routed: dispatch on col.type for skill-file operations ─────────────────
61
+ async downloadSkill(collection, skillName, destDir) {
62
+ const col = await this.inner.readCollection(collection);
63
+ const skillType = col.type ?? collection.backend;
64
+ // Only cross-dispatch when the skill source differs from the collection's own backend.
65
+ // Same-backend collections (e.g. GitHub-native) handle routing internally.
66
+ if (skillType === "github" && collection.backend !== "github") {
67
+ const repo = this.requireRepo(col, collection.name);
68
+ const entry = this.requireEntry(col, skillName, collection.name);
69
+ await new GithubBackend().downloadSkillFromRepo(repo, entry.path, destDir);
70
+ return;
71
+ }
72
+ return this.inner.downloadSkill(collection, skillName, destDir);
73
+ }
74
+ async uploadSkill(collection, localPath, skillName) {
75
+ const col = await this.inner.readCollection(collection);
76
+ const skillType = col.type ?? collection.backend;
77
+ // Case 1: collection YAML in one backend, skills declared in another (col.type set)
78
+ if (skillType !== collection.backend) {
79
+ throw new Error(`Cannot upload skill to collection "${collection.name}": ` +
80
+ `its skills source type is "${skillType}". ` +
81
+ `Use --remote-path to register a skill path instead.`);
82
+ }
83
+ // Case 2: GitHub-native collection whose metadata.repo points to a foreign repo
84
+ if (skillType === "github" && col.metadata?.repo) {
85
+ const hostRepo = collection.folderId.split(":")[0];
86
+ const foreign = col.metadata.repo;
87
+ if (foreign !== hostRepo) {
88
+ throw new Error(`Cannot upload skill to collection "${collection.name}": ` +
89
+ `its skills source is "${foreign}" (a repo you may not own). ` +
90
+ `Use --remote-path to register a skill path instead.`);
91
+ }
92
+ }
93
+ return this.inner.uploadSkill(collection, localPath, skillName);
94
+ }
95
+ async deleteSkill(collection, skillName) {
96
+ const col = await this.inner.readCollection(collection);
97
+ const skillType = col.type ?? collection.backend;
98
+ // Only cross-dispatch when the skill source differs from the collection's own backend.
99
+ if (skillType === "github" && collection.backend !== "github") {
100
+ const repo = this.requireRepo(col, collection.name);
101
+ const entry = col.skills.find((s) => s.name === skillName);
102
+ if (!entry)
103
+ return;
104
+ await new GithubBackend().deleteSkillFromRepo(repo, entry.path);
105
+ return;
106
+ }
107
+ return this.inner.deleteSkill(collection, skillName);
108
+ }
109
+ // ── Private helpers ────────────────────────────────────────────────────────
110
+ requireRepo(col, collectionName) {
111
+ const repo = col.metadata?.repo;
112
+ if (!repo) {
113
+ throw new Error(`Collection "${collectionName}" has type "github" but is missing metadata.repo`);
114
+ }
115
+ return repo;
116
+ }
117
+ requireEntry(col, skillName, collectionName) {
118
+ const entry = col.skills.find((s) => s.name === skillName);
119
+ if (!entry) {
120
+ throw new Error(`Skill "${skillName}" not found in collection "${collectionName}"`);
121
+ }
122
+ return entry;
123
+ }
124
+ }
@@ -1,3 +1,6 @@
1
1
  export declare function addCommand(skillPath: string, options: {
2
2
  collection?: string;
3
+ remotePath?: string;
4
+ name?: string;
5
+ description?: string;
3
6
  }): Promise<void>;
@@ -4,9 +4,14 @@ import chalk from "chalk";
4
4
  import ora from "ora";
5
5
  import YAML from "yaml";
6
6
  import { readConfig, trackSkill } from "../config.js";
7
- import { GithubBackend } from "../backends/github.js";
7
+ import { detectRepoContext } from "../utils/git.js";
8
8
  import { resolveBackend } from "../backends/resolve.js";
9
9
  export async function addCommand(skillPath, options) {
10
+ // ── Remote-path mode: register a skill from a foreign repo without local files ─
11
+ if (options.remotePath) {
12
+ await addRemotePath(options);
13
+ return;
14
+ }
10
15
  const absPath = path.resolve(skillPath);
11
16
  if (!fs.existsSync(absPath) || !fs.statSync(absPath).isDirectory()) {
12
17
  console.log(chalk.red(`"${skillPath}" is not a valid directory.`));
@@ -34,7 +39,7 @@ export async function addCommand(skillPath, options) {
34
39
  // If no --collection specified and a matching GitHub collection exists in config,
35
40
  // use it automatically (no prompt — agent-friendly).
36
41
  if (!options.collection) {
37
- const ctx = GithubBackend.detectRepoContext(absPath);
42
+ const ctx = detectRepoContext(absPath);
38
43
  if (ctx) {
39
44
  let config;
40
45
  try {
@@ -75,21 +80,136 @@ export async function addCommand(skillPath, options) {
75
80
  console.log(chalk.red("No collections configured. Run: skillsmanager collection create"));
76
81
  return;
77
82
  }
83
+ // If the collection's skill type differs from its hosting backend, handle specially.
84
+ // col.type declares who handles skill files; col.metadata.repo gives the target repo.
85
+ {
86
+ const hostBackend = await resolveBackend(collection.backend);
87
+ const col = await hostBackend.readCollection(collection);
88
+ const skillType = col.type ?? collection.backend;
89
+ const foreignRepo = col.metadata?.repo;
90
+ if (skillType !== collection.backend && skillType === "github" && foreignRepo) {
91
+ const ctx = detectRepoContext(absPath);
92
+ if (!ctx || ctx.repo !== foreignRepo) {
93
+ console.log(chalk.red(`This collection's skills source is "${foreignRepo}" (type: ${skillType}). ` +
94
+ `The provided path does not belong to that repo.\n` +
95
+ chalk.dim(` To register a skill by path without a local clone, use:\n`) +
96
+ chalk.dim(` skillsmanager add --collection ${collection.name} --remote-path <rel/path> --name <name> --description <desc>`)));
97
+ return;
98
+ }
99
+ // Path is from the foreign repo (cloned locally) — register relative path only, no upload
100
+ const spinner = ora(`Adding ${chalk.bold(skillName)} to ${collection.name}...`).start();
101
+ try {
102
+ const existing = col.skills.findIndex((s) => s.name === skillName);
103
+ const entry = { name: skillName, path: ctx.relPath, description };
104
+ if (existing >= 0) {
105
+ col.skills[existing] = entry;
106
+ }
107
+ else {
108
+ col.skills.push(entry);
109
+ }
110
+ await hostBackend.writeCollection(collection, col);
111
+ trackSkill(skillName, collection.id, absPath);
112
+ spinner.succeed(`${chalk.bold(skillName)} registered in ${collection.name} at ${chalk.dim(ctx.relPath)}`);
113
+ }
114
+ catch (err) {
115
+ spinner.fail(`Failed: ${err.message}`);
116
+ }
117
+ return;
118
+ }
119
+ }
78
120
  const backend = await resolveBackend(collection.backend);
79
121
  await uploadToCollection(backend, collection, absPath, skillName, description);
80
122
  }
123
+ // ── Remote-path mode: register a skill entry without local files ─────────────
124
+ async function addRemotePath(options) {
125
+ const { remotePath, name: skillName, description = "", collection: collectionName } = options;
126
+ if (!remotePath) {
127
+ console.log(chalk.red("--remote-path requires a relative path (e.g. tools/my-skill/)"));
128
+ return;
129
+ }
130
+ if (!skillName) {
131
+ console.log(chalk.red("--remote-path requires --name <skill-name>"));
132
+ return;
133
+ }
134
+ let config;
135
+ try {
136
+ config = readConfig();
137
+ }
138
+ catch {
139
+ console.log(chalk.red("No config found. Run: skillsmanager collection create"));
140
+ return;
141
+ }
142
+ let collection = config.collections[0];
143
+ if (collectionName) {
144
+ const found = config.collections.find((c) => c.name === collectionName);
145
+ if (!found) {
146
+ console.log(chalk.red(`Collection "${collectionName}" not found.`));
147
+ console.log(chalk.dim(` Available: ${config.collections.map((c) => c.name).join(", ")}`));
148
+ return;
149
+ }
150
+ collection = found;
151
+ }
152
+ if (!collection) {
153
+ console.log(chalk.red("No collections configured. Run: skillsmanager collection create"));
154
+ return;
155
+ }
156
+ const hostBackend = await resolveBackend(collection.backend);
157
+ const colForType = await hostBackend.readCollection(collection);
158
+ const skillType = colForType.type ?? collection.backend;
159
+ if (skillType !== "github" && collection.backend !== "github") {
160
+ console.log(chalk.red(`--remote-path is only supported for collections with type "github".`));
161
+ return;
162
+ }
163
+ const spinner = ora(`Registering ${chalk.bold(skillName)} in ${collection.name} at ${chalk.dim(remotePath)}...`).start();
164
+ try {
165
+ const col = colForType;
166
+ const existing = col.skills.findIndex((s) => s.name === skillName);
167
+ const entry = { name: skillName, path: remotePath, description };
168
+ if (existing >= 0) {
169
+ col.skills[existing] = entry;
170
+ }
171
+ else {
172
+ col.skills.push(entry);
173
+ }
174
+ await hostBackend.writeCollection(collection, col);
175
+ spinner.succeed(`${chalk.bold(skillName)} registered in ${collection.name} at ${chalk.dim(remotePath)}`);
176
+ }
177
+ catch (err) {
178
+ spinner.fail(`Failed: ${err.message}`);
179
+ }
180
+ }
81
181
  // ── GitHub path: register in-repo skill or copy external skill ────────────────
82
182
  async function addToGithub(absPath, ctx, skillName, description, collection) {
83
- const github = new GithubBackend();
183
+ const github = await resolveBackend("github");
84
184
  const spinner = ora(`Adding ${chalk.bold(skillName)} to github:${collection.folderId}...`).start();
85
185
  try {
86
- // uploadSkill is a no-op for in-repo skills; copies if external
87
- await github.uploadSkill(collection, absPath, skillName);
88
- // Determine effective skill path in the repo
89
- const skillEntry = absPath.startsWith(ctx.repoRoot)
90
- ? ctx.relPath // in-repo: use relative path
91
- : `.agentskills/${skillName}`; // external: was copied here by uploadSkill
92
186
  const col = await github.readCollection(collection);
187
+ const foreignRepo = col.metadata?.repo;
188
+ const hostRepo = collection.folderId.split(":")[0];
189
+ // If collection has metadata.repo pointing to a foreign repo, validate that
190
+ // the local skill belongs to that foreign repo (not the collection host repo).
191
+ if (foreignRepo && foreignRepo !== hostRepo) {
192
+ if (ctx.repo !== foreignRepo) {
193
+ spinner.fail(`This collection's skills source is "${foreignRepo}" but the provided path belongs to "${ctx.repo}".`);
194
+ return;
195
+ }
196
+ // Skill is in the foreign repo (cloned locally) — register path only, no upload
197
+ const entry = { name: skillName, path: ctx.relPath, description };
198
+ const existing = col.skills.findIndex((s) => s.name === skillName);
199
+ if (existing >= 0) {
200
+ col.skills[existing] = entry;
201
+ }
202
+ else {
203
+ col.skills.push(entry);
204
+ }
205
+ await github.writeCollection(collection, col);
206
+ trackSkill(skillName, collection.id, absPath);
207
+ spinner.succeed(`${chalk.bold(skillName)} registered in github:${collection.folderId} at ${chalk.dim(ctx.relPath)}`);
208
+ return;
209
+ }
210
+ // Standard case: skill is in (or being added to) the collection's host repo
211
+ // uploadSkill returns the canonical in-repo path
212
+ const skillEntry = await github.uploadSkill(collection, absPath, skillName);
93
213
  const existing = col.skills.findIndex((s) => s.name === skillName);
94
214
  if (existing >= 0) {
95
215
  col.skills[existing] = { name: skillName, path: skillEntry, description };
@@ -109,23 +229,7 @@ async function addToGithub(absPath, ctx, skillName, description, collection) {
109
229
  async function uploadToCollection(backend, collection, absPath, skillName, description) {
110
230
  const spinner = ora(`Adding ${chalk.bold(skillName)} to ${collection.name}...`).start();
111
231
  try {
112
- await backend.uploadSkill(collection, absPath, skillName);
113
- // For GitHub collections, determine the effective in-repo path
114
- let skillPath;
115
- if (collection.backend === "github") {
116
- // If the skill is already inside the repo workdir, use its relative path
117
- const ctx = GithubBackend.detectRepoContext(absPath);
118
- const repoFromCollection = collection.folderId.split(":")[0];
119
- if (ctx && ctx.repo === repoFromCollection) {
120
- skillPath = ctx.relPath; // e.g. "src/my-inrepo-skill"
121
- }
122
- else {
123
- skillPath = `.agentskills/${skillName}`; // external → copied here by uploadSkill
124
- }
125
- }
126
- else {
127
- skillPath = `${skillName}/`;
128
- }
232
+ const skillPath = await backend.uploadSkill(collection, absPath, skillName);
129
233
  const col = await backend.readCollection(collection);
130
234
  const existing = col.skills.findIndex((s) => s.name === skillName);
131
235
  if (existing >= 0) {
@@ -1,4 +1,5 @@
1
1
  export declare function collectionCreateCommand(name?: string, options?: {
2
2
  backend?: string;
3
3
  repo?: string;
4
+ skillsRepo?: string;
4
5
  }): Promise<void>;
@@ -2,51 +2,28 @@ import chalk from "chalk";
2
2
  import ora from "ora";
3
3
  import fs from "fs";
4
4
  import { writeConfig, CONFIG_PATH, readConfig } from "../config.js";
5
- import { ensureAuth } from "../auth.js";
6
- import { GDriveBackend } from "../backends/gdrive.js";
7
- import { GithubBackend } from "../backends/github.js";
5
+ import { resolveBackend } from "../backends/resolve.js";
8
6
  export async function collectionCreateCommand(name, options = {}) {
9
7
  const backendName = options.backend ?? "gdrive";
10
- if (backendName === "github") {
11
- await createGithubCollection(name, options.repo);
12
- }
13
- else {
14
- await createGdriveCollection(name);
15
- }
16
- }
17
- async function createGithubCollection(name, repo) {
18
- if (!repo) {
8
+ if (backendName === "github" && !options.repo) {
19
9
  console.log(chalk.red("GitHub backend requires --repo <owner/repo>"));
20
10
  console.log(chalk.dim(" Example: skillsmanager collection create my-skills --backend github --repo owner/my-repo"));
21
11
  return;
22
12
  }
23
- const collectionName = name ?? "default";
24
- const backend = new GithubBackend();
25
- console.log(chalk.bold(`\nCreating GitHub collection "${collectionName}" in ${repo}...\n`));
13
+ const collectionName = name ?? (backendName === "gdrive" ? "MY_SKILLS" : "default");
14
+ const spinner = ora(`Creating collection "${collectionName}" in ${backendName}...`).start();
26
15
  try {
27
- const collection = await backend.createCollection(collectionName, repo);
28
- console.log(chalk.green(`\n ✓ Collection "${collectionName}" created in github:${collection.folderId}`));
29
- const config = loadOrDefaultConfig();
30
- upsertCollection(config, collection);
31
- writeConfig(config);
32
- console.log(`\nRun ${chalk.bold("skillsmanager add <path>")} to add skills to it.\n`);
33
- }
34
- catch (err) {
35
- console.log(chalk.red(`Failed: ${err.message}`));
36
- }
37
- }
38
- async function createGdriveCollection(name) {
39
- const auth = await ensureAuth();
40
- const backend = new GDriveBackend(auth);
41
- const PREFIX = "SKILLS_";
42
- const folderName = !name
43
- ? `${PREFIX}MY_SKILLS`
44
- : name.startsWith(PREFIX) ? name : `${PREFIX}${name}`;
45
- const spinner = ora(`Creating collection "${folderName}" in Google Drive...`).start();
46
- try {
47
- const collection = await backend.createCollection(folderName);
48
- spinner.succeed(`Collection "${folderName}" created in Google Drive`);
16
+ const backend = await resolveBackend(backendName);
17
+ const collection = await backend.createCollection({
18
+ name: collectionName,
19
+ repo: options.repo,
20
+ skillsRepo: options.skillsRepo,
21
+ });
22
+ spinner.succeed(`Collection "${collection.name}" created (${backendName}:${collection.folderId})`);
49
23
  const config = loadOrDefaultConfig();
24
+ const registry = await ensureRegistry(config);
25
+ await registerCollectionInRegistry(registry, collection, config);
26
+ collection.sourceRegistryId = registry.id;
50
27
  upsertCollection(config, collection);
51
28
  writeConfig(config);
52
29
  console.log(`\nRun ${chalk.bold("skillsmanager add <path>")} to add skills to it.\n`);
@@ -74,3 +51,32 @@ function upsertCollection(config, collection) {
74
51
  config.collections.push(collection);
75
52
  }
76
53
  }
54
+ /** Returns the first registry in config, auto-creating a local one if none exists. */
55
+ async function ensureRegistry(config) {
56
+ if (config.registries.length > 0)
57
+ return config.registries[0];
58
+ console.log(chalk.dim(" No registry found — creating a local registry..."));
59
+ const local = await resolveBackend("local");
60
+ const registry = await local.createRegistry();
61
+ config.registries.push(registry);
62
+ console.log(chalk.green(" ✓ Local registry created"));
63
+ return registry;
64
+ }
65
+ /** Registers the collection ref in the given registry (writes directly to the registry's backend). */
66
+ async function registerCollectionInRegistry(registry, collection, config) {
67
+ const backend = await resolveBackend(registry.backend);
68
+ const registryData = await backend.readRegistry(registry);
69
+ if (registryData.collections.find((c) => c.name === collection.name))
70
+ return;
71
+ registryData.collections.push({
72
+ name: collection.name,
73
+ backend: collection.backend,
74
+ ref: collection.folderId,
75
+ });
76
+ await backend.writeRegistry(registry, registryData);
77
+ // Keep local config registry list in sync
78
+ if (!config.registries.find((r) => r.id === registry.id)) {
79
+ config.registries.push(registry);
80
+ }
81
+ console.log(chalk.dim(` Registered in registry "${registry.name}" (${registry.backend})`));
82
+ }