linear-sdk-cli 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,13 @@
4
4
 
5
5
  import { Command, Option } from "commander";
6
6
  import { action } from "../lib/action.js";
7
- import { parseList, parseIntOption, addAliasOption, readAlias } from "../lib/options.js";
7
+ import {
8
+ parseList,
9
+ parseIntOption,
10
+ addAliasOption,
11
+ readAlias,
12
+ addIncludeArchivedOption,
13
+ } from "../lib/options.js";
8
14
  import { usageError } from "../lib/errors.js";
9
15
  import { resolveBody } from "../lib/body.js";
10
16
  import { confirmDestructive, promptInput } from "../lib/prompt.js";
@@ -12,10 +18,16 @@ import type { Context } from "../context.js";
12
18
  import * as svc from "../services/project.js";
13
19
  import { formatMilestoneProgress } from "./milestone.js";
14
20
  import type { Column } from "../output/table.js";
21
+ import { lifecycleSuffix } from "../output/lifecycle.js";
15
22
 
16
23
  const ROW_COLUMNS: Column<svc.ProjectRow>[] = [
17
24
  { key: "name", header: "Name", value: (r) => r.name, max: 36 },
18
- { key: "state", header: "State", value: (r) => r.status?.name ?? r.state ?? "—", max: 14 },
25
+ {
26
+ key: "state",
27
+ header: "State",
28
+ value: (r) => `${r.status?.name ?? r.state ?? "—"}${lifecycleSuffix(r)}`,
29
+ max: 26,
30
+ },
19
31
  { key: "progress", header: "Progress", value: (r) => formatProgress(r.progress) },
20
32
  { key: "lead", header: "Lead", value: (r) => r.lead?.displayName ?? "—", max: 16 },
21
33
  { key: "target", header: "Target", value: (r) => r.targetDate ?? "—" },
@@ -72,6 +84,7 @@ export function registerProject(program: Command): void {
72
84
  team: opts.allTeams ? undefined : (opts.team ?? ctx.defaultTeam),
73
85
  allTeams: !!opts.allTeams,
74
86
  state: readAlias(opts, "--state", "--status"),
87
+ includeArchived: !!opts.includeArchived,
75
88
  },
76
89
  ctx.limit,
77
90
  ctx.defaultTeam,
@@ -79,6 +92,7 @@ export function registerProject(program: Command): void {
79
92
  ctx.output.list(rows, ROW_COLUMNS, rows);
80
93
  }),
81
94
  );
95
+ addIncludeArchivedOption(list);
82
96
  // `--status` is the reference CLI's spelling for the same thing; it shipped
83
97
  // earlier as a visible duplicate and now goes through the shared alias
84
98
  // mechanism, so `--help` shows one canonical spelling here like everywhere else.
