@enrichlayer/el-linear 1.7.0 → 1.9.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 (62) hide show
  1. package/README.md +69 -1
  2. package/claude-skills/linear-operations/SKILL.md +55 -0
  3. package/dist/auth/oauth-callback.js +14 -7
  4. package/dist/auth/oauth-fs.d.ts +22 -0
  5. package/dist/auth/oauth-fs.js +77 -6
  6. package/dist/auth/oauth-headless.d.ts +13 -8
  7. package/dist/auth/oauth-headless.js +18 -11
  8. package/dist/auth/oauth-storage.d.ts +11 -3
  9. package/dist/auth/oauth-storage.js +15 -8
  10. package/dist/auth/token-resolver.d.ts +9 -0
  11. package/dist/auth/token-resolver.js +57 -37
  12. package/dist/commands/init/index.js +2 -0
  13. package/dist/commands/init/oauth.d.ts +6 -0
  14. package/dist/commands/init/oauth.js +8 -2
  15. package/dist/commands/init/token.d.ts +0 -8
  16. package/dist/commands/init/token.js +13 -1
  17. package/dist/commands/issues/branch.d.ts +17 -0
  18. package/dist/commands/issues/branch.js +40 -0
  19. package/dist/commands/issues/description.d.ts +89 -0
  20. package/dist/commands/issues/description.js +187 -0
  21. package/dist/commands/issues.js +34 -215
  22. package/dist/commands/labels.js +7 -5
  23. package/dist/commands/profile/migrate-legacy.js +10 -33
  24. package/dist/commands/profile.js +1 -10
  25. package/dist/commands/projects.d.ts +5 -1
  26. package/dist/commands/projects.js +135 -64
  27. package/dist/commands/read-shortcut.d.ts +6 -0
  28. package/dist/commands/read-shortcut.js +6 -1
  29. package/dist/commands/refs.js +2 -1
  30. package/dist/commands/templates.js +127 -1
  31. package/dist/commands/users.js +2 -1
  32. package/dist/config/config.js +31 -9
  33. package/dist/config/issue-validation.js +1 -1
  34. package/dist/config/paths.d.ts +11 -0
  35. package/dist/config/paths.js +44 -6
  36. package/dist/config/term-enforcer.js +1 -1
  37. package/dist/main.js +37 -2
  38. package/dist/queries/templates.d.ts +3 -0
  39. package/dist/queries/templates.js +43 -0
  40. package/dist/utils/auth.js +7 -9
  41. package/dist/utils/auto-link-references.js +15 -1
  42. package/dist/utils/disk-cache.js +2 -1
  43. package/dist/utils/formatters/summary.d.ts +62 -0
  44. package/dist/utils/formatters/summary.js +755 -0
  45. package/dist/utils/graphql-issues-service.d.ts +106 -3
  46. package/dist/utils/graphql-issues-service.js +51 -37
  47. package/dist/utils/issue-reference-extractor.d.ts +9 -1
  48. package/dist/utils/issue-reference-extractor.js +16 -9
  49. package/dist/utils/issue-reference-wrapper.js +1 -54
  50. package/dist/utils/linear-service.d.ts +7 -3
  51. package/dist/utils/linear-service.js +27 -5
  52. package/dist/utils/markdown-prosemirror.js +17 -1
  53. package/dist/utils/mention-resolver.js +17 -5
  54. package/dist/utils/output.d.ts +5 -1
  55. package/dist/utils/output.js +28 -1
  56. package/dist/utils/protected-ranges.d.ts +33 -0
  57. package/dist/utils/protected-ranges.js +73 -0
  58. package/dist/utils/table-formatter.d.ts +36 -0
  59. package/dist/utils/table-formatter.js +46 -24
  60. package/dist/utils/validators.d.ts +9 -1
  61. package/dist/utils/validators.js +10 -0
  62. package/package.json +1 -1
