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
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
|
+

|
|
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
|
+

|
|
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
|
|
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/
|
|
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. `
|
|
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
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
|
+
});
|
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
|
+
}
|