gramatr 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CLAUDE.md +18 -0
  2. package/README.md +78 -0
  3. package/bin/clean-legacy-install.ts +28 -0
  4. package/bin/get-token.py +3 -0
  5. package/bin/gmtr-login.ts +547 -0
  6. package/bin/gramatr.js +33 -0
  7. package/bin/gramatr.ts +248 -0
  8. package/bin/install.ts +756 -0
  9. package/bin/render-claude-hooks.ts +16 -0
  10. package/bin/statusline.ts +437 -0
  11. package/bin/uninstall.ts +289 -0
  12. package/bin/version-sync.ts +46 -0
  13. package/codex/README.md +28 -0
  14. package/codex/hooks/session-start.ts +73 -0
  15. package/codex/hooks/stop.ts +34 -0
  16. package/codex/hooks/user-prompt-submit.ts +76 -0
  17. package/codex/install.ts +99 -0
  18. package/codex/lib/codex-hook-utils.ts +48 -0
  19. package/codex/lib/codex-install-utils.ts +123 -0
  20. package/core/feedback.ts +55 -0
  21. package/core/formatting.ts +167 -0
  22. package/core/install.ts +114 -0
  23. package/core/installer-cli.ts +122 -0
  24. package/core/migration.ts +244 -0
  25. package/core/routing.ts +98 -0
  26. package/core/session.ts +202 -0
  27. package/core/targets.ts +292 -0
  28. package/core/types.ts +178 -0
  29. package/core/version.ts +2 -0
  30. package/gemini/README.md +95 -0
  31. package/gemini/hooks/session-start.ts +72 -0
  32. package/gemini/hooks/stop.ts +30 -0
  33. package/gemini/hooks/user-prompt-submit.ts +74 -0
  34. package/gemini/install.ts +272 -0
  35. package/gemini/lib/gemini-hook-utils.ts +63 -0
  36. package/gemini/lib/gemini-install-utils.ts +169 -0
  37. package/hooks/GMTRPromptEnricher.hook.ts +650 -0
  38. package/hooks/GMTRRatingCapture.hook.ts +198 -0
  39. package/hooks/GMTRSecurityValidator.hook.ts +399 -0
  40. package/hooks/GMTRToolTracker.hook.ts +181 -0
  41. package/hooks/StopOrchestrator.hook.ts +78 -0
  42. package/hooks/gmtr-tool-tracker-utils.ts +105 -0
  43. package/hooks/lib/gmtr-hook-utils.ts +771 -0
  44. package/hooks/lib/identity.ts +227 -0
  45. package/hooks/lib/notify.ts +46 -0
  46. package/hooks/lib/paths.ts +104 -0
  47. package/hooks/lib/transcript-parser.ts +452 -0
  48. package/hooks/session-end.hook.ts +168 -0
  49. package/hooks/session-start.hook.ts +490 -0
  50. package/package.json +54 -0
