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.
Files changed (79) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +96 -58
  4. package/COVERAGE.md +5 -5
  5. package/MIGRATING.md +71 -69
  6. package/README.md +115 -90
  7. package/package.json +6 -7
  8. package/skills/README.md +15 -0
  9. package/skills/linear-sdk-cli/SKILL.md +27 -3
  10. package/skills/linear-sdk-cli/references/api.md +9 -9
  11. package/skills/linear-sdk-cli/references/auth.md +18 -0
  12. package/skills/linear-sdk-cli/references/commands.md +2 -2
  13. package/skills/linear-sdk-cli/references/comment.md +9 -6
  14. package/skills/linear-sdk-cli/references/config.md +9 -11
  15. package/skills/linear-sdk-cli/references/issue.md +72 -18
  16. package/skills/linear-sdk-cli/references/open.md +29 -0
  17. package/skills/linear-sdk-cli/references/team.md +16 -0
  18. package/skills/linear-sdk-cli/scripts/generate-docs.ts +6 -2
  19. package/skills/linear-sdk-cli-maintenance/SKILL.md +97 -0
  20. package/src/bin/linear.ts +16 -6
  21. package/src/cli.ts +3 -1
  22. package/src/commands/agent-session.ts +13 -5
  23. package/src/commands/api.ts +15 -9
  24. package/src/commands/attach.ts +15 -4
  25. package/src/commands/comment.ts +50 -11
  26. package/src/commands/completion.ts +2 -6
  27. package/src/commands/cycle.ts +6 -1
  28. package/src/commands/discover.ts +6 -2
  29. package/src/commands/document.ts +4 -1
  30. package/src/commands/initiative.ts +5 -1
  31. package/src/commands/issue.ts +392 -91
  32. package/src/commands/meta.ts +139 -31
  33. package/src/commands/milestone.ts +4 -1
  34. package/src/commands/notification.ts +15 -6
  35. package/src/commands/open.ts +84 -0
  36. package/src/commands/project.ts +17 -4
  37. package/src/commands/team.ts +13 -0
  38. package/src/commands/user.ts +2 -2
  39. package/src/commands/webhook.ts +12 -2
  40. package/src/config.ts +501 -184
  41. package/src/context.ts +13 -4
  42. package/src/git.ts +28 -3
  43. package/src/lib/body.ts +26 -2
  44. package/src/lib/errors.ts +33 -8
  45. package/src/lib/introspect.ts +76 -7
  46. package/src/lib/keyring.ts +51 -9
  47. package/src/lib/mentions.ts +49 -0
  48. package/src/lib/open.ts +32 -0
  49. package/src/lib/options.ts +108 -12
  50. package/src/lib/output-shapes.ts +81 -9
  51. package/src/lib/pagination.ts +50 -17
  52. package/src/lib/resolve.ts +40 -7
  53. package/src/lib/shape.ts +16 -12
  54. package/src/lib/upload.ts +3 -1
  55. package/src/output/color.ts +52 -0
  56. package/src/output/format.ts +96 -18
  57. package/src/output/markdown.ts +133 -0
  58. package/src/output/pager.ts +131 -0
  59. package/src/output/sanitize.ts +1 -1
  60. package/src/output/table.ts +7 -7
  61. package/src/services/agent-session.ts +11 -3
  62. package/src/services/attachment.ts +14 -5
  63. package/src/services/comment.ts +37 -9
  64. package/src/services/cycle.ts +11 -4
  65. package/src/services/document.ts +29 -10
  66. package/src/services/favorite.ts +15 -9
  67. package/src/services/initiative-update.ts +16 -15
  68. package/src/services/initiative.ts +15 -10
  69. package/src/services/issue.ts +263 -19
  70. package/src/services/label.ts +12 -2
  71. package/src/services/milestone.ts +8 -5
  72. package/src/services/notification.ts +16 -2
  73. package/src/services/organization.ts +23 -18
  74. package/src/services/project.ts +55 -34
  75. package/src/services/roadmap.ts +7 -6
  76. package/src/services/state.ts +9 -3
  77. package/src/services/team.ts +67 -24
  78. package/src/services/user.ts +2 -2
  79. package/src/services/webhook.ts +28 -17
package/src/context.ts CHANGED
@@ -8,6 +8,7 @@ import type { LinearClient } from "@linear/sdk";
8
8
  import { resolveConfig, type ResolvedConfig } from "./config.js";
