A Rust implementation of the unofficial schpet/linear-cli, written for headless, non-interactive use by agents. One binary, no runtime, no daemon: every invocation is a one-shot process that either prints data and exits 0, or prints a one-line explanation to stderr and exits non-zero.
It keeps upstream's command tree, output strings, error contexts and --json shapes, so
prompts, scripts and agent instructions written against upstream keep working.
linear issue mine --json # what am I working on
linear issue view ENG-123 # human-readable detail
linear issue create --team ENG --title "..." # non-interactive creation
linear api 'query { viewer { name } }' # raw GraphQL escape hatch
| Property | What it means for an agent |
|---|---|
| stdout is data, stderr is diagnostics | warnings never contaminate --json output; you can pipe stdout straight into a parser |
| truthful exit codes | 0 success (including "no results"), non-zero for validation, auth, not-found and API failures; never a panic, never a silent empty success |
| no blocking prompts | commands that would confirm interactively require an explicit --force / --yes; without a terminal they fail fast with a message instead of waiting on stdin |
--json preserves GraphQL field names |
payloads are camelCase exactly as Linear's API docs describe them, so they concatenate cleanly into agent context |
| one-shot process, no state | nothing is resident between calls; commands are safe to run concurrently |
| no runtime to install | a single ~5 MiB binary linking only libc/libgcc — no Node/Deno/Python, so it drops into a minimal container |
| small memory footprint | ~10.7 MiB peak for a command that touches nothing but its own binary (--help, completions, markdown) and ~14.4 MiB for the heaviest, schema, which loads credentials and builds a client schema from Linear's ~2.7 MB introspection document (schema --json skips the build and peaks ~13.6 MiB; both write their output as it is produced rather than assembling it in memory first). Measured as the kernel's ru_maxrss, one fresh process per command with a scratch HOME, because measuring in a shared process reports the running maximum of every child instead of the child — and the floor is the process, not the data: a 5.27 MB binary plus libc and the Rust runtime |
| pipeline-safe | a closed reader (`linear issue list |
| bounded network behaviour | every outbound call has a deadline and a shorter connect deadline; a read that fails on a 429/5xx or a transport error is retried up to three times with jittered backoff, while a mutation is sent exactly once — a retried create is a duplicate |
Two channels, and they do not publish the same target set. Stated rather than aspired to:
| channel | targets | what it publishes |
|---|---|---|
| the instance's rolling release (what this deployment consumes) | x86_64-unknown-linux-gnu |
CLI + service archives, both republished on every push to main |
GitHub Actions (Vedaru/linear-cli-rs, mirror) |
linux gnu, linux musl, x86_64-apple-darwin, aarch64-apple-darwin, x86_64-pc-windows-msvc |
the same two archives per target |
"They do not have the same target set" is not an oversight to fix later: the instance's runner is a single Linux image, and the Apple and Windows targets need SDKs that image does not have, while the five-target matrix runs where free runners exist for it. "Every target on both channels" is therefore not reachable, and this table is the honest form of that answer.
Neither channel is on crates.io, and this is a decision rather than a gap. Installing means
downloading a tarball or building from source, so cargo install linear and cargo binstall linear
are not supported and their metadata is deliberately absent instead of half-declared. Either reason
alone would be enough: the crate name an obvious choice would want is taken, and a registry copy
would be a third channel to keep in step with the two that exist.
From a release tarball (the archive extracts into a top-level directory containing linear,
README.md and AGENTS.md):
tar xf linear-cli-rs-<sha>-x86_64-unknown-linux-gnu.tar.gz
install -m 0755 linear-cli-rs-<sha>-x86_64-unknown-linux-gnu/linear ~/.local/bin/linearTwo archives are published for each build, from one commit: the plain name above is the CLI
alone, and linear-cli-rs-<sha>-service-x86_64-unknown-linux-gnu.tar.gz adds the bridge half
(linear sync, linear webhook) that a deployment installs.
From a checkout, which works on every target today and needs no tarball:
cargo install --path . # onto PATHA one-command install straight from the URL - curl … | tar … or cargo install --git … against the
instance - is not documented because it does not work yet: a plain request for an asset is met by the
authenticating gateway rather than the file (that is VED-71, and it is the same rule that makes a
hard reload slow), so the path a stranger can follow today is the GitHub mirror, which is public but
deliberately frozen. Both halves of that sentence are temporary; the tarball recipe above is not.
Or build it:
cargo build --release # target/release/linear — the CLI, and nothing elseThat is the whole CLI: no store, no intake server, no SQLite (~5 MiB). The bridge half — the
mirroring engine behind linear sync and the webhook service behind linear webhook — is behind
one optional feature, so it stays out of a binary that only talks to Linear:
cargo build --release --features service # adds `sync`, `webhook`; ~7.1 MiBThe published release tarball is built with it, because the deployment that consumes that
tarball is the service. Everything below about linear sync and linear webhook needs that build
or that tarball.
Resolution order (highest first): command-line flags, LINEAR_API_KEY in the environment,
a project config (linear.toml, .linear.toml or .config/linear.toml in the working
directory or git root), then the global config at $XDG_CONFIG_HOME/linear/linear.toml.
For a server or an agent host, prefer the environment or a 0600 global config — never
pass a key as an argument, where it lands in shell history and ps:
read -rsp 'Linear API key: ' K && printf 'api_key = "%s"\n' "$K" > ~/.config/linear/linear.toml \
&& chmod 600 ~/.config/linear/linear.toml && unset Klinear auth login stores a credential in the OS keyring and needs a real terminal; on a
host without a keyring pass --plaintext to keep it in credentials.toml instead. It
validates the key against Linear before storing anything, and its error output never echoes
the key.
Pin the workspace's team once and no command needs --team:
# ~/.config/linear/linear.toml
api_key = "lin_api_..."
team_id = "35feb448-7bc2-4bcb-a949-a58c7572949a" # a UUID, a key or a name all resolve- Exit codes:
0success,1for everything the CLI reports (validation, auth, not-found, GraphQL errors, HTTP failures). Failures print one line prefixed✗plus an indented suggestion on stderr, e.g.✗ Failed to fetch projects: Team not found: NOSUCHTEAM/Available teams: WAV (WAVE-cloud). --jsonis on 85 of the 140 leaf commands, and the other 55 are listed with a reason intests/json_coverage.rs(EXEMPT) rather than quietly missing it: a command whose output is not data (a credential, a reference document, a scaffold, the raw API response) never will carry it, and the rest are mutations whose success is the exit code today. The ratchet fails if that list grows.issue queryis the machine-readable issue lister, and it carries the levers an agent needs on top of the filter flags:--count-only(how many match, without fetching them),--group-by state|priority|assignee|project,--since 7d, and--ndjson(one JSON object per line, streamed page by page). Note thatissue listis a hidden alias ofissue mine(it answersUsage: linear issue mine), so scripts can use either name. Always checklinear <group> <sub> --helpbefore scripting a flag.- Empty results: with
--jsonyou always get JSON (an emptynodesarray); on human output a listing prints a notice such asNo issues found.instead. Parse the JSON rather than scraping text. - Confirmations: destructive commands (
issue delete,issue archive,project delete,document delete,initiative delete,milestone delete,label delete,team delete) take--force/--yes; in a headless run the flag is mandatory. - Inputs from files or stdin: bodies accept
--body-file <path>, and where a flag is omitted the CLI reads stdin rather than opening an editor when there is no terminal. linear api '<graphql>'is the escape hatch for anything the typed commands do not cover; pass variables with--variables <json>(or stdin).- Pipelines: a reader that closes early is not an error — the process dies by
SIGPIPEwith no message on stderr. - Environment:
LINEAR_API_KEY,LINEAR_TEAM_ID,LINEAR_WORKSPACE,LINEAR_GRAPHQL_ENDPOINT(point it at a mock to test),LINEAR_IGNORE_ENV_FILE=1,LINEAR_DEBUG=1(full error context),NO_COLOR=1, plus.envloading from the working directory. - External helpers:
git,jj,gh,$EDITORand the pager are invoked with deadlines and without inheriting a terminal, so a missing tool or a hung helper cannot stall an agent (issue pull-requestneedsgh;team autolinksneedsghand a GitHub remote).
26 groups, 140 leaf commands. Run linear <group> --help for flags — the help text
is the authoritative reference. Where this port adds to upstream it says so: AGENTS.md lists
every addition and every deliberate deviation.
| Group | Subcommands |
|---|---|
auth |
login add a workspace credential · logout · list configured workspaces · default · token print the configured token · whoami · migrate plaintext credentials to the keyring |
issue |
id · mine · query structured filters (--count-only, --group-by, --since, --ndjson, --view) · title · start · view · url · describe · commits (jj only) · pull-request (gh) · archive · unarchive · delete · subscribe · unsubscribe · create · update · close · assign · move · transfer (the four common edits as names over update) · comment (add/list/resolve…) · attach sidebar link · link a URL · attachment list/get/update/delete a sidebar link (create-only upstream) · relation dependencies · agent-session |
project |
list · view · create · update · delete (trashes: unarchive restores it) · archive (reversible) · unarchive · members who is on it · member add/remove (incremental) · label list/add/remove/set · comment |
project-update |
create · list project status updates |
roadmap |
list · view a roadmap and the projects on it — reads only: Linear deprecated roadmap writes, and initiative add-project is the successor |
team |
create · delete · update (the API's teamUpdate; upstream can create and delete a team but never correct one) · list · id · autolinks (gh) · members · states workflow states |
user |
list workspace members |
cycle |
list · view · create · complete · update · archive (archiving is irreversible — the API has no unarchive, and no cycleDelete exists to wrap) |
sprint |
status · progress · carry-over (a dry run unless --apply) · burndown · velocity — every figure is the product and the chart is a rendering of it, so all five answer --json |
milestone |
list · view · create · update · delete |
initiative |
list · view · create · update · archive · unarchive · delete · add-project · remove-project · comment |
initiative-update |
create · list initiative timeline posts |
label |
list · create · delete · update (rename, recolour, redescribe) |
template |
list · view what a workspace template pre-fills · show · create · update · delete — local templates live under the config directory and are the default, --workspace addresses Linear's own, and a local name that shadows a workspace one says so |
document |
list · view · create · update · delete · comment |
view |
list · view · create · update · delete custom views — and apply one to a listing with issue query --view <name|id> |
export |
issues · projects as csv (default), json, ndjson (issues only) or markdown — the JSON document is the one issue query --json prints, so it re-imports |
import |
issues from a CSV or JSON export — dry run unless --apply, matched by the identifier column then by title, and only the fields that differ are sent |
notification |
list · read · archive what Linear told this user — every listing and write names the user whose read state it is, because Linear's read state is per user |
config |
service print the bridge's config sections, for a linear.toml you already have (no questions, and never a secret) |
schema |
print the GraphQL schema, or --json for the raw introspection result |
api |
raw GraphQL request |
markdown |
the Linear-flavoured markdown reference (mentions, collapsible sections) |
completions |
bash · zsh · fish · powershell |
webhook |
serve the intake service · replay a stored delivery again, as the provider sent it (both need the service feature) |
sync |
status what the store holds and what it gave up on · link pair two entities by hand (needs the service feature) |
# Discover before writing: ids, teams and states
linear team list --json | jq -r '.nodes[] | "\(.key)\t\(.id)"'
linear team states ENG --json | jq -r '.[] | select(.type=="started") | .id'
# Triage loop, machine-readable end to end
linear issue mine --json
linear issue query --team ENG --state started --json
# Create with a body from a file, then move it along
linear issue create --team ENG --title "Fix login redirect" --description-file /tmp/body.md --json
linear issue update ENG-123 --state "In Progress"
linear issue comment add ENG-123 --body-file /tmp/note.md
# Link context: a URL on the issue, a dependency between issues
linear issue link ENG-123 https://example.com/logs
linear issue relation add ENG-123 blocks ENG-124
# Anything not covered by a typed command
linear api 'mutation { issueUpdate(id: "...", input: { title: "New" }) { success } }'The CLI will do exactly what you ask, including deleting. For agent use:
- Read before writing — list with
--jsonand take IDs from the output rather than inventing or reusing stale ones. - There is no upsert: creating is not idempotent, so search for an existing item first.
- Destructive calls need
--force/--yes; preferarchiveoverdeletewhere the workspace's history matters. - Never run workspace-level changes (
team delete, credential changes) without a human in the loop. - On HTTP 429/5xx the CLI reports the status and exits non-zero; it does not retry — add backoff in the caller.
Every group and subcommand was exercised against a real workspace; the full pass/fail matrix, with repros, is in docs/CLI-AUDIT.md. The gaps worth knowing before you rely on something:
- branch-state issue resolution is not implemented, so
issue idand the optional[issueId]arguments need the identifier passed explicitly (passing IDs is the reliable pattern for agents anyway); gh-dependent commands (issue pull-request,team autolinks) need the GitHub CLI and a GitHub remote; they fail cleanly without them;- cycles require cycles to be enabled for the team, otherwise
cycle list/viewreport that cleanly; - some upstream wordings are reproduced verbatim, quirks included — for example the bulk
failure line reads
Failed to delet all N issues, because upstream derives its verb by stripping a trailing "ed" fromdeleted. Kept for output parity rather than silently improved.
The same binary also runs as a service: it accepts webhook deliveries and mirrors work between
the platforms behind them — issues, comments, fields, and placement on a project board, where a
card follows its issue's state onto the column the mapping names. It reads the same linear.toml:
[bridge]
bind = "127.0.0.1:8787"
[platform.forgejo]
type = "forgejo"
secret_env = "FORGEJO_WEBHOOK_SECRET"
[[mapping]]
source = "linear:VED"
sink = "forgejo:Vedaru/linear-cli-rs"
# Which project's mirror lives in which repository. Placement is configuration: a
# project with no entry is not mirrored, and neither is its issue - an issue that
# names no project at all falls back to the mapping's repository. A project's own
# links are never consulted.
[[mapping.project]]
project = "kuro"
scope = "Vedaru/kuro"linear config service # print the sections above, for a linear.toml you already have
LINEAR_WEBHOOK_SECRET=... FORGEJO_WEBHOOK_SECRET=... linear webhook serve
linear webhook serve --check # resolve the config, print it, do not bind
linear webhook replay 41 # run a stored delivery again, as the provider sent it
linear sync # or work on demand, with no service at all:
linear sync --apply # the plan, and then the plan carried out
linear sync status # what the store holds, and what it gave up on
linear sync link linear:VED#a-uuid forgejo:Vedaru/linear-cli-rs#7 # these two are the samelinear sync is the same engine driven by a command instead of by a delivery: it reads
both ends of every mapping, works out which side moved from the revision each link
recorded, and writes the difference. It is a dry run unless --apply is given - a
sweep can create, so the safe default is to look first, and the plan it prints is the
plan that runs.
linear sync status answers the other question a quiet setup raises - whether the queue
drained or filled, which look identical from outside because both answer a webhook with 202
and neither writes anything. It reads the same store the service writes, prints the queue and
the mappings, and lists the deliveries the queue gave up on with the error that stopped each
one. A store it cannot read is a failure, not a zero.
Running it as a service is a linear.toml, the four variables the config names, and a port: the
service binds 127.0.0.1:8787, reads the same file the CLI does, and answers sync on its own
store. Two things decide whether a deployment works, and neither is a file this repository can
carry: the webhook's route must not sit behind a login page (a 302 is followed by the platform,
read as 200, and filed as a delivery that never happened), and the Linear → forge direction needs
no route at all if the timer runs linear sync --apply instead.
linear config service prints those sections rather than asking questions: one file holds both
halves - the CLI's settings and the bridge's - which is the design rather than a coincidence,
because both read the same linear.toml with the same precedence. It never writes a secret: the
file names the environment variable and the environment holds the value, so a config is safe to
commit. What it cannot know from its arguments or the working directory it leaves as an obvious
placeholder rather than a guess.
linear sync link is the one statement the engine cannot make for itself. It pairs two entities
by hand, which matters for a pair that already existed on both sides before anyone started
mirroring: the engine pairs what it created (a marker in the body) or what it has written across
(a link it recorded), and two pre-existing entities have neither, so it can only treat them as
strangers and make a second copy. The link it records carries no revision, which is what makes
the sweep treat the pair as adopted - and adopt means the mapping's source side wins wherever
the two differ, because that is what source means. The command says which side that is rather
than leaving it to be discovered.
linear webhook replay <id> is for after the fix: the store keeps every delivery's raw body,
so a delivery can be run again against exactly what the provider sent rather than against what
it looked like to the code that failed. It goes through the same handler the service builds - a
replay that took a different path could reach a different conclusion, which would make it
useless for the one thing it is for - and records the outcome the way the worker would, so
sync status keeps telling the truth about a delivery that has been re-run.
A config for this shape needs no webhook secrets at all - just the credentials it writes
with - because a sweep never verifies a delivery. The same file is still refused by
webhook serve, which says which platform would need one.
POST /webhooks/<platform> verifies the signature against the raw body, stores the delivery
and answers 202; the work happens off the request path. GET /healthz reports store
liveness and delivery counts.
Platforms are configuration, not code. Each platform is described by a spec: where its
signature lives, where its event name lives, and how to address the entity in the payload
(JSON pointers for the id, scope, URL, actor, comment body, reference text, a review
request's merge state, and an optional
fan-out array for a push). The presets in crates/bridge/presets/
linear,forgejo/gitea/codeberg- are exactly that description, sotype = "forgejo"and an inline[platform.<name>.spec]run the same engine, and a platform nobody has written a preset for costs a configuration change rather than a pull request. See crates/bridge/README.md.
cargo build --release # the CLI alone (default: no `service` feature)
cargo test --workspace --locked # the CLI's own suite
cargo test --workspace --all-features --locked # + the bridge half's suites
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
cargo clippy -p linear --no-default-features --all-targets --locked -- -D warningsTwo shapes, and both are built and linted by CI. The default is the CLI; --features service
adds the linear-bridge half, and six integration suites are gated on that feature
(#![cfg(feature = "service")]) because they exercise commands that only exist there. That is
exactly why the gate passes --all-features: without it the service tests would be skipped and
the run would be green by absence. The reverse check - a --no-default-features clippy - keeps
the shape a laptop installs from drifting into warnings nobody sees.
The release profile is tuned for distribution, not for speed: lto, codegen-units = 1,
strip, opt-level = "z" and panic = "abort" take the CLI from ~16.3 MiB to ~5 MiB (-70%)
against cargo's default release profile for the same source (opt-level 3, no LTO, 16 codegen
units, unstripped, unwinding), measured with rustc 1.93.1. Every command is network-bound, so the
slower code this generates is invisible next to an API round trip. A release-mode test run would
need -Z panic-abort-tests.
The bridge adds roughly 2 MiB on top of that (~5 -> ~7.1 MiB): SQLite (bundled, so no system library is needed to run it), a small sync HTTP server, TOML parsing and HMAC. Memory is bounded by construction rather than by tuning - bodies are capped while reading, one delivery is in flight per worker, and the queue lives in the database rather than in memory - so a burst costs disk, not RSS.
CI gates every push on build, test, clippy -D warnings and a release build, then
publishes a rolling release. Output fidelity is enforced where it matters: linear schema
is checked byte-for-byte against goldens captured from the pre-change binary, so a
refactor that alters a single byte of SDL fails the suite.
One caveat when running the suite by hand: the CLI resolves configuration from the
environment, so a real ~/.config/linear/linear.toml (or an exported XDG_CONFIG_HOME)
leaks into the tests and some of them will fail on a machine that is configured. Run
env -u XDG_CONFIG_HOME HOME=$(mktemp -d) cargo test --locked to see the true result.
This is an independent port of schpet/linear-cli by Peter Schilling and contributors, and is not affiliated with Linear. It is distributed under the same ISC licence — see LICENSE.