@dabble/linear-cli 1.0.4 → 1.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/bin/linear.mjs CHANGED
@@ -14,6 +14,7 @@ const API_URL = 'https://api.linear.app/graphql';
14
14
  let CONFIG_FILE = '';
15
15
  let LINEAR_API_KEY = '';
16
16
  let TEAM_KEY = '';
17
+ let ALIASES = {};
17
18
 
18
19
  // Colors (ANSI)
19
20
  const colors = {
@@ -43,12 +44,32 @@ function loadConfig() {
43
44
  // Load from config file first (highest priority)
44
45
  if (CONFIG_FILE) {
45
46
  const content = readFileSync(CONFIG_FILE, 'utf-8');
47
+ let inAliasSection = false;
48
+
46
49
  for (const line of content.split('\n')) {
47
- if (!line || line.startsWith('#')) continue;
50
+ const trimmed = line.trim();
51
+ if (!trimmed || trimmed.startsWith('#')) continue;
52
+
53
+ // Check for section header
54
+ if (trimmed === '[aliases]') {
55
+ inAliasSection = true;
56
+ continue;
57
+ }
58
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
59
+ inAliasSection = false;
60
+ continue;
61
+ }
62
+
48
63
  const [key, ...rest] = line.split('=');
49
64
  const value = rest.join('=').trim();
50
- if (key === 'api_key') LINEAR_API_KEY = value;
51
- if (key === 'team') TEAM_KEY = value;
65
+
66
+ if (inAliasSection) {
67
+ // Store aliases with uppercase keys
68
+ ALIASES[key.trim().toUpperCase()] = value;
69
+ } else {
70
+ if (key.trim() === 'api_key') LINEAR_API_KEY = value;
71
+ if (key.trim() === 'team') TEAM_KEY = value;
72
+ }
52
73
  }
53
74
  }
54
75
 
@@ -57,6 +78,107 @@ function loadConfig() {
57
78
  if (!TEAM_KEY) TEAM_KEY = process.env.LINEAR_TEAM || '';
58
79
  }
59
80
 
81
+ function resolveAlias(nameOrAlias) {
82
+ if (!nameOrAlias) return nameOrAlias;
83
+ return ALIASES[nameOrAlias.toUpperCase()] || nameOrAlias;
84
+ }
85
+
86
+ function saveAlias(code, name) {
87
+ if (!CONFIG_FILE) {
88
+ console.error(colors.red('Error: No config file found. Run "linear login" first.'));
89
+ process.exit(1);
90
+ }
91
+
92
+ const content = readFileSync(CONFIG_FILE, 'utf-8');
93
+ const lines = content.split('\n');
94
+
95
+ // Find or create [aliases] section
96
+ let aliasStart = -1;
97
+ let aliasEnd = -1;
98
+ let existingAliasLine = -1;
99
+ const upperCode = code.toUpperCase();
100
+
101
+ for (let i = 0; i < lines.length; i++) {
102
+ const trimmed = lines[i].trim();
103
+ if (trimmed === '[aliases]') {
104
+ aliasStart = i;
105
+ } else if (aliasStart !== -1 && aliasEnd === -1) {
106
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
107
+ aliasEnd = i;
108
+ } else if (trimmed && !trimmed.startsWith('#')) {
109
+ const [key] = trimmed.split('=');
110
+ if (key.trim().toUpperCase() === upperCode) {
111
+ existingAliasLine = i;
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ // If no alias section end found, it goes to EOF
118
+ if (aliasStart !== -1 && aliasEnd === -1) {
119
+ aliasEnd = lines.length;
120
+ }
121
+
122
+ const aliasLine = `${upperCode}=${name}`;
123
+
124
+ if (existingAliasLine !== -1) {
125
+ // Update existing alias
126
+ lines[existingAliasLine] = aliasLine;
127
+ } else if (aliasStart !== -1) {
128
+ // Add to existing section
129
+ lines.splice(aliasStart + 1, 0, aliasLine);
130
+ } else {
131
+ // Create new section at end
132
+ if (lines[lines.length - 1] !== '') {
133
+ lines.push('');
134
+ }
135
+ lines.push('[aliases]');
136
+ lines.push(aliasLine);
137
+ }
138
+
139
+ writeFileSync(CONFIG_FILE, lines.join('\n'));
140
+ ALIASES[upperCode] = name;
141
+ }
142
+
143
+ function removeAlias(code) {
144
+ if (!CONFIG_FILE) {
145
+ console.error(colors.red('Error: No config file found.'));
146
+ process.exit(1);
147
+ }
148
+
149
+ const upperCode = code.toUpperCase();
150
+ if (!ALIASES[upperCode]) {
151
+ console.error(colors.red(`Alias not found: ${code}`));
152
+ process.exit(1);
153
+ }
154
+
155
+ const content = readFileSync(CONFIG_FILE, 'utf-8');
156
+ const lines = content.split('\n');
157
+ let inAliasSection = false;
158
+
159
+ const newLines = lines.filter(line => {
160
+ const trimmed = line.trim();
161
+ if (trimmed === '[aliases]') {
162
+ inAliasSection = true;
163
+ return true;
164
+ }
165
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
166
+ inAliasSection = false;
167
+ return true;
168
+ }
169
+ if (inAliasSection && trimmed && !trimmed.startsWith('#')) {
170
+ const [key] = trimmed.split('=');
171
+ if (key.trim().toUpperCase() === upperCode) {
172
+ return false;
173
+ }
174
+ }
175
+ return true;
176
+ });
177
+
178
+ writeFileSync(CONFIG_FILE, newLines.join('\n'));
179
+ delete ALIASES[upperCode];
180
+ }
181
+
60
182
  function checkAuth() {
61
183
  if (!LINEAR_API_KEY) {
62
184
  console.error(colors.red("Error: Not logged in. Run 'linear login' first."));
@@ -132,16 +254,27 @@ function openBrowser(url) {
132
254
  exec(cmd);
133
255
  }
134
256
 
257
+ // Strip ANSI escape codes for length calculation
258
+ function stripAnsi(str) {
259
+ return str.replace(/\x1b\[[0-9;]*m/g, '');
260
+ }
261
+
135
262
  function formatTable(rows) {
136
263
  if (rows.length === 0) return '';
137
264
  const colWidths = [];
138
265
  for (const row of rows) {
139
266
  row.forEach((cell, i) => {
140
- colWidths[i] = Math.max(colWidths[i] || 0, String(cell).length);
267
+ // Use visible length (without ANSI codes) for width calculation
268
+ colWidths[i] = Math.max(colWidths[i] || 0, stripAnsi(String(cell)).length);
141
269
  });
142
270
  }
143
271
  return rows.map(row =>
144
- row.map((cell, i) => String(cell).padEnd(colWidths[i])).join(' ')
272
+ row.map((cell, i) => {
273
+ const str = String(cell);
274
+ const visibleLen = stripAnsi(str).length;
275
+ // Pad based on visible length, not string length
276
+ return str + ' '.repeat(Math.max(0, colWidths[i] - visibleLen));
277
+ }).join(' ')
145
278
  ).join('\n');
146
279
  }
147
280
 
@@ -187,6 +320,7 @@ async function cmdIssues(args) {
187
320
  milestone: 'string',
188
321
  state: 'string', s: 'string',
189
322
  label: 'string', l: 'string',
323
+ priority: 'string',
190
324
  });
191
325
 
192
326
  const inProgress = opts['in-progress'];
@@ -199,6 +333,7 @@ async function cmdIssues(args) {
199
333
  const milestoneFilter = opts.milestone;
200
334
  const stateFilter = opts.state || opts.s;
201
335
  const labelFilter = opts.label || opts.l;
336
+ const priorityFilter = (opts.priority || '').toLowerCase();
202
337
 
203
338
  // Get current user ID for filtering/sorting
204
339
  const viewerResult = await gql('{ viewer { id } }');
@@ -234,26 +369,35 @@ async function cmdIssues(args) {
234
369
  // Check if any issues have assignees (to decide whether to show column)
235
370
  const hasAssignees = issues.some(i => i.assignee);
236
371
 
237
- // Sort: assigned to you first, then by priority, then by sortOrder
372
+ // Sort: assigned to you first, then by priority (urgent first), then by sortOrder
238
373
  issues.sort((a, b) => {
239
374
  const aIsMine = a.assignee?.id === viewerId;
240
375
  const bIsMine = b.assignee?.id === viewerId;
241
376
  if (aIsMine && !bIsMine) return -1;
242
377
  if (!aIsMine && bIsMine) return 1;
243
- // Then by priority (higher = more urgent)
244
- if ((b.priority || 0) !== (a.priority || 0)) return (b.priority || 0) - (a.priority || 0);
378
+ // Then by priority (lower number = more urgent, but 0 means no priority so sort last)
379
+ const aPri = a.priority || 5; // No priority (0) sorts after Low (4)
380
+ const bPri = b.priority || 5;
381
+ if (aPri !== bPri) return aPri - bPri;
245
382
  // Then by sortOrder
246
383
  return (b.sortOrder || 0) - (a.sortOrder || 0);
247
384
  });
248
385
 
386
+ // Check if any issues have priority set
387
+ const hasPriority = issues.some(i => i.priority > 0);
388
+
249
389
  // Helper to format issue row
250
390
  const formatRow = (i) => {
251
391
  const row = [
252
392
  i.identifier,
253
393
  i.title,
254
394
  i.state.name,
255
- i.project?.name || '-'
256
395
  ];
396
+ if (hasPriority) {
397
+ const pri = PRIORITY_LABELS[i.priority] || '';
398
+ row.push(pri ? colors.bold(pri) : '-');
399
+ }
400
+ row.push(i.project?.name || '-');
257
401
  if (hasAssignees) {
258
402
  const assignee = i.assignee?.id === viewerId ? 'you' : (i.assignee?.name || '-');
259
403
  row.push(assignee);
@@ -273,15 +417,23 @@ async function cmdIssues(args) {
273
417
  );
274
418
  }
275
419
  if (projectFilter) {
420
+ const resolvedProject = resolveAlias(projectFilter);
276
421
  filtered = filtered.filter(i =>
277
- i.project?.name?.toLowerCase().includes(projectFilter.toLowerCase())
422
+ i.project?.name?.toLowerCase().includes(resolvedProject.toLowerCase())
278
423
  );
279
424
  }
280
425
  if (milestoneFilter) {
426
+ const resolvedMilestone = resolveAlias(milestoneFilter);
281
427
  filtered = filtered.filter(i =>
282
- i.projectMilestone?.name?.toLowerCase().includes(milestoneFilter.toLowerCase())
428
+ i.projectMilestone?.name?.toLowerCase().includes(resolvedMilestone.toLowerCase())
283
429
  );
284
430
  }
431
+ if (priorityFilter) {
432
+ const targetPriority = PRIORITY_MAP[priorityFilter];
433
+ if (targetPriority !== undefined) {
434
+ filtered = filtered.filter(i => i.priority === targetPriority);
435
+ }
436
+ }
285
437
  return filtered;
286
438
  };
287
439
 
@@ -497,6 +649,24 @@ const ESTIMATE_MAP = {
497
649
  'xl': 5,
498
650
  };
499
651
 
652
+ // Linear priority values (lower number = higher priority)
653
+ // 0 = No priority, 1 = Urgent, 2 = High, 3 = Medium, 4 = Low
654
+ const PRIORITY_LABELS = {
655
+ 0: '',
656
+ 1: 'Urgent',
657
+ 2: 'High',
658
+ 3: 'Medium',
659
+ 4: 'Low',
660
+ };
661
+
662
+ const PRIORITY_MAP = {
663
+ 'urgent': 1,
664
+ 'high': 2,
665
+ 'medium': 3,
666
+ 'low': 4,
667
+ 'none': 0,
668
+ };
669
+
500
670
  async function cmdIssueCreate(args) {
501
671
  const opts = parseArgs(args, {
502
672
  title: 'string', t: 'string',
@@ -507,6 +677,7 @@ async function cmdIssueCreate(args) {
507
677
  state: 'string', s: 'string',
508
678
  assign: 'boolean',
509
679
  estimate: 'string', e: 'string',
680
+ priority: 'string',
510
681
  label: 'string', l: 'string',
511
682
  blocks: 'string',
512
683
  'blocked-by': 'string',
@@ -514,8 +685,9 @@ async function cmdIssueCreate(args) {
514
685
 
515
686
  const title = opts.title || opts.t || opts._[0];
516
687
  const description = opts.description || opts.d || '';
517
- const project = opts.project || opts.p;
518
- const milestone = opts.milestone;
688
+ const project = resolveAlias(opts.project || opts.p);
689
+ const priority = (opts.priority || '').toLowerCase();
690
+ const milestone = resolveAlias(opts.milestone);
519
691
  const parent = opts.parent;
520
692
  const shouldAssign = opts.assign;
521
693
  const estimate = (opts.estimate || opts.e || '').toLowerCase();
@@ -525,7 +697,7 @@ async function cmdIssueCreate(args) {
525
697
 
526
698
  if (!title) {
527
699
  console.error(colors.red('Error: Title is required'));
528
- console.error('Usage: linear issue create --title "Issue title" [--project "..."] [--milestone "..."] [--parent ISSUE-X] [--estimate M] [--assign] [--label bug] [--blocks ISSUE-X] [--blocked-by ISSUE-X]');
700
+ console.error('Usage: linear issue create --title "Issue title" [--project "..."] [--milestone "..."] [--parent ISSUE-X] [--estimate M] [--priority urgent] [--assign] [--label bug] [--blocks ISSUE-X] [--blocked-by ISSUE-X]');
529
701
  process.exit(1);
530
702
  }
531
703
 
@@ -535,6 +707,12 @@ async function cmdIssueCreate(args) {
535
707
  process.exit(1);
536
708
  }
537
709
 
710
+ // Validate priority
711
+ if (priority && !PRIORITY_MAP.hasOwnProperty(priority)) {
712
+ console.error(colors.red(`Error: Invalid priority "${priority}". Use: urgent, high, medium, low, or none`));
713
+ process.exit(1);
714
+ }
715
+
538
716
  // Get team UUID (required for mutations)
539
717
  const teamResult = await gql(`{ team(id: "${TEAM_KEY}") { id } }`);
540
718
  const teamId = teamResult.data?.team?.id;
@@ -626,6 +804,7 @@ async function cmdIssueCreate(args) {
626
804
  if (parent) input.parentId = parent;
627
805
  if (assigneeId) input.assigneeId = assigneeId;
628
806
  if (estimate) input.estimate = ESTIMATE_MAP[estimate];
807
+ if (priority) input.priority = PRIORITY_MAP[priority];
629
808
  if (labelIds.length > 0) input.labelIds = labelIds;
630
809
 
631
810
  const result = await gql(mutation, { input });
@@ -633,7 +812,8 @@ async function cmdIssueCreate(args) {
633
812
  if (result.data?.issueCreate?.success) {
634
813
  const issue = result.data.issueCreate.issue;
635
814
  const estLabel = estimate ? ` [${estimate.toUpperCase()}]` : '';
636
- console.log(colors.green(`Created: ${issue.identifier}${estLabel}`));
815
+ const priLabel = priority && priority !== 'none' ? ` [${priority.charAt(0).toUpperCase() + priority.slice(1)}]` : '';
816
+ console.log(colors.green(`Created: ${issue.identifier}${estLabel}${priLabel}`));
637
817
  console.log(issue.url);
638
818
 
639
819
  // Create blocking relations if specified
@@ -680,19 +860,32 @@ async function cmdIssueUpdate(args) {
680
860
  state: 'string', s: 'string',
681
861
  project: 'string', p: 'string',
682
862
  milestone: 'string',
863
+ priority: 'string',
683
864
  append: 'string', a: 'string',
865
+ check: 'string',
866
+ uncheck: 'string',
684
867
  blocks: 'string',
685
868
  'blocked-by': 'string',
686
869
  });
687
870
 
688
871
  const blocksIssue = opts.blocks;
689
872
  const blockedByIssue = opts['blocked-by'];
690
- const projectName = opts.project || opts.p;
691
- const milestoneName = opts.milestone;
873
+ const projectName = resolveAlias(opts.project || opts.p);
874
+ const milestoneName = resolveAlias(opts.milestone);
875
+ const priorityName = (opts.priority || '').toLowerCase();
692
876
  const input = {};
693
877
 
694
878
  if (opts.title || opts.t) input.title = opts.title || opts.t;
695
879
 
880
+ // Handle priority
881
+ if (priorityName) {
882
+ if (!PRIORITY_MAP.hasOwnProperty(priorityName)) {
883
+ console.error(colors.red(`Error: Invalid priority "${priorityName}". Use: urgent, high, medium, low, or none`));
884
+ process.exit(1);
885
+ }
886
+ input.priority = PRIORITY_MAP[priorityName];
887
+ }
888
+
696
889
  // Handle append
697
890
  if (opts.append || opts.a) {
698
891
  const currentResult = await gql(`{ issue(id: "${issueId}") { description } }`);
@@ -702,6 +895,70 @@ async function cmdIssueUpdate(args) {
702
895
  input.description = opts.description || opts.d;
703
896
  }
704
897
 
898
+ // Handle check/uncheck
899
+ const checkText = opts.check;
900
+ const uncheckText = opts.uncheck;
901
+ if (checkText || uncheckText) {
902
+ const isCheck = !!checkText;
903
+ const query = checkText || uncheckText;
904
+ const fromPattern = isCheck ? /- \[ \] / : /- \[x\] /i;
905
+ const toMark = isCheck ? '- [x] ' : '- [ ] ';
906
+ const verb = isCheck ? 'Checked' : 'Unchecked';
907
+
908
+ // Fetch current description if we haven't already
909
+ let desc = input.description;
910
+ if (!desc) {
911
+ const currentResult = await gql(`{ issue(id: "${issueId}") { description } }`);
912
+ desc = currentResult.data?.issue?.description || '';
913
+ }
914
+
915
+ const lines = desc.split('\n');
916
+ const checkboxLines = lines
917
+ .map((line, i) => ({ line, index: i }))
918
+ .filter(({ line }) => fromPattern.test(line));
919
+
920
+ if (checkboxLines.length === 0) {
921
+ console.error(colors.red(`Error: No ${isCheck ? 'unchecked' : 'checked'} items found in description`));
922
+ process.exit(1);
923
+ }
924
+
925
+ // Find best match: score each checkbox line by similarity to query
926
+ const queryLower = query.toLowerCase();
927
+ let bestMatch = null;
928
+ let bestScore = 0;
929
+
930
+ for (const { line, index } of checkboxLines) {
931
+ const text = line.replace(/- \[[ x]\] /i, '').toLowerCase();
932
+ // Exact match
933
+ if (text === queryLower) { bestMatch = { line, index }; bestScore = Infinity; break; }
934
+ // Substring match
935
+ if (text.includes(queryLower) || queryLower.includes(text)) {
936
+ const score = queryLower.length / Math.max(text.length, queryLower.length);
937
+ if (score > bestScore) { bestScore = score; bestMatch = { line, index }; }
938
+ } else {
939
+ // Word overlap scoring
940
+ const queryWords = queryLower.split(/\s+/);
941
+ const textWords = text.split(/\s+/);
942
+ const overlap = queryWords.filter(w => textWords.some(tw => tw.includes(w) || w.includes(tw))).length;
943
+ const score = overlap / Math.max(queryWords.length, textWords.length);
944
+ if (score > bestScore) { bestScore = score; bestMatch = { line, index }; }
945
+ }
946
+ }
947
+
948
+ if (!bestMatch || bestScore < 0.3) {
949
+ console.error(colors.red(`Error: No checkbox matching "${query}"`));
950
+ console.error('Available items:');
951
+ checkboxLines.forEach(({ line }) => console.error(' ' + line.trim()));
952
+ process.exit(1);
953
+ }
954
+
955
+ lines[bestMatch.index] = bestMatch.line.replace(fromPattern, toMark);
956
+ input.description = lines.join('\n');
957
+
958
+ const itemText = bestMatch.line.replace(/- \[[ x]\] /i, '').trim();
959
+ console.log(colors.green(`${verb}: ${itemText}`));
960
+ }
961
+
705
962
  // Handle state
706
963
  if (opts.state || opts.s) {
707
964
  const stateName = opts.state || opts.s;
@@ -953,21 +1210,38 @@ async function cmdProjects(args) {
953
1210
  projects = projects.filter(p => !['completed', 'canceled'].includes(p.state));
954
1211
  }
955
1212
 
1213
+ // Find alias for a project (name must start with alias target)
1214
+ const findAliasFor = (name) => {
1215
+ const lowerName = name.toLowerCase();
1216
+ let bestMatch = null;
1217
+ let bestLength = 0;
1218
+ for (const [code, aliasName] of Object.entries(ALIASES)) {
1219
+ const lowerAlias = aliasName.toLowerCase();
1220
+ // Name must start with the alias target, and prefer longer matches
1221
+ if (lowerName.startsWith(lowerAlias) && lowerAlias.length > bestLength) {
1222
+ bestMatch = code;
1223
+ bestLength = lowerAlias.length;
1224
+ }
1225
+ }
1226
+ return bestMatch;
1227
+ };
1228
+
956
1229
  console.log(colors.bold('Projects:\n'));
957
- const rows = projects.map(p => [
958
- p.name,
959
- p.state,
960
- `${Math.floor(p.progress * 100)}%`
961
- ]);
1230
+ const rows = projects.map(p => {
1231
+ const alias = findAliasFor(p.name);
1232
+ const nameCol = alias ? `${colors.bold(`[${alias}]`)} ${p.name}` : p.name;
1233
+ return [nameCol, p.state, `${Math.floor(p.progress * 100)}%`];
1234
+ });
962
1235
  console.log(formatTable(rows));
963
1236
  }
964
1237
 
965
1238
  async function cmdProjectShow(args) {
966
- const projectName = args[0];
967
- if (!projectName) {
1239
+ const projectNameArg = args[0];
1240
+ if (!projectNameArg) {
968
1241
  console.error(colors.red('Error: Project name required'));
969
1242
  process.exit(1);
970
1243
  }
1244
+ const projectName = resolveAlias(projectNameArg);
971
1245
 
972
1246
  const query = `{
973
1247
  team(id: "${TEAM_KEY}") {
@@ -982,7 +1256,7 @@ async function cmdProjectShow(args) {
982
1256
 
983
1257
  const result = await gql(query);
984
1258
  const projects = result.data?.team?.projects?.nodes || [];
985
- const project = projects.find(p => p.name.includes(projectName));
1259
+ const project = projects.find(p => p.name.toLowerCase().includes(projectName.toLowerCase()));
986
1260
 
987
1261
  if (!project) {
988
1262
  console.error(colors.red(`Project not found: ${projectName}`));
@@ -1129,34 +1403,58 @@ async function cmdMilestones(args) {
1129
1403
  projects = projects.filter(p => !['completed', 'canceled'].includes(p.state));
1130
1404
  }
1131
1405
 
1132
- // Filter by project name if specified
1406
+ // Filter by project name if specified (resolve alias first)
1133
1407
  if (projectFilter) {
1134
- projects = projects.filter(p => p.name.toLowerCase().includes(projectFilter.toLowerCase()));
1408
+ const resolvedFilter = resolveAlias(projectFilter);
1409
+ projects = projects.filter(p => p.name.toLowerCase().includes(resolvedFilter.toLowerCase()));
1135
1410
  }
1136
1411
 
1412
+ // Find alias for a name (name must start with alias target)
1413
+ const findAliasFor = (name) => {
1414
+ const lowerName = name.toLowerCase();
1415
+ let bestMatch = null;
1416
+ let bestLength = 0;
1417
+ for (const [code, aliasName] of Object.entries(ALIASES)) {
1418
+ const lowerAlias = aliasName.toLowerCase();
1419
+ // Name must start with the alias target, and prefer longer matches
1420
+ if (lowerName.startsWith(lowerAlias) && lowerAlias.length > bestLength) {
1421
+ bestMatch = code;
1422
+ bestLength = lowerAlias.length;
1423
+ }
1424
+ }
1425
+ return bestMatch;
1426
+ };
1427
+
1137
1428
  console.log(colors.bold('Milestones:\n'));
1138
1429
  for (const project of projects) {
1139
1430
  const milestones = project.projectMilestones?.nodes || [];
1140
1431
  if (milestones.length === 0) continue;
1141
1432
 
1142
- console.log(colors.bold(project.name));
1433
+ const projectAlias = findAliasFor(project.name);
1434
+ const projectHeader = projectAlias
1435
+ ? `${colors.bold(`[${projectAlias}]`)} ${colors.bold(project.name)}`
1436
+ : colors.bold(project.name);
1437
+ console.log(projectHeader);
1143
1438
  for (const m of milestones) {
1439
+ const milestoneAlias = findAliasFor(m.name);
1440
+ const namePrefix = milestoneAlias ? `${colors.bold(`[${milestoneAlias}]`)} ` : '';
1144
1441
  const date = m.targetDate ? ` (${m.targetDate})` : '';
1145
1442
  const status = m.status !== 'planned' ? ` [${m.status}]` : '';
1146
- console.log(` ${m.name}${date}${status}`);
1443
+ console.log(` ${namePrefix}${m.name}${date}${status}`);
1147
1444
  }
1148
1445
  console.log('');
1149
1446
  }
1150
1447
  }
1151
1448
 
1152
1449
  async function cmdMilestoneShow(args) {
1153
- const milestoneName = args[0];
1154
- if (!milestoneName) {
1450
+ const milestoneNameArg = args[0];
1451
+ if (!milestoneNameArg) {
1155
1452
  console.error(colors.red('Error: Milestone name required'));
1156
1453
  process.exit(1);
1157
1454
  }
1455
+ const milestoneName = resolveAlias(milestoneNameArg);
1158
1456
 
1159
- const query = `{
1457
+ const projectsQuery = `{
1160
1458
  team(id: "${TEAM_KEY}") {
1161
1459
  projects(first: 50) {
1162
1460
  nodes {
@@ -1164,7 +1462,6 @@ async function cmdMilestoneShow(args) {
1164
1462
  projectMilestones {
1165
1463
  nodes {
1166
1464
  id name description targetDate status sortOrder
1167
- issues { nodes { identifier title state { name type } } }
1168
1465
  }
1169
1466
  }
1170
1467
  }
@@ -1172,8 +1469,23 @@ async function cmdMilestoneShow(args) {
1172
1469
  }
1173
1470
  }`;
1174
1471
 
1175
- const result = await gql(query);
1176
- const projects = result.data?.team?.projects?.nodes || [];
1472
+ const issuesQuery = `{
1473
+ team(id: "${TEAM_KEY}") {
1474
+ issues(first: 200) {
1475
+ nodes {
1476
+ identifier title state { name type }
1477
+ projectMilestone { id }
1478
+ }
1479
+ }
1480
+ }
1481
+ }`;
1482
+
1483
+ const [projectsResult, issuesResult] = await Promise.all([
1484
+ gql(projectsQuery),
1485
+ gql(issuesQuery)
1486
+ ]);
1487
+ const projects = projectsResult.data?.team?.projects?.nodes || [];
1488
+ const allIssues = issuesResult.data?.team?.issues?.nodes || [];
1177
1489
 
1178
1490
  let milestone = null;
1179
1491
  let projectName = '';
@@ -1199,7 +1511,7 @@ async function cmdMilestoneShow(args) {
1199
1511
  if (milestone.targetDate) console.log(`Target: ${milestone.targetDate}`);
1200
1512
  if (milestone.description) console.log(`\n## Description\n${milestone.description}`);
1201
1513
 
1202
- const issues = milestone.issues?.nodes || [];
1514
+ const issues = allIssues.filter(i => i.projectMilestone?.id === milestone.id);
1203
1515
  if (issues.length > 0) {
1204
1516
  // Group by state type
1205
1517
  const done = issues.filter(i => i.state.type === 'completed');
@@ -1910,6 +2222,77 @@ async function cmdLabelCreate(args) {
1910
2222
  }
1911
2223
  }
1912
2224
 
2225
+ // ============================================================================
2226
+ // ALIASES
2227
+ // ============================================================================
2228
+
2229
+ async function cmdAlias(args) {
2230
+ const opts = parseArgs(args, {
2231
+ list: 'boolean', l: 'boolean',
2232
+ remove: 'string', r: 'string',
2233
+ });
2234
+
2235
+ const showList = opts.list || opts.l;
2236
+ const removeCode = opts.remove || opts.r;
2237
+ const code = opts._[0];
2238
+ const name = opts._[1];
2239
+
2240
+ // List aliases
2241
+ if (showList || (Object.keys(opts).length === 1 && opts._.length === 0)) {
2242
+ const aliases = Object.entries(ALIASES);
2243
+ if (aliases.length === 0) {
2244
+ console.log('No aliases defined.');
2245
+ console.log('Usage: linear alias CODE "Project or Milestone Name"');
2246
+ return;
2247
+ }
2248
+
2249
+ // Fetch projects to determine type (project vs milestone)
2250
+ const query = `{
2251
+ team(id: "${TEAM_KEY}") {
2252
+ projects(first: 50) {
2253
+ nodes { name }
2254
+ }
2255
+ }
2256
+ }`;
2257
+
2258
+ const result = await gql(query);
2259
+ const projects = result.data?.team?.projects?.nodes || [];
2260
+
2261
+ // Check if alias target matches a project (using partial match)
2262
+ const matchesProject = (target) => {
2263
+ const lowerTarget = target.toLowerCase();
2264
+ return projects.some(p => p.name.toLowerCase().includes(lowerTarget));
2265
+ };
2266
+
2267
+ console.log(colors.bold('Aliases:\n'));
2268
+ for (const [code, target] of aliases) {
2269
+ const isProject = matchesProject(target);
2270
+ const type = isProject ? colors.blue('project') : colors.yellow('milestone');
2271
+ console.log(` ${colors.bold(code)} -> ${target} (${type})`);
2272
+ }
2273
+ return;
2274
+ }
2275
+
2276
+ // Remove alias
2277
+ if (removeCode) {
2278
+ removeAlias(removeCode);
2279
+ console.log(colors.green(`Removed alias: ${removeCode.toUpperCase()}`));
2280
+ return;
2281
+ }
2282
+
2283
+ // Create/update alias
2284
+ if (!code || !name) {
2285
+ console.error(colors.red('Error: Code and name required'));
2286
+ console.error('Usage: linear alias CODE "Project or Milestone Name"');
2287
+ console.error(' linear alias --list');
2288
+ console.error(' linear alias --remove CODE');
2289
+ process.exit(1);
2290
+ }
2291
+
2292
+ saveAlias(code, name);
2293
+ console.log(colors.green(`Alias set: ${code.toUpperCase()} -> ${name}`));
2294
+ }
2295
+
1913
2296
  // ============================================================================
1914
2297
  // GIT INTEGRATION
1915
2298
  // ============================================================================
@@ -2748,6 +3131,7 @@ ISSUES:
2748
3131
  --milestone <name> Filter by milestone
2749
3132
  --state, -s <state> Filter by state
2750
3133
  --label, -l <name> Filter by label
3134
+ --priority <level> Filter by priority (urgent/high/medium/low/none)
2751
3135
  issues reorder <ids...> Reorder issues by listing IDs in order
2752
3136
 
2753
3137
  issue show <id> Show issue details with parent context
@@ -2760,6 +3144,7 @@ ISSUES:
2760
3144
  --parent <id> Parent issue (for sub-issues)
2761
3145
  --assign Assign to yourself
2762
3146
  --estimate, -e <size> Estimate: XS, S, M, L, XL
3147
+ --priority <level> Priority: urgent, high, medium, low, none
2763
3148
  --label, -l <name> Add label
2764
3149
  --blocks <id> This issue blocks another
2765
3150
  --blocked-by <id> This issue is blocked by another
@@ -2769,7 +3154,10 @@ ISSUES:
2769
3154
  --state, -s <state> New state
2770
3155
  --project, -p <name> Move to project
2771
3156
  --milestone <name> Move to milestone
3157
+ --priority <level> Set priority (urgent/high/medium/low/none)
2772
3158
  --append, -a <text> Append to description
3159
+ --check <text> Check a checkbox item (fuzzy match)
3160
+ --uncheck <text> Uncheck a checkbox item (fuzzy match)
2773
3161
  --blocks <id> Add blocking relation
2774
3162
  --blocked-by <id> Add blocked-by relation
2775
3163
  issue close <id> Mark issue as done
@@ -2816,6 +3204,15 @@ LABELS:
2816
3204
  --description, -d <desc> Label description
2817
3205
  --color, -c <hex> Label color (e.g., #FF0000)
2818
3206
 
3207
+ ALIASES:
3208
+ alias <CODE> "<name>" Create alias for project/milestone
3209
+ alias --list List all aliases
3210
+ alias --remove <CODE> Remove an alias
3211
+
3212
+ Aliases can be used anywhere a project or milestone name is accepted:
3213
+ linear issues --project LWW
3214
+ linear issue create --milestone MVP "New feature"
3215
+
2819
3216
  GIT:
2820
3217
  branch <id> Create git branch from issue (ISSUE-5-issue-title)
2821
3218
 
@@ -2838,6 +3235,10 @@ CONFIGURATION:
2838
3235
  api_key=lin_api_xxx
2839
3236
  team=ISSUE
2840
3237
 
3238
+ [aliases]
3239
+ LWW=Last-Write-Wins Support
3240
+ MVP=MVP Release
3241
+
2841
3242
  EXAMPLES:
2842
3243
  linear roadmap # See all projects and milestones
2843
3244
  linear issues --unblocked # Find workable issues
@@ -2971,6 +3372,10 @@ async function main() {
2971
3372
  }
2972
3373
  break;
2973
3374
  }
3375
+ case 'alias':
3376
+ checkAuth();
3377
+ await cmdAlias(args.slice(1));
3378
+ break;
2974
3379
  case 'branch':
2975
3380
  checkAuth();
2976
3381
  await cmdBranch(args.slice(1));
@@ -31,6 +31,45 @@ Config is loaded in order: `./.linear` → `~/.linear` → env vars
31
31
  # .linear file format
32
32
  api_key=lin_api_xxx
33
33
  team=ISSUE
34
+
35
+ [aliases]
36
+ V2=Version 2.0 Release
37
+ MVP=MVP Milestone
38
+ ```
39
+
40
+ ## Aliases
41
+
42
+ Create short codes for projects and milestones to use in commands:
43
+
44
+ ```bash
45
+ # Create aliases
46
+ linear alias V2 "Version 2.0" # For a project
47
+ linear alias MVP "MVP Milestone" # For a milestone
48
+
49
+ # List all aliases
50
+ linear alias --list
51
+
52
+ # Remove an alias
53
+ linear alias --remove MVP
54
+ ```
55
+
56
+ Use aliases anywhere a project or milestone name is accepted:
57
+
58
+ ```bash
59
+ linear issues --project V2
60
+ linear issues --milestone MVP
61
+ linear issue create --project V2 --milestone MVP "New feature"
62
+ linear milestones --project V2
63
+ ```
64
+
65
+ Aliases are shown in `linear projects` and `linear milestones` output:
66
+
67
+ ```
68
+ Projects:
69
+ [V2] Version 2.0 Release started 58%
70
+
71
+ Milestones:
72
+ [MVP] MVP Milestone [next]
34
73
  ```
35
74
 
36
75
  ## Quick Reference
@@ -53,15 +92,19 @@ linear issues --mine # Only your assigned issues
53
92
  linear issues --project "Name" # Issues in a project
54
93
  linear issues --milestone "M1" # Issues in a milestone
55
94
  linear issues --label bug # Filter by label
95
+ linear issues --priority urgent # Filter by priority (urgent/high/medium/low/none)
56
96
  # Flags can be combined: linear issues --in-progress --mine
57
97
  linear issue show ISSUE-1 # Full details with parent context
58
98
  linear issue start ISSUE-1 # Assign to you + set In Progress
59
99
  linear issue create --title "Fix bug" --project "Phase 1" --assign --estimate M
100
+ linear issue create --title "Urgent bug" --priority urgent --assign
60
101
  linear issue create --title "Task" --milestone "Beta" --estimate S
61
102
  linear issue create --title "Blocked task" --blocked-by ISSUE-1
62
103
  linear issue update ISSUE-1 --state "In Progress"
104
+ linear issue update ISSUE-1 --priority high # Set priority
63
105
  linear issue update ISSUE-1 --milestone "Beta"
64
106
  linear issue update ISSUE-1 --append "Notes..."
107
+ linear issue update ISSUE-1 --check "validation" # Check off a todo item
65
108
  linear issue update ISSUE-1 --blocks ISSUE-2 # Add blocking relation
66
109
  linear issue close ISSUE-1
67
110
  linear issue comment ISSUE-1 "Comment text"
@@ -90,6 +133,12 @@ linear issue move ISSUE-5 --before ISSUE-1 # Move single issue
90
133
  linear labels # List all labels
91
134
  linear label create "bug" --color "#FF0000"
92
135
 
136
+ # Aliases
137
+ linear alias V2 "Version 2.0" # Create alias for project/milestone
138
+ linear alias --list # List all aliases
139
+ linear alias --remove V2 # Remove alias
140
+ # Then use: linear issues --project V2
141
+
93
142
  # Git
94
143
  linear branch ISSUE-1 # Create branch: ISSUE-1-issue-title
95
144
  ```
@@ -174,6 +223,26 @@ linear issue create --title "Step 3: Add tests" --parent ISSUE-5 --estimate S
174
223
  linear issue start ISSUE-6
175
224
  ```
176
225
 
226
+ ### Checklists vs. sub-issues
227
+ Use description checklists for lightweight steps within a single issue. Use sub-issues when items need their own status, assignee, or estimate.
228
+
229
+ ```bash
230
+ # Checklist — quick implementation steps, a punch list, acceptance criteria
231
+ linear issue update ISSUE-5 --append "## TODO\n- [ ] Add validation\n- [ ] Update tests\n- [ ] Check edge cases"
232
+
233
+ # Check off completed items (fuzzy matches the item text)
234
+ linear issue update ISSUE-5 --check "validation"
235
+ linear issue update ISSUE-5 --check "tests"
236
+
237
+ # Uncheck if needed
238
+ linear issue update ISSUE-5 --uncheck "validation"
239
+
240
+ # Sub-issues — substantial, independently trackable work
241
+ linear issue create --title "Add login endpoint" --parent ISSUE-5 --estimate S
242
+ ```
243
+
244
+ Prefer checklists when the items are small and don't need independent tracking. Prefer sub-issues when you'd want to assign, estimate, or block on them individually. Use `--check` to mark items complete as you finish them.
245
+
177
246
  ### Completing work
178
247
  After finishing implementation, ask the developer if they want to close the issue:
179
248
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dabble/linear-cli",
3
- "version": "1.0.4",
3
+ "version": "1.1.0",
4
4
  "description": "Linear CLI with unblocked issue filtering, built for AI-assisted development",
5
5
  "type": "module",
6
6
  "bin": {
package/postinstall.mjs CHANGED
@@ -28,8 +28,7 @@ try {
28
28
  console.log(`\x1b[33m⚠\x1b[0m Could not install Claude files: ${err.message}`);
29
29
  }
30
30
 
31
- // Add linear permission to settings.json
32
- const PERMISSION = 'Bash(linear:*)';
31
+ // Add permissions to settings.json
33
32
  const settingsPath = join(dest, 'settings.json');
34
33
 
35
34
  try {
@@ -43,11 +42,37 @@ try {
43
42
  settings.permissions = settings.permissions || {};
44
43
  settings.permissions.allow = settings.permissions.allow || [];
45
44
 
46
- // Add permission if not already present
47
- if (!settings.permissions.allow.includes(PERMISSION)) {
48
- settings.permissions.allow.push(PERMISSION);
45
+ // Build list of permissions to add
46
+ const permissions = ['Bash(linear:*)'];
47
+
48
+ // Add Skill permissions for each skill in the skills directory
49
+ const skillsDir = join(src, 'skills');
50
+ if (existsSync(skillsDir)) {
51
+ const skills = readdirSync(skillsDir, { withFileTypes: true })
52
+ .filter((e) => e.isDirectory())
53
+ .map((e) => e.name);
54
+
55
+ for (const skill of skills) {
56
+ permissions.push(`Skill(${skill})`);
57
+ permissions.push(`Skill(${skill}:*)`);
58
+ }
59
+ }
60
+
61
+ // Add permissions if not already present
62
+ const added = [];
63
+ for (const permission of permissions) {
64
+ if (!settings.permissions.allow.includes(permission)) {
65
+ settings.permissions.allow.push(permission);
66
+ added.push(permission);
67
+ }
68
+ }
69
+
70
+ if (added.length > 0) {
49
71
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
50
- console.log(`\x1b[32m✓\x1b[0m Added ${PERMISSION} to ~/.claude/settings.json`);
72
+ console.log(`\x1b[32m✓\x1b[0m Added permissions to ~/.claude/settings.json:`);
73
+ for (const permission of added) {
74
+ console.log(` - ${permission}`);
75
+ }
51
76
  }
52
77
  } catch (err) {
53
78
  console.log(`\x1b[33m⚠\x1b[0m Could not update settings: ${err.message}`);