package/bin/install.ts ADDED
@@ -0,0 +1,756 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gramatr installer — pure TypeScript, no bash dependency.
4
+ * Runs via node (with tsx loader), bun, or npx tsx.
5
+ *
6
+ * Usage:
7
+ * npx tsx install.ts # Universal (Node + tsx)
8
+ * bun install.ts # If bun is available
9
+ * GMTR_TOKEN=xxx npx tsx install.ts # Headless with token
10
+ * npx tsx install.ts --yes # Non-interactive (auto-accept defaults)
11
+ */
12
+
13
+ import {
14
+ existsSync, readFileSync, writeFileSync, mkdirSync, cpSync,
15
+ readdirSync, statSync, chmodSync, appendFileSync, rmSync,
16
+ } from 'fs';
17
+ import { join, dirname, basename, resolve } from 'path';
18
+ import { execSync, spawnSync } from 'child_process';
19
+ import { createInterface } from 'readline';
20
+ import { buildClaudeHooksFile, detectTsRunner } from '../core/install.ts';
21
+ import { VERSION } from '../core/version.ts';
22
+
23
+ // ── Constants ──
24
+
25
+ const HOME = process.env.HOME || process.env.USERPROFILE || '';
26
+ const CLAUDE_DIR = join(HOME, '.claude');
27
+ const CLAUDE_SETTINGS = join(CLAUDE_DIR, 'settings.json');
28
+ const CLAUDE_JSON = join(HOME, '.claude.json');
29
+ const CLIENT_DIR = join(HOME, 'gmtr-client');
30
+ const GMTR_JSON = join(HOME, '.gmtr.json');
31
+ const GMTR_BIN = join(HOME, '.gmtr', 'bin');
32
+ const SCRIPT_DIR = dirname(dirname(resolve(import.meta.filename || __filename)));
33
+ const DEFAULT_URL = 'https://api.gramatr.com/mcp';
34
+
35
+ const args = process.argv.slice(2);
36
+ const YES = args.includes('--yes') || args.includes('-y');
37
+ const isInteractive = process.stdin.isTTY && !YES;
38
+
39
+ // ── Helpers ──
40
+
41
+ function log(msg: string): void { process.stdout.write(`${msg}\n`); }
42
+
43
+ function readJson(path: string): Record<string, any> {
44
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return {}; }
45
+ }
46
+
47
+ function writeJson(path: string, data: Record<string, any>): void {
48
+ writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
49
+ }
50
+
51
+ function mergeJson(path: string, fn: (data: Record<string, any>) => Record<string, any>): void {
52
+ const data = readJson(path);
53
+ writeJson(path, fn(data));
54
+ }
55
+
56
+ async function prompt(msg: string, defaultVal?: string): Promise<string> {
57
+ if (!isInteractive) return defaultVal || '';
58
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
59
+ return new Promise((resolve) => {
60
+ const display = defaultVal ? `${msg} [${defaultVal}]: ` : `${msg}: `;
61
+ rl.question(display, (answer) => {
62
+ rl.close();
63
+ resolve(answer.trim() || defaultVal || '');
64
+ });
65
+ });
66
+ }
67
+
68
+ async function confirm(msg: string, defaultYes = true): Promise<boolean> {
69
+ if (YES) return defaultYes;
70
+ if (!isInteractive) return defaultYes;
71
+ const answer = await prompt(`${msg} [${defaultYes ? 'Y/n' : 'y/N'}]`);
72
+ if (!answer) return defaultYes;
73
+ return answer.toLowerCase() === 'y';
74
+ }
75
+
76
+ function countFiles(dir: string, pattern?: RegExp): number {
77
+ try {
78
+ return readdirSync(dir).filter(f => !pattern || pattern.test(f)).length;
79
+ } catch { return 0; }
80
+ }
81
+
82
+ function countFilesRecursive(dir: string): number {
83
+ let count = 0;
84
+ try {
85
+ for (const f of readdirSync(dir)) {
86
+ const p = join(dir, f);
87
+ const s = statSync(p);
88
+ if (s.isDirectory()) count += countFilesRecursive(p);
89
+ else count++;
90
+ }
91
+ } catch { /* ignore */ }
92
+ return count;
93
+ }
94
+
95
+ function copyDir(src: string, dest: string): void {
96
+ mkdirSync(dest, { recursive: true });
97
+ cpSync(src, dest, { recursive: true, force: true });
98
+ }
99
+
100
+ function dirSize(dir: string): string {
101
+ let total = 0;
102
+ const walk = (d: string) => {
103
+ try {
104
+ for (const f of readdirSync(d)) {
105
+ const p = join(d, f);
106
+ const s = statSync(p);
107
+ if (s.isDirectory()) walk(p); else total += s.size;
108
+ }
109
+ } catch { /* ignore */ }
110
+ };
111
+ walk(dir);
112
+ if (total > 1048576) return `${(total / 1048576).toFixed(1)}M`;
113
+ if (total > 1024) return `${(total / 1024).toFixed(0)}K`;
114
+ return `${total}B`;
115
+ }
116
+
117
+ function which(cmd: string): string | null {
118
+ try {
119
+ return execSync(`command -v ${cmd}`, { encoding: 'utf8' }).trim() || null;
120
+ } catch { return null; }
121
+ }
122
+
123
+ function copyFileIfExists(src: string, dest: string, executable = false): boolean {
124
+ if (!existsSync(src)) return false;
125
+ mkdirSync(dirname(dest), { recursive: true });
126
+ cpSync(src, dest, { force: true });
127
+ if (executable) chmodSync(dest, 0o755);
128
+ return true;
129
+ }
130
+
131
+ function timestamp(): string {
132
+ return new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
133
+ }
134
+
135
+ // ── Prerequisites ──
136
+
137
+ async function checkPrereqs(): Promise<{ tsRunner: string }> {
138
+ // Node.js
139
+ const nodeVer = process.versions.node;
140
+ const major = parseInt(nodeVer.split('.')[0], 10);
141
+ if (major < 20) {
142
+ log(`X Node.js 20+ required (found: ${nodeVer})`);
143
+ process.exit(1);
144
+ }
145
+ log(`OK Node.js v${nodeVer}`);
146
+
147
+ // Detect best TypeScript runner
148
+ const tsRunner = detectTsRunner();
149
+ if (tsRunner === 'bun') {
150
+ try {
151
+ const ver = execSync('bun --version', { encoding: 'utf8' }).trim();
152
+ log(`OK TS runner: bun ${ver}`);
153
+ } catch {
154
+ log(`OK TS runner: bun`);
155
+ }
156
+ } else {
157
+ log(`OK TS runner: npx tsx (install bun for faster hooks)`);
158
+ }
159
+
160
+ // Ensure ~/.gmtr/bin exists and is on PATH
161
+ mkdirSync(GMTR_BIN, { recursive: true });
162
+ const pathLine = 'export PATH="$HOME/.gmtr/bin:$PATH"';
163
+ for (const rc of ['.zshrc', '.bashrc']) {
164
+ const rcPath = join(HOME, rc);
165
+ if (existsSync(rcPath)) {
166
+ const content = readFileSync(rcPath, 'utf8');
167
+ if (!content.includes('.gmtr/bin')) {
168
+ appendFileSync(rcPath, `\n# gramatr — local tool binaries\n${pathLine}\n`);
169
+ }
170
+ }
171
+ }
172
+
173
+ // Claude Code
174
+ if (!existsSync(CLAUDE_DIR)) {
175
+ log('X Claude Code not found (~/.claude/ does not exist)');
176
+ process.exit(1);
177
+ }
178
+ log('OK Claude Code directory exists');
179
+
180
+ return { tsRunner };
181
+ }
182
+
183
+ // ── Legacy Detection ──
184
+
185
+ async function handleLegacy(): Promise<{ legacyToken: string }> {
186
+ let legacyToken = '';
187
+
188
+ // Check for aios MCP server
189
+ if (existsSync(CLAUDE_JSON)) {
190
+ const claudeJson = readJson(CLAUDE_JSON);
191
+ const aiosUrl = claudeJson.mcpServers?.aios?.url;
192
+ const aiosAuth = claudeJson.mcpServers?.aios?.headers?.Authorization;
193
+ const aiosToken = aiosAuth?.replace('Bearer ', '') || '';
194
+
195
+ if (aiosUrl) {
196
+ log('━━━ Legacy Installation Detected ━━━');
197
+ log('');
198
+ log(` Found: aios MCP server`);
199
+ log(` URL: ${aiosUrl}`);
200
+ if (aiosToken) log(` Token: ${aiosToken.slice(0, 20)}... (present)`);
201
+ log('');
202
+
203
+ if (await confirm(' Migrate to gramatr? This removes aios config and reuses your auth token.')) {
204
+ if (aiosToken) {
205
+ legacyToken = aiosToken;
206
+ log(' OK Extracted auth token from aios config');
207
+ }
208
+ delete claudeJson.mcpServers.aios;
209
+ writeJson(CLAUDE_JSON, claudeJson);
210
+ log(' OK Removed aios MCP server from ~/.claude.json');
211
+
212
+ const oldClient = join(HOME, 'aios-v2-client');
213
+ if (existsSync(oldClient)) {
214
+ rmSync(oldClient, { recursive: true, force: true });
215
+ log(' OK Removed ~/aios-v2-client/');
216
+ }
217
+ log('');
218
+ }
219
+ }
220
+ }
221
+
222
+ // Check for PAI
223
+ const paiDir = join(CLAUDE_DIR, 'skills', 'PAI');
224
+ if (existsSync(paiDir)) {
225
+ log('━━━ PAI Installation Detected ━━━');
226
+ log(` Size: ${dirSize(paiDir)}`);
227
+ log('');
228
+ if (await confirm(' Remove PAI? gramatr provides all PAI capabilities server-side.')) {
229
+ rmSync(paiDir, { recursive: true, force: true });
230
+ log(' OK Removed ~/.claude/skills/PAI/');
231
+ }
232
+ log('');
233
+ }
234
+
235
+ // Check for Fabric
236
+ const fabricDir = join(CLAUDE_DIR, 'skills', 'Fabric');
237
+ if (existsSync(fabricDir)) {
238
+ log('━━━ Fabric Installation Detected ━━━');
239
+ log(` Size: ${dirSize(fabricDir)}`);
240
+ log('');
241
+ if (await confirm(' Remove Fabric skill directory? (CLI tool preserved)')) {
242
+ rmSync(fabricDir, { recursive: true, force: true });
243
+ log(' OK Removed ~/.claude/skills/Fabric/');
244
+ }
245
+ log('');
246
+ }
247
+
248
+ return { legacyToken };
249
+ }
250
+
251
+ // ── Step 1: Copy client files ──
252
+
253
+ function installClientFiles(): void {
254
+ log('━━━ Step 1: Installing client files ━━━');
255
+ log('');
256
+
257
+ // Create directory structure
258
+ for (const sub of ['hooks/lib', 'bin', 'core']) {
259
+ mkdirSync(join(CLIENT_DIR, sub), { recursive: true });
260
+ }
261
+
262
+ // Hooks (7 core + utils)
263
+ let hookCount = 0;
264
+ const hooksSrc = join(SCRIPT_DIR, 'hooks');
265
+ if (existsSync(hooksSrc)) {
266
+ for (const f of readdirSync(hooksSrc)) {
267
+ const src = join(hooksSrc, f);
268
+ if (statSync(src).isFile() && (f.endsWith('.hook.ts') || f.endsWith('-utils.ts'))) {
269
+ cpSync(src, join(CLIENT_DIR, 'hooks', f));
270
+ chmodSync(join(CLIENT_DIR, 'hooks', f), 0o755);
271
+ hookCount++;
272
+ }
273
+ }
274
+
275
+ // Hook lib (paths, identity, notify, gmtr-hook-utils, transcript-parser)
276
+ const libHookSrc = join(hooksSrc, 'lib');
277
+ if (existsSync(libHookSrc)) {
278
+ const libs = readdirSync(libHookSrc).filter(f => f.endsWith('.ts') && !f.endsWith('.test.ts'));
279
+ for (const f of libs) cpSync(join(libHookSrc, f), join(CLIENT_DIR, 'hooks/lib', f));
280
+ log(`OK Installed ${hookCount} hooks + ${libs.length} lib modules`);
281
+ }
282
+ }
283
+
284
+ // Bin files
285
+ copyFileIfExists(join(SCRIPT_DIR, 'bin/statusline.ts'), join(CLIENT_DIR, 'bin/statusline.ts'), true);
286
+ copyFileIfExists(join(SCRIPT_DIR, 'bin/gmtr-login.ts'), join(CLIENT_DIR, 'bin/gmtr-login.ts'), true);
287
+ copyFileIfExists(join(SCRIPT_DIR, 'bin/render-claude-hooks.ts'), join(CLIENT_DIR, 'bin/render-claude-hooks.ts'), true);
288
+ log('OK Installed bin (statusline, gmtr-login, render-claude-hooks)');
289
+
290
+ // Core dependencies (routing.ts required by GMTRPromptEnricher hook)
291
+ for (const f of ['install.ts', 'version.ts', 'types.ts', 'routing.ts']) {
292
+ copyFileIfExists(join(SCRIPT_DIR, 'core', f), join(CLIENT_DIR, 'core', f));
293
+ }
294
+ log('OK Installed core modules');
295
+
296
+ // CLAUDE.md
297
+ copyFileIfExists(join(SCRIPT_DIR, 'CLAUDE.md'), join(CLIENT_DIR, 'CLAUDE.md'));
298
+ log('OK Installed CLAUDE.md (minimal — server delivers behavioral rules)');
299
+
300
+ log('');
301
+ log(` Thin client: ${hookCount} hooks, server delivers everything else`);
302
+ log('');
303
+ }
304
+
305
+ // ── Step 1b: CLAUDE.md behavioral framework ──
306
+
307
+ function installClaudeMd(): void {
308
+ log('━━━ Step 1b: Installing gramatr behavioral framework ━━━');
309
+ log('');
310
+
311
+ const claudeMd = join(HOME, '.claude', 'CLAUDE.md');
312
+ const gmtrSource = join(CLIENT_DIR, 'CLAUDE.md');
313
+ const GMTR_START = '<!-- GMTR-START';
314
+ const GMTR_END = '<!-- GMTR-END -->';
315
+
316
+ if (!existsSync(gmtrSource)) {
317
+ log('X CLAUDE.md source not found');
318
+ return;
319
+ }
320
+
321
+ const sourceContent = readFileSync(gmtrSource, 'utf8');
322
+
323
+ if (existsSync(claudeMd)) {
324
+ let content = readFileSync(claudeMd, 'utf8');
325
+
326
+ // Clean PAI directives
327
+ if (content.includes('read skills/PAI')) {
328
+ content = content.split('\n')
329
+ .filter(l => !l.includes('read skills/PAI') && !l.includes('# Read the PAI system'))
330
+ .join('\n');
331
+ }
332
+ if (content.startsWith('This file does nothing.')) {
333
+ content = content.replace(/^This file does nothing\.\n?/, '');
334
+ }
335
+
336
+ if (content.includes(GMTR_START)) {
337
+ // Replace existing GMTR section
338
+ const startIdx = content.indexOf(GMTR_START);
339
+ const endIdx = content.indexOf(GMTR_END);
340
+ if (endIdx > startIdx) {
341
+ content = content.slice(0, startIdx) + sourceContent + content.slice(endIdx + GMTR_END.length);
342
+ }
343
+ log('OK Updated GMTR section in existing CLAUDE.md');
344
+ } else {
345
+ content += '\n' + sourceContent;
346
+ log('OK Appended GMTR section to existing CLAUDE.md');
347
+ }
348
+ writeFileSync(claudeMd, content);
349
+ } else {
350
+ writeFileSync(claudeMd, sourceContent);
351
+ log('OK Created CLAUDE.md with gramatr behavioral framework');
352
+ }
353
+ log('');
354
+ }
355
+
356
+ // ── Step 2: (removed — skills/agents now server-side) ──
357
+
358
+ // ── Step 3: Auth ──
359
+
360
+ async function handleAuth(legacyToken: string, tsRunner: string): Promise<{ url: string; token: string }> {
361
+ log('━━━ Step 3: Configuring gramatr MCP server ━━━');
362
+ log('');
363
+
364
+ const url = await prompt('gramatr server URL', DEFAULT_URL) || DEFAULT_URL;
365
+
366
+ // Token priority: env > ~/.gmtr.json > legacy > login
367
+ let token = process.env.GMTR_TOKEN || '';
368
+
369
+ if (token) {
370
+ log('OK Using GMTR_TOKEN from environment');
371
+ }
372
+
373
+ if (!token && existsSync(GMTR_JSON)) {
374
+ const existing = readJson(GMTR_JSON);
375
+ if (existing.token) {
376
+ token = existing.token;
377
+ log('OK Found existing auth token in ~/.gmtr.json');
378
+ }
379
+ }
380
+
381
+ if (!token && legacyToken) {
382
+ token = legacyToken;
383
+ log('OK Reusing auth token from legacy aios installation');
384
+ }
385
+
386
+ // No token — run gmtr-login directly (imported, not subprocess)
387
+ if (!token && isInteractive) {
388
+ log('');
389
+ log(' No auth token found. Starting gramatr login...');
390
+ log('');
391
+
392
+ const loginScript = join(CLIENT_DIR, 'bin', 'gmtr-login.ts');
393
+ if (existsSync(loginScript)) {
394
+ // Run gmtr-login as subprocess but with proper stdio handling
395
+ // Use spawnSync so stdin is properly passed through (no stall)
396
+ const result = spawnSync(tsRunner, [loginScript], {
397
+ stdio: 'inherit',
398
+ env: { ...process.env },
399
+ });
400
+
401
+ // Re-read token after login
402
+ if (existsSync(GMTR_JSON)) {
403
+ const data = readJson(GMTR_JSON);
404
+ if (data.token) token = data.token;
405
+ }
406
+ }
407
+
408
+ if (!token) {
409
+ log(' Authentication skipped — run /gmtr-login later to authenticate');
410
+ }
411
+ log('');
412
+ } else if (!token) {
413
+ log('');
414
+ log('Non-interactive: no auth token. Set GMTR_TOKEN env var or run gmtr-login after install.');
415
+ log('');
416
+ }
417
+
418
+ return { url, token };
419
+ }
420
+
421
+ // ── Step 3b: Identity config ──
422
+
423
+ async function configureIdentity(): Promise<void> {
424
+ log('━━━ Step 3b: Creating gramatr identity config ━━━');
425
+ log('');
426
+
427
+ const settingsPath = join(CLIENT_DIR, 'settings.json');
428
+
429
+ if (!existsSync(settingsPath)) {
430
+ writeJson(settingsPath, {
431
+ daidentity: {
432
+ name: 'gramatr',
433
+ fullName: 'gramatr — Personal AI',
434
+ displayName: 'gramatr',
435
+ color: '#3B82F6',
436
+ },
437
+ principal: {
438
+ name: 'User',
439
+ timezone: 'UTC',
440
+ },
441
+ });
442
+ log(`OK Created ${settingsPath}`);
443
+ } else {
444
+ log('OK gramatr settings.json exists — preserving identity');
445
+ }
446
+
447
+ const settings = readJson(settingsPath);
448
+ const currentName = settings.principal?.name || 'User';
449
+
450
+ if (currentName === 'User' && isInteractive) {
451
+ const name = await prompt(' What is your name? (shown in gramatr responses)');
452
+ if (name) {
453
+ settings.principal = settings.principal || {};
454
+ settings.principal.name = name;
455
+ log(` OK Set principal.name to "${name}"`);
456
+ }
457
+
458
+ const tz = await prompt(' Your timezone?', 'America/Chicago');
459
+ settings.principal = settings.principal || {};
460
+ settings.principal.timezone = tz;
461
+ log(` OK Set timezone to "${tz}"`);
462
+
463
+ writeJson(settingsPath, settings);
464
+ }
465
+
466
+ log('');
467
+ }
468
+
469
+ // ── Step 4: Settings merge ──
470
+
471
+ function updateClaudeSettings(tsRunner: string, url: string, token: string): void {
472
+ log('━━━ Step 4: Updating Claude Code settings ━━━');
473
+ log('');
474
+
475
+ if (!existsSync(CLAUDE_SETTINGS)) {
476
+ writeJson(CLAUDE_SETTINGS, {});
477
+ }
478
+
479
+ // Backup
480
+ const backup = `${CLAUDE_SETTINGS}.backup-${timestamp()}`;
481
+ cpSync(CLAUDE_SETTINGS, backup);
482
+ log(`OK Backed up settings to ${basename(backup)}`);
483
+
484
+ const includeOptionalUx = process.env.GMTR_ENABLE_OPTIONAL_CLAUDE_UX === '1';
485
+ const hooksConfig = buildClaudeHooksFile(CLIENT_DIR, { includeOptionalUx, tsRunner });
486
+
487
+ const settings = readJson(CLAUDE_SETTINGS);
488
+
489
+ // Env vars
490
+ settings.env = settings.env || {};
491
+ settings.env.GMTR_DIR = CLIENT_DIR;
492
+ settings.env.GMTR_URL = url;
493
+ settings.env.PATH = `${HOME}/.gmtr/bin:/usr/local/bin:/usr/bin:/bin`;
494
+ if (token) settings.env.AIOS_MCP_TOKEN = token;
495
+
496
+ // Identity defaults (don't overwrite)
497
+ if (!settings.daidentity) {
498
+ settings.daidentity = {
499
+ name: 'gramatr',
500
+ fullName: 'gramatr — AI Intelligence Layer',
501
+ displayName: 'gramatr',
502
+ color: '#3B82F6',
503
+ };
504
+ }
505
+ if (!settings.principal) {
506
+ settings.principal = { name: 'User', timezone: 'UTC' };
507
+ }
508
+
509
+ // Hooks
510
+ settings.hooks = hooksConfig.hooks;
511
+ log('OK Wired managed gramatr hooks from shared install manifest');
512
+ if (includeOptionalUx) {
513
+ log('OK Optional Claude UX hooks enabled');
514
+ } else {
515
+ log('OK Thin-client hook set (set GMTR_ENABLE_OPTIONAL_CLAUDE_UX=1 for optional hooks)');
516
+ }
517
+
518
+ // Status line
519
+ settings.statusLine = {
520
+ type: 'command',
521
+ command: `${tsRunner} ${CLIENT_DIR}/bin/statusline.ts`,
522
+ };
523
+ log('OK Configured status line');
524
+
525
+ writeJson(CLAUDE_SETTINGS, settings);
526
+ log('');
527
+ }
528
+
529
+ // ── Step 4b: MCP server registration ──
530
+
531
+ function registerMcpServer(url: string, token: string): void {
532
+ log('━━━ Step 4b: Registering MCP server in ~/.claude.json ━━━');
533
+ log('');
534
+
535
+ if (!existsSync(CLAUDE_JSON)) writeJson(CLAUDE_JSON, {});
536
+
537
+ const backup = `${CLAUDE_JSON}.backup-${timestamp()}`;
538
+ cpSync(CLAUDE_JSON, backup);
539
+
540
+ // Store token in ~/.gmtr.json
541
+ if (token) {
542
+ mergeJson(GMTR_JSON, (data) => ({
543
+ ...data,
544
+ token,
545
+ token_updated_at: new Date().toISOString(),
546
+ }));
547
+ try { chmodSync(GMTR_JSON, 0o600); } catch { /* ok */ }
548
+ log('OK Token stored in ~/.gmtr.json (canonical source)');
549
+ }
550
+
551
+ // Write tool paths to ~/.gmtr.json
552
+ mergeJson(GMTR_JSON, (data) => ({
553
+ ...data,
554
+ jq_binary: which('jq') || '',
555
+ bun_binary: which('bun') || '',
556
+ }));
557
+
558
+ // Register in ~/.claude.json
559
+ mergeJson(CLAUDE_JSON, (data) => {
560
+ data.env = data.env || {};
561
+ if (token) data.env.GMTR_TOKEN = token;
562
+ delete data.env.AIOS_MCP_TOKEN;
563
+
564
+ data.mcpServers = data.mcpServers || {};
565
+ data.mcpServers.gramatr = {
566
+ type: 'http',
567
+ url,
568
+ headers: { Authorization: 'Bearer ${GMTR_TOKEN}' },
569
+ autoApprove: true,
570
+ };
571
+ return data;
572
+ });
573
+
574
+ log(`OK Registered MCP server 'gramatr' in ~/.claude.json -> ${url}`);
575
+ log('');
576
+ }
577
+
578
+ // ── Step 4c: Additional CLIs ──
579
+
580
+ function installAdditionalClis(tsRunner: string): void {
581
+ log('━━━ Step 4c: Additional CLI platforms ━━━');
582
+ log('');
583
+
584
+ // Codex
585
+ const codexDir = join(HOME, '.codex');
586
+ if (existsSync(codexDir) || which('codex')) {
587
+ log(' Codex CLI detected — installing gramatr hooks...');
588
+ const codexInstall = join(SCRIPT_DIR, 'codex', 'install.ts');
589
+ if (existsSync(codexInstall)) {
590
+ const result = spawnSync(tsRunner, [codexInstall], { stdio: 'inherit' });
591
+ if (result.status === 0) {
592
+ log(' OK Codex hooks installed');
593
+ } else {
594
+ log(' X Codex install failed (non-fatal)');
595
+ }
596
+ }
597
+ log('');
598
+ } else {
599
+ log(' -- Codex CLI not detected (skipping)');
600
+ }
601
+
602
+ // Gemini
603
+ const geminiDir = join(HOME, '.gemini');
604
+ if (existsSync(geminiDir) || which('gemini')) {
605
+ log(' Gemini CLI detected — installing gramatr extension...');
606
+ const geminiInstall = join(SCRIPT_DIR, 'gemini', 'install.ts');
607
+ if (existsSync(geminiInstall)) {
608
+ const result = spawnSync(tsRunner, [geminiInstall], { stdio: 'inherit' });
609
+ if (result.status === 0) {
610
+ log(' OK Gemini extension installed');
611
+ } else {
612
+ log(' X Gemini install failed (non-fatal)');
613
+ }
614
+ }
615
+ log('');
616
+ } else {
617
+ log(' -- Gemini CLI not detected (skipping)');
618
+ }
619
+
620
+ log('');
621
+ }
622
+
623
+ // ── Step 5: Verification ──
624
+
625
+ function verify(url: string, token: string): boolean {
626
+ log('━━━ Step 5: Verification ━━━');
627
+ log('');
628
+
629
+ let allOk = true;
630
+ const check = (condition: boolean, ok: string, fail: string) => {
631
+ if (condition) {
632
+ log(`OK ${ok}`);
633
+ } else {
634
+ log(`X ${fail}`);
635
+ allOk = false;
636
+ }
637
+ };
638
+
639
+ // Critical files
640
+ for (const f of [
641
+ 'hooks/GMTRToolTracker.hook.ts',
642
+ 'hooks/GMTRPromptEnricher.hook.ts',
643
+ 'hooks/GMTRRatingCapture.hook.ts',
644
+ 'hooks/GMTRSecurityValidator.hook.ts',
645
+ 'hooks/lib/notify.ts',
646
+ 'core/routing.ts',
647
+ 'bin/statusline.ts',
648
+ 'bin/gmtr-login.ts',
649
+ 'CLAUDE.md',
650
+ ]) {
651
+ check(existsSync(join(CLIENT_DIR, f)), f, `${f} MISSING`);
652
+ }
653
+
654
+ // Hook lib
655
+ for (const f of ['hooks/lib/identity.ts', 'hooks/lib/paths.ts']) {
656
+ check(existsSync(join(CLIENT_DIR, f)), f, `${f} MISSING`);
657
+ }
658
+
659
+ // Settings hooks
660
+ const settings = readJson(CLAUDE_SETTINGS);
661
+ const hookCounts = {
662
+ PreToolUse: (settings.hooks?.PreToolUse || []).length,
663
+ PostToolUse: (settings.hooks?.PostToolUse || []).length,
664
+ UserPromptSubmit: (settings.hooks?.UserPromptSubmit || []).length,
665
+ SessionStart: (settings.hooks?.SessionStart || []).length,
666
+ SessionEnd: (settings.hooks?.SessionEnd || []).length,
667
+ Stop: (settings.hooks?.Stop || []).length,
668
+ };
669
+ check(hookCounts.PreToolUse >= 4, `PreToolUse: ${hookCounts.PreToolUse} hooks`, `PreToolUse hooks missing (got ${hookCounts.PreToolUse}, need 4)`);
670
+ check(hookCounts.PostToolUse >= 1, `PostToolUse: ${hookCounts.PostToolUse} hooks`, `PostToolUse hooks missing (got ${hookCounts.PostToolUse}, need 1)`);
671
+ check(hookCounts.UserPromptSubmit >= 2, `UserPromptSubmit: ${hookCounts.UserPromptSubmit} groups`, `UserPromptSubmit hooks missing (got ${hookCounts.UserPromptSubmit}, need 2)`);
672
+ check(hookCounts.SessionStart >= 1, `SessionStart: ${hookCounts.SessionStart}`, `SessionStart hooks missing`);
673
+ check(hookCounts.SessionEnd >= 1, `SessionEnd: ${hookCounts.SessionEnd}`, `SessionEnd hooks missing`);
674
+ check(hookCounts.Stop >= 1, `Stop: ${hookCounts.Stop}`, `Stop hooks missing`);
675
+
676
+ // MCP server
677
+ const claudeJson = readJson(CLAUDE_JSON);
678
+ check(!!claudeJson.mcpServers?.gramatr, "MCP server 'gramatr' registered", 'MCP server missing');
679
+
680
+ // Status line
681
+ check(!!settings.statusLine?.command, 'Status line configured', 'Status line missing');
682
+
683
+ // No stale paths
684
+ const settingsStr = JSON.stringify(settings);
685
+ check(
686
+ !settingsStr.includes('PAI_DIR') && !settingsStr.includes('aios-v2-client'),
687
+ 'No stale PAI_DIR or aios-v2-client paths',
688
+ 'Stale PAI/AIOS paths found',
689
+ );
690
+
691
+ // CLAUDE.md
692
+ const claudeMd = join(HOME, '.claude', 'CLAUDE.md');
693
+ check(
694
+ existsSync(claudeMd) && readFileSync(claudeMd, 'utf8').includes('GMTR-START'),
695
+ 'CLAUDE.md contains gramatr behavioral framework',
696
+ 'CLAUDE.md missing GMTR section',
697
+ );
698
+
699
+ log('');
700
+ return allOk;
701
+ }
702
+
703
+ // ── Main ──
704
+
705
+ async function main(): Promise<void> {
706
+ log('');
707
+ log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
708
+ log(` gramatr v${VERSION} — Intelligence Layer`);
709
+ log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
710
+ log('');
711
+
712
+ const { tsRunner } = await checkPrereqs();
713
+ log('');
714
+
715
+ const { legacyToken } = await handleLegacy();
716
+
717
+ installClientFiles();
718
+ installClaudeMd();
719
+
720
+ const { url, token } = await handleAuth(legacyToken, tsRunner);
721
+ await configureIdentity();
722
+ updateClaudeSettings(tsRunner, url, token);
723
+ registerMcpServer(url, token);
724
+ installAdditionalClis(tsRunner);
725
+
726
+ const allOk = verify(url, token);
727
+
728
+ const totalFiles = countFilesRecursive(CLIENT_DIR);
729
+
730
+ if (allOk) {
731
+ log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
732
+ log(' gramatr Client installed successfully!');
733
+ log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
734
+ log('');
735
+ log(` Installed to: ${CLIENT_DIR} (${totalFiles} files)`);
736
+ log(` MCP server: ${url}`);
737
+ log('');
738
+ log(' Next steps:');
739
+ log(' 1. Restart Claude Code to pick up MCP server config');
740
+ if (!token) {
741
+ log(' 2. Run /gmtr-login in Claude Code to authenticate');
742
+ } else {
743
+ log(' 2. Already authenticated (token found in ~/.gmtr.json)');
744
+ }
745
+ log('');
746
+ } else {
747
+ log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
748
+ log(' Install completed with warnings');
749
+ log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
750
+ }
751
+ }
752
+
753
+ main().catch((err) => {
754
+ log(`\nX Install failed: ${err.message}\n`);
755
+ process.exit(1);
756
+ });