@asteroidai/cli 0.0.0 → 1.8.193

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,280 @@
1
+ import { mkdtempSync, readFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { beforeEach, describe, expect, it } from "vitest";
5
+
6
+ import { CredentialStore } from "./auth/store";
7
+ import { readEnv, saveConfig } from "./config";
8
+ import { COMMANDS } from "./generated/commands";
9
+ import { renderReference } from "./reference";
10
+ import { type Deps, run } from "./run";
11
+
12
+ interface Call {
13
+ url: string;
14
+ init: RequestInit;
15
+ }
16
+
17
+ function jwt(claims: Record<string, unknown>): string {
18
+ const part = (value: unknown) =>
19
+ Buffer.from(JSON.stringify(value)).toString("base64url");
20
+ return `${part({ alg: "none" })}.${part(claims)}.sig`;
21
+ }
22
+
23
+ function harness(
24
+ env: Record<string, string>,
25
+ respond: (call: Call) => Response,
26
+ ) {
27
+ const configDir = mkdtempSync(join(tmpdir(), "asteroid-cli-"));
28
+ const calls: Call[] = [];
29
+ const out: string[] = [];
30
+ const err: string[] = [];
31
+ const deps: Deps = {
32
+ env: readEnv({ ASTEROID_CONFIG_DIR: configDir, ...env }),
33
+ cwd: configDir,
34
+ fetch: (async (url: string, init: RequestInit) => {
35
+ const call = { url: String(url), init };
36
+ calls.push(call);
37
+ return respond(call);
38
+ }) as typeof fetch,
39
+ stdout: (text) => out.push(text),
40
+ stderr: (text) => err.push(text),
41
+ writeBytes: () => undefined,
42
+ };
43
+ return { deps, calls, out, err, configDir };
44
+ }
45
+
46
+ const json = (body: unknown, status = 200) =>
47
+ new Response(JSON.stringify(body), {
48
+ status,
49
+ headers: { "content-type": "application/json" },
50
+ });
51
+
52
+ beforeEach(() => {
53
+ process.env.ASTEROID_NO_KEYCHAIN = "1";
54
+ });
55
+
56
+ describe("run", () => {
57
+ it("sends the API key and prints a table", async () => {
58
+ const h = harness({ ASTEROID_API_KEY: "sk_test" }, () =>
59
+ json({
60
+ items: [{ id: "w1", name: "Fill form" }],
61
+ page: 1,
62
+ pageSize: 50,
63
+ total: 1,
64
+ }),
65
+ );
66
+ const code = await run(["workflow", "list"], h.deps);
67
+ expect(code).toBe(0);
68
+ expect(h.calls[0].url).toBe(
69
+ "https://odyssey.asteroid.ai/agents/v2/workflows",
70
+ );
71
+ expect(
72
+ (h.calls[0].init.headers as Record<string, string>)["X-Api-Key"],
73
+ ).toBe("sk_test");
74
+ expect(h.out.join("")).toContain("Fill form");
75
+ });
76
+
77
+ it("prints the problem detail and exits 1 on an API error", async () => {
78
+ const h = harness({ ASTEROID_API_KEY: "sk_test" }, () =>
79
+ json({ detail: "Execution not found", status: 404 }, 404),
80
+ );
81
+ const code = await run(["execution", "get", "e1"], h.deps);
82
+ expect(code).toBe(1);
83
+ expect(h.err.join("")).toContain("404: Execution not found");
84
+ });
85
+
86
+ it("exits 2 on a usage error without calling the API", async () => {
87
+ const h = harness({ ASTEROID_API_KEY: "sk_test" }, () => json({}));
88
+ expect(await run(["workflow", "get"], h.deps)).toBe(2);
89
+ expect(await run(["nope"], h.deps)).toBe(2);
90
+ expect(h.calls).toHaveLength(0);
91
+ });
92
+
93
+ it("prints raw JSON with --json and names the org on a write", async () => {
94
+ const h = harness({ ASTEROID_API_KEY: "sk_test" }, () =>
95
+ json({ id: "w1" }),
96
+ );
97
+ await run(["workflow", "rename", "w1", "--name", "New", "--json"], h.deps);
98
+ expect(JSON.parse(h.out.join(""))).toEqual({ id: "w1" });
99
+ expect(h.err.join("")).toContain("Acting in the API key's organization.");
100
+ expect(JSON.parse(String(h.calls[0].init.body))).toEqual({ name: "New" });
101
+ });
102
+
103
+ it("mints an org token from the stored sign-in and caches it", async () => {
104
+ const access = jwt({
105
+ exp: Date.now() / 1000 + 300,
106
+ org_id: "org_A",
107
+ org: { id: "uuid-a", name: "Acme" },
108
+ });
109
+ const h = harness({}, (call) => {
110
+ if (call.url.includes("workos")) {
111
+ expect(
112
+ Object.fromEntries(call.init.body as URLSearchParams),
113
+ ).toMatchObject({
114
+ grant_type: "refresh_token",
115
+ refresh_token: "r1",
116
+ organization_id: "org_A",
117
+ });
118
+ return json({
119
+ access_token: access,
120
+ refresh_token: "r2",
121
+ organization_id: "org_A",
122
+ });
123
+ }
124
+ return json({ items: [], page: 1, pageSize: 50, total: 0 });
125
+ });
126
+ h.deps.env.workosApiUrl = "https://workos.test";
127
+ const store = new CredentialStore(h.deps.env);
128
+ await store.writeLogin({ refreshToken: "r1" });
129
+ saveConfig(h.deps.env, { org: { workosId: "org_A" } });
130
+
131
+ const codes = await Promise.all([
132
+ run(["workflow", "list"], h.deps),
133
+ run(["workflow", "list"], h.deps),
134
+ ]);
135
+ expect(codes).toEqual([0, 0]);
136
+ expect(await run(["workflow", "list"], h.deps)).toBe(0);
137
+
138
+ const workosCalls = h.calls.filter((c) => c.url.includes("workos"));
139
+ expect(workosCalls).toHaveLength(1);
140
+ expect(
141
+ (h.calls.at(-1)!.init.headers as Record<string, string>).Authorization,
142
+ ).toBe(`Bearer ${access}`);
143
+ expect((await store.readLogin())?.refreshToken).toBe("r2");
144
+ const config = JSON.parse(
145
+ readFileSync(join(h.configDir, "config.json"), "utf8"),
146
+ );
147
+ expect(config.org).toEqual({
148
+ workosId: "org_A",
149
+ id: "uuid-a",
150
+ name: "Acme",
151
+ });
152
+ });
153
+
154
+ it("asks for an org when none is set", async () => {
155
+ const h = harness({}, () => json({}));
156
+ await new CredentialStore(h.deps.env).writeLogin({ refreshToken: "r1" });
157
+ expect(await run(["workflow", "list"], h.deps)).toBe(1);
158
+ expect(h.err.join("")).toContain("asteroid use <org>");
159
+ expect(h.calls).toHaveLength(0);
160
+ });
161
+
162
+ it("sends an SSO or MFA org back to login", async () => {
163
+ const h = harness({}, () =>
164
+ json({ error: "sso_required", error_description: "SSO required" }, 400),
165
+ );
166
+ h.deps.env.workosApiUrl = "https://workos.test";
167
+ await new CredentialStore(h.deps.env).writeLogin({ refreshToken: "r1" });
168
+ saveConfig(h.deps.env, { org: { workosId: "org_A", name: "Acme" } });
169
+ expect(await run(["workflow", "list"], h.deps)).toBe(1);
170
+ expect(h.err.join("")).toContain("requires SSO or MFA");
171
+ });
172
+
173
+ it("asks to sign in when there is no credential", async () => {
174
+ const h = harness({}, () => json({}));
175
+ expect(await run(["workflow", "list"], h.deps)).toBe(1);
176
+ expect(h.err.join("")).toContain("asteroid login");
177
+ });
178
+ });
179
+
180
+ describe("guards", () => {
181
+ it("refuses --org with an API key", async () => {
182
+ const h = harness({ ASTEROID_API_KEY: "sk_test" }, () => json({}));
183
+ expect(await run(["workflow", "list", "--org", "Acme"], h.deps)).toBe(2);
184
+ expect(h.calls).toHaveLength(0);
185
+ });
186
+
187
+ it("rejects extra arguments and unknown flags on account commands", async () => {
188
+ const h = harness({}, () => json({}));
189
+ expect(await run(["use", "a", "b"], h.deps)).toBe(2);
190
+ expect(await run(["logout", "--typo", "x"], h.deps)).toBe(2);
191
+ });
192
+
193
+ it("drops the API key when a download redirects to another host", async () => {
194
+ const h = harness({ ASTEROID_API_KEY: "sk_test" }, (call) =>
195
+ call.url.startsWith("https://odyssey")
196
+ ? new Response(null, {
197
+ status: 303,
198
+ headers: { location: "https://storage.test/file" },
199
+ })
200
+ : new Response("bytes", { status: 200 }),
201
+ );
202
+ expect(
203
+ await run(["execution", "file", "download", "e1", "f1"], h.deps),
204
+ ).toBe(0);
205
+ const download = h.calls[1];
206
+ expect(download.url).toBe("https://storage.test/file");
207
+ expect(
208
+ (download.init.headers as Record<string, string>)["X-Api-Key"],
209
+ ).toBeUndefined();
210
+ });
211
+
212
+ it("keeps the method and body on a 307", async () => {
213
+ const h = harness({ ASTEROID_API_KEY: "sk_test" }, (call) =>
214
+ call.url.endsWith("/validate")
215
+ ? new Response(null, {
216
+ status: 307,
217
+ headers: { location: "/agents/v2/schema/validate2" },
218
+ })
219
+ : json({ valid: true }),
220
+ );
221
+ await run(["schema", "validate", "--schema", "{}"], h.deps);
222
+ expect(h.calls[1].init.method).toBe("POST");
223
+ expect(h.calls[1].init.body).toBe('{"schema":{}}');
224
+ expect(
225
+ (h.calls[1].init.headers as Record<string, string>)["X-Api-Key"],
226
+ ).toBe("sk_test");
227
+ });
228
+
229
+ it("renders nested object lists as a table", async () => {
230
+ const h = harness({ ASTEROID_API_KEY: "sk_test" }, () =>
231
+ json({ tools: [{ name: "go_to_url", capability: "web" }] }),
232
+ );
233
+ await run(["tool", "list"], h.deps);
234
+ expect(h.out.join("")).toContain("go_to_url");
235
+ });
236
+ });
237
+
238
+ describe("terminal safety", () => {
239
+ it("refuses to print a download to a terminal", async () => {
240
+ const h = harness(
241
+ { ASTEROID_API_KEY: "sk_test" },
242
+ () => new Response("bytes"),
243
+ );
244
+ h.deps.stdoutIsTTY = true;
245
+ expect(await run(["execution", "recording", "e1"], h.deps)).toBe(2);
246
+ expect(h.err.join("")).toContain("--output");
247
+ });
248
+
249
+ it("cleans control characters out of API errors", async () => {
250
+ const h = harness({ ASTEROID_API_KEY: "sk_test" }, () =>
251
+ json({ detail: "bad\u001b[2Jthing" }, 400),
252
+ );
253
+ await run(["execution", "get", "e1"], h.deps);
254
+ expect(h.err.join("")).toContain("badthing");
255
+ expect(h.err.join("")).not.toContain("\u001b");
256
+ });
257
+
258
+ it("fills a body organization from an API key's whoami", async () => {
259
+ const h = harness({ ASTEROID_API_KEY: "sk_test" }, (call) =>
260
+ call.url.endsWith("/users/v2/whoami")
261
+ ? json({ organizationId: "org-from-key" })
262
+ : json({ id: "w1" }),
263
+ );
264
+ expect(await run(["workflow", "create", "--name", "W"], h.deps)).toBe(0);
265
+ expect(JSON.parse(String(h.calls.at(-1)!.init.body))).toMatchObject({
266
+ organizationId: "org-from-key",
267
+ name: "W",
268
+ });
269
+ });
270
+ });
271
+
272
+ describe("reference", () => {
273
+ it("matches the generated agent-docs page", () => {
274
+ const page = readFileSync(
275
+ new URL("../../../docs/cli/reference.mdx", import.meta.url),
276
+ "utf8",
277
+ );
278
+ expect(page).toBe(renderReference(COMMANDS));
279
+ });
280
+ });
package/src/run.ts ADDED
@@ -0,0 +1,178 @@
1
+ import { writeFileSync } from "node:fs";
2
+
3
+ import { login, logout, orgs, use, whoami } from "./account";
4
+ import { type Parsed, UsageError } from "./args";
5
+ import { type Auth, AuthError, resolveAuth } from "./auth/credentials";
6
+ import { WorkosError } from "./auth/workos";
7
+ import { type Env, loadConfig, orgLabel, requestedOrg } from "./config";
8
+ import { COMMANDS } from "./generated/commands";
9
+ import { ApiError, send, VERSION } from "./http";
10
+ import { clean, render } from "./output";
11
+ import { parseInvocation } from "./program";
12
+ import { BUILTINS } from "./reference";
13
+ import { buildRequest } from "./request";
14
+ import type { CliCommand } from "./types";
15
+ import type { Ui } from "./ui";
16
+
17
+ export interface Deps {
18
+ env: Env;
19
+ cwd: string;
20
+ fetch: typeof fetch;
21
+ stdout: (text: string) => void;
22
+ stderr: (text: string) => void;
23
+ writeBytes: (bytes: Uint8Array) => void;
24
+ stdoutIsTTY?: boolean;
25
+ styled?: boolean;
26
+ ui?: Ui;
27
+ openUrl?: (url: string) => void;
28
+ }
29
+
30
+ function rejectOrgWithApiKey(deps: Deps, flagOrg: string | undefined) {
31
+ if (deps.env.apiKey && (flagOrg || deps.env.org)) {
32
+ throw new UsageError(
33
+ "ASTEROID_API_KEY acts in its own organization. Unset --org and ASTEROID_ORG, or sign in with `asteroid login`.",
34
+ );
35
+ }
36
+ }
37
+
38
+ async function runBuiltin(
39
+ name: string,
40
+ parsed: Parsed,
41
+ deps: Deps,
42
+ ): Promise<number> {
43
+ if (name !== "login" && name !== "logout")
44
+ rejectOrgWithApiKey(deps, parsed.org);
45
+ switch (name) {
46
+ case "login":
47
+ return login(deps);
48
+ case "logout":
49
+ return logout(deps);
50
+ case "orgs":
51
+ return orgs(deps, parsed.org, parsed.json);
52
+ case "complete":
53
+ throw new UsageError(
54
+ "run `asteroid complete <shell>` to print a completion script",
55
+ );
56
+ case "use":
57
+ return use(deps, parsed.positionals[0]);
58
+ default:
59
+ return whoami(deps, parsed.org, parsed.json);
60
+ }
61
+ }
62
+
63
+ async function credentialOrganization(
64
+ deps: Deps,
65
+ auth: Auth,
66
+ ): Promise<string | undefined> {
67
+ if (auth.org?.id) return auth.org.id;
68
+ const response = await send(
69
+ deps.env.apiUrl,
70
+ auth,
71
+ { method: "GET", path: "/users/v2/whoami", query: new URLSearchParams() },
72
+ false,
73
+ deps.fetch,
74
+ );
75
+ return (response.json as { organizationId?: string } | undefined)
76
+ ?.organizationId;
77
+ }
78
+
79
+ async function runCommand(
80
+ command: CliCommand,
81
+ parsed: Parsed,
82
+ deps: Deps,
83
+ ): Promise<number> {
84
+ const needsOrganization = command.body?.credentialOrganization === true;
85
+ let request = needsOrganization
86
+ ? undefined
87
+ : await buildRequest(command, parsed);
88
+ rejectOrgWithApiKey(deps, parsed.org);
89
+ const config = loadConfig(deps.env);
90
+ const auth = await resolveAuth(
91
+ deps.env,
92
+ config,
93
+ requestedOrg(parsed.org, deps.env, deps.cwd, config),
94
+ deps.fetch,
95
+ );
96
+ request ??= await buildRequest(command, parsed, {
97
+ organizationId: await credentialOrganization(deps, auth),
98
+ });
99
+ if (!command.readOnly) {
100
+ const org =
101
+ auth.kind === "api-key"
102
+ ? "the API key's organization"
103
+ : auth.org
104
+ ? orgLabel(auth.org)
105
+ : "your default organization";
106
+ deps.stderr(`Acting in ${org}.\n`);
107
+ }
108
+
109
+ const response = await send(
110
+ deps.env.apiUrl,
111
+ auth,
112
+ request,
113
+ command.binary,
114
+ deps.fetch,
115
+ );
116
+ if (response.bytes) {
117
+ if (parsed.output) {
118
+ writeFileSync(parsed.output, response.bytes);
119
+ deps.stderr(
120
+ `Wrote ${response.bytes.length} bytes to ${parsed.output}.\n`,
121
+ );
122
+ } else if (deps.stdoutIsTTY) {
123
+ throw new UsageError(
124
+ "refusing to print binary data to a terminal. Pass --output <file> or redirect stdout.",
125
+ );
126
+ } else deps.writeBytes(response.bytes);
127
+ return 0;
128
+ }
129
+ if (response.status === 204) {
130
+ if (!parsed.json) deps.stderr("Done.\n");
131
+ return 0;
132
+ }
133
+ if (response.json !== undefined) {
134
+ deps.stdout(
135
+ `${parsed.json ? JSON.stringify(response.json, null, 2) : render(response.json, { styled: deps.styled })}\n`,
136
+ );
137
+ return 0;
138
+ }
139
+ if (response.text) deps.stdout(`${clean(response.text)}\n`);
140
+ return 0;
141
+ }
142
+
143
+ const DESCRIPTION =
144
+ "The Asteroid CLI. Sign in with `asteroid login`, or set ASTEROID_API_KEY.";
145
+
146
+ export async function run(argv: string[], deps: Deps): Promise<number> {
147
+ try {
148
+ const invocation = await parseInvocation(argv, {
149
+ commands: COMMANDS,
150
+ builtins: BUILTINS,
151
+ version: VERSION,
152
+ description: DESCRIPTION,
153
+ });
154
+ if (invocation.kind === "exit") {
155
+ if (invocation.stdout) deps.stdout(invocation.stdout);
156
+ if (invocation.stderr) deps.stderr(invocation.stderr);
157
+ return invocation.code;
158
+ }
159
+ if (invocation.kind === "builtin") {
160
+ return await runBuiltin(invocation.name, invocation.parsed, deps);
161
+ }
162
+ return await runCommand(invocation.command, invocation.parsed, deps);
163
+ } catch (error) {
164
+ if (error instanceof UsageError) {
165
+ deps.stderr(`asteroid: ${clean(error.message)}\n`);
166
+ return 2;
167
+ }
168
+ if (error instanceof ApiError) {
169
+ deps.stderr(`asteroid: ${error.status}: ${clean(error.message)}\n`);
170
+ return 1;
171
+ }
172
+ if (error instanceof AuthError || error instanceof WorkosError) {
173
+ deps.stderr(`asteroid: ${clean(error.message)}\n`);
174
+ return 1;
175
+ }
176
+ throw error;
177
+ }
178
+ }
package/src/types.ts ADDED
@@ -0,0 +1,42 @@
1
+ export type ValueType =
2
+ | "string"
3
+ | "integer"
4
+ | "number"
5
+ | "boolean"
6
+ | "string[]"
7
+ | "integer[]"
8
+ | "number[]"
9
+ | "json";
10
+
11
+ export interface Option {
12
+ name: string;
13
+ flag: string;
14
+ type: ValueType;
15
+ required: boolean;
16
+ description?: string;
17
+ enum?: string[];
18
+ explode?: boolean;
19
+ }
20
+
21
+ export interface BodySpec {
22
+ required: boolean;
23
+ contentType: "application/json" | "multipart/form-data";
24
+ fields: Option[];
25
+ files: string[];
26
+ credentialOrganization: boolean;
27
+ }
28
+
29
+ export interface CliCommand {
30
+ command: string;
31
+ tool: string;
32
+ title: string;
33
+ description: string;
34
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
35
+ path: string;
36
+ args: Option[];
37
+ query: Option[];
38
+ body?: BodySpec;
39
+ binary: boolean;
40
+ readOnly: boolean;
41
+ destructive: boolean;
42
+ }
package/src/ui.ts ADDED
@@ -0,0 +1,30 @@
1
+ import * as clack from "@clack/prompts";
2
+
3
+ export interface Choice {
4
+ value: string;
5
+ label: string;
6
+ }
7
+
8
+ export interface Ui {
9
+ select(message: string, choices: Choice[]): Promise<string | undefined>;
10
+ wait<T>(message: string, done: string, work: Promise<T>): Promise<T>;
11
+ }
12
+
13
+ export const clackUi: Ui = {
14
+ async select(message, choices) {
15
+ const value = await clack.select({ message, options: choices });
16
+ return clack.isCancel(value) ? undefined : value;
17
+ },
18
+ async wait(message, done, work) {
19
+ const spinner = clack.spinner();
20
+ spinner.start(message);
21
+ try {
22
+ const result = await work;
23
+ spinner.stop(done);
24
+ return result;
25
+ } catch (error) {
26
+ spinner.stop(message);
27
+ throw error;
28
+ }
29
+ },
30
+ };