skillshelf 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +83 -20
  2. package/package.json +8 -2
  3. package/src/adapters/inference/agent.ts +23 -16
  4. package/src/cli.ts +39 -0
  5. package/src/commands/add.ts +624 -128
  6. package/src/commands/adopted.test.ts +144 -0
  7. package/src/commands/agents-config.test.ts +126 -0
  8. package/src/commands/agents.test.ts +96 -0
  9. package/src/commands/agents.ts +243 -0
  10. package/src/commands/drop.ts +21 -13
  11. package/src/commands/import.ts +44 -28
  12. package/src/commands/infer.ts +6 -6
  13. package/src/commands/link.test.ts +160 -0
  14. package/src/commands/link.ts +317 -0
  15. package/src/commands/ls.ts +136 -19
  16. package/src/commands/migrate.test.ts +157 -0
  17. package/src/commands/migrate.ts +260 -0
  18. package/src/commands/mode-surfacing.test.ts +110 -0
  19. package/src/commands/outdated.test.ts +55 -0
  20. package/src/commands/outdated.ts +166 -18
  21. package/src/commands/projects.test.ts +85 -0
  22. package/src/commands/projects.ts +80 -0
  23. package/src/commands/refresh.ts +133 -0
  24. package/src/commands/remediation.test.ts +149 -0
  25. package/src/commands/rename.test.ts +121 -0
  26. package/src/commands/rename.ts +64 -0
  27. package/src/commands/retag.ts +58 -0
  28. package/src/commands/retire.ts +39 -0
  29. package/src/commands/rm.test.ts +133 -0
  30. package/src/commands/rm.ts +107 -0
  31. package/src/commands/roots.ts +41 -0
  32. package/src/commands/scan.ts +122 -30
  33. package/src/commands/show.ts +130 -11
  34. package/src/commands/status.ts +43 -8
  35. package/src/commands/tag.test.ts +109 -0
  36. package/src/commands/tag.ts +68 -0
  37. package/src/commands/track.test.ts +170 -0
  38. package/src/commands/track.ts +340 -0
  39. package/src/commands/unretire.ts +33 -0
  40. package/src/commands/untag.ts +73 -0
  41. package/src/commands/untrack.ts +44 -0
  42. package/src/commands/update.test.ts +71 -0
  43. package/src/commands/update.ts +157 -15
  44. package/src/commands/use.test.ts +122 -0
  45. package/src/commands/use.ts +46 -23
  46. package/src/commands/where.ts +232 -0
  47. package/src/config.test.ts +198 -0
  48. package/src/config.ts +232 -10
  49. package/src/core/agents.test.ts +319 -0
  50. package/src/core/agents.ts +438 -0
  51. package/src/core/bundle.ts +12 -15
  52. package/src/core/core.test.ts +21 -8
  53. package/src/core/crawl.ts +22 -5
  54. package/src/core/dedupe.ts +36 -0
  55. package/src/core/deployments.test.ts +147 -0
  56. package/src/core/deployments.ts +208 -0
  57. package/src/core/fetch.ts +371 -75
  58. package/src/core/indexgen.ts +2 -0
  59. package/src/core/library.test.ts +41 -0
  60. package/src/core/library.ts +61 -16
  61. package/src/core/lifecycle.ts +252 -0
  62. package/src/core/surfaces.ts +46 -0
  63. package/src/core/taxonomy.test.ts +159 -0
  64. package/src/core/taxonomy.ts +190 -0
  65. package/src/lib/fs.ts +2 -2
  66. package/src/types.ts +155 -15
  67. package/src/core/overlay.ts +0 -63
@@ -1,16 +1,49 @@
1
1
  // `skl ls [bundle]` — one-line listing of the whole library, or a single
2
2
  // bundle (tag query). Excludes retired by default; `--all` includes them.
3
3
 
4
- import type { Ctx, Skill } from "../types.ts";
5
- import { activeSkills } from "../core/library.ts";
4
+ import { statSync } from "node:fs";
5
+ import type { Ctx, Skill, Provenance } from "../types.ts";
6
+ import { activeSkills, entryModeInfo } from "../core/library.ts";
6
7
  import { resolveBundle } from "../core/bundle.ts";
