@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.
@@ -1,11 +1,113 @@
1
1
  import { loadSettings, saveSettings } from '../src/settings.js';
2
2
  import { createLinearClient } from '../src/linear-client.js';
3
- import { debug } from '../src/logger.js';
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 getLinearApiKey() {
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
- try {
56
- const settings = await loadSettings();
57
- if (settings.linearApiKey && settings.linearApiKey.trim()) {
58
- cachedApiKey = settings.linearApiKey.trim();
59
- return cachedApiKey;
60
- }
61
- } catch {
62
- // ignore, error below
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('LINEAR_API_KEY not set. Use /linear-tools-config --api-key <key> or set environment variable.');
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 apiKey = envKey || settings.linearApiKey?.trim() || null;
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 (!apiKey) {
130
- const authMethod = await ctx.ui.select('Select authentication method', ['OAuth', 'API Key']);
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 (!authMethod) {
281
+ if (!logoutSelection) {
133
282
  ctx.ui.notify('Configuration cancelled', 'warning');
134
283
  return;
135
284
  }
136
285
 
137
- if (authMethod === 'OAuth') {
138
- ctx.ui.notify('OAuth setup is not implemented yet. Aborting.', 'warning');
139
- return;
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
- const promptedKey = await ctx.ui.input('Enter Linear API key', 'lin_xxx');
143
- const normalized = String(promptedKey || '').trim();
144
- if (!normalized) {
145
- ctx.ui.notify('No API key provided. Aborting.', 'warning');
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
- apiKey = normalized;
150
- settings.linearApiKey = normalized;
151
- cachedApiKey = normalized;
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 apiKey = await getLinearApiKey();
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 apiKey = await getLinearApiKey();
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 apiKey = await getLinearApiKey();
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
- pi.registerTool({
410
- name: 'linear_milestone',
411
- label: 'Linear Milestone',
412
- description: 'Interact with Linear project milestones. Actions: list, view, create, update, delete',
413
- parameters: {
414
- type: 'object',
415
- properties: {
416
- action: {
417
- type: 'string',
418
- enum: ['list', 'view', 'create', 'update', 'delete'],
419
- description: 'Action to perform on milestone(s)',
420
- },
421
- milestone: {
422
- type: 'string',
423
- description: 'Milestone ID (for view, update, delete)',
424
- },
425
- project: {
426
- type: 'string',
427
- description: 'Project name or ID (for list, create)',
428
- },
429
- name: {
430
- type: 'string',
431
- description: 'Milestone name (required for create, optional for update)',
432
- },
433
- description: {
434
- type: 'string',
435
- description: 'Milestone description in markdown',
436
- },
437
- targetDate: {
438
- type: 'string',
439
- description: 'Target completion date (ISO 8601 date)',
440
- },
441
- status: {
442
- type: 'string',
443
- enum: ['backlogged', 'planned', 'inProgress', 'paused', 'completed', 'done', 'cancelled'],
444
- description: 'Milestone status',
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
- required: ['action'],
448
- additionalProperties: false,
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
- switch (params.action) {
455
- case 'list':
456
- return executeMilestoneList(client, params);
457
- case 'view':
458
- return executeMilestoneView(client, params);
459
- case 'create':
460
- return executeMilestoneCreate(client, params);
461
- case 'update':
462
- return executeMilestoneUpdate(client, params);
463
- case 'delete':
464
- return executeMilestoneDelete(client, params);
465
- default:
466
- throw new Error(`Unknown action: ${params.action}`);
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.linearApiKey = apiKey;
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 resolvedKey = await getLinearApiKey();
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
- 'LLM-callable tools:',
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
  }