skillshelf 0.6.1 → 0.7.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 +15 -9
- package/package.json +2 -2
- package/src/cli.ts +4 -0
- package/src/commands/add.ts +9 -9
- package/src/commands/diff.ts +78 -0
- package/src/commands/realign.ts +89 -0
- package/src/commands/rm.test.ts +24 -0
- package/src/commands/rm.ts +71 -52
- package/src/commands/use.ts +26 -8
- package/src/core/agent-matrix.test.ts +7 -7
- package/src/core/agents.test.ts +10 -10
- package/src/core/agents.ts +12 -12
- package/src/core/surfaces.ts +1 -1
- package/src/core/vendor.test.ts +15 -13
- package/src/core/vendor.ts +20 -19
package/README.md
CHANGED
|
@@ -115,7 +115,7 @@ skl import rnaseq-qc --from ~/projects/lab/.claude/skills/rnaseq-qc --force
|
|
|
115
115
|
|
|
116
116
|
# For a skill you actively develop in its own git repo, shelve a LINK instead of a copy —
|
|
117
117
|
# the repo stays canonical and edits show up live, no drift, no re-sync (ADR-0004):
|
|
118
|
-
skl link --from ~/
|
|
118
|
+
skl link --from ~/dev/headline-picker/skill/headline-picker
|
|
119
119
|
|
|
120
120
|
# 3. Tag the now-populated library in one pass. Domain is tags, not folders, so this
|
|
121
121
|
# runs AFTER import with no reorg — no skill ever has to move because a tag changed.
|
|
@@ -183,29 +183,35 @@ See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for the full design.
|
|
|
183
183
|
| `skl import <name> --from <path>` | Adopt your own skill into the library as an OWNED copy (move + symlink-back, or `--copy`) | `--copy`, `--as <slug>`, `--force` |
|
|
184
184
|
| `skl link [<name>] --from <dev-repo>` | Shelve a dev-repo skill as a LINKED entry (library symlinks to it; the repo stays canonical). `--at <path>` instead collapses a stray copy into the library | `--from`, `--at`, `--force` |
|
|
185
185
|
| `skl new <name>` | Scaffold a new skill dir + SKILL.md into the library | `--domain <d>`, `--desc "..."`, `--force` |
|
|
186
|
-
| `skl ls [bundle]` | One-line listing of the library, or one bundle (`--json` carries `mode`/`linkTarget`) | `--all` |
|
|
186
|
+
| `skl ls [bundle]` | One-line listing of the library, or one bundle (`--json` carries `mode`/`linkTarget`) | `--all`, `--sort modified\|name\|domain\|deploys\|source` |
|
|
187
187
|
| `skl search <kw...>` | Fuzzy match over name + description + domains across the library | — |
|
|
188
188
|
| `skl show <name>` | Print a skill's SKILL.md body; list reference-file paths (not contents) | — |
|
|
189
189
|
| `skl tag <name> <domain>…` | Add domain tag(s) to a skill in the central taxonomy (deterministic, no LLM) | — |
|
|
190
190
|
| `skl untag <name> <domain>` | Remove a domain tag from a skill | — |
|
|
191
191
|
| `skl retag <old> <new>` | Rename a domain across the whole library taxonomy (deterministic) | — |
|
|
192
192
|
| `skl rename <old> <new>` | Rename a skill slug atomically (dir + frontmatter + taxonomy + lock). Alias `skl mv` | — |
|
|
193
|
-
| `skl retire <name
|
|
194
|
-
| `skl unretire <name
|
|
195
|
-
| `skl rm <name
|
|
196
|
-
| `skl status` | Show which library skills are linked into `./.claude/skills
|
|
193
|
+
| `skl retire <name…>` | Soft-delete skill(s) into `_retired/` (reversible; excluded from deploys) | — |
|
|
194
|
+
| `skl unretire <name…>` | Restore retired skill(s) back to the active library | — |
|
|
195
|
+
| `skl rm <name…>` | Delete skill(s) (dir/symlink + taxonomy + lock), re-index. Refuses a live OWNED skill without `--force`; a LINKED entry `rm`s freely (safe unlink) | `--force`, `--dry-run` |
|
|
196
|
+
| `skl status` | Show which library skills are linked into an agent's project skills dir (default `./.claude/skills`); flags unmanaged real copies (drift-prone) | `--agent <id>`, `--project <dir>` |
|
|
197
197
|
| `skl where [name]` | Map where each skill is deployed across all agents (Claude, Codex, Cursor…); flags copies, drift, 2nd-sources, dead links — a dev repo a library entry links to shows as a clean `✓ source` | `--problems`, `--prune`, `--fix`, `--dry-run` |
|
|
198
|
-
| `skl
|
|
199
|
-
| `skl
|
|
198
|
+
| `skl agents [name]` | Per-agent × per-scope deployment matrix (one skill's row, or the whole library) | `--agent <id>`, `--project <dir>` |
|
|
199
|
+
| `skl use <bundle\|skill>…` | Symlink bundle(s)/skill(s) into an agent's skills dir (default `./.claude/skills/`, hot-loads). `--force` replaces a real-file conflict with the library symlink | `--agent <id>`, `--global \| --project <name>`, `--force` |
|
|
200
|
+
| `skl drop <bundle\|skill>…` | Remove bundle/skill symlinks from an agent's skills dir | `--agent <id>`, `--global \| --project <name>` |
|
|
201
|
+
| `skl realign <deployed-name>` | Rename an aliased deployment symlink (wrong name) to match its library skill | `--agent <id>`, `--global \| --project <name>` |
|
|
202
|
+
| `skl diff <name>` | Unified diff of a deployed copy's `SKILL.md` against the library skill (read-only) | `--agent <id>`, `--global \| --project <name>` |
|
|
200
203
|
| `skl refresh` | Re-sync this project's `./.claude/skills` symlinks to current library reality (repoint stale, prune vanished) | `--dry-run` |
|
|
204
|
+
| `skl projects` | Manage the persisted nav projects shown as scopes in the desktop app (`ls`/`add`/`rm`) | — |
|
|
201
205
|
| `skl add <src>` | Install third-party skill(s) into the library (librarian only — no agent-dir writes). One repo = **one clone**: a bare repo with several skills needs `--all`/`--skill`/`--list`; single-skill `add <repo>/<path>` is unchanged. `--all` installs the **published set** (the `.claude-plugin`/`marketplace.json` manifest allowlist when present, else every discovered skill; minus `metadata.internal`); an unpublished skill installs only via `--skill <name>`. A published set over **15** skills refuses without `--yes` (a count gate on blast radius; `--skill` is never gated). `--list` discovers + prints (marks `published`/`unpublished`); `--dry-run` previews drift (new/identical/differs); a `differs` skill is skipped without `--force` ([ADR-0012](./docs/adr/0012-published-set-and-all-count-gate.md)) | `--all`, `--skill <a,b>`, `--list`, `--yes`, `--dry-run`, `--domain <d>`, `--name <slug>`, `--no-infer`, `--force` |
|
|
206
|
+
| `skl track <name> --source <src>` | Adopt provenance for a library skill you already have (offline; no re-download). `skl untrack` drops the lock entry (idempotent inverse) | `--ref <r>`, `--resolve`, `--force` |
|
|
207
|
+
| `skl migrate` | Bulk-adopt provenance from a vendor skill-lock for skills already in your library | `--from <path>`, `--dry-run`, `--resolve`, `--force` |
|
|
202
208
|
| `skl outdated [name]` | Check upstream ref per tracked skill and mark stale ones (LINKED dev-repo entries are reported, never probed); `--check-local` diffs the local body against its baseline offline | `--check-local` |
|
|
203
209
|
| `skl update [name]` | Re-pull upstream body, preserve domain tags, diff if local body diverged (LINKED entries are skipped — their own git owns versioning) | `--force`, `--dry-run` |
|
|
204
210
|
| `skl index` | Regenerate `INDEX.md` (catalog grouped by domain) | — |
|
|
205
211
|
| `skl infer` | Re-run AI domain taxonomy over the library (emit/apply/provider modes) | see below |
|
|
206
212
|
|
|
207
213
|
Every command also accepts `--json`. Destructive/edit verbs (`rm`, `retire`/`unretire`,
|
|
208
|
-
`rename`, `tag`/`untag`/`retag`, `scan --remove-root`, `where --prune`/`--fix`,
|
|
214
|
+
`rename`, `realign`, `tag`/`untag`/`retag`, `scan --remove-root`, `where --prune`/`--fix`,
|
|
209
215
|
`refresh`) are the inverse + fine-grained-edit family from
|
|
210
216
|
[ADR-0005](./docs/adr/0005-inverse-and-edit-verbs.md): reversible by default, transactional
|
|
211
217
|
across the skill dir + `taxonomy.json` + `shelf.lock.json` + `INDEX.md`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skillshelf",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Agent-first skill registry + manager for Claude Code and compatible agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"skl": "./src/cli.ts"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
|
-
"preinstall": "bun --version >/dev/null 2>&1 || { echo '\\nskillshelf requires the Bun runtime (https://bun.sh)
|
|
28
|
+
"preinstall": "bun --version >/dev/null 2>&1 || { echo '\\nskillshelf requires the Bun runtime (https://bun.sh) \u2014 its bin is a TypeScript entrypoint, not a compiled Node script.\\nInstall Bun, then: bun add -g skillshelf (or run without installing: bunx skillshelf <command>)\\n' >&2; exit 1; }",
|
|
29
29
|
"skl": "bun run src/cli.ts",
|
|
30
30
|
"test": "bun test"
|
|
31
31
|
},
|
package/src/cli.ts
CHANGED
|
@@ -21,6 +21,8 @@ import * as show from "./commands/show.ts";
|
|
|
21
21
|
import * as index from "./commands/index.ts";
|
|
22
22
|
import * as use from "./commands/use.ts";
|
|
23
23
|
import * as drop from "./commands/drop.ts";
|
|
24
|
+
import * as realign from "./commands/realign.ts";
|
|
25
|
+
import * as diffCmd from "./commands/diff.ts";
|
|
24
26
|
import * as init from "./commands/init.ts";
|
|
25
27
|
import * as add from "./commands/add.ts";
|
|
26
28
|
import * as outdated from "./commands/outdated.ts";
|
|
@@ -56,6 +58,8 @@ const MODULES: CommandModule[] = [
|
|
|
56
58
|
show,
|
|
57
59
|
use,
|
|
58
60
|
drop,
|
|
61
|
+
realign,
|
|
62
|
+
diffCmd,
|
|
59
63
|
refresh,
|
|
60
64
|
add,
|
|
61
65
|
scan,
|
package/src/commands/add.ts
CHANGED
|
@@ -174,7 +174,6 @@ async function reportDryRun(
|
|
|
174
174
|
parsed: ParsedSource,
|
|
175
175
|
ref: string,
|
|
176
176
|
discovered: DiscoveredSkill[],
|
|
177
|
-
domainFolder: string | null,
|
|
178
177
|
): Promise<number> {
|
|
179
178
|
interface Row {
|
|
180
179
|
name: string;
|
|
@@ -189,7 +188,7 @@ async function reportDryRun(
|
|
|
189
188
|
rows.push({ name: d.name, subpath: d.subpath, verdict: "invalid", willInstall: false, needsForce: false });
|
|
190
189
|
continue;
|
|
191
190
|
}
|
|
192
|
-
const destDir = destDirFor(ctx.config.libraryPath,
|
|
191
|
+
const destDir = destDirFor(ctx.config.libraryPath, d.name);
|
|
193
192
|
if (writesThroughSymlink(ctx.config.libraryPath, destDir)) {
|
|
194
193
|
rows.push({ name: d.name, subpath: d.subpath, verdict: "linked", willInstall: false, needsForce: false });
|
|
195
194
|
continue;
|
|
@@ -240,10 +239,11 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
240
239
|
return 1;
|
|
241
240
|
}
|
|
242
241
|
|
|
243
|
-
// --domain validated once up front (applies to every install path).
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
242
|
+
// --domain validated once up front (applies to every install path). It is a taxonomy
|
|
243
|
+
// tag, never a folder — installs always land flat at library/<name> (ADR-0001).
|
|
244
|
+
const domain = flags.domain && flags.domain.trim() !== "" ? flags.domain.trim() : null;
|
|
245
|
+
if (domain !== null && !SLUG_RE.test(domain)) {
|
|
246
|
+
ctx.error(`add: invalid --domain "${domain}" — use lowercase letters, digits, and hyphens`);
|
|
247
247
|
return 1;
|
|
248
248
|
}
|
|
249
249
|
|
|
@@ -340,7 +340,7 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
340
340
|
: flags.skill !== null
|
|
341
341
|
? discovered.filter((d) => flags.skill!.includes(d.name))
|
|
342
342
|
: discovered;
|
|
343
|
-
return await reportDryRun(ctx, flags, parsed, ref, preview
|
|
343
|
+
return await reportDryRun(ctx, flags, parsed, ref, preview);
|
|
344
344
|
}
|
|
345
345
|
|
|
346
346
|
// ---- selection ----
|
|
@@ -395,7 +395,7 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
395
395
|
selected[0]!,
|
|
396
396
|
{
|
|
397
397
|
libraryPath: ctx.config.libraryPath,
|
|
398
|
-
|
|
398
|
+
domain,
|
|
399
399
|
nameOverride: flags.name,
|
|
400
400
|
sourceStr: sourceOf(selected[0]!),
|
|
401
401
|
ref,
|
|
@@ -474,7 +474,7 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
474
474
|
s,
|
|
475
475
|
{
|
|
476
476
|
libraryPath: ctx.config.libraryPath,
|
|
477
|
-
|
|
477
|
+
domain,
|
|
478
478
|
nameOverride: null,
|
|
479
479
|
sourceStr: sourceOf(s),
|
|
480
480
|
ref,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// `skl diff <name>` — unified diff between a DEPLOYED site's SKILL.md and the
|
|
2
|
+
// library skill's SKILL.md, for one agent surface. Read-only (an L2 fact per
|
|
3
|
+
// ADR-0007: two runs can't disagree). The engine verb behind the UI's drift
|
|
4
|
+
// "View diff" action. A clean symlink deployment trivially reports identical —
|
|
5
|
+
// its realpath IS the library copy.
|
|
6
|
+
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import type { Ctx } from "../types.ts";
|
|
10
|
+
import { parseDeployTarget } from "../core/agents.ts";
|
|
11
|
+
import { findByName } from "../core/library.ts";
|
|
12
|
+
import { readSkillBody, unifiedDiff } from "../core/fetch.ts";
|
|
13
|
+
import { isSymlink, realpathOrSelfAsync } from "../lib/fs.ts";
|
|
14
|
+
import { render, type CommandResult } from "../core/report.ts";
|
|
15
|
+
|
|
16
|
+
export const meta = {
|
|
17
|
+
name: "diff",
|
|
18
|
+
summary: "Unified diff of a deployed copy's SKILL.md against the library skill",
|
|
19
|
+
usage: "skl diff <name> [--agent <id>] [--global | --project <name>] [--json]",
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
23
|
+
try {
|
|
24
|
+
const json = argv.includes("--json");
|
|
25
|
+
const parsed = parseDeployTarget(argv);
|
|
26
|
+
if ("error" in parsed) {
|
|
27
|
+
ctx.error(`skl diff: ${parsed.error}`);
|
|
28
|
+
ctx.error("usage: " + meta.usage);
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
const { positionals, target } = parsed;
|
|
32
|
+
const name = positionals[0];
|
|
33
|
+
if (!name || positionals.length > 1) {
|
|
34
|
+
ctx.error("usage: " + meta.usage);
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const skills = await ctx.loadLibrary();
|
|
39
|
+
const skill = findByName(
|
|
40
|
+
skills.filter((s) => !s.retired),
|
|
41
|
+
name,
|
|
42
|
+
);
|
|
43
|
+
if (!skill) {
|
|
44
|
+
ctx.error(`skl diff: no library skill named "${name}"`);
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const site = join(target.dir, name);
|
|
49
|
+
if (!existsSync(site) && !isSymlink(site)) {
|
|
50
|
+
ctx.error(`skl diff: ${name} is not deployed at ${target.dir}`);
|
|
51
|
+
return 1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A symlink resolving to the library skill can't drift — same bytes.
|
|
55
|
+
const siteReal = await realpathOrSelfAsync(site);
|
|
56
|
+
const libReal = await realpathOrSelfAsync(skill.path);
|
|
57
|
+
const deployedBody = siteReal === libReal ? null : await readSkillBody(site);
|
|
58
|
+
const libraryBody = await readSkillBody(skill.path);
|
|
59
|
+
const text =
|
|
60
|
+
deployedBody === null
|
|
61
|
+
? ""
|
|
62
|
+
: await unifiedDiff(libraryBody, deployedBody, `library/${name}/SKILL.md`, `${site}/SKILL.md`);
|
|
63
|
+
|
|
64
|
+
const identical = text.trim() === "";
|
|
65
|
+
const result: CommandResult = {
|
|
66
|
+
json: { name, site, library: skill.path, identical, diff: text },
|
|
67
|
+
human: (emit) => {
|
|
68
|
+
if (identical) emit(`${name}: deployed copy matches the library (no drift)`);
|
|
69
|
+
else emit(text.trimEnd());
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
render(ctx, json, result);
|
|
73
|
+
return 0;
|
|
74
|
+
} catch (err) {
|
|
75
|
+
ctx.error(`skl diff failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
76
|
+
return 1;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// `skl realign <deployed-name>` — fix an ALIASED deployment: a symlink in an
|
|
2
|
+
// agent's skills dir that resolves to a library skill but carries the WRONG
|
|
3
|
+
// name (e.g. `nuwa` -> <library>/huashu-nuwa). Renames the link to the library
|
|
4
|
+
// skill's name so name-keyed views (status/agents) see it again. The engine
|
|
5
|
+
// verb behind the UI's "Realign name" remediation. Only ever renames a symlink
|
|
6
|
+
// in place — never touches the library or the link target.
|
|
7
|
+
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { rename } from "node:fs/promises";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import type { Ctx } from "../types.ts";
|
|
12
|
+
import { parseDeployTarget } from "../core/agents.ts";
|
|
13
|
+
import { isSymlink, realpathOrSelf, realpathOrSelfAsync } from "../lib/fs.ts";
|
|
14
|
+
import { render, type CommandResult } from "../core/report.ts";
|
|
15
|
+
|
|
16
|
+
export const meta = {
|
|
17
|
+
name: "realign",
|
|
18
|
+
summary: "Rename an aliased deployment symlink to match its library skill",
|
|
19
|
+
usage: "skl realign <deployed-name> [--agent <id>] [--global | --project <name>] [--json]",
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
23
|
+
try {
|
|
24
|
+
const json = argv.includes("--json");
|
|
25
|
+
const parsed = parseDeployTarget(argv);
|
|
26
|
+
if ("error" in parsed) {
|
|
27
|
+
ctx.error(`skl realign: ${parsed.error}`);
|
|
28
|
+
ctx.error("usage: " + meta.usage);
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
const { positionals, target } = parsed;
|
|
32
|
+
const deployedName = positionals[0];
|
|
33
|
+
if (!deployedName || positionals.length > 1) {
|
|
34
|
+
ctx.error("usage: " + meta.usage);
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const link = join(target.dir, deployedName);
|
|
39
|
+
if (!isSymlink(link)) {
|
|
40
|
+
ctx.error(
|
|
41
|
+
`skl realign: ${link} is not a symlink — realign only renames aliased links (a real copy is a different anomaly; see \`skl where\`)`,
|
|
42
|
+
);
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Resolve which library skill the link points at, by realpath — this also
|
|
47
|
+
// matches a LINKED library entry, whose canonical realpath is its external
|
|
48
|
+
// dev repo, exactly like the `where` classifier does.
|
|
49
|
+
const real = await realpathOrSelfAsync(link);
|
|
50
|
+
const skills = await ctx.loadLibrary();
|
|
51
|
+
const owner = skills.find((s) => !s.retired && realpathOrSelf(s.path) === real);
|
|
52
|
+
if (!owner) {
|
|
53
|
+
ctx.error(
|
|
54
|
+
`skl realign: ${link} does not resolve to any library skill (target: ${real}) — nothing to align to`,
|
|
55
|
+
);
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (owner.name === deployedName) {
|
|
60
|
+
const result: CommandResult = {
|
|
61
|
+
json: { from: deployedName, to: owner.name, link, status: "already" },
|
|
62
|
+
human: (emit) => emit(`${deployedName} already matches its library skill — nothing to do`),
|
|
63
|
+
};
|
|
64
|
+
render(ctx, json, result);
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const dest = join(target.dir, owner.name);
|
|
69
|
+
if (existsSync(dest) || isSymlink(dest)) {
|
|
70
|
+
ctx.error(
|
|
71
|
+
`skl realign: ${dest} already exists — remove one of the two deployments first (\`skl drop ${deployedName}\` keeps the aligned one)`,
|
|
72
|
+
);
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Atomic same-dir rename of the LINK itself (rename never dereferences).
|
|
77
|
+
await rename(link, dest);
|
|
78
|
+
|
|
79
|
+
const result: CommandResult = {
|
|
80
|
+
json: { from: deployedName, to: owner.name, link: dest, status: "realigned" },
|
|
81
|
+
human: (emit) => emit(`realigned ${deployedName} -> ${owner.name} in ${target.dir}`),
|
|
82
|
+
};
|
|
83
|
+
render(ctx, json, result);
|
|
84
|
+
return 0;
|
|
85
|
+
} catch (err) {
|
|
86
|
+
ctx.error(`skl realign failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/commands/rm.test.ts
CHANGED
|
@@ -166,6 +166,30 @@ describe("skl rm/retire/unretire — removal lifecycle (friction #1)", () => {
|
|
|
166
166
|
expect(errors.join("\n")).toContain("not in the library");
|
|
167
167
|
});
|
|
168
168
|
|
|
169
|
+
test("rm removes MULTIPLE names in one call (regression: batch must not drop names)", async () => {
|
|
170
|
+
await writeSkill(library, "beta");
|
|
171
|
+
await writeSkill(library, "gamma");
|
|
172
|
+
const { ctx } = makeCtx(library);
|
|
173
|
+
// Live OWNED skills need --force; the WHOLE batch must purge, not just the first
|
|
174
|
+
// (the old single-name rm silently ignored names 2..N).
|
|
175
|
+
const code = await rmRun(["alpha", "beta", "gamma", "--force"], ctx);
|
|
176
|
+
expect(code).toBe(0);
|
|
177
|
+
for (const n of ["alpha", "beta", "gamma"]) {
|
|
178
|
+
expect(existsSync(join(library, n))).toBe(false);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("rm batch is ATOMIC on validation — one missing name deletes nothing", async () => {
|
|
183
|
+
await writeSkill(library, "beta");
|
|
184
|
+
const { ctx, errors } = makeCtx(library);
|
|
185
|
+
const code = await rmRun(["alpha", "ghost", "beta", "--force"], ctx);
|
|
186
|
+
expect(code).toBe(1);
|
|
187
|
+
expect(errors.join("\n")).toContain("not in the library");
|
|
188
|
+
// No partial purge — both valid names survive because one name was invalid.
|
|
189
|
+
expect(existsSync(join(library, "alpha"))).toBe(true);
|
|
190
|
+
expect(existsSync(join(library, "beta"))).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
169
193
|
test("rm refuses a path-traversal name (no escape outside the library)", async () => {
|
|
170
194
|
// a sibling dir outside the library that must NOT be deletable via `../`
|
|
171
195
|
const victim = join(tmp, "victim");
|
package/src/commands/rm.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
// `skl rm <name
|
|
2
|
-
// a LINKED entry, just the symlink — the dev repo it points at is never touched, so
|
|
3
|
-
// this doubles as `unlink`) AND drop its taxonomy + lockfile entries, then re-index.
|
|
1
|
+
// `skl rm <name>...` — delete skill(s) from the library entirely: remove each dir (or,
|
|
2
|
+
// for a LINKED entry, just the symlink — the dev repo it points at is never touched, so
|
|
3
|
+
// this doubles as `unlink`) AND drop its taxonomy + lockfile entries, then re-index ONCE.
|
|
4
4
|
// Before this, the ONLY delete path was an unguarded `rm -rf` against the library —
|
|
5
5
|
// no safety rail for the most destructive lifecycle step.
|
|
6
6
|
//
|
|
7
|
-
// skl rm <name
|
|
7
|
+
// skl rm <name>... [--force] [--dry-run] [--json]
|
|
8
|
+
//
|
|
9
|
+
// Batch: multiple names delete in one call (reindex collapses to one, mirroring
|
|
10
|
+
// `retire`). Validation is ATOMIC — if any name is missing or a live OWNED skill without
|
|
11
|
+
// --force, the whole batch refuses and nothing is deleted (never a partial purge).
|
|
8
12
|
//
|
|
9
13
|
// Safety (mirrors `link --at` refuse-by-default + named escape hatch): a LIVE (active,
|
|
10
14
|
// non-retired) skill is refused without --force — retire it first (reversible) or pass
|
|
@@ -12,15 +16,15 @@
|
|
|
12
16
|
// it purges without --force. --dry-run previews exactly what would be removed.
|
|
13
17
|
|
|
14
18
|
import type { Ctx } from "../types.ts";
|
|
15
|
-
import { locateEntry, removeSkill, reindexLibrary } from "../core/lifecycle.ts";
|
|
19
|
+
import { locateEntry, removeSkill, reindexLibrary, type RemoveResult } from "../core/lifecycle.ts";
|
|
16
20
|
import { readTaxonomy } from "../core/taxonomy.ts";
|
|
17
21
|
import { readLockfile } from "../core/provenance.ts";
|
|
18
22
|
import { render, type CommandResult } from "../core/report.ts";
|
|
19
23
|
|
|
20
24
|
export const meta = {
|
|
21
25
|
name: "rm",
|
|
22
|
-
summary: "Delete
|
|
23
|
-
usage: "skl rm <name
|
|
26
|
+
summary: "Delete skill(s) from the library (dir/symlink + taxonomy + lock), re-index",
|
|
27
|
+
usage: "skl rm <name>... [--force] [--dry-run] [--json]",
|
|
24
28
|
} as const;
|
|
25
29
|
|
|
26
30
|
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
@@ -35,64 +39,71 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
35
39
|
ctx.error(`usage: ${meta.usage}`);
|
|
36
40
|
return 1;
|
|
37
41
|
}
|
|
38
|
-
const
|
|
39
|
-
if (
|
|
42
|
+
const names = argv.filter((a) => !a.startsWith("--"));
|
|
43
|
+
if (names.length === 0) {
|
|
40
44
|
ctx.error("skl rm: a <name> is required");
|
|
41
45
|
ctx.error(`usage: ${meta.usage}`);
|
|
42
46
|
return 1;
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
try {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
// Locate + guard EVERY name up front. The batch is ATOMIC on validation: if any
|
|
51
|
+
// name is missing, or is a live OWNED skill without --force (destroys real bytes),
|
|
52
|
+
// refuse the WHOLE batch and delete nothing — never a partial purge.
|
|
53
|
+
const located = names.map((name) => ({ name, loc: locateEntry(ctx.libraryPath, name) }));
|
|
54
|
+
|
|
55
|
+
const notFound = located.filter((x) => !x.loc.path).map((x) => x.name);
|
|
56
|
+
if (notFound.length) {
|
|
57
|
+
// Not-found stays a plain error (no --json body), matching the prior single-name path.
|
|
58
|
+
for (const n of notFound) ctx.error(`skl rm: '${n}' is not in the library`);
|
|
49
59
|
return 1;
|
|
50
60
|
}
|
|
51
|
-
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (loc.active && !loc.isLink && !force) {
|
|
60
|
-
// Refusal is an ERROR path: the human side prints via ctx.error (not ctx.log),
|
|
61
|
-
// so it stays in run(); only the --json payload routes through render().
|
|
61
|
+
|
|
62
|
+
// Refuse only a live OWNED skill unless forced — that destroys real bytes. A LINKED
|
|
63
|
+
// entry is just a symlink (removing it loses nothing); a purely-retired skill is
|
|
64
|
+
// already in the reversible holding area — both purge freely. Gate on `loc.active`
|
|
65
|
+
// itself (NOT `!loc.retired`) so a skill present in BOTH active and _retired keeps
|
|
66
|
+
// the guard on its live copy.
|
|
67
|
+
const refused = located.filter((x) => x.loc.active && !x.loc.isLink && !force).map((x) => x.name);
|
|
68
|
+
if (refused.length) {
|
|
62
69
|
if (json) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
// Preserve the single-name payload shape; use a list form for a batch.
|
|
71
|
+
const body =
|
|
72
|
+
names.length === 1
|
|
73
|
+
? { ok: false, name: names[0], refused: true, reason: "live-owned-needs-force" }
|
|
74
|
+
: { ok: false, refused, reason: "live-owned-needs-force" };
|
|
75
|
+
render(ctx, json, { json: body, human: () => {} });
|
|
68
76
|
} else {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
for (const n of refused) {
|
|
78
|
+
ctx.error(`skl rm: '${n}' is a live skill — this destroys real bytes.`);
|
|
79
|
+
ctx.error("Retire it first (reversible): skl retire " + n);
|
|
80
|
+
ctx.error("Or hard-purge now: skl rm " + n + " --force");
|
|
81
|
+
}
|
|
72
82
|
}
|
|
73
83
|
return 1;
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
// Preview what would be dropped (for --dry-run and richer reporting).
|
|
77
|
-
const tax = await readTaxonomy(ctx.libraryPath);
|
|
78
|
-
const lock = await readLockfile(ctx.libraryPath);
|
|
79
|
-
const plan = {
|
|
80
|
-
name,
|
|
81
|
-
path: loc.path,
|
|
82
|
-
mode: loc.isLink ? ("linked" as const) : ("owned" as const),
|
|
83
|
-
wasRetired: loc.retired && !loc.active,
|
|
84
|
-
taxonomyEntry: name in tax.skills,
|
|
85
|
-
lockEntry: name in lock.entries,
|
|
86
|
-
};
|
|
87
|
-
|
|
88
87
|
if (dryRun) {
|
|
88
|
+
const tax = await readTaxonomy(ctx.libraryPath);
|
|
89
|
+
const lock = await readLockfile(ctx.libraryPath);
|
|
90
|
+
const plans = located.map(({ name, loc }) => ({
|
|
91
|
+
name,
|
|
92
|
+
path: loc.path,
|
|
93
|
+
mode: loc.isLink ? ("linked" as const) : ("owned" as const),
|
|
94
|
+
wasRetired: loc.retired && !loc.active,
|
|
95
|
+
taxonomyEntry: name in tax.skills,
|
|
96
|
+
lockEntry: name in lock.entries,
|
|
97
|
+
}));
|
|
89
98
|
const result: CommandResult = {
|
|
90
|
-
json: { ok: true, dryRun: true, plan },
|
|
99
|
+
json: names.length === 1 ? { ok: true, dryRun: true, plan: plans[0] } : { ok: true, dryRun: true, plans },
|
|
91
100
|
human: (emit) => {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
101
|
+
for (const plan of plans) {
|
|
102
|
+
emit(`DRY RUN — would remove '${plan.name}':`);
|
|
103
|
+
emit(` path: ${plan.path}${plan.mode === "linked" ? " (symlink only — dev repo untouched)" : ""}`);
|
|
104
|
+
emit(` taxonomy: ${plan.taxonomyEntry ? "drop entry" : "none"}`);
|
|
105
|
+
emit(` lockfile: ${plan.lockEntry ? "drop entry" : "none"}`);
|
|
106
|
+
}
|
|
96
107
|
emit("Re-run without --dry-run to apply.");
|
|
97
108
|
},
|
|
98
109
|
};
|
|
@@ -100,14 +111,22 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
100
111
|
return 0;
|
|
101
112
|
}
|
|
102
113
|
|
|
103
|
-
|
|
114
|
+
// Apply: remove each, then re-index ONCE at the end (a per-name reindex is the cost
|
|
115
|
+
// a bulk rm avoids — collapse it to one, mirroring bulkLifecycle).
|
|
116
|
+
const removed: RemoveResult[] = [];
|
|
117
|
+
for (const { name } of located) {
|
|
118
|
+
removed.push(await removeSkill(ctx.libraryPath, name));
|
|
119
|
+
}
|
|
104
120
|
await reindexLibrary(ctx.libraryPath);
|
|
121
|
+
|
|
105
122
|
const result: CommandResult = {
|
|
106
|
-
json: { ok: true, ...removed },
|
|
123
|
+
json: names.length === 1 ? { ok: true, ...removed[0] } : { ok: true, removed },
|
|
107
124
|
human: (emit) => {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
125
|
+
for (const r of removed) {
|
|
126
|
+
emit(`removed ${r.name}${r.wasLink ? " (symlink only — dev repo untouched)" : ""}`);
|
|
127
|
+
if (r.taxonomyDropped) emit(" dropped taxonomy entry");
|
|
128
|
+
if (r.lockDropped) emit(" dropped lockfile entry");
|
|
129
|
+
}
|
|
111
130
|
},
|
|
112
131
|
};
|
|
113
132
|
render(ctx, json, result);
|
package/src/commands/use.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// library crawl, one symlink pass); the single-name shape is byte-identical to before.
|
|
6
6
|
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
-
import { mkdir } from "node:fs/promises";
|
|
8
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
9
9
|
import type { Ctx, Skill } from "../types.ts";
|
|
10
10
|
import { resolveBundle } from "../core/bundle.ts";
|
|
11
11
|
import { activeSkills, findByName } from "../core/library.ts";
|
|
@@ -16,20 +16,25 @@ import { render, type CommandResult } from "../core/report.ts";
|
|
|
16
16
|
export const meta = {
|
|
17
17
|
name: "use",
|
|
18
18
|
summary: "Symlink bundle(s)/skill(s) into an agent's skills dir (default: ./.claude/skills/)",
|
|
19
|
-
usage: "skl use <bundle|skill>... [--agent <id>] [--global | --project <name>] [--json]",
|
|
19
|
+
usage: "skl use <bundle|skill>... [--agent <id>] [--global | --project <name>] [--force] [--json]",
|
|
20
20
|
} as const;
|
|
21
21
|
|
|
22
22
|
interface LinkResult {
|
|
23
23
|
name: string;
|
|
24
24
|
target: string;
|
|
25
25
|
link: string;
|
|
26
|
-
status: "linked" | "already" | "conflict";
|
|
26
|
+
status: "linked" | "already" | "conflict" | "overwritten";
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
30
30
|
try {
|
|
31
31
|
const json = argv.includes("--json");
|
|
32
|
-
|
|
32
|
+
// --force: a real (non-symlink) file/dir occupying a slot is REPLACED by the
|
|
33
|
+
// library symlink instead of reported as a conflict — the UI's "Overwrite
|
|
34
|
+
// from library" drift remediation. Consumed here; parseDeployTarget rejects
|
|
35
|
+
// unknown flags.
|
|
36
|
+
const force = argv.includes("--force");
|
|
37
|
+
const parsed = parseDeployTarget(argv.filter((a) => a !== "--force"));
|
|
33
38
|
if ("error" in parsed) {
|
|
34
39
|
ctx.error(`skl use: ${parsed.error}`);
|
|
35
40
|
ctx.error("usage: " + meta.usage);
|
|
@@ -118,9 +123,16 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
118
123
|
const want = await realpathOrSelfAsync(skillPath);
|
|
119
124
|
if (cur === want) status = "already";
|
|
120
125
|
} else if (await pathTakenNonLink(link)) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
126
|
+
if (!force) {
|
|
127
|
+
// A real (non-symlink) file/dir occupies the slot — don't clobber it.
|
|
128
|
+
results.push({ name: s.name, target: skillPath, link, status: "conflict" });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
// --force: replace the real entry with the library symlink. `link` is
|
|
132
|
+
// always join(skillsDir, <library skill name>), so the removal cannot
|
|
133
|
+
// reach outside the agent's skills dir.
|
|
134
|
+
await rm(link, { recursive: true, force: true });
|
|
135
|
+
status = "overwritten";
|
|
124
136
|
}
|
|
125
137
|
|
|
126
138
|
await safeSymlink(skillPath, link);
|
|
@@ -139,7 +151,13 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
139
151
|
emit(`${label} -> ${skillsDir}`);
|
|
140
152
|
for (const r of results) {
|
|
141
153
|
const tag =
|
|
142
|
-
r.status === "linked"
|
|
154
|
+
r.status === "linked"
|
|
155
|
+
? "linked"
|
|
156
|
+
: r.status === "already"
|
|
157
|
+
? "ok"
|
|
158
|
+
: r.status === "overwritten"
|
|
159
|
+
? "overwritten (real file replaced)"
|
|
160
|
+
: "SKIP (real file present)";
|
|
143
161
|
emit(` ${r.name} [${tag}]`);
|
|
144
162
|
}
|
|
145
163
|
for (const u of unresolved) {
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from "./agent-matrix.ts";
|
|
15
15
|
|
|
16
16
|
const HOME = "/home/u";
|
|
17
|
-
const IDS = ["claude", "codex", "cursor", "opencode", "gemini", "
|
|
17
|
+
const IDS = ["claude", "codex", "cursor", "opencode", "gemini", "pi"] as const;
|
|
18
18
|
|
|
19
19
|
function site(p: Partial<DeploymentSite> & { name: string; surface: string }): DeploymentSite {
|
|
20
20
|
return {
|
|
@@ -31,9 +31,9 @@ function site(p: Partial<DeploymentSite> & { name: string; surface: string }): D
|
|
|
31
31
|
describe("agentIdForSurface honors `ids` ordering", () => {
|
|
32
32
|
test("returns the FIRST matching id in the given order (custom precedence)", () => {
|
|
33
33
|
// A surface that could match two ids: the caller's order decides the winner.
|
|
34
|
-
const s = "/work/proj/.
|
|
35
|
-
expect(agentIdForSurface(s, ["
|
|
36
|
-
//
|
|
34
|
+
const s = "/work/proj/.walrus/skills";
|
|
35
|
+
expect(agentIdForSurface(s, ["walrus", "claude"])).toBe("walrus");
|
|
36
|
+
// walrus not in the id set → no match (the engine never widened to it).
|
|
37
37
|
expect(agentIdForSurface(s, ["claude", "codex"])).toBeNull();
|
|
38
38
|
});
|
|
39
39
|
test("matches segment, trailing dotdir, and trailing /skills", () => {
|
|
@@ -146,8 +146,8 @@ describe("foldAgentMatrix", () => {
|
|
|
146
146
|
});
|
|
147
147
|
|
|
148
148
|
test("custom-agent surface detected via a widened id set", () => {
|
|
149
|
-
const sites = [site({ name: "x", surface: "/work/proj/.
|
|
150
|
-
const m = foldAgentMatrix(sites, { home: HOME, agentIds: [...IDS, "
|
|
151
|
-
expect(m.deployments.x!.
|
|
149
|
+
const sites = [site({ name: "x", surface: "/work/proj/.walrus/skills", kind: "linked" })];
|
|
150
|
+
const m = foldAgentMatrix(sites, { home: HOME, agentIds: [...IDS, "walrus"] });
|
|
151
|
+
expect(m.deployments.x!.walrus!.p!.proj).toBe("clean");
|
|
152
152
|
});
|
|
153
153
|
});
|
package/src/core/agents.test.ts
CHANGED
|
@@ -113,7 +113,7 @@ describe("computeAgentsReport", () => {
|
|
|
113
113
|
const r = computeAgentsReport(report, HOME);
|
|
114
114
|
|
|
115
115
|
test("registers the 6 known agents (installed=false under a fake HOME)", () => {
|
|
116
|
-
expect(r.agents.map((a) => a.id)).toEqual(["claude", "codex", "cursor", "opencode", "gemini", "
|
|
116
|
+
expect(r.agents.map((a) => a.id)).toEqual(["claude", "codex", "cursor", "opencode", "gemini", "pi"]);
|
|
117
117
|
expect(r.agents.every((a) => a.installed === false)).toBe(true);
|
|
118
118
|
expect(r.agents[0]!.global).toBe("~/.claude/skills");
|
|
119
119
|
});
|
|
@@ -138,9 +138,9 @@ describe("computeAgentsReport — opts (ADR-0010)", () => {
|
|
|
138
138
|
problems: [],
|
|
139
139
|
};
|
|
140
140
|
|
|
141
|
-
test("opts.agents appends a custom agent (
|
|
141
|
+
test("opts.agents appends a custom agent (walrus) after the seeds", () => {
|
|
142
142
|
const r = computeAgentsReport(baseReport, HOME, {
|
|
143
|
-
agents: [{ id: "
|
|
143
|
+
agents: [{ id: "walrus", name: "Walrus Agent", short: "Walrus", icon: "anthropic" }],
|
|
144
144
|
});
|
|
145
145
|
expect(r.agents.map((a) => a.id)).toEqual([
|
|
146
146
|
"claude",
|
|
@@ -148,8 +148,8 @@ describe("computeAgentsReport — opts (ADR-0010)", () => {
|
|
|
148
148
|
"cursor",
|
|
149
149
|
"opencode",
|
|
150
150
|
"gemini",
|
|
151
|
-
"omp",
|
|
152
151
|
"pi",
|
|
152
|
+
"walrus",
|
|
153
153
|
]);
|
|
154
154
|
});
|
|
155
155
|
|
|
@@ -177,17 +177,17 @@ describe("computeAgentsReport — opts (ADR-0010)", () => {
|
|
|
177
177
|
const r = computeAgentsReport(
|
|
178
178
|
{
|
|
179
179
|
surfaces: [],
|
|
180
|
-
sites: [site({ name: "x", surface: "/work/proj/.
|
|
180
|
+
sites: [site({ name: "x", surface: "/work/proj/.walrus/skills", kind: "linked" })],
|
|
181
181
|
problems: [],
|
|
182
182
|
},
|
|
183
183
|
HOME,
|
|
184
|
-
{ agents: [{ id: "
|
|
184
|
+
{ agents: [{ id: "walrus", name: "Walrus", short: "Walrus" }] },
|
|
185
185
|
);
|
|
186
|
-
expect(r.deployments.x!.
|
|
186
|
+
expect(r.deployments.x!.walrus!.p!.proj).toBe("clean");
|
|
187
187
|
});
|
|
188
188
|
test("no opts is backward-compatible (seed-only, default extraScopes)", () => {
|
|
189
189
|
const r = computeAgentsReport(baseReport, HOME);
|
|
190
|
-
expect(r.agents.map((a) => a.id)).toEqual(["claude", "codex", "cursor", "opencode", "gemini", "
|
|
190
|
+
expect(r.agents.map((a) => a.id)).toEqual(["claude", "codex", "cursor", "opencode", "gemini", "pi"]);
|
|
191
191
|
});
|
|
192
192
|
|
|
193
193
|
test("inheritsGlobal: all seeds default true; report carries the flag", () => {
|
|
@@ -199,11 +199,11 @@ describe("computeAgentsReport — opts (ADR-0010)", () => {
|
|
|
199
199
|
test("inheritsGlobal: a custom agent defaults true unless inheritsGlobal:false", () => {
|
|
200
200
|
const r = computeAgentsReport(baseReport, HOME, {
|
|
201
201
|
agents: [
|
|
202
|
-
{ id: "
|
|
202
|
+
{ id: "walrus", name: "Walrus", short: "Walrus" }, // no flag → inherits (true)
|
|
203
203
|
{ id: "static", name: "Static", short: "Static", inheritsGlobal: false },
|
|
204
204
|
],
|
|
205
205
|
});
|
|
206
|
-
expect(r.agents.find((a) => a.id === "
|
|
206
|
+
expect(r.agents.find((a) => a.id === "walrus")!.inheritsGlobal).toBe(true);
|
|
207
207
|
expect(r.agents.find((a) => a.id === "static")!.inheritsGlobal).toBe(false);
|
|
208
208
|
});
|
|
209
209
|
|
package/src/core/agents.ts
CHANGED
|
@@ -72,22 +72,22 @@ interface AgentSeed {
|
|
|
72
72
|
short: string;
|
|
73
73
|
}
|
|
74
74
|
// ENGINE seed list — the full set of agents `skl` can DETECT on disk (claude,
|
|
75
|
-
// codex, cursor, opencode, gemini,
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
// flow through config (delta 4) on both sides. If you
|
|
82
|
-
// deliberately whether the app list should follow
|
|
83
|
-
// AGENT_SEEDS in app/src/lib/agents.ts.
|
|
75
|
+
// codex, cursor, opencode, gemini, pi). `pi` is now a first-class engine-seeded
|
|
76
|
+
// agent, matching the app-side seed list in `app/src/lib/agents.ts` (claude,
|
|
77
|
+
// codex, pi), which shows only the agents a given user actually deploys to and
|
|
78
|
+
// reconstructs the report in the browser dev fallback. The two lists are NOT
|
|
79
|
+
// meant to match exactly: the engine errs toward broad detection (cursor,
|
|
80
|
+
// opencode, gemini included), the app toward the user's real surfaces, and
|
|
81
|
+
// custom/overridden agents flow through config (delta 4) on both sides. If you
|
|
82
|
+
// add/remove an id here, decide deliberately whether the app list should follow
|
|
83
|
+
// — see the matching comment over AGENT_SEEDS in app/src/lib/agents.ts.
|
|
84
84
|
const AGENT_SEEDS: AgentSeed[] = [
|
|
85
85
|
{ id: "claude", name: "Claude Code", short: "Claude" },
|
|
86
86
|
{ id: "codex", name: "Codex", short: "Codex" },
|
|
87
87
|
{ id: "cursor", name: "Cursor", short: "Cursor" },
|
|
88
88
|
{ id: "opencode", name: "OpenCode", short: "OpenCode" },
|
|
89
89
|
{ id: "gemini", name: "Gemini", short: "Gemini" },
|
|
90
|
-
{ id: "
|
|
90
|
+
{ id: "pi", name: "Pi", short: "Pi" },
|
|
91
91
|
];
|
|
92
92
|
|
|
93
93
|
const AGENT_IDS = AGENT_SEEDS.map((a) => a.id);
|
|
@@ -105,9 +105,9 @@ export function agentIdForSurface(
|
|
|
105
105
|
return agentIdForSurfaceCore(surface, ids);
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
/** Relative subdirectory under .<id>/ where skills live (agent/skills for
|
|
108
|
+
/** Relative subdirectory under .<id>/ where skills live (agent/skills for pi, skills otherwise). */
|
|
109
109
|
function skillsSuffix(id: string): string {
|
|
110
|
-
return id === "
|
|
110
|
+
return id === "pi" ? join("agent", "skills") : "skills";
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
/** Absolute path to an agent's global skills dir. */
|
package/src/core/surfaces.ts
CHANGED
|
@@ -37,7 +37,7 @@ export function knownAgentSurfaces(home: string = homedir()): AgentSurface[] {
|
|
|
37
37
|
// matrix advertises must have its global surface scanned, or `installed`
|
|
38
38
|
// can disagree with the (empty) matrix. See agents.test.ts coverage check.
|
|
39
39
|
{ agent: "gemini", path: join(home, ".gemini", "skills") },
|
|
40
|
-
{ agent: "
|
|
40
|
+
{ agent: "pi", path: join(home, ".pi", "agent", "skills") },
|
|
41
41
|
];
|
|
42
42
|
}
|
|
43
43
|
|
package/src/core/vendor.test.ts
CHANGED
|
@@ -49,7 +49,7 @@ async function makeStagedSkill(parent: string, name: string, body?: string): Pro
|
|
|
49
49
|
function installOpts(libraryPath: string, over: Partial<InstallOptions> = {}): InstallOptions {
|
|
50
50
|
return {
|
|
51
51
|
libraryPath,
|
|
52
|
-
|
|
52
|
+
domain: null,
|
|
53
53
|
nameOverride: null,
|
|
54
54
|
sourceStr: "github:owner/repo",
|
|
55
55
|
ref: "abc123",
|
|
@@ -146,13 +146,14 @@ describe("vendor.installSkill — copy + provenance + verdict", () => {
|
|
|
146
146
|
expect(o.reason).toContain("not overwriting");
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
-
test("
|
|
149
|
+
test("--domain tags the taxonomy but the skill lands FLAT (ADR-0001: tags, not folders)", async () => {
|
|
150
150
|
const skill = await makeStagedSkill(tmp, "foo");
|
|
151
|
-
const o = await installSkill(skill, installOpts(library, {
|
|
151
|
+
const o = await installSkill(skill, installOpts(library, { domain: "data" }));
|
|
152
152
|
expect(o.status).toBe("installed");
|
|
153
|
-
expect(o.path).toBe(join(library, "
|
|
153
|
+
expect(o.path).toBe(join(library, "foo"));
|
|
154
154
|
expect(o.domains).toEqual(["data"]);
|
|
155
|
-
expect(existsSync(join(library, "
|
|
155
|
+
expect(existsSync(join(library, "foo", "SKILL.md"))).toBe(true);
|
|
156
|
+
expect(existsSync(join(library, "data"))).toBe(false);
|
|
156
157
|
});
|
|
157
158
|
|
|
158
159
|
test("nameOverride renames the install slug", async () => {
|
|
@@ -214,16 +215,18 @@ describe("vendor.installSkill — guard suite", () => {
|
|
|
214
215
|
expect(devBody).toContain("DEV REPO");
|
|
215
216
|
});
|
|
216
217
|
|
|
217
|
-
test("
|
|
218
|
-
// <library>/ext -> /external
|
|
218
|
+
test("--domain never routes the write through a same-named symlinked dir (regression: flat layout)", async () => {
|
|
219
|
+
// <library>/ext -> /external. Pre-ADR-0001 leftovers made --domain ext write into
|
|
220
|
+
// library/ext/foo (through the symlink); flat layout must land at library/foo.
|
|
219
221
|
const external = join(tmp, "external");
|
|
220
222
|
await mkdir(external, { recursive: true });
|
|
221
223
|
await symlink(external, join(library, "ext"));
|
|
222
224
|
|
|
223
225
|
const skill = await makeStagedSkill(tmp, "foo");
|
|
224
|
-
const o = await installSkill(skill, installOpts(library, {
|
|
225
|
-
expect(o.status).toBe("
|
|
226
|
-
expect(o.
|
|
226
|
+
const o = await installSkill(skill, installOpts(library, { domain: "ext", multi: true }));
|
|
227
|
+
expect(o.status).toBe("installed");
|
|
228
|
+
expect(o.path).toBe(join(library, "foo"));
|
|
229
|
+
expect(o.domains).toEqual(["ext"]);
|
|
227
230
|
expect(existsSync(join(external, "foo"))).toBe(false);
|
|
228
231
|
});
|
|
229
232
|
});
|
|
@@ -241,9 +244,8 @@ describe("vendor guard primitives (pure-ish, fs-anchored)", () => {
|
|
|
241
244
|
await rm(tmp, { recursive: true, force: true });
|
|
242
245
|
});
|
|
243
246
|
|
|
244
|
-
test("destDirFor
|
|
245
|
-
expect(destDirFor("/lib",
|
|
246
|
-
expect(destDirFor("/lib", "data", "foo")).toBe(join("/lib", "data", "foo"));
|
|
247
|
+
test("destDirFor is always flat under the library root", () => {
|
|
248
|
+
expect(destDirFor("/lib", "foo")).toBe(join("/lib", "foo"));
|
|
247
249
|
});
|
|
248
250
|
|
|
249
251
|
test("nearestExisting climbs to the closest on-disk ancestor", () => {
|
package/src/core/vendor.ts
CHANGED
|
@@ -59,9 +59,9 @@ export function bodyOf(text: string): string {
|
|
|
59
59
|
// GUARD SUITE — the checks every library-write path funnels through, ONCE.
|
|
60
60
|
// ---------------------------------------------------------------------------
|
|
61
61
|
|
|
62
|
-
/** The library destination dir for a slug
|
|
63
|
-
export function destDirFor(libraryPath: string,
|
|
64
|
-
return
|
|
62
|
+
/** The library destination dir for a slug — always flat `library/<name>` (ADR-0001). */
|
|
63
|
+
export function destDirFor(libraryPath: string, name: string): string {
|
|
64
|
+
return join(libraryPath, name);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/** The nearest ancestor of `p` (incl. `p`) that exists on disk; falls back to `p`. */
|
|
@@ -78,7 +78,7 @@ export function nearestExisting(p: string): string {
|
|
|
78
78
|
/**
|
|
79
79
|
* True if writing to `destDir` would resolve OUTSIDE the library — i.e. the nearest
|
|
80
80
|
* existing component on the way to destDir is (or is reached through) a symlink whose
|
|
81
|
-
* realpath escapes the library. Catches a symlinked
|
|
81
|
+
* realpath escapes the library. Catches a symlinked ancestor dir (`library/<d> ->
|
|
82
82
|
* /external`) that the leaf-only `isSymlink(destDir)` check misses, so `--force` can't
|
|
83
83
|
* clobber an external tree through a symlinked parent (ADR-0004). Both sides anchor to
|
|
84
84
|
* their nearest existing ancestor, so a fresh (not-yet-created) library is not a false
|
|
@@ -93,8 +93,8 @@ export function destEscapesLibrary(libraryPath: string, destDir: string): boolea
|
|
|
93
93
|
/**
|
|
94
94
|
* The symlink-escape guard: would writing `destDir` go THROUGH a symlink into something
|
|
95
95
|
* the library doesn't own — a LINKED leaf entry, OR a destination reached through a
|
|
96
|
-
* symlinked ANCESTOR
|
|
97
|
-
*
|
|
96
|
+
* symlinked ANCESTOR whose realpath escapes the library? Writing through either
|
|
97
|
+
* clobbers an external dev repo, even with --force
|
|
98
98
|
* (ADR-0004). The two add/dry-run sites that combined `isSymlink(destDir) ||
|
|
99
99
|
* destEscapesLibrary(...)` inline now share this one predicate.
|
|
100
100
|
*/
|
|
@@ -108,8 +108,8 @@ export function writesThroughSymlink(libraryPath: string, destDir: string): bool
|
|
|
108
108
|
* beside a tombstone would strand a duplicate and break `skl unretire`, so add/import/
|
|
109
109
|
* link all refuse it regardless of --force (force overwrites an ACTIVE copy, not a
|
|
110
110
|
* retired one). Each command keeps its own bespoke error wording + exit handling; this
|
|
111
|
-
* is the shared PREDICATE they all branch on. Checked against the flat library root
|
|
112
|
-
*
|
|
111
|
+
* is the shared PREDICATE they all branch on. Checked against the flat library root;
|
|
112
|
+
* name-validated via entryStatus.
|
|
113
113
|
*/
|
|
114
114
|
export function isRetiredOnly(libraryPath: string, name: string): boolean {
|
|
115
115
|
const status = entryStatus(libraryPath, name);
|
|
@@ -179,7 +179,8 @@ async function maybeInferTags(
|
|
|
179
179
|
|
|
180
180
|
export interface InstallOptions {
|
|
181
181
|
libraryPath: string;
|
|
182
|
-
|
|
182
|
+
/** taxonomy tag to apply after install — never a folder (ADR-0001) */
|
|
183
|
+
domain: string | null;
|
|
183
184
|
/** single-skill --name override; ignored in multi mode */
|
|
184
185
|
nameOverride: string | null;
|
|
185
186
|
/** per-skill lockfile `source` string (already carries the @subpath / #subpath) */
|
|
@@ -258,7 +259,7 @@ export async function installSkill(
|
|
|
258
259
|
// (<library>/_retired/<name>), do NOT install a fresh active copy beside it — that
|
|
259
260
|
// strands a duplicate and breaks `skl unretire`. The user must unretire first. This
|
|
260
261
|
// fires regardless of --force (force overwrites an ACTIVE copy, not a retired one).
|
|
261
|
-
// Checked against the flat library root
|
|
262
|
+
// Checked against the flat library root.
|
|
262
263
|
if (isRetiredOnly(opts.libraryPath, rawName)) {
|
|
263
264
|
return {
|
|
264
265
|
...base,
|
|
@@ -268,14 +269,14 @@ export async function installSkill(
|
|
|
268
269
|
};
|
|
269
270
|
}
|
|
270
271
|
|
|
271
|
-
const destDir = destDirFor(opts.libraryPath,
|
|
272
|
+
const destDir = destDirFor(opts.libraryPath, rawName);
|
|
272
273
|
const verdict = await driftVerdict(skill, destDir);
|
|
273
274
|
base.verdict = verdict;
|
|
274
275
|
|
|
275
276
|
// Never copy THROUGH a symlink into something the library doesn't own: a LINKED leaf
|
|
276
|
-
// entry, OR a destination reached through a symlinked ANCESTOR
|
|
277
|
-
//
|
|
278
|
-
//
|
|
277
|
+
// entry, OR a destination reached through a symlinked ANCESTOR whose realpath escapes
|
|
278
|
+
// the library. Writing through either would clobber an external dev repo, even with
|
|
279
|
+
// --force (ADR-0004).
|
|
279
280
|
const throughSymlink = writesThroughSymlink(opts.libraryPath, destDir);
|
|
280
281
|
|
|
281
282
|
if (opts.multi) {
|
|
@@ -308,7 +309,7 @@ export async function installSkill(
|
|
|
308
309
|
}
|
|
309
310
|
|
|
310
311
|
// ---- write into the library ----
|
|
311
|
-
await ensureDir(opts.
|
|
312
|
+
await ensureDir(opts.libraryPath);
|
|
312
313
|
await copySkillDir(skill.dir, destDir);
|
|
313
314
|
|
|
314
315
|
const installedBody = bodyOf(await readSkillBody(skill.dir));
|
|
@@ -327,8 +328,8 @@ export async function installSkill(
|
|
|
327
328
|
const installed: Skill = {
|
|
328
329
|
name: rawName,
|
|
329
330
|
description: skill.description,
|
|
330
|
-
primaryDomain: opts.
|
|
331
|
-
domains: opts.
|
|
331
|
+
primaryDomain: opts.domain,
|
|
332
|
+
domains: opts.domain ? [opts.domain] : [],
|
|
332
333
|
path: destDir,
|
|
333
334
|
bodyPath: join(destDir, "SKILL.md"),
|
|
334
335
|
refFiles: [],
|
|
@@ -337,14 +338,14 @@ export async function installSkill(
|
|
|
337
338
|
mirrorOf: null,
|
|
338
339
|
contentHash: "",
|
|
339
340
|
};
|
|
340
|
-
if (opts.
|
|
341
|
+
if (opts.domain) await setDomainsForName(opts.libraryPath, rawName, [opts.domain]);
|
|
341
342
|
|
|
342
343
|
let inferred: string[] | null = null;
|
|
343
344
|
if (opts.infer) {
|
|
344
345
|
inferred = await maybeInferTags(installed, warn);
|
|
345
346
|
if (inferred && inferred.length > 0) await setDomainsForName(opts.libraryPath, rawName, inferred);
|
|
346
347
|
}
|
|
347
|
-
const domains = inferred && inferred.length > 0 ? inferred : opts.
|
|
348
|
+
const domains = inferred && inferred.length > 0 ? inferred : opts.domain ? [opts.domain] : [];
|
|
348
349
|
|
|
349
350
|
return {
|
|
350
351
|
...base,
|