@fink-andreas/pi-linear-tools 0.2.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.
@@ -613,10 +613,6 @@ async function registerLinearTools(pi) {
613
613
  type: 'string',
614
614
  description: 'Parent comment ID for reply (for comment)',
615
615
  },
616
- branch: {
617
- type: 'string',
618
- description: 'Custom branch name override (for start)',
619
- },
620
616
  fromRef: {
621
617
  type: 'string',
622
618
  description: 'Git ref to branch from (default: HEAD, for start)',
@@ -750,11 +746,6 @@ async function registerLinearTools(pi) {
750
746
  type: 'string',
751
747
  description: 'Target completion date (ISO 8601 date)',
752
748
  },
753
- status: {
754
- type: 'string',
755
- enum: ['backlogged', 'planned', 'inProgress', 'paused', 'completed', 'done', 'cancelled'],
756
- description: 'Milestone status',
757
- },
758
749
  },
759
750
  required: ['action'],
760
751
  additionalProperties: false,
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fink-andreas/pi-linear-tools",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Pi extension with Linear SDK tools and configuration commands",
5
5
  "type": "module",
6
6
  "engines": {
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "scripts": {
29
29
  "start": "node index.js",
30
- "test": "node tests/test-package-manifest.js && node tests/test-extension-registration.js && node tests/test-settings.js && node tests/test-assignee-update.js && node tests/test-full-assignee-flow.js",
30
+ "test": "node tests/test-package-manifest.js && node tests/test-extension-registration.js && node tests/test-settings.js && node tests/test-assignee-update.js && node tests/test-full-assignee-flow.js && node tests/test-branch-param.js",
31
31
  "dev:sync-local-extension": "node scripts/dev-sync-local-extension.mjs",
32
32
  "release:check": "npm test && npm pack --dry-run"
33
33
  },
@@ -40,7 +40,7 @@
40
40
  ],
41
41
  "pi": {
42
42
  "extensions": [
43
- "./extensions"
43
+ "./index.js"
44
44
  ]
45
45
  },
46
46
  "license": "MIT",
