linear-sdk-cli 0.2.4 → 0.3.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +36 -16
  2. package/MIGRATING.md +7 -6
  3. package/README.md +120 -31
  4. package/package.json +5 -3
  5. package/skills/linear-sdk-cli/SKILL.md +43 -8
  6. package/skills/linear-sdk-cli/references/api.md +1 -1
  7. package/skills/linear-sdk-cli/references/attachment.md +1 -1
  8. package/skills/linear-sdk-cli/references/auth.md +26 -7
  9. package/skills/linear-sdk-cli/references/commands.md +1 -1
  10. package/skills/linear-sdk-cli/references/comment.md +1 -1
  11. package/skills/linear-sdk-cli/references/completion.md +1 -1
  12. package/skills/linear-sdk-cli/references/config.md +5 -1
  13. package/skills/linear-sdk-cli/references/cycle.md +1 -1
  14. package/skills/linear-sdk-cli/references/document.md +1 -1
  15. package/skills/linear-sdk-cli/references/favorite.md +1 -1
  16. package/skills/linear-sdk-cli/references/initiative-update.md +1 -1
  17. package/skills/linear-sdk-cli/references/initiative.md +1 -1
  18. package/skills/linear-sdk-cli/references/issue.md +1 -1
  19. package/skills/linear-sdk-cli/references/label.md +1 -1
  20. package/skills/linear-sdk-cli/references/milestone.md +1 -1
  21. package/skills/linear-sdk-cli/references/notification.md +1 -1
  22. package/skills/linear-sdk-cli/references/open.md +1 -1
  23. package/skills/linear-sdk-cli/references/organization.md +1 -1
  24. package/skills/linear-sdk-cli/references/project-update.md +1 -1
  25. package/skills/linear-sdk-cli/references/project.md +1 -1
  26. package/skills/linear-sdk-cli/references/roadmap.md +1 -1
  27. package/skills/linear-sdk-cli/references/schema.md +1 -1
  28. package/skills/linear-sdk-cli/references/state.md +1 -1
  29. package/skills/linear-sdk-cli/references/team.md +1 -1
  30. package/skills/linear-sdk-cli/references/user.md +1 -1
  31. package/skills/linear-sdk-cli/references/webhook.md +1 -1
  32. package/skills/linear-sdk-cli/references/whoami.md +1 -1
  33. package/skills/linear-sdk-cli/scripts/generate-docs.ts +3 -1
  34. package/src/client.ts +57 -2
  35. package/src/commands/meta.ts +240 -65
  36. package/src/config.ts +319 -16
  37. package/src/context.ts +2 -0
  38. package/src/index.ts +19 -0
  39. package/src/lib/introspect.ts +1 -0
  40. package/src/lib/options.ts +1 -0
  41. package/src/lib/output-shapes.ts +21 -2
  42. package/src/oauth.ts +582 -0
package/src/config.ts CHANGED
@@ -10,18 +10,23 @@
10
10
  * each directory — every location schpet/linear-cli 2.5 reads (it checks
11
11
  * cwd and the git root; we check every directory between, which is a
12
12
  * superset), in its order.
13
- * - The API key has a STRICTER boundary and is never read from a project file
13
+ * - Authentication secrets have a STRICTER boundary and are never read from a project file
14
14
  * or from the reference CLI's global file (avoids committing secrets):
15
- * flag > LINEAR_API_KEY env > user config (plaintext `api_key`) > OS keyring.
15
+ * Explicit `--access-token` / `--api-key` flags override their environment counterparts.
16
+ * Invocation OAuth access tokens are stateless (`LINEAR_ACCESS_TOKEN` only). Human browser
17
+ * OAuth sessions and API keys may also come from the OS keyring; API keys alone may opt into
18
+ * the user config as plaintext.
16
19
  * A project `workspace` may select which already-stored credential to use;
17
20
  * it can never provide or override the secret itself.
18
21
  *
19
22
  * Multi-workspace credentials live under quoted `[workspaces."<slug>"]` tables
20
23
  * in the user config, with an optional top-level `default_workspace`. A table
21
24
  * either carries the key itself (`api_key = "…"`, plaintext, 0600 file) or
