@kisev/skills-opencode 2.4.1 → 3.0.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 +6 -4
- package/README.ru.md +7 -7
- package/dist/assets/agents/manager.md +9 -7
- package/dist/assets/migration-inventory.json +16 -10
- package/dist/catalog.d.ts +3 -3
- package/dist/catalog.js +2 -4
- package/dist/cli-output.d.ts +2 -0
- package/dist/cli-output.js +22 -4
- package/dist/cli.js +153 -126
- package/dist/doctor.js +0 -1
- package/dist/index.d.ts +0 -136
- package/dist/index.js +1 -95
- package/dist/installer.d.ts +0 -1
- package/dist/installer.js +15 -11
- package/dist/lifecycle.d.ts +14 -1
- package/dist/lifecycle.js +67 -24
- package/dist/reconcile.d.ts +43 -5
- package/dist/reconcile.js +539 -71
- package/dist/registry.d.ts +2 -4
- package/dist/registry.js +5 -43
- package/package.json +1 -1
- package/dist/assets/commands/agent-profiles.md +0 -9
- package/dist/assets/commands/capabilities.md +0 -9
- package/dist/assets/commands/doctor.md +0 -9
- package/dist/assets/commands/doit.md +0 -10
- package/dist/assets/commands/lsp-report.md +0 -10
- package/dist/assets/commands/reconcile.md +0 -9
package/dist/reconcile.js
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { lstat, opendir, readdir, readFile } from "node:fs/promises";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
5
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { applyTransaction, assertSafePath, consumeReceipt, destination, digest, LifecycleError, lifecycleRoot, recoverTransaction, saveReceipt, supersedeReceipt, sha256, stable, withLifecycleLock, } from "./lifecycle.js";
|
|
7
|
+
import { applyTransaction, archiveRoot, assertSafePath, consumeReceipt, destination, digest, LifecycleError, lifecycleRoot, recoverTransaction, saveReceipt, supersedeReceipt, sha256, stable, withLifecycleLock, } from "./lifecycle.js";
|
|
7
8
|
import { archiveMutations } from "./installer.js";
|
|
9
|
+
import { skillsInstallerSpec } from "./package-metadata.js";
|
|
8
10
|
const PACKAGE_NAME = "@kisev/skills-opencode";
|
|
9
11
|
const GENERIC_MANIFEST = ".skills-opencode-manifest.json";
|
|
10
12
|
const SEMANTIC_MANIFEST = ".skills-opencode/agent-profiles.manifest.json";
|
|
13
|
+
const PORTABLE_SOURCE = "https://kisev.github.io/skills";
|
|
14
|
+
const PROCESS_OUTPUT_LIMIT = 64 * 1024;
|
|
15
|
+
const PROCESS_TIMEOUT_MS = 120_000;
|
|
16
|
+
const PORTABLE_FILE_LIMIT = 4 * 1024 * 1024;
|
|
17
|
+
const PORTABLE_TREE_FILE_LIMIT = 256;
|
|
18
|
+
const PORTABLE_TREE_BYTE_LIMIT = 16 * 1024 * 1024;
|
|
19
|
+
const PORTABLE_TREE_ENTRY_LIMIT = 1_024;
|
|
20
|
+
const PORTABLE_TREE_DEPTH_LIMIT = 32;
|
|
11
21
|
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
22
|
const assetsRoot = resolve(packageRoot, "dist", "assets");
|
|
13
23
|
const inventoryPath = resolve(assetsRoot, "migration-inventory.json");
|
|
@@ -18,6 +28,13 @@ function loadInventory() {
|
|
|
18
28
|
if (value.schema_version !== 2 ||
|
|
19
29
|
typeof value.inventory_version !== "string" ||
|
|
20
30
|
!Array.isArray(value.active_portable_skills) ||
|
|
31
|
+
!Array.isArray(value.removed) ||
|
|
32
|
+
!value.renamed ||
|
|
33
|
+
typeof value.renamed !== "object" ||
|
|
34
|
+
Array.isArray(value.renamed) ||
|
|
35
|
+
!value.replacements ||
|
|
36
|
+
typeof value.replacements !== "object" ||
|
|
37
|
+
Array.isArray(value.replacements) ||
|
|
21
38
|
!Array.isArray(value.records)) {
|
|
22
39
|
throw new ReconcileError("invalid_inventory", "Migration inventory has an unsupported schema");
|
|
23
40
|
}
|
|
@@ -26,8 +43,46 @@ function loadInventory() {
|
|
|
26
43
|
function scopeRoot(scope, cwd, home) {
|
|
27
44
|
return scope === "global" ? resolve(home) : resolve(cwd);
|
|
28
45
|
}
|
|
29
|
-
function
|
|
30
|
-
|
|
46
|
+
function environmentRoot(name, home, fallback) {
|
|
47
|
+
const configured = home === homedir() ? process.env[name]?.trim() : undefined;
|
|
48
|
+
return resolve(configured || resolve(home, fallback));
|
|
49
|
+
}
|
|
50
|
+
function portableEnvironment(home) {
|
|
51
|
+
const xdgState = home === homedir() ? process.env.XDG_STATE_HOME?.trim() : undefined;
|
|
52
|
+
return {
|
|
53
|
+
HOME: resolve(home),
|
|
54
|
+
XDG_CONFIG_HOME: environmentRoot("XDG_CONFIG_HOME", home, ".config"),
|
|
55
|
+
...(xdgState ? { XDG_STATE_HOME: resolve(xdgState) } : {}),
|
|
56
|
+
CODEX_HOME: environmentRoot("CODEX_HOME", home, ".codex"),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function portableRoots(scope, cwd, home) {
|
|
60
|
+
if (scope === "project")
|
|
61
|
+
return [resolve(cwd, ".agents", "skills")];
|
|
62
|
+
const environment = portableEnvironment(home);
|
|
63
|
+
return [
|
|
64
|
+
resolve(home, ".agents", "skills"),
|
|
65
|
+
resolve(environment.XDG_CONFIG_HOME, "opencode", "skills"),
|
|
66
|
+
resolve(environment.CODEX_HOME, "skills"),
|
|
67
|
+
].filter((value, index, values) => values.indexOf(value) === index);
|
|
68
|
+
}
|
|
69
|
+
function reconcileAllowedRoots(scope, cwd, home) {
|
|
70
|
+
const environment = portableEnvironment(home);
|
|
71
|
+
const lock = scope === "global"
|
|
72
|
+
? environment.XDG_STATE_HOME
|
|
73
|
+
? resolve(environment.XDG_STATE_HOME, "skills")
|
|
74
|
+
: resolve(home, ".agents")
|
|
75
|
+
: resolve(cwd);
|
|
76
|
+
return [archiveRoot(scope, cwd, home), ...portableRoots(scope, cwd, home), lock];
|
|
77
|
+
}
|
|
78
|
+
function displayPath(root, target) {
|
|
79
|
+
const value = relative(resolve(root), resolve(target));
|
|
80
|
+
return !value.startsWith(`..${sep}`) && value !== ".." && !value.startsWith(sep)
|
|
81
|
+
? value.split(sep).join("/")
|
|
82
|
+
: resolve(target);
|
|
83
|
+
}
|
|
84
|
+
function safePortableName(name) {
|
|
85
|
+
return name.length <= 64 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name);
|
|
31
86
|
}
|
|
32
87
|
function relativePath(root, target) {
|
|
33
88
|
const value = relative(resolve(root), resolve(target));
|
|
@@ -78,21 +133,57 @@ async function filesUnder(root) {
|
|
|
78
133
|
if (info.isSymbolicLink() || !info.isDirectory())
|
|
79
134
|
return [{ relative: "", absolute: root, unsafe: true }];
|
|
80
135
|
const result = [];
|
|
81
|
-
|
|
82
|
-
|
|
136
|
+
let fileCount = 0;
|
|
137
|
+
let totalBytes = 0;
|
|
138
|
+
let entryCount = 0;
|
|
139
|
+
let exceeded = false;
|
|
140
|
+
const exceed = (absolute) => {
|
|
141
|
+
if (!exceeded)
|
|
142
|
+
result.push({ relative: relativePath(root, absolute), absolute, unsafe: true });
|
|
143
|
+
exceeded = true;
|
|
144
|
+
};
|
|
145
|
+
async function visit(directory, depth) {
|
|
146
|
+
if (exceeded)
|
|
147
|
+
return;
|
|
148
|
+
if (depth > PORTABLE_TREE_DEPTH_LIMIT) {
|
|
149
|
+
exceed(directory);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const entries = [];
|
|
153
|
+
const handle = await opendir(directory);
|
|
154
|
+
for await (const entry of handle) {
|
|
155
|
+
entryCount += 1;
|
|
156
|
+
if (entryCount > PORTABLE_TREE_ENTRY_LIMIT) {
|
|
157
|
+
exceed(directory);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
entries.push(entry);
|
|
161
|
+
}
|
|
162
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
163
|
+
if (exceeded)
|
|
164
|
+
return;
|
|
83
165
|
const absolute = join(directory, entry.name);
|
|
84
166
|
const child = await metadata(absolute);
|
|
85
167
|
if (!child || child.isSymbolicLink()) {
|
|
86
168
|
result.push({ relative: relativePath(root, absolute), absolute, unsafe: true });
|
|
87
169
|
}
|
|
88
170
|
else if (child.isDirectory()) {
|
|
89
|
-
await visit(absolute);
|
|
171
|
+
await visit(absolute, depth + 1);
|
|
90
172
|
}
|
|
91
173
|
else if (child.isFile() && child.nlink === 1) {
|
|
174
|
+
fileCount += 1;
|
|
175
|
+
totalBytes += Number(child.size);
|
|
176
|
+
if (fileCount > PORTABLE_TREE_FILE_LIMIT ||
|
|
177
|
+
Number(child.size) > PORTABLE_FILE_LIMIT ||
|
|
178
|
+
totalBytes > PORTABLE_TREE_BYTE_LIMIT) {
|
|
179
|
+
exceed(absolute);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
92
182
|
result.push({
|
|
93
183
|
relative: relativePath(root, absolute),
|
|
94
184
|
absolute,
|
|
95
185
|
content: await readFile(absolute),
|
|
186
|
+
mode: Number(child.mode) & 0o777,
|
|
96
187
|
});
|
|
97
188
|
}
|
|
98
189
|
else {
|
|
@@ -100,9 +191,92 @@ async function filesUnder(root) {
|
|
|
100
191
|
}
|
|
101
192
|
}
|
|
102
193
|
}
|
|
103
|
-
await visit(root);
|
|
194
|
+
await visit(root, 0);
|
|
104
195
|
return result;
|
|
105
196
|
}
|
|
197
|
+
function portableIdentity(content) {
|
|
198
|
+
const text = content.toString("utf8");
|
|
199
|
+
if (!text.startsWith("---\n"))
|
|
200
|
+
return undefined;
|
|
201
|
+
const end = text.indexOf("\n---\n", 4);
|
|
202
|
+
if (end < 0)
|
|
203
|
+
return undefined;
|
|
204
|
+
const lines = text.slice(4, end).split("\n");
|
|
205
|
+
const scalar = (value) => {
|
|
206
|
+
const trimmed = value.trim();
|
|
207
|
+
if (!trimmed || trimmed.startsWith("[") || trimmed.startsWith("{"))
|
|
208
|
+
return undefined;
|
|
209
|
+
const first = trimmed[0];
|
|
210
|
+
const last = trimmed.at(-1);
|
|
211
|
+
if (first === "'" || first === '"') {
|
|
212
|
+
if (last !== first || trimmed.length < 2)
|
|
213
|
+
return undefined;
|
|
214
|
+
const inner = trimmed.slice(1, -1);
|
|
215
|
+
return inner.includes(first) ? undefined : inner;
|
|
216
|
+
}
|
|
217
|
+
return last === "'" || last === '"' ? undefined : trimmed;
|
|
218
|
+
};
|
|
219
|
+
const allowed = new Set([
|
|
220
|
+
"name",
|
|
221
|
+
"description",
|
|
222
|
+
"license",
|
|
223
|
+
"compatibility",
|
|
224
|
+
"metadata",
|
|
225
|
+
"allowed-tools",
|
|
226
|
+
]);
|
|
227
|
+
const fields = new Map();
|
|
228
|
+
const metadata = new Map();
|
|
229
|
+
let current = "";
|
|
230
|
+
let block = false;
|
|
231
|
+
for (const line of lines) {
|
|
232
|
+
if (!line)
|
|
233
|
+
continue;
|
|
234
|
+
if (!line.startsWith(" ")) {
|
|
235
|
+
const match = /^([a-z][a-z0-9-]*):\s*(.*)$/.exec(line);
|
|
236
|
+
if (!match || !allowed.has(match[1]) || fields.has(match[1]))
|
|
237
|
+
return undefined;
|
|
238
|
+
current = match[1];
|
|
239
|
+
const value = match[2];
|
|
240
|
+
block = value === ">-" || value === "|" || value === "|-";
|
|
241
|
+
if (current === "metadata") {
|
|
242
|
+
if (value || block)
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
else if (!block && scalar(value) === undefined)
|
|
246
|
+
return undefined;
|
|
247
|
+
fields.set(current, value);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (current === "metadata") {
|
|
251
|
+
const match = /^ ([a-z][a-z0-9-]*):\s*(.*)$/.exec(line);
|
|
252
|
+
if (!match || metadata.has(match[1]))
|
|
253
|
+
return undefined;
|
|
254
|
+
const value = scalar(match[2]);
|
|
255
|
+
if (value === undefined)
|
|
256
|
+
return undefined;
|
|
257
|
+
metadata.set(match[1], value);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (!block || !/^ \S/.test(line))
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
if (!["name", "description", "license", "metadata"].every((key) => fields.has(key)))
|
|
264
|
+
return undefined;
|
|
265
|
+
const name = scalar(fields.get("name"));
|
|
266
|
+
const source = metadata.get("source");
|
|
267
|
+
return name ? { name, ...(source ? { source } : {}) } : undefined;
|
|
268
|
+
}
|
|
269
|
+
function treeDigest(files) {
|
|
270
|
+
return digest({
|
|
271
|
+
schema_version: 1,
|
|
272
|
+
files: files.map((file) => ({
|
|
273
|
+
path: file.relative,
|
|
274
|
+
mode: file.mode,
|
|
275
|
+
size: file.content?.length,
|
|
276
|
+
sha256: file.content ? sha256(file.content) : undefined,
|
|
277
|
+
})),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
106
280
|
function item(path, status, reason, content, replacement) {
|
|
107
281
|
return {
|
|
108
282
|
path,
|
|
@@ -164,24 +338,136 @@ function retiredRecords(inventory, scope) {
|
|
|
164
338
|
}));
|
|
165
339
|
return [...inventory.records, ...commandRecords].filter((record) => record.retired && record.scopes.includes(scope));
|
|
166
340
|
}
|
|
167
|
-
function diagnostic(
|
|
168
|
-
const
|
|
341
|
+
async function diagnostic(home) {
|
|
342
|
+
const base = process.env.XDG_STATE_HOME && home === homedir()
|
|
343
|
+
? resolve(process.env.XDG_STATE_HOME)
|
|
344
|
+
: resolve(home, ".local", "state");
|
|
345
|
+
const result = [];
|
|
346
|
+
for (const name of ["goal", "multi-run"]) {
|
|
347
|
+
const path = resolve(base, "opencode", "skills", name);
|
|
348
|
+
if (await metadata(path))
|
|
349
|
+
result.push(item(path, "diagnostic-state-only", "runtime state is not inspected or migrated"));
|
|
350
|
+
}
|
|
351
|
+
return result;
|
|
352
|
+
}
|
|
353
|
+
function portableRemoveCommand(scope, names) {
|
|
169
354
|
return [
|
|
170
|
-
|
|
171
|
-
|
|
355
|
+
"npx",
|
|
356
|
+
"--yes",
|
|
357
|
+
skillsInstallerSpec(),
|
|
358
|
+
"remove",
|
|
359
|
+
...names,
|
|
360
|
+
"--agent",
|
|
361
|
+
"opencode",
|
|
362
|
+
"--agent",
|
|
363
|
+
"codex",
|
|
364
|
+
...(scope === "global" ? ["--global"] : []),
|
|
365
|
+
"--yes",
|
|
172
366
|
];
|
|
173
367
|
}
|
|
368
|
+
function boundedAppend(current, chunk) {
|
|
369
|
+
if (current.length >= PROCESS_OUTPUT_LIMIT)
|
|
370
|
+
return { value: current, truncated: true };
|
|
371
|
+
const remaining = PROCESS_OUTPUT_LIMIT - current.length;
|
|
372
|
+
return {
|
|
373
|
+
value: Buffer.concat([current, chunk.subarray(0, remaining)]),
|
|
374
|
+
truncated: chunk.length > remaining,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
async function runPortableRemove(command, cwd, environment) {
|
|
378
|
+
return new Promise((resolvePromise, reject) => {
|
|
379
|
+
const windows = process.platform === "win32";
|
|
380
|
+
if (windows && command.some((value) => !/^[A-Za-z0-9_./:@=-]+$/.test(value))) {
|
|
381
|
+
reject(new ReconcileError("invalid_plan", "Portable remover has an unsafe Windows argument"));
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const executable = windows ? (process.env.ComSpec ?? "cmd.exe") : command[0];
|
|
385
|
+
const arguments_ = windows
|
|
386
|
+
? ["/d", "/s", "/c", `npx.cmd ${command.slice(1).join(" ")}`]
|
|
387
|
+
: command.slice(1);
|
|
388
|
+
const child = spawn(executable, arguments_, {
|
|
389
|
+
cwd,
|
|
390
|
+
env: environment,
|
|
391
|
+
shell: false,
|
|
392
|
+
detached: !windows,
|
|
393
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
394
|
+
});
|
|
395
|
+
let stdout = Buffer.alloc(0);
|
|
396
|
+
let stderr = Buffer.alloc(0);
|
|
397
|
+
let stdoutTruncated = false;
|
|
398
|
+
let stderrTruncated = false;
|
|
399
|
+
let timedOut = false;
|
|
400
|
+
let forceTimeout;
|
|
401
|
+
const terminate = (signal) => {
|
|
402
|
+
if (!windows && child.pid) {
|
|
403
|
+
try {
|
|
404
|
+
process.kill(-child.pid, signal);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
catch { }
|
|
408
|
+
}
|
|
409
|
+
if (windows && child.pid) {
|
|
410
|
+
const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
|
|
411
|
+
stdio: "ignore",
|
|
412
|
+
windowsHide: true,
|
|
413
|
+
});
|
|
414
|
+
killer.on("error", () => undefined);
|
|
415
|
+
}
|
|
416
|
+
child.kill(signal);
|
|
417
|
+
};
|
|
418
|
+
const timeout = setTimeout(() => {
|
|
419
|
+
timedOut = true;
|
|
420
|
+
terminate("SIGTERM");
|
|
421
|
+
forceTimeout = setTimeout(() => terminate("SIGKILL"), 5_000);
|
|
422
|
+
}, PROCESS_TIMEOUT_MS);
|
|
423
|
+
child.stdout.on("data", (chunk) => {
|
|
424
|
+
const next = boundedAppend(stdout, chunk);
|
|
425
|
+
stdout = next.value;
|
|
426
|
+
stdoutTruncated ||= next.truncated;
|
|
427
|
+
});
|
|
428
|
+
child.stderr.on("data", (chunk) => {
|
|
429
|
+
const next = boundedAppend(stderr, chunk);
|
|
430
|
+
stderr = next.value;
|
|
431
|
+
stderrTruncated ||= next.truncated;
|
|
432
|
+
});
|
|
433
|
+
child.once("error", (error) => {
|
|
434
|
+
clearTimeout(timeout);
|
|
435
|
+
if (forceTimeout)
|
|
436
|
+
clearTimeout(forceTimeout);
|
|
437
|
+
reject(error);
|
|
438
|
+
});
|
|
439
|
+
child.once("close", (code, signal) => {
|
|
440
|
+
clearTimeout(timeout);
|
|
441
|
+
if (forceTimeout)
|
|
442
|
+
clearTimeout(forceTimeout);
|
|
443
|
+
resolvePromise({
|
|
444
|
+
code,
|
|
445
|
+
signal,
|
|
446
|
+
stdout: stdout.toString("utf8"),
|
|
447
|
+
stderr: stderr.toString("utf8"),
|
|
448
|
+
stdout_truncated: stdoutTruncated,
|
|
449
|
+
stderr_truncated: stderrTruncated,
|
|
450
|
+
timed_out: timedOut,
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
}
|
|
174
455
|
async function build(scope, cwd = process.cwd(), home = homedir()) {
|
|
175
456
|
const inventory = loadInventory();
|
|
457
|
+
const retiredPortableNames = new Set([
|
|
458
|
+
...inventory.removed,
|
|
459
|
+
...Object.keys(inventory.renamed),
|
|
460
|
+
...Object.keys(inventory.replacements),
|
|
461
|
+
]);
|
|
176
462
|
const root = scopeRoot(scope, cwd, home);
|
|
177
463
|
const deployment = resolve(root, scope === "global" ? ".config/opencode" : ".opencode");
|
|
178
|
-
const
|
|
464
|
+
const portableLocations = portableRoots(scope, cwd, home);
|
|
465
|
+
const portableEnv = portableEnvironment(home);
|
|
179
466
|
const currentAssets = await packageAssets();
|
|
180
467
|
const retired = retiredRecords(inventory, scope);
|
|
181
468
|
const groups = {
|
|
182
469
|
current: [],
|
|
183
470
|
retired: [],
|
|
184
|
-
"archive-pending": [],
|
|
185
471
|
renamed: [],
|
|
186
472
|
"modified-managed": [],
|
|
187
473
|
"user-owned": [],
|
|
@@ -190,7 +476,9 @@ async function build(scope, cwd = process.cwd(), home = homedir()) {
|
|
|
190
476
|
"diagnostic-state-only": [],
|
|
191
477
|
};
|
|
192
478
|
const mutations = [];
|
|
479
|
+
const portableMutationTargets = new Set();
|
|
193
480
|
const archiveCandidates = [];
|
|
481
|
+
const portableTrees = [];
|
|
194
482
|
const managed = new Map();
|
|
195
483
|
const manifestPath = join(deployment, GENERIC_MANIFEST);
|
|
196
484
|
const manifestValue = await regular(manifestPath);
|
|
@@ -226,9 +514,10 @@ async function build(scope, cwd = process.cwd(), home = homedir()) {
|
|
|
226
514
|
else if (value &&
|
|
227
515
|
record.sha256 === sha256(value.content) &&
|
|
228
516
|
(!manifestRecord || manifestRecord.sha256 === sha256(value.content))) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
517
|
+
const status = record.replacement ? "renamed" : "retired";
|
|
518
|
+
add(groups, item(relativePath(root, target), status, record.replacement
|
|
519
|
+
? "exact historical SHA-256 proves renamed package ownership"
|
|
520
|
+
: "exact historical SHA-256 proves retired package ownership", value.content, record.replacement));
|
|
232
521
|
const kind = record.kind === "package-command"
|
|
233
522
|
? "command"
|
|
234
523
|
: record.kind === "package-plugin"
|
|
@@ -248,7 +537,8 @@ async function build(scope, cwd = process.cwd(), home = homedir()) {
|
|
|
248
537
|
});
|
|
249
538
|
}
|
|
250
539
|
else if (!value && manifestRecord && record.sha256 === manifestRecord.sha256) {
|
|
251
|
-
|
|
540
|
+
const status = record.replacement ? "renamed" : "retired";
|
|
541
|
+
add(groups, item(relativePath(root, target), status, "stale exact package ownership record", undefined, record.replacement));
|
|
252
542
|
}
|
|
253
543
|
else if (value) {
|
|
254
544
|
add(groups, item(relativePath(root, target), "conflict", "retired asset is modified or ownership is ambiguous", value.content));
|
|
@@ -354,36 +644,151 @@ async function build(scope, cwd = process.cwd(), home = homedir()) {
|
|
|
354
644
|
add(groups, item(relativePath(root, target), "user-owned", "agent has no semantic ownership record", value.content));
|
|
355
645
|
}
|
|
356
646
|
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
647
|
+
const installed = new Map();
|
|
648
|
+
for (const portableRoot of portableLocations) {
|
|
649
|
+
const portableInfo = await metadata(portableRoot);
|
|
650
|
+
if (portableInfo?.isSymbolicLink() || (portableInfo && !portableInfo.isDirectory())) {
|
|
651
|
+
add(groups, item(displayPath(root, portableRoot), "conflict", "portable skill root is a symlink or non-directory"));
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
for (const entry of portableInfo?.isDirectory() ? (await readdir(portableRoot)).sort() : []) {
|
|
655
|
+
const directory = resolve(portableRoot, entry);
|
|
656
|
+
const directoryInfo = await metadata(directory);
|
|
657
|
+
if (!directoryInfo?.isDirectory() || directoryInfo.isSymbolicLink()) {
|
|
658
|
+
add(groups, item(displayPath(root, directory), "unknown", "portable skill entry is an external symlink or non-directory"));
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
const values = installed.get(entry) ?? [];
|
|
662
|
+
values.push({ directory, portableRoot });
|
|
663
|
+
installed.set(entry, values);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
for (const [entry, locations] of [...installed].sort(([left], [right]) => left.localeCompare(right))) {
|
|
667
|
+
if (inventory.active_portable_skills.includes(entry)) {
|
|
668
|
+
for (const { directory } of locations)
|
|
669
|
+
add(groups, item(displayPath(root, directory), "current", "current portable skill source"));
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
if (!safePortableName(entry)) {
|
|
673
|
+
for (const { directory } of locations)
|
|
674
|
+
add(groups, item(displayPath(root, directory), "conflict", "portable skill name is unsafe for delegated removal"));
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
const candidates = [];
|
|
678
|
+
let blocked = false;
|
|
679
|
+
for (const { directory, portableRoot } of locations) {
|
|
680
|
+
const path = displayPath(root, directory);
|
|
681
|
+
const skill = await regular(resolve(directory, "SKILL.md"));
|
|
682
|
+
if (!skill || "unsafe" in skill) {
|
|
683
|
+
add(groups, item(path, "unknown", "portable skill has no safe SKILL.md ownership marker"));
|
|
684
|
+
blocked = true;
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
const identity = portableIdentity(skill.content);
|
|
688
|
+
if (identity?.name !== entry || identity.source !== PORTABLE_SOURCE) {
|
|
689
|
+
add(groups, item(path, "unknown", "portable skill is outside the marked project inventory"));
|
|
690
|
+
blocked = true;
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
const files = await filesUnder(directory);
|
|
694
|
+
if (!files.length ||
|
|
695
|
+
files.some((file) => file.unsafe || !file.content || file.mode === undefined)) {
|
|
696
|
+
add(groups, item(path, "conflict", "marked portable skill contains an unsafe file"));
|
|
697
|
+
blocked = true;
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
candidates.push({ directory, portableRoot, files });
|
|
701
|
+
}
|
|
702
|
+
if (blocked)
|
|
703
|
+
continue;
|
|
704
|
+
if (!retiredPortableNames.has(entry)) {
|
|
705
|
+
for (const candidate of candidates)
|
|
706
|
+
add(groups, item(displayPath(root, candidate.directory), "unknown", "marked portable skill is not declared retired by this package inventory"));
|
|
707
|
+
continue;
|
|
365
708
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
709
|
+
const replacement = inventory.renamed[entry];
|
|
710
|
+
const status = replacement ? "renamed" : "retired";
|
|
711
|
+
for (const candidate of candidates) {
|
|
712
|
+
const path = displayPath(root, candidate.directory);
|
|
713
|
+
add(groups, item(path, status, replacement
|
|
714
|
+
? "project source marker proves renamed portable ownership"
|
|
715
|
+
: "project source marker proves retired portable ownership", undefined, replacement));
|
|
716
|
+
portableTrees.push({
|
|
717
|
+
name: entry,
|
|
718
|
+
path,
|
|
719
|
+
root: candidate.portableRoot,
|
|
720
|
+
tree_sha256: treeDigest(candidate.files),
|
|
721
|
+
files: candidate.files.length,
|
|
722
|
+
});
|
|
723
|
+
for (const file of candidate.files) {
|
|
724
|
+
const target = `${entry}/${file.relative}`;
|
|
370
725
|
archiveCandidates.push({
|
|
371
|
-
path:
|
|
372
|
-
record: {
|
|
726
|
+
path: displayPath(root, file.absolute),
|
|
727
|
+
record: {
|
|
728
|
+
sha256: sha256(file.content),
|
|
729
|
+
mode: file.mode,
|
|
730
|
+
kind: "state",
|
|
731
|
+
},
|
|
373
732
|
content: file.content,
|
|
374
|
-
reason:
|
|
733
|
+
reason: `${status} portable skill`,
|
|
375
734
|
kind: "state",
|
|
376
735
|
});
|
|
377
736
|
mutations.push({
|
|
378
|
-
|
|
379
|
-
|
|
737
|
+
root: candidate.portableRoot,
|
|
738
|
+
path: target,
|
|
739
|
+
operation: "external-remove",
|
|
380
740
|
expected: { sha256: sha256(file.content) },
|
|
381
741
|
});
|
|
742
|
+
portableMutationTargets.add(file.absolute);
|
|
382
743
|
}
|
|
383
744
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
745
|
+
}
|
|
746
|
+
const portableNames = [...new Set(portableTrees.map((tree) => tree.name))].sort();
|
|
747
|
+
const locks = [];
|
|
748
|
+
if (portableNames.length) {
|
|
749
|
+
const lockPath = scope === "global"
|
|
750
|
+
? portableEnv.XDG_STATE_HOME
|
|
751
|
+
? resolve(portableEnv.XDG_STATE_HOME, "skills", ".skill-lock.json")
|
|
752
|
+
: resolve(home, ".agents", ".skill-lock.json")
|
|
753
|
+
: resolve(root, "skills-lock.json");
|
|
754
|
+
const lock = await regular(lockPath);
|
|
755
|
+
if (lock && "unsafe" in lock) {
|
|
756
|
+
add(groups, item(lockPath, "conflict", "portable installer lock is unsafe"));
|
|
757
|
+
}
|
|
758
|
+
else if (lock) {
|
|
759
|
+
try {
|
|
760
|
+
const parsed = JSON.parse(lock.content.toString("utf8"));
|
|
761
|
+
if (typeof parsed.version !== "number" || !parsed.skills || Array.isArray(parsed.skills))
|
|
762
|
+
throw new Error("unsupported lock");
|
|
763
|
+
if ((scope === "global" && parsed.version < 3) ||
|
|
764
|
+
(scope === "project" && parsed.version < 1))
|
|
765
|
+
throw new Error("unsupported lock version");
|
|
766
|
+
const tracked = portableNames.filter((name) => Object.hasOwn(parsed.skills, name));
|
|
767
|
+
if (tracked.length) {
|
|
768
|
+
const remaining = Object.fromEntries(Object.entries(parsed.skills).filter(([name]) => !portableNames.includes(name)));
|
|
769
|
+
const next = Buffer.from(scope === "global"
|
|
770
|
+
? JSON.stringify({ ...parsed, skills: remaining }, null, 2)
|
|
771
|
+
: `${JSON.stringify({ version: parsed.version, skills: Object.fromEntries(Object.entries(remaining).sort(([left], [right]) => left.localeCompare(right))) }, null, 2)}\n`);
|
|
772
|
+
const lockRoot = resolve(dirname(lockPath));
|
|
773
|
+
mutations.push({
|
|
774
|
+
root: lockRoot,
|
|
775
|
+
path: lockPath.slice(lockRoot.length + 1),
|
|
776
|
+
operation: "external-write",
|
|
777
|
+
content: next,
|
|
778
|
+
mode: (await lstat(lockPath)).mode & 0o777,
|
|
779
|
+
expected: { sha256: sha256(lock.content) },
|
|
780
|
+
});
|
|
781
|
+
portableMutationTargets.add(lockPath);
|
|
782
|
+
locks.push({
|
|
783
|
+
path: lockPath,
|
|
784
|
+
before_sha256: sha256(lock.content),
|
|
785
|
+
after_sha256: sha256(next),
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
add(groups, item(lockPath, "conflict", "portable installer lock is not valid JSON"));
|
|
791
|
+
}
|
|
387
792
|
}
|
|
388
793
|
}
|
|
389
794
|
if (archiveCandidates.length && manifestRaw) {
|
|
@@ -393,53 +798,82 @@ async function build(scope, cwd = process.cwd(), home = homedir()) {
|
|
|
393
798
|
}
|
|
394
799
|
const value = JSON.parse(manifestRaw.toString("utf8"));
|
|
395
800
|
const next = Buffer.from(`${stable({ ...value, files: manifestFiles })}\n`);
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
801
|
+
if (!next.equals(manifestRaw))
|
|
802
|
+
mutations.push({
|
|
803
|
+
path: relativePath(root, manifestPath),
|
|
804
|
+
operation: "write",
|
|
805
|
+
content: next,
|
|
806
|
+
mode: 0o600,
|
|
807
|
+
expected: { sha256: sha256(manifestRaw) },
|
|
808
|
+
});
|
|
403
809
|
}
|
|
404
810
|
mutations.push(...(await archiveMutations(archiveCandidates, scope, cwd, home, inventory.inventory_version)));
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
operation: mutation.operation,
|
|
421
|
-
...(mutation.operation === "write" ? { sha256: sha256(mutation.content) } : {}),
|
|
811
|
+
const command = portableRemoveCommand(scope, portableNames);
|
|
812
|
+
const operations = mutations
|
|
813
|
+
.filter((mutation) => !portableMutationTargets.has(destination(resolve(mutation.root ?? root), mutation.path)))
|
|
814
|
+
.map((mutation) => ({
|
|
815
|
+
path: mutation.root ? resolve(mutation.root, mutation.path) : mutation.path,
|
|
816
|
+
operation: mutation.root
|
|
817
|
+
? "archive"
|
|
818
|
+
: mutation.operation === "external-write"
|
|
819
|
+
? "write"
|
|
820
|
+
: mutation.operation === "external-remove"
|
|
821
|
+
? "remove"
|
|
822
|
+
: mutation.operation,
|
|
823
|
+
...(mutation.operation === "write" || mutation.operation === "external-write"
|
|
824
|
+
? { sha256: sha256(mutation.content) }
|
|
825
|
+
: {}),
|
|
422
826
|
}));
|
|
827
|
+
for (const tree of portableTrees) {
|
|
828
|
+
operations.push({ path: tree.path, operation: "archive", sha256: tree.tree_sha256 });
|
|
829
|
+
operations.push({ path: tree.path, operation: "remove", via: "skills-cli" });
|
|
830
|
+
}
|
|
831
|
+
for (const lock of locks)
|
|
832
|
+
operations.push({
|
|
833
|
+
path: lock.path,
|
|
834
|
+
operation: "write",
|
|
835
|
+
sha256: lock.after_sha256,
|
|
836
|
+
via: "skills-cli",
|
|
837
|
+
});
|
|
838
|
+
if (portableNames.length)
|
|
839
|
+
operations.push({ path: command[0], operation: "execute", via: "skills-cli" });
|
|
423
840
|
const base = {
|
|
424
|
-
schema_version:
|
|
841
|
+
schema_version: 2,
|
|
425
842
|
domain: "reconcile",
|
|
426
843
|
scope,
|
|
427
844
|
root,
|
|
428
845
|
inventory_version: inventory.inventory_version,
|
|
429
846
|
current: groups.current.sort((left, right) => left.path.localeCompare(right.path)),
|
|
430
847
|
retired: groups.retired.sort((left, right) => left.path.localeCompare(right.path)),
|
|
431
|
-
"archive-pending": groups["archive-pending"].sort((left, right) => left.path.localeCompare(right.path)),
|
|
432
848
|
renamed: groups.renamed.sort((left, right) => left.path.localeCompare(right.path)),
|
|
433
849
|
modified_managed: groups["modified-managed"].sort((left, right) => left.path.localeCompare(right.path)),
|
|
434
850
|
user_owned: groups["user-owned"].sort((left, right) => left.path.localeCompare(right.path)),
|
|
435
851
|
unknown: groups.unknown.sort((left, right) => left.path.localeCompare(right.path)),
|
|
436
852
|
conflicts: groups.conflict.sort((left, right) => left.path.localeCompare(right.path)),
|
|
437
|
-
diagnostic_state_only: diagnostic(
|
|
853
|
+
diagnostic_state_only: await diagnostic(home),
|
|
438
854
|
operations: operations.sort((left, right) => left.path.localeCompare(right.path)),
|
|
855
|
+
...(portableNames.length
|
|
856
|
+
? {
|
|
857
|
+
portable_cleanup: {
|
|
858
|
+
source: PORTABLE_SOURCE,
|
|
859
|
+
names: portableNames,
|
|
860
|
+
command,
|
|
861
|
+
cwd: root,
|
|
862
|
+
environment: portableEnv,
|
|
863
|
+
locks,
|
|
864
|
+
trees: portableTrees
|
|
865
|
+
.map(({ root: _root, ...tree }) => tree)
|
|
866
|
+
.sort((left, right) => left.path.localeCompare(right.path)),
|
|
867
|
+
},
|
|
868
|
+
}
|
|
869
|
+
: {}),
|
|
439
870
|
};
|
|
440
871
|
const planDigest = digest(base);
|
|
872
|
+
const confirmable = operations.length > 0 &&
|
|
873
|
+
groups["modified-managed"].length === 0 &&
|
|
874
|
+
groups.conflict.length === 0;
|
|
441
875
|
return {
|
|
442
|
-
plan: { ...base, plan_digest: planDigest, confirmable
|
|
876
|
+
plan: { ...base, plan_digest: planDigest, confirmable, digest: planDigest },
|
|
443
877
|
mutations,
|
|
444
878
|
};
|
|
445
879
|
}
|
|
@@ -448,11 +882,12 @@ export async function previewReconcile(scope, cwd = process.cwd(), home = homedi
|
|
|
448
882
|
const root = scopeRoot(scope, cwd, home);
|
|
449
883
|
try {
|
|
450
884
|
return await withLifecycleLock(stateRoot, async () => {
|
|
451
|
-
if (await recoverTransaction(root, stateRoot))
|
|
885
|
+
if (await recoverTransaction(root, stateRoot, reconcileAllowedRoots(scope, cwd, home)))
|
|
452
886
|
throw new ReconcileError("recovered_transaction", "Recovered an interrupted transaction; request a fresh plan");
|
|
453
887
|
const built = await build(scope, cwd, home);
|
|
454
888
|
const blocked = built.plan.modified_managed.length > 0 || built.plan.conflicts.length > 0;
|
|
455
|
-
|
|
889
|
+
const actionable = built.plan.operations.length > 0;
|
|
890
|
+
if (blocked || !actionable) {
|
|
456
891
|
await supersedeReceipt(stateRoot);
|
|
457
892
|
const { digest: _digest, ...withoutReceipt } = built.plan;
|
|
458
893
|
return { ...withoutReceipt, confirmable: false };
|
|
@@ -488,7 +923,7 @@ export async function applyReconcile(scope, confirmationDigest, cwd = process.cw
|
|
|
488
923
|
const root = scopeRoot(scope, cwd, home);
|
|
489
924
|
try {
|
|
490
925
|
return await withLifecycleLock(stateRoot, async () => {
|
|
491
|
-
if (await recoverTransaction(root, stateRoot))
|
|
926
|
+
if (await recoverTransaction(root, stateRoot, reconcileAllowedRoots(scope, cwd, home)))
|
|
492
927
|
throw new ReconcileError("recovered_transaction", "Recovered an interrupted transaction; request a fresh plan");
|
|
493
928
|
const payload = (await consumeReceipt(stateRoot, {
|
|
494
929
|
digest: confirmationDigest,
|
|
@@ -501,18 +936,51 @@ export async function applyReconcile(scope, confirmationDigest, cwd = process.cw
|
|
|
501
936
|
throw new ReconcileError("stale_plan", "Reconcile inventory changed after preview");
|
|
502
937
|
if (built.plan.conflicts.length || built.plan.modified_managed.length)
|
|
503
938
|
throw new ReconcileError("conflict", "Reconcile contains unsafe ownership conflicts");
|
|
939
|
+
const cleanup = built.plan.portable_cleanup;
|
|
940
|
+
let portableRemove;
|
|
504
941
|
await applyTransaction(root, stateRoot, built.mutations, {
|
|
505
942
|
...options,
|
|
943
|
+
applyExternal: async () => {
|
|
944
|
+
await options.applyExternal?.();
|
|
945
|
+
if (!cleanup)
|
|
946
|
+
return;
|
|
947
|
+
const environment = {
|
|
948
|
+
...process.env,
|
|
949
|
+
...cleanup.environment,
|
|
950
|
+
DO_NOT_TRACK: "1",
|
|
951
|
+
};
|
|
952
|
+
if (!cleanup.environment.XDG_STATE_HOME)
|
|
953
|
+
delete environment.XDG_STATE_HOME;
|
|
954
|
+
const result = await (options.runPortableRemove ?? runPortableRemove)(cleanup.command, cleanup.cwd, environment);
|
|
955
|
+
portableRemove = result;
|
|
956
|
+
if (result.code !== 0 || result.signal || result.timed_out)
|
|
957
|
+
throw new ReconcileError("portable_remove_failed", `skills remove failed with ${result.signal ?? `exit ${result.code}`}`);
|
|
958
|
+
},
|
|
506
959
|
validateFinal: async () => {
|
|
507
960
|
await options.validateFinal?.();
|
|
961
|
+
if (cleanup) {
|
|
962
|
+
for (const tree of cleanup.trees)
|
|
963
|
+
if (await metadata(resolve(root, tree.path)))
|
|
964
|
+
throw new ReconcileError("final_validation_failed", `skills remove retained portable skill: ${tree.path}`);
|
|
965
|
+
for (const expected of cleanup.locks) {
|
|
966
|
+
const lock = await regular(expected.path);
|
|
967
|
+
if (!lock || "unsafe" in lock || sha256(lock.content) !== expected.after_sha256)
|
|
968
|
+
throw new ReconcileError("final_validation_failed", `skills remove left an invalid lock: ${expected.path}`);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
508
971
|
const final = await build(scope, cwd, home);
|
|
509
972
|
if (final.plan.retired.length ||
|
|
510
|
-
final.plan
|
|
973
|
+
final.plan.renamed.length ||
|
|
511
974
|
final.plan.operations.length)
|
|
512
975
|
throw new ReconcileError("final_validation_failed", "Retired assets remain after reconcile");
|
|
513
976
|
},
|
|
514
977
|
});
|
|
515
|
-
return {
|
|
978
|
+
return {
|
|
979
|
+
status: "ok",
|
|
980
|
+
applied: true,
|
|
981
|
+
plan: { ...built.plan, digest: confirmationDigest },
|
|
982
|
+
...(portableRemove ? { portable_remove: portableRemove } : {}),
|
|
983
|
+
};
|
|
516
984
|
});
|
|
517
985
|
}
|
|
518
986
|
catch (error) {
|