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 CHANGED
@@ -24,6 +24,31 @@ skillshelf is the middle path: a single git-backed **library** that is a *passiv
24
24
  project needs, exactly when it needs them. Find anything in one place; pay only for what you
25
25
  actually use.
26
26
 
27
+ ## Desktop app
28
+
29
+ A cross-platform desktop UI (React + Tauri) sits on top of the same engine — a **Library-first**
30
+ workbench for managing where every skill is deployed across your agents. It reads the **real**
31
+ `skl` library (no separate data store) and every toggle writes the same symlinks the CLI does.
32
+
33
+ ![skillshelf — Library view](docs/images/main.png)
34
+
35
+ - **Library-first, skill-centric list** — scan all skills, flip them on/off per agent inline; a
36
+ top **scope switcher** (Global · each project · + Add project) and a per-scope count bar.
37
+ - **Project scopes** — manage a project's loadout without leaving the app; globally-deployed
38
+ skills show an *"active via Global"* inherited state so you always know what's effectively live.
39
+ - **Two-tier toggles** — a clean on/off for the happy path, but drift / copy / dead / aliased
40
+ surface a resolve flow instead of silently doing the wrong thing (state is derived from the
41
+ filesystem, never stored — so the UI can't lie about what's deployed).
42
+
43
+ ![skillshelf — skill detail drawer](docs/images/detail.png)
44
+
45
+ The detail drawer shows a skill's body, tags, provenance, and an `agent × scope` deployment
46
+ matrix (Global + the projects where it's pinned), plus lifecycle actions.
47
+
48
+ > Browser/dev mode (`cd app && bun run dev`) renders synthetic fixtures with no backend; the
49
+ > packaged desktop app talks to your real `skl` engine. See [`docs/adr/0010-*`](docs/adr/) for the
50
+ > design.
51
+
27
52
  ## Install
28
53
 
29
54
  skillshelf runs on [Bun](https://bun.sh) (>= 1.0). No other runtime dependencies.
@@ -85,7 +110,7 @@ skl scan # report every candidate + duplicate/drift group
85
110
  # 2. Adopt the ones you want, one at a time. Each import moves the skill into the
86
111
  # library and leaves a symlink behind so old paths keep resolving.
87
112
  skl import rnaseq-qc --from ~/.claude/skills/rnaseq-qc
88
- skl import xhs-title --from ~/notes/.agents/skills/xhs-title
113
+ skl import headline-picker --from ~/notes/.agents/skills/headline-picker
89
114
 
90
115
  # For a skill living inside a project repo, copy instead of move (no symlink left behind):
91
116
  skl import deploy-check --from ~/projects/web/.claude/skills/deploy-check --copy
@@ -95,7 +120,7 @@ skl import rnaseq-qc --from ~/projects/lab/.claude/skills/rnaseq-qc --force
95
120
 
96
121
  # For a skill you actively develop in its own git repo, shelve a LINK instead of a copy —
97
122
  # the repo stays canonical and edits show up live, no drift, no re-sync (ADR-0004):
98
- skl link --from ~/Documents/GitHub/cairn/skill/cairn
123
+ skl link --from ~/Documents/GitHub/claim-log/skill/claim-log
99
124
 
100
125
  # 3. Tag the now-populated library in one pass. Domain is tags, not folders, so this
101
126
  # runs AFTER import with no reorg — no skill ever has to move because a tag changed.
@@ -126,7 +151,7 @@ skillshelf separates *owning* a skill from *loading* it.
126
151
  - **Owned vs linked entries** ([ADR-0004](./docs/adr/0004-owned-vs-linked-entries.md)) — the
127
152
  library is a *bookshelf*: an entry either **owns** its bytes (a real copy; the library is
128
153
  canonical — for downloads and stabilized skills) or is **linked** (a symlink to an external dev
129
- repo that stays canonical — for skills you actively develop in their own git, e.g. `cairn`).
154
+ repo that stays canonical — for skills you actively develop in their own git, e.g. `claim-log`).
130
155
  `skl link --from <dev-repo>` registers a linked entry; `skl where` shows it as a clean
131
156
  `✓ source`; `skl update` / `outdated` skip linked entries so they never pull upstream into your
132
157
  dev repo. The mode is derived from the filesystem (a symlink resolving outside the library),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillshelf",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Agent-first skill registry + manager for Claude Code and compatible agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.ts CHANGED
@@ -29,8 +29,12 @@ import * as infer from "./commands/infer.ts";
29
29
  import * as newCmd from "./commands/new.ts";
30
30
  import * as scan from "./commands/scan.ts";
31
31
  import * as roots from "./commands/roots.ts";
32
+ import * as projects from "./commands/projects.ts";
32
33
  import * as importCmd from "./commands/import.ts";
33
34
  import * as link from "./commands/link.ts";
35
+ import * as track from "./commands/track.ts";
36
+ import * as untrack from "./commands/untrack.ts";
37
+ import * as migrate from "./commands/migrate.ts";
34
38
  import * as where from "./commands/where.ts";
35
39
  import * as agents from "./commands/agents.ts";
36
40
  import * as tag from "./commands/tag.ts";
@@ -56,8 +60,12 @@ const MODULES: CommandModule[] = [
56
60
  add,
57
61
  scan,
58
62
  roots,
63
+ projects,
59
64
  importCmd,
60
65
  link,
66
+ track,
67
+ untrack,
68
+ migrate,
61
69
  tag,
62
70
  untag,
63
71
  retag,
@@ -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
+ });
@@ -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 type { Ctx } from "../types.ts";
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 { computeAgentsReport, resolveReadTarget, type DeployState } from "../core/agents.ts";
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
- // Same surface union as `skl where` (+ any --project surfaces for this call) so
44
- // the agent matrix sees the same reality and can verify an ad-hoc project deploy.
45
- const surfaces = [...ctx.roots, ctx.config.globalCoreTarget, ...knownAgentSurfacePaths(), ...rt.extraSurfaces];
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
- let agentsReport = computeAgentsReport(report);
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
+ }