linear-sdk-cli 0.1.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.
Files changed (111) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +9 -0
  3. package/CHANGELOG.md +807 -0
  4. package/COVERAGE.md +495 -0
  5. package/LICENSE +21 -0
  6. package/MIGRATING.md +220 -0
  7. package/README.md +524 -0
  8. package/package.json +87 -0
  9. package/skills/linear-sdk-cli/SKILL.md +534 -0
  10. package/skills/linear-sdk-cli/references/api.md +27 -0
  11. package/skills/linear-sdk-cli/references/attachment.md +82 -0
  12. package/skills/linear-sdk-cli/references/auth.md +174 -0
  13. package/skills/linear-sdk-cli/references/commands.md +37 -0
  14. package/skills/linear-sdk-cli/references/comment.md +154 -0
  15. package/skills/linear-sdk-cli/references/completion.md +17 -0
  16. package/skills/linear-sdk-cli/references/config.md +99 -0
  17. package/skills/linear-sdk-cli/references/cycle.md +131 -0
  18. package/skills/linear-sdk-cli/references/document.md +180 -0
  19. package/skills/linear-sdk-cli/references/favorite.md +78 -0
  20. package/skills/linear-sdk-cli/references/initiative-update.md +67 -0
  21. package/skills/linear-sdk-cli/references/initiative.md +256 -0
  22. package/skills/linear-sdk-cli/references/issue.md +928 -0
  23. package/skills/linear-sdk-cli/references/label.md +112 -0
  24. package/skills/linear-sdk-cli/references/milestone.md +139 -0
  25. package/skills/linear-sdk-cli/references/notification.md +123 -0
  26. package/skills/linear-sdk-cli/references/open.md +29 -0
  27. package/skills/linear-sdk-cli/references/organization.md +99 -0
  28. package/skills/linear-sdk-cli/references/project-update.md +67 -0
  29. package/skills/linear-sdk-cli/references/project.md +234 -0
  30. package/skills/linear-sdk-cli/references/roadmap.md +150 -0
  31. package/skills/linear-sdk-cli/references/schema.md +21 -0
  32. package/skills/linear-sdk-cli/references/state.md +61 -0
  33. package/skills/linear-sdk-cli/references/team.md +227 -0
  34. package/skills/linear-sdk-cli/references/user.md +103 -0
  35. package/skills/linear-sdk-cli/references/webhook.md +151 -0
  36. package/skills/linear-sdk-cli/references/whoami.md +26 -0
  37. package/skills/linear-sdk-cli/scripts/generate-docs.ts +339 -0
  38. package/src/bin/linear.ts +97 -0
  39. package/src/cli.ts +232 -0
  40. package/src/client.ts +172 -0
  41. package/src/commands/agent-session.ts +121 -0
  42. package/src/commands/api.ts +317 -0
  43. package/src/commands/attach.ts +90 -0
  44. package/src/commands/attachment.ts +74 -0
  45. package/src/commands/comment.ts +225 -0
  46. package/src/commands/completion.ts +69 -0
  47. package/src/commands/cycle.ts +125 -0
  48. package/src/commands/discover.ts +170 -0
  49. package/src/commands/document.ts +211 -0
  50. package/src/commands/favorite.ts +72 -0
  51. package/src/commands/initiative-update.ts +47 -0
  52. package/src/commands/initiative.ts +261 -0
  53. package/src/commands/issue.ts +1263 -0
  54. package/src/commands/label.ts +119 -0
  55. package/src/commands/meta.ts +534 -0
  56. package/src/commands/milestone.ts +163 -0
  57. package/src/commands/notification.ts +110 -0
  58. package/src/commands/open.ts +84 -0
  59. package/src/commands/organization.ts +79 -0
  60. package/src/commands/project-update.ts +47 -0
  61. package/src/commands/project.ts +339 -0
  62. package/src/commands/roadmap.ts +131 -0
  63. package/src/commands/state.ts +58 -0
  64. package/src/commands/team.ts +243 -0
  65. package/src/commands/user.ts +107 -0
  66. package/src/commands/webhook.ts +156 -0
  67. package/src/config.ts +1236 -0
  68. package/src/context.ts +117 -0
  69. package/src/git.ts +174 -0
  70. package/src/index.ts +10 -0
  71. package/src/lib/action.ts +26 -0
  72. package/src/lib/body.ts +172 -0
  73. package/src/lib/errors.ts +195 -0
  74. package/src/lib/introspect.ts +150 -0
  75. package/src/lib/keyring.ts +270 -0
  76. package/src/lib/mutation.ts +53 -0
  77. package/src/lib/open.ts +32 -0
  78. package/src/lib/options.ts +640 -0
  79. package/src/lib/output-shapes.ts +562 -0
  80. package/src/lib/pagination.ts +145 -0
  81. package/src/lib/prompt.ts +91 -0
  82. package/src/lib/resolve.ts +596 -0
  83. package/src/lib/shape.ts +273 -0
  84. package/src/lib/status-update.ts +91 -0
  85. package/src/lib/upload.ts +329 -0
  86. package/src/output/color.ts +52 -0
  87. package/src/output/format.ts +238 -0
  88. package/src/output/markdown.ts +133 -0
  89. package/src/output/pager.ts +131 -0
  90. package/src/output/sanitize.ts +76 -0
  91. package/src/output/table.ts +198 -0
  92. package/src/services/agent-session.ts +289 -0
  93. package/src/services/attachment.ts +198 -0
  94. package/src/services/comment.ts +262 -0
  95. package/src/services/cycle.ts +224 -0
  96. package/src/services/document.ts +357 -0
  97. package/src/services/favorite.ts +147 -0
  98. package/src/services/initiative-update.ts +59 -0
  99. package/src/services/initiative.ts +449 -0
  100. package/src/services/issue.ts +1153 -0
  101. package/src/services/label.ts +193 -0
  102. package/src/services/milestone.ts +262 -0
  103. package/src/services/notification.ts +190 -0
  104. package/src/services/organization.ts +144 -0
  105. package/src/services/project-update.ts +36 -0
  106. package/src/services/project.ts +494 -0
  107. package/src/services/roadmap.ts +177 -0
  108. package/src/services/state.ts +127 -0
  109. package/src/services/team.ts +386 -0
  110. package/src/services/user.ts +137 -0
  111. package/src/services/webhook.ts +203 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * `linear label` (alias `lb`) — work with issue labels.
