@fink-andreas/pi-linear-tools 0.1.0 → 0.2.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 +20 -1
- package/README.md +18 -2
- package/extensions/pi-linear-tools.js +454 -109
- package/package.json +4 -2
- package/src/auth/callback-server.js +337 -0
- package/src/auth/index.js +246 -0
- package/src/auth/oauth.js +281 -0
- package/src/auth/pkce.js +111 -0
- package/src/auth/token-refresh.js +210 -0
- package/src/auth/token-store.js +415 -0
- package/src/cli.js +232 -59
- package/src/handlers.js +7 -2
- package/src/linear-client.js +36 -6
- package/src/linear.js +13 -1
- package/src/settings.js +107 -6
|
@@ -1,11 +1,113 @@
|
|
|
1
1
|
import { loadSettings, saveSettings } from '../src/settings.js';
|
|
2
2
|
import { createLinearClient } from '../src/linear-client.js';
|
|
3
|
-
import {
|
|
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
|
+
}
|
|
73
|
+
|
|
74
|
+
async function importPiCodingAgent() {
|
|
75
|
+
try {
|
|
76
|
+
return await import('@mariozechner/pi-coding-agent');
|
|
77
|
+
} catch {
|
|
78
|
+
return importFromPiRoot('dist/index.js');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function importPiTui() {
|
|
83
|
+
try {
|
|
84
|
+
return await import('@mariozechner/pi-tui');
|
|
85
|
+
} catch {
|
|
86
|
+
// pi-tui is a dependency of pi-coding-agent and may be nested under it
|
|
87
|
+
return importFromPiRoot('node_modules/@mariozechner/pi-tui/dist/index.js');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Optional imports for markdown rendering (provided by pi runtime)
|
|
92
|
+
let Markdown = null;
|
|
93
|
+
let Text = null;
|
|
94
|
+
let getMarkdownTheme = null;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const piTui = await importPiTui();
|
|
98
|
+
Markdown = piTui?.Markdown || null;
|
|
99
|
+
Text = piTui?.Text || null;
|
|
100
|
+
} catch {
|
|
101
|
+
// ignore
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const piCodingAgent = await importPiCodingAgent();
|
|
106
|
+
getMarkdownTheme = piCodingAgent?.getMarkdownTheme || null;
|
|
107
|
+
} catch {
|
|
108
|
+
// ignore
|
|
109
|
+
}
|
|
110
|
+
|
|
9
111
|
import {
|
|
10
112
|
executeIssueList,
|
|
11
113
|
executeIssueView,
|
|
@@ -22,6 +124,7 @@ import {
|
|
|
22
124
|
executeMilestoneUpdate,
|
|
23
125
|
executeMilestoneDelete,
|
|
24
126
|
} from '../src/handlers.js';
|
|
127
|
+
import { authenticate, getAccessToken, logout } from '../src/auth/index.js';
|
|
25
128
|
|
|
26
129
|
function parseArgs(argsString) {
|
|
27
130
|
if (!argsString || !argsString.trim()) return [];
|
|
@@ -42,27 +145,57 @@ function readFlag(args, flag) {
|
|
|
42
145
|
|
|
43
146
|
let cachedApiKey = null;
|
|
44
147
|
|
|
45
|
-
async function
|
|
148
|
+
async function getLinearAuth() {
|
|
46
149
|
const envKey = process.env.LINEAR_API_KEY;
|
|
47
150
|
if (envKey && envKey.trim()) {
|
|
48
|
-
return envKey.trim();
|
|
151
|
+
return { apiKey: envKey.trim() };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const settings = await loadSettings();
|
|
155
|
+
const authMethod = settings.authMethod || 'api-key';
|
|
156
|
+
|
|
157
|
+
if (authMethod === 'oauth') {
|
|
158
|
+
const accessToken = await getAccessToken();
|
|
159
|
+
if (accessToken) {
|
|
160
|
+
return { accessToken };
|
|
161
|
+
}
|
|
49
162
|
}
|
|
50
163
|
|
|
51
164
|
if (cachedApiKey) {
|
|
52
|
-
return cachedApiKey;
|
|
165
|
+
return { apiKey: cachedApiKey };
|
|
53
166
|
}
|
|
54
167
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
168
|
+
const apiKey = settings.apiKey || settings.linearApiKey;
|
|
169
|
+
if (apiKey && apiKey.trim()) {
|
|
170
|
+
cachedApiKey = apiKey.trim();
|
|
171
|
+
return { apiKey: cachedApiKey };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const fallbackAccessToken = await getAccessToken();
|
|
175
|
+
if (fallbackAccessToken) {
|
|
176
|
+
return { accessToken: fallbackAccessToken };
|
|
63
177
|
}
|
|
64
178
|
|
|
65
|
-
throw new Error(
|
|
179
|
+
throw new Error(
|
|
180
|
+
'No Linear authentication configured. Use /linear-tools-config --api-key <key> or run `pi-linear-tools auth login` in CLI.'
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function createAuthenticatedClient() {
|
|
185
|
+
return createLinearClient(await getLinearAuth());
|
|
186
|
+
}
|
|
187
|
+
|
|
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;
|
|
66
199
|
}
|
|
67
200
|
|
|
68
201
|
async function resolveDefaultTeam(projectId) {
|
|
@@ -120,38 +253,166 @@ async function startGitBranchForIssue(pi, branchName, fromRef = 'HEAD', onBranch
|
|
|
120
253
|
return { action: 'switched', branchName };
|
|
121
254
|
}
|
|
122
255
|
|
|
123
|
-
async function runInteractiveConfigFlow(ctx) {
|
|
256
|
+
async function runInteractiveConfigFlow(ctx, pi) {
|
|
124
257
|
const settings = await loadSettings();
|
|
258
|
+
const previousAuthMethod = settings.authMethod || 'api-key';
|
|
125
259
|
const envKey = process.env.LINEAR_API_KEY?.trim();
|
|
126
260
|
|
|
127
|
-
let
|
|
261
|
+
let client;
|
|
262
|
+
let apiKey = settings.apiKey?.trim() || settings.linearApiKey?.trim() || null;
|
|
263
|
+
let accessToken = null;
|
|
264
|
+
|
|
265
|
+
setQuietMode(true);
|
|
266
|
+
try {
|
|
267
|
+
accessToken = await getAccessToken();
|
|
268
|
+
} finally {
|
|
269
|
+
setQuietMode(false);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const hasWorkingAuth = !!(envKey || apiKey || accessToken);
|
|
128
273
|
|
|
129
|
-
if (
|
|
130
|
-
const
|
|
274
|
+
if (hasWorkingAuth) {
|
|
275
|
+
const source = envKey ? 'environment API key' : (accessToken ? 'OAuth token' : 'stored API key');
|
|
276
|
+
const logoutSelection = await ctx.ui.select(
|
|
277
|
+
`Existing authentication detected (${source}). Logout and re-authenticate?`,
|
|
278
|
+
['No', 'Yes']
|
|
279
|
+
);
|
|
131
280
|
|
|
132
|
-
if (!
|
|
281
|
+
if (!logoutSelection) {
|
|
133
282
|
ctx.ui.notify('Configuration cancelled', 'warning');
|
|
134
283
|
return;
|
|
135
284
|
}
|
|
136
285
|
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
|
|
286
|
+
if (logoutSelection === 'Yes') {
|
|
287
|
+
setQuietMode(true);
|
|
288
|
+
try {
|
|
289
|
+
await logout();
|
|
290
|
+
} finally {
|
|
291
|
+
setQuietMode(false);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
settings.apiKey = null;
|
|
295
|
+
if (Object.prototype.hasOwnProperty.call(settings, 'linearApiKey')) {
|
|
296
|
+
delete settings.linearApiKey;
|
|
297
|
+
}
|
|
298
|
+
cachedApiKey = null;
|
|
299
|
+
accessToken = null;
|
|
300
|
+
apiKey = null;
|
|
301
|
+
|
|
302
|
+
await saveSettings(settings);
|
|
303
|
+
ctx.ui.notify('Stored authentication cleared.', 'info');
|
|
304
|
+
|
|
305
|
+
if (envKey) {
|
|
306
|
+
ctx.ui.notify('LINEAR_API_KEY is still set in environment and cannot be removed by this command.', 'warning');
|
|
307
|
+
}
|
|
308
|
+
} else {
|
|
309
|
+
if (envKey) {
|
|
310
|
+
client = createLinearClient(envKey);
|
|
311
|
+
} else if (accessToken) {
|
|
312
|
+
settings.authMethod = 'oauth';
|
|
313
|
+
client = createLinearClient({ accessToken });
|
|
314
|
+
} else if (apiKey) {
|
|
315
|
+
settings.authMethod = 'api-key';
|
|
316
|
+
cachedApiKey = apiKey;
|
|
317
|
+
client = createLinearClient(apiKey);
|
|
318
|
+
}
|
|
140
319
|
}
|
|
320
|
+
}
|
|
141
321
|
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
322
|
+
if (!client) {
|
|
323
|
+
const selectedAuthMethod = await ctx.ui.select('Select authentication method', ['API Key (recommended for full functionlaity)', 'OAuth']);
|
|
324
|
+
|
|
325
|
+
if (!selectedAuthMethod) {
|
|
326
|
+
ctx.ui.notify('Configuration cancelled', 'warning');
|
|
146
327
|
return;
|
|
147
328
|
}
|
|
148
329
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
330
|
+
if (selectedAuthMethod === 'OAuth') {
|
|
331
|
+
settings.authMethod = 'oauth';
|
|
332
|
+
cachedApiKey = null;
|
|
333
|
+
|
|
334
|
+
setQuietMode(true);
|
|
335
|
+
try {
|
|
336
|
+
accessToken = await getAccessToken();
|
|
337
|
+
|
|
338
|
+
if (!accessToken) {
|
|
339
|
+
ctx.ui.notify('Starting OAuth login...', 'info');
|
|
340
|
+
try {
|
|
341
|
+
await authenticate({
|
|
342
|
+
onAuthorizationUrl: async (authUrl) => {
|
|
343
|
+
pi.sendMessage({
|
|
344
|
+
customType: 'pi-linear-tools',
|
|
345
|
+
content: [
|
|
346
|
+
'### Linear OAuth login',
|
|
347
|
+
'',
|
|
348
|
+
`[Open authorization URL](${authUrl})`,
|
|
349
|
+
'',
|
|
350
|
+
'If browser did not open automatically, copy and open this URL:',
|
|
351
|
+
`\`${authUrl}\``,
|
|
352
|
+
'',
|
|
353
|
+
'After authorizing, paste the callback URL in the next prompt.',
|
|
354
|
+
].join('\n'),
|
|
355
|
+
display: true,
|
|
356
|
+
});
|
|
357
|
+
ctx.ui.notify('Complete OAuth in browser, then paste callback URL in the prompt.', 'info');
|
|
358
|
+
},
|
|
359
|
+
manualCodeInput: async () => {
|
|
360
|
+
const entered = await ctx.ui.input(
|
|
361
|
+
'Paste callback URL from browser (or type "cancel")',
|
|
362
|
+
'http://localhost:34711/callback?code=...&state=...'
|
|
363
|
+
);
|
|
364
|
+
const normalized = String(entered || '').trim();
|
|
365
|
+
if (!normalized || normalized.toLowerCase() === 'cancel') {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
return normalized;
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
} catch (error) {
|
|
372
|
+
if (String(error?.message || '').includes('cancelled by user')) {
|
|
373
|
+
ctx.ui.notify('OAuth authentication cancelled.', 'warning');
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
accessToken = await getAccessToken();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (!accessToken) {
|
|
382
|
+
throw new Error('OAuth authentication failed: no access token available after login');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
client = createLinearClient({ accessToken });
|
|
386
|
+
} finally {
|
|
387
|
+
setQuietMode(false);
|
|
388
|
+
}
|
|
389
|
+
} else {
|
|
390
|
+
setQuietMode(true);
|
|
391
|
+
try {
|
|
392
|
+
await logout();
|
|
393
|
+
} finally {
|
|
394
|
+
setQuietMode(false);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (!envKey && !apiKey) {
|
|
398
|
+
const promptedKey = await ctx.ui.input('Enter Linear API key', 'lin_xxx');
|
|
399
|
+
const normalized = String(promptedKey || '').trim();
|
|
400
|
+
if (!normalized) {
|
|
401
|
+
ctx.ui.notify('No API key provided. Aborting.', 'warning');
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
apiKey = normalized;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const selectedApiKey = envKey || apiKey;
|
|
409
|
+
settings.apiKey = selectedApiKey;
|
|
410
|
+
settings.authMethod = 'api-key';
|
|
411
|
+
cachedApiKey = selectedApiKey;
|
|
412
|
+
client = createLinearClient(selectedApiKey);
|
|
413
|
+
}
|
|
152
414
|
}
|
|
153
415
|
|
|
154
|
-
const client = createLinearClient(apiKey);
|
|
155
416
|
const workspaces = await fetchWorkspaces(client);
|
|
156
417
|
|
|
157
418
|
if (workspaces.length === 0) {
|
|
@@ -188,9 +449,59 @@ async function runInteractiveConfigFlow(ctx) {
|
|
|
188
449
|
|
|
189
450
|
await saveSettings(settings);
|
|
190
451
|
ctx.ui.notify(`Configuration saved: workspace ${selectedWorkspace.name}, team ${selectedTeam.key}`, 'info');
|
|
452
|
+
|
|
453
|
+
if (previousAuthMethod !== settings.authMethod) {
|
|
454
|
+
ctx.ui.notify(
|
|
455
|
+
'Authentication method changed. Please restart pi to refresh and make the correct tools available.',
|
|
456
|
+
'warning'
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function shouldExposeMilestoneTool() {
|
|
462
|
+
const settings = await loadSettings();
|
|
463
|
+
const authMethod = settings.authMethod || 'api-key';
|
|
464
|
+
const apiKeyFromSettings = (settings.apiKey || settings.linearApiKey || '').trim();
|
|
465
|
+
const apiKeyFromEnv = (process.env.LINEAR_API_KEY || '').trim();
|
|
466
|
+
const hasApiKey = !!(apiKeyFromEnv || apiKeyFromSettings);
|
|
467
|
+
|
|
468
|
+
return authMethod === 'api-key' || hasApiKey;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Render tool result as markdown
|
|
473
|
+
*/
|
|
474
|
+
function renderMarkdownResult(result, _options, _theme) {
|
|
475
|
+
const text = result.content?.[0]?.text || '';
|
|
476
|
+
|
|
477
|
+
// Fall back to plain text if markdown packages not available
|
|
478
|
+
if (!Markdown || !getMarkdownTheme) {
|
|
479
|
+
const lines = text.split('\n');
|
|
480
|
+
return {
|
|
481
|
+
render: (width) => lines.map((line) => (width && line.length > width ? line.slice(0, width) : line)),
|
|
482
|
+
invalidate: () => {},
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Return Markdown component directly - the TUI will call its render() method
|
|
487
|
+
try {
|
|
488
|
+
const mdTheme = getMarkdownTheme();
|
|
489
|
+
return new Markdown(text, 0, 0, mdTheme, _theme ? { color: (t) => _theme.fg('toolOutput', t) } : undefined);
|
|
490
|
+
} catch (error) {
|
|
491
|
+
// If markdown rendering fails for any reason, show a visible error so we can diagnose.
|
|
492
|
+
const msg = `[pi-linear-tools] Markdown render failed: ${String(error?.message || error)}`;
|
|
493
|
+
if (Text) {
|
|
494
|
+
return new Text((_theme ? _theme.fg('error', msg) : msg) + `\n\n` + text, 0, 0);
|
|
495
|
+
}
|
|
496
|
+
const lines = (msg + '\n\n' + text).split('\n');
|
|
497
|
+
return {
|
|
498
|
+
render: (width) => lines.map((line) => (width && line.length > width ? line.slice(0, width) : line)),
|
|
499
|
+
invalidate: () => {},
|
|
500
|
+
};
|
|
501
|
+
}
|
|
191
502
|
}
|
|
192
503
|
|
|
193
|
-
function registerLinearTools(pi) {
|
|
504
|
+
async function registerLinearTools(pi) {
|
|
194
505
|
if (typeof pi.registerTool !== 'function') return;
|
|
195
506
|
|
|
196
507
|
pi.registerTool({
|
|
@@ -319,9 +630,9 @@ function registerLinearTools(pi) {
|
|
|
319
630
|
required: ['action'],
|
|
320
631
|
additionalProperties: false,
|
|
321
632
|
},
|
|
633
|
+
renderResult: renderMarkdownResult,
|
|
322
634
|
async execute(_toolCallId, params) {
|
|
323
|
-
const
|
|
324
|
-
const client = createLinearClient(apiKey);
|
|
635
|
+
const client = await createAuthenticatedClient();
|
|
325
636
|
|
|
326
637
|
switch (params.action) {
|
|
327
638
|
case 'list':
|
|
@@ -364,9 +675,9 @@ function registerLinearTools(pi) {
|
|
|
364
675
|
required: ['action'],
|
|
365
676
|
additionalProperties: false,
|
|
366
677
|
},
|
|
678
|
+
renderResult: renderMarkdownResult,
|
|
367
679
|
async execute(_toolCallId, params) {
|
|
368
|
-
const
|
|
369
|
-
const client = createLinearClient(apiKey);
|
|
680
|
+
const client = await createAuthenticatedClient();
|
|
370
681
|
|
|
371
682
|
switch (params.action) {
|
|
372
683
|
case 'list':
|
|
@@ -393,9 +704,9 @@ function registerLinearTools(pi) {
|
|
|
393
704
|
required: ['action'],
|
|
394
705
|
additionalProperties: false,
|
|
395
706
|
},
|
|
707
|
+
renderResult: renderMarkdownResult,
|
|
396
708
|
async execute(_toolCallId, params) {
|
|
397
|
-
const
|
|
398
|
-
const client = createLinearClient(apiKey);
|
|
709
|
+
const client = await createAuthenticatedClient();
|
|
399
710
|
|
|
400
711
|
switch (params.action) {
|
|
401
712
|
case 'list':
|
|
@@ -406,72 +717,76 @@ function registerLinearTools(pi) {
|
|
|
406
717
|
},
|
|
407
718
|
});
|
|
408
719
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
720
|
+
if (await shouldExposeMilestoneTool()) {
|
|
721
|
+
pi.registerTool({
|
|
722
|
+
name: 'linear_milestone',
|
|
723
|
+
label: 'Linear Milestone',
|
|
724
|
+
description: 'Interact with Linear project milestones. Actions: list, view, create, update, delete',
|
|
725
|
+
parameters: {
|
|
726
|
+
type: 'object',
|
|
727
|
+
properties: {
|
|
728
|
+
action: {
|
|
729
|
+
type: 'string',
|
|
730
|
+
enum: ['list', 'view', 'create', 'update', 'delete'],
|
|
731
|
+
description: 'Action to perform on milestone(s)',
|
|
732
|
+
},
|
|
733
|
+
milestone: {
|
|
734
|
+
type: 'string',
|
|
735
|
+
description: 'Milestone ID (for view, update, delete)',
|
|
736
|
+
},
|
|
737
|
+
project: {
|
|
738
|
+
type: 'string',
|
|
739
|
+
description: 'Project name or ID (for list, create)',
|
|
740
|
+
},
|
|
741
|
+
name: {
|
|
742
|
+
type: 'string',
|
|
743
|
+
description: 'Milestone name (required for create, optional for update)',
|
|
744
|
+
},
|
|
745
|
+
description: {
|
|
746
|
+
type: 'string',
|
|
747
|
+
description: 'Milestone description in markdown',
|
|
748
|
+
},
|
|
749
|
+
targetDate: {
|
|
750
|
+
type: 'string',
|
|
751
|
+
description: 'Target completion date (ISO 8601 date)',
|
|
752
|
+
},
|
|
753
|
+
status: {
|
|
754
|
+
type: 'string',
|
|
755
|
+
enum: ['backlogged', 'planned', 'inProgress', 'paused', 'completed', 'done', 'cancelled'],
|
|
756
|
+
description: 'Milestone status',
|
|
757
|
+
},
|
|
758
|
+
},
|
|
759
|
+
required: ['action'],
|
|
760
|
+
additionalProperties: false,
|
|
446
761
|
},
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
async execute(_toolCallId, params) {
|
|
451
|
-
const apiKey = await getLinearApiKey();
|
|
452
|
-
const client = createLinearClient(apiKey);
|
|
762
|
+
renderResult: renderMarkdownResult,
|
|
763
|
+
async execute(_toolCallId, params) {
|
|
764
|
+
const client = await createAuthenticatedClient();
|
|
453
765
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
766
|
+
try {
|
|
767
|
+
switch (params.action) {
|
|
768
|
+
case 'list':
|
|
769
|
+
return await executeMilestoneList(client, params);
|
|
770
|
+
case 'view':
|
|
771
|
+
return await executeMilestoneView(client, params);
|
|
772
|
+
case 'create':
|
|
773
|
+
return await executeMilestoneCreate(client, params);
|
|
774
|
+
case 'update':
|
|
775
|
+
return await executeMilestoneUpdate(client, params);
|
|
776
|
+
case 'delete':
|
|
777
|
+
return await executeMilestoneDelete(client, params);
|
|
778
|
+
default:
|
|
779
|
+
throw new Error(`Unknown action: ${params.action}`);
|
|
780
|
+
}
|
|
781
|
+
} catch (error) {
|
|
782
|
+
throw withMilestoneScopeHint(error);
|
|
783
|
+
}
|
|
784
|
+
},
|
|
785
|
+
});
|
|
786
|
+
}
|
|
470
787
|
}
|
|
471
788
|
|
|
472
|
-
export default function piLinearToolsExtension(pi) {
|
|
473
|
-
registerLinearTools(pi);
|
|
474
|
-
|
|
789
|
+
export default async function piLinearToolsExtension(pi) {
|
|
475
790
|
pi.registerCommand('linear-tools-config', {
|
|
476
791
|
description: 'Configure pi-linear-tools settings (API key and default team mappings)',
|
|
477
792
|
handler: async (argsText, ctx) => {
|
|
@@ -483,11 +798,19 @@ export default function piLinearToolsExtension(pi) {
|
|
|
483
798
|
|
|
484
799
|
if (apiKey) {
|
|
485
800
|
const settings = await loadSettings();
|
|
486
|
-
settings.
|
|
801
|
+
const previousAuthMethod = settings.authMethod || 'api-key';
|
|
802
|
+
settings.apiKey = apiKey;
|
|
803
|
+
settings.authMethod = 'api-key';
|
|
487
804
|
await saveSettings(settings);
|
|
488
805
|
cachedApiKey = null;
|
|
489
806
|
if (ctx?.hasUI) {
|
|
490
807
|
ctx.ui.notify('LINEAR_API_KEY saved to settings', 'info');
|
|
808
|
+
if (previousAuthMethod !== settings.authMethod) {
|
|
809
|
+
ctx.ui.notify(
|
|
810
|
+
'Authentication method changed. Please restart pi to refresh and make the correct tools available.',
|
|
811
|
+
'warning'
|
|
812
|
+
);
|
|
813
|
+
}
|
|
491
814
|
}
|
|
492
815
|
return;
|
|
493
816
|
}
|
|
@@ -507,8 +830,7 @@ export default function piLinearToolsExtension(pi) {
|
|
|
507
830
|
|
|
508
831
|
let projectId = projectName;
|
|
509
832
|
try {
|
|
510
|
-
const
|
|
511
|
-
const client = createLinearClient(resolvedKey);
|
|
833
|
+
const client = await createAuthenticatedClient();
|
|
512
834
|
const resolved = await resolveProjectRef(client, projectName);
|
|
513
835
|
projectId = resolved.id;
|
|
514
836
|
} catch {
|
|
@@ -537,13 +859,13 @@ export default function piLinearToolsExtension(pi) {
|
|
|
537
859
|
}
|
|
538
860
|
|
|
539
861
|
if (!apiKey && !defaultTeam && !projectTeam && !projectName && ctx?.hasUI && ctx?.ui) {
|
|
540
|
-
await runInteractiveConfigFlow(ctx);
|
|
862
|
+
await runInteractiveConfigFlow(ctx, pi);
|
|
541
863
|
return;
|
|
542
864
|
}
|
|
543
865
|
|
|
544
866
|
const settings = await loadSettings();
|
|
545
|
-
const hasKey = !!(settings.linearApiKey || process.env.LINEAR_API_KEY);
|
|
546
|
-
const keySource = process.env.LINEAR_API_KEY ? 'environment' : (settings.linearApiKey ? 'settings' : 'not set');
|
|
867
|
+
const hasKey = !!(settings.apiKey || settings.linearApiKey || process.env.LINEAR_API_KEY);
|
|
868
|
+
const keySource = process.env.LINEAR_API_KEY ? 'environment' : (settings.apiKey || settings.linearApiKey ? 'settings' : 'not set');
|
|
547
869
|
|
|
548
870
|
pi.sendMessage({
|
|
549
871
|
customType: 'pi-linear-tools',
|
|
@@ -553,6 +875,16 @@ export default function piLinearToolsExtension(pi) {
|
|
|
553
875
|
},
|
|
554
876
|
});
|
|
555
877
|
|
|
878
|
+
pi.registerCommand('linear-tools-reload', {
|
|
879
|
+
description: 'Reload extension runtime (extensions, skills, prompts, themes)',
|
|
880
|
+
handler: async (_args, ctx) => {
|
|
881
|
+
if (ctx?.hasUI) {
|
|
882
|
+
ctx.ui.notify('Reloading runtime...', 'info');
|
|
883
|
+
}
|
|
884
|
+
await ctx.reload();
|
|
885
|
+
},
|
|
886
|
+
});
|
|
887
|
+
|
|
556
888
|
pi.registerCommand('linear-tools-help', {
|
|
557
889
|
description: 'Show pi-linear-tools commands and tools',
|
|
558
890
|
handler: async (_args, ctx) => {
|
|
@@ -560,6 +892,20 @@ export default function piLinearToolsExtension(pi) {
|
|
|
560
892
|
ctx.ui.notify('pi-linear-tools extension commands available', 'info');
|
|
561
893
|
}
|
|
562
894
|
|
|
895
|
+
const showMilestoneTool = await shouldExposeMilestoneTool();
|
|
896
|
+
const toolLines = [
|
|
897
|
+
'LLM-callable tools:',
|
|
898
|
+
' linear_issue (list/view/create/update/comment/start/delete)',
|
|
899
|
+
' linear_project (list)',
|
|
900
|
+
' linear_team (list)',
|
|
901
|
+
];
|
|
902
|
+
|
|
903
|
+
if (showMilestoneTool) {
|
|
904
|
+
toolLines.push(' linear_milestone (list/view/create/update/delete)');
|
|
905
|
+
} else {
|
|
906
|
+
toolLines.push(' linear_milestone hidden: requires API key auth');
|
|
907
|
+
}
|
|
908
|
+
|
|
563
909
|
pi.sendMessage({
|
|
564
910
|
customType: 'pi-linear-tools',
|
|
565
911
|
content: [
|
|
@@ -568,15 +914,14 @@ export default function piLinearToolsExtension(pi) {
|
|
|
568
914
|
' /linear-tools-config --default-team <team-key>',
|
|
569
915
|
' /linear-tools-config --team <team-key> --project <project-name-or-id>',
|
|
570
916
|
' /linear-tools-help',
|
|
917
|
+
' /linear-tools-reload',
|
|
571
918
|
'',
|
|
572
|
-
|
|
573
|
-
' linear_issue (list/view/create/update/comment/start/delete)',
|
|
574
|
-
' linear_project (list)',
|
|
575
|
-
' linear_team (list)',
|
|
576
|
-
' linear_milestone (list/view/create/update/delete)',
|
|
919
|
+
...toolLines,
|
|
577
920
|
].join('\n'),
|
|
578
921
|
display: true,
|
|
579
922
|
});
|
|
580
923
|
},
|
|
581
924
|
});
|
|
925
|
+
|
|
926
|
+
await registerLinearTools(pi);
|
|
582
927
|
}
|