crbro-memory 2.7.2 → 2.9.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 +66 -7
- package/SECURITY.md +36 -0
- package/bin/crbro.mjs +1437 -1366
- package/dist/daemon/endpoint.d.ts.map +1 -1
- package/dist/daemon/endpoint.js +18 -0
- package/dist/daemon/endpoint.js.map +1 -1
- package/dist/engine/brain.d.ts.map +1 -1
- package/dist/engine/brain.js +30 -0
- package/dist/engine/brain.js.map +1 -1
- package/dist/engine/cortex.d.ts +55 -1
- package/dist/engine/cortex.d.ts.map +1 -1
- package/dist/engine/cortex.js +183 -3
- package/dist/engine/cortex.js.map +1 -1
- package/dist/engine/maintenance.d.ts +22 -0
- package/dist/engine/maintenance.d.ts.map +1 -1
- package/dist/engine/maintenance.js +59 -2
- package/dist/engine/maintenance.js.map +1 -1
- package/dist/engine/modinstall.d.ts +215 -0
- package/dist/engine/modinstall.d.ts.map +1 -0
- package/dist/engine/modinstall.js +1342 -0
- package/dist/engine/modinstall.js.map +1 -0
- package/dist/engine/shelf.d.ts +97 -0
- package/dist/engine/shelf.d.ts.map +1 -0
- package/dist/engine/shelf.js +343 -0
- package/dist/engine/shelf.js.map +1 -0
- package/dist/engine/source.d.ts +6 -0
- package/dist/engine/source.d.ts.map +1 -0
- package/dist/engine/source.js +101 -0
- package/dist/engine/source.js.map +1 -0
- package/dist/search/index.d.ts +6 -0
- package/dist/search/index.d.ts.map +1 -1
- package/dist/search/index.js +46 -1
- package/dist/search/index.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +226 -33
- package/dist/server.js.map +1 -1
- package/dist/sync/materialize.d.ts +4 -0
- package/dist/sync/materialize.d.ts.map +1 -1
- package/dist/sync/materialize.js +80 -1
- package/dist/sync/materialize.js.map +1 -1
- package/dist/sync/ops.d.ts +29 -2
- package/dist/sync/ops.d.ts.map +1 -1
- package/dist/sync/ops.js.map +1 -1
- package/dist/sync/space.d.ts.map +1 -1
- package/dist/sync/space.js +15 -2
- package/dist/sync/space.js.map +1 -1
- package/dist/types/index.d.ts +47 -0
- package/dist/types/index.d.ts.map +1 -1
- package/mods/crbro-pending/.claude-plugin/plugin.json +21 -0
- package/mods/crbro-pending/hooks/hooks.json +1 -0
- package/mods/crbro-pending/hooks/register.tsx +576 -0
- package/mods/crbro-pending/hooks/strings.ts +200 -0
- package/mods/crbro-pending/types/index.d.ts +27 -0
- package/package.json +2 -1
|
@@ -0,0 +1,1342 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ─── The Claude Code mod: install, uninstall, verify ─────────────
|
|
3
|
+
//
|
|
4
|
+
// mods/crbro-pending is a Claude Code mod (a plugin of function hooks): the
|
|
5
|
+
// open-items band above the prompt and the /pending pane. Claude Code loads a
|
|
6
|
+
// plugin folder named in CLAUDE_CODE_PLUGIN_DIRS, from the process environment
|
|
7
|
+
// or from the `env` block of ~/.claude/settings.json; that block is the one
|
|
8
|
+
// place that reaches the desktop app's Code tab as well as the CLI.
|
|
9
|
+
//
|
|
10
|
+
// `install-mod` copies the mod to ~/.claude/crbro-mods/crbro-pending — a
|
|
11
|
+
// stable path, because the npx cache moves with every release — and adds that
|
|
12
|
+
// folder to the list, with the platform's separator, once. It takes the same
|
|
13
|
+
// care as the install-hooks commands: a settings.json that does not parse is
|
|
14
|
+
// never touched, the write is atomic (.tmp + rename), and a second run
|
|
15
|
+
// changes nothing. A folder already in the list whose plugin.json is named
|
|
16
|
+
// crbro-pendientes (the local copy this mod grew out of) is replaced in place,
|
|
17
|
+
// said so and written down in crbro-mods/crbro-pending.replaced.json; the
|
|
18
|
+
// folder itself is left on disk. Any other crbro-pending in the list, or a
|
|
19
|
+
// copy in ~/.claude/mods, is pointed out and left alone.
|
|
20
|
+
//
|
|
21
|
+
// `uninstall-mod` takes the path out of the list (and the variable, if it
|
|
22
|
+
// ends up empty), puts back what install-mod replaced if that folder is still
|
|
23
|
+
// there, and deletes ~/.claude/crbro-mods/crbro-pending, nothing else.
|
|
24
|
+
//
|
|
25
|
+
// `verifyMod` compares the installed copy with the package, file by file, by
|
|
26
|
+
// SHA-256, the way hookverify.ts does for the hooks, and names any other copy
|
|
27
|
+
// Claude Code would load beside it. It only reads.
|
|
28
|
+
//
|
|
29
|
+
// `autoInstallMod` is what crbro_boot runs, once per process: the band
|
|
30
|
+
// reaches everyone who has CRBRO and Claude Code without a command, and says
|
|
31
|
+
// so (mod_notice in the boot answer). It installs the way install-mod does,
|
|
32
|
+
// refreshes the files alone when the package ships different ones, and stays
|
|
33
|
+
// out for good once uninstall-mod has been run (state.json, optedOut) or the
|
|
34
|
+
// folder was taken out of the list by hand; CRBRO_MOD=0 keeps the client it
|
|
35
|
+
// is set in out of it.
|
|
36
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
37
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
38
|
+
};
|
|
39
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
|
+
exports.NOTICE_SESSIONS = exports.BOOT_BUDGET_MS = exports.MOD_OFF_VALUES = exports.MOD_SWITCH_VAR = exports.PLUGIN_DIRS_VAR = exports.COPY_NAMES = exports.LEGACY_NAME = exports.MOD_NAME = void 0;
|
|
41
|
+
exports.modPaths = modPaths;
|
|
42
|
+
exports.pathListSeparator = pathListSeparator;
|
|
43
|
+
exports.sha256 = sha256;
|
|
44
|
+
exports.modFiles = modFiles;
|
|
45
|
+
exports.readModState = readModState;
|
|
46
|
+
exports.compareVersions = compareVersions;
|
|
47
|
+
exports.installMod = installMod;
|
|
48
|
+
exports.uninstallMod = uninstallMod;
|
|
49
|
+
exports.verifyMod = verifyMod;
|
|
50
|
+
exports.formatModVerify = formatModVerify;
|
|
51
|
+
exports.modSwitchedOff = modSwitchedOff;
|
|
52
|
+
exports.takeModNotice = takeModNotice;
|
|
53
|
+
exports.autoInstallMod = autoInstallMod;
|
|
54
|
+
exports.startModOnBoot = startModOnBoot;
|
|
55
|
+
exports.modNoticeOnBoot = modNoticeOnBoot;
|
|
56
|
+
exports.resetModOnBoot = resetModOnBoot;
|
|
57
|
+
const crypto_1 = require("crypto");
|
|
58
|
+
const fs_1 = require("fs");
|
|
59
|
+
const os_1 = require("os");
|
|
60
|
+
const path_1 = __importDefault(require("path"));
|
|
61
|
+
exports.MOD_NAME = 'crbro-pending';
|
|
62
|
+
/** The local copy this mod grew out of: a folder in the list holding it is replaced. */
|
|
63
|
+
exports.LEGACY_NAME = 'crbro-pendientes';
|
|
64
|
+
/** Plugin names that would load a second band beside this one. */
|
|
65
|
+
exports.COPY_NAMES = [exports.LEGACY_NAME, exports.MOD_NAME];
|
|
66
|
+
/** pluginConfigs keys Claude Code may store this plugin's options under. */
|
|
67
|
+
const CONFIG_KEYS = [exports.MOD_NAME, `${exports.MOD_NAME}@inline`];
|
|
68
|
+
exports.PLUGIN_DIRS_VAR = 'CLAUDE_CODE_PLUGIN_DIRS';
|
|
69
|
+
/** What the package ships but the install leaves out: tests and editor config. */
|
|
70
|
+
const NOT_SHIPPED_TOP = new Set(['test', 'tsconfig.json', '.gitignore', '.npmignore', 'node_modules']);
|
|
71
|
+
const NOT_SHIPPED_DIRS = new Set(['.claude-plugin/types']);
|
|
72
|
+
function modPaths(home) {
|
|
73
|
+
const claudeDir = path_1.default.join(home, '.claude');
|
|
74
|
+
return {
|
|
75
|
+
claudeDir,
|
|
76
|
+
settingsPath: path_1.default.join(claudeDir, 'settings.json'),
|
|
77
|
+
modsDir: path_1.default.join(claudeDir, 'crbro-mods'),
|
|
78
|
+
installedDir: path_1.default.join(claudeDir, 'crbro-mods', exports.MOD_NAME),
|
|
79
|
+
/** What install-mod took out of the list, so uninstall-mod can put it back. */
|
|
80
|
+
replacedPath: path_1.default.join(claudeDir, 'crbro-mods', `${exports.MOD_NAME}.replaced.json`),
|
|
81
|
+
/** The opt-out mark, what was installed and what failed: see ModState. */
|
|
82
|
+
statePath: path_1.default.join(claudeDir, 'crbro-mods', 'state.json'),
|
|
83
|
+
/** A notice the automatic install left for the next crbro_boot to hand over. */
|
|
84
|
+
noticePath: path_1.default.join(claudeDir, 'crbro-mods', 'notice.json'),
|
|
85
|
+
/** Held while an automatic install runs, so two sessions starting at once do not both write. */
|
|
86
|
+
lockPath: path_1.default.join(claudeDir, 'crbro-mods', 'auto.lock'),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function pathListSeparator(platform = process.platform) {
|
|
90
|
+
return platform === 'win32' ? ';' : ':';
|
|
91
|
+
}
|
|
92
|
+
function sha256(buf) {
|
|
93
|
+
return (0, crypto_1.createHash)('sha256').update(buf).digest('hex');
|
|
94
|
+
}
|
|
95
|
+
function lfSha(buf) {
|
|
96
|
+
return sha256(Buffer.from(buf.toString('utf8').replace(/\r\n/g, '\n'), 'utf8'));
|
|
97
|
+
}
|
|
98
|
+
/** The files the mod is made of, relative and with forward slashes, sorted. */
|
|
99
|
+
function modFiles(dir) {
|
|
100
|
+
const out = [];
|
|
101
|
+
const walk = (rel) => {
|
|
102
|
+
for (const name of (0, fs_1.readdirSync)(path_1.default.join(dir, rel)).sort()) {
|
|
103
|
+
const relPath = rel ? `${rel}/${name}` : name;
|
|
104
|
+
if (!rel && NOT_SHIPPED_TOP.has(name))
|
|
105
|
+
continue;
|
|
106
|
+
if (NOT_SHIPPED_DIRS.has(relPath))
|
|
107
|
+
continue;
|
|
108
|
+
const st = (0, fs_1.statSync)(path_1.default.join(dir, relPath));
|
|
109
|
+
if (st.isDirectory())
|
|
110
|
+
walk(relPath);
|
|
111
|
+
else if (st.isFile())
|
|
112
|
+
out.push(relPath);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
if ((0, fs_1.existsSync)(dir))
|
|
116
|
+
walk('');
|
|
117
|
+
return out.sort();
|
|
118
|
+
}
|
|
119
|
+
/** Every file under a folder, nothing left out: what an installed copy really holds. */
|
|
120
|
+
function allFiles(dir) {
|
|
121
|
+
const out = [];
|
|
122
|
+
const walk = (rel) => {
|
|
123
|
+
for (const name of (0, fs_1.readdirSync)(path_1.default.join(dir, rel)).sort()) {
|
|
124
|
+
const relPath = rel ? `${rel}/${name}` : name;
|
|
125
|
+
const st = (0, fs_1.statSync)(path_1.default.join(dir, relPath));
|
|
126
|
+
if (st.isDirectory())
|
|
127
|
+
walk(relPath);
|
|
128
|
+
else
|
|
129
|
+
out.push(relPath);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
if ((0, fs_1.existsSync)(dir))
|
|
133
|
+
walk('');
|
|
134
|
+
return out.sort();
|
|
135
|
+
}
|
|
136
|
+
/** A list entry without the quotes a hand-edited list sometimes has around it. */
|
|
137
|
+
function unquote(p) {
|
|
138
|
+
return p.trim().replace(/^(["'])(.*)\1$/, '$2').trim();
|
|
139
|
+
}
|
|
140
|
+
function expandHome(p, home) {
|
|
141
|
+
p = unquote(p);
|
|
142
|
+
if (p === '~')
|
|
143
|
+
return home;
|
|
144
|
+
if (p.startsWith('~/') || p.startsWith('~\\'))
|
|
145
|
+
return path_1.default.join(home, p.slice(2));
|
|
146
|
+
return p;
|
|
147
|
+
}
|
|
148
|
+
function samePath(a, b, home, platform) {
|
|
149
|
+
const norm = (p) => {
|
|
150
|
+
const s = expandHome(p.trim(), home).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
151
|
+
return platform === 'win32' ? s.toLowerCase() : s;
|
|
152
|
+
};
|
|
153
|
+
return norm(a) === norm(b);
|
|
154
|
+
}
|
|
155
|
+
/** The `name` in <dir>/.claude-plugin/plugin.json, or null. */
|
|
156
|
+
function pluginNameAt(dir, home) {
|
|
157
|
+
try {
|
|
158
|
+
const raw = (0, fs_1.readFileSync)(path_1.default.join(expandHome(dir.trim(), home), '.claude-plugin', 'plugin.json'), 'utf8');
|
|
159
|
+
const name = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw)?.name;
|
|
160
|
+
return typeof name === 'string' ? name : null;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
167
|
+
/** settings.json as an object, null when absent; refuses anything it would have to guess about. */
|
|
168
|
+
function readSettings(settingsPath) {
|
|
169
|
+
if (!(0, fs_1.existsSync)(settingsPath))
|
|
170
|
+
return { ok: true, settings: null, indent: 2, eol: '\n', hash: 'absent' };
|
|
171
|
+
let settings;
|
|
172
|
+
let raw;
|
|
173
|
+
let hash;
|
|
174
|
+
try {
|
|
175
|
+
const buf = (0, fs_1.readFileSync)(settingsPath);
|
|
176
|
+
hash = sha256(buf);
|
|
177
|
+
raw = buf.toString('utf8');
|
|
178
|
+
settings = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
return { ok: false, error: `${settingsPath} exists but could not be parsed — not touching it.\n ${e.message}` };
|
|
182
|
+
}
|
|
183
|
+
if (!isObject(settings)) {
|
|
184
|
+
return { ok: false, error: `${settingsPath} is not a JSON object — not touching it.` };
|
|
185
|
+
}
|
|
186
|
+
const env = settings.env;
|
|
187
|
+
if (env !== undefined && !isObject(env)) {
|
|
188
|
+
return { ok: false, error: `env in ${settingsPath} is not an object — not touching it.` };
|
|
189
|
+
}
|
|
190
|
+
const dirs = env?.[exports.PLUGIN_DIRS_VAR];
|
|
191
|
+
if (dirs !== undefined && typeof dirs !== 'string') {
|
|
192
|
+
return { ok: false, error: `env.${exports.PLUGIN_DIRS_VAR} in ${settingsPath} is not a string — not touching it.` };
|
|
193
|
+
}
|
|
194
|
+
const configs = settings.pluginConfigs;
|
|
195
|
+
if (configs !== undefined && !isObject(configs)) {
|
|
196
|
+
return { ok: false, error: `pluginConfigs in ${settingsPath} is not an object — not touching it.` };
|
|
197
|
+
}
|
|
198
|
+
for (const key of CONFIG_KEYS) {
|
|
199
|
+
const mine = configs?.[key];
|
|
200
|
+
if (mine === undefined)
|
|
201
|
+
continue;
|
|
202
|
+
if (!isObject(mine)) {
|
|
203
|
+
return { ok: false, error: `pluginConfigs["${key}"] in ${settingsPath} is not an object — not touching it.` };
|
|
204
|
+
}
|
|
205
|
+
if (mine.options !== undefined && !isObject(mine.options)) {
|
|
206
|
+
return { ok: false, error: `pluginConfigs["${key}"].options in ${settingsPath} is not an object — not touching it.` };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// Written back with the indentation and the line endings it came with.
|
|
210
|
+
const indent = /\n([ \t]+)"/.exec(raw)?.[1] ?? 2;
|
|
211
|
+
const eol = /\r\n/.test(raw) ? '\r\n' : '\n';
|
|
212
|
+
return { ok: true, settings, indent, eol, hash };
|
|
213
|
+
}
|
|
214
|
+
/** Thrown by writeAtomic when settings.json changed after it was read: the caller merges again. */
|
|
215
|
+
const SETTINGS_CHANGED = 'ESETTINGSCHANGED';
|
|
216
|
+
/** Errors a later start may well not meet (a file held open, an antivirus scan): never written down as a failure. */
|
|
217
|
+
const TRANSIENT = new Set(['EBUSY', 'EPERM', 'EACCES', 'ENOTEMPTY', 'EEXIST', SETTINGS_CHANGED]);
|
|
218
|
+
/**
|
|
219
|
+
* Writes settings.json through a .tmp + rename. A symlinked settings.json
|
|
220
|
+
* (dotfiles) is written at its target, so the link stays a link, and the
|
|
221
|
+
* file keeps its permission bits (on POSIX: its env block often carries
|
|
222
|
+
* tokens) and its line endings. With `expectHash`, the file is hashed once
|
|
223
|
+
* more right before the rename and nothing is written if it is no longer
|
|
224
|
+
* what was read: Claude Code or an editor saved it in between, and their
|
|
225
|
+
* change must not be lost. On Windows the renamed file takes the folder's
|
|
226
|
+
* inherited ACL, and a hard link to settings.json is not kept.
|
|
227
|
+
*/
|
|
228
|
+
function writeAtomic(settingsPath, settings, indent = 2, eol = '\n', expectHash) {
|
|
229
|
+
let target = settingsPath;
|
|
230
|
+
try {
|
|
231
|
+
if ((0, fs_1.lstatSync)(settingsPath).isSymbolicLink())
|
|
232
|
+
target = (0, fs_1.realpathSync)(settingsPath);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// Not there yet: written as a plain file.
|
|
236
|
+
}
|
|
237
|
+
let mode;
|
|
238
|
+
try {
|
|
239
|
+
mode = (0, fs_1.statSync)(target).mode & 0o777;
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
mode = undefined;
|
|
243
|
+
}
|
|
244
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
245
|
+
try {
|
|
246
|
+
let text = JSON.stringify(settings, null, indent) + '\n';
|
|
247
|
+
if (eol !== '\n')
|
|
248
|
+
text = text.replace(/\n/g, eol);
|
|
249
|
+
(0, fs_1.writeFileSync)(tmp, text, mode === undefined ? 'utf8' : { encoding: 'utf8', mode });
|
|
250
|
+
if (expectHash !== undefined && settingsFingerprint(target) !== expectHash) {
|
|
251
|
+
const changed = new Error(`${settingsPath} changed while it was being written.`);
|
|
252
|
+
changed.code = SETTINGS_CHANGED;
|
|
253
|
+
throw changed;
|
|
254
|
+
}
|
|
255
|
+
(0, fs_1.renameSync)(tmp, target);
|
|
256
|
+
}
|
|
257
|
+
catch (e) {
|
|
258
|
+
(0, fs_1.rmSync)(tmp, { force: true });
|
|
259
|
+
throw e;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function splitList(value, sep) {
|
|
263
|
+
return (value ?? '').split(sep).map(s => s.trim()).filter(Boolean);
|
|
264
|
+
}
|
|
265
|
+
const STAGING = /^crbro-pending\.\d+\.(tmp|old)$/;
|
|
266
|
+
function copyFailed(installedDir, e) {
|
|
267
|
+
const err = new Error(`could not replace ${installedDir} (${e.message}).\n` +
|
|
268
|
+
' Close the sessions and terminals using that folder and run it again.');
|
|
269
|
+
err.code = e?.code;
|
|
270
|
+
return err;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Copies the shipped files into a fresh folder, then swaps it in: the old
|
|
274
|
+
* folder is renamed aside first and put back if the swap fails, so the
|
|
275
|
+
* listed folder is never left missing or half written.
|
|
276
|
+
*/
|
|
277
|
+
function copyMod(packageDir, installedDir) {
|
|
278
|
+
const files = modFiles(packageDir);
|
|
279
|
+
const parent = path_1.default.dirname(installedDir);
|
|
280
|
+
(0, fs_1.mkdirSync)(parent, { recursive: true });
|
|
281
|
+
// A staging or set-aside folder left by a run that failed half way: its pid is gone.
|
|
282
|
+
for (const name of (0, fs_1.readdirSync)(parent)) {
|
|
283
|
+
if (STAGING.test(name))
|
|
284
|
+
(0, fs_1.rmSync)(path_1.default.join(parent, name), { recursive: true, force: true, maxRetries: 3 });
|
|
285
|
+
}
|
|
286
|
+
const staging = `${installedDir}.${process.pid}.tmp`;
|
|
287
|
+
const aside = `${installedDir}.${process.pid}.old`;
|
|
288
|
+
let setAside = false;
|
|
289
|
+
try {
|
|
290
|
+
for (const rel of files) {
|
|
291
|
+
const target = path_1.default.join(staging, ...rel.split('/'));
|
|
292
|
+
(0, fs_1.mkdirSync)(path_1.default.dirname(target), { recursive: true });
|
|
293
|
+
(0, fs_1.cpSync)(path_1.default.join(packageDir, ...rel.split('/')), target);
|
|
294
|
+
}
|
|
295
|
+
if ((0, fs_1.existsSync)(installedDir)) {
|
|
296
|
+
(0, fs_1.renameSync)(installedDir, aside);
|
|
297
|
+
setAside = true;
|
|
298
|
+
}
|
|
299
|
+
(0, fs_1.renameSync)(staging, installedDir);
|
|
300
|
+
}
|
|
301
|
+
catch (e) {
|
|
302
|
+
if (setAside && !(0, fs_1.existsSync)(installedDir)) {
|
|
303
|
+
try {
|
|
304
|
+
(0, fs_1.renameSync)(aside, installedDir);
|
|
305
|
+
}
|
|
306
|
+
catch { /* left as .old: the next run puts in a fresh copy */ }
|
|
307
|
+
}
|
|
308
|
+
(0, fs_1.rmSync)(staging, { recursive: true, force: true });
|
|
309
|
+
throw copyFailed(installedDir, e);
|
|
310
|
+
}
|
|
311
|
+
if (setAside) {
|
|
312
|
+
try {
|
|
313
|
+
(0, fs_1.rmSync)(aside, { recursive: true, force: true, maxRetries: 3 });
|
|
314
|
+
}
|
|
315
|
+
catch { /* the next run removes it */ }
|
|
316
|
+
}
|
|
317
|
+
return files;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* copyMod for crbro_boot: the same steps through fs.promises, so a slow disk
|
|
321
|
+
* or a file an antivirus holds (rm's retries) never stalls the event loop,
|
|
322
|
+
* which in daemon mode serves every conversation.
|
|
323
|
+
*/
|
|
324
|
+
async function copyModAsync(packageDir, installedDir) {
|
|
325
|
+
const files = modFiles(packageDir);
|
|
326
|
+
const parent = path_1.default.dirname(installedDir);
|
|
327
|
+
await fs_1.promises.mkdir(parent, { recursive: true });
|
|
328
|
+
for (const name of await fs_1.promises.readdir(parent)) {
|
|
329
|
+
if (STAGING.test(name))
|
|
330
|
+
await fs_1.promises.rm(path_1.default.join(parent, name), { recursive: true, force: true, maxRetries: 3 });
|
|
331
|
+
}
|
|
332
|
+
const staging = `${installedDir}.${process.pid}.tmp`;
|
|
333
|
+
const aside = `${installedDir}.${process.pid}.old`;
|
|
334
|
+
let setAside = false;
|
|
335
|
+
try {
|
|
336
|
+
for (const rel of files) {
|
|
337
|
+
const target = path_1.default.join(staging, ...rel.split('/'));
|
|
338
|
+
await fs_1.promises.mkdir(path_1.default.dirname(target), { recursive: true });
|
|
339
|
+
await fs_1.promises.copyFile(path_1.default.join(packageDir, ...rel.split('/')), target);
|
|
340
|
+
}
|
|
341
|
+
if ((0, fs_1.existsSync)(installedDir)) {
|
|
342
|
+
await fs_1.promises.rename(installedDir, aside);
|
|
343
|
+
setAside = true;
|
|
344
|
+
}
|
|
345
|
+
await fs_1.promises.rename(staging, installedDir);
|
|
346
|
+
}
|
|
347
|
+
catch (e) {
|
|
348
|
+
if (setAside && !(0, fs_1.existsSync)(installedDir))
|
|
349
|
+
await fs_1.promises.rename(aside, installedDir).catch(() => undefined);
|
|
350
|
+
await fs_1.promises.rm(staging, { recursive: true, force: true }).catch(() => undefined);
|
|
351
|
+
throw copyFailed(installedDir, e);
|
|
352
|
+
}
|
|
353
|
+
if (setAside)
|
|
354
|
+
await fs_1.promises.rm(aside, { recursive: true, force: true, maxRetries: 3 }).catch(() => undefined);
|
|
355
|
+
return files;
|
|
356
|
+
}
|
|
357
|
+
/** What an earlier install-mod took out of the list; [] when nothing or unreadable. */
|
|
358
|
+
function readReplaced(file) {
|
|
359
|
+
try {
|
|
360
|
+
const list = JSON.parse((0, fs_1.readFileSync)(file, 'utf8'))?.replaced;
|
|
361
|
+
return Array.isArray(list)
|
|
362
|
+
? list.filter((r) => typeof r?.dir === 'string' && typeof r?.name === 'string')
|
|
363
|
+
: [];
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return [];
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Other copies Claude Code would load beside this one: folders in the list
|
|
371
|
+
* whose plugin is named crbro-pendientes or crbro-pending, and the same in
|
|
372
|
+
* ~/.claude/mods, which Claude Code may load on its own.
|
|
373
|
+
*/
|
|
374
|
+
function otherCopies(list, home, platform) {
|
|
375
|
+
const { claudeDir, installedDir } = modPaths(home);
|
|
376
|
+
const isCopy = (dir) => {
|
|
377
|
+
const name = pluginNameAt(dir, home);
|
|
378
|
+
return name !== null && exports.COPY_NAMES.includes(name);
|
|
379
|
+
};
|
|
380
|
+
const listed = list.filter(dir => !samePath(dir, installedDir, home, platform) && isCopy(dir));
|
|
381
|
+
const modsFolder = path_1.default.join(claudeDir, 'mods');
|
|
382
|
+
let local = [];
|
|
383
|
+
try {
|
|
384
|
+
local = (0, fs_1.readdirSync)(modsFolder).map(name => path_1.default.join(modsFolder, name)).filter(isCopy);
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
local = [];
|
|
388
|
+
}
|
|
389
|
+
return [...listed, ...local.filter(dir => !listed.some(d => samePath(d, dir, home, platform)))];
|
|
390
|
+
}
|
|
391
|
+
function readModState(home) {
|
|
392
|
+
try {
|
|
393
|
+
const raw = JSON.parse((0, fs_1.readFileSync)(modPaths(home).statePath, 'utf8'));
|
|
394
|
+
return isObject(raw) ? raw : {};
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
return {};
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
function stateClean(state) {
|
|
401
|
+
return Object.fromEntries(Object.entries(state).filter(([, v]) => v !== undefined));
|
|
402
|
+
}
|
|
403
|
+
/** Written through .tmp + rename; an empty state removes the file. */
|
|
404
|
+
function writeModState(home, state) {
|
|
405
|
+
const { modsDir, statePath } = modPaths(home);
|
|
406
|
+
const clean = stateClean(state);
|
|
407
|
+
if (Object.keys(clean).length === 0) {
|
|
408
|
+
(0, fs_1.rmSync)(statePath, { force: true });
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
(0, fs_1.mkdirSync)(modsDir, { recursive: true });
|
|
412
|
+
const tmp = `${statePath}.${process.pid}.tmp`;
|
|
413
|
+
(0, fs_1.writeFileSync)(tmp, JSON.stringify(clean, null, 2) + '\n', 'utf8');
|
|
414
|
+
(0, fs_1.renameSync)(tmp, statePath);
|
|
415
|
+
}
|
|
416
|
+
/** The version in the package.json two folders above mods/crbro-pending, or null. */
|
|
417
|
+
function packageVersion(packageDir) {
|
|
418
|
+
try {
|
|
419
|
+
const v = JSON.parse((0, fs_1.readFileSync)(path_1.default.join(packageDir, '..', '..', 'package.json'), 'utf8'))?.version;
|
|
420
|
+
return typeof v === 'string' ? v : null;
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* -1, 0 or 1 on x.y.z; a prerelease (2.8.0-beta.1) is below its release.
|
|
428
|
+
* Anything that is not x.y.z compares equal, so it never blocks an update.
|
|
429
|
+
*/
|
|
430
|
+
function compareVersions(a, b) {
|
|
431
|
+
const parse = (v) => /^(\d+)\.(\d+)\.(\d+)(-)?/.exec(v);
|
|
432
|
+
const x = parse(a);
|
|
433
|
+
const y = parse(b);
|
|
434
|
+
if (!x || !y)
|
|
435
|
+
return 0;
|
|
436
|
+
for (let i = 1; i <= 3; i++)
|
|
437
|
+
if (Number(x[i]) !== Number(y[i]))
|
|
438
|
+
return Number(x[i]) < Number(y[i]) ? -1 : 1;
|
|
439
|
+
if (!!x[4] !== !!y[4])
|
|
440
|
+
return x[4] ? -1 : 1;
|
|
441
|
+
return 0;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* One hash for every shipped file and its path, read with LF line endings:
|
|
445
|
+
* what "this build of the mod" means. A git checkout with autocrlf and the
|
|
446
|
+
* npm copy of the same release hash the same.
|
|
447
|
+
*/
|
|
448
|
+
function treeHash(dir) {
|
|
449
|
+
const h = (0, crypto_1.createHash)('sha256');
|
|
450
|
+
for (const rel of modFiles(dir)) {
|
|
451
|
+
h.update(rel).update('\0').update(lfSha((0, fs_1.readFileSync)(path_1.default.join(dir, ...rel.split('/'))))).update('\0');
|
|
452
|
+
}
|
|
453
|
+
return h.digest('hex');
|
|
454
|
+
}
|
|
455
|
+
/** What settings.json holds right now, as a hash, or "absent": a failure is retried once this changes. */
|
|
456
|
+
function settingsFingerprint(settingsPath) {
|
|
457
|
+
try {
|
|
458
|
+
return sha256((0, fs_1.readFileSync)(settingsPath));
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
return 'absent';
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const REQUIREMENT_LINES = [
|
|
465
|
+
' Needs Claude Code 2.1.286 or later with mods: the CLI and the desktop app\'s Code tab.',
|
|
466
|
+
' Claude Desktop chat, Codex, Cursor and the VS Code extension do not draw it.',
|
|
467
|
+
' Open a new session to see the band; /pending (or /pendientes) opens the list.',
|
|
468
|
+
];
|
|
469
|
+
function installMod(opts) {
|
|
470
|
+
const platform = opts.platform ?? process.platform;
|
|
471
|
+
const { claudeDir, settingsPath, installedDir, replacedPath } = modPaths(opts.home);
|
|
472
|
+
const base = {
|
|
473
|
+
ok: false, installedDir, settingsPath, files: [], changed: false, alreadyListed: false, replaced: [], elsewhere: [],
|
|
474
|
+
envOnly: [], lines: [],
|
|
475
|
+
};
|
|
476
|
+
if (!(0, fs_1.existsSync)(path_1.default.join(opts.packageDir, '.claude-plugin', 'plugin.json'))) {
|
|
477
|
+
return { ...base, error: `The mod is missing from this package (${opts.packageDir}).` };
|
|
478
|
+
}
|
|
479
|
+
const read = readSettings(settingsPath);
|
|
480
|
+
if (!read.ok)
|
|
481
|
+
return { ...base, error: read.error };
|
|
482
|
+
if (read.settings === null && !(0, fs_1.existsSync)(claudeDir)) {
|
|
483
|
+
const skipped = '~/.claude not found: the mod is for Claude Code (2.1.286 or later), so nothing was installed.';
|
|
484
|
+
return { ...base, ok: true, skipped, lines: [` ⚪ ${skipped}`] };
|
|
485
|
+
}
|
|
486
|
+
// The files go in first, so the time between reading settings.json and
|
|
487
|
+
// writing it back is as short as it can be.
|
|
488
|
+
let files;
|
|
489
|
+
try {
|
|
490
|
+
files = opts.copied ?? copyMod(opts.packageDir, installedDir);
|
|
491
|
+
}
|
|
492
|
+
catch (e) {
|
|
493
|
+
return { ...base, error: e.message, transient: TRANSIENT.has(e.code ?? '') };
|
|
494
|
+
}
|
|
495
|
+
// Read, merge and write; if settings.json changed in between, merge again
|
|
496
|
+
// onto what is there now. Three tries, then give up without writing.
|
|
497
|
+
for (let attempt = 0;; attempt++) {
|
|
498
|
+
const fresh = readSettings(settingsPath);
|
|
499
|
+
if (!fresh.ok)
|
|
500
|
+
return { ...base, files, error: fresh.error };
|
|
501
|
+
const merged = mergeInto(fresh, opts, platform);
|
|
502
|
+
try {
|
|
503
|
+
opts.beforeSettingsWrite?.();
|
|
504
|
+
// uninstall-mod ran while this was under way: its answer wins. Checked
|
|
505
|
+
// last, right before the write; uninstall-mod writes its mark first.
|
|
506
|
+
if (opts.auto && readModState(opts.home).optedOut)
|
|
507
|
+
return { ...base, ok: true, files, skipped: 'opted-out' };
|
|
508
|
+
recordReplaced(replacedPath, merged.replaced, opts.home, platform);
|
|
509
|
+
if (merged.changed)
|
|
510
|
+
writeAtomic(settingsPath, merged.settings, fresh.indent, fresh.eol, fresh.hash);
|
|
511
|
+
return finishInstall(opts, merged, base, files, platform);
|
|
512
|
+
}
|
|
513
|
+
catch (e) {
|
|
514
|
+
if (e.code === SETTINGS_CHANGED && attempt < 2)
|
|
515
|
+
continue;
|
|
516
|
+
return { ...base, files, error: e.message, transient: TRANSIENT.has(e.code ?? '') };
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
/** The mod's folder put into the list of a settings.json just read, and its language. Writes nothing. */
|
|
521
|
+
function mergeInto(read, opts, platform) {
|
|
522
|
+
const { installedDir } = modPaths(opts.home);
|
|
523
|
+
const settings = read.settings ?? {};
|
|
524
|
+
const before = JSON.stringify(settings);
|
|
525
|
+
const sep = pathListSeparator(platform);
|
|
526
|
+
const env = settings.env ?? {};
|
|
527
|
+
const current = splitList(env[exports.PLUGIN_DIRS_VAR], sep);
|
|
528
|
+
const next = [];
|
|
529
|
+
const replaced = [];
|
|
530
|
+
let placed = false;
|
|
531
|
+
let alreadyListed = false;
|
|
532
|
+
for (const dir of current) {
|
|
533
|
+
if (samePath(dir, installedDir, opts.home, platform)) {
|
|
534
|
+
alreadyListed = true;
|
|
535
|
+
if (!placed) {
|
|
536
|
+
next.push(installedDir);
|
|
537
|
+
placed = true;
|
|
538
|
+
}
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
// Only the copy this mod grew out of is replaced; any other crbro-pending
|
|
542
|
+
// (a checkout someone works on, say) is pointed out below and kept.
|
|
543
|
+
if (pluginNameAt(dir, opts.home) === exports.LEGACY_NAME) {
|
|
544
|
+
replaced.push({ dir, name: exports.LEGACY_NAME });
|
|
545
|
+
if (!placed) {
|
|
546
|
+
next.push(installedDir);
|
|
547
|
+
placed = true;
|
|
548
|
+
}
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
next.push(dir);
|
|
552
|
+
}
|
|
553
|
+
if (!placed)
|
|
554
|
+
next.push(installedDir);
|
|
555
|
+
// An unchanged list keeps its own spelling: idempotent byte for byte.
|
|
556
|
+
const joined = next.join(sep);
|
|
557
|
+
const sameList = next.length === current.length && next.every((d, i) => samePath(d, current[i], opts.home, platform));
|
|
558
|
+
if (!sameList) {
|
|
559
|
+
env[exports.PLUGIN_DIRS_VAR] = joined;
|
|
560
|
+
settings.env = env;
|
|
561
|
+
}
|
|
562
|
+
if (opts.lang === 'en' || opts.lang === 'es') {
|
|
563
|
+
settings.pluginConfigs = settings.pluginConfigs ?? {};
|
|
564
|
+
const mine = settings.pluginConfigs[exports.MOD_NAME] = settings.pluginConfigs[exports.MOD_NAME] ?? {};
|
|
565
|
+
mine.options = { ...(mine.options ?? {}), language: opts.lang };
|
|
566
|
+
// Claude Code may keep the options under "<name>@inline" too: the same answer there.
|
|
567
|
+
const inline = settings.pluginConfigs[`${exports.MOD_NAME}@inline`];
|
|
568
|
+
if (inline !== undefined)
|
|
569
|
+
inline.options = { ...(inline.options ?? {}), language: opts.lang };
|
|
570
|
+
}
|
|
571
|
+
else if (opts.lang === 'auto') {
|
|
572
|
+
dropLanguage(settings);
|
|
573
|
+
}
|
|
574
|
+
return { settings, changed: JSON.stringify(settings) !== before, next, replaced, alreadyListed };
|
|
575
|
+
}
|
|
576
|
+
/** What install-mod replaced, written down before settings.json so a failure there never loses it. */
|
|
577
|
+
function recordReplaced(replacedPath, replaced, home, platform) {
|
|
578
|
+
if (replaced.length === 0)
|
|
579
|
+
return;
|
|
580
|
+
const known = readReplaced(replacedPath);
|
|
581
|
+
const all = [...known, ...replaced.filter(r => !known.some(k => samePath(k.dir, r.dir, home, platform)))];
|
|
582
|
+
(0, fs_1.writeFileSync)(replacedPath, JSON.stringify({ replaced: all }, null, 2) + '\n', 'utf8');
|
|
583
|
+
}
|
|
584
|
+
function finishInstall(opts, merged, base, files, platform) {
|
|
585
|
+
const { installedDir, settingsPath } = modPaths(opts.home);
|
|
586
|
+
const sep = pathListSeparator(platform);
|
|
587
|
+
const { changed, next, replaced, alreadyListed } = merged;
|
|
588
|
+
// Installed on purpose: any earlier opt-out is lifted, and the automatic
|
|
589
|
+
// install knows which build these files are. The automatic install itself
|
|
590
|
+
// never lifts a mark: that is the user's to do.
|
|
591
|
+
const state = readModState(opts.home);
|
|
592
|
+
const wasOptedOut = state.optedOut === true;
|
|
593
|
+
if (!opts.auto) {
|
|
594
|
+
delete state.optedOut;
|
|
595
|
+
delete state.optedOutAt;
|
|
596
|
+
delete state.optedOutBy;
|
|
597
|
+
}
|
|
598
|
+
delete state.failed;
|
|
599
|
+
state.installed = true;
|
|
600
|
+
state.version = packageVersion(opts.packageDir) ?? undefined;
|
|
601
|
+
try {
|
|
602
|
+
state.pkg = treeHash(opts.packageDir);
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
delete state.pkg;
|
|
606
|
+
}
|
|
607
|
+
try {
|
|
608
|
+
writeModState(opts.home, state);
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
// The mod is in; only the bookkeeping is missing.
|
|
612
|
+
}
|
|
613
|
+
const lines = [];
|
|
614
|
+
lines.push(` ✅ Mod ${exports.MOD_NAME} copied (${files.length} files).`);
|
|
615
|
+
if (wasOptedOut && !opts.auto)
|
|
616
|
+
lines.push(' CRBRO keeps it up to date at boot again (CRBRO_MOD=0 turns that off).');
|
|
617
|
+
lines.push(` ${installedDir}`);
|
|
618
|
+
for (const r of replaced) {
|
|
619
|
+
lines.push(` ✅ Replaced your earlier copy "${r.name}" in ${exports.PLUGIN_DIRS_VAR}: ${r.dir}`);
|
|
620
|
+
lines.push(' Its folder is left where it is; uninstall-mod puts it back in the list.');
|
|
621
|
+
}
|
|
622
|
+
if (alreadyListed && replaced.length === 0) {
|
|
623
|
+
lines.push(` ✅ Already in ${exports.PLUGIN_DIRS_VAR}. Files refreshed.`);
|
|
624
|
+
}
|
|
625
|
+
else if (!alreadyListed) {
|
|
626
|
+
lines.push(` ✅ Added to ${exports.PLUGIN_DIRS_VAR} in ${settingsPath}`);
|
|
627
|
+
}
|
|
628
|
+
// Another crbro-pending in the list, or a copy in ~/.claude/mods (which
|
|
629
|
+
// Claude Code may load on its own): not ours to delete, but two bands and
|
|
630
|
+
// two /pendientes would be confusing.
|
|
631
|
+
const elsewhere = otherCopies(next, opts.home, platform);
|
|
632
|
+
for (const dir of elsewhere) {
|
|
633
|
+
lines.push(` ⚠️ Another copy is in ${dir}, which Claude Code may load as well.`);
|
|
634
|
+
lines.push(' Remove it, or take it out of the list, if you see two bands; this command does not touch it.');
|
|
635
|
+
}
|
|
636
|
+
// The variable can also come from the shell. settings.json's env is applied
|
|
637
|
+
// on top of it, so folders listed only there may stop loading.
|
|
638
|
+
const fromEnv = splitList(opts.envDirs ?? process.env[exports.PLUGIN_DIRS_VAR], sep);
|
|
639
|
+
const envOnly = fromEnv.filter(dir => !next.some(d => samePath(d, dir, opts.home, platform)));
|
|
640
|
+
if (envOnly.length > 0) {
|
|
641
|
+
lines.push(` ⚠️ ${exports.PLUGIN_DIRS_VAR} is also set in this shell's environment, with folders settings.json does not list:`);
|
|
642
|
+
for (const dir of envOnly)
|
|
643
|
+
lines.push(` ${dir}`);
|
|
644
|
+
lines.push(' settings.json\'s env is applied on top of the environment: if those plugins stop loading, add them there.');
|
|
645
|
+
}
|
|
646
|
+
if (opts.lang === 'en' || opts.lang === 'es') {
|
|
647
|
+
lines.push(` Language: ${opts.lang === 'es' ? 'Spanish' : 'English'} (pluginConfigs.${exports.MOD_NAME}.options.language; /config changes it).`);
|
|
648
|
+
}
|
|
649
|
+
else if (opts.lang === 'auto') {
|
|
650
|
+
lines.push(' Language: auto (CRBRO_LANG, then LC_ALL / LC_MESSAGES / LANG, then the system locale).');
|
|
651
|
+
}
|
|
652
|
+
lines.push(...REQUIREMENT_LINES);
|
|
653
|
+
lines.push(' Undo with: npx crbro-memory uninstall-mod');
|
|
654
|
+
return { ...base, ok: true, files, changed, alreadyListed, replaced, elsewhere, envOnly, lang: opts.lang, lines };
|
|
655
|
+
}
|
|
656
|
+
/** Removes the language this installer set, under either key; true when something went. */
|
|
657
|
+
function dropLanguage(settings) {
|
|
658
|
+
let dropped = false;
|
|
659
|
+
for (const key of CONFIG_KEYS) {
|
|
660
|
+
const mine = settings.pluginConfigs?.[key];
|
|
661
|
+
if (!isObject(mine))
|
|
662
|
+
continue;
|
|
663
|
+
if (isObject(mine.options) && 'language' in mine.options) {
|
|
664
|
+
delete mine.options.language;
|
|
665
|
+
dropped = true;
|
|
666
|
+
if (Object.keys(mine.options).length === 0)
|
|
667
|
+
delete mine.options;
|
|
668
|
+
}
|
|
669
|
+
if (Object.keys(mine).length === 0)
|
|
670
|
+
delete settings.pluginConfigs[key];
|
|
671
|
+
}
|
|
672
|
+
if (isObject(settings.pluginConfigs) && Object.keys(settings.pluginConfigs).length === 0)
|
|
673
|
+
delete settings.pluginConfigs;
|
|
674
|
+
return dropped;
|
|
675
|
+
}
|
|
676
|
+
function uninstallMod(opts) {
|
|
677
|
+
const platform = opts.platform ?? process.platform;
|
|
678
|
+
const { settingsPath, installedDir, modsDir, replacedPath } = modPaths(opts.home);
|
|
679
|
+
const base = {
|
|
680
|
+
ok: false, installedDir, settingsPath, changed: false, removedDir: false, restored: [], markFailed: false, lines: [],
|
|
681
|
+
};
|
|
682
|
+
// The opt-out mark goes first, whatever happens next: crbro_boot never
|
|
683
|
+
// installs the mod again on its own until install-mod is run.
|
|
684
|
+
const { claudeDir, noticePath } = modPaths(opts.home);
|
|
685
|
+
let marked = false;
|
|
686
|
+
const markWanted = (0, fs_1.existsSync)(claudeDir);
|
|
687
|
+
if (markWanted) {
|
|
688
|
+
try {
|
|
689
|
+
writeModState(opts.home, { optedOut: true, optedOutAt: new Date().toISOString(), optedOutBy: 'uninstall-mod' });
|
|
690
|
+
(0, fs_1.rmSync)(noticePath, { force: true });
|
|
691
|
+
marked = true;
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
marked = false;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
const read = readSettings(settingsPath);
|
|
698
|
+
if (!read.ok)
|
|
699
|
+
return { ...base, error: read.error };
|
|
700
|
+
let changed = false;
|
|
701
|
+
let unlisted = false;
|
|
702
|
+
const restored = [];
|
|
703
|
+
const gone = [];
|
|
704
|
+
const settings = read.settings;
|
|
705
|
+
if (settings !== null) {
|
|
706
|
+
const before = JSON.stringify(settings);
|
|
707
|
+
const sep = pathListSeparator(platform);
|
|
708
|
+
const current = splitList(settings.env?.[exports.PLUGIN_DIRS_VAR], sep);
|
|
709
|
+
const kept = [];
|
|
710
|
+
let at = -1;
|
|
711
|
+
for (const dir of current) {
|
|
712
|
+
if (samePath(dir, installedDir, opts.home, platform)) {
|
|
713
|
+
if (at < 0)
|
|
714
|
+
at = kept.length;
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
kept.push(dir);
|
|
718
|
+
}
|
|
719
|
+
unlisted = at >= 0;
|
|
720
|
+
// What install-mod replaced goes back where this mod stood, if it is
|
|
721
|
+
// still on disk and still that plugin.
|
|
722
|
+
for (const r of readReplaced(replacedPath)) {
|
|
723
|
+
if (kept.some(d => samePath(d, r.dir, opts.home, platform)) || restored.includes(r.dir))
|
|
724
|
+
continue;
|
|
725
|
+
if (pluginNameAt(r.dir, opts.home) === r.name)
|
|
726
|
+
restored.push(r.dir);
|
|
727
|
+
else
|
|
728
|
+
gone.push(r.dir);
|
|
729
|
+
}
|
|
730
|
+
kept.splice(at < 0 ? kept.length : at, 0, ...restored);
|
|
731
|
+
const sameList = kept.length === current.length && kept.every((d, i) => samePath(d, current[i], opts.home, platform));
|
|
732
|
+
if (!sameList) {
|
|
733
|
+
const env = settings.env ?? {};
|
|
734
|
+
if (kept.length)
|
|
735
|
+
env[exports.PLUGIN_DIRS_VAR] = kept.join(sep);
|
|
736
|
+
else
|
|
737
|
+
delete env[exports.PLUGIN_DIRS_VAR];
|
|
738
|
+
// An env block this leaves empty goes too, as it was before install-mod.
|
|
739
|
+
if (Object.keys(env).length === 0)
|
|
740
|
+
delete settings.env;
|
|
741
|
+
else
|
|
742
|
+
settings.env = env;
|
|
743
|
+
}
|
|
744
|
+
for (const key of CONFIG_KEYS) {
|
|
745
|
+
if (settings.pluginConfigs?.[key] !== undefined)
|
|
746
|
+
delete settings.pluginConfigs[key];
|
|
747
|
+
}
|
|
748
|
+
if (isObject(settings.pluginConfigs) && Object.keys(settings.pluginConfigs).length === 0)
|
|
749
|
+
delete settings.pluginConfigs;
|
|
750
|
+
changed = JSON.stringify(settings) !== before;
|
|
751
|
+
if (changed)
|
|
752
|
+
writeAtomic(settingsPath, settings, read.indent);
|
|
753
|
+
}
|
|
754
|
+
const removedDir = (0, fs_1.existsSync)(installedDir);
|
|
755
|
+
(0, fs_1.rmSync)(installedDir, { recursive: true, force: true, maxRetries: 3 });
|
|
756
|
+
(0, fs_1.rmSync)(replacedPath, { force: true });
|
|
757
|
+
try {
|
|
758
|
+
if ((0, fs_1.readdirSync)(modsDir).length === 0)
|
|
759
|
+
(0, fs_1.rmdirSync)(modsDir);
|
|
760
|
+
}
|
|
761
|
+
catch {
|
|
762
|
+
// Not there, or not empty: left as it is.
|
|
763
|
+
}
|
|
764
|
+
const lines = [];
|
|
765
|
+
if (unlisted)
|
|
766
|
+
lines.push(` ✅ Removed from ${exports.PLUGIN_DIRS_VAR} in ${settingsPath}`);
|
|
767
|
+
else if (changed && restored.length === 0)
|
|
768
|
+
lines.push(` ✅ Its language setting was removed from ${settingsPath}`);
|
|
769
|
+
for (const dir of restored)
|
|
770
|
+
lines.push(` ✅ Put your earlier copy back in ${exports.PLUGIN_DIRS_VAR}: ${dir}`);
|
|
771
|
+
for (const dir of gone)
|
|
772
|
+
lines.push(` ⚪ Your earlier copy ${dir} is no longer there, so it was not put back.`);
|
|
773
|
+
if (removedDir)
|
|
774
|
+
lines.push(` ✅ Deleted ${installedDir}`);
|
|
775
|
+
if (!changed && !removedDir)
|
|
776
|
+
lines.push(' ⚪ The mod was not installed. Nothing changed.');
|
|
777
|
+
else
|
|
778
|
+
lines.push(' Open a new session: the band and /pending are gone from it.');
|
|
779
|
+
if (marked) {
|
|
780
|
+
lines.push(' CRBRO will not install it again on its own (marked in ~/.claude/crbro-mods/state.json);');
|
|
781
|
+
lines.push(' npx crbro-memory install-mod brings it back.');
|
|
782
|
+
}
|
|
783
|
+
else if (markWanted) {
|
|
784
|
+
lines.push(' ⚠️ Could not write the opt-out mark (~/.claude/crbro-mods/state.json): CRBRO may install the mod');
|
|
785
|
+
lines.push(' again at its next start. Fix that folder and run this again, or set CRBRO_MOD=0 in every MCP client.');
|
|
786
|
+
}
|
|
787
|
+
return { ...base, ok: true, changed, removedDir, restored, markFailed: markWanted && !marked, lines };
|
|
788
|
+
}
|
|
789
|
+
/** Read-only: hashes files and parses settings.json, writes nothing. */
|
|
790
|
+
function verifyMod(opts) {
|
|
791
|
+
const platform = opts.platform ?? process.platform;
|
|
792
|
+
const sep = pathListSeparator(platform);
|
|
793
|
+
const { settingsPath, installedDir } = modPaths(opts.home);
|
|
794
|
+
const installed = (0, fs_1.existsSync)(installedDir);
|
|
795
|
+
let settingsUnreadable = false;
|
|
796
|
+
let fromSettings;
|
|
797
|
+
if ((0, fs_1.existsSync)(settingsPath)) {
|
|
798
|
+
try {
|
|
799
|
+
const raw = (0, fs_1.readFileSync)(settingsPath, 'utf8');
|
|
800
|
+
const settings = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
|
|
801
|
+
const value = settings?.env?.[exports.PLUGIN_DIRS_VAR];
|
|
802
|
+
if (typeof value === 'string')
|
|
803
|
+
fromSettings = value;
|
|
804
|
+
}
|
|
805
|
+
catch {
|
|
806
|
+
settingsUnreadable = true;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
// settings.json's env wins over the environment; without it, the environment counts.
|
|
810
|
+
const list = splitList(fromSettings ?? opts.envDirs ?? process.env[exports.PLUGIN_DIRS_VAR], sep);
|
|
811
|
+
const listed = list.some(d => samePath(d, installedDir, opts.home, platform));
|
|
812
|
+
const files = [];
|
|
813
|
+
if (installed) {
|
|
814
|
+
const shipped = modFiles(opts.packageDir);
|
|
815
|
+
// Everything in the installed folder: install-mod never writes a test,
|
|
816
|
+
// tsconfig or node_modules there, so any of them is somebody else's.
|
|
817
|
+
const present = allFiles(installedDir);
|
|
818
|
+
for (const name of shipped) {
|
|
819
|
+
const pkg = (0, fs_1.readFileSync)(path_1.default.join(opts.packageDir, ...name.split('/')));
|
|
820
|
+
const check = { name, status: 'missing', package_sha256: sha256(pkg) };
|
|
821
|
+
if (present.includes(name)) {
|
|
822
|
+
const mine = (0, fs_1.readFileSync)(path_1.default.join(installedDir, ...name.split('/')));
|
|
823
|
+
check.installed_sha256 = sha256(mine);
|
|
824
|
+
if (check.installed_sha256 === check.package_sha256)
|
|
825
|
+
check.status = 'same';
|
|
826
|
+
else if (lfSha(mine) === lfSha(pkg))
|
|
827
|
+
check.status = 'line_endings_only';
|
|
828
|
+
else
|
|
829
|
+
check.status = 'different';
|
|
830
|
+
}
|
|
831
|
+
files.push(check);
|
|
832
|
+
}
|
|
833
|
+
for (const name of present) {
|
|
834
|
+
if (shipped.includes(name))
|
|
835
|
+
continue;
|
|
836
|
+
files.push({ name, status: 'unknown', installed_sha256: sha256((0, fs_1.readFileSync)(path_1.default.join(installedDir, ...name.split('/')))) });
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
// Two copies mean two bands and two /pendientes: only a problem once this one is in.
|
|
840
|
+
const duplicates = installed || listed ? otherCopies(list, opts.home, platform) : [];
|
|
841
|
+
const filesOk = files.every(f => f.status === 'same' || f.status === 'line_endings_only');
|
|
842
|
+
const ok = !settingsUnreadable && installed === listed && filesOk && duplicates.length === 0;
|
|
843
|
+
return {
|
|
844
|
+
package_dir: opts.packageDir,
|
|
845
|
+
installed_dir: installedDir,
|
|
846
|
+
settings_path: settingsPath,
|
|
847
|
+
installed,
|
|
848
|
+
listed,
|
|
849
|
+
files,
|
|
850
|
+
duplicates,
|
|
851
|
+
settings_unreadable: settingsUnreadable,
|
|
852
|
+
ok,
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
const STATUS_TEXT = {
|
|
856
|
+
same: '✅ same as the package',
|
|
857
|
+
line_endings_only: '✅ same except line endings (CRLF/LF)',
|
|
858
|
+
different: '⚠️ DIFFERENT from the package',
|
|
859
|
+
missing: '⚠️ missing from the installed copy',
|
|
860
|
+
unknown: '⚠️ not a file of this package',
|
|
861
|
+
};
|
|
862
|
+
function formatModVerify(r) {
|
|
863
|
+
const L = [''];
|
|
864
|
+
L.push(` Mod verification: ${exports.MOD_NAME} (read-only: nothing is changed)`);
|
|
865
|
+
L.push(` Package: ${r.package_dir}`);
|
|
866
|
+
L.push(` Installed: ${r.installed_dir}`);
|
|
867
|
+
L.push('');
|
|
868
|
+
if (!r.installed && !r.listed) {
|
|
869
|
+
L.push(' ⚪ Not installed. Install it with: npx crbro-memory install-mod');
|
|
870
|
+
}
|
|
871
|
+
for (const f of r.files) {
|
|
872
|
+
L.push(` ${f.name.padEnd(28)} ${STATUS_TEXT[f.status]}`);
|
|
873
|
+
if (f.status === 'different' || f.status === 'unknown') {
|
|
874
|
+
if (f.package_sha256)
|
|
875
|
+
L.push(` package sha256 ${f.package_sha256}`);
|
|
876
|
+
if (f.installed_sha256)
|
|
877
|
+
L.push(` installed sha256 ${f.installed_sha256}`);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
if (r.settings_unreadable) {
|
|
881
|
+
L.push('');
|
|
882
|
+
L.push(` ⚠️ ${r.settings_path} cannot be read as JSON: whether Claude Code loads the mod could not be checked.`);
|
|
883
|
+
}
|
|
884
|
+
else if (r.installed && !r.listed) {
|
|
885
|
+
L.push('');
|
|
886
|
+
L.push(` ⚠️ The folder exists but ${exports.PLUGIN_DIRS_VAR} does not list it: Claude Code does not load it.`);
|
|
887
|
+
}
|
|
888
|
+
else if (r.listed && !r.installed) {
|
|
889
|
+
L.push('');
|
|
890
|
+
L.push(` ⚠️ ${exports.PLUGIN_DIRS_VAR} lists ${r.installed_dir}, which does not exist.`);
|
|
891
|
+
}
|
|
892
|
+
if (r.duplicates.length > 0) {
|
|
893
|
+
L.push('');
|
|
894
|
+
for (const dir of r.duplicates)
|
|
895
|
+
L.push(` ⚠️ Another copy is in ${dir}: Claude Code may load it too, with a second band.`);
|
|
896
|
+
L.push(' Remove it, or take it out of the list; install-mod and uninstall-mod never touch it.');
|
|
897
|
+
}
|
|
898
|
+
if (r.installed || r.listed) {
|
|
899
|
+
L.push('');
|
|
900
|
+
if (r.ok) {
|
|
901
|
+
L.push(' The installed mod matches this package.');
|
|
902
|
+
}
|
|
903
|
+
else if (r.duplicates.length > 0 && r.files.every(f => f.status === 'same' || f.status === 'line_endings_only')
|
|
904
|
+
&& r.installed === r.listed && !r.settings_unreadable) {
|
|
905
|
+
L.push(' The installed mod matches this package, but it is not the only copy.');
|
|
906
|
+
}
|
|
907
|
+
else {
|
|
908
|
+
L.push(' There are differences. If you just updated CRBRO that is expected: run');
|
|
909
|
+
L.push(' npx crbro-memory install-mod again and the copy is refreshed. If you did not');
|
|
910
|
+
L.push(' update, find out who changed those files before using the mod again.');
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
L.push('');
|
|
914
|
+
return L.join('\n');
|
|
915
|
+
}
|
|
916
|
+
// ─── Automatic install at boot ───────────────────────────────────
|
|
917
|
+
//
|
|
918
|
+
// crbro_boot runs this once per process (once per daemon too). It installs
|
|
919
|
+
// the mod for anyone who has Claude Code (~/.claude exists) and refreshes its
|
|
920
|
+
// files when the package ships a newer build, so an update of CRBRO reaches
|
|
921
|
+
// the band with no command. What it changes, it says through a notice the
|
|
922
|
+
// next boots hand to the agent (mod_notice). It never:
|
|
923
|
+
// - runs in a client whose env has CRBRO_MOD=0 (or off / false / no); a
|
|
924
|
+
// daemon started without it does not serve that client, because the
|
|
925
|
+
// variable is part of configFingerprint;
|
|
926
|
+
// - comes back after uninstall-mod (state.json optedOut), from any client,
|
|
927
|
+
// or after the folder was taken out of CLAUDE_CODE_PLUGIN_DIRS by hand
|
|
928
|
+
// (said once, with the way back);
|
|
929
|
+
// - touches a settings.json it cannot parse, adds a second band beside
|
|
930
|
+
// another crbro-pending already in the list, or writes a list into
|
|
931
|
+
// settings.json while only the environment holds one: settings.json's env
|
|
932
|
+
// wins over it, and the folders listed there would stop loading (said
|
|
933
|
+
// once instead);
|
|
934
|
+
// - throws or stalls the boot: files are copied through fs.promises, every
|
|
935
|
+
// failure is caught, and one that is not transient is written down and
|
|
936
|
+
// not retried until the package or settings.json changes, or a day has
|
|
937
|
+
// gone by.
|
|
938
|
+
// Two sessions starting at once take turns through an exclusive lock file
|
|
939
|
+
// (auto.lock, released only by its owner, taken over after a minute if that
|
|
940
|
+
// owner died); settings.json is written only if it is still what was read,
|
|
941
|
+
// and the write is checked afterwards.
|
|
942
|
+
exports.MOD_SWITCH_VAR = 'CRBRO_MOD';
|
|
943
|
+
/** The values of CRBRO_MOD that turn the automatic install off; src/daemon/endpoint.ts mirrors them. */
|
|
944
|
+
exports.MOD_OFF_VALUES = new Set(['0', 'off', 'false', 'no']);
|
|
945
|
+
const DEFAULT_STALE_LOCK_MS = 60_000;
|
|
946
|
+
const DEFAULT_RETRY_FAILED_MS = 24 * 60 * 60 * 1000;
|
|
947
|
+
/** How long crbro_boot waits for the automatic install before answering without its notice. */
|
|
948
|
+
exports.BOOT_BUDGET_MS = 1_500;
|
|
949
|
+
/** A notice is handed to this many server processes, in case one is a background run nobody reads... */
|
|
950
|
+
exports.NOTICE_SESSIONS = 3;
|
|
951
|
+
/** ...and dropped after this long, whoever saw it. */
|
|
952
|
+
const NOTICE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
953
|
+
function modSwitchedOff(env = process.env) {
|
|
954
|
+
return exports.MOD_OFF_VALUES.has(String(env[exports.MOD_SWITCH_VAR] ?? '').trim().toLowerCase());
|
|
955
|
+
}
|
|
956
|
+
const HOW_TO_REMOVE = 'To remove it: npx crbro-memory uninstall-mod (CRBRO will not put it back, whichever app runs it), ' +
|
|
957
|
+
'or set CRBRO_MOD=0 in the env of every MCP client that runs CRBRO to stop automatic installs.';
|
|
958
|
+
const TELL_ONCE = 'Tell the user this once, briefly, in their language. It is handed to up to ' +
|
|
959
|
+
`${exports.NOTICE_SESSIONS} sessions in case one is a background run nobody reads; if the user plainly knows already, skip it.`;
|
|
960
|
+
function installedNotice(version, r) {
|
|
961
|
+
const parts = [
|
|
962
|
+
`CRBRO${version ? ` ${version}` : ''} installed its Claude Code mod "${exports.MOD_NAME}": the user's open items in a band above the prompt, and /pending (alias /pendientes) to see them all.`,
|
|
963
|
+
'It shows up in NEW Claude Code sessions (CLI and the desktop app\'s Code tab, 2.1.286 or later), not in this one.',
|
|
964
|
+
`Files: ${r.installedDir}, listed in env.${exports.PLUGIN_DIRS_VAR} of ${r.settingsPath}.`,
|
|
965
|
+
];
|
|
966
|
+
for (const rep of r.replaced) {
|
|
967
|
+
parts.push(`It took the place of the earlier copy "${rep.name}" (${rep.dir}) in that list; that folder was left on disk and uninstall-mod puts it back.`);
|
|
968
|
+
}
|
|
969
|
+
// A replaced copy that sits in ~/.claude/mods is named once, above.
|
|
970
|
+
const elsewhere = r.elsewhere.filter(dir => !r.replaced.some(rep => path_1.default.resolve(rep.dir).toLowerCase() === path_1.default.resolve(dir).toLowerCase()));
|
|
971
|
+
for (const dir of elsewhere)
|
|
972
|
+
parts.push(`Another copy is in ${dir}: if two bands appear, remove that one.`);
|
|
973
|
+
parts.push(HOW_TO_REMOVE, TELL_ONCE);
|
|
974
|
+
return parts.join(' ');
|
|
975
|
+
}
|
|
976
|
+
function updatedNotice(version, installedDir, files) {
|
|
977
|
+
return [
|
|
978
|
+
`CRBRO${version ? ` ${version}` : ''} updated its Claude Code mod "${exports.MOD_NAME}" (the open-items band and /pending): ${files} files refreshed in ${installedDir}.`,
|
|
979
|
+
'New Claude Code sessions load the new version; settings.json was not touched.',
|
|
980
|
+
HOW_TO_REMOVE,
|
|
981
|
+
TELL_ONCE,
|
|
982
|
+
].join(' ');
|
|
983
|
+
}
|
|
984
|
+
function unlistedNotice(installedDir, settingsPath) {
|
|
985
|
+
return [
|
|
986
|
+
`CRBRO's Claude Code mod "${exports.MOD_NAME}" (the open-items band and /pending) is no longer in env.${exports.PLUGIN_DIRS_VAR} of ${settingsPath}, where CRBRO had put it.`,
|
|
987
|
+
'CRBRO takes that as a no and will not put it back on its own (marked in ~/.claude/crbro-mods/state.json).',
|
|
988
|
+
`If the user did not take it out (a settings.json restored or rewritten by another tool, say), npx crbro-memory install-mod brings it back; its files are in ${installedDir}.`,
|
|
989
|
+
TELL_ONCE,
|
|
990
|
+
].join(' ');
|
|
991
|
+
}
|
|
992
|
+
function envListNotice(list, settingsPath) {
|
|
993
|
+
return [
|
|
994
|
+
`CRBRO did not install its Claude Code mod "${exports.MOD_NAME}" (the open-items band and /pending): ${exports.PLUGIN_DIRS_VAR} is set in the environment Claude Code starts from (${list.join(', ')}) and not in ${settingsPath},`,
|
|
995
|
+
'whose env block wins over the environment: writing the variable there would stop those plugins from loading.',
|
|
996
|
+
`To get the band, move that variable into the env block of ${settingsPath}; the next CRBRO start adds the mod beside those folders.`,
|
|
997
|
+
'npx crbro-memory uninstall-mod (or CRBRO_MOD=0) stops CRBRO from looking at this again.',
|
|
998
|
+
TELL_ONCE,
|
|
999
|
+
].join(' ');
|
|
1000
|
+
}
|
|
1001
|
+
/** The lock's token when taken, null when another live process holds it. */
|
|
1002
|
+
async function takeLock(lockPath, now, staleMs) {
|
|
1003
|
+
await fs_1.promises.mkdir(path_1.default.dirname(lockPath), { recursive: true });
|
|
1004
|
+
const token = `${process.pid}.${(0, crypto_1.randomBytes)(8).toString('hex')}`;
|
|
1005
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1006
|
+
try {
|
|
1007
|
+
const fh = await fs_1.promises.open(lockPath, 'wx');
|
|
1008
|
+
try {
|
|
1009
|
+
await fh.writeFile(JSON.stringify({ token, pid: process.pid, at: new Date(now()).toISOString() }));
|
|
1010
|
+
}
|
|
1011
|
+
finally {
|
|
1012
|
+
await fh.close();
|
|
1013
|
+
}
|
|
1014
|
+
return token;
|
|
1015
|
+
}
|
|
1016
|
+
catch (e) {
|
|
1017
|
+
if (e.code !== 'EEXIST')
|
|
1018
|
+
throw e;
|
|
1019
|
+
let age = 0;
|
|
1020
|
+
try {
|
|
1021
|
+
age = now() - (await fs_1.promises.stat(lockPath)).mtimeMs;
|
|
1022
|
+
}
|
|
1023
|
+
catch {
|
|
1024
|
+
continue; // Released between the two calls: try again.
|
|
1025
|
+
}
|
|
1026
|
+
if (age <= staleMs)
|
|
1027
|
+
return null;
|
|
1028
|
+
// Its owner died half way. Renamed aside, not deleted: of two processes
|
|
1029
|
+
// that both found it stale, only one rename succeeds.
|
|
1030
|
+
const aside = `${lockPath}.${token}.stale`;
|
|
1031
|
+
try {
|
|
1032
|
+
await fs_1.promises.rename(lockPath, aside);
|
|
1033
|
+
}
|
|
1034
|
+
catch {
|
|
1035
|
+
return null;
|
|
1036
|
+
}
|
|
1037
|
+
try {
|
|
1038
|
+
// What was renamed is a lock somebody took a moment ago: give it back.
|
|
1039
|
+
if (now() - (await fs_1.promises.stat(aside)).mtimeMs <= staleMs) {
|
|
1040
|
+
if (!(0, fs_1.existsSync)(lockPath))
|
|
1041
|
+
await fs_1.promises.rename(aside, lockPath);
|
|
1042
|
+
return null;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
catch {
|
|
1046
|
+
return null;
|
|
1047
|
+
}
|
|
1048
|
+
await fs_1.promises.rm(aside, { force: true }).catch(() => undefined);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
return null;
|
|
1052
|
+
}
|
|
1053
|
+
/** Removes the lock only if it is still the one this token took. */
|
|
1054
|
+
async function releaseLock(lockPath, token) {
|
|
1055
|
+
try {
|
|
1056
|
+
const held = JSON.parse(await fs_1.promises.readFile(lockPath, 'utf8'))?.token;
|
|
1057
|
+
if (held === token)
|
|
1058
|
+
await fs_1.promises.rm(lockPath, { force: true });
|
|
1059
|
+
}
|
|
1060
|
+
catch {
|
|
1061
|
+
// Gone, or not ours to remove.
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
/** The list settings.json sets, or null when it does not set the variable. */
|
|
1065
|
+
function settingsList(settingsPath, platform) {
|
|
1066
|
+
const read = readSettings(settingsPath);
|
|
1067
|
+
if (!read.ok)
|
|
1068
|
+
return null;
|
|
1069
|
+
const value = read.settings?.env?.[exports.PLUGIN_DIRS_VAR];
|
|
1070
|
+
return typeof value === 'string' ? splitList(value, pathListSeparator(platform)) : null;
|
|
1071
|
+
}
|
|
1072
|
+
/** Folders in the list holding another crbro-pending (a checkout): installing beside it would draw two bands. */
|
|
1073
|
+
function listedOtherPending(settingsPath, home, platform, envDirs) {
|
|
1074
|
+
const list = settingsList(settingsPath, platform)
|
|
1075
|
+
?? splitList(envDirs ?? process.env[exports.PLUGIN_DIRS_VAR], pathListSeparator(platform));
|
|
1076
|
+
const { installedDir } = modPaths(home);
|
|
1077
|
+
return list.filter(dir => !samePath(dir, installedDir, home, platform) && pluginNameAt(dir, home) === exports.MOD_NAME);
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Folders only the environment lists: settings.json does not set the
|
|
1081
|
+
* variable, the environment does. Writing a list into settings.json would
|
|
1082
|
+
* hide them from Claude Code.
|
|
1083
|
+
*/
|
|
1084
|
+
function environmentOnlyList(settingsPath, home, platform, envDirs) {
|
|
1085
|
+
if (settingsList(settingsPath, platform) !== null)
|
|
1086
|
+
return [];
|
|
1087
|
+
const { installedDir } = modPaths(home);
|
|
1088
|
+
return splitList(envDirs ?? process.env[exports.PLUGIN_DIRS_VAR], pathListSeparator(platform))
|
|
1089
|
+
.filter(dir => !samePath(dir, installedDir, home, platform));
|
|
1090
|
+
}
|
|
1091
|
+
/** Leaves a notice for the next crbro_boot calls, in this process or another; a newer one replaces it. */
|
|
1092
|
+
function leaveNotice(home, text) {
|
|
1093
|
+
const { noticePath } = modPaths(home);
|
|
1094
|
+
const tmp = `${noticePath}.${process.pid}.tmp`;
|
|
1095
|
+
const notice = { notice: text, id: (0, crypto_1.randomBytes)(8).toString('hex'), at: new Date().toISOString(), delivered: 0 };
|
|
1096
|
+
(0, fs_1.writeFileSync)(tmp, JSON.stringify(notice) + '\n', 'utf8');
|
|
1097
|
+
(0, fs_1.renameSync)(tmp, noticePath);
|
|
1098
|
+
}
|
|
1099
|
+
/** Notice ids this process has handed over: a later boot of the same conversation, or a subagent's, does not hear it again. */
|
|
1100
|
+
const handedOver = new Set();
|
|
1101
|
+
/**
|
|
1102
|
+
* Takes the pending notice, if any, for this boot. The rename is atomic, so
|
|
1103
|
+
* of two boots reaching for it at once exactly one gets it. It goes back for
|
|
1104
|
+
* a later process until NOTICE_SESSIONS processes have had it or it is a
|
|
1105
|
+
* week old: the first boot to take it may be a background run, a scheduled
|
|
1106
|
+
* `claude -p` or another app, with nobody reading.
|
|
1107
|
+
*/
|
|
1108
|
+
function takeModNotice(home, now = Date.now) {
|
|
1109
|
+
const { noticePath } = modPaths(home);
|
|
1110
|
+
try {
|
|
1111
|
+
const peek = JSON.parse((0, fs_1.readFileSync)(noticePath, 'utf8'));
|
|
1112
|
+
if (typeof peek?.id === 'string' && handedOver.has(peek.id))
|
|
1113
|
+
return null;
|
|
1114
|
+
}
|
|
1115
|
+
catch {
|
|
1116
|
+
return null;
|
|
1117
|
+
}
|
|
1118
|
+
const taken = `${noticePath}.${process.pid}.${(0, crypto_1.randomBytes)(4).toString('hex')}.taken`;
|
|
1119
|
+
try {
|
|
1120
|
+
(0, fs_1.renameSync)(noticePath, taken);
|
|
1121
|
+
}
|
|
1122
|
+
catch {
|
|
1123
|
+
return null;
|
|
1124
|
+
}
|
|
1125
|
+
try {
|
|
1126
|
+
const n = JSON.parse((0, fs_1.readFileSync)(taken, 'utf8'));
|
|
1127
|
+
const text = n?.notice;
|
|
1128
|
+
if (typeof text !== 'string' || !text)
|
|
1129
|
+
return null;
|
|
1130
|
+
const id = typeof n.id === 'string' ? n.id : null;
|
|
1131
|
+
if (id)
|
|
1132
|
+
handedOver.add(id);
|
|
1133
|
+
const delivered = (Number(n.delivered) || 0) + 1;
|
|
1134
|
+
const age = now() - Date.parse(String(n.at));
|
|
1135
|
+
if (id && delivered < exports.NOTICE_SESSIONS && age < NOTICE_MAX_AGE_MS && !(0, fs_1.existsSync)(noticePath)) {
|
|
1136
|
+
(0, fs_1.writeFileSync)(taken, JSON.stringify({ ...n, delivered }) + '\n', 'utf8');
|
|
1137
|
+
(0, fs_1.renameSync)(taken, noticePath);
|
|
1138
|
+
}
|
|
1139
|
+
return text;
|
|
1140
|
+
}
|
|
1141
|
+
catch {
|
|
1142
|
+
return null;
|
|
1143
|
+
}
|
|
1144
|
+
finally {
|
|
1145
|
+
(0, fs_1.rmSync)(taken, { force: true });
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
async function autoInstallMod(opts) {
|
|
1149
|
+
const platform = opts.platform ?? process.platform;
|
|
1150
|
+
const now = opts.now ?? Date.now;
|
|
1151
|
+
const { claudeDir, settingsPath, installedDir, lockPath } = modPaths(opts.home);
|
|
1152
|
+
if (modSwitchedOff(opts.env ?? process.env))
|
|
1153
|
+
return { action: 'skipped', reason: 'disabled' };
|
|
1154
|
+
// No ~/.claude, no Claude Code: nothing is created for it.
|
|
1155
|
+
if (!(0, fs_1.existsSync)(claudeDir))
|
|
1156
|
+
return { action: 'skipped', reason: 'no-claude-code' };
|
|
1157
|
+
if (!(0, fs_1.existsSync)(path_1.default.join(opts.packageDir, '.claude-plugin', 'plugin.json'))) {
|
|
1158
|
+
return { action: 'skipped', reason: 'not-in-package' };
|
|
1159
|
+
}
|
|
1160
|
+
if (readModState(opts.home).optedOut)
|
|
1161
|
+
return { action: 'skipped', reason: 'opted-out' };
|
|
1162
|
+
const pkg = treeHash(opts.packageDir);
|
|
1163
|
+
const version = packageVersion(opts.packageDir);
|
|
1164
|
+
const before = readModState(opts.home).failed;
|
|
1165
|
+
if (before && before.pkg === pkg && before.settings === settingsFingerprint(settingsPath)
|
|
1166
|
+
&& now() - Date.parse(before.at) < (opts.retryFailedMs ?? DEFAULT_RETRY_FAILED_MS)) {
|
|
1167
|
+
return { action: 'skipped', reason: 'failed-before' };
|
|
1168
|
+
}
|
|
1169
|
+
const token = await takeLock(lockPath, now, opts.staleLockMs ?? DEFAULT_STALE_LOCK_MS);
|
|
1170
|
+
if (!token) {
|
|
1171
|
+
// Another session is doing it right now; its notice reaches whoever boots next.
|
|
1172
|
+
return { action: 'skipped', reason: 'busy' };
|
|
1173
|
+
}
|
|
1174
|
+
// A failure is written down so the same cause is not retried at every
|
|
1175
|
+
// start; one that may well pass next time (a file held open, settings.json
|
|
1176
|
+
// being saved by someone else) is not.
|
|
1177
|
+
const fail = (error, transient = false) => {
|
|
1178
|
+
if (!transient) {
|
|
1179
|
+
try {
|
|
1180
|
+
const state = readModState(opts.home);
|
|
1181
|
+
state.failed = { pkg, settings: settingsFingerprint(settingsPath), at: new Date(now()).toISOString(), error };
|
|
1182
|
+
writeModState(opts.home, state);
|
|
1183
|
+
}
|
|
1184
|
+
catch {
|
|
1185
|
+
// Nowhere to write it down: the next start tries again, still without touching anything.
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
return { action: 'failed', reason: error };
|
|
1189
|
+
};
|
|
1190
|
+
const isTransient = (e) => TRANSIENT.has(e?.code ?? '');
|
|
1191
|
+
try {
|
|
1192
|
+
// Read again under the lock: the session before may have just changed it.
|
|
1193
|
+
const state = readModState(opts.home);
|
|
1194
|
+
if (state.optedOut)
|
|
1195
|
+
return { action: 'skipped', reason: 'opted-out' };
|
|
1196
|
+
const v = verifyMod({ ...opts, platform });
|
|
1197
|
+
if (v.settings_unreadable)
|
|
1198
|
+
return fail(`${settingsPath} could not be parsed — not touching it.`);
|
|
1199
|
+
if (v.installed && v.listed) {
|
|
1200
|
+
if (v.files.every(f => f.status === 'same' || f.status === 'line_endings_only')) {
|
|
1201
|
+
// Installed by hand before this existed, or by another build with the
|
|
1202
|
+
// same files: remember it, quietly. A copy already known writes nothing.
|
|
1203
|
+
const newest = version && (!state.version || compareVersions(version, state.version) > 0) ? version : state.version;
|
|
1204
|
+
const known = { ...state, installed: true, version: newest ?? undefined, pkg, failed: undefined };
|
|
1205
|
+
if (JSON.stringify(stateClean(known)) !== JSON.stringify(stateClean(state)))
|
|
1206
|
+
writeModState(opts.home, known);
|
|
1207
|
+
return { action: 'current' };
|
|
1208
|
+
}
|
|
1209
|
+
// A newer CRBRO elsewhere on this machine (another client, a global
|
|
1210
|
+
// install) put its files there: an older one does not take them back.
|
|
1211
|
+
const order = version && state.version ? compareVersions(version, state.version) : 0;
|
|
1212
|
+
if (order < 0)
|
|
1213
|
+
return { action: 'skipped', reason: 'newer-installed' };
|
|
1214
|
+
// Another build of the same version (a checkout beside the npm copy)
|
|
1215
|
+
// wrote them and they are still as it left them: it keeps them, or the
|
|
1216
|
+
// two would rewrite each other at every start.
|
|
1217
|
+
if (order === 0 && state.pkg && state.pkg !== pkg && treeHash(installedDir) === state.pkg) {
|
|
1218
|
+
return { action: 'skipped', reason: 'other-build' };
|
|
1219
|
+
}
|
|
1220
|
+
// Only the files: the list already names the folder.
|
|
1221
|
+
const files = await copyModAsync(opts.packageDir, installedDir);
|
|
1222
|
+
writeModState(opts.home, { ...state, installed: true, version: version ?? undefined, pkg, failed: undefined });
|
|
1223
|
+
const notice = updatedNotice(version, installedDir, files.length);
|
|
1224
|
+
leaveNotice(opts.home, notice);
|
|
1225
|
+
return { action: 'updated', notice };
|
|
1226
|
+
}
|
|
1227
|
+
// It was in once and is no longer listed: somebody took it out by hand.
|
|
1228
|
+
// That is an answer too, kept like uninstall-mod's, and said once.
|
|
1229
|
+
if (state.installed && !v.listed) {
|
|
1230
|
+
writeModState(opts.home, {
|
|
1231
|
+
...state, optedOut: true, optedOutAt: new Date(now()).toISOString(), optedOutBy: 'unlisted', failed: undefined,
|
|
1232
|
+
});
|
|
1233
|
+
const notice = unlistedNotice(installedDir, settingsPath);
|
|
1234
|
+
leaveNotice(opts.home, notice);
|
|
1235
|
+
return { action: 'skipped', reason: 'removed-by-hand', notice };
|
|
1236
|
+
}
|
|
1237
|
+
if (listedOtherPending(settingsPath, opts.home, platform, opts.envDirs).length > 0) {
|
|
1238
|
+
return { action: 'skipped', reason: 'another-copy' };
|
|
1239
|
+
}
|
|
1240
|
+
const envOnly = environmentOnlyList(settingsPath, opts.home, platform, opts.envDirs);
|
|
1241
|
+
if (envOnly.length > 0) {
|
|
1242
|
+
const seen = sha256(Buffer.from(envOnly.join('\n'), 'utf8'));
|
|
1243
|
+
if (state.envListNoticed === seen)
|
|
1244
|
+
return { action: 'skipped', reason: 'env-list' };
|
|
1245
|
+
writeModState(opts.home, { ...state, envListNoticed: seen });
|
|
1246
|
+
const notice = envListNotice(envOnly, settingsPath);
|
|
1247
|
+
leaveNotice(opts.home, notice);
|
|
1248
|
+
return { action: 'skipped', reason: 'env-list', notice };
|
|
1249
|
+
}
|
|
1250
|
+
const hadDir = (0, fs_1.existsSync)(installedDir);
|
|
1251
|
+
const dropCopy = async () => {
|
|
1252
|
+
if (!hadDir)
|
|
1253
|
+
await fs_1.promises.rm(installedDir, { recursive: true, force: true, maxRetries: 3 }).catch(() => undefined);
|
|
1254
|
+
};
|
|
1255
|
+
let files;
|
|
1256
|
+
try {
|
|
1257
|
+
files = await copyModAsync(opts.packageDir, installedDir);
|
|
1258
|
+
}
|
|
1259
|
+
catch (e) {
|
|
1260
|
+
return fail(e.message, isTransient(e));
|
|
1261
|
+
}
|
|
1262
|
+
const install = () => installMod({
|
|
1263
|
+
packageDir: opts.packageDir, home: opts.home, platform, envDirs: opts.envDirs, auto: true, copied: files,
|
|
1264
|
+
beforeSettingsWrite: opts.beforeSettingsWrite,
|
|
1265
|
+
});
|
|
1266
|
+
let r = install();
|
|
1267
|
+
// Checked after writing: something else (Claude Code itself, an editor)
|
|
1268
|
+
// may have written settings.json right after. Once more if so.
|
|
1269
|
+
if (r.ok && !r.skipped && !verifyMod({ ...opts, platform }).listed)
|
|
1270
|
+
r = install();
|
|
1271
|
+
if (r.ok && r.skipped === 'opted-out') {
|
|
1272
|
+
await dropCopy();
|
|
1273
|
+
return { action: 'skipped', reason: 'opted-out' };
|
|
1274
|
+
}
|
|
1275
|
+
if (!r.ok) {
|
|
1276
|
+
await dropCopy();
|
|
1277
|
+
return fail(r.error ?? 'install-mod failed', r.transient);
|
|
1278
|
+
}
|
|
1279
|
+
if (r.skipped)
|
|
1280
|
+
return { action: 'skipped', reason: 'no-claude-code' };
|
|
1281
|
+
const notice = installedNotice(version, r);
|
|
1282
|
+
leaveNotice(opts.home, notice);
|
|
1283
|
+
return { action: 'installed', notice };
|
|
1284
|
+
}
|
|
1285
|
+
catch (e) {
|
|
1286
|
+
return fail(e.message, isTransient(e));
|
|
1287
|
+
}
|
|
1288
|
+
finally {
|
|
1289
|
+
await releaseLock(lockPath, token);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
let onBoot = null;
|
|
1293
|
+
/**
|
|
1294
|
+
* What crbro_boot calls first: starts the automatic install the first time in
|
|
1295
|
+
* this process and returns the function to call once the boot has its answer
|
|
1296
|
+
* ready. That function waits for the install until `budgetMs` after the start
|
|
1297
|
+
* and takes the notice waiting to be said, or null. Neither throws. A boot
|
|
1298
|
+
* that fails never calls it, so it never takes a notice it cannot hand over.
|
|
1299
|
+
*/
|
|
1300
|
+
function startModOnBoot(opts = {}) {
|
|
1301
|
+
try {
|
|
1302
|
+
const home = opts.home ?? (0, os_1.homedir)();
|
|
1303
|
+
const env = opts.env ?? process.env;
|
|
1304
|
+
if (modSwitchedOff(env) || !(0, fs_1.existsSync)(modPaths(home).claudeDir))
|
|
1305
|
+
return async () => null;
|
|
1306
|
+
if (!onBoot) {
|
|
1307
|
+
const packageDir = opts.packageDir ?? path_1.default.join(__dirname, '..', '..', 'mods', exports.MOD_NAME);
|
|
1308
|
+
onBoot = autoInstallMod({ ...opts, home, env, packageDir })
|
|
1309
|
+
.catch(e => ({ action: 'failed', reason: e.message }));
|
|
1310
|
+
}
|
|
1311
|
+
const running = onBoot;
|
|
1312
|
+
let timer;
|
|
1313
|
+
const budget = new Promise(resolve => {
|
|
1314
|
+
timer = setTimeout(resolve, opts.budgetMs ?? exports.BOOT_BUDGET_MS);
|
|
1315
|
+
timer.unref?.();
|
|
1316
|
+
});
|
|
1317
|
+
return async () => {
|
|
1318
|
+
try {
|
|
1319
|
+
await Promise.race([running, budget]);
|
|
1320
|
+
if (timer)
|
|
1321
|
+
clearTimeout(timer);
|
|
1322
|
+
return takeModNotice(home, opts.now);
|
|
1323
|
+
}
|
|
1324
|
+
catch {
|
|
1325
|
+
return null;
|
|
1326
|
+
}
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
catch {
|
|
1330
|
+
return async () => null;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
/** startModOnBoot and its notice in one call. */
|
|
1334
|
+
async function modNoticeOnBoot(opts = {}) {
|
|
1335
|
+
return startModOnBoot(opts)();
|
|
1336
|
+
}
|
|
1337
|
+
/** Tests only: the next boot in this process behaves as the first boot of a new process. */
|
|
1338
|
+
function resetModOnBoot() {
|
|
1339
|
+
onBoot = null;
|
|
1340
|
+
handedOver.clear();
|
|
1341
|
+
}
|
|
1342
|
+
//# sourceMappingURL=modinstall.js.map
|