511 Commits

Author SHA1 Message Date
Peter Schilling d341afdbd5 Add explicit clearing flags to issue update and project update
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
2026-09-05 07:25:32 -07:00
Peter Schilling d7bba4a670 Add document, project, and initiative comments with threaded replies
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
2026-09-05 07:22:36 -07:00
Peter Schilling 3a00cc7604 Accept team names and IDs, and state names, wherever the CLI takes them
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
2026-09-05 07:19:38 -07:00
Peter Schilling 7e0577fa59 Add --content and --content-file to project update
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
2026-09-03 20:11:16 -07:00
Peter Schilling 6684ffe2e3 Add --json to team list, cycle list/view, milestone list/view, project view
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
2026-09-03 16:28:35 -07:00
Peter Schilling 196315343d chore: Release linear-cli version 2.6.0 v2.6.0 2026-09-02 12:24:24 -07:00
Peter Schilling 3c30f365b7 Teach Linear Markdown to agents without the skill
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
2026-09-01 16:04:26 -07:00
Peter Schilling 31f91e02c5 Sort issue statuses the way the app displays them
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
2026-09-01 12:45:14 -07:00
Peter Schilling 648969eee7 Stabilize issue-mine ordering snapshot
Use a fixed future update timestamp so this ordering-focused snapshot does not fail whenever the calendar day changes.
2026-09-01 08:04:59 -07:00
Peter Schilling 4653df2191 Teach agents to create real Linear mentions
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
2026-09-01 08:04:59 -07:00
Peter Schilling e8a7cd4668 Add issue templates and CONTRIBUTING with tracker scope
The issue tracker occasionally gets unsolicited issues promoting the
author's own npm package rather than reporting a problem with linear-cli.
State the tracker's scope up front so closing those cites a rule instead
of an ad-hoc judgment call.

