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.
- package/.claude-plugin/marketplace.json +20 -0
- package/.claude-plugin/plugin.json +9 -0
- package/CHANGELOG.md +807 -0
- package/COVERAGE.md +495 -0
- package/LICENSE +21 -0
- package/MIGRATING.md +220 -0
- package/README.md +524 -0
- package/package.json +87 -0
- package/skills/linear-sdk-cli/SKILL.md +534 -0
- package/skills/linear-sdk-cli/references/api.md +27 -0
- package/skills/linear-sdk-cli/references/attachment.md +82 -0
- package/skills/linear-sdk-cli/references/auth.md +174 -0
- package/skills/linear-sdk-cli/references/commands.md +37 -0
- package/skills/linear-sdk-cli/references/comment.md +154 -0
- package/skills/linear-sdk-cli/references/completion.md +17 -0
- package/skills/linear-sdk-cli/references/config.md +99 -0
- package/skills/linear-sdk-cli/references/cycle.md +131 -0
- package/skills/linear-sdk-cli/references/document.md +180 -0
- package/skills/linear-sdk-cli/references/favorite.md +78 -0
- package/skills/linear-sdk-cli/references/initiative-update.md +67 -0
- package/skills/linear-sdk-cli/references/initiative.md +256 -0
- package/skills/linear-sdk-cli/references/issue.md +928 -0
- package/skills/linear-sdk-cli/references/label.md +112 -0
- package/skills/linear-sdk-cli/references/milestone.md +139 -0
- package/skills/linear-sdk-cli/references/notification.md +123 -0
- package/skills/linear-sdk-cli/references/open.md +29 -0
- package/skills/linear-sdk-cli/references/organization.md +99 -0
- package/skills/linear-sdk-cli/references/project-update.md +67 -0
- package/skills/linear-sdk-cli/references/project.md +234 -0
- package/skills/linear-sdk-cli/references/roadmap.md +150 -0
- package/skills/linear-sdk-cli/references/schema.md +21 -0
- package/skills/linear-sdk-cli/references/state.md +61 -0
- package/skills/linear-sdk-cli/references/team.md +227 -0
- package/skills/linear-sdk-cli/references/user.md +103 -0
- package/skills/linear-sdk-cli/references/webhook.md +151 -0
- package/skills/linear-sdk-cli/references/whoami.md +26 -0
- package/skills/linear-sdk-cli/scripts/generate-docs.ts +339 -0
- package/src/bin/linear.ts +97 -0
- package/src/cli.ts +232 -0
- package/src/client.ts +172 -0
- package/src/commands/agent-session.ts +121 -0
- package/src/commands/api.ts +317 -0
- package/src/commands/attach.ts +90 -0
- package/src/commands/attachment.ts +74 -0
- package/src/commands/comment.ts +225 -0
- package/src/commands/completion.ts +69 -0
- package/src/commands/cycle.ts +125 -0
- package/src/commands/discover.ts +170 -0
- package/src/commands/document.ts +211 -0
- package/src/commands/favorite.ts +72 -0
- package/src/commands/initiative-update.ts +47 -0
- package/src/commands/initiative.ts +261 -0
- package/src/commands/issue.ts +1263 -0
- package/src/commands/label.ts +119 -0
- package/src/commands/meta.ts +534 -0
- package/src/commands/milestone.ts +163 -0
- package/src/commands/notification.ts +110 -0
- package/src/commands/open.ts +84 -0
- package/src/commands/organization.ts +79 -0
- package/src/commands/project-update.ts +47 -0
- package/src/commands/project.ts +339 -0
- package/src/commands/roadmap.ts +131 -0
- package/src/commands/state.ts +58 -0
- package/src/commands/team.ts +243 -0
- package/src/commands/user.ts +107 -0
- package/src/commands/webhook.ts +156 -0
- package/src/config.ts +1236 -0
- package/src/context.ts +117 -0
- package/src/git.ts +174 -0
- package/src/index.ts +10 -0
- package/src/lib/action.ts +26 -0
- package/src/lib/body.ts +172 -0
- package/src/lib/errors.ts +195 -0
- package/src/lib/introspect.ts +150 -0
- package/src/lib/keyring.ts +270 -0
- package/src/lib/mutation.ts +53 -0
- package/src/lib/open.ts +32 -0
- package/src/lib/options.ts +640 -0
- package/src/lib/output-shapes.ts +562 -0
- package/src/lib/pagination.ts +145 -0
- package/src/lib/prompt.ts +91 -0
- package/src/lib/resolve.ts +596 -0
- package/src/lib/shape.ts +273 -0
- package/src/lib/status-update.ts +91 -0
- package/src/lib/upload.ts +329 -0
- package/src/output/color.ts +52 -0
- package/src/output/format.ts +238 -0
- package/src/output/markdown.ts +133 -0
- package/src/output/pager.ts +131 -0
- package/src/output/sanitize.ts +76 -0
- package/src/output/table.ts +198 -0
- package/src/services/agent-session.ts +289 -0
- package/src/services/attachment.ts +198 -0
- package/src/services/comment.ts +262 -0
- package/src/services/cycle.ts +224 -0
- package/src/services/document.ts +357 -0
- package/src/services/favorite.ts +147 -0
- package/src/services/initiative-update.ts +59 -0
- package/src/services/initiative.ts +449 -0
- package/src/services/issue.ts +1153 -0
- package/src/services/label.ts +193 -0
- package/src/services/milestone.ts +262 -0
- package/src/services/notification.ts +190 -0
- package/src/services/organization.ts +144 -0
- package/src/services/project-update.ts +36 -0
- package/src/services/project.ts +494 -0
- package/src/services/roadmap.ts +177 -0
- package/src/services/state.ts +127 -0
- package/src/services/team.ts +386 -0
- package/src/services/user.ts +137 -0
- package/src/services/webhook.ts +203 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `linear comment` (alias `cm`) — manage comments by id beyond the basic
|
|
3
|
+
* `linear issue comment` add (Phase 1). Adds reply, update, delete, and
|
|
4
|
+
* resolve/unresolve, plus a standalone `list <issue>`.
|
|
5
|
+
*
|
|
6
|
+
* Comment ids are UUIDs; the `<issue>` argument accepts an identifier (TES-123)
|
|
7
|
+
* or a UUID and is resolved via resolveIssue.
|
|
8
|
+
*
|
|
9
|
+
* The four core verbs are built by factories rather than registered inline, so
|
|
10
|
+
* the `linear issue comment {add,list,update,delete}` subgroup (the reference
|
|
11
|
+
* CLI's layout) can mount the *same* handlers under `issue` without a second
|
|
12
|
+
* copy of the logic. Commander commands carry a parent pointer, so each mount
|
|
13
|
+
* point needs its own instance — hence factories, not shared constants.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Command } from "commander";
|
|
17
|
+
import { action } from "../lib/action.js";
|
|
18
|
+
import { resolveBody } from "../lib/body.js";
|
|
19
|
+
import { confirmDestructive } from "../lib/prompt.js";
|
|
20
|
+
import { usageError } from "../lib/errors.js";
|
|
21
|
+
import { collectArray } from "../lib/options.js";
|
|
22
|
+
import type { Context } from "../context.js";
|
|
23
|
+
import * as svc from "../services/comment.js";
|
|
24
|
+
import type { Column } from "../output/table.js";
|
|
25
|
+
|
|
26
|
+
const ROW_COLUMNS: Column<svc.CommentRow>[] = [
|
|
27
|
+
{ key: "createdAt", header: "Date", value: (r) => r.createdAt.slice(0, 10) },
|
|
28
|
+
{ key: "author", header: "Author", value: (r) => r.user?.displayName ?? "—", max: 18 },
|
|
29
|
+
{ key: "body", header: "Body", value: (r) => r.body.replace(/\n/g, " "), max: 70 },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/** Options for a mounted copy of the shared verbs. */
|
|
33
|
+
interface MountOptions {
|
|
34
|
+
/** Register the short aliases (`ls`, `edit`, `rm`). Off under `issue comment`. */
|
|
35
|
+
aliases?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildList(o: MountOptions): Command {
|
|
39
|
+
const cmd = new Command("list").argument("<issue>").description("List comments on an issue");
|
|
40
|
+
if (o.aliases !== false) cmd.alias("ls");
|
|
41
|
+
return cmd.action(
|
|
42
|
+
action(async (ctx: Context, _opts, issueArg: string) => {
|
|
43
|
+
const rows = await svc.listComments(ctx.client, issueArg, ctx.limit);
|
|
44
|
+
ctx.output.list(rows, ROW_COLUMNS, rows);
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildAdd(_o: MountOptions): Command {
|
|
50
|
+
return new Command("add")
|
|
51
|
+
.argument("<issue>")
|
|
52
|
+
.argument("[body]")
|
|
53
|
+
.description("Add a comment to an issue (images uploaded with --attach render inline)")
|
|
54
|
+
.option("--body-file <path>", "read comment body from a file ('-' = stdin)")
|
|
55
|
+
.option(
|
|
56
|
+
"--attach <file>",
|
|
57
|
+
"upload a file and embed it in the comment (images inline; repeatable; private by default)",
|
|
58
|
+
collectArray,
|
|
59
|
+
)
|
|
60
|
+
.option(
|
|
61
|
+
"--public",
|
|
62
|
+
"upload the attachments to public, world-readable URLs (raster images only)",
|
|
63
|
+
)
|
|
64
|
+
.action(
|
|
65
|
+
action(async (ctx: Context, opts, issueArg: string, bodyArg?: string) => {
|
|
66
|
+
const attachments: string[] = opts.attach ?? [];
|
|
67
|
+
if (opts.public && attachments.length === 0) {
|
|
68
|
+
throw usageError("--public applies to uploads; add --attach <file>, or drop --public.");
|
|
69
|
+
}
|
|
70
|
+
// With attachments the body is optional (the embeds are the comment),
|
|
71
|
+
// so the editor is not opened for one — the reference CLI's rule too.
|
|
72
|
+
const body = resolveBody({
|
|
73
|
+
arg: bodyArg,
|
|
74
|
+
file: opts.bodyFile,
|
|
75
|
+
interactive: ctx.isTTY && attachments.length === 0,
|
|
76
|
+
});
|
|
77
|
+
if (!body && attachments.length === 0) throw usageError("No comment body provided.");
|
|
78
|
+
const {
|
|
79
|
+
issue,
|
|
80
|
+
comment: created,
|
|
81
|
+
uploads,
|
|
82
|
+
} = await svc.addComment(ctx.client, issueArg, body ?? "", {
|
|
83
|
+
attachments,
|
|
84
|
+
public: opts.public === true,
|
|
85
|
+
});
|
|
86
|
+
for (const u of uploads) {
|
|
87
|
+
if (u.public)
|
|
88
|
+
ctx.output.warn(`${u.filename} is on a public URL, readable by anyone: ${u.assetUrl}`);
|
|
89
|
+
}
|
|
90
|
+
ctx.output.emit(
|
|
91
|
+
{
|
|
92
|
+
id: created.id,
|
|
93
|
+
issue: issue.identifier,
|
|
94
|
+
url: created.url,
|
|
95
|
+
...(uploads.length
|
|
96
|
+
? {
|
|
97
|
+
attachments: uploads.map((u) => ({
|
|
98
|
+
filename: u.filename,
|
|
99
|
+
assetUrl: u.assetUrl,
|
|
100
|
+
contentType: u.contentType,
|
|
101
|
+
size: u.size,
|
|
102
|
+
})),
|
|
103
|
+
}
|
|
104
|
+
: {}),
|
|
105
|
+
},
|
|
106
|
+
() => ctx.output.success(`Commented on ${issue.identifier}`),
|
|
107
|
+
);
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildUpdate(o: MountOptions): Command {
|
|
113
|
+
const cmd = new Command("update")
|
|
114
|
+
.argument("<commentId>")
|
|
115
|
+
.argument("[body]")
|
|
116
|
+
.description("Update a comment's body")
|
|
117
|
+
.option("--body-file <path>", "read new body from a file ('-' = stdin)");
|
|
118
|
+
if (o.aliases !== false) cmd.alias("edit");
|
|
119
|
+
return cmd.action(
|
|
120
|
+
action(async (ctx: Context, opts, commentId: string, bodyArg?: string) => {
|
|
121
|
+
// Fetch the comment first: the editor opens ON the current body (so
|
|
122
|
+
// quitting without typing cannot blank it), a missing id fails before
|
|
123
|
+
// anyone writes anything, and the service can refuse a no-op update.
|
|
124
|
+
const current = await svc.getComment(ctx.client, commentId);
|
|
125
|
+
const body = resolveBody({
|
|
126
|
+
arg: bodyArg,
|
|
127
|
+
file: opts.bodyFile,
|
|
128
|
+
interactive: ctx.isTTY,
|
|
129
|
+
template: current.body,
|
|
130
|
+
});
|
|
131
|
+
if (body === undefined) throw usageError("No comment body provided.");
|
|
132
|
+
// The editor path hands back a trimmed body, so compare against the
|
|
133
|
+
// trimmed original: an untouched session must read as "unchanged".
|
|
134
|
+
const updated = await svc.updateComment(ctx.client, commentId, body, current.body.trim());
|
|
135
|
+
ctx.output.emit({ id: updated.id, url: updated.url }, () =>
|
|
136
|
+
ctx.output.success(`Updated comment ${updated.id}`),
|
|
137
|
+
);
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function buildDelete(o: MountOptions): Command {
|
|
143
|
+
const cmd = new Command("delete").argument("<commentId>").description("Delete a comment");
|
|
144
|
+
if (o.aliases !== false) cmd.alias("rm");
|
|
145
|
+
return cmd.action(
|
|
146
|
+
action(async (ctx: Context, _opts, commentId: string) => {
|
|
147
|
+
if (!(await confirmDestructive(ctx, `Delete comment ${commentId}?`))) return;
|
|
148
|
+
const deleted = await svc.deleteComment(ctx.client, commentId);
|
|
149
|
+
ctx.output.emit({ id: deleted.id, deleted: true }, () =>
|
|
150
|
+
ctx.output.success(`Deleted comment ${deleted.id}`),
|
|
151
|
+
);
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The four verbs the reference CLI mounts under `issue comment`. */
|
|
157
|
+
export const SHARED_COMMENT_VERBS = {
|
|
158
|
+
add: buildAdd,
|
|
159
|
+
list: buildList,
|
|
160
|
+
update: buildUpdate,
|
|
161
|
+
delete: buildDelete,
|
|
162
|
+
} as const;
|
|
163
|
+
|
|
164
|
+
export function registerComment(program: Command): void {
|
|
165
|
+
const comment = program.command("comment").alias("cm").description("Manage comments");
|
|
166
|
+
|
|
167
|
+
// add / list / update / delete — shared with `issue comment` -----------------
|
|
168
|
+
for (const build of Object.values(SHARED_COMMENT_VERBS)) comment.addCommand(build({}));
|
|
169
|
+
|
|
170
|
+
// reply -------------------------------------------------------------------
|
|
171
|
+
comment
|
|
172
|
+
.command("reply <commentId> [body]")
|
|
173
|
+
.description("Reply to a comment (nested under it)")
|
|
174
|
+
.option("--body-file <path>", "read reply body from a file ('-' = stdin)")
|
|
175
|
+
.action(
|
|
176
|
+
action(async (ctx: Context, opts, commentId: string, bodyArg?: string) => {
|
|
177
|
+
const body = resolveBody({ arg: bodyArg, file: opts.bodyFile, interactive: ctx.isTTY });
|
|
178
|
+
if (!body) throw usageError("No reply body provided.");
|
|
179
|
+
const { comment: created, issue } = await svc.replyToComment(ctx.client, commentId, body);
|
|
180
|
+
ctx.output.emit(
|
|
181
|
+
{ id: created.id, parent: commentId, issue: issue?.identifier ?? null, url: created.url },
|
|
182
|
+
() => ctx.output.success(`Replied to comment${issue ? ` on ${issue.identifier}` : ""}`),
|
|
183
|
+
);
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// resolve / unresolve -----------------------------------------------------
|
|
188
|
+
comment
|
|
189
|
+
.command("resolve <commentId>")
|
|
190
|
+
.description("Resolve a comment thread")
|
|
191
|
+
.action(
|
|
192
|
+
action(async (ctx: Context, _opts, commentId: string) => {
|
|
193
|
+
const c = await svc.setResolved(ctx.client, commentId, true);
|
|
194
|
+
ctx.output.emit({ id: c.id, resolved: true }, () =>
|
|
195
|
+
ctx.output.success(`Resolved comment ${c.id}`),
|
|
196
|
+
);
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
comment
|
|
201
|
+
.command("unresolve <commentId>")
|
|
202
|
+
.description("Unresolve a comment thread")
|
|
203
|
+
.action(
|
|
204
|
+
action(async (ctx: Context, _opts, commentId: string) => {
|
|
205
|
+
const c = await svc.setResolved(ctx.client, commentId, false);
|
|
206
|
+
ctx.output.emit({ id: c.id, resolved: false }, () =>
|
|
207
|
+
ctx.output.success(`Unresolved comment ${c.id}`),
|
|
208
|
+
);
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Mount `add`/`list`/`update`/`delete` under the existing `issue comment`
|
|
215
|
+
* command, which keeps its own `[id] [body]` "add a comment" behavior for every
|
|
216
|
+
* other operand. The short aliases (`ls`, `edit`, `rm`) are deliberately NOT
|
|
217
|
+
* registered here: each subcommand name shadows a one-word comment body under
|
|
218
|
+
* the parent, so the collision surface stays at the four names the reference
|
|
219
|
+
* actually ships.
|
|
220
|
+
*/
|
|
221
|
+
export function registerIssueCommentGroup(issueComment: Command): void {
|
|
222
|
+
for (const build of Object.values(SHARED_COMMENT_VERBS)) {
|
|
223
|
+
issueComment.addCommand(build({ aliases: false }));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `linear completion <shell>` — print a shell completion script to stdout.
|
|
3
|
+
*
|
|
4
|
+
* These are static, hand-written completions that defer to the binary for help.
|
|
5
|
+
* Kept intentionally simple (top-level commands + common flags); they are
|
|
6
|
+
* generated from the live command tree so they don't drift.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Command } from "commander";
|
|
10
|
+
import { action } from "../lib/action.js";
|
|
11
|
+
import { usageError } from "../lib/errors.js";
|
|
12
|
+
|
|
13
|
+
export type Shell = "bash" | "zsh" | "fish";
|
|
14
|
+
|
|
15
|
+
/** Collect top-level command names from the root program. */
|
|
16
|
+
export function topLevelCommands(program: Command): string[] {
|
|
17
|
+
return program.commands
|
|
18
|
+
.map((c) => c.name())
|
|
19
|
+
.filter((n) => n && n !== "completion")
|
|
20
|
+
.sort();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function completionScript(shell: Shell, commands: string[]): string {
|
|
24
|
+
const list = commands.join(" ");
|
|
25
|
+
switch (shell) {
|
|
26
|
+
case "bash":
|
|
27
|
+
return `# linear bash completion. Add to ~/.bashrc: source <(linear completion bash)
|
|
28
|
+
_linear_completions() {
|
|
29
|
+
local cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
30
|
+
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
31
|
+
COMPREPLY=( $(compgen -W "${list}" -- "$cur") )
|
|
32
|
+
fi
|
|
33
|
+
}
|
|
34
|
+
complete -F _linear_completions linear lin
|
|
35
|
+
`;
|
|
36
|
+
case "zsh":
|
|
37
|
+
return `#compdef linear lin
|
|
38
|
+
# linear zsh completion. Add to your fpath or: source <(linear completion zsh)
|
|
39
|
+
_linear() {
|
|
40
|
+
local -a cmds
|
|
41
|
+
cmds=(${commands.map((c) => `'${c}'`).join(" ")})
|
|
42
|
+
if (( CURRENT == 2 )); then
|
|
43
|
+
_describe 'command' cmds
|
|
44
|
+
fi
|
|
45
|
+
}
|
|
46
|
+
compdef _linear linear lin
|
|
47
|
+
`;
|
|
48
|
+
case "fish":
|
|
49
|
+
return `# linear fish completion: linear completion fish > ~/.config/fish/completions/linear.fish
|
|
50
|
+
${commands.map((c) => `complete -c linear -n '__fish_use_subcommand' -a '${c}'`).join("\n")}
|
|
51
|
+
${commands.map((c) => `complete -c lin -n '__fish_use_subcommand' -a '${c}'`).join("\n")}
|
|
52
|
+
`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function registerCompletion(program: Command): void {
|
|
57
|
+
program
|
|
58
|
+
.command("completion <shell>")
|
|
59
|
+
.description("Output a shell completion script (bash|zsh|fish)")
|
|
60
|
+
.action(
|
|
61
|
+
action(async (ctx, _opts, shell: string) => {
|
|
62
|
+
if (shell !== "bash" && shell !== "zsh" && shell !== "fish") {
|
|
63
|
+
throw usageError(`Unsupported shell '${shell}'. Use bash, zsh, or fish.`);
|
|
64
|
+
}
|
|
65
|
+
const script = completionScript(shell, topLevelCommands(program));
|
|
66
|
+
process.stdout.write(script);
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `linear cycle` (alias `c`) — work with team cycles (sprints).
|
|
3
|
+
*
|
|
4
|
+
* A team is required for list/current/create; it defaults to ctx.defaultTeam
|
|
5
|
+
* when the optional [team] argument is omitted. `view`/`update` take a cycle id
|
|
6
|
+
* (UUID), or a number/`current` when a team is in scope.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Command } from "commander";
|
|
10
|
+
import { action } from "../lib/action.js";
|
|
11
|
+
import type { Context } from "../context.js";
|
|
12
|
+
import * as svc from "../services/cycle.js";
|
|
13
|
+
import type { Column } from "../output/table.js";
|
|
14
|
+
|
|
15
|
+
const ROW_COLUMNS: Column<svc.CycleRow>[] = [
|
|
16
|
+
{ key: "number", header: "#", value: (r) => r.number },
|
|
17
|
+
{ key: "name", header: "Name", value: (r) => r.name ?? "—", max: 24 },
|
|
18
|
+
{ key: "startsAt", header: "Starts", value: (r) => r.startsAt.slice(0, 10) },
|
|
19
|
+
{ key: "endsAt", header: "Ends", value: (r) => r.endsAt.slice(0, 10) },
|
|
20
|
+
{ key: "progress", header: "Progress", value: (r) => svc.formatProgress(r.progress) },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export function registerCycle(program: Command): void {
|
|
24
|
+
const cycle = program.command("cycle").alias("c").description("Work with cycles");
|
|
25
|
+
|
|
26
|
+
// list --------------------------------------------------------------------
|
|
27
|
+
cycle
|
|
28
|
+
.command("list [team]")
|
|
29
|
+
.alias("ls")
|
|
30
|
+
.description("List cycles for a team (defaults to the configured team)")
|
|
31
|
+
.action(
|
|
32
|
+
action(async (ctx: Context, _opts, teamArg?: string) => {
|
|
33
|
+
const rows = await svc.listCycles(ctx.client, teamArg, ctx.limit, ctx.defaultTeam);
|
|
34
|
+
ctx.output.list(rows, ROW_COLUMNS, rows);
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// view --------------------------------------------------------------------
|
|
39
|
+
cycle
|
|
40
|
+
.command("view <id>")
|
|
41
|
+
.description("Show a cycle (by id, number, or 'current')")
|
|
42
|
+
.action(
|
|
43
|
+
action(async (ctx: Context, opts, id: string) => {
|
|
44
|
+
const detail = await svc.getCycleDetail(
|
|
45
|
+
ctx.client,
|
|
46
|
+
id,
|
|
47
|
+
opts.team ?? ctx.defaultTeam,
|
|
48
|
+
ctx.defaultTeam,
|
|
49
|
+
);
|
|
50
|
+
emitDetail(ctx, detail);
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// current -----------------------------------------------------------------
|
|
55
|
+
cycle
|
|
56
|
+
.command("current [team]")
|
|
57
|
+
.description("Show the team's currently active cycle")
|
|
58
|
+
.action(
|
|
59
|
+
action(async (ctx: Context, _opts, teamArg?: string) => {
|
|
60
|
+
const detail = await svc.getCurrentCycle(ctx.client, teamArg, ctx.defaultTeam);
|
|
61
|
+
emitDetail(ctx, detail);
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// create ------------------------------------------------------------------
|
|
66
|
+
cycle
|
|
67
|
+
.command("create [team]")
|
|
68
|
+
.alias("new")
|
|
69
|
+
.description("Create a cycle")
|
|
70
|
+
.requiredOption("--start <date>", "start date/time (ISO, e.g. 2026-07-01)")
|
|
71
|
+
.requiredOption("--end <date>", "end date/time (ISO, e.g. 2026-07-14)")
|
|
72
|
+
.option("--name <name>", "custom cycle name")
|
|
73
|
+
.action(
|
|
74
|
+
action(async (ctx: Context, opts, teamArg?: string) => {
|
|
75
|
+
const created = await svc.createCycle(
|
|
76
|
+
ctx.client,
|
|
77
|
+
{
|
|
78
|
+
team: teamArg ?? opts.team,
|
|
79
|
+
name: opts.name,
|
|
80
|
+
startsAt: opts.start,
|
|
81
|
+
endsAt: opts.end,
|
|
82
|
+
},
|
|
83
|
+
ctx.defaultTeam,
|
|
84
|
+
);
|
|
85
|
+
ctx.output.emit({ id: created.id, number: created.number }, () =>
|
|
86
|
+
ctx.output.success(`Created cycle #${created.number}`),
|
|
87
|
+
);
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// update ------------------------------------------------------------------
|
|
92
|
+
cycle
|
|
93
|
+
.command("update <id>")
|
|
94
|
+
.alias("edit")
|
|
95
|
+
.description("Update a cycle (by id, number, or 'current')")
|
|
96
|
+
.option("--name <name>", "custom cycle name")
|
|
97
|
+
.option("--start <date>", "start date/time (ISO)")
|
|
98
|
+
.option("--end <date>", "end date/time (ISO)")
|
|
99
|
+
.action(
|
|
100
|
+
action(async (ctx: Context, opts, id: string) => {
|
|
101
|
+
const updated = await svc.updateCycle(
|
|
102
|
+
ctx.client,
|
|
103
|
+
id,
|
|
104
|
+
{ name: opts.name, startsAt: opts.start, endsAt: opts.end },
|
|
105
|
+
opts.team ?? ctx.defaultTeam,
|
|
106
|
+
ctx.defaultTeam,
|
|
107
|
+
);
|
|
108
|
+
ctx.output.emit({ id: updated.id, number: updated.number }, () =>
|
|
109
|
+
ctx.output.success(`Updated cycle #${updated.number}`),
|
|
110
|
+
);
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function emitDetail(ctx: Context, detail: svc.CycleDetail): void {
|
|
116
|
+
ctx.output.detail(detail, [
|
|
117
|
+
["Cycle", `#${detail.number}${detail.name ? ` ${detail.name}` : ""}`],
|
|
118
|
+
["Team", detail.team],
|
|
119
|
+
["Starts", detail.startsAt],
|
|
120
|
+
["Ends", detail.endsAt],
|
|
121
|
+
["Completed", detail.completedAt],
|
|
122
|
+
["Progress", svc.formatProgress(detail.progress)],
|
|
123
|
+
["Description", detail.description ? `\n${detail.description}` : null],
|
|
124
|
+
]);
|
|
125
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovery commands for agents and scripts:
|
|
3
|
+
* - `linear commands` — a machine-readable tree of every (sub)command.
|
|
4
|
+
* - `linear schema` — the Linear GraphQL schema as SDL (or raw introspection
|
|
5
|
+
* with --json). `-o <file>` chooses the destination for either format.
|
|
6
|
+
*
|
|
7
|
+
* Both are registered last (in cli.ts) so they can introspect the full program.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Command } from "commander";
|
|
11
|
+
import { writeFileSync } from "node:fs";
|
|
12
|
+
import { buildClientSchema, getIntrospectionQuery, printSchema } from "graphql";
|
|
13
|
+
import { action } from "../lib/action.js";
|
|
14
|
+
import { withRetry } from "../client.js";
|
|
15
|
+
import { walkCommands, type CommandNode } from "../lib/introspect.js";
|
|
16
|
+
import { renderOutputShape } from "../lib/shape.js";
|
|
17
|
+
import { usageError, notFound, CliError } from "../lib/errors.js";
|
|
18
|
+
import type { Context } from "../context.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `linear commands [path...]` — the command tree (bare array in --json), or one
|
|
22
|
+
* command by path (bare object) with its options and, since TES-610, the shape
|
|
23
|
+
* of what it prints under `--json`: `linear commands issue list --json | jq
|
|
24
|
+
* .output.fields` says a row has `.state.name` before anything is run.
|
|
25
|
+
*/
|
|
26
|
+
export function registerCommands(program: Command): void {
|
|
27
|
+
program
|
|
28
|
+
.command("commands [path...]")
|
|
29
|
+
.description(
|
|
30
|
+
"List every command in a machine-readable tree, or describe one (for scripts/agents)",
|
|
31
|
+
)
|
|
32
|
+
.addHelpText(
|
|
33
|
+
"after",
|
|
34
|
+
[
|
|
35
|
+
"",
|
|
36
|
+
"Examples:",
|
|
37
|
+
" linear commands --json | jq -r '.[].path'",
|
|
38
|
+
" linear commands issue list # options + the --json row shape",
|
|
39
|
+
" linear commands issue view --json | jq '.output.fields'",
|
|
40
|
+
" linear commands --json | jq '.[] | select(.output.kind==\"list\") | .path'",
|
|
41
|
+
].join("\n"),
|
|
42
|
+
)
|
|
43
|
+
.action(
|
|
44
|
+
action(async (ctx: Context, _opts, pathWords: string[] = []) => {
|
|
45
|
+
const nodes = walkCommands(program);
|
|
46
|
+
if (pathWords.length === 0) {
|
|
47
|
+
// Bare array in --json; a compact indented listing otherwise.
|
|
48
|
+
ctx.output.emit(nodes, () => renderTree(ctx, nodes));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const path = pathWords.join(" ");
|
|
52
|
+
const node = nodes.find((n) => n.path === path);
|
|
53
|
+
if (!node) {
|
|
54
|
+
const near = nodes.filter((n) => n.path.startsWith(path)).map((n) => n.path);
|
|
55
|
+
throw notFound(
|
|
56
|
+
`No command '${path}'.${near.length ? ` Did you mean: ${near.slice(0, 5).join(", ")}?` : " Try 'linear commands' for the list."}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
// One command → a bare object in --json (its `output` is the shape a
|
|
60
|
+
// script wants), a full description otherwise.
|
|
61
|
+
ctx.output.emit(node, () => {
|
|
62
|
+
renderOne(ctx, node);
|
|
63
|
+
const children = nodes.filter((n) => n.path.startsWith(`${path} `));
|
|
64
|
+
if (children.length) {
|
|
65
|
+
ctx.output.line();
|
|
66
|
+
ctx.output.line("Subcommands:");
|
|
67
|
+
renderTree(ctx, children, path.split(" ").length);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `<id> [extra...]` from a node's arguments. */
|
|
75
|
+
function usageArgs(n: CommandNode): string {
|
|
76
|
+
return n.arguments
|
|
77
|
+
.map((a) => (a.required ? `<${a.name}>` : `[${a.name}]`) + (a.variadic ? "..." : ""))
|
|
78
|
+
.join(" ");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function renderTree(ctx: Context, nodes: CommandNode[], baseDepth = 0): void {
|
|
82
|
+
for (const n of nodes) {
|
|
83
|
+
const depth = n.path.split(" ").length - 1 - baseDepth;
|
|
84
|
+
const indent = " ".repeat(depth);
|
|
85
|
+
const head = [n.path, usageArgs(n)].filter(Boolean).join(" ");
|
|
86
|
+
const alias = n.aliases.length ? ` (${n.aliases.join(", ")})` : "";
|
|
87
|
+
ctx.output.line(`${indent}${head}${alias}`);
|
|
88
|
+
if (n.description) ctx.output.line(`${indent} ${n.description}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One command in full: usage, aliases, options, and the `--json` output shape. */
|
|
93
|
+
function renderOne(ctx: Context, n: CommandNode): void {
|
|
94
|
+
ctx.output.line(
|
|
95
|
+
`Usage: linear ${[n.path, n.options.length ? "[options]" : "", usageArgs(n)].filter(Boolean).join(" ")}`,
|
|
96
|
+
);
|
|
97
|
+
if (n.description) ctx.output.line(` ${n.description}`);
|
|
98
|
+
if (n.aliases.length) ctx.output.line(`Aliases: ${n.aliases.join(", ")}`);
|
|
99
|
+
if (n.options.length) {
|
|
100
|
+
ctx.output.line();
|
|
101
|
+
ctx.output.line("Options:");
|
|
102
|
+
const width = Math.max(...n.options.map((o) => o.flags.length));
|
|
103
|
+
for (const o of n.options) ctx.output.line(` ${o.flags.padEnd(width)} ${o.description}`);
|
|
104
|
+
}
|
|
105
|
+
ctx.output.line();
|
|
106
|
+
if (n.output) {
|
|
107
|
+
const [head, ...rest] = renderOutputShape(n.output);
|
|
108
|
+
ctx.output.line(`Output (--json): ${head}`);
|
|
109
|
+
for (const line of rest) ctx.output.line(line);
|
|
110
|
+
} else {
|
|
111
|
+
ctx.output.line("Output (--json): none of its own (see its subcommands)");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `linear schema` — dump the GraphQL schema as SDL (or raw introspection). */
|
|
116
|
+
export function registerSchema(program: Command): void {
|
|
117
|
+
program
|
|
118
|
+
.command("schema")
|
|
119
|
+
.description("Print the Linear GraphQL schema as SDL (--json prints raw introspection)")
|
|
120
|
+
.option("-o, --output <file>", "write to a file instead of stdout")
|
|
121
|
+
.addHelpText(
|
|
122
|
+
"after",
|
|
123
|
+
[
|
|
124
|
+
"",
|
|
125
|
+
"Examples:",
|
|
126
|
+
" linear schema -o /tmp/linear.graphql && grep 'type Issue' /tmp/linear.graphql",
|
|
127
|
+
" linear schema | less",
|
|
128
|
+
" linear schema --json | jq '.__schema.types | length'",
|
|
129
|
+
" linear schema --json -o /tmp/introspection.json",
|
|
130
|
+
].join("\n"),
|
|
131
|
+
)
|
|
132
|
+
.action(action(runSchema));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The `schema` action body, exported so tests can drive it against a fake client. */
|
|
136
|
+
export async function runSchema(ctx: Context, opts: Record<string, any>): Promise<void> {
|
|
137
|
+
const result: any = await withRetry(() =>
|
|
138
|
+
(ctx.client.client as any).rawRequest(getIntrospectionQuery()),
|
|
139
|
+
);
|
|
140
|
+
const introspection = result.data;
|
|
141
|
+
if (!introspection) throw usageError("Introspection returned no schema data.");
|
|
142
|
+
|
|
143
|
+
// Format and destination are independent: --json picks the format, -o picks
|
|
144
|
+
// where it lands. (The JSON branch used to return before reaching the write,
|
|
145
|
+
// so `--json -o file` printed to stdout and left no file.)
|
|
146
|
+
const json = ctx.output.json;
|
|
147
|
+
if (opts.output) {
|
|
148
|
+
const body = json
|
|
149
|
+
? JSON.stringify(introspection, null, 2)
|
|
150
|
+
: printSchema(buildClientSchema(introspection));
|
|
151
|
+
writeSchemaFile(opts.output, body + "\n");
|
|
152
|
+
ctx.output.success(`Wrote ${json ? "introspection JSON" : "schema"} to ${opts.output}`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (json) {
|
|
157
|
+
// Raw introspection result — bare object on stdout.
|
|
158
|
+
ctx.output.emit(introspection, () => {});
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
ctx.output.line(printSchema(buildClientSchema(introspection)));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function writeSchemaFile(path: string, body: string): void {
|
|
165
|
+
try {
|
|
166
|
+
writeFileSync(path, body, "utf8");
|
|
167
|
+
} catch (err) {
|
|
168
|
+
throw new CliError(`Cannot write to '${path}': ${(err as Error).message}`, "runtime");
|
|
169
|
+
}
|
|
170
|
+
}
|