@tiny-fish/cli 0.1.3 → 0.1.4-next.35

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,2 @@
1
+ import { Command } from "commander";
2
+ export declare function registerBatch(agentCmd: Command): void;
@@ -0,0 +1,223 @@
1
+ import * as fs from "fs";
2
+ import { randomUUID } from "crypto";
3
+ import { getApiKey } from "../lib/auth.js";
4
+ import { cancelBatchRuns, getBatchRuns, submitBatch } from "../lib/client.js";
5
+ import { findBatch, loadBatches, saveBatch } from "../lib/batch-store.js";
6
+ import { err, handleApiError, out, outLine } from "../lib/output.js";
7
+ import { BASE_URL } from "../lib/constants.js";
8
+ // ── CSV parsing ───────────────────────────────────────────────────────────────
9
+ /**
10
+ * Parse one RFC 4180 field from `line` starting at `pos`.
11
+ * Returns { value, next } where `next` is the index after the field (at the
12
+ * delimiter comma or end-of-string). Throws on unclosed quotes.
13
+ */
14
+ function parseField(line, pos, rowNum) {
15
+ if (line[pos] === '"') {
16
+ // Quoted field — read until closing unescaped quote
17
+ let value = "";
18
+ let i = pos + 1;
19
+ while (i < line.length) {
20
+ if (line[i] === '"') {
21
+ if (line[i + 1] === '"') {
22
+ value += '"';
23
+ i += 2;
24
+ continue;
25
+ } // escaped quote
26
+ i++; // skip closing quote
27
+ break;
28
+ }
29
+ value += line[i++];
30
+ }
31
+ if (i > line.length)
32
+ throw new Error(`Row ${rowNum}: unclosed quote in field`);
33
+ // Skip optional comma after closing quote
34
+ return { value, next: line[i] === "," ? i + 1 : i };
35
+ }
36
+ // Unquoted field — read to next comma
37
+ const commaIdx = line.indexOf(",", pos);
38
+ if (commaIdx === -1)
39
+ return { value: line.slice(pos).trim(), next: line.length };
40
+ return { value: line.slice(pos, commaIdx).trim(), next: commaIdx + 1 };
41
+ }
42
+ /** Parse a strict two-column CSV with header row (url,goal). Supports RFC 4180 quoted fields. */
43
+ function parseCsv(content) {
44
+ const lines = content.split(/\r?\n/).filter((l) => l.trim().length > 0);
45
+ if (lines.length === 0)
46
+ throw new Error("CSV file is empty");
47
+ const header = lines[0].trim();
48
+ if (header !== "url,goal") {
49
+ throw new Error(`Invalid CSV header: "${header}". Expected: "url,goal"`);
50
+ }
51
+ const dataLines = lines.slice(1);
52
+ if (dataLines.length === 0)
53
+ throw new Error("CSV file has no data rows");
54
+ return dataLines.map((line, idx) => {
55
+ const rowNum = idx + 2;
56
+ const urlField = parseField(line, 0, rowNum);
57
+ if (urlField.next >= line.length && line.indexOf(",") === -1) {
58
+ throw new Error(`Row ${rowNum}: missing comma separator — "${line}"`);
59
+ }
60
+ const goalField = parseField(line, urlField.next, rowNum);
61
+ if (!urlField.value)
62
+ throw new Error(`Row ${rowNum}: missing url — "${line}"`);
63
+ if (!goalField.value)
64
+ throw new Error(`Row ${rowNum}: missing goal — "${line}"`);
65
+ return { url: urlField.value, goal: goalField.value };
66
+ });
67
+ }
68
+ // ── register ──────────────────────────────────────────────────────────────────
69
+ export function registerBatch(agentCmd) {
70
+ const batchCmd = agentCmd
71
+ .command("batch")
72
+ .description("Batch run operations")
73
+ .enablePositionalOptions();
74
+ // ── batch run ───────────────────────────────────────────────────────────────
75
+ batchCmd
76
+ .command("run")
77
+ .description("Submit a batch of runs from a CSV file (columns: url,goal)")
78
+ .requiredOption("--input <file>", "Path to CSV file with columns: url,goal")
79
+ .option("--pretty", "Human-readable output")
80
+ .action(async (opts) => {
81
+ // Read CSV file
82
+ let csvContent;
83
+ try {
84
+ csvContent = fs.readFileSync(opts.input, "utf8");
85
+ }
86
+ catch {
87
+ err({ error: `Cannot read file: ${opts.input}` });
88
+ process.exit(1);
89
+ }
90
+ // Parse CSV — throws on any malformed input
91
+ let runs;
92
+ try {
93
+ runs = parseCsv(csvContent);
94
+ }
95
+ catch (e) {
96
+ err({ error: String(e instanceof Error ? e.message : e), recommended_action: "Fix the CSV file and try again" });
97
+ process.exit(1);
98
+ }
99
+ const apiKey = getApiKey();
100
+ const response = await submitBatch({ runs }, apiKey).catch(handleApiError);
101
+ // Surface application-level errors from the API before persisting
102
+ if (response.error)
103
+ handleApiError(response.error);
104
+ const runIds = response.run_ids;
105
+ if (!runIds || runIds.length === 0) {
106
+ err({ error: "Batch submission returned no run IDs", recommended_action: "Check the API key and try again" });
107
+ process.exit(1);
108
+ }
109
+ const submitted = runIds.length;
110
+ const batchId = randomUUID();
111
+ const createdAt = new Date().toISOString();
112
+ const batch = {
113
+ batch_id: batchId,
114
+ run_ids: runIds,
115
+ total: runs.length,
116
+ submitted,
117
+ created_at: createdAt,
118
+ };
119
+ saveBatch(batch);
120
+ const resultsUrl = `${BASE_URL}/runs`;
121
+ if (opts.pretty) {
122
+ outLine("Batch submitted");
123
+ outLine(`ID: ${batchId}`);
124
+ outLine(`Total: ${runs.length}`);
125
+ outLine(`Submitted: ${submitted}`);
126
+ outLine(`Runs URL: ${resultsUrl}`);
127
+ }
128
+ else {
129
+ out({ data: { batch_id: batchId, total: runs.length, submitted, results_url: resultsUrl } });
130
+ }
131
+ });
132
+ // ── batch list ──────────────────────────────────────────────────────────────
133
+ batchCmd
134
+ .command("list")
135
+ .description("List locally tracked batches")
136
+ .option("--pretty", "Human-readable output")
137
+ .action((opts) => {
138
+ const batches = loadBatches();
139
+ if (opts.pretty) {
140
+ if (batches.length === 0) {
141
+ outLine("No batches found.");
142
+ return;
143
+ }
144
+ outLine(`${"BATCH ID".padEnd(38)} ${"TOTAL".padEnd(6)} ${"SUBMITTED".padEnd(9)} CREATED AT`);
145
+ outLine(`${"-".repeat(38)} ${"-".repeat(6)} ${"-".repeat(9)} ${"-".repeat(24)}`);
146
+ for (const b of batches) {
147
+ outLine(`${b.batch_id.padEnd(38)} ${String(b.total).padEnd(6)} ${String(b.submitted).padEnd(9)} ${b.created_at}`);
148
+ }
149
+ }
150
+ else {
151
+ out(batches);
152
+ }
153
+ });
154
+ // ── batch get ───────────────────────────────────────────────────────────────
155
+ batchCmd
156
+ .command("get <batch_id>")
157
+ .description("Get run results for a batch")
158
+ .option("--pretty", "Human-readable output")
159
+ .action(async (batchId, opts) => {
160
+ const batch = findBatch(batchId);
161
+ if (!batch) {
162
+ err({ error: `Batch not found: ${batchId}` });
163
+ process.exit(1);
164
+ }
165
+ const apiKey = getApiKey();
166
+ const response = await getBatchRuns(batch.run_ids, apiKey).catch(handleApiError);
167
+ if (opts.pretty) {
168
+ outLine(`Batch: ${batchId}`);
169
+ outLine(`Total: ${batch.total}`);
170
+ outLine(`Found: ${response.data.length}`);
171
+ if (response.not_found && response.not_found.length > 0) {
172
+ outLine(`Not found: ${response.not_found.length}`);
173
+ }
174
+ for (const run of response.data) {
175
+ outLine("");
176
+ outLine(`Run: ${run.run_id}`);
177
+ outLine(`Status: ${run.status}${run.num_of_steps != null ? ` (${run.num_of_steps} steps)` : ""}`);
178
+ outLine(`Goal: ${run.goal}`);
179
+ if (run.result != null) {
180
+ outLine(`Result: ${JSON.stringify(run.result, null, 2).replace(/\n/g, "\n ")}`);
181
+ }
182
+ else if (run.error) {
183
+ outLine(`Error: ${JSON.stringify(run.error)}`);
184
+ }
185
+ }
186
+ }
187
+ else {
188
+ out({ batch_id: batchId, ...response });
189
+ }
190
+ });
191
+ // ── batch cancel ─────────────────────────────────────────────────────────────
192
+ batchCmd
193
+ .command("cancel <batch_id>")
194
+ .description("Cancel all runs in a batch")
195
+ .option("--pretty", "Human-readable output")
196
+ .action(async (batchId, opts) => {
197
+ const batch = findBatch(batchId);
198
+ if (!batch) {
199
+ err({ error: `Batch not found: ${batchId}` });
200
+ process.exit(1);
201
+ }
202
+ const apiKey = getApiKey();
203
+ const response = await cancelBatchRuns(batch.run_ids, apiKey).catch(handleApiError);
204
+ if (opts.pretty) {
205
+ const cancelled = response.results.filter((r) => r.status === "CANCELLED").length;
206
+ outLine(`Batch ${batchId}: ${cancelled}/${response.results.length} run(s) cancelled.`);
207
+ if (response.results.length > 0) {
208
+ outLine("");
209
+ outLine(`${"RUN ID".padEnd(36)} ${"STATUS".padEnd(10)} CANCELLED AT`);
210
+ outLine(`${"-".repeat(36)} ${"-".repeat(10)} ${"-".repeat(24)}`);
211
+ for (const r of response.results) {
212
+ outLine(`${r.run_id.padEnd(36)} ${r.status.padEnd(10)} ${r.cancelled_at ?? "-"}`);
213
+ }
214
+ }
215
+ if (response.not_found && response.not_found.length > 0) {
216
+ outLine(`\nNot found: ${response.not_found.join(", ")}`);
217
+ }
218
+ }
219
+ else {
220
+ out(response);
221
+ }
222
+ });
223
+ }
@@ -1,15 +1,30 @@
1
+ import { EventType, RunStatus } from "@tiny-fish/sdk";
1
2
  import { getApiKey } from "../lib/auth.js";
