@tacuchi/agent-workflow-cli 25.3.0 → 25.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.
Files changed (31) hide show
  1. package/README.md +1 -1
  2. package/dist/application/doctor/repair-runner.js +35 -2
  3. package/dist/application/doctor/repair-runner.js.map +1 -1
  4. package/dist/application/elicitation-server.js +19 -3
  5. package/dist/application/elicitation-server.js.map +1 -1
  6. package/dist/application/self/install-plugin-skills-git.js +28 -1
  7. package/dist/application/self/install-plugin-skills-git.js.map +1 -1
  8. package/dist/application/self/skills-apply.js +623 -0
  9. package/dist/application/self/skills-apply.js.map +1 -0
  10. package/dist/application/self/skills-catalog.js +35 -0
  11. package/dist/application/self/skills-catalog.js.map +1 -0
  12. package/dist/application/self/skills-change.js +479 -0
  13. package/dist/application/self/skills-change.js.map +1 -0
  14. package/dist/application/self/skills-discovery.js +275 -0
  15. package/dist/application/self/skills-discovery.js.map +1 -0
  16. package/dist/application/self/skills-manager.js +69 -499
  17. package/dist/application/self/skills-manager.js.map +1 -1
  18. package/dist/application/self/skills-payload.js +566 -0
  19. package/dist/application/self/skills-payload.js.map +1 -0
  20. package/dist/application/self/skills-registry.js +31 -11
  21. package/dist/application/self/skills-registry.js.map +1 -1
  22. package/dist/cli/tui/data/recommended-skills.js +333 -8
  23. package/dist/cli/tui/data/recommended-skills.js.map +1 -1
  24. package/dist/cli/tui/tabs/skills-tab.js +448 -204
  25. package/dist/cli/tui/tabs/skills-tab.js.map +1 -1
  26. package/dist/domain/doctor/operations.js +3 -1
  27. package/dist/domain/doctor/operations.js.map +1 -1
  28. package/dist/domain/elicitation.js +26 -16
  29. package/dist/domain/elicitation.js.map +1 -1
  30. package/package.json +1 -1
  31. package/skills/w/roles/README.md +2 -0
@@ -1,33 +1,25 @@
1
- // Standalone-skills engine (skills.sh model): canonical copy in
2
- // ~/.agents/skills/<n> (the open-standard anchor that non-Claude hosts scan)
3
- // + replica in ~/.claude/skills/<n> via symlink, with a copy fallback
4
- // (Windows without links).
1
+ // Standalone-skills INVENTORY and ownership (skills.sh model): canonical copy
2
+ // in ~/.agents/skills/<n> (the open-standard anchor that non-Claude hosts
3
+ // scan) + a replica per host that does not read it.
5
4
  //
6
- // The user-level registry (skills-registry.ts) is the source of truth for WHAT
7
- // this engine manages. Ownership guards (the engine writes under the user's
8
- // real HOME, which also hosts the `w` bundle, plugin skills and manual
9
- // skills):
10
- // - every operation requires a registered name (SKILL_NOT_REGISTERED);
11
- // - the canonical dir is only created/deleted if this engine materialized it
12
- // — signal: `installedAt` in the registry (SKILL_NAME_COLLISION when
13
- // colliding with a foreign dir; on uninstall/remove the foreign dir is
14
- // preserved with a warning);
15
- // - the Claude replica is ours only if it is a symlink THAT POINTS AT the
16
- // canonical or a dir with registered mode:"copy" (FOREIGN_REPLICA
17
- // otherwise), verified BEFORE mutating anything;
18
- // - an unreadable registry (read warning) aborts every mutation: never
19
- // rewrite over entries that could not be read.
20
- import { readFile, readdir, readlink, rename } from "node:fs/promises";
21
- import { mkdtemp, rm } from "node:fs/promises";
22
- import { tmpdir } from "node:os";
23
- import { basename, isAbsolute, join, relative, resolve } from "node:path";
5
+ // This module READS. Since Spec 043 the only way to change an installation is
6
+ // `prepareSkillChange` + `applySkillChange` (skills-change / skills-apply):
7
+ // one preparation, one preview, one concrete approval and one journal. There
8
+ // is deliberately no mutating function here for a surface to reach for — that
9
+ // was how the TUI could install without showing what it was about to do.
10
+ //
11
+ // The user-level registry (skills-registry.ts) stays the source of truth for
12
+ // WHAT this engine manages, and `installedAt` is still the only signal that
13
+ // says this engine materialized a directory: everything else on those roots
14
+ // belongs to somebody else (the `w` bundle, plugin skills, a manual install)
15
+ // and is reported, never touched.
16
+ import { readFile, readlink } from "node:fs/promises";
17
+ import { isAbsolute, join, resolve } from "node:path";
24
18
  import { harnessByInstallTarget } from "../../domain/harnesses.js";
