@amsterdamdatalabs/enact-extensions 0.1.56 → 0.1.58

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.
@@ -1,11 +1,9 @@
1
1
  /**
2
2
  * Repo-local install: `enact-extensions install <bundle> --repo <path>`.
3
3
  *
4
- * Writes a bundle into a git repository as COMMITTED, RELATIVE files that
5
- * follow each host's documented project-scope standard. Nothing is written
6
- * under $HOME. Every COMMITTED file is relative; the single absolute-path
7
- * exception is the gitignored `.claude/settings.local.json` env block (see
8
- * the claude adapter), which cannot work any other way.
4
+ * Rebuilds a bundle's declared repo-local roots from source as COMMITTED,
5
+ * RELATIVE files. It runs only in a repository already marked by root
6
+ * `enact-config.toml`; enrollment is deliberately a separate concern.
9
7
  *
10
8
  * Every host receives only its native project files:
11
9
  * claude — `.claude/agents`, `.claude/skills`, `.mcp.json`, and hooks in
@@ -23,78 +21,33 @@
23
21
  * Repo installation never writes a plugin root, marketplace, or plugin
24
22
  * registration. Those belong exclusively to explicit global installation.
25
23
  *
26
- * State: `.agents/<name>.lock.json` records every managed file (sha256) and
27
- * every entry merged into a user file. Re-runs are idempotent.
28
- *
29
- * Owner decision (Tarun): merged hooks and MCP entries are always repaired.
30
- * Direct native agent and skill files are lock-owned files: user collisions
31
- * fail loudly and the lock makes uninstall remove only this bundle's files.
32
- *
33
- * Two kinds:
34
- * - Whole files in host-native agent/skill locations, which are conflict
35
- * checked and recorded as files.
36
- * - Merged surfaces shared with the user (`.cursor/hooks.json`,
37
- * `.cursor/mcp.json`, `.kimi-code/mcp.json`, `.codex/hooks.json`,
38
- * `.claude/settings.json`, the marker block in `.codex/config.toml`):
39
- * ONLY our own entries are force-rewritten; a user's own keys/hooks/mcp
40
- * servers are left completely untouched. A foreign entry that happens to
41
- * share OUR key/server name is taken over and its original captured into
42
- * `displaced` for a byte-exact restore on uninstall, exactly like a
43
- * `--force` takeover always has -- it just no longer needs the flag.
44
- * `--force` gates native whole-file drift and baseline lean-ctx config files
45
- * (`.agents/lean-ctx/config/lean-ctx/*.toml`), preserving displaced content
46
- * for byte-exact restoration on uninstall.
47
- * Uninstall removes exactly what the lock records; uninstall's OWN drift
48
- * check (refusing to remove content edited since install without --force)
49
- * is unaffected by any of the above -- that gate is about safe removal, not
50
- * install-time repair.
24
+ * State: `.agents/<name>.lock.json` records the exact projected files plus
25
+ * the owned-root map. User/foreign/drifted content in an owned root is not
26
+ * retained: the next reconciliation deletes it and recreates the canonical
27
+ * projection. Explicit runtime boundaries
28
+ * under `.agents/` are the sole exception and are owned by their writers.
51
29
  */
52
30
  import { createHash } from "node:crypto";
53
- import {
54
- existsSync,
55
- lstatSync,
56
- mkdtempSync,
57
- readdirSync,
58
- readFileSync,
59
- readlinkSync,
60
- realpathSync,
61
- rmSync,
62
- statSync,
63
- } from "node:fs";
31
+ import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
64
32
  import { homedir, tmpdir } from "node:os";
65
33
  import { isAbsolute, join, resolve } from "node:path";
66
- import * as TOML from "@iarna/toml";
67
34
  import {
68
35
  applyHostFlagToHooksDoc,
69
36
  assertSupportedHookEvents,
70
37
  clampHookTimeoutsForHost,
71
- cutBlock,
72
38
  cutExtensionsBlock,
73
39
  cutLegacySkillsBlock,
74
- declaredExtensionsFrom,
75
40
  declareExtension,
76
41
  deriveOpenCodeMcp,
77
42
  EXTENSIONS_BLOCK,
78
- ensureContainer,
79
- entryPresent,
80
- extractTomlTable,
81
- findBlock,
82
- getAt,
83
43
  makeFileOps,
84
44
  materializeCodexSkillMetadata,
85
- pathKey,
86
45
  projectCursorHooks,
87
46
  projectNativeAgentsDir,
88
- reinsertTomlSpans,
89
47
  removeExtensionBlock,
90
- removeJsonEntries,
91
48
  renderTomlBlock,
92
- siblingOwnersOf,
93
- siblingOwnsPrefix,
94
- siblingRecordsJsonEntry,
95
49
  unmappedCursorHookEvents,
96
50
  unmappedCursorHookMatchers,
97
- valueHash,
98
51
  } from "../../dist/index.js";
99
52
 
100
53
  export const REPO_HOSTS = ["claude", "codex", "cursor", "kimi", "opencode"];
@@ -112,10 +65,66 @@ const HOST_AGENTS_DIR = {
112
65
  kimi: ".kimi-code/agents",
113
66
  opencode: ".opencode/agents",
114
67
  };
68
+ /**
69
+ * Inspect only enact-extensions' own TOML table as text.
70
+ *
71
+ * `enact-config.toml` is shared: accepting or rejecting it based on a parser
72
+ * that reads `[hooks]`, `[controls]`, or another owner's syntax would make us
73
+ * an accidental validator for that owner. This deliberately limited scanner
74
+ * recognises our headers and the basic `key = value` shape inside them; every
75
+ * other table is an opaque boundary.
76
+ */
77
+ export function inspectExtensionsScope(configText, action = "reading [extensions]") {
78
+ if (typeof configText !== "string") throw new Error(`Cannot inspect ${CONFIG_PATH} (${action}): expected text`);
79
+ const declarations = [];
80
+ let present = false;
81
+ let inScope = false;
82
+ let table = null;
83
+ for (const [index, line] of configText.split("\n").entries()) {
84
+ const trimmed = line.trim();
85
+ if (trimmed.startsWith("[")) {
86
+ if (trimmed === "[extensions]") {
87
+ if (present)
88
+ throw new Error(
89
+ `Invalid [extensions] scope in ${CONFIG_PATH} (${action}): duplicate [extensions] at line ${index + 1}`,
90
+ );
91
+ present = true;
92
+ inScope = true;
93
+ table = "root";
94
+ continue;
95
+ }
96
+ if (trimmed.startsWith("[extensions")) {
97
+ const match = /^\[extensions\.([A-Za-z0-9][A-Za-z0-9_-]*)\]$/.exec(trimmed);
98
+ if (!match || !present) {
99
+ throw new Error(
100
+ `Invalid [extensions] scope in ${CONFIG_PATH} (${action}): malformed header at line ${index + 1}`,
101
+ );
102
+ }
103
+ declarations.push(match[1]);
104
+ inScope = true;
105
+ table = "declaration";
106
+ continue;
107
+ }
108
+ // An unrelated header ends our table span. Its syntax and contents are
109
+ // owned elsewhere and intentionally never inspected here.
110
+ inScope = false;
111
+ table = null;
112
+ continue;
113
+ }
114
+ if (!inScope || trimmed === "" || trimmed.startsWith("#")) continue;
115
+ const eq = trimmed.indexOf("=");
116
+ if (eq <= 0 || trimmed.slice(eq + 1).trim() === "") {
117
+ throw new Error(
118
+ `Invalid [extensions] scope in ${CONFIG_PATH} (${action}): malformed ${table ?? "table"} entry at line ${index + 1}`,
119
+ );
120
+ }
121
+ }
122
+ return { present, declarations };
123
+ }
124
+
115
125
  /** Declared extension names from `[extensions.<name>]` sub-tables, or []. */
116
126
  export function readDeclaredExtensions(configText) {
117
- if (typeof configText !== "string" || configText.length === 0) return [];
118
- return declaredExtensionsFrom(parseConfigText(configText, "reading [extensions]"));
127
+ return inspectExtensionsScope(configText).declarations;
119
128
  }
120
129
  // Shared repo config at the repo root; its presence marks a repo as managed
121
130
  // (enact-hook's contract). One owner per root table: enact-hook owns
@@ -127,10 +136,8 @@ const GITIGNORE_PATH = ".agents/.gitignore";
127
136
  // The global `enact-hook` runtime (not this installer) writes two per-repo
128
137
  // logs under `.agents/hooks/` in any managed repo: the deny log (hooks.log)
129
138
  // and the activity log (hooks-activity.log), both append-only CSV. They are
130
- // the runtime's own state -- never written by install -- so they get the
131
- // SAME managed-gitignore-line treatment as the lean-ctx runtime dirs below:
132
- // a plain uninstall keeps the lines while a log file is still present, and
133
- // `--purge` removes the log files themselves before the lines.
139
+ // the runtime's own state -- never written, removed, or restored by this
140
+ // installer. They are explicit unmanaged boundaries during root rebuild.
134
141
  export const HOOK_LOG_FILES = ["hooks.log", "hooks-activity.log"].map((f) => `.agents/hooks/${f}`);
135
142
 
136
143
  // ---------------------------------------------------------------------------
