linear-sdk-cli 0.7.1 → 0.8.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/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ Release Please generates this file from Conventional Commits. Feature and fix pu
4
4
  not edit it directly; their squash-commit titles are the release-note source of truth. This project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.8.0](https://github.com/zhendalf/linear-sdk-cli/compare/v0.7.2...v0.8.0) (2026-09-29)
8
+
9
+ ### Features
10
+
11
+ - **auth:** persist app identity credentials ([#84](https://github.com/zhendalf/linear-sdk-cli/issues/84)) ([7d7ec19](https://github.com/zhendalf/linear-sdk-cli/commit/7d7ec19cb94cf43fb524dc1dd807b4d7d1c67515))
12
+
13
+ ### Bug Fixes
14
+
15
+ - improve CLI onboarding and JSON error consistency ([#59](https://github.com/zhendalf/linear-sdk-cli/issues/59)) ([4444c38](https://github.com/zhendalf/linear-sdk-cli/commit/4444c38f05bc4f442da1e44582eafb35b8e342d1))
16
+
17
+ ## [0.7.2](https://github.com/zhendalf/linear-sdk-cli/compare/v0.7.1...v0.7.2) (2026-09-28)
18
+
19
+ ### Bug Fixes
20
+
21
+ - **deps:** complete SDK and formatter upgrades ([#82](https://github.com/zhendalf/linear-sdk-cli/issues/82)) ([9ba7d6f](https://github.com/zhendalf/linear-sdk-cli/commit/9ba7d6ff77a1d7e9d63bc1d4e4957aed5d6a1ed4))
22
+ - **deps:** update compatible dependency versions ([6e3e462](https://github.com/zhendalf/linear-sdk-cli/commit/6e3e46245088eb923886f6c29c3f8ec7d463ffa6))
23
+
7
24
  ## [0.7.1](https://github.com/zhendalf/linear-sdk-cli/compare/v0.7.0...v0.7.1) (2026-09-20)
8
25
 
9
26
  ### Bug Fixes
package/COVERAGE.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # SDK Coverage Audit
2
2
 
3
- Generated by `bun run audit:coverage`. Total LinearClient members: **476**.
3
+ Generated by `bun run audit:coverage`. Total LinearClient members: **480**.
4
4
 
5
5
  | Status | Count | Meaning |
6
6
  | -------- | ----- | ----------------------------------------------- |
7
7
  | curated | 107 | Has a first-class CLI command |
8
- | raw-only | 144 | Reachable via `linear api` (no bespoke command) |
9
- | excluded | 225 | Out of scope (admin/integration/SDK plumbing) |
8
+ | raw-only | 145 | Reachable via `linear api` (no bespoke command) |
9
+ | excluded | 228 | Out of scope (admin/integration/SDK plumbing) |
10
10
 
11
11
  Every member is classified — CI fails otherwise. `raw-only` members are still
12
12
  100% reachable through the raw GraphQL escape hatch; they simply lack a tailored command.
@@ -125,7 +125,7 @@ Every member is classified — CI fails otherwise. `raw-only` members are still
125
125
  - `workflowState` — state
126
126
  - `workflowStates` — state
127
127
 
128
- ## Excluded (225)
128
+ ## Excluded (228)
129
129
 
130
130
  - `agentActivities` — specialized/raw api only
131
131
  - `agentActivity` — specialized/raw api only
@@ -235,7 +235,10 @@ Every member is classified — CI fails otherwise. `raw-only` members are still
235
235
  - `integrationGithubImportRefresh` — integration/import (raw api only)
236
236
  - `integrationGithubRemoveCodeAccess` — integration/import (raw api only)
237
237
  - `integrationGitlabConnect` — integration/import (raw api only)
238
+ - `integrationGitlabRotate` — integration/import (raw api only)
238
239
  - `integrationGitlabTestConnection` — integration/import (raw api only)
240
+ - `integrationGitlabUpdateRotationSettings` — integration/import (raw api only)
241
+ - `integrationGitlabUpdateToken` — integration/import (raw api only)
239
242
  - `integrationGong` — integration/import (raw api only)
240
243
  - `integrationGoogleSheets` — integration/import (raw api only)
241
244
  - `integrationHasScopes` — integration/import (raw api only)
@@ -353,7 +356,7 @@ Every member is classified — CI fails otherwise. `raw-only` members are still
353
356
  - `userUnlinkFromIdentityProvider` — admin/enterprise (raw api only)
354
357
  - `userViewPreferences` — specialized/raw api only
355
358
 
356
- ## Raw-only (144)
359
+ ## Raw-only (145)
357
360
 
358
361
  <details><summary>Show</summary>
359
362
 
@@ -405,6 +408,7 @@ Every member is classified — CI fails otherwise. `raw-only` members are still
405
408
  - `deleteTeamCycles`
406
409
  - `deleteTeamKey`
407
410
  - `deleteTeamMembership`
411
+ - `dependencyPackageMetadata`
408
412
  - `documentContentHistory`
409
413
  - `emailIntakeAddress`
410
414
  - `emailIntakeAddressRefreshSesDomainStatus`
package/README.md CHANGED
@@ -121,9 +121,10 @@ branch, so most issue commands let you omit the id entirely.
121
121
  For a person at a terminal, `linear auth login` uses browser Authorization Code + PKCE and the
122
122
  default `actor=user`. Existing personal API keys and invocation-scoped OAuth access tokens remain
123
123
  supported. Explicit `--api-key` or `--access-token` flags override environment credentials;
124
- without a credential flag the CLI reads `LINEAR_API_KEY` or `LINEAR_ACCESS_TOKEN`. If both kinds
125
- are supplied at the same precedence level, the CLI fails rather than silently choosing a Linear
126
- actor.
124
+ without a credential flag the CLI reads `LINEAR_API_KEY`, `LINEAR_ACCESS_TOKEN`, or the pair
125
+ `LINEAR_CLIENT_ID` / `LINEAR_CLIENT_SECRET`. Mixing credential types in the environment fails
126
+ rather than silently choosing a Linear actor. Client credentials must be supplied together;
127
+ explicit credential flags still override all environment credentials.
127
128
 
128
129
  > **Credential trust boundary.** A secret is **never** read from a project-local
129
130
  > `.linear.toml` — only non-secret settings live there — so a key can't be committed by accident.
@@ -159,7 +160,7 @@ store lock. `--admin` explicitly adds the admin scope; it is never implicit.
159
160
  `--no-browser` is useful over SSH only when the machine running the CLI is also reachable as the
160
161
  browser's loopback host; Linear does not currently document a device-code flow. Override a
161
162
  separately registered public client's identity with `LINEAR_OAUTH_CLIENT_ID` and its exact callback
162
- with `LINEAR_OAUTH_REDIRECT_URI`. No OAuth client secret is embedded, accepted, or stored.
163
+ with `LINEAR_OAUTH_REDIRECT_URI`. No OAuth client secret is embedded or used for browser login.
163
164
 
164
165
  For a personal API key, `auth login --key -` reads stdin, validates the viewer/workspace, stores the
165
166
  key in the system keyring by default, and writes only a marker to the config. `--plaintext` is an
@@ -209,8 +210,7 @@ await child.exited;
209
210
 
210
211
  Call `tokens.invalidate()` (or `getAccessToken({ forceRefresh: true })`) before one bounded retry
211
212
  when Linear rejects the token with `401`. Client-credentials tokens do not have refresh tokens;
212
- renewal is another authenticated exchange. A short-lived/serverless process should use a secure
213
- shared token cache or central broker rather than minting a new 30-day token on every invocation.
213
+ renewal is another authenticated exchange. For CI and scheduled automation, request a fresh token at the start of each run, as Linear recommends.
214
214
 
215
215
  For an app installed into multiple workspaces, use Authorization Code with `actor=app` instead and
216
216
  store each installation's rotating refresh token encrypted in the host. That hosted app lifecycle
@@ -223,13 +223,64 @@ webhooks. App actors cannot request the `admin` scope. See Linear's
223
223
  [app-actor authorization](https://linear.app/developers/oauth-actor-authorization), and
224
224
  [agent guide](https://linear.app/developers/agents).
225
225
 
226
- Hosted app tokens supplied through `LINEAR_ACCESS_TOKEN` remain invocation-scoped. The only OAuth
227
- tokens persisted by the CLI are human browser-login sessions, stored as an isolated per-workspace
228
- keyring record; the CLI never persists or uses an OAuth client secret.
226
+ For persistent app identity on a local machine, enable client credentials in your Linear OAuth
227
+ app, then sign in once:
228
+
229
+ ```sh
230
+ # Supply the secret through stdin from your secret manager.
231
+ linear auth login --app --client-id YOUR_CLIENT_ID --client-secret -
232
+ linear auth status --json
233
+ linear issue list --json
234
+ ```
235
+
236
+ `--client-credentials` is an alias for `--app`. `--client-secret -` reads stdin until EOF;
237
+ otherwise the client ID and secret default to `LINEAR_CLIENT_ID` and `LINEAR_CLIENT_SECRET`.
238
+ The client ID also accepts the existing `LINEAR_OAUTH_CLIENT_ID` fallback during explicit login.
239
+ Login validates the token and workspace before storing credentials.
240
+
241
+ By default, app credentials go into an isolated `app:<workspace>` OS keyring account; config files
242
+ contain only a marker. On headless hosts, explicitly select private-file storage:
243
+
244
+ ```sh
245
+ linear auth login --client-credentials --plaintext --client-id YOUR_CLIENT_ID --client-secret -
246
+ # In a new shell, under the same user and config directory:
247
+ linear auth status --json
248
+ linear issue list --json
249
+ ```
250
+
251
+ `--plaintext` stores client credentials in `$XDG_CONFIG_HOME/linear/config.toml` (or
252
+ `~/.config/linear/config.toml`) with `0600` permissions, and makes its `linear` directory private
253
+ with `0700` permissions. There is no automatic plaintext fallback for app credentials. The directory
254
+ must be on persistent storage to survive container replacement, and agent subprocesses must have
255
+ access to the same user config. Switching storage removes the previous copy of the app credentials;
256
+ replacing an existing keyring profile requires access to that keyring.
257
+
258
+ Use `--read-only` to request only `read`, or `--scope read,comments:create` for custom app scopes
259
+ (default: `read,write`). App actors cannot request `--admin` or an `admin` scope. Linear must have
260
+ client credentials enabled for the app. Changing scopes can revoke existing app tokens, so use
261
+ the same scopes across hosts.
262
+
263
+ For environment-only authentication, supply `LINEAR_CLIENT_ID` and `LINEAR_CLIENT_SECRET` from
264
+ your platform's secret manager, then invoke `linear` directly. Optional `LINEAR_CLIENT_SCOPES`
265
+ configures the comma-separated scopes. This mode overrides stored profiles and never writes
266
+ credentials or tokens to config or cache files. Only an explicit `auth login --client-credentials`
267
+ persists these environment credentials.
268
+
269
+ Each invocation obtains a fresh token on its first API request, caches it in memory, renews before
270
+ expiry, and retries an API request once with a new token after an authentication failure. This
271
+ covers both SDK calls and raw GraphQL queries. Concurrent processes obtain their own tokens and do
272
+ not share a disk token cache; within one process concurrent token exchanges are coalesced. No access
273
+ or refresh token is persisted for app identity. `auth status` reports credential availability
274
+ without making a network request, uses credential type `oauth-app` with source `user`, `keychain`,
275
+ or `env`, and leaves `expiresAt` null because it has no stored token. It does not prove that Linear
276
+ still accepts the client secret. Environment scopes describe configured requests, not independently
277
+ verified grants. `auth token` continues to export personal API keys only. `auth logout` removes
278
+ local app credentials and restores an existing personal API-key profile if present; it does not
279
+ disable the app in Linear. Environment-only credentials must be unset separately.
229
280
 
230
281
  ### Multiple workspaces
231
282
 
232
- Credentials are stored per **workspace slug**. Browser and API-key login both derive the slug from
283
+ Credentials are stored per **workspace slug**. Browser, app, and API-key login derive the slug from
233
284
  the authenticated organization; `--workspace` validates that the result matches:
234
285
 
235
286
  ```sh
@@ -243,7 +294,7 @@ linear --workspace other-org issue list # use a specific workspace for one co
243
294
  linear auth logout --workspace acme # revoke OAuth, then remove one credential
244
295
  ```
245
296
 
246
- Successful browser and API-key login also save `workspace = "<authenticated slug>"` in the discovered project config, preserving other settings and comments. If no config exists, login creates `.linear.toml` at the Git root (or cwd outside Git). Use `auth login --no-project` for credential-only login. Human output reports the association path; JSON includes `projectConfigPath` (null with `--no-project`). Credentials remain in the global credential store. Environment overrides still take precedence over this association.
297
+ Successful browser, app, and API-key login also save `workspace = "<authenticated slug>"` in the discovered project config, preserving other settings and comments. If no config exists, login creates `.linear.toml` at the Git root (or cwd outside Git). Use `auth login --no-project` for credential-only login. Human output reports the association path; JSON includes `projectConfigPath` (null with `--no-project`). Credentials remain in the global credential store. Environment overrides still take precedence over this association.
247
298
 
248
299
  Global defaults are optional: login, credential adoption, and logout never choose a new one. Use `linear auth default <slug>` to explicitly select a global fallback. Removing that workspace clears its default without promoting another workspace. Existing defaults, including defaults imported from the reference CLI, are preserved on upgrade because older files cannot distinguish a user choice from an automatically assigned default. To opt out of a legacy default, remove the top-level `default_workspace` from the user config and, if present, `default` from the reference CLI's `credentials.toml`; retain the workspace entries.
249
300
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linear-sdk-cli",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Ergonomic command-line interface for Linear, built on @linear/sdk",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -71,18 +71,18 @@
71
71
  },
72
72
  "dependencies": {
73
73
  "@inquirer/prompts": "^8.7.2",
74
- "@linear/sdk": "^95.1.0",
74
+ "@linear/sdk": "^97.0.0",
75
75
  "commander": "^15.0.0",
76
76
  "graphql": "^17.0.2",
77
77
  "picocolors": "^1.1.1",
78
- "smol-toml": "^1.8.0"
78
+ "smol-toml": "^1.9.0"
79
79
  },
80
80
  "packageManager": "bun@1.4.2",
81
81
  "devDependencies": {
82
82
  "@types/bun": "^1.4.2",
83
- "@types/node": "^26.6.2",
84
- "oxfmt": "^0.68.0",
85
- "oxlint": "^1.83.0",
83
+ "@types/node": "^26.6.3",
84
+ "oxfmt": "^0.71.0",
85
+ "oxlint": "^1.86.0",
86
86
  "typescript": "^7.0.2"
87
87
  }
88
88
  }
@@ -66,17 +66,43 @@ The CLI supports distinct human, hosted-app, and personal-key lifecycles:
66
66
  the 30-day app token in memory, renews before expiry, coalesces concurrent exchanges, and
67
67
  supports invalidation or forced renewal for one bounded retry after a `401`. Keep the client
68
68
  secret in the host's secret manager and inject only `LINEAR_ACCESS_TOKEN` into CLI children.
69
- Serverless hosts need a secure shared token cache or broker so cold starts do not mint a new
70
- token for every command.
69
+ For CI and scheduled automation, request a fresh token at the start of each run.
71
70
 
72
- 3. **`--api-key <key>`** flag (per invocation) or **`LINEAR_API_KEY`** env var — best
71
+ 3. **Persistent app identity** with client credentials (enable this grant in the Linear app):
72
+
73
+ ```bash
74
+ linear auth login --app --client-id YOUR_CLIENT_ID --client-secret -
75
+ linear auth status --json
76
+ ```
77
+
78
+ `--client-credentials` is an alias for `--app`. Read the secret from stdin until EOF, or
79
+ explicitly log in using `LINEAR_CLIENT_ID` / `LINEAR_CLIENT_SECRET`. The default store is
80
+ the OS keyring; on headless hosts use explicit `--plaintext` to store client credentials in
81
+ the user config (`0600`, containing directory `0700`). Keep that directory on persistent
82
+ storage in containers and make it accessible to agent subprocesses under the same user.
83
+ There is no automatic plaintext fallback.
84
+
85
+ Commands obtain a fresh token per invocation and renew before expiry or once on authentication
86
+ failure, including raw GraphQL requests. Tokens remain in memory. Use `--read-only` for read
87
+ access or `--scope read,comments:create` for custom scopes (default `read,write`); app actors
88
+ cannot request `admin`. Keep scopes consistent across hosts to avoid revoking existing tokens.
89
+ Status reports `oauth-app`, the source, and configured scopes without validating credentials
90
+ remotely; expiry is null because there is no persisted token. Logout removes local app
91
+ credentials without disabling the app. `auth token` still exports personal API keys only.
92
+
93
+ Environment-only app auth also works directly: supply `LINEAR_CLIENT_ID` and
94
+ `LINEAR_CLIENT_SECRET` together, optionally `LINEAR_CLIENT_SCOPES`, then run `linear`.
95
+ It writes no config or cache files. Mixing these with environment API keys/access tokens
96
+ is rejected; explicit `--api-key` or `--access-token` flags override environment credentials.
97
+
98
+ 4. **`--api-key <key>`** flag (per invocation) or **`LINEAR_API_KEY`** env var — best
73
99
  for CI and ephemeral agent runs:
74
100
 
75
101
  ```bash
76
102
  LINEAR_API_KEY=lin_api_... linear whoami --json
77
103
  ```
78
104
 
79
- 4. **Stored personal API-key credentials** via the explicit compatibility path:
105
+ 5. **Stored personal API-key credentials** via the explicit compatibility path:
80
106
 
81
107
  ```bash
82
108
  printf '%s\n' "$LINEAR_API_KEY" | linear auth login --key -
@@ -85,7 +111,7 @@ The CLI supports distinct human, hosted-app, and personal-key lifecycles:
85
111
  Passing `--key <value>` also works but exposes it in argv and produces a warning. API keys may
86
112
  use `--plaintext`; browser OAuth is always keyring-only.
87
113
 
88
- 5. **Multiple workspaces** — store several OAuth or API-key credentials and select one per call with
114
+ 6. **Multiple workspaces** — store several OAuth or API-key credentials and select one per call with
89
115
  `--workspace <slug>`; set a default with `linear auth default <slug>`:
90
116
 
91
117
  ```bash
@@ -127,11 +153,12 @@ stderr), so it is always safe to pipe into `jq`. The envelope is a stable contra
127
153
  `--debug` the extra detail rides _inside_ that object as `error.detail`, so `--json --debug`
128
154
  stays parseable.
129
155
 
130
- Relations are **objects with ids**, on list rows and on `view` alike — `state: {id,name,type}`,
131
- `team: {id,key,name}`, `assignee: {id,displayName,email}`, `project`/`milestone: {id,name}`,
132
- `cycle: {id,number,name}`, `parent: {id,identifier}`, `labels: [{id,name}]` — so `.state.name`
133
- reads the same everywhere and the id you need to act on is already in hand. Issues also carry
134
- `archivedAt` and `trashed`; a deleted issue still views, and says so.
156
+ Issue list rows use compact relations: `state: {name,type}`, `assignee: {displayName}`,
157
+ `project: {name}`, and `labels: string[]`. They do not include `team` or `parent`.
158
+ `issue view` returns richer relations with IDs, including `team`, `parent`, and label
159
+ objects. Use `issue view <identifier> --json` when you need those relation IDs;
160
+ inspect `linear commands issue list --json` or `linear commands issue view --json`
161
+ for the exact shape. Both include `archivedAt` and `trashed`.
135
162
 
136
163
  ```bash
137
164
  linear issue list --json | jq -r '.[].identifier'
@@ -612,7 +639,7 @@ GRAPHQL
612
639
  linear api --query-file q.graphql --vars-file vars.json --paginate
613
640
 
614
641
  # Pipe data straight to jq (status notes go to stderr)
615
- linear api '{ issues(first: 5) { nodes { identifier title } } }' | jq '.data.issues.nodes[].title'
642
+ linear api '{ issues(first: 5) { nodes { identifier title } } }' | jq '.issues.nodes[].title'
616
643
  ```
617
644
 
618
645
  `api` flags: `--query-file <path>` (`-` = stdin), `--var <k=v>` (repeatable string
@@ -75,17 +75,21 @@ Authenticate and select the workspace for this project
75
75
  linear auth login [options]
76
76
  ```
77
77
 
78
- | Option | Description |
79
- | ---------------------- | --------------------------------------------------------------------- |
80
- | `--no-project` | save credentials without changing the project workspace |
81
- | `--key <key>` | use a personal API key ('-' reads it from stdin) |
82
- | `--plaintext` | Store the key in the config file (0600) instead of the system keyring |
83
- | `--no-browser` | print the authorization URL instead of opening it |
84
- | `--read-only` | request read-only OAuth access |
85
- | `--admin` | explicitly add the OAuth admin scope |
86
- | `--timeout <seconds>` | seconds to wait for the loopback callback |
87
- | `--client-id <id>` | OAuth client ID (defaults to the packaged CLI app) |
88
- | `--redirect-uri <uri>` | registered HTTP loopback callback URI |
78
+ | Option | Description |
79
+ | -------------------------- | ----------------------------------------------------------------------- |
80
+ | `--no-project` | save credentials without changing the project workspace |
81
+ | `--key <key>` | use a personal API key ('-' reads it from stdin) |
82
+ | `--plaintext` | Store API-key or app credentials in the private user config file (0600) |
83
+ | `--no-browser` | print the authorization URL instead of opening it |
84
+ | `--read-only` | request read-only OAuth access |
85
+ | `--admin` | explicitly add the OAuth admin scope |
86
+ | `--timeout <seconds>` | seconds to wait for the loopback callback |
87
+ | `--app` | sign in as an app using client credentials |
88
+ | `--client-credentials` | alias for --app |
89
+ | `--scope <scopes>` | comma-separated app scopes (default: read,write) |
90
+ | `--client-secret <secret>` | app client secret ('-' reads stdin; defaults to LINEAR_CLIENT_SECRET) |
91
+ | `--client-id <id>` | OAuth client ID (app: LINEAR_CLIENT_ID; browser: packaged CLI app) |
92
+ | `--redirect-uri <uri>` | registered HTTP loopback callback URI |
89
93
 
90
94
  **Output (`--json`)**: a receipt object
91
95
 
package/src/bin/linear.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { CommanderError } from "commander";
9
- import { createProgram, parsedGlobalOptions, usageHint, suppressedHelp } from "../cli.js";
9
+ import { createProgram, errorGlobalOptions, usageHint, suppressedHelp } from "../cli.js";
10
10
  import { Output } from "../output/format.js";
11
11
  import { CliError, ExitCode, normalizeError } from "../lib/errors.js";
12
12
  import { commandPath } from "../lib/options.js";
@@ -31,6 +31,8 @@ program.parseAsync(process.argv).catch((err) => {
31
31
  if (err.exitCode === 0) process.exit(0);
32
32
  }
33
33
 
34
+ const globals = errorGlobalOptions(program, process.argv.slice(2));
35
+
34
36
  // A group invoked bare (`linear notification`) is commander asking to show
35
37
  // that group's help and exit non-zero. It wrote the help to the stderr we
36
38
  // route through here, so print exactly that for a human — an envelope
@@ -38,7 +40,7 @@ program.parseAsync(process.argv).catch((err) => {
38
40
  // usage error under --json.
39
41
  if (err instanceof CommanderError && err.code === "commander.help") {
40
42
  const help = suppressedHelp(program);
41
- if (help && parsedGlobalOptions(program).json !== true) {
43
+ if (help && globals.json !== true) {
42
44
  process.stderr.write(help.text);
43
45
  process.exit(ExitCode.Usage);
44
46
  }
@@ -46,11 +48,8 @@ program.parseAsync(process.argv).catch((err) => {
46
48
 
47
49
  const cliError = err instanceof CommanderError ? fromCommander(err) : normalizeError(err);
48
50
 
49
- // Format the error the way the user asked for the output — from the globals
50
- // commander parsed, so every spelling it accepts (`-j`, `-jq`, `--json`)
51
- // reaches the envelope. Errors go to stderr, so that stream's TTY-ness decides
52
- // colour, not stdout's.
53
- const globals = parsedGlobalOptions(program);
51
+ // Recovered flags include trailing options after a value-validation failure.
52
+ // Errors go to stderr, so that stream determines color.
54
53
  const json = globals.json === true;
55
54
  const out = new Output({
56
55
  json,
package/src/cli.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * Kept free of side effects so it can be imported by tests and the bin entry.
4
4
  */
5
5
 
6
- import { Command } from "commander";
6
+ import { Command, Option } from "commander";
7
7
  import { addGlobalOptions, globalOptionKeys, unknownCommand, commandPath } from "./lib/options.js";
8
8
  import { registerMeta } from "./commands/meta.js";
9
9
  import { registerApi } from "./commands/api.js";
@@ -57,6 +57,21 @@ export function createProgram(): Command {
57
57
  // 0 arguments but got 2: issues, list." — true, and no help at all.
58
58
  .allowExcessArguments();
59
59
 
60
+ program.addHelpText(
61
+ "after",
62
+ [
63
+ "",
64
+ "Start here:",
65
+ " linear auth status # check your connection",
66
+ " linear auth login # connect if needed",
67
+ " linear team list # find your team key",
68
+ " linear issue list --assignee me # find your work",
69
+ " linear issue view TES-42 # read an issue",
70
+ " linear issue list --help # discover filters and examples",
71
+ " linear commands issue list --json # inspect options and JSON fields",
72
+ ].join("\n"),
73
+ );
74
+
60
75
  addGlobalOptions(program);
61
76
 
62
77
  // Phase 0: meta + escape hatch + completion.
@@ -140,17 +155,9 @@ export function createProgram(): Command {
140
155
  * The global options as commander actually parsed them, read back off the
141
156
  * command tree after `parseAsync` has thrown.
142
157
  *
143
- * The error boundary has no Context to ask — the failure may have happened
144
- * before any action ran — so it used to re-parse `process.argv` by hand with
145
- * `argv.includes("--json")`. That check did not know the `-j` alias, bundled
146
- * short flags (`-jq`) or any other spelling commander accepts, so `linear issue
147
- * view NOPE-1 -j` printed a plaintext error while `--json` printed the
148
- * envelope: an unparseable error stream for exactly the callers the envelope
149
- * exists for. Commander has already parsed whatever it got to before failing
150
- * (a bad option is reported only after the whole argument list is scanned), so
151
- * the boundary reads that instead of parsing again. Every global is registered
152
- * on every command and stored under the same key, so the flag is found
153
- * wherever on the command path it was given.
158
+ * The error boundary has no Context to ask because an option parser may fail
159
+ * before an action runs. Read explicitly supplied globals across the tree;
160
+ * errorGlobalOptions supplements these when trailing flags were not reached.
154
161
  */
155
162
  export function parsedGlobalOptions(program: Command): GlobalOptions {
156
163
  const keys = globalOptionKeys();
@@ -165,6 +172,75 @@ export function parsedGlobalOptions(program: Command): GlobalOptions {
165
172
  return merged as GlobalOptions;
166
173
  }
167
174
 
175
+ /** Recover output flags after a value parser stops before reaching trailing flags.
176
+ * Parse a side-effect-free copy of the grammar: no actions, hooks, validation,
177
+ * credentials, or API calls. Commander still handles option values, clusters,
178
+ * aliases and `--`, so literal body text cannot turn on JSON mode.
179
+ */
180
+ export function errorGlobalOptions(program: Command, args: string[]): GlobalOptions {
181
+ class RecoveryOption extends Option {
182
+ private readonly key: string;
183
+
184
+ constructor(source: Option) {
185
+ super(source.flags, source.description);
186
+ this.negate = source.negate;
187
+ this.key = source.attributeName();
188
+ }
189
+
190
+ override attributeName(): string {
191
+ return this.key;
192
+ }
193
+ }
194
+
195
+ const copy = (source: Command): Command => {
196
+ const cmd = new Command(source.name())
197
+ .aliases(source.aliases())
198
+ .allowExcessArguments()
199
+ .allowUnknownOption()
200
+ .helpOption(false)
201
+ .exitOverride()
202
+ .configureOutput({ writeOut: () => {}, writeErr: () => {} })
203
+ .action(() => {});
204
+ for (const option of source.options) {
205
+ // Preserve custom storage keys and negation semantics (notably the
206
+ // NoFlagOption globals behind --no-ansi / --no-color / --no-input).
207
+ cmd.addOption(new RecoveryOption(option));
208
+ }
209
+ for (const child of source.commands) cmd.addCommand(copy(child));
210
+ return cmd;
211
+ };
212
+ const grammar = copy(program).enablePositionalOptions();
213
+ try {
214
+ grammar.parse(args, { from: "user" });
215
+ } catch {
216
+ // A malformed or missing option value may still stop parsing; retain
217
+ // whatever was read and recover the boundary-only booleans below.
218
+ }
219
+
220
+ // An output flag can itself be consumed as the malformed value of the option
221
+ // before it (`--limit --json`). Recover only the booleans used by the error
222
+ // boundary, stopping at `--` and ignoring `--flag=<literal>` values. A short
223
+ // cluster counts only when every character is one of our boolean aliases.
224
+ const boundary: GlobalOptions = {};
225
+ for (const arg of args) {
226
+ if (arg === "--") break;
227
+ if (arg === "--json") boundary.json = true;
228
+ else if (arg === "--quiet") boundary.quiet = true;
229
+ else if (arg === "--debug") boundary.debug = true;
230
+ else if (arg === "--no-ansi" || arg === "--no-color") boundary.noAnsi = true;
231
+ else if (/^-[jq]+$/.test(arg)) {
232
+ if (arg.includes("j")) boundary.json = true;
233
+ if (arg.includes("q")) boundary.quiet = true;
234
+ }
235
+ }
236
+
237
+ return {
238
+ ...parsedGlobalOptions(grammar),
239
+ ...parsedGlobalOptions(program),
240
+ ...boundary,
241
+ };
242
+ }
243
+
168
244
  function applyGlobalOptionsToAll(cmd: Command): void {
169
245
  for (const sub of cmd.commands) {
170
246
  // addGlobalOptions skips any global the command already declares itself, so
package/src/client.ts CHANGED
@@ -4,10 +4,15 @@
4
4
 
5
5
  import { LinearClient } from "@linear/sdk";
6
6
  import { rotateOAuthCredential, type ResolvedConfig } from "./config.js";
7
- import { OAuthUserTokenProvider } from "./oauth.js";
7
+ import {
8
+ ClientCredentialsTokenProvider,
9
+ OAuthUserTokenProvider,
10
+ type GetAccessTokenOptions,
11
+ } from "./oauth.js";
8
12
  import { authError, normalizeError, CliError } from "./lib/errors.js";
9
13
 
10
14
  export function createClient(config: ResolvedConfig): LinearClient {
15
+ if (config.appCredential) return createRefreshingAppClient(config);
11
16
  if (config.oauthCredential) {
12
17
  return createRefreshingOAuthClient(config);
13
18
  }
@@ -27,21 +32,56 @@ export function createClient(config: ResolvedConfig): LinearClient {
27
32
  return new LinearClient({ apiKey: config.apiKey });
28
33
  }
29
34
 
35
+ /** App credentials obtain a new token each invocation and renew before expiry or once on 401. */
36
+ function createRefreshingAppClient(config: ResolvedConfig): LinearClient {
37
+ const provider = new ClientCredentialsTokenProvider(config.appCredential!);
38
+ return installTokenRenewal(
39
+ new LinearClient({ accessToken: "pending-app-token" }),
40
+ async (options) => (await provider.getAccessToken(options)).accessToken,
41
+ );
42
+ }
43
+
44
+ /** Both generated SDK requests and raw queries must cross the same token boundary. */
45
+ function installTokenRenewal(
46
+ client: LinearClient,
47
+ getAccessToken: (options?: GetAccessTokenOptions) => Promise<string>,
48
+ ): LinearClient {
49
+ const run = async <T>(request: () => Promise<T>): Promise<T> => {
50
+ client.client.setHeader("Authorization", `Bearer ${await getAccessToken()}`);
51
+ try {
52
+ return await request();
53
+ } catch (err) {
54
+ if (!isAuthenticationFailure(err)) throw err;
55
+ client.client.setHeader(
56
+ "Authorization",
57
+ `Bearer ${await getAccessToken({ forceRefresh: true })}`,
58
+ );
59
+ return request();
60
+ }
61
+ };
62
+ const request = client.client.request.bind(client.client);
63
+ const rawRequest = client.client.rawRequest.bind(client.client);
64
+ client.client.request = (...args) => run(() => request(...args));
65
+ client.client.rawRequest = (...args) => run(() => rawRequest(...args));
66
+ return client;
67
+ }
68
+
30
69
  function isAuthenticationFailure(err: unknown): boolean {
31
70
  const value = err as Record<string, any> | undefined;
32
71
  const status = value?.status ?? value?.response?.status ?? value?.raw?.response?.status;
33
72
  if (status === 401) return true;
73
+ if (/^authentication(?:error)?$/i.test(String(value?.type ?? ""))) return true;
34
74
  const errors = value?.response?.errors ?? value?.errors ?? value?.raw?.response?.errors;
35
75
  return (
36
76
  Array.isArray(errors) &&
37
77
  errors.some((item) => {
38
- const type = `${item?.extensions?.type ?? ""} ${item?.extensions?.code ?? ""}`;
78
+ const type = `${item?.type ?? item?.extensions?.type ?? ""} ${item?.extensions?.code ?? ""}`;
39
79
  const message = `${item?.message ?? ""}`;
40
80
  return (
41
81
  /^(?:authentication(?:error)?|unauthorized|invalidtoken|invalid_token)$/i.test(
42
82
  type.trim(),
43
83
  ) ||
44
- /authentication (?:failed|required)|unauthori[sz]ed|invalid (?:access )?token|token (?:expired|invalid)/i.test(
84
+ /unauthenticated|authentication (?:failed|required)|unauthori[sz]ed|invalid (?:access )?token|token (?:expired|invalid)/i.test(
45
85
  message,
46
86
  )
47
87
  );
@@ -56,21 +96,9 @@ function createRefreshingOAuthClient(config: ResolvedConfig): LinearClient {
56
96
  credential,
57
97
  persist: (previous, next) => rotateOAuthCredential(credential.workspace.urlKey, previous, next),
58
98
  });
59
- const client = new LinearClient({ accessToken: credential.accessToken });
60
- const original = client.client.request.bind(client.client);
61
- client.client.request = async (document, variables, requestHeaders) => {
62
- const accessToken = await provider.getAccessToken();
63
- client.client.setHeader("Authorization", `Bearer ${accessToken}`);
64
- try {
65
- return await original(document, variables, requestHeaders);
66
- } catch (err) {
67
- if (!isAuthenticationFailure(err)) throw err;
68
- const replacement = await provider.getAccessToken({ forceRefresh: true });
69
- client.client.setHeader("Authorization", `Bearer ${replacement}`);
70
- return original(document, variables, requestHeaders);
71
- }
72
- };
73
- return client;
99
+ return installTokenRenewal(new LinearClient({ accessToken: credential.accessToken }), (options) =>
100
+ provider.getAccessToken(options),
101
+ );
74
102
  }
75
103
 
76
104
  /**
@@ -290,7 +290,13 @@ export function registerIssue(program: Command): void {
290
290
  ctx.limit,
291
291
  ctx.defaultTeam,
292
292
  );
293
- ctx.output.list(rows, listColumns(ctx), rows);
293
+ ctx.output.list(rows, listColumns(ctx), {
294
+ jsonRows: rows,
295
+ empty:
296
+ !opts.allStates && !opts.state?.length
297
+ ? "No unstarted issues assigned to you match these filters. Use --all-states to include in-progress and other states."
298
+ : undefined,
299
+ });
294
300
  }),
295
301
  );
296
302
  addFilterOptions(mine, { assignee: false }).addOption(