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
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Resolve terminal colour and debug policy in one place. Both the per-command
3
+ * Context and the top-level error boundary use these helpers, so a parse-time
4
+ * failure behaves exactly like an API result.
5
+ */
6
+
7
+ export interface ColorPolicy {
8
+ /** An explicit CLI opt-out (`--no-ansi` / `--no-color`). */
9
+ disabled?: boolean;
10
+ /** JSON is a byte-oriented machine format and never carries terminal ANSI. */
11
+ json?: boolean;
12
+ /** TTY state of the stream that will receive the text. */
13
+ isTTY?: boolean;
14
+ env?: NodeJS.ProcessEnv;
15
+ }
16
+
17
+ /** Values conventionally used to turn a forcing variable off. */
18
+ function isFalse(value: string): boolean {
19
+ return value === "0" || value.toLowerCase() === "false";
20
+ }
21
+
22
+ /**
23
+ * Whether human output should contain ANSI colour.
24
+ *
25
+ * Precedence is deliberately conservative:
26
+ *
27
+ * CLI opt-out / JSON > NO_COLOR > FORCE_COLOR > CLICOLOR_FORCE > TTY
28
+ *
29
+ * `FORCE_COLOR=0` is also an explicit opt-out, matching the convention used by
30
+ * Node tooling. `CLICOLOR_FORCE=0` merely means "do not force". This lets a
31
+ * caller keep colour while stdout is connected to a pager without ever
32
+ * putting ANSI into JSON.
33
+ */
34
+ export function shouldUseColor(policy: ColorPolicy): boolean {
35
+ if (policy.disabled === true || policy.json === true) return false;
36
+
37
+ const env = policy.env ?? process.env;
38
+ if (env.NO_COLOR !== undefined) return false;
39
+
40
+ if (env.FORCE_COLOR !== undefined) {
41
+ return !isFalse(env.FORCE_COLOR);
42
+ }
43
+ if (env.CLICOLOR_FORCE !== undefined && !isFalse(env.CLICOLOR_FORCE)) return true;
44
+
45
+ return policy.isTTY === true;
46
+ }
47
+
48
+ /** `--debug` and LINEAR_DEBUG are equivalent, with the flag always additive. */
49
+ export function isDebugEnabled(explicit: boolean | undefined, env = process.env): boolean {
50
+ const value = env.LINEAR_DEBUG?.toLowerCase();
51
+ return explicit === true || value === "1" || value === "true";
52
+ }
@@ -6,7 +6,7 @@
6
6
  * - lists → bare array, single resource → bare object, mutations → affected
7
7
  * object (or {success,id} when there is no body)
8
8
  * - status/progress/pagination notes → stderr
9
- * - errors → {"error":{"message","code"}} on stderr + non-zero exit
9
+ * - errors → {"error":{"message","code", …optional additive fields}} on stderr + non-zero exit
10
10
  */
11
11
 
12
12
  import pc from "picocolors";
@@ -20,6 +20,9 @@ import {
20
20
  type Column,
21
21
  } from "./table.js";
22
22
  import { sanitizeForTerminal } from "./sanitize.js";
23
+ import { renderMarkdown } from "./markdown.js";
24
+ import { pageOutput, shouldUsePager } from "./pager.js";
25
+ import { hasMoreResults } from "../lib/pagination.js";
23
26
 
