@gramatr/mcp 0.13.128 → 0.13.130

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.
Files changed (40) hide show
  1. package/README.md +14 -2
  2. package/dist/bin/hook-dispatcher.d.ts.map +1 -1
  3. package/dist/bin/hook-dispatcher.js +0 -5
  4. package/dist/bin/hook-dispatcher.js.map +1 -1
  5. package/dist/bin/install.d.ts +89 -0
  6. package/dist/bin/install.d.ts.map +1 -1
  7. package/dist/bin/install.js +371 -15
  8. package/dist/bin/install.js.map +1 -1
  9. package/dist/bin/setup-legacy.d.ts +1 -0
  10. package/dist/bin/setup-legacy.d.ts.map +1 -1
  11. package/dist/bin/setup-legacy.js +114 -0
  12. package/dist/bin/setup-legacy.js.map +1 -1
  13. package/dist/hooks/generated/hook-registry.d.ts +2 -2
  14. package/dist/hooks/generated/hook-registry.d.ts.map +1 -1
  15. package/dist/hooks/generated/hook-registry.js +1 -4
  16. package/dist/hooks/generated/hook-registry.js.map +1 -1
  17. package/dist/hooks/generated/hook-timeouts.d.ts +2 -6
  18. package/dist/hooks/generated/hook-timeouts.d.ts.map +1 -1
  19. package/dist/hooks/generated/hook-timeouts.js +2 -6
  20. package/dist/hooks/generated/hook-timeouts.js.map +1 -1
  21. package/dist/hooks/index.d.ts +0 -1
  22. package/dist/hooks/index.d.ts.map +1 -1
  23. package/dist/hooks/index.js +0 -1
  24. package/dist/hooks/index.js.map +1 -1
  25. package/dist/hooks/lib/auto-feedback.d.ts +7 -0
  26. package/dist/hooks/lib/auto-feedback.d.ts.map +1 -0
  27. package/dist/hooks/lib/auto-feedback.js +37 -0
  28. package/dist/hooks/lib/auto-feedback.js.map +1 -0
  29. package/dist/hooks/stop.d.ts.map +1 -1
  30. package/dist/hooks/stop.js +11 -0
  31. package/dist/hooks/stop.js.map +1 -1
  32. package/dist/setup/generated/platform-hooks.d.ts +1 -1
  33. package/dist/setup/generated/platform-hooks.d.ts.map +1 -1
  34. package/dist/setup/generated/platform-hooks.js +1 -4
  35. package/dist/setup/generated/platform-hooks.js.map +1 -1
  36. package/package.json +1 -1
  37. package/dist/hooks/rating-capture.d.ts +0 -2
  38. package/dist/hooks/rating-capture.d.ts.map +0 -1
  39. package/dist/hooks/rating-capture.js +0 -113
  40. package/dist/hooks/rating-capture.js.map +0 -1
@@ -15,9 +15,11 @@
15
15
  * Scope: Claude Code only in v1. Codex / Cursor / Gemini stay on `setup`
16
16
  * subcommands until follow-up.
17
17
  */
18
- import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
18
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
19
+ import { platform as osPlatform } from "node:os";
19
20
  import { dirname, join, resolve } from "node:path";
20
21
  import { fileURLToPath } from "node:url";
22
+ import { buildConnectorInstructions, buildPromptSuggestion } from "../setup/web-connector.js";
21
23
  /** Sentinel pair carved into ~/.claude/CLAUDE.md. */
22
24
  export const GRAMATR_MD_START = "<!-- GRAMATR-START -->";
23
25
  export const GRAMATR_MD_END = "<!-- GRAMATR-END -->";
