linear-sdk-cli 0.5.0 → 0.5.2

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,20 @@ 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.5.2](https://github.com/zhendalf/linear-sdk-cli/compare/v0.5.1...v0.5.2) (2026-09-13)
8
+
9
+ ### Bug Fixes
10
+
11
+ - **deps:** refresh SDK and toolchain dependencies ([#66](https://github.com/zhendalf/linear-sdk-cli/issues/66)) ([dcc6060](https://github.com/zhendalf/linear-sdk-cli/commit/dcc60603f79a52d64d7265abd658ea4712e99041))
12
+
13
+ ## [0.5.1](https://github.com/zhendalf/linear-sdk-cli/compare/v0.5.0...v0.5.1) (2026-09-06)
14
+
15
+ ### Bug Fixes
16
+
17
+ - associate login with projects and make workspace defaults explicit ([#62](https://github.com/zhendalf/linear-sdk-cli/issues/62)) ([faf4028](https://github.com/zhendalf/linear-sdk-cli/commit/faf4028246a947dc13f674d75df2b1ba3c0ac0f3))
18
+ - **comment:** accept create as an add alias ([#57](https://github.com/zhendalf/linear-sdk-cli/issues/57)) ([c8146b9](https://github.com/zhendalf/linear-sdk-cli/commit/c8146b98f60b68b5cc8bd2063fdd49cc9cc91216))
19
+ - reduce label resolution requests and latency ([#64](https://github.com/zhendalf/linear-sdk-cli/issues/64)) ([d1300a5](https://github.com/zhendalf/linear-sdk-cli/commit/d1300a5379e99a466a6111e24fbcfa7b85f21c1f))
20
+
7
21
  ## [0.5.0](https://github.com/zhendalf/linear-sdk-cli/compare/v0.4.0...v0.5.0) (2026-09-03)
8
22
 
9
23
  ### Features
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: **472**.
3
+ Generated by `bun run audit:coverage`. Total LinearClient members: **476**.
4
4
 
5
5
  | Status | Count | Meaning |
6
6
  | -------- | ----- | ----------------------------------------------- |
7
7
  | curated | 107 | Has a first-class CLI command |
8
- | raw-only | 142 | Reachable via `linear api` (no bespoke command) |
9
- | excluded | 223 | Out of scope (admin/integration/SDK plumbing) |
8
+ | raw-only | 144 | Reachable via `linear api` (no bespoke command) |
9
+ | excluded | 225 | 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 (223)
128
+ ## Excluded (225)
129
129
 
130
130
  - `agentActivities` — specialized/raw api only
131
131
  - `agentActivity` — specialized/raw api only
@@ -310,6 +310,7 @@ Every member is classified — CI fails otherwise. `raw-only` members are still
310
310
  - `ssoUrlFromEmail` — auth/session flows (out of scope)
311
311
  - `suspendUser` — admin/enterprise (raw api only)
312
312
  - `template` — specialized/raw api only
313
+ - `templateSearch` — specialized/raw api only
313
314
  - `templates` — specialized/raw api only
314
315
  - `templatesForIntegration` — integration/import (raw api only)
315
316
  - `timeSchedule` — specialized/raw api only
@@ -350,8 +351,9 @@ Every member is classified — CI fails otherwise. `raw-only` members are still
350
351
  - `userRevokeAllSessions` — admin/enterprise (raw api only)
351
352
  - `userRevokeSession` — admin/enterprise (raw api only)
352
353
  - `userUnlinkFromIdentityProvider` — admin/enterprise (raw api only)
354
+ - `userViewPreferences` — specialized/raw api only
353
355
 
354
- ## Raw-only (142)
356
+ ## Raw-only (144)
355
357
 
356
358
  <details><summary>Show</summary>
357
359
 
@@ -408,6 +410,7 @@ Every member is classified — CI fails otherwise. `raw-only` members are still
408
410
  - `emailIntakeAddressRefreshSesDomainStatus`
409
411
  - `emailIntakeAddressRotate`
410
412
  - `emailUnsubscribe`
413
+ - `inboxNotifications`
411
414
  - `initiativeAddLabel`
412
415
  - `initiativeFilterSuggestion`
413
416
  - `initiativeLabel`
@@ -472,6 +475,7 @@ Every member is classified — CI fails otherwise. `raw-only` members are still
472
475
  - `updateAttachment`
473
476
  - `updateEmailIntakeAddress`
474
477
  - `updateFavorite`
478
+ - `updateInboxNotification`
475
479
  - `updateInitiativeLabel`
476
480
  - `updateInitiativeRelation`
477
481
  - `updateInitiativeToProject`
package/README.md CHANGED
@@ -236,6 +236,10 @@ linear --workspace other-org issue list # use a specific workspace for one co
236
236
  linear auth logout --workspace acme # revoke OAuth, then remove one credential
237
237
  ```
238
238
 
239
+ 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.
240
+
241
+ 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.
242
+
239
243
  If revocation is intentionally unavailable, `auth logout --local-only` removes only local state.
240
244
  If browser login temporarily superseded an existing personal API-key profile for that workspace,
241
245
  OAuth logout preserves and reactivates the API key instead of deleting it.
@@ -244,8 +248,9 @@ OAuth logout preserves and reactivates the API key instead of deleting it.
244
248
  environment and stored-credential lookup. Otherwise `LINEAR_API_KEY` or `LINEAR_ACCESS_TOKEN` is
245
249
  absolute. With no invocation-scoped credential, the workspace is chosen by `--workspace` →
246
250
  `LINEAR_WORKSPACE` env → project config `workspace` → `default_workspace` in the user config. With
247
- one configured workspace it's used automatically; with several and no selection, the CLI asks you
248
- to pick (via `--workspace`, project config, or `auth default`). An invocation credential does not
251
+ one configured workspace it's used automatically; with several and no selection, interactive
252
+ commands prompt for an invocation-only choice. Noninteractive and JSON commands fail clearly
253
+ and explain how to select a workspace. Auth repair and discovery commands remain available. An invocation credential does not
249
254
  silently inherit the default or project profile's team; pair it with `--workspace` (or
250
255
  `LINEAR_WORKSPACE`) naming a configured profile when that profile metadata should apply.
251
256
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linear-sdk-cli",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Ergonomic command-line interface for Linear, built on @linear/sdk",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -57,6 +57,7 @@
57
57
  "format": "oxfmt .",
58
58
  "format:check": "oxfmt --check .",
59
59
  "test": "bun test test/unit test/contract",
60
+ "test:performance": "bun test --timeout 15000 test/performance",
60
61
  "test:unit": "bun test test/unit",
61
62
  "test:contract": "bun test test/contract",
62
63
  "test:live": "LINEAR_CLI_LIVE=1 bun test --timeout 60000 test/integration",
@@ -69,19 +70,19 @@
69
70
  "janitor": "bun run test/janitor.ts"
70
71
  },
71
72
  "dependencies": {
72
- "@inquirer/prompts": "^8.7.1",
73
- "@linear/sdk": "^92.0.0",
73
+ "@inquirer/prompts": "^8.7.2",
74
+ "@linear/sdk": "^95.0.0",
74
75
  "commander": "^15.0.0",
75
76
  "graphql": "^17.0.2",
76
77
  "picocolors": "^1.1.1",
77
78
  "smol-toml": "^1.8.0"
78
79
  },
79
- "packageManager": "bun@1.4.0",
80
+ "packageManager": "bun@1.4.2",
80
81
  "devDependencies": {
81
- "@types/bun": "^1.4.0",
82
- "@types/node": "^26.4.1",
83
- "oxfmt": "^0.66.0",
84
- "oxlint": "^1.81.0",
82
+ "@types/bun": "^1.4.2",
83
+ "@types/node": "^26.5.1",
84
+ "oxfmt": "^0.67.0",
85
+ "oxlint": "^1.82.0",
85
86
  "typescript": "^7.0.2"
86
87
  }
87
88
  }
@@ -94,6 +94,8 @@ The CLI supports distinct human, hosted-app, and personal-key lifecycles:
94
94
  linear issue list --workspace acme --json
95
95
  ```
96
96
 
97
+ 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.
98
+
97
99
  An explicit credential flag overrides environment credentials. If both credential kinds occur at
98
100
  the same precedence level, the CLI fails rather than silently choosing an actor. Inspect resolution
99
101
  with `linear auth status`. `linear auth token` exports stored API keys only and never OAuth tokens.
@@ -215,6 +217,14 @@ perfectly with a heredoc:
215
217
  - `comment add` / `comment update` / `comment reply` → `--body-file <path>`
216
218
  - `document create` / `document update` → `--content-file <path>`
217
219
 
220
+ Comments canonically use `add`, while resources such as attachments use `create`.
221
+ `create` is also accepted as a compatibility alias for comment `add`. For a file-backed
222
+ issue comment, copy this form:
223
+
224
+ ```bash
225
+ linear issue comment add LUMI-9340 --body-file comment.md --json
226
+ ```
227
+
218
228
  ```bash
219
229
  linear issue create --title "Onboarding bug" --team TES --no-input --json \
220
230
  --description-file - <<'EOF'
@@ -69,7 +69,7 @@ storage: string
69
69
 
70
70
  ### `linear auth login`
71
71
 
72
- Authenticate in the browser with OAuth, or store a personal API key
72
+ Authenticate and select the workspace for this project
73
73
 
74
74
  ```
75
75
  linear auth login [options]
@@ -77,6 +77,7 @@ linear auth login [options]
77
77
 
78
78
  | Option | Description |
79
79
  | ---------------------- | --------------------------------------------------------------------- |
80
+ | `--no-project` | save credentials without changing the project workspace |
80
81
  | `--key <key>` | use a personal API key ('-' reads it from stdin) |
81
82
  | `--plaintext` | Store the key in the config file (0600) instead of the system keyring |
82
83
  | `--no-browser` | print the authorization URL instead of opening it |
@@ -97,6 +98,7 @@ storage: string
97
98
  scopes?: string[]
98
99
  expiresAt?: string
99
100
  path: string
101
+ projectConfigPath: string | null
100
102
  ```
101
103
 
102
104
  ### `linear auth logout`
@@ -22,6 +22,8 @@ linear comment [options]
22
22
 
23
23
  Add a comment to an issue (images uploaded with --attach render inline)
24
24
 
25
+ Aliases: `create`
26
+
25
27
  ```
26
28
  linear comment add [options] <issue> [body]
27
29
  ```
@@ -255,6 +255,8 @@ issue: string
255
255
 
256
256
  Add a comment to an issue (images uploaded with --attach render inline)
257
257
 
258
+ Aliases: `create`
259
+
258
260
  ```
259
261
  linear issue comment add [options] <issue> [body]
260
262
  ```
package/src/cli.ts CHANGED
@@ -111,6 +111,7 @@ export function createProgram(): Command {
111
111
  command.help();
112
112
  return;
113
113
  }
114
+ await ctx.selectWorkspace();
114
115
  const detail = await getIssueDetail(ctx.client, id);
115
116
  await renderIssueDetail(ctx, detail, true);
116
117
  });
@@ -32,12 +32,12 @@ const ROW_COLUMNS: Column<svc.CommentRow>[] = [
32
32
  /** Options for a mounted copy of the shared verbs. */
33
33
  interface MountOptions {
34
34
  /** Register the short aliases (`ls`, `edit`, `rm`). Off under `issue comment`. */
35
- aliases?: boolean;
35
+ shortAliases?: boolean;
36
36
  }
37
37
 
38
38
  function buildList(o: MountOptions): Command {
39
39
  const cmd = new Command("list").argument("<issue>").description("List comments on an issue");
40
- if (o.aliases !== false) cmd.alias("ls");
40
+ if (o.shortAliases !== false) cmd.alias("ls");
41
41
  return cmd.action(
42
42
  action(async (ctx: Context, _opts, issueArg: string) => {
43
43
  const rows = await svc.listComments(ctx.client, issueArg, ctx.limit);
@@ -48,6 +48,7 @@ function buildList(o: MountOptions): Command {
48
48
 
49
49
  function buildAdd(_o: MountOptions): Command {
50
50
  return new Command("add")
51
+ .alias("create")
51
52
  .argument("<issue>")
52
53
  .argument("[body]")
53
54
  .description("Add a comment to an issue (images uploaded with --attach render inline)")
@@ -129,7 +130,7 @@ function buildUpdate(o: MountOptions): Command {
129
130
  "prepend a real Linear mention (name, email, me, or id; repeatable)",
130
131
  collectArray,
131
132
  );
132
- if (o.aliases !== false) cmd.alias("edit");
133
+ if (o.shortAliases !== false) cmd.alias("edit");
133
134
  return cmd.action(
134
135
  action(async (ctx: Context, opts, commentId: string, bodyArg?: string) => {
135
136
  // Fetch the comment first: the editor opens ON the current body (so
@@ -161,7 +162,7 @@ function buildUpdate(o: MountOptions): Command {
161
162
 
162
163
  function buildDelete(o: MountOptions): Command {
163
164
  const cmd = new Command("delete").argument("<commentId>").description("Delete a comment");
164
- if (o.aliases !== false) cmd.alias("rm");
165
+ if (o.shortAliases !== false) cmd.alias("rm");
165
166
  return cmd.action(
166
167
  action(async (ctx: Context, _opts, commentId: string) => {
167
168
  if (!(await confirmDestructive(ctx, `Delete comment ${commentId}?`))) return;
@@ -250,11 +251,12 @@ export function registerComment(program: Command): void {
250
251
  * command, which keeps its own `[id] [body]` "add a comment" behavior for every
251
252
  * other operand. The short aliases (`ls`, `edit`, `rm`) are deliberately NOT
252
253
  * registered here: each subcommand name shadows a one-word comment body under
253
- * the parent, so the collision surface stays at the four names the reference
254
- * actually ships.
254
+ * the parent. `create` is the sole additional collision because agents commonly
255
+ * infer it from other resource mutations; accepting that spelling is safer than
256
+ * rejecting an intended comment mutation.
255
257
  */
256
258
  export function registerIssueCommentGroup(issueComment: Command): void {
257
259
  for (const build of Object.values(SHARED_COMMENT_VERBS)) {
258
- issueComment.addCommand(build({ aliases: false }));
260
+ issueComment.addCommand(build({ shortAliases: false }));
259
261
  }
260
262
  }
@@ -31,7 +31,7 @@ import {
31
31
  } from "../config.js";
32
32
  import { keyring } from "../lib/keyring.js";
33
33
  import { readStdinSync } from "../lib/body.js";
34
- import { authError, normalizeError, usageError } from "../lib/errors.js";
34
+ import { CliError, authError, normalizeError, usageError } from "../lib/errors.js";
35
35
  import { confirmDestructive, promptSelect } from "../lib/prompt.js";
36
36
  import { listTeams } from "../services/team.js";
37
37
  import { resolveTeam } from "../lib/resolve.js";
@@ -177,6 +177,21 @@ export async function validateAuthCredential(
177
177
  };
178
178
  }
179
179
 
180
+ /** Bind a completed login using the same project location rules as `config set`. */
181
+ function saveLoginProject(ctx: Context, workspace: string, project: boolean): string | null {
182
+ if (!project) return null;
183
+ const path = ctx.config.projectConfigPath ?? defaultProjectConfigPath();
184
+ try {
185
+ setConfigKey(path, "workspace", workspace);
186
+ } catch {
187
+ throw new CliError(
188
+ `Credentials saved for workspace '${workspace}', but its project association could not be saved to ${path}. Fix the project file and run \`linear config set workspace ${workspace}\`; login does not need to be repeated.`,
189
+ "runtime",
190
+ );
191
+ }
192
+ return path;
193
+ }
194
+
180
195
  function explicitWorkspaceSlug(input: string): string {
181
196
  const slug = input.trim();
182
197
  if (!/^[a-z0-9](?:[a-z0-9-]{0,98}[a-z0-9])?$/.test(slug)) {
@@ -202,7 +217,8 @@ export function registerMeta(program: Command): void {
202
217
  // login -------------------------------------------------------------------
203
218
  auth
204
219
  .command("login")
205
- .description("Authenticate in the browser with OAuth, or store a personal API key")
220
+ .description("Authenticate and select the workspace for this project")
221
+ .option("--no-project", "save credentials without changing the project workspace")
206
222
  .option("--key <key>", "use a personal API key ('-' reads it from stdin)")
207
223
  .option("--plaintext", "Store the key in the config file (0600) instead of the system keyring")
208
224
  .option("--no-browser", "print the authorization URL instead of opening it")
@@ -241,6 +257,7 @@ export function registerMeta(program: Command): void {
241
257
  const { path, storage, keyringLabel } = writeCredential(org.urlKey, key, {
242
258
  plaintext: opts.plaintext === true,
243
259
  });
260
+ const projectConfigPath = saveLoginProject(ctx, org.urlKey, opts.project !== false);
244
261
  const where =
245
262
  storage === "keychain"
246
263
  ? `Credential saved to the ${keyringLabel}; ${path} records the workspace.`
@@ -253,10 +270,11 @@ export function registerMeta(program: Command): void {
253
270
  user: me,
254
271
  storage,
255
272
  path,
273
+ projectConfigPath,
256
274
  },
257
275
  () =>
258
276
  ctx.output.success(
259
- `Authenticated as ${me.name} <${me.email}> for workspace '${org.urlKey}'. ${where}`,
277
+ `Authenticated as ${me.name} <${me.email}> for workspace '${org.urlKey}'. ${where}${projectConfigPath ? ` Project workspace saved to ${projectConfigPath}.` : ""}`,
260
278
  ),
261
279
  );
262
280
  return;
@@ -327,6 +345,11 @@ export function registerMeta(program: Command): void {
327
345
  user: identity.user,
328
346
  };
329
347
  const saved = writeOAuthCredential(credential);
348
+ const projectConfigPath = saveLoginProject(
349
+ ctx,
350
+ credential.workspace.urlKey,
351
+ opts.project !== false,
352
+ );
330
353
  ctx.output.emit(
331
354
  {
332
355
  success: true,
@@ -337,10 +360,11 @@ export function registerMeta(program: Command): void {
337
360
  scopes: credential.scopes,
338
361
  expiresAt: new Date(credential.expiresAt).toISOString(),
339
362
  path: saved.path,
363
+ projectConfigPath,
340
364
  },
341
365
  () =>
342
366
  ctx.output.success(
343
- `Authenticated as ${credential.user.name} <${credential.user.email}> for workspace '${credential.workspace.urlKey}'. OAuth credentials saved to the ${saved.keyringLabel}.`,
367
+ `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}.` : ""}`,
344
368
  ),
345
369
  );
346
370
  } finally {
@@ -455,6 +479,7 @@ export function registerMeta(program: Command): void {
455
479
  .action(
456
480
  action(async (ctx) => {
457
481
  const c = ctx.config;
482
+ if (c.workspaceChoices) throw c.apiKeyError;
458
483
  const credential = c.accessToken ?? c.apiKey;
459
484
  const credentialType = c.oauthCredential
460
485
  ? "oauth-user"
@@ -660,6 +685,7 @@ export function registerMeta(program: Command): void {
660
685
  if (!ctx.isTTY) {
661
686
  throw usageError("Pass --team <key>; there is no terminal to choose one from.");
662
687
  }
688
+ await ctx.selectWorkspace();
663
689
  const teams = await listTeams(ctx.client, Infinity);
664
690
  if (teams.length === 0) throw usageError("This workspace has no teams to choose from.");
665
691
  team = await promptSelect(
@@ -46,11 +46,12 @@ async function resolveOpenTarget(
46
46
  ctx: Context,
47
47
  target: string | undefined,
48
48
  ): Promise<{ target: OpenTarget; url: string; label: string }> {
49
+ if (target && URL.test(target)) return { target: "url", url: target, label: target };
50
+ await ctx.selectWorkspace();
49
51
  if (!target) {
50
52
  const org = await getOrganizationDetail(ctx.client);
51
53
  return { target: "workspace", url: `https://linear.app/${org.urlKey}`, label: org.name };
52
54
  }
53
- if (URL.test(target)) return { target: "url", url: target, label: target };
54
55
  if (target.startsWith("team:")) {
55
56
  const team = await resolveTeam(ctx.client, target.slice("team:".length), ctx.defaultTeam);
56
57
  const org = await getOrganizationDetail(ctx.client);
package/src/config.ts CHANGED
@@ -113,6 +113,8 @@ export interface ResolvedConfig {
113
113
  * needed (see createClient / `auth token`).
114
114
  */
115
115
  apiKeyError?: CliError;
116
+ /** Stored choices when selection is ambiguous; never an implicit default. */
117
+ workspaceChoices?: string[];
116
118
  /** Workspace slug whose stored credential was used, if any. */
117
119
  credentialWorkspace?: string;
118
120
  /** Selected stored profile whose non-secret metadata may participate. */
@@ -467,6 +469,7 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
467
469
  let oauthCredential: OAuthUserCredential | undefined;
468
470
  let credentialWorkspace: string | undefined;
469
471
  let workspaceProfile: string | undefined;
472
+ let workspaceChoices: string[] | undefined;
470
473
  let apiKeyError: CliError | undefined;
471
474
 
472
475
  if (flags.apiKey && flags.accessToken) {
@@ -539,8 +542,9 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
539
542
  take(slugs[0]!);
540
543
  } else if (slugs.length > 1) {
541
544
  // Multiple workspaces but no default → ambiguous (deferred).
545
+ workspaceChoices = slugs;
542
546
  apiKeyError = new CliError(
543
- `Multiple workspaces are configured (${slugs.join(", ")}) but none is selected. Pass --workspace <slug> or run \`linear auth default <slug>\`.`,
547
+ `Multiple workspaces are configured (${slugs.join(", ")}) but none is selected. Pass --workspace <slug>, run \`linear config set workspace <slug>\` for this project, or explicitly choose \`linear auth default <slug>\`.`,
544
548
  "usage",
545
549
  );
546
550
  }
@@ -621,6 +625,7 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
621
625
  apiKeyError,
622
626
  credentialWorkspace,
623
627
  workspaceProfile,
628
+ workspaceChoices,
624
629
  team: teamTiers.find(([, settings]) => settings.team !== undefined)?.[1].team,
625
630
  // The display setting walks every tier. Credential selection above uses
626
631
  // the flag, env, and project tiers, then the auth-specific user default.
@@ -1001,10 +1006,6 @@ export function writeOAuthCredential(credential: OAuthUserCredential): WriteOAut
1001
1006
  table.keyring = true;
1002
1007
  table.oauth = true;
1003
1008
  ws[slug] = table;
1004
- if (!asString(obj.default_workspace)) {
1005
- const reference = readReferenceCredentials(referenceCredentialsPath());
1006
- if (!reference.defaultWorkspace) obj.default_workspace = slug;
1007
- }
1008
1009
  writeUserObject(path, obj);
1009
1010
  } catch (err) {
1010
1011
  try {
@@ -1130,10 +1131,6 @@ export function adoptKeyringCredential(
1130
1131
  delete table.api_key;
1131
1132
  table.keyring = true;
1132
1133
  ws[slug] = table;
1133
- if (!asString(obj.default_workspace)) {
1134
- const reference = readReferenceCredentials(referenceCredentialsPath());
1135
- if (!reference.defaultWorkspace) obj.default_workspace = slug;
1136
- }
1137
1134
  writeUserObject(path, obj);
1138
1135
  return { path, storage: "keychain", keyringLabel: found.keyringLabel };
1139
1136
  });
@@ -1146,8 +1143,8 @@ export function adoptKeyringCredential(
1146
1143
  * no keyring, it is written as `api_key` in the 0600 file — the latter without
1147
1144
  * complaint, since a platform is not an error. Either way the OTHER form for
1148
1145
  * the same slug is dropped from the file, so a re-login moves the key rather
1149
- * than leaving a plaintext copy behind that would keep winning. If no default
1150
- * workspace exists anywhere (ours or the reference CLI's), this slug becomes it.
1146
+ * than leaving a plaintext copy behind that would keep winning. Global defaults
1147
+ * are only written by setDefaultWorkspace; existing legacy defaults are preserved.
1151
1148
  */
1152
1149
  export function writeCredential(
1153
1150
  slug: string,
@@ -1177,12 +1174,6 @@ export function writeCredential(
1177
1174
  }
1178
1175
  systemBackend?.delete(oauthAccount(slug));
1179
1176
  ws[slug] = table;
1180
- if (!asString(obj.default_workspace)) {
1181
- // Leave the reference CLI's default in charge if it has one; writing ours
1182
- // over it would silently switch a migrating user's workspace.
1183
- const reference = readReferenceCredentials(referenceCredentialsPath());
1184
- if (!reference.defaultWorkspace) obj.default_workspace = slug;
1185
- }
1186
1177
  writeUserObject(path, obj);
1187
1178
  return { path, storage, keyringLabel: backend?.label };
1188
1179
  });
@@ -1242,7 +1233,7 @@ function removeFromReferenceCredentials(slug: string): boolean {
1242
1233
  const reference = readReferenceCredentials(path);
1243
1234
  if (!reference.workspaces.includes(slug)) return false;
1244
1235
  const workspaces = reference.workspaces.filter((s) => s !== slug).sort();
1245
- const def = reference.defaultWorkspace === slug ? workspaces[0] : reference.defaultWorkspace;
1236
+ const def = reference.defaultWorkspace === slug ? undefined : reference.defaultWorkspace;
1246
1237
  const obj: Record<string, unknown> = {};
1247
1238
  if (def) obj.default = def;
1248
1239
  obj.workspaces = workspaces;
@@ -1253,8 +1244,8 @@ function removeFromReferenceCredentials(slug: string): boolean {
1253
1244
  /**
1254
1245
  * Forget a workspace: remove its `[workspaces."<slug>"]` table, its keyring
1255
1246
  * entry, and its line in the reference CLI's list — whichever exist. If the
1256
- * removed slug was the default, the default is repointed to a remaining
1257
- * workspace (or cleared). Returns true if anything was removed.
1247
+ * removed slug was the default, that default is cleared without choosing another
1248
+ * workspace. Returns true if anything was removed.
1258
1249
  */
1259
1250
  export interface RemoveCredentialResult {
1260
1251
  removed: boolean;
@@ -1287,9 +1278,7 @@ export function removeCredentialWithMetadata(slug: string): RemoveCredentialResu
1287
1278
  if (Object.keys(ws).length === 0) delete obj.workspaces;
1288
1279
 
1289
1280
  if (asString(obj.default_workspace) === slug) {
1290
- const remaining = obj.workspaces ? Object.keys(obj.workspaces as object) : [];
1291
- if (remaining.length > 0) obj.default_workspace = remaining[0]!;
1292
- else delete obj.default_workspace;
1281
+ delete obj.default_workspace;
1293
1282
  writeUserObject(path, obj);
1294
1283
  } else if (listed) {
1295
1284
  writeUserObject(path, obj);
@@ -1344,7 +1333,6 @@ export function removeOAuthCredential(slug: string): RemoveOAuthCredentialResult
1344
1333
  const table = ws[slug];
1345
1334
  const hadOAuthMarker = table?.oauth === true;
1346
1335
  if (hadOAuthMarker) removed = true;
1347
- const reference = readReferenceCredentials(referenceCredentialsPath());
1348
1336
  const apiKeyFallback = Boolean(asString(table?.api_key)) || keyringApiKey;
1349
1337
 
1350
1338
  if (apiKeyFallback) {
@@ -1360,11 +1348,7 @@ export function removeOAuthCredential(slug: string): RemoveOAuthCredentialResult
1360
1348
  if (Object.keys(ws).length === 0) delete obj.workspaces;
1361
1349
 
1362
1350
  if (!apiKeyFallback && asString(obj.default_workspace) === slug) {
1363
- const remaining = obj.workspaces ? Object.keys(obj.workspaces as object) : [];
1364
- const referenceFallback = reference.workspaces.find((candidate) => candidate !== slug);
1365
- if (remaining.length > 0) obj.default_workspace = remaining[0]!;
1366
- else if (referenceFallback) obj.default_workspace = referenceFallback;
1367
- else delete obj.default_workspace;
1351
+ delete obj.default_workspace;
1368
1352
  }
1369
1353
  if (hadOAuthMarker || apiKeyFallback) writeUserObject(path, obj);
1370
1354
  return {
package/src/context.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  import type { LinearClient } from "@linear/sdk";
8
8
  import { resolveConfig, type ResolvedConfig } from "./config.js";
9
9
  import { createClient, setRetryReporter } from "./client.js";
10
+ import { promptSelect } from "./lib/prompt.js";
10
11
  import { Output } from "./output/format.js";
11
12
  import { isDebugEnabled, shouldUseColor } from "./output/color.js";
12
13
 
@@ -46,7 +47,7 @@ export function firstTeam(team: string | string[] | undefined): string | undefin
46
47
  }
47
48
 
48
49
  export class Context {
49
- readonly config: ResolvedConfig;
50
+ config: ResolvedConfig;
50
51
  readonly output: Output;
51
52
  readonly options: GlobalOptions;
52
53
  readonly isTTY: boolean;
@@ -96,6 +97,25 @@ export class Context {
96
97
  setRetryReporter((line) => this.output.info(line));
97
98
  }
98
99
 
100
+ /** Choose only for this invocation; never persist an interactive fallback. */
101
+ async selectWorkspace(): Promise<void> {
102
+ const choices = this.config.workspaceChoices;
103
+ if (!choices || !this.isTTY) return;
104
+ const workspace = await promptSelect(
105
+ this,
106
+ "Select a workspace for this invocation",
107
+ choices.map((slug) => ({ name: slug, value: slug })),
108
+ );
109
+ this.config = resolveConfig({
110
+ flags: {
111
+ apiKey: this.options.apiKey,
112
+ accessToken: this.options.accessToken,
113
+ team: firstTeam(this.options.team),
114
+ workspace,
115
+ },
116
+ });
117
+ }
118
+
99
119
  /** Lazily construct the Linear client (so `--help` never needs a key). */
100
120
  get client(): LinearClient {
101
121
  if (!this._client) this._client = createClient(this.config);
package/src/lib/action.ts CHANGED
@@ -21,6 +21,14 @@ export function action(handler: ActionHandler) {
21
21
  const localOpts = (args[args.length - 2] ?? {}) as Record<string, any>;
22
22
  const positionals = args.slice(0, -2);
23
23
  const ctx = new Context(command.optsWithGlobals() as GlobalOptions);
24
+ // Repair and local discovery need no selection. Config/open select within
25
+ // their handlers only when the chosen path needs credentials.
26
+ let group = command;
27
+ while (group.parent?.parent) group = group.parent;
28
+ const offline =
29
+ ["config", "open", "commands", "completion"].includes(group.name()) ||
30
+ (group.name() === "auth" && !["whoami", "status", "token"].includes(command.name()));
31
+ if (!offline) await ctx.selectWorkspace();
24
32
  await handler(ctx, localOpts, ...positionals);
25
33
  };
26
34
  }
@@ -328,6 +328,7 @@ export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
328
328
  "scopes?": ["string"],
329
329
  "expiresAt?": "string",
330
330
  path: "string",
331
+ projectConfigPath: "string|null",
331
332
  }),
332
333
  "auth logout": receipt({
333
334
  success: "boolean",
@@ -209,46 +209,60 @@ export async function resolveLabelIds(
209
209
  names: string[],
210
210
  teamId?: string,
211
211
  ): Promise<string[]> {
212
- const ids: string[] = [];
213
- for (const name of names) {
214
- if (isUuid(name)) {
215
- ids.push(name);
216
- continue;
212
+ // Share in-flight searches only within this call. Later calls must observe
213
+ // renamed/deleted labels, and exact spelling matters for case preference.
214
+ const lookups = new Map<string, Promise<any[]>>();
215
+ const lookup = (name: string): Promise<any[]> => {
216
+ let pending = lookups.get(name);
217
+ if (!pending) {
218
+ pending = (async () => {
219
+ const conn = await withRetry(() =>
220
+ client.issueLabels({
221
+ filter: { name: { eqIgnoreCase: name } } as any,
222
+ first: RESOLVE_PAGE,
223
+ }),
224
+ );
225
+ // Share the complete scan too: concurrent consumers must not advance
226
+ // the same mutable SDK connection independently.
227
+ return scanAll<any>(conn as any, "labels", "linear label list");
228
+ })();
229
+ lookups.set(name, pending);
217
230
  }
218
- const conn = await withRetry(() =>
219
- client.issueLabels({ filter: { name: { eqIgnoreCase: name } } as any, first: RESOLVE_PAGE }),
220
- );
221
- // Scanned rather than capped: the team narrowing below can discard every
222
- // label on the first page, so a fixed page cap could turn a label that
223
- // exists into a not-found.
224
- const nodes = await scanAll<any>(conn as any, "labels", "linear label list");
225
- if (nodes.length === 0)
226
- throw notFound(`No label matching '${name}'. Run 'linear label list' to see the options.`);
227
-
228
- // Narrow to this team's labels + workspace-level labels when a team is known.
229
- // If a team is known and nothing is in scope, that's not-found (do NOT fall
230
- // back to an out-of-scope label from another team).
231
- // Label groups are containers, not labels that can be applied to an issue.
232
- let candidates: any[] = nodes.filter((label: any) => !label.isGroup);
233
- if (teamId) {
234
- const scoped = await Promise.all(
235
- nodes.map(async (l: any) => ({ label: l, team: await l.team })),
236
- );
237
- candidates = scoped.filter((s) => !s.team || s.team.id === teamId).map((s) => s.label);
238
- if (candidates.length === 0) throw notFound(`No label '${name}' in this team or workspace.`);
239
- }
240
-
241
- // Prefer an exact (case-sensitive) match.
242
- const exact = candidates.filter((l: any) => l.name === name);
243
- const finalists = exact.length ? exact : candidates;
244
- if (finalists.length > 1) {
245
- throw ambiguous(
246
- `Multiple labels named '${name}'${teamId ? " in scope" : ""}; pass the label id instead.`,
247
- );
248
- }
249
- ids.push(finalists[0]!.id);
250
- }
251
- return ids;
231
+ return pending;
232
+ };
233
+ return Promise.all(
234
+ names.map(async (name) => {
235
+ if (isUuid(name)) return name;
236
+ const nodes = await lookup(name);
237
+ if (nodes.length === 0)
238
+ throw notFound(`No label matching '${name}'. Run 'linear label list' to see the options.`);
239
+
240
+ // Narrow to this team's labels + workspace-level labels when a team is known.
241
+ // If a team is known and nothing is in scope, that's not-found (do NOT fall
242
+ // back to an out-of-scope label from another team).
243
+ // Label groups are containers, not labels that can be applied to an issue.
244
+ let candidates: any[] = nodes.filter((label: any) => !label.isGroup);
245
+ if (teamId) {
246
+ // The SDK includes teamId in the label response; accessing team fetches it again.
247
+ candidates = candidates.filter((label) => !label.teamId || label.teamId === teamId);
248
+ if (candidates.length === 0)
249
+ throw notFound(`No label '${name}' in this team or workspace.`);
250
+ }
251
+
252
+ if (candidates.length === 0)
253
+ throw notFound(`No label matching '${name}'. Run 'linear label list' to see the options.`);
254
+
255
+ // Prefer an exact (case-sensitive) match.
256
+ const exact = candidates.filter((l: any) => l.name === name);
257
+ const finalists = exact.length ? exact : candidates;
258
+ if (finalists.length > 1) {
259
+ throw ambiguous(
260
+ `Multiple labels named '${name}'${teamId ? " in scope" : ""}; pass the label id instead.`,
261
+ );
262
+ }
263
+ return finalists[0]!.id;
264
+ }),
265
+ );
252
266
  }
253
267
 
254
268
  /**