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.
- package/README.md +28 -3
- package/package.json +1 -1
- package/src/cli.ts +8 -0
- package/src/commands/adopted.test.ts +144 -0
- package/src/commands/agents-config.test.ts +126 -0
- package/src/commands/agents.test.ts +96 -0
- package/src/commands/agents.ts +129 -6
- package/src/commands/link.test.ts +26 -26
- package/src/commands/ls.ts +18 -1
- package/src/commands/migrate.test.ts +157 -0
- package/src/commands/migrate.ts +260 -0
- package/src/commands/outdated.ts +41 -13
- package/src/commands/projects.test.ts +85 -0
- package/src/commands/projects.ts +80 -0
- package/src/commands/show.ts +126 -10
- package/src/commands/track.test.ts +170 -0
- package/src/commands/track.ts +340 -0
- package/src/commands/untrack.ts +44 -0
- package/src/commands/update.ts +92 -0
- package/src/commands/use.test.ts +30 -0
- package/src/config.test.ts +130 -1
- package/src/config.ts +154 -1
- package/src/core/agents.test.ts +92 -5
- package/src/core/agents.ts +83 -8
- package/src/core/core.test.ts +7 -7
- package/src/core/deployments.test.ts +20 -20
- package/src/core/fetch.ts +28 -6
- package/src/core/library.test.ts +3 -3
- package/src/core/taxonomy.test.ts +2 -2
- package/src/types.ts +70 -0
|
@@ -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:
|
|
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, "
|
|
57
|
+
const src = await makeSkillDir(devRepo, "claim-log");
|
|
58
58
|
const { ctx, json } = makeCtx(library);
|
|
59
|
-
const code = await run(["
|
|
59
|
+
const code = await run(["claim-log", "--from", src, "--json"], ctx);
|
|
60
60
|
|
|
61
61
|
expect(code).toBe(0);
|
|
62
|
-
const libEntry = join(library, "
|
|
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: "
|
|
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, "
|
|
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, "
|
|
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, "
|
|
81
|
+
const src = await makeSkillDir(devRepo, "claim-log");
|
|
82
82
|
const { ctx } = makeCtx(library);
|
|
83
|
-
await run(["
|
|
83
|
+
await run(["claim-log", "--from", src], ctx);
|
|
84
84
|
|
|
85
85
|
const { ctx: ctx2, json } = makeCtx(library);
|
|
86
|
-
const code = await run(["
|
|
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, "
|
|
93
|
-
const src = await makeSkillDir(devRepo, "
|
|
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(["
|
|
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, "
|
|
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, "
|
|
105
|
-
const src = await makeSkillDir(devRepo, "
|
|
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(["
|
|
108
|
+
const code = await run(["claim-log", "--from", src, "--force", "--json"], ctx);
|
|
109
109
|
expect(code).toBe(0);
|
|
110
|
-
expect((await lstat(join(library, "
|
|
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, "
|
|
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
|
-
|
|
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, "
|
|
126
|
+
const src = await makeSkillDir(devRepo, "claim-log");
|
|
127
127
|
const { ctx } = makeCtx(library);
|
|
128
128
|
|
|
129
|
-
const code = await run(["
|
|
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
|
|
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, "
|
|
136
|
+
const src = await makeSkillDir(devRepo, "claim-log");
|
|
137
137
|
const { ctx, errors } = makeCtx(library);
|
|
138
|
-
const code = await run(["
|
|
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, "
|
|
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);
|
package/src/commands/ls.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/commands/outdated.ts
CHANGED
|
@@ -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
|
-
:
|
|
160
|
-
?
|
|
161
|
-
|
|
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
|
-
: "
|
|
226
|
+
: r.status === "adopted" ? "adopted "
|
|
227
|
+
: "unknown ";
|
|
202
228
|
const refInfo =
|
|
203
229
|
r.status === "linked"
|
|
204
230
|
? r.note
|
|
205
|
-
: r.status === "
|
|
206
|
-
?
|
|
207
|
-
: r.status === "
|
|
208
|
-
? r.
|
|
209
|
-
: r.status === "
|
|
210
|
-
?
|
|
211
|
-
:
|
|
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("");
|