linear-sdk-cli 0.4.0 → 0.5.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/src/config.ts CHANGED
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Two tiers with different trust boundaries:
5
5
  * - Non-secret settings (team, workspace, sort): flag > env > project
6
- * config > user config (`~/.config/linear/config.toml`) > the reference
7
- * CLI's global config (`~/.config/linear/linear.toml`, read-only).
6
+ * config > selected workspace profile (team only) > user config
7
+ * (`~/.config/linear/config.toml`) > the reference CLI's global config
8
+ * (`~/.config/linear/linear.toml`, read-only).
8
9
  * The project config is the first file found walking cwd → filesystem
9
10
  * root, checking `linear.toml`, `.linear.toml`, `.config/linear.toml` in
10
11
  * each directory — every location schpet/linear-cli 2.5 reads (it checks
@@ -70,7 +71,15 @@ export interface RawSettings {
70
71
  * `global` is the reference CLI's `~/.config/linear/linear.toml`, read for
71
72
  * non-secret settings only; `keychain` is the OS keyring (API key or human OAuth session).
72
73
  */
73
- export type ConfigSource = "flag" | "env" | "project" | "user" | "global" | "keychain" | "none";
74
+ export type ConfigSource =
75
+ | "flag"
76
+ | "env"
77
+ | "project"
78
+ | "workspace-profile"
79
+ | "user"
80
+ | "global"
81
+ | "keychain"
82
+ | "none";
74
83
 
75
84
  /** Where a stored workspace credential's secret lives. */
76
85
  export type CredentialStorage = "file" | "keychain";
@@ -81,6 +90,8 @@ export interface SettingOrigin {
81
90
  path?: string;
82
91
  /** Exact spelling found in the file (`issue_sort`, not just canonical `sort`). */
83
92
  key?: string;
93
+ /** Workspace slug for a value read from `[workspaces."<slug>"]`. */
94
+ workspace?: string;
84
95
  }
85
96
 
86
97
  /** The non-secret settings, each with its provenance. */
@@ -102,8 +113,12 @@ export interface ResolvedConfig {
102
113
  * needed (see createClient / `auth token`).
103
114
  */
104
115
  apiKeyError?: CliError;
116
+ /** Stored choices when selection is ambiguous; never an implicit default. */
117
+ workspaceChoices?: string[];
105
118
  /** Workspace slug whose stored credential was used, if any. */
106
119
  credentialWorkspace?: string;
120
+ /** Selected stored profile whose non-secret metadata may participate. */
121
+ workspaceProfile?: string;
107
122
  team?: string;
108
123
  /** Non-secret display workspace setting (separate from credentialWorkspace). */
109
124
  workspace?: string;
@@ -174,6 +189,8 @@ interface WorkspaceEntry {
174
189
  apiKey?: string;
175
190
  /** This workspace uses the isolated `oauth:<slug>` keyring account. */
176
191
  oauth?: boolean;
192
+ /** Non-secret default team for this workspace. */
193
+ team?: string;
177
194
  }
178
195
 
179
196
  /** Parsed view of the user config: top-level settings + nested workspace creds. */
@@ -328,6 +345,7 @@ function readUserConfig(path: string): UserConfig {
328
345
  workspaces[slug] = {
329
346
  apiKey: asString(table.api_key),
330
347
  oauth: table.oauth === true,
348
+ team: asString(table.team),
331
349
  };
332
350
  }
333
351
  }
@@ -450,6 +468,8 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
450
468
  let accessTokenSource: ConfigSource = "none";
451
469
  let oauthCredential: OAuthUserCredential | undefined;
452
470
  let credentialWorkspace: string | undefined;
471
+ let workspaceProfile: string | undefined;
472
+ let workspaceChoices: string[] | undefined;
453
473
  let apiKeyError: CliError | undefined;
454
474
 
455
475
  if (flags.apiKey && flags.accessToken) {
@@ -522,8 +542,9 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
522
542
  take(slugs[0]!);
523
543
  } else if (slugs.length > 1) {
524
544
  // Multiple workspaces but no default → ambiguous (deferred).
545
+ workspaceChoices = slugs;
525
546
  apiKeyError = new CliError(
526
- `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>\`.`,
527
548
  "usage",
528
549
  );
529
550
  }
@@ -531,8 +552,27 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
531
552
  }
532
553
  }
