baychat 0.24.0 → 0.25.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 +98 -15
- package/dist/api.js +19 -2
- package/dist/claude-context-flush.js +219 -0
- package/dist/claude-context-state.js +191 -0
- package/dist/claude-context.js +311 -0
- package/dist/claude-settings.js +503 -0
- package/dist/context-hooks-command.js +165 -0
- package/dist/context-report.js +307 -0
- package/dist/help-topics.js +2 -23
- package/dist/index.js +26 -499
- package/dist/main.js +511 -0
- package/dist/mcp-notes.js +110 -0
- package/dist/mcp.js +84 -5
- package/dist/protocol-content.js +1 -1
- package/dist/relay/acp/client.js +18 -0
- package/dist/relay/acp/prompt.js +54 -3
- package/dist/relay/acp/runner.js +100 -2
- package/dist/relay/acp/turn-queue.js +99 -3
- package/dist/relay/codex-context.js +242 -0
- package/dist/relay/compact-command.js +63 -0
- package/dist/relay/compact-focus.attacks.js +36 -0
- package/dist/relay/compact-focus.js +64 -0
- package/dist/relay/context-notice.js +144 -0
- package/dist/relay/daemon.js +287 -28
- package/dist/relay/fresh-start-verify.js +237 -0
- package/dist/relay/fresh-start.js +141 -0
- package/dist/relay/message-format.js +34 -15
- package/dist/relay/project-brief.js +195 -0
- package/dist/relay/project-notes.js +300 -0
- package/dist/relay/resume.js +25 -0
- package/dist/relay/session-commands.js +2 -4
- package/dist/relay/terminal-text.js +13 -0
- package/dist/relay/updates.js +42 -7
- package/dist/relay/ws.js +34 -2
- package/dist/runtimes.js +11 -0
- package/dist/tool-defs.js +93 -0
- package/package.json +1 -1
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Putting BayChat's context reader into Claude Code's own settings file — and taking it out again.
|
|
3
|
+
//
|
|
4
|
+
// WHAT IS INSTALLED. Three entries in ~/.claude/settings.json, and nothing else:
|
|
5
|
+
//
|
|
6
|
+
// • `statusLine.command` → a short POSIX shell program (see `wrapperCommand`) that runs THE
|
|
7
|
+
// PERSON'S OWN status-line command with the same input, in the same shell, and hands BayChat a
|
|
8
|
+
// copy of the input in the background. Their line never waits for Node.
|
|
9
|
+
// • `hooks.PreCompact` and `hooks.PostCompact` → `context-hook`, async (it can neither block nor
|
|
10
|
+
// delay a compaction) and printing nothing.
|
|
11
|
+
//
|
|
12
|
+
// HOW CLAUDE CODE RUNS IT — read from Claude Code 2.1.282's own executor, not assumed: the status
|
|
13
|
+
// line goes through the same function as hooks, which on macOS and Linux is
|
|
14
|
+
// `spawn(command, { shell: true })` — `/bin/sh -c` — with the status JSON on stdin; its stdout is
|
|
15
|
+
// shown ONLY when it exits 0. So the program below is POSIX sh, runs their command with `eval` in
|
|
16
|
+
// that same sh, and ends with their command's exit status. (Windows runs it through Git Bash;
|
|
17
|
+
// installing there is refused until that is proven — see context-hooks-command.ts.)
|
|
18
|
+
//
|
|
19
|
+
// NOTHING DEPENDS ON PATH. Node and the CLI entry are written in by ABSOLUTE path, because Claude
|
|
20
|
+
// Code started from an IDE or a GUI (or under another nvm version) has a different PATH from the
|
|
21
|
+
// shell that ran `install`. If either is gone — `npm uninstall -g baychat` without `uninstall`, an
|
|
22
|
+
// nvm version removed — the program skips BayChat and still runs their command, so their status
|
|
23
|
+
// line keeps rendering exactly as it did.
|
|
24
|
+
//
|
|
25
|
+
// THE ORIGINAL IS KEPT TWICE: base64url in the marker at the front (`: baychat-context-statusline
|
|
26
|
+
// b<…>;`, a no-op), which is what uninstall reads, and single-quoted for `eval`, which is what runs.
|
|
27
|
+
//
|
|
28
|
+
// THE CONSENT. Nothing here runs by itself: `baychat context-hooks install` is the deliberate act,
|
|
29
|
+
// `baychat context-hooks uninstall` the off switch (REMOTE_APPROVAL_HOOK.md is the precedent).
|
|
30
|
+
//
|
|
31
|
+
// NEVER CORRUPT OR LOSE THE FILE:
|
|
32
|
+
// • a file that does not parse is REFUSED, never overwritten;
|
|
33
|
+
// • a symlinked settings.json (stow, chezmoi) is followed: the REAL file is edited, in place,
|
|
34
|
+
// atomically (temp file beside it, then rename), and the link stays a link;
|
|
35
|
+
// • unknown keys are preserved; the file's mode and indentation are kept;
|
|
36
|
+
// • every write first copies the file to a backup that is NEVER overwritten: the first is
|
|
37
|
+
// `settings.json.baychat-backup` (the pre-install original, kept for good), later ones are
|
|
38
|
+
// timestamped;
|
|
39
|
+
// • uninstall restores the person's own bytes when the result is the same settings as a backup
|
|
40
|
+
// (their formatting intact); otherwise — they changed something while installed — it keeps
|
|
41
|
+
// their change, re-serialises, and SAYS it restored semantically;
|
|
42
|
+
// • install and uninstall are idempotent.
|
|
43
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
44
|
+
if (k2 === undefined) k2 = k;
|
|
45
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
46
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
47
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
48
|
+
}
|
|
49
|
+
Object.defineProperty(o, k2, desc);
|
|
50
|
+
}) : (function(o, m, k, k2) {
|
|
51
|
+
if (k2 === undefined) k2 = k;
|
|
52
|
+
o[k2] = m[k];
|
|
53
|
+
}));
|
|
54
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
55
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
56
|
+
}) : function(o, v) {
|
|
57
|
+
o["default"] = v;
|
|
58
|
+
});
|
|
59
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
60
|
+
var ownKeys = function(o) {
|
|
61
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
62
|
+
var ar = [];
|
|
63
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
64
|
+
return ar;
|
|
65
|
+
};
|
|
66
|
+
return ownKeys(o);
|
|
67
|
+
};
|
|
68
|
+
return function (mod) {
|
|
69
|
+
if (mod && mod.__esModule) return mod;
|
|
70
|
+
var result = {};
|
|
71
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
72
|
+
__setModuleDefault(result, mod);
|
|
73
|
+
return result;
|
|
74
|
+
};
|
|
75
|
+
})();
|
|
76
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
77
|
+
exports.nodeSettingsIo = void 0;
|
|
78
|
+
exports.encodeChain = encodeChain;
|
|
79
|
+
exports.decodeChain = decodeChain;
|
|
80
|
+
exports.shQuote = shQuote;
|
|
81
|
+
exports.wrapperCommand = wrapperCommand;
|
|
82
|
+
exports.hookCommand = hookCommand;
|
|
83
|
+
exports.parseWrapper = parseWrapper;
|
|
84
|
+
exports.contextHooksState = contextHooksState;
|
|
85
|
+
exports.installContextHooks = installContextHooks;
|
|
86
|
+
exports.uninstallContextHooks = uninstallContextHooks;
|
|
87
|
+
exports.claudeSettingsPath = claudeSettingsPath;
|
|
88
|
+
exports.settingsBackupPath = settingsBackupPath;
|
|
89
|
+
exports.detectIndent = detectIndent;
|
|
90
|
+
exports.editClaudeSettings = editClaudeSettings;
|
|
91
|
+
exports.readClaudeSettingsState = readClaudeSettingsState;
|
|
92
|
+
const fs = __importStar(require("fs"));
|
|
93
|
+
const os = __importStar(require("os"));
|
|
94
|
+
const path = __importStar(require("path"));
|
|
95
|
+
/** The first release's commands, still recognised so uninstall and upgrade can find them. */
|
|
96
|
+
const LEGACY_STATUSLINE = "baychat context-statusline";
|
|
97
|
+
const LEGACY_HOOK = "baychat context-hook";
|
|
98
|
+
const LEGACY_CHAIN_FLAG = "--chain";
|
|
99
|
+
const STATUSLINE_MARKER = ": baychat-context-statusline ";
|
|
100
|
+
const HOOK_MARKER = ": baychat-context-hook;";
|
|
101
|
+
const HOOK_EVENTS = ["PreCompact", "PostCompact"];
|
|
102
|
+
/** Generous: the hook is async and returns in milliseconds; this only bounds a wedged one. */
|
|
103
|
+
const HOOK_TIMEOUT_SECONDS = 30;
|
|
104
|
+
const isRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
105
|
+
// ─── The commands ────────────────────────────────────────────────────────────
|
|
106
|
+
/** base64url: only [A-Za-z0-9_-], so the result needs no quoting in any shell. */
|
|
107
|
+
function encodeChain(command) {
|
|
108
|
+
return Buffer.from(command, "utf8").toString("base64url");
|
|
109
|
+
}
|
|
110
|
+
function decodeChain(encoded) {
|
|
111
|
+
if (!/^[A-Za-z0-9_-]*$/.test(encoded))
|
|
112
|
+
return undefined;
|
|
113
|
+
const text = Buffer.from(encoded, "base64url").toString("utf8");
|
|
114
|
+
// A round trip that does not reproduce the input was not ours.
|
|
115
|
+
return encodeChain(text) === encoded ? text : undefined;
|
|
116
|
+
}
|
|
117
|
+
/** POSIX single quotes: everything literal; a `'` is closed, escaped and reopened. */
|
|
118
|
+
function shQuote(text) {
|
|
119
|
+
return `'${text.replace(/'/g, `'\\''`)}'`;
|
|
120
|
+
}
|
|
121
|
+
/** `[ -x node ] && [ -f entry ]`: BayChat is still there to run. */
|
|
122
|
+
function present(launch) {
|
|
123
|
+
return `[ -x ${shQuote(launch.node)} ] && [ -f ${shQuote(launch.entry)} ]`;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The status-line program, in POSIX sh:
|
|
127
|
+
*
|
|
128
|
+
* : baychat-context-statusline b<original, base64url>; ← marker; a no-op
|
|
129
|
+
* baychat_in=$(cat; printf x); baychat_in=${baychat_in%x} ← all of stdin, trailing newlines kept
|
|
130
|
+
* if <node and entry exist>; then
|
|
131
|
+
* printf '%s' "$baychat_in" | '<node>' '<entry>' context-statusline >/dev/null 2>&1 & ← background
|
|
132
|
+
* fi
|
|
133
|
+
* printf '%s' "$baychat_in" | eval '<original>' ← their line, their exit status
|
|
134
|
+
*
|
|
135
|
+
* The background copy has stdout and stderr on /dev/null, so it holds none of Claude Code's pipes
|
|
136
|
+
* and the line is shown the moment their command ends.
|
|
137
|
+
*/
|
|
138
|
+
function wrapperCommand(original, launch) {
|
|
139
|
+
const marker = `${STATUSLINE_MARKER}${original === undefined ? "-" : `b${encodeChain(original)}`};`;
|
|
140
|
+
const read = `baychat_in=$(cat; printf x); baychat_in=\${baychat_in%x};`;
|
|
141
|
+
const note = `if ${present(launch)}; then printf '%s' "$baychat_in" | ${shQuote(launch.node)} ${shQuote(launch.entry)} context-statusline >/dev/null 2>&1 & fi;`;
|
|
142
|
+
const theirs = original === undefined ? ":" : `printf '%s' "$baychat_in" | eval ${shQuote(original)}`;
|
|
143
|
+
return `${marker} ${read} ${note} ${theirs}`;
|
|
144
|
+
}
|
|
145
|
+
/** The compaction hook: runs BayChat if it is still there, else does nothing, silently. */
|
|
146
|
+
function hookCommand(launch) {
|
|
147
|
+
return `${HOOK_MARKER} if ${present(launch)}; then exec ${shQuote(launch.node)} ${shQuote(launch.entry)} context-hook; fi`;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Is this status-line command ours, and if so what did it wrap?
|
|
151
|
+
* `wrapped: undefined` means ours with nothing chained; `ours: false` means somebody else's.
|
|
152
|
+
* Reads the current format and the first release's.
|
|
153
|
+
*/
|
|
154
|
+
function parseWrapper(command) {
|
|
155
|
+
if (typeof command !== "string")
|
|
156
|
+
return { ours: false };
|
|
157
|
+
const trimmed = command.trim();
|
|
158
|
+
if (trimmed.startsWith(STATUSLINE_MARKER)) {
|
|
159
|
+
const token = trimmed.slice(STATUSLINE_MARKER.length).split(";", 1)[0].trim();
|
|
160
|
+
if (token === "-")
|
|
161
|
+
return { ours: true };
|
|
162
|
+
const wrapped = token.startsWith("b") ? decodeChain(token.slice(1)) : undefined;
|
|
163
|
+
return wrapped === undefined ? { ours: false } : { ours: true, wrapped };
|
|
164
|
+
}
|
|
165
|
+
if (trimmed === LEGACY_STATUSLINE)
|
|
166
|
+
return { ours: true };
|
|
167
|
+
const prefix = `${LEGACY_STATUSLINE} ${LEGACY_CHAIN_FLAG} `;
|
|
168
|
+
if (!trimmed.startsWith(prefix))
|
|
169
|
+
return { ours: false };
|
|
170
|
+
const wrapped = decodeChain(trimmed.slice(prefix.length).trim());
|
|
171
|
+
return wrapped === undefined || wrapped === "" ? { ours: false } : { ours: true, wrapped };
|
|
172
|
+
}
|
|
173
|
+
function isOurHook(h) {
|
|
174
|
+
if (!isRecord(h) || typeof h.command !== "string")
|
|
175
|
+
return false;
|
|
176
|
+
const c = h.command.trim();
|
|
177
|
+
return c === LEGACY_HOOK || c.startsWith(HOOK_MARKER);
|
|
178
|
+
}
|
|
179
|
+
function eventHasOurHook(settings, event) {
|
|
180
|
+
const hooks = isRecord(settings.hooks) ? settings.hooks : undefined;
|
|
181
|
+
const groups = hooks && Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
182
|
+
return groups.some((g) => isRecord(g) && Array.isArray(g.hooks) && g.hooks.some(isOurHook));
|
|
183
|
+
}
|
|
184
|
+
function contextHooksState(settings) {
|
|
185
|
+
const line = isRecord(settings.statusLine) ? settings.statusLine : undefined;
|
|
186
|
+
const parsed = parseWrapper(line?.command);
|
|
187
|
+
return {
|
|
188
|
+
statusLine: !line ? "none" : parsed.ours ? "ours" : "theirs",
|
|
189
|
+
...(parsed.ours && parsed.wrapped !== undefined ? { wrapped: parsed.wrapped } : {}),
|
|
190
|
+
hooks: { PreCompact: eventHasOurHook(settings, "PreCompact"), PostCompact: eventHasOurHook(settings, "PostCompact") },
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Add (or bring up to date) the status-line wrapper and the two compaction hooks. Idempotent for
|
|
195
|
+
* the same `launch`; a different one (another node, another install) rewrites the paths.
|
|
196
|
+
*
|
|
197
|
+
* Only `statusLine.command` is replaced; `padding`, `refreshInterval` and anything else stay. A
|
|
198
|
+
* status line that is not a command is left alone rather than guessed at — the hooks still go in.
|
|
199
|
+
*/
|
|
200
|
+
function installContextHooks(input, launch) {
|
|
201
|
+
const settings = structuredClone(input);
|
|
202
|
+
let changed = false;
|
|
203
|
+
const line = settings.statusLine;
|
|
204
|
+
if (line === undefined) {
|
|
205
|
+
settings.statusLine = { type: "command", command: wrapperCommand(undefined, launch) };
|
|
206
|
+
changed = true;
|
|
207
|
+
}
|
|
208
|
+
else if (isRecord(line) && (line.type === undefined || line.type === "command") && typeof line.command === "string") {
|
|
209
|
+
const parsed = parseWrapper(line.command);
|
|
210
|
+
const want = wrapperCommand(parsed.ours ? parsed.wrapped : line.command, launch);
|
|
211
|
+
if (line.command !== want) {
|
|
212
|
+
settings.statusLine = { ...line, command: want };
|
|
213
|
+
changed = true;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const hook = hookCommand(launch);
|
|
217
|
+
const hooks = isRecord(settings.hooks) ? { ...settings.hooks } : {};
|
|
218
|
+
let hooksChanged = false;
|
|
219
|
+
for (const event of HOOK_EVENTS) {
|
|
220
|
+
const groups = Array.isArray(hooks[event]) ? [...hooks[event]] : [];
|
|
221
|
+
if (eventHasOurHook(settings, event)) {
|
|
222
|
+
// Ours already: bring its command up to date (legacy form, or another node).
|
|
223
|
+
const updated = groups.map((g) => isRecord(g) && Array.isArray(g.hooks)
|
|
224
|
+
? { ...g, hooks: g.hooks.map((h) => (isOurHook(h) && h.command !== hook ? { ...h, command: hook } : h)) }
|
|
225
|
+
: g);
|
|
226
|
+
if (JSON.stringify(updated) !== JSON.stringify(groups)) {
|
|
227
|
+
hooks[event] = updated;
|
|
228
|
+
hooksChanged = true;
|
|
229
|
+
}
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
groups.push({ hooks: [{ type: "command", command: hook, async: true, timeout: HOOK_TIMEOUT_SECONDS }] });
|
|
233
|
+
hooks[event] = groups;
|
|
234
|
+
hooksChanged = true;
|
|
235
|
+
}
|
|
236
|
+
if (hooksChanged) {
|
|
237
|
+
settings.hooks = hooks;
|
|
238
|
+
changed = true;
|
|
239
|
+
}
|
|
240
|
+
return { settings: changed ? settings : input, changed };
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Remove exactly what `installContextHooks` added (any format). Idempotent.
|
|
244
|
+
*
|
|
245
|
+
* The status line goes back to the person's own command; when there was none, ours is removed.
|
|
246
|
+
* Only OUR hook entries are removed — a group that held other hooks keeps them, and an event
|
|
247
|
+
* array or `hooks` object is removed only when our removal is what emptied it.
|
|
248
|
+
*/
|
|
249
|
+
function uninstallContextHooks(input) {
|
|
250
|
+
const settings = structuredClone(input);
|
|
251
|
+
let changed = false;
|
|
252
|
+
const line = settings.statusLine;
|
|
253
|
+
if (isRecord(line)) {
|
|
254
|
+
const parsed = parseWrapper(line.command);
|
|
255
|
+
if (parsed.ours) {
|
|
256
|
+
if (parsed.wrapped === undefined)
|
|
257
|
+
delete settings.statusLine;
|
|
258
|
+
else
|
|
259
|
+
settings.statusLine = { ...line, command: parsed.wrapped };
|
|
260
|
+
changed = true;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (isRecord(settings.hooks)) {
|
|
264
|
+
const hooks = { ...settings.hooks };
|
|
265
|
+
let hooksChanged = false;
|
|
266
|
+
for (const event of HOOK_EVENTS) {
|
|
267
|
+
if (!Array.isArray(hooks[event]))
|
|
268
|
+
continue;
|
|
269
|
+
const before = hooks[event];
|
|
270
|
+
const after = [];
|
|
271
|
+
for (const group of before) {
|
|
272
|
+
if (!isRecord(group) || !Array.isArray(group.hooks) || !group.hooks.some(isOurHook)) {
|
|
273
|
+
after.push(group);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
hooksChanged = true;
|
|
277
|
+
const rest = group.hooks.filter((h) => !isOurHook(h));
|
|
278
|
+
if (rest.length > 0)
|
|
279
|
+
after.push({ ...group, hooks: rest });
|
|
280
|
+
}
|
|
281
|
+
if (after.length === before.length && after.every((g, i) => g === before[i]))
|
|
282
|
+
continue;
|
|
283
|
+
if (after.length > 0)
|
|
284
|
+
hooks[event] = after;
|
|
285
|
+
else
|
|
286
|
+
delete hooks[event];
|
|
287
|
+
}
|
|
288
|
+
if (hooksChanged) {
|
|
289
|
+
changed = true;
|
|
290
|
+
if (Object.keys(hooks).length > 0)
|
|
291
|
+
settings.hooks = hooks;
|
|
292
|
+
else
|
|
293
|
+
delete settings.hooks;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return { settings: changed ? settings : input, changed };
|
|
297
|
+
}
|
|
298
|
+
// ─── The file ────────────────────────────────────────────────────────────────
|
|
299
|
+
function claudeSettingsPath(home = os.homedir()) {
|
|
300
|
+
return path.join(process.env.CLAUDE_CONFIG_DIR || path.join(home, ".claude"), "settings.json");
|
|
301
|
+
}
|
|
302
|
+
/** The FIRST backup: the pre-install original. Later backups are timestamped beside it. */
|
|
303
|
+
function settingsBackupPath(file) {
|
|
304
|
+
return `${file}.baychat-backup`;
|
|
305
|
+
}
|
|
306
|
+
/** The indentation the file already uses, so our write changes only what it has to. */
|
|
307
|
+
function detectIndent(text) {
|
|
308
|
+
const match = /^[{[]\s*\n([ \t]+)\S/.exec(text);
|
|
309
|
+
if (!match)
|
|
310
|
+
return 2;
|
|
311
|
+
return match[1].includes("\t") ? "\t" : match[1].length;
|
|
312
|
+
}
|
|
313
|
+
exports.nodeSettingsIo = {
|
|
314
|
+
read(file) {
|
|
315
|
+
try {
|
|
316
|
+
return fs.readFileSync(file, "utf8");
|
|
317
|
+
}
|
|
318
|
+
catch (err) {
|
|
319
|
+
if (err.code === "ENOENT")
|
|
320
|
+
return null;
|
|
321
|
+
throw err;
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
write(file, text, mode) {
|
|
325
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
326
|
+
// Beside the target, so the rename cannot cross a filesystem (which would not be atomic).
|
|
327
|
+
const tmp = `${file}.baychat-${process.pid}.tmp`;
|
|
328
|
+
try {
|
|
329
|
+
fs.writeFileSync(tmp, text, { mode: mode ?? 0o600 });
|
|
330
|
+
if (mode !== undefined)
|
|
331
|
+
fs.chmodSync(tmp, mode);
|
|
332
|
+
fs.renameSync(tmp, file);
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
try {
|
|
336
|
+
fs.unlinkSync(tmp);
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
/* nothing was left behind */
|
|
340
|
+
}
|
|
341
|
+
throw err;
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
copyExclusive(from, to) {
|
|
345
|
+
fs.copyFileSync(from, to, fs.constants.COPYFILE_EXCL);
|
|
346
|
+
},
|
|
347
|
+
mode(file) {
|
|
348
|
+
try {
|
|
349
|
+
return fs.statSync(file).mode & 0o777;
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
return undefined;
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
realpath(file) {
|
|
356
|
+
try {
|
|
357
|
+
return fs.realpathSync(file);
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
if (err.code !== "ENOENT")
|
|
361
|
+
throw err;
|
|
362
|
+
// A dangling symlink is not "no file": writing through it would replace the link.
|
|
363
|
+
let link = false;
|
|
364
|
+
try {
|
|
365
|
+
link = fs.lstatSync(file).isSymbolicLink();
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
/* truly absent */
|
|
369
|
+
}
|
|
370
|
+
if (link)
|
|
371
|
+
throw new Error(`${file} is a symlink to a file that does not exist, so it was left untouched.`);
|
|
372
|
+
return file;
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
list(dir) {
|
|
376
|
+
try {
|
|
377
|
+
return fs.readdirSync(dir);
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
return [];
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
mtime(file) {
|
|
384
|
+
try {
|
|
385
|
+
return fs.statSync(file).mtimeMs;
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
return 0;
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
/** Copy `file` to a backup that did not exist before. Returns its path. */
|
|
393
|
+
function backUp(file, io) {
|
|
394
|
+
const first = settingsBackupPath(file);
|
|
395
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
|
|
396
|
+
for (const candidate of [first, `${first}-${stamp}`, ...Array.from({ length: 20 }, (_, i) => `${first}-${stamp}-${i + 2}`)]) {
|
|
397
|
+
try {
|
|
398
|
+
io.copyExclusive(file, candidate);
|
|
399
|
+
return candidate;
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
if (err.code !== "EEXIST")
|
|
403
|
+
throw err;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
throw new Error(`Could not find a free backup name beside ${file}; nothing was changed.`);
|
|
407
|
+
}
|
|
408
|
+
/** Every backup of `file`, newest first. */
|
|
409
|
+
function backupsOf(file, io) {
|
|
410
|
+
const base = path.basename(settingsBackupPath(file));
|
|
411
|
+
const dir = path.dirname(file);
|
|
412
|
+
return io
|
|
413
|
+
.list(dir)
|
|
414
|
+
.filter((name) => name === base || name.startsWith(`${base}-`))
|
|
415
|
+
.map((name) => path.join(dir, name))
|
|
416
|
+
.sort((a, b) => io.mtime(b) - io.mtime(a));
|
|
417
|
+
}
|
|
418
|
+
/** Deep equality of parsed JSON, key order ignored. */
|
|
419
|
+
function sameJson(a, b) {
|
|
420
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
421
|
+
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => sameJson(v, b[i]));
|
|
422
|
+
}
|
|
423
|
+
if (isRecord(a) && isRecord(b)) {
|
|
424
|
+
const ka = Object.keys(a);
|
|
425
|
+
return ka.length === Object.keys(b).length && ka.every((k) => Object.prototype.hasOwnProperty.call(b, k) && sameJson(a[k], b[k]));
|
|
426
|
+
}
|
|
427
|
+
return a === b;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Read → transform → back up → write atomically, on the REAL file. Throws, with the file
|
|
431
|
+
* untouched, when it does not parse or is not a JSON object.
|
|
432
|
+
*
|
|
433
|
+
* `restoreFromBackups` (uninstall): when the result is the same settings as one of the backups,
|
|
434
|
+
* that backup's exact text is written instead, so the person's formatting comes back too.
|
|
435
|
+
*/
|
|
436
|
+
function editClaudeSettings(file, transform, io = exports.nodeSettingsIo, opts = {}) {
|
|
437
|
+
const target = io.realpath(file);
|
|
438
|
+
const text = io.read(target);
|
|
439
|
+
let settings = {};
|
|
440
|
+
if (text !== null && text.trim() !== "") {
|
|
441
|
+
let parsed;
|
|
442
|
+
try {
|
|
443
|
+
parsed = JSON.parse(text);
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
throw new Error(`${target} is not valid JSON, so it was left untouched. Fix it (or move it aside), then run this again.`);
|
|
447
|
+
}
|
|
448
|
+
if (!isRecord(parsed)) {
|
|
449
|
+
throw new Error(`${target} is not a JSON object, so it was left untouched.`);
|
|
450
|
+
}
|
|
451
|
+
settings = parsed;
|
|
452
|
+
}
|
|
453
|
+
const result = transform(settings);
|
|
454
|
+
if (!result.changed)
|
|
455
|
+
return { kind: "unchanged", state: contextHooksState(settings), file: target };
|
|
456
|
+
let restored;
|
|
457
|
+
let next = `${JSON.stringify(result.settings, null, text ? detectIndent(text) : 2)}\n`;
|
|
458
|
+
if (opts.restoreFromBackups) {
|
|
459
|
+
restored = "semantic";
|
|
460
|
+
for (const candidate of backupsOf(target, io)) {
|
|
461
|
+
const old = io.read(candidate);
|
|
462
|
+
if (old === null)
|
|
463
|
+
continue;
|
|
464
|
+
try {
|
|
465
|
+
if (sameJson(JSON.parse(old), result.settings)) {
|
|
466
|
+
next = old;
|
|
467
|
+
restored = "exact";
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
/* a backup that does not parse cannot be the one */
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const backup = text !== null ? backUp(target, io) : undefined;
|
|
477
|
+
io.write(target, next, io.mode(target));
|
|
478
|
+
return {
|
|
479
|
+
kind: "written",
|
|
480
|
+
state: contextHooksState(result.settings),
|
|
481
|
+
file: target,
|
|
482
|
+
...(backup ? { backup } : {}),
|
|
483
|
+
...(restored ? { restored } : {}),
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
function readClaudeSettingsState(file, io = exports.nodeSettingsIo) {
|
|
487
|
+
let text;
|
|
488
|
+
try {
|
|
489
|
+
text = io.read(io.realpath(file));
|
|
490
|
+
}
|
|
491
|
+
catch (err) {
|
|
492
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
493
|
+
}
|
|
494
|
+
if (text === null || text.trim() === "")
|
|
495
|
+
return contextHooksState({});
|
|
496
|
+
try {
|
|
497
|
+
const parsed = JSON.parse(text);
|
|
498
|
+
return isRecord(parsed) ? contextHooksState(parsed) : { error: `${file} is not a JSON object` };
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
return { error: `${file} is not valid JSON` };
|
|
502
|
+
}
|
|
503
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// `baychat context-hooks install | uninstall | status` — the person's switch for Claude Code context
|
|
3
|
+
// reporting. Nothing is ever installed without this command being run (see claude-settings.ts).
|
|
4
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
5
|
+
if (k2 === undefined) k2 = k;
|
|
6
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
7
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
8
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
9
|
+
}
|
|
10
|
+
Object.defineProperty(o, k2, desc);
|
|
11
|
+
}) : (function(o, m, k, k2) {
|
|
12
|
+
if (k2 === undefined) k2 = k;
|
|
13
|
+
o[k2] = m[k];
|
|
14
|
+
}));
|
|
15
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
16
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
17
|
+
}) : function(o, v) {
|
|
18
|
+
o["default"] = v;
|
|
19
|
+
});
|
|
20
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
21
|
+
var ownKeys = function(o) {
|
|
22
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
23
|
+
var ar = [];
|
|
24
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
25
|
+
return ar;
|
|
26
|
+
};
|
|
27
|
+
return ownKeys(o);
|
|
28
|
+
};
|
|
29
|
+
return function (mod) {
|
|
30
|
+
if (mod && mod.__esModule) return mod;
|
|
31
|
+
var result = {};
|
|
32
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
33
|
+
__setModuleDefault(result, mod);
|
|
34
|
+
return result;
|
|
35
|
+
};
|
|
36
|
+
})();
|
|
37
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
38
|
+
exports.currentLaunch = currentLaunch;
|
|
39
|
+
exports.launchProblem = launchProblem;
|
|
40
|
+
exports.cmdContextHooks = cmdContextHooks;
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
const claude_context_1 = require("./claude-context");
|
|
44
|
+
const claude_settings_1 = require("./claude-settings");
|
|
45
|
+
const config_1 = require("./config");
|
|
46
|
+
const USAGE = "Usage: baychat context-hooks <install|uninstall|status>";
|
|
47
|
+
/**
|
|
48
|
+
* This very CLI, by absolute path: the node running it and the entry file it was started from.
|
|
49
|
+
* Symlinks resolved, so an npm bin link or an nvm alias cannot move under it later.
|
|
50
|
+
*/
|
|
51
|
+
function currentLaunch() {
|
|
52
|
+
return { node: fs.realpathSync(process.execPath), entry: fs.realpathSync(process.argv[1] ?? "") };
|
|
53
|
+
}
|
|
54
|
+
/** Why this launch cannot be written into settings.json, or undefined when it can. */
|
|
55
|
+
function launchProblem(launch) {
|
|
56
|
+
if (/[\\/]_npx[\\/]/.test(launch.entry)) {
|
|
57
|
+
return "This is a temporary `npx` copy of BayChat, which npm deletes on its own — your status line would lose BayChat without warning. Install it for good (`npm install --global baychat`), then run `baychat context-hooks install` from that.";
|
|
58
|
+
}
|
|
59
|
+
if (!fs.existsSync(launch.entry))
|
|
60
|
+
return `BayChat's own entry file (${launch.entry}) could not be found.`;
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
function describe(state) {
|
|
64
|
+
const line = state.statusLine === "ours"
|
|
65
|
+
? state.wrapped !== undefined
|
|
66
|
+
? ` status line: BayChat reader, running your own command: ${state.wrapped}`
|
|
67
|
+
: " status line: BayChat reader (you had no status line of your own)"
|
|
68
|
+
: state.statusLine === "theirs"
|
|
69
|
+
? " status line: yours, not wrapped — context is not being read"
|
|
70
|
+
: " status line: none";
|
|
71
|
+
return [
|
|
72
|
+
line,
|
|
73
|
+
` PreCompact hook: ${state.hooks.PreCompact ? "installed" : "not installed"}`,
|
|
74
|
+
` PostCompact hook: ${state.hooks.PostCompact ? "installed" : "not installed"}`,
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
async function cmdContextHooks(args, deps = {}) {
|
|
78
|
+
const out = deps.out ?? ((l) => console.log(l));
|
|
79
|
+
const err = deps.err ?? ((l) => console.error(l));
|
|
80
|
+
const file = deps.settingsFile ?? (0, claude_settings_1.claudeSettingsPath)();
|
|
81
|
+
const sub = args[0];
|
|
82
|
+
if (sub === "status") {
|
|
83
|
+
const state = (0, claude_settings_1.readClaudeSettingsState)(file);
|
|
84
|
+
if ("error" in state) {
|
|
85
|
+
err(state.error);
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
out(`Claude Code context reporting (${file}):`);
|
|
89
|
+
for (const l of describe(state))
|
|
90
|
+
out(l);
|
|
91
|
+
out(` BayChat login: ${(0, config_1.loadDeviceCredentials)() ? "yes" : "no — run `baychat login`"}`);
|
|
92
|
+
const until = (0, claude_context_1.backoffUntil)();
|
|
93
|
+
if (until > Date.now())
|
|
94
|
+
out(` server: does not accept context reports yet — next try after ${new Date(until).toLocaleTimeString()}`);
|
|
95
|
+
out(` problems, if any, are logged in ${path.join((0, claude_context_1.contextDir)(), "errors.log")}`);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
if (sub === "install") {
|
|
99
|
+
const platform = deps.platform ?? process.platform;
|
|
100
|
+
if (platform === "win32") {
|
|
101
|
+
// Claude Code runs a status line through Git Bash or PowerShell on Windows, and the wrapper
|
|
102
|
+
// re-runs yours through Node's shell (cmd.exe). Until that is proven to give the same
|
|
103
|
+
// output, a broken status line is too high a price.
|
|
104
|
+
err("Not on Windows yet: BayChat could not promise your status line would look exactly the same. Nothing was changed.");
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
const launch = (deps.launch ?? currentLaunch)();
|
|
108
|
+
const problem = launchProblem(launch);
|
|
109
|
+
if (problem) {
|
|
110
|
+
err(`${problem} Nothing was changed.`);
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
let result;
|
|
114
|
+
try {
|
|
115
|
+
result = (0, claude_settings_1.editClaudeSettings)(file, (settings) => (0, claude_settings_1.installContextHooks)(settings, launch));
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
err(e instanceof Error ? e.message : String(e));
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
if (result.kind === "unchanged") {
|
|
122
|
+
out("Already installed — nothing changed.");
|
|
123
|
+
for (const l of describe(result.state))
|
|
124
|
+
out(l);
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
out(`Installed in ${result.file}:`);
|
|
128
|
+
for (const l of describe(result.state))
|
|
129
|
+
out(l);
|
|
130
|
+
if (result.backup)
|
|
131
|
+
out(`Your previous settings are copied to ${result.backup}.`);
|
|
132
|
+
out("");
|
|
133
|
+
out("Your status line looks exactly as before: your own command still runs, in the same shell, and BayChat reads a copy of its input in the background.");
|
|
134
|
+
out(`BayChat is called by absolute path (${launch.node}, ${launch.entry}). If that ever goes away, your status line keeps working without it; run this again after moving Node or BayChat.`);
|
|
135
|
+
out("BayChat now sees how full each Claude Code session's context is, and when it tidies itself.");
|
|
136
|
+
out("A status line set in a project's .claude/settings.json takes precedence over this one there.");
|
|
137
|
+
if (!(0, config_1.loadDeviceCredentials)())
|
|
138
|
+
out("This computer is not logged in to BayChat yet — run `baychat login`, or nothing can be sent.");
|
|
139
|
+
out("To undo it: baychat context-hooks uninstall");
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
if (sub === "uninstall") {
|
|
143
|
+
let result;
|
|
144
|
+
try {
|
|
145
|
+
result = (0, claude_settings_1.editClaudeSettings)(file, claude_settings_1.uninstallContextHooks, undefined, { restoreFromBackups: true });
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
err(e instanceof Error ? e.message : String(e));
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
if (result.kind === "unchanged") {
|
|
152
|
+
out("Not installed — nothing changed.");
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
out(`Removed from ${result.file}. Your status line is your own command again${result.state.statusLine === "none" ? " (you had none)" : ""}.`);
|
|
156
|
+
out(result.restored === "exact"
|
|
157
|
+
? "Restored byte-for-byte: the file is exactly as you had it before install, formatting included."
|
|
158
|
+
: "Restored semantically: the same settings you had (and any you changed since), but the file was re-formatted — your hand formatting is in the backups beside it.");
|
|
159
|
+
if (result.backup)
|
|
160
|
+
out(`The settings as they were a moment ago are copied to ${result.backup}. Backups are never overwritten; the first one (${(0, claude_settings_1.settingsBackupPath)(result.file)}) is your file from before BayChat ever touched it.`);
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
err(USAGE);
|
|
164
|
+
return 1;
|
|
165
|
+
}
|