linear-sdk-cli 0.1.3 → 0.2.3

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.
Files changed (79) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +96 -58
  4. package/COVERAGE.md +5 -5
  5. package/MIGRATING.md +71 -69
  6. package/README.md +115 -90
  7. package/package.json +6 -7
  8. package/skills/README.md +15 -0
  9. package/skills/linear-sdk-cli/SKILL.md +27 -3
  10. package/skills/linear-sdk-cli/references/api.md +9 -9
  11. package/skills/linear-sdk-cli/references/auth.md +18 -0
  12. package/skills/linear-sdk-cli/references/commands.md +2 -2
  13. package/skills/linear-sdk-cli/references/comment.md +9 -6
  14. package/skills/linear-sdk-cli/references/config.md +9 -11
  15. package/skills/linear-sdk-cli/references/issue.md +72 -18
  16. package/skills/linear-sdk-cli/references/open.md +29 -0
  17. package/skills/linear-sdk-cli/references/team.md +16 -0
  18. package/skills/linear-sdk-cli/scripts/generate-docs.ts +6 -2
  19. package/skills/linear-sdk-cli-maintenance/SKILL.md +97 -0
  20. package/src/bin/linear.ts +16 -6
  21. package/src/cli.ts +3 -1
  22. package/src/commands/agent-session.ts +13 -5
  23. package/src/commands/api.ts +15 -9
  24. package/src/commands/attach.ts +15 -4
  25. package/src/commands/comment.ts +50 -11
  26. package/src/commands/completion.ts +2 -6
  27. package/src/commands/cycle.ts +6 -1
  28. package/src/commands/discover.ts +6 -2
  29. package/src/commands/document.ts +4 -1
  30. package/src/commands/initiative.ts +5 -1
  31. package/src/commands/issue.ts +392 -91
  32. package/src/commands/meta.ts +139 -31
  33. package/src/commands/milestone.ts +4 -1
  34. package/src/commands/notification.ts +15 -6
  35. package/src/commands/open.ts +84 -0
  36. package/src/commands/project.ts +17 -4
  37. package/src/commands/team.ts +13 -0
  38. package/src/commands/user.ts +2 -2
  39. package/src/commands/webhook.ts +12 -2
  40. package/src/config.ts +501 -184
  41. package/src/context.ts +13 -4
  42. package/src/git.ts +28 -3
  43. package/src/lib/body.ts +26 -2
  44. package/src/lib/errors.ts +33 -8
  45. package/src/lib/introspect.ts +76 -7
  46. package/src/lib/keyring.ts +51 -9
  47. package/src/lib/mentions.ts +49 -0
  48. package/src/lib/open.ts +32 -0
  49. package/src/lib/options.ts +108 -12
  50. package/src/lib/output-shapes.ts +81 -9
  51. package/src/lib/pagination.ts +50 -17
  52. package/src/lib/resolve.ts +40 -7
  53. package/src/lib/shape.ts +16 -12
  54. package/src/lib/upload.ts +3 -1
  55. package/src/output/color.ts +52 -0
  56. package/src/output/format.ts +96 -18
  57. package/src/output/markdown.ts +133 -0
  58. package/src/output/pager.ts +131 -0
  59. package/src/output/sanitize.ts +1 -1
  60. package/src/output/table.ts +7 -7
  61. package/src/services/agent-session.ts +11 -3
  62. package/src/services/attachment.ts +14 -5
  63. package/src/services/comment.ts +37 -9
  64. package/src/services/cycle.ts +11 -4
  65. package/src/services/document.ts +29 -10
  66. package/src/services/favorite.ts +15 -9
  67. package/src/services/initiative-update.ts +16 -15
  68. package/src/services/initiative.ts +15 -10
  69. package/src/services/issue.ts +263 -19
  70. package/src/services/label.ts +12 -2
  71. package/src/services/milestone.ts +8 -5
  72. package/src/services/notification.ts +16 -2
  73. package/src/services/organization.ts +23 -18
  74. package/src/services/project.ts +55 -34
  75. package/src/services/roadmap.ts +7 -6
  76. package/src/services/state.ts +9 -3
  77. package/src/services/team.ts +67 -24
  78. package/src/services/user.ts +2 -2
  79. package/src/services/webhook.ts +28 -17
