linear-sdk-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +9 -0
  3. package/CHANGELOG.md +807 -0
  4. package/COVERAGE.md +495 -0
  5. package/LICENSE +21 -0
  6. package/MIGRATING.md +220 -0
  7. package/README.md +524 -0
  8. package/package.json +87 -0
  9. package/skills/linear-sdk-cli/SKILL.md +534 -0
  10. package/skills/linear-sdk-cli/references/api.md +27 -0
  11. package/skills/linear-sdk-cli/references/attachment.md +82 -0
  12. package/skills/linear-sdk-cli/references/auth.md +174 -0
  13. package/skills/linear-sdk-cli/references/commands.md +37 -0
  14. package/skills/linear-sdk-cli/references/comment.md +154 -0
  15. package/skills/linear-sdk-cli/references/completion.md +17 -0
  16. package/skills/linear-sdk-cli/references/config.md +99 -0
  17. package/skills/linear-sdk-cli/references/cycle.md +131 -0
  18. package/skills/linear-sdk-cli/references/document.md +180 -0
  19. package/skills/linear-sdk-cli/references/favorite.md +78 -0
  20. package/skills/linear-sdk-cli/references/initiative-update.md +67 -0
  21. package/skills/linear-sdk-cli/references/initiative.md +256 -0
  22. package/skills/linear-sdk-cli/references/issue.md +928 -0
  23. package/skills/linear-sdk-cli/references/label.md +112 -0
  24. package/skills/linear-sdk-cli/references/milestone.md +139 -0
  25. package/skills/linear-sdk-cli/references/notification.md +123 -0
  26. package/skills/linear-sdk-cli/references/open.md +29 -0
  27. package/skills/linear-sdk-cli/references/organization.md +99 -0
  28. package/skills/linear-sdk-cli/references/project-update.md +67 -0
  29. package/skills/linear-sdk-cli/references/project.md +234 -0
  30. package/skills/linear-sdk-cli/references/roadmap.md +150 -0
  31. package/skills/linear-sdk-cli/references/schema.md +21 -0
  32. package/skills/linear-sdk-cli/references/state.md +61 -0
  33. package/skills/linear-sdk-cli/references/team.md +227 -0
  34. package/skills/linear-sdk-cli/references/user.md +103 -0
  35. package/skills/linear-sdk-cli/references/webhook.md +151 -0
  36. package/skills/linear-sdk-cli/references/whoami.md +26 -0
  37. package/skills/linear-sdk-cli/scripts/generate-docs.ts +339 -0
  38. package/src/bin/linear.ts +97 -0
  39. package/src/cli.ts +232 -0
  40. package/src/client.ts +172 -0
  41. package/src/commands/agent-session.ts +121 -0
  42. package/src/commands/api.ts +317 -0
  43. package/src/commands/attach.ts +90 -0
  44. package/src/commands/attachment.ts +74 -0
  45. package/src/commands/comment.ts +225 -0
  46. package/src/commands/completion.ts +69 -0
  47. package/src/commands/cycle.ts +125 -0
  48. package/src/commands/discover.ts +170 -0
  49. package/src/commands/document.ts +211 -0
  50. package/src/commands/favorite.ts +72 -0
  51. package/src/commands/initiative-update.ts +47 -0
  52. package/src/commands/initiative.ts +261 -0
  53. package/src/commands/issue.ts +1263 -0
  54. package/src/commands/label.ts +119 -0
  55. package/src/commands/meta.ts +534 -0
  56. package/src/commands/milestone.ts +163 -0
  57. package/src/commands/notification.ts +110 -0
  58. package/src/commands/open.ts +84 -0
  59. package/src/commands/organization.ts +79 -0
  60. package/src/commands/project-update.ts +47 -0
  61. package/src/commands/project.ts +339 -0
  62. package/src/commands/roadmap.ts +131 -0
  63. package/src/commands/state.ts +58 -0
  64. package/src/commands/team.ts +243 -0
  65. package/src/commands/user.ts +107 -0
  66. package/src/commands/webhook.ts +156 -0
  67. package/src/config.ts +1236 -0
  68. package/src/context.ts +117 -0
  69. package/src/git.ts +174 -0
  70. package/src/index.ts +10 -0
  71. package/src/lib/action.ts +26 -0
  72. package/src/lib/body.ts +172 -0
  73. package/src/lib/errors.ts +195 -0
  74. package/src/lib/introspect.ts +150 -0
  75. package/src/lib/keyring.ts +270 -0
  76. package/src/lib/mutation.ts +53 -0
  77. package/src/lib/open.ts +32 -0
  78. package/src/lib/options.ts +640 -0
  79. package/src/lib/output-shapes.ts +562 -0
  80. package/src/lib/pagination.ts +145 -0
  81. package/src/lib/prompt.ts +91 -0
  82. package/src/lib/resolve.ts +596 -0
  83. package/src/lib/shape.ts +273 -0
  84. package/src/lib/status-update.ts +91 -0
  85. package/src/lib/upload.ts +329 -0
  86. package/src/output/color.ts +52 -0
  87. package/src/output/format.ts +238 -0
  88. package/src/output/markdown.ts +133 -0
  89. package/src/output/pager.ts +131 -0
  90. package/src/output/sanitize.ts +76 -0
  91. package/src/output/table.ts +198 -0
  92. package/src/services/agent-session.ts +289 -0
  93. package/src/services/attachment.ts +198 -0
  94. package/src/services/comment.ts +262 -0
  95. package/src/services/cycle.ts +224 -0
  96. package/src/services/document.ts +357 -0
  97. package/src/services/favorite.ts +147 -0
  98. package/src/services/initiative-update.ts +59 -0
  99. package/src/services/initiative.ts +449 -0
  100. package/src/services/issue.ts +1153 -0
  101. package/src/services/label.ts +193 -0
  102. package/src/services/milestone.ts +262 -0
  103. package/src/services/notification.ts +190 -0
  104. package/src/services/organization.ts +144 -0
  105. package/src/services/project-update.ts +36 -0
  106. package/src/services/project.ts +494 -0
  107. package/src/services/roadmap.ts +177 -0
  108. package/src/services/state.ts +127 -0
  109. package/src/services/team.ts +386 -0
  110. package/src/services/user.ts +137 -0
  111. package/src/services/webhook.ts +203 -0
