@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.
- package/README.md +31 -0
- package/dist/{arg-registry-B8QvBM_Q.d.ts → arg-registry-Be4GJADw.d.ts} +15 -1
- package/dist/augment.d.ts +1 -1
- package/dist/augment.js +1 -1
- package/dist/cli-main-C8D_mKfv.js +2 -0
- package/dist/cli-main-ClQaGXB3.js +1 -0
- package/dist/cli-run-BFXIag27.js +1 -0
- package/dist/cli.js +1 -15
- package/dist/command-Mbdt0bmN.js +1 -0
- package/dist/compile-cache-VuUbFWIo.js +1 -0
- package/dist/compile-cache.js +1 -3
- package/dist/completion-D8DMrPqg.js +116 -0
- package/dist/completion-DUMcvXkT.js +1 -0
- package/dist/completion.d.ts +87 -7
- package/dist/completion.js +1 -4
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +84 -3045
- package/dist/dynamic-CMtee4tD.js +1 -0
- package/dist/dynamic-CrnlveHj.js +6 -0
- package/dist/field-meta-COGya7xp.js +1 -0
- package/dist/index.d.ts +20 -16
- package/dist/index.js +1 -27
- package/dist/log-collector-hiFa3sNm.js +1 -0
- package/dist/logger-CJsyJ8sb.js +1 -0
- package/dist/prompt-CqmGq1_N.js +1 -0
- package/dist/prompt-clack.d.ts +1 -1
- package/dist/prompt-clack.js +1 -32
- package/dist/prompt-inquirer.d.ts +1 -1
- package/dist/prompt-inquirer.js +1 -47
- package/dist/prompt.d.ts +1 -1
- package/dist/prompt.js +1 -4
- package/dist/register-C1WbbeYH.js +1 -0
- package/dist/runner-C_wCXh6X.js +1 -0
- package/dist/runner-uPmkI9Gb.js +29 -0
- package/dist/schema-BiUP_KyV.js +1 -0
- package/dist/schema-extractor-DU0Vuhbd.js +1 -0
- package/dist/skill.d.ts +1 -1
- package/dist/skill.js +2 -1835
- package/dist/subcommand-router-D8GTMXYL.js +1 -0
- package/dist/with-completion-command-DDohm8U6.js +3 -0
- package/dist/{index-CMb2xLiJ.d.ts → with-completion-command-xaVZtAga.d.ts} +3 -74
- package/package.json +3 -3
- package/dist/cli-main-BQfutJEX.js +0 -3
- package/dist/cli-main-D4kkrRfX.js +0 -314
- package/dist/cli-run-wxd6kPPf.js +0 -8
- package/dist/command-k-4yAz4J.js +0 -42
- package/dist/compile-cache-BC65o7MH.js +0 -103
- package/dist/completion-DW5qVc3l.js +0 -5613
- package/dist/field-meta-DMy5BcRr.js +0 -146
- package/dist/log-collector-CoUkLVJB.js +0 -114
- package/dist/logger-i_bb-Jhc.js +0 -133
- package/dist/prompt-BjIZThsH.js +0 -169
- package/dist/register-DlGnMLIY.js +0 -440
- package/dist/runner-DAGvxV2P.js +0 -2980
- package/dist/runner-MMjpKeEa.js +0 -3
- package/dist/schema-extractor-DMSozq40.js +0 -250
- package/dist/src-CLogLsbS.js +0 -12
- package/dist/src-bYH2XCQ4.js +0 -6
- package/dist/subcommand-router-Cskpofdk.js +0 -134
package/dist/skill.js
CHANGED
|
@@ -1,1835 +1,2 @@
|
|
|
1
|
-
import "./register-DlGnMLIY.js";
|
|
2
|
-
|
|
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};
|