@politty/zod 0.1.1 → 0.2.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 (59) hide show
  1. package/README.md +31 -0
  2. package/dist/{arg-registry-B8QvBM_Q.d.ts → arg-registry-Be4GJADw.d.ts} +15 -1
  3. package/dist/augment.d.ts +1 -1
  4. package/dist/augment.js +1 -1
  5. package/dist/cli-main-C8D_mKfv.js +2 -0
  6. package/dist/cli-main-ClQaGXB3.js +1 -0
  7. package/dist/cli-run-BFXIag27.js +1 -0
  8. package/dist/cli.js +1 -15
  9. package/dist/command-Mbdt0bmN.js +1 -0
  10. package/dist/compile-cache-VuUbFWIo.js +1 -0
  11. package/dist/compile-cache.js +1 -3
  12. package/dist/completion-D8DMrPqg.js +116 -0
  13. package/dist/completion-DUMcvXkT.js +1 -0
  14. package/dist/completion.d.ts +87 -7
  15. package/dist/completion.js +1 -4
  16. package/dist/docs.d.ts +1 -1
  17. package/dist/docs.js +84 -3045
  18. package/dist/dynamic-CMtee4tD.js +1 -0
  19. package/dist/dynamic-CrnlveHj.js +6 -0
  20. package/dist/field-meta-COGya7xp.js +1 -0
  21. package/dist/index.d.ts +20 -16
  22. package/dist/index.js +1 -27
  23. package/dist/log-collector-hiFa3sNm.js +1 -0
  24. package/dist/logger-CJsyJ8sb.js +1 -0
  25. package/dist/prompt-CqmGq1_N.js +1 -0
  26. package/dist/prompt-clack.d.ts +1 -1
  27. package/dist/prompt-clack.js +1 -32
  28. package/dist/prompt-inquirer.d.ts +1 -1
  29. package/dist/prompt-inquirer.js +1 -47
  30. package/dist/prompt.d.ts +1 -1
  31. package/dist/prompt.js +1 -4
  32. package/dist/register-C1WbbeYH.js +1 -0
  33. package/dist/runner-C_wCXh6X.js +1 -0
  34. package/dist/runner-uPmkI9Gb.js +29 -0
  35. package/dist/schema-BiUP_KyV.js +1 -0
  36. package/dist/schema-extractor-DU0Vuhbd.js +1 -0
  37. package/dist/skill.d.ts +1 -1
  38. package/dist/skill.js +2 -1835
  39. package/dist/subcommand-router-D8GTMXYL.js +1 -0
  40. package/dist/with-completion-command-DDohm8U6.js +3 -0
  41. package/dist/{index-CMb2xLiJ.d.ts → with-completion-command-xaVZtAga.d.ts} +3 -74
  42. package/package.json +3 -3
  43. package/dist/cli-main-BQfutJEX.js +0 -3
  44. package/dist/cli-main-D4kkrRfX.js +0 -314
  45. package/dist/cli-run-wxd6kPPf.js +0 -8
  46. package/dist/command-k-4yAz4J.js +0 -42
  47. package/dist/compile-cache-BC65o7MH.js +0 -103
  48. package/dist/completion-DW5qVc3l.js +0 -5613
  49. package/dist/field-meta-DMy5BcRr.js +0 -146
  50. package/dist/log-collector-CoUkLVJB.js +0 -114
  51. package/dist/logger-i_bb-Jhc.js +0 -133
  52. package/dist/prompt-BjIZThsH.js +0 -169
  53. package/dist/register-DlGnMLIY.js +0 -440
  54. package/dist/runner-DAGvxV2P.js +0 -2980
  55. package/dist/runner-MMjpKeEa.js +0 -3
  56. package/dist/schema-extractor-DMSozq40.js +0 -250
  57. package/dist/src-CLogLsbS.js +0 -12
  58. package/dist/src-bYH2XCQ4.js +0 -6
  59. package/dist/subcommand-router-Cskpofdk.js +0 -134