22
- * marks the slug as keyring-backed (`keyring = true`), in which case the secret
25
+ * marks the slug as keyring-backed (`keyring = true`), in which case an API-key secret
23
26
  * lives in the OS keyring under service `linear-cli` / account `<slug>` — the
24
- * reference CLI's convention, so a user migrating from it is authenticated
27
+ * reference CLI's convention. A browser OAuth session uses the isolated account
28
+ * `oauth:<slug>` and a non-secret `oauth = true` marker, so its rotating token record
29
+ * cannot be mistaken for an API key. A user migrating from the reference CLI is authenticated
25
30
  * before they run a single command. Its own workspace list
26
31
  * (`credentials.toml`, a sibling of our file) is read for slugs and the
27
32
  * default, never for keys. Credential selection is lazy: resolution never
@@ -50,18 +55,20 @@ import { execFileSync } from "node:child_process";
50
55
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
51
56
  import { CliError } from "./lib/errors.js";
52
57
  import { keyring, KeyringError } from "./lib/keyring.js";
58
+ import type { OAuthUserCredential } from "./oauth.js";
53
59
 
54
60
  export interface RawSettings {
55
61
  team?: string;
56
62
  workspace?: string;
57
63
  sort?: string;
58
64
  apiKey?: string;
65
+ accessToken?: string;
59
66
  }
60
67
 
61
68
  /**
62
69
  * Where a value came from. `user` is our `~/.config/linear/config.toml`;
63
70
  * `global` is the reference CLI's `~/.config/linear/linear.toml`, read for
64
- * non-secret settings only; `keychain` is the OS keyring (API key only).
71
+ * non-secret settings only; `keychain` is the OS keyring (API key or human OAuth session).
65
72
  */
66
73
  export type ConfigSource = "flag" | "env" | "project" | "user" | "global" | "keychain" | "none";
67
74
 
@@ -82,9 +89,14 @@ export type SettingOrigins = Record<"team" | "workspace" | "sort", SettingOrigin
82
89
  export interface ResolvedConfig {
83
90
  apiKey?: string;
84
91
  apiKeySource: ConfigSource;
92
+ /** OAuth access token supplied for this invocation; never read from or written to config. */
93
+ accessToken?: string;
94
+ accessTokenSource: ConfigSource;
95
+ /** Keyring-backed human OAuth session. Never serialized outside the keyring. */
96
+ oauthCredential?: OAuthUserCredential;
85
97
  /**
86
- * Deferred credential-selection error. Resolution is total (it never throws
87
- * for selection problems) so commands that REPAIR auth state — `auth list`,
98
+ * Deferred credential-resolution error. Resolution is total (it never throws
99
+ * for selection or ambiguous identity problems) so commands that REPAIR auth state — `auth list`,
88
100
  * `auth default`, `auth logout`, `auth login --workspace new-org` — can still
89
101
  * build a Context. The error is surfaced only when an API client is actually
90
102
  * needed (see createClient / `auth token`).
@@ -160,6 +172,8 @@ export function referenceCredentialsPath(env: NodeJS.ProcessEnv = process.env):
160
172
  interface WorkspaceEntry {
161
173
  /** Plaintext key from `[workspaces."<slug>"].api_key`; absent → keyring-backed. */
162
174
  apiKey?: string;
175
+ /** This workspace uses the isolated `oauth:<slug>` keyring account. */
176
+ oauth?: boolean;
163
177
  }
164
178
 
165
179
  /** Parsed view of the user config: top-level settings + nested workspace creds. */
@@ -310,7 +324,11 @@ function readUserConfig(path: string): UserConfig {
310
324
  if (val && typeof val === "object") {
311
325
  // A table with no api_key is a keyring-backed workspace (whether or
312
326
  // not it carries the `keyring = true` marker `auth login` writes).
313
- workspaces[slug] = { apiKey: asString((val as Record<string, unknown>).api_key) };
327
+ const table = val as Record<string, unknown>;
328
+ workspaces[slug] = {
329
+ apiKey: asString(table.api_key),
330
+ oauth: table.oauth === true,
331
+ };
314
332
  }
315
333
  }
