@aron98/load-lock 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -14
- package/dist/adapters/opencode/plugin.js +15 -1
- package/dist/adapters/opencode/tui.js +7 -2
- package/dist/cli/copies.d.ts +3 -10
- package/dist/cli/copies.js +32 -44
- package/dist/cli/hook.js +48 -4
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/install.js +68 -14
- package/dist/cli/main.js +108 -211
- package/dist/cli/service.d.ts +56 -0
- package/dist/cli/service.js +261 -0
- package/dist/cli/setup.d.ts +131 -0
- package/dist/cli/setup.js +942 -0
- package/dist/cli/update.d.ts +57 -0
- package/dist/cli/update.js +287 -0
- package/dist/core/daemon.d.ts +29 -3
- package/dist/core/daemon.js +111 -11
- package/dist/core/persist.d.ts +3 -0
- package/dist/core/persist.js +49 -10
- package/dist/core/state.d.ts +9 -0
- package/dist/core/state.js +41 -3
- package/dist/dashboard/feed.d.ts +23 -0
- package/dist/dashboard/feed.js +64 -2
- package/dist/dashboard/frame.js +2 -0
- package/dist/dashboard/view.d.ts +11 -0
- package/dist/dashboard/view.js +13 -0
- package/dist/mcp/server.d.ts +11 -3
- package/dist/mcp/server.js +38 -6
- package/dist/policy/text.d.ts +13 -3
- package/dist/policy/text.js +14 -3
- package/dist/shared/agent-guide.d.ts +3 -0
- package/dist/shared/agent-guide.js +16 -0
- package/dist/shared/client.js +16 -4
- package/dist/shared/drift.d.ts +23 -0
- package/dist/shared/drift.js +95 -0
- package/dist/shared/identity.d.ts +51 -0
- package/dist/shared/identity.js +116 -0
- package/dist/shared/installs.d.ts +39 -0
- package/dist/shared/installs.js +101 -0
- package/dist/shared/pkginfo.d.ts +25 -0
- package/dist/shared/pkginfo.js +70 -0
- package/dist/shared/protocol.d.ts +22 -0
- package/dist/shared/protocol.js +15 -0
- package/dist/shared/runtimes.d.ts +41 -0
- package/dist/shared/runtimes.js +81 -0
- package/dist/shared/version.d.ts +6 -0
- package/dist/shared/version.js +9 -1
- package/dist/test/ace-mcp.test.d.ts +1 -0
- package/dist/test/ace-mcp.test.js +157 -0
- package/dist/test/ace-opencode.test.d.ts +1 -0
- package/dist/test/ace-opencode.test.js +287 -0
- package/dist/test/check-pins.test.d.ts +1 -0
- package/dist/test/check-pins.test.js +108 -0
- package/dist/test/compat.test.d.ts +1 -0
- package/dist/test/compat.test.js +190 -0
- package/dist/test/hook.test.js +179 -1
- package/dist/test/identity.test.d.ts +1 -0
- package/dist/test/identity.test.js +155 -0
- package/dist/test/install.test.js +135 -1
- package/dist/test/launcher.test.d.ts +1 -0
- package/dist/test/launcher.test.js +222 -0
- package/dist/test/mcp.test.js +76 -7
- package/dist/test/plugin.test.d.ts +1 -0
- package/dist/test/plugin.test.js +144 -0
- package/dist/test/reconnect.test.d.ts +1 -0
- package/dist/test/reconnect.test.js +389 -0
- package/dist/test/runtimes.test.d.ts +1 -0
- package/dist/test/runtimes.test.js +282 -0
- package/dist/test/setup.test.d.ts +1 -0
- package/dist/test/setup.test.js +354 -0
- package/dist/test/update-command.test.d.ts +1 -0
- package/dist/test/update-command.test.js +679 -0
- package/dist/test/update.test.js +33 -1
- package/docs/ADAPTERS.md +3 -2
- package/docs/AGENT_GUIDE.md +48 -2
- package/docs/ARCHITECTURE.md +122 -7
- package/docs/COMPATIBILITY.md +156 -13
- package/docs/OPERATIONS.md +407 -21
- package/package.json +3 -2
- package/skills/load-lock/SKILL.md +62 -0
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `load-lock setup` (issue #58): read-only inventory of every load-lock
|
|
3
|
+
* integration on this machine, across every harness `load-lock install`
|
|
4
|
+
* supports, plus the runtime copies (#52 / copies.ts).
|
|
5
|
+
*
|
|
6
|
+
* For each harness it lists what it finds — skill copies, MCP
|
|
7
|
+
* registrations, loose hooks, plugins, policy sections, sandbox grants —
|
|
8
|
+
* who wrote each entry (our installer, the harness's plugin system, ACE,
|
|
9
|
+
* or something else), whether an entry points at a load-lock copy that no
|
|
10
|
+
* longer exists, the current rung (plugin / loose / mcp / skill / none),
|
|
11
|
+
* and duplicates.
|
|
12
|
+
*
|
|
13
|
+
* Paths mirror src/cli/install.ts exactly. Everything here only READS;
|
|
14
|
+
* unreadable or unparseable files are skipped, never rewritten.
|
|
15
|
+
*/
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { findInstalledCopies, npxCacheRoot, opencodeCacheRoot, thisCopy } from "./copies.js";
|
|
20
|
+
import { loadRuntimes, readDaemonInfo } from "../shared/runtimes.js";
|
|
21
|
+
import { resolveStateDir } from "../shared/transport.js";
|
|
22
|
+
import { PKG_NAME } from "../shared/pkginfo.js";
|
|
23
|
+
/**
|
|
24
|
+
* Name of the Claude Code plugin (decided on #61: the plugin is not called
|
|
25
|
+
* `load-lock`, so it never collides with the standalone skill). Matched as
|
|
26
|
+
* `<name>@<marketplace>` in `enabledPlugins`, whatever the marketplace.
|
|
27
|
+
*/
|
|
28
|
+
export const CLAUDE_PLUGIN_NAME = "load-lock-plugin";
|
|
29
|
+
const MD_MARKER = "<!-- load-lock:begin -->";
|
|
30
|
+
const GOOSE_BEGIN = "# load-lock:begin (managed by `load-lock install`)";
|
|
31
|
+
const CODEX_TOML_TAG = "# load-lock (added by `load-lock install`)";
|
|
32
|
+
export const HARNESSES = [
|
|
33
|
+
"claude-code",
|
|
34
|
+
"opencode",
|
|
35
|
+
"codex",
|
|
36
|
+
"cursor",
|
|
37
|
+
"gemini-cli",
|
|
38
|
+
"copilot-cli",
|
|
39
|
+
"cline",
|
|
40
|
+
"roo-code",
|
|
41
|
+
"goose",
|
|
42
|
+
"omac",
|
|
43
|
+
];
|
|
44
|
+
// ---------------------------------------------------------------- io helpers
|
|
45
|
+
function readJson(file) {
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function readText(file) {
|
|
54
|
+
try {
|
|
55
|
+
return fs.readFileSync(file, "utf8");
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function isObject(v) {
|
|
62
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
63
|
+
}
|
|
64
|
+
/** All string leaves of a value (command / args / cmd, nested or flat). */
|
|
65
|
+
function strings(v, out = []) {
|
|
66
|
+
if (typeof v === "string")
|
|
67
|
+
out.push(v);
|
|
68
|
+
else if (Array.isArray(v))
|
|
69
|
+
for (const x of v)
|
|
70
|
+
strings(x, out);
|
|
71
|
+
else if (isObject(v))
|
|
72
|
+
for (const x of Object.values(v))
|
|
73
|
+
strings(x, out);
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
/** Package root of an absolute `<root>/dist/<sub>/main.js` path, or null. */
|
|
77
|
+
function copyOfEntry(p, sub) {
|
|
78
|
+
const norm = p.replace(/\\/g, "/");
|
|
79
|
+
if (!norm.endsWith(`/dist/${sub}/main.js`))
|
|
80
|
+
return null;
|
|
81
|
+
return path.resolve(p, "..", "..", "..");
|
|
82
|
+
}
|
|
83
|
+
// ------------------------------------------------------------ entry matchers
|
|
84
|
+
/**
|
|
85
|
+
* Classify an MCP server entry. Returns null when it is not load-lock.
|
|
86
|
+
* The installer's shape is `{ command: <node>, args: ["<root>/dist/mcp/main.js"] }`
|
|
87
|
+
* under the key `load-lock` (goose: `cmd`; opencode: a `command` array).
|
|
88
|
+
*/
|
|
89
|
+
function mcpEntry(name, entry) {
|
|
90
|
+
const strs = strings(isObject(entry) ? { command: entry.command, cmd: entry.cmd, args: entry.args } : entry);
|
|
91
|
+
for (const s of strs) {
|
|
92
|
+
const copy = copyOfEntry(s, "mcp");
|
|
93
|
+
if (copy && (name === "load-lock" || /load-lock/.test(s))) {
|
|
94
|
+
return { owner: name === "load-lock" ? "installer" : "other", copy, stale: !fs.existsSync(s) };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const joined = strs.join(" ");
|
|
98
|
+
if (joined.includes(PKG_NAME) || /(^|[\s/\\])load-lock(-mcp)?(\.cmd)?(\s|$)/.test(joined) || name === "load-lock") {
|
|
99
|
+
return { owner: "other" };
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
/** Collect every `command` string in a hooks structure (nested or flat). */
|
|
104
|
+
function hookCommands(v, out = []) {
|
|
105
|
+
if (Array.isArray(v))
|
|
106
|
+
for (const x of v)
|
|
107
|
+
hookCommands(x, out);
|
|
108
|
+
else if (isObject(v)) {
|
|
109
|
+
for (const [k, x] of Object.entries(v)) {
|
|
110
|
+
if (k === "command" && typeof x === "string")
|
|
111
|
+
out.push(x);
|
|
112
|
+
else
|
|
113
|
+
hookCommands(x, out);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
const INSTALLER_HOOK = /^(.*?) ("(?:[^"\\]|\\.)*") hook (\S+) (\S+)$/;
|
|
119
|
+
/**
|
|
120
|
+
* Classify a hook command. The installer writes
|
|
121
|
+
* `<node> "<root>/dist/cli/main.js" hook <harness> <sub>`; anything else
|
|
122
|
+
* running `load-lock hook <harness> ...` is `other`.
|
|
123
|
+
*/
|
|
124
|
+
function hookCommand(cmd, harness) {
|
|
125
|
+
const m = INSTALLER_HOOK.exec(cmd);
|
|
126
|
+
if (m && m[3] === harness) {
|
|
127
|
+
let cli;
|
|
128
|
+
try {
|
|
129
|
+
cli = JSON.parse(m[2]);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
cli = undefined;
|
|
133
|
+
}
|
|
134
|
+
const copy = cli ? copyOfEntry(cli, "cli") : null;
|
|
135
|
+
if (cli && copy)
|
|
136
|
+
return { owner: "installer", sub: m[4], copy, stale: !fs.existsSync(cli) };
|
|
137
|
+
}
|
|
138
|
+
const loose = new RegExp(`load-lock\\S*\\s+hook\\s+${harness}\\s+(\\S+)`).exec(cmd);
|
|
139
|
+
if (loose)
|
|
140
|
+
return { owner: "other", sub: loose[1] };
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* load-lock hook entries in one hooks file, one integration per copy (so a
|
|
145
|
+
* second copy's hooks show up as their own line), plus foreign ones.
|
|
146
|
+
*/
|
|
147
|
+
function hooksIn(file, hooks, harness, scope) {
|
|
148
|
+
if (!isObject(hooks))
|
|
149
|
+
return [];
|
|
150
|
+
const groups = new Map();
|
|
151
|
+
for (const [event, list] of Object.entries(hooks)) {
|
|
152
|
+
for (const cmd of hookCommands(list)) {
|
|
153
|
+
const h = hookCommand(cmd, harness);
|
|
154
|
+
if (!h)
|
|
155
|
+
continue;
|
|
156
|
+
const key = h.owner + "\0" + (h.copy ?? "");
|
|
157
|
+
const g = groups.get(key) ?? { owner: h.owner, copy: h.copy, stale: h.stale, subs: [] };
|
|
158
|
+
g.subs.push(`${event}:${h.sub}`);
|
|
159
|
+
groups.set(key, g);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return [...groups.values()].map((g) => ({
|
|
163
|
+
kind: "hooks",
|
|
164
|
+
scope,
|
|
165
|
+
file,
|
|
166
|
+
detail: g.subs.join(", "),
|
|
167
|
+
owner: g.owner,
|
|
168
|
+
...(g.copy ? { copy: g.copy, stale: g.stale } : {}),
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
/** load-lock entries under `key` (mcpServers / servers / mcp) of a JSON file. */
|
|
172
|
+
function mcpServersIn(file, key, scope, cfg) {
|
|
173
|
+
const c = cfg ?? readJson(file);
|
|
174
|
+
if (!isObject(c) || !isObject(c[key]))
|
|
175
|
+
return [];
|
|
176
|
+
const out = [];
|
|
177
|
+
for (const [name, entry] of Object.entries(c[key])) {
|
|
178
|
+
const m = mcpEntry(name, entry);
|
|
179
|
+
if (m)
|
|
180
|
+
out.push({ kind: "mcp", scope, file, detail: `${key}.${name}`, ...m });
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
function policyIn(file, scope, what) {
|
|
185
|
+
const text = readText(file);
|
|
186
|
+
if (text === undefined || !text.includes(MD_MARKER))
|
|
187
|
+
return [];
|
|
188
|
+
return [{ kind: "policy", scope, file, detail: what, owner: "installer" }];
|
|
189
|
+
}
|
|
190
|
+
function ruleFile(file, scope, what) {
|
|
191
|
+
return fs.existsSync(file) ? [{ kind: "policy", scope, file, detail: what, owner: "installer" }] : [];
|
|
192
|
+
}
|
|
193
|
+
/** SKILL.md frontmatter `name`, if any. */
|
|
194
|
+
function skillName(text) {
|
|
195
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
196
|
+
return m ? /^name:\s*["']?([^"'\r\n]+?)["']?\s*$/m.exec(m[1])?.[1] : undefined;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Who owns a skill directory: `ace` when ACE stamped it (`.ace.json`),
|
|
200
|
+
* `installer` when it is the dir `load-lock install` writes (a real
|
|
201
|
+
* directory, not a symlink to someone's checkout), else `other`.
|
|
202
|
+
* `install` / `uninstall` only write or remove an `installer` dir (#89).
|
|
203
|
+
*/
|
|
204
|
+
export function skillDirOwner(dir, installerDir) {
|
|
205
|
+
if (fs.existsSync(path.join(dir, ".ace.json")))
|
|
206
|
+
return "ace";
|
|
207
|
+
if (dir !== installerDir)
|
|
208
|
+
return "other";
|
|
209
|
+
try {
|
|
210
|
+
return fs.lstatSync(dir).isSymbolicLink() ? "other" : "installer";
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return "installer"; // not there yet: install creates it
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* load-lock skills (and ACE skills-dir plugin bundles) under a skills dir:
|
|
218
|
+
* `<dir>/load-lock/SKILL.md`, or any `<dir>/<x>/SKILL.md` named load-lock.
|
|
219
|
+
*/
|
|
220
|
+
function skillsIn(dir, scope, installerDir) {
|
|
221
|
+
let names;
|
|
222
|
+
try {
|
|
223
|
+
names = fs.readdirSync(dir);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return [];
|
|
227
|
+
}
|
|
228
|
+
const out = [];
|
|
229
|
+
for (const n of names.sort()) {
|
|
230
|
+
const d = path.join(dir, n);
|
|
231
|
+
const owner = skillDirOwner(d, installerDir);
|
|
232
|
+
const text = readText(path.join(d, "SKILL.md"));
|
|
233
|
+
if (text !== undefined && (n === "load-lock" || skillName(text) === "load-lock")) {
|
|
234
|
+
out.push({
|
|
235
|
+
kind: "skill",
|
|
236
|
+
scope,
|
|
237
|
+
file: d,
|
|
238
|
+
detail: "SKILL.md",
|
|
239
|
+
owner,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
// ACE installs Claude plugins as skills-dir plugins (#64 Q4).
|
|
243
|
+
const manifest = readJson(path.join(d, ".claude-plugin", "plugin.json"));
|
|
244
|
+
if (isObject(manifest) && manifest.name === CLAUDE_PLUGIN_NAME) {
|
|
245
|
+
out.push({
|
|
246
|
+
kind: "plugin",
|
|
247
|
+
scope,
|
|
248
|
+
file: d,
|
|
249
|
+
detail: `skills-dir plugin "${CLAUDE_PLUGIN_NAME}"`,
|
|
250
|
+
owner: owner === "ace" ? "ace" : "other",
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
function configHome(c) {
|
|
257
|
+
const xdg = c.env.XDG_CONFIG_HOME;
|
|
258
|
+
return xdg && xdg.trim() ? path.resolve(xdg) : path.join(c.home, ".config");
|
|
259
|
+
}
|
|
260
|
+
function scanClaudeCode(c) {
|
|
261
|
+
const out = [];
|
|
262
|
+
const claudeJson = path.join(c.home, ".claude.json");
|
|
263
|
+
const cj = readJson(claudeJson);
|
|
264
|
+
out.push(...mcpServersIn(claudeJson, "mcpServers", "user", cj));
|
|
265
|
+
// local-scope servers live under projects[<path>] in ~/.claude.json
|
|
266
|
+
const proj = isObject(cj?.projects) ? cj.projects[c.project] : undefined;
|
|
267
|
+
if (isObject(proj))
|
|
268
|
+
out.push(...mcpServersIn(claudeJson, "mcpServers", "project", proj));
|
|
269
|
+
const mcpJson = path.join(c.project, ".mcp.json");
|
|
270
|
+
out.push(...mcpServersIn(mcpJson, "mcpServers", "project"));
|
|
271
|
+
// ACE's MCP install patch uses a `servers` key (#64 Q4)
|
|
272
|
+
out.push(...mcpServersIn(mcpJson, "servers", "project"));
|
|
273
|
+
const settings = [
|
|
274
|
+
[path.join(c.home, ".claude", "settings.json"), "user"],
|
|
275
|
+
[path.join(c.project, ".claude", "settings.json"), "project"],
|
|
276
|
+
];
|
|
277
|
+
for (const [file, scope] of settings) {
|
|
278
|
+
const s = readJson(file);
|
|
279
|
+
if (!isObject(s))
|
|
280
|
+
continue;
|
|
281
|
+
out.push(...hooksIn(file, s.hooks, "claude-code", scope));
|
|
282
|
+
if (isObject(s.enabledPlugins)) {
|
|
283
|
+
for (const [key, on] of Object.entries(s.enabledPlugins)) {
|
|
284
|
+
if (key.startsWith(CLAUDE_PLUGIN_NAME + "@")) {
|
|
285
|
+
out.push({ kind: "plugin", scope, file, detail: `enabledPlugins["${key}"]`, owner: "harness", enabled: on === true });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
out.push(...policyIn(path.join(c.home, ".claude", "CLAUDE.md"), "user", "CLAUDE.md section"));
|
|
291
|
+
const userSkills = path.join(c.home, ".claude", "skills");
|
|
292
|
+
out.push(...skillsIn(userSkills, "user", path.join(userSkills, "load-lock")));
|
|
293
|
+
out.push(...skillsIn(path.join(c.project, ".claude", "skills"), "project"));
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
function scanOpenCode(c) {
|
|
297
|
+
const out = [];
|
|
298
|
+
const files = [
|
|
299
|
+
[path.join(c.home, ".config", "opencode", "opencode.json"), "user"],
|
|
300
|
+
[path.join(c.project, "opencode.json"), "project"],
|
|
301
|
+
];
|
|
302
|
+
const stub = path.join(c.home, ".config", "opencode", "plugins", "load-lock");
|
|
303
|
+
for (const [file, scope] of files) {
|
|
304
|
+
const cfg = readJson(file);
|
|
305
|
+
if (!isObject(cfg))
|
|
306
|
+
continue;
|
|
307
|
+
// the installer writes `plugins`; OpenCode's documented key is `plugin`
|
|
308
|
+
for (const key of ["plugin", "plugins"]) {
|
|
309
|
+
if (!Array.isArray(cfg[key]))
|
|
310
|
+
continue;
|
|
311
|
+
for (const p of cfg[key]) {
|
|
312
|
+
if (typeof p !== "string")
|
|
313
|
+
continue;
|
|
314
|
+
if (p === PKG_NAME || p.startsWith(PKG_NAME + "@")) {
|
|
315
|
+
const owner = p === PKG_NAME && key === "plugins" && scope === "user" ? "installer" : "other";
|
|
316
|
+
out.push({ kind: "plugin", scope, file, detail: `${key}: "${p}"`, owner });
|
|
317
|
+
}
|
|
318
|
+
else if (p === stub) {
|
|
319
|
+
out.push({ kind: "plugin", scope, file, detail: `${key}: legacy stub ${p}`, owner: "installer", stale: !fs.existsSync(p) });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
out.push(...mcpServersIn(file, "mcp", scope, cfg));
|
|
324
|
+
}
|
|
325
|
+
if (fs.existsSync(stub))
|
|
326
|
+
out.push({ kind: "plugin", scope: "user", file: stub, detail: "legacy plugin stub dir", owner: "installer" });
|
|
327
|
+
out.push(...skillsIn(path.join(c.home, ".config", "opencode", "skills"), "user"));
|
|
328
|
+
out.push(...skillsIn(path.join(c.project, ".opencode", "skills"), "project"));
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
/** `[mcp_servers.<name>]` tables in a config.toml that run load-lock. */
|
|
332
|
+
function codexToml(file) {
|
|
333
|
+
const text = readText(file);
|
|
334
|
+
if (text === undefined)
|
|
335
|
+
return [];
|
|
336
|
+
const lines = text.split(/\r?\n/);
|
|
337
|
+
const out = [];
|
|
338
|
+
for (let i = 0; i < lines.length; i++) {
|
|
339
|
+
const h = /^\[mcp_servers\.("?)([^"\]]+)\1\]\s*$/.exec(lines[i]);
|
|
340
|
+
if (!h)
|
|
341
|
+
continue;
|
|
342
|
+
let j = i + 1;
|
|
343
|
+
while (j < lines.length && !/^\s*\[/.test(lines[j]))
|
|
344
|
+
j++;
|
|
345
|
+
const body = lines.slice(i + 1, j).join("\n");
|
|
346
|
+
const args = /^args\s*=\s*\[\s*("(?:[^"\\]|\\.)*")/m.exec(body);
|
|
347
|
+
const command = /^command\s*=\s*("(?:[^"\\]|\\.)*")/m.exec(body);
|
|
348
|
+
const parse = (s) => {
|
|
349
|
+
try {
|
|
350
|
+
return s ? JSON.parse(s) : undefined;
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
const m = mcpEntry(h[2], { command: parse(command?.[1]), args: [parse(args?.[1]) ?? body] });
|
|
357
|
+
if (!m)
|
|
358
|
+
continue;
|
|
359
|
+
const tagged = i > 0 && lines[i - 1] === CODEX_TOML_TAG;
|
|
360
|
+
out.push({
|
|
361
|
+
kind: "mcp",
|
|
362
|
+
scope: "user",
|
|
363
|
+
file,
|
|
364
|
+
detail: `[mcp_servers.${h[2]}]`,
|
|
365
|
+
...m,
|
|
366
|
+
owner: tagged && m.owner === "installer" ? "installer" : "other",
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
function scanCodex(c) {
|
|
372
|
+
const codexHome = c.env.CODEX_HOME ?? path.join(c.home, ".codex");
|
|
373
|
+
const hooksFile = path.join(codexHome, "hooks.json");
|
|
374
|
+
return [
|
|
375
|
+
...hooksIn(hooksFile, readJson(hooksFile)?.hooks, "codex", "user"),
|
|
376
|
+
...codexToml(path.join(codexHome, "config.toml")),
|
|
377
|
+
...policyIn(path.join(codexHome, "AGENTS.md"), "user", "AGENTS.md section"),
|
|
378
|
+
...skillsIn(path.join(codexHome, "skills"), "user"),
|
|
379
|
+
...skillsIn(path.join(c.project, ".codex", "skills"), "project"),
|
|
380
|
+
];
|
|
381
|
+
}
|
|
382
|
+
function scanCursor(c) {
|
|
383
|
+
const dir = path.join(c.project, ".cursor");
|
|
384
|
+
const hooksFile = path.join(dir, "hooks.json");
|
|
385
|
+
return [
|
|
386
|
+
...mcpServersIn(path.join(c.home, ".cursor", "mcp.json"), "mcpServers", "user").map((i) => ({ ...i, owner: "other" })),
|
|
387
|
+
...mcpServersIn(path.join(dir, "mcp.json"), "mcpServers", "project"),
|
|
388
|
+
...hooksIn(hooksFile, readJson(hooksFile)?.hooks, "cursor", "project"),
|
|
389
|
+
...ruleFile(path.join(dir, "rules", "load-lock.mdc"), "project", "rule load-lock.mdc"),
|
|
390
|
+
];
|
|
391
|
+
}
|
|
392
|
+
function scanGemini(c) {
|
|
393
|
+
return [
|
|
394
|
+
...mcpServersIn(path.join(c.home, ".gemini", "settings.json"), "mcpServers", "user"),
|
|
395
|
+
...policyIn(path.join(c.home, ".gemini", "GEMINI.md"), "user", "GEMINI.md section"),
|
|
396
|
+
];
|
|
397
|
+
}
|
|
398
|
+
function scanCopilot(c) {
|
|
399
|
+
return [
|
|
400
|
+
...mcpServersIn(path.join(c.home, ".copilot", "mcp-config.json"), "mcpServers", "user"),
|
|
401
|
+
...policyIn(path.join(c.home, ".copilot", "copilot-instructions.md"), "user", "copilot-instructions.md section"),
|
|
402
|
+
...skillsIn(path.join(c.project, ".github", "skills"), "project"),
|
|
403
|
+
];
|
|
404
|
+
}
|
|
405
|
+
function scanCline(c) {
|
|
406
|
+
const file = c.env.CLINE_MCP_SETTINGS_PATH?.trim()
|
|
407
|
+
? path.resolve(c.env.CLINE_MCP_SETTINGS_PATH)
|
|
408
|
+
: path.join(c.home, ".cline", "data", "settings", "cline_mcp_settings.json");
|
|
409
|
+
return [
|
|
410
|
+
...mcpServersIn(file, "mcpServers", "user"),
|
|
411
|
+
...ruleFile(path.join(c.home, ".cline", "rules", "load-lock.md"), "user", "rule load-lock.md"),
|
|
412
|
+
];
|
|
413
|
+
}
|
|
414
|
+
/** Global Roo/Zoo mcp_settings.json files (same list as the installer). */
|
|
415
|
+
function rooGlobalMcpFiles(c) {
|
|
416
|
+
const appData = c.platform === "darwin" ? path.join(c.home, "Library", "Application Support") : configHome(c);
|
|
417
|
+
const out = [];
|
|
418
|
+
for (const editor of ["Code", "Code - OSS", "VSCodium"]) {
|
|
419
|
+
for (const ext of ["roocode.roo-cline", "RooCodeInc.roo-code", "ZooCodeOrganization.zoo-code"]) {
|
|
420
|
+
const f = path.join(appData, editor, "User", "globalStorage", ext, "settings", "mcp_settings.json");
|
|
421
|
+
if (fs.existsSync(f))
|
|
422
|
+
out.push(f);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return out;
|
|
426
|
+
}
|
|
427
|
+
function scanRoo(c) {
|
|
428
|
+
return [
|
|
429
|
+
...mcpServersIn(path.join(c.project, ".roo", "mcp.json"), "mcpServers", "project"),
|
|
430
|
+
...rooGlobalMcpFiles(c).flatMap((f) => mcpServersIn(f, "mcpServers", "user")),
|
|
431
|
+
...ruleFile(path.join(c.home, ".roo", "rules", "load-lock.md"), "user", "rule load-lock.md"),
|
|
432
|
+
];
|
|
433
|
+
}
|
|
434
|
+
function scanGoose(c) {
|
|
435
|
+
const out = [];
|
|
436
|
+
const file = path.join(configHome(c), "goose", "config.yaml");
|
|
437
|
+
const text = readText(file);
|
|
438
|
+
if (text !== undefined) {
|
|
439
|
+
const lines = text.split("\n");
|
|
440
|
+
for (let i = 0; i < lines.length; i++) {
|
|
441
|
+
if (!/^\s+load-lock:\s*(#.*)?$/.test(lines[i]))
|
|
442
|
+
continue;
|
|
443
|
+
const marked = i > 0 && lines[i - 1].trim() === GOOSE_BEGIN;
|
|
444
|
+
const args = /^\s+args:\s*\[\s*("(?:[^"\\]|\\.)*")/.exec(lines.slice(i + 1, i + 8).find((l) => /^\s+args:/.test(l)) ?? "");
|
|
445
|
+
let copy = null;
|
|
446
|
+
let stale;
|
|
447
|
+
try {
|
|
448
|
+
const a = args ? JSON.parse(args[1]) : undefined;
|
|
449
|
+
copy = a ? copyOfEntry(a, "mcp") : null;
|
|
450
|
+
if (a && copy)
|
|
451
|
+
stale = !fs.existsSync(a);
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
/* unparseable args — report without a copy */
|
|
455
|
+
}
|
|
456
|
+
out.push({
|
|
457
|
+
kind: "mcp",
|
|
458
|
+
scope: "user",
|
|
459
|
+
file,
|
|
460
|
+
detail: "extensions.load-lock",
|
|
461
|
+
owner: marked ? "installer" : "other",
|
|
462
|
+
...(copy ? { copy, stale } : {}),
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
out.push(...policyIn(path.join(configHome(c), "goose", ".goosehints"), "user", ".goosehints section"));
|
|
467
|
+
return out;
|
|
468
|
+
}
|
|
469
|
+
function scanOmac(c) {
|
|
470
|
+
const file = path.join(configHome(c), "omac", "sandbox-profiles", "default.json");
|
|
471
|
+
const p = readJson(file);
|
|
472
|
+
if (!isObject(p))
|
|
473
|
+
return [];
|
|
474
|
+
const stateDir = resolveStateDir(c.platform, c.env);
|
|
475
|
+
const grants = [];
|
|
476
|
+
if (isObject(p.filesystem) && Array.isArray(p.filesystem.allow_unix_dir) && p.filesystem.allow_unix_dir.includes(stateDir)) {
|
|
477
|
+
grants.push("filesystem.allow_unix_dir");
|
|
478
|
+
}
|
|
479
|
+
if (isObject(p.filesystem) && Array.isArray(p.filesystem.read) && p.filesystem.read.some((r) => typeof r === "string" && r.endsWith(path.join("node_modules", PKG_NAME)))) {
|
|
480
|
+
grants.push("filesystem.read");
|
|
481
|
+
}
|
|
482
|
+
if (isObject(p.environment) && Array.isArray(p.environment.allow_vars) && p.environment.allow_vars.includes("LOAD_LOCK_*")) {
|
|
483
|
+
grants.push("environment.allow_vars");
|
|
484
|
+
}
|
|
485
|
+
return grants.length ? [{ kind: "grants", scope: "user", file, detail: grants.join(", "), owner: "installer" }] : [];
|
|
486
|
+
}
|
|
487
|
+
const SCANNERS = {
|
|
488
|
+
"claude-code": scanClaudeCode,
|
|
489
|
+
opencode: scanOpenCode,
|
|
490
|
+
codex: scanCodex,
|
|
491
|
+
cursor: scanCursor,
|
|
492
|
+
"gemini-cli": scanGemini,
|
|
493
|
+
"copilot-cli": scanCopilot,
|
|
494
|
+
cline: scanCline,
|
|
495
|
+
"roo-code": scanRoo,
|
|
496
|
+
goose: scanGoose,
|
|
497
|
+
omac: scanOmac,
|
|
498
|
+
};
|
|
499
|
+
// ------------------------------------------------------------ rung + findings
|
|
500
|
+
/** A plugin entry the harness will actually load (ACE skills-dir plugins are unverified, #63). */
|
|
501
|
+
export function activePlugin(i) {
|
|
502
|
+
return i.kind === "plugin" && i.owner !== "ace" && i.enabled !== false && !i.detail.includes("legacy") && i.stale !== true;
|
|
503
|
+
}
|
|
504
|
+
function rungOf(list) {
|
|
505
|
+
if (list.some(activePlugin))
|
|
506
|
+
return "plugin";
|
|
507
|
+
if (list.some((i) => i.kind === "hooks" || i.kind === "policy" || i.kind === "grants"))
|
|
508
|
+
return "loose";
|
|
509
|
+
if (list.some((i) => i.kind === "mcp"))
|
|
510
|
+
return "mcp";
|
|
511
|
+
if (list.some((i) => i.kind === "skill"))
|
|
512
|
+
return "skill";
|
|
513
|
+
return "none";
|
|
514
|
+
}
|
|
515
|
+
function findingsOf(h, list) {
|
|
516
|
+
const f = [];
|
|
517
|
+
const plugins = list.filter(activePlugin);
|
|
518
|
+
const hooks = list.filter((i) => i.kind === "hooks");
|
|
519
|
+
const mcps = list.filter((i) => i.kind === "mcp");
|
|
520
|
+
const skills = list.filter((i) => i.kind === "skill");
|
|
521
|
+
if (plugins.length && hooks.length) {
|
|
522
|
+
f.push(`loose + plugin double install: hooks in ${[...new Set(hooks.map((i) => i.file))].join(", ")} run next to the plugin's`);
|
|
523
|
+
}
|
|
524
|
+
if (plugins.length > 1)
|
|
525
|
+
f.push(`plugin registered ${plugins.length} times: ${plugins.map((i) => i.detail).join("; ")}`);
|
|
526
|
+
// A Claude Code plugin bundles an MCP server; the OpenCode plugin brings its own tools.
|
|
527
|
+
const toolSources = mcps.length + (plugins.length ? 1 : 0);
|
|
528
|
+
if (toolSources > 1) {
|
|
529
|
+
f.push(`MCP registered ${toolSources} times: ` +
|
|
530
|
+
[...(plugins.length ? [h === "opencode" ? "plugin tools" : "plugin-bundled server"] : []), ...mcps.map((i) => `${i.detail} in ${i.file}`)].join("; "));
|
|
531
|
+
}
|
|
532
|
+
const hookCopies = new Set(hooks.map((i) => i.copy ?? i.owner + i.file));
|
|
533
|
+
if (hookCopies.size > 1)
|
|
534
|
+
f.push(`hooks from ${hookCopies.size} different sources: ${hooks.map((i) => i.copy ?? i.file).join("; ")}`);
|
|
535
|
+
for (const i of hooks) {
|
|
536
|
+
const subs = i.detail.split(", ");
|
|
537
|
+
if (new Set(subs).size !== subs.length)
|
|
538
|
+
f.push(`duplicate hook entries in ${i.file}: ${i.detail}`);
|
|
539
|
+
}
|
|
540
|
+
if (skills.length > 1)
|
|
541
|
+
f.push(`skill installed ${skills.length} times: ${skills.map((i) => i.file).join("; ")}`);
|
|
542
|
+
for (const i of list) {
|
|
543
|
+
if (i.stale)
|
|
544
|
+
f.push(`stale ${i.kind} entry (${i.detail} in ${i.file}) points at a missing copy${i.copy ? ` ${i.copy}` : ""}`);
|
|
545
|
+
if (i.kind === "plugin" && i.enabled === false)
|
|
546
|
+
f.push(`plugin ${i.detail} is installed but disabled`);
|
|
547
|
+
if (i.kind === "plugin" && i.owner === "ace")
|
|
548
|
+
f.push(`ACE skills-dir plugin at ${i.file} (whether Claude Code loads it is unverified, see #63)`);
|
|
549
|
+
}
|
|
550
|
+
return f;
|
|
551
|
+
}
|
|
552
|
+
// ------------------------------------------------------------------ public
|
|
553
|
+
function ctxOf(opts) {
|
|
554
|
+
return {
|
|
555
|
+
home: opts.home ?? os.homedir(),
|
|
556
|
+
project: path.resolve(opts.project),
|
|
557
|
+
env: opts.env ?? process.env,
|
|
558
|
+
platform: opts.platform ?? process.platform,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
function scanHarness(h, c) {
|
|
562
|
+
let integrations;
|
|
563
|
+
try {
|
|
564
|
+
integrations = SCANNERS[h](c);
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
integrations = []; // best-effort: a scanner never fails the inventory
|
|
568
|
+
}
|
|
569
|
+
return { harness: h, rung: rungOf(integrations), integrations, findings: findingsOf(h, integrations) };
|
|
570
|
+
}
|
|
571
|
+
/** Inventory of one harness only (no runtime copies). */
|
|
572
|
+
export function inventoryHarness(h, opts) {
|
|
573
|
+
return scanHarness(h, ctxOf(opts));
|
|
574
|
+
}
|
|
575
|
+
export function claudeCodeMode(h) {
|
|
576
|
+
const plugin = h.integrations.some(activePlugin);
|
|
577
|
+
const loose = h.integrations.some((i) => i.owner === "installer" && (i.kind === "mcp" || i.kind === "hooks" || i.kind === "policy"));
|
|
578
|
+
return plugin && loose ? "both" : plugin ? "plugin" : loose ? "loose" : "none";
|
|
579
|
+
}
|
|
580
|
+
export function inventory(opts) {
|
|
581
|
+
const c = ctxOf(opts);
|
|
582
|
+
const harnesses = HARNESSES.map((h) => scanHarness(h, c));
|
|
583
|
+
const stateDir = resolveStateDir(c.platform, c.env);
|
|
584
|
+
const copies = findInstalledCopies(c.platform, c.env);
|
|
585
|
+
const dinfo = readDaemonInfo(stateDir);
|
|
586
|
+
const findings = [];
|
|
587
|
+
const versions = new Set(copies.map((x) => x.version).filter((v) => v !== null));
|
|
588
|
+
if (versions.size > 1) {
|
|
589
|
+
findings.push(`multiple load-lock versions installed (${[...versions].sort().join(", ")}); the daemon moves to the newest copy that contacts it`);
|
|
590
|
+
}
|
|
591
|
+
return {
|
|
592
|
+
home: c.home,
|
|
593
|
+
project: c.project,
|
|
594
|
+
harnesses,
|
|
595
|
+
runtime: {
|
|
596
|
+
copies,
|
|
597
|
+
daemon: dinfo?.packageRoot ? { path: dinfo.packageRoot, version: dinfo.version ?? null } : null,
|
|
598
|
+
known: loadRuntimes(stateDir),
|
|
599
|
+
},
|
|
600
|
+
findings,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
const RUNG_LABEL = {
|
|
604
|
+
plugin: "plugin",
|
|
605
|
+
loose: "loose (`load-lock install`)",
|
|
606
|
+
mcp: "MCP only",
|
|
607
|
+
skill: "skill only",
|
|
608
|
+
none: "not installed",
|
|
609
|
+
};
|
|
610
|
+
/** Human-readable inventory. */
|
|
611
|
+
export function formatInventory(inv) {
|
|
612
|
+
const tilde = (p) => (p === inv.home || p.startsWith(inv.home + path.sep) ? "~" + p.slice(inv.home.length) : p);
|
|
613
|
+
const lines = [`load-lock setup: inventory (read-only), project ${tilde(inv.project)}`, ""];
|
|
614
|
+
const width = Math.max(...inv.harnesses.map((h) => h.harness.length));
|
|
615
|
+
for (const h of inv.harnesses) {
|
|
616
|
+
lines.push(`${h.harness.padEnd(width)} ${RUNG_LABEL[h.rung]}`);
|
|
617
|
+
for (const i of h.integrations) {
|
|
618
|
+
const via = i.copy ? ` -> ${tilde(i.copy)}${i.stale ? " (missing)" : ""}` : "";
|
|
619
|
+
const en = i.enabled === false ? " (disabled)" : "";
|
|
620
|
+
lines.push(` ${i.kind.padEnd(6)} ${i.scope.padEnd(7)} ${tilde(i.file)}: ${i.detail}${en} [${i.owner}]${via}`);
|
|
621
|
+
}
|
|
622
|
+
for (const f of h.findings)
|
|
623
|
+
lines.push(` ! ${f.split(inv.home).join("~")}`);
|
|
624
|
+
}
|
|
625
|
+
lines.push("", "runtime copies:");
|
|
626
|
+
for (const x of inv.runtime.copies)
|
|
627
|
+
lines.push(` ${x.source.padEnd(14)} ${x.version ?? "?"} ${tilde(x.path)}`);
|
|
628
|
+
if (inv.runtime.daemon)
|
|
629
|
+
lines.push(` daemon runs ${inv.runtime.daemon.version ?? "?"} ${tilde(inv.runtime.daemon.path)}`);
|
|
630
|
+
for (const k of inv.runtime.known)
|
|
631
|
+
lines.push(` seen by daemon ${k.version} ${tilde(k.path)}`);
|
|
632
|
+
for (const f of inv.findings)
|
|
633
|
+
lines.push(` ! ${f}`);
|
|
634
|
+
return lines.join("\n");
|
|
635
|
+
}
|
|
636
|
+
// ---------------------------------------------------------------- converge
|
|
637
|
+
//
|
|
638
|
+
// `load-lock setup --yes` (issue #59) converges each harness that already
|
|
639
|
+
// has some load-lock wiring to the recommended layout: the highest rung we
|
|
640
|
+
// can reach, exactly one of each. It only uses moves the installers can
|
|
641
|
+
// undo — `load-lock install` (merge-only) and removing entries in the exact
|
|
642
|
+
// shape `install` writes — so `install` / `uninstall` reverse it. Entries
|
|
643
|
+
// written by anyone else (the harness's plugin system, ACE, manual edits)
|
|
644
|
+
// are never touched: the plan prints what to do instead.
|
|
645
|
+
/** What `load-lock install --harness <h>` provides, per harness. */
|
|
646
|
+
const EXPECTED = {
|
|
647
|
+
"claude-code": ["mcp", "hooks", "policy", "skill"],
|
|
648
|
+
opencode: ["plugin"],
|
|
649
|
+
codex: ["mcp", "hooks", "policy"],
|
|
650
|
+
cursor: ["mcp", "hooks", "policy"],
|
|
651
|
+
"gemini-cli": ["mcp", "policy"],
|
|
652
|
+
"copilot-cli": ["mcp", "policy"],
|
|
653
|
+
cline: ["mcp", "policy"],
|
|
654
|
+
"roo-code": ["mcp", "policy"],
|
|
655
|
+
goose: ["mcp", "policy"],
|
|
656
|
+
omac: ["grants"],
|
|
657
|
+
};
|
|
658
|
+
/** The MCP slot `install` writes (an `other` entry there would be overwritten). */
|
|
659
|
+
const INSTALLER_MCP_SLOT = {
|
|
660
|
+
"claude-code": "mcpServers.load-lock",
|
|
661
|
+
codex: "[mcp_servers.load-lock]",
|
|
662
|
+
cursor: "mcpServers.load-lock",
|
|
663
|
+
"gemini-cli": "mcpServers.load-lock",
|
|
664
|
+
"copilot-cli": "mcpServers.load-lock",
|
|
665
|
+
cline: "mcpServers.load-lock",
|
|
666
|
+
"roo-code": "mcpServers.load-lock",
|
|
667
|
+
goose: "extensions.load-lock",
|
|
668
|
+
};
|
|
669
|
+
/** Harnesses whose installer writes into the project, not the user's config. */
|
|
670
|
+
const PROJECT_SCOPED = ["cursor", "roo-code"];
|
|
671
|
+
function installerOwned(list, kind) {
|
|
672
|
+
return list.filter((i) => i.kind === kind && i.owner === "installer");
|
|
673
|
+
}
|
|
674
|
+
function needsRefresh(h) {
|
|
675
|
+
const L = h.integrations;
|
|
676
|
+
// any working OpenCode plugin entry will do (e.g. a pinned one the user chose)
|
|
677
|
+
if (h.harness === "opencode")
|
|
678
|
+
return !L.some(activePlugin) || L.some((i) => i.detail.includes("legacy"));
|
|
679
|
+
if (EXPECTED[h.harness].some((k) => installerOwned(L, k).length === 0))
|
|
680
|
+
return true;
|
|
681
|
+
if (L.some((i) => i.owner === "installer" && i.stale))
|
|
682
|
+
return true;
|
|
683
|
+
const hooks = installerOwned(L, "hooks");
|
|
684
|
+
if (new Set(hooks.map((i) => i.copy)).size > 1)
|
|
685
|
+
return true;
|
|
686
|
+
return hooks.some((i) => new Set(i.detail.split(", ")).size !== i.detail.split(", ").length);
|
|
687
|
+
}
|
|
688
|
+
/** Manual steps for entries we will not touch. */
|
|
689
|
+
function foreignSteps(h, keepForeignMcp, why) {
|
|
690
|
+
const steps = [];
|
|
691
|
+
const L = h.integrations;
|
|
692
|
+
const mcps = L.filter((i) => i.kind === "mcp" && i.owner !== "installer");
|
|
693
|
+
// with a plugin or our own MCP entry in place, any other registration is a duplicate
|
|
694
|
+
if (!keepForeignMcp) {
|
|
695
|
+
for (const i of mcps)
|
|
696
|
+
steps.push({ harness: h.harness, action: "manual", detail: `remove ${i.detail} from ${i.file} (${why})` });
|
|
697
|
+
}
|
|
698
|
+
for (const i of L.filter((x) => x.kind === "hooks" && x.owner !== "installer")) {
|
|
699
|
+
steps.push({ harness: h.harness, action: "manual", detail: `remove the load-lock hooks (${i.detail}) from ${i.file} (${why})` });
|
|
700
|
+
}
|
|
701
|
+
const skills = L.filter((i) => i.kind === "skill");
|
|
702
|
+
if (skills.length > 1) {
|
|
703
|
+
steps.push({
|
|
704
|
+
harness: h.harness,
|
|
705
|
+
action: "manual",
|
|
706
|
+
detail: `skill installed ${skills.length} times (${skills.map((i) => i.file).join("; ")}): keep one`,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
const plugins = L.filter(activePlugin);
|
|
710
|
+
if (plugins.length > 1) {
|
|
711
|
+
steps.push({
|
|
712
|
+
harness: h.harness,
|
|
713
|
+
action: "manual",
|
|
714
|
+
detail: h.harness === "claude-code"
|
|
715
|
+
? `plugin enabled in ${plugins.length} places (${plugins.map((i) => i.file).join("; ")}): keep one with /plugin`
|
|
716
|
+
: `plugin listed ${plugins.length} times (${plugins.map((i) => `${i.detail} in ${i.file}`).join("; ")}): keep one`,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
return steps;
|
|
720
|
+
}
|
|
721
|
+
/** The running copy, and whether it sits in the npx / opencode cache. */
|
|
722
|
+
export function selfCopy(platform = process.platform, env = process.env) {
|
|
723
|
+
const self = thisCopy()?.path ?? null;
|
|
724
|
+
const caches = [npxCacheRoot(platform, env), opencodeCacheRoot(platform, env)].filter((c) => !!c);
|
|
725
|
+
let real = self;
|
|
726
|
+
try {
|
|
727
|
+
real = self ? fs.realpathSync(self) : null;
|
|
728
|
+
}
|
|
729
|
+
catch {
|
|
730
|
+
/* keep the unresolved path */
|
|
731
|
+
}
|
|
732
|
+
const inside = (p, root) => !!p && !path.relative(root, p).startsWith("..") && !path.isAbsolute(path.relative(root, p));
|
|
733
|
+
return { selfCopy: self, selfEphemeral: caches.some((c) => inside(self, c) || inside(real, c)) };
|
|
734
|
+
}
|
|
735
|
+
export function planConverge(inv, opts) {
|
|
736
|
+
const steps = [];
|
|
737
|
+
const hints = [];
|
|
738
|
+
for (const h of inv.harnesses) {
|
|
739
|
+
const L = h.integrations;
|
|
740
|
+
if (h.rung === "none")
|
|
741
|
+
continue;
|
|
742
|
+
// Claude Code with its plugin active: the plugin replaces the loose wiring.
|
|
743
|
+
if (h.harness === "claude-code" && h.rung === "plugin") {
|
|
744
|
+
const loose = L.filter((i) => i.owner === "installer" && (i.kind === "mcp" || i.kind === "hooks" || i.kind === "policy"));
|
|
745
|
+
if (loose.length) {
|
|
746
|
+
steps.push({
|
|
747
|
+
harness: h.harness,
|
|
748
|
+
action: "remove-loose",
|
|
749
|
+
detail: `remove the loose ${[...new Set(loose.map((i) => i.kind))].join(", ")} from \`load-lock install\` ` +
|
|
750
|
+
"(the plugin provides them; the skill stays). Undo: load-lock install --harness claude-code --legacy",
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
steps.push(...foreignSteps(h, false, "the plugin bundles its own MCP server and hooks"));
|
|
754
|
+
for (const i of L.filter((x) => x.kind === "plugin" && x.enabled === false)) {
|
|
755
|
+
steps.push({ harness: h.harness, action: "manual", detail: `${i.detail} in ${i.file} is disabled: enable it with /plugin, or remove it with /plugin uninstall` });
|
|
756
|
+
}
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
// Everything else converges onto the installer's wiring.
|
|
760
|
+
const slot = INSTALLER_MCP_SLOT[h.harness];
|
|
761
|
+
const occupied = slot ? L.find((i) => i.kind === "mcp" && i.owner !== "installer" && i.detail === slot) : undefined;
|
|
762
|
+
const aceSkill = h.harness === "claude-code" ? L.find((i) => i.kind === "skill" && i.owner === "ace" && i.scope === "user" && path.basename(i.file) === "load-lock") : undefined;
|
|
763
|
+
const projectOnly = PROJECT_SCOPED.includes(h.harness) && !L.some((i) => i.owner === "installer" && i.scope === "project");
|
|
764
|
+
let installed = false;
|
|
765
|
+
if (needsRefresh(h) && !projectOnly) {
|
|
766
|
+
if (occupied) {
|
|
767
|
+
steps.push({
|
|
768
|
+
harness: h.harness,
|
|
769
|
+
action: "manual",
|
|
770
|
+
detail: `${occupied.detail} in ${occupied.file} was not written by \`load-lock install\`; replace it with \`load-lock install --harness ${h.harness}\` or keep it`,
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
else if (aceSkill) {
|
|
774
|
+
steps.push({
|
|
775
|
+
harness: h.harness,
|
|
776
|
+
action: "manual",
|
|
777
|
+
detail: `the skill at ${aceSkill.file} is managed by ACE (\`load-lock install --harness claude-code\` leaves it in place); run that yourself if you want the loose wiring`,
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
else if (opts.selfEphemeral) {
|
|
781
|
+
steps.push({
|
|
782
|
+
harness: h.harness,
|
|
783
|
+
action: "manual",
|
|
784
|
+
detail: `this load-lock runs from a cache copy (${opts.selfCopy ?? "?"}) that npm may prune; install it globally ` +
|
|
785
|
+
`(npm i -g ${PKG_NAME}) and re-run \`load-lock setup\` to wire ${h.harness}`,
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
else {
|
|
789
|
+
steps.push({ harness: h.harness, action: "install", detail: `load-lock install --harness ${h.harness}` });
|
|
790
|
+
installed = true;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
const ownMcp = installed || installerOwned(L, "mcp").length > 0 || L.some(activePlugin);
|
|
794
|
+
steps.push(...foreignSteps(h, !ownMcp, ownMcp ? "load-lock is already registered" : ""));
|
|
795
|
+
}
|
|
796
|
+
const cc = inv.harnesses.find((h) => h.harness === "claude-code");
|
|
797
|
+
if (cc && cc.rung !== "none" && cc.rung !== "plugin") {
|
|
798
|
+
hints.push("Claude Code: the plugin adds the same wiring as one toggleable unit: " +
|
|
799
|
+
`/plugin marketplace add aron98/load-lock, then /plugin install ${CLAUDE_PLUGIN_NAME}@load-lock; re-run load-lock setup afterwards`);
|
|
800
|
+
}
|
|
801
|
+
const none = inv.harnesses.filter((h) => h.rung === "none").map((h) => h.harness);
|
|
802
|
+
if (none.length)
|
|
803
|
+
hints.push(`not installed: ${none.join(", ")} (load-lock install --harness <name> adds one)`);
|
|
804
|
+
return { steps, hints };
|
|
805
|
+
}
|
|
806
|
+
// ------------------------------------------------------------------- apply
|
|
807
|
+
function writeJsonFile(file, data) {
|
|
808
|
+
const tmp = file + ".tmp-ll-" + process.pid;
|
|
809
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
|
|
810
|
+
fs.renameSync(tmp, file);
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Remove every hook entry in `file` whose command has the installer's shape
|
|
814
|
+
* for `harness`, whichever copy it runs. Drops emptied events / `hooks`;
|
|
815
|
+
* removes the file when only the installer's bare shape is left.
|
|
816
|
+
*/
|
|
817
|
+
function stripInstallerHooks(file, harness) {
|
|
818
|
+
const cfg = readJson(file);
|
|
819
|
+
if (!isObject(cfg) || !isObject(cfg.hooks))
|
|
820
|
+
return false;
|
|
821
|
+
let changed = false;
|
|
822
|
+
for (const [ev, list] of Object.entries(cfg.hooks)) {
|
|
823
|
+
if (!Array.isArray(list))
|
|
824
|
+
continue;
|
|
825
|
+
const kept = list.filter((e) => !hookCommands(e).some((c) => hookCommand(c, harness)?.owner === "installer"));
|
|
826
|
+
if (kept.length === list.length)
|
|
827
|
+
continue;
|
|
828
|
+
changed = true;
|
|
829
|
+
if (kept.length)
|
|
830
|
+
cfg.hooks[ev] = kept;
|
|
831
|
+
else
|
|
832
|
+
delete cfg.hooks[ev];
|
|
833
|
+
}
|
|
834
|
+
if (!changed)
|
|
835
|
+
return false;
|
|
836
|
+
if (Object.keys(cfg.hooks).length === 0)
|
|
837
|
+
delete cfg.hooks;
|
|
838
|
+
const keys = Object.keys(cfg);
|
|
839
|
+
if (keys.length === 0 || (harness === "cursor" && keys.length === 1 && cfg.version === 1))
|
|
840
|
+
fs.rmSync(file);
|
|
841
|
+
else
|
|
842
|
+
writeJsonFile(file, cfg);
|
|
843
|
+
return true;
|
|
844
|
+
}
|
|
845
|
+
/** Remove the installer's tagged `[mcp_servers.load-lock]` block (same match as uninstall). */
|
|
846
|
+
function stripCodexBlock(file) {
|
|
847
|
+
const toml = readText(file);
|
|
848
|
+
const re = /\n?# load-lock \(added by `load-lock install`\)\n\[mcp_servers\.load-lock\]\n[^\n]*\n[^\n]*\n/;
|
|
849
|
+
if (toml === undefined || !re.test(toml))
|
|
850
|
+
return false;
|
|
851
|
+
fs.writeFileSync(file, toml.replace(re, ""));
|
|
852
|
+
return true;
|
|
853
|
+
}
|
|
854
|
+
/** Remove a marked section the way the installer's uninstall does. */
|
|
855
|
+
function removeMdSection(file) {
|
|
856
|
+
const text = readText(file);
|
|
857
|
+
if (text === undefined)
|
|
858
|
+
return false;
|
|
859
|
+
const idx = text.indexOf(MD_MARKER);
|
|
860
|
+
if (idx === -1)
|
|
861
|
+
return false;
|
|
862
|
+
const next = text.indexOf(MD_MARKER, idx + MD_MARKER.length);
|
|
863
|
+
const end = next === -1 ? text.length : next;
|
|
864
|
+
const start = idx > 0 && text[idx - 1] === "\n" ? idx - 1 : idx;
|
|
865
|
+
fs.writeFileSync(file, text.slice(0, start) + text.slice(end));
|
|
866
|
+
return true;
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Drop the loose Claude Code wiring (installer-shaped entries only); keep
|
|
870
|
+
* the skill. Shared by `setup --yes` (`remove-loose`) and `install` when
|
|
871
|
+
* the plugin is enabled (#62).
|
|
872
|
+
*/
|
|
873
|
+
export function removeClaudeLoose(h) {
|
|
874
|
+
const done = [];
|
|
875
|
+
for (const i of h.integrations) {
|
|
876
|
+
if (i.owner !== "installer")
|
|
877
|
+
continue;
|
|
878
|
+
if (i.kind === "mcp" && i.detail === "mcpServers.load-lock" && i.scope === "user") {
|
|
879
|
+
const cj = readJson(i.file);
|
|
880
|
+
if (isObject(cj?.mcpServers) && "load-lock" in cj.mcpServers) {
|
|
881
|
+
delete cj.mcpServers["load-lock"];
|
|
882
|
+
writeJsonFile(i.file, cj);
|
|
883
|
+
done.push(`removed mcpServers.load-lock from ${i.file}`);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
else if (i.kind === "hooks") {
|
|
887
|
+
if (stripInstallerHooks(i.file, "claude-code"))
|
|
888
|
+
done.push(`removed load-lock hooks from ${i.file}`);
|
|
889
|
+
}
|
|
890
|
+
else if (i.kind === "policy") {
|
|
891
|
+
if (removeMdSection(i.file))
|
|
892
|
+
done.push(`removed the load-lock section from ${i.file}`);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return done;
|
|
896
|
+
}
|
|
897
|
+
/** Apply the non-manual steps in order; returns what was done. */
|
|
898
|
+
export async function applyPlan(inv, plan, opts) {
|
|
899
|
+
const { installCommand } = await import("./install.js");
|
|
900
|
+
const done = [];
|
|
901
|
+
for (const step of plan.steps) {
|
|
902
|
+
const h = inv.harnesses.find((x) => x.harness === step.harness);
|
|
903
|
+
if (step.action === "remove-loose") {
|
|
904
|
+
done.push(...removeClaudeLoose(h).map((d) => `${h.harness}: ${d}`));
|
|
905
|
+
}
|
|
906
|
+
else if (step.action === "install") {
|
|
907
|
+
for (const file of new Set(installerOwned(h.integrations, "hooks").map((i) => i.file))) {
|
|
908
|
+
if (stripInstallerHooks(file, h.harness))
|
|
909
|
+
done.push(`${h.harness}: removed load-lock hooks from ${file} before re-installing`);
|
|
910
|
+
}
|
|
911
|
+
for (const i of installerOwned(h.integrations, "mcp")) {
|
|
912
|
+
if (h.harness === "codex" && i.stale && stripCodexBlock(i.file))
|
|
913
|
+
done.push(`${h.harness}: removed stale [mcp_servers.load-lock] from ${i.file}`);
|
|
914
|
+
}
|
|
915
|
+
await installCommand({ harness: h.harness, project: opts.project, json: true, global: true, binDir: opts.binDir });
|
|
916
|
+
done.push(`${h.harness}: ran load-lock install --harness ${h.harness}`);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
return done;
|
|
920
|
+
}
|
|
921
|
+
/** Human-readable plan. */
|
|
922
|
+
export function formatPlan(plan, applied) {
|
|
923
|
+
const lines = [""];
|
|
924
|
+
if (!plan.steps.length)
|
|
925
|
+
lines.push("plan: nothing to change, every installed harness has the recommended layout");
|
|
926
|
+
else {
|
|
927
|
+
lines.push(applied ? "plan (applied):" : "plan (dry run, re-run with --yes to apply):");
|
|
928
|
+
const width = Math.max(...plan.steps.map((s) => s.harness.length));
|
|
929
|
+
for (const s of plan.steps)
|
|
930
|
+
lines.push(` ${s.harness.padEnd(width)} ${s.action.padEnd(12)} ${s.detail}`);
|
|
931
|
+
}
|
|
932
|
+
if (applied) {
|
|
933
|
+
lines.push("", "done:");
|
|
934
|
+
for (const d of applied)
|
|
935
|
+
lines.push(" - " + d);
|
|
936
|
+
if (plan.steps.some((s) => s.action === "manual"))
|
|
937
|
+
lines.push(" (manual steps above were not applied)");
|
|
938
|
+
}
|
|
939
|
+
for (const h of plan.hints)
|
|
940
|
+
lines.push("", "hint: " + h);
|
|
941
|
+
return lines.join("\n");
|
|
942
|
+
}
|