533
554
 
555
+ // A stored credential binds its own profile. An invocation-scoped credential
556
+ // never inherits the default/project workspace implicitly; it may bind only
557
+ // when this invocation explicitly selected a profile by flag or environment
558
+ // and that profile is actually configured.
559
+ if (credentialWorkspace && user.workspaces[credentialWorkspace]) {
560
+ workspaceProfile = credentialWorkspace;
561
+ } else {
562
+ const invocationCredential =
563
+ apiKeySource === "flag" ||
564
+ apiKeySource === "env" ||
565
+ accessTokenSource === "flag" ||
566
+ accessTokenSource === "env";
567
+ const explicitWorkspace = flags.workspace ?? envSettings.workspace;
568
+ if (invocationCredential && explicitWorkspace && user.workspaces[explicitWorkspace]) {
569
+ workspaceProfile = explicitWorkspace;
570
+ }
571
+ }
572
+
534
573
  // ----- Non-secret settings (project + global config MAY participate) -----
535
- // flag > env > project file > our user file > the reference CLI's global file.
574
+ // Workspace and sort keep their existing order. Team inserts the selected
575
+ // workspace profile between project and the legacy top-level user fallback.
536
576
  const tiers: Array<[ConfigSource, RawSettings, string | undefined, SettingKeys | undefined]> = [
537
577
  ["flag", flags, undefined, undefined],
538
578
  ["env", envSettings, undefined, undefined],
@@ -543,13 +583,30 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
543
583
  const pick = <K extends "team" | "workspace" | "sort">(key: K): string | undefined =>
544
584
  tiers.find(([, settings]) => settings[key] !== undefined)?.[1][key];
545
585
 
586
+ const profileTeam = workspaceProfile ? user.workspaces[workspaceProfile]?.team : undefined;
587
+ const teamTiers: typeof tiers = [
588
+ ["flag", flags, undefined, undefined],
589
+ ["env", envSettings, undefined, undefined],
590
+ ["project", project.settings, projectPath, project.keys],
591
+ [
592
+ "workspace-profile",
593
+ profileTeam ? { team: profileTeam } : {},
594
+ userPath,
595
+ profileTeam ? { team: "team" } : undefined,
596
+ ],
597
+ ["user", user.settings, userPath, user.settingKeys],
598
+ ["global", global.settings, globalPath, global.keys],
599
+ ];
600
+
546
601
  /** Which tier `pick` took the value from, and which file if it was one. */
547
602
  const originOf = (key: "team" | "workspace" | "sort"): SettingOrigin => {
548
- const hit = tiers.find(([, settings]) => settings[key] !== undefined);
603
+ const candidates = key === "team" ? teamTiers : tiers;
604
+ const hit = candidates.find(([, settings]) => settings[key] !== undefined);
549
605
  if (!hit) return { source: "none" };
550
606
  const origin: SettingOrigin = { source: hit[0], path: hit[2] };
551
607
  const spelling = hit[3]?.[key];
552
608
  if (spelling !== undefined) origin.key = spelling;
609
+ if (hit[0] === "workspace-profile") origin.workspace = workspaceProfile;
553
610
  return origin;
554
611
  };
555
612
 
@@ -567,7 +624,9 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
567
624
  oauthCredential,
568
625
  apiKeyError,
569
626
  credentialWorkspace,
570
- team: pick("team"),
627
+ workspaceProfile,
628
+ workspaceChoices,
629
+ team: teamTiers.find(([, settings]) => settings.team !== undefined)?.[1].team,
571
630
  // The display setting walks every tier. Credential selection above uses
572
631
  // the flag, env, and project tiers, then the auth-specific user default.
573
632
  workspace: pick("workspace"),
@@ -823,6 +882,7 @@ interface WorkspaceTable {
823
882
  api_key?: string;
824
883
  keyring?: boolean;
825
884
  oauth?: boolean;
885
+ team?: string;
826
886
  }
827
887
 
828
888
  function workspacesTable(obj: Record<string, unknown>): Record<string, WorkspaceTable> {
@@ -946,10 +1006,6 @@ export function writeOAuthCredential(credential: OAuthUserCredential): WriteOAut
946
1006
  table.keyring = true;
947
1007
  table.oauth = true;
948
1008
  ws[slug] = table;
949
- if (!asString(obj.default_workspace)) {
950
- const reference = readReferenceCredentials(referenceCredentialsPath());
951
- if (!reference.defaultWorkspace) obj.default_workspace = slug;
952
- }
953
1009
  writeUserObject(path, obj);
954
1010
  } catch (err) {
955
1011
  try {
@@ -1075,10 +1131,6 @@ export function adoptKeyringCredential(
1075
1131
  delete table.api_key;
1076
1132
  table.keyring = true;
1077
1133
  ws[slug] = table;
1078
- if (!asString(obj.default_workspace)) {
1079
- const reference = readReferenceCredentials(referenceCredentialsPath());
1080
- if (!reference.defaultWorkspace) obj.default_workspace = slug;
1081
- }
1082
1134
  writeUserObject(path, obj);
1083
1135
  return { path, storage: "keychain", keyringLabel: found.keyringLabel };
1084
1136
  });
@@ -1091,8 +1143,8 @@ export function adoptKeyringCredential(
1091
1143
  * no keyring, it is written as `api_key` in the 0600 file — the latter without
1092
1144
  * complaint, since a platform is not an error. Either way the OTHER form for
1093
1145
  * the same slug is dropped from the file, so a re-login moves the key rather
1094
- * than leaving a plaintext copy behind that would keep winning. If no default
1095
- * 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.
1096
1148
  */
1097
1149
  export function writeCredential(
1098
1150
  slug: string,
@@ -1122,12 +1174,6 @@ export function writeCredential(
1122
1174
  }
1123
1175
  systemBackend?.delete(oauthAccount(slug));
1124
1176
  ws[slug] = table;
1125
- if (!asString(obj.default_workspace)) {
1126
- // Leave the reference CLI's default in charge if it has one; writing ours
1127
- // over it would silently switch a migrating user's workspace.
1128
- const reference = readReferenceCredentials(referenceCredentialsPath());
1129
- if (!reference.defaultWorkspace) obj.default_workspace = slug;
1130
- }
1131
1177
  writeUserObject(path, obj);
1132
1178
  return { path, storage, keyringLabel: backend?.label };
1133
1179
  });
@@ -1151,6 +1197,31 @@ export function setDefaultWorkspace(slug: string): string {
1151
1197
  });
1152
1198
  }
