@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/index.js CHANGED
@@ -1,8 +1,918 @@
1
- #!/usr/bin/env node
1
+ import { loadSettings, saveSettings } from './src/settings.js';
2
+ import { createLinearClient } from './src/linear-client.js';
3
+ import { setQuietMode } from './src/logger.js';
4
+ import {
5
+ resolveProjectRef,
6
+ fetchTeams,
7
+ fetchWorkspaces,
8
+ } from './src/linear.js';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { pathToFileURL } from 'node:url';
2
12
 
3
- import { runCli } from './src/cli.js';
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
+ }
4
23
 
5
- runCli().catch((error) => {
6
- console.error('pi-linear-tools error:', error?.message || error);
7
- process.exit(1);
8
- });
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
+
111
+ import {
112
+ executeIssueList,
113
+ executeIssueView,
114
+ executeIssueCreate,
115
+ executeIssueUpdate,
116
+ executeIssueComment,
117
+ executeIssueStart,
118
+ executeIssueDelete,
119
+ executeProjectList,
120
+ executeTeamList,
121
+ executeMilestoneList,
122
+ executeMilestoneView,
123
+ executeMilestoneCreate,
124
+ executeMilestoneUpdate,
125
+ executeMilestoneDelete,
126
+ } from './src/handlers.js';
127
+ 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
+ }
145
+
146
+ let cachedApiKey = null;
147
+
148
+ async function getLinearAuth() {
149
+ const envKey = process.env.LINEAR_API_KEY;
150
+ if (envKey && 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
+ }
162
+ }
163
+
164
+ if (cachedApiKey) {
165
+ return { apiKey: cachedApiKey };
166
+ }
167
+
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 };
177
+ }
178
+
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;
199
+ }
200
+
201
+ async function resolveDefaultTeam(projectId) {
202
+ const settings = await loadSettings();
203
+
204
+ if (projectId && settings.projects?.[projectId]?.scope?.team) {
205
+ return settings.projects[projectId].scope.team;
206
+ }
207
+
208
+ return settings.defaultTeam || null;
209
+ }
210
+
211
+ async function runGit(pi, args) {
212
+ if (typeof pi.exec !== 'function') {
213
+ throw new Error('pi.exec is unavailable in this runtime; cannot run git operations');
214
+ }
215
+
216
+ const result = await pi.exec('git', args);
217
+ if (result?.code !== 0) {
218
+ const stderr = String(result?.stderr || '').trim();
219
+ throw new Error(`git ${args.join(' ')} failed${stderr ? `: ${stderr}` : ''}`);
220
+ }
221
+ return result;
222
+ }
223
+
224
+ async function gitBranchExists(pi, branchName) {
225
+ if (typeof pi.exec !== 'function') return false;
226
+ const result = await pi.exec('git', ['rev-parse', '--verify', branchName]);
227
+ return result?.code === 0;
228
+ }
229
+
230
+ async function startGitBranchForIssue(pi, branchName, fromRef = 'HEAD', onBranchExists = 'switch') {
231
+ const exists = await gitBranchExists(pi, branchName);
232
+
233
+ if (!exists) {
234
+ await runGit(pi, ['checkout', '-b', branchName, fromRef || 'HEAD']);
235
+ return { action: 'created', branchName };
236
+ }
237
+
238
+ if (onBranchExists === 'suffix') {
239
+ let suffix = 1;
240
+ let nextName = `${branchName}-${suffix}`;
241
+
242
+ // eslint-disable-next-line no-await-in-loop
243
+ while (await gitBranchExists(pi, nextName)) {
244
+ suffix += 1;
245
+ nextName = `${branchName}-${suffix}`;
246
+ }
247
+
248
+ await runGit(pi, ['checkout', '-b', nextName, fromRef || 'HEAD']);
249
+ return { action: 'created-suffix', branchName: nextName };
250
+ }
251
+
252
+ await runGit(pi, ['checkout', branchName]);
253
+ return { action: 'switched', branchName };
254
+ }
255
+
256
+ async function runInteractiveConfigFlow(ctx, pi) {
257
+ const settings = await loadSettings();
258
+ const previousAuthMethod = settings.authMethod || 'api-key';
259
+ const envKey = process.env.LINEAR_API_KEY?.trim();
260
+
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);
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) {
282
+ ctx.ui.notify('Configuration cancelled', 'warning');
283
+ return;
284
+ }
285
+
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
+ }
319
+ }
320
+ }
321
+
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');
327
+ return;
328
+ }
329
+
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
+ }
414
+ }
415
+
416
+ const workspaces = await fetchWorkspaces(client);
417
+
418
+ if (workspaces.length === 0) {
419
+ throw new Error('No workspaces available for this Linear account');
420
+ }
421
+
422
+ const workspaceOptions = workspaces.map((w) => `${w.name} (${w.id})`);
423
+ const selectedWorkspaceLabel = await ctx.ui.select('Select workspace', workspaceOptions);
424
+ if (!selectedWorkspaceLabel) {
425
+ ctx.ui.notify('Configuration cancelled', 'warning');
426
+ return;
427
+ }
428
+
429
+ const selectedWorkspace = workspaces[workspaceOptions.indexOf(selectedWorkspaceLabel)];
430
+ settings.defaultWorkspace = {
431
+ id: selectedWorkspace.id,
432
+ name: selectedWorkspace.name,
433
+ };
434
+
435
+ const teams = await fetchTeams(client);
436
+ if (teams.length === 0) {
437
+ throw new Error('No teams found in selected workspace');
438
+ }
439
+
440
+ const teamOptions = teams.map((t) => `${t.key} - ${t.name} (${t.id})`);
441
+ const selectedTeamLabel = await ctx.ui.select('Select default team', teamOptions);
442
+ if (!selectedTeamLabel) {
443
+ ctx.ui.notify('Configuration cancelled', 'warning');
444
+ return;
445
+ }
446
+
447
+ const selectedTeam = teams[teamOptions.indexOf(selectedTeamLabel)];
448
+ settings.defaultTeam = selectedTeam.key;
449
+
450
+ await saveSettings(settings);
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
+ }
502
+ }
503
+
504
+ async function registerLinearTools(pi) {
505
+ if (typeof pi.registerTool !== 'function') return;
506
+
507
+ pi.registerTool({
508
+ name: 'linear_issue',
509
+ label: 'Linear Issue',
510
+ description: 'Interact with Linear issues. Actions: list, view, create, update, comment, start, delete',
511
+ parameters: {
512
+ type: 'object',
513
+ properties: {
514
+ action: {
515
+ type: 'string',
516
+ enum: ['list', 'view', 'create', 'update', 'comment', 'start', 'delete'],
517
+ description: 'Action to perform on issue(s)',
518
+ },
519
+ issue: {
520
+ type: 'string',
521
+ description: 'Issue key (ABC-123) or Linear issue ID (for view, update, comment, start, delete)',
522
+ },
523
+ project: {
524
+ type: 'string',
525
+ description: 'Project name or ID for listing/creating issues (default: current repo directory name)',
526
+ },
527
+ states: {
528
+ type: 'array',
529
+ items: { type: 'string' },
530
+ description: 'Filter by state names for listing',
531
+ },
532
+ assignee: {
533
+ type: 'string',
534
+ description: 'For list: "me" or "all". For create/update: "me" or assignee ID.',
535
+ },
536
+ assigneeId: {
537
+ type: 'string',
538
+ description: 'Optional explicit assignee ID alias for update/create debugging/compatibility.',
539
+ },
540
+ limit: {
541
+ type: 'number',
542
+ description: 'Maximum number of issues to list (default: 50)',
543
+ },
544
+ includeComments: {
545
+ type: 'boolean',
546
+ description: 'Include comments when viewing issue (default: true)',
547
+ },
548
+ title: {
549
+ type: 'string',
550
+ description: 'Issue title (required for create, optional for update)',
551
+ },
552
+ description: {
553
+ type: 'string',
554
+ description: 'Issue description in markdown (for create, update)',
555
+ },
556
+ priority: {
557
+ type: 'number',
558
+ description: 'Priority 0..4 (for create, update)',
559
+ },
560
+ state: {
561
+ type: 'string',
562
+ description: 'Target state name or ID (for create, update)',
563
+ },
564
+ milestone: {
565
+ type: 'string',
566
+ description: 'For update: milestone name/ID, or "none" to clear milestone assignment.',
567
+ },
568
+ projectMilestoneId: {
569
+ type: 'string',
570
+ description: 'Optional explicit milestone ID alias for update.',
571
+ },
572
+ subIssueOf: {
573
+ type: 'string',
574
+ description: 'For update: set this issue as sub-issue of the given issue key/ID, or "none" to clear parent.',
575
+ },
576
+ parentOf: {
577
+ type: 'array',
578
+ items: { type: 'string' },
579
+ description: 'For update: set listed issues as children of this issue.',
580
+ },
581
+ blockedBy: {
582
+ type: 'array',
583
+ items: { type: 'string' },
584
+ description: 'For update: add "blocked by" dependencies (issues that block this issue).',
585
+ },
586
+ blocking: {
587
+ type: 'array',
588
+ items: { type: 'string' },
589
+ description: 'For update: add "blocking" dependencies (issues this issue blocks).',
590
+ },
591
+ relatedTo: {
592
+ type: 'array',
593
+ items: { type: 'string' },
594
+ description: 'For update: add related issue links.',
595
+ },
596
+ duplicateOf: {
597
+ type: 'string',
598
+ description: 'For update: mark this issue as duplicate of the given issue key/ID.',
599
+ },
600
+ team: {
601
+ type: 'string',
602
+ description: 'Team key (e.g. ENG) or name (optional if default team configured)',
603
+ },
604
+ parentId: {
605
+ type: 'string',
606
+ description: 'Parent issue ID for sub-issues (for create)',
607
+ },
608
+ body: {
609
+ type: 'string',
610
+ description: 'Comment body in markdown (for comment)',
611
+ },
612
+ parentCommentId: {
613
+ type: 'string',
614
+ description: 'Parent comment ID for reply (for comment)',
615
+ },
616
+ fromRef: {
617
+ type: 'string',
618
+ description: 'Git ref to branch from (default: HEAD, for start)',
619
+ },
620
+ onBranchExists: {
621
+ type: 'string',
622
+ enum: ['switch', 'suffix'],
623
+ description: 'When branch exists: switch to it or create suffixed branch (for start)',
624
+ },
625
+ },
626
+ required: ['action'],
627
+ additionalProperties: false,
628
+ },
629
+ renderResult: renderMarkdownResult,
630
+ 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}`);
654
+ }
655
+ },
656
+ });
657
+
658
+ pi.registerTool({
659
+ name: 'linear_project',
660
+ label: 'Linear Project',
661
+ description: 'Interact with Linear projects. Actions: list',
662
+ parameters: {
663
+ type: 'object',
664
+ properties: {
665
+ action: {
666
+ type: 'string',
667
+ enum: ['list'],
668
+ description: 'Action to perform on project(s)',
669
+ },
670
+ },
671
+ required: ['action'],
672
+ additionalProperties: false,
673
+ },
674
+ renderResult: renderMarkdownResult,
675
+ async execute(_toolCallId, params) {
676
+ const client = await createAuthenticatedClient();
677
+
678
+ switch (params.action) {
679
+ case 'list':
680
+ return executeProjectList(client);
681
+ default:
682
+ throw new Error(`Unknown action: ${params.action}`);
683
+ }
684
+ },
685
+ });
686
+
687
+ pi.registerTool({
688
+ name: 'linear_team',
689
+ label: 'Linear Team',
690
+ description: 'Interact with Linear teams. Actions: list',
691
+ parameters: {
692
+ type: 'object',
693
+ properties: {
694
+ action: {
695
+ type: 'string',
696
+ enum: ['list'],
697
+ description: 'Action to perform on team(s)',
698
+ },
699
+ },
700
+ required: ['action'],
701
+ additionalProperties: false,
702
+ },
703
+ renderResult: renderMarkdownResult,
704
+ async execute(_toolCallId, params) {
705
+ const client = await createAuthenticatedClient();
706
+
707
+ switch (params.action) {
708
+ case 'list':
709
+ return executeTeamList(client);
710
+ default:
711
+ throw new Error(`Unknown action: ${params.action}`);
712
+ }
713
+ },
714
+ });
715
+
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,
752
+ },
753
+ renderResult: renderMarkdownResult,
754
+ async execute(_toolCallId, params) {
755
+ const client = await createAuthenticatedClient();
756
+
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
+ }
778
+ }
779
+
780
+ export default async function piLinearToolsExtension(pi) {
781
+ pi.registerCommand('linear-tools-config', {
782
+ description: 'Configure pi-linear-tools settings (API key and default team mappings)',
783
+ handler: async (argsText, ctx) => {
784
+ const args = parseArgs(argsText);
785
+ const apiKey = readFlag(args, '--api-key');
786
+ const defaultTeam = readFlag(args, '--default-team');
787
+ const projectTeam = readFlag(args, '--team');
788
+ const projectName = readFlag(args, '--project');
789
+
790
+ if (apiKey) {
791
+ const settings = await loadSettings();
792
+ const previousAuthMethod = settings.authMethod || 'api-key';
793
+ settings.apiKey = apiKey;
794
+ settings.authMethod = 'api-key';
795
+ await saveSettings(settings);
796
+ cachedApiKey = null;
797
+ if (ctx?.hasUI) {
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
+ }
805
+ }
806
+ return;
807
+ }
808
+
809
+ if (defaultTeam) {
810
+ const settings = await loadSettings();
811
+ settings.defaultTeam = defaultTeam;
812
+ await saveSettings(settings);
813
+ if (ctx?.hasUI) {
814
+ ctx.ui.notify(`Default team set to: ${defaultTeam}`, 'info');
815
+ }
816
+ return;
817
+ }
818
+
819
+ if (projectTeam && projectName) {
820
+ const settings = await loadSettings();
821
+
822
+ let projectId = projectName;
823
+ try {
824
+ const client = await createAuthenticatedClient();
825
+ const resolved = await resolveProjectRef(client, projectName);
826
+ projectId = resolved.id;
827
+ } catch {
828
+ // keep provided value as project ID/name key
829
+ }
830
+
831
+ if (!settings.projects[projectId]) {
832
+ settings.projects[projectId] = {
833
+ scope: {
834
+ team: null,
835
+ },
836
+ };
837
+ }
838
+
839
+ if (!settings.projects[projectId].scope) {
840
+ settings.projects[projectId].scope = { team: null };
841
+ }
842
+
843
+ settings.projects[projectId].scope.team = projectTeam;
844
+ await saveSettings(settings);
845
+
846
+ if (ctx?.hasUI) {
847
+ ctx.ui.notify(`Team for project "${projectName}" set to: ${projectTeam}`, 'info');
848
+ }
849
+ return;
850
+ }
851
+
852
+ if (!apiKey && !defaultTeam && !projectTeam && !projectName && ctx?.hasUI && ctx?.ui) {
853
+ await runInteractiveConfigFlow(ctx, pi);
854
+ return;
855
+ }
856
+
857
+ const settings = await loadSettings();
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');
860
+
861
+ pi.sendMessage({
862
+ customType: 'pi-linear-tools',
863
+ content: `Configuration:\n LINEAR_API_KEY: ${hasKey ? 'configured' : 'not set'} (source: ${keySource})\n Default workspace: ${settings.defaultWorkspace?.name || 'not set'}\n Default team: ${settings.defaultTeam || 'not set'}\n Project team mappings: ${Object.keys(settings.projects || {}).length}\n\nCommands:\n /linear-tools-config --api-key lin_xxx\n /linear-tools-config --default-team ENG\n /linear-tools-config --team ENG --project MyProject\n\nNote: environment LINEAR_API_KEY takes precedence over settings file.`,
864
+ display: true,
865
+ });
866
+ },
867
+ });
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
+
879
+ pi.registerCommand('linear-tools-help', {
880
+ description: 'Show pi-linear-tools commands and tools',
881
+ handler: async (_args, ctx) => {
882
+ if (ctx?.hasUI) {
883
+ ctx.ui.notify('pi-linear-tools extension commands available', 'info');
884
+ }
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
+
900
+ pi.sendMessage({
901
+ customType: 'pi-linear-tools',
902
+ content: [
903
+ 'Commands:',
904
+ ' /linear-tools-config --api-key <key>',
905
+ ' /linear-tools-config --default-team <team-key>',
906
+ ' /linear-tools-config --team <team-key> --project <project-name-or-id>',
907
+ ' /linear-tools-help',
908
+ ' /linear-tools-reload',
909
+ '',
910
+ ...toolLines,
911
+ ].join('\n'),
912
+ display: true,
913
+ });
914
+ },
915
+ });
916
+
917
+ await registerLinearTools(pi);
918
+ }