politty 0.9.0 → 0.9.2

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