linear-sdk-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +9 -0
  3. package/CHANGELOG.md +807 -0
  4. package/COVERAGE.md +495 -0
  5. package/LICENSE +21 -0
  6. package/MIGRATING.md +220 -0
  7. package/README.md +524 -0
  8. package/package.json +87 -0
  9. package/skills/linear-sdk-cli/SKILL.md +534 -0
  10. package/skills/linear-sdk-cli/references/api.md +27 -0
  11. package/skills/linear-sdk-cli/references/attachment.md +82 -0
  12. package/skills/linear-sdk-cli/references/auth.md +174 -0
  13. package/skills/linear-sdk-cli/references/commands.md +37 -0
  14. package/skills/linear-sdk-cli/references/comment.md +154 -0
  15. package/skills/linear-sdk-cli/references/completion.md +17 -0
  16. package/skills/linear-sdk-cli/references/config.md +99 -0
  17. package/skills/linear-sdk-cli/references/cycle.md +131 -0
  18. package/skills/linear-sdk-cli/references/document.md +180 -0
  19. package/skills/linear-sdk-cli/references/favorite.md +78 -0
  20. package/skills/linear-sdk-cli/references/initiative-update.md +67 -0
  21. package/skills/linear-sdk-cli/references/initiative.md +256 -0
  22. package/skills/linear-sdk-cli/references/issue.md +928 -0
  23. package/skills/linear-sdk-cli/references/label.md +112 -0
  24. package/skills/linear-sdk-cli/references/milestone.md +139 -0
  25. package/skills/linear-sdk-cli/references/notification.md +123 -0
  26. package/skills/linear-sdk-cli/references/open.md +29 -0
  27. package/skills/linear-sdk-cli/references/organization.md +99 -0
  28. package/skills/linear-sdk-cli/references/project-update.md +67 -0
  29. package/skills/linear-sdk-cli/references/project.md +234 -0
  30. package/skills/linear-sdk-cli/references/roadmap.md +150 -0
  31. package/skills/linear-sdk-cli/references/schema.md +21 -0
  32. package/skills/linear-sdk-cli/references/state.md +61 -0
  33. package/skills/linear-sdk-cli/references/team.md +227 -0
  34. package/skills/linear-sdk-cli/references/user.md +103 -0
  35. package/skills/linear-sdk-cli/references/webhook.md +151 -0
  36. package/skills/linear-sdk-cli/references/whoami.md +26 -0
  37. package/skills/linear-sdk-cli/scripts/generate-docs.ts +339 -0
  38. package/src/bin/linear.ts +97 -0
  39. package/src/cli.ts +232 -0
  40. package/src/client.ts +172 -0
  41. package/src/commands/agent-session.ts +121 -0
  42. package/src/commands/api.ts +317 -0
  43. package/src/commands/attach.ts +90 -0
  44. package/src/commands/attachment.ts +74 -0
  45. package/src/commands/comment.ts +225 -0
  46. package/src/commands/completion.ts +69 -0
  47. package/src/commands/cycle.ts +125 -0
  48. package/src/commands/discover.ts +170 -0
  49. package/src/commands/document.ts +211 -0
  50. package/src/commands/favorite.ts +72 -0
  51. package/src/commands/initiative-update.ts +47 -0
  52. package/src/commands/initiative.ts +261 -0
  53. package/src/commands/issue.ts +1263 -0
  54. package/src/commands/label.ts +119 -0
  55. package/src/commands/meta.ts +534 -0
  56. package/src/commands/milestone.ts +163 -0
  57. package/src/commands/notification.ts +110 -0
  58. package/src/commands/open.ts +84 -0
  59. package/src/commands/organization.ts +79 -0
  60. package/src/commands/project-update.ts +47 -0
  61. package/src/commands/project.ts +339 -0
  62. package/src/commands/roadmap.ts +131 -0
  63. package/src/commands/state.ts +58 -0
  64. package/src/commands/team.ts +243 -0
  65. package/src/commands/user.ts +107 -0
  66. package/src/commands/webhook.ts +156 -0
  67. package/src/config.ts +1236 -0
  68. package/src/context.ts +117 -0
  69. package/src/git.ts +174 -0
  70. package/src/index.ts +10 -0
  71. package/src/lib/action.ts +26 -0
  72. package/src/lib/body.ts +172 -0
  73. package/src/lib/errors.ts +195 -0
  74. package/src/lib/introspect.ts +150 -0
  75. package/src/lib/keyring.ts +270 -0
  76. package/src/lib/mutation.ts +53 -0
  77. package/src/lib/open.ts +32 -0
  78. package/src/lib/options.ts +640 -0
  79. package/src/lib/output-shapes.ts +562 -0
  80. package/src/lib/pagination.ts +145 -0
  81. package/src/lib/prompt.ts +91 -0
  82. package/src/lib/resolve.ts +596 -0
  83. package/src/lib/shape.ts +273 -0
  84. package/src/lib/status-update.ts +91 -0
  85. package/src/lib/upload.ts +329 -0
  86. package/src/output/color.ts +52 -0
  87. package/src/output/format.ts +238 -0
  88. package/src/output/markdown.ts +133 -0
  89. package/src/output/pager.ts +131 -0
  90. package/src/output/sanitize.ts +76 -0
  91. package/src/output/table.ts +198 -0
  92. package/src/services/agent-session.ts +289 -0
  93. package/src/services/attachment.ts +198 -0
  94. package/src/services/comment.ts +262 -0
  95. package/src/services/cycle.ts +224 -0
  96. package/src/services/document.ts +357 -0
  97. package/src/services/favorite.ts +147 -0
  98. package/src/services/initiative-update.ts +59 -0
  99. package/src/services/initiative.ts +449 -0
  100. package/src/services/issue.ts +1153 -0
  101. package/src/services/label.ts +193 -0
  102. package/src/services/milestone.ts +262 -0
  103. package/src/services/notification.ts +190 -0
  104. package/src/services/organization.ts +144 -0
  105. package/src/services/project-update.ts +36 -0
  106. package/src/services/project.ts +494 -0
  107. package/src/services/roadmap.ts +177 -0
  108. package/src/services/state.ts +127 -0
  109. package/src/services/team.ts +386 -0
  110. package/src/services/user.ts +137 -0
  111. package/src/services/webhook.ts +203 -0
