linear-sdk-cli 0.3.2 → 0.4.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.
@@ -0,0 +1,279 @@
1
+ /** `linear custom-view` (alias `cv`) — manage saved custom views. */
2
+
3
+ import { Command, Option } from "commander";
4
+ import type { Context } from "../context.js";
5
+ import { action } from "../lib/action.js";
6
+ import { resolveBody } from "../lib/body.js";
7
+ import { usageError } from "../lib/errors.js";
8
+ import { parseJsonObjectInput } from "../lib/json-input.js";
9
+ import { confirmDestructive, promptInput, promptSelect } from "../lib/prompt.js";
10
+ import type { Column } from "../output/table.js";
11
+ import * as svc from "../services/custom-view.js";
12
+
13
+ const ROW_COLUMNS: Column<svc.CustomViewRow>[] = [
14
+ { key: "name", header: "Name", value: (row) => row.name, max: 40 },
15
+ { key: "type", header: "Type", value: (row) => row.type, max: 12 },
16
+ { key: "shared", header: "Shared", value: (row) => (row.shared ? "yes" : "no") },
17
+ { key: "owner", header: "Owner", value: (row) => row.owner?.displayName ?? "—", max: 24 },
18
+ {
19
+ key: "team",
20
+ header: "Team",
21
+ value: (row) => (row.team ? `${row.team.key} ${row.team.name}` : "—"),
22
+ max: 28,
23
+ },
24
+ { key: "id", header: "ID", value: (row) => row.id },
25
+ ];
26
+
27
+ const RESULT_COLUMNS: Column<svc.CustomViewResultRow>[] = [
28
+ { key: "type", header: "Type", value: (row) => row.type, max: 12 },
29
+ { key: "identifier", header: "Key", value: (row) => row.identifier ?? "—", max: 16 },
30
+ { key: "name", header: "Name", value: (row) => row.name, max: 60 },
31
+ { key: "id", header: "ID", value: (row) => row.id },
32
+ ];
33
+
34
+ function filterFrom(opts: Record<string, any>): Record<string, unknown> | undefined {
35
+ return parseJsonObjectInput({ inline: opts.filter, file: opts.filterFile, label: "filter" });
36
+ }
37
+
38
+ function visibility(opts: Record<string, any>): boolean | undefined {
39
+ if (opts.shared && opts.personal) {
40
+ throw usageError("Pass either --shared or --personal, not both.");
41
+ }
42
+ if (opts.shared) return true;
43
+ if (opts.personal) return false;
44
+ return undefined;
45
+ }
46
+
47
+ function addFilterOptions(command: Command): Command {
48
+ return command
49
+ .addOption(
50
+ new Option("--filter <json>", "typed Linear filter as a JSON object").conflicts("filterFile"),
51
+ )
52
+ .addOption(
53
+ new Option(
54
+ "--filter-file <path>",
55
+ "read the filter JSON from a file ('-' = stdin)",
56
+ ).conflicts("filter"),
57
+ );
58
+ }
59
+
60
+ function addVisibilityOptions(command: Command): Command {
61
+ return command
62
+ .addOption(new Option("--shared", "share the view with the workspace").conflicts("personal"))
63
+ .addOption(new Option("--personal", "make the view owner-only").conflicts("shared"));
64
+ }
65
+
66
+ function addDescriptionOptions(command: Command, update = false): Command {
67
+ command
68
+ .addOption(
69
+ new Option("-d, --description <text>", update ? "new description" : "view description")
70
+ .conflicts("descriptionFile")
71
+ .conflicts("clearDescription"),
72
+ )
73
+ .addOption(
74
+ new Option("--description-file <path>", "read description from a file ('-' = stdin)")
75
+ .conflicts("description")
76
+ .conflicts("clearDescription"),
77
+ );
78
+ return command;
79
+ }
80
+
81
+ function receipt(view: any) {
82
+ return {
83
+ id: view.id,
84
+ name: view.name,
85
+ type: svc.customViewType(view.modelName),
86
+ shared: view.shared,
87
+ slugId: view.slugId,
88
+ };
89
+ }
90
+
91
+ export function registerCustomView(program: Command): void {
92
+ const customView = program
93
+ .command("custom-view")
94
+ .alias("cv")
95
+ .description("Create and manage saved custom views");
96
+
97
+ customView
98
+ .command("list")
99
+ .alias("ls")
100
+ .description("List accessible workspace and team custom views")
101
+ .action(
102
+ action(async (ctx: Context) => {
103
+ const rows = await svc.listCustomViews(ctx.client, ctx.limit);
104
+ ctx.output.list(rows, ROW_COLUMNS, rows);
105
+ }),
106
+ );
107
+
108
+ customView
109
+ .command("view <id>", { isDefault: true })
110
+ .alias("show")
111
+ .description("Show a custom view by UUID")
112
+ .action(
113
+ action(async (ctx: Context, _opts, id: string) => {
114
+ const detail = await svc.getCustomViewDetail(ctx.client, id);
115
+ ctx.output.detail(detail, [
116
+ ["Name", detail.name],
117
+ ["Type", detail.type],
118
+ ["Visibility", detail.shared ? "shared" : "personal"],
119
+ ["Owner", detail.owner?.displayName],
120
+ ["Creator", detail.creator?.displayName],
121
+ ["Team", detail.team ? `${detail.team.key} ${detail.team.name}` : null],
122
+ ["Description", detail.description],
123
+ ["Filter", JSON.stringify(detail.filter)],
124
+ ["Color", detail.color],
125
+ ["Icon", detail.icon],
126
+ ["Slug", detail.slugId],
127
+ ["Created", detail.createdAt],
128
+ ["Updated", detail.updatedAt],
129
+ ["Archived", detail.archivedAt],
130
+ ["ID", detail.id],
131
+ ]);
132
+ }),
133
+ );
134
+
135
+ customView
136
+ .command("results <id>")
137
+ .alias("items")
138
+ .description("List issues, projects, or initiatives matched by a view UUID")
139
+ .action(
140
+ action(async (ctx: Context, _opts, id: string) => {
141
+ const rows = await svc.listCustomViewResults(ctx.client, id, ctx.limit);
142
+ ctx.output.list(rows, RESULT_COLUMNS, rows, { empty: "(view matches no entities)" });
143
+ }),
144
+ );
145
+
146
+ let create = customView
147
+ .command("create")
148
+ .alias("new")
149
+ .description("Create an issue, project, or initiative custom view")
150
+ .option("--name <name>", "view name")
151
+ .addOption(new Option("--type <type>", "entity type").choices([...svc.CUSTOM_VIEW_TYPES]))
152
+ .option("--owner <who>", "owner (me|email|name|id)")
153
+ .option("--color <hex>", "icon color")
154
+ .option("--icon <icon>", "emoji or decorative icon identifier")
155
+ .addOption(
156
+ new Option("--scope-team <key|name|id>", "attach the view to a team").conflicts([
157
+ "scopeProject",
158
+ "scopeInitiative",
159
+ ]),
160
+ )
161
+ .addOption(
162
+ new Option("--scope-project <id|name>", "attach the view to a project").conflicts([
163
+ "scopeTeam",
164
+ "scopeInitiative",
165
+ ]),
166
+ )
167
+ .addOption(
168
+ new Option("--scope-initiative <id|name>", "attach the view to an initiative").conflicts([
169
+ "scopeTeam",
170
+ "scopeProject",
171
+ ]),
172
+ );
173
+ create = addVisibilityOptions(addFilterOptions(addDescriptionOptions(create)));
174
+ create
175
+ .addHelpText(
176
+ "after",
177
+ `\nExamples:\n linear custom-view create --name "Urgent" --type issue --filter '{"priority":{"eq":1}}' --shared\n linear cv create --name "At risk" --type project --filter-file project-filter.json --scope-team ENG\n`,
178
+ )
179
+ .action(
180
+ action(async (ctx: Context, opts) => {
181
+ const name = opts.name ?? (await promptInput(ctx, "Name:", { required: true }));
182
+ const type: svc.CustomViewType =
183
+ opts.type ??
184
+ (await promptSelect(ctx, "View type:", [
185
+ { name: "Issues", value: "issue" as const },
186
+ { name: "Projects", value: "project" as const },
187
+ { name: "Initiatives", value: "initiative" as const },
188
+ ]));
189
+ const description = resolveBody({
190
+ arg: opts.description,
191
+ file: opts.descriptionFile,
192
+ interactive: false,
193
+ });
194
+ const created = await svc.createCustomView(ctx.client, {
195
+ name,
196
+ type,
197
+ filter: filterFrom(opts),
198
+ description,
199
+ color: opts.color,
200
+ icon: opts.icon,
201
+ owner: opts.owner,
202
+ shared: visibility(opts),
203
+ scopeTeam: opts.scopeTeam,
204
+ scopeProject: opts.scopeProject,
205
+ scopeInitiative: opts.scopeInitiative,
206
+ });
207
+ ctx.output.emit(receipt(created), () =>
208
+ ctx.output.success(`Created custom view ${created.name} (${created.id})`),
209
+ );
210
+ }),
211
+ );
212
+
213
+ let update = customView
214
+ .command("update <id>")
215
+ .alias("edit")
216
+ .description("Update mutable fields on a custom view UUID")
217
+ .option("--name <name>", "new name")
218
+ .option("--owner <who>", "new owner (me|email|name|id)")
219
+ .addOption(new Option("--color <hex>", "new icon color").conflicts("clearColor"))
220
+ .addOption(new Option("--clear-color", "clear the icon color").conflicts("color"))
221
+ .addOption(new Option("--icon <icon>", "new icon").conflicts("clearIcon"))
222
+ .addOption(new Option("--clear-icon", "clear the icon").conflicts("icon"))
223
+ .addOption(
224
+ new Option("--clear-description", "clear the description").conflicts([
225
+ "description",
226
+ "descriptionFile",
227
+ ]),
228
+ )
229
+ .addOption(
230
+ new Option("--scope-team <key|name|id>", "set the public team scope").conflicts(
231
+ "clearTeamScope",
232
+ ),
233
+ )
234
+ .addOption(
235
+ new Option("--clear-team-scope", "clear the public team scope").conflicts("scopeTeam"),
236
+ );
237
+ update = addVisibilityOptions(addFilterOptions(addDescriptionOptions(update, true)));
238
+ update.action(
239
+ action(async (ctx: Context, opts, id: string) => {
240
+ const description = resolveBody({
241
+ arg: opts.description,
242
+ file: opts.descriptionFile,
243
+ interactive: false,
244
+ });
245
+ const changed = await svc.updateCustomView(ctx.client, id, {
246
+ name: opts.name,
247
+ filter: filterFrom(opts),
248
+ description,
249
+ clearDescription: opts.clearDescription,
250
+ color: opts.color,
251
+ clearColor: opts.clearColor,
252
+ icon: opts.icon,
253
+ clearIcon: opts.clearIcon,
254
+ owner: opts.owner,
255
+ shared: visibility(opts),
256
+ scopeTeam: opts.scopeTeam,
257
+ clearTeamScope: opts.clearTeamScope,
258
+ });
259
+ ctx.output.emit(receipt(changed), () =>
260
+ ctx.output.success(`Updated custom view ${changed.name}`),
261
+ );
262
+ }),
263
+ );
264
+
265
+ customView
266
+ .command("delete <id>")
267
+ .alias("rm")
268
+ .description("Delete a custom view by UUID")
269
+ .action(
270
+ action(async (ctx: Context, _opts, id: string) => {
271
+ const detail = await svc.getCustomViewDetail(ctx.client, id);
272
+ if (!(await confirmDestructive(ctx, `Delete custom view ${detail.name}?`))) return;
273
+ const deleted = await svc.deleteCustomView(ctx.client, id);
274
+ ctx.output.emit({ id: deleted.id, name: deleted.name, deleted: true }, () =>
275
+ ctx.output.success(`Deleted custom view ${deleted.name}`),
276
+ );
277
+ }),
278
+ );
279
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Parse structured JSON supplied inline or from a file/stdin.
3
+ *
4
+ * Complex Linear filters evolve with the GraphQL schema. Keeping them as JSON
5
+ * objects lets the CLI pass the current typed filter shape to @linear/sdk
6
+ * without inventing a lossy mini-language or hard-coding raw GraphQL.
7
+ */
8
+
9
+ import { readFileSync } from "node:fs";
10
+ import { usageError } from "./errors.js";
11
+
12
+ export interface JsonObjectInput {
13
+ inline?: string;
14
+ file?: string;
15
+ label: string;
16
+ }
17
+
18
+ export function parseJsonObjectInput(input: JsonObjectInput): Record<string, unknown> | undefined {
19
+ if (input.inline !== undefined && input.file !== undefined) {
20
+ throw usageError(`Pass either --${input.label} or --${input.label}-file, not both.`);
21
+ }
22
+ if (input.inline === undefined && input.file === undefined) return undefined;
23
+
24
+ let source: string;
25
+ if (input.inline !== undefined) {
26
+ source = input.inline;
27
+ } else {
28
+ const file = input.file!;
29
+ try {
30
+ source = readFileSync(file === "-" ? 0 : file, "utf8");
31
+ } catch (err) {
32
+ throw usageError(`Cannot read ${input.label} file '${file}': ${(err as Error).message}`);
33
+ }
34
+ }
35
+
36
+ let parsed: unknown;
37
+ try {
38
+ parsed = JSON.parse(source);
39
+ } catch (err) {
40
+ throw usageError(`Invalid JSON for --${input.label}: ${(err as Error).message}`);
41
+ }
42
+ if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") {
43
+ throw usageError(`--${input.label} must be a JSON object.`);
44
+ }
45
+ return parsed as Record<string, unknown>;
46
+ }
@@ -355,6 +355,9 @@ export const FIELDS_COMMANDS: ReadonlySet<string> = new Set([
355
355
  "cycle list",
356
356
  "cycle view",
357
357
  "cycle current",
358
+ "custom-view list",
359
+ "custom-view results",
360
+ "custom-view view",
358
361
  "document list",
359
362
  "document view",
360
363
  "favorite list",
@@ -406,6 +409,8 @@ export const LIMIT_COMMANDS: ReadonlySet<string> = new Set([
406
409
  "attachment list",
407
410
  "comment list",
408
411
  "cycle list",
412
+ "custom-view list",
413
+ "custom-view results",
409
414
  "document list",
410
415
  "favorite list",
411
416
  "initiative list",
@@ -478,6 +483,7 @@ export const YES_COMMANDS: ReadonlySet<string> = new Set([
478
483
  "attachment delete",
479
484
  "auth logout",
480
485
  "comment delete",
486
+ "custom-view delete",
481
487
  "issue comment delete",
482
488
  "document delete",
483
489
  "favorite remove",
@@ -25,6 +25,11 @@ import { AGENT_SESSION_DETAIL_SHAPE, AGENT_SESSION_ROW_SHAPE } from "../services
25
25
  import { ATTACHMENT_ROW_SHAPE } from "../services/attachment.js";
26
26
  import { COMMENT_ROW_SHAPE } from "../services/comment.js";
27
27
  import { CYCLE_DETAIL_SHAPE, CYCLE_ROW_SHAPE } from "../services/cycle.js";
28
+ import {
29
+ CUSTOM_VIEW_DETAIL_SHAPE,
30
+ CUSTOM_VIEW_RESULT_ROW_SHAPE,
31
+ CUSTOM_VIEW_ROW_SHAPE,
32
+ } from "../services/custom-view.js";
28
33
  import type { DocumentDetail, DocumentRow, DocumentTargets } from "../services/document.js";
29
34
  import { FAVORITE_ROW_SHAPE } from "../services/favorite.js";
30
35
  import {
@@ -329,6 +334,26 @@ export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
329
334
  "cycle update": receipt({ id: "string", number: "number" }),
330
335
  "cycle view": CYCLE_VIEW,
331
336
 
337
+ "custom-view": runs("custom-view view", object(CUSTOM_VIEW_DETAIL_SHAPE)),
338
+ "custom-view create": receipt({
339
+ id: "string",
340
+ name: "string",
341
+ type: "string",
342
+ shared: "boolean",
343
+ slugId: "string",
344
+ }),
345
+ "custom-view delete": receipt(NAMED_DELETED),
346
+ "custom-view list": list(CUSTOM_VIEW_ROW_SHAPE),
347
+ "custom-view results": list(CUSTOM_VIEW_RESULT_ROW_SHAPE),
348
+ "custom-view update": receipt({
349
+ id: "string",
350
+ name: "string",
351
+ type: "string",
352
+ shared: "boolean",
353
+ slugId: "string",
354
+ }),
355
+ "custom-view view": object(CUSTOM_VIEW_DETAIL_SHAPE),
356
+
332
357
  document: runs("document view", object(DOCUMENT_DETAIL_SHAPE)),
333
358
  "document create": receipt({ id: "string", title: "string", url: "string" }),
334
359
  "document delete": receipt({ id: "string", title: "string", deleted: "boolean" }),