@amsterdamdatalabs/enact-extensions 0.1.55 → 0.1.57

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
@@ -17,84 +15,39 @@
17
15
  * kimi — `.kimi-code/agents`, `.kimi-code/skills`, and `.kimi-code/mcp.json`.
18
16
  * Kimi hooks remain global-only and are not owned here.
19
17
  * opencode — `.opencode/agents`, `.opencode/skills`, and `opencode.json` MCP.
20
- * OpenCode has no native project hook surface, so a hook-bearing
21
- * bundle fails before writes and names enact-vps' global plugin path.
18
+ * Its process-level hook carrier is global, while this installer
19
+ * owns every repo-local artifact and legacy-root migration.
22
20
  *
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) {
@@ -507,11 +479,6 @@ const HOST_ADAPTERS = {
507
479
  },
508
480
 
509
481
  opencode(plan, bundle, pluginRoot) {
510
- if (bundle.hooks) {
511
- throw new Error(
512
- "OpenCode repo-local installation cannot install hooks: install the explicit global OpenCode plugin through enact-vps first",
513
- );
514
- }
515
482
  addNativeAgents(plan, pluginRoot, "opencode");
516
483
  addNativeSkills(plan, bundle, "opencode");
517
484
  const mcp = jsonMerge(plan, "opencode.json");
@@ -532,8 +499,7 @@ export function buildPlan(pluginRoot, bundle, hosts, repo) {
532
499
  files: new Map(),
533
500
  // Subset of `files` keys that are owner-forced (hooks/mcp/skills content
534
501
  // exclusively generated by us): always overwritten, never gated behind
535
- // `--force`, never captured for a displaced-content restore. See the
536
- // file-header doc comment.
502
+ // File content is always materialized from the canonical bundle.
537
503
  forcedFiles: new Set(),
538
504
  // Files regenerated from a source of truth. They are exempt from uninstall
539
505
  // drift checks when a sibling can legitimately rewrite them.
@@ -563,11 +529,189 @@ export function buildPlan(pluginRoot, bundle, hosts, repo) {
563
529
  }
564
530
 
565
531
  // ---------------------------------------------------------------------------
566
- // JSON merge helpers
532
+ // authoritative repo-root reconciliation
567
533
  // ---------------------------------------------------------------------------
568
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
+
569
713
  // ---------------------------------------------------------------------------
570
- // TOML block helpers — text level, so user content and comments are untouched
714
+ // JSON merge helpers
571
715
  // ---------------------------------------------------------------------------
572
716
 
573
717
  // ---------------------------------------------------------------------------
@@ -594,10 +738,8 @@ export function readLock(repo, name) {
594
738
  * of the same skill names), so "is this path mine?" is the wrong question. The
595
739
  * right one is "does ANY installed plugin own it?".
596
740
  *
597
- * Reads only `.agents/*.lock.json`, skipping `exclude` and any file whose
598
- * `plugin` field disagrees with its filename (readLock throws on that, and a
599
- * malformed sibling must not break an unrelated install -- it is reported by
600
- * 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.
601
743
  */
602
744
  export function readSiblingLocks(repo, exclude) {
603
745
  const dir = abs(repo, ".agents");
@@ -607,12 +749,7 @@ export function readSiblingLocks(repo, exclude) {
607
749
  if (!entry.isFile() || !entry.name.endsWith(".lock.json")) continue;
608
750
  const name = entry.name.slice(0, -".lock.json".length);
609
751
  if (name === exclude) continue;
610
- let lock;
611
- try {
612
- lock = readLock(repo, name);
613
- } catch {
614
- continue;
615
- }
752
+ const lock = readLock(repo, name);
616
753
  if (lock) out.push({ name, lock });
617
754
  }
618
755
  return out;
@@ -642,43 +779,25 @@ function replaceSymlinkAncestors(repo, rel, ops) {
642
779
  }
643
780
  }
644
781
 
645
- function recordMissingDirs(repo, rel, createdDirs) {
646
- const parts = rel.split("/").slice(0, -1);
647
- for (let i = 1; i <= parts.length; i++) {
648
- const dir = parts.slice(0, i).join("/");
649
- if (!existsSync(abs(repo, dir)) && !createdDirs.includes(dir)) createdDirs.push(dir);
650
- }
651
- }
652
-
653
- function removeEmptyDirs(repo, dirs, ops) {
654
- for (const dir of [...dirs].sort((a, b) => b.split("/").length - a.split("/").length)) {
655
- ops.rmdirIfEmpty(dir);
656
- }
657
- }
658
-
659
- // Strip the lines this plugin's install added to a managed `.gitignore` (per
660
- // `ginfo` = one entry of a lock's `gitignore` map: {created, lines,
661
- // appended}), same convention as the TOML-block / displaced-content removal
662
- // elsewhere in this file: prefer stripping the exact byte-appended tail, fall
663
- // back to a line-by-line filter over the owned set. Any line NOT in
664
- // `ginfo.lines` (including a user's own pre-existing content) is left
665
- // untouched. Removes the file entirely when it was created by us and is now
666
- // empty. Returns whether anything changed on disk.
667
- function removeManagedGitignoreLines(repo, rel, ginfo, ops) {
668
- const path = abs(repo, rel);
669
- if (!existsSync(path)) return false;
670
- const text = readFileSync(path, "utf8");
671
- const appended = ginfo.appended;
672
- const out =
673
- typeof appended === "string" && text.endsWith(appended)
674
- ? text.slice(0, text.length - appended.length)
675
- : text
676
- .split("\n")
677
- .filter((l) => !new Set(ginfo.lines ?? []).has(l.trim()))
678
- .join("\n");
679
- if (ginfo.created && out.trim() === "") return ops.remove(rel, "gitignore lines removed (file now empty)");
680
- return ops.write(rel, out, "gitignore lines removed");
681
- }
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
+ ];
682
801
 
683
802
  // ---------------------------------------------------------------------------
684
803
  // enact-config.toml [extensions] — text-level only. The document is parsed
@@ -686,67 +805,19 @@ function removeManagedGitignoreLines(repo, rel, ginfo, ops) {
686
805
  // other table is read, and the document is never re-serialized.
687
806
  // ---------------------------------------------------------------------------
688
807
 
689
- function parseConfigText(text, action) {
690
- try {
691
- return TOML.parse(text);
692
- } catch (err) {
693
- throw new Error(`Cannot parse ${CONFIG_PATH} (${action}): ${err instanceof Error ? err.message : String(err)}`);
694
- }
695
- }
696
-
697
- /**
698
- * `uninstall --purge` rule for `.opencode/`: OpenCode auto-generates
699
- * `node_modules/`, `package.json`, `package-lock.json`, `bun.lock` and
700
- * `.gitignore` there the first time it loads a project plugin (verified
701
- * 2026-09-14, this session -- see OPENCODE_AUTOGEN_ENTRIES' doc comment).
702
- * We never write those files ourselves (only `plugins/<name>.ts`, already
703
- * removed by the managed-file removal loop above). Remove them ONLY when
704
- * every remaining entry under `.opencode/` is one of OpenCode's own
705
- * auto-generated names (and `plugins/`, if present, is now empty) --
706
- * i.e. nothing user-owned is left that could be silently destroyed.
707
- */
708
- function purgeOpenCodeAutogenIfClean(repo, ops) {
709
- const dir = abs(repo, ".opencode");
710
- if (!existsSync(dir)) return;
711
- const entries = readdirSync(dir);
712
- // `plugins/` and `skills/` are both OURS: the generated TS plugin and, since
713
- // OpenCode is the only host whose plugin surface cannot carry skills, its
714
- // flat skills directory. Both are emptied by the managed-file removal above,
715
- // so an empty one is not user content -- but this check runs before
716
- // removeEmptyDirs, so the now-empty directories are still on disk here.
717
- // Recursive: removing our managed files leaves the skill directories behind
718
- // as empty shells until removeEmptyDirs runs (after this), so a top-level
719
- // listing would report them as user content. What matters is whether any FILE
720
- // survives, not whether the directory is literally empty.
721
- const hasAnyFile = (d) =>
722
- existsSync(d) &&
723
- readdirSync(d, { withFileTypes: true }).some((e) => (e.isDirectory() ? hasAnyFile(join(d, e.name)) : true));
724
- const ourDirs = ["plugins", "skills"];
725
- const notEmpty = ourDirs.filter((d) => hasAnyFile(join(dir, d)));
726
- const leftover = entries.filter((e) => !ourDirs.includes(e) && !OPENCODE_AUTOGEN_ENTRIES.has(e));
727
- if (leftover.length > 0 || notEmpty.length > 0) {
728
- console.log(
729
- `kept .opencode/ auto-generated files (user-owned content present: ${[...leftover, ...notEmpty.map((d) => `${d}/*`)].join(", ")})`,
730
- );
731
- return;
732
- }
733
- for (const e of entries) ops.removeDir(`.opencode/${e}`, "OpenCode auto-generated file (--purge)");
734
- ops.rmdirIfEmpty(".opencode");
735
- console.log(
736
- "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)",
737
- );
738
- }
739
-
740
808
  // ---------------------------------------------------------------------------
741
809
  // install
742
810
  // ---------------------------------------------------------------------------
743
811
 
744
812
  export function runRepoInstall(pluginRoot, options = {}) {
745
813
  const repo = resolveRepoRoot(options.repo);
746
- const force = options.force === true;
747
814
  const dryRun = options.dryRun === true;
748
815
  const actions = [];
749
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);
750
821
  assertSupportedHookEvents(pluginRoot);
751
822
  const bundle = readBundle(pluginRoot);
752
823
  const hosts = parseRepoHosts(options.host, bundle.manifest);
@@ -757,485 +828,23 @@ export function runRepoInstall(pluginRoot, options = {}) {
757
828
  const siblings = readSiblingLocks(repo, bundle.name);
758
829
  const prev = readLock(repo, bundle.name) ?? {
759
830
  files: {},
760
- merged: {},
761
831
  toml: {},
762
832
  gitignore: {},
763
833
  symlinks: {},
764
834
  config: null,
765
835
  createdDirs: [],
766
836
  };
767
- const prevSymlinks = prev.symlinks ?? {};
768
- const prevDisplaced = prev.displaced ?? { files: {}, json: {}, toml: {} };
769
- // Originals displaced by a --force takeover of pre-existing foreign content,
770
- // carried forward from the previous lock and extended below. Re-running
771
- // install never overwrites an already-recorded original (see per-surface
772
- // "already recorded" guards): the FIRST takeover is the only one that ever
773
- // writes here. Uninstall restores exactly what is recorded here.
774
- const displaced = {
775
- files: { ...prevDisplaced.files },
776
- json: Object.fromEntries(Object.entries(prevDisplaced.json ?? {}).map(([rel, m]) => [rel, { ...m }])),
777
- toml: Object.fromEntries(Object.entries(prevDisplaced.toml ?? {}).map(([rel, spans]) => [rel, [...spans]])),
778
- };
779
-
780
- // -- preflight: enact-config.toml must be valid TOML before we append to it
781
- const configExists = existsSync(abs(repo, CONFIG_PATH));
782
- const configText = configExists ? readFileSync(abs(repo, CONFIG_PATH), "utf8") : null;
783
- const configHasExtensions =
784
- configExists && parseConfigText(configText, "refusing to append [extensions]").extensions !== undefined;
785
- // -- preflight: the legacy `[skills]` marker is cut here, not carried. It
786
- // refuses rather than guesses when the table is not the one install wrote,
787
- // and it runs BEFORE any file is touched so a refusal leaves the repo
788
- // untouched instead of half-installed.
789
- const configLegacyCut = configExists ? cutLegacySkillsBlock(configText) : null;
790
-
791
- // -- preflight: skills never overwrite a USER-AUTHORED skill dir (not even
792
- // with --force). A dir owned by THIS plugin, or by any SIBLING plugin
793
- // installed in the same repo, is fine: `.agents/skills/` is one flat pool
794
- // shared across plugins by design, and bundles legitimately ship the same
795
- // skill name (enact-repo and engineering share 23). Checking only `prev`
796
- // made every sibling-owned dir look foreign, which refused the install with
797
- // a false "non-managed" claim and advised deleting files another plugin
798
- // depends on.
799
- const skillConflicts = [...plan.skills].filter(
800
- (dirRel) =>
801
- existsSync(abs(repo, dirRel)) &&
802
- !Object.keys(prev.files).some((f) => f.startsWith(`${dirRel}/`)) &&
803
- !siblingOwnsPrefix(siblings, dirRel),
804
- );
805
- if (skillConflicts.length > 0) {
806
- throw new Error(
807
- `Refusing to overwrite existing user-authored skill dir(s); rename or remove them first:\n ${skillConflicts.join("\n ")}`,
808
- );
809
- }
810
-
811
- // A sibling owning the SAME path with DIFFERENT bytes is the genuinely
812
- // dangerous case, and it used to pass silently: skill files are forcedFiles,
813
- // so plugin B simply overwrote plugin A's `handoff/SKILL.md` with an
814
- // unrelated skill and nothing warned. `.agents/skills/` is flat on every host
815
- // that reads it, so two plugins cannot ship different skills under one name.
816
- // Refuse, and do NOT let --force through -- forcing would leave the other
817
- // plugin's lock pointing at bytes it did not write.
818
- const SHARED_CONTENT_EXEMPT = new Set();
819
- const divergent = [];
820
- // Every repo-relative path this run changed, in write order. Declared HERE
821
- // rather than beside the other `-- apply` locals because the stale-symlink
822
- // sweep below already pushes to it, and that sweep has to run before any file
823
- // is written. `const` further down put this in its temporal dead zone, so the
824
- // sweep threw `Cannot access 'written' before initialization` on exactly the
825
- // runs where it had a symlink to drop -- see install-stale-symlink-crash.test.mjs.
826
- const written = [];
827
- // BEFORE any file is written: drop a recorded symlink that is no longer in
828
- // the plan. A managed directory that is still a symlink from an earlier
829
- // layout would otherwise be FOLLOWED by the writes below -- the skills a
830
- // plugin root should now own would land wherever the old link pointed, and
831
- // the link would survive, so nothing looked wrong.
832
- for (const rel of Object.keys(prevSymlinks)) {
833
- if (plan.symlinks.has(rel)) continue;
834
- if (ops.removeSymlink(rel, "symlink dropped out of plan")) written.push(rel);
835
- }
836
- for (const [rel, content] of plan.files) {
837
- if (SHARED_CONTENT_EXEMPT.has(rel)) continue;
838
- for (const { name: owner, lock } of siblings) {
839
- const theirs = lock.files?.[rel]?.sha256;
840
- if (theirs && theirs !== sha256(content)) divergent.push({ rel, owner });
841
- }
842
- }
843
- if (divergent.length > 0) {
844
- throw new Error(
845
- `Refusing to install: another plugin already provides these paths with different content.\n` +
846
- `Two plugins cannot ship different content under the same shared path.\n` +
847
- divergent.map(({ rel, owner }) => ` ${rel} (installed by ${owner})`).join("\n") +
848
- `\nRename one of them.`,
849
- );
850
- }
851
-
852
- const edited = [];
853
- const conflicts = [];
854
- // Structured tracking (in parallel with the human-readable `conflicts`
855
- // strings above) of exactly which foreign whole files / JSON keys a
856
- // --force takeover is about to overwrite, so their original content can be
857
- // captured before it's lost.
858
- const foreignFileRels = new Set();
859
- const foreignJsonKeys = [];
860
-
861
- // -- preflight: whole managed files. `forcedFiles` (hooks/mcp/skills
862
- // content, exclusively ours -- see file-header comment) is skipped
863
- // entirely: drift or a stale pre-existing entry there is never a blocking
864
- // conflict, just silently overwritten by the apply loop below.
865
- for (const rel of new Set([...plan.files.keys(), ...Object.keys(prev.files)])) {
866
- if (plan.forcedFiles.has(rel)) continue;
867
- if (!existsSync(abs(repo, rel))) continue;
868
- if (prev.files[rel]) {
869
- // A derived file is regenerated from the declared set, so a sibling's
870
- // install legitimately changed it since ours -- not drift.
871
- if (!prev.files[rel].derived && fileSha(repo, rel) !== prev.files[rel].sha256) edited.push(rel);
872
- } else if (siblingOwnersOf(siblings, rel).length > 0) {
873
- // Owned by another installed extension, not by a user. Ownership is a
874
- // question about every installed plugin, not just this one.
875
- } else {
876
- conflicts.push(`${rel} (file exists and is not managed by ${bundle.name})`);
877
- foreignFileRels.add(rel);
878
- }
879
- }
880
-
881
- // -- preflight: symlinks. The claude skills symlink is in the same
882
- // owner-forced scope as `forcedFiles` above (skills content, exclusively
883
- // generated by us) -- no preflight gate at all. `ops.symlink` in the apply
884
- // phase below already replaces whatever it finds (a stale pre-symlink real
885
- // directory, a wrong target, nothing) unconditionally.
886
-
887
- // -- preflight: JSON merges
888
- const jsonRels = [...new Set([...plan.json.keys(), ...Object.keys(prev.merged)])].sort();
889
- const jsonDocs = new Map();
890
- for (const rel of jsonRels) {
891
- const path = abs(repo, rel);
892
- const exists = existsSync(path);
893
- const text = exists ? readFileSync(path, "utf8") : null;
894
- const doc = exists ? readJsonStrict(path, rel) : {};
895
- if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw new Error(`${rel} must contain a JSON object`);
896
- jsonDocs.set(rel, { exists, text, doc });
897
- // Hooks/mcp/plugin-enablement merges are owner-forced (see file-header
898
- // comment): our own entries are always repaired to match the bundle on
899
- // the write below, whether or not they drifted since the last install --
900
- // never a preflight refusal, never gated behind --force.
901
- const record = prev.merged[rel];
902
- // Owned by US or by any SIBLING enact plugin. A key another installed
903
- // extension wrote is not foreign: capturing it as a "displaced original"
904
- // made uninstall RESTORE it instead of deleting it, so a shared
905
- // legacy registration key survives migration only while another installed
906
- // extension still records it; otherwise the final uninstall removes it.
907
- const ownedKeys = new Set([
908
- ...(record?.entries ?? []).filter((e) => e.type === "key").map((e) => pathKey(e.path)),
909
- ...siblings.flatMap(({ lock: l }) =>
910
- (l.merged?.[rel]?.entries ?? []).filter((e) => e.type === "key").map((e) => pathKey(e.path)),
911
- ),
912
- ]);
913
- for (const key of plan.json.get(rel)?.keys ?? []) {
914
- if (getAt(doc, key.path) !== undefined && !ownedKeys.has(pathKey(key.path))) {
915
- // A foreign entry sharing OUR key: taken over unconditionally below
916
- // (captured into `displaced` for a byte-exact restore on uninstall,
917
- // exactly like a --force takeover always has -- just without needing
918
- // the flag). A user's OTHER keys are never touched by any of this.
919
- foreignJsonKeys.push({ rel, dKey: pathKey(key.path) });
920
- }
921
- }
922
- }
923
-
924
- // -- preflight: TOML blocks. Same owner-forced scope as the JSON merges
925
- // above (.codex/config.toml's mcp_servers block) -- no edited/conflict
926
- // gating; `foreign` below still drives the unconditional takeover in the
927
- // apply phase.
928
- const tomlRels = [...new Set([...plan.toml.keys(), ...Object.keys(prev.toml)])].sort();
929
- const tomlDocs = new Map();
930
- for (const rel of tomlRels) {
931
- const path = abs(repo, rel);
932
- const exists = existsSync(path);
933
- const text = exists ? readFileSync(path, "utf8") : "";
934
- const found = findBlock(text, bundle.name);
935
- let outside;
936
- try {
937
- outside = TOML.parse(found ? text.slice(0, found.start) + text.slice(found.stop) : text);
938
- } catch (err) {
939
- throw new Error(`Cannot parse ${rel}: ${err instanceof Error ? err.message : String(err)}`);
940
- }
941
- const foreign = Object.keys(plan.toml.get(rel)?.tables ?? {}).filter((s) => outside.mcp_servers?.[s] !== undefined);
942
- tomlDocs.set(rel, { exists, text, foreign });
943
- }
944
-
945
- const problems = [];
946
- if (edited.length > 0) {
947
- problems.push(
948
- `Managed content was edited since the last install (re-run with --force to overwrite):\n ${edited.join("\n ")}`,
949
- );
950
- }
951
- if (conflicts.length > 0) {
952
- problems.push(
953
- `Conflict with existing entries not owned by ${bundle.name} (re-run with --force to take them over):\n ${conflicts.join("\n ")}`,
954
- );
955
- }
956
- if (problems.length > 0 && !force) throw new Error(problems.join("\n"));
957
-
958
- // -- apply
959
- const createdDirs = prev.createdDirs.filter((d) => existsSync(abs(repo, d)));
960
- const writeRepoFile = (rel, content, reason) => {
961
- replaceSymlinkAncestors(repo, rel, ops);
962
- recordMissingDirs(repo, rel, createdDirs);
963
- if (ops.write(rel, content, reason)) written.push(rel);
964
- };
965
- const removeRepoFile = (rel, reason) => {
966
- if (ops.remove(rel, reason)) written.push(rel);
967
- };
968
-
969
- const files = {};
970
- for (const rel of Object.keys(prev.files)) {
971
- if (plan.files.has(rel) || !existsSync(abs(repo, rel))) continue;
972
- // This managed file is dropping out of the plan (e.g. --host narrowed).
973
- // If it took over foreign content, restore it instead of deleting it.
974
- const displacedFile = displaced.files[rel];
975
- if (displacedFile) {
976
- const storedPath = abs(repo, displacedFile.storedAt);
977
- if (!existsSync(storedPath)) throw new Error(`Missing recorded original for ${rel} at ${displacedFile.storedAt}`);
978
- writeRepoFile(rel, readFileSync(storedPath));
979
- removeRepoFile(displacedFile.storedAt);
980
- delete displaced.files[rel];
981
- } else {
982
- removeRepoFile(rel);
983
- }
984
- }
985
- // -- capture originals for whole files taking over pre-existing foreign
986
- // content this run, BEFORE they get overwritten below. Never recapture: if
987
- // an original is already recorded (from an earlier --force takeover), keep it.
988
- for (const rel of foreignFileRels) {
989
- if (displaced.files[rel]) continue;
990
- const storedRel = `.agents/enact-repo-originals/${bundle.name}/${rel}`;
991
- writeRepoFile(storedRel, readFileSync(abs(repo, rel)));
992
- displaced.files[rel] = { storedAt: storedRel };
993
- }
994
- for (const [rel, content] of plan.files) {
995
- writeRepoFile(rel, content);
996
- files[rel] = { sha256: sha256(content), ...(plan.derivedFiles.has(rel) ? { derived: true } : {}) };
997
- }
998
-
999
- const symlinks = {};
1000
- for (const [rel, target] of plan.symlinks) {
1001
- recordMissingDirs(repo, rel, createdDirs);
1002
- if (ops.symlink(rel, target)) written.push(rel);
1003
- symlinks[rel] = { target, created: prevSymlinks[rel]?.created ?? true };
1004
- }
1005
-
1006
- const merged = {};
1007
- for (const rel of jsonRels) {
1008
- const { exists, text, doc } = jsonDocs.get(rel);
1009
- const record = prev.merged[rel];
1010
- const want = plan.json.get(rel);
1011
- const format = exists ? detectJsonFormat(text) : { indent: " ", trailingNewline: true };
1012
- const containers = [...(record?.containers ?? [])];
1013
- const scaffold = [...(record?.scaffold ?? [])];
1014
-
1015
- if (!want) {
1016
- const relDisplaced = displaced.json[rel] ?? {};
1017
- removeJsonEntries(doc, { entries: record.entries, containers, scaffold }, relDisplaced);
1018
- delete displaced.json[rel];
1019
- if (!exists) continue;
1020
- if (record.created && Object.keys(doc).length === 0) removeRepoFile(rel);
1021
- else writeRepoFile(rel, serializeJson(doc, format));
1022
- continue;
1023
- }
1024
-
1025
- const wanted = new Set([
1026
- ...want.keys.map((k) => `key${pathKey(k.path)}`),
1027
- ...want.items.map((i) => `item${pathKey(i.path)}${valueHash(i.value)}`),
1028
- ]);
1029
- const stale = (record?.entries ?? []).filter((e) =>
1030
- e.type === "key" ? !wanted.has(`key${pathKey(e.path)}`) : !wanted.has(`item${pathKey(e.path)}${e.sha256}`),
1031
- );
1032
- const relDisplaced = displaced.json[rel] ?? {};
1033
- removeJsonEntries(doc, { entries: stale }, relDisplaced);
1034
-
1035
- const created = [];
1036
- for (const s of want.scaffold) {
1037
- if (getAt(doc, s.path) === undefined) doc[s.path[0]] = s.value;
1038
- // Record shared framing whether or not we created it. This is retained
1039
- // for lock migration: all current install projections are direct native
1040
- // files, but an old lock can still name shared metadata that must be
1041
- // removed only after its final owner uninstalls.
1042
- if (!scaffold.some((x) => pathKey(x.path) === pathKey(s.path))) scaffold.push({ path: s.path });
1043
- }
1044
- const entries = [];
1045
- for (const key of want.keys) {
1046
- const parent = ensureContainer(doc, key.path.slice(0, -1), "object", created);
1047
- const k = key.path[key.path.length - 1];
1048
- const h = valueHash(key.value);
1049
- const dKey = pathKey(key.path);
1050
- const isTakeover = foreignJsonKeys.some((f) => f.rel === rel && f.dKey === dKey);
1051
- if (isTakeover && has(parent, k) && !has(relDisplaced, dKey)) relDisplaced[dKey] = parent[k];
1052
- if (!has(parent, k) || valueHash(parent[k]) !== h) parent[k] = key.value;
1053
- entries.push({ type: "key", path: key.path, sha256: h });
1054
- }
1055
- if (Object.keys(relDisplaced).length > 0) displaced.json[rel] = relDisplaced;
1056
- else delete displaced.json[rel];
1057
- for (const item of want.items) {
1058
- const arr = ensureContainer(doc, item.path, "array", created);
1059
- const h = valueHash(item.value);
1060
- if (!arr.some((v) => valueHash(v) === h)) arr.push(item.value);
1061
- entries.push({ type: "item", path: item.path, sha256: h });
1062
- }
1063
- // Containers WE created, plus any a sibling enact plugin recorded creating.
1064
- // Inheriting matters because the second plugin to write into a shared file
1065
- // creates nothing, so it recorded no container and left an empty `{}`
1066
- // behind when it uninstalled last. Recording a container the USER created
1067
- // would be worse -- uninstall would prune a key of theirs -- so this is
1068
- // deliberately limited to containers enact is known to have created.
1069
- const siblingContainers = siblings.flatMap(({ lock: l }) => l.merged?.[rel]?.containers ?? []);
1070
- for (const c of [...created, ...siblingContainers]) {
1071
- if (c.length > 0 && !containers.some((x) => pathKey(x) === pathKey(c))) containers.push(c);
1072
- }
1073
- writeRepoFile(rel, serializeJson(doc, format));
1074
- // `created` is inherited: if ANY installed enact plugin created this file,
1075
- // it is ours to remove once the last of us leaves. Recording only our own
1076
- // creation left the second plugin believing a user owned the file, so an
1077
- // empty `{}` stayed behind forever.
1078
- const createdByEnact =
1079
- record?.created ?? (!exists || siblings.some(({ lock: l }) => l.merged?.[rel]?.created === true));
1080
- merged[rel] = { created: createdByEnact, containers, scaffold, entries };
1081
- }
1082
-
1083
- const toml = {};
1084
- for (const rel of tomlRels) {
1085
- const { exists, foreign } = tomlDocs.get(rel);
1086
- let { text } = tomlDocs.get(rel);
1087
- const record = prev.toml[rel];
1088
- const want = plan.toml.get(rel);
1089
-
1090
- // Cut foreign tables sharing one of OUR managed server names, recording
1091
- // (byte-exact, incl. comments) any not already recorded from an earlier
1092
- // takeover -- unconditional (mcp is owner-forced), not gated by --force.
1093
- const displacedSpans = [...(displaced.toml[rel] ?? [])];
1094
- for (const s of foreign) {
1095
- const already = displacedSpans.some((d) => d.server === s);
1096
- const extracted = extractTomlTable(text, s);
1097
- text = extracted.text;
1098
- if (!already && extracted.removed) displacedSpans.push({ server: s, ...extracted.removed });
1099
- }
1100
-
1101
- const current = findBlock(text, bundle.name);
1102
- if (!want) {
1103
- if (current) text = cutBlock(text, current, record?.prefix ?? "");
1104
- text = reinsertTomlSpans(text, displacedSpans);
1105
- delete displaced.toml[rel];
1106
- if (record?.created && text === "") removeRepoFile(rel);
1107
- else if (exists || displacedSpans.length > 0) writeRepoFile(rel, text);
1108
- continue;
1109
- }
1110
- if (displacedSpans.length > 0) displaced.toml[rel] = displacedSpans;
1111
- else delete displaced.toml[rel];
1112
-
1113
- const block = renderTomlBlock(bundle.name, want.tables);
1114
- let prefix = record?.prefix ?? "";
1115
- if (current) {
1116
- text = text.slice(0, current.start) + block + text.slice(current.stop);
1117
- } else {
1118
- prefix = text.length === 0 ? "" : text.endsWith("\n") ? "\n" : "\n\n";
1119
- text = `${text}${prefix}${block}`;
1120
- }
1121
- try {
1122
- TOML.parse(text);
1123
- } catch (err) {
1124
- throw new Error(`Refusing to write invalid TOML to ${rel}: ${err instanceof Error ? err.message : String(err)}`);
1125
- }
1126
- writeRepoFile(rel, text);
1127
- toml[rel] = { created: record ? record.created : !exists, prefix, sha256: sha256(block) };
1128
- }
1129
-
1130
- // Managed .gitignore files, keyed by repo-relative path:
1131
- // .agents/.gitignore keeps the repo-local lean-ctx runtime dirs
1132
- // (data/cache/state/bin) and the enact-hook logs out
1133
- // of git; the lean-ctx config/ dir stays committed.
1134
- // .claude/.gitignore keeps settings.local.json (absolute, machine-
1135
- // specific LEAN_CTX_*_DIR values) out of git.
1136
- // Only lines we actually add are ever recorded/owned — a line a user
1137
- // already has (even one of ours, verbatim) is left unclaimed and survives
1138
- // uninstall untouched.
1139
- const gitignore = { ...(prev.gitignore ?? {}) };
1140
- for (const rel of Object.keys(gitignore)) {
1141
- // A managed .gitignore that dropped out of the plan (e.g. --host narrowed
1142
- // away from claude): strip our lines, exactly as uninstall would.
1143
- if (plan.gitignore.has(rel)) continue;
1144
- if (removeManagedGitignoreLines(repo, rel, gitignore[rel], ops)) written.push(rel);
1145
- delete gitignore[rel];
1146
- }
1147
- for (const [rel, lines] of plan.gitignore) {
1148
- const path = abs(repo, rel);
1149
- const exists = existsSync(path);
1150
- const text = exists ? readFileSync(path, "utf8") : "";
1151
- const currentLines = new Set(text.split("\n").map((l) => l.trim()));
1152
- const missing = lines.filter((l) => !currentLines.has(l));
1153
- if (missing.length === 0) continue;
1154
- const prefix = text.length === 0 || text.endsWith("\n") ? "" : "\n";
1155
- const block = missing.map((l) => `${l}\n`).join("");
1156
- writeRepoFile(rel, `${text}${prefix}${block}`);
1157
- const owned = new Set([...(prev.gitignore?.[rel]?.lines ?? []), ...missing]);
1158
- // `appended` is the exact byte span (prefix + block) this run tacked onto
1159
- // the end of the file — lets uninstall strip it byte-exact (matching the
1160
- // TOML block / displaced-content restore convention elsewhere in this
1161
- // file) when nothing else touched the file afterward. Only reflects the
1162
- // MOST RECENT append; `lines` (the full owned set) is the correctness
1163
- // fallback if a later edit moved our lines away from the tail.
1164
- gitignore[rel] = {
1165
- created: prev.gitignore?.[rel]?.created ?? !exists,
1166
- lines: [...owned].sort(),
1167
- appended: `${prefix}${block}`,
1168
- };
1169
- }
1170
-
1171
- // enact-config.toml — the managed-repo marker AND the declared extension
1172
- // roster. `[extensions]` is created (with `version = 1`) when missing and
1173
- // appended at text level when the file exists without it; this extension is
1174
- // then declared as its own `[extensions.<name>]` table. Both steps are
1175
- // computed IN MEMORY and written once: re-reading the file between them
1176
- // would make a dry-run plan differently from the real run, because the first
1177
- // write has not happened on disk yet. `appended` is the exact span we added,
1178
- // so `--purge` can cut only that.
1179
- let config;
1180
- {
1181
- let text;
1182
- // The legacy `[skills]` table is already gone from `base` when one was
1183
- // present; `configText` stays the ORIGINAL so the "did anything change"
1184
- // comparison below still sees the cut.
1185
- const base = configLegacyCut ?? configText;
1186
- if (!configExists) {
1187
- const appended = `\n${EXTENSIONS_BLOCK}`;
1188
- text = `${CONFIG_ROOT_CONTENT}${appended}`;
1189
- config = { created: true, appended };
1190
- } else if (!configHasExtensions) {
1191
- const appended = `${base.length === 0 ? "" : base.endsWith("\n") ? "\n" : "\n\n"}${EXTENSIONS_BLOCK}`;
1192
- text = `${base}${appended}`;
1193
- config = { created: prev.config?.created ?? false, appended };
1194
- } else {
1195
- text = base;
1196
- config = prev.config ?? { created: false, appended: null };
1197
- }
1198
- // Insert-once: null means nothing to change (already declared with no
1199
- // stale version key, or no [extensions] to write into) -- see declareExtension.
1200
- const declared = declareExtension(text, bundle.name);
1201
- if (declared !== null) text = declared;
1202
- if (text !== configText) {
1203
- parseConfigText(text, "refusing to write invalid TOML");
1204
- writeRepoFile(CONFIG_PATH, text, `managed-repo marker with [extensions.${bundle.name}]`);
1205
- }
1206
- }
1207
-
1208
- removeEmptyDirs(repo, createdDirs, ops);
1209
- recordMissingDirs(repo, lockRel(bundle.name), createdDirs);
1210
- const lock = {
1211
- lockVersion: LOCK_VERSION,
1212
- plugin: bundle.name,
1213
- version: bundle.version,
837
+ return runAuthoritativeRepoInstall(pluginRoot, options, {
838
+ repo,
839
+ bundle,
1214
840
  hosts,
1215
- files: sortObject(files),
1216
- symlinks: sortObject(symlinks),
1217
- merged: sortObject(merged),
1218
- toml: sortObject(toml),
1219
- gitignore: sortObject(gitignore),
1220
- config,
1221
- createdDirs: [...new Set(createdDirs)].filter((d) => d === ".agents" || existsSync(abs(repo, d))).sort(),
1222
- displaced: pruneDisplaced(displaced),
1223
- };
1224
- writeRepoFile(lockRel(bundle.name), `${JSON.stringify(lock, null, 2)}\n`, "install lock file");
1225
-
1226
- const tag = dryRun ? "[dry-run] " : "";
1227
- console.log(`${tag}repo install ${bundle.name}@${bundle.version} -> ${repo} (hosts: ${hosts.join(", ")})`);
1228
- console.log(
1229
- written.length > 0 ? `${dryRun ? "would change" : "changed"} ${written.length} file(s)` : "up to date (no changes)",
1230
- );
1231
- if (hosts.includes("claude")) {
1232
- console.log("claude: trust this folder and review the project hooks before using the installed agents and skills");
1233
- }
1234
- if (hosts.includes("codex"))
1235
- console.log("codex: config and hooks load after you trust this project and review hooks (/hooks)");
1236
- if (hosts.includes("kimi"))
1237
- console.log("kimi: hooks are global-only (installed by enact-vps); no repo hook file written");
1238
- 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
+ });
1239
848
  }
1240
849
 
1241
850
  // ---------------------------------------------------------------------------
@@ -1244,234 +853,32 @@ export function runRepoInstall(pluginRoot, options = {}) {
1244
853
 
1245
854
  export function runRepoUninstall(name, options = {}) {
1246
855
  const repo = resolveRepoRoot(options.repo);
1247
- const force = options.force === true;
1248
856
  const dryRun = options.dryRun === true;
1249
857
  const actions = [];
1250
858
  const ops = makeFileOps(repo, dryRun, actions);
859
+ assertMarkedRepo(repo);
1251
860
  const lock = readLock(repo, name);
1252
861
  if (!lock) throw new Error(`${name} is not installed in ${repo} (no lock at ${lockRel(name)})`);
1253
- // Other plugins still installed here; their claims outlive this uninstall.
1254
- const siblings = readSiblingLocks(repo, name);
1255
-
1256
- const uninstallConfigPath = abs(repo, CONFIG_PATH);
1257
- const uninstallConfigText = existsSync(uninstallConfigPath) ? readFileSync(uninstallConfigPath, "utf8") : "";
1258
-
1259
- const edited = [];
1260
- for (const [rel, record] of Object.entries(lock.files)) {
1261
- // Derived files are regenerated whenever the declared set changes -- a
1262
- // sibling's install or uninstall rewrites them legitimately, so a hash
1263
- // mismatch here is expected, not user damage.
1264
- if (record.derived) continue;
1265
- if (existsSync(abs(repo, rel)) && fileSha(repo, rel) !== record.sha256) edited.push(rel);
1266
- }
1267
- for (const [rel, record] of Object.entries(lock.symlinks ?? {})) {
1268
- // lstat, not existsSync: existsSync follows the link and would silently
1269
- // skip a dangling one instead of flagging that something is off.
1270
- const st = lstatSync(abs(repo, rel), { throwIfNoEntry: false });
1271
- if (st && !(st.isSymbolicLink() && readlinkSync(abs(repo, rel)) === record.target)) edited.push(rel);
1272
- }
1273
- const jsonDocs = new Map();
1274
- for (const [rel, record] of Object.entries(lock.merged)) {
1275
- const path = abs(repo, rel);
1276
- if (!existsSync(path)) {
1277
- edited.push(`${rel} (file removed)`);
1278
- continue;
1279
- }
1280
- const text = readFileSync(path, "utf8");
1281
- const doc = readJsonStrict(path, rel);
1282
- for (const entry of record.entries) {
1283
- // An entry a SIBLING also records is shared lock-owned metadata, not
1284
- // drift. This only matters while uninstalling historical lock content;
1285
- // current installs create no plugin registration or marketplace data.
1286
- if (siblingRecordsJsonEntry(siblings, rel, entry)) continue;
1287
- if (!entryPresent(doc, entry)) edited.push(`${rel} (${entry.path.join(".")})`);
1288
- }
1289
- jsonDocs.set(rel, { text, doc });
1290
- }
1291
- for (const [rel, record] of Object.entries(lock.toml)) {
1292
- const path = abs(repo, rel);
1293
- const found = existsSync(path) ? findBlock(readFileSync(path, "utf8"), name) : null;
1294
- if (!found || sha256(found.block) !== record.sha256) edited.push(rel);
1295
- }
1296
- // --purge cuts our [extensions] block from enact-config.toml; plan it up front so
1297
- // an edited block is refused like any other edited managed content.
1298
- let configCut = null;
1299
- if (options.purge && typeof lock.config?.appended === "string" && existsSync(abs(repo, CONFIG_PATH))) {
1300
- const text = readFileSync(abs(repo, CONFIG_PATH), "utf8");
1301
- configCut = cutExtensionsBlock(text, lock.config.appended);
1302
- if (configCut === null) edited.push(`${CONFIG_PATH} ([extensions] block edited since install)`);
1303
- }
1304
- if (edited.length > 0 && !force) {
1305
- throw new Error(
1306
- `Managed content was edited since install (re-run with --force to remove anyway):\n ${edited.join("\n ")}`,
1307
- );
1308
- }
1309
-
1310
- // Originals displaced by a --force takeover during install; restored below
1311
- // instead of being dropped along with the rest of the managed content.
1312
- const lockDisplaced = lock.displaced ?? { files: {}, json: {}, toml: {} };
1313
-
1314
- for (const rel of Object.keys(lock.files)) {
1315
- // Reference counting: a path another installed plugin still claims is not
1316
- // ours to delete. Bundles ship byte-identical skills (cmux-send,
1317
- // cmux-leader and remove-deadcode are identical across bundles today), so
1318
- // without this, uninstalling one plugin silently removes a skill a
1319
- // surviving plugin's lock still points at -- which its doctor then reports
1320
- // as a missing managed file.
1321
- const alsoOwnedBy = siblingOwnersOf(siblings, rel);
1322
- if (alsoOwnedBy.length > 0) {
1323
- ops.skip?.(rel, `still owned by ${alsoOwnedBy.join(", ")}`);
1324
- continue;
1325
- }
1326
- const displacedFile = lockDisplaced.files[rel];
1327
- if (displacedFile) {
1328
- const storedPath = abs(repo, displacedFile.storedAt);
1329
- if (!existsSync(storedPath)) throw new Error(`Missing recorded original for ${rel} at ${displacedFile.storedAt}`);
1330
- ops.write(rel, readFileSync(storedPath), "restoring pre-install original content");
1331
- ops.remove(displacedFile.storedAt, "captured original no longer needed");
1332
- } else {
1333
- ops.remove(rel, "managed file");
1334
- }
1335
- }
1336
- for (const rel of Object.keys(lock.symlinks ?? {})) {
1337
- ops.removeSymlink(rel, "managed symlink");
1338
- }
1339
- // Undeclare this extension: remove its own `[extensions.<name>]` table. The
1340
- // `[extensions]` root table is cut only by --purge, and only once no other
1341
- // extension is declared.
1342
- if (uninstallConfigText.length > 0) {
1343
- const next = removeExtensionBlock(uninstallConfigText, name);
1344
- if (next !== uninstallConfigText) {
1345
- parseConfigText(next, "refusing to write invalid TOML");
1346
- ops.write(CONFIG_PATH, next, `undeclared [extensions.${name}]`);
1347
- }
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);
1348
866
  }
1349
- for (const [rel, record] of Object.entries(lock.merged)) {
1350
- if (!jsonDocs.has(rel)) continue;
1351
- const { text, doc } = jsonDocs.get(rel);
1352
- // Reference-count historical shared metadata just as managed files. A
1353
- // sibling lock can still own an old registration during migration, so the
1354
- // first uninstall must leave it for the final owner to remove.
1355
- const retained = record.entries.filter((e) => siblingRecordsJsonEntry(siblings, rel, e));
1356
- const ours = { ...record, entries: record.entries.filter((e) => !retained.includes(e)) };
1357
- for (const e of retained) ops.skip(`${rel} (${e.path.join(".")})`, "still registered by another installed plugin");
1358
- removeJsonEntries(doc, ours, { ...(lockDisplaced.json[rel] ?? {}) });
1359
- // Remove only a file WE created -- never one the user already had, even if
1360
- // our entries were the last thing in it. `created` is inherited across
1361
- // siblings at install time (see below), so the second plugin to write a
1362
- // shared file still knows enact created it and can clean it up when it is
1363
- // the last one out.
1364
- if (record.created && Object.keys(doc).length === 0) ops.remove(rel, "json merge file (now empty)");
1365
- else ops.write(rel, serializeJson(doc, detectJsonFormat(text)), "json merge entries removed");
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");
1366
870
  }
1367
- for (const [rel, record] of Object.entries(lock.toml)) {
1368
- const path = abs(repo, rel);
1369
- if (!existsSync(path)) continue;
1370
- const text = readFileSync(path, "utf8");
1371
- const found = findBlock(text, name);
1372
- if (!found) continue;
1373
- const out = reinsertTomlSpans(cutBlock(text, found, record.prefix), lockDisplaced.toml[rel] ?? []);
1374
- if (record.created && out === "") ops.remove(rel, "toml block removed (file now empty)");
1375
- else ops.write(rel, out, "toml mcp_servers block removed");
1376
- }
1377
- // Whether this uninstall must leave managed content behind that only
1378
- // `--purge` can remove: the lean-ctx runtime dirs AND the enact-hook log
1379
- // files (both our own runtime's state, never user data) are still
1380
- // present, so the `.agents/.gitignore` lines hiding them from git must
1381
- // stay too (removing them would surface those paths as untracked in
1382
- // `git status`).
1383
- //
1384
- // Only `.agents/.gitignore` is ever retained: it is the one hiding that
1385
- // runtime state. Every other managed .gitignore (today: `.claude/.gitignore`,
1386
- // hiding settings.local.json) has its lines stripped unconditionally, and
1387
- // the file itself goes when we created it and nothing else is left in it.
1388
- const lockGitignore = lock.gitignore ?? {};
1389
- for (const [rel, ginfo] of Object.entries(lockGitignore)) {
1390
- if (rel === GITIGNORE_PATH) continue;
1391
- removeManagedGitignoreLines(repo, rel, ginfo, ops);
1392
- }
1393
- let retainedGitignore = false;
1394
- if (lockGitignore[GITIGNORE_PATH]) {
1395
- const presentRuntimeDirs = LEAN_CTX_RUNTIME_DIRS.filter((rel) => existsSync(abs(repo, rel)));
1396
- const presentHookLogFiles = HOOK_LOG_FILES.filter((rel) => existsSync(abs(repo, rel)));
1397
- if (options.purge) {
1398
- // Runtime state OUR OWN runtime created (not user data): safe to
1399
- // remove outright, before the gitignore lines that were hiding it.
1400
- for (const rel of presentRuntimeDirs) ops.removeDir(rel, "lean-ctx runtime dir (--purge)");
1401
- for (const rel of presentHookLogFiles) ops.remove(rel, "enact-hook log file (--purge)");
1402
- }
1403
- const presentRuntimePaths = [...presentRuntimeDirs, ...presentHookLogFiles];
1404
- retainedGitignore = !options.purge && presentRuntimePaths.length > 0;
1405
- if (retainedGitignore) {
1406
- const kept = [
1407
- GITIGNORE_PATH,
1408
- ...(typeof lock.config?.appended === "string" ? [`${CONFIG_PATH} [extensions]`] : []),
1409
- ];
1410
- console.log(
1411
- `kept ${kept.join(" and ")} -- lean-ctx runtime dirs / enact-hook log files still present (${presentRuntimePaths.join(", ")}); re-run uninstall --purge to remove them too`,
1412
- );
1413
- } else {
1414
- removeManagedGitignoreLines(repo, GITIGNORE_PATH, lockGitignore[GITIGNORE_PATH], ops);
1415
- }
1416
- }
1417
-
1418
- if (options.purge || !retainedGitignore) {
1419
- ops.remove(lockRel(name), "uninstall lock file");
1420
- } else {
1421
- // Something above is still waiting on `--purge`: rewrite the SAME lock
1422
- // file (same path, same schema) down to a minimal residual -- no
1423
- // managed files/json/toml left (those were already fully removed
1424
- // above), just the gitignore/config bookkeeping a later STANDALONE
1425
- // `uninstall --purge` call needs in order to find and finish the job.
1426
- // This is what makes the printed "re-run uninstall --purge" message
1427
- // above actually true: readLock() at the top of that later call will
1428
- // find this file (and see it as belonging to `name`), not nothing --
1429
- // this is the fix for the bug where a plain uninstall unconditionally
1430
- // deleted the lock even while retaining content, so a follow-up
1431
- // `uninstall --purge` had nothing to read and refused with "not
1432
- // installed ... (no lock)". `install` re-run against this residual
1433
- // lock adopts `gitignore`/`config`/`createdDirs` from it unchanged
1434
- // (see `runRepoInstall`'s `prev = readLock(...) ?? {...}`) and
1435
- // overwrites it with a full lock again -- idempotent either way.
1436
- const residual = {
1437
- lockVersion: LOCK_VERSION,
1438
- plugin: name,
1439
- version: lock.version,
1440
- hosts: [],
1441
- files: {},
1442
- symlinks: {},
1443
- merged: {},
1444
- toml: {},
1445
- // Only the still-retained `.agents/.gitignore` record: every other
1446
- // managed .gitignore was already stripped above, so a later standalone
1447
- // `--purge` must not try to strip it a second time.
1448
- gitignore: { [GITIGNORE_PATH]: lockGitignore[GITIGNORE_PATH] },
1449
- config: lock.config,
1450
- createdDirs: lock.createdDirs,
1451
- displaced: { files: {}, json: {}, toml: {} },
1452
- };
1453
- ops.write(
1454
- lockRel(name),
1455
- `${JSON.stringify(residual, null, 2)}\n`,
1456
- "residual lock (gitignore/config retained pending --purge)",
1457
- );
1458
- }
1459
-
1460
- if (options.purge) {
1461
- if (configCut !== null) {
1462
- if (lock.config.created && configCut.trim() === CONFIG_ROOT_CONTENT.trim()) {
1463
- ops.remove(CONFIG_PATH, "managed-repo marker created by install ([extensions] removed, only version = 1 left)");
1464
- } else {
1465
- ops.write(CONFIG_PATH, configCut, "[extensions] block removed (--purge)");
1466
- }
1467
- } else if (typeof lock.config?.appended === "string" && existsSync(abs(repo, CONFIG_PATH))) {
1468
- console.log(`kept ${CONFIG_PATH} [extensions] block (edited since install; --force never cuts an edited block)`);
1469
- } else if (existsSync(abs(repo, CONFIG_PATH))) {
1470
- console.log(`kept ${CONFIG_PATH} (install did not write its [extensions] block)`);
1471
- }
1472
- purgeOpenCodeAutogenIfClean(repo, ops);
1473
- }
1474
- removeEmptyDirs(repo, lock.createdDirs, ops);
1475
- console.log(`${dryRun ? "[dry-run] " : ""}repo uninstall ${name} <- ${repo}${options.purge ? " (purged)" : ""}`);
1476
- 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 };
1477
884
  }