skillshelf 0.5.0 → 0.6.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/package.json +1 -1
- package/src/commands/add.ts +117 -349
- package/src/commands/agents.ts +63 -54
- package/src/commands/import.ts +7 -7
- package/src/commands/infer.ts +27 -26
- package/src/commands/link.ts +34 -23
- package/src/commands/ls.ts +24 -18
- package/src/commands/migrate.ts +4 -4
- package/src/commands/outdated.ts +74 -55
- package/src/commands/projects.ts +33 -26
- package/src/commands/rm.ts +29 -17
- package/src/commands/scan.ts +74 -66
- package/src/commands/status.ts +39 -37
- package/src/commands/track.ts +7 -209
- package/src/commands/update.ts +114 -184
- package/src/commands/use.ts +32 -22
- package/src/commands/where.ts +61 -55
- package/src/core/agent-matrix.test.ts +153 -0
- package/src/core/agent-matrix.ts +184 -0
- package/src/core/agents.test.ts +4 -4
- package/src/core/agents.ts +55 -139
- package/src/core/reconcile.test.ts +203 -0
- package/src/core/reconcile.ts +142 -0
- package/src/core/report.test.ts +167 -0
- package/src/core/report.ts +129 -0
- package/src/core/surfaces.ts +1 -0
- package/src/core/vendor.test.ts +383 -0
- package/src/core/vendor.ts +564 -0
package/package.json
CHANGED
package/src/commands/add.ts
CHANGED
|
@@ -18,28 +18,31 @@
|
|
|
18
18
|
//
|
|
19
19
|
// Read-only commands take --json; add is a write, but still emits a --json summary.
|
|
20
20
|
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import type { Ctx, Skill, LockEntry } from "../types.ts";
|
|
21
|
+
import { basename } from "node:path";
|
|
22
|
+
import type { Ctx, Skill } from "../types.ts";
|
|
24
23
|
import {
|
|
25
24
|
parseSource,
|
|
26
25
|
fetchSource,
|
|
27
26
|
fetchRepo,
|
|
28
27
|
discoverSkills,
|
|
29
28
|
discoverSingleLenient,
|
|
30
|
-
copySkillDir,
|
|
31
29
|
cleanupStaging,
|
|
32
30
|
readSkillBody,
|
|
33
31
|
type DiscoveredSkill,
|
|
34
32
|
type ParsedSource,
|
|
35
33
|
} from "../core/fetch.ts";
|
|
36
34
|
import { parseFrontmatter } from "../lib/frontmatter.ts";
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
35
|
+
import { SLUG_RE } from "../core/lifecycle.ts";
|
|
36
|
+
import { loadLibrary, findByName } from "../core/library.ts";
|
|
37
|
+
import {
|
|
38
|
+
installSkill,
|
|
39
|
+
destDirFor,
|
|
40
|
+
driftVerdict,
|
|
41
|
+
writesThroughSymlink,
|
|
42
|
+
type InstallOutcome,
|
|
43
|
+
type Verdict,
|
|
44
|
+
} from "../core/vendor.ts";
|
|
45
|
+
import { render, addDryRunVerdictMark, type CommandResult } from "../core/report.ts";
|
|
43
46
|
|
|
44
47
|
export const meta = {
|
|
45
48
|
name: "add",
|
|
@@ -116,256 +119,11 @@ function parseFlags(argv: string[]): Flags {
|
|
|
116
119
|
return f;
|
|
117
120
|
}
|
|
118
121
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
/** The library destination dir for a slug (under its domain folder, if any). */
|
|
125
|
-
function destDirFor(libraryPath: string, domainFolder: string | null, name: string): string {
|
|
126
|
-
return domainFolder ? join(libraryPath, domainFolder, name) : join(libraryPath, name);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** The nearest ancestor of `p` (incl. `p`) that exists on disk; falls back to `p`. */
|
|
130
|
-
function nearestExisting(p: string): string {
|
|
131
|
-
let cur = p;
|
|
132
|
-
while (!existsSync(cur) && !isSymlink(cur)) {
|
|
133
|
-
const parent = dirname(cur);
|
|
134
|
-
if (parent === cur) return cur;
|
|
135
|
-
cur = parent;
|
|
136
|
-
}
|
|
137
|
-
return cur;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* True if writing to `destDir` would resolve OUTSIDE the library — i.e. the nearest
|
|
142
|
-
* existing component on the way to destDir is (or is reached through) a symlink whose
|
|
143
|
-
* realpath escapes the library. Catches a symlinked DOMAIN folder (`library/<d> ->
|
|
144
|
-
* /external`) that the leaf-only `isSymlink(destDir)` check misses, so `--force` can't
|
|
145
|
-
* clobber an external tree through a symlinked parent (ADR-0004). Both sides anchor to
|
|
146
|
-
* their nearest existing ancestor, so a fresh (not-yet-created) library is not a false
|
|
147
|
-
* positive — its anchor is the shared parent, which contains itself.
|
|
148
|
-
*/
|
|
149
|
-
function destEscapesLibrary(libraryPath: string, destDir: string): boolean {
|
|
150
|
-
const libReal = realpathOrSelf(nearestExisting(libraryPath));
|
|
151
|
-
const destReal = realpathOrSelf(nearestExisting(destDir));
|
|
152
|
-
return !(destReal === libReal || destReal.startsWith(libReal + sep));
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
type Verdict = "new" | "identical" | "differs";
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Drift preflight for one skill against the library destination it would install to:
|
|
159
|
-
* - new — nothing at the destination → would install
|
|
160
|
-
* - identical — destination body hash matches upstream → lossless overwrite
|
|
161
|
-
* - differs — destination body differs → would clobber local content (needs --force)
|
|
162
|
-
* Compares the frontmatter-stripped BODY (matches installedHash / `skl update`).
|
|
163
|
-
*/
|
|
164
|
-
async function driftVerdict(skill: DiscoveredSkill, destDir: string): Promise<Verdict> {
|
|
165
|
-
const localPath = join(destDir, "SKILL.md");
|
|
166
|
-
if (!existsSync(localPath)) return "new";
|
|
167
|
-
const upstream = hashContent(bodyOf(await readSkillBody(skill.dir)));
|
|
168
|
-
let localText = "";
|
|
169
|
-
try {
|
|
170
|
-
localText = await Bun.file(localPath).text();
|
|
171
|
-
} catch {
|
|
172
|
-
localText = "";
|
|
173
|
-
}
|
|
174
|
-
const local = hashContent(bodyOf(localText));
|
|
175
|
-
return upstream === local ? "identical" : "differs";
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Optional AI inference tagging pass over a freshly-installed skill. No inference hook
|
|
180
|
-
* ships today, so this always leaves the skill untagged (returns null); installs land
|
|
181
|
-
* with whatever `--domain` gave them. Kept as a seam so `--infer`/`--no-infer` and the
|
|
182
|
-
* `tagged` summary field stay meaningful when a hook is wired in.
|
|
183
|
-
*/
|
|
184
|
-
async function maybeInferTags(
|
|
185
|
-
_skill: Skill,
|
|
186
|
-
_warn?: (msg: string) => void,
|
|
187
|
-
): Promise<string[] | null> {
|
|
188
|
-
return null;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
interface InstallOptions {
|
|
192
|
-
libraryPath: string;
|
|
193
|
-
domainFolder: string | null;
|
|
194
|
-
/** single-skill --name override; ignored in multi mode */
|
|
195
|
-
nameOverride: string | null;
|
|
196
|
-
/** per-skill lockfile `source` string (already carries the @subpath / #subpath) */
|
|
197
|
-
sourceStr: string;
|
|
198
|
-
ref: string;
|
|
199
|
-
channel: string;
|
|
200
|
-
infer: boolean;
|
|
201
|
-
force: boolean;
|
|
202
|
-
/** multi mode applies skip-differs-without-force + the never-write-through-symlink
|
|
203
|
-
* guard; single mode preserves the legacy "exists without --force → error" rule. */
|
|
204
|
-
multi: boolean;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
interface InstallOutcome {
|
|
208
|
-
name: string;
|
|
209
|
-
subpath: string;
|
|
210
|
-
verdict: Verdict | "duplicate" | "retired";
|
|
211
|
-
status: "installed" | "skipped" | "error";
|
|
212
|
-
reason: string;
|
|
213
|
-
path: string;
|
|
214
|
-
source: string;
|
|
215
|
-
ref: string;
|
|
216
|
-
channel: string;
|
|
217
|
-
installedAt: string;
|
|
218
|
-
tagged: boolean;
|
|
219
|
-
domains: string[];
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/** Install (copy + record provenance + tag) a single discovered skill. */
|
|
223
|
-
async function installOne(
|
|
224
|
-
ctx: Ctx,
|
|
225
|
-
skill: DiscoveredSkill,
|
|
226
|
-
opts: InstallOptions,
|
|
227
|
-
): Promise<InstallOutcome> {
|
|
228
|
-
const rawName =
|
|
229
|
-
opts.nameOverride && opts.nameOverride.trim() !== "" ? opts.nameOverride.trim() : skill.name;
|
|
230
|
-
const base: InstallOutcome = {
|
|
231
|
-
name: rawName,
|
|
232
|
-
subpath: skill.subpath,
|
|
233
|
-
verdict: "new",
|
|
234
|
-
status: "error",
|
|
235
|
-
reason: "",
|
|
236
|
-
path: "",
|
|
237
|
-
source: opts.sourceStr,
|
|
238
|
-
ref: opts.ref,
|
|
239
|
-
channel: opts.channel,
|
|
240
|
-
installedAt: "",
|
|
241
|
-
tagged: false,
|
|
242
|
-
domains: [],
|
|
243
|
-
};
|
|
244
|
-
|
|
245
|
-
// SECURITY: `name` derives from untrusted upstream frontmatter (or --name) and is
|
|
246
|
-
// joined into a library path. Reject anything that isn't a clean slug, then a
|
|
247
|
-
// belt-and-suspenders single-segment check, so a crafted name (e.g. "../../etc")
|
|
248
|
-
// can't escape the library before it reaches join()/copy.
|
|
249
|
-
if (!SLUG_RE.test(rawName)) {
|
|
250
|
-
return {
|
|
251
|
-
...base,
|
|
252
|
-
reason: `invalid skill name "${rawName}" — use lowercase letters, digits, and hyphens${opts.multi ? "" : " (override with --name <slug>)"}`,
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
try {
|
|
256
|
-
assertSafeName(rawName);
|
|
257
|
-
} catch (err) {
|
|
258
|
-
return { ...base, reason: err instanceof Error ? err.message : String(err) };
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// Retired-aware collision guard: if this name exists ONLY as a retired tombstone
|
|
262
|
-
// (<library>/_retired/<name>), do NOT install a fresh active copy beside it — that
|
|
263
|
-
// strands a duplicate and breaks `skl unretire`. The user must unretire first. This
|
|
264
|
-
// fires regardless of --force (force overwrites an ACTIVE copy, not a retired one).
|
|
265
|
-
// Checked against the flat library root (retirement is never under a domain folder).
|
|
266
|
-
const status = entryStatus(opts.libraryPath, rawName);
|
|
267
|
-
if (status.retired && !status.active) {
|
|
268
|
-
return {
|
|
269
|
-
...base,
|
|
270
|
-
verdict: "retired",
|
|
271
|
-
status: "skipped",
|
|
272
|
-
reason: `a retired '${rawName}' exists — run \`skl unretire ${rawName}\` first`,
|
|
273
|
-
};
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
const destDir = destDirFor(opts.libraryPath, opts.domainFolder, rawName);
|
|
277
|
-
const verdict = await driftVerdict(skill, destDir);
|
|
278
|
-
base.verdict = verdict;
|
|
279
|
-
|
|
280
|
-
// Never copy THROUGH a symlink into something the library doesn't own: a LINKED leaf
|
|
281
|
-
// entry, OR a destination reached through a symlinked ANCESTOR (e.g. a symlinked
|
|
282
|
-
// --domain folder) whose realpath escapes the library. Writing through either would
|
|
283
|
-
// clobber an external dev repo, even with --force (ADR-0004).
|
|
284
|
-
const throughSymlink = isSymlink(destDir) || destEscapesLibrary(opts.libraryPath, destDir);
|
|
285
|
-
|
|
286
|
-
if (opts.multi) {
|
|
287
|
-
if (throughSymlink) {
|
|
288
|
-
return {
|
|
289
|
-
...base,
|
|
290
|
-
status: "skipped",
|
|
291
|
-
reason: "linked entry / resolves outside the library — not overwriting via symlink",
|
|
292
|
-
};
|
|
293
|
-
}
|
|
294
|
-
// new + identical install; differs needs --force.
|
|
295
|
-
if (verdict === "differs" && !opts.force) {
|
|
296
|
-
return { ...base, status: "skipped", reason: "local body differs from upstream — not overwriting (use --force)" };
|
|
297
|
-
}
|
|
298
|
-
} else {
|
|
299
|
-
// SINGLE path: refuse to write through a symlink even with --force (never clobber a
|
|
300
|
-
// dev repo), then preserve today's exact rule — refuse any existing dest w/o --force.
|
|
301
|
-
if (throughSymlink) {
|
|
302
|
-
return {
|
|
303
|
-
...base,
|
|
304
|
-
reason: `${rawName} resolves through a symlink outside the library (${destDir}) — refusing to write (manage a linked entry with \`skl link\`/\`skl rm\`)`,
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
|
-
if (existsSync(destDir) && !opts.force) {
|
|
308
|
-
return {
|
|
309
|
-
...base,
|
|
310
|
-
reason: `${rawName} already exists at ${destDir} (use --force to overwrite, or skl update ${rawName} to re-pull)`,
|
|
311
|
-
};
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// ---- write into the library ----
|
|
316
|
-
await ensureDir(opts.domainFolder ? join(opts.libraryPath, opts.domainFolder) : opts.libraryPath);
|
|
317
|
-
await copySkillDir(skill.dir, destDir);
|
|
318
|
-
|
|
319
|
-
const installedBody = bodyOf(await readSkillBody(skill.dir));
|
|
320
|
-
const installedAt = new Date().toISOString();
|
|
321
|
-
const entry: LockEntry = {
|
|
322
|
-
name: rawName,
|
|
323
|
-
source: opts.sourceStr,
|
|
324
|
-
ref: opts.ref,
|
|
325
|
-
channel: opts.channel,
|
|
326
|
-
installedAt,
|
|
327
|
-
localEdits: false,
|
|
328
|
-
installedHash: hashContent(installedBody),
|
|
329
|
-
};
|
|
330
|
-
await recordEntry(opts.libraryPath, entry);
|
|
331
|
-
|
|
332
|
-
const installed: Skill = {
|
|
333
|
-
name: rawName,
|
|
334
|
-
description: skill.description,
|
|
335
|
-
primaryDomain: opts.domainFolder,
|
|
336
|
-
domains: opts.domainFolder ? [opts.domainFolder] : [],
|
|
337
|
-
path: destDir,
|
|
338
|
-
bodyPath: join(destDir, "SKILL.md"),
|
|
339
|
-
refFiles: [],
|
|
340
|
-
source: { source: opts.sourceStr, ref: opts.ref, channel: opts.channel, installedAt, localEdits: false },
|
|
341
|
-
retired: false,
|
|
342
|
-
mirrorOf: null,
|
|
343
|
-
contentHash: "",
|
|
344
|
-
};
|
|
345
|
-
if (opts.domainFolder) await setDomainsForName(opts.libraryPath, rawName, [opts.domainFolder]);
|
|
346
|
-
|
|
347
|
-
let inferred: string[] | null = null;
|
|
348
|
-
if (opts.infer) {
|
|
349
|
-
inferred = await maybeInferTags(installed, (m) => ctx.error(m));
|
|
350
|
-
if (inferred && inferred.length > 0) await setDomainsForName(opts.libraryPath, rawName, inferred);
|
|
351
|
-
}
|
|
352
|
-
const domains = inferred && inferred.length > 0 ? inferred : opts.domainFolder ? [opts.domainFolder] : [];
|
|
353
|
-
|
|
354
|
-
return {
|
|
355
|
-
...base,
|
|
356
|
-
status: "installed",
|
|
357
|
-
reason:
|
|
358
|
-
verdict === "identical"
|
|
359
|
-
? "re-installed (identical body)"
|
|
360
|
-
: verdict === "differs"
|
|
361
|
-
? "overwrote differing body (--force)"
|
|
362
|
-
: "installed",
|
|
363
|
-
path: destDir,
|
|
364
|
-
installedAt,
|
|
365
|
-
tagged: Boolean(inferred && inferred.length > 0),
|
|
366
|
-
domains,
|
|
367
|
-
};
|
|
368
|
-
}
|
|
122
|
+
// The vendor WRITE operations (installSkill copy+provenance+verdict, the drift verdict,
|
|
123
|
+
// destDirFor, and the symlink-escape guard) live in core/vendor.ts — the curator boundary
|
|
124
|
+
// where `add` (and only `add`) writes the library. This command keeps the parse + select
|
|
125
|
+
// + render; vendor owns the mutation. driftVerdict is still imported here for the
|
|
126
|
+
// read-only --dry-run preflight (no write).
|
|
369
127
|
|
|
370
128
|
/** `--list`: discover + print, no writes. */
|
|
371
129
|
function reportList(
|
|
@@ -386,22 +144,26 @@ function reportList(
|
|
|
386
144
|
published: d.published,
|
|
387
145
|
internal: d.internal,
|
|
388
146
|
}));
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
147
|
+
// Structured payload (verbatim) + human renderer; the json/human fork goes through
|
|
148
|
+
// render() (the reporter seam). Read-only --list always exits 0.
|
|
149
|
+
const result: CommandResult = {
|
|
150
|
+
json: { ok: true, action: "list", source: parsed.source, ref, count: rows.length, skills: rows },
|
|
151
|
+
human: (emit) => {
|
|
152
|
+
const publishedCount = rows.filter((r) => r.published).length;
|
|
153
|
+
emit(`${rows.length} skill(s) in ${parsed.source}${ref ? ` @ ${ref.slice(0, 10)}` : ""} (${publishedCount} published):`);
|
|
154
|
+
emit();
|
|
155
|
+
for (const r of rows) {
|
|
156
|
+
const mark = r.inLibrary ? "✓" : " ";
|
|
157
|
+
const tag = r.published ? "published " : r.internal ? "internal " : "unpublished";
|
|
158
|
+
emit(` ${mark} ${tag} ${r.name.padEnd(28)} ${r.subpath || "(root)"}`);
|
|
159
|
+
if (r.description) emit(` ${r.description.length > 100 ? r.description.slice(0, 99) + "…" : r.description}`);
|
|
160
|
+
}
|
|
161
|
+
emit();
|
|
162
|
+
emit(`✓ = already in your library. published = installed by --all; unpublished/internal = only via --skill <name>.`);
|
|
163
|
+
emit(`Install with: skl add ${flags.src} --all (or --skill <name,…>)`);
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
render(ctx, flags.json, result);
|
|
405
167
|
return 0;
|
|
406
168
|
}
|
|
407
169
|
|
|
@@ -428,7 +190,7 @@ async function reportDryRun(
|
|
|
428
190
|
continue;
|
|
429
191
|
}
|
|
430
192
|
const destDir = destDirFor(ctx.config.libraryPath, domainFolder, d.name);
|
|
431
|
-
if (
|
|
193
|
+
if (writesThroughSymlink(ctx.config.libraryPath, destDir)) {
|
|
432
194
|
rows.push({ name: d.name, subpath: d.subpath, verdict: "linked", willInstall: false, needsForce: false });
|
|
433
195
|
continue;
|
|
434
196
|
}
|
|
@@ -445,31 +207,25 @@ async function reportDryRun(
|
|
|
445
207
|
invalid: rows.filter((r) => r.verdict === "invalid").length,
|
|
446
208
|
};
|
|
447
209
|
const willInstall = rows.filter((r) => r.willInstall).length;
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
}
|
|
468
|
-
ctx.log("");
|
|
469
|
-
ctx.log(
|
|
470
|
-
`${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)" : ""}.`,
|
|
471
|
-
);
|
|
472
|
-
if (counts.differs > 0 && !flags.force) ctx.log("re-run with --force to overwrite differing skills.");
|
|
210
|
+
// Structured payload (verbatim) + human renderer; the verdict->tag ladder now lives in
|
|
211
|
+
// report.ts as addDryRunVerdictMark(). Read-only --dry-run always exits 0.
|
|
212
|
+
const result: CommandResult = {
|
|
213
|
+
json: { ok: true, action: "dry-run", source: parsed.source, ref, counts, willInstall, force: flags.force, skills: rows },
|
|
214
|
+
human: (emit) => {
|
|
215
|
+
emit(`dry-run for ${parsed.source}${ref ? ` @ ${ref.slice(0, 10)}` : ""} (${rows.length} skill(s)):`);
|
|
216
|
+
emit();
|
|
217
|
+
for (const r of rows) {
|
|
218
|
+
const note = r.verdict === "differs" && !flags.force ? " (needs --force)" : "";
|
|
219
|
+
emit(` ${addDryRunVerdictMark(r.verdict)} ${r.name.padEnd(28)} ${r.subpath || "(root)"}${note}`);
|
|
220
|
+
}
|
|
221
|
+
emit();
|
|
222
|
+
emit(
|
|
223
|
+
`${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)" : ""}.`,
|
|
224
|
+
);
|
|
225
|
+
if (counts.differs > 0 && !flags.force) emit("re-run with --force to overwrite differing skills.");
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
render(ctx, flags.json, result);
|
|
473
229
|
return 0;
|
|
474
230
|
}
|
|
475
231
|
|
|
@@ -635,17 +391,21 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
635
391
|
|
|
636
392
|
// ---- install ----
|
|
637
393
|
if (!multi) {
|
|
638
|
-
const o = await
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
394
|
+
const o = await installSkill(
|
|
395
|
+
selected[0]!,
|
|
396
|
+
{
|
|
397
|
+
libraryPath: ctx.config.libraryPath,
|
|
398
|
+
domainFolder,
|
|
399
|
+
nameOverride: flags.name,
|
|
400
|
+
sourceStr: sourceOf(selected[0]!),
|
|
401
|
+
ref,
|
|
402
|
+
channel,
|
|
403
|
+
infer: flags.infer,
|
|
404
|
+
force: flags.force,
|
|
405
|
+
multi: false,
|
|
406
|
+
},
|
|
407
|
+
(m) => ctx.error(m),
|
|
408
|
+
);
|
|
649
409
|
if (o.status === "error") {
|
|
650
410
|
ctx.error("add:", o.reason);
|
|
651
411
|
return 1;
|
|
@@ -668,17 +428,19 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
668
428
|
tagged: o.tagged,
|
|
669
429
|
domains: o.domains,
|
|
670
430
|
};
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
431
|
+
const result: CommandResult = {
|
|
432
|
+
json: summary,
|
|
433
|
+
human: (emit) => {
|
|
434
|
+
emit(`added ${o.name}`);
|
|
435
|
+
emit(` path: ${o.path}`);
|
|
436
|
+
emit(` source: ${o.source}`);
|
|
437
|
+
emit(` ref: ${o.ref || "(unknown)"}`);
|
|
438
|
+
emit(` channel: ${o.channel}`);
|
|
439
|
+
if (o.tagged) emit(` domains: ${o.domains.join(", ")}`);
|
|
440
|
+
else emit(` domains: (untagged — run \`skl infer\` to assign)`);
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
render(ctx, flags.json, result);
|
|
682
444
|
return 0;
|
|
683
445
|
}
|
|
684
446
|
|
|
@@ -708,25 +470,29 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
708
470
|
}
|
|
709
471
|
seenSlugs.add(s.name);
|
|
710
472
|
outcomes.push(
|
|
711
|
-
await
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
473
|
+
await installSkill(
|
|
474
|
+
s,
|
|
475
|
+
{
|
|
476
|
+
libraryPath: ctx.config.libraryPath,
|
|
477
|
+
domainFolder,
|
|
478
|
+
nameOverride: null,
|
|
479
|
+
sourceStr: sourceOf(s),
|
|
480
|
+
ref,
|
|
481
|
+
channel,
|
|
482
|
+
infer: flags.infer,
|
|
483
|
+
force: flags.force,
|
|
484
|
+
multi: true,
|
|
485
|
+
},
|
|
486
|
+
(m) => ctx.error(m),
|
|
487
|
+
),
|
|
722
488
|
);
|
|
723
489
|
}
|
|
724
490
|
const installed = outcomes.filter((o) => o.status === "installed");
|
|
725
491
|
const skipped = outcomes.filter((o) => o.status === "skipped");
|
|
726
492
|
const errored = outcomes.filter((o) => o.status === "error");
|
|
727
493
|
|
|
728
|
-
|
|
729
|
-
|
|
494
|
+
const result: CommandResult = {
|
|
495
|
+
json: {
|
|
730
496
|
ok: errored.length === 0,
|
|
731
497
|
action: "add",
|
|
732
498
|
source: parsed.source,
|
|
@@ -744,18 +510,20 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
744
510
|
tagged: o.tagged,
|
|
745
511
|
domains: o.domains,
|
|
746
512
|
})),
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
513
|
+
},
|
|
514
|
+
human: (emit) => {
|
|
515
|
+
for (const o of outcomes) {
|
|
516
|
+
const tag = o.status === "installed" ? "added " : o.status === "skipped" ? "skipped " : "ERROR ";
|
|
517
|
+
emit(`${tag} ${o.name.padEnd(28)} ${o.reason}`);
|
|
518
|
+
}
|
|
519
|
+
emit("");
|
|
520
|
+
emit(
|
|
521
|
+
`${selected.length} selected, ${installed.length} installed, ${skipped.length} skipped${errored.length ? `, ${errored.length} error(s)` : ""} from ${parsed.source}`,
|
|
522
|
+
);
|
|
523
|
+
if (skipped.some((o) => o.verdict === "differs")) emit("re-run with --force to overwrite differing skills.");
|
|
524
|
+
},
|
|
525
|
+
};
|
|
526
|
+
render(ctx, flags.json, result);
|
|
759
527
|
return errored.length > 0 ? 1 : 0;
|
|
760
528
|
} catch (err) {
|
|
761
529
|
ctx.error("add: failed:", err instanceof Error ? err.message : String(err));
|