linear-sdk-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +9 -0
  3. package/CHANGELOG.md +807 -0
  4. package/COVERAGE.md +495 -0
  5. package/LICENSE +21 -0
  6. package/MIGRATING.md +220 -0
  7. package/README.md +524 -0
  8. package/package.json +87 -0
  9. package/skills/linear-sdk-cli/SKILL.md +534 -0
  10. package/skills/linear-sdk-cli/references/api.md +27 -0
  11. package/skills/linear-sdk-cli/references/attachment.md +82 -0
  12. package/skills/linear-sdk-cli/references/auth.md +174 -0
  13. package/skills/linear-sdk-cli/references/commands.md +37 -0
  14. package/skills/linear-sdk-cli/references/comment.md +154 -0
  15. package/skills/linear-sdk-cli/references/completion.md +17 -0
  16. package/skills/linear-sdk-cli/references/config.md +99 -0
  17. package/skills/linear-sdk-cli/references/cycle.md +131 -0
  18. package/skills/linear-sdk-cli/references/document.md +180 -0
  19. package/skills/linear-sdk-cli/references/favorite.md +78 -0
  20. package/skills/linear-sdk-cli/references/initiative-update.md +67 -0
  21. package/skills/linear-sdk-cli/references/initiative.md +256 -0
  22. package/skills/linear-sdk-cli/references/issue.md +928 -0
  23. package/skills/linear-sdk-cli/references/label.md +112 -0
  24. package/skills/linear-sdk-cli/references/milestone.md +139 -0
  25. package/skills/linear-sdk-cli/references/notification.md +123 -0
  26. package/skills/linear-sdk-cli/references/open.md +29 -0
  27. package/skills/linear-sdk-cli/references/organization.md +99 -0
  28. package/skills/linear-sdk-cli/references/project-update.md +67 -0
  29. package/skills/linear-sdk-cli/references/project.md +234 -0
  30. package/skills/linear-sdk-cli/references/roadmap.md +150 -0
  31. package/skills/linear-sdk-cli/references/schema.md +21 -0
  32. package/skills/linear-sdk-cli/references/state.md +61 -0
  33. package/skills/linear-sdk-cli/references/team.md +227 -0
  34. package/skills/linear-sdk-cli/references/user.md +103 -0
  35. package/skills/linear-sdk-cli/references/webhook.md +151 -0
  36. package/skills/linear-sdk-cli/references/whoami.md +26 -0
  37. package/skills/linear-sdk-cli/scripts/generate-docs.ts +339 -0
  38. package/src/bin/linear.ts +97 -0
  39. package/src/cli.ts +232 -0
  40. package/src/client.ts +172 -0
  41. package/src/commands/agent-session.ts +121 -0
  42. package/src/commands/api.ts +317 -0
  43. package/src/commands/attach.ts +90 -0
  44. package/src/commands/attachment.ts +74 -0
  45. package/src/commands/comment.ts +225 -0
  46. package/src/commands/completion.ts +69 -0
  47. package/src/commands/cycle.ts +125 -0
  48. package/src/commands/discover.ts +170 -0
  49. package/src/commands/document.ts +211 -0
  50. package/src/commands/favorite.ts +72 -0
  51. package/src/commands/initiative-update.ts +47 -0
  52. package/src/commands/initiative.ts +261 -0
  53. package/src/commands/issue.ts +1263 -0
  54. package/src/commands/label.ts +119 -0
  55. package/src/commands/meta.ts +534 -0
  56. package/src/commands/milestone.ts +163 -0
  57. package/src/commands/notification.ts +110 -0
  58. package/src/commands/open.ts +84 -0
  59. package/src/commands/organization.ts +79 -0
  60. package/src/commands/project-update.ts +47 -0
  61. package/src/commands/project.ts +339 -0
  62. package/src/commands/roadmap.ts +131 -0
  63. package/src/commands/state.ts +58 -0
  64. package/src/commands/team.ts +243 -0
  65. package/src/commands/user.ts +107 -0
  66. package/src/commands/webhook.ts +156 -0
  67. package/src/config.ts +1236 -0
  68. package/src/context.ts +117 -0
  69. package/src/git.ts +174 -0
  70. package/src/index.ts +10 -0
  71. package/src/lib/action.ts +26 -0
  72. package/src/lib/body.ts +172 -0
  73. package/src/lib/errors.ts +195 -0
  74. package/src/lib/introspect.ts +150 -0
  75. package/src/lib/keyring.ts +270 -0
  76. package/src/lib/mutation.ts +53 -0
  77. package/src/lib/open.ts +32 -0
  78. package/src/lib/options.ts +640 -0
  79. package/src/lib/output-shapes.ts +562 -0
  80. package/src/lib/pagination.ts +145 -0
  81. package/src/lib/prompt.ts +91 -0
  82. package/src/lib/resolve.ts +596 -0
  83. package/src/lib/shape.ts +273 -0
  84. package/src/lib/status-update.ts +91 -0
  85. package/src/lib/upload.ts +329 -0
  86. package/src/output/color.ts +52 -0
  87. package/src/output/format.ts +238 -0
  88. package/src/output/markdown.ts +133 -0
  89. package/src/output/pager.ts +131 -0
  90. package/src/output/sanitize.ts +76 -0
  91. package/src/output/table.ts +198 -0
  92. package/src/services/agent-session.ts +289 -0
  93. package/src/services/attachment.ts +198 -0
  94. package/src/services/comment.ts +262 -0
  95. package/src/services/cycle.ts +224 -0
  96. package/src/services/document.ts +357 -0
  97. package/src/services/favorite.ts +147 -0
  98. package/src/services/initiative-update.ts +59 -0
  99. package/src/services/initiative.ts +449 -0
  100. package/src/services/issue.ts +1153 -0
  101. package/src/services/label.ts +193 -0
  102. package/src/services/milestone.ts +262 -0
  103. package/src/services/notification.ts +190 -0
  104. package/src/services/organization.ts +144 -0
  105. package/src/services/project-update.ts +36 -0
  106. package/src/services/project.ts +494 -0
  107. package/src/services/roadmap.ts +177 -0
  108. package/src/services/state.ts +127 -0
  109. package/src/services/team.ts +386 -0
  110. package/src/services/user.ts +137 -0
  111. package/src/services/webhook.ts +203 -0
