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