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
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * CLI entry point + central error boundary. All commands throw; failures —
4
+ * including commander's own parse/usage errors — are normalized here into the
5
+ * error envelope and a stable exit code.
6
+ */
7
+
8
+ import { CommanderError } from "commander";
9
+ import { createProgram, parsedGlobalOptions, usageHint, suppressedHelp } from "../cli.js";
10
+ import { Output } from "../output/format.js";
11
+ import { CliError, ExitCode, normalizeError } from "../lib/errors.js";
12
+ import { commandPath } from "../lib/options.js";
13
+ import { isDebugEnabled, shouldUseColor } from "../output/color.js";
14
+
15
+ // `linear schema | head -2` is a reader that stops early, and stdout closing
16
+ // under us is that reader's business, not an error: exit quietly, as every
17
+ // well-behaved Unix filter does. Without a listener Bun surfaces the EPIPE as
18
+ // an unhandled stream error — a raw stack dump on stderr and exit 1.
19
+ for (const stream of [process.stdout, process.stderr]) {
20
+ stream.on("error", (err: NodeJS.ErrnoException) => {
21
+ if (err.code === "EPIPE") process.exit(0);
22
+ throw err;
23
+ });
24
+ }
25
+
26
+ const program = createProgram();
27
+
28
+ program.parseAsync(process.argv).catch((err) => {
29
+ // Help/version are normal terminations that commander already wrote to stdout.
30
+ if (err instanceof CommanderError) {
31
+ if (err.exitCode === 0) process.exit(0);
32
+ }
33
+
34
+ // A group invoked bare (`linear notification`) is commander asking to show
35
+ // that group's help and exit non-zero. It wrote the help to the stderr we
36
+ // route through here, so print exactly that for a human — an envelope
37
+ // wrapping thirty lines of usage is not an error message — and the one-line
38
+ // usage error under --json.
39
+ if (err instanceof CommanderError && err.code === "commander.help") {
40
+ const help = suppressedHelp(program);
41
+ if (help && parsedGlobalOptions(program).json !== true) {
42
+ process.stderr.write(help.text);
43
+ process.exit(ExitCode.Usage);
44
+ }
45
+ }
46
+
47
+ const cliError = err instanceof CommanderError ? fromCommander(err) : normalizeError(err);
48
+
49
+ // Format the error the way the user asked for the output — from the globals
50
+ // commander parsed, so every spelling it accepts (`-j`, `-jq`, `--json`)
51
+ // reaches the envelope. Errors go to stderr, so that stream's TTY-ness decides
52
+ // colour, not stdout's.
53
+ const globals = parsedGlobalOptions(program);
54
+ const json = globals.json === true;
55
+ const out = new Output({
56
+ json,
57
+ color: shouldUseColor({
58
+ disabled: globals.noAnsi,
59
+ json,
60
+ isTTY: process.stderr.isTTY === true,
61
+ }),
62
+ quiet: globals.quiet === true,
63
+ debug: isDebugEnabled(globals.debug),
64
+ isTTY: process.stderr.isTTY === true,
65
+ });
66
+ out.error(cliError);
67
+ process.exit(cliError.exitCode);
68
+ });
69
+
70
+ /**
71
+ * Commander's own failures as CliErrors. A parse failure gets a pointer at the
72
+ * failing command's `--help` — the string form of what `.showHelpAfterError()`
73
+ * would print, if commander's stderr were not routed through here.
74
+ */
75
+ function fromCommander(err: CommanderError): CliError {
76
+ if (err.code === "commander.help") {
77
+ const help = suppressedHelp(program);
78
+ const path = help ? commandPath(help.command) : "linear";
79
+ return new CliError(
80
+ "Missing subcommand.",
81
+ "usage",
82
+ err.code,
83
+ `Run '${path} --help' to see the commands.`,
84
+ );
85
+ }
86
+ return new CliError(
87
+ stripErrorPrefix(err.message).replace(/\.?$/, "."),
88
+ "usage",
89
+ err.code,
90
+ usageHint(program),
91
+ );
92
+ }
93
+
94
+ /** Commander prefixes messages with "error: "; drop it for our envelope. */
95
+ function stripErrorPrefix(message: string): string {
96
+ return message.replace(/^error:\s*/i, "");
97
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Root commander program: registers global options and all command groups.
3
+ * Kept free of side effects so it can be imported by tests and the bin entry.
4
+ */
5
+
6
+ import { Command } from "commander";
7
+ import { addGlobalOptions, globalOptionKeys, unknownCommand, commandPath } from "./lib/options.js";
8
+ import { registerMeta } from "./commands/meta.js";
9
+ import { registerApi } from "./commands/api.js";
10
+ import { registerCompletion } from "./commands/completion.js";
11
+ import { registerIssue, renderIssueDetail } from "./commands/issue.js";
12
+ import { registerAgentSession } from "./commands/agent-session.js";
13
+ import { registerAttach } from "./commands/attach.js";
14
+ import { registerTeam } from "./commands/team.js";
15
+ import { registerProject } from "./commands/project.js";
16
+ import { registerProjectUpdate } from "./commands/project-update.js";
17
+ import { registerMilestone } from "./commands/milestone.js";
18
+ import { registerCycle } from "./commands/cycle.js";
19
+ import { registerUser } from "./commands/user.js";
20
+ import { registerLabel } from "./commands/label.js";
21
+ import { registerState } from "./commands/state.js";
22
+ import { registerComment } from "./commands/comment.js";
23
+ import { registerDocument } from "./commands/document.js";
24
+ import { registerAttachment } from "./commands/attachment.js";
25
+ import { registerFavorite } from "./commands/favorite.js";
26
+ import { registerInitiative } from "./commands/initiative.js";
27
+ import { registerInitiativeUpdate } from "./commands/initiative-update.js";
28
+ import { registerRoadmap } from "./commands/roadmap.js";
29
+ import { registerNotification } from "./commands/notification.js";
30
+ import { registerOrganization } from "./commands/organization.js";
31
+ import { registerWebhook } from "./commands/webhook.js";
32
+ import { registerOpen } from "./commands/open.js";
33
+ import { registerCommands, registerSchema } from "./commands/discover.js";
34
+ import { Context, type GlobalOptions } from "./context.js";
35
+ import { currentIssueId } from "./git.js";
36
+ import { getIssueDetail } from "./services/issue.js";
37
+ import { usageError } from "./lib/errors.js";
38
+ import packageJson from "../package.json" with { type: "json" };
39
+
40
+ /** One version source for the package, both binaries, and programmatic use. */
41
+ export const VERSION = packageJson.version;
42
+
43
+ export function createProgram(): Command {
44
+ const program = new Command();
45
+
46
+ program
47
+ .name("linear")
48
+ .description("Ergonomic command-line interface for Linear, built on @linear/sdk")
49
+ .version(VERSION, "-V, --version", "output the version number")
50
+ .enablePositionalOptions()
51
+ // A stray word at the root is an unknown command, and the root action says
52
+ // so (with a guess). Left to commander it was "too many arguments. Expected
53
+ // 0 arguments but got 2: issues, list." — true, and no help at all.
54
+ .allowExcessArguments();
55
+
56
+ addGlobalOptions(program);
57
+
58
+ // Phase 0: meta + escape hatch + completion.
59
+ registerMeta(program);
60
+ registerApi(program);
61
+ registerCompletion(program);
62
+ registerOpen(program);
63
+ // Phase 1: issues. Agent sessions live on issues, so their group is mounted
64
+ // under `issue` (`linear issue agent-session …`); so is file upload
65
+ // (`linear issue attach <issue> <file...>`, TES-602).
66
+ registerIssue(program);
67
+ registerAgentSession(program.commands.find((c) => c.name() === "issue")!);
68
+ registerAttach(program.commands.find((c) => c.name() === "issue")!);
69
+ // Phase 2: teams, projects, milestones, cycles.
70
+ registerTeam(program);
71
+ registerProject(program);
72
+ registerProjectUpdate(program);
73
+ registerMilestone(program);
74
+ registerCycle(program);
75
+ // Phase 3: users, labels, workflow states, comments, documents, attachments, favorites.
76
+ registerUser(program);
77
+ registerLabel(program);
78
+ registerState(program);
79
+ registerComment(program);
80
+ registerDocument(program);
81
+ registerAttachment(program);
82
+ registerFavorite(program);
83
+ // Phase 4: initiatives, roadmaps, notifications, organization, webhooks.
84
+ registerInitiative(program);
85
+ registerInitiativeUpdate(program);
86
+ registerRoadmap(program);
87
+ registerNotification(program);
88
+ registerOrganization(program);
89
+ registerWebhook(program);
90
+
91
+ // Discovery commands need the fully-built program tree, so register them last.
92
+ registerCommands(program);
93
+ registerSchema(program);
94
+
95
+ // Bare `linear` (no subcommand): show the current branch's issue if one can
96
+ // be inferred. With no inferable id: human mode shows help; --json fails with
97
+ // a usage error (so a bare `linear --json` never emits non-JSON to stdout).
98
+ // When an id IS inferred, the output matches `issue view <id>` exactly.
99
+ program.action(async (_opts: unknown, command: Command) => {
100
+ if (command.args.length > 0) throw unknownCommand(command, command.args[0]!);
101
+ const ctx = new Context(command.optsWithGlobals() as GlobalOptions);
102
+ const id = currentIssueId();
103
+ if (!id) {
104
+ if (ctx.output.json) {
105
+ throw usageError(
106
+ "No issue id inferred from the current branch. Pass an issue id or run `linear --help`.",
107
+ );
108
+ }
109
+ command.help();
110
+ return;
111
+ }
112
+ const detail = await getIssueDetail(ctx.client, id);
113
+ await renderIssueDetail(ctx, detail, true);
114
+ });
115
+
116
+ // Make global options usable in any position (e.g. `linear whoami --json`),
117
+ // not just before the subcommand. Commander's optsWithGlobals() correctly
118
+ // prefers an explicitly-set value over a child default, so this is safe.
119
+ applyGlobalOptionsToAll(program);
120
+
121
+ // Route commander's own parse/usage failures through our central error
122
+ // boundary (so they honor the JSON envelope + exit codes) instead of letting
123
+ // commander call process.exit with plain text. Its error text is suppressed;
124
+ // bin/linear.ts re-emits via the Output layer.
125
+ configureErrorHandling(program);
126
+
127
+ return program;
128
+ }
129
+
130
+ /**
131
+ * The global options as commander actually parsed them, read back off the
132
+ * command tree after `parseAsync` has thrown.
133
+ *
134
+ * The error boundary has no Context to ask — the failure may have happened
135
+ * before any action ran — so it used to re-parse `process.argv` by hand with
136
+ * `argv.includes("--json")`. That check did not know the `-j` alias, bundled
137
+ * short flags (`-jq`) or any other spelling commander accepts, so `linear issue
138
+ * view NOPE-1 -j` printed a plaintext error while `--json` printed the
139
+ * envelope: an unparseable error stream for exactly the callers the envelope
140
+ * exists for. Commander has already parsed whatever it got to before failing
141
+ * (a bad option is reported only after the whole argument list is scanned), so
142
+ * the boundary reads that instead of parsing again. Every global is registered
143
+ * on every command and stored under the same key, so the flag is found
144
+ * wherever on the command path it was given.
145
+ */
146
+ export function parsedGlobalOptions(program: Command): GlobalOptions {
147
+ const keys = globalOptionKeys();
148
+ const merged: Record<string, unknown> = {};
149
+ const visit = (cmd: Command): void => {
150
+ for (const key of keys) {
151
+ if (cmd.getOptionValueSource(key) === "cli") merged[key] = cmd.getOptionValue(key);
152
+ }
153
+ for (const sub of cmd.commands) visit(sub);
154
+ };
155
+ visit(program);
156
+ return merged as GlobalOptions;
157
+ }
158
+
159
+ function applyGlobalOptionsToAll(cmd: Command): void {
160
+ for (const sub of cmd.commands) {
161
+ // addGlobalOptions skips any global the command already declares itself, so
162
+ // this is idempotent and never clobbers a command-specific version (the
163
+ // repeatable `--team` on the issue queries).
164
+ addGlobalOptions(sub);
165
+ applyGlobalOptionsToAll(sub);
166
+ }
167
+ }
168
+
169
+ /**
170
+ * The commands whose own parse failed, so the boundary can point at the right
171
+ * `--help`. Commander calls `outputError` on the command that detected the
172
+ * problem, synchronously, just before it throws — that is the only place the
173
+ * failing command is known, since `CommanderError` does not carry it.
174
+ * `.showHelpAfterError()` used to be configured for this and did nothing: the
175
+ * suppressed `writeErr` below swallowed the help it would have printed.
176
+ */
177
+ const failedCommands = new WeakSet<Command>();
178
+
179
+ /**
180
+ * What commander wrote to stderr for a command — help, when a group is
181
+ * invoked bare. `linear notification` used to print `error: (outputHelp)` and
182
+ * nothing else: commander sends the group's help to `writeErr` and asks for a
183
+ * non-zero exit, and `writeErr` was a no-op. The boundary prints this instead.
184
+ */
185
+ const suppressedStderr = new WeakMap<Command, string>();
186
+
187
+ function configureErrorHandling(cmd: Command): void {
188
+ cmd.exitOverride();
189
+ // Route commander's stderr through here rather than to the terminal: the
190
+ // boundary decides what to print (the envelope, or the buffered help).
191
+ // Help/version requested by the user use writeOut (stdout) and are intact.
192
+ cmd.configureOutput({
193
+ writeErr: (str) => {
194
+ suppressedStderr.set(cmd, (suppressedStderr.get(cmd) ?? "") + str);
195
+ },
196
+ outputError: () => {
197
+ failedCommands.add(cmd);
198
+ },
199
+ });
200
+ for (const sub of cmd.commands) configureErrorHandling(sub);
201
+ }
202
+
203
+ /** The command that last wrote to (suppressed) stderr, and what it wrote. */
204
+ export function suppressedHelp(program: Command): { command: Command; text: string } | undefined {
205
+ const find = (cmd: Command): { command: Command; text: string } | undefined => {
206
+ for (const sub of cmd.commands) {
207
+ const hit = find(sub);
208
+ if (hit) return hit;
209
+ }
210
+ const text = suppressedStderr.get(cmd);
211
+ return text ? { command: cmd, text } : undefined;
212
+ };
213
+ return find(program);
214
+ }
215
+
216
+ /**
217
+ * `Run 'linear issue create --help' for usage.` for the command whose parse
218
+ * failed, or nothing if none did (an error thrown by an action is not a parse
219
+ * error and needs no such hint).
220
+ */
221
+ export function usageHint(program: Command): string | undefined {
222
+ const find = (cmd: Command): Command | undefined => {
223
+ if (failedCommands.has(cmd)) return cmd;
224
+ for (const sub of cmd.commands) {
225
+ const hit = find(sub);
226
+ if (hit) return hit;
227
+ }
228
+ return undefined;
229
+ };
230
+ const failed = find(program);
231
+ return failed ? `Run '${commandPath(failed)} --help' for usage.` : undefined;
232
+ }
package/src/client.ts ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * LinearClient factory + a thin retry wrapper for rate limits.
3
+ */
4
+
5
+ import { LinearClient } from "@linear/sdk";
6
+ import type { ResolvedConfig } from "./config.js";
7
+ import { authError, normalizeError, CliError } from "./lib/errors.js";
8
+
9
+ export function createClient(config: ResolvedConfig): LinearClient {
10
+ if (!config.apiKey) {
11
+ // Surface the precise credential-selection error (ambiguous / unstored
12
+ // workspace) only now, when a client is actually required.
13
+ throw (
14
+ config.apiKeyError ??
15
+ authError("No API key found. Set LINEAR_API_KEY, pass --api-key, or run `linear auth login`.")
16
+ );
17
+ }
18
+ return new LinearClient({ apiKey: config.apiKey });
19
+ }
20
+
21
+ /**
22
+ * The longest single wait `withRetry` will honour. Linear's request quota
23
+ * resets hourly, so a `Retry-After` can be in the thousands of seconds; a CLI
24
+ * that silently sleeps that long looks hung (and an agent's tool call just
25
+ * times out). Beyond this we fail fast with the reset time in the message so
26
+ * the caller can schedule instead of wait.
27
+ */
28
+ export const MAX_RETRY_WAIT_MS = 30_000;
29
+
30
+ export interface RetryOptions {
31
+ /** Retries after the first attempt for a rate limit (default 3). */
32
+ retries?: number;
33
+ /** Retries for a connection that was never opened (default 1). */
34
+ networkRetries?: number;
35
+ /** Backoff base when the response carries no Retry-After (default 500 ms). */
36
+ baseDelayMs?: number;
37
+ /** Longest single wait honoured (default MAX_RETRY_WAIT_MS). */
38
+ maxWaitMs?: number;
39
+ /** Clock seam for tests. */
40
+ sleep?: (ms: number) => Promise<void>;
41
+ /**
42
+ * Where the one-line "retrying in Ns" note goes. Defaults to the reporter
43
+ * installed with `setRetryReporter` (stderr); `null` silences it.
44
+ */
45
+ report?: ((line: string) => void) | null;
46
+ }
47
+
48
+ type Reporter = (line: string) => void;
49
+
50
+ /** Plain stderr line — status, never data, so it cannot pollute `--json` stdout. */
51
+ const stderrReporter: Reporter = (line) => {
52
+ process.stderr.write(line + "\n");
53
+ };
54
+
55
+ let retryReporter: Reporter | null = stderrReporter;
56
+
57
+ /**
58
+ * Install the sink for retry status lines. The Context wires this to its
59
+ * `Output.info` so `--quiet` is honoured; until then (and for library callers)
60
+ * the default writes to stderr. Pass `null` to silence.
61
+ */
62
+ export function setRetryReporter(fn: Reporter | null): void {
63
+ retryReporter = fn;
64
+ }
65
+
66
+ /**
67
+ * Transport failures where the request provably never reached Linear, so a
68
+ * retry cannot duplicate a mutation. `ETIMEDOUT`/`ECONNRESET` are NOT here:
69
+ * the request may have been sent, and `withRetry` wraps creates.
70
+ */
71
+ const NEVER_SENT = new Set(["ConnectionRefused", "ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN"]);
72
+
73
+ /**
74
+ * Execute a Linear call, retrying on rate-limit with exponential backoff and
75
+ * normalizing any failure into a CliError. Used to wrap every service call.
76
+ *
77
+ * A 429 is safe to retry unconditionally (the request was rejected, not run).
78
+ * A connection that was refused or a name that did not resolve is retried once
79
+ * for the same reason. Anything else — including 5xx, which for a mutation may
80
+ * or may not have executed — is thrown as-is.
81
+ */
82
+ export async function withRetry<T>(fn: () => Promise<T>, opts: RetryOptions = {}): Promise<T> {
83
+ const retries = opts.retries ?? 3;
84
+ const networkRetries = opts.networkRetries ?? 1;
85
+ const base = opts.baseDelayMs ?? 500;
86
+ const maxWait = opts.maxWaitMs ?? MAX_RETRY_WAIT_MS;
87
+ const sleep = opts.sleep ?? delay;
88
+ const report = opts.report === undefined ? retryReporter : opts.report;
89
+ let attempt = 0;
90
+ for (;;) {
91
+ try {
92
+ return await fn();
93
+ } catch (err) {
94
+ const normalized = normalizeError(err);
95
+ const kind = retryKind(normalized);
96
+ const budget = kind === "rate_limited" ? retries : kind === "network" ? networkRetries : 0;
97
+ if (!kind || attempt >= budget) throw normalized;
98
+ attempt++;
99
+ const hinted = kind === "rate_limited" ? retryAfterMs(err) : undefined;
100
+ const wait = hinted ?? base * 2 ** (attempt - 1);
101
+ if (wait > maxWait) throw waitTooLong(normalized, wait);
102
+ report?.(
103
+ `${kind === "rate_limited" ? "rate limited" : "connection failed"}; retrying in ${formatSeconds(wait)} (attempt ${attempt}/${budget})`,
104
+ );
105
+ await sleep(wait);
106
+ }
107
+ }
108
+ }
109
+
110
+ function retryKind(err: CliError): "rate_limited" | "network" | undefined {
111
+ if (err.code === "rate_limited") return "rate_limited";
112
+ if (err.code === "network") {
113
+ const code = (err.detail as { code?: unknown } | undefined)?.code;
114
+ if (typeof code === "string" && NEVER_SENT.has(code)) return "network";
115
+ }
116
+ return undefined;
117
+ }
118
+
119
+ /**
120
+ * Fail fast instead of sleeping past the cap: keep the `rate_limited` code (so
121
+ * the exit code stays 5 and a script can key on it) and say when the quota
122
+ * resets, so the caller can schedule the retry.
123
+ */
124
+ function waitTooLong(err: CliError, waitMs: number): CliError {
125
+ const at = new Date(Date.now() + waitMs);
126
+ return new CliError(
127
+ `Rate limited by Linear; the quota resets in ${formatSeconds(waitMs)} (at ${at.toLocaleTimeString()}). Retry after that.`,
128
+ "rate_limited",
129
+ err.detail,
130
+ );
131
+ }
132
+
133
+ /**
134
+ * The server's Retry-After, in ms. Read from the SDK's parsed `retryAfter`
135
+ * (seconds) or the raw header, which may be seconds or an HTTP-date.
136
+ */
137
+ export function retryAfterMs(err: unknown): number | undefined {
138
+ const e = err as { retryAfter?: unknown; raw?: { response?: { headers?: any } } } | undefined;
139
+ const headers = e?.raw?.response?.headers;
140
+ const value = headers?.get?.("retry-after") ?? headers?.["retry-after"] ?? e?.retryAfter;
141
+ if (value === undefined || value === null || value === "") return undefined;
142
+ const secs = Number(value);
143
+ if (Number.isFinite(secs)) return Math.max(0, secs * 1000);
144
+ const at = Date.parse(String(value));
145
+ return Number.isFinite(at) ? Math.max(0, at - Date.now()) : undefined;
146
+ }
147
+
148
+ function formatSeconds(ms: number): string {
149
+ const secs = ms / 1000;
150
+ if (secs >= 3600) return `${(secs / 3600).toFixed(1).replace(/\.0$/, "")}h`;
151
+ if (secs >= 60) return `${Math.round(secs / 60)}m`;
152
+ return `${secs < 10 ? secs.toFixed(1).replace(/\.0$/, "") : Math.round(secs)}s`;
153
+ }
154
+
155
+ function delay(ms: number): Promise<void> {
156
+ return new Promise((resolve) => setTimeout(resolve, ms));
157
+ }
158
+
159
+ /**
160
+ * `conn.fetchNext()` behind `withRetry`, for pagination loops: page 1 of every
161
+ * listing is already wrapped at its call site, but each later page is its own
162
+ * request and can be rate limited on its own. `collect()` should advance with
163
+ * `conn = await fetchNextWithRetry(conn)` rather than a bare `fetchNext()`.
164
+ */
165
+ export function fetchNextWithRetry<C extends { fetchNext: () => Promise<C> }>(
166
+ conn: C,
167
+ opts?: RetryOptions,
168
+ ): Promise<C> {
169
+ return withRetry(() => conn.fetchNext(), opts);
170
+ }
171
+
172
+ export { CliError };
@@ -0,0 +1,121 @@
1
+ /**
2
+ * `linear issue agent-session` — the sessions Linear's agent integrations open
3
+ * on issues. Mounted under `issue` (from cli.ts) because a session lives on an
4
+ * issue: `list` reads the issue from its argument or the current branch like
5
+ * every other `issue` subcommand, and `--all-issues` widens to the workspace.
6
+ */
7
+
8
+ import { Command, Option } from "commander";
9
+ import { action } from "../lib/action.js";
10
+ import { usageError } from "../lib/errors.js";
11
+ import { currentIssueId } from "../git.js";
12
+ import type { Context } from "../context.js";
13
+ import * as svc from "../services/agent-session.js";
14
+ import type { Column } from "../output/table.js";
15
+
16
+ const ROW_COLUMNS: Column<svc.AgentSessionRow>[] = [
17
+ { key: "status", header: "Status", value: (r) => r.status, max: 13 },
18
+ { key: "agent", header: "Agent", value: (r) => r.agent?.displayName ?? "—", max: 20 },
19
+ { key: "issue", header: "Issue", value: (r) => r.issue?.identifier ?? "—" },
20
+ { key: "createdAt", header: "Created", value: (r) => r.createdAt.slice(0, 10) },
21
+ {
22
+ key: "summary",
23
+ header: "Summary",
24
+ value: (r) => r.summary?.replace(/\n/g, " ") ?? "—",
25
+ max: 60,
26
+ },
27
+ { key: "id", header: "ID", value: (r) => r.id },
28
+ ];
29
+
30
+ export function registerAgentSession(issue: Command): void {
31
+ const group = issue
32
+ .command("agent-session")
33
+ .description("Inspect the agent sessions on an issue");
34
+
35
+ // list --------------------------------------------------------------------
36
+ group
37
+ .command("list [issue]")
38
+ .alias("ls")
39
+ .description("List an issue's agent sessions (newest first)")
40
+ .addOption(
41
+ new Option("--status <status>", "only sessions in this status").choices([
42
+ ...svc.AGENT_SESSION_STATUSES,
43
+ ]),
44
+ )
45
+ .option("--all-issues", "every session in the workspace, ignoring the issue")
46
+ .addHelpText(
47
+ "after",
48
+ [
49
+ "",
50
+ "Examples:",
51
+ " linear issue agent-session list TES-123",
52
+ " linear issue agent-session list --all-issues --status awaitingInput",
53
+ " linear issue agent-session list TES-123 --json | jq -r '.[].id'",
54
+ ].join("\n"),
55
+ )
56
+ .action(
57
+ action(async (ctx: Context, opts, issueArg?: string) => {
58
+ const filters = { status: opts.status };
59
+ let rows: svc.AgentSessionRow[];
60
+ if (opts.allIssues) {
61
+ if (issueArg !== undefined) {
62
+ throw usageError("Pass either an issue or --all-issues, not both.");
63
+ }
64
+ rows = await svc.listAllAgentSessions(ctx.client, ctx.limit, filters);
65
+ } else {
66
+ const id = issueArg ?? currentIssueId();
67
+ if (!id) {
68
+ throw usageError(
69
+ "No issue id given and none could be inferred from the current branch. " +
70
+ "Pass an issue, or --all-issues for the whole workspace.",
71
+ );
72
+ }
73
+ rows = await svc.listIssueAgentSessions(ctx.client, id, ctx.limit, filters);
74
+ }
75
+ ctx.output.list(rows, ROW_COLUMNS, rows);
76
+ }),
77
+ );
78
+
79
+ // view --------------------------------------------------------------------
80
+ group
81
+ .command("view <id>")
82
+ .alias("show")
83
+ .description("Show an agent session and its activity")
84
+ .action(
85
+ action(async (ctx: Context, _opts, id: string) => {
86
+ const d = await svc.getAgentSessionDetail(ctx.client, id);
87
+ ctx.output.detail(d, [
88
+ ["Session", d.id],
89
+ ["Status", d.status],
90
+ ["Agent", d.agent ? `${d.agent.name} (${d.agent.displayName})` : null],
91
+ ["Creator", d.creator ? `${d.creator.name} (${d.creator.displayName})` : null],
92
+ ["Issue", d.issue ? `${d.issue.identifier} ${d.issue.title}` : null],
93
+ ["Created", d.createdAt],
94
+ ["Started", d.startedAt],
95
+ ["Ended", d.endedAt],
96
+ [
97
+ "Dismissed",
98
+ d.dismissedAt
99
+ ? `${d.dismissedAt}${d.dismissedBy ? ` by ${d.dismissedBy.displayName}` : ""}`
100
+ : null,
101
+ ],
102
+ ["External link", d.externalLink],
103
+ ["URL", d.url],
104
+ ["Summary", d.summary ? `\n${d.summary}` : null],
105
+ ["Activity", d.activities.length ? `\n${renderActivities(d)}` : null],
106
+ ]);
107
+ }),
108
+ );
109
+ }
110
+
111
+ /** `- <time> <type>: <one line>` per activity, oldest first, plus a truncation note. */
112
+ function renderActivities(d: svc.AgentSessionDetail): string {
113
+ const lines = d.activities.map((a) => {
114
+ const when = a.createdAt.slice(11, 19);
115
+ const text =
116
+ a.action !== null ? `${a.action}${a.parameter ? `: ${a.parameter}` : ""}` : (a.body ?? "");
117
+ return `- ${when} ${a.type}: ${text.replace(/\s*\n\s*/g, " ")}`;
118
+ });
119
+ if (d.activitiesTruncated) lines.push(`… more activities not shown (use --json)`);
120
+ return lines.join("\n");
121
+ }