@gramatr/mcp 0.13.127 → 0.13.128

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.
@@ -0,0 +1,801 @@
1
+ /**
2
+ * gramatr install / uninstall — idempotent Claude Code setup (#2472).
3
+ *
4
+ * Single command that fully wires gramatr into Claude Code:
5
+ * - Auth check (skips device-flow when token already present)
6
+ * - Drop hook script to ~/.gramatr/scripts/hook-userpromptsubmit.sh
7
+ * - Merge UserPromptSubmit entry into ~/.claude/settings.json (sentinel-safe)
8
+ * - Merge gramatr section into ~/.claude/CLAUDE.md (sentinel-safe)
9
+ * - Optional cleanup of legacy 14-handler scaffold + daemon cruft
10
+ *
11
+ * Idempotency contract: every step computes desired state, compares to
12
+ * current, and writes only the diff. Re-running install on an
13
+ * already-installed system produces no observable changes.
14
+ *
15
+ * Scope: Claude Code only in v1. Codex / Cursor / Gemini stay on `setup`
16
+ * subcommands until follow-up.
17
+ */
18
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
19
+ import { dirname, join, resolve } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ /** Sentinel pair carved into ~/.claude/CLAUDE.md. */
22
+ export const GRAMATR_MD_START = "<!-- GRAMATR-START -->";
23
+ export const GRAMATR_MD_END = "<!-- GRAMATR-END -->";
24
+ /** Comment marker we insert on the UserPromptSubmit hook command so we can
25
+ * identify a gramatr-owned entry without depending on the script path
26
+ * (paths drift across machines — markers don't). */
27
+ const HOOK_OWNER_TAG = "# gramatr-managed: UserPromptSubmit";
28
+ const SESSION_END_OWNER_TAG = "# gramatr-managed: SessionEnd";
29
+ const SESSION_START_OWNER_TAG = "# gramatr-managed: SessionStart";
30
+ const STOP_HOOK_OWNER_TAG = "# gramatr-managed: Stop";
31
+ const HOOK_SCRIPT_FILENAME = "hook-userpromptsubmit.sh";
32
+ const HOOK_REL_PATH = join("scripts", HOOK_SCRIPT_FILENAME);
33
+ const SESSION_END_SCRIPT_FILENAME = "hook-sessionend.sh";
34
+ const SESSION_END_REL_PATH = join("scripts", SESSION_END_SCRIPT_FILENAME);
35
+ const SESSION_START_SCRIPT_FILENAME = "hook-sessionstart.sh";
36
+ const SESSION_START_REL_PATH = join("scripts", SESSION_START_SCRIPT_FILENAME);
37
+ const STOP_HOOK_SCRIPT_FILENAME = "hook-stop.sh";
38
+ const STOP_HOOK_REL_PATH = join("scripts", STOP_HOOK_SCRIPT_FILENAME);
39
+ // ── path helpers (parameterized by home so tests can use tmpdir) ────────────
40
+ export function claudeSettingsPath(home) {
41
+ return join(home, ".claude", "settings.json");
42
+ }
43
+ export function claudeMarkdownPath(home) {
44
+ return join(home, ".claude", "CLAUDE.md");
45
+ }
46
+ export function gramatrDir(home) {
47
+ return join(home, ".gramatr");
48
+ }
49
+ export function gramatrHookScriptPath(home) {
50
+ return join(gramatrDir(home), HOOK_REL_PATH);
51
+ }
52
+ export function gramatrSessionEndScriptPath(home) {
53
+ return join(gramatrDir(home), SESSION_END_REL_PATH);
54
+ }
55
+ export function gramatrSessionStartScriptPath(home) {
56
+ return join(gramatrDir(home), SESSION_START_REL_PATH);
57
+ }
58
+ export function gramatrStopScriptPath(home) {
59
+ return join(gramatrDir(home), STOP_HOOK_REL_PATH);
60
+ }
61
+ export function gramatrTokenPath(home) {
62
+ return join(home, ".gramatr.json");
63
+ }
64
+ /** Resolve the bundled SessionEnd hook script source inside @gramatr/mcp. */
65
+ export function resolveBundledSessionEndHookSource() {
66
+ const here = fileURLToPath(import.meta.url);
67
+ const pkgRoot = resolve(dirname(here), "..", "..");
68
+ const candidate = join(pkgRoot, "scripts", SESSION_END_SCRIPT_FILENAME);
69
+ if (existsSync(candidate))
70
+ return candidate;
71
+ const devCandidate = resolve(dirname(here), "..", "..", "scripts", SESSION_END_SCRIPT_FILENAME);
72
+ return devCandidate;
73
+ }
74
+ /** Resolve the bundled SessionStart hook script source inside @gramatr/mcp (#2475). */
75
+ export function resolveBundledSessionStartHookSource() {
76
+ const here = fileURLToPath(import.meta.url);
77
+ const pkgRoot = resolve(dirname(here), "..", "..");
78
+ const candidate = join(pkgRoot, "scripts", SESSION_START_SCRIPT_FILENAME);
79
+ if (existsSync(candidate))
80
+ return candidate;
81
+ const devCandidate = resolve(dirname(here), "..", "..", "scripts", SESSION_START_SCRIPT_FILENAME);
82
+ return devCandidate;
83
+ }
84
+ /** Resolve the bundled Stop hook script source inside @gramatr/mcp (#2476). */
85
+ export function resolveBundledStopHookSource() {
86
+ const here = fileURLToPath(import.meta.url);
87
+ const pkgRoot = resolve(dirname(here), "..", "..");
88
+ const candidate = join(pkgRoot, "scripts", STOP_HOOK_SCRIPT_FILENAME);
89
+ if (existsSync(candidate))
90
+ return candidate;
91
+ const devCandidate = resolve(dirname(here), "..", "..", "scripts", STOP_HOOK_SCRIPT_FILENAME);
92
+ return devCandidate;
93
+ }
94
+ /** Resolve the bundled hook script source inside the @gramatr/mcp package. */
95
+ export function resolveBundledHookSource() {
96
+ // Compiled output lives at packages/mcp/dist/bin/install.js.
97
+ // Source lives at packages/mcp/src/bin/install.ts.
98
+ // The script ships from packages/mcp/scripts/hook-userpromptsubmit.sh
99
+ // and is included in the npm tarball via the package "files" array.
100
+ const here = fileURLToPath(import.meta.url);
101
+ // Walk up to the package root (two parents from dist/bin/install.js,
102
+ // and src/bin/install.ts both land at packages/mcp/).
103
+ const pkgRoot = resolve(dirname(here), "..", "..");
104
+ const candidate = join(pkgRoot, "scripts", HOOK_SCRIPT_FILENAME);
105
+ if (existsSync(candidate))
106
+ return candidate;
107
+ // Fallback for ts-run during development.
108
+ const devCandidate = resolve(dirname(here), "..", "..", "scripts", HOOK_SCRIPT_FILENAME);
109
+ return devCandidate;
110
+ }
111
+ // ── atomic IO ──────────────────────────────────────────────────────────────
112
+ function ensureDir(path) {
113
+ mkdirSync(path, { recursive: true });
114
+ }
115
+ function readFileOr(path, fallback) {
116
+ try {
117
+ return readFileSync(path, "utf8");
118
+ }
119
+ catch {
120
+ return fallback;
121
+ }
122
+ }
123
+ function parseJsonOr(raw, fallback) {
124
+ if (raw === null)
125
+ return fallback;
126
+ try {
127
+ const parsed = JSON.parse(raw);
128
+ return (parsed ?? fallback);
129
+ }
130
+ catch {
131
+ return fallback;
132
+ }
133
+ }
134
+ function atomicWriteFile(path, content, mode = 0o644) {
135
+ ensureDir(dirname(path));
136
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
137
+ writeFileSync(tmp, content, { mode });
138
+ renameSync(tmp, path);
139
+ }
140
+ function backupFile(path) {
141
+ if (!existsSync(path))
142
+ return null;
143
+ const ts = Math.floor(Date.now() / 1000);
144
+ const dest = `${path}.bak-${ts}`;
145
+ copyFileSync(path, dest);
146
+ return dest;
147
+ }
148
+ export function buildHookCommand(home) {
149
+ return `${gramatrHookScriptPath(home)} ${HOOK_OWNER_TAG}`;
150
+ }
151
+ export function buildSessionEndHookCommand(home) {
152
+ return `${gramatrSessionEndScriptPath(home)} ${SESSION_END_OWNER_TAG}`;
153
+ }
154
+ export function buildSessionStartHookCommand(home) {
155
+ return `${gramatrSessionStartScriptPath(home)} ${SESSION_START_OWNER_TAG}`;
156
+ }
157
+ export function buildStopHookCommand(home) {
158
+ return `${gramatrStopScriptPath(home)} ${STOP_HOOK_OWNER_TAG}`;
159
+ }
160
+ function isGramatrHookCommand(cmd) {
161
+ if (cmd.includes(HOOK_OWNER_TAG))
162
+ return true;
163
+ if (cmd.includes(SESSION_END_OWNER_TAG))
164
+ return true;
165
+ if (cmd.includes(SESSION_START_OWNER_TAG))
166
+ return true;
167
+ if (cmd.includes(STOP_HOOK_OWNER_TAG))
168
+ return true;
169
+ // Legacy detection: any reference to gramatr-hook, gramatr-mcp,
170
+ // hook-userpromptsubmit, hook-sessionend, hook-sessionstart, hook-stop, or ~/.gramatr/ path.
171
+ return (cmd.includes("gramatr-hook") ||
172
+ cmd.includes("@gramatr/mcp") ||
173
+ cmd.includes(HOOK_SCRIPT_FILENAME) ||
174
+ cmd.includes(SESSION_END_SCRIPT_FILENAME) ||
175
+ cmd.includes(SESSION_START_SCRIPT_FILENAME) ||
176
+ cmd.includes(STOP_HOOK_SCRIPT_FILENAME) ||
177
+ cmd.includes(".gramatr/"));
178
+ }
179
+ /**
180
+ * Merge the UserPromptSubmit hook entry into the settings object. Returns
181
+ * { next, legacyRemoved } where legacyRemoved counts hook entries that
182
+ * referenced gramatr but were stale (different script path / event).
183
+ *
184
+ * Behaviour:
185
+ * - Removes ALL legacy gramatr-tagged commands across every hook event
186
+ * when removeLegacy=true (matches issue spec: 14-handler scaffold).
187
+ * - Inserts a single UserPromptSubmit entry pointing at our owned script.
188
+ * - No-ops when the same entry already exists with the same command.
189
+ */
190
+ export function mergeUserPromptSubmitHookIntoSettings(settings, home, removeLegacy) {
191
+ const cmd = buildHookCommand(home);
192
+ const hooks = { ...(settings.hooks ?? {}) };
193
+ let legacyRemoved = 0;
194
+ if (removeLegacy) {
195
+ for (const [event, entries] of Object.entries(hooks)) {
196
+ if (!Array.isArray(entries))
197
+ continue;
198
+ const filtered = [];
199
+ for (const entry of entries) {
200
+ const cmds = Array.isArray(entry.hooks) ? entry.hooks : [];
201
+ const keptCmds = cmds.filter((c) => {
202
+ const v = typeof c.command === "string" ? c.command : "";
203
+ if (!v)
204
+ return true;
205
+ if (isGramatrHookCommand(v)) {
206
+ legacyRemoved += 1;
207
+ return false;
208
+ }
209
+ return true;
210
+ });
211
+ if (keptCmds.length > 0) {
212
+ filtered.push({ ...entry, hooks: keptCmds });
213
+ }
214
+ else if (cmds.length === 0) {
215
+ filtered.push(entry);
216
+ }
217
+ }
218
+ if (filtered.length > 0) {
219
+ hooks[event] = filtered;
220
+ }
221
+ else {
222
+ delete hooks[event];
223
+ }
224
+ }
225
+ }
226
+ // Add our entry. Preserve any non-gramatr UserPromptSubmit entries.
227
+ const ups = Array.isArray(hooks.UserPromptSubmit)
228
+ ? [...hooks.UserPromptSubmit]
229
+ : [];
230
+ const alreadyPresent = ups.some((e) => (e.hooks ?? []).some((c) => c.command === cmd));
231
+ if (!alreadyPresent) {
232
+ ups.push({
233
+ matcher: "*",
234
+ hooks: [{ type: "command", command: cmd }],
235
+ });
236
+ }
237
+ hooks.UserPromptSubmit = ups;
238
+ // Add SessionEnd entry. Preserve any non-gramatr SessionEnd entries.
239
+ const seCmd = buildSessionEndHookCommand(home);
240
+ const seEntries = Array.isArray(hooks.SessionEnd)
241
+ ? [...hooks.SessionEnd]
242
+ : [];
243
+ const seAlreadyPresent = seEntries.some((e) => (e.hooks ?? []).some((c) => c.command === seCmd));
244
+ if (!seAlreadyPresent) {
245
+ seEntries.push({
246
+ matcher: "*",
247
+ hooks: [{ type: "command", command: seCmd }],
248
+ });
249
+ }
250
+ hooks.SessionEnd = seEntries;
251
+ // Add SessionStart entry. Preserve any non-gramatr SessionStart entries (#2475).
252
+ const ssCmd = buildSessionStartHookCommand(home);
253
+ const ssEntries = Array.isArray(hooks.SessionStart)
254
+ ? [...hooks.SessionStart]
255
+ : [];
256
+ const ssAlreadyPresent = ssEntries.some((e) => (e.hooks ?? []).some((c) => c.command === ssCmd));
257
+ if (!ssAlreadyPresent) {
258
+ ssEntries.push({
259
+ matcher: "*",
260
+ hooks: [{ type: "command", command: ssCmd }],
261
+ });
262
+ }
263
+ hooks.SessionStart = ssEntries;
264
+ // Add Stop entry. Preserve any non-gramatr Stop entries (#2476).
265
+ const stopCmd = buildStopHookCommand(home);
266
+ const stopEntries = Array.isArray(hooks.Stop)
267
+ ? [...hooks.Stop]
268
+ : [];
269
+ const stopAlreadyPresent = stopEntries.some((e) => (e.hooks ?? []).some((c) => c.command === stopCmd));
270
+ if (!stopAlreadyPresent) {
271
+ stopEntries.push({
272
+ matcher: "*",
273
+ hooks: [{ type: "command", command: stopCmd }],
274
+ });
275
+ }
276
+ hooks.Stop = stopEntries;
277
+ return { next: { ...settings, hooks }, legacyRemoved };
278
+ }
279
+ /**
280
+ * Inverse of mergeUserPromptSubmitHookIntoSettings — strip gramatr
281
+ * UserPromptSubmit entries (and any other gramatr-tagged hook commands
282
+ * left behind), preserve non-gramatr hooks intact.
283
+ */
284
+ export function removeUserPromptSubmitHookFromSettings(settings) {
285
+ const hooks = { ...(settings.hooks ?? {}) };
286
+ let removed = 0;
287
+ for (const [event, entries] of Object.entries(hooks)) {
288
+ if (!Array.isArray(entries))
289
+ continue;
290
+ const filtered = [];
291
+ for (const entry of entries) {
292
+ const cmds = Array.isArray(entry.hooks) ? entry.hooks : [];
293
+ const keptCmds = cmds.filter((c) => {
294
+ const v = typeof c.command === "string" ? c.command : "";
295
+ if (!v)
296
+ return true;
297
+ if (isGramatrHookCommand(v)) {
298
+ removed += 1;
299
+ return false;
300
+ }
301
+ return true;
302
+ });
303
+ if (keptCmds.length > 0) {
304
+ filtered.push({ ...entry, hooks: keptCmds });
305
+ }
306
+ else if (cmds.length === 0) {
307
+ filtered.push(entry);
308
+ }
309
+ }
310
+ if (filtered.length > 0) {
311
+ hooks[event] = filtered;
312
+ }
313
+ else {
314
+ delete hooks[event];
315
+ }
316
+ }
317
+ const next = { ...settings };
318
+ if (Object.keys(hooks).length > 0) {
319
+ next.hooks = hooks;
320
+ }
321
+ else {
322
+ delete next.hooks;
323
+ }
324
+ return { next, removed };
325
+ }
326
+ // ── CLAUDE.md sentinel block merge ─────────────────────────────────────────
327
+ /**
328
+ * Strip the meta-instruction preamble from the bundled doc so only the
329
+ * canonical section ships into ~/.claude/CLAUDE.md.
330
+ *
331
+ * The doc at docs/global-claude-md-gramatr-section.md begins with a
332
+ * "# gramatr global CLAUDE.md section" + intro paragraph + "---" rule.
333
+ * Everything after the `---` is the section body.
334
+ */
335
+ export function extractCanonicalSection(docText) {
336
+ const idx = docText.indexOf("\n---\n");
337
+ if (idx === -1)
338
+ return docText.trim();
339
+ return docText.slice(idx + 5).trim();
340
+ }
341
+ /** Sentinel-safe upsert into CLAUDE.md. Preserves all out-of-block content. */
342
+ export function upsertGramatrSection(existing, sectionBody) {
343
+ const block = `${GRAMATR_MD_START}\n${sectionBody.trim()}\n${GRAMATR_MD_END}`;
344
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
345
+ const re = new RegExp(`${escape(GRAMATR_MD_START)}[\\s\\S]*?${escape(GRAMATR_MD_END)}`, "m");
346
+ if (re.test(existing)) {
347
+ return existing.replace(re, block);
348
+ }
349
+ const trimmed = existing.trimEnd();
350
+ return trimmed ? `${trimmed}\n\n${block}\n` : `${block}\n`;
351
+ }
352
+ /** Sentinel-safe removal from CLAUDE.md. */
353
+ export function stripGramatrSection(existing) {
354
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
355
+ const re = new RegExp(`\\n*${escape(GRAMATR_MD_START)}[\\s\\S]*?${escape(GRAMATR_MD_END)}\\n*`, "m");
356
+ return existing.replace(re, "\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
357
+ }
358
+ function listMatching(dir, predicate) {
359
+ try {
360
+ return readdirSync(dir).filter(predicate).map((n) => join(dir, n));
361
+ }
362
+ catch {
363
+ return [];
364
+ }
365
+ }
366
+ function detectLegacyArtifacts(home) {
367
+ const out = [];
368
+ const gdir = gramatrDir(home);
369
+ const bin = join(gdir, "bin");
370
+ for (const p of listMatching(bin, (n) => n.startsWith("gramatr-hook")))
371
+ out.push(p);
372
+ for (const p of listMatching(gdir, (n) => n.startsWith("state.db")))
373
+ out.push(p);
374
+ const debug = join(gdir, "debug");
375
+ if (existsSync(debug))
376
+ out.push(debug);
377
+ const cdir = join(home, ".claude");
378
+ for (const p of listMatching(cdir, (n) => /^mcp-needs-auth-cache\.json\.bak-/.test(n))) {
379
+ out.push(p);
380
+ }
381
+ return out;
382
+ }
383
+ function removeLegacyArtifacts(paths, dryRun) {
384
+ if (dryRun)
385
+ return { removed: paths };
386
+ const removed = [];
387
+ for (const p of paths) {
388
+ try {
389
+ const s = statSync(p);
390
+ if (s.isDirectory()) {
391
+ rmSync(p, { recursive: true, force: true });
392
+ }
393
+ else {
394
+ unlinkSync(p);
395
+ }
396
+ removed.push(p);
397
+ }
398
+ catch {
399
+ /* best-effort */
400
+ }
401
+ }
402
+ return { removed };
403
+ }
404
+ // ── auth helpers ──────────────────────────────────────────────────────────
405
+ export function hasValidToken(home) {
406
+ const path = gramatrTokenPath(home);
407
+ try {
408
+ const raw = readFileSync(path, "utf8");
409
+ const parsed = JSON.parse(raw);
410
+ return typeof parsed.token === "string" && parsed.token.length > 0;
411
+ }
412
+ catch {
413
+ return false;
414
+ }
415
+ }
416
+ // ── install / uninstall orchestrators ─────────────────────────────────────
417
+ export async function install(opts) {
418
+ const log = opts.log ?? ((m) => process.stderr.write(`${m}\n`));
419
+ const dryRun = !!opts.dryRun;
420
+ const cleanLegacy = !!opts.cleanLegacy || !!opts.nonInteractive;
421
+ const summary = {
422
+ hookScriptWritten: false,
423
+ sessionEndScriptWritten: false,
424
+ sessionStartScriptWritten: false,
425
+ stopScriptWritten: false,
426
+ settingsUpdated: false,
427
+ claudeMdUpdated: false,
428
+ legacyEntriesRemoved: 0,
429
+ legacyFilesRemoved: [],
430
+ backups: [],
431
+ };
432
+ // 1. Hook script
433
+ const hookSrc = opts.hookSourcePath ?? resolveBundledHookSource();
434
+ const hookDst = gramatrHookScriptPath(opts.home);
435
+ const desiredHook = readFileOr(hookSrc, null);
436
+ if (desiredHook === null) {
437
+ log(`[gramatr] hook source missing: ${hookSrc}`);
438
+ }
439
+ else {
440
+ const currentHook = readFileOr(hookDst, null);
441
+ if (currentHook !== desiredHook) {
442
+ if (!dryRun) {
443
+ ensureDir(dirname(hookDst));
444
+ atomicWriteFile(hookDst, desiredHook, 0o755);
445
+ try {
446
+ chmodSync(hookDst, 0o755);
447
+ }
448
+ catch {
449
+ /* mode flag on write covered most cases */
450
+ }
451
+ }
452
+ summary.hookScriptWritten = true;
453
+ log(`[gramatr] hook script ${dryRun ? "(dry-run) " : ""}→ ${hookDst}`);
454
+ }
455
+ }
456
+ // 1b. SessionEnd hook script
457
+ const seHookSrc = opts.sessionEndHookSourcePath ?? resolveBundledSessionEndHookSource();
458
+ const seHookDst = gramatrSessionEndScriptPath(opts.home);
459
+ const seDesired = readFileOr(seHookSrc, null);
460
+ if (seDesired === null) {
461
+ log(`[gramatr] SessionEnd hook source missing: ${seHookSrc}`);
462
+ }
463
+ else {
464
+ const current = readFileOr(seHookDst, null);
465
+ if (current !== seDesired) {
466
+ if (!dryRun) {
467
+ ensureDir(dirname(seHookDst));
468
+ atomicWriteFile(seHookDst, seDesired, 0o755);
469
+ try {
470
+ chmodSync(seHookDst, 0o755);
471
+ }
472
+ catch {
473
+ /* mode flag on write covered most cases */
474
+ }
475
+ }
476
+ summary.sessionEndScriptWritten = true;
477
+ log(`[gramatr] SessionEnd hook script ${dryRun ? "(dry-run) " : ""}→ ${seHookDst}`);
478
+ }
479
+ }
480
+ // 1c. SessionStart hook script (#2475)
481
+ const ssHookSrc = opts.sessionStartHookSourcePath ?? resolveBundledSessionStartHookSource();
482
+ const ssHookDst = gramatrSessionStartScriptPath(opts.home);
483
+ const ssDesired = readFileOr(ssHookSrc, null);
484
+ if (ssDesired === null) {
485
+ log(`[gramatr] SessionStart hook source missing: ${ssHookSrc}`);
486
+ }
487
+ else {
488
+ const current = readFileOr(ssHookDst, null);
489
+ if (current !== ssDesired) {
490
+ if (!dryRun) {
491
+ ensureDir(dirname(ssHookDst));
492
+ atomicWriteFile(ssHookDst, ssDesired, 0o755);
493
+ try {
494
+ chmodSync(ssHookDst, 0o755);
495
+ }
496
+ catch {
497
+ /* mode flag on write covered most cases */
498
+ }
499
+ }
500
+ summary.sessionStartScriptWritten = true;
501
+ log(`[gramatr] SessionStart hook script ${dryRun ? "(dry-run) " : ""}→ ${ssHookDst}`);
502
+ }
503
+ }
504
+ // 1d. Stop hook script (#2476)
505
+ const stopHookSrc = opts.stopHookSourcePath ?? resolveBundledStopHookSource();
506
+ const stopHookDst = gramatrStopScriptPath(opts.home);
507
+ const stopDesired = readFileOr(stopHookSrc, null);
508
+ if (stopDesired === null) {
509
+ log(`[gramatr] Stop hook source missing: ${stopHookSrc}`);
510
+ }
511
+ else {
512
+ const current = readFileOr(stopHookDst, null);
513
+ if (current !== stopDesired) {
514
+ if (!dryRun) {
515
+ ensureDir(dirname(stopHookDst));
516
+ atomicWriteFile(stopHookDst, stopDesired, 0o755);
517
+ try {
518
+ chmodSync(stopHookDst, 0o755);
519
+ }
520
+ catch {
521
+ /* mode flag on write covered most cases */
522
+ }
523
+ }
524
+ summary.stopScriptWritten = true;
525
+ log(`[gramatr] Stop hook script ${dryRun ? "(dry-run) " : ""}→ ${stopHookDst}`);
526
+ }
527
+ }
528
+ // 2. settings.json
529
+ const settingsPath = claudeSettingsPath(opts.home);
530
+ const settingsRaw = readFileOr(settingsPath, null);
531
+ const settings = parseJsonOr(settingsRaw, {});
532
+ const merged = mergeUserPromptSubmitHookIntoSettings(settings, opts.home, cleanLegacy);
533
+ summary.legacyEntriesRemoved = merged.legacyRemoved;
534
+ const desiredSettings = JSON.stringify(merged.next, null, 2) + "\n";
535
+ const currentSettingsText = settingsRaw ?? "";
536
+ if (desiredSettings !== currentSettingsText) {
537
+ if (!dryRun) {
538
+ const bak = backupFile(settingsPath);
539
+ if (bak)
540
+ summary.backups.push(bak);
541
+ atomicWriteFile(settingsPath, desiredSettings, 0o600);
542
+ }
543
+ summary.settingsUpdated = true;
544
+ log(`[gramatr] settings.json ${dryRun ? "(dry-run) " : ""}→ UserPromptSubmit hook merged` +
545
+ (merged.legacyRemoved > 0
546
+ ? ` (removed ${merged.legacyRemoved} legacy entr${merged.legacyRemoved === 1 ? "y" : "ies"})`
547
+ : ""));
548
+ }
549
+ // 3. CLAUDE.md
550
+ const mdPath = claudeMarkdownPath(opts.home);
551
+ const sectionBody = opts.claudeMdSection ?? loadCanonicalSection();
552
+ const currentMd = readFileOr(mdPath, "");
553
+ const desiredMd = upsertGramatrSection(currentMd, sectionBody);
554
+ if (desiredMd !== currentMd) {
555
+ if (!dryRun) {
556
+ const bak = backupFile(mdPath);
557
+ if (bak)
558
+ summary.backups.push(bak);
559
+ atomicWriteFile(mdPath, desiredMd, 0o644);
560
+ }
561
+ summary.claudeMdUpdated = true;
562
+ log(`[gramatr] CLAUDE.md ${dryRun ? "(dry-run) " : ""}→ sentinel block upserted`);
563
+ }
564
+ // 4. Legacy cruft (only with --clean-legacy / non-interactive — we never
565
+ // delete files behind the user's back in interactive runs).
566
+ if (cleanLegacy) {
567
+ const legacy = detectLegacyArtifacts(opts.home);
568
+ if (legacy.length > 0) {
569
+ const res = removeLegacyArtifacts(legacy, dryRun);
570
+ summary.legacyFilesRemoved = res.removed;
571
+ for (const p of res.removed)
572
+ log(`[gramatr] cleaned ${dryRun ? "(dry-run) " : ""}${p}`);
573
+ }
574
+ }
575
+ else {
576
+ const legacy = detectLegacyArtifacts(opts.home);
577
+ if (legacy.length > 0) {
578
+ log(`[gramatr] note: detected ${legacy.length} legacy artifact${legacy.length === 1 ? "" : "s"}; rerun with --clean-legacy to remove`);
579
+ }
580
+ }
581
+ return summary;
582
+ }
583
+ export async function uninstall(opts) {
584
+ const log = opts.log ?? ((m) => process.stderr.write(`${m}\n`));
585
+ const dryRun = !!opts.dryRun;
586
+ const summary = {
587
+ hookEntryRemoved: false,
588
+ claudeMdSectionRemoved: false,
589
+ hookScriptRemoved: false,
590
+ sessionEndScriptRemoved: false,
591
+ sessionStartScriptRemoved: false,
592
+ stopScriptRemoved: false,
593
+ tokenRemoved: false,
594
+ backups: [],
595
+ };
596
+ // 1. settings.json
597
+ const settingsPath = claudeSettingsPath(opts.home);
598
+ const settingsRaw = readFileOr(settingsPath, null);
599
+ if (settingsRaw !== null) {
600
+ const settings = parseJsonOr(settingsRaw, {});
601
+ const stripped = removeUserPromptSubmitHookFromSettings(settings);
602
+ if (stripped.removed > 0) {
603
+ const next = JSON.stringify(stripped.next, null, 2) + "\n";
604
+ if (!dryRun) {
605
+ const bak = backupFile(settingsPath);
606
+ if (bak)
607
+ summary.backups.push(bak);
608
+ atomicWriteFile(settingsPath, next, 0o600);
609
+ }
610
+ summary.hookEntryRemoved = true;
611
+ log(`[gramatr] settings.json ${dryRun ? "(dry-run) " : ""}→ removed ${stripped.removed} gramatr hook entr${stripped.removed === 1 ? "y" : "ies"}`);
612
+ }
613
+ }
614
+ // 2. CLAUDE.md
615
+ const mdPath = claudeMarkdownPath(opts.home);
616
+ const currentMd = readFileOr(mdPath, null);
617
+ if (currentMd !== null && currentMd.includes(GRAMATR_MD_START)) {
618
+ const next = stripGramatrSection(currentMd);
619
+ if (next !== currentMd) {
620
+ if (!dryRun) {
621
+ const bak = backupFile(mdPath);
622
+ if (bak)
623
+ summary.backups.push(bak);
624
+ atomicWriteFile(mdPath, next, 0o644);
625
+ }
626
+ summary.claudeMdSectionRemoved = true;
627
+ log(`[gramatr] CLAUDE.md ${dryRun ? "(dry-run) " : ""}→ sentinel block removed`);
628
+ }
629
+ }
630
+ // 3. hook script
631
+ const hookDst = gramatrHookScriptPath(opts.home);
632
+ if (existsSync(hookDst)) {
633
+ if (!dryRun) {
634
+ const bak = backupFile(hookDst);
635
+ if (bak)
636
+ summary.backups.push(bak);
637
+ unlinkSync(hookDst);
638
+ }
639
+ summary.hookScriptRemoved = true;
640
+ log(`[gramatr] hook script ${dryRun ? "(dry-run) " : ""}→ removed ${hookDst}`);
641
+ }
642
+ // 3b. SessionEnd hook script
643
+ const seHookDst = gramatrSessionEndScriptPath(opts.home);
644
+ if (existsSync(seHookDst)) {
645
+ if (!dryRun) {
646
+ const bak = backupFile(seHookDst);
647
+ if (bak)
648
+ summary.backups.push(bak);
649
+ unlinkSync(seHookDst);
650
+ }
651
+ summary.sessionEndScriptRemoved = true;
652
+ log(`[gramatr] SessionEnd hook script ${dryRun ? "(dry-run) " : ""}→ removed ${seHookDst}`);
653
+ }
654
+ // 3c. SessionStart hook script (#2475)
655
+ const ssHookDst = gramatrSessionStartScriptPath(opts.home);
656
+ if (existsSync(ssHookDst)) {
657
+ if (!dryRun) {
658
+ const bak = backupFile(ssHookDst);
659
+ if (bak)
660
+ summary.backups.push(bak);
661
+ unlinkSync(ssHookDst);
662
+ }
663
+ summary.sessionStartScriptRemoved = true;
664
+ log(`[gramatr] SessionStart hook script ${dryRun ? "(dry-run) " : ""}→ removed ${ssHookDst}`);
665
+ }
666
+ // 3d. Stop hook script (#2476)
667
+ const stopHookDst = gramatrStopScriptPath(opts.home);
668
+ if (existsSync(stopHookDst)) {
669
+ if (!dryRun) {
670
+ const bak = backupFile(stopHookDst);
671
+ if (bak)
672
+ summary.backups.push(bak);
673
+ unlinkSync(stopHookDst);
674
+ }
675
+ summary.stopScriptRemoved = true;
676
+ log(`[gramatr] Stop hook script ${dryRun ? "(dry-run) " : ""}→ removed ${stopHookDst}`);
677
+ }
678
+ // 4. token (only on --purge)
679
+ if (opts.purge) {
680
+ const tokPath = gramatrTokenPath(opts.home);
681
+ if (existsSync(tokPath)) {
682
+ if (!dryRun) {
683
+ const bak = backupFile(tokPath);
684
+ if (bak)
685
+ summary.backups.push(bak);
686
+ unlinkSync(tokPath);
687
+ }
688
+ summary.tokenRemoved = true;
689
+ log(`[gramatr] token ${dryRun ? "(dry-run) " : ""}→ removed ${tokPath}`);
690
+ }
691
+ }
692
+ return summary;
693
+ }
694
+ // ── canonical section loader ──────────────────────────────────────────────
695
+ /** Locate the canonical section doc that ships in the repo. */
696
+ function loadCanonicalSection() {
697
+ const here = fileURLToPath(import.meta.url);
698
+ const candidates = [
699
+ // From compiled dist/bin/install.js — walk up to repo root.
700
+ resolve(dirname(here), "..", "..", "..", "..", "docs", "global-claude-md-gramatr-section.md"),
701
+ // From source src/bin/install.ts in dev.
702
+ resolve(dirname(here), "..", "..", "..", "..", "docs", "global-claude-md-gramatr-section.md"),
703
+ // Packaged inside the npm tarball (alongside scripts/).
704
+ resolve(dirname(here), "..", "..", "docs", "global-claude-md-gramatr-section.md"),
705
+ ];
706
+ for (const c of candidates) {
707
+ if (existsSync(c))
708
+ return extractCanonicalSection(readFileSync(c, "utf8"));
709
+ }
710
+ // Fallback: minimal inline copy so install still works if the doc
711
+ // wasn't bundled into the npm tarball.
712
+ return INLINE_FALLBACK_SECTION;
713
+ }
714
+ const INLINE_FALLBACK_SECTION = `## gramatr classification block — agent contract
715
+
716
+ If a \`<gramatr-classification>...</gramatr-classification>\` block appears in this turn's context, parse the JSON inside it and follow this protocol literally.
717
+
718
+ - \`call_route_request\`: invoke \`mcp__gramatr__route_request\` with \`directive.params_for_route_request\` verbatim.
719
+ - \`respond_directly\`: answer using only the block's classification + context.
720
+
721
+ After substantive work, call \`classification_feedback\` with \`was_correct\` based on whether the classification matched the actual work.
722
+ `;
723
+ // ── CLI entrypoints ───────────────────────────────────────────────────────
724
+ function resolveHome() {
725
+ // gramatr-allow: C1 — CLI entry point, reads HOME for config path
726
+ return process.env.HOME || process.env.USERPROFILE || "";
727
+ }
728
+ export async function runInstallCli(argv) {
729
+ const home = resolveHome();
730
+ if (!home) {
731
+ process.stderr.write("[gramatr] HOME is not set; cannot install.\n");
732
+ return 1;
733
+ }
734
+ const opts = {
735
+ home,
736
+ cleanLegacy: argv.includes("--clean-legacy"),
737
+ nonInteractive: argv.includes("--non-interactive") || argv.includes("--yes") || argv.includes("-y"),
738
+ dryRun: argv.includes("--dry-run"),
739
+ };
740
+ // Auth check — defer to existing login flow when no token is present and
741
+ // we're allowed to be interactive. We don't run device-flow in dry-run.
742
+ if (!hasValidToken(home) && !opts.dryRun) {
743
+ try {
744
+ const { loginBrowser } = await import("./login.js");
745
+ process.stderr.write("[gramatr] no token found; running login...\n");
746
+ await loginBrowser();
747
+ }
748
+ catch (err) {
749
+ const detail = err instanceof Error ? err.message : String(err);
750
+ process.stderr.write(`[gramatr] login failed: ${detail}\n`);
751
+ process.stderr.write("[gramatr] proceeding with install — re-run `gramatr login` later.\n");
752
+ }
753
+ }
754
+ const summary = await install(opts);
755
+ process.stderr.write("\n[gramatr] install summary:\n");
756
+ process.stderr.write(` hook script: ${summary.hookScriptWritten ? "written" : "up-to-date"}\n`);
757
+ process.stderr.write(` SessionEnd hook script: ${summary.sessionEndScriptWritten ? "written" : "up-to-date"}\n`);
758
+ process.stderr.write(` SessionStart hook script: ${summary.sessionStartScriptWritten ? "written" : "up-to-date"}\n`);
759
+ process.stderr.write(` Stop hook script: ${summary.stopScriptWritten ? "written" : "up-to-date"}\n`);
760
+ process.stderr.write(` settings.json: ${summary.settingsUpdated ? "updated" : "up-to-date"}\n`);
761
+ process.stderr.write(` CLAUDE.md: ${summary.claudeMdUpdated ? "updated" : "up-to-date"}\n`);
762
+ if (summary.legacyEntriesRemoved > 0) {
763
+ process.stderr.write(` legacy hook entries removed: ${summary.legacyEntriesRemoved}\n`);
764
+ }
765
+ if (summary.legacyFilesRemoved.length > 0) {
766
+ process.stderr.write(` legacy files removed: ${summary.legacyFilesRemoved.length}\n`);
767
+ }
768
+ if (summary.backups.length > 0) {
769
+ process.stderr.write(` backups: ${summary.backups.length}\n`);
770
+ for (const b of summary.backups)
771
+ process.stderr.write(` ${b}\n`);
772
+ }
773
+ process.stderr.write("\n Restart Claude Code to activate.\n");
774
+ return 0;
775
+ }
776
+ export async function runUninstallCli(argv) {
777
+ const home = resolveHome();
778
+ if (!home) {
779
+ process.stderr.write("[gramatr] HOME is not set; cannot uninstall.\n");
780
+ return 1;
781
+ }
782
+ const summary = await uninstall({
783
+ home,
784
+ purge: argv.includes("--purge"),
785
+ dryRun: argv.includes("--dry-run"),
786
+ });
787
+ process.stderr.write("\n[gramatr] uninstall summary:\n");
788
+ process.stderr.write(` hook entry: ${summary.hookEntryRemoved ? "removed" : "not present"}\n`);
789
+ process.stderr.write(` CLAUDE.md section: ${summary.claudeMdSectionRemoved ? "removed" : "not present"}\n`);
790
+ process.stderr.write(` hook script: ${summary.hookScriptRemoved ? "removed" : "not present"}\n`);
791
+ process.stderr.write(` SessionEnd hook script: ${summary.sessionEndScriptRemoved ? "removed" : "not present"}\n`);
792
+ process.stderr.write(` SessionStart hook script: ${summary.sessionStartScriptRemoved ? "removed" : "not present"}\n`);
793
+ process.stderr.write(` Stop hook script: ${summary.stopScriptRemoved ? "removed" : "not present"}\n`);
794
+ if (summary.tokenRemoved)
795
+ process.stderr.write(` token: removed\n`);
796
+ if (summary.backups.length > 0) {
797
+ process.stderr.write(` backups: ${summary.backups.length}\n`);
798
+ }
799
+ return 0;
800
+ }
801
+ //# sourceMappingURL=install.js.map