@gramatr/mcp 0.13.41 → 0.13.42

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/dist/bin/login.js CHANGED
@@ -15,32 +15,33 @@
15
15
  * Token is stored in ~/.gramatr.json under the "token" key.
16
16
  * The local gramatr MCP runtime reads this on every proxied request.
17
17
  */
18
- import { createHash, randomBytes } from 'crypto';
19
- import { readFileSync, writeFileSync } from 'fs';
20
- import { join } from 'path';
21
- import { createServer } from 'http';
18
+ import { createHash, randomBytes } from "crypto";
19
+ import { readFileSync, writeFileSync } from "fs";
20
+ import { createServer } from "http";
21
+ import { join } from "path";
22
22
  // ── Config ──
23
- const HOME = process.env.HOME || process.env.USERPROFILE || '';
24
- const CONFIG_PATH = join(HOME, '.gramatr.json');
25
- const GRAMATR_DIR = process.env.GRAMATR_DIR || join(HOME, '.gramatr');
26
- const DEFAULT_SERVER = process.env.GRAMATR_URL || 'https://api.gramatr.com/mcp';
23
+ const HOME = process.env.HOME || process.env.USERPROFILE || "";
24
+ const CONFIG_PATH = join(HOME, ".gramatr.json");
25
+ const _GRAMATR_DIR = process.env.GRAMATR_DIR || join(HOME, ".gramatr");
26
+ const DEFAULT_SERVER = process.env.GRAMATR_URL || "https://api.gramatr.com/mcp";
27
27
  // Strip /mcp suffix to get base URL
28
- const SERVER_BASE = DEFAULT_SERVER.replace(/\/mcp\/?$/, '');
29
- const DASHBOARD_BASE = process.env.GMTR_DASHBOARD_URL || (() => {
30
- try {
31
- const url = new URL(SERVER_BASE);
32
- if (url.hostname.startsWith('api.')) {
33
- url.hostname = `app.${url.hostname.slice(4)}`;
28
+ const SERVER_BASE = DEFAULT_SERVER.replace(/\/mcp\/?$/, "");
29
+ const DASHBOARD_BASE = process.env.GMTR_DASHBOARD_URL ||
30
+ (() => {
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(/\/$/, "");
34
40
  }
35
- url.pathname = '';
36
- url.search = '';
37
- url.hash = '';
38
- return url.toString().replace(/\/$/, '');
39
- }
40
- catch {
41
- return 'https://app.gramatr.com';
42
- }
43
- })();
41
+ catch {
42
+ return "https://app.gramatr.com";
43
+ }
44
+ })();
44
45
  // CALLBACK_PORT is now dynamically allocated per login (random localhost port).
45
46
  // The server's DCR endpoint accepts arbitrary localhost redirect_uris for
46
47
  // public CLIs (token_endpoint_auth_method=none). See loginBrowser() below.
@@ -167,7 +168,7 @@ function htmlPage(title, body) {
167
168
  </body></html>`;
168
169
  }
169
170
  function successPage() {
170
- return htmlPage('Authenticated', `
171
+ return htmlPage("Authenticated", `
171
172
  <div class="status-icon">${CHECK_SVG}</div>
172
173
  <h2>You're signed in</h2>
173
174
  <p>Your token is saved on this machine. You can close this tab and return to your terminal.</p>
@@ -175,7 +176,7 @@ function successPage() {
175
176
  `);
176
177
  }
177
178
  function errorPage(title, detail) {
178
- return htmlPage('Error', `
179
+ return htmlPage("Error", `
179
180
  <div class="status-icon">${X_SVG}</div>
180
181
  <h2>${title}</h2>
181
182
  <p>${detail}</p>
@@ -187,7 +188,7 @@ export function isHeadless(forceFlag = false) {
187
188
  // Explicit override — user passed --headless, OR env GRAMATR_LOGIN_HEADLESS=1
188
189
  if (forceFlag)
189
190
  return true;
190
- if (process.env.GRAMATR_LOGIN_HEADLESS === '1')
191
+ if (process.env.GRAMATR_LOGIN_HEADLESS === "1")
191
192
  return true;
192
193
  // SSH session — always go headless. Even on macOS (which has a local
193
194
  // display), `open` would launch Safari on the Mac's *physical* screen,
@@ -199,21 +200,21 @@ export function isHeadless(forceFlag = false) {
199
200
  if (process.env.CI || process.env.DOCKER)
200
201
  return true;
201
202
  // Linux without display
202
- if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY)
203
+ if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY)
203
204
  return true;
204
205
  return false;
205
206
  }
206
207
  // ── Helpers ──
207
208
  export function readConfig() {
208
209
  try {
209
- return JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
210
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
210
211
  }
211
212
  catch {
212
213
  return {};
213
214
  }
214
215
  }
215
216
  export function writeConfig(config) {
216
- writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
217
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
217
218
  }
218
219
  /**
219
220
  * Kill any running gramatr stdio server processes so they respawn with
@@ -227,15 +228,15 @@ export function writeConfig(config) {
227
228
  */
228
229
  export function killStaleMcpServers() {
229
230
  try {
230
- const { execSync } = require('child_process');
231
- if (process.platform === 'win32') {
232
- execSync('taskkill /F /IM gramatr.exe /T 2>nul', { stdio: 'ignore' });
231
+ const { execSync } = require("child_process");
232
+ if (process.platform === "win32") {
233
+ execSync("taskkill /F /IM gramatr.exe /T 2>nul", { stdio: "ignore" });
233
234
  }
234
235
  else {
235
236
  // pkill -TERM -f matches against the full command line; the $ anchor ensures
236
237
  // we only kill processes whose argv[1] ends in 'gramatr' (the stdio server),
237
238
  // not subcommands like 'gramatr login' which have trailing arguments.
238
- execSync(`pkill -TERM -f 'gramatr$' 2>/dev/null; true`, { stdio: 'ignore' });
239
+ execSync(`pkill -TERM -f 'gramatr$' 2>/dev/null; true`, { stdio: "ignore" });
239
240
  }
240
241
  }
241
242
  catch {
@@ -244,7 +245,7 @@ export function killStaleMcpServers() {
244
245
  }
245
246
  export async function readJsonRecord(response) {
246
247
  const payload = await response.json().catch(() => ({}));
247
- if (!payload || typeof payload !== 'object' || Array.isArray(payload))
248
+ if (!payload || typeof payload !== "object" || Array.isArray(payload))
248
249
  return {};
249
250
  return payload;
250
251
  }
@@ -252,7 +253,7 @@ export async function checkServerHealth() {
252
253
  try {
253
254
  const res = await fetch(`${SERVER_BASE}/health`, { signal: AbortSignal.timeout(5000) });
254
255
  if (res.ok) {
255
- const data = await res.json();
256
+ const data = (await res.json());
256
257
  return { ok: true, version: data.version };
257
258
  }
258
259
  return { ok: false, error: `HTTP ${res.status}` };
@@ -264,35 +265,37 @@ export async function checkServerHealth() {
264
265
  export async function testToken(token) {
265
266
  try {
266
267
  const res = await fetch(`${SERVER_BASE}/mcp`, {
267
- method: 'POST',
268
+ method: "POST",
268
269
  headers: {
269
- 'Content-Type': 'application/json',
270
- Accept: 'application/json, text/event-stream',
270
+ "Content-Type": "application/json",
271
+ Accept: "application/json, text/event-stream",
271
272
  Authorization: `Bearer ${token}`,
272
273
  },
273
274
  body: JSON.stringify({
274
- jsonrpc: '2.0',
275
+ jsonrpc: "2.0",
275
276
  id: 1,
276
- method: 'tools/call',
277
- params: { name: 'aggregate_stats', arguments: {} },
277
+ method: "tools/call",
278
+ params: { name: "aggregate_stats", arguments: {} },
278
279
  }),
279
280
  signal: AbortSignal.timeout(10000),
280
281
  });
281
282
  const text = await res.text();
282
283
  // Check for auth errors
283
- if (text.includes('JWT token is required') || text.includes('signature validation failed') || text.includes('Unauthorized')) {
284
- return { valid: false, error: 'Token rejected by server' };
284
+ if (text.includes("JWT token is required") ||
285
+ text.includes("signature validation failed") ||
286
+ text.includes("Unauthorized")) {
287
+ return { valid: false, error: "Token rejected by server" };
285
288
  }
286
289
  // Check for successful response
287
- for (const line of text.split('\n')) {
288
- if (line.startsWith('data: ')) {
290
+ for (const line of text.split("\n")) {
291
+ if (line.startsWith("data: ")) {
289
292
  try {
290
293
  const d = JSON.parse(line.slice(6));
291
294
  if (d?.result?.isError) {
292
- return { valid: false, error: d.result.content?.[0]?.text || 'Unknown error' };
295
+ return { valid: false, error: d.result.content?.[0]?.text || "Unknown error" };
293
296
  }
294
297
  if (d?.result?.content?.[0]?.text) {
295
- return { valid: true, user: 'authenticated' };
298
+ return { valid: true, user: "authenticated" };
296
299
  }
297
300
  }
298
301
  catch {
@@ -300,7 +303,7 @@ export async function testToken(token) {
300
303
  }
301
304
  }
302
305
  }
303
- return { valid: false, error: 'Unexpected response' };
306
+ return { valid: false, error: "Unexpected response" };
304
307
  }
305
308
  catch (e) {
306
309
  return { valid: false, error: e.message };
@@ -308,35 +311,37 @@ export async function testToken(token) {
308
311
  }
309
312
  export async function startDeviceAuthorization() {
310
313
  const res = await fetch(`${SERVER_BASE}/device/start`, {
311
- method: 'POST',
312
- headers: { 'Content-Type': 'application/json' },
313
- body: JSON.stringify({ client_name: 'gramatr-mcp-login' }),
314
+ method: "POST",
315
+ headers: { "Content-Type": "application/json" },
316
+ body: JSON.stringify({ client_name: "gramatr-mcp-login" }),
314
317
  signal: AbortSignal.timeout(10000),
315
318
  });
316
319
  const payload = await readJsonRecord(res);
317
320
  if (!res.ok) {
318
321
  throw new Error(payload.error_description || payload.error || `HTTP ${res.status}`);
319
322
  }
320
- if (typeof payload.device_code !== 'string' ||
321
- typeof payload.user_code !== 'string' ||
322
- typeof payload.verification_uri !== 'string' ||
323
- typeof payload.expires_in !== 'number') {
324
- throw new Error('Device authorization response missing required fields');
323
+ if (typeof payload.device_code !== "string" ||
324
+ typeof payload.user_code !== "string" ||
325
+ typeof payload.verification_uri !== "string" ||
326
+ typeof payload.expires_in !== "number") {
327
+ throw new Error("Device authorization response missing required fields");
325
328
  }
326
329
  return {
327
330
  device_code: payload.device_code,
328
331
  user_code: payload.user_code,
329
332
  verification_uri: payload.verification_uri,
330
- verification_uri_complete: typeof payload.verification_uri_complete === 'string' ? payload.verification_uri_complete : undefined,
333
+ verification_uri_complete: typeof payload.verification_uri_complete === "string"
334
+ ? payload.verification_uri_complete
335
+ : undefined,
331
336
  expires_in: payload.expires_in,
332
- interval: typeof payload.interval === 'number' ? payload.interval : 5,
337
+ interval: typeof payload.interval === "number" ? payload.interval : 5,
333
338
  };
334
339
  }
335
340
  export async function pollDeviceAuthorization(deviceCode) {
336
341
  while (true) {
337
342
  const res = await fetch(`${SERVER_BASE}/device/token`, {
338
- method: 'POST',
339
- headers: { 'Content-Type': 'application/json' },
343
+ method: "POST",
344
+ headers: { "Content-Type": "application/json" },
340
345
  body: JSON.stringify({ device_code: deviceCode }),
341
346
  signal: AbortSignal.timeout(10000),
342
347
  });
@@ -344,7 +349,7 @@ export async function pollDeviceAuthorization(deviceCode) {
344
349
  if (res.ok && payload.access_token) {
345
350
  return payload.access_token;
346
351
  }
347
- if ((res.status === 428 || res.status === 400) && payload.error === 'authorization_pending') {
352
+ if ((res.status === 428 || res.status === 400) && payload.error === "authorization_pending") {
348
353
  const waitSeconds = Math.max(1, Number(payload.interval) || 5);
349
354
  await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
350
355
  continue;
@@ -354,33 +359,33 @@ export async function pollDeviceAuthorization(deviceCode) {
354
359
  }
355
360
  // ── Commands ──
356
361
  export async function showStatus() {
357
- console.log('\n gramatr authentication status\n');
362
+ console.log("\n gramatr authentication status\n");
358
363
  const config = readConfig();
359
364
  const token = config.token;
360
365
  console.log(` Server: ${SERVER_BASE}`);
361
366
  const health = await checkServerHealth();
362
367
  if (health.ok) {
363
- console.log(` Health: ✓ healthy (v${health.version || 'unknown'})`);
368
+ console.log(` Health: ✓ healthy (v${health.version || "unknown"})`);
364
369
  }
365
370
  else {
366
371
  console.log(` Health: ✗ ${health.error}`);
367
372
  }
368
373
  if (!token) {
369
- console.log(' Token: ✗ not configured');
370
- console.log('\n Run: gramatr login to authenticate\n');
374
+ console.log(" Token: ✗ not configured");
375
+ console.log("\n Run: gramatr login to authenticate\n");
371
376
  return;
372
377
  }
373
378
  const prefix = token.substring(0, 15);
374
379
  console.log(` Token: ${prefix}...`);
375
380
  const result = await testToken(token);
376
381
  if (result.valid) {
377
- console.log(' Auth: ✓ token is valid');
382
+ console.log(" Auth: ✓ token is valid");
378
383
  }
379
384
  else {
380
385
  console.log(` Auth: ✗ ${result.error}`);
381
- console.log('\n Run: gramatr login to re-authenticate\n');
386
+ console.log("\n Run: gramatr login to re-authenticate\n");
382
387
  }
383
- console.log('');
388
+ console.log("");
384
389
  }
385
390
  export async function logout() {
386
391
  const config = readConfig();
@@ -389,7 +394,7 @@ export async function logout() {
389
394
  delete config.token_expires;
390
395
  delete config.authenticated_at;
391
396
  writeConfig(config);
392
- console.log('\n ✓ Logged out. Token removed from ~/.gramatr.json\n');
397
+ console.log("\n ✓ Logged out. Token removed from ~/.gramatr.json\n");
393
398
  }
394
399
  async function fetchUserIdentity(token) {
395
400
  try {
@@ -399,11 +404,29 @@ async function fetchUserIdentity(token) {
399
404
  });
400
405
  if (!res.ok)
401
406
  return {};
402
- const data = await res.json();
403
- return {
404
- user_id: typeof data.user_id === 'string' ? data.user_id : undefined,
405
- email: typeof data.email === 'string' ? data.email : undefined,
406
- };
407
+ const data = (await res.json());
408
+ const user_id = typeof data.user_id === "string" ? data.user_id : undefined;
409
+ const email = typeof data.email === "string" ? data.email : undefined;
410
+ // Attempt to fetch display name from profile API (404 expected for new users)
411
+ let name;
412
+ if (user_id) {
413
+ try {
414
+ const profileRes = await fetch(`${SERVER_BASE}/api/v1/profile/${user_id}`, {
415
+ headers: { Authorization: `Bearer ${token}` },
416
+ signal: AbortSignal.timeout(5000),
417
+ });
418
+ if (profileRes.ok) {
419
+ const profile = (await profileRes.json());
420
+ if (typeof profile.name === "string" && profile.name.trim()) {
421
+ name = profile.name.trim();
422
+ }
423
+ }
424
+ }
425
+ catch {
426
+ // non-fatal — profile fetch failure does not block login
427
+ }
428
+ }
429
+ return { user_id, email, name };
407
430
  }
408
431
  catch {
409
432
  return {};
@@ -412,7 +435,7 @@ async function fetchUserIdentity(token) {
412
435
  async function clearReauthFlag(token) {
413
436
  try {
414
437
  await fetch(`${SERVER_BASE}/api/v1/auth/reauth-flag`, {
415
- method: 'DELETE',
438
+ method: "DELETE",
416
439
  headers: { Authorization: `Bearer ${token}` },
417
440
  signal: AbortSignal.timeout(5000),
418
441
  });
@@ -431,10 +454,10 @@ async function promptUserNameIfAbsent() {
431
454
  const config = readConfig();
432
455
  if (config.user?.name)
433
456
  return;
434
- const { createInterface } = await import('readline/promises');
457
+ const { createInterface } = await import("readline/promises");
435
458
  const rl = createInterface({ input: process.stdin, output: process.stderr });
436
459
  try {
437
- const answer = await rl.question(' What should grāmatr call you? [Enter to skip] ');
460
+ const answer = await rl.question(" What should grāmatr call you? [Enter to skip] ");
438
461
  const name = answer.trim();
439
462
  if (name) {
440
463
  const updated = readConfig();
@@ -445,64 +468,100 @@ async function promptUserNameIfAbsent() {
445
468
  rl.close();
446
469
  }
447
470
  }
471
+ /**
472
+ * Prompt the user to enable auto-compact if not already configured.
473
+ * Only runs when stdin and stderr are TTYs (interactive terminal).
474
+ */
475
+ async function promptAutoCompactIfAbsent() {
476
+ if (!process.stdin.isTTY || !process.stderr.isTTY)
477
+ return;
478
+ const config = readConfig();
479
+ if (config.auto_compact !== undefined)
480
+ return;
481
+ process.stderr.write(" grāmatr session continuity: saves full context every 15 turns — tasks, git state,\n");
482
+ process.stderr.write(" recent work. Restores automatically after /clear so nothing is lost.\n");
483
+ const { createInterface } = await import("readline/promises");
484
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
485
+ try {
486
+ const answer = await rl.question(" Enable auto-compact? Automatically condenses context when it gets long. [Y/n] ");
487
+ const normalized = answer.trim().toLowerCase();
488
+ const enable = normalized !== "n" && normalized !== "no";
489
+ const updated = readConfig();
490
+ writeConfig({ ...updated, auto_compact: { auto: enable } });
491
+ }
492
+ finally {
493
+ rl.close();
494
+ }
495
+ }
496
+ /**
497
+ * Run all post-login onboarding prompts in order.
498
+ * Called at every login path that completes successfully.
499
+ */
500
+ async function runPostLoginOnboarding() {
501
+ await promptUserNameIfAbsent();
502
+ await promptAutoCompactIfAbsent();
503
+ }
448
504
  export async function loginWithToken(token) {
449
- console.log('\n Testing token...');
505
+ console.log("\n Testing token...");
450
506
  const result = await testToken(token);
451
507
  if (result.valid) {
452
508
  const config = readConfig();
453
509
  config.token = token;
454
- config.token_type = /^[a-z]+_(?:sk|pk)_/i.test(token) ? 'api_key' : 'oauth';
510
+ config.token_type = /^[a-z]+_(?:sk|pk)_/i.test(token) ? "api_key" : "oauth";
455
511
  config.authenticated_at = new Date().toISOString();
456
512
  const [identity] = await Promise.all([fetchUserIdentity(token), clearReauthFlag(token)]);
457
513
  if (identity.user_id)
458
514
  config.user_id = identity.user_id;
459
515
  if (identity.email)
460
516
  config.email = identity.email;
517
+ if (identity.name && !config.user?.name) {
518
+ config.user = { ...(config.user ?? {}), name: identity.name };
519
+ }
461
520
  writeConfig(config);
462
521
  killStaleMcpServers();
463
- await promptUserNameIfAbsent();
464
- console.log(' ✓ Token valid. Saved to ~/.gramatr.json');
465
- console.log(' gramatr intelligence is now active.\n');
522
+ await runPostLoginOnboarding();
523
+ console.log(" ✓ Token valid. Saved to ~/.gramatr.json");
524
+ console.log(" gramatr intelligence is now active.\n");
466
525
  }
467
526
  else {
468
527
  console.log(` ✗ Token rejected: ${result.error}`);
469
- console.log(' Token was NOT saved.\n');
528
+ console.log(" Token was NOT saved.\n");
470
529
  process.exit(1);
471
530
  }
472
531
  }
473
532
  export async function loginBrowser(opts = {}) {
474
- console.log('\n gramatr login\n');
533
+ console.log("\n gramatr login\n");
475
534
  console.log(` Server: ${SERVER_BASE}`);
476
535
  console.log(` Dashboard: ${DASHBOARD_BASE}`);
477
536
  // Check server health first
478
537
  const health = await checkServerHealth();
479
538
  if (!health.ok) {
480
539
  console.log(` ✗ Server unreachable: ${health.error}`);
481
- console.log(' Cannot authenticate. Is the server running?\n');
540
+ console.log(" Cannot authenticate. Is the server running?\n");
482
541
  process.exit(1);
483
542
  return;
484
543
  }
485
- console.log(` Health: ✓ v${health.version || 'unknown'}`);
486
- console.log('');
544
+ console.log(` Health: ✓ v${health.version || "unknown"}`);
545
+ console.log("");
487
546
  // Headless environments use device auth (no local server needed).
488
547
  // --headless flag or GRAMATR_LOGIN_HEADLESS=1 forces this path even on
489
548
  // desktop — escape hatch when the browser flow is broken or the user
490
549
  // prefers the device flow's out-of-band UX.
491
550
  if (isHeadless(opts.forceHeadless)) {
492
- console.log(' Headless environment detected. Starting device login...\n');
551
+ console.log(" Headless environment detected. Starting device login...\n");
493
552
  try {
494
553
  const device = await startDeviceAuthorization();
495
554
  console.log(` Code: ${device.user_code}`);
496
555
  console.log(` Open: ${device.verification_uri_complete || device.verification_uri}`);
497
- console.log(' Sign in with Google or GitHub, approve the device, then return here.\n');
498
- console.log(' Waiting for authorization...');
556
+ console.log(" Sign in with Google or GitHub, approve the device, then return here.\n");
557
+ console.log(" Waiting for authorization...");
499
558
  // v0.3.63 hotfix: must clear the timeout after the race resolves,
500
559
  // otherwise the orphan setTimeout keeps the Node event loop alive
501
560
  // until expires_in elapses (typically 600s). Symptom: success path
502
561
  // prints "Authenticated successfully" and then hangs until Ctrl+C.
503
562
  let timeoutHandle;
504
563
  const timeoutPromise = new Promise((_, reject) => {
505
- timeoutHandle = setTimeout(() => reject(new Error('Device login timed out')), device.expires_in * 1000);
564
+ timeoutHandle = setTimeout(() => reject(new Error("Device login timed out")), device.expires_in * 1000);
506
565
  });
507
566
  let accessToken;
508
567
  try {
@@ -517,28 +576,34 @@ export async function loginBrowser(opts = {}) {
517
576
  }
518
577
  const config = readConfig();
519
578
  config.token = accessToken;
520
- config.token_type = 'oauth';
579
+ config.token_type = "oauth";
521
580
  config.authenticated_at = new Date().toISOString();
522
581
  config.server_url = SERVER_BASE;
523
582
  config.dashboard_url = DASHBOARD_BASE;
524
583
  config.token_expires_at = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString();
525
- const [deviceIdentity] = await Promise.all([fetchUserIdentity(accessToken), clearReauthFlag(accessToken)]);
584
+ const [deviceIdentity] = await Promise.all([
585
+ fetchUserIdentity(accessToken),
586
+ clearReauthFlag(accessToken),
587
+ ]);
526
588
  if (deviceIdentity.user_id)
527
589
  config.user_id = deviceIdentity.user_id;
528
590
  if (deviceIdentity.email)
529
591
  config.email = deviceIdentity.email;
592
+ if (deviceIdentity.name && !config.user?.name) {
593
+ config.user = { ...(config.user ?? {}), name: deviceIdentity.name };
594
+ }
530
595
  writeConfig(config);
531
596
  killStaleMcpServers();
532
- await promptUserNameIfAbsent();
533
- console.log('');
534
- console.log(' ✓ Authenticated successfully');
535
- console.log(' Token saved to ~/.gramatr.json');
536
- console.log(' gramatr intelligence is now active.\n');
597
+ await runPostLoginOnboarding();
598
+ console.log("");
599
+ console.log(" ✓ Authenticated successfully");
600
+ console.log(" Token saved to ~/.gramatr.json");
601
+ console.log(" gramatr intelligence is now active.\n");
537
602
  return;
538
603
  }
539
604
  catch (e) {
540
605
  console.log(` ✗ Device login failed: ${e.message}`);
541
- console.log(' Run `gramatr login` when you\'re ready to authenticate.\n');
606
+ console.log(" Run `gramatr login` when you're ready to authenticate.\n");
542
607
  process.exit(1);
543
608
  return;
544
609
  }
@@ -559,8 +624,8 @@ export async function loginBrowser(opts = {}) {
559
624
  // 1. Bind a localhost callback server (random port — server's DCR allows it)
560
625
  const callbackServer = createServer();
561
626
  await new Promise((resolve, reject) => {
562
- callbackServer.once('error', reject);
563
- callbackServer.listen(0, '127.0.0.1', () => resolve());
627
+ callbackServer.once("error", reject);
628
+ callbackServer.listen(0, "127.0.0.1", () => resolve());
564
629
  });
565
630
  const port = callbackServer.address().port;
566
631
  const redirectUri = `http://localhost:${port}/callback`;
@@ -571,14 +636,14 @@ export async function loginBrowser(opts = {}) {
571
636
  let clientId;
572
637
  try {
573
638
  const regRes = await fetch(`${SERVER_BASE}/register`, {
574
- method: 'POST',
575
- headers: { 'Content-Type': 'application/json' },
639
+ method: "POST",
640
+ headers: { "Content-Type": "application/json" },
576
641
  body: JSON.stringify({
577
- client_name: 'gramatr-mcp-login',
642
+ client_name: "gramatr-mcp-login",
578
643
  redirect_uris: [redirectUri],
579
- grant_types: ['authorization_code'],
580
- response_types: ['code'],
581
- token_endpoint_auth_method: 'none',
644
+ grant_types: ["authorization_code"],
645
+ response_types: ["code"],
646
+ token_endpoint_auth_method: "none",
582
647
  }),
583
648
  signal: AbortSignal.timeout(10000),
584
649
  });
@@ -597,9 +662,9 @@ export async function loginBrowser(opts = {}) {
597
662
  return;
598
663
  }
599
664
  // 3. PKCE: generate verifier + S256 challenge
600
- const codeVerifier = randomBytes(32).toString('base64url');
601
- const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
602
- const state = randomBytes(16).toString('base64url');
665
+ const codeVerifier = randomBytes(32).toString("base64url");
666
+ const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
667
+ const state = randomBytes(16).toString("base64url");
603
668
  // 4. Run the callback server, waiting for /callback?code=...&state=...
604
669
  //
605
670
  // v0.6.5: capture the timeout handle and clearTimeout() in finally — same
@@ -608,16 +673,16 @@ export async function loginBrowser(opts = {}) {
608
673
  // alive past success and the process hangs until Ctrl+C.
609
674
  let codeTimeoutHandle;
610
675
  const codePromise = new Promise((resolve, reject) => {
611
- callbackServer.on('request', (req, res) => {
612
- const url = new URL(req.url || '/', redirectUri);
613
- if (url.pathname !== '/callback') {
676
+ callbackServer.on("request", (req, res) => {
677
+ const url = new URL(req.url || "/", redirectUri);
678
+ if (url.pathname !== "/callback") {
614
679
  res.writeHead(404);
615
- res.end('Not found');
680
+ res.end("Not found");
616
681
  return;
617
682
  }
618
- const code = url.searchParams.get('code');
619
- const returnedState = url.searchParams.get('state');
620
- const error = url.searchParams.get('error');
683
+ const code = url.searchParams.get("code");
684
+ const returnedState = url.searchParams.get("state");
685
+ const error = url.searchParams.get("error");
621
686
  // v0.6.6: `server.close()` alone does not terminate keep-alive
622
687
  // sockets — the browser holds the connection open and the Node
623
688
  // event loop never exits, so the CLI prints "Authenticated" and
@@ -626,50 +691,50 @@ export async function loginBrowser(opts = {}) {
626
691
  // lingering sockets after we respond.
627
692
  const shutdown = () => {
628
693
  callbackServer.close();
629
- if (typeof callbackServer.closeAllConnections === 'function') {
694
+ if (typeof callbackServer.closeAllConnections === "function") {
630
695
  callbackServer.closeAllConnections();
631
696
  }
632
697
  };
633
698
  if (error) {
634
- res.writeHead(200, { 'Content-Type': 'text/html', Connection: 'close' });
635
- res.end(errorPage('Authentication Failed', error));
699
+ res.writeHead(200, { "Content-Type": "text/html", Connection: "close" });
700
+ res.end(errorPage("Authentication Failed", error));
636
701
  shutdown();
637
702
  reject(new Error(`OAuth error: ${error}`));
638
703
  return;
639
704
  }
640
705
  if (!code || returnedState !== state) {
641
- res.writeHead(400, { 'Content-Type': 'text/html', Connection: 'close' });
642
- res.end(errorPage('Invalid Callback', 'Missing code or state mismatch. Please try again.'));
706
+ res.writeHead(400, { "Content-Type": "text/html", Connection: "close" });
707
+ res.end(errorPage("Invalid Callback", "Missing code or state mismatch. Please try again."));
643
708
  shutdown();
644
- reject(new Error('Invalid callback'));
709
+ reject(new Error("Invalid callback"));
645
710
  return;
646
711
  }
647
- res.writeHead(200, { 'Content-Type': 'text/html', Connection: 'close' });
712
+ res.writeHead(200, { "Content-Type": "text/html", Connection: "close" });
648
713
  res.end(successPage());
649
714
  shutdown();
650
715
  resolve(code);
651
716
  });
652
717
  codeTimeoutHandle = setTimeout(() => {
653
718
  callbackServer.close();
654
- reject(new Error('Login timed out after 5 minutes'));
719
+ reject(new Error("Login timed out after 5 minutes"));
655
720
  }, 5 * 60 * 1000);
656
721
  });
657
722
  // 5. Open the browser to the server's /authorize endpoint with PKCE params
658
- const authorizeUrl = new URL('/authorize', SERVER_BASE);
659
- authorizeUrl.searchParams.set('response_type', 'code');
660
- authorizeUrl.searchParams.set('client_id', clientId);
661
- authorizeUrl.searchParams.set('redirect_uri', redirectUri);
662
- authorizeUrl.searchParams.set('code_challenge', codeChallenge);
663
- authorizeUrl.searchParams.set('code_challenge_method', 'S256');
664
- authorizeUrl.searchParams.set('state', state);
665
- console.log(' Opening browser for authentication...');
723
+ const authorizeUrl = new URL("/authorize", SERVER_BASE);
724
+ authorizeUrl.searchParams.set("response_type", "code");
725
+ authorizeUrl.searchParams.set("client_id", clientId);
726
+ authorizeUrl.searchParams.set("redirect_uri", redirectUri);
727
+ authorizeUrl.searchParams.set("code_challenge", codeChallenge);
728
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
729
+ authorizeUrl.searchParams.set("state", state);
730
+ console.log(" Opening browser for authentication...");
666
731
  console.log(` If it doesn't open, visit:`);
667
732
  console.log(` ${authorizeUrl.toString()}`);
668
- console.log('');
669
- const { exec } = await import('child_process');
670
- const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
733
+ console.log("");
734
+ const { exec } = await import("child_process");
735
+ const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
671
736
  exec(`${openCmd} "${authorizeUrl.toString()}"`);
672
- console.log(' Waiting for authorization...');
737
+ console.log(" Waiting for authorization...");
673
738
  let authCode;
674
739
  try {
675
740
  authCode = await codePromise;
@@ -688,10 +753,10 @@ export async function loginBrowser(opts = {}) {
688
753
  let expiresIn = 31536000; // server default = 1 year
689
754
  try {
690
755
  const tokenRes = await fetch(`${SERVER_BASE}/token`, {
691
- method: 'POST',
692
- headers: { 'Content-Type': 'application/json' },
756
+ method: "POST",
757
+ headers: { "Content-Type": "application/json" },
693
758
  body: JSON.stringify({
694
- grant_type: 'authorization_code',
759
+ grant_type: "authorization_code",
695
760
  code: authCode,
696
761
  code_verifier: codeVerifier,
697
762
  client_id: clientId,
@@ -704,7 +769,7 @@ export async function loginBrowser(opts = {}) {
704
769
  throw new Error(payload.error_description || payload.error || `HTTP ${tokenRes.status}`);
705
770
  }
706
771
  accessToken = payload.access_token;
707
- if (typeof payload.expires_in === 'number')
772
+ if (typeof payload.expires_in === "number")
708
773
  expiresIn = payload.expires_in;
709
774
  }
710
775
  catch (e) {
@@ -715,25 +780,31 @@ export async function loginBrowser(opts = {}) {
715
780
  // 7. Save the opaque token — same shape as the device flow
716
781
  const updated = readConfig();
717
782
  updated.token = accessToken;
718
- updated.token_type = 'oauth';
783
+ updated.token_type = "oauth";
719
784
  updated.authenticated_at = new Date().toISOString();
720
785
  updated.server_url = SERVER_BASE;
721
786
  updated.dashboard_url = DASHBOARD_BASE;
722
787
  updated.token_expires_at = new Date(Date.now() + expiresIn * 1000).toISOString();
723
788
  if (clientId)
724
789
  updated.oauth_client_id = clientId;
725
- const [browserIdentity] = await Promise.all([fetchUserIdentity(accessToken), clearReauthFlag(accessToken)]);
790
+ const [browserIdentity] = await Promise.all([
791
+ fetchUserIdentity(accessToken),
792
+ clearReauthFlag(accessToken),
793
+ ]);
726
794
  if (browserIdentity.user_id)
727
795
  updated.user_id = browserIdentity.user_id;
728
796
  if (browserIdentity.email)
729
797
  updated.email = browserIdentity.email;
798
+ if (browserIdentity.name && !updated.user?.name) {
799
+ updated.user = { ...(updated.user ?? {}), name: browserIdentity.name };
800
+ }
730
801
  writeConfig(updated);
731
802
  killStaleMcpServers();
732
- await promptUserNameIfAbsent();
733
- console.log('');
734
- console.log(' ✓ Authenticated successfully');
735
- console.log(' Token saved to ~/.gramatr.json');
736
- console.log(' gramatr intelligence is now active.\n');
803
+ await runPostLoginOnboarding();
804
+ console.log("");
805
+ console.log(" ✓ Authenticated successfully");
806
+ console.log(" Token saved to ~/.gramatr.json");
807
+ console.log(" gramatr intelligence is now active.\n");
737
808
  }
738
809
  // ── CLI ──
739
810
  //
@@ -744,30 +815,33 @@ export async function loginBrowser(opts = {}) {
744
815
  // safe even if the package ever loses `"type": "module"` or tsx
745
816
  // changes its default target to CJS.
746
817
  export async function main(rawArgs = process.argv.slice(2)) {
747
- const args = rawArgs[0] === 'login' ? rawArgs.slice(1) : rawArgs;
748
- if (args.includes('--status') || args.includes('status')) {
818
+ const args = rawArgs[0] === "login" ? rawArgs.slice(1) : rawArgs;
819
+ if (args.includes("--status") || args.includes("status")) {
749
820
  await showStatus();
750
821
  return;
751
822
  }
752
- if (args.includes('--logout') || args.includes('logout')) {
823
+ if (args.includes("--logout") || args.includes("logout")) {
753
824
  await logout();
754
825
  return;
755
826
  }
756
- if (args.includes('--token') || args.includes('-t')) {
757
- const tokenIdx = args.indexOf('--token') !== -1 ? args.indexOf('--token') : args.indexOf('-t');
827
+ if (args.includes("--token") || args.includes("-t")) {
828
+ const tokenIdx = args.indexOf("--token") !== -1 ? args.indexOf("--token") : args.indexOf("-t");
758
829
  const token = args[tokenIdx + 1];
759
830
  if (!token) {
760
831
  // Interactive paste mode — like Claude's login
761
- console.log('\n Paste your gramatr token below.');
762
- console.log(' (API keys start with sk_)\n');
763
- process.stdout.write(' Token: ');
764
- const { createInterface } = await import('readline');
832
+ console.log("\n Paste your gramatr token below.");
833
+ console.log(" (API keys start with sk_)\n");
834
+ process.stdout.write(" Token: ");
835
+ const { createInterface } = await import("readline");
765
836
  const rl = createInterface({ input: process.stdin, output: process.stdout });
766
837
  const pastedToken = await new Promise((resolve) => {
767
- rl.on('line', (line) => { rl.close(); resolve(line.trim()); });
838
+ rl.on("line", (line) => {
839
+ rl.close();
840
+ resolve(line.trim());
841
+ });
768
842
  });
769
843
  if (!pastedToken) {
770
- console.log(' No token provided.\n');
844
+ console.log(" No token provided.\n");
771
845
  process.exit(1);
772
846
  }
773
847
  await loginWithToken(pastedToken);
@@ -777,7 +851,7 @@ export async function main(rawArgs = process.argv.slice(2)) {
777
851
  }
778
852
  return;
779
853
  }
780
- if (args.includes('--help') || args.includes('-h')) {
854
+ if (args.includes("--help") || args.includes("-h")) {
781
855
  console.log(`
782
856
  gramatr login — Authenticate with the gramatr server
783
857
 
@@ -802,17 +876,17 @@ export async function main(rawArgs = process.argv.slice(2)) {
802
876
  }
803
877
  // Default: browser login flow (falls back to device flow if headless
804
878
  // detected, OR if --headless / GRAMATR_LOGIN_HEADLESS=1 is set).
805
- const forceHeadless = args.includes('--headless');
879
+ const forceHeadless = args.includes("--headless");
806
880
  await loginBrowser({ forceHeadless });
807
881
  }
808
882
  // Module-run guard. Works both when invoked directly via
809
883
  // `tsx bin/login.ts` and when imported from another module
810
884
  // (tests, programmatic use). Under ESM, import.meta.url is the
811
885
  // canonical check; we also accept a path-suffix match as a belt.
812
- const invokedAs = process.argv[1] || '';
886
+ const invokedAs = process.argv[1] || "";
813
887
  const isMain = import.meta.url === `file://${invokedAs}` ||
814
- invokedAs.endsWith('login.ts') ||
815
- invokedAs.endsWith('login.js');
888
+ invokedAs.endsWith("login.ts") ||
889
+ invokedAs.endsWith("login.js");
816
890
  if (isMain) {
817
891
  main().catch((err) => {
818
892
  console.error(err instanceof Error ? err.message : String(err));