@newlandnpt/pep-cli 0.6.0 → 0.7.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 +9 -3
- package/dist/pep.cjs +105 -33
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -153,9 +153,12 @@ A skill's name is the folder name **inside** the repository, not the repository'
|
|
|
153
153
|
different repositories that each ship a `semi-integration/SKILL.md` both want
|
|
154
154
|
`~/.agents/skills/semi-integration/`, and the second would silently overwrite the first.
|
|
155
155
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
until you `remove` one
|
|
156
|
+
`add` refuses, and the error names the repository already there — it will not pick a winner for
|
|
157
|
+
you. `update` never fails over it: the contested skill is left exactly as it is, everything else in
|
|
158
|
+
that repository still refreshes, and the clash is reported every run until you `remove` one. That
|
|
159
|
+
matters because a clash can appear **later**, when one repository adds a folder the other already
|
|
160
|
+
has — you did nothing, and one upstream mistake must not stop every other repository from
|
|
161
|
+
updating.
|
|
159
162
|
|
|
160
163
|
```
|
|
161
164
|
⚠ 3 repositories all install "semi-integration" here:
|
|
@@ -166,6 +169,9 @@ until you `remove` one:
|
|
|
166
169
|
|
|
167
170
|
Installing them into **different** directories is fine — that is what `-p` is for.
|
|
168
171
|
|
|
172
|
+
A contested skill stays frozen at whichever copy is on disk until you remove one of the claimants.
|
|
173
|
+
That is deliberate: stable and loudly reported beats "whoever runs update last wins".
|
|
174
|
+
|
|
169
175
|
`remove` leaves a shared skill's files on disk (which copy it is can no longer be told) and says
|
|
170
176
|
so, rather than deleting what might be the other repository's content.
|
|
171
177
|
|
package/dist/pep.cjs
CHANGED
|
@@ -51,6 +51,9 @@ function defaultSkillsDirectory() {
|
|
|
51
51
|
function claudeSkillsDirectory() {
|
|
52
52
|
return (0, import_node_path.join)((0, import_node_os.homedir)(), ".claude", "skills");
|
|
53
53
|
}
|
|
54
|
+
function samePlace(a, b) {
|
|
55
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
56
|
+
}
|
|
54
57
|
function userSkillsTarget() {
|
|
55
58
|
return { directory: defaultSkillsDirectory(), linkInto: claudeSkillsDirectory() };
|
|
56
59
|
}
|
|
@@ -1102,6 +1105,14 @@ function readTar(bytes) {
|
|
|
1102
1105
|
|
|
1103
1106
|
// src/skills-service.ts
|
|
1104
1107
|
var ARCHIVE_PATH = "/api/skills/archive";
|
|
1108
|
+
var MAX_ARCHIVE_BYTES = 64 * 1024 * 1024;
|
|
1109
|
+
var MAX_UNPACKED_BYTES = 256 * 1024 * 1024;
|
|
1110
|
+
var MAX_ENTRIES = 1e4;
|
|
1111
|
+
function tooBig(what, limit) {
|
|
1112
|
+
return new Error(
|
|
1113
|
+
`The skills archive is too large (${what} exceeds ${Math.round(limit / 1024 / 1024)} MiB). Refusing to unpack it.`
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1105
1116
|
var SKILL_MANIFEST = "SKILL.md";
|
|
1106
1117
|
function planSkillFiles(entries) {
|
|
1107
1118
|
const roots = [];
|
|
@@ -1116,13 +1127,13 @@ function planSkillFiles(entries) {
|
|
|
1116
1127
|
for (const root of roots) {
|
|
1117
1128
|
const name = root[root.length - 1];
|
|
1118
1129
|
const where = root.join("/");
|
|
1119
|
-
const first = seen.get(name);
|
|
1130
|
+
const first = seen.get(name.toLowerCase());
|
|
1120
1131
|
if (first !== void 0) {
|
|
1121
1132
|
throw new Error(
|
|
1122
1133
|
`Two skills in this package would both be called "${name}": ${first} and ${where}. Skill names come from the directory name, so they must be unique within a package.`
|
|
1123
1134
|
);
|
|
1124
1135
|
}
|
|
1125
|
-
seen.set(name, where);
|
|
1136
|
+
seen.set(name.toLowerCase(), where);
|
|
1126
1137
|
}
|
|
1127
1138
|
const files = [];
|
|
1128
1139
|
for (const entry of entries) {
|
|
@@ -1137,9 +1148,38 @@ function planSkillFiles(entries) {
|
|
|
1137
1148
|
}
|
|
1138
1149
|
return files;
|
|
1139
1150
|
}
|
|
1151
|
+
async function readCapped(response, limit) {
|
|
1152
|
+
const reader = response.body?.getReader();
|
|
1153
|
+
if (!reader) return new Uint8Array(await response.arrayBuffer());
|
|
1154
|
+
const chunks = [];
|
|
1155
|
+
let total = 0;
|
|
1156
|
+
for (; ; ) {
|
|
1157
|
+
const { done, value } = await reader.read();
|
|
1158
|
+
if (done) break;
|
|
1159
|
+
total += value.byteLength;
|
|
1160
|
+
if (total > limit) {
|
|
1161
|
+
await reader.cancel();
|
|
1162
|
+
throw tooBig("the download", limit);
|
|
1163
|
+
}
|
|
1164
|
+
chunks.push(value);
|
|
1165
|
+
}
|
|
1166
|
+
const out = new Uint8Array(total);
|
|
1167
|
+
let at = 0;
|
|
1168
|
+
for (const chunk of chunks) {
|
|
1169
|
+
out.set(chunk, at);
|
|
1170
|
+
at += chunk.byteLength;
|
|
1171
|
+
}
|
|
1172
|
+
return out;
|
|
1173
|
+
}
|
|
1174
|
+
function assertInside(base, target) {
|
|
1175
|
+
const rel = (0, import_node_path4.relative)((0, import_node_path4.resolve)(base), (0, import_node_path4.resolve)(target));
|
|
1176
|
+
if (rel === "" || rel.startsWith("..") || (0, import_node_path4.isAbsolute)(rel)) {
|
|
1177
|
+
throw new Error(`Refusing an archive entry that escapes its directory: ${target}`);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1140
1180
|
function segmentsOf(path) {
|
|
1141
|
-
const segments = path.split(
|
|
1142
|
-
if (segments.some((one) => one === "..")) {
|
|
1181
|
+
const segments = path.split(/[\\/]+/).filter((one) => one !== "" && one !== ".");
|
|
1182
|
+
if (segments.some((one) => one === ".." || /^[a-z]:$/i.test(one))) {
|
|
1143
1183
|
throw new Error(`Refusing an archive entry that escapes its directory: ${path}`);
|
|
1144
1184
|
}
|
|
1145
1185
|
return segments;
|
|
@@ -1195,6 +1235,12 @@ function hashSkill(files) {
|
|
|
1195
1235
|
}
|
|
1196
1236
|
return hash.digest("hex").slice(0, 32);
|
|
1197
1237
|
}
|
|
1238
|
+
function otherClaimant(packages, self, directory, skill) {
|
|
1239
|
+
const wanted = skill.toLowerCase();
|
|
1240
|
+
return Object.entries(packages).find(
|
|
1241
|
+
([other, pkg]) => other !== self && samePlace(pkg.directory, directory) && Object.keys(pkg.skills).some((one) => one.toLowerCase() === wanted)
|
|
1242
|
+
)?.[0];
|
|
1243
|
+
}
|
|
1198
1244
|
async function removeSkills(name, store) {
|
|
1199
1245
|
const state = await store.read();
|
|
1200
1246
|
const pkg = state?.packages[name];
|
|
@@ -1204,9 +1250,7 @@ async function removeSkills(name, store) {
|
|
|
1204
1250
|
const deleted = [];
|
|
1205
1251
|
const keptForOthers = [];
|
|
1206
1252
|
for (const skill of Object.keys(pkg.skills).sort()) {
|
|
1207
|
-
const owner =
|
|
1208
|
-
([other, one]) => other !== name && one.directory === pkg.directory && skill in one.skills
|
|
1209
|
-
)?.[0];
|
|
1253
|
+
const owner = otherClaimant(state.packages, name, pkg.directory, skill);
|
|
1210
1254
|
if (owner !== void 0) {
|
|
1211
1255
|
keptForOthers.push({ skill, owner });
|
|
1212
1256
|
continue;
|
|
@@ -1248,10 +1292,8 @@ async function updateSkills(dependencies) {
|
|
|
1248
1292
|
const commit = response.headers.get("x-skills-commit") ?? void 0;
|
|
1249
1293
|
const state = await dependencies.stateStore.read();
|
|
1250
1294
|
const previous = state?.packages[name];
|
|
1251
|
-
const sameTarget = previous
|
|
1252
|
-
const claimedElsewhere = (skill) =>
|
|
1253
|
-
([other, pkg]) => other !== name && pkg.directory === dependencies.directory && skill in pkg.skills
|
|
1254
|
-
)?.[0];
|
|
1295
|
+
const sameTarget = previous !== void 0 && samePlace(previous.directory, dependencies.directory) && (previous.linkedInto === void 0 ? dependencies.linkInto === void 0 : dependencies.linkInto !== void 0 && samePlace(previous.linkedInto, dependencies.linkInto));
|
|
1296
|
+
const claimedElsewhere = (skill) => otherClaimant(state?.packages ?? {}, name, dependencies.directory, skill);
|
|
1255
1297
|
const gone = previous !== void 0 && sameTarget ? await missingSkills(previous.directory, Object.keys(previous.skills)) : /* @__PURE__ */ new Set();
|
|
1256
1298
|
const nothingToRestore = !dependencies.restoreMissing || gone.size === 0;
|
|
1257
1299
|
const standing = Object.keys(previous?.skills ?? {}).flatMap((skill) => {
|
|
@@ -1262,8 +1304,23 @@ async function updateSkills(dependencies) {
|
|
|
1262
1304
|
await response.body?.cancel();
|
|
1263
1305
|
return { status: "unchanged", name, commit, conflicts: standing };
|
|
1264
1306
|
}
|
|
1265
|
-
const gzipped =
|
|
1266
|
-
|
|
1307
|
+
const gzipped = await readCapped(response, dependencies.maxArchiveBytes ?? MAX_ARCHIVE_BYTES);
|
|
1308
|
+
let unpacked;
|
|
1309
|
+
try {
|
|
1310
|
+
unpacked = (0, import_node_zlib.gunzipSync)(gzipped, { maxOutputLength: MAX_UNPACKED_BYTES });
|
|
1311
|
+
} catch (error) {
|
|
1312
|
+
if (error.code === "ERR_BUFFER_TOO_LARGE") {
|
|
1313
|
+
throw tooBig("the unpacked archive", MAX_UNPACKED_BYTES);
|
|
1314
|
+
}
|
|
1315
|
+
throw error;
|
|
1316
|
+
}
|
|
1317
|
+
const entries = readTar(unpacked);
|
|
1318
|
+
if (entries.length > MAX_ENTRIES) {
|
|
1319
|
+
throw new Error(
|
|
1320
|
+
`The skills archive holds ${entries.length} files (limit ${MAX_ENTRIES}). Refusing to unpack it.`
|
|
1321
|
+
);
|
|
1322
|
+
}
|
|
1323
|
+
const files = planSkillFiles(entries);
|
|
1267
1324
|
const bySkill = /* @__PURE__ */ new Map();
|
|
1268
1325
|
for (const file of files) {
|
|
1269
1326
|
const group = bySkill.get(file.skill);
|
|
@@ -1272,7 +1329,19 @@ async function updateSkills(dependencies) {
|
|
|
1272
1329
|
}
|
|
1273
1330
|
const inArchive = [...bySkill.keys()].sort();
|
|
1274
1331
|
const skipped = dependencies.restoreMissing ? [] : inArchive.filter((skill) => gone.has(skill));
|
|
1275
|
-
const
|
|
1332
|
+
const candidates = inArchive.filter((skill) => !skipped.includes(skill));
|
|
1333
|
+
const conflicts = [];
|
|
1334
|
+
for (const skill of candidates) {
|
|
1335
|
+
const owner = claimedElsewhere(skill);
|
|
1336
|
+
if (owner === void 0) continue;
|
|
1337
|
+
if (dependencies.restoreMissing) {
|
|
1338
|
+
throw new Error(
|
|
1339
|
+
`${owner} already installs a skill named "${skill}" in ${dependencies.directory}. Two repositories cannot both provide "${skill}" there \u2014 the second would silently overwrite the first. Install this one elsewhere (-p, or --dir <path>), or run \`pep skills remove ${owner}\` first.`
|
|
1340
|
+
);
|
|
1341
|
+
}
|
|
1342
|
+
conflicts.push({ skill, owner });
|
|
1343
|
+
}
|
|
1344
|
+
const skills = candidates.filter((skill) => !conflicts.some((one) => one.skill === skill));
|
|
1276
1345
|
const hashes = {};
|
|
1277
1346
|
const updated = [];
|
|
1278
1347
|
const unchanged = [];
|
|
@@ -1284,22 +1353,11 @@ async function updateSkills(dependencies) {
|
|
|
1284
1353
|
else if (before !== void 0 && before !== "" && before === hash) unchanged.push(skill);
|
|
1285
1354
|
else updated.push(skill);
|
|
1286
1355
|
}
|
|
1287
|
-
const conflicts = [];
|
|
1288
|
-
for (const skill of skills) {
|
|
1289
|
-
const owner = claimedElsewhere(skill);
|
|
1290
|
-
if (owner === void 0) continue;
|
|
1291
|
-
if (previous !== void 0 && skill in previous.skills) {
|
|
1292
|
-
conflicts.push({ skill, owner });
|
|
1293
|
-
continue;
|
|
1294
|
-
}
|
|
1295
|
-
throw new Error(
|
|
1296
|
-
`${owner} already installs a skill named "${skill}" in ${dependencies.directory}. Two repositories cannot both provide "${skill}" there \u2014 the second would silently overwrite the first. Install this one elsewhere (-p, or --dir <path>), or run \`pep skills remove ${owner}\` first.`
|
|
1297
|
-
);
|
|
1298
|
-
}
|
|
1299
1356
|
const removed = Object.keys(previous?.skills ?? {}).filter((skill) => !inArchive.includes(skill));
|
|
1300
1357
|
if (previous !== void 0) {
|
|
1301
1358
|
const stale = sameTarget ? removed : Object.keys(previous.skills);
|
|
1302
1359
|
for (const skill of stale) {
|
|
1360
|
+
if (otherClaimant(state?.packages ?? {}, name, previous.directory, skill)) continue;
|
|
1303
1361
|
await (0, import_promises4.rm)((0, import_node_path4.join)(previous.directory, skill), { recursive: true, force: true });
|
|
1304
1362
|
if (previous.linkedInto !== void 0) {
|
|
1305
1363
|
await (0, import_promises4.rm)((0, import_node_path4.join)(previous.linkedInto, skill), { recursive: true, force: true });
|
|
@@ -1309,9 +1367,11 @@ async function updateSkills(dependencies) {
|
|
|
1309
1367
|
for (const skill of skills) {
|
|
1310
1368
|
await (0, import_promises4.rm)((0, import_node_path4.join)(dependencies.directory, skill), { recursive: true, force: true });
|
|
1311
1369
|
}
|
|
1370
|
+
const untouched = /* @__PURE__ */ new Set([...skipped, ...conflicts.map((one) => one.skill)]);
|
|
1312
1371
|
for (const file of files) {
|
|
1313
|
-
if (
|
|
1372
|
+
if (untouched.has(file.skill)) continue;
|
|
1314
1373
|
const target = (0, import_node_path4.join)(dependencies.directory, file.skill, ...file.path.split("/"));
|
|
1374
|
+
assertInside(dependencies.directory, target);
|
|
1315
1375
|
await (0, import_promises4.mkdir)((0, import_node_path4.dirname)(target), { recursive: true });
|
|
1316
1376
|
await (0, import_promises4.writeFile)(target, file.data);
|
|
1317
1377
|
}
|
|
@@ -1337,7 +1397,12 @@ async function updateSkills(dependencies) {
|
|
|
1337
1397
|
...dependencies.linkInto !== void 0 ? { linkedInto: dependencies.linkInto } : {},
|
|
1338
1398
|
// 跳过的那些**原样留着上次的哈希** —— 账上仍然认它是这个包的(将来 add 能补回来,
|
|
1339
1399
|
// 上游删掉时也还清得动),只是这次没去碰它。
|
|
1340
|
-
|
|
1400
|
+
// 跳过的与让开的都**原样留着上次的哈希** —— 账上仍然认它是这个包的(将来 add
|
|
1401
|
+
// 能补回来、`remove` 也还清得动),只是这次没去碰它。
|
|
1402
|
+
skills: {
|
|
1403
|
+
...pickSkipped(previous, [...skipped, ...conflicts.map((one) => one.skill)]),
|
|
1404
|
+
...hashes
|
|
1405
|
+
}
|
|
1341
1406
|
}
|
|
1342
1407
|
}
|
|
1343
1408
|
});
|
|
@@ -1347,7 +1412,9 @@ async function updateSkills(dependencies) {
|
|
|
1347
1412
|
updated,
|
|
1348
1413
|
unchanged,
|
|
1349
1414
|
skills,
|
|
1350
|
-
|
|
1415
|
+
// ⚠ 扣掉的是 `untouched` 全部(用户删的 + 让给别的包的),不只是 skipped ——
|
|
1416
|
+
// 让开没写的那些文件算进去,「N file(s)」就是句假话。
|
|
1417
|
+
fileCount: files.filter((one) => !untouched.has(one.skill)).length,
|
|
1351
1418
|
removed,
|
|
1352
1419
|
skipped,
|
|
1353
1420
|
conflicts,
|
|
@@ -1359,7 +1426,7 @@ async function updateSkills(dependencies) {
|
|
|
1359
1426
|
}
|
|
1360
1427
|
|
|
1361
1428
|
// src/cli.ts
|
|
1362
|
-
var VERSION = false ? "0.0.0-dev" : "0.
|
|
1429
|
+
var VERSION = false ? "0.0.0-dev" : "0.7.0";
|
|
1363
1430
|
function usage() {
|
|
1364
1431
|
return `PEP CLI ${VERSION}
|
|
1365
1432
|
|
|
@@ -1421,8 +1488,10 @@ list. It only removes what it installed itself, and it needs no login. A skill t
|
|
|
1421
1488
|
repository also installs is left on disk (which copy it is can no longer be told) and reported.
|
|
1422
1489
|
|
|
1423
1490
|
Two repositories cannot both provide a skill with the SAME NAME in one directory \u2014 the skill name
|
|
1424
|
-
is the folder name inside the repository, so they would overwrite each other.
|
|
1425
|
-
|
|
1491
|
+
is the folder name inside the repository, so they would overwrite each other. add refuses, naming
|
|
1492
|
+
the repository already there. update never fails over it: it leaves that one skill untouched,
|
|
1493
|
+
refreshes everything else, and reports it every run until you remove one. Installing them into
|
|
1494
|
+
different directories (-p, or --dir) is fine.
|
|
1426
1495
|
|
|
1427
1496
|
Delete a skill folder by hand and update LEAVES IT DELETED \u2014 it refreshes what is still there and
|
|
1428
1497
|
reports the ones it left alone. Run \`pep skills add <repo>\` to put them back; add is the command
|
|
@@ -1715,7 +1784,10 @@ ${usage()}`);
|
|
|
1715
1784
|
);
|
|
1716
1785
|
}
|
|
1717
1786
|
const wanted = named.length > 0 ? named : installed;
|
|
1718
|
-
const targets = chosenTarget ? wanted.filter((one) =>
|
|
1787
|
+
const targets = chosenTarget ? wanted.filter((one) => {
|
|
1788
|
+
const at = state?.packages[one]?.directory;
|
|
1789
|
+
return at !== void 0 && samePlace(at, chosenTarget.directory);
|
|
1790
|
+
}) : wanted;
|
|
1719
1791
|
if (targets.length === 0) {
|
|
1720
1792
|
console.error(`No skills are installed in ${chosenTarget?.directory}.`);
|
|
1721
1793
|
console.error("`pep skills update` (no flag) refreshes every repository where it lives;");
|