@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.
- package/README.md +1 -1
- package/dist/application/doctor/repair-runner.js +35 -2
- package/dist/application/doctor/repair-runner.js.map +1 -1
- package/dist/application/elicitation-server.js +19 -3
- package/dist/application/elicitation-server.js.map +1 -1
- package/dist/application/self/install-plugin-skills-git.js +28 -1
- package/dist/application/self/install-plugin-skills-git.js.map +1 -1
- package/dist/application/self/skills-apply.js +623 -0
- package/dist/application/self/skills-apply.js.map +1 -0
- package/dist/application/self/skills-catalog.js +35 -0
- package/dist/application/self/skills-catalog.js.map +1 -0
- package/dist/application/self/skills-change.js +479 -0
- package/dist/application/self/skills-change.js.map +1 -0
- package/dist/application/self/skills-discovery.js +275 -0
- package/dist/application/self/skills-discovery.js.map +1 -0
- package/dist/application/self/skills-manager.js +69 -499
- package/dist/application/self/skills-manager.js.map +1 -1
- package/dist/application/self/skills-payload.js +566 -0
- package/dist/application/self/skills-payload.js.map +1 -0
- package/dist/application/self/skills-registry.js +31 -11
- package/dist/application/self/skills-registry.js.map +1 -1
- package/dist/cli/tui/data/recommended-skills.js +333 -8
- package/dist/cli/tui/data/recommended-skills.js.map +1 -1
- package/dist/cli/tui/tabs/skills-tab.js +448 -204
- package/dist/cli/tui/tabs/skills-tab.js.map +1 -1
- package/dist/domain/doctor/operations.js +3 -1
- package/dist/domain/doctor/operations.js.map +1 -1
- package/dist/domain/elicitation.js +26 -16
- package/dist/domain/elicitation.js.map +1 -1
- package/package.json +1 -1
- package/skills/w/roles/README.md +2 -0
|
@@ -1,33 +1,25 @@
|
|
|
1
|
-
// Standalone-skills
|
|
2
|
-
// ~/.agents/skills/<n> (the open-standard anchor that non-Claude hosts
|
|
3
|
-
// + replica
|
|
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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
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 {
|
|
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 {
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
325
|
-
|
|
326
|
-
//
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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(
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
447
|
-
|
|
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`) +
|
|
479
|
-
* → registered → recommended; alphabetical
|
|
480
|
-
|
|
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
|
|
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
|
-
...(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|