pm-linear
v2026.8.16
Published
Linear.app issue sync for pm-cli
Maintainers
Readme
pm-linear
Linear.app issue sync extension for pm-cli.
Fetches issues from a Linear team and upserts them as pm items, keeping identifiers, priorities, estimates, statuses, labels, due dates, assignees, cycles, customer names, and project names in sync. Also provides a native import pipeline (pm linear import) and an exporter (pm linear export) that renders pm items as Linear issue-create payloads, and declares linear_id / linear_url provenance fields.
Capabilities
| Capability | Surface |
|------------|---------|
| commands | pm linear sync, pm linear validate |
| importers | pm linear import (+ legacy linear-sync importer) |
| importers (exporter) | pm linear export |
| schema | linear_id, linear_url item fields + command/importer/exporter flags |
| preflight | credential + reachability guard for commands that reach Linear |
Offline vs live.
--dry-runnever writes to Linear, but "no writes" and "no network" are not the same thing:
- Offline (no
LINEAR_API_KEYneeded):sync/import/linear-sync importwith--dry-runalone, andexport --dry-runwith or without--push. These build and print the exact GraphQL request/variables, or the would-beissueCreate/issueUpdatemutations, and make no network call.- Reaches Linear (live
LINEAR_API_KEYrequired): the real (non-dry-run)sync/import,export --push,validate --check-network, andsync/importwith--atomic --dry-run— the atomic preview fetches issues in order to build the plan it reports, so it needs credentials even though it writes nothing.The preflight gate follows exactly that split: it guards commands that reach Linear, not commands that mutate it.
Requirements
- pm-cli
>=2026.7.29 - Node.js
>=22.18.0 - A Linear API key with read access to the relevant teams (only for live
paths; every
--dry-runand offlinevalidateworks without one)
Setup
1. Get a Linear API key
- Go to Linear → Settings → API (
https://linear.app/settings/api) - Create a Personal API key (or a workspace key for shared use)
- Copy the key into your shell environment.
2. Set the environment variable
export LINEAR_API_KEY=<linear-api-key>Add this to your shell profile (~/.zshrc, ~/.bashrc, etc.) to persist it.
3. Install the extension
pm install npm:pm-linear --globalThe
npm:prefix is required. A barepm install pm-linearresolves only a local directory or a bundled alias, never the registry, and agithub.com/unbraind/pm-linearsource cannot work either — pm copies a GitHub source as-is without building it, and this repository does not commitdist/.
Or install per-project:
pm install npm:pm-linear --projectCommands
pm linear sync
Fetches issues from a Linear team and syncs them into pm items.
pm linear sync --team <slug> [options]Flags
| Flag | Type | Default | Description |
|------|------|---------|-------------|
| --team | string | (required) | Linear team slug (e.g. ENG, BACKEND) |
| --project | string | — | Filter by Linear project name |
| --state | string | — | Filter by state name (e.g. "In Progress") |
| --cycle | string | — | Filter by cycle name (e.g. "Sprint 7", "Q3"); case-insensitive substring match, issues with no cycle are excluded |
| --assignee | string | — | Filter by assignee email |
| --label | string | — | Filter by label name |
| --updated-since | string | — | Only issues updated at/after an ISO date or duration (e.g. 2026-01-01, -P7D) |
| --status-map | string | — | Override status mapping, e.g. "In Review=in_progress,Backlog=open" |
| --map | string | — | Remap Linear→pm fields, e.g. "identifier=ignore,priority=ignore" |
| --project-map | string | — | Tag items by Linear project name (additive). Bare flag tags with the verbatim name; "Mobile=mobile,Web=web" remaps |
| --limit | number | 100 | Max issues to fetch |
| --dry-run | boolean | false | Print the exact GraphQL request, no writes. Offline on its own; with --atomic it fetches issues to build the plan and needs LINEAR_API_KEY |
| --skip-preflight-network | boolean | false | Skip the preflight reachability probe (offline/CI) |
--map field remapping
--map linearField=pmField[,…] adjusts how Linear fields feed pm fields at
import time. The special value ignore suppresses a field:
| --map entry | Effect |
|---------------|--------|
| identifier=ignore | Drop the [ENG-1] prefix from the pm title |
| priority=ignore | Skip priority (pm item gets the default) |
| labels=ignore | Skip label→tag import (also drops the cycle:<name> tag) |
| status=ignore | Skip status mapping (pm item stays open) |
| assignee=ignore | Skip assignee import (pm item left unassigned) |
| estimate=ignore | Skip the estimate:<points> context tag |
| customer=ignore | Skip the customer:<name> context tag |
--project-map project tagging
--project-map is additive: imported items keep their label-derived tags and
gain one more tag for their Linear project. It mirrors --status-map.
| --project-map value | Effect |
|-----------------------|--------|
| (bare flag) / * / true | Passthrough — tag each item with its verbatim Linear project name |
| "Mobile App=mobile,Web=web" | Remap — tag matched projects with the mapped value; an unmatched project falls back to its own name |
| "Legacy=ignore" | Suppress tagging for that specific project |
# Tag every imported item with its Linear project name
pm linear sync --team ENG --project-map
# Map project names to short tags
pm linear sync --team ENG --project-map "Mobile App=mobile,Web=web"Examples
# Sync all open issues from the ENG team
pm linear sync --team ENG
# Only sync issues currently In Progress
pm linear sync --team ENG --state "In Progress" --cycle "Sprint 7"
# Restrict to a single Linear project
pm linear sync --team ENG --project "Q3 Roadmap"
# Custom status mapping (Linear state name -> pm status)
pm linear sync --team ENG --status-map "In Review=in_progress,Backlog=open"
# Sync up to 200 issues from the BACKEND team
pm linear sync --team BACKEND --limit 200
# Preview what would be synced (no writes)
pm linear sync --team ENG --dry-runpm linear import
Native import pipeline. Pulls issues from a Linear team (and optional project) via the
GraphQL API and creates pm items. Accepts the same --team, --project, --state, --cycle,
--assignee, --label, --updated-since, --status-map, --map, --project-map,
--limit, and --dry-run flags as pm linear sync.
pm linear import --team ENG
pm linear import --team ENG --project "Q3 Roadmap" --limit 50
pm linear import --team ENG --dry-runRequires LINEAR_API_KEY (or falls back to LINEAR_DEFAULT_TEAM for the team). When
the key is missing it exits non-zero with a structured error rather than crashing.
pm linear export
Renders pm items as Linear issue-create payloads. The exported payload is
symmetric with the importer: a pm item's priority, tags, deadline,
estimate:<n> tag, and cycle:<name> tag are carried into the Linear mutation as
priority (int), labelIds (resolved from tag names to the team's existing
labels at push time), dueDate (YYYY-MM-DD), estimate (int), and cycleId
(resolved from the cycle name to the team's cycle at push time) respectively —
alongside title, description, and the status→state mapping.
The importer encodes Linear's estimate and cycle as the namespaced tags
estimate:<points> and cycle:<name> (pm has no first-class field for either).
Export promotes those tags back to first-class Linear issue fields and does not
re-emit them as Linear labels, so the round-trip is lossless and clean.
- Default (no
--push): prints the JSON payload to stdout. Safe, read-only, no network. Items that already carry Linear provenance are flaggedalreadyInLinear: true. --push: creates/updates the issues in Linear. Only mutates Linear when both--pushis set andLINEAR_API_KEYis present, and requires--team <slug>to resolve the target team (and its labels/states). Items already linked to Linear are updated in place so the push is idempotent. Tags that don't match an existing team label are dropped rather than failing the push. A per-item create/update failure is isolated: it is logged, counted asskipped, and the batch continues rather than aborting the remaining items.
# Preview the payload (no network, no writes)
pm linear export
# Create the issues in Linear (requires LINEAR_API_KEY)
pm linear export --push --team ENG| Flag | Type | Default | Description |
|------|------|---------|-------------|
| --push | boolean | false | Create the issues in Linear (requires key + --team) |
| --team | string | — | Target Linear team slug (required with --push) |
| --map | string | — | Suppress export fields, e.g. "estimate=ignore,cycle=ignore" |
| --dry-run | boolean | false | Offline — print the would-be issueCreate/issueUpdate mutations + variables, no network |
Cycle resolution. The
cycle:<name>tag carries a cycle name; Linear's mutation input needs acycleId. On--push, the exporter resolves the name against the team's cycles (fetched alongside its labels/states) and setscycleIdwhen it matches (by cycle name or number, case-insensitively). A cycle name that doesn't resolve — offline, unknown, or a workspace that doesn't model it — is skipped (the rest of the issue still pushes) with a single stderr warning per push. Use--map cycle=ignoreto drop cycle export entirely, or--map estimate=ignoreto drop estimate.
# Preview the exact Linear mutations that a --push would send (no network)
pm linear export --dry-run --team ENGpm linear validate
Readiness diagnostics. Reports whether LINEAR_API_KEY and LINEAR_DEFAULT_TEAM
are configured — without leaking the key (only a short prefix + length are
shown) — and whether the workspace is ready for writes. Offline by default;
--check-network (opt-in) probes the Linear API to confirm the key is accepted.
pm linear validate # offline: config presence only
pm --json linear validate # structured report
pm linear validate --check-network # live: confirm the key is accepted (needs key + network)| Flag | Type | Default | Description |
|------|------|---------|-------------|
| --check-network | boolean | false | Probe the Linear API to confirm the key is accepted (needs network) |
Provenance fields
The extension declares two custom item fields (registerItemFields):
| Field | Type | Description |
|-------|------|-------------|
| linear_id | string (optional) | Linear issue UUID |
| linear_url | string (optional) | Canonical Linear issue URL |
Because pm create has no generic setter for extension-declared fields, imported items
encode provenance in their description behind a stable [linear] marker
([linear] linear_id=… linear_url=…). pm linear export reads this marker back so
already-linked items are not re-created on --push.
Programmatic importer
The extension registers a linear-sync importer for use in pm config or pipelines:
{
"importers": [
{
"name": "linear-sync",
"config": {
"team": "ENG",
"limit": 100
}
}
]
}Set LINEAR_DEFAULT_TEAM as a fallback if team is not specified in config:
export LINEAR_DEFAULT_TEAM=ENGField mapping
Priority
| Linear priority | Linear label | pm priority | |----------------|--------------|-------------| | 1 | Urgent | 1 (Urgent) | | 2 | High | 2 (High) | | 3 | Medium | 3 (Medium) | | 4 | Low | 4 (Low) | | 0 | No priority | 3 (Medium) |
Status
The importer maps every Linear issue to one of pm's four statuses —
open, in_progress, closed, blocked:
| Linear state type / name | pm status |
|--------------------------|-----------|
| type started (In Progress, In Review) | in_progress |
| type completed / cancelled | closed |
| name contains "in progress" / "in review" | in_progress |
| name contains "blocked" | blocked |
| name contains "done" / "completed" / "cancelled" | closed |
| everything else (Todo, Backlog, Triage, unstarted) | open |
A --status-map "Linear State=pm_status,…" overrides this heuristic
(matched case-insensitively on the Linear state name) and is inverted to
drive the export/push direction (pm status → Linear state name). The inverted
direction echoes the Linear state name with its original casing preserved
(Backlog, not backlog).
Item fields (import: Linear → pm)
pm create/pm update is the only setter available to a standalone extension,
so imported issues map onto pm's first-class fields (there are no
meta.linear_* fields):
| pm field | Linear source |
|----------|---------------|
| title | [ENG-123] Issue title (the [ENG-123] prefix can be dropped with --map identifier=ignore) |
| body | description |
| status | mapped from state.type / state.name (see above) |
| priority | mapped from priority |
| tags | label names |
| tags (cycle:<name>) | cycle.name — the issue's Linear cycle, encoded as a namespaced tag |
| tags (project) | project.name — only when --project-map is supplied (see above) |
| deadline | dueDate (if set) |
| assignee | assignee.email (or assignee.name when no email); suppress with --map assignee=ignore |
| description marker | [linear] linear_id=… linear_url=… provenance (see below) |
Cycle encoding. pm has no first-class cycle/sprint field reachable from a standalone extension's
pm create, so the Linear cycle is stored as acycle:<name>tag (additive, de-duplicated). Filter on it withpm list --tag "cycle:Sprint 7".
pm linear export reverses the core mapping so a round-trip is lossless for the
fields it carries:
| Linear input field | pm source |
|--------------------|-----------|
| title | title |
| description | body |
| stateName / stateId | mapped from status (via inverted --status-map) |
| priority | priority (pm 1–4 → Linear 1–4, else 0 "No priority") |
| labelIds | tags (resolved to existing team labels; unknowns dropped; estimate:/cycle: tags excluded) |
| dueDate | deadline (normalized to YYYY-MM-DD) |
| estimate | estimate:<n> tag (integer; suppress with --map estimate=ignore) |
| cycleId | cycle:<name> tag (name resolved to the team's cycle id at push time; skipped if unresolved; suppress with --map cycle=ignore) |
Export does not currently push assignee back to Linear (import-direction only).
Environment variables
| Variable | Required | Description |
|----------|----------|-------------|
| LINEAR_API_KEY | Yes | Linear personal or workspace API key |
| LINEAR_DEFAULT_TEAM | No | Default team slug for the importer (if not set in config) |
Development
# Install dev deps
npm install
# Build TypeScript → dist/
npm run build
# Watch mode
npm run devThe compiled output lands in dist/index.js, which is the entry point referenced by manifest.json.
License
MIT
Release Automation
This package is release-ready for GitHub, npm, and Bun-compatible installs. CI runs type checking, build, production dependency audit, package packing, Bun install verification, and pm-changelog validation. The daily release workflow publishes only when commits exist after the latest release tag and uses pm-changelog to generate CHANGELOG.md and GitHub release notes.
Multi-agent merge safety
This repo tracks its project management in .agents/pm/ and ships a committed .gitattributes
that maps those tracker artifacts to pm-cli's field-aware Git merge drivers, so concurrent-branch
tracker edits merge cleanly instead of hard-conflicting. The driver definitions live in per-clone
Git config; npm install / npm ci wires them automatically via the prepare script (a portable Node guard, scripts/prepare-merge-driver.mjs: it runs
pm merge install only when the pm CLI is on PATH, and no-ops cleanly otherwise so
production / --omit=dev installs are not broken; being Node-based it behaves identically
on POSIX shells and Windows cmd.exe). To (re)run manually: npm run merge:install.
After merging a branch that touched .agents/pm/, reconcile any residual history-hash drift with
pm merge reconcile (pm-cli ≥ 2026.7.22): preview with pm merge reconcile --dry-run, apply with
pm merge reconcile --message "post-merge reconcile", then confirm with pm validate, which scans the
whole tracker and flags remaining history drift across every affected item (pm merge reconcile
itself lists each affected stream in its output; pm history --verify <id> spot-checks one item). The field-aware driver already unions every author's
content, so reconcile only re-greens the hash chain (no data loss) — see the authoritative
pm-cli merge-safety guide. The
older blunt pm history-repair --all remains available as a lower-level primitive.
