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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +96 -58
- package/COVERAGE.md +5 -5
- package/MIGRATING.md +71 -69
- package/README.md +115 -90
- package/package.json +6 -7
- package/skills/README.md +15 -0
- package/skills/linear-sdk-cli/SKILL.md +27 -3
- package/skills/linear-sdk-cli/references/api.md +9 -9
- package/skills/linear-sdk-cli/references/auth.md +18 -0
- package/skills/linear-sdk-cli/references/commands.md +2 -2
- package/skills/linear-sdk-cli/references/comment.md +9 -6
- package/skills/linear-sdk-cli/references/config.md +9 -11
- package/skills/linear-sdk-cli/references/issue.md +72 -18
- package/skills/linear-sdk-cli/references/open.md +29 -0
- package/skills/linear-sdk-cli/references/team.md +16 -0
- package/skills/linear-sdk-cli/scripts/generate-docs.ts +6 -2
- package/skills/linear-sdk-cli-maintenance/SKILL.md +97 -0
- package/src/bin/linear.ts +16 -6
- package/src/cli.ts +3 -1
- package/src/commands/agent-session.ts +13 -5
- package/src/commands/api.ts +15 -9
- package/src/commands/attach.ts +15 -4
- package/src/commands/comment.ts +50 -11
- package/src/commands/completion.ts +2 -6
- package/src/commands/cycle.ts +6 -1
- package/src/commands/discover.ts +6 -2
- package/src/commands/document.ts +4 -1
- package/src/commands/initiative.ts +5 -1
- package/src/commands/issue.ts +392 -91
- package/src/commands/meta.ts +139 -31
- package/src/commands/milestone.ts +4 -1
- package/src/commands/notification.ts +15 -6
- package/src/commands/open.ts +84 -0
- package/src/commands/project.ts +17 -4
- package/src/commands/team.ts +13 -0
- package/src/commands/user.ts +2 -2
- package/src/commands/webhook.ts +12 -2
- package/src/config.ts +501 -184
- package/src/context.ts +13 -4
- package/src/git.ts +28 -3
- package/src/lib/body.ts +26 -2
- package/src/lib/errors.ts +33 -8
- package/src/lib/introspect.ts +76 -7
- package/src/lib/keyring.ts +51 -9
- package/src/lib/mentions.ts +49 -0
- package/src/lib/open.ts +32 -0
- package/src/lib/options.ts +108 -12
- package/src/lib/output-shapes.ts +81 -9
- package/src/lib/pagination.ts +50 -17
- package/src/lib/resolve.ts +40 -7
- package/src/lib/shape.ts +16 -12
- package/src/lib/upload.ts +3 -1
- package/src/output/color.ts +52 -0
- package/src/output/format.ts +96 -18
- package/src/output/markdown.ts +133 -0
- package/src/output/pager.ts +131 -0
- package/src/output/sanitize.ts +1 -1
- package/src/output/table.ts +7 -7
- package/src/services/agent-session.ts +11 -3
- package/src/services/attachment.ts +14 -5
- package/src/services/comment.ts +37 -9
- package/src/services/cycle.ts +11 -4
- package/src/services/document.ts +29 -10
- package/src/services/favorite.ts +15 -9
- package/src/services/initiative-update.ts +16 -15
- package/src/services/initiative.ts +15 -10
- package/src/services/issue.ts +263 -19
- package/src/services/label.ts +12 -2
- package/src/services/milestone.ts +8 -5
- package/src/services/notification.ts +16 -2
- package/src/services/organization.ts +23 -18
- package/src/services/project.ts +55 -34
- package/src/services/roadmap.ts +7 -6
- package/src/services/state.ts +9 -3
- package/src/services/team.ts +67 -24
- package/src/services/user.ts +2 -2
- 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
|
+
}
|
package/src/output/format.ts
CHANGED
|
@@ -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
|
-
|
|
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>(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
+
}
|
package/src/output/sanitize.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
);
|
package/src/output/table.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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 `${
|
|
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>({
|
|
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
|
-
|
|
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<{
|
|
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(
|
|
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(
|
|
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
|
|