skillshelf 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +83 -20
  2. package/package.json +8 -2
  3. package/src/adapters/inference/agent.ts +23 -16
  4. package/src/cli.ts +39 -0
  5. package/src/commands/add.ts +624 -128
  6. package/src/commands/adopted.test.ts +144 -0
  7. package/src/commands/agents-config.test.ts +126 -0
  8. package/src/commands/agents.test.ts +96 -0
  9. package/src/commands/agents.ts +243 -0
  10. package/src/commands/drop.ts +21 -13
  11. package/src/commands/import.ts +44 -28
  12. package/src/commands/infer.ts +6 -6
  13. package/src/commands/link.test.ts +160 -0
  14. package/src/commands/link.ts +317 -0
  15. package/src/commands/ls.ts +136 -19
  16. package/src/commands/migrate.test.ts +157 -0
  17. package/src/commands/migrate.ts +260 -0
  18. package/src/commands/mode-surfacing.test.ts +110 -0
  19. package/src/commands/outdated.test.ts +55 -0
  20. package/src/commands/outdated.ts +166 -18
  21. package/src/commands/projects.test.ts +85 -0
  22. package/src/commands/projects.ts +80 -0
  23. package/src/commands/refresh.ts +133 -0
  24. package/src/commands/remediation.test.ts +149 -0
  25. package/src/commands/rename.test.ts +121 -0
  26. package/src/commands/rename.ts +64 -0
  27. package/src/commands/retag.ts +58 -0
  28. package/src/commands/retire.ts +39 -0
  29. package/src/commands/rm.test.ts +133 -0
  30. package/src/commands/rm.ts +107 -0
  31. package/src/commands/roots.ts +41 -0
  32. package/src/commands/scan.ts +122 -30
  33. package/src/commands/show.ts +130 -11
  34. package/src/commands/status.ts +43 -8
  35. package/src/commands/tag.test.ts +109 -0
  36. package/src/commands/tag.ts +68 -0
  37. package/src/commands/track.test.ts +170 -0
  38. package/src/commands/track.ts +340 -0
  39. package/src/commands/unretire.ts +33 -0
  40. package/src/commands/untag.ts +73 -0
  41. package/src/commands/untrack.ts +44 -0
  42. package/src/commands/update.test.ts +71 -0
  43. package/src/commands/update.ts +157 -15
  44. package/src/commands/use.test.ts +122 -0
  45. package/src/commands/use.ts +46 -23
  46. package/src/commands/where.ts +232 -0
  47. package/src/config.test.ts +198 -0
  48. package/src/config.ts +232 -10
  49. package/src/core/agents.test.ts +319 -0
  50. package/src/core/agents.ts +438 -0
  51. package/src/core/bundle.ts +12 -15
  52. package/src/core/core.test.ts +21 -8
  53. package/src/core/crawl.ts +22 -5
  54. package/src/core/dedupe.ts +36 -0
  55. package/src/core/deployments.test.ts +147 -0
  56. package/src/core/deployments.ts +208 -0
  57. package/src/core/fetch.ts +371 -75
  58. package/src/core/indexgen.ts +2 -0
  59. package/src/core/library.test.ts +41 -0
  60. package/src/core/library.ts +61 -16
  61. package/src/core/lifecycle.ts +252 -0
  62. package/src/core/surfaces.ts +46 -0
  63. package/src/core/taxonomy.test.ts +159 -0
  64. package/src/core/taxonomy.ts +190 -0
  65. package/src/lib/fs.ts +2 -2
  66. package/src/types.ts +155 -15
  67. package/src/core/overlay.ts +0 -63
