skillshelf 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -20
- package/package.json +8 -2
- package/src/adapters/inference/agent.ts +23 -16
- package/src/cli.ts +39 -0
- package/src/commands/add.ts +624 -128
- 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 +243 -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 +136 -19
- package/src/commands/migrate.test.ts +157 -0
- package/src/commands/migrate.ts +260 -0
- package/src/commands/mode-surfacing.test.ts +110 -0
- package/src/commands/outdated.test.ts +55 -0
- package/src/commands/outdated.ts +166 -18
- package/src/commands/projects.test.ts +85 -0
- package/src/commands/projects.ts +80 -0
- 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 +130 -11
- 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/track.test.ts +170 -0
- package/src/commands/track.ts +340 -0
- package/src/commands/unretire.ts +33 -0
- package/src/commands/untag.ts +73 -0
- package/src/commands/untrack.ts +44 -0
- package/src/commands/update.test.ts +71 -0
- package/src/commands/update.ts +157 -15
- package/src/commands/use.test.ts +122 -0
- package/src/commands/use.ts +46 -23
- package/src/commands/where.ts +232 -0
- package/src/config.test.ts +198 -0
- package/src/config.ts +232 -10
- package/src/core/agents.test.ts +319 -0
- package/src/core/agents.ts +438 -0
- package/src/core/bundle.ts +12 -15
- package/src/core/core.test.ts +21 -8
- 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 +371 -75
- 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 +155 -15
- package/src/core/overlay.ts +0 -63
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, symlink, rm, realpath } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { run as lsRun } from "./ls.ts";
|
|
6
|
+
import { run as outdatedRun } from "./outdated.ts";
|
|
7
|
+
import { run as updateRun } from "./update.ts";
|
|
8
|
+
import { loadLibrary } from "../core/library.ts";
|
|
9
|
+
import { hashContent } from "../core/crawl.ts";
|
|
10
|
+
import { parseFrontmatter } from "../lib/frontmatter.ts";
|
|
11
|
+
import type { Ctx } from "../types.ts";
|
|
12
|
+
|
|
13
|
+
function makeCtx(libraryPath: string) {
|
|
14
|
+
const json: unknown[] = [];
|
|
15
|
+
const ctx = {
|
|
16
|
+
config: { libraryPath },
|
|
17
|
+
libraryPath,
|
|
18
|
+
loadLibrary: () => loadLibrary(libraryPath),
|
|
19
|
+
log: () => {},
|
|
20
|
+
error: () => {},
|
|
21
|
+
json: (v: unknown) => json.push(v),
|
|
22
|
+
} as unknown as Ctx;
|
|
23
|
+
return { ctx, json };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("owned-vs-linked surfacing (friction #7)", () => {
|
|
27
|
+
let tmp: string;
|
|
28
|
+
let library: string;
|
|
29
|
+
let dev: string;
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-mode-")));
|
|
33
|
+
library = join(tmp, "library");
|
|
34
|
+
await mkdir(join(library, "owned1"), { recursive: true });
|
|
35
|
+
await writeFile(join(library, "owned1", "SKILL.md"), "---\nname: owned1\ndescription: o\n---\n\nbody\n");
|
|
36
|
+
dev = join(tmp, "dev", "devskill");
|
|
37
|
+
await mkdir(dev, { recursive: true });
|
|
38
|
+
await writeFile(join(dev, "SKILL.md"), "---\nname: devskill\ndescription: d\n---\n\nbody\n");
|
|
39
|
+
await symlink(dev, join(library, "devskill"));
|
|
40
|
+
});
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
await rm(tmp, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("ls --json carries mode + linkTarget", async () => {
|
|
46
|
+
const { ctx, json } = makeCtx(library);
|
|
47
|
+
await lsRun(["--all", "--json"], ctx);
|
|
48
|
+
const rows = json[0] as Array<{ name: string; mode: string; linkTarget: string | null }>;
|
|
49
|
+
const owned = rows.find((r) => r.name === "owned1")!;
|
|
50
|
+
const linked = rows.find((r) => r.name === "devskill")!;
|
|
51
|
+
expect(owned.mode).toBe("owned");
|
|
52
|
+
expect(owned.linkTarget).toBeNull();
|
|
53
|
+
expect(linked.mode).toBe("linked");
|
|
54
|
+
expect(linked.linkTarget).toBe(dev);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("outdated surfaces a LINKED skill that has NO lock entry", async () => {
|
|
58
|
+
const { ctx, json } = makeCtx(library);
|
|
59
|
+
const code = await outdatedRun(["--json"], ctx);
|
|
60
|
+
expect(code).toBe(0);
|
|
61
|
+
const rows = (json[0] as { rows: Array<{ name: string; status: string }> }).rows;
|
|
62
|
+
expect(rows.find((r) => r.name === "devskill")!.status).toBe("linked");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("update reports a LINKED skill (no lock entry) as explicitly skipped", async () => {
|
|
66
|
+
const { ctx, json } = makeCtx(library);
|
|
67
|
+
await updateRun(["devskill", "--json"], ctx);
|
|
68
|
+
const results = (json[0] as { results: Array<{ name: string; outcome: string }> }).results;
|
|
69
|
+
expect(results.find((r) => r.name === "devskill")!.outcome).toBe("skipped");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("outdated --check-local flags local divergence offline (no network)", async () => {
|
|
73
|
+
// owned1 tracked with an installedHash that does NOT match the local body.
|
|
74
|
+
const localBody = parseFrontmatter("---\nname: owned1\n---\n\nbody\n").body;
|
|
75
|
+
const staleHash = hashContent(localBody + "DIFFERENT");
|
|
76
|
+
await writeFile(
|
|
77
|
+
join(library, "shelf.lock.json"),
|
|
78
|
+
JSON.stringify({
|
|
79
|
+
version: 1,
|
|
80
|
+
entries: {
|
|
81
|
+
owned1: { name: "owned1", source: "github:o/r", ref: "abc", channel: "github", installedAt: "2020-01-01T00:00:00.000Z", localEdits: false, installedHash: staleHash },
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
const { ctx, json } = makeCtx(library);
|
|
86
|
+
const code = await outdatedRun(["--check-local", "--json"], ctx);
|
|
87
|
+
expect(code).toBe(2); // diverged -> non-zero
|
|
88
|
+
const rows = (json[0] as { rows: Array<{ name: string; status: string }> }).rows;
|
|
89
|
+
expect(rows.find((r) => r.name === "owned1")!.status).toBe("diverged");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("outdated --check-local reports a matching baseline as current (offline)", async () => {
|
|
93
|
+
const localBody = parseFrontmatter("---\nname: owned1\n---\n\nbody\n").body;
|
|
94
|
+
const matchHash = hashContent(localBody);
|
|
95
|
+
await writeFile(
|
|
96
|
+
join(library, "shelf.lock.json"),
|
|
97
|
+
JSON.stringify({
|
|
98
|
+
version: 1,
|
|
99
|
+
entries: {
|
|
100
|
+
owned1: { name: "owned1", source: "github:o/r", ref: "abc", channel: "github", installedAt: "2020-01-01T00:00:00.000Z", localEdits: false, installedHash: matchHash },
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
const { ctx, json } = makeCtx(library);
|
|
105
|
+
const code = await outdatedRun(["--check-local", "--json"], ctx);
|
|
106
|
+
expect(code).toBe(0);
|
|
107
|
+
const rows = (json[0] as { rows: Array<{ name: string; status: string }> }).rows;
|
|
108
|
+
expect(rows.find((r) => r.name === "owned1")!.status).toBe("current");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, symlink, rm, realpath } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { run } from "./outdated.ts";
|
|
6
|
+
import type { Ctx } from "../types.ts";
|
|
7
|
+
|
|
8
|
+
function makeCtx(libraryPath: string) {
|
|
9
|
+
const json: unknown[] = [];
|
|
10
|
+
const ctx = {
|
|
11
|
+
config: { libraryPath },
|
|
12
|
+
libraryPath,
|
|
13
|
+
log: () => {},
|
|
14
|
+
error: () => {},
|
|
15
|
+
json: (v: unknown) => json.push(v),
|
|
16
|
+
} as unknown as Ctx;
|
|
17
|
+
return { ctx, json };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("skl outdated — LINKED entries are reported, not probed (ADR-0004)", () => {
|
|
21
|
+
let tmp: string;
|
|
22
|
+
let library: string;
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-outdated-")));
|
|
26
|
+
library = join(tmp, "library");
|
|
27
|
+
const dev = join(tmp, "dev", "devskill");
|
|
28
|
+
await mkdir(library, { recursive: true });
|
|
29
|
+
await mkdir(dev, { recursive: true });
|
|
30
|
+
await writeFile(join(dev, "SKILL.md"), "---\nname: devskill\n---\n\nbody\n");
|
|
31
|
+
await symlink(dev, join(library, "devskill"));
|
|
32
|
+
await writeFile(
|
|
33
|
+
join(library, "shelf.lock.json"),
|
|
34
|
+
JSON.stringify({
|
|
35
|
+
version: 1,
|
|
36
|
+
entries: {
|
|
37
|
+
devskill: { name: "devskill", source: "github:owner/repo", ref: "abc", channel: "github", installedAt: "2020-01-01T00:00:00.000Z", localEdits: false },
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
afterEach(async () => {
|
|
43
|
+
await rm(tmp, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("a LINKED entry is status 'linked', never counted stale", async () => {
|
|
47
|
+
const { ctx, json } = makeCtx(library);
|
|
48
|
+
const code = await run(["--json"], ctx);
|
|
49
|
+
|
|
50
|
+
expect(code).toBe(0); // not stale -> exit 0 (no network probe of the dead github ref)
|
|
51
|
+
const report = json[0] as { stale: number; rows: Array<{ name: string; status: string }> };
|
|
52
|
+
expect(report.stale).toBe(0);
|
|
53
|
+
expect(report.rows.find((r) => r.name === "devskill")!.status).toBe("linked");
|
|
54
|
+
});
|
|
55
|
+
});
|
package/src/commands/outdated.ts
CHANGED
|
@@ -6,17 +6,22 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Read command: supports --json.
|
|
8
8
|
|
|
9
|
-
import
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import type { Ctx, LockEntry, Skill } from "../types.ts";
|
|
10
12
|
import { readLockfile } from "../core/provenance.ts";
|
|
13
|
+
import { entryMode, entryModeInfo, loadLibrary, findByName } from "../core/library.ts";
|
|
14
|
+
import { hashContent } from "../core/crawl.ts";
|
|
15
|
+
import { parseFrontmatter } from "../lib/frontmatter.ts";
|
|
11
16
|
import { parseStoredSource, latestRef } from "../core/fetch.ts";
|
|
12
17
|
|
|
13
18
|
export const meta = {
|
|
14
19
|
name: "outdated",
|
|
15
20
|
summary: "Check upstream ref per tracked skill and mark stale ones",
|
|
16
|
-
usage: "skl outdated [name] [--json]",
|
|
21
|
+
usage: "skl outdated [name] [--check-local] [--json]",
|
|
17
22
|
} as const;
|
|
18
23
|
|
|
19
|
-
type Status = "stale" | "current" | "unknown";
|
|
24
|
+
type Status = "stale" | "current" | "unknown" | "linked" | "diverged" | "adopted";
|
|
20
25
|
|
|
21
26
|
interface Row {
|
|
22
27
|
name: string;
|
|
@@ -59,53 +64,196 @@ async function checkEntry(entry: LockEntry): Promise<Row> {
|
|
|
59
64
|
};
|
|
60
65
|
}
|
|
61
66
|
|
|
67
|
+
/**
|
|
68
|
+
* A LINKED entry (library/<name> symlinks to an external dev repo) has no tracked
|
|
69
|
+
* upstream — its own git owns versioning. Report it as such instead of probing a
|
|
70
|
+
* (possibly stale) github ref (ADR-0004).
|
|
71
|
+
*/
|
|
72
|
+
function linkedRow(entry: LockEntry): Row {
|
|
73
|
+
return {
|
|
74
|
+
name: entry.name,
|
|
75
|
+
channel: "local",
|
|
76
|
+
source: entry.source,
|
|
77
|
+
installedRef: entry.ref,
|
|
78
|
+
latestRef: null,
|
|
79
|
+
status: "linked",
|
|
80
|
+
note: "dev repo owns versioning",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A LINKED library skill with NO lockfile entry (the normal case for `skl link
|
|
86
|
+
* --from` — it drops any lock entry). Without this, a freshly-linked dev skill is
|
|
87
|
+
* INVISIBLE to outdated, giving an agent zero positive evidence its dev repo is the
|
|
88
|
+
* canonical source. Surface it as a `linked` row regardless.
|
|
89
|
+
*/
|
|
90
|
+
/**
|
|
91
|
+
* An ADOPTED entry (`skl track`/`skl migrate`): provenance is known but the upstream
|
|
92
|
+
* baseline was NEVER verified against real upstream (the recorded ref may be empty and
|
|
93
|
+
* the installedHash describes the LOCAL copy only). Reporting it as stale/current off the
|
|
94
|
+
* empty ref would be a lie, so surface it as `adopted` and do NOT network-probe (ADR-0011).
|
|
95
|
+
*/
|
|
96
|
+
function adoptedRow(entry: LockEntry): Row {
|
|
97
|
+
return {
|
|
98
|
+
name: entry.name,
|
|
99
|
+
channel: entry.channel,
|
|
100
|
+
source: entry.source,
|
|
101
|
+
installedRef: entry.ref || "-",
|
|
102
|
+
latestRef: null,
|
|
103
|
+
status: "adopted",
|
|
104
|
+
note: "provenance adopted; baseline unverified — run `skl update` to reconcile",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function linkedRowFromName(name: string, linkTarget: string | null): Row {
|
|
109
|
+
return {
|
|
110
|
+
name,
|
|
111
|
+
channel: "local",
|
|
112
|
+
source: linkTarget ?? "(dev repo)",
|
|
113
|
+
installedRef: "-",
|
|
114
|
+
latestRef: null,
|
|
115
|
+
status: "linked",
|
|
116
|
+
note: "dev repo owns versioning",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Offline (no network) divergence check for an OWNED tracked skill: compare the local
|
|
122
|
+
* SKILL.md body hash to the baseline recorded at install/update time
|
|
123
|
+
* (lockfile.installedHash). Answers "have I locally edited this?" without probing
|
|
124
|
+
* upstream — usable on a plane / in CI with no creds.
|
|
125
|
+
*/
|
|
126
|
+
function checkEntryLocal(entry: LockEntry, library: Skill[], libraryPath: string): Row {
|
|
127
|
+
const skill = findByName(library, entry.name);
|
|
128
|
+
const bodyPath = skill?.bodyPath ?? join(libraryPath, entry.name, "SKILL.md");
|
|
129
|
+
let localHash: string | null = null;
|
|
130
|
+
try {
|
|
131
|
+
if (existsSync(bodyPath)) {
|
|
132
|
+
const raw = readFileSync(bodyPath, "utf8");
|
|
133
|
+
localHash = hashContent(parseFrontmatter(raw).body);
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
/* leave null */
|
|
137
|
+
}
|
|
138
|
+
const base = {
|
|
139
|
+
name: entry.name,
|
|
140
|
+
channel: entry.channel,
|
|
141
|
+
source: entry.source,
|
|
142
|
+
installedRef: entry.ref,
|
|
143
|
+
latestRef: null,
|
|
144
|
+
};
|
|
145
|
+
if (entry.installedHash == null) {
|
|
146
|
+
return { ...base, status: "unknown", note: "no recorded baseline (installed before hash tracking) — re-run `skl update` to record one" };
|
|
147
|
+
}
|
|
148
|
+
if (localHash == null) {
|
|
149
|
+
return { ...base, status: "unknown", note: "local SKILL.md unreadable" };
|
|
150
|
+
}
|
|
151
|
+
return localHash === entry.installedHash
|
|
152
|
+
? { ...base, status: "current", note: "matches installed baseline (offline)" }
|
|
153
|
+
: { ...base, status: "diverged", note: "local body diverged from installed baseline (offline)" };
|
|
154
|
+
}
|
|
155
|
+
|
|
62
156
|
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
63
157
|
const json = argv.includes("--json");
|
|
158
|
+
const checkLocal = argv.includes("--check-local");
|
|
64
159
|
const nameArg = argv.find((a) => !a.startsWith("-")) ?? null;
|
|
160
|
+
const libraryPath = ctx.config.libraryPath;
|
|
65
161
|
|
|
66
162
|
try {
|
|
67
|
-
const lock = await readLockfile(
|
|
163
|
+
const lock = await readLockfile(libraryPath);
|
|
68
164
|
let entries = Object.values(lock.entries);
|
|
69
165
|
if (nameArg) entries = entries.filter((e) => e.name === nameArg);
|
|
70
166
|
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
71
167
|
|
|
72
|
-
|
|
168
|
+
// Load the library so we can (a) hash local bodies for --check-local and
|
|
169
|
+
// (b) surface LINKED skills that have NO lock entry — the normal `skl link
|
|
170
|
+
// --from` case — which would otherwise be invisible here.
|
|
171
|
+
const library = await loadLibrary(libraryPath);
|
|
172
|
+
|
|
173
|
+
const rows = await Promise.all(
|
|
174
|
+
entries.map((e) =>
|
|
175
|
+
entryMode(libraryPath, e.name) === "linked"
|
|
176
|
+
? Promise.resolve(linkedRow(e))
|
|
177
|
+
: e.adopted === true
|
|
178
|
+
? // An adopted entry has an unverified (often empty) baseline — never probe
|
|
179
|
+
// upstream off it; report it as `adopted` so `update` reconciles (ADR-0011).
|
|
180
|
+
// --check-local still does the offline body-vs-baseline compare below.
|
|
181
|
+
checkLocal
|
|
182
|
+
? Promise.resolve(checkEntryLocal(e, library, libraryPath))
|
|
183
|
+
: Promise.resolve(adoptedRow(e))
|
|
184
|
+
: checkLocal
|
|
185
|
+
? Promise.resolve(checkEntryLocal(e, library, libraryPath))
|
|
186
|
+
: checkEntry(e),
|
|
187
|
+
),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// Augment: LINKED library skills not already represented by a lock entry get a
|
|
191
|
+
// `linked` row, so a freshly-shelved dev skill shows positive evidence.
|
|
192
|
+
const known = new Set(rows.map((r) => r.name));
|
|
193
|
+
for (const s of library) {
|
|
194
|
+
if (known.has(s.name)) continue;
|
|
195
|
+
if (nameArg && s.name !== nameArg) continue;
|
|
196
|
+
const info = entryModeInfo(libraryPath, s.name);
|
|
197
|
+
if (info.mode === "linked") rows.push(linkedRowFromName(s.name, info.linkTarget));
|
|
198
|
+
}
|
|
199
|
+
rows.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
200
|
+
|
|
201
|
+
if (rows.length === 0) {
|
|
73
202
|
if (json) ctx.json({ ok: true, checked: 0, stale: 0, rows: [] });
|
|
74
203
|
else if (nameArg) ctx.log(`no tracked skill named "${nameArg}"`);
|
|
75
204
|
else ctx.log("no tracked third-party skills (lockfile is empty)");
|
|
76
205
|
return 0;
|
|
77
206
|
}
|
|
78
207
|
|
|
79
|
-
const rows = await Promise.all(entries.map((e) => checkEntry(e)));
|
|
80
208
|
const stale = rows.filter((r) => r.status === "stale");
|
|
209
|
+
const diverged = rows.filter((r) => r.status === "diverged");
|
|
81
210
|
|
|
82
211
|
if (json) {
|
|
83
212
|
ctx.json({
|
|
84
213
|
ok: true,
|
|
85
214
|
checked: rows.length,
|
|
86
215
|
stale: stale.length,
|
|
216
|
+
diverged: diverged.length,
|
|
87
217
|
rows,
|
|
88
218
|
});
|
|
89
219
|
} else {
|
|
90
220
|
for (const r of rows) {
|
|
91
|
-
const mark =
|
|
221
|
+
const mark =
|
|
222
|
+
r.status === "stale" ? "STALE "
|
|
223
|
+
: r.status === "diverged" ? "DIVERGED"
|
|
224
|
+
: r.status === "current" ? "current "
|
|
225
|
+
: r.status === "linked" ? "linked "
|
|
226
|
+
: r.status === "adopted" ? "adopted "
|
|
227
|
+
: "unknown ";
|
|
92
228
|
const refInfo =
|
|
93
|
-
r.status === "
|
|
94
|
-
?
|
|
95
|
-
: r.status === "
|
|
96
|
-
?
|
|
97
|
-
:
|
|
98
|
-
|
|
229
|
+
r.status === "linked"
|
|
230
|
+
? r.note
|
|
231
|
+
: r.status === "adopted"
|
|
232
|
+
? r.note
|
|
233
|
+
: r.status === "stale"
|
|
234
|
+
? `${shortRef(r.installedRef)} -> ${shortRef(r.latestRef ?? "")}`
|
|
235
|
+
: r.status === "diverged"
|
|
236
|
+
? r.note
|
|
237
|
+
: r.status === "current"
|
|
238
|
+
? shortRef(r.installedRef) + (checkLocal ? " (offline)" : "")
|
|
239
|
+
: `${shortRef(r.installedRef)} (${r.note})`;
|
|
240
|
+
const extra =
|
|
241
|
+
r.note && !["unknown", "linked", "diverged", "adopted"].includes(r.status) ? ` [${r.note}]` : "";
|
|
99
242
|
ctx.log(`${mark} ${r.name.padEnd(28)} ${r.channel.padEnd(15)} ${refInfo}${extra}`);
|
|
100
243
|
}
|
|
101
244
|
ctx.log("");
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
ctx.log(
|
|
245
|
+
if (checkLocal) {
|
|
246
|
+
ctx.log(`${rows.length} tracked, ${diverged.length} locally diverged (offline check — no upstream probed).`);
|
|
247
|
+
if (diverged.length > 0) ctx.log("re-run \`skl update [name]\` to see the upstream diff, or \`--force\` to overwrite.");
|
|
248
|
+
} else {
|
|
249
|
+
ctx.log(`${rows.length} tracked, ${stale.length} stale.`);
|
|
250
|
+
if (stale.length > 0) ctx.log(`run \`skl update [name]\` to re-pull (domain tags are preserved).`);
|
|
105
251
|
}
|
|
106
252
|
}
|
|
107
|
-
// Non-zero exit when stale skills exist, so
|
|
108
|
-
|
|
253
|
+
// Non-zero exit when stale (or, offline, locally-diverged) skills exist, so
|
|
254
|
+
// agents/CI can branch on it.
|
|
255
|
+
const flagged = checkLocal ? diverged.length : stale.length;
|
|
256
|
+
return flagged > 0 ? 2 : 0;
|
|
109
257
|
} catch (err) {
|
|
110
258
|
ctx.error("outdated: failed:", err instanceof Error ? err.message : String(err));
|
|
111
259
|
return 1;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// `skl projects [add|rm|ls]` verb (ADR-0010 §5a). Drives the real ctx through an
|
|
2
|
+
// isolated SKILLSHELF_CONFIG so the JSON shapes the GUI/bridge bind to are pinned.
|
|
3
|
+
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
5
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { loadContext } from "../config.ts";
|
|
9
|
+
import { run as projectsRun } from "./projects.ts";
|
|
10
|
+
import type { Ctx } from "../types.ts";
|
|
11
|
+
|
|
12
|
+
describe("skl projects verb", () => {
|
|
13
|
+
let tmp: string;
|
|
14
|
+
let cfg: string;
|
|
15
|
+
|
|
16
|
+
async function makeCtx(): Promise<{ ctx: Ctx; json: unknown[]; logs: string[] }> {
|
|
17
|
+
const json: unknown[] = [];
|
|
18
|
+
const logs: string[] = [];
|
|
19
|
+
const ctx = await loadContext({ env: { SKILLSHELF_CONFIG: cfg } as NodeJS.ProcessEnv });
|
|
20
|
+
ctx.json = (v: unknown) => json.push(v);
|
|
21
|
+
ctx.log = (...a: unknown[]) => logs.push(a.join(" "));
|
|
22
|
+
ctx.error = () => {};
|
|
23
|
+
return { ctx, json, logs };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
tmp = await mkdtemp(join(tmpdir(), "skl-projects-cmd-"));
|
|
28
|
+
cfg = join(tmp, "config.json");
|
|
29
|
+
});
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
await rm(tmp, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("ls --json on an empty registry returns {projects:[]}", async () => {
|
|
35
|
+
const { ctx, json } = await makeCtx();
|
|
36
|
+
const code = await projectsRun(["ls", "--json"], ctx);
|
|
37
|
+
expect(code).toBe(0);
|
|
38
|
+
expect(json[0]).toEqual({ projects: [] });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("default verb (no args) is ls", async () => {
|
|
42
|
+
const { ctx, json } = await makeCtx();
|
|
43
|
+
const code = await projectsRun(["--json"], ctx);
|
|
44
|
+
expect(code).toBe(0);
|
|
45
|
+
expect(json[0]).toEqual({ projects: [] });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("add persists and reports the updated list", async () => {
|
|
49
|
+
const { ctx, json } = await makeCtx();
|
|
50
|
+
const code = await projectsRun(["add", "/tmp/webapp", "--json"], ctx);
|
|
51
|
+
expect(code).toBe(0);
|
|
52
|
+
expect(json[0]).toEqual({ projects: ["/tmp/webapp"], added: true });
|
|
53
|
+
|
|
54
|
+
// a fresh ctx reads it back from disk
|
|
55
|
+
const { ctx: ctx2, json: json2 } = await makeCtx();
|
|
56
|
+
await projectsRun(["ls", "--json"], ctx2);
|
|
57
|
+
expect(json2[0]).toEqual({ projects: ["/tmp/webapp"] });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("rm removes a persisted project", async () => {
|
|
61
|
+
const { ctx } = await makeCtx();
|
|
62
|
+
await projectsRun(["add", "/tmp/webapp"], ctx);
|
|
63
|
+
const { ctx: ctx2, json } = await makeCtx();
|
|
64
|
+
const code = await projectsRun(["rm", "/tmp/webapp", "--json"], ctx2);
|
|
65
|
+
expect(code).toBe(0);
|
|
66
|
+
expect(json[0]).toEqual({ projects: [], removed: true });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("rm on a non-registered path reports removed:false", async () => {
|
|
70
|
+
const { ctx, json } = await makeCtx();
|
|
71
|
+
const code = await projectsRun(["rm", "/tmp/nope", "--json"], ctx);
|
|
72
|
+
expect(code).toBe(0);
|
|
73
|
+
expect(json[0]).toEqual({ projects: [], removed: false });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("add without a path errors", async () => {
|
|
77
|
+
const { ctx } = await makeCtx();
|
|
78
|
+
expect(await projectsRun(["add"], ctx)).toBe(1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("unknown verb errors", async () => {
|
|
82
|
+
const { ctx } = await makeCtx();
|
|
83
|
+
expect(await projectsRun(["frobnicate"], ctx)).toBe(1);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// `skl projects [add|rm|ls] [path]` — manage the persisted NAV projects list
|
|
2
|
+
// (ADR-0010 §5a). This is navigation state only: a persisted project becomes a
|
|
3
|
+
// selectable scope in the management UI even before anything is deployed into it.
|
|
4
|
+
// It is NEVER deployment truth — cell/count state is always derived from reality
|
|
5
|
+
// (`skl agents`/`skl where`), so an added-but-empty project shows all-absent cells.
|
|
6
|
+
//
|
|
7
|
+
// skl projects [ls] [--json] list the persisted nav projects (default verb)
|
|
8
|
+
// skl projects add <path> add a project dir (expands ~, absolutizes, de-dupes)
|
|
9
|
+
// skl projects rm <path> remove a project dir (matched by resolved path)
|
|
10
|
+
//
|
|
11
|
+
// The GUI's `+ Add project` persists through this verb behind the Tauri bridge.
|
|
12
|
+
|
|
13
|
+
import type { Ctx } from "../types.ts";
|
|
14
|
+
|
|
15
|
+
export const meta = {
|
|
16
|
+
name: "projects",
|
|
17
|
+
summary: "Manage the persisted nav projects shown as scopes in the UI (add/rm/ls)",
|
|
18
|
+
usage: "skl projects [ls|add <path>|rm <path>] [--json]",
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
22
|
+
const json = argv.includes("--json");
|
|
23
|
+
const rest = argv.filter((a) => a !== "--json");
|
|
24
|
+
const verb = rest[0] && !rest[0].startsWith("-") ? rest[0] : "ls";
|
|
25
|
+
|
|
26
|
+
if (verb === "ls") {
|
|
27
|
+
const unknown = rest.slice(verb === rest[0] ? 1 : 0).find((a) => a.startsWith("-"));
|
|
28
|
+
if (unknown) return usageError(ctx, `unknown argument: ${unknown}`);
|
|
29
|
+
const projects = ctx.config.projects;
|
|
30
|
+
if (json) {
|
|
31
|
+
ctx.json({ projects });
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
if (projects.length === 0) {
|
|
35
|
+
ctx.log("No nav projects configured.");
|
|
36
|
+
ctx.log("Add one with: skl projects add <path>");
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
ctx.log(`Nav projects (${projects.length}):`);
|
|
40
|
+
for (const p of projects) ctx.log(` ${p}`);
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (verb === "add") {
|
|
45
|
+
const path = rest[1];
|
|
46
|
+
if (!path || path.startsWith("-")) return usageError(ctx, "add requires a path");
|
|
47
|
+
const projects = await ctx.addProject(path);
|
|
48
|
+
if (json) {
|
|
49
|
+
ctx.json({ projects, added: true });
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
ctx.log(`Added project. Nav projects (${projects.length}):`);
|
|
53
|
+
for (const p of projects) ctx.log(` ${p}`);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (verb === "rm") {
|
|
58
|
+
const path = rest[1];
|
|
59
|
+
if (!path || path.startsWith("-")) return usageError(ctx, "rm requires a path");
|
|
60
|
+
const { projects, removed } = await ctx.removeProject(path);
|
|
61
|
+
if (json) {
|
|
62
|
+
ctx.json({ projects, removed });
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
ctx.log(removed ? "Removed project." : "No matching project (nothing removed).");
|
|
66
|
+
if (projects.length > 0) {
|
|
67
|
+
ctx.log(`Nav projects (${projects.length}):`);
|
|
68
|
+
for (const p of projects) ctx.log(` ${p}`);
|
|
69
|
+
}
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return usageError(ctx, `unknown verb: ${verb}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function usageError(ctx: Ctx, msg: string): number {
|
|
77
|
+
ctx.error(`skl projects: ${msg}`);
|
|
78
|
+
ctx.error(`usage: ${meta.usage}`);
|
|
79
|
+
return 1;
|
|
80
|
+
}
|