2
3
  import { cancelRun, runAsync, runStream, runSync } from "../lib/client.js";
3
4
  import { err, errLine, handleApiError, out, outLine } from "../lib/output.js";
4
- import { RunStatus, StreamEventType } from "../lib/types.js";
5
5
  import { BASE_URL } from "../lib/constants.js";
6
- const RUN_TIMEOUT_MS = 20 * 60 * 1000; // 20 minutes
6
+ function extractErrorMessage(error) {
7
+ if (typeof error === "string")
8
+ return error;
9
+ if (typeof error === "object" && error !== null) {
10
+ const maybeError = error;
11
+ if (typeof maybeError.message === "string") {
12
+ return maybeError.message;
13
+ }
14
+ }
15
+ return undefined;
16
+ }
7
17
  function checkRunResult(result, expectComplete = true) {
8
- if (result.error) {
9
- err({ error: result.error, run_id: result.run_id, status: result.status });
18
+ const errorMessage = extractErrorMessage(result.error);
19
+ if (errorMessage) {
20
+ err({
21
+ error: errorMessage,
22
+ run_id: result.run_id,
23
+ status: "status" in result ? result.status : undefined,
24
+ });
10
25
  process.exit(1);
11
26
  }
12
- if (expectComplete && result.status && result.status !== RunStatus.COMPLETED) {
27
+ if (expectComplete && "status" in result && result.status && result.status !== RunStatus.COMPLETED) {
13
28
  err({ error: `Run ${result.status}`, run_id: result.run_id, status: result.status });
14
29
  process.exit(1);
15
30
  }
@@ -68,8 +83,11 @@ export function registerRun(agentCmd) {
68
83
  handleApiError(e);
69
84
  }
70
85
  checkRunResult(result, false); // async = PENDING is expected
71
- const { run_id: _asyncRunId, ...asyncRest } = result; // eslint-disable-line @typescript-eslint/no-unused-vars
72
- const asyncResultWithUrl = { run_id: result.run_id, run_url: `${BASE_URL}/runs/${result.run_id}`, ...asyncRest };
86
+ const asyncResultWithUrl = {
87
+ ...result,
88
+ status: RunStatus.PENDING,
89
+ run_url: `${BASE_URL}/runs/${result.run_id}`,
90
+ };
73
91
  if (opts.pretty) {
74
92
  outLine(`Run submitted\nID: ${asyncResultWithUrl.run_id}\nURL: ${asyncResultWithUrl.run_url}`);
75
93
  }
@@ -82,14 +100,16 @@ export function registerRun(agentCmd) {
82
100
  errLine("Waiting for result...");
83
101
  let result;
84
102
  try {
85
- result = await runSync(req, apiKey, AbortSignal.timeout(RUN_TIMEOUT_MS));
103
+ result = await runSync(req, apiKey);
86
104
  }
87
105
  catch (e) {
88
106
  handleApiError(e);
89
107
  }
90
108
  checkRunResult(result);
91
- const { run_id: _syncRunId, ...syncRest } = result; // eslint-disable-line @typescript-eslint/no-unused-vars
92
- const syncResultWithUrl = { run_id: result.run_id, run_url: `${BASE_URL}/runs/${result.run_id}`, ...syncRest };
109
+ const syncResultWithUrl = {
110
+ ...result,
111
+ run_url: `${BASE_URL}/runs/${result.run_id}`,
112
+ };
93
113
  if (opts.pretty) {
94
114
  outLine(`Status: ${syncResultWithUrl.status}\n\n${JSON.stringify(syncResultWithUrl.result ?? {}, null, 2)}`);
95
115
  }
@@ -98,11 +118,10 @@ export function registerRun(agentCmd) {
98
118
  }
99
119
  return;
100
120
  }
101
- // Default: SSE stream — 20 min timeout + Ctrl+C cancellation
121
+ // Default: SSE stream — Ctrl+C cancels the active run.
102
122
  const controller = new AbortController();
103
123
  let siginted = false;
104
124
  let capturedRunId = null;
105
- const timeout = setTimeout(() => controller.abort(), RUN_TIMEOUT_MS);
106
125
  const onSigint = () => {
107
126
  siginted = true;
108
127
  controller.abort();
@@ -115,6 +134,9 @@ export function registerRun(agentCmd) {
115
134
  streamFailed = true;
116
135
  break;
117
136
  }
137
+ if (event.type === EventType.COMPLETE) {
138
+ break;
139
+ }
118
140
  }
119
141
  }
120
142
  catch (e) {
@@ -130,14 +152,9 @@ export function registerRun(agentCmd) {
130
152
  process.stderr.write(cancelled ? "\nRun cancelled.\n" : "\n");
131
153
  process.exit(130);
132
154
  }
133
- if (controller.signal.aborted) {
134
- err({ error: "Run timed out after 20 minutes" });
135
- process.exit(1);
136
- }
137
155
  handleApiError(e);
138
156
  }
139
157
  finally {
140
- clearTimeout(timeout);
141
158
  process.removeListener("SIGINT", onSigint);
142
159
  }
143
160
  if (streamFailed)
@@ -150,42 +167,37 @@ export function registerRun(agentCmd) {
150
167
  * Returns false on terminal error (caller should exit 1); true otherwise.
151
168
  */
152
169
  function handleStreamEvent(event, pretty, onRunId) {
153
- if (event.type === StreamEventType.ERROR) {
154
- err({ error: event.error, run_id: event.run_id, status: StreamEventType.ERROR });
155
- return false;
156
- }
157
- if (event.type === StreamEventType.COMPLETE && event.status !== RunStatus.COMPLETED) {
158
- err({ error: event.error ?? `Run ${event.status}`, run_id: event.run_id, status: event.status });
170
+ if (event.type === EventType.COMPLETE && event.status !== RunStatus.COMPLETED) {
171
+ err({ error: event.error?.message ?? `Run ${event.status}`, run_id: event.run_id, status: event.status });
159
172
  return false;
160
173
  }
161
174
  if (pretty) {
162
175
  switch (event.type) {
163
- case StreamEventType.STARTED:
176
+ case EventType.STARTED:
164
177
  if (onRunId && event.run_id)
165
178
  onRunId(event.run_id);
166
179
  outLine(`▶ Run started`);
167
180
  break;
168
- case StreamEventType.STREAMING_URL:
181
+ case EventType.STREAMING_URL:
169
182
  outLine(`🔗 Live view: ${event.streaming_url}`);
170
183
  break;
171
- case StreamEventType.PROGRESS:
184
+ case EventType.PROGRESS:
172
185
  outLine(`• ${event.purpose}`);
173
186
  break;
174
- case StreamEventType.COMPLETE: {
187
+ case EventType.COMPLETE: {
175
188
  const runUrl = `${BASE_URL}/runs/${event.run_id}`;
176
189
  outLine(`✓ Completed\n\n${JSON.stringify(event.result ?? {}, null, 2)}\n\nView run: ${runUrl}`);
177
190
  break;
178
191
  }
179
- case StreamEventType.HEARTBEAT:
192
+ case EventType.HEARTBEAT:
180
193
  // silently skip — keep-alive noise
181
194
  break;
182
195
  }
183
196
  }
184
197
  else {
185
198
  // Raw JSON: emit all events (STREAMING_URL and HEARTBEAT are useful for agents)
186
- if (event.type === StreamEventType.COMPLETE) {
187
- const { run_id: _eventRunId, ...eventRest } = event; // eslint-disable-line @typescript-eslint/no-unused-vars
188
- out({ run_id: event.run_id, run_url: `${BASE_URL}/runs/${event.run_id}`, ...eventRest });
199
+ if (event.type === EventType.COMPLETE) {
200
+ out({ ...event, run_url: `${BASE_URL}/runs/${event.run_id}` });
189
201
  }
190
202
  else {
191
203
  out(event);
@@ -1,7 +1,7 @@
1
+ import { RunStatus } from "@tiny-fish/sdk";
1
2
  import { getApiKey } from "../lib/auth.js";
2
3
  import { cancelRun, getRun, listRuns } from "../lib/client.js";
3
4
  import { err, handleApiError, out, outLine } from "../lib/output.js";
4
- import { RunStatus } from "../lib/types.js";
5
5
  const VALID_STATUSES = Object.values(RunStatus);
6
6
  export function registerRuns(runCmd) {
7
7
  // ── run list ───────────────────────────────────────────────────────────────
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { createRequire } from "module";
3
3
  import { Command } from "commander";
4
4
  import { registerAuth } from "./commands/auth.js";
5
+ import { registerBatch } from "./commands/batch.js";
5
6
  import { registerRun } from "./commands/run.js";
6
7
  import { registerRuns } from "./commands/runs.js";
7
8
  const { version } = createRequire(import.meta.url)("../package.json");
@@ -26,6 +27,7 @@ const agentCmd = program
26
27
  .enablePositionalOptions();
27
28
  const runCmd = registerRun(agentCmd);
28
29
  registerRuns(runCmd);
30
+ registerBatch(agentCmd);
29
31
  // Await parseAsync so async command handlers complete before the process exits
30
32
  program.parseAsync(process.argv).catch((e) => {
31
33
  process.stderr.write(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }) + "\n");
@@ -1,4 +1,5 @@
1
1
  export { DASHBOARD_URL } from "./constants.js";
2
+ export declare function configDir(): string;
2
3
  interface TinyfishConfig {
3
4
  api_key?: string;
4
5
  }
package/dist/lib/auth.js CHANGED
@@ -5,7 +5,7 @@ import { err } from "./output.js";
5
5
  const KEY_PREFIXES = ["sk-tinyfish-", "sk-mino-"];
6
6
  const MIN_KEY_LENGTH = 20;
7
7
  export { DASHBOARD_URL } from "./constants.js";
8
- function configDir() {
8
+ export function configDir() {
9
9
  // os.homedir() is cross-platform; process.env.HOME is undefined on Windows
10
10
  return path.join(os.homedir(), ".tinyfish");
11
11
  }
@@ -0,0 +1,5 @@
1
+ import type { LocalBatch } from "./types.js";
2
+ export declare function loadBatches(): LocalBatch[];
3
+ /** Appends `batch` to the persistent store. Reads existing entries first. */
4
+ export declare function saveBatch(batch: LocalBatch): void;
5
+ export declare function findBatch(batchId: string): LocalBatch | undefined;
@@ -0,0 +1,32 @@
1
+ import * as fs from "fs";
2
+ import { configDir } from "./auth.js";
3
+ function batchesFile() {
4
+ return `${configDir()}/batches.json`;
5
+ }
6
+ export function loadBatches() {
7
+ const file = batchesFile();
8
+ try {
9
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
10
+ if (Array.isArray(raw))
11
+ return raw;
12
+ return [];
13
+ }
14
+ catch (e) {
15
+ // File does not exist — treat as empty store (expected on first use)
16
+ if (typeof e === "object" && e !== null && e.code === "ENOENT")
17
+ return [];
18
+ // Any other error (corrupt JSON, permission denied) — rethrow rather than
19
+ // silently returning [] and risking saveBatch() overwriting the store.
20
+ const message = e instanceof Error ? e.message : String(e);
21
+ throw new Error(`Failed to read batch store at ${file}: ${message}`, { cause: e });
22
+ }
23
+ }
24
+ /** Appends `batch` to the persistent store. Reads existing entries first. */
25
+ export function saveBatch(batch) {
26
+ fs.mkdirSync(configDir(), { recursive: true, mode: 0o700 });
27
+ const existing = loadBatches();
28
+ fs.writeFileSync(batchesFile(), JSON.stringify([...existing, batch], null, 2), { mode: 0o600 });
29
+ }
30
+ export function findBatch(batchId) {
31
+ return loadBatches().find((b) => b.batch_id === batchId);
32
+ }
@@ -1,7 +1,11 @@
1
- import type { CancelRunResponse, ListRunsOptions, ListRunsResponse, RunApiResponse, RunRequest, RunResult, StreamEvent } from "./types.js";
2
- export declare function runSync(req: RunRequest, apiKey: string, signal?: AbortSignal): Promise<RunResult>;
3
- export declare function runAsync(req: RunRequest, apiKey: string): Promise<RunResult>;
4
- export declare function runStream(req: RunRequest, apiKey: string, signal?: AbortSignal): AsyncGenerator<StreamEvent>;
5
- export declare function listRuns(opts: ListRunsOptions, apiKey: string): Promise<ListRunsResponse>;
6
- export declare function getRun(runId: string, apiKey: string): Promise<RunApiResponse>;
1
+ import { type AgentRunAsyncResponse, type AgentRunParams, type AgentRunResponse, type AgentRunWithStreamingResponse, type Run, type RunListParams, type RunListResponse } from "@tiny-fish/sdk";
2
+ import type { BatchCancelResponse, BatchGetResponse, BatchRunRequest, BatchRunResponse, CancelRunResponse } from "./types.js";
3
+ export declare function runSync(req: AgentRunParams, apiKey: string): Promise<AgentRunResponse>;
4
+ export declare function runAsync(req: AgentRunParams, apiKey: string): Promise<AgentRunAsyncResponse>;
5
+ export declare function runStream(req: AgentRunParams, apiKey: string, signal?: AbortSignal): AsyncGenerator<AgentRunWithStreamingResponse>;
6
+ export declare function listRuns(opts: RunListParams, apiKey: string): Promise<RunListResponse>;
7
+ export declare function getRun(runId: string, apiKey: string): Promise<Run>;
7
8
  export declare function cancelRun(runId: string, apiKey: string): Promise<CancelRunResponse>;
9
+ export declare function submitBatch(req: BatchRunRequest, apiKey: string): Promise<BatchRunResponse>;
10
+ export declare function getBatchRuns(runIds: string[], apiKey: string): Promise<BatchGetResponse>;
11
+ export declare function cancelBatchRuns(runIds: string[], apiKey: string): Promise<BatchCancelResponse>;
@@ -1,125 +1,159 @@
1
+ import { APIStatusError, TinyFish, runSchema, } from "@tiny-fish/sdk";
1
2
  import { BASE_URL } from "./constants.js";
2
3
  import { ApiError } from "./output.js";
3
- const API_TIMEOUT_MS = 30_000;
4
- // ── Debug logging ─────────────────────────────────────────────────────────────
5
- function isDebugEnabled() {
6
- return /^(1|true)$/i.test(process.env["TINYFISH_DEBUG"] ?? "");
7
- }
8
- function debugLog(msg) {
9
- if (isDebugEnabled()) {
10
- process.stderr.write(`[debug] ${msg}\n`);
4
+ import { z } from "zod";
5
+ class TinyFishCliClient extends TinyFish {
6
+ _buildHeaders() {
7
+ return {
8
+ ...super._buildHeaders(),
9
+ "X-TF-Request-Origin": "tinyfish-cli",
10
+ };
11
11
  }
12
12
  }
13
- // ── Shared HTTP helpers ───────────────────────────────────────────────────────
14
- function headers(apiKey) {
15
- return {
16
- "X-API-Key": apiKey,
17
- "Content-Type": "application/json",
18
- "X-TF-Request-Origin": "tinyfish-cli",
19
- };
13
+ function createSdkClient(apiKey, timeout) {
14
+ return new TinyFishCliClient({
15
+ apiKey,
16
+ baseURL: BASE_URL,
17
+ maxRetries: 0,
18
+ ...(timeout !== undefined ? { timeout } : {}),
19
+ });
20
20
  }
21
- async function throwIfError(res) {
22
- if (!res.ok) {
23
- const json = await res.json().catch(() => ({}));
24
- const message = json.error?.message ??
25
- json.detail ??
26
- `HTTP ${res.status}`;
27
- throw new ApiError(res.status, message);
21
+ function rethrowSdkError(error) {
22
+ if (error instanceof APIStatusError) {
23
+ throw new ApiError(error.statusCode, error.message);
28
24
  }
25
+ if (error instanceof Error) {
26
+ throw error;
27
+ }
28
+ throw new Error(String(error));
29
29
  }
30
- /** POST JSON to a path and return the raw Response, throwing ApiError on non-2xx. */
31
- async function postJson(path, body, apiKey, signal) {
32
- const start = Date.now();
33
- debugLog(`--> POST ${path}`);
34
- const res = await fetch(`${BASE_URL}${path}`, {
35
- method: "POST",
36
- headers: headers(apiKey),
37
- body: JSON.stringify(body),
38
- signal,
39
- });
40
- debugLog(`<-- ${res.status} (${Date.now() - start}ms)`);
41
- await throwIfError(res);
42
- return res;
43
- }
44
- /** GET a path and return the parsed JSON body, throwing ApiError on non-2xx. */
45
- async function getJson(path, apiKey, signal) {
46
- const start = Date.now();
47
- debugLog(`--> GET ${path}`);
48
- const res = await fetch(`${BASE_URL}${path}`, {
49
- method: "GET",
50
- headers: headers(apiKey),
51
- signal,
52
- });
53
- debugLog(`<-- ${res.status} (${Date.now() - start}ms)`);
54
- await throwIfError(res);
55
- return res.json();
30
+ const cancelRunResponseSchema = z.object({
31
+ run_id: z.string(),
32
+ status: z.string(),
33
+ cancelled_at: z.string().nullable(),
34
+ message: z.string().nullable(),
35
+ });
36
+ const batchRunResponseSchema = z.object({
37
+ run_ids: z.array(z.string()).nullable(),
38
+ error: z.object({
39
+ code: z.string(),
40
+ message: z.string(),
41
+ }).nullable(),
42
+ });
43
+ const batchGetResponseSchema = z.object({
44
+ data: z.array(runSchema),
45
+ not_found: z.array(z.string()).nullable(),
46
+ });
47
+ const batchCancelResultSchema = z.object({
48
+ run_id: z.string(),
49
+ status: z.string(),
50
+ cancelled_at: z.string().nullable(),
51
+ message: z.string().nullable(),
52
+ });
53
+ const batchCancelResponseSchema = z.object({
54
+ results: z.array(batchCancelResultSchema),
55
+ not_found: z.array(z.string()).nullable(),
56
+ });
57
+ function parseWithSchema(schema, value, message) {
58
+ const result = schema.safeParse(value);
59
+ if (!result.success) {
60
+ throw new Error(message);
61
+ }
62
+ return result.data;
56
63
  }
57
- /** Yield parsed StreamEvents from a single SSE data line. */
58
- function* parseSseLine(line) {
59
- const trimmed = line.trim();
60
- if (!trimmed.startsWith("data:"))
61
- return;
62
- const data = trimmed.slice(5).trim();
63
- if (!data || data === "[DONE]")
64
- return;
64
+ export async function runSync(req, apiKey) {
65
65
  try {
66
- yield JSON.parse(data);
66
+ return await createSdkClient(apiKey).agent.run(req);
67
67
  }
68
- catch {
69
- // malformed SSE line — skip
68
+ catch (error) {
69
+ rethrowSdkError(error);
70
70
  }
71
71
  }
72
- // ── Automation run ────────────────────────────────────────────────────────────
73
- export async function runSync(req, apiKey, signal) {
74
- const res = await postJson("/v1/automation/run", req, apiKey, signal);
75
- return res.json();
76
- }
77
72
  export async function runAsync(req, apiKey) {
78
- // Async just submits the job — 30 s is plenty to get an ACK
79
- const res = await postJson("/v1/automation/run-async", req, apiKey, AbortSignal.timeout(API_TIMEOUT_MS));
80
- return res.json();
73
+ try {
74
+ return await createSdkClient(apiKey).agent.queue(req);
75
+ }
76
+ catch (error) {
77
+ rethrowSdkError(error);
78
+ }
81
79
  }
82
80
  export async function* runStream(req, apiKey, signal) {
83
- const res = await postJson("/v1/automation/run-sse", req, apiKey, signal);
84
- if (!res.body) {
85
- throw new ApiError(200, "Empty response body");
86
- }
87
- const reader = res.body.getReader();
88
- const decoder = new TextDecoder();
89
- let buffer = "";
90
- while (true) {
91
- const { done, value } = await reader.read();
92
- if (done) {
93
- // Flush remaining buffer — handles final event without trailing newline
94
- for (const line of buffer.split("\n")) {
95
- yield* parseSseLine(line);
96
- }
97
- break;
81
+ let stream = null;
82
+ try {
83
+ stream = await createSdkClient(apiKey).agent.stream(req, { signal });
84
+ for await (const event of stream) {
85
+ yield event;
98
86
  }
99
- buffer += decoder.decode(value, { stream: true });
100
- const lines = buffer.split("\n");
101
- buffer = lines.pop() ?? "";
102
- for (const line of lines) {
103
- yield* parseSseLine(line);
87
+ }
88
+ catch (error) {
89
+ rethrowSdkError(error);
90
+ }
91
+ finally {
92
+ if (stream) {
93
+ try {
94
+ await stream.close();
95
+ }
96
+ catch {
97
+ // Ignore close errors — the original stream result/error already decided control flow.
98
+ }
104
99
  }
105
100
  }
106
101
  }
107
- // ── Run history ───────────────────────────────────────────────────────────────
108
102
  export async function listRuns(opts, apiKey) {
109
- const params = new URLSearchParams();
110
- if (opts.status)
111
- params.set("status", opts.status);
112
- if (opts.limit != null)
113
- params.set("limit", String(opts.limit));
114
- if (opts.cursor)
115
- params.set("cursor", opts.cursor);
116
- const qs = params.size ? `?${params}` : "";
117
- return getJson(`/v1/runs${qs}`, apiKey, AbortSignal.timeout(API_TIMEOUT_MS));
103
+ try {
104
+ return await createSdkClient(apiKey).runs.list(opts);
105
+ }
106
+ catch (error) {
107
+ rethrowSdkError(error);
108
+ }
118
109
  }
119
110
  export async function getRun(runId, apiKey) {
120
- return getJson(`/v1/runs/${encodeURIComponent(runId)}`, apiKey, AbortSignal.timeout(API_TIMEOUT_MS));
111
+ try {
112
+ return await createSdkClient(apiKey).runs.get(runId);
113
+ }
114
+ catch (error) {
115
+ rethrowSdkError(error);
116
+ }
121
117
  }
122
118
  export async function cancelRun(runId, apiKey) {
123
- const res = await postJson(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {}, apiKey, AbortSignal.timeout(API_TIMEOUT_MS));
124
- return res.json();
119
+ try {
120
+ const response = await createSdkClient(apiKey).post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, { json: {} });
121
+ return parseWithSchema(cancelRunResponseSchema, response, "Invalid cancel run response");
122
+ }
123
+ catch (error) {
124
+ rethrowSdkError(error);
125
+ }
126
+ }
127
+ export async function submitBatch(req, apiKey) {
128
+ try {
129
+ const response = await createSdkClient(apiKey).post("/v1/automation/run-batch", {
130
+ json: req,
131
+ });
132
+ return parseWithSchema(batchRunResponseSchema, response, "Invalid batch run response");
133
+ }
134
+ catch (error) {
135
+ rethrowSdkError(error);
136
+ }
137
+ }
138
+ export async function getBatchRuns(runIds, apiKey) {
139
+ try {
140
+ const response = await createSdkClient(apiKey).post("/v1/runs/batch", {
141
+ json: { run_ids: runIds },
142
+ });
143
+ return parseWithSchema(batchGetResponseSchema, response, "Invalid batch get response");
144
+ }
145
+ catch (error) {
146
+ rethrowSdkError(error);
147
+ }
148
+ }
149
+ export async function cancelBatchRuns(runIds, apiKey) {
150
+ try {
151
+ const response = await createSdkClient(apiKey).post("/v1/runs/batch/cancel", {
152
+ json: { run_ids: runIds },
153
+ });
154
+ return parseWithSchema(batchCancelResponseSchema, response, "Invalid batch cancel response");
155
+ }
156
+ catch (error) {
157
+ rethrowSdkError(error);
158
+ }
125
159
  }
@@ -1,102 +1,42 @@
1
- export interface RunRequest {
2
- goal: string;
3
- url: string;
4
- }
5
- export declare const RunStatus: {
6
- readonly PENDING: "PENDING";
7
- readonly RUNNING: "RUNNING";
8
- readonly COMPLETED: "COMPLETED";
9
- readonly FAILED: "FAILED";
10
- readonly CANCELLED: "CANCELLED";
11
- };
12
- export type RunStatus = (typeof RunStatus)[keyof typeof RunStatus];
13
- export declare const StreamEventType: {
14
- readonly STARTED: "STARTED";
15
- readonly STREAMING_URL: "STREAMING_URL";
16
- readonly HEARTBEAT: "HEARTBEAT";
17
- readonly PROGRESS: "PROGRESS";
18
- readonly COMPLETE: "COMPLETE";
19
- readonly ERROR: "ERROR";
20
- };
21
- export type StreamEventType = (typeof StreamEventType)[keyof typeof StreamEventType];
22
- export interface RunResult {
23
- run_id: string;
24
- status: RunStatus;
25
- result?: unknown;
26
- error?: string;
27
- }
28
- /** Discriminated union of all real SSE event shapes from the API */
29
- export type StreamEvent = {
30
- type: typeof StreamEventType.STARTED;
31
- run_id: string;
32
- timestamp: string;
33
- } | {
34
- type: typeof StreamEventType.STREAMING_URL;
35
- run_id: string;
36
- streaming_url: string;
37
- timestamp: string;
38
- } | {
39
- type: typeof StreamEventType.HEARTBEAT;
40
- timestamp: string;
41
- } | {
42
- type: typeof StreamEventType.PROGRESS;
43
- run_id: string;
44
- purpose: string;
45
- timestamp: string;
46
- } | {
47
- type: typeof StreamEventType.COMPLETE;
1
+ import type { Run, RunStatus } from "@tiny-fish/sdk";
2
+ export interface CancelRunResponse {
48
3
  run_id: string;
49
- status: RunStatus;
50
- timestamp: string;
51
- result?: unknown;
52
- error?: string;
53
- help_url?: string;
54
- help_message?: string;
55
- } | {
56
- type: typeof StreamEventType.ERROR;
57
- run_id?: string;
58
- error: string;
59
- timestamp: string;
60
- };
61
- export interface RunError {
62
- message: string;
63
- category: string;
64
- retry_after: string | null;
65
- help_url: string | null;
66
- help_message: string | null;
4
+ status: RunStatus | string;
5
+ cancelled_at: string | null;
6
+ message: string | null;
67
7
  }
68
- export interface RunApiResponse {
69
- run_id: string;
70
- status: RunStatus;
8
+ export interface BatchRunEntry {
9
+ url: string;
71
10
  goal: string;
72
- created_at: string;
73
- started_at: string | null;
74
- finished_at: string | null;
75
- num_of_steps: number;
76
- result: unknown;
77
- error: RunError | null;
78
- streaming_url: string | null;
79
- browser_config: {
80
- proxy_enabled: boolean | null;
81
- proxy_country_code: string | null;
82
- } | null;
83
11
  }
84
- export interface ListRunsResponse {
85
- data: RunApiResponse[];
86
- pagination: {
87
- total: number;
88
- next_cursor: string | null;
89
- has_more: boolean;
90
- };
12
+ export interface BatchRunRequest {
13
+ runs: BatchRunEntry[];
91
14
  }
92
- export interface ListRunsOptions {
93
- status?: RunStatus;
94
- limit?: number;
95
- cursor?: string;
15
+ export interface BatchRunResponse {
16
+ run_ids: string[] | null;
17
+ error: {
18
+ code: string;
19
+ message: string;
20
+ } | null;
96
21
  }
97
- export interface CancelRunResponse {
22
+ export interface BatchGetResponse {
23
+ data: Run[];
24
+ not_found: string[] | null;
25
+ }
26
+ export interface BatchCancelResult {
98
27
  run_id: string;
99
- status: RunStatus | string;
100
- cancelled_at: string;
28
+ status: string;
29
+ cancelled_at: string | null;
101
30
  message: string | null;
102
31
  }
32
+ export interface BatchCancelResponse {
33
+ results: BatchCancelResult[];
34
+ not_found: string[] | null;
35
+ }
36
+ export interface LocalBatch {
37
+ batch_id: string;
38
+ run_ids: string[];
39
+ total: number;
40
+ submitted: number;
41
+ created_at: string;
42
+ }
package/dist/lib/types.js CHANGED
@@ -1,18 +1 @@
1
- // ── Automation run request ────────────────────────────────────────────────────
2
- // ── Run status enum ───────────────────────────────────────────────────────────
3
- export const RunStatus = {
4
- PENDING: "PENDING",
5
- RUNNING: "RUNNING",
6
- COMPLETED: "COMPLETED",
7
- FAILED: "FAILED",
8
- CANCELLED: "CANCELLED",
9
- };
10
- // ── SSE event types ───────────────────────────────────────────────────────────
11
- export const StreamEventType = {
12
- STARTED: "STARTED",
13
- STREAMING_URL: "STREAMING_URL",
14
- HEARTBEAT: "HEARTBEAT",
15
- PROGRESS: "PROGRESS",
16
- COMPLETE: "COMPLETE",
17
- ERROR: "ERROR",
18
- };
1
+ export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.1.3",
4
- "description": "TinyFish CLI \u2014 run web automations from your terminal",
3
+ "version": "0.1.4-next.35",
4
+ "description": "TinyFish CLI run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "tinyfish": "./dist/index.js"
@@ -23,18 +23,24 @@
23
23
  "prepublishOnly": "npm run build"
24
24
  },
25
25
  "dependencies": {
26
+ "@tiny-fish/sdk": "^0.0.7",
26
27
  "commander": "^12.0.0",
27
- "globals": "^17.4.0"
28
+ "globals": "^17.4.0",
29
+ "zod": "^4.3.6"
28
30
  },
29
31
  "devDependencies": {
32
+ "@eslint/js": "^10.0.1",
30
33
  "@types/node": "^20.0.0",
31
- "@typescript-eslint/eslint-plugin": "^8.0.0",
32
- "@typescript-eslint/parser": "^8.0.0",
33
- "eslint": "^9.0.0",
34
+ "@typescript-eslint/eslint-plugin": "^8.57.2",
35
+ "@typescript-eslint/parser": "^8.57.2",
36
+ "eslint": "^10.1.0",
34
37
  "prettier": "^3.0.0",
35
38
  "typescript": "^5.0.0",
36
39
  "vitest": "^3.0.0"
37
40
  },
41
+ "overrides": {
42
+ "brace-expansion": "5.0.5"
43
+ },
38
44
  "engines": {
39
45
  "node": ">=24.0.0"
40
46
  },