@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/dist/reconcile.js CHANGED
@@ -1,13 +1,23 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { lstat, readdir, readFile } from "node:fs/promises";
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 portableRoot(scope, cwd, home) {
30
- return resolve(scopeRoot(scope, cwd, home), ".agents", "skills");
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
- async function visit(directory) {
82
- for (const entry of (await readdir(directory, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name))) {
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(scope, cwd, home) {
168
- const state = lifecycleRoot(scope, cwd, home);
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
- item(`${state}/goal`, "diagnostic-state-only", "runtime state is not inspected or migrated"),
171
- item(`${state}/multi-run`, "diagnostic-state-only", "runtime state is not inspected or migrated"),
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 portable = portableRoot(scope, cwd, home);
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
- add(groups, item(relativePath(root, target), "archive-pending", record.replacement
230
- ? "exact historical SHA-256 proves renamed ownership; archive lifecycle is pending"
231
- : "exact historical SHA-256 proves retired public ownership; archive lifecycle is pending", value.content, record.replacement));
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
- add(groups, item(relativePath(root, target), "archive-pending", "stale exact ownership record; archive lifecycle is pending", undefined, record.replacement));
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
- for (const record of retired.filter((value) => value.kind === "portable-skill")) {
358
- const directory = resolve(portable, record.installed_path.replace(/^skills\//, ""));
359
- const files = await filesUnder(directory);
360
- const expected = record.files ?? {};
361
- const safe = files.length === Object.keys(expected).length &&
362
- files.every((file) => !file.unsafe && expected[file.relative] === sha256(file.content));
363
- if (files.some((file) => file.unsafe)) {
364
- add(groups, item(relativePath(root, directory), "conflict", "retired skill contains a symlink or unsafe file"));
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
- else if (safe) {
367
- for (const file of files) {
368
- const target = relativePath(root, file.absolute);
369
- add(groups, item(target, "archive-pending", "exact historical SHA-256 proves retired portable ownership; archive lifecycle is pending", file.content));
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: relativePath(root, file.absolute),
372
- record: { sha256: sha256(file.content), mode: 0o644, kind: "state" },
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: "retired portable skill",
733
+ reason: `${status} portable skill`,
375
734
  kind: "state",
376
735
  });
377
736
  mutations.push({
378
- path: relativePath(root, file.absolute),
379
- operation: "remove",
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
- else if (files.length) {
385
- for (const file of files)
386
- add(groups, item(relativePath(root, file.absolute), "conflict", "retired skill is modified or ownership is ambiguous", file.content));
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
- mutations.push({
397
- path: relativePath(root, manifestPath),
398
- operation: "write",
399
- content: next,
400
- mode: 0o600,
401
- expected: { sha256: sha256(manifestRaw) },
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 portableInfo = await metadata(portable);
406
- if (portableInfo?.isSymbolicLink() || (portableInfo && !portableInfo.isDirectory())) {
407
- add(groups, item(relativePath(root, portable), "conflict", "portable skill root is a symlink or non-directory"));
408
- }
409
- for (const entry of portableInfo?.isDirectory() ? await readdir(portable) : []) {
410
- const directory = resolve(portable, entry);
411
- if (retired.some((record) => record.kind === "portable-skill" && record.installed_path === `skills/${entry}`))
412
- continue;
413
- const path = relativePath(root, directory);
414
- add(groups, item(path, inventory.active_portable_skills.includes(entry) ? "current" : "unknown", inventory.active_portable_skills.includes(entry)
415
- ? "current portable skill source"
416
- : "portable skill is outside the canonical inventory"));
417
- }
418
- const operations = mutations.map((mutation) => ({
419
- path: mutation.path,
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: 1,
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(scope, cwd, home),
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: true, digest: planDigest },
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
- if (blocked) {
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["archive-pending"].length ||
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 { status: "ok", applied: true, plan: { ...built.plan, digest: confirmationDigest } };
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) {