@gramatr/mcp 0.13.127 → 0.13.129

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,1157 @@
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, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
19
+ import { platform as osPlatform } from "node:os";
20
+ import { dirname, join, resolve } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import { buildConnectorInstructions, buildPromptSuggestion } from "../setup/web-connector.js";
23
+ /** Sentinel pair carved into ~/.claude/CLAUDE.md. */
24
+ export const GRAMATR_MD_START = "<!-- GRAMATR-START -->";
25
+ export const GRAMATR_MD_END = "<!-- GRAMATR-END -->";
26
+ /** Comment marker we insert on the UserPromptSubmit hook command so we can
27
+ * identify a gramatr-owned entry without depending on the script path
28
+ * (paths drift across machines — markers don't). */
29
+ const HOOK_OWNER_TAG = "# gramatr-managed: UserPromptSubmit";
30
+ const SESSION_END_OWNER_TAG = "# gramatr-managed: SessionEnd";
31
+ const SESSION_START_OWNER_TAG = "# gramatr-managed: SessionStart";
32
+ const STOP_HOOK_OWNER_TAG = "# gramatr-managed: Stop";
33
+ const HOOK_SCRIPT_FILENAME = "hook-userpromptsubmit.sh";
34
+ const HOOK_REL_PATH = join("scripts", HOOK_SCRIPT_FILENAME);
35
+ const SESSION_END_SCRIPT_FILENAME = "hook-sessionend.sh";
36
+ const SESSION_END_REL_PATH = join("scripts", SESSION_END_SCRIPT_FILENAME);
37
+ const SESSION_START_SCRIPT_FILENAME = "hook-sessionstart.sh";
38
+ const SESSION_START_REL_PATH = join("scripts", SESSION_START_SCRIPT_FILENAME);
39
+ const STOP_HOOK_SCRIPT_FILENAME = "hook-stop.sh";
40
+ const STOP_HOOK_REL_PATH = join("scripts", STOP_HOOK_SCRIPT_FILENAME);
41
+ // ── path helpers (parameterized by home so tests can use tmpdir) ────────────
42
+ export function claudeSettingsPath(home) {
43
+ return join(home, ".claude", "settings.json");
44
+ }
45
+ export function claudeMarkdownPath(home) {
46
+ return join(home, ".claude", "CLAUDE.md");
47
+ }
48
+ export function gramatrDir(home) {
49
+ return join(home, ".gramatr");
50
+ }
51
+ export function gramatrHookScriptPath(home) {
52
+ return join(gramatrDir(home), HOOK_REL_PATH);
53
+ }
54
+ export function gramatrSessionEndScriptPath(home) {
55
+ return join(gramatrDir(home), SESSION_END_REL_PATH);
56
+ }
57
+ export function gramatrSessionStartScriptPath(home) {
58
+ return join(gramatrDir(home), SESSION_START_REL_PATH);
59
+ }
60
+ export function gramatrStopScriptPath(home) {
61
+ return join(gramatrDir(home), STOP_HOOK_REL_PATH);
62
+ }
63
+ export function gramatrTokenPath(home) {
64
+ return join(home, ".gramatr.json");
65
+ }
66
+ /** Path to Claude Code slash-command directory. */
67
+ export function claudeCommandsDir(home) {
68
+ return join(home, ".claude", "commands");
69
+ }
70
+ /**
71
+ * Resolve the claude_desktop_config.json path for the current platform.
72
+ * macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
73
+ * Windows: %APPDATA%\Claude\claude_desktop_config.json
74
+ * Linux: ~/.config/Claude/claude_desktop_config.json
75
+ */
76
+ export function claudeDesktopConfigPath(home, platform = osPlatform()) {
77
+ if (platform === "darwin") {
78
+ return join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
79
+ }
80
+ if (platform === "win32") {
81
+ // gramatr-allow: C1 — Windows fallback for APPDATA when not present
82
+ const appData = process.env.APPDATA || join(home, "AppData", "Roaming");
83
+ return join(appData, "Claude", "claude_desktop_config.json");
84
+ }
85
+ return join(home, ".config", "Claude", "claude_desktop_config.json");
86
+ }
87
+ /** Resolve the bundled SessionEnd hook script source inside @gramatr/mcp. */
88
+ export function resolveBundledSessionEndHookSource() {
89
+ const here = fileURLToPath(import.meta.url);
90
+ const pkgRoot = resolve(dirname(here), "..", "..");
91
+ const candidate = join(pkgRoot, "scripts", SESSION_END_SCRIPT_FILENAME);
92
+ if (existsSync(candidate))
93
+ return candidate;
94
+ const devCandidate = resolve(dirname(here), "..", "..", "scripts", SESSION_END_SCRIPT_FILENAME);
95
+ return devCandidate;
96
+ }
97
+ /** Resolve the bundled SessionStart hook script source inside @gramatr/mcp (#2475). */
98
+ export function resolveBundledSessionStartHookSource() {
99
+ const here = fileURLToPath(import.meta.url);
100
+ const pkgRoot = resolve(dirname(here), "..", "..");
101
+ const candidate = join(pkgRoot, "scripts", SESSION_START_SCRIPT_FILENAME);
102
+ if (existsSync(candidate))
103
+ return candidate;
104
+ const devCandidate = resolve(dirname(here), "..", "..", "scripts", SESSION_START_SCRIPT_FILENAME);
105
+ return devCandidate;
106
+ }
107
+ /** Resolve the bundled Stop hook script source inside @gramatr/mcp (#2476). */
108
+ export function resolveBundledStopHookSource() {
109
+ const here = fileURLToPath(import.meta.url);
110
+ const pkgRoot = resolve(dirname(here), "..", "..");
111
+ const candidate = join(pkgRoot, "scripts", STOP_HOOK_SCRIPT_FILENAME);
112
+ if (existsSync(candidate))
113
+ return candidate;
114
+ const devCandidate = resolve(dirname(here), "..", "..", "scripts", STOP_HOOK_SCRIPT_FILENAME);
115
+ return devCandidate;
116
+ }
117
+ /** Resolve the bundled hook script source inside the @gramatr/mcp package. */
118
+ export function resolveBundledHookSource() {
119
+ // Compiled output lives at packages/mcp/dist/bin/install.js.
120
+ // Source lives at packages/mcp/src/bin/install.ts.
121
+ // The script ships from packages/mcp/scripts/hook-userpromptsubmit.sh
122
+ // and is included in the npm tarball via the package "files" array.
123
+ const here = fileURLToPath(import.meta.url);
124
+ // Walk up to the package root (two parents from dist/bin/install.js,
125
+ // and src/bin/install.ts both land at packages/mcp/).
126
+ const pkgRoot = resolve(dirname(here), "..", "..");
127
+ const candidate = join(pkgRoot, "scripts", HOOK_SCRIPT_FILENAME);
128
+ if (existsSync(candidate))
129
+ return candidate;
130
+ // Fallback for ts-run during development.
131
+ const devCandidate = resolve(dirname(here), "..", "..", "scripts", HOOK_SCRIPT_FILENAME);
132
+ return devCandidate;
133
+ }
134
+ // ── atomic IO ──────────────────────────────────────────────────────────────
135
+ function ensureDir(path) {
136
+ mkdirSync(path, { recursive: true });
137
+ }
138
+ function readFileOr(path, fallback) {
139
+ try {
140
+ return readFileSync(path, "utf8");
141
+ }
142
+ catch {
143
+ return fallback;
144
+ }
145
+ }
146
+ function parseJsonOr(raw, fallback) {
147
+ if (raw === null)
148
+ return fallback;
149
+ try {
150
+ const parsed = JSON.parse(raw);
151
+ return (parsed ?? fallback);
152
+ }
153
+ catch {
154
+ return fallback;
155
+ }
156
+ }
157
+ function atomicWriteFile(path, content, mode = 0o644) {
158
+ ensureDir(dirname(path));
159
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
160
+ writeFileSync(tmp, content, { mode });
161
+ renameSync(tmp, path);
162
+ }
163
+ function backupFile(path) {
164
+ if (!existsSync(path))
165
+ return null;
166
+ const ts = Math.floor(Date.now() / 1000);
167
+ const dest = `${path}.bak-${ts}`;
168
+ copyFileSync(path, dest);
169
+ return dest;
170
+ }
171
+ export function buildHookCommand(home) {
172
+ return `${gramatrHookScriptPath(home)} ${HOOK_OWNER_TAG}`;
173
+ }
174
+ export function buildSessionEndHookCommand(home) {
175
+ return `${gramatrSessionEndScriptPath(home)} ${SESSION_END_OWNER_TAG}`;
176
+ }
177
+ export function buildSessionStartHookCommand(home) {
178
+ return `${gramatrSessionStartScriptPath(home)} ${SESSION_START_OWNER_TAG}`;
179
+ }
180
+ export function buildStopHookCommand(home) {
181
+ return `${gramatrStopScriptPath(home)} ${STOP_HOOK_OWNER_TAG}`;
182
+ }
183
+ function isGramatrHookCommand(cmd) {
184
+ if (cmd.includes(HOOK_OWNER_TAG))
185
+ return true;
186
+ if (cmd.includes(SESSION_END_OWNER_TAG))
187
+ return true;
188
+ if (cmd.includes(SESSION_START_OWNER_TAG))
189
+ return true;
190
+ if (cmd.includes(STOP_HOOK_OWNER_TAG))
191
+ return true;
192
+ // Legacy detection: any reference to gramatr-hook, gramatr-mcp,
193
+ // hook-userpromptsubmit, hook-sessionend, hook-sessionstart, hook-stop, or ~/.gramatr/ path.
194
+ return (cmd.includes("gramatr-hook") ||
195
+ cmd.includes("@gramatr/mcp") ||
196
+ cmd.includes(HOOK_SCRIPT_FILENAME) ||
197
+ cmd.includes(SESSION_END_SCRIPT_FILENAME) ||
198
+ cmd.includes(SESSION_START_SCRIPT_FILENAME) ||
199
+ cmd.includes(STOP_HOOK_SCRIPT_FILENAME) ||
200
+ cmd.includes(".gramatr/"));
201
+ }
202
+ /**
203
+ * Merge the UserPromptSubmit hook entry into the settings object. Returns
204
+ * { next, legacyRemoved } where legacyRemoved counts hook entries that
205
+ * referenced gramatr but were stale (different script path / event).
206
+ *
207
+ * Behaviour:
208
+ * - Removes ALL legacy gramatr-tagged commands across every hook event
209
+ * when removeLegacy=true (matches issue spec: 14-handler scaffold).
210
+ * - Inserts a single UserPromptSubmit entry pointing at our owned script.
211
+ * - No-ops when the same entry already exists with the same command.
212
+ */
213
+ export function mergeUserPromptSubmitHookIntoSettings(settings, home, removeLegacy) {
214
+ const cmd = buildHookCommand(home);
215
+ const hooks = { ...(settings.hooks ?? {}) };
216
+ let legacyRemoved = 0;
217
+ if (removeLegacy) {
218
+ for (const [event, entries] of Object.entries(hooks)) {
219
+ if (!Array.isArray(entries))
220
+ continue;
221
+ const filtered = [];
222
+ for (const entry of entries) {
223
+ const cmds = Array.isArray(entry.hooks) ? entry.hooks : [];
224
+ const keptCmds = cmds.filter((c) => {
225
+ const v = typeof c.command === "string" ? c.command : "";
226
+ if (!v)
227
+ return true;
228
+ if (isGramatrHookCommand(v)) {
229
+ legacyRemoved += 1;
230
+ return false;
231
+ }
232
+ return true;
233
+ });
234
+ if (keptCmds.length > 0) {
235
+ filtered.push({ ...entry, hooks: keptCmds });
236
+ }
237
+ else if (cmds.length === 0) {
238
+ filtered.push(entry);
239
+ }
240
+ }
241
+ if (filtered.length > 0) {
242
+ hooks[event] = filtered;
243
+ }
244
+ else {
245
+ delete hooks[event];
246
+ }
247
+ }
248
+ }
249
+ // Add our entry. Preserve any non-gramatr UserPromptSubmit entries.
250
+ const ups = Array.isArray(hooks.UserPromptSubmit) ? [...hooks.UserPromptSubmit] : [];
251
+ const alreadyPresent = ups.some((e) => (e.hooks ?? []).some((c) => c.command === cmd));
252
+ if (!alreadyPresent) {
253
+ ups.push({
254
+ matcher: "*",
255
+ hooks: [{ type: "command", command: cmd }],
256
+ });
257
+ }
258
+ hooks.UserPromptSubmit = ups;
259
+ // Add SessionEnd entry. Preserve any non-gramatr SessionEnd entries.
260
+ const seCmd = buildSessionEndHookCommand(home);
261
+ const seEntries = Array.isArray(hooks.SessionEnd) ? [...hooks.SessionEnd] : [];
262
+ const seAlreadyPresent = seEntries.some((e) => (e.hooks ?? []).some((c) => c.command === seCmd));
263
+ if (!seAlreadyPresent) {
264
+ seEntries.push({
265
+ matcher: "*",
266
+ hooks: [{ type: "command", command: seCmd }],
267
+ });
268
+ }
269
+ hooks.SessionEnd = seEntries;
270
+ // Add SessionStart entry. Preserve any non-gramatr SessionStart entries (#2475).
271
+ const ssCmd = buildSessionStartHookCommand(home);
272
+ const ssEntries = Array.isArray(hooks.SessionStart) ? [...hooks.SessionStart] : [];
273
+ const ssAlreadyPresent = ssEntries.some((e) => (e.hooks ?? []).some((c) => c.command === ssCmd));
274
+ if (!ssAlreadyPresent) {
275
+ ssEntries.push({
276
+ matcher: "*",
277
+ hooks: [{ type: "command", command: ssCmd }],
278
+ });
279
+ }
280
+ hooks.SessionStart = ssEntries;
281
+ // Add Stop entry. Preserve any non-gramatr Stop entries (#2476).
282
+ const stopCmd = buildStopHookCommand(home);
283
+ const stopEntries = Array.isArray(hooks.Stop) ? [...hooks.Stop] : [];
284
+ const stopAlreadyPresent = stopEntries.some((e) => (e.hooks ?? []).some((c) => c.command === stopCmd));
285
+ if (!stopAlreadyPresent) {
286
+ stopEntries.push({
287
+ matcher: "*",
288
+ hooks: [{ type: "command", command: stopCmd }],
289
+ });
290
+ }
291
+ hooks.Stop = stopEntries;
292
+ return { next: { ...settings, hooks }, legacyRemoved };
293
+ }
294
+ /**
295
+ * Inverse of mergeUserPromptSubmitHookIntoSettings — strip gramatr
296
+ * UserPromptSubmit entries (and any other gramatr-tagged hook commands
297
+ * left behind), preserve non-gramatr hooks intact.
298
+ */
299
+ export function removeUserPromptSubmitHookFromSettings(settings) {
300
+ const hooks = { ...(settings.hooks ?? {}) };
301
+ let removed = 0;
302
+ for (const [event, entries] of Object.entries(hooks)) {
303
+ if (!Array.isArray(entries))
304
+ continue;
305
+ const filtered = [];
306
+ for (const entry of entries) {
307
+ const cmds = Array.isArray(entry.hooks) ? entry.hooks : [];
308
+ const keptCmds = cmds.filter((c) => {
309
+ const v = typeof c.command === "string" ? c.command : "";
310
+ if (!v)
311
+ return true;
312
+ if (isGramatrHookCommand(v)) {
313
+ removed += 1;
314
+ return false;
315
+ }
316
+ return true;
317
+ });
318
+ if (keptCmds.length > 0) {
319
+ filtered.push({ ...entry, hooks: keptCmds });
320
+ }
321
+ else if (cmds.length === 0) {
322
+ filtered.push(entry);
323
+ }
324
+ }
325
+ if (filtered.length > 0) {
326
+ hooks[event] = filtered;
327
+ }
328
+ else {
329
+ delete hooks[event];
330
+ }
331
+ }
332
+ const next = { ...settings };
333
+ if (Object.keys(hooks).length > 0) {
334
+ next.hooks = hooks;
335
+ }
336
+ else {
337
+ delete next.hooks;
338
+ }
339
+ return { next, removed };
340
+ }
341
+ // ── CLAUDE.md sentinel block merge ─────────────────────────────────────────
342
+ /**
343
+ * Strip the meta-instruction preamble from the bundled doc so only the
344
+ * canonical section ships into ~/.claude/CLAUDE.md.
345
+ *
346
+ * The doc at docs/global-claude-md-gramatr-section.md begins with a
347
+ * "# gramatr global CLAUDE.md section" + intro paragraph + "---" rule.
348
+ * Everything after the `---` is the section body.
349
+ */
350
+ export function extractCanonicalSection(docText) {
351
+ const idx = docText.indexOf("\n---\n");
352
+ if (idx === -1)
353
+ return docText.trim();
354
+ return docText.slice(idx + 5).trim();
355
+ }
356
+ /** Sentinel-safe upsert into CLAUDE.md. Preserves all out-of-block content. */
357
+ export function upsertGramatrSection(existing, sectionBody) {
358
+ const block = `${GRAMATR_MD_START}\n${sectionBody.trim()}\n${GRAMATR_MD_END}`;
359
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
360
+ const re = new RegExp(`${escape(GRAMATR_MD_START)}[\\s\\S]*?${escape(GRAMATR_MD_END)}`, "m");
361
+ if (re.test(existing)) {
362
+ return existing.replace(re, block);
363
+ }
364
+ const trimmed = existing.trimEnd();
365
+ return trimmed ? `${trimmed}\n\n${block}\n` : `${block}\n`;
366
+ }
367
+ /** Sentinel-safe removal from CLAUDE.md. */
368
+ export function stripGramatrSection(existing) {
369
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
370
+ const re = new RegExp(`\\n*${escape(GRAMATR_MD_START)}[\\s\\S]*?${escape(GRAMATR_MD_END)}\\n*`, "m");
371
+ return (existing
372
+ .replace(re, "\n")
373
+ .replace(/\n{3,}/g, "\n\n")
374
+ .trimEnd() + "\n");
375
+ }
376
+ function listMatching(dir, predicate) {
377
+ try {
378
+ return readdirSync(dir)
379
+ .filter(predicate)
380
+ .map((n) => join(dir, n));
381
+ }
382
+ catch {
383
+ return [];
384
+ }
385
+ }
386
+ function detectLegacyArtifacts(home) {
387
+ const out = [];
388
+ const gdir = gramatrDir(home);
389
+ const bin = join(gdir, "bin");
390
+ for (const p of listMatching(bin, (n) => n.startsWith("gramatr-hook")))
391
+ out.push(p);
392
+ for (const p of listMatching(gdir, (n) => n.startsWith("state.db")))
393
+ out.push(p);
394
+ const debug = join(gdir, "debug");
395
+ if (existsSync(debug))
396
+ out.push(debug);
397
+ const cdir = join(home, ".claude");
398
+ for (const p of listMatching(cdir, (n) => /^mcp-needs-auth-cache\.json\.bak-/.test(n))) {
399
+ out.push(p);
400
+ }
401
+ return out;
402
+ }
403
+ function removeLegacyArtifacts(paths, dryRun) {
404
+ if (dryRun)
405
+ return { removed: paths };
406
+ const removed = [];
407
+ for (const p of paths) {
408
+ try {
409
+ const s = statSync(p);
410
+ if (s.isDirectory()) {
411
+ rmSync(p, { recursive: true, force: true });
412
+ }
413
+ else {
414
+ unlinkSync(p);
415
+ }
416
+ removed.push(p);
417
+ }
418
+ catch {
419
+ /* best-effort */
420
+ }
421
+ }
422
+ return { removed };
423
+ }
424
+ // ── legacy slash-command cleanup (#2490) ──────────────────────────────────
425
+ /** Filenames in ~/.claude/commands/ known to be gramatr-flavored legacy. */
426
+ const LEGACY_SLASH_COMMAND_FILENAMES = new Set([
427
+ "save-handoff.md",
428
+ "gramatr-restore.md",
429
+ "gramatr-compact.md",
430
+ ]);
431
+ /**
432
+ * Heuristic: file content looks gramatr-flavored if it references retired
433
+ * daemon paths or the gramatr namespace.
434
+ */
435
+ function isGramatrFlavoredSlashCommand(content) {
436
+ return (content.includes("~/.gramatr/.state/") ||
437
+ content.includes(".gramatr/.state/") ||
438
+ content.includes("~/.gramatr/bin/gramatr-hook") ||
439
+ content.includes(".gramatr/bin/gramatr-hook") ||
440
+ /\bgrāmatr\b/i.test(content) ||
441
+ /\bgramatr\b/i.test(content));
442
+ }
443
+ /**
444
+ * Scan ~/.claude/commands/*.md for legacy gramatr-flavored slash commands.
445
+ * Returns absolute paths of files that match.
446
+ *
447
+ * Match rules:
448
+ * - Filename in LEGACY_SLASH_COMMAND_FILENAMES AND content gramatr-flavored.
449
+ * - OR content references retired daemon paths (~/.gramatr/.state/, etc.).
450
+ *
451
+ * Non-gramatr slash commands are preserved.
452
+ */
453
+ export function detectLegacySlashCommands(home) {
454
+ const dir = claudeCommandsDir(home);
455
+ let files;
456
+ try {
457
+ files = readdirSync(dir).filter((n) => n.endsWith(".md"));
458
+ }
459
+ catch {
460
+ return [];
461
+ }
462
+ const out = [];
463
+ for (const name of files) {
464
+ const path = join(dir, name);
465
+ let content = "";
466
+ try {
467
+ content = readFileSync(path, "utf8");
468
+ }
469
+ catch {
470
+ continue;
471
+ }
472
+ const knownStale = LEGACY_SLASH_COMMAND_FILENAMES.has(name);
473
+ const refsRetiredPath = content.includes("~/.gramatr/.state/") ||
474
+ content.includes(".gramatr/.state/") ||
475
+ content.includes("~/.gramatr/bin/gramatr-hook") ||
476
+ content.includes(".gramatr/bin/gramatr-hook");
477
+ if (refsRetiredPath) {
478
+ out.push(path);
479
+ continue;
480
+ }
481
+ if (knownStale && isGramatrFlavoredSlashCommand(content)) {
482
+ out.push(path);
483
+ }
484
+ }
485
+ return out;
486
+ }
487
+ /** Remove the given slash-command files. Best-effort, returns removed paths. */
488
+ export function cleanupLegacySlashCommands(home, dryRun = false) {
489
+ const matches = detectLegacySlashCommands(home);
490
+ if (dryRun)
491
+ return matches;
492
+ const removed = [];
493
+ for (const p of matches) {
494
+ try {
495
+ unlinkSync(p);
496
+ removed.push(p);
497
+ }
498
+ catch {
499
+ /* best-effort */
500
+ }
501
+ }
502
+ return removed;
503
+ }
504
+ // ── client detection (#2472) ──────────────────────────────────────────────
505
+ /**
506
+ * Detect which client this machine looks like:
507
+ * - claude-code if ~/.claude/settings.json exists OR ~/.claude.json has mcpServers
508
+ * - else claude-desktop if claude_desktop_config.json exists
509
+ * - else claude-web
510
+ */
511
+ export function detectClient(home, platform = osPlatform()) {
512
+ if (existsSync(claudeSettingsPath(home)))
513
+ return "claude-code";
514
+ const claudeJsonPath = join(home, ".claude.json");
515
+ if (existsSync(claudeJsonPath)) {
516
+ try {
517
+ const raw = readFileSync(claudeJsonPath, "utf8");
518
+ const parsed = JSON.parse(raw);
519
+ if (parsed && typeof parsed === "object" && parsed.mcpServers) {
520
+ return "claude-code";
521
+ }
522
+ }
523
+ catch {
524
+ /* fall through */
525
+ }
526
+ }
527
+ // ~/.claude/ exists (even empty) → assume claude-code is being set up.
528
+ if (existsSync(join(home, ".claude")))
529
+ return "claude-code";
530
+ if (existsSync(claudeDesktopConfigPath(home, platform)))
531
+ return "claude-desktop";
532
+ return "claude-web";
533
+ }
534
+ // ── auth helpers ──────────────────────────────────────────────────────────
535
+ export function hasValidToken(home) {
536
+ const path = gramatrTokenPath(home);
537
+ try {
538
+ const raw = readFileSync(path, "utf8");
539
+ const parsed = JSON.parse(raw);
540
+ return typeof parsed.token === "string" && parsed.token.length > 0;
541
+ }
542
+ catch {
543
+ return false;
544
+ }
545
+ }
546
+ // ── install / uninstall orchestrators ─────────────────────────────────────
547
+ export async function install(opts) {
548
+ const log = opts.log ?? ((m) => process.stderr.write(`${m}\n`));
549
+ const dryRun = !!opts.dryRun;
550
+ const cleanLegacy = !!opts.cleanLegacy || !!opts.nonInteractive;
551
+ const platform = opts.platformOverride ?? osPlatform();
552
+ const client = opts.client ?? detectClient(opts.home, platform);
553
+ const summary = {
554
+ client,
555
+ hookScriptWritten: false,
556
+ sessionEndScriptWritten: false,
557
+ sessionStartScriptWritten: false,
558
+ stopScriptWritten: false,
559
+ settingsUpdated: false,
560
+ claudeMdUpdated: false,
561
+ desktopConfigUpdated: false,
562
+ legacyEntriesRemoved: 0,
563
+ legacyFilesRemoved: [],
564
+ legacySlashCommandsRemoved: [],
565
+ backups: [],
566
+ };
567
+ // Dispatch to non-claude-code branches early.
568
+ if (client === "claude-web") {
569
+ summary.webInstructions = buildWebInstallInstructions(opts.mcpServerUrl);
570
+ log("[gramatr] claude-web detected — no local filesystem changes.");
571
+ log(summary.webInstructions);
572
+ return summary;
573
+ }
574
+ if (client === "claude-desktop") {
575
+ const cfgPath = opts.desktopConfigPathOverride ?? claudeDesktopConfigPath(opts.home, platform);
576
+ const updated = installClaudeDesktop({
577
+ configPath: cfgPath,
578
+ home: opts.home,
579
+ mcpServerUrl: opts.mcpServerUrl,
580
+ dryRun,
581
+ log,
582
+ });
583
+ summary.desktopConfigUpdated = updated.updated;
584
+ if (updated.backup)
585
+ summary.backups.push(updated.backup);
586
+ log(`[gramatr] claude-desktop ${dryRun ? "(dry-run) " : ""}config at ${cfgPath}`);
587
+ log("[gramatr] restart Claude Desktop after install to load the new MCP server.");
588
+ return summary;
589
+ }
590
+ // ─── claude-code path (legacy default — full hook + settings + CLAUDE.md) ──
591
+ // 1. Hook script
592
+ const hookSrc = opts.hookSourcePath ?? resolveBundledHookSource();
593
+ const hookDst = gramatrHookScriptPath(opts.home);
594
+ const desiredHook = readFileOr(hookSrc, null);
595
+ if (desiredHook === null) {
596
+ log(`[gramatr] hook source missing: ${hookSrc}`);
597
+ }
598
+ else {
599
+ const currentHook = readFileOr(hookDst, null);
600
+ if (currentHook !== desiredHook) {
601
+ if (!dryRun) {
602
+ ensureDir(dirname(hookDst));
603
+ atomicWriteFile(hookDst, desiredHook, 0o755);
604
+ try {
605
+ chmodSync(hookDst, 0o755);
606
+ }
607
+ catch {
608
+ /* mode flag on write covered most cases */
609
+ }
610
+ }
611
+ summary.hookScriptWritten = true;
612
+ log(`[gramatr] hook script ${dryRun ? "(dry-run) " : ""}→ ${hookDst}`);
613
+ }
614
+ }
615
+ // 1b. SessionEnd hook script
616
+ const seHookSrc = opts.sessionEndHookSourcePath ?? resolveBundledSessionEndHookSource();
617
+ const seHookDst = gramatrSessionEndScriptPath(opts.home);
618
+ const seDesired = readFileOr(seHookSrc, null);
619
+ if (seDesired === null) {
620
+ log(`[gramatr] SessionEnd hook source missing: ${seHookSrc}`);
621
+ }
622
+ else {
623
+ const current = readFileOr(seHookDst, null);
624
+ if (current !== seDesired) {
625
+ if (!dryRun) {
626
+ ensureDir(dirname(seHookDst));
627
+ atomicWriteFile(seHookDst, seDesired, 0o755);
628
+ try {
629
+ chmodSync(seHookDst, 0o755);
630
+ }
631
+ catch {
632
+ /* mode flag on write covered most cases */
633
+ }
634
+ }
635
+ summary.sessionEndScriptWritten = true;
636
+ log(`[gramatr] SessionEnd hook script ${dryRun ? "(dry-run) " : ""}→ ${seHookDst}`);
637
+ }
638
+ }
639
+ // 1c. SessionStart hook script (#2475)
640
+ const ssHookSrc = opts.sessionStartHookSourcePath ?? resolveBundledSessionStartHookSource();
641
+ const ssHookDst = gramatrSessionStartScriptPath(opts.home);
642
+ const ssDesired = readFileOr(ssHookSrc, null);
643
+ if (ssDesired === null) {
644
+ log(`[gramatr] SessionStart hook source missing: ${ssHookSrc}`);
645
+ }
646
+ else {
647
+ const current = readFileOr(ssHookDst, null);
648
+ if (current !== ssDesired) {
649
+ if (!dryRun) {
650
+ ensureDir(dirname(ssHookDst));
651
+ atomicWriteFile(ssHookDst, ssDesired, 0o755);
652
+ try {
653
+ chmodSync(ssHookDst, 0o755);
654
+ }
655
+ catch {
656
+ /* mode flag on write covered most cases */
657
+ }
658
+ }
659
+ summary.sessionStartScriptWritten = true;
660
+ log(`[gramatr] SessionStart hook script ${dryRun ? "(dry-run) " : ""}→ ${ssHookDst}`);
661
+ }
662
+ }
663
+ // 1d. Stop hook script (#2476)
664
+ const stopHookSrc = opts.stopHookSourcePath ?? resolveBundledStopHookSource();
665
+ const stopHookDst = gramatrStopScriptPath(opts.home);
666
+ const stopDesired = readFileOr(stopHookSrc, null);
667
+ if (stopDesired === null) {
668
+ log(`[gramatr] Stop hook source missing: ${stopHookSrc}`);
669
+ }
670
+ else {
671
+ const current = readFileOr(stopHookDst, null);
672
+ if (current !== stopDesired) {
673
+ if (!dryRun) {
674
+ ensureDir(dirname(stopHookDst));
675
+ atomicWriteFile(stopHookDst, stopDesired, 0o755);
676
+ try {
677
+ chmodSync(stopHookDst, 0o755);
678
+ }
679
+ catch {
680
+ /* mode flag on write covered most cases */
681
+ }
682
+ }
683
+ summary.stopScriptWritten = true;
684
+ log(`[gramatr] Stop hook script ${dryRun ? "(dry-run) " : ""}→ ${stopHookDst}`);
685
+ }
686
+ }
687
+ // 2. settings.json
688
+ const settingsPath = claudeSettingsPath(opts.home);
689
+ const settingsRaw = readFileOr(settingsPath, null);
690
+ const settings = parseJsonOr(settingsRaw, {});
691
+ const merged = mergeUserPromptSubmitHookIntoSettings(settings, opts.home, cleanLegacy);
692
+ summary.legacyEntriesRemoved = merged.legacyRemoved;
693
+ const desiredSettings = JSON.stringify(merged.next, null, 2) + "\n";
694
+ const currentSettingsText = settingsRaw ?? "";
695
+ if (desiredSettings !== currentSettingsText) {
696
+ if (!dryRun) {
697
+ const bak = backupFile(settingsPath);
698
+ if (bak)
699
+ summary.backups.push(bak);
700
+ atomicWriteFile(settingsPath, desiredSettings, 0o600);
701
+ }
702
+ summary.settingsUpdated = true;
703
+ log(`[gramatr] settings.json ${dryRun ? "(dry-run) " : ""}→ UserPromptSubmit hook merged` +
704
+ (merged.legacyRemoved > 0
705
+ ? ` (removed ${merged.legacyRemoved} legacy entr${merged.legacyRemoved === 1 ? "y" : "ies"})`
706
+ : ""));
707
+ }
708
+ // 3. CLAUDE.md
709
+ const mdPath = claudeMarkdownPath(opts.home);
710
+ const sectionBody = opts.claudeMdSection ?? loadCanonicalSection();
711
+ const currentMd = readFileOr(mdPath, "");
712
+ const desiredMd = upsertGramatrSection(currentMd, sectionBody);
713
+ if (desiredMd !== currentMd) {
714
+ if (!dryRun) {
715
+ const bak = backupFile(mdPath);
716
+ if (bak)
717
+ summary.backups.push(bak);
718
+ atomicWriteFile(mdPath, desiredMd, 0o644);
719
+ }
720
+ summary.claudeMdUpdated = true;
721
+ log(`[gramatr] CLAUDE.md ${dryRun ? "(dry-run) " : ""}→ sentinel block upserted`);
722
+ }
723
+ // 4. Legacy cruft (only with --clean-legacy / non-interactive — we never
724
+ // delete files behind the user's back in interactive runs).
725
+ if (cleanLegacy) {
726
+ const legacy = detectLegacyArtifacts(opts.home);
727
+ if (legacy.length > 0) {
728
+ const res = removeLegacyArtifacts(legacy, dryRun);
729
+ summary.legacyFilesRemoved = res.removed;
730
+ for (const p of res.removed)
731
+ log(`[gramatr] cleaned ${dryRun ? "(dry-run) " : ""}${p}`);
732
+ }
733
+ // Also clean up legacy slash commands (#2490).
734
+ const removedCmds = cleanupLegacySlashCommands(opts.home, dryRun);
735
+ summary.legacySlashCommandsRemoved = removedCmds;
736
+ for (const p of removedCmds) {
737
+ log(`[gramatr] cleaned legacy slash command ${dryRun ? "(dry-run) " : ""}${p}`);
738
+ }
739
+ }
740
+ else {
741
+ const legacy = detectLegacyArtifacts(opts.home);
742
+ if (legacy.length > 0) {
743
+ log(`[gramatr] note: detected ${legacy.length} legacy artifact${legacy.length === 1 ? "" : "s"}; rerun with --clean-legacy to remove`);
744
+ }
745
+ const staleCmds = detectLegacySlashCommands(opts.home);
746
+ if (staleCmds.length > 0) {
747
+ log(`[gramatr] note: detected ${staleCmds.length} legacy slash command${staleCmds.length === 1 ? "" : "s"}; rerun with --clean-legacy to remove`);
748
+ }
749
+ }
750
+ return summary;
751
+ }
752
+ /**
753
+ * Build the claude-desktop mcpServers entry for gramatr. Uses HTTP transport
754
+ * since Desktop supports it and we get a free Bearer token from ~/.gramatr.json.
755
+ */
756
+ export function buildDesktopMcpEntry(home, serverUrl = "https://api.gramatr.com/mcp") {
757
+ const token = readBearerToken(home);
758
+ const headers = {};
759
+ if (token)
760
+ headers.Authorization = `Bearer ${token}`;
761
+ return {
762
+ type: "http",
763
+ url: serverUrl,
764
+ headers,
765
+ };
766
+ }
767
+ function readBearerToken(home) {
768
+ try {
769
+ const raw = readFileSync(gramatrTokenPath(home), "utf8");
770
+ const parsed = JSON.parse(raw);
771
+ return typeof parsed.token === "string" && parsed.token.length > 0 ? parsed.token : null;
772
+ }
773
+ catch {
774
+ return null;
775
+ }
776
+ }
777
+ /**
778
+ * Merge gramatr entry into a claude-desktop config object. Returns the next
779
+ * object and whether a change was made. Idempotent.
780
+ */
781
+ export function mergeDesktopConfig(current, entry) {
782
+ const servers = { ...(current.mcpServers ?? {}) };
783
+ const existing = servers.gramatr;
784
+ const same = existing && JSON.stringify(existing) === JSON.stringify(entry);
785
+ if (same)
786
+ return { next: current, changed: false };
787
+ servers.gramatr = entry;
788
+ return { next: { ...current, mcpServers: servers }, changed: true };
789
+ }
790
+ function installClaudeDesktop(opts) {
791
+ const entry = buildDesktopMcpEntry(opts.home, opts.mcpServerUrl);
792
+ const raw = readFileOr(opts.configPath, null);
793
+ const current = parseJsonOr(raw, {});
794
+ const { next, changed } = mergeDesktopConfig(current, entry);
795
+ if (!changed)
796
+ return { updated: false, backup: null };
797
+ const desired = JSON.stringify(next, null, 2) + "\n";
798
+ if (opts.dryRun)
799
+ return { updated: true, backup: null };
800
+ const backup = backupFile(opts.configPath);
801
+ atomicWriteFile(opts.configPath, desired, 0o600);
802
+ return { updated: true, backup };
803
+ }
804
+ /** Remove gramatr entry from claude-desktop config. */
805
+ export function uninstallDesktopConfig(current) {
806
+ if (!current.mcpServers || !current.mcpServers.gramatr) {
807
+ return { next: current, changed: false };
808
+ }
809
+ const servers = { ...current.mcpServers };
810
+ delete servers.gramatr;
811
+ const next = { ...current };
812
+ if (Object.keys(servers).length > 0) {
813
+ next.mcpServers = servers;
814
+ }
815
+ else {
816
+ delete next.mcpServers;
817
+ }
818
+ return { next, changed: true };
819
+ }
820
+ // ── claude-web instructions (#2472) ───────────────────────────────────────
821
+ /**
822
+ * Build the copy-paste instructions for claude-web. No filesystem writes.
823
+ * Combines connector steps with the canonical prompt suggestion block.
824
+ */
825
+ export function buildWebInstallInstructions(serverUrl) {
826
+ const conn = buildConnectorInstructions({ serverUrl, target: "claude-web" });
827
+ const suggestion = buildPromptSuggestion("claude-web");
828
+ const lines = [];
829
+ lines.push("# gramatr — claude-web manual setup");
830
+ lines.push("");
831
+ lines.push("Claude Web (claude.ai) has no local install. Follow these steps:");
832
+ lines.push("");
833
+ for (let i = 0; i < conn.steps.length; i++) {
834
+ lines.push(` ${i + 1}. ${conn.steps[i]}`);
835
+ }
836
+ lines.push("");
837
+ lines.push("Paste the following block into Settings > Profile > Custom Instructions:");
838
+ lines.push("");
839
+ lines.push("---");
840
+ lines.push(suggestion);
841
+ lines.push("---");
842
+ return lines.join("\n");
843
+ }
844
+ export async function uninstall(opts) {
845
+ const log = opts.log ?? ((m) => process.stderr.write(`${m}\n`));
846
+ const dryRun = !!opts.dryRun;
847
+ const platform = opts.platformOverride ?? osPlatform();
848
+ const client = opts.client ?? detectClient(opts.home, platform);
849
+ const summary = {
850
+ client,
851
+ hookEntryRemoved: false,
852
+ claudeMdSectionRemoved: false,
853
+ hookScriptRemoved: false,
854
+ sessionEndScriptRemoved: false,
855
+ sessionStartScriptRemoved: false,
856
+ stopScriptRemoved: false,
857
+ desktopConfigUpdated: false,
858
+ legacySlashCommandsRemoved: [],
859
+ tokenRemoved: false,
860
+ backups: [],
861
+ };
862
+ if (client === "claude-web") {
863
+ log("[gramatr] claude-web: no local state to remove. Disconnect the connector inside claude.ai.");
864
+ if (opts.purge) {
865
+ const tokPath = gramatrTokenPath(opts.home);
866
+ if (existsSync(tokPath)) {
867
+ if (!dryRun)
868
+ unlinkSync(tokPath);
869
+ summary.tokenRemoved = true;
870
+ }
871
+ }
872
+ return summary;
873
+ }
874
+ if (client === "claude-desktop") {
875
+ const cfgPath = opts.desktopConfigPathOverride ?? claudeDesktopConfigPath(opts.home, platform);
876
+ const raw = readFileOr(cfgPath, null);
877
+ if (raw !== null) {
878
+ const current = parseJsonOr(raw, {});
879
+ const { next, changed } = uninstallDesktopConfig(current);
880
+ if (changed) {
881
+ if (!dryRun) {
882
+ const bak = backupFile(cfgPath);
883
+ if (bak)
884
+ summary.backups.push(bak);
885
+ atomicWriteFile(cfgPath, JSON.stringify(next, null, 2) + "\n", 0o600);
886
+ }
887
+ summary.desktopConfigUpdated = true;
888
+ log(`[gramatr] claude-desktop ${dryRun ? "(dry-run) " : ""}removed gramatr mcpServers entry`);
889
+ }
890
+ }
891
+ if (opts.purge) {
892
+ const tokPath = gramatrTokenPath(opts.home);
893
+ if (existsSync(tokPath)) {
894
+ if (!dryRun)
895
+ unlinkSync(tokPath);
896
+ summary.tokenRemoved = true;
897
+ }
898
+ }
899
+ return summary;
900
+ }
901
+ // 1. settings.json
902
+ const settingsPath = claudeSettingsPath(opts.home);
903
+ const settingsRaw = readFileOr(settingsPath, null);
904
+ if (settingsRaw !== null) {
905
+ const settings = parseJsonOr(settingsRaw, {});
906
+ const stripped = removeUserPromptSubmitHookFromSettings(settings);
907
+ if (stripped.removed > 0) {
908
+ const next = JSON.stringify(stripped.next, null, 2) + "\n";
909
+ if (!dryRun) {
910
+ const bak = backupFile(settingsPath);
911
+ if (bak)
912
+ summary.backups.push(bak);
913
+ atomicWriteFile(settingsPath, next, 0o600);
914
+ }
915
+ summary.hookEntryRemoved = true;
916
+ log(`[gramatr] settings.json ${dryRun ? "(dry-run) " : ""}→ removed ${stripped.removed} gramatr hook entr${stripped.removed === 1 ? "y" : "ies"}`);
917
+ }
918
+ }
919
+ // 2. CLAUDE.md
920
+ const mdPath = claudeMarkdownPath(opts.home);
921
+ const currentMd = readFileOr(mdPath, null);
922
+ if (currentMd !== null && currentMd.includes(GRAMATR_MD_START)) {
923
+ const next = stripGramatrSection(currentMd);
924
+ if (next !== currentMd) {
925
+ if (!dryRun) {
926
+ const bak = backupFile(mdPath);
927
+ if (bak)
928
+ summary.backups.push(bak);
929
+ atomicWriteFile(mdPath, next, 0o644);
930
+ }
931
+ summary.claudeMdSectionRemoved = true;
932
+ log(`[gramatr] CLAUDE.md ${dryRun ? "(dry-run) " : ""}→ sentinel block removed`);
933
+ }
934
+ }
935
+ // 3. hook script
936
+ const hookDst = gramatrHookScriptPath(opts.home);
937
+ if (existsSync(hookDst)) {
938
+ if (!dryRun) {
939
+ const bak = backupFile(hookDst);
940
+ if (bak)
941
+ summary.backups.push(bak);
942
+ unlinkSync(hookDst);
943
+ }
944
+ summary.hookScriptRemoved = true;
945
+ log(`[gramatr] hook script ${dryRun ? "(dry-run) " : ""}→ removed ${hookDst}`);
946
+ }
947
+ // 3b. SessionEnd hook script
948
+ const seHookDst = gramatrSessionEndScriptPath(opts.home);
949
+ if (existsSync(seHookDst)) {
950
+ if (!dryRun) {
951
+ const bak = backupFile(seHookDst);
952
+ if (bak)
953
+ summary.backups.push(bak);
954
+ unlinkSync(seHookDst);
955
+ }
956
+ summary.sessionEndScriptRemoved = true;
957
+ log(`[gramatr] SessionEnd hook script ${dryRun ? "(dry-run) " : ""}→ removed ${seHookDst}`);
958
+ }
959
+ // 3c. SessionStart hook script (#2475)
960
+ const ssHookDst = gramatrSessionStartScriptPath(opts.home);
961
+ if (existsSync(ssHookDst)) {
962
+ if (!dryRun) {
963
+ const bak = backupFile(ssHookDst);
964
+ if (bak)
965
+ summary.backups.push(bak);
966
+ unlinkSync(ssHookDst);
967
+ }
968
+ summary.sessionStartScriptRemoved = true;
969
+ log(`[gramatr] SessionStart hook script ${dryRun ? "(dry-run) " : ""}→ removed ${ssHookDst}`);
970
+ }
971
+ // 3d. Stop hook script (#2476)
972
+ const stopHookDst = gramatrStopScriptPath(opts.home);
973
+ if (existsSync(stopHookDst)) {
974
+ if (!dryRun) {
975
+ const bak = backupFile(stopHookDst);
976
+ if (bak)
977
+ summary.backups.push(bak);
978
+ unlinkSync(stopHookDst);
979
+ }
980
+ summary.stopScriptRemoved = true;
981
+ log(`[gramatr] Stop hook script ${dryRun ? "(dry-run) " : ""}→ removed ${stopHookDst}`);
982
+ }
983
+ // 3e. Legacy slash commands (always cleaned during uninstall; #2490).
984
+ const removedCmds = cleanupLegacySlashCommands(opts.home, dryRun);
985
+ summary.legacySlashCommandsRemoved = removedCmds;
986
+ for (const p of removedCmds) {
987
+ log(`[gramatr] removed legacy slash command ${dryRun ? "(dry-run) " : ""}${p}`);
988
+ }
989
+ // 4. token (only on --purge)
990
+ if (opts.purge) {
991
+ const tokPath = gramatrTokenPath(opts.home);
992
+ if (existsSync(tokPath)) {
993
+ if (!dryRun) {
994
+ const bak = backupFile(tokPath);
995
+ if (bak)
996
+ summary.backups.push(bak);
997
+ unlinkSync(tokPath);
998
+ }
999
+ summary.tokenRemoved = true;
1000
+ log(`[gramatr] token ${dryRun ? "(dry-run) " : ""}→ removed ${tokPath}`);
1001
+ }
1002
+ }
1003
+ return summary;
1004
+ }
1005
+ // ── canonical section loader ──────────────────────────────────────────────
1006
+ /** Locate the canonical section doc that ships in the repo. */
1007
+ function loadCanonicalSection() {
1008
+ const here = fileURLToPath(import.meta.url);
1009
+ const candidates = [
1010
+ // From compiled dist/bin/install.js — walk up to repo root.
1011
+ resolve(dirname(here), "..", "..", "..", "..", "docs", "global-claude-md-gramatr-section.md"),
1012
+ // From source src/bin/install.ts in dev.
1013
+ resolve(dirname(here), "..", "..", "..", "..", "docs", "global-claude-md-gramatr-section.md"),
1014
+ // Packaged inside the npm tarball (alongside scripts/).
1015
+ resolve(dirname(here), "..", "..", "docs", "global-claude-md-gramatr-section.md"),
1016
+ ];
1017
+ for (const c of candidates) {
1018
+ if (existsSync(c))
1019
+ return extractCanonicalSection(readFileSync(c, "utf8"));
1020
+ }
1021
+ // Fallback: minimal inline copy so install still works if the doc
1022
+ // wasn't bundled into the npm tarball.
1023
+ return INLINE_FALLBACK_SECTION;
1024
+ }
1025
+ const INLINE_FALLBACK_SECTION = `## gramatr classification block — agent contract
1026
+
1027
+ If a \`<gramatr-classification>...</gramatr-classification>\` block appears in this turn's context, parse the JSON inside it and follow this protocol literally.
1028
+
1029
+ - \`call_route_request\`: invoke \`mcp__gramatr__route_request\` with \`directive.params_for_route_request\` verbatim.
1030
+ - \`respond_directly\`: answer using only the block's classification + context.
1031
+
1032
+ After substantive work, call \`classification_feedback\` with \`was_correct\` based on whether the classification matched the actual work.
1033
+ `;
1034
+ // ── CLI entrypoints ───────────────────────────────────────────────────────
1035
+ function resolveHome() {
1036
+ // gramatr-allow: C1 — CLI entry point, reads HOME for config path
1037
+ return process.env.HOME || process.env.USERPROFILE || "";
1038
+ }
1039
+ function parseClientFlag(argv) {
1040
+ for (const a of argv) {
1041
+ if (a.startsWith("--client=")) {
1042
+ const v = a.slice("--client=".length);
1043
+ if (v === "claude-code" || v === "claude-desktop" || v === "claude-web")
1044
+ return v;
1045
+ process.stderr.write(`[gramatr] unknown --client value: ${v} (expected claude-code|claude-desktop|claude-web)\n`);
1046
+ }
1047
+ }
1048
+ return undefined;
1049
+ }
1050
+ export async function runInstallCli(argv) {
1051
+ const home = resolveHome();
1052
+ if (!home) {
1053
+ process.stderr.write("[gramatr] HOME is not set; cannot install.\n");
1054
+ return 1;
1055
+ }
1056
+ const opts = {
1057
+ home,
1058
+ client: parseClientFlag(argv),
1059
+ cleanLegacy: argv.includes("--clean-legacy"),
1060
+ nonInteractive: argv.includes("--non-interactive") || argv.includes("--yes") || argv.includes("-y"),
1061
+ dryRun: argv.includes("--dry-run"),
1062
+ };
1063
+ // Auth check — defer to existing login flow when no token is present and
1064
+ // we're allowed to be interactive. We don't run device-flow in dry-run.
1065
+ if (!hasValidToken(home) && !opts.dryRun) {
1066
+ try {
1067
+ const { loginBrowser } = await import("./login.js");
1068
+ process.stderr.write("[gramatr] no token found; running login...\n");
1069
+ await loginBrowser();
1070
+ }
1071
+ catch (err) {
1072
+ const detail = err instanceof Error ? err.message : String(err);
1073
+ process.stderr.write(`[gramatr] login failed: ${detail}\n`);
1074
+ process.stderr.write("[gramatr] proceeding with install — re-run `gramatr login` later.\n");
1075
+ }
1076
+ }
1077
+ const summary = await install(opts);
1078
+ process.stderr.write("\n[gramatr] install summary:\n");
1079
+ process.stderr.write(` client: ${summary.client}\n`);
1080
+ if (summary.client === "claude-web") {
1081
+ // Instructions already printed via log().
1082
+ return 0;
1083
+ }
1084
+ if (summary.client === "claude-desktop") {
1085
+ process.stderr.write(` claude-desktop config: ${summary.desktopConfigUpdated ? "updated" : "up-to-date"}\n`);
1086
+ if (summary.backups.length > 0) {
1087
+ process.stderr.write(` backups: ${summary.backups.length}\n`);
1088
+ }
1089
+ process.stderr.write("\n Restart Claude Desktop to activate.\n");
1090
+ return 0;
1091
+ }
1092
+ process.stderr.write(` hook script: ${summary.hookScriptWritten ? "written" : "up-to-date"}\n`);
1093
+ process.stderr.write(` SessionEnd hook script: ${summary.sessionEndScriptWritten ? "written" : "up-to-date"}\n`);
1094
+ process.stderr.write(` SessionStart hook script: ${summary.sessionStartScriptWritten ? "written" : "up-to-date"}\n`);
1095
+ process.stderr.write(` Stop hook script: ${summary.stopScriptWritten ? "written" : "up-to-date"}\n`);
1096
+ process.stderr.write(` settings.json: ${summary.settingsUpdated ? "updated" : "up-to-date"}\n`);
1097
+ process.stderr.write(` CLAUDE.md: ${summary.claudeMdUpdated ? "updated" : "up-to-date"}\n`);
1098
+ if (summary.legacyEntriesRemoved > 0) {
1099
+ process.stderr.write(` legacy hook entries removed: ${summary.legacyEntriesRemoved}\n`);
1100
+ }
1101
+ if (summary.legacyFilesRemoved.length > 0) {
1102
+ process.stderr.write(` legacy files removed: ${summary.legacyFilesRemoved.length}\n`);
1103
+ }
1104
+ if (summary.legacySlashCommandsRemoved.length > 0) {
1105
+ process.stderr.write(` legacy slash commands removed: ${summary.legacySlashCommandsRemoved.length}\n`);
1106
+ }
1107
+ if (summary.backups.length > 0) {
1108
+ process.stderr.write(` backups: ${summary.backups.length}\n`);
1109
+ for (const b of summary.backups)
1110
+ process.stderr.write(` ${b}\n`);
1111
+ }
1112
+ process.stderr.write("\n Restart Claude Code to activate.\n");
1113
+ return 0;
1114
+ }
1115
+ export async function runUninstallCli(argv) {
1116
+ const home = resolveHome();
1117
+ if (!home) {
1118
+ process.stderr.write("[gramatr] HOME is not set; cannot uninstall.\n");
1119
+ return 1;
1120
+ }
1121
+ const summary = await uninstall({
1122
+ home,
1123
+ client: parseClientFlag(argv),
1124
+ purge: argv.includes("--purge"),
1125
+ dryRun: argv.includes("--dry-run"),
1126
+ });
1127
+ process.stderr.write("\n[gramatr] uninstall summary:\n");
1128
+ process.stderr.write(` client: ${summary.client}\n`);
1129
+ if (summary.client === "claude-web") {
1130
+ process.stderr.write(" (no local state — disconnect the connector inside claude.ai)\n");
1131
+ if (summary.tokenRemoved)
1132
+ process.stderr.write(` token: removed\n`);
1133
+ return 0;
1134
+ }
1135
+ if (summary.client === "claude-desktop") {
1136
+ process.stderr.write(` claude-desktop config: ${summary.desktopConfigUpdated ? "updated" : "not present"}\n`);
1137
+ if (summary.tokenRemoved)
1138
+ process.stderr.write(` token: removed\n`);
1139
+ return 0;
1140
+ }
1141
+ process.stderr.write(` hook entry: ${summary.hookEntryRemoved ? "removed" : "not present"}\n`);
1142
+ process.stderr.write(` CLAUDE.md section: ${summary.claudeMdSectionRemoved ? "removed" : "not present"}\n`);
1143
+ process.stderr.write(` hook script: ${summary.hookScriptRemoved ? "removed" : "not present"}\n`);
1144
+ process.stderr.write(` SessionEnd hook script: ${summary.sessionEndScriptRemoved ? "removed" : "not present"}\n`);
1145
+ process.stderr.write(` SessionStart hook script: ${summary.sessionStartScriptRemoved ? "removed" : "not present"}\n`);
1146
+ process.stderr.write(` Stop hook script: ${summary.stopScriptRemoved ? "removed" : "not present"}\n`);
1147
+ if (summary.legacySlashCommandsRemoved.length > 0) {
1148
+ process.stderr.write(` legacy slash commands removed: ${summary.legacySlashCommandsRemoved.length}\n`);
1149
+ }
1150
+ if (summary.tokenRemoved)
1151
+ process.stderr.write(` token: removed\n`);
1152
+ if (summary.backups.length > 0) {
1153
+ process.stderr.write(` backups: ${summary.backups.length}\n`);
1154
+ }
1155
+ return 0;
1156
+ }
1157
+ //# sourceMappingURL=install.js.map