Issue forms replace free-form issues: their required problem and
reproduction fields are the friction that actually helps, by asking for
the thing a promotional issue does not have. Usage questions route to
Discussions; nothing routes package promotion anywhere.
2026-08-31 20:43:31 -07:00
Peter Schilling 9058328e4c Follow-up to #266: make the pull request template actually reach GitHub
#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.
2026-08-31 17:29:54 -07:00
Marc-Antoine Parent 10cfda8e59 Allow to pass on a template file to gh in pr issue create. Add default to config. 2026-08-31 17:23:17 -07:00
Peter Schilling 39603334ee Group issue statuses the way the Linear app does
`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.
2026-08-31 16:44:58 -07:00
Peter Schilling 8b187e6723 Follow-up to #268: cover external and bot comment authors, validate --id
#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.
2026-08-31 13:39:40 -07:00
Leonard Sellem 1bce95bb11 fix(comment-list): expose immutable author evidence (#268)
## 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.
2026-08-31 13:33:53 -07:00
Peter Schilling 189d1ee6ba Follow-up to #265: report unusable .env files and stop the dotenv hang
#265 stops the `IsADirectory` crash by requiring `.env` to be a regular file.
That check is right, and this builds on it rather than replacing it.

Two things it leaves open, both of which bite the setup in #264:

A `.env` written to be `source`d by a shell hangs the CLI outright. @std/dotenv
expands `$VAR` references in unquoted values using a `while` loop that never
terminates when a value refers to itself, so a single ordinary line like
`export PATH=$PATH:/opt/bin` spins forever at startup -- no error, no exit.
That is a worse failure than the crash #265 fixes, it is still present in the
latest @std/dotenv (0.225.8), and the reporter's files are exactly the
shell-sourced kind that contain it. Since the only keys we ever apply are
LINEAR_/GH_/GITHUB_, the fix is to drop every other line before parsing, which
removes the whole class of failure and also silences the parser's warnings
about keys that were never ours to complain about. An unquoted `$` reference
in one of our own keys is refused with a warning instead of expanded --
unexpanded references otherwise resolve to the literal string "undefined",
which is silent corruption of a config value. Quoted values are left alone,
since dotenv takes those literally and they cannot hang.

And skipping the file silently hides it. Both the issue and CLAUDE.md ask for
the opposite: the reporter explicitly said being entirely silent "may hide
deeper issues", and the project's rule is to never fail silently. So an
unusable candidate now prints one yellow warning on stderr -- never stdout, so
--json output and the completion scripts stay clean -- and the CLI continues.
`LINEAR_IGNORE_ENV_FILE=1` opts out entirely, so the warning is self-terminating
for a repo that will never have a dotenv-shaped .env.

Also folded in: an unreadable .env (mode 000) passed #265's isFile check and
then crashed in the read, so read and parse failures are caught too, and the
repository-root candidate goes through the same path instead of a near-copy of
it.
2026-08-31 12:02:07 -07:00
Jack DeVries 0362fe5d23 feat: silently ignore .env directories when loading config 2026-08-31 11:58:51 -07:00
Peter Schilling 5af828699a Suppress default team note when the team is project configuration
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.
2026-08-12 14:25:16 -07:00
Peter Schilling dcbb7ab372 chore: Release linear-cli version 2.5.0 v2.5.0 2026-08-11 14:52:00 -07:00
Peter Schilling 928b1eb65e Support all six document attachment targets in create/update/list/view
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
2026-08-11 14:32:51 -07:00
Peter Schilling b296fe9b2c chore: sync Linear GraphQL schema
Refresh the vendored schema from live introspection. Notably picks up
releaseId/cycleId on DocumentCreateInput/DocumentUpdateInput, the
team/cycle/release relations on DocumentFilter, Document.cycle and
Document.release, and ReleaseFilter — prerequisites for document
attachment-target support.
2026-08-11 14:32:51 -07:00
Peter Schilling cf349ba477 chore: Release linear-cli version 2.4.0 v2.4.0 2026-08-05 15:02:39 -07:00
Peter Schilling 5214ca9b80 Add --add-label and --remove-label to issue update
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
2026-08-05 14:59:10 -07:00
Peter Schilling adf7f9cbec chore: Release linear-cli version 2.3.1 v2.3.1 2026-08-04 14:30:18 -07:00
Peter Schilling fe6c8b0616 Fix document list --issue, which never worked
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.
2026-08-04 13:38:35 -07:00
Peter Schilling e855b51f9c chore: Release linear-cli version 2.3.0 v2.3.0 2026-07-23 11:10:45 -07:00
Peter Schilling 78f1812493 Make inline image attachments discoverable, eval-validated
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).
2026-07-23 10:46:18 -07:00
Peter Schilling c0256eae1e Add a Common Tasks section to the linear-cli skill (#207), eval-validated
Adds ~7 copy-pasteable recipes near the top of SKILL.template.md (and the
generated SKILL.md): filtered queries via issue query (with the issue
list/mine alias gotcha spelled out), my-issues, create with
--description-file and --no-interactive, update state/assignee/labels
(noting label replacement semantics), comment from file, view/URL. The
boundary note stays generic so it doesn't teach the eval's control answers.

Post-change eval, same frozen 36-trial protocol as the baseline: 29/30
supported tasks full success, holdout 15/15, controls 6/6 still correctly
choosing linear api — no overcorrection from the new recipes. The single
failure was a subject first trying the skill's documented npx alternative
(npx @schpet/linear-cli issue create ...), which the frozen grader counts
as a bypass; it was not a GraphQL fallback and the task then completed
correctly via the CLI. With the baseline already at 30/30, the eval finds
no measurable routing headroom at this configuration; the change is
validated as non-regressing rather than as an improvement. See
evals/linear-cli-skill/results/comparison.md.

Related to #207
2026-07-23 10:46:18 -07:00
Peter Schilling 07e7d4b54a Add codex-based eval harness for the linear-cli skill, with baseline
Issue #207 claims agents reading the skill skip dedicated CLI subcommands
and reach for raw GraphQL via `linear api`. Before changing the skill text,
this adds an eval that can actually measure that: codex exec runs each task
prompt in a fully isolated environment (fresh CODEX_HOME + fake HOME so the
globally installed skill can't leak in, recording shims for linear/curl/
npx/npm, workspace-write sandbox) and a deterministic grader classifies
route choice and flag correctness from the recorded invocations.

Cases: five recipe families with development + holdout prompts, plus two
controls where GraphQL is genuinely the right route. Outcome rules were
declared before the baseline ran (see evals/linear-cli-skill/README.md).

Baseline result, 36 trials at low effort on gpt-5.6-sol: 30/30 supported
tasks full success, 6/6 controls on linear api. The premise of #207 did not
reproduce in this configuration — with the skill actually read, routing is
already perfect. Two earlier baseline runs were voided during harness
development because stateless canned outputs (issue view contradicting the
subject's own update; ENG-prefixed identifiers for OPS-team requests)
baited subjects into GraphQL investigation and contaminated the signal;
the shim is now consistency-aware.

Related to #207
2026-07-23 10:46:18 -07:00
Peter Schilling eb6f07499a Improve issue mine's no-team error and suggest linear config in repos
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.
2026-07-23 09:26:34 -07:00
Peter Schilling 97d6077455 Follow-up to #253: error on invalid configured sort instead of defaulting
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.
2026-07-23 08:37:46 -07:00
Frieder Bluemle b4013c9d8f Default sort to priority
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.
2026-07-23 08:33:15 -07:00
Peter Schilling 487d682f7b chore: Release linear-cli version 2.2.0 v2.2.0 2026-07-22 15:22:26 -07:00
Peter Schilling aad5f2f32a Expose cycles in issue query, mine, and view
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.
2026-07-21 13:12:00 -07:00
Peter Schilling 1393c70952 Add user list, team members --json, and member role markers
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.
2026-07-18 14:22:16 -07:00
Peter Schilling fb8887c11c Add issue update --unassign to clear an issue's assignee
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.
2026-07-18 14:07:21 -07:00
Peter Schilling 3c0a3dec20 Add --project to document update to re-point a document
`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
2026-07-18 12:50:07 -07:00
Peter Schilling b3a41f7d8c Add team states command and list valid states on wrong --state
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.
2026-07-17 14:06:50 -07:00
Peter Schilling 70976249ad Point config suggestions at the real linear config command
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.
2026-07-17 11:39:18 -07:00
Peter Schilling f721c86f6e chore: Release linear-cli version 2.1.1 v2.1.1 2026-07-15 16:04:39 -07:00
Prasanth Somasundar 770d5cfecd Fix Linux keyring command handling
Detect secret-tool availability by whether the executable can be launched,
rather than by probing the Secret Service.