@@ -14,6 +14,8 @@ import {
14
14
  readAlias,
15
15
  parseList,
16
16
  parseIntOption,
17
+ parsePriority,
18
+ collectArray,
17
19
  CYCLE_FLAG,
18
20
  CYCLE_DESC,
19
21
  suggestSubcommand,
@@ -33,27 +35,34 @@ import {
33
35
  buildPrArgs,
34
36
  } from "../git.js";
35
37
  import { execFileSync } from "node:child_process";
36
- import { CliError } from "../lib/errors.js";
38
+ import { readFileSync } from "node:fs";
39
+ import { CliError, normalizeError } from "../lib/errors.js";
40
+ import { openUrl } from "../lib/open.js";
37
41
  import type { Context } from "../context.js";
38
42
  import * as svc from "../services/issue.js";
39
43
  import * as commentSvc from "../services/comment.js";
40
- import { isSelf } from "../lib/resolve.js";
44
+ import { isSelf, normalizeIssueReference, resolveIssue } from "../lib/resolve.js";
41
45
  import type { Column } from "../output/table.js";
42
46
 
43
- /** Resolve the target issue id from an argument or the current git branch. */
44
- function requireId(idArg: string | undefined): string {
47
+ /** Resolve the target issue id from an argument/current branch, expanding `42` via the default team. */
48
+ function requireId(idArg: string | undefined, defaultTeam?: string): string {
45
49
  const id = idArg ?? currentIssueId();
46
50
  if (!id) {
47
51
  throw usageError("No issue id given and none could be inferred from the current branch.");
48
52
  }
49
- return id;
53
+ return normalizeIssueReference(id, defaultTeam);
50
54
  }
51
55
 
52
56
  const ROW_COLUMNS: Column<svc.IssueRow>[] = [
53
57
  { key: "id", header: "ID", value: (r) => r.identifier },
54
58
  // `--include-archived` mixes live, archived and trashed rows; mark the latter
55
59
  // two so they cannot pass for live. The state name alone stays under 14.
56
- { key: "state", header: "State", value: (r) => `${r.state?.name ?? ""}${lifecycleMark(r)}`, max: 26 },
60
+ {
61
+ key: "state",
62
+ header: "State",
63
+ value: (r) => `${r.state?.name ?? ""}${lifecycleMark(r)}`,
64
+ max: 26,
65
+ },
57
66
  { key: "priority", header: "Pri", value: (r) => shortPriority(r.priority) },
58
67
  { key: "assignee", header: "Assignee", value: (r) => r.assignee?.displayName ?? "—", max: 16 },
59
68
  { key: "title", header: "Title", value: (r) => r.title, max: 60 },
@@ -88,6 +97,16 @@ function lifecycleMark(r: Pick<svc.IssueRow, "trashed" | "archivedAt">): string
88
97
  return r.trashed ? " (trashed)" : r.archivedAt ? " (archived)" : "";
89
98
  }
90
99
 
100
+ /** A removed reference flag whose meaning is already this CLI's query default. */
101
+ function addNoopAllAssignees(command: Command): void {
102
+ command.addOption(
103
+ new Option(
104
+ "--all-assignees",
105
+ "accepted for compatibility (does not change the query)",
106
+ ).hideHelp(),
107
+ );
108
+ }
109
+
91
110
  /**
92
111
  * schpet/linear-cli `issue` subcommands that a migrating user may type and that
93
112
  * do not exist under that name here. Since `view` is the default subcommand,
@@ -97,7 +116,8 @@ function lifecycleMark(r: Pick<svc.IssueRow, "trashed" | "archivedAt">): string
97
116
  */
98
117
  const SCHPET_ISSUE_SUBCOMMANDS: Record<string, string> = {
99
118
  link: "Use 'linear attachment create <issue> --url <url>'.",
100
- commits: "'issue commits' is not available here (jj/git log integration is not adopted). Use 'git log --grep <ID>'.",
119
+ commits:
120
+ "'issue commits' is not available here (jj/git log integration is not adopted). Use 'git log --grep <ID>'.",
101
121
  "agent-session": "Use 'linear issue agent-session list|view <issue>'.",
102
122
  };
103
123
 
@@ -109,7 +129,16 @@ export function registerIssue(program: Command): void {
109
129
  .command("view [id]", { isDefault: true })
110
130
  .description("Show an issue (defaults to the current branch's issue)")
111
131
  .option("-w, --web", "open the issue in the browser instead of printing")
112
- .option("--comments", "include recent comments")
132
+ .option("--app", "open the issue in Linear.app instead of printing")
133
+ .option("--no-comments", "exclude comments from the output")
134
+ // The old opt-in spelling remains accepted; comments are now on by default.
135
+ .addOption(
136
+ new Option(
137
+ "--comments",
138
+ "accepted for compatibility: comments are included by default",
139
+ ).hideHelp(),
140
+ )
141
+ .option("--show-resolved-threads", "include resolved comment threads")
113
142
  .action(
114
143
  action(async (ctx: Context, opts, idArg?: string) => {
115
144
  // `view` is the default subcommand, so `linear issue lst` lands here
@@ -121,21 +150,28 @@ export function registerIssue(program: Command): void {
121
150
  const ported = SCHPET_ISSUE_SUBCOMMANDS[idArg.toLowerCase()];
122
151
  const guess = ported ? undefined : suggestSubcommand(issue, idArg);
123
152
  throw usageError(
124
- `'${idArg}' is not a valid issue id (expected e.g. TES-123 or a UUID).${
153
+ `'${idArg}' is not a valid issue id (expected e.g. TES-123, 123 with a default team, or a UUID).${
125
154
  ported ? ` ${ported}` : guess ? ` Did you mean 'linear issue ${guess}'?` : ""
126
155
  }`,
127
156
  );
128
157
  }
129
- const detail = await svc.getIssueDetail(ctx.client, requireId(idArg));
130
- if (opts.web) {
131
- await openUrl(detail.url);
158
+ if (opts.web && opts.app) throw usageError("Pass either --web or --app, not both.");
159
+ const includeComments = opts.comments !== false && !opts.web && !opts.app;
160
+ const detail = await svc.getIssueDetail(ctx.client, requireId(idArg, ctx.defaultTeam), {
161
+ includeComments,
162
+ });
163
+ if (opts.web || opts.app) {
164
+ await openUrl(detail.url, { app: opts.app === true });
132
165
  ctx.output.emit(
133
166
  { id: detail.id, identifier: detail.identifier, url: detail.url, opened: true },
134
- () => ctx.output.success(`Opened ${detail.identifier}`),
167
+ () =>
168
+ ctx.output.success(
169
+ `Opened ${detail.identifier} in ${opts.app ? "Linear.app" : "the browser"}`,
170
+ ),
135
171
  );
136
172
  return;
137
173
  }
138
- await renderIssueDetail(ctx, detail, !!opts.comments);
174
+ await renderIssueDetail(ctx, detail, includeComments, opts.showResolvedThreads === true);
139
175
  }),
140
176
  );
141
177
 
@@ -196,8 +232,12 @@ export function registerIssue(program: Command): void {
196
232
  addFilterOptions(list).addOption(
197
233
  // Not an alias of anything here — `list` already spans every state — but
198
234
  // the reference ships it, so accept it as a no-op instead of erroring.
199
- new Option("--all-states", "accepted for compatibility (list is all-states already)").hideHelp(),
235
+ new Option(
236
+ "--all-states",
237
+ "accepted for compatibility (list is all-states already)",
238
+ ).hideHelp(),
200
239
  );
240
+ addNoopAllAssignees(list);
201
241
 
202
242
  // mine --------------------------------------------------------------------
203
243
  // `list` stays general; `mine` is the opinionated "what's on my plate" view
@@ -260,6 +300,7 @@ export function registerIssue(program: Command): void {
260
300
  addFilterOptions(mine, { assignee: false }).addOption(
261
301
  new Option("--all-states", "include every workflow state, not just unstarted"),
262
302
  );
303
+ addNoopAllAssignees(mine);
263
304
 
264
305
  // search ------------------------------------------------------------------
265
306
  const search = issue
@@ -309,6 +350,7 @@ export function registerIssue(program: Command): void {
309
350
  "--search-comments",
310
351
  "match comment bodies as well as titles and descriptions",
311
352
  );
353
+ addNoopAllAssignees(search);
312
354
 
313
355
  // create ------------------------------------------------------------------
314
356
  const create = issue
@@ -321,13 +363,16 @@ export function registerIssue(program: Command): void {
321
363
  .option("--editor", "compose the description in $EDITOR")
322
364
  .option("-a, --assignee <who>", "assignee (me|email|name|id)")
323
365
  .option("-s, --state <name>", "workflow state name or type")
324
- .option("-P, --priority <0-4>", "priority", parseIntOption)
366
+ .option("-P, --priority <0-4>", "priority", parsePriority)
325
367
  .option("-l, --label <name>", "label (repeatable / comma-separated)", parseList)
326
368
  .option("-p, --project <name>", "project name or id")
327
369
  .option("--milestone <name>", "project milestone (requires --project)")
328
370
  .option(CYCLE_FLAG, CYCLE_DESC)
329
371
  .option("--estimate <n>", "estimate points", parseIntOption)
330
- .option("--parent <id>", "parent issue id (the sub-issue joins the parent's project unless --project says otherwise)")
372
+ .option(
373
+ "--parent <id>",
374
+ "parent issue id (the sub-issue joins the parent's project unless --project says otherwise)",
375
+ )
331
376
  .option("--due <date>", "due date (YYYY-MM-DD)")
332
377
  .option("--template <name|id>", "create from an issue template (the team's or a shared one)")
333
378
  .option("--no-default-template", "do not apply the team's default issue template")
@@ -380,7 +425,7 @@ export function registerIssue(program: Command): void {
380
425
  milestone: opts.milestone,
381
426
  cycle: opts.cycle,
382
427
  estimate: opts.estimate,
383
- parent: opts.parent,
428
+ parent: opts.parent ? normalizeIssueReference(opts.parent, ctx.defaultTeam) : undefined,
384
429
  dueDate: readAlias(opts, "--due", "--due-date"),
385
430
  template: opts.template,
386
431
  // Both spellings: ours, and the reference CLI's `--no-use-default-template`.
@@ -389,8 +434,9 @@ export function registerIssue(program: Command): void {
389
434
  ctx.defaultTeam,
390
435
  );
391
436
  if (!opts.start) {
392
- ctx.output.emit({ id: created.id, identifier: created.identifier, url: created.url }, () =>
393
- ctx.output.success(`Created ${created.identifier}: ${created.url}`),
437
+ ctx.output.emit(
438
+ { id: created.id, identifier: created.identifier, url: created.url },
439
+ () => ctx.output.success(`Created ${created.identifier}: ${created.url}`),
394
440
  );
395
441
  return;
396
442
  }
@@ -398,8 +444,10 @@ export function registerIssue(program: Command): void {
398
444
  // just created. An explicit --state already put it where it belongs, so
399
445
  // only the default case moves it (to the team's first `started` state).
400
446
  const moved = !opts.state;
401
- await svc.moveIssueState(ctx.client, created, { move: moved });
447
+ // Local checkout is the preflight for starting work. If git refuses
448
+ // (dirty tree, invalid ref, …), the issue remains in its existing state.
402
449
  const branchResult = isGitRepo() ? checkoutBranch(created.branchName) : undefined;
450
+ await svc.moveIssueState(ctx.client, created, { move: moved });
403
451
  ctx.output.emit(
404
452
  {
405
453
  id: created.id,
@@ -438,13 +486,14 @@ export function registerIssue(program: Command): void {
438
486
  .option("-t, --team <key>", "move the issue to another team (changes its identifier)")
439
487
  .option("-a, --assignee <who>", "assignee (me|email|name|id)")
440
488
  .option("-s, --state <name>", "workflow state name or type")
441
- .option("-P, --priority <0-4>", "priority", parseIntOption)
489
+ .option("-P, --priority <0-4>", "priority", parsePriority)
442
490
  .option("-p, --project <name>", "project name or id")
443
491
  .option("--milestone <name>", "project milestone")
444
492
  .option(CYCLE_FLAG, CYCLE_DESC)
445
493
  .option("--estimate <n>", "estimate points", parseIntOption)
446
494
  .option("--parent <id>", "parent issue id")
447
495
  .option("--due <date>", "due date (YYYY-MM-DD)")
496
+ .option("-l, --label <name>", "replace all labels (repeatable / comma-separated)", parseList)
448
497
  .option("--add-label <name>", "add a label (repeatable)", parseList)
449
498
  .option("--remove-label <name>", "remove a label (repeatable)", parseList)
450
499
  .option("--unassign", "clear the assignee")
@@ -467,7 +516,7 @@ export function registerIssue(program: Command): void {
467
516
  file: opts.descriptionFile,
468
517
  interactive: false,
469
518
  });
470
- const updated = await svc.updateIssue(ctx.client, requireId(idArg), {
519
+ const updated = await svc.updateIssue(ctx.client, requireId(idArg, ctx.defaultTeam), {
471
520
  title: opts.title,
472
521
  description,
473
522
  // Only the explicit flag moves an issue — never `ctx.defaultTeam`, or
@@ -480,25 +529,29 @@ export function registerIssue(program: Command): void {
480
529
  milestone: opts.milestone,
481
530
  cycle: opts.cycle,
482
531
  estimate: opts.estimate,
483
- parent: opts.parent,
532
+ parent: opts.parent ? normalizeIssueReference(opts.parent, ctx.defaultTeam) : undefined,
484
533
  dueDate: readAlias(opts, "--due", "--due-date"),
534
+ label: opts.label,
485
535
  addLabel: opts.addLabel,
486
536
  removeLabel: opts.removeLabel,
487
537
  unassign: opts.unassign,
488
538
  clearCycle: opts.clearCycle,
489
539
  });
490
- ctx.output.emit({ id: updated.id, identifier: updated.identifier, url: updated.url }, () => {
491
- ctx.output.success(`Updated ${updated.identifier}`);
492
- // A move renumbers the issue and Linear drops what the destination
493
- // team cannot hold. Say so once, rather than letting a script's next
494
- // `TES-42` fail with "no such issue". Verified live — see CHANGELOG.
495
- if (opts.team) {
496
- ctx.output.info(
497
- `Moved to team ${updated.identifier.split("-")[0]}: the issue is now ${updated.identifier}. ` +
498
- `Its cycle, team-scoped labels, and any project the new team is not part of do not carry over.`,
499
- );
500
- }
501
- });
540
+ ctx.output.emit(
541
+ { id: updated.id, identifier: updated.identifier, url: updated.url },
542
+ () => {
543
+ ctx.output.success(`Updated ${updated.identifier}`);
544
+ // A move renumbers the issue and Linear drops what the destination
545
+ // team cannot hold. Say so once, rather than letting a script's next
546
+ // `TES-42` fail with "no such issue". Verified live — see CHANGELOG.
547
+ if (opts.team) {
548
+ ctx.output.info(
549
+ `Moved to team ${updated.identifier.split("-")[0]}: the issue is now ${updated.identifier}. ` +
550
+ `Its cycle, team-scoped labels, and any project the new team is not part of do not carry over.`,
551
+ );
552
+ }
553
+ },
554
+ );
502
555
  }),
503
556
  );
504
557
  addAliasOption(update, "--due-date <date>", "--due");
@@ -510,7 +563,9 @@ export function registerIssue(program: Command): void {
510
563
  .action(
511
564
  action(async (ctx: Context, _opts, a?: string, b?: string) => {
512
565
  const { idArg, value } = oneOrTwo(a, b, "assignee");
513
- const updated = await svc.updateIssue(ctx.client, requireId(idArg), { assignee: value });
566
+ const updated = await svc.updateIssue(ctx.client, requireId(idArg, ctx.defaultTeam), {
567
+ assignee: value,
568
+ });
514
569
  ctx.output.emit({ id: updated.id, identifier: updated.identifier }, () =>
515
570
  ctx.output.success(`Assigned ${updated.identifier}`),
516
571
  );
@@ -523,7 +578,9 @@ export function registerIssue(program: Command): void {
523
578
  .action(
524
579
  action(async (ctx: Context, _opts, a?: string, b?: string) => {
525
580
  const { idArg, value } = oneOrTwo(a, b, "state");
526
- const updated = await svc.updateIssue(ctx.client, requireId(idArg), { state: value });
581
+ const updated = await svc.updateIssue(ctx.client, requireId(idArg, ctx.defaultTeam), {
582
+ state: value,
583
+ });
527
584
  ctx.output.emit({ id: updated.id, identifier: updated.identifier }, () =>
528
585
  ctx.output.success(`Moved ${updated.identifier} → ${value}`),
529
586
  );
@@ -539,7 +596,7 @@ export function registerIssue(program: Command): void {
539
596
  .action(
540
597
  action(async (ctx: Context, opts, idArg?: string) => {
541
598
  if (!opts.add && !opts.remove) throw usageError("Pass --add and/or --remove.");
542
- const updated = await svc.updateIssue(ctx.client, requireId(idArg), {
599
+ const updated = await svc.updateIssue(ctx.client, requireId(idArg, ctx.defaultTeam), {
543
600
  addLabel: opts.add,
544
601
  removeLabel: opts.remove,
545
602
  });
@@ -553,9 +610,14 @@ export function registerIssue(program: Command): void {
553
610
  const comment = issue
554
611
  .command("comment [id] [body]")
555
612
  .description(
556
- "Add a comment to an issue; on a matching branch, `issue comment \"<body>\"` is enough (or use the add/list/update/delete subcommands)",
613
+ 'Add a comment to an issue; on a matching branch, `issue comment "<body>"` is enough (or use the add/list/update/delete subcommands)',
557
614
  )
558
615
  .option("--body-file <path>", "read comment body from a file ('-' = stdin)")
616
+ .option(
617
+ "--mention <user>",
618
+ "prepend a real Linear mention (name, email, me, or id; repeatable)",
619
+ collectArray,
620
+ )
559
621
  .action(
560
622
  action(async (ctx: Context, opts, a?: string, b?: string) => {
561
623
  // Both operands are optional, so a lone one is ambiguous: `TES-42` is
@@ -565,10 +627,17 @@ export function registerIssue(program: Command): void {
565
627
  const { idArg, bodyArg } = idAndBody(a, b);
566
628
  // Settle the id BEFORE the editor can open, so nobody writes a comment
567
629
  // only to be told there was nowhere to put it.
568
- const id = requireId(idArg);
569
- const body = resolveBody({ arg: bodyArg, file: opts.bodyFile, interactive: ctx.isTTY });
570
- if (!body) throw usageError("No comment body provided.");
571
- const { issue: iss, comment } = await commentSvc.addComment(ctx.client, id, body);
630
+ const id = requireId(idArg, ctx.defaultTeam);
631
+ const mentions: string[] = opts.mention ?? [];
632
+ const body = resolveBody({
633
+ arg: bodyArg,
634
+ file: opts.bodyFile,
635
+ interactive: ctx.isTTY && mentions.length === 0,
636
+ });
637
+ if (!body && mentions.length === 0) throw usageError("No comment body provided.");
638
+ const { issue: iss, comment } = await commentSvc.addComment(ctx.client, id, body ?? "", {
639
+ mentions,
640
+ });
572
641
  ctx.output.emit({ id: comment?.id, issue: iss.identifier }, () =>
573
642
  ctx.output.success(`Commented on ${iss.identifier}`),
574
643
  );
@@ -587,7 +656,11 @@ export function registerIssue(program: Command): void {
587
656
  action(async (ctx: Context, _opts, idArg?: string) => {
588
657
  // Same implementation and row shape as `comment list` — TES-629: this
589
658
  // used to be a second, narrower lister with its own JSON.
590
- const comments = await commentSvc.listComments(ctx.client, requireId(idArg), ctx.limit);
659
+ const comments = await commentSvc.listComments(
660
+ ctx.client,
661
+ requireId(idArg, ctx.defaultTeam),
662
+ ctx.limit,
663
+ );
591
664
  ctx.output.list(
592
665
  comments,
593
666
  [
@@ -612,7 +685,9 @@ export function registerIssue(program: Command): void {
612
685
  // is a surprise.
613
686
  issue
614
687
  .command("start [id]")
615
- .description("Start work on an issue: check out its branch and move it to the first 'started' state")
688
+ .description(
689
+ "Start work on an issue: check out its branch and move it to the first 'started' state",
690
+ )
616
691
  .option("--state <name>", "move to this state instead of the first 'started' one")
617
692
  .option("--no-move", "do not change the state; only check out the branch")
618
693
  .addOption(new Option("--move", "accepted for compatibility: moving is the default").hideHelp())
@@ -635,14 +710,17 @@ export function registerIssue(program: Command): void {
635
710
  throw usageError("Pass either --state or --no-move, not both.");
636
711
  }
637
712
  const moved = opts.state !== undefined || opts.move !== false;
638
- const issueModel = await svc.startIssue(ctx.client, requireId(idArg), {
639
- stateInput: opts.state,
640
- move: moved,
641
- });
713
+ const issueModel = await resolveIssue(ctx.client, requireId(idArg, ctx.defaultTeam));
642
714
  let branchResult: { branch: string; created: boolean } | undefined;
715
+ // Branch first, Linear second. A checkout failure is recoverable local
716
+ // feedback and must never leave the remote issue claiming work started.
643
717
  if (opts.checkout !== false && isGitRepo()) {
644
718
  branchResult = checkoutBranch(issueModel.branchName);
645
719
  }
720
+ await svc.moveIssueState(ctx.client, issueModel, {
721
+ stateInput: opts.state,
722
+ move: moved,
723
+ });
646
724
  ctx.output.emit(
647
725
  {
648
726
  id: issueModel.id,
@@ -656,7 +734,8 @@ export function registerIssue(program: Command): void {
656
734
  ctx.output.success(
657
735
  `${branchResult.created ? "Created and checked out" : "Checked out"} ${branchResult.branch}`,
658
736
  );
659
- if (moved) ctx.output.success(`Moved ${issueModel.identifier} → ${opts.state ?? "started"}`);
737
+ if (moved)
738
+ ctx.output.success(`Moved ${issueModel.identifier} → ${opts.state ?? "started"}`);
660
739
  if (!branchResult && !moved) ctx.output.info(`Branch name: ${issueModel.branchName}`);
661
740
  },
662
741
  );
@@ -675,10 +754,14 @@ export function registerIssue(program: Command): void {
675
754
  .option("-r, --references", "link without closing: 'References <ID>' instead of 'Fixes <ID>'")
676
755
  .action(
677
756
  action(async (ctx: Context, opts, idArg?: string) => {
678
- const detail = await svc.getIssueDetail(ctx.client, requireId(idArg));
757
+ const detail = await svc.getIssueDetail(ctx.client, requireId(idArg, ctx.defaultTeam), {
758
+ includeComments: false,
759
+ });
679
760
  const references = opts.references === true;
680
761
  const trailer = buildTrailer(detail.identifier, { references });
681
- const message = buildDescription(detail.identifier, detail.title, detail.url, { references });
762
+ const message = buildDescription(detail.identifier, detail.title, detail.url, {
763
+ references,
764
+ });
682
765
  ctx.output.emit(
683
766
  {
684
767
  identifier: detail.identifier,
@@ -709,7 +792,9 @@ export function registerIssue(program: Command): void {
709
792
  if (!isGitRepo()) {
710
793
  throw usageError("`issue pr` must be run inside a git repository.");
711
794
  }
712
- const detail = await svc.getIssueDetail(ctx.client, requireId(idArg));
795
+ const detail = await svc.getIssueDetail(ctx.client, requireId(idArg, ctx.defaultTeam), {
796
+ includeComments: false,
797
+ });
713
798
  // Title `ID Title` (a custom --title is prefixed the same way), body the
714
799
  // two Linear-issue trailers — schpet/linear-cli's PR, plus the magic word
715
800
  // (`buildPrContent`). The issue's *description* is no longer copied in
@@ -750,8 +835,9 @@ export function registerIssue(program: Command): void {
750
835
  .option("--duplicate", "relation type: duplicate")
751
836
  .action(
752
837
  action(async (ctx: Context, opts, id: string, op: string, other?: string) => {
838
+ const issueId = normalizeIssueReference(id, ctx.defaultTeam);
753
839
  if (op === "list") {
754
- const rels = await svc.listRelations(ctx.client, id);
840
+ const rels = await svc.listRelations(ctx.client, issueId);
755
841
  ctx.output.list(
756
842
  rels,
757
843
  [
@@ -772,7 +858,13 @@ export function registerIssue(program: Command): void {
772
858
  : opts.duplicate
773
859
  ? "duplicate"
774
860
  : "related";
775
- const { issue: a, other: b } = await svc.addRemoveRelation(ctx.client, id, op, type as any, other);
861
+ const { issue: a, other: b } = await svc.addRemoveRelation(
862
+ ctx.client,
863
+ issueId,
864
+ op,
865
+ type as any,
866
+ normalizeIssueReference(other, ctx.defaultTeam),
867
+ );
776
868
  ctx.output.emit(
777
869
  {
778
870
  issueId: a.id,
@@ -796,7 +888,7 @@ export function registerIssue(program: Command): void {
796
888
  .description("Subscribe to an issue")
797
889
  .action(
798
890
  action(async (ctx: Context, _opts, idArg?: string) => {
799
- const iss = await svc.setSubscription(ctx.client, requireId(idArg), true);
891
+ const iss = await svc.setSubscription(ctx.client, requireId(idArg, ctx.defaultTeam), true);
800
892
  ctx.output.emit({ id: iss.id, identifier: iss.identifier, subscribed: true }, () =>
801
893
  ctx.output.success(`Subscribed to ${iss.identifier}`),
802
894
  );
@@ -807,7 +899,7 @@ export function registerIssue(program: Command): void {
807
899
  .description("Unsubscribe from an issue")
808
900
  .action(
809
901
  action(async (ctx: Context, _opts, idArg?: string) => {
810
- const iss = await svc.setSubscription(ctx.client, requireId(idArg), false);
902
+ const iss = await svc.setSubscription(ctx.client, requireId(idArg, ctx.defaultTeam), false);
811
903
  ctx.output.emit({ id: iss.id, identifier: iss.identifier, subscribed: false }, () =>
812
904
  ctx.output.success(`Unsubscribed from ${iss.identifier}`),
813
905
  );
@@ -818,13 +910,13 @@ export function registerIssue(program: Command): void {
818
910
  issue
819
911
  .command("archive [id]")
820
912
  .description("Archive an issue")
913
+ .option("--bulk <ids>", "archive comma-separated issue ids (repeatable)", parseList)
914
+ .option("--bulk-file <path>", "archive issue ids from a file (one per line)")
915
+ .option("--bulk-stdin", "archive issue ids read from stdin (one per line)")
821
916
  .action(
822
- action(async (ctx: Context, _opts, idArg?: string) => {
823
- const id = requireId(idArg);
824
- if (!(await confirmDestructive(ctx, `Archive issue ${id}?`))) return;
825
- const iss = await svc.archiveIssue(ctx.client, id, false);
826
- ctx.output.emit({ id: iss.id, identifier: iss.identifier, archived: true }, () =>
827
- ctx.output.success(`Archived ${iss.identifier}`),
917
+ action(async (ctx: Context, opts, idArg?: string) => {
918
+ await runBulkIssueMutation(ctx, opts, idArg, "archive", (id) =>
919
+ svc.archiveIssue(ctx.client, id, false),
828
920
  );
829
921
  }),
830
922
  );
@@ -833,7 +925,7 @@ export function registerIssue(program: Command): void {
833
925
  .description("Unarchive an issue")
834
926
  .action(
835
927
  action(async (ctx: Context, _opts, idArg?: string) => {
836
- const iss = await svc.archiveIssue(ctx.client, requireId(idArg), true);
928
+ const iss = await svc.archiveIssue(ctx.client, requireId(idArg, ctx.defaultTeam), true);
837
929
  ctx.output.emit({ id: iss.id, identifier: iss.identifier, archived: false }, () =>
838
930
  ctx.output.success(`Unarchived ${iss.identifier}`),
839
931
  );
@@ -843,13 +935,13 @@ export function registerIssue(program: Command): void {
843
935
  .command("delete [id]")
844
936
  .alias("rm")
845
937
  .description("Delete (trash) an issue")
938
+ .option("--bulk <ids>", "delete comma-separated issue ids (repeatable)", parseList)
939
+ .option("--bulk-file <path>", "delete issue ids from a file (one per line)")
940
+ .option("--bulk-stdin", "delete issue ids read from stdin (one per line)")
846
941
  .action(
847
- action(async (ctx: Context, _opts, idArg?: string) => {
848
- const id = requireId(idArg);
849
- if (!(await confirmDestructive(ctx, `Delete issue ${id}?`))) return;
850
- const iss = await svc.deleteIssue(ctx.client, id);
851
- ctx.output.emit({ id: iss.id, identifier: iss.identifier, deleted: true }, () =>
852
- ctx.output.success(`Deleted ${iss.identifier}`),
942
+ action(async (ctx: Context, opts, idArg?: string) => {
943
+ await runBulkIssueMutation(ctx, opts, idArg, "delete", (id) =>
944
+ svc.deleteIssue(ctx.client, id),
853
945
  );
854
946
  }),
855
947
  );
@@ -861,6 +953,115 @@ export function registerIssue(program: Command): void {
861
953
  registerScalar(issue, "branch", "Print the suggested git branch name", (d) => d.branchName);
862
954
  }
863
955
 
956
+ type BulkAction = "archive" | "delete";
957
+ type BulkIssueResult = {
958
+ input: string;
959
+ id?: string;
960
+ identifier?: string;
961
+ archived?: boolean;
962
+ deleted?: boolean;
963
+ error?: { message: string; code: string };
964
+ };
965
+
966
+ /**
967
+ * Collect issue ids from one explicit source. A positional id remains the
968
+ * ergonomic path for one issue (and can still be inferred from the branch),
969
+ * while bulk sources intentionally never consult the branch: an empty input
970
+ * must not accidentally mutate the current issue.
971
+ */
972
+ function bulkIssueIds(
973
+ opts: Record<string, unknown>,
974
+ idArg: string | undefined,
975
+ ): string[] | undefined {
976
+ const sources = [
977
+ Array.isArray(opts.bulk) && opts.bulk.length > 0 ? "--bulk" : undefined,
978
+ typeof opts.bulkFile === "string" ? "--bulk-file" : undefined,
979
+ opts.bulkStdin === true ? "--bulk-stdin" : undefined,
980
+ ].filter((v): v is string => !!v);
981
+ if (sources.length === 0) return undefined;
982
+ if (sources.length > 1 || idArg !== undefined) {
983
+ throw usageError(
984
+ "Pass either one issue id or exactly one of --bulk, --bulk-file, and --bulk-stdin.",
985
+ );
986
+ }
987
+ let values: string[];
988
+ if (sources[0] === "--bulk") values = opts.bulk as string[];
989
+ else {
990
+ const path = sources[0] === "--bulk-stdin" ? 0 : (opts.bulkFile as string);
991
+ let text: string;
992
+ try {
993
+ text = readFileSync(path, "utf8");
994
+ } catch (err) {
995
+ throw new CliError(
996
+ `Cannot read ${sources[0] === "--bulk-stdin" ? "stdin" : path}: ${(err as Error).message}`,
997
+ "runtime",
998
+ );
999
+ }
1000
+ values = text.split(/[\s,]+/).filter(Boolean);
1001
+ }
1002
+ const ids = [...new Set(values.map((value) => value.trim()).filter(Boolean))];
1003
+ if (ids.length === 0) throw usageError(`${sources[0]} did not contain any issue ids.`);
1004
+ return ids;
1005
+ }
1006
+
1007
+ async function runBulkIssueMutation(
1008
+ ctx: Context,
1009
+ opts: Record<string, unknown>,
1010
+ idArg: string | undefined,
1011
+ actionName: BulkAction,
1012
+ mutate: (id: string) => Promise<{ id: string; identifier: string }>,
1013
+ ): Promise<void> {
1014
+ const bulk = bulkIssueIds(opts, idArg);
1015
+ const ids = bulk ?? [requireId(idArg, ctx.defaultTeam)];
1016
+ const single = bulk === undefined;
1017
+ const noun = actionName === "archive" ? "Archive" : "Delete";
1018
+ const message = single ? `${noun} issue ${ids[0]}?` : `${noun} ${ids.length} issues?`;
1019
+ if (!(await confirmDestructive(ctx, message))) return;
1020
+
1021
+ const results: BulkIssueResult[] = [];
1022
+ for (const input of ids) {
1023
+ const id = normalizeIssueReference(input, ctx.defaultTeam);
1024
+ try {
1025
+ const issue = await mutate(id);
1026
+ results.push({
1027
+ input,
1028
+ id: issue.id,
1029
+ identifier: issue.identifier,
1030
+ [actionName === "archive" ? "archived" : "deleted"]: true,
1031
+ });
1032
+ } catch (err) {
1033
+ const normalized = normalizeError(err);
1034
+ results.push({ input, error: { message: normalized.message, code: normalized.code } });
1035
+ }
1036
+ }
1037
+
1038
+ if (single) {
1039
+ const result = results[0]!;
1040
+ if (result.error)
1041
+ throw new CliError(result.error.message, result.error.code as CliError["code"]);
1042
+ ctx.output.emit(
1043
+ actionName === "archive"
1044
+ ? { id: result.id!, identifier: result.identifier!, archived: true }
1045
+ : { id: result.id!, identifier: result.identifier!, deleted: true },
1046
+ () => ctx.output.success(`${noun}d ${result.identifier}`),
1047
+ );
1048
+ return;
1049
+ }
1050
+
1051
+ const failures = results.filter((result) => result.error).length;
1052
+ ctx.output.emit(
1053
+ { action: actionName, results, succeeded: results.length - failures, failed: failures },
1054
+ () => {
1055
+ for (const result of results) {
1056
+ if (result.error) ctx.output.warn(`${result.input}: ${result.error.message}`);
1057
+ else ctx.output.success(`${noun}d ${result.identifier}`);
1058
+ }
1059
+ ctx.output.info(`${results.length - failures}/${results.length} issues ${actionName}d.`);
1060
+ },
1061
+ );
1062
+ if (failures > 0) process.exitCode = 1;
1063
+ }
1064
+
864
1065
  /**
865
1066
  * Render a single issue's detail block. Shared by `issue view` and the bare
866
1067
  * `linear` command so `linear --json` === `issue view <id> --json`.
@@ -869,14 +1070,20 @@ export async function renderIssueDetail(
869
1070
  ctx: Context,
870
1071
  detail: svc.IssueDetail,
871
1072
  includeComments: boolean,
1073
+ showResolvedThreads = false,
872
1074
  ): Promise<void> {
873
- const comments = includeComments ? await commentSvc.listComments(ctx.client, detail.id, 10) : [];
874
1075
  const { cycle, team } = detail;
875
- ctx.output.detail({ ...detail, comments: includeComments ? comments : undefined }, [
1076
+ const commentPairs = includeComments
1077
+ ? issueCommentPairs(detail.comments, showResolvedThreads)
1078
+ : [];
1079
+ ctx.output.detail(detail, [
876
1080
  ["Issue", `${detail.identifier} ${detail.title}`],
877
1081
  // A deleted issue used to view exactly like a live one. Say so first, and
878
1082
  // in capitals: an agent that deletes and re-reads must see the change.
879
- ["Trashed", detail.trashed ? `YES (deleted ${detail.archivedAt ?? "at an unknown time"})` : null],
1083
+ [
1084
+ "Trashed",
1085
+ detail.trashed ? `YES (deleted ${detail.archivedAt ?? "at an unknown time"})` : null,
1086
+ ],
880
1087
  ["Archived", !detail.trashed && detail.archivedAt ? `YES (${detail.archivedAt})` : null],
881
1088
  ["State", detail.state?.name ?? null],
882
1089
  ["Priority", detail.priorityLabel],
@@ -885,21 +1092,109 @@ export async function renderIssueDetail(
885
1092
  ["Project", detail.project?.name ?? null],
886
1093
  ["Milestone", detail.milestone?.name ?? null],
887
1094
  ["Cycle", cycle ? `#${cycle.number}${cycle.name ? ` ${cycle.name}` : ""}` : null],
888
- ["Parent", detail.parent?.identifier ?? null],
1095
+ ["Parent", detail.parent ? issueRefLabel(detail.parent) : null],
1096
+ [
1097
+ "Sub-issues",
1098
+ detail.children.length ? `\n${detail.children.map(issueRefLabel).join("\n")}` : null,
1099
+ ],
889
1100
  ["Estimate", detail.estimate],
890
1101
  ["Labels", detail.labels.length ? detail.labels.map((l) => l.name).join(", ") : null],
1102
+ [
1103
+ "Attachments",
1104
+ detail.attachments.length ? `\n${detail.attachments.map(attachmentLabel).join("\n")}` : null,
1105
+ ],
1106
+ [
1107
+ "Documents",
1108
+ detail.documents.length
1109
+ ? `\n${detail.documents.map((d) => `${d.title}: ${d.url}`).join("\n")}`
1110
+ : null,
1111
+ ],
1112
+ ["Relations", relationLabels(detail)],
891
1113
  ["Due", detail.dueDate],
892
1114
  ["URL", detail.url],
893
1115
  ["Updated", detail.updatedAt],
894
1116
  ["Description", detail.description ? `\n${detail.description}` : null],
895
- ...(includeComments
896
- ? comments.map(
897
- (c) => [c.createdAt.slice(0, 10) + " " + (c.user?.displayName ?? "—"), c.body] as [string, unknown],
898
- )
899
- : []),
1117
+ ...commentPairs,
900
1118
  ]);
901
1119
  }
902
1120
 
1121
+ function issueRefLabel(issue: svc.IssueDetailRef): string {
1122
+ return `${issue.identifier} ${issue.title}${issue.state ? ` [${issue.state.name}]` : ""}`;
1123
+ }
1124
+
1125
+ function attachmentLabel(attachment: svc.IssueAttachmentDetail): string {
1126
+ const source = attachment.sourceType ? ` [${attachment.sourceType}]` : "";
1127
+ const subtitle = attachment.subtitle ? ` — ${attachment.subtitle}` : "";
1128
+ return `${attachment.title}: ${attachment.url}${source}${subtitle}`;
1129
+ }
1130
+
1131
+ function relationLabels(detail: svc.IssueDetail): string | null {
1132
+ const lines = [
1133
+ ...detail.relations.map(
1134
+ (r) => `${relationVerb(r.type, false)} ${issueRefLabel(r.relatedIssue)}`,
1135
+ ),
1136
+ ...detail.inverseRelations.map(
1137
+ (r) => `${relationVerb(r.type, true)} ${issueRefLabel(r.issue)}`,
1138
+ ),
1139
+ ];
1140
+ return lines.length ? `\n${lines.join("\n")}` : null;
1141
+ }
1142
+
1143
+ function relationVerb(type: string, inverse: boolean): string {
1144
+ if (type === "blocks") return inverse ? "Blocked by" : "Blocks";
1145
+ if (type === "duplicate") return inverse ? "Duplicated by" : "Duplicates";
1146
+ return type === "related" ? "Related to" : `${inverse ? "Inverse" : "Related"} (${type})`;
1147
+ }
1148
+
1149
+ /** Human comment rows grouped as threads, hiding resolved roots unless requested. */
1150
+ function issueCommentPairs(
1151
+ comments: svc.IssueCommentDetail[],
1152
+ showResolvedThreads: boolean,
1153
+ ): Array<[string, unknown]> {
1154
+ const byId = new Map(comments.map((comment) => [comment.id, comment]));
1155
+ const rootId = (comment: svc.IssueCommentDetail): string => {
1156
+ const seen = new Set<string>();
1157
+ let current = comment;
1158
+ while (current.parent && !seen.has(current.id)) {
1159
+ seen.add(current.id);
1160
+ const parent = byId.get(current.parent.id);
1161
+ if (!parent) break;
1162
+ current = parent;
1163
+ }
1164
+ return current.id;
1165
+ };
1166
+ const roots = comments.filter((comment) => !comment.parent);
1167
+ const visibleRoots = roots.filter((comment) => showResolvedThreads || !comment.resolvedAt);
1168
+ const visibleIds = new Set(visibleRoots.map((comment) => comment.id));
1169
+ const pairs: Array<[string, unknown]> = [];
1170
+
1171
+ for (const root of visibleRoots) {
1172
+ pairs.push(["Comment", commentLabel(root, false)]);
1173
+ for (const reply of comments) {
1174
+ if (reply.parent && rootId(reply) === root.id && visibleIds.has(root.id)) {
1175
+ pairs.push(["Reply", commentLabel(reply, true)]);
1176
+ }
1177
+ }
1178
+ }
1179
+ const hidden = roots.length - visibleRoots.length;
1180
+ if (hidden > 0) {
1181
+ pairs.push([
1182
+ "Comments",
1183
+ `${hidden} resolved ${hidden === 1 ? "thread" : "threads"} hidden; use --show-resolved-threads to show ${hidden === 1 ? "it" : "them"}.`,
1184
+ ]);
1185
+ }
1186
+ return pairs;
1187
+ }
1188
+
1189
+ function commentLabel(comment: svc.IssueCommentDetail, reply: boolean): string {
1190
+ const author = comment.user?.displayName ?? comment.externalUser?.displayName ?? "Unknown";
1191
+ const thread = reply ? "" : ` [thread: ${comment.id}]`;
1192
+ const resolved = comment.resolvedAt
1193
+ ? ` [resolved${comment.resolvingUser ? ` by ${comment.resolvingUser.displayName}` : ""}]`
1194
+ : "";
1195
+ return `@${author} ${comment.createdAt.slice(0, 10)}${thread}${resolved}\n${comment.body}`;
1196
+ }
1197
+
903
1198
  function registerScalar(
904
1199
  issue: Command,
905
1200
  name: string,
@@ -911,27 +1206,36 @@ function registerScalar(
911
1206
  .description(description)
912
1207
  .action(
913
1208
  action(async (ctx: Context, _opts, idArg?: string) => {
914
- const detail = await svc.getIssueDetail(ctx.client, requireId(idArg));
1209
+ const detail = await svc.getIssueDetail(ctx.client, requireId(idArg, ctx.defaultTeam), {
1210
+ includeComments: false,
1211
+ });
915
1212
  ctx.output.emit({ [name]: pick(detail) }, () => ctx.output.line(pick(detail)));
916
1213
  }),
917
1214
  );
918
1215
  }
919
1216
 
920
- const ISSUE_ID_RE = /^([a-zA-Z][a-zA-Z0-9]*-\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
1217
+ const ISSUE_ID_RE =
1218
+ /^(\d+|[a-zA-Z][a-zA-Z0-9]*-\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
921
1219
 
922
1220
  /**
923
1221
  * Disambiguate `[id] [value]` where both are optional so the issue id can be
924
1222
  * inferred from the branch. Two args → (id, value). One arg → it's the value
925
1223
  * unless it looks like an issue id (then the value is missing → usage error).
926
1224
  */
927
- function oneOrTwo(a: string | undefined, b: string | undefined, valueName: string): {
1225
+ function oneOrTwo(
1226
+ a: string | undefined,
1227
+ b: string | undefined,
1228
+ valueName: string,
1229
+ ): {
928
1230
  idArg?: string;
929
1231
  value: string;
930
1232
  } {
931
1233
  if (a !== undefined && b !== undefined) return { idArg: a, value: b };
932
1234
  if (a === undefined) throw usageError(`Missing ${valueName}.`);
933
1235
  if (ISSUE_ID_RE.test(a)) {
934
- throw usageError(`Missing ${valueName}. Usage: <id> <${valueName}> (or just <${valueName}> on a matching branch)`);
1236
+ throw usageError(
1237
+ `Missing ${valueName}. Usage: <id> <${valueName}> (or just <${valueName}> on a matching branch)`,
1238
+ );
935
1239
  }
936
1240
  return { value: a };
937
1241
  }
@@ -941,7 +1245,10 @@ function oneOrTwo(a: string | undefined, b: string | undefined, valueName: strin
941
1245
  * legitimately be absent (it can come from --body-file or $EDITOR): a lone
942
1246
  * operand that looks like an issue id IS the id; anything else is the body.
943
1247
  */
944
- function idAndBody(a: string | undefined, b: string | undefined): { idArg?: string; bodyArg?: string } {
1248
+ function idAndBody(
1249
+ a: string | undefined,
1250
+ b: string | undefined,
1251
+ ): { idArg?: string; bodyArg?: string } {
945
1252
  if (a !== undefined && b !== undefined) return { idArg: a, bodyArg: b };
946
1253
  if (a === undefined) return {};
947
1254
  return ISSUE_ID_RE.test(a) ? { idArg: a } : { bodyArg: a };
@@ -967,9 +1274,3 @@ function runGh(args: string[]): string {
967
1274
  throw new CliError(stderr || `gh exited with code ${e.status ?? 1}.`, "runtime");
968
1275
  }
969
1276
  }
970
-
971
- async function openUrl(url: string): Promise<void> {
972
- const { execFile } = await import("node:child_process");
973
- const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
974
- await new Promise<void>((resolve) => execFile(cmd, [url], () => resolve()));
975
- }