@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,599 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gmtr-login — Authenticate with the gramatr server
4
+ *
5
+ * Opens the grāmatr dashboard login flow, captures a Firebase ID token
6
+ * on localhost, and stores it in ~/.gmtr.json.
7
+ *
8
+ * Usage:
9
+ * bun gmtr-login.ts # Interactive browser login via Firebase dashboard
10
+ * bun gmtr-login.ts --token <token> # Paste a token directly (API key or Firebase token)
11
+ * bun gmtr-login.ts --status # Check current auth status
12
+ * bun gmtr-login.ts --logout # Remove stored credentials
13
+ *
14
+ * Token is stored in ~/.gmtr.json under the "token" key.
15
+ * The GMTRPromptEnricher hook reads this on every prompt.
16
+ */
17
+
18
+ import { randomBytes } from 'crypto';
19
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
20
+ import { join } from 'path';
21
+ import { createServer, type IncomingMessage, type ServerResponse } from 'http';
22
+
23
+ // ── Config ──
24
+
25
+ const HOME = process.env.HOME || process.env.USERPROFILE || '';
26
+ const CONFIG_PATH = join(HOME, '.gmtr.json');
27
+ const DEFAULT_SERVER = process.env.GMTR_URL || 'https://api.gramatr.com/mcp';
28
+ // Strip /mcp suffix to get base URL
29
+ const SERVER_BASE = DEFAULT_SERVER.replace(/\/mcp\/?$/, '');
30
+ const DASHBOARD_BASE = process.env.GMTR_DASHBOARD_URL || (() => {
31
+ try {
32
+ const url = new URL(SERVER_BASE);
33
+ if (url.hostname.startsWith('api.')) {
34
+ url.hostname = `app.${url.hostname.slice(4)}`;
35
+ }
36
+ url.pathname = '';
37
+ url.search = '';
38
+ url.hash = '';
39
+ return url.toString().replace(/\/$/, '');
40
+ } catch {
41
+ return 'https://app.gramatr.com';
42
+ }
43
+ })();
44
+ const CALLBACK_PORT = 58787; // Must match server's redirect_uris
45
+
46
+ // ── HTML Templates ──
47
+
48
+ const BRAND_CSS = `
49
+ * { margin: 0; padding: 0; box-sizing: border-box; }
50
+ body {
51
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
52
+ background: #0a0e17;
53
+ color: #e0e6ed;
54
+ min-height: 100vh;
55
+ display: flex;
56
+ align-items: center;
57
+ justify-content: center;
58
+ }
59
+ .card {
60
+ background: #141b2d;
61
+ border: 1px solid #1e2940;
62
+ border-radius: 16px;
63
+ padding: 48px;
64
+ max-width: 440px;
65
+ width: 90%;
66
+ text-align: center;
67
+ box-shadow: 0 8px 32px rgba(0,0,0,0.4);
68
+ }
69
+ .logo {
70
+ font-size: 28px;
71
+ font-weight: 700;
72
+ letter-spacing: -0.5px;
73
+ margin-bottom: 8px;
74
+ }
75
+ .logo .accent { color: #00b4d8; }
76
+ .logo .dim { color: #5a6a8a; }
77
+ .subtitle {
78
+ color: #5a6a8a;
79
+ font-size: 13px;
80
+ margin-bottom: 32px;
81
+ }
82
+ .status {
83
+ font-size: 48px;
84
+ margin-bottom: 16px;
85
+ }
86
+ h2 {
87
+ font-size: 20px;
88
+ font-weight: 600;
89
+ margin-bottom: 12px;
90
+ }
91
+ h2.success { color: #00b4d8; }
92
+ h2.error { color: #e74c3c; }
93
+ p {
94
+ color: #7a8aaa;
95
+ font-size: 14px;
96
+ line-height: 1.6;
97
+ }
98
+ .hint {
99
+ margin-top: 24px;
100
+ padding-top: 24px;
101
+ border-top: 1px solid #1e2940;
102
+ font-size: 12px;
103
+ color: #4a5a7a;
104
+ }
105
+ `;
106
+
107
+ function htmlPage(title: string, body: string): string {
108
+ return `<!DOCTYPE html>
109
+ <html lang="en"><head>
110
+ <meta charset="utf-8">
111
+ <meta name="viewport" content="width=device-width, initial-scale=1">
112
+ <title>${title} — gramatr</title>
113
+ <style>${BRAND_CSS}</style>
114
+ </head><body>
115
+ <div class="card">
116
+ <div class="logo"><span class="accent">gr</span>āma<span class="accent">tr</span></div>
117
+ <div class="subtitle">your cross-agent AI brain</div>
118
+ ${body}
119
+ </div>
120
+ </body></html>`;
121
+ }
122
+
123
+ function successPage(): string {
124
+ return htmlPage('Authenticated', `
125
+ <div class="status">✓</div>
126
+ <h2 class="success">Authenticated</h2>
127
+ <p>Token saved. You can close this tab and return to your terminal.</p>
128
+ <div class="hint">gramatr intelligence is now active across all your AI tools.</div>
129
+ `);
130
+ }
131
+
132
+ function errorPage(title: string, detail: string): string {
133
+ return htmlPage('Error', `
134
+ <div class="status">✗</div>
135
+ <h2 class="error">${title}</h2>
136
+ <p>${detail}</p>
137
+ <div class="hint">Return to your terminal and try again, or use <code>gmtr-login --token</code> to paste a token directly.</div>
138
+ `);
139
+ }
140
+
141
+ // ── Headless Detection ──
142
+
143
+ function isHeadless(): boolean {
144
+ // SSH session without display forwarding
145
+ if (process.env.SSH_CONNECTION || process.env.SSH_TTY) {
146
+ if (!process.env.DISPLAY && process.platform !== 'darwin') return true;
147
+ }
148
+ // Docker / CI / no TTY
149
+ if (process.env.CI || process.env.DOCKER) return true;
150
+ // Linux without display
151
+ if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return true;
152
+ return false;
153
+ }
154
+
155
+ // ── Helpers ──
156
+
157
+ function readConfig(): Record<string, any> {
158
+ try {
159
+ return JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
160
+ } catch {
161
+ return {};
162
+ }
163
+ }
164
+
165
+ function writeConfig(config: Record<string, any>): void {
166
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
167
+ }
168
+
169
+ async function checkServerHealth(): Promise<{ ok: boolean; version?: string; error?: string }> {
170
+ try {
171
+ const res = await fetch(`${SERVER_BASE}/health`, { signal: AbortSignal.timeout(5000) });
172
+ if (res.ok) {
173
+ const data = await res.json() as any;
174
+ return { ok: true, version: data.version };
175
+ }
176
+ return { ok: false, error: `HTTP ${res.status}` };
177
+ } catch (e: any) {
178
+ return { ok: false, error: e.message };
179
+ }
180
+ }
181
+
182
+ async function testToken(token: string): Promise<{ valid: boolean; user?: string; error?: string }> {
183
+ try {
184
+ const res = await fetch(`${SERVER_BASE}/mcp`, {
185
+ method: 'POST',
186
+ headers: {
187
+ 'Content-Type': 'application/json',
188
+ Accept: 'application/json, text/event-stream',
189
+ Authorization: `Bearer ${token}`,
190
+ },
191
+ body: JSON.stringify({
192
+ jsonrpc: '2.0',
193
+ id: 1,
194
+ method: 'tools/call',
195
+ params: { name: 'aggregate_stats', arguments: {} },
196
+ }),
197
+ signal: AbortSignal.timeout(10000),
198
+ });
199
+
200
+ const text = await res.text();
201
+
202
+ // Check for auth errors
203
+ if (text.includes('JWT token is required') || text.includes('signature validation failed') || text.includes('Unauthorized')) {
204
+ return { valid: false, error: 'Token rejected by server' };
205
+ }
206
+
207
+ // Check for successful response
208
+ for (const line of text.split('\n')) {
209
+ if (line.startsWith('data: ')) {
210
+ try {
211
+ const d = JSON.parse(line.slice(6));
212
+ if (d?.result?.isError) {
213
+ return { valid: false, error: d.result.content?.[0]?.text || 'Unknown error' };
214
+ }
215
+ if (d?.result?.content?.[0]?.text) {
216
+ return { valid: true, user: 'authenticated' };
217
+ }
218
+ } catch {
219
+ continue;
220
+ }
221
+ }
222
+ }
223
+
224
+ return { valid: false, error: 'Unexpected response' };
225
+ } catch (e: any) {
226
+ return { valid: false, error: e.message };
227
+ }
228
+ }
229
+
230
+ async function startDeviceAuthorization(): Promise<{
231
+ device_code: string;
232
+ user_code: string;
233
+ verification_uri: string;
234
+ verification_uri_complete?: string;
235
+ expires_in: number;
236
+ interval: number;
237
+ }> {
238
+ const res = await fetch(`${SERVER_BASE}/device/start`, {
239
+ method: 'POST',
240
+ headers: { 'Content-Type': 'application/json' },
241
+ body: JSON.stringify({ client_name: 'gmtr-login' }),
242
+ signal: AbortSignal.timeout(10000),
243
+ });
244
+
245
+ const payload = await res.json().catch(() => ({}));
246
+ if (!res.ok) {
247
+ throw new Error(payload.error_description || payload.error || `HTTP ${res.status}`);
248
+ }
249
+ return payload;
250
+ }
251
+
252
+ async function pollDeviceAuthorization(deviceCode: string): Promise<string> {
253
+ while (true) {
254
+ const res = await fetch(`${SERVER_BASE}/device/token`, {
255
+ method: 'POST',
256
+ headers: { 'Content-Type': 'application/json' },
257
+ body: JSON.stringify({ device_code: deviceCode }),
258
+ signal: AbortSignal.timeout(10000),
259
+ });
260
+
261
+ const payload = await res.json().catch(() => ({}));
262
+ if (res.ok && payload.access_token) {
263
+ return payload.access_token as string;
264
+ }
265
+
266
+ if ((res.status === 428 || res.status === 400) && payload.error === 'authorization_pending') {
267
+ const waitSeconds = Math.max(1, Number(payload.interval) || 5);
268
+ await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
269
+ continue;
270
+ }
271
+
272
+ throw new Error(payload.error_description || payload.error || `HTTP ${res.status}`);
273
+ }
274
+ }
275
+
276
+ // ── Commands ──
277
+
278
+ async function showStatus(): Promise<void> {
279
+ console.log('\n gramatr authentication status\n');
280
+
281
+ const config = readConfig();
282
+ const token = config.token;
283
+
284
+ console.log(` Server: ${SERVER_BASE}`);
285
+
286
+ const health = await checkServerHealth();
287
+ if (health.ok) {
288
+ console.log(` Health: ✓ healthy (v${health.version || 'unknown'})`);
289
+ } else {
290
+ console.log(` Health: ✗ ${health.error}`);
291
+ }
292
+
293
+ if (!token) {
294
+ console.log(' Token: ✗ not configured');
295
+ console.log('\n Run: bun gmtr-login.ts to authenticate\n');
296
+ return;
297
+ }
298
+
299
+ const prefix = token.substring(0, 15);
300
+ console.log(` Token: ${prefix}...`);
301
+
302
+ const result = await testToken(token);
303
+ if (result.valid) {
304
+ console.log(' Auth: ✓ token is valid');
305
+ } else {
306
+ console.log(` Auth: ✗ ${result.error}`);
307
+ console.log('\n Run: bun gmtr-login.ts to re-authenticate\n');
308
+ }
309
+
310
+ console.log('');
311
+ }
312
+
313
+ async function logout(): Promise<void> {
314
+ const config = readConfig();
315
+ delete config.token;
316
+ delete config.token_type;
317
+ delete config.token_expires;
318
+ delete config.authenticated_at;
319
+ writeConfig(config);
320
+ console.log('\n ✓ Logged out. Token removed from ~/.gmtr.json\n');
321
+ }
322
+
323
+ async function loginWithToken(token: string): Promise<void> {
324
+ console.log('\n Testing token...');
325
+
326
+ const result = await testToken(token);
327
+ if (result.valid) {
328
+ const config = readConfig();
329
+ config.token = token;
330
+ config.token_type = token.startsWith('aios_sk_') || token.startsWith('gmtr_sk_') ? 'api_key' : 'oauth';
331
+ config.authenticated_at = new Date().toISOString();
332
+ writeConfig(config);
333
+ console.log(' ✓ Token valid. Saved to ~/.gmtr.json');
334
+ console.log(' gramatr intelligence is now active.\n');
335
+ } else {
336
+ console.log(` ✗ Token rejected: ${result.error}`);
337
+ console.log(' Token was NOT saved.\n');
338
+ process.exit(1);
339
+ }
340
+ }
341
+
342
+ async function loginBrowser(): Promise<void> {
343
+ console.log('\n gramatr login\n');
344
+ console.log(` Server: ${SERVER_BASE}`);
345
+ console.log(` Dashboard: ${DASHBOARD_BASE}`);
346
+
347
+ // Check server health first
348
+ const health = await checkServerHealth();
349
+ if (!health.ok) {
350
+ console.log(` ✗ Server unreachable: ${health.error}`);
351
+ console.log(' Cannot authenticate. Is the server running?\n');
352
+ process.exit(1);
353
+ }
354
+ console.log(` Health: ✓ v${health.version || 'unknown'}`);
355
+
356
+ console.log('');
357
+
358
+ // Headless environments use device auth (no local server needed)
359
+ if (isHeadless()) {
360
+ console.log(' Headless environment detected. Starting device login...\n');
361
+ try {
362
+ const device = await startDeviceAuthorization();
363
+ console.log(` Code: ${device.user_code}`);
364
+ console.log(` Open: ${device.verification_uri_complete || device.verification_uri}`);
365
+ console.log(' Sign in with Google or GitHub, approve the device, then return here.\n');
366
+ console.log(' Waiting for authorization...');
367
+
368
+ // v0.3.63 hotfix: must clear the timeout after the race resolves,
369
+ // otherwise the orphan setTimeout keeps the Node event loop alive
370
+ // until expires_in elapses (typically 600s). Symptom: success path
371
+ // prints "Authenticated successfully" and then hangs until Ctrl+C.
372
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
373
+ const timeoutPromise = new Promise<string>((_, reject) => {
374
+ timeoutHandle = setTimeout(
375
+ () => reject(new Error('Device login timed out')),
376
+ device.expires_in * 1000,
377
+ );
378
+ });
379
+ let accessToken: string;
380
+ try {
381
+ accessToken = await Promise.race([
382
+ pollDeviceAuthorization(device.device_code),
383
+ timeoutPromise,
384
+ ]);
385
+ } finally {
386
+ if (timeoutHandle) clearTimeout(timeoutHandle);
387
+ }
388
+
389
+ const config = readConfig();
390
+ config.token = accessToken;
391
+ config.token_type = 'oauth';
392
+ config.authenticated_at = new Date().toISOString();
393
+ config.server_url = SERVER_BASE;
394
+ config.dashboard_url = DASHBOARD_BASE;
395
+ writeConfig(config);
396
+
397
+ console.log('');
398
+ console.log(' ✓ Authenticated successfully');
399
+ console.log(' Token saved to ~/.gmtr.json');
400
+ console.log(' gramatr intelligence is now active.\n');
401
+ return;
402
+ } catch (e: any) {
403
+ console.log(` ✗ Device login failed: ${e.message}`);
404
+ console.log(' Fallback: gmtr-login --token\n');
405
+ process.exit(1);
406
+ }
407
+ }
408
+
409
+ // Browser environments use local callback server
410
+ const state = randomBytes(16).toString('base64url');
411
+ const callbackUrl = `http://localhost:${CALLBACK_PORT}/callback`;
412
+
413
+ const tokenPromise = new Promise<string>((resolve, reject) => {
414
+ const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
415
+ const url = new URL(req.url || '/', `http://localhost:${CALLBACK_PORT}`);
416
+
417
+ if (url.pathname !== '/callback') {
418
+ res.writeHead(404);
419
+ res.end('Not found');
420
+ return;
421
+ }
422
+
423
+ const token = url.searchParams.get('token');
424
+ const returnedState = url.searchParams.get('state');
425
+ const error = url.searchParams.get('error');
426
+
427
+ if (error) {
428
+ res.writeHead(200, { 'Content-Type': 'text/html' });
429
+ res.end(errorPage('Authentication Failed', error));
430
+ server.close();
431
+ reject(new Error(`OAuth error: ${error}`));
432
+ return;
433
+ }
434
+
435
+ if (!token || returnedState !== state) {
436
+ res.writeHead(400, { 'Content-Type': 'text/html' });
437
+ res.end(errorPage('Invalid Callback', 'Missing Firebase token or state mismatch. Please try again.'));
438
+ server.close();
439
+ reject(new Error('Invalid callback'));
440
+ return;
441
+ }
442
+
443
+ try {
444
+ const validation = await testToken(token);
445
+ if (!validation.valid) {
446
+ res.writeHead(200, { 'Content-Type': 'text/html' });
447
+ res.end(errorPage('Token Validation Failed', validation.error || 'Server rejected token'));
448
+ server.close();
449
+ reject(new Error(validation.error || 'Server rejected token'));
450
+ return;
451
+ }
452
+
453
+ res.writeHead(200, { 'Content-Type': 'text/html' });
454
+ res.end(successPage());
455
+ server.close();
456
+ resolve(token);
457
+ } catch (e: any) {
458
+ res.writeHead(500, { 'Content-Type': 'text/html' });
459
+ res.end(errorPage('Unexpected Error', e.message));
460
+ server.close();
461
+ reject(e);
462
+ }
463
+ });
464
+
465
+ server.listen(CALLBACK_PORT, () => {
466
+ // Server ready
467
+ });
468
+
469
+ // Timeout after 2 minutes
470
+ setTimeout(() => {
471
+ server.close();
472
+ reject(new Error('Login timed out after 2 minutes'));
473
+ }, 120000);
474
+ });
475
+
476
+ const authorizeUrl = new URL('/login', `${DASHBOARD_BASE}/`);
477
+ authorizeUrl.searchParams.set('callback', callbackUrl);
478
+ authorizeUrl.searchParams.set('state', state);
479
+
480
+ console.log(' Opening browser for authentication...');
481
+ console.log(` If it doesn't open, visit:`);
482
+ console.log(` ${authorizeUrl.toString()}`);
483
+ console.log('');
484
+
485
+ // Open browser
486
+ const { exec } = await import('child_process');
487
+ const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
488
+ exec(`${openCmd} "${authorizeUrl.toString()}"`);
489
+
490
+ console.log(' Waiting for authorization...');
491
+
492
+ try {
493
+ const accessToken = await tokenPromise;
494
+
495
+ // Save token
496
+ const config = readConfig();
497
+ config.token = accessToken;
498
+ config.token_type = 'firebase';
499
+ config.authenticated_at = new Date().toISOString();
500
+ config.server_url = SERVER_BASE;
501
+ config.dashboard_url = DASHBOARD_BASE;
502
+ writeConfig(config);
503
+
504
+ console.log('');
505
+ console.log(' ✓ Authenticated successfully');
506
+ console.log(' Token saved to ~/.gmtr.json');
507
+ console.log(' gramatr intelligence is now active.\n');
508
+ } catch (e: any) {
509
+ console.log(`\n ✗ Authentication failed: ${e.message}\n`);
510
+ process.exit(1);
511
+ }
512
+ }
513
+
514
+ // ── CLI ──
515
+ //
516
+ // Defense in depth (Fix A' for Windows top-level await crash):
517
+ // All CLI code is wrapped in an async `main()` and invoked via a
518
+ // module-run guard. This keeps the module importable without firing
519
+ // side effects and avoids top-level await entirely, so the file stays
520
+ // safe even if the package ever loses `"type": "module"` or tsx
521
+ // changes its default target to CJS.
522
+
523
+ export async function main(): Promise<void> {
524
+ const args = process.argv.slice(2);
525
+
526
+ if (args.includes('--status') || args.includes('status')) {
527
+ await showStatus();
528
+ return;
529
+ }
530
+
531
+ if (args.includes('--logout') || args.includes('logout')) {
532
+ await logout();
533
+ return;
534
+ }
535
+
536
+ if (args.includes('--token') || args.includes('-t')) {
537
+ const tokenIdx = args.indexOf('--token') !== -1 ? args.indexOf('--token') : args.indexOf('-t');
538
+ const token = args[tokenIdx + 1];
539
+ if (!token) {
540
+ // Interactive paste mode — like Claude's login
541
+ console.log('\n Paste your gramatr token below.');
542
+ console.log(' (API keys start with aios_sk_ or gmtr_sk_)\n');
543
+ process.stdout.write(' Token: ');
544
+
545
+ const { createInterface } = await import('readline');
546
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
547
+ const pastedToken = await new Promise<string>((resolve) => {
548
+ rl.on('line', (line: string) => { rl.close(); resolve(line.trim()); });
549
+ });
550
+ if (!pastedToken) {
551
+ console.log(' No token provided.\n');
552
+ process.exit(1);
553
+ }
554
+ await loginWithToken(pastedToken);
555
+ } else {
556
+ await loginWithToken(token);
557
+ }
558
+ return;
559
+ }
560
+
561
+ if (args.includes('--help') || args.includes('-h')) {
562
+ console.log(`
563
+ gmtr-login — Authenticate with the gramatr server
564
+
565
+ Usage:
566
+ gmtr-login Interactive dashboard login (browser or headless device flow)
567
+ gmtr-login --token Paste a token (API key or Firebase token)
568
+ gmtr-login --token <t> Provide token directly
569
+ gmtr-login --status Check authentication status
570
+ gmtr-login --logout Remove stored credentials
571
+ gmtr-login --help Show this help
572
+
573
+ Token storage: ~/.gmtr.json
574
+ Server: ${SERVER_BASE}
575
+ Dashboard: ${DASHBOARD_BASE}
576
+ `);
577
+ return;
578
+ }
579
+
580
+ // Default: browser login flow
581
+ await loginBrowser();
582
+ }
583
+
584
+ // Module-run guard. Works both when invoked directly via
585
+ // `tsx bin/gmtr-login.ts` and when imported from another module
586
+ // (tests, programmatic use). Under ESM, import.meta.url is the
587
+ // canonical check; we also accept a path-suffix match as a belt.
588
+ const invokedAs = process.argv[1] || '';
589
+ const isMain =
590
+ import.meta.url === `file://${invokedAs}` ||
591
+ invokedAs.endsWith('gmtr-login.ts') ||
592
+ invokedAs.endsWith('gmtr-login.js');
593
+
594
+ if (isMain) {
595
+ main().catch((err) => {
596
+ console.error(err instanceof Error ? err.message : String(err));
597
+ process.exit(1);
598
+ });
599
+ }
package/bin/gramatr.js ADDED
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gramatr CLI entry point — thin JS wrapper that bootstraps TypeScript.
4
+ * tsx is a production dependency, resolved directly from node_modules.
5
+ *
6
+ * ESM module: packages/client/package.json has "type": "module" (added in
7
+ * PR #487 to support top-level await in bin/gmtr-login.ts). This file must
8
+ * use ESM imports — bare `require()` will throw ReferenceError at load time.
9
+ * v0.3.60 hotfix: #487 converted the .ts bin files but missed this .js twin.
10
+ */
11
+ import { spawnSync } from 'node:child_process';
12
+ import { dirname, join } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { createRequire } from 'node:module';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const __dirname = dirname(fileURLToPath(import.meta.url));
18
+
19
+ const script = join(__dirname, 'gramatr.ts');
20
+ const args = process.argv.slice(2);
21
+
22
+ // gramatr standardizes on `npx tsx` — see issue #468 for the architectural
23
+ // rationale. bun detection was removed because it silently produced broken
24
+ // hook + statusline configs on hosts where the install-time PATH did not
25
+ // match the runtime PATH of spawned subprocesses.
26
+
27
+ // Resolve tsx from node_modules (tsx is a production dependency)
28
+ try {
29
+ const tsxBin = join(dirname(require.resolve('tsx/package.json')), 'dist', 'cli.mjs');
30
+ const r = spawnSync(process.execPath, [tsxBin, script, ...args], { stdio: 'inherit' });
31
+ process.exit(r.status ?? 1);
32
+ } catch {}
33
+
34
+ // Last resort: npx tsx
35
+ const r = spawnSync('npx', ['--yes', 'tsx', script, ...args], { stdio: 'inherit', shell: true });
36
+ process.exit(r.status ?? 1);