issue update could set a due date, estimate, parent, project, or
milestone but never remove one, and project update had the same gap for
lead, start date, and target date. Only --unassign and --clear-cycle
existed. cliffy rejects an empty string as a missing option value, so
--due-date "" is not a workaround, and an agent driving the CLI had to
fall back to a hand-written projectUpdate/issueUpdate mutation through
linear api.
Add one boolean clear flag per field, each placed after its set flag and
modelled on --clear-cycle: it conflicts with its set flag (a
ValidationError before any request, with a null check so --estimate 0
counts as a value), skips the lookup the set flag would run, and puts an
explicit null in the mutation input. --clear-project also rejects
--milestone, because a milestone belongs to the project being removed;
--project with --clear-milestone is allowed so a move can detach a stale
milestone in one update. The project update guard treats each clear flag
as an update and its suggestion lists them.
Linear honours null for every field, including startDate, verified on a
scratch project and issue.
Claude-Session: https://claude.ai/code/session_01A9qEGri4p2HZMQSuYsBmub
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
A project's long-form overview body could be set at creation via
project create --content / --content-file, but never changed afterwards:
project update only exposed --description, which is Linear's separate
255-character summary field. Updating the body meant hand-writing a
projectUpdate mutation through linear api and reading the markdown from a
file yourself.
project update now takes the same two flags as create, spelled and worded
identically, and resolves them through create's existing helper so the
mutual-exclusion and file-read behavior cannot drift between the two
commands. Content and description are independent API fields and may be
set together. The no-options guard uses null checks so an empty content
file still counts as an explicit value to forward; Linear currently keeps
the existing body when sent an empty string, so this is not a way to clear
it, and cliffy rejects --content "" outright.
No short aliases: -f already means --description-file on this command and
create has none for content either.
Claude-Session: https://claude.ai/code/session_01A9qEGri4p2HZMQSuYsBmub
Scripts need to map a team name to its key and id, and team list was the
only way to see both, so they had to scrape its table. It was also the last
list command without JSON. The maintainer asked to sweep the other commands
in the same state; the survey found cycle list, cycle view, milestone list,
milestone view, and project view, so all six get -j, --json here. issue mine
stays human-only on purpose (issue query is its JSON surface).
The reporter proposed a five-field subset for team list. The JSON instead
carries every field the query already selects, per the repository rule to
preserve GraphQL names and nesting rather than invent CLI shapes. Lists emit
{ nodes, pageInfo } after the same filtering and ordering as the table, so
archived teams stay hidden and cycles stay newest-first. Views emit the
object as fetched, including every issue rather than the ten-item preview,
and milestone view --all --json includes every page.
Two of these queries took Linear's default page with no cursor: cycle list
and milestone list silently dropped everything past fifty. Adding JSON would
have made that easier to consume without making it safer, so both now
paginate and fail loudly if Linear advertises a page without a cursor. The
view queries gain pageInfo on their issues connection so callers can see
when a page was partial. A 2.0.0 changelog entry claimed cycle list --json;
that merge only touched SVG files, so this is the first time it ships.
Github-Issue: Fixes#276
Github-Issue-Url: https://github.com/schpet/linear-cli/issues/276
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
Statuses were grouped by type and then by ascending position, which is
what the GraphQL schema documents: WorkflowState.position says states
"are displayed in ascending order of position within their type group".
The app does the opposite. Inside a group it orders by position
descending, and it puts the started group above unstarted rather than
following lifecycle order.
Two states that share a type never consult the type table, so the
position tiebreak is the only thing ordering them. That is where the
divergence showed: a team with two states of one type saw them come out
reversed from the app. Every fixture here was discriminated by type
alone, so nothing ever exercised the tiebreak in the direction that
mattered.
Flipping the comparator is not sufficient on its own. Four call sites
read "first state of this type in the list" as "earliest state in the
workflow", relying on the list arriving in ascending position order:
the target state for issue start, bare-type state resolution, and the
three interactive issue create defaults. Under the new order that read
silently returns the LAST state of the type, so issue start would have
begun moving issues to the final started state. Selection now asks for
the lowest position explicitly, so it no longer depends on how the
caller happened to sort its list.
One consequence is left deliberately unchanged: for a team with no
unstarted state at all, the issue create default still falls back to the
first state in the list, which is now the display-first one. The old
fallback never meant "lowest position overall" either, and picking a
cross-type minimum would invent a rule rather than restore one.
team states keeps sharing the display order, so reading a workflow and
listing issues group statuses identically.
Claude-Session: https://claude.ai/code/session_01FKkCVHWGLZdNemwyb7AynH
Expose canonical user URLs in team and workspace member JSON so the skill can resolve mentions without guessing profile slugs. Document team-first URL mentions and Linear collapsible syntax.
Add a stubbed Claude forward eval that captures submitted Markdown. The frozen cases improved from 1/4 to 4/4 while preserving the verbatim-body control.
Fixes#112
#266 adds `-T/--template` and a `pr_template` config option, which are the right
surface -- the names and the precedence are kept exactly as the contributor
designed them. The mechanism cannot work, though, and I could not find a variant
of it that does.
The command already passes `--body <issue url>`, and #266 appends `--template`
next to it. `gh` refuses that pair outright:
`--template` is not supported when using `--body` or `--body-file`
So every use of the new flag fails, and setting `pr_template` in config breaks
`issue pr` on every invocation rather than only when the flag is passed.
Dropping `--body` to make room for `--template` -- the obvious repair -- is
worse. `gh` only consults a template when it is running interactively; without a
body a non-TTY caller gets
must provide `--title` and `--body` (or `--fill` ...) when not running interactively
and no pull request at all. That would trade a broken flag for a command broken
in CI, scripts, and agents. Handing `gh` a temporary file that already contains
the template fails the same way, because the problem is the missing `--body`,
not the file's contents.
So the template is read here and folded into the body we already send, with the
issue URL appended after it. The URL is what Linear matches on to attach the pull
request to its issue, so it has to survive; putting it last leaves the template's
prose as the first thing a reviewer reads. Every existing flag keeps working,
because the argv shape is unchanged.
Reading the file ourselves means we own its failures, and per CLAUDE.md an
explicitly requested template that cannot be used is an error rather than a
silent fallback to a URL-only body -- otherwise the user gets a pull request
quietly missing the content they asked for. Missing paths, directories,
non-regular files and unreadable files all produce a message naming the path.
NUL bytes are rejected too: `Deno.readTextFile` does not refuse binary input, it
substitutes U+FFFD and keeps the NULs, which `Deno.Command` then rejects with a
bare TypeError that never mentions the file.
One deliberate surface change: #266's description suggests `-T ""` to override a
configured default. That worked only because an empty string happened to be
falsy. It is now an explicit `--no-template` flag, and `-T ""` errors with a
suggestion pointing at it.
The generated skill docs under skills/ are left alone; they are produced from an
installed binary out of band and are already stale on trunk.
`issue mine --all-states` listed Canceled and Done first and Backlog and Todo
last -- the reverse of the app. The primary sort was
`workflowState: { order: "Descending" }`, copy-pasted across four call sites
covering `issue mine`, `issue query`, and `issue start`. The commit that added
it (592000f) recorded only "sorts by workflow state first"; no rationale for the
direction survives.
Simply flipping the direction would not have fixed it. Measured against the API,
`Ascending` returns Todo before Backlog, so neither direction matches a team's
configured order -- Linear sorts by an internal ranking, and `WorkflowStateSort`
offers no way to sort by position at all. The schema says what the app actually
does: position orders states "in ascending order of position within their type
group". So the key is (type group, position), and ordering has to happen
locally.
That distinction is load-bearing rather than pedantic. This workspace has an
"In Review" at position 1002 with type `started`; under a raw-position sort it
lands after "Duplicate" instead of beside "In Progress" -- which is exactly what
`linear team states` has been printing, the same bug reached through
`getWorkflowStates()`. Both now share one comparator.
`position` is selected on each issue's own state rather than looked up per team,
so there is no extra round trip and multi-team results are automatically
correct. It is only meaningful within a team, though, so the key is chosen once
per result set: a single-team listing sorts by type group then position, while a
multi-team one sorts by type group alone and leaves the server's priority order
standing inside each group. Ranking one team's positions against another's would
compare unrelated numbers, and deciding it per-pair would not even be transitive.
Two consequences worth flagging. The server sort is now `Ascending`, which under
`--limit` changes which issues are fetched, not just their order: previously a
truncated `--all-states` listing filled up with canceled issues before reaching
any open work. And a non-finite position now throws instead of being tolerated,
because a NaN comparator result reads as "equal" and would quietly degrade the
listing to some other order -- that guard caught two stale test fixtures the
moment it went in.
Search is untouched; it is relevance-ranked, and regrouping it by status would
destroy the ordering that is the point of the command.
#268 adds `user.id` and `editedAt` to `issue comment list --json`, which is the
right shape -- the raw connection is passed straight through, so GraphQL field
names and nesting are preserved. This fills in the cases it stops short of.
`externalUser` did not get the same treatment as `user`, but it has the same
problem and the schema is explicit about why: ExternalUser.displayName "can
match the display name of an actual user". So a consumer could disambiguate two
workspace members from each other and still be unable to tell a member from an
external commenter with the same name. It now carries `id` too.
Integration-authored comments were the bigger gap. They have `user` and
`externalUser` both null, so they arrived in the JSON with no author
information at all -- the exact problem #268 sets out to fix, for a whole class
of comment it does not reach. Selecting `botActor` gives them `type` (non-null,
the reliable key, since ActorBot is not a Node and its `id` is nullable) plus
`subType`, `name` and `id`. `userDisplayName` is available but left out: it
names a person in an external system and is display-only, so it is not worth
the exposure to solve an identity problem the other fields already solve.
That also fixes a rendering bug we were one field away from: every GitHub,
Slack and workflow comment printed as `@Unknown`, because the query never asked
who the bot was. The author fallback was duplicated for root comments and
replies; it is now one helper, with botActor checked last so a comment carrying
both a user and a bot actor still renders the human, and every existing
snapshot stays byte-identical.
Finally, `--id` was forwarded to the API unvalidated. The repo already has
`isLinearUuid`, and CLAUDE.md asks for an immediate, actionable error when
user-supplied input is malformed rather than a raw GraphQL failure, so a
non-UUID is now rejected before the request with a message showing the expected
shape. The flag stays hidden, with a comment recording why it exists.
## Summary
- include the native comment author ID in JSON output
- include Linear editedAt so consumers can distinguish author edits from backend updatedAt drift
- preserve the GraphQL field names and nesting
## Verification
- focused comment-list tests: 3 passed
- full suite: 560 passed, 6 ignored
- deno check
- targeted deno lint
- git diff --check
Also adds a hidden `--id` flag to `issue comment add`, forwarding a
caller-supplied UUID as CommentCreateInput.id so a retried create is
idempotent rather than posting a duplicate.
issue query prints "Note: using default team ..." whenever the team scope
falls back to the configured default. The note exists to flag ambient
defaults (a global config file or a shell-exported LINEAR_TEAM_ID) silently
narrowing a query, but it also fired when the team came from the project's
own linear.toml or .env — explicit, directory-scoped configuration where
the reminder is just noise on every query.
Track where each option was resolved from (cli, env, project-env,
project-config, global-config) by keeping global and project config
separate and recording which env keys were applied from a project .env.
issue query now consults the source and only prints the note for ambient
sources. getOption keeps its interface, delegating to the new
getOptionWithSource, which also removes its type casts.
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 reporter asked for a way to detach a label from one issue without
deleting it team-wide, believing --label was additive. It actually
replaces the issue's entire label set (IssueUpdateInput.labelIds), so
the gap was wider than reported: adding one label clobbered the rest.
Rather than only the suggested --remove-label, this maps both
--add-label and --remove-label onto the API's addedLabelIds/
removedLabelIds (one atomic mutation, no read-modify-write). --label
keeps its documented replace semantics for existing scripts, with help
text that now says so. Flag names match gh issue edit, the surface
users and agents reach for first.
A --clear-labels flag was considered (an empty label set is currently
inexpressible in one command) and deliberately deferred: adding a flag
later is backwards compatible, removing one is breaking, and nothing
has asked for clear-all yet.
Invalid combinations error before any network call: --label with
incremental flags, the same resolved label ID in both add and remove,
and --team moves combined with incremental flags (label names resolve
against the destination team, which would make source-team labels
silently unresolvable). Live QA confirmed removing an unattached label
is rejected by Linear's API ("Label <id> is not on issue <id>"), not a
silent no-op — surfaced as-is, consistent with the repo's
explicit-input-errors philosophy.
The reporter's alternative ask (label rename) is deferred: it is
team-wide and would not solve the per-issue detach workflow.
Github-Issue: Fixes#258
Github-Issue-Url: https://github.com/schpet/linear-cli/issues/258
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.
Agents asked to put a visible screenshot on an issue reach for
`issue attach`, which uploads the file but creates a sidebar link
attachment that never renders inline — while the success output
("Attachment created") convinces them the image is visible. The working
path, `issue comment add --attach`, has existed since v2.0.0 but nothing
pointed at it: the skill had no image guidance and the flag was buried in
a reference table.
Three coordinated changes, validated as experiment 2 of the skill eval:
- Skill: a Common Tasks recipe for visible images via
`issue comment add --attach`, with an explicit warning about
`issue attach`'s sidebar-only behavior.
- CLI: `issue attach` now says it created a sidebar link attachment,
and for images prints a copy-pasteable hint suggesting
`issue comment add --attach` (shell-quoted, --public preserved).
Help descriptions updated on both commands.
- Eval: new frozen image family (trap-phrased development prompt,
comment-phrased holdout) plus a sidebar-control case graded on
positionals, with pre-declared outcome rules, CLI/API control split,
binary-safe fixture checks, and version-matched shim output.
Result (rules frozen before baseline): image-development went 0/3 to 3/3
— every baseline trial fell into the attach trap and wrongly reported
success; every post-change trial routed straight to the recipe. Image
family 3/6 to 6/6 lands in the pre-declared partial-baseline band, so it
is reported as consistent with improvement (exploratory Fisher p = 0.09)
rather than confirmed. Controls held except one known npx-version-check
grader artifact, adjudicated by an Opus gold-label pass (17/18 agreement
with the deterministic grader).
When no team can be determined, issue mine (and its list alias) now uses
the same message as issue query — 'No default team configured and no team
scope provided' — instead of the inaccurate 'Could not determine team key
from directory name or team flag' (the team only ever comes from the
--team flag, LINEAR_TEAM_ID, or team_id config; directory names are not
consulted). The error now carries a suggestion: always offer --team
<key>, and when run inside a git work tree, also point at linear config,
which links the repository to a team by generating .linear.toml.
Repo detection is a new best-effort isInsideGitRepo() helper: any git
failure (not a repo, dubious ownership) counts as false so the optional
hint can never turn the team error into a git crash. The same stale
message remains in cycle-list, cycle-view, team-states, and team-members;
those are deferred so they can adopt the helper in a follow-up.
Add resolveIssueSort() so all issue-listing commands share one sort
resolution path: --sort flag > LINEAR_ISSUE_SORT env > issue_sort config
> priority default. Unlike the getOption fallback, an explicitly
configured but invalid sort value now errors with guidance instead of
silently sorting by priority; this also fixes the same latent silent
downgrade in issue query.
Also cover the gaps around the new default: a genuinely unconfigured
subprocess test (the repo's own .linear.toml supplies issue_sort when
tests run in-process, so the previous no-config tests were passing via
that config file), a test that a configured sort order still wins over
the default, and updated skill docs that no longer claim issue list
requires a sort order.
Previously, commands listing issues required a sort order via the
--sort flag, the configuration file, or the LINEAR_ISSUE_SORT
environment variable, and errored out when none was provided. Fall
back to priority sort instead so the commands work out of the box,
and document the default in the --sort help text.
Also ignore the .idea/ directory.
Issue tables gain a compact CYC column (only when a listed team has
cycles enabled): 'now' for the active cycle, +1/-1 from the API's
next/previous flags, +N/-N anchored on team.activeCycle, and #N when no
anchor exists (cooldown or pre-first-cycle). issue view annotates its
Cycle meta part with the same token, and issue JSON output now carries
the cycle flags and team anchor in raw GraphQL shape.
The shared cycle resolver understands now/next/previous and signed
offsets, so --cycle on query/mine/create/update and cycle view all
speak the relative vocabulary. issue update gains --clear-cycle
(explicit cycleId: null, mirroring --unassign). Also fixes issue query
--search silently dropping the --cycle filter.
Three related gaps in member listing:
`team members` had no --json output. It now emits the connection shape
({ nodes, pageInfo }) with GraphQL field names preserved, matching label list
and project list.
There was no way to list everyone in the workspace, only per-team. Adds
`linear user list` (alias `u`) querying viewer.organization.users. This is a
sibling command rather than a `team members --organization` flag: workspace
members are definitionally not team members, and a flag that has to error when
combined with the positional team key is the design saying it's two commands.
Members now show admin, owner, and you markers alongside the existing
inactive/guest/not-assignable ones, via a shared renderer.
Along the way this fixes `team members --all`, which was a no-op. getTeamMembers
never passed includeDisabled, so Linear defaulted it to false and disabled users
were never fetched — the client-side `active` filter was narrowing a set that
could not contain them. The regression test pins includeDisabled in the mock
variables, so it fails loudly if the flag stops reaching the API.
Note: the --all fix could not be exercised against real data; the workspace used
for QA has no disabled users.
There was no way to unassign an issue. The mutation input was built as a
`Record<string, string | number | string[] | undefined>`, a type that
structurally cannot hold null, so `IssueUpdateInput.assigneeId` could never
be set to null no matter what flags were added.
Swap that hand-rolled Record for the codegen'd `IssueUpdateInput` — matching
what `project update` already does — and add an explicit `--unassign` flag.
Passing both --assignee and --unassign is a ValidationError rather than one
silently winning.
No short alias: `-U, --unassigned` is already bound in three sibling commands
as a read-side filter, and putting a data-clearing mutation one shift-key away
from a harmless filter invites accidents.
Verified against the real API that Linear honors `assigneeId: null` — worth
checking explicitly, since it silently ignores `projectId: null` elsewhere.
`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
Discovering a team's valid workflow state names required a raw GraphQL query,
and passing a wrong `--state` to `issue create`/`issue update` failed with a
bare "Workflow state not found" and no hint at the valid options.
Add `linear team states [teamKey]` (table + `--json`) reusing the existing
getWorkflowStates helper, and make the wrong-state failure actionable: both
issue commands now fetch the states once, resolve against them, and on a miss
throw an error that lists the valid states and points at `linear team states`.
Resolution moves to a pure resolveWorkflowState + a shared
workflowStateNotFoundError factory so both call sites stay identical and the
matching logic is unit-testable; the fetched list is reused for the suggestion
(no second round-trip).
The reporter also hypothesized a raw TypeError for unknown teams; verified this
is false — team(id) is non-null in the schema and an unknown team already
yields a clean "Could not find referenced Team." error, so no null guard is
added. The `--state` help text was left unchanged to avoid churning the
(width-sensitive, globally stale) generated skill docs; the enriched error is
the load-bearing discovery path.
Three user-facing strings advertised a `linear configure` command that does
not exist — the interactive setup command is registered as `config`, so
`linear configure` failed with "Unknown command". The reporter hit this via
`linear team id` with no team configured.
Fix all three suggestions to name the canonical `config` command, and add
`configure` as an alias so the natural command people (and the CLI's own help
text) reach for just works instead of erroring. Keeping `config` canonical in
every string and doc while tolerating `configure` is more robust than a bare
text swap: the alias matches the exact instinct that produced the bug.
Also upgrade the bare `Error` thrown for an integer id with no team to a
`ValidationError` so it renders with the standard ✗ + suggestion treatment.
The root command moves from src/main.ts into src/cli.ts so it can be imported
by tests (to assert the alias resolves) without its complex inferred cliffy
type entering the published public API and tripping no-slow-types; main.ts
stays the entry point and only runs it under import.meta.main.
Adds a repeatable `--label` flag to `linear project update`, mirroring the
existing `project create --label`: names resolve case-insensitively and an
unknown label raises NotFoundError (no auto-create).
The flag uses replace semantics — the supplied labels become the project's
complete label set — consistent with `project update --team` and
`issue update --label`. Empty/whitespace labels are rejected up front, and
case-insensitive duplicates collapse to a single ID.
The project-label lookup is extracted from project-create into a shared
getProjectLabelIdByName helper in utils/linear.ts so create and update stay
identical.
This reshapes the update half of #226 to the repo's existing label
conventions; the create half of #226 already shipped in #216, and the PR's
auto-create/interactive-create behavior is intentionally dropped.
Co-authored-by: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com>
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>
#170 adds `labels` to `issue view --json` by selecting them in both
GetIssueDetails and GetIssueDetailsWithComments. Its dedicated
labels-with-content test only exercises the --no-comments (GetIssueDetails)
path; the with-comments path was covered only with empty labels. Add a
--json (with comments) test carrying real labels so both fetchIssueDetailsRaw
code paths are verified to surface labels — the consistency both were meant to
guarantee.
## Summary
This improves `linear issue create` in three related areas:
- allow `--project` to keep issue creation interactive, the same way
`--parent` already does
- add optional project selection in interactive issue creation
- add configurable default self-assignment behavior for created issues
Before this change, `linear issue create --project "Dashboard"` skipped
interactive mode and failed with "Title is required when not using
interactive mode". With this PR, `--project` is treated as an
interactive-safe input, so users can preselect a project without also
needing `--title`.
Project prompting in interactive mode remains opt-in.
`issue_create_ask_project` defaults to `false`, so existing interactive
behavior is unchanged unless users explicitly enable the new prompt.
## Changes
- allow interactive issue creation when the only flags are `--project`,
`--parent`, or both
- add a team-scoped project picker during interactive issue creation
- gate the direct project prompt behind a new config option:
`issue_create_ask_project`
- keep `issue_create_ask_project = false` as the default, so project
selection stays out of the main interactive flow unless enabled
- when `issue_create_ask_project = false`, expose `Project` through the
existing "Add more fields" flow instead
- continue inheriting the parent issue's project when `--parent` is used
without an explicit `--project`
- allow explicit `--project` together with `--parent` and defer any
invalid combination checks to the Linear API
- add a new config option, `issue_create_assign_self`, with these modes:
- `auto` (default): respect Linear's `autoAssignToSelf` setting
- `always`: default-assign created issues to self
- `never`: never default-assign created issues
- update docs for the new interactive behavior and config options
## Notes
- `issue_create_ask_project` defaults to `false`, so users who do not
set it will keep the previous interactive flow
- project selection only shows projects available to the selected team
- the interactive project picker is not shown when a parent issue is
present unless the project was explicitly provided
- explicit `--assignee`, the interactive assignee flow, and `--start`
still override the default assignment behavior
## Testing
- `deno task codegen`
- `deno task check`
- `deno lint`
- `deno fmt`
- `deno task generate-skill-docs`
- `deno test --allow-all --quiet test/config.test.ts`
- `deno test --allow-all --quiet
test/commands/issue/issue-create.test.ts`
#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`.
#228 fixes milestone view silently capping its issue list, adding --all to
paginate. Two corrections:
1. Fail loudly on inconsistent --all pagination. The loop guarded on
`while (pageInfo.hasNextPage && pageInfo.endCursor)` and `if (next == null)
break`, so if Linear advertised another page but returned no cursor (or the
milestone vanished mid-pagination), --all would stop early and return a
*partial* list with no error — reintroducing the exact silent-truncation bug
this command exists to fix. Now throws CliError / NotFoundError instead, with
a regression test.
2. Fix timezone-flaky snapshots. The new "Truncated" and "--all paginates" tests
used midnight-UTC timestamps (2020-01-01T00:00:00Z) whose snapshots were
generated in a US timezone, so `formatRelativeTime`'s toLocaleDateString
fallback rendered 12/31/2019 there but 1/1/2020 under CI's UTC — failing CI
(fork CI never ran, so it slipped through). Moved the fixtures to noon UTC so
they render the same date in every timezone; verified passing under TZ=UTC and
TZ=America/Los_Angeles.
milestone view silently capped its issues list at 10 items. A user with
12 issues would see only 10 and could miss issues entirely when
iterating over the visible output in a script. The cap was also
undocumented in --help.
- Request first: 50 issues with pageInfo on the connection.
- Add --all to paginate through every page when needed.
- Show a prominent truncation footer pointing at --all and at
`linear issue query --milestone X --json` for full programmatic
access.
- Document the default cap in --help.
Closes#222
PR #216 adds --content/--content-file plus priority/label/member/icon/color
to `linear project create`, with happy-path snapshot tests and a
mutual-exclusion unit test. This adds the missing failure-path coverage that
both blind-planning passes called for:
- invalid --priority is rejected with a ValidationError before any network call
- an unknown --label surfaces a NotFoundError
- an unknown --member surfaces a NotFoundError
These use a stubbed Deno.exit and capture stderr (mirroring the validation
tests in issue-query.test.ts), and run against a mock Linear server so they
never touch the real API. No product code changes — the contributor's feature
is correct as written; this only locks in the error behavior.
## Why
Linear projects have two text fields: a short description and a longer
project overview. The CLI only supported the short description, so
projects created from the terminal still needed a manual edit in Linear
to add goals, plans, specs, or launch notes.
This PR lets users create a project with its overview already filled in,
either from inline markdown or a markdown file. That makes `linear
project create` more useful for scripted project setup, templates, and
project specs kept in a repo.
It also exposes a few existing Linear create fields so users can set
priority, labels, members, icon, and color when creating the project
instead of doing a follow-up edit.
## What changed
- `linear project create` now accepts `--content` for inline project
overview markdown.
- `--content-file` reads the project overview from a markdown file.
- `--content` and `--content-file` are mutually exclusive.
- Project create can now set priority, labels, members, icon, and color.
- The command passes these values through `ProjectCreateInput` using
Linear field names.
## Checks
- `deno task codegen`
- `deno task check`
- `deno lint`
- `deno task test`
PR #234 makes attachment uploads private by default and adds a --public
opt-in that errors (rather than silently downgrades) for non-image types.
Two gaps remained in `issue comment add`, both flagged independently while
blind-planning the fix:
- The public/type check only ran inside uploadFile, per file, at upload
time. With `--public` and multiple attachments, an earlier valid image
was uploaded *publicly* before a later non-image threw — a partial, and
unwanted-public, upload. Pre-flight resolveMakePublic() for every
attachment in the existing existence-validation loop so a mixed batch
fails before anything is uploaded.
- `--public` with no `--attach` was a silent no-op. Per the repo's
"explicit invalid input should error" convention, reject it.
Adds command-level tests for both validation paths (they short-circuit
before any network call, so no upload mock is needed).
Add a 'B' column (with ⊘) to 'linear issue mine' and 'linear issue query'
that flags issues with at least one active blocker — a blocked-by relation
whose blocker is not in a completed or canceled workflow state. The
inverseRelations connection is also surfaced in --json output.
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.
Linear's comments connection returns newest-first and the GraphQL schema
doesn't expose a direction argument, so sort root comments ascending
client-side to match the order shown in Linear's UI.
Introduce 'issue mine' for personal work queue and 'issue query' for
structured retrieval with optional full-text search via --search.
Keep 'issue list' as a separate backwards-compatible command.
Remove standalone 'issue search' registration in favor of query --search.
query supports multi-team filtering, --all-teams, --json output,
--search-comments, and verifies GraphQL variables in tests.