Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/command-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Command runtime

Command handlers extend `CommandOptions` from `src/core/runtime/options.ts` and add only operation-specific flags. Output-only commands can extend `CommandOutputOptions` instead.

- Use `createCommandContext(options)` for profile resolution, GraphQL, resolver dependencies, retry normalization, and standard success/failure output.
- `ctx.resolveProfile()` caches the selected profile. Selection remains explicit `profile`, then `env.LINEAR_PROFILE`, then the configured default. An explicit API URL overrides the profile's base URL.
- The same `fetchImpl` handles OAuth refresh, GraphQL, and name resolution. Do not resolve a profile through a separate uninjected transport.
- Use `commandIO(options)` or `ctx.stdout`/`ctx.stderr` for human-readable output and JSONL. Pass the options through to validation, dry-run, pagination, and retry helpers so diagnostics use the same streams.
- `stdout` and `stderr` are optional minimal writable objects. They default to the process streams, but no global runtime or stream replacement is installed. `main()` forwards its supplied streams to every registry handler and the advisory schema freshness check.

Use `tests/helpers/output.ts` to capture injected output in tests. Concurrent command invocations can use independent streams and transports.

## Issue commands

`src/commands/issue.ts` retains registry dispatch and the existing public exports. Implementation lives in `src/commands/issue/`:

| Module | Responsibility |
| --- | --- |
| `options.ts` | Issue-specific flags |
| `documents.ts` | Shared GraphQL fragments and documents |
| `model.ts` | Response types, normalization, human formatting |
| `input.ts` | Input validation, filters, and shared issue lookup |
| `read.ts` | Get, list, and search |
| `write.ts` | Create, update, and delete |
| `workflow.ts` | Close, assign, comment, and Slack attachment |
| `bulk.ts` | Bulk execution and partial-failure contracts |

Specialized envelopes remain in handlers where their contracts differ from standard context output, such as raw GraphQL partial data, bulk partial failures, and schema metadata. CLI flags, JSON shapes, and exit-code contracts are unchanged.
2 changes: 1 addition & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ If `file upload --issue` uploads successfully but attachment creation fails, its

File upload and download stream with backpressure instead of buffering entire files. Upload sizes come from local file metadata; upload sources must be regular files and should not be modified during a transfer.

`--transfer-timeout` sets a total transfer deadline in whole seconds (default **120**, range 1–2147483). The deadline starts with the first PUT/GET and covers all redirects and response-body consumption, including stalled bodies and download writes. It does not change the separate GraphQL request timeout or retry policy; file transfers are not automatically retried. Ctrl-C (SIGINT) or SIGTERM cancels an active transfer and cleans up local resources. Timeout/cancellation returns exit 1.
`--transfer-timeout` sets a total transfer deadline in whole seconds (default **120**, range 1–2147483). The deadline starts with the first PUT/GET and covers all redirects and response-body consumption, including stalled bodies and download writes. It does not change the separate GraphQL request timeout or retry policy; file transfers are not automatically retried. Ctrl-C (SIGINT) or SIGTERM cancels an active transfer and cleans up local resources. Timeout/cancellation returns exit 1 before the download commit boundary. Cancellation is checked immediately before the atomic destination rename; once dispatched, that non-cancellable filesystem operation is awaited and its actual result reported, even if cancellation or the deadline arrives meanwhile.

Downloads overwrite existing destinations **only after successful completion**, using a private staging directory beside the output and an atomic rename on the same filesystem. Transfer, write, or rename failures leave the existing destination unchanged and attempt to remove staging files; the parent directory must already exist and be writable. A destination symlink is replaced, not followed. The new file uses private permissions (0600 on POSIX); existing permissions/metadata are not retained. Staging cleanup is best effort: filesystem cleanup failures do not hide the original transfer error or turn a committed download into a failure. Cleanup failures, forced termination (SIGKILL), crashes, or power loss can leave staging directories; atomic replacement is not a crash-durability guarantee.

