linear-sdk-cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +46 -46
  2. package/COVERAGE.md +5 -5
  3. package/MIGRATING.md +69 -71
  4. package/README.md +88 -96
  5. package/package.json +3 -4
  6. package/skills/linear-sdk-cli/SKILL.md +3 -13
  7. package/skills/linear-sdk-cli/references/api.md +9 -9
  8. package/skills/linear-sdk-cli/references/auth.md +0 -18
  9. package/skills/linear-sdk-cli/references/commands.md +2 -2
  10. package/skills/linear-sdk-cli/references/config.md +11 -9
  11. package/skills/linear-sdk-cli/references/issue.md +12 -63
  12. package/skills/linear-sdk-cli/references/team.md +0 -16
  13. package/skills/linear-sdk-cli/scripts/generate-docs.ts +2 -6
  14. package/src/bin/linear.ts +6 -16
  15. package/src/cli.ts +1 -3
  16. package/src/commands/agent-session.ts +5 -13
  17. package/src/commands/api.ts +9 -15
  18. package/src/commands/attach.ts +4 -15
  19. package/src/commands/comment.ts +2 -6
  20. package/src/commands/completion.ts +6 -2
  21. package/src/commands/cycle.ts +1 -6
  22. package/src/commands/discover.ts +2 -6
  23. package/src/commands/document.ts +1 -4
  24. package/src/commands/initiative.ts +1 -5
  25. package/src/commands/issue.ts +88 -376
  26. package/src/commands/meta.ts +31 -139
  27. package/src/commands/milestone.ts +1 -4
  28. package/src/commands/notification.ts +6 -15
  29. package/src/commands/project.ts +4 -17
  30. package/src/commands/team.ts +0 -13
  31. package/src/commands/webhook.ts +2 -12
  32. package/src/config.ts +183 -500
  33. package/src/context.ts +4 -13
  34. package/src/git.ts +3 -28
  35. package/src/lib/body.ts +2 -26
  36. package/src/lib/errors.ts +7 -32
  37. package/src/lib/introspect.ts +7 -76
  38. package/src/lib/keyring.ts +9 -51
  39. package/src/lib/options.ts +12 -108
  40. package/src/lib/output-shapes.ts +9 -81
  41. package/src/lib/pagination.ts +17 -50
  42. package/src/lib/resolve.ts +7 -40
  43. package/src/lib/shape.ts +12 -16
  44. package/src/lib/upload.ts +1 -3
  45. package/src/output/format.ts +18 -96
  46. package/src/output/table.ts +7 -7
  47. package/src/services/agent-session.ts +3 -11
  48. package/src/services/attachment.ts +5 -14
  49. package/src/services/comment.ts +4 -12
  50. package/src/services/cycle.ts +4 -11
  51. package/src/services/document.ts +10 -29
  52. package/src/services/favorite.ts +9 -15
  53. package/src/services/initiative-update.ts +15 -16
  54. package/src/services/initiative.ts +10 -15
  55. package/src/services/issue.ts +18 -262
  56. package/src/services/label.ts +2 -12
  57. package/src/services/milestone.ts +5 -8
  58. package/src/services/notification.ts +2 -16
  59. package/src/services/organization.ts +18 -23
  60. package/src/services/project.ts +34 -55
  61. package/src/services/roadmap.ts +6 -7
  62. package/src/services/state.ts +3 -9
  63. package/src/services/team.ts +24 -67
  64. package/src/services/user.ts +2 -2
  65. package/src/services/webhook.ts +17 -28
  66. package/skills/linear-sdk-cli/references/open.md +0 -29
  67. package/src/commands/open.ts +0 -84
  68. package/src/lib/open.ts +0 -32
  69. package/src/output/color.ts +0 -52
  70. package/src/output/markdown.ts +0 -133
  71. package/src/output/pager.ts +0 -131
@@ -10,8 +10,6 @@ import { withRetry } from "../client.js";
10
10
  import { resolve } from "node:path";