1153
1199
 
1200
+ /** Set non-secret team metadata on one existing workspace profile. */
1201
+ export function setWorkspaceTeam(
1202
+ slug: string,
1203
+ team: string,
1204
+ env: NodeJS.ProcessEnv = process.env,
1205
+ ): string {
1206
+ const path = userConfigPath(env);
1207
+ return withUserConfigLock(path, () => {
1208
+ const known = readUserConfig(path).workspaces;
1209
+ if (!known[slug]) {
1210
+ throw new CliError(
1211
+ `Workspace '${sanitize(slug)}' is not configured. Run \`linear auth login --workspace ${sanitize(slug)}\` first.`,
1212
+ "not_found",
1213
+ );
1214
+ }
1215
+ const obj = readUserObject(path);
1216
+ const ws = workspacesTable(obj);
1217
+ const table: WorkspaceTable = { ...(ws[slug] ?? {}) };
1218
+ table.team = team;
1219
+ ws[slug] = table;
1220
+ writeUserObject(path, obj);
1221
+ return path;
1222
+ });
1223
+ }
1224
+
1154
1225
  /**
1155
1226
  * Drop `slug` from the reference CLI's keyring-format credentials list, if it
1156
1227
  * is there, in that file's own layout (`default` first, sorted `workspaces`).
@@ -1162,7 +1233,7 @@ function removeFromReferenceCredentials(slug: string): boolean {
1162
1233
  const reference = readReferenceCredentials(path);
1163
1234
  if (!reference.workspaces.includes(slug)) return false;
1164
1235
  const workspaces = reference.workspaces.filter((s) => s !== slug).sort();
1165
- const def = reference.defaultWorkspace === slug ? workspaces[0] : reference.defaultWorkspace;
1236
+ const def = reference.defaultWorkspace === slug ? undefined : reference.defaultWorkspace;
1166
1237
  const obj: Record<string, unknown> = {};
1167
1238
  if (def) obj.default = def;
1168
1239
  obj.workspaces = workspaces;
@@ -1173,13 +1244,20 @@ function removeFromReferenceCredentials(slug: string): boolean {
1173
1244
  /**
1174
1245
  * Forget a workspace: remove its `[workspaces."<slug>"]` table, its keyring
1175
1246
  * entry, and its line in the reference CLI's list — whichever exist. If the
1176
- * removed slug was the default, the default is repointed to a remaining
1177
- * 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.
1178
1249
  */