Expand Down
2 changes: 2 additions & 0 deletions docs/filtering-and-pagination.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Warning: results truncated at 50 items. Use --all to fetch all results, or --max

This prevents silently incomplete data. Always check stderr or use `--all`/`--max` when you need complete results.

`--max` and `--limit` share one bound: the last occurrence wins, including across leading and command-position options. For example, `--max 10 issue list --limit 20` uses a bound of 20.

`project list` requests 50 projects per page by default, including with `--all`, to stay under Linear's query-complexity limit while preserving milestone and team fields in JSON output. An explicit `--page-size <n>` still takes precedence.

### Flags
Expand Down
4 changes: 2 additions & 2 deletions skills/linearctl/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ Raw GraphQL should not be used merely because it is possible. It is the fallback
- `linearctl file upload <path> [--issue <id>] [--transfer-timeout <seconds>] --json`
- `linearctl file url <attachment-id> [--expires-in <seconds>] --json`
- `linearctl file download <url> [--output <path>] [--transfer-timeout <seconds>] --json`
- Upload/download stream with backpressure. Upload sources must be regular files; do not modify them during transfer. `--transfer-timeout` is a total PUT/GET deadline in whole seconds (default 120, range 1–2147483), including redirects, response bodies, and download writes; GraphQL requests keep their separate timeout/retry policy. Transfers are not automatically retried. Ctrl-C/SIGINT or SIGTERM cancels an active transfer; timeout/cancellation returns exit 1.
- Upload/download stream with backpressure. Upload sources must be regular files; do not modify them during transfer. `--transfer-timeout` is a total PUT/GET deadline in whole seconds (default 120, range 1–2147483), including redirects, response bodies, and download writes; GraphQL requests keep their separate timeout/retry policy. Transfers are not automatically retried. Ctrl-C/SIGINT or SIGTERM cancels an active transfer; timeout/cancellation returns exit 1 before download commit. The final atomic rename is checked for cancellation before dispatch, but cannot be cancelled once in flight; its actual result is reported even if the deadline or cancellation arrives meanwhile.
- Downloads stage beside the destination and atomically overwrite it only after success. Transfer/write/rename failures preserve existing contents and attempt staging cleanup; cleanup failures do not change the primary transfer outcome. The parent must exist and be writable. Destination symlinks are replaced rather than followed; new files have private permissions (0600 on POSIX), not the old metadata. Cleanup failures or SIGKILL/crashes can leave staging directories; this is not a crash-durability guarantee.
- Requests and redirects require HTTPS, with at most five redirects; downloads must start at `uploads.linear.app`. Same-host redirects keep signed upload headers or Linear authorization; cross-host redirects drop sensitive headers permanently. Redirected PUTs replay the file from byte zero.

Expand Down Expand Up @@ -201,7 +201,7 @@ Use `--dry-run` on any mutating command to preview what would happen without exe
- Default list behavior returns the first page only (up to 50 items)
- **When results are truncated, a warning is emitted to stderr** — check stderr to know if you have incomplete data
- Use `--all` to fetch all results (with `--max` or `--limit` to limit)
- Use `--max <n>` or `--limit <n>` to cap total results
- Use `--max <n>` or `--limit <n>` to cap total results; the last occurrence of either alias wins, including across leading and command-position flags
- Use `--quiet` / `-q` to suppress the truncation warning (useful when piping JSON)
- Add filters before broad pagination whenever possible
- Prefer `--jsonl` for large result sets — it streams one object per line; pass `--all` or `--max <n>`
Expand Down
35 changes: 25 additions & 10 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ import { maybeWarnForStaleSchema } from "../core/schema/freshness.js";
import { failureEnvelope } from "../core/output/envelope.js";
import { mapCommandFailure, type CommandFailure } from "../core/errors/command-failure.js";
import type { FetchLike } from "../core/transport/graphql.js";
import type { OutputStream } from "../core/runtime/options.js";
import packageJson from "../../package.json" with { type: "json" };

