The CLI could only comment on issues. Documents, projects, and initiatives
all take comments in Linear (GitHub issue #230 asked for document comments),
so this adds `document comment list|add`, `project comment list|add`, and
`initiative comment list|add`, mirroring `issue comment` with the same
--body / --body-file conventions and the shared Markdown hint. Every comment
`add`, including the issue one, now takes `--reply-to <commentId>`; -p and
--parent stay as aliases so existing scripts keep working.
The entity-agnostic parts live in src/utils/comments.ts: a typed comment
target union feeding one AddComment mutation, strict body handling (an
explicitly blank --body or body file is an error, not a fall-through to the
prompt), a CommentListFields fragment so the four --json shapes cannot drift,
a page collector, and the threaded renderer. Comment lists now fetch every
page instead of stopping silently at 50, and their JSON nodes, plus the
comments in `issue view --json`, carry quotedText (the passage an inline
comment quotes) alongside parent.id. Replies whose root is missing from the
result are rendered as replies naming their parent instead of being dropped.
API findings, verified live against scratch objects on 2026-09-04:
- A reply must carry its entity id as well as parentId; parentId alone is
rejected, so every add sends both.
- Project comments attach via projectId, but the schema's Project.comments
connection does not return them; only the root `comments` query filtered
by project does. Initiative has no comments connection at all. Both list
commands therefore use the root query and select the entity in the same
operation so an unknown UUID is reported as not found rather than as an
empty list.
- Document comments attach via the document's documentContentId, which is
looked up first; `document(id:)` accepts a UUID or slug directly.
- Linear rejects a reply to a reply and a cross-entity parent with a
user-presentable message, which is surfaced verbatim.
Linear's not-found error carries the user-presentable message "Could not
find referenced <Type>.", which isNotFoundError never matched, so the
existing not-found branches were dead. Matching that wording exposed a
`document view` catch block that re-threw instead of reporting; it now goes
through handleError like everything else.
Claude-Session: https://claude.ai/code/session_01A9qEGri4p2HZMQSuYsBmub
Every command that takes a team now accepts its key, name, or UUID through
one shared resolver (findTeam / resolveTeam / resolveTeams), replacing the
key-only getTeamIdByKey and the ad-hoc uppercasing spread across commands.
One aliased ResolveTeam operation looks up key, name, and (for UUID-shaped
input) id in a single round trip; precedence is key, then id, then name,
applied client-side so a reference that equals one team's key and another
team's name always means the key. Keys stay the canonical downstream form:
filters that matched on team.key still do, with the server's uppercase key,
and callers that need a UUID take it from the same resolved object. An
unknown team now errors with the list of valid keys instead of an empty
result or a raw "Entity not found" from the API.
Only explicit input goes through the resolver. The configured default team
is already a normalized key, and resolving it would add a round trip to
every default-team invocation of the most-used commands for no gain. In
issue create, the interactive substring picker survives only for that
default; an explicit --team that matches nothing errors like everywhere
else.
issue query --state and issue mine --state take a workflow state name or
ID as well as the six type tokens. Names and IDs are resolved within the
queried scope (the team, the teams, or the whole workspace under
--all-teams, where a name matches every team's same-named state), so a
state from another team errors instead of silently matching nothing, and
the error lists the scope's states. Type-only input still sends the same
{ type: { in } } filter with no extra request; a mix of types and names
becomes an or-filter.
The MCP server already describes these parameters as "key, name, or ID"
and "type, name, or ID"; this brings the CLI to parity so an agent does
not need a preliminary team list to translate a name into a key.
Claude-Session: https://claude.ai/code/session_01A9qEGri4p2HZMQSuYsBmub
The skill learned how Linear mentions actually work; the CLI itself did not.
An agent driving `linear` without it still writes `@peter` in a comment body
and posts text that notifies nobody.
Put the trap-avoiding rule inline on the ten commands that take a Markdown
body, because a pointer alone only helps an agent that already suspects it has
a gap. The full reference lives in a new `linear markdown` command, used both
as its description and as what it prints: printing keeps the `+++` block
unindented and copyable, and the description is what the skill-docs generator
captures from `--help`. The `--json` listings say what the `url` field is for,
with the team-first safeguard kept on the workspace-wide one.
Help screens grow by one root row and about five lines per authoring command;
parent command tables are unaffected, since cliffy renders only a
description's first line there.
Claude-Session: https://claude.ai/code/session_01TWeJWzhCAW61GLqv2kTpNB
Linear attaches every document to exactly one target — project, issue,
initiative, team, cycle, or release — and documentCreate now rejects
targetless documents outright. The CLI only exposed --project/--issue on
create, --project on update, and slug-only --project plus --issue on list.
The reporter asked for the missing create flags; the consistent fix is
wider: a shared attachment-target module (six long-only flags, exactly-one
validation before any network/editor/stdin work, --team + --cycle
collapsing into one team-scoped cycle target like the issue commands)
now backs create, update, and list, so their semantics cannot drift.
Along the way this removes the interactive "workspace document" option
(it always fails server-side now), gives update the missing --issue,
fixes list --project silently matching slug IDs only, resolves list
filters to IDs so bad input errors instead of returning an empty list,
types the ATTACHMENT column (six namespaces make bare names ambiguous),
and shows all six associations in view/list output.
New shared resolvers: resolveInitiativeId (UUID/slug/name) and
resolveReleaseId (UUID/name/version, paginated to exhaustion so ambiguity
detection sees every candidate, erroring on ambiguous matches rather than
picking one silently). teamId/initiativeId/cycleId are [Internal] in
Linear's schema but verified working with a regular API key, as issueId
was before it became public.
Live-QA'd against a real workspace for project/issue/team/cycle/
initiative targets, including re-pointing (the server clears the old
target). Releases require a Business plan and are covered by mocked
tests and the refreshed schema only.
Github-Issue: Fixes#260
Github-Issue-Url: https://github.com/schpet/linear-cli/issues/260
The filter was built as issue: { identifier: { eq: ... } }, but IssueFilter
has no identifier field — the comparator for the human identifier is spelled
id. Linear rejected the variable during coercion, so every invocation of the
flag failed before reaching the resolver, regardless of whether the issue
existed. The flag has been broken since the command was introduced.
Type the filter local as DocumentFilter instead of any, which turns this class
of mistake into a compile error rather than a runtime API rejection; deno check
flags the bad field directly. Building the filter as a single annotated
expression also drops the deno-lint-ignore and keeps it undefined when neither
--project nor --issue is passed, so an unfiltered list still sends no filter.
The previous tests here were removed for rendering relative timestamps, which
are non-deterministic. The regression test instead goes through --json, which
prints raw timestamps, and declares the exact request variables so the mock
only matches the correct filter shape — verified by restoring the pre-fix code
and watching it fail.
`document create` accepts --project but `document update` did not, so a
document's project attachment was fixed at creation time — changing it required
the web UI. Add --project to update (UUID, slug ID, or name), reusing the same
resolveProjectId path as create so resolution and errors stay consistent.
The reporter framed this around Linear's web UI supporting multiple project
links per document with add/remove semantics (--project X --remove). The API
tells a different story: a Document has a single related project (the scalar
DocumentUpdateInput.projectId), and create requires exactly one anchor — so a
document always has one project and setting merely replaces it. A detach flag
was prototyped, but live testing showed the API silently ignores
`projectId: null` (returns success, keeps the project), so shipping it would
have been a silent no-op; it was dropped rather than lie about detaching.
Github-Issue: Fixes#225
Github-Issue-Url: https://github.com/schpet/linear-cli/issues/225
The CLI was inconsistent about whether --project and --milestone flags
accept a UUID, slug, or name depending on which command you called.
Several commands silently rejected one form with a misleading "not
found" error.
- Extend resolveProjectId to accept UUID, slug, or name uniformly.
- Add resolveMilestoneId that accepts a UUID directly, or a name when
--project is supplied so the milestone lookup can be scoped.
- Wire the resolvers into issue create/update/mine/query, milestone
create/update/list, and milestone view (which now also accepts an
optional --project for name-based lookup).
- Remove document create's duplicate local resolver in favor of the
shared one.
- Update --help to say "UUID, slug ID, or name" everywhere.
Closes#221
---------
Co-authored-by: Theo Gregory <theo@gregory.sh>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Peter Schilling <code@schpet.com>
Co-authored-by: Bryan <bryandesigning@gmail.com>
Co-authored-by: Joseph <31323641+josephyooo@users.noreply.github.com>
Co-authored-by: Magnus Buvarp <magnus.buvarp@gmail.com>
Co-authored-by: Rengar Lee <Rengarlee@163.com>
Co-authored-by: Hyunsu Lim <hyunsu.lim01@gmail.com>
Co-authored-by: Luc Leray <luc.leray@gmail.com>
Co-authored-by: c-99-e <268417377+c-99-e@users.noreply.github.com>
Co-authored-by: Al Johri <al.johri@gmail.com>
Co-authored-by: Mihai Chiorean <mihai.v.chiorean@gmail.com>
Co-authored-by: Mihai Chiorean <mihai-chiorean@users.noreply.github.com>
Co-authored-by: Jeffrey Holm <jeff.holm@scale.com>
Co-authored-by: Paymahn Moghadasian <paymahn1@gmail.com>
Co-authored-by: Evan Jacobson <evanjacobson3@gmail.com>
Co-authored-by: schpetbot <bot@schpet.com>
Co-authored-by: Alex Broekhof <alex@quilt.com>
#235 makes `document update` refuse a content replacement when the document has
active inline comments (whose anchors the replacement could orphan), with
--force to override, and exposes comments in `document view --json`. Two
corrections to the guard in document-update.ts:
- Exclude resolved/archived inline comments. The guard query only selected
`quotedText`, so it blocked on ANY inline comment — including resolved
(closed) threads whose anchor detaching loses no live context. It now also
selects `resolvedAt`/`archivedAt` and ignores comments that are resolved or
archived, so a closed thread no longer forces users to pass --force.
- Drop the hand-written `DocumentInlineComment*` interfaces in favor of the
codegen-inferred type (`DocumentInlineCommentGuardQuery`), per the repo's
no-`any`/typed-GraphQL convention. The annotation also breaks the circular
result-type inference that the reused `after` cursor variable introduces
(the reason the hand-written interface existed).
Adds a test that a resolved inline comment lets the update proceed without
--force.
## Summary
This makes Linear document comments visible to agents/automation and
prevents `linear document update` from silently orphaning inline comment
anchors.
- include paginated document comments in `linear document view <id>
--json`
- guard Markdown content updates when active inline document comments
are present
- allow top-level document comments to pass through, since they do not
carry losable inline anchors
- add `--force` to opt back into the existing replacement behavior when
the caller intentionally accepts the risk
## Background / related work
Closes#230, which reports that document comments are currently
unreachable from the CLI and omitted from `document view --json`.
Related:
- #219 added richer `document view` behavior for downloaded inline
images; this PR keeps normal/raw rendered views lean and only expands
`--json` with comment metadata.
- #121 added the raw `linear api` GraphQL escape hatch. That is useful
for inspection, but it does not make `documentUpdate(content)` safe for
inline document comments.
## API limitation
This is intentionally a safety guard plus read fix, not a
comment-preserving writer.
From the schema used by this CLI:
- `Document.comments(...)` exposes document comments and
`Comment.quotedText`, which is enough to detect inline comments.
- `DocumentUpdateInput` only accepts Markdown `content` for document
body writes; it does not accept `contentState`/YJS/ProseMirror data or
comment anchor metadata.
- `CommentCreateInput`/`CommentUpdateInput` expose `quotedText`, but
live testing showed raw GraphQL `commentUpdate(quotedText: ...)` returns
success without recreating an inline anchor. Writing
`<linear-comment>`-style tags through `documentUpdate(content)` stores
literal text, not an anchor.
So the CLI cannot preserve or restore inline anchors through the public
GraphQL write path it uses. The best safe behavior here is to make
comments visible and convert silent data loss into an explicit stop.
`--force` remains the old replacement behavior behind an intentional
flag.
## Behavior
`linear document view <id> --json` now returns a flattened, paginated
`comments.nodes` list with fields useful to agents:
- `id`, `body`, `quotedText`, `documentContentId`
- timestamps / archive / resolution metadata
- `url`, `user`, and `parent.id`
- final `pageInfo`
`linear document update <id> --content...` now scans active document
comments page-by-page. It blocks only when it finds a comment with
`quotedText`, i.e. an inline comment anchor that can be detached by
replacing Markdown content. Top-level document comments with
`quotedText: null` do not block.
## Testing
- `deno task validate`
- `deno test --allow-all --quiet`
Full suite result locally: `338 passed`, `0 failed`, `5 ignored`.
Document view's rendered/raw output now downloads inline images and
Linear-upload links to the same /tmp cache used by issue view, so terminal
renderers and downstream tools see local file paths instead of remote URLs
that require auth.
Image helpers (extractImageInfo, extractLinearLinkInfo, replaceImageUrls,
getUrlHash, getLinearUploadHost, downloadMarkdownImages) move from
src/commands/issue/issue-view.ts to src/utils/markdown-images.ts so both
commands share one implementation. downloadIssueImages becomes
downloadMarkdownImages, which takes an array of markdown sources rather
than an issue-specific (description, comments) tuple.
Adds --no-download to document view (mirroring issue view) and reorders
the command so the download step runs after the --json early return but
before --raw, matching issue view's behavior where piped output also gets
local paths.
Also adds remark-gfm to the parse/stringify pipeline used by
replaceImageUrls so GFM constructs (task lists, tables, strikethrough)
survive the URL rewrite. Without it, remark-stringify would re-escape
`- [ ] todo` as `* \[ ] todo` whenever an image is rewritten, mangling
documents and issue descriptions that lean on GFM syntax.
Fixes#116
Error messages are now clean and user-friendly by default. Stack traces
are only shown when LINEAR_DEBUG=1 is set, similar to RUST_BACKTRACE.
Changes:
- Add src/utils/errors.ts with error handling infrastructure:
- CliError base class with user-facing messages and suggestions
- NotFoundError for entity lookups
- ValidationError for invalid input
- AuthError for authentication issues
- handleError() for consistent error display
- extractGraphQLMessage() to parse Linear API errors
- Update all commands to use handleError() for consistent error display
- Error output goes to stderr with ✗ prefix
- GraphQL errors show userPresentableMessage when available
Example before:
Error: Entity not found: Issue: {"response":{"data":null...
Example after:
✗ Issue not found: FAKE-9999
Updated bulk.ts to use the centralized shouldShowSpinner() function instead
of directly checking Deno.stdout.isTerminal(). This ensures progress display
during bulk operations also respects the NO_COLOR environment variable.
Fixes#113
Add built-in credential storage for managing multiple Linear workspaces:
- ~/.config/linear/credentials.toml stores API keys by workspace slug
- auth login: add credentials (auto-detects workspace from API)
- auth logout: remove credentials
- auth list: show configured workspaces with org/user info
- auth default: set the default workspace
- global -w/--workspace flag to target specific workspace
API key precedence: CLI flag > env var > config > workspace flag > project workspace > default
Commands with confirmation prompts now exit with a helpful error message
when stdin is not a TTY, directing users to use the appropriate flag
(--force, --yes, --confirm, or --team) instead of hanging forever.
Affected commands:
- initiative remove-project, archive, delete, unarchive
- document delete
- issue delete
- label delete
- team delete
- milestone delete
This PR adds several major features to linear-cli:
- **Initiative management**: Full CRUD support for initiatives including
list, view, create, archive, unarchive, update, and delete commands
- **Initiative-project linking**: Commands to add and remove projects
from initiatives
- **Label management**: List, create, and delete commands for labels
with team filtering
- **Project creation**: New `project create` command with interactive
mode and initiative linking
- **Team deletion**: New `team delete` command with confirmation
- **Bulk operations**: New utility supporting bulk operations across
commands (issue delete now supports multiple IDs)
## New Commands
### Initiatives
- `linear initiative list` - List all initiatives with filtering options
- `linear initiative view <id>` - View initiative details including
linked projects
- `linear initiative create` - Create new initiative (interactive or via
flags)
- `linear initiative archive <id>` - Archive an initiative
- `linear initiative unarchive <id>` - Unarchive an initiative
- `linear initiative update <id>` - Update initiative properties
- `linear initiative delete <id>` - Delete an initiative (with
confirmation)
- `linear initiative add-project` - Link a project to an initiative
- `linear initiative remove-project` - Remove project from initiative
### Labels
- `linear label list` - List labels with optional team filter
- `linear label create` - Create a new label
- `linear label delete <id>` - Delete a label
### Projects
- `linear project create` - Create a new project with team, lead, dates,
status, and optional initiative linking
### Teams
- `linear team delete <id>` - Delete a team (with confirmation)