24
27
  export interface OutputOptions {
25
28
  json: boolean;
@@ -28,10 +31,38 @@ export interface OutputOptions {
28
31
  debug: boolean;
29
32
  /** `--fields`: table columns / detail lines (human), top-level keys (json). */
30
33
  fields?: string[];
34
+ /** Injectable terminal facts; default to stdout's live state. */
35
+ isTTY?: boolean;
36
+ terminalRows?: number;
37
+ terminalColumns?: number;
38
+ /** Used only to resolve PAGER when long-form output is eligible. */
39
+ env?: NodeJS.ProcessEnv;
40
+ }
41
+
42
+ export interface ListOutputOptions {
43
+ /** JSON rows may be richer/differently shaped than the human table rows. */
44
+ jsonRows?: unknown[];
45
+ /** Context shown instead of the default `(no results)` in human mode. */
46
+ empty?: string;
47
+ }
48
+
49
+ export interface MarkdownOutputOptions {
50
+ /** Leave Markdown syntax intact (terminal controls are still removed). */
51
+ raw?: boolean;
52
+ /** Commands with `--no-pager` pass false. Defaults to enabled. */
53
+ pager?: boolean;
31
54
  }
32
55
 
33
56
  export class Output {
34
- constructor(private readonly opts: OutputOptions) {}
57
+ private readonly opts: OutputOptions;
58
+ private readonly colors: ReturnType<typeof pc.createColors>;
59
+
60
+ constructor(opts: OutputOptions) {
61
+ this.opts = opts;
62
+ // Do not rely on picocolors' module-load-time environment detection:
63
+ // CLICOLOR_FORCE and per-stream TTY policy are resolved by Context.
64
+ this.colors = pc.createColors(opts.color);
65
+ }
35
66
 
36
67
  get json(): boolean {
37
68
  return this.opts.json;
@@ -54,13 +85,28 @@ export class Output {
54
85
  * Emit a list as a table (human) or bare array (json). `--fields` narrows
55
86
  * both: the table's columns (or any row key), and the JSON objects' keys.
56
87
  */
57
- list<T>(rows: T[], columns: Column<T>[], jsonRows?: unknown[]): void {
88
+ list<T>(
89
+ rows: T[],
90
+ columns: Column<T>[],
91
+ jsonRowsOrOptions?: unknown[] | ListOutputOptions,
92
+ legacyOptions?: Pick<ListOutputOptions, "empty">,
93
+ ): void {
94
+ const options: ListOutputOptions = Array.isArray(jsonRowsOrOptions)
95
+ ? { jsonRows: jsonRowsOrOptions, ...legacyOptions }
96
+ : (jsonRowsOrOptions ?? {});
97
+ if (hasMoreResults(rows)) {
98
+ this.info(
99
+ `Showing ${rows.length} results; more exist. Use --all or increase --limit to see them.`,
100
+ );
101
+ }
58
102
  if (this.opts.json) {
59
- this.writeJson(projectFields(jsonRows ?? rows, this.opts.fields));
103
+ this.writeJson(projectFields(options.jsonRows ?? rows, this.opts.fields));
60
104
  return;
61
105
  }
62
106
  const cols = selectColumns(columns, this.opts.fields, rows[0]);
63
- process.stdout.write(renderTable(rows, cols, { color: this.opts.color }) + "\n");
107
+ process.stdout.write(
108
+ renderTable(rows, cols, { color: this.opts.color, empty: options.empty }) + "\n",
109
+ );
64
110
  }
65
111
 
66
112
  /**
@@ -84,6 +130,41 @@ export class Output {
84
130
  if (!this.opts.json) process.stdout.write(sanitizeForTerminal(text) + "\n");
85
131
  }
86
132
 
133
+ /**
134
+ * Render a long Markdown body for a human, optionally through a pager.
135
+ *
136
+ * Non-TTY output keeps the original Markdown so pipes/files receive stable,
137
+ * reusable text. JSON mode writes nothing — callers normally put this inside
138
+ * `emit(value, () => output.markdown(...))`, but the guard prevents an
139
+ * accidental second stdout value. Both human paths remain terminal-sanitized.
140
+ */
141
+ markdown(text: string, options: MarkdownOutputOptions = {}): void {
142
+ if (this.opts.json) return;
143
+
144
+ const isTTY = this.opts.isTTY ?? process.stdout.isTTY === true;
145
+ const columns = this.opts.terminalColumns ?? process.stdout.columns ?? 80;
146
+ const rows = this.opts.terminalRows ?? process.stdout.rows;
147
+ const content =
148
+ isTTY && options.raw !== true
149
+ ? renderMarkdown(text, { color: this.opts.color, width: columns })
150
+ : sanitizeForTerminal(text);
151
+ const output = content.endsWith("\n") ? content : `${content}\n`;
152
+
153
+ if (
154
+ shouldUsePager(output, {
155
+ enabled: options.pager,
156
+ json: this.opts.json,
157
+ isTTY,
158
+ rows,
159
+ columns,
160
+ }) &&
161
+ pageOutput(output, { env: this.opts.env })
162
+ ) {
163
+ return;
164
+ }
165
+ process.stdout.write(output);
166
+ }
167
+
87
168
  /** Status/progress to stderr. Suppressed by --quiet. */
88
169
  info(text: string): void {
89
170
  if (!this.opts.quiet) process.stderr.write(sanitizeForTerminal(text) + "\n");
@@ -91,13 +172,11 @@ export class Output {
91
172
 
92
173
  success(text: string): void {
93
174
  if (!this.opts.quiet)
94
- process.stderr.write(
95
- (this.opts.color ? pc.green("✓ ") : "✓ ") + sanitizeForTerminal(text) + "\n",
96
- );
175
+ process.stderr.write(this.colors.green("✓ ") + sanitizeForTerminal(text) + "\n");
97
176
  }
98
177
 
99
178
  warn(text: string): void {
100
- process.stderr.write((this.opts.color ? pc.yellow("! ") : "! ") + sanitizeForTerminal(text) + "\n");
179
+ process.stderr.write(this.colors.yellow("! ") + sanitizeForTerminal(text) + "\n");
101
180
  }
102
181
 
103
182
  /**
@@ -117,7 +196,7 @@ export class Output {
117
196
  }
118
197
  if (!this.opts.quiet) {
119
198
  process.stderr.write(
120
- (this.opts.color ? pc.yellow("! ") : "! ") + `Cancelled: ${sanitizeForTerminal(action)}\n`,
199
+ this.colors.yellow("! ") + `Cancelled: ${sanitizeForTerminal(action)}\n`,
121
200
  );
122
201
  }
123
202
  }
@@ -137,20 +216,19 @@ export class Output {
137
216
  // `message` and `code` keep their positions; `detail` is additive, and
138
217
  // absent entirely without --debug.
139
218
  const error: Record<string, unknown> = { message: err.message, code: err.code };
219
+ if (err.suggestion !== undefined) error.suggestion = err.suggestion;
140
220
  if (showDetail) error.detail = err.detail;
141
221
  process.stderr.write(JSON.stringify({ error }) + "\n");
142
222
  return;
143
223
  }
144
224
  // An error message can quote API data (a name that matched several, …).
145
- process.stderr.write(
146
- (this.opts.color ? pc.red("error: ") : "error: ") + sanitizeForTerminal(err.message) + "\n",
147
- );
225
+ process.stderr.write(this.colors.red("error: ") + sanitizeForTerminal(err.message) + "\n");
226
+ if (err.suggestion !== undefined) {
227
+ process.stderr.write(this.colors.dim("hint: ") + sanitizeForTerminal(err.suggestion) + "\n");
228
+ }
148
229
  if (showDetail) {
149
- process.stderr.write(
150
- (this.opts.color ? pc.dim("detail: ") : "detail: ") +
151
- JSON.stringify(err.detail, null, 2) +
152
- "\n",
153
- );
230
+ const detail = JSON.stringify(err.detail, null, 2) ?? String(err.detail);
231
+ process.stderr.write(this.colors.dim("detail: ") + sanitizeForTerminal(detail) + "\n");
154
232
  }
155
233
  }
156
234
 
@@ -0,0 +1,133 @@
1
+ /**
2
+ * A small, dependency-free Markdown renderer for terminal output.
3
+ *
4
+ * This intentionally implements the Markdown constructs that occur in Linear
5
+ * bodies rather than pretending to be a CommonMark parser. The important
6
+ * properties for the CLI are deterministic readable text, optional ANSI, and
7
+ * sanitizing API-controlled bytes *before* adding any of our own escapes.
8
+ */
9
+
10
+ import pc from "picocolors";
11
+ import { sanitizeForTerminal } from "./sanitize.js";
12
+
13
+ export interface MarkdownRenderOptions {
14
+ color?: boolean;
15
+ /** Used for horizontal rules. Wrapping itself is left to the terminal. */
16
+ width?: number;
17
+ }
18
+
19
+ /** Render the useful subset of Markdown used by issue/document/project bodies. */
20
+ export function renderMarkdown(markdown: string, options: MarkdownRenderOptions = {}): string {
21
+ const colors = pc.createColors(options.color === true);
22
+ const width = Math.max(10, options.width ?? 80);
23
+ // CR and terminal controls disappear here. JSON paths never call this and
24
+ // therefore retain the exact API bytes (escaped by JSON.stringify).
25
+ const source = sanitizeForTerminal(markdown);
26
+ const lines = source.split("\n");
27
+ const rendered: string[] = [];
28
+ let fence: "```" | "~~~" | undefined;
29
+
30
+ for (const original of lines) {
31
+ const fenceMatch = original.match(/^\s*(```|~~~)/);
32
+ if (fenceMatch) {
33
+ const marker = fenceMatch[1] as "```" | "~~~";
34
+ if (!fence) fence = marker;
35
+ else if (fence === marker) fence = undefined;
36
+ // Fence markers and their language labels are presentation syntax.
37
+ continue;
38
+ }
39
+
40
+ if (fence) {
41
+ rendered.push(` ${colors.cyan(original)}`.trimEnd());
42
+ continue;
43
+ }
44
+
45
+ const heading = original.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/);
46
+ if (heading) {
47
+ const text = inline(heading[2] ?? "", options.color === true);
48
+ rendered.push(
49
+ heading[1]?.length === 1 ? colors.underline(colors.bold(text)) : colors.bold(text),
50
+ );
51
+ continue;
52
+ }
53
+
54
+ if (/^\s{0,3}((\*\s*){3,}|(-\s*){3,}|(_\s*){3,})$/.test(original)) {
55
+ rendered.push(colors.dim("─".repeat(Math.min(width, 72))));
56
+ continue;
57
+ }
58
+
59
+ const quote = original.match(/^(\s*)>\s?(.*)$/);
60
+ if (quote) {
61
+ rendered.push(
62
+ `${quote[1] ?? ""}${colors.dim("│")} ${colors.dim(inline(quote[2] ?? "", options.color === true))}`,
63
+ );
64
+ continue;
65
+ }
66
+
67
+ const task = original.match(/^(\s*)[-+*]\s+\[([ xX])\]\s+(.*)$/);
68
+ if (task) {
69
+ rendered.push(
70
+ `${task[1] ?? ""}${task[2]?.toLowerCase() === "x" ? "☑" : "☐"} ${inline(task[3] ?? "", options.color === true)}`,
71
+ );
72
+ continue;
73
+ }
74
+
75
+ const bullet = original.match(/^(\s*)[-+*]\s+(.*)$/);
76
+ if (bullet) {
77
+ rendered.push(`${bullet[1] ?? ""}• ${inline(bullet[2] ?? "", options.color === true)}`);
78
+ continue;
79
+ }
80
+
81
+ const ordered = original.match(/^(\s*)(\d+)[.)]\s+(.*)$/);
82
+ if (ordered) {
83
+ rendered.push(
84
+ `${ordered[1] ?? ""}${ordered[2]}. ${inline(ordered[3] ?? "", options.color === true)}`,
85
+ );
86
+ continue;
87
+ }
88
+
89
+ rendered.push(inline(original, options.color === true));
90
+ }
91
+
92
+ // Opening a malformed/unclosed fence must not make content disappear; its
93
+ // body has already been emitted as code. Trim only trailing blank lines so
94
+ // Output can own the single final newline consistently.
95
+ while (rendered.at(-1) === "") rendered.pop();
96
+ return rendered.join("\n");
97
+ }
98
+
99
+ /** Inline Markdown, with code spans shielded from emphasis/link processing. */
100
+ function inline(value: string, color: boolean): string {
101
+ const colors = pc.createColors(color);
102
+ const pieces = value.split(/(`+[^`]*`+)/g);
103
+ return pieces
104
+ .map((piece) => {
105
+ if (/^`+[^`]*`+$/.test(piece)) {
106
+ const ticks = piece.match(/^`+/)?.[0].length ?? 1;
107
+ return colors.cyan(piece.slice(ticks, -ticks));
108
+ }
109
+
110
+ return piece
111
+ .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_all, alt: string, url: string) => {
112
+ const label = alt.trim() ? `image: ${alt.trim()}` : "image";
113
+ return `${colors.dim(`[${label}]`)} (${url})`;
114
+ })
115
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_all, label: string, url: string) => {
116
+ return `${colors.underline(label)} (${url})`;
117
+ })
118
+ .replace(/<((?:https?|mailto):[^>]+)>/g, "$1")
119
+ .replace(/<br\s*\/?>/gi, " ")
120
+ .replace(/<\/?[A-Za-z][^>]*>/g, "")
121
+ .replace(/\*\*([^*]+)\*\*/g, (_all, text: string) => colors.bold(text))
122
+ .replace(/__([^_]+)__/g, (_all, text: string) => colors.bold(text))
123
+ .replace(/~~([^~]+)~~/g, (_all, text: string) => colors.strikethrough(text))
124
+ .replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, (_all, before: string, text: string) => {
125
+ return `${before}${colors.italic(text)}`;
126
+ })
127
+ .replace(/(^|[^_])_([^_\n]+)_(?!_)/g, (_all, before: string, text: string) => {
128
+ return `${before}${colors.italic(text)}`;
129
+ })
130
+ .replace(/\\([\\`*_[\]{}()#+.!|>~-])/g, "$1");
131
+ })
132
+ .join("");
133
+ }
@@ -0,0 +1,131 @@
1
+ /** Conditional, portable pager support for long human output. */
2
+
3
+ import { spawnSync } from "node:child_process";
4
+ import { displayWidth } from "./sanitize.js";
5
+
6
+ export interface PagerCommand {
7
+ command: string;
8
+ args: string[];
9
+ }
10
+
11
+ export interface PagerDecision {
12
+ enabled?: boolean;
13
+ json?: boolean;
14
+ isTTY?: boolean;
15
+ rows?: number;
16
+ columns?: number;
17
+ }
18
+
19
+ /** Count terminal rows, including the wrapping a terminal applies to long lines. */
20
+ export function terminalLineCount(content: string, columns = 80): number {
21
+ const width = Math.max(1, columns);
22
+ return content.split("\n").reduce((count, line) => {
23
+ return count + Math.max(1, Math.ceil(displayWidth(line) / width));
24
+ }, 0);
25
+ }
26
+
27
+ /** Page only deliberately-enabled, long, interactive human output. */
28
+ export function shouldUsePager(content: string, decision: PagerDecision): boolean {
29
+ if (decision.enabled === false || decision.json === true || decision.isTTY !== true) return false;
30
+ const rows = decision.rows && decision.rows > 2 ? decision.rows : 52;
31
+ return terminalLineCount(content, decision.columns ?? 80) > rows - 2;
32
+ }
33
+
34
+ /**
35
+ * Resolve `$PAGER` followed by platform fallbacks. The environment value is a
36
+ * command line, not a shell program: quotes/backslashes are honoured, while
37
+ * expansions and redirections are deliberately not executed.
38
+ */
39
+ export function pagerCommands(
40
+ env: NodeJS.ProcessEnv = process.env,
41
+ platform: NodeJS.Platform = process.platform,
42
+ ): PagerCommand[] {
43
+ const commands: PagerCommand[] = [];
44
+ const configured = env.PAGER?.trim();
45
+ if (configured) {
46
+ const argv = splitCommand(configured);
47
+ if (argv.length) commands.push({ command: argv[0]!, args: argv.slice(1) });
48
+ }
49
+
50
+ if (platform === "win32") {
51
+ commands.push({ command: "more.com", args: [] });
52
+ } else {
53
+ // -R passes through our SGR colour, -X leaves the terminal contents visible.
54
+ commands.push({ command: "less", args: ["-R", "-X"] });
55
+ commands.push({ command: "more", args: [] });
56
+ }
57
+
58
+ const seen = new Set<string>();
59
+ return commands.filter(({ command, args }) => {
60
+ const key = JSON.stringify([command, args]);
61
+ if (seen.has(key)) return false;
62
+ seen.add(key);
63
+ return true;
64
+ });
65
+ }
66
+
67
+ export interface PageOptions {
68
+ env?: NodeJS.ProcessEnv;
69
+ platform?: NodeJS.Platform;
70
+ /** Test seam; production uses spawnSync without a shell. */
71
+ run?: (candidate: PagerCommand, content: string, env: NodeJS.ProcessEnv) => boolean;
72
+ }
73
+
74
+ /** Try the configured pager and fallbacks. False means the caller should print directly. */
75
+ export function pageOutput(content: string, options: PageOptions = {}): boolean {
76
+ const env = options.env ?? process.env;
77
+ const run = options.run ?? runPager;
78
+ for (const candidate of pagerCommands(env, options.platform)) {
79
+ if (run(candidate, content, env)) return true;
80
+ }
81
+ return false;
82
+ }
83
+
84
+ function runPager(candidate: PagerCommand, content: string, env: NodeJS.ProcessEnv): boolean {
85
+ const result = spawnSync(candidate.command, candidate.args, {
86
+ input: content,
87
+ stdio: ["pipe", "inherit", "inherit"],
88
+ env,
89
+ });
90
+ return !result.error && result.status === 0;
91
+ }
92
+
93
+ /** A non-evaluating, shell-like argv splitter for PAGER. */
94
+ function splitCommand(line: string): string[] {
95
+ const out: string[] = [];
96
+ let current = "";
97
+ let inWord = false;
98
+ let quote: '"' | "'" | undefined;
99
+
100
+ for (let i = 0; i < line.length; i++) {
101
+ const char = line[i]!;
102
+ if (quote === "'") {
103
+ if (char === "'") quote = undefined;
104
+ else current += char;
105
+ } else if (quote === '"') {
106
+ if (char === '"') quote = undefined;
107
+ else if (char === "\\" && i + 1 < line.length && '"\\'.includes(line[i + 1]!)) {
108
+ current += line[++i];
109
+ } else current += char;
110
+ } else if (char === "'" || char === '"') {
111
+ quote = char;
112
+ inWord = true;
113
+ } else if (char === "\\" && i + 1 < line.length) {
114
+ current += line[++i];
115
+ inWord = true;
116
+ } else if (/\s/.test(char)) {
117
+ if (inWord) out.push(current);
118
+ current = "";
119
+ inWord = false;
120
+ } else {
121
+ current += char;
122
+ inWord = true;
123
+ }
124
+ }
125
+
126
+ // A malformed environment setting should not abort an otherwise successful
127
+ // CLI command; ignore it and continue to the portable fallbacks.
128
+ if (quote) return [];
129
+ if (inWord) out.push(current);
130
+ return out;
131
+ }
@@ -47,7 +47,7 @@ const ESCAPE_SEQUENCES = new RegExp(
47
47
  * characters human output needs.
48
48
  */
49
49
  const CONTROL_CHARS = new RegExp(
50
- // eslint-disable-next-line no-control-regex
50
+ // oxlint-disable-next-line no-control-regex
51
51
  "[\\u0000-\\u0008\\u000B-\\u001F\\u007F-\\u009F\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u2069]",
52
52
  "g",
53
53
  );
@@ -148,15 +148,14 @@ export function selectPairs(
148
148
  export function renderTable<T>(
149
149
  rows: T[],
150
150
  columns: Column<T>[],
151
- opts: { color?: boolean } = {},
151
+ opts: { color?: boolean; empty?: string } = {},
152
152
  ): string {
153
153
  const color = opts.color ?? false;
154
- if (rows.length === 0) return color ? pc.dim("(no results)") : "(no results)";
154
+ const colors = pc.createColors(color);
155
+ if (rows.length === 0) return colors.dim(sanitizeForTerminal(opts.empty ?? "(no results)"));
155
156
 
156
157
  const headers = columns.map((c) => c.header ?? c.key);
157
- const body = rows.map((row) =>
158
- columns.map((c) => truncate(cell(c.value(row)), c.max ?? 60)),
159
- );
158
+ const body = rows.map((row) => columns.map((c) => truncate(cell(c.value(row)), c.max ?? 60)));
160
159
 
161
160
  const widths = columns.map((_, i) => {
162
161
  const header = headers[i] ?? "";
@@ -167,7 +166,7 @@ export function renderTable<T>(
167
166
  const pad = (s: string, w: number) => s + " ".repeat(Math.max(0, w - displayWidth(s)));
168
167
 
169
168
  const headerLine = headers
170
- .map((h, i) => pad(color ? pc.bold(h) : h, widths[i] ?? 0))
169
+ .map((h, i) => pad(colors.bold(h), widths[i] ?? 0))
171
170
  .join(" ")
172
171
  .trimEnd();
173
172
 
@@ -187,12 +186,13 @@ export function renderDetail(
187
186
  opts: { color?: boolean } = {},
188
187
  ): string {
189
188
  const color = opts.color ?? false;
189
+ const colors = pc.createColors(color);
190
190
  const visible = pairs.filter(([, v]) => v !== undefined && v !== null);
191
191
  const keyWidth = visible.reduce((m, [k]) => Math.max(m, k.length), 0);
192
192
  return visible
193
193
  .map(([k, v]) => {
194
194
  const label = (k + ":").padEnd(keyWidth + 1);
195
- return `${color ? pc.dim(label) : label} ${cell(v)}`;
195
+ return `${colors.dim(label)} ${cell(v)}`;
196
196
  })
197
197
  .join("\n");
198
198
  }
@@ -14,7 +14,7 @@
14
14
  import type { LinearClient } from "@linear/sdk";
15
15
  import { withRetry } from "../client.js";
16
16
  import { shape } from "../lib/shape.js";
17
- import { collectRawQuery } from "../lib/pagination.js";
17
+ import { collectRawQuery, hasMoreResults, setPaginationMetadata } from "../lib/pagination.js";
18
18
  import { notFound } from "../lib/errors.js";
19
19
 
20
20
  /** The statuses Linear reports for a session (AgentSessionStatus). */
@@ -35,7 +35,11 @@ export interface SessionUser {
35
35
  displayName: string;
36
36
  }
37
37
 
38
- const SESSION_USER_SHAPE = shape<SessionUser>({ id: "string", name: "string", displayName: "string" });
38
+ const SESSION_USER_SHAPE = shape<SessionUser>({
39
+ id: "string",
40
+ name: "string",
41
+ displayName: "string",
42
+ });
39
43
 
40
44
  export interface AgentSessionRow {
41
45
  id: string;
@@ -154,7 +158,11 @@ function finish(
154
158
  const filtered = opts.status ? present.filter((r) => r.status === opts.status) : present;
155
159
  // Newest first, whichever query found them (an issue's comments come oldest first).
156
160
  filtered.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
157
- return limit === Infinity ? filtered : filtered.slice(0, limit);
161
+ if (limit === Infinity) return setPaginationMetadata(filtered, false);
162
+ return setPaginationMetadata(
163
+ filtered.slice(0, limit),
164
+ filtered.length > limit || (!opts.status && filtered.length >= limit && hasMoreResults(rows)),
165
+ );
158
166
  }
159
167
 
160
168
  function toRow(s: any): AgentSessionRow {
@@ -11,7 +11,7 @@
11
11
  import type { LinearClient } from "@linear/sdk";
12
12
  import { withRetry } from "../client.js";
13
13
  import { shape } from "../lib/shape.js";
14
- import { collect, pageSize } from "../lib/pagination.js";
14
+ import { collect, inheritPaginationMetadata, pageSize } from "../lib/pagination.js";
15
15
  import { notFound, usageError } from "../lib/errors.js";
16
16
  import { assertMutation, unwrapMutation } from "../lib/mutation.js";
17
17
  import { resolveIssue } from "../lib/resolve.js";
@@ -65,7 +65,7 @@ export async function listAttachments(
65
65
  const issue = await resolveIssue(client, issueArg);
66
66
  const conn = await withRetry(() => issue.attachments({ first: pageSize(limit) }));
67
67
  const nodes = await collect(conn as any, limit);
68
- return nodes.map(toRow);
68
+ return inheritPaginationMetadata(nodes.map(toRow), nodes);
69
69
  }
70
70
 
71
71
  export interface CreateOptions {
@@ -128,9 +128,15 @@ export async function attachFiles(
128
128
  issueArg: string,
129
129
  paths: string[],
130
130
  opts: AttachFilesOptions,
131
- ): Promise<{ issue: { id: string; identifier: string }; attachments: AttachedFile[]; comment?: { id: string; url: string } }> {
131
+ ): Promise<{
132
+ issue: { id: string; identifier: string };
133
+ attachments: AttachedFile[];
134
+ comment?: { id: string; url: string };
135
+ }> {
132
136
  if (opts.title !== undefined && paths.length > 1) {
133
- throw usageError("--title names a single attachment; with several files, drop it to use each file's name.");
137
+ throw usageError(
138
+ "--title names a single attachment; with several files, drop it to use each file's name.",
139
+ );
134
140
  }
135
141
  validateUploads(paths, { public: opts.public });
136
142
  const issue = await resolveIssue(client, issueArg);
@@ -178,7 +184,10 @@ export async function attachFiles(
178
184
  export async function deleteAttachment(client: LinearClient, id: string) {
179
185
  const attachment = await withRetry(() => client.attachment(id));
180
186
  if (!attachment) throw notFound(`No attachment ${id}.`);
181
- await assertMutation(withRetry(() => client.deleteAttachment(id)), "Attachment deletion");
187
+ await assertMutation(
188
+ withRetry(() => client.deleteAttachment(id)),
189
+ "Attachment deletion",
190
+ );
182
191
  return { id: attachment.id, title: attachment.title };
183
192
  }
184
193