skillshelf 0.3.0 → 0.4.1
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 +60 -35
- package/package.json +1 -1
- package/src/cli.ts +8 -0
- package/src/commands/add.test.ts +118 -0
- package/src/commands/add.ts +23 -2
- 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/import.test.ts +71 -0
- package/src/commands/import.ts +13 -0
- package/src/commands/link.test.ts +40 -26
- package/src/commands/link.ts +11 -0
- 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/new.test.ts +63 -0
- package/src/commands/new.ts +12 -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/rename.test.ts +14 -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/library.ts +54 -0
- package/src/core/lifecycle.ts +26 -32
- package/src/core/taxonomy.test.ts +2 -2
- package/src/types.ts +70 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// `skl agents --json` end-to-end with ADR-0010 config: a persisted-but-empty
|
|
2
|
+
// project must surface as a scope (no phantom deployments), a custom agent must
|
|
3
|
+
// appear in the matrix, and a real deploy into a config-project must be inventoried.
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
6
|
+
import { mkdtemp, mkdir, writeFile, rm, realpath } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { run as agentsRun } from "./agents.ts";
|
|
10
|
+
import { run as useRun } from "./use.ts";
|
|
11
|
+
import { loadLibrary } from "../core/library.ts";
|
|
12
|
+
import type { AgentsReport } from "../core/agents.ts";
|
|
13
|
+
import type { Config, Ctx } from "../types.ts";
|
|
14
|
+
|
|
15
|
+
async function writeSkill(library: string, name: string) {
|
|
16
|
+
const dir = join(library, name);
|
|
17
|
+
await mkdir(dir, { recursive: true });
|
|
18
|
+
await writeFile(join(dir, "SKILL.md"), `---\nname: ${name}\ndescription: ${name}\ndomains: [bio]\n---\n\nbody\n`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("skl agents --json — config projects + agents (ADR-0010)", () => {
|
|
22
|
+
let tmp: string;
|
|
23
|
+
let library: string;
|
|
24
|
+
|
|
25
|
+
function makeCtx(config: Partial<Config>): { ctx: Ctx; json: unknown[] } {
|
|
26
|
+
const json: unknown[] = [];
|
|
27
|
+
const full: Config = {
|
|
28
|
+
libraryPath: library,
|
|
29
|
+
globalCoreTarget: join(tmp, ".no-global-core"),
|
|
30
|
+
roots: [],
|
|
31
|
+
agents: [],
|
|
32
|
+
projects: [],
|
|
33
|
+
configFile: null,
|
|
34
|
+
configFilePath: join(tmp, "config.json"),
|
|
35
|
+
source: "default",
|
|
36
|
+
...config,
|
|
37
|
+
};
|
|
38
|
+
const ctx = {
|
|
39
|
+
config: full,
|
|
40
|
+
libraryPath: library,
|
|
41
|
+
roots: full.roots,
|
|
42
|
+
loadLibrary: () => loadLibrary(library),
|
|
43
|
+
log: () => {},
|
|
44
|
+
error: () => {},
|
|
45
|
+
json: (v: unknown) => json.push(v),
|
|
46
|
+
} as unknown as Ctx;
|
|
47
|
+
return { ctx, json };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
beforeEach(async () => {
|
|
51
|
+
tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-agents-cmd-")));
|
|
52
|
+
library = join(tmp, "library");
|
|
53
|
+
await writeSkill(library, "alpha");
|
|
54
|
+
});
|
|
55
|
+
afterEach(async () => {
|
|
56
|
+
await rm(tmp, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("an empty config-project appears as a scope with no phantom deployments", async () => {
|
|
60
|
+
const empty = join(tmp, "scratch");
|
|
61
|
+
await mkdir(empty, { recursive: true });
|
|
62
|
+
const { ctx, json } = makeCtx({ projects: [empty] });
|
|
63
|
+
const code = await agentsRun(["--json"], ctx);
|
|
64
|
+
expect(code).toBe(0);
|
|
65
|
+
const report = json[0] as AgentsReport;
|
|
66
|
+
expect(report.scopes).toContain("scratch");
|
|
67
|
+
// empty project = NO fabricated cell. (The matrix may contain real machine
|
|
68
|
+
// global deployments; the invariant is that nothing is keyed to `scratch`.)
|
|
69
|
+
for (const byAgent of Object.values(report.deployments)) {
|
|
70
|
+
for (const dep of Object.values(byAgent)) {
|
|
71
|
+
expect(dep.p?.scratch).toBeUndefined();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("a custom config-agent appears in the agents list", async () => {
|
|
77
|
+
const { ctx, json } = makeCtx({ agents: [{ id: "pi", name: "PI Agent", short: "PI" }] });
|
|
78
|
+
await agentsRun(["--json"], ctx);
|
|
79
|
+
const report = json[0] as AgentsReport;
|
|
80
|
+
expect(report.agents.map((a) => a.id)).toContain("pi");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a real deploy into a config-project is inventoried in the matrix", async () => {
|
|
84
|
+
const proj = join(tmp, "webapp");
|
|
85
|
+
await mkdir(proj, { recursive: true });
|
|
86
|
+
// deploy alpha into the config project for claude
|
|
87
|
+
const { ctx: useCtx } = makeCtx({ projects: [proj] });
|
|
88
|
+
await useRun(["alpha", "--agent", "claude", "--project", proj, "--json"], useCtx);
|
|
89
|
+
|
|
90
|
+
const { ctx, json } = makeCtx({ projects: [proj] });
|
|
91
|
+
await agentsRun(["--json"], ctx);
|
|
92
|
+
const report = json[0] as AgentsReport;
|
|
93
|
+
expect(report.scopes).toContain("webapp");
|
|
94
|
+
expect(report.deployments.alpha!.claude!.p!.webapp).toBe("clean");
|
|
95
|
+
});
|
|
96
|
+
});
|
package/src/commands/agents.ts
CHANGED
|
@@ -6,10 +6,16 @@
|
|
|
6
6
|
// skl agents --json full AgentsReport { agents, scopes, deployments }
|
|
7
7
|
// skl agents <name> one skill's row across agents × scopes
|
|
8
8
|
|
|
9
|
-
import
|
|
9
|
+
import { basename, join, sep } from "node:path";
|
|
10
|
+
import type { AgentConfigEntry, Ctx } from "../types.ts";
|
|
10
11
|
import { inventoryDeployments } from "../core/deployments.ts";
|
|
11
12
|
import { knownAgentSurfacePaths } from "../core/surfaces.ts";
|
|
12
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
computeAgentsReport,
|
|
15
|
+
knownAgentIds,
|
|
16
|
+
resolveReadTarget,
|
|
17
|
+
type DeployState,
|
|
18
|
+
} from "../core/agents.ts";
|
|
13
19
|
|
|
14
20
|
export const meta = {
|
|
15
21
|
name: "agents",
|
|
@@ -28,7 +34,24 @@ const GLYPH: Record<DeployState, string> = {
|
|
|
28
34
|
|
|
29
35
|
const LEGEND = "legend: ✓ clean · ⊙ source · ⚠ drift · □ copy · ✗ dead · · absent";
|
|
30
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Scope name for a persisted project dir = its basename — matching the engine's
|
|
39
|
+
* scope derivation (parseDeployTarget / scopeForSurface use the last path segment),
|
|
40
|
+
* so a config project reconciles with the FS-derived scopes in the matrix.
|
|
41
|
+
*/
|
|
42
|
+
function projectScopeName(projectPath: string): string {
|
|
43
|
+
return projectPath.split(sep).filter(Boolean).pop() || basename(projectPath) || projectPath;
|
|
44
|
+
}
|
|
45
|
+
|
|
31
46
|
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
47
|
+
// ── Config write subverbs (ADR-0010 delta 4): `skl agents add|rm` mutate the
|
|
48
|
+
// custom-agent registry in config.json. These are registry metadata only —
|
|
49
|
+
// they never deploy; the matrix stays FS-derived. Checked BEFORE the read
|
|
50
|
+
// path so `add`/`rm` are never mistaken for a skill name. (`add`/`rm` are
|
|
51
|
+
// reserved subverbs; a skill literally named "add"/"rm" is not a concern.)
|
|
52
|
+
if (argv[0] === "add" || argv[0] === "rm") {
|
|
53
|
+
return runConfigSubverb(argv, ctx);
|
|
54
|
+
}
|
|
32
55
|
try {
|
|
33
56
|
const rt = resolveReadTarget(argv);
|
|
34
57
|
if ("error" in rt) {
|
|
@@ -40,11 +63,35 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
40
63
|
const name = rt.rest.find((a) => !a.startsWith("--")) ?? null;
|
|
41
64
|
|
|
42
65
|
const lib = await ctx.loadLibrary();
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
const
|
|
66
|
+
// Effective agent ids = built-in seeds + custom config agents (so a custom
|
|
67
|
+
// agent's project/global skills dirs are scanned for inventory).
|
|
68
|
+
const agentIds = new Set<string>(knownAgentIds());
|
|
69
|
+
for (const a of ctx.config.agents) {
|
|
70
|
+
if (a && typeof a.id === "string" && a.id.trim() !== "") agentIds.add(a.id);
|
|
71
|
+
}
|
|
72
|
+
// Config-project surfaces (ADR-0010 §5a): scan each persisted project dir for
|
|
73
|
+
// every effective agent so a deployed config-project is inventoried.
|
|
74
|
+
const projectSurfaces: string[] = [];
|
|
75
|
+
for (const proj of ctx.config.projects) {
|
|
76
|
+
for (const id of agentIds) projectSurfaces.push(join(proj, `.${id}`, "skills"));
|
|
77
|
+
}
|
|
78
|
+
// Same surface union as `skl where` (+ any --project surfaces for this call,
|
|
79
|
+
// + persisted config-project surfaces) so the agent matrix sees the same
|
|
80
|
+
// reality and can verify an ad-hoc OR persisted project deploy.
|
|
81
|
+
const surfaces = [
|
|
82
|
+
...ctx.roots,
|
|
83
|
+
ctx.config.globalCoreTarget,
|
|
84
|
+
...knownAgentSurfacePaths(),
|
|
85
|
+
...projectSurfaces,
|
|
86
|
+
...rt.extraSurfaces,
|
|
87
|
+
];
|
|
46
88
|
const report = await inventoryDeployments(surfaces, ctx.libraryPath, lib);
|
|
47
|
-
|
|
89
|
+
// Empty persisted projects still appear as scope rows (basename = scope name);
|
|
90
|
+
// custom agents merge into the matrix. Deployments stay FS-derived.
|
|
91
|
+
let agentsReport = computeAgentsReport(report, undefined, {
|
|
92
|
+
agents: ctx.config.agents,
|
|
93
|
+
extraScopes: ctx.config.projects.map((p) => projectScopeName(p)),
|
|
94
|
+
});
|
|
48
95
|
|
|
49
96
|
// --agent <id> focuses the whole report on that agent: prune the agents list
|
|
50
97
|
// and the deployments map to just that agent (skills not deployed to it drop).
|
|
@@ -118,3 +165,79 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
118
165
|
return 1;
|
|
119
166
|
}
|
|
120
167
|
}
|
|
168
|
+
|
|
169
|
+
/** Read a `--flag value` option out of argv (returns undefined if absent). */
|
|
170
|
+
function flag(argv: string[], name: string): string | undefined {
|
|
171
|
+
const i = argv.indexOf(name);
|
|
172
|
+
if (i < 0) return undefined;
|
|
173
|
+
const v = argv[i + 1];
|
|
174
|
+
return v && !v.startsWith("--") ? v : undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* `skl agents add <id> --name <n> --global <dir> --proj-convention <c> [--icon k]
|
|
179
|
+
* [--color #rgb]` and `skl agents rm <id>` — persist the custom-agent registry
|
|
180
|
+
* (ADR-0010 delta 4). With `--json` emits `{ agents }` / `{ agents, removed }`
|
|
181
|
+
* (the full custom-agent list) so the GUI can round-trip without a separate read.
|
|
182
|
+
*/
|
|
183
|
+
async function runConfigSubverb(argv: string[], ctx: Ctx): Promise<number> {
|
|
184
|
+
const verb = argv[0];
|
|
185
|
+
const json = argv.includes("--json");
|
|
186
|
+
const id = argv[1] && !argv[1].startsWith("--") ? argv[1].trim() : "";
|
|
187
|
+
|
|
188
|
+
if (!id) {
|
|
189
|
+
ctx.error(`skl agents ${verb}: requires an agent id`);
|
|
190
|
+
ctx.error("usage: skl agents add <id> --name <name> --global <dir> --proj-convention <conv> [--icon <key>] [--color <hex>]");
|
|
191
|
+
return 1;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (verb === "rm") {
|
|
195
|
+
const { agents, removed } = await ctx.removeAgent(id);
|
|
196
|
+
if (json) {
|
|
197
|
+
ctx.json({ agents, removed });
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
ctx.log(removed ? `Removed custom agent "${id}".` : `No custom agent "${id}" (nothing removed).`);
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// add
|
|
205
|
+
const name = flag(argv, "--name") ?? id;
|
|
206
|
+
const global = flag(argv, "--global");
|
|
207
|
+
const projConvention = flag(argv, "--proj-convention");
|
|
208
|
+
const icon = flag(argv, "--icon");
|
|
209
|
+
const color = flag(argv, "--color");
|
|
210
|
+
// `--hidden` persists a hide override (ADR-0010 delta 4 / RISK 8): mergeAgents
|
|
211
|
+
// drops `hidden:true` so the agent leaves the matrix everywhere. A hide entry
|
|
212
|
+
// for a SEED carries no paths, so --global/--proj-convention are only required
|
|
213
|
+
// for a real (visible) registration.
|
|
214
|
+
const hidden = argv.includes("--hidden");
|
|
215
|
+
// Global→project inheritance (ADR-0010): default TRUE (the ~/.x/skills
|
|
216
|
+
// convention). `--no-inherits-global` opts out (persists inheritsGlobal:false);
|
|
217
|
+
// `--inherits-global` is accepted for symmetry but is the default, so we only
|
|
218
|
+
// persist the flag when it diverges from the default to keep config minimal.
|
|
219
|
+
const inheritsGlobal = !argv.includes("--no-inherits-global");
|
|
220
|
+
if (!hidden && (!global || !projConvention)) {
|
|
221
|
+
ctx.error("skl agents add: --global and --proj-convention are required");
|
|
222
|
+
return 1;
|
|
223
|
+
}
|
|
224
|
+
const entry: AgentConfigEntry = {
|
|
225
|
+
id,
|
|
226
|
+
name,
|
|
227
|
+
short: name,
|
|
228
|
+
...(global ? { global } : {}),
|
|
229
|
+
...(projConvention ? { projConvention } : {}),
|
|
230
|
+
...(icon ? { icon } : {}),
|
|
231
|
+
...(color ? { color } : {}),
|
|
232
|
+
...(hidden ? { hidden: true } : {}),
|
|
233
|
+
...(inheritsGlobal ? {} : { inheritsGlobal: false }),
|
|
234
|
+
};
|
|
235
|
+
const agents = await ctx.addAgent(entry);
|
|
236
|
+
if (json) {
|
|
237
|
+
ctx.json({ agents, added: true });
|
|
238
|
+
return 0;
|
|
239
|
+
}
|
|
240
|
+
ctx.log(`Registered custom agent "${name}" (${id}). Custom agents (${agents.length}):`);
|
|
241
|
+
for (const a of agents) ctx.log(` ${a.id} ${a.global}`);
|
|
242
|
+
return 0;
|
|
243
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, rm, realpath } from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { run } from "./import.ts";
|
|
7
|
+
import type { Ctx } from "../types.ts";
|
|
8
|
+
|
|
9
|
+
const BODY = "---\nname: caveman\ndescription: a test skill\n---\n\nbody\n";
|
|
10
|
+
|
|
11
|
+
interface Captured {
|
|
12
|
+
ctx: Ctx;
|
|
13
|
+
logs: string[];
|
|
14
|
+
errors: string[];
|
|
15
|
+
json: unknown[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeCtx(libraryPath: string): Captured {
|
|
19
|
+
const logs: string[] = [];
|
|
20
|
+
const errors: string[] = [];
|
|
21
|
+
const json: unknown[] = [];
|
|
22
|
+
const ctx = {
|
|
23
|
+
config: { libraryPath },
|
|
24
|
+
libraryPath,
|
|
25
|
+
log: (...a: unknown[]) => logs.push(a.join(" ")),
|
|
26
|
+
error: (...a: unknown[]) => errors.push(a.join(" ")),
|
|
27
|
+
json: (v: unknown) => json.push(v),
|
|
28
|
+
} as unknown as Ctx;
|
|
29
|
+
return { ctx, logs, errors, json };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("skl import — retired-aware collision", () => {
|
|
33
|
+
let tmp: string;
|
|
34
|
+
let library: string;
|
|
35
|
+
let candidate: string;
|
|
36
|
+
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-import-")));
|
|
39
|
+
library = join(tmp, "library");
|
|
40
|
+
await mkdir(library, { recursive: true });
|
|
41
|
+
// A candidate skill dir on disk to import.
|
|
42
|
+
candidate = join(tmp, "ext", "caveman");
|
|
43
|
+
await mkdir(candidate, { recursive: true });
|
|
44
|
+
await writeFile(join(candidate, "SKILL.md"), BODY);
|
|
45
|
+
});
|
|
46
|
+
afterEach(async () => {
|
|
47
|
+
await rm(tmp, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("importing to a retired name refuses (exit 1, no write)", async () => {
|
|
51
|
+
// Retire "caveman": a tombstone, no active copy.
|
|
52
|
+
await mkdir(join(library, "_retired", "caveman"), { recursive: true });
|
|
53
|
+
await writeFile(join(library, "_retired", "caveman", "SKILL.md"), BODY);
|
|
54
|
+
|
|
55
|
+
const { ctx, errors } = makeCtx(library);
|
|
56
|
+
const code = await run(["caveman", "--from", candidate, "--copy"], ctx);
|
|
57
|
+
expect(code).toBe(1);
|
|
58
|
+
expect(errors.join("\n")).toContain("skl unretire caveman");
|
|
59
|
+
|
|
60
|
+
// No active copy created; the candidate is untouched.
|
|
61
|
+
expect(existsSync(join(library, "caveman"))).toBe(false);
|
|
62
|
+
expect(existsSync(join(candidate, "SKILL.md"))).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("importing a non-retired name still works (no regression)", async () => {
|
|
66
|
+
const { ctx } = makeCtx(library);
|
|
67
|
+
const code = await run(["caveman", "--from", candidate, "--copy", "--json"], ctx);
|
|
68
|
+
expect(code).toBe(0);
|
|
69
|
+
expect(existsSync(join(library, "caveman", "SKILL.md"))).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/commands/import.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
isSymlink,
|
|
35
35
|
realpathOrSelfAsync,
|
|
36
36
|
} from "../lib/fs.ts";
|
|
37
|
+
import { entryStatus } from "../core/library.ts";
|
|
37
38
|
|
|
38
39
|
export const meta = {
|
|
39
40
|
name: "import",
|
|
@@ -202,6 +203,18 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
202
203
|
// Flat, non-semantic layout (ADR-0001): always <library>/<name>/.
|
|
203
204
|
const destDir = join(libraryPath, targetName);
|
|
204
205
|
|
|
206
|
+
// Retired-aware guard: refuse if the name exists ONLY as a retired tombstone
|
|
207
|
+
// (<library>/_retired/<name>). Importing beside it would strand a duplicate and
|
|
208
|
+
// break `skl unretire`; --force overwrites an ACTIVE copy, not a retired one, so
|
|
209
|
+
// this fires regardless. The user must unretire first (or import under --as).
|
|
210
|
+
const status = entryStatus(libraryPath, targetName);
|
|
211
|
+
if (status.retired && !status.active) {
|
|
212
|
+
ctx.error(
|
|
213
|
+
`skl import: a retired '${targetName}' exists — run \`skl unretire ${targetName}\` first (or import under another name with --as <slug>)`,
|
|
214
|
+
);
|
|
215
|
+
return 1;
|
|
216
|
+
}
|
|
217
|
+
|
|
205
218
|
// Idempotency guard: refuse to clobber an existing library skill unless --force
|
|
206
219
|
// (or the user re-aimed with --as). This protects a managed copy from a stray
|
|
207
220
|
// re-import.
|
|
@@ -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,108 @@ 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
|
+
});
|
|
102
|
+
|
|
103
|
+
test("refuses to shelve over a retired tombstone (points at unretire)", async () => {
|
|
104
|
+
// "claim-log" exists only as a retired tombstone — no active library entry.
|
|
105
|
+
await makeSkillDir(join(library, "_retired"), "claim-log");
|
|
106
|
+
const src = await makeSkillDir(devRepo, "claim-log");
|
|
107
|
+
const { ctx, errors } = makeCtx(library);
|
|
108
|
+
|
|
109
|
+
const code = await run(["claim-log", "--from", src], ctx);
|
|
110
|
+
expect(code).toBe(1);
|
|
111
|
+
expect(errors.join("\n")).toContain("skl unretire claim-log");
|
|
112
|
+
// No active symlink created beside the tombstone.
|
|
113
|
+
expect(existsSync(join(library, "claim-log"))).toBe(false);
|
|
114
|
+
expect(existsSync(join(library, "_retired", "claim-log"))).toBe(true);
|
|
101
115
|
});
|
|
102
116
|
|
|
103
117
|
test("--force replaces an owned copy with the symlink and reports discarded", async () => {
|
|
104
|
-
await makeSkillDir(library, "
|
|
105
|
-
const src = await makeSkillDir(devRepo, "
|
|
118
|
+
await makeSkillDir(library, "claim-log");
|
|
119
|
+
const src = await makeSkillDir(devRepo, "claim-log");
|
|
106
120
|
const { ctx, json } = makeCtx(library);
|
|
107
121
|
|
|
108
|
-
const code = await run(["
|
|
122
|
+
const code = await run(["claim-log", "--from", src, "--force", "--json"], ctx);
|
|
109
123
|
expect(code).toBe(0);
|
|
110
|
-
expect((await lstat(join(library, "
|
|
124
|
+
expect((await lstat(join(library, "claim-log"))).isSymbolicLink()).toBe(true);
|
|
111
125
|
expect(json[0]).toMatchObject({ discarded: true, mode: "linked" });
|
|
112
126
|
});
|
|
113
127
|
|
|
114
128
|
test("drops a stale lockfile entry so update/outdated skip the now-LINKED skill", async () => {
|
|
115
129
|
// An owned import existed (real copy + a github lock entry); now convert to LINKED.
|
|
116
|
-
await makeSkillDir(library, "
|
|
130
|
+
await makeSkillDir(library, "claim-log");
|
|
117
131
|
await writeFile(
|
|
118
132
|
join(library, "shelf.lock.json"),
|
|
119
133
|
JSON.stringify({
|
|
120
134
|
version: 1,
|
|
121
135
|
entries: {
|
|
122
|
-
|
|
136
|
+
"claim-log": { name: "claim-log", source: "github:owner/repo", ref: "abc", channel: "github", installedAt: "2020-01-01T00:00:00.000Z", localEdits: false },
|
|
123
137
|
},
|
|
124
138
|
}),
|
|
125
139
|
);
|
|
126
|
-
const src = await makeSkillDir(devRepo, "
|
|
140
|
+
const src = await makeSkillDir(devRepo, "claim-log");
|
|
127
141
|
const { ctx } = makeCtx(library);
|
|
128
142
|
|
|
129
|
-
const code = await run(["
|
|
143
|
+
const code = await run(["claim-log", "--from", src, "--force"], ctx);
|
|
130
144
|
expect(code).toBe(0);
|
|
131
145
|
const lock = JSON.parse(await readFile(join(library, "shelf.lock.json"), "utf8"));
|
|
132
|
-
expect(lock.entries
|
|
146
|
+
expect(lock.entries["claim-log"]).toBeUndefined();
|
|
133
147
|
});
|
|
134
148
|
|
|
135
149
|
test("rejects --at and --from together", async () => {
|
|
136
|
-
const src = await makeSkillDir(devRepo, "
|
|
150
|
+
const src = await makeSkillDir(devRepo, "claim-log");
|
|
137
151
|
const { ctx, errors } = makeCtx(library);
|
|
138
|
-
const code = await run(["
|
|
152
|
+
const code = await run(["claim-log", "--from", src, "--at", "/tmp/x"], ctx);
|
|
139
153
|
expect(code).toBe(1);
|
|
140
154
|
expect(errors.join("\n")).toContain("mutually exclusive");
|
|
141
155
|
});
|
|
142
156
|
|
|
143
157
|
test("refuses a --from source inside the library", async () => {
|
|
144
|
-
const inside = await makeSkillDir(library, "
|
|
158
|
+
const inside = await makeSkillDir(library, "claim-log");
|
|
145
159
|
const { ctx, errors } = makeCtx(library);
|
|
146
160
|
const code = await run(["other", "--from", inside], ctx);
|
|
147
161
|
expect(code).toBe(1);
|
package/src/commands/link.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
safeSymlink,
|
|
40
40
|
realpathOrSelfAsync,
|
|
41
41
|
} from "../lib/fs.ts";
|
|
42
|
+
import { entryStatus } from "../core/library.ts";
|
|
42
43
|
|
|
43
44
|
export const meta = {
|
|
44
45
|
name: "link",
|
|
@@ -166,6 +167,16 @@ async function runFrom(flags: Flags, ctx: Ctx): Promise<number> {
|
|
|
166
167
|
}
|
|
167
168
|
}
|
|
168
169
|
|
|
170
|
+
// Retired-aware guard: refuse if the name exists ONLY as a retired tombstone
|
|
171
|
+
// (<library>/_retired/<name>). Shelving a symlink beside it would strand a duplicate
|
|
172
|
+
// and break `skl unretire`; --force replaces an ACTIVE entry, not a retired one, so
|
|
173
|
+
// this fires regardless. The user must unretire first.
|
|
174
|
+
const status = entryStatus(libraryPath, name);
|
|
175
|
+
if (status.retired && !status.active) {
|
|
176
|
+
ctx.error(`skl link: a retired '${name}' exists — run \`skl unretire ${name}\` first.`);
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
|
|
169
180
|
// An existing library entry won't be clobbered silently.
|
|
170
181
|
const exists = existsSync(libDir) || isSymlink(libDir);
|
|
171
182
|
if (exists && !flags.force) {
|
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>();
|