25
- import { gitClone, gitCloneSkillManifests, gitSparseAddSkillDir, gitSparseDisable, } from "./install-plugin-skills-git.js";
26
- import { copyDir, hasValidFrontmatter } from "./install-plugin-skills.js";
19
+ import { hasValidFrontmatter } from "./install-plugin-skills.js";
27
20
  import { COMMAND_SKILL_PREFIX, LEGACY_SKILL_NAMES, SKILL_DIR_NAME } from "./install-skill.js";
28
- import { isValidSkillName, readSkillsRegistry, readSkillsShLockSources, writeSkillsRegistry, } from "./skills-registry.js";
29
- // skills/<category>/<skill> (mattpocock/skills) fits within 3 levels below the root.
30
- const MAX_SCAN_DEPTH = 3;
21
+ import { curationOf, isRecommendedEntry, } from "./skills-catalog.js";
22
+ import { isValidSkillName, readSkillsRegistry, readSkillsShLockSources, } from "./skills-registry.js";
31
23
  export function canonicalSkillsRoot(home) {
32
24
  return join(home, ".agents", "skills");
33
25
  }
@@ -91,409 +83,67 @@ async function isSkillDir(dir) {
91
83
  }
92
84
  }
93
85
  /** Frontmatter `name:` — the skill's real name when the source IS a skill dir
94
- * (the basename of a temp clone is random, never usable). */
95
- function frontmatterName(content) {
86
+ * (the basename of a temp clone is random, never usable). Exported so the
87
+ * path-addressable discovery reads identity the same way this engine does. */
88
+ export function skillFrontmatterName(content) {
96
89
  const block = content.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? "";
97
90
  return block.match(/^name:\s*(\S[^\r\n]*)/m)?.[1]?.trim() ?? null;
98
91
  }
99
- /** Bounded walk: nested skills up to skills/<category>/<skill>; skips
100
- * node_modules, symlinks (a link's Dirent is not isDirectory) and dot-dirs —
101
- * EXCEPT `.claude`, the canonical Claude skills home, so a repo that packages
102
- * its skills the standard way (`.claude/skills/<skill>`) stays discoverable. */
103
- async function walkSkillDirs(dir, depth) {
104
- if (depth >= MAX_SCAN_DEPTH)
105
- return [];
106
- let entries;
107
- try {
108
- entries = await readdir(dir, { withFileTypes: true });
109
- }
110
- catch {
111
- return [];
112
- }
113
- const out = [];
114
- for (const entry of entries) {
115
- if (!entry.isDirectory())
116
- continue;
117
- if (entry.name === "node_modules")
118
- continue;
119
- if (entry.name.startsWith(".") && entry.name !== ".claude")
120
- continue;
121
- const full = join(dir, entry.name);
122
- if (await isSkillDir(full)) {
123
- out.push({ name: entry.name, path: full });
124
- }
125
- else {
126
- out.push(...(await walkSkillDirs(full, depth + 1)));
127
- }
128
- }
129
- return out;
130
- }
131
- /** Skill dirs of a source: the dir itself (if it IS a skill, named by its
132
- * frontmatter) or the tree; duplicate or invalid names are discarded. */
133
- async function collectCandidates(dir) {
134
- if (await isSkillDir(dir)) {
135
- const content = await readFile(join(dir, "SKILL.md"), "utf8");
136
- const name = frontmatterName(content) ?? basename(dir);
137
- return isValidSkillName(name) ? [{ name, path: dir }] : [];
138
- }
139
- const found = await walkSkillDirs(dir, 0);
140
- const byName = new Map();
141
- for (const c of found) {
142
- if (!isValidSkillName(c.name) || byName.has(c.name))
143
- continue;
144
- byName.set(c.name, c);
145
- }
146
- return [...byName.values()];
147
- }
148
- /** Brings the source into a readable dir and lists its skills (temp clone for
149
- * git). The git clone is manifest-only (just the SKILL.md files) so DISCOVERY
150
- * never downloads unrelated repo content; the picked skill's full directory is
151
- * fetched later in `prepare`. */
152
- async function fetchSourceCandidates(resolved, ctx) {
153
- if (resolved.kind === "local") {
154
- if (!(await ctx.fs.exists(resolved.path))) {
155
- return { code: "SOURCE_NOT_FOUND", error: `el path local '${resolved.path}' no existe` };
156
- }
157
- return {
158
- candidates: await collectCandidates(resolved.path),
159
- prepare: async (c) => c.path,
160
- cleanup: async () => { },
161
- };
162
- }
163
- const temp = await mkdtemp(join(tmpdir(), "aw-skill-fetch-"));
164
- const cleanup = async () => {
165
- await rm(temp, { recursive: true, force: true }).catch(() => { });
166
- };
167
- // A server that rejects the partial/sparse clone falls back to a plain full
168
- // clone (then `prepare` is identity — everything is already checked out).
169
- let sparse;
170
- try {
171
- await gitCloneSkillManifests(resolved.url, temp, resolved.ref);
172
- sparse = true;
173
- }
174
- catch {
175
- await rm(temp, { recursive: true, force: true }).catch(() => { });
176
- try {
177
- await gitClone(resolved.url, temp, resolved.ref);
178
- sparse = false;
179
- }
180
- catch (err) {
181
- await cleanup();
182
- return { code: "GIT_CLONE_FAILED", error: `git clone falló: ${err.message}` };
183
- }
184
- }
185
- const prepare = async (c) => {
186
- if (!sparse)
187
- return c.path;
188
- const rel = relative(temp, c.path);
189
- // Root-is-skill: the whole repo is the payload → restore the full tree.
190
- if (rel === "" || rel === ".")
191
- await gitSparseDisable(temp);
192
- else
193
- await gitSparseAddSkillDir(temp, rel);
194
- return c.path;
195
- };
196
- return { candidates: await collectCandidates(temp), prepare, cleanup };
197
- }
198
- /** Read preceding a mutation: an unreadable registry ABORTS — the first write
199
- * after a failed read would clobber every prior entry. */
200
- async function readRegistryForWrite(ctx) {
201
- const read = await readSkillsRegistry(ctx);
202
- if (read.warning) {
203
- return {
204
- code: "REGISTRY_UNREADABLE",
205
- error: `${read.warning} Corregí (o borrá) el archivo antes de operar.`,
206
- };
207
- }
208
- return { registry: read.registry };
209
- }
210
- /** Side-effect-free inspection: lists a source's skills so the wizard can show
211
- * the picker + third-party warning BEFORE registering anything. */
212
- export async function probeSkillSource(ctx, input) {
213
- const resolved = resolveSkillSource(input.source, input.ref);
214
- if ("error" in resolved)
215
- return fail("INVALID_SOURCE", resolved.error);
216
- const fetched = await fetchSourceCandidates(resolved, ctx);
217
- if ("error" in fetched)
218
- return fail(fetched.code, fetched.error);
219
- try {
220
- if (fetched.candidates.length === 0) {
221
- return fail("SOURCE_NOT_FOUND", `no se encontró ninguna skill válida (SKILL.md con name+description) en '${input.source}'`);
222
- }
223
- return {
224
- ok: true,
225
- data: { candidates: fetched.candidates.map((c) => c.name).sort() },
226
- exitCode: 0,
227
- };
228
- }
229
- finally {
230
- await fetched.cleanup();
231
- }
232
- }
233
- export async function registerSkill(ctx, input) {
234
- const resolved = resolveSkillSource(input.source, input.ref);
235
- if ("error" in resolved)
236
- return fail("INVALID_SOURCE", resolved.error);
237
- const fetched = await fetchSourceCandidates(resolved, ctx);
238
- if ("error" in fetched)
239
- return fail(fetched.code, fetched.error);
240
- try {
241
- const picked = pickCandidate(fetched.candidates, input);
242
- if ("error" in picked)
243
- return fail(picked.code, picked.error);
244
- if ("candidates" in picked) {
245
- return {
246
- ok: true,
247
- data: {
248
- status: "needs-pick",
249
- candidates: picked.candidates,
250
- summary: `La fuente contiene ${picked.candidates.length} skills; elegí una para registrar.`,
251
- },
252
- exitCode: 0,
253
- };
254
- }
255
- const read = await readRegistryForWrite(ctx);
256
- if ("error" in read)
257
- return fail(read.code, read.error);
258
- const { registry } = read;
259
- if (registry.skills[picked.name]) {
260
- return fail("SKILL_ALREADY_REGISTERED", `'${picked.name}' ya está registrada (fuente: ${registry.skills[picked.name]?.source})`);
261
- }
262
- // `w-*` is the namespace of the bundle's synthesized wrappers
263
- // (install-skill.ts) — registering a standalone skill there would leave it
264
- // at the mercy of the install/uninstall sweep over the same roots.
265
- if (picked.name.startsWith(COMMAND_SKILL_PREFIX)) {
266
- return fail("RESERVED_SKILL_PREFIX", `'${picked.name}' usa el prefijo reservado '${COMMAND_SKILL_PREFIX}' (wrappers del bundle) — elegí otro nombre`);
267
- }
268
- // Ownership guard: an existing UNregistered canonical dir belongs to someone
269
- // else (`w` bundle, plugin skill, manual install) — we never adopt it.
270
- const canonical = join(canonicalSkillsRoot(ctx.env.homeDir()), picked.name);
271
- if (await ctx.fs.exists(canonical)) {
272
- return fail("SKILL_NAME_COLLISION", `ya existe ${canonical} y no está en el registro de sueltas — elegí otro nombre o resolvé la colisión a mano`);
273
- }
274
- const entry = {
275
- source: resolved.kind === "git" ? (input.source.split("#")[0] ?? input.source) : resolved.path,
276
- ...(resolved.kind === "git" && resolved.ref ? { ref: resolved.ref } : {}),
277
- };
278
- registry.skills[picked.name] = entry;
279
- await writeSkillsRegistry(ctx, registry);
280
- return {
281
- ok: true,
282
- data: {
283
- status: "registered",
284
- name: picked.name,
285
- entry,
286
- summary: `Skill '${picked.name}' registrada desde ${entry.source}.`,
287
- },
288
- exitCode: 0,
289
- };
290
- }
291
- finally {
292
- await fetched.cleanup();
293
- }
294
- }
295
- function pickCandidate(candidates, input) {
296
- if (candidates.length === 0) {
297
- return {
298
- code: "SOURCE_NOT_FOUND",
299
- error: `no se encontró ninguna skill válida (SKILL.md con name+description) en '${input.source}'`,
300
- };
301
- }
302
- if (input.pick !== undefined) {
303
- const found = candidates.find((c) => c.name === input.pick);
304
- return (found ?? {
305
- code: "INVALID_PICK",
306
- error: `la fuente no contiene la skill '${input.pick}' (disponibles: ${candidates
307
- .map((c) => c.name)
308
- .sort()
309
- .join(", ")})`,
310
- });
311
- }
312
- if (candidates.length === 1 && candidates[0])
313
- return candidates[0];
314
- return { candidates: candidates.map((c) => c.name).sort() };
315
- }
316
- export async function installSkill(ctx, name, opts = { refetch: true }) {
317
- const read = await readRegistryForWrite(ctx);
318
- if ("error" in read)
319
- return fail(read.code, read.error);
320
- const entry = read.registry.skills[name];
321
- if (!entry)
322
- return notRegistered(name);
92
+ export async function inspectSkillOwnership(ctx, name, entry) {
323
93
  const home = ctx.env.homeDir();
324
- const canonical = join(canonicalSkillsRoot(home), name);
325
- // Pre-flight BEFORE mutating anything: a foreign replica (on any host) or a
326
- // canonical collision aborts without touching the prior install or the foreign dir.
327
- const replicaStates = new Map();
328
- for (const host of REPLICA_HOSTS) {
329
- const state = await inspectReplica(ctx, name, entry.mode, host);
330
- if (state === "foreign") {
331
- return fail("FOREIGN_REPLICA", `ya existe ${join(host.root(home), name)} y no lo creó este manager — resolvé la colisión a mano`);
332
- }
333
- replicaStates.set(host.key, state);
334
- }
335
- if (opts.refetch && !entry.installedAt && (await ctx.fs.exists(canonical))) {
336
- return fail("SKILL_NAME_COLLISION", `ya existe ${canonical} y este manager no lo materializó — resolvé la colisión a mano`);
337
- }
338
- if (opts.refetch) {
339
- const resolved = resolveSkillSource(entry.source, entry.ref);
340
- if ("error" in resolved)
341
- return fail("INVALID_SOURCE", resolved.error);
342
- const fetched = await fetchSourceCandidates(resolved, ctx);
343
- if ("error" in fetched)
344
- return fail("FETCH_FAILED", fetched.error);
345
- try {
346
- const skillDir = fetched.candidates.find((c) => c.name === name);
347
- if (!skillDir) {
348
- return fail("FETCH_FAILED", `la fuente '${entry.source}' ya no contiene la skill '${name}'`);
349
- }
350
- const srcDir = await fetched.prepare(skillDir);
351
- await materializeCanonical(ctx, srcDir, canonical, name);
352
- }
353
- catch (err) {
354
- return fail("MATERIALIZE_FAILED", err.message);
355
- }
356
- finally {
357
- await fetched.cleanup();
358
- }
359
- }
360
- else if (!entry.installedAt || !(await ctx.fs.exists(canonical))) {
361
- // Reinstall requires THIS manager to have materialized the canonical dir:
362
- // without installedAt, an existing same-named dir is foreign and is not replicated.
363
- return fail("SKILL_NOT_INSTALLED", `'${name}' no está instalada (no existe ${canonical} materializada por este manager); usá Install/Update`);
364
- }
94
+ const canonicalPath = join(canonicalSkillsRoot(home), name);
95
+ const present = await ctx.fs.exists(canonicalPath);
96
+ // `installedAt` is the only signal that says THIS manager materialized it;
97
+ // a same-named dir nobody registered belongs to whoever put it there.
98
+ const canonicalState = !present
99
+ ? "absent"
100
+ : entry?.installedAt
101
+ ? "ours"
102
+ : "foreign";
365
103
  const replicas = [];
366
104
  for (const host of REPLICA_HOSTS) {
367
- replicas.push(await replicateToHost(ctx, name, replicaStates.get(host.key) ?? "absent", host));
368
- }
369
- // The registered mode is still the Claude replica's (the primary one): the
370
- // TUI's "(copy)" badge flags ITS degradation (Windows without symlinks);
371
- // the gemini copy is by-design and authenticates via marker, not mode.
372
- const claudeReplica = replicas[0] ?? { path: canonical, mode: "symlink" };
373
- // Re-read before writing: the clone can take a while and another invocation
374
- // may have touched the registry in between — never write a stale snapshot.
375
- const fresh = await readRegistryForWrite(ctx);
376
- if ("error" in fresh)
377
- return fail(fresh.code, fresh.error);
378
- const current = fresh.registry.skills[name] ?? entry;
379
- fresh.registry.skills[name] = {
380
- ...current,
381
- mode: claudeReplica.mode,
382
- ...(opts.refetch ? { installedAt: new Date().toISOString() } : {}),
383
- };
384
- await writeSkillsRegistry(ctx, fresh.registry);
385
- return {
386
- ok: true,
387
- data: {
388
- status: opts.refetch ? "installed" : "reinstalled",
389
- name,
390
- canonical,
391
- mode: claudeReplica.mode,
392
- replicas: replicas.map((r) => ({ host: r.host, path: r.path, mode: r.mode })),
393
- summary: `Skill '${name}' materializada en ${canonical} (réplicas: ${replicas
394
- .map((r) => `${r.host} ${r.mode}`)
395
- .join(" · ")}).`,
396
- },
397
- exitCode: 0,
398
- };
399
- }
400
- /** Reinstall: repairs canonical→replica without touching the source (offline-safe). */
401
- export async function reinstallSkill(ctx, name) {
402
- return installSkill(ctx, name, { refetch: false });
403
- }
404
- /** Update: re-fetches the registered ref; git sources only. Staging+swap: a
405
- * fetch failure leaves the prior install intact. */
406
- export async function updateSkill(ctx, name) {
407
- const read = await readRegistryForWrite(ctx);
408
- if ("error" in read)
409
- return fail(read.code, read.error);
410
- const entry = read.registry.skills[name];
411
- if (!entry)
412
- return notRegistered(name);
413
- const resolved = resolveSkillSource(entry.source, entry.ref);
414
- if ("error" in resolved || resolved.kind !== "git") {
415
- return fail("UPDATE_REQUIRES_GIT", `Update re-fetchea git; '${name}' viene de un path local — usá Reinstall (o re-registrá la fuente).`);
416
- }
417
- if (!(await ctx.fs.exists(join(canonicalSkillsRoot(ctx.env.homeDir()), name)))) {
418
- return fail("SKILL_NOT_INSTALLED", `'${name}' no está instalada; usá Install`);
105
+ replicas.push({
106
+ host: host.key,
107
+ path: join(host.root(home), name),
108
+ state: await inspectReplica(ctx, name, entry?.mode, host),
109
+ });
419
110
  }
420
- const result = await installSkill(ctx, name, { refetch: true });
421
- if (!result.ok || !result.data)
422
- return result;
423
- return {
424
- ...result,
425
- data: {
426
- ...result.data,
427
- status: "updated",
428
- summary: `Skill '${name}' actualizada (ref registrado re-fetcheado).`,
429
- },
430
- };
111
+ return { name, canonical: { path: canonicalPath, state: canonicalState }, replicas };
431
112
  }
432
- export async function uninstallSkill(ctx, name) {
433
- const read = await readRegistryForWrite(ctx);
434
- if ("error" in read)
435
- return fail(read.code, read.error);
436
- const entry = read.registry.skills[name];
437
- if (!entry)
438
- return notRegistered(name);
439
- const warning = await teardownSkill(ctx, name, entry);
440
- read.registry.skills[name] = {
441
- source: entry.source,
442
- ...(entry.ref ? { ref: entry.ref } : {}),
443
- };
444
- await writeSkillsRegistry(ctx, read.registry);
113
+ /** The catalog's contribution to a row: what the review says about this name,
114
+ * or nothing at all — an unreviewed installation borrows no verdict. */
115
+ function catalogFields(entry) {
116
+ const curation = curationOf(entry);
445
117
  return {
446
- ok: true,
447
- data: {
448
- status: "uninstalled",
449
- name,
450
- ...(warning ? { warning } : {}),
451
- summary: `Skill '${name}' desinstalada; sigue registrada (fuente: ${entry.source}).`,
452
- },
453
- exitCode: 0,
454
- };
455
- }
456
- export async function removeSkill(ctx, name) {
457
- const read = await readRegistryForWrite(ctx);
458
- if ("error" in read)
459
- return fail(read.code, read.error);
460
- const entry = read.registry.skills[name];
461
- if (!entry)
462
- return notRegistered(name);
463
- const warning = await teardownSkill(ctx, name, entry);
464
- delete read.registry.skills[name];
465
- await writeSkillsRegistry(ctx, read.registry);
466
- return {
467
- ok: true,
468
- data: {
469
- status: "removed",
470
- name,
471
- ...(warning ? { warning } : {}),
472
- summary: `Skill '${name}' desinstalada y quitada del registro.`,
473
- },
474
- exitCode: 0,
118
+ ...(entry?.description ? { description: entry.description } : {}),
119
+ ...(curation !== undefined ? { curation } : {}),
475
120
  };
476
121
  }
477
122
  /** Single list for the TUI: registered (installed or not) + canonicals outside
478
- * the registry (`unmanaged`) + unregistered seed. Order: installed → unmanaged
479
- * → registered → recommended; alphabetical within each group. */
480
- export async function listSkills(ctx, seed) {
123
+ * the registry (`unmanaged`) + the recommended catalog entries nobody has.
124
+ * Order: installed → unmanaged → registered → recommended; alphabetical
125
+ * within each group.
126
+ *
127
+ * `catalog` is the WHOLE reviewed record (Spec 043): its metadata reaches
128
+ * every row it knows — a withdrawn entry included, which is what keeps an
129
+ * installation readable after it stops being recommended — while only the
130
+ * recommended entries produce a `recommended` row of their own. */
131
+ export async function listSkills(ctx, catalog) {
481
132
  const { registry, warning } = await readSkillsRegistry(ctx);
482
133
  const home = ctx.env.homeDir();
483
- const seedByName = new Map(seed.map((s) => [s.name, s]));
134
+ const catalogByName = new Map(catalog.map((s) => [s.name, s]));
484
135
  const items = [];
485
136
  for (const [name, entry] of Object.entries(registry.skills)) {
486
137
  const canonical = await ctx.fs.exists(join(canonicalSkillsRoot(home), name));
487
138
  const replica = (await ctx.fs.lstat(join(claudeReplicaRoot(home), name))) !== null;
488
139
  const gemini = (await ctx.fs.lstat(join(geminiReplicaRoot(home), name))) !== null;
489
- const description = seedByName.get(name)?.description;
490
140
  items.push({
491
141
  name,
492
142
  source: entry.source,
493
143
  ...(entry.ref ? { ref: entry.ref } : {}),
494
144
  ...(entry.mode ? { mode: entry.mode } : {}),
495
145
  ...(entry.installedAt ? { installedAt: entry.installedAt } : {}),
496
- ...(description ? { description } : {}),
146
+ ...catalogFields(catalogByName.get(name)),
497
147
  status: canonical ? "installed" : "registered",
498
148
  replicas: { agents: canonical, claude: replica, gemini },
499
149
  });
@@ -529,6 +179,7 @@ export async function listSkills(ctx, seed) {
529
179
  items.push({
530
180
  name: entry.name,
531
181
  source: lockSources[entry.name] ?? "",
182
+ ...catalogFields(catalogByName.get(entry.name)),
532
183
  status: "unmanaged",
533
184
  replicas: { agents: true, claude: replica, gemini },
534
185
  });
@@ -539,18 +190,22 @@ export async function listSkills(ctx, seed) {
539
190
  // managed skills are already listed; never empty the tab over this.
540
191
  }
541
192
  }
542
- for (const s of seed) {
193
+ for (const s of catalog) {
194
+ // A withdrawn entry is no longer offered: it keeps its metadata for the
195
+ // rows above, but it never comes back as a `recommended` one.
196
+ if (!isRecommendedEntry(s))
197
+ continue;
543
198
  if (Object.hasOwn(registry.skills, s.name) || unmanaged.has(s.name))
544
199
  continue;
545
200
  // Same-named canonical the scan did not list (invalid frontmatter, file,
546
201
  // unreadable registry): offering Install guarantees SKILL_NAME_COLLISION —
547
- // better not to offer the seed.
202
+ // better not to offer the catalog entry.
548
203
  if (await ctx.fs.exists(join(root, s.name)))
549
204
  continue;
550
205
  items.push({
551
206
  name: s.name,
552
207
  source: s.source,
553
- description: s.description,
208
+ ...catalogFields(s),
554
209
  status: "recommended",
555
210
  replicas: { agents: false, claude: false, gemini: false },
556
211
  });
@@ -563,38 +218,6 @@ export async function listSkills(ctx, seed) {
563
218
  };
564
219
  return items.sort((a, b) => rank[a.status] - rank[b.status] || a.name.localeCompare(b.name));
565
220
  }
566
- // --- internals ---
567
- /**
568
- * Copies src into a hidden staging dir and swaps it with the canonical one;
569
- * the .bak is only discarded once the swap completed — a failure restores the
570
- * previous state. `renameFn` is injectable to test the restore branch.
571
- */
572
- export async function materializeCanonical(ctx, srcDir, canonical, name, renameFn = rename) {
573
- const root = canonicalSkillsRoot(ctx.env.homeDir());
574
- await ctx.fs.mkdirp(root);
575
- const staging = join(root, `.staging-${name}-${process.pid}`);
576
- const bak = join(root, `.bak-${name}-${process.pid}`);
577
- await ctx.fs.remove(staging);
578
- try {
579
- await copyDir(srcDir, staging);
580
- const hadPrevious = await ctx.fs.exists(canonical);
581
- if (hadPrevious)
582
- await renameFn(canonical, bak);
583
- try {
584
- await renameFn(staging, canonical);
585
- }
586
- catch (err) {
587
- if (hadPrevious)
588
- await renameFn(bak, canonical).catch(() => { });
589
- throw err;
590
- }
591
- if (hadPrevious)
592
- await ctx.fs.remove(bak);
593
- }
594
- finally {
595
- await ctx.fs.remove(staging);
596
- }
597
- }
598
221
  /** Replica ownership on a host: ours only if it is a symlink pointing at OUR
599
222
  * canonical, a real dir with the `.aw-replica` marker, or — legacy
600
223
  * (pre-marker copies, Windows) — a real dir with registered mode:"copy". A
@@ -603,7 +226,12 @@ async function inspectReplica(ctx, name, registeredMode, host) {
603
226
  const home = ctx.env.homeDir();
604
227
  const replicaRoot = host.root(home);
605
228
  const replica = join(replicaRoot, name);
606
- const existing = await ctx.fs.lstat(replica);
229
+ // A location this manager cannot even READ is definitely not one it owns —
230
+ // e.g. somebody left a FILE where the skills root goes. `foreign` is the
231
+ // honest answer: report it, never write through it.
232
+ const existing = await ctx.fs.lstat(replica).catch(() => "unreadable");
233
+ if (existing === "unreadable")
234
+ return "foreign";
607
235
  if (!existing)
608
236
  return "absent";
609
237
  if (existing.isSymlink) {
@@ -622,62 +250,4 @@ async function inspectReplica(ctx, name, registeredMode, host) {
622
250
  // registered mode.
623
251
  return host.key === "claude" && registeredMode === "copy" ? "ours" : "foreign";
624
252
  }
625
- /** Materializes the replica on a host (pre-flight already ensured it is not foreign). */
626
- async function replicateToHost(ctx, name, state, host) {
627
- const home = ctx.env.homeDir();
628
- const canonical = join(canonicalSkillsRoot(home), name);
629
- const replicaRoot = host.root(home);
630
- const replica = join(replicaRoot, name);
631
- if (state === "ours")
632
- await ctx.fs.remove(replica);
633
- await ctx.fs.mkdirp(replicaRoot);
634
- if (host.preferSymlink) {
635
- try {
636
- await ctx.fs.symlink(canonical, replica);
637
- return { host: host.key, path: replica, mode: "symlink" };
638
- }
639
- catch {
640
- // No symlinks (Windows without Developer Mode / EPERM) → real copy.
641
- }
642
- }
643
- await copyDir(canonical, replica);
644
- await ctx.fs.writeText(join(replica, REPLICA_MARKER_FILENAME), `${canonical}\n`);
645
- return { host: host.key, path: replica, mode: "copy" };
646
- }
647
- /** Tears down replicas and the canonical dir respecting ownership; returns a
648
- * warning if a same-named foreign dir was preserved. */
649
- async function teardownSkill(ctx, name, entry) {
650
- const home = ctx.env.homeDir();
651
- const warnings = [];
652
- for (const host of REPLICA_HOSTS) {
653
- const replica = join(host.root(home), name);
654
- const replicaState = await inspectReplica(ctx, name, entry.mode, host);
655
- if (replicaState === "ours")
656
- await ctx.fs.remove(replica);
657
- else if (replicaState === "foreign") {
658
- warnings.push(`se conservó ${replica}: existe pero no lo creó este manager`);
659
- }
660
- }
661
- const canonical = join(canonicalSkillsRoot(home), name);
662
- if (entry.installedAt) {
663
- await ctx.fs.remove(canonical);
664
- }
665
- else if (await ctx.fs.exists(canonical)) {
666
- warnings.push(`se conservó ${canonical}: este manager no lo materializó`);
667
- }
668
- return warnings.length > 0 ? warnings.join(" · ") : undefined;
669
- }
670
- function notRegistered(name) {
671
- return {
672
- ok: false,
673
- error: {
674
- code: "SKILL_NOT_REGISTERED",
675
- message: `'${name}' no está en el registro de sueltas — este manager no toca dirs que no registró (bundle w, skills de plugins o manuales).`,
676
- },
677
- exitCode: 1,
678
- };
679
- }
680
- function fail(code, message) {
681
- return { ok: false, error: { code, message }, exitCode: 1 };
682
- }
683
253
  //# sourceMappingURL=skills-manager.js.map