@enrichlayer/el-linear
v1.28.0
Published
A pragmatic CLI for Linear.app — deterministic team/label/member resolution, structured issue validation, configurable term enforcement, and a GraphQL escape hatch.
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-profileEach 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~/.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.
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. el-linear can record each
fire/override decision to a local JSONL file so you can measure the gate's
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.
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, create, update} |
| Labels | labels {list, create, retire, restore} |
| Projects | projects {list, add-team, remove-team} |
| 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, 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>.
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, 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 |
| 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, 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.
One-line write confirmations: -q, --quiet
issues create|update 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…--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.
