@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.
- package/README.md +37 -27
- package/extensions/enact-repo/.agents/plugin.json +1 -1
- package/extensions/enact-repo/README.md +26 -15
- package/package.json +1 -1
- package/scripts/enact-extensions.mjs +26 -18
- package/scripts/lib/run-repo-doctor.mjs +51 -47
- package/scripts/lib/run-repo-install.mjs +313 -906
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Repo-local install: `enact-extensions install <bundle> --repo <path>`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
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
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
|
|
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
|
|
131
|
-
//
|
|
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
|
-
//
|
|
162
|
-
//
|
|
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}>
|
|
362
|
-
// toml Map<rel, {tables}>
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
598
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
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
|
-
|
|
1254
|
-
const
|
|
1255
|
-
|
|
1256
|
-
|
|
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
|
|
1350
|
-
|
|
1351
|
-
|
|
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
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
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
|
}
|