9
9
  import { createClient, setRetryReporter } from "./client.js";
10
10
  import { Output } from "./output/format.js";
11
+ import { isDebugEnabled, shouldUseColor } from "./output/color.js";
11
12
 
12
13
  /**
13
14
  * The globals as the parser actually stores them.
@@ -57,10 +58,17 @@ export class Context {
57
58
  // of the config (the default team for creates, cycle lookups, …) is
58
59
  // single-valued, so those commands read the full list off their own opts
59
60
  // and the config sees the first key.
60
- flags: { apiKey: options.apiKey, team: firstTeam(options.team), workspace: options.workspace },
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,
61
71
  });
62
- const color =
63
- options.noAnsi !== true && options.json !== true && process.stdout.isTTY === true;
64
72
  // Four independent reasons not to prompt, and any one of them is enough.
65
73
  // The first two are the user saying so; the last two are the situation
66
74
  // saying so. `--json` counts because JSON is what a script or an agent
@@ -77,8 +85,9 @@ export class Context {
77
85
  json: options.json === true,
78
86
  color,
79
87
  quiet: options.quiet === true,
80
- debug: options.debug === true,
88
+ debug: isDebugEnabled(options.debug),
81
89
  fields: options.fields,
90
+ isTTY: process.stdout.isTTY === true,
82
91
  });
83
92
  // Rate-limit waits are status, so they go through the same sink as every
84
93
  // other status line: stderr, silenced by --quiet, never on JSON stdout.
package/src/git.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { execFileSync } from "node:child_process";
8
+ import { CliError } from "./lib/errors.js";
8
9
 
9
10
  /**
10
11
  * Extract a Linear issue identifier (e.g. `TES-123`) from a git branch name.
@@ -74,7 +75,11 @@ export function buildTrailer(identifier: string, opts: { references?: boolean }
74
75
  * word directly before the id and links — and closes — the issue. The URL line is
75
76
  * for the humans reading the log.
76
77
  */
77
- export function buildTrailers(identifier: string, url: string, opts: { references?: boolean } = {}): string {
78
+ export function buildTrailers(
79
+ identifier: string,
80
+ url: string,
81
+ opts: { references?: boolean } = {},
82
+ ): string {
78
83
  return `Linear-issue: ${buildTrailer(identifier, opts)}\nLinear-issue-url: ${url}`;
79
84
  }
80
85
 
@@ -136,14 +141,34 @@ export interface CheckoutResult {
136
141
  created: boolean;
137
142
  }
138
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
+
139
164
  /** Checkout `branch`, creating it if it does not exist. */
140
165
  export function checkoutBranch(branch: string, cwd?: string): CheckoutResult {
141
166
  const exists =
142
167
  git(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], cwd) !== undefined;
143
168
  if (exists) {
144
- execFileSync("git", ["checkout", branch], { cwd, stdio: "ignore" });
169
+ gitCheckout(["checkout", branch], cwd);
145
170
  return { branch, created: false };
146
171
  }
147
- execFileSync("git", ["checkout", "-b", branch], { cwd, stdio: "ignore" });
172
+ gitCheckout(["checkout", "-b", branch], cwd);
148
173
  return { branch, created: true };
149
174
  }
package/src/lib/body.ts CHANGED
@@ -30,7 +30,7 @@ export interface BodyInputs {
30
30
  }
31
31
 
