@dabble/linear-cli 1.0.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/bin/linear.mjs ADDED
@@ -0,0 +1,2068 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync, statSync } from 'fs';
4
+ import { homedir } from 'os';
5
+ import { join, basename } from 'path';
6
+ import { createInterface } from 'readline';
7
+ import { exec, execSync } from 'child_process';
8
+
9
+ // ============================================================================
10
+ // CONFIG
11
+ // ============================================================================
12
+
13
+ const API_URL = 'https://api.linear.app/graphql';
14
+ let CONFIG_FILE = '';
15
+ let LINEAR_API_KEY = '';
16
+ let TEAM_KEY = '';
17
+
18
+ // Colors (ANSI)
19
+ const colors = {
20
+ red: s => `\x1b[31m${s}\x1b[0m`,
21
+ green: s => `\x1b[32m${s}\x1b[0m`,
22
+ yellow: s => `\x1b[33m${s}\x1b[0m`,
23
+ blue: s => `\x1b[34m${s}\x1b[0m`,
24
+ gray: s => `\x1b[90m${s}\x1b[0m`,
25
+ bold: s => `\x1b[1m${s}\x1b[0m`,
26
+ };
27
+
28
+ // ============================================================================
29
+ // UTILITIES
30
+ // ============================================================================
31
+
32
+ function loadConfig() {
33
+ const localPath = join(process.cwd(), '.linear');
34
+ const globalPath = join(homedir(), '.linear');
35
+
36
+ // Priority: ./.linear > ~/.linear > env vars
37
+ if (existsSync(localPath)) {
38
+ CONFIG_FILE = localPath;
39
+ } else if (existsSync(globalPath)) {
40
+ CONFIG_FILE = globalPath;
41
+ }
42
+
43
+ // Load from config file first (highest priority)
44
+ if (CONFIG_FILE) {
45
+ const content = readFileSync(CONFIG_FILE, 'utf-8');
46
+ for (const line of content.split('\n')) {
47
+ if (!line || line.startsWith('#')) continue;
48
+ const [key, ...rest] = line.split('=');
49
+ const value = rest.join('=').trim();
50
+ if (key === 'api_key') LINEAR_API_KEY = value;
51
+ if (key === 'team') TEAM_KEY = value;
52
+ }
53
+ }
54
+
55
+ // Fall back to env vars if not set by config file
56
+ if (!LINEAR_API_KEY) LINEAR_API_KEY = process.env.LINEAR_API_KEY || '';
57
+ if (!TEAM_KEY) TEAM_KEY = process.env.LINEAR_TEAM || '';
58
+
59
+ if (!LINEAR_API_KEY || !TEAM_KEY) {
60
+ console.error(colors.red("Error: No config file found or missing API key or team key. Run 'linear login' first."));
61
+ process.exit(1);
62
+ }
63
+ }
64
+
65
+ function checkAuth() {
66
+ if (!LINEAR_API_KEY) {
67
+ console.error(colors.red("Error: Not logged in. Run 'linear login' first."));
68
+ process.exit(1);
69
+ }
70
+ }
71
+
72
+ async function gql(query, variables = {}) {
73
+ let response;
74
+ try {
75
+ response = await fetch(API_URL, {
76
+ method: 'POST',
77
+ headers: {
78
+ 'Content-Type': 'application/json',
79
+ 'Authorization': LINEAR_API_KEY,
80
+ },
81
+ body: JSON.stringify({ query, variables }),
82
+ });
83
+ } catch (err) {
84
+ console.error(colors.red(`Network error: ${err.message}`));
85
+ process.exit(1);
86
+ }
87
+
88
+ if (!response.ok) {
89
+ console.error(colors.red(`HTTP error: ${response.status} ${response.statusText}`));
90
+ process.exit(1);
91
+ }
92
+
93
+ const json = await response.json();
94
+
95
+ if (json.errors?.length) {
96
+ console.error(colors.red(`API error: ${json.errors[0].message}`));
97
+ process.exit(1);
98
+ }
99
+
100
+ return json;
101
+ }
102
+
103
+ function prompt(question) {
104
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
105
+ return new Promise(resolve => {
106
+ rl.question(question, answer => {
107
+ rl.close();
108
+ resolve(answer);
109
+ });
110
+ });
111
+ }
112
+
113
+ function suggestTeamKey(teamName) {
114
+ // Generate acronym from first letter of each word
115
+ const words = teamName.trim().split(/\s+/);
116
+ let key = words.map(w => w[0] || '').join('').toUpperCase();
117
+
118
+ // If single word or very short, use first 3-4 chars instead
119
+ if (key.length < 2) {
120
+ key = teamName.trim().slice(0, 4).toUpperCase().replace(/\s+/g, '');
121
+ }
122
+
123
+ // Ensure it's at least 2 chars and max 5
124
+ return key.slice(0, 5) || 'TEAM';
125
+ }
126
+
127
+ function openBrowser(url) {
128
+ let cmd;
129
+ if (process.platform === 'darwin') {
130
+ cmd = `open "${url}"`;
131
+ } else if (process.platform === 'win32') {
132
+ // start requires cmd /c, and first quoted arg is window title (so pass empty)
133
+ cmd = `cmd /c start "" "${url}"`;
134
+ } else {
135
+ cmd = `xdg-open "${url}"`;
136
+ }
137
+ exec(cmd);
138
+ }
139
+
140
+ function formatTable(rows) {
141
+ if (rows.length === 0) return '';
142
+ const colWidths = [];
143
+ for (const row of rows) {
144
+ row.forEach((cell, i) => {
145
+ colWidths[i] = Math.max(colWidths[i] || 0, String(cell).length);
146
+ });
147
+ }
148
+ return rows.map(row =>
149
+ row.map((cell, i) => String(cell).padEnd(colWidths[i])).join(' ')
150
+ ).join('\n');
151
+ }
152
+
153
+ function parseArgs(args, flags = {}) {
154
+ const result = { _: [] };
155
+ let i = 0;
156
+ while (i < args.length) {
157
+ const arg = args[i];
158
+ if (arg.startsWith('--') || arg.startsWith('-')) {
159
+ const key = arg.replace(/^-+/, '');
160
+ const flagDef = flags[key];
161
+ if (flagDef === 'boolean') {
162
+ result[key] = true;
163
+ } else {
164
+ const value = args[++i];
165
+ if (value === undefined || value.startsWith('-')) {
166
+ console.error(colors.red(`Error: --${key} requires a value`));
167
+ process.exit(1);
168
+ }
169
+ result[key] = value;
170
+ }
171
+ } else {
172
+ result._.push(arg);
173
+ }
174
+ i++;
175
+ }
176
+ return result;
177
+ }
178
+
179
+ // ============================================================================
180
+ // ISSUES
181
+ // ============================================================================
182
+
183
+ async function cmdIssues(args) {
184
+ const opts = parseArgs(args, {
185
+ unblocked: 'boolean', u: 'boolean',
186
+ all: 'boolean', a: 'boolean',
187
+ open: 'boolean', o: 'boolean',
188
+ mine: 'boolean', m: 'boolean',
189
+ 'in-progress': 'boolean',
190
+ project: 'string', p: 'string',
191
+ state: 'string', s: 'string',
192
+ label: 'string', l: 'string',
193
+ });
194
+
195
+ const inProgress = opts['in-progress'];
196
+ const unblocked = opts.unblocked || opts.u;
197
+ const allStates = opts.all || opts.a;
198
+ const openOnly = opts.open || opts.o;
199
+ const mineOnly = opts.mine || opts.m;
200
+ const stateFilter = inProgress ? 'started' : (opts.state || opts.s || 'backlog');
201
+ const labelFilter = opts.label || opts.l;
202
+
203
+ // Get current user ID for filtering/sorting
204
+ const viewerResult = await gql('{ viewer { id } }');
205
+ const viewerId = viewerResult.data?.viewer?.id;
206
+
207
+ const query = `{
208
+ team(id: "${TEAM_KEY}") {
209
+ issues(first: 100) {
210
+ nodes {
211
+ identifier
212
+ title
213
+ priority
214
+ state { name type }
215
+ project { name }
216
+ assignee { id name }
217
+ labels { nodes { name } }
218
+ relations(first: 20) {
219
+ nodes {
220
+ type
221
+ relatedIssue { identifier state { type } }
222
+ }
223
+ }
224
+ }
225
+ }
226
+ }
227
+ }`;
228
+
229
+ const result = await gql(query);
230
+ let issues = result.data?.team?.issues?.nodes || [];
231
+
232
+ // Check if any issues have assignees (to decide whether to show column)
233
+ const hasAssignees = issues.some(i => i.assignee);
234
+
235
+ // Sort: assigned to you first, then by identifier
236
+ issues.sort((a, b) => {
237
+ const aIsMine = a.assignee?.id === viewerId;
238
+ const bIsMine = b.assignee?.id === viewerId;
239
+ if (aIsMine && !bIsMine) return -1;
240
+ if (!aIsMine && bIsMine) return 1;
241
+ return a.identifier.localeCompare(b.identifier);
242
+ });
243
+
244
+ // Helper to format issue row
245
+ const formatRow = (i) => {
246
+ const row = [
247
+ i.identifier,
248
+ i.title,
249
+ i.state.name,
250
+ i.project?.name || '-'
251
+ ];
252
+ if (hasAssignees) {
253
+ const assignee = i.assignee?.id === viewerId ? 'you' : (i.assignee?.name || '-');
254
+ row.push(assignee);
255
+ }
256
+ return row;
257
+ };
258
+
259
+ // Helper to apply common filters (mine, label)
260
+ const applyFilters = (list) => {
261
+ let filtered = list;
262
+ if (mineOnly) {
263
+ filtered = filtered.filter(i => i.assignee?.id === viewerId);
264
+ }
265
+ if (labelFilter) {
266
+ filtered = filtered.filter(i =>
267
+ i.labels?.nodes?.some(l => l.name.toLowerCase() === labelFilter.toLowerCase())
268
+ );
269
+ }
270
+ return filtered;
271
+ };
272
+
273
+ if (unblocked) {
274
+ // Collect all blocked issue IDs
275
+ const blocked = new Set();
276
+ for (const issue of issues) {
277
+ for (const rel of issue.relations?.nodes || []) {
278
+ if (rel.type === 'blocks') {
279
+ blocked.add(rel.relatedIssue.identifier);
280
+ }
281
+ }
282
+ }
283
+
284
+ // Filter to unblocked, non-completed issues
285
+ let filtered = issues.filter(i =>
286
+ !['completed', 'canceled'].includes(i.state.type) &&
287
+ !blocked.has(i.identifier)
288
+ );
289
+
290
+ filtered = applyFilters(filtered);
291
+
292
+ console.log(colors.bold('Unblocked Issues:\n'));
293
+ console.log(formatTable(filtered.map(formatRow)));
294
+ } else if (allStates) {
295
+ let filtered = applyFilters(issues);
296
+
297
+ console.log(colors.bold('All Issues:\n'));
298
+ console.log(formatTable(filtered.map(formatRow)));
299
+ } else if (openOnly) {
300
+ // Open = everything except completed/canceled
301
+ let filtered = issues.filter(i =>
302
+ !['completed', 'canceled'].includes(i.state.type)
303
+ );
304
+
305
+ filtered = applyFilters(filtered);
306
+
307
+ console.log(colors.bold('Open Issues:\n'));
308
+ console.log(formatTable(filtered.map(formatRow)));
309
+ } else {
310
+ let filtered = issues.filter(i =>
311
+ i.state.type === stateFilter || i.state.name.toLowerCase() === stateFilter.toLowerCase()
312
+ );
313
+
314
+ filtered = applyFilters(filtered);
315
+
316
+ console.log(colors.bold(`Issues (${stateFilter}):\n`));
317
+ console.log(formatTable(filtered.map(formatRow)));
318
+ }
319
+ }
320
+
321
+ async function cmdIssueShow(args) {
322
+ const issueId = args[0];
323
+ if (!issueId) {
324
+ console.error(colors.red('Error: Issue ID required'));
325
+ process.exit(1);
326
+ }
327
+
328
+ // Enhanced query to get parent context with siblings
329
+ const query = `{
330
+ issue(id: "${issueId}") {
331
+ identifier
332
+ title
333
+ description
334
+ state { name }
335
+ priority
336
+ project { name }
337
+ labels { nodes { name } }
338
+ assignee { name }
339
+ parent {
340
+ identifier
341
+ title
342
+ description
343
+ children { nodes { identifier title state { name } } }
344
+ parent {
345
+ identifier
346
+ title
347
+ children { nodes { identifier title state { name } } }
348
+ parent {
349
+ identifier
350
+ title
351
+ }
352
+ }
353
+ }
354
+ children { nodes { identifier title state { name } } }
355
+ relations(first: 20) {
356
+ nodes {
357
+ type
358
+ relatedIssue { identifier title state { name } }
359
+ }
360
+ }
361
+ comments { nodes { body createdAt user { name } } }
362
+ }
363
+ }`;
364
+
365
+ const result = await gql(query);
366
+ const issue = result.data?.issue;
367
+
368
+ if (!issue) {
369
+ console.error(colors.red(`Issue not found: ${issueId}`));
370
+ process.exit(1);
371
+ }
372
+
373
+ console.log(`# ${issue.identifier}: ${issue.title}\n`);
374
+ console.log(`State: ${issue.state.name}`);
375
+ console.log(`Priority: ${issue.priority || 'None'}`);
376
+ console.log(`Project: ${issue.project?.name || 'None'}`);
377
+ console.log(`Assignee: ${issue.assignee?.name || 'Unassigned'}`);
378
+ console.log(`Labels: ${issue.labels.nodes.map(l => l.name).join(', ') || 'None'}`);
379
+
380
+ // Show parent context with siblings (where you are in the larger work)
381
+ if (issue.parent) {
382
+ console.log('\n## Context\n');
383
+
384
+ // Build parent chain (walk up)
385
+ const parentChain = [];
386
+ let current = issue.parent;
387
+ while (current) {
388
+ parentChain.unshift(current);
389
+ current = current.parent;
390
+ }
391
+
392
+ // Show parent chain
393
+ for (let i = 0; i < parentChain.length; i++) {
394
+ const parent = parentChain[i];
395
+ const indent = ' '.repeat(i);
396
+ console.log(`${indent}${colors.bold(parent.identifier)}: ${parent.title}`);
397
+
398
+ // Show siblings at each level (children of this parent)
399
+ const siblings = parent.children?.nodes || [];
400
+ for (const sibling of siblings) {
401
+ const sibIndent = ' '.repeat(i + 1);
402
+ const isCurrent = sibling.identifier === issue.identifier;
403
+ const isDirectParent = i === parentChain.length - 1;
404
+
405
+ if (isCurrent && isDirectParent) {
406
+ // This is the current issue - highlight it
407
+ console.log(`${sibIndent}${colors.green('→')} [${sibling.state.name}] ${colors.green(sibling.identifier)}: ${sibling.title} ${colors.green('← you are here')}`);
408
+ } else {
409
+ console.log(`${sibIndent}- [${sibling.state.name}] ${sibling.identifier}: ${sibling.title}`);
410
+ }
411
+ }
412
+ }
413
+
414
+ // Show parent description if available (most immediate parent)
415
+ const immediateParent = parentChain[parentChain.length - 1];
416
+ if (immediateParent.description) {
417
+ console.log(`\n### Parent Description (${immediateParent.identifier})\n`);
418
+ // Show truncated description
419
+ const desc = immediateParent.description;
420
+ const truncated = desc.length > 500 ? desc.slice(0, 500) + '...' : desc;
421
+ console.log(colors.gray(truncated));
422
+ }
423
+ }
424
+
425
+ if (issue.children.nodes.length > 0) {
426
+ console.log('\n## Sub-issues\n');
427
+ for (const child of issue.children.nodes) {
428
+ console.log(` - [${child.state.name}] ${child.identifier}: ${child.title}`);
429
+ }
430
+ }
431
+
432
+ const blockedBy = issue.relations.nodes.filter(r => r.type === 'is_blocked_by');
433
+ if (blockedBy.length > 0) {
434
+ console.log('\n## Blocked by\n');
435
+ for (const rel of blockedBy) {
436
+ console.log(` - ${rel.relatedIssue.identifier}: ${rel.relatedIssue.title}`);
437
+ }
438
+ }
439
+
440
+ const blocks = issue.relations.nodes.filter(r => r.type === 'blocks');
441
+ if (blocks.length > 0) {
442
+ console.log('\n## Blocks\n');
443
+ for (const rel of blocks) {
444
+ console.log(` - ${rel.relatedIssue.identifier}: ${rel.relatedIssue.title}`);
445
+ }
446
+ }
447
+
448
+ console.log('\n## Description\n');
449
+ console.log(issue.description || 'No description');
450
+
451
+ if (issue.comments.nodes.length > 0) {
452
+ console.log('\n## Comments\n');
453
+ for (const comment of issue.comments.nodes) {
454
+ const date = comment.createdAt.split('T')[0];
455
+ console.log(`**${comment.user.name}** (${date}):`);
456
+ console.log(comment.body);
457
+ console.log('');
458
+ }
459
+ }
460
+ }
461
+
462
+ // T-shirt size to Linear estimate mapping
463
+ const ESTIMATE_MAP = {
464
+ 'xs': 0,
465
+ 's': 1,
466
+ 'm': 2,
467
+ 'l': 3,
468
+ 'xl': 5,
469
+ };
470
+
471
+ async function cmdIssueCreate(args) {
472
+ const opts = parseArgs(args, {
473
+ title: 'string', t: 'string',
474
+ description: 'string', d: 'string',
475
+ project: 'string', p: 'string',
476
+ parent: 'string',
477
+ state: 'string', s: 'string',
478
+ assign: 'boolean',
479
+ estimate: 'string', e: 'string',
480
+ label: 'string', l: 'string',
481
+ blocks: 'string',
482
+ 'blocked-by': 'string',
483
+ });
484
+
485
+ const title = opts.title || opts.t || opts._[0];
486
+ const description = opts.description || opts.d || '';
487
+ const project = opts.project || opts.p;
488
+ const parent = opts.parent;
489
+ const shouldAssign = opts.assign;
490
+ const estimate = (opts.estimate || opts.e || '').toLowerCase();
491
+ const labelName = opts.label || opts.l;
492
+ const blocksIssue = opts.blocks;
493
+ const blockedByIssue = opts['blocked-by'];
494
+
495
+ if (!title) {
496
+ console.error(colors.red('Error: Title is required'));
497
+ console.error('Usage: linear issue create --title "Issue title" [--project "..."] [--parent ISSUE-X] [--estimate M] [--assign] [--label bug] [--blocks ISSUE-X] [--blocked-by ISSUE-X]');
498
+ process.exit(1);
499
+ }
500
+
501
+ // Validate estimate
502
+ if (estimate && !ESTIMATE_MAP.hasOwnProperty(estimate)) {
503
+ console.error(colors.red(`Error: Invalid estimate "${estimate}". Use: XS, S, M, L, or XL`));
504
+ process.exit(1);
505
+ }
506
+
507
+ // Get team UUID (required for mutations)
508
+ const teamResult = await gql(`{ team(id: "${TEAM_KEY}") { id } }`);
509
+ const teamId = teamResult.data?.team?.id;
510
+
511
+ if (!teamId) {
512
+ console.error(colors.red(`Error: Team not found: ${TEAM_KEY}`));
513
+ process.exit(1);
514
+ }
515
+
516
+ // Look up project ID
517
+ let projectId = null;
518
+ if (project) {
519
+ const projectsResult = await gql(`{
520
+ team(id: "${TEAM_KEY}") {
521
+ projects(first: 50) { nodes { id name } }
522
+ }
523
+ }`);
524
+ const projects = projectsResult.data?.team?.projects?.nodes || [];
525
+ const match = projects.find(p => p.name.toLowerCase().includes(project.toLowerCase()));
526
+ if (match) projectId = match.id;
527
+ }
528
+
529
+ // Look up label ID
530
+ let labelIds = [];
531
+ if (labelName) {
532
+ const labelsResult = await gql(`{
533
+ team(id: "${TEAM_KEY}") {
534
+ labels(first: 100) { nodes { id name } }
535
+ }
536
+ }`);
537
+ const labels = labelsResult.data?.team?.labels?.nodes || [];
538
+ const match = labels.find(l => l.name.toLowerCase() === labelName.toLowerCase());
539
+ if (match) {
540
+ labelIds.push(match.id);
541
+ } else {
542
+ console.error(colors.yellow(`Warning: Label "${labelName}" not found. Creating issue without label.`));
543
+ }
544
+ }
545
+
546
+ // Get current user ID if assigning
547
+ let assigneeId = null;
548
+ if (shouldAssign) {
549
+ const viewerResult = await gql('{ viewer { id } }');
550
+ assigneeId = viewerResult.data?.viewer?.id;
551
+ }
552
+
553
+ const mutation = `
554
+ mutation($input: IssueCreateInput!) {
555
+ issueCreate(input: $input) {
556
+ success
557
+ issue { identifier title url estimate }
558
+ }
559
+ }
560
+ `;
561
+
562
+ const input = { teamId, title, description };
563
+ if (projectId) input.projectId = projectId;
564
+ if (parent) input.parentId = parent;
565
+ if (assigneeId) input.assigneeId = assigneeId;
566
+ if (estimate) input.estimate = ESTIMATE_MAP[estimate];
567
+ if (labelIds.length > 0) input.labelIds = labelIds;
568
+
569
+ const result = await gql(mutation, { input });
570
+
571
+ if (result.data?.issueCreate?.success) {
572
+ const issue = result.data.issueCreate.issue;
573
+ const estLabel = estimate ? ` [${estimate.toUpperCase()}]` : '';
574
+ console.log(colors.green(`Created: ${issue.identifier}${estLabel}`));
575
+ console.log(issue.url);
576
+
577
+ // Create blocking relations if specified
578
+ if (blocksIssue || blockedByIssue) {
579
+ const relationMutation = `
580
+ mutation($input: IssueRelationCreateInput!) {
581
+ issueRelationCreate(input: $input) { success }
582
+ }
583
+ `;
584
+
585
+ if (blocksIssue) {
586
+ // This issue blocks another issue
587
+ await gql(relationMutation, {
588
+ input: { issueId: issue.identifier, relatedIssueId: blocksIssue, type: 'blocks' }
589
+ });
590
+ console.log(colors.gray(` → blocks ${blocksIssue}`));
591
+ }
592
+
593
+ if (blockedByIssue) {
594
+ // This issue is blocked by another issue
595
+ await gql(relationMutation, {
596
+ input: { issueId: blockedByIssue, relatedIssueId: issue.identifier, type: 'blocks' }
597
+ });
598
+ console.log(colors.gray(` → blocked by ${blockedByIssue}`));
599
+ }
600
+ }
601
+ } else {
602
+ console.error(colors.red('Failed to create issue'));
603
+ console.error(result.errors?.[0]?.message || JSON.stringify(result));
604
+ process.exit(1);
605
+ }
606
+ }
607
+
608
+ async function cmdIssueUpdate(args) {
609
+ const issueId = args[0];
610
+ if (!issueId) {
611
+ console.error(colors.red('Error: Issue ID required'));
612
+ process.exit(1);
613
+ }
614
+
615
+ const opts = parseArgs(args.slice(1), {
616
+ title: 'string', t: 'string',
617
+ description: 'string', d: 'string',
618
+ state: 'string', s: 'string',
619
+ append: 'string', a: 'string',
620
+ blocks: 'string',
621
+ 'blocked-by': 'string',
622
+ });
623
+
624
+ const blocksIssue = opts.blocks;
625
+ const blockedByIssue = opts['blocked-by'];
626
+ const input = {};
627
+
628
+ if (opts.title || opts.t) input.title = opts.title || opts.t;
629
+
630
+ // Handle append
631
+ if (opts.append || opts.a) {
632
+ const currentResult = await gql(`{ issue(id: "${issueId}") { description } }`);
633
+ const current = currentResult.data?.issue?.description || '';
634
+ input.description = current + '\n\n' + (opts.append || opts.a);
635
+ } else if (opts.description || opts.d) {
636
+ input.description = opts.description || opts.d;
637
+ }
638
+
639
+ // Handle state
640
+ if (opts.state || opts.s) {
641
+ const stateName = opts.state || opts.s;
642
+ const statesResult = await gql(`{
643
+ team(id: "${TEAM_KEY}") {
644
+ states { nodes { id name } }
645
+ }
646
+ }`);
647
+ const states = statesResult.data?.team?.states?.nodes || [];
648
+ const match = states.find(s => s.name.toLowerCase().includes(stateName.toLowerCase()));
649
+ if (match) input.stateId = match.id;
650
+ }
651
+
652
+ // Handle blocking relations (can be set even without other updates)
653
+ const hasRelationUpdates = blocksIssue || blockedByIssue;
654
+
655
+ if (Object.keys(input).length === 0 && !hasRelationUpdates) {
656
+ console.error(colors.red('Error: No updates specified'));
657
+ process.exit(1);
658
+ }
659
+
660
+ // Update issue fields if any
661
+ if (Object.keys(input).length > 0) {
662
+ const mutation = `
663
+ mutation($id: String!, $input: IssueUpdateInput!) {
664
+ issueUpdate(id: $id, input: $input) {
665
+ success
666
+ issue { identifier title state { name } }
667
+ }
668
+ }
669
+ `;
670
+
671
+ const result = await gql(mutation, { id: issueId, input });
672
+
673
+ if (result.data?.issueUpdate?.success) {
674
+ const issue = result.data.issueUpdate.issue;
675
+ console.log(colors.green(`Updated: ${issue.identifier}`));
676
+ console.log(`${issue.identifier}: ${issue.title} [${issue.state.name}]`);
677
+ } else {
678
+ console.error(colors.red('Failed to update issue'));
679
+ console.error(result.errors?.[0]?.message || JSON.stringify(result));
680
+ process.exit(1);
681
+ }
682
+ }
683
+
684
+ // Create blocking relations if specified
685
+ if (hasRelationUpdates) {
686
+ const relationMutation = `
687
+ mutation($input: IssueRelationCreateInput!) {
688
+ issueRelationCreate(input: $input) { success }
689
+ }
690
+ `;
691
+
692
+ if (blocksIssue) {
693
+ await gql(relationMutation, {
694
+ input: { issueId: issueId, relatedIssueId: blocksIssue, type: 'blocks' }
695
+ });
696
+ console.log(colors.green(`${issueId} now blocks ${blocksIssue}`));
697
+ }
698
+
699
+ if (blockedByIssue) {
700
+ await gql(relationMutation, {
701
+ input: { issueId: blockedByIssue, relatedIssueId: issueId, type: 'blocks' }
702
+ });
703
+ console.log(colors.green(`${issueId} now blocked by ${blockedByIssue}`));
704
+ }
705
+ }
706
+ }
707
+
708
+ async function cmdIssueClose(args) {
709
+ const issueId = args[0];
710
+ if (!issueId) {
711
+ console.error(colors.red('Error: Issue ID required'));
712
+ process.exit(1);
713
+ }
714
+
715
+ // Find completed state
716
+ const statesResult = await gql(`{
717
+ team(id: "${TEAM_KEY}") {
718
+ states { nodes { id name type } }
719
+ }
720
+ }`);
721
+ const states = statesResult.data?.team?.states?.nodes || [];
722
+ const doneState = states.find(s => s.type === 'completed');
723
+
724
+ if (!doneState) {
725
+ console.error(colors.red('Error: Could not find completed state'));
726
+ process.exit(1);
727
+ }
728
+
729
+ const mutation = `
730
+ mutation($id: String!, $input: IssueUpdateInput!) {
731
+ issueUpdate(id: $id, input: $input) {
732
+ success
733
+ issue { identifier }
734
+ }
735
+ }
736
+ `;
737
+
738
+ const result = await gql(mutation, { id: issueId, input: { stateId: doneState.id } });
739
+
740
+ if (result.data?.issueUpdate?.success) {
741
+ console.log(colors.green(`Closed: ${issueId}`));
742
+ } else {
743
+ console.error(colors.red('Failed to close issue'));
744
+ console.error(result.errors?.[0]?.message || JSON.stringify(result));
745
+ process.exit(1);
746
+ }
747
+ }
748
+
749
+ async function cmdIssueStart(args) {
750
+ const issueId = args[0];
751
+ if (!issueId) {
752
+ console.error(colors.red('Error: Issue ID required'));
753
+ process.exit(1);
754
+ }
755
+
756
+ // Get current user and "In Progress" state
757
+ const dataResult = await gql(`{
758
+ viewer { id }
759
+ team(id: "${TEAM_KEY}") {
760
+ states { nodes { id name type } }
761
+ }
762
+ }`);
763
+
764
+ const viewerId = dataResult.data?.viewer?.id;
765
+ const states = dataResult.data?.team?.states?.nodes || [];
766
+ const inProgressState = states.find(s => s.type === 'started');
767
+
768
+ if (!inProgressState) {
769
+ console.error(colors.red('Error: Could not find "In Progress" state'));
770
+ process.exit(1);
771
+ }
772
+
773
+ const mutation = `
774
+ mutation($id: String!, $input: IssueUpdateInput!) {
775
+ issueUpdate(id: $id, input: $input) {
776
+ success
777
+ issue { identifier title state { name } }
778
+ }
779
+ }
780
+ `;
781
+
782
+ const result = await gql(mutation, {
783
+ id: issueId,
784
+ input: { stateId: inProgressState.id, assigneeId: viewerId }
785
+ });
786
+
787
+ if (result.data?.issueUpdate?.success) {
788
+ const issue = result.data.issueUpdate.issue;
789
+ console.log(colors.green(`Started: ${issue.identifier}`));
790
+ console.log(`${issue.identifier}: ${issue.title} [${issue.state.name}]`);
791
+ } else {
792
+ console.error(colors.red('Failed to start issue'));
793
+ console.error(result.errors?.[0]?.message || JSON.stringify(result));
794
+ process.exit(1);
795
+ }
796
+ }
797
+
798
+ async function cmdIssueComment(args) {
799
+ const issueId = args[0];
800
+ const body = args.slice(1).join(' ');
801
+
802
+ if (!issueId || !body) {
803
+ console.error(colors.red('Error: Issue ID and comment body required'));
804
+ console.error('Usage: linear issue comment ISSUE-1 "Comment text"');
805
+ process.exit(1);
806
+ }
807
+
808
+ const mutation = `
809
+ mutation($input: CommentCreateInput!) {
810
+ commentCreate(input: $input) {
811
+ success
812
+ }
813
+ }
814
+ `;
815
+
816
+ const result = await gql(mutation, { input: { issueId, body } });
817
+
818
+ if (result.data?.commentCreate?.success) {
819
+ console.log(colors.green(`Comment added to ${issueId}`));
820
+ } else {
821
+ console.error(colors.red('Failed to add comment'));
822
+ console.error(result.errors?.[0]?.message || JSON.stringify(result));
823
+ process.exit(1);
824
+ }
825
+ }
826
+
827
+ // ============================================================================
828
+ // PROJECTS
829
+ // ============================================================================
830
+
831
+ async function cmdProjects(args) {
832
+ const opts = parseArgs(args, { all: 'boolean', a: 'boolean' });
833
+ const showAll = opts.all || opts.a;
834
+
835
+ const query = `{
836
+ team(id: "${TEAM_KEY}") {
837
+ projects(first: 50) {
838
+ nodes { id name description state progress }
839
+ }
840
+ }
841
+ }`;
842
+
843
+ const result = await gql(query);
844
+ let projects = result.data?.team?.projects?.nodes || [];
845
+
846
+ if (!showAll) {
847
+ projects = projects.filter(p => !['completed', 'canceled'].includes(p.state));
848
+ }
849
+
850
+ console.log(colors.bold('Projects:\n'));
851
+ const rows = projects.map(p => [
852
+ p.name,
853
+ p.state,
854
+ `${Math.floor(p.progress * 100)}%`
855
+ ]);
856
+ console.log(formatTable(rows));
857
+ }
858
+
859
+ async function cmdProjectShow(args) {
860
+ const projectName = args[0];
861
+ if (!projectName) {
862
+ console.error(colors.red('Error: Project name required'));
863
+ process.exit(1);
864
+ }
865
+
866
+ const query = `{
867
+ team(id: "${TEAM_KEY}") {
868
+ projects(first: 50) {
869
+ nodes {
870
+ id name description state progress
871
+ issues { nodes { identifier title state { name } } }
872
+ }
873
+ }
874
+ }
875
+ }`;
876
+
877
+ const result = await gql(query);
878
+ const projects = result.data?.team?.projects?.nodes || [];
879
+ const project = projects.find(p => p.name.includes(projectName));
880
+
881
+ if (!project) {
882
+ console.error(colors.red(`Project not found: ${projectName}`));
883
+ process.exit(1);
884
+ }
885
+
886
+ console.log(`# ${project.name}\n`);
887
+ console.log(`State: ${project.state}`);
888
+ console.log(`Progress: ${Math.floor(project.progress * 100)}%`);
889
+ console.log(`\n## Description\n${project.description || 'No description'}`);
890
+
891
+ // Group issues by state
892
+ const byState = {};
893
+ for (const issue of project.issues.nodes) {
894
+ const state = issue.state.name;
895
+ if (!byState[state]) byState[state] = [];
896
+ byState[state].push(issue);
897
+ }
898
+
899
+ console.log('\n## Issues\n');
900
+ for (const [state, issues] of Object.entries(byState)) {
901
+ console.log(`### ${state}`);
902
+ for (const issue of issues) {
903
+ console.log(`- ${issue.identifier}: ${issue.title}`);
904
+ }
905
+ console.log('');
906
+ }
907
+ }
908
+
909
+ async function cmdProjectCreate(args) {
910
+ const opts = parseArgs(args, {
911
+ name: 'string', n: 'string',
912
+ description: 'string', d: 'string',
913
+ });
914
+
915
+ const name = opts.name || opts.n || opts._[0];
916
+ const description = opts.description || opts.d || '';
917
+
918
+ if (!name) {
919
+ console.error(colors.red('Error: Name is required'));
920
+ console.error('Usage: linear project create "Project name" [--description "..."]');
921
+ process.exit(1);
922
+ }
923
+
924
+ // Get team UUID
925
+ const teamResult = await gql(`{ team(id: "${TEAM_KEY}") { id } }`);
926
+ const teamId = teamResult.data?.team?.id;
927
+
928
+ const mutation = `
929
+ mutation($input: ProjectCreateInput!) {
930
+ projectCreate(input: $input) {
931
+ success
932
+ project { id name url }
933
+ }
934
+ }
935
+ `;
936
+
937
+ const result = await gql(mutation, {
938
+ input: { name, description, teamIds: [teamId] }
939
+ });
940
+
941
+ if (result.data?.projectCreate?.success) {
942
+ const project = result.data.projectCreate.project;
943
+ console.log(colors.green(`Created project: ${project.name}`));
944
+ console.log(project.url);
945
+ } else {
946
+ console.error(colors.red('Failed to create project'));
947
+ console.error(result.errors?.[0]?.message || JSON.stringify(result));
948
+ process.exit(1);
949
+ }
950
+ }
951
+
952
+ async function cmdProjectComplete(args) {
953
+ const projectName = args[0];
954
+ if (!projectName) {
955
+ console.error(colors.red('Error: Project name required'));
956
+ process.exit(1);
957
+ }
958
+
959
+ // Find project
960
+ const projectsResult = await gql(`{
961
+ team(id: "${TEAM_KEY}") {
962
+ projects(first: 50) { nodes { id name } }
963
+ }
964
+ }`);
965
+ const projects = projectsResult.data?.team?.projects?.nodes || [];
966
+ const project = projects.find(p => p.name.includes(projectName));
967
+
968
+ if (!project) {
969
+ console.error(colors.red(`Project not found: ${projectName}`));
970
+ process.exit(1);
971
+ }
972
+
973
+ const mutation = `
974
+ mutation($id: String!, $input: ProjectUpdateInput!) {
975
+ projectUpdate(id: $id, input: $input) {
976
+ success
977
+ project { name state }
978
+ }
979
+ }
980
+ `;
981
+
982
+ const result = await gql(mutation, { id: project.id, input: { state: 'completed' } });
983
+
984
+ if (result.data?.projectUpdate?.success) {
985
+ console.log(colors.green(`Completed project: ${projectName}`));
986
+ } else {
987
+ console.error(colors.red('Failed to complete project'));
988
+ console.error(result.errors?.[0]?.message || JSON.stringify(result));
989
+ process.exit(1);
990
+ }
991
+ }
992
+
993
+ // ============================================================================
994
+ // LABELS
995
+ // ============================================================================
996
+
997
+ async function cmdLabels() {
998
+ const query = `{
999
+ team(id: "${TEAM_KEY}") {
1000
+ labels(first: 100) {
1001
+ nodes { id name color description }
1002
+ }
1003
+ }
1004
+ }`;
1005
+
1006
+ const result = await gql(query);
1007
+ const labels = result.data?.team?.labels?.nodes || [];
1008
+
1009
+ console.log(colors.bold('Labels:\n'));
1010
+ if (labels.length === 0) {
1011
+ console.log('No labels found. Create one with: linear label create "name"');
1012
+ return;
1013
+ }
1014
+
1015
+ const rows = labels.map(l => [
1016
+ l.name,
1017
+ l.description || '-'
1018
+ ]);
1019
+ console.log(formatTable(rows));
1020
+ }
1021
+
1022
+ async function cmdLabelCreate(args) {
1023
+ const opts = parseArgs(args, {
1024
+ name: 'string', n: 'string',
1025
+ description: 'string', d: 'string',
1026
+ color: 'string', c: 'string',
1027
+ });
1028
+
1029
+ const name = opts.name || opts.n || opts._[0];
1030
+ const description = opts.description || opts.d || '';
1031
+ const color = opts.color || opts.c;
1032
+
1033
+ if (!name) {
1034
+ console.error(colors.red('Error: Name is required'));
1035
+ console.error('Usage: linear label create "label name" [--description "..."] [--color "#FF0000"]');
1036
+ process.exit(1);
1037
+ }
1038
+
1039
+ // Get team UUID
1040
+ const teamResult = await gql(`{ team(id: "${TEAM_KEY}") { id } }`);
1041
+ const teamId = teamResult.data?.team?.id;
1042
+
1043
+ const mutation = `
1044
+ mutation($input: IssueLabelCreateInput!) {
1045
+ issueLabelCreate(input: $input) {
1046
+ success
1047
+ issueLabel { id name }
1048
+ }
1049
+ }
1050
+ `;
1051
+
1052
+ const input = { teamId, name };
1053
+ if (description) input.description = description;
1054
+ if (color) input.color = color;
1055
+
1056
+ const result = await gql(mutation, { input });
1057
+
1058
+ if (result.data?.issueLabelCreate?.success) {
1059
+ console.log(colors.green(`Created label: ${name}`));
1060
+ } else {
1061
+ console.error(colors.red('Failed to create label'));
1062
+ console.error(result.errors?.[0]?.message || JSON.stringify(result));
1063
+ process.exit(1);
1064
+ }
1065
+ }
1066
+
1067
+ // ============================================================================
1068
+ // GIT INTEGRATION
1069
+ // ============================================================================
1070
+
1071
+ function slugify(text) {
1072
+ return text
1073
+ .toLowerCase()
1074
+ .replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with dashes
1075
+ .replace(/^-+|-+$/g, '') // Trim leading/trailing dashes
1076
+ .slice(0, 50); // Limit length
1077
+ }
1078
+
1079
+ function detectPackageManager(dir) {
1080
+ if (existsSync(join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
1081
+ if (existsSync(join(dir, 'yarn.lock'))) return 'yarn';
1082
+ if (existsSync(join(dir, 'bun.lockb'))) return 'bun';
1083
+ if (existsSync(join(dir, 'package-lock.json'))) return 'npm';
1084
+ if (existsSync(join(dir, 'package.json'))) return 'npm'; // fallback
1085
+ return null;
1086
+ }
1087
+
1088
+ function copyWorktreeIncludes(repoRoot, worktreePath) {
1089
+ const includeFile = join(repoRoot, '.worktreeinclude');
1090
+ if (!existsSync(includeFile)) return [];
1091
+
1092
+ const patterns = readFileSync(includeFile, 'utf-8')
1093
+ .split('\n')
1094
+ .filter(line => line && !line.startsWith('#'));
1095
+
1096
+ const copied = [];
1097
+ for (const pattern of patterns) {
1098
+ const sourcePath = join(repoRoot, pattern);
1099
+ const destPath = join(worktreePath, pattern);
1100
+
1101
+ if (!existsSync(sourcePath)) continue;
1102
+
1103
+ // Check if the file/dir is gitignored (only copy if it is)
1104
+ try {
1105
+ execSync(`git check-ignore -q "${pattern}"`, { cwd: repoRoot, stdio: 'pipe' });
1106
+ // If we get here, the file IS ignored - copy it
1107
+ const destDir = join(destPath, '..');
1108
+ if (!existsSync(destDir)) {
1109
+ mkdirSync(destDir, { recursive: true });
1110
+ }
1111
+
1112
+ // Use cp for both files and directories
1113
+ const fileStat = statSync(sourcePath);
1114
+ if (fileStat.isDirectory()) {
1115
+ execSync(`cp -r "${sourcePath}" "${destPath}"`, { stdio: 'pipe' });
1116
+ } else {
1117
+ execSync(`cp "${sourcePath}" "${destPath}"`, { stdio: 'pipe' });
1118
+ }
1119
+ copied.push(pattern);
1120
+ } catch (err) {
1121
+ // File is not gitignored or doesn't exist, skip it
1122
+ }
1123
+ }
1124
+ return copied;
1125
+ }
1126
+
1127
+ async function cmdNext(args) {
1128
+ const opts = parseArgs(args, { 'dry-run': 'boolean' });
1129
+ const dryRun = opts['dry-run'];
1130
+
1131
+ // Get repo info
1132
+ let repoRoot;
1133
+ try {
1134
+ repoRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim();
1135
+ } catch (err) {
1136
+ console.error(colors.red('Error: Not in a git repository'));
1137
+ process.exit(1);
1138
+ }
1139
+ const repoName = basename(repoRoot);
1140
+
1141
+ // Get current user ID for sorting
1142
+ const viewerResult = await gql('{ viewer { id } }');
1143
+ const viewerId = viewerResult.data?.viewer?.id;
1144
+
1145
+ // Fetch unblocked issues (reuse logic from cmdIssues --unblocked)
1146
+ const query = `{
1147
+ team(id: "${TEAM_KEY}") {
1148
+ issues(first: 100) {
1149
+ nodes {
1150
+ identifier
1151
+ title
1152
+ priority
1153
+ state { name type }
1154
+ project { name }
1155
+ assignee { id name }
1156
+ relations(first: 20) {
1157
+ nodes {
1158
+ type
1159
+ relatedIssue { identifier state { type } }
1160
+ }
1161
+ }
1162
+ }
1163
+ }
1164
+ }
1165
+ }`;
1166
+
1167
+ const result = await gql(query);
1168
+ let issues = result.data?.team?.issues?.nodes || [];
1169
+
1170
+ // Collect all blocked issue IDs
1171
+ const blocked = new Set();
1172
+ for (const issue of issues) {
1173
+ for (const rel of issue.relations?.nodes || []) {
1174
+ if (rel.type === 'blocks') {
1175
+ blocked.add(rel.relatedIssue.identifier);
1176
+ }
1177
+ }
1178
+ }
1179
+
1180
+ // Filter to unblocked, non-completed issues
1181
+ issues = issues.filter(i =>
1182
+ !['completed', 'canceled'].includes(i.state.type) &&
1183
+ !blocked.has(i.identifier)
1184
+ );
1185
+
1186
+ // Sort: assigned to you first, then by identifier
1187
+ issues.sort((a, b) => {
1188
+ const aIsMine = a.assignee?.id === viewerId;
1189
+ const bIsMine = b.assignee?.id === viewerId;
1190
+ if (aIsMine && !bIsMine) return -1;
1191
+ if (!aIsMine && bIsMine) return 1;
1192
+ return a.identifier.localeCompare(b.identifier);
1193
+ });
1194
+
1195
+ // Limit to 10 issues
1196
+ issues = issues.slice(0, 10);
1197
+
1198
+ if (issues.length === 0) {
1199
+ console.error(colors.red('No unblocked issues found'));
1200
+ process.exit(1);
1201
+ }
1202
+
1203
+ // Display issues with numbered selection
1204
+ console.log(colors.bold('Select an issue to work on:\n'));
1205
+ issues.forEach((issue, i) => {
1206
+ const assignee = issue.assignee?.id === viewerId ? colors.green('(you)') : '';
1207
+ const project = issue.project?.name ? colors.gray(`[${issue.project.name}]`) : '';
1208
+ console.log(` ${i + 1}. ${issue.identifier}: ${issue.title} ${assignee} ${project}`);
1209
+ });
1210
+ console.log('');
1211
+
1212
+ // Interactive selection
1213
+ const selection = await prompt('Enter number: ');
1214
+ const idx = parseInt(selection) - 1;
1215
+
1216
+ if (isNaN(idx) || idx < 0 || idx >= issues.length) {
1217
+ console.error(colors.red('Invalid selection'));
1218
+ process.exit(1);
1219
+ }
1220
+
1221
+ const selectedIssue = issues[idx];
1222
+ const branchName = `${selectedIssue.identifier}-${slugify(selectedIssue.title)}`;
1223
+ const worktreePath = join(homedir(), '.claude-worktrees', repoName, branchName);
1224
+
1225
+ if (dryRun) {
1226
+ console.log(colors.bold('\nDry run - would execute:\n'));
1227
+ console.log(` git worktree add "${worktreePath}" -b "${branchName}"`);
1228
+ console.log(` Copy .worktreeinclude files to worktree`);
1229
+ const pm = detectPackageManager(repoRoot);
1230
+ if (pm) {
1231
+ console.log(` ${pm} install`);
1232
+ }
1233
+ console.log(` cd "${worktreePath}" && claude --plan "/next ${selectedIssue.identifier}"`);
1234
+ process.exit(0);
1235
+ }
1236
+
1237
+ // Create worktree directory parent if needed
1238
+ const worktreeParent = join(homedir(), '.claude-worktrees', repoName);
1239
+ if (!existsSync(worktreeParent)) {
1240
+ mkdirSync(worktreeParent, { recursive: true });
1241
+ }
1242
+
1243
+ // Check if worktree already exists
1244
+ if (existsSync(worktreePath)) {
1245
+ console.log(colors.yellow(`\nWorktree already exists: ${worktreePath}`));
1246
+ console.log(`cd "${worktreePath}" && claude --plan "/next ${selectedIssue.identifier}"`);
1247
+ process.exit(0);
1248
+ }
1249
+
1250
+ // Create the worktree
1251
+ console.log(colors.gray(`\nCreating worktree at ${worktreePath}...`));
1252
+ try {
1253
+ execSync(`git worktree add "${worktreePath}" -b "${branchName}"`, {
1254
+ cwd: repoRoot,
1255
+ stdio: 'inherit'
1256
+ });
1257
+ } catch (err) {
1258
+ // Branch might already exist, try without -b
1259
+ try {
1260
+ execSync(`git worktree add "${worktreePath}" "${branchName}"`, {
1261
+ cwd: repoRoot,
1262
+ stdio: 'inherit'
1263
+ });
1264
+ } catch (err2) {
1265
+ console.error(colors.red(`Failed to create worktree: ${err2.message}`));
1266
+ process.exit(1);
1267
+ }
1268
+ }
1269
+
1270
+ // Copy .worktreeinclude files
1271
+ const copied = copyWorktreeIncludes(repoRoot, worktreePath);
1272
+ if (copied.length > 0) {
1273
+ console.log(colors.green(`Copied: ${copied.join(', ')}`));
1274
+ }
1275
+
1276
+ // Detect package manager and install dependencies
1277
+ const pm = detectPackageManager(worktreePath);
1278
+ if (pm) {
1279
+ console.log(colors.gray(`Installing dependencies with ${pm}...`));
1280
+ try {
1281
+ execSync(`${pm} install`, { cwd: worktreePath, stdio: 'inherit' });
1282
+ } catch (err) {
1283
+ console.error(colors.yellow(`Warning: ${pm} install failed, continuing anyway`));
1284
+ }
1285
+ }
1286
+
1287
+ // Output eval-able shell command for the wrapper function
1288
+ console.log(`cd "${worktreePath}" && claude --plan "/next ${selectedIssue.identifier}"`);
1289
+ }
1290
+
1291
+ async function cmdBranch(args) {
1292
+ const issueId = args[0];
1293
+ if (!issueId) {
1294
+ console.error(colors.red('Error: Issue ID required'));
1295
+ console.error('Usage: linear branch ISSUE-5');
1296
+ process.exit(1);
1297
+ }
1298
+
1299
+ // Fetch issue title
1300
+ const result = await gql(`{
1301
+ issue(id: "${issueId}") {
1302
+ identifier
1303
+ title
1304
+ }
1305
+ }`);
1306
+
1307
+ const issue = result.data?.issue;
1308
+ if (!issue) {
1309
+ console.error(colors.red(`Issue not found: ${issueId}`));
1310
+ process.exit(1);
1311
+ }
1312
+
1313
+ // Create branch name: ISSUE-5-slugified-title
1314
+ const branchName = `${issue.identifier}-${slugify(issue.title)}`;
1315
+
1316
+ try {
1317
+ // Check for uncommitted changes
1318
+ const status = execSync('git status --porcelain', { encoding: 'utf-8' });
1319
+ if (status.trim()) {
1320
+ console.error(colors.yellow('Warning: You have uncommitted changes'));
1321
+ }
1322
+
1323
+ // Create and checkout branch
1324
+ execSync(`git checkout -b "${branchName}"`, { stdio: 'inherit' });
1325
+ console.log(colors.green(`\nCreated branch: ${branchName}`));
1326
+ console.log(`\nWorking on: ${issue.identifier} - ${issue.title}`);
1327
+ } catch (err) {
1328
+ if (err.message?.includes('not a git repository')) {
1329
+ console.error(colors.red('Error: Not in a git repository'));
1330
+ } else if (err.message?.includes('already exists')) {
1331
+ console.error(colors.red(`Branch '${branchName}' already exists`));
1332
+ console.log(`Try: git checkout ${branchName}`);
1333
+ } else {
1334
+ console.error(colors.red(`Git error: ${err.message}`));
1335
+ }
1336
+ process.exit(1);
1337
+ }
1338
+ }
1339
+
1340
+ // ============================================================================
1341
+ // DONE (Complete work on an issue)
1342
+ // ============================================================================
1343
+
1344
+ function getIssueFromBranch() {
1345
+ try {
1346
+ const branch = execSync('git branch --show-current', { encoding: 'utf-8' }).trim();
1347
+ // Extract issue ID from branch name (e.g., ISSUE-12-some-title -> ISSUE-12)
1348
+ const match = branch.match(/^([A-Z]+-\d+)/);
1349
+ return match ? match[1] : null;
1350
+ } catch (err) {
1351
+ return null;
1352
+ }
1353
+ }
1354
+
1355
+ function isInWorktree() {
1356
+ try {
1357
+ // In a worktree, git rev-parse --git-dir returns something like /path/to/main/.git/worktrees/branch-name
1358
+ const gitDir = execSync('git rev-parse --git-dir', { encoding: 'utf-8' }).trim();
1359
+ return gitDir.includes('/worktrees/');
1360
+ } catch (err) {
1361
+ return false;
1362
+ }
1363
+ }
1364
+
1365
+ function getMainRepoPath() {
1366
+ try {
1367
+ // Get the path to the main working tree
1368
+ const worktreeList = execSync('git worktree list --porcelain', { encoding: 'utf-8' });
1369
+ const lines = worktreeList.split('\n');
1370
+ // First worktree entry is the main repo
1371
+ for (const line of lines) {
1372
+ if (line.startsWith('worktree ')) {
1373
+ return line.replace('worktree ', '');
1374
+ }
1375
+ }
1376
+ return null;
1377
+ } catch (err) {
1378
+ return null;
1379
+ }
1380
+ }
1381
+
1382
+ async function cmdDone(args) {
1383
+ const opts = parseArgs(args, {
1384
+ 'no-close': 'boolean',
1385
+ 'keep-branch': 'boolean',
1386
+ });
1387
+
1388
+ // Determine issue ID: from argument or from branch name
1389
+ let issueId = opts._[0];
1390
+ if (!issueId) {
1391
+ issueId = getIssueFromBranch();
1392
+ if (!issueId) {
1393
+ console.error(colors.red('Error: Could not detect issue from branch name'));
1394
+ console.error('Usage: linear done [ISSUE-12]');
1395
+ process.exit(1);
1396
+ }
1397
+ }
1398
+
1399
+ const shouldClose = !opts['no-close'];
1400
+ const keepBranch = opts['keep-branch'];
1401
+ const inWorktree = isInWorktree();
1402
+
1403
+ // Verify the issue exists
1404
+ const result = await gql(`{
1405
+ issue(id: "${issueId}") {
1406
+ identifier
1407
+ title
1408
+ state { name type }
1409
+ }
1410
+ }`);
1411
+
1412
+ const issue = result.data?.issue;
1413
+ if (!issue) {
1414
+ console.error(colors.red(`Issue not found: ${issueId}`));
1415
+ process.exit(1);
1416
+ }
1417
+
1418
+ console.log(colors.bold(`\nCompleting: ${issue.identifier}: ${issue.title}\n`));
1419
+
1420
+ // Close the issue if not already closed
1421
+ if (shouldClose && issue.state.type !== 'completed') {
1422
+ const statesResult = await gql(`{
1423
+ team(id: "${TEAM_KEY}") {
1424
+ states { nodes { id name type } }
1425
+ }
1426
+ }`);
1427
+ const states = statesResult.data?.team?.states?.nodes || [];
1428
+ const doneState = states.find(s => s.type === 'completed');
1429
+
1430
+ if (doneState) {
1431
+ const closeResult = await gql(`
1432
+ mutation($id: String!, $input: IssueUpdateInput!) {
1433
+ issueUpdate(id: $id, input: $input) { success }
1434
+ }
1435
+ `, { id: issueId, input: { stateId: doneState.id } });
1436
+
1437
+ if (closeResult.data?.issueUpdate?.success) {
1438
+ console.log(colors.green(`✓ Closed ${issueId}`));
1439
+ } else {
1440
+ console.error(colors.yellow(`Warning: Could not close issue`));
1441
+ }
1442
+ }
1443
+ } else if (issue.state.type === 'completed') {
1444
+ console.log(colors.gray(`Issue already closed`));
1445
+ }
1446
+
1447
+ // Handle worktree cleanup
1448
+ if (inWorktree) {
1449
+ const currentDir = process.cwd();
1450
+ const mainRepo = getMainRepoPath();
1451
+ const branchName = execSync('git branch --show-current', { encoding: 'utf-8' }).trim();
1452
+
1453
+ console.log(colors.gray(`\nWorktree detected: ${currentDir}`));
1454
+
1455
+ // Output commands for the shell wrapper to execute
1456
+ // We can't cd from within Node, so we output eval-able commands
1457
+ console.log(colors.bold('\nTo clean up the worktree, run:\n'));
1458
+ console.log(`cd "${mainRepo}"`);
1459
+ console.log(`git worktree remove "${currentDir}"`);
1460
+ if (!keepBranch) {
1461
+ console.log(`git branch -d "${branchName}"`);
1462
+ }
1463
+
1464
+ // Also provide a one-liner
1465
+ console.log(colors.gray('\nOr copy this one-liner:'));
1466
+ const oneLiner = keepBranch
1467
+ ? `cd "${mainRepo}" && git worktree remove "${currentDir}"`
1468
+ : `cd "${mainRepo}" && git worktree remove "${currentDir}" && git branch -d "${branchName}"`;
1469
+ console.log(oneLiner);
1470
+ } else {
1471
+ console.log(colors.green('\nDone!'));
1472
+ }
1473
+ }
1474
+
1475
+ // ============================================================================
1476
+ // STANDUP
1477
+ // ============================================================================
1478
+
1479
+ function getYesterdayDate() {
1480
+ const date = new Date();
1481
+ date.setDate(date.getDate() - 1);
1482
+ return date.toISOString().split('T')[0];
1483
+ }
1484
+
1485
+ function getTodayDate() {
1486
+ return new Date().toISOString().split('T')[0];
1487
+ }
1488
+
1489
+ async function cmdStandup(args) {
1490
+ const opts = parseArgs(args, {
1491
+ 'no-github': 'boolean',
1492
+ });
1493
+
1494
+ const skipGitHub = opts['no-github'];
1495
+ const yesterday = getYesterdayDate();
1496
+ const today = getTodayDate();
1497
+
1498
+ // Get current user
1499
+ const viewerResult = await gql('{ viewer { id name } }');
1500
+ const viewer = viewerResult.data?.viewer;
1501
+ if (!viewer) {
1502
+ console.error(colors.red('Error: Could not fetch user info'));
1503
+ process.exit(1);
1504
+ }
1505
+
1506
+ console.log(colors.bold(`\nStandup for ${viewer.name}\n`));
1507
+ console.log(colors.gray(`─────────────────────────────────────────\n`));
1508
+
1509
+ // Fetch issues with completion info
1510
+ const query = `{
1511
+ team(id: "${TEAM_KEY}") {
1512
+ issues(first: 100) {
1513
+ nodes {
1514
+ identifier
1515
+ title
1516
+ state { name type }
1517
+ assignee { id }
1518
+ completedAt
1519
+ relations(first: 20) {
1520
+ nodes {
1521
+ type
1522
+ relatedIssue { identifier state { type } }
1523
+ }
1524
+ }
1525
+ }
1526
+ }
1527
+ }
1528
+ }`;
1529
+
1530
+ const result = await gql(query);
1531
+ const issues = result.data?.team?.issues?.nodes || [];
1532
+
1533
+ // Issues completed yesterday (by me)
1534
+ const completedYesterday = issues.filter(i => {
1535
+ if (i.assignee?.id !== viewer.id) return false;
1536
+ if (!i.completedAt) return false;
1537
+ const completedDate = i.completedAt.split('T')[0];
1538
+ return completedDate === yesterday;
1539
+ });
1540
+
1541
+ // Issues in progress (assigned to me)
1542
+ const inProgress = issues.filter(i =>
1543
+ i.assignee?.id === viewer.id &&
1544
+ i.state.type === 'started'
1545
+ );
1546
+
1547
+ // Blocked issues (assigned to me)
1548
+ const blockedIds = new Set();
1549
+ for (const issue of issues) {
1550
+ for (const rel of issue.relations?.nodes || []) {
1551
+ if (rel.type === 'blocks' && rel.relatedIssue.state.type !== 'completed') {
1552
+ blockedIds.add(rel.relatedIssue.identifier);
1553
+ }
1554
+ }
1555
+ }
1556
+ const blocked = issues.filter(i =>
1557
+ i.assignee?.id === viewer.id &&
1558
+ blockedIds.has(i.identifier)
1559
+ );
1560
+
1561
+ // Display Linear info
1562
+ console.log(colors.bold('Yesterday (completed):'));
1563
+ if (completedYesterday.length === 0) {
1564
+ console.log(colors.gray(' No issues completed'));
1565
+ } else {
1566
+ for (const issue of completedYesterday) {
1567
+ console.log(` ${colors.green('✓')} ${issue.identifier}: ${issue.title}`);
1568
+ }
1569
+ }
1570
+
1571
+ console.log('');
1572
+ console.log(colors.bold('Today (in progress):'));
1573
+ if (inProgress.length === 0) {
1574
+ console.log(colors.gray(' No issues in progress'));
1575
+ } else {
1576
+ for (const issue of inProgress) {
1577
+ console.log(` → ${issue.identifier}: ${issue.title}`);
1578
+ }
1579
+ }
1580
+
1581
+ if (blocked.length > 0) {
1582
+ console.log('');
1583
+ console.log(colors.bold('Blocked:'));
1584
+ for (const issue of blocked) {
1585
+ console.log(` ${colors.red('⊘')} ${issue.identifier}: ${issue.title}`);
1586
+ }
1587
+ }
1588
+
1589
+ // GitHub activity
1590
+ if (!skipGitHub) {
1591
+ console.log('');
1592
+ console.log(colors.gray(`─────────────────────────────────────────\n`));
1593
+ console.log(colors.bold('GitHub Activity (yesterday):'));
1594
+
1595
+ try {
1596
+ // Get commits from yesterday
1597
+ const sinceDate = `${yesterday}T00:00:00`;
1598
+ const untilDate = `${today}T00:00:00`;
1599
+
1600
+ // Try to get repo info
1601
+ let repoOwner, repoName;
1602
+ try {
1603
+ const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf-8' }).trim();
1604
+ const match = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
1605
+ if (match) {
1606
+ repoOwner = match[1];
1607
+ repoName = match[2];
1608
+ }
1609
+ } catch (err) {
1610
+ // Not in a git repo or no origin
1611
+ }
1612
+
1613
+ if (repoOwner && repoName) {
1614
+ // Get git user name for author matching (may differ from Linear display name)
1615
+ let gitUserName;
1616
+ try {
1617
+ gitUserName = execSync('git config user.name', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
1618
+ } catch (err) {
1619
+ gitUserName = viewer.name; // Fall back to Linear name
1620
+ }
1621
+
1622
+ // Get commits using git log (more reliable than gh for commits)
1623
+ try {
1624
+ const gitLog = execSync(
1625
+ `git log --since="${sinceDate}" --until="${untilDate}" --author="${gitUserName}" --oneline`,
1626
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
1627
+ ).trim();
1628
+
1629
+ if (gitLog) {
1630
+ const commits = gitLog.split('\n').filter(Boolean);
1631
+ console.log(`\n Commits (${commits.length}):`);
1632
+ for (const commit of commits.slice(0, 10)) {
1633
+ console.log(` ${commit}`);
1634
+ }
1635
+ if (commits.length > 10) {
1636
+ console.log(colors.gray(` ... and ${commits.length - 10} more`));
1637
+ }
1638
+ }
1639
+ } catch (err) {
1640
+ // No commits or git error
1641
+ }
1642
+
1643
+ // Get PRs using gh
1644
+ try {
1645
+ const prsJson = execSync(
1646
+ `gh pr list --author @me --state all --json number,title,state,mergedAt,createdAt --limit 20`,
1647
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
1648
+ );
1649
+ const prs = JSON.parse(prsJson);
1650
+
1651
+ // Filter to PRs created or merged yesterday
1652
+ const relevantPrs = prs.filter(pr => {
1653
+ const createdDate = pr.createdAt?.split('T')[0];
1654
+ const mergedDate = pr.mergedAt?.split('T')[0];
1655
+ return createdDate === yesterday || mergedDate === yesterday;
1656
+ });
1657
+
1658
+ if (relevantPrs.length > 0) {
1659
+ console.log(`\n Pull Requests:`);
1660
+ for (const pr of relevantPrs) {
1661
+ const status = pr.state === 'MERGED' ? colors.green('merged') :
1662
+ pr.state === 'OPEN' ? colors.yellow('open') :
1663
+ colors.gray(pr.state.toLowerCase());
1664
+ console.log(` #${pr.number} ${pr.title} [${status}]`);
1665
+ }
1666
+ }
1667
+ } catch (err) {
1668
+ // gh not available or error
1669
+ console.log(colors.gray(' (gh CLI not available or not authenticated)'));
1670
+ }
1671
+ } else {
1672
+ console.log(colors.gray(' (not in a GitHub repository)'));
1673
+ }
1674
+ } catch (err) {
1675
+ console.log(colors.gray(` Error fetching GitHub data: ${err.message}`));
1676
+ }
1677
+ }
1678
+
1679
+ console.log('');
1680
+ }
1681
+
1682
+ // ============================================================================
1683
+ // AUTH
1684
+ // ============================================================================
1685
+
1686
+ async function cmdLogin(args) {
1687
+ const opts = parseArgs(args, { global: 'boolean', g: 'boolean' });
1688
+ const saveGlobal = opts.global || opts.g;
1689
+
1690
+ console.log(colors.bold('Linear CLI Login\n'));
1691
+ console.log('Opening Linear API settings in your browser...');
1692
+ console.log(colors.gray('(Create a new personal API key if you don\'t have one)\n'));
1693
+
1694
+ openBrowser('https://linear.app/settings/api');
1695
+
1696
+ await new Promise(r => setTimeout(r, 1000));
1697
+
1698
+ const apiKey = await prompt('Paste your API key: ');
1699
+
1700
+ if (!apiKey) {
1701
+ console.error(colors.red('Error: API key is required'));
1702
+ process.exit(1);
1703
+ }
1704
+
1705
+ console.log('\nValidating...');
1706
+ LINEAR_API_KEY = apiKey;
1707
+
1708
+ const teamsResult = await gql('{ teams { nodes { id key name } } }');
1709
+ const teams = teamsResult.data?.teams?.nodes;
1710
+
1711
+ if (!teams || teams.length === 0) {
1712
+ console.error(colors.red('Error: Invalid API key or no access to any teams'));
1713
+ if (teamsResult.errors) {
1714
+ console.error(teamsResult.errors[0]?.message);
1715
+ }
1716
+ process.exit(1);
1717
+ }
1718
+
1719
+ console.log(colors.green('Valid!\n'));
1720
+ console.log(colors.bold('Select a team:\n'));
1721
+
1722
+ teams.forEach((team, i) => {
1723
+ console.log(` ${i + 1}. ${team.name} (${team.key})`);
1724
+ });
1725
+ console.log(` ${teams.length + 1}. Create a new team...`);
1726
+ console.log('');
1727
+
1728
+ const selection = await prompt('Enter number [1]: ') || '1';
1729
+ let selectedKey = '';
1730
+
1731
+ if (parseInt(selection) === teams.length + 1) {
1732
+ // Create new team
1733
+ console.log('');
1734
+ const teamName = await prompt('Team name: ');
1735
+
1736
+ if (!teamName) {
1737
+ console.error(colors.red('Error: Team name is required'));
1738
+ process.exit(1);
1739
+ }
1740
+
1741
+ const suggestedKey = suggestTeamKey(teamName);
1742
+ let teamKey = await prompt(`Team key [${suggestedKey}]: `);
1743
+ teamKey = (teamKey || suggestedKey).toUpperCase();
1744
+
1745
+ const createResult = await gql(`
1746
+ mutation($input: TeamCreateInput!) {
1747
+ teamCreate(input: $input) {
1748
+ success
1749
+ team { key name }
1750
+ }
1751
+ }
1752
+ `, { input: { name: teamName, key: teamKey } });
1753
+
1754
+ if (createResult.data?.teamCreate?.success) {
1755
+ selectedKey = teamKey;
1756
+ console.log(colors.green(`Created team: ${teamName} (${teamKey})`));
1757
+ } else {
1758
+ console.error(colors.red('Failed to create team'));
1759
+ console.error(createResult.errors?.[0]?.message || JSON.stringify(createResult));
1760
+ process.exit(1);
1761
+ }
1762
+ } else {
1763
+ const idx = parseInt(selection) - 1;
1764
+ if (idx < 0 || idx >= teams.length) {
1765
+ console.error(colors.red('Error: Invalid selection'));
1766
+ process.exit(1);
1767
+ }
1768
+ selectedKey = teams[idx].key;
1769
+ }
1770
+
1771
+ // Save config
1772
+ const configPath = saveGlobal ? join(homedir(), '.linear') : join(process.cwd(), '.linear');
1773
+ const configContent = `# Linear CLI configuration
1774
+ api_key=${apiKey}
1775
+ team=${selectedKey}
1776
+ `;
1777
+
1778
+ writeFileSync(configPath, configContent);
1779
+
1780
+ console.log('');
1781
+ console.log(colors.green(`Saved to ${configPath}`));
1782
+
1783
+ // Add .linear to .gitignore if saving locally
1784
+ if (!saveGlobal) {
1785
+ const gitignorePath = join(process.cwd(), '.gitignore');
1786
+ try {
1787
+ let gitignore = '';
1788
+ if (existsSync(gitignorePath)) {
1789
+ gitignore = readFileSync(gitignorePath, 'utf-8');
1790
+ }
1791
+
1792
+ // Check if .linear is already in .gitignore
1793
+ const lines = gitignore.split('\n').map(l => l.trim());
1794
+ if (!lines.includes('.linear')) {
1795
+ // Add .linear to .gitignore
1796
+ const newline = gitignore.endsWith('\n') || gitignore === '' ? '' : '\n';
1797
+ const content = gitignore + newline + '.linear\n';
1798
+ writeFileSync(gitignorePath, content);
1799
+ console.log(colors.green(`Added .linear to .gitignore`));
1800
+ }
1801
+ } catch (err) {
1802
+ // Silently ignore if we can't update .gitignore
1803
+ }
1804
+
1805
+ // Add .linear to .worktreeinclude for worktree support
1806
+ const worktreeIncludePath = join(process.cwd(), '.worktreeinclude');
1807
+ try {
1808
+ let worktreeInclude = '';
1809
+ if (existsSync(worktreeIncludePath)) {
1810
+ worktreeInclude = readFileSync(worktreeIncludePath, 'utf-8');
1811
+ }
1812
+
1813
+ const wtLines = worktreeInclude.split('\n').map(l => l.trim());
1814
+ if (!wtLines.includes('.linear')) {
1815
+ const newline = worktreeInclude.endsWith('\n') || worktreeInclude === '' ? '' : '\n';
1816
+ writeFileSync(worktreeIncludePath, worktreeInclude + newline + '.linear\n');
1817
+ console.log(colors.green(`Added .linear to .worktreeinclude`));
1818
+ }
1819
+ } catch (err) {
1820
+ // Silently ignore if we can't update .worktreeinclude
1821
+ }
1822
+ }
1823
+
1824
+ console.log('');
1825
+ console.log("You're ready to go! Try:");
1826
+ console.log(' linear issues --unblocked');
1827
+ console.log(' linear projects');
1828
+ }
1829
+
1830
+ async function cmdLogout() {
1831
+ const localPath = join(process.cwd(), '.linear');
1832
+ const globalPath = join(homedir(), '.linear');
1833
+
1834
+ if (existsSync(localPath)) {
1835
+ unlinkSync(localPath);
1836
+ console.log(colors.green(`Removed ${localPath}`));
1837
+ } else if (existsSync(globalPath)) {
1838
+ unlinkSync(globalPath);
1839
+ console.log(colors.green(`Removed ${globalPath}`));
1840
+ } else {
1841
+ console.log('No config file found.');
1842
+ }
1843
+ }
1844
+
1845
+ async function cmdWhoami() {
1846
+ checkAuth();
1847
+
1848
+ const result = await gql('{ viewer { id name email } }');
1849
+ const user = result.data?.viewer;
1850
+
1851
+ if (!user) {
1852
+ console.error(colors.red('Error: Could not fetch user info'));
1853
+ process.exit(1);
1854
+ }
1855
+
1856
+ console.log(`Logged in as: ${user.name} <${user.email}>`);
1857
+ console.log(`Team: ${TEAM_KEY}`);
1858
+ console.log(`Config: ${CONFIG_FILE || '(environment variables)'}`);
1859
+ }
1860
+
1861
+ // ============================================================================
1862
+ // HELP
1863
+ // ============================================================================
1864
+
1865
+ function showHelp() {
1866
+ console.log(`Linear CLI - A simple wrapper around Linear's GraphQL API
1867
+
1868
+ USAGE:
1869
+ linear <command> [options]
1870
+
1871
+ AUTHENTICATION:
1872
+ login [--global] Login and save credentials to .linear
1873
+ --global, -g Save to ~/.linear instead of ./.linear
1874
+ logout Remove saved credentials
1875
+ whoami Show current user and team
1876
+
1877
+ ISSUES:
1878
+ issues [options] List issues (yours shown first)
1879
+ --unblocked, -u Show only unblocked issues
1880
+ --open, -o Show all non-completed/canceled issues
1881
+ --all, -a Show all states (including completed)
1882
+ --mine, -m Show only issues assigned to you
1883
+ --in-progress Show issues in progress
1884
+ --state, -s <state> Filter by state (default: backlog)
1885
+ --label, -l <name> Filter by label
1886
+
1887
+ issue show <id> Show issue details with parent context
1888
+ issue start <id> Assign to yourself and set to In Progress
1889
+ issue create [options] Create a new issue
1890
+ --title, -t <title> Issue title (required)
1891
+ --description, -d <desc> Issue description
1892
+ --project, -p <name> Add to project
1893
+ --parent <id> Parent issue (for sub-issues)
1894
+ --assign Assign to yourself
1895
+ --estimate, -e <size> Estimate: XS, S, M, L, XL
1896
+ --label, -l <name> Add label
1897
+ --blocks <id> This issue blocks another
1898
+ --blocked-by <id> This issue is blocked by another
1899
+ issue update <id> [opts] Update an issue
1900
+ --title, -t <title> New title
1901
+ --description, -d <desc> New description
1902
+ --state, -s <state> New state
1903
+ --append, -a <text> Append to description
1904
+ --blocks <id> Add blocking relation
1905
+ --blocked-by <id> Add blocked-by relation
1906
+ issue close <id> Mark issue as done
1907
+ issue comment <id> <body> Add a comment
1908
+
1909
+ PROJECTS:
1910
+ projects [options] List projects
1911
+ --all, -a Include completed projects
1912
+ project show <name> Show project details
1913
+ project create [options] Create a new project
1914
+ --name, -n <name> Project name (required)
1915
+ --description, -d <desc> Project description
1916
+ project complete <name> Mark project as completed
1917
+
1918
+ LABELS:
1919
+ labels List all labels
1920
+ label create [options] Create a new label
1921
+ --name, -n <name> Label name (required)
1922
+ --description, -d <desc> Label description
1923
+ --color, -c <hex> Label color (e.g., #FF0000)
1924
+
1925
+ GIT:
1926
+ branch <id> Create git branch from issue (ISSUE-5-issue-title)
1927
+
1928
+ WORKFLOW:
1929
+ next Pick an issue and start in a new worktree
1930
+ --dry-run Show commands without executing
1931
+ done [id] Complete work on an issue
1932
+ --no-close Don't close the issue in Linear
1933
+ --keep-branch Don't suggest deleting the branch
1934
+ standup Show daily standup summary
1935
+ --no-github Skip GitHub activity
1936
+
1937
+ Shell setup (add to ~/.zshrc):
1938
+ lnext() { eval "$(linear next "$@")"; }
1939
+
1940
+ CONFIGURATION:
1941
+ Config is loaded from ./.linear first, then ~/.linear, then env vars.
1942
+
1943
+ File format:
1944
+ api_key=lin_api_xxx
1945
+ team=ISSUE
1946
+
1947
+ EXAMPLES:
1948
+ linear login # First-time setup
1949
+ linear issues --unblocked # Find workable issues
1950
+ linear issues --in-progress # See what you're working on
1951
+ linear issue show ISSUE-1 # View with parent context
1952
+ linear issue start ISSUE-1 # Assign and start working
1953
+ linear issue create --title "Fix bug" --project "Phase 1" --assign --estimate M
1954
+ linear issue create --title "Needs API key" --blocked-by ISSUE-5
1955
+ linear issue update ISSUE-1 --append "Found the root cause..."
1956
+ linear branch ISSUE-5 # Create git branch for issue
1957
+ `);
1958
+ }
1959
+
1960
+ // ============================================================================
1961
+ // MAIN
1962
+ // ============================================================================
1963
+
1964
+ async function main() {
1965
+ loadConfig();
1966
+
1967
+ const args = process.argv.slice(2);
1968
+ const cmd = args[0];
1969
+
1970
+ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
1971
+ showHelp();
1972
+ process.exit(0);
1973
+ }
1974
+
1975
+ try {
1976
+ switch (cmd) {
1977
+ case 'login':
1978
+ await cmdLogin(args.slice(1));
1979
+ break;
1980
+ case 'logout':
1981
+ await cmdLogout();
1982
+ break;
1983
+ case 'whoami':
1984
+ await cmdWhoami();
1985
+ break;
1986
+ case 'issues':
1987
+ checkAuth();
1988
+ await cmdIssues(args.slice(1));
1989
+ break;
1990
+ case 'issue': {
1991
+ checkAuth();
1992
+ const subcmd = args[1];
1993
+ const subargs = args.slice(2);
1994
+ switch (subcmd) {
1995
+ case 'show': await cmdIssueShow(subargs); break;
1996
+ case 'create': await cmdIssueCreate(subargs); break;
1997
+ case 'update': await cmdIssueUpdate(subargs); break;
1998
+ case 'start': await cmdIssueStart(subargs); break;
1999
+ case 'close': await cmdIssueClose(subargs); break;
2000
+ case 'comment': await cmdIssueComment(subargs); break;
2001
+ default:
2002
+ console.error(`Unknown issue command: ${subcmd}`);
2003
+ process.exit(1);
2004
+ }
2005
+ break;
2006
+ }
2007
+ case 'projects':
2008
+ checkAuth();
2009
+ await cmdProjects(args.slice(1));
2010
+ break;
2011
+ case 'project': {
2012
+ checkAuth();
2013
+ const subcmd = args[1];
2014
+ const subargs = args.slice(2);
2015
+ switch (subcmd) {
2016
+ case 'show': await cmdProjectShow(subargs); break;
2017
+ case 'create': await cmdProjectCreate(subargs); break;
2018
+ case 'complete': await cmdProjectComplete(subargs); break;
2019
+ default:
2020
+ console.error(`Unknown project command: ${subcmd}`);
2021
+ process.exit(1);
2022
+ }
2023
+ break;
2024
+ }
2025
+ case 'labels':
2026
+ checkAuth();
2027
+ await cmdLabels();
2028
+ break;
2029
+ case 'label': {
2030
+ checkAuth();
2031
+ const subcmd = args[1];
2032
+ const subargs = args.slice(2);
2033
+ switch (subcmd) {
2034
+ case 'create': await cmdLabelCreate(subargs); break;
2035
+ default:
2036
+ console.error(`Unknown label command: ${subcmd}`);
2037
+ process.exit(1);
2038
+ }
2039
+ break;
2040
+ }
2041
+ case 'branch':
2042
+ checkAuth();
2043
+ await cmdBranch(args.slice(1));
2044
+ break;
2045
+ case 'next':
2046
+ checkAuth();
2047
+ await cmdNext(args.slice(1));
2048
+ break;
2049
+ case 'done':
2050
+ checkAuth();
2051
+ await cmdDone(args.slice(1));
2052
+ break;
2053
+ case 'standup':
2054
+ checkAuth();
2055
+ await cmdStandup(args.slice(1));
2056
+ break;
2057
+ default:
2058
+ console.error(`Unknown command: ${cmd}`);
2059
+ showHelp();
2060
+ process.exit(1);
2061
+ }
2062
+ } catch (err) {
2063
+ console.error(colors.red(`Error: ${err.message}`));
2064
+ process.exit(1);
2065
+ }
2066
+ }
2067
+
2068
+ main();