@gramatr/client 0.5.1

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 (65) hide show
  1. package/AGENTS.md +17 -0
  2. package/CLAUDE.md +18 -0
  3. package/README.md +108 -0
  4. package/bin/add-api-key.ts +264 -0
  5. package/bin/clean-legacy-install.ts +28 -0
  6. package/bin/clear-creds.ts +141 -0
  7. package/bin/get-token.py +3 -0
  8. package/bin/gmtr-login.ts +599 -0
  9. package/bin/gramatr.js +36 -0
  10. package/bin/gramatr.ts +374 -0
  11. package/bin/install.ts +716 -0
  12. package/bin/lib/config.ts +57 -0
  13. package/bin/lib/git.ts +111 -0
  14. package/bin/lib/stdin.ts +53 -0
  15. package/bin/logout.ts +76 -0
  16. package/bin/render-claude-hooks.ts +16 -0
  17. package/bin/statusline.ts +81 -0
  18. package/bin/uninstall.ts +289 -0
  19. package/chatgpt/README.md +95 -0
  20. package/chatgpt/install.ts +140 -0
  21. package/chatgpt/lib/chatgpt-install-utils.ts +89 -0
  22. package/codex/README.md +28 -0
  23. package/codex/hooks/session-start.ts +73 -0
  24. package/codex/hooks/stop.ts +34 -0
  25. package/codex/hooks/user-prompt-submit.ts +79 -0
  26. package/codex/install.ts +116 -0
  27. package/codex/lib/codex-hook-utils.ts +48 -0
  28. package/codex/lib/codex-install-utils.ts +123 -0
  29. package/core/auth.ts +170 -0
  30. package/core/feedback.ts +55 -0
  31. package/core/formatting.ts +179 -0
  32. package/core/install.ts +107 -0
  33. package/core/installer-cli.ts +122 -0
  34. package/core/migration.ts +479 -0
  35. package/core/routing.ts +108 -0
  36. package/core/session.ts +202 -0
  37. package/core/targets.ts +292 -0
  38. package/core/types.ts +179 -0
  39. package/core/version-check.ts +219 -0
  40. package/core/version.ts +47 -0
  41. package/desktop/README.md +72 -0
  42. package/desktop/build-mcpb.ts +166 -0
  43. package/desktop/install.ts +136 -0
  44. package/desktop/lib/desktop-install-utils.ts +70 -0
  45. package/gemini/README.md +95 -0
  46. package/gemini/hooks/session-start.ts +72 -0
  47. package/gemini/hooks/stop.ts +30 -0
  48. package/gemini/hooks/user-prompt-submit.ts +77 -0
  49. package/gemini/install.ts +281 -0
  50. package/gemini/lib/gemini-hook-utils.ts +63 -0
  51. package/gemini/lib/gemini-install-utils.ts +169 -0
  52. package/hooks/GMTRPromptEnricher.hook.ts +651 -0
  53. package/hooks/GMTRRatingCapture.hook.ts +198 -0
  54. package/hooks/GMTRSecurityValidator.hook.ts +399 -0
  55. package/hooks/GMTRToolTracker.hook.ts +181 -0
  56. package/hooks/StopOrchestrator.hook.ts +78 -0
  57. package/hooks/gmtr-tool-tracker-utils.ts +105 -0
  58. package/hooks/lib/gmtr-hook-utils.ts +770 -0
  59. package/hooks/lib/identity.ts +227 -0
  60. package/hooks/lib/notify.ts +46 -0
  61. package/hooks/lib/paths.ts +104 -0
  62. package/hooks/lib/transcript-parser.ts +452 -0
  63. package/hooks/session-end.hook.ts +168 -0
  64. package/hooks/session-start.hook.ts +501 -0
  65. package/package.json +63 -0