@@ -0,0 +1,144 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, writeFile, readFile, rm, realpath } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { run as outdatedRun } from "./outdated.ts";
6
+ import { run as updateRun } from "./update.ts";
7
+ import { readLockfile, recordEntry } from "../core/provenance.ts";
8
+ import type { Ctx, LockEntry } from "../types.ts";
9
+
10
+ function makeCtx(libraryPath: string) {
11
+ const logs: string[] = [];
12
+ const errors: string[] = [];
13
+ const json: unknown[] = [];
14
+ const ctx = {
15
+ config: { libraryPath },
16
+ libraryPath,
17
+ log: (...a: unknown[]) => logs.push(a.join(" ")),
18
+ error: (...a: unknown[]) => errors.push(a.join(" ")),
19
+ json: (v: unknown) => json.push(v),
20
+ } as unknown as Ctx;
21
+ return { ctx, logs, errors, json };
22
+ }
23
+
24
+ /** Build a tiny on-disk git repo to act as a `git:` upstream for one skill. */
25
+ async function makeGitUpstream(dir: string, name: string, body: string): Promise<void> {
26
+ await mkdir(join(dir, name), { recursive: true });
27
+ await writeFile(join(dir, name, "SKILL.md"), `---\nname: ${name}\ndescription: d\n---\n\n${body}\n`);
28
+ const git = (args: string[]) => Bun.spawnSync(["git", "-C", dir, ...args], { stdout: "ignore", stderr: "ignore" });
29
+ git(["init", "-q"]);
30
+ git(["config", "user.email", "t@t.t"]);
31
+ git(["config", "user.name", "t"]);
32
+ git(["add", "-A"]);
33
+ git(["commit", "-q", "-m", "init"]);
34
+ }
35
+
36
+ describe("adopted entries — outdated reports 'adopted', not stale", () => {
37
+ let tmp: string;
38
+ let library: string;
39
+
40
+ beforeEach(async () => {
41
+ tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-adopted-outdated-")));
42
+ library = join(tmp, "library");
43
+ await mkdir(join(library, "foo"), { recursive: true });
44
+ await writeFile(join(library, "foo", "SKILL.md"), "---\nname: foo\ndescription: d\n---\n\nbody\n");
45
+ const entry: LockEntry = {
46
+ name: "foo",
47
+ source: "github:owner/repo",
48
+ ref: "",
49
+ channel: "github",
50
+ installedAt: "2024-01-01T00:00:00.000Z",
51
+ localEdits: false,
52
+ installedHash: "abc",
53
+ adopted: true,
54
+ };
55
+ await recordEntry(library, entry);
56
+ });
57
+ afterEach(async () => {
58
+ await rm(tmp, { recursive: true, force: true });
59
+ });
60
+
61
+ test("status is 'adopted' and never counted stale (no upstream probe)", async () => {
62
+ const { ctx, json } = makeCtx(library);
63
+ const code = await outdatedRun(["--json"], ctx);
64
+ expect(code).toBe(0); // not stale -> exit 0, no network probe of the empty ref
65
+ const report = json[0] as { stale: number; rows: Array<{ name: string; status: string; note: string }> };
66
+ expect(report.stale).toBe(0);
67
+ const row = report.rows.find((r) => r.name === "foo")!;
68
+ expect(row.status).toBe("adopted");
69
+ expect(row.note).toContain("baseline unverified");
70
+ });
71
+ });
72
+
73
+ describe("adopted entries — update is conservative and graduates", () => {
74
+ let tmp: string;
75
+ let library: string;
76
+ let upstream: string;
77
+
78
+ async function seedAdopted(libBody: string, upstreamBody: string): Promise<void> {
79
+ await mkdir(join(library, "foo"), { recursive: true });
80
+ await writeFile(join(library, "foo", "SKILL.md"), `---\nname: foo\ndescription: d\n---\n\n${libBody}\n`);
81
+ upstream = join(tmp, "upstream");
82
+ await makeGitUpstream(upstream, "foo", upstreamBody);
83
+ const entry: LockEntry = {
84
+ name: "foo",
85
+ source: `git:${upstream}#foo`,
86
+ ref: "",
87
+ channel: "git",
88
+ installedAt: "2024-01-01T00:00:00.000Z",
89
+ localEdits: false,
90
+ installedHash: "unverified-baseline-hash",
91
+ adopted: true,
92
+ };
93
+ await recordEntry(library, entry);
94
+ }
95
+
96
+ beforeEach(async () => {
97
+ tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-adopted-update-")));
98
+ library = join(tmp, "library");
99
+ await mkdir(library, { recursive: true });
100
+ });
101
+ afterEach(async () => {
102
+ await rm(tmp, { recursive: true, force: true });
103
+ });
104
+
105
+ test("differing body: requires --force, shows diff, does not clobber", async () => {
106
+ await seedAdopted("LOCAL BODY", "UPSTREAM BODY");
107
+ const { ctx, json } = makeCtx(library);
108
+ const code = await updateRun(["foo", "--json"], ctx);
109
+ // diverged -> exit 2
110
+ expect(code).toBe(2);
111
+ const report = json[0] as { results: Array<{ name: string; outcome: string }> };
112
+ expect(report.results.find((r) => r.name === "foo")!.outcome).toBe("diverged");
113
+ // local body untouched
114
+ const body = await readFile(join(library, "foo", "SKILL.md"), "utf8");
115
+ expect(body).toContain("LOCAL BODY");
116
+ // still adopted (not graduated)
117
+ expect((await readLockfile(library)).entries.foo!.adopted).toBe(true);
118
+ });
119
+
120
+ test("differing body with --force: overwrites and graduates (adopted cleared)", async () => {
121
+ await seedAdopted("LOCAL BODY", "UPSTREAM BODY");
122
+ const { ctx } = makeCtx(library);
123
+ const code = await updateRun(["foo", "--force"], ctx);
124
+ expect(code).toBe(0);
125
+ const body = await readFile(join(library, "foo", "SKILL.md"), "utf8");
126
+ expect(body).toContain("UPSTREAM BODY");
127
+ const e = (await readLockfile(library)).entries.foo!;
128
+ expect(e.adopted).toBe(false); // graduated
129
+ expect(e.ref).not.toBe(""); // real commit pinned
130
+ expect(e.localEdits).toBe(false);
131
+ });
132
+
133
+ test("identical body: graduates without --force (lossless)", async () => {
134
+ await seedAdopted("SAME BODY", "SAME BODY");
135
+ const { ctx } = makeCtx(library);
136
+ const code = await updateRun(["foo"], ctx);
137
+ expect(code).toBe(0);
138
+ const e = (await readLockfile(library)).entries.foo!;
139
+ expect(e.adopted).toBe(false); // graduated
140
+ expect(e.ref).not.toBe("");
141
+ // installedHash now reflects the verified upstream body.
142
+ expect(e.installedHash).not.toBe("unverified-baseline-hash");
143
+ });
144
+ });
@@ -0,0 +1,126 @@
1
+ // `skl agents add|rm` write verbs (ADR-0010 delta 4). Drives the real ctx through
2
+ // an isolated SKILLSHELF_CONFIG so the GUI round-trip is pinned: a registered
3
+ // custom agent persists, reads back tagged `custom:true` in `agents --json` (the
4
+ // flag loadConfig() filters on), and `rm` removes it. This is the round-trip the
5
+ // review flagged as a non-persisting stub.
6
+
7
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
+ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { loadContext } from "../config.ts";
12
+ import { run as agentsRun } from "./agents.ts";
13
+ import type { AgentsReport } from "../core/agents.ts";
14
+ import type { Ctx } from "../types.ts";
15
+
16
+ describe("skl agents add|rm write verbs (ADR-0010 delta 4)", () => {
17
+ let tmp: string;
18
+ let cfg: string;
19
+ let library: string;
20
+
21
+ async function makeCtx(): Promise<{ ctx: Ctx; json: unknown[] }> {
22
+ const json: unknown[] = [];
23
+ const ctx = await loadContext({
24
+ env: {
25
+ SKILLSHELF_CONFIG: cfg,
26
+ SKILLSHELF_LIBRARY: library,
27
+ SKILLSHELF_GLOBAL_CORE: join(tmp, ".no-global-core"),
28
+ } as NodeJS.ProcessEnv,
29
+ });
30
+ ctx.json = (v: unknown) => json.push(v);
31
+ ctx.log = () => {};
32
+ ctx.error = () => {};
33
+ return { ctx, json };
34
+ }
35
+
36
+ beforeEach(async () => {
37
+ tmp = await mkdtemp(join(tmpdir(), "skl-agents-config-"));
38
+ cfg = join(tmp, "config.json");
39
+ library = join(tmp, "library");
40
+ await mkdir(library, { recursive: true });
41
+ await writeFile(
42
+ join(library, "config.json"),
43
+ "", // placeholder so library dir is non-empty; real skills not needed here
44
+ );
45
+ });
46
+ afterEach(async () => {
47
+ await rm(tmp, { recursive: true, force: true });
48
+ });
49
+
50
+ test("add persists a custom agent and reports the updated list", async () => {
51
+ const { ctx, json } = await makeCtx();
52
+ const code = await agentsRun(
53
+ [
54
+ "add",
55
+ "cursor",
56
+ "--name",
57
+ "Cursor",
58
+ "--global",
59
+ "~/.cursor/skills",
60
+ "--proj-convention",
61
+ ".cursor/skills",
62
+ "--icon",
63
+ "cursor",
64
+ "--json",
65
+ ],
66
+ ctx,
67
+ );
68
+ expect(code).toBe(0);
69
+ const out = json[0] as { agents: Array<{ id: string; icon?: string }>; added: boolean };
70
+ expect(out.added).toBe(true);
71
+ expect(out.agents.map((a) => a.id)).toContain("cursor");
72
+ expect(out.agents.find((a) => a.id === "cursor")?.icon).toBe("cursor");
73
+ });
74
+
75
+ test("a persisted custom agent reads back tagged custom:true in agents --json", async () => {
76
+ const { ctx } = await makeCtx();
77
+ await agentsRun(
78
+ ["add", "cursor", "--name", "Cursor", "--global", "~/.cursor/skills", "--proj-convention", ".cursor/skills"],
79
+ ctx,
80
+ );
81
+
82
+ // fresh ctx reads config from disk
83
+ const { ctx: ctx2, json } = await makeCtx();
84
+ await agentsRun(["--json"], ctx2);
85
+ const report = json[0] as AgentsReport;
86
+ const cursor = report.agents.find((a) => a.id === "cursor");
87
+ expect(cursor).toBeDefined();
88
+ expect(cursor!.custom).toBe(true);
89
+ // built-in seeds are NOT tagged custom (loadConfig must not pick them up).
90
+ const claude = report.agents.find((a) => a.id === "claude");
91
+ expect(claude?.custom).toBeUndefined();
92
+ });
93
+
94
+ test("rm removes a persisted custom agent", async () => {
95
+ const { ctx } = await makeCtx();
96
+ await agentsRun(
97
+ ["add", "cursor", "--name", "Cursor", "--global", "~/.cursor/skills", "--proj-convention", ".cursor/skills"],
98
+ ctx,
99
+ );
100
+
101
+ const { ctx: ctx2, json } = await makeCtx();
102
+ const code = await agentsRun(["rm", "cursor", "--json"], ctx2);
103
+ expect(code).toBe(0);
104
+ const out = json[0] as { agents: unknown[]; removed: boolean };
105
+ expect(out.removed).toBe(true);
106
+ expect(out.agents).toEqual([]);
107
+ });
108
+
109
+ test("rm on a non-registered id reports removed:false", async () => {
110
+ const { ctx, json } = await makeCtx();
111
+ const code = await agentsRun(["rm", "nope", "--json"], ctx);
112
+ expect(code).toBe(0);
113
+ expect(json[0]).toEqual({ agents: [], removed: false });
114
+ });
115
+
116
+ test("add without required path flags errors", async () => {
117
+ const { ctx } = await makeCtx();
118
+ expect(await agentsRun(["add", "cursor", "--name", "Cursor"], ctx)).toBe(1);
119
+ });
120
+
121
+ test("add/rm without an id errors", async () => {
122
+ const { ctx } = await makeCtx();
123
+ expect(await agentsRun(["add"], ctx)).toBe(1);
124
+ expect(await agentsRun(["rm"], ctx)).toBe(1);
125
+ });
126
+ });
@@ -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
+ });
@@ -0,0 +1,243 @@
1
+ // `skl agents [name]` — the multi-agent deployment matrix: for each known agent
2
+ // (claude, codex, …) and scope (Global / a project), what is each skill's
3
+ // deployment state? Built on the same reality `skl where` inventories. ADR-0008 §7.3.
4
+ //
5
+ // skl agents human summary of installed agents + per-agent counts
6
+ // skl agents --json full AgentsReport { agents, scopes, deployments }
7
+ // skl agents <name> one skill's row across agents × scopes
8
+
9
+ import { basename, join, sep } from "node:path";
10
+ import type { AgentConfigEntry, Ctx } from "../types.ts";
11
+ import { inventoryDeployments } from "../core/deployments.ts";
12
+ import { knownAgentSurfacePaths } from "../core/surfaces.ts";
13
+ import {
14
+ computeAgentsReport,
15
+ knownAgentIds,
16
+ resolveReadTarget,
17
+ type DeployState,
18
+ } from "../core/agents.ts";
19
+
20
+ export const meta = {
21
+ name: "agents",
22
+ summary: "Show each skill's deployment state across known agents (claude, codex, …) and scopes",
23
+ usage: "skl agents [name] [--agent <id>] [--project <dir>] [--json]",
24
+ } as const;
25
+
26
+ const GLYPH: Record<DeployState, string> = {
27
+ clean: "✓",
28
+ source: "⊙",
29
+ drift: "⚠",
30
+ copy: "□",
31
+ dead: "✗",
32
+ absent: "·",
33
+ };
34
+
35
+ const LEGEND = "legend: ✓ clean · ⊙ source · ⚠ drift · □ copy · ✗ dead · · absent";
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
+
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
+ }
55
+ try {
56
+ const rt = resolveReadTarget(argv);
57
+ if ("error" in rt) {
58
+ ctx.error(`skl agents: ${rt.error}`);
59
+ ctx.error("usage: " + meta.usage);
60
+ return 1;
61
+ }
62
+ const json = rt.rest.includes("--json");
63
+ const name = rt.rest.find((a) => !a.startsWith("--")) ?? null;
64
+
65
+ const lib = await ctx.loadLibrary();
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
+ ];
88
+ const report = await inventoryDeployments(surfaces, ctx.libraryPath, lib);
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
+ });
95
+
96
+ // --agent <id> focuses the whole report on that agent: prune the agents list
97
+ // and the deployments map to just that agent (skills not deployed to it drop).
98
+ if (rt.agentId) {
99
+ const id = rt.agentId;
100
+ const deployments: typeof agentsReport.deployments = {};
101
+ for (const [skill, byAgent] of Object.entries(agentsReport.deployments)) {
102
+ if (byAgent[id]) deployments[skill] = { [id]: byAgent[id] };
103
+ }
104
+ agentsReport = {
105
+ agents: agentsReport.agents.filter((a) => a.id === id),
106
+ scopes: agentsReport.scopes,
107
+ deployments,
108
+ };
109
+ }
110
+
111
+ if (json) {
112
+ if (name !== null) {
113
+ ctx.json({
114
+ name,
115
+ agents: agentsReport.agents,
116
+ scopes: agentsReport.scopes,
117
+ deployment: agentsReport.deployments[name] ?? {},
118
+ });
119
+ return 0;
120
+ }
121
+ ctx.json(agentsReport);
122
+ return 0;
123
+ }
124
+
125
+ // --- human view ------------------------------------------------------
126
+ const { agents, scopes, deployments } = agentsReport;
127
+ if (name !== null) {
128
+ const byAgent = deployments[name] ?? {};
129
+ ctx.log(`${name} — deployment across agents:`);
130
+ for (const a of agents) {
131
+ const dep = byAgent[a.id];
132
+ const g = dep?.g ?? "absent";
133
+ const projects = dep?.p
134
+ ? Object.entries(dep.p).map(([s, st]) => `${s} ${GLYPH[st]}`).join(" ")
135
+ : "";
136
+ const installed = a.installed ? "" : " (not installed)";
137
+ ctx.log(` ${a.short.padEnd(10)} ${GLYPH[g]} global·${g}${installed}${projects ? ` ${projects}` : ""}`);
138
+ }
139
+ ctx.log("");
140
+ ctx.log(LEGEND);
141
+ return 0;
142
+ }
143
+
144
+ ctx.log(`Agents (${agents.filter((a) => a.installed).length} installed) · scopes: ${scopes.join(", ")}`);
145
+ for (const a of agents) {
146
+ let clean = 0;
147
+ let problems = 0;
148
+ for (const byAgent of Object.values(deployments)) {
149
+ const dep = byAgent[a.id];
150
+ if (!dep) continue;
151
+ const states: DeployState[] = [dep.g ?? "absent", ...Object.values(dep.p ?? {})];
152
+ for (const st of states) {
153
+ if (st === "clean" || st === "source") clean++;
154
+ else if (st !== "absent") problems++;
155
+ }
156
+ }
157
+ const tag = a.installed ? "" : " (not installed)";
158
+ ctx.log(` ${a.short.padEnd(10)} ${a.global}${tag} — ${clean} clean, ${problems} need attention`);
159
+ }
160
+ ctx.log("");
161
+ ctx.log(LEGEND);
162
+ return 0;
163
+ } catch (err) {
164
+ ctx.error(`skl agents failed: ${err instanceof Error ? err.message : String(err)}`);
165
+ return 1;
166
+ }
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
+ }
@@ -5,12 +5,14 @@
5
5
  import { join } from "node:path";
