@asteroidai/cli 0.0.0 → 1.8.197

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/src/http.ts ADDED
@@ -0,0 +1,115 @@
1
+ import { type Auth, UNSET_API_KEY } from "./auth/credentials";
2
+ import type { BuiltRequest } from "./request";
3
+
4
+ export class ApiError extends Error {
5
+ constructor(
6
+ readonly status: number,
7
+ message: string,
8
+ readonly body: unknown,
9
+ ) {
10
+ super(message);
11
+ }
12
+ }
13
+
14
+ export interface ApiResponse {
15
+ status: number;
16
+ json?: unknown;
17
+ text?: string;
18
+ bytes?: Uint8Array;
19
+ }
20
+
21
+ export const VERSION = process.env.ASTEROID_CLI_VERSION ?? "dev";
22
+
23
+ function problem(status: number, body: unknown, fallback: string): string {
24
+ if (body && typeof body === "object") {
25
+ const record = body as Record<string, unknown>;
26
+ for (const key of ["detail", "message", "error", "title"]) {
27
+ if (typeof record[key] === "string" && record[key])
28
+ return record[key] as string;
29
+ }
30
+ }
31
+ return fallback.trim() || `HTTP ${status}`;
32
+ }
33
+
34
+ export async function send(
35
+ apiUrl: string,
36
+ auth: Auth,
37
+ request: BuiltRequest,
38
+ binary: boolean,
39
+ fetchImpl: typeof fetch = fetch,
40
+ ): Promise<ApiResponse> {
41
+ const query = request.query.toString();
42
+ const url = `${apiUrl}${request.path}${query ? `?${query}` : ""}`;
43
+ const headers: Record<string, string> = {
44
+ ...auth.headers,
45
+ Accept: binary ? "*/*" : "application/json",
46
+ "User-Agent": `asteroid-cli/${VERSION}`,
47
+ "X-Request-Source": "asteroid-cli",
48
+ };
49
+ let body: BodyInit | undefined;
50
+ if (request.form) body = request.form;
51
+ else if (request.json !== undefined) {
52
+ headers["Content-Type"] = "application/json";
53
+ body = JSON.stringify(request.json);
54
+ }
55
+
56
+ let response = await fetchImpl(url, {
57
+ method: request.method,
58
+ headers,
59
+ body,
60
+ redirect: "manual",
61
+ });
62
+ let current = new URL(url);
63
+ let hopHeaders: Record<string, string> = { ...headers };
64
+ let method: string = request.method;
65
+ for (let hop = 0; hop < 5; hop++) {
66
+ const location = response.headers.get("location");
67
+ if (response.status < 300 || response.status >= 400 || !location) break;
68
+ const target = new URL(location, current);
69
+ if (target.origin !== current.origin) {
70
+ hopHeaders = { "User-Agent": headers["User-Agent"] };
71
+ if (headers["Content-Type"])
72
+ hopHeaders["Content-Type"] = headers["Content-Type"];
73
+ }
74
+ current = target;
75
+ const keepMethod = response.status === 307 || response.status === 308;
76
+ if (!keepMethod && method !== "HEAD") {
77
+ method = "GET";
78
+ body = undefined;
79
+ delete hopHeaders["Content-Type"];
80
+ }
81
+ response = await fetchImpl(target, {
82
+ method,
83
+ headers: hopHeaders,
84
+ body,
85
+ redirect: "manual",
86
+ });
87
+ }
88
+ const contentType = response.headers.get("content-type") ?? "";
89
+
90
+ if (!response.ok) {
91
+ const text = await response.text();
92
+ let parsed: unknown = text;
93
+ try {
94
+ parsed = JSON.parse(text);
95
+ } catch {
96
+ parsed = text;
97
+ }
98
+ let message = problem(response.status, parsed, text.slice(0, 200));
99
+ const sentKey = "X-Api-Key" in hopHeaders;
100
+ if (sentKey && response.status === 401)
101
+ message = `ASTEROID_API_KEY was rejected: ${message}. Check the key. ${UNSET_API_KEY}`;
102
+ else if (sentKey && response.status === 403)
103
+ message = `${message} (sent with ASTEROID_API_KEY. ${UNSET_API_KEY})`;
104
+ throw new ApiError(response.status, message, parsed);
105
+ }
106
+ if (binary)
107
+ return {
108
+ status: response.status,
109
+ bytes: new Uint8Array(await response.arrayBuffer()),
110
+ };
111
+ if (response.status === 204) return { status: 204 };
112
+ if (contentType.includes("json"))
113
+ return { status: response.status, json: await response.json() };
114
+ return { status: response.status, text: await response.text() };
115
+ }
package/src/main.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ import { readEnv } from "./config";
4
+ import { COMMANDS } from "./generated/commands";
5
+ import { runCompletion } from "./program";
6
+ import { BUILTINS } from "./reference";
7
+ import { run } from "./run";
8
+ import { clackUi } from "./ui";
9
+
10
+ function openUrl(url: string) {
11
+ if (process.env.ASTEROID_NO_BROWSER || !/^https:\/\/[^\s"'<>^&|]+$/.test(url))
12
+ return;
13
+ const [command, args] =
14
+ process.platform === "darwin"
15
+ ? ["open", [url]]
16
+ : process.platform === "win32"
17
+ ? ["rundll32", ["url.dll,FileProtocolHandler", url]]
18
+ : ["xdg-open", [url]];
19
+ try {
20
+ spawn(command, args, { stdio: "ignore", detached: true })
21
+ .on("error", () => undefined)
22
+ .unref();
23
+ } catch {
24
+ return;
25
+ }
26
+ }
27
+
28
+ const argv = process.argv.slice(2);
29
+ if (argv[0] === "complete") {
30
+ await runCompletion(argv, { commands: COMMANDS, builtins: BUILTINS });
31
+ process.exit(0);
32
+ }
33
+
34
+ const code = await run(argv, {
35
+ env: readEnv(),
36
+ cwd: process.cwd(),
37
+ fetch,
38
+ stdout: (text) => process.stdout.write(text),
39
+ stderr: (text) => process.stderr.write(text),
40
+ writeBytes: (bytes) => process.stdout.write(bytes),
41
+ ui: process.stdin.isTTY && process.stderr.isTTY ? clackUi : undefined,
42
+ stdoutIsTTY: process.stdout.isTTY === true,
43
+ styled:
44
+ process.stdout.isTTY === true &&
45
+ !process.env.NO_COLOR &&
46
+ process.env.TERM !== "dumb",
47
+ openUrl,
48
+ });
49
+ process.exitCode = code;
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { clean, render } from "./output";
4
+
5
+ describe("clean", () => {
6
+ it("strips escape sequences and control characters, keeps newlines and tabs", () => {
7
+ const hostile =
8
+ "a\u001b[31mred\u001b[0m\u001b]0;title\u0007b\rc\u0008d\te\nf\u009bg";
9
+ expect(clean(hostile)).toBe("aredbcd\te\nfg");
10
+ });
11
+ });
12
+
13
+ describe("render", () => {
14
+ it("cleans server text in tables and records", () => {
15
+ const plain = render({ items: [{ id: "1", name: "x\u001b[2Jy" }] });
16
+ expect(plain).toContain("xy");
17
+ expect(plain).not.toContain("\u001b");
18
+ expect(render({ note: "a\u0007b" })).toContain("ab");
19
+ });
20
+
21
+ it("draws a bordered table only when styled", () => {
22
+ const rows = [{ id: "1", name: "Ada" }];
23
+ expect(render(rows)).not.toContain("┌");
24
+ expect(render(rows, { styled: true })).toContain("┌");
25
+ });
26
+ });
package/src/output.ts ADDED
@@ -0,0 +1,153 @@
1
+ import Table from "cli-table3";
2
+ import { styleText } from "node:util";
3
+
4
+ const PREFERRED = [
5
+ "id",
6
+ "name",
7
+ "title",
8
+ "workflowName",
9
+ "status",
10
+ "outcome",
11
+ "version",
12
+ "createdAt",
13
+ "updatedAt",
14
+ ];
15
+ const HIDDEN = /^agent[A-Z]|Url$/;
16
+ const MAX_COLUMNS = 6;
17
+ const MAX_CELL = 60;
18
+ const CONTROL =
19
+ /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b[@-_]?|[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
20
+
21
+ type Row = Record<string, unknown>;
22
+
23
+ export interface RenderOptions {
24
+ styled?: boolean;
25
+ }
26
+
27
+ export function clean(text: string): string {
28
+ return text.replace(CONTROL, "");
29
+ }
30
+
31
+ function isObject(value: unknown): boolean {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+
35
+ function isScalar(value: unknown): boolean {
36
+ return (
37
+ value === null || ["string", "number", "boolean"].includes(typeof value)
38
+ );
39
+ }
40
+
41
+ function indent(text: string): string {
42
+ return text
43
+ .split("\n")
44
+ .map((line) => ` ${line}`)
45
+ .join("\n");
46
+ }
47
+
48
+ function text(value: unknown): string {
49
+ return clean(isScalar(value) ? String(value) : JSON.stringify(value));
50
+ }
51
+
52
+ function cell(value: unknown): string {
53
+ if (value === null || value === undefined) return "";
54
+ const line = text(value).replace(/\s+/g, " ");
55
+ return line.length > MAX_CELL ? `${line.slice(0, MAX_CELL - 1)}…` : line;
56
+ }
57
+
58
+ function columns(rows: Row[]): string[] {
59
+ const scalarKeys: string[] = [];
60
+ for (const row of rows) {
61
+ for (const [key, value] of Object.entries(row)) {
62
+ if (isScalar(value) && !HIDDEN.test(key) && !scalarKeys.includes(key))
63
+ scalarKeys.push(key);
64
+ }
65
+ }
66
+ const ordered = [
67
+ ...PREFERRED.filter((key) => scalarKeys.includes(key)),
68
+ ...scalarKeys.filter((key) => !PREFERRED.includes(key)),
69
+ ];
70
+ return ordered.slice(0, MAX_COLUMNS);
71
+ }
72
+
73
+ function style(
74
+ format: Parameters<typeof styleText>[0],
75
+ value: string,
76
+ options: RenderOptions,
77
+ ) {
78
+ return options.styled
79
+ ? styleText(format, value, { validateStream: false })
80
+ : value;
81
+ }
82
+
83
+ export function table(rows: Row[], options: RenderOptions = {}): string {
84
+ if (rows.length === 0) return "No results.";
85
+ const keys = columns(rows);
86
+ if (keys.length === 0) return rows.map((row) => text(row)).join("\n");
87
+ const cells = rows.map((row) => keys.map((key) => cell(row[key])));
88
+ if (options.styled) {
89
+ const grid = new Table({
90
+ head: keys.map((key) => style("bold", key, options)),
91
+ style: { head: [], border: [] },
92
+ });
93
+ grid.push(...cells);
94
+ return grid.toString();
95
+ }
96
+ const widths = keys.map((key, i) =>
97
+ Math.max(key.length, ...cells.map((r) => r[i].length)),
98
+ );
99
+ const line = (values: string[]) =>
100
+ values
101
+ .map((v, i) => v.padEnd(widths[i]))
102
+ .join(" ")
103
+ .trimEnd();
104
+ return [line(keys.map((k) => k.toUpperCase())), ...cells.map(line)].join(
105
+ "\n",
106
+ );
107
+ }
108
+
109
+ function summary(value: unknown): string {
110
+ if (Array.isArray(value))
111
+ return `[${value.length} item${value.length === 1 ? "" : "s"}]`;
112
+ if (value && typeof value === "object") return cell(value);
113
+ return text(value);
114
+ }
115
+
116
+ function record(value: Row, options: RenderOptions): string {
117
+ const width = Math.max(...Object.keys(value).map((key) => key.length));
118
+ const label = (key: string) => style("bold", key.padEnd(width), options);
119
+ return Object.entries(value)
120
+ .map(([key, v]) => {
121
+ if (Array.isArray(v) && v.length > 0 && v.every(isObject)) {
122
+ return `${style("bold", key, options)}:\n${indent(table(v as Row[], options))}`;
123
+ }
124
+ if (Array.isArray(v) && v.length > 0 && v.every(isScalar)) {
125
+ return `${label(key)} ${v.map(text).join(`\n${" ".repeat(width + 2)}`)}`;
126
+ }
127
+ return `${label(key)} ${isScalar(v) ? text(v) : summary(v)}`;
128
+ })
129
+ .join("\n");
130
+ }
131
+
132
+ export function render(value: unknown, options: RenderOptions = {}): string {
133
+ if (value === undefined) return "";
134
+ if (Array.isArray(value)) {
135
+ return value.every(isObject)
136
+ ? table(value as Row[], options)
137
+ : value.map(text).join("\n");
138
+ }
139
+ if (isObject(value)) {
140
+ const row = value as Row;
141
+ if (Array.isArray(row.items)) {
142
+ const rest = Object.entries(row).filter(
143
+ ([k, v]) => k !== "items" && isScalar(v),
144
+ );
145
+ const footer = rest.length
146
+ ? `\n\n${style("dim", rest.map(([k, v]) => `${k}: ${text(v)}`).join(" "), options)}`
147
+ : "";
148
+ return `${table(row.items as Row[], options)}${footer}`;
149
+ }
150
+ return record(row, options);
151
+ }
152
+ return text(value);
153
+ }
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { COMMANDS } from "./generated/commands";
4
+ import { type Invocation, parseInvocation } from "./program";
5
+ import { BUILTINS } from "./reference";
6
+
7
+ const parse = (argv: string[]) =>
8
+ parseInvocation(argv, { commands: COMMANDS, builtins: BUILTINS });
9
+
10
+ function command(invocation: Invocation) {
11
+ if (invocation.kind !== "command")
12
+ throw new Error(`expected a command, got ${invocation.kind}`);
13
+ return invocation;
14
+ }
15
+
16
+ describe("parseInvocation", () => {
17
+ it("collects repeated flags, globals anywhere, and positionals", async () => {
18
+ const got = command(
19
+ await parse([
20
+ "--json",
21
+ "execution",
22
+ "list",
23
+ "--status",
24
+ "failed",
25
+ "--status",
26
+ "completed",
27
+ "--org",
28
+ "acme",
29
+ ]),
30
+ );
31
+ expect(got.command.tool).toBe("executions_list");
32
+ expect(got.parsed.flags.get("status")).toEqual(["failed", "completed"]);
33
+ expect(got.parsed.json).toBe(true);
34
+ expect(got.parsed.org).toBe("acme");
35
+ });
36
+
37
+ it("keeps dotted body flags for the request builder", async () => {
38
+ const got = command(
39
+ await parse([
40
+ "workflow",
41
+ "execute",
42
+ "w-1",
43
+ "--execution-options.variant-key",
44
+ "canary",
45
+ ]),
46
+ );
47
+ expect(got.parsed.positionals).toEqual(["w-1"]);
48
+ expect(got.parsed.flags.get("execution-options.variant-key")).toEqual([
49
+ "canary",
50
+ ]);
51
+ });
52
+
53
+ it("rejects a dotted flag no body field owns", async () => {
54
+ await expect(
55
+ parse(["workflow", "list", "--nope.deep", "1"]),
56
+ ).rejects.toThrow(/unknown flag: --nope.deep/);
57
+ });
58
+
59
+ it("reads --no-<flag> as false", async () => {
60
+ const got = command(
61
+ await parse(["schedule", "update", "w-1", "s-1", "--no-enabled"]),
62
+ );
63
+ expect(got.parsed.flags.get("enabled")).toEqual(["false"]);
64
+ });
65
+
66
+ it("routes account commands as builtins and checks their arguments", async () => {
67
+ const use = await parse(["use", "acme"]);
68
+ expect(use).toMatchObject({ kind: "builtin", name: "use" });
69
+ expect(await parse(["use", "a", "b"])).toMatchObject({
70
+ kind: "exit",
71
+ code: 2,
72
+ });
73
+ expect(await parse(["logout", "--typo", "x"])).toMatchObject({
74
+ kind: "exit",
75
+ code: 2,
76
+ });
77
+ });
78
+
79
+ it("shows group and command help with exit 0", async () => {
80
+ const group = await parse(["workflow", "version"]);
81
+ expect(group).toMatchObject({ kind: "exit", code: 0 });
82
+ expect(group.kind === "exit" && group.stdout + group.stderr).toContain(
83
+ "publish",
84
+ );
85
+ const leaf = await parse(["workflow", "get", "--help"]);
86
+ expect(leaf.kind === "exit" && leaf.stdout).toContain("<workflow-id>");
87
+ });
88
+
89
+ it("fills a defaulted first argument when it is left out", async () => {
90
+ const got = command(
91
+ await parseInvocation(["workflow", "tag", "list"], {
92
+ commands: COMMANDS,
93
+ defaultArg: (_, arg) =>
94
+ arg.name === "workflowId" ? "chat-wf" : undefined,
95
+ }),
96
+ );
97
+ expect(got.parsed.positionals).toEqual(["chat-wf"]);
98
+ });
99
+ });
package/src/program.ts ADDED
@@ -0,0 +1,256 @@
1
+ import { Command, CommanderError } from "commander";
2
+
3
+ import { type Parsed, UsageError } from "./args";
4
+ import { type BuiltinCommand, GLOBAL_FLAGS } from "./reference";
5
+ import type { CliCommand, Option } from "./types";
6
+
7
+ export type Invocation =
8
+ | { kind: "command"; command: CliCommand; parsed: Parsed }
9
+ | { kind: "builtin"; name: string; parsed: Parsed }
10
+ | { kind: "exit"; code: number; stdout: string; stderr: string };
11
+
12
+ export interface ProgramOptions {
13
+ commands: readonly CliCommand[];
14
+ builtins?: readonly BuiltinCommand[];
15
+ version?: string;
16
+ description?: string;
17
+ defaultArg?: (command: CliCommand, arg: Option) => string | undefined;
18
+ defaultArgNote?: string;
19
+ }
20
+
21
+ const DOTTED = /^--([a-z0-9-]+\.[a-z0-9.-]+)(?:=(.*))?$/;
22
+
23
+ function collect(value: string, previous: string[] | undefined): string[] {
24
+ return [...(previous ?? []), value];
25
+ }
26
+
27
+ function optionText(option: Option): string {
28
+ const text = option.description?.replace(/\s+/g, " ").trim() ?? "";
29
+ const parts = [text && !/[.!?]$/.test(text) ? `${text}.` : text];
30
+ if (option.enum) parts.push(`One of: ${option.enum.join(", ")}.`);
31
+ if (option.type.endsWith("[]")) parts.push("Repeatable.");
32
+ if (option.required) parts.push("Required.");
33
+ return parts.filter(Boolean).join(" ");
34
+ }
35
+
36
+ function splitDotted(argv: string[]): {
37
+ argv: string[];
38
+ dotted: Map<string, string[]>;
39
+ } {
40
+ const rest: string[] = [];
41
+ const dotted = new Map<string, string[]>();
42
+ for (let i = 0; i < argv.length; i++) {
43
+ const match = DOTTED.exec(argv[i]);
44
+ if (!match) {
45
+ rest.push(argv[i]);
46
+ continue;
47
+ }
48
+ const value = match[2] ?? argv[++i];
49
+ if (value === undefined)
50
+ throw new UsageError(`--${match[1]} needs a value`);
51
+ dotted.set(match[1], [...(dotted.get(match[1]) ?? []), value]);
52
+ }
53
+ return { argv: rest, dotted };
54
+ }
55
+
56
+ function group(
57
+ parent: Command,
58
+ name: string,
59
+ groups: Map<string, Command>,
60
+ path: string,
61
+ ) {
62
+ const existing = groups.get(path);
63
+ if (existing) return existing;
64
+ const created = parent
65
+ .command(name)
66
+ .description(`${name} commands`)
67
+ .helpCommand(false);
68
+ groups.set(path, created);
69
+ return created;
70
+ }
71
+
72
+ function leafOptions(leaf: Command, command: CliCommand) {
73
+ for (const option of [...command.query, ...(command.body?.fields ?? [])]) {
74
+ if (option.type === "boolean") {
75
+ leaf.option(`--${option.flag}`, optionText(option));
76
+ leaf.option(`--no-${option.flag}`, `Set ${option.flag} to false.`);
77
+ continue;
78
+ }
79
+ const placeholder = option.enum
80
+ ? option.enum.join("|")
81
+ : option.type.replace("[]", "");
82
+ leaf.option(
83
+ `--${option.flag} <${placeholder}>`,
84
+ optionText(option),
85
+ collect,
86
+ );
87
+ }
88
+ for (const file of command.body?.files ?? []) {
89
+ leaf.option(`--${file} <path>`, "File to upload. Repeatable.", collect);
90
+ }
91
+ }
92
+
93
+ function toParsed(
94
+ leaf: Command,
95
+ positionals: string[],
96
+ dotted: Map<string, string[]>,
97
+ ): Parsed {
98
+ const globals = leaf.optsWithGlobals<Record<string, unknown>>();
99
+ const own = leaf.opts<Record<string, unknown>>();
100
+ const flags = new Map(dotted);
101
+ for (const option of leaf.options) {
102
+ const flag = option.long?.replace(/^--(no-)?/, "");
103
+ const value = own[option.attributeName()];
104
+ if (!flag || value === undefined || flags.has(flag)) continue;
105
+ flags.set(flag, Array.isArray(value) ? value.map(String) : [String(value)]);
106
+ }
107
+ return {
108
+ positionals,
109
+ flags,
110
+ json: globals.json === true,
111
+ org: globals.org as string | undefined,
112
+ body: globals.body as string | undefined,
113
+ output: globals.output as string | undefined,
114
+ };
115
+ }
116
+
117
+ export function defineCommands(
118
+ root: Command,
119
+ options: ProgramOptions,
120
+ dotted: Map<string, string[]> = new Map(),
121
+ onInvocation: (invocation: Invocation) => void = () => undefined,
122
+ ): Command {
123
+ if (options.description) root.description(options.description);
124
+ if (options.version) root.version(options.version, "-v, --version");
125
+ for (const [flags, text] of GLOBAL_FLAGS) {
126
+ if (!flags.startsWith("-h")) root.option(flags, text);
127
+ }
128
+
129
+ for (const builtin of options.builtins ?? []) {
130
+ const leaf = root.command(builtin.command).description(builtin.description);
131
+ if (builtin.arg) leaf.argument(`[${builtin.arg}]`);
132
+ leaf.action((...args: unknown[]) => {
133
+ const cmd = args.at(-1) as Command;
134
+ onInvocation({
135
+ kind: "builtin",
136
+ name: builtin.command,
137
+ parsed: toParsed(cmd, cmd.args, new Map()),
138
+ });
139
+ });
140
+ }
141
+
142
+ const reserved = new Set((options.builtins ?? []).map((b) => b.command));
143
+ const groups = new Map<string, Command>();
144
+ for (const command of options.commands) {
145
+ if (reserved.has(command.command)) continue;
146
+ const words = command.command.split(" ");
147
+ let parent = root;
148
+ for (let i = 0; i < words.length - 1; i++) {
149
+ parent = group(parent, words[i], groups, words.slice(0, i + 1).join(" "));
150
+ }
151
+ const leaf = parent
152
+ .command(words.at(-1)!)
153
+ .description(command.description)
154
+ .summary(command.title);
155
+ const defaults = command.args.map((arg) =>
156
+ options.defaultArg?.(command, arg),
157
+ );
158
+ command.args.forEach((arg, i) => {
159
+ leaf.argument(
160
+ defaults[i] === undefined ? `<${arg.flag}>` : `[${arg.flag}]`,
161
+ arg.description ?? "",
162
+ );
163
+ });
164
+ if (
165
+ defaults.some((value) => value !== undefined) &&
166
+ options.defaultArgNote
167
+ ) {
168
+ leaf.addHelpText("after", `\n${options.defaultArgNote}`);
169
+ }
170
+ leafOptions(leaf, command);
171
+ leaf.action((...args: unknown[]) => {
172
+ const cmd = args.at(-1) as Command;
173
+ let positionals = cmd.args;
174
+ if (
175
+ positionals.length === command.args.length - 1 &&
176
+ defaults[0] !== undefined
177
+ ) {
178
+ positionals = [defaults[0], ...positionals];
179
+ }
180
+ if (positionals.length !== command.args.length) {
181
+ const expected = command.args.map((a) => `<${a.flag}>`).join(" ");
182
+ throw new UsageError(
183
+ `expected ${expected}, got ${cmd.args.length} argument(s)`,
184
+ );
185
+ }
186
+ const unknownDotted = [...dotted.keys()].filter(
187
+ (flag) =>
188
+ !(command.body?.fields ?? []).some((f) =>
189
+ flag.startsWith(`${f.flag}.`),
190
+ ),
191
+ );
192
+ if (unknownDotted.length > 0) {
193
+ throw new UsageError(
194
+ `unknown flag: ${unknownDotted.map((f) => `--${f}`).join(", ")}`,
195
+ );
196
+ }
197
+ onInvocation({
198
+ kind: "command",
199
+ command,
200
+ parsed: toParsed(cmd, positionals, dotted),
201
+ });
202
+ });
203
+ }
204
+
205
+ return root;
206
+ }
207
+
208
+ export async function parseInvocation(
209
+ argv: string[],
210
+ options: ProgramOptions,
211
+ ): Promise<Invocation> {
212
+ let stdout = "";
213
+ let stderr = "";
214
+ let result: Invocation | undefined;
215
+
216
+ const { argv: rest, dotted } = splitDotted(argv);
217
+ const root = new Command("asteroid")
218
+ .exitOverride()
219
+ .helpCommand(false)
220
+ .configureOutput({
221
+ writeOut: (text) => (stdout += text),
222
+ writeErr: (text) => (stderr += text),
223
+ });
224
+ defineCommands(root, options, dotted, (invocation) => {
225
+ result = invocation;
226
+ });
227
+
228
+ try {
229
+ await root.parseAsync(rest, { from: "user" });
230
+ } catch (error) {
231
+ if (!(error instanceof CommanderError)) throw error;
232
+ if (error.code === "commander.help") {
233
+ return { kind: "exit", code: 0, stdout: stdout + stderr, stderr: "" };
234
+ }
235
+ const shown = ["commander.helpDisplayed", "commander.version"];
236
+ return {
237
+ kind: "exit",
238
+ code: shown.includes(error.code) ? 0 : 2,
239
+ stdout,
240
+ stderr,
241
+ };
242
+ }
243
+ if (result) return result;
244
+ root.outputHelp();
245
+ return { kind: "exit", code: 0, stdout, stderr };
246
+ }
247
+
248
+ export async function runCompletion(argv: string[], options: ProgramOptions) {
249
+ const { default: tab } = await import("@bomb.sh/tab/commander");
250
+ const root = defineCommands(new Command("asteroid").helpCommand(false), {
251
+ ...options,
252
+ builtins: options.builtins?.filter((b) => b.command !== "complete"),
253
+ });
254
+ tab(root);
255
+ await root.parseAsync(argv, { from: "user" });
256
+ }