skillshelf 0.2.0 → 0.3.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 +57 -19
- package/package.json +8 -2
- package/src/adapters/inference/agent.ts +23 -16
- package/src/cli.ts +31 -0
- package/src/commands/add.ts +624 -128
- package/src/commands/agents.ts +120 -0
- package/src/commands/drop.ts +21 -13
- package/src/commands/import.ts +44 -28
- package/src/commands/infer.ts +6 -6
- package/src/commands/link.test.ts +160 -0
- package/src/commands/link.ts +317 -0
- package/src/commands/ls.ts +118 -18
- package/src/commands/mode-surfacing.test.ts +110 -0
- package/src/commands/outdated.test.ts +55 -0
- package/src/commands/outdated.ts +138 -18
- package/src/commands/refresh.ts +133 -0
- package/src/commands/remediation.test.ts +149 -0
- package/src/commands/rename.test.ts +121 -0
- package/src/commands/rename.ts +64 -0
- package/src/commands/retag.ts +58 -0
- package/src/commands/retire.ts +39 -0
- package/src/commands/rm.test.ts +133 -0
- package/src/commands/rm.ts +107 -0
- package/src/commands/roots.ts +41 -0
- package/src/commands/scan.ts +122 -30
- package/src/commands/show.ts +4 -1
- package/src/commands/status.ts +43 -8
- package/src/commands/tag.test.ts +109 -0
- package/src/commands/tag.ts +68 -0
- package/src/commands/unretire.ts +33 -0
- package/src/commands/untag.ts +73 -0
- package/src/commands/update.test.ts +71 -0
- package/src/commands/update.ts +65 -15
- package/src/commands/use.test.ts +92 -0
- package/src/commands/use.ts +46 -23
- package/src/commands/where.ts +232 -0
- package/src/config.test.ts +69 -0
- package/src/config.ts +79 -10
- package/src/core/agents.test.ts +232 -0
- package/src/core/agents.ts +363 -0
- package/src/core/bundle.ts +12 -15
- package/src/core/core.test.ts +14 -1
- package/src/core/crawl.ts +22 -5
- package/src/core/dedupe.ts +36 -0
- package/src/core/deployments.test.ts +147 -0
- package/src/core/deployments.ts +208 -0
- package/src/core/fetch.ts +344 -70
- package/src/core/indexgen.ts +2 -0
- package/src/core/library.test.ts +41 -0
- package/src/core/library.ts +61 -16
- package/src/core/lifecycle.ts +252 -0
- package/src/core/surfaces.ts +46 -0
- package/src/core/taxonomy.test.ts +159 -0
- package/src/core/taxonomy.ts +190 -0
- package/src/lib/fs.ts +2 -2
- package/src/types.ts +85 -15
- package/src/core/overlay.ts +0 -63
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, rm, realpath } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { run as useRun } from "./use.ts";
|
|
6
|
+
import { run as dropRun } from "./drop.ts";
|
|
7
|
+
import { loadLibrary } from "../core/library.ts";
|
|
8
|
+
import type { Ctx } from "../types.ts";
|
|
9
|
+
|
|
10
|
+
function makeCtx(libraryPath: string) {
|
|
11
|
+
const json: unknown[] = [];
|
|
12
|
+
const ctx = {
|
|
13
|
+
config: { libraryPath },
|
|
14
|
+
libraryPath,
|
|
15
|
+
loadLibrary: () => loadLibrary(libraryPath),
|
|
16
|
+
log: () => {},
|
|
17
|
+
error: () => {},
|
|
18
|
+
json: (v: unknown) => json.push(v),
|
|
19
|
+
} as unknown as Ctx;
|
|
20
|
+
return { ctx, json };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function writeSkill(library: string, name: string, domain: string) {
|
|
24
|
+
const dir = join(library, name);
|
|
25
|
+
await mkdir(dir, { recursive: true });
|
|
26
|
+
await writeFile(join(dir, "SKILL.md"), `---\nname: ${name}\ndescription: ${name}\ndomains: [${domain}]\n---\n\nbody\n`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("skl use/drop — single-skill deploy (friction #2)", () => {
|
|
30
|
+
let tmp: string;
|
|
31
|
+
let library: string;
|
|
32
|
+
let project: string;
|
|
33
|
+
let prevCwd: string;
|
|
34
|
+
|
|
35
|
+
beforeEach(async () => {
|
|
36
|
+
tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-use-")));
|
|
37
|
+
library = join(tmp, "library");
|
|
38
|
+
project = join(tmp, "project");
|
|
39
|
+
await mkdir(project, { recursive: true });
|
|
40
|
+
await writeSkill(library, "alpha", "bio");
|
|
41
|
+
await writeSkill(library, "beta", "bio");
|
|
42
|
+
prevCwd = process.cwd();
|
|
43
|
+
process.chdir(project);
|
|
44
|
+
});
|
|
45
|
+
afterEach(async () => {
|
|
46
|
+
process.chdir(prevCwd);
|
|
47
|
+
await rm(tmp, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("`use <skill>` deploys exactly one skill (kind: skill)", async () => {
|
|
51
|
+
const { ctx, json } = makeCtx(library);
|
|
52
|
+
const code = await useRun(["alpha", "--json"], ctx);
|
|
53
|
+
expect(code).toBe(0);
|
|
54
|
+
const out = json[0] as { kind: string; linked: Array<{ name: string; status: string }> };
|
|
55
|
+
expect(out.kind).toBe("skill");
|
|
56
|
+
expect(out.linked).toHaveLength(1);
|
|
57
|
+
expect(out.linked[0]!.name).toBe("alpha");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("`use <bundle>` still resolves the whole tag query (kind: bundle)", async () => {
|
|
61
|
+
const { ctx, json } = makeCtx(library);
|
|
62
|
+
await useRun(["bio", "--json"], ctx);
|
|
63
|
+
const out = json[0] as { kind: string; linked: unknown[] };
|
|
64
|
+
expect(out.kind).toBe("bundle");
|
|
65
|
+
expect(out.linked).toHaveLength(2);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("a skill name shadows bundle resolution (skill-first)", async () => {
|
|
69
|
+
// name a skill the same as no domain — exact-name match wins
|
|
70
|
+
const { ctx, json } = makeCtx(library);
|
|
71
|
+
await useRun(["beta", "--json"], ctx);
|
|
72
|
+
expect((json[0] as { kind: string }).kind).toBe("skill");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("`drop <skill>` undoes `use <skill>` symmetrically", async () => {
|
|
76
|
+
const { ctx } = makeCtx(library);
|
|
77
|
+
await useRun(["alpha", "--json"], ctx);
|
|
78
|
+
const { ctx: ctx2, json } = makeCtx(library);
|
|
79
|
+
const code = await dropRun(["alpha", "--json"], ctx2);
|
|
80
|
+
expect(code).toBe(0);
|
|
81
|
+
const out = json[0] as { results: Array<{ status: string }>; removed: number };
|
|
82
|
+
expect(out.removed).toBe(1);
|
|
83
|
+
expect(out.results[0]!.status).toBe("removed");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("unknown name is a clean empty-bundle error, not a crash", async () => {
|
|
87
|
+
const { ctx, json } = makeCtx(library);
|
|
88
|
+
const code = await useRun(["does-not-exist", "--json"], ctx);
|
|
89
|
+
expect(code).toBe(1);
|
|
90
|
+
expect((json[0] as { error: string }).error).toBe("empty-bundle");
|
|
91
|
+
});
|
|
92
|
+
});
|
package/src/commands/use.ts
CHANGED
|
@@ -3,15 +3,17 @@
|
|
|
3
3
|
// links without error. Reports what was linked (and is JSON-parseable on --json).
|
|
4
4
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import
|
|
6
|
+
import { mkdir } from "node:fs/promises";
|
|
7
|
+
import type { Ctx, Skill } from "../types.ts";
|
|
7
8
|
import { resolveBundle } from "../core/bundle.ts";
|
|
8
|
-
import { activeSkills } from "../core/library.ts";
|
|
9
|
+
import { activeSkills, findByName } from "../core/library.ts";
|
|
10
|
+
import { parseDeployTarget } from "../core/agents.ts";
|
|
9
11
|
import { safeSymlink, isSymlink, realpathOrSelf, realpathOrSelfAsync } from "../lib/fs.ts";
|
|
10
12
|
|
|
11
13
|
export const meta = {
|
|
12
14
|
name: "use",
|
|
13
|
-
summary: "Symlink a bundle's skills
|
|
14
|
-
usage: "skl use <bundle> [--json]",
|
|
15
|
+
summary: "Symlink a bundle (or skill) into an agent's skills dir (default: ./.claude/skills/)",
|
|
16
|
+
usage: "skl use <bundle|skill> [--agent <id>] [--global | --project <name>] [--json]",
|
|
15
17
|
} as const;
|
|
16
18
|
|
|
17
19
|
interface LinkResult {
|
|
@@ -21,15 +23,17 @@ interface LinkResult {
|
|
|
21
23
|
status: "linked" | "already" | "conflict";
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
/** Project skills dir for the cwd. */
|
|
25
|
-
function projectSkillsDir(): string {
|
|
26
|
-
return join(process.cwd(), ".claude", "skills");
|
|
27
|
-
}
|
|
28
|
-
|
|
29
26
|
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
30
27
|
try {
|
|
31
28
|
const json = argv.includes("--json");
|
|
32
|
-
const
|
|
29
|
+
const parsed = parseDeployTarget(argv);
|
|
30
|
+
if ("error" in parsed) {
|
|
31
|
+
ctx.error(`skl use: ${parsed.error}`);
|
|
32
|
+
ctx.error("usage: " + meta.usage);
|
|
33
|
+
return 1;
|
|
34
|
+
}
|
|
35
|
+
const { positionals, target } = parsed;
|
|
36
|
+
const bundleName = positionals[0];
|
|
33
37
|
|
|
34
38
|
if (!bundleName) {
|
|
35
39
|
ctx.error("usage: " + meta.usage);
|
|
@@ -37,53 +41,72 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
const skills = await ctx.loadLibrary();
|
|
40
|
-
const
|
|
44
|
+
const active = activeSkills(skills);
|
|
45
|
+
|
|
46
|
+
// Resolve the arg as a SINGLE SKILL first (exact name), then fall back to a
|
|
47
|
+
// bundle (a domain tag query). This makes `skl use <skill>` a first-class
|
|
48
|
+
// single-skill deploy instead of erroring 'empty-bundle' and forcing a hand
|
|
49
|
+
// `ln -s` — the exact manual symlink skillshelf exists to eliminate.
|
|
50
|
+
let resolved: { name: string; kind: "skill" | "bundle"; skills: Skill[] };
|
|
51
|
+
const single = findByName(active, bundleName);
|
|
52
|
+
if (single) {
|
|
53
|
+
resolved = { name: single.name, kind: "skill", skills: [single] };
|
|
54
|
+
} else {
|
|
55
|
+
const bundle = await resolveBundle(active, bundleName);
|
|
56
|
+
resolved = { name: bundle.name, kind: "bundle", skills: bundle.skills };
|
|
57
|
+
}
|
|
41
58
|
|
|
42
|
-
if (
|
|
59
|
+
if (resolved.skills.length === 0) {
|
|
43
60
|
if (json) {
|
|
44
|
-
ctx.json({ bundle: bundleName, linked: [], skillsDir:
|
|
61
|
+
ctx.json({ bundle: bundleName, kind: "bundle", linked: [], skillsDir: target.dir, error: "empty-bundle" });
|
|
45
62
|
} else {
|
|
46
|
-
ctx.error(`No active
|
|
63
|
+
ctx.error(`No active skill or bundle matches '${bundleName}'.`);
|
|
47
64
|
}
|
|
48
65
|
return 1;
|
|
49
66
|
}
|
|
50
67
|
|
|
51
|
-
const
|
|
68
|
+
const bundle = { name: resolved.name, skills: resolved.skills };
|
|
69
|
+
const skillsDir = target.dir;
|
|
70
|
+
// The target agent's skills dir may not exist yet (e.g. a fresh ~/.codex/skills).
|
|
71
|
+
await mkdir(skillsDir, { recursive: true });
|
|
52
72
|
const results: LinkResult[] = [];
|
|
53
73
|
|
|
54
74
|
for (const s of bundle.skills) {
|
|
55
75
|
const link = join(skillsDir, s.name);
|
|
56
|
-
const
|
|
76
|
+
const skillPath = s.path;
|
|
57
77
|
let status: LinkResult["status"] = "linked";
|
|
58
78
|
|
|
59
79
|
// Determine prior state for accurate reporting before we touch it.
|
|
60
80
|
if (isSymlink(link)) {
|
|
61
81
|
const cur = realpathOrSelf(link);
|
|
62
|
-
const want = await realpathOrSelfAsync(
|
|
82
|
+
const want = await realpathOrSelfAsync(skillPath);
|
|
63
83
|
if (cur === want) status = "already";
|
|
64
84
|
} else if (await pathTakenNonLink(link)) {
|
|
65
85
|
// A real (non-symlink) file/dir occupies the slot — don't clobber it.
|
|
66
|
-
results.push({ name: s.name, target, link, status: "conflict" });
|
|
86
|
+
results.push({ name: s.name, target: skillPath, link, status: "conflict" });
|
|
67
87
|
continue;
|
|
68
88
|
}
|
|
69
89
|
|
|
70
|
-
await safeSymlink(
|
|
71
|
-
results.push({ name: s.name, target, link, status });
|
|
90
|
+
await safeSymlink(skillPath, link);
|
|
91
|
+
results.push({ name: s.name, target: skillPath, link, status });
|
|
72
92
|
}
|
|
73
93
|
|
|
74
94
|
const conflicts = results.filter((r) => r.status === "conflict");
|
|
75
95
|
|
|
76
96
|
if (json) {
|
|
77
|
-
ctx.json({ bundle: bundle.name, skillsDir, linked: results });
|
|
97
|
+
ctx.json({ bundle: bundle.name, kind: resolved.kind, skillsDir, agent: target.agentId, scope: target.scope, linked: results });
|
|
78
98
|
} else {
|
|
79
|
-
|
|
99
|
+
const label = resolved.kind === "skill" ? `Skill '${bundle.name}'` : `Bundle '${bundle.name}'`;
|
|
100
|
+
ctx.log(`${label} -> ${skillsDir}`);
|
|
80
101
|
for (const r of results) {
|
|
81
102
|
const tag =
|
|
82
103
|
r.status === "linked" ? "linked" : r.status === "already" ? "ok" : "SKIP (real file present)";
|
|
83
104
|
ctx.log(` ${r.name} [${tag}]`);
|
|
84
105
|
}
|
|
85
106
|
ctx.log("");
|
|
86
|
-
|
|
107
|
+
if (target.scope !== "Global") {
|
|
108
|
+
ctx.log(`Reminder: add '${target.agentId === "claude" ? ".claude" : "." + target.agentId}/skills/' to this project's .gitignore so these symlinks aren't committed.`);
|
|
109
|
+
}
|
|
87
110
|
}
|
|
88
111
|
|
|
89
112
|
return conflicts.length > 0 ? 1 : 0;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// `skl where [name]` — the deployment map: where is each library skill actually
|
|
2
|
+
// deployed across every surface tools read skills from, and what's a mess?
|
|
3
|
+
//
|
|
4
|
+
// Skillshelf's founding job: many skills × many tools (Claude Code, Codex, …) that
|
|
5
|
+
// scatter copies/symlinks nobody can track. `status` only sees the cwd project;
|
|
6
|
+
// `scan` is root-centric. `where` is skill-centric across all surfaces, computed
|
|
7
|
+
// from reality (no stored state). It only REPORTS — it never mutates.
|
|
8
|
+
//
|
|
9
|
+
// skl where full map + a flagged "problems" section with fixes
|
|
10
|
+
// skl where <name> one skill: every place it's deployed, classified
|
|
11
|
+
// skl where --problems only the non-clean rows
|
|
12
|
+
// skl where --json structured DeploymentReport
|
|
13
|
+
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import type { Ctx, DeploymentSite } from "../types.ts";
|
|
17
|
+
import {
|
|
18
|
+
inventoryDeployments,
|
|
19
|
+
suggestionFor,
|
|
20
|
+
remediationFor,
|
|
21
|
+
type RemediationAction,
|
|
22
|
+
} from "../core/deployments.ts";
|
|
23
|
+
import { knownAgentSurfacePaths } from "../core/surfaces.ts";
|
|
24
|
+
import { resolveReadTarget } from "../core/agents.ts";
|
|
25
|
+
import { safeSymlink, removeSymlink } from "../lib/fs.ts";
|
|
26
|
+
|
|
27
|
+
export const meta = {
|
|
28
|
+
name: "where",
|
|
29
|
+
summary: "Show where each library skill is deployed across all surfaces (copies, symlinks, drift)",
|
|
30
|
+
// NB: --project is read-only on its own, but `--project <dir> --fix/--prune`
|
|
31
|
+
// WILL remediate problems found in that injected surface (intended: verify-and-fix
|
|
32
|
+
// a deploy you just made).
|
|
33
|
+
usage: "skl where [name] [--agent <id>] [--project <dir>] [--problems] [--prune | --fix] [--dry-run] [--json]",
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
const HOME = homedir();
|
|
37
|
+
/** Shorten an absolute path under $HOME to ~ for display. */
|
|
38
|
+
function tilde(p: string): string {
|
|
39
|
+
return p === HOME ? "~" : p.startsWith(HOME + "/") ? "~" + p.slice(HOME.length) : p;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Short human label for a site's classification. */
|
|
43
|
+
function labelFor(s: DeploymentSite): string {
|
|
44
|
+
switch (s.kind) {
|
|
45
|
+
case "linked":
|
|
46
|
+
return "✓ linked";
|
|
47
|
+
case "source":
|
|
48
|
+
return "✓ source";
|
|
49
|
+
case "dead":
|
|
50
|
+
return "✗ dead link";
|
|
51
|
+
case "foreign-link":
|
|
52
|
+
return "⚠ 2nd-source";
|
|
53
|
+
case "aliased":
|
|
54
|
+
return "⚠ aliased link";
|
|
55
|
+
case "copy":
|
|
56
|
+
if (!s.inLibrary) return "⚠ untracked copy";
|
|
57
|
+
return s.drift ? "⚠ drifted copy" : "⚠ redundant copy";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface Args {
|
|
62
|
+
name: string | null;
|
|
63
|
+
problems: boolean;
|
|
64
|
+
prune: boolean;
|
|
65
|
+
fix: boolean;
|
|
66
|
+
dryRun: boolean;
|
|
67
|
+
json: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseArgs(argv: string[]): { args: Args } | { error: string } {
|
|
71
|
+
const args: Args = { name: null, problems: false, prune: false, fix: false, dryRun: false, json: false };
|
|
72
|
+
for (const a of argv) {
|
|
73
|
+
if (a === "--problems") args.problems = true;
|
|
74
|
+
else if (a === "--prune") args.prune = true;
|
|
75
|
+
else if (a === "--fix") args.fix = true;
|
|
76
|
+
else if (a === "--dry-run") args.dryRun = true;
|
|
77
|
+
else if (a === "--json") args.json = true;
|
|
78
|
+
else if (a.startsWith("--")) return { error: `unknown argument: ${a}` };
|
|
79
|
+
else if (args.name === null) args.name = a;
|
|
80
|
+
else return { error: `unexpected extra argument: ${a}` };
|
|
81
|
+
}
|
|
82
|
+
if (args.prune && args.fix) {
|
|
83
|
+
return { error: "--prune and --fix are mutually exclusive (--fix includes --prune's dead-link cleanup)" };
|
|
84
|
+
}
|
|
85
|
+
return { args };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface FixOutcome {
|
|
89
|
+
name: string;
|
|
90
|
+
path: string;
|
|
91
|
+
action: RemediationAction;
|
|
92
|
+
applied: boolean;
|
|
93
|
+
note: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Apply where's own remediation to the flagged sites and return per-site outcomes.
|
|
98
|
+
* --prune handles only dead links; --fix also dedupes content-identical copies to a
|
|
99
|
+
* symlink into the library. `manual` problems (drift / 2nd-source / untracked) are
|
|
100
|
+
* NEVER auto-resolved — they carry a real decision; we report them with the existing
|
|
101
|
+
* suggestion so the loop is closed but judgment stays with the human/agent.
|
|
102
|
+
*/
|
|
103
|
+
export async function remediate(
|
|
104
|
+
sites: DeploymentSite[],
|
|
105
|
+
libraryPath: string,
|
|
106
|
+
opts: { fix: boolean; dryRun: boolean },
|
|
107
|
+
): Promise<FixOutcome[]> {
|
|
108
|
+
const out: FixOutcome[] = [];
|
|
109
|
+
for (const s of sites) {
|
|
110
|
+
const action = remediationFor(s);
|
|
111
|
+
if (action === "remove-dead") {
|
|
112
|
+
if (!opts.dryRun) await removeSymlink(s.path, { force: true });
|
|
113
|
+
out.push({ name: s.name, path: s.path, action, applied: !opts.dryRun, note: opts.dryRun ? "would remove dead link" : "removed dead link" });
|
|
114
|
+
} else if (action === "dedupe-copy" && opts.fix) {
|
|
115
|
+
if (!opts.dryRun) await safeSymlink(join(libraryPath, s.name), s.path, { force: true });
|
|
116
|
+
out.push({ name: s.name, path: s.path, action, applied: !opts.dryRun, note: opts.dryRun ? "would replace identical copy with a symlink into the library" : "deduped copy -> symlink into library" });
|
|
117
|
+
} else {
|
|
118
|
+
// manual (or dedupe-copy under --prune): not auto-applied.
|
|
119
|
+
out.push({ name: s.name, path: s.path, action: "manual", applied: false, note: suggestionFor(s) });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
126
|
+
// Extract --agent/--project first (read-side targeting), then parse where's own
|
|
127
|
+
// flags from what remains.
|
|
128
|
+
const rt = resolveReadTarget(argv);
|
|
129
|
+
if ("error" in rt) {
|
|
130
|
+
ctx.error(`skl where: ${rt.error}`);
|
|
131
|
+
ctx.error(`usage: ${meta.usage}`);
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
const parsed = parseArgs(rt.rest);
|
|
135
|
+
if ("error" in parsed) {
|
|
136
|
+
ctx.error(`skl where: ${parsed.error}`);
|
|
137
|
+
ctx.error(`usage: ${meta.usage}`);
|
|
138
|
+
return 1;
|
|
139
|
+
}
|
|
140
|
+
const { name, problems, json } = parsed.args;
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const lib = await ctx.loadLibrary();
|
|
144
|
+
// Surfaces = configured roots + the global-core target + the well-known
|
|
145
|
+
// cross-agent global skill dirs (claude, codex, …) so sprawl across agents
|
|
146
|
+
// shows up without manual `skl scan --add-root`. Plus any --project surfaces
|
|
147
|
+
// injected for THIS invocation only (verify a deploy you just made).
|
|
148
|
+
// inventoryDeployments realpath-de-duplicates and skips missing dirs.
|
|
149
|
+
const surfaces = [...ctx.roots, ctx.config.globalCoreTarget, ...knownAgentSurfacePaths(), ...rt.extraSurfaces];
|
|
150
|
+
const report = await inventoryDeployments(surfaces, ctx.libraryPath, lib);
|
|
151
|
+
|
|
152
|
+
// --- remediation (--prune / --fix) -----------------------------------
|
|
153
|
+
if (parsed.args.prune || parsed.args.fix) {
|
|
154
|
+
const targets = name !== null ? report.problems.filter((s) => s.name === name) : report.problems;
|
|
155
|
+
const outcomes = await remediate(targets, ctx.libraryPath, {
|
|
156
|
+
fix: parsed.args.fix,
|
|
157
|
+
dryRun: parsed.args.dryRun,
|
|
158
|
+
});
|
|
159
|
+
const applied = outcomes.filter((o) => o.applied).length;
|
|
160
|
+
const manual = outcomes.filter((o) => o.action === "manual");
|
|
161
|
+
if (json) {
|
|
162
|
+
ctx.json({ dryRun: parsed.args.dryRun, mode: parsed.args.fix ? "fix" : "prune", applied, outcomes });
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
const verb = parsed.args.dryRun ? "Would apply" : "Applied";
|
|
166
|
+
ctx.log(`${verb} ${parsed.args.fix ? "fix" : "prune"} to ${outcomes.length} problem site(s):`);
|
|
167
|
+
for (const o of outcomes) {
|
|
168
|
+
const flag = o.action === "manual" ? "•" : parsed.args.dryRun ? "?" : "✓";
|
|
169
|
+
ctx.log(` ${flag} ${o.name} ${tilde(o.path)}`);
|
|
170
|
+
ctx.log(` ${o.note}`);
|
|
171
|
+
}
|
|
172
|
+
ctx.log("");
|
|
173
|
+
ctx.log(`${applied} ${parsed.args.dryRun ? "would be " : ""}auto-fixed, ${manual.length} need a manual decision.`);
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// --- single skill ----------------------------------------------------
|
|
178
|
+
if (name !== null) {
|
|
179
|
+
const sites = report.sites.filter((s) => s.name === name);
|
|
180
|
+
const inLibrary = lib.some((s) => s.name === name);
|
|
181
|
+
if (json) {
|
|
182
|
+
ctx.json({ name, inLibrary, sites });
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
if (sites.length === 0) {
|
|
186
|
+
ctx.log(
|
|
187
|
+
inLibrary
|
|
188
|
+
? `${name}: in the library, but not deployed to any scanned surface.`
|
|
189
|
+
: `${name}: not in the library and not found in any scanned surface.`,
|
|
190
|
+
);
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
ctx.log(`${name} — deployed at ${sites.length} site${sites.length === 1 ? "" : "s"}:`);
|
|
194
|
+
for (const s of sites) {
|
|
195
|
+
ctx.log(` ${labelFor(s)} ${tilde(s.path)}`);
|
|
196
|
+
if (s.kind === "foreign-link" && s.target) ctx.log(` → ${tilde(s.target)}`);
|
|
197
|
+
const fix = suggestionFor(s);
|
|
198
|
+
if (fix) ctx.log(` ${fix}`);
|
|
199
|
+
}
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// --- full map / problems --------------------------------------------
|
|
204
|
+
if (json) {
|
|
205
|
+
ctx.json(problems ? { surfaces: report.surfaces, problems: report.problems } : report);
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const linked = report.sites.filter((s) => s.kind === "linked");
|
|
210
|
+
ctx.log(`Scanned ${report.surfaces.length} surface${report.surfaces.length === 1 ? "" : "s"}:`);
|
|
211
|
+
for (const s of report.surfaces) ctx.log(` ${tilde(s)}`);
|
|
212
|
+
ctx.log("");
|
|
213
|
+
ctx.log(`Clean: ${linked.length} linked deployment${linked.length === 1 ? "" : "s"}.`);
|
|
214
|
+
|
|
215
|
+
if (report.problems.length === 0) {
|
|
216
|
+
ctx.log("No deployment problems. ✨");
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
ctx.log("");
|
|
221
|
+
ctx.log(`Problems (${report.problems.length}):`);
|
|
222
|
+
for (const s of report.problems) {
|
|
223
|
+
ctx.log(` ${s.name} [${labelFor(s)}]`);
|
|
224
|
+
ctx.log(` ${tilde(s.path)}${s.kind === "foreign-link" && s.target ? ` → ${tilde(s.target)}` : ""}`);
|
|
225
|
+
ctx.log(` → ${suggestionFor(s)}`);
|
|
226
|
+
}
|
|
227
|
+
return 0;
|
|
228
|
+
} catch (err) {
|
|
229
|
+
ctx.error(`skl where failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
230
|
+
return 1;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, readFile } from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { resolveConfig, addRoot, removeRoot, loadContext } from "./config.ts";
|
|
7
|
+
|
|
8
|
+
describe("config: SKILLSHELF_CONFIG isolation + root registry inverse", () => {
|
|
9
|
+
let tmp: string;
|
|
10
|
+
let cfg: string;
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
tmp = await mkdtemp(join(tmpdir(), "skl-config-"));
|
|
14
|
+
cfg = join(tmp, "config.json");
|
|
15
|
+
});
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await rm(tmp, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("SKILLSHELF_CONFIG env redirects the config file path", async () => {
|
|
21
|
+
const config = await resolveConfig({ env: { SKILLSHELF_CONFIG: cfg } as NodeJS.ProcessEnv });
|
|
22
|
+
expect(config.configFilePath).toBe(cfg);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("addRoot then removeRoot is a clean round-trip", async () => {
|
|
26
|
+
await addRoot(cfg, [], "/tmp/alpha");
|
|
27
|
+
const after = await addRoot(cfg, ["/tmp/alpha"], "/tmp/beta");
|
|
28
|
+
expect(after).toEqual(["/tmp/alpha", "/tmp/beta"]);
|
|
29
|
+
|
|
30
|
+
const { roots, removed } = await removeRoot(cfg, "/tmp/alpha");
|
|
31
|
+
expect(removed).toBe(true);
|
|
32
|
+
expect(roots).toEqual(["/tmp/beta"]);
|
|
33
|
+
|
|
34
|
+
const onDisk = JSON.parse(await readFile(cfg, "utf8"));
|
|
35
|
+
expect(onDisk.roots).toEqual(["/tmp/beta"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("removeRoot on a non-registered path is idempotent (removed:false)", async () => {
|
|
39
|
+
await addRoot(cfg, [], "/tmp/alpha");
|
|
40
|
+
const { roots, removed } = await removeRoot(cfg, "/tmp/nope");
|
|
41
|
+
expect(removed).toBe(false);
|
|
42
|
+
expect(roots).toEqual(["/tmp/alpha"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("removeRoot preserves annotated RootEntry siblings", async () => {
|
|
46
|
+
await Bun.write(
|
|
47
|
+
cfg,
|
|
48
|
+
JSON.stringify({
|
|
49
|
+
roots: [{ path: "/tmp/keep", notes: "important" }, "/tmp/drop"],
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
const { roots, removed } = await removeRoot(cfg, "/tmp/drop");
|
|
53
|
+
expect(removed).toBe(true);
|
|
54
|
+
expect(roots).toEqual(["/tmp/keep"]);
|
|
55
|
+
const onDisk = JSON.parse(await readFile(cfg, "utf8"));
|
|
56
|
+
// annotation on the surviving entry is preserved
|
|
57
|
+
expect(onDisk.roots).toEqual([{ path: "/tmp/keep", notes: "important" }]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("ctx.removeRoot persists and keeps the live roots view in sync", async () => {
|
|
61
|
+
const ctx = await loadContext({ env: { SKILLSHELF_CONFIG: cfg } as NodeJS.ProcessEnv });
|
|
62
|
+
await ctx.addRoot("/tmp/one");
|
|
63
|
+
await ctx.addRoot("/tmp/two");
|
|
64
|
+
const res = await ctx.removeRoot("/tmp/one");
|
|
65
|
+
expect(res.removed).toBe(true);
|
|
66
|
+
expect(ctx.roots).toEqual(["/tmp/two"]);
|
|
67
|
+
expect(existsSync(cfg)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { join, resolve, isAbsolute } from "node:path";
|
|
10
10
|
import { existsSync } from "node:fs";
|
|
11
|
-
import type { Config, ConfigFile, Ctx, Skill } from "./types.ts";
|
|
11
|
+
import type { Config, ConfigFile, Ctx, RootEntry, Skill } from "./types.ts";
|
|
12
12
|
|
|
13
13
|
export const DEFAULT_CONFIG_FILE = join(homedir(), ".skillshelf", "config.json");
|
|
14
14
|
export const DEFAULT_LIBRARY = join(homedir(), ".skillshelf", "library");
|
|
@@ -45,7 +45,15 @@ export async function resolveConfig(opts: {
|
|
|
45
45
|
configFilePath?: string;
|
|
46
46
|
} = {}): Promise<Config> {
|
|
47
47
|
const env = opts.env ?? process.env;
|
|
48
|
-
|
|
48
|
+
// Config-file resolution: explicit opt override (tests) → SKILLSHELF_CONFIG env →
|
|
49
|
+
// default ~/.skillshelf/config.json. The env override lets an experiment redirect
|
|
50
|
+
// ALL persisted state (roots especially) into a sandbox without touching the real
|
|
51
|
+
// config — `skl scan --add-root` otherwise writes the real file regardless of
|
|
52
|
+
// SKILLSHELF_LIBRARY (the isolation gap this closes).
|
|
53
|
+
const envCfg = env.SKILLSHELF_CONFIG;
|
|
54
|
+
const configFilePath =
|
|
55
|
+
opts.configFilePath ??
|
|
56
|
+
(envCfg && envCfg.trim() !== "" ? abs(envCfg.trim()) : DEFAULT_CONFIG_FILE);
|
|
49
57
|
|
|
50
58
|
const fileCfg = await readConfigFile(configFilePath);
|
|
51
59
|
const usedConfigFile = fileCfg ? configFilePath : null;
|
|
@@ -84,14 +92,25 @@ export async function resolveConfig(opts: {
|
|
|
84
92
|
};
|
|
85
93
|
}
|
|
86
94
|
|
|
87
|
-
/**
|
|
95
|
+
/**
|
|
96
|
+
* Expand ~, absolutize, and de-duplicate a list of scan roots (order-preserving).
|
|
97
|
+
* Each entry may be a bare path string OR an annotated {path, layout?, notes?}
|
|
98
|
+
* object (see RootEntry); both normalize to an absolute path string here.
|
|
99
|
+
* layout/notes are informational only and dropped — crawl auto-detects layout and
|
|
100
|
+
* nothing consumes them programmatically.
|
|
101
|
+
*/
|
|
88
102
|
function normalizeRoots(input: unknown): string[] {
|
|
89
103
|
if (!Array.isArray(input)) return [];
|
|
90
104
|
const out: string[] = [];
|
|
91
105
|
const seen = new Set<string>();
|
|
92
106
|
for (const r of input) {
|
|
93
|
-
|
|
94
|
-
|
|
107
|
+
let raw: string | null = null;
|
|
108
|
+
if (typeof r === "string") raw = r;
|
|
109
|
+
else if (r && typeof r === "object" && typeof (r as { path?: unknown }).path === "string") {
|
|
110
|
+
raw = (r as { path: string }).path;
|
|
111
|
+
}
|
|
112
|
+
if (raw === null || raw.trim() === "") continue;
|
|
113
|
+
const a = abs(raw.trim());
|
|
95
114
|
if (seen.has(a)) continue;
|
|
96
115
|
seen.add(a);
|
|
97
116
|
out.push(a);
|
|
@@ -102,7 +121,10 @@ function normalizeRoots(input: unknown): string[] {
|
|
|
102
121
|
/**
|
|
103
122
|
* Persist a new scan root into the config file (`configFilePath`), expanding ~,
|
|
104
123
|
* absolutizing, and de-duplicating against existing roots. Preserves the rest of
|
|
105
|
-
* the config file (library / globalCore)
|
|
124
|
+
* the config file (library / globalCore) AND any annotations on existing root
|
|
125
|
+
* entries ({path, layout?, notes?}): we only APPEND a new bare-path entry when the
|
|
126
|
+
* resolved path is absent. Returns the full updated roots list as resolved
|
|
127
|
+
* absolute path strings (the in-memory Config.roots form).
|
|
106
128
|
*/
|
|
107
129
|
export async function addRoot(
|
|
108
130
|
configFilePath: string,
|
|
@@ -110,13 +132,53 @@ export async function addRoot(
|
|
|
110
132
|
path: string,
|
|
111
133
|
): Promise<string[]> {
|
|
112
134
|
const a = abs(path.trim());
|
|
113
|
-
const roots = [...existingRoots];
|
|
114
|
-
if (!roots.includes(a)) roots.push(a);
|
|
115
135
|
|
|
136
|
+
// Preserve the on-disk roots verbatim (including RootEntry annotations); only
|
|
137
|
+
// append the new path if it is not already present (compared after resolution).
|
|
116
138
|
const current = (await readConfigFile(configFilePath)) ?? {};
|
|
117
|
-
const
|
|
139
|
+
const persisted: Array<string | RootEntry> = Array.isArray(current.roots)
|
|
140
|
+
? [...current.roots]
|
|
141
|
+
: [];
|
|
142
|
+
const resolvedOf = (entry: string | RootEntry): string | null => {
|
|
143
|
+
const raw = typeof entry === "string" ? entry : entry?.path;
|
|
144
|
+
return typeof raw === "string" && raw.trim() !== "" ? abs(raw.trim()) : null;
|
|
145
|
+
};
|
|
146
|
+
if (!persisted.some((e) => resolvedOf(e) === a)) persisted.push(a);
|
|
147
|
+
|
|
148
|
+
const next: ConfigFile = { ...current, roots: persisted };
|
|
118
149
|
await Bun.write(configFilePath, JSON.stringify(next, null, 2) + "\n");
|
|
119
|
-
|
|
150
|
+
|
|
151
|
+
// Return the resolved, de-duped absolute path list (Config.roots stays string[]).
|
|
152
|
+
return normalizeRoots(persisted);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Remove a scan root from the config file (`configFilePath`) — the inverse of
|
|
157
|
+
* addRoot. Matches by resolved absolute path, so the caller may pass a bare/`~`/
|
|
158
|
+
* relative form of an already-persisted root. Preserves the rest of the config and
|
|
159
|
+
* any annotations on the surviving root entries. Returns the updated roots list as
|
|
160
|
+
* resolved absolute path strings, plus whether anything was actually removed.
|
|
161
|
+
*/
|
|
162
|
+
export async function removeRoot(
|
|
163
|
+
configFilePath: string,
|
|
164
|
+
path: string,
|
|
165
|
+
): Promise<{ roots: string[]; removed: boolean }> {
|
|
166
|
+
const target = abs(path.trim());
|
|
167
|
+
const current = (await readConfigFile(configFilePath)) ?? {};
|
|
168
|
+
const persisted: Array<string | RootEntry> = Array.isArray(current.roots)
|
|
169
|
+
? [...current.roots]
|
|
170
|
+
: [];
|
|
171
|
+
const resolvedOf = (entry: string | RootEntry): string | null => {
|
|
172
|
+
const raw = typeof entry === "string" ? entry : entry?.path;
|
|
173
|
+
return typeof raw === "string" && raw.trim() !== "" ? abs(raw.trim()) : null;
|
|
174
|
+
};
|
|
175
|
+
const kept = persisted.filter((e) => resolvedOf(e) !== target);
|
|
176
|
+
const removed = kept.length !== persisted.length;
|
|
177
|
+
if (removed) {
|
|
178
|
+
const next: ConfigFile = { ...current, roots: kept };
|
|
179
|
+
await Bun.write(configFilePath, JSON.stringify(next, null, 2) + "\n");
|
|
180
|
+
}
|
|
181
|
+
return { roots: normalizeRoots(kept), removed };
|
|
120
182
|
}
|
|
121
183
|
|
|
122
184
|
/**
|
|
@@ -146,6 +208,13 @@ export async function loadContext(opts: {
|
|
|
146
208
|
config.roots = roots;
|
|
147
209
|
return roots;
|
|
148
210
|
},
|
|
211
|
+
removeRoot: async (path: string): Promise<{ roots: string[]; removed: boolean }> => {
|
|
212
|
+
const res = await removeRoot(config.configFilePath, path);
|
|
213
|
+
roots = res.roots;
|
|
214
|
+
ctx.roots = roots;
|
|
215
|
+
config.roots = roots;
|
|
216
|
+
return res;
|
|
217
|
+
},
|
|
149
218
|
log: (...args: unknown[]) => {
|
|
150
219
|
console.log(...args);
|
|
151
220
|
},
|