@@ -0,0 +1,755 @@
1
+ /**
2
+ * Human-readable summary formatters for the `--format summary` output mode.
3
+ *
4
+ * These are pure functions: each takes the same shape that the JSON output
5
+ * path receives (a single resource object or a `{ data, meta }` envelope)
6
+ * and returns a plain string suitable for terminal display.
7
+ *
8
+ * Why this exists: every consumer (humans and LLMs) was piping
9
+ * `el-linear ... | python -c 'json.load(...)'` or jq-based extraction to
10
+ * pull out title/state/assignee. This codifies that recurring shape as a
11
+ * first-class output mode, so callers don't reinvent it in shell.
12
+ *
13
+ * The summary format is intentionally stable — it's a contract, not a
14
+ * pretty-print. Field ordering and labels should not change across
15
+ * patch releases without a CHANGELOG entry.
16
+ */
17
+ const TITLE_TRUNC = 60;
18
+ const DESCRIPTION_LINE_LIMIT = 10;
19
+ const DESCRIPTION_CHAR_LIMIT = 4096;
20
+ const TRUNC_FOOTER = "... (truncated; --format json for full body)";
21
+ // ── tiny helpers ───────────────────────────────────────────────
22
+ /**
23
+ * Strip terminal control sequences from a string so the summary
24
+ * formatter cannot be hijacked by attacker-controlled content
25
+ * (issue/comment titles set by anyone with workspace write access).
26
+ *
27
+ * Removes:
28
+ *
29
+ * - C0 control bytes (0x00–0x1F) except `\t` (kept for legible
30
+ * embedded tabs) and `\n` (callers like `clipDescription` rely on
31
+ * it). `\x1B` (ESC) — the start byte for CSI / OSC / DCS / SS3
32
+ * sequences — is dropped along with the rest of C0.
33
+ * - DEL (0x7F).
34
+ * - C1 control bytes (0x80–0x9F).
35
+ *
36
+ * This is conservative — anything that would tell a terminal to move
37
+ * the cursor, switch screens, set a hyperlink target, change palette,
38
+ * etc. is silently dropped. Visible Unicode (including emoji and CJK)
39
+ * passes through untouched.
40
+ */
41
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: this regex is the sanitizer.
42
+ const TERMINAL_CONTROL_RE = /[\x00-\x08\x0B-\x1F\x7F-\x9F]/g;
43
+ function sanitizeForTerminal(value) {
44
+ return value.replace(TERMINAL_CONTROL_RE, "");
45
+ }
46
+ function truncate(s, n) {
47
+ // truncate(s, n<=0) → "" (no room even for an ellipsis)
48
+ // truncate(s, 1) → "…"
49
+ // truncate(s, n>=2) → first n-1 characters + ellipsis
50
+ // Width math elsewhere assumes the result is ≤ n characters; this
51
+ // keeps the contract honest at the small-n boundary.
52
+ if (s.length <= n)
53
+ return s;
54
+ if (n <= 0)
55
+ return "";
56
+ if (n === 1)
57
+ return "…";
58
+ return `${s.slice(0, n - 1)}…`;
59
+ }
60
+ function pad(s, n) {
61
+ if (s.length >= n)
62
+ return s;
63
+ return s + " ".repeat(n - s.length);
64
+ }
65
+ function indent(text, prefix = " ") {
66
+ return text
67
+ .split("\n")
68
+ .map((line) => prefix + line)
69
+ .join("\n");
70
+ }
71
+ function clipDescription(body) {
72
+ if (!body)
73
+ return null;
74
+ const sanitized = sanitizeForTerminal(body);
75
+ // Char cap first: a 5MB description with no newlines would otherwise
76
+ // pass the line-count check unchanged and dump verbatim to stdout.
77
+ let clipped = sanitized;
78
+ let charClipped = false;
79
+ if (clipped.length > DESCRIPTION_CHAR_LIMIT) {
80
+ clipped = clipped.slice(0, DESCRIPTION_CHAR_LIMIT);
81
+ charClipped = true;
82
+ }
83
+ const lines = clipped.split("\n");
84
+ if (lines.length <= DESCRIPTION_LINE_LIMIT && !charClipped) {
85
+ return indent(clipped);
86
+ }
87
+ const kept = lines.slice(0, DESCRIPTION_LINE_LIMIT).join("\n");
88
+ return `${indent(kept)}\n${indent(TRUNC_FOOTER)}`;
89
+ }
90
+ function s(v) {
91
+ if (v === null || v === undefined || v === "")
92
+ return "—";
93
+ return sanitizeForTerminal(String(v));
94
+ }
95
+ function asObj(v) {
96
+ if (v && typeof v === "object" && !Array.isArray(v)) {
97
+ return v;
98
+ }
99
+ return null;
100
+ }
101
+ function getName(v) {
102
+ const o = asObj(v);
103
+ if (!o)
104
+ return s(v);
105
+ const candidate = o.name ?? o.displayName ?? o.title ?? o.key ?? o.identifier ?? null;
106
+ return s(candidate);
107
+ }
108
+ function joinLabels(v) {
109
+ if (!Array.isArray(v) || v.length === 0)
110
+ return "";
111
+ return v
112
+ .map((l) => {
113
+ const o = asObj(l);
114
+ return o ? s(o.name) : s(l);
115
+ })
116
+ .filter((x) => x && x !== "—")
117
+ .join(", ");
118
+ }
119
+ /**
120
+ * Render a list of rows as a fixed-width text table with a header,
121
+ * separator, body, and "<N> <noun>s" footer.
122
+ *
123
+ * Subsumes the formatXList family in this file — each one used to
124
+ * inline its own column-width math, header/separator wiring, and
125
+ * footer pluralization. Centralizing here cuts ~340 lines of
126
+ * mechanical boilerplate to a single 30-line helper plus per-resource
127
+ * column declarations.
128
+ */
129
+ function renderTable(rows, columns, options) {
130
+ if (rows.length === 0)
131
+ return options.emptyText;
132
+ // Extract values once per row so we don't re-evaluate `extract` for
133
+ // the width pass and the body pass.
134
+ const values = rows.map((row) => columns.map((col) => {
135
+ const raw = col.extract(row);
136
+ return col.maxWidth ? truncate(raw, col.maxWidth) : raw;
137
+ }));
138
+ const widths = columns.map((col, i) => {
139
+ const fromRows = values.reduce((max, row) => Math.max(max, row[i].length), 0);
140
+ const computed = Math.max(col.minWidth, col.header.length, fromRows);
141
+ return col.maxWidth ? Math.min(col.maxWidth, computed) : computed;
142
+ });
143
+ const header = columns.map((col, i) => pad(col.header, widths[i])).join(" ");
144
+ const sep = "-".repeat(header.length);
145
+ const body = values
146
+ .map((row) => row.map((v, i) => pad(v, widths[i])).join(" "))
147
+ .join("\n");
148
+ const noun = `${rows.length} ${options.itemNoun}${rows.length === 1 ? "" : "s"}`;
149
+ return `${header}\n${sep}\n${body}\n\n${noun}`;
150
+ }
151
+ /**
152
+ * Render a list of label/value pairs with the labels right-padded so values
153
+ * align in a column. Empty / missing values are dropped before render so
154
+ * the summary doesn't show "Project: —" rows for resources where a field
155
+ * isn't applicable.
156
+ */
157
+ function renderHeader(fields) {
158
+ const visible = fields.filter((f) => f.value && f.value !== "—");
159
+ if (visible.length === 0)
160
+ return "";
161
+ const labelWidth = Math.max(...visible.map((f) => f.label.length)) + 1;
162
+ return visible
163
+ .map((f) => `${pad(`${f.label}:`, labelWidth + 1)} ${f.value}`)
164
+ .join("\n");
165
+ }
166
+ // ── issues ─────────────────────────────────────────────────────
167
+ export function formatIssueSummary(issue) {
168
+ const identifier = s(issue.identifier);
169
+ const title = s(issue.title);
170
+ const headerLine = `${identifier} ${title}`;
171
+ const fields = [
172
+ { label: "State", value: getName(issue.state) },
173
+ { label: "Assignee", value: getName(issue.assignee) },
174
+ { label: "Project", value: getName(issue.project) },
175
+ { label: "Cycle", value: getName(issue.cycle) },
176
+ { label: "Milestone", value: getName(issue.projectMilestone) },
177
+ { label: "Labels", value: joinLabels(issue.labels) },
178
+ { label: "URL", value: s(issue.url) },
179
+ ];
180
+ const header = renderHeader(fields);
181
+ const body = clipDescription(issue.description);
182
+ const parts = [headerLine, header];
183
+ if (body)
184
+ parts.push("", body);
185
+ return parts.filter((p) => p !== "").join("\n");
186
+ }
187
+ export function formatIssueList(issues) {
188
+ return renderTable(issues.map((raw) => asObj(raw) ?? {}), [
189
+ { header: "ID", minWidth: 2, extract: (i) => s(i.identifier) },
190
+ {
191
+ header: "TITLE",
192
+ minWidth: 5,
193
+ maxWidth: TITLE_TRUNC,
194
+ extract: (i) => s(i.title),
195
+ },
196
+ { header: "STATE", minWidth: 5, extract: (i) => getName(i.state) },
197
+ {
198
+ header: "ASSIGNEE",
199
+ minWidth: 8,
200
+ extract: (i) => getName(i.assignee),
201
+ },
202
+ ], { emptyText: "(no issues)", itemNoun: "issue" });
203
+ }
204
+ // ── projects ───────────────────────────────────────────────────
205
+ export function formatProjectSummary(project) {
206
+ const name = s(project.name);
207
+ const headerLine = name;
208
+ const teams = (() => {
209
+ const t = project.teams;
210
+ if (!Array.isArray(t) || t.length === 0)
211
+ return "";
212
+ return t
213
+ .map((team) => {
214
+ const o = asObj(team);
215
+ return o ? s(o.key ?? o.name) : s(team);
216
+ })
217
+ .join(", ");
218
+ })();
219
+ const progress = typeof project.progress === "number"
220
+ ? `${Math.round(project.progress * 100)}%`
221
+ : "";
222
+ const fields = [
223
+ { label: "State", value: s(project.state) },
224
+ { label: "Lead", value: getName(project.lead) },
225
+ { label: "Teams", value: teams },
226
+ { label: "Target", value: s(project.targetDate) },
227
+ { label: "Progress", value: progress },
228
+ { label: "URL", value: s(project.url) },
229
+ ];
230
+ const header = renderHeader(fields);
231
+ const body = clipDescription(project.description);
232
+ const parts = [headerLine, header];
233
+ if (body)
234
+ parts.push("", body);
235
+ return parts.filter((p) => p !== "").join("\n");
236
+ }
237
+ function pct(value) {
238
+ return typeof value === "number" ? `${Math.round(value * 100)}%` : "—";
239
+ }
240
+ export function formatProjectList(projects) {
241
+ return renderTable(projects.map((raw) => asObj(raw) ?? {}), [
242
+ {
243
+ header: "NAME",
244
+ minWidth: 4,
245
+ maxWidth: TITLE_TRUNC,
246
+ extract: (p) => s(p.name),
247
+ },
248
+ { header: "STATE", minWidth: 5, extract: (p) => s(p.state) },
249
+ { header: "PROGRESS", minWidth: 8, extract: (p) => pct(p.progress) },
250
+ { header: "LEAD", minWidth: 4, extract: (p) => getName(p.lead) },
251
+ ], { emptyText: "(no projects)", itemNoun: "project" });
252
+ }
253
+ // ── comments ───────────────────────────────────────────────────
254
+ export function formatCommentSummary(comment) {
255
+ const author = getName(comment.user);
256
+ const createdAt = s(comment.createdAt);
257
+ const body = clipDescription(comment.body);
258
+ const headerLine = `${author} ${createdAt}`;
259
+ const parts = [headerLine];
260
+ if (body)
261
+ parts.push("", body);
262
+ return parts.join("\n");
263
+ }
264
+ export function formatCommentList(comments) {
265
+ if (comments.length === 0)
266
+ return "(no comments)";
267
+ return comments
268
+ .map((c) => {
269
+ const obj = asObj(c) ?? {};
270
+ return formatCommentSummary(obj);
271
+ })
272
+ .join("\n\n---\n\n");
273
+ }
274
+ // ── cycles ─────────────────────────────────────────────────────
275
+ export function formatCycleSummary(cycle) {
276
+ const name = s(cycle.name ?? `Cycle ${s(cycle.number)}`);
277
+ const headerLine = name;
278
+ const progress = typeof cycle.progress === "number"
279
+ ? `${Math.round(cycle.progress * 100)}%`
280
+ : "";
281
+ const fields = [
282
+ { label: "Number", value: s(cycle.number) },
283
+ { label: "Team", value: getName(cycle.team) },
284
+ { label: "Active", value: cycle.isActive ? "yes" : "" },
285
+ { label: "Starts", value: s(cycle.startsAt) },
286
+ { label: "Ends", value: s(cycle.endsAt) },
287
+ { label: "Progress", value: progress },
288
+ ];
289
+ const header = renderHeader(fields);
290
+ // If this is a cycle "read" payload, it includes issues — append a list.
291
+ const issues = cycle.issues;
292
+ let issuesBlock = "";
293
+ if (Array.isArray(issues) && issues.length > 0) {
294
+ issuesBlock = `\n\nIssues:\n${formatIssueList(issues)}`;
295
+ }
296
+ return `${headerLine}\n${header}${issuesBlock}`;
297
+ }
298
+ export function formatCycleList(cycles) {
299
+ return renderTable(cycles.map((raw) => asObj(raw) ?? {}), [
300
+ { header: "#", minWidth: 2, extract: (c) => s(c.number) },
301
+ {
302
+ header: "NAME",
303
+ minWidth: 4,
304
+ maxWidth: 40,
305
+ extract: (c) => s(c.name ?? `Cycle ${s(c.number)}`),
306
+ },
307
+ { header: "TEAM", minWidth: 4, extract: (c) => getName(c.team) },
308
+ {
309
+ header: "ACTIVE",
310
+ minWidth: 6,
311
+ extract: (c) => (c.isActive ? "yes" : "no"),
312
+ },
313
+ { header: "PROGRESS", minWidth: 8, extract: (c) => pct(c.progress) },
314
+ ], { emptyText: "(no cycles)", itemNoun: "cycle" });
315
+ }
316
+ // ── milestones ─────────────────────────────────────────────────
317
+ export function formatMilestoneSummary(milestone) {
318
+ const name = s(milestone.name);
319
+ const fields = [
320
+ { label: "Target", value: s(milestone.targetDate) },
321
+ { label: "Project", value: getName(milestone.project) },
322
+ { label: "Sort", value: s(milestone.sortOrder) },
323
+ ];
324
+ const header = renderHeader(fields);
325
+ const body = clipDescription(milestone.description);
326
+ const parts = [name, header];
327
+ if (body)
328
+ parts.push("", body);
329
+ return parts.filter((p) => p !== "").join("\n");
330
+ }
331
+ export function formatMilestoneList(milestones) {
332
+ return renderTable(milestones.map((raw) => asObj(raw) ?? {}), [
333
+ {
334
+ header: "NAME",
335
+ minWidth: 4,
336
+ maxWidth: TITLE_TRUNC,
337
+ extract: (m) => s(m.name),
338
+ },
339
+ { header: "TARGET", minWidth: 6, extract: (m) => s(m.targetDate) },
340
+ { header: "PROJECT", minWidth: 7, extract: (m) => getName(m.project) },
341
+ ], { emptyText: "(no milestones)", itemNoun: "milestone" });
342
+ }
343
+ // ── teams ──────────────────────────────────────────────────────
344
+ export function formatTeamList(teams) {
345
+ return renderTable(teams.map((raw) => asObj(raw) ?? {}), [
346
+ { header: "KEY", minWidth: 3, extract: (t) => s(t.key) },
347
+ {
348
+ header: "NAME",
349
+ minWidth: 4,
350
+ maxWidth: TITLE_TRUNC,
351
+ extract: (t) => s(t.name),
352
+ },
353
+ {
354
+ header: "DESCRIPTION",
355
+ minWidth: 11,
356
+ maxWidth: 60,
357
+ extract: (t) => s(t.description),
358
+ },
359
+ ], { emptyText: "(no teams)", itemNoun: "team" });
360
+ }
361
+ // ── labels ─────────────────────────────────────────────────────
362
+ export function formatLabelList(labels) {
363
+ return renderTable(labels.map((raw) => asObj(raw) ?? {}), [
364
+ {
365
+ header: "NAME",
366
+ minWidth: 4,
367
+ maxWidth: TITLE_TRUNC,
368
+ extract: (l) => s(l.name),
369
+ },
370
+ { header: "SCOPE", minWidth: 5, extract: (l) => s(l.scope) },
371
+ {
372
+ header: "TEAM",
373
+ minWidth: 4,
374
+ extract: (l) => (l.team ? getName(l.team) : ""),
375
+ },
376
+ { header: "COLOR", minWidth: 5, extract: (l) => s(l.color) },
377
+ ], { emptyText: "(no labels)", itemNoun: "label" });
378
+ }
379
+ // ── users ──────────────────────────────────────────────────────
380
+ export function formatUserSummary(user) {
381
+ const name = s(user.name);
382
+ const fields = [
383
+ { label: "Display name", value: s(user.displayName) },
384
+ { label: "Email", value: s(user.email) },
385
+ { label: "Active", value: user.active === false ? "no" : "yes" },
386
+ ];
387
+ const header = renderHeader(fields);
388
+ return `${name}\n${header}`;
389
+ }
390
+ export function formatUserList(users) {
391
+ return renderTable(users.map((raw) => asObj(raw) ?? {}), [
392
+ { header: "NAME", minWidth: 4, maxWidth: 30, extract: (u) => s(u.name) },
393
+ {
394
+ header: "DISPLAY",
395
+ minWidth: 7,
396
+ maxWidth: 25,
397
+ extract: (u) => s(u.displayName),
398
+ },
399
+ {
400
+ header: "EMAIL",
401
+ minWidth: 5,
402
+ maxWidth: 40,
403
+ extract: (u) => s(u.email),
404
+ },
405
+ {
406
+ header: "ACTIVE",
407
+ minWidth: 6,
408
+ extract: (u) => (u.active === false ? "no" : "yes"),
409
+ },
410
+ ], { emptyText: "(no users)", itemNoun: "user" });
411
+ }
412
+ // ── documents ──────────────────────────────────────────────────
413
+ export function formatDocumentSummary(doc) {
414
+ const title = s(doc.title);
415
+ const fields = [
416
+ { label: "Project", value: getName(doc.project) },
417
+ { label: "Issue", value: getName(doc.issue) },
418
+ { label: "Creator", value: getName(doc.creator) },
419
+ { label: "Updated", value: s(doc.updatedAt) },
420
+ { label: "URL", value: s(doc.url) },
421
+ ];
422
+ const header = renderHeader(fields);
423
+ const body = clipDescription(doc.content);
424
+ const parts = [title, header];
425
+ if (body)
426
+ parts.push("", body);
427
+ return parts.filter((p) => p !== "").join("\n");
428
+ }
429
+ export function formatDocumentList(docs) {
430
+ return renderTable(docs.map((raw) => asObj(raw) ?? {}), [
431
+ {
432
+ header: "TITLE",
433
+ minWidth: 5,
434
+ maxWidth: TITLE_TRUNC,
435
+ extract: (d) => s(d.title),
436
+ },
437
+ { header: "PROJECT", minWidth: 7, extract: (d) => getName(d.project) },
438
+ {
439
+ header: "UPDATED",
440
+ minWidth: 7,
441
+ extract: (d) => s(d.updatedAt).slice(0, 10),
442
+ },
443
+ ], { emptyText: "(no documents)", itemNoun: "document" });
444
+ }
445
+ // ── templates ──────────────────────────────────────────────────
446
+ export function formatTemplateSummary(tpl) {
447
+ const name = s(tpl.name);
448
+ const fields = [
449
+ { label: "Type", value: s(tpl.type) },
450
+ { label: "Team", value: getName(tpl.team) },
451
+ { label: "Creator", value: getName(tpl.creator) },
452
+ { label: "Updated", value: s(tpl.updatedAt) },
453
+ { label: "ID", value: s(tpl.id) },
454
+ ];
455
+ const header = renderHeader(fields);
456
+ const body = clipDescription(tpl.description);
457
+ const parts = [name, header];
458
+ if (body)
459
+ parts.push("", body);
460
+ return parts.filter((p) => p !== "").join("\n");
461
+ }
462
+ export function formatTemplateList(tpls) {
463
+ return renderTable(tpls.map((raw) => asObj(raw) ?? {}), [
464
+ {
465
+ header: "NAME",
466
+ minWidth: 4,
467
+ maxWidth: TITLE_TRUNC,
468
+ extract: (t) => s(t.name),
469
+ },
470
+ { header: "TYPE", minWidth: 4, extract: (t) => s(t.type) },
471
+ { header: "TEAM", minWidth: 4, extract: (t) => getName(t.team) },
472
+ { header: "ID", minWidth: 2, extract: (t) => s(t.id) },
473
+ ], { emptyText: "(no templates)", itemNoun: "template" });
474
+ }
475
+ // ── attachments ────────────────────────────────────────────────
476
+ export function formatAttachmentList(attachments) {
477
+ return renderTable(attachments.map((raw) => asObj(raw) ?? {}), [
478
+ {
479
+ header: "TITLE",
480
+ minWidth: 5,
481
+ maxWidth: TITLE_TRUNC,
482
+ extract: (a) => s(a.title),
483
+ },
484
+ { header: "URL", minWidth: 3, maxWidth: 60, extract: (a) => s(a.url) },
485
+ {
486
+ header: "CREATED",
487
+ minWidth: 7,
488
+ extract: (a) => s(a.createdAt).slice(0, 10),
489
+ },
490
+ ], { emptyText: "(no attachments)", itemNoun: "attachment" });
491
+ }
492
+ // ── releases ───────────────────────────────────────────────────
493
+ export function formatReleaseSummary(release) {
494
+ const name = s(release.name);
495
+ const stage = asObj(release.stage);
496
+ const fields = [
497
+ { label: "Version", value: s(release.version) },
498
+ { label: "Stage", value: stage ? s(stage.name) : "" },
499
+ { label: "Pipeline", value: getName(release.pipeline) },
500
+ { label: "Start", value: s(release.startDate) },
501
+ { label: "Target", value: s(release.targetDate) },
502
+ { label: "URL", value: s(release.url) },
503
+ ];
504
+ const header = renderHeader(fields);
505
+ const body = clipDescription(release.description);
506
+ const parts = [name, header];
507
+ if (body)
508
+ parts.push("", body);
509
+ return parts.filter((p) => p !== "").join("\n");
510
+ }
511
+ export function formatReleaseList(releases) {
512
+ return renderTable(releases.map((raw) => asObj(raw) ?? {}), [
513
+ {
514
+ header: "NAME",
515
+ minWidth: 4,
516
+ maxWidth: TITLE_TRUNC,
517
+ extract: (r) => s(r.name),
518
+ },
519
+ { header: "VERSION", minWidth: 7, extract: (r) => s(r.version) },
520
+ {
521
+ header: "STAGE",
522
+ minWidth: 5,
523
+ extract: (r) => {
524
+ const stage = asObj(r.stage);
525
+ return stage ? s(stage.name) : "—";
526
+ },
527
+ },
528
+ { header: "TARGET", minWidth: 6, extract: (r) => s(r.targetDate) },
529
+ ], { emptyText: "(no releases)", itemNoun: "release" });
530
+ }
531
+ // ── search-results (cross-resource) ────────────────────────────
532
+ export function formatSearchResultList(results) {
533
+ return renderTable(results.map((raw) => asObj(raw) ?? {}), [
534
+ { header: "TYPE", minWidth: 4, extract: (r) => s(r.type) },
535
+ {
536
+ header: "ID",
537
+ minWidth: 2,
538
+ maxWidth: 18,
539
+ extract: (r) => s(r.identifier ?? r.id),
540
+ },
541
+ {
542
+ header: "TITLE",
543
+ minWidth: 5,
544
+ maxWidth: TITLE_TRUNC,
545
+ extract: (r) => s(r.title ?? r.name),
546
+ },
547
+ ], { emptyText: "(no results)", itemNoun: "result" });
548
+ }
549
+ // ── fallback ───────────────────────────────────────────────────
550
+ /**
551
+ * Generic fallback for resources we don't have a dedicated formatter for
552
+ * yet (e.g. profile/config/template/document). Renders any object as a
553
+ * label-padded key/value block, with a "..." footer hinting at JSON for
554
+ * the full payload. Lists fall back to a simple bulleted list.
555
+ */
556
+ export function formatGenericSummary(value) {
557
+ if (Array.isArray(value)) {
558
+ if (value.length === 0)
559
+ return "(empty)";
560
+ return value
561
+ .map((v) => `- ${formatGenericInline(v)}`)
562
+ .concat([`\n${value.length} item${value.length === 1 ? "" : "s"}`])
563
+ .join("\n");
564
+ }
565
+ const obj = asObj(value);
566
+ if (!obj)
567
+ return s(value);
568
+ const fields = Object.entries(obj)
569
+ .filter(([k]) => !k.startsWith("_"))
570
+ .map(([k, v]) => ({ label: k, value: formatGenericInline(v) }));
571
+ return renderHeader(fields);
572
+ }
573
+ function formatGenericInline(v) {
574
+ if (v === null || v === undefined)
575
+ return "—";
576
+ if (Array.isArray(v)) {
577
+ if (v.length === 0)
578
+ return "(empty)";
579
+ const inner = v
580
+ .slice(0, 5)
581
+ .map((item) => {
582
+ const o = asObj(item);
583
+ if (o)
584
+ return s(o.name ?? o.identifier ?? o.id);
585
+ return s(item);
586
+ })
587
+ .join(", ");
588
+ return v.length > 5 ? `${inner}, … (${v.length} total)` : inner;
589
+ }
590
+ const o = asObj(v);
591
+ if (o)
592
+ return s(o.name ?? o.displayName ?? o.identifier ?? o.id ?? "{…}");
593
+ if (typeof v === "string" && v.length > 80) {
594
+ return `${v.slice(0, 80)}…`;
595
+ }
596
+ return s(v);
597
+ }
598
+ /**
599
+ * Heuristic — used by the central `outputSuccess` path which doesn't know
600
+ * which command produced the payload. Looks at the shape of the data to
601
+ * pick a formatter.
602
+ *
603
+ * Single-resource detection: uses signature fields (e.g. `identifier` +
604
+ * `state` for issues, `name` + `progress` for projects). When a payload
605
+ * doesn't match any known shape we fall through to `formatGenericSummary`.
606
+ */
607
+ export function inferKindFromPayload(value) {
608
+ if (Array.isArray(value)) {
609
+ return inferListKind(value);
610
+ }
611
+ const obj = asObj(value);
612
+ if (!obj)
613
+ return "generic";
614
+ // list envelope: { data: [...], meta: {...} }
615
+ if (Array.isArray(obj.data)) {
616
+ return inferListKind(obj.data);
617
+ }
618
+ // single resource
619
+ if ("identifier" in obj && "title" in obj && "url" in obj)
620
+ return "issue";
621
+ if ("name" in obj &&
622
+ "progress" in obj &&
623
+ ("state" in obj || "lead" in obj || "teams" in obj))
624
+ return "project";
625
+ if ("body" in obj && "createdAt" in obj && "user" in obj)
626
+ return "comment";
627
+ if (("number" in obj || "isActive" in obj) &&
628
+ ("startsAt" in obj || "endsAt" in obj || "issues" in obj))
629
+ return "cycle";
630
+ if ("title" in obj && "content" in obj && "slugId" in obj)
631
+ return "document";
632
+ if ("name" in obj && "templateData" in obj)
633
+ return "template";
634
+ if ("name" in obj &&
635
+ "stage" in obj &&
636
+ ("version" in obj || "pipeline" in obj))
637
+ return "release";
638
+ if ("targetDate" in obj && "name" in obj && !("teams" in obj))
639
+ return "milestone";
640
+ if ("displayName" in obj && "email" in obj)
641
+ return "user";
642
+ return "generic";
643
+ }
644
+ function inferListKind(items) {
645
+ if (items.length === 0) {
646
+ // Empty list — render a generic "(no results)" marker. We avoid
647
+ // falling through to "generic" because that renders envelope
648
+ // keys (data, meta) rather than treating the payload as the
649
+ // empty list it is.
650
+ return "empty-list";
651
+ }
652
+ const sample = asObj(items[0]) ?? {};
653
+ if ("identifier" in sample && "title" in sample) {
654
+ // could be issue or search result
655
+ if ("type" in sample && !("templateData" in sample)) {
656
+ return "search-result-list";
657
+ }
658
+ return "issue-list";
659
+ }
660
+ if ("body" in sample && "createdAt" in sample)
661
+ return "comment-list";
662
+ if ("progress" in sample && "name" in sample) {
663
+ if ("startsAt" in sample || "endsAt" in sample)
664
+ return "cycle-list";
665
+ return "project-list";
666
+ }
667
+ if ("title" in sample && ("content" in sample || "slugId" in sample))
668
+ return "document-list";
669
+ // templates always have type + name; some include team / templateData
670
+ if ("name" in sample &&
671
+ "type" in sample &&
672
+ ("templateData" in sample || "creator" in sample))
673
+ return "template-list";
674
+ // releases: name + stage, typically with version or pipeline
675
+ if ("name" in sample &&
676
+ "stage" in sample &&
677
+ ("version" in sample || "pipeline" in sample))
678
+ return "release-list";
679
+ if ("title" in sample && "url" in sample && !("identifier" in sample))
680
+ return "attachment-list";
681
+ if ("targetDate" in sample && "name" in sample)
682
+ return "milestone-list";
683
+ if ("key" in sample && "name" in sample)
684
+ return "team-list";
685
+ if ("color" in sample && "scope" in sample)
686
+ return "label-list";
687
+ if ("displayName" in sample && "email" in sample)
688
+ return "user-list";
689
+ return "generic";
690
+ }
691
+ /**
692
+ * Central dispatch: given a resource kind and payload, return the
693
+ * formatted string. The payload for list kinds may be either the raw
694
+ * array or a `{ data: [...] }` envelope — both are handled.
695
+ */
696
+ export function dispatch(kind, payload) {
697
+ const obj = asObj(payload);
698
+ const list = (() => {
699
+ if (Array.isArray(payload))
700
+ return payload;
701
+ if (obj && Array.isArray(obj.data))
702
+ return obj.data;
703
+ return null;
704
+ })();
705
+ switch (kind) {
706
+ case "issue":
707
+ return formatIssueSummary((obj ?? {}));
708
+ case "issue-list":
709
+ return formatIssueList(list ?? []);
710
+ case "project":
711
+ return formatProjectSummary((obj ?? {}));
712
+ case "project-list":
713
+ return formatProjectList(list ?? []);
714
+ case "comment":
715
+ return formatCommentSummary((obj ?? {}));
716
+ case "comment-list":
717
+ return formatCommentList(list ?? []);
718
+ case "cycle":
719
+ return formatCycleSummary((obj ?? {}));
720
+ case "cycle-list":
721
+ return formatCycleList(list ?? []);
722
+ case "milestone":
723
+ return formatMilestoneSummary((obj ?? {}));
724
+ case "milestone-list":
725
+ return formatMilestoneList(list ?? []);
726
+ case "team-list":
727
+ return formatTeamList(list ?? []);
728
+ case "label-list":
729
+ return formatLabelList(list ?? []);
730
+ case "user":
731
+ return formatUserSummary((obj ?? {}));
732
+ case "user-list":
733
+ return formatUserList(list ?? []);
734
+ case "document":
735
+ return formatDocumentSummary((obj ?? {}));
736
+ case "document-list":
737
+ return formatDocumentList(list ?? []);
738
+ case "template":
739
+ return formatTemplateSummary((obj ?? {}));
740
+ case "template-list":
741
+ return formatTemplateList(list ?? []);
742
+ case "attachment-list":
743
+ return formatAttachmentList(list ?? []);
744
+ case "release":
745
+ return formatReleaseSummary((obj ?? {}));
746
+ case "release-list":
747
+ return formatReleaseList(list ?? []);
748
+ case "search-result-list":
749
+ return formatSearchResultList(list ?? []);
750
+ case "empty-list":
751
+ return "(no results)";
752
+ case "generic":
753
+ return formatGenericSummary(payload);
754
+ }
755
+ }