interface MainRuntime {
export interface MainRuntime {
env: NodeJS.ProcessEnv;
stdin: NodeJS.ReadableStream;
stdout: NodeJS.WriteStream | Pick<NodeJS.WriteStream, "write">;
stderr: NodeJS.WriteStream | Pick<NodeJS.WriteStream, "write">;
stdout: OutputStream;
stderr: OutputStream;
fetchImpl?: FetchLike;
schemaFreshnessTimeoutMs?: number;
}
Expand Down Expand Up @@ -73,15 +74,24 @@ function parseCliOptionSet(
argv: string[],
options: Record<string, { type: "boolean"; short?: string; multiple?: true } | { type: "string"; short?: string; multiple?: true }>
): { values: Record<string, unknown>; positionals: string[] } {
const { values, positionals } = parseArgs({
const { values, positionals, tokens } = parseArgs({
args: argv,
options,
allowPositionals: true,
strict: true
strict: true,
tokens: true
});

// Normalize aliases before merging leading and command-position options.
// Tokens retain ordering even when both aliases occur in the same segment.
const bound = tokens.slice().reverse().find((token) => token.kind === "option" && (token.name === "max" || token.name === "limit"));
if (bound?.kind === "option") {
values.max = values[bound.name];
delete values.limit;
}

return {
values: values as Record<string, unknown>,
values: { ...values, ...(bound?.kind === "option" ? { maxOptionName: bound.name } : {}) },
positionals
};
}
Expand Down Expand Up @@ -208,7 +218,7 @@ function toParsedCliArguments(values: Record<string, unknown>, positionals: stri
const jsonl = values.jsonl === true;
const jsonEnvelope = values["json-envelope"] === true;
const states = stringArrayValue(values.state);
const maxValue = typeof values.max === "string" ? values.max : typeof values.limit === "string" ? values.limit : undefined;
const maxValue = typeof values.max === "string" ? values.max : undefined;

if (jsonl && jsonEnvelope) {
throw new Error("--jsonl and --json-envelope are mutually exclusive");
Expand Down Expand Up @@ -296,7 +306,7 @@ function toParsedCliArguments(values: Record<string, unknown>, positionals: stri
...(typeof values["order-by"] === "string" ? { orderBy: values["order-by"] } : {}),
...(typeof values["order-dir"] === "string" ? { orderDir: values["order-dir"] } : {}),
all: values.all === true,
...(maxValue === undefined ? {} : { max: parsePositiveInt(maxValue, typeof values.max === "string" ? "max" : "limit") }),
...(maxValue === undefined ? {} : { max: parsePositiveInt(maxValue, values.maxOptionName === "limit" ? "limit" : "max") }),
...(typeof values["page-size"] === "string" ? { pageSize: parsePositiveInt(values["page-size"], "page-size") } : {}),
...(typeof values.after === "string" ? { after: values.after } : {}),
sync: values.sync === true,
Expand Down Expand Up @@ -383,8 +393,12 @@ export async function main(argv: string[], runtime: MainRuntime = defaultRuntime
if (registration !== undefined) {
try {
const options = registration.buildOptions(args, runtime.env, runtime.stdin);
if (runtime.fetchImpl !== undefined && options !== null && typeof options === "object") {
(options as Record<string, unknown>).fetchImpl = runtime.fetchImpl;
if (options !== null && typeof options === "object") {
Object.assign(options, {
stdout: runtime.stdout,
stderr: runtime.stderr,
...(runtime.fetchImpl === undefined ? {} : { fetchImpl: runtime.fetchImpl }),
});
}
const exitCode = await registration.handler(args.positionals.slice(1), options);
if (!args.help && !args.dryRun) {
Expand Down Expand Up @@ -462,6 +476,7 @@ async function runSchemaFreshnessCheck(
credentialsFile: args.credentialsFile,
...(args.apiUrl === undefined ? {} : { apiUrl: args.apiUrl }),
env: runtime.env,
stderr: runtime.stderr,
fetchImpl
}),
new Promise<void>((resolve) => {
Expand Down
Loading
Loading