@fink-andreas/pi-linear-tools 0.1.0 → 0.2.1
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 +449 -113
- package/index.js +916 -6
- package/package.json +6 -4
- 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 +238 -65
- package/src/handlers.js +18 -10
- package/src/linear-client.js +36 -6
- package/src/linear.js +16 -9
- 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;
|
|
128
264
|
|
|
129
|
-
|
|
130
|
-
|
|
265
|
+
setQuietMode(true);
|
|
266
|
+
try {
|
|
267
|
+
accessToken = await getAccessToken();
|
|
268
|
+
} finally {
|
|
269
|
+
setQuietMode(false);
|
|
270
|
+
}
|
|
131
271
|
|
|
132
|
-
|
|
272
|
+
const hasWorkingAuth = !!(envKey || apiKey || accessToken);
|
|
273
|
+
|
|
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
|
+
);
|
|
280
|
+
|
|
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
|
+
}
|
|
321
|
+
|
|
322
|
+
if (!client) {
|
|
323
|
+
const selectedAuthMethod = await ctx.ui.select('Select authentication method', ['API Key (recommended for full functionlaity)', 'OAuth']);
|
|
141
324
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if (!normalized) {
|
|
145
|
-
ctx.ui.notify('No API key provided. Aborting.', 'warning');
|
|
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
|
+
}
|
|
191
459
|
}
|
|
192
460
|
|
|
193
|
-
function
|
|
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
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function registerLinearTools(pi) {
|
|
194
505
|
if (typeof pi.registerTool !== 'function') return;
|
|
195
506
|
|
|
196
507
|
pi.registerTool({
|
|
@@ -302,10 +613,6 @@ function registerLinearTools(pi) {
|
|
|
302
613
|
type: 'string',
|
|
303
614
|
description: 'Parent comment ID for reply (for comment)',
|
|
304
615
|
},
|
|
305
|
-
branch: {
|
|
306
|
-
type: 'string',
|
|
307
|
-
description: 'Custom branch name override (for start)',
|
|
308
|
-
},
|
|
309
616
|
fromRef: {
|
|
310
617
|
type: 'string',
|
|
311
618
|
description: 'Git ref to branch from (default: HEAD, for start)',
|
|
@@ -319,9 +626,9 @@ function registerLinearTools(pi) {
|
|
|
319
626
|
required: ['action'],
|
|
320
627
|
additionalProperties: false,
|
|
321
628
|
},
|
|
629
|
+
renderResult: renderMarkdownResult,
|
|
322
630
|
async execute(_toolCallId, params) {
|
|
323
|
-
const
|
|
324
|
-
const client = createLinearClient(apiKey);
|
|
631
|
+
const client = await createAuthenticatedClient();
|
|
325
632
|
|
|
326
633
|
switch (params.action) {
|
|
327
634
|
case 'list':
|
|
@@ -364,9 +671,9 @@ function registerLinearTools(pi) {
|
|
|
364
671
|
required: ['action'],
|
|
365
672
|
additionalProperties: false,
|
|
366
673
|
},
|
|
674
|
+
renderResult: renderMarkdownResult,
|
|
367
675
|
async execute(_toolCallId, params) {
|
|
368
|
-
const
|
|
369
|
-
const client = createLinearClient(apiKey);
|
|
676
|
+
const client = await createAuthenticatedClient();
|
|
370
677
|
|
|
371
678
|
switch (params.action) {
|
|
372
679
|
case 'list':
|
|
@@ -393,9 +700,9 @@ function registerLinearTools(pi) {
|
|
|
393
700
|
required: ['action'],
|
|
394
701
|
additionalProperties: false,
|
|
395
702
|
},
|
|
703
|
+
renderResult: renderMarkdownResult,
|
|
396
704
|
async execute(_toolCallId, params) {
|
|
397
|
-
const
|
|
398
|
-
const client = createLinearClient(apiKey);
|
|
705
|
+
const client = await createAuthenticatedClient();
|
|
399
706
|
|
|
400
707
|
switch (params.action) {
|
|
401
708
|
case 'list':
|
|
@@ -406,72 +713,71 @@ function registerLinearTools(pi) {
|
|
|
406
713
|
},
|
|
407
714
|
});
|
|
408
715
|
|
|
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
|
-
},
|
|
716
|
+
if (await shouldExposeMilestoneTool()) {
|
|
717
|
+
pi.registerTool({
|
|
718
|
+
name: 'linear_milestone',
|
|
719
|
+
label: 'Linear Milestone',
|
|
720
|
+
description: 'Interact with Linear project milestones. Actions: list, view, create, update, delete',
|
|
721
|
+
parameters: {
|
|
722
|
+
type: 'object',
|
|
723
|
+
properties: {
|
|
724
|
+
action: {
|
|
725
|
+
type: 'string',
|
|
726
|
+
enum: ['list', 'view', 'create', 'update', 'delete'],
|
|
727
|
+
description: 'Action to perform on milestone(s)',
|
|
728
|
+
},
|
|
729
|
+
milestone: {
|
|
730
|
+
type: 'string',
|
|
731
|
+
description: 'Milestone ID (for view, update, delete)',
|
|
732
|
+
},
|
|
733
|
+
project: {
|
|
734
|
+
type: 'string',
|
|
735
|
+
description: 'Project name or ID (for list, create)',
|
|
736
|
+
},
|
|
737
|
+
name: {
|
|
738
|
+
type: 'string',
|
|
739
|
+
description: 'Milestone name (required for create, optional for update)',
|
|
740
|
+
},
|
|
741
|
+
description: {
|
|
742
|
+
type: 'string',
|
|
743
|
+
description: 'Milestone description in markdown',
|
|
744
|
+
},
|
|
745
|
+
targetDate: {
|
|
746
|
+
type: 'string',
|
|
747
|
+
description: 'Target completion date (ISO 8601 date)',
|
|
748
|
+
},
|
|
749
|
+
},
|
|
750
|
+
required: ['action'],
|
|
751
|
+
additionalProperties: false,
|
|
446
752
|
},
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
async execute(_toolCallId, params) {
|
|
451
|
-
const apiKey = await getLinearApiKey();
|
|
452
|
-
const client = createLinearClient(apiKey);
|
|
753
|
+
renderResult: renderMarkdownResult,
|
|
754
|
+
async execute(_toolCallId, params) {
|
|
755
|
+
const client = await createAuthenticatedClient();
|
|
453
756
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
757
|
+
try {
|
|
758
|
+
switch (params.action) {
|
|
759
|
+
case 'list':
|
|
760
|
+
return await executeMilestoneList(client, params);
|
|
761
|
+
case 'view':
|
|
762
|
+
return await executeMilestoneView(client, params);
|
|
763
|
+
case 'create':
|
|
764
|
+
return await executeMilestoneCreate(client, params);
|
|
765
|
+
case 'update':
|
|
766
|
+
return await executeMilestoneUpdate(client, params);
|
|
767
|
+
case 'delete':
|
|
768
|
+
return await executeMilestoneDelete(client, params);
|
|
769
|
+
default:
|
|
770
|
+
throw new Error(`Unknown action: ${params.action}`);
|
|
771
|
+
}
|
|
772
|
+
} catch (error) {
|
|
773
|
+
throw withMilestoneScopeHint(error);
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
});
|
|
777
|
+
}
|
|
470
778
|
}
|
|
471
779
|
|
|
472
|
-
export default function piLinearToolsExtension(pi) {
|
|
473
|
-
registerLinearTools(pi);
|
|
474
|
-
|
|
780
|
+
export default async function piLinearToolsExtension(pi) {
|
|
475
781
|
pi.registerCommand('linear-tools-config', {
|
|
476
782
|
description: 'Configure pi-linear-tools settings (API key and default team mappings)',
|
|
477
783
|
handler: async (argsText, ctx) => {
|
|
@@ -483,11 +789,19 @@ export default function piLinearToolsExtension(pi) {
|
|
|
483
789
|
|
|
484
790
|
if (apiKey) {
|
|
485
791
|
const settings = await loadSettings();
|
|
486
|
-
settings.
|
|
792
|
+
const previousAuthMethod = settings.authMethod || 'api-key';
|
|
793
|
+
settings.apiKey = apiKey;
|
|
794
|
+
settings.authMethod = 'api-key';
|
|
487
795
|
await saveSettings(settings);
|
|
488
796
|
cachedApiKey = null;
|
|
489
797
|
if (ctx?.hasUI) {
|
|
490
798
|
ctx.ui.notify('LINEAR_API_KEY saved to settings', 'info');
|
|
799
|
+
if (previousAuthMethod !== settings.authMethod) {
|
|
800
|
+
ctx.ui.notify(
|
|
801
|
+
'Authentication method changed. Please restart pi to refresh and make the correct tools available.',
|
|
802
|
+
'warning'
|
|
803
|
+
);
|
|
804
|
+
}
|
|
491
805
|
}
|
|
492
806
|
return;
|
|
493
807
|
}
|
|
@@ -507,8 +821,7 @@ export default function piLinearToolsExtension(pi) {
|
|
|
507
821
|
|
|
508
822
|
let projectId = projectName;
|
|
509
823
|
try {
|
|
510
|
-
const
|
|
511
|
-
const client = createLinearClient(resolvedKey);
|
|
824
|
+
const client = await createAuthenticatedClient();
|
|
512
825
|
const resolved = await resolveProjectRef(client, projectName);
|
|
513
826
|
projectId = resolved.id;
|
|
514
827
|
} catch {
|
|
@@ -537,13 +850,13 @@ export default function piLinearToolsExtension(pi) {
|
|
|
537
850
|
}
|
|
538
851
|
|
|
539
852
|
if (!apiKey && !defaultTeam && !projectTeam && !projectName && ctx?.hasUI && ctx?.ui) {
|
|
540
|
-
await runInteractiveConfigFlow(ctx);
|
|
853
|
+
await runInteractiveConfigFlow(ctx, pi);
|
|
541
854
|
return;
|
|
542
855
|
}
|
|
543
856
|
|
|
544
857
|
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');
|
|
858
|
+
const hasKey = !!(settings.apiKey || settings.linearApiKey || process.env.LINEAR_API_KEY);
|
|
859
|
+
const keySource = process.env.LINEAR_API_KEY ? 'environment' : (settings.apiKey || settings.linearApiKey ? 'settings' : 'not set');
|
|
547
860
|
|
|
548
861
|
pi.sendMessage({
|
|
549
862
|
customType: 'pi-linear-tools',
|
|
@@ -553,6 +866,16 @@ export default function piLinearToolsExtension(pi) {
|
|
|
553
866
|
},
|
|
554
867
|
});
|
|
555
868
|
|
|
869
|
+
pi.registerCommand('linear-tools-reload', {
|
|
870
|
+
description: 'Reload extension runtime (extensions, skills, prompts, themes)',
|
|
871
|
+
handler: async (_args, ctx) => {
|
|
872
|
+
if (ctx?.hasUI) {
|
|
873
|
+
ctx.ui.notify('Reloading runtime...', 'info');
|
|
874
|
+
}
|
|
875
|
+
await ctx.reload();
|
|
876
|
+
},
|
|
877
|
+
});
|
|
878
|
+
|
|
556
879
|
pi.registerCommand('linear-tools-help', {
|
|
557
880
|
description: 'Show pi-linear-tools commands and tools',
|
|
558
881
|
handler: async (_args, ctx) => {
|
|
@@ -560,6 +883,20 @@ export default function piLinearToolsExtension(pi) {
|
|
|
560
883
|
ctx.ui.notify('pi-linear-tools extension commands available', 'info');
|
|
561
884
|
}
|
|
562
885
|
|
|
886
|
+
const showMilestoneTool = await shouldExposeMilestoneTool();
|
|
887
|
+
const toolLines = [
|
|
888
|
+
'LLM-callable tools:',
|
|
889
|
+
' linear_issue (list/view/create/update/comment/start/delete)',
|
|
890
|
+
' linear_project (list)',
|
|
891
|
+
' linear_team (list)',
|
|
892
|
+
];
|
|
893
|
+
|
|
894
|
+
if (showMilestoneTool) {
|
|
895
|
+
toolLines.push(' linear_milestone (list/view/create/update/delete)');
|
|
896
|
+
} else {
|
|
897
|
+
toolLines.push(' linear_milestone hidden: requires API key auth');
|
|
898
|
+
}
|
|
899
|
+
|
|
563
900
|
pi.sendMessage({
|
|
564
901
|
customType: 'pi-linear-tools',
|
|
565
902
|
content: [
|
|
@@ -568,15 +905,14 @@ export default function piLinearToolsExtension(pi) {
|
|
|
568
905
|
' /linear-tools-config --default-team <team-key>',
|
|
569
906
|
' /linear-tools-config --team <team-key> --project <project-name-or-id>',
|
|
570
907
|
' /linear-tools-help',
|
|
908
|
+
' /linear-tools-reload',
|
|
571
909
|
'',
|
|
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)',
|
|
910
|
+
...toolLines,
|
|
577
911
|
].join('\n'),
|
|
578
912
|
display: true,
|
|
579
913
|
});
|
|
580
914
|
},
|
|
581
915
|
});
|
|
916
|
+
|
|
917
|
+
await registerLinearTools(pi);
|
|
582
918
|
}
|