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,640 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared option helpers and parser functions for commander, so flags stay
|
|
3
|
+
* consistent across the ~20 command groups.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Command, Option } from "commander";
|
|
7
|
+
import { usageError, type CliError } from "./errors.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Repeatable `--var k=v` collector. JSON literals become their real types
|
|
11
|
+
* (`2`, `true`, `null`, arrays/objects); ordinary text stays a string.
|
|
12
|
+
*/
|
|
13
|
+
export function collectKeyVal(
|
|
14
|
+
value: string,
|
|
15
|
+
previous: Record<string, unknown> = {},
|
|
16
|
+
): Record<string, unknown> {
|
|
17
|
+
const eq = value.indexOf("=");
|
|
18
|
+
if (eq === -1) throw usageError(`Expected key=value, got '${value}'`);
|
|
19
|
+
const key = value.slice(0, eq);
|
|
20
|
+
if (!key) throw usageError(`Expected a non-empty key in key=value, got '${value}'`);
|
|
21
|
+
const raw = value.slice(eq + 1);
|
|
22
|
+
try {
|
|
23
|
+
previous[key] = JSON.parse(raw);
|
|
24
|
+
} catch {
|
|
25
|
+
previous[key] = raw;
|
|
26
|
+
}
|
|
27
|
+
return previous;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Repeatable string collector → string[]. */
|
|
31
|
+
export function collectArray(value: string, previous: string[] = []): string[] {
|
|
32
|
+
previous.push(value);
|
|
33
|
+
return previous;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Comma-or-repeat list parser used by --fields and --label. */
|
|
37
|
+
export function parseList(value: string, previous: string[] = []): string[] {
|
|
38
|
+
for (const part of value.split(",")) {
|
|
39
|
+
const trimmed = part.trim();
|
|
40
|
+
if (trimmed) previous.push(trimmed);
|
|
41
|
+
}
|
|
42
|
+
return previous;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A *complete* integer token.
|
|
47
|
+
*
|
|
48
|
+
* `Number.parseInt` stops at the first character it cannot use, so the previous
|
|
49
|
+
* parser answered a different question than the user asked: `--priority 1.9`
|
|
50
|
+
* became 1 and `--estimate 2junk` became 2, both silently. A flag value that is
|
|
51
|
+
* not entirely an integer is a typo, and a typo should cost an error message,
|
|
52
|
+
* not a wrong mutation.
|
|
53
|
+
*/
|
|
54
|
+
export function parseIntOption(value: string): number {
|
|
55
|
+
if (!/^-?\d+$/.test(value)) throw usageError(`Expected an integer, got '${value}'`);
|
|
56
|
+
return Number.parseInt(value, 10);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Priority as Linear defines it: a complete integer in 0–4. The API does reject
|
|
61
|
+
* out-of-range values on the round-trip, but locally is where it costs nothing,
|
|
62
|
+
* and it makes issue/project priority behave like initiative priority, which
|
|
63
|
+
* has validated locally all along (`resolvePriority` in services/initiative.ts —
|
|
64
|
+
* the wording here matches deliberately, so the two cannot read as different
|
|
65
|
+
* rules).
|
|
66
|
+
*/
|
|
67
|
+
export function parsePriority(value: string): number {
|
|
68
|
+
const n = parseIntOption(value);
|
|
69
|
+
if (n < 0 || n > 4)
|
|
70
|
+
throw usageError(
|
|
71
|
+
`Invalid priority '${value}'. Valid: 0 (none), 1 (urgent), 2 (high), 3 (medium), 4 (low).`,
|
|
72
|
+
);
|
|
73
|
+
return n;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The filter spelling of `--priority`. Validates exactly like `parsePriority`
|
|
78
|
+
* but hands back the canonical *string* the issue filter already consumes, so
|
|
79
|
+
* the check lands at the CLI boundary without reshaping the service interface.
|
|
80
|
+
*/
|
|
81
|
+
function parsePriorityFilter(value: string): string {
|
|
82
|
+
return String(parsePriority(value));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Strict count parser for `--limit`: accepts `1`, `2`, … plus the single
|
|
87
|
+
* special value `0`, which the reference CLI spells as "no limit" and which we
|
|
88
|
+
* map onto `--all` (see `Context.limit`). Rejects `-1`, `12x`, `1.5` and
|
|
89
|
+
* leading zeros with a clear usage error instead of silently falling back.
|
|
90
|
+
*/
|
|
91
|
+
export function parsePositiveInt(value: string): number {
|
|
92
|
+
if (value === "0") return 0;
|
|
93
|
+
if (!/^[1-9]\d*$/.test(value)) {
|
|
94
|
+
throw usageError(`Expected --limit to be a positive integer (or 0 for all), got '${value}'.`);
|
|
95
|
+
}
|
|
96
|
+
return Number.parseInt(value, 10);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The subcommand of `cmd` a mistyped `word` most plausibly meant, or nothing.
|
|
101
|
+
*
|
|
102
|
+
* Commander's own "did you mean" only fires on a command with no action
|
|
103
|
+
* handler; the root has one (bare `linear` shows the branch's issue) and so does
|
|
104
|
+
* `issue` (its default subcommand is `view`), so a stray word there used to be
|
|
105
|
+
* reported as "too many arguments" or "not a valid issue id". Names and aliases
|
|
106
|
+
* both count; a prefix (`proj` → `project`) or a small edit distance
|
|
107
|
+
* (`issues` → `issue`, `lable` → `label`) is close enough, and the closest wins.
|
|
108
|
+
*/
|
|
109
|
+
export function suggestSubcommand(cmd: Command, word: string): string | undefined {
|
|
110
|
+
const lower = word.toLowerCase();
|
|
111
|
+
let best: { name: string; distance: number } | undefined;
|
|
112
|
+
for (const sub of cmd.commands) {
|
|
113
|
+
for (const candidate of [sub.name(), ...sub.aliases()]) {
|
|
114
|
+
const c = candidate.toLowerCase();
|
|
115
|
+
// A prefix is a match (`proj` → `project`). Otherwise up to one edit per
|
|
116
|
+
// two characters, capped at three, so `lable` finds `label` — but never
|
|
117
|
+
// against a two-letter alias, where one edit away is everything.
|
|
118
|
+
let distance: number;
|
|
119
|
+
if (lower.length >= 2 && c.startsWith(lower)) distance = 0;
|
|
120
|
+
else if (c.length < 3) continue;
|
|
121
|
+
else distance = editDistance(lower, c);
|
|
122
|
+
if (distance > Math.min(3, Math.max(1, Math.floor(lower.length / 2)))) continue;
|
|
123
|
+
if (!best || distance < best.distance) best = { name: sub.name(), distance };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return best?.name;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A usage error for a word that names no subcommand of `cmd`, with the closest real one. */
|
|
130
|
+
export function unknownCommand(cmd: Command, word: string): CliError {
|
|
131
|
+
const path = commandPath(cmd);
|
|
132
|
+
const guess = suggestSubcommand(cmd, word);
|
|
133
|
+
return usageError(
|
|
134
|
+
`Unknown command '${word}'.${guess ? ` Did you mean '${guess}'?` : ""} Run '${path} --help' to see the commands.`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** `linear issue` for the `issue` command — the way a user would type it. */
|
|
139
|
+
export function commandPath(cmd: Command): string {
|
|
140
|
+
const names: string[] = [];
|
|
141
|
+
for (let c: Command | null = cmd; c; c = c.parent) names.unshift(c.name());
|
|
142
|
+
return names.join(" ");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Levenshtein distance — small strings only (command names). */
|
|
146
|
+
function editDistance(a: string, b: string): number {
|
|
147
|
+
const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
148
|
+
for (let i = 1; i <= a.length; i++) {
|
|
149
|
+
let diag = prev[0]!;
|
|
150
|
+
prev[0] = i;
|
|
151
|
+
for (let j = 1; j <= b.length; j++) {
|
|
152
|
+
const tmp = prev[j]!;
|
|
153
|
+
prev[j] = Math.min(prev[j]! + 1, prev[j - 1]! + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
154
|
+
diag = tmp;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return prev[b.length]!;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Shared metavar + help for the cycle option (filter + create/update). */
|
|
161
|
+
export const CYCLE_FLAG = "--cycle <n>";
|
|
162
|
+
export const CYCLE_DESC = "cycle number, name, id, or 'current'";
|
|
163
|
+
|
|
164
|
+
// --- Long-flag aliases -----------------------------------------------------
|
|
165
|
+
//
|
|
166
|
+
// The reference CLI spells several of our flags differently (`--due-date` for
|
|
167
|
+
// `--due`, `--search` for `--query`, …). We accept both spellings so a
|
|
168
|
+
// transplanted script keeps working, with one mechanism used everywhere:
|
|
169
|
+
//
|
|
170
|
+
// 1. the alias is registered as a *hidden* option, so `--help` and
|
|
171
|
+
// `linear commands --json` keep showing exactly one canonical spelling
|
|
172
|
+
// each and don't double in size;
|
|
173
|
+
// 2. the action reads it through `readAlias`, which errors when BOTH
|
|
174
|
+
// spellings are passed rather than silently preferring one.
|
|
175
|
+
//
|
|
176
|
+
// Every alias is listed in README's "Coming from linear-cli" table.
|
|
177
|
+
|
|
178
|
+
/** `"--due-date <date>"` → `"dueDate"`, matching commander's opts key. */
|
|
179
|
+
function optionKey(flags: string): string {
|
|
180
|
+
const long = flags.split(/[ ,|]+/).find((f) => f.startsWith("--"));
|
|
181
|
+
if (!long) throw new Error(`No long flag in '${flags}'`);
|
|
182
|
+
return long.replace(/^--/, "").replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Register `flags` as a hidden alias of the already-registered `canonical`
|
|
187
|
+
* option on `cmd`. Read the pair back with `readAlias`.
|
|
188
|
+
*/
|
|
189
|
+
export function addAliasOption(cmd: Command, flags: string, canonical: string): Command {
|
|
190
|
+
return cmd.addOption(new Option(flags, `alias of ${canonical}`).hideHelp());
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Read an option that may have been given under either spelling.
|
|
195
|
+
*
|
|
196
|
+
* Rule: passing both spellings at once is a usage error. Neither wins by
|
|
197
|
+
* default — quietly preferring one would hide a real mistake (`--due 2026-01-01
|
|
198
|
+
* --due-date 2026-02-01` is not a typo we should guess at).
|
|
199
|
+
*/
|
|
200
|
+
export function readAlias<T = string>(
|
|
201
|
+
opts: Record<string, any>,
|
|
202
|
+
canonical: string,
|
|
203
|
+
alias: string,
|
|
204
|
+
): T | undefined {
|
|
205
|
+
const canonicalValue = opts[optionKey(canonical)];
|
|
206
|
+
const aliasValue = opts[optionKey(alias)];
|
|
207
|
+
if (canonicalValue !== undefined && aliasValue !== undefined) {
|
|
208
|
+
throw usageError(`Pass either ${canonical} or ${alias}, not both.`);
|
|
209
|
+
}
|
|
210
|
+
return (canonicalValue ?? aliasValue) as T | undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A `--no-…` global registered as an ordinary boolean flag rather than as
|
|
215
|
+
* commander's *negation* of a positive option. Two bugs came out of leaving
|
|
216
|
+
* these as negations, and neither is obvious from the flag's spelling:
|
|
217
|
+
*
|
|
218
|
+
* 1. **The storage key is shared.** Commander stores a negation under the name
|
|
219
|
+
* with `no-` stripped, so `--no-color` owns the attribute `color` — the very
|
|
220
|
+
* one the entity flag `--color <hex>` owns on labels, projects and roadmaps.
|
|
221
|
+
* `label create --name x --team TES --no-color` therefore put `color: false`
|
|
222
|
+
* into the mutation input and Linear rejected it: `Variable "$input" got
|
|
223
|
+
* invalid value false at "input.color"`. There was also no way to set an
|
|
224
|
+
* entity colour *and* turn off terminal colour, since one flag overwrote
|
|
225
|
+
* the other.
|
|
226
|
+
*
|
|
227
|
+
* 2. **A lone negation is seeded with a default of `true`** on every command it
|
|
228
|
+
* is registered on — including the root program — and `optsWithGlobals()`
|
|
229
|
+
* lets **ancestors overwrite descendants**. With `enablePositionalOptions()`
|
|
230
|
+
* the flag is parsed by the *sub*command, so the root's default `true` then
|
|
231
|
+
* overwrote the subcommand's explicit `false`. That is the second, deeper
|
|
232
|
+
* half of the dead `--no-input`: even reading `input === false` would have
|
|
233
|
+
* seen `true`, because by the time `Context` gets the merged options the
|
|
234
|
+
* `false` is gone.
|
|
235
|
+
*
|
|
236
|
+
* As a plain boolean neither happens: the key is ours to choose and cannot
|
|
237
|
+
* collide with an entity field, and there is no default, so the key is simply
|
|
238
|
+
* absent unless the user passed the flag — and an absent key cannot clobber
|
|
239
|
+
* anything during the merge. Present ⇒ `true`, which is what the flag's own name
|
|
240
|
+
* says it means.
|
|
241
|
+
*/
|
|
242
|
+
class NoFlagOption extends Option {
|
|
243
|
+
private readonly key: string;
|
|
244
|
+
constructor(flags: string, description: string, key?: string) {
|
|
245
|
+
super(flags, description);
|
|
246
|
+
// Opt out of commander's negation handling; see above.
|
|
247
|
+
this.negate = false;
|
|
248
|
+
this.key = key ?? super.attributeName();
|
|
249
|
+
}
|
|
250
|
+
override attributeName(): string {
|
|
251
|
+
return this.key;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The global option set, as fresh Option instances (they are per-command state). */
|
|
256
|
+
function globalOptions(): Option[] {
|
|
257
|
+
return [
|
|
258
|
+
new Option("-j, --json", "output machine-readable JSON"),
|
|
259
|
+
new NoFlagOption("--no-ansi", "disable colored output"),
|
|
260
|
+
// `--no-color` is the conventional spelling and stays supported everywhere,
|
|
261
|
+
// including on the commands that own a `--color <hex>` of their own — which
|
|
262
|
+
// is the entire point. It shares the canonical flag's key, so the two
|
|
263
|
+
// spellings cannot drift apart.
|
|
264
|
+
new NoFlagOption("--no-color", "alias of --no-ansi", "noAnsi").hideHelp(),
|
|
265
|
+
new Option("--api-key <key>", "Linear API key (overrides env/config)"),
|
|
266
|
+
new Option("--workspace <slug>", "select workspace credential profile"),
|
|
267
|
+
new Option("-t, --team <key>", "default team key (e.g. TES)"),
|
|
268
|
+
new Option("-n, --limit <n>", "max results (positive integer; 0 = all)").argParser(
|
|
269
|
+
parsePositiveInt,
|
|
270
|
+
),
|
|
271
|
+
new Option("--all", "fetch all results (exhaust pagination)"),
|
|
272
|
+
new Option(
|
|
273
|
+
"-f, --fields <a,b,c>",
|
|
274
|
+
"select fields: table columns or detail lines (human), top-level keys (--json)",
|
|
275
|
+
).argParser(parseList),
|
|
276
|
+
new Option("-y, --yes", "skip confirmation prompts"),
|
|
277
|
+
new Option("-q, --quiet", "suppress status output"),
|
|
278
|
+
new NoFlagOption("--no-input", "never prompt; fail instead"),
|
|
279
|
+
new Option("--debug", "verbose errors (stack traces, raw GraphQL)"),
|
|
280
|
+
];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The attribute names the globals are stored under (`json`, `noAnsi`, `limit`,
|
|
285
|
+
* …), deduplicated — `--no-color` shares `noAnsi` with `--no-ansi` by design.
|
|
286
|
+
* The error boundary reads these back off the parsed command tree.
|
|
287
|
+
*/
|
|
288
|
+
export function globalOptionKeys(): string[] {
|
|
289
|
+
return [...new Set(globalOptions().map((option) => option.attributeName()))];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Register the global options shared by all commands, on the root program and
|
|
294
|
+
* (via `applyGlobalOptionsToAll` in cli.ts) on every subcommand, so they work in
|
|
295
|
+
* any position. Commander makes them inheritable via `cmd.optsWithGlobals()`.
|
|
296
|
+
*
|
|
297
|
+
* A command that has already registered its own version of a global keeps it:
|
|
298
|
+
* `issue list`/`mine`/`search` declare a **repeatable** `--team` (several teams
|
|
299
|
+
* in one query), and re-adding the single-valued global on top of it would
|
|
300
|
+
* silently take the last key instead of collecting them.
|
|
301
|
+
*
|
|
302
|
+
* On the root program this also installs the guard that rejects `--fields`,
|
|
303
|
+
* `--limit` and `--all` on commands that never read them (`assertGlobalsApply`).
|
|
304
|
+
* The root is the one command every action descends from, so one hook there
|
|
305
|
+
* covers the tree; cli.ts calls this on the root before registering anything.
|
|
306
|
+
*/
|
|
307
|
+
export function addGlobalOptions(program: Command): Command {
|
|
308
|
+
for (const option of globalOptions()) {
|
|
309
|
+
if (program.options.some((existing) => existing.long === option.long)) continue;
|
|
310
|
+
program.addOption(option);
|
|
311
|
+
}
|
|
312
|
+
if (!program.parent) {
|
|
313
|
+
program.hook("preAction", (_root, actionCommand) => assertGlobalsApply(actionCommand));
|
|
314
|
+
}
|
|
315
|
+
return program;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// --- Which commands honor which globals ------------------------------------
|
|
319
|
+
//
|
|
320
|
+
// Every global is registered on every command so it can sit anywhere on the
|
|
321
|
+
// command line, but not every command has anything to do with every global.
|
|
322
|
+
// `--fields` projects a *rendered* result — a table (`Output.list`) or a
|
|
323
|
+
// detail block (`Output.detail`); `--limit`/`--all` cap or exhaust a *paged*
|
|
324
|
+
// query. A receipt (`Output.emit`: every mutation, `issue id`, `commands`, …)
|
|
325
|
+
// has no columns to pick and no pages to walk, and on those commands the flags
|
|
326
|
+
// used to vanish without a word.
|
|
327
|
+
//
|
|
328
|
+
// That silence is a transplant hazard, not a nicety. schpet/linear-cli spells
|
|
329
|
+
// `--description-file` as `-f` on `project create`, so `linear project create
|
|
330
|
+
// --name X -f desc.md` parsed `-f` as *our* `--fields`, created the project
|
|
331
|
+
// with NO description, and exited 0 (TES-637 item 2). A receipt is only ever
|
|
332
|
+
// printed *after* the mutation, so the check cannot wait for the render to
|
|
333
|
+
// notice nothing consumed the flag: it has to run before the action, which
|
|
334
|
+
// means knowing up front which commands render. That is what these two sets
|
|
335
|
+
// are — the applicability table for the two globals whose silent no-op costs
|
|
336
|
+
// data. It is keyed by command path (`project list`), the same spelling
|
|
337
|
+
// `linear commands --json` reports, so it can feed that output when the
|
|
338
|
+
// per-command applicability of every global is exposed there (TES-596).
|
|
339
|
+
//
|
|
340
|
+
// A renderer missing from the set fails LOUDLY (`--fields` is refused where it
|
|
341
|
+
// would have worked), so the failure mode of a stale table is an obvious usage
|
|
342
|
+
// error, not a quiet wrong result; `test/unit/options.test.ts` pins every entry
|
|
343
|
+
// against the real program tree.
|
|
344
|
+
|
|
345
|
+
/** Commands whose output `--fields` projects: everything that renders a table or a detail block. */
|
|
346
|
+
export const FIELDS_COMMANDS: ReadonlySet<string> = new Set([
|
|
347
|
+
"whoami",
|
|
348
|
+
"auth whoami",
|
|
349
|
+
"auth status",
|
|
350
|
+
"auth list",
|
|
351
|
+
"config show",
|
|
352
|
+
"attachment list",
|
|
353
|
+
"comment list",
|
|
354
|
+
"cycle list",
|
|
355
|
+
"cycle view",
|
|
356
|
+
"cycle current",
|
|
357
|
+
"document list",
|
|
358
|
+
"document view",
|
|
359
|
+
"favorite list",
|
|
360
|
+
"initiative list",
|
|
361
|
+
"initiative view",
|
|
362
|
+
"initiative-update list",
|
|
363
|
+
"issue view",
|
|
364
|
+
"issue list",
|
|
365
|
+
"issue mine",
|
|
366
|
+
"issue search",
|
|
367
|
+
"issue comments",
|
|
368
|
+
"issue comment list",
|
|
369
|
+
// `relation … list` renders a table; `add`/`remove` print a receipt. One
|
|
370
|
+
// command, so it is admitted whole rather than split by its positional.
|
|
371
|
+
"issue relation",
|
|
372
|
+
"issue agent-session list",
|
|
373
|
+
"issue agent-session view",
|
|
374
|
+
"label list",
|
|
375
|
+
"milestone list",
|
|
376
|
+
"milestone view",
|
|
377
|
+
"notification list",
|
|
378
|
+
"organization view",
|
|
379
|
+
"organization members",
|
|
380
|
+
"organization invites",
|
|
381
|
+
"project list",
|
|
382
|
+
"project view",
|
|
383
|
+
"project milestones",
|
|
384
|
+
"project-update list",
|
|
385
|
+
"roadmap list",
|
|
386
|
+
"roadmap view",
|
|
387
|
+
"state list",
|
|
388
|
+
"state view",
|
|
389
|
+
"team list",
|
|
390
|
+
"team view",
|
|
391
|
+
"team id",
|
|
392
|
+
"team members",
|
|
393
|
+
"team states",
|
|
394
|
+
"team labels",
|
|
395
|
+
"team cycles",
|
|
396
|
+
"user list",
|
|
397
|
+
"user view",
|
|
398
|
+
"user me",
|
|
399
|
+
"webhook list",
|
|
400
|
+
"webhook view",
|
|
401
|
+
]);
|
|
402
|
+
|
|
403
|
+
/** Commands that page through a query: `--limit <n>` caps them, `--all` (or `--limit 0`) exhausts them. */
|
|
404
|
+
export const LIMIT_COMMANDS: ReadonlySet<string> = new Set([
|
|
405
|
+
"attachment list",
|
|
406
|
+
"comment list",
|
|
407
|
+
"cycle list",
|
|
408
|
+
"document list",
|
|
409
|
+
"favorite list",
|
|
410
|
+
"initiative list",
|
|
411
|
+
"initiative-update list",
|
|
412
|
+
"issue list",
|
|
413
|
+
"issue mine",
|
|
414
|
+
"issue search",
|
|
415
|
+
"issue comments",
|
|
416
|
+
"issue comment list",
|
|
417
|
+
"issue agent-session list",
|
|
418
|
+
"label list",
|
|
419
|
+
"milestone list",
|
|
420
|
+
// The detail block lists the milestone's issues, capped by --limit.
|
|
421
|
+
"milestone view",
|
|
422
|
+
"notification list",
|
|
423
|
+
"organization members",
|
|
424
|
+
"organization invites",
|
|
425
|
+
"project list",
|
|
426
|
+
"project milestones",
|
|
427
|
+
"project-update list",
|
|
428
|
+
"roadmap list",
|
|
429
|
+
"state list",
|
|
430
|
+
"team list",
|
|
431
|
+
"team members",
|
|
432
|
+
"team states",
|
|
433
|
+
"team labels",
|
|
434
|
+
"team cycles",
|
|
435
|
+
"user list",
|
|
436
|
+
"webhook list",
|
|
437
|
+
]);
|
|
438
|
+
|
|
439
|
+
/** Commands where the global team changes resolution, filtering, or a mutation. */
|
|
440
|
+
export const TEAM_COMMANDS: ReadonlySet<string> = new Set([
|
|
441
|
+
"config show",
|
|
442
|
+
"config init",
|
|
443
|
+
"issue view",
|
|
444
|
+
"issue list",
|
|
445
|
+
"issue mine",
|
|
446
|
+
"issue search",
|
|
447
|
+
"issue create",
|
|
448
|
+
"issue update",
|
|
449
|
+
"team view",
|
|
450
|
+
"team members",
|
|
451
|
+
"team states",
|
|
452
|
+
"team labels",
|
|
453
|
+
"team cycles",
|
|
454
|
+
"team update",
|
|
455
|
+
"project list",
|
|
456
|
+
"project create",
|
|
457
|
+
// Registered locally by project update so it can reject the dangerous
|
|
458
|
+
// singular spelling with a tailored `--teams` explanation.
|
|
459
|
+
"project update",
|
|
460
|
+
"cycle list",
|
|
461
|
+
"cycle view",
|
|
462
|
+
"cycle current",
|
|
463
|
+
"cycle create",
|
|
464
|
+
"cycle update",
|
|
465
|
+
"state list",
|
|
466
|
+
"state view",
|
|
467
|
+
"label list",
|
|
468
|
+
"label create",
|
|
469
|
+
"document list",
|
|
470
|
+
"document create",
|
|
471
|
+
"document update",
|
|
472
|
+
"webhook create",
|
|
473
|
+
]);
|
|
474
|
+
|
|
475
|
+
/** Commands whose confirmation prompt is bypassed by `--yes`. */
|
|
476
|
+
export const YES_COMMANDS: ReadonlySet<string> = new Set([
|
|
477
|
+
"attachment delete",
|
|
478
|
+
"auth logout",
|
|
479
|
+
"comment delete",
|
|
480
|
+
"issue comment delete",
|
|
481
|
+
"document delete",
|
|
482
|
+
"favorite remove",
|
|
483
|
+
"initiative archive",
|
|
484
|
+
"initiative remove-project",
|
|
485
|
+
"initiative delete",
|
|
486
|
+
"issue archive",
|
|
487
|
+
"issue delete",
|
|
488
|
+
"label delete",
|
|
489
|
+
"milestone delete",
|
|
490
|
+
"notification archive",
|
|
491
|
+
"project archive",
|
|
492
|
+
"project delete",
|
|
493
|
+
"roadmap delete",
|
|
494
|
+
"team delete",
|
|
495
|
+
"webhook delete",
|
|
496
|
+
]);
|
|
497
|
+
|
|
498
|
+
/** `project list` for the `list` command under `project` — the path without the program name. */
|
|
499
|
+
export function subcommandPath(cmd: Command): string {
|
|
500
|
+
const names: string[] = [];
|
|
501
|
+
for (let c: Command | null = cmd; c && c.parent; c = c.parent) names.unshift(c.name());
|
|
502
|
+
return names.join(" ");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* The file-reading options a command offers, if any. schpet's `-f` is
|
|
507
|
+
* `--description-file` / `--content-file` on the four commands that have them,
|
|
508
|
+
* which is exactly where a transplanted `-f <path>` lands on our `--fields`; the
|
|
509
|
+
* error names the flag the user was reaching for.
|
|
510
|
+
*/
|
|
511
|
+
function fileOptionsOf(cmd: Command): string[] {
|
|
512
|
+
return cmd.options
|
|
513
|
+
.filter((o) => o.long === "--description-file" || o.long === "--content-file")
|
|
514
|
+
.map((o) => `${o.long} <path>`);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Refuse `--fields`, `--limit` and `--all` on a command that never reads them.
|
|
519
|
+
*
|
|
520
|
+
* Runs as the root's `preAction` hook — before the action, and so before any
|
|
521
|
+
* request — for every command in the tree. The root's own action (bare
|
|
522
|
+
* `linear`, which renders the branch's issue) and group commands (no action of
|
|
523
|
+
* their own) are left alone. Rejection is a usage error naming the command and
|
|
524
|
+
* what it prints instead, so a `-f desc.md` meant as a file lands on the flag
|
|
525
|
+
* that reads one, and a `-n 'My Project'` meant as a name is a message about
|
|
526
|
+
* `--limit`, never a project created without either.
|
|
527
|
+
*/
|
|
528
|
+
export function assertGlobalsApply(cmd: Command): void {
|
|
529
|
+
const path = subcommandPath(cmd);
|
|
530
|
+
if (!path) return;
|
|
531
|
+
const merged = cmd.optsWithGlobals() as Record<string, unknown>;
|
|
532
|
+
if (merged.fields !== undefined && !FIELDS_COMMANDS.has(path)) {
|
|
533
|
+
const files = fileOptionsOf(cmd);
|
|
534
|
+
throw usageError(
|
|
535
|
+
`--fields does not apply to \`linear ${path}\`: it prints a receipt, not a table or detail block to select fields from.` +
|
|
536
|
+
(files.length
|
|
537
|
+
? ` To read from a file, use ${files.join(" or ")} (-f is --fields here, not a file).`
|
|
538
|
+
: ""),
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
if (!LIMIT_COMMANDS.has(path)) {
|
|
542
|
+
if (merged.limit !== undefined) {
|
|
543
|
+
throw usageError(
|
|
544
|
+
`--limit does not apply to \`linear ${path}\`: it is not a paged query.` +
|
|
545
|
+
(cmd.options.some((o) => o.long === "--name")
|
|
546
|
+
? " (-n is --limit here; the name is --name <name>.)"
|
|
547
|
+
: ""),
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
if (merged.all === true) {
|
|
551
|
+
throw usageError(`--all does not apply to \`linear ${path}\`: it is not a paged query.`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (merged.team !== undefined && !TEAM_COMMANDS.has(path)) {
|
|
555
|
+
throw usageError(`--team does not apply to \`linear ${path}\`.`);
|
|
556
|
+
}
|
|
557
|
+
if (merged.yes === true && !YES_COMMANDS.has(path)) {
|
|
558
|
+
throw usageError(`--yes does not apply to \`linear ${path}\`: it has no confirmation prompt.`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Opt-outs for the shared filter sets; `issue mine` is fixed to the viewer. */
|
|
563
|
+
export interface FilterOptionSet {
|
|
564
|
+
/** Register `-a, --assignee`. Off for `issue mine`, whose assignee is you. */
|
|
565
|
+
assignee?: boolean;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Issue filters shared by `issue list`, `issue search`, and `issue mine`. All
|
|
570
|
+
* narrow to the default team unless `--all-teams` widens them back to the whole
|
|
571
|
+
* workspace. Repeating `--label` narrows (the issue must carry every label).
|
|
572
|
+
*
|
|
573
|
+
* `--team` and `--state` are **repeatable here only**. `--team` is a global
|
|
574
|
+
* option used by ~135 commands for far more than filtering (it is the team an
|
|
575
|
+
* issue is created in, the team a cycle belongs to, …), where "several teams"
|
|
576
|
+
* has no meaning; making the global itself a list would push an array through
|
|
577
|
+
* every one of those call sites. So the queries — the only commands where a
|
|
578
|
+
* multi-team answer is well-defined — declare their own repeatable `--team`,
|
|
579
|
+
* and `addGlobalOptions` leaves it alone. Repeating either flag BROADENS, unlike
|
|
580
|
+
* `--label`: an issue belongs to exactly one team and sits in exactly one state,
|
|
581
|
+
* so narrowing would be a filter that can never match.
|
|
582
|
+
*/
|
|
583
|
+
export function addCoreFilterOptions(cmd: Command, set: FilterOptionSet = {}): Command {
|
|
584
|
+
cmd.addOption(
|
|
585
|
+
new Option(
|
|
586
|
+
"-t, --team <key>",
|
|
587
|
+
"filter by team key (repeatable; default: configured team)",
|
|
588
|
+
).argParser(collectArray),
|
|
589
|
+
);
|
|
590
|
+
cmd.addOption(
|
|
591
|
+
new Option("-s, --state <name>", "filter by workflow state name/type (repeatable)").argParser(
|
|
592
|
+
collectArray,
|
|
593
|
+
),
|
|
594
|
+
);
|
|
595
|
+
if (set.assignee !== false) {
|
|
596
|
+
cmd.addOption(new Option("-a, --assignee <who>", "filter by assignee (me|email|name)"));
|
|
597
|
+
cmd.addOption(new Option("-U, --unassigned", "only issues with no assignee"));
|
|
598
|
+
}
|
|
599
|
+
return cmd
|
|
600
|
+
.addOption(new Option("-p, --project <name>", "filter by project"))
|
|
601
|
+
.addOption(
|
|
602
|
+
new Option("--project-label <name>", "filter by the project's label (excludes --project)"),
|
|
603
|
+
)
|
|
604
|
+
.addOption(new Option("--milestone <name>", "filter by project milestone"))
|
|
605
|
+
.addOption(
|
|
606
|
+
new Option("-l, --label <name>", "filter by label (repeat to narrow)").argParser(parseList),
|
|
607
|
+
)
|
|
608
|
+
.addOption(
|
|
609
|
+
new Option("-P, --priority <0-4>", "filter by priority").argParser(parsePriorityFilter),
|
|
610
|
+
)
|
|
611
|
+
.addOption(new Option(CYCLE_FLAG, CYCLE_DESC))
|
|
612
|
+
.addOption(
|
|
613
|
+
new Option("--created-after <date>", "only issues created at/after a date (YYYY-MM-DD)"),
|
|
614
|
+
)
|
|
615
|
+
.addOption(
|
|
616
|
+
new Option("--updated-after <date>", "only issues updated at/after a date (YYYY-MM-DD)"),
|
|
617
|
+
)
|
|
618
|
+
.addOption(new Option("--all-teams", "search every team, ignoring the default team"))
|
|
619
|
+
.addOption(new Option("--include-archived", "include archived issues"));
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* `issue list` / `issue mine` filters: the shared set plus full-text and sort,
|
|
624
|
+
* neither of which applies to `issue search` (the term *is* the text, and search
|
|
625
|
+
* is relevance-ordered).
|
|
626
|
+
*/
|
|
627
|
+
export function addFilterOptions(cmd: Command, set: FilterOptionSet = {}): Command {
|
|
628
|
+
addCoreFilterOptions(cmd, set)
|
|
629
|
+
.addOption(new Option("--query <text>", "full-text search"))
|
|
630
|
+
.addOption(
|
|
631
|
+
new Option("--sort <field>", "sort order").choices([
|
|
632
|
+
"priority",
|
|
633
|
+
"manual",
|
|
634
|
+
"updated",
|
|
635
|
+
"created",
|
|
636
|
+
]),
|
|
637
|
+
);
|
|
638
|
+
// `--search` is the reference CLI's spelling of the same filter.
|
|
639
|
+
return addAliasOption(cmd, "--search <text>", "--query");
|
|
640
|
+
}
|