linear-sdk-cli 0.5.2 → 0.6.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.
@@ -0,0 +1,144 @@
1
+ /** `linear project-label` — manage labels that apply to projects, not issues. */
2
+
3
+ import { Command } from "commander";
4
+ import type { Context } from "../context.js";
5
+ import { action } from "../lib/action.js";
6
+ import { confirmDestructive, promptInput } from "../lib/prompt.js";
7
+ import type { Column } from "../output/table.js";
8
+ import * as svc from "../services/project-label.js";
9
+
10
+ const COLUMNS: Column<svc.ProjectLabelRow>[] = [
11
+ { key: "name", header: "Name", value: (label) => label.name, max: 30 },
12
+ { key: "color", header: "Color", value: (label) => label.color },
13
+ { key: "group", header: "Group", value: (label) => (label.isGroup ? "yes" : "—") },
14
+ { key: "parent", header: "Parent", value: (label) => label.parent?.name ?? "—" },
15
+ { key: "retired", header: "Retired", value: (label) => (label.retiredAt ? "yes" : "—") },
16
+ ];
17
+
18
+ const receipt = (label: { id: string; name: string; color: string }) => ({
19
+ id: label.id,
20
+ name: label.name,
21
+ color: label.color,
22
+ });
23
+
24
+ export function registerProjectLabel(program: Command): void {
25
+ const labels = program
26
+ .command("project-label")
27
+ .alias("pl")
28
+ .description("Manage project labels (distinct from issue labels)");
29
+
30
+ labels
31
+ .command("list")
32
+ .alias("ls")
33
+ .description("List project labels")
34
+ .option("--include-retired", "include labels retired from new project assignment")
35
+ .action(
36
+ action(async (ctx: Context, opts) => {
37
+ const rows = await svc.listProjectLabels(ctx.client, ctx.limit, !!opts.includeRetired);
38
+ ctx.output.list(rows, COLUMNS, rows);
39
+ }),
40
+ );
41
+
42
+ labels
43
+ .command("view <name-or-id>")
44
+ .description("View a project label or label group by exact name or id")
45
+ .action(
46
+ action(async (ctx: Context, _opts, input: string) => {
47
+ const label = await svc.getProjectLabel(ctx.client, input);
48
+ ctx.output.emit(label, () =>
49
+ ctx.output.detail(label.name, [
50
+ ["ID", label.id],
51
+ ["Color", label.color],
52
+ ["Description", label.description],
53
+ ["Group", label.isGroup ? "yes" : "no"],
54
+ ["Parent", label.parent?.name ?? null],
55
+ ["Retired", label.retiredAt],
56
+ [
57
+ "Children",
58
+ label.children.length ? label.children.map((child) => child.name).join(", ") : null,
59
+ ],
60
+ ]),
61
+ );
62
+ }),
63
+ );
64
+
65
+ labels
66
+ .command("create")
67
+ .alias("new")
68
+ .description("Create a project label or label group")
69
+ .option("--name <name>", "project label name")
70
+ .option("--color <hex>", "label color (six-digit hex, e.g. #5E6AD2)")
71
+ .option("-d, --description <text>", "label description")
72
+ .option("--group", "create a non-assignable label group")
73
+ .option("--parent <name-or-id>", "put the label in this group")
74
+ .action(
75
+ action(async (ctx: Context, opts) => {
76
+ let name: string | undefined = opts.name;
77
+ if (!name) name = await promptInput(ctx, "Name:", { required: true });
78
+ const label = await svc.createProjectLabel(ctx.client, { ...opts, name });
79
+ ctx.output.emit(receipt(label), () =>
80
+ ctx.output.success(`Created project label ${label.name}`),
81
+ );
82
+ }),
83
+ );
84
+
85
+ labels
86
+ .command("update <name-or-id>")
87
+ .alias("edit")
88
+ .description("Update a project label by exact name or id")
89
+ .option("--name <name>", "new name")
90
+ .option("--color <hex>", "new color (six-digit hex, e.g. #5E6AD2)")
91
+ .option("-d, --description <text>", "new description")
92
+ .option("--parent <name-or-id>", "move the label into this group")
93
+ .option("--clear-parent", "remove the label from its group")
94
+ .action(
95
+ action(async (ctx: Context, opts, input: string) => {
96
+ const label = await svc.updateProjectLabel(ctx.client, input, opts);
97
+ ctx.output.emit(receipt(label), () =>
98
+ ctx.output.success(`Updated project label ${label.name}`),
99
+ );
100
+ }),
101
+ );
102
+
103
+ labels
104
+ .command("retire <name-or-id>")
105
+ .description("Retire a label so it cannot be assigned to new projects")
106
+ .action(
107
+ action(async (ctx: Context, _opts, input: string) => {
108
+ const current = await svc.getProjectLabel(ctx.client, input);
109
+ if (!(await confirmDestructive(ctx, `Retire project label ${current.name}?`))) return;
110
+ const label = await svc.retireProjectLabel(ctx.client, current.id);
111
+ ctx.output.emit({ id: label.id, name: label.name, retired: true }, () =>
112
+ ctx.output.success(`Retired project label ${label.name}`),
113
+ );
114
+ }),
115
+ );
116
+
117
+ labels
118
+ .command("restore <name-or-id>")
119
+ .alias("unretire")
120
+ .description("Restore a retired project label")
121
+ .action(
122
+ action(async (ctx: Context, _opts, input: string) => {
123
+ const label = await svc.restoreProjectLabel(ctx.client, input);
124
+ ctx.output.emit({ id: label.id, name: label.name, restored: true }, () =>
125
+ ctx.output.success(`Restored project label ${label.name}`),
126
+ );
127
+ }),
128
+ );
129
+
130
+ labels
131
+ .command("delete <name-or-id>")
132
+ .alias("rm")
133
+ .description("Permanently delete a project label")
134
+ .action(
135
+ action(async (ctx: Context, _opts, input: string) => {
136
+ const current = await svc.getProjectLabel(ctx.client, input);
137
+ if (!(await confirmDestructive(ctx, `Delete project label ${current.name}?`))) return;
138
+ const label = await svc.deleteProjectLabel(ctx.client, current.id);
139
+ ctx.output.emit({ id: label.id, name: label.name, deleted: true }, () =>
140
+ ctx.output.success(`Deleted project label ${label.name}`),
141
+ );
142
+ }),
143
+ );
144
+ }
@@ -0,0 +1,166 @@
1
+ /** `linear project-status` — manage workspace project-status definitions. */
2
+
3
+ import { Command } from "commander";
4
+ import type { Context } from "../context.js";
5
+ import { action } from "../lib/action.js";
6
+ import { resolveBody } from "../lib/body.js";
7
+ import { usageError } from "../lib/errors.js";
8
+ import { confirmDestructive, promptInput } from "../lib/prompt.js";
9
+ import type { Column } from "../output/table.js";
10
+ import * as svc from "../services/project-status.js";
11
+
12
+ const ROW_COLUMNS: Column<svc.ProjectStatusRow>[] = [
13
+ { key: "name", header: "Name", value: (r) => r.name, max: 28 },
14
+ { key: "type", header: "Type", value: (r) => r.type },
15
+ { key: "position", header: "Pos", value: (r) => r.position },
16
+ { key: "color", header: "Color", value: (r) => r.color },
17
+ { key: "indefinite", header: "Indefinite", value: (r) => (r.indefinite ? "yes" : "no") },
18
+ { key: "archived", header: "Archived", value: (r) => (r.archivedAt ? "yes" : "no") },
19
+ ];
20
+
21
+ export function registerProjectStatus(program: Command): void {
22
+ const status = program
23
+ .command("project-status")
24
+ .alias("project-statuses")
25
+ .description("Manage workspace project-status definitions");
26
+
27
+ status
28
+ .command("list")
29
+ .alias("ls")
30
+ .description("List workspace project statuses")
31
+ .option("--include-archived", "include archived project statuses")
32
+ .action(
33
+ action(async (ctx: Context, opts) => {
34
+ const rows = await svc.listProjectStatuses(ctx.client, ctx.limit, !!opts.includeArchived);
35
+ ctx.output.list(rows, ROW_COLUMNS, rows);
36
+ }),
37
+ );
38
+
39
+ status
40
+ .command("view <name-or-id>")
41
+ .description("Show a project status by exact name or id")
42
+ .action(
43
+ action(async (ctx: Context, _opts, input: string) => {
44
+ const detail = await svc.getProjectStatusDetail(ctx.client, input);
45
+ ctx.output.detail(detail, [
46
+ ["Project status", detail.name],
47
+ ["ID", detail.id],
48
+ ["Type", detail.type],
49
+ ["Position", detail.position],
50
+ ["Color", detail.color],
51
+ ["Indefinite", detail.indefinite],
52
+ ["Archived", detail.archivedAt],
53
+ ["Description", detail.description ? `\n${detail.description}` : null],
54
+ ["Created", detail.createdAt],
55
+ ["Updated", detail.updatedAt],
56
+ ]);
57
+ }),
58
+ );
59
+
60
+ status
61
+ .command("create")
62
+ .alias("new")
63
+ .description("Create a workspace project status (explicit because duplicate names are valid)")
64
+ .option("--name <name>", "status name")
65
+ .option("-d, --description <text>", "status description")
66
+ .option("--description-file <path>", "read description from a file ('-' = stdin)")
67
+ .option("--color <hex>", "six-digit hex color (e.g. #5E6AD2)")
68
+ .option("--position <number>", "position within the status type", parseNumber)
69
+ .option("--type <type>", `status type (${svc.PROJECT_STATUS_TYPES.join("|")})`)
70
+ .option("--indefinite", "allow projects to remain in this status indefinitely")
71
+ .action(
72
+ action(async (ctx: Context, opts) => {
73
+ let name: string | undefined = opts.name;
74
+ if (!name) name = await promptInput(ctx, "Name:", { required: true });
75
+ const created = await svc.createProjectStatus(ctx.client, {
76
+ name,
77
+ description: resolveBody({
78
+ arg: opts.description,
79
+ file: opts.descriptionFile,
80
+ interactive: false,
81
+ }),
82
+ color: opts.color,
83
+ position: opts.position,
84
+ type: opts.type,
85
+ indefinite: opts.indefinite,
86
+ });
87
+ ctx.output.emit(statusReceipt(created), () =>
88
+ ctx.output.success(`Created project status ${created.name}`),
89
+ );
90
+ }),
91
+ );
92
+
93
+ status
94
+ .command("update <name-or-id>")
95
+ .alias("edit")
96
+ .description("Update a project status by exact name or id")
97
+ .option("--name <name>", "new status name")
98
+ .option("-d, --description <text>", "new status description")
99
+ .option("--description-file <path>", "read description from a file ('-' = stdin)")
100
+ .option("--color <hex>", "six-digit hex color (e.g. #5E6AD2)")
101
+ .option("--position <number>", "position within the status type", parseNumber)
102
+ .option("--type <type>", `status type (${svc.PROJECT_STATUS_TYPES.join("|")})`)
103
+ .option("--indefinite", "allow projects to remain in this status indefinitely")
104
+ .option("--no-indefinite", "do not allow projects to remain indefinitely")
105
+ .action(
106
+ action(async (ctx: Context, opts, input: string) => {
107
+ const updated = await svc.updateProjectStatus(ctx.client, input, {
108
+ name: opts.name,
109
+ description: resolveBody({
110
+ arg: opts.description,
111
+ file: opts.descriptionFile,
112
+ interactive: false,
113
+ }),
114
+ color: opts.color,
115
+ position: opts.position,
116
+ type: opts.type,
117
+ indefinite: opts.indefinite,
118
+ });
119
+ ctx.output.emit(statusReceipt(updated), () =>
120
+ ctx.output.success(`Updated project status ${updated.name}`),
121
+ );
122
+ }),
123
+ );
124
+
125
+ status
126
+ .command("archive <name-or-id>")
127
+ .description(
128
+ "Archive a project status (requires no active projects and another status of its type)",
129
+ )
130
+ .action(
131
+ action(async (ctx: Context, _opts, input: string) => {
132
+ const detail = await svc.getProjectStatusDetail(ctx.client, input);
133
+ if (!(await confirmDestructive(ctx, `Archive project status ${detail.name}?`))) return;
134
+ const archived = await svc.archiveProjectStatus(ctx.client, detail.id);
135
+ ctx.output.emit({ id: archived.id, name: archived.name, archived: true }, () =>
136
+ ctx.output.success(`Archived project status ${archived.name}`),
137
+ );
138
+ }),
139
+ );
140
+
141
+ status
142
+ .command("unarchive <name-or-id>")
143
+ .description("Unarchive a project status by exact name or id")
144
+ .action(
145
+ action(async (ctx: Context, _opts, input: string) => {
146
+ const restored = await svc.unarchiveProjectStatus(ctx.client, input);
147
+ ctx.output.emit({ id: restored.id, name: restored.name, archived: false }, () =>
148
+ ctx.output.success(`Unarchived project status ${restored.name}`),
149
+ );
150
+ }),
151
+ );
152
+ }
153
+
154
+ function statusReceipt(status: any) {
155
+ return {
156
+ id: status.id,
157
+ name: status.name,
158
+ type: status.type,
159
+ };
160
+ }
161
+
162
+ function parseNumber(value: string): number {
163
+ const parsed = Number(value);
164
+ if (!Number.isFinite(parsed)) throw usageError(`Invalid number '${value}'.`);
165
+ return parsed;
166
+ }
@@ -165,7 +165,11 @@ export function registerProject(program: Command): void {
165
165
  .option("--start <date>", "planned start date (YYYY-MM-DD)")
166
166
  .option("--target <date>", "planned target date (YYYY-MM-DD)")
167
167
  .option("-P, --priority <0-4>", "priority (0 none, 1 urgent … 4 low)", parseIntOption)
168
- .option("-l, --label <name>", "project label (repeatable / comma-separated)", parseList)
168
+ .option(
169
+ "-l, --label <name>",
170
+ "project label from `project-label list` (repeatable / comma-separated)",
171
+ parseList,
172
+ )
169
173
  .option("--icon <name>", "Linear icon name, capitalized (e.g. Rocket)")
170
174
  .option("--color <hex>", "project color (e.g. #EB5757)")
171
175
  .addHelpText(
@@ -243,7 +247,11 @@ export function registerProject(program: Command): void {
243
247
  .option("--start <date>", "planned start date (YYYY-MM-DD)")
244
248
  .option("--target <date>", "planned target date (YYYY-MM-DD)")
245
249
  .option("-P, --priority <0-4>", "priority (0 none, 1 urgent … 4 low)", parseIntOption)
246
- .option("-l, --label <name>", "replace the labels (repeatable / comma-separated)", parseList)
250
+ .option(
251
+ "-l, --label <name>",
252
+ "replace project labels from `project-label list` (repeatable / comma-separated)",
253
+ parseList,
254
+ )
247
255
  .option("--icon <name>", "Linear icon name, capitalized (e.g. Rocket)")
248
256
  .option("--color <hex>", "project color (e.g. #EB5757)")
249
257
  // `-t/--team` is a global on every other command, so it is the flag a user
package/src/lib/action.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { Command } from "commander";
10
10
  import { Context, type GlobalOptions } from "../context.js";
11
+ import { enrichAuthorizationError } from "./authorization.js";
11
12
 
12
13
  export type ActionHandler = (
13
14
  ctx: Context,
@@ -29,6 +30,10 @@ export function action(handler: ActionHandler) {
29
30
  ["config", "open", "commands", "completion"].includes(group.name()) ||
30
31
  (group.name() === "auth" && !["whoami", "status", "token"].includes(command.name()));
31
32
  if (!offline) await ctx.selectWorkspace();
32
- await handler(ctx, localOpts, ...positionals);
33
+ try {
34
+ await handler(ctx, localOpts, ...positionals);
35
+ } catch (error) {
36
+ throw enrichAuthorizationError(error, ctx.config);
37
+ }
33
38
  };
34
39
  }
@@ -0,0 +1,91 @@
1
+ import type { ResolvedConfig } from "../config.js";
2
+ import { CliError, normalizeError } from "./errors.js";
3
+
4
+ export type CredentialType = "api-key" | "oauth-access-token" | "oauth-user" | null;
5
+ export type ScopeVisibility = "known" | "unknown" | "unavailable";
6
+
7
+ export interface AuthorizationCapabilities {
8
+ credentialType: CredentialType;
9
+ scopeVisibility: ScopeVisibility;
10
+ scopes: string[] | null;
11
+ adminScope: boolean | null;
12
+ note: string;
13
+ }
14
+
15
+ export function credentialType(config: ResolvedConfig): CredentialType {
16
+ if (config.oauthCredential) return "oauth-user";
17
+ if (config.accessToken) return "oauth-access-token";
18
+ if (config.apiKey) return "api-key";
19
+ return null;
20
+ }
21
+
22
+ /**
23
+ * Report only capabilities the CLI can establish without probing a mutation.
24
+ * Stored OAuth grants carry their requested scopes. Linear exposes no safe
25
+ * read-only scope introspection for personal API keys or injected access tokens.
26
+ */
27
+ export function authorizationCapabilities(config: ResolvedConfig): AuthorizationCapabilities {
28
+ const type = credentialType(config);
29
+ if (config.oauthCredential) {
30
+ const scopes = [...config.oauthCredential.scopes];
31
+ return {
32
+ credentialType: type,
33
+ scopeVisibility: "known",
34
+ scopes,
35
+ adminScope: scopes.includes("admin"),
36
+ note: "Stored OAuth grant scopes are known; workspace role is a separate authorization check.",
37
+ };
38
+ }
39
+ if (type) {
40
+ return {
41
+ credentialType: type,
42
+ scopeVisibility: "unknown",
43
+ scopes: null,
44
+ adminScope: null,
45
+ note:
46
+ type === "api-key"
47
+ ? "Linear does not expose personal API-key scopes for read-only introspection; workspace role alone does not prove admin-scope access."
48
+ : "Injected OAuth token scopes are not available to the CLI; workspace role alone does not prove admin-scope access.",
49
+ };
50
+ }
51
+ return {
52
+ credentialType: null,
53
+ scopeVisibility: "unavailable",
54
+ scopes: null,
55
+ adminScope: null,
56
+ note: "No active credential is available to inspect.",
57
+ };
58
+ }
59
+
60
+ function requiredScope(error: CliError): string | undefined {
61
+ const text = [error.message, JSON.stringify(error.detail ?? "")].join(" ");
62
+ if (/admin[^.]{0,40}scope|scope[^.]{0,40}admin/i.test(text)) return "admin";
63
+ return undefined;
64
+ }
65
+
66
+ /** Add safe, stable authorization context to scope-related forbidden failures. */
67
+ export function enrichAuthorizationError(error: unknown, config: ResolvedConfig): CliError {
68
+ const normalized = normalizeError(error);
69
+ if (normalized.code !== "forbidden") return normalized;
70
+ const required = requiredScope(normalized);
71
+ if (!required) return normalized;
72
+
73
+ const capabilities = authorizationCapabilities(config);
74
+ const remediation =
75
+ capabilities.credentialType === "oauth-user"
76
+ ? "Re-authenticate with `linear auth login --admin`; member admin status alone is insufficient."
77
+ : "Use a credential authorized for the admin scope; member admin status alone is insufficient.";
78
+ return new CliError(
79
+ normalized.message,
80
+ normalized.code,
81
+ normalized.detail,
82
+ normalized.suggestion ?? remediation,
83
+ {
84
+ requiredScope: required,
85
+ credentialType: capabilities.credentialType,
86
+ scopeVisibility: capabilities.scopeVisibility,
87
+ grantedScopes: capabilities.scopes,
88
+ remediation,
89
+ },
90
+ );
91
+ }
package/src/lib/errors.ts CHANGED
@@ -52,14 +52,23 @@ export class CliError extends Error {
52
52
  readonly detail?: unknown;
53
53
  /** A separately actionable hint; machine callers never have to parse prose. */
54
54
  readonly suggestion?: string;
55
-
56
- constructor(message: string, code: ErrorCode = "runtime", detail?: unknown, suggestion?: string) {
55
+ /** Stable, safe machine-readable context; unlike `detail`, this needs no --debug. */
56
+ readonly details?: Record<string, unknown>;
57
+
58
+ constructor(
59
+ message: string,
60
+ code: ErrorCode = "runtime",
61
+ detail?: unknown,
62
+ suggestion?: string,
63
+ details?: Record<string, unknown>,
64
+ ) {
57
65
  super(message);
58
66
  this.name = "CliError";
59
67
  this.code = code;
60
68
  this.exitCode = EXIT_BY_CODE[code];
61
69
  this.detail = detail;
62
70
  this.suggestion = suggestion;
71
+ this.details = details;
63
72
  }
64
73
  }
65
74
 
@@ -399,6 +399,10 @@ export const FIELDS_COMMANDS: ReadonlySet<string> = new Set([
399
399
  "project list",
400
400
  "project view",
401
401
  "project milestones",
402
+ "project-status list",
403
+ "project-status view",
404
+ "project-label list",
405
+ "project-label view",
402
406
  "project-update list",
403
407
  "roadmap list",
404
408
  "roadmap view",
@@ -444,6 +448,8 @@ export const LIMIT_COMMANDS: ReadonlySet<string> = new Set([
444
448
  "organization invites",
445
449
  "project list",
446
450
  "project milestones",
451
+ "project-status list",
452
+ "project-label list",
447
453
  "project-update list",
448
454
  "roadmap list",
449
455
  "state list",
@@ -510,7 +516,10 @@ export const YES_COMMANDS: ReadonlySet<string> = new Set([
510
516
  "milestone delete",
511
517
  "notification archive",
512
518
  "project archive",
519
+ "project-status archive",
513
520
  "project delete",
521
+ "project-label retire",
522
+ "project-label delete",
514
523
  "roadmap delete",
515
524
  "team delete",
516
525
  "webhook delete",
@@ -51,6 +51,11 @@ import {
51
51
  PROJECT_MILESTONE_ROW_SHAPE,
52
52
  PROJECT_ROW_SHAPE,
53
53
  } from "../services/project.js";
54
+ import {
55
+ PROJECT_STATUS_DETAIL_SHAPE,
56
+ PROJECT_STATUS_ROW_SHAPE,
57
+ } from "../services/project-status.js";
58
+ import { PROJECT_LABEL_DETAIL_SHAPE, PROJECT_LABEL_ROW_SHAPE } from "../services/project-label.js";
54
59
  import { ROADMAP_DETAIL_SHAPE, ROADMAP_ROW_SHAPE } from "../services/roadmap.js";
55
60
  import { STATE_DETAIL_SHAPE, STATE_ROW_SHAPE } from "../services/state.js";
56
61
  import {
@@ -140,6 +145,14 @@ const UPDATE_RECEIPT_SHAPE = shape<UpdateRow & { url: string }>({
140
145
  url: "string",
141
146
  });
142
147
 
148
+ const AUTHORIZATION_CAPABILITIES: ObjectFields = {
149
+ credentialType: "string|null",
150
+ scopeVisibility: "string",
151
+ scopes: { nullable: ["string"] },
152
+ adminScope: "boolean|null",
153
+ note: "string",
154
+ };
155
+
143
156
  /** `whoami` / `auth whoami`. */
144
157
  const WHOAMI_SHAPE: ObjectFields = {
145
158
  id: "string",
@@ -147,6 +160,7 @@ const WHOAMI_SHAPE: ObjectFields = {
147
160
  displayName: "string",
148
161
  email: "string",
149
162
  admin: "boolean",
163
+ authorization: AUTHORIZATION_CAPABILITIES,
150
164
  organization: { id: "string", name: "string", urlKey: "string" },
151
165
  };
152
166
 
@@ -348,6 +362,9 @@ export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
348
362
  keyring: "string|null",
349
363
  scopes: { nullable: ["string"] },
350
364
  expiresAt: "string|null",
365
+ scopeVisibility: "string",
366
+ adminScope: "boolean|null",
367
+ scopeNote: "string",
351
368
  }),
352
369
  "auth token": object({ apiKey: "string", workspace: "string|null" }),
353
370
  "auth whoami": WHOAMI,
@@ -620,6 +637,31 @@ export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
620
637
  "project update": receipt(NAMED_URL_RECEIPT),
621
638
  "project view": object(PROJECT_DETAIL_SHAPE),
622
639
 
640
+ "project-status": null,
641
+ "project-status archive": receipt({ id: "string", name: "string", archived: "boolean" }),
642
+ "project-status create": receipt({
643
+ id: "string",
644
+ name: "string",
645
+ type: "string",
646
+ }),
647
+ "project-status list": list(PROJECT_STATUS_ROW_SHAPE),
648
+ "project-status unarchive": receipt({ id: "string", name: "string", archived: "boolean" }),
649
+ "project-status update": receipt({
650
+ id: "string",
651
+ name: "string",
652
+ type: "string",
653
+ }),
654
+ "project-status view": object(PROJECT_STATUS_DETAIL_SHAPE),
655
+
656
+ "project-label": null,
657
+ "project-label create": receipt({ id: "string", name: "string", color: "string" }),
658
+ "project-label delete": receipt(NAMED_DELETED),
659
+ "project-label list": list(PROJECT_LABEL_ROW_SHAPE),
660
+ "project-label restore": receipt({ id: "string", name: "string", restored: "boolean" }),
661
+ "project-label retire": receipt({ id: "string", name: "string", retired: "boolean" }),
662
+ "project-label update": receipt({ id: "string", name: "string", color: "string" }),
663
+ "project-label view": object(PROJECT_LABEL_DETAIL_SHAPE),
664
+
623
665
  "project-update": null,
624
666
  "project-update create": receipt(UPDATE_RECEIPT_SHAPE),
625
667
  "project-update list": list(UPDATE_ROW_SHAPE),
@@ -315,10 +315,15 @@ export async function resolveProjectLabelIds(
315
315
  client: LinearClient,
316
316
  names: string[],
317
317
  ): Promise<string[]> {
318
- const ids: string[] = [];
318
+ const labels: any[] = [];
319
319
  for (const name of names) {
320
320
  if (isUuid(name)) {
321
- ids.push(name);
321
+ const label: any = await withRetry(() => (client as any).projectLabel(name));
322
+ if (label.isGroup)
323
+ throw usageError(`Project label '${label.name}' is a group and cannot be assigned.`);
324
+ if (label.retiredAt || label.retiredById)
325
+ throw usageError(`Project label '${label.name}' is retired and cannot be assigned.`);
326
+ labels.push(label);
322
327
  continue;
323
328
  }
324
329
  const conn: any = await withRetry(() =>
@@ -327,21 +332,33 @@ export async function resolveProjectLabelIds(
327
332
  first: RESOLVE_PAGE,
328
333
  }),
329
334
  );
330
- const nodes = await scanAll<any>(conn, "project labels", "linear project label list");
331
- const candidates = nodes.filter((l) => !l.isGroup);
335
+ const nodes = await scanAll<any>(conn, "project labels", "linear project-label list");
336
+ const candidates = nodes.filter((l) => !l.isGroup && !l.retiredAt && !l.retiredById);
332
337
  if (candidates.length === 0)
333
338
  throw notFound(
334
- `No project label matching '${name}'. Run 'linear project label list' to see the options.`,
339
+ `No project label matching '${name}'. Run 'linear project-label list' to see the options.`,
335
340
  );
336
341
 
337
342
  const exact = candidates.filter((l) => l.name === name);
338
343
  const finalists = exact.length ? exact : candidates;
339
344
  if (finalists.length > 1)
340
345
  throw ambiguous(`Multiple project labels named '${name}'; pass the label id instead.`);
341
- ids.push(finalists[0]!.id);
346
+ labels.push(finalists[0]!);
347
+ }
348
+ const byParent = new Map<string, any>();
349
+ for (const label of labels) {
350
+ const parentId = label.parentId;
351
+ if (!parentId) continue;
352
+ const other = byParent.get(parentId);
353
+ if (other && other.id !== label.id) {
354
+ throw usageError(
355
+ `Project labels '${other.name}' and '${label.name}' belong to the same mutually exclusive group; choose one.`,
356
+ );
357
+ }
358
+ byParent.set(parentId, label);
342
359
  }
343
360
  // The same label named twice (or by name and id) must not be sent twice.
344
- return [...new Set(ids)];
361
+ return [...new Set(labels.map((label) => label.id))];
345
362
  }
346
363
 
347
364
  /** First workflow state of a given type (lowest position) within a team. */
@@ -217,6 +217,7 @@ export class Output {
217
217
  // absent entirely without --debug.
218
218
  const error: Record<string, unknown> = { message: err.message, code: err.code };
219
219
  if (err.suggestion !== undefined) error.suggestion = err.suggestion;
220
+ if (err.details !== undefined) error.details = err.details;
220
221
  if (showDetail) error.detail = err.detail;
221
222
  process.stderr.write(JSON.stringify({ error }) + "\n");
222
223
  return;