17 Commits

Author SHA1 Message Date
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 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 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 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 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
Ryan Schumacher dd39d9e269 fix(cli): accept both UUID and name for --project/--milestone (#229)
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>
2026-07-11 14:58:31 -07:00
Peter Schilling dec4ee5966 Follow-up to #235: exclude resolved inline comments from the update guard
#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.
2026-07-11 14:01:13 -07:00
Joseph 9756aafe19 Guard document updates when inline comments exist (#235)
## Summary

This makes Linear document comments visible to agents/automation and
prevents `linear document update` from silently orphaning inline comment
anchors.

- include paginated document comments in `linear document view <id>
--json`
- guard Markdown content updates when active inline document comments
are present
- allow top-level document comments to pass through, since they do not
carry losable inline anchors
- add `--force` to opt back into the existing replacement behavior when
the caller intentionally accepts the risk


## Background / related work

Closes #230, which reports that document comments are currently
unreachable from the CLI and omitted from `document view --json`.

Related:

- #219 added richer `document view` behavior for downloaded inline
images; this PR keeps normal/raw rendered views lean and only expands
`--json` with comment metadata.
- #121 added the raw `linear api` GraphQL escape hatch. That is useful
for inspection, but it does not make `documentUpdate(content)` safe for
inline document comments.

## API limitation

This is intentionally a safety guard plus read fix, not a
comment-preserving writer.

From the schema used by this CLI:

- `Document.comments(...)` exposes document comments and
`Comment.quotedText`, which is enough to detect inline comments.
- `DocumentUpdateInput` only accepts Markdown `content` for document
body writes; it does not accept `contentState`/YJS/ProseMirror data or
comment anchor metadata.
- `CommentCreateInput`/`CommentUpdateInput` expose `quotedText`, but
live testing showed raw GraphQL `commentUpdate(quotedText: ...)` returns
success without recreating an inline anchor. Writing
`<linear-comment>`-style tags through `documentUpdate(content)` stores
literal text, not an anchor.

So the CLI cannot preserve or restore inline anchors through the public
GraphQL write path it uses. The best safe behavior here is to make
comments visible and convert silent data loss into an explicit stop.
`--force` remains the old replacement behavior behind an intentional
flag.

## Behavior

`linear document view <id> --json` now returns a flattened, paginated
`comments.nodes` list with fields useful to agents:

- `id`, `body`, `quotedText`, `documentContentId`
- timestamps / archive / resolution metadata
- `url`, `user`, and `parent.id`
- final `pageInfo`

`linear document update <id> --content...` now scans active document
comments page-by-page. It blocks only when it finds a comment with
`quotedText`, i.e. an inline comment anchor that can be detached by
replacing Markdown content. Top-level document comments with
`quotedText: null` do not block.

## Testing

- `deno task validate`
- `deno test --allow-all --quiet`

Full suite result locally: `338 passed`, `0 failed`, `5 ignored`.
2026-07-11 13:58:36 -07:00
Peter Schilling eb8a659f04 feat(document): download attached images in document view
Document view's rendered/raw output now downloads inline images and
Linear-upload links to the same /tmp cache used by issue view, so terminal
renderers and downstream tools see local file paths instead of remote URLs
that require auth.

Image helpers (extractImageInfo, extractLinearLinkInfo, replaceImageUrls,
getUrlHash, getLinearUploadHost, downloadMarkdownImages) move from
src/commands/issue/issue-view.ts to src/utils/markdown-images.ts so both
commands share one implementation. downloadIssueImages becomes
downloadMarkdownImages, which takes an array of markdown sources rather
than an issue-specific (description, comments) tuple.

Adds --no-download to document view (mirroring issue view) and reorders
the command so the download step runs after the --json early return but
before --raw, matching issue view's behavior where piped output also gets
local paths.

Also adds remark-gfm to the parse/stringify pipeline used by
replaceImageUrls so GFM constructs (task lists, tables, strikethrough)
survive the URL rewrite. Without it, remark-stringify would re-escape
`- [ ] todo` as `* \[ ] todo` whenever an image is rewritten, mangling
documents and issue descriptions that lean on GFM syntax.
2026-05-19 12:54:29 -07:00
Peter Schilling f460007a5f refactor: preserve GraphQL connection shape in document list JSON 2026-04-02 15:15:00 -07:00
Peter Schilling 430ac0402d feat: add user-friendly error handling with LINEAR_DEBUG support
Fixes #116

Error messages are now clean and user-friendly by default. Stack traces
are only shown when LINEAR_DEBUG=1 is set, similar to RUST_BACKTRACE.

Changes:
- Add src/utils/errors.ts with error handling infrastructure:
  - CliError base class with user-facing messages and suggestions
  - NotFoundError for entity lookups
  - ValidationError for invalid input
  - AuthError for authentication issues
  - handleError() for consistent error display
  - extractGraphQLMessage() to parse Linear API errors
- Update all commands to use handleError() for consistent error display
- Error output goes to stderr with ✗ prefix
- GraphQL errors show userPresentableMessage when available

Example before:
  Error: Entity not found: Issue: {"response":{"data":null...

Example after:
  ✗ Issue not found: FAKE-9999
2026-02-01 22:51:12 -08:00
Peter Schilling 59dce3cf19 fix: use centralized shouldShowSpinner() for all spinner checks
Updated bulk.ts to use the centralized shouldShowSpinner() function instead
of directly checking Deno.stdout.isTerminal(). This ensures progress display
during bulk operations also respects the NO_COLOR environment variable.

Fixes #113
2026-02-01 20:38:23 -08:00
Peter Schilling 540818c0b1 feat: add multi-workspace authentication support
Add built-in credential storage for managing multiple Linear workspaces:
- ~/.config/linear/credentials.toml stores API keys by workspace slug
- auth login: add credentials (auto-detects workspace from API)
- auth logout: remove credentials
- auth list: show configured workspaces with org/user info  
- auth default: set the default workspace
- global -w/--workspace flag to target specific workspace

API key precedence: CLI flag > env var > config > workspace flag > project workspace > default
2026-01-23 14:28:45 -08:00
Peter Schilling cf770acf9d test: remove non-deterministic snapshot tests
Remove tests with inherently non-deterministic output:
- document-list: 3 tests displaying relative timestamps (e.g., '3 days ago')
- document-delete: 1 test with stack traces containing line numbers
- document-create: 1 test with stack traces containing machine-specific paths
- document-update: 2 tests with stack traces containing machine-specific paths
- document-view: 1 test with stack traces containing machine-specific paths

The fakeTime solution causes hangs with mock servers (known issue).
Stack traces contain machine-specific file paths that differ across environments.
2026-01-21 09:40:06 -08:00
Peter Schilling b32381308a fix: add TTY checks before interactive prompts to prevent hanging in non-interactive mode
Commands with confirmation prompts now exit with a helpful error message
when stdin is not a TTY, directing users to use the appropriate flag
(--force, --yes, --confirm, or --team) instead of hanging forever.

Affected commands:
- initiative remove-project, archive, delete, unarchive
- document delete
- issue delete
- label delete
- team delete
- milestone delete
2026-01-21 09:40:06 -08:00
Sam Gbafa cd45a8a6aa Add initiative, label, and bulk operation support (#95)
This PR adds several major features to linear-cli:

- **Initiative management**: Full CRUD support for initiatives including
list, view, create, archive, unarchive, update, and delete commands
- **Initiative-project linking**: Commands to add and remove projects
from initiatives
- **Label management**: List, create, and delete commands for labels
with team filtering
- **Project creation**: New `project create` command with interactive
mode and initiative linking
- **Team deletion**: New `team delete` command with confirmation
- **Bulk operations**: New utility supporting bulk operations across
commands (issue delete now supports multiple IDs)

## New Commands

### Initiatives
- `linear initiative list` - List all initiatives with filtering options
- `linear initiative view <id>` - View initiative details including
linked projects
- `linear initiative create` - Create new initiative (interactive or via
flags)
- `linear initiative archive <id>` - Archive an initiative
- `linear initiative unarchive <id>` - Unarchive an initiative  
- `linear initiative update <id>` - Update initiative properties
- `linear initiative delete <id>` - Delete an initiative (with
confirmation)
- `linear initiative add-project` - Link a project to an initiative
- `linear initiative remove-project` - Remove project from initiative

### Labels
- `linear label list` - List labels with optional team filter
- `linear label create` - Create a new label
- `linear label delete <id>` - Delete a label

### Projects
- `linear project create` - Create a new project with team, lead, dates,
status, and optional initiative linking

### Teams
- `linear team delete <id>` - Delete a team (with confirmation)
2026-01-21 08:45:54 -08:00