@politty/zod 0.0.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 (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +561 -0
  3. package/bin/cli.mjs +3 -0
  4. package/dist/arg-registry-YaWTVu_x.d.ts +1025 -0
  5. package/dist/augment.d.ts +15 -0
  6. package/dist/augment.js +1 -0
  7. package/dist/cli-main-Dn88vIyn.js +84 -0
  8. package/dist/cli-run-eibUcgys.js +7 -0
  9. package/dist/cli.d.ts +1 -0
  10. package/dist/cli.js +16 -0
  11. package/dist/command-k-4yAz4J.js +42 -0
  12. package/dist/compile-cache-Ct41pWGL.js +100 -0
  13. package/dist/compile-cache.d.ts +78 -0
  14. package/dist/compile-cache.js +3 -0
  15. package/dist/completion-gtWX3mwP.js +5608 -0
  16. package/dist/completion.d.ts +242 -0
  17. package/dist/completion.js +4 -0
  18. package/dist/docs.d.ts +770 -0
  19. package/dist/docs.js +3044 -0
  20. package/dist/field-meta-DMy5BcRr.js +146 -0
  21. package/dist/index-CvhsecfS.d.ts +455 -0
  22. package/dist/index.d.ts +799 -0
  23. package/dist/index.js +17 -0
  24. package/dist/log-collector-CoUkLVJB.js +114 -0
  25. package/dist/logger-i_bb-Jhc.js +133 -0
  26. package/dist/prompt-CEIZ-7H1.js +171 -0
  27. package/dist/prompt-clack.d.ts +16 -0
  28. package/dist/prompt-clack.js +32 -0
  29. package/dist/prompt-inquirer.d.ts +16 -0
  30. package/dist/prompt-inquirer.js +47 -0
  31. package/dist/prompt.d.ts +106 -0
  32. package/dist/prompt.js +4 -0
  33. package/dist/register-Bk0K83W2.js +439 -0
  34. package/dist/runner-D72I7wvK.js +2956 -0
  35. package/dist/runner-FvUwOHyE.js +3 -0
  36. package/dist/schema-extractor-DMSozq40.js +250 -0
  37. package/dist/skill.d.ts +608 -0
  38. package/dist/skill.js +1832 -0
  39. package/dist/src-KzC0g5CS.js +191 -0
  40. package/dist/subcommand-router-Cskpofdk.js +134 -0
  41. package/package.json +103 -0
package/dist/skill.js ADDED
@@ -0,0 +1,1832 @@
1
+ import "./register-Bk0K83W2.js";
2
+ import { a as internalField, i as internalArgs, t as extractFields } from "./schema-extractor-DMSozq40.js";
3
+ import { n as defineCommand } from "./command-k-4yAz4J.js";
4
+ import { a as symbols, n as logger } from "./logger-i_bb-Jhc.js";
5
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync } from "node:fs";
6
+ import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
7
+ import { parse as parse$1 } from "yaml";
8
+ import { z } from "zod";
9
+
10
+ //#region ../core/src/skill/frontmatter.ts
11
+ /**
12
+ * Skill name pattern from the Agent Skills specification:
13
+ * https://agentskills.io/specification
14
+ *
15
+ * Lowercase alphanumerics separated by single hyphens, no leading/trailing
16
+ * hyphen. Also used as the skill directory name; enforced again at scan time
17
+ * to match the containing directory name.
18
+ */
19
+ const SKILL_NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
20
+ /**
21
+ * Max lengths come from the Agent Skills specification.
22
+ */
23
+ const NAME_MAX = 64;
24
+ const DESCRIPTION_MAX = 1024;
25
+ const COMPATIBILITY_MAX = 500;
26
+ /**
27
+ * Validate parsed SKILL.md frontmatter against the Agent Skills
28
+ * specification (https://agentskills.io/specification).
29
+ *
30
+ * Validates the spec-defined fields strictly; unknown fields are preserved
31
+ * (passthrough) so spec extensions and vendor keys round-trip intact.
32
+ * Mirrors the deprecated `skillFrontmatterSchema` zod export
33
+ * (`@politty/zod`'s skill-frontmatter-schema.ts) — keep the two in sync
34
+ * when the spec changes.
35
+ *
36
+ * Provenance / ownership for politty-managed installs is recorded under
37
+ * `metadata["politty-cli"]` as `"{packageName}:{cliName}"`.
38
+ */
39
+ /**
40
+ * Human-readable type label for "received" diagnostics, matching zod's
41
+ * granularity (`null` and `array` instead of a blanket `object`).
42
+ */
43
+ function receivedLabel(value) {
44
+ if (value === null) return "null";
45
+ if (Array.isArray(value)) return "array";
46
+ return typeof value;
47
+ }
48
+ function validateSkillFrontmatter(data) {
49
+ const issues = [];
50
+ const checkString = (key, opts) => {
51
+ const value = data[key];
52
+ if (value === void 0) {
53
+ if (opts.required) issues.push({
54
+ path: [key],
55
+ message: "Invalid input: expected string, received undefined"
56
+ });
57
+ return;
58
+ }
59
+ if (typeof value !== "string") {
60
+ issues.push({
61
+ path: [key],
62
+ message: `Invalid input: expected string, received ${receivedLabel(value)}`
63
+ });
64
+ return;
65
+ }
66
+ if (opts.min !== void 0 && value.length < opts.min) issues.push({
67
+ path: [key],
68
+ message: `Too small: expected string to have >=${opts.min} characters`
69
+ });
70
+ if (opts.max !== void 0 && value.length > opts.max) issues.push({
71
+ path: [key],
72
+ message: `Too big: expected string to have <=${opts.max} characters`
73
+ });
74
+ };
75
+ checkString("name", {
76
+ required: true,
77
+ min: 1,
78
+ max: NAME_MAX
79
+ });
80
+ if (typeof data.name === "string" && !SKILL_NAME_PATTERN.test(data.name)) issues.push({
81
+ path: ["name"],
82
+ message: "name must be lowercase alphanumerics separated by single hyphens"
83
+ });
84
+ checkString("description", {
85
+ required: true,
86
+ min: 1,
87
+ max: DESCRIPTION_MAX
88
+ });
89
+ checkString("license", { min: 1 });
90
+ checkString("compatibility", { max: COMPATIBILITY_MAX });
91
+ checkString("allowed-tools", {});
92
+ const metadata = data.metadata;
93
+ if (metadata !== void 0) {
94
+ if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) issues.push({
95
+ path: ["metadata"],
96
+ message: `Invalid input: expected record, received ${receivedLabel(metadata)}`
97
+ });
98
+ else for (const [key, value] of Object.entries(metadata)) if (typeof value !== "string") issues.push({
99
+ path: ["metadata", key],
100
+ message: `Invalid input: expected string, received ${receivedLabel(value)}`
101
+ });
102
+ }
103
+ if (issues.length > 0) return {
104
+ success: false,
105
+ issues
106
+ };
107
+ return {
108
+ success: true,
109
+ data: { ...data }
110
+ };
111
+ }
112
+ /**
113
+ * Matches a YAML frontmatter block. The leading `\uFEFF?` tolerates a UTF-8
114
+ * byte-order mark that some editors prepend to saved files.
115
+ */
116
+ const FRONTMATTER_PATTERN = /^\uFEFF?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)([\s\S]*)$/;
117
+ /**
118
+ * Parse YAML frontmatter from a SKILL.md string.
119
+ *
120
+ * `parseError` is set when the frontmatter fence was present but the YAML
121
+ * inside failed to parse, so the scanner can distinguish "invalid YAML"
122
+ * from "missing required field" in its diagnostics. A non-object root
123
+ * (e.g. a top-level YAML list) also returns empty `data` without
124
+ * `parseError` — schema validation surfaces that case clearly
125
+ * enough on its own.
126
+ *
127
+ * @example
128
+ * ```typescript
129
+ * const result = parseFrontmatter(`---
130
+ * name: commit
131
+ * description: Git commit message generation
132
+ * ---
133
+ * # Instructions...`);
134
+ *
135
+ * result.data.name; // "commit"
136
+ * ```
137
+ */
138
+ function parseFrontmatter(content) {
139
+ const match = content.match(FRONTMATTER_PATTERN);
140
+ if (!match) return {
141
+ data: {},
142
+ body: content
143
+ };
144
+ const yamlBlock = match[1];
145
+ const body = match[2];
146
+ try {
147
+ const data = parse$1(yamlBlock);
148
+ if (!isPlainObject(data)) return {
149
+ data: {},
150
+ body
151
+ };
152
+ return {
153
+ data,
154
+ body
155
+ };
156
+ } catch (error) {
157
+ return {
158
+ data: {},
159
+ body,
160
+ parseError: error instanceof Error ? error.message : String(error)
161
+ };
162
+ }
163
+ }
164
+ /**
165
+ * Root-level plain-object check. Rejects Dates, Maps, and custom tagged types
166
+ * at the root of the parsed YAML; nested values are still validated by the
167
+ * frontmatter validation that consumes this data.
168
+ */
169
+ function isPlainObject(value) {
170
+ if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
171
+ const proto = Object.getPrototypeOf(value);
172
+ return proto === Object.prototype || proto === null;
173
+ }
174
+ /**
175
+ * Parse and validate a SKILL.md content string.
176
+ *
177
+ * @returns Parsed skill metadata and body, or `null` if the frontmatter is
178
+ * missing or fails schema validation.
179
+ */
180
+ function parseSkillMd(content) {
181
+ const { data, body } = parseFrontmatter(content);
182
+ const result = validateSkillFrontmatter(data);
183
+ if (!result.success) return null;
184
+ return {
185
+ frontmatter: result.data,
186
+ body,
187
+ rawContent: content
188
+ };
189
+ }
190
+
191
+ //#endregion
192
+ //#region ../core/src/skill/installer.ts
193
+ /** Canonical directory where skill files are stored. */
194
+ const AGENTS_SKILLS_DIR = ".agents/skills";
195
+ /**
196
+ * Agent directories that get symlinks to the canonical skill directory.
197
+ * Universal agents (Cursor, Cline, etc.) read from .agents/skills/ directly.
198
+ *
199
+ * Exported as the single source of truth shared with `commands.ts`'s
200
+ * dangling-symlink reaper so the two stay in lock-step.
201
+ */
202
+ const SYMLINK_TARGETS = [".claude/skills"];
203
+ /**
204
+ * Key used to read provenance off an installed skill. The SKILL.md's
205
+ * `metadata["politty-cli"]` must equal `"{packageName}:{cliName}"` for the
206
+ * owning CLI to manage it. This stamp is authored by the skill package,
207
+ * not rewritten at install time.
208
+ */
209
+ const OWNERSHIP_METADATA_KEY = "politty-cli";
210
+ /**
211
+ * Defense-in-depth check against path traversal. Skill names are also
212
+ * validated by the frontmatter schema (1..64 chars, lowercase alphanumerics
213
+ * separated by single hyphens), but we re-validate here in case a caller
214
+ * bypasses it. The 64-char limit is intentionally duplicated rather than
215
+ * imported from frontmatter.ts so this check stays independent.
216
+ */
217
+ function assertSafeName(name) {
218
+ if (name.length < 1 || name.length > 64 || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) throw new Error(`Invalid skill name: ${JSON.stringify(name)}`);
219
+ }
220
+ /**
221
+ * Install a skill to the project's agent skill directories.
222
+ *
223
+ * Canonical `.agents/skills/<name>` and each `SYMLINK_TARGETS` entry are
224
+ * populated according to `options.mode`:
225
+ *
226
+ * - `"symlink"` (default): symlink to the source (or to the canonical dir
227
+ * for the agent-specific slots). Source updates propagate live. Throws
228
+ * with guidance to retry with `"copy"` on filesystems without symlink
229
+ * support (e.g. Windows without Developer Mode).
230
+ * - `"copy"`: recursive copy. Works anywhere, but source updates require
231
+ * re-running install.
232
+ *
233
+ * **Symlink target convention.** Symlinks are written with relative
234
+ * targets so an install survives when the project tree is copied or
235
+ * mounted at a different absolute path. The two endpoints are resolved
236
+ * asymmetrically:
237
+ *
238
+ * - The install root (`.agents/skills/`, each `SYMLINK_TARGETS` parent)
239
+ * is passed through `realpathSync` so a symlinked checkout doesn't
240
+ * bake a stale parent path into the relative target.
241
+ * - The source path is resolved by {@link resolveSourcePreservingPackageHop},
242
+ * which walks root→leaf dereferencing every ancestor symlink (a symlinked
243
+ * checkout, macOS `/tmp` → `/private/tmp`, etc.) like `realpathSync` would
244
+ * — EXCEPT a `node_modules/<pkg>` (or `node_modules/@scope/<pkg>`)
245
+ * symlink, which is preserved. Following the package-manager hop would
246
+ * bake pnpm's volatile `node_modules/.pnpm/<pkg>@<version>_<hash>/...`
247
+ * into the link target and a subsequent `pnpm update` would leave every
248
+ * install dangling. Keeping the hop preserves the stable
249
+ * `node_modules/<pkg>` symlink that pnpm keeps repointing, while the
250
+ * project-root portion still matches the install root's realpath style
251
+ * so the install survives copying or remounting the project tree.
252
+ *
253
+ * The overlap guard and copy-mode payload still use the *fully*
254
+ * `realpathSync`-resolved source: the guard must catch a source-side
255
+ * symlink whose target is nested inside the install root, and the
256
+ * copy-mode payload reads through every symlink so a copy install doesn't
257
+ * leave dangling references back into `node_modules`.
258
+ *
259
+ * No absolute-path symlinks are produced by this function.
260
+ *
261
+ * **Atomicity.** This call is *not* transactional across multi-step
262
+ * installs. The canonical slot is cleared then written, and each
263
+ * `SYMLINK_TARGETS` slot is then cleared and written one at a time. A
264
+ * crash mid-install can leave the canonical slot updated and one or
265
+ * more agent-specific slots stale; re-running `installSkill` (or the
266
+ * `skills sync` subcommand, which iterates over multiple skills) is
267
+ * idempotent and converges back to the intended state. Within a single
268
+ * slot's copy-mode write, the staging-and-rename in {@link atomicCopyDir}
269
+ * guarantees the destination is either absent or fully populated — a
270
+ * mid-copy failure never leaves a stamp-less partial directory that the
271
+ * next install's `clearInstallSlot` would refuse to replace. Multi-skill
272
+ * orchestration in {@link createSkillSyncCommand} is fail-fast — the
273
+ * first failed skill aborts the loop without rolling back already-
274
+ * installed siblings, again because re-running converges.
275
+ *
276
+ * The ownership stamp (`metadata["politty-cli"]`) is authored by the skill
277
+ * package; the installer does not modify SKILL.md.
278
+ */
279
+ function installSkill(skill, cwd = process.cwd(), options = {}) {
280
+ const name = skill.frontmatter.name;
281
+ assertSafeName(name);
282
+ const mode = options.mode ?? "symlink";
283
+ const expectedStamp = skill.frontmatter.metadata?.["politty-cli"] ?? null;
284
+ if (mode === "copy" && expectedStamp === null) throw new Error(`Refusing to install "${skill.frontmatter.name}" in copy mode without an ownership stamp. Add metadata.${OWNERSHIP_METADATA_KEY}="{package}:{cli}" to the source SKILL.md so subsequent installs can replace the copy in place.`);
285
+ const canonicalParent = resolve(cwd, AGENTS_SKILLS_DIR);
286
+ const resolvedSource = realpathSync(skill.sourcePath);
287
+ const symlinkAwareSource = resolveSourcePreservingPackageHop(skill.sourcePath);
288
+ const overlap = [join(resolveExistingPrefix(canonicalParent), name), ...SYMLINK_TARGETS.map((target) => join(resolveExistingPrefix(resolve(cwd, target)), name))].find((dest) => pathsOverlap(dest, resolvedSource));
289
+ if (overlap !== void 0) throw new Error(`Refusing to install "${name}": source ${resolvedSource} overlaps install destination ${overlap}. Choose a sourceDir outside .agents/skills/ and any agent-specific slot directory (e.g. .claude/skills/).`);
290
+ if (mode === "symlink") {
291
+ const preflightCanonicalParent = resolveExistingPrefix(canonicalParent);
292
+ assertRelativeLinkTarget(join(preflightCanonicalParent, name), relative(preflightCanonicalParent, symlinkAwareSource));
293
+ for (const target of SYMLINK_TARGETS) {
294
+ const preflightAgentParent = resolveExistingPrefix(resolve(cwd, target));
295
+ assertRelativeLinkTarget(join(preflightAgentParent, name), join(relative(preflightAgentParent, preflightCanonicalParent), name));
296
+ }
297
+ }
298
+ mkdirSync(canonicalParent, { recursive: true });
299
+ const resolvedParent = realpathSync(canonicalParent);
300
+ const canonicalDir = join(resolvedParent, name);
301
+ clearInstallSlot(canonicalDir, expectedStamp);
302
+ symlinkOrCopy({
303
+ linkTarget: relative(resolvedParent, symlinkAwareSource),
304
+ linkPath: canonicalDir,
305
+ copyFrom: resolvedSource,
306
+ mode
307
+ });
308
+ populateAgentDirs(cwd, name, canonicalDir, expectedStamp, mode);
309
+ }
310
+ /**
311
+ * Refuse to write a symlink whose target was returned absolute. On Windows
312
+ * `path.relative` returns an absolute path when the endpoints live on
313
+ * different drive letters; producing an absolute symlink target would
314
+ * silently break the "relative target" contract and surprise anyone
315
+ * copying the project tree. Used both as the pre-flight in `installSkill`
316
+ * (before any `clearInstallSlot`) and as the in-line guard inside
317
+ * `symlinkOrCopy`.
318
+ */
319
+ function assertRelativeLinkTarget(linkPath, linkTarget) {
320
+ if (isAbsolute(linkTarget)) throw new Error(`Refusing to write an absolute symlink target at ${linkPath} → ${linkTarget}. The skill source and install root appear to live on different filesystem roots (e.g. different Windows drive letters); retry with mode: "copy".`);
321
+ }
322
+ /**
323
+ * Resolve `sourcePath` so every ancestor symlink (a symlinked checkout,
324
+ * macOS `/tmp` → `/private/tmp`, etc.) gets dereferenced — EXCEPT a
325
+ * `node_modules/<pkg>` or `node_modules/@scope/<pkg>` symlink, which is
326
+ * preserved verbatim from that point onward.
327
+ *
328
+ * Used by `installSkill` to compute the canonical symlink target (see the
329
+ * "Symlink target convention" JSDoc on `installSkill`). The project-root
330
+ * portion of the source must end up in the same realpath style as the
331
+ * install root so a copy/remount keeps both ends in sync; the package
332
+ * manager hop must be preserved so a `pnpm update` that swaps the
333
+ * `.pnpm/<pkg>@<version>_<hash>/...` hashed directory doesn't leave the
334
+ * install dangling.
335
+ *
336
+ * Algorithm: walk root → leaf segment-by-segment. At each segment,
337
+ * `lstatSync` the prefix. If it is a symlink AND its parent looks like a
338
+ * package-manager hop (`node_modules` directly, or an `@scope/` directory
339
+ * inside `node_modules`), return immediately with the remaining segments
340
+ * joined lexically. Otherwise dereference via `realpathSync` (regular
341
+ * directories are kept as-is; ancestor symlinks are followed). If the
342
+ * path doesn't exist past some prefix, return what we have plus the
343
+ * remaining tail lexically — installs against a missing source still
344
+ * throw via the up-front `realpathSync(sourcePath)` call in `installSkill`.
345
+ */
346
+ function resolveSourcePreservingPackageHop(sourcePath) {
347
+ const abs = resolve(sourcePath);
348
+ const { root } = parse(abs);
349
+ const parts = abs.slice(root.length).split(sep).filter((s) => s !== "");
350
+ let current = root;
351
+ for (const [i, segment] of parts.entries()) {
352
+ const next = join(current, segment);
353
+ let stat;
354
+ try {
355
+ stat = lstatSync(next);
356
+ } catch {
357
+ return join(current, ...parts.slice(i));
358
+ }
359
+ if (stat.isSymbolicLink()) {
360
+ if (isPackageManagerHop(current)) return join(current, ...parts.slice(i));
361
+ current = realpathSync(next);
362
+ } else current = next;
363
+ }
364
+ return current;
365
+ }
366
+ /**
367
+ * Does `parentDir` look like the directory immediately above a
368
+ * package-manager symlink? Two layouts qualify:
369
+ *
370
+ * - `<...>/node_modules` — a child symlink at this level is a plain
371
+ * package (`node_modules/<pkg>`).
372
+ * - `<...>/node_modules/@<scope>` — a child symlink at this level is a
373
+ * scoped package (`node_modules/@scope/<pkg>`).
374
+ *
375
+ * Anything else (a symlinked project checkout, `/tmp`, an arbitrary
376
+ * shortcut elsewhere in the tree) is dereferenced.
377
+ */
378
+ function isPackageManagerHop(parentDir) {
379
+ if (basename(parentDir) === "node_modules") return true;
380
+ return basename(parentDir).startsWith("@") && basename(dirname(parentDir)) === "node_modules";
381
+ }
382
+ /**
383
+ * Resolve `p`'s deepest existing ancestor through `realpathSync` and then
384
+ * re-append the lexical tail. Used to compare a not-yet-created destination
385
+ * path against a realpath'd source without materialising the destination —
386
+ * this catches /tmp ↔ /private/tmp style remaps even when the destination
387
+ * parents don't exist yet.
388
+ */
389
+ function resolveExistingPrefix(p) {
390
+ let cur = p;
391
+ const tail = [];
392
+ while (true) try {
393
+ const r = realpathSync(cur);
394
+ return tail.length === 0 ? r : resolve(r, ...tail.reverse());
395
+ } catch {
396
+ const parent = dirname(cur);
397
+ if (parent === cur) return p;
398
+ tail.push(cur.slice(parent.length).replace(/^[/\\]+/, ""));
399
+ cur = parent;
400
+ }
401
+ }
402
+ /**
403
+ * Uninstall a skill from the project's agent skill directories.
404
+ *
405
+ * Each slot is unlinked only when its ownership can be proven:
406
+ * - Agent-specific symlink slots (`.claude/skills/<name>` etc.) — a live
407
+ * symlink is unlinked only when it routes to our canonical slot, so a
408
+ * foreign tool's symlink at the same shared path is left untouched.
409
+ * - The canonical slot (`.agents/skills/<name>`) — a live symlink is
410
+ * unlinked only when its routed-to SKILL.md carries
411
+ * `options.expectedOwnership`, so another politty-based CLI's live
412
+ * install in the same shared namespace is left untouched.
413
+ * - Real directories at any slot are removed only when the directory's
414
+ * SKILL.md carries `options.expectedOwnership`. Unstamped or foreign
415
+ * real directories are left alone so legacy/manual installs are not
416
+ * silently recursively deleted.
417
+ *
418
+ * `skills remove` / `skills sync` always pass `expectedOwnership`. Direct
419
+ * programmatic callers that omit it get the legacy permissive behaviour
420
+ * on symlinks (unconditional unlink) but the conservative behaviour on
421
+ * real directories (no-op). Broken (dangling) canonical symlinks are
422
+ * outside this function's purview — they have no SKILL.md to read, so
423
+ * `cleanupBrokenSlot` handles them with a routing check instead.
424
+ */
425
+ function uninstallSkill(name, cwd = process.cwd(), options = {}) {
426
+ assertSafeName(name);
427
+ const expected = options.expectedOwnership ?? null;
428
+ const canonicalSlot = resolve(cwd, AGENTS_SKILLS_DIR, name);
429
+ for (const target of SYMLINK_TARGETS) removeInstalledSlot(resolve(cwd, target, name), expected, { restrictSymlinkTo: canonicalSlot });
430
+ removeInstalledSlot(canonicalSlot, expected);
431
+ }
432
+ /**
433
+ * Does the symlink at `slot` route to `expected`?
434
+ *
435
+ * Used to gate symlink unlinking in shared-namespace agent slots
436
+ * (`.claude/skills/<name>` etc.) so we never silently clobber a symlink
437
+ * another tool installed there.
438
+ *
439
+ * Resolution rules:
440
+ * - Absolute symlink target → compare directly.
441
+ * - Relative target → resolve against the symlink's directory (lexical),
442
+ * which works even for a dangling symlink we still expect to own.
443
+ * - When both endpoints exist, also match via `realpathSync` so a
444
+ * logically-equivalent path through a parent symlink still matches.
445
+ */
446
+ function symlinkRoutesTo$1(slot, expected) {
447
+ let raw;
448
+ try {
449
+ raw = readlinkSync(slot);
450
+ } catch {
451
+ return false;
452
+ }
453
+ const resolvedTarget = isAbsolute(raw) ? raw : resolve(dirname(slot), raw);
454
+ if (resolvedTarget === expected) return true;
455
+ try {
456
+ return realpathSync(resolvedTarget) === realpathSync(expected);
457
+ } catch {
458
+ return false;
459
+ }
460
+ }
461
+ /**
462
+ * Remove a previously-installed slot:
463
+ * - Symlink at an agent-specific slot (`restrictSymlinkTo` provided) →
464
+ * unlink only when the symlink resolves to that target. A foreign
465
+ * symlink (another tool, manual install) is left alone so removing one
466
+ * owned canonical skill never deletes another tool's link.
467
+ * - Symlink at the canonical slot (no `restrictSymlinkTo`) → unlink only
468
+ * when its routed-to SKILL.md carries `expectedStamp`. `.agents/skills/`
469
+ * is a namespace shared by every politty-based CLI, so unconditionally
470
+ * unlinking would let a programmatic `uninstallSkill` caller delete a
471
+ * foreign CLI's live install. `expectedStamp === null` preserves the
472
+ * legacy permissive behaviour for callers that opt out of ownership
473
+ * checks entirely (e.g. teardown helpers).
474
+ * - Real directory whose SKILL.md carries `expectedStamp` → rm -rf. This
475
+ * handles copy-mode installs that share the same canonical path as the
476
+ * symlink-mode installs.
477
+ * - Anything else (absent, real dir without matching stamp, real file,
478
+ * broken symlink with no stamp to read) → no-op; caller can detect
479
+ * nothing changed by checking after the call.
480
+ *
481
+ * `unlinkSync` (not `rmSync`) is required for symlinks to directories —
482
+ * `rmSync` without `recursive: true` errors "Path is a directory" on a
483
+ * dir-symlink, but passing `recursive: true` would follow the symlink and
484
+ * delete its target contents.
485
+ */
486
+ function removeInstalledSlot(path, expectedStamp, options = {}) {
487
+ let stat;
488
+ try {
489
+ stat = lstatSync(path);
490
+ } catch (err) {
491
+ if (isNodeError$1(err) && (err.code === "ENOENT" || err.code === "ENOTDIR")) return;
492
+ throw err;
493
+ }
494
+ if (stat.isSymbolicLink()) {
495
+ if (options.restrictSymlinkTo !== void 0) {
496
+ if (symlinkRoutesTo$1(path, options.restrictSymlinkTo)) unlinkSync(path);
497
+ return;
498
+ }
499
+ if (expectedStamp === null || readStampAt(path) === expectedStamp) unlinkSync(path);
500
+ return;
501
+ }
502
+ if (stat.isDirectory() && expectedStamp !== null && readStampAt(path) === expectedStamp) rmSync(path, {
503
+ recursive: true,
504
+ force: true
505
+ });
506
+ }
507
+ /**
508
+ * Clear a slot so a new install can occupy `path`.
509
+ *
510
+ * - Absent → no-op.
511
+ * - Symlink (live or broken) → unlink. In a shared-namespace slot
512
+ * (`restrictSymlinkTo` provided), only when the symlink resolves to that
513
+ * target; a foreign symlink at the slot throws instead of being silently
514
+ * replaced.
515
+ * - Real directory whose SKILL.md carries `expectedStamp` → rm -rf. This
516
+ * is how a copy-mode install gets replaced in place by another install
517
+ * (symlink or copy); the ownership check guarantees we are only ever
518
+ * removing data we previously produced.
519
+ * - Real file or foreign real directory → throw. The ownership guards in
520
+ * `addSkill` / `removeOwnedSkill` usually prevent this from being
521
+ * reachable, but a programmatic caller or a hand-made legacy install
522
+ * surfaces as an actionable error here rather than silent data loss.
523
+ */
524
+ function clearInstallSlot(path, expectedStamp, options = {}) {
525
+ let stat;
526
+ try {
527
+ stat = lstatSync(path);
528
+ } catch (err) {
529
+ if (isNodeError$1(err) && (err.code === "ENOENT" || err.code === "ENOTDIR")) return;
530
+ throw err;
531
+ }
532
+ if (stat.isSymbolicLink()) {
533
+ if (options.restrictSymlinkTo === void 0 || symlinkRoutesTo$1(path, options.restrictSymlinkTo)) {
534
+ unlinkSync(path);
535
+ return;
536
+ }
537
+ throw new Error(`Refusing to replace symlink at ${path}: it does not route to this CLI's canonical slot (${options.restrictSymlinkTo}). Remove or migrate the foreign symlink before retrying.`);
538
+ }
539
+ if (stat.isDirectory() && expectedStamp !== null && readStampAt(path) === expectedStamp) {
540
+ rmSync(path, {
541
+ recursive: true,
542
+ force: true
543
+ });
544
+ return;
545
+ }
546
+ throw new Error(`Refusing to replace non-symlink path at ${path}. This looks like a legacy or manual install; remove or migrate it before retrying.`);
547
+ }
548
+ /**
549
+ * Create `linkPath` as a symlink to `linkTarget` (symlink mode) or
550
+ * recursively copy `copyFrom` into `linkPath` (copy mode).
551
+ *
552
+ * In symlink mode, a `symlinkSync` failure is re-thrown with guidance to
553
+ * retry with `mode: "copy"`. Windows without Developer Mode is the
554
+ * canonical case — the underlying EPERM doesn't hint at the fix on its
555
+ * own.
556
+ */
557
+ function symlinkOrCopy(args) {
558
+ const { linkTarget, linkPath, copyFrom, mode } = args;
559
+ if (mode === "copy") {
560
+ atomicCopyDir(copyFrom, linkPath);
561
+ return;
562
+ }
563
+ assertRelativeLinkTarget(linkPath, linkTarget);
564
+ try {
565
+ symlinkSync(linkTarget, linkPath, "dir");
566
+ } catch (err) {
567
+ const cause = err instanceof Error ? err.message : String(err);
568
+ throw new Error(`Failed to symlink ${linkPath} → ${linkTarget}: ${cause}. If this filesystem does not support symlinks (e.g. Windows without Developer Mode), retry with mode: "copy".`, { cause: err });
569
+ }
570
+ }
571
+ /**
572
+ * Stage the copy at a sibling `<dest>.partial-XXXXXX` and rename it into
573
+ * place only after the full copy succeeds. A partial copy that throws
574
+ * partway (unreadable child, cyclic symlink, EIO, etc.) is removed before
575
+ * the error propagates, so `dest` is never left as a stamp-less real
576
+ * directory that `clearInstallSlot` would later refuse to replace.
577
+ *
578
+ * The staging directory lives in `dest`'s parent so `renameSync` stays on
579
+ * the same filesystem (cross-device rename would fail with EXDEV).
580
+ * Callers run `clearInstallSlot(dest, ...)` first, so `dest` is expected
581
+ * to be absent when this is called — the `renameSync` simply moves the
582
+ * fully-populated temp into place. A `*.partial-*` sibling surviving a
583
+ * crash (e.g. `kill -9` between `copyDirRecursive` and `renameSync`) is
584
+ * left as harmless on-disk garbage; subsequent installs ignore it.
585
+ */
586
+ function atomicCopyDir(src, dest) {
587
+ const tmp = mkdtempSync(`${dest}.partial-`);
588
+ try {
589
+ copyDirRecursive(src, tmp);
590
+ renameSync(tmp, dest);
591
+ } catch (err) {
592
+ try {
593
+ rmSync(tmp, {
594
+ recursive: true,
595
+ force: true
596
+ });
597
+ } catch {}
598
+ throw err;
599
+ }
600
+ }
601
+ /**
602
+ * Recursively copy `src` to `dest` following symlinks (`statSync`, not
603
+ * `lstatSync`). Symlinks in the source are materialised as copies of
604
+ * their target content so the install does not leave dangling references
605
+ * back into `node_modules`. Non-regular files (sockets, devices) are
606
+ * ignored.
607
+ *
608
+ * `activeRealPaths` tracks the realpath of every directory currently on
609
+ * the recursion stack so a directory symlink pointing at an ancestor
610
+ * (e.g. `foo/bar -> ../..`) fails fast instead of recursing until the
611
+ * stack overflows or the disk fills.
612
+ *
613
+ * Callers wrap this through {@link atomicCopyDir} so a partial copy is
614
+ * never left at the final destination.
615
+ */
616
+ function copyDirRecursive(src, dest, activeRealPaths = /* @__PURE__ */ new Set()) {
617
+ const stat = statSync(src);
618
+ if (stat.isDirectory()) {
619
+ const realSrc = realpathSync(src);
620
+ if (activeRealPaths.has(realSrc)) throw new Error(`Refusing to recursively copy cyclic directory symlink at ${src} (resolves to ${realSrc}, already on the copy stack).`);
621
+ activeRealPaths.add(realSrc);
622
+ try {
623
+ mkdirSync(dest, { recursive: true });
624
+ for (const entry of readdirSync(src)) copyDirRecursive(join(src, entry), join(dest, entry), activeRealPaths);
625
+ } finally {
626
+ activeRealPaths.delete(realSrc);
627
+ }
628
+ return;
629
+ }
630
+ if (stat.isFile()) copyFileSync(src, dest);
631
+ }
632
+ /**
633
+ * Read the `metadata["politty-cli"]` stamp from a SKILL.md at `<dir>/SKILL.md`.
634
+ * Returns `null` when the file is absent (ENOENT/ENOTDIR), has no
635
+ * frontmatter, or has no string-valued stamp. Other read failures
636
+ * (EACCES/EPERM/IO) propagate — `clearInstallSlot` and
637
+ * `removeInstalledSlot` gate destructive `rmSync`/`unlinkSync` on the
638
+ * stamp matching, so silently treating an unreadable owned copy as
639
+ * "unstamped" would either strand an agent slot after deleting the
640
+ * canonical or report a misleading "legacy or manual install" message
641
+ * from `clearInstallSlot`'s no-clobber guard. Symmetric with
642
+ * `readInstalledOwnership`'s ENOENT/ENOTDIR-only carve-out.
643
+ */
644
+ function readStampAt(dir) {
645
+ let content;
646
+ try {
647
+ content = readFileSync(join(dir, "SKILL.md"), "utf-8");
648
+ } catch (err) {
649
+ if (isNodeError$1(err) && (err.code === "ENOENT" || err.code === "ENOTDIR")) return null;
650
+ throw err;
651
+ }
652
+ const { data } = parseFrontmatter(content);
653
+ const metadata = data.metadata;
654
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null;
655
+ const value = metadata[OWNERSHIP_METADATA_KEY];
656
+ return typeof value === "string" ? value : null;
657
+ }
658
+ /**
659
+ * Report whether a skill is currently installed, independent of its
660
+ * ownership stamp. Returns `true` when `.agents/skills/<name>/SKILL.md`
661
+ * resolves to a readable file (through a valid symlink or directly, or
662
+ * via a copy-mode install); returns `false` when the path is absent or
663
+ * the canonical symlink is broken (source package uninstalled).
664
+ *
665
+ * Callers use this to distinguish the two cases where
666
+ * {@link readInstalledOwnership} returns `null` — "not installed" (safe
667
+ * to install fresh) vs. "installed but unstamped" (legacy or manual
668
+ * install that should not be silently clobbered).
669
+ */
670
+ function hasInstalledSkill(name, cwd = process.cwd()) {
671
+ assertSafeName(name);
672
+ return existsSync(resolve(cwd, AGENTS_SKILLS_DIR, name, "SKILL.md"));
673
+ }
674
+ /**
675
+ * Read the ownership stamp off an installed skill's SKILL.md, if any.
676
+ *
677
+ * For symlink-mode installs `.agents/skills/<name>` points at the source,
678
+ * so this reads the package-authored stamp. For copy-mode installs the
679
+ * stamp was captured at install time into the local copy.
680
+ *
681
+ * @returns `metadata["politty-cli"]` as `"{packageName}:{cliName}"`, or
682
+ * `null` if the skill is not installed *or* the stamp is absent/malformed.
683
+ * Use {@link hasInstalledSkill} to distinguish the two cases.
684
+ */
685
+ function readInstalledOwnership(name, cwd = process.cwd()) {
686
+ assertSafeName(name);
687
+ const path = resolve(cwd, AGENTS_SKILLS_DIR, name, "SKILL.md");
688
+ let content;
689
+ try {
690
+ content = readFileSync(path, "utf-8");
691
+ } catch (err) {
692
+ if (isNodeError$1(err) && (err.code === "ENOENT" || err.code === "ENOTDIR")) return null;
693
+ throw err;
694
+ }
695
+ const { data } = parseFrontmatter(content);
696
+ const metadata = data.metadata;
697
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null;
698
+ const value = metadata[OWNERSHIP_METADATA_KEY];
699
+ return typeof value === "string" ? value : null;
700
+ }
701
+ /**
702
+ * Populate each agent-specific directory so it routes to the canonical
703
+ * install. In symlink-capable filesystems the agent slot is a symlink to
704
+ * `.agents/skills/<name>` so one install swap updates all agent views at
705
+ * once. When `mode` is `"copy"` the slot is a recursive copy of
706
+ * `canonicalDir` instead.
707
+ */
708
+ function populateAgentDirs(cwd, name, canonicalDir, expectedStamp, mode) {
709
+ for (const target of SYMLINK_TARGETS) {
710
+ const targetParent = resolve(cwd, target);
711
+ mkdirSync(targetParent, { recursive: true });
712
+ const resolvedTargetParent = realpathSync(targetParent);
713
+ const targetDir = join(resolvedTargetParent, name);
714
+ clearInstallSlot(targetDir, expectedStamp, { restrictSymlinkTo: canonicalDir });
715
+ symlinkOrCopy({
716
+ linkTarget: join(relative(resolvedTargetParent, realpathSync(resolve(canonicalDir, ".."))), name),
717
+ linkPath: targetDir,
718
+ copyFrom: canonicalDir,
719
+ mode
720
+ });
721
+ }
722
+ }
723
+ function isNodeError$1(err) {
724
+ return err instanceof Error && typeof err.code === "string";
725
+ }
726
+ /**
727
+ * Do `a` and `b` refer to the same directory, or is one nested inside the
728
+ * other? Used to refuse copy-mode installs where the source and destination
729
+ * would recurse into each other. Inputs are expected to be `realpathSync`'d
730
+ * absolute paths so trailing separators and symlink hops don't desynchronise
731
+ * the comparison.
732
+ *
733
+ * Containment is boundary-aware: only `..` or `..<sep>...` counts as escaping
734
+ * `outer`. A relative path like `..backup` is a same-level sibling (one
735
+ * segment whose name happens to start with two dots), so it must NOT be
736
+ * treated as escape. The previous `startsWith("..")` check misclassified such
737
+ * names as outside, missing real overlaps with siblings whose name begins
738
+ * with `..`.
739
+ */
740
+ function pathsOverlap(a, b) {
741
+ if (a === b) return true;
742
+ const isContainedIn = (inner, outer) => {
743
+ const rel = relative(outer, inner);
744
+ if (rel === "" || rel === ".") return true;
745
+ if (isAbsolute(rel)) return false;
746
+ return rel !== ".." && !rel.startsWith(`..${sep}`);
747
+ };
748
+ return isContainedIn(a, b) || isContainedIn(b, a);
749
+ }
750
+
751
+ //#endregion
752
+ //#region ../core/src/skill/scanner.ts
753
+ const SKILL_MD = "SKILL.md";
754
+ /**
755
+ * Scan a source directory for SKILL.md files.
756
+ *
757
+ * Each immediate subdirectory is a candidate skill; its `SKILL.md` is
758
+ * parsed and validated against the Agent Skills specification, and the
759
+ * frontmatter `name` must match the subdirectory name (spec requirement).
760
+ *
761
+ * If `sourceDir` itself contains a `SKILL.md`, it is treated as a
762
+ * single-skill source. The parent-directory-name match is not enforced in
763
+ * that case because the caller chose an arbitrary path.
764
+ *
765
+ * Symlinks within the source tree are followed (symlinked skill dirs and
766
+ * symlinked SKILL.md files are both accepted). npm packages already
767
+ * execute arbitrary JS on install, so additional symlink-based isolation
768
+ * here would not raise the trust boundary in any realistic threat model.
769
+ *
770
+ * @example
771
+ * ```
772
+ * sourceDir: "node_modules/@my-agent/skills/skills"
773
+ *
774
+ * node_modules/@my-agent/skills/skills/
775
+ * ├── commit/
776
+ * │ └── SKILL.md
777
+ * └── review-pr/
778
+ * └── SKILL.md
779
+ * ```
780
+ */
781
+ function scanSourceDir(sourceDir) {
782
+ const skills = [];
783
+ const errors = [];
784
+ try {
785
+ let sourceStat;
786
+ try {
787
+ sourceStat = statSync(sourceDir);
788
+ } catch (error) {
789
+ if (isNodeError(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) errors.push({
790
+ path: sourceDir,
791
+ reason: "missing-source",
792
+ message: `Source directory does not exist: ${sourceDir}`
793
+ });
794
+ else errors.push({
795
+ path: sourceDir,
796
+ reason: "read-failed",
797
+ message: `Failed to stat source directory ${sourceDir}: ${errorMessage$1(error)}`
798
+ });
799
+ return {
800
+ skills,
801
+ errors
802
+ };
803
+ }
804
+ if (!sourceStat.isDirectory()) {
805
+ errors.push({
806
+ path: sourceDir,
807
+ reason: "missing-source",
808
+ message: `Source path is not a directory: ${sourceDir}`
809
+ });
810
+ return {
811
+ skills,
812
+ errors
813
+ };
814
+ }
815
+ const rootSkillMdPath = join(sourceDir, SKILL_MD);
816
+ const rootCheck = skillMdPresent(rootSkillMdPath);
817
+ if (rootCheck.kind === "error") {
818
+ errors.push({
819
+ path: sourceDir,
820
+ reason: "read-failed",
821
+ message: `Failed to check ${rootSkillMdPath}: ${rootCheck.message}`
822
+ });
823
+ return {
824
+ skills,
825
+ errors
826
+ };
827
+ }
828
+ if (rootCheck.kind === "present") {
829
+ pushResult(tryParseSkillDir(sourceDir, { enforceParentMatch: false }), skills, errors);
830
+ return {
831
+ skills,
832
+ errors
833
+ };
834
+ }
835
+ const entries = readdirSync(sourceDir, { withFileTypes: true });
836
+ for (const entry of entries) {
837
+ const skillDir = join(sourceDir, entry.name);
838
+ let isDir;
839
+ try {
840
+ isDir = statSync(skillDir).isDirectory();
841
+ } catch (error) {
842
+ if (isNodeError(error) && error.code === "ENOENT" && !entry.isSymbolicLink()) continue;
843
+ errors.push({
844
+ path: skillDir,
845
+ reason: "read-failed",
846
+ message: entry.isSymbolicLink() ? `Dangling symlink at ${skillDir}: ${errorMessage$1(error)}` : `Failed to stat ${skillDir}: ${errorMessage$1(error)}`
847
+ });
848
+ continue;
849
+ }
850
+ if (!isDir) continue;
851
+ const skillMdPath = join(skillDir, SKILL_MD);
852
+ const childCheck = skillMdPresent(skillMdPath);
853
+ if (childCheck.kind === "error") {
854
+ errors.push({
855
+ path: skillDir,
856
+ reason: "read-failed",
857
+ message: `Failed to check ${skillMdPath}: ${childCheck.message}`
858
+ });
859
+ continue;
860
+ }
861
+ if (childCheck.kind === "absent") continue;
862
+ pushResult(tryParseSkillDir(skillDir, { enforceParentMatch: true }), skills, errors);
863
+ }
864
+ } catch (error) {
865
+ errors.push({
866
+ path: sourceDir,
867
+ reason: "read-failed",
868
+ message: `Failed to scan ${sourceDir}: ${errorMessage$1(error)}`
869
+ });
870
+ }
871
+ skills.sort((a, b) => a.frontmatter.name < b.frontmatter.name ? -1 : a.frontmatter.name > b.frontmatter.name ? 1 : 0);
872
+ return {
873
+ skills,
874
+ errors
875
+ };
876
+ }
877
+ function tryParseSkillDir(dir, opts) {
878
+ const skillMdPath = join(dir, SKILL_MD);
879
+ let content;
880
+ try {
881
+ content = readFileSync(skillMdPath, "utf-8");
882
+ } catch (error) {
883
+ return {
884
+ path: dir,
885
+ reason: "read-failed",
886
+ message: `Failed to read ${skillMdPath}: ${errorMessage$1(error)}`
887
+ };
888
+ }
889
+ const { data, parseError } = parseFrontmatter(content);
890
+ if (parseError !== void 0) return {
891
+ path: dir,
892
+ reason: "parse-failed",
893
+ message: `Invalid SKILL.md frontmatter in ${dir}: YAML parse error: ${parseError}`
894
+ };
895
+ const result = validateSkillFrontmatter(data);
896
+ if (!result.success) return {
897
+ path: dir,
898
+ reason: "parse-failed",
899
+ message: `Invalid SKILL.md frontmatter in ${dir}: ${result.issues.map((issue) => `${issue.path.join(".") || "<root>"}: ${issue.message}`).join("; ")}`
900
+ };
901
+ if (opts.enforceParentMatch) {
902
+ const parent = basename(dir);
903
+ if (parent !== result.data.name) return {
904
+ path: dir,
905
+ reason: "name-mismatch",
906
+ message: `Skill name "${result.data.name}" does not match directory "${parent}"`,
907
+ skillName: result.data.name
908
+ };
909
+ }
910
+ return {
911
+ frontmatter: result.data,
912
+ sourcePath: dir,
913
+ rawContent: content
914
+ };
915
+ }
916
+ function pushResult(value, skills, errors) {
917
+ if ("frontmatter" in value) skills.push(value);
918
+ else errors.push(value);
919
+ }
920
+ function errorMessage$1(error) {
921
+ return error instanceof Error ? error.message : String(error);
922
+ }
923
+ function isNodeError(error) {
924
+ return error instanceof Error && typeof error.code === "string";
925
+ }
926
+ function skillMdPresent(path) {
927
+ try {
928
+ lstatSync(path);
929
+ return { kind: "present" };
930
+ } catch (error) {
931
+ if (isNodeError(error) && error.code === "ENOENT") return { kind: "absent" };
932
+ return {
933
+ kind: "error",
934
+ message: errorMessage$1(error)
935
+ };
936
+ }
937
+ }
938
+
939
+ //#endregion
940
+ //#region ../core/src/skill/commands.ts
941
+ /**
942
+ * Stream scan errors. Per-error `logger.warn` writes to stderr (so a
943
+ * malformed source SKILL.md is always loud), and trailing `logger.info`
944
+ * summary lines echo to stdout — important for pipelines that consume
945
+ * only stdout from the CLI. Directory-level failures (`missing-source`,
946
+ * or per-entry failures on the source directory itself) get their own
947
+ * stdout summary so a stdout-only consumer can tell apart "scan failed"
948
+ * from a legitimate empty bundle.
949
+ *
950
+ * `silentStdout` suppresses only the stdout summary lines (per-error
951
+ * stderr warnings still fire). Used by `skills list --json` so the
952
+ * machine-readable JSON output on stdout stays parseable.
953
+ */
954
+ function logScanErrors(errors, opts = {}) {
955
+ let skipped = 0;
956
+ let directoryFailed = false;
957
+ for (const err of errors) {
958
+ if (err.reason === "missing-source" || err.path === opts.sourceDir) {
959
+ directoryFailed = true;
960
+ logger.warn(`Failed to scan source directory ${err.path}: ${err.message}`);
961
+ continue;
962
+ }
963
+ skipped += 1;
964
+ logger.warn(`Skipping skill at ${err.path}: ${err.message}`);
965
+ }
966
+ if (opts.silentStdout) return;
967
+ if (skipped > 0) logger.info(`${symbols.warning} Skipped ${skipped} skill(s) due to scan errors (see warnings above).`);
968
+ if (directoryFailed) logger.info(`${symbols.warning} Source directory scan failed (see warnings above); subsequent operations may be skipped.`);
969
+ }
970
+ /**
971
+ * Did the scan fail authoritatively at the directory level? Used by
972
+ * commands to distinguish "legitimately empty source" from "scan
973
+ * couldn't enumerate the source", so success-path summaries
974
+ * ("No skills found", "no skills bundled") don't mask a config error.
975
+ */
976
+ function scanFailedAtRoot(result, sourceDir) {
977
+ const directoryScanFailed = result.errors.some((e) => e.reason === "missing-source" || e.path === sourceDir);
978
+ const allSkillsInvalid = result.errors.length > 0 && result.skills.length === 0;
979
+ return directoryScanFailed || allSkillsInvalid;
980
+ }
981
+ function loadSkills(options, logOpts = {}) {
982
+ const result = scanSourceDir(options.sourceDir);
983
+ logScanErrors(result.errors, {
984
+ ...logOpts,
985
+ sourceDir: options.sourceDir
986
+ });
987
+ return result;
988
+ }
989
+ /**
990
+ * Build the metadata for `--exclude` honouring the configured alias.
991
+ * `undefined` alias means `arg()` is called without an alias key.
992
+ */
993
+ function excludeArgMeta(options) {
994
+ const meta = { description: "Skill names to exclude from sync" };
995
+ if (options.excludeAlias !== void 0) meta.alias = options.excludeAlias;
996
+ return meta;
997
+ }
998
+ /**
999
+ * Build the metadata for `--verbose` (shared by `add`/`sync`) honouring the
1000
+ * configured alias. `undefined` alias means `arg()` is called without an
1001
+ * alias key. Only called when `options.verbose.disabled` is `false`.
1002
+ */
1003
+ function verboseArgMeta(options) {
1004
+ const meta = { description: "Print install paths and modes" };
1005
+ if (options.verbose.alias !== void 0) meta.alias = options.verbose.alias;
1006
+ return meta;
1007
+ }
1008
+ /**
1009
+ * Read a same-named boolean out of a leaf command's (already-merged) args
1010
+ * object. When a built-in flag is omitted because `SkillCommandOptions.
1011
+ * globalArgs` already declares a same-named field (see
1012
+ * `ResolvedSkillOptions.verbose`/`.json`), the local schema no longer
1013
+ * declares it, but politty's runner still merges the host's
1014
+ * `globalArgs`-parsed values into every leaf command's `args` regardless of
1015
+ * the leaf's own schema shape. Reading it here (rather than hardcoding
1016
+ * `false`) means the omission hands control to that global flag instead of
1017
+ * just turning the feature off.
1018
+ */
1019
+ function mergedFlag(args, name) {
1020
+ return Boolean(args[name]);
1021
+ }
1022
+ /**
1023
+ * Create the `skills sync` subcommand.
1024
+ *
1025
+ * Removes and reinstalls all skills discovered in sourceDir. Skills owned
1026
+ * by this CLI that are no longer present in sourceDir are also removed so
1027
+ * stale skills do not linger after the CLI drops them from its bundle.
1028
+ */
1029
+ function createSkillSyncCommand(resolved) {
1030
+ function runSync(args, verbose) {
1031
+ const { skills: allSkills, errors } = loadSkills(resolved);
1032
+ const stamp = resolved.stamp;
1033
+ const sourceNamesAll = new Set(allSkills.map((s) => s.frontmatter.name));
1034
+ const ownedInstalled = new Set(findOwnedInstalledSkills(stamp, resolved.cwd, resolved.sourceDir));
1035
+ const unknownExclude = Array.from(new Set(args.exclude)).filter((n) => !sourceNamesAll.has(n) && !ownedInstalled.has(n));
1036
+ if (unknownExclude.length > 0) {
1037
+ const subject = unknownExclude.length === 1 ? "Skill" : "Skills";
1038
+ const quoted = unknownExclude.map((n) => JSON.stringify(n)).join(", ");
1039
+ throw new Error(`--exclude: ${subject} ${quoted} not found in source directory or among installed skills.\n` + formatSkillUniverse({
1040
+ source: allSkills,
1041
+ installed: ownedInstalled
1042
+ }));
1043
+ }
1044
+ const excluded = new Set(args.exclude);
1045
+ const skills = allSkills.filter((s) => !excluded.has(s.frontmatter.name));
1046
+ const rootScanFailed = scanFailedAtRoot({
1047
+ skills: allSkills,
1048
+ errors
1049
+ }, resolved.sourceDir);
1050
+ let removed = 0;
1051
+ if (!rootScanFailed) {
1052
+ const sourceNames = new Set(skills.map((s) => s.frontmatter.name));
1053
+ const erroredSlotNames = /* @__PURE__ */ new Set();
1054
+ for (const err of errors) {
1055
+ if (err.path === resolved.sourceDir) continue;
1056
+ erroredSlotNames.add(basename(err.path));
1057
+ if (err.skillName !== void 0) erroredSlotNames.add(err.skillName);
1058
+ }
1059
+ for (const orphan of ownedInstalled) {
1060
+ if (sourceNames.has(orphan) || excluded.has(orphan) || erroredSlotNames.has(orphan)) continue;
1061
+ removeOwnedSkill(orphan, stamp, resolved.cwd, resolved.sourceDir);
1062
+ removed += 1;
1063
+ }
1064
+ }
1065
+ let installed = 0;
1066
+ for (const skill of skills) {
1067
+ addSkill(skill, stamp, resolved, verbose);
1068
+ installed += 1;
1069
+ }
1070
+ if (installed === 0 && removed === 0) {
1071
+ const reason = rootScanFailed ? "source directory scan failed; see warnings" : allSkills.length > 0 && skills.length === 0 ? "all skills excluded" : "no skills bundled";
1072
+ logger.info(`No skills installed (${reason}).`);
1073
+ } else logger.info(`Sync complete: ${installed} installed, ${removed} removed.`);
1074
+ }
1075
+ if (resolved.verbose.disabled) return defineCommand({
1076
+ name: "sync",
1077
+ description: resolved.descriptions.sync,
1078
+ args: internalArgs({ exclude: internalField.stringArray(excludeArgMeta(resolved)) }, { unknownKeys: resolved.unknownKeys }),
1079
+ run(args) {
1080
+ runSync(args, mergedFlag(args, "verbose"));
1081
+ }
1082
+ });
1083
+ return defineCommand({
1084
+ name: "sync",
1085
+ description: resolved.descriptions.sync,
1086
+ args: internalArgs({
1087
+ exclude: internalField.stringArray(excludeArgMeta(resolved)),
1088
+ verbose: internalField.boolean(verboseArgMeta(resolved))
1089
+ }, { unknownKeys: resolved.unknownKeys }),
1090
+ run(args) {
1091
+ runSync(args, args.verbose);
1092
+ }
1093
+ });
1094
+ }
1095
+ /**
1096
+ * Create the `skills add` subcommand.
1097
+ *
1098
+ * Installs skills from sourceDir. Accepts zero or more skill names; with no
1099
+ * names, installs every skill in source. With one or more names, every name
1100
+ * is validated against sourceSkills up-front so a typo never silently
1101
+ * proceeds with the valid neighbours — a single unknown name aborts the run
1102
+ * and lists every unknown name at once. Duplicates are deduplicated.
1103
+ */
1104
+ function createSkillAddCommand(resolved) {
1105
+ const nameArgMeta = {
1106
+ positional: true,
1107
+ description: "Skill name(s) to install (default: all)",
1108
+ placeholder: "NAME"
1109
+ };
1110
+ function runAdd(args, verbose) {
1111
+ const scanResult = loadSkills(resolved);
1112
+ const sourceSkills = scanResult.skills;
1113
+ const stamp = resolved.stamp;
1114
+ if (args.name.length > 0) {
1115
+ const known = new Set(sourceSkills.map((s) => s.frontmatter.name));
1116
+ const requested = Array.from(new Set(args.name));
1117
+ const unknown = requested.filter((n) => !known.has(n));
1118
+ if (unknown.length > 0) {
1119
+ const subject = unknown.length === 1 ? "Skill" : "Skills";
1120
+ const quoted = unknown.map((n) => JSON.stringify(n)).join(", ");
1121
+ throw new Error(`${subject} ${quoted} not found in source directory.\n` + formatSkillUniverse({ source: sourceSkills }));
1122
+ }
1123
+ const wanted = new Set(requested);
1124
+ for (const skill of sourceSkills) if (wanted.has(skill.frontmatter.name)) addSkill(skill, stamp, resolved, verbose);
1125
+ return;
1126
+ }
1127
+ if (sourceSkills.length === 0) {
1128
+ if (scanFailedAtRoot(scanResult, resolved.sourceDir)) logger.info("No skills installed (source directory scan failed; see warnings).");
1129
+ else logger.info("No skills found in source directory.");
1130
+ return;
1131
+ }
1132
+ for (const skill of sourceSkills) addSkill(skill, stamp, resolved, verbose);
1133
+ }
1134
+ if (resolved.verbose.disabled) return defineCommand({
1135
+ name: resolved.commandNames.add.name,
1136
+ ...resolved.commandNames.add.aliases.length > 0 ? { aliases: resolved.commandNames.add.aliases } : {},
1137
+ description: resolved.descriptions.add,
1138
+ args: internalArgs({ name: internalField.stringArray(nameArgMeta) }, { unknownKeys: resolved.unknownKeys }),
1139
+ run(args) {
1140
+ runAdd(args, mergedFlag(args, "verbose"));
1141
+ }
1142
+ });
1143
+ return defineCommand({
1144
+ name: resolved.commandNames.add.name,
1145
+ ...resolved.commandNames.add.aliases.length > 0 ? { aliases: resolved.commandNames.add.aliases } : {},
1146
+ description: resolved.descriptions.add,
1147
+ args: internalArgs({
1148
+ name: internalField.stringArray(nameArgMeta),
1149
+ verbose: internalField.boolean(verboseArgMeta(resolved))
1150
+ }, { unknownKeys: resolved.unknownKeys }),
1151
+ run(args) {
1152
+ runAdd(args, args.verbose);
1153
+ }
1154
+ });
1155
+ }
1156
+ /**
1157
+ * Create the `skills remove` subcommand.
1158
+ *
1159
+ * Removes installed skills. Defaults to all skills discovered in sourceDir
1160
+ * if no name is given. Only skills stamped with this CLI's ownership
1161
+ * (`metadata["politty-cli"] === "{package}:{cli}"`) are removed — skills
1162
+ * another tool installed are left untouched.
1163
+ */
1164
+ function createSkillRemoveCommand(resolved) {
1165
+ return defineCommand({
1166
+ name: resolved.commandNames.remove.name,
1167
+ ...resolved.commandNames.remove.aliases.length > 0 ? { aliases: resolved.commandNames.remove.aliases } : {},
1168
+ description: resolved.descriptions.remove,
1169
+ args: internalArgs({ name: internalField.optionalString({
1170
+ positional: true,
1171
+ description: "Skill name to remove (default: all)",
1172
+ placeholder: "NAME"
1173
+ }) }, { unknownKeys: resolved.unknownKeys }),
1174
+ run(args) {
1175
+ const scanResult = loadSkills(resolved);
1176
+ const sourceSkills = scanResult.skills;
1177
+ const stamp = resolved.stamp;
1178
+ if (args.name) {
1179
+ if (sourceSkills.some((s) => s.frontmatter.name === args.name)) findOrThrow(sourceSkills, args.name);
1180
+ if (!removeOwnedSkill(args.name, stamp, resolved.cwd, resolved.sourceDir)) if (slotPresent(args.name, resolved.cwd)) logger.info(`${args.name} is installed without a ${OWNERSHIP_METADATA_KEY} stamp this CLI recognises; refusing to remove. Remove .agents/skills/${args.name} manually if intended.`);
1181
+ else {
1182
+ const installed = new Set(findOwnedInstalledSkills(stamp, resolved.cwd, resolved.sourceDir));
1183
+ logger.info(`${args.name} is not installed; nothing to remove.\n` + formatSkillUniverse({ installed }));
1184
+ }
1185
+ return;
1186
+ }
1187
+ if (sourceSkills.length === 0) {
1188
+ if (scanFailedAtRoot(scanResult, resolved.sourceDir)) logger.info("No skills found (source directory scan failed; see warnings); nothing to remove.");
1189
+ else logger.info("No skills found in source directory; nothing to remove.");
1190
+ return;
1191
+ }
1192
+ let removed = 0;
1193
+ for (const skill of sourceSkills) if (removeOwnedSkill(skill.frontmatter.name, stamp, resolved.cwd, resolved.sourceDir)) removed += 1;
1194
+ if (removed === 0) logger.info("No installed skills owned by this CLI; nothing to remove.");
1195
+ }
1196
+ });
1197
+ }
1198
+ function listStatus(name, expectedOwnership, cwd, sourceDir) {
1199
+ let owner;
1200
+ try {
1201
+ owner = readInstalledOwnership(name, cwd);
1202
+ } catch (error) {
1203
+ logger.warn(`Failed to read ownership for installed skill ${name}: ${errorMessage(error)}`);
1204
+ return "unreadable";
1205
+ }
1206
+ if (owner === expectedOwnership) return "installed";
1207
+ if (owner !== null) return "foreign";
1208
+ if (!hasInstalledSkill(name, cwd)) {
1209
+ const canonical = resolve(cwd, AGENTS_SKILLS_DIR, name);
1210
+ if (isDanglingSymlink(canonical) && danglingRoutesToSource(canonical, sourceDir)) return "missing";
1211
+ return slotPresent(name, cwd) ? "unstamped" : "not-installed";
1212
+ }
1213
+ return "unstamped";
1214
+ }
1215
+ function errorMessage(error) {
1216
+ return error instanceof Error ? error.message : String(error);
1217
+ }
1218
+ function slotPresent(name, cwd) {
1219
+ try {
1220
+ lstatSync(resolve(cwd, AGENTS_SKILLS_DIR, name));
1221
+ return true;
1222
+ } catch {
1223
+ return false;
1224
+ }
1225
+ }
1226
+ /**
1227
+ * Create the `skills list` subcommand.
1228
+ *
1229
+ * Lists available skills from the source directory.
1230
+ */
1231
+ function createSkillListCommand(resolved) {
1232
+ function runList(json) {
1233
+ const scanResult = loadSkills(resolved, { silentStdout: json });
1234
+ const sourceSkills = scanResult.skills;
1235
+ const stamp = resolved.stamp;
1236
+ if (json) {
1237
+ console.log(JSON.stringify(sourceSkills.map((s) => ({
1238
+ name: s.frontmatter.name,
1239
+ description: s.frontmatter.description,
1240
+ owner: s.frontmatter.metadata?.["politty-cli"] ?? null,
1241
+ expectedOwner: stamp,
1242
+ status: listStatus(s.frontmatter.name, stamp, resolved.cwd, resolved.sourceDir),
1243
+ sourcePath: s.sourcePath
1244
+ }))));
1245
+ return;
1246
+ }
1247
+ if (sourceSkills.length === 0) {
1248
+ if (scanFailedAtRoot(scanResult, resolved.sourceDir)) logger.info("Source directory scan failed; see warnings.");
1249
+ else logger.info("No skills found in source directory.");
1250
+ return;
1251
+ }
1252
+ logger.info("Available skills:");
1253
+ for (const skill of sourceSkills) {
1254
+ const status = listStatus(skill.frontmatter.name, stamp, resolved.cwd, resolved.sourceDir);
1255
+ logger.info(` ${skill.frontmatter.name.padEnd(20)} ${status.padEnd(14)} ${skill.frontmatter.description}`);
1256
+ }
1257
+ }
1258
+ if (resolved.json.disabled) return defineCommand({
1259
+ name: "list",
1260
+ description: resolved.descriptions.list,
1261
+ args: internalArgs({}, { unknownKeys: resolved.unknownKeys }),
1262
+ run(args) {
1263
+ runList(mergedFlag(args, "json"));
1264
+ }
1265
+ });
1266
+ return defineCommand({
1267
+ name: "list",
1268
+ description: resolved.descriptions.list,
1269
+ args: internalArgs({ json: internalField.boolean({ description: "Output as JSON" }) }, { unknownKeys: resolved.unknownKeys }),
1270
+ run(args) {
1271
+ runList(args.json);
1272
+ }
1273
+ });
1274
+ }
1275
+ function findOrThrow(skills, name) {
1276
+ const skill = skills.find((s) => s.frontmatter.name === name);
1277
+ if (!skill) {
1278
+ const available = skills.map((s) => s.frontmatter.name).join(", ") || "<none>";
1279
+ throw new Error(`Skill "${name}" not found in source directory. Available: ${available}`);
1280
+ }
1281
+ return skill;
1282
+ }
1283
+ /**
1284
+ * Render skill-name lists for typo-error diagnostics. Each command lists
1285
+ * only the universe its argument actually accepts so the suggestions
1286
+ * match what the user can legitimately retype:
1287
+ * - `add` — source only.
1288
+ * - `remove` — installed only.
1289
+ * - `sync --exclude` — both (a source skill skips its install, an
1290
+ * installed-owned orphan is preserved from removal).
1291
+ * Empty sections render as `<none>` so the user can tell apart "I don't
1292
+ * know about any" from "the message forgot a section".
1293
+ */
1294
+ function formatSkillUniverse(opts) {
1295
+ const parts = [];
1296
+ if (opts.source !== void 0) parts.push(` Source: ${opts.source.map((s) => s.frontmatter.name).join(", ") || "<none>"}`);
1297
+ if (opts.installed !== void 0) parts.push(` Installed: ${[...opts.installed].sort().join(", ") || "<none>"}`);
1298
+ return parts.join("\n");
1299
+ }
1300
+ function addSkill(skill, expectedOwnership, resolved, verbose) {
1301
+ const name = skill.frontmatter.name;
1302
+ const cwd = resolved.cwd;
1303
+ const mode = resolved.mode;
1304
+ const sourceOwnership = skill.frontmatter.metadata?.["politty-cli"] ?? null;
1305
+ if (sourceOwnership !== expectedOwnership) throw new Error(`Refusing to install "${name}": source SKILL.md declares metadata.${OWNERSHIP_METADATA_KEY}=${JSON.stringify(sourceOwnership)}, expected ${JSON.stringify(expectedOwnership)}.`);
1306
+ const actual = readInstalledOwnership(name, cwd);
1307
+ if (actual !== null && actual !== expectedOwnership) throw new Error(`Refusing to install "${name}": owned by ${JSON.stringify(actual)}, not ${JSON.stringify(expectedOwnership)}. Check metadata.${OWNERSHIP_METADATA_KEY} in .agents/skills/${name}/SKILL.md.`);
1308
+ const canonical = resolve(cwd, AGENTS_SKILLS_DIR, name);
1309
+ const danglingOurs = isDanglingSymlink(canonical) && danglingRoutesToSource(canonical, resolved.sourceDir);
1310
+ if (actual === null && slotPresent(name, cwd) && !danglingOurs) throw new Error(`Refusing to install "${name}": .agents/skills/${name} exists without a ${OWNERSHIP_METADATA_KEY} stamp, so it was not installed by this CLI. Remove it manually (or add the stamp to take ownership) before running "skills add".`);
1311
+ installSkill(skill, cwd, mode === void 0 ? {} : { mode });
1312
+ logger.info(`${symbols.success} Installed ${name}`);
1313
+ if (verbose) {
1314
+ const effectiveMode = mode ?? "symlink";
1315
+ logger.info(` mode=${effectiveMode} path=${canonical}`);
1316
+ }
1317
+ }
1318
+ /**
1319
+ * Remove a skill only if it belongs to this CLI (ownership stamp matches
1320
+ * `{package}:{cli}`). Returns `true` when something was actually removed,
1321
+ * `false` when the skill was not installed (allowing callers to surface a
1322
+ * "nothing to remove" message).
1323
+ *
1324
+ * A broken canonical symlink (`.agents/skills/<name>` exists as a symlink
1325
+ * but its target does not) is also cleaned up here, even though
1326
+ * `readInstalledOwnership` returns `null` in that case — the slot is in
1327
+ * this CLI's namespace and unlinking a dangling symlink can never delete
1328
+ * user data. This matches the `status: "missing"` listed by `skills list`.
1329
+ *
1330
+ * Throws when the skill exists but is owned by someone else — callers
1331
+ * like `sync` that iterate silently would otherwise clobber user data.
1332
+ */
1333
+ function removeOwnedSkill(name, expectedOwnership, cwd, sourceDir) {
1334
+ const actual = readInstalledOwnership(name, cwd);
1335
+ if (actual === null) {
1336
+ if (cleanupBrokenSlot(name, cwd, sourceDir)) {
1337
+ logger.info(`${symbols.success} Removed ${name} (broken symlink)`);
1338
+ return true;
1339
+ }
1340
+ return false;
1341
+ }
1342
+ if (actual !== expectedOwnership) throw new Error(`Refusing to remove "${name}": owned by ${JSON.stringify(actual)}, not ${JSON.stringify(expectedOwnership)}. Check metadata.${OWNERSHIP_METADATA_KEY} in .agents/skills/${name}/SKILL.md.`);
1343
+ uninstallSkill(name, cwd, { expectedOwnership });
1344
+ logger.info(`${symbols.success} Removed ${name}`);
1345
+ return true;
1346
+ }
1347
+ /**
1348
+ * If `.agents/skills/<name>` is a dangling symlink that still routes to
1349
+ * this CLI's source directory, unlink it (and any agent-specific
1350
+ * dangling-symlink slots that route through it). Returns `true` when the
1351
+ * canonical slot was cleaned.
1352
+ *
1353
+ * A dangling canonical whose target lies outside our source directory
1354
+ * (e.g. a foreign politty-based CLI's stale install in the shared
1355
+ * `.agents/skills/` namespace) is left alone — without an ownership
1356
+ * stamp to read, we can't prove the slot belongs to this CLI. Live
1357
+ * symlinks (target still resolves) go through the normal stamp-checked
1358
+ * path.
1359
+ */
1360
+ function cleanupBrokenSlot(name, cwd, sourceDir) {
1361
+ const canonical = resolve(cwd, AGENTS_SKILLS_DIR, name);
1362
+ if (!isDanglingSymlink(canonical)) return false;
1363
+ if (!danglingRoutesToSource(canonical, sourceDir)) return false;
1364
+ for (const target of SYMLINK_TARGETS) {
1365
+ const agentSlot = resolve(cwd, target, name);
1366
+ if (!isDanglingSymlink(agentSlot)) continue;
1367
+ if (symlinkRoutesTo(agentSlot, canonical)) unlinkSync(agentSlot);
1368
+ }
1369
+ unlinkSync(canonical);
1370
+ return true;
1371
+ }
1372
+ /**
1373
+ * Does the dangling symlink at `canonical` still route into `sourceDir`?
1374
+ * Used to confirm a stale `.agents/skills/<name>` belongs to this CLI
1375
+ * before we unlink it in the shared namespace.
1376
+ *
1377
+ * The link target is resolved lexically (the path is dangling so
1378
+ * `realpathSync` on it would fail) against the symlink's own directory.
1379
+ * `sourceDir` is resolved through `resolveDeepestExisting` so the
1380
+ * comparison survives realpath remapping (macOS `/tmp` →
1381
+ * `/private/tmp`, a project mounted through a symlink, etc) *and* the
1382
+ * documented case where the configured source path itself no longer
1383
+ * exists (the source package was uninstalled — exactly the scenario
1384
+ * `status: "missing"` is meant to surface). Containment uses the same
1385
+ * boundary-aware `..`-only escape check as `installer.ts`'s
1386
+ * `pathsOverlap` so a sibling directory whose name happens to start
1387
+ * with `..` is not misclassified as outside.
1388
+ */
1389
+ function danglingRoutesToSource(canonical, sourceDir) {
1390
+ let raw;
1391
+ try {
1392
+ raw = readlinkSync(canonical);
1393
+ } catch {
1394
+ return false;
1395
+ }
1396
+ const absoluteTarget = isAbsolute(raw) ? raw : resolve(dirname(canonical), raw);
1397
+ const rel = relative(resolveDeepestExisting(resolve(sourceDir)), resolveDeepestExisting(absoluteTarget));
1398
+ if (isAbsolute(rel)) return false;
1399
+ if (rel === ".." || rel.startsWith(`..${sep}`)) return false;
1400
+ return true;
1401
+ }
1402
+ function resolveDeepestExisting(p) {
1403
+ let cur = p;
1404
+ const tail = [];
1405
+ while (true) try {
1406
+ const r = realpathSync(cur);
1407
+ return tail.length === 0 ? r : resolve(r, ...tail.reverse());
1408
+ } catch {
1409
+ const parent = dirname(cur);
1410
+ if (parent === cur) return p;
1411
+ tail.push(cur.slice(parent.length).replace(/^[/\\]+/, ""));
1412
+ cur = parent;
1413
+ }
1414
+ }
1415
+ /**
1416
+ * Does the symlink at `slot` route to `expected` (lexically, with a
1417
+ * realpath fallback)? Mirrors `symlinkRoutesTo` in `installer.ts` —
1418
+ * deliberately a local duplicate so `commands.ts` does not depend on the
1419
+ * installer's private helpers.
1420
+ */
1421
+ function symlinkRoutesTo(slot, expected) {
1422
+ let raw;
1423
+ try {
1424
+ raw = readlinkSync(slot);
1425
+ } catch {
1426
+ return false;
1427
+ }
1428
+ const resolvedTarget = isAbsolute(raw) ? raw : resolve(dirname(slot), raw);
1429
+ if (resolvedTarget === expected) return true;
1430
+ try {
1431
+ return realpathSync(resolvedTarget) === realpathSync(expected);
1432
+ } catch {
1433
+ return false;
1434
+ }
1435
+ }
1436
+ function isDanglingSymlink(path) {
1437
+ let stat;
1438
+ try {
1439
+ stat = lstatSync(path);
1440
+ } catch {
1441
+ return false;
1442
+ }
1443
+ if (!stat.isSymbolicLink()) return false;
1444
+ return !existsSync(path);
1445
+ }
1446
+ /**
1447
+ * Enumerate installed skills that should be reconciled by `sync`'s orphan
1448
+ * cleanup: skills carrying this CLI's ownership stamp, plus dangling
1449
+ * canonical symlinks whose link target routes back to this CLI's source
1450
+ * directory. `.agents/skills/` is a namespace shared with every other
1451
+ * politty-based CLI, so a dangling canonical symlink without a routing
1452
+ * match likely belongs to a foreign CLI whose source was uninstalled —
1453
+ * including it would let `sync` unlink it under our authority.
1454
+ */
1455
+ function findOwnedInstalledSkills(expectedOwnership, cwd, sourceDir) {
1456
+ const base = resolve(cwd, AGENTS_SKILLS_DIR);
1457
+ const owned = [];
1458
+ let entries;
1459
+ try {
1460
+ entries = readdirSync(base, { withFileTypes: true });
1461
+ } catch (err) {
1462
+ const code = err.code;
1463
+ if (code === "ENOENT" || code === "ENOTDIR") return owned;
1464
+ logger.warn(`Failed to enumerate ${base}: ${err instanceof Error ? err.message : String(err)}`);
1465
+ return owned;
1466
+ }
1467
+ for (const entry of entries) {
1468
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
1469
+ if (entry.name.length < 1 || entry.name.length > 64) continue;
1470
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(entry.name)) continue;
1471
+ let owner;
1472
+ try {
1473
+ owner = readInstalledOwnership(entry.name, cwd);
1474
+ } catch (err) {
1475
+ logger.warn(`Failed to read ownership for ${entry.name}: ${err instanceof Error ? err.message : String(err)}`);
1476
+ continue;
1477
+ }
1478
+ if (owner === expectedOwnership) {
1479
+ owned.push(entry.name);
1480
+ continue;
1481
+ }
1482
+ const canonical = resolve(base, entry.name);
1483
+ if (owner === null && isDanglingSymlink(canonical) && danglingRoutesToSource(canonical, sourceDir)) owned.push(entry.name);
1484
+ }
1485
+ return owned;
1486
+ }
1487
+
1488
+ //#endregion
1489
+ //#region ../core/src/skill/options.ts
1490
+ /** Default unknown-keys mode for the `add`/`sync`/`remove`/`list` arg schemas. */
1491
+ const DEFAULT_UNKNOWN_KEYS = "strip";
1492
+ /** Default short alias for `skills sync --exclude`. */
1493
+ const DEFAULT_EXCLUDE_ALIAS = "x";
1494
+ /** Default short alias for `skills add`/`skills sync --verbose`. */
1495
+ const DEFAULT_VERBOSE_ALIAS = "v";
1496
+ /** Default primary name + aliases for `skills add`. */
1497
+ const DEFAULT_ADD_NAMES = ["add", "install"];
1498
+ /** Default primary name + aliases for `skills remove`. */
1499
+ const DEFAULT_REMOVE_NAMES = ["remove", "uninstall"];
1500
+ /**
1501
+ * Default description text for the `skills` command and each built-in
1502
+ * subcommand, keyed by canonical role. Overridden per-key via
1503
+ * `SkillCommandOptions.descriptions`.
1504
+ */
1505
+ const DEFAULT_DESCRIPTIONS = {
1506
+ skills: "Manage agent skills",
1507
+ sync: "Remove and reinstall all skills from source",
1508
+ add: "Install skills from source",
1509
+ remove: "Remove installed skills",
1510
+ list: "List available skills from source"
1511
+ };
1512
+ /**
1513
+ * Same safe-token pattern politty's own command validator enforces for
1514
+ * subcommand aliases (`checkSubCommandAliasConflicts` in
1515
+ * src/validator/command-validator.ts). That check only runs when a host
1516
+ * explicitly calls `validateCommand()`, not automatically from
1517
+ * `runMain`/`runCommand`, so `commandMap` entries need their own check.
1518
+ */
1519
+ const SAFE_TOKEN = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
1520
+ /** Marker files identifying a project root for find-up. */
1521
+ const PROJECT_ROOT_MARKERS = [".git", "package.json"];
1522
+ /**
1523
+ * Resolve user-facing {@link SkillCommandOptions} into the concrete shape
1524
+ * each subcommand consumes. Defaults applied here:
1525
+ *
1526
+ * - `cwd` — `findProjectRoot(process.cwd()) ?? process.cwd()`.
1527
+ * - `excludeAlias` — `"x"` unless overridden via
1528
+ * `flags.exclude.alias` (string) or disabled (`false`).
1529
+ * - `verbose` — alias `"v"` unless overridden via `flags.verbose.alias`;
1530
+ * the flag itself is omitted from `add`/`sync` whenever `globalArgs`
1531
+ * already defines a `verbose` field.
1532
+ * - `json` — the flag is omitted from `list` whenever `globalArgs` already
1533
+ * defines a `json` field.
1534
+ * - `commandNames.add`/`.remove` — primary name `"add"`/`"remove"` plus
1535
+ * alias `"install"`/`"uninstall"` unless overridden via
1536
+ * `options.commandMap.add`/`.remove` (first array element becomes the
1537
+ * primary name, the rest become aliases).
1538
+ * - `unknownKeys` — `"strip"` unless overridden via `options.unknownKeys`.
1539
+ * - `descriptionAppend` — a one-line hint mentioning the skills
1540
+ * subcommands. Pass an explicit string to override or `false` to opt out.
1541
+ * - `descriptions` — politty's default text per canonical role, unless
1542
+ * overridden via `options.descriptions`.
1543
+ */
1544
+ function resolveSkillOptions(options, cliName) {
1545
+ return {
1546
+ sourceDir: options.sourceDir,
1547
+ package: options.package,
1548
+ mode: options.mode,
1549
+ cwd: resolveCwd(options.cwd),
1550
+ excludeAlias: resolveExcludeAlias(options.flags?.exclude?.alias),
1551
+ verbose: resolveVerbose(options.flags?.verbose, options.globalArgs),
1552
+ json: { disabled: hasGlobalField(options.globalArgs, "json") },
1553
+ commandNames: {
1554
+ add: resolveCommandNaming(options.commandMap?.add, DEFAULT_ADD_NAMES, "add"),
1555
+ remove: resolveCommandNaming(options.commandMap?.remove, DEFAULT_REMOVE_NAMES, "remove")
1556
+ },
1557
+ unknownKeys: options.unknownKeys ?? DEFAULT_UNKNOWN_KEYS,
1558
+ descriptionAppend: resolveDescriptionAppend(options.descriptionAppend, cliName),
1559
+ stamp: `${options.package}:${cliName}`,
1560
+ descriptions: resolveDescriptions(options.descriptions)
1561
+ };
1562
+ }
1563
+ /** Fill in {@link DEFAULT_DESCRIPTIONS} for any key the caller left unset. */
1564
+ function resolveDescriptions(value) {
1565
+ return {
1566
+ skills: value?.skills ?? DEFAULT_DESCRIPTIONS.skills,
1567
+ sync: value?.sync ?? DEFAULT_DESCRIPTIONS.sync,
1568
+ add: value?.add ?? DEFAULT_DESCRIPTIONS.add,
1569
+ remove: value?.remove ?? DEFAULT_DESCRIPTIONS.remove,
1570
+ list: value?.list ?? DEFAULT_DESCRIPTIONS.list
1571
+ };
1572
+ }
1573
+ function resolveCwd(override) {
1574
+ if (override !== void 0) return resolve(override);
1575
+ const start = process.cwd();
1576
+ return findProjectRoot(start) ?? start;
1577
+ }
1578
+ /**
1579
+ * Walk up from `start` looking for the closest directory containing one
1580
+ * of {@link PROJECT_ROOT_MARKERS}. Returns `null` when the walk reaches
1581
+ * the filesystem root without a hit.
1582
+ *
1583
+ * `.git` matches both repositories (a directory) and worktrees / submodule
1584
+ * checkouts (a file pointing at the parent gitdir) because `existsSync`
1585
+ * accepts either.
1586
+ */
1587
+ function findProjectRoot(start) {
1588
+ let dir = resolve(start);
1589
+ while (true) {
1590
+ for (const marker of PROJECT_ROOT_MARKERS) if (existsSync(resolve(dir, marker))) return dir;
1591
+ const parent = dirname(dir);
1592
+ if (parent === dir) return null;
1593
+ dir = parent;
1594
+ }
1595
+ }
1596
+ function resolveExcludeAlias(value) {
1597
+ if (value === false) return void 0;
1598
+ if (typeof value === "string") return value;
1599
+ return DEFAULT_EXCLUDE_ALIAS;
1600
+ }
1601
+ /**
1602
+ * The first element of `value` (or `defaults`, when `value` is `undefined`)
1603
+ * becomes the primary name; the rest become aliases.
1604
+ */
1605
+ function resolveCommandNaming(value, defaults, label) {
1606
+ const names = value ?? defaults;
1607
+ if (names.length === 0) throw new Error(`SkillCommandOptions.commandMap.${label} must include at least one name.`);
1608
+ const invalid = names.find((name) => !SAFE_TOKEN.test(name));
1609
+ if (invalid !== void 0) throw new Error(`SkillCommandOptions.commandMap.${label} contains an invalid entry ${JSON.stringify(invalid)}. Names/aliases must start with an alphanumeric character and contain only alphanumeric characters, hyphens, or underscores.`);
1610
+ return {
1611
+ name: names[0],
1612
+ aliases: names.slice(1)
1613
+ };
1614
+ }
1615
+ function resolveVerbose(value, globalArgs) {
1616
+ return {
1617
+ alias: value?.alias === false ? void 0 : value?.alias ?? DEFAULT_VERBOSE_ALIAS,
1618
+ disabled: hasGlobalField(globalArgs, "verbose")
1619
+ };
1620
+ }
1621
+ /**
1622
+ * Does `globalArgs` (the host's `runMain`/`runCommand` global args schema,
1623
+ * if passed through `SkillCommandOptions.globalArgs`) already declare a
1624
+ * *non-positional boolean* field with this name? Determines whether the
1625
+ * matching built-in local flag (`verbose`/`json`) is omitted — see
1626
+ * {@link SkillCommandOptions.globalArgs}. Positional fields are excluded: a
1627
+ * positional named `verbose`/`json` has no `--verbose`/`--json` flag syntax
1628
+ * at all, so it can't actually collide with one. Non-boolean fields are
1629
+ * excluded too: `mergedFlag` boolean-coerces whatever value flows through,
1630
+ * and a same-named string/number field (e.g. a verbosity level or enum)
1631
+ * isn't really the same flag — coercing it (e.g. `Boolean("off")` is `true`)
1632
+ * would silently misread it, so it's not treated as a collision here and
1633
+ * the local boolean flag stays declared.
1634
+ *
1635
+ * Note: politty itself independently rejects this same situation. When
1636
+ * `globalArgs` and a command's own schema both declare a same-named field
1637
+ * with different definitions (as happens here — global non-boolean vs.
1638
+ * local boolean), `runMain`/`runCommand` throws `FieldTypeConflictError` at
1639
+ * parse time, regardless of what this function decides. This function
1640
+ * keeping the local flag "declared" doesn't make the combination usable —
1641
+ * it just means the failure surfaces as politty's own clear error instead
1642
+ * of a silent boolean-coercion misread. {@link SkillFlagOverrides.verbose}'s
1643
+ * `alias` option can't help either, since it only renames the short alias,
1644
+ * not the conflicting long field name. A host whose `globalArgs` defines a
1645
+ * non-boolean `verbose`/`json` field must rename or remove that global
1646
+ * field (or make it a plain boolean) instead.
1647
+ */
1648
+ function hasGlobalField(globalArgs, name) {
1649
+ if (!globalArgs) return false;
1650
+ return extractFields(globalArgs).fields.some((field) => field.name === name && !field.positional && field.type === "boolean");
1651
+ }
1652
+ function resolveDescriptionAppend(value, cliName) {
1653
+ if (value === false) return false;
1654
+ if (typeof value === "string") return value;
1655
+ return `Manage agent skills with \`${cliName} skills <add|sync|remove|list>\`.`;
1656
+ }
1657
+
1658
+ //#endregion
1659
+ //#region ../core/src/skill/types.ts
1660
+ /**
1661
+ * All kinds of scan failure, as a runtime tuple so callers can exhaustively
1662
+ * iterate (e.g. for message tables). Derived {@link ScanErrorReason} stays
1663
+ * the single source of truth for the type-level enum.
1664
+ */
1665
+ const SCAN_ERROR_REASONS = [
1666
+ "parse-failed",
1667
+ "name-mismatch",
1668
+ "read-failed",
1669
+ "missing-source"
1670
+ ];
1671
+
1672
+ //#endregion
1673
+ //#region ../core/src/skill/index.ts
1674
+ /**
1675
+ * Skill management module for coding agent CLIs.
1676
+ *
1677
+ * Provides source-directory scanning and symlink-based (or copy-based)
1678
+ * installation of SKILL.md-based agent skills, validated against the
1679
+ * Agent Skills specification (https://agentskills.io/specification).
1680
+ *
1681
+ * Provenance of politty-managed installs is recorded under
1682
+ * `metadata["politty-cli"]` as `"{packageName}:{cliName}"`, so
1683
+ * `skills remove` can safely refuse to delete skills that belong to
1684
+ * another tool.
1685
+ *
1686
+ * @example
1687
+ * ```typescript
1688
+ * import { dirname, resolve } from "node:path";
1689
+ * import { fileURLToPath } from "node:url";
1690
+ * import { defineCommand, runMain } from "politty";
1691
+ * import { withSkillCommand } from "politty/skill";
1692
+ *
1693
+ * const sourceDir = resolve(dirname(fileURLToPath(import.meta.url)), "../skills");
1694
+ *
1695
+ * const cli = withSkillCommand(
1696
+ * defineCommand({
1697
+ * name: "my-agent",
1698
+ * description: "My coding agent CLI",
1699
+ * subCommands: {
1700
+ * run: runCommand,
1701
+ * },
1702
+ * }),
1703
+ * { sourceDir, package: "@my-agent/skills" },
1704
+ * );
1705
+ *
1706
+ * runMain(cli);
1707
+ * ```
1708
+ *
1709
+ * SKILL.md format (spec-compliant):
1710
+ * ```markdown
1711
+ * ---
1712
+ * name: commit
1713
+ * description: Git commit message generation
1714
+ * license: MIT
1715
+ * metadata:
1716
+ * politty-cli: "@my-agent/skills:my-agent"
1717
+ * ---
1718
+ * # Instructions for the agent...
1719
+ * ```
1720
+ *
1721
+ * @packageDocumentation
1722
+ */
1723
+ /**
1724
+ * Wrap a command with a `skills` subcommand for managing SKILL.md-based skills.
1725
+ *
1726
+ * Adds `skills sync`, `skills add`, `skills remove`, and `skills list`.
1727
+ * The install materialization is controlled by `options.mode`
1728
+ * (see {@link SkillCommandOptions}):
1729
+ *
1730
+ * - `"symlink"` (default) — symlink the source into place. Source updates
1731
+ * propagate live. Install errors out with guidance to retry with `"copy"`
1732
+ * when `symlinkSync` fails (e.g. Windows without Developer Mode).
1733
+ * - `"copy"` — recursive copy. Source updates require re-running sync.
1734
+ *
1735
+ * Under both modes the canonical slot is `.agents/skills/<name>` and each
1736
+ * agent-specific directory (e.g. `.claude/skills/<name>`) is populated
1737
+ * from that canonical slot. politty never writes to `SKILL.md`. The
1738
+ * ownership stamp `metadata["politty-cli"] = "{package}:{cliName}"` must
1739
+ * be authored by the skill package itself; `add` and `sync` verify it
1740
+ * before installing and `remove` / `sync` consult it before deleting, so
1741
+ * this CLI never clobbers skills another tool installed.
1742
+ *
1743
+ * @throws if `command.subCommands.skills` already exists — silently
1744
+ * overwriting it would hide a configuration bug.
1745
+ */
1746
+ function withSkillCommand(command, options) {
1747
+ if (command.subCommands && Object.hasOwn(command.subCommands, "skills")) throw new Error(`withSkillCommand: command "${command.name}" already defines a "skills" subcommand.`);
1748
+ const resolved = resolveSkillOptions(options, command.name);
1749
+ const addName = resolved.commandNames.add.name;
1750
+ const removeName = resolved.commandNames.remove.name;
1751
+ const allNames = [
1752
+ "sync",
1753
+ "list",
1754
+ addName,
1755
+ ...resolved.commandNames.add.aliases,
1756
+ removeName,
1757
+ ...resolved.commandNames.remove.aliases
1758
+ ];
1759
+ const duplicate = allNames.find((name, i) => allNames.indexOf(name) !== i);
1760
+ if (duplicate) throw new Error(`withSkillCommand: commandMap produced duplicate subcommand name/alias "${duplicate}".`);
1761
+ const skillsSubCommand = defineCommand({
1762
+ name: "skills",
1763
+ description: resolved.descriptions.skills,
1764
+ subCommands: {
1765
+ sync: createSkillSyncCommand(resolved),
1766
+ [addName]: createSkillAddCommand(resolved),
1767
+ [removeName]: createSkillRemoveCommand(resolved),
1768
+ list: createSkillListCommand(resolved)
1769
+ }
1770
+ });
1771
+ return {
1772
+ ...command,
1773
+ description: appendDescription(command.description, resolved.descriptionAppend),
1774
+ subCommands: {
1775
+ ...command.subCommands,
1776
+ skills: skillsSubCommand
1777
+ }
1778
+ };
1779
+ }
1780
+ /**
1781
+ * Append the configured skills hint to the root command's description.
1782
+ *
1783
+ * Returns the original description unchanged when `append` is `false` or
1784
+ * empty. When the existing description already ends with the same hint,
1785
+ * skip the append so re-wrapping (e.g. in tests) does not duplicate it.
1786
+ *
1787
+ * The separator is a blank line so help renderers display the hint as
1788
+ * its own paragraph — a single space would run the hint into the host
1789
+ * description (especially when the description has no trailing period).
1790
+ */
1791
+ function appendDescription(existing, append) {
1792
+ if (append === false || append === "") return existing;
1793
+ if (!existing) return append;
1794
+ if (existing.endsWith(append)) return existing;
1795
+ return `${existing}\n\n${append}`;
1796
+ }
1797
+
1798
+ //#endregion
1799
+ //#region src/skill-frontmatter-schema.ts
1800
+ /**
1801
+ * Zod schema for SKILL.md frontmatter.
1802
+ *
1803
+ * @deprecated Kept as a public export of `politty/skill` for backwards
1804
+ * compatibility. politty itself no longer validates frontmatter through
1805
+ * this schema — see `validateSkillFrontmatter` in `frontmatter.ts`, which
1806
+ * implements the same Agent Skills specification rules without a runtime
1807
+ * zod dependency. Keep the two in sync when the spec changes.
1808
+ *
1809
+ * Strictly validates the fields defined in the Agent Skills specification
1810
+ * (https://agentskills.io/specification). Unknown fields are preserved via
1811
+ * `.passthrough()` so spec extensions and vendor keys round-trip intact.
1812
+ *
1813
+ * Provenance / ownership for politty-managed installs is recorded under
1814
+ * `metadata["politty-cli"]` as `"{packageName}:{cliName}"`.
1815
+ */
1816
+ const skillFrontmatterSchema = z.object({
1817
+ /** Skill identifier. Lowercase alphanumerics + hyphens, 1..64 chars. */
1818
+ name: z.string().min(1).max(64).regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, { message: "name must be lowercase alphanumerics separated by single hyphens" }),
1819
+ /** Human-readable description (1..1024 chars). */
1820
+ description: z.string().min(1).max(1024),
1821
+ /** SPDX license identifier or free-form string. */
1822
+ license: z.string().min(1).optional(),
1823
+ /** Runtime / tool compatibility string (<=500 chars). */
1824
+ compatibility: z.string().max(500).optional(),
1825
+ /** Metadata map (spec: string keys, string values). */
1826
+ metadata: z.record(z.string(), z.string()).optional(),
1827
+ /** Experimental spec field. */
1828
+ "allowed-tools": z.string().optional()
1829
+ }).passthrough();
1830
+
1831
+ //#endregion
1832
+ export { OWNERSHIP_METADATA_KEY, SCAN_ERROR_REASONS, hasInstalledSkill, installSkill, parseFrontmatter, parseSkillMd, readInstalledOwnership, scanSourceDir, skillFrontmatterSchema, uninstallSkill, withSkillCommand };