@@ -0,0 +1,122 @@
1
+ import type { StaleArtifact } from './migration.ts';
2
+ import type { IntegrationTargetId } from './targets.ts';
3
+
4
+ export interface DetectionDisplayTarget {
5
+ id: IntegrationTargetId;
6
+ label: string;
7
+ kind: 'local' | 'remote';
8
+ description: string;
9
+ detection: {
10
+ detected: boolean;
11
+ details?: string;
12
+ };
13
+ }
14
+
15
+ export function formatDetectionLines(detections: DetectionDisplayTarget[]): string[] {
16
+ const locals = detections.filter((target) => target.kind === 'local');
17
+ const remotes = detections.filter((target) => target.kind === 'remote');
18
+ const lines: string[] = [];
19
+
20
+ lines.push('Detected local integration targets:');
21
+ for (const target of locals) {
22
+ const status = target.detection.detected ? 'detected' : 'not found';
23
+ const suffix = target.detection.details ? ` (${target.detection.details})` : '';
24
+ lines.push(`- ${target.id}: ${status}${suffix}`);
25
+ }
26
+
27
+ lines.push('');
28
+ lines.push('Remote / hosted integration targets:');
29
+ for (const target of remotes) {
30
+ const suffix = target.detection.details ? ` (${target.detection.details})` : '';
31
+ lines.push(`- ${target.id}: ${target.description}${suffix}`);
32
+ }
33
+
34
+ return lines;
35
+ }
36
+
37
+ export function formatRemoteGuidanceLines(detections: DetectionDisplayTarget[]): string[] {
38
+ const remotes = detections.filter((target) => target.kind === 'remote');
39
+ const lines: string[] = [];
40
+ lines.push('');
41
+ lines.push('Hosted / remote targets are configured differently:');
42
+ for (const target of remotes) {
43
+ lines.push(`- ${target.id}: ${target.description}`);
44
+ }
45
+ lines.push("Use 'gramatr install remote-mcp' for remote setup guidance.");
46
+ return lines;
47
+ }
48
+
49
+ export function formatInstallMenuLines(
50
+ detections: DetectionDisplayTarget[],
51
+ ): { lines: string[]; targetIds: IntegrationTargetId[] } {
52
+ const targets = detections.filter((target) => target.kind === 'local');
53
+ const lines = ['Available local install targets:'];
54
+ for (const [index, target] of targets.entries()) {
55
+ const status = target.detection.detected ? 'detected' : 'not found';
56
+ lines.push(`${index + 1}. ${target.label} (${target.id}) - ${status}`);
57
+ }
58
+ lines.push(`${targets.length + 1}. all detected local targets`);
59
+ return { lines, targetIds: targets.map((target) => target.id) };
60
+ }
61
+
62
+ export function resolveInteractiveSelection(
63
+ answer: string,
64
+ targetIds: IntegrationTargetId[],
65
+ detectedLocalTargets: IntegrationTargetId[],
66
+ resolveTarget: (id: string) => { id: IntegrationTargetId; kind: 'local' | 'remote' } | undefined,
67
+ ): IntegrationTargetId[] {
68
+ if (!answer) return [];
69
+ if (answer === 'all') return detectedLocalTargets;
70
+
71
+ const parts = answer.split(',').map((part) => part.trim()).filter(Boolean);
72
+ const selected = new Set<IntegrationTargetId>();
73
+
74
+ for (const part of parts) {
75
+ const numeric = Number(part);
76
+ if (!Number.isNaN(numeric) && Number.isInteger(numeric)) {
77
+ if (numeric === targetIds.length + 1) {
78
+ for (const target of detectedLocalTargets) selected.add(target);
79
+ continue;
80
+ }
81
+ const indexed = targetIds[numeric - 1];
82
+ if (indexed) {
83
+ selected.add(indexed);
84
+ continue;
85
+ }
86
+ }
87
+
88
+ const target = resolveTarget(part);
89
+ if (target?.kind === 'local') {
90
+ selected.add(target.id);
91
+ continue;
92
+ }
93
+
94
+ throw new Error(`Unknown install target selection: ${part}`);
95
+ }
96
+
97
+ return Array.from(selected);
98
+ }
99
+
100
+ export function formatDoctorLines(
101
+ detections: DetectionDisplayTarget[],
102
+ payloadDir: string,
103
+ payloadPresent: boolean,
104
+ staleArtifacts: StaleArtifact[],
105
+ ): string[] {
106
+ const lines = formatDetectionLines(detections);
107
+ lines.push('');
108
+ lines.push(`Payload dir: ${payloadDir} ${payloadPresent ? '(present)' : '(missing)'}`);
109
+
110
+ if (staleArtifacts.length === 0) {
111
+ lines.push('Stale legacy artifacts: none detected');
112
+ return lines;
113
+ }
114
+
115
+ lines.push('Stale legacy artifacts:');
116
+ for (const artifact of staleArtifacts) {
117
+ lines.push(`- ${artifact.path} (${artifact.reason})`);
118
+ }
119
+ lines.push('Run `gramatr upgrade` after cleanup to re-sync installed targets.');
120
+ return lines;
121
+ }
122
+
@@ -0,0 +1,479 @@
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { buildClaudeHooksFile } from './install.ts';
4
+
5
+ // ── Deep clean: PAI / Fabric / aios artifact removal ─────────────────────────
6
+ //
7
+ // `gramatr migrate --deep` detects and removes PAI, Fabric, and aios
8
+ // leftovers from ~/.claude/{commands,skills} and from MCP server registries.
9
+ // Always dry-run unless --apply. Always backs up to ~/.claude/backups/
10
+ // before deleting. Conservative: deny-list only, never blanket-deletes.
11
+
12
+ /**
13
+ * Slash command + skill name patterns that identify PAI/Fabric/aios artifacts.
14
+ * Match is case-insensitive against the basename (without .md extension).
15
+ */
16
+ export const DEEP_CLEAN_DENY_PATTERNS: RegExp[] = [
17
+ /^pai[-_]/i,
18
+ /^pai$/i,
19
+ /^fabric[-_]/i,
20
+ /^fabric$/i,
21
+ /^aios[-_]/i,
22
+ /^aios$/i,
23
+ /^extract[-_]?wisdom/i,
24
+ /^extract[-_]/i,
25
+ /^analyze[-_]/i,
26
+ /^summarize[-_]/i,
27
+ /^create[-_]?prompt/i,
28
+ /^write[-_]?essay/i,
29
+ /^write[-_]?story/i,
30
+ /^get[-_]?wow/i,
31
+ /^improve[-_]?prompt/i,
32
+ /^official[-_]?pattern/i,
33
+ /^pattern[-_]/i,
34
+ /^council$/i,
35
+ /^prompting$/i,
36
+ /^becreative$/i,
37
+ /^beexpert$/i,
38
+ ];
39
+
40
+ /**
41
+ * MCP server keys that are PAI/Fabric/aios remnants (case-insensitive).
42
+ * The legacy migration already strips `aios`. This adds the rest.
43
+ */
44
+ export const DEEP_CLEAN_MCP_DENY_KEYS: RegExp[] = [
45
+ /^aios/i,
46
+ /^pai$/i,
47
+ /^pai[-_]/i,
48
+ /^fabric/i,
49
+ ];
50
+
51
+ /**
52
+ * Slash commands gramatr itself ships. Always preserved by deep-clean.
53
+ * Both legacy `gmtr-*` / `GMTR-*` and the new lowercase `gramatr-*` per #500.
54
+ */
55
+ export const GRAMATR_OWNED_COMMANDS: Set<string> = new Set([
56
+ 'gramatr-algorithm.md',
57
+ 'gramatr-init.md',
58
+ 'gramatr-login.md',
59
+ 'gramatr-query.md',
60
+ 'gramatr-recall.md',
61
+ 'gramatr-remember.md',
62
+ 'GMTR-ALGORITHM.md',
63
+ 'gmtr-init.md',
64
+ 'gmtr-login.md',
65
+ 'gmtr-query.md',
66
+ 'gmtr-recall.md',
67
+ 'gmtr-remember.md',
68
+ ]);
69
+
70
+ export interface DeepCleanArtifact {
71
+ kind: 'command-file' | 'skill-dir' | 'mcp-server-entry';
72
+ path: string;
73
+ reason: string;
74
+ // For mcp-server-entry: which file the entry lives in + the key.
75
+ mcpFile?: string;
76
+ mcpKey?: string;
77
+ }
78
+
79
+ export interface DeepCleanResult {
80
+ artifacts: DeepCleanArtifact[];
81
+ backupDir?: string;
82
+ }
83
+
84
+ function matchesDenyPattern(name: string): boolean {
85
+ const stem = name.replace(/\.md$/i, '');
86
+ return DEEP_CLEAN_DENY_PATTERNS.some((rx) => rx.test(stem));
87
+ }
88
+
89
+ function matchesMcpDenyKey(key: string): boolean {
90
+ return DEEP_CLEAN_MCP_DENY_KEYS.some((rx) => rx.test(key));
91
+ }
92
+
93
+ /**
94
+ * Detect PAI/Fabric/aios cruft. Read-only — never modifies the filesystem.
95
+ */
96
+ export function findDeepCleanArtifacts(homeDir: string): DeepCleanArtifact[] {
97
+ const out: DeepCleanArtifact[] = [];
98
+
99
+ // 1. ~/.claude/commands/*.md — flag any matching deny pattern OR (when not
100
+ // in the gramatr allowlist AND matching a known PAI prefix). User's own
101
+ // custom commands are NEVER touched unless they match a deny pattern.
102
+ const commandsDir = join(homeDir, '.claude', 'commands');
103
+ if (existsSync(commandsDir) && statSync(commandsDir).isDirectory()) {
104
+ for (const entry of readdirSync(commandsDir)) {
105
+ if (!entry.endsWith('.md')) continue;
106
+ if (GRAMATR_OWNED_COMMANDS.has(entry)) continue;
107
+ if (matchesDenyPattern(entry)) {
108
+ out.push({
109
+ kind: 'command-file',
110
+ path: join(commandsDir, entry),
111
+ reason: 'PAI/Fabric/aios slash command (matches deny pattern)',
112
+ });
113
+ }
114
+ }
115
+ }
116
+
117
+ // 2. ~/.claude/skills/*/ — flag any directory matching deny pattern.
118
+ // gramatr ships zero skills today; anything matching the deny patterns
119
+ // is definitely cruft. We do NOT blanket-delete the whole skills/ dir.
120
+ const skillsDir = join(homeDir, '.claude', 'skills');
121
+ if (existsSync(skillsDir) && statSync(skillsDir).isDirectory()) {
122
+ for (const entry of readdirSync(skillsDir)) {
123
+ const path = join(skillsDir, entry);
124
+ if (!statSync(path).isDirectory()) continue;
125
+ if (matchesDenyPattern(entry)) {
126
+ out.push({
127
+ kind: 'skill-dir',
128
+ path,
129
+ reason: 'PAI/Fabric skill directory (matches deny pattern)',
130
+ });
131
+ }
132
+ }
133
+ }
134
+
135
+ // 3. ~/.claude.json mcpServers + ~/.claude/mcp.json — flag deny-key entries.
136
+ for (const mcpFile of [join(homeDir, '.claude.json'), join(homeDir, '.claude', 'mcp.json')]) {
137
+ if (!existsSync(mcpFile)) continue;
138
+ let parsed: JsonObject;
139
+ try {
140
+ parsed = JSON.parse(readFileSync(mcpFile, 'utf8')) as JsonObject;
141
+ } catch {
142
+ continue;
143
+ }
144
+ const servers = isRecord(parsed.mcpServers) ? parsed.mcpServers : null;
145
+ if (!servers) continue;
146
+ for (const key of Object.keys(servers)) {
147
+ if (matchesMcpDenyKey(key)) {
148
+ out.push({
149
+ kind: 'mcp-server-entry',
150
+ path: `${mcpFile}#mcpServers.${key}`,
151
+ reason: `PAI/Fabric/aios MCP server entry "${key}"`,
152
+ mcpFile,
153
+ mcpKey: key,
154
+ });
155
+ }
156
+ }
157
+ }
158
+
159
+ return out;
160
+ }
161
+
162
+ /**
163
+ * Run a deep-clean. Dry-run unless apply=true. When applying, backs up every
164
+ * removed artifact to ~/.claude/backups/gramatr-deep-clean-<timestamp>/ before
165
+ * deletion (full reversibility).
166
+ */
167
+ export function runDeepClean(opts: {
168
+ homeDir: string;
169
+ apply: boolean;
170
+ log?: (message: string) => void;
171
+ }): DeepCleanResult {
172
+ const { homeDir, apply, log } = opts;
173
+ const artifacts = findDeepCleanArtifacts(homeDir);
174
+
175
+ log?.('gramatr deep clean (PAI / Fabric / aios)');
176
+ log?.(`Mode: ${apply ? 'apply' : 'dry-run'}`);
177
+ log?.(`Found ${artifacts.length} artifact(s)`);
178
+
179
+ if (artifacts.length === 0) {
180
+ log?.('Nothing to clean. Already gramatr-only.');
181
+ return { artifacts };
182
+ }
183
+
184
+ // Group + display
185
+ const byKind = new Map<string, DeepCleanArtifact[]>();
186
+ for (const a of artifacts) {
187
+ const arr = byKind.get(a.kind) ?? [];
188
+ arr.push(a);
189
+ byKind.set(a.kind, arr);
190
+ }
191
+ for (const [kind, list] of byKind) {
192
+ log?.(`\n${kind} (${list.length}):`);
193
+ for (const a of list) log?.(` ${a.path} — ${a.reason}`);
194
+ }
195
+
196
+ if (!apply) {
197
+ log?.('\nDry-run only. Re-run with --apply to remove these (with backup).');
198
+ return { artifacts };
199
+ }
200
+
201
+ // ─── APPLY MODE ───
202
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
203
+ const backupDir = join(homeDir, '.claude', 'backups', `gramatr-deep-clean-${ts}`);
204
+ mkdirSync(backupDir, { recursive: true });
205
+ log?.(`\nBacking up to ${backupDir}`);
206
+
207
+ for (const artifact of artifacts) {
208
+ if (artifact.kind === 'command-file') {
209
+ const dest = join(backupDir, 'commands', artifact.path.split('/').pop() ?? 'unknown');
210
+ mkdirSync(join(backupDir, 'commands'), { recursive: true });
211
+ cpSync(artifact.path, dest);
212
+ rmSync(artifact.path, { force: true });
213
+ log?.(` removed ${artifact.path}`);
214
+ } else if (artifact.kind === 'skill-dir') {
215
+ const dest = join(backupDir, 'skills', artifact.path.split('/').pop() ?? 'unknown');
216
+ mkdirSync(join(backupDir, 'skills'), { recursive: true });
217
+ cpSync(artifact.path, dest, { recursive: true });
218
+ rmSync(artifact.path, { recursive: true, force: true });
219
+ log?.(` removed ${artifact.path}`);
220
+ } else if (artifact.kind === 'mcp-server-entry') {
221
+ // Strip the entry from the JSON file. Backup the original file once.
222
+ if (!artifact.mcpFile || !artifact.mcpKey) continue;
223
+ const fileBackup = join(backupDir, artifact.mcpFile.split('/').pop() ?? 'mcp.json');
224
+ if (!existsSync(fileBackup)) cpSync(artifact.mcpFile, fileBackup);
225
+ const parsed = JSON.parse(readFileSync(artifact.mcpFile, 'utf8')) as JsonObject;
226
+ if (isRecord(parsed.mcpServers)) {
227
+ const next = { ...parsed.mcpServers };
228
+ delete next[artifact.mcpKey];
229
+ parsed.mcpServers = next;
230
+ writeFileSync(artifact.mcpFile, `${JSON.stringify(parsed, null, 2)}\n`);
231
+ }
232
+ log?.(` stripped ${artifact.path}`);
233
+ }
234
+ }
235
+
236
+ log?.(`\nDone. ${artifacts.length} artifact(s) removed. Backup: ${backupDir}`);
237
+ return { artifacts, backupDir };
238
+ }
239
+
240
+ const MANAGED_EVENTS = [
241
+ 'PreToolUse',
242
+ 'PostToolUse',
243
+ 'UserPromptSubmit',
244
+ 'SessionStart',
245
+ 'SessionEnd',
246
+ 'Stop',
247
+ ] as const;
248
+
249
+ const LEGACY_HOOK_BASENAMES = [
250
+ 'LoadContext.hook.ts',
251
+ 'SecurityValidator.hook.ts',
252
+ 'RatingCapture.hook.ts',
253
+ 'VoiceGate.hook.ts',
254
+ 'AutoWorkCreation.hook.ts',
255
+ 'WorkCompletionLearning.hook.ts',
256
+ 'RelationshipMemory.hook.ts',
257
+ 'SessionSummary.hook.ts',
258
+ 'UpdateCounts.hook.ts',
259
+ 'IntegrityCheck.hook.ts',
260
+ ];
261
+
262
+ export const LEGACY_CLIENT_DIRS = ['aios-v2-client'];
263
+ export const LEGACY_REMOVE_DIRS = ['.claude/hooks'];
264
+ export const LEGACY_REMOVE_HOOK_FILES = LEGACY_HOOK_BASENAMES;
265
+
266
+ export interface StaleArtifact {
267
+ kind: 'directory' | 'hook-file';
268
+ path: string;
269
+ reason: string;
270
+ }
271
+
272
+ export interface MigrationOptions {
273
+ homeDir: string;
274
+ clientDir: string;
275
+ includeOptionalUx?: boolean;
276
+ apply: boolean;
277
+ log?: (message: string) => void;
278
+ }
279
+
280
+ interface JsonObject {
281
+ [key: string]: unknown;
282
+ }
283
+
284
+ function isRecord(value: unknown): value is JsonObject {
285
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
286
+ }
287
+
288
+ function commandLooksLegacy(command: string): boolean {
289
+ if (command.includes('/.claude/hooks/')) return true;
290
+ if (command.includes('/aios-v2-client/')) return true;
291
+ return LEGACY_HOOK_BASENAMES.some((basename) => command.includes(`/${basename}`));
292
+ }
293
+
294
+ function stripLegacyEntriesFromHookEvent(value: unknown): unknown {
295
+ if (!Array.isArray(value)) return value;
296
+
297
+ return value
298
+ .map((entry) => {
299
+ if (!isRecord(entry) || !Array.isArray(entry.hooks)) return entry;
300
+ const hooks = entry.hooks.filter((hook) => {
301
+ if (!isRecord(hook) || typeof hook.command !== 'string') return true;
302
+ return !commandLooksLegacy(hook.command);
303
+ });
304
+
305
+ if (hooks.length === 0) return null;
306
+ return { ...entry, hooks };
307
+ })
308
+ .filter(Boolean);
309
+ }
310
+
311
+ export function sanitizeClaudeSettings(
312
+ settings: JsonObject,
313
+ clientDir: string,
314
+ _includeOptionalUx: boolean = false, // deprecated — thin client has no optional hooks
315
+ ): JsonObject {
316
+ const next = { ...settings };
317
+ const hooks = isRecord(next.hooks) ? { ...next.hooks } : {};
318
+
319
+ for (const [eventName, eventValue] of Object.entries(hooks)) {
320
+ hooks[eventName] = stripLegacyEntriesFromHookEvent(eventValue);
321
+ }
322
+
323
+ const managed = buildClaudeHooksFile(clientDir).hooks;
324
+ for (const eventName of MANAGED_EVENTS) {
325
+ hooks[eventName] = managed[eventName];
326
+ }
327
+
328
+ next.hooks = hooks;
329
+ return next;
330
+ }
331
+
332
+ export function sanitizeClaudeJson(claudeJson: JsonObject): JsonObject {
333
+ if (!isRecord(claudeJson.mcpServers)) return claudeJson;
334
+ const mcpServers = { ...claudeJson.mcpServers };
335
+ delete mcpServers.aios;
336
+ return { ...claudeJson, mcpServers };
337
+ }
338
+
339
+ export function findStaleArtifacts(
340
+ homeDir: string,
341
+ clientDir: string,
342
+ exists: (path: string) => boolean,
343
+ ): StaleArtifact[] {
344
+ const artifacts: StaleArtifact[] = [];
345
+
346
+ for (const dir of LEGACY_CLIENT_DIRS) {
347
+ const path = `${homeDir}/${dir}`;
348
+ if (exists(path)) {
349
+ artifacts.push({
350
+ kind: 'directory',
351
+ path,
352
+ reason: 'Legacy client payload directory',
353
+ });
354
+ }
355
+ }
356
+
357
+ for (const dir of LEGACY_REMOVE_DIRS) {
358
+ const path = `${homeDir}/${dir}`;
359
+ if (exists(path)) {
360
+ artifacts.push({
361
+ kind: 'directory',
362
+ path,
363
+ reason: 'Legacy Claude hook directory',
364
+ });
365
+ }
366
+ }
367
+
368
+ for (const basename of LEGACY_REMOVE_HOOK_FILES) {
369
+ const path = `${clientDir}/hooks/${basename}`;
370
+ if (exists(path)) {
371
+ artifacts.push({
372
+ kind: 'hook-file',
373
+ path,
374
+ reason: 'Deprecated legacy hook file',
375
+ });
376
+ }
377
+ }
378
+
379
+ return artifacts;
380
+ }
381
+
382
+ function backupFile(path: string, log?: (message: string) => void): void {
383
+ if (!existsSync(path)) return;
384
+ const backup = `${path}.backup-${Date.now()}`;
385
+ cpSync(path, backup);
386
+ log?.(`OK Backed up ${path} -> ${backup}`);
387
+ }
388
+
389
+ function readJson(path: string): Record<string, unknown> {
390
+ return JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
391
+ }
392
+
393
+ function writeJson(path: string, value: Record<string, unknown>): void {
394
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
395
+ }
396
+
397
+ function removeIfExists(path: string, log?: (message: string) => void): void {
398
+ if (!existsSync(path)) return;
399
+ rmSync(path, { recursive: true, force: true });
400
+ log?.(`OK Removed ${path}`);
401
+ }
402
+
403
+ function removeLegacyHookFiles(clientDir: string, log?: (message: string) => void): void {
404
+ const hooksDir = join(clientDir, 'hooks');
405
+ if (!existsSync(hooksDir)) return;
406
+
407
+ for (const basename of LEGACY_REMOVE_HOOK_FILES) {
408
+ const path = join(hooksDir, basename);
409
+ if (existsSync(path)) {
410
+ rmSync(path, { force: true });
411
+ log?.(`OK Removed stale hook ${path}`);
412
+ }
413
+ }
414
+
415
+ for (const entry of readdirSync(hooksDir)) {
416
+ if (!entry.endsWith('.sh')) continue;
417
+ const path = join(hooksDir, entry);
418
+ if (statSync(path).isFile()) {
419
+ rmSync(path, { force: true });
420
+ log?.(`OK Removed stale shell hook ${path}`);
421
+ }
422
+ }
423
+ }
424
+
425
+ export function runLegacyMigration(options: MigrationOptions): void {
426
+ const { homeDir, clientDir, includeOptionalUx = false, apply, log } = options;
427
+ const claudeSettingsPath = join(homeDir, '.claude', 'settings.json');
428
+ const claudeJsonPath = join(homeDir, '.claude.json');
429
+
430
+ log?.('grāmatr legacy install cleanup');
431
+ log?.(`Mode: ${apply ? 'apply' : 'dry-run'}`);
432
+ log?.(`Client dir: ${clientDir}`);
433
+
434
+ if (existsSync(claudeSettingsPath)) {
435
+ const sanitized = sanitizeClaudeSettings(readJson(claudeSettingsPath), clientDir, includeOptionalUx);
436
+ if (apply) {
437
+ backupFile(claudeSettingsPath, log);
438
+ writeJson(claudeSettingsPath, sanitized);
439
+ log?.(`OK Sanitized ${claudeSettingsPath}`);
440
+ } else {
441
+ log?.(`Would sanitize ${claudeSettingsPath}`);
442
+ }
443
+ }
444
+
445
+ if (existsSync(claudeJsonPath)) {
446
+ const sanitized = sanitizeClaudeJson(readJson(claudeJsonPath));
447
+ if (apply) {
448
+ backupFile(claudeJsonPath, log);
449
+ writeJson(claudeJsonPath, sanitized);
450
+ log?.(`OK Sanitized ${claudeJsonPath}`);
451
+ } else {
452
+ log?.(`Would sanitize ${claudeJsonPath}`);
453
+ }
454
+ }
455
+
456
+ for (const dir of LEGACY_CLIENT_DIRS) {
457
+ const path = join(homeDir, dir);
458
+ if (apply) {
459
+ removeIfExists(path, log);
460
+ } else if (existsSync(path)) {
461
+ log?.(`Would remove ${path}`);
462
+ }
463
+ }
464
+
465
+ for (const dir of LEGACY_REMOVE_DIRS) {
466
+ const path = join(homeDir, dir);
467
+ if (apply) {
468
+ removeIfExists(path, log);
469
+ } else if (existsSync(path)) {
470
+ log?.(`Would remove ${path}`);
471
+ }
472
+ }
473
+
474
+ if (apply) {
475
+ removeLegacyHookFiles(clientDir, log);
476
+ } else if (existsSync(join(clientDir, 'hooks'))) {
477
+ log?.(`Would remove stale hook files from ${join(clientDir, 'hooks')}`);
478
+ }
479
+ }
@@ -0,0 +1,108 @@
1
+ import {
2
+ callMcpToolDetailed,
3
+ resolveMcpUrl,
4
+ saveLastClassification,
5
+ type MctToolCallError,
6
+ } from '../hooks/lib/gmtr-hook-utils.ts';
7
+ import type { RouteResponse } from './types.ts';
8
+
9
+ export const MIN_PROMPT_LENGTH = 10;
10
+ export const TRIVIAL_PATTERNS =
11
+ /^(hi|hey|hello|yo|ok|yes|no|thanks|thank you|sure|yep|nope|k|bye|quit|exit)\b/i;
12
+
13
+ export function shouldSkipPromptRouting(prompt: string): boolean {
14
+ const trimmed = prompt.trim();
15
+ return trimmed.length < MIN_PROMPT_LENGTH || TRIVIAL_PATTERNS.test(trimmed);
16
+ }
17
+
18
+ export async function routePrompt(options: {
19
+ prompt: string;
20
+ projectId?: string;
21
+ sessionId?: string;
22
+ timeoutMs?: number;
23
+ /**
24
+ * Cross-tool statusline injection (#496). When true, the server attaches a
25
+ * composed [GMTR Status] markdown block to the route response. Hooks for
26
+ * Codex and Gemini set this; the Claude Code path leaves it false because
27
+ * Claude Code has its own native terminal statusline.
28
+ */
29
+ includeStatusline?: boolean;
30
+ statuslineSize?: 'small' | 'medium' | 'large';
31
+ }): Promise<{ route: RouteResponse | null; error: MctToolCallError | null }> {
32
+ const result = await callMcpToolDetailed<RouteResponse>(
33
+ 'gmtr_route_request',
34
+ {
35
+ prompt: options.prompt,
36
+ ...(options.projectId ? { project_id: options.projectId } : {}),
37
+ ...(options.sessionId ? { session_id: options.sessionId } : {}),
38
+ ...(options.includeStatusline ? { include_statusline: true } : {}),
39
+ ...(options.statuslineSize ? { statusline_size: options.statuslineSize } : {}),
40
+ },
41
+ options.timeoutMs ?? 15000,
42
+ );
43
+
44
+ return {
45
+ route: result.data,
46
+ error: result.error,
47
+ };
48
+ }
49
+
50
+ export function describeRoutingFailure(error: MctToolCallError): {
51
+ title: string;
52
+ detail: string;
53
+ action: string;
54
+ } {
55
+ switch (error.reason) {
56
+ case 'auth':
57
+ return {
58
+ title: 'Routing request failed due to authentication.',
59
+ detail: error.detail,
60
+ action: 'Check the configured GMTR token for the hook runtime.',
61
+ };
62
+ case 'timeout':
63
+ return {
64
+ title: 'Routing request timed out.',
65
+ detail: error.detail,
66
+ action: 'Check gramatr server latency or increase the hook timeout.',
67
+ };
68
+ case 'network_error':
69
+ return {
70
+ title: 'Routing request could not reach the gramatr MCP endpoint.',
71
+ detail: error.detail,
72
+ action: `Verify connectivity to ${resolveMcpUrl()}.`,
73
+ };
74
+ case 'http_error':
75
+ case 'mcp_error':
76
+ case 'parse_error':
77
+ case 'unknown':
78
+ default:
79
+ return {
80
+ title: 'Routing request failed before intelligence could be injected.',
81
+ detail: error.detail,
82
+ action: `Inspect the response from ${resolveMcpUrl()} and the gmtr_route_request handler.`,
83
+ };
84
+ }
85
+ }
86
+
87
+ export function persistClassificationResult(options: {
88
+ rootDir: string;
89
+ prompt: string;
90
+ route: RouteResponse | null;
91
+ downstreamModel: string | null;
92
+ clientType: string;
93
+ agentName: string;
94
+ }): void {
95
+ saveLastClassification(options.rootDir, {
96
+ timestamp: new Date().toISOString(),
97
+ original_prompt: options.prompt,
98
+ effort_level: options.route?.classification?.effort_level || null,
99
+ intent_type: options.route?.classification?.intent_type || null,
100
+ confidence: options.route?.classification?.confidence ?? null,
101
+ classifier_model: options.route?.execution_summary?.classifier_model || null, // previously: qwen_model
102
+ downstream_model: options.downstreamModel || null,
103
+ client_type: options.clientType,
104
+ agent_name: options.agentName,
105
+ pending_feedback: true,
106
+ feedback_submitted_at: null,
107
+ });
108
+ }