npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/linctl with binary linctl, but reverted to @enrichlayer/el-linear (binary el-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-linear

Requires 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 oauth

By 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 app

App 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:

  1. --api-token <token> flag.
  2. LINEAR_API_TOKEN environment variable.
  3. Active profile's OAuth state (oauth.json) from el-linear init oauth.
  4. Active profile's ~/.config/el-linear/profiles/<name>/token file (see Profiles below).
  5. ~/.config/el-linear/token file (legacy single-profile, recommended for human use when only one workspace is needed).
  6. ~/.linear_api_token file (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-profile

Each 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:

  1. --profile <name> flag (per-invocation)
  2. EL_LINEAR_PROFILE env var
  3. ~/.config/el-linear/active-profile (one-line marker, written by profile use)
  4. 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 --yes

The 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=1

Configuration

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:

  1. --workspace-url-key <key> flag on refs wrap (per-invocation, highest priority)
  2. EL_LINEAR_WORKSPACE_URL_KEY env var
  3. workspaceUrlKey field in config.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):

  1. Built-in defaults
  2. Team config file
  3. 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 pointer

The 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:

  1. EL_LINEAR_TEAM_CONFIG env var
  2. teamConfigPath in personal config.json
  3. ~/.config/el-tools-root marker → <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-123

Agents 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 through jq, 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 summary unless 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 bundled claude-skills/linear-operations/SKILL.md documents 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 templates

Existing 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 plan

Depth 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.md

Wrapping 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-operations

The 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 --version

See CONTRIBUTING.md for the full guide.

Built by

Enrich Layer — data enrichment APIs.

License

MIT.