@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.
@@ -0,0 +1,170 @@
1
+ import type { CliCommand, Option } from "./types";
2
+
3
+ export interface BuiltinCommand {
4
+ command: string;
5
+ usage: string;
6
+ description: string;
7
+ args: number;
8
+ arg?: string;
9
+ }
10
+
11
+ export const BUILTINS: BuiltinCommand[] = [
12
+ {
13
+ command: "login",
14
+ usage: "asteroid login",
15
+ description:
16
+ "Sign in with your browser. Stores a refresh token in the OS keychain, or in a private file in the config directory when no keychain is available.",
17
+ args: 0,
18
+ },
19
+ {
20
+ command: "logout",
21
+ usage: "asteroid logout",
22
+ description: "Forget the stored sign-in and cached tokens.",
23
+ args: 0,
24
+ },
25
+ {
26
+ command: "orgs",
27
+ usage: "asteroid orgs",
28
+ description:
29
+ "List the organizations you can act in. The active one is marked.",
30
+ args: 0,
31
+ },
32
+ {
33
+ command: "use",
34
+ usage: "asteroid use [org]",
35
+ arg: "org",
36
+ description:
37
+ "Make an organization, by id or name, the default for later commands. Without one, pick from a list.",
38
+ args: 1,
39
+ },
40
+ {
41
+ command: "complete",
42
+ usage: "asteroid complete <zsh|bash|fish|powershell>",
43
+ arg: "shell",
44
+ description:
45
+ "Print a shell completion script. For zsh: `asteroid complete zsh > ~/.zfunc/_asteroid`.",
46
+ args: 1,
47
+ },
48
+ {
49
+ command: "whoami",
50
+ usage: "asteroid whoami",
51
+ description:
52
+ "Show who you act as, in which organization, and how long the token lasts.",
53
+ args: 0,
54
+ },
55
+ ];
56
+
57
+ const BUILTIN_COMMANDS = new Set(BUILTINS.map((b) => b.command));
58
+
59
+ function generated(commands: CliCommand[]): CliCommand[] {
60
+ return commands.filter((c) => !BUILTIN_COMMANDS.has(c.command));
61
+ }
62
+
63
+ export const GLOBAL_FLAGS: [string, string][] = [
64
+ ["--json", "Print the response as JSON."],
65
+ [
66
+ "--org <id|name>",
67
+ "Act in this organization for one command. Also ASTEROID_ORG.",
68
+ ],
69
+ [
70
+ "--body <json|@file|->",
71
+ "Request body as JSON, a file, or stdin. Flags override its fields.",
72
+ ],
73
+ ["--output <file>", "Write a downloaded file here, not to stdout."],
74
+ ["-h, --help", "Show help."],
75
+ ];
76
+
77
+ export function usage(command: CliCommand): string {
78
+ const args = command.args.map((arg) => `<${arg.flag}>`).join(" ");
79
+ return `asteroid ${command.command}${args ? ` ${args}` : ""} [flags]`;
80
+ }
81
+
82
+ function flagLine(option: Option, prefix = ""): string {
83
+ const value =
84
+ option.type === "boolean"
85
+ ? ""
86
+ : option.enum
87
+ ? ` <${option.enum.join("|")}>`
88
+ : ` <${option.type}>`;
89
+ const notes = [
90
+ option.required ? "required" : "",
91
+ option.type.endsWith("[]") ? "repeatable" : "",
92
+ ].filter(Boolean);
93
+ const description = [
94
+ option.description?.replace(/\s+/g, " ").trim() ?? "",
95
+ notes.length ? `(${notes.join(", ")})` : "",
96
+ ]
97
+ .filter(Boolean)
98
+ .join(" ");
99
+ const name =
100
+ option.type === "boolean"
101
+ ? `[no-]${prefix}${option.flag}`
102
+ : `${prefix}${option.flag}`;
103
+ return `--${name}${value}${description ? ` ${description}` : ""}`;
104
+ }
105
+
106
+ export function flagLines(command: CliCommand): string[] {
107
+ const lines = command.query.map((option) => flagLine(option));
108
+ for (const field of command.body?.fields ?? []) lines.push(flagLine(field));
109
+ for (const file of command.body?.files ?? []) {
110
+ lines.push(`--${file} <path> File to upload (repeatable)`);
111
+ }
112
+ return lines;
113
+ }
114
+
115
+ function mdx(text: string): string {
116
+ return text
117
+ .split("`")
118
+ .map((part, i) =>
119
+ i % 2 === 1
120
+ ? part
121
+ : part
122
+ .replace(/</g, "&lt;")
123
+ .replace(/\{/g, "&#123;")
124
+ .replace(/\}/g, "&#125;"),
125
+ )
126
+ .join("`");
127
+ }
128
+
129
+ export function renderReference(commands: CliCommand[]): string {
130
+ const lines = [
131
+ "---",
132
+ 'title: "CLI reference"',
133
+ 'description: "Every asteroid command, its arguments and its flags"',
134
+ "---",
135
+ "",
136
+ "{/* Generated by packages/asteroid-cli from the cli surface of the External API. Do not edit by hand. */}",
137
+ "",
138
+ "Sign in with `asteroid login`, or set `ASTEROID_API_KEY` for CI and agents.",
139
+ "Commands that return data print compact text. Add `--json` to get the response as JSON.",
140
+ "The text output may change between releases. Scripts should read `--json`.",
141
+ "Downloads write raw bytes. `login`, `logout` and `use` only report status on stderr.",
142
+ "Exit codes: 0 ok, 1 API error, 2 usage error.",
143
+ "",
144
+ "## Global flags",
145
+ "",
146
+ ];
147
+ for (const [flag, text] of GLOBAL_FLAGS)
148
+ lines.push(`- \`${flag}\`: ${mdx(text)}`);
149
+ lines.push("", "## Account", "");
150
+ for (const builtin of BUILTINS) {
151
+ lines.push(`### \`${builtin.usage}\``, "", mdx(builtin.description), "");
152
+ }
153
+ lines.push("## Commands", "");
154
+ for (const command of generated(commands)) {
155
+ lines.push(`### \`${usage(command)}\``, "", mdx(command.description), "");
156
+ for (const arg of command.args) {
157
+ lines.push(
158
+ `- \`<${arg.flag}>\`${arg.description ? `: ${mdx(arg.description)}` : ""}`,
159
+ );
160
+ }
161
+ for (const flag of flagLines(command)) {
162
+ const [name, ...rest] = flag.split(" ");
163
+ lines.push(
164
+ `- \`${name}\`${rest.length ? `: ${mdx(rest.join(" "))}` : ""}`,
165
+ );
166
+ }
167
+ lines.push("");
168
+ }
169
+ return `${lines.join("\n").trimEnd()}\n`;
170
+ }
@@ -0,0 +1,164 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { matchCommand, UsageError } from "./args";
4
+ import { COMMANDS } from "./generated/commands";
5
+ import { parseInvocation } from "./program";
6
+ import { buildRequest, type InputReader } from "./request";
7
+
8
+ async function build(argv: string[], io?: InputReader) {
9
+ const invocation = await parseInvocation(argv, { commands: COMMANDS });
10
+ if (invocation.kind !== "command") {
11
+ throw new UsageError(
12
+ invocation.kind === "exit" ? invocation.stderr : "no command",
13
+ );
14
+ }
15
+ return buildRequest(invocation.command, invocation.parsed, { io });
16
+ }
17
+
18
+ describe("matchCommand", () => {
19
+ it("takes the longest command and leaves ids as arguments", () => {
20
+ const { command } = matchCommand(
21
+ ["workflow", "version", "get", "w1", "v1"],
22
+ COMMANDS,
23
+ );
24
+ expect(command?.tool).toBe("workflow_version_get");
25
+ });
26
+
27
+ it("returns a group prefix when no command matches", () => {
28
+ const { command, prefix } = matchCommand(["workflow", "version"], COMMANDS);
29
+ expect(command).toBeUndefined();
30
+ expect(prefix).toEqual(["workflow", "version"]);
31
+ });
32
+ });
33
+
34
+ describe("buildRequest", async () => {
35
+ it("fills path arguments in order", async () => {
36
+ const request = await build(["workflow", "version", "get", "w-1", "v-2"]);
37
+ expect(request.method).toBe("GET");
38
+ expect(request.path).toBe("/agents/v2/workflows/w-1/versions/v-2");
39
+ });
40
+
41
+ it("repeats array query flags and checks enums", async () => {
42
+ const request = await build([
43
+ "execution",
44
+ "list",
45
+ "--status",
46
+ "failed",
47
+ "--status=completed",
48
+ "--page-size",
49
+ "5",
50
+ ]);
51
+ expect(request.query.getAll("status")).toEqual(["failed", "completed"]);
52
+ expect(request.query.get("pageSize")).toBe("5");
53
+ await expect(
54
+ build(["execution", "list", "--status", "nope"]),
55
+ ).rejects.toThrow(UsageError);
56
+ });
57
+
58
+ it("rejects a non-integer for an integer flag", async () => {
59
+ await expect(
60
+ build(["execution", "list", "--page-size", "five"]),
61
+ ).rejects.toThrow(/integer/);
62
+ });
63
+
64
+ it("nests dotted flags into the body", async () => {
65
+ const request = await build([
66
+ "workflow",
67
+ "execute",
68
+ "w-1",
69
+ "--execution-options.variant-key",
70
+ "canary",
71
+ "--inputs",
72
+ '{"name":"Ada"}',
73
+ ]);
74
+ expect(request.json).toMatchObject({
75
+ executionOptions: { variantKey: "canary" },
76
+ inputs: { name: "Ada" },
77
+ });
78
+ });
79
+
80
+ it("lets flags override --body fields", async () => {
81
+ const request = await build([
82
+ "schema",
83
+ "validate",
84
+ "--body",
85
+ '{"schema":{"type":"string"}}',
86
+ "--schema",
87
+ '{"type":"object"}',
88
+ ]);
89
+ expect(request.json).toEqual({ schema: { type: "object" } });
90
+ });
91
+
92
+ it("fails on unknown flags and missing arguments", async () => {
93
+ await expect(build(["workflow", "list", "--nope", "1"])).rejects.toThrow(
94
+ /unknown option '--nope'/,
95
+ );
96
+ await expect(build(["workflow", "get"])).rejects.toThrow(
97
+ /missing required argument 'workflow-id'/,
98
+ );
99
+ });
100
+
101
+ it("requires required body fields", async () => {
102
+ await expect(build(["schema", "validate"])).rejects.toThrow(
103
+ /--schema is required/,
104
+ );
105
+ });
106
+
107
+ it("does not take a following flag as a value", async () => {
108
+ await expect(
109
+ build(["workflow", "rename", "w1", "--name", "--json"]),
110
+ ).rejects.toThrow(/'--name <string>' argument missing/);
111
+ });
112
+
113
+ it("rejects a --body that is not an object", async () => {
114
+ await expect(
115
+ build(["schema", "validate", "--body", "null"]),
116
+ ).rejects.toThrow(/--body must be a JSON object/);
117
+ });
118
+
119
+ it("requires files on an upload and refuses --body there", async () => {
120
+ await expect(build(["temp-file", "upload"])).rejects.toThrow(
121
+ /--files is required/,
122
+ );
123
+ await expect(
124
+ build(["temp-file", "upload", "--body", "{}"]),
125
+ ).rejects.toThrow(/use the file flags/);
126
+ });
127
+
128
+ it("reads @file and stdin through the reader it is given", async () => {
129
+ const io: InputReader = {
130
+ text: async (path) =>
131
+ path === 0 ? '{"schema":{"type":"string"}}' : '{"type":"object"}',
132
+ bytes: async () => new Uint8Array(),
133
+ };
134
+ const fromFile = await build(
135
+ ["schema", "validate", "--schema", "@/workspace/s.json"],
136
+ io,
137
+ );
138
+ expect(fromFile.json).toEqual({ schema: { type: "object" } });
139
+ const fromStdin = await build(["schema", "validate", "--body", "-"], io);
140
+ expect(fromStdin.json).toEqual({ schema: { type: "string" } });
141
+ });
142
+
143
+ it("takes the organization from the credential, never from flags or --body", async () => {
144
+ await expect(
145
+ build(["tag", "list", "--organization-id", "other"]),
146
+ ).rejects.toThrow(/unknown option '--organization-id'/);
147
+ const invocation = await parseInvocation(
148
+ [
149
+ "workflow",
150
+ "create",
151
+ "--name",
152
+ "W",
153
+ "--body",
154
+ '{"organizationId":"other"}',
155
+ ],
156
+ { commands: COMMANDS },
157
+ );
158
+ if (invocation.kind !== "command") throw new Error("expected a command");
159
+ const request = await buildRequest(invocation.command, invocation.parsed, {
160
+ organizationId: "mine",
161
+ });
162
+ expect(request.json).toMatchObject({ organizationId: "mine", name: "W" });
163
+ });
164
+ });
package/src/request.ts ADDED
@@ -0,0 +1,252 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { basename } from "node:path";
3
+
4
+ import { type Parsed, UsageError } from "./args";
5
+ import type { CliCommand, Option } from "./types";
6
+
7
+ export interface BuiltRequest {
8
+ method: CliCommand["method"];
9
+ path: string;
10
+ query: URLSearchParams;
11
+ json?: unknown;
12
+ form?: FormData;
13
+ }
14
+
15
+ function camel(segment: string): string {
16
+ return segment.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
17
+ }
18
+
19
+ function scalar(option: Option, raw: string): unknown {
20
+ const type = option.type.replace("[]", "");
21
+ if (option.enum && !option.enum.includes(raw)) {
22
+ throw new UsageError(
23
+ `--${option.flag} must be one of ${option.enum.join(", ")}`,
24
+ );
25
+ }
26
+ if (type === "integer" || type === "number") {
27
+ const value = Number(raw);
28
+ if (
29
+ raw.trim() === "" ||
30
+ Number.isNaN(value) ||
31
+ (type === "integer" && !Number.isInteger(value))
32
+ ) {
33
+ throw new UsageError(
34
+ `--${option.flag} must be ${type === "integer" ? "an integer" : "a number"}`,
35
+ );
36
+ }
37
+ return value;
38
+ }
39
+ if (type === "boolean") {
40
+ if (raw !== "true" && raw !== "false") {
41
+ throw new UsageError(`--${option.flag} must be true or false`);
42
+ }
43
+ return raw === "true";
44
+ }
45
+ if (type === "json") return parseJson(raw, `--${option.flag}`);
46
+ return raw;
47
+ }
48
+
49
+ function value(option: Option, raws: string[]): unknown {
50
+ if (option.type.endsWith("[]")) return raws.map((raw) => scalar(option, raw));
51
+ return scalar(option, raws.at(-1)!);
52
+ }
53
+
54
+ export interface InputReader {
55
+ text(path: string | 0): Promise<string>;
56
+ bytes(path: string): Promise<Uint8Array<ArrayBuffer>>;
57
+ }
58
+
59
+ export const nodeReader: InputReader = {
60
+ text: async (path) => readFileSync(path, "utf8"),
61
+ bytes: async (path) => new Uint8Array(readFileSync(path)),
62
+ };
63
+
64
+ async function readInput(raw: string, io: InputReader): Promise<string> {
65
+ if (raw === "-") return io.text(0);
66
+ if (raw.startsWith("@")) return io.text(raw.slice(1));
67
+ return raw;
68
+ }
69
+
70
+ async function resolveInputs(
71
+ command: CliCommand,
72
+ parsed: Parsed,
73
+ io: InputReader,
74
+ ): Promise<Parsed> {
75
+ const jsonFlags = [...command.query, ...(command.body?.fields ?? [])]
76
+ .filter((option) => option.type === "json")
77
+ .map((option) => option.flag);
78
+ const flags = new Map(parsed.flags);
79
+ for (const flag of jsonFlags) {
80
+ const raws = flags.get(flag);
81
+ if (raws)
82
+ flags.set(flag, await Promise.all(raws.map((raw) => readInput(raw, io))));
83
+ }
84
+ const body =
85
+ parsed.body === undefined ? undefined : await readInput(parsed.body, io);
86
+ return { ...parsed, flags, body };
87
+ }
88
+
89
+ function parseJson(raw: string, label: string): unknown {
90
+ try {
91
+ return JSON.parse(raw);
92
+ } catch {
93
+ throw new UsageError(`${label} is not valid JSON`);
94
+ }
95
+ }
96
+
97
+ function loose(raw: string): unknown {
98
+ if (/^(true|false|null)$/.test(raw) || /^[[{]/.test(raw.trim())) {
99
+ try {
100
+ return JSON.parse(raw);
101
+ } catch {
102
+ return raw;
103
+ }
104
+ }
105
+ return raw;
106
+ }
107
+
108
+ function setPath(
109
+ target: Record<string, unknown>,
110
+ path: string[],
111
+ value: unknown,
112
+ ) {
113
+ let node = target;
114
+ for (const key of path.slice(0, -1)) {
115
+ const next = node[key];
116
+ if (typeof next !== "object" || next === null || Array.isArray(next))
117
+ node[key] = {};
118
+ node = node[key] as Record<string, unknown>;
119
+ }
120
+ node[path.at(-1)!] = value;
121
+ }
122
+
123
+ export interface RequestContext {
124
+ io?: InputReader;
125
+ organizationId?: string;
126
+ }
127
+
128
+ export async function buildRequest(
129
+ command: CliCommand,
130
+ input: Parsed,
131
+ context: RequestContext = {},
132
+ ): Promise<BuiltRequest> {
133
+ const io = context.io ?? nodeReader;
134
+ const parsed = await resolveInputs(command, input, io);
135
+ if (parsed.positionals.length !== command.args.length) {
136
+ const expected =
137
+ command.args.map((a) => `<${a.flag}>`).join(" ") || "no arguments";
138
+ throw new UsageError(
139
+ `expected ${expected}, got ${parsed.positionals.length} argument(s)`,
140
+ );
141
+ }
142
+ let path = command.path;
143
+ command.args.forEach((arg, i) => {
144
+ path = path.replace(
145
+ `{${arg.name}}`,
146
+ encodeURIComponent(parsed.positionals[i]),
147
+ );
148
+ });
149
+
150
+ const flags = new Map(parsed.flags);
151
+ const query = new URLSearchParams();
152
+ for (const option of command.query) {
153
+ const raws = flags.get(option.flag);
154
+ flags.delete(option.flag);
155
+ if (!raws) {
156
+ if (option.required) throw new UsageError(`--${option.flag} is required`);
157
+ continue;
158
+ }
159
+ const parsedValue = value(option, raws);
160
+ const values = Array.isArray(parsedValue) ? parsedValue : [parsedValue];
161
+ if (option.explode === false)
162
+ query.append(option.name, values.map(String).join(","));
163
+ else for (const v of values) query.append(option.name, String(v));
164
+ }
165
+
166
+ const built: BuiltRequest = { method: command.method, path, query };
167
+ const body = command.body;
168
+ if (!body) {
169
+ if (parsed.body) throw new UsageError(`${command.command} takes no body`);
170
+ rejectUnknown(flags);
171
+ return built;
172
+ }
173
+
174
+ if (body.contentType === "multipart/form-data") {
175
+ if (parsed.body) {
176
+ throw new UsageError(
177
+ `${command.command} uploads files; use the file flags, not --body`,
178
+ );
179
+ }
180
+ const form = new FormData();
181
+ for (const file of body.files) {
182
+ const paths = flags.get(file) ?? [];
183
+ if (body.required && paths.length === 0) {
184
+ throw new UsageError(`--${file} is required`);
185
+ }
186
+ for (const filePath of paths) {
187
+ form.append(
188
+ file,
189
+ new Blob([await io.bytes(filePath)]),
190
+ basename(filePath),
191
+ );
192
+ }
193
+ flags.delete(file);
194
+ }
195
+ for (const field of body.fields) {
196
+ const raws = flags.get(field.flag);
197
+ flags.delete(field.flag);
198
+ if (raws) for (const raw of raws) form.append(field.name, raw);
199
+ }
200
+ rejectUnknown(flags);
201
+ built.form = form;
202
+ return built;
203
+ }
204
+
205
+ const base = parsed.body ? parseJson(parsed.body, "--body") : {};
206
+ if (typeof base !== "object" || base === null || Array.isArray(base)) {
207
+ throw new UsageError("--body must be a JSON object");
208
+ }
209
+ const json = base as Record<string, unknown>;
210
+ for (const field of body.fields) {
211
+ const raws = flags.get(field.flag);
212
+ if (!raws) continue;
213
+ flags.delete(field.flag);
214
+ json[field.name] = value(field, raws);
215
+ }
216
+ for (const [flag, raws] of [...flags]) {
217
+ const [head, ...rest] = flag.split(".");
218
+ const field = body.fields.find((f) => f.flag === head);
219
+ if (!field || rest.length === 0) continue;
220
+ flags.delete(flag);
221
+ const current = json[field.name];
222
+ json[field.name] =
223
+ typeof current === "object" && current !== null ? current : {};
224
+ setPath(json, [field.name, ...rest.map(camel)], loose(raws.at(-1)!));
225
+ }
226
+ rejectUnknown(flags);
227
+ delete json.organizationId;
228
+ if (body.credentialOrganization) {
229
+ if (!context.organizationId) {
230
+ throw new UsageError(
231
+ "could not tell which organization this credential acts in",
232
+ );
233
+ }
234
+ json.organizationId = context.organizationId;
235
+ }
236
+ for (const field of body.fields) {
237
+ if (field.required && json[field.name] === undefined) {
238
+ throw new UsageError(`--${field.flag} is required`);
239
+ }
240
+ }
241
+ if (body.required || Object.keys(json).length > 0) built.json = json;
242
+ return built;
243
+ }
244
+
245
+ function rejectUnknown(flags: Map<string, string[]>) {
246
+ const unknown = [...flags.keys()];
247
+ if (unknown.length > 0) {
248
+ throw new UsageError(
249
+ `unknown flag${unknown.length > 1 ? "s" : ""}: ${unknown.map((f) => `--${f}`).join(", ")}`,
250
+ );
251
+ }
252
+ }