linear-sdk-cli 0.5.3 → 0.7.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,108 @@
1
+ /** Show release notes from the CHANGELOG.md bundled with the CLI package. */
2
+
3
+ import { readFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { Command } from "commander";
6
+ import { action } from "../lib/action.js";
7
+ import { CliError } from "../lib/errors.js";
8
+ import type { Context } from "../context.js";
9
+
10
+ export interface ChangelogSection {
11
+ title: string;
12
+ items: string[];
13
+ }
14
+
15
+ export interface ChangelogRelease {
16
+ version: string;
17
+ date: string;
18
+ url: string;
19
+ sections: ChangelogSection[];
20
+ }
21
+
22
+ const CHANGELOG_PATH = fileURLToPath(new URL("../../CHANGELOG.md", import.meta.url));
23
+ const DEFAULT_RELEASE_COUNT = 3;
24
+
25
+ /** Parse the Release Please Markdown format into a stable machine-readable shape. */
26
+ export function parseChangelog(markdown: string): ChangelogRelease[] {
27
+ const releases: ChangelogRelease[] = [];
28
+ const releasePattern = /^## \[([^\]]+)]\(([^)]+)\) \((\d{4}-\d{2}-\d{2})\)\s*$/gm;
29
+ const matches = [...markdown.matchAll(releasePattern)];
30
+
31
+ for (const [index, match] of matches.entries()) {
32
+ const bodyStart = (match.index ?? 0) + match[0].length;
33
+ const bodyEnd = matches[index + 1]?.index ?? markdown.length;
34
+ const body = markdown.slice(bodyStart, bodyEnd);
35
+ const sections: ChangelogSection[] = [];
36
+ const sectionPattern = /^### (.+)\s*$/gm;
37
+ const sectionMatches = [...body.matchAll(sectionPattern)];
38
+
39
+ for (const [sectionIndex, sectionMatch] of sectionMatches.entries()) {
40
+ const itemsStart = (sectionMatch.index ?? 0) + sectionMatch[0].length;
41
+ const itemsEnd = sectionMatches[sectionIndex + 1]?.index ?? body.length;
42
+ const itemsBody = body.slice(itemsStart, itemsEnd).trim();
43
+ const items = itemsBody
44
+ .split(/\n(?=- )/)
45
+ .map((item) => item.replace(/^- /, "").replace(/\n\s+/g, " ").trim())
46
+ .filter(Boolean);
47
+ sections.push({ title: sectionMatch[1]!.trim(), items });
48
+ }
49
+
50
+ releases.push({
51
+ version: match[1]!,
52
+ url: match[2]!,
53
+ date: match[3]!,
54
+ sections,
55
+ });
56
+ }
57
+
58
+ return releases;
59
+ }
60
+
61
+ export function registerChangelog(program: Command): void {
62
+ program
63
+ .command("changelog")
64
+ .description("Show recent CLI release notes")
65
+ .addHelpText(
66
+ "after",
67
+ [
68
+ "",
69
+ "Examples:",
70
+ " linear changelog # latest three releases",
71
+ " linear changelog --limit 1 # latest release only",
72
+ " linear changelog --all # complete bundled history",
73
+ " linear changelog --json # structured release notes",
74
+ ].join("\n"),
75
+ )
76
+ .action(action(runChangelog));
77
+ }
78
+
79
+ export function runChangelog(ctx: Context): void {
80
+ let markdown: string;
81
+ try {
82
+ markdown = readFileSync(CHANGELOG_PATH, "utf8");
83
+ } catch (error) {
84
+ throw new CliError(`Cannot read the bundled changelog: ${(error as Error).message}`, "runtime");
85
+ }
86
+
87
+ const releases = parseChangelog(markdown);
88
+ const count =
89
+ ctx.options.all || ctx.options.limit === 0
90
+ ? releases.length
91
+ : (ctx.options.limit ?? DEFAULT_RELEASE_COUNT);
92
+ const recent = releases.slice(0, count);
93
+
94
+ ctx.output.emit(recent, () => {
95
+ const rendered = recent
96
+ .map((release) => {
97
+ const sections = release.sections
98
+ .map(
99
+ (section) =>
100
+ `### ${section.title}\n\n${section.items.map((item) => `- ${item}`).join("\n")}`,
101
+ )
102
+ .join("\n\n");
103
+ return `## [${release.version}](${release.url}) (${release.date})\n\n${sections}`;
104
+ })
105
+ .join("\n\n");
106
+ ctx.output.markdown(rendered);
107
+ });
108
+ }
@@ -38,6 +38,10 @@ import { resolveTeam } from "../lib/resolve.js";
38
38
  import { ISSUE_SORTS } from "../services/issue.js";
39
39
  import { firstTeam, type Context } from "../context.js";
40
40
  import { openUrl } from "../lib/open.js";
41
+ import {
42
+ authorizationCapabilities,
43
+ credentialType as resolvedCredentialType,
44
+ } from "../lib/authorization.js";
41
45
  import {
42
46
  DEFAULT_OAUTH_SCOPES,
43
47
  buildAuthorizationUrl,
@@ -59,6 +63,7 @@ export const DEFAULT_OAUTH_CLIENT_ID = "eda60862e4bb8b8af82cfb3193b65c2f";
59
63
  const whoamiAction = action(async (ctx: Context) => {
60
64
  const me = await withRetry(() => ctx.client.viewer);
61
65
  const org = await withRetry(() => ctx.client.organization);
66
+ const authorization = authorizationCapabilities(ctx.config);
62
67
  ctx.output.detail(
63
68
  {
64
69
  id: me.id,
@@ -66,13 +71,17 @@ const whoamiAction = action(async (ctx: Context) => {
66
71
  displayName: me.displayName,
67
72
  email: me.email,
68
73
  admin: me.admin,
74
+ authorization,
69
75
  organization: { id: org.id, name: org.name, urlKey: org.urlKey },
70
76
  },
71
77
  [
72
78
  ["Name", me.name],
73
79
  ["Display name", me.displayName],
74
80
  ["Email", me.email],
75
- ["Admin", me.admin],
81
+ ["Workspace admin", me.admin],
82
+ ["Credential type", authorization.credentialType ?? "(none)"],
83
+ ["Credential scopes", authorization.scopes?.join(", ") ?? "unknown"],
84
+ ["Credential admin scope", authorization.adminScope ?? "unknown"],
76
85
  ["User ID", me.id],
77
86
  ["Organization", `${org.name} (${org.urlKey})`],
78
87
  ],
@@ -481,13 +490,8 @@ export function registerMeta(program: Command): void {
481
490
  const c = ctx.config;
482
491
  if (c.workspaceChoices) throw c.apiKeyError;
483
492
  const credential = c.accessToken ?? c.apiKey;
484
- const credentialType = c.oauthCredential
485
- ? "oauth-user"
486
- : c.accessToken
487
- ? "oauth-access-token"
488
- : c.apiKey
489
- ? "api-key"
490
- : null;
493
+ const credentialType = resolvedCredentialType(c);
494
+ const authorization = authorizationCapabilities(c);
491
495
  const source = c.accessToken ? c.accessTokenSource : c.apiKeySource;
492
496
  const backend = keyring();
493
497
  ctx.output.detail(
@@ -502,6 +506,9 @@ export function registerMeta(program: Command): void {
502
506
  expiresAt: c.oauthCredential
503
507
  ? new Date(c.oauthCredential.expiresAt).toISOString()
504
508
  : null,
509
+ scopeVisibility: authorization.scopeVisibility,
510
+ adminScope: authorization.adminScope,
511
+ scopeNote: authorization.note,
505
512
  },
506
513
  [
507
514
  ["Authenticated", !!credential],
@@ -511,6 +518,9 @@ export function registerMeta(program: Command): void {
511
518
  ["Credential", redactKey(credential)],
512
519
  ["Keyring", backend ? backend.label : "(none on this platform)"],
513
520
  ["Scopes", c.oauthCredential?.scopes.join(", ")],
521
+ ["Scope visibility", authorization.scopeVisibility],
522
+ ["Admin scope", authorization.adminScope ?? "unknown"],
523
+ ["Scope note", authorization.note],
514
524
  [
515
525
  "Expires",
516
526
  c.oauthCredential ? new Date(c.oauthCredential.expiresAt).toISOString() : undefined,
@@ -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,
@@ -26,9 +27,13 @@ export function action(handler: ActionHandler) {
26
27
  let group = command;
27
28
  while (group.parent?.parent) group = group.parent;
28
29
  const offline =
29
- ["config", "open", "commands", "completion"].includes(group.name()) ||
30
+ ["config", "open", "commands", "completion", "changelog"].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