@@ -61,6 +63,27 @@ export function gramatrStopScriptPath(home) {
61
63
  export function gramatrTokenPath(home) {
62
64
  return join(home, ".gramatr.json");
63
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
+ }
64
87
  /** Resolve the bundled SessionEnd hook script source inside @gramatr/mcp. */
65
88
  export function resolveBundledSessionEndHookSource() {
66
89
  const here = fileURLToPath(import.meta.url);
@@ -224,9 +247,7 @@ export function mergeUserPromptSubmitHookIntoSettings(settings, home, removeLega
224
247
  }
225
248
  }
226
249
  // Add our entry. Preserve any non-gramatr UserPromptSubmit entries.
227
- const ups = Array.isArray(hooks.UserPromptSubmit)
228
- ? [...hooks.UserPromptSubmit]
229
- : [];
250
+ const ups = Array.isArray(hooks.UserPromptSubmit) ? [...hooks.UserPromptSubmit] : [];
230
251
  const alreadyPresent = ups.some((e) => (e.hooks ?? []).some((c) => c.command === cmd));
231
252
  if (!alreadyPresent) {
232
253
  ups.push({
@@ -237,9 +258,7 @@ export function mergeUserPromptSubmitHookIntoSettings(settings, home, removeLega
237
258
  hooks.UserPromptSubmit = ups;
238
259
  // Add SessionEnd entry. Preserve any non-gramatr SessionEnd entries.
239
260
  const seCmd = buildSessionEndHookCommand(home);
240
- const seEntries = Array.isArray(hooks.SessionEnd)
241
- ? [...hooks.SessionEnd]
242
- : [];
261
+ const seEntries = Array.isArray(hooks.SessionEnd) ? [...hooks.SessionEnd] : [];
243
262
  const seAlreadyPresent = seEntries.some((e) => (e.hooks ?? []).some((c) => c.command === seCmd));
244
263
  if (!seAlreadyPresent) {
245
264
  seEntries.push({
@@ -250,9 +269,7 @@ export function mergeUserPromptSubmitHookIntoSettings(settings, home, removeLega
250
269
  hooks.SessionEnd = seEntries;
251
270
  // Add SessionStart entry. Preserve any non-gramatr SessionStart entries (#2475).
252
271
  const ssCmd = buildSessionStartHookCommand(home);
253
- const ssEntries = Array.isArray(hooks.SessionStart)
254
- ? [...hooks.SessionStart]
255
- : [];
272
+ const ssEntries = Array.isArray(hooks.SessionStart) ? [...hooks.SessionStart] : [];
256
273
  const ssAlreadyPresent = ssEntries.some((e) => (e.hooks ?? []).some((c) => c.command === ssCmd));
257
274
  if (!ssAlreadyPresent) {
258
275
  ssEntries.push({
@@ -263,9 +280,7 @@ export function mergeUserPromptSubmitHookIntoSettings(settings, home, removeLega
263
280
  hooks.SessionStart = ssEntries;
264
281
  // Add Stop entry. Preserve any non-gramatr Stop entries (#2476).
265
282
  const stopCmd = buildStopHookCommand(home);
266
- const stopEntries = Array.isArray(hooks.Stop)
267
- ? [...hooks.Stop]
268
- : [];
283
+ const stopEntries = Array.isArray(hooks.Stop) ? [...hooks.Stop] : [];
269
284
  const stopAlreadyPresent = stopEntries.some((e) => (e.hooks ?? []).some((c) => c.command === stopCmd));
270
285
  if (!stopAlreadyPresent) {
271
286
  stopEntries.push({
@@ -353,11 +368,16 @@ export function upsertGramatrSection(existing, sectionBody) {
353
368
  export function stripGramatrSection(existing) {
354
369
  const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
355
370
  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";
371
+ return (existing
372
+ .replace(re, "\n")
373
+ .replace(/\n{3,}/g, "\n\n")
374
+ .trimEnd() + "\n");
357
375
  }
358
376
  function listMatching(dir, predicate) {
359
377
  try {
360
- return readdirSync(dir).filter(predicate).map((n) => join(dir, n));
378
+ return readdirSync(dir)
379
+ .filter(predicate)
380
+ .map((n) => join(dir, n));
361
381
  }
362
382
  catch {
363
383
  return [];
@@ -401,6 +421,116 @@ function removeLegacyArtifacts(paths, dryRun) {
401
421
  }
402
422
  return { removed };
403
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
+ }
404
534
  // ── auth helpers ──────────────────────────────────────────────────────────
405
535
  export function hasValidToken(home) {
406
536
  const path = gramatrTokenPath(home);
@@ -418,17 +548,46 @@ export async function install(opts) {
418
548
  const log = opts.log ?? ((m) => process.stderr.write(`${m}\n`));
419
549
  const dryRun = !!opts.dryRun;
420
550
  const cleanLegacy = !!opts.cleanLegacy || !!opts.nonInteractive;
551
+ const platform = opts.platformOverride ?? osPlatform();
552
+ const client = opts.client ?? detectClient(opts.home, platform);
421
553
  const summary = {
554
+ client,
422
555
  hookScriptWritten: false,
423
556
  sessionEndScriptWritten: false,
424
557
  sessionStartScriptWritten: false,
425
558
  stopScriptWritten: false,
426
559
  settingsUpdated: false,
427
560
  claudeMdUpdated: false,
561
+ desktopConfigUpdated: false,
428
562
  legacyEntriesRemoved: 0,
429
563
  legacyFilesRemoved: [],
564
+ legacySlashCommandsRemoved: [],
430
565
  backups: [],
431
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) ──
432
591
  // 1. Hook script
433
592
  const hookSrc = opts.hookSourcePath ?? resolveBundledHookSource();
434
593
  const hookDst = gramatrHookScriptPath(opts.home);
@@ -571,28 +730,174 @@ export async function install(opts) {
571
730
  for (const p of res.removed)
572
731
  log(`[gramatr] cleaned ${dryRun ? "(dry-run) " : ""}${p}`);
573
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
+ }
574
739
  }
575
740
  else {
576
741
  const legacy = detectLegacyArtifacts(opts.home);
577
742
  if (legacy.length > 0) {
578
743
  log(`[gramatr] note: detected ${legacy.length} legacy artifact${legacy.length === 1 ? "" : "s"}; rerun with --clean-legacy to remove`);
579
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
+ }
580
749
  }
581
750
  return summary;
582
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
+ }
583
844
  export async function uninstall(opts) {
584
845
  const log = opts.log ?? ((m) => process.stderr.write(`${m}\n`));
585
846
  const dryRun = !!opts.dryRun;
847
+ const platform = opts.platformOverride ?? osPlatform();
848
+ const client = opts.client ?? detectClient(opts.home, platform);
586
849
  const summary = {
850
+ client,
587
851
  hookEntryRemoved: false,
588
852
  claudeMdSectionRemoved: false,
589
853
  hookScriptRemoved: false,
590
854
  sessionEndScriptRemoved: false,
591
855
  sessionStartScriptRemoved: false,
592
856
  stopScriptRemoved: false,
857
+ desktopConfigUpdated: false,
858
+ legacySlashCommandsRemoved: [],
593
859
  tokenRemoved: false,
594
860
  backups: [],
595
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
+ }
596
901
  // 1. settings.json
597
902
  const settingsPath = claudeSettingsPath(opts.home);
598
903
  const settingsRaw = readFileOr(settingsPath, null);
@@ -675,6 +980,12 @@ export async function uninstall(opts) {
675
980
  summary.stopScriptRemoved = true;
676
981
  log(`[gramatr] Stop hook script ${dryRun ? "(dry-run) " : ""}→ removed ${stopHookDst}`);
677
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
+ }
678
989
  // 4. token (only on --purge)
679
990
  if (opts.purge) {
680
991
  const tokPath = gramatrTokenPath(opts.home);
@@ -725,6 +1036,17 @@ function resolveHome() {
725
1036
  // gramatr-allow: C1 — CLI entry point, reads HOME for config path
726
1037
  return process.env.HOME || process.env.USERPROFILE || "";
727
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
+ }
728
1050
  export async function runInstallCli(argv) {
729
1051
  const home = resolveHome();
730
1052
  if (!home) {
@@ -733,6 +1055,7 @@ export async function runInstallCli(argv) {
733
1055
  }
734
1056
  const opts = {
735
1057
  home,
1058
+ client: parseClientFlag(argv),
736
1059
  cleanLegacy: argv.includes("--clean-legacy"),
737
1060
  nonInteractive: argv.includes("--non-interactive") || argv.includes("--yes") || argv.includes("-y"),
738
1061
  dryRun: argv.includes("--dry-run"),
@@ -753,6 +1076,19 @@ export async function runInstallCli(argv) {
753
1076
  }
754
1077
  const summary = await install(opts);
755
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
+ }
756
1092
  process.stderr.write(` hook script: ${summary.hookScriptWritten ? "written" : "up-to-date"}\n`);
757
1093
  process.stderr.write(` SessionEnd hook script: ${summary.sessionEndScriptWritten ? "written" : "up-to-date"}\n`);
758
1094
  process.stderr.write(` SessionStart hook script: ${summary.sessionStartScriptWritten ? "written" : "up-to-date"}\n`);
@@ -765,6 +1101,9 @@ export async function runInstallCli(argv) {
765
1101
  if (summary.legacyFilesRemoved.length > 0) {
766
1102
  process.stderr.write(` legacy files removed: ${summary.legacyFilesRemoved.length}\n`);
767
1103
  }
1104
+ if (summary.legacySlashCommandsRemoved.length > 0) {
1105
+ process.stderr.write(` legacy slash commands removed: ${summary.legacySlashCommandsRemoved.length}\n`);
1106
+ }
768
1107
  if (summary.backups.length > 0) {
769
1108
  process.stderr.write(` backups: ${summary.backups.length}\n`);
770
1109
  for (const b of summary.backups)
@@ -781,16 +1120,33 @@ export async function runUninstallCli(argv) {
781
1120
  }
782
1121
  const summary = await uninstall({
783
1122
  home,
1123
+ client: parseClientFlag(argv),
784
1124
  purge: argv.includes("--purge"),
785
1125
  dryRun: argv.includes("--dry-run"),
786
1126
  });
787
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
+ }
788
1141
  process.stderr.write(` hook entry: ${summary.hookEntryRemoved ? "removed" : "not present"}\n`);
789
1142
  process.stderr.write(` CLAUDE.md section: ${summary.claudeMdSectionRemoved ? "removed" : "not present"}\n`);
790
1143
  process.stderr.write(` hook script: ${summary.hookScriptRemoved ? "removed" : "not present"}\n`);
791
1144
  process.stderr.write(` SessionEnd hook script: ${summary.sessionEndScriptRemoved ? "removed" : "not present"}\n`);
792
1145
  process.stderr.write(` SessionStart hook script: ${summary.sessionStartScriptRemoved ? "removed" : "not present"}\n`);
793
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
+ }
794
1150
  if (summary.tokenRemoved)
795
1151
  process.stderr.write(` token: removed\n`);
796
1152
  if (summary.backups.length > 0) {