1179
- export function removeCredential(slug: string): boolean {
1250
+ export interface RemoveCredentialResult {
1251
+ removed: boolean;
1252
+ teamMetadataRemoved: boolean;
1253
+ }
1254
+
1255
+ /** Detailed removal receipt used by `auth logout`. */
1256
+ export function removeCredentialWithMetadata(slug: string): RemoveCredentialResult {
1180
1257
  const path = userConfigPath();
1181
1258
  return withUserConfigLock(path, () => {
1182
1259
  let removed = false;
1260
+ let teamMetadataRemoved = false;
1183
1261
 
1184
1262
  const backend = keyring();
1185
1263
  if (backend) {
@@ -1188,31 +1266,36 @@ export function removeCredential(slug: string): boolean {
1188
1266
  }
1189
1267
  if (removeFromReferenceCredentials(slug)) removed = true;
1190
1268
 
1191
- if (!existsSync(path)) return removed;
1269
+ if (!existsSync(path)) return { removed, teamMetadataRemoved };
1192
1270
  const obj = readUserObject(path);
1193
1271
  const ws = workspacesTable(obj);
1194
1272
  const listed = Boolean(ws[slug]);
1195
1273
  if (listed) {
1274
+ teamMetadataRemoved = asString(ws[slug]?.team) !== undefined;
1196
1275
  delete ws[slug];
1197
1276
  removed = true;
1198
1277
  }
1199
1278
  if (Object.keys(ws).length === 0) delete obj.workspaces;
1200
1279
 
1201
1280
  if (asString(obj.default_workspace) === slug) {
1202
- const remaining = obj.workspaces ? Object.keys(obj.workspaces as object) : [];
1203
- if (remaining.length > 0) obj.default_workspace = remaining[0]!;
1204
- else delete obj.default_workspace;
1281
+ delete obj.default_workspace;
1205
1282
  writeUserObject(path, obj);
1206
1283
  } else if (listed) {
1207
1284
  writeUserObject(path, obj);
1208
1285
  }
1209
- return removed;
1286
+ return { removed, teamMetadataRemoved };
1210
1287
  });
1211
1288
  }
1212
1289
 
1290
+ /** Backward-compatible boolean removal API. */
1291
+ export function removeCredential(slug: string): boolean {
1292
+ return removeCredentialWithMetadata(slug).removed;
1293
+ }
1294
+
1213
1295
  export interface RemoveOAuthCredentialResult {
1214
1296
  removed: boolean;
1215
1297
  fallbackCredentialType: "api-key" | null;
1298
+ teamMetadataRemoved: boolean;
1216
1299
  }
1217
1300
 
1218
1301
  /** Remove only the human OAuth lifecycle, restoring a pre-existing API-key profile if present. */
@@ -1223,6 +1306,7 @@ export function removeOAuthCredential(slug: string): RemoveOAuthCredentialResult
1223
1306
  let oauthRaw: string | null = null;
1224
1307
  let keyringApiKey = false;
1225
1308
  let removed = false;
1309
+ let teamMetadataRemoved = false;
1226
1310
  if (backend) {
1227
1311
  try {
1228
1312
  oauthRaw = backend.get(oauthAccount(slug));
@@ -1237,7 +1321,11 @@ export function removeOAuthCredential(slug: string): RemoveOAuthCredentialResult
1237
1321
  }
1238
1322
 
1239
1323
  if (!existsSync(path)) {
1240
- return { removed, fallbackCredentialType: keyringApiKey ? "api-key" : null };
1324
+ return {
1325
+ removed,
1326
+ fallbackCredentialType: keyringApiKey ? "api-key" : null,
1327
+ teamMetadataRemoved,
1328
+ };
1241
1329
  }
1242
1330
  try {
1243
1331
  const obj = readUserObject(path);
@@ -1245,7 +1333,6 @@ export function removeOAuthCredential(slug: string): RemoveOAuthCredentialResult
1245
1333
  const table = ws[slug];
1246
1334
  const hadOAuthMarker = table?.oauth === true;
1247
1335
  if (hadOAuthMarker) removed = true;
1248
- const reference = readReferenceCredentials(referenceCredentialsPath());
1249
1336
  const apiKeyFallback = Boolean(asString(table?.api_key)) || keyringApiKey;
1250
1337
 
1251
1338
  if (apiKeyFallback) {
@@ -1255,21 +1342,19 @@ export function removeOAuthCredential(slug: string): RemoveOAuthCredentialResult
1255
1342
  else fallback.keyring = true;
1256
1343
  ws[slug] = fallback;
1257
1344
  } else if (table) {
1345
+ teamMetadataRemoved = asString(table.team) !== undefined;
1258
1346
  delete ws[slug];
1259
1347
  }
1260
1348
  if (Object.keys(ws).length === 0) delete obj.workspaces;
1261
1349
 
1262
1350
  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;
1351
+ delete obj.default_workspace;
1268
1352
  }
1269
1353
  if (hadOAuthMarker || apiKeyFallback) writeUserObject(path, obj);
1270
1354
  return {
1271
1355
  removed,
1272
1356
  fallbackCredentialType: apiKeyFallback ? "api-key" : null,
1357
+ teamMetadataRemoved,
1273
1358
  };
1274
1359
  } catch (err) {
1275
1360
  try {
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
  }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Linear for Agents (Developer Preview) helpers.
3
+ *
4
+ * Delegates are deliberately resolved separately from assignees. A human can
5
+ * own an issue; only an active, assignable app user with access to the issue's
6
+ * team can be delegated the work.
7
+ */
8
+
9
+ import type { LinearClient } from "@linear/sdk";
10
+ import { withRetry } from "../client.js";
11
+ import { CliError, ambiguous, normalizeError, notFound } from "./errors.js";
12
+ import { isUuid } from "./resolve.js";
13
+
14
+ const PAGE_SIZE = 250;
15
+ const SCAN_CAP = 2000;
16
+ const MAX_LISTED = 25;
17
+
18
+ export interface DelegateRef {
19
+ id: string;
20
+ displayName: string;
21
+ name: string;
22
+ }
23
+
24
+ function delegationMessages(err: unknown): { normalized: CliError; messages: string } {
25
+ const normalized = normalizeError(err);
26
+ return {
27
+ normalized,
28
+ messages: [normalized.message, JSON.stringify(normalized.detail ?? "")].join(" "),
29
+ };
30
+ }
31
+
32
+ function missingDelegationSchema(messages: string): boolean {
33
+ return (
34
+ /(cannot query field|unknown (argument|field)|is not defined by type).*(delegate|isAssignable|canAccessAnyPublicTeam)/i.test(
35
+ messages,
36
+ ) ||
37
+ /(delegateId|delegate|isAssignable|canAccessAnyPublicTeam).*(cannot query field|unknown (argument|field)|is not defined by type)/i.test(
38
+ messages,
39
+ )
40
+ );
41
+ }
42
+
43
+ /** Whether an issue read can safely retry without its one preview-only field. */
44
+ export function isDelegationReadUnavailable(err: unknown): boolean {
45
+ const { normalized, messages } = delegationMessages(err);
46
+ return (
47
+ missingDelegationSchema(messages) ||
48
+ (normalized.code === "feature_not_accessible" && /delegate/i.test(messages))
49
+ );
50
+ }
51
+
52
+ interface DelegateCandidate extends DelegateRef {
53
+ active: boolean;
54
+ app: boolean;
55
+ isAssignable: boolean;
56
+ canAccessAnyPublicTeam: boolean;
57
+ teams: { nodes: Array<{ id: string }> };
58
+ }
59
+
60
+ const CANDIDATE_FIELDS = `
61
+ id displayName name active app isAssignable canAccessAnyPublicTeam
62
+ teams(filter: { id: { eq: $teamId } }, first: 1) { nodes { id } }
63
+ `;
64
+
65
+ const CANDIDATES_QUERY = `
66
+ query CliDelegateCandidates($teamId: String!, $after: String) {
67
+ users(first: ${PAGE_SIZE}, after: $after, includeDisabled: true, includeArchived: true) {
68
+ nodes { ${CANDIDATE_FIELDS} }
69
+ pageInfo { hasNextPage endCursor }
70
+ }
71
+ }
72
+ `;
73
+
74
+ const CANDIDATE_QUERY = `
75
+ query CliDelegateCandidate($id: String!, $teamId: String!) {
76
+ user(id: $id) { ${CANDIDATE_FIELDS} }
77
+ }
78
+ `;
79
+
80
+ /**
81
+ * Turn an unavailable Developer Preview schema/feature into a stable boundary.
82
+ * Other validation failures retain their original code and wording.
83
+ */
84
+ export function delegationFeatureError(err: unknown): CliError {
85
+ const { normalized, messages } = delegationMessages(err);
86
+ const schemaMissing = missingDelegationSchema(messages);
87
+ if (normalized.code !== "feature_not_accessible" && !schemaMissing) return normalized;
88
+ return new CliError(
89
+ "Issue delegation is unavailable in this Linear workspace or API schema (Linear for Agents is in Developer Preview).",
90
+ "feature_not_accessible",
91
+ normalized.detail,
92
+ "Enable an assignable agent integration with access to this team, or retry when the workspace supports issue delegation.",
93
+ );
94
+ }
95
+
96
+ async function request<T>(client: LinearClient, query: string, variables: object): Promise<T> {
97
+ try {
98
+ const response: any = await withRetry(() =>
99
+ (client as any).client.rawRequest(query, variables),
100
+ );
101
+ return response.data as T;
102
+ } catch (err) {
103
+ throw delegationFeatureError(err);
104
+ }
105
+ }
106
+
107
+ async function allCandidates(client: LinearClient, teamId: string): Promise<DelegateCandidate[]> {
108
+ const candidates: DelegateCandidate[] = [];
109
+ let after: string | undefined;
110
+ for (;;) {
111
+ const data = await request<{
112
+ users: {
113
+ nodes: DelegateCandidate[];
114
+ pageInfo: { hasNextPage: boolean; endCursor: string | null };
115
+ };
116
+ }>(client, CANDIDATES_QUERY, { teamId, after });
117
+ candidates.push(...data.users.nodes);
118
+ if (!data.users.pageInfo.hasNextPage) return candidates;
119
+ if (candidates.length >= SCAN_CAP || !data.users.pageInfo.endCursor) {
120
+ throw new CliError(
121
+ `More than ${SCAN_CAP} workspace users to search; pass the agent user UUID instead.`,
122
+ "usage",
123
+ );
124
+ }
125
+ after = data.users.pageInfo.endCursor;
126
+ }
127
+ }
128
+
129
+ function isBaseEligible(candidate: DelegateCandidate): boolean {
130
+ return candidate.active && candidate.app && candidate.isAssignable;
131
+ }
132
+
133
+ function hasTeamAccess(candidate: DelegateCandidate, privateTeam: boolean): boolean {
134
+ return candidate.teams.nodes.length > 0 || (!privateTeam && candidate.canAccessAnyPublicTeam);
135
+ }
136
+
137
+ function label(candidate: DelegateCandidate): string {
138
+ const names =
139
+ candidate.displayName === candidate.name
140
+ ? candidate.displayName
141
+ : `${candidate.displayName} (${candidate.name})`;
142
+ return `${names} [${candidate.id}]`;
143
+ }
144
+
145
+ function validateCandidate(
146
+ candidate: DelegateCandidate,
147
+ team: { key: string; private: boolean },
148
+ ): DelegateRef {
149
+ if (!candidate.active) {
150
+ throw new CliError(
151
+ `'${candidate.displayName}' is inactive and cannot be delegated issues.`,
152
+ "validation",
153
+ );
154
+ }
155
+ if (!candidate.app) {
156
+ throw new CliError(
157
+ `'${candidate.displayName}' is a human user. Use --assignee for people; --delegate accepts agent app users only.`,
158
+ "validation",
159
+ );
160
+ }
161
+ if (!candidate.isAssignable) {
162
+ throw new CliError(
163
+ `'${candidate.displayName}' is an app user but is not eligible for issue delegation.`,
164
+ "validation",
165
+ undefined,
166
+ "Reinstall or reconfigure the agent with Linear's app:assignable scope.",
167
+ );
168
+ }
169
+ if (!hasTeamAccess(candidate, team.private)) {
170
+ throw new CliError(
171
+ `'${candidate.displayName}' cannot access team ${team.key} and cannot be delegated this issue.`,
172
+ "validation",
173
+ undefined,
174
+ "Ask a Linear workspace admin to grant the agent access to this team.",
175
+ );
176
+ }
177
+ return { id: candidate.id, displayName: candidate.displayName, name: candidate.name };
178
+ }
179
+
180
+ /**
181
+ * Resolve an agent by UUID, exact display name, or exact full name. Exact case
182
+ * wins over case-insensitive matching; ambiguity considers eligible app users
183
+ * only and lists safe names plus IDs.
184
+ */
185
+ export async function resolveDelegate(
186
+ client: LinearClient,
187
+ input: string,
188
+ teamId: string,
189
+ ): Promise<DelegateRef> {
190
+ let team: { key: string; private: boolean };
191
+ try {
192
+ const model: any = await withRetry(() => client.team(teamId));
193
+ team = { key: model.key, private: !!model.private };
194
+ } catch (err) {
195
+ throw delegationFeatureError(err);
196
+ }
197
+
198
+ if (isUuid(input)) {
199
+ const data = await request<{ user: DelegateCandidate | null }>(client, CANDIDATE_QUERY, {
200
+ id: input,
201
+ teamId,
202
+ });
203
+ if (!data.user) throw notFound(`No agent user with id '${input}'.`);
204
+ return validateCandidate(data.user, team);
205
+ }
206
+
207
+ const candidates = await allCandidates(client, teamId);
208
+ const exact = candidates.filter(
209
+ (candidate) => candidate.displayName === input || candidate.name === input,
210
+ );
211
+ const lower = input.toLocaleLowerCase();
212
+ const matching = exact.length
213
+ ? exact
214
+ : candidates.filter(
215
+ (candidate) =>
216
+ candidate.displayName.toLocaleLowerCase() === lower ||
217
+ candidate.name.toLocaleLowerCase() === lower,
218
+ );
219
+ const eligible = matching.filter(
220
+ (candidate) => isBaseEligible(candidate) && hasTeamAccess(candidate, team.private),
221
+ );
222
+ if (eligible.length > 1) {
223
+ throw ambiguous(
224
+ `Multiple eligible agents match '${input}': ${eligible.map(label).join(", ")}. Pass the agent user UUID.`,
225
+ );
226
+ }
227
+ if (eligible.length === 1) return validateCandidate(eligible[0]!, team);
228
+ if (matching.length > 0) return validateCandidate(matching[0]!, team);
229
+
230
+ const available = candidates.filter(
231
+ (candidate) => isBaseEligible(candidate) && hasTeamAccess(candidate, team.private),
232
+ );
233
+ const suffix =
234
+ available.length === 0
235
+ ? " No eligible agents can access this team."
236
+ : available.length > MAX_LISTED
237
+ ? ` Run 'linear user list --all' to inspect workspace users.`
238
+ : ` Available agents: ${available.map(label).join(", ")}.`;
239
+ throw notFound(`No eligible agent matching '${input}'.${suffix}`);
240
+ }