@@ -73,7 +73,7 @@ async function writeTokensToFile(tokenData) {
73
73
  await mkdir(parentDir, { recursive: true, mode: 0o700 });
74
74
  await writeFile(tokenFilePath, tokenData, { encoding: 'utf-8', mode: 0o600 });
75
75
 
76
- warn('Stored OAuth tokens in fallback file storage because keychain is unavailable', {
76
+ debug('Stored OAuth tokens in fallback file storage because keychain is unavailable', {
77
77
  path: tokenFilePath,
78
78
  });
79
79
  }
@@ -94,7 +94,7 @@ async function isKeytarAvailable() {
94
94
  debug('keytar module loaded successfully');
95
95
  return true;
96
96
  } catch (error) {
97
- warn('keytar module not available, using fallback storage', { error: error.message });
97
+ debug('keytar module not available, using fallback storage', { error: error.message });
98
98
  keytarModule = false;
99
99
  return false;
100
100
  }
@@ -153,7 +153,7 @@ export async function storeTokens(tokens) {
153
153
  // Clean up fallback file if keychain works again
154
154
  await unlink(getTokenFilePath()).catch(() => {});
155
155
  } catch (error) {
156
- warn('Failed to store tokens in keychain, falling back to file storage', {
156
+ debug('Failed to store tokens in keychain, falling back to file storage', {
157
157
  error: error.message,
158
158
  });
159
159
  await writeTokensToFile(tokenData);
@@ -235,7 +235,7 @@ export async function getTokens() {
235
235
 
236
236
  debug('No tokens found in keychain');
237
237
  } catch (error) {
238
- warn('Failed to retrieve tokens from keychain, trying fallback storage', {
238
+ debug('Failed to retrieve tokens from keychain, trying fallback storage', {
239
239
  error: error.message,
240
240
  });
241
241
  }
@@ -277,7 +277,7 @@ export async function clearTokens() {
277
277
  const message = String(error?.message || 'Unknown error');
278
278
  // Keychain providers (e.g. DBus Secret Service) may be unavailable at runtime.
279
279
  // Clearing is best-effort and we still clear fallback file/in-memory tokens below.
280
- warn('Skipping keychain token clear; keychain backend unavailable', {
280
+ debug('Skipping keychain token clear; keychain backend unavailable', {
281
281
  error: message,
282
282
  });
283
283
  if (/org\.freedesktop\.secrets/i.test(message)) {
package/src/cli.js CHANGED
@@ -143,15 +143,17 @@ Usage:
143
143
  pi-linear-tools <command> [options]
144
144
 
145
145
  Commands:
146
+ issue <action> [options] Manage issues
147
+ project <action> [options] Manage projects
148
+ team <action> [options] Manage teams
149
+ milestone <action> [options] Manage milestones
150
+
151
+ Other commands:
146
152
  help Show this help message
147
153
  auth <action> Manage authentication (OAuth 2.0)
148
154
  config Show current configuration
149
155
  config --api-key <key> Set Linear API key (legacy)
150
156
  config --default-team <key> Set default team
151
- issue <action> [options] Manage issues
152
- project <action> [options] Manage projects
153
- team <action> [options] Manage teams
154
- milestone <action> [options] Manage milestones
155
157
 
156
158
  Auth Actions:
157
159
  login Authenticate with Linear via OAuth 2.0
@@ -165,7 +167,7 @@ Issue Actions:
165
167
  update <issue> [--title X] [--description X] [--state X] [--priority 0-4]
166
168
  [--assignee me|ID] [--milestone X] [--sub-issue-of X]
167
169
  comment <issue> --body X
168
- start <issue> [--branch X] [--from-ref X] [--on-branch-exists switch|suffix]
170
+ start <issue> [--from-ref X] [--on-branch-exists switch|suffix]
169
171
  delete <issue>
170
172
 
171
173
  Project Actions:
@@ -201,10 +203,10 @@ Examples:
201
203
  pi-linear-tools config --api-key lin_xxx
202
204
 
203
205
  Authentication:
204
- OAuth 2.0 is the recommended authentication method.
205
- Run 'pi-linear-tools auth login' to authenticate.
206
- For CI/headless environments, set environment variables:
207
- LINEAR_ACCESS_TOKEN, LINEAR_REFRESH_TOKEN, LINEAR_EXPIRES_AT
206
+ API key is the recommended authentication method (supports milestones).
207
+ Run 'pi-linear-tools config --api-key <key>' to authenticate.
208
+ For CI/headless environments, set the LINEAR_API_KEY environment variable.
209
+ OAuth 2.0 is also available via 'pi-linear-tools auth login'.
208
210
  `);
209
211
  }
210
212
 
@@ -258,7 +260,6 @@ Comment Options:
258
260
 
259
261
  Start Options:
260
262
  <issue> Issue key or ID
261
- --branch X Custom branch name (default: issue's branch name)
262
263
  --from-ref X Git ref to branch from (default: HEAD)
263
264
  --on-branch-exists X "switch" or "suffix" (default: switch)
264
265
 
@@ -642,7 +643,6 @@ async function handleIssueStart(args) {
642
643
 
643
644
  const params = {
644
645
  issue: positional[0],
645
- branch: readFlag(args, '--branch'),
646
646
  fromRef: readFlag(args, '--from-ref'),
647
647
  onBranchExists: readFlag(args, '--on-branch-exists'),
648
648
  };
package/src/handlers.js CHANGED
@@ -447,28 +447,31 @@ export async function executeIssueStart(client, params, options = {}) {
447
447
  const issue = ensureNonEmpty(params.issue, 'issue');
448
448
  const prepared = await prepareIssueStart(client, issue);
449
449
 
450
- const desiredBranch = params.branch || prepared.branchName;
451
- if (!desiredBranch) {
450
+ // Always use Linear's suggested branchName - it cannot be changed via API
451
+ // and using a custom branch would break Linear's branch-to-issue linking
452
+ const branchName = prepared.branchName;
453
+ if (!branchName) {
452
454
  throw new Error(
453
- `No branch name resolved for issue ${prepared.issue.identifier}. Provide the 'branch' parameter explicitly.`
455
+ `No branch name available for issue ${prepared.issue.identifier}. The issue may not have a team assigned.`
454
456
  );
455
457
  }
456
458
 
457
459
  let gitResult;
458
460
  if (gitExecutor) {
459
461
  // Use provided git executor (e.g., pi.exec)
460
- gitResult = await gitExecutor(desiredBranch, params.fromRef || 'HEAD', params.onBranchExists || 'switch');
462
+ gitResult = await gitExecutor(branchName, params.fromRef || 'HEAD', params.onBranchExists || 'switch');
461
463
  } else {
462
464
  // Use built-in child_process git operations
463
- gitResult = await startGitBranch(desiredBranch, params.fromRef || 'HEAD', params.onBranchExists || 'switch');
465
+ gitResult = await startGitBranch(branchName, params.fromRef || 'HEAD', params.onBranchExists || 'switch');
464
466
  }
465
467
 
466
468
  const updatedIssue = await setIssueState(client, prepared.issue.id, prepared.startedState.id);
467
469
 
470
+ const identifier = updatedIssue.identifier || prepared.issue.identifier;
468
471
  const compactTitle = String(updatedIssue.title || prepared.issue?.title || '').trim().toLowerCase();
469
472
  const summary = compactTitle
470
- ? `Started issue ${updatedIssue.identifier} (${compactTitle})`
471
- : `Started issue ${updatedIssue.identifier}`;
473
+ ? `Started issue ${identifier} (${compactTitle})`
474
+ : `Started issue ${identifier}`;
472
475
 
473
476
  return toTextResult(summary, {
474
477
  issueId: updatedIssue.id,
@@ -731,11 +734,11 @@ export async function executeMilestoneCreate(client, params) {
731
734
  export async function executeMilestoneUpdate(client, params) {
732
735
  const milestoneId = ensureNonEmpty(params.milestone, 'milestone');
733
736
 
737
+ // Note: status is not included as it's a computed/read-only field in Linear's API
734
738
  const result = await updateProjectMilestone(client, milestoneId, {
735
739
  name: params.name,
736
740
  description: params.description,
737
741
  targetDate: params.targetDate,
738
- status: params.status,
739
742
  });
740
743
 
741
744
  const friendlyChanges = result.changed;
package/src/linear.js CHANGED
@@ -1136,14 +1136,9 @@ export async function updateProjectMilestone(client, milestoneId, patch = {}) {
1136
1136
  updateInput.targetDate = patch.targetDate;
1137
1137
  }
1138
1138
 
1139
- if (patch.status !== undefined) {
1140
- const validStatuses = ['backlogged', 'planned', 'inProgress', 'paused', 'completed', 'done', 'cancelled'];
1141
- const status = String(patch.status);
1142
- if (!validStatuses.includes(status)) {
1143
- throw new Error(`Invalid status: ${status}. Valid values: ${validStatuses.join(', ')}`);
1144
- }
1145
- updateInput.status = status;
1146
- }
1139
+ // Note: status is a computed/read-only field in Linear's API (ProjectMilestoneStatus enum)
1140
+ // It cannot be set via ProjectMilestoneUpdateInput. The status values (done, next, overdue, unstarted)
1141
+ // are automatically determined by Linear based on milestone progress and dates.
1147
1142
 
1148
1143
  if (Object.keys(updateInput).length === 0) {
1149
1144
  throw new Error('No update fields provided');