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
@@ -130,10 +130,7 @@ export const UPDATE_ROW_SHAPE = shape<UpdateRow>({
130
130
  body: "string",
131
131
  health: "string|null",
132
132
  });
133
- const UPDATE_RECEIPT_SHAPE = shape<UpdateRow & { url: string }>({
134
- ...UPDATE_ROW_SHAPE,
135
- url: "string",
136
- });
133
+ const UPDATE_RECEIPT_SHAPE = shape<UpdateRow & { url: string }>({ ...UPDATE_ROW_SHAPE, url: "string" });
137
134
 
138
135
  /** `whoami` / `auth whoami`. */
139
136
  const WHOAMI_SHAPE: ObjectFields = {
@@ -145,15 +142,12 @@ const WHOAMI_SHAPE: ObjectFields = {
145
142
  organization: { id: "string", name: "string", urlKey: "string" },
146
143
  };
147
144
 
148
- const SETTING_ORIGIN_SHAPE = shape<SettingOrigin>({
149
- source: "string",
150
- "path?": "string",
151
- "key?": "string",
152
- });
145
+ const SETTING_ORIGIN_SHAPE = shape<SettingOrigin>({ source: "string", "path?": "string" });
153
146
  const SETTING_ORIGINS_SHAPE = shape<SettingOrigins>({
154
147
  team: SETTING_ORIGIN_SHAPE,
155
148
  workspace: SETTING_ORIGIN_SHAPE,
156
149
  sort: SETTING_ORIGIN_SHAPE,
150
+ vcs: SETTING_ORIGIN_SHAPE,
157
151
  });
158
152
 
159
153
  /** `config show`: every resolved setting, redacted, with its provenance. */
@@ -164,6 +158,7 @@ const CONFIG_SHOW_SHAPE: ObjectFields = {
164
158
  team: "string|null",
165
159
  workspace: "string|null",
166
160
  sort: "string",
161
+ vcs: "string",
167
162
  origins: SETTING_ORIGINS_SHAPE,
168
163
  userConfigPath: "string",
169
164
  projectConfigPath: "string|null",
@@ -176,20 +171,7 @@ export const COMMAND_NODE_SHAPE = shape<CommandNode>({
176
171
  description: "string",
177
172
  aliases: ["string"],
178
173
  arguments: [{ name: "string", required: "boolean", variadic: "boolean" }],
179
- options: [
180
- {
181
- flags: "string",
182
- description: "string",
183
- attribute: "string",
184
- valueRequired: "boolean",
185
- valueOptional: "boolean",
186
- variadic: "boolean",
187
- "choices?": ["string"],
188
- "defaultValue?": "unknown",
189
- global: "boolean",
190
- applicable: "boolean",
191
- },
192
- ],
174
+ options: [{ flags: "string", description: "string" }],
193
175
  "output?": {
194
176
  kind: "string",
195
177
  "fields?": "object",
@@ -228,8 +210,8 @@ const RELATION_ROW: ObjectFields = { type: "string", issue: "string", title: "st
228
210
 
229
211
  const ISSUE_VIEW = object(ISSUE_DETAIL_SHAPE, {
230
212
  variants: {
213
+ "--comments": object({ ...ISSUE_DETAIL_SHAPE, comments: [COMMENT_ROW_SHAPE] }),
231
214
  "--web": receipt({ ...ISSUE_URL_RECEIPT, opened: "boolean" }),
232
- "--app": receipt({ ...ISSUE_URL_RECEIPT, opened: "boolean" }),
233
215
  },
234
216
  });
235
217
  const ISSUE_LIST = list(ISSUE_ROW_SHAPE);
@@ -254,13 +236,6 @@ export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
254
236
  "attachment list": list(ATTACHMENT_ROW_SHAPE),
255
237
 
256
238
  auth: null,
257
- "auth adopt": receipt({
258
- success: "boolean",
259
- workspace: "string",
260
- user: { id: "string", name: "string", email: "string" },
261
- storage: "string",
262
- path: "string",
263
- }),
264
239
  "auth default": receipt({ success: "boolean", default_workspace: "string", path: "string" }),
265
240
  "auth list": list({ slug: "string", isDefault: "boolean", storage: "string" }),
266
241
  "auth login": receipt({
@@ -341,28 +316,7 @@ export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
341
316
  "issue agent-session": null,
342
317
  "issue agent-session list": list(AGENT_SESSION_ROW_SHAPE),
343
318
  "issue agent-session view": object(AGENT_SESSION_DETAIL_SHAPE),
344
- "issue archive": receipt(
345
- { ...ISSUE_REF, archived: "boolean" },
346
- {
347
- variants: {
348
- "--bulk": receipt({
349
- action: "string",
350
- results: [
351
- {
352
- input: "string",
353
- "id?": "string",
354
- "identifier?": "string",
355
- "archived?": "boolean",
356
- "deleted?": "boolean",
357
- "error?": { message: "string", code: "string" },
358
- },
359
- ],
360
- succeeded: "number",
361
- failed: "number",
362
- }),
363
- },
364
- },
365
- ),
319
+ "issue archive": receipt({ ...ISSUE_REF, archived: "boolean" }),
366
320
  "issue assign": receipt(ISSUE_REF),
367
321
  "issue attach": list(
368
322
  {
@@ -396,33 +350,10 @@ export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
396
350
  }),
397
351
  },
398
352
  }),
399
- "issue delete": receipt(
400
- { ...ISSUE_REF, deleted: "boolean" },
401
- {
402
- variants: {
403
- "--bulk": receipt({
404
- action: "string",
405
- results: [
406
- {
407
- input: "string",
408
- "id?": "string",
409
- "identifier?": "string",
410
- "archived?": "boolean",
411
- "deleted?": "boolean",
412
- "error?": { message: "string", code: "string" },
413
- },
414
- ],
415
- succeeded: "number",
416
- failed: "number",
417
- }),
418
- },
419
- },
420
- ),
353
+ "issue delete": receipt({ ...ISSUE_REF, deleted: "boolean" }),
421
354
  "issue describe": receipt(
422
355
  { identifier: "string", title: "string", url: "string", trailer: "string", message: "string" },
423
- {
424
- note: "`trailer` is the magic-word phrase (Fixes TES-1); `message` the full commit text as printed",
425
- },
356
+ { note: "`trailer` is the magic-word phrase (Fixes TES-1); `message` the full commit text as printed" },
426
357
  ),
427
358
  "issue id": receipt({ id: "string" }, { note: "`id` is the identifier (TES-123), not the UUID" }),
428
359
  "issue label": receipt(ISSUE_REF),
@@ -485,8 +416,6 @@ export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
485
416
  "notification snooze": receipt({ id: "string", snoozedUntilAt: "string" }),
486
417
  "notification unread": receipt({ id: "string", read: "boolean" }),
487
418
 
488
- open: receipt({ target: "string", url: "string", label: "string", opened: "boolean" }),
489
-
490
419
  organization: runs("organization view", object(ORGANIZATION_DETAIL_SHAPE)),
491
420
  "organization invites": list(INVITE_ROW_SHAPE),
492
421
  "organization members": list(ORGANIZATION_MEMBER_ROW_SHAPE),
@@ -530,7 +459,6 @@ export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
530
459
  movedIssues: "number",
531
460
  movedTo: { nullable: TEAM_ROW_SHAPE },
532
461
  }),
533
- "team id": receipt(TEAM_ROW_SHAPE),
534
462
  "team labels": list(TEAM_LABEL_ROW_SHAPE),
535
463
  "team list": list(TEAM_ROW_SHAPE),
536
464
  "team members": list(TEAM_MEMBER_ROW_SHAPE),
@@ -5,10 +5,7 @@
5
5
  * and `.fetchNext()` mutates the connection to append the next page's nodes.
6
6
  */
7
7
 
8
- import { fetchNextWithRetry, withRetry } from "../client.js";
9
-
10
- /** Linear accepts at most 250 nodes per connection page. */
11
- export const MAX_PAGE_SIZE = 250;
8
+ import { withRetry } from "../client.js";
12
9
 
13
10
  export interface Connection<T> {
14
11
  nodes: T[];
@@ -16,49 +13,22 @@ export interface Connection<T> {
16
13
  fetchNext: () => Promise<Connection<T>>;
17
14
  }
18
15
 
19
- /** Non-enumerable metadata carried by list arrays without changing their JSON contract. */
20
- const HAS_MORE = Symbol.for("linear-sdk-cli.pagination.has-more");
21
-
22
- type PagedArray<T> = T[] & { [HAS_MORE]?: boolean };
23
-
24
- function withMoreMetadata<T>(items: T[], hasMore: boolean): T[] {
25
- Object.defineProperty(items, HAS_MORE, { value: hasMore, enumerable: false });
26
- return items;
27
- }
28
-
29
- /** Attach an explicit pagination result to an array without changing its JSON value. */
30
- export function setPaginationMetadata<T>(items: T[], hasMore: boolean): T[] {
31
- return withMoreMetadata(items, hasMore);
32
- }
33
-
34
- /** Whether a service list stopped at its caller-supplied limit. */
35
- export function hasMoreResults(items: readonly unknown[]): boolean {
36
- return (items as PagedArray<unknown>)[HAS_MORE] === true;
37
- }
38
-
39
- /** Carry pagination metadata through a service's map/filter/sort result. */
40
- export function inheritPaginationMetadata<T>(items: T[], source: readonly unknown[]): T[] {
41
- return withMoreMetadata(items, hasMoreResults(source));
42
- }
43
-
44
- export async function collect<T>(first: Connection<T>, limit: number): Promise<T[]> {
16
+ export async function collect<T>(
17
+ first: Connection<T>,
18
+ limit: number,
19
+ ): Promise<T[]> {
45
20
  let conn = first;
46
21
  // Each fetchNext() appends to conn.nodes in place.
47
22
  while (conn.nodes.length < limit && conn.pageInfo.hasNextPage) {
48
- // Page two and later are independent requests and can be rate-limited just
49
- // like page one. Keep them behind the same bounded retry policy.
50
- conn = await fetchNextWithRetry(conn);
23
+ conn = await conn.fetchNext();
51
24
  }
52
- if (limit === Infinity) return withMoreMetadata(conn.nodes, false);
53
- const hasMore =
54
- conn.nodes.length > limit || (conn.nodes.length >= limit && conn.pageInfo.hasNextPage);
55
- return withMoreMetadata(conn.nodes.slice(0, limit), hasMore);
25
+ return limit === Infinity ? conn.nodes : conn.nodes.slice(0, limit);
56
26
  }
57
27
 
58
28
  /** How many to request per page given a desired total limit. */
59
29
  export function pageSize(limit: number): number {
60
- if (limit === Infinity) return MAX_PAGE_SIZE;
61
- return Math.min(Math.max(limit, 1), MAX_PAGE_SIZE);
30
+ if (limit === Infinity) return 100;
31
+ return Math.min(Math.max(limit, 1), 100);
62
32
  }
63
33
 
64
34
  /**
@@ -106,7 +76,7 @@ interface RawClient {
106
76
  * `withRetry`; pagination stops at `limit` or when `hasNextPage` is false.
107
77
  *
108
78
  * Mirrors the hand-rolled loops in the list services exactly: page size is
109
- * `limit === Infinity ? 250 : Math.min(limit, 250)`, and rows are capped at
79
+ * `limit === Infinity ? 100 : Math.min(limit, 100)`, and rows are capped at
110
80
  * `limit` mid-page.
111
81
  */
112
82
  export async function collectRawQuery<T>(
@@ -117,29 +87,26 @@ export async function collectRawQuery<T>(
117
87
  limit: number,
118
88
  transform: (node: any) => T,
119
89
  ): Promise<T[]> {
120
- const pageLimit = pageSize(limit);
90
+ const pageLimit = limit === Infinity ? 100 : Math.min(limit, 100);
121
91
  const rows: T[] = [];
122
92
  let after: string | undefined;
123
- let hasMore = false;
124
93
 
125
94
  for (;;) {
126
95
  const data: any = await withRetry(() =>
127
96
  client.client.rawRequest(query, { ...variables, first: pageLimit, after }),
128
97
  );
129
- const conn = connectionPath.split(".").reduce((acc: any, key) => acc?.[key], data.data) as
130
- RawConnection | null | undefined;
98
+ const conn = connectionPath
99
+ .split(".")
100
+ .reduce((acc: any, key) => acc?.[key], data.data) as RawConnection | null | undefined;
131
101
  // A nested path (`issue.comments`) can land on null when the parent entity
132
102
  // is gone; that is an empty list, not a crash.
133
103
  if (!conn) break;
134
- for (const [index, n] of conn.nodes.entries()) {
104
+ for (const n of conn.nodes) {
135
105
  rows.push(transform(n));
136
- if (rows.length >= limit) {
137
- hasMore = index < conn.nodes.length - 1 || conn.pageInfo.hasNextPage;
138
- break;
139
- }
106
+ if (rows.length >= limit) break;
140
107
  }
141
108
  if (rows.length >= limit || !conn.pageInfo.hasNextPage) break;
142
109
  after = conn.pageInfo.endCursor;
143
110
  }
144
- return withMoreMetadata(rows, hasMore);
111
+ return rows;
145
112
  }
@@ -15,27 +15,6 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
15
15
  export const isUuid = (v: string): boolean => UUID_RE.test(v);
16
16
 
17
17
  const IDENTIFIER_RE = /^([a-zA-Z][a-zA-Z0-9]*)-(\d+)$/;
18
- const ISSUE_NUMBER_RE = /^\d+$/;
19
-
20
- /**
21
- * Expand a bare issue number with the configured team (`42` + `TES` →
22
- * `TES-42`). This is deliberately pure: commands can normalize every place an
23
- * issue reference appears before handing it to the existing resolvers.
24
- */
25
- export function normalizeIssueReference(input: string, defaultTeamKey?: string): string {
26
- if (!ISSUE_NUMBER_RE.test(input)) return input;
27
- if (!defaultTeamKey) {
28
- throw usageError(
29
- `Bare issue number '${input}' needs a default team. Pass --team <KEY> or set one in config.`,
30
- );
31
- }
32
- if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(defaultTeamKey)) {
33
- throw usageError(
34
- `Cannot expand issue number '${input}' with default team '${defaultTeamKey}'; configure a team key such as TES.`,
35
- );
36
- }
37
- return `${defaultTeamKey.toUpperCase()}-${Number.parseInt(input, 10)}`;
38
- }
39
18
 
40
19
  /** Known workflow-state types, used to decide name-vs-type filtering. */
41
20
  export const STATE_TYPES = ["triage", "backlog", "unstarted", "started", "completed", "canceled"];
@@ -122,15 +101,13 @@ export async function resolveTeam(
122
101
  "linear team list",
123
102
  )}`,
124
103
  );
125
- if (matches.length > 1)
126
- throw ambiguous(`Multiple teams match '${value}': ${matches.map((t) => t.key).join(", ")}`);
104
+ if (matches.length > 1) throw ambiguous(`Multiple teams match '${value}': ${matches.map((t) => t.key).join(", ")}`);
127
105
  const team = matches[0]!;
128
106
  return { id: team.id, key: team.key, name: team.name };
129
107
  }
130
108
 
131
109
  /** The spellings that mean "the authenticated user": ours (`me`, `@me`) and the reference CLI's (`self`). */
132
- export const isSelf = (input: string): boolean =>
133
- input === "me" || input === "@me" || input === "self";
110
+ export const isSelf = (input: string): boolean => input === "me" || input === "@me" || input === "self";
134
111
 
135
112
  /**
136
113
  * Resolve an assignee reference (`me`, email, name, or id) to a user id.
@@ -161,9 +138,7 @@ export async function resolveUserId(client: LinearClient, input: string): Promis
161
138
  if (nodes.length === 0)
162
139
  throw notFound(`No user matching '${input}'. Run 'linear user list' to see workspace members.`);
163
140
  if (nodes.length > 1)
164
- throw ambiguous(
165
- `Multiple users match '${input}': ${nodes.map((u: any) => u.email).join(", ")}`,
166
- );
141
+ throw ambiguous(`Multiple users match '${input}': ${nodes.map((u: any) => u.email).join(", ")}`);
167
142
  return nodes[0]!.id;
168
143
  }
169
144
 
@@ -228,8 +203,7 @@ export async function resolveLabelIds(
228
203
  // Narrow to this team's labels + workspace-level labels when a team is known.
229
204
  // If a team is known and nothing is in scope, that's not-found (do NOT fall
230
205
  // back to an out-of-scope label from another team).
231
- // Label groups are containers, not labels that can be applied to an issue.
232
- let candidates: any[] = nodes.filter((label: any) => !label.isGroup);
206
+ let candidates: any[] = nodes;
233
207
  if (teamId) {
234
208
  const scoped = await Promise.all(
235
209
  nodes.map(async (l: any) => ({ label: l, team: await l.team })),
@@ -360,9 +334,7 @@ export async function resolveProjectId(client: LinearClient, input: string): Pro
360
334
  if (nodes.length === 0)
361
335
  throw notFound(`No project matching '${input}'. Run 'linear project list' to see the options.`);
362
336
  if (nodes.length > 1)
363
- throw ambiguous(
364
- `Multiple projects match '${input}': ${nodes.map((p: any) => p.name).join(", ")}`,
365
- );
337
+ throw ambiguous(`Multiple projects match '${input}': ${nodes.map((p: any) => p.name).join(", ")}`);
366
338
  return nodes[0]!.id;
367
339
  }
368
340
 
@@ -513,11 +485,7 @@ export async function resolveReleaseId(client: LinearClient, input: string): Pro
513
485
  first: RESOLVE_PAGE,
514
486
  }),
515
487
  );
516
- const nodes = await scanAll<any>(
517
- conn as any,
518
- "releases",
519
- "linear api '{ releases { nodes { id name version } } }'",
520
- );
488
+ const nodes = await scanAll<any>(conn as any, "releases", "linear api '{ releases { nodes { id name version } } }'");
521
489
  if (nodes.length === 0) throw notFound(`No release named or versioned '${input}'.`);
522
490
  if (nodes.length > 1)
523
491
  throw ambiguous(
@@ -579,8 +547,7 @@ export async function resolveTemplateId(
579
547
  export async function resolveIssue(client: LinearClient, input: string): Promise<Issue> {
580
548
  if (isUuid(input)) return withRetry(() => client.issue(input));
581
549
  const match = input.match(IDENTIFIER_RE);
582
- if (!match)
583
- throw usageError(`'${input}' is not a valid issue id (expected e.g. TES-123 or a UUID).`);
550
+ if (!match) throw usageError(`'${input}' is not a valid issue id (expected e.g. TES-123 or a UUID).`);
584
551
  const key = match[1]!.toUpperCase();
585
552
  const number = Number.parseInt(match[2]!, 10);
586
553
  // includeArchived so archive/unarchive/delete can operate on archived issues.
package/src/lib/shape.ts CHANGED
@@ -66,13 +66,11 @@ type IsAny<T> = 0 extends 1 & T ? true : false;
66
66
  type ObjectShapeOf<T> = string extends keyof T
67
67
  ? "object"
68
68
  : {
69
- [
70
- K in keyof T as IsAny<T[K]> extends true
71
- ? K & string
72
- : undefined extends T[K]
73
- ? `${K & string}?`
74
- : K & string
75
- ]-?: ShapeOf<Exclude<T[K], undefined>>;
69
+ [K in keyof T as IsAny<T[K]> extends true
70
+ ? K & string
71
+ : undefined extends T[K]
72
+ ? `${K & string}?`
73
+ : K & string]-?: ShapeOf<Exclude<T[K], undefined>>;
76
74
  };
77
75
 
78
76
  export type ShapeOf<T> =
@@ -121,7 +119,10 @@ export type ObjectFields = Record<string, FieldShape>;
121
119
 
122
120
  function isNullableWrapper(s: FieldShape): s is { readonly nullable: FieldShape } {
123
121
  return (
124
- typeof s === "object" && !Array.isArray(s) && Object.keys(s).length === 1 && "nullable" in s
122
+ typeof s === "object" &&
123
+ !Array.isArray(s) &&
124
+ Object.keys(s).length === 1 &&
125
+ "nullable" in s
125
126
  );
126
127
  }
127
128
 
@@ -159,9 +160,7 @@ export function matchesShape(
159
160
  }
160
161
  if (Array.isArray(shape)) {
161
162
  if (!Array.isArray(value)) return [`${path}: expected an array, got ${describe(value)}`];
162
- return value.flatMap((item, i) =>
163
- matchesShape(item, shape[0], `${path}[${i}]`, strictNullable),
164
- );
163
+ return value.flatMap((item, i) => matchesShape(item, shape[0], `${path}[${i}]`, strictNullable));
165
164
  }
166
165
  if (isNullableWrapper(shape)) {
167
166
  if (value === undefined) return [`${path}: expected an object or null, got undefined`];
@@ -185,8 +184,7 @@ export function matchesShape(
185
184
  problems.push(...matchesShape(record[key], sub, `${path}.${key}`, strictNullable));
186
185
  }
187
186
  for (const key of Object.keys(record)) {
188
- if (!declared.has(key) && record[key] !== undefined)
189
- problems.push(`${path}.${key}: not in the declared shape`);
187
+ if (!declared.has(key) && record[key] !== undefined) problems.push(`${path}.${key}: not in the declared shape`);
190
188
  }
191
189
  return problems;
192
190
  }
@@ -205,9 +203,7 @@ export function renderShape(shape: FieldShape): string {
205
203
  if (typeof shape === "string") return shape.replace("|null", " | null");
206
204
  if (Array.isArray(shape)) {
207
205
  const inner = renderShape(shape[0]);
208
- return typeof shape[0] === "string" && !shape[0].includes("|")
209
- ? `${inner}[]`
210
- : `Array<${inner}>`;
206
+ return typeof shape[0] === "string" && !shape[0].includes("|") ? `${inner}[]` : `Array<${inner}>`;
211
207
  }
212
208
  if (isNullableWrapper(shape)) return `${renderShape(shape.nullable)} | null`;
213
209
  const parts = Object.entries(shape).map(([k, v]) => `${k}: ${renderShape(v)}`);
package/src/lib/upload.ts CHANGED
@@ -294,9 +294,7 @@ function redact(text: string, uploadUrl: string): string {
294
294
  }
295
295
 
296
296
  /** Markdown for a comment body: images embed inline, anything else links. */
297
- export function formatEmbed(
298
- r: Pick<UploadResult, "filename" | "assetUrl" | "contentType">,
299
- ): string {
297
+ export function formatEmbed(r: Pick<UploadResult, "filename" | "assetUrl" | "contentType">): string {
300
298
  return `${isImage(r.contentType) ? "!" : ""}[${r.filename}](${r.assetUrl})`;
301
299
  }
302
300
 
@@ -6,7 +6,7 @@
6
6
  * - lists → bare array, single resource → bare object, mutations → affected
7
7
  * object (or {success,id} when there is no body)
8
8
  * - status/progress/pagination notes → stderr
9
- * - errors → {"error":{"message","code", …optional additive fields}} on stderr + non-zero exit
9
+ * - errors → {"error":{"message","code"}} on stderr + non-zero exit
10
10
  */
11
11
 
12
12
  import pc from "picocolors";
@@ -20,9 +20,6 @@ import {
20
20
  type Column,
21
21
  } from "./table.js";
22
22
  import { sanitizeForTerminal } from "./sanitize.js";
23
- import { renderMarkdown } from "./markdown.js";
24
- import { pageOutput, shouldUsePager } from "./pager.js";
25
- import { hasMoreResults } from "../lib/pagination.js";
26
23
 
27
24
  export interface OutputOptions {
28
25
  json: boolean;
@@ -31,38 +28,10 @@ export interface OutputOptions {
31
28
  debug: boolean;
32
29
  /** `--fields`: table columns / detail lines (human), top-level keys (json). */
33
30
  fields?: string[];
34
- /** Injectable terminal facts; default to stdout's live state. */
35
- isTTY?: boolean;
36
- terminalRows?: number;
37
- terminalColumns?: number;
38
- /** Used only to resolve PAGER when long-form output is eligible. */
39
- env?: NodeJS.ProcessEnv;
40
- }
41
-
42
- export interface ListOutputOptions {
43
- /** JSON rows may be richer/differently shaped than the human table rows. */
44
- jsonRows?: unknown[];
45
- /** Context shown instead of the default `(no results)` in human mode. */
46
- empty?: string;
47
- }
48
-
49
- export interface MarkdownOutputOptions {
50
- /** Leave Markdown syntax intact (terminal controls are still removed). */
51
- raw?: boolean;
52
- /** Commands with `--no-pager` pass false. Defaults to enabled. */
53
- pager?: boolean;
54
31
  }
55
32
 
56
33
  export class Output {
57
- private readonly opts: OutputOptions;
58
- private readonly colors: ReturnType<typeof pc.createColors>;
59
-
60
- constructor(opts: OutputOptions) {
61
- this.opts = opts;
62
- // Do not rely on picocolors' module-load-time environment detection:
63
- // CLICOLOR_FORCE and per-stream TTY policy are resolved by Context.
64
- this.colors = pc.createColors(opts.color);
65
- }
34
+ constructor(private readonly opts: OutputOptions) {}
66
35
 
67
36
  get json(): boolean {
68
37
  return this.opts.json;
@@ -85,28 +54,13 @@ export class Output {
85
54
  * Emit a list as a table (human) or bare array (json). `--fields` narrows
86
55
  * both: the table's columns (or any row key), and the JSON objects' keys.
87
56
  */
88
- list<T>(
89
- rows: T[],
90
- columns: Column<T>[],
91
- jsonRowsOrOptions?: unknown[] | ListOutputOptions,
92
- legacyOptions?: Pick<ListOutputOptions, "empty">,
93
- ): void {
94
- const options: ListOutputOptions = Array.isArray(jsonRowsOrOptions)
95
- ? { jsonRows: jsonRowsOrOptions, ...legacyOptions }
96
- : (jsonRowsOrOptions ?? {});
97
- if (hasMoreResults(rows)) {
98
- this.info(
99
- `Showing ${rows.length} results; more exist. Use --all or increase --limit to see them.`,
100
- );
101
- }
57
+ list<T>(rows: T[], columns: Column<T>[], jsonRows?: unknown[]): void {
102
58
  if (this.opts.json) {
103
- this.writeJson(projectFields(options.jsonRows ?? rows, this.opts.fields));
59
+ this.writeJson(projectFields(jsonRows ?? rows, this.opts.fields));
104
60
  return;
105
61
  }
106
62
  const cols = selectColumns(columns, this.opts.fields, rows[0]);
107
- process.stdout.write(
108
- renderTable(rows, cols, { color: this.opts.color, empty: options.empty }) + "\n",
109
- );
63
+ process.stdout.write(renderTable(rows, cols, { color: this.opts.color }) + "\n");
110
64
  }
111
65
 
112
66
  /**
@@ -130,41 +84,6 @@ export class Output {
130
84
  if (!this.opts.json) process.stdout.write(sanitizeForTerminal(text) + "\n");
131
85
  }
132
86
 
133
- /**
134
- * Render a long Markdown body for a human, optionally through a pager.
135
- *
136
- * Non-TTY output keeps the original Markdown so pipes/files receive stable,
137
- * reusable text. JSON mode writes nothing — callers normally put this inside
138
- * `emit(value, () => output.markdown(...))`, but the guard prevents an
139
- * accidental second stdout value. Both human paths remain terminal-sanitized.
140
- */
141
- markdown(text: string, options: MarkdownOutputOptions = {}): void {
142
- if (this.opts.json) return;
143
-
144
- const isTTY = this.opts.isTTY ?? process.stdout.isTTY === true;
145
- const columns = this.opts.terminalColumns ?? process.stdout.columns ?? 80;
146
- const rows = this.opts.terminalRows ?? process.stdout.rows;
147
- const content =
148
- isTTY && options.raw !== true
149
- ? renderMarkdown(text, { color: this.opts.color, width: columns })
150
- : sanitizeForTerminal(text);
151
- const output = content.endsWith("\n") ? content : `${content}\n`;
152
-
153
- if (
154
- shouldUsePager(output, {
155
- enabled: options.pager,
156
- json: this.opts.json,
157
- isTTY,
158
- rows,
159
- columns,
160
- }) &&
161
- pageOutput(output, { env: this.opts.env })
162
- ) {
163
- return;
164
- }
165
- process.stdout.write(output);
166
- }
167
-
168
87
  /** Status/progress to stderr. Suppressed by --quiet. */
169
88
  info(text: string): void {
170
89
  if (!this.opts.quiet) process.stderr.write(sanitizeForTerminal(text) + "\n");
@@ -172,11 +91,13 @@ export class Output {
172
91
 
173
92
  success(text: string): void {
174
93
  if (!this.opts.quiet)
175
- process.stderr.write(this.colors.green("✓ ") + sanitizeForTerminal(text) + "\n");
94
+ process.stderr.write(
95
+ (this.opts.color ? pc.green("✓ ") : "✓ ") + sanitizeForTerminal(text) + "\n",
96
+ );
176
97
  }
177
98
 
178
99
  warn(text: string): void {
179
- process.stderr.write(this.colors.yellow("! ") + sanitizeForTerminal(text) + "\n");
100
+ process.stderr.write((this.opts.color ? pc.yellow("! ") : "! ") + sanitizeForTerminal(text) + "\n");
180
101
  }
181
102
 
182
103
  /**
@@ -196,7 +117,7 @@ export class Output {
196
117
  }
197
118
  if (!this.opts.quiet) {
198
119
  process.stderr.write(
199
- this.colors.yellow("! ") + `Cancelled: ${sanitizeForTerminal(action)}\n`,
120
+ (this.opts.color ? pc.yellow("! ") : "! ") + `Cancelled: ${sanitizeForTerminal(action)}\n`,
200
121
  );
201
122
  }
202
123
  }
@@ -216,19 +137,20 @@ export class Output {
216
137
  // `message` and `code` keep their positions; `detail` is additive, and
217
138
  // absent entirely without --debug.
218
139
  const error: Record<string, unknown> = { message: err.message, code: err.code };
219
- if (err.suggestion !== undefined) error.suggestion = err.suggestion;
220
140
  if (showDetail) error.detail = err.detail;
221
141
  process.stderr.write(JSON.stringify({ error }) + "\n");
222
142
  return;
223
143
  }
224
144
  // An error message can quote API data (a name that matched several, …).
225
- process.stderr.write(this.colors.red("error: ") + sanitizeForTerminal(err.message) + "\n");
226
- if (err.suggestion !== undefined) {
227
- process.stderr.write(this.colors.dim("hint: ") + sanitizeForTerminal(err.suggestion) + "\n");
228
- }
145
+ process.stderr.write(
146
+ (this.opts.color ? pc.red("error: ") : "error: ") + sanitizeForTerminal(err.message) + "\n",
147
+ );
229
148
  if (showDetail) {
230
- const detail = JSON.stringify(err.detail, null, 2) ?? String(err.detail);
231
- process.stderr.write(this.colors.dim("detail: ") + sanitizeForTerminal(detail) + "\n");
149
+ process.stderr.write(
150
+ (this.opts.color ? pc.dim("detail: ") : "detail: ") +
151
+ JSON.stringify(err.detail, null, 2) +
152
+ "\n",
153
+ );
232
154
  }
233
155
  }
234
156