8
+ import { inventoryDeployments } from "../core/deployments.ts";
9
+ import { knownAgentSurfacePaths } from "../core/surfaces.ts";
10
+ import { isCleanSite } from "../core/agents.ts";
7
11
 
8
12
  export const meta = {
9
13
  name: "ls",
10
14
  summary: "One-line listing of the library, or one bundle",
11
- usage: "skl ls [bundle] [--all] [--json]",
15
+ usage: "skl ls [bundle] [--all] [--sort modified|name|domain|deploys|source] [--json]",
12
16
  } as const;
13
17
 
18
+ const SORT_FIELDS = ["modified", "name", "domain", "deploys", "source"] as const;
19
+ type SortField = (typeof SORT_FIELDS)[number];
20
+
21
+ /** Sort a copy of skills by a report field (modified/deploys descending). */
22
+ function sortSkills(skills: Skill[], field: SortField, deployCounts: Map<string, number>): Skill[] {
23
+ const primary = (s: Skill) => s.primaryDomain ?? s.domains[0] ?? "_unclassified";
24
+ const mtimes =
25
+ field === "modified" ? new Map(skills.map((s) => [s.name, fileTimes(s.bodyPath).modifiedAt])) : null;
26
+ return skills.slice().sort((a, b) => {
27
+ if (field === "name") return a.name.localeCompare(b.name);
28
+ if (field === "domain") return primary(a).localeCompare(primary(b)) || a.name.localeCompare(b.name);
29
+ if (field === "source") {
30
+ const sa = a.source ? "vendored" : "local";
31
+ const sb = b.source ? "vendored" : "local";
32
+ return sa.localeCompare(sb) || a.name.localeCompare(b.name);
33
+ }
34
+ if (field === "deploys") {
35
+ return (deployCounts.get(b.name) ?? 0) - (deployCounts.get(a.name) ?? 0) || a.name.localeCompare(b.name);
36
+ }
37
+ // modified — most-recent first; null/untracked last.
38
+ const am = mtimes!.get(a.name) ?? null;
39
+ const bm = mtimes!.get(b.name) ?? null;
40
+ if (!am && !bm) return a.name.localeCompare(b.name);
41
+ if (!am) return 1;
42
+ if (!bm) return -1;
43
+ return bm.localeCompare(am);
44
+ });
45
+ }
46
+
14
47
  function oneLine(desc: string, max = 100): string {
15
48
  const flat = desc.replace(/\s+/g, " ").trim();
16
49
  return flat.length <= max ? flat : flat.slice(0, max - 1).trimEnd() + "…";
@@ -29,46 +62,130 @@ function emitHuman(ctx: Ctx, skills: Skill[]): void {
29
62
  }
30
63
  }
31
64
 
