linear-sdk-cli 0.1.3 → 0.2.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 (79) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +84 -58
  4. package/COVERAGE.md +5 -5
  5. package/MIGRATING.md +71 -69
  6. package/README.md +115 -90
  7. package/package.json +6 -7
  8. package/skills/README.md +15 -0
  9. package/skills/linear-sdk-cli/SKILL.md +27 -3
  10. package/skills/linear-sdk-cli/references/api.md +9 -9
  11. package/skills/linear-sdk-cli/references/auth.md +18 -0
  12. package/skills/linear-sdk-cli/references/commands.md +2 -2
  13. package/skills/linear-sdk-cli/references/comment.md +9 -6
  14. package/skills/linear-sdk-cli/references/config.md +9 -11
  15. package/skills/linear-sdk-cli/references/issue.md +72 -18
  16. package/skills/linear-sdk-cli/references/open.md +29 -0
  17. package/skills/linear-sdk-cli/references/team.md +16 -0
  18. package/skills/linear-sdk-cli/scripts/generate-docs.ts +6 -2
  19. package/skills/linear-sdk-cli-maintenance/SKILL.md +93 -0
  20. package/src/bin/linear.ts +16 -6
  21. package/src/cli.ts +3 -1
  22. package/src/commands/agent-session.ts +13 -5
  23. package/src/commands/api.ts +15 -9
  24. package/src/commands/attach.ts +15 -4
  25. package/src/commands/comment.ts +50 -11
  26. package/src/commands/completion.ts +2 -6
  27. package/src/commands/cycle.ts +6 -1
  28. package/src/commands/discover.ts +6 -2
  29. package/src/commands/document.ts +4 -1
  30. package/src/commands/initiative.ts +5 -1
  31. package/src/commands/issue.ts +392 -91
  32. package/src/commands/meta.ts +139 -31
  33. package/src/commands/milestone.ts +4 -1
  34. package/src/commands/notification.ts +15 -6
  35. package/src/commands/open.ts +84 -0
  36. package/src/commands/project.ts +17 -4
  37. package/src/commands/team.ts +13 -0
  38. package/src/commands/user.ts +2 -2
  39. package/src/commands/webhook.ts +12 -2
  40. package/src/config.ts +501 -184
  41. package/src/context.ts +13 -4
  42. package/src/git.ts +28 -3
  43. package/src/lib/body.ts +26 -2
  44. package/src/lib/errors.ts +33 -8
  45. package/src/lib/introspect.ts +76 -7
  46. package/src/lib/keyring.ts +51 -9
  47. package/src/lib/mentions.ts +49 -0
  48. package/src/lib/open.ts +32 -0
  49. package/src/lib/options.ts +108 -12
  50. package/src/lib/output-shapes.ts +81 -9
  51. package/src/lib/pagination.ts +50 -17
  52. package/src/lib/resolve.ts +40 -7
  53. package/src/lib/shape.ts +16 -12
  54. package/src/lib/upload.ts +3 -1
  55. package/src/output/color.ts +52 -0
  56. package/src/output/format.ts +96 -18
  57. package/src/output/markdown.ts +133 -0
  58. package/src/output/pager.ts +131 -0
  59. package/src/output/sanitize.ts +1 -1
  60. package/src/output/table.ts +7 -7
  61. package/src/services/agent-session.ts +11 -3
  62. package/src/services/attachment.ts +14 -5
  63. package/src/services/comment.ts +37 -9
  64. package/src/services/cycle.ts +11 -4
  65. package/src/services/document.ts +29 -10
  66. package/src/services/favorite.ts +15 -9
  67. package/src/services/initiative-update.ts +16 -15
  68. package/src/services/initiative.ts +15 -10
  69. package/src/services/issue.ts +263 -19
  70. package/src/services/label.ts +12 -2
  71. package/src/services/milestone.ts +8 -5
  72. package/src/services/notification.ts +16 -2
  73. package/src/services/organization.ts +23 -18
  74. package/src/services/project.ts +55 -34
  75. package/src/services/roadmap.ts +7 -6
  76. package/src/services/state.ts +9 -3
  77. package/src/services/team.ts +67 -24
  78. package/src/services/user.ts +2 -2
  79. package/src/services/webhook.ts +28 -17
