@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 +23 -0
- package/extensions/pi-linear-tools.js +4 -96
- package/index.js +167 -134
- package/package.json +2 -2
- package/src/auth/callback-server.js +1 -1
- package/src/auth/constants.js +9 -0
- package/src/auth/index.js +50 -7
- package/src/auth/oauth.js +6 -5
- package/src/auth/pkce.js +16 -53
- package/src/auth/token-refresh.js +5 -7
- package/src/cli.js +1 -13
- package/src/error-hints.js +24 -0
- package/src/handlers.js +7 -5
- package/src/linear-client.js +149 -39
- package/src/linear.js +7 -12
- package/src/logger.js +56 -16
- package/src/settings.js +5 -4
- package/src/shared.js +112 -0
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,
|
|
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
|
|
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 } =
|
|
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
|
|
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')
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
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
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
-
|
|
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
|
+
"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.
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|