An agent-first CLI for Linear: list, search, show, create and
update issues and comments from a shell or an AI agent, with --json on every
command, --dry-run/--yes on every change, and a describe command that prints
the whole surface as JSON.
linearctl speaks GraphQL to https://api.linear.app/graphql directly, without
@linear/sdk. The
SDK loads relations lazily, one request each, and an API key gets 2,500 requests
an hour; a linearctl read sends one request per page of up to 250. The
queries are written by hand and checked against Linear's published schema by the
tests.
npm install -g @solcreek/linear-cli # macOS, Linux, Windows
cargo install --git https://github.com/solcreek/linear-cli # from sourceOr download a binary from Releases
(checksums.txt alongside). The npm package installs the native binary for
your platform, and its install script puts that binary where linearctl
points, so no Node runs when you call it (~3 ms a run). Where install scripts
are skipped (--ignore-scripts, pnpm, and npm versions that ask for
--allow-scripts), a small Node shim runs the same binary instead (~34 ms).
To keep the fast path on npm 11.19 and later:
npm install -g @solcreek/linear-cli --allow-scripts=@solcreek/linear-cliA program that depends on the npm package can run the native binary itself, with no Node in between. Its path is stable, the one the package's own shim uses:
const exe = process.platform === "win32" ? "linearctl.exe" : "linearctl"
const bin = require.resolve(`@solcreek/linear-cli-${process.platform}-${process.arch}/bin/${exe}`)linearctl --demo issue list
LINEARCTL_DEMO=1 linearctl issue show ENG-142Demo mode answers from a fictional workspace built into the binary, Northwind,
with no API key and no network. Every command sends its usual GraphQL query and
gets the answer from that workspace, so the output has exactly the shapes of
real output. Writes are refused; --dry-run works.
Create a personal API key at https://linear.app/settings/account/security, then either export it:
export LINEAR_API_KEY=lin_api_…or store it (macOS Keychain, or credentials.json with mode 0600 elsewhere). The
key is read from stdin, never from an argument:
pbpaste | linearctl auth login --yes
linearctl auth statusLINEAR_API_KEY wins over a stored key.
linearctl issue list --assignee me --state-type started
linearctl issue list --team ENG --label bug --updated-since 7d --json
linearctl issue list --query "login timeout" --limit 10
linearctl issue list --any-label bug --any-label regression --without-label wontfix
linearctl issue list --project 0527ca4c9128 --milestone Beta # a project's slugId, URL, UUID or name
linearctl issue show ENG-123
linearctl issue show ENG-123 --signed-urls 300 --json # image URLs that open without the key
linearctl issue create --team ENG --title "Fix login timeout" --label bug --dry-run --json
linearctl issue create --team ENG --title "Fix login timeout" --label bug --id <id from dry run> --yes
linearctl issue update ENG-123 --state "In Progress" --assignee me --yes
linearctl comment create ENG-123 --body-file notes.md --id <uuid> --yes
linearctl team list
linearctl state list --team ENG
linearctl label list --team ENG
echo '{"query":"query { viewer { name } }"}' | linearctl api query --json-input -Every command has --help with examples. linearctl describe prints all commands,
flags and exit codes as JSON.
| command | does | changes things |
|---|---|---|
auth login / status / logout |
store, check, remove the API key | login, logout |
issue list |
list and search issues; filter by team, assignee, state, label, project, milestone, cycle, date; --fields adds each row's parent and relations |
|
issue show |
one issue with description, comments, sub-issues, relations, attachments | |
issue create / update / archive |
change issues | yes |
comment list / create |
read and post comments | create |
team / state / label / user / project / cycle list |
the names other commands take | |
project show |
one project with its overview, documents, milestones and first 50 issues; --all-issues for every one, issuesTruncated says when some were left out |
|
project create / update |
create a project in one or more teams; change its name, summary or overview | yes |
file download |
save an image or attachment from uploads.linear.app |
yes |
config show / set |
default-team |
set |
api query |
send your own GraphQL query (read-only) | |
serve --stdio |
run many invocations from one process, as JSON lines (~80 ms less per call) | |
watch |
stream changes to the issues a filter matches, as NDJSON (Linear's subscriptions, else polling) |
AGENTS.md is the context to give an agent. In short:
- Always pass
--json. Into a pipe it is compact, one document on stdout. - Every command that changes something takes
--dry-runor--yes, exactly one, and never prompts. A rerun that has nothing to do succeeds with"changed": false. issue createandcomment createneed--id UUIDwith--yes.--dry-runprints one; retrying with the same id returns the existing item instead of a duplicate.- Lists are
{items, total, shown, truncated, nextCursor}; continue with--cursor, trim with--fieldsand--limit. issue list --blockedis Linear'shasBlockedByRelations, and Linear decides what it matches: an open issue that an open issue blocks (open: triage, backlog, unstarted or started). A completed or canceled blocker does not count;--blockingis the same from the other end. To see each blocker and its state, add--fields identifier,inverseRelations.- A failure is
{error, usage, code}, plushintwhen there is a next step: the command that finds what was not found, or that a rerun is safe. Branch oncode(NOT_FOUND,AMBIGUOUS,NETWORK, …;describelists them all), not on the words oferror. A name that did not resolve addskindandname,available(for a state or label) orcandidates(forAMBIGUOUS). issue listandwatchtake--projectas a linear.app project URL, a slugId, a UUID or a name, and--milestoneas a UUID or a name (looked for in--projectwhen given). Both are looked up first, in one request, and the issues filtered by id: one that finds nothing isNOT_FOUND, and a name more than one project (or milestone) has isAMBIGUOUS, never an empty or mixed list.nonemeans in no project (milestone) and needs no lookup.
| exit | meaning |
|---|---|
| 0 | ok, including "found nothing" |
| 1 | failed: a network error, a Linear error, not found |
| 2 | usage: fix the invocation, do not retry it as is |
| 3 | partial: api query data with problems |
| 4 | busy: a config lock is held; retry |
| 5 | auth: no API key, or Linear rejected it |
| 6 | rate-limited: retry after retryAt |
| 7 | conflict: --if-updated-at no longer holds; read it again |
- Pass
--jsonand read one document from stdout (one line, in a pipe). Decide by the exit code, then bycodein a failure (describelists the codes). - Give linearctl a stdin pipe only when you send input (
--json-input -,--body-file -,--description-file -,auth login); otherwise give it none or/dev/null. linearctl can exit before reading stdin, after a usage error or for a command that reads none, and your write to the pipe then fails with EPIPE: ignore that error and use the exit code. - Retry on
NETWORK, and onRATE_LIMITEDafterretryAt. Changes are safe to rerun (changed: false);issue create,comment create,cycle createandproject createneed--idfor that. - Making many calls:
serve --stdioanswers them from one process, about 80 ms less each. A request's"stdin"is its command's standard input (for--json-input=-), and--concurrency Nruns up to N at once, answers matched byid. - From the npm package, run the native binary directly (see Install) to skip Node.
A command's time is almost all the request to Linear (~150 ms). Starting, decoding and printing take ~4 ms: 2.7 ms to start, ~1.3 ms for a 100-issue page. Measurements and what was tried: docs/performance.md.
linearctl is written in Rust; the acceptance tests and the benchmark's fake Linear are Go.
make test # unit tests, then acceptance/ and tests/golden against the binary
make check # what CI runs: rustfmt, clippy, gofmt, go vet, then make test
make cover # coverage of all of the above, merged
make live # against the real API: LINEARCTL_LIVE_TEAM=SANDBOX LINEAR_API_KEY=…
make schema # refresh graphql/schema.graphql from Linearacceptance/ runs the built binary against a fake Linear that checks every
request against Linear's schema, with LINEARCTL_HOME pointing at a temporary
directory, so the tests never touch the network, the Keychain or your config.
tests/golden holds what every command prints offline (help, errors, demo
mode), byte for byte. The design is in docs/design.md.