skillshelf 0.3.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.
@@ -6,7 +6,7 @@ import { join } from "node:path";
6
6
  import { run } from "./link.ts";
7
7
  import type { Ctx } from "../types.ts";
8
8
 
9
- const BODY = "---\nname: cairn\ndescription: a test skill\n---\n\nbody\n";
9
+ const BODY = "---\nname: claim-log\ndescription: a test skill\n---\n\nbody\n";
10
10
 
11
11
  async function makeSkillDir(parent: string, name: string, body = BODY): Promise<string> {
12
12
  const dir = join(parent, name);
@@ -54,94 +54,94 @@ describe("skl link --from (LINKED mode)", () => {
54
54
  });
55
55
 
56
56
  test("registers a dev-repo skill as a library symlink", async () => {
57
- const src = await makeSkillDir(devRepo, "cairn");
57
+ const src = await makeSkillDir(devRepo, "claim-log");
58
58
  const { ctx, json } = makeCtx(library);
59
- const code = await run(["cairn", "--from", src, "--json"], ctx);
59
+ const code = await run(["claim-log", "--from", src, "--json"], ctx);
60
60
 
61
61
  expect(code).toBe(0);
62
- const libEntry = join(library, "cairn");
62
+ const libEntry = join(library, "claim-log");
63
63
  const st = await lstat(libEntry);
64
64
  expect(st.isSymbolicLink()).toBe(true);
65
65
  expect(await realpath(libEntry)).toBe(await realpath(src));
66
- expect(json[0]).toMatchObject({ ok: true, name: "cairn", mode: "linked", discarded: false });
66
+ expect(json[0]).toMatchObject({ ok: true, name: "claim-log", mode: "linked", discarded: false });
67
67
  });
68
68
 
69
69
  test("derives the name from the dev-repo dir basename when omitted", async () => {
70
- const src = await makeSkillDir(devRepo, "cairn");
70
+ const src = await makeSkillDir(devRepo, "claim-log");
71
71
  const { ctx } = makeCtx(library);
72
72
  const code = await run(["--from", src], ctx);
73
73
 
74
74
  expect(code).toBe(0);
75
- const libEntry = join(library, "cairn");
75
+ const libEntry = join(library, "claim-log");
76
76
  expect((await lstat(libEntry)).isSymbolicLink()).toBe(true);
77
77
  expect(await realpath(libEntry)).toBe(await realpath(src));
78
78
  });
79
79
 
80
80
  test("is idempotent — re-running reports 'already'", async () => {
81
- const src = await makeSkillDir(devRepo, "cairn");
81
+ const src = await makeSkillDir(devRepo, "claim-log");
82
82
  const { ctx } = makeCtx(library);
83
- await run(["cairn", "--from", src], ctx);
83
+ await run(["claim-log", "--from", src], ctx);
84
84
 
85
85
  const { ctx: ctx2, json } = makeCtx(library);
86
- const code = await run(["cairn", "--from", src, "--json"], ctx2);
86
+ const code = await run(["claim-log", "--from", src, "--json"], ctx2);
87
87
  expect(code).toBe(0);
88
88
  expect(json[0]).toMatchObject({ status: "already", mode: "linked" });
89
89
  });
90
90
 
91
91
  test("refuses to clobber an existing owned library copy without --force", async () => {
92
- await makeSkillDir(library, "cairn"); // a real OWNED copy already in the library
93
- const src = await makeSkillDir(devRepo, "cairn");
92
+ await makeSkillDir(library, "claim-log"); // a real OWNED copy already in the library
93
+ const src = await makeSkillDir(devRepo, "claim-log");
94
94
  const { ctx, errors } = makeCtx(library);
95
95
 
96
- const code = await run(["cairn", "--from", src], ctx);
96
+ const code = await run(["claim-log", "--from", src], ctx);
97
97
  expect(code).toBe(1);
98
98
  expect(errors.join("\n")).toContain("already exists in the library");
99
99
  // unchanged: still a real dir, not a symlink
100
- expect((await lstat(join(library, "cairn"))).isSymbolicLink()).toBe(false);
100
+ expect((await lstat(join(library, "claim-log"))).isSymbolicLink()).toBe(false);
101
101
  });
102
102
 
103
103
  test("--force replaces an owned copy with the symlink and reports discarded", async () => {
104
- await makeSkillDir(library, "cairn");
105
- const src = await makeSkillDir(devRepo, "cairn");
104
+ await makeSkillDir(library, "claim-log");
105
+ const src = await makeSkillDir(devRepo, "claim-log");
106
106
  const { ctx, json } = makeCtx(library);
107
107
 
108
- const code = await run(["cairn", "--from", src, "--force", "--json"], ctx);
108
+ const code = await run(["claim-log", "--from", src, "--force", "--json"], ctx);
109
109
  expect(code).toBe(0);
110
- expect((await lstat(join(library, "cairn"))).isSymbolicLink()).toBe(true);
110
+ expect((await lstat(join(library, "claim-log"))).isSymbolicLink()).toBe(true);
111
111
  expect(json[0]).toMatchObject({ discarded: true, mode: "linked" });
112
112
  });
113
113
 
114
114
  test("drops a stale lockfile entry so update/outdated skip the now-LINKED skill", async () => {
115
115
  // An owned import existed (real copy + a github lock entry); now convert to LINKED.
116
- await makeSkillDir(library, "cairn");
116
+ await makeSkillDir(library, "claim-log");
117
117
  await writeFile(
118
118
  join(library, "shelf.lock.json"),
119
119
  JSON.stringify({
120
120
  version: 1,
121
121
  entries: {
122
- cairn: { name: "cairn", source: "github:owner/repo", ref: "abc", channel: "github", installedAt: "2020-01-01T00:00:00.000Z", localEdits: false },
122
+ "claim-log": { name: "claim-log", source: "github:owner/repo", ref: "abc", channel: "github", installedAt: "2020-01-01T00:00:00.000Z", localEdits: false },
123
123
  },
124
124
  }),
125
125
  );
126
- const src = await makeSkillDir(devRepo, "cairn");
126
+ const src = await makeSkillDir(devRepo, "claim-log");
127
127
  const { ctx } = makeCtx(library);
128
128
 
129
- const code = await run(["cairn", "--from", src, "--force"], ctx);
129
+ const code = await run(["claim-log", "--from", src, "--force"], ctx);
130
130
  expect(code).toBe(0);
131
131
  const lock = JSON.parse(await readFile(join(library, "shelf.lock.json"), "utf8"));
132
- expect(lock.entries.cairn).toBeUndefined();
132
+ expect(lock.entries["claim-log"]).toBeUndefined();
133
133
  });
134
134
 
135
135
  test("rejects --at and --from together", async () => {
136
- const src = await makeSkillDir(devRepo, "cairn");
136
+ const src = await makeSkillDir(devRepo, "claim-log");
137
137
  const { ctx, errors } = makeCtx(library);
138
- const code = await run(["cairn", "--from", src, "--at", "/tmp/x"], ctx);
138
+ const code = await run(["claim-log", "--from", src, "--at", "/tmp/x"], ctx);
139
139
  expect(code).toBe(1);
140
140
  expect(errors.join("\n")).toContain("mutually exclusive");
141
141
  });
142
142
 
143
143
  test("refuses a --from source inside the library", async () => {
144
- const inside = await makeSkillDir(library, "cairn");
144
+ const inside = await makeSkillDir(library, "claim-log");
145
145
  const { ctx, errors } = makeCtx(library);
146
146
  const code = await run(["other", "--from", inside], ctx);
147
147
  expect(code).toBe(1);
@@ -2,7 +2,7 @@
2
2
  // bundle (tag query). Excludes retired by default; `--all` includes them.
3
3
 
4
4
  import { statSync } from "node:fs";
5
- import type { Ctx, Skill } from "../types.ts";
5
+ import type { Ctx, Skill, Provenance } from "../types.ts";
6
6
  import { activeSkills, entryModeInfo } from "../core/library.ts";
7
7
  import { resolveBundle } from "../core/bundle.ts";
8
8
  import { inventoryDeployments } from "../core/deployments.ts";
@@ -94,8 +94,11 @@ function toJson(
94
94
  mode,
95
95
  linkTarget,
96
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),
97
98
  // stat timestamps, and the count of clean deployment sites.
98
99
  source: s.source ? "vendored" : "local",
100
+ origin: originLabel(s.source),
101
+ channel: s.source ? s.source.channel : null,
99
102
  modifiedAt,
100
103
  createdAt,
101
104
  deployCount: deployCounts.get(s.name) ?? 0,
@@ -103,6 +106,20 @@ function toJson(
103
106
  });
104
107
  }
105
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
+
106
123
  /** Count clean (`linked`) deployment sites per skill across all surfaces. */
107
124
  async function deployCountsFor(ctx: Ctx, lib: Skill[]): Promise<Map<string, number>> {
108
125
  const counts = new Map<string, number>();
@@ -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
+ }
@@ -21,7 +21,7 @@ export const meta = {
21
21
  usage: "skl outdated [name] [--check-local] [--json]",
22
22
  } as const;
23
23
 
24
- type Status = "stale" | "current" | "unknown" | "linked" | "diverged";
24
+ type Status = "stale" | "current" | "unknown" | "linked" | "diverged" | "adopted";
25
25
 
26
26
  interface Row {
27
27
  name: string;
@@ -87,6 +87,24 @@ function linkedRow(entry: LockEntry): Row {
87
87
  * INVISIBLE to outdated, giving an agent zero positive evidence its dev repo is the
88
88
  * canonical source. Surface it as a `linked` row regardless.
89
89
  */
90
+ /**
91
+ * An ADOPTED entry (`skl track`/`skl migrate`): provenance is known but the upstream
92
+ * baseline was NEVER verified against real upstream (the recorded ref may be empty and
93
+ * the installedHash describes the LOCAL copy only). Reporting it as stale/current off the
94
+ * empty ref would be a lie, so surface it as `adopted` and do NOT network-probe (ADR-0011).
95
+ */
96
+ function adoptedRow(entry: LockEntry): Row {
97
+ return {
98
+ name: entry.name,
99
+ channel: entry.channel,
100
+ source: entry.source,
101
+ installedRef: entry.ref || "-",
102
+ latestRef: null,
103
+ status: "adopted",
104
+ note: "provenance adopted; baseline unverified — run `skl update` to reconcile",
105
+ };
106
+ }
107
+
90
108
  function linkedRowFromName(name: string, linkTarget: string | null): Row {
91
109
  return {
92
110
  name,
@@ -156,9 +174,16 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
156
174
  entries.map((e) =>
157
175
  entryMode(libraryPath, e.name) === "linked"
158
176
  ? Promise.resolve(linkedRow(e))
159
- : checkLocal
160
- ? Promise.resolve(checkEntryLocal(e, library, libraryPath))
161
- : checkEntry(e),
177
+ : e.adopted === true
178
+ ? // An adopted entry has an unverified (often empty) baseline — never probe
179
+ // upstream off it; report it as `adopted` so `update` reconciles (ADR-0011).
180
+ // --check-local still does the offline body-vs-baseline compare below.
181
+ checkLocal
182
+ ? Promise.resolve(checkEntryLocal(e, library, libraryPath))
183
+ : Promise.resolve(adoptedRow(e))
184
+ : checkLocal
185
+ ? Promise.resolve(checkEntryLocal(e, library, libraryPath))
186
+ : checkEntry(e),
162
187
  ),
163
188
  );
164
189
 
@@ -198,19 +223,22 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
198
223
  : r.status === "diverged" ? "DIVERGED"
199
224
  : r.status === "current" ? "current "
200
225
  : r.status === "linked" ? "linked "
201
- : "unknown ";
226
+ : r.status === "adopted" ? "adopted "
227
+ : "unknown ";
202
228
  const refInfo =
203
229
  r.status === "linked"
204
230
  ? r.note
205
- : r.status === "stale"
206
- ? `${shortRef(r.installedRef)} -> ${shortRef(r.latestRef ?? "")}`
207
- : r.status === "diverged"
208
- ? r.note
209
- : r.status === "current"
210
- ? shortRef(r.installedRef) + (checkLocal ? " (offline)" : "")
211
- : `${shortRef(r.installedRef)} (${r.note})`;
231
+ : r.status === "adopted"
232
+ ? r.note
233
+ : r.status === "stale"
234
+ ? `${shortRef(r.installedRef)} -> ${shortRef(r.latestRef ?? "")}`
235
+ : r.status === "diverged"
236
+ ? r.note
237
+ : r.status === "current"
238
+ ? shortRef(r.installedRef) + (checkLocal ? " (offline)" : "")
239
+ : `${shortRef(r.installedRef)} (${r.note})`;
212
240
  const extra =
213
- r.note && !["unknown", "linked", "diverged"].includes(r.status) ? ` [${r.note}]` : "";
241
+ r.note && !["unknown", "linked", "diverged", "adopted"].includes(r.status) ? ` [${r.note}]` : "";
214
242
  ctx.log(`${mark} ${r.name.padEnd(28)} ${r.channel.padEnd(15)} ${refInfo}${extra}`);
215
243
  }
216
244
  ctx.log("");