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
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.
#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.
#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.
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
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.
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).
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
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
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.
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.
Fixesschpet/linear-cli#231
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.
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.
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.
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>
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.
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).
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.