32
- function toJson(skills: Skill[]): unknown {
33
- return skills.map((s) => ({
34
- name: s.name,
35
- description: s.description,
36
- primaryDomain: s.primaryDomain,
37
- domains: s.domains,
38
- path: s.path,
39
- retired: s.retired,
40
- }));
65
+ /** Stat-derived timestamps for a skill's SKILL.md (ISO-8601), null if unavailable. */
66
+ function fileTimes(bodyPath: string): { modifiedAt: string | null; createdAt: string | null } {
67
+ try {
68
+ const st = statSync(bodyPath);
69
+ const created = st.birthtimeMs > 0 ? st.birthtime : null;
70
+ return {
71
+ modifiedAt: st.mtime.toISOString(),
72
+ createdAt: created ? created.toISOString() : null,
73
+ };
74
+ } catch {
75
+ return { modifiedAt: null, createdAt: null };
76
+ }
77
+ }
78
+
79
+ function toJson(
80
+ skills: Skill[],
81
+ libraryPath: string,
82
+ deployCounts: Map<string, number>,
83
+ ): unknown {
84
+ return skills.map((s) => {
85
+ const { mode, linkTarget } = entryModeInfo(libraryPath, s.name);
86
+ const { modifiedAt, createdAt } = fileTimes(s.bodyPath);
87
+ return {
88
+ name: s.name,
89
+ description: s.description,
90
+ primaryDomain: s.primaryDomain,
91
+ domains: s.domains,
92
+ path: s.path,
93
+ retired: s.retired,
94
+ mode,
95
+ linkTarget,
96
+ // ADR-0008 §7.1 additions: a string source (UI maps "vendored"/"local"),
97
+ // a human origin label (the real upstream, NOT a hard-coded channel),
98
+ // stat timestamps, and the count of clean deployment sites.
99
+ source: s.source ? "vendored" : "local",
100
+ origin: originLabel(s.source),
101
+ channel: s.source ? s.source.channel : null,
102
+ modifiedAt,
103
+ createdAt,
104
+ deployCount: deployCounts.get(s.name) ?? 0,
105
+ };
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Short, human display of a vendored skill's real upstream origin, e.g.
111
+ * "jimliu/baoyu-skills" from "github:jimliu/baoyu-skills@skills/baoyu-translate".
112
+ * Null for hand-written (local) skills. Replaces the UI's old hard-coded
113
+ * "dbskill" label so the table tells the truth about where a skill came from.
114
+ */
115
+ function originLabel(prov: Provenance | null): string | null {
116
+ if (!prov) return null;
117
+ const stripped = prov.source.replace(/@.*$/, ""); // drop @subpath
118
+ const colon = stripped.indexOf(":");
119
+ const repo = colon >= 0 ? stripped.slice(colon + 1) : stripped; // drop channel:
120
+ return repo || prov.channel || "vendored";
121
+ }
122
+
123
+ /** Count clean (`linked`) deployment sites per skill across all surfaces. */
124
+ async function deployCountsFor(ctx: Ctx, lib: Skill[]): Promise<Map<string, number>> {
125
+ const counts = new Map<string, number>();
126
+ try {
127
+ const surfaces = [...ctx.roots, ctx.config.globalCoreTarget, ...knownAgentSurfacePaths()];
128
+ const report = await inventoryDeployments(surfaces, ctx.libraryPath, lib);
129
+ for (const site of report.sites) {
130
+ // "clean" = the ✓ states in `skl where` (linked OR canonical source), shared
131
+ // with the agents matrix via isCleanSite so the Deploys column agrees with it.
132
+ if (isCleanSite(site)) counts.set(site.name, (counts.get(site.name) ?? 0) + 1);
133
+ }
134
+ } catch {
135
+ // deployment scan is best-effort enrichment; ls still returns the library.
136
+ }
137
+ return counts;
41
138
  }
42
139
 
43
140
  export async function run(argv: string[], ctx: Ctx): Promise<number> {
44
141
  try {
45
142
  const json = argv.includes("--json");
46
143
  const all = argv.includes("--all");
47
- const positional = argv.filter((a) => !a.startsWith("--"));
144
+ // Extract `--sort <field>` and its value so the value isn't taken as a bundle.
145
+ const sortIdx = argv.indexOf("--sort");
146
+ const sortField = sortIdx >= 0 ? argv[sortIdx + 1] : undefined;
147
+ if (sortField !== undefined && !SORT_FIELDS.includes(sortField as SortField)) {
148
+ ctx.error(`ls: --sort expects one of: ${SORT_FIELDS.join(", ")}`);
149
+ return 1;
150
+ }
151
+ const consumed = new Set<number>();
152
+ if (sortIdx >= 0) {
153
+ consumed.add(sortIdx);
154
+ consumed.add(sortIdx + 1);
155
+ }
156
+ const positional = argv.filter((a, i) => !a.startsWith("--") && !consumed.has(i));
48
157
  const bundleName = positional[0];
49
158
 
50
159
  const skills = await ctx.loadLibrary();
160
+ // deployCounts needed for --json, or to sort by deploys.
161
+ const deployCounts =
162
+ json || sortField === "deploys"
163
+ ? await deployCountsFor(ctx, skills)
164
+ : new Map<string, number>();
165
+ const applySort = (list: Skill[]) =>
166
+ sortField ? sortSkills(list, sortField as SortField, deployCounts) : list;
51
167
 
52
168
  if (bundleName) {
53
169
  const bundle = await resolveBundle(skills, bundleName, {
54
170
  includeRetired: all,
55
171
  });
172
+ const rows = applySort(bundle.skills);
56
173
  if (json) {
57
- ctx.json({ bundle: bundle.name, skills: toJson(bundle.skills) });
174
+ ctx.json({ bundle: bundle.name, skills: toJson(rows, ctx.libraryPath, deployCounts) });
58
175
  return 0;
59
176
  }
60
- if (bundle.skills.length === 0) {
177
+ if (rows.length === 0) {
61
178
  ctx.log(`Bundle "${bundle.name}" has no skills.`);
62
179
  return 0;
63
180
  }
64
- ctx.log(`# ${bundle.name} (${bundle.skills.length})`);
65
- emitHuman(ctx, bundle.skills);
181
+ ctx.log(`# ${bundle.name} (${rows.length})`);
182
+ emitHuman(ctx, rows);
66
183
  return 0;
67
184
  }
68
185
 
69
- const listed = all ? skills : activeSkills(skills);
186
+ const listed = applySort(all ? skills : activeSkills(skills));
70
187
  if (json) {
71
- ctx.json(toJson(listed));
188
+ ctx.json(toJson(listed, ctx.libraryPath, deployCounts));
72
189
  return 0;
73
190
  }
74
191
  emitHuman(ctx, listed);
@@ -0,0 +1,157 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, writeFile, rm, realpath } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { run as migrateRun } from "./migrate.ts";
6
+ import { readLockfile } from "../core/provenance.ts";
7
+ import type { Ctx } from "../types.ts";
8
+
9
+ function makeCtx(libraryPath: string) {
10
+ const logs: string[] = [];
11
+ const errors: string[] = [];
12
+ const json: unknown[] = [];
13
+ const ctx = {
14
+ config: { libraryPath },
15
+ libraryPath,
16
+ log: (...a: unknown[]) => logs.push(a.join(" ")),
17
+ error: (...a: unknown[]) => errors.push(a.join(" ")),
18
+ json: (v: unknown) => json.push(v),
19
+ } as unknown as Ctx;
20
+ return { ctx, logs, errors, json };
21
+ }
22
+
23
+ async function addLibSkill(library: string, name: string, body = "body") {
24
+ await mkdir(join(library, name), { recursive: true });
25
+ await writeFile(join(library, name, "SKILL.md"), `---\nname: ${name}\ndescription: d\n---\n\n${body}\n`);
26
+ }
27
+
28
+ // A fake vendor (.agents/.skill-lock.json) fixture covering every mapping branch.
29
+ function vendorLock() {
30
+ return {
31
+ version: 3,
32
+ skills: {
33
+ // github with a subpath dir → github:owner/repo@dir
34
+ ghskill: {
35
+ source: "owner/repo",
36
+ sourceType: "github",
37
+ sourceUrl: "https://github.com/owner/repo",
38
+ skillPath: "skills/ghskill/SKILL.md",
39
+ skillFolderHash: "treesha-not-skl-hash",
40
+ installedAt: "2024-01-01T00:00:00.000Z",
41
+ ref: "main",
42
+ },
43
+ // github, NOT in the library → report only
44
+ ghmissing: {
45
+ source: "owner/other",
46
+ sourceType: "github",
47
+ sourceUrl: "https://github.com/owner/other",
48
+ skillPath: "SKILL.md",
49
+ skillFolderHash: "abc",
50
+ installedAt: "2024-01-01T00:00:00.000Z",
51
+ },
52
+ // git source → git:<url>#dir
53
+ gitskill: {
54
+ source: "ignored",
55
+ sourceType: "git",
56
+ sourceUrl: "/some/local/repo",
57
+ skillPath: "sub/gitskill/SKILL.md",
58
+ skillFolderHash: "def",
59
+ installedAt: "2024-01-01T00:00:00.000Z",
60
+ },
61
+ // local → not trackable
62
+ localskill: {
63
+ source: "localskill",
64
+ sourceType: "local",
65
+ sourceUrl: "/here",
66
+ skillPath: "SKILL.md",
67
+ skillFolderHash: "ghi",
68
+ installedAt: "2024-01-01T00:00:00.000Z",
69
+ },
70
+ },
71
+ };
72
+ }
73
+
74
+ describe("skl migrate — bulk-adopt from a vendor lock", () => {
75
+ let tmp: string;
76
+ let library: string;
77
+ let vendorPath: string;
78
+
79
+ beforeEach(async () => {
80
+ tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-migrate-")));
81
+ library = join(tmp, "library");
82
+ await mkdir(library, { recursive: true });
83
+ // ghskill, gitskill, localskill exist in the library; ghmissing does NOT.
84
+ await addLibSkill(library, "ghskill");
85
+ await addLibSkill(library, "gitskill");
86
+ await addLibSkill(library, "localskill");
87
+ vendorPath = join(tmp, ".skill-lock.json");
88
+ await writeFile(vendorPath, JSON.stringify(vendorLock(), null, 2));
89
+ });
90
+ afterEach(async () => {
91
+ await rm(tmp, { recursive: true, force: true });
92
+ });
93
+
94
+ test("tracks in-library github/git skills, reports missing, flags local as not trackable", async () => {
95
+ const { ctx, json } = makeCtx(library);
96
+ const code = await migrateRun(["--from", vendorPath, "--json"], ctx);
97
+ expect(code).toBe(0);
98
+
99
+ const r = json[0] as {
100
+ counts: { tracked: number; skipped: number; notInLibrary: number; notTrackable: number };
101
+ tracked: Array<{ name: string; source: string }>;
102
+ notInLibrary: Array<{ name: string; source: string }>;
103
+ notTrackable: Array<{ name: string; sourceType: string }>;
104
+ };
105
+ expect(r.counts.tracked).toBe(2);
106
+ expect(r.counts.notInLibrary).toBe(1);
107
+ expect(r.counts.notTrackable).toBe(1);
108
+
109
+ const lock = await readLockfile(library);
110
+ // github subpath mapped to the @-convention.
111
+ expect(lock.entries.ghskill!.source).toBe("github:owner/repo@skills/ghskill");
112
+ expect(lock.entries.ghskill!.adopted).toBe(true);
113
+ // vendor tree-sha is NOT reused as installedHash; vendor branch is NOT the ref.
114
+ expect(lock.entries.ghskill!.installedHash).not.toBe("treesha-not-skl-hash");
115
+ expect(lock.entries.ghskill!.ref).toBe("");
116
+ // git source mapped to git:<url>#dir.
117
+ expect(lock.entries.gitskill!.source).toBe("git:/some/local/repo#sub/gitskill");
118
+
119
+ // missing skill is reported with an `skl add` line, never installed.
120
+ expect(r.notInLibrary[0]!.name).toBe("ghmissing");
121
+ expect(lock.entries.ghmissing).toBeUndefined();
122
+
123
+ // local skill is not trackable (no lock entry).
124
+ expect(r.notTrackable[0]!.name).toBe("localskill");
125
+ expect(lock.entries.localskill).toBeUndefined();
126
+ });
127
+
128
+ test("--dry-run previews without writing", async () => {
129
+ const { ctx, json } = makeCtx(library);
130
+ const code = await migrateRun(["--from", vendorPath, "--dry-run", "--json"], ctx);
131
+ expect(code).toBe(0);
132
+ expect((json[0] as { counts: { tracked: number } }).counts.tracked).toBe(2);
133
+ // No entries written.
134
+ expect(Object.keys((await readLockfile(library)).entries)).toHaveLength(0);
135
+ });
136
+
137
+ test("skips already-tracked skills unless --force", async () => {
138
+ // First pass tracks ghskill + gitskill.
139
+ await migrateRun(["--from", vendorPath], makeCtx(library).ctx);
140
+ // Second pass should skip both as already-tracked.
141
+ const { ctx, json } = makeCtx(library);
142
+ const code = await migrateRun(["--from", vendorPath, "--json"], ctx);
143
+ expect(code).toBe(0);
144
+ const r = json[0] as { counts: { tracked: number; skipped: number } };
145
+ expect(r.counts.tracked).toBe(0);
146
+ expect(r.counts.skipped).toBe(2);
147
+ });
148
+
149
+ test("rejects a non-vendor lock file", async () => {
150
+ const notVendor = join(tmp, "not-vendor.json");
151
+ await writeFile(notVendor, JSON.stringify({ version: 1, entries: {} }));
152
+ const { ctx, errors } = makeCtx(library);
153
+ const code = await migrateRun(["--from", notVendor], ctx);
154
+ expect(code).toBe(1);
155
+ expect(errors.join("\n")).toContain("not a recognized vendor");
156
+ });
157
+ });
@@ -0,0 +1,260 @@
1
+ // `skl migrate [--from <path>]` — bulk-adopt provenance from a VENDOR skill lock
2
+ // (e.g. ~/.agents/.skill-lock.json) for skills ALREADY in your library (ADR-0011).
3
+ //
4
+ // skl migrate [--from <path>] [--dry-run] [--resolve] [--force] [--json]
5
+ //
6
+ // A thin adapter over `track`: it reads a foreign (vendor) lockfile, maps each vendor
7
+ // entry to an `skl` source, and — for skills already in the library — calls the same
8
+ // `trackOne` logic `skl track` uses. It NEVER installs/downloads: a skill not in the
9
+ // library is REPORTED ONLY (with the `skl add <src>` line to bring it in). The vendor's
10
+ // own hashes/refs are NOT reused (a vendor tree-SHA is not skl's body sha256, and a
11
+ // vendor branch is not a commit) — so every adopted entry is `adopted: true` unless
12
+ // --resolve verifies it.
13
+ //
14
+ // Buckets: ✓ tracked · ⊘ skipped (already tracked) · ⚠ not in library · (not trackable).
15
+
16
+ import { homedir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { existsSync } from "node:fs";
19
+ import type { Ctx } from "../types.ts";
20
+ import { loadLibrary, findByName } from "../core/library.ts";
21
+ import { readLockfile } from "../core/provenance.ts";
22
+ import { trackOne } from "./track.ts";
23
+
24
+ export const meta = {
25
+ name: "migrate",
26
+ summary: "Bulk-adopt provenance from a vendor skill-lock for skills already in your library",
27
+ usage: "skl migrate [--from <path>] [--dry-run] [--resolve] [--force] [--json]",
28
+ } as const;
29
+
30
+ /** The default vendor lock location (the `agents`/`skills` CLI convention). */
31
+ function defaultVendorLock(): string {
32
+ return join(homedir(), ".agents", ".skill-lock.json");
33
+ }
34
+
35
+ /** One entry in the vendor lock (the shape we adapt from). */
36
+ interface VendorEntry {
37
+ source?: string;
38
+ sourceType?: "github" | "git" | "local" | "well-known" | string;
39
+ sourceUrl?: string;
40
+ skillPath?: string;
41
+ skillFolderHash?: string;
42
+ installedAt?: string;
43
+ ref?: string;
44
+ }
45
+
46
+ interface VendorLock {
47
+ version?: number;
48
+ skills?: Record<string, VendorEntry>;
49
+ }
50
+
51
+ interface Flags {
52
+ from: string | null;
53
+ dryRun: boolean;
54
+ resolve: boolean;
55
+ force: boolean;
56
+ json: boolean;
57
+ }
58
+
59
+ function parseFlags(argv: string[]): { flags: Flags } | { error: string } {
60
+ const flags: Flags = { from: null, dryRun: false, resolve: false, force: false, json: false };
61
+ for (let i = 0; i < argv.length; i++) {
62
+ const a = argv[i]!;
63
+ if (a === "--from") {
64
+ const v = argv[++i];
65
+ if (v === undefined) return { error: "--from requires a <path>" };
66
+ flags.from = v;
67
+ } else if (a.startsWith("--from=")) {
68
+ flags.from = a.slice("--from=".length);
69
+ } else if (a === "--dry-run") {
70
+ flags.dryRun = true;
71
+ } else if (a === "--resolve") {
72
+ flags.resolve = true;
73
+ } else if (a === "--force") {
74
+ flags.force = true;
75
+ } else if (a === "--json") {
76
+ flags.json = true;
77
+ } else if (a.startsWith("--")) {
78
+ return { error: `unknown argument: ${a}` };
79
+ } else {
80
+ return { error: `unexpected argument: ${a}` };
81
+ }
82
+ }
83
+ return { flags };
84
+ }
85
+
86
+ /** True if the parsed JSON looks like the vendor lock format (signature detection). */
87
+ function isVendorLock(parsed: unknown): parsed is VendorLock {
88
+ if (!parsed || typeof parsed !== "object") return false;
89
+ const p = parsed as Record<string, unknown>;
90
+ if (p.version !== 3) return false;
91
+ if (!p.skills || typeof p.skills !== "object") return false;
92
+ // Carry the vendor signature: at least one entry with a skillFolderHash.
93
+ const skills = p.skills as Record<string, unknown>;
94
+ return Object.values(skills).some(
95
+ (e) => e && typeof e === "object" && "skillFolderHash" in (e as object),
96
+ );
97
+ }
98
+
99
+ /** POSIX dirname of a skillPath ("dir/SKILL.md" -> "dir"; "SKILL.md" -> ""). */
100
+ function skillDir(skillPath: string | undefined): string {
101
+ if (!skillPath) return "";
102
+ const norm = skillPath.replace(/\\/g, "/").replace(/\/+$/, "");
103
+ const i = norm.lastIndexOf("/");
104
+ return i >= 0 ? norm.slice(0, i) : "";
105
+ }
106
+
107
+ /**
108
+ * Map a vendor entry to an `skl` source string, or null when it's not trackable.
109
+ * github → `github:owner/repo` (+ `@<dir>` if skillPath has a dir)
110
+ * git → `git:<sourceUrl>` (+ `#<dir>`)
111
+ * local/well-known → null (no upstream to track)
112
+ * NOTE: the vendor `ref` (a branch) and `skillFolderHash` (a tree SHA) are deliberately
113
+ * NOT propagated — they are not skl's commit ref / body sha256.
114
+ */
115
+ function mapVendorSource(entry: VendorEntry): string | null {
116
+ const dir = skillDir(entry.skillPath);
117
+ const type = entry.sourceType;
118
+ if (type === "github") {
119
+ if (!entry.source) return null;
120
+ return `github:${entry.source}${dir ? `@${dir}` : ""}`;
121
+ }
122
+ if (type === "git") {
123
+ if (!entry.sourceUrl) return null;
124
+ return `git:${entry.sourceUrl}${dir ? `#${dir}` : ""}`;
125
+ }
126
+ // local / well-known → not trackable (no upstream baseline).
127
+ return null;
128
+ }
129
+
130
+ interface TrackedRow { name: string; source: string; ref: string; adopted: boolean; note: string }
131
+ interface SkippedRow { name: string; reason: string }
132
+ interface MissingRow { name: string; source: string }
133
+ interface NotTrackableRow { name: string; sourceType: string }
134
+
135
+ export async function run(argv: string[], ctx: Ctx): Promise<number> {
136
+ const parsed = parseFlags(argv);
137
+ if ("error" in parsed) {
138
+ ctx.error(`skl migrate: ${parsed.error}`);
139
+ ctx.error(`usage: ${meta.usage}`);
140
+ return 1;
141
+ }
142
+ const flags = parsed.flags;
143
+ const fromPath = flags.from && flags.from.trim() !== "" ? flags.from.trim() : defaultVendorLock();
144
+ const libraryPath = ctx.config.libraryPath;
145
+
146
+ try {
147
+ if (!existsSync(fromPath)) {
148
+ ctx.error(`skl migrate: vendor lock not found: ${fromPath}`);
149
+ ctx.error("Point at one with --from <path>, or install skills via the vendor CLI first.");
150
+ return 1;
151
+ }
152
+
153
+ let vendorParsed: unknown;
154
+ try {
155
+ vendorParsed = JSON.parse(await Bun.file(fromPath).text());
156
+ } catch (err) {
157
+ ctx.error(`skl migrate: could not parse ${fromPath}: ${err instanceof Error ? err.message : String(err)}`);
158
+ return 1;
159
+ }
160
+ if (!isVendorLock(vendorParsed)) {
161
+ ctx.error(`skl migrate: ${fromPath} is not a recognized vendor skill-lock (expected {version:3, skills:{…}}).`);
162
+ return 1;
163
+ }
164
+ const vendorSkills = vendorParsed.skills ?? {};
165
+
166
+ const library = await loadLibrary(libraryPath);
167
+ const lock = await readLockfile(libraryPath);
168
+
169
+ const tracked: TrackedRow[] = [];
170
+ const skipped: SkippedRow[] = [];
171
+ const missing: MissingRow[] = [];
172
+ const notTrackable: NotTrackableRow[] = [];
173
+
174
+ // Stable iteration order by name.
175
+ const names = Object.keys(vendorSkills).sort();
176
+ for (const name of names) {
177
+ const entry = vendorSkills[name]!;
178
+ const source = mapVendorSource(entry);
179
+ if (source === null) {
180
+ notTrackable.push({ name, sourceType: entry.sourceType ?? "unknown" });
181
+ continue;
182
+ }
183
+
184
+ const inLibrary = Boolean(findByName(library, name));
185
+ if (!inLibrary) {
186
+ // REPORT ONLY — migrate never installs/downloads.
187
+ missing.push({ name, source });
188
+ continue;
189
+ }
190
+
191
+ // Already tracked → skip unless --force.
192
+ if (lock.entries[name] && !flags.force) {
193
+ skipped.push({ name, reason: "already tracked" });
194
+ continue;
195
+ }
196
+
197
+ if (flags.dryRun) {
198
+ tracked.push({ name, source, ref: "", adopted: true, note: "would track (dry-run)" });
199
+ continue;
200
+ }
201
+
202
+ const res = await trackOne(libraryPath, library, {
203
+ name,
204
+ source,
205
+ resolve: flags.resolve,
206
+ force: flags.force,
207
+ });
208
+ if (res.ok) {
209
+ tracked.push({ name, source: res.source, ref: res.ref, adopted: res.adopted, note: res.note });
210
+ } else {
211
+ // A guard tripped at track time (e.g. LINKED, source didn't round-trip) — surface it.
212
+ skipped.push({ name, reason: res.reason });
213
+ }
214
+ }
215
+
216
+ if (flags.json) {
217
+ ctx.json({
218
+ ok: true,
219
+ action: "migrate",
220
+ from: fromPath,
221
+ dryRun: flags.dryRun,
222
+ counts: {
223
+ tracked: tracked.length,
224
+ skipped: skipped.length,
225
+ notInLibrary: missing.length,
226
+ notTrackable: notTrackable.length,
227
+ },
228
+ tracked,
229
+ skipped,
230
+ notInLibrary: missing,
231
+ notTrackable,
232
+ });
233
+ return 0;
234
+ }
235
+
236
+ ctx.log(`migrate from ${fromPath}${flags.dryRun ? " (dry-run)" : ""}:`);
237
+ ctx.log("");
238
+ for (const r of tracked) {
239
+ ctx.log(` ✓ tracked ${r.name.padEnd(28)} ${r.source}${r.adopted ? " (adopted)" : ""}${r.note ? ` — ${r.note}` : ""}`);
240
+ }
241
+ for (const r of skipped) {
242
+ ctx.log(` ⊘ skipped ${r.name.padEnd(28)} ${r.reason}`);
243
+ }
244
+ for (const r of missing) {
245
+ ctx.log(` ⚠ not in library ${r.name.padEnd(25)} bring it in: skl add ${r.source}`);
246
+ }
247
+ for (const r of notTrackable) {
248
+ ctx.log(` · not trackable ${r.name.padEnd(27)} sourceType=${r.sourceType} (no upstream)`);
249
+ }
250
+ ctx.log("");
251
+ ctx.log(
252
+ `✓ tracked ${tracked.length} · ⊘ skipped ${skipped.length} (already tracked) · ⚠ not in library ${missing.length} · not trackable ${notTrackable.length}`,
253
+ );
254
+ if (missing.length > 0) ctx.log("Skills not in your library were only reported — `skl add <src>` to install them, then re-run migrate.");
255
+ return 0;
256
+ } catch (err) {
257
+ ctx.error(`skl migrate: failed: ${err instanceof Error ? err.message : String(err)}`);
258
+ return 1;
259
+ }
260
+ }