linear-sdk-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +20 -0
- package/.claude-plugin/plugin.json +9 -0
- package/CHANGELOG.md +807 -0
- package/COVERAGE.md +495 -0
- package/LICENSE +21 -0
- package/MIGRATING.md +220 -0
- package/README.md +524 -0
- package/package.json +87 -0
- package/skills/linear-sdk-cli/SKILL.md +534 -0
- package/skills/linear-sdk-cli/references/api.md +27 -0
- package/skills/linear-sdk-cli/references/attachment.md +82 -0
- package/skills/linear-sdk-cli/references/auth.md +174 -0
- package/skills/linear-sdk-cli/references/commands.md +37 -0
- package/skills/linear-sdk-cli/references/comment.md +154 -0
- package/skills/linear-sdk-cli/references/completion.md +17 -0
- package/skills/linear-sdk-cli/references/config.md +99 -0
- package/skills/linear-sdk-cli/references/cycle.md +131 -0
- package/skills/linear-sdk-cli/references/document.md +180 -0
- package/skills/linear-sdk-cli/references/favorite.md +78 -0
- package/skills/linear-sdk-cli/references/initiative-update.md +67 -0
- package/skills/linear-sdk-cli/references/initiative.md +256 -0
- package/skills/linear-sdk-cli/references/issue.md +928 -0
- package/skills/linear-sdk-cli/references/label.md +112 -0
- package/skills/linear-sdk-cli/references/milestone.md +139 -0
- package/skills/linear-sdk-cli/references/notification.md +123 -0
- package/skills/linear-sdk-cli/references/open.md +29 -0
- package/skills/linear-sdk-cli/references/organization.md +99 -0
- package/skills/linear-sdk-cli/references/project-update.md +67 -0
- package/skills/linear-sdk-cli/references/project.md +234 -0
- package/skills/linear-sdk-cli/references/roadmap.md +150 -0
- package/skills/linear-sdk-cli/references/schema.md +21 -0
- package/skills/linear-sdk-cli/references/state.md +61 -0
- package/skills/linear-sdk-cli/references/team.md +227 -0
- package/skills/linear-sdk-cli/references/user.md +103 -0
- package/skills/linear-sdk-cli/references/webhook.md +151 -0
- package/skills/linear-sdk-cli/references/whoami.md +26 -0
- package/skills/linear-sdk-cli/scripts/generate-docs.ts +339 -0
- package/src/bin/linear.ts +97 -0
- package/src/cli.ts +232 -0
- package/src/client.ts +172 -0
- package/src/commands/agent-session.ts +121 -0
- package/src/commands/api.ts +317 -0
- package/src/commands/attach.ts +90 -0
- package/src/commands/attachment.ts +74 -0
- package/src/commands/comment.ts +225 -0
- package/src/commands/completion.ts +69 -0
- package/src/commands/cycle.ts +125 -0
- package/src/commands/discover.ts +170 -0
- package/src/commands/document.ts +211 -0
- package/src/commands/favorite.ts +72 -0
- package/src/commands/initiative-update.ts +47 -0
- package/src/commands/initiative.ts +261 -0
- package/src/commands/issue.ts +1263 -0
- package/src/commands/label.ts +119 -0
- package/src/commands/meta.ts +534 -0
- package/src/commands/milestone.ts +163 -0
- package/src/commands/notification.ts +110 -0
- package/src/commands/open.ts +84 -0
- package/src/commands/organization.ts +79 -0
- package/src/commands/project-update.ts +47 -0
- package/src/commands/project.ts +339 -0
- package/src/commands/roadmap.ts +131 -0
- package/src/commands/state.ts +58 -0
- package/src/commands/team.ts +243 -0
- package/src/commands/user.ts +107 -0
- package/src/commands/webhook.ts +156 -0
- package/src/config.ts +1236 -0
- package/src/context.ts +117 -0
- package/src/git.ts +174 -0
- package/src/index.ts +10 -0
- package/src/lib/action.ts +26 -0
- package/src/lib/body.ts +172 -0
- package/src/lib/errors.ts +195 -0
- package/src/lib/introspect.ts +150 -0
- package/src/lib/keyring.ts +270 -0
- package/src/lib/mutation.ts +53 -0
- package/src/lib/open.ts +32 -0
- package/src/lib/options.ts +640 -0
- package/src/lib/output-shapes.ts +562 -0
- package/src/lib/pagination.ts +145 -0
- package/src/lib/prompt.ts +91 -0
- package/src/lib/resolve.ts +596 -0
- package/src/lib/shape.ts +273 -0
- package/src/lib/status-update.ts +91 -0
- package/src/lib/upload.ts +329 -0
- package/src/output/color.ts +52 -0
- package/src/output/format.ts +238 -0
- package/src/output/markdown.ts +133 -0
- package/src/output/pager.ts +131 -0
- package/src/output/sanitize.ts +76 -0
- package/src/output/table.ts +198 -0
- package/src/services/agent-session.ts +289 -0
- package/src/services/attachment.ts +198 -0
- package/src/services/comment.ts +262 -0
- package/src/services/cycle.ts +224 -0
- package/src/services/document.ts +357 -0
- package/src/services/favorite.ts +147 -0
- package/src/services/initiative-update.ts +59 -0
- package/src/services/initiative.ts +449 -0
- package/src/services/issue.ts +1153 -0
- package/src/services/label.ts +193 -0
- package/src/services/milestone.ts +262 -0
- package/src/services/notification.ts +190 -0
- package/src/services/organization.ts +144 -0
- package/src/services/project-update.ts +36 -0
- package/src/services/project.ts +494 -0
- package/src/services/roadmap.ts +177 -0
- package/src/services/state.ts +127 -0
- package/src/services/team.ts +386 -0
- package/src/services/user.ts +137 -0
- package/src/services/webhook.ts +203 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Every command declares its `--json` output, and `linear commands <path>` prints it (TES-610).**
|
|
12
|
+
An agent driving the CLI cold guessed key names and filed false bugs (`.user` read as `.author`,
|
|
13
|
+
`.parent` as `.parentId`); the field names were knowable only from the source. Each node of
|
|
14
|
+
`linear commands --json` now carries `output`: `kind` (`list` — bare array of rows; `object` — a
|
|
15
|
+
view; `receipt` — a mutation's ids and what happened; `raw` — `api`/`schema`; `none` —
|
|
16
|
+
`completion`), `fields` (key → `"string"`, `"number|null"`, `["string"]`, nested `{…}`,
|
|
17
|
+
`{nullable: {…}}` for a relation that may be null, `"comments?"` for a key present only
|
|
18
|
+
sometimes) and `variants` (the whole shape under `--web`, `--start`, `op=list`, …). New `linear
|
|
19
|
+
commands <path...>` describes one command — a bare object under `--json` (`.output.fields`),
|
|
20
|
+
or usage, options and one `key: type` line per field for a human, plus its subcommands for a
|
|
21
|
+
group; an unknown path is `not_found` (exit 3) with the nearest paths. The shapes are declared
|
|
22
|
+
beside the interfaces they describe (`shape<IssueRow>({…})`, `src/lib/shape.ts`), where a field
|
|
23
|
+
renamed, added, removed, re-typed or de-nulled in the interface is a compile error in the shape;
|
|
24
|
+
the receipts live in `src/lib/output-shapes.ts`, one table keyed by command path; and a test
|
|
25
|
+
drives every JSON-printing command through the real program against an everything-succeeds
|
|
26
|
+
client and holds what it printed against what it declared — every key, no extra key, every type,
|
|
27
|
+
and a relation still null after a source that answers every relation ("not selected or not
|
|
28
|
+
mapped"). A command missing from the table fails the same test, so a new command cannot ship
|
|
29
|
+
undocumented. The skill's per-command references (`bun run skill:docs`) carry the same
|
|
30
|
+
**Output (`--json`)** block, and `SKILL.md` tells an agent to read it before guessing.
|
|
31
|
+
- **File uploads: `issue attach <issue> <file...>` and `comment add --attach <file>`, private by
|
|
32
|
+
default (TES-602).** `issue attach` uploads each file to Linear's storage (`fileUpload` for a
|
|
33
|
+
signed URL, then an HTTP `PUT` of the bytes with exactly the headers Linear returned plus the
|
|
34
|
+
Content-Type the URL was signed for) and attaches it to the issue by its asset URL — `--title`
|
|
35
|
+
for a single file, `--comment <body>` to also post one comment embedding every file as markdown
|
|
36
|
+
(`` for images, `[name](url)` otherwise, one per line after a blank line), and
|
|
37
|
+
`--json` a bare array of `{id, title, url, assetUrl, contentType, size}` (plus `comment` when
|
|
38
|
+
one was posted). `comment add` (and its `issue comment add` mount) takes `--attach <file>`,
|
|
39
|
+
repeatable, appending the same embeds to the body; a bodiless comment with attachments is just
|
|
40
|
+
the embeds, and no editor opens for it. Uploads are **private** — `uploads.linear.app`,
|
|
41
|
+
readable by workspace members only, like an upload from the Linear app (an anonymous fetch
|
|
42
|
+
answers 401). `--public` puts a raster image on a world-readable `public.linear.app` URL and
|
|
43
|
+
warns so on stderr; on any other type it is a usage error, decided before any bytes move
|
|
44
|
+
(Linear itself refuses "Public uploads are only supported for images (excluding SVG)"). Every
|
|
45
|
+
file in a batch is validated up front — exists, is a regular file, is readable, and may be
|
|
46
|
+
public if asked — so a typo in file 3 does not leave files 1–2 uploaded and orphaned. The
|
|
47
|
+
signed upload URL is a bearer credential: it is never printed and is redacted from any error,
|
|
48
|
+
including the storage backend's `SignatureDoesNotMatch` bodies. MIME comes from a small
|
|
49
|
+
extension table with `application/octet-stream` as the fallback. `linear issue attach x` used
|
|
50
|
+
to land in `view` with a "not available yet" pointer; it is a real subcommand now.
|
|
51
|
+
- **`project delete`, `team delete`, and `issue agent-session list/view` (TES-644).**
|
|
52
|
+
`project delete <id>` trashes a project (`projectDelete`) where `archive` keeps it read-only;
|
|
53
|
+
`team delete <key>` deletes a team, naming its issue count in the confirmation, and
|
|
54
|
+
`--move-issues <team>` moves them elsewhere first (in batches of 50 via `issueBatchUpdate`)
|
|
55
|
+
— the key is required, never the configured default. Both take the shared confirmation gate
|
|
56
|
+
(`--yes` off-TTY, exit 6 on decline). `issue agent-session list [issue]` lists the sessions
|
|
57
|
+
Linear's agent integrations opened on an issue (or the current branch's; `--all-issues` for the
|
|
58
|
+
workspace feed, `--status` to narrow), and `view <id>` shows one with its activity transcript,
|
|
59
|
+
oldest first. The row is the same whichever way a session is found.
|
|
60
|
+
- **Initiative ↔ project linking, `unarchive`, list filters, `--icon`/`--color` (TES-603,
|
|
61
|
+
TES-642).** `initiative add-project <initiative> <project>` (`--sort-order`) and
|
|
62
|
+
`remove-project` (confirmed; finds the link on the project's side, so no workspace-wide paging)
|
|
63
|
+
and `initiative unarchive <id>` (resolves among archived initiatives, refuses a live one). `initiative
|
|
64
|
+
list` gains `--status`, `--owner`, `--archived` — the list stays every-status by default, and
|
|
65
|
+
the reference CLI's `--all-statuses` is accepted as the no-op it is here. `initiative
|
|
66
|
+
create/update` take `--icon`/`--color`, and `initiative view` shows `icon`, `archivedAt` and
|
|
67
|
+
the linked `projects` (`{id, name, status}`).
|
|
68
|
+
- **`project list --all-teams`, `team create --private`, health-only status updates (TES-642).**
|
|
69
|
+
`project list` is scoped to the default team and the only way out was the accidental
|
|
70
|
+
`--team ''`; `--all-teams` drops the team clause (and refuses to be combined with `--team`).
|
|
71
|
+
`team create --private` sends `private: true` (a plan without private teams refuses it as
|
|
72
|
+
`feature_not_accessible`). `project-update create` / `initiative-update create` accept `--health`
|
|
73
|
+
with no body — the UI's "mark on track" — posting the empty body the API stores for it; a
|
|
74
|
+
create with neither body nor health is still a usage error.
|
|
75
|
+
- **Credentials in the OS keyring, and schpet/linear-cli's found without a re-login.**
|
|
76
|
+
`auth login` now stores the API key in the macOS Keychain (or Linux `secret-tool`) under service
|
|
77
|
+
`linear-cli` / account `<workspace slug>` — the reference CLI's exact convention — and writes only
|
|
78
|
+
a `keyring = true` marker to `config.toml`; `--plaintext` keeps today's `0600` file behaviour,
|
|
79
|
+
and a platform with no keyring falls back to it silently (a platform is not an error). Resolution
|
|
80
|
+
falls through to the keyring after the flag, the env and a plaintext `api_key`, and `auth status`
|
|
81
|
+
reports `Source: keychain`. A user coming from schpet 2.x is authenticated before their first
|
|
82
|
+
command: their `credentials.toml` workspace list and `default` are read (never its inline keys),
|
|
83
|
+
and the Keychain item is where we look. New `auth migrate` moves every plaintext key from the
|
|
84
|
+
file into the keyring, all-or-nothing with rollback; `auth list` gains a `Storage` column;
|
|
85
|
+
`auth logout` removes the keyring entry too, and drops the slug from schpet's list so neither
|
|
86
|
+
tool advertises a workspace whose key is gone. The secret never travels on argv: macOS goes
|
|
87
|
+
through `security -i` (commands over stdin — `add-generic-password -w` as the last option
|
|
88
|
+
prompts on `/dev/tty` and hangs in a real terminal, verified), Linux through `secret-tool`'s
|
|
89
|
+
stdin. `auth login --key -` reads the key from stdin; `--key <value>` warns that argv is
|
|
90
|
+
visible to other processes.
|
|
91
|
+
- **Config discovery now covers every file schpet/linear-cli reads.** We looked only for
|
|
92
|
+
`.linear.toml` up the tree and `~/.config/linear/config.toml`, so a repo configured with its
|
|
93
|
+
`linear config` — which prefers `<git root>/.config/linear.toml` — and a user's global
|
|
94
|
+
`~/.config/linear/linear.toml` were both invisible: `issue create` said "No team specified" and
|
|
95
|
+
every list widened to the workspace. The project walk now checks `linear.toml`, `.linear.toml`
|
|
96
|
+
and `.config/linear.toml` in each directory from cwd up, in its order (it looks at cwd and the
|
|
97
|
+
git root; every directory between is a superset that agrees wherever it would find something),
|
|
98
|
+
and its global `linear.toml` is read as the lowest tier for non-secret settings — never for the
|
|
99
|
+
`api_key` it allows there, same rule as a project file. `linear config` now prints each value with
|
|
100
|
+
the tier and file it came from (`Team: TES (project: /repo/.config/linear.toml)`), and the JSON
|
|
101
|
+
carries `origins` and `globalConfigPath`.
|
|
102
|
+
- **`config init` and `config set` — the config was read-only, so a project's `.linear.toml` had to
|
|
103
|
+
be written by hand.** `config init` writes `<git root>/.linear.toml` (or `./.linear.toml` outside
|
|
104
|
+
a repository; `--path` to choose), taking `--team` or offering the workspace's teams in a prompt;
|
|
105
|
+
it refuses to replace an existing file without `--force`. `config set <key> <value>` changes one
|
|
106
|
+
of `team`, `workspace`, `sort`, `vcs` in the project config discovery would actually read — a
|
|
107
|
+
schpet-written `.config/linear.toml` included, in _its_ spelling (`team_id`, `issue_sort`), so no
|
|
108
|
+
second competing key appears — or, with `--user`, in `~/.config/linear/config.toml`. The edit is
|
|
109
|
+
textual, one line replaced or appended before the first table, so comments and layout survive;
|
|
110
|
+
the result is parsed back to prove it, falling back to a full re-serialize only if a layout we
|
|
111
|
+
did not foresee defeats the line edit. Values are checked the way the reader will judge them
|
|
112
|
+
(`sort manual` is refused here, not on the next `issue list`), keys that belong to the credential
|
|
113
|
+
store (`api_key`, `workspaces`, …) are refused with a pointer to `auth login`, and every write
|
|
114
|
+
is atomic. Bare `linear config` still shows the resolved configuration (it is `config show`).
|
|
115
|
+
|
|
116
|
+
- **The query filters a script carried over from `linear-cli` expects.** These failed loudly before
|
|
117
|
+
(unknown flag), so nothing changes meaning — but they blocked real workflows, and every one of
|
|
118
|
+
them is now on `issue list`, `issue mine` and `issue search` alike, because all three share one
|
|
119
|
+
filter builder.
|
|
120
|
+
- **`-U/--unassigned`** — issues with no assignee (`assignee: { null: true }`). Combining it with
|
|
121
|
+
`--assignee` is a usage error rather than a silent winner: "unassigned issues assigned to Ada"
|
|
122
|
+
is not a question with an answer.
|
|
123
|
+
- **Repeatable `--team` and `--state`.** Both broaden, which is the opposite of repeated
|
|
124
|
+
`--label` (which narrows) — and deliberately so: an issue belongs to exactly one team and sits
|
|
125
|
+
in exactly one state, so intersecting them would build a filter that can never match. A single
|
|
126
|
+
`--team`/`--state` sends the exact filter it always did (`key: { eq }` / `type: { eq }`);
|
|
127
|
+
several send `key: { in: [...] }` and an `or` of state clauses. `--state` keeps taking a state
|
|
128
|
+
_name_ or a state _type_, mixed freely: names stay individual `eqIgnoreCase` clauses because
|
|
129
|
+
`in` is exact-case, so `--state 'in progress'` still matches "In Progress".
|
|
130
|
+
`--team` is repeatable **only on the three issue queries**. It is a global option that ~135
|
|
131
|
+
commands use for something other than filtering — the team an issue is created in, the team a
|
|
132
|
+
cycle or workflow state belongs to — where "several teams" has no meaning, so making the global
|
|
133
|
+
itself a list would push an array through every one of those call sites to no benefit. The
|
|
134
|
+
queries declare their own repeatable `--team`, and the global-option injection now leaves a
|
|
135
|
+
locally-declared global alone instead of overwriting it (which would have quietly kept the last
|
|
136
|
+
key only). `issue mine`'s "unstarted by default" now travels through this same `--state` field
|
|
137
|
+
rather than a second one, so there is one state path in the filter builder, not two.
|
|
138
|
+
- **`--created-after` / `--updated-after`** — inclusive (`gte`) bounds taking `YYYY-MM-DD` or full
|
|
139
|
+
ISO 8601. The date is validated locally first: `new Date()` accepts "1", "March 2024" and
|
|
140
|
+
"yesterday", and a garbage bound is not an API error but an empty result set — a filter that
|
|
141
|
+
silently matches nothing looks exactly like a query with no matches.
|
|
142
|
+
- **`--project-label`** — every issue whose _project_ carries a label, matched case-insensitively
|
|
143
|
+
(`--project-label backend` finds the "Backend" label). Mutually exclusive with `--project`:
|
|
144
|
+
one names a single project, the other a set of them.
|
|
145
|
+
- **`--search-comments`** (`issue search` only) — match comment bodies as well as titles and
|
|
146
|
+
descriptions. It rides on `searchIssues`' own `includeComments` argument rather than the shared
|
|
147
|
+
filter, because the plain `issues` query has nowhere to put it; that is also why it is absent
|
|
148
|
+
from `issue list`/`mine`. Off by default, as in the reference: comment text widens a search a
|
|
149
|
+
lot, and you should be able to ask for that rather than discover it.
|
|
150
|
+
- **`--milestone`** — filter by project milestone, by name or id. The reference CLI requires
|
|
151
|
+
`--project` alongside it; Linear's `IssueFilter` does not, so here that scoping is optional and
|
|
152
|
+
only changes precision: with `--project` the name is resolved to a milestone id inside that
|
|
153
|
+
project, without it the milestone is matched by name across projects. A transplanted command
|
|
154
|
+
always passes `--project` and behaves identically either way.
|
|
155
|
+
- **`issue update --team <key>` now actually moves the issue between teams** (AUDIT.md #8: the flag
|
|
156
|
+
was accepted and silently dropped — alone it produced a misleading "Nothing to update", and
|
|
157
|
+
alongside another flag it moved nothing at all). Everything team-scoped in the same command is
|
|
158
|
+
resolved against the **destination** team, so `issue update TES-42 --team ENG --state 'In Review'`
|
|
159
|
+
means ENG's "In Review": the state id of the team the issue is leaving is not valid in the team
|
|
160
|
+
it is joining, and the API rejects the pair with "Discrepancy between issue team and state, cycle
|
|
161
|
+
or project" (verified). What Linear does to the rest of the issue was verified live rather than
|
|
162
|
+
assumed — it needs no clearing from us, because it remaps server-side: the workflow state is
|
|
163
|
+
carried over to the destination team's equivalent state, the **cycle is dropped** (cycles are
|
|
164
|
+
team-scoped), **team-scoped labels are dropped** while workspace-level labels survive, and a
|
|
165
|
+
**project the destination team is not part of is dropped** along with its milestone. The assignee
|
|
166
|
+
survives, and the issue is renumbered (`TES-489` → `CLM-2`), which human output now says out loud
|
|
167
|
+
so the next command in a script does not go looking for an identifier that no longer exists.
|
|
168
|
+
|
|
169
|
+
- **Aliases for the reference `linear-cli`'s spellings.** Everything below is purely additive:
|
|
170
|
+
no existing flag, command, or output changed meaning. The goal is that a script or a habit
|
|
171
|
+
carried over from `linear-cli` keeps working instead of failing on a spelling.
|
|
172
|
+
- **Short flags.** `-j` for the global `--json` (the reference's most-used flag, on 17 of its
|
|
173
|
+
commands) and `-w` for `--web` on `issue view` / `issue pull-request`. Both letters were
|
|
174
|
+
free in our tree, so neither costs an existing meaning — we still hold one meaning per
|
|
175
|
+
letter everywhere, which is exactly why we are _not_ adopting their `-t`/`-p`/`-a`/`-l`/`-s`
|
|
176
|
+
(see `ALIGNMENT.md`: their own tree spells `-t` as both `--title` and `--team`).
|
|
177
|
+
- **Long-flag spellings.** `--due-date` (`issue create`/`update`), `--target-date` (`project`,
|
|
178
|
+
`milestone`, `initiative` create/update), `--start-date` (`project create`/`update`),
|
|
179
|
+
`--search` (`issue list`/`mine`), and `--all-states` on `issue list`, where it is the
|
|
180
|
+
no-op it describes — `list` already spans every state. Aliases are registered _hidden_, so
|
|
181
|
+
`--help` and `linear commands --json` keep advertising exactly one spelling per flag; the
|
|
182
|
+
full mapping lives in README's "Coming from linear-cli". Passing both spellings at once is a
|
|
183
|
+
usage error rather than a silent pick: `--due 2026-01-01 --due-date 2026-02-01` is not a typo
|
|
184
|
+
anything should be guessing at. `project list --status`, which shipped earlier as a visible
|
|
185
|
+
duplicate option, now goes through this same mechanism.
|
|
186
|
+
- **Accepted values.** `self` joins `me`/`@me` as the viewer sentinel anywhere a user is named,
|
|
187
|
+
so `--assignee self` assigns to you instead of hunting for a user called "self".
|
|
188
|
+
`--limit 0` is accepted as a synonym for `--all` — it used to be a usage error, and the
|
|
189
|
+
silent-failure risk is the opposite direction: a transplanted `--limit 0` that quietly
|
|
190
|
+
returned the 50-row default would look like a complete result. `--all` stays the spelling we
|
|
191
|
+
teach. Cycles now resolve by **name** as well as number/id, and `active` is accepted
|
|
192
|
+
alongside `current` — the union of both CLIs' vocabularies, so no cycle reference from
|
|
193
|
+
either side is rejected.
|
|
194
|
+
- **Command aliases.** `issue query` (their name for this listing) runs the same command object
|
|
195
|
+
as `issue list`; `auth whoami` runs the identical handler as our top-level `whoami`; and
|
|
196
|
+
`issue comment` gained `add`/`list`/`update`/`delete` subcommands mounted on the same
|
|
197
|
+
handlers as the top-level `comment` group, so there is one implementation, not two.
|
|
198
|
+
`issue comment <id> <body>` is unchanged — commander only dispatches to a subcommand when
|
|
199
|
+
the _first_ operand is one of those four names, which is the one behavior this touches: a
|
|
200
|
+
lone `linear issue comment add` used to mean "comment the word 'add' on the branch's issue"
|
|
201
|
+
and now reaches the subcommand (and says so loudly). The short aliases `ls`/`edit`/`rm` are
|
|
202
|
+
deliberately not registered under `issue comment`, to keep that collision at four words.
|
|
203
|
+
- **`issue mine` — your unstarted work.** The reference `linear-cli` makes this its
|
|
204
|
+
_default_ listing (`issue list` there is an alias of `mine`), so a script or a habit carried
|
|
205
|
+
over from it silently saw only your own unstarted issues. We keep `list` general — a command
|
|
206
|
+
named "list" should list — and add `mine` next to it with the reference's defaults: the
|
|
207
|
+
viewer's issues, restricted to `unstarted` states, widened by `--all-states`. It takes the same
|
|
208
|
+
filters as `issue list` minus `--assignee`, which would make the command's name a lie. Same
|
|
209
|
+
service path as `list`, so the JSON contract and `--fields`/`--limit` behave identically.
|
|
210
|
+
It deliberately does **not** take the reference's `l` alias: `list` is `ls` here, so `l` and `ls`
|
|
211
|
+
would sit one keystroke apart and return completely different sets.
|
|
212
|
+
- **Project content, priority, labels and members.** `project create`/`update` gained
|
|
213
|
+
`--content`/`--content-file` — the project's **markdown body**, which the CLI previously had no
|
|
214
|
+
way to set (`--description` is the one-line summary, a different field) — plus
|
|
215
|
+
`-P/--priority <0-4>` (validated locally), `-l/--label` (resolved against workspace project
|
|
216
|
+
labels, deduplicated, label groups skipped), `--member` (repeatable, deduplicated),
|
|
217
|
+
`--icon <name>` (a capitalized Linear icon name such as `Rocket`, validated by the API) and
|
|
218
|
+
`--color <hex>`. On `update`, `--label` and `--member` replace the whole set. `project view`
|
|
219
|
+
now shows Labels and Content.
|
|
220
|
+
- **`issue update --unassign` and `--clear-cycle`.** Clearing an assignee or removing an issue
|
|
221
|
+
from its cycle was previously impossible — every flag could only set a value. Passing a clear
|
|
222
|
+
flag together with its setting counterpart (`--unassign` with `--assignee`) is a usage error
|
|
223
|
+
rather than a silent last-one-wins.
|
|
224
|
+
- **`document list --project` / `--issue`.** Documents can be narrowed to their container;
|
|
225
|
+
human references (a project name, an issue identifier like `TES-1`) are resolved to ids first,
|
|
226
|
+
since `DocumentFilter` matches containers by id.
|
|
227
|
+
- **Documents: all six attachment targets, and `update` re-points** (TES-613). A document is
|
|
228
|
+
attached to exactly one of a project, issue, initiative, team, cycle or release
|
|
229
|
+
(`DocumentCreateInput` carries the six ids; verified live for every one the test workspace can
|
|
230
|
+
hold — releases need a Business plan, so `--release` is schema-verified and resolved by name or
|
|
231
|
+
version, not exercised). `document create` and `document list` take `--project`, `--issue`,
|
|
232
|
+
`--initiative`, `--team`, `--cycle`, `--release`; `document update` takes the same six to
|
|
233
|
+
**move a document onto another target**, which the server answers by clearing the old one
|
|
234
|
+
(`update <id> --issue TES-42` takes a project document off its project). `--team` stays the
|
|
235
|
+
global flag: alone it names the team; with `--cycle` it scopes the cycle lookup and is not a
|
|
236
|
+
second target, as on the issue commands. Two targets on any of the three is a usage error naming
|
|
237
|
+
both flags — a document has one, and a list filtered by two could never match — and `create`
|
|
238
|
+
with none falls back to the configured team, as before; `list` is never narrowed by the
|
|
239
|
+
configured team (documents are workspace-wide) and `update` never re-points on its account,
|
|
240
|
+
only on an explicit `--team`. `document list --team X`, which was accepted (global) and
|
|
241
|
+
ignored, now filters. The list's `Project` column becomes `Attached to`, typed
|
|
242
|
+
(`Issue: TES-42`, `Cycle: #4 Sprint 4`, …), and `document view` shows the same line.
|
|
243
|
+
- **`milestone view` lists the milestone's issues** (identifier, state, title), capped by the
|
|
244
|
+
global `-n/--limit` and with an explicit `… more (use --all)` notice when the cap hides some,
|
|
245
|
+
so a partial list never reads as a complete one.
|
|
246
|
+
- **Initiative priority and labels.** `initiative create` and `initiative update` take
|
|
247
|
+
`-P/--priority <0-4>` (0 none, 1 urgent … 4 low; validated locally with a usage error) and
|
|
248
|
+
`-l/--label <name>` (repeatable/comma-separated, resolved by name or id — `update --label`
|
|
249
|
+
replaces the whole set, matching `issue update --label`). `initiative view` shows Priority and
|
|
250
|
+
Labels, and `initiative list` gains a `Pri` column. Linear made these fields public in
|
|
251
|
+
`@linear/sdk` 88.2; they were `[Internal]` before. Initiative labels are their own
|
|
252
|
+
workspace-scoped entity, so name resolution goes through `initiativeLabels` and skips label
|
|
253
|
+
groups, which are containers rather than applicable labels.
|
|
254
|
+
- **Discovery commands for scripts & agents.** `linear commands` prints a machine-readable tree
|
|
255
|
+
of every (sub)command — `--json` emits a bare array of
|
|
256
|
+
`{ path, description, aliases, arguments, options }` (human mode is a compact indented listing).
|
|
257
|
+
`linear schema` dumps the Linear GraphQL schema as SDL (`-o, --output <file>` writes to a file;
|
|
258
|
+
`--json` prints the raw introspection result), so an agent can do
|
|
259
|
+
`linear schema -o /tmp/s.graphql && grep 'type Issue' /tmp/s.graphql` and then reach anything
|
|
260
|
+
via `linear api`.
|
|
261
|
+
- **Help examples.** `issue create`/`list`/`update`/`start` and `project create` now include an
|
|
262
|
+
Examples section in `--help` surfacing forgiving inputs (`--assignee me`, label-by-name,
|
|
263
|
+
`--cycle current`, `--state "In Progress"`) and a `--json` recipe.
|
|
264
|
+
- **`issue create`: templates, sub-issues in their parent's project, and `--start`** (TES-639).
|
|
265
|
+
`--template <name|id>` creates from an issue template — the team's own or a workspace-shared one,
|
|
266
|
+
resolved by name (a team template outranks a shared one of the same name; a miss lists what
|
|
267
|
+
there is), sent as `templateId`; every value you pass alongside overrides what the template
|
|
268
|
+
fills. **The team's default template is now applied**, as Linear's own new-issue form and the
|
|
269
|
+
reference CLI do: the API applies it _only when asked_ (verified live — a plain `issueCreate` on
|
|
270
|
+
a team with a default template returns an issue with no description), so `useDefaultTemplate:
|
|
271
|
+
true` is sent unless `--no-default-template` (also accepted under the reference's spelling,
|
|
272
|
+
`--no-use-default-template`); an explicit `--template` replaces it rather than stacking. A
|
|
273
|
+
`--parent` child now **joins its parent's project** unless `--project` says otherwise — the
|
|
274
|
+
sub-issues we created landed outside the project their parent was in — and `--milestone` can
|
|
275
|
+
name a milestone in that inherited project. `--start` does what `issue start --move` does, on the
|
|
276
|
+
issue it just created: assigns it to you (naming somebody else at the same time is a usage
|
|
277
|
+
error, as in the reference), moves it to the team's first `started` state (an explicit `--state`
|
|
278
|
+
is respected instead), and checks out the branch when run inside a git repository; the JSON
|
|
279
|
+
gains `branch`, `checkedOut`, `stateChanged` in that case and is unchanged otherwise.
|
|
280
|
+
|
|
281
|
+
### Changed
|
|
282
|
+
|
|
283
|
+
- **`--fields`, `--limit` and `--all` are refused on commands that never read them** (TES-637 (2),
|
|
284
|
+
TES-596). All twelve globals are registered on every command so they can sit anywhere on the
|
|
285
|
+
line, but `--fields` projects a _rendered_ result (a table or a detail block) and `--limit`/`--all`
|
|
286
|
+
cap or exhaust a _paged_ query; on the ~90 commands that print a receipt (every mutation, `issue
|
|
287
|
+
id`, `commands`, …) all three used to vanish without a word. That silence cost data: schpet's `-f`
|
|
288
|
+
is `--description-file` on `project create`, so `linear project create --name X -f desc.md`
|
|
289
|
+
parsed `-f` as _our_ `--fields`, created the project with **no description**, and exited 0. Now a
|
|
290
|
+
root `preAction` hook (`assertGlobalsApply` in `lib/options.ts`, driven by two applicability
|
|
291
|
+
tables keyed by command path — `FIELDS_COMMANDS`, `LIMIT_COMMANDS`) rejects them before the
|
|
292
|
+
action runs, with a usage error that names the command and, where the command has one, the flag
|
|
293
|
+
the user was reaching for: "--fields does not apply to `linear project create` … use
|
|
294
|
+
--description-file <path> or --content-file <path> (-f is --fields here, not a file)";
|
|
295
|
+
`project create -n 5` gets "-n is --limit here; the name is --name". Every renderer and paged
|
|
296
|
+
query is unchanged; the tables are pinned against the real program tree so a rename shows up
|
|
297
|
+
in the tests, and a renderer missing from the table fails loudly (its `--fields` is refused),
|
|
298
|
+
never quietly. `--team` is deliberately _not_ guarded this way: `alias lin='linear -t TES'` is a
|
|
299
|
+
real usage pattern and must keep working on workspace-scoped commands. Not adopted: a local
|
|
300
|
+
`-f = --description-file` on the four commands — one letter, two meanings (see MIGRATING.md §5).
|
|
301
|
+
- **`project create -t/--team` collects, as `--teams` does** (TES-637 (3)). The global `--team` is
|
|
302
|
+
single-valued, and on `project create` it named the project's team — so schpet's repeatable
|
|
303
|
+
`--team A --team B` created the project in **B alone**. `--team` is now declared locally there,
|
|
304
|
+
repeatable and comma-separated, one list with `--teams`; both flags at once is a usage error
|
|
305
|
+
("Pass either --teams or --team, not both"). `project update --team` stays refused (that list
|
|
306
|
+
_replaces_ the project's teams; the message points at `--teams`).
|
|
307
|
+
- **`user list --all` / `team members --all` say what `--all` did not do** (TES-637 (1)). schpet's
|
|
308
|
+
`--all` there means "include inactive members"; ours is the global "exhaust pagination", and
|
|
309
|
+
deactivated users are `--include-disabled`. Same line, both exit 0, different rows. `--all` keeps
|
|
310
|
+
its one meaning; the listing prints a stderr warning — even under `--quiet`, since a script is
|
|
311
|
+
where a wrong result set goes unnoticed — that deactivated users are still excluded and names
|
|
312
|
+
`--include-disabled`; silent when that flag is present; `--help` says so up front on both.
|
|
313
|
+
- **`issue start` moves the issue to the first `started` state by default** (TES-637 (4)).
|
|
314
|
+
schpet/linear-cli always does after branching; ours only did with `--move`, so a transplanted
|
|
315
|
+
`linear issue start TES-1` checked the branch out and left the issue in Backlog without a word.
|
|
316
|
+
"Start" moving the issue is what the word means and what an agent that says "start" expects,
|
|
317
|
+
and the change is visible (✓ Moved … on stderr, `stateChanged` in the JSON). `--no-move` opts
|
|
318
|
+
out (branch only); `--move` is still accepted, hidden; `--state <name>` still picks the state;
|
|
319
|
+
`--state` with `--no-move` is a usage error.
|
|
320
|
+
- **`issue describe` prints schpet's commit message, byte for byte; `issue pull-request` titles
|
|
321
|
+
the PR `ID Title` and stops copying the issue description into GitHub** (TES-637 (5)).
|
|
322
|
+
`describe` used to print `Title`, a blank line, `Fixes ID`; it now prints `ID Title`, a blank
|
|
323
|
+
line, `Linear-issue: Fixes ID`, `Linear-issue-url: <url>` — git trailers that `git
|
|
324
|
+
interpret-trailers` / `git log --format=%(trailers:key=Linear-issue)` and jj's `trailers`
|
|
325
|
+
template read back, while Linear still sees the magic word directly before the id
|
|
326
|
+
(linear.app/docs/github lists `fixes` and `references` among the recognized words) and links
|
|
327
|
+
and closes the issue. `-r/--references` swaps the word. `--json` gains `url` and `message` (the
|
|
328
|
+
full text as printed). `pull-request`: title `<ID> <title>` (a custom `--title` is prefixed the
|
|
329
|
+
same way, as there); body the same two trailers — schpet sends the bare URL and relies on the
|
|
330
|
+
branch name for the link, the `Fixes` line keeps the link (and auto-close) when the branch was
|
|
331
|
+
renamed. The issue _description_ is no longer pasted into the PR body: a GitHub PR is a wider
|
|
332
|
+
audience than a Linear issue, and Linear links from the trailer, not the prose.
|
|
333
|
+
- **A team-scoped listing with no team says it is listing every team's** (TES-637 (8)). `issue
|
|
334
|
+
list`/`mine`/`search` and `project list` with no `--team`, no configured team and no
|
|
335
|
+
`--all-teams` list the whole workspace — by design; schpet errors there ("No default team…"), so
|
|
336
|
+
someone arriving from it could read the workspace as the team. One `info` line on stderr ("No
|
|
337
|
+
default team configured; listing every team's. Pass --team <KEY> …"), silenced by `--quiet`,
|
|
338
|
+
never on `--json` stdout.
|
|
339
|
+
- **`--fields` is one projection, applied and validated in both modes** (TES-635 (1)). It was
|
|
340
|
+
validated only in human list mode — `--fields nope --json` exited 0 with every key while
|
|
341
|
+
`--fields nope` exited 2 — ignored on detail views entirely, and able to pick only among the
|
|
342
|
+
table's default columns although the row carried more (`issue list --fields id,title,labels` was
|
|
343
|
+
`Unknown field 'labels'`). Now: on a human table, a field is a column key/header **or any row
|
|
344
|
+
key** (`labels`, `project`, `url`, `updatedAt`, …), rendered readably (arrays comma-joined, a
|
|
345
|
+
relation object by its name); on a human detail block, a field is one of its labelled lines
|
|
346
|
+
(`--fields state,url`); under `--json`, a field is a top-level key of the object(s), kept in the
|
|
347
|
+
order asked (`issue list --fields identifier,state --json` → `[{identifier, state}]`) — smaller
|
|
348
|
+
payloads for an agent that wants three keys of fifty rows. Unknown fields are a usage error in
|
|
349
|
+
every mode, listing what there is. **The one behaviour change for scripts:** `--fields` under
|
|
350
|
+
`--json` used to be a no-op; a script that passed it and relied on getting every key back will now
|
|
351
|
+
get only the keys it named. Note the human `id` column shows the identifier (`TES-42`) while the
|
|
352
|
+
JSON `id` key is the UUID, as it always has been.
|
|
353
|
+
- **Detail JSON carries relations as objects, not display strings** (TES-627 — a deliberate
|
|
354
|
+
JSON-contract change). `issue view --json` used to flatten every relation:
|
|
355
|
+
`team: "TES Test-workspace-bla"` (not even parseable — team names contain spaces),
|
|
356
|
+
`cycle: "#3 name"`, `assignee`/`project`/`milestone`/`parent` as bare display names with no ids,
|
|
357
|
+
`labels` as names only — while `issue list --json` carried `state: {name,type}` objects, so the
|
|
358
|
+
same field had two types depending on the command. Every one of those keys **stays**, and the
|
|
359
|
+
value under it becomes the object the row already used, plus the id:
|
|
360
|
+
`state: {id,name,type}`, `assignee: {id,displayName,email}`, `team: {id,key,name}`,
|
|
361
|
+
`project: {id,name}`, `milestone: {id,name}`, `cycle: {id,number,name}`,
|
|
362
|
+
`parent: {id,identifier}`, `labels: [{id,name}]`, `subscribers: [{id,displayName}]`. So
|
|
363
|
+
`.state.name` reads the same on a list row and a detail row, and an agent that wants the team
|
|
364
|
+
key, the state type or the assignee id after a `view` no longer needs a second command. Same
|
|
365
|
+
for `project view` (`status: {id,name,type}`, `lead`/`members: {id,displayName,email}`,
|
|
366
|
+
`teams: [{id,key,name}]`, `labels: [{id,name}]`) and `milestone view` (`project: {id,name}`,
|
|
367
|
+
`issues[].state: {id,name,type}`, plus `issues[].id`). The human renderings are unchanged; the
|
|
368
|
+
detail's top-level `id` is still the UUID. **If a script did `jq -r '.state'` or `.team` on a
|
|
369
|
+
view, it now needs `.state.name` / `.team.key`.**
|
|
370
|
+
- **`document view` / `document list --json` carry the target relations as objects** (TES-613,
|
|
371
|
+
the same contract change TES-627 made for issues). `document view --json` had `project`,
|
|
372
|
+
`issue`, `creator` as bare display strings; they are now `project: {id,name}`,
|
|
373
|
+
`issue: {id,identifier}`, `creator: {id,displayName}`, joined by `initiative: {id,name}`,
|
|
374
|
+
`team: {id,key,name}`, `cycle: {id,number,name}`, `release: {id,name,version}` — the one that
|
|
375
|
+
is set, the rest `null` — and a list row carries the same six keys (its `project` gains `id`).
|
|
376
|
+
The human renderings are unchanged. **If a script did `jq -r '.project'` on `document view`, it
|
|
377
|
+
now needs `.project.name`.** Both now come from one tailored request each: the SDK's `Document`
|
|
378
|
+
model has no `team`/`cycle` getter (both `[Internal]` in the schema, though they work with a
|
|
379
|
+
plain API key), and the previous view spent three lazy fetches on what one query selects.
|
|
380
|
+
- **Repeating `--label` now narrows instead of broadening.** `--label bug --label regression`
|
|
381
|
+
used to return issues carrying _either_ label; it now returns only those carrying _both_.
|
|
382
|
+
Every other repeatable filter in this CLI narrows, the reference CLI narrows, and the broadening
|
|
383
|
+
reading was the surprising one — a ported script got a superset of what it asked for and no
|
|
384
|
+
error to notice. A single `--label` is unchanged, and both forms stay case-insensitive.
|
|
385
|
+
Implemented as `labels: {and: [{some: …}, …]}` — one `some` per label, because a single `some`
|
|
386
|
+
wrapping an `and` would require one label to be named two things at once and match nothing.
|
|
387
|
+
- **`--sort priority` orders by workflow state first.** It was priority alone, which interleaved
|
|
388
|
+
states and floated a backlog item above work that is actually in progress. The order is now
|
|
389
|
+
workflow state **ascending** (active work above the backlog), then priority descending
|
|
390
|
+
(no-priority last), then manual ascending. Membership is unchanged; only the ordering moved.
|
|
391
|
+
`--sort updated` and `--sort created` (which the reference lacks) are untouched.
|
|
392
|
+
The reference CLI hardcodes state _descending_ for this flag, which the API answers with Backlog
|
|
393
|
+
**before** In Progress — a Low-priority backlog item outranks an Urgent in-progress one there.
|
|
394
|
+
We match the intent (state-grouped, UI-like) rather than that payload; verified against the API.
|
|
395
|
+
- **`issue search` takes filters, and is now team-scoped by default.** Linear's `searchIssues`
|
|
396
|
+
accepts an `IssueFilter`, so search now honors the same filters as `issue list`
|
|
397
|
+
(`--state`, `--assignee`, `--project`, `--label`, `--priority`, `--cycle`,
|
|
398
|
+
`--include-archived`) — including the global `-t/--team` and the configured default team.
|
|
399
|
+
**This narrows the previous behavior**, where search always ran across the whole workspace:
|
|
400
|
+
pass the new `--all-teams` (also available on `issue list`) to get that back. `--query` and
|
|
401
|
+
`--sort` are deliberately absent from search — the term is the query, and results come back
|
|
402
|
+
relevance-ordered.
|
|
403
|
+
- **Dependencies updated to current majors** — `@linear/sdk` 87 → 89, commander 15,
|
|
404
|
+
`@inquirer/prompts` 8, TypeScript 6, eslint 10, `@types/node` 26. The Linear schema changes in
|
|
405
|
+
88/89 are additive for everything this CLI touches; no command behavior changes. TypeScript is
|
|
406
|
+
held at 6.x because typescript-eslint does not yet accept 7.
|
|
407
|
+
- **`issue archive` now confirms** before archiving, matching `issue delete` and the other
|
|
408
|
+
`archive` commands. Pass `-y/--yes` (required outside a TTY). `unarchive` stays un-gated.
|
|
409
|
+
- **Stable `id` in mutation JSON.** Every issue mutation's `--json` output now carries the stable
|
|
410
|
+
UUID `id` alongside the human `identifier`: `archive`, `unarchive`, `delete`, `subscribe`,
|
|
411
|
+
`unsubscribe`. `issue relation add/remove` now emits `issueId`/`issueIdentifier` and
|
|
412
|
+
`otherId`/`otherIdentifier`.
|
|
413
|
+
- **Bare `linear` is now consistent with `issue view`.** When an issue id is inferred from the
|
|
414
|
+
branch, `linear` (and `linear --json`) produces the exact same output as `issue view <id>`.
|
|
415
|
+
When no id can be inferred, `--json` fails with a usage error (so stdout is never non-JSON);
|
|
416
|
+
human mode still prints help.
|
|
417
|
+
- **`issue view --web --json`** now emits `{ id, identifier, url, opened: true }` after opening
|
|
418
|
+
the browser (previously it produced no JSON).
|
|
419
|
+
- **`api --paginate`** now warns on stderr when the result is truncated at the 1000-page safety
|
|
420
|
+
cap (previously it stopped silently).
|
|
421
|
+
- **`--cycle`** uses a single metavar and description everywhere (`--cycle <n>`,
|
|
422
|
+
"cycle number, id, or 'current'") across filters and create/update.
|
|
423
|
+
- **A resolution failure now points somewhere.** `resolveStateId` already listed the team's states
|
|
424
|
+
when a name did not match, and nothing else did — a miss said only that nothing matched, leaving
|
|
425
|
+
you to guess whether the name was wrong, the scope was wrong, or the thing did not exist. Every
|
|
426
|
+
resolver now ends its not-found message with either the candidates or the command that lists
|
|
427
|
+
them, and neither costs a round-trip: the resolvers that scan (team, workflow state, cycle,
|
|
428
|
+
milestone) already hold the full candidate set, so they list it — capped at 25 names, past which
|
|
429
|
+
they name the discovery command instead of pasting a wall of text. The ones that match through a
|
|
430
|
+
server-side filter (user, project, label) have no candidate set to show and name the command
|
|
431
|
+
instead of fetching one just to write an error.
|
|
432
|
+
|
|
433
|
+
### Fixed
|
|
434
|
+
|
|
435
|
+
- **`issue list`/`mine`/`search` rows omitted `milestone` and `cycle`** (TES-652). The list
|
|
436
|
+
query selected project and labels but neither relation, so `issue list --project X --json | jq
|
|
437
|
+
'group_by(.milestone.name)'` put every issue in the null bucket while `issue view` showed the
|
|
438
|
+
milestone. Rows now carry `milestone: {id, name} | null` and `cycle: {id, number, name} | null`
|
|
439
|
+
— the detail's exact object shapes, from the one existing query (two selections, no extra
|
|
440
|
+
request; `LIST_QUERY` and `SEARCH_QUERY` share the relation block so they cannot drift again).
|
|
441
|
+
The human table does not grow (it is wide already): `--fields id,milestone,cycle` selects the
|
|
442
|
+
new columns by name, an unnamed cycle shows as `#n`, and the "Unknown field" message lists them.
|
|
443
|
+
- **`$EDITOR` with arguments (`code --wait`, `subl -w`, `vim -f`, `emacsclient -t`) broke the
|
|
444
|
+
editor path with a raw ENOENT** (TES-631). `openEditor` ran the whole `$EDITOR` string as the
|
|
445
|
+
executable name. It is now split shell-style — quotes and backslashes honoured, nothing
|
|
446
|
+
expanded — into argv with the file appended, the way git/gh run it; `$VISUAL` is consulted
|
|
447
|
+
before `$EDITOR` (git's precedence; `EDITOR` used to win); a missing editor is a usage error that
|
|
448
|
+
names the program and the variable it came from; and a non-zero editor exit is a failure rather
|
|
449
|
+
than "save whatever is in the file", as in git.
|
|
450
|
+
- **`comment update` opened an empty editor, and an empty body wiped the comment** (TES-620).
|
|
451
|
+
The editor now opens on the comment's current body (fetched first, so a bad id fails before
|
|
452
|
+
anyone types), and the update refuses an empty or whitespace-only body ("Refusing to blank the
|
|
453
|
+
comment body. To remove a comment, use 'comment delete'.") and an unchanged one ("nothing to
|
|
454
|
+
update" — quitting the editor untouched is not an edit, and should not stamp `editedAt`).
|
|
455
|
+
Non-interactively, `comment update <id>` with no body is a usage error, not an editor.
|
|
456
|
+
- **`issue comment "<body>"` on a matching branch failed** (TES-619) — the README's headline
|
|
457
|
+
example. `issue comment [id] [body]` read a lone operand as the id, so `linear issue comment
|
|
458
|
+
'shipped'` on `tes-615-…` produced "No comment body provided" (and, in a terminal, opened
|
|
459
|
+
`$EDITOR` for the body first and only then rejected `'shipped'` as an id — work lost).
|
|
460
|
+
`assign` and `state` already disambiguated a lone operand; `comment` now does the same: one
|
|
461
|
+
operand that looks like an issue id is the id (body from `--body-file` or the editor), anything
|
|
462
|
+
else is the body with the id inferred from the branch. The id is settled before any editor can
|
|
463
|
+
open. Two operands, the four `add|list|update|delete` subcommands, and `--body-file` are
|
|
464
|
+
unchanged.
|
|
465
|
+
- **`-j` did not switch the error boundary to the JSON envelope** (TES-618). The boundary in
|
|
466
|
+
`src/bin/linear.ts` decided the error format by scanning `process.argv` for the literal string
|
|
467
|
+
`--json`, so the `-j` alias, bundled short flags (`-jq`) and every other spelling commander
|
|
468
|
+
accepts printed a plaintext `error: …` on stderr where a script expected
|
|
469
|
+
`{"error":{message,code}}` — an unparseable error stream for exactly the callers the envelope
|
|
470
|
+
exists for. Verified live: `linear issue view NOPE-1 -j` printed `error: No issue NOPE-1.` while
|
|
471
|
+
`--json` printed the envelope. The boundary no longer parses argv by hand: it reads the globals
|
|
472
|
+
back off the command tree commander already parsed (`parsedGlobalOptions` in `src/cli.ts`), which
|
|
473
|
+
finds `-j`/`--json`/`--debug`/`--no-ansi` wherever on the command path they sat, and works for
|
|
474
|
+
parse-time failures too because commander scans the whole argument list before reporting a bad
|
|
475
|
+
option. The contract suite now spawns the real binary against an isolated, key-less config and
|
|
476
|
+
asserts the envelope under `--json`, `-j`, `-jq` and `linear -j issue …`; before, it exercised
|
|
477
|
+
only the `Output` class, which is how this slipped past 600 tests.
|
|
478
|
+
- **`linear schema | head` no longer dumps an EPIPE stack** (TES-635 (7)). A reader that stops
|
|
479
|
+
early closes stdout under us; without a listener Bun surfaced the resulting EPIPE as an unhandled
|
|
480
|
+
stream error — a raw `EPIPE: broken pipe, write … fd: 5` on stderr and exit 1. The binary now
|
|
481
|
+
listens for it on stdout and stderr and exits quietly (0), as any Unix filter does. Also fixed in
|
|
482
|
+
passing: `project milestones` printed milestone progress ×100 like the two `milestone`
|
|
483
|
+
renderers (TES-648).
|
|
484
|
+
- **An unknown command is reported as one, with a guess; usage errors point at the right
|
|
485
|
+
`--help`; a bare group shows its help** (TES-633). The root has an action (bare `linear` shows the
|
|
486
|
+
branch's issue) and `issue` has a default subcommand (`view`), so commander's own "unknown
|
|
487
|
+
command" never fired for either: `linear issues list` was `too many arguments. Expected 0
|
|
488
|
+
arguments but got 2: issues, list.` and `linear issue lst` was `'lst' is not a valid issue id`.
|
|
489
|
+
Now: `Unknown command 'issues'. Did you mean 'issue'? Run 'linear --help' to see the commands.`
|
|
490
|
+
and `'lst' is not a valid issue id (…). Did you mean 'linear issue list'?` — a prefix (`proj`),
|
|
491
|
+
a small typo (`lable`, `isue`) or an alias (`docs`, `notif`) is close enough; two-letter aliases
|
|
492
|
+
are never guessed from, so `ab` does not "mean" `label`. `.showHelpAfterError()` was configured
|
|
493
|
+
and dead — the stderr it would have written was the one the boundary suppresses — so every
|
|
494
|
+
parse-time usage error now carries the hint itself, in both modes:
|
|
495
|
+
`unknown option '--nope'. Run 'linear issue create --help' for usage.` And a group invoked bare
|
|
496
|
+
(`linear notification`) printed only `error: (outputHelp)`, because commander wrote the group's
|
|
497
|
+
help to that same suppressed stderr; the boundary keeps what commander wrote and prints it (exit
|
|
498
|
+
2), or a one-line usage error under `--json`.
|
|
499
|
+
- **Terminal escape sequences in Linear data no longer reach the terminal** (TES-623). API text
|
|
500
|
+
was written to human output byte-for-byte: `renderTable`/`renderDetail`, the bare scalar lines
|
|
501
|
+
(`issue title`), and every status/error line. Anyone who can create an issue in a workspace could
|
|
502
|
+
put colour, cursor movement, a clear-screen, a `\r` overwrite, an OSC-8 fake hyperlink or a
|
|
503
|
+
window-title rewrite into a title, and every teammate's `linear issue list` ran it — verified live
|
|
504
|
+
on a `clitest-esc` issue whose title carried `\e[31m` and an OSC-8 link. Every string that
|
|
505
|
+
reaches a person now passes through one function (`sanitizeForTerminal`, `src/output/sanitize.ts`)
|
|
506
|
+
that strips _whole_ sequences — CSI, OSC (BEL- or ST-terminated), DCS/SOS/PM/APC, other `ESC`
|
|
507
|
+
sequences, and their 8-bit C1 spellings — so `\e[31m` vanishes rather than leaving `[31m`
|
|
508
|
+
behind, then whatever C0/C1/DEL and bidi-override characters remain (`\n` and `\t` stay: a
|
|
509
|
+
description is multi-line). It is applied in `cell()` (every table cell and detail value) and in
|
|
510
|
+
`Output.line/info/success/warn/cancelled` and the human error line. **`--json` is untouched**: JSON
|
|
511
|
+
escapes control characters itself and a script is owed the exact bytes; the contract test pins
|
|
512
|
+
both halves. While in `table.ts`: column widths are terminal columns now, not `s.length` — a CJK
|
|
513
|
+
title or one emoji used to push every column after it out of line — via Bun's `stringWidth`, and
|
|
514
|
+
truncation cuts by whole grapheme so an emoji is never split.
|
|
515
|
+
- **`milestone view|update|delete` and `state view` take names, not only UUIDs** (TES-634). The
|
|
516
|
+
by-name resolvers existed — `issue create --milestone <name> --project <p>` and `--state <name>`
|
|
517
|
+
use them — but the entity commands sent whatever they were given to `projectMilestone(id:)` /
|
|
518
|
+
`workflowState(id:)`, so a name got the API's "Could not find referenced ProjectMilestone" with
|
|
519
|
+
no hint that only ids were ever tried. `milestone view|update|delete <name> --project <p>` now
|
|
520
|
+
resolves the name inside that project (names are unique per project only, the same rule
|
|
521
|
+
`issue update --milestone` applies); a name without `--project` is a usage error that says so:
|
|
522
|
+
`'…' is not a milestone id; pass --project <name|id> to look a milestone up by name.` `state view
|
|
523
|
+
<name-or-type>` resolves against `--team` or the configured default team, exactly as
|
|
524
|
+
`issue create --state` does, and says to pass `--team` when there is neither. UUIDs still go
|
|
525
|
+
straight through.
|
|
526
|
+
- **`milestone list`/`view` printed progress ×100 — `3846%`** (TES-648, found while fixing the
|
|
527
|
+
above). `ProjectMilestone.progress` is already a percentage (`38.46`, verified live), unlike
|
|
528
|
+
`Project.progress`, which is a fraction; the milestone renderers multiplied it by 100 anyway. The
|
|
529
|
+
human output now reads `38%`; the JSON value stays as the API sends it.
|
|
530
|
+
- **Detail views are one request each, not six to sixteen** (TES-622). Lists have always used a
|
|
531
|
+
tailored GraphQL query, but every _detail_ path awaited the SDK model's lazy relation getters,
|
|
532
|
+
each its own HTTP round-trip. Measured live with a fetch counter before/after:
|
|
533
|
+
`getIssueDetail(TES-601)` **8 → 1**; `getProjectDetail(linear-sdk-cli)` **7 → 1**;
|
|
534
|
+
`getMilestoneDetail` with 13 issues **16 → 1** (it awaited `issue.state` per issue, so `-n 50`
|
|
535
|
+
on a full milestone cost ~53). Each now selects its relations in one query
|
|
536
|
+
(`CliIssueDetail`/`CliProjectDetail`/`CliMilestoneDetail`, in the services), and the unit tests
|
|
537
|
+
record `rawRequest` and assert the call count and the selection, so it cannot regress quietly.
|
|
538
|
+
Two things learned on the way and now written down in the code: Linear prices a query by its
|
|
539
|
+
worst case, so a project lookup at `first: 250` × three nested 50-item connections was refused
|
|
540
|
+
as too complex (49 975 vs. a cap of 10 000) — a name match with a second hit is already
|
|
541
|
+
"ambiguous", so it asks for two; and the milestone's issue pages are followed by cursor with the
|
|
542
|
+
milestone fields riding along, read off the first page. `updateIssue` is unchanged in this pass
|
|
543
|
+
(9 requests): its cost is in the shared resolvers (`resolveLabelIds` fetching each candidate
|
|
544
|
+
label's team, `teamStates` fetching the team then its states, and the SDK payload's lazy
|
|
545
|
+
`issue`), which are a separate change.
|
|
546
|
+
- **`issue view`/`issue list` show archived and trashed issues as such** (TES-624). A deleted
|
|
547
|
+
issue read back exactly like a live one — `issue delete TES-616` then `issue view TES-616 --json`
|
|
548
|
+
showed `state: Backlog`, exit 0 — and `--include-archived` mixed live, archived and trashed rows
|
|
549
|
+
indistinguishably. Both shapes now carry `archivedAt`, `trashed`, `startedAt`, `completedAt` and
|
|
550
|
+
`canceledAt` (`trashed` is nullable upstream and is normalised to a boolean). The human `view`
|
|
551
|
+
says so first and in capitals — `Trashed: YES (deleted 2026-08-16T15:41:08.952Z)` /
|
|
552
|
+
`Archived: YES (…)` right under the title — and the list table marks the state column
|
|
553
|
+
`Backlog (trashed)` / `Backlog (archived)`.
|
|
554
|
+
- **Rate-limit waits are announced through the Context's `Output`** rather than a bare stderr
|
|
555
|
+
writer, so the "rate limited; retrying in Ns" line honours `--quiet` like every other status line
|
|
556
|
+
and can never land on the JSON stdout a script is parsing.
|
|
557
|
+
- **Mutations could report a success the API never gave** (AUDIT.md #6). Every Linear mutation
|
|
558
|
+
answers with a payload carrying `success: Boolean!`, and almost nothing here read it. The
|
|
559
|
+
create/update paths did have a guard, but it tested whether the _entity_ came back, not whether
|
|
560
|
+
the write happened — so a `{success: false}` that still carried an entity walked through it — and
|
|
561
|
+
the deletes, archives, subscribes and notification writes discarded the payload unread. Driven
|
|
562
|
+
against a client whose every write is refused, **50 of the 51 mutating service entry points
|
|
563
|
+
resolved happily**; `comment delete` was the single one that checked. The sharpest case was
|
|
564
|
+
`issue update`, which fell back to the issue it had resolved _before_ the mutation, so a
|
|
565
|
+
`{success: false, issue: null}` printed `Updated TES-1` and exited **0** — the shell would run the
|
|
566
|
+
`&&` side of a command that changed nothing. All of it now goes through one helper
|
|
567
|
+
(`unwrapMutation` / `assertMutation`, `src/lib/mutation.ts`) that requires `success === true` and,
|
|
568
|
+
where the payload is supposed to carry an entity, that the entity is really there. The payloads
|
|
569
|
+
that genuinely carry nothing but `{success, lastSyncId}` are handled as that, rather than by
|
|
570
|
+
pretending an entity exists.
|
|
571
|
+
|
|
572
|
+
A refusal is reported as **`api`, exit 1** rather than the `usage`/exit 2 these paths used to
|
|
573
|
+
throw. Exit 2 tells a script it called the CLI wrong; the caller typed a valid command and the
|
|
574
|
+
server declined it, and the two deserve different codes. `notification read`/`snooze`/`archive`
|
|
575
|
+
now emit a receipt of what the API confirmed instead of restating the request — the command used
|
|
576
|
+
to print `✓ Marked … read` and `{"read": true}` whatever the payload said.
|
|
577
|
+
|
|
578
|
+
- **`notification read-all` claimed every notification was marked read.** It hardcoded
|
|
579
|
+
`success: true` and reported the number of _unread_ notifications it found, not the number it
|
|
580
|
+
actually marked, so a batch where some writes were refused looked identical to one where none
|
|
581
|
+
were. It now derives the aggregate: `count` is how many really went through, `attempted` how many
|
|
582
|
+
it tried, and `failed` lists the ones that did not with the API's reason. A single refusal no
|
|
583
|
+
longer aborts the rest — it is reported instead of hidden, and the human output names each one.
|
|
584
|
+
|
|
585
|
+
- **`milestone view` reported `issuesTruncated: false` while hiding issues** (AUDIT.md #5). The
|
|
586
|
+
truncation check read `pageInfo.hasNextPage` off the connection _after_ collecting from it, and
|
|
587
|
+
`fetchNext()` mutates that connection in place — so the flag described the last page fetched
|
|
588
|
+
rather than the issues the limit hid. With 180 issues at `--limit 150` it returned 150 and said
|
|
589
|
+
nothing was hidden, suppressing the `… more (use --all)` notice at exactly the moment it was
|
|
590
|
+
needed. Truncation is now a fact rather than an inference: one extra item is requested (in the
|
|
591
|
+
same page, so it costs no round-trip) and its presence is the answer. The unit test that shipped
|
|
592
|
+
alongside the bug passed because its mock's `fetchNext()` returned a _fresh object_ instead of
|
|
593
|
+
mutating — every faked connection in the suite now comes from one faithful builder
|
|
594
|
+
(`test/unit/_fakes.ts`) that appends in place and returns `this`, as the SDK does.
|
|
595
|
+
|
|
596
|
+
- **Name resolution stopped at a fixed page, so a large workspace got false `not_found`s.** The
|
|
597
|
+
resolvers that match client-side asked for a fixed `first: 100`/`first: 250` and searched only
|
|
598
|
+
what came back: a team, workflow state, cycle or milestone that existed past the cap could not be
|
|
599
|
+
resolved by name, and — worse, because it is silent — the _ambiguity_ check only ever saw a prefix,
|
|
600
|
+
so a duplicate name past the cap was invisible and the CLI picked one arbitrarily. Resolution now
|
|
601
|
+
follows the connection. The page size is 250 (Linear's maximum), so the ordinary workspace still
|
|
602
|
+
costs the single request it always did and only workspaces that really have more than 250 of
|
|
603
|
+
something pay for extra pages. The scan is bounded at 2000 rather than unbounded, and hitting the
|
|
604
|
+
bound is an honest error asking for the id — not a quiet truncation.
|
|
605
|
+
|
|
606
|
+
- **`project list --state` filtered nothing at all.** It built `state: {eq: …}`, which targets
|
|
607
|
+
Linear's deprecated legacy `Project.state` field — the API silently ignores it, so every value,
|
|
608
|
+
valid or not, returned the complete unfiltered list. It now matches the project's status by
|
|
609
|
+
**name or type**, case-insensitively (`--state 'In QA'` and `--state started` both work), and
|
|
610
|
+
`--status` is accepted as an alias for the same thing.
|
|
611
|
+
- **`issue list --label` was case-sensitive.** The filter used an exact-case `in` comparator, so
|
|
612
|
+
`--label bug` returned an empty list when the label is stored as `Bug` — wrong results with no
|
|
613
|
+
error, while label _resolution_ everywhere else matches case-insensitively. (Repeating the flag
|
|
614
|
+
now narrows the match — see Changed.)
|
|
615
|
+
- **Deactivated users were invisible.** `team members` and `user list` never sent
|
|
616
|
+
`includeDisabled`, which Linear defaults to `false` — so deactivated users were never returned
|
|
617
|
+
and the `Active` column could only ever print `yes`. Both commands now take
|
|
618
|
+
`--include-disabled` (still excluded by default), and `team members` requests a full page
|
|
619
|
+
instead of relying on the server's default page size.
|
|
620
|
+
- **An invalid configured sort was silently ignored.** `--sort` is validated by the parser, but
|
|
621
|
+
`LINEAR_ISSUE_SORT` / `sort` (`issue_sort`) in config was not: an unrecognized value fell
|
|
622
|
+
through to `updatedAt` rather than the documented `priority` default, with no warning.
|
|
623
|
+
Resolution now runs through a single validated path that errors with the valid values and
|
|
624
|
+
names where the bad value came from (the env var, or the exact config file).
|
|
625
|
+
- **`issue search --json` reported no labels.** The search path hardcoded an empty label list, so
|
|
626
|
+
the same field was populated by `issue list` and empty from `issue search`. Search now uses the
|
|
627
|
+
same tailored query as `list` and returns an identical row — which also removes an N+1
|
|
628
|
+
(state/assignee/project were fetched one issue at a time).
|
|
629
|
+
|
|
630
|
+
- **Strict `--limit`.** `--limit` now accepts only a positive integer; `--limit 0`, `--limit -1`,
|
|
631
|
+
and `--limit 12x` are usage errors instead of silently falling back to the default.
|
|
632
|
+
- **Strict `--fields`.** An unknown `--fields` name is now a usage error listing the available
|
|
633
|
+
columns, instead of silently showing all columns. (JSON output is unaffected — it stays
|
|
634
|
+
complete.) Fields also match by column header, case-insensitively.
|
|
635
|
+
|
|
636
|
+
- **Status updates.** Two new command groups post and list project/initiative status updates:
|
|
637
|
+
`project-update` (alias `pu`) and `initiative-update` (alias `iu`), each with `create <ref>` and
|
|
638
|
+
`list <ref>` (alias `ls`). `create` takes the body from `--body`, `--body-file <path>` (`-` =
|
|
639
|
+
stdin), or `--editor` ($EDITOR), and an optional `--health <onTrack|atRisk|offTrack>`; an empty
|
|
640
|
+
body is a usage error. `create` emits the new update (bare object in `--json`); `list` emits the
|
|
641
|
+
usual bare array. The reference (project or initiative) is resolved by name or id.
|
|
642
|
+
- **Git + GitHub PR workflow.** Two new issue subcommands bridge Linear and your VCS, with the
|
|
643
|
+
issue id inferred from the current branch as usual. `issue describe [id]` prints the issue
|
|
644
|
+
title plus a commit-message trailer using Linear's git magic words (`Fixes <ID>`, or
|
|
645
|
+
`References <ID>` with `-r`/`--references`) — drop it into `git commit -m "$(linear issue
|
|
646
|
+
describe)"`. `issue pull-request [id]` (alias `pr`) creates a GitHub PR via the `gh` CLI:
|
|
647
|
+
the title defaults to the issue title (`--title` to override) and the body is the issue
|
|
648
|
+
description followed by a `Fixes <ID>` trailer and the Linear URL, so the PR and issue
|
|
649
|
+
reference each other. Flags: `--base`, `--head`, `--draft`, `--web`. The created PR URL is the
|
|
650
|
+
only thing emitted to stdout (`{ url, identifier, title }` in `--json`). It never auto-pushes
|
|
651
|
+
or creates branches, and fails with clear errors when not in a git repo, when `gh` is missing,
|
|
652
|
+
or when `gh` itself fails.
|
|
653
|
+
- **Multi-workspace credentials.** Store API keys for several workspaces and switch between
|
|
654
|
+
them. New `auth` subcommands: `auth list` (configured workspaces + default), `auth default
|
|
655
|
+
<slug>` (set the default), and `auth token` (print the resolved key for scripting). `auth
|
|
656
|
+
login` now accepts the global `--workspace <slug>` (derived from the key's organization when
|
|
657
|
+
omitted) and `auth status` reports the active credential workspace. A new global
|
|
658
|
+
`--workspace <slug>` selects which stored credential to use for any command. Credentials live
|
|
659
|
+
in quoted `[workspaces."<slug>"]` tables under a top-level `default_workspace`. Credential
|
|
660
|
+
selection follows flag/`LINEAR_API_KEY` (absolute) →
|
|
661
|
+
`--workspace`/`LINEAR_WORKSPACE`/`default_workspace`, and is never steered by project
|
|
662
|
+
`.linear.toml`. When several workspaces are configured with no default, the error is deferred
|
|
663
|
+
until a command actually needs the API — so `auth list`/`default`/`login` still work.
|
|
664
|
+
- **`--no-input` did nothing** (AUDIT.md #3). It is the flag that makes the CLI safe to run
|
|
665
|
+
unattended, and it had never once had an effect. Two independent faults, either of which alone
|
|
666
|
+
was fatal. First, the key: commander stores a negation under the name with `no-` stripped, so the
|
|
667
|
+
parser wrote `input: false` while `Context` read `noInput` — a field nothing ever set, so the
|
|
668
|
+
guard was reading `undefined` forever. Second, and the reason fixing the key would not have been
|
|
669
|
+
enough: a lone negation is _also_ seeded with a default of `true` on every command it is
|
|
670
|
+
registered on, including the root program, and `optsWithGlobals()` lets ancestors overwrite
|
|
671
|
+
descendants. Because global options are usable in any position, the flag is parsed by the
|
|
672
|
+
subcommand — so the root's default `true` then overwrote the subcommand's `false` on the way into
|
|
673
|
+
`Context`. Both `--no-input` and `--no-ansi`/`--no-color` are now registered as ordinary boolean
|
|
674
|
+
flags rather than commander negations: the key is ours to choose, and there is no default, so the
|
|
675
|
+
key is absent unless the user passed the flag and nothing can clobber it.
|
|
676
|
+
- **`--json` and a non-TTY stdout now imply non-interactive.** A prompt inside a pipeline is not a
|
|
677
|
+
question, it is a hang — there is nobody at the other end to answer it. JSON output is what a
|
|
678
|
+
script or an agent asks for, so asking for it is enough to mean "do not stop and ask me"; the same
|
|
679
|
+
goes for a redirected stdout, which is where inquirer draws the prompt and would therefore write
|
|
680
|
+
the question into the caller's output. Commands that need a missing value now fail with the usual
|
|
681
|
+
usage error naming the flag to pass, instead of waiting forever.
|
|
682
|
+
- **`--no-color` broke `label create`** (AUDIT.md #4). The global terminal-colour flag and the
|
|
683
|
+
entity flag `--color <hex>` both reduced to commander's `color` attribute, so
|
|
684
|
+
`label create --name x --team TES --no-color` put `color: false` into the mutation input and
|
|
685
|
+
Linear rejected the call outright: `Variable "$input" got invalid value false at "input.color"`.
|
|
686
|
+
`project` and `roadmap` escaped the crash only because their guards happened to be truthy-based —
|
|
687
|
+
they dropped the flag instead. Beyond the crash, there was simply no way to set an entity colour
|
|
688
|
+
_and_ turn off terminal colour in one command, because one flag overwrote the other. The global
|
|
689
|
+
is now spelled **`--no-ansi`**, with `--no-color` kept as an alias on every command (it is the
|
|
690
|
+
conventional spelling, and both write the same key, so they cannot drift). Terminal colour and
|
|
691
|
+
entity colour are separate keys now, which makes the whole class of collision structurally
|
|
692
|
+
impossible rather than fixed one command at a time — a test walks the command tree and asserts no
|
|
693
|
+
option anywhere can write `false` into `color`.
|
|
694
|
+
- **`auth login` echoed the API key** (AUDIT.md #9). The prompt used inquirer's `input`, so the
|
|
695
|
+
credential appeared on screen as it was typed and then stayed in the terminal's scrollback for as
|
|
696
|
+
long as the window lived. It now uses inquirer's `password` prompt, which never renders the value.
|
|
697
|
+
`--key` is unchanged for scripts, and nothing in the command logs or echoes the key — the receipt
|
|
698
|
+
names the user and the file it was written to.
|
|
699
|
+
- **`--json --debug` produced output that could not be parsed.** The debug detail was appended
|
|
700
|
+
_after_ the error envelope as a second, plaintext block, so the one combination a caller reaches
|
|
701
|
+
for when a scripted call misbehaves was the one that broke the contract:
|
|
702
|
+
`linear … --json --debug 2>&1 | jq` died with "Invalid numeric literal". In JSON mode the detail
|
|
703
|
+
now lives inside the envelope as `error.detail`. Without `--debug`, or when the error carries no
|
|
704
|
+
detail, the key is absent and the locked `{message, code}` shape is byte-for-byte what it was.
|
|
705
|
+
- **A declined confirmation exited 0 and said nothing.** Every gated command answers a "no" with a
|
|
706
|
+
bare `return`, which produced an empty stdout and a success status — indistinguishable from a
|
|
707
|
+
delete that worked, so `linear issue delete X && …` ran the `&&` side after the user had just
|
|
708
|
+
said no. Declining now emits a cancellation receipt — `{"cancelled": true, "action": "…"}` on
|
|
709
|
+
stdout in JSON mode, a `Cancelled: …` note on stderr otherwise — and exits **6**, a code that is
|
|
710
|
+
neither success nor any of the failures, so a script can tell "you said no" apart from "it broke".
|
|
711
|
+
The receipt is emitted by `confirmDestructive` itself rather than at each of the ~14 call sites,
|
|
712
|
+
which is what makes it identical across every gated command instead of something each one has to
|
|
713
|
+
remember. (The exit code currently lives next to the prompt rather than in the `ExitCode` table
|
|
714
|
+
in `lib/errors.ts`; folding it in is a follow-up.)
|
|
715
|
+
- **Integer flags accepted values they then quietly changed.** `Number.parseInt` stops at the first
|
|
716
|
+
character it cannot use, so `--priority 1.9` became `1` and `--estimate 2junk` became `2` — a
|
|
717
|
+
different request from the one that was typed, executed without comment. A flag value must now be
|
|
718
|
+
a complete integer. Priority additionally validates the 0–4 range locally, matching the wording
|
|
719
|
+
`initiative` has used all along, so the two cannot read as different rules.
|
|
720
|
+
- **`project update --team` was accepted and ignored** (AUDIT.md #8). It is rejected now, rather
|
|
721
|
+
than implemented: a project belongs to _several_ teams, and the existing `--teams` **replaces**
|
|
722
|
+
that whole set — so quietly treating `--team TES` as `--teams TES` would remove every other team
|
|
723
|
+
from the project, which is a destructive reading of a flag the user most likely meant as "also
|
|
724
|
+
this team". The error names `--teams` and says that it replaces. The flag is declared locally on
|
|
725
|
+
the command (which is what stops the global from being injected over it) and hidden, so `--help`
|
|
726
|
+
and `linear commands --json` advertise only the flag that works. `project create --team` is
|
|
727
|
+
untouched — there it is the genuine fallback team.
|
|
728
|
+
- **`api --operation` did nothing at all** (AUDIT.md #7). It was passed as a fourth argument to the
|
|
729
|
+
SDK's `rawRequest`, which takes three — so it was discarded, and a multi-operation document went
|
|
730
|
+
to the API with no operation named. The API's answer to that is not "run the first one" but a
|
|
731
|
+
flat `The operation does not exist on the query.`, which made multi-operation documents unusable
|
|
732
|
+
and named the wrong culprit. There is no `operationName` to fix this with: the SDK's request body
|
|
733
|
+
carries only `{query, variables}`. So the selection is done before the request instead — the
|
|
734
|
+
document is parsed with `graphql` and the chosen operation, plus the fragments it uses
|
|
735
|
+
transitively, is printed as a new single-operation document. Unreferenced fragments and the other
|
|
736
|
+
operations are left behind. A document with one operation is still sent **verbatim**, so
|
|
737
|
+
formatting and comments survive and nothing changes for the common case. Choosing nothing from a
|
|
738
|
+
multi-operation document is now a usage error that lists the names, rather than a confusing error
|
|
739
|
+
from the server, and `--operation` naming an operation the document does not define is caught here
|
|
740
|
+
too.
|
|
741
|
+
- **`api --paginate` never checked what it was re-running** (AUDIT.md #10). The loop re-executes the
|
|
742
|
+
whole document once per cursor, so a _mutation_ whose payload happens to contain a paginatable
|
|
743
|
+
connection would have been executed once per page — creating duplicate entities, silently, in
|
|
744
|
+
proportion to how much data came back. It now refuses anything that is not a query, and refuses it
|
|
745
|
+
**before the first request**, so the check cannot itself cost a side effect. The kind is read from
|
|
746
|
+
the same parse that `--operation` uses, which means it is the _selected_ operation that is
|
|
747
|
+
checked, not merely the first one in the file.
|
|
748
|
+
- **`schema --json --output <file>` ignored the file.** The JSON branch returned before reaching the
|
|
749
|
+
write, so the introspection result went to stdout and no file was ever created — silently, since
|
|
750
|
+
the redirect the user asked for simply did not happen. Format and destination are independent now:
|
|
751
|
+
`--json -o f` writes introspection JSON to `f`, `-o f` alone writes SDL, and each without `-o`
|
|
752
|
+
goes to stdout. A destination that cannot be written is reported as a CLI error naming the path
|
|
753
|
+
rather than a raw `fs` throw.
|
|
754
|
+
- **A malformed config file could print an API key to the terminal.** `smol-toml`'s parse error
|
|
755
|
+
embeds an excerpt of the offending source, and we passed that message through untouched — so a
|
|
756
|
+
config truncated mid-credential (`api_key = "lin_api_…` with no closing quote) printed the key to
|
|
757
|
+
stderr, where it lands in scrollback, CI logs and bug reports. The same channel let a project
|
|
758
|
+
`.linear.toml` — a file that is not ours and may arrive with a checkout — emit raw escape
|
|
759
|
+
sequences through our error output. We now report only the reason and the position (`line 3,
|
|
760
|
+
column 36`), never an excerpt from a credential-bearing file, and strip control characters and
|
|
761
|
+
bidi overrides from what remains. The error stays actionable: it still says exactly where to look.
|
|
762
|
+
- **Credential writes could lose a config or leave a truncated one.** Writing the config in place
|
|
763
|
+
truncates it first, so a crash or a concurrently-running `linear` could observe — or leave behind
|
|
764
|
+
— a half-written file, which for this file means every stored credential gone. The write now goes
|
|
765
|
+
to a temp file in the same directory and is renamed over the target, which POSIX makes atomic: a
|
|
766
|
+
reader sees either the old config or the new one. The temp file is created `0600` so the key is
|
|
767
|
+
never briefly world-readable, and the final file is still asserted to `0600` rather than
|
|
768
|
+
inheriting whatever was there.
|
|
769
|
+
|
|
770
|
+
### Changed
|
|
771
|
+
|
|
772
|
+
- **Bun-only distribution.** The CLI now ships as raw TypeScript and runs directly on
|
|
773
|
+
[Bun](https://bun.sh) (≥ 1.1) — no build step, no bundle, no Node. Install with
|
|
774
|
+
`bun add -g linear-sdk-cli`. The toolchain (install, test, run) is Bun end-to-end.
|
|
775
|
+
- **BREAKING: `label create --workspace` renamed to `--shared`.** The boolean that forces a
|
|
776
|
+
workspace-level (shared) label collided with the new global `--workspace <slug>` credential
|
|
777
|
+
selector. Use `linear label create --shared` instead.
|
|
778
|
+
- **BREAKING: `project updates <id>` removed.** Use `project-update list <project>` (alias
|
|
779
|
+
`pu ls`), the canonical replacement, instead.
|
|
780
|
+
- **BREAKING: consistent date flags.** `cycle create`/`update` now take `--start`/`--end`
|
|
781
|
+
(was `--startsAt`/`--endsAt`), and `initiative create`/`update` now take `--target` (was
|
|
782
|
+
`--target-date`). These align with the `--target`/`--start`/`--end` already used by `project`
|
|
783
|
+
and `milestone`, removing the lone camelCase pair. The accepted date/ISO values are unchanged.
|
|
784
|
+
- **`roadmap delete` alias is now `rm`** (was `del`), matching the `rm` delete alias used by
|
|
785
|
+
every other group.
|
|
786
|
+
|
|
787
|
+
## [0.1.0]
|
|
788
|
+
|
|
789
|
+
Initial release. An ergonomic CLI for Linear built on `@linear/sdk`, with human-readable output
|
|
790
|
+
by default and a stable `--json` mode for scripts and agents.
|
|
791
|
+
|
|
792
|
+
### Added
|
|
793
|
+
|
|
794
|
+
- **Issues** — view, list, search, create, update, delete, archive/unarchive, `start`
|
|
795
|
+
(git-branch checkout + optional state move), assign, state transitions, label management,
|
|
796
|
+
comments, relations (both directions), subscribe/unsubscribe, and `id`/`title`/`url`/`branch`
|
|
797
|
+
helpers. Git-branch awareness infers the current issue from the branch name.
|
|
798
|
+
- **Teams, projects, milestones, cycles** — listing, detail, and CRUD where appropriate.
|
|
799
|
+
- **Users, labels, workflow states, comments, documents, attachments, favorites** — the
|
|
800
|
+
supporting resource graph.
|
|
801
|
+
- **Initiatives, roadmaps, notifications, organization, webhooks** — extended resources.
|
|
802
|
+
- **`linear api`** — raw GraphQL escape hatch (query/mutation from arg/file/stdin, variables,
|
|
803
|
+
named operations, `--paginate`).
|
|
804
|
+
- **Config & auth** — hierarchical config (`.linear.toml` + env + flags) with a strict API-key
|
|
805
|
+
boundary (never read from project files) and redaction.
|
|
806
|
+
- **Shell completion** for bash, zsh, and fish.
|
|
807
|
+
- **Coverage audit** classifying every `LinearClient` member, gated against a committed snapshot.
|