3
+ *
4
+ * Labels are either workspace-level (no team) or scoped to a single team. The
5
+ * `list` command optionally narrows to a team (defaulting to the configured
6
+ * team); create/update/delete take a label name or id.
7
+ */
8
+
9
+ import { Command } from "commander";
10
+ import { action } from "../lib/action.js";
11
+ import { confirmDestructive, promptInput } from "../lib/prompt.js";
12
+ import type { Context } from "../context.js";
13
+ import * as svc from "../services/label.js";
14
+ import type { Column } from "../output/table.js";
15
+
16
+ const ROW_COLUMNS: Column<svc.LabelRow>[] = [
17
+ { key: "name", header: "Name", value: (r) => r.name, max: 30 },
18
+ { key: "color", header: "Color", value: (r) => r.color },
19
+ { key: "team", header: "Team", value: (r) => r.team?.key ?? "—" },
20
+ { key: "isGroup", header: "Group", value: (r) => (r.isGroup ? "yes" : "no") },
21
+ ];
22
+
23
+ export function registerLabel(program: Command): void {
24
+ const label = program.command("label").alias("lb").description("Work with issue labels");
25
+
26
+ // list --------------------------------------------------------------------
27
+ label
28
+ .command("list [team]")
29
+ .alias("ls")
30
+ .description("List the labels usable in a team (its own plus workspace-level ones)")
31
+ .option("--all-teams", "list every label in the workspace, ignoring the team scope")
32
+ .addHelpText(
33
+ "after",
34
+ [
35
+ "",
36
+ "Examples:",
37
+ " linear label list # labels valid on the default team's issues",
38
+ " linear label list ENG # the same for ENG",
39
+ " linear label list --all-teams # every label in the workspace",
40
+ " linear label list --json | jq -c '.[] | select(.team == null) | .name' # workspace-level only",
41
+ ].join("\n"),
42
+ )
43
+ .action(
44
+ action(async (ctx: Context, opts, teamArg?: string) => {
45
+ const rows = await svc.listLabels(ctx.client, teamArg, ctx.limit, ctx.defaultTeam, {
46
+ allTeams: !!opts.allTeams,
47
+ });
48
+ ctx.output.list(rows, ROW_COLUMNS, rows);
49
+ }),
50
+ );
51
+
52
+ // create ------------------------------------------------------------------
53
+ label
54
+ .command("create")
55
+ .alias("new")
56
+ .description("Create a label (scoped to --team if set, else workspace-level)")
57
+ .option("--name <name>", "label name")
58
+ .option("--color <hex>", "label color (e.g. #EB5757)")
59
+ .option("-d, --description <text>", "label description")
60
+ .option("--shared", "create a workspace-level (shared) label even when a default team is set")
61
+ .option("--parent <name>", "parent label (creates a sub-label)")
62
+ .action(
63
+ action(async (ctx: Context, opts) => {
64
+ let name: string | undefined = opts.name;
65
+ if (!name) name = await promptInput(ctx, "Name:", { required: true });
66
+ const created = await svc.createLabel(
67
+ ctx.client,
68
+ {
69
+ name,
70
+ color: opts.color,
71
+ description: opts.description,
72
+ // Scope to the global --team (ctx.defaultTeam) unless --shared.
73
+ team: opts.shared ? undefined : ctx.defaultTeam,
74
+ parent: opts.parent,
75
+ },
76
+ ctx.defaultTeam,
77
+ );
78
+ ctx.output.emit({ id: created.id, name: created.name, color: created.color }, () =>
79
+ ctx.output.success(`Created label ${created.name}`),
80
+ );
81
+ }),
82
+ );
83
+
84
+ // update ------------------------------------------------------------------
85
+ label
86
+ .command("update <id>")
87
+ .alias("edit")
88
+ .description("Update a label (by name or id)")
89
+ .option("--name <name>", "new label name")
90
+ .option("--color <hex>", "new label color (e.g. #EB5757)")
91
+ .option("-d, --description <text>", "new label description")
92
+ .action(
93
+ action(async (ctx: Context, opts, idArg: string) => {
94
+ const updated = await svc.updateLabel(ctx.client, idArg, {
95
+ name: opts.name,
96
+ color: opts.color,
97
+ description: opts.description,
98
+ });
99
+ ctx.output.emit({ id: updated.id, name: updated.name, color: updated.color }, () =>
100
+ ctx.output.success(`Updated label ${updated.name}`),
101
+ );
102
+ }),
103
+ );
104
+
105
+ // delete ------------------------------------------------------------------
106
+ label
107
+ .command("delete <id>")
108
+ .alias("rm")
109
+ .description("Delete a label (by name or id)")
110
+ .action(
111
+ action(async (ctx: Context, _opts, idArg: string) => {
112
+ if (!(await confirmDestructive(ctx, `Delete label ${idArg}?`))) return;
113
+ const deleted = await svc.deleteLabel(ctx.client, idArg);
114
+ ctx.output.emit({ id: deleted.id, name: deleted.name, deleted: true }, () =>
115
+ ctx.output.success(`Deleted label ${deleted.name}`),
116
+ );
117
+ }),
118
+ );
119
+ }
@@ -0,0 +1,534 @@
1
+ /**
2
+ * Top-level meta commands: whoami, auth (login/list/default/token/status/
3
+ * logout/migrate), config.
4
+ */
5
+
6
+ import { Command } from "commander";
7
+ import { LinearClient } from "@linear/sdk";
8
+ import { action } from "../lib/action.js";
9
+ import { withRetry } from "../client.js";
10
+ import { resolve } from "node:path";
11
+ import {
12
+ writeCredential,
13
+ probeKeyringCredential,
14
+ adoptKeyringCredential,
15
+ removeCredential,
16
+ setDefaultWorkspace,
17
+ listCredentials,
18
+ migrateCredentials,
19
+ redactKey,
20
+ resolveConfig,
21
+ defaultProjectConfigPath,
22
+ assertSettableKey,
23
+ setConfigKey,
24
+ initProjectConfig,
25
+ SETTABLE_KEYS,
26
+ type SettableKey,
27
+ } from "../config.js";
28
+ import { keyring } from "../lib/keyring.js";
29
+ import { readStdinSync } from "../lib/body.js";
30
+ import { authError, normalizeError, usageError } from "../lib/errors.js";
31
+ import { confirmDestructive, promptSecret, promptSelect } from "../lib/prompt.js";
32
+ import { listTeams } from "../services/team.js";
33
+ import { ISSUE_SORTS } from "../services/issue.js";
34
+ import { firstTeam, type Context } from "../context.js";
35
+
36
+ /**
37
+ * The `whoami` handler, shared by the top-level `linear whoami` and the
38
+ * reference CLI's spelling `linear auth whoami` so the two cannot drift.
39
+ */
40
+ const whoamiAction = action(async (ctx: Context) => {
41
+ const me = await withRetry(() => ctx.client.viewer);
42
+ const org = await withRetry(() => ctx.client.organization);
43
+ ctx.output.detail(
44
+ {
45
+ id: me.id,
46
+ name: me.name,
47
+ displayName: me.displayName,
48
+ email: me.email,
49
+ admin: me.admin,
50
+ organization: { id: org.id, name: org.name, urlKey: org.urlKey },
51
+ },
52
+ [
53
+ ["Name", me.name],
54
+ ["Display name", me.displayName],
55
+ ["Email", me.email],
56
+ ["Admin", me.admin],
57
+ ["User ID", me.id],
58
+ ["Organization", `${org.name} (${org.urlKey})`],
59
+ ],
60
+ );
61
+ });
62
+
63
+ interface AuthIdentity {
64
+ user: { id: string; name: string; email: string };
65
+ organization: { id: string; name: string; urlKey: string };
66
+ }
67
+
68
+ interface AuthValidationClient {
69
+ viewer: Promise<{ id: string; name: string; email: string }>;
70
+ organization: Promise<{ id: string; name: string; urlKey: string }>;
71
+ }
72
+
73
+ export type AuthValidationClientFactory = (apiKey: string) => AuthValidationClient;
74
+
75
+ const defaultAuthValidationClientFactory: AuthValidationClientFactory = (apiKey) =>
76
+ new LinearClient({ apiKey }) as unknown as AuthValidationClient;
77
+ let authValidationClientFactory = defaultAuthValidationClientFactory;
78
+
79
+ /** Test seam for command-level auth tests; production always uses LinearClient. */
80
+ export function setAuthValidationClientFactoryForTests(
81
+ factory: AuthValidationClientFactory | undefined,
82
+ ): void {
83
+ authValidationClientFactory = factory ?? defaultAuthValidationClientFactory;
84
+ }
85
+
86
+ /** Merge the local and global login spellings without silently choosing one. */
87
+ export function selectLoginKey(
88
+ localKey: string | undefined,
89
+ globalKey: string | undefined,
90
+ ): string | undefined {
91
+ if (localKey !== undefined && globalKey !== undefined) {
92
+ throw usageError("Pass only one of --key or --api-key to `auth login`.");
93
+ }
94
+ return localKey ?? globalKey;
95
+ }
96
+
97
+ /** Validate a credential and prove that an explicitly requested slug matches it. */
98
+ export async function validateAuthCredential(
99
+ apiKey: string,
100
+ requestedWorkspace?: string,
101
+ ): Promise<AuthIdentity> {
102
+ const client = authValidationClientFactory(apiKey);
103
+ let me;
104
+ let org;
105
+ try {
106
+ me = await withRetry(() => client.viewer);
107
+ org = await withRetry(() => client.organization);
108
+ } catch (err) {
109
+ const normalized = normalizeError(err);
110
+ if (normalized.code === "auth" || normalized.code === "forbidden") {
111
+ throw authError("That API key was rejected by Linear.");
112
+ }
113
+ throw normalized;
114
+ }
115
+ if (requestedWorkspace && requestedWorkspace !== org.urlKey) {
116
+ throw usageError(
117
+ `--workspace '${requestedWorkspace}' does not match the key's workspace '${org.urlKey}'. Store credentials under their Linear workspace slug.`,
118
+ );
119
+ }
120
+ return {
121
+ user: { id: me.id, name: me.name, email: me.email },
122
+ organization: { id: org.id, name: org.name, urlKey: org.urlKey },
123
+ };
124
+ }
125
+
126
+ function explicitWorkspaceSlug(input: string): string {
127
+ const slug = input.trim();
128
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,98}[a-z0-9])?$/.test(slug)) {
129
+ throw usageError(
130
+ "Workspace slug must be its lowercase Linear URL key (letters, numbers, and hyphens).",
131
+ );
132
+ }
133
+ return slug;
134
+ }
135
+
136
+ export function registerMeta(program: Command): void {
137
+ program.command("whoami").description("Show the authenticated user").action(whoamiAction);
138
+
139
+ const auth = program.command("auth").description("Manage authentication");
140
+
141
+ // whoami ------------------------------------------------------------------
142
+ // The reference CLI nests this under `auth`; ours is top-level. Both spellings
143
+ // run the identical handler. (Note it is NOT an alias of `auth status`, which
144
+ // reports where the *key* came from and never names the user — the reference's
145
+ // `auth whoami` prints user + workspace, exactly like our top-level `whoami`.)
146
+ auth.command("whoami").description("Show the authenticated user").action(whoamiAction);
147
+
148
+ // login -------------------------------------------------------------------
149
+ auth
150
+ .command("login")
151
+ .description("Validate and store a Linear API key for a workspace")
152
+ .option("--key <key>", "API key (otherwise prompted; '-' reads it from stdin)")
153
+ .option("--plaintext", "Store the key in the config file (0600) instead of the system keyring")
154
+ .action(
155
+ // The global `--workspace <slug>` selects the slug to store under
156
+ // (default: derived from the key's organization urlKey).
157
+ action(async (ctx: Context, opts) => {
158
+ // `--key` still works for scripts. When prompted, the key is masked —
159
+ // it is a credential, and it must not reach the screen or scrollback.
160
+ // Nothing below echoes it either: the receipt reports the user and
161
+ // where the key went, never the value.
162
+ let key: string | undefined = selectLoginKey(opts.key, ctx.options.apiKey);
163
+ if (key === "-") {
164
+ key = readStdinSync();
165
+ } else if (key) {
166
+ // argv is world-readable (`ps`) and lands in shell history; say so
167
+ // once, quietly, and carry on — the script author chose it.
168
+ ctx.output.warn(
169
+ "An API key on the command line is visible to other processes and shell history; prefer the prompt or `--key -` (stdin).",
170
+ );
171
+ } else {
172
+ key = await promptSecret(ctx, "Linear API key:", { required: true });
173
+ }
174
+ key = key.trim();
175
+ if (!key) throw usageError("No API key provided.");
176
+ // Validate before persisting and learn the workspace slug.
177
+ const identity = await validateAuthCredential(key, ctx.options.workspace);
178
+ const { user: me, organization: org } = identity;
179
+ const slug: string = org.urlKey;
180
+ const { path, storage, keyringLabel } = writeCredential(slug, key, {
181
+ plaintext: opts.plaintext === true,
182
+ });
183
+ const where =
184
+ storage === "keychain"
185
+ ? `Credential saved to the ${keyringLabel} (service linear-cli, account '${slug}'); ${path} records the workspace.`
186
+ : opts.plaintext
187
+ ? `Credential saved to ${path} (plaintext, 0600).`
188
+ : `Credential saved to ${path} (plaintext, 0600 — no system keyring on this platform).`;
189
+ ctx.output.emit(
190
+ {
191
+ success: true,
192
+ workspace: slug,
193
+ user: { id: me.id, name: me.name, email: me.email },
194
+ storage,
195
+ path,
196
+ },
197
+ () =>
198
+ ctx.output.success(
199
+ `Authenticated as ${me.name} <${me.email}> for workspace '${slug}'. ${where}`,
200
+ ),
201
+ );
202
+ }),
203
+ );
204
+
205
+ // adopt ------------------------------------------------------------------
206
+ auth
207
+ .command("adopt <slug>")
208
+ .description("Adopt an existing named credential from the shared OS keyring")
209
+ .action(
210
+ action(async (ctx: Context, _opts, input: string) => {
211
+ const slug = explicitWorkspaceSlug(input);
212
+ if (ctx.options.apiKey !== undefined) {
213
+ throw usageError(
214
+ "`auth adopt` reads only the named OS keyring entry; remove --api-key (or use `auth login --key -` to supply a key on stdin).",
215
+ );
216
+ }
217
+ if (ctx.options.workspace && ctx.options.workspace !== slug) {
218
+ throw usageError(
219
+ `The positional workspace '${slug}' conflicts with --workspace '${ctx.options.workspace}'.`,
220
+ );
221
+ }
222
+
223
+ // Probe one exact account only. No keyring enumeration and no secret
224
+ // on argv, stdout, stderr, or disk.
225
+ const found = probeKeyringCredential(slug);
226
+ const identity = await validateAuthCredential(found.apiKey, slug);
227
+ const adopted = adoptKeyringCredential(slug, found.apiKey);
228
+ ctx.output.emit(
229
+ {
230
+ success: true,
231
+ workspace: slug,
232
+ user: identity.user,
233
+ storage: adopted.storage,
234
+ path: adopted.path,
235
+ },
236
+ () =>
237
+ ctx.output.success(
238
+ `Adopted workspace '${slug}' from the ${adopted.keyringLabel}; ${adopted.path} now records the workspace without storing the secret.`,
239
+ ),
240
+ );
241
+ }),
242
+ );
243
+
244
+ // list --------------------------------------------------------------------
245
+ auth
246
+ .command("list")
247
+ .alias("ls")
248
+ .description("List configured workspace credentials")
249
+ .action(
250
+ action(async (ctx: Context) => {
251
+ const entries = listCredentials();
252
+ ctx.output.list(
253
+ entries,
254
+ [
255
+ { key: "slug", header: "Workspace", value: (e) => e.slug },
256
+ { key: "isDefault", header: "Default", value: (e) => (e.isDefault ? "yes" : "") },
257
+ { key: "storage", header: "Storage", value: (e) => e.storage },
258
+ ],
259
+ entries,
260
+ );
261
+ }),
262
+ );
263
+
264
+ // default -----------------------------------------------------------------
265
+ auth
266
+ .command("default <slug>")
267
+ .description("Set the default workspace credential")
268
+ .action(
269
+ action(async (ctx: Context, _opts, slug: string) => {
270
+ const path = setDefaultWorkspace(slug);
271
+ ctx.output.emit({ success: true, default_workspace: slug, path }, () =>
272
+ ctx.output.success(`Default workspace set to '${slug}'.`),
273
+ );
274
+ }),
275
+ );
276
+
277
+ // token -------------------------------------------------------------------
278
+ auth
279
+ .command("token")
280
+ .description("Print the resolved API key for the active workspace (for scripting)")
281
+ .action(
282
+ action(async (ctx: Context) => {
283
+ const c = ctx.config;
284
+ if (!c.apiKey) {
285
+ // Surface the precise selection error (ambiguous / unstored slug) if any.
286
+ throw (
287
+ c.apiKeyError ??
288
+ authError("No API key resolved. Run `linear auth login` or pass --workspace/--api-key.")
289
+ );
290
+ }
291
+ // This command intentionally prints the secret — that is its purpose.
292
+ ctx.output.emit({ apiKey: c.apiKey, workspace: c.credentialWorkspace ?? null }, () =>
293
+ process.stdout.write(c.apiKey + "\n"),
294
+ );
295
+ }),
296
+ );
297
+
298
+ // status ------------------------------------------------------------------
299
+ auth
300
+ .command("status")
301
+ .description("Show where the API key is resolved from (key redacted)")
302
+ .action(
303
+ action(async (ctx) => {
304
+ const c = ctx.config;
305
+ const backend = keyring();
306
+ ctx.output.detail(
307
+ {
308
+ authenticated: !!c.apiKey,
309
+ source: c.apiKeySource,
310
+ workspace: c.credentialWorkspace ?? null,
311
+ key: redactKey(c.apiKey),
312
+ keyring: backend?.name ?? null,
313
+ },
314
+ [
315
+ ["Authenticated", !!c.apiKey],
316
+ ["Source", c.apiKeySource],
317
+ ["Workspace", c.credentialWorkspace ?? "(none)"],
318
+ ["Key", redactKey(c.apiKey)],
319
+ ["Keyring", backend ? backend.label : "(none on this platform)"],
320
+ ],
321
+ );
322
+ // A plaintext key on a machine that has a keyring is worth one nudge
323
+ // (a human one — the JSON already carries `source` and `keyring`).
324
+ if (c.apiKeySource === "user" && backend && !ctx.output.json) {
325
+ ctx.output.info(`Run \`linear auth migrate\` to move it into the ${backend.label}.`);
326
+ }
327
+ }),
328
+ );
329
+
330
+ // migrate -----------------------------------------------------------------
331
+ auth
332
+ .command("migrate")
333
+ .description("Move plaintext credentials from the config file into the system keyring")
334
+ .action(
335
+ action(async (ctx: Context) => {
336
+ const { migrated, path, keyringLabel } = migrateCredentials();
337
+ ctx.output.emit({ success: true, migrated, path }, () => {
338
+ if (migrated.length === 0) {
339
+ ctx.output.info(`Nothing to migrate: no plaintext keys in ${path}.`);
340
+ } else {
341
+ ctx.output.success(
342
+ `Moved ${migrated.length} credential${migrated.length === 1 ? "" : "s"} into the ${keyringLabel}: ${migrated.join(", ")}. ${path} now holds only the workspace list.`,
343
+ );
344
+ }
345
+ });
346
+ }),
347
+ );
348
+
349
+ // logout ------------------------------------------------------------------
350
+ auth
351
+ .command("logout")
352
+ .description("Remove a stored workspace credential (select with --workspace <slug>)")
353
+ .action(
354
+ // Targets the global `--workspace <slug>`. When omitted, the sole
355
+ // configured workspace is used; if several exist, this errors.
356
+ action(async (ctx: Context) => {
357
+ let slug: string | undefined = ctx.options.workspace;
358
+ if (!slug) {
359
+ const configured = listCredentials();
360
+ if (configured.length === 1) slug = configured[0]!.slug;
361
+ else if (configured.length === 0)
362
+ throw usageError("No workspace credentials are configured.");
363
+ else
364
+ throw usageError(
365
+ `Multiple workspaces are configured (${configured
366
+ .map((e) => e.slug)
367
+ .join(", ")}). Pass --workspace <slug> to choose which to remove.`,
368
+ );
369
+ }
370
+ if (!(await confirmDestructive(ctx, `Remove credential for workspace '${slug}'?`))) return;
371
+ const removed = removeCredential(slug);
372
+ ctx.output.emit({ success: true, workspace: slug, removed }, () =>
373
+ removed
374
+ ? ctx.output.success(`Removed workspace '${slug}'.`)
375
+ : ctx.output.info(`No credential for workspace '${slug}' to remove.`),
376
+ );
377
+ }),
378
+ );
379
+
380
+ const config = program
381
+ .command("config")
382
+ .description("Show the resolved configuration, or write a project config");
383
+
384
+ // show (default) ----------------------------------------------------------
385
+ config
386
+ .command("show", { isDefault: true })
387
+ .description(
388
+ "Show the resolved configuration and where each value came from (secrets redacted)",
389
+ )
390
+ .action(
391
+ action(async (ctx) => {
392
+ // Re-resolve to expose sources/paths beyond what Context keeps.
393
+ const c = resolveConfig({
394
+ flags: {
395
+ apiKey: ctx.options.apiKey,
396
+ // Still the *flag* value (so `teamSource` stays honest), just
397
+ // narrowed: `--team` is repeatable on the issue queries.
398
+ team: firstTeam(ctx.options.team),
399
+ workspace: ctx.options.workspace,
400
+ },
401
+ });
402
+ // Each setting names the tier it came from — and the file, when it was
403
+ // one — so "why is my team X" has a one-line answer.
404
+ const from = (key: keyof typeof c.origins): string => {
405
+ const o = c.origins[key];
406
+ if (o.source === "none") return "default";
407
+ return o.path ? `${o.source}: ${o.path}` : o.source;
408
+ };
409
+ const show = (
410
+ key: keyof typeof c.origins,
411
+ value: string | undefined,
412
+ ): string | undefined => (value === undefined ? undefined : `${value} (${from(key)})`);
413
+ ctx.output.detail(
414
+ {
415
+ apiKey: redactKey(c.apiKey),
416
+ apiKeySource: c.apiKeySource,
417
+ credentialWorkspace: c.credentialWorkspace ?? null,
418
+ team: c.team ?? null,
419
+ workspace: c.workspace ?? null,
420
+ sort: c.sort,
421
+ origins: c.origins,
422
+ userConfigPath: c.userConfigPath,
423
+ projectConfigPath: c.projectConfigPath ?? null,
424
+ globalConfigPath: c.globalConfigPath ?? null,
425
+ },
426
+ [
427
+ ["API key", `${redactKey(c.apiKey)} (${c.apiKeySource})`],
428
+ ["Credential workspace", c.credentialWorkspace ?? "(none)"],
429
+ ["Team", show("team", c.team)],
430
+ ["Workspace", show("workspace", c.workspace)],
431
+ ["Sort", show("sort", c.sort)],
432
+ ["User config", c.userConfigPath],
433
+ ["Project config", c.projectConfigPath],
434
+ ["Global config", c.globalConfigPath],
435
+ ],
436
+ );
437
+ }),
438
+ );
439
+
440
+ // init --------------------------------------------------------------------
441
+ config
442
+ .command("init")
443
+ .description("Write a project .linear.toml (at the git root, or here outside a repository)")
444
+ .option("--team <key>", "default team key (otherwise chosen from a list)")
445
+ .option("--sort <order>", `default issue-list sort (${ISSUE_SORTS.join(" | ")})`)
446
+ .option("--path <file>", "write this file instead of <git root>/.linear.toml")
447
+ .option("--force", "overwrite an existing file")
448
+ .action(
449
+ action(async (ctx: Context, opts) => {
450
+ const path = opts.path ? resolve(opts.path) : defaultProjectConfigPath();
451
+ // Team is the one setting that matters; without --team, offer the real
452
+ // list (a prompt, so it needs a terminal — scripts pass the flag).
453
+ let team: string | undefined = opts.team ?? firstTeam(ctx.options.team);
454
+ if (!team) {
455
+ if (!ctx.isTTY) {
456
+ throw usageError("Pass --team <key>; there is no terminal to choose one from.");
457
+ }
458
+ const teams = await listTeams(ctx.client, Infinity);
459
+ if (teams.length === 0) throw usageError("This workspace has no teams to choose from.");
460
+ team = await promptSelect(
461
+ ctx,
462
+ "Default team for this repository:",
463
+ teams
464
+ .sort((a, b) => a.name.localeCompare(b.name))
465
+ .map((t) => ({ name: `${t.name} (${t.key})`, value: t.key })),
466
+ );
467
+ }
468
+ const settings: Partial<Record<SettableKey, string>> = {
469
+ team: validateSetting("team", team),
470
+ };
471
+ if (opts.sort !== undefined) settings.sort = validateSetting("sort", opts.sort);
472
+ initProjectConfig(path, settings, { force: opts.force === true });
473
+ ctx.output.emit({ success: true, path, ...settings }, () =>
474
+ ctx.output.success(
475
+ `Wrote ${path} (team ${settings.team}${settings.sort ? `, sort ${settings.sort}` : ""}).`,
476
+ ),
477
+ );
478
+ }),
479
+ );
480
+
481
+ // set ---------------------------------------------------------------------
482
+ config
483
+ .command("set <key> <value>")
484
+ .description(`Set one project setting (${SETTABLE_KEYS.join(", ")}) in the project config`)
485
+ .option("--user", "write the user config (~/.config/linear/config.toml) instead")
486
+ .option("--path <file>", "write this file instead of the project config in effect")
487
+ .action(
488
+ action(async (ctx: Context, opts, key: string, value: string) => {
489
+ assertSettableKey(key);
490
+ const clean = validateSetting(key, value);
491
+ let path: string;
492
+ let mode: number | undefined;
493
+ if (opts.user && opts.path) throw usageError("Pass --user or --path, not both.");
494
+ if (opts.user) {
495
+ path = ctx.config.userConfigPath;
496
+ mode = 0o600; // it holds credentials
497
+ } else if (opts.path) {
498
+ path = resolve(opts.path);
499
+ } else {
500
+ // The file discovery would read — so the change is visible from
501
+ // here — or a fresh .linear.toml at the git root if there is none.
502
+ path = ctx.config.projectConfigPath ?? defaultProjectConfigPath();
503
+ }
504
+ const spelling = setConfigKey(path, key, clean, { mode });
505
+ ctx.output.emit({ success: true, path, key: spelling, value: clean }, () =>
506
+ ctx.output.success(`Set ${spelling} = ${JSON.stringify(clean)} in ${path}`),
507
+ );
508
+ }),
509
+ );
510
+ }
511
+
512
+ /**
513
+ * Check a value for a project setting the way the reader will judge it, so a
514
+ * bad value is refused here rather than on the next `issue list`.
515
+ */
516
+ function validateSetting(key: SettableKey, value: string): string {
517
+ const v = value.trim();
518
+ if (!v) throw usageError(`A value for '${key}' is required.`);
519
+ switch (key) {
520
+ case "sort":
521
+ if (!(ISSUE_SORTS as readonly string[]).includes(v)) {
522
+ throw usageError(`Invalid sort '${v}'. Valid values: ${ISSUE_SORTS.join(", ")}.`);
523
+ }
524
+ return v;
525
+ case "team":
526
+ // A team key: letters and digits, as Linear issues them (TES, ENG2).
527
+ if (!/^[A-Za-z][A-Za-z0-9]*$/.test(v)) {
528
+ throw usageError(`'${v}' does not look like a team key (letters and digits, e.g. TES).`);
529
+ }
530
+ return v.toUpperCase();
531
+ default:
532
+ return v;
533
+ }
534
+ }