@asteroidai/cli 1.9.12 → 1.9.28

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/request.ts DELETED
@@ -1,252 +0,0 @@
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
- }
package/src/run.test.ts DELETED
@@ -1,431 +0,0 @@
1
- import { mkdtempSync, readFileSync, writeFileSync } 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 { ALL_COMMANDS } from "./commands";
8
- import { readEnv, saveConfig } from "./config";
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("tells a signed-in user that the API key overrides use", async () => {
188
- const h = harness({ ASTEROID_API_KEY: "sk_test" }, () => json({}));
189
- await new CredentialStore(h.deps.env).writeLogin({ refreshToken: "r1" });
190
- expect(await run(["use", "org_A"], h.deps)).toBe(2);
191
- expect(h.err.join("")).toContain("ignores `asteroid login`");
192
- expect(h.err.join("")).toContain("unset ASTEROID_API_KEY");
193
- });
194
-
195
- it("names the API key when the API rejects it", async () => {
196
- const h = harness({ ASTEROID_API_KEY: "sk_bad" }, () =>
197
- json({ detail: "invalid API key" }, 401),
198
- );
199
- expect(await run(["workflow", "list"], h.deps)).toBe(1);
200
- expect(h.err.join("")).toContain(
201
- "401: ASTEROID_API_KEY was rejected: invalid API key.",
202
- );
203
- });
204
-
205
- it("rejects extra arguments and unknown flags on account commands", async () => {
206
- const h = harness({}, () => json({}));
207
- expect(await run(["use", "a", "b"], h.deps)).toBe(2);
208
- expect(await run(["logout", "--typo", "x"], h.deps)).toBe(2);
209
- });
210
-
211
- it("drops the API key when a download redirects to another host", async () => {
212
- const h = harness({ ASTEROID_API_KEY: "sk_test" }, (call) =>
213
- call.url.startsWith("https://odyssey")
214
- ? new Response(null, {
215
- status: 303,
216
- headers: { location: "https://storage.test/file" },
217
- })
218
- : new Response("bytes", { status: 200 }),
219
- );
220
- expect(
221
- await run(["execution", "file", "download", "e1", "f1"], h.deps),
222
- ).toBe(0);
223
- const download = h.calls[1];
224
- expect(download.url).toBe("https://storage.test/file");
225
- expect(
226
- (download.init.headers as Record<string, string>)["X-Api-Key"],
227
- ).toBeUndefined();
228
- });
229
-
230
- it("does not blame the API key for an error after a cross-host redirect", async () => {
231
- const h = harness({ ASTEROID_API_KEY: "sk_test" }, (call) =>
232
- call.url.startsWith("https://odyssey")
233
- ? new Response(null, {
234
- status: 303,
235
- headers: { location: "https://storage.test/file" },
236
- })
237
- : new Response("expired", { status: 403 }),
238
- );
239
- expect(
240
- await run(["execution", "file", "download", "e1", "f1"], h.deps),
241
- ).toBe(1);
242
- expect(h.err.join("")).toContain("403: expired");
243
- expect(h.err.join("")).not.toContain("ASTEROID_API_KEY");
244
- });
245
-
246
- it("keeps the method and body on a 307", async () => {
247
- const h = harness({ ASTEROID_API_KEY: "sk_test" }, (call) =>
248
- call.url.endsWith("/validate")
249
- ? new Response(null, {
250
- status: 307,
251
- headers: { location: "/agents/v2/schema/validate2" },
252
- })
253
- : json({ valid: true }),
254
- );
255
- await run(["schema", "validate", "--schema", "{}"], h.deps);
256
- expect(h.calls[1].init.method).toBe("POST");
257
- expect(h.calls[1].init.body).toBe('{"schema":{}}');
258
- expect(
259
- (h.calls[1].init.headers as Record<string, string>)["X-Api-Key"],
260
- ).toBe("sk_test");
261
- });
262
-
263
- it("renders nested object lists as a table", async () => {
264
- const h = harness({ ASTEROID_API_KEY: "sk_test" }, () =>
265
- json({ tools: [{ name: "go_to_url", capability: "web" }] }),
266
- );
267
- await run(["tool", "list"], h.deps);
268
- expect(h.out.join("")).toContain("go_to_url");
269
- });
270
- });
271
-
272
- describe("cell width", () => {
273
- const long = "x".repeat(80);
274
- const reply = () => json({ items: [{ id: "w-1", name: long }] });
275
-
276
- it("keeps cells whole when stdout is not a terminal", async () => {
277
- const h = harness({ ASTEROID_API_KEY: "sk_test" }, reply);
278
- await run(["workflow", "list"], h.deps);
279
- expect(h.out.join("")).toContain(long);
280
- });
281
-
282
- it("cuts long cells at a terminal", async () => {
283
- const h = harness({ ASTEROID_API_KEY: "sk_test" }, reply);
284
- h.deps.stdoutIsTTY = true;
285
- await run(["workflow", "list"], h.deps);
286
- const out = h.out.join("");
287
- expect(out).toContain(`${"x".repeat(59)}…`);
288
- expect(out).not.toContain(long);
289
- });
290
- });
291
-
292
- describe("terminal safety", () => {
293
- it("refuses to print a download to a terminal", async () => {
294
- const h = harness(
295
- { ASTEROID_API_KEY: "sk_test" },
296
- () => new Response("bytes"),
297
- );
298
- h.deps.stdoutIsTTY = true;
299
- expect(await run(["execution", "recording", "e1"], h.deps)).toBe(2);
300
- expect(h.err.join("")).toContain("--output");
301
- });
302
-
303
- it("cleans control characters out of API errors", async () => {
304
- const h = harness({ ASTEROID_API_KEY: "sk_test" }, () =>
305
- json({ detail: "bad\u001b[2Jthing" }, 400),
306
- );
307
- await run(["execution", "get", "e1"], h.deps);
308
- expect(h.err.join("")).toContain("badthing");
309
- expect(h.err.join("")).not.toContain("\u001b");
310
- });
311
-
312
- it("fills a body organization from an API key's whoami", async () => {
313
- const h = harness({ ASTEROID_API_KEY: "sk_test" }, (call) =>
314
- call.url.endsWith("/users/v2/whoami")
315
- ? json({ organizationId: "org-from-key" })
316
- : json({ id: "w1" }),
317
- );
318
- expect(await run(["workflow", "create", "--name", "W"], h.deps)).toBe(0);
319
- expect(JSON.parse(String(h.calls.at(-1)!.init.body))).toMatchObject({
320
- organizationId: "org-from-key",
321
- name: "W",
322
- });
323
- });
324
- });
325
-
326
- describe("files --out", () => {
327
- it("writes a pinned version into the folder it is given", async () => {
328
- const h = harness({ ASTEROID_API_KEY: "sk_test" }, () =>
329
- json({
330
- rev: 1,
331
- version: 2,
332
- versionId: "v-2",
333
- workflowName: "Fill form",
334
- files: [
335
- {
336
- path: "settings.yaml",
337
- kind: "structural",
338
- size: 3,
339
- content: "a: 1",
340
- },
341
- ],
342
- }),
343
- );
344
- const dir = join(h.configDir, "pulled");
345
-
346
- const code = await run(
347
- ["workflow", "version", "pull", "w1", "v-2", "--out", dir],
348
- h.deps,
349
- );
350
-
351
- expect(code).toBe(0);
352
- expect(h.calls[0].url).toBe(
353
- "https://odyssey.asteroid.ai/agents/v2/workflows/w1/versions/v-2/files?contents=all",
354
- );
355
- expect(readFileSync(join(dir, "settings.yaml"), "utf8")).toBe("a: 1");
356
- });
357
- });
358
-
359
- describe("workflow pull and push", () => {
360
- const tree = {
361
- rev: 1,
362
- version: 2,
363
- versionId: "v-2",
364
- workflowName: "Fill form",
365
- files: [
366
- { path: "settings.yaml", kind: "structural", size: 4, content: "a: 1" },
367
- ],
368
- };
369
-
370
- it("pulls into ./<workflow-name>/head and pushes from there with no arguments", async () => {
371
- const h = harness({ ASTEROID_API_KEY: "sk_test" }, (call) =>
372
- call.init.method === "PATCH" ? json({}) : json({ ...tree, rev: 2 }),
373
- );
374
-
375
- expect(await run(["workflow", "pull", "w1"], h.deps)).toBe(0);
376
- const head = join(h.configDir, "Fill_form", "head");
377
- expect(readFileSync(join(head, "settings.yaml"), "utf8")).toBe("a: 1");
378
-
379
- writeFileSync(join(head, "settings.yaml"), "a: 2");
380
- const code = await run(["workflow", "push"], { ...h.deps, cwd: head });
381
-
382
- expect(code).toBe(0);
383
- const patch = h.calls.find((call) => call.init.method === "PATCH")!;
384
- expect(patch.url).toBe(
385
- "https://odyssey.asteroid.ai/agents/v2/workflows/w1/head/files",
386
- );
387
- expect(JSON.parse(String(patch.init.body))).toMatchObject({ baseRev: 2 });
388
- });
389
-
390
- it("pushes the folder --from names, taking the workflow from its manifest", async () => {
391
- const h = harness({ ASTEROID_API_KEY: "sk_test" }, (call) =>
392
- call.init.method === "PATCH" ? json({}) : json({ ...tree, rev: 2 }),
393
- );
394
- await run(["workflow", "pull", "w9"], h.deps);
395
- const head = join(h.configDir, "Fill_form", "head");
396
- writeFileSync(join(head, "settings.yaml"), "a: 3");
397
-
398
- const code = await run(["workflow", "push", "--from", head], h.deps);
399
-
400
- expect(code).toBe(0);
401
- const patch = h.calls.find((call) => call.init.method === "PATCH")!;
402
- expect(patch.url).toBe(
403
- "https://odyssey.asteroid.ai/agents/v2/workflows/w9/head/files",
404
- );
405
- });
406
- });
407
-
408
- describe("workflow pull with a filter", () => {
409
- it("refuses a partial tree, which would delete the files it leaves out", async () => {
410
- const h = harness({ ASTEROID_API_KEY: "sk_test" }, () => json({}));
411
-
412
- const code = await run(
413
- ["workflow", "pull", "w1", "--paths", "a.md"],
414
- h.deps,
415
- );
416
-
417
- expect(code).toBe(2);
418
- expect(h.calls).toEqual([]);
419
- expect(h.err.join("")).toContain("cannot take --paths");
420
- });
421
- });
422
-
423
- describe("reference", () => {
424
- it("matches the generated agent-docs page", () => {
425
- const page = readFileSync(
426
- new URL("../../../docs/cli/reference.mdx", import.meta.url),
427
- "utf8",
428
- );
429
- expect(page).toBe(renderReference(ALL_COMMANDS));
430
- });
431
- });