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.
- package/.claude-plugin/marketplace.json +20 -0
- package/.claude-plugin/plugin.json +9 -0
- package/CHANGELOG.md +807 -0
- package/COVERAGE.md +495 -0
- package/LICENSE +21 -0
- package/MIGRATING.md +220 -0
- package/README.md +524 -0
- package/package.json +87 -0
- package/skills/linear-sdk-cli/SKILL.md +534 -0
- package/skills/linear-sdk-cli/references/api.md +27 -0
- package/skills/linear-sdk-cli/references/attachment.md +82 -0
- package/skills/linear-sdk-cli/references/auth.md +174 -0
- package/skills/linear-sdk-cli/references/commands.md +37 -0
- package/skills/linear-sdk-cli/references/comment.md +154 -0
- package/skills/linear-sdk-cli/references/completion.md +17 -0
- package/skills/linear-sdk-cli/references/config.md +99 -0
- package/skills/linear-sdk-cli/references/cycle.md +131 -0
- package/skills/linear-sdk-cli/references/document.md +180 -0
- package/skills/linear-sdk-cli/references/favorite.md +78 -0
- package/skills/linear-sdk-cli/references/initiative-update.md +67 -0
- package/skills/linear-sdk-cli/references/initiative.md +256 -0
- package/skills/linear-sdk-cli/references/issue.md +928 -0
- package/skills/linear-sdk-cli/references/label.md +112 -0
- package/skills/linear-sdk-cli/references/milestone.md +139 -0
- package/skills/linear-sdk-cli/references/notification.md +123 -0
- package/skills/linear-sdk-cli/references/open.md +29 -0
- package/skills/linear-sdk-cli/references/organization.md +99 -0
- package/skills/linear-sdk-cli/references/project-update.md +67 -0
- package/skills/linear-sdk-cli/references/project.md +234 -0
- package/skills/linear-sdk-cli/references/roadmap.md +150 -0
- package/skills/linear-sdk-cli/references/schema.md +21 -0
- package/skills/linear-sdk-cli/references/state.md +61 -0
- package/skills/linear-sdk-cli/references/team.md +227 -0
- package/skills/linear-sdk-cli/references/user.md +103 -0
- package/skills/linear-sdk-cli/references/webhook.md +151 -0
- package/skills/linear-sdk-cli/references/whoami.md +26 -0
- package/skills/linear-sdk-cli/scripts/generate-docs.ts +339 -0
- package/src/bin/linear.ts +97 -0
- package/src/cli.ts +232 -0
- package/src/client.ts +172 -0
- package/src/commands/agent-session.ts +121 -0
- package/src/commands/api.ts +317 -0
- package/src/commands/attach.ts +90 -0
- package/src/commands/attachment.ts +74 -0
- package/src/commands/comment.ts +225 -0
- package/src/commands/completion.ts +69 -0
- package/src/commands/cycle.ts +125 -0
- package/src/commands/discover.ts +170 -0
- package/src/commands/document.ts +211 -0
- package/src/commands/favorite.ts +72 -0
- package/src/commands/initiative-update.ts +47 -0
- package/src/commands/initiative.ts +261 -0
- package/src/commands/issue.ts +1263 -0
- package/src/commands/label.ts +119 -0
- package/src/commands/meta.ts +534 -0
- package/src/commands/milestone.ts +163 -0
- package/src/commands/notification.ts +110 -0
- package/src/commands/open.ts +84 -0
- package/src/commands/organization.ts +79 -0
- package/src/commands/project-update.ts +47 -0
- package/src/commands/project.ts +339 -0
- package/src/commands/roadmap.ts +131 -0
- package/src/commands/state.ts +58 -0
- package/src/commands/team.ts +243 -0
- package/src/commands/user.ts +107 -0
- package/src/commands/webhook.ts +156 -0
- package/src/config.ts +1236 -0
- package/src/context.ts +117 -0
- package/src/git.ts +174 -0
- package/src/index.ts +10 -0
- package/src/lib/action.ts +26 -0
- package/src/lib/body.ts +172 -0
- package/src/lib/errors.ts +195 -0
- package/src/lib/introspect.ts +150 -0
- package/src/lib/keyring.ts +270 -0
- package/src/lib/mutation.ts +53 -0
- package/src/lib/open.ts +32 -0
- package/src/lib/options.ts +640 -0
- package/src/lib/output-shapes.ts +562 -0
- package/src/lib/pagination.ts +145 -0
- package/src/lib/prompt.ts +91 -0
- package/src/lib/resolve.ts +596 -0
- package/src/lib/shape.ts +273 -0
- package/src/lib/status-update.ts +91 -0
- package/src/lib/upload.ts +329 -0
- package/src/output/color.ts +52 -0
- package/src/output/format.ts +238 -0
- package/src/output/markdown.ts +133 -0
- package/src/output/pager.ts +131 -0
- package/src/output/sanitize.ts +76 -0
- package/src/output/table.ts +198 -0
- package/src/services/agent-session.ts +289 -0
- package/src/services/attachment.ts +198 -0
- package/src/services/comment.ts +262 -0
- package/src/services/cycle.ts +224 -0
- package/src/services/document.ts +357 -0
- package/src/services/favorite.ts +147 -0
- package/src/services/initiative-update.ts +59 -0
- package/src/services/initiative.ts +449 -0
- package/src/services/issue.ts +1153 -0
- package/src/services/label.ts +193 -0
- package/src/services/milestone.ts +262 -0
- package/src/services/notification.ts +190 -0
- package/src/services/organization.ts +144 -0
- package/src/services/project-update.ts +36 -0
- package/src/services/project.ts +494 -0
- package/src/services/roadmap.ts +177 -0
- package/src/services/state.ts +127 -0
- package/src/services/team.ts +386 -0
- package/src/services/user.ts +137 -0
- 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
|
+
}
|