11
11
  import {
12
12
  writeCredential,
13
- probeKeyringCredential,
14
- adoptKeyringCredential,
15
13
  removeCredential,
16
14
  setDefaultWorkspace,
17
15
  listCredentials,
@@ -27,8 +25,8 @@ import {
27
25
  } from "../config.js";
28
26
  import { keyring } from "../lib/keyring.js";
29
27
  import { readStdinSync } from "../lib/body.js";
30
- import { authError, normalizeError, usageError } from "../lib/errors.js";
31
- import { confirmDestructive, promptSecret, promptSelect } from "../lib/prompt.js";
28
+ import { authError, usageError } from "../lib/errors.js";
29
+ import { promptSecret, promptSelect } from "../lib/prompt.js";
32
30
  import { listTeams } from "../services/team.js";
33
31
  import { ISSUE_SORTS } from "../services/issue.js";
34
32
  import { firstTeam, type Context } from "../context.js";
@@ -60,79 +58,6 @@ const whoamiAction = action(async (ctx: Context) => {
60
58
  );
61
59
  });
62
60
 
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
61
  export function registerMeta(program: Command): void {
137
62
  program.command("whoami").description("Show the authenticated user").action(whoamiAction);
138
63
 
@@ -150,7 +75,10 @@ export function registerMeta(program: Command): void {
150
75
  .command("login")
151
76
  .description("Validate and store a Linear API key for a workspace")
152
77
  .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")
78
+ .option(
79
+ "--plaintext",
80
+ "Store the key in the config file (0600) instead of the system keyring",
81
+ )
154
82
  .action(
155
83
  // The global `--workspace <slug>` selects the slug to store under
156
84
  // (default: derived from the key's organization urlKey).
@@ -159,14 +87,14 @@ export function registerMeta(program: Command): void {
159
87
  // it is a credential, and it must not reach the screen or scrollback.
160
88
  // Nothing below echoes it either: the receipt reports the user and
161
89
  // where the key went, never the value.
162
- let key: string | undefined = selectLoginKey(opts.key, ctx.options.apiKey);
90
+ let key: string | undefined = opts.key;
163
91
  if (key === "-") {
164
92
  key = readStdinSync();
165
93
  } else if (key) {
166
94
  // argv is world-readable (`ps`) and lands in shell history; say so
167
95
  // once, quietly, and carry on — the script author chose it.
168
96
  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).",
97
+ "--key on the command line is visible to other processes and shell history; prefer the prompt or `--key -` (stdin).",
170
98
  );
171
99
  } else {
172
100
  key = await promptSecret(ctx, "Linear API key:", { required: true });
@@ -174,18 +102,25 @@ export function registerMeta(program: Command): void {
174
102
  key = key.trim();
175
103
  if (!key) throw usageError("No API key provided.");
176
104
  // 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;
105
+ const client = new LinearClient({ apiKey: key });
106
+ let me;
107
+ let org;
108
+ try {
109
+ me = await client.viewer;
110
+ org = await client.organization;
111
+ } catch {
112
+ throw authError("That API key was rejected by Linear.");
113
+ }
114
+ const slug: string = ctx.options.workspace ?? org.urlKey;
180
115
  const { path, storage, keyringLabel } = writeCredential(slug, key, {
181
116
  plaintext: opts.plaintext === true,
182
117
  });
183
118
  const where =
184
119
  storage === "keychain"
185
- ? `Credential saved to the ${keyringLabel} (service linear-cli, account '${slug}'); ${path} records the workspace.`
120
+ ? `Key saved to the ${keyringLabel} (service linear-cli, account '${slug}'); ${path} records the workspace.`
186
121
  : opts.plaintext
187
- ? `Credential saved to ${path} (plaintext, 0600).`
188
- : `Credential saved to ${path} (plaintext, 0600 — no system keyring on this platform).`;
122
+ ? `Key saved to ${path} (plaintext, 0600).`
123
+ : `Key saved to ${path} (plaintext, 0600 — no system keyring on this platform).`;
189
124
  ctx.output.emit(
190
125
  {
191
126
  success: true,
@@ -202,45 +137,6 @@ export function registerMeta(program: Command): void {
202
137
  }),
203
138
  );
204
139
 
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
140
  // list --------------------------------------------------------------------
245
141
  auth
246
142
  .command("list")
@@ -367,7 +263,6 @@ export function registerMeta(program: Command): void {
367
263
  .join(", ")}). Pass --workspace <slug> to choose which to remove.`,
368
264
  );
369
265
  }
370
- if (!(await confirmDestructive(ctx, `Remove credential for workspace '${slug}'?`))) return;
371
266
  const removed = removeCredential(slug);
372
267
  ctx.output.emit({ success: true, workspace: slug, removed }, () =>
373
268
  removed
@@ -384,9 +279,7 @@ export function registerMeta(program: Command): void {
384
279
  // show (default) ----------------------------------------------------------
385
280
  config
386
281
  .command("show", { isDefault: true })
387
- .description(
388
- "Show the resolved configuration and where each value came from (secrets redacted)",
389
- )
282
+ .description("Show the resolved configuration and where each value came from (secrets redacted)")
390
283
  .action(
391
284
  action(async (ctx) => {
392
285
  // Re-resolve to expose sources/paths beyond what Context keeps.
@@ -406,10 +299,8 @@ export function registerMeta(program: Command): void {
406
299
  if (o.source === "none") return "default";
407
300
  return o.path ? `${o.source}: ${o.path}` : o.source;
408
301
  };
409
- const show = (
410
- key: keyof typeof c.origins,
411
- value: string | undefined,
412
- ): string | undefined => (value === undefined ? undefined : `${value} (${from(key)})`);
302
+ const show = (key: keyof typeof c.origins, value: string | undefined): string | undefined =>
303
+ value === undefined ? undefined : `${value} (${from(key)})`;
413
304
  ctx.output.detail(
414
305
  {
415
306
  apiKey: redactKey(c.apiKey),
@@ -418,6 +309,7 @@ export function registerMeta(program: Command): void {
418
309
  team: c.team ?? null,
419
310
  workspace: c.workspace ?? null,
420
311
  sort: c.sort,
312
+ vcs: c.vcs,
421
313
  origins: c.origins,
422
314
  userConfigPath: c.userConfigPath,
423
315
  projectConfigPath: c.projectConfigPath ?? null,
@@ -429,6 +321,7 @@ export function registerMeta(program: Command): void {
429
321
  ["Team", show("team", c.team)],
430
322
  ["Workspace", show("workspace", c.workspace)],
431
323
  ["Sort", show("sort", c.sort)],
324
+ ["VCS", show("vcs", c.vcs)],
432
325
  ["User config", c.userConfigPath],
433
326
  ["Project config", c.projectConfigPath],
434
327
  ["Global config", c.globalConfigPath],
@@ -465,15 +358,11 @@ export function registerMeta(program: Command): void {
465
358
  .map((t) => ({ name: `${t.name} (${t.key})`, value: t.key })),
466
359
  );
467
360
  }
468
- const settings: Partial<Record<SettableKey, string>> = {
469
- team: validateSetting("team", team),
470
- };
361
+ const settings: Partial<Record<SettableKey, string>> = { team: validateSetting("team", team) };
471
362
  if (opts.sort !== undefined) settings.sort = validateSetting("sort", opts.sort);
472
363
  initProjectConfig(path, settings, { force: opts.force === true });
473
364
  ctx.output.emit({ success: true, path, ...settings }, () =>
474
- ctx.output.success(
475
- `Wrote ${path} (team ${settings.team}${settings.sort ? `, sort ${settings.sort}` : ""}).`,
476
- ),
365
+ ctx.output.success(`Wrote ${path} (team ${settings.team}${settings.sort ? `, sort ${settings.sort}` : ""}).`),
477
366
  );
478
367
  }),
479
368
  );
@@ -522,6 +411,9 @@ function validateSetting(key: SettableKey, value: string): string {
522
411
  throw usageError(`Invalid sort '${v}'. Valid values: ${ISSUE_SORTS.join(", ")}.`);
523
412
  }
524
413
  return v;
414
+ case "vcs":
415
+ if (!["git", "jj"].includes(v)) throw usageError(`Invalid vcs '${v}'. Valid values: git, jj.`);
416
+ return v;
525
417
  case "team":
526
418
  // A team key: letters and digits, as Linear issues them (TES, ENG2).
527
419
  if (!/^[A-Za-z][A-Za-z0-9]*$/.test(v)) {
@@ -34,10 +34,7 @@ export function formatMilestoneProgress(progress: number | null): string {
34
34
  return `${Math.round(progress)}%`;
35
35
  }
36
36
 
37
- const PROJECT_SCOPE = [
38
- "-p, --project <name>",
39
- "the milestone's project, when <id> is a name (names are unique per project only)",
40
- ] as const;
37
+ const PROJECT_SCOPE = ["-p, --project <name>", "the milestone's project, when <id> is a name (names are unique per project only)"] as const;
41
38
 
42
39
  export function registerMilestone(program: Command): void {
43
40
  const milestone = program
@@ -8,7 +8,6 @@ import { confirmDestructive } from "../lib/prompt.js";
8
8
  import type { Context } from "../context.js";
9
9
  import * as svc from "../services/notification.js";
10
10
  import type { Column } from "../output/table.js";
11
- import { ExitCode } from "../lib/errors.js";
12
11
 
13
12
  const ROW_COLUMNS: Column<svc.NotificationRow>[] = [
14
13
  { key: "type", header: "Type", value: (r) => r.type, max: 24 },
@@ -68,20 +67,10 @@ export function registerNotification(program: Command): void {
68
67
  action(async (ctx: Context) => {
69
68
  const res = await svc.markAllRead(ctx.client);
70
69
  ctx.output.emit(res, () => {
71
- if (res.success) {
72
- ctx.output.success(`Marked ${res.count} of ${res.attempted} notification(s) read`);
73
- } else {
74
- ctx.output.warn(
75
- `Marked ${res.count} of ${res.attempted} notification(s) read; ${res.failed.length} failed`,
76
- );
77
- for (const f of res.failed) ctx.output.warn(`${f.id}: ${f.error}`);
78
- }
70
+ ctx.output.success(`Marked ${res.count} of ${res.attempted} notification(s) read`);
71
+ // A partial batch says so out loud instead of hiding behind the count.
72
+ for (const f of res.failed) ctx.output.warn(`${f.id}: ${f.error}`);
79
73
  });
80
- // A batch receipt belongs on stdout even when only part of the batch
81
- // succeeded, but exit zero would let `read-all && ...` treat that as a
82
- // complete success. Preserve the receipt and make the process outcome
83
- // unambiguous.
84
- if (!res.success) process.exitCode = ExitCode.Runtime;
85
74
  }),
86
75
  );
87
76
 
@@ -93,7 +82,9 @@ export function registerNotification(program: Command): void {
93
82
  action(async (ctx: Context, _opts, id: string) => {
94
83
  if (!(await confirmDestructive(ctx, `Archive notification ${id}?`))) return;
95
84
  const success = await svc.archiveNotification(ctx.client, id);
96
- ctx.output.emit({ id, archived: success }, () => ctx.output.success(`Archived ${id}`));
85
+ ctx.output.emit({ id, archived: success }, () =>
86
+ ctx.output.success(`Archived ${id}`),
87
+ );
97
88
  }),
98
89
  );
99
90
 
@@ -36,10 +36,7 @@ function formatProgress(p: number | null): string {
36
36
  * stderr, once; `--quiet` silences it, `--json` stdout never carries it.
37
37
  * Shared by `project list` and the issue queries.
38
38
  */
39
- export function noteWorkspaceWide(
40
- ctx: Context,
41
- opts: { team?: unknown; allTeams?: boolean },
42
- ): void {
39
+ export function noteWorkspaceWide(ctx: Context, opts: { team?: unknown; allTeams?: boolean }): void {
43
40
  const teamGiven = Array.isArray(opts.team) ? opts.team.length > 0 : opts.team !== undefined;
44
41
  if (!teamGiven && !opts.allTeams && !ctx.defaultTeam) {
45
42
  ctx.output.info(
@@ -100,14 +97,8 @@ export function registerProject(program: Command): void {
100
97
  ["Progress", detail.progress !== null ? formatProgress(detail.progress) : null],
101
98
  ["Priority", detail.priorityLabel],
102
99
  ["Lead", detail.lead?.displayName ?? null],
103
- [
104
- "Teams",
105
- detail.teams.length ? detail.teams.map((t) => `${t.key} ${t.name}`).join(", ") : null,
106
- ],
107
- [
108
- "Members",
109
- detail.members.length ? detail.members.map((m) => m.displayName).join(", ") : null,
110
- ],
100
+ ["Teams", detail.teams.length ? detail.teams.map((t) => `${t.key} ${t.name}`).join(", ") : null],
101
+ ["Members", detail.members.length ? detail.members.map((m) => m.displayName).join(", ") : null],
111
102
  ["Labels", detail.labels.length ? detail.labels.map((l) => l.name).join(", ") : null],
112
103
  ["Start", detail.startDate],
113
104
  ["Target", detail.targetDate],
@@ -326,11 +317,7 @@ export function registerProject(program: Command): void {
326
317
  { key: "name", header: "Name", value: (m) => m.name, max: 40 },
327
318
  { key: "target", header: "Target", value: (m) => m.targetDate ?? "—" },
328
319
  // A milestone's progress is already a percentage (TES-648), unlike a project's.
329
- {
330
- key: "progress",
331
- header: "Progress",
332
- value: (m) => formatMilestoneProgress(m.progress),
333
- },
320
+ { key: "progress", header: "Progress", value: (m) => formatMilestoneProgress(m.progress) },
334
321
  ],
335
322
  rows,
336
323
  );
@@ -59,19 +59,6 @@ export function registerTeam(program: Command): void {
59
59
  }),
60
60
  );
61
61
 
62
- // id ----------------------------------------------------------------------
63
- team
64
- .command("id [key]")
65
- .description("Print a team's UUID (defaults to the configured team)")
66
- .action(
67
- action(async (ctx: Context, _opts, keyArg?: string) => {
68
- const resolved = await svc.getTeamIdentity(ctx.client, keyArg, ctx.defaultTeam);
69
- // Human mode is deliberately a single UUID for command substitution;
70
- // JSON retains the resolved key and name as useful context.
71
- ctx.output.emit(resolved, () => process.stdout.write(resolved.id + "\n"));
72
- }),
73
- );
74
-
75
62
  // view --------------------------------------------------------------------
76
63
  team
77
64
  .command("view [key]")
@@ -91,12 +91,7 @@ export function registerWebhook(program: Command): void {
91
91
  secret: opts.secret,
92
92
  });
93
93
  ctx.output.emit(
94
- {
95
- id: created.id,
96
- url: created.url,
97
- enabled: created.enabled,
98
- resourceTypes: created.resourceTypes,
99
- },
94
+ { id: created.id, url: created.url, enabled: created.enabled, resourceTypes: created.resourceTypes },
100
95
  () => ctx.output.success(`Created webhook ${created.id}`),
101
96
  );
102
97
  }),
@@ -126,12 +121,7 @@ export function registerWebhook(program: Command): void {
126
121
  secret: opts.secret,
127
122
  });
128
123
  ctx.output.emit(
129
- {
130
- id: updated.id,
131
- url: updated.url,
132
- enabled: updated.enabled,
133
- resourceTypes: updated.resourceTypes,
134
- },
124
+ { id: updated.id, url: updated.url, enabled: updated.enabled, resourceTypes: updated.resourceTypes },
135
125
  () => ctx.output.success(`Updated webhook ${updated.id}`),
136
126
  );
137
127
  }),