@@ -94,7 +108,11 @@ export function registerProject(program: Command): void {
94
108
  const detail = await svc.getProjectDetail(ctx.client, idArg);
95
109
  ctx.output.detail(detail, [
96
110
  ["Project", detail.name],
97
- ["Archived", detail.archivedAt ? `YES (${detail.archivedAt})` : null],
111
+ [
112
+ "Trashed",
113
+ detail.trashed ? `YES (deleted ${detail.archivedAt ?? "at an unknown time"})` : null,
114
+ ],
115
+ ["Archived", !detail.trashed && detail.archivedAt ? `YES (${detail.archivedAt})` : null],
98
116
  ["State", detail.status?.name ?? detail.state],
99
117
  ["Health", detail.health],
100
118
  ["Progress", detail.progress !== null ? formatProgress(detail.progress) : null],
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. */
@@ -104,6 +115,8 @@ export interface ResolvedConfig {
104
115
  apiKeyError?: CliError;
105
116
  /** Workspace slug whose stored credential was used, if any. */
106
117
  credentialWorkspace?: string;
118
+ /** Selected stored profile whose non-secret metadata may participate. */
119
+ workspaceProfile?: string;
107
120
  team?: string;
108
121
  /** Non-secret display workspace setting (separate from credentialWorkspace). */
109
122
  workspace?: string;
@@ -174,6 +187,8 @@ interface WorkspaceEntry {
174
187
  apiKey?: string;
175
188
  /** This workspace uses the isolated `oauth:<slug>` keyring account. */
176
189
  oauth?: boolean;
190
+ /** Non-secret default team for this workspace. */
191
+ team?: string;
177
192
  }
178
193
 
179
194
  /** Parsed view of the user config: top-level settings + nested workspace creds. */
@@ -328,6 +343,7 @@ function readUserConfig(path: string): UserConfig {
328
343
  workspaces[slug] = {
329
344
  apiKey: asString(table.api_key),
330
345
  oauth: table.oauth === true,
346
+ team: asString(table.team),
331
347
  };
332
348
  }
333
349
  }
@@ -450,6 +466,7 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
450
466
  let accessTokenSource: ConfigSource = "none";
451
467
  let oauthCredential: OAuthUserCredential | undefined;
452
468
  let credentialWorkspace: string | undefined;
469
+ let workspaceProfile: string | undefined;
453
470
  let apiKeyError: CliError | undefined;
454
471
 
455
472
  if (flags.apiKey && flags.accessToken) {
@@ -531,8 +548,27 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
531
548
  }
532
549
  }
533
550
 
551
+ // A stored credential binds its own profile. An invocation-scoped credential
552
+ // never inherits the default/project workspace implicitly; it may bind only
553
+ // when this invocation explicitly selected a profile by flag or environment
554
+ // and that profile is actually configured.
555
+ if (credentialWorkspace && user.workspaces[credentialWorkspace]) {
556
+ workspaceProfile = credentialWorkspace;
557
+ } else {
558
+ const invocationCredential =
559
+ apiKeySource === "flag" ||
560
+ apiKeySource === "env" ||
561
+ accessTokenSource === "flag" ||
562
+ accessTokenSource === "env";
563
+ const explicitWorkspace = flags.workspace ?? envSettings.workspace;
564
+ if (invocationCredential && explicitWorkspace && user.workspaces[explicitWorkspace]) {
565
+ workspaceProfile = explicitWorkspace;
566
+ }
567
+ }
568
+
534
569
  // ----- Non-secret settings (project + global config MAY participate) -----
535
- // flag > env > project file > our user file > the reference CLI's global file.
570
+ // Workspace and sort keep their existing order. Team inserts the selected
571
+ // workspace profile between project and the legacy top-level user fallback.
536
572
  const tiers: Array<[ConfigSource, RawSettings, string | undefined, SettingKeys | undefined]> = [
537
573
  ["flag", flags, undefined, undefined],
538
574
  ["env", envSettings, undefined, undefined],
@@ -543,13 +579,30 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
543
579
  const pick = <K extends "team" | "workspace" | "sort">(key: K): string | undefined =>
544
580
  tiers.find(([, settings]) => settings[key] !== undefined)?.[1][key];
545
581
 
582
+ const profileTeam = workspaceProfile ? user.workspaces[workspaceProfile]?.team : undefined;
583
+ const teamTiers: typeof tiers = [
584
+ ["flag", flags, undefined, undefined],
585
+ ["env", envSettings, undefined, undefined],
586
+ ["project", project.settings, projectPath, project.keys],
587
+ [
588
+ "workspace-profile",
589
+ profileTeam ? { team: profileTeam } : {},
590
+ userPath,
591
+ profileTeam ? { team: "team" } : undefined,
592
+ ],
593
+ ["user", user.settings, userPath, user.settingKeys],
594
+ ["global", global.settings, globalPath, global.keys],
595
+ ];
596
+
546
597
  /** Which tier `pick` took the value from, and which file if it was one. */
547
598
  const originOf = (key: "team" | "workspace" | "sort"): SettingOrigin => {
548
- const hit = tiers.find(([, settings]) => settings[key] !== undefined);
599
+ const candidates = key === "team" ? teamTiers : tiers;
600
+ const hit = candidates.find(([, settings]) => settings[key] !== undefined);
549
601
  if (!hit) return { source: "none" };
550
602
  const origin: SettingOrigin = { source: hit[0], path: hit[2] };
551
603
  const spelling = hit[3]?.[key];
552
604
  if (spelling !== undefined) origin.key = spelling;
605
+ if (hit[0] === "workspace-profile") origin.workspace = workspaceProfile;
553
606
  return origin;
554
607
  };
555
608
 
@@ -567,7 +620,8 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
567
620
  oauthCredential,
568
621
  apiKeyError,
569
622
  credentialWorkspace,
570
- team: pick("team"),
623
+ workspaceProfile,
624
+ team: teamTiers.find(([, settings]) => settings.team !== undefined)?.[1].team,
571
625
  // The display setting walks every tier. Credential selection above uses
572
626
  // the flag, env, and project tiers, then the auth-specific user default.
573
627
  workspace: pick("workspace"),
@@ -823,6 +877,7 @@ interface WorkspaceTable {
823
877
  api_key?: string;
824
878
  keyring?: boolean;
825
879
  oauth?: boolean;
880
+ team?: string;
826
881
  }
827
882
 
828
883
  function workspacesTable(obj: Record<string, unknown>): Record<string, WorkspaceTable> {
@@ -1151,6 +1206,31 @@ export function setDefaultWorkspace(slug: string): string {
1151
1206
  });
1152
1207
  }
1153
1208
 
1209
+ /** Set non-secret team metadata on one existing workspace profile. */
1210
+ export function setWorkspaceTeam(
1211
+ slug: string,
1212
+ team: string,
1213
+ env: NodeJS.ProcessEnv = process.env,
1214
+ ): string {
1215
+ const path = userConfigPath(env);
1216
+ return withUserConfigLock(path, () => {
1217
+ const known = readUserConfig(path).workspaces;
1218
+ if (!known[slug]) {
1219
+ throw new CliError(
1220
+ `Workspace '${sanitize(slug)}' is not configured. Run \`linear auth login --workspace ${sanitize(slug)}\` first.`,
1221
+ "not_found",
1222
+ );
1223
+ }
1224
+ const obj = readUserObject(path);
1225
+ const ws = workspacesTable(obj);
1226
+ const table: WorkspaceTable = { ...(ws[slug] ?? {}) };
1227
+ table.team = team;
1228
+ ws[slug] = table;
1229
+ writeUserObject(path, obj);
1230
+ return path;
1231
+ });
1232
+ }
1233
+
1154
1234
  /**
1155
1235
  * Drop `slug` from the reference CLI's keyring-format credentials list, if it
1156
1236
  * is there, in that file's own layout (`default` first, sorted `workspaces`).
@@ -1176,10 +1256,17 @@ function removeFromReferenceCredentials(slug: string): boolean {
1176
1256
  * removed slug was the default, the default is repointed to a remaining
1177
1257
  * workspace (or cleared). Returns true if anything was removed.
1178
1258
  */
1179
- export function removeCredential(slug: string): boolean {
1259
+ export interface RemoveCredentialResult {
1260
+ removed: boolean;
1261
+ teamMetadataRemoved: boolean;
1262
+ }
1263
+
1264
+ /** Detailed removal receipt used by `auth logout`. */
1265
+ export function removeCredentialWithMetadata(slug: string): RemoveCredentialResult {
1180
1266
  const path = userConfigPath();
1181
1267
  return withUserConfigLock(path, () => {
1182
1268
  let removed = false;
1269
+ let teamMetadataRemoved = false;
1183
1270
 
1184
1271
  const backend = keyring();
1185
1272
  if (backend) {
@@ -1188,11 +1275,12 @@ export function removeCredential(slug: string): boolean {
1188
1275
  }
1189
1276
  if (removeFromReferenceCredentials(slug)) removed = true;
1190
1277
 
1191
- if (!existsSync(path)) return removed;
1278
+ if (!existsSync(path)) return { removed, teamMetadataRemoved };
1192
1279
  const obj = readUserObject(path);
1193
1280
  const ws = workspacesTable(obj);
1194
1281
  const listed = Boolean(ws[slug]);
1195
1282
  if (listed) {
1283
+ teamMetadataRemoved = asString(ws[slug]?.team) !== undefined;
1196
1284
  delete ws[slug];
1197
1285
  removed = true;
1198
1286
  }
@@ -1206,13 +1294,19 @@ export function removeCredential(slug: string): boolean {
1206
1294
  } else if (listed) {
1207
1295
  writeUserObject(path, obj);
1208
1296
  }
1209
- return removed;
1297
+ return { removed, teamMetadataRemoved };
1210
1298
  });
1211
1299
  }
1212
1300
 
1301
+ /** Backward-compatible boolean removal API. */
1302
+ export function removeCredential(slug: string): boolean {
1303
+ return removeCredentialWithMetadata(slug).removed;
1304
+ }
1305
+
1213
1306
  export interface RemoveOAuthCredentialResult {
1214
1307
  removed: boolean;
1215
1308
  fallbackCredentialType: "api-key" | null;
1309
+ teamMetadataRemoved: boolean;
1216
1310
  }
1217
1311
 
1218
1312
  /** Remove only the human OAuth lifecycle, restoring a pre-existing API-key profile if present. */
@@ -1223,6 +1317,7 @@ export function removeOAuthCredential(slug: string): RemoveOAuthCredentialResult
1223
1317
  let oauthRaw: string | null = null;
1224
1318
  let keyringApiKey = false;
1225
1319
  let removed = false;
1320
+ let teamMetadataRemoved = false;
1226
1321
  if (backend) {
1227
1322
  try {
1228
1323
  oauthRaw = backend.get(oauthAccount(slug));
@@ -1237,7 +1332,11 @@ export function removeOAuthCredential(slug: string): RemoveOAuthCredentialResult
1237
1332
  }
1238
1333
 
1239
1334
  if (!existsSync(path)) {
1240
- return { removed, fallbackCredentialType: keyringApiKey ? "api-key" : null };
1335
+ return {
1336
+ removed,
1337
+ fallbackCredentialType: keyringApiKey ? "api-key" : null,
1338
+ teamMetadataRemoved,
1339
+ };
1241
1340
  }
1242
1341
  try {
1243
1342
  const obj = readUserObject(path);
@@ -1255,6 +1354,7 @@ export function removeOAuthCredential(slug: string): RemoveOAuthCredentialResult
1255
1354
  else fallback.keyring = true;
1256
1355
  ws[slug] = fallback;
1257
1356
  } else if (table) {
1357
+ teamMetadataRemoved = asString(table.team) !== undefined;
1258
1358
  delete ws[slug];
1259
1359
  }
1260
1360
  if (Object.keys(ws).length === 0) delete obj.workspaces;
@@ -1270,6 +1370,7 @@ export function removeOAuthCredential(slug: string): RemoveOAuthCredentialResult
1270
1370
  return {
1271
1371
  removed,
1272
1372
  fallbackCredentialType: apiKeyFallback ? "api-key" : null,
1373
+ teamMetadataRemoved,
1273
1374
  };
1274
1375
  } catch (err) {
1275
1376
  try {
@@ -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
+ }
@@ -210,6 +210,20 @@ export function readAlias<T = string>(
210
210
  return (canonicalValue ?? aliasValue) as T | undefined;
211
211
  }
212
212
 
213
+ /**
214
+ * Add the canonical lifecycle-listing option. Linear may return both archived
215
+ * and trashed records when this is true, so the help does not imply an
216
+ * archived-only filter.
217
+ */
218
+ export function addIncludeArchivedOption(cmd: Command): Command {
219
+ return cmd.addOption(
220
+ new Option(
221
+ "--include-archived",
222
+ "include archived resources (the API may also return trashed records)",
223
+ ),
224
+ );
225
+ }
226
+
213
227
  /**
214
228
  * A `--no-…` global registered as an ordinary boolean flag rather than as
215
229
  * commander's *negation* of a positive option. Two bugs came out of leaving
@@ -603,7 +617,7 @@ export function addCoreFilterOptions(cmd: Command, set: FilterOptionSet = {}): C
603
617
  cmd.addOption(new Option("-a, --assignee <who>", "filter by assignee (me|email|name)"));
604
618
  cmd.addOption(new Option("-U, --unassigned", "only issues with no assignee"));
605
619
  }
606
- return cmd
620
+ cmd
607
621
  .addOption(new Option("-p, --project <name>", "filter by project"))
608
622
  .addOption(
609
623
  new Option("--project-label <name>", "filter by the project's label (excludes --project)"),
@@ -622,8 +636,8 @@ export function addCoreFilterOptions(cmd: Command, set: FilterOptionSet = {}): C
622
636
  .addOption(
623
637
  new Option("--updated-after <date>", "only issues updated at/after a date (YYYY-MM-DD)"),
624
638
  )
625
- .addOption(new Option("--all-teams", "search every team, ignoring the default team"))
626
- .addOption(new Option("--include-archived", "include archived issues"));
639
+ .addOption(new Option("--all-teams", "search every team, ignoring the default team"));
640
+ return addIncludeArchivedOption(cmd);
627
641
  }
628
642
 
629
643
  /**