Distinguish missing lookup results from operational failures using stderr,
and stop treating clear failures as successful deletions. Extend the
integration test to cover missing D-Bus and missing executable cases.

Fixes schpet/linear-cli#231
2026-07-15 15:58:08 -07:00
Peter Schilling a1d73708df chore: Release linear-cli version 2.1.0 v2.1.0 2026-07-14 22:13:29 -07:00
Peter Schilling 694441c018 docs(justfile): correct the dist install hint for dist-generate
The comment pointed at astral-sh/cargo-dist v0.28.3, but that repo stops at
0.28.7 and the version this workspace pins (cargo-dist-version = 0.31.0) is
only published from axodotdev/cargo-dist, so the documented command fails with
'failed to find tag v0.31.0'.

Point at the version dist-workspace.toml actually pins, and note why matching
it matters: 'dist generate' rewrites cargo-dist-version to whatever version
generated the file, so running a mismatched dist silently repins the workspace.
2026-07-14 22:11:41 -07:00
Peter Schilling 12860b253c ci: pin deno to 2.7.9 in the release and publish pipelines
027f25e pinned deno to 2.7.9 in mise.toml and ci.yaml, but the two workflows
that actually build and ship artifacts were left on 'v2.x': release.yml runs
'deno compile' for every target triple, and publish.yaml runs codegen before
'jsr publish'. So the binaries users install were produced by whatever v2.x
resolved to at build time, not the toolchain the project pins and tests on.

build-setup.yml is the dist 'github-build-setup' hook that generates the deno
step in release.yml, so it and the generated line are updated together to keep
'dist generate' a no-op.

