linear-sdk-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +20 -0
- package/.claude-plugin/plugin.json +9 -0
- package/CHANGELOG.md +807 -0
- package/COVERAGE.md +495 -0
- package/LICENSE +21 -0
- package/MIGRATING.md +220 -0
- package/README.md +524 -0
- package/package.json +87 -0
- package/skills/linear-sdk-cli/SKILL.md +534 -0
- package/skills/linear-sdk-cli/references/api.md +27 -0
- package/skills/linear-sdk-cli/references/attachment.md +82 -0
- package/skills/linear-sdk-cli/references/auth.md +174 -0
- package/skills/linear-sdk-cli/references/commands.md +37 -0
- package/skills/linear-sdk-cli/references/comment.md +154 -0
- package/skills/linear-sdk-cli/references/completion.md +17 -0
- package/skills/linear-sdk-cli/references/config.md +99 -0
- package/skills/linear-sdk-cli/references/cycle.md +131 -0
- package/skills/linear-sdk-cli/references/document.md +180 -0
- package/skills/linear-sdk-cli/references/favorite.md +78 -0
- package/skills/linear-sdk-cli/references/initiative-update.md +67 -0
- package/skills/linear-sdk-cli/references/initiative.md +256 -0
- package/skills/linear-sdk-cli/references/issue.md +928 -0
- package/skills/linear-sdk-cli/references/label.md +112 -0
- package/skills/linear-sdk-cli/references/milestone.md +139 -0
- package/skills/linear-sdk-cli/references/notification.md +123 -0
- package/skills/linear-sdk-cli/references/open.md +29 -0
- package/skills/linear-sdk-cli/references/organization.md +99 -0
- package/skills/linear-sdk-cli/references/project-update.md +67 -0
- package/skills/linear-sdk-cli/references/project.md +234 -0
- package/skills/linear-sdk-cli/references/roadmap.md +150 -0
- package/skills/linear-sdk-cli/references/schema.md +21 -0
- package/skills/linear-sdk-cli/references/state.md +61 -0
- package/skills/linear-sdk-cli/references/team.md +227 -0
- package/skills/linear-sdk-cli/references/user.md +103 -0
- package/skills/linear-sdk-cli/references/webhook.md +151 -0
- package/skills/linear-sdk-cli/references/whoami.md +26 -0
- package/skills/linear-sdk-cli/scripts/generate-docs.ts +339 -0
- package/src/bin/linear.ts +97 -0
- package/src/cli.ts +232 -0
- package/src/client.ts +172 -0
- package/src/commands/agent-session.ts +121 -0
- package/src/commands/api.ts +317 -0
- package/src/commands/attach.ts +90 -0
- package/src/commands/attachment.ts +74 -0
- package/src/commands/comment.ts +225 -0
- package/src/commands/completion.ts +69 -0
- package/src/commands/cycle.ts +125 -0
- package/src/commands/discover.ts +170 -0
- package/src/commands/document.ts +211 -0
- package/src/commands/favorite.ts +72 -0
- package/src/commands/initiative-update.ts +47 -0
- package/src/commands/initiative.ts +261 -0
- package/src/commands/issue.ts +1263 -0
- package/src/commands/label.ts +119 -0
- package/src/commands/meta.ts +534 -0
- package/src/commands/milestone.ts +163 -0
- package/src/commands/notification.ts +110 -0
- package/src/commands/open.ts +84 -0
- package/src/commands/organization.ts +79 -0
- package/src/commands/project-update.ts +47 -0
- package/src/commands/project.ts +339 -0
- package/src/commands/roadmap.ts +131 -0
- package/src/commands/state.ts +58 -0
- package/src/commands/team.ts +243 -0
- package/src/commands/user.ts +107 -0
- package/src/commands/webhook.ts +156 -0
- package/src/config.ts +1236 -0
- package/src/context.ts +117 -0
- package/src/git.ts +174 -0
- package/src/index.ts +10 -0
- package/src/lib/action.ts +26 -0
- package/src/lib/body.ts +172 -0
- package/src/lib/errors.ts +195 -0
- package/src/lib/introspect.ts +150 -0
- package/src/lib/keyring.ts +270 -0
- package/src/lib/mutation.ts +53 -0
- package/src/lib/open.ts +32 -0
- package/src/lib/options.ts +640 -0
- package/src/lib/output-shapes.ts +562 -0
- package/src/lib/pagination.ts +145 -0
- package/src/lib/prompt.ts +91 -0
- package/src/lib/resolve.ts +596 -0
- package/src/lib/shape.ts +273 -0
- package/src/lib/status-update.ts +91 -0
- package/src/lib/upload.ts +329 -0
- package/src/output/color.ts +52 -0
- package/src/output/format.ts +238 -0
- package/src/output/markdown.ts +133 -0
- package/src/output/pager.ts +131 -0
- package/src/output/sanitize.ts +76 -0
- package/src/output/table.ts +198 -0
- package/src/services/agent-session.ts +289 -0
- package/src/services/attachment.ts +198 -0
- package/src/services/comment.ts +262 -0
- package/src/services/cycle.ts +224 -0
- package/src/services/document.ts +357 -0
- package/src/services/favorite.ts +147 -0
- package/src/services/initiative-update.ts +59 -0
- package/src/services/initiative.ts +449 -0
- package/src/services/issue.ts +1153 -0
- package/src/services/label.ts +193 -0
- package/src/services/milestone.ts +262 -0
- package/src/services/notification.ts +190 -0
- package/src/services/organization.ts +144 -0
- package/src/services/project-update.ts +36 -0
- package/src/services/project.ts +494 -0
- package/src/services/roadmap.ts +177 -0
- package/src/services/state.ts +127 -0
- package/src/services/team.ts +386 -0
- package/src/services/user.ts +137 -0
- package/src/services/webhook.ts +203 -0
|
@@ -0,0 +1,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
|
+
}
|