@asteroidai/cli 1.9.4 → 1.9.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteroidai/cli",
3
- "version": "1.9.4",
3
+ "version": "1.9.11",
4
4
  "description": "The asteroid command-line interface for the Asteroid platform.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,7 +38,8 @@
38
38
  "tsx": "^4.23.12",
39
39
  "typescript": "5.9.3",
40
40
  "vitest": "^4.1.11",
41
- "yaml": "^2.9.0"
41
+ "yaml": "^2.9.0",
42
+ "@asteroid/workflow-files": "0.1.0"
42
43
  },
43
44
  "scripts": {
44
45
  "generate": "tsx scripts/generate.ts && pnpm exec prettier --write src/generated/commands.ts",
@@ -0,0 +1,13 @@
1
+ import { COMMANDS } from "./generated/commands";
2
+ import { SYNC_COMMANDS } from "./sync/commands";
3
+ import type { CliCommand } from "./types";
4
+
5
+ export function withSyncCommands(
6
+ commands: readonly CliCommand[],
7
+ ): CliCommand[] {
8
+ return [...commands, ...SYNC_COMMANDS].sort((a, b) =>
9
+ a.command.localeCompare(b.command),
10
+ );
11
+ }
12
+
13
+ export const ALL_COMMANDS = withSyncCommands(COMMANDS);
package/src/core.ts CHANGED
@@ -8,3 +8,34 @@ export {
8
8
  } from "./program";
9
9
  export { type BuiltRequest, buildRequest, type InputReader } from "./request";
10
10
  export type { CliCommand, Option } from "./types";
11
+ export { ALL_COMMANDS, withSyncCommands } from "./commands";
12
+ export { type Activity, formatActivitiesMarkdown } from "./sync/activities";
13
+ export {
14
+ type Execution,
15
+ type ExecutionPullOptions,
16
+ type ExecutionPullResult,
17
+ formatExecutionPull,
18
+ pullExecution,
19
+ } from "./sync/execution-pull";
20
+ export { matchesGlob } from "./sync/paths";
21
+ export { runSync, type SyncDeps, SYNC_COMMANDS } from "./sync/run";
22
+ export {
23
+ type FileEntry,
24
+ type FileTree,
25
+ type LocalFiles,
26
+ MANIFEST,
27
+ type Manifest,
28
+ type Remote,
29
+ type Sync,
30
+ } from "./sync/types";
31
+ export {
32
+ formatWorkflowPull,
33
+ pullWorkflow,
34
+ type WorkflowPullOptions,
35
+ type WorkflowPullResult,
36
+ } from "./sync/workflow-pull";
37
+ export {
38
+ formatWorkflowPush,
39
+ pushWorkflow,
40
+ type WorkflowPushResult,
41
+ } from "./sync/workflow-push";
package/src/main.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
 
3
+ import { ALL_COMMANDS } from "./commands";
3
4
  import { readEnv } from "./config";
4
- import { COMMANDS } from "./generated/commands";
5
5
  import { runCompletion } from "./program";
6
6
  import { BUILTINS } from "./reference";
7
7
  import { run } from "./run";
@@ -27,7 +27,7 @@ function openUrl(url: string) {
27
27
 
28
28
  const argv = process.argv.slice(2);
29
29
  if (argv[0] === "complete") {
30
- await runCompletion(argv, { commands: COMMANDS, builtins: BUILTINS });
30
+ await runCompletion(argv, { commands: ALL_COMMANDS, builtins: BUILTINS });
31
31
  process.exit(0);
32
32
  }
33
33
 
@@ -133,4 +133,23 @@ describe("parseInvocation", () => {
133
133
  "expected [workflow-id] <schedule-id>, got 0 argument(s)",
134
134
  );
135
135
  });
136
+
137
+ it("prints the version only as the first argument", async () => {
138
+ const options = {
139
+ commands: COMMANDS,
140
+ builtins: BUILTINS,
141
+ version: "1.2.3",
142
+ };
143
+
144
+ const version = await parseInvocation(["--version"], options);
145
+ const create = await parseInvocation(
146
+ ["workflow", "create", "--name", "W", "--version", "{}"],
147
+ options,
148
+ );
149
+
150
+ expect(version).toMatchObject({ kind: "exit", code: 0, stdout: "1.2.3\n" });
151
+ expect(create.kind).toBe("command");
152
+ if (create.kind === "command")
153
+ expect(create.parsed.flags.get("version")).toEqual(["{}"]);
154
+ });
136
155
  });
package/src/program.ts CHANGED
@@ -85,6 +85,8 @@ function leafOptions(leaf: Command, command: CliCommand) {
85
85
  optionText(option),
86
86
  collect,
87
87
  );
