@tiny-fish/cli 0.1.0

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 registerAuth(program: Command): void;
@@ -0,0 +1,180 @@
1
+ import { spawn } from "child_process";
2
+ import * as readline from "readline";
3
+ import { DASHBOARD_URL, clearConfig, loadConfig, maskKey, saveConfig, validateKeyFormat, } from "../lib/auth.js";
4
+ import { err, out, outLine } from "../lib/output.js";
5
+ /**
6
+ * Read a key from stdin without echoing it to the terminal.
7
+ * Uses setRawMode on TTY so characters are never written to the screen.
8
+ * Falls back to plain readline when stdin is not a TTY (piped CI/CD input).
9
+ */
10
+ async function readKeyHidden(prompt) {
11
+ process.stderr.write(prompt);
12
+ if (!process.stdin.isTTY) {
13
+ // Non-interactive (piped): read stdin line as-is — no echo suppression needed
14
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
15
+ return new Promise((resolve) => {
16
+ rl.once("line", (line) => {
17
+ rl.close();
18
+ resolve(line.trim());
19
+ });
20
+ });
21
+ }
22
+ // Interactive TTY: use raw mode so keystrokes are never echoed
23
+ return new Promise((resolve) => {
24
+ let input = "";
25
+ process.stdin.setRawMode(true);
26
+ process.stdin.resume();
27
+ process.stdin.setEncoding("utf8");
28
+ const onData = (char) => {
29
+ if (char === "\r" || char === "\n") {
30
+ process.stdin.setRawMode(false);
31
+ process.stdin.pause();
32
+ process.stdin.removeListener("data", onData);
33
+ process.stderr.write("\n");
34
+ resolve(input);
35
+ }
36
+ else if (char === "\u0003") {
37
+ // Ctrl+C — restore terminal state before exiting
38
+ process.stdin.setRawMode(false);
39
+ process.exit(130);
40
+ }
41
+ else if (char === "\u007f" || char === "\b") {
42
+ // Backspace / Delete
43
+ input = input.slice(0, -1);
44
+ }
45
+ else {
46
+ input += char;
47
+ }
48
+ };
49
+ process.stdin.on("data", onData);
50
+ });
51
+ }
52
+ /**
53
+ * Read key from piped stdin (CI/CD) or hidden TTY prompt (interactive).
54
+ * Never reads from a positional arg — avoids shell history / process list exposure.
55
+ */
56
+ async function readKeyFromStdinOrPrompt() {
57
+ if (!process.stdin.isTTY) {
58
+ // Piped input: echo $KEY | tinyfish auth set
59
+ return new Promise((resolve) => {
60
+ let data = "";
61
+ process.stdin.setEncoding("utf8");
62
+ process.stdin.on("data", (chunk) => (data += chunk));
63
+ process.stdin.on("end", () => resolve(data.trim()));
64
+ });
65
+ }
66
+ return readKeyHidden("Paste your API key: ");
67
+ }
68
+ export function registerAuth(program) {
69
+ const auth = program.command("auth").description("Manage your TinyFish API key");
70
+ auth
71
+ .command("login")
72
+ .description("Open the API keys page and save your key interactively")
73
+ .action(async () => {
74
+ process.stderr.write(`Opening ${DASHBOARD_URL} in your browser...\n`);
75
+ try {
76
+ // Use spawn with an explicit args array — no shell, no interpolation
77
+ let cmd;
78
+ let args;
79
+ if (process.platform === "darwin") {
80
+ cmd = "open";
81
+ args = [DASHBOARD_URL];
82
+ }
83
+ else if (process.platform === "win32") {
84
+ // "start" treats the first quoted arg as a window title — empty string avoids it
85
+ cmd = "cmd";
86
+ args = ["/c", "start", "", DASHBOARD_URL];
87
+ }
88
+ else {
89
+ cmd = "xdg-open";
90
+ args = [DASHBOARD_URL];
91
+ }
92
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
93
+ child.unref();
94
+ child.on("error", () => { }); // headless — ignore
95
+ }
96
+ catch {
97
+ // headless — ignore
98
+ }
99
+ process.stderr.write(`If the browser didn't open, visit: ${DASHBOARD_URL}\n\n`);
100
+ const key = await readKeyHidden("Paste your API key: ");
101
+ if (!key) {
102
+ err("API key cannot be empty");
103
+ process.exit(1);
104
+ }
105
+ if (!validateKeyFormat(key)) {
106
+ err("Invalid key format. Expected sk-tinyfish-... or sk-mino-...");
107
+ process.exit(1);
108
+ }
109
+ saveConfig(key);
110
+ out({ status: "ok", message: "API key saved", key_preview: maskKey(key) });
111
+ });
112
+ auth
113
+ .command("set")
114
+ .description("Save an API key. Pass via stdin: echo $KEY | tinyfish auth set (safe for CI/CD)")
115
+ .action(async () => {
116
+ // Read from stdin pipe (CI/CD) or hidden TTY prompt (interactive)
117
+ const key = await readKeyFromStdinOrPrompt();
118
+ if (!key) {
119
+ err("API key cannot be empty");
120
+ process.exit(1);
121
+ }
122
+ if (!validateKeyFormat(key)) {
123
+ err("Invalid key format. Expected sk-tinyfish-... or sk-mino-...");
124
+ process.exit(1);
125
+ }
126
+ saveConfig(key);
127
+ out({ status: "ok", message: "API key saved", key_preview: maskKey(key) });
128
+ });
129
+ auth
130
+ .command("status")
131
+ .description("Show which API key is active and its source")
132
+ .option("--pretty", "Human-readable output")
133
+ .action((opts) => {
134
+ const envKey = process.env.TINYFISH_API_KEY;
135
+ if (envKey) {
136
+ const authenticated = validateKeyFormat(envKey);
137
+ const result = { source: "env", key_preview: maskKey(envKey), authenticated };
138
+ if (opts.pretty) {
139
+ outLine(`Source: environment variable (TINYFISH_API_KEY)\nKey: ${maskKey(envKey)}`);
140
+ }
141
+ else {
142
+ out(result);
143
+ }
144
+ return;
145
+ }
146
+ const config = loadConfig();
147
+ if (config.api_key) {
148
+ const authenticated = validateKeyFormat(config.api_key);
149
+ const result = {
150
+ source: "config",
151
+ key_preview: maskKey(config.api_key),
152
+ authenticated,
153
+ };
154
+ if (opts.pretty) {
155
+ outLine(`Source: ~/.tinyfish/config.json\nKey: ${maskKey(config.api_key)}`);
156
+ }
157
+ else {
158
+ out(result);
159
+ }
160
+ return;
161
+ }
162
+ const result = { source: "none", key_preview: null, authenticated: false };
163
+ if (opts.pretty) {
164
+ outLine("No API key configured. Run: tinyfish auth login");
165
+ }
166
+ else {
167
+ out(result);
168
+ }
169
+ });
170
+ auth
171
+ .command("logout")
172
+ .description("Remove stored API key")
173
+ .action(() => {
174
+ if (!clearConfig()) {
175
+ out({ status: "ok", message: "No API key stored" });
176
+ return;
177
+ }
178
+ out({ status: "ok", message: "API key removed" });
179
+ });
180
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function registerRun(program: Command): void;
@@ -0,0 +1,153 @@
1
+ import { getApiKey } from "../lib/auth.js";
2
+ import { runAsync, runStream, runSync } from "../lib/client.js";
3
+ import { err, handleApiError, out, outLine } from "../lib/output.js";
4
+ import { RunStatus, StreamEventType } from "../lib/types.js";
5
+ const RUN_TIMEOUT_MS = 20 * 60 * 1000; // 20 minutes
6
+ function checkRunResult(result, expectComplete = true) {
7
+ if (result.error) {
8
+ err({ error: result.error, runId: result.runId, status: result.status });
9
+ process.exit(1);
10
+ }
11
+ if (expectComplete && result.status && result.status !== RunStatus.COMPLETED) {
12
+ err({ error: `Run ${result.status}`, runId: result.runId, status: result.status });
13
+ process.exit(1);
14
+ }
15
+ }
16
+ export function registerRun(program) {
17
+ program
18
+ .command("run <goal>")
19
+ .description("Run a browser automation")
20
+ .requiredOption("--url <url>", "Target URL for the automation")
21
+ .option("--sync", "Wait for result without streaming steps")
22
+ .option("--async", "Submit without waiting for result")
23
+ .option("--pretty", "Human-readable output")
24
+ .action(async (goal, opts) => {
25
+ if (opts.sync && opts.async) {
26
+ err({ error: "--sync and --async are mutually exclusive" });
27
+ process.exit(1);
28
+ }
29
+ // Reject obviously invalid input — accepts bare hostnames like "google.com"
30
+ try {
31
+ new URL(opts.url);
32
+ }
33
+ catch {
34
+ try {
35
+ new URL(`https://${opts.url}`);
36
+ }
37
+ catch {
38
+ err({ error: "--url does not look like a valid URL" });
39
+ process.exit(1);
40
+ }
41
+ }
42
+ const apiKey = getApiKey();
43
+ const req = { goal, url: opts.url };
44
+ if (opts.async) {
45
+ let result;
46
+ try {
47
+ result = await runAsync(req, apiKey);
48
+ }
49
+ catch (e) {
50
+ handleApiError(e);
51
+ }
52
+ checkRunResult(result, false); // async = PENDING is expected
53
+ if (opts.pretty) {
54
+ outLine(`Run submitted\nID: ${result.runId}\nStatus: ${result.status}`);
55
+ }
56
+ else {
57
+ out(result);
58
+ }
59
+ return;
60
+ }
61
+ if (opts.sync) {
62
+ process.stderr.write("Waiting for result...\n");
63
+ let result;
64
+ try {
65
+ result = await runSync(req, apiKey, AbortSignal.timeout(RUN_TIMEOUT_MS));
66
+ }
67
+ catch (e) {
68
+ handleApiError(e);
69
+ }
70
+ checkRunResult(result);
71
+ if (opts.pretty) {
72
+ outLine(`Status: ${result.status}\n\n${JSON.stringify(result.resultJson ?? {}, null, 2)}`);
73
+ }
74
+ else {
75
+ out(result);
76
+ }
77
+ return;
78
+ }
79
+ // Default: SSE stream — 20 min timeout + Ctrl+C cancellation
80
+ const controller = new AbortController();
81
+ let siginted = false;
82
+ const timeout = setTimeout(() => controller.abort(), RUN_TIMEOUT_MS);
83
+ const onSigint = () => {
84
+ siginted = true;
85
+ controller.abort();
86
+ };
87
+ process.once("SIGINT", onSigint);
88
+ let streamFailed = false;
89
+ try {
90
+ for await (const event of runStream(req, apiKey, controller.signal)) {
91
+ if (!handleStreamEvent(event, opts.pretty)) {
92
+ streamFailed = true;
93
+ break;
94
+ }
95
+ }
96
+ }
97
+ catch (e) {
98
+ if (controller.signal.aborted) {
99
+ if (siginted) {
100
+ process.stderr.write("\n");
101
+ process.exit(130);
102
+ }
103
+ err({ error: "Run timed out after 20 minutes" });
104
+ process.exit(1);
105
+ }
106
+ handleApiError(e);
107
+ }
108
+ finally {
109
+ clearTimeout(timeout);
110
+ process.removeListener("SIGINT", onSigint);
111
+ }
112
+ if (streamFailed)
113
+ process.exit(1);
114
+ });
115
+ }
116
+ /**
117
+ * Handle one SSE event from the stream.
118
+ * Returns false on terminal error (caller should exit 1); true otherwise.
119
+ */
120
+ function handleStreamEvent(event, pretty) {
121
+ if (event.type === StreamEventType.ERROR) {
122
+ err({ error: event.error, runId: event.runId, status: StreamEventType.ERROR });
123
+ return false;
124
+ }
125
+ if (event.type === StreamEventType.COMPLETE && event.status !== RunStatus.COMPLETED) {
126
+ err({ error: event.error ?? `Run ${event.status}`, runId: event.runId, status: event.status });
127
+ return false;
128
+ }
129
+ if (pretty) {
130
+ switch (event.type) {
131
+ case StreamEventType.STARTED:
132
+ outLine(`▶ Run started`);
133
+ break;
134
+ case StreamEventType.STREAMING_URL:
135
+ outLine(`🔗 Live view: ${event.streamingUrl}`);
136
+ break;
137
+ case StreamEventType.PROGRESS:
138
+ outLine(`• ${event.purpose}`);
139
+ break;
140
+ case StreamEventType.COMPLETE:
141
+ outLine(`✓ Completed\n\n${JSON.stringify(event.resultJson ?? {}, null, 2)}`);
142
+ break;
143
+ case StreamEventType.HEARTBEAT:
144
+ // silently skip — keep-alive noise
145
+ break;
146
+ }
147
+ }
148
+ else {
149
+ // Raw JSON: emit all events (STREAMING_URL and HEARTBEAT are useful for agents)
150
+ out(event);
151
+ }
152
+ return true;
153
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function registerRuns(program: Command): void;
@@ -0,0 +1,101 @@
1
+ import { getApiKey } from "../lib/auth.js";
2
+ import { getRun, listRuns } from "../lib/client.js";
3
+ import { err, handleApiError, out, outLine } from "../lib/output.js";
4
+ import { RunStatus } from "../lib/types.js";
5
+ const VALID_STATUSES = Object.values(RunStatus);
6
+ export function registerRuns(program) {
7
+ const runs = program
8
+ .command("runs")
9
+ .description("Manage past automation runs");
10
+ // ── runs list ──────────────────────────────────────────────────────────────
11
+ runs
12
+ .command("list")
13
+ .description("List your automation runs")
14
+ .option("--status <status>", "Filter by status (PENDING|RUNNING|COMPLETED|FAILED|CANCELLED)")
15
+ .option("--limit <n>", "Max number of runs to return (default: 20)")
16
+ .option("--cursor <cursor>", "Pagination cursor from a previous response")
17
+ .option("--pretty", "Human-readable output")
18
+ .action(async (opts) => {
19
+ // Validate --status
20
+ if (opts.status != null && !VALID_STATUSES.includes(opts.status)) {
21
+ err({ error: `Invalid --status "${opts.status}". Must be one of: ${VALID_STATUSES.join(", ")}` });
22
+ process.exit(1);
23
+ }
24
+ // Validate --limit
25
+ const MAX_LIMIT = 100;
26
+ let limit;
27
+ if (opts.limit != null) {
28
+ limit = parseInt(opts.limit, 10);
29
+ if (!isFinite(limit) || limit <= 0) {
30
+ err({ error: `Invalid --limit "${opts.limit}". Must be a positive integer.` });
31
+ process.exit(1);
32
+ }
33
+ if (limit > MAX_LIMIT) {
34
+ err({ error: `--limit cannot exceed ${MAX_LIMIT}.` });
35
+ process.exit(1);
36
+ }
37
+ }
38
+ const apiKey = getApiKey();
39
+ const filter = {
40
+ status: opts.status,
41
+ limit,
42
+ cursor: opts.cursor,
43
+ };
44
+ try {
45
+ const body = await listRuns(filter, apiKey);
46
+ if (opts.pretty) {
47
+ if (body.data.length === 0) {
48
+ outLine("No runs found.");
49
+ return;
50
+ }
51
+ outLine(`${"RUN ID".padEnd(36)} ${"STATUS".padEnd(10)} ${"CREATED AT".padEnd(24)} GOAL`);
52
+ outLine(`${"-".repeat(36)} ${"-".repeat(10)} ${"-".repeat(24)} ${"-".repeat(30)}`);
53
+ for (const run of body.data) {
54
+ outLine(`${run.run_id.padEnd(36)} ${run.status.padEnd(10)} ${run.created_at.padEnd(24)} ${run.goal}`);
55
+ }
56
+ if (body.pagination.has_more) {
57
+ outLine(`\nNext cursor: ${body.pagination.next_cursor}`);
58
+ }
59
+ }
60
+ else {
61
+ out(body);
62
+ }
63
+ }
64
+ catch (e) {
65
+ handleApiError(e);
66
+ }
67
+ });
68
+ // ── runs get ───────────────────────────────────────────────────────────────
69
+ runs
70
+ .command("get <run_id>")
71
+ .description("Get a run by ID")
72
+ .option("--pretty", "Human-readable output")
73
+ .action(async (runId, opts) => {
74
+ const apiKey = getApiKey();
75
+ try {
76
+ const run = await getRun(runId, apiKey);
77
+ if (opts.pretty) {
78
+ outLine(`Run ID: ${run.run_id}`);
79
+ outLine(`Status: ${run.status}`);
80
+ outLine(`Goal: ${run.goal}`);
81
+ outLine(`Created: ${run.created_at}`);
82
+ if (run.started_at)
83
+ outLine(`Started: ${run.started_at}`);
84
+ if (run.finished_at)
85
+ outLine(`Finished: ${run.finished_at}`);
86
+ outLine(`Steps: ${run.num_of_steps}`);
87
+ if (run.result !== null && run.result !== undefined) {
88
+ outLine(`\nResult:\n${JSON.stringify(run.result, null, 2)}`);
89
+ }
90
+ if (run.error)
91
+ outLine(`\nError: ${run.error.message}`);
92
+ }
93
+ else {
94
+ out(run);
95
+ }
96
+ }
97
+ catch (e) {
98
+ handleApiError(e);
99
+ }
100
+ });
101
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "module";
3
+ import { Command } from "commander";
4
+ import { registerAuth } from "./commands/auth.js";
5
+ import { registerRun } from "./commands/run.js";
6
+ import { registerRuns } from "./commands/runs.js";
7
+ const { version } = createRequire(import.meta.url)("../package.json");
8
+ const program = new Command();
9
+ program
10
+ .name("tinyfish")
11
+ .description("TinyFish CLI — run web automations from your terminal or agent.")
12
+ .version(version, "-V, --version", "Show version")
13
+ .helpOption("-h, --help", "Show help")
14
+ .addHelpCommand(false)
15
+ .option("--debug", "Print HTTP requests and responses to stderr (or set TINYFISH_DEBUG=1)")
16
+ .hook("preAction", (cmd) => {
17
+ if (cmd.opts().debug) {
18
+ process.env["TINYFISH_DEBUG"] = "1";
19
+ }
20
+ });
21
+ registerAuth(program);
22
+ registerRun(program);
23
+ registerRuns(program);
24
+ // Await parseAsync so async command handlers complete before the process exits
25
+ program.parseAsync(process.argv).catch((e) => {
26
+ process.stderr.write(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }) + "\n");
27
+ process.exit(1);
28
+ });
@@ -0,0 +1,10 @@
1
+ export { DASHBOARD_URL } from "./constants.js";
2
+ interface TinyfishConfig {
3
+ api_key?: string;
4
+ }
5
+ export declare function loadConfig(): TinyfishConfig;
6
+ export declare function saveConfig(apiKey: string): void;
7
+ export declare function clearConfig(): boolean;
8
+ export declare function validateKeyFormat(key: string): boolean;
9
+ export declare function maskKey(key: string): string;
10
+ export declare function getApiKey(): string;
@@ -0,0 +1,90 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { err } from "./output.js";
5
+ const KEY_PREFIXES = ["sk-tinyfish-", "sk-mino-"];
6
+ const MIN_KEY_LENGTH = 20;
7
+ export { DASHBOARD_URL } from "./constants.js";
8
+ function configDir() {
9
+ // os.homedir() is cross-platform; process.env.HOME is undefined on Windows
10
+ return path.join(os.homedir(), ".tinyfish");
11
+ }
12
+ function configFile() {
13
+ return path.join(configDir(), "config.json");
14
+ }
15
+ export function loadConfig() {
16
+ try {
17
+ const raw = JSON.parse(fs.readFileSync(configFile(), "utf8"));
18
+ if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
19
+ const obj = raw;
20
+ return { api_key: typeof obj["api_key"] === "string" ? obj["api_key"] : undefined };
21
+ }
22
+ return {};
23
+ }
24
+ catch {
25
+ return {};
26
+ }
27
+ }
28
+ export function saveConfig(apiKey) {
29
+ const dir = configDir();
30
+ try {
31
+ // 0o700: only owner can read/write/execute the directory
32
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
33
+ const existing = loadConfig();
34
+ // 0o600: only owner can read/write the config file (key is sensitive)
35
+ fs.writeFileSync(configFile(), JSON.stringify({ ...existing, api_key: apiKey }), {
36
+ mode: 0o600,
37
+ });
38
+ }
39
+ catch (e) {
40
+ err({ error: `Could not write config: ${e instanceof Error ? e.message : String(e)}` });
41
+ process.exit(1);
42
+ }
43
+ }
44
+ export function clearConfig() {
45
+ const config = loadConfig();
46
+ if (!config.api_key)
47
+ return false;
48
+ delete config.api_key;
49
+ try {
50
+ fs.writeFileSync(configFile(), JSON.stringify(config), { mode: 0o600 });
51
+ return true;
52
+ }
53
+ catch {
54
+ // Write failed — key was not actually removed
55
+ return false;
56
+ }
57
+ }
58
+ export function validateKeyFormat(key) {
59
+ // Reject control characters (covers CR, LF, and others) — guards against header injection
60
+ for (let i = 0; i < key.length; i += 1) {
61
+ if (key.charCodeAt(i) < 0x20)
62
+ return false;
63
+ }
64
+ return KEY_PREFIXES.some((p) => key.startsWith(p)) && key.length > MIN_KEY_LENGTH;
65
+ }
66
+ export function maskKey(key) {
67
+ if (key.length <= 8)
68
+ return "***";
69
+ return key.slice(0, 12) + "..." + key.slice(-4);
70
+ }
71
+ export function getApiKey() {
72
+ const envKey = process.env.TINYFISH_API_KEY;
73
+ if (envKey) {
74
+ if (!validateKeyFormat(envKey)) {
75
+ err({ error: "TINYFISH_API_KEY has invalid format. Expected sk-tinyfish-... or sk-mino-..." });
76
+ process.exit(1);
77
+ }
78
+ return envKey;
79
+ }
80
+ const config = loadConfig();
81
+ if (config.api_key) {
82
+ if (!validateKeyFormat(config.api_key)) {
83
+ err({ error: "Stored API key has invalid format. Run: tinyfish auth login" });
84
+ process.exit(1);
85
+ }
86
+ return config.api_key;
87
+ }
88
+ err({ error: "No API key found. Run: tinyfish auth login" });
89
+ process.exit(1);
90
+ }
@@ -0,0 +1,6 @@
1
+ import type { 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>;
@@ -0,0 +1,116 @@
1
+ import { BASE_URL } from "./constants.js";
2
+ import { ApiError } from "./output.js";
3
+ // ── Debug logging ─────────────────────────────────────────────────────────────
4
+ const DEBUG_ENABLED = /^(1|true)$/i.test(process.env["TINYFISH_DEBUG"] ?? "");
5
+ function debugLog(msg) {
6
+ if (DEBUG_ENABLED) {
7
+ process.stderr.write(`[debug] ${msg}\n`);
8
+ }
9
+ }
10
+ // ── Shared HTTP helpers ───────────────────────────────────────────────────────
11
+ function headers(apiKey) {
12
+ return {
13
+ "X-API-Key": apiKey,
14
+ "Content-Type": "application/json",
15
+ "X-TF-Request-Origin": "tinyfish-cli",
16
+ };
17
+ }
18
+ async function throwIfError(res) {
19
+ if (!res.ok) {
20
+ const json = await res.json().catch(() => ({}));
21
+ const message = json.detail ?? `HTTP ${res.status}`;
22
+ throw new ApiError(res.status, message);
23
+ }
24
+ }
25
+ /** POST JSON to a path and return the raw Response, throwing ApiError on non-2xx. */
26
+ async function postJson(path, body, apiKey, signal) {
27
+ const start = Date.now();
28
+ debugLog(`--> POST ${path}`);
29
+ const res = await fetch(`${BASE_URL}${path}`, {
30
+ method: "POST",
31
+ headers: headers(apiKey),
32
+ body: JSON.stringify(body),
33
+ signal,
34
+ });
35
+ debugLog(`<-- ${res.status} (${Date.now() - start}ms)`);
36
+ await throwIfError(res);
37
+ return res;
38
+ }
39
+ /** GET a path and return the parsed JSON body, throwing ApiError on non-2xx. */
40
+ async function getJson(path, apiKey, signal) {
41
+ const start = Date.now();
42
+ debugLog(`--> GET ${path}`);
43
+ const res = await fetch(`${BASE_URL}${path}`, {
44
+ method: "GET",
45
+ headers: headers(apiKey),
46
+ signal,
47
+ });
48
+ debugLog(`<-- ${res.status} (${Date.now() - start}ms)`);
49
+ await throwIfError(res);
50
+ return res.json();
51
+ }
52
+ /** Yield parsed StreamEvents from a single SSE data line. */
53
+ function* parseSseLine(line) {
54
+ const trimmed = line.trim();
55
+ if (!trimmed.startsWith("data:"))
56
+ return;
57
+ const data = trimmed.slice(5).trim();
58
+ if (!data || data === "[DONE]")
59
+ return;
60
+ try {
61
+ yield JSON.parse(data);
62
+ }
63
+ catch {
64
+ // malformed SSE line — skip
65
+ }
66
+ }
67
+ // ── Automation run ────────────────────────────────────────────────────────────
68
+ export async function runSync(req, apiKey, signal) {
69
+ const res = await postJson("/v1/automation/run", req, apiKey, signal);
70
+ return res.json();
71
+ }
72
+ export async function runAsync(req, apiKey) {
73
+ // Async just submits the job — 30 s is plenty to get an ACK
74
+ const res = await postJson("/v1/automation/run-async", req, apiKey, AbortSignal.timeout(30_000));
75
+ return res.json();
76
+ }
77
+ export async function* runStream(req, apiKey, signal) {
78
+ const res = await postJson("/v1/automation/run-sse", req, apiKey, signal);
79
+ if (!res.body) {
80
+ throw new ApiError(200, "Empty response body");
81
+ }
82
+ const reader = res.body.getReader();
83
+ const decoder = new TextDecoder();
84
+ let buffer = "";
85
+ while (true) {
86
+ const { done, value } = await reader.read();
87
+ if (done) {
88
+ // Flush remaining buffer — handles final event without trailing newline
89
+ for (const line of buffer.split("\n")) {
90
+ yield* parseSseLine(line);
91
+ }
92
+ break;
93
+ }
94
+ buffer += decoder.decode(value, { stream: true });
95
+ const lines = buffer.split("\n");
96
+ buffer = lines.pop() ?? "";
97
+ for (const line of lines) {
98
+ yield* parseSseLine(line);
99
+ }
100
+ }
101
+ }
102
+ // ── Run history ───────────────────────────────────────────────────────────────
103
+ export async function listRuns(opts, apiKey) {
104
+ const params = new URLSearchParams();
105
+ if (opts.status)
106
+ params.set("status", opts.status);
107
+ if (opts.limit != null)
108
+ params.set("limit", String(opts.limit));
109
+ if (opts.cursor)
110
+ params.set("cursor", opts.cursor);
111
+ const qs = params.size ? `?${params}` : "";
112
+ return getJson(`/v1/runs${qs}`, apiKey, AbortSignal.timeout(30_000));
113
+ }
114
+ export async function getRun(runId, apiKey) {
115
+ return getJson(`/v1/runs/${encodeURIComponent(runId)}`, apiKey, AbortSignal.timeout(30_000));
116
+ }
@@ -0,0 +1,3 @@
1
+ export declare const BASE_URL: string;
2
+ /** URL where users can create and manage API keys */
3
+ export declare const DASHBOARD_URL: string;
@@ -0,0 +1,5 @@
1
+ /** Base URL for the TinyFish API. Override with TINYFISH_API_URL for staging/self-hosted. */
2
+ const apiUrlOverride = process.env["TINYFISH_API_URL"]?.trim();
3
+ export const BASE_URL = apiUrlOverride || "https://agent.tinyfish.ai";
4
+ /** URL where users can create and manage API keys */
5
+ export const DASHBOARD_URL = `${BASE_URL}/api-keys`;
@@ -0,0 +1,9 @@
1
+ export declare class ApiError extends Error {
2
+ readonly status: number;
3
+ readonly code?: string | undefined;
4
+ constructor(status: number, message: string, code?: string | undefined);
5
+ }
6
+ export declare function out(data: unknown): void;
7
+ export declare function outLine(line: string): void;
8
+ export declare function err(data: unknown): void;
9
+ export declare function handleApiError(e: unknown): never;
@@ -0,0 +1,35 @@
1
+ export class ApiError extends Error {
2
+ status;
3
+ code;
4
+ constructor(status, message, code) {
5
+ super(message);
6
+ this.status = status;
7
+ this.code = code;
8
+ this.name = "ApiError";
9
+ }
10
+ }
11
+ export function out(data) {
12
+ process.stdout.write(JSON.stringify(data) + "\n");
13
+ }
14
+ export function outLine(line) {
15
+ process.stdout.write(line + "\n");
16
+ }
17
+ export function err(data) {
18
+ const payload = typeof data === "string" ? { error: data } : data;
19
+ process.stderr.write(JSON.stringify(payload) + "\n");
20
+ }
21
+ export function handleApiError(e) {
22
+ if (e instanceof ApiError) {
23
+ const payload = { error: e.message, status: e.status };
24
+ if (e.code)
25
+ payload.code = e.code;
26
+ err(payload);
27
+ }
28
+ else if (e instanceof Error) {
29
+ err({ error: e.message });
30
+ }
31
+ else {
32
+ err({ error: String(e) });
33
+ }
34
+ process.exit(1);
35
+ }
@@ -0,0 +1,96 @@
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
+ runId: string;
24
+ status: RunStatus;
25
+ resultJson?: 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
+ runId: string;
32
+ timestamp: string;
33
+ } | {
34
+ type: typeof StreamEventType.STREAMING_URL;
35
+ runId: string;
36
+ streamingUrl: string;
37
+ timestamp: string;
38
+ } | {
39
+ type: typeof StreamEventType.HEARTBEAT;
40
+ timestamp: string;
41
+ } | {
42
+ type: typeof StreamEventType.PROGRESS;
43
+ runId: string;
44
+ purpose: string;
45
+ timestamp: string;
46
+ } | {
47
+ type: typeof StreamEventType.COMPLETE;
48
+ runId: string;
49
+ status: RunStatus;
50
+ timestamp: string;
51
+ resultJson?: unknown;
52
+ error?: string;
53
+ help_url?: string;
54
+ help_message?: string;
55
+ } | {
56
+ type: typeof StreamEventType.ERROR;
57
+ runId?: 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;
67
+ }
68
+ export interface RunApiResponse {
69
+ run_id: string;
70
+ status: RunStatus;
71
+ 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
+ }
84
+ export interface ListRunsResponse {
85
+ data: RunApiResponse[];
86
+ pagination: {
87
+ total: number;
88
+ next_cursor: string | null;
89
+ has_more: boolean;
90
+ };
91
+ }
92
+ export interface ListRunsOptions {
93
+ status?: RunStatus;
94
+ limit?: number;
95
+ cursor?: string;
96
+ }
@@ -0,0 +1,18 @@
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
+ };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@tiny-fish/cli",
3
+ "version": "0.1.0",
4
+ "description": "TinyFish CLI — run web automations from your terminal",
5
+ "type": "module",
6
+ "bin": {
7
+ "tinyfish": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist/",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "prepack": "npm run build",
17
+ "test": "vitest --run",
18
+ "test:file": "vitest --run",
19
+ "test:watch": "vitest",
20
+ "test:integration": "vitest --run --config vitest.integration.config.ts",
21
+ "lint": "eslint src tests",
22
+ "format": "prettier --write src tests",
23
+ "type-check": "tsc --noEmit --project tsconfig.all.json"
24
+ },
25
+ "dependencies": {
26
+ "commander": "^12.0.0",
27
+ "globals": "^17.4.0"
28
+ },
29
+ "devDependencies": {
30
+ "@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
+ "prettier": "^3.0.0",
35
+ "typescript": "^5.0.0",
36
+ "vitest": "^3.0.0"
37
+ },
38
+ "engines": {
39
+ "node": ">=24.0.0"
40
+ }
41
+ }