@@ -158,12 +165,8 @@ const LEAN_CTX_DIRS = [
158
165
  // lean-ctx config (config.toml, layout.toml) stays committed; only its
159
166
  // runtime-generated dirs are ignored.
160
167
  const LEAN_CTX_CONFIG_DIR = LEAN_CTX_DIRS[0].rel;
161
- // RUNTIME dirs lean-ctx itself creates while the plugin is in use (never
162
- // written by the installer) -- these are exactly what GITIGNORE_LINES hides.
163
- // A plain uninstall must not remove those gitignore lines while any of these
164
- // dirs still exist on disk (it would surface them as untracked); `--purge`
165
- // removes the dirs themselves first (they are OUR runtime's own state, not
166
- // user data), then the gitignore lines.
168
+ // Runtime dirs are never part of the projection. They remain explicit
169
+ // unmanaged boundaries during install and both uninstall modes.
167
170
  const LEAN_CTX_RUNTIME_DIRS = LEAN_CTX_DIRS.filter((d) => d.runtime).map((d) => d.rel);
168
171
  // `.agents/.gitignore` is itself inside `.agents/`, so its patterns are
169
172
  // anchored one level down (`.agents/lean-ctx/data` -> `/lean-ctx/data/`).
@@ -177,26 +180,13 @@ const GITIGNORE_LINES = [
177
180
  // `.claude/.gitignore` line to hide it. Both were removed with the carrier:
178
181
  // absolute paths in an installed file are machine-specific and go stale
179
182
  // silently on a rename or a fresh clone at a different path.
180
- // OpenCode's own auto-generated project-local files. We never write these;
181
- // `--purge` removes them only when no user-owned content remains under
182
- // `.opencode/` after the lock-owned native files are removed.
183
- const OPENCODE_AUTOGEN_ENTRIES = new Set([
184
- "node_modules",
185
- "package.json",
186
- "package-lock.json",
187
- "bun.lock",
188
- ".gitignore",
189
- ]);
190
183
  const LOCK_VERSION = 1;
191
184
 
192
- const CONFIG_ROOT_CONTENT = "version = 1\n";
193
-
194
185
  // ---------------------------------------------------------------------------
195
186
  // small utils
196
187
  // ---------------------------------------------------------------------------
197
188
 
198
189
  export const sha256 = (data) => createHash("sha256").update(data).digest("hex");
199
- const has = (obj, key) => Object.hasOwn(obj, key);
200
190
 
201
191
  export const abs = (repo, rel) => join(repo, ...rel.split("/"));
202
192
 
@@ -219,15 +209,6 @@ function listFilesRecursive(root, rel = "") {
219
209
  return out.sort();
220
210
  }
221
211
 
222
- function detectJsonFormat(text) {
223
- const match = /^[\t ]+(?=")/m.exec(text);
224
- return { indent: match ? match[0] : " ", trailingNewline: text.endsWith("\n") };
225
- }
226
-
227
- function serializeJson(doc, format) {
228
- return `${JSON.stringify(doc, null, format.indent)}${format.trailingNewline ? "\n" : ""}`;
229
- }
230
-
231
212
  function sortObject(obj) {
232
213
  return Object.fromEntries(
233
214
  Object.keys(obj)
@@ -236,15 +217,6 @@ function sortObject(obj) {
236
217
  );
237
218
  }
238
219
 
239
- // Drop empty per-rel entries before persisting `displaced` to the lock.
240
- function pruneDisplaced(displaced) {
241
- const json = {};
242
- for (const [rel, m] of Object.entries(displaced.json)) if (Object.keys(m).length > 0) json[rel] = m;
243
- const toml = {};
244
- for (const [rel, spans] of Object.entries(displaced.toml)) if (spans.length > 0) toml[rel] = spans;
245
- return { files: sortObject(displaced.files), json: sortObject(json), toml: sortObject(toml) };
246
- }
247
-
248
220
  // ---------------------------------------------------------------------------
249
221
  // repo + bundle resolution
250
222
  // ---------------------------------------------------------------------------
@@ -358,8 +330,8 @@ export function readBundle(pluginRoot) {
358
330
  // ---------------------------------------------------------------------------
359
331
  // plan: the desired repo state
360
332
  // files Map<rel, Buffer> whole managed files
361
- // json Map<rel, {scaffold, keys, items}> entries merged into user JSON
362
- // toml Map<rel, {tables}> mcp_servers tables merged into user TOML
333
+ // json Map<rel, {scaffold, keys, items}> canonical JSON file content
334
+ // toml Map<rel, {tables}> canonical TOML file content
363
335
  // ---------------------------------------------------------------------------
364
336
 
365
337
  function jsonMerge(plan, rel) {
@@ -527,8 +499,7 @@ export function buildPlan(pluginRoot, bundle, hosts, repo) {
527
499
  files: new Map(),
528
500
  // Subset of `files` keys that are owner-forced (hooks/mcp/skills content
529
501
  // exclusively generated by us): always overwritten, never gated behind
530
- // `--force`, never captured for a displaced-content restore. See the
531
- // file-header doc comment.
502
+ // File content is always materialized from the canonical bundle.
532
503
  forcedFiles: new Set(),
533
504
  // Files regenerated from a source of truth. They are exempt from uninstall
534
505
  // drift checks when a sibling can legitimately rewrite them.
@@ -558,11 +529,189 @@ export function buildPlan(pluginRoot, bundle, hosts, repo) {
558
529
  }
559
530
 
560
531
  // ---------------------------------------------------------------------------
561
- // JSON merge helpers
532
+ // authoritative repo-root reconciliation
562
533
  // ---------------------------------------------------------------------------
563
534
 
535
+ function jsonValue(value) {
536
+ return JSON.parse(JSON.stringify(value));
537
+ }
538
+
539
+ function assignJsonPath(doc, path, value) {
540
+ let at = doc;
541
+ for (const key of path.slice(0, -1)) {
542
+ if (!at[key] || typeof at[key] !== "object" || Array.isArray(at[key])) at[key] = {};
543
+ at = at[key];
544
+ }
545
+ at[path.at(-1)] = jsonValue(value);
546
+ }
547
+
548
+ /** Build the exact native files from the bundle, without reading a repo file. */
549
+ function materializeAuthoritativeFiles(plan, bundle) {
550
+ const files = new Map(plan.files);
551
+ for (const [rel, merge] of plan.json) {
552
+ const doc = {};
553
+ for (const entry of merge.scaffold) assignJsonPath(doc, entry.path, entry.value);
554
+ for (const entry of merge.keys) assignJsonPath(doc, entry.path, entry.value);
555
+ for (const entry of merge.items) {
556
+ let at = doc;
557
+ for (const key of entry.path.slice(0, -1)) {
558
+ if (!at[key] || typeof at[key] !== "object" || Array.isArray(at[key])) at[key] = {};
559
+ at = at[key];
560
+ }
561
+ const key = entry.path.at(-1);
562
+ if (!Array.isArray(at[key])) at[key] = [];
563
+ at[key].push(jsonValue(entry.value));
564
+ }
565
+ files.set(rel, Buffer.from(`${JSON.stringify(doc, null, 2)}\n`));
566
+ }
567
+ for (const [rel, toml] of plan.toml) files.set(rel, Buffer.from(renderTomlBlock(bundle.name, toml.tables)));
568
+ for (const [rel, lines] of plan.gitignore) files.set(rel, Buffer.from(`${lines.join("\n")}\n`));
569
+ return files;
570
+ }
571
+
572
+ function rootForHost(host) {
573
+ return host === "kimi" ? ".kimi-code" : `.${host}`;
574
+ }
575
+
576
+ function isBoundaryOrAncestor(rel, boundaries) {
577
+ return boundaries.some(
578
+ (boundary) => boundary === rel || boundary.startsWith(`${rel}/`) || rel.startsWith(`${boundary}/`),
579
+ );
580
+ }
581
+
582
+ function clearOwnedRoot(repo, root, boundaries, ops) {
583
+ const rootPath = abs(repo, root);
584
+ const rootState = lstatSync(rootPath, { throwIfNoEntry: false });
585
+ if (!rootState) return;
586
+ if (!rootState.isDirectory() || rootState.isSymbolicLink()) {
587
+ ops.removeSymlink(root, "replacing non-directory owned root");
588
+ return;
589
+ }
590
+ if (boundaries.length === 0) {
591
+ ops.removeDir(root, "rebuilding authoritative owned root");
592
+ return;
593
+ }
594
+ const visit = (rel) => {
595
+ const path = abs(repo, rel);
596
+ const state = lstatSync(path, { throwIfNoEntry: false });
597
+ if (!state) return;
598
+ // A boundary is entirely outside this deployment. Do not descend into it:
599
+ // logs/caches are owned by their runtime, not by this installer.
600
+ if (boundaries.some((boundary) => rel === boundary || rel.startsWith(`${boundary}/`))) return;
601
+ if (!state.isDirectory() || state.isSymbolicLink()) {
602
+ ops.remove(rel, "removing stale content from authoritative owned root");
603
+ return;
604
+ }
605
+ if (!isBoundaryOrAncestor(rel, boundaries)) {
606
+ ops.removeDir(rel, "rebuilding authoritative owned root");
607
+ return;
608
+ }
609
+ for (const entry of readdirSync(path, { withFileTypes: true })) visit(`${rel}/${entry.name}`);
610
+ ops.rmdirIfEmpty(rel);
611
+ };
612
+ for (const entry of readdirSync(rootPath, { withFileTypes: true })) visit(`${root}/${entry.name}`);
613
+ ops.rmdirIfEmpty(root);
614
+ }
615
+
616
+ function assertMarkedRepo(repo) {
617
+ const configPath = abs(repo, CONFIG_PATH);
618
+ // `existsSync` follows a symlink. The central marker is a repo-root
619
+ // ownership boundary, so following one could make text surgery mutate a
620
+ // config owned outside this checkout. Inspect the directory entry itself
621
+ // before any read or write.
622
+ const state = lstatSync(configPath, { throwIfNoEntry: false });
623
+ if (!state) {
624
+ throw new Error(
625
+ `Refusing to reconcile unmarked repository ${repo}: ${CONFIG_PATH} is the required central-management marker`,
626
+ );
627
+ }
628
+ if (!state.isFile() || state.isSymbolicLink()) {
629
+ throw new Error(
630
+ `Refusing to reconcile repository ${repo}: root ${CONFIG_PATH} must be a regular, non-symlink file`,
631
+ );
632
+ }
633
+ inspectExtensionsScope(readFileSync(configPath, "utf8"), "checking central-management marker");
634
+ }
635
+
636
+ function runAuthoritativeRepoInstall(pluginRoot, options, context) {
637
+ const { repo, bundle, hosts, plan, prev, siblings, ops, actions, dryRun } = context;
638
+ if (siblings.length > 0) {
639
+ throw new Error(
640
+ `Refusing incomplete authoritative reconciliation: ${bundle.name} cannot rebuild shared owned roots while sibling locks exist (${siblings.map(({ name }) => name).join(", ")})`,
641
+ );
642
+ }
643
+
644
+ // A reconciliation owns the complete declared component surface, not just
645
+ // the hosts selected for this invocation. Otherwise a previous host's
646
+ // root would become an accidental preservation zone when the host list is
647
+ // narrowed.
648
+ const roots = new Set(OWNED_REPO_ROOTS);
649
+ const desired = materializeAuthoritativeFiles(plan, bundle);
650
+ for (const root of roots) {
651
+ const boundaries = UNMANAGED_RUNTIME_BOUNDARIES.filter((path) => path.startsWith(`${root}/`));
652
+ clearOwnedRoot(repo, root, boundaries, ops);
653
+ }
654
+ for (const root of RETIRED_REPO_ROOTS) ops.removeDir(root, "retired Enact plugin root");
655
+
656
+ // Root-level native config is also wholly generated. enact-config.toml is
657
+ // intentionally excluded: only its [extensions] table belongs to us.
658
+ for (const rel of [".mcp.json", "opencode.json"]) {
659
+ if (desired.has(rel)) ops.remove(rel, "rebuilding authoritative owned file");
660
+ }
661
+
662
+ const files = {};
663
+ for (const [rel, content] of desired) {
664
+ replaceSymlinkAncestors(repo, rel, ops);
665
+ ops.write(rel, content, "authoritative bundle projection");
666
+ files[rel] = { sha256: sha256(content) };
667
+ }
668
+
669
+ // This is the single partial-file boundary in the ownership map. The
670
+ // document marker pre-existed (assertMarkedRepo), and text surgery touches
671
+ // only the table owned by enact-extensions.
672
+ const originalConfig = readFileSync(abs(repo, CONFIG_PATH), "utf8");
673
+ const base = cutLegacySkillsBlock(originalConfig) ?? originalConfig;
674
+ let config = prev.config ?? { created: false, appended: null };
675
+ let nextConfig = base;
676
+ if (!inspectExtensionsScope(nextConfig, "reading [extensions]").present) {
677
+ const appended = `${nextConfig.length === 0 ? "" : nextConfig.endsWith("\n") ? "\n" : "\n\n"}${EXTENSIONS_BLOCK}`;
678
+ nextConfig += appended;
679
+ config = { created: false, appended };
680
+ }
681
+ const declared = declareExtension(nextConfig, bundle.name);
682
+ if (declared !== null) nextConfig = declared;
683
+ if (nextConfig !== originalConfig)
684
+ ops.write(CONFIG_PATH, nextConfig, `managed-repo marker with [extensions.${bundle.name}]`);
685
+
686
+ const lock = {
687
+ lockVersion: LOCK_VERSION,
688
+ plugin: bundle.name,
689
+ version: bundle.version,
690
+ hosts,
691
+ files: sortObject(files),
692
+ symlinks: {},
693
+ toml: {},
694
+ gitignore: {},
695
+ config,
696
+ createdDirs: [],
697
+ ownedRoots: [...roots].sort(),
698
+ unmanagedBoundaries: UNMANAGED_RUNTIME_BOUNDARIES,
699
+ };
700
+ ops.write(lockRel(bundle.name), `${JSON.stringify(lock, null, 2)}\n`, "authoritative install lock file");
701
+ console.log(
702
+ `${dryRun ? "[dry-run] " : ""}repo install ${bundle.name}@${bundle.version} -> ${repo} (authoritative roots: ${[...roots].join(", ")})`,
703
+ );
704
+ if (options.force === true) {
705
+ console.log(`${dryRun ? "[dry-run] " : ""}force: authoritative repair requested; no owned content is preserved`);
706
+ }
707
+ if (hosts.includes("claude")) {
708
+ console.log("claude: trust this folder and review its project hooks before using the installed agents and skills");
709
+ }
710
+ return { repo, name: bundle.name, version: bundle.version, hosts, changed: actions, actions, dryRun };
711
+ }
712
+
564
713
  // ---------------------------------------------------------------------------
565
- // TOML block helpers — text level, so user content and comments are untouched
714
+ // JSON merge helpers
566
715
  // ---------------------------------------------------------------------------
567
716
 
568
717
  // ---------------------------------------------------------------------------
@@ -589,10 +738,8 @@ export function readLock(repo, name) {
589
738
  * of the same skill names), so "is this path mine?" is the wrong question. The
590
739
  * right one is "does ANY installed plugin own it?".
591
740
  *
592
- * Reads only `.agents/*.lock.json`, skipping `exclude` and any file whose
593
- * `plugin` field disagrees with its filename (readLock throws on that, and a
594
- * malformed sibling must not break an unrelated install -- it is reported by
595
- * that plugin's own doctor instead).
741
+ * Reads every sibling lock. A malformed claim is fatal: proceeding would make
742
+ * the ownership map incomplete and could erase another extension's files.
596
743
  */
597
744
  export function readSiblingLocks(repo, exclude) {
598
745
  const dir = abs(repo, ".agents");
@@ -602,12 +749,7 @@ export function readSiblingLocks(repo, exclude) {
602
749
  if (!entry.isFile() || !entry.name.endsWith(".lock.json")) continue;
603
750
  const name = entry.name.slice(0, -".lock.json".length);
604
751
  if (name === exclude) continue;
605
- let lock;
606
- try {
607
- lock = readLock(repo, name);
608
- } catch {
609
- continue;
610
- }
752
+ const lock = readLock(repo, name);
611
753
  if (lock) out.push({ name, lock });
612
754
  }
613
755
  return out;
@@ -637,43 +779,25 @@ function replaceSymlinkAncestors(repo, rel, ops) {
637
779
  }
638
780
  }
639
781
 
640
- function recordMissingDirs(repo, rel, createdDirs) {
641
- const parts = rel.split("/").slice(0, -1);
642
- for (let i = 1; i <= parts.length; i++) {
643
- const dir = parts.slice(0, i).join("/");
644
- if (!existsSync(abs(repo, dir)) && !createdDirs.includes(dir)) createdDirs.push(dir);
645
- }
646
- }
647
-
648
- function removeEmptyDirs(repo, dirs, ops) {
649
- for (const dir of [...dirs].sort((a, b) => b.split("/").length - a.split("/").length)) {
650
- ops.rmdirIfEmpty(dir);
651
- }
652
- }
653
-
654
- // Strip the lines this plugin's install added to a managed `.gitignore` (per
655
- // `ginfo` = one entry of a lock's `gitignore` map: {created, lines,
656
- // appended}), same convention as the TOML-block / displaced-content removal
657
- // elsewhere in this file: prefer stripping the exact byte-appended tail, fall
658
- // back to a line-by-line filter over the owned set. Any line NOT in
659
- // `ginfo.lines` (including a user's own pre-existing content) is left
660
- // untouched. Removes the file entirely when it was created by us and is now
661
- // empty. Returns whether anything changed on disk.
662
- function removeManagedGitignoreLines(repo, rel, ginfo, ops) {
663
- const path = abs(repo, rel);
664
- if (!existsSync(path)) return false;
665
- const text = readFileSync(path, "utf8");
666
- const appended = ginfo.appended;
667
- const out =
668
- typeof appended === "string" && text.endsWith(appended)
669
- ? text.slice(0, text.length - appended.length)
670
- : text
671
- .split("\n")
672
- .filter((l) => !new Set(ginfo.lines ?? []).has(l.trim()))
673
- .join("\n");
674
- if (ginfo.created && out.trim() === "") return ops.remove(rel, "gitignore lines removed (file now empty)");
675
- return ops.write(rel, out, "gitignore lines removed");
676
- }
782
+ // Repo-local agent roots are an authoritative deployment, not a merge point.
783
+ // A marked repo grants enact-extensions sole ownership of these roots. The
784
+ // only exceptions are runtime state written by a different process; they are
785
+ // named here so a future writer cannot silently claim the same paths.
786
+ export const OWNED_REPO_ROOTS = [".agents", ".claude", ".codex", ".cursor", ".kimi-code", ".opencode"];
787
+ export const RETIRED_REPO_ROOTS = [
788
+ ".claude-plugin",
789
+ ".codex-plugin",
790
+ ".cursor-plugin",
791
+ ".kimi-plugin",
792
+ ".opencode/plugins",
793
+ ];
794
+ export const UNMANAGED_RUNTIME_BOUNDARIES = [
795
+ ".agents/hooks",
796
+ ".agents/lean-ctx/data",
797
+ ".agents/lean-ctx/cache",
798
+ ".agents/lean-ctx/state",
799
+ ".agents/lean-ctx/bin",
800
+ ];
677
801
 
678
802
  // ---------------------------------------------------------------------------
679
803
  // enact-config.toml [extensions] — text-level only. The document is parsed
@@ -681,67 +805,19 @@ function removeManagedGitignoreLines(repo, rel, ginfo, ops) {
681
805
  // other table is read, and the document is never re-serialized.
682
806
  // ---------------------------------------------------------------------------
683
807
 
684
- function parseConfigText(text, action) {
685
- try {
686
- return TOML.parse(text);
687
- } catch (err) {
688
- throw new Error(`Cannot parse ${CONFIG_PATH} (${action}): ${err instanceof Error ? err.message : String(err)}`);
689
- }
690
- }
691
-
692
- /**
693
- * `uninstall --purge` rule for `.opencode/`: OpenCode auto-generates
694
- * `node_modules/`, `package.json`, `package-lock.json`, `bun.lock` and
695
- * `.gitignore` there the first time it loads a project plugin (verified
696
- * 2026-09-14, this session -- see OPENCODE_AUTOGEN_ENTRIES' doc comment).
697
- * We never write those files ourselves (only `plugins/<name>.ts`, already
698
- * removed by the managed-file removal loop above). Remove them ONLY when
699
- * every remaining entry under `.opencode/` is one of OpenCode's own
700
- * auto-generated names (and `plugins/`, if present, is now empty) --
701
- * i.e. nothing user-owned is left that could be silently destroyed.
702
- */
703
- function purgeOpenCodeAutogenIfClean(repo, ops) {
704
- const dir = abs(repo, ".opencode");
705
- if (!existsSync(dir)) return;
706
- const entries = readdirSync(dir);
707
- // `plugins/` and `skills/` are both OURS: the generated TS plugin and, since
708
- // OpenCode is the only host whose plugin surface cannot carry skills, its
709
- // flat skills directory. Both are emptied by the managed-file removal above,
710
- // so an empty one is not user content -- but this check runs before
711
- // removeEmptyDirs, so the now-empty directories are still on disk here.
712
- // Recursive: removing our managed files leaves the skill directories behind
713
- // as empty shells until removeEmptyDirs runs (after this), so a top-level
714
- // listing would report them as user content. What matters is whether any FILE
715
- // survives, not whether the directory is literally empty.
716
- const hasAnyFile = (d) =>
717
- existsSync(d) &&
718
- readdirSync(d, { withFileTypes: true }).some((e) => (e.isDirectory() ? hasAnyFile(join(d, e.name)) : true));
719
- const ourDirs = ["plugins", "skills"];
720
- const notEmpty = ourDirs.filter((d) => hasAnyFile(join(dir, d)));
721
- const leftover = entries.filter((e) => !ourDirs.includes(e) && !OPENCODE_AUTOGEN_ENTRIES.has(e));
722
- if (leftover.length > 0 || notEmpty.length > 0) {
723
- console.log(
724
- `kept .opencode/ auto-generated files (user-owned content present: ${[...leftover, ...notEmpty.map((d) => `${d}/*`)].join(", ")})`,
725
- );
726
- return;
727
- }
728
- for (const e of entries) ops.removeDir(`.opencode/${e}`, "OpenCode auto-generated file (--purge)");
729
- ops.rmdirIfEmpty(".opencode");
730
- console.log(
731
- "purged .opencode/ auto-generated files (node_modules/package.json/package-lock.json/bun.lock/.gitignore -- matched OpenCode's own auto-generated shape, nothing else present)",
732
- );
733
- }
734
-
735
808
  // ---------------------------------------------------------------------------
736
809
  // install
737
810
  // ---------------------------------------------------------------------------
738
811
 
739
812
  export function runRepoInstall(pluginRoot, options = {}) {
740
813
  const repo = resolveRepoRoot(options.repo);
741
- const force = options.force === true;
742
814
  const dryRun = options.dryRun === true;
743
815
  const actions = [];
744
816
  const ops = makeFileOps(repo, dryRun, actions);
817
+ // Reconciliation is deliberately not enrollment. The scheduler may point
818
+ // at arbitrary git checkouts, so the centrally managed marker must already
819
+ // exist before this command is allowed to alter anything.
820
+ assertMarkedRepo(repo);
745
821
  assertSupportedHookEvents(pluginRoot);
746
822
  const bundle = readBundle(pluginRoot);
747
823
  const hosts = parseRepoHosts(options.host, bundle.manifest);
@@ -752,485 +828,23 @@ export function runRepoInstall(pluginRoot, options = {}) {
752
828
  const siblings = readSiblingLocks(repo, bundle.name);
753
829
  const prev = readLock(repo, bundle.name) ?? {
754
830
  files: {},
755
- merged: {},
756
831
  toml: {},
757
832
  gitignore: {},
758
833
  symlinks: {},
759
834
  config: null,
760
835
  createdDirs: [],
761
836
  };
762
- const prevSymlinks = prev.symlinks ?? {};
763
- const prevDisplaced = prev.displaced ?? { files: {}, json: {}, toml: {} };
764
- // Originals displaced by a --force takeover of pre-existing foreign content,
765
- // carried forward from the previous lock and extended below. Re-running
766
- // install never overwrites an already-recorded original (see per-surface
767
- // "already recorded" guards): the FIRST takeover is the only one that ever
768
- // writes here. Uninstall restores exactly what is recorded here.
769
- const displaced = {
770
- files: { ...prevDisplaced.files },
771
- json: Object.fromEntries(Object.entries(prevDisplaced.json ?? {}).map(([rel, m]) => [rel, { ...m }])),
772
- toml: Object.fromEntries(Object.entries(prevDisplaced.toml ?? {}).map(([rel, spans]) => [rel, [...spans]])),
773
- };
774
-
775
- // -- preflight: enact-config.toml must be valid TOML before we append to it
776
- const configExists = existsSync(abs(repo, CONFIG_PATH));
777
- const configText = configExists ? readFileSync(abs(repo, CONFIG_PATH), "utf8") : null;
778
- const configHasExtensions =
779
- configExists && parseConfigText(configText, "refusing to append [extensions]").extensions !== undefined;
780
- // -- preflight: the legacy `[skills]` marker is cut here, not carried. It
781
- // refuses rather than guesses when the table is not the one install wrote,
782
- // and it runs BEFORE any file is touched so a refusal leaves the repo
783
- // untouched instead of half-installed.
784
- const configLegacyCut = configExists ? cutLegacySkillsBlock(configText) : null;
785
-
786
- // -- preflight: skills never overwrite a USER-AUTHORED skill dir (not even
787
- // with --force). A dir owned by THIS plugin, or by any SIBLING plugin
788
- // installed in the same repo, is fine: `.agents/skills/` is one flat pool
789
- // shared across plugins by design, and bundles legitimately ship the same
790
- // skill name (enact-repo and engineering share 23). Checking only `prev`
791
- // made every sibling-owned dir look foreign, which refused the install with
792
- // a false "non-managed" claim and advised deleting files another plugin
793
- // depends on.
794
- const skillConflicts = [...plan.skills].filter(
795
- (dirRel) =>
796
- existsSync(abs(repo, dirRel)) &&
797
- !Object.keys(prev.files).some((f) => f.startsWith(`${dirRel}/`)) &&
798
- !siblingOwnsPrefix(siblings, dirRel),
799
- );
800
- if (skillConflicts.length > 0) {
801
- throw new Error(
802
- `Refusing to overwrite existing user-authored skill dir(s); rename or remove them first:\n ${skillConflicts.join("\n ")}`,
803
- );
804
- }
805
-
806
- // A sibling owning the SAME path with DIFFERENT bytes is the genuinely
807
- // dangerous case, and it used to pass silently: skill files are forcedFiles,
808
- // so plugin B simply overwrote plugin A's `handoff/SKILL.md` with an
809
- // unrelated skill and nothing warned. `.agents/skills/` is flat on every host
810
- // that reads it, so two plugins cannot ship different skills under one name.
811
- // Refuse, and do NOT let --force through -- forcing would leave the other
812
- // plugin's lock pointing at bytes it did not write.
813
- const SHARED_CONTENT_EXEMPT = new Set();
814
- const divergent = [];
815
- // Every repo-relative path this run changed, in write order. Declared HERE
816
- // rather than beside the other `-- apply` locals because the stale-symlink
817
- // sweep below already pushes to it, and that sweep has to run before any file
818
- // is written. `const` further down put this in its temporal dead zone, so the
819
- // sweep threw `Cannot access 'written' before initialization` on exactly the
820
- // runs where it had a symlink to drop -- see install-stale-symlink-crash.test.mjs.
821
- const written = [];
822
- // BEFORE any file is written: drop a recorded symlink that is no longer in
823
- // the plan. A managed directory that is still a symlink from an earlier
824
- // layout would otherwise be FOLLOWED by the writes below -- the skills a
825
- // plugin root should now own would land wherever the old link pointed, and
826
- // the link would survive, so nothing looked wrong.
827
- for (const rel of Object.keys(prevSymlinks)) {
828
- if (plan.symlinks.has(rel)) continue;
829
- if (ops.removeSymlink(rel, "symlink dropped out of plan")) written.push(rel);
830
- }
831
- for (const [rel, content] of plan.files) {
832
- if (SHARED_CONTENT_EXEMPT.has(rel)) continue;
833
- for (const { name: owner, lock } of siblings) {
834
- const theirs = lock.files?.[rel]?.sha256;
835
- if (theirs && theirs !== sha256(content)) divergent.push({ rel, owner });
836
- }
837
- }
838
- if (divergent.length > 0) {
839
- throw new Error(
840
- `Refusing to install: another plugin already provides these paths with different content.\n` +
841
- `Two plugins cannot ship different content under the same shared path.\n` +
842
- divergent.map(({ rel, owner }) => ` ${rel} (installed by ${owner})`).join("\n") +
843
- `\nRename one of them.`,
844
- );
845
- }
846
-
847
- const edited = [];
848
- const conflicts = [];
849
- // Structured tracking (in parallel with the human-readable `conflicts`
850
- // strings above) of exactly which foreign whole files / JSON keys a
851
- // --force takeover is about to overwrite, so their original content can be
852
- // captured before it's lost.
853
- const foreignFileRels = new Set();
854
- const foreignJsonKeys = [];
855
-
856
- // -- preflight: whole managed files. `forcedFiles` (hooks/mcp/skills
857
- // content, exclusively ours -- see file-header comment) is skipped
858
- // entirely: drift or a stale pre-existing entry there is never a blocking
859
- // conflict, just silently overwritten by the apply loop below.
860
- for (const rel of new Set([...plan.files.keys(), ...Object.keys(prev.files)])) {
861
- if (plan.forcedFiles.has(rel)) continue;
862
- if (!existsSync(abs(repo, rel))) continue;
863
- if (prev.files[rel]) {
864
- // A derived file is regenerated from the declared set, so a sibling's
865
- // install legitimately changed it since ours -- not drift.
866
- if (!prev.files[rel].derived && fileSha(repo, rel) !== prev.files[rel].sha256) edited.push(rel);
867
- } else if (siblingOwnersOf(siblings, rel).length > 0) {
868
- // Owned by another installed extension, not by a user. Ownership is a
869
- // question about every installed plugin, not just this one.
870
- } else {
871
- conflicts.push(`${rel} (file exists and is not managed by ${bundle.name})`);
872
- foreignFileRels.add(rel);
873
- }
874
- }
875
-
876
- // -- preflight: symlinks. The claude skills symlink is in the same
877
- // owner-forced scope as `forcedFiles` above (skills content, exclusively
878
- // generated by us) -- no preflight gate at all. `ops.symlink` in the apply
879
- // phase below already replaces whatever it finds (a stale pre-symlink real
880
- // directory, a wrong target, nothing) unconditionally.
881
-
882
- // -- preflight: JSON merges
883
- const jsonRels = [...new Set([...plan.json.keys(), ...Object.keys(prev.merged)])].sort();
884
- const jsonDocs = new Map();
885
- for (const rel of jsonRels) {
886
- const path = abs(repo, rel);
887
- const exists = existsSync(path);
888
- const text = exists ? readFileSync(path, "utf8") : null;
889
- const doc = exists ? readJsonStrict(path, rel) : {};
890
- if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw new Error(`${rel} must contain a JSON object`);
891
- jsonDocs.set(rel, { exists, text, doc });
892
- // Hooks/mcp/plugin-enablement merges are owner-forced (see file-header
893
- // comment): our own entries are always repaired to match the bundle on
894
- // the write below, whether or not they drifted since the last install --
895
- // never a preflight refusal, never gated behind --force.
896
- const record = prev.merged[rel];
897
- // Owned by US or by any SIBLING enact plugin. A key another installed
898
- // extension wrote is not foreign: capturing it as a "displaced original"
899
- // made uninstall RESTORE it instead of deleting it, so a shared
900
- // legacy registration key survives migration only while another installed
901
- // extension still records it; otherwise the final uninstall removes it.
902
- const ownedKeys = new Set([
903
- ...(record?.entries ?? []).filter((e) => e.type === "key").map((e) => pathKey(e.path)),
904
- ...siblings.flatMap(({ lock: l }) =>
905
- (l.merged?.[rel]?.entries ?? []).filter((e) => e.type === "key").map((e) => pathKey(e.path)),
906
- ),
907
- ]);
908
- for (const key of plan.json.get(rel)?.keys ?? []) {
909
- if (getAt(doc, key.path) !== undefined && !ownedKeys.has(pathKey(key.path))) {
910
- // A foreign entry sharing OUR key: taken over unconditionally below
911
- // (captured into `displaced` for a byte-exact restore on uninstall,
912
- // exactly like a --force takeover always has -- just without needing
913
- // the flag). A user's OTHER keys are never touched by any of this.
914
- foreignJsonKeys.push({ rel, dKey: pathKey(key.path) });
915
- }
916
- }
917
- }
918
-
919
- // -- preflight: TOML blocks. Same owner-forced scope as the JSON merges
920
- // above (.codex/config.toml's mcp_servers block) -- no edited/conflict
921
- // gating; `foreign` below still drives the unconditional takeover in the
922
- // apply phase.
923
- const tomlRels = [...new Set([...plan.toml.keys(), ...Object.keys(prev.toml)])].sort();
924
- const tomlDocs = new Map();
925
- for (const rel of tomlRels) {
926
- const path = abs(repo, rel);
927
- const exists = existsSync(path);
928
- const text = exists ? readFileSync(path, "utf8") : "";
929
- const found = findBlock(text, bundle.name);
930
- let outside;
931
- try {
932
- outside = TOML.parse(found ? text.slice(0, found.start) + text.slice(found.stop) : text);
933
- } catch (err) {
934
- throw new Error(`Cannot parse ${rel}: ${err instanceof Error ? err.message : String(err)}`);
935
- }
936
- const foreign = Object.keys(plan.toml.get(rel)?.tables ?? {}).filter((s) => outside.mcp_servers?.[s] !== undefined);
937
- tomlDocs.set(rel, { exists, text, foreign });
938
- }
939
-
940
- const problems = [];
941
- if (edited.length > 0) {
942
- problems.push(
943
- `Managed content was edited since the last install (re-run with --force to overwrite):\n ${edited.join("\n ")}`,
944
- );
945
- }
946
- if (conflicts.length > 0) {
947
- problems.push(
948
- `Conflict with existing entries not owned by ${bundle.name} (re-run with --force to take them over):\n ${conflicts.join("\n ")}`,
949
- );
950
- }
951
- if (problems.length > 0 && !force) throw new Error(problems.join("\n"));
952
-
953
- // -- apply
954
- const createdDirs = prev.createdDirs.filter((d) => existsSync(abs(repo, d)));
955
- const writeRepoFile = (rel, content, reason) => {
956
- replaceSymlinkAncestors(repo, rel, ops);
957
- recordMissingDirs(repo, rel, createdDirs);
958
- if (ops.write(rel, content, reason)) written.push(rel);
959
- };
960
- const removeRepoFile = (rel, reason) => {
961
- if (ops.remove(rel, reason)) written.push(rel);
962
- };
963
-
964
- const files = {};
965
- for (const rel of Object.keys(prev.files)) {
966
- if (plan.files.has(rel) || !existsSync(abs(repo, rel))) continue;
967
- // This managed file is dropping out of the plan (e.g. --host narrowed).
968
- // If it took over foreign content, restore it instead of deleting it.
969
- const displacedFile = displaced.files[rel];
970
- if (displacedFile) {
971
- const storedPath = abs(repo, displacedFile.storedAt);
972
- if (!existsSync(storedPath)) throw new Error(`Missing recorded original for ${rel} at ${displacedFile.storedAt}`);
973
- writeRepoFile(rel, readFileSync(storedPath));
974
- removeRepoFile(displacedFile.storedAt);
975
- delete displaced.files[rel];
976
- } else {
977
- removeRepoFile(rel);
978
- }
979
- }
980
- // -- capture originals for whole files taking over pre-existing foreign
981
- // content this run, BEFORE they get overwritten below. Never recapture: if
982
- // an original is already recorded (from an earlier --force takeover), keep it.
983
- for (const rel of foreignFileRels) {
984
- if (displaced.files[rel]) continue;
985
- const storedRel = `.agents/enact-repo-originals/${bundle.name}/${rel}`;
986
- writeRepoFile(storedRel, readFileSync(abs(repo, rel)));
987
- displaced.files[rel] = { storedAt: storedRel };
988
- }
989
- for (const [rel, content] of plan.files) {
990
- writeRepoFile(rel, content);
991
- files[rel] = { sha256: sha256(content), ...(plan.derivedFiles.has(rel) ? { derived: true } : {}) };
992
- }
993
-
994
- const symlinks = {};
995
- for (const [rel, target] of plan.symlinks) {
996
- recordMissingDirs(repo, rel, createdDirs);
997
- if (ops.symlink(rel, target)) written.push(rel);
998
- symlinks[rel] = { target, created: prevSymlinks[rel]?.created ?? true };
999
- }
1000
-
1001
- const merged = {};
1002
- for (const rel of jsonRels) {
1003
- const { exists, text, doc } = jsonDocs.get(rel);
1004
- const record = prev.merged[rel];
1005
- const want = plan.json.get(rel);
1006
- const format = exists ? detectJsonFormat(text) : { indent: " ", trailingNewline: true };
1007
- const containers = [...(record?.containers ?? [])];
1008
- const scaffold = [...(record?.scaffold ?? [])];
1009
-
1010
- if (!want) {
1011
- const relDisplaced = displaced.json[rel] ?? {};
1012
- removeJsonEntries(doc, { entries: record.entries, containers, scaffold }, relDisplaced);
1013
- delete displaced.json[rel];
1014
- if (!exists) continue;
1015
- if (record.created && Object.keys(doc).length === 0) removeRepoFile(rel);
1016
- else writeRepoFile(rel, serializeJson(doc, format));
1017
- continue;
1018
- }
1019
-
1020
- const wanted = new Set([
1021
- ...want.keys.map((k) => `key${pathKey(k.path)}`),
1022
- ...want.items.map((i) => `item${pathKey(i.path)}${valueHash(i.value)}`),
1023
- ]);
1024
- const stale = (record?.entries ?? []).filter((e) =>
1025
- e.type === "key" ? !wanted.has(`key${pathKey(e.path)}`) : !wanted.has(`item${pathKey(e.path)}${e.sha256}`),
1026
- );
1027
- const relDisplaced = displaced.json[rel] ?? {};
1028
- removeJsonEntries(doc, { entries: stale }, relDisplaced);
1029
-
1030
- const created = [];
1031
- for (const s of want.scaffold) {
1032
- if (getAt(doc, s.path) === undefined) doc[s.path[0]] = s.value;
1033
- // Record shared framing whether or not we created it. This is retained
1034
- // for lock migration: all current install projections are direct native
1035
- // files, but an old lock can still name shared metadata that must be
1036
- // removed only after its final owner uninstalls.
1037
- if (!scaffold.some((x) => pathKey(x.path) === pathKey(s.path))) scaffold.push({ path: s.path });
1038
- }
1039
- const entries = [];
1040
- for (const key of want.keys) {
1041
- const parent = ensureContainer(doc, key.path.slice(0, -1), "object", created);
1042
- const k = key.path[key.path.length - 1];
1043
- const h = valueHash(key.value);
1044
- const dKey = pathKey(key.path);
1045
- const isTakeover = foreignJsonKeys.some((f) => f.rel === rel && f.dKey === dKey);
1046
- if (isTakeover && has(parent, k) && !has(relDisplaced, dKey)) relDisplaced[dKey] = parent[k];
1047
- if (!has(parent, k) || valueHash(parent[k]) !== h) parent[k] = key.value;
1048
- entries.push({ type: "key", path: key.path, sha256: h });
1049
- }
1050
- if (Object.keys(relDisplaced).length > 0) displaced.json[rel] = relDisplaced;
1051
- else delete displaced.json[rel];
1052
- for (const item of want.items) {
1053
- const arr = ensureContainer(doc, item.path, "array", created);
1054
- const h = valueHash(item.value);
1055
- if (!arr.some((v) => valueHash(v) === h)) arr.push(item.value);
1056
- entries.push({ type: "item", path: item.path, sha256: h });
1057
- }
1058
- // Containers WE created, plus any a sibling enact plugin recorded creating.
1059
- // Inheriting matters because the second plugin to write into a shared file
1060
- // creates nothing, so it recorded no container and left an empty `{}`
1061
- // behind when it uninstalled last. Recording a container the USER created
1062
- // would be worse -- uninstall would prune a key of theirs -- so this is
1063
- // deliberately limited to containers enact is known to have created.
1064
- const siblingContainers = siblings.flatMap(({ lock: l }) => l.merged?.[rel]?.containers ?? []);
1065
- for (const c of [...created, ...siblingContainers]) {
1066
- if (c.length > 0 && !containers.some((x) => pathKey(x) === pathKey(c))) containers.push(c);
1067
- }
1068
- writeRepoFile(rel, serializeJson(doc, format));
1069
- // `created` is inherited: if ANY installed enact plugin created this file,
1070
- // it is ours to remove once the last of us leaves. Recording only our own
1071
- // creation left the second plugin believing a user owned the file, so an
1072
- // empty `{}` stayed behind forever.
1073
- const createdByEnact =
1074
- record?.created ?? (!exists || siblings.some(({ lock: l }) => l.merged?.[rel]?.created === true));
1075
- merged[rel] = { created: createdByEnact, containers, scaffold, entries };
1076
- }
1077
-
1078
- const toml = {};
1079
- for (const rel of tomlRels) {
1080
- const { exists, foreign } = tomlDocs.get(rel);
1081
- let { text } = tomlDocs.get(rel);
1082
- const record = prev.toml[rel];
1083
- const want = plan.toml.get(rel);
1084
-
1085
- // Cut foreign tables sharing one of OUR managed server names, recording
1086
- // (byte-exact, incl. comments) any not already recorded from an earlier
1087
- // takeover -- unconditional (mcp is owner-forced), not gated by --force.
1088
- const displacedSpans = [...(displaced.toml[rel] ?? [])];
1089
- for (const s of foreign) {
1090
- const already = displacedSpans.some((d) => d.server === s);
1091
- const extracted = extractTomlTable(text, s);
1092
- text = extracted.text;
1093
- if (!already && extracted.removed) displacedSpans.push({ server: s, ...extracted.removed });
1094
- }
1095
-
1096
- const current = findBlock(text, bundle.name);
1097
- if (!want) {
1098
- if (current) text = cutBlock(text, current, record?.prefix ?? "");
1099
- text = reinsertTomlSpans(text, displacedSpans);
1100
- delete displaced.toml[rel];
1101
- if (record?.created && text === "") removeRepoFile(rel);
1102
- else if (exists || displacedSpans.length > 0) writeRepoFile(rel, text);
1103
- continue;
1104
- }
1105
- if (displacedSpans.length > 0) displaced.toml[rel] = displacedSpans;
1106
- else delete displaced.toml[rel];
1107
-
1108
- const block = renderTomlBlock(bundle.name, want.tables);
1109
- let prefix = record?.prefix ?? "";
1110
- if (current) {
1111
- text = text.slice(0, current.start) + block + text.slice(current.stop);
1112
- } else {
1113
- prefix = text.length === 0 ? "" : text.endsWith("\n") ? "\n" : "\n\n";
1114
- text = `${text}${prefix}${block}`;
1115
- }
1116
- try {
1117
- TOML.parse(text);
1118
- } catch (err) {
1119
- throw new Error(`Refusing to write invalid TOML to ${rel}: ${err instanceof Error ? err.message : String(err)}`);
1120
- }
1121
- writeRepoFile(rel, text);
1122
- toml[rel] = { created: record ? record.created : !exists, prefix, sha256: sha256(block) };
1123
- }
1124
-
1125
- // Managed .gitignore files, keyed by repo-relative path:
1126
- // .agents/.gitignore keeps the repo-local lean-ctx runtime dirs
1127
- // (data/cache/state/bin) and the enact-hook logs out
1128
- // of git; the lean-ctx config/ dir stays committed.
1129
- // .claude/.gitignore keeps settings.local.json (absolute, machine-
1130
- // specific LEAN_CTX_*_DIR values) out of git.
1131
- // Only lines we actually add are ever recorded/owned — a line a user
1132
- // already has (even one of ours, verbatim) is left unclaimed and survives
1133
- // uninstall untouched.
1134
- const gitignore = { ...(prev.gitignore ?? {}) };
1135
- for (const rel of Object.keys(gitignore)) {
1136
- // A managed .gitignore that dropped out of the plan (e.g. --host narrowed
1137
- // away from claude): strip our lines, exactly as uninstall would.
1138
- if (plan.gitignore.has(rel)) continue;
1139
- if (removeManagedGitignoreLines(repo, rel, gitignore[rel], ops)) written.push(rel);
1140
- delete gitignore[rel];
1141
- }
1142
- for (const [rel, lines] of plan.gitignore) {
1143
- const path = abs(repo, rel);
1144
- const exists = existsSync(path);
1145
- const text = exists ? readFileSync(path, "utf8") : "";
1146
- const currentLines = new Set(text.split("\n").map((l) => l.trim()));
1147
- const missing = lines.filter((l) => !currentLines.has(l));
1148
- if (missing.length === 0) continue;
1149
- const prefix = text.length === 0 || text.endsWith("\n") ? "" : "\n";
1150
- const block = missing.map((l) => `${l}\n`).join("");
1151
- writeRepoFile(rel, `${text}${prefix}${block}`);
1152
- const owned = new Set([...(prev.gitignore?.[rel]?.lines ?? []), ...missing]);
1153
- // `appended` is the exact byte span (prefix + block) this run tacked onto
1154
- // the end of the file — lets uninstall strip it byte-exact (matching the
1155
- // TOML block / displaced-content restore convention elsewhere in this
1156
- // file) when nothing else touched the file afterward. Only reflects the
1157
- // MOST RECENT append; `lines` (the full owned set) is the correctness
1158
- // fallback if a later edit moved our lines away from the tail.
1159
- gitignore[rel] = {
1160
- created: prev.gitignore?.[rel]?.created ?? !exists,
1161
- lines: [...owned].sort(),
1162
- appended: `${prefix}${block}`,
1163
- };
1164
- }
1165
-
1166
- // enact-config.toml — the managed-repo marker AND the declared extension
1167
- // roster. `[extensions]` is created (with `version = 1`) when missing and
1168
- // appended at text level when the file exists without it; this extension is
1169
- // then declared as its own `[extensions.<name>]` table. Both steps are
1170
- // computed IN MEMORY and written once: re-reading the file between them
1171
- // would make a dry-run plan differently from the real run, because the first
1172
- // write has not happened on disk yet. `appended` is the exact span we added,
1173
- // so `--purge` can cut only that.
1174
- let config;
1175
- {
1176
- let text;
1177
- // The legacy `[skills]` table is already gone from `base` when one was
1178
- // present; `configText` stays the ORIGINAL so the "did anything change"
1179
- // comparison below still sees the cut.
1180
- const base = configLegacyCut ?? configText;
1181
- if (!configExists) {
1182
- const appended = `\n${EXTENSIONS_BLOCK}`;
1183
- text = `${CONFIG_ROOT_CONTENT}${appended}`;
1184
- config = { created: true, appended };
1185
- } else if (!configHasExtensions) {
1186
- const appended = `${base.length === 0 ? "" : base.endsWith("\n") ? "\n" : "\n\n"}${EXTENSIONS_BLOCK}`;
1187
- text = `${base}${appended}`;
1188
- config = { created: prev.config?.created ?? false, appended };
1189
- } else {
1190
- text = base;
1191
- config = prev.config ?? { created: false, appended: null };
1192
- }
1193
- // Insert-once: null means nothing to change (already declared with no
1194
- // stale version key, or no [extensions] to write into) -- see declareExtension.
1195
- const declared = declareExtension(text, bundle.name);
1196
- if (declared !== null) text = declared;
1197
- if (text !== configText) {
1198
- parseConfigText(text, "refusing to write invalid TOML");
1199
- writeRepoFile(CONFIG_PATH, text, `managed-repo marker with [extensions.${bundle.name}]`);
1200
- }
1201
- }
1202
-
1203
- removeEmptyDirs(repo, createdDirs, ops);
1204
- recordMissingDirs(repo, lockRel(bundle.name), createdDirs);
1205
- const lock = {
1206
- lockVersion: LOCK_VERSION,
1207
- plugin: bundle.name,
1208
- version: bundle.version,
837
+ return runAuthoritativeRepoInstall(pluginRoot, options, {
838
+ repo,
839
+ bundle,
1209
840
  hosts,
1210
- files: sortObject(files),
1211
- symlinks: sortObject(symlinks),
1212
- merged: sortObject(merged),
1213
- toml: sortObject(toml),
1214
- gitignore: sortObject(gitignore),
1215
- config,
1216
- createdDirs: [...new Set(createdDirs)].filter((d) => d === ".agents" || existsSync(abs(repo, d))).sort(),
1217
- displaced: pruneDisplaced(displaced),
1218
- };
1219
- writeRepoFile(lockRel(bundle.name), `${JSON.stringify(lock, null, 2)}\n`, "install lock file");
1220
-
1221
- const tag = dryRun ? "[dry-run] " : "";
1222
- console.log(`${tag}repo install ${bundle.name}@${bundle.version} -> ${repo} (hosts: ${hosts.join(", ")})`);
1223
- console.log(
1224
- written.length > 0 ? `${dryRun ? "would change" : "changed"} ${written.length} file(s)` : "up to date (no changes)",
1225
- );
1226
- if (hosts.includes("claude")) {
1227
- console.log("claude: trust this folder and review the project hooks before using the installed agents and skills");
1228
- }
1229
- if (hosts.includes("codex"))
1230
- console.log("codex: config and hooks load after you trust this project and review hooks (/hooks)");
1231
- if (hosts.includes("kimi"))
1232
- console.log("kimi: hooks are global-only (installed by enact-vps); no repo hook file written");
1233
- return { repo, name: bundle.name, version: bundle.version, hosts, changed: written, actions, dryRun };
841
+ plan,
842
+ prev,
843
+ siblings,
844
+ ops,
845
+ actions,
846
+ dryRun,
847
+ });
1234
848
  }
1235
849
 
1236
850
  // ---------------------------------------------------------------------------
@@ -1239,234 +853,32 @@ export function runRepoInstall(pluginRoot, options = {}) {
1239
853
 
1240
854
  export function runRepoUninstall(name, options = {}) {
1241
855
  const repo = resolveRepoRoot(options.repo);
1242
- const force = options.force === true;
1243
856
  const dryRun = options.dryRun === true;
1244
857
  const actions = [];
1245
858
  const ops = makeFileOps(repo, dryRun, actions);
859
+ assertMarkedRepo(repo);
1246
860
  const lock = readLock(repo, name);
1247
861
  if (!lock) throw new Error(`${name} is not installed in ${repo} (no lock at ${lockRel(name)})`);
1248
- // Other plugins still installed here; their claims outlive this uninstall.
1249
- const siblings = readSiblingLocks(repo, name);
1250
-
1251
- const uninstallConfigPath = abs(repo, CONFIG_PATH);
1252
- const uninstallConfigText = existsSync(uninstallConfigPath) ? readFileSync(uninstallConfigPath, "utf8") : "";
1253
-
1254
- const edited = [];
1255
- for (const [rel, record] of Object.entries(lock.files)) {
1256
- // Derived files are regenerated whenever the declared set changes -- a
1257
- // sibling's install or uninstall rewrites them legitimately, so a hash
1258
- // mismatch here is expected, not user damage.
1259
- if (record.derived) continue;
1260
- if (existsSync(abs(repo, rel)) && fileSha(repo, rel) !== record.sha256) edited.push(rel);
862
+ const roots = lock.ownedRoots ?? [".agents", ...(lock.hosts ?? []).map(rootForHost)];
863
+ for (const root of roots) {
864
+ const boundaries = UNMANAGED_RUNTIME_BOUNDARIES.filter((path) => path.startsWith(`${root}/`));
865
+ clearOwnedRoot(repo, root, boundaries, ops);
1261
866
  }
1262
- for (const [rel, record] of Object.entries(lock.symlinks ?? {})) {
1263
- // lstat, not existsSync: existsSync follows the link and would silently
1264
- // skip a dangling one instead of flagging that something is off.
1265
- const st = lstatSync(abs(repo, rel), { throwIfNoEntry: false });
1266
- if (st && !(st.isSymbolicLink() && readlinkSync(abs(repo, rel)) === record.target)) edited.push(rel);
867
+ for (const root of RETIRED_REPO_ROOTS) ops.removeDir(root, "retired Enact plugin root");
868
+ for (const rel of [".mcp.json", "opencode.json"]) {
869
+ if (lock.files?.[rel]) ops.remove(rel, "authoritative owned file removed on uninstall");
1267
870
  }
1268
- const jsonDocs = new Map();
1269
- for (const [rel, record] of Object.entries(lock.merged)) {
1270
- const path = abs(repo, rel);
1271
- if (!existsSync(path)) {
1272
- edited.push(`${rel} (file removed)`);
1273
- continue;
1274
- }
1275
- const text = readFileSync(path, "utf8");
1276
- const doc = readJsonStrict(path, rel);
1277
- for (const entry of record.entries) {
1278
- // An entry a SIBLING also records is shared lock-owned metadata, not
1279
- // drift. This only matters while uninstalling historical lock content;
1280
- // current installs create no plugin registration or marketplace data.
1281
- if (siblingRecordsJsonEntry(siblings, rel, entry)) continue;
1282
- if (!entryPresent(doc, entry)) edited.push(`${rel} (${entry.path.join(".")})`);
1283
- }
1284
- jsonDocs.set(rel, { text, doc });
1285
- }
1286
- for (const [rel, record] of Object.entries(lock.toml)) {
1287
- const path = abs(repo, rel);
1288
- const found = existsSync(path) ? findBlock(readFileSync(path, "utf8"), name) : null;
1289
- if (!found || sha256(found.block) !== record.sha256) edited.push(rel);
1290
- }
1291
- // --purge cuts our [extensions] block from enact-config.toml; plan it up front so
1292
- // an edited block is refused like any other edited managed content.
1293
- let configCut = null;
1294
- if (options.purge && typeof lock.config?.appended === "string" && existsSync(abs(repo, CONFIG_PATH))) {
1295
- const text = readFileSync(abs(repo, CONFIG_PATH), "utf8");
1296
- configCut = cutExtensionsBlock(text, lock.config.appended);
1297
- if (configCut === null) edited.push(`${CONFIG_PATH} ([extensions] block edited since install)`);
1298
- }
1299
- if (edited.length > 0 && !force) {
1300
- throw new Error(
1301
- `Managed content was edited since install (re-run with --force to remove anyway):\n ${edited.join("\n ")}`,
1302
- );
1303
- }
1304
-
1305
- // Originals displaced by a --force takeover during install; restored below
1306
- // instead of being dropped along with the rest of the managed content.
1307
- const lockDisplaced = lock.displaced ?? { files: {}, json: {}, toml: {} };
1308
-
1309
- for (const rel of Object.keys(lock.files)) {
1310
- // Reference counting: a path another installed plugin still claims is not
1311
- // ours to delete. Bundles ship byte-identical skills (cmux-send,
1312
- // cmux-leader and remove-deadcode are identical across bundles today), so
1313
- // without this, uninstalling one plugin silently removes a skill a
1314
- // surviving plugin's lock still points at -- which its doctor then reports
1315
- // as a missing managed file.
1316
- const alsoOwnedBy = siblingOwnersOf(siblings, rel);
1317
- if (alsoOwnedBy.length > 0) {
1318
- ops.skip?.(rel, `still owned by ${alsoOwnedBy.join(", ")}`);
1319
- continue;
1320
- }
1321
- const displacedFile = lockDisplaced.files[rel];
1322
- if (displacedFile) {
1323
- const storedPath = abs(repo, displacedFile.storedAt);
1324
- if (!existsSync(storedPath)) throw new Error(`Missing recorded original for ${rel} at ${displacedFile.storedAt}`);
1325
- ops.write(rel, readFileSync(storedPath), "restoring pre-install original content");
1326
- ops.remove(displacedFile.storedAt, "captured original no longer needed");
1327
- } else {
1328
- ops.remove(rel, "managed file");
1329
- }
1330
- }
1331
- for (const rel of Object.keys(lock.symlinks ?? {})) {
1332
- ops.removeSymlink(rel, "managed symlink");
1333
- }
1334
- // Undeclare this extension: remove its own `[extensions.<name>]` table. The
1335
- // `[extensions]` root table is cut only by --purge, and only once no other
1336
- // extension is declared.
1337
- if (uninstallConfigText.length > 0) {
1338
- const next = removeExtensionBlock(uninstallConfigText, name);
1339
- if (next !== uninstallConfigText) {
1340
- parseConfigText(next, "refusing to write invalid TOML");
1341
- ops.write(CONFIG_PATH, next, `undeclared [extensions.${name}]`);
1342
- }
1343
- }
1344
- for (const [rel, record] of Object.entries(lock.merged)) {
1345
- if (!jsonDocs.has(rel)) continue;
1346
- const { text, doc } = jsonDocs.get(rel);
1347
- // Reference-count historical shared metadata just as managed files. A
1348
- // sibling lock can still own an old registration during migration, so the
1349
- // first uninstall must leave it for the final owner to remove.
1350
- const retained = record.entries.filter((e) => siblingRecordsJsonEntry(siblings, rel, e));
1351
- const ours = { ...record, entries: record.entries.filter((e) => !retained.includes(e)) };
1352
- for (const e of retained) ops.skip(`${rel} (${e.path.join(".")})`, "still registered by another installed plugin");
1353
- removeJsonEntries(doc, ours, { ...(lockDisplaced.json[rel] ?? {}) });
1354
- // Remove only a file WE created -- never one the user already had, even if
1355
- // our entries were the last thing in it. `created` is inherited across
1356
- // siblings at install time (see below), so the second plugin to write a
1357
- // shared file still knows enact created it and can clean it up when it is
1358
- // the last one out.
1359
- if (record.created && Object.keys(doc).length === 0) ops.remove(rel, "json merge file (now empty)");
1360
- else ops.write(rel, serializeJson(doc, detectJsonFormat(text)), "json merge entries removed");
1361
- }
1362
- for (const [rel, record] of Object.entries(lock.toml)) {
1363
- const path = abs(repo, rel);
1364
- if (!existsSync(path)) continue;
1365
- const text = readFileSync(path, "utf8");
1366
- const found = findBlock(text, name);
1367
- if (!found) continue;
1368
- const out = reinsertTomlSpans(cutBlock(text, found, record.prefix), lockDisplaced.toml[rel] ?? []);
1369
- if (record.created && out === "") ops.remove(rel, "toml block removed (file now empty)");
1370
- else ops.write(rel, out, "toml mcp_servers block removed");
1371
- }
1372
- // Whether this uninstall must leave managed content behind that only
1373
- // `--purge` can remove: the lean-ctx runtime dirs AND the enact-hook log
1374
- // files (both our own runtime's state, never user data) are still
1375
- // present, so the `.agents/.gitignore` lines hiding them from git must
1376
- // stay too (removing them would surface those paths as untracked in
1377
- // `git status`).
1378
- //
1379
- // Only `.agents/.gitignore` is ever retained: it is the one hiding that
1380
- // runtime state. Every other managed .gitignore (today: `.claude/.gitignore`,
1381
- // hiding settings.local.json) has its lines stripped unconditionally, and
1382
- // the file itself goes when we created it and nothing else is left in it.
1383
- const lockGitignore = lock.gitignore ?? {};
1384
- for (const [rel, ginfo] of Object.entries(lockGitignore)) {
1385
- if (rel === GITIGNORE_PATH) continue;
1386
- removeManagedGitignoreLines(repo, rel, ginfo, ops);
1387
- }
1388
- let retainedGitignore = false;
1389
- if (lockGitignore[GITIGNORE_PATH]) {
1390
- const presentRuntimeDirs = LEAN_CTX_RUNTIME_DIRS.filter((rel) => existsSync(abs(repo, rel)));
1391
- const presentHookLogFiles = HOOK_LOG_FILES.filter((rel) => existsSync(abs(repo, rel)));
1392
- if (options.purge) {
1393
- // Runtime state OUR OWN runtime created (not user data): safe to
1394
- // remove outright, before the gitignore lines that were hiding it.
1395
- for (const rel of presentRuntimeDirs) ops.removeDir(rel, "lean-ctx runtime dir (--purge)");
1396
- for (const rel of presentHookLogFiles) ops.remove(rel, "enact-hook log file (--purge)");
1397
- }
1398
- const presentRuntimePaths = [...presentRuntimeDirs, ...presentHookLogFiles];
1399
- retainedGitignore = !options.purge && presentRuntimePaths.length > 0;
1400
- if (retainedGitignore) {
1401
- const kept = [
1402
- GITIGNORE_PATH,
1403
- ...(typeof lock.config?.appended === "string" ? [`${CONFIG_PATH} [extensions]`] : []),
1404
- ];
1405
- console.log(
1406
- `kept ${kept.join(" and ")} -- lean-ctx runtime dirs / enact-hook log files still present (${presentRuntimePaths.join(", ")}); re-run uninstall --purge to remove them too`,
1407
- );
1408
- } else {
1409
- removeManagedGitignoreLines(repo, GITIGNORE_PATH, lockGitignore[GITIGNORE_PATH], ops);
1410
- }
1411
- }
1412
-
1413
- if (options.purge || !retainedGitignore) {
1414
- ops.remove(lockRel(name), "uninstall lock file");
1415
- } else {
1416
- // Something above is still waiting on `--purge`: rewrite the SAME lock
1417
- // file (same path, same schema) down to a minimal residual -- no
1418
- // managed files/json/toml left (those were already fully removed
1419
- // above), just the gitignore/config bookkeeping a later STANDALONE
1420
- // `uninstall --purge` call needs in order to find and finish the job.
1421
- // This is what makes the printed "re-run uninstall --purge" message
1422
- // above actually true: readLock() at the top of that later call will
1423
- // find this file (and see it as belonging to `name`), not nothing --
1424
- // this is the fix for the bug where a plain uninstall unconditionally
1425
- // deleted the lock even while retaining content, so a follow-up
1426
- // `uninstall --purge` had nothing to read and refused with "not
1427
- // installed ... (no lock)". `install` re-run against this residual
1428
- // lock adopts `gitignore`/`config`/`createdDirs` from it unchanged
1429
- // (see `runRepoInstall`'s `prev = readLock(...) ?? {...}`) and
1430
- // overwrites it with a full lock again -- idempotent either way.
1431
- const residual = {
1432
- lockVersion: LOCK_VERSION,
1433
- plugin: name,
1434
- version: lock.version,
1435
- hosts: [],
1436
- files: {},
1437
- symlinks: {},
1438
- merged: {},
1439
- toml: {},
1440
- // Only the still-retained `.agents/.gitignore` record: every other
1441
- // managed .gitignore was already stripped above, so a later standalone
1442
- // `--purge` must not try to strip it a second time.
1443
- gitignore: { [GITIGNORE_PATH]: lockGitignore[GITIGNORE_PATH] },
1444
- config: lock.config,
1445
- createdDirs: lock.createdDirs,
1446
- displaced: { files: {}, json: {}, toml: {} },
1447
- };
1448
- ops.write(
1449
- lockRel(name),
1450
- `${JSON.stringify(residual, null, 2)}\n`,
1451
- "residual lock (gitignore/config retained pending --purge)",
1452
- );
1453
- }
1454
-
1455
- if (options.purge) {
1456
- if (configCut !== null) {
1457
- if (lock.config.created && configCut.trim() === CONFIG_ROOT_CONTENT.trim()) {
1458
- ops.remove(CONFIG_PATH, "managed-repo marker created by install ([extensions] removed, only version = 1 left)");
1459
- } else {
1460
- ops.write(CONFIG_PATH, configCut, "[extensions] block removed (--purge)");
1461
- }
1462
- } else if (typeof lock.config?.appended === "string" && existsSync(abs(repo, CONFIG_PATH))) {
1463
- console.log(`kept ${CONFIG_PATH} [extensions] block (edited since install; --force never cuts an edited block)`);
1464
- } else if (existsSync(abs(repo, CONFIG_PATH))) {
1465
- console.log(`kept ${CONFIG_PATH} (install did not write its [extensions] block)`);
1466
- }
1467
- purgeOpenCodeAutogenIfClean(repo, ops);
1468
- }
1469
- removeEmptyDirs(repo, lock.createdDirs, ops);
1470
- console.log(`${dryRun ? "[dry-run] " : ""}repo uninstall ${name} <- ${repo}${options.purge ? " (purged)" : ""}`);
1471
- return { repo, name, removedFiles: Object.keys(lock.files).length, actions, dryRun };
871
+ const configPath = abs(repo, CONFIG_PATH);
872
+ const configText = readFileSync(configPath, "utf8");
873
+ const withoutDeclaration = removeExtensionBlock(configText, name);
874
+ let nextConfig = withoutDeclaration;
875
+ if (options.purge && typeof lock.config?.appended === "string") {
876
+ nextConfig = cutExtensionsBlock(nextConfig, lock.config.appended) ?? nextConfig;
877
+ }
878
+ if (nextConfig !== configText) {
879
+ inspectExtensionsScope(nextConfig, "removing [extensions] declaration");
880
+ ops.write(CONFIG_PATH, nextConfig, `removed [extensions.${name}]`);
881
+ }
882
+ console.log(`${dryRun ? "[dry-run] " : ""}repo uninstall ${name} <- ${repo} (authoritative roots removed)`);
883
+ return { repo, name, removedFiles: Object.keys(lock.files ?? {}).length, actions, dryRun };
1472
884
  }