@@ -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
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * The Output object: the single sink for everything a command prints.
3
+ *
4
+ * JSON-mode contract (locked — scripts depend on it):
5
+ * - machine JSON is the ONLY thing written to stdout
6
+ * - lists → bare array, single resource → bare object, mutations → affected
7
+ * object (or {success,id} when there is no body)
8
+ * - status/progress/pagination notes → stderr
9
+ * - errors → {"error":{"message","code", …optional additive fields}} on stderr + non-zero exit
10
+ */
11
+
12
+ import pc from "picocolors";
13
+ import type { CliError } from "../lib/errors.js";
14
+ import {
15
+ renderTable,
16
+ renderDetail,
17
+ selectColumns,
18
+ selectPairs,
19
+ projectFields,
20
+ type Column,
21
+ } from "./table.js";
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";
26
+
27
+ export interface OutputOptions {
28
+ json: boolean;
29
+ color: boolean;
30
+ quiet: boolean;
31
+ debug: boolean;
32
+ /** `--fields`: table columns / detail lines (human), top-level keys (json). */
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;
54
+ }
55
+
56
+ export class Output {
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
+ }
66
+
67
+ get json(): boolean {
68
+ return this.opts.json;
69
+ }
70
+
71
+ get color(): boolean {
72
+ return this.opts.color;
73
+ }
74
+
75
+ /** Emit a primary result: JSON in json-mode, else run the human renderer. */
76
+ emit(value: unknown, human: () => void): void {
77
+ if (this.opts.json) {
78
+ this.writeJson(value);
79
+ } else {
80
+ human();
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Emit a list as a table (human) or bare array (json). `--fields` narrows
86
+ * both: the table's columns (or any row key), and the JSON objects' keys.
87
+ */
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
+ }
102
+ if (this.opts.json) {
103
+ this.writeJson(projectFields(options.jsonRows ?? rows, this.opts.fields));
104
+ return;
105
+ }
106
+ const cols = selectColumns(columns, this.opts.fields, rows[0]);
107
+ process.stdout.write(
108
+ renderTable(rows, cols, { color: this.opts.color, empty: options.empty }) + "\n",
109
+ );
110
+ }
111
+
112
+ /**
113
+ * Emit a single record as a detail block (human) or bare object (json).
114
+ * `--fields` narrows both: the block's labelled lines, and the JSON keys.
115
+ */
116
+ detail(jsonValue: unknown, pairs: Array<[string, unknown]>): void {
117
+ if (this.opts.json) {
118
+ this.writeJson(projectFields(jsonValue, this.opts.fields));
119
+ return;
120
+ }
121
+ const shown = selectPairs(pairs, this.opts.fields);
122
+ process.stdout.write(renderDetail(shown, { color: this.opts.color }) + "\n");
123
+ }
124
+
125
+ /**
126
+ * Stdout line (human mode only); in json mode this is suppressed. Sanitized:
127
+ * `issue title` prints API data bare, and a title can carry escapes.
128
+ */
129
+ line(text = ""): void {
130
+ if (!this.opts.json) process.stdout.write(sanitizeForTerminal(text) + "\n");
131
+ }
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
+
168
+ /** Status/progress to stderr. Suppressed by --quiet. */
169
+ info(text: string): void {
170
+ if (!this.opts.quiet) process.stderr.write(sanitizeForTerminal(text) + "\n");
171
+ }
172
+
173
+ success(text: string): void {
174
+ if (!this.opts.quiet)
175
+ process.stderr.write(this.colors.green("✓ ") + sanitizeForTerminal(text) + "\n");
176
+ }
177
+
178
+ warn(text: string): void {
179
+ process.stderr.write(this.colors.yellow("! ") + sanitizeForTerminal(text) + "\n");
180
+ }
181
+
182
+ /**
183
+ * A destructive action the user declined at the confirmation prompt.
184
+ *
185
+ * Declining is not a failure, so this is not the error envelope — but it is
186
+ * not silence either. In JSON mode it is a real result on stdout, so a
187
+ * pipeline still gets something parseable back instead of nothing; in human
188
+ * mode it is a stderr note. The caller sets the exit code (see
189
+ * `EXIT_CANCELLED` in lib/prompt.ts), which is what stops
190
+ * `linear issue delete X && …` from running the `&&` side.
191
+ */
192
+ cancelled(action: string): void {
193
+ if (this.opts.json) {
194
+ this.writeJson({ cancelled: true, action });
195
+ return;
196
+ }
197
+ if (!this.opts.quiet) {
198
+ process.stderr.write(
199
+ this.colors.yellow("! ") + `Cancelled: ${sanitizeForTerminal(action)}\n`,
200
+ );
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Print a normalized error envelope to stderr.
206
+ *
207
+ * In JSON mode `--debug` detail goes *inside* the envelope. It used to be
208
+ * appended afterwards as a second, plaintext block, which meant the one
209
+ * combination a caller reaches for when a scripted call misbehaves —
210
+ * `--json --debug` — was the one that produced unparseable output:
211
+ * `linear … --json --debug 2>&1 | jq` died on "Invalid numeric literal".
212
+ */
213
+ error(err: CliError): void {
214
+ const showDetail = this.opts.debug && err.detail !== undefined;
215
+ if (this.opts.json) {
216
+ // `message` and `code` keep their positions; `detail` is additive, and
217
+ // absent entirely without --debug.
218
+ const error: Record<string, unknown> = { message: err.message, code: err.code };
219
+ if (err.suggestion !== undefined) error.suggestion = err.suggestion;
220
+ if (showDetail) error.detail = err.detail;
221
+ process.stderr.write(JSON.stringify({ error }) + "\n");
222
+ return;
223
+ }
224
+ // An error message can quote API data (a name that matched several, …).
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
+ }
229
+ if (showDetail) {
230
+ const detail = JSON.stringify(err.detail, null, 2) ?? String(err.detail);
231
+ process.stderr.write(this.colors.dim("detail: ") + sanitizeForTerminal(detail) + "\n");
232
+ }
233
+ }
234
+
235
+ private writeJson(value: unknown): void {
236
+ process.stdout.write(JSON.stringify(value, null, this.opts.json ? 2 : 0) + "\n");
237
+ }
238
+ }
@@ -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
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Terminal hygiene for human output.
3
+ *
4
+ * Everything the CLI prints for a human is, at some point, text that came from
5
+ * Linear — and anyone who can create an issue in a workspace can put terminal
6
+ * escape sequences in a title. Written raw, those drive every teammate's
7
+ * terminal: colour and cursor movement are the harmless end; an OSC-8 sequence
8
+ * makes a fake hyperlink, an OSC-0 rewrites the window title, and a `\r` lets
9
+ * later text overwrite what a reader thought they had already seen. Verified
10
+ * live (TES-623): a title created as `\e[31mRED\e[0m \e]8;;https://evil…`
11
+ * reached `issue list`, `issue view` and `issue title` byte-for-byte.
12
+ *
13
+ * `--json` output is untouched — JSON escapes control characters, and a script
14
+ * is owed the exact bytes. This is for the table, the detail block, the bare
15
+ * scalar lines, and the status/error lines: whatever a person reads.
16
+ */
17
+
18
+ /**
19
+ * Whole escape sequences first, so `\e[31m` disappears rather than leaving a
20
+ * literal `[31m` behind:
21
+ *
22
+ * - CSI `ESC [` params intermediates final (colour, cursor, erase)
23
+ * - OSC `ESC ]` … BEL | ST (hyperlinks, window title)
24
+ * - DCS/SOS/PM/APC `ESC P|X|^|_` … ST
25
+ * - any other two/three-byte `ESC` sequence
26
+ * - the 8-bit C1 spellings of CSI (0x9B) and OSC (0x9D)
27
+ *
28
+ * Spelled as `\uXXXX` escapes so no literal control byte lives in this source.
29
+ */
30
+ const ESCAPE_SEQUENCES = new RegExp(
31
+ [
32
+ "\\u001B\\[[0-?]*[ -/]*[@-~]",
33
+ "\\u009B[0-?]*[ -/]*[@-~]",
34
+ "\\u001B\\][^\\u0007\\u001B\\u009C]*(?:\\u0007|\\u001B\\\\|\\u009C)?",
35
+ "\\u009D[^\\u0007\\u001B\\u009C]*(?:\\u0007|\\u001B\\\\|\\u009C)?",
36
+ "\\u001B[PX^_][^\\u001B\\u009C]*(?:\\u001B\\\\|\\u009C)?",
37
+ "\\u001B[ -/]*[0-~]",
38
+ ].join("|"),
39
+ "g",
40
+ );
41
+
42
+ /**
43
+ * Then whatever control characters remain: C0 except `\t` and `\n` (a
44
+ * description is multi-line and that is the point of it), DEL, C1, and the
45
+ * bidi embedding/override characters, which can make `a → b` read as `b → a`.
46
+ * Same set as `config.ts` uses for TOML errors, minus the two whitespace
47
+ * characters human output needs.
48
+ */
49
+ const CONTROL_CHARS = new RegExp(
50
+ // eslint-disable-next-line no-control-regex
51
+ "[\\u0000-\\u0008\\u000B-\\u001F\\u007F-\\u009F\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u2069]",
52
+ "g",
53
+ );
54
+
55
+ /** Strip anything in `text` that could drive a terminal rather than be read on it. */
56
+ export function sanitizeForTerminal(text: string): string {
57
+ return text.replace(ESCAPE_SEQUENCES, "").replace(CONTROL_CHARS, "");
58
+ }
59
+
60
+ // Built via fromCharCode to avoid a control char in a regex literal (no-control-regex).
61
+ const ANSI_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
62
+ const stripAnsi = (s: string): string => s.replace(ANSI_RE, "");
63
+
64
+ /**
65
+ * The number of terminal columns `s` occupies. Our own SGR colour is
66
+ * zero-width; a CJK character or an emoji is two. `s.length` counted UTF-16
67
+ * code units, so a table with a Japanese title, or one emoji, mis-aligned every
68
+ * column after it. Bun's `stringWidth` knows East Asian width and grapheme
69
+ * clusters; the fallback (a non-Bun runtime, which the package does not
70
+ * support) is the old count.
71
+ */
72
+ export function displayWidth(s: string): number {
73
+ const bun = (globalThis as { Bun?: { stringWidth?: (s: string) => number } }).Bun;
74
+ if (bun?.stringWidth) return bun.stringWidth(s);
75
+ return stripAnsi(s).length;
76
+ }