88
+ if (option.negatable)
89
+ leaf.option(`--no-${option.flag}`, `Skip ${option.flag}.`);
88
90
  }
89
91
  for (const file of command.body?.files ?? []) {
90
92
  leaf.option(`--${file} <path>`, "File to upload. Repeatable.", collect);
@@ -122,7 +124,11 @@ export function defineCommands(
122
124
  onInvocation: (invocation: Invocation) => void = () => undefined,
123
125
  ): Command {
124
126
  if (options.description) root.description(options.description);
125
- if (options.version) root.version(options.version, "-v, --version");
127
+ if (options.version)
128
+ root.addHelpText(
129
+ "after",
130
+ "\nRun `asteroid --version` to print the CLI version.",
131
+ );
126
132
  for (const [flags, text] of GLOBAL_FLAGS) {
127
133
  if (!flags.startsWith("-h")) root.option(flags, text);
128
134
  }
@@ -224,6 +230,14 @@ export async function parseInvocation(
224
230
  let stderr = "";
225
231
  let result: Invocation | undefined;
226
232
 
233
+ if (options.version && (argv[0] === "-v" || argv[0] === "--version")) {
234
+ return {
235
+ kind: "exit",
236
+ code: 0,
237
+ stdout: `${options.version}\n`,
238
+ stderr: "",
239
+ };
240
+ }
227
241
  const { argv: rest, dotted } = splitDotted(argv);
228
242
  const root = new Command("asteroid")
229
243
  .exitOverride()
package/src/run.test.ts CHANGED
@@ -4,8 +4,8 @@ import { join } from "node:path";
4
4
  import { beforeEach, describe, expect, it } from "vitest";
5
5
 
6
6
  import { CredentialStore } from "./auth/store";
7
+ import { ALL_COMMANDS } from "./commands";
7
8
  import { readEnv, saveConfig } from "./config";
8
- import { COMMANDS } from "./generated/commands";
9
9
  import { renderReference } from "./reference";
10
10
  import { type Deps, run } from "./run";
11
11
 
@@ -323,12 +323,45 @@ describe("terminal safety", () => {
323
323
  });
324
324
  });
325
325
 
326
+ describe("workflow pull", () => {
327
+ it("pulls 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", "pull", "w1", "--version-id", "v-2", "--dir", 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
+
326
359
  describe("reference", () => {
327
360
  it("matches the generated agent-docs page", () => {
328
361
  const page = readFileSync(
329
362
  new URL("../../../docs/cli/reference.mdx", import.meta.url),
330
363
  "utf8",
331
364
  );
332
- expect(page).toBe(renderReference(COMMANDS));
365
+ expect(page).toBe(renderReference(ALL_COMMANDS));
333
366
  });
334
367
  });
package/src/run.ts CHANGED
@@ -10,13 +10,15 @@ import {
10
10
  UNSET_API_KEY,
11
11
  } from "./auth/credentials";
12
12
  import { WorkosError } from "./auth/workos";
13
+ import { ALL_COMMANDS } from "./commands";
13
14
  import { type Env, loadConfig, orgLabel, requestedOrg } from "./config";
14
- import { COMMANDS } from "./generated/commands";
15
15
  import { ApiError, send, VERSION } from "./http";
16
16
  import { clean, render } from "./output";
17
17
  import { parseInvocation } from "./program";
18
18
  import { BUILTINS } from "./reference";
19
19
  import { buildRequest } from "./request";
20
+ import { runSync } from "./sync/run";
21
+ import type { Remote } from "./sync/types";
20
22
  import type { CliCommand } from "./types";
21
23
  import type { Ui } from "./ui";
22
24
 
@@ -82,6 +84,28 @@ async function credentialOrganization(
82
84
  ?.organizationId;
83
85
  }
84
86
 
87
+ async function authFor(parsed: Parsed, deps: Deps): Promise<Auth> {
88
+ rejectOrgWithApiKey(deps, parsed.org);
89
+ const config = loadConfig(deps.env);
90
+ return resolveAuth(
91
+ deps.env,
92
+ config,
93
+ requestedOrg(parsed.org, deps.env, deps.cwd, config),
94
+ deps.fetch,
95
+ );
96
+ }
97
+
98
+ function announce(command: CliCommand, auth: Auth, deps: Deps) {
99
+ if (command.readOnly) return;
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
+
85
109
  async function runCommand(
86
110
  command: CliCommand,
87
111
  parsed: Parsed,
@@ -91,26 +115,11 @@ async function runCommand(
91
115
  let request = needsOrganization
92
116
  ? undefined
93
117
  : await buildRequest(command, parsed);
94
- rejectOrgWithApiKey(deps, parsed.org);
95
- const config = loadConfig(deps.env);
96
- const auth = await resolveAuth(
97
- deps.env,
98
- config,
99
- requestedOrg(parsed.org, deps.env, deps.cwd, config),
100
- deps.fetch,
101
- );
118
+ const auth = await authFor(parsed, deps);
102
119
  request ??= await buildRequest(command, parsed, {
103
120
  organizationId: await credentialOrganization(deps, auth),
104
121
  });
105
- if (!command.readOnly) {
106
- const org =
107
- auth.kind === "api-key"
108
- ? "the API key's organization"
109
- : auth.org
110
- ? orgLabel(auth.org)
111
- : "your default organization";
112
- deps.stderr(`Acting in ${org}.\n`);
113
- }
122
+ announce(command, auth, deps);
114
123
 
115
124
  const response = await send(
116
125
  deps.env.apiUrl,
@@ -153,13 +162,30 @@ async function runCommand(
153
162
  return 0;
154
163
  }
155
164
 
165
+ function nodeRemote(
166
+ apiUrl: string,
167
+ auth: Auth,
168
+ fetchImpl: typeof fetch,
169
+ ): Remote {
170
+ return {
171
+ json: async (request) =>
172
+ (await send(apiUrl, auth, request, false, fetchImpl)).json,
173
+ async download(url) {
174
+ const response = await fetchImpl(url);
175
+ if (!response.ok)
176
+ throw new Error(`download failed: HTTP ${response.status}`);
177
+ return new Uint8Array(await response.arrayBuffer());
178
+ },
179
+ };
180
+ }
181
+
156
182
  const DESCRIPTION =
157
183
  "The Asteroid CLI. Sign in with `asteroid login`, or set ASTEROID_API_KEY.\nAt a terminal, tables show up to 6 columns and cut long cells to fit; when output is piped, they show every column in full. Use `--json` for every field.";
158
184
 
159
185
  export async function run(argv: string[], deps: Deps): Promise<number> {
160
186
  try {
161
187
  const invocation = await parseInvocation(argv, {
162
- commands: COMMANDS,
188
+ commands: ALL_COMMANDS,
163
189
  builtins: BUILTINS,
164
190
  version: VERSION,
165
191
  description: DESCRIPTION,
@@ -172,6 +198,14 @@ export async function run(argv: string[], deps: Deps): Promise<number> {
172
198
  if (invocation.kind === "builtin") {
173
199
  return await runBuiltin(invocation.name, invocation.parsed, deps);
174
200
  }
201
+ if (invocation.command.local) {
202
+ const auth = await authFor(invocation.parsed, deps);
203
+ announce(invocation.command, auth, deps);
204
+ return await runSync(invocation.command, invocation.parsed, {
205
+ ...deps,
206
+ remote: nodeRemote(deps.env.apiUrl, auth, deps.fetch),
207
+ });
208
+ }
175
209
  return await runCommand(invocation.command, invocation.parsed, deps);
176
210
  } catch (error) {
177
211
  if (error instanceof UsageError) {
@@ -0,0 +1,80 @@
1
+ interface Payload {
2
+ activityType: string;
3
+ display?: { turnId?: string; summaryText?: string };
4
+ reasoning?: string;
5
+ message?: string;
6
+ actionName?: string;
7
+ info?: Record<string, unknown>;
8
+ summaryTokens?: number;
9
+ durationMs?: number;
10
+ }
11
+
12
+ export interface Activity {
13
+ payload: Payload;
14
+ }
15
+
16
+ function indentJson(data: Record<string, unknown>): string {
17
+ return JSON.stringify(data, null, 2)
18
+ .split("\n")
19
+ .map((line) => ` ${line}`)
20
+ .join("\n");
21
+ }
22
+
23
+ function action(label: string, payload: Payload): string {
24
+ const info = payload.info;
25
+ const detail =
26
+ info && Object.keys(info).length > 0 ? `\ninfo:\n${indentJson(info)}` : "";
27
+ return `[${label}: ${payload.actionName}]:${detail}\n`;
28
+ }
29
+
30
+ function line(payload: Payload): string {
31
+ switch (payload.activityType) {
32
+ case "reasoning":
33
+ return `[reasoning]:\n${payload.reasoning}\n`;
34
+ case "generic":
35
+ return `[message]:\n${payload.message}\n`;
36
+ case "user_message_received":
37
+ return `[user_message]:\n${payload.message}\n`;
38
+ case "action_started":
39
+ return action("action_started", payload);
40
+ case "action_completed":
41
+ return action("action_completed", payload);
42
+ case "action_failed":
43
+ return `[action_failed: ${payload.actionName}]:\nfailure: ${payload.message}\n`;
44
+ case "compaction_performed":
45
+ return `[compaction]: summary_tokens: ${payload.summaryTokens}, duration_ms: ${payload.durationMs}\n`;
46
+ case "compaction_started":
47
+ return "[compaction_started]\n";
48
+ default: {
49
+ const summary = payload.display?.summaryText;
50
+ return summary
51
+ ? `[${payload.activityType}]: ${summary}\n`
52
+ : `[${payload.activityType}]\n`;
53
+ }
54
+ }
55
+ }
56
+
57
+ export function formatActivitiesMarkdown(
58
+ activities: Activity[],
59
+ status: string,
60
+ createdAt: string,
61
+ ): string {
62
+ const lines = [
63
+ "# Execution Activities\n",
64
+ `Status: ${status}`,
65
+ `Started: ${createdAt}`,
66
+ ];
67
+ let currentTurn = "";
68
+ for (const { payload } of activities) {
69
+ const turnId = payload.display?.turnId ?? "";
70
+ if (turnId && turnId !== currentTurn) {
71
+ currentTurn = turnId;
72
+ lines.push(`\n---\n\n## ${turnId}\n`);
73
+ } else if (!currentTurn && !turnId) {
74
+ currentTurn = "_started";
75
+ lines.push("\n---\n");
76
+ }
77
+ lines.push(line(payload));
78
+ }
79
+ return lines.join("\n");
80
+ }
@@ -0,0 +1,111 @@
1
+ import type { CliCommand, Option } from "../types";
2
+
3
+ function option(
4
+ name: string,
5
+ type: Option["type"],
6
+ description: string,
7
+ extra: Partial<Option> = {},
8
+ ): Option {
9
+ return { name, flag: name, type, required: false, description, ...extra };
10
+ }
11
+
12
+ const DIR = option(
13
+ "dir",
14
+ "string",
15
+ "Write here instead of `<workflow-name>/...` under the current directory.",
16
+ );
17
+
18
+ const local = {
19
+ method: "GET",
20
+ path: "",
21
+ binary: false,
22
+ destructive: false,
23
+ local: true,
24
+ } as const;
25
+
26
+ export const SYNC_COMMANDS: CliCommand[] = [
27
+ {
28
+ ...local,
29
+ command: "workflow pull",
30
+ tool: "workflow_pull",
31
+ title: "Pull a workflow to disk",
32
+ description:
33
+ "Write a workflow's editable head to `<workflow-name>/head/` as files: settings, per-node instructions and scripts, and the workflow's own files. Pass --version-id for a pinned version, written read-only to `<workflow-name>/.versions/`. Edit the head files, then run `asteroid workflow push` from that folder.",
34
+ readOnly: true,
35
+ args: [
36
+ {
37
+ name: "workflowId",
38
+ flag: "workflow-id",
39
+ type: "string",
40
+ required: true,
41
+ },
42
+ ],
43
+ query: [
44
+ option(
45
+ "version-id",
46
+ "string",
47
+ "A workflow version ID. Omit for the editable head.",
48
+ ),
49
+ DIR,
50
+ ],
51
+ },
52
+ {
53
+ ...local,
54
+ command: "workflow push",
55
+ tool: "workflow_push",
56
+ title: "Push local edits to a workflow head",
57
+ description:
58
+ "Send the edits in a pulled head folder to the workflow's editable head: changed and new files are written, removed files are deleted. Fails if the head changed since the pull; pull again and reapply. Push does not publish.",
59
+ readOnly: false,
60
+ args: [],
61
+ query: [
62
+ option(
63
+ "dir",
64
+ "string",
65
+ "The pulled head folder. Defaults to the current directory.",
66
+ ),
67
+ ],
68
+ },
69
+ {
70
+ ...local,
71
+ command: "execution pull",
72
+ tool: "execution_pull",
73
+ title: "Pull an execution to disk",
74
+ description:
75
+ "Write an execution to `<workflow-name>/executions/<id>/`: summary.json, activities.md (the step-by-step log), and the files the run produced.",
76
+ readOnly: true,
77
+ args: [
78
+ {
79
+ name: "executionId",
80
+ flag: "execution-id",
81
+ type: "string",
82
+ required: true,
83
+ },
84
+ ],
85
+ query: [
86
+ option(
87
+ "files",
88
+ "string",
89
+ "Only download files whose path matches this glob. --no-files skips downloads.",
90
+ {
91
+ negatable: true,
92
+ },
93
+ ),
94
+ option(
95
+ "summary-only",
96
+ "boolean",
97
+ "Write summary.json only: no activities, no files.",
98
+ ),
99
+ option(
100
+ "max-file-size",
101
+ "integer",
102
+ "Skip files larger than this many bytes.",
103
+ ),
104
+ option(
105
+ "dir",
106
+ "string",
107
+ "Write here instead of `<workflow-name>/executions/<id>/` under the current directory.",
108
+ ),
109
+ ],
110
+ },
111
+ ];