@@ -26,6 +26,7 @@ import { usageError, notFound } from "../lib/errors.js";
26
26
  import { assertMutation, unwrapMutation } from "../lib/mutation.js";
27
27
  import { resolveIssue } from "../lib/resolve.js";
28
28
  import { appendEmbeds, uploadFile, validateUploads, type UploadResult } from "../lib/upload.js";
29
+ import { prependMentions } from "../lib/mentions.js";
29
30
 
30
31
  export interface CommentRow {
31
32
  id: string;
@@ -139,6 +140,8 @@ export interface AddCommentOptions {
139
140
  attachments?: string[];
140
141
  /** Upload the attachments to public, world-readable URLs (raster images only). */
141
142
  public?: boolean;
143
+ /** Explicit user references to emit as real Linear mentions before the body. */
144
+ mentions?: string[];
142
145
  }
143
146
 
144
147
  /** Add a comment to an issue, optionally with uploaded files embedded in it. */
@@ -152,10 +155,13 @@ export async function addComment(
152
155
  // Every file first — a missing one fails before anything is uploaded or resolved.
153
156
  validateUploads(paths, { public: opts.public });
154
157
  const issue = await resolveIssue(client, issueArg);
158
+ const mentionedBody = await prependMentions(client, body, opts.mentions);
155
159
  const uploads: UploadResult[] = [];
156
160
  for (const path of paths) uploads.push(await uploadFile(client, path, { public: opts.public }));
157
161
  const comment = await unwrapMutation(
158
- withRetry(() => client.createComment({ issueId: issue.id, body: appendEmbeds(body, uploads) })),
162
+ withRetry(() =>
163
+ client.createComment({ issueId: issue.id, body: appendEmbeds(mentionedBody, uploads) }),
164
+ ),
159
165
  "comment",
160
166
  "Comment creation",
161
167
  );
@@ -184,11 +190,23 @@ export interface CommentLookup {
184
190
  * still requires the owning entity, so we look the parent up (for its issueId)
185
191
  * and pass both. Returns the new comment plus the parent's issue for output.
186
192
  */
187
- export async function replyToComment(client: LinearClient, commentId: string, body: string) {
193
+ export async function replyToComment(
194
+ client: LinearClient,
195
+ commentId: string,
196
+ body: string,
197
+ mentions: string[] = [],
198
+ ) {
188
199
  const parent = await getComment(client, commentId);
189
200
  if (!parent.issueId) throw usageError("Can only reply to comments that belong to an issue.");
201
+ const mentionedBody = await prependMentions(client, body, mentions);
190
202
  const comment = await unwrapMutation(
191
- withRetry(() => client.createComment({ parentId: parent.id, issueId: parent.issueId!, body })),
203
+ withRetry(() =>
204
+ client.createComment({
205
+ parentId: parent.id,
206
+ issueId: parent.issueId!,
207
+ body: mentionedBody,
208
+ }),
209
+ ),
192
210
  "comment",
193
211
  "Reply creation",
194
212
  );
@@ -208,27 +226,37 @@ export async function updateComment(
208
226
  commentId: string,
209
227
  body: string,
210
228
  current?: string,
229
+ mentions: string[] = [],
211
230
  ) {
212
- if (body.trim() === "") {
213
- throw usageError("Refusing to blank the comment body. To remove a comment, use 'comment delete'.");
231
+ const mentionedBody = await prependMentions(client, body, mentions);
232
+ if (mentionedBody.trim() === "") {
233
+ throw usageError(
234
+ "Refusing to blank the comment body. To remove a comment, use 'comment delete'.",
235
+ );
214
236
  }
215
- if (current !== undefined && body === current) throw usageError("Comment body unchanged; nothing to update.");
237
+ if (current !== undefined && mentionedBody === current)
238
+ throw usageError("Comment body unchanged; nothing to update.");
216
239
  return unwrapMutation(
217
- withRetry(() => client.updateComment(commentId, { body })),
240
+ withRetry(() => client.updateComment(commentId, { body: mentionedBody })),
218
241
  "comment",
219
242
  "Comment update",
220
243
  );
221
244
  }
222
245
 
223
246
  export async function deleteComment(client: LinearClient, commentId: string) {
224
- await assertMutation(withRetry(() => client.deleteComment(commentId)), "Comment deletion");
247
+ await assertMutation(
248
+ withRetry(() => client.deleteComment(commentId)),
249
+ "Comment deletion",
250
+ );
225
251
  return { id: commentId };
226
252
  }
227
253
 
228
254
  /** Resolve (or unresolve) a comment thread. */
229
255
  export async function setResolved(client: LinearClient, commentId: string, resolved: boolean) {
230
256
  return unwrapMutation(
231
- withRetry(() => (resolved ? client.commentResolve(commentId) : client.commentUnresolve(commentId))),
257
+ withRetry(() =>
258
+ resolved ? client.commentResolve(commentId) : client.commentUnresolve(commentId),
259
+ ),
232
260
  "comment",
233
261
  `Comment ${resolved ? "resolve" : "unresolve"}`,
234
262
  );
@@ -8,7 +8,7 @@
8
8
  import type { LinearClient } from "@linear/sdk";
9
9
  import { withRetry } from "../client.js";
10
10
  import { shape } from "../lib/shape.js";
11
- import { collect, pageSize } from "../lib/pagination.js";
11
+ import { collect, pageSize, setPaginationMetadata } from "../lib/pagination.js";
12
12
  import { usageError, notFound } from "../lib/errors.js";
13
13
  import { unwrapMutation } from "../lib/mutation.js";
14
14
  import { resolveTeam, resolveCycleId, isUuid } from "../lib/resolve.js";
@@ -44,11 +44,16 @@ export async function listCycles(
44
44
  const team = await resolveTeam(client, teamInput, defaultTeamKey);
45
45
  const teamModel = await withRetry(() => client.team(team.id));
46
46
  const conn = await withRetry(() => teamModel.cycles({ first: pageSize(limit) }));
47
- const nodes = await collect(conn as any, limit);
47
+ // Cycle connection order is not its user-facing number order. Sort first so
48
+ // `--limit` means the newest cycles, not an arbitrary API prefix.
49
+ const nodes = await collect(conn as any, Infinity);
48
50
  const rows = nodes.map(toRow);
49
51
  // Surface most-recent (highest number) cycle first.
50
52
  rows.sort((a, b) => b.number - a.number);
51
- return rows;
53
+ return setPaginationMetadata(
54
+ limit === Infinity ? rows : rows.slice(0, limit),
55
+ rows.length > limit,
56
+ );
52
57
  }
53
58
 
54
59
  /** The team's currently active cycle, if any. */
@@ -174,7 +179,9 @@ async function resolveCycleArg(
174
179
  if (isUuid(idArg)) return idArg;
175
180
  const teamKey = teamInput ?? defaultTeamKey;
176
181
  if (!teamKey)
177
- throw usageError("Resolving a cycle by number or 'current' requires --team (or pass a cycle id).");
182
+ throw usageError(
183
+ "Resolving a cycle by number or 'current' requires --team (or pass a cycle id).",
184
+ );
178
185
  const team = await resolveTeam(client, teamKey, undefined);
179
186
  return resolveCycleId(client, team.id, idArg);
180
187
  }
@@ -19,7 +19,7 @@
19
19
  import type { LinearClient } from "@linear/sdk";
20
20
  import { withRetry } from "../client.js";
21
21
  import { collectRawQuery } from "../lib/pagination.js";
22
- import { usageError, notFound } from "../lib/errors.js";
22
+ import { usageError, notFound, normalizeError } from "../lib/errors.js";
23
23
  import { assertMutation, unwrapMutation } from "../lib/mutation.js";
24
24
  import {
25
25
  resolveProjectId,
@@ -100,7 +100,9 @@ export async function resolveTarget(
100
100
  case "cycle": {
101
101
  const teamKey = sel.team ?? defaultTeamKey;
102
102
  if (!teamKey)
103
- throw usageError("--cycle needs a team to look the cycle up in: pass --team <KEY> or set a default team.");
103
+ throw usageError(
104
+ "--cycle needs a team to look the cycle up in: pass --team <KEY> or set a default team.",
105
+ );
104
106
  const team = await resolveTeam(client, teamKey, undefined);
105
107
  return { kind: "cycle", id: await resolveCycleId(client, team.id, sel.value) };
106
108
  }
@@ -169,7 +171,9 @@ function targets(n: any): DocumentTargets {
169
171
  initiative: n.initiative ?? null,
170
172
  team: n.team ?? null,
171
173
  cycle: n.cycle ? { id: n.cycle.id, number: n.cycle.number, name: n.cycle.name ?? null } : null,
172
- release: n.release ? { id: n.release.id, name: n.release.name, version: n.release.version ?? null } : null,
174
+ release: n.release
175
+ ? { id: n.release.id, name: n.release.name, version: n.release.version ?? null }
176
+ : null,
173
177
  };
174
178
  }
175
179
 
@@ -184,7 +188,8 @@ export function describeTarget(t: DocumentTargets): string | null {
184
188
  if (t.initiative) return `Initiative: ${t.initiative.name}`;
185
189
  if (t.team) return `Team: ${t.team.key} ${t.team.name}`;
186
190
  if (t.cycle) return `Cycle: #${t.cycle.number}${t.cycle.name ? ` ${t.cycle.name}` : ""}`;
187
- if (t.release) return `Release: ${t.release.name}${t.release.version ? ` (${t.release.version})` : ""}`;
191
+ if (t.release)
192
+ return `Release: ${t.release.name}${t.release.version ? ` (${t.release.version})` : ""}`;
188
193
  return null;
189
194
  }
190
195
 
@@ -233,10 +238,15 @@ query CliDocumentDetail($id: String!) {
233
238
  }
234
239
  }`;
235
240
 
236
- export async function getDocumentDetail(client: LinearClient, idArg: string): Promise<DocumentDetail> {
241
+ export async function getDocumentDetail(
242
+ client: LinearClient,
243
+ idArg: string,
244
+ ): Promise<DocumentDetail> {
237
245
  // An unknown id is an API error ("Could not find referenced Document"), which
238
246
  // the error boundary already reads as not-found (exit 3); a null is guarded too.
239
- const data: any = await withRetry(() => (client as any).client.rawRequest(DETAIL_QUERY, { id: idArg }));
247
+ const data: any = await withRetry(() =>
248
+ (client as any).client.rawRequest(DETAIL_QUERY, { id: idArg }),
249
+ );
240
250
  const n = data?.data?.document;
241
251
  if (!n) throw notFound(`No document matching '${idArg}'.`);
242
252
  return {
@@ -267,7 +277,11 @@ export interface CreateOptions extends DocumentTargetOptions {
267
277
  * NOT a fallback here: the command decides that, so the service never turns
268
278
  * "no target" into "the team from config" behind the caller's back.
269
279
  */
270
- export async function createDocument(client: LinearClient, opts: CreateOptions, defaultTeamKey?: string) {
280
+ export async function createDocument(
281
+ client: LinearClient,
282
+ opts: CreateOptions,
283
+ defaultTeamKey?: string,
284
+ ) {
271
285
  const sel = selectTarget(opts);
272
286
  if (!sel) throw usageError(`A document needs a target: pass one of ${TARGET_FLAGS}.`);
273
287
  const input: Record<string, any> = { title: opts.title };
@@ -318,7 +332,10 @@ export async function updateDocument(
318
332
 
319
333
  export async function deleteDocument(client: LinearClient, idArg: string) {
320
334
  const document = await resolveDocument(client, idArg);
321
- await assertMutation(withRetry(() => client.deleteDocument(document.id)), "Document deletion");
335
+ await assertMutation(
336
+ withRetry(() => client.deleteDocument(document.id)),
337
+ "Document deletion",
338
+ );
322
339
  return document;
323
340
  }
324
341
 
@@ -332,7 +349,9 @@ async function resolveDocument(client: LinearClient, idArg: string) {
332
349
  // The SDK's document() also resolves a slugId; fall back to it directly.
333
350
  try {
334
351
  return await withRetry(() => client.document(idArg));
335
- } catch {
336
- throw notFound(`No document matching '${idArg}'.`);
352
+ } catch (err) {
353
+ const normalized = normalizeError(err);
354
+ if (normalized.code === "not_found") throw notFound(`No document matching '${idArg}'.`);
355
+ throw normalized;
337
356
  }
338
357
  }
@@ -13,7 +13,7 @@
13
13
  import type { LinearClient } from "@linear/sdk";
14
14
  import { withRetry } from "../client.js";
15
15
  import { shape } from "../lib/shape.js";
16
- import { collect, pageSize } from "../lib/pagination.js";
16
+ import { collect, inheritPaginationMetadata, pageSize } from "../lib/pagination.js";
17
17
  import { usageError } from "../lib/errors.js";
18
18
  import { assertMutation, unwrapMutation } from "../lib/mutation.js";
19
19
  import { resolveIssue, resolveProjectId, isUuid } from "../lib/resolve.js";
@@ -69,13 +69,16 @@ export function entityLabel(entity: any): string {
69
69
  export async function listFavorites(client: LinearClient, limit: number): Promise<FavoriteRow[]> {
70
70
  const conn = await withRetry(() => client.favorites({ first: pageSize(limit) }));
71
71
  const nodes = await collect(conn as any, limit);
72
- return Promise.all(
73
- nodes.map(async (f: any) => ({
74
- id: f.id,
75
- type: f.type,
76
- name: await favoriteName(f),
77
- url: f.url ?? null,
78
- })),
72
+ return inheritPaginationMetadata(
73
+ await Promise.all(
74
+ nodes.map(async (f: any) => ({
75
+ id: f.id,
76
+ type: f.type,
77
+ name: await favoriteName(f),
78
+ url: f.url ?? null,
79
+ })),
80
+ ),
81
+ nodes,
79
82
  );
80
83
  }
81
84
 
@@ -136,6 +139,9 @@ export async function addFavorite(client: LinearClient, opts: AddOptions) {
136
139
 
137
140
  /** Delete a favorite by id. */
138
141
  export async function removeFavorite(client: LinearClient, id: string) {
139
- await assertMutation(withRetry(() => client.deleteFavorite(id)), "Favorite removal");
142
+ await assertMutation(
143
+ withRetry(() => client.deleteFavorite(id)),
144
+ "Favorite removal",
145
+ );
140
146
  return { id };
141
147
  }
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { LinearClient } from "@linear/sdk";
9
9
  import { withRetry } from "../client.js";
10
- import { collect } from "../lib/pagination.js";
10
+ import { collect, inheritPaginationMetadata, pageSize } from "../lib/pagination.js";
11
11
  import { normalizeUpdatePayload, type Health, type UpdateRow } from "../lib/status-update.js";
12
12
  import { resolveInitiative } from "./initiative.js";
13
13
 
@@ -20,21 +20,22 @@ export async function listInitiativeUpdates(
20
20
  limit: number,
21
21
  ): Promise<UpdateRow[]> {
22
22
  const initiative = await resolveInitiative(client, idArg);
23
- const conn = await withRetry(() =>
24
- initiative.initiativeUpdates({ first: Math.min(limit === Infinity ? 100 : limit, 100) }),
25
- );
23
+ const conn = await withRetry(() => initiative.initiativeUpdates({ first: pageSize(limit) }));
26
24
  const nodes = await collect(conn as any, limit);
27
- return Promise.all(
28
- nodes.map(async (u: any) => {
29
- const user = await u.user;
30
- return {
31
- id: u.id,
32
- createdAt: u.createdAt?.toISOString?.() ?? String(u.createdAt),
33
- user: user?.displayName ?? "unknown",
34
- body: u.body ?? "",
35
- health: u.health ?? null,
36
- };
37
- }),
25
+ return inheritPaginationMetadata(
26
+ await Promise.all(
27
+ nodes.map(async (u: any) => {
28
+ const user = await u.user;
29
+ return {
30
+ id: u.id,
31
+ createdAt: u.createdAt?.toISOString?.() ?? String(u.createdAt),
32
+ user: user?.displayName ?? "unknown",
33
+ body: u.body ?? "",
34
+ health: u.health ?? null,
35
+ };
36
+ }),
37
+ ),
38
+ nodes,
38
39
  );
39
40
  }
40
41
 
@@ -164,7 +164,9 @@ export const INITIATIVE_DETAIL_SHAPE = shape<InitiativeDetail>({
164
164
  archivedAt: "string|null",
165
165
  owner: "string|null",
166
166
  creator: "string|null",
167
- projects: [{ id: "string", name: "string", status: { nullable: { name: "string", type: "string" } } }],
167
+ projects: [
168
+ { id: "string", name: "string", status: { nullable: { name: "string", type: "string" } } },
169
+ ],
168
170
  });
169
171
 
170
172
  /** How many linked projects `view` reads; more than this is unusual and shows a truncation note. */
@@ -187,7 +189,11 @@ export async function getInitiativeDetail(
187
189
  const projectRows = await Promise.all(
188
190
  (projects?.nodes ?? []).map(async (p: any) => {
189
191
  const status = await p.status;
190
- return { id: p.id, name: p.name, status: status ? { name: status.name, type: status.type } : null };
192
+ return {
193
+ id: p.id,
194
+ name: p.name,
195
+ status: status ? { name: status.name, type: status.type } : null,
196
+ };
191
197
  }),
192
198
  );
193
199
  return {
@@ -257,11 +263,7 @@ export interface UpdateOptions {
257
263
  color?: string;
258
264
  }
259
265
 
260
- export async function updateInitiative(
261
- client: LinearClient,
262
- idArg: string,
263
- opts: UpdateOptions,
264
- ) {
266
+ export async function updateInitiative(client: LinearClient, idArg: string, opts: UpdateOptions) {
265
267
  const initiative = await resolveInitiative(client, idArg);
266
268
  const input: Record<string, any> = {};
267
269
  if (opts.name !== undefined) input.name = opts.name;
@@ -420,7 +422,11 @@ export async function resolveInitiative(
420
422
  const matches: any[] = [];
421
423
  for (;;) {
422
424
  const conn = await withRetry(() =>
423
- client.initiatives({ first: 100, after, includeArchived: opts.includeArchived === true } as any),
425
+ client.initiatives({
426
+ first: 100,
427
+ after,
428
+ includeArchived: opts.includeArchived === true,
429
+ } as any),
424
430
  );
425
431
  for (const n of conn.nodes) {
426
432
  if (n.name.toLowerCase() === lower) matches.push(n);
@@ -438,7 +444,6 @@ export async function resolveInitiative(
438
444
  export function resolveStatus(input: string): string {
439
445
  const lower = input.toLowerCase();
440
446
  const match = STATUSES.find((s) => s.toLowerCase() === lower);
441
- if (!match)
442
- throw usageError(`Invalid status '${input}'. Valid: ${STATUSES.join(", ")}.`);
447
+ if (!match) throw usageError(`Invalid status '${input}'. Valid: ${STATUSES.join(", ")}.`);
443
448
  return match;
444
449
  }