package/src/context.ts ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Per-invocation context: resolved config, lazily-created client, and the
3
+ * Output sink. Built once from the root program's global options and threaded
4
+ * into every command action.
5
+ */
6
+
7
+ import type { LinearClient } from "@linear/sdk";
8
+ import { resolveConfig, type ResolvedConfig } from "./config.js";
9
+ import { createClient, setRetryReporter } from "./client.js";
10
+ import { Output } from "./output/format.js";
11
+ import { isDebugEnabled, shouldUseColor } from "./output/color.js";
12
+
13
+ /**
14
+ * The globals as the parser actually stores them.
15
+ *
16
+ * This interface used to declare `noInput` while commander stored `input:
17
+ * false` — the field was never written by anything, stayed `undefined` forever,
18
+ * and the prompt guard built on it never fired. The two agree now because the
19
+ * flags are registered as plain booleans (`NoFlagOption` in lib/options.ts)
20
+ * whose keys we choose, rather than as commander negations whose keys we only
21
+ * assumed. `test/unit/options.test.ts` pins the keys against a real parse, so
22
+ * the type cannot quietly go back to describing a parser that does not exist.
23
+ */
24
+ export interface GlobalOptions {
25
+ json?: boolean;
26
+ /** `--no-ansi`, or its alias `--no-color`. Present (true) only when passed. */
27
+ noAnsi?: boolean;
28
+ /** `--no-input`. Present (true) only when passed. */
29
+ noInput?: boolean;
30
+ apiKey?: string;
31
+ /** A key, or several — `issue list`/`mine`/`search` make `--team` repeatable. */
32
+ team?: string | string[];
33
+ workspace?: string;
34
+ limit?: number;
35
+ all?: boolean;
36
+ fields?: string[];
37
+ yes?: boolean;
38
+ quiet?: boolean;
39
+ debug?: boolean;
40
+ }
41
+
42
+ /** `--team` may arrive repeated (issue queries); single-team consumers take the first. */
43
+ export function firstTeam(team: string | string[] | undefined): string | undefined {
44
+ return Array.isArray(team) ? team[0] : team;
45
+ }
46
+
47
+ export class Context {
48
+ readonly config: ResolvedConfig;
49
+ readonly output: Output;
50
+ readonly options: GlobalOptions;
51
+ readonly isTTY: boolean;
52
+ private _client?: LinearClient;
53
+
54
+ constructor(options: GlobalOptions) {
55
+ this.options = options;
56
+ this.config = resolveConfig({
57
+ // The issue queries accept `--team` several times; everything downstream
58
+ // of the config (the default team for creates, cycle lookups, …) is
59
+ // single-valued, so those commands read the full list off their own opts
60
+ // and the config sees the first key.
61
+ flags: {
62
+ apiKey: options.apiKey,
63
+ team: firstTeam(options.team),
64
+ workspace: options.workspace,
65
+ },
66
+ });
67
+ const color = shouldUseColor({
68
+ disabled: options.noAnsi,
69
+ json: options.json,
70
+ isTTY: process.stdout.isTTY === true,
71
+ });
72
+ // Four independent reasons not to prompt, and any one of them is enough.
73
+ // The first two are the user saying so; the last two are the situation
74
+ // saying so. `--json` counts because JSON is what a script or an agent
75
+ // asks for, and a prompt inside a pipeline is a hang, not a question —
76
+ // there is no one at the other end to answer it. Non-TTY stdout counts for
77
+ // the same reason: inquirer draws on stdout, so a redirect would send the
78
+ // question into the file the caller is collecting.
79
+ this.isTTY =
80
+ options.noInput !== true &&
81
+ options.json !== true &&
82
+ process.stdin.isTTY === true &&
83
+ process.stdout.isTTY === true;
84
+ this.output = new Output({
85
+ json: options.json === true,
86
+ color,
87
+ quiet: options.quiet === true,
88
+ debug: isDebugEnabled(options.debug),
89
+ fields: options.fields,
90
+ isTTY: process.stdout.isTTY === true,
91
+ });
92
+ // Rate-limit waits are status, so they go through the same sink as every
93
+ // other status line: stderr, silenced by --quiet, never on JSON stdout.
94
+ setRetryReporter((line) => this.output.info(line));
95
+ }
96
+
97
+ /** Lazily construct the Linear client (so `--help` never needs a key). */
98
+ get client(): LinearClient {
99
+ if (!this._client) this._client = createClient(this.config);
100
+ return this._client;
101
+ }
102
+
103
+ /** Default team key from flags/config (used when a command omits --team). */
104
+ get defaultTeam(): string | undefined {
105
+ return firstTeam(this.options.team) ?? this.config.team;
106
+ }
107
+
108
+ /**
109
+ * Resolve the effective page limit. `--all` means exhaust (Infinity), and so
110
+ * does `--limit 0` — the reference CLI's spelling for "no limit", accepted
111
+ * here so a transplanted script does not silently get the 50-row default.
112
+ */
113
+ get limit(): number {
114
+ if (this.options.all || this.options.limit === 0) return Infinity;
115
+ return this.options.limit && this.options.limit > 0 ? this.options.limit : 50;
116
+ }
117
+ }
package/src/git.ts ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Git integration: infer the "current issue" from the branch name, build
3
+ * suggested branch names, and assemble commit trailers / `gh pr create` argv.
4
+ * Pure parsing/building logic is separated for unit testing.
5
+ */
6
+
7
+ import { execFileSync } from "node:child_process";
8
+ import { CliError } from "./lib/errors.js";
9
+
10
+ /**
11
+ * Extract a Linear issue identifier (e.g. `TES-123`) from a git branch name.
12
+ * Linear's suggested branches look like `username/tes-123-title` or
13
+ * `tes-123-title`; we grab the first `<letters>-<digits>` token and uppercase
14
+ * the team key.
15
+ */
16
+ export function branchToIssueId(branch: string | undefined | null): string | undefined {
17
+ if (!branch) return undefined;
18
+ // Strip any leading path segment(s) like `feature/` or `user/`.
19
+ const match = branch.match(/(?:^|[/_-])([a-zA-Z][a-zA-Z0-9]*)-(\d+)(?:$|[/_-])/);
20
+ if (!match) return undefined;
21
+ const [, key, num] = match;
22
+ if (!key || !num) return undefined;
23
+ return `${key.toUpperCase()}-${num}`;
24
+ }
25
+
26
+ /** Run a git command, returning trimmed stdout or undefined on any failure. */
27
+ function git(args: string[], cwd?: string): string | undefined {
28
+ try {
29
+ return execFileSync("git", args, {
30
+ cwd,
31
+ stdio: ["ignore", "pipe", "ignore"],
32
+ encoding: "utf8",
33
+ }).trim();
34
+ } catch {
35
+ return undefined;
36
+ }
37
+ }
38
+
39
+ export function currentBranch(cwd?: string): string | undefined {
40
+ const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
41
+ return branch && branch !== "HEAD" ? branch : undefined;
42
+ }
43
+
44
+ /** The issue identifier inferred from the current branch, if any. */
45
+ export function currentIssueId(cwd?: string): string | undefined {
46
+ return branchToIssueId(currentBranch(cwd));
47
+ }
48
+
49
+ export function isGitRepo(cwd?: string): boolean {
50
+ return git(["rev-parse", "--is-inside-work-tree"], cwd) === "true";
51
+ }
52
+
53
+ /**
54
+ * The Linear magic-word phrase that links a commit or PR to an issue: `Fixes
55
+ * <ID>` closes the issue on merge; the `references` option gives `References
56
+ * <ID>`, which links without closing. Both are on Linear's list of recognized
57
+ * words (linear.app/docs/github); the word must sit directly before the id.
58
+ */
59
+ export function buildTrailer(identifier: string, opts: { references?: boolean } = {}): string {
60
+ return `${opts.references ? "References" : "Fixes"} ${identifier}`;
61
+ }
62
+
63
+ /**
64
+ * The two git trailers `issue describe` and `issue pull-request` emit — the
65
+ * exact lines schpet/linear-cli writes (T `src/utils/jj.ts`), so a transplanted
66
+ * `git commit -m "$(linear issue describe)"` produces the same commit:
67
+ *
68
+ * Linear-issue: Fixes TES-123
69
+ * Linear-issue-url: https://linear.app/…/issue/TES-123/…
70
+ *
71
+ * Proper `Key: value` trailers rather than a bare `Fixes TES-123` line, on
72
+ * purpose: `git interpret-trailers` / `git log --format=%(trailers:key=Linear-issue)`
73
+ * and jj's `trailers` template can read them back (schpet's jj mode infers the
74
+ * current issue from exactly this trailer), while Linear still sees the magic
75
+ * word directly before the id and links — and closes — the issue. The URL line is
76
+ * for the humans reading the log.
77
+ */
78
+ export function buildTrailers(
79
+ identifier: string,
80
+ url: string,
81
+ opts: { references?: boolean } = {},
82
+ ): string {
83
+ return `Linear-issue: ${buildTrailer(identifier, opts)}\nLinear-issue-url: ${url}`;
84
+ }
85
+
86
+ /**
87
+ * What `issue describe` prints: schpet's subject line (`ID Title`), a blank
88
+ * line, then the trailers. Piped into `git commit -m` / `jj describe -m` it is
89
+ * the whole message.
90
+ */
91
+ export function buildDescription(
92
+ identifier: string,
93
+ title: string,
94
+ url: string,
95
+ opts: { references?: boolean } = {},
96
+ ): string {
97
+ return `${identifier} ${title}\n\n${buildTrailers(identifier, url, opts)}`;
98
+ }
99
+
100
+ /**
101
+ * The title and body `issue pull-request` hands to `gh pr create`: schpet's
102
+ * `ID Title` (a custom title is prefixed the same way), and the two trailers as
103
+ * the body — the URL for the humans, the magic word so Linear links (and, on
104
+ * merge, closes) the issue even when the branch name does not carry the id.
105
+ */
106
+ export function buildPrContent(
107
+ issue: { identifier: string; title: string; url: string },
108
+ customTitle?: string,
109
+ ): { title: string; body: string } {
110
+ return {
111
+ title: `${issue.identifier} ${customTitle ?? issue.title}`,
112
+ body: buildTrailers(issue.identifier, issue.url),
113
+ };
114
+ }
115
+
116
+ export interface PrArgsInput {
117
+ title: string;
118
+ body: string;
119
+ base?: string;
120
+ head?: string;
121
+ draft?: boolean;
122
+ web?: boolean;
123
+ }
124
+
125
+ /**
126
+ * Build the argv passed to `gh pr create`. Title and body are always present;
127
+ * base/head/draft/web are included only when set. Pure + unit-tested so the
128
+ * exact flag wiring is verifiable without shelling out to `gh`.
129
+ */
130
+ export function buildPrArgs(input: PrArgsInput): string[] {
131
+ const args = ["pr", "create", "--title", input.title, "--body", input.body];
132
+ if (input.base) args.push("--base", input.base);
133
+ if (input.head) args.push("--head", input.head);
134
+ if (input.draft) args.push("--draft");
135
+ if (input.web) args.push("--web");
136
+ return args;
137
+ }
138
+
139
+ export interface CheckoutResult {
140
+ branch: string;
141
+ created: boolean;
142
+ }
143
+
144
+ /** Run the mutating checkout command and retain git's explanation on failure. */
145
+ function gitCheckout(args: string[], cwd?: string): void {
146
+ try {
147
+ execFileSync("git", args, {
148
+ cwd,
149
+ stdio: ["ignore", "pipe", "pipe"],
150
+ encoding: "utf8",
151
+ });
152
+ } catch (err) {
153
+ const e = err as NodeJS.ErrnoException & { status?: number; stderr?: Buffer | string };
154
+ if (e.code === "ENOENT")
155
+ throw new CliError("Git is required to check out an issue branch.", "runtime");
156
+ const stderr = e.stderr ? e.stderr.toString().trim() : "";
157
+ throw new CliError(
158
+ stderr || `git ${args.join(" ")} exited with code ${e.status ?? 1}.`,
159
+ "runtime",
160
+ );
161
+ }
162
+ }
163
+
164
+ /** Checkout `branch`, creating it if it does not exist. */
165
+ export function checkoutBranch(branch: string, cwd?: string): CheckoutResult {
166
+ const exists =
167
+ git(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], cwd) !== undefined;
168
+ if (exists) {
169
+ gitCheckout(["checkout", branch], cwd);
170
+ return { branch, created: false };
171
+ }
172
+ gitCheckout(["checkout", "-b", branch], cwd);
173
+ return { branch, created: true };
174
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Programmatic entry point. Lets the CLI be embedded/tested without spawning.
3
+ */
4
+
5
+ export { createProgram, VERSION } from "./cli.js";
6
+ export { Context } from "./context.js";
7
+ export { resolveConfig, type ResolvedConfig } from "./config.js";
8
+ export { createClient } from "./client.js";
9
+ export { CliError, ExitCode, normalizeError } from "./lib/errors.js";
10
+ export { branchToIssueId, currentIssueId } from "./git.js";
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Wrap a command handler so it receives a built Context, the command's local
3
+ * options, and positionals — in that order. Errors propagate to the central
4
+ * boundary in bin/linear.ts.
5
+ *
6
+ * Commander invokes actions as (...positionals, localOpts, command).
7
+ */
8
+
9
+ import type { Command } from "commander";
10
+ import { Context, type GlobalOptions } from "../context.js";
11
+
12
+ export type ActionHandler = (
13
+ ctx: Context,
14
+ opts: Record<string, any>,
15
+ ...positionals: any[]
16
+ ) => Promise<void> | void;
17
+
18
+ export function action(handler: ActionHandler) {
19
+ return async (...args: any[]): Promise<void> => {
20
+ const command = args[args.length - 1] as Command;
21
+ const localOpts = (args[args.length - 2] ?? {}) as Record<string, any>;
22
+ const positionals = args.slice(0, -2);
23
+ const ctx = new Context(command.optsWithGlobals() as GlobalOptions);
24
+ await handler(ctx, localOpts, ...positionals);
25
+ };
26
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Resolve a text body (issue/comment/document content) from, in priority:
3
+ * 1. an explicit positional/flag argument
4
+ * 2. --body-file <path> ('-' reads stdin)
5
+ * 3. $EDITOR (only when interactive)
6
+ *
7
+ * Returns undefined when no source is available and interaction is not allowed.
8
+ */
9
+
10
+ import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { spawnSync } from "node:child_process";
14
+ import { CliError, usageError } from "./errors.js";
15
+
16
+ export interface BodyInputs {
17
+ arg?: string;
18
+ file?: string;
19
+ /** Whether $EDITOR may be invoked (TTY + not --no-input/--json). */
20
+ interactive: boolean;
21
+ /**
22
+ * The user explicitly asked for the editor (`--editor`). Kept separate from
23
+ * `interactive` so an unavailable editor fails instead of silently producing
24
+ * an empty body — `--editor --json` used to create issues with no description
25
+ * at all, since `--json` implies non-interactive.
26
+ */
27
+ editorRequested?: boolean;
28
+ /** Initial content to seed the editor with. */
29
+ template?: string;
30
+ }
31
+
32
+ export function resolveBody(inputs: BodyInputs): string | undefined {
33
+ if (inputs.arg !== undefined) return validateInlineBody(inputs.arg);
34
+ if (inputs.file !== undefined) return readBodyFile(inputs.file);
35
+ if (inputs.interactive) return openEditor(inputs.template ?? "");
36
+ if (inputs.editorRequested) {
37
+ throw usageError(
38
+ "--editor needs an interactive terminal, and is unavailable under --json, --no-input, " +
39
+ "or when input/output is redirected. Pass the text directly or read it from a file.",
40
+ );
41
+ }
42
+ return undefined;
43
+ }
44
+
45
+ /**
46
+ * A quoted shell argument does not interpret `\n`. This is an especially easy
47
+ * mistake for an agent composing Markdown in one command: Linear then stores
48
+ * and renders the two literal characters, often across an entire issue body.
49
+ *
50
+ * Do not silently unescape them. A literal backslash-n can be intentional in
51
+ * source code, JSON, or a regular expression, and changing it would corrupt
52
+ * user data. File/stdin input is the unambiguous route for both real newlines
53
+ * and intentional escape syntax.
54
+ */
55
+ export function validateInlineBody(body: string): string {
56
+ if (body.includes("\\n")) {
57
+ throw usageError(
58
+ "Inline text contains a literal \\n sequence; shell quotes do not turn it into a line break. " +
59
+ "Use this command's *-file option with '-' and provide the Markdown on stdin. " +
60
+ "If the literal \\n is intentional, file input preserves it unchanged.",
61
+ );
62
+ }
63
+ return body;
64
+ }
65
+
66
+ function readBodyFile(file: string): string {
67
+ if (file === "-") return readStdinSync();
68
+ try {
69
+ return readFileSync(file, "utf8");
70
+ } catch (err) {
71
+ throw usageError(`Cannot read body file '${file}': ${(err as Error).message}`);
72
+ }
73
+ }
74
+
75
+ export function readStdinSync(): string {
76
+ try {
77
+ return readFileSync(0, "utf8");
78
+ } catch {
79
+ return "";
80
+ }
81
+ }
82
+
83
+ /**
84
+ * The editor command, git's way: `$VISUAL`, then `$EDITOR`, then `vi` — and
85
+ * the value is a *command line*, not a program name. `code --wait`, `subl -w`,
86
+ * `vim -f`, `emacsclient -t` are how those editors are documented to be set,
87
+ * so it is split shell-style (quotes and backslashes honoured) into argv, and
88
+ * the file is appended. Exported for tests.
89
+ */
90
+ export function editorCommand(env: NodeJS.ProcessEnv = process.env): {
91
+ raw: string;
92
+ argv: string[];
93
+ } {
94
+ const raw = env.VISUAL || env.EDITOR || "vi";
95
+ return { raw, argv: shellSplit(raw) };
96
+ }
97
+
98
+ /**
99
+ * Split one line into words the way a POSIX shell would for a simple command:
100
+ * whitespace separates, single quotes are literal, double quotes allow `\"`
101
+ * and `\\`, an unquoted backslash escapes the next character. No expansions —
102
+ * `$HOME` stays `$HOME` — because an editor setting is not a shell script.
103
+ */
104
+ export function shellSplit(line: string): string[] {
105
+ const out: string[] = [];
106
+ let cur = "";
107
+ let inWord = false;
108
+ let quote: '"' | "'" | null = null;
109
+ for (let i = 0; i < line.length; i++) {
110
+ const c = line[i]!;
111
+ if (quote === "'") {
112
+ if (c === "'") quote = null;
113
+ else cur += c;
114
+ } else if (quote === '"') {
115
+ if (c === '"') quote = null;
116
+ else if (c === "\\" && i + 1 < line.length && '"\\'.includes(line[i + 1]!)) cur += line[++i];
117
+ else cur += c;
118
+ } else if (c === "'" || c === '"') {
119
+ quote = c;
120
+ inWord = true;
121
+ } else if (c === "\\" && i + 1 < line.length) {
122
+ cur += line[++i];
123
+ inWord = true;
124
+ } else if (/\s/.test(c)) {
125
+ if (inWord) out.push(cur);
126
+ cur = "";
127
+ inWord = false;
128
+ } else {
129
+ cur += c;
130
+ inWord = true;
131
+ }
132
+ }
133
+ if (quote) throw usageError(`Unbalanced quote in editor setting: ${line}`);
134
+ if (inWord) out.push(cur);
135
+ return out;
136
+ }
137
+
138
+ function openEditor(template: string): string {
139
+ const { raw, argv } = editorCommand();
140
+ const [cmd, ...args] = argv;
141
+ if (!cmd) throw usageError("VISUAL/EDITOR is set but empty; set it to an editor on your PATH.");
142
+ const dir = mkdtempSync(join(tmpdir(), "linear-"));
143
+ const file = join(dir, "LINEAR_EDITMSG.md");
144
+ try {
145
+ writeFileSync(file, template, "utf8");
146
+ const r = spawnSync(cmd, [...args, file], { stdio: "inherit" });
147
+ if (r.error) {
148
+ const e = r.error as NodeJS.ErrnoException;
149
+ if (e.code === "ENOENT") {
150
+ const from = process.env.VISUAL
151
+ ? `VISUAL='${raw}'`
152
+ : process.env.EDITOR
153
+ ? `EDITOR='${raw}'`
154
+ : "the default, neither VISUAL nor EDITOR being set";
155
+ throw usageError(
156
+ `Editor '${cmd}' not found (from ${from}). Set VISUAL or EDITOR to an editor on your PATH.`,
157
+ );
158
+ }
159
+ throw new CliError(`Could not run editor '${raw}': ${e.message}`, "runtime");
160
+ }
161
+ if (r.status !== 0) {
162
+ // git aborts here too: an editor that failed did not save what you meant.
163
+ throw new CliError(
164
+ `Editor '${raw}' exited with status ${r.status ?? `signal ${r.signal}`}; nothing was saved.`,
165
+ "runtime",
166
+ );
167
+ }
168
+ return readFileSync(file, "utf8").trim();
169
+ } finally {
170
+ rmSync(dir, { recursive: true, force: true });
171
+ }
172
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Error handling: a single `CliError` type the whole app throws, plus a
3
+ * normalizer that maps Linear SDK errors into stable codes + exit codes.
4
+ *
5
+ * Exit codes (documented in PLAN.md):
6
+ * 0 ok · 1 runtime/API · 2 usage · 3 not-found/ambiguous · 4 auth · 5 rate-limited
7
+ */
8
+
9
+ export const ExitCode = {
10
+ Ok: 0,
11
+ Runtime: 1,
12
+ Usage: 2,
13
+ NotFound: 3,
14
+ Auth: 4,
15
+ RateLimited: 5,
16
+ } as const;
17
+
18
+ export type ExitCodeValue = (typeof ExitCode)[keyof typeof ExitCode];
19
+
20
+ /** Stable machine-readable error codes surfaced in the JSON error envelope. */
21
+ export type ErrorCode =
22
+ | "usage"
23
+ | "auth"
24
+ | "not_found"
25
+ | "ambiguous"
26
+ | "forbidden"
27
+ | "validation"
28
+ | "rate_limited"
29
+ | "network"
30
+ | "feature_not_accessible"
31
+ | "api"
32
+ | "runtime";
33
+
34
+ const EXIT_BY_CODE: Record<ErrorCode, ExitCodeValue> = {
35
+ usage: ExitCode.Usage,
36
+ auth: ExitCode.Auth,
37
+ not_found: ExitCode.NotFound,
38
+ ambiguous: ExitCode.NotFound,
39
+ forbidden: ExitCode.Auth,
40
+ validation: ExitCode.Usage,
41
+ rate_limited: ExitCode.RateLimited,
42
+ network: ExitCode.Runtime,
43
+ feature_not_accessible: ExitCode.Runtime,
44
+ api: ExitCode.Runtime,
45
+ runtime: ExitCode.Runtime,
46
+ };
47
+
48
+ export class CliError extends Error {
49
+ readonly code: ErrorCode;
50
+ readonly exitCode: ExitCodeValue;
51
+ /** Extra detail surfaced only with --debug (e.g. raw GraphQL errors). */
52
+ readonly detail?: unknown;
53
+ /** A separately actionable hint; machine callers never have to parse prose. */
54
+ readonly suggestion?: string;
55
+
56
+ constructor(message: string, code: ErrorCode = "runtime", detail?: unknown, suggestion?: string) {
57
+ super(message);
58
+ this.name = "CliError";
59
+ this.code = code;
60
+ this.exitCode = EXIT_BY_CODE[code];
61
+ this.detail = detail;
62
+ this.suggestion = suggestion;
63
+ }
64
+ }
65
+
66
+ /** Convenience constructors for the common cases. */
67
+ export const usageError = (m: string, suggestion?: string) =>
68
+ new CliError(m, "usage", undefined, suggestion);
69
+ export const notFound = (m: string, suggestion?: string) =>
70
+ new CliError(m, "not_found", undefined, suggestion);
71
+ export const ambiguous = (m: string, suggestion?: string) =>
72
+ new CliError(m, "ambiguous", undefined, suggestion);
73
+ export const authError = (m: string, suggestion?: string) =>
74
+ new CliError(m, "auth", undefined, suggestion);
75
+
76
+ /**
77
+ * Map an unknown thrown value (typically a Linear SDK error) into a CliError
78
+ * with a stable code. We classify by the SDK error class name + GraphQL error
79
+ * `extensions.type` so behavior is predictable across SDK versions.
80
+ */
81
+ export function normalizeError(err: unknown): CliError {
82
+ if (err instanceof CliError) return err;
83
+
84
+ if (err instanceof Error) {
85
+ const name = err.constructor?.name ?? err.name ?? "";
86
+ const anyErr = err as Record<string, any>;
87
+ const gqlErrors: any[] =
88
+ [anyErr.errors, anyErr.response?.errors, anyErr.raw?.response?.errors].find(
89
+ (errors) => Array.isArray(errors) && errors.length > 0,
90
+ ) ?? [];
91
+ const type: string | undefined =
92
+ anyErr.type ?? gqlErrors[0]?.extensions?.type ?? gqlErrors[0]?.extensions?.code;
93
+ const message = pickMessage(err, gqlErrors);
94
+
95
+ // A request that never got an HTTP response is a transport failure, however
96
+ // the SDK labelled it (it says `UnknownLinearError`, type Unknown, for a
97
+ // refused connection). Classify by the socket error underneath.
98
+ const transport = transportFailure(anyErr);
99
+ let code = transport ? "network" : classify(name, type);
100
+ // Linear surfaces "could not find referenced X" as a validation error, but
101
+ // semantically it is a not-found — reclassify so `view <bad-id>` exits 3.
102
+ if (
103
+ (code === "validation" || code === "api") &&
104
+ /could ?n[o']t find|not found|does not exist|no such|referenced \w+\.?$/i.test(
105
+ messageCandidates(err, gqlErrors).join("; "),
106
+ )
107
+ ) {
108
+ code = "not_found";
109
+ }
110
+ // `--debug` detail: the GraphQL errors when there are any; otherwise what
111
+ // the transport said, since that is all there is to show.
112
+ const detail = gqlErrors.length ? gqlErrors : (transport ?? rawDetail(anyErr));
113
+ return new CliError(message, code, detail);
114
+ }
115
+
116
+ return new CliError(String(err), "runtime");
117
+ }
118
+
119
+ /**
120
+ * Socket / DNS / fetch failure codes as bun and node's fetch spell them (bun:
121
+ * `ConnectionRefused`, `FailedToOpenSocket`; node/undici: errno names and
122
+ * `UND_ERR_*`), plus a bare `TypeError: fetch failed`.
123
+ */
124
+ const TRANSPORT_CODES =
125
+ /^(ConnectionRefused|ConnectionClosed|FailedToOpenSocket|ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|EHOSTUNREACH|ENETUNREACH|EPIPE|UND_ERR_\w+)$/;
126
+
127
+ interface TransportDetail {
128
+ name?: string;
129
+ code?: string;
130
+ status?: undefined;
131
+ }
132
+
133
+ /**
134
+ * Detect a request that failed before any HTTP response existed. A real
135
+ * status — even a 5xx — is an answer from the API and is left to `classify`.
136
+ */
137
+ function transportFailure(err: Record<string, any>): TransportDetail | undefined {
138
+ if (err.status !== undefined && err.status !== null) return undefined;
139
+ const raw = err.raw ?? err;
140
+ if (raw?.response?.status !== undefined) return undefined;
141
+ const candidates = [raw, raw?.cause, err.cause].filter(Boolean);
142
+ for (const c of candidates) {
143
+ const code = typeof c.code === "string" ? c.code : undefined;
144
+ if (code && TRANSPORT_CODES.test(code)) return { name: c.name, code };
145
+ if (c instanceof TypeError && /fetch failed|unable to connect|network/i.test(c.message ?? "")) {
146
+ return { name: c.name, code };
147
+ }
148
+ }
149
+ return undefined;
150
+ }
151
+
152
+ /** `{name, code, status}` from the SDK's raw error, when any of them is set. */
153
+ function rawDetail(err: Record<string, any>): Record<string, unknown> | undefined {
154
+ const raw = err.raw;
155
+ const detail = {
156
+ name: raw?.name,
157
+ code: raw?.code,
158
+ status: err.status ?? raw?.response?.status,
159
+ };
160
+ return Object.values(detail).some((v) => v !== undefined) ? detail : undefined;
161
+ }
162
+
163
+ function classify(name: string, type: string | undefined): ErrorCode {
164
+ const n = `${name} ${type ?? ""}`.toLowerCase();
165
+ if (n.includes("authentication")) return "auth";
166
+ if (n.includes("ratelimit")) return "rate_limited";
167
+ if (n.includes("forbidden")) return "forbidden";
168
+ if (n.includes("featurenotaccessible") || n.includes("usagelimit"))
169
+ return "feature_not_accessible";
170
+ if (n.includes("invalidinput") || n.includes("validation")) return "validation";
171
+ if (n.includes("network")) return "network";
172
+ if (name.includes("LinearError") || name.includes("GraphQL")) return "api";
173
+ return "runtime";
174
+ }
175
+
176
+ function pickMessage(err: Error, gqlErrors: any[]): string {
177
+ const presentable = gqlErrors
178
+ .map((e) => e?.extensions?.userPresentableMessage)
179
+ .find((message) => typeof message === "string" && message.trim().length > 0);
180
+ if (presentable) return presentable;
181
+
182
+ const fromGql = gqlErrors
183
+ .map((e) => e?.message)
184
+ .filter(Boolean)
185
+ .join("; ");
186
+ return fromGql || err.message || "Unknown error";
187
+ }
188
+
189
+ /** All message forms are retained for classification even when a nicer one is displayed. */
190
+ function messageCandidates(err: Error, gqlErrors: any[]): string[] {
191
+ return [
192
+ err.message,
193
+ ...gqlErrors.flatMap((error) => [error?.message, error?.extensions?.userPresentableMessage]),
194
+ ].filter((message): message is string => typeof message === "string" && message.length > 0);
195
+ }