package/dist/skill.js CHANGED
@@ -1,1835 +1,2 @@
1
- import "./register-DlGnMLIY.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
- const resolvedCanonicalParent = realpathSync(resolve(canonicalDir, ".."));
716
- symlinkOrCopy({
717
- linkTarget: join(relative(resolvedTargetParent, resolvedCanonicalParent), name),
718
- linkPath: targetDir,
719
- copyFrom: canonicalDir,
720
- mode
721
- });
722
- }
723
- }
724
- function isNodeError$1(err) {
725
- return err instanceof Error && typeof err.code === "string";
726
- }
727
- /**
728
- * Do `a` and `b` refer to the same directory, or is one nested inside the
729
- * other? Used to refuse copy-mode installs where the source and destination
730
- * would recurse into each other. Inputs are expected to be `realpathSync`'d
731
- * absolute paths so trailing separators and symlink hops don't desynchronise
732
- * the comparison.
733
- *
734
- * Containment is boundary-aware: only `..` or `..<sep>...` counts as escaping
735
- * `outer`. A relative path like `..backup` is a same-level sibling (one
736
- * segment whose name happens to start with two dots), so it must NOT be
737
- * treated as escape. The previous `startsWith("..")` check misclassified such
738
- * names as outside, missing real overlaps with siblings whose name begins
739
- * with `..`.
740
- */
741
- function pathsOverlap(a, b) {
742
- if (a === b) return true;
743
- const isContainedIn = (inner, outer) => {
744
- const rel = relative(outer, inner);
745
- if (rel === "" || rel === ".") return true;
746
- if (isAbsolute(rel)) return false;
747
- return rel !== ".." && !rel.startsWith(`..${sep}`);
748
- };
749
- return isContainedIn(a, b) || isContainedIn(b, a);
750
- }
751
-
752
- //#endregion
753
- //#region ../core/src/skill/scanner.ts
754
- const SKILL_MD = "SKILL.md";
755
- /**
756
- * Scan a source directory for SKILL.md files.
757
- *
758
- * Each immediate subdirectory is a candidate skill; its `SKILL.md` is
759
- * parsed and validated against the Agent Skills specification, and the
760
- * frontmatter `name` must match the subdirectory name (spec requirement).
761
- *
762
- * If `sourceDir` itself contains a `SKILL.md`, it is treated as a
763
- * single-skill source. The parent-directory-name match is not enforced in
764
- * that case because the caller chose an arbitrary path.
765
- *
766
- * Symlinks within the source tree are followed (symlinked skill dirs and
767
- * symlinked SKILL.md files are both accepted). npm packages already
768
- * execute arbitrary JS on install, so additional symlink-based isolation
769
- * here would not raise the trust boundary in any realistic threat model.
770
- *
771
- * @example
772
- * ```
773
- * sourceDir: "node_modules/@my-agent/skills/skills"
774
- *
775
- * node_modules/@my-agent/skills/skills/
776
- * ├── commit/
777
- * │ └── SKILL.md
778
- * └── review-pr/
779
- * └── SKILL.md
780
- * ```
781
- */
782
- function scanSourceDir(sourceDir) {
783
- const skills = [];
784
- const errors = [];
785
- try {
786
- let sourceStat;
787
- try {
788
- sourceStat = statSync(sourceDir);
789
- } catch (error) {
790
- if (isNodeError(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) errors.push({
791
- path: sourceDir,
792
- reason: "missing-source",
793
- message: `Source directory does not exist: ${sourceDir}`
794
- });
795
- else errors.push({
796
- path: sourceDir,
797
- reason: "read-failed",
798
- message: `Failed to stat source directory ${sourceDir}: ${errorMessage$1(error)}`
799
- });
800
- return {
801
- skills,
802
- errors
803
- };
804
- }
805
- if (!sourceStat.isDirectory()) {
806
- errors.push({
807
- path: sourceDir,
808
- reason: "missing-source",
809
- message: `Source path is not a directory: ${sourceDir}`
810
- });
811
- return {
812
- skills,
813
- errors
814
- };
815
- }
816
- const rootSkillMdPath = join(sourceDir, SKILL_MD);
817
- const rootCheck = skillMdPresent(rootSkillMdPath);
818
- if (rootCheck.kind === "error") {
819
- errors.push({
820
- path: sourceDir,
821
- reason: "read-failed",
822
- message: `Failed to check ${rootSkillMdPath}: ${rootCheck.message}`
823
- });
824
- return {
825
- skills,
826
- errors
827
- };
828
- }
829
- if (rootCheck.kind === "present") {
830
- pushResult(tryParseSkillDir(sourceDir, { enforceParentMatch: false }), skills, errors);
831
- return {
832
- skills,
833
- errors
834
- };
835
- }
836
- const entries = readdirSync(sourceDir, { withFileTypes: true });
837
- for (const entry of entries) {
838
- const skillDir = join(sourceDir, entry.name);
839
- let isDir;
840
- try {
841
- isDir = statSync(skillDir).isDirectory();
842
- } catch (error) {
843
- if (isNodeError(error) && error.code === "ENOENT" && !entry.isSymbolicLink()) continue;
844
- errors.push({
845
- path: skillDir,
846
- reason: "read-failed",
847
- message: entry.isSymbolicLink() ? `Dangling symlink at ${skillDir}: ${errorMessage$1(error)}` : `Failed to stat ${skillDir}: ${errorMessage$1(error)}`
848
- });
849
- continue;
850
- }
851
- if (!isDir) continue;
852
- const skillMdPath = join(skillDir, SKILL_MD);
853
- const childCheck = skillMdPresent(skillMdPath);
854
- if (childCheck.kind === "error") {
855
- errors.push({
856
- path: skillDir,
857
- reason: "read-failed",
858
- message: `Failed to check ${skillMdPath}: ${childCheck.message}`
859
- });
860
- continue;
861
- }
862
- if (childCheck.kind === "absent") continue;
863
- pushResult(tryParseSkillDir(skillDir, { enforceParentMatch: true }), skills, errors);
864
- }
865
- } catch (error) {
866
- errors.push({
867
- path: sourceDir,
868
- reason: "read-failed",
869
- message: `Failed to scan ${sourceDir}: ${errorMessage$1(error)}`
870
- });
871
- }
872
- skills.sort((a, b) => a.frontmatter.name < b.frontmatter.name ? -1 : a.frontmatter.name > b.frontmatter.name ? 1 : 0);
873
- return {
874
- skills,
875
- errors
876
- };
877
- }
878
- function tryParseSkillDir(dir, opts) {
879
- const skillMdPath = join(dir, SKILL_MD);
880
- let content;
881
- try {
882
- content = readFileSync(skillMdPath, "utf-8");
883
- } catch (error) {
884
- return {
885
- path: dir,
886
- reason: "read-failed",
887
- message: `Failed to read ${skillMdPath}: ${errorMessage$1(error)}`
888
- };
889
- }
890
- const { data, parseError } = parseFrontmatter(content);
891
- if (parseError !== void 0) return {
892
- path: dir,
893
- reason: "parse-failed",
894
- message: `Invalid SKILL.md frontmatter in ${dir}: YAML parse error: ${parseError}`
895
- };
896
- const result = validateSkillFrontmatter(data);
897
- if (!result.success) return {
898
- path: dir,
899
- reason: "parse-failed",
900
- message: `Invalid SKILL.md frontmatter in ${dir}: ${result.issues.map((issue) => `${issue.path.join(".") || "<root>"}: ${issue.message}`).join("; ")}`
901
- };
902
- if (opts.enforceParentMatch) {
903
- const parent = basename(dir);
904
- if (parent !== result.data.name) return {
905
- path: dir,
906
- reason: "name-mismatch",
907
- message: `Skill name "${result.data.name}" does not match directory "${parent}"`,
908
- skillName: result.data.name
909
- };
910
- }
911
- return {
912
- frontmatter: result.data,
913
- sourcePath: dir,
914
- rawContent: content
915
- };
916
- }
917
- function pushResult(value, skills, errors) {
918
- if ("frontmatter" in value) skills.push(value);
919
- else errors.push(value);
920
- }
921
- function errorMessage$1(error) {
922
- return error instanceof Error ? error.message : String(error);
923
- }
924
- function isNodeError(error) {
925
- return error instanceof Error && typeof error.code === "string";
926
- }
927
- function skillMdPresent(path) {
928
- try {
929
- lstatSync(path);
930
- return { kind: "present" };
931
- } catch (error) {
932
- if (isNodeError(error) && error.code === "ENOENT") return { kind: "absent" };
933
- return {
934
- kind: "error",
935
- message: errorMessage$1(error)
936
- };
937
- }
938
- }
939
-
940
- //#endregion
941
- //#region ../core/src/skill/commands.ts
942
- /**
943
- * Stream scan errors. Per-error `logger.warn` writes to stderr (so a
944
- * malformed source SKILL.md is always loud), and trailing `logger.info`
945
- * summary lines echo to stdout — important for pipelines that consume
946
- * only stdout from the CLI. Directory-level failures (`missing-source`,
947
- * or per-entry failures on the source directory itself) get their own
948
- * stdout summary so a stdout-only consumer can tell apart "scan failed"
949
- * from a legitimate empty bundle.
950
- *
951
- * `silentStdout` suppresses only the stdout summary lines (per-error
952
- * stderr warnings still fire). Used by `skills list --json` so the
953
- * machine-readable JSON output on stdout stays parseable.
954
- */
955
- function logScanErrors(errors, opts = {}) {
956
- let skipped = 0;
957
- let directoryFailed = false;
958
- for (const err of errors) {
959
- if (err.reason === "missing-source" || err.path === opts.sourceDir) {
960
- directoryFailed = true;
961
- logger.warn(`Failed to scan source directory ${err.path}: ${err.message}`);
962
- continue;
963
- }
964
- skipped += 1;
965
- logger.warn(`Skipping skill at ${err.path}: ${err.message}`);
966
- }
967
- if (opts.silentStdout) return;
968
- if (skipped > 0) logger.info(`${symbols.warning} Skipped ${skipped} skill(s) due to scan errors (see warnings above).`);
969
- if (directoryFailed) logger.info(`${symbols.warning} Source directory scan failed (see warnings above); subsequent operations may be skipped.`);
970
- }
971
- /**
972
- * Did the scan fail authoritatively at the directory level? Used by
973
- * commands to distinguish "legitimately empty source" from "scan
974
- * couldn't enumerate the source", so success-path summaries
975
- * ("No skills found", "no skills bundled") don't mask a config error.
976
- */
977
- function scanFailedAtRoot(result, sourceDir) {
978
- const directoryScanFailed = result.errors.some((e) => e.reason === "missing-source" || e.path === sourceDir);
979
- const allSkillsInvalid = result.errors.length > 0 && result.skills.length === 0;
980
- return directoryScanFailed || allSkillsInvalid;
981
- }
982
- function loadSkills(options, logOpts = {}) {
983
- const result = scanSourceDir(options.sourceDir);
984
- logScanErrors(result.errors, {
985
- ...logOpts,
986
- sourceDir: options.sourceDir
987
- });
988
- return result;
989
- }
990
- /**
991
- * Build the metadata for `--exclude` honouring the configured alias.
992
- * `undefined` alias means `arg()` is called without an alias key.
993
- */
994
- function excludeArgMeta(options) {
995
- const meta = { description: "Skill names to exclude from sync" };
996
- if (options.excludeAlias !== void 0) meta.alias = options.excludeAlias;
997
- return meta;
998
- }
999
- /**
1000
- * Build the metadata for `--verbose` (shared by `add`/`sync`) honouring the
1001
- * configured alias. `undefined` alias means `arg()` is called without an
1002
- * alias key. Only called when `options.verbose.disabled` is `false`.
1003
- */
1004
- function verboseArgMeta(options) {
1005
- const meta = { description: "Print install paths and modes" };
1006
- if (options.verbose.alias !== void 0) meta.alias = options.verbose.alias;
1007
- return meta;
1008
- }
1009
- /**
1010
- * Read a same-named boolean out of a leaf command's (already-merged) args
1011
- * object. When a built-in flag is omitted because `SkillCommandOptions.
1012
- * globalArgs` already declares a same-named field (see
1013
- * `ResolvedSkillOptions.verbose`/`.json`), the local schema no longer
1014
- * declares it, but politty's runner still merges the host's
1015
- * `globalArgs`-parsed values into every leaf command's `args` regardless of
1016
- * the leaf's own schema shape. Reading it here (rather than hardcoding
1017
- * `false`) means the omission hands control to that global flag instead of
1018
- * just turning the feature off.
1019
- */
1020
- function mergedFlag(args, name) {
1021
- return Boolean(args[name]);
1022
- }
1023
- /**
1024
- * Create the `skills sync` subcommand.
1025
- *
1026
- * Removes and reinstalls all skills discovered in sourceDir. Skills owned
1027
- * by this CLI that are no longer present in sourceDir are also removed so
1028
- * stale skills do not linger after the CLI drops them from its bundle.
1029
- */
1030
- function createSkillSyncCommand(resolved) {
1031
- function runSync(args, verbose) {
1032
- const { skills: allSkills, errors } = loadSkills(resolved);
1033
- const stamp = resolved.stamp;
1034
- const sourceNamesAll = new Set(allSkills.map((s) => s.frontmatter.name));
1035
- const ownedInstalled = new Set(findOwnedInstalledSkills(stamp, resolved.cwd, resolved.sourceDir));
1036
- const unknownExclude = Array.from(new Set(args.exclude)).filter((n) => !sourceNamesAll.has(n) && !ownedInstalled.has(n));
1037
- if (unknownExclude.length > 0) {
1038
- const subject = unknownExclude.length === 1 ? "Skill" : "Skills";
1039
- const quoted = unknownExclude.map((n) => JSON.stringify(n)).join(", ");
1040
- throw new Error(`--exclude: ${subject} ${quoted} not found in source directory or among installed skills.\n` + formatSkillUniverse({
1041
- source: allSkills,
1042
- installed: ownedInstalled
1043
- }));
1044
- }
1045
- const excluded = new Set(args.exclude);
1046
- const skills = allSkills.filter((s) => !excluded.has(s.frontmatter.name));
1047
- const rootScanFailed = scanFailedAtRoot({
1048
- skills: allSkills,
1049
- errors
1050
- }, resolved.sourceDir);
1051
- let removed = 0;
1052
- if (!rootScanFailed) {
1053
- const sourceNames = new Set(skills.map((s) => s.frontmatter.name));
1054
- const erroredSlotNames = /* @__PURE__ */ new Set();
1055
- for (const err of errors) {
1056
- if (err.path === resolved.sourceDir) continue;
1057
- erroredSlotNames.add(basename(err.path));
1058
- if (err.skillName !== void 0) erroredSlotNames.add(err.skillName);
1059
- }
1060
- for (const orphan of ownedInstalled) {
1061
- if (sourceNames.has(orphan) || excluded.has(orphan) || erroredSlotNames.has(orphan)) continue;
1062
- removeOwnedSkill(orphan, stamp, resolved.cwd, resolved.sourceDir);
1063
- removed += 1;
1064
- }
1065
- }
1066
- let installed = 0;
1067
- for (const skill of skills) {
1068
- addSkill(skill, stamp, resolved, verbose);
1069
- installed += 1;
1070
- }
1071
- if (installed === 0 && removed === 0) {
1072
- const reason = rootScanFailed ? "source directory scan failed; see warnings" : allSkills.length > 0 && skills.length === 0 ? "all skills excluded" : "no skills bundled";
1073
- logger.info(`No skills installed (${reason}).`);
1074
- } else logger.info(`Sync complete: ${installed} installed, ${removed} removed.`);
1075
- }
1076
- if (resolved.verbose.disabled) return defineCommand({
1077
- name: "sync",
1078
- description: resolved.descriptions.sync,
1079
- args: internalArgs({ exclude: internalField.stringArray(excludeArgMeta(resolved)) }, { unknownKeys: resolved.unknownKeys }),
1080
- run(args) {
1081
- runSync(args, mergedFlag(args, "verbose"));
1082
- }
1083
- });
1084
- return defineCommand({
1085
- name: "sync",
1086
- description: resolved.descriptions.sync,
1087
- args: internalArgs({
1088
- exclude: internalField.stringArray(excludeArgMeta(resolved)),
1089
- verbose: internalField.boolean(verboseArgMeta(resolved))
1090
- }, { unknownKeys: resolved.unknownKeys }),
1091
- run(args) {
1092
- runSync(args, args.verbose);
1093
- }
1094
- });
1095
- }
1096
- /**
1097
- * Create the `skills add` subcommand.
1098
- *
1099
- * Installs skills from sourceDir. Accepts zero or more skill names; with no
1100
- * names, installs every skill in source. With one or more names, every name
1101
- * is validated against sourceSkills up-front so a typo never silently
1102
- * proceeds with the valid neighbours — a single unknown name aborts the run
1103
- * and lists every unknown name at once. Duplicates are deduplicated.
1104
- */
1105
- function createSkillAddCommand(resolved) {
1106
- const nameArgMeta = {
1107
- positional: true,
1108
- description: "Skill name(s) to install (default: all)",
1109
- placeholder: "NAME"
1110
- };
1111
- function runAdd(args, verbose) {
1112
- const scanResult = loadSkills(resolved);
1113
- const sourceSkills = scanResult.skills;
1114
- const stamp = resolved.stamp;
1115
- if (args.name.length > 0) {
1116
- const known = new Set(sourceSkills.map((s) => s.frontmatter.name));
1117
- const requested = Array.from(new Set(args.name));
1118
- const unknown = requested.filter((n) => !known.has(n));
1119
- if (unknown.length > 0) {
1120
- const subject = unknown.length === 1 ? "Skill" : "Skills";
1121
- const quoted = unknown.map((n) => JSON.stringify(n)).join(", ");
1122
- throw new Error(`${subject} ${quoted} not found in source directory.\n` + formatSkillUniverse({ source: sourceSkills }));
1123
- }
1124
- const wanted = new Set(requested);
1125
- for (const skill of sourceSkills) if (wanted.has(skill.frontmatter.name)) addSkill(skill, stamp, resolved, verbose);
1126
- return;
1127
- }
1128
- if (sourceSkills.length === 0) {
1129
- if (scanFailedAtRoot(scanResult, resolved.sourceDir)) logger.info("No skills installed (source directory scan failed; see warnings).");
1130
- else logger.info("No skills found in source directory.");
1131
- return;
1132
- }
1133
- for (const skill of sourceSkills) addSkill(skill, stamp, resolved, verbose);
1134
- }
1135
- if (resolved.verbose.disabled) return defineCommand({
1136
- name: resolved.commandNames.add.name,
1137
- ...resolved.commandNames.add.aliases.length > 0 ? { aliases: resolved.commandNames.add.aliases } : {},
1138
- description: resolved.descriptions.add,
1139
- args: internalArgs({ name: internalField.stringArray(nameArgMeta) }, { unknownKeys: resolved.unknownKeys }),
1140
- run(args) {
1141
- runAdd(args, mergedFlag(args, "verbose"));
1142
- }
1143
- });
1144
- return defineCommand({
1145
- name: resolved.commandNames.add.name,
1146
- ...resolved.commandNames.add.aliases.length > 0 ? { aliases: resolved.commandNames.add.aliases } : {},
1147
- description: resolved.descriptions.add,
1148
- args: internalArgs({
1149
- name: internalField.stringArray(nameArgMeta),
1150
- verbose: internalField.boolean(verboseArgMeta(resolved))
1151
- }, { unknownKeys: resolved.unknownKeys }),
1152
- run(args) {
1153
- runAdd(args, args.verbose);
1154
- }
1155
- });
1156
- }
1157
- /**
1158
- * Create the `skills remove` subcommand.
1159
- *
1160
- * Removes installed skills. Defaults to all skills discovered in sourceDir
1161
- * if no name is given. Only skills stamped with this CLI's ownership
1162
- * (`metadata["politty-cli"] === "{package}:{cli}"`) are removed — skills
1163
- * another tool installed are left untouched.
1164
- */
1165
- function createSkillRemoveCommand(resolved) {
1166
- return defineCommand({
1167
- name: resolved.commandNames.remove.name,
1168
- ...resolved.commandNames.remove.aliases.length > 0 ? { aliases: resolved.commandNames.remove.aliases } : {},
1169
- description: resolved.descriptions.remove,
1170
- args: internalArgs({ name: internalField.optionalString({
1171
- positional: true,
1172
- description: "Skill name to remove (default: all)",
1173
- placeholder: "NAME"
1174
- }) }, { unknownKeys: resolved.unknownKeys }),
1175
- run(args) {
1176
- const scanResult = loadSkills(resolved);
1177
- const sourceSkills = scanResult.skills;
1178
- const stamp = resolved.stamp;
1179
- if (args.name) {
1180
- if (sourceSkills.some((s) => s.frontmatter.name === args.name)) findOrThrow(sourceSkills, args.name);
1181
- 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.`);
1182
- else {
1183
- const installed = new Set(findOwnedInstalledSkills(stamp, resolved.cwd, resolved.sourceDir));
1184
- logger.info(`${args.name} is not installed; nothing to remove.\n` + formatSkillUniverse({ installed }));
1185
- }
1186
- return;
1187
- }
1188
- if (sourceSkills.length === 0) {
1189
- if (scanFailedAtRoot(scanResult, resolved.sourceDir)) logger.info("No skills found (source directory scan failed; see warnings); nothing to remove.");
1190
- else logger.info("No skills found in source directory; nothing to remove.");
1191
- return;
1192
- }
1193
- let removed = 0;
1194
- for (const skill of sourceSkills) if (removeOwnedSkill(skill.frontmatter.name, stamp, resolved.cwd, resolved.sourceDir)) removed += 1;
1195
- if (removed === 0) logger.info("No installed skills owned by this CLI; nothing to remove.");
1196
- }
1197
- });
1198
- }
1199
- function listStatus(name, expectedOwnership, cwd, sourceDir) {
1200
- let owner;
1201
- try {
1202
- owner = readInstalledOwnership(name, cwd);
1203
- } catch (error) {
1204
- logger.warn(`Failed to read ownership for installed skill ${name}: ${errorMessage(error)}`);
1205
- return "unreadable";
1206
- }
1207
- if (owner === expectedOwnership) return "installed";
1208
- if (owner !== null) return "foreign";
1209
- if (!hasInstalledSkill(name, cwd)) {
1210
- const canonical = resolve(cwd, AGENTS_SKILLS_DIR, name);
1211
- if (isDanglingSymlink(canonical) && danglingRoutesToSource(canonical, sourceDir)) return "missing";
1212
- return slotPresent(name, cwd) ? "unstamped" : "not-installed";
1213
- }
1214
- return "unstamped";
1215
- }
1216
- function errorMessage(error) {
1217
- return error instanceof Error ? error.message : String(error);
1218
- }
1219
- function slotPresent(name, cwd) {
1220
- try {
1221
- lstatSync(resolve(cwd, AGENTS_SKILLS_DIR, name));
1222
- return true;
1223
- } catch {
1224
- return false;
1225
- }
1226
- }
1227
- /**
1228
- * Create the `skills list` subcommand.
1229
- *
1230
- * Lists available skills from the source directory.
1231
- */
1232
- function createSkillListCommand(resolved) {
1233
- function runList(json) {
1234
- const scanResult = loadSkills(resolved, { silentStdout: json });
1235
- const sourceSkills = scanResult.skills;
1236
- const stamp = resolved.stamp;
1237
- if (json) {
1238
- console.log(JSON.stringify(sourceSkills.map((s) => ({
1239
- name: s.frontmatter.name,
1240
- description: s.frontmatter.description,
1241
- owner: s.frontmatter.metadata?.["politty-cli"] ?? null,
1242
- expectedOwner: stamp,
1243
- status: listStatus(s.frontmatter.name, stamp, resolved.cwd, resolved.sourceDir),
1244
- sourcePath: s.sourcePath
1245
- }))));
1246
- return;
1247
- }
1248
- if (sourceSkills.length === 0) {
1249
- if (scanFailedAtRoot(scanResult, resolved.sourceDir)) logger.info("Source directory scan failed; see warnings.");
1250
- else logger.info("No skills found in source directory.");
1251
- return;
1252
- }
1253
- logger.info("Available skills:");
1254
- for (const skill of sourceSkills) {
1255
- const status = listStatus(skill.frontmatter.name, stamp, resolved.cwd, resolved.sourceDir);
1256
- logger.info(` ${skill.frontmatter.name.padEnd(20)} ${status.padEnd(14)} ${skill.frontmatter.description}`);
1257
- }
1258
- }
1259
- if (resolved.json.disabled) return defineCommand({
1260
- name: "list",
1261
- description: resolved.descriptions.list,
1262
- args: internalArgs({}, { unknownKeys: resolved.unknownKeys }),
1263
- run(args) {
1264
- runList(mergedFlag(args, "json"));
1265
- }
1266
- });
1267
- return defineCommand({
1268
- name: "list",
1269
- description: resolved.descriptions.list,
1270
- args: internalArgs({ json: internalField.boolean({ description: "Output as JSON" }) }, { unknownKeys: resolved.unknownKeys }),
1271
- run(args) {
1272
- runList(args.json);
1273
- }
1274
- });
1275
- }
1276
- function findOrThrow(skills, name) {
1277
- const skill = skills.find((s) => s.frontmatter.name === name);
1278
- if (!skill) {
1279
- const available = skills.map((s) => s.frontmatter.name).join(", ") || "<none>";
1280
- throw new Error(`Skill "${name}" not found in source directory. Available: ${available}`);
1281
- }
1282
- return skill;
1283
- }
1284
- /**
1285
- * Render skill-name lists for typo-error diagnostics. Each command lists
1286
- * only the universe its argument actually accepts so the suggestions
1287
- * match what the user can legitimately retype:
1288
- * - `add` — source only.
1289
- * - `remove` — installed only.
1290
- * - `sync --exclude` — both (a source skill skips its install, an
1291
- * installed-owned orphan is preserved from removal).
1292
- * Empty sections render as `<none>` so the user can tell apart "I don't
1293
- * know about any" from "the message forgot a section".
1294
- */
1295
- function formatSkillUniverse(opts) {
1296
- const parts = [];
1297
- if (opts.source !== void 0) parts.push(` Source: ${opts.source.map((s) => s.frontmatter.name).join(", ") || "<none>"}`);
1298
- if (opts.installed !== void 0) parts.push(` Installed: ${[...opts.installed].sort().join(", ") || "<none>"}`);
1299
- return parts.join("\n");
1300
- }
1301
- function addSkill(skill, expectedOwnership, resolved, verbose) {
1302
- const name = skill.frontmatter.name;
1303
- const cwd = resolved.cwd;
1304
- const mode = resolved.mode;
1305
- const sourceOwnership = skill.frontmatter.metadata?.["politty-cli"] ?? null;
1306
- 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)}.`);
1307
- const actual = readInstalledOwnership(name, cwd);
1308
- 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.`);
1309
- const canonical = resolve(cwd, AGENTS_SKILLS_DIR, name);
1310
- const danglingOurs = isDanglingSymlink(canonical) && danglingRoutesToSource(canonical, resolved.sourceDir);
1311
- 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".`);
1312
- installSkill(skill, cwd, mode === void 0 ? {} : { mode });
1313
- logger.info(`${symbols.success} Installed ${name}`);
1314
- if (verbose) {
1315
- const effectiveMode = mode ?? "symlink";
1316
- logger.info(` mode=${effectiveMode} path=${canonical}`);
1317
- }
1318
- }
1319
- /**
1320
- * Remove a skill only if it belongs to this CLI (ownership stamp matches
1321
- * `{package}:{cli}`). Returns `true` when something was actually removed,
1322
- * `false` when the skill was not installed (allowing callers to surface a
1323
- * "nothing to remove" message).
1324
- *
1325
- * A broken canonical symlink (`.agents/skills/<name>` exists as a symlink
1326
- * but its target does not) is also cleaned up here, even though
1327
- * `readInstalledOwnership` returns `null` in that case — the slot is in
1328
- * this CLI's namespace and unlinking a dangling symlink can never delete
1329
- * user data. This matches the `status: "missing"` listed by `skills list`.
1330
- *
1331
- * Throws when the skill exists but is owned by someone else — callers
1332
- * like `sync` that iterate silently would otherwise clobber user data.
1333
- */
1334
- function removeOwnedSkill(name, expectedOwnership, cwd, sourceDir) {
1335
- const actual = readInstalledOwnership(name, cwd);
1336
- if (actual === null) {
1337
- if (cleanupBrokenSlot(name, cwd, sourceDir)) {
1338
- logger.info(`${symbols.success} Removed ${name} (broken symlink)`);
1339
- return true;
1340
- }
1341
- return false;
1342
- }
1343
- 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.`);
1344
- uninstallSkill(name, cwd, { expectedOwnership });
1345
- logger.info(`${symbols.success} Removed ${name}`);
1346
- return true;
1347
- }
1348
- /**
1349
- * If `.agents/skills/<name>` is a dangling symlink that still routes to
1350
- * this CLI's source directory, unlink it (and any agent-specific
1351
- * dangling-symlink slots that route through it). Returns `true` when the
1352
- * canonical slot was cleaned.
1353
- *
1354
- * A dangling canonical whose target lies outside our source directory
1355
- * (e.g. a foreign politty-based CLI's stale install in the shared
1356
- * `.agents/skills/` namespace) is left alone — without an ownership
1357
- * stamp to read, we can't prove the slot belongs to this CLI. Live
1358
- * symlinks (target still resolves) go through the normal stamp-checked
1359
- * path.
1360
- */
1361
- function cleanupBrokenSlot(name, cwd, sourceDir) {
1362
- const canonical = resolve(cwd, AGENTS_SKILLS_DIR, name);
1363
- if (!isDanglingSymlink(canonical)) return false;
1364
- if (!danglingRoutesToSource(canonical, sourceDir)) return false;
1365
- for (const target of SYMLINK_TARGETS) {
1366
- const agentSlot = resolve(cwd, target, name);
1367
- if (!isDanglingSymlink(agentSlot)) continue;
1368
- if (symlinkRoutesTo(agentSlot, canonical)) unlinkSync(agentSlot);
1369
- }
1370
- unlinkSync(canonical);
1371
- return true;
1372
- }
1373
- /**
1374
- * Does the dangling symlink at `canonical` still route into `sourceDir`?
1375
- * Used to confirm a stale `.agents/skills/<name>` belongs to this CLI
1376
- * before we unlink it in the shared namespace.
1377
- *
1378
- * The link target is resolved lexically (the path is dangling so
1379
- * `realpathSync` on it would fail) against the symlink's own directory.
1380
- * `sourceDir` is resolved through `resolveDeepestExisting` so the
1381
- * comparison survives realpath remapping (macOS `/tmp` →
1382
- * `/private/tmp`, a project mounted through a symlink, etc) *and* the
1383
- * documented case where the configured source path itself no longer
1384
- * exists (the source package was uninstalled — exactly the scenario
1385
- * `status: "missing"` is meant to surface). Containment uses the same
1386
- * boundary-aware `..`-only escape check as `installer.ts`'s
1387
- * `pathsOverlap` so a sibling directory whose name happens to start
1388
- * with `..` is not misclassified as outside.
1389
- */
1390
- function danglingRoutesToSource(canonical, sourceDir) {
1391
- let raw;
1392
- try {
1393
- raw = readlinkSync(canonical);
1394
- } catch {
1395
- return false;
1396
- }
1397
- const absoluteTarget = isAbsolute(raw) ? raw : resolve(dirname(canonical), raw);
1398
- const resolvedSource = resolveDeepestExisting(resolve(sourceDir));
1399
- const resolvedTarget = resolveDeepestExisting(absoluteTarget);
1400
- const rel = relative(resolvedSource, resolvedTarget);
1401
- if (isAbsolute(rel)) return false;
1402
- if (rel === ".." || rel.startsWith(`..${sep}`)) return false;
1403
- return true;
1404
- }
1405
- function resolveDeepestExisting(p) {
1406
- let cur = p;
1407
- const tail = [];
1408
- while (true) try {
1409
- const r = realpathSync(cur);
1410
- return tail.length === 0 ? r : resolve(r, ...tail.reverse());
1411
- } catch {
1412
- const parent = dirname(cur);
1413
- if (parent === cur) return p;
1414
- tail.push(cur.slice(parent.length).replace(/^[/\\]+/, ""));
1415
- cur = parent;
1416
- }
1417
- }
1418
- /**
1419
- * Does the symlink at `slot` route to `expected` (lexically, with a
1420
- * realpath fallback)? Mirrors `symlinkRoutesTo` in `installer.ts` —
1421
- * deliberately a local duplicate so `commands.ts` does not depend on the
1422
- * installer's private helpers.
1423
- */
1424
- function symlinkRoutesTo(slot, expected) {
1425
- let raw;
1426
- try {
1427
- raw = readlinkSync(slot);
1428
- } catch {
1429
- return false;
1430
- }
1431
- const resolvedTarget = isAbsolute(raw) ? raw : resolve(dirname(slot), raw);
1432
- if (resolvedTarget === expected) return true;
1433
- try {
1434
- return realpathSync(resolvedTarget) === realpathSync(expected);
1435
- } catch {
1436
- return false;
1437
- }
1438
- }
1439
- function isDanglingSymlink(path) {
1440
- let stat;
1441
- try {
1442
- stat = lstatSync(path);
1443
- } catch {
1444
- return false;
1445
- }
1446
- if (!stat.isSymbolicLink()) return false;
1447
- return !existsSync(path);
1448
- }
1449
- /**
1450
- * Enumerate installed skills that should be reconciled by `sync`'s orphan
1451
- * cleanup: skills carrying this CLI's ownership stamp, plus dangling
1452
- * canonical symlinks whose link target routes back to this CLI's source
1453
- * directory. `.agents/skills/` is a namespace shared with every other
1454
- * politty-based CLI, so a dangling canonical symlink without a routing
1455
- * match likely belongs to a foreign CLI whose source was uninstalled —
1456
- * including it would let `sync` unlink it under our authority.
1457
- */
1458
- function findOwnedInstalledSkills(expectedOwnership, cwd, sourceDir) {
1459
- const base = resolve(cwd, AGENTS_SKILLS_DIR);
1460
- const owned = [];
1461
- let entries;
1462
- try {
1463
- entries = readdirSync(base, { withFileTypes: true });
1464
- } catch (err) {
1465
- const code = err.code;
1466
- if (code === "ENOENT" || code === "ENOTDIR") return owned;
1467
- logger.warn(`Failed to enumerate ${base}: ${err instanceof Error ? err.message : String(err)}`);
1468
- return owned;
1469
- }
1470
- for (const entry of entries) {
1471
- if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
1472
- if (entry.name.length < 1 || entry.name.length > 64) continue;
1473
- if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(entry.name)) continue;
1474
- let owner;
1475
- try {
1476
- owner = readInstalledOwnership(entry.name, cwd);
1477
- } catch (err) {
1478
- logger.warn(`Failed to read ownership for ${entry.name}: ${err instanceof Error ? err.message : String(err)}`);
1479
- continue;
1480
- }
1481
- if (owner === expectedOwnership) {
1482
- owned.push(entry.name);
1483
- continue;
1484
- }
1485
- const canonical = resolve(base, entry.name);
1486
- if (owner === null && isDanglingSymlink(canonical) && danglingRoutesToSource(canonical, sourceDir)) owned.push(entry.name);
1487
- }
1488
- return owned;
1489
- }
1490
-
1491
- //#endregion
1492
- //#region ../core/src/skill/options.ts
1493
- /** Default unknown-keys mode for the `add`/`sync`/`remove`/`list` arg schemas. */
1494
- const DEFAULT_UNKNOWN_KEYS = "strip";
1495
- /** Default short alias for `skills sync --exclude`. */
1496
- const DEFAULT_EXCLUDE_ALIAS = "x";
1497
- /** Default short alias for `skills add`/`skills sync --verbose`. */
1498
- const DEFAULT_VERBOSE_ALIAS = "v";
1499
- /** Default primary name + aliases for `skills add`. */
1500
- const DEFAULT_ADD_NAMES = ["add", "install"];
1501
- /** Default primary name + aliases for `skills remove`. */
1502
- const DEFAULT_REMOVE_NAMES = ["remove", "uninstall"];
1503
- /**
1504
- * Default description text for the `skills` command and each built-in
1505
- * subcommand, keyed by canonical role. Overridden per-key via
1506
- * `SkillCommandOptions.descriptions`.
1507
- */
1508
- const DEFAULT_DESCRIPTIONS = {
1509
- skills: "Manage agent skills",
1510
- sync: "Remove and reinstall all skills from source",
1511
- add: "Install skills from source",
1512
- remove: "Remove installed skills",
1513
- list: "List available skills from source"
1514
- };
1515
- /**
1516
- * Same safe-token pattern politty's own command validator enforces for
1517
- * subcommand aliases (`checkSubCommandAliasConflicts` in
1518
- * src/validator/command-validator.ts). That check only runs when a host
1519
- * explicitly calls `validateCommand()`, not automatically from
1520
- * `runMain`/`runCommand`, so `commandMap` entries need their own check.
1521
- */
1522
- const SAFE_TOKEN = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
1523
- /** Marker files identifying a project root for find-up. */
1524
- const PROJECT_ROOT_MARKERS = [".git", "package.json"];
1525
- /**
1526
- * Resolve user-facing {@link SkillCommandOptions} into the concrete shape
1527
- * each subcommand consumes. Defaults applied here:
1528
- *
1529
- * - `cwd` — `findProjectRoot(process.cwd()) ?? process.cwd()`.
1530
- * - `excludeAlias` — `"x"` unless overridden via
1531
- * `flags.exclude.alias` (string) or disabled (`false`).
1532
- * - `verbose` — alias `"v"` unless overridden via `flags.verbose.alias`;
1533
- * the flag itself is omitted from `add`/`sync` whenever `globalArgs`
1534
- * already defines a `verbose` field.
1535
- * - `json` — the flag is omitted from `list` whenever `globalArgs` already
1536
- * defines a `json` field.
1537
- * - `commandNames.add`/`.remove` — primary name `"add"`/`"remove"` plus
1538
- * alias `"install"`/`"uninstall"` unless overridden via
1539
- * `options.commandMap.add`/`.remove` (first array element becomes the
1540
- * primary name, the rest become aliases).
1541
- * - `unknownKeys` — `"strip"` unless overridden via `options.unknownKeys`.
1542
- * - `descriptionAppend` — a one-line hint mentioning the skills
1543
- * subcommands. Pass an explicit string to override or `false` to opt out.
1544
- * - `descriptions` — politty's default text per canonical role, unless
1545
- * overridden via `options.descriptions`.
1546
- */
1547
- function resolveSkillOptions(options, cliName) {
1548
- return {
1549
- sourceDir: options.sourceDir,
1550
- package: options.package,
1551
- mode: options.mode,
1552
- cwd: resolveCwd(options.cwd),
1553
- excludeAlias: resolveExcludeAlias(options.flags?.exclude?.alias),
1554
- verbose: resolveVerbose(options.flags?.verbose, options.globalArgs),
1555
- json: { disabled: hasGlobalField(options.globalArgs, "json") },
1556
- commandNames: {
1557
- add: resolveCommandNaming(options.commandMap?.add, DEFAULT_ADD_NAMES, "add"),
1558
- remove: resolveCommandNaming(options.commandMap?.remove, DEFAULT_REMOVE_NAMES, "remove")
1559
- },
1560
- unknownKeys: options.unknownKeys ?? DEFAULT_UNKNOWN_KEYS,
1561
- descriptionAppend: resolveDescriptionAppend(options.descriptionAppend, cliName),
1562
- stamp: `${options.package}:${cliName}`,
1563
- descriptions: resolveDescriptions(options.descriptions)
1564
- };
1565
- }
1566
- /** Fill in {@link DEFAULT_DESCRIPTIONS} for any key the caller left unset. */
1567
- function resolveDescriptions(value) {
1568
- return {
1569
- skills: value?.skills ?? DEFAULT_DESCRIPTIONS.skills,
1570
- sync: value?.sync ?? DEFAULT_DESCRIPTIONS.sync,
1571
- add: value?.add ?? DEFAULT_DESCRIPTIONS.add,
1572
- remove: value?.remove ?? DEFAULT_DESCRIPTIONS.remove,
1573
- list: value?.list ?? DEFAULT_DESCRIPTIONS.list
1574
- };
1575
- }
1576
- function resolveCwd(override) {
1577
- if (override !== void 0) return resolve(override);
1578
- const start = process.cwd();
1579
- return findProjectRoot(start) ?? start;
1580
- }
1581
- /**
1582
- * Walk up from `start` looking for the closest directory containing one
1583
- * of {@link PROJECT_ROOT_MARKERS}. Returns `null` when the walk reaches
1584
- * the filesystem root without a hit.
1585
- *
1586
- * `.git` matches both repositories (a directory) and worktrees / submodule
1587
- * checkouts (a file pointing at the parent gitdir) because `existsSync`
1588
- * accepts either.
1589
- */
1590
- function findProjectRoot(start) {
1591
- let dir = resolve(start);
1592
- while (true) {
1593
- for (const marker of PROJECT_ROOT_MARKERS) if (existsSync(resolve(dir, marker))) return dir;
1594
- const parent = dirname(dir);
1595
- if (parent === dir) return null;
1596
- dir = parent;
1597
- }
1598
- }
1599
- function resolveExcludeAlias(value) {
1600
- if (value === false) return void 0;
1601
- if (typeof value === "string") return value;
1602
- return DEFAULT_EXCLUDE_ALIAS;
1603
- }
1604
- /**
1605
- * The first element of `value` (or `defaults`, when `value` is `undefined`)
1606
- * becomes the primary name; the rest become aliases.
1607
- */
1608
- function resolveCommandNaming(value, defaults, label) {
1609
- const names = value ?? defaults;
1610
- if (names.length === 0) throw new Error(`SkillCommandOptions.commandMap.${label} must include at least one name.`);
1611
- const invalid = names.find((name) => !SAFE_TOKEN.test(name));
1612
- 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.`);
1613
- return {
1614
- name: names[0],
1615
- aliases: names.slice(1)
1616
- };
1617
- }
1618
- function resolveVerbose(value, globalArgs) {
1619
- return {
1620
- alias: value?.alias === false ? void 0 : value?.alias ?? DEFAULT_VERBOSE_ALIAS,
1621
- disabled: hasGlobalField(globalArgs, "verbose")
1622
- };
1623
- }
1624
- /**
1625
- * Does `globalArgs` (the host's `runMain`/`runCommand` global args schema,
1626
- * if passed through `SkillCommandOptions.globalArgs`) already declare a
1627
- * *non-positional boolean* field with this name? Determines whether the
1628
- * matching built-in local flag (`verbose`/`json`) is omitted — see
1629
- * {@link SkillCommandOptions.globalArgs}. Positional fields are excluded: a
1630
- * positional named `verbose`/`json` has no `--verbose`/`--json` flag syntax
1631
- * at all, so it can't actually collide with one. Non-boolean fields are
1632
- * excluded too: `mergedFlag` boolean-coerces whatever value flows through,
1633
- * and a same-named string/number field (e.g. a verbosity level or enum)
1634
- * isn't really the same flag — coercing it (e.g. `Boolean("off")` is `true`)
1635
- * would silently misread it, so it's not treated as a collision here and
1636
- * the local boolean flag stays declared.
1637
- *
1638
- * Note: politty itself independently rejects this same situation. When
1639
- * `globalArgs` and a command's own schema both declare a same-named field
1640
- * with different definitions (as happens here — global non-boolean vs.
1641
- * local boolean), `runMain`/`runCommand` throws `FieldTypeConflictError` at
1642
- * parse time, regardless of what this function decides. This function
1643
- * keeping the local flag "declared" doesn't make the combination usable —
1644
- * it just means the failure surfaces as politty's own clear error instead
1645
- * of a silent boolean-coercion misread. {@link SkillFlagOverrides.verbose}'s
1646
- * `alias` option can't help either, since it only renames the short alias,
1647
- * not the conflicting long field name. A host whose `globalArgs` defines a
1648
- * non-boolean `verbose`/`json` field must rename or remove that global
1649
- * field (or make it a plain boolean) instead.
1650
- */
1651
- function hasGlobalField(globalArgs, name) {
1652
- if (!globalArgs) return false;
1653
- return extractFields(globalArgs).fields.some((field) => field.name === name && !field.positional && field.type === "boolean");
1654
- }
1655
- function resolveDescriptionAppend(value, cliName) {
1656
- if (value === false) return false;
1657
- if (typeof value === "string") return value;
1658
- return `Manage agent skills with \`${cliName} skills <add|sync|remove|list>\`.`;
1659
- }
1660
-
1661
- //#endregion
1662
- //#region ../core/src/skill/types.ts
1663
- /**
1664
- * All kinds of scan failure, as a runtime tuple so callers can exhaustively
1665
- * iterate (e.g. for message tables). Derived {@link ScanErrorReason} stays
1666
- * the single source of truth for the type-level enum.
1667
- */
1668
- const SCAN_ERROR_REASONS = [
1669
- "parse-failed",
1670
- "name-mismatch",
1671
- "read-failed",
1672
- "missing-source"
1673
- ];
1674
-
1675
- //#endregion
1676
- //#region ../core/src/skill/index.ts
1677
- /**
1678
- * Skill management module for coding agent CLIs.
1679
- *
1680
- * Provides source-directory scanning and symlink-based (or copy-based)
1681
- * installation of SKILL.md-based agent skills, validated against the
1682
- * Agent Skills specification (https://agentskills.io/specification).
1683
- *
1684
- * Provenance of politty-managed installs is recorded under
1685
- * `metadata["politty-cli"]` as `"{packageName}:{cliName}"`, so
1686
- * `skills remove` can safely refuse to delete skills that belong to
1687
- * another tool.
1688
- *
1689
- * @example
1690
- * ```typescript
1691
- * import { dirname, resolve } from "node:path";
1692
- * import { fileURLToPath } from "node:url";
1693
- * import { defineCommand, runMain } from "politty";
1694
- * import { withSkillCommand } from "politty/skill";
1695
- *
1696
- * const sourceDir = resolve(dirname(fileURLToPath(import.meta.url)), "../skills");
1697
- *
1698
- * const cli = withSkillCommand(
1699
- * defineCommand({
1700
- * name: "my-agent",
1701
- * description: "My coding agent CLI",
1702
- * subCommands: {
1703
- * run: runCommand,
1704
- * },
1705
- * }),
1706
- * { sourceDir, package: "@my-agent/skills" },
1707
- * );
1708
- *
1709
- * runMain(cli);
1710
- * ```
1711
- *
1712
- * SKILL.md format (spec-compliant):
1713
- * ```markdown
1714
- * ---
1715
- * name: commit
1716
- * description: Git commit message generation
1717
- * license: MIT
1718
- * metadata:
1719
- * politty-cli: "@my-agent/skills:my-agent"
1720
- * ---
1721
- * # Instructions for the agent...
1722
- * ```
1723
- *
1724
- * @packageDocumentation
1725
- */
1726
- /**
1727
- * Wrap a command with a `skills` subcommand for managing SKILL.md-based skills.
1728
- *
1729
- * Adds `skills sync`, `skills add`, `skills remove`, and `skills list`.
1730
- * The install materialization is controlled by `options.mode`
1731
- * (see {@link SkillCommandOptions}):
1732
- *
1733
- * - `"symlink"` (default) — symlink the source into place. Source updates
1734
- * propagate live. Install errors out with guidance to retry with `"copy"`
1735
- * when `symlinkSync` fails (e.g. Windows without Developer Mode).
1736
- * - `"copy"` — recursive copy. Source updates require re-running sync.
1737
- *
1738
- * Under both modes the canonical slot is `.agents/skills/<name>` and each
1739
- * agent-specific directory (e.g. `.claude/skills/<name>`) is populated
1740
- * from that canonical slot. politty never writes to `SKILL.md`. The
1741
- * ownership stamp `metadata["politty-cli"] = "{package}:{cliName}"` must
1742
- * be authored by the skill package itself; `add` and `sync` verify it
1743
- * before installing and `remove` / `sync` consult it before deleting, so
1744
- * this CLI never clobbers skills another tool installed.
1745
- *
1746
- * @throws if `command.subCommands.skills` already exists — silently
1747
- * overwriting it would hide a configuration bug.
1748
- */
1749
- function withSkillCommand(command, options) {
1750
- if (command.subCommands && Object.hasOwn(command.subCommands, "skills")) throw new Error(`withSkillCommand: command "${command.name}" already defines a "skills" subcommand.`);
1751
- const resolved = resolveSkillOptions(options, command.name);
1752
- const addName = resolved.commandNames.add.name;
1753
- const removeName = resolved.commandNames.remove.name;
1754
- const allNames = [
1755
- "sync",
1756
- "list",
1757
- addName,
1758
- ...resolved.commandNames.add.aliases,
1759
- removeName,
1760
- ...resolved.commandNames.remove.aliases
1761
- ];
1762
- const duplicate = allNames.find((name, i) => allNames.indexOf(name) !== i);
1763
- if (duplicate) throw new Error(`withSkillCommand: commandMap produced duplicate subcommand name/alias "${duplicate}".`);
1764
- const skillsSubCommand = defineCommand({
1765
- name: "skills",
1766
- description: resolved.descriptions.skills,
1767
- subCommands: {
1768
- sync: createSkillSyncCommand(resolved),
1769
- [addName]: createSkillAddCommand(resolved),
1770
- [removeName]: createSkillRemoveCommand(resolved),
1771
- list: createSkillListCommand(resolved)
1772
- }
1773
- });
1774
- return {
1775
- ...command,
1776
- description: appendDescription(command.description, resolved.descriptionAppend),
1777
- subCommands: {
1778
- ...command.subCommands,
1779
- skills: skillsSubCommand
1780
- }
1781
- };
1782
- }
1783
- /**
1784
- * Append the configured skills hint to the root command's description.
1785
- *
1786
- * Returns the original description unchanged when `append` is `false` or
1787
- * empty. When the existing description already ends with the same hint,
1788
- * skip the append so re-wrapping (e.g. in tests) does not duplicate it.
1789
- *
1790
- * The separator is a blank line so help renderers display the hint as
1791
- * its own paragraph — a single space would run the hint into the host
1792
- * description (especially when the description has no trailing period).
1793
- */
1794
- function appendDescription(existing, append) {
1795
- if (append === false || append === "") return existing;
1796
- if (!existing) return append;
1797
- if (existing.endsWith(append)) return existing;
1798
- return `${existing}\n\n${append}`;
1799
- }
1800
-
1801
- //#endregion
1802
- //#region src/skill-frontmatter-schema.ts
1803
- /**
1804
- * Zod schema for SKILL.md frontmatter.
1805
- *
1806
- * @deprecated Kept as a public export of `politty/skill` for backwards
1807
- * compatibility. politty itself no longer validates frontmatter through
1808
- * this schema — see `validateSkillFrontmatter` in `frontmatter.ts`, which
1809
- * implements the same Agent Skills specification rules without a runtime
1810
- * zod dependency. Keep the two in sync when the spec changes.
1811
- *
1812
- * Strictly validates the fields defined in the Agent Skills specification
1813
- * (https://agentskills.io/specification). Unknown fields are preserved via
1814
- * `.passthrough()` so spec extensions and vendor keys round-trip intact.
1815
- *
1816
- * Provenance / ownership for politty-managed installs is recorded under
1817
- * `metadata["politty-cli"]` as `"{packageName}:{cliName}"`.
1818
- */
1819
- const skillFrontmatterSchema = z.object({
1820
- /** Skill identifier. Lowercase alphanumerics + hyphens, 1..64 chars. */
1821
- name: z.string().min(1).max(64).regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, { message: "name must be lowercase alphanumerics separated by single hyphens" }),
1822
- /** Human-readable description (1..1024 chars). */
1823
- description: z.string().min(1).max(1024),
1824
- /** SPDX license identifier or free-form string. */
1825
- license: z.string().min(1).optional(),
1826
- /** Runtime / tool compatibility string (<=500 chars). */
1827
- compatibility: z.string().max(500).optional(),
1828
- /** Metadata map (spec: string keys, string values). */
1829
- metadata: z.record(z.string(), z.string()).optional(),
1830
- /** Experimental spec field. */
1831
- "allowed-tools": z.string().optional()
1832
- }).passthrough();
1833
-
1834
- //#endregion
1835
- export { OWNERSHIP_METADATA_KEY, SCAN_ERROR_REASONS, hasInstalledSkill, installSkill, parseFrontmatter, parseSkillMd, readInstalledOwnership, scanSourceDir, skillFrontmatterSchema, uninstallSkill, withSkillCommand };
1
+ import"./register-C1WbbeYH.js";import{a as e,i as t,t as n}from"./schema-extractor-DU0Vuhbd.js";import{n as r}from"./command-Mbdt0bmN.js";import{a as i,n as a}from"./logger-CJsyJ8sb.js";import{copyFileSync as o,existsSync as s,lstatSync as c,mkdirSync as l,mkdtempSync as u,readFileSync as d,readdirSync as f,readlinkSync as p,realpathSync as m,renameSync as ee,rmSync as h,statSync as g,symlinkSync as te,unlinkSync as _}from"node:fs";import{basename as v,dirname as y,isAbsolute as b,join as x,parse as ne,relative as S,resolve as C,sep as w}from"node:path";import{parse as re}from"yaml";import{z as T}from"zod";const ie=/^[a-z0-9]+(-[a-z0-9]+)*$/;function E(e){return e===null?`null`:Array.isArray(e)?`array`:typeof e}function ae(e){let t=[],n=(n,r)=>{let i=e[n];if(i===void 0){r.required&&t.push({path:[n],message:`Invalid input: expected string, received undefined`});return}if(typeof i!=`string`){t.push({path:[n],message:`Invalid input: expected string, received ${E(i)}`});return}r.min!==void 0&&i.length<r.min&&t.push({path:[n],message:`Too small: expected string to have >=${r.min} characters`}),r.max!==void 0&&i.length>r.max&&t.push({path:[n],message:`Too big: expected string to have <=${r.max} characters`})};n(`name`,{required:!0,min:1,max:64}),typeof e.name==`string`&&!ie.test(e.name)&&t.push({path:[`name`],message:`name must be lowercase alphanumerics separated by single hyphens`}),n(`description`,{required:!0,min:1,max:1024}),n(`license`,{min:1}),n(`compatibility`,{max:500}),n(`allowed-tools`,{});let r=e.metadata;if(r!==void 0){if(typeof r!=`object`||!r||Array.isArray(r))t.push({path:[`metadata`],message:`Invalid input: expected record, received ${E(r)}`});else for(let[e,n]of Object.entries(r))typeof n!=`string`&&t.push({path:[`metadata`,e],message:`Invalid input: expected string, received ${E(n)}`})}return t.length>0?{success:!1,issues:t}:{success:!0,data:{...e}}}const oe=/^\uFEFF?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)([\s\S]*)$/;function D(e){let t=e.match(oe);if(!t)return{data:{},body:e};let n=t[1],r=t[2];try{let e=re(n);return se(e)?{data:e,body:r}:{data:{},body:r}}catch(e){return{data:{},body:r,parseError:e instanceof Error?e.message:String(e)}}}function se(e){if(typeof e!=`object`||!e||Array.isArray(e))return!1;let t=Object.getPrototypeOf(e);return t===Object.prototype||t===null}function ce(e){let{data:t,body:n}=D(e),r=ae(t);return r.success?{frontmatter:r.data,body:n,rawContent:e}:null}const O=`.agents/skills`,k=[`.claude/skills`],A=`politty-cli`;function j(e){if(e.length<1||e.length>64||!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(e))throw Error(`Invalid skill name: ${JSON.stringify(e)}`)}function le(e,t=process.cwd(),n={}){let r=e.frontmatter.name;j(r);let i=n.mode??`symlink`,a=e.frontmatter.metadata?.[`politty-cli`]??null;if(i===`copy`&&a===null)throw Error(`Refusing to install "${e.frontmatter.name}" in copy mode without an ownership stamp. Add metadata.${A}="{package}:{cli}" to the source SKILL.md so subsequent installs can replace the copy in place.`);let o=C(t,O),s=m(e.sourcePath),c=ue(e.sourcePath),u=[x(N(o),r),...k.map(e=>x(N(C(t,e)),r))].find(e=>ye(e,s));if(u!==void 0)throw Error(`Refusing to install "${r}": source ${s} overlaps install destination ${u}. Choose a sourceDir outside .agents/skills/ and any agent-specific slot directory (e.g. .claude/skills/).`);if(i===`symlink`){let e=N(o);M(x(e,r),S(e,c));for(let n of k){let i=N(C(t,n));M(x(i,r),x(S(i,e),r))}}l(o,{recursive:!0});let d=m(o),f=x(d,r);he(f,a),ge({linkTarget:S(d,c),linkPath:f,copyFrom:s,mode:i}),ve(t,r,f,a,i)}function M(e,t){if(b(t))throw Error(`Refusing to write an absolute symlink target at ${e} → ${t}. The skill source and install root appear to live on different filesystem roots (e.g. different Windows drive letters); retry with mode: "copy".`)}function ue(e){let t=C(e),{root:n}=ne(t),r=t.slice(n.length).split(w).filter(e=>e!==``),i=n;for(let[e,t]of r.entries()){let n=x(i,t),a;try{a=c(n)}catch{return x(i,...r.slice(e))}if(a.isSymbolicLink()){if(de(i))return x(i,...r.slice(e));i=m(n)}else i=n}return i}function de(e){return v(e)===`node_modules`||v(e).startsWith(`@`)&&v(y(e))===`node_modules`}function N(e){let t=e,n=[];for(;;)try{let e=m(t);return n.length===0?e:C(e,...n.reverse())}catch{let r=y(t);if(r===t)return e;n.push(t.slice(r.length).replace(/^[/\\]+/,``)),t=r}}function fe(e,t=process.cwd(),n={}){j(e);let r=n.expectedOwnership??null,i=C(t,O,e);for(let n of k)me(C(t,n,e),r,{restrictSymlinkTo:i});me(i,r)}function pe(e,t){let n;try{n=p(e)}catch{return!1}let r=b(n)?n:C(y(e),n);if(r===t)return!0;try{return m(r)===m(t)}catch{return!1}}function me(e,t,n={}){let r;try{r=c(e)}catch(e){if(R(e)&&(e.code===`ENOENT`||e.code===`ENOTDIR`))return;throw e}if(r.isSymbolicLink()){if(n.restrictSymlinkTo!==void 0){pe(e,n.restrictSymlinkTo)&&_(e);return}(t===null||F(e)===t)&&_(e);return}r.isDirectory()&&t!==null&&F(e)===t&&h(e,{recursive:!0,force:!0})}function he(e,t,n={}){let r;try{r=c(e)}catch(e){if(R(e)&&(e.code===`ENOENT`||e.code===`ENOTDIR`))return;throw e}if(r.isSymbolicLink()){if(n.restrictSymlinkTo===void 0||pe(e,n.restrictSymlinkTo)){_(e);return}throw Error(`Refusing to replace symlink at ${e}: it does not route to this CLI's canonical slot (${n.restrictSymlinkTo}). Remove or migrate the foreign symlink before retrying.`)}if(r.isDirectory()&&t!==null&&F(e)===t){h(e,{recursive:!0,force:!0});return}throw Error(`Refusing to replace non-symlink path at ${e}. This looks like a legacy or manual install; remove or migrate it before retrying.`)}function ge(e){let{linkTarget:t,linkPath:n,copyFrom:r,mode:i}=e;if(i===`copy`){_e(r,n);return}M(n,t);try{te(t,n,`dir`)}catch(e){let r=e instanceof Error?e.message:String(e);throw Error(`Failed to symlink ${n} → ${t}: ${r}. If this filesystem does not support symlinks (e.g. Windows without Developer Mode), retry with mode: "copy".`,{cause:e})}}function _e(e,t){let n=u(`${t}.partial-`);try{P(e,n),ee(n,t)}catch(e){try{h(n,{recursive:!0,force:!0})}catch{}throw e}}function P(e,t,n=new Set){let r=g(e);if(r.isDirectory()){let r=m(e);if(n.has(r))throw Error(`Refusing to recursively copy cyclic directory symlink at ${e} (resolves to ${r}, already on the copy stack).`);n.add(r);try{l(t,{recursive:!0});for(let r of f(e))P(x(e,r),x(t,r),n)}finally{n.delete(r)}return}r.isFile()&&o(e,t)}function F(e){let t;try{t=d(x(e,`SKILL.md`),`utf-8`)}catch(e){if(R(e)&&(e.code===`ENOENT`||e.code===`ENOTDIR`))return null;throw e}let{data:n}=D(t),r=n.metadata;if(!r||typeof r!=`object`||Array.isArray(r))return null;let i=r[A];return typeof i==`string`?i:null}function I(e,t=process.cwd()){return j(e),s(C(t,O,e,`SKILL.md`))}function L(e,t=process.cwd()){j(e);let n=C(t,O,e,`SKILL.md`),r;try{r=d(n,`utf-8`)}catch(e){if(R(e)&&(e.code===`ENOENT`||e.code===`ENOTDIR`))return null;throw e}let{data:i}=D(r),a=i.metadata;if(!a||typeof a!=`object`||Array.isArray(a))return null;let o=a[A];return typeof o==`string`?o:null}function ve(e,t,n,r,i){for(let a of k){let o=C(e,a);l(o,{recursive:!0});let s=m(o),c=x(s,t);he(c,r,{restrictSymlinkTo:n});let u=m(C(n,`..`));ge({linkTarget:x(S(s,u),t),linkPath:c,copyFrom:n,mode:i})}}function R(e){return e instanceof Error&&typeof e.code==`string`}function ye(e,t){if(e===t)return!0;let n=(e,t)=>{let n=S(t,e);return n===``||n===`.`||!b(n)&&n!==`..`&&!n.startsWith(`..${w}`)};return n(e,t)||n(t,e)}const z=`SKILL.md`;function B(e){let t=[],n=[];try{let r;try{r=g(e)}catch(r){return H(r)&&(r.code===`ENOENT`||r.code===`ENOTDIR`)?n.push({path:e,reason:`missing-source`,message:`Source directory does not exist: ${e}`}):n.push({path:e,reason:`read-failed`,message:`Failed to stat source directory ${e}: ${V(r)}`}),{skills:t,errors:n}}if(!r.isDirectory())return n.push({path:e,reason:`missing-source`,message:`Source path is not a directory: ${e}`}),{skills:t,errors:n};let i=x(e,z),a=Se(i);if(a.kind===`error`)return n.push({path:e,reason:`read-failed`,message:`Failed to check ${i}: ${a.message}`}),{skills:t,errors:n};if(a.kind===`present`)return xe(be(e,{enforceParentMatch:!1}),t,n),{skills:t,errors:n};let o=f(e,{withFileTypes:!0});for(let r of o){let i=x(e,r.name),a;try{a=g(i).isDirectory()}catch(e){if(H(e)&&e.code===`ENOENT`&&!r.isSymbolicLink())continue;n.push({path:i,reason:`read-failed`,message:r.isSymbolicLink()?`Dangling symlink at ${i}: ${V(e)}`:`Failed to stat ${i}: ${V(e)}`});continue}if(!a)continue;let o=x(i,z),s=Se(o);if(s.kind===`error`){n.push({path:i,reason:`read-failed`,message:`Failed to check ${o}: ${s.message}`});continue}s.kind!==`absent`&&xe(be(i,{enforceParentMatch:!0}),t,n)}}catch(t){n.push({path:e,reason:`read-failed`,message:`Failed to scan ${e}: ${V(t)}`})}return t.sort((e,t)=>e.frontmatter.name<t.frontmatter.name?-1:+(e.frontmatter.name>t.frontmatter.name)),{skills:t,errors:n}}function be(e,t){let n=x(e,z),r;try{r=d(n,`utf-8`)}catch(t){return{path:e,reason:`read-failed`,message:`Failed to read ${n}: ${V(t)}`}}let{data:i,parseError:a}=D(r);if(a!==void 0)return{path:e,reason:`parse-failed`,message:`Invalid SKILL.md frontmatter in ${e}: YAML parse error: ${a}`};let o=ae(i);if(!o.success)return{path:e,reason:`parse-failed`,message:`Invalid SKILL.md frontmatter in ${e}: ${o.issues.map(e=>`${e.path.join(`.`)||`<root>`}: ${e.message}`).join(`; `)}`};if(t.enforceParentMatch){let t=v(e);if(t!==o.data.name)return{path:e,reason:`name-mismatch`,message:`Skill name "${o.data.name}" does not match directory "${t}"`,skillName:o.data.name}}return{frontmatter:o.data,sourcePath:e,rawContent:r}}function xe(e,t,n){`frontmatter`in e?t.push(e):n.push(e)}function V(e){return e instanceof Error?e.message:String(e)}function H(e){return e instanceof Error&&typeof e.code==`string`}function Se(e){try{return c(e),{kind:`present`}}catch(e){return H(e)&&e.code===`ENOENT`?{kind:`absent`}:{kind:`error`,message:V(e)}}}function Ce(e,t={}){let n=0,r=!1;for(let i of e){if(i.reason===`missing-source`||i.path===t.sourceDir){r=!0,a.warn(`Failed to scan source directory ${i.path}: ${i.message}`);continue}n+=1,a.warn(`Skipping skill at ${i.path}: ${i.message}`)}t.silentStdout||(n>0&&a.info(`${i.warning} Skipped ${n} skill(s) due to scan errors (see warnings above).`),r&&a.info(`${i.warning} Source directory scan failed (see warnings above); subsequent operations may be skipped.`))}function U(e,t){let n=e.errors.some(e=>e.reason===`missing-source`||e.path===t),r=e.errors.length>0&&e.skills.length===0;return n||r}function W(e,t={}){let n=B(e.sourceDir);return Ce(n.errors,{...t,sourceDir:e.sourceDir}),n}function we(e){let t={description:`Skill names to exclude from sync`};return e.excludeAlias!==void 0&&(t.alias=e.excludeAlias),t}function Te(e){let t={description:`Print install paths and modes`};return e.verbose.alias!==void 0&&(t.alias=e.verbose.alias),t}function G(e,t){return!!e[t]}function Ee(n){function i(e,t){let{skills:r,errors:i}=W(n),o=n.stamp,s=new Set(r.map(e=>e.frontmatter.name)),c=new Set(Ie(o,n.cwd,n.sourceDir)),l=Array.from(new Set(e.exclude)).filter(e=>!s.has(e)&&!c.has(e));if(l.length>0){let e=l.length===1?`Skill`:`Skills`,t=l.map(e=>JSON.stringify(e)).join(`, `);throw Error(`--exclude: ${e} ${t} not found in source directory or among installed skills.\n`+q({source:r,installed:c}))}let u=new Set(e.exclude),d=r.filter(e=>!u.has(e.frontmatter.name)),f=U({skills:r,errors:i},n.sourceDir),p=0;if(!f){let e=new Set(d.map(e=>e.frontmatter.name)),t=new Set;for(let e of i)e.path!==n.sourceDir&&(t.add(v(e.path)),e.skillName!==void 0&&t.add(e.skillName));for(let r of c)e.has(r)||u.has(r)||t.has(r)||(Y(r,o,n.cwd,n.sourceDir),p+=1)}let m=0;for(let e of d)J(e,o,n,t),m+=1;if(m===0&&p===0){let e=f?`source directory scan failed; see warnings`:r.length>0&&d.length===0?`all skills excluded`:`no skills bundled`;a.info(`No skills installed (${e}).`)}else a.info(`Sync complete: ${m} installed, ${p} removed.`)}return n.verbose.disabled?r({name:`sync`,description:n.descriptions.sync,args:t({exclude:e.stringArray(we(n))},{unknownKeys:n.unknownKeys}),run(e){i(e,G(e,`verbose`))}}):r({name:`sync`,description:n.descriptions.sync,args:t({exclude:e.stringArray(we(n)),verbose:e.boolean(Te(n))},{unknownKeys:n.unknownKeys}),run(e){i(e,e.verbose)}})}function De(n){let i={positional:!0,description:`Skill name(s) to install (default: all)`,placeholder:`NAME`};function o(e,t){let r=W(n),i=r.skills,o=n.stamp;if(e.name.length>0){let r=new Set(i.map(e=>e.frontmatter.name)),a=Array.from(new Set(e.name)),s=a.filter(e=>!r.has(e));if(s.length>0){let e=s.length===1?`Skill`:`Skills`,t=s.map(e=>JSON.stringify(e)).join(`, `);throw Error(`${e} ${t} not found in source directory.\n`+q({source:i}))}let c=new Set(a);for(let e of i)c.has(e.frontmatter.name)&&J(e,o,n,t);return}if(i.length===0){U(r,n.sourceDir)?a.info(`No skills installed (source directory scan failed; see warnings).`):a.info(`No skills found in source directory.`);return}for(let e of i)J(e,o,n,t)}return n.verbose.disabled?r({name:n.commandNames.add.name,...n.commandNames.add.aliases.length>0?{aliases:n.commandNames.add.aliases}:{},description:n.descriptions.add,args:t({name:e.stringArray(i)},{unknownKeys:n.unknownKeys}),run(e){o(e,G(e,`verbose`))}}):r({name:n.commandNames.add.name,...n.commandNames.add.aliases.length>0?{aliases:n.commandNames.add.aliases}:{},description:n.descriptions.add,args:t({name:e.stringArray(i),verbose:e.boolean(Te(n))},{unknownKeys:n.unknownKeys}),run(e){o(e,e.verbose)}})}function Oe(n){return r({name:n.commandNames.remove.name,...n.commandNames.remove.aliases.length>0?{aliases:n.commandNames.remove.aliases}:{},description:n.descriptions.remove,args:t({name:e.optionalString({positional:!0,description:`Skill name to remove (default: all)`,placeholder:`NAME`})},{unknownKeys:n.unknownKeys}),run(e){let t=W(n),r=t.skills,i=n.stamp;if(e.name){if(r.some(t=>t.frontmatter.name===e.name)&&Me(r,e.name),!Y(e.name,i,n.cwd,n.sourceDir)){if(K(e.name,n.cwd))a.info(`${e.name} is installed without a ${A} stamp this CLI recognises; refusing to remove. Remove .agents/skills/${e.name} manually if intended.`);else{let t=new Set(Ie(i,n.cwd,n.sourceDir));a.info(`${e.name} is not installed; nothing to remove.\n`+q({installed:t}))}}return}if(r.length===0){U(t,n.sourceDir)?a.info(`No skills found (source directory scan failed; see warnings); nothing to remove.`):a.info(`No skills found in source directory; nothing to remove.`);return}let o=0;for(let e of r)Y(e.frontmatter.name,i,n.cwd,n.sourceDir)&&(o+=1);o===0&&a.info(`No installed skills owned by this CLI; nothing to remove.`)}})}function ke(e,t,n,r){let i;try{i=L(e,n)}catch(t){return a.warn(`Failed to read ownership for installed skill ${e}: ${Ae(t)}`),`unreadable`}if(i===t)return`installed`;if(i!==null)return`foreign`;if(!I(e,n)){let t=C(n,O,e);return Z(t)&&X(t,r)?`missing`:K(e,n)?`unstamped`:`not-installed`}return`unstamped`}function Ae(e){return e instanceof Error?e.message:String(e)}function K(e,t){try{return c(C(t,O,e)),!0}catch{return!1}}function je(n){function i(e){let t=W(n,{silentStdout:e}),r=t.skills,i=n.stamp;if(e){console.log(JSON.stringify(r.map(e=>({name:e.frontmatter.name,description:e.frontmatter.description,owner:e.frontmatter.metadata?.[`politty-cli`]??null,expectedOwner:i,status:ke(e.frontmatter.name,i,n.cwd,n.sourceDir),sourcePath:e.sourcePath}))));return}if(r.length===0){U(t,n.sourceDir)?a.info(`Source directory scan failed; see warnings.`):a.info(`No skills found in source directory.`);return}a.info(`Available skills:`);for(let e of r){let t=ke(e.frontmatter.name,i,n.cwd,n.sourceDir);a.info(` ${e.frontmatter.name.padEnd(20)} ${t.padEnd(14)} ${e.frontmatter.description}`)}}return n.json.disabled?r({name:`list`,description:n.descriptions.list,args:t({},{unknownKeys:n.unknownKeys}),run(e){i(G(e,`json`))}}):r({name:`list`,description:n.descriptions.list,args:t({json:e.boolean({description:`Output as JSON`})},{unknownKeys:n.unknownKeys}),run(e){i(e.json)}})}function Me(e,t){let n=e.find(e=>e.frontmatter.name===t);if(!n){let n=e.map(e=>e.frontmatter.name).join(`, `)||`<none>`;throw Error(`Skill "${t}" not found in source directory. Available: ${n}`)}return n}function q(e){let t=[];return e.source!==void 0&&t.push(` Source: ${e.source.map(e=>e.frontmatter.name).join(`, `)||`<none>`}`),e.installed!==void 0&&t.push(` Installed: ${[...e.installed].sort().join(`, `)||`<none>`}`),t.join(`
2
+ `)}function J(e,t,n,r){let o=e.frontmatter.name,s=n.cwd,c=n.mode,l=e.frontmatter.metadata?.[`politty-cli`]??null;if(l!==t)throw Error(`Refusing to install "${o}": source SKILL.md declares metadata.${A}=${JSON.stringify(l)}, expected ${JSON.stringify(t)}.`);let u=L(o,s);if(u!==null&&u!==t)throw Error(`Refusing to install "${o}": owned by ${JSON.stringify(u)}, not ${JSON.stringify(t)}. Check metadata.${A} in .agents/skills/${o}/SKILL.md.`);let d=C(s,O,o),f=Z(d)&&X(d,n.sourceDir);if(u===null&&K(o,s)&&!f)throw Error(`Refusing to install "${o}": .agents/skills/${o} exists without a ${A} stamp, so it was not installed by this CLI. Remove it manually (or add the stamp to take ownership) before running "skills add".`);if(le(e,s,c===void 0?{}:{mode:c}),a.info(`${i.success} Installed ${o}`),r){let e=c??`symlink`;a.info(` mode=${e} path=${d}`)}}function Y(e,t,n,r){let o=L(e,n);if(o===null)return Ne(e,n,r)?(a.info(`${i.success} Removed ${e} (broken symlink)`),!0):!1;if(o!==t)throw Error(`Refusing to remove "${e}": owned by ${JSON.stringify(o)}, not ${JSON.stringify(t)}. Check metadata.${A} in .agents/skills/${e}/SKILL.md.`);return fe(e,n,{expectedOwnership:t}),a.info(`${i.success} Removed ${e}`),!0}function Ne(e,t,n){let r=C(t,O,e);if(!Z(r)||!X(r,n))return!1;for(let n of k){let i=C(t,n,e);Z(i)&&Fe(i,r)&&_(i)}return _(r),!0}function X(e,t){let n;try{n=p(e)}catch{return!1}let r=b(n)?n:C(y(e),n),i=Pe(C(t)),a=Pe(r),o=S(i,a);return!(b(o)||o===`..`||o.startsWith(`..${w}`))}function Pe(e){let t=e,n=[];for(;;)try{let e=m(t);return n.length===0?e:C(e,...n.reverse())}catch{let r=y(t);if(r===t)return e;n.push(t.slice(r.length).replace(/^[/\\]+/,``)),t=r}}function Fe(e,t){let n;try{n=p(e)}catch{return!1}let r=b(n)?n:C(y(e),n);if(r===t)return!0;try{return m(r)===m(t)}catch{return!1}}function Z(e){let t;try{t=c(e)}catch{return!1}return t.isSymbolicLink()?!s(e):!1}function Ie(e,t,n){let r=C(t,O),i=[],o;try{o=f(r,{withFileTypes:!0})}catch(e){let t=e.code;return t===`ENOENT`||t===`ENOTDIR`||a.warn(`Failed to enumerate ${r}: ${e instanceof Error?e.message:String(e)}`),i}for(let s of o){if(!s.isDirectory()&&!s.isSymbolicLink()||s.name.length<1||s.name.length>64||!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(s.name))continue;let o;try{o=L(s.name,t)}catch(e){a.warn(`Failed to read ownership for ${s.name}: ${e instanceof Error?e.message:String(e)}`);continue}if(o===e){i.push(s.name);continue}let c=C(r,s.name);o===null&&Z(c)&&X(c,n)&&i.push(s.name)}return i}const Le=[`add`,`install`],Re=[`remove`,`uninstall`],Q={skills:`Manage agent skills`,sync:`Remove and reinstall all skills from source`,add:`Install skills from source`,remove:`Remove installed skills`,list:`List available skills from source`},ze=/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,Be=[`.git`,`package.json`];function Ve(e,t){return{sourceDir:e.sourceDir,package:e.package,mode:e.mode,cwd:Ue(e.cwd),excludeAlias:Ge(e.flags?.exclude?.alias),verbose:qe(e.flags?.verbose,e.globalArgs),json:{disabled:$(e.globalArgs,`json`)},commandNames:{add:Ke(e.commandMap?.add,Le,`add`),remove:Ke(e.commandMap?.remove,Re,`remove`)},unknownKeys:e.unknownKeys??`strip`,descriptionAppend:Je(e.descriptionAppend,t),stamp:`${e.package}:${t}`,descriptions:He(e.descriptions)}}function He(e){return{skills:e?.skills??Q.skills,sync:e?.sync??Q.sync,add:e?.add??Q.add,remove:e?.remove??Q.remove,list:e?.list??Q.list}}function Ue(e){if(e!==void 0)return C(e);let t=process.cwd();return We(t)??t}function We(e){let t=C(e);for(;;){for(let e of Be)if(s(C(t,e)))return t;let e=y(t);if(e===t)return null;t=e}}function Ge(e){if(e!==!1)return typeof e==`string`?e:`x`}function Ke(e,t,n){let r=e??t;if(r.length===0)throw Error(`SkillCommandOptions.commandMap.${n} must include at least one name.`);let i=r.find(e=>!ze.test(e));if(i!==void 0)throw Error(`SkillCommandOptions.commandMap.${n} contains an invalid entry ${JSON.stringify(i)}. Names/aliases must start with an alphanumeric character and contain only alphanumeric characters, hyphens, or underscores.`);return{name:r[0],aliases:r.slice(1)}}function qe(e,t){return{alias:e?.alias===!1?void 0:e?.alias??`v`,disabled:$(t,`verbose`)}}function $(e,t){return e?n(e).fields.some(e=>e.name===t&&!e.positional&&e.type===`boolean`):!1}function Je(e,t){return e===!1?!1:typeof e==`string`?e:`Manage agent skills with \`${t} skills <add|sync|remove|list>\`.`}const Ye=[`parse-failed`,`name-mismatch`,`read-failed`,`missing-source`];function Xe(e,t){if(e.subCommands&&Object.hasOwn(e.subCommands,`skills`))throw Error(`withSkillCommand: command "${e.name}" already defines a "skills" subcommand.`);let n=Ve(t,e.name),i=n.commandNames.add.name,a=n.commandNames.remove.name,o=[`sync`,`list`,i,...n.commandNames.add.aliases,a,...n.commandNames.remove.aliases],s=o.find((e,t)=>o.indexOf(e)!==t);if(s)throw Error(`withSkillCommand: commandMap produced duplicate subcommand name/alias "${s}".`);let c=r({name:`skills`,description:n.descriptions.skills,subCommands:{sync:Ee(n),[i]:De(n),[a]:Oe(n),list:je(n)}});return{...e,description:Ze(e.description,n.descriptionAppend),subCommands:{...e.subCommands,skills:c}}}function Ze(e,t){return t===!1||t===``?e:e?e.endsWith(t)?e:`${e}\n\n${t}`:t}const Qe=T.object({name:T.string().min(1).max(64).regex(/^[a-z0-9]+(-[a-z0-9]+)*$/,{message:`name must be lowercase alphanumerics separated by single hyphens`}),description:T.string().min(1).max(1024),license:T.string().min(1).optional(),compatibility:T.string().max(500).optional(),metadata:T.record(T.string(),T.string()).optional(),"allowed-tools":T.string().optional()}).passthrough();export{A as OWNERSHIP_METADATA_KEY,Ye as SCAN_ERROR_REASONS,I as hasInstalledSkill,le as installSkill,D as parseFrontmatter,ce as parseSkillMd,L as readInstalledOwnership,B as scanSourceDir,Qe as skillFrontmatterSchema,fe as uninstallSkill,Xe as withSkillCommand};