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
package/src/commands/add.ts
CHANGED
|
@@ -1,36 +1,51 @@
|
|
|
1
|
-
// skl add <src> — install
|
|
1
|
+
// skl add <src> — install third-party skill(s) into the library.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
3
|
+
// Two shapes, ONE clone:
|
|
4
|
+
// skl add github:owner/repo/path/to/skill → install that one skill (unchanged)
|
|
5
|
+
// skl add github:owner/repo → if exactly one skill, install it;
|
|
6
|
+
// if several, error and point at the
|
|
7
|
+
// flags below (never silently pick one)
|
|
8
|
+
// skl add github:owner/repo --list → discover + print, no writes
|
|
9
|
+
// skl add github:owner/repo --all → install every discovered skill
|
|
10
|
+
// skl add github:owner/repo --skill a,b → install only those (by frontmatter name)
|
|
11
|
+
// skl add github:owner/repo --all --dry-run → drift preflight (new/identical/differs)
|
|
10
12
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
+
// `add` is a LIBRARIAN, not an installer: it writes ONLY into ~/.skillshelf/library
|
|
14
|
+
// (provenance + central taxonomy). It never touches agent dirs / symlink fan-out —
|
|
15
|
+
// that stays with `skl use` (project) / a future `skl deploy` (ADR-0003). A repo-wide
|
|
16
|
+
// add clones the repo ONCE (fetchRepo), discovers all skills, and copies the selected
|
|
17
|
+
// subset out of the single staging checkout — N installs, one network fetch.
|
|
18
|
+
//
|
|
19
|
+
// Read-only commands take --json; add is a write, but still emits a --json summary.
|
|
13
20
|
|
|
14
|
-
import { join, basename } from "node:path";
|
|
21
|
+
import { join, basename, dirname, sep } from "node:path";
|
|
15
22
|
import { existsSync } from "node:fs";
|
|
16
23
|
import type { Ctx, Skill, LockEntry } from "../types.ts";
|
|
17
24
|
import {
|
|
18
25
|
parseSource,
|
|
19
26
|
fetchSource,
|
|
27
|
+
fetchRepo,
|
|
28
|
+
discoverSkills,
|
|
29
|
+
discoverSingleLenient,
|
|
20
30
|
copySkillDir,
|
|
21
31
|
cleanupStaging,
|
|
22
32
|
readSkillBody,
|
|
33
|
+
type DiscoveredSkill,
|
|
34
|
+
type ParsedSource,
|
|
23
35
|
} from "../core/fetch.ts";
|
|
24
36
|
import { parseFrontmatter } from "../lib/frontmatter.ts";
|
|
25
37
|
import { hashContent } from "../core/crawl.ts";
|
|
26
38
|
import { recordEntry } from "../core/provenance.ts";
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
39
|
+
import { setDomainsForName } from "../core/taxonomy.ts";
|
|
40
|
+
import { assertSafeName } from "../core/lifecycle.ts";
|
|
41
|
+
import { loadLibrary, findByName } from "../core/library.ts";
|
|
42
|
+
import { ensureDir, isSymlink, realpathOrSelf } from "../lib/fs.ts";
|
|
29
43
|
|
|
30
44
|
export const meta = {
|
|
31
45
|
name: "add",
|
|
32
|
-
summary: "Install
|
|
33
|
-
usage:
|
|
46
|
+
summary: "Install third-party skill(s) (github:/git:/registry); repo-wide via --all/--skill",
|
|
47
|
+
usage:
|
|
48
|
+
"skl add <src> [--all|--skill <a,b,…>] [--list] [--dry-run] [--domain <d>] [--name <slug>] [--no-infer] [--force] [--json]",
|
|
34
49
|
} as const;
|
|
35
50
|
|
|
36
51
|
interface Flags {
|
|
@@ -39,184 +54,665 @@ interface Flags {
|
|
|
39
54
|
name: string | null;
|
|
40
55
|
infer: boolean;
|
|
41
56
|
force: boolean;
|
|
57
|
+
all: boolean;
|
|
58
|
+
list: boolean;
|
|
59
|
+
dryRun: boolean;
|
|
60
|
+
/** null = flag not given; otherwise the (possibly empty) list of requested names */
|
|
61
|
+
skill: string[] | null;
|
|
42
62
|
src: string | null;
|
|
43
63
|
}
|
|
44
64
|
|
|
65
|
+
// A skill slug is lowercase letters/digits/hyphens. This is also a SECURITY guard:
|
|
66
|
+
// `name` may be derived from an untrusted third-party SKILL.md frontmatter and
|
|
67
|
+
// `domain` from a flag, and both are joined into a library path. Rejecting anything
|
|
68
|
+
// outside this charset stops `..`/`/` path traversal out of the library.
|
|
69
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
70
|
+
|
|
71
|
+
function addSkillFilter(cur: string[] | null, raw: string): string[] {
|
|
72
|
+
const names = raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
|
|
73
|
+
return [...(cur ?? []), ...names];
|
|
74
|
+
}
|
|
75
|
+
|
|
45
76
|
function parseFlags(argv: string[]): Flags {
|
|
46
|
-
const f: Flags = {
|
|
77
|
+
const f: Flags = {
|
|
78
|
+
json: false,
|
|
79
|
+
domain: null,
|
|
80
|
+
name: null,
|
|
81
|
+
infer: true,
|
|
82
|
+
force: false,
|
|
83
|
+
all: false,
|
|
84
|
+
list: false,
|
|
85
|
+
dryRun: false,
|
|
86
|
+
skill: null,
|
|
87
|
+
src: null,
|
|
88
|
+
};
|
|
47
89
|
for (let i = 0; i < argv.length; i++) {
|
|
48
90
|
const a = argv[i]!;
|
|
49
91
|
if (a === "--json") f.json = true;
|
|
50
92
|
else if (a === "--no-infer") f.infer = false;
|
|
51
93
|
else if (a === "--force") f.force = true;
|
|
94
|
+
else if (a === "--all") f.all = true;
|
|
95
|
+
else if (a === "--list") f.list = true;
|
|
96
|
+
else if (a === "--dry-run") f.dryRun = true;
|
|
52
97
|
else if (a === "--domain") f.domain = argv[++i] ?? null;
|
|
53
98
|
else if (a === "--name") f.name = argv[++i] ?? null;
|
|
54
|
-
else if (a === "--
|
|
99
|
+
else if (a === "--skill") f.skill = addSkillFilter(f.skill, argv[++i] ?? "");
|
|
100
|
+
else if (a.startsWith("--domain=")) f.domain = a.slice("--domain=".length);
|
|
55
101
|
else if (a.startsWith("--name=")) f.name = a.slice("--name=".length);
|
|
102
|
+
else if (a.startsWith("--skill=")) f.skill = addSkillFilter(f.skill, a.slice("--skill=".length));
|
|
56
103
|
else if (!a.startsWith("-") && f.src === null) f.src = a;
|
|
57
104
|
}
|
|
58
105
|
return f;
|
|
59
106
|
}
|
|
60
107
|
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
108
|
+
/** Body text after frontmatter — the unit drift/install hashes operate on. */
|
|
109
|
+
function bodyOf(text: string): string {
|
|
110
|
+
return parseFrontmatter(text).body;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The library destination dir for a slug (under its domain folder, if any). */
|
|
114
|
+
function destDirFor(libraryPath: string, domainFolder: string | null, name: string): string {
|
|
115
|
+
return domainFolder ? join(libraryPath, domainFolder, name) : join(libraryPath, name);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The nearest ancestor of `p` (incl. `p`) that exists on disk; falls back to `p`. */
|
|
119
|
+
function nearestExisting(p: string): string {
|
|
120
|
+
let cur = p;
|
|
121
|
+
while (!existsSync(cur) && !isSymlink(cur)) {
|
|
122
|
+
const parent = dirname(cur);
|
|
123
|
+
if (parent === cur) return cur;
|
|
124
|
+
cur = parent;
|
|
125
|
+
}
|
|
126
|
+
return cur;
|
|
68
127
|
}
|
|
69
128
|
|
|
70
129
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
|
|
130
|
+
* True if writing to `destDir` would resolve OUTSIDE the library — i.e. the nearest
|
|
131
|
+
* existing component on the way to destDir is (or is reached through) a symlink whose
|
|
132
|
+
* realpath escapes the library. Catches a symlinked DOMAIN folder (`library/<d> ->
|
|
133
|
+
* /external`) that the leaf-only `isSymlink(destDir)` check misses, so `--force` can't
|
|
134
|
+
* clobber an external tree through a symlinked parent (ADR-0004). Both sides anchor to
|
|
135
|
+
* their nearest existing ancestor, so a fresh (not-yet-created) library is not a false
|
|
136
|
+
* positive — its anchor is the shared parent, which contains itself.
|
|
137
|
+
*/
|
|
138
|
+
function destEscapesLibrary(libraryPath: string, destDir: string): boolean {
|
|
139
|
+
const libReal = realpathOrSelf(nearestExisting(libraryPath));
|
|
140
|
+
const destReal = realpathOrSelf(nearestExisting(destDir));
|
|
141
|
+
return !(destReal === libReal || destReal.startsWith(libReal + sep));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
type Verdict = "new" | "identical" | "differs";
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Drift preflight for one skill against the library destination it would install to:
|
|
148
|
+
* - new — nothing at the destination → would install
|
|
149
|
+
* - identical — destination body hash matches upstream → lossless overwrite
|
|
150
|
+
* - differs — destination body differs → would clobber local content (needs --force)
|
|
151
|
+
* Compares the frontmatter-stripped BODY (matches installedHash / `skl update`).
|
|
152
|
+
*/
|
|
153
|
+
async function driftVerdict(skill: DiscoveredSkill, destDir: string): Promise<Verdict> {
|
|
154
|
+
const localPath = join(destDir, "SKILL.md");
|
|
155
|
+
if (!existsSync(localPath)) return "new";
|
|
156
|
+
const upstream = hashContent(bodyOf(await readSkillBody(skill.dir)));
|
|
157
|
+
let localText = "";
|
|
158
|
+
try {
|
|
159
|
+
localText = await Bun.file(localPath).text();
|
|
160
|
+
} catch {
|
|
161
|
+
localText = "";
|
|
162
|
+
}
|
|
163
|
+
const local = hashContent(bodyOf(localText));
|
|
164
|
+
return upstream === local ? "identical" : "differs";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Optionally run an AI inference tagging pass over a freshly-installed skill.
|
|
79
169
|
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
170
|
+
* A MISSING hook module is expected and stays silent (untagged is valid). But a hook
|
|
171
|
+
* that IS present and THROWS is a real failure — we surface it via `warn` rather than
|
|
172
|
+
* swallowing it. Either way the skill stays untagged. Returns the domains written.
|
|
82
173
|
*/
|
|
83
|
-
async function maybeInferTags(
|
|
174
|
+
async function maybeInferTags(
|
|
175
|
+
skill: Skill,
|
|
176
|
+
warn?: (msg: string) => void,
|
|
177
|
+
): Promise<string[] | null> {
|
|
84
178
|
const candidates = ["../core/infer.ts", "../adapters/inference/tag.ts"];
|
|
85
179
|
for (const rel of candidates) {
|
|
180
|
+
const spec: string = rel;
|
|
181
|
+
const mod: unknown = await import(spec).catch(() => null);
|
|
182
|
+
if (!mod || typeof mod !== "object") continue;
|
|
183
|
+
const hook = (mod as Record<string, unknown>).tagSkill;
|
|
184
|
+
if (typeof hook !== "function") continue;
|
|
86
185
|
try {
|
|
87
|
-
// Non-literal specifier: keeps a missing optional module from becoming a
|
|
88
|
-
// static import/resolution error; degrades to "untagged" at runtime.
|
|
89
|
-
const spec: string = rel;
|
|
90
|
-
const mod: unknown = await import(spec).catch(() => null);
|
|
91
|
-
if (!mod || typeof mod !== "object") continue;
|
|
92
|
-
const hook = (mod as Record<string, unknown>).tagSkill;
|
|
93
|
-
if (typeof hook !== "function") continue;
|
|
94
186
|
const result = await (hook as (s: Skill) => Promise<string[] | null>)(skill);
|
|
95
187
|
if (Array.isArray(result)) {
|
|
96
188
|
return result.filter((d) => typeof d === "string" && d.trim() !== "");
|
|
97
189
|
}
|
|
98
190
|
return null;
|
|
99
|
-
} catch {
|
|
100
|
-
|
|
191
|
+
} catch (err) {
|
|
192
|
+
warn?.(
|
|
193
|
+
`add: inference hook ${rel} failed (skill left untagged): ${err instanceof Error ? err.message : String(err)}`,
|
|
194
|
+
);
|
|
195
|
+
return null;
|
|
101
196
|
}
|
|
102
197
|
}
|
|
103
198
|
return null;
|
|
104
199
|
}
|
|
105
200
|
|
|
201
|
+
interface InstallOptions {
|
|
202
|
+
libraryPath: string;
|
|
203
|
+
domainFolder: string | null;
|
|
204
|
+
/** single-skill --name override; ignored in multi mode */
|
|
205
|
+
nameOverride: string | null;
|
|
206
|
+
/** per-skill lockfile `source` string (already carries the @subpath / #subpath) */
|
|
207
|
+
sourceStr: string;
|
|
208
|
+
ref: string;
|
|
209
|
+
channel: string;
|
|
210
|
+
infer: boolean;
|
|
211
|
+
force: boolean;
|
|
212
|
+
/** multi mode applies skip-differs-without-force + the never-write-through-symlink
|
|
213
|
+
* guard; single mode preserves the legacy "exists without --force → error" rule. */
|
|
214
|
+
multi: boolean;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
interface InstallOutcome {
|
|
218
|
+
name: string;
|
|
219
|
+
subpath: string;
|
|
220
|
+
verdict: Verdict | "duplicate";
|
|
221
|
+
status: "installed" | "skipped" | "error";
|
|
222
|
+
reason: string;
|
|
223
|
+
path: string;
|
|
224
|
+
source: string;
|
|
225
|
+
ref: string;
|
|
226
|
+
channel: string;
|
|
227
|
+
installedAt: string;
|
|
228
|
+
tagged: boolean;
|
|
229
|
+
domains: string[];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Install (copy + record provenance + tag) a single discovered skill. */
|
|
233
|
+
async function installOne(
|
|
234
|
+
ctx: Ctx,
|
|
235
|
+
skill: DiscoveredSkill,
|
|
236
|
+
opts: InstallOptions,
|
|
237
|
+
): Promise<InstallOutcome> {
|
|
238
|
+
const rawName =
|
|
239
|
+
opts.nameOverride && opts.nameOverride.trim() !== "" ? opts.nameOverride.trim() : skill.name;
|
|
240
|
+
const base: InstallOutcome = {
|
|
241
|
+
name: rawName,
|
|
242
|
+
subpath: skill.subpath,
|
|
243
|
+
verdict: "new",
|
|
244
|
+
status: "error",
|
|
245
|
+
reason: "",
|
|
246
|
+
path: "",
|
|
247
|
+
source: opts.sourceStr,
|
|
248
|
+
ref: opts.ref,
|
|
249
|
+
channel: opts.channel,
|
|
250
|
+
installedAt: "",
|
|
251
|
+
tagged: false,
|
|
252
|
+
domains: [],
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// SECURITY: `name` derives from untrusted upstream frontmatter (or --name) and is
|
|
256
|
+
// joined into a library path. Reject anything that isn't a clean slug, then a
|
|
257
|
+
// belt-and-suspenders single-segment check, so a crafted name (e.g. "../../etc")
|
|
258
|
+
// can't escape the library before it reaches join()/copy.
|
|
259
|
+
if (!SLUG_RE.test(rawName)) {
|
|
260
|
+
return {
|
|
261
|
+
...base,
|
|
262
|
+
reason: `invalid skill name "${rawName}" — use lowercase letters, digits, and hyphens${opts.multi ? "" : " (override with --name <slug>)"}`,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
assertSafeName(rawName);
|
|
267
|
+
} catch (err) {
|
|
268
|
+
return { ...base, reason: err instanceof Error ? err.message : String(err) };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const destDir = destDirFor(opts.libraryPath, opts.domainFolder, rawName);
|
|
272
|
+
const verdict = await driftVerdict(skill, destDir);
|
|
273
|
+
base.verdict = verdict;
|
|
274
|
+
|
|
275
|
+
// 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 (e.g. a symlinked
|
|
277
|
+
// --domain folder) whose realpath escapes the library. Writing through either would
|
|
278
|
+
// clobber an external dev repo, even with --force (ADR-0004).
|
|
279
|
+
const throughSymlink = isSymlink(destDir) || destEscapesLibrary(opts.libraryPath, destDir);
|
|
280
|
+
|
|
281
|
+
if (opts.multi) {
|
|
282
|
+
if (throughSymlink) {
|
|
283
|
+
return {
|
|
284
|
+
...base,
|
|
285
|
+
status: "skipped",
|
|
286
|
+
reason: "linked entry / resolves outside the library — not overwriting via symlink",
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
// new + identical install; differs needs --force.
|
|
290
|
+
if (verdict === "differs" && !opts.force) {
|
|
291
|
+
return { ...base, status: "skipped", reason: "local body differs from upstream — not overwriting (use --force)" };
|
|
292
|
+
}
|
|
293
|
+
} else {
|
|
294
|
+
// SINGLE path: refuse to write through a symlink even with --force (never clobber a
|
|
295
|
+
// dev repo), then preserve today's exact rule — refuse any existing dest w/o --force.
|
|
296
|
+
if (throughSymlink) {
|
|
297
|
+
return {
|
|
298
|
+
...base,
|
|
299
|
+
reason: `${rawName} resolves through a symlink outside the library (${destDir}) — refusing to write (manage a linked entry with \`skl link\`/\`skl rm\`)`,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
if (existsSync(destDir) && !opts.force) {
|
|
303
|
+
return {
|
|
304
|
+
...base,
|
|
305
|
+
reason: `${rawName} already exists at ${destDir} (use --force to overwrite, or skl update ${rawName} to re-pull)`,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---- write into the library ----
|
|
311
|
+
await ensureDir(opts.domainFolder ? join(opts.libraryPath, opts.domainFolder) : opts.libraryPath);
|
|
312
|
+
await copySkillDir(skill.dir, destDir);
|
|
313
|
+
|
|
314
|
+
const installedBody = bodyOf(await readSkillBody(skill.dir));
|
|
315
|
+
const installedAt = new Date().toISOString();
|
|
316
|
+
const entry: LockEntry = {
|
|
317
|
+
name: rawName,
|
|
318
|
+
source: opts.sourceStr,
|
|
319
|
+
ref: opts.ref,
|
|
320
|
+
channel: opts.channel,
|
|
321
|
+
installedAt,
|
|
322
|
+
localEdits: false,
|
|
323
|
+
installedHash: hashContent(installedBody),
|
|
324
|
+
};
|
|
325
|
+
await recordEntry(opts.libraryPath, entry);
|
|
326
|
+
|
|
327
|
+
const installed: Skill = {
|
|
328
|
+
name: rawName,
|
|
329
|
+
description: skill.description,
|
|
330
|
+
primaryDomain: opts.domainFolder,
|
|
331
|
+
domains: opts.domainFolder ? [opts.domainFolder] : [],
|
|
332
|
+
path: destDir,
|
|
333
|
+
bodyPath: join(destDir, "SKILL.md"),
|
|
334
|
+
refFiles: [],
|
|
335
|
+
source: { source: opts.sourceStr, ref: opts.ref, channel: opts.channel, installedAt, localEdits: false },
|
|
336
|
+
retired: false,
|
|
337
|
+
mirrorOf: null,
|
|
338
|
+
contentHash: "",
|
|
339
|
+
};
|
|
340
|
+
if (opts.domainFolder) await setDomainsForName(opts.libraryPath, rawName, [opts.domainFolder]);
|
|
341
|
+
|
|
342
|
+
let inferred: string[] | null = null;
|
|
343
|
+
if (opts.infer) {
|
|
344
|
+
inferred = await maybeInferTags(installed, (m) => ctx.error(m));
|
|
345
|
+
if (inferred && inferred.length > 0) await setDomainsForName(opts.libraryPath, rawName, inferred);
|
|
346
|
+
}
|
|
347
|
+
const domains = inferred && inferred.length > 0 ? inferred : opts.domainFolder ? [opts.domainFolder] : [];
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
...base,
|
|
351
|
+
status: "installed",
|
|
352
|
+
reason:
|
|
353
|
+
verdict === "identical"
|
|
354
|
+
? "re-installed (identical body)"
|
|
355
|
+
: verdict === "differs"
|
|
356
|
+
? "overwrote differing body (--force)"
|
|
357
|
+
: "installed",
|
|
358
|
+
path: destDir,
|
|
359
|
+
installedAt,
|
|
360
|
+
tagged: Boolean(inferred && inferred.length > 0),
|
|
361
|
+
domains,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** `--list`: discover + print, no writes. */
|
|
366
|
+
function reportList(
|
|
367
|
+
ctx: Ctx,
|
|
368
|
+
flags: Flags,
|
|
369
|
+
parsed: ParsedSource,
|
|
370
|
+
ref: string,
|
|
371
|
+
discovered: DiscoveredSkill[],
|
|
372
|
+
library: Skill[],
|
|
373
|
+
): number {
|
|
374
|
+
const rows = discovered.map((d) => ({
|
|
375
|
+
name: d.name,
|
|
376
|
+
description: d.description,
|
|
377
|
+
subpath: d.subpath,
|
|
378
|
+
inLibrary: Boolean(findByName(library, d.name)),
|
|
379
|
+
}));
|
|
380
|
+
if (flags.json) {
|
|
381
|
+
ctx.json({ ok: true, action: "list", source: parsed.source, ref, count: rows.length, skills: rows });
|
|
382
|
+
return 0;
|
|
383
|
+
}
|
|
384
|
+
ctx.log(`${rows.length} skill(s) in ${parsed.source}${ref ? ` @ ${ref.slice(0, 10)}` : ""}:`);
|
|
385
|
+
ctx.log("");
|
|
386
|
+
for (const r of rows) {
|
|
387
|
+
const mark = r.inLibrary ? "✓" : " ";
|
|
388
|
+
ctx.log(` ${mark} ${r.name.padEnd(28)} ${r.subpath || "(root)"}`);
|
|
389
|
+
if (r.description) ctx.log(` ${r.description.length > 100 ? r.description.slice(0, 99) + "…" : r.description}`);
|
|
390
|
+
}
|
|
391
|
+
ctx.log("");
|
|
392
|
+
ctx.log(`✓ = already in your library. Install with: skl add ${flags.src} --all (or --skill <name,…>)`);
|
|
393
|
+
return 0;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** `--dry-run`: drift preflight over the full discovered set, no writes. */
|
|
397
|
+
async function reportDryRun(
|
|
398
|
+
ctx: Ctx,
|
|
399
|
+
flags: Flags,
|
|
400
|
+
parsed: ParsedSource,
|
|
401
|
+
ref: string,
|
|
402
|
+
discovered: DiscoveredSkill[],
|
|
403
|
+
domainFolder: string | null,
|
|
404
|
+
): Promise<number> {
|
|
405
|
+
interface Row {
|
|
406
|
+
name: string;
|
|
407
|
+
subpath: string;
|
|
408
|
+
verdict: Verdict | "invalid" | "linked";
|
|
409
|
+
willInstall: boolean;
|
|
410
|
+
needsForce: boolean;
|
|
411
|
+
}
|
|
412
|
+
const rows: Row[] = [];
|
|
413
|
+
for (const d of discovered) {
|
|
414
|
+
if (!SLUG_RE.test(d.name)) {
|
|
415
|
+
rows.push({ name: d.name, subpath: d.subpath, verdict: "invalid", willInstall: false, needsForce: false });
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const destDir = destDirFor(ctx.config.libraryPath, domainFolder, d.name);
|
|
419
|
+
if (isSymlink(destDir) || destEscapesLibrary(ctx.config.libraryPath, destDir)) {
|
|
420
|
+
rows.push({ name: d.name, subpath: d.subpath, verdict: "linked", willInstall: false, needsForce: false });
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
const verdict = await driftVerdict(d, destDir);
|
|
424
|
+
const needsForce = verdict === "differs";
|
|
425
|
+
const willInstall = verdict === "new" || verdict === "identical" || (verdict === "differs" && flags.force);
|
|
426
|
+
rows.push({ name: d.name, subpath: d.subpath, verdict, willInstall, needsForce });
|
|
427
|
+
}
|
|
428
|
+
const counts = {
|
|
429
|
+
new: rows.filter((r) => r.verdict === "new").length,
|
|
430
|
+
identical: rows.filter((r) => r.verdict === "identical").length,
|
|
431
|
+
differs: rows.filter((r) => r.verdict === "differs").length,
|
|
432
|
+
linked: rows.filter((r) => r.verdict === "linked").length,
|
|
433
|
+
invalid: rows.filter((r) => r.verdict === "invalid").length,
|
|
434
|
+
};
|
|
435
|
+
const willInstall = rows.filter((r) => r.willInstall).length;
|
|
436
|
+
if (flags.json) {
|
|
437
|
+
ctx.json({ ok: true, action: "dry-run", source: parsed.source, ref, counts, willInstall, force: flags.force, skills: rows });
|
|
438
|
+
return 0;
|
|
439
|
+
}
|
|
440
|
+
ctx.log(`dry-run for ${parsed.source}${ref ? ` @ ${ref.slice(0, 10)}` : ""} (${rows.length} skill(s)):`);
|
|
441
|
+
ctx.log("");
|
|
442
|
+
for (const r of rows) {
|
|
443
|
+
const tag =
|
|
444
|
+
r.verdict === "new"
|
|
445
|
+
? "new "
|
|
446
|
+
: r.verdict === "identical"
|
|
447
|
+
? "identical"
|
|
448
|
+
: r.verdict === "differs"
|
|
449
|
+
? "DIFFERS "
|
|
450
|
+
: r.verdict === "linked"
|
|
451
|
+
? "linked "
|
|
452
|
+
: "INVALID ";
|
|
453
|
+
const note = r.verdict === "differs" && !flags.force ? " (needs --force)" : "";
|
|
454
|
+
ctx.log(` ${tag} ${r.name.padEnd(28)} ${r.subpath || "(root)"}${note}`);
|
|
455
|
+
}
|
|
456
|
+
ctx.log("");
|
|
457
|
+
ctx.log(
|
|
458
|
+
`${counts.new} new, ${counts.identical} identical, ${counts.differs} differ${counts.linked ? `, ${counts.linked} linked` : ""}${counts.invalid ? `, ${counts.invalid} invalid` : ""} → ${willInstall} would install${flags.force ? " (--force)" : ""}.`,
|
|
459
|
+
);
|
|
460
|
+
if (counts.differs > 0 && !flags.force) ctx.log("re-run with --force to overwrite differing skills.");
|
|
461
|
+
return 0;
|
|
462
|
+
}
|
|
463
|
+
|
|
106
464
|
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
107
465
|
const flags = parseFlags(argv);
|
|
108
466
|
if (!flags.src) {
|
|
109
467
|
ctx.error("usage:", meta.usage);
|
|
110
468
|
return 1;
|
|
111
469
|
}
|
|
470
|
+
if (flags.all && flags.skill !== null) {
|
|
471
|
+
ctx.error("add: --all and --skill are mutually exclusive");
|
|
472
|
+
return 1;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// --domain validated once up front (applies to every install path).
|
|
476
|
+
const domainFolder = flags.domain && flags.domain.trim() !== "" ? flags.domain.trim() : null;
|
|
477
|
+
if (domainFolder !== null && !SLUG_RE.test(domainFolder)) {
|
|
478
|
+
ctx.error(`add: invalid --domain "${domainFolder}" — use lowercase letters, digits, and hyphens`);
|
|
479
|
+
return 1;
|
|
480
|
+
}
|
|
112
481
|
|
|
113
482
|
const parsed = parseSource(flags.src);
|
|
483
|
+
const repoChannel = parsed.channel === "github" || parsed.channel === "git";
|
|
484
|
+
const wantsMulti = flags.all || flags.skill !== null;
|
|
114
485
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
486
|
+
if (flags.name && wantsMulti) {
|
|
487
|
+
ctx.error("add: --name applies only to a single-skill add (omit --all/--skill)");
|
|
488
|
+
return 1;
|
|
489
|
+
}
|
|
490
|
+
if (!repoChannel && (wantsMulti || flags.list || flags.dryRun)) {
|
|
491
|
+
ctx.error("add: --all/--skill/--list/--dry-run apply to github:/git: repo sources, not registry names");
|
|
120
492
|
return 1;
|
|
121
493
|
}
|
|
122
494
|
|
|
495
|
+
// Per-skill lockfile `source`: each skill carries its OWN subpath (github uses the
|
|
496
|
+
// `owner/repo@subpath` convention; git encodes it after `#`; registry is the name).
|
|
497
|
+
const sourceOf = (skill: DiscoveredSkill): string => {
|
|
498
|
+
if (parsed.channel === "github") return `${parsed.source}${skill.subpath ? `@${skill.subpath}` : ""}`;
|
|
499
|
+
if (parsed.channel === "git") return `git:${parsed.localPath}${skill.subpath ? `#${skill.subpath}` : ""}`;
|
|
500
|
+
return parsed.source; // registry: the bare name
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
// ---- 1+2. FETCH (clone once for repos; the `skills` CLI for a registry name) ----
|
|
504
|
+
let staging: string | undefined;
|
|
505
|
+
let ref: string;
|
|
506
|
+
let channel: string;
|
|
507
|
+
let discovered: DiscoveredSkill[];
|
|
508
|
+
|
|
509
|
+
if (repoChannel) {
|
|
510
|
+
const repo = await fetchRepo(parsed);
|
|
511
|
+
if (!repo.ok) {
|
|
512
|
+
await cleanupStaging(repo.staging);
|
|
513
|
+
ctx.error("add: download failed:", repo.error);
|
|
514
|
+
return 1;
|
|
515
|
+
}
|
|
516
|
+
staging = repo.staging;
|
|
517
|
+
ref = repo.ref;
|
|
518
|
+
channel = repo.channel;
|
|
519
|
+
discovered = await discoverSkills(repo.checkout, parsed.subpath);
|
|
520
|
+
// Implicit single-skill add: if the convention gate found nothing, fall back to
|
|
521
|
+
// lenient single resolution so a one-skill repo whose SKILL.md omits a description
|
|
522
|
+
// still installs (pre-ADR-0006 behavior). NOT for --all/--skill/--list/--dry-run,
|
|
523
|
+
// where the name+description gate is the intended filter.
|
|
524
|
+
if (discovered.length === 0 && !flags.all && flags.skill === null && !flags.list && !flags.dryRun) {
|
|
525
|
+
const one = await discoverSingleLenient(repo.checkout, parsed.subpath);
|
|
526
|
+
if (one) discovered = [one];
|
|
527
|
+
}
|
|
528
|
+
} else {
|
|
529
|
+
const fetched = await fetchSource(parsed);
|
|
530
|
+
if (!fetched.ok) {
|
|
531
|
+
await cleanupStaging(fetched.staging);
|
|
532
|
+
ctx.error("add: download failed:", fetched.error);
|
|
533
|
+
return 1;
|
|
534
|
+
}
|
|
535
|
+
staging = fetched.staging;
|
|
536
|
+
ref = fetched.ref;
|
|
537
|
+
channel = fetched.channel;
|
|
538
|
+
const body = await readSkillBody(fetched.skillDir);
|
|
539
|
+
const { data } = parseFrontmatter(body);
|
|
540
|
+
const nm = typeof data.name === "string" && data.name.trim() !== "" ? data.name.trim() : basename(fetched.skillDir);
|
|
541
|
+
const desc = typeof data.description === "string" ? data.description.trim() : "";
|
|
542
|
+
discovered = [{ name: nm, dir: fetched.skillDir, subpath: "", description: desc }];
|
|
543
|
+
}
|
|
544
|
+
|
|
123
545
|
try {
|
|
124
|
-
|
|
125
|
-
const name = await deriveName(fetched.skillDir, flags.name);
|
|
126
|
-
const domainFolder = flags.domain && flags.domain.trim() !== "" ? flags.domain.trim() : null;
|
|
127
|
-
const destDir = domainFolder
|
|
128
|
-
? join(ctx.config.libraryPath, domainFolder, name)
|
|
129
|
-
: join(ctx.config.libraryPath, name);
|
|
130
|
-
|
|
131
|
-
if (existsSync(destDir) && !flags.force) {
|
|
546
|
+
if (discovered.length === 0) {
|
|
132
547
|
ctx.error(
|
|
133
|
-
|
|
548
|
+
"add:",
|
|
549
|
+
parsed.subpath
|
|
550
|
+
? `no SKILL.md found at ${parsed.subpath} in ${parsed.source}`
|
|
551
|
+
: `no skills found in ${parsed.source}`,
|
|
134
552
|
);
|
|
135
553
|
return 1;
|
|
136
554
|
}
|
|
137
555
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const entry: LockEntry = {
|
|
148
|
-
name,
|
|
149
|
-
source: `${fetched.source}${subSuffix}`,
|
|
150
|
-
ref: fetched.ref,
|
|
151
|
-
channel: fetched.channel,
|
|
152
|
-
installedAt: new Date().toISOString(),
|
|
153
|
-
localEdits: false,
|
|
154
|
-
installedHash: hashContent(installedBody),
|
|
155
|
-
};
|
|
156
|
-
await recordEntry(ctx.config.libraryPath, entry);
|
|
157
|
-
|
|
158
|
-
// 5. Empty overlay so taxonomy survives future updates.
|
|
159
|
-
const installed: Skill = {
|
|
160
|
-
name,
|
|
161
|
-
description: "",
|
|
162
|
-
primaryDomain: domainFolder,
|
|
163
|
-
domains: domainFolder ? [domainFolder] : [],
|
|
164
|
-
path: destDir,
|
|
165
|
-
bodyPath: join(destDir, "SKILL.md"),
|
|
166
|
-
refFiles: [],
|
|
167
|
-
source: {
|
|
168
|
-
source: entry.source,
|
|
169
|
-
ref: entry.ref,
|
|
170
|
-
channel: entry.channel,
|
|
171
|
-
installedAt: entry.installedAt,
|
|
172
|
-
localEdits: false,
|
|
173
|
-
},
|
|
174
|
-
retired: false,
|
|
175
|
-
mirrorOf: null,
|
|
176
|
-
contentHash: "",
|
|
177
|
-
};
|
|
178
|
-
const overlayPathStr = join(destDir, `${name}.shelf.json`);
|
|
179
|
-
if (!existsSync(overlayPathStr)) {
|
|
180
|
-
await writeOverlay(installed, domainFolder ? { domains: [domainFolder] } : {});
|
|
556
|
+
// ---- --list (report full set, no writes) ----
|
|
557
|
+
if (flags.list) {
|
|
558
|
+
const library = await loadLibrary(ctx.config.libraryPath);
|
|
559
|
+
return reportList(ctx, flags, parsed, ref, discovered, library);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ---- --dry-run (drift preflight over the full set, no writes) ----
|
|
563
|
+
if (flags.dryRun) {
|
|
564
|
+
return await reportDryRun(ctx, flags, parsed, ref, discovered, domainFolder);
|
|
181
565
|
}
|
|
182
566
|
|
|
183
|
-
//
|
|
184
|
-
let
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
567
|
+
// ---- selection ----
|
|
568
|
+
let selected: DiscoveredSkill[];
|
|
569
|
+
let multi: boolean;
|
|
570
|
+
if (flags.skill !== null) {
|
|
571
|
+
const want = new Set(flags.skill);
|
|
572
|
+
selected = discovered.filter((d) => want.has(d.name));
|
|
573
|
+
const missing = [...want].filter((n) => !discovered.some((d) => d.name === n));
|
|
574
|
+
if (missing.length > 0) {
|
|
575
|
+
ctx.error(`add: requested skill(s) not found in ${parsed.source}: ${missing.join(", ")}`);
|
|
576
|
+
ctx.error(` available: ${discovered.map((d) => d.name).join(", ") || "(none)"}`);
|
|
577
|
+
return 1;
|
|
189
578
|
}
|
|
579
|
+
multi = true;
|
|
580
|
+
} else if (flags.all) {
|
|
581
|
+
selected = discovered;
|
|
582
|
+
multi = true;
|
|
583
|
+
} else if (discovered.length > 1) {
|
|
584
|
+
ctx.error(
|
|
585
|
+
`add: ${discovered.length} skills found in ${parsed.source} — choose with --all, --skill <name,…>, or inspect with --list:`,
|
|
586
|
+
);
|
|
587
|
+
for (const d of discovered) ctx.error(` ${d.name}${d.subpath ? ` (${d.subpath})` : ""}`);
|
|
588
|
+
return 1;
|
|
589
|
+
} else {
|
|
590
|
+
selected = discovered;
|
|
591
|
+
multi = false;
|
|
190
592
|
}
|
|
191
593
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
594
|
+
// ---- install ----
|
|
595
|
+
if (!multi) {
|
|
596
|
+
const o = await installOne(ctx, selected[0]!, {
|
|
597
|
+
libraryPath: ctx.config.libraryPath,
|
|
598
|
+
domainFolder,
|
|
599
|
+
nameOverride: flags.name,
|
|
600
|
+
sourceStr: sourceOf(selected[0]!),
|
|
601
|
+
ref,
|
|
602
|
+
channel,
|
|
603
|
+
infer: flags.infer,
|
|
604
|
+
force: flags.force,
|
|
605
|
+
multi: false,
|
|
606
|
+
});
|
|
607
|
+
if (o.status === "error") {
|
|
608
|
+
ctx.error("add:", o.reason);
|
|
609
|
+
return 1;
|
|
610
|
+
}
|
|
611
|
+
// Legacy single-skill summary shape (unchanged for existing consumers).
|
|
612
|
+
const summary = {
|
|
613
|
+
ok: true,
|
|
614
|
+
name: o.name,
|
|
615
|
+
path: o.path,
|
|
616
|
+
source: o.source,
|
|
617
|
+
ref: o.ref,
|
|
618
|
+
channel: o.channel,
|
|
619
|
+
installedAt: o.installedAt,
|
|
620
|
+
tagged: o.tagged,
|
|
621
|
+
domains: o.domains,
|
|
622
|
+
};
|
|
623
|
+
if (flags.json) {
|
|
624
|
+
ctx.json(summary);
|
|
625
|
+
} else {
|
|
626
|
+
ctx.log(`added ${o.name}`);
|
|
627
|
+
ctx.log(` path: ${o.path}`);
|
|
628
|
+
ctx.log(` source: ${o.source}`);
|
|
629
|
+
ctx.log(` ref: ${o.ref || "(unknown)"}`);
|
|
630
|
+
ctx.log(` channel: ${o.channel}`);
|
|
631
|
+
if (o.tagged) ctx.log(` domains: ${o.domains.join(", ")}`);
|
|
632
|
+
else ctx.log(` domains: (untagged — run \`skl infer\` to assign)`);
|
|
633
|
+
}
|
|
634
|
+
return 0;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// multi: install each selected skill out of the single staging checkout. Two
|
|
638
|
+
// upstream skills sharing a frontmatter `name` would target the SAME library slug
|
|
639
|
+
// (last-write-wins clobber + a single lockfile entry); install the first, skip the
|
|
640
|
+
// rest with a duplicate-slug reason so nothing is silently lost or miscounted.
|
|
641
|
+
const outcomes: InstallOutcome[] = [];
|
|
642
|
+
const seenSlugs = new Set<string>();
|
|
643
|
+
for (const s of selected) {
|
|
644
|
+
if (seenSlugs.has(s.name)) {
|
|
645
|
+
outcomes.push({
|
|
646
|
+
name: s.name,
|
|
647
|
+
subpath: s.subpath,
|
|
648
|
+
verdict: "duplicate",
|
|
649
|
+
status: "skipped",
|
|
650
|
+
reason: `duplicate slug "${s.name}" — another skill in this repo already installs to it; skipped to avoid clobbering`,
|
|
651
|
+
path: "",
|
|
652
|
+
source: sourceOf(s),
|
|
653
|
+
ref,
|
|
654
|
+
channel,
|
|
655
|
+
installedAt: "",
|
|
656
|
+
tagged: false,
|
|
657
|
+
domains: [],
|
|
658
|
+
});
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
seenSlugs.add(s.name);
|
|
662
|
+
outcomes.push(
|
|
663
|
+
await installOne(ctx, s, {
|
|
664
|
+
libraryPath: ctx.config.libraryPath,
|
|
665
|
+
domainFolder,
|
|
666
|
+
nameOverride: null,
|
|
667
|
+
sourceStr: sourceOf(s),
|
|
668
|
+
ref,
|
|
669
|
+
channel,
|
|
670
|
+
infer: flags.infer,
|
|
671
|
+
force: flags.force,
|
|
672
|
+
multi: true,
|
|
673
|
+
}),
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
const installed = outcomes.filter((o) => o.status === "installed");
|
|
677
|
+
const skipped = outcomes.filter((o) => o.status === "skipped");
|
|
678
|
+
const errored = outcomes.filter((o) => o.status === "error");
|
|
203
679
|
|
|
204
680
|
if (flags.json) {
|
|
205
|
-
ctx.json(
|
|
681
|
+
ctx.json({
|
|
682
|
+
ok: errored.length === 0,
|
|
683
|
+
action: "add",
|
|
684
|
+
source: parsed.source,
|
|
685
|
+
ref,
|
|
686
|
+
counts: { selected: selected.length, installed: installed.length, skipped: skipped.length, errors: errored.length },
|
|
687
|
+
results: outcomes.map((o) => ({
|
|
688
|
+
name: o.name,
|
|
689
|
+
subpath: o.subpath,
|
|
690
|
+
status: o.status,
|
|
691
|
+
verdict: o.verdict,
|
|
692
|
+
reason: o.reason,
|
|
693
|
+
source: o.source,
|
|
694
|
+
ref: o.ref,
|
|
695
|
+
path: o.path,
|
|
696
|
+
tagged: o.tagged,
|
|
697
|
+
domains: o.domains,
|
|
698
|
+
})),
|
|
699
|
+
});
|
|
206
700
|
} else {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
ctx.log(
|
|
212
|
-
|
|
213
|
-
|
|
701
|
+
for (const o of outcomes) {
|
|
702
|
+
const tag = o.status === "installed" ? "added " : o.status === "skipped" ? "skipped " : "ERROR ";
|
|
703
|
+
ctx.log(`${tag} ${o.name.padEnd(28)} ${o.reason}`);
|
|
704
|
+
}
|
|
705
|
+
ctx.log("");
|
|
706
|
+
ctx.log(
|
|
707
|
+
`${selected.length} selected, ${installed.length} installed, ${skipped.length} skipped${errored.length ? `, ${errored.length} error(s)` : ""} from ${parsed.source}`,
|
|
708
|
+
);
|
|
709
|
+
if (skipped.some((o) => o.verdict === "differs")) ctx.log("re-run with --force to overwrite differing skills.");
|
|
214
710
|
}
|
|
215
|
-
return 0;
|
|
711
|
+
return errored.length > 0 ? 1 : 0;
|
|
216
712
|
} catch (err) {
|
|
217
713
|
ctx.error("add: failed:", err instanceof Error ? err.message : String(err));
|
|
218
714
|
return 1;
|
|
219
715
|
} finally {
|
|
220
|
-
await cleanupStaging(
|
|
716
|
+
await cleanupStaging(staging);
|
|
221
717
|
}
|
|
222
718
|
}
|