setup-deno@v2 already runs an exact 2.7.9 in both ci.yaml jobs, so this is the
version resolution those jobs have been proving all along.
2026-07-14 22:10:47 -07:00
Peter Schilling d1beb924e4 fix(test): skip keyring integration test when the macOS keychain is locked
isKeyringAvailable() only probed on Linux and returned true unconditionally
everywhere else, so on macOS the keyring round-trip always attempted a real
write. Under ssh or an agent shell the login keychain has no UI session to
unlock it, so 'security add-generic-password' fails with exit 36
(errSecInteractionNotAllowed) and 'deno task test' fails locally even though
nothing is wrong with the code.

Probe with 'security show-keychain-info', which is read-only and returns 36 in
exactly that state. A read-only probe is not enough on its own: reads still
succeed while locked (find-generic-password returns 44), so only a
write-capable check detects it. The guard keys on 36 specifically rather than
any nonzero exit, so a genuine keyring bug still fails the test loudly.

CI is unaffected: its macOS keychain is unlocked, and the keyring job now sets
LINEAR_KEYRING_INTEGRATION=1 to force the test to run, so a probe that ever
wrongly reports 'unavailable' cannot quietly turn that job into a no-op.
2026-07-14 22:10:09 -07:00
Peter Schilling 7e84ad9747 feat(project): add --label to project update
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>
2026-07-13 21:37:38 -07:00
Peter Schilling 82daad741e Follow-up to #236: fail hard on fmt errors, trim abort output, render SKILL.md first
Corrective delta on top of #236's generator hardening:

- deno fmt failure is now fatal instead of a logged warning; unformatted
  committed docs would otherwise break `deno fmt --check` in CI.
- The top-level error boundary prints a concise message instead of the raw
  error (and its stack trace) on every abort path.
- SKILL.md is rendered from its template before writeReferences prunes any
  stale docs, so a missing or broken template aborts before touching the
  references directory.
2026-07-13 20:53:47 -07:00
Edwin Hernandez ffb986d3f4 refactor(skill-docs): harden generator and make output deterministic (#236)
This reworks `skills/linear-cli/scripts/generate-docs.ts` for robustness and stable output, and regenerates the references with the new ordering.

Robustness:
- `run()` now catches the error `Deno.Command` throws when a binary is missing (e.g. `NotFound`) instead of crashing, returning a failed result.
- Help-fetch failures are collected and the run aborts before writing, so an error string can never be embedded into committed docs.
- Reference files are written before stale ones are pruned, so a partial failure can no longer gut the `references/` directory.
- `main()` runs under an `import.meta.main` guard with a `.catch` that logs and exits non-zero.
- The two `pop()` non-null assertions are replaced with a safe `lastSegment()` helper.

Output stability:
- Top-level commands and each subcommand list are sorted by name before rendering, so generation is deterministic and stops producing reordering churn between runs (related to the motivation behind dropping the staleness check in #218). This is the source of the large but reorder-only docs diff in this PR. Happy to drop the sort if you'd rather keep CLI help order.

Cleanup:
- The markdown builders are rewritten as pure `map`/`join`/`flatMap` helpers, and the unused `formatCommandMarkdown` helper is removed.

Verified with the repo-pinned Deno (2.7.9): `deno fmt --check`, `deno lint`, `deno check`, and `deno task generate-skill-docs` run twice with an empty diff between runs. The docs diff is reordering only (identical sorted line sets per file).
2026-07-13 20:50:42 -07:00
Ryan Schumacher ad1563877b feat(project): add --description-file and doc 255-char API limit (#227)
The Linear API rejects project descriptions longer than 255 characters,
but the CLI didn't document the limit or offer a way to work around the
shell-quoting friction of long inline strings.

- Add --description-file to project create and project update, mirroring
document create's --content-file. Mutually exclusive with --description.
- Pre-validate description length client-side with a ValidationError
that points users at --description-file or attaching a Document, so they
don't have to wait for the API to reject the request.
- Update --help text on both commands to mention the 255-char limit.

Closes #224

---------

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>
2026-07-11 16:14:03 -07:00