316
334
  }
@@ -335,7 +353,21 @@ function readUserConfig(path: string): UserConfig {
335
353
  function lookupCredential(
336
354
  user: UserConfig,
337
355
  slug: string,
338
- ): { apiKey: string; source: "user" | "keychain" } | { error: CliError } | undefined {
356
+ ):
357
+ | { apiKey: string; source: "user" | "keychain" }
358
+ | { oauthCredential: OAuthUserCredential; source: "keychain" }
359
+ | { error: CliError }
360
+ | undefined {
361
+ if (user.workspaces[slug]?.oauth) {
362
+ try {
363
+ const credential = readOAuthCredential(slug);
364
+ return credential ? { oauthCredential: credential, source: "keychain" } : undefined;
365
+ } catch (err) {
366
+ return {
367
+ error: err instanceof CliError ? err : new CliError("Could not read OAuth credentials."),
368
+ };
369
+ }
370
+ }
339
371
  const plaintext = user.workspaces[slug]?.apiKey;
340
372
  if (plaintext) return { apiKey: plaintext, source: "user" };
341
373
  const backend = keyring();
@@ -385,6 +417,7 @@ export function findProjectConfig(cwd: string): string | undefined {
385
417
  function fromEnv(env: NodeJS.ProcessEnv): RawSettings {
386
418
  return {
387
419
  apiKey: env.LINEAR_API_KEY || env.LINEAR_API_TOKEN,
420
+ accessToken: env.LINEAR_ACCESS_TOKEN,
388
421
  team: env.LINEAR_TEAM || env.LINEAR_TEAM_ID,
389
422
  workspace: env.LINEAR_WORKSPACE,
390
423
  sort: env.LINEAR_ISSUE_SORT,
@@ -407,19 +440,38 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
407
440
  const globalPath = existsSync(globalCandidate) ? globalCandidate : undefined;
408
441
  const global = globalPath ? readTomlFile(globalPath) : { settings: {}, keys: {} };
409
442
 
410
- // ----- API key resolution (strict trust boundary) -----------------------
443
+ // ----- Credential resolution (strict trust boundary) --------------------
411
444
  // Resolution is TOTAL: selection problems are stashed in `apiKeyError` rather
412
445
  // than thrown, so auth-repair commands can still build a Context. The error
413
446
  // is surfaced lazily, when an API client is actually needed.
414
447
  let apiKey: string | undefined;
415
448
  let apiKeySource: ConfigSource = "none";
449
+ let accessToken: string | undefined;
450
+ let accessTokenSource: ConfigSource = "none";
451
+ let oauthCredential: OAuthUserCredential | undefined;
416
452
  let credentialWorkspace: string | undefined;
417
453
  let apiKeyError: CliError | undefined;
418
454
 
419
- if (flags.apiKey) {
420
- // 1. An explicit flag is absolute: it bypasses all workspace selection.
455
+ if (flags.apiKey && flags.accessToken) {
456
+ apiKeyError = new CliError(
457
+ "Pass only one of --api-key or --access-token; they may identify different Linear actors.",
458
+ "usage",
459
+ );
460
+ } else if (flags.accessToken) {
461
+ // 1. An explicit credential flag is absolute: it bypasses env and workspace selection.
462
+ accessToken = flags.accessToken;
463
+ accessTokenSource = "flag";
464
+ } else if (flags.apiKey) {
421
465
  apiKey = flags.apiKey;
422
466
  apiKeySource = "flag";
467
+ } else if (envSettings.apiKey && envSettings.accessToken) {
468
+ apiKeyError = new CliError(
469
+ "Both LINEAR_API_KEY/LINEAR_API_TOKEN and LINEAR_ACCESS_TOKEN are set. Unset one so the Linear actor is unambiguous.",
470
+ "usage",
471
+ );
472
+ } else if (envSettings.accessToken) {
473
+ accessToken = envSettings.accessToken;
474
+ accessTokenSource = "env";
423
475
  } else if (envSettings.apiKey) {
424
476
  // 1. LINEAR_API_KEY/LINEAR_API_TOKEN is absolute too.
425
477
  apiKey = envSettings.apiKey;
@@ -448,8 +500,14 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
448
500
  } else if ("error" in found) {
449
501
  apiKeyError = found.error;
450
502
  } else {
451
- apiKey = found.apiKey;
452
- apiKeySource = found.source;
503
+ if ("oauthCredential" in found) {
504
+ oauthCredential = found.oauthCredential;
505
+ accessToken = found.oauthCredential.accessToken;
506
+ accessTokenSource = found.source;
507
+ } else {
508
+ apiKey = found.apiKey;
509
+ apiKeySource = found.source;
510
+ }
453
511
  credentialWorkspace = slug;
454
512
  }
455
513
  };
@@ -504,6 +562,9 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
504
562
  return {
505
563
  apiKey,
506
564
  apiKeySource,
565
+ accessToken,
566
+ accessTokenSource,
567
+ oauthCredential,
507
568
  apiKeyError,
508
569
  credentialWorkspace,
509
570
  team: pick("team"),
@@ -761,6 +822,7 @@ function writeUserObject(path: string, obj: Record<string, unknown>): void {
761
822
  interface WorkspaceTable {
762
823
  api_key?: string;
763
824
  keyring?: boolean;
825
+ oauth?: boolean;
764
826
  }
765
827
 
766
828
  function workspacesTable(obj: Record<string, unknown>): Record<string, WorkspaceTable> {
@@ -777,6 +839,166 @@ export interface WriteCredentialResult {
777
839
  keyringLabel?: string;
778
840
  }
779
841
 
842
+ export const OAUTH_KEYRING_ACCOUNT_PREFIX = "oauth:";
843
+
844
+ function oauthAccount(slug: string): string {
845
+ return `${OAUTH_KEYRING_ACCOUNT_PREFIX}${slug}`;
846
+ }
847
+
848
+ function parseOAuthCredential(slug: string, raw: string): OAuthUserCredential {
849
+ let value: unknown;
850
+ try {
851
+ value = JSON.parse(raw);
852
+ } catch {
853
+ throw new CliError(
854
+ `The stored OAuth credential for workspace '${sanitize(slug)}' is malformed. Run \`linear auth logout --workspace ${sanitize(slug)} --local-only\` and sign in again.`,
855
+ "auth",
856
+ );
857
+ }
858
+ const c = value as Partial<OAuthUserCredential> | null;
859
+ if (
860
+ !c ||
861
+ c.version !== 1 ||
862
+ c.kind !== "oauth-user" ||
863
+ c.actor !== "user" ||
864
+ typeof c.accessToken !== "string" ||
865
+ !c.accessToken ||
866
+ typeof c.refreshToken !== "string" ||
867
+ !c.refreshToken ||
868
+ typeof c.expiresAt !== "number" ||
869
+ !Number.isFinite(c.expiresAt) ||
870
+ !Array.isArray(c.scopes) ||
871
+ c.scopes.some((scope) => typeof scope !== "string" || !scope) ||
872
+ c.tokenType !== "Bearer" ||
873
+ typeof c.clientId !== "string" ||
874
+ !c.clientId ||
875
+ !c.workspace ||
876
+ typeof c.workspace.id !== "string" ||
877
+ typeof c.workspace.name !== "string" ||
878
+ c.workspace.urlKey !== slug ||
879
+ !c.user ||
880
+ typeof c.user.id !== "string" ||
881
+ typeof c.user.name !== "string" ||
882
+ typeof c.user.email !== "string"
883
+ ) {
884
+ throw new CliError(
885
+ `The stored OAuth credential for workspace '${sanitize(slug)}' is invalid. Sign in again.`,
886
+ "auth",
887
+ );
888
+ }
889
+ return c as OAuthUserCredential;
890
+ }
891
+
892
+ /** Read one explicitly selected human OAuth session from its isolated keyring account. */
893
+ export function readOAuthCredential(slug: string): OAuthUserCredential | undefined {
894
+ const backend = keyring();
895
+ if (!backend) {
896
+ throw new CliError(
897
+ `A system keyring is required for OAuth credentials for workspace '${sanitize(slug)}'.`,
898
+ "runtime",
899
+ );
900
+ }
901
+ let raw: string | null;
902
+ try {
903
+ raw = backend.get(oauthAccount(slug));
904
+ } catch {
905
+ throw new CliError(
906
+ `Could not read the ${backend.label} OAuth entry for workspace '${sanitize(slug)}'.`,
907
+ "runtime",
908
+ );
909
+ }
910
+ return raw ? parseOAuthCredential(slug, raw) : undefined;
911
+ }
912
+
913
+ export interface WriteOAuthCredentialResult {
914
+ path: string;
915
+ storage: "keychain";
916
+ keyringLabel: string;
917
+ }
918
+
919
+ /** Store the complete OAuth session in the keyring; the config gets only a non-secret marker. */
920
+ export function writeOAuthCredential(credential: OAuthUserCredential): WriteOAuthCredentialResult {
921
+ const slug = credential.workspace.urlKey;
922
+ const path = userConfigPath();
923
+ const backend = keyring();
924
+ if (!backend) {
925
+ throw new CliError(
926
+ "Browser OAuth requires a usable system keyring; use `linear auth login --key -` on this platform.",
927
+ "runtime",
928
+ );
929
+ }
930
+ return withUserConfigLock(path, () => {
931
+ const account = oauthAccount(slug);
932
+ let previous: string | null;
933
+ try {
934
+ previous = backend.get(account);
935
+ backend.set(account, JSON.stringify(credential));
936
+ } catch {
937
+ throw new CliError(
938
+ `Could not store OAuth credentials for workspace '${sanitize(slug)}' in the ${backend.label}.`,
939
+ "runtime",
940
+ );
941
+ }
942
+ try {
943
+ const obj = readUserObject(path);
944
+ const ws = workspacesTable(obj);
945
+ const table: WorkspaceTable = { ...(ws[slug] ?? {}) };
946
+ table.keyring = true;
947
+ table.oauth = true;
948
+ ws[slug] = table;
949
+ if (!asString(obj.default_workspace)) {
950
+ const reference = readReferenceCredentials(referenceCredentialsPath());
951
+ if (!reference.defaultWorkspace) obj.default_workspace = slug;
952
+ }
953
+ writeUserObject(path, obj);
954
+ } catch (err) {
955
+ try {
956
+ if (previous === null) backend.delete(account);
957
+ else backend.set(account, previous);
958
+ } catch {
959
+ // The primary error is the failed config commit; do not expose secrets from a rollback error.
960
+ }
961
+ throw err;
962
+ }
963
+ return { path, storage: "keychain", keyringLabel: backend.label };
964
+ });
965
+ }
966
+
967
+ /** Compare-and-swap rotating refresh state. A concurrent winner is retained, never overwritten. */
968
+ export function rotateOAuthCredential(
969
+ slug: string,
970
+ previousRefreshToken: string,
971
+ next: OAuthUserCredential,
972
+ ): OAuthUserCredential {
973
+ const path = userConfigPath();
974
+ return withUserConfigLock(path, () => {
975
+ const backend = keyring();
976
+ if (!backend) throw new CliError("A system keyring is required to refresh OAuth credentials.");
977
+ let raw: string | null;
978
+ try {
979
+ raw = backend.get(oauthAccount(slug));
980
+ } catch {
981
+ throw new CliError(
982
+ `Could not read OAuth credentials for workspace '${sanitize(slug)}' from the ${backend.label}.`,
983
+ "runtime",
984
+ );
985
+ }
986
+ if (!raw)
987
+ throw new CliError(`No stored OAuth credential for workspace '${sanitize(slug)}'.`, "auth");
988
+ const current = parseOAuthCredential(slug, raw);
989
+ if (current.refreshToken !== previousRefreshToken) return current;
990
+ try {
991
+ backend.set(oauthAccount(slug), JSON.stringify(next));
992
+ } catch {
993
+ throw new CliError(
994
+ `Could not rotate OAuth credentials for workspace '${sanitize(slug)}' in the ${backend.label}.`,
995
+ "runtime",
996
+ );
997
+ }
998
+ return next;
999
+ });
1000
+ }
1001
+
780
1002
  /** A single, explicitly named credential found in the shared OS keyring. */
781
1003
  export interface KeyringCredentialProbe {
782
1004
  /** Kept in memory only; callers must never print it or pass it on argv. */
@@ -880,6 +1102,7 @@ export function writeCredential(
880
1102
  const path = userConfigPath();
881
1103
  return withUserConfigLock(path, () => {
882
1104
  const backend = opts.plaintext ? null : keyring();
1105
+ const systemBackend = keyring();
883
1106
  const obj = readUserObject(path);
884
1107
  const ws = workspacesTable(obj);
885
1108
  const table: WorkspaceTable = { ...(ws[slug] ?? {}) };
@@ -889,12 +1112,15 @@ export function writeCredential(
889
1112
  backend.set(slug, apiKey);
890
1113
  delete table.api_key;
891
1114
  table.keyring = true;
1115
+ delete table.oauth;
892
1116
  storage = "keychain";
893
1117
  } else {
894
1118
  delete table.keyring;
1119
+ delete table.oauth;
895
1120
  table.api_key = apiKey;
896
1121
  storage = "file";
897
1122
  }
1123
+ systemBackend?.delete(oauthAccount(slug));
898
1124
  ws[slug] = table;
899
1125
  if (!asString(obj.default_workspace)) {
900
1126
  // Leave the reference CLI's default in charge if it has one; writing ours
@@ -956,7 +1182,10 @@ export function removeCredential(slug: string): boolean {
956
1182
  let removed = false;
957
1183
 
958
1184
  const backend = keyring();
959
- if (backend && backend.delete(slug)) removed = true;
1185
+ if (backend) {
1186
+ if (backend.delete(slug)) removed = true;
1187
+ if (backend.delete(oauthAccount(slug))) removed = true;
1188
+ }
960
1189
  if (removeFromReferenceCredentials(slug)) removed = true;
961
1190
 
962
1191
  if (!existsSync(path)) return removed;
@@ -981,6 +1210,78 @@ export function removeCredential(slug: string): boolean {
981
1210
  });
982
1211
  }
983
1212
 
1213
+ export interface RemoveOAuthCredentialResult {
1214
+ removed: boolean;
1215
+ fallbackCredentialType: "api-key" | null;
1216
+ }
1217
+
1218
+ /** Remove only the human OAuth lifecycle, restoring a pre-existing API-key profile if present. */
1219
+ export function removeOAuthCredential(slug: string): RemoveOAuthCredentialResult {
1220
+ const path = userConfigPath();
1221
+ return withUserConfigLock(path, () => {
1222
+ const backend = keyring();
1223
+ let oauthRaw: string | null = null;
1224
+ let keyringApiKey = false;
1225
+ let removed = false;
1226
+ if (backend) {
1227
+ try {
1228
+ oauthRaw = backend.get(oauthAccount(slug));
1229
+ keyringApiKey = backend.get(slug) !== null;
1230
+ if (oauthRaw && backend.delete(oauthAccount(slug))) removed = true;
1231
+ } catch {
1232
+ throw new CliError(
1233
+ `Could not remove OAuth credentials for workspace '${sanitize(slug)}' from the ${backend.label}.`,
1234
+ "runtime",
1235
+ );
1236
+ }
1237
+ }
1238
+
1239
+ if (!existsSync(path)) {
1240
+ return { removed, fallbackCredentialType: keyringApiKey ? "api-key" : null };
1241
+ }
1242
+ try {
1243
+ const obj = readUserObject(path);
1244
+ const ws = workspacesTable(obj);
1245
+ const table = ws[slug];
1246
+ const hadOAuthMarker = table?.oauth === true;
1247
+ if (hadOAuthMarker) removed = true;
1248
+ const reference = readReferenceCredentials(referenceCredentialsPath());
1249
+ const apiKeyFallback = Boolean(asString(table?.api_key)) || keyringApiKey;
1250
+
1251
+ if (apiKeyFallback) {
1252
+ const fallback: WorkspaceTable = { ...(table ?? {}) };
1253
+ delete fallback.oauth;
1254
+ if (asString(fallback.api_key)) delete fallback.keyring;
1255
+ else fallback.keyring = true;
1256
+ ws[slug] = fallback;
1257
+ } else if (table) {
1258
+ delete ws[slug];
1259
+ }
1260
+ if (Object.keys(ws).length === 0) delete obj.workspaces;
1261
+
1262
+ if (!apiKeyFallback && asString(obj.default_workspace) === slug) {
1263
+ const remaining = obj.workspaces ? Object.keys(obj.workspaces as object) : [];
1264
+ const referenceFallback = reference.workspaces.find((candidate) => candidate !== slug);
1265
+ if (remaining.length > 0) obj.default_workspace = remaining[0]!;
1266
+ else if (referenceFallback) obj.default_workspace = referenceFallback;
1267
+ else delete obj.default_workspace;
1268
+ }
1269
+ if (hadOAuthMarker || apiKeyFallback) writeUserObject(path, obj);
1270
+ return {
1271
+ removed,
1272
+ fallbackCredentialType: apiKeyFallback ? "api-key" : null,
1273
+ };
1274
+ } catch (err) {
1275
+ try {
1276
+ if (backend && oauthRaw) backend.set(oauthAccount(slug), oauthRaw);
1277
+ } catch {
1278
+ // Preserve the primary config error without exposing keyring or credential details.
1279
+ }
1280
+ throw err;
1281
+ }
1282
+ });
1283
+ }
1284
+
984
1285
  export interface MigrateResult {
985
1286
  /** Slugs whose keys moved from the file into the keyring. */
986
1287
  migrated: string[];
@@ -1042,6 +1343,7 @@ export interface CredentialEntry {
1042
1343
  isDefault: boolean;
1043
1344
  /** Where the secret is kept. */
1044
1345
  storage: CredentialStorage;
1346
+ credentialType: "api-key" | "oauth-user";
1045
1347
  }
1046
1348
 
1047
1349
  /** List configured credentials: one entry per stored workspace. */
@@ -1052,6 +1354,7 @@ export function listCredentials(env: NodeJS.ProcessEnv = process.env): Credentia
1052
1354
  slug,
1053
1355
  isDefault: user.defaultWorkspace === slug,
1054
1356
  storage: entry.apiKey ? "file" : "keychain",
1357
+ credentialType: entry.oauth ? "oauth-user" : "api-key",
1055
1358
  }));
1056
1359
  }
1057
1360
 
@@ -1088,7 +1391,7 @@ const KEY_ALIASES: Record<SettableKey, readonly string[]> = {
1088
1391
  };
1089
1392
 
1090
1393
  /** Keys that must never be written to a project file, and why. */
1091
- const SECRET_KEYS = new Set(["api_key", "workspaces", "default_workspace", "keyring"]);
1394
+ const SECRET_KEYS = new Set(["api_key", "workspaces", "default_workspace", "keyring", "oauth"]);
1092
1395
 
1093
1396
  /**
1094
1397
  * Where `config init` writes and `config set` falls back to when no project
package/src/context.ts CHANGED
@@ -28,6 +28,7 @@ export interface GlobalOptions {
28
28
  /** `--no-input`. Present (true) only when passed. */
29
29
  noInput?: boolean;
30
30
  apiKey?: string;
31
+ accessToken?: string;
31
32
  /** A key, or several — `issue list`/`mine`/`search` make `--team` repeatable. */
32
33
  team?: string | string[];
33
34
  workspace?: string;
@@ -60,6 +61,7 @@ export class Context {
60
61
  // and the config sees the first key.
61
62
  flags: {
62
63
  apiKey: options.apiKey,
64
+ accessToken: options.accessToken,
63
65
  team: firstTeam(options.team),
64
66
  workspace: options.workspace,
65
67
  },
package/src/index.ts CHANGED
@@ -6,5 +6,24 @@ export { createProgram, VERSION } from "./cli.js";
6
6
  export { Context } from "./context.js";
7
7
  export { resolveConfig, type ResolvedConfig } from "./config.js";
8
8
  export { createClient } from "./client.js";
9
+ export {
10
+ DEFAULT_OAUTH_CALLBACK_TIMEOUT_MS,
11
+ DEFAULT_OAUTH_REFRESH_SKEW_MS,
12
+ DEFAULT_OAUTH_SCOPES,
13
+ ClientCredentialsTokenProvider,
14
+ OAuthUserTokenProvider,
15
+ OAuthTokenError,
16
+ buildAuthorizationUrl,
17
+ createPkceRequest,
18
+ exchangeAuthorizationCode,
19
+ revokeOAuthToken,
20
+ startLoopbackCallback,
21
+ type ClientCredentialsTokenProviderOptions,
22
+ type GetAccessTokenOptions,
23
+ type OAuthAccessToken,
24
+ type OAuthUserCredential,
25
+ type OAuthUserTokenProviderOptions,
26
+ type PkceRequest,
27
+ } from "./oauth.js";
9
28
  export { CliError, ExitCode, normalizeError } from "./lib/errors.js";
10
29
  export { branchToIssueId, currentIssueId } from "./git.js";
@@ -123,6 +123,7 @@ const GLOBAL_LONGS = new Set([
123
123
  "--json",
124
124
  "--no-ansi",
125
125
  "--api-key",
126
+ "--access-token",
126
127
  "--workspace",
127
128
  "--team",
128
129
  "--limit",
@@ -263,6 +263,7 @@ function globalOptions(): Option[] {
263
263
  // spellings cannot drift apart.
264
264
  new NoFlagOption("--no-color", "alias of --no-ansi", "noAnsi").hideHelp(),
265
265
  new Option("--api-key <key>", "Linear API key (overrides env/config)"),
266
+ new Option("--access-token <token>", "Linear OAuth access token (overrides env/config)"),
266
267
  new Option("--workspace <slug>", "select workspace credential profile"),
267
268
  new Option("-t, --team <key>", "default team key (e.g. TES)"),
268
269
  new Option("-n, --limit <n>", "max results (positive integer; 0 = all)").argParser(
@@ -160,6 +160,8 @@ const SETTING_ORIGINS_SHAPE = shape<SettingOrigins>({
160
160
  const CONFIG_SHOW_SHAPE: ObjectFields = {
161
161
  apiKey: "string",
162
162
  apiKeySource: "string",
163
+ accessToken: "string",
164
+ accessTokenSource: "string",
163
165
  credentialWorkspace: "string|null",
164
166
  team: "string|null",
165
167
  workspace: "string|null",
@@ -262,22 +264,39 @@ export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
262
264
  path: "string",
263
265
  }),
264
266
  "auth default": receipt({ success: "boolean", default_workspace: "string", path: "string" }),
265
- "auth list": list({ slug: "string", isDefault: "boolean", storage: "string" }),
267
+ "auth list": list({
268
+ slug: "string",
269
+ isDefault: "boolean",
270
+ credentialType: "string",
271
+ storage: "string",
272
+ }),
266
273
  "auth login": receipt({
267
274
  success: "boolean",
275
+ credentialType: "string",
268
276
  workspace: "string",
269
277
  user: { id: "string", name: "string", email: "string" },
270
278
  storage: "string",
279
+ "scopes?": ["string"],
280
+ "expiresAt?": "string",
271
281
  path: "string",
272
282
  }),
273
- "auth logout": receipt({ success: "boolean", workspace: "string", removed: "boolean" }),
283
+ "auth logout": receipt({
284
+ success: "boolean",
285
+ workspace: "string",
286
+ removed: "boolean",
287
+ revocation: "string",
288
+ fallbackCredentialType: "string|null",
289
+ }),
274
290
  "auth migrate": receipt({ success: "boolean", migrated: ["string"], path: "string" }),
275
291
  "auth status": object({
276
292
  authenticated: "boolean",
293
+ credentialType: "string|null",
277
294
  source: "string",
278
295
  workspace: "string|null",
279
296
  key: "string",
280
297
  keyring: "string|null",
298
+ scopes: { nullable: ["string"] },
299
+ expiresAt: "string|null",
281
300
  }),
282
301
  "auth token": object({ apiKey: "string", workspace: "string|null" }),
283
302
  "auth whoami": WHOAMI,