@tiny-fish/cli 0.1.3-next.26 → 0.1.3-next.27

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
+ }
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,31 @@
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
+ throw new Error(`Failed to read batch store at ${file}: ${e.message}`);
21
+ }
22
+ }
23
+ /** Appends `batch` to the persistent store. Reads existing entries first. */
24
+ export function saveBatch(batch) {
25
+ fs.mkdirSync(configDir(), { recursive: true, mode: 0o700 });
26
+ const existing = loadBatches();
27
+ fs.writeFileSync(batchesFile(), JSON.stringify([...existing, batch], null, 2), { mode: 0o600 });
28
+ }
29
+ export function findBatch(batchId) {
30
+ return loadBatches().find((b) => b.batch_id === batchId);
31
+ }
@@ -1,7 +1,10 @@
1
- import type { CancelRunResponse, ListRunsOptions, ListRunsResponse, RunApiResponse, RunRequest, RunResult, StreamEvent } from "./types.js";
1
+ import type { BatchCancelResponse, BatchGetResponse, BatchRunRequest, BatchRunResponse, CancelRunResponse, ListRunsOptions, ListRunsResponse, RunApiResponse, RunRequest, RunResult, StreamEvent } from "./types.js";
2
2
  export declare function runSync(req: RunRequest, apiKey: string, signal?: AbortSignal): Promise<RunResult>;
3
3
  export declare function runAsync(req: RunRequest, apiKey: string): Promise<RunResult>;
4
4
  export declare function runStream(req: RunRequest, apiKey: string, signal?: AbortSignal): AsyncGenerator<StreamEvent>;
5
5
  export declare function listRuns(opts: ListRunsOptions, apiKey: string): Promise<ListRunsResponse>;
6
6
  export declare function getRun(runId: string, apiKey: string): Promise<RunApiResponse>;
7
7
  export declare function cancelRun(runId: string, apiKey: string): Promise<CancelRunResponse>;
8
+ export declare function submitBatch(req: BatchRunRequest, apiKey: string): Promise<BatchRunResponse>;
9
+ export declare function getBatchRuns(runIds: string[], apiKey: string): Promise<BatchGetResponse>;
10
+ export declare function cancelBatchRuns(runIds: string[], apiKey: string): Promise<BatchCancelResponse>;
@@ -123,3 +123,16 @@ export async function cancelRun(runId, apiKey) {
123
123
  const res = await postJson(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {}, apiKey, AbortSignal.timeout(API_TIMEOUT_MS));
124
124
  return res.json();
125
125
  }
126
+ // ── Batch operations ──────────────────────────────────────────────────────────
127
+ export async function submitBatch(req, apiKey) {
128
+ const res = await postJson("/v1/automation/run-batch", req, apiKey, AbortSignal.timeout(API_TIMEOUT_MS));
129
+ return res.json();
130
+ }
131
+ export async function getBatchRuns(runIds, apiKey) {
132
+ const res = await postJson("/v1/runs/batch", { run_ids: runIds }, apiKey, AbortSignal.timeout(API_TIMEOUT_MS));
133
+ return res.json();
134
+ }
135
+ export async function cancelBatchRuns(runIds, apiKey) {
136
+ const res = await postJson("/v1/runs/batch/cancel", { run_ids: runIds }, apiKey, AbortSignal.timeout(API_TIMEOUT_MS));
137
+ return res.json();
138
+ }
@@ -100,3 +100,38 @@ export interface CancelRunResponse {
100
100
  cancelled_at: string;
101
101
  message: string | null;
102
102
  }
103
+ export interface BatchRunEntry {
104
+ url: string;
105
+ goal: string;
106
+ }
107
+ export interface BatchRunRequest {
108
+ runs: BatchRunEntry[];
109
+ }
110
+ export interface BatchRunResponse {
111
+ run_ids: string[] | null;
112
+ error: {
113
+ code: string;
114
+ message: string;
115
+ } | null;
116
+ }
117
+ export interface BatchGetResponse {
118
+ data: RunApiResponse[];
119
+ not_found: string[] | null;
120
+ }
121
+ export interface BatchCancelResult {
122
+ run_id: string;
123
+ status: string;
124
+ cancelled_at: string | null;
125
+ message: string | null;
126
+ }
127
+ export interface BatchCancelResponse {
128
+ results: BatchCancelResult[];
129
+ not_found: string[] | null;
130
+ }
131
+ export interface LocalBatch {
132
+ batch_id: string;
133
+ run_ids: string[];
134
+ total: number;
135
+ submitted: number;
136
+ created_at: string;
137
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.1.3-next.26",
3
+ "version": "0.1.3-next.27",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {