@fink-andreas/pi-linear-tools 0.3.0 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.4.0 (2026-03-23)
4
+
5
+ Comprehensive error handling and file-first logging release.
6
+
7
+ ### New Features
8
+ - **File-first logging**: Structured JSON logs written to `~/.config/pi-linear-tools/` instead of stdout (TUI-safe)
9
+ - **Request metrics tracking**: Monitor total/success/failed/rate-limited requests per client
10
+ - **Periodic usage summaries**: Logged every 50 requests or 15 seconds
11
+
12
+ ### Bug Fixes
13
+ - All tool `execute()` functions now wrapped with comprehensive try/catch
14
+ - Never let raw SDK errors propagate to users
15
+ - Extension initialization protected with safety wrapper
16
+
17
+ ### Improvements
18
+ - Clear user-facing error messages for:
19
+ - Rate limit errors (with reset times)
20
+ - Authentication/authorization failures
21
+ - Network errors
22
+ - Server errors (5xx)
23
+ - Graceful handling when `pi.registerTool` is unavailable
24
+ - Better error context for debugging
25
+
3
26
  ## v0.3.0 (2026-03-22)
4
27
 
5
28
  Rate limit optimization and crash prevention release.
@@ -1,75 +1,12 @@
1
1
  import { loadSettings, saveSettings } from '../src/settings.js';
2
- import { createLinearClient, isGloballyRateLimited, markRateLimited } from '../src/linear-client.js';
2
+ import { createLinearClient, checkAndClearRateLimit, markRateLimited } from '../src/linear-client.js';
3
3
  import { setQuietMode } from '../src/logger.js';
4
4
  import {
5
5
  resolveProjectRef,
6
6
  fetchTeams,
7
7
  fetchWorkspaces,
8
8
  } from '../src/linear.js';
9
- import fs from 'node:fs';
10
- import path from 'node:path';
11
- import { pathToFileURL } from 'node:url';
12
-
13
- function isPiCodingAgentRoot(dir) {
14
- const pkgPath = path.join(dir, 'package.json');
15
- if (!fs.existsSync(pkgPath)) return false;
16
- try {
17
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
18
- return pkg?.name === '@mariozechner/pi-coding-agent';
19
- } catch {
20
- return false;
21
- }
22
- }
23
-
24
- function findPiCodingAgentRoot() {
25
- const entry = process.argv?.[1];
26
- if (!entry) return null;
27
-
28
- // Method 1: walk up from argv1 (works when argv1 is .../pi-coding-agent/dist/cli.js)
29
- {
30
- let dir = path.dirname(entry);
31
- for (let i = 0; i < 20; i += 1) {
32
- if (isPiCodingAgentRoot(dir)) {
33
- return dir;
34
- }
35
- const parent = path.dirname(dir);
36
- if (parent === dir) break;
37
- dir = parent;
38
- }
39
- }
40
-
41
- // Method 2: npm global layout guess (works when argv1 is .../<prefix>/bin/pi)
42
- // <prefix>/bin/pi -> <prefix>/lib/node_modules/@mariozechner/pi-coding-agent
43
- {
44
- const binDir = path.dirname(entry);
45
- const prefix = path.resolve(binDir, '..');
46
- const candidate = path.join(prefix, 'lib', 'node_modules', '@mariozechner', 'pi-coding-agent');
47
- if (isPiCodingAgentRoot(candidate)) {
48
- return candidate;
49
- }
50
- }
51
-
52
- // Method 3: common global node_modules locations
53
- for (const candidate of [
54
- '/usr/local/lib/node_modules/@mariozechner/pi-coding-agent',
55
- '/usr/lib/node_modules/@mariozechner/pi-coding-agent',
56
- ]) {
57
- if (isPiCodingAgentRoot(candidate)) {
58
- return candidate;
59
- }
60
- }
61
-
62
- return null;
63
- }
64
-
65
- async function importFromPiRoot(relativePathFromPiRoot) {
66
- const piRoot = findPiCodingAgentRoot();
67
-
68
- if (!piRoot) throw new Error('Unable to locate @mariozechner/pi-coding-agent installation');
69
-
70
- const absPath = path.join(piRoot, relativePathFromPiRoot);
71
- return import(pathToFileURL(absPath).href);
72
- }
9
+ import { isPiCodingAgentRoot, findPiCodingAgentRoot, importFromPiRoot, parseArgs, readFlag } from '../src/shared.js';
73
10
 
74
11
  async function importPiCodingAgent() {
75
12
  try {
@@ -125,23 +62,7 @@ import {
125
62
  executeMilestoneDelete,
126
63
  } from '../src/handlers.js';
127
64
  import { authenticate, getAccessToken, logout } from '../src/auth/index.js';
128
-
129
- function parseArgs(argsString) {
130
- if (!argsString || !argsString.trim()) return [];
131
- const tokens = argsString.match(/"[^"]*"|'[^']*'|\S+/g) || [];
132
- return tokens.map((t) => {
133
- if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
134
- return t.slice(1, -1);
135
- }
136
- return t;
137
- });
138
- }
139
-
140
- function readFlag(args, flag) {
141
- const idx = args.indexOf(flag);
142
- if (idx >= 0 && idx + 1 < args.length) return args[idx + 1];
143
- return undefined;
144
- }
65
+ import { withMilestoneScopeHint } from '../src/error-hints.js';
145
66
 
146
67
  let cachedApiKey = null;
147
68
 
@@ -185,19 +106,6 @@ async function createAuthenticatedClient() {
185
106
  return createLinearClient(await getLinearAuth());
186
107
  }
187
108
 
188
- function withMilestoneScopeHint(error) {
189
- const message = String(error?.message || error || 'Unknown error');
190
-
191
- if (/invalid scope/i.test(message) && /write/i.test(message)) {
192
- return new Error(
193
- `${message}\nHint: Milestone create/update/delete require Linear write scope. ` +
194
- `Use API key auth for milestone management: /linear-tools-config --api-key <key>`
195
- );
196
- }
197
-
198
- return error;
199
- }
200
-
201
109
  async function resolveDefaultTeam(projectId) {
202
110
  const settings = await loadSettings();
203
111
 
@@ -632,7 +540,7 @@ async function registerLinearTools(pi) {
632
540
  renderResult: renderMarkdownResult,
633
541
  async execute(_toolCallId, params) {
634
542
  // Pre-check: skip API calls if we know we're rate limited
635
- const { isRateLimited, resetAt } = isGloballyRateLimited();
543
+ const { isRateLimited, resetAt } = checkAndClearRateLimit();
636
544
  if (isRateLimited) {
637
545
  throw new Error(
638
546
  `Linear API rate limit exceeded (cached).\n\n` +
package/index.js CHANGED
@@ -1,75 +1,12 @@
1
1
  import { loadSettings, saveSettings } from './src/settings.js';
2
- import { createLinearClient } from './src/linear-client.js';
2
+ import { createLinearClient, checkAndClearRateLimit, markRateLimited } from './src/linear-client.js';
3
3
  import { setQuietMode } from './src/logger.js';
4
4
  import {
5
5
  resolveProjectRef,
6
6
  fetchTeams,
7
7
  fetchWorkspaces,
8
8
  } from './src/linear.js';
9
- import fs from 'node:fs';
10
- import path from 'node:path';
11
- import { pathToFileURL } from 'node:url';
12
-
13
- function isPiCodingAgentRoot(dir) {
14
- const pkgPath = path.join(dir, 'package.json');
15
- if (!fs.existsSync(pkgPath)) return false;
16
- try {
17
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
18
- return pkg?.name === '@mariozechner/pi-coding-agent';
19
- } catch {
20
- return false;
21
- }
22
- }
23
-
24
- function findPiCodingAgentRoot() {
25
- const entry = process.argv?.[1];
26
- if (!entry) return null;
27
-
28
- // Method 1: walk up from argv1 (works when argv1 is .../pi-coding-agent/dist/cli.js)
29
- {
30
- let dir = path.dirname(entry);
31
- for (let i = 0; i < 20; i += 1) {
32
- if (isPiCodingAgentRoot(dir)) {
33
- return dir;
34
- }
35
- const parent = path.dirname(dir);
36
- if (parent === dir) break;
37
- dir = parent;
38
- }
39
- }
40
-
41
- // Method 2: npm global layout guess (works when argv1 is .../<prefix>/bin/pi)
42
- // <prefix>/bin/pi -> <prefix>/lib/node_modules/@mariozechner/pi-coding-agent
43
- {
44
- const binDir = path.dirname(entry);
45
- const prefix = path.resolve(binDir, '..');
46
- const candidate = path.join(prefix, 'lib', 'node_modules', '@mariozechner', 'pi-coding-agent');
47
- if (isPiCodingAgentRoot(candidate)) {
48
- return candidate;
49
- }
50
- }
51
-
52
- // Method 3: common global node_modules locations
53
- for (const candidate of [
54
- '/usr/local/lib/node_modules/@mariozechner/pi-coding-agent',
55
- '/usr/lib/node_modules/@mariozechner/pi-coding-agent',
56
- ]) {
57
- if (isPiCodingAgentRoot(candidate)) {
58
- return candidate;
59
- }
60
- }
61
-
62
- return null;
63
- }
64
-
65
- async function importFromPiRoot(relativePathFromPiRoot) {
66
- const piRoot = findPiCodingAgentRoot();
67
-
68
- if (!piRoot) throw new Error('Unable to locate @mariozechner/pi-coding-agent installation');
69
-
70
- const absPath = path.join(piRoot, relativePathFromPiRoot);
71
- return import(pathToFileURL(absPath).href);
72
- }
9
+ import { isPiCodingAgentRoot, findPiCodingAgentRoot, importFromPiRoot, parseArgs, readFlag } from './src/shared.js';
73
10
 
74
11
  async function importPiCodingAgent() {
75
12
  try {
@@ -125,23 +62,7 @@ import {
125
62
  executeMilestoneDelete,
126
63
  } from './src/handlers.js';
127
64
  import { authenticate, getAccessToken, logout } from './src/auth/index.js';
128
-
129
- function parseArgs(argsString) {
130
- if (!argsString || !argsString.trim()) return [];
131
- const tokens = argsString.match(/"[^"]*"|'[^']*'|\S+/g) || [];
132
- return tokens.map((t) => {
133
- if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
134
- return t.slice(1, -1);
135
- }
136
- return t;
137
- });
138
- }
139
-
140
- function readFlag(args, flag) {
141
- const idx = args.indexOf(flag);
142
- if (idx >= 0 && idx + 1 < args.length) return args[idx + 1];
143
- return undefined;
144
- }
65
+ import { withMilestoneScopeHint } from './src/error-hints.js';
145
66
 
146
67
  let cachedApiKey = null;
147
68
 
@@ -185,19 +106,6 @@ async function createAuthenticatedClient() {
185
106
  return createLinearClient(await getLinearAuth());
186
107
  }
187
108
 
188
- function withMilestoneScopeHint(error) {
189
- const message = String(error?.message || error || 'Unknown error');
190
-
191
- if (/invalid scope/i.test(message) && /write/i.test(message)) {
192
- return new Error(
193
- `${message}\nHint: Milestone create/update/delete require Linear write scope. ` +
194
- `Use API key auth for milestone management: /linear-tools-config --api-key <key>`
195
- );
196
- }
197
-
198
- return error;
199
- }
200
-
201
109
  async function resolveDefaultTeam(projectId) {
202
110
  const settings = await loadSettings();
203
111
 
@@ -502,7 +410,10 @@ function renderMarkdownResult(result, _options, _theme) {
502
410
  }
503
411
 
504
412
  async function registerLinearTools(pi) {
505
- if (typeof pi.registerTool !== 'function') return;
413
+ if (typeof pi.registerTool !== 'function') {
414
+ console.warn('[pi-linear-tools] pi.registerTool not available');
415
+ return;
416
+ }
506
417
 
507
418
  pi.registerTool({
508
419
  name: 'linear_issue',
@@ -628,29 +539,90 @@ async function registerLinearTools(pi) {
628
539
  },
629
540
  renderResult: renderMarkdownResult,
630
541
  async execute(_toolCallId, params) {
631
- const client = await createAuthenticatedClient();
632
-
633
- switch (params.action) {
634
- case 'list':
635
- return executeIssueList(client, params);
636
- case 'view':
637
- return executeIssueView(client, params);
638
- case 'create':
639
- return executeIssueCreate(client, params, { resolveDefaultTeam });
640
- case 'update':
641
- return executeIssueUpdate(client, params);
642
- case 'comment':
643
- return executeIssueComment(client, params);
644
- case 'start':
645
- return executeIssueStart(client, params, {
646
- gitExecutor: async (branchName, fromRef, onBranchExists) => {
647
- return startGitBranchForIssue(pi, branchName, fromRef, onBranchExists);
648
- },
649
- });
650
- case 'delete':
651
- return executeIssueDelete(client, params);
652
- default:
653
- throw new Error(`Unknown action: ${params.action}`);
542
+ // Pre-check: skip API calls if we know we're rate limited
543
+ const { isRateLimited, resetAt } = checkAndClearRateLimit();
544
+ if (isRateLimited) {
545
+ throw new Error(
546
+ `Linear API rate limit exceeded (cached).\n\n` +
547
+ `The rate limit resets at: ${resetAt.toLocaleTimeString()}\n\n` +
548
+ `Please wait before making more requests.`
549
+ );
550
+ }
551
+
552
+ try {
553
+ const client = await createAuthenticatedClient();
554
+
555
+ switch (params.action) {
556
+ case 'list':
557
+ return await executeIssueList(client, params);
558
+ case 'view':
559
+ return await executeIssueView(client, params);
560
+ case 'create':
561
+ return await executeIssueCreate(client, params, { resolveDefaultTeam });
562
+ case 'update':
563
+ return await executeIssueUpdate(client, params);
564
+ case 'comment':
565
+ return await executeIssueComment(client, params);
566
+ case 'start':
567
+ return await executeIssueStart(client, params, {
568
+ gitExecutor: async (branchName, fromRef, onBranchExists) => {
569
+ return startGitBranchForIssue(pi, branchName, fromRef, onBranchExists);
570
+ },
571
+ });
572
+ case 'delete':
573
+ return await executeIssueDelete(client, params);
574
+ default:
575
+ throw new Error(`Unknown action: ${params.action}`);
576
+ }
577
+ } catch (error) {
578
+ // Comprehensive error handling - catch ALL errors including SDK's RatelimitedLinearError
579
+ const errorType = error?.type || '';
580
+ const errorMessage = String(error?.message || error || 'Unknown error');
581
+
582
+ // Rate limit error - provide clear reset time and mark globally
583
+ if (errorType === 'Ratelimited' || errorMessage.toLowerCase().includes('rate limit')) {
584
+ const resetTimestamp = error?.requestsResetAt || (Date.now() + 3600000);
585
+ const resetTime = new Date(resetTimestamp).toLocaleTimeString();
586
+ markRateLimited(resetTimestamp);
587
+ throw new Error(
588
+ `Linear API rate limit exceeded.\n\n` +
589
+ `The rate limit resets at: ${resetTime}\n\n` +
590
+ `Please wait before making more requests, or reduce the frequency of API calls.`
591
+ );
592
+ }
593
+
594
+ // Authentication/Forbidden errors (handles SDK's ForbiddenLinearError)
595
+ if (errorType === 'Forbidden' || errorType === 'AuthenticationError' ||
596
+ errorMessage.toLowerCase().includes('forbidden') || errorMessage.toLowerCase().includes('unauthorized')) {
597
+ throw new Error(
598
+ `Linear API authentication failed: ${errorMessage}\n\n` +
599
+ `Please check your API key or OAuth token permissions.`
600
+ );
601
+ }
602
+
603
+ // Network errors (handles SDK's NetworkError)
604
+ if (errorType === 'NetworkError' || errorMessage.toLowerCase().includes('network')) {
605
+ throw new Error(
606
+ `Network error communicating with Linear API.\n\n` +
607
+ `Please check your internet connection and try again.`
608
+ );
609
+ }
610
+
611
+ // Internal server errors (handles SDK's InternalError)
612
+ if (errorType === 'InternalError' || (error?.status >= 500 && error?.status < 600)) {
613
+ throw new Error(
614
+ `Linear API server error (${error?.status || 'unknown'}).\n\n` +
615
+ `Linear may be experiencing issues. Please try again later.`
616
+ );
617
+ }
618
+
619
+ // Re-throw if already formatted with "Linear API error:"
620
+ if (errorMessage.includes('Linear API error:')) {
621
+ throw error;
622
+ }
623
+
624
+ // Wrap unexpected errors with context - NEVER let raw errors propagate
625
+ throw new Error(`Linear issue operation failed: ${errorMessage}`);
654
626
  }
655
627
  },
656
628
  });
@@ -673,13 +645,32 @@ async function registerLinearTools(pi) {
673
645
  },
674
646
  renderResult: renderMarkdownResult,
675
647
  async execute(_toolCallId, params) {
676
- const client = await createAuthenticatedClient();
648
+ try {
649
+ const client = await createAuthenticatedClient();
650
+
651
+ switch (params.action) {
652
+ case 'list':
653
+ return await executeProjectList(client);
654
+ default:
655
+ throw new Error(`Unknown action: ${params.action}`);
656
+ }
657
+ } catch (error) {
658
+ // Comprehensive error handling - catch ALL errors
659
+ const errorType = error?.type || '';
660
+ const errorMessage = String(error?.message || error || 'Unknown error');
661
+
662
+ if (errorType === 'Ratelimited' || errorMessage.toLowerCase().includes('rate limit')) {
663
+ const resetAt = error?.requestsResetAt
664
+ ? new Date(error.requestsResetAt).toLocaleTimeString()
665
+ : 'approximately 1 hour from now';
666
+ throw new Error(`Linear API rate limit exceeded. Resets at: ${resetAt}. Please wait before retrying.`);
667
+ }
677
668
 
678
- switch (params.action) {
679
- case 'list':
680
- return executeProjectList(client);
681
- default:
682
- throw new Error(`Unknown action: ${params.action}`);
669
+ if (errorMessage.includes('Linear API error:')) {
670
+ throw error;
671
+ }
672
+
673
+ throw new Error(`Linear project operation failed: ${errorMessage}`);
683
674
  }
684
675
  },
685
676
  });
@@ -702,13 +693,32 @@ async function registerLinearTools(pi) {
702
693
  },
703
694
  renderResult: renderMarkdownResult,
704
695
  async execute(_toolCallId, params) {
705
- const client = await createAuthenticatedClient();
696
+ try {
697
+ const client = await createAuthenticatedClient();
698
+
699
+ switch (params.action) {
700
+ case 'list':
701
+ return await executeTeamList(client);
702
+ default:
703
+ throw new Error(`Unknown action: ${params.action}`);
704
+ }
705
+ } catch (error) {
706
+ // Comprehensive error handling - catch ALL errors
707
+ const errorType = error?.type || '';
708
+ const errorMessage = String(error?.message || error || 'Unknown error');
709
+
710
+ if (errorType === 'Ratelimited' || errorMessage.toLowerCase().includes('rate limit')) {
711
+ const resetAt = error?.requestsResetAt
712
+ ? new Date(error.requestsResetAt).toLocaleTimeString()
713
+ : 'approximately 1 hour from now';
714
+ throw new Error(`Linear API rate limit exceeded. Resets at: ${resetAt}. Please wait before retrying.`);
715
+ }
706
716
 
707
- switch (params.action) {
708
- case 'list':
709
- return executeTeamList(client);
710
- default:
711
- throw new Error(`Unknown action: ${params.action}`);
717
+ if (errorMessage.includes('Linear API error:')) {
718
+ throw error;
719
+ }
720
+
721
+ throw new Error(`Linear team operation failed: ${errorMessage}`);
712
722
  }
713
723
  },
714
724
  });
@@ -752,9 +762,9 @@ async function registerLinearTools(pi) {
752
762
  },
753
763
  renderResult: renderMarkdownResult,
754
764
  async execute(_toolCallId, params) {
755
- const client = await createAuthenticatedClient();
756
-
757
765
  try {
766
+ const client = await createAuthenticatedClient();
767
+
758
768
  switch (params.action) {
759
769
  case 'list':
760
770
  return await executeMilestoneList(client, params);
@@ -770,7 +780,24 @@ async function registerLinearTools(pi) {
770
780
  throw new Error(`Unknown action: ${params.action}`);
771
781
  }
772
782
  } catch (error) {
773
- throw withMilestoneScopeHint(error);
783
+ // Apply milestone-specific hint, then wrap with operation context
784
+ const hintError = withMilestoneScopeHint(error);
785
+ // Comprehensive error handling - catch ALL errors
786
+ const errorType = error?.type || '';
787
+ const errorMessage = String(hintError?.message || hintError || 'Unknown error');
788
+
789
+ if (errorType === 'Ratelimited' || errorMessage.toLowerCase().includes('rate limit')) {
790
+ const resetAt = error?.requestsResetAt
791
+ ? new Date(error.requestsResetAt).toLocaleTimeString()
792
+ : 'approximately 1 hour from now';
793
+ throw new Error(`Linear API rate limit exceeded. Resets at: ${resetAt}. Please wait before retrying.`);
794
+ }
795
+
796
+ if (errorMessage.includes('Linear API error:')) {
797
+ throw hintError;
798
+ }
799
+
800
+ throw new Error(`Linear milestone operation failed: ${errorMessage}`);
774
801
  }
775
802
  },
776
803
  });
@@ -778,6 +805,8 @@ async function registerLinearTools(pi) {
778
805
  }
779
806
 
780
807
  export default async function piLinearToolsExtension(pi) {
808
+ // Safety wrapper: never let extension errors crash pi
809
+ try {
781
810
  pi.registerCommand('linear-tools-config', {
782
811
  description: 'Configure pi-linear-tools settings (API key and default team mappings)',
783
812
  handler: async (argsText, ctx) => {
@@ -915,4 +944,8 @@ export default async function piLinearToolsExtension(pi) {
915
944
  });
916
945
 
917
946
  await registerLinearTools(pi);
947
+ } catch (error) {
948
+ // Safety: never let extension initialization crash pi
949
+ console.error('[pi-linear-tools] Extension initialization failed:', error?.message || error);
950
+ }
918
951
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fink-andreas/pi-linear-tools",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Pi extension with Linear SDK tools and configuration commands",
5
5
  "type": "module",
6
6
  "engines": {
@@ -46,6 +46,6 @@
46
46
  "license": "MIT",
47
47
  "dependencies": {
48
48
  "@linear/sdk": "^75.0.0",
49
- "keytar": "^7.9.0"
49
+ "@github/keytar": "^7.10.6"
50
50
  }
51
51
  }
@@ -7,7 +7,7 @@
7
7
 
8
8
  import http from 'node:http';
9
9
  import { URL } from 'node:url';
10
- import { debug, warn, error as logError } from '../logger.js';
10
+ import { debug, error as logError } from '../logger.js';
11
11
 
12
12
  // Default callback server configuration
13
13
  const SERVER_CONFIG = {
@@ -0,0 +1,9 @@
1
+ /**
2
+ * OAuth constants for pi-linear-tools
3
+ */
4
+
5
+ // Linear OAuth application client ID
6
+ export const OAUTH_CLIENT_ID = 'a3e177176c6697611367f1a2405d4a34';
7
+
8
+ // OAuth scopes - minimal required scopes
9
+ export const OAUTH_SCOPES = ['read', 'issues:create', 'comments:create'];
package/src/auth/index.js CHANGED
@@ -6,10 +6,9 @@
6
6
  */
7
7
 
8
8
  import { generatePkceParams } from './pkce.js';
9
- import { buildAuthorizationUrl, exchangeCodeForToken } from './oauth.js';
9
+ import { buildAuthorizationUrl, exchangeCodeForToken, refreshAccessToken, revokeToken } from './oauth.js';
10
10
  import { waitForCallback } from './callback-server.js';
11
- import { storeTokens, getTokens, clearTokens, hasValidTokens } from './token-store.js';
12
- import { getValidAccessToken } from './token-refresh.js';
11
+ import { storeTokens, getTokens, clearTokens } from './token-store.js';
13
12
  import { debug, info, warn, error as logError } from '../logger.js';
14
13
 
15
14
  function parseManualCallbackInput(rawInput) {
@@ -184,10 +183,41 @@ export async function authenticate({
184
183
  /**
185
184
  * Get a valid access token, refreshing if necessary
186
185
  *
187
- * @returns {Promise<string|null>} Valid access token or null if not authenticated
186
+ * @returns {Promise<string|null>} Valid access token, or null if no auth configured or refresh failed
188
187
  */
189
188
  export async function getAccessToken() {
190
- return getValidAccessToken(getTokens);
189
+ const tokens = await getTokens();
190
+
191
+ if (!tokens) {
192
+ return null;
193
+ }
194
+
195
+ const now = Date.now();
196
+ const bufferSeconds = 60;
197
+ const bufferMs = bufferSeconds * 1000;
198
+
199
+ // Check if token is still valid (with buffer)
200
+ if (now < tokens.expiresAt - bufferMs) {
201
+ return tokens.accessToken;
202
+ }
203
+
204
+ // Token needs refresh
205
+ try {
206
+ const tokenResponse = await refreshAccessToken(tokens.refreshToken);
207
+ const expiresAt = Date.now() + tokenResponse.expires_in * 1000;
208
+ const newTokens = {
209
+ accessToken: tokenResponse.access_token,
210
+ refreshToken: tokenResponse.refresh_token,
211
+ expiresAt: expiresAt,
212
+ scope: tokenResponse.scope ? tokenResponse.scope.split(' ') : [],
213
+ tokenType: tokenResponse.token_type || 'Bearer',
214
+ };
215
+ await storeTokens(newTokens);
216
+ return newTokens.accessToken;
217
+ } catch (error) {
218
+ debug('Failed to get valid access token', { error: error.message });
219
+ return null;
220
+ }
191
221
  }
192
222
 
193
223
  /**
@@ -196,7 +226,11 @@ export async function getAccessToken() {
196
226
  * @returns {Promise<boolean>} True if authenticated with valid tokens
197
227
  */
198
228
  export async function isAuthenticated() {
199
- return hasValidTokens();
229
+ const tokens = await getTokens();
230
+ if (!tokens) {
231
+ return false;
232
+ }
233
+ return Date.now() < tokens.expiresAt;
200
234
  }
201
235
 
202
236
  /**
@@ -223,12 +257,21 @@ export async function getAuthStatus() {
223
257
  }
224
258
 
225
259
  /**
226
- * Logout (clear stored tokens)
260
+ * Logout (clear stored tokens and revoke OAuth tokens)
227
261
  *
228
262
  * @returns {Promise<void>}
229
263
  */
230
264
  export async function logout() {
231
265
  info('Logging out...');
266
+ const tokens = await getTokens();
267
+ if (tokens?.accessToken) {
268
+ try {
269
+ await revokeToken(tokens.accessToken);
270
+ debug('OAuth token revoked');
271
+ } catch {
272
+ // Ignore revocation errors - we still want to clear local tokens
273
+ }
274
+ }
232
275
  await clearTokens();
233
276
  info('Logged out successfully');
234
277
  }