6
6
  import type { Ctx } from "../types.ts";
7
7
  import { resolveBundle } from "../core/bundle.ts";
8
+ import { findByName } from "../core/library.ts";
9
+ import { parseDeployTarget } from "../core/agents.ts";
8
10
  import { isSymlink, realpathOrSelf, realpathOrSelfAsync, removeSymlink } from "../lib/fs.ts";
9
11
 
10
12
  export const meta = {
11
13
  name: "drop",
12
- summary: "Remove a bundle's symlinks from ./.claude/skills/",
13
- usage: "skl drop <bundle> [--json]",
14
+ summary: "Remove a bundle's (or skill's) symlinks from an agent's skills dir (default: ./.claude/skills/)",
15
+ usage: "skl drop <bundle|skill> [--agent <id>] [--global | --project <name>] [--json]",
14
16
  } as const;
15
17
 
16
18
  interface DropResult {
@@ -19,14 +21,17 @@ interface DropResult {
19
21
  status: "removed" | "absent" | "skipped";
20
22
  }
21
23
 
22
- function projectSkillsDir(): string {
23
- return join(process.cwd(), ".claude", "skills");
24
- }
25
-
26
24
  export async function run(argv: string[], ctx: Ctx): Promise<number> {
27
25
  try {
28
26
  const json = argv.includes("--json");
29
- const bundleName = argv.find((a) => !a.startsWith("-"));
27
+ const parsed = parseDeployTarget(argv);
28
+ if ("error" in parsed) {
29
+ ctx.error(`skl drop: ${parsed.error}`);
30
+ ctx.error("usage: " + meta.usage);
31
+ return 1;
32
+ }
33
+ const { positionals, target } = parsed;
34
+ const bundleName = positionals[0];
30
35
 
31
36
  if (!bundleName) {
32
37
  ctx.error("usage: " + meta.usage);
@@ -36,12 +41,15 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
36
41
  // Include retired so a bundle that was `use`d (which excludes retired) and a
37
42
  // later drop stay symmetric on the active set — we match on the same active set.
38
43
  const skills = await ctx.loadLibrary();
39
- const bundle = await resolveBundle(
40
- skills.filter((s) => !s.retired),
41
- bundleName,
42
- );
44
+ const active = skills.filter((s) => !s.retired);
45
+ // Mirror `use`: resolve a single skill name first, else a bundle, so
46
+ // `skl drop <skill>` undoes `skl use <skill>` symmetrically.
47
+ const single = findByName(active, bundleName);
48
+ const bundle = single
49
+ ? { name: single.name, skills: [single] }
50
+ : await resolveBundle(active, bundleName);
43
51
 
44
- const skillsDir = projectSkillsDir();
52
+ const skillsDir = target.dir;
45
53
  const results: DropResult[] = [];
46
54
 
47
55
  for (const s of bundle.skills) {
@@ -68,7 +76,7 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
68
76
  const removedCount = results.filter((r) => r.status === "removed").length;
69
77
 
70
78
  if (json) {
71
- ctx.json({ bundle: bundle.name, skillsDir, results, removed: removedCount });
79
+ ctx.json({ bundle: bundle.name, skillsDir, agent: target.agentId, scope: target.scope, results, removed: removedCount });
72
80
  } else {
73
81
  if (bundle.skills.length === 0) {
74
82
  ctx.log(`Bundle '${bundleName}' has no skills; nothing to drop.`);