linear-sdk-cli 0.9.0 → 0.9.1

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,12 @@ 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.9.1](https://github.com/zhendalf/linear-sdk-cli/compare/v0.9.0...v0.9.1) (2026-10-22)
8
+
9
+ ### Bug Fixes
10
+
11
+ - **auth:** default to personal API-key login ([#98](https://github.com/zhendalf/linear-sdk-cli/issues/98)) ([0d57f7c](https://github.com/zhendalf/linear-sdk-cli/commit/0d57f7c741bc5e9099787271cde16f436cb21f13))
12
+
7
13
  ## [0.9.0](https://github.com/zhendalf/linear-sdk-cli/compare/v0.8.3...v0.9.0) (2026-10-22)
8
14
 
9
15
  ### Features
package/MIGRATING.md CHANGED
@@ -48,13 +48,21 @@ linear auth list # …lists it, Storage: keychain
48
48
 
49
49
  If it does not — no keyring on this platform, or you used schpet's plaintext mode (`slug =
50
50
  "lin_api_…"` inline in `credentials.toml`, which we deliberately do not read) — pipe the key to
51
- `linear auth login --key -`; `--plaintext` keeps it in our `0600` `config.toml` instead. Bare
52
- `linear auth login` now starts browser OAuth, so it never prompts for a personal API key. `auth
53
- migrate` moves plaintext credentials from our file into the keyring, same command name as theirs.
51
+ `linear auth login --key -`, or run bare `linear auth login` for a hidden personal API-key prompt.
52
+ Login requires a system keyring unless you explicitly pass `--plaintext` to use our `0600`
53
+ `config.toml` instead. `auth migrate` moves plaintext credentials from our file into the keyring,
54
+ same command name as theirs.
54
55
  Because the API-key entry is shared, `auth logout` here removes it for both tools (and drops the
55
56
  slug from schpet's list file, so neither reports a workspace whose key is gone). The API key is
56
57
  **never** read from a project `.linear.toml`.
57
58
 
59
+ Earlier versions defaulted to browser OAuth through a bundled client ID. That login flow and its
60
+ callback options have been removed. Existing stored OAuth user profiles still refresh and support
61
+ logout/revocation using their saved client ID. For new authentication, use a personal API key,
62
+ `LINEAR_ACCESS_TOKEN` / `--access-token` on commands (the issuing grant determines user/app identity),
63
+ or `auth login --app` with your own client ID and secret. Supplied access tokens remain temporary;
64
+ the CLI does not persist or refresh them. No hardcoded OAuth client ID is shipped.
65
+
58
66
  ## 3. Config — same files, same keys
59
67
 
60
68
  We look in every place schpet does, in its order. Walking up from the working directory, each
package/README.md CHANGED
@@ -70,9 +70,9 @@ retains their best ideas and provides a careful compatibility and migration path
70
70
 
71
71
  ## Install
72
72
 
73
- Requires [Bun](https://bun.sh) **1.1 or newer**. Authenticate with browser OAuth (the default), an
74
- invocation-scoped OAuth access token, or a personal API key. The CLI ships as TypeScript and runs
75
- directly on Bun — no build step, no bundle, no Node.
73
+ Requires [Bun](https://bun.sh) **1.1 or newer**. Authenticate with a personal API key (the default),
74
+ an invocation-scoped OAuth access token, or your app's client credentials. The CLI ships as
75
+ TypeScript and runs directly on Bun — no build step, no bundle, no Node.
76
76
 
77
77
  ```sh
78
78
  bun add --global linear-sdk-cli
@@ -103,7 +103,7 @@ bun run src/bin/linear.ts --help # or: bun run dev -- --help
103
103
  ## Quickstart
104
104
 
105
105
  ```sh
106
- linear auth login # browser OAuth; or set LINEAR_API_KEY
106
+ linear auth login # securely prompt for a personal API key
107
107
  linear whoami # confirm you're connected
108
108
 
109
109
  linear issue list --assignee me --state started # my in-progress work
@@ -118,9 +118,12 @@ branch, so most issue commands let you omit the id entirely.
118
118
 
119
119
  ## Authentication
120
120
 
121
- For a person at a terminal, `linear auth login` uses browser Authorization Code + PKCE and the
122
- default `actor=user`. Existing personal API keys and invocation-scoped OAuth access tokens remain
123
- supported. Explicit `--api-key` or `--access-token` flags override environment credentials;
121
+ For a person at a terminal, `linear auth login` prompts for a personal API key without echoing it.
122
+ Create a key in [Security & Access](https://linear.app/settings/account/security), choosing its
123
+ permissions and team access there. Workspace administrators may restrict member key creation.
124
+ No OAuth application installation, bundled client ID, or browser callback is required.
125
+
126
+ For commands, explicit `--api-key` or `--access-token` flags override environment credentials;
124
127
  without a credential flag the CLI reads `LINEAR_API_KEY`, `LINEAR_ACCESS_TOKEN`, or the pair
125
128
  `LINEAR_CLIENT_ID` / `LINEAR_CLIENT_SECRET`. Mixing credential types in the environment fails
126
129
  rather than silently choosing a Linear actor. Client credentials must be supplied together;
@@ -129,14 +132,13 @@ explicit credential flags still override all environment credentials.
129
132
  > **Credential trust boundary.** A secret is **never** read from a project-local
130
133
  > `.linear.toml` — only non-secret settings live there — so a key can't be committed by accident.
131
134
  > A project may select one of your already-stored credentials with `workspace = "<slug>"`, but it
132
- > cannot provide or override the secret. Browser OAuth credentials are keyring-only; an access
133
- > token injected with `--access-token` or `LINEAR_ACCESS_TOKEN` remains invocation-scoped.
135
+ > cannot provide or override the secret. Existing browser OAuth credentials remain keyring-only;
136
+ > a token injected with `--access-token` or `LINEAR_ACCESS_TOKEN` remains invocation-scoped.
134
137
 
135
138
  ```sh
136
- linear auth login # browser OAuth, read + write, actor=user
137
- linear auth login --read-only # request only Linear's read scope
138
- linear auth login --no-browser # print the URL; still waits on the loopback callback
139
- printf '%s\n' "$LINEAR_API_KEY" | linear auth login --key - # personal API-key fallback
139
+ linear auth login # hidden personal API-key prompt
140
+ printf '%s\n' "$LINEAR_API_KEY" | linear auth login --key - # explicit stdin input
141
+ linear auth login --app --client-id YOUR_CLIENT_ID --client-secret -
140
142
  linear auth status # credential kind and source (value redacted)
141
143
  linear auth logout --workspace acme # revoke OAuth (if used) and remove the profile
142
144
  ```
@@ -148,30 +150,27 @@ Linear provides no safe read-only scope introspection for them. `whoami --json`
148
150
  member's workspace role in `admin` and reports credential authorization separately under
149
151
  `authorization`; an admin member can still hold a credential that lacks the `admin` scope.
150
152
 
151
- Browser login generates a cryptographically random verifier and CSRF state, requires PKCE S256,
152
- opens Linear, and listens temporarily on the registered `127.0.0.1` callback. It validates the
153
- viewer and workspace before saving the access token, rotating refresh token, expiry, granted
154
- scopes, OAuth client identity, and workspace identity. The whole record lives only in the system
155
- keyring (`service = linear-cli`, `account = oauth:<workspace slug>`); the config file gets only
156
- non-secret `keyring = true` and `oauth = true` markers. Access tokens refresh before expiry and
157
- once after an authentication failure, with refresh-token rotation committed under the credential
158
- 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
161
- browser's loopback host; Linear does not currently document a device-code flow. Override a
162
- separately registered public client's identity with `LINEAR_OAUTH_CLIENT_ID` and its exact callback
163
- with `LINEAR_OAUTH_REDIRECT_URI`. No OAuth client secret is embedded or used for browser login.
164
-
165
- For a personal API key, `auth login --key -` reads stdin, validates the viewer/workspace, stores the
166
- key in the system keyring by default, and writes only a marker to the config. `--plaintext` is an
167
- API-key-only compatibility option. API-key entries use `account = <workspace slug>`, preserving
168
- compatibility with existing keyring profiles. Passing a key as `--key <value>` works but warns
169
- because argv is visible to other processes. `auth migrate` moves legacy plaintext keys into the
170
- keyring, and `auth token` exports stored API keys only; OAuth tokens are never printed.
153
+ Both the prompt and `auth login --key -` validate the viewer/workspace before storing the key in
154
+ the system keyring (`service = linear-cli`, `account = <workspace slug>`), with only a marker in
155
+ the user config. If there is no system keyring, login requires explicit `--plaintext` to store
156
+ API-key or app credentials in the private user config (`0600`). Passing `--key <value>` also works
157
+ but warns because argv and shell history expose it. `auth migrate` moves legacy plaintext keys
158
+ into the keyring; `auth token` exports stored API keys only, never OAuth tokens.
159
+
160
+ JSON, `--no-input`, and redirected sessions never prompt. Supply `--key -` explicitly for stdin;
161
+ bare login does not import environment credentials. To authenticate a command without saving
162
+ credentials, set `LINEAR_API_KEY` or `LINEAR_ACCESS_TOKEN` instead. `--read-only` and `--scope`
163
+ apply only to `--app`; set personal API-key permissions in Linear when creating the key.
164
+
165
+ Browser login and its `--no-browser`, `--redirect-uri`, `--timeout`, and `--admin` options have
166
+ been removed. Existing stored OAuth user profiles still refresh using their own saved client ID,
167
+ retain scope reporting, and support revocation on logout. No embedded client ID is needed to use
168
+ these profiles. New personal logins use API keys.
171
169
 
172
170
  ### Apps, agents, and service accounts
173
171
 
174
- Use an OAuth access token when the CLI runs inside an integration or agent:
172
+ Use an OAuth access token when the CLI runs inside an integration or agent. The token may
173
+ represent a user or app; its issuing grant determines the identity and permissions:
175
174
 
176
175
  ```sh
177
176
  LINEAR_ACCESS_TOKEN="$access_token" linear whoami --json
@@ -256,7 +255,7 @@ access to the same user config. Switching storage removes the previous copy of t
256
255
  replacing an existing keyring profile requires access to that keyring.
257
256
 
258
257
  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
258
+ (default: `read,write`). App actors cannot request an `admin` scope. Linear must have
260
259
  client credentials enabled for the app. Changing scopes can revoke existing app tokens, so use
261
260
  the same scopes across hosts.
262
261
 
@@ -280,13 +279,13 @@ disable the app in Linear. Environment-only credentials must be unset separately
280
279
 
281
280
  ### Multiple workspaces
282
281
 
283
- Credentials are stored per **workspace slug**. Browser, app, and API-key login derive the slug from
282
+ Credentials are stored per **workspace slug**. App and API-key login derive the slug from
284
283
  the authenticated organization; `--workspace` validates that the result matches:
285
284
 
286
285
  ```sh
287
- linear auth login --workspace acme # browser OAuth for "acme"
286
+ linear auth login --workspace acme # prompt for an API key for "acme"
288
287
  linear auth login --workspace other-org # …and another workspace
289
- linear auth login --workspace acme --key - # personal API-key fallback via stdin
288
+ linear auth login --workspace acme --key - # personal API key via stdin
290
289
  linear auth list # show configured workspaces + which is default
291
290
  linear auth default acme # choose the default workspace
292
291
  linear config set team ENG --user --workspace acme # set its validated default team
@@ -294,7 +293,7 @@ linear --workspace other-org issue list # use a specific workspace for one co
294
293
  linear auth logout --workspace acme # revoke OAuth, then remove one credential
295
294
  ```
296
295
 
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.
296
+ Successful 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.
298
297
 
299
298
  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.
300
299
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linear-sdk-cli",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Ergonomic command-line interface for Linear, built on @linear/sdk",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,22 +35,24 @@ lin --version
35
35
 
36
36
  ### Authentication
37
37
 
38
- The CLI supports distinct human, hosted-app, and personal-key lifecycles:
38
+ The CLI supports personal API keys, supplied access tokens, and app credentials. No client ID
39
+ is bundled, and new login never starts browser OAuth:
39
40
 
40
- 1. **Human browser OAuth (default)** via Authorization Code + PKCE S256, `actor=user`:
41
+ 1. **Personal API key (default)**:
41
42
 
42
43
  ```bash
43
- linear auth login # opens Linear; read + write scopes
44
- linear auth login --read-only # read scope only
45
- linear auth login --no-browser # prints URL; loopback callback still required
44
+ linear auth login # hidden API-key prompt in an interactive terminal
45
+ printf '%s\n' "$LINEAR_API_KEY" | linear auth login --key -
46
46
  ```
47
47
 
48
- The CLI validates the viewer/workspace and stores the access token, rotating refresh token,
49
- expiry, scopes, client identity, and workspace identity only in the OS keyring. It refreshes
50
- before expiry and once after an authentication failure. `--admin` is explicit; no client secret
51
- is embedded. `auth logout` revokes this grant before removing it (`--local-only` skips
52
- revocation). Remote/headless callers need the loopback host reachable; Linear has no documented
53
- device-code flow.
48
+ Create a key at https://linear.app/settings/account/security, choosing permissions and team
49
+ access in Linear; workspace policy may restrict member key creation. Never ask for a key in
50
+ chat. The CLI validates the viewer/workspace before storing it in the OS keyring. If no keyring
51
+ is available, login requires explicit `--plaintext` for private user-config storage (`0600`).
52
+ Passing `--key <value>` also works but exposes the key in argv and produces a warning.
53
+ JSON, `--no-input`, and redirected sessions never prompt; use explicit `--key -` for stdin.
54
+ Bare login does not import environment credentials. `--read-only` and `--scope` require
55
+ `--app`; they cannot restrict an existing personal API key.
54
56
 
55
57
  2. **Invocation OAuth access token** via `--access-token <token>` or `LINEAR_ACCESS_TOKEN` — use
56
58
  this for hosted Linear apps, agents, and service accounts. Prefer the environment variable so
@@ -60,6 +62,7 @@ The CLI supports distinct human, hosted-app, and personal-key lifecycles:
60
62
  LINEAR_ACCESS_TOKEN=... linear whoami --json
61
63
  ```
62
64
 
65
+ Tokens may represent a user or app; their issuing grant determines identity and permissions.
63
66
  The host application owns OAuth installation, token refresh/client-credentials exchange,
64
67
  client-secret storage, and webhooks. These tokens remain invocation-scoped.
65
68
  Long-lived hosts can import `ClientCredentialsTokenProvider` from `linear-sdk-cli`; it caches
@@ -102,16 +105,7 @@ The CLI supports distinct human, hosted-app, and personal-key lifecycles:
102
105
  LINEAR_API_KEY=lin_api_... linear whoami --json
103
106
  ```
104
107
 
105
- 5. **Stored personal API-key credentials** via the explicit compatibility path:
106
-
107
- ```bash
108
- printf '%s\n' "$LINEAR_API_KEY" | linear auth login --key -
109
- ```
110
-
111
- Passing `--key <value>` also works but exposes it in argv and produces a warning. API keys may
112
- use `--plaintext`; browser OAuth is always keyring-only.
113
-
114
- 6. **Multiple workspaces** — store several OAuth or API-key credentials and select one per call with
108
+ 5. **Multiple workspaces** — store several app or API-key credentials and select one per call with
115
109
  `--workspace <slug>`; set a default with `linear auth default <slug>`:
116
110
 
117
111
  ```bash
@@ -120,6 +114,11 @@ The CLI supports distinct human, hosted-app, and personal-key lifecycles:
120
114
  linear issue list --workspace acme --json
121
115
  ```
122
116
 
117
+ Existing stored OAuth user profiles remain supported: their own saved client ID is used for
118
+ refresh, scope reporting is retained, and logout revokes the grant (`--local-only` skips
119
+ revocation). New browser login and its `--no-browser`, `--redirect-uri`, `--timeout`, and `--admin`
120
+ options have been removed. Do not tell users to register or install a CLI OAuth application.
121
+
123
122
  Login associates the authenticated workspace with the discovered project config, or creates `.linear.toml` at the Git root (cwd outside Git). Use `--no-project` for credential-only login; JSON reports `projectConfigPath` or null. Credentials stay global. Workspace selection is `--workspace` → `LINEAR_WORKSPACE` → project config → optional global default. A sole stored workspace is automatic; multiple unselected workspaces prompt only interactively and fail in JSON/noninteractive mode. Login/adoption/logout never assign a new default. Existing legacy defaults remain valid because their original intent is unknown; `auth default` is an explicit convenience.
124
123
 
125
124
  When a workspace URL slug changes, use `linear auth rename <old-slug> <new-slug> --json`
@@ -69,7 +69,7 @@ storage: string
69
69
 
70
70
  ### `linear auth login`
71
71
 
72
- Authenticate and select the workspace for this project
72
+ Sign in with a personal API key (default) or app credentials
73
73
 
74
74
  ```
75
75
  linear auth login [options]
@@ -80,16 +80,12 @@ linear auth login [options]
80
80
  | `--no-project` | save credentials without changing the project workspace |
81
81
  | `--key <key>` | use a personal API key ('-' reads it from stdin) |
82
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 |
83
+ | `--read-only` | request read-only access with --app |
87
84
  | `--app` | sign in as an app using client credentials |
88
85
  | `--client-credentials` | alias for --app |
89
86
  | `--scope <scopes>` | comma-separated app scopes (default: read,write) |
90
87
  | `--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 |
88
+ | `--client-id <id>` | app client ID (defaults to LINEAR_CLIENT_ID) |
93
89
 
94
90
  **Output (`--json`)**: a receipt object
95
91
 
@@ -99,8 +95,6 @@ credentialType: string
99
95
  workspace: string
100
96
  user: {id: string, name: string, email: string}
101
97
  storage: string
102
- scopes?: string[]
103
- expiresAt?: string
104
98
  path: string
105
99
  projectConfigPath: string | null
106
100
  ```
@@ -18,7 +18,6 @@ import {
18
18
  setDefaultWorkspace,
19
19
  listCredentials,
20
20
  readOAuthCredential,
21
- writeOAuthCredential,
22
21
  migrateCredentials,
23
22
  renameApiKeyProfile,
24
23
  profileRenameProjectUpdates,
@@ -35,12 +34,11 @@ import {
35
34
  import { keyring } from "../lib/keyring.js";
36
35
  import { readStdinSync } from "../lib/body.js";
37
36
  import { CliError, authError, normalizeError, usageError } from "../lib/errors.js";
38
- import { confirmDestructive, promptSelect } from "../lib/prompt.js";
37
+ import { confirmDestructive, promptSecret, promptSelect } from "../lib/prompt.js";
39
38
  import { listTeams } from "../services/team.js";
40
39
  import { resolveTeam } from "../lib/resolve.js";
41
40
  import { ISSUE_SORTS } from "../services/issue.js";
42
41
  import { firstTeam, type Context } from "../context.js";
43
- import { openUrl } from "../lib/open.js";
44
42
  import {
45
43
  authorizationCapabilities,
46
44
  credentialType as resolvedCredentialType,
@@ -48,19 +46,9 @@ import {
48
46
  import {
49
47
  ClientCredentialsTokenProvider,
50
48
  clientCredentialsScopes,
51
- DEFAULT_OAUTH_SCOPES,
52
- buildAuthorizationUrl,
53
- createPkceRequest,
54
- exchangeAuthorizationCode,
55
49
  revokeOAuthToken,
56
- startLoopbackCallback,
57
- type OAuthUserCredential,
58
50
  } from "../oauth.js";
59
51
 
60
- export const DEFAULT_OAUTH_REDIRECT_URI = "http://127.0.0.1:43821/oauth/callback";
61
- /** Public PKCE client identity. This is not a credential; no client secret is shipped or used. */
62
- export const DEFAULT_OAUTH_CLIENT_ID = "eda60862e4bb8b8af82cfb3193b65c2f";
63
-
64
52
  /**
65
53
  * The `whoami` handler, shared by the top-level `linear whoami` and the
66
54
  * reference CLI's spelling `linear auth whoami` so the two cannot drift.
@@ -113,6 +101,12 @@ const defaultAuthValidationClientFactory: AuthValidationClientFactory = (credent
113
101
  kind === "oauth-access-token" ? { accessToken: credential } : { apiKey: credential },
114
102
  ) as unknown as AuthValidationClient;
115
103
  let authValidationClientFactory = defaultAuthValidationClientFactory;
104
+ let authSecretPrompt = promptSecret;
105
+
106
+ /** Test seam that does not depend on Bun's module-namespace spying behavior. */
107
+ export function setAuthSecretPromptForTests(prompt: typeof promptSecret | undefined): void {
108
+ authSecretPrompt = prompt ?? promptSecret;
109
+ }
116
110
 
117
111
  /** Test seam for command-level auth tests; production always uses LinearClient. */
118
112
  export function setAuthValidationClientFactoryForTests(
@@ -231,17 +225,14 @@ export function registerMeta(program: Command): void {
231
225
  // login -------------------------------------------------------------------
232
226
  auth
233
227
  .command("login")
234
- .description("Authenticate and select the workspace for this project")
228
+ .description("Sign in with a personal API key (default) or app credentials")
235
229
  .option("--no-project", "save credentials without changing the project workspace")
236
230
  .option("--key <key>", "use a personal API key ('-' reads it from stdin)")
237
231
  .option(
238
232
  "--plaintext",
239
233
  "Store API-key or app credentials in the private user config file (0600)",
240
234
  )
241
- .option("--no-browser", "print the authorization URL instead of opening it")
242
- .option("--read-only", "request read-only OAuth access")
243
- .option("--admin", "explicitly add the OAuth admin scope")
244
- .option("--timeout <seconds>", "seconds to wait for the loopback callback", "120")
235
+ .option("--read-only", "request read-only access with --app")
245
236
  .option("--app", "sign in as an app using client credentials")
246
237
  .option("--client-credentials", "alias for --app")
247
238
  .option("--scope <scopes>", "comma-separated app scopes (default: read,write)")
@@ -249,30 +240,18 @@ export function registerMeta(program: Command): void {
249
240
  "--client-secret <secret>",
250
241
  "app client secret ('-' reads stdin; defaults to LINEAR_CLIENT_SECRET)",
251
242
  )
252
- .option(
253
- "--client-id <id>",
254
- "OAuth client ID (app: LINEAR_CLIENT_ID; browser: packaged CLI app)",
255
- )
256
- .option("--redirect-uri <uri>", "registered HTTP loopback callback URI")
243
+ .option("--client-id <id>", "app client ID (defaults to LINEAR_CLIENT_ID)")
257
244
  .action(
258
245
  action(async (ctx: Context, opts) => {
259
246
  if (ctx.options.accessToken !== undefined) {
260
247
  throw usageError(
261
- "`auth login` cannot persist an injected access token. Remove --access-token and use browser OAuth, or use --key - for a personal API key.",
248
+ "`auth login` cannot persist an injected access token. Use LINEAR_ACCESS_TOKEN or --access-token with a data command, or use `auth login --key -` for a personal API key.",
262
249
  );
263
250
  }
264
251
  if (opts.app || opts.clientCredentials) {
265
- if (
266
- opts.key !== undefined ||
267
- ctx.options.apiKey !== undefined ||
268
- opts.redirectUri ||
269
- opts.browser === false
270
- ) {
271
- throw usageError(
272
- "--app cannot be combined with personal API-key or browser callback options.",
273
- );
252
+ if (opts.key !== undefined || ctx.options.apiKey !== undefined) {
253
+ throw usageError("--app cannot be combined with --key or --api-key.");
274
254
  }
275
- if (opts.admin) throw usageError("App actors cannot request the admin scope.");
276
255
  if (!opts.plaintext && !keyring())
277
256
  throw new CliError(
278
257
  "App authentication requires a system keyring; use --plaintext for explicit private-file storage.",
@@ -337,144 +316,64 @@ export function registerMeta(program: Command): void {
337
316
  );
338
317
  return;
339
318
  }
340
- if (opts.clientSecret !== undefined || opts.scope !== undefined)
341
- throw usageError("--client-secret and --scope require --app or --client-credentials.");
319
+ if (
320
+ opts.clientId !== undefined ||
321
+ opts.clientSecret !== undefined ||
322
+ opts.scope !== undefined ||
323
+ opts.readOnly
324
+ )
325
+ throw usageError(
326
+ "--client-id, --client-secret, --scope, and --read-only require --app or --client-credentials. Set personal API-key permissions in Linear's Security & Access settings.",
327
+ );
342
328
  let key: string | undefined = selectLoginKey(opts.key, ctx.options.apiKey);
343
- if (key !== undefined) {
344
- if (
345
- opts.readOnly ||
346
- opts.admin ||
347
- opts.browser === false ||
348
- opts.redirectUri ||
349
- opts.clientId
350
- ) {
351
- throw usageError("OAuth-only options cannot be combined with --key or --api-key.");
352
- }
329
+ if (key === undefined && !ctx.isTTY)
330
+ throw usageError(
331
+ "Personal API-key login requires interactive input. Pipe a key to `linear auth login --key -`, or use --app for app credentials. To authenticate commands without saving credentials, set LINEAR_API_KEY or LINEAR_ACCESS_TOKEN.",
332
+ );
333
+ if (!opts.plaintext && !keyring())
334
+ throw new CliError(
335
+ "API-key login requires a system keyring; use --plaintext for explicit private-file storage.",
336
+ "runtime",
337
+ );
338
+ if (key === undefined) {
339
+ ctx.output.info(
340
+ "Create a personal API key at https://linear.app/settings/account/security. Choose its permissions and team access in Linear; workspace policy may restrict key creation.",
341
+ );
342
+ key = await authSecretPrompt(ctx, "Linear API key:", { required: true });
343
+ } else {
353
344
  if (key === "-") key = readStdinSync();
354
345
  else
355
346
  ctx.output.warn(
356
347
  "An API key on the command line is visible to other processes and shell history; prefer --key - (stdin).",
357
348
  );
358
- key = key.trim();
359
- if (!key) throw usageError("No API key provided.");
360
- const identity = await validateAuthCredential(key, ctx.options.workspace);
361
- const { user: me, organization: org } = identity;
362
- const { path, storage, keyringLabel } = writeCredential(org.urlKey, key, {
363
- plaintext: opts.plaintext === true,
364
- });
365
- const projectConfigPath = saveLoginProject(ctx, org.urlKey, opts.project !== false);
366
- const where =
367
- storage === "keychain"
368
- ? `Credential saved to the ${keyringLabel}; ${path} records the workspace.`
369
- : `Credential saved to ${path} (plaintext, 0600).`;
370
- ctx.output.emit(
371
- {
372
- success: true,
373
- credentialType: "api-key",
374
- workspace: org.urlKey,
375
- user: me,
376
- storage,
377
- path,
378
- projectConfigPath,
379
- },
380
- () =>
381
- ctx.output.success(
382
- `Authenticated as ${me.name} <${me.email}> for workspace '${org.urlKey}'. ${where}${projectConfigPath ? ` Project workspace saved to ${projectConfigPath}.` : ""}`,
383
- ),
384
- );
385
- return;
386
349
  }
387
-
388
- if (opts.plaintext) {
389
- throw usageError(
390
- "Browser OAuth is keyring-only; --plaintext requires --key, --api-key, or --client-credentials.",
391
- );
392
- }
393
- if (opts.readOnly && opts.admin) {
394
- throw usageError("--read-only cannot be combined with --admin.");
395
- }
396
- const timeoutSeconds = Number(opts.timeout);
397
- if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
398
- throw usageError("--timeout must be a positive number of seconds.");
399
- }
400
- const clientId = String(
401
- opts.clientId ?? process.env.LINEAR_OAUTH_CLIENT_ID ?? DEFAULT_OAUTH_CLIENT_ID,
402
- ).trim();
403
- const redirectUri = String(
404
- opts.redirectUri ?? process.env.LINEAR_OAUTH_REDIRECT_URI ?? DEFAULT_OAUTH_REDIRECT_URI,
405
- );
406
- const scopes = opts.readOnly
407
- ? ["read"]
408
- : opts.admin
409
- ? [...DEFAULT_OAUTH_SCOPES, "admin"]
410
- : [...DEFAULT_OAUTH_SCOPES];
411
- const pkce = createPkceRequest();
412
- const callback = await startLoopbackCallback({
413
- redirectUri,
414
- state: pkce.state,
415
- timeoutMs: timeoutSeconds * 1000,
350
+ key = key.trim();
351
+ if (!key) throw usageError("No API key provided.");
352
+ const identity = await validateAuthCredential(key, ctx.options.workspace);
353
+ const { user: me, organization: org } = identity;
354
+ const { path, storage, keyringLabel } = writeCredential(org.urlKey, key, {
355
+ plaintext: opts.plaintext === true,
416
356
  });
417
- try {
418
- const authorizationUrl = buildAuthorizationUrl({
419
- clientId,
420
- redirectUri: callback.redirectUri,
421
- scopes,
422
- challenge: pkce.challenge,
423
- state: pkce.state,
424
- });
425
- if (opts.browser === false) {
426
- ctx.output.info(`Open this URL to authenticate with Linear:\n${authorizationUrl}`);
427
- } else {
428
- ctx.output.info("Opening Linear in your browser…");
429
- await openUrl(authorizationUrl);
430
- }
431
- const code = await callback.wait;
432
- const token = await exchangeAuthorizationCode({
433
- code,
434
- verifier: pkce.verifier,
435
- clientId,
436
- redirectUri: callback.redirectUri,
437
- });
438
- const identity = await validateAuthCredential(
439
- token.accessToken,
440
- ctx.options.workspace,
441
- "oauth-access-token",
442
- );
443
- const credential: OAuthUserCredential = {
444
- version: 1,
445
- kind: "oauth-user",
446
- actor: "user",
447
- ...token,
448
- clientId,
449
- workspace: identity.organization,
450
- user: identity.user,
451
- };
452
- const saved = writeOAuthCredential(credential);
453
- const projectConfigPath = saveLoginProject(
454
- ctx,
455
- credential.workspace.urlKey,
456
- opts.project !== false,
457
- );
458
- ctx.output.emit(
459
- {
460
- success: true,
461
- credentialType: "oauth-user",
462
- workspace: credential.workspace.urlKey,
463
- user: credential.user,
464
- storage: saved.storage,
465
- scopes: credential.scopes,
466
- expiresAt: new Date(credential.expiresAt).toISOString(),
467
- path: saved.path,
468
- projectConfigPath,
469
- },
470
- () =>
471
- ctx.output.success(
472
- `Authenticated as ${credential.user.name} <${credential.user.email}> for workspace '${credential.workspace.urlKey}'. OAuth credentials saved to the ${saved.keyringLabel}.${projectConfigPath ? ` Project workspace saved to ${projectConfigPath}.` : ""}`,
473
- ),
474
- );
475
- } finally {
476
- await callback.close();
477
- }
357
+ const projectConfigPath = saveLoginProject(ctx, org.urlKey, opts.project !== false);
358
+ const where =
359
+ storage === "keychain"
360
+ ? `Credential saved to the ${keyringLabel}; ${path} records the workspace.`
361
+ : `Credential saved to ${path} (plaintext, 0600).`;
362
+ ctx.output.emit(
363
+ {
364
+ success: true,
365
+ credentialType: "api-key",
366
+ workspace: org.urlKey,
367
+ user: me,
368
+ storage,
369
+ path,
370
+ projectConfigPath,
371
+ },
372
+ () =>
373
+ ctx.output.success(
374
+ `Authenticated as ${me.name} <${me.email}> for workspace '${org.urlKey}'. ${where}${projectConfigPath ? ` Project workspace saved to ${projectConfigPath}.` : ""}`,
375
+ ),
376
+ );
478
377
  }),
479
378
  );
480
379
 
package/src/config.ts CHANGED
@@ -14,10 +14,10 @@
14
14
  * - Authentication secrets have a STRICTER boundary and are never read from a project file
15
15
  * or from the reference CLI's global file (avoids committing secrets):
16
16
  * Explicit `--access-token` / `--api-key` flags override their environment counterparts.
17
- * Invocation OAuth access tokens are stateless (`LINEAR_ACCESS_TOKEN` only). Human browser
18
- * OAuth sessions, app client credentials, and API keys may come from the OS keyring.
19
- * App credentials require explicit --plaintext to use a private user config file;
20
- * browser OAuth stays keyring-only. Environment client credentials stay invocation-scoped.
17
+ * Invocation OAuth access tokens are stateless (`LINEAR_ACCESS_TOKEN` / --access-token).
18
+ * Existing OAuth user sessions, app client credentials, and API keys may come from the OS keyring.
19
+ * New login requires explicit --plaintext to use a private user config file;
20
+ * existing browser OAuth stays keyring-only. Environment client credentials stay invocation-scoped.
21
21
  * A project `workspace` may select which already-stored credential to use;
22
22
  * it can never provide or override the secret itself.
23
23
  *
@@ -1443,7 +1443,7 @@ export function writeOAuthCredential(credential: OAuthUserCredential): WriteOAut
1443
1443
  const backend = keyring();
1444
1444
  if (!backend) {
1445
1445
  throw new CliError(
1446
- "Browser OAuth requires a usable system keyring; use `linear auth login --key -` on this platform.",
1446
+ "Stored OAuth sessions require a usable system keyring; use `linear auth login --key - --plaintext` for a personal API key on this platform.",
1447
1447
  "runtime",
1448
1448
  );
1449
1449
  }
@@ -1540,7 +1540,7 @@ export function probeKeyringCredential(slug: string): KeyringCredentialProbe {
1540
1540
  const backend = keyring();
1541
1541
  if (!backend) {
1542
1542
  throw new CliError(
1543
- `No usable system keyring is available, so workspace '${sanitize(slug)}' cannot be adopted. Run \`linear auth login --key -\` to read the key from stdin instead.`,
1543
+ `No usable system keyring is available, so workspace '${sanitize(slug)}' cannot be adopted. Run \`linear auth login --key - --plaintext\` to read the key from stdin into the private user config instead.`,
1544
1544
  "runtime",
1545
1545
  );
1546
1546
  }
@@ -76,7 +76,7 @@ export function enrichAuthorizationError(error: unknown, config: ResolvedConfig)
76
76
  const capabilities = authorizationCapabilities(config);
77
77
  const remediation =
78
78
  capabilities.credentialType === "oauth-user"
79
- ? "Re-authenticate with `linear auth login --admin`; member admin status alone is insufficient."
79
+ ? "Create a personal API key with admin permission in Linear's Security & Access settings, then run `linear auth login`; member admin status alone is insufficient."
80
80
  : "Use a credential authorized for the admin scope; member admin status alone is insufficient.";
81
81
  return new CliError(
82
82
  normalized.message,
@@ -10,8 +10,8 @@
10
10
  * Backends are synchronous (spawnSync) because config resolution is
11
11
  * synchronous and runs before any command body. Only macOS Keychain and the
12
12
  * Linux Secret Service (`secret-tool`) are implemented; everywhere else — and
13
- * wherever the tool is missing — `keyring()` returns null and the callers fall
14
- * back to the plaintext file without complaint.
13
+ * wherever the tool is missing — `keyring()` returns null. Login requires explicit
14
+ * --plaintext for private-file storage; low-level legacy API-key writers can fall back.
15
15
  *
16
16
  * The secret never travels on argv (visible to every process via `ps`): on
17
17
  * macOS it goes through `security -i`, which reads its command line from stdin;
@@ -391,8 +391,6 @@ export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
391
391
  workspace: "string",
392
392
  user: { id: "string", name: "string", email: "string" },
393
393
  storage: "string",
394
- "scopes?": ["string"],
395
- "expiresAt?": "string",
396
394
  path: "string",
397
395
  projectConfigPath: "string|null",
398
396
  }),