@@ -0,0 +1,317 @@
1
+ /**
2
+ * `linear api` — raw GraphQL escape hatch.
3
+ *
4
+ * Reaches any part of the Linear API the curated commands don't wrap. Accepts a
5
+ * query/mutation as an argument, from --query-file, or from stdin; variables via
6
+ * repeatable --var k=v, --vars '<json>', or --vars-file <path>; --operation to
7
+ * pick one operation out of a multi-operation document; --paginate to
8
+ * auto-follow a connection's cursor; --raw to print the full response
9
+ * (data + extensions).
10
+ */
11
+
12
+ import { Command } from "commander";
13
+ import { readFileSync } from "node:fs";
14
+ import {
15
+ Kind,
16
+ parse as parseGraphQL,
17
+ print as printGraphQL,
18
+ visit,
19
+ type DocumentNode,
20
+ type FragmentDefinitionNode,
21
+ type OperationDefinitionNode,
22
+ } from "graphql";
23
+ import { action } from "../lib/action.js";
24
+ import { withRetry } from "../client.js";
25
+ import { collectKeyVal } from "../lib/options.js";
26
+ import { readStdinSync } from "../lib/body.js";
27
+ import { usageError, CliError } from "../lib/errors.js";
28
+ import type { Context } from "../context.js";
29
+
30
+ export function registerApi(program: Command): void {
31
+ program
32
+ .command("api [query]")
33
+ .description("Run a raw GraphQL query or mutation against the Linear API")
34
+ .option("--query-file <path>", "read the query from a file ('-' for stdin)")
35
+ .option(
36
+ "--var <k=v...>",
37
+ "set a variable (repeatable; JSON literals are typed, other values are strings)",
38
+ collectKeyVal,
39
+ {},
40
+ )
41
+ .option("--vars <json>", "variables as a JSON object")
42
+ .option("--vars-file <path>", "read variables from a JSON file ('-' for stdin)")
43
+ .option("--operation <name>", "run this operation of a multi-operation document")
44
+ .option("--paginate", "auto-follow the first connection's pageInfo cursor (queries only)")
45
+ .option("--raw", "print the full GraphQL response (data + extensions)")
46
+ .addHelpText(
47
+ "after",
48
+ [
49
+ "",
50
+ "Examples:",
51
+ " linear api '{ viewer { id name } }'",
52
+ " linear api 'query($id:String!){ issue(id:$id){ title } }' --var id=TES-1",
53
+ " echo '{ teams { nodes { key name } } }' | linear api --query-file -",
54
+ " linear api --query-file q.graphql --vars-file vars.json --paginate",
55
+ " linear api --query-file ops.graphql --operation Second",
56
+ ].join("\n"),
57
+ )
58
+ .action(action(runApi));
59
+ }
60
+
61
+ /**
62
+ * The `api` action body, exported so tests can drive it against a fake client
63
+ * and assert on exactly what reaches `rawRequest`.
64
+ */
65
+ export async function runApi(
66
+ ctx: Context,
67
+ opts: Record<string, any>,
68
+ queryArg?: string,
69
+ ): Promise<void> {
70
+ const source = resolveQuery(queryArg, opts.queryFile);
71
+ const variables = resolveVariables(opts);
72
+ // --operation and --paginate both need a parsed document: one to pick an
73
+ // operation out of it, the other to refuse anything that is not a query.
74
+ const doc = prepareDocument(source, opts.operation, {
75
+ mustParse: Boolean(opts.operation) || Boolean(opts.paginate),
76
+ });
77
+
78
+ if (opts.paginate) {
79
+ if (doc.operation !== "query") {
80
+ throw usageError(
81
+ `--paginate re-runs the document once per page, so it only accepts a query; this document is a ${doc.operation}. Re-running a ${doc.operation} would repeat its side effects on every page.`,
82
+ );
83
+ }
84
+ const { nodes, pageCount, truncated } = await paginate(ctx, doc.query, variables);
85
+ // Contract: stdout is a bare array; pagination metadata goes to stderr.
86
+ ctx.output.info(`fetched ${nodes.length} node(s) across ${pageCount} page(s)`);
87
+ if (truncated)
88
+ ctx.output.warn(`result truncated at the ${PAGE_CAP}-page safety cap; more pages may exist`);
89
+ ctx.output.emit(nodes, () => process.stdout.write(JSON.stringify(nodes, null, 2) + "\n"));
90
+ return;
91
+ }
92
+
93
+ const result: any = await withRetry(() =>
94
+ (ctx.client.client as any).rawRequest(doc.query, variables),
95
+ );
96
+ const payload = opts.raw ? { data: result.data, extensions: result.extensions } : result.data;
97
+ ctx.output.emit(payload, () => process.stdout.write(JSON.stringify(payload, null, 2) + "\n"));
98
+ }
99
+
100
+ function readFileOrThrow(path: string, label: string): string {
101
+ if (path === "-") return readStdinSync();
102
+ try {
103
+ return readFileSync(path, "utf8");
104
+ } catch (err) {
105
+ throw usageError(`Cannot read ${label} '${path}': ${(err as Error).message}`);
106
+ }
107
+ }
108
+
109
+ function resolveQuery(arg: string | undefined, file: string | undefined): string {
110
+ if (arg !== undefined && file !== undefined) {
111
+ throw usageError(
112
+ "Pass the GraphQL query either as an argument or with --query-file, not both.",
113
+ );
114
+ }
115
+ let query: string | undefined;
116
+ if (arg !== undefined) query = arg;
117
+ else if (file !== undefined) query = readFileOrThrow(file, "--query-file");
118
+ else if (!process.stdin.isTTY) query = readStdinSync();
119
+
120
+ query = query?.trim();
121
+ if (!query) throw usageError("No GraphQL query provided (argument, --query-file, or stdin).");
122
+ return query;
123
+ }
124
+
125
+ function resolveVariables(opts: Record<string, any>): Record<string, unknown> {
126
+ let vars: Record<string, unknown> = {};
127
+ if (opts.varsFile) {
128
+ const text = readFileOrThrow(opts.varsFile, "--vars-file");
129
+ vars = { ...vars, ...parseJsonObject(text, "--vars-file") };
130
+ }
131
+ if (opts.vars) vars = { ...vars, ...parseJsonObject(opts.vars, "--vars") };
132
+ if (opts.var) vars = { ...vars, ...(opts.var as Record<string, unknown>) };
133
+ return vars;
134
+ }
135
+
136
+ function parseJsonObject(text: string, label: string): Record<string, unknown> {
137
+ let parsed: unknown;
138
+ try {
139
+ parsed = JSON.parse(text);
140
+ } catch (err) {
141
+ throw usageError(`${label} is not valid JSON: ${(err as Error).message}`);
142
+ }
143
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
144
+ throw usageError(`${label} must be a JSON object`);
145
+ }
146
+ return parsed as Record<string, unknown>;
147
+ }
148
+
149
+ /**
150
+ * The document we will actually send, plus what we learned by parsing it.
151
+ *
152
+ * `operation` is the kind (`query`/`mutation`/`subscription`) of the operation
153
+ * that will run, or `undefined` when the document could not be parsed and we
154
+ * are deferring to the server (only possible when neither --operation nor
155
+ * --paginate was asked for).
156
+ */
157
+ export interface PreparedDocument {
158
+ query: string;
159
+ operation: "query" | "mutation" | "subscription" | undefined;
160
+ operationName?: string;
161
+ }
162
+
163
+ /**
164
+ * Resolve which operation of a document will run.
165
+ *
166
+ * The SDK's `rawRequest(query, variables, headers)` has no `operationName`
167
+ * parameter and its request body carries only `{query, variables}`, so the only
168
+ * way to select one operation out of many is to *send only that one*: we parse
169
+ * the document and print a new one containing the chosen operation plus the
170
+ * fragments it (transitively) uses. A single-operation document is sent
171
+ * verbatim, so formatting, comments and any syntax our parser merely tolerates
172
+ * survive untouched.
173
+ */
174
+ export function prepareDocument(
175
+ source: string,
176
+ requested: string | undefined,
177
+ { mustParse }: { mustParse: boolean },
178
+ ): PreparedDocument {
179
+ let doc: DocumentNode;
180
+ try {
181
+ doc = parseGraphQL(source);
182
+ } catch (err) {
183
+ // The escape hatch should not become less capable than the server it wraps:
184
+ // when nothing depends on our understanding of the document, send it as-is
185
+ // and let the API be the authority on what is valid.
186
+ if (!mustParse) return { query: source, operation: undefined };
187
+ throw usageError(`Cannot parse the GraphQL document: ${(err as Error).message}`);
188
+ }
189
+
190
+ const operations = doc.definitions.filter(
191
+ (d): d is OperationDefinitionNode => d.kind === Kind.OPERATION_DEFINITION,
192
+ );
193
+ if (operations.length === 0) {
194
+ throw usageError("The GraphQL document defines no operation to run.");
195
+ }
196
+
197
+ let selected: OperationDefinitionNode;
198
+ if (requested !== undefined) {
199
+ const match = operations.find((o) => o.name?.value === requested);
200
+ if (!match) throw usageError(unknownOperationMessage(requested, operations));
201
+ selected = match;
202
+ } else if (operations.length === 1) {
203
+ selected = operations[0]!;
204
+ } else {
205
+ throw usageError(
206
+ `The document defines ${operations.length} operations (${describeNames(operations)}); pass --operation <name> to choose one.`,
207
+ );
208
+ }
209
+
210
+ const result: PreparedDocument = {
211
+ query: source,
212
+ operation: selected.operation,
213
+ operationName: selected.name?.value,
214
+ };
215
+ // Only rewrite when the document really holds more than one operation —
216
+ // otherwise what we send is byte-for-byte what the user wrote.
217
+ if (operations.length > 1) {
218
+ result.query = printGraphQL({
219
+ kind: Kind.DOCUMENT,
220
+ definitions: [selected, ...reachableFragments(doc, selected)],
221
+ });
222
+ }
223
+ return result;
224
+ }
225
+
226
+ function describeNames(operations: OperationDefinitionNode[]): string {
227
+ return operations.map((o) => o.name?.value ?? "<unnamed>").join(", ");
228
+ }
229
+
230
+ function unknownOperationMessage(requested: string, operations: OperationDefinitionNode[]): string {
231
+ const named = operations.filter((o) => o.name).map((o) => o.name!.value);
232
+ const available = named.length
233
+ ? `Available: ${named.join(", ")}.`
234
+ : "The document's only operation is unnamed.";
235
+ return `No operation named '${requested}' in the document. ${available}`;
236
+ }
237
+
238
+ /** Every fragment definition the operation depends on, following spreads transitively. */
239
+ function reachableFragments(
240
+ doc: DocumentNode,
241
+ operation: OperationDefinitionNode,
242
+ ): FragmentDefinitionNode[] {
243
+ const defined = new Map<string, FragmentDefinitionNode>();
244
+ for (const def of doc.definitions) {
245
+ if (def.kind === Kind.FRAGMENT_DEFINITION) defined.set(def.name.value, def);
246
+ }
247
+ const needed = new Map<string, FragmentDefinitionNode>();
248
+ const queue: Array<OperationDefinitionNode | FragmentDefinitionNode> = [operation];
249
+ while (queue.length > 0) {
250
+ visit(queue.pop()!, {
251
+ FragmentSpread(spread) {
252
+ const name = spread.name.value;
253
+ if (needed.has(name)) return;
254
+ const fragment = defined.get(name);
255
+ // A spread with no definition is left dangling for the server to reject
256
+ // — the same answer the user would get without --operation.
257
+ if (!fragment) return;
258
+ needed.set(name, fragment);
259
+ queue.push(fragment);
260
+ },
261
+ });
262
+ }
263
+ return [...needed.values()];
264
+ }
265
+
266
+ /**
267
+ * Best-effort pagination: repeatedly run the query injecting `after`, find the
268
+ * first `{ nodes, pageInfo }` connection in the result, and concatenate nodes.
269
+ * The supplied query must accept an `$after: String` variable.
270
+ */
271
+ const PAGE_CAP = 1000;
272
+
273
+ async function paginate(
274
+ ctx: Context,
275
+ query: string,
276
+ variables: Record<string, unknown>,
277
+ ): Promise<{ nodes: unknown[]; pageCount: number; truncated: boolean }> {
278
+ let after: string | undefined = variables.after as string | undefined;
279
+ const allNodes: unknown[] = [];
280
+ let pages = 0;
281
+ let truncated = false;
282
+
283
+ for (;;) {
284
+ const result: any = await withRetry(() =>
285
+ (ctx.client.client as any).rawRequest(query, { ...variables, after }),
286
+ );
287
+ const conn = findConnection(result.data);
288
+ if (!conn) {
289
+ if (pages === 0)
290
+ throw new CliError("No connection (nodes/pageInfo) found to paginate.", "usage");
291
+ break;
292
+ }
293
+ allNodes.push(...conn.nodes);
294
+ pages++;
295
+ if (!conn.pageInfo?.hasNextPage || !conn.pageInfo?.endCursor) break;
296
+ after = conn.pageInfo.endCursor as string;
297
+ if (pages >= PAGE_CAP) {
298
+ truncated = true; // safety bound hit; more pages may exist
299
+ break;
300
+ }
301
+ }
302
+
303
+ return { nodes: allNodes, pageCount: pages, truncated };
304
+ }
305
+
306
+ /** Find the first `{ nodes, pageInfo }` connection anywhere in the response. Exported for tests. */
307
+ export function findConnection(data: any): { nodes: unknown[]; pageInfo: any } | undefined {
308
+ if (!data || typeof data !== "object") return undefined;
309
+ if (Array.isArray((data as any).nodes) && (data as any).pageInfo) {
310
+ return data as { nodes: unknown[]; pageInfo: any };
311
+ }
312
+ for (const value of Object.values(data)) {
313
+ const found = findConnection(value);
314
+ if (found) return found;
315
+ }
316
+ return undefined;
317
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * `linear issue attach <issue> <file...>` — upload files to Linear's storage
3
+ * and attach them to an issue (TES-602). Mounted under `issue` (from cli.ts)
4
+ * rather than under the `attachment` group: it is the reference CLI's
5
+ * spelling, and the workflow is an issue workflow — `attachment create` stays
6
+ * the URL-only form.
7
+ *
8
+ * Private by default: the asset URL is readable by workspace members only,
9
+ * like an upload from the Linear app. `--public` puts a raster image on a
10
+ * world-readable URL and says so on stderr; for anything else it is a usage
11
+ * error, before any bytes move. Every file is validated before the first
12
+ * upload, so a typo in file 3 does not leave files 1–2 uploaded and orphaned.
13
+ *
14
+ * A sidebar attachment does not render an image inline; `--comment <body>`
15
+ * also posts one comment embedding every file as markdown (`![name](url)` for
16
+ * images, `[name](url)` otherwise) so it shows in the thread too.
17
+ */
18
+
19
+ import { Command } from "commander";
20
+ import { action } from "../lib/action.js";
21
+ import { formatBytes, isImage } from "../lib/upload.js";
22
+ import type { Context } from "../context.js";
23
+ import * as svc from "../services/attachment.js";
24
+
25
+ export function registerAttach(issue: Command): void {
26
+ issue
27
+ .command("attach <issue> <file...>")
28
+ .description("Upload files and attach them to an issue (private by default)")
29
+ .option("--title <title>", "attachment title (single file only; default: the file name)")
30
+ .option(
31
+ "--comment <body>",
32
+ "also post a comment with this body embedding the files as markdown",
33
+ )
34
+ .option(
35
+ "--public",
36
+ "upload to a public, world-readable URL (raster images only; default: private)",
37
+ )
38
+ .addHelpText(
39
+ "after",
40
+ [
41
+ "",
42
+ "Examples:",
43
+ " linear issue attach TES-42 screenshot.png",
44
+ " linear issue attach TES-42 repro.log trace.json --comment 'Logs from the failing run'",
45
+ " linear issue attach TES-42 shot.png --json | jq -r '.[].assetUrl'",
46
+ "",
47
+ "Files are private (workspace members only) unless --public is given, which Linear",
48
+ "allows for raster images only. Sidebar attachments do not render images inline —",
49
+ "use --comment here, or 'linear comment add <issue> --attach <file>', for that.",
50
+ ].join("\n"),
51
+ )
52
+ .action(
53
+ action(async (ctx: Context, opts, issueArg: string, files: string[]) => {
54
+ const result = await svc.attachFiles(ctx.client, issueArg, files, {
55
+ title: opts.title,
56
+ comment: opts.comment,
57
+ public: opts.public === true,
58
+ onAttached: (a, iss) => {
59
+ ctx.output.success(
60
+ `Attached ${a.filename} (${formatBytes(a.size)}) to ${iss.identifier}`,
61
+ );
62
+ if (a.public)
63
+ ctx.output.warn(
64
+ `${a.filename} is on a public URL, readable by anyone: ${a.assetUrl}`,
65
+ );
66
+ },
67
+ });
68
+ const identifier = result.issue.identifier;
69
+ if (result.comment) ctx.output.success(`Commented on ${identifier}`);
70
+ else if (result.attachments.some((a) => isImage(a.contentType))) {
71
+ ctx.output.info(
72
+ "Hint: sidebar attachments do not render images inline. To show one in the thread: " +
73
+ `linear comment add ${identifier} --attach <file> (or --comment here).`,
74
+ );
75
+ }
76
+ ctx.output.emit(
77
+ result.attachments.map((a) => ({
78
+ id: a.id,
79
+ title: a.title,
80
+ url: a.url,
81
+ assetUrl: a.assetUrl,
82
+ contentType: a.contentType,
83
+ size: a.size,
84
+ ...(result.comment ? { comment: result.comment } : {}),
85
+ })),
86
+ () => {},
87
+ );
88
+ }),
89
+ );
90
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `linear attachment` (alias `at`) — list, create, and delete issue attachments.
3
+ *
4
+ * `list`/`create` take an issue reference (identifier like TES-123 or a UUID),
5
+ * resolved via `resolveIssue`. `delete` takes an attachment id (UUID).
6
+ */
7
+
8
+ import { Command } from "commander";
9
+ import { action } from "../lib/action.js";
10
+ import { confirmDestructive } from "../lib/prompt.js";
11
+ import type { Context } from "../context.js";
12
+ import * as svc from "../services/attachment.js";
13
+ import type { Column } from "../output/table.js";
14
+
15
+ const ROW_COLUMNS: Column<svc.AttachmentRow>[] = [
16
+ { key: "title", header: "Title", value: (r) => r.title, max: 40 },
17
+ { key: "url", header: "URL", value: (r) => r.url, max: 50 },
18
+ { key: "source", header: "Source", value: (r) => r.source ?? "—", max: 16 },
19
+ ];
20
+
21
+ export function registerAttachment(program: Command): void {
22
+ const attachment = program
23
+ .command("attachment")
24
+ .alias("at")
25
+ .description("Work with issue attachments");
26
+
27
+ // list --------------------------------------------------------------------
28
+ attachment
29
+ .command("list <issue>")
30
+ .alias("ls")
31
+ .description("List the attachments on an issue")
32
+ .action(
33
+ action(async (ctx: Context, _opts, issueArg: string) => {
34
+ const rows = await svc.listAttachments(ctx.client, issueArg, ctx.limit);
35
+ ctx.output.list(rows, ROW_COLUMNS, rows);
36
+ }),
37
+ );
38
+
39
+ // create ------------------------------------------------------------------
40
+ attachment
41
+ .command("create <issue>")
42
+ .alias("new")
43
+ .description("Attach a URL to an issue")
44
+ .requiredOption("--url <url>", "the URL to attach")
45
+ .requiredOption("--title <title>", "attachment title")
46
+ .option("--subtitle <text>", "attachment subtitle")
47
+ .action(
48
+ action(async (ctx: Context, opts, issueArg: string) => {
49
+ const created = await svc.createAttachment(ctx.client, issueArg, {
50
+ url: opts.url,
51
+ title: opts.title,
52
+ subtitle: opts.subtitle,
53
+ });
54
+ ctx.output.emit({ id: created.id, title: created.title, url: created.url }, () =>
55
+ ctx.output.success(`Created attachment ${created.title}`),
56
+ );
57
+ }),
58
+ );
59
+
60
+ // delete ------------------------------------------------------------------
61
+ attachment
62
+ .command("delete <id>")
63
+ .alias("rm")
64
+ .description("Delete an attachment by id")
65
+ .action(
66
+ action(async (ctx: Context, _opts, id: string) => {
67
+ if (!(await confirmDestructive(ctx, `Delete attachment ${id}?`))) return;
68
+ const deleted = await svc.deleteAttachment(ctx.client, id);
69
+ ctx.output.emit({ id: deleted.id, title: deleted.title, deleted: true }, () =>
70
+ ctx.output.success(`Deleted attachment ${deleted.title}`),
71
+ );
72
+ }),
73
+ );
74
+ }