32
32
  export function resolveBody(inputs: BodyInputs): string | undefined {
33
- if (inputs.arg !== undefined) return inputs.arg;
33
+ if (inputs.arg !== undefined) return validateInlineBody(inputs.arg);
34
34
  if (inputs.file !== undefined) return readBodyFile(inputs.file);
35
35
  if (inputs.interactive) return openEditor(inputs.template ?? "");
36
36
  if (inputs.editorRequested) {
@@ -42,6 +42,27 @@ export function resolveBody(inputs: BodyInputs): string | undefined {
42
42
  return undefined;
43
43
  }
44
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
+
45
66
  function readBodyFile(file: string): string {
46
67
  if (file === "-") return readStdinSync();
47
68
  try {
@@ -66,7 +87,10 @@ export function readStdinSync(): string {
66
87
  * so it is split shell-style (quotes and backslashes honoured) into argv, and
67
88
  * the file is appended. Exported for tests.
68
89
  */
69
- export function editorCommand(env: NodeJS.ProcessEnv = process.env): { raw: string; argv: string[] } {
90
+ export function editorCommand(env: NodeJS.ProcessEnv = process.env): {
91
+ raw: string;
92
+ argv: string[];
93
+ } {
70
94
  const raw = env.VISUAL || env.EDITOR || "vi";
71
95
  return { raw, argv: shellSplit(raw) };
72
96
  }
package/src/lib/errors.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Error handling: a single `CliError` type the whole app throws, plus a
3
3
  * normalizer that maps Linear SDK errors into stable codes + exit codes.
4
4
  *
5
- * Exit codes (documented in PLAN.md):
5
+ * Exit codes (documented in README.md):
6
6
  * 0 ok · 1 runtime/API · 2 usage · 3 not-found/ambiguous · 4 auth · 5 rate-limited
7
7
  */
8
8
 
@@ -50,21 +50,28 @@ export class CliError extends Error {
50
50
  readonly exitCode: ExitCodeValue;
51
51
  /** Extra detail surfaced only with --debug (e.g. raw GraphQL errors). */
52
52
  readonly detail?: unknown;
53
+ /** A separately actionable hint; machine callers never have to parse prose. */
54
+ readonly suggestion?: string;
53
55
 
54
- constructor(message: string, code: ErrorCode = "runtime", detail?: unknown) {
56
+ constructor(message: string, code: ErrorCode = "runtime", detail?: unknown, suggestion?: string) {
55
57
  super(message);
56
58
  this.name = "CliError";
57
59
  this.code = code;
58
60
  this.exitCode = EXIT_BY_CODE[code];
59
61
  this.detail = detail;
62
+ this.suggestion = suggestion;
60
63
  }
61
64
  }
62
65
 
63
66
  /** Convenience constructors for the common cases. */
64
- export const usageError = (m: string) => new CliError(m, "usage");
65
- export const notFound = (m: string) => new CliError(m, "not_found");
66
- export const ambiguous = (m: string) => new CliError(m, "ambiguous");
67
- export const authError = (m: string) => new CliError(m, "auth");
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);
68
75
 
69
76
  /**
70
77
  * Map an unknown thrown value (typically a Linear SDK error) into a CliError
@@ -77,7 +84,10 @@ export function normalizeError(err: unknown): CliError {
77
84
  if (err instanceof Error) {
78
85
  const name = err.constructor?.name ?? err.name ?? "";
79
86
  const anyErr = err as Record<string, any>;
80
- const gqlErrors: any[] = anyErr.errors ?? anyErr.raw?.response?.errors ?? [];
87
+ const gqlErrors: any[] =
88
+ [anyErr.errors, anyErr.response?.errors, anyErr.raw?.response?.errors].find(
89
+ (errors) => Array.isArray(errors) && errors.length > 0,
90
+ ) ?? [];
81
91
  const type: string | undefined =
82
92
  anyErr.type ?? gqlErrors[0]?.extensions?.type ?? gqlErrors[0]?.extensions?.code;
83
93
  const message = pickMessage(err, gqlErrors);
@@ -91,7 +101,9 @@ export function normalizeError(err: unknown): CliError {
91
101
  // semantically it is a not-found — reclassify so `view <bad-id>` exits 3.
92
102
  if (
93
103
  (code === "validation" || code === "api") &&
94
- /could ?n[o']t find|not found|does not exist|no such|referenced \w+\.?$/i.test(message)
104
+ /could ?n[o']t find|not found|does not exist|no such|referenced \w+\.?$/i.test(
105
+ messageCandidates(err, gqlErrors).join("; "),
106
+ )
95
107
  ) {
96
108
  code = "not_found";
97
109
  }
@@ -162,9 +174,22 @@ function classify(name: string, type: string | undefined): ErrorCode {
162
174
  }
163
175
 
164
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
+
165
182
  const fromGql = gqlErrors
166
183
  .map((e) => e?.message)
167
184
  .filter(Boolean)
168
185
  .join("; ");
169
186
  return fromGql || err.message || "Unknown error";
170
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
+ }
@@ -6,6 +6,13 @@
6
6
  import type { Command } from "commander";
7
7
  import type { OutputShape } from "./shape.js";
8
8
  import { outputShapeOf } from "./output-shapes.js";
9
+ import {
10
+ FIELDS_COMMANDS,
11
+ LIMIT_COMMANDS,
12
+ TEAM_COMMANDS,
13
+ YES_COMMANDS,
14
+ globalOptionKeys,
15
+ } from "./options.js";
9
16
 
10
17
  export interface CommandArgument {
11
18
  name: string;
@@ -16,6 +23,18 @@ export interface CommandArgument {
16
23
  export interface CommandOption {
17
24
  flags: string;
18
25
  description: string;
26
+ /** Commander's destination key (e.g. `includeArchived`). */
27
+ attribute: string;
28
+ /** Whether the option's value is required/optional after the flag. */
29
+ valueRequired: boolean;
30
+ valueOptional: boolean;
31
+ variadic: boolean;
32
+ choices?: string[];
33
+ defaultValue?: unknown;
34
+ /** True for the flags inherited from the root CLI contract. */
35
+ global: boolean;
36
+ /** Whether this global has an effect on this command. Local options are always applicable. */
37
+ applicable: boolean;
19
38
  }
20
39
 
21
40
  export interface CommandNode {
@@ -58,17 +77,37 @@ export function walkCommands(root: Command): CommandNode[] {
58
77
  }
59
78
 
60
79
  function describe(cmd: Command, path: string[]): CommandNode {
61
- const args: CommandArgument[] = (cmd as any).registeredArguments?.map((a: any) => ({
62
- name: a.name(),
63
- required: a.required === true,
64
- variadic: a.variadic === true,
65
- })) ?? [];
80
+ const args: CommandArgument[] =
81
+ (cmd as any).registeredArguments?.map((a: any) => ({
82
+ name: a.name(),
83
+ required: a.required === true,
84
+ variadic: a.variadic === true,
85
+ })) ?? [];
66
86
 
87
+ const joined = path.join(" ");
88
+ const defaultName = (cmd as any)._defaultCommandName as string | undefined;
89
+ const effectivePath = defaultName ? `${joined} ${defaultName}` : joined;
90
+ const runnable = Boolean((cmd as any)._actionHandler) || defaultName !== undefined;
91
+ const globalKeys = new Set(globalOptionKeys());
67
92
  const options: CommandOption[] = cmd.options
68
93
  .filter((o: any) => !o.hidden)
69
- .map((o: any) => ({ flags: o.flags as string, description: (o.description as string) ?? "" }));
94
+ .map((o: any) => {
95
+ const attribute = o.attributeName() as string;
96
+ const global = globalKeys.has(attribute) && isGlobalFlag(o.long);
97
+ return {
98
+ flags: o.flags as string,
99
+ description: (o.description as string) ?? "",
100
+ attribute,
101
+ valueRequired: o.required === true,
102
+ valueOptional: o.optional === true,
103
+ variadic: o.variadic === true,
104
+ ...(Array.isArray(o.argChoices) ? { choices: [...o.argChoices] as string[] } : {}),
105
+ ...(o.defaultValue !== undefined ? { defaultValue: o.defaultValue as unknown } : {}),
106
+ global,
107
+ applicable: global ? globalApplies(effectivePath, o.long, runnable) : true,
108
+ };
109
+ });
70
110
 
71
- const joined = path.join(" ");
72
111
  const output = outputShapeOf(joined);
73
112
  return {
74
113
  path: joined,
@@ -79,3 +118,33 @@ function describe(cmd: Command, path: string[]): CommandNode {
79
118
  ...(output ? { output } : {}),
80
119
  };
81
120
  }
121
+
122
+ const GLOBAL_LONGS = new Set([
123
+ "--json",
124
+ "--no-ansi",
125
+ "--api-key",
126
+ "--workspace",
127
+ "--team",
128
+ "--limit",
129
+ "--all",
130
+ "--fields",
131
+ "--yes",
132
+ "--quiet",
133
+ "--no-input",
134
+ "--debug",
135
+ ]);
136
+
137
+ function isGlobalFlag(long: string | undefined): boolean {
138
+ return long !== undefined && GLOBAL_LONGS.has(long);
139
+ }
140
+
141
+ function globalApplies(path: string, long: string | undefined, hasAction: boolean): boolean {
142
+ if (!hasAction) return false;
143
+ if (long === "--fields") return FIELDS_COMMANDS.has(path);
144
+ if (long === "--limit" || long === "--all") return LIMIT_COMMANDS.has(path);
145
+ if (long === "--team") return TEAM_COMMANDS.has(path);
146
+ if (long === "--yes") return YES_COMMANDS.has(path);
147
+ // The remaining globals control the shared parser/context/error/output
148
+ // boundary and are intentionally safe on every runnable command.
149
+ return true;
150
+ }
@@ -71,7 +71,9 @@ function runSecurity(args: string[], input?: string) {
71
71
  */
72
72
  function quoteForSecurity(token: string, what: string): string {
73
73
  if (/[\r\n]/.test(token)) {
74
- throw new KeyringError(`The ${what} contains a line break and cannot be stored in the keychain.`);
74
+ throw new KeyringError(
75
+ `The ${what} contains a line break and cannot be stored in the keychain.`,
76
+ );
75
77
  }
76
78
  return `"${token.replace(/[\\"]/g, (c) => `\\${c}`)}"`;
77
79
  }
@@ -98,7 +100,16 @@ export const macosKeychain: KeyringBackend = {
98
100
  // never an argv element. (`add-generic-password -w` as the LAST option
99
101
  // prompts instead — but on /dev/tty, so it hangs in a real terminal.)
100
102
  const line =
101
- ["add-generic-password", "-a", quoteForSecurity(account, "workspace slug"), "-s", KEYRING_SERVICE, "-U", "-w", quoteForSecurity(secret, "API key")].join(" ") + "\n";
103
+ [
104
+ "add-generic-password",
105
+ "-a",
106
+ quoteForSecurity(account, "workspace slug"),
107
+ "-s",
108
+ KEYRING_SERVICE,
109
+ "-U",
110
+ "-w",
111
+ quoteForSecurity(secret, "API key"),
112
+ ].join(" ") + "\n";
102
113
  const r = runSecurity(["-i"], line);
103
114
  if (r.status !== 0) {
104
115
  throw new KeyringError(
@@ -150,7 +161,15 @@ export const linuxSecretService: KeyringBackend = {
150
161
 
151
162
  set(account, secret) {
152
163
  const r = runSecretTool(
153
- ["store", "--label", `${KEYRING_SERVICE}: ${account}`, "service", KEYRING_SERVICE, "account", account],
164
+ [
165
+ "store",
166
+ "--label",
167
+ `${KEYRING_SERVICE}: ${account}`,
168
+ "service",
169
+ KEYRING_SERVICE,
170
+ "account",
171
+ account,
172
+ ],
154
173
  secret,
155
174
  );
156
175
  if (r.status !== 0) {
@@ -178,12 +197,24 @@ export const linuxSecretService: KeyringBackend = {
178
197
  // Selection
179
198
  // ---------------------------------------------------------------------------
180
199
 
181
- /** `undefined` = not decided yet; `null` = decided: no keyring here. */
182
- let selected: KeyringBackend | null | undefined;
200
+ /** Explicit test/application override; `undefined` means automatic detection. */
201
+ let overrideBackend: KeyringBackend | null | undefined;
202
+ /** Automatic detection is cached only while HOME is unchanged. */
203
+ let detectedBackend: KeyringBackend | null | undefined;
204
+ let detectedHome: string | undefined;
183
205
 
184
206
  function detect(): KeyringBackend | null {
185
207
  if (process.platform === "darwin") {
186
- return existsSync(SECURITY) ? macosKeychain : null;
208
+ if (!existsSync(SECURITY)) return null;
209
+ // `security add-generic-password` opens a system dialog when the process
210
+ // has no default user keychain (common with an isolated HOME in tests,
211
+ // containers, and launch agents). Probe first so that environment is
212
+ // treated like every other machine without a usable keyring and callers
213
+ // can fall back to the 0600 credential file without surprising UI.
214
+ const probe = spawnSync(SECURITY, ["default-keychain", "-d", "user"], {
215
+ encoding: "utf8",
216
+ });
217
+ return !probe.error && probe.status === 0 ? macosKeychain : null;
187
218
  }
188
219
  if (process.platform === "linux") {
189
220
  // With no arguments secret-tool prints usage and exits 2; a completed run
@@ -200,8 +231,13 @@ function detect(): KeyringBackend | null {
200
231
  * error condition, just a platform.
201
232
  */
202
233
  export function keyring(): KeyringBackend | null {
203
- if (selected === undefined) selected = detect();
204
- return selected;
234
+ if (overrideBackend !== undefined) return overrideBackend;
235
+ const home = process.env.HOME;
236
+ if (detectedBackend === undefined || detectedHome !== home) {
237
+ detectedBackend = detect();
238
+ detectedHome = home;
239
+ }
240
+ return detectedBackend;
205
241
  }
206
242
 
207
243
  /**
@@ -209,7 +245,13 @@ export function keyring(): KeyringBackend | null {
209
245
  * never touch the developer's real Keychain. `undefined` restores detection.
210
246
  */
211
247
  export function setKeyringBackend(backend: KeyringBackend | null | undefined): void {
212
- selected = backend;
248
+ overrideBackend = backend;
249
+ if (backend === undefined) {
250
+ // Tests routinely swap HOME to an isolated fixture. Do not let a backend
251
+ // detected against the real login keychain escape into that environment.
252
+ detectedBackend = undefined;
253
+ detectedHome = undefined;
254
+ }
213
255
  }
214
256
 
215
257
  /** An in-memory backend for tests. */
@@ -0,0 +1,49 @@
1
+ import type { LinearClient } from "@linear/sdk";
2
+ import { withRetry } from "../client.js";
3
+ import { usageError } from "./errors.js";
4
+ import { resolveUserId } from "./resolve.js";
5
+
6
+ /**
7
+ * Prepend explicit Linear mentions to a Markdown body.
8
+ *
9
+ * Linear stores a real user mention as `@[label](user-id)`. Ordinary `@name`
10
+ * text is deliberately not parsed: it is prose unless the caller also passes
11
+ * `--mention <user>`. Keeping the intent in an option prevents an innocent
12
+ * name in a pasted body from notifying somebody.
13
+ */
14
+ export async function prependMentions(
15
+ client: LinearClient,
16
+ body: string,
17
+ references: string[] = [],
18
+ ): Promise<string> {
19
+ if (references.length === 0) return body;
20
+
21
+ const mentions: string[] = [];
22
+ const seen = new Set<string>();
23
+ for (const raw of references) {
24
+ const reference = normalizeMentionReference(raw);
25
+ const id = await resolveUserId(client, reference);
26
+ if (seen.has(id)) continue;
27
+ seen.add(id);
28
+
29
+ const user = await withRetry(() => client.user(id));
30
+ const label = escapeMentionLabel(user.displayName);
31
+ mentions.push(`@[${label}](${id})`);
32
+ }
33
+
34
+ const prefix = mentions.join(" ");
35
+ return body ? `${prefix}\n\n${body}` : prefix;
36
+ }
37
+
38
+ /** Accept the natural `@ada` spelling without confusing it with an email. */
39
+ export function normalizeMentionReference(raw: string): string {
40
+ const reference = raw.trim();
41
+ if (!reference) throw usageError("--mention requires a non-empty user reference.");
42
+ if (reference.startsWith("@") && reference !== "@me") return reference.slice(1);
43
+ return reference;
44
+ }
45
+
46
+ /** Escape the Markdown-link label without changing Linear's mention grammar. */
47
+ export function escapeMentionLabel(label: string): string {
48
+ return label.replace(/\\/g, "\\\\").replace(/\]/g, "\\]");
49
+ }
@@ -0,0 +1,32 @@
1
+ /** Open a Linear URL in the system browser or the macOS Linear app. */
2
+
3
+ import { execFile } from "node:child_process";
4
+ import { CliError, usageError } from "./errors.js";
5
+
6
+ export async function openUrl(url: string, opts: { app?: boolean } = {}): Promise<void> {
7
+ if (opts.app && process.platform !== "darwin") {
8
+ throw usageError("--app currently requires macOS with Linear.app installed; use --web here.");
9
+ }
10
+ const command =
11
+ process.platform === "darwin"
12
+ ? "open"
13
+ : process.platform === "win32"
14
+ ? "rundll32.exe"
15
+ : "xdg-open";
16
+ const args =
17
+ process.platform === "darwin"
18
+ ? opts.app
19
+ ? ["-a", "Linear", url]
20
+ : [url]
21
+ : process.platform === "win32"
22
+ ? ["url.dll,FileProtocolHandler", url]
23
+ : [url];
24
+
25
+ await new Promise<void>((resolve, reject) => {
26
+ execFile(command, args, { encoding: "utf8" }, (err, _stdout, stderr) => {
27
+ if (!err) return resolve();
28
+ const detail = stderr.trim();
29
+ reject(new CliError(detail || `Failed to open ${url}: ${err.message}`, "runtime"));
30
+ });
31
+ });
32
+ }