@enrichlayer/el-linear
v1.44.1
Published
A pragmatic CLI for Linear.app — deterministic team/label/member resolution, structured issue validation, configurable term enforcement, and a GraphQL escape hatch.
Downloads
11,724
Readme
A pragmatic CLI for Linear.app — deterministic team / label / member resolution, structured issue validation, configurable term enforcement, and a GraphQL escape hatch for everything that isn't a first-class command.
Note on naming. This package was briefly published as
@enrichlayer/linctlwith binarylinctl, but reverted to@enrichlayer/el-linear(binaryel-linear) because of an npm name collision with dorkitude/linctl. See CHANGELOG.md for the migration recipe.
Install
Published on npm as @enrichlayer/el-linear.
pnpm add -g @enrichlayer/el-linear
# or
npm install -g @enrichlayer/el-linearRequires Node.js ≥ 22.
Quickstart
# Run the interactive setup wizard. Only the API token is required;
# every other step is skippable and revisitable later.
el-linear init
# Sanity-check
el-linear teams list
# Create your first issue
el-linear issues create "Investigate flaky deploy" \
--team ENG --assignee alice --project "Reliability" \
--description "..."el-linear init walks you through the API token, default team, member
aliases, and other defaults. Each step is also a stand-alone sub-command
(el-linear init token, el-linear init aliases --import users.csv, etc.) so
you can revisit individual sections later. Skip is the default at every
prompt — running the wizard twice with no input is a no-op.
If you'd rather skip the wizard entirely, the configuration schema is
fully documented in docs/configuration.md so
any LLM or script can write ~/.config/el-linear/config.json directly.
Output is JSON by default. Pipe through jq for ad-hoc queries, or use
the built-in --jq / --fields / --raw flags.
Why el-linear
The Linear API + SDK are excellent. el-linear adds the layer above them that every team ends up writing themselves:
| Concern | What el-linear gives you |
|---------|----------------------|
| Name resolution | Map team keys, member aliases, and label names to UUIDs from one config file. No fuzzy matching, no API roundtrips per call. |
| Issue hygiene | Required labels, required assignee, required project, type-label-to-verb conventions — all configurable. Warn or hard-fail. |
| Term enforcement | Catch misspellings of brand and project names in issue titles and descriptions ("EnrichLayer" → "Enrich Layer"). |
| Status defaults | "No project? → Triage. Has assignee+project? → Todo." Per-workspace, configurable. |
| Auto-link & relate | When you write EMW-258 in a description, el-linear wraps it as a markdown link and creates the corresponding sidebar relation. Prose like "blocked by EMW-258" infers the relation type. |
| Agent delegation | Use Linear's native delegate field for agent work while keeping the human assignee responsible. issues start moves work to the team's first started status. |
| GraphQL escape hatch | Anything not covered by built-in commands: el-linear graphql '{ viewer { id } }'. Schema introspection included. |
| Bundled Claude skill | The published tarball includes a claude-skills/linear-operations/ directory you can symlink into your project's .claude/skills/. |
Authentication
el-linear supports either OAuth or a personal Linear API token. OAuth is configured with:
el-linear init oauthBy default, that command walks you through registering your own Linear OAuth
app. Teams can make the flow a single browser authorization step by writing a
local, untracked app-defaults file at ~/.config/el-linear/team-oauth.json
or pointing EL_LINEAR_OAUTH_CONFIG at one:
{
"linearOAuth": {
"actor": "user",
"clientId": "your-linear-oauth-client-id",
"redirectPort": 8765,
"scopes": ["read", "write", "issues:create", "comments:create"],
"passwordManagerPath": "op://vault/item/client_id"
}
}passwordManagerPath is optional metadata for humans/scripts; el-linear does
not execute password-manager commands from it. Do not put a client_secret in
this shared file. The OAuth flow uses PKCE.
For agent/service-account OAuth, authorize the app actor:
el-linear init oauth --actor appApp actor tokens can request app:assignable and app:mentionable, but not
admin. The authorized app user ID is stored in oauth.json as viewerId.
At runtime, credentials are resolved in this order:
--api-token <token>flag.LINEAR_API_TOKENenvironment variable.- Active profile's OAuth state (
oauth.json) fromel-linear init oauth. - Active profile's
~/.config/el-linear/profiles/<name>/tokenfile (see Profiles below). ~/.config/el-linear/tokenfile (legacy single-profile, recommended for human use when only one workspace is needed).~/.linear_api_tokenfile (legacy, still honored).
el-linear never logs the token.
Profiles
Use profiles to switch between multiple Linear workspaces (e.g. day-job and side-project) with separate tokens + configs.
# Create a profile + run the init wizard scoped to it.
# After this finishes, <name> becomes the active profile.
el-linear profile add forage
# Switch the default at any time:
el-linear profile use day-job
el-linear profile current # → day-job
el-linear profile list # all profiles + which is active
# One-off override for a single command:
el-linear --profile forage issues list
EL_LINEAR_PROFILE=forage el-linear teams list
# Remove a profile (token + config gone; confirms first):
el-linear profile remove old-profileRecommended for multiple workspaces: pin the repo
If you work across several workspaces, pin each repo to the profile it belongs to instead of relying on profile use. A pin is scoped to the repo, so a write in that repo always targets the right workspace no matter what the machine-global default happens to be.
# In a repo, pin it to the profile it belongs to (defaults to the active one):
el-linear profile pin forage
# Show the resolved pin + where it came from:
el-linear profile pin --show
# Pin this exact origin owner/repo in your global user config instead:
el-linear profile pin work --global
# Remove the repo-local pin:
el-linear profile unpin
# Remove this repo's exact owner/repo entry from the global user config:
el-linear profile unpin --globalA repo-local pin is stored in .el-git.json at the repo root as { "linearProfile": "<name>" }. el-linear, el-git, and el-session read the same file and resolve the repo to the same workspace.
--global writes the exact origin owner/repo entry into ~/.config/el-git/linear-profiles.json; the resolver also honors manually configured owner/* wildcard entries. The repo-local file wins over the global table.
When a write command (issues create / comments create) runs under the bare machine-global default in a repo with no effective pin, el-linear prints a one-line, non-blocking stderr hint suggesting profile pin. Silence it with EL_LINEAR_NO_PIN_HINT=1 (or --quiet).
Fix or clean up a single member's aliases/handles without hand-editing
config.json — profile members operates directly on the active profile's
on-disk config (no Linear API round-trip):
el-linear profile members list # show configured members
el-linear profile members clear "linear" # remove a mistaken/stale entry
el-linear profile members set "Alice Anderson" --aliases ali,al,alexEach profile lives at ~/.config/el-linear/profiles/<name>/ and owns:
token— its Linear API token (mode 0600)config.json— its full el-linear config (defaultTeam, terms, etc.)
The active profile is selected by, in priority:
--profile <name>flag (per-invocation)EL_LINEAR_PROFILEenv var- Repo pin —
.el-git.jsonat the repo root, else theowner/repo/owner/*entry in~/.config/el-git/linear-profiles.json(see Recommended for multiple workspaces) ~/.config/el-linear/active-profile(one-line marker, written byprofile use)- Legacy single-file layout (
~/.config/el-linear/{token,config.json})
The legacy fallback means existing single-profile users see no behavior change — profiles are purely opt-in.
Automation and concurrent sessions: prefer --profile/$EL_LINEAR_PROFILE
over profile use. ~/.config/el-linear/active-profile is a single,
machine-global marker file — profile use <name> mutates it for every
el-linear invocation on the machine, including other terminals, scripts, and
agent sessions that don't set an explicit override. Two processes wanting
different workspaces at the same time will silently clobber each other. The
flag and env var are per-invocation and never touch the marker file, so they
compose safely with any number of concurrent sessions each pinned to their
own workspace. Reserve profile use for a human's own interactive default
switch; profile use/profile add print a loud warning when they change the
global marker so the blast radius is visible instead of silent. Better still
for a human who works across workspaces: profile pin each repo (above) —
the pin sits above the global marker in the precedence list, so the right
workspace is selected automatically per-repo without any per-command flag.
Migrating from v1.0–1.3
Versions 1.0–1.3 stored everything in the single-file layout
(~/.config/el-linear/{token,config.json}). 1.4 introduced named
profiles (~/.config/el-linear/profiles/<name>/{token,config.json}) and
the legacy single-file layout still works as a fallback.
Some upgrade paths leave the legacy config.json on disk (with all your
member aliases and brand rules) but no usable token, in which case every
command fails with a generic No API token found error. 1.5 detects this
state and prints a one-line stderr hint before the auth error fires:
el-linear: legacy config detected at ~/.config/el-linear/config.json
but no token. Migrate with:
el-linear profile migrate-legacy [--name <profile>]
Or suppress this hint with EL_LINEAR_SKIP_MIGRATION_HINT=1.Run the suggested command to copy your legacy config into a named profile in one step:
# Default target name is "default"; pass --name to choose another.
el-linear profile migrate-legacy
# CI / scripted: read the token from a file, skip all prompts.
el-linear profile migrate-legacy \
--name work \
--token-from /path/to/token.txt \
--yes
# Pick the token up from an env var instead.
EL_LINEAR_TOKEN=lin_api_xxx el-linear profile migrate-legacy --yesThe migration is idempotent — re-running with the same inputs is a no-op.
If the destination profile already has a different config.json or token,
the command refuses unless you pass --force (and confirms before
overwriting unless you also pass --yes).
The legacy ~/.config/el-linear/config.json is never deleted — you keep
a rollback path. Once you've verified the new profile works, you can remove
the legacy file by hand at your leisure.
If you've decided to stay on the legacy single-file layout intentionally, suppress the hint with:
export EL_LINEAR_SKIP_MIGRATION_HINT=1Configuration
el-linear reads ~/.config/el-linear/config.json on startup. All keys are
optional; defaults work for casual use.
{
"defaultTeam": "ENG",
"defaultLabels": ["claude"],
"members": {
"aliases": { "alice": "Alice Anderson" },
"uuids": { "Alice Anderson": "<uuid-from-linear>" }
},
"teams": { "ENG": "<uuid-from-linear>" },
"labels": {
"workspace": { "claude": "<uuid-from-linear>" },
"teams": { "ENG": { "feature": "<uuid-from-linear>" } }
},
"statusDefaults": {
"noProject": "Triage",
"withAssigneeAndProject": "Todo"
},
"terms": [
{ "canonical": "Enrich Layer", "reject": ["EnrichLayer", "enrichlayer"] }
]
}A full reference with every key documented lives in config.example.json.
UUIDs come from the Linear UI (URL bars, settings pages) or via el-linear
itself: el-linear teams list --raw | jq '.[] | {key, id}', etc.
Gate telemetry (optional)
issues create has a duplicate-detection gate (on by default), an opt-in
SOP-label parent gate,
and an opt-in
goal-completion gate
(requires a falsifiable "Done when" / acceptance-criteria section), plus an
opt-in
intake-decision gate
that requires need, value, ownership, and placement to be decided before issue
creation.
el-linear can record each gate's fire/override decision to a local JSONL file so
you can measure its override-rate and tell whether it's too aggressive. It is
off by default and writes nothing unless you opt in (e.g.
export EL_TELEMETRY_DIR=<path>); there is no server or database, and
EL_TELEMETRY_DISABLED=1 forces it off. The active ledger rotates to one .old
backup before append when it exceeds 2 MiB. Full opt-in rules, the event schema,
and a jq reader are in docs/telemetry.md.
Networking (IPv4 preference)
el-linear talks only to api.linear.app (Cloudflare, dual-stack). On a network
whose IPv6 route is broken or blackholed, Node's defaults (DNS result order
verbatim, often IPv6-first, plus Happy Eyeballs) can make every call stall
until it times out — surfacing as GraphQL request failed: fetch failed. To
avoid that, el-linear prefers IPv4 by default (ipv4first DNS ordering with
autoSelectFamily disabled).
If you're on a pure IPv6-only network (no IPv4 route at all), set
EL_LINEAR_NETWORK_VERBATIM=1 to restore Node's native verbatim /
Happy-Eyeballs behavior.
Workspace URL key
refs wrap and the auto-link paths build canonical issue URLs like
https://linear.app/<workspaceUrlKey>/issue/<id>/. By default the key is
fetched once per CLI invocation via viewer.organization.urlKey. To skip the
network call (offline use, perf, --no-validate), provide it via any of:
--workspace-url-key <key>flag onrefs wrap(per-invocation, highest priority)EL_LINEAR_WORKSPACE_URL_KEYenv varworkspaceUrlKeyfield inconfig.json
When any of these is set, no GraphQL request is made.
Shared team config
Teams can check a shared config file into a repository (e.g. a Tools repo) and have every member load it automatically. The team file holds things that are the same for everyone — member aliases, label maps, term rules, validation settings — while personal config holds individual preferences and tokens.
Merge order (lowest → highest priority):
- Built-in defaults
- Team config file
- Personal
~/.config/el-linear/config.json(or profile config)
Personal config wins on scalar conflicts. Arrays (terms, defaultLabels) are
concatenated — personal entries are appended to team entries, so you extend
the team rules rather than replace them.
Set the team config path with the config team subcommand — it
resolves relative / ~/... paths, validates the file before writing, and
performs an atomic file-locked update:
el-linear config team set-path /path/to/tools-repo/.el-linear/config.json
el-linear config team show # confirm path + the keys it contributes
el-linear config team clear # remove the pointerThe command writes teamConfigPath into your personal config.json, so
the setting persists across shells. To override the persistent setting
per-invocation, use the EL_LINEAR_TEAM_CONFIG env var (highest
priority):
EL_LINEAR_TEAM_CONFIG=/path/to/tools-repo/.el-linear/config.json el-linear issues create ...If you prefer to edit the personal config by hand, the field looks like:
{ "teamConfigPath": "/path/to/tools-repo/.el-linear/config.json" }— but the CLI path is preferred (it catches missing-file and invalid-JSON mistakes before they silently fall back to no team layer).
Auto-discovery from an onboarding marker. As a third fallback, when
neither the env var nor teamConfigPath is set, el-linear reads
~/.config/el-tools-root (written by EL onboarding's link-project.sh)
and uses <root>/config/el-linear.shared.json if it exists. This means
developers who've already run onboarding get the team layer for free —
no extra set-path step. The marker is the opt-in consent signal; users
without it (the OSS audience) see no behavior change. Resolution order
in full:
EL_LINEAR_TEAM_CONFIGenv varteamConfigPathin personalconfig.json~/.config/el-tools-rootmarker →<root>/config/el-linear.shared.json
el-linear config team show reports which of these resolved (source
field: env / personal / marker / null).
The team config file is a standard config.json fragment — any ElLinearConfig
field is valid except teamConfigPath itself. Keep it free of tokens and personal
preferences; those stay in personal config. Example team file:
{
"members": {
"aliases": { "alice": "Alice Anderson", "bob": "Bob Barnes" },
"uuids": { "Alice Anderson": "<uuid>", "Bob Barnes": "<uuid>" }
},
"teams": { "ENG": "<uuid>", "OPS": "<uuid>" },
"labels": { "workspace": { "claude": "<uuid>" } },
"terms": [
{ "canonical": "Enrich Layer", "reject": ["EnrichLayer", "enrichlayer"] }
],
"validation": { "enabled": true }
}Run el-linear config show to see the resolved config and confirm which team
config path is active (teamConfig field in the output).
Migrating a personal-heavy config
Before the team-config split, members typically carried full local copies of
members / teams / labels / statusDefaults / teamAliases — and often
a deprecated brand: { name, reject } key. Once a team config is in place
those local copies just silently shadow the shared values (and silently
diverge when they drift). To clean it up safely:
el-linear config migrate-from-personal # dry-run — prints the plan per file
el-linear config migrate-from-personal --apply # write the slimmed files (with .bak-<ts> backups)Targets the global personal config + every named profile's config.json. For
each top-level shadowable key, the slim drops it only when the local copy
is a strict subset of team (zero divergence, zero non-trivial personal-only
entries); otherwise the key is left untouched and the divergence is reported
in the plan so a human resolves it. The deprecated brand key is dropped
when content-identical to a team terms[] entry, or converted to a personal
terms[] entry when it differs (with a warning).
Term enforcement (with brand-promotion examples)
The terms rules let you keep a list of canonical names and the misspellings
to reject. el-linear warns (or in --strict mode, throws) when an issue title
or description contains a rejected form.
{
"terms": [
{ "canonical": "Enrich Layer", "reject": ["EnrichLayer", "enrichlayer", "Enrichlayer"] },
{ "canonical": "Linear", "reject": ["linear.app", "Linear App"] },
{ "canonical": "GitHub", "reject": ["Github", "GitHUB"] }
]
}$ el-linear issues create "Add EnrichLayer auth flow" --team ENG --description "..." --strict
Term enforcement failed:
- Found "EnrichLayer" — use "Enrich Layer" instead (1 occurrence)URLs and file paths are exempt — enrichlayer.com and path/to/enrichlayer
are allowed even though enrichlayer is rejected.
If you don't define any rules, term enforcement is a no-op.
Native Agent Delegation
Linear now has a native issue delegate field for agent work. Use assignee
for the accountable human and delegate for the agent app user doing the
implementation.
el-linear issues create "Migrate auth middleware to new session store" \
--team ENG --assignee alice --delegate claude --project "Auth Refactor" \
--description "..."
el-linear issues start ENG-123Agents find delegated work with el-linear issues list --delegate claude.
--claude still applies the legacy configured label, but native --delegate
is the preferred contract.
Commands at a glance
el-linear usage # full reference for all commands
el-linear <command> --help # detailed help for one command| Group | Common commands |
|-------|-----------------|
| Issues | issues {list, search, create, read, update, start, delete, history, related, link-references} |
| Comments | comments {list, read, create, update, delete} |
| Labels | labels {list, create, retire, restore} |
| Projects | projects {list, add-team, remove-team} |
| Project updates | project-updates {create, list, read} (post a status update to a project's Updates feed) |
| Cycles | cycles {list, read} |
| Documents | documents {list, read, create, update, delete} |
| Releases | releases {list, read, create, pipelines} |
| Files | embeds {upload, download}, attachments {list, create, delete} |
| Search | search <query> (semantic, cross-resource) |
| Refs | refs wrap (rewrite issue identifiers in arbitrary text as links) |
| Escape hatch | graphql [query] (with --introspect) |
| Config | config show, users {list, read}, teams list, templates list |
All list subcommands support -l, --limit <n>. All commands accept the
top-level filters: --format <json|summary>, --raw, --jq <expr>, --fields <list>.
issues list also accepts --all (equivalent to --limit 0) to fetch every
matching issue. It paginates the full set in safe chunks under the hood, so
enumerating a large team no longer trips Linear's GraphQL complexity ceiling
(Query too complex) the way a single big --limit used to — no manual
per-status / per-priority bucketing needed:
el-linear issues list --team DEV --all --format csv --fields identifier # the whole open DEV backlog, one commandissues search intentionally has no --all: Linear's full-text search is a
relevance-ranked candidate search whose results are filtered client-side, not
an exhaustive enumeration. Its --limit reads at most 200 ranked candidates to
keep the rich issue query below Linear's complexity ceiling. Use issues list
with structured filters and --all when you need every matching issue.
Open by default — issues list and issues search skip terminal states
el-linear issues list and el-linear issues search exclude issues in
terminal workflow states (Done / Canceled) by default so triage and
survey runs return the open set without piping through grep. The implicit
filter is surfaced in _warnings on every invocation, so scripts notice it
deterministically rather than silently. Three ways to opt back in:
el-linear issues list --include-closed # everything, including Done/Canceled
el-linear issues search "auth" --status "Done" # explicit --status wins
el-linear issues list --status "Todo,In Progress" # any explicit status disables the implicit filter--include-closed and explicit --status both bypass the implicit filter
(explicit choice always wins). The change is per-command and only affects
list-shaped reads — single-issue issues read DEV-123 is unaffected.
Output formats
Every command accepts --format <kind> at the root:
--format json(default) — emits the full structured envelope. Stable shape across releases. Composes with--jq,--fields, and--raw.--format summary— emits a fixed human-readable rendering. Use this whenever you'd otherwise pipe throughjq,head,python -c, or similar shell tools to extract a few fields. Stable field set per resource (identifier, title, state, assignee, project, labels, URL for issues; analogous fields for projects, comments, cycles, milestones, teams, labels, users, documents, templates, attachments, releases, and cross-resource search results).
Working with an LLM / Claude Code? Default every read/list call to
--format summaryunless you specifically need the JSON envelope. A 12-line summary table is dramatically cheaper in tokens than a 500-line JSON dump and contains the same information humans actually use. The bundledclaude-skills/linear-operations/SKILL.mddocuments this rule.
el-linear issues read DEV-123 --format summary
# DEV-123 Fix login flicker on Safari 17
# State: In Progress
# Assignee: Alice
# Project: Auth Refactor
# Labels: Feature, tool
# URL: https://linear.app/acme/issue/DEV-123/...
#
# Login button briefly disappears when the form first loads.
# Repro on Safari 17 / iOS 17. Chrome / Firefox unaffected.
# ... (truncated; --format json for full body)
el-linear issues search "auth" --format summary
# ID TITLE STATE ASSIGNEE
# ---------------------------------------------------------------------------------------
# DEV-100 Migrate auth middleware to new session store In Progress Alice
# DEV-104 Auth callback returns 502 under load Todo Bob
#
# 2 issues
el-linear projects list --format summary
# NAME STATE PROGRESS LEAD
# -------------------------------------------------------
# Auth Refactor started 65% Alice
# Pricing v2 backlog 0% —
#
# 2 projects
el-linear templates list --format summary
# NAME TYPE TEAM ID
# ------------------------------------------------------------------
# Bug report issue PYT cf45b82e-0c71-4d24-be70-d4ecf915
# Tech Planning document — d4bcb82e-5ee4-49d3-b057-40f4a2e2
#
# 2 templatesExisting issues list, issues search, and projects list commands
continue to accept their per-command formats too: table, md,
markdown, csv — those go to the per-command rendering path. The
global summary value works on every read/list command.
--format summary does not compose with --jq (jq is JSON-only). Use
--raw together with --format summary to render a list envelope as a
bare item-list rather than an envelope.
--fields on --format summary — project additional columns
--fields on a summary render is a projection request, not a
JSON-shape filter (DEV-4750). For list outputs it sets the column set;
for single-resource outputs it filters the labelled field block beneath
the headline. Order is preserved: --fields project,identifier,title
renders PROJECT ID TITLE.
Currently wired through:
| Resource | Defaults | Extras you can request |
|----------|----------|------------------------|
| issues list | id, title, state, assignee | project, cycle, milestone, labels, url, priority, estimate, createdAt, updatedAt, completedAt, team |
| projects list | name, state, progress, lead | teams, target, url, updatedAt |
| issues read (single) | state, assignee, project, cycle, milestone, labels, url | priority, estimate, created, updated, completed |
| projects read (single) | state, lead, teams, target, progress, url | (filter only — no extras) |
identifier, id, title and name are headline-only on single-resource summaries and are filtered out of the labelled block (they remain on the title line above). status is accepted as a synonym for state, owner for assignee, targetDate for target.
# issues list with project column (the gap that motivated DEV-4750):
el-linear issues list --status "In Progress" --format summary \
--fields identifier,title,status,assignee,project
# ID TITLE STATE ASSIGNEE PROJECT
# -----------------------------------------------------------------------------
# DEV-1 Migrate auth middleware In Progress Alice Auth Refactor
# ...
# projects list with teams column:
el-linear projects list --format summary --fields name,state,progress,lead,teams
# NAME STATE PROGRESS LEAD TEAMS
# ---------------------------------------------------------
# Auth Refactor started 65% Alice DEV, FE
# ...Unrecognized field names are reported as a _warnings: line appended after the summary block (fields_unprojectable: --format summary on issues list does not project foo, bar; ...) — same signal scripts get on the JSON path. Resources whose summary formatter doesn't yet wire --fields (cycles, milestones, project updates, comments, teams, labels, users, documents, templates, attachments, releases, search results) emit the same warning and render their default summary.
Windowed metadata (WindowedMeta)
When a command returns less than its complete result set — because it
windowed by time, paginated, filtered, or hit a --limit — it should make
that visible in the envelope's meta rather than leaving the consumer to
guess. The shared output package (@enrichlayer/el-linear/output) exports a
canonical WindowedMeta type for exactly these fields, so every CLI built on
it uses one set of names instead of ad-hoc _window / _total / truncated
keys:
| Field | Populate when… |
| ---------------- | --------------------------------------------------------------------- |
| _window | a time/scope window was applied — "30d", "since 2026-06-01". |
| _limit_applied | a cap is in effect — the caller's value, or the default when omitted. |
| _query | a search / filter expression produced data. |
| _total | the total matching rows before windowing / limiting / filtering. |
| _fetched | rows in this response (equals meta.count for list envelopes). |
| truncated | _fetched hit _limit_applied and more rows exist beyond this page. |
| availability | {status: "complete" \| "partial" \| "degraded", detail?} — emit degraded when a sub-source failed, never an empty result that reads as "no hits". |
All fields are optional; a command populates only the ones that apply. The
meta object still admits CLI-specific counters (_total_hits,
_source_users_total, …) alongside these, but prefer the generic field where
one fits so cross-CLI tooling and skills can read a single shape. Skill output
templates that show counts MUST consume _total / truncated from meta
rather than counting returned rows.
This convention comes from the output-transparency audit's "Standard
Convention" section (docs/output-transparency-audit-report.md in the
vertical-int/tools repo, DEV-3810); WindowedMeta is the shared type that
audit recommends promoting into the envelope (DEV-4668).
import type { WindowedMeta } from "@enrichlayer/el-linear/output";
// A list command echoing what it windowed and whether it clipped:
outputList(rows, {
_window: "30d",
_limit_applied: 100,
_total: 247,
truncated: rows.length === 100,
} satisfies WindowedMeta);Extract a single description section: --field
issues read --field <name> extracts one named section from an issue's
markdown description and prints just that section's text — no JSON
envelope. Matches ##/### headers and bold pseudo-headers
(**Done when**) case-insensitively. Returns exit code 1 with a stderr
message when the section is missing.
el-linear issues read DEV-123 --field "Done when"
# - Thing A
# - Thing B
el-linear read ADM-652 --field "Out of scope"
# Stuff we won't do.Single-issue only — pair it with --jq on full JSON for batch
extraction across many issues.
Print the whole description as raw text: --body
issues read --body prints the issue's entire description as raw
markdown — real newlines, no JSON envelope — instead of one named
section. Single-issue only; exits 1 with a stderr hint when the issue has
no description. This is the canonical replacement for
... --format json | python3 -c "...['description']".
el-linear issues read DEV-123 --body
# ## Why we need this
# ...full description body...Mutually exclusive with --field / --sections / --with.
Read full comment bodies without JSON parsing
comments read <comment-id> reads one comment by full UUID, comment-<hash>
token, or a Linear URL containing #comment-<hash>. Use --body for the raw
markdown body with real newlines and no JSON envelope. comments list --body
prints every complete body on an issue as plain text blocks, and
comments list --format summary --no-truncate keeps the summary shape while
showing full bodies.
el-linear comments list DEV-123 --format summary
# comment c5d15b28-... Alice 2026-07-01T00:00:00.000Z
# Short preview...
el-linear comments read comment-c5d15b28 --body
# Full comment body...
el-linear comments list DEV-123 --body
# comment c5d15b28-...
#
# Full comment body...Both --body surfaces treat "no text to print" as a failure rather than an
empty success, so a caller never has to guess whether silence meant "nothing is
there" or "the fetch silently dropped it": comments read --body exits 1 when
the comment has no body, and comments list --body exits 1 with
el-linear: DEV-123 has no comments (or … has N comments, none with a body)
and writes nothing to stdout. The structured surfaces need no such signal and
stay exit 0 — --format summary prints (no results) and the JSON envelope
carries meta.count.
One-line write confirmations: -q, --quiet
issues create|update, issues relate, and comments create|update accept
-q, --quiet, printing a single confirmation line instead of the full JSON
envelope so you don't have to grep the result:
el-linear issues update DEV-123 --status "In Review" --quiet
# DEV-123 In Review https://linear.app/acme/issue/DEV-123/...
el-linear comments create DEV-123 --body "..." --quiet
# comment 6f1c…
el-linear issues relate DEV-123 --related-to "DEV-456,DEV-789" --quiet
# DEV-123 related DEV-456,DEV-789 (2)For issues relate the line is source-oriented — relations are grouped by
type from the source issue's perspective, so a --blocked-by reads back as
blockedBy. --format summary renders the same source-oriented view as a
TYPE / TARGET / TITLE table (TARGET is the peer issue; the source isn't
repeated as a column):
el-linear issues relate DEV-1 --blocked-by DEV-9 --format summary
# TYPE TARGET TITLE
# ------------------------------------
# blockedBy DEV-9 Add IP ban system
#
# 1 relation--quiet overrides --format and is independent of --fields / --jq.
Render an issue's tree: issues tree
issues tree <ID> walks the parent → children graph for an issue in a
single GraphQL round-trip and returns either a nested JSON envelope
(default) or an ASCII tree (--format summary).
el-linear issues tree DEV-100 --format summary
# DEV-100 Migrate auth middleware
# ├── DEV-101 Write design doc
# │ ├── DEV-104 Survey existing auth flows [Done]
# │ └── DEV-105 Draft RFC
# ├── DEV-102 Build new session store (@Alice)
# └── DEV-103 Cutover planDepth defaults to 3 (max 5 — Linear has no native depth-N recursion,
so each level adds a children { nodes { ... } } block to the generated
query). Terminal-state branches (Done / Canceled) are kept by
default because the tree's value is structural; pass
--no-include-closed to prune them.
Extract several sections in one call: --sections
When you want multiple sections (e.g. Done when, Out of scope, and
Steps), don't issue N separate --field calls — pass a comma-separated
list to --sections instead:
el-linear issues read DEV-123 --sections "Done when,Out of scope"
# {
# "identifier": "DEV-123",
# "sections": {
# "Done when": "...",
# "Out of scope": "..."
# }
# }Returns a JSON envelope { identifier, sections: { name → text|null } }.
Missing sections map to null and surface in _warnings so scripts can
detect them deterministically. Single-issue only, mutually exclusive
with --field. (Named --sections rather than the seemingly-obvious
--fields because --fields is already taken at the program level for
output-key filtering — el-linear is the namespace owner.)
Opt-in includes: --with
issues read --with <names> adds extra blocks of related data to the
JSON envelope. Comma-separated; unknown values are rejected with the
candidate list.
Currently supported:
| Include | What it adds |
|---------|--------------|
| relations | A top-level relations array (outgoing + incoming cross-issue links), built from the same data as issues related. |
# Issue + its sidebar relations in one call:
el-linear issues read DEV-123 --with relations
# Across multiple issues — relations fetched per issue in parallel:
el-linear read DEV-1 DEV-2 --with relations | jq '.data[].relations'--with is JSON-only — it composes with --jq / --fields / --raw,
and is mutually exclusive with --field (which prints raw section text,
no envelope).
Wrapping Linear references in arbitrary text
el-linear refs wrap takes plain text on stdin (or via --file) and rewrites
every recognized Linear issue identifier (e.g. DEV-123, LIN-1) as a real
link. By default it validates each candidate against the workspace — strings
that match the [A-Z]+-\d+ shape but aren't real issues (e.g. ISO-1424)
are left untouched.
# stdin → stdout, markdown output (default)
echo "see DEV-100 and ISO-1424" | el-linear refs wrap
# → see [DEV-100](https://linear.app/acme/issue/DEV-100/) and ISO-1424
# read from a file
el-linear refs wrap --file notes.md > notes.linked.md
# Slack mrkdwn output: <url|label>
el-linear refs wrap --target slack < release-notes.md
# offline regex-only fallback — wraps every match, no API calls,
# may produce broken links for IDs that don't exist in the workspace
el-linear refs wrap --no-validate < notes.mdWrapping is idempotent — running it again on already-wrapped output is a no-op. Refs are also skipped inside fenced code blocks, inline backticks, existing markdown or Slack links, angle-bracket autolinks, and bare URLs, so it's safe to pipe documents that already contain a mix of formatted links and bare identifiers.
Relation-candidate confirmation prompt
When el-linear search or el-linear issues search returns results that
carry issue identifiers (the "I just ran a duplicate/related check" shape),
the JSON envelope embeds a structured _warnings line:
{
"data": [{ "identifier": "DEV-2134", "title": "…" }, /* … */],
"meta": { "count": 3, "query": "auth flicker" },
"_warnings": [
"relation_candidates: Found 3 candidate related issues (DEV-2134, FIN-77, ALL-672). To link them as related: reply with the IDs you want linked (e.g. \"link DEV-2134 and FIN-77\"). To skip linking: reply \"no links\". (Agent-inferred IDs are blocked by auto-mode; user-named IDs pass — DEV-4494.)"
]
}Why it exists: Claude Code's auto-mode permission classifier blocks
el-linear issues relate <source> --related-to "<ids>" calls when the IDs
were inferred by the agent from its own search rather than typed by the
user — because each listed peer is a write target. The prompt nudges the
caller (typically an agent driving the linear-operations skill) to surface
the candidates verbatim and have the user name which IDs to link; any
subsequent issues relate call then carries user-specified IDs and the
guard passes naturally. The fix is not to weaken the guard — see
DEV-4494 for the original
incident (PYT-213 triage, 2026-06-04).
The relation_candidates: prefix is a stable token so a skill or harness
can grep for it without parsing free-form prose, matching the existing
results_truncated: convention. Non-issue rows (projects, documents,
initiatives) are ignored; the warning is suppressed when no result carries
an identifier.
Use with Claude Code
el-linear ships a Claude Code skill at claude-skills/linear-operations/SKILL.md.
After installing the package, symlink it into your project:
PKG=$(npm root -g)/@enrichlayer/el-linear
ln -s "$PKG/claude-skills/linear-operations" .claude/skills/linear-operationsThe skill teaches Claude Code el-linear's syntax, the duplicate/related issue
check, the label taxonomy, the auto-link flow, and the --claude delegation
pattern.
Development
git clone https://github.com/enrichlayer/el-linear.git
cd el-linear
pnpm install
pnpm test # vitest
pnpm exec tsc --noEmit # typecheck
pnpm exec biome check src/
pnpm run build
node dist/main.js --versionSee CONTRIBUTING.md for the full guide.
Built by
Enrich Layer — data enrichment APIs.
License
MIT.
