@gramatr/client 0.6.2 → 0.6.5

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.
package/CLAUDE.md CHANGED
@@ -10,7 +10,7 @@ ISC scaffold, capability audit, phase templates, and composed agents.
10
10
  **Memory:** Use gramatr MCP tools (`search_semantic`, `create_entity`, `add_observation`),
11
11
  not local markdown files.
12
12
 
13
- **Identity:** Read from `~/gmtr-client/settings.json` — `daidentity` for your name,
13
+ **Identity:** Read from `~/.gramatr/settings.json` — `daidentity` for your name,
14
14
  `principal` for the user's name.
15
15
 
16
16
  **If the server is unreachable:** Use 7-phase structure (OBSERVE → THINK → PLAN → BUILD →
package/README.md CHANGED
@@ -72,14 +72,14 @@ The client never stores intelligence locally. The server delivers everything: be
72
72
  ## What gets installed
73
73
 
74
74
  ```
75
- ~/gmtr-client/ # Client runtime
75
+ ~/.gramatr/ # Client runtime
76
76
  hooks/ # 8 lifecycle hooks
77
77
  core/ # Shared routing + session logic
78
78
  bin/ # Status line, login, utilities
79
79
  CLAUDE.md # Minimal behavioral framework
80
80
  ~/.claude/settings.json # Hook configuration (merged, not overwritten)
81
81
  ~/.claude.json # MCP server registration
82
- ~/.gmtr.json # Auth token (canonical source)
82
+ ~/.gramatr.json # Auth token (canonical source)
83
83
  ```
84
84
 
85
85
  ## Commands
@@ -40,7 +40,7 @@ function showHelp(): void {
40
40
  log(`gramatr clear-creds — Remove every stored gramatr credential
41
41
 
42
42
  Usage:
43
- gramatr clear-creds Sweep ~/.gramatr.json + auth.api_key from gmtr-client/settings.json
43
+ gramatr clear-creds Sweep ~/.gramatr.json + auth.api_key from ~/.gramatr/settings.json
44
44
 
45
45
  After running, the next install or login will be forced through OAuth.
46
46
 
package/bin/gmtr-login.ts CHANGED
@@ -15,10 +15,11 @@
15
15
  * The GMTRPromptEnricher hook reads this on every prompt.
16
16
  */
17
17
 
18
- import { randomBytes } from 'crypto';
18
+ import { createHash, randomBytes } from 'crypto';
19
19
  import { readFileSync, writeFileSync, existsSync } from 'fs';
20
20
  import { join } from 'path';
21
21
  import { createServer, type IncomingMessage, type ServerResponse } from 'http';
22
+ import type { AddressInfo } from 'net';
22
23
 
23
24
  // ── Config ──
24
25
 
@@ -41,7 +42,9 @@ const DASHBOARD_BASE = process.env.GMTR_DASHBOARD_URL || (() => {
41
42
  return 'https://app.gramatr.com';
42
43
  }
43
44
  })();
44
- const CALLBACK_PORT = 58787; // Must match server's redirect_uris
45
+ // CALLBACK_PORT is now dynamically allocated per login (random localhost port).
46
+ // The server's DCR endpoint accepts arbitrary localhost redirect_uris for
47
+ // public CLIs (token_endpoint_auth_method=none). See loginBrowser() below.
45
48
 
46
49
  // ── HTML Templates ──
47
50
 
@@ -140,7 +143,11 @@ function errorPage(title: string, detail: string): string {
140
143
 
141
144
  // ── Headless Detection ──
142
145
 
143
- function isHeadless(): boolean {
146
+ function isHeadless(forceFlag: boolean = false): boolean {
147
+ // Explicit override — user passed --headless, OR env GRAMATR_LOGIN_HEADLESS=1
148
+ if (forceFlag) return true;
149
+ if (process.env.GRAMATR_LOGIN_HEADLESS === '1') return true;
150
+
144
151
  // SSH session without display forwarding
145
152
  if (process.env.SSH_CONNECTION || process.env.SSH_TTY) {
146
153
  if (!process.env.DISPLAY && process.platform !== 'darwin') return true;
@@ -339,7 +346,7 @@ async function loginWithToken(token: string): Promise<void> {
339
346
  }
340
347
  }
341
348
 
342
- async function loginBrowser(): Promise<void> {
349
+ async function loginBrowser(opts: { forceHeadless?: boolean } = {}): Promise<void> {
343
350
  console.log('\n gramatr login\n');
344
351
  console.log(` Server: ${SERVER_BASE}`);
345
352
  console.log(` Dashboard: ${DASHBOARD_BASE}`);
@@ -355,8 +362,11 @@ async function loginBrowser(): Promise<void> {
355
362
 
356
363
  console.log('');
357
364
 
358
- // Headless environments use device auth (no local server needed)
359
- if (isHeadless()) {
365
+ // Headless environments use device auth (no local server needed).
366
+ // --headless flag or GRAMATR_LOGIN_HEADLESS=1 forces this path even on
367
+ // desktop — escape hatch when the browser flow is broken or the user
368
+ // prefers the device flow's out-of-band UX.
369
+ if (isHeadless(opts.forceHeadless)) {
360
370
  console.log(' Headless environment detected. Starting device login...\n');
361
371
  try {
362
372
  const device = await startDeviceAuthorization();
@@ -406,75 +416,115 @@ async function loginBrowser(): Promise<void> {
406
416
  }
407
417
  }
408
418
 
409
- // Browser environments use local callback server
410
- const state = randomBytes(16).toString('base64url');
411
- const callbackUrl = `http://localhost:${CALLBACK_PORT}/callback`;
419
+ // ── Browser environments use OAuth 2.0 authorization-code grant + PKCE ──
420
+ //
421
+ // v0.6.5: This now mirrors the headless device flow's auth model — both
422
+ // paths produce an opaque `mcp-access-token-<uuid>` token (1-year TTL,
423
+ // Redis-backed) instead of the short-lived raw Firebase ID token the old
424
+ // dashboard-redirect flow stored. Per RFC 7591 (Dynamic Client Registration)
425
+ // we register the CLI as an OAuth client on first run, then per RFC 7636
426
+ // (PKCE) we exchange a code for the opaque token.
427
+ //
428
+ // Bug history: pre-v0.6.5 the browser flow stored a raw Firebase ID token
429
+ // (token_type: 'firebase'), which had a 1-hour TTL. After 1 hour every
430
+ // MCP call returned "JWT signature validation failed" because the token
431
+ // genuinely expired. See #519 + #524 + this PR.
432
+
433
+ // 1. Bind a localhost callback server (random port — server's DCR allows it)
434
+ const callbackServer = createServer();
435
+ await new Promise<void>((resolve, reject) => {
436
+ callbackServer.once('error', reject);
437
+ callbackServer.listen(0, '127.0.0.1', () => resolve());
438
+ });
439
+ const port = (callbackServer.address() as AddressInfo).port;
440
+ const redirectUri = `http://localhost:${port}/callback`;
441
+
442
+ // 2. Dynamic client registration (RFC 7591). We always re-register because
443
+ // the redirect_uri changes every run (random localhost port). Server
444
+ // allows DCR with token_endpoint_auth_method=none for public CLIs.
445
+ const config = readConfig();
446
+ let clientId: string;
447
+ try {
448
+ const regRes = await fetch(`${SERVER_BASE}/register`, {
449
+ method: 'POST',
450
+ headers: { 'Content-Type': 'application/json' },
451
+ body: JSON.stringify({
452
+ client_name: 'gmtr-login',
453
+ redirect_uris: [redirectUri],
454
+ grant_types: ['authorization_code'],
455
+ response_types: ['code'],
456
+ token_endpoint_auth_method: 'none',
457
+ }),
458
+ signal: AbortSignal.timeout(10000),
459
+ });
460
+ const reg = await regRes.json().catch(() => ({}));
461
+ if (!regRes.ok || !reg.client_id) {
462
+ throw new Error(reg.error_description || reg.error || `HTTP ${regRes.status}`);
463
+ }
464
+ clientId = reg.client_id as string;
465
+ config.oauth_client_id = clientId;
466
+ writeConfig(config);
467
+ } catch (e: any) {
468
+ callbackServer.close();
469
+ console.log(` ✗ Dynamic client registration failed: ${e.message}\n`);
470
+ process.exit(1);
471
+ }
412
472
 
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}`);
473
+ // 3. PKCE: generate verifier + S256 challenge
474
+ const codeVerifier = randomBytes(32).toString('base64url');
475
+ const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
476
+ const state = randomBytes(16).toString('base64url');
416
477
 
478
+ // 4. Run the callback server, waiting for /callback?code=...&state=...
479
+ //
480
+ // v0.6.5: capture the timeout handle and clearTimeout() in finally — same
481
+ // orphan-timer bug that v0.3.63 fixed for the device flow. Without the
482
+ // clear, Promise.race against a setTimeout keeps the Node event loop
483
+ // alive past success and the process hangs until Ctrl+C.
484
+ let codeTimeoutHandle: ReturnType<typeof setTimeout> | undefined;
485
+ const codePromise = new Promise<string>((resolve, reject) => {
486
+ callbackServer.on('request', (req: IncomingMessage, res: ServerResponse) => {
487
+ const url = new URL(req.url || '/', redirectUri);
417
488
  if (url.pathname !== '/callback') {
418
489
  res.writeHead(404);
419
490
  res.end('Not found');
420
491
  return;
421
492
  }
422
-
423
- const token = url.searchParams.get('token');
493
+ const code = url.searchParams.get('code');
424
494
  const returnedState = url.searchParams.get('state');
425
495
  const error = url.searchParams.get('error');
426
-
427
496
  if (error) {
428
497
  res.writeHead(200, { 'Content-Type': 'text/html' });
429
498
  res.end(errorPage('Authentication Failed', error));
430
- server.close();
499
+ callbackServer.close();
431
500
  reject(new Error(`OAuth error: ${error}`));
432
501
  return;
433
502
  }
434
-
435
- if (!token || returnedState !== state) {
503
+ if (!code || returnedState !== state) {
436
504
  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();
505
+ res.end(errorPage('Invalid Callback', 'Missing code or state mismatch. Please try again.'));
506
+ callbackServer.close();
439
507
  reject(new Error('Invalid callback'));
440
508
  return;
441
509
  }
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
510
+ res.writeHead(200, { 'Content-Type': 'text/html' });
511
+ res.end(successPage());
512
+ callbackServer.close();
513
+ resolve(code);
467
514
  });
468
-
469
- // Timeout after 2 minutes
470
- setTimeout(() => {
471
- server.close();
472
- reject(new Error('Login timed out after 2 minutes'));
473
- }, 120000);
515
+ codeTimeoutHandle = setTimeout(() => {
516
+ callbackServer.close();
517
+ reject(new Error('Login timed out after 5 minutes'));
518
+ }, 5 * 60 * 1000);
474
519
  });
475
520
 
476
- const authorizeUrl = new URL('/login', `${DASHBOARD_BASE}/`);
477
- authorizeUrl.searchParams.set('callback', callbackUrl);
521
+ // 5. Open the browser to the server's /authorize endpoint with PKCE params
522
+ const authorizeUrl = new URL('/authorize', SERVER_BASE);
523
+ authorizeUrl.searchParams.set('response_type', 'code');
524
+ authorizeUrl.searchParams.set('client_id', clientId);
525
+ authorizeUrl.searchParams.set('redirect_uri', redirectUri);
526
+ authorizeUrl.searchParams.set('code_challenge', codeChallenge);
527
+ authorizeUrl.searchParams.set('code_challenge_method', 'S256');
478
528
  authorizeUrl.searchParams.set('state', state);
479
529
 
480
530
  console.log(' Opening browser for authentication...');
@@ -482,33 +532,64 @@ async function loginBrowser(): Promise<void> {
482
532
  console.log(` ${authorizeUrl.toString()}`);
483
533
  console.log('');
484
534
 
485
- // Open browser
486
535
  const { exec } = await import('child_process');
487
536
  const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
488
537
  exec(`${openCmd} "${authorizeUrl.toString()}"`);
489
538
 
490
539
  console.log(' Waiting for authorization...');
491
540
 
541
+ let authCode: string;
492
542
  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 ~/.gramatr.json');
507
- console.log(' gramatr intelligence is now active.\n');
543
+ authCode = await codePromise;
508
544
  } catch (e: any) {
545
+ if (codeTimeoutHandle) clearTimeout(codeTimeoutHandle);
509
546
  console.log(`\n ✗ Authentication failed: ${e.message}\n`);
510
547
  process.exit(1);
511
548
  }
549
+ if (codeTimeoutHandle) clearTimeout(codeTimeoutHandle);
550
+
551
+ // 6. Exchange the code for an opaque MCP access token at /token
552
+ let accessToken: string;
553
+ let expiresIn: number = 31536000; // server default = 1 year
554
+ try {
555
+ const tokenRes = await fetch(`${SERVER_BASE}/token`, {
556
+ method: 'POST',
557
+ headers: { 'Content-Type': 'application/json' },
558
+ body: JSON.stringify({
559
+ grant_type: 'authorization_code',
560
+ code: authCode,
561
+ code_verifier: codeVerifier,
562
+ client_id: clientId,
563
+ redirect_uri: redirectUri,
564
+ }),
565
+ signal: AbortSignal.timeout(10000),
566
+ });
567
+ const payload = await tokenRes.json().catch(() => ({}));
568
+ if (!tokenRes.ok || !payload.access_token) {
569
+ throw new Error(payload.error_description || payload.error || `HTTP ${tokenRes.status}`);
570
+ }
571
+ accessToken = payload.access_token as string;
572
+ if (typeof payload.expires_in === 'number') expiresIn = payload.expires_in;
573
+ } catch (e: any) {
574
+ console.log(`\n ✗ Token exchange failed: ${e.message}\n`);
575
+ process.exit(1);
576
+ }
577
+
578
+ // 7. Save the opaque token — same shape as the device flow
579
+ const updated = readConfig();
580
+ updated.token = accessToken;
581
+ updated.token_type = 'oauth';
582
+ updated.authenticated_at = new Date().toISOString();
583
+ updated.server_url = SERVER_BASE;
584
+ updated.dashboard_url = DASHBOARD_BASE;
585
+ updated.token_expires_at = new Date(Date.now() + expiresIn * 1000).toISOString();
586
+ if (clientId) updated.oauth_client_id = clientId;
587
+ writeConfig(updated);
588
+
589
+ console.log('');
590
+ console.log(' ✓ Authenticated successfully');
591
+ console.log(' Token saved to ~/.gramatr.json');
592
+ console.log(' gramatr intelligence is now active.\n');
512
593
  }
513
594
 
514
595
  // ── CLI ──
@@ -563,13 +644,19 @@ export async function main(): Promise<void> {
563
644
  gmtr-login — Authenticate with the gramatr server
564
645
 
565
646
  Usage:
566
- gmtr-login Interactive dashboard login (browser or headless device flow)
647
+ gmtr-login Interactive login (browser PKCE when display available,
648
+ device flow otherwise — auto-detected)
649
+ gmtr-login --headless Force the device flow even on desktops (escape hatch
650
+ when the browser flow is broken or unavailable)
567
651
  gmtr-login --token Paste a token (API key or Firebase token)
568
652
  gmtr-login --token <t> Provide token directly
569
653
  gmtr-login --status Check authentication status
570
654
  gmtr-login --logout Remove stored credentials
571
655
  gmtr-login --help Show this help
572
656
 
657
+ Environment:
658
+ GRAMATR_LOGIN_HEADLESS=1 Same as --headless (forces device flow)
659
+
573
660
  Token storage: ~/.gramatr.json
574
661
  Server: ${SERVER_BASE}
575
662
  Dashboard: ${DASHBOARD_BASE}
@@ -577,8 +664,10 @@ export async function main(): Promise<void> {
577
664
  return;
578
665
  }
579
666
 
580
- // Default: browser login flow
581
- await loginBrowser();
667
+ // Default: browser login flow (falls back to device flow if headless
668
+ // detected, OR if --headless / GRAMATR_LOGIN_HEADLESS=1 is set).
669
+ const forceHeadless = args.includes('--headless');
670
+ await loginBrowser({ forceHeadless });
582
671
  }
583
672
 
584
673
  // Module-run guard. Works both when invoked directly via
package/bin/uninstall.ts CHANGED
@@ -170,7 +170,7 @@ async function main(): Promise<void> {
170
170
  try {
171
171
  const settings = JSON.parse(readFileSync(claudeSettings, 'utf8'));
172
172
  if (settings.hooks) {
173
- // Remove hook entries that reference gmtr-client
173
+ // Remove hook entries that reference .gramatr or gramatr paths
174
174
  for (const [event, hooks] of Object.entries(settings.hooks)) {
175
175
  if (Array.isArray(hooks)) {
176
176
  settings.hooks[event] = (hooks as any[]).filter(
package/chatgpt/README.md CHANGED
@@ -19,7 +19,7 @@ bun chatgpt/install.ts
19
19
  ```
20
20
 
21
21
  The installer will:
22
- 1. Find your API key from `~/.gmtr.json`, `GRAMATR_API_KEY` env, or prompt you
22
+ 1. Find your API key from `~/.gramatr.json`, `GRAMATR_API_KEY` env, or prompt you
23
23
  2. Validate connectivity to the gramatr server
24
24
  3. Detect your platform and locate the ChatGPT config file
25
25
  4. Merge the gramatr MCP server entry without overwriting existing servers
package/codex/README.md CHANGED
@@ -21,8 +21,8 @@ Install locally with:
21
21
  - `pnpm --filter @gramatr/client install-codex`
22
22
 
23
23
  The installer:
24
- - syncs this Codex runtime into `~/gmtr-client/codex`
25
- - syncs the shared `gmtr-hook-utils.ts` dependency into `~/gmtr-client/hooks/lib`
24
+ - syncs this Codex runtime into `~/.gramatr/codex`
25
+ - syncs the shared `gmtr-hook-utils.ts` dependency into `~/.gramatr/hooks/lib`
26
26
  - merges `~/.codex/hooks.json`
27
27
  - enables `codex_hooks` in `~/.codex/config.toml`
28
28
  - upserts a managed gramatr block in `~/.codex/AGENTS.md`
@@ -2,7 +2,7 @@
2
2
  * version-check.ts — opportunistic npm registry version check.
3
3
  *
4
4
  * Queries https://registry.npmjs.org/gramatr/latest on a 3s timeout, caches
5
- * the result for one hour under ~/.gmtr-client/.cache/version-check.json, and
5
+ * the result for one hour under ~/.gramatr/.cache/version-check.json, and
6
6
  * reports whether the installed client is behind the published version.
7
7
  *
8
8
  * Design constraints (see issue #468 sibling work):
@@ -56,7 +56,7 @@ export function compareVersions(a: string, b: string): number {
56
56
  }
57
57
 
58
58
  export function getCachePath(home: string = homedir()): string {
59
- return join(home, '.gmtr-client', '.cache', 'version-check.json');
59
+ return join(home, '.gramatr', '.cache', 'version-check.json');
60
60
  }
61
61
 
62
62
  function readCache(path: string): CacheFile | null {
package/desktop/README.md CHANGED
@@ -12,7 +12,7 @@ bun packages/client/desktop/install.ts
12
12
  ```
13
13
 
14
14
  The installer:
15
- 1. Resolves your API key from `~/.gmtr.json`, `GRAMATR_API_KEY` env, or prompts
15
+ 1. Resolves your API key from `~/.gramatr.json`, `GRAMATR_API_KEY` env, or prompts
16
16
  2. Validates connectivity to the gramatr server
17
17
  3. Detects platform (macOS or Windows)
18
18
  4. Reads existing `claude_desktop_config.json` without overwriting other MCP servers
package/gemini/README.md CHANGED
@@ -62,7 +62,7 @@ echo "GRAMATR_API_KEY=your-key-here" > ~/.gemini/extensions/gramatr/.env
62
62
 
63
63
  gramatr requires a Bearer token for all MCP calls. The installer handles this by:
64
64
 
65
- 1. Checking `~/.gmtr.json` for an existing token (shared with Claude Code / Codex)
65
+ 1. Checking `~/.gramatr.json` for an existing token (shared with Claude Code / Codex)
66
66
  2. Checking the `GRAMATR_API_KEY` environment variable
67
67
  3. Prompting for a token interactively
68
68
 
@@ -72,7 +72,7 @@ To authenticate before installing, run:
72
72
  bun packages/client/bin/gmtr-login.ts
73
73
  ```
74
74
 
75
- This stores the token in `~/.gmtr.json`, which the installer reads automatically.
75
+ This stores the token in `~/.gramatr.json`, which the installer reads automatically.
76
76
 
77
77
  API keys start with `gramatr_sk_` and can be created at [gramatr.com](https://gramatr.com) or via the `gramatr_create_api_key` MCP tool.
78
78
 
@@ -92,4 +92,4 @@ After installing and restarting Gemini CLI:
92
92
  > @gramatr search for recent learning signals
93
93
  ```
94
94
 
95
- If the MCP server responds, the extension is working. If you see auth errors, re-run the installer or check `~/.gmtr.json`.
95
+ If the MCP server responds, the extension is working. If you see auth errors, re-run the installer or check `~/.gramatr.json`.
@@ -321,7 +321,7 @@ async function main(): Promise<void> {
321
321
  log('This will:');
322
322
  log(` 1. Search gramatr for existing project: ${git.projectName}`);
323
323
  log(' 2. Create project entity if not found');
324
- log(' 3. Link entity to .gmtr.json');
324
+ log(' 3. Link entity to .gramatr.json');
325
325
  log(' 4. Enable full memory persistence');
326
326
  log('');
327
327
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gramatr/client",
3
- "version": "0.6.2",
3
+ "version": "0.6.5",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },