@fink-andreas/pi-linear-tools 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js ADDED
@@ -0,0 +1,729 @@
1
+ import { loadSettings, saveSettings } from './settings.js';
2
+ import { createLinearClient } from './linear-client.js';
3
+ import { resolveProjectRef } from './linear.js';
4
+ import {
5
+ executeIssueList,
6
+ executeIssueView,
7
+ executeIssueCreate,
8
+ executeIssueUpdate,
9
+ executeIssueComment,
10
+ executeIssueStart,
11
+ executeIssueDelete,
12
+ executeProjectList,
13
+ executeTeamList,
14
+ executeMilestoneList,
15
+ executeMilestoneView,
16
+ executeMilestoneCreate,
17
+ executeMilestoneUpdate,
18
+ executeMilestoneDelete,
19
+ } from './handlers.js';
20
+
21
+ // ===== ARGUMENT PARSING =====
22
+
23
+ function readFlag(args, flag) {
24
+ const idx = args.indexOf(flag);
25
+ if (idx >= 0 && idx + 1 < args.length) {
26
+ return args[idx + 1];
27
+ }
28
+ return undefined;
29
+ }
30
+
31
+ function readMultiFlag(args, flag) {
32
+ const values = [];
33
+ for (let i = 0; i < args.length; i += 1) {
34
+ if (args[i] === flag && i + 1 < args.length) {
35
+ values.push(args[i + 1]);
36
+ }
37
+ }
38
+ return values;
39
+ }
40
+
41
+ function hasFlag(args, flag) {
42
+ return args.includes(flag);
43
+ }
44
+
45
+ function parseArrayValue(value) {
46
+ if (!value) return undefined;
47
+ // Support comma-separated values
48
+ return value.split(',').map((v) => v.trim()).filter(Boolean);
49
+ }
50
+
51
+ function parseNumber(value) {
52
+ if (value === undefined || value === null) return undefined;
53
+ const parsed = Number.parseInt(value, 10);
54
+ return Number.isNaN(parsed) ? undefined : parsed;
55
+ }
56
+
57
+ function parseBoolean(value) {
58
+ if (value === 'true' || value === '1') return true;
59
+ if (value === 'false' || value === '0') return false;
60
+ return undefined;
61
+ }
62
+
63
+ // ===== API KEY RESOLUTION =====
64
+
65
+ let cachedApiKey = null;
66
+
67
+ async function getLinearApiKey() {
68
+ const envKey = process.env.LINEAR_API_KEY;
69
+ if (envKey && envKey.trim()) {
70
+ return envKey.trim();
71
+ }
72
+
73
+ if (cachedApiKey) {
74
+ return cachedApiKey;
75
+ }
76
+
77
+ try {
78
+ const settings = await loadSettings();
79
+ if (settings.linearApiKey && settings.linearApiKey.trim()) {
80
+ cachedApiKey = settings.linearApiKey.trim();
81
+ return cachedApiKey;
82
+ }
83
+ } catch {
84
+ // ignore, error below
85
+ }
86
+
87
+ throw new Error('LINEAR_API_KEY not set. Run: pi-linear-tools config --api-key <key>');
88
+ }
89
+
90
+ async function resolveDefaultTeam(projectId) {
91
+ const settings = await loadSettings();
92
+
93
+ if (projectId && settings.projects?.[projectId]?.scope?.team) {
94
+ return settings.projects[projectId].scope.team;
95
+ }
96
+
97
+ return settings.defaultTeam || null;
98
+ }
99
+
100
+ // ===== HELP OUTPUT =====
101
+
102
+ function printHelp() {
103
+ console.log(`pi-linear-tools - Linear CLI
104
+
105
+ Usage:
106
+ pi-linear-tools <command> [options]
107
+
108
+ Commands:
109
+ help Show this help message
110
+ config Show current configuration
111
+ config --api-key <key> Set Linear API key
112
+ config --default-team <key> Set default team
113
+ issue <action> [options] Manage issues
114
+ project <action> [options] Manage projects
115
+ team <action> [options] Manage teams
116
+ milestone <action> [options] Manage milestones
117
+
118
+ Issue Actions:
119
+ list [--project X] [--states X,Y] [--assignee me|all] [--limit N]
120
+ view <issue> [--no-comments]
121
+ create --title X [--team X] [--project X] [--description X] [--priority 0-4] [--assignee me|ID]
122
+ update <issue> [--title X] [--description X] [--state X] [--priority 0-4]
123
+ [--assignee me|ID] [--milestone X] [--sub-issue-of X]
124
+ comment <issue> --body X
125
+ start <issue> [--branch X] [--from-ref X] [--on-branch-exists switch|suffix]
126
+ delete <issue>
127
+
128
+ Project Actions:
129
+ list
130
+
131
+ Team Actions:
132
+ list
133
+
134
+ Milestone Actions:
135
+ list [--project X]
136
+ view <milestone-id>
137
+ create --project X --name X [--description X] [--target-date YYYY-MM-DD] [--status X]
138
+ update <milestone-id> [--name X] [--description X] [--target-date X] [--status X]
139
+ delete <milestone-id>
140
+
141
+ Common Flags:
142
+ --project Project name or ID
143
+ --team Team key (e.g., ENG)
144
+ --assignee "me" or assignee ID
145
+ --priority Priority 0-4 (0=None, 1=Urgent, 2=High, 3=Medium, 4=Low)
146
+ --state State name or ID
147
+ --limit Max results (default: 50)
148
+
149
+ Examples:
150
+ pi-linear-tools issue list --project MyProject --states "In Progress,Backlog"
151
+ pi-linear-tools issue view ENG-123
152
+ pi-linear-tools issue create --title "Fix bug" --team ENG --priority 2
153
+ pi-linear-tools issue update ENG-123 --state "In Progress" --assignee me
154
+ pi-linear-tools issue start ENG-123
155
+ pi-linear-tools milestone list --project MyProject
156
+ pi-linear-tools config --api-key lin_xxx
157
+ `);
158
+ }
159
+
160
+ function printIssueHelp() {
161
+ console.log(`pi-linear-tools issue - Manage Linear issues
162
+
163
+ Usage:
164
+ pi-linear-tools issue <action> [options]
165
+
166
+ Actions:
167
+ list List issues in a project
168
+ view View issue details
169
+ create Create a new issue
170
+ update Update an existing issue
171
+ comment Add a comment to an issue
172
+ start Start working on an issue (create branch, set In Progress)
173
+ delete Delete an issue
174
+
175
+ List Options:
176
+ --project X Project name or ID (default: current directory name)
177
+ --states X,Y Filter by state names (comma-separated)
178
+ --assignee X Filter by assignee: "me" or "all"
179
+ --limit N Max results (default: 50)
180
+
181
+ View Options:
182
+ <issue> Issue key (e.g., ENG-123) or ID
183
+ --no-comments Exclude comments from output
184
+
185
+ Create Options:
186
+ --title X Issue title (required)
187
+ --team X Team key, e.g., ENG (required if no default team)
188
+ --project X Project name or ID
189
+ --description X Issue description (markdown)
190
+ --priority N Priority 0-4
191
+ --assignee X "me" or assignee ID
192
+ --parent-id X Parent issue ID for sub-issues
193
+
194
+ Update Options:
195
+ <issue> Issue key or ID
196
+ --title X New title
197
+ --description X New description
198
+ --state X New state name or ID
199
+ --priority N New priority 0-4
200
+ --assignee X "me" or assignee ID
201
+ --milestone X Milestone name/ID, or "none" to clear
202
+ --sub-issue-of X Parent issue key/ID, or "none" to clear
203
+
204
+ Comment Options:
205
+ <issue> Issue key or ID
206
+ --body X Comment body (markdown)
207
+
208
+ Start Options:
209
+ <issue> Issue key or ID
210
+ --branch X Custom branch name (default: issue's branch name)
211
+ --from-ref X Git ref to branch from (default: HEAD)
212
+ --on-branch-exists X "switch" or "suffix" (default: switch)
213
+
214
+ Delete Options:
215
+ <issue> Issue key or ID
216
+ `);
217
+ }
218
+
219
+ function printProjectHelp() {
220
+ console.log(`pi-linear-tools project - Manage Linear projects
221
+
222
+ Usage:
223
+ pi-linear-tools project <action>
224
+
225
+ Actions:
226
+ list List all accessible projects
227
+ `);
228
+ }
229
+
230
+ function printTeamHelp() {
231
+ console.log(`pi-linear-tools team - Manage Linear teams
232
+
233
+ Usage:
234
+ pi-linear-tools team <action>
235
+
236
+ Actions:
237
+ list List all accessible teams
238
+ `);
239
+ }
240
+
241
+ function printMilestoneHelp() {
242
+ console.log(`pi-linear-tools milestone - Manage Linear project milestones
243
+
244
+ Usage:
245
+ pi-linear-tools milestone <action> [options]
246
+
247
+ Actions:
248
+ list List milestones in a project
249
+ view View milestone details
250
+ create Create a new milestone
251
+ update Update an existing milestone
252
+ delete Delete a milestone
253
+
254
+ List Options:
255
+ --project X Project name or ID (default: current directory name)
256
+
257
+ View Options:
258
+ <milestone-id> Milestone ID
259
+
260
+ Create Options:
261
+ --project X Project name or ID (required)
262
+ --name X Milestone name (required)
263
+ --description X Milestone description
264
+ --target-date X Target date (YYYY-MM-DD)
265
+ --status X Status: backlogged, planned, inProgress, paused, completed, cancelled
266
+
267
+ Update Options:
268
+ <milestone-id> Milestone ID
269
+ --name X New name
270
+ --description X New description
271
+ --target-date X New target date
272
+ --status X New status
273
+
274
+ Delete Options:
275
+ <milestone-id> Milestone ID
276
+ `);
277
+ }
278
+
279
+ // ===== CONFIG HANDLER =====
280
+
281
+ async function tryResolveProjectId(projectRef, explicitApiKey = null) {
282
+ const envKey = process.env.LINEAR_API_KEY;
283
+ const apiKey = explicitApiKey || (envKey && envKey.trim() ? envKey.trim() : null);
284
+
285
+ if (!apiKey) {
286
+ return projectRef;
287
+ }
288
+
289
+ try {
290
+ const client = createLinearClient(apiKey);
291
+ const resolved = await resolveProjectRef(client, projectRef);
292
+ return resolved.id;
293
+ } catch {
294
+ return projectRef;
295
+ }
296
+ }
297
+
298
+ async function handleConfig(args) {
299
+ const apiKey = readFlag(args, '--api-key');
300
+ const defaultTeam = readFlag(args, '--default-team');
301
+ const projectTeam = readFlag(args, '--team');
302
+ const projectName = readFlag(args, '--project');
303
+
304
+ if (apiKey) {
305
+ const settings = await loadSettings();
306
+ settings.linearApiKey = apiKey;
307
+ await saveSettings(settings);
308
+ cachedApiKey = null;
309
+ console.log('LINEAR_API_KEY saved to settings');
310
+ return;
311
+ }
312
+
313
+ if (defaultTeam) {
314
+ const settings = await loadSettings();
315
+ settings.defaultTeam = defaultTeam;
316
+ await saveSettings(settings);
317
+ console.log(`Default team set to: ${defaultTeam}`);
318
+ return;
319
+ }
320
+
321
+ if (projectTeam) {
322
+ if (!projectName) {
323
+ throw new Error('Missing required flag: --project when using --team');
324
+ }
325
+
326
+ const settings = await loadSettings();
327
+ const projectId = await tryResolveProjectId(projectName, settings.linearApiKey);
328
+
329
+ if (!settings.projects[projectId]) {
330
+ settings.projects[projectId] = { scope: { team: null } };
331
+ }
332
+
333
+ if (!settings.projects[projectId].scope) {
334
+ settings.projects[projectId].scope = { team: null };
335
+ }
336
+
337
+ settings.projects[projectId].scope.team = projectTeam;
338
+ await saveSettings(settings);
339
+ console.log(`Team for project "${projectName}" set to: ${projectTeam}`);
340
+ return;
341
+ }
342
+
343
+ const settings = await loadSettings();
344
+ const hasKey = !!(settings.linearApiKey || process.env.LINEAR_API_KEY);
345
+ const keySource = process.env.LINEAR_API_KEY ? 'environment' : (settings.linearApiKey ? 'settings' : 'not set');
346
+
347
+ console.log(`Configuration:
348
+ LINEAR_API_KEY: ${hasKey ? 'configured' : 'not set'} (source: ${keySource})
349
+ Default team: ${settings.defaultTeam || 'not set'}
350
+ Project team mappings: ${Object.keys(settings.projects || {}).length}
351
+
352
+ Commands:
353
+ pi-linear-tools config --api-key lin_xxx
354
+ pi-linear-tools config --default-team ENG
355
+ pi-linear-tools config --team ENG --project MyProject`);
356
+ }
357
+
358
+ // ===== ISSUE HANDLERS =====
359
+
360
+ async function handleIssueList(args) {
361
+ const apiKey = await getLinearApiKey();
362
+ const client = createLinearClient(apiKey);
363
+
364
+ const params = {
365
+ project: readFlag(args, '--project'),
366
+ states: parseArrayValue(readFlag(args, '--states')),
367
+ assignee: readFlag(args, '--assignee'),
368
+ limit: parseNumber(readFlag(args, '--limit')),
369
+ };
370
+
371
+ const result = await executeIssueList(client, params);
372
+ console.log(result.content[0].text);
373
+ }
374
+
375
+ async function handleIssueView(args) {
376
+ const apiKey = await getLinearApiKey();
377
+ const client = createLinearClient(apiKey);
378
+
379
+ const positional = args.filter((a) => !a.startsWith('-'));
380
+ if (positional.length === 0) {
381
+ throw new Error('Missing required argument: issue key or ID');
382
+ }
383
+
384
+ const params = {
385
+ issue: positional[0],
386
+ includeComments: !hasFlag(args, '--no-comments'),
387
+ };
388
+
389
+ const result = await executeIssueView(client, params);
390
+ console.log(result.content[0].text);
391
+ }
392
+
393
+ async function handleIssueCreate(args) {
394
+ const apiKey = await getLinearApiKey();
395
+ const client = createLinearClient(apiKey);
396
+
397
+ const params = {
398
+ title: readFlag(args, '--title'),
399
+ team: readFlag(args, '--team'),
400
+ project: readFlag(args, '--project'),
401
+ description: readFlag(args, '--description'),
402
+ priority: parseNumber(readFlag(args, '--priority')),
403
+ assignee: readFlag(args, '--assignee'),
404
+ parentId: readFlag(args, '--parent-id'),
405
+ state: readFlag(args, '--state'),
406
+ };
407
+
408
+ if (!params.title) {
409
+ throw new Error('Missing required flag: --title');
410
+ }
411
+
412
+ const result = await executeIssueCreate(client, params, { resolveDefaultTeam });
413
+ console.log(result.content[0].text);
414
+ }
415
+
416
+ async function handleIssueUpdate(args) {
417
+ const apiKey = await getLinearApiKey();
418
+ const client = createLinearClient(apiKey);
419
+
420
+ const positional = args.filter((a) => !a.startsWith('-'));
421
+ if (positional.length === 0) {
422
+ throw new Error('Missing required argument: issue key or ID');
423
+ }
424
+
425
+ const params = {
426
+ issue: positional[0],
427
+ title: readFlag(args, '--title'),
428
+ description: readFlag(args, '--description'),
429
+ state: readFlag(args, '--state'),
430
+ priority: parseNumber(readFlag(args, '--priority')),
431
+ assignee: readFlag(args, '--assignee'),
432
+ milestone: readFlag(args, '--milestone'),
433
+ subIssueOf: readFlag(args, '--sub-issue-of'),
434
+ };
435
+
436
+ const result = await executeIssueUpdate(client, params);
437
+ console.log(result.content[0].text);
438
+ }
439
+
440
+ async function handleIssueComment(args) {
441
+ const apiKey = await getLinearApiKey();
442
+ const client = createLinearClient(apiKey);
443
+
444
+ const positional = args.filter((a) => !a.startsWith('-'));
445
+ if (positional.length === 0) {
446
+ throw new Error('Missing required argument: issue key or ID');
447
+ }
448
+
449
+ const params = {
450
+ issue: positional[0],
451
+ body: readFlag(args, '--body'),
452
+ };
453
+
454
+ if (!params.body) {
455
+ throw new Error('Missing required flag: --body');
456
+ }
457
+
458
+ const result = await executeIssueComment(client, params);
459
+ console.log(result.content[0].text);
460
+ }
461
+
462
+ async function handleIssueStart(args) {
463
+ const apiKey = await getLinearApiKey();
464
+ const client = createLinearClient(apiKey);
465
+
466
+ const positional = args.filter((a) => !a.startsWith('-'));
467
+ if (positional.length === 0) {
468
+ throw new Error('Missing required argument: issue key or ID');
469
+ }
470
+
471
+ const params = {
472
+ issue: positional[0],
473
+ branch: readFlag(args, '--branch'),
474
+ fromRef: readFlag(args, '--from-ref'),
475
+ onBranchExists: readFlag(args, '--on-branch-exists'),
476
+ };
477
+
478
+ const result = await executeIssueStart(client, params);
479
+ console.log(result.content[0].text);
480
+ }
481
+
482
+ async function handleIssueDelete(args) {
483
+ const apiKey = await getLinearApiKey();
484
+ const client = createLinearClient(apiKey);
485
+
486
+ const positional = args.filter((a) => !a.startsWith('-'));
487
+ if (positional.length === 0) {
488
+ throw new Error('Missing required argument: issue key or ID');
489
+ }
490
+
491
+ const params = {
492
+ issue: positional[0],
493
+ };
494
+
495
+ const result = await executeIssueDelete(client, params);
496
+ console.log(result.content[0].text);
497
+ }
498
+
499
+ async function handleIssue(args) {
500
+ const [action, ...rest] = args;
501
+
502
+ if (!action || action === '--help' || action === '-h') {
503
+ printIssueHelp();
504
+ return;
505
+ }
506
+
507
+ switch (action) {
508
+ case 'list':
509
+ return handleIssueList(rest);
510
+ case 'view':
511
+ return handleIssueView(rest);
512
+ case 'create':
513
+ return handleIssueCreate(rest);
514
+ case 'update':
515
+ return handleIssueUpdate(rest);
516
+ case 'comment':
517
+ return handleIssueComment(rest);
518
+ case 'start':
519
+ return handleIssueStart(rest);
520
+ case 'delete':
521
+ return handleIssueDelete(rest);
522
+ default:
523
+ throw new Error(`Unknown issue action: ${action}`);
524
+ }
525
+ }
526
+
527
+ // ===== PROJECT HANDLERS =====
528
+
529
+ async function handleProjectList() {
530
+ const apiKey = await getLinearApiKey();
531
+ const client = createLinearClient(apiKey);
532
+
533
+ const result = await executeProjectList(client);
534
+ console.log(result.content[0].text);
535
+ }
536
+
537
+ async function handleProject(args) {
538
+ const [action] = args;
539
+
540
+ if (!action || action === '--help' || action === '-h') {
541
+ printProjectHelp();
542
+ return;
543
+ }
544
+
545
+ switch (action) {
546
+ case 'list':
547
+ return handleProjectList();
548
+ default:
549
+ throw new Error(`Unknown project action: ${action}`);
550
+ }
551
+ }
552
+
553
+ // ===== TEAM HANDLERS =====
554
+
555
+ async function handleTeamList() {
556
+ const apiKey = await getLinearApiKey();
557
+ const client = createLinearClient(apiKey);
558
+
559
+ const result = await executeTeamList(client);
560
+ console.log(result.content[0].text);
561
+ }
562
+
563
+ async function handleTeam(args) {
564
+ const [action] = args;
565
+
566
+ if (!action || action === '--help' || action === '-h') {
567
+ printTeamHelp();
568
+ return;
569
+ }
570
+
571
+ switch (action) {
572
+ case 'list':
573
+ return handleTeamList();
574
+ default:
575
+ throw new Error(`Unknown team action: ${action}`);
576
+ }
577
+ }
578
+
579
+ // ===== MILESTONE HANDLERS =====
580
+
581
+ async function handleMilestoneList(args) {
582
+ const apiKey = await getLinearApiKey();
583
+ const client = createLinearClient(apiKey);
584
+
585
+ const params = {
586
+ project: readFlag(args, '--project'),
587
+ };
588
+
589
+ const result = await executeMilestoneList(client, params);
590
+ console.log(result.content[0].text);
591
+ }
592
+
593
+ async function handleMilestoneView(args) {
594
+ const apiKey = await getLinearApiKey();
595
+ const client = createLinearClient(apiKey);
596
+
597
+ const positional = args.filter((a) => !a.startsWith('-'));
598
+ if (positional.length === 0) {
599
+ throw new Error('Missing required argument: milestone ID');
600
+ }
601
+
602
+ const params = {
603
+ milestone: positional[0],
604
+ };
605
+
606
+ const result = await executeMilestoneView(client, params);
607
+ console.log(result.content[0].text);
608
+ }
609
+
610
+ async function handleMilestoneCreate(args) {
611
+ const apiKey = await getLinearApiKey();
612
+ const client = createLinearClient(apiKey);
613
+
614
+ const params = {
615
+ project: readFlag(args, '--project'),
616
+ name: readFlag(args, '--name'),
617
+ description: readFlag(args, '--description'),
618
+ targetDate: readFlag(args, '--target-date'),
619
+ status: readFlag(args, '--status'),
620
+ };
621
+
622
+ if (!params.name) {
623
+ throw new Error('Missing required flag: --name');
624
+ }
625
+
626
+ const result = await executeMilestoneCreate(client, params);
627
+ console.log(result.content[0].text);
628
+ }
629
+
630
+ async function handleMilestoneUpdate(args) {
631
+ const apiKey = await getLinearApiKey();
632
+ const client = createLinearClient(apiKey);
633
+
634
+ const positional = args.filter((a) => !a.startsWith('-'));
635
+ if (positional.length === 0) {
636
+ throw new Error('Missing required argument: milestone ID');
637
+ }
638
+
639
+ const params = {
640
+ milestone: positional[0],
641
+ name: readFlag(args, '--name'),
642
+ description: readFlag(args, '--description'),
643
+ targetDate: readFlag(args, '--target-date'),
644
+ status: readFlag(args, '--status'),
645
+ };
646
+
647
+ const result = await executeMilestoneUpdate(client, params);
648
+ console.log(result.content[0].text);
649
+ }
650
+
651
+ async function handleMilestoneDelete(args) {
652
+ const apiKey = await getLinearApiKey();
653
+ const client = createLinearClient(apiKey);
654
+
655
+ const positional = args.filter((a) => !a.startsWith('-'));
656
+ if (positional.length === 0) {
657
+ throw new Error('Missing required argument: milestone ID');
658
+ }
659
+
660
+ const params = {
661
+ milestone: positional[0],
662
+ };
663
+
664
+ const result = await executeMilestoneDelete(client, params);
665
+ console.log(result.content[0].text);
666
+ }
667
+
668
+ async function handleMilestone(args) {
669
+ const [action, ...rest] = args;
670
+
671
+ if (!action || action === '--help' || action === '-h') {
672
+ printMilestoneHelp();
673
+ return;
674
+ }
675
+
676
+ switch (action) {
677
+ case 'list':
678
+ return handleMilestoneList(rest);
679
+ case 'view':
680
+ return handleMilestoneView(rest);
681
+ case 'create':
682
+ return handleMilestoneCreate(rest);
683
+ case 'update':
684
+ return handleMilestoneUpdate(rest);
685
+ case 'delete':
686
+ return handleMilestoneDelete(rest);
687
+ default:
688
+ throw new Error(`Unknown milestone action: ${action}`);
689
+ }
690
+ }
691
+
692
+ // ===== MAIN CLI ENTRY =====
693
+
694
+ export async function runCli(argv = process.argv.slice(2)) {
695
+ const [command, ...rest] = argv;
696
+
697
+ if (!command || command === '--help' || command === '-h' || command === 'help') {
698
+ printHelp();
699
+ return;
700
+ }
701
+
702
+ if (command === 'config') {
703
+ await handleConfig(rest);
704
+ return;
705
+ }
706
+
707
+ if (command === 'issue') {
708
+ await handleIssue(rest);
709
+ return;
710
+ }
711
+
712
+ if (command === 'project') {
713
+ await handleProject(rest);
714
+ return;
715
+ }
716
+
717
+ if (command === 'team') {
718
+ await handleTeam(rest);
719
+ return;
720
+ }
721
+
722
+ if (command === 'milestone') {
723
+ await handleMilestone(rest);
724
+ return;
725
+ }
726
+
727
+ printHelp();
728
+ process.exitCode = 1;
729
+ }