@tiny-fish/cli 0.1.0 → 0.1.2-next.15

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.
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "child_process";
2
2
  import * as readline from "readline";
3
3
  import { DASHBOARD_URL, clearConfig, loadConfig, maskKey, saveConfig, validateKeyFormat, } from "../lib/auth.js";
4
- import { err, out, outLine } from "../lib/output.js";
4
+ import { err, errLine, out, outLine } from "../lib/output.js";
5
5
  /**
6
6
  * Read a key from stdin without echoing it to the terminal.
7
7
  * Uses setRawMode on TTY so characters are never written to the screen.
@@ -71,7 +71,7 @@ export function registerAuth(program) {
71
71
  .command("login")
72
72
  .description("Open the API keys page and save your key interactively")
73
73
  .action(async () => {
74
- process.stderr.write(`Opening ${DASHBOARD_URL} in your browser...\n`);
74
+ errLine(`Opening ${DASHBOARD_URL} in your browser...`);
75
75
  try {
76
76
  // Use spawn with an explicit args array — no shell, no interpolation
77
77
  let cmd;
@@ -166,6 +166,7 @@ export function registerAuth(program) {
166
166
  else {
167
167
  out(result);
168
168
  }
169
+ process.exit(1);
169
170
  });
170
171
  auth
171
172
  .command("logout")
@@ -1,2 +1,2 @@
1
1
  import { Command } from "commander";
2
- export declare function registerRun(program: Command): void;
2
+ export declare function registerRun(agentCmd: Command): Command;
@@ -1,46 +1,64 @@
1
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";
2
+ import { cancelRun, runAsync, runStream, runSync } from "../lib/client.js";
3
+ import { err, errLine, handleApiError, out, outLine } from "../lib/output.js";
4
4
  import { RunStatus, StreamEventType } from "../lib/types.js";
5
+ import { BASE_URL } from "../lib/constants.js";
5
6
  const RUN_TIMEOUT_MS = 20 * 60 * 1000; // 20 minutes
6
7
  function checkRunResult(result, expectComplete = true) {
7
8
  if (result.error) {
8
- err({ error: result.error, runId: result.runId, status: result.status });
9
+ err({ error: result.error, run_id: result.run_id, status: result.status });
9
10
  process.exit(1);
10
11
  }
11
12
  if (expectComplete && result.status && result.status !== RunStatus.COMPLETED) {
12
- err({ error: `Run ${result.status}`, runId: result.runId, status: result.status });
13
+ err({ error: `Run ${result.status}`, run_id: result.run_id, status: result.status });
13
14
  process.exit(1);
14
15
  }
15
16
  }
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")
17
+ export function registerRun(agentCmd) {
18
+ const runCmd = agentCmd
19
+ .command("run")
20
+ .description("Run a browser automation or manage runs")
21
+ .option("--url <url>", "Target URL for the automation")
21
22
  .option("--sync", "Wait for result without streaming steps")
22
23
  .option("--async", "Submit without waiting for result")
23
24
  .option("--pretty", "Human-readable output")
25
+ .argument("[goal]", "Goal to automate")
24
26
  .action(async (goal, opts) => {
27
+ // If no goal is provided and no subcommand matched, show help
28
+ if (!goal) {
29
+ runCmd.help();
30
+ return;
31
+ }
32
+ if (!opts.url) {
33
+ err({ error: "required option '--url <url>' not specified" });
34
+ process.exit(1);
35
+ }
25
36
  if (opts.sync && opts.async) {
26
37
  err({ error: "--sync and --async are mutually exclusive" });
27
38
  process.exit(1);
28
39
  }
29
- // Reject obviously invalid input — accepts bare hostnames like "google.com"
40
+ // Normalise URL: accept bare hostnames by prepending https://.
41
+ // The server requires a valid URL — it does not auto-prefix.
42
+ let normalizedUrl = opts.url;
30
43
  try {
31
44
  new URL(opts.url);
32
45
  }
33
46
  catch {
34
47
  try {
35
48
  new URL(`https://${opts.url}`);
49
+ normalizedUrl = `https://${opts.url}`;
36
50
  }
37
51
  catch {
38
- err({ error: "--url does not look like a valid URL" });
52
+ err({ error: `Invalid --url "${opts.url}". Provide a valid URL, e.g. https://example.com or example.com` });
39
53
  process.exit(1);
40
54
  }
41
55
  }
56
+ if (!goal.trim()) {
57
+ err({ error: 'Goal cannot be empty' });
58
+ process.exit(1);
59
+ }
42
60
  const apiKey = getApiKey();
43
- const req = { goal, url: opts.url };
61
+ const req = { goal, url: normalizedUrl };
44
62
  if (opts.async) {
45
63
  let result;
46
64
  try {
@@ -50,16 +68,18 @@ export function registerRun(program) {
50
68
  handleApiError(e);
51
69
  }
52
70
  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 };
53
73
  if (opts.pretty) {
54
- outLine(`Run submitted\nID: ${result.runId}\nStatus: ${result.status}`);
74
+ outLine(`Run submitted\nID: ${asyncResultWithUrl.run_id}\nURL: ${asyncResultWithUrl.run_url}`);
55
75
  }
56
76
  else {
57
- out(result);
77
+ out(asyncResultWithUrl);
58
78
  }
59
79
  return;
60
80
  }
61
81
  if (opts.sync) {
62
- process.stderr.write("Waiting for result...\n");
82
+ errLine("Waiting for result...");
63
83
  let result;
64
84
  try {
65
85
  result = await runSync(req, apiKey, AbortSignal.timeout(RUN_TIMEOUT_MS));
@@ -68,17 +88,20 @@ export function registerRun(program) {
68
88
  handleApiError(e);
69
89
  }
70
90
  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 };
71
93
  if (opts.pretty) {
72
- outLine(`Status: ${result.status}\n\n${JSON.stringify(result.resultJson ?? {}, null, 2)}`);
94
+ outLine(`Status: ${syncResultWithUrl.status}\n\n${JSON.stringify(syncResultWithUrl.result ?? {}, null, 2)}`);
73
95
  }
74
96
  else {
75
- out(result);
97
+ out(syncResultWithUrl);
76
98
  }
77
99
  return;
78
100
  }
79
101
  // Default: SSE stream — 20 min timeout + Ctrl+C cancellation
80
102
  const controller = new AbortController();
81
103
  let siginted = false;
104
+ let capturedRunId = null;
82
105
  const timeout = setTimeout(() => controller.abort(), RUN_TIMEOUT_MS);
83
106
  const onSigint = () => {
84
107
  siginted = true;
@@ -88,18 +111,26 @@ export function registerRun(program) {
88
111
  let streamFailed = false;
89
112
  try {
90
113
  for await (const event of runStream(req, apiKey, controller.signal)) {
91
- if (!handleStreamEvent(event, opts.pretty)) {
114
+ if (!handleStreamEvent(event, opts.pretty, (id) => { capturedRunId = id; })) {
92
115
  streamFailed = true;
93
116
  break;
94
117
  }
95
118
  }
96
119
  }
97
120
  catch (e) {
98
- if (controller.signal.aborted) {
99
- if (siginted) {
100
- process.stderr.write("\n");
101
- process.exit(130);
121
+ if (controller.signal.aborted && siginted) {
122
+ let cancelled = false;
123
+ if (capturedRunId) {
124
+ try {
125
+ await cancelRun(capturedRunId, apiKey);
126
+ cancelled = true;
127
+ }
128
+ catch { /* ignore */ }
102
129
  }
130
+ process.stderr.write(cancelled ? "\nRun cancelled.\n" : "\n");
131
+ process.exit(130);
132
+ }
133
+ if (controller.signal.aborted) {
103
134
  err({ error: "Run timed out after 20 minutes" });
104
135
  process.exit(1);
105
136
  }
@@ -112,34 +143,39 @@ export function registerRun(program) {
112
143
  if (streamFailed)
113
144
  process.exit(1);
114
145
  });
146
+ return runCmd;
115
147
  }
116
148
  /**
117
149
  * Handle one SSE event from the stream.
118
150
  * Returns false on terminal error (caller should exit 1); true otherwise.
119
151
  */
120
- function handleStreamEvent(event, pretty) {
152
+ function handleStreamEvent(event, pretty, onRunId) {
121
153
  if (event.type === StreamEventType.ERROR) {
122
- err({ error: event.error, runId: event.runId, status: StreamEventType.ERROR });
154
+ err({ error: event.error, run_id: event.run_id, status: StreamEventType.ERROR });
123
155
  return false;
124
156
  }
125
157
  if (event.type === StreamEventType.COMPLETE && event.status !== RunStatus.COMPLETED) {
126
- err({ error: event.error ?? `Run ${event.status}`, runId: event.runId, status: event.status });
158
+ err({ error: event.error ?? `Run ${event.status}`, run_id: event.run_id, status: event.status });
127
159
  return false;
128
160
  }
129
161
  if (pretty) {
130
162
  switch (event.type) {
131
163
  case StreamEventType.STARTED:
164
+ if (onRunId && event.run_id)
165
+ onRunId(event.run_id);
132
166
  outLine(`▶ Run started`);
133
167
  break;
134
168
  case StreamEventType.STREAMING_URL:
135
- outLine(`🔗 Live view: ${event.streamingUrl}`);
169
+ outLine(`🔗 Live view: ${event.streaming_url}`);
136
170
  break;
137
171
  case StreamEventType.PROGRESS:
138
172
  outLine(`• ${event.purpose}`);
139
173
  break;
140
- case StreamEventType.COMPLETE:
141
- outLine(`✓ Completed\n\n${JSON.stringify(event.resultJson ?? {}, null, 2)}`);
174
+ case StreamEventType.COMPLETE: {
175
+ const runUrl = `${BASE_URL}/runs/${event.run_id}`;
176
+ outLine(`✓ Completed\n\n${JSON.stringify(event.result ?? {}, null, 2)}\n\nView run: ${runUrl}`);
142
177
  break;
178
+ }
143
179
  case StreamEventType.HEARTBEAT:
144
180
  // silently skip — keep-alive noise
145
181
  break;
@@ -147,7 +183,13 @@ function handleStreamEvent(event, pretty) {
147
183
  }
148
184
  else {
149
185
  // Raw JSON: emit all events (STREAMING_URL and HEARTBEAT are useful for agents)
150
- out(event);
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 });
189
+ }
190
+ else {
191
+ out(event);
192
+ }
151
193
  }
152
194
  return true;
153
195
  }
@@ -1,2 +1,2 @@
1
1
  import { Command } from "commander";
2
- export declare function registerRuns(program: Command): void;
2
+ export declare function registerRuns(runCmd: Command): void;
@@ -1,14 +1,11 @@
1
1
  import { getApiKey } from "../lib/auth.js";
2
- import { getRun, listRuns } from "../lib/client.js";
2
+ import { cancelRun, getRun, listRuns } from "../lib/client.js";
3
3
  import { err, handleApiError, out, outLine } from "../lib/output.js";
4
4
  import { RunStatus } from "../lib/types.js";
5
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
6
+ export function registerRuns(runCmd) {
7
+ // ── run list ───────────────────────────────────────────────────────────────
8
+ runCmd
12
9
  .command("list")
13
10
  .description("List your automation runs")
14
11
  .option("--status <status>", "Filter by status (PENDING|RUNNING|COMPLETED|FAILED|CANCELLED)")
@@ -65,8 +62,8 @@ export function registerRuns(program) {
65
62
  handleApiError(e);
66
63
  }
67
64
  });
68
- // ── runs get ───────────────────────────────────────────────────────────────
69
- runs
65
+ // ── run get ────────────────────────────────────────────────────────────────
66
+ runCmd
70
67
  .command("get <run_id>")
71
68
  .description("Get a run by ID")
72
69
  .option("--pretty", "Human-readable output")
@@ -98,4 +95,31 @@ export function registerRuns(program) {
98
95
  handleApiError(e);
99
96
  }
100
97
  });
98
+ // ── run cancel ────────────────────────────────────────────────────────────
99
+ runCmd
100
+ .command("cancel <run_id>")
101
+ .description("Cancel a run by ID")
102
+ .option("--pretty", "Human-readable output")
103
+ .action(async (runId, opts) => {
104
+ const apiKey = getApiKey();
105
+ try {
106
+ const result = await cancelRun(runId, apiKey);
107
+ if (opts.pretty) {
108
+ if (result.status === RunStatus.CANCELLED) {
109
+ outLine(result.message
110
+ ? `Run ${result.run_id} cancelled (${result.message}).`
111
+ : `Run ${result.run_id} cancelled.`);
112
+ }
113
+ else {
114
+ outLine(`Run ${result.run_id} already finished (${result.status}).`);
115
+ }
116
+ }
117
+ else {
118
+ out(result);
119
+ }
120
+ }
121
+ catch (e) {
122
+ handleApiError(e);
123
+ }
124
+ });
101
125
  }
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ program
12
12
  .version(version, "-V, --version", "Show version")
13
13
  .helpOption("-h, --help", "Show help")
14
14
  .addHelpCommand(false)
15
+ .enablePositionalOptions()
15
16
  .option("--debug", "Print HTTP requests and responses to stderr (or set TINYFISH_DEBUG=1)")
16
17
  .hook("preAction", (cmd) => {
17
18
  if (cmd.opts().debug) {
@@ -19,8 +20,12 @@ program
19
20
  }
20
21
  });
21
22
  registerAuth(program);
22
- registerRun(program);
23
- registerRuns(program);
23
+ const agentCmd = program
24
+ .command("agent")
25
+ .description("Agent automation commands")
26
+ .enablePositionalOptions();
27
+ const runCmd = registerRun(agentCmd);
28
+ registerRuns(runCmd);
24
29
  // Await parseAsync so async command handlers complete before the process exits
25
30
  program.parseAsync(process.argv).catch((e) => {
26
31
  process.stderr.write(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }) + "\n");
@@ -1,6 +1,7 @@
1
- import type { ListRunsOptions, ListRunsResponse, RunApiResponse, RunRequest, RunResult, StreamEvent } from "./types.js";
1
+ import type { 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
+ export declare function cancelRun(runId: string, apiKey: string): Promise<CancelRunResponse>;
@@ -1,5 +1,6 @@
1
1
  import { BASE_URL } from "./constants.js";
2
2
  import { ApiError } from "./output.js";
3
+ const API_TIMEOUT_MS = 30_000;
3
4
  // ── Debug logging ─────────────────────────────────────────────────────────────
4
5
  const DEBUG_ENABLED = /^(1|true)$/i.test(process.env["TINYFISH_DEBUG"] ?? "");
5
6
  function debugLog(msg) {
@@ -18,7 +19,9 @@ function headers(apiKey) {
18
19
  async function throwIfError(res) {
19
20
  if (!res.ok) {
20
21
  const json = await res.json().catch(() => ({}));
21
- const message = json.detail ?? `HTTP ${res.status}`;
22
+ const message = json.error?.message ??
23
+ json.detail ??
24
+ `HTTP ${res.status}`;
22
25
  throw new ApiError(res.status, message);
23
26
  }
24
27
  }
@@ -71,7 +74,7 @@ export async function runSync(req, apiKey, signal) {
71
74
  }
72
75
  export async function runAsync(req, apiKey) {
73
76
  // 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));
77
+ const res = await postJson("/v1/automation/run-async", req, apiKey, AbortSignal.timeout(API_TIMEOUT_MS));
75
78
  return res.json();
76
79
  }
77
80
  export async function* runStream(req, apiKey, signal) {
@@ -109,8 +112,12 @@ export async function listRuns(opts, apiKey) {
109
112
  if (opts.cursor)
110
113
  params.set("cursor", opts.cursor);
111
114
  const qs = params.size ? `?${params}` : "";
112
- return getJson(`/v1/runs${qs}`, apiKey, AbortSignal.timeout(30_000));
115
+ return getJson(`/v1/runs${qs}`, apiKey, AbortSignal.timeout(API_TIMEOUT_MS));
113
116
  }
114
117
  export async function getRun(runId, apiKey) {
115
- return getJson(`/v1/runs/${encodeURIComponent(runId)}`, apiKey, AbortSignal.timeout(30_000));
118
+ return getJson(`/v1/runs/${encodeURIComponent(runId)}`, apiKey, AbortSignal.timeout(API_TIMEOUT_MS));
119
+ }
120
+ export async function cancelRun(runId, apiKey) {
121
+ const res = await postJson(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {}, apiKey, AbortSignal.timeout(API_TIMEOUT_MS));
122
+ return res.json();
116
123
  }
@@ -5,5 +5,6 @@ export declare class ApiError extends Error {
5
5
  }
6
6
  export declare function out(data: unknown): void;
7
7
  export declare function outLine(line: string): void;
8
+ export declare function errLine(line: string): void;
8
9
  export declare function err(data: unknown): void;
9
10
  export declare function handleApiError(e: unknown): never;
@@ -14,6 +14,9 @@ export function out(data) {
14
14
  export function outLine(line) {
15
15
  process.stdout.write(line + "\n");
16
16
  }
17
+ export function errLine(line) {
18
+ process.stderr.write(line + "\n");
19
+ }
17
20
  export function err(data) {
18
21
  const payload = typeof data === "string" ? { error: data } : data;
19
22
  process.stderr.write(JSON.stringify(payload) + "\n");
@@ -20,41 +20,41 @@ export declare const StreamEventType: {
20
20
  };
21
21
  export type StreamEventType = (typeof StreamEventType)[keyof typeof StreamEventType];
22
22
  export interface RunResult {
23
- runId: string;
23
+ run_id: string;
24
24
  status: RunStatus;
25
- resultJson?: unknown;
25
+ result?: unknown;
26
26
  error?: string;
27
27
  }
28
28
  /** Discriminated union of all real SSE event shapes from the API */
29
29
  export type StreamEvent = {
30
30
  type: typeof StreamEventType.STARTED;
31
- runId: string;
31
+ run_id: string;
32
32
  timestamp: string;
33
33
  } | {
34
34
  type: typeof StreamEventType.STREAMING_URL;
35
- runId: string;
36
- streamingUrl: string;
35
+ run_id: string;
36
+ streaming_url: string;
37
37
  timestamp: string;
38
38
  } | {
39
39
  type: typeof StreamEventType.HEARTBEAT;
40
40
  timestamp: string;
41
41
  } | {
42
42
  type: typeof StreamEventType.PROGRESS;
43
- runId: string;
43
+ run_id: string;
44
44
  purpose: string;
45
45
  timestamp: string;
46
46
  } | {
47
47
  type: typeof StreamEventType.COMPLETE;
48
- runId: string;
48
+ run_id: string;
49
49
  status: RunStatus;
50
50
  timestamp: string;
51
- resultJson?: unknown;
51
+ result?: unknown;
52
52
  error?: string;
53
53
  help_url?: string;
54
54
  help_message?: string;
55
55
  } | {
56
56
  type: typeof StreamEventType.ERROR;
57
- runId?: string;
57
+ run_id?: string;
58
58
  error: string;
59
59
  timestamp: string;
60
60
  };
@@ -94,3 +94,9 @@ export interface ListRunsOptions {
94
94
  limit?: number;
95
95
  cursor?: string;
96
96
  }
97
+ export interface CancelRunResponse {
98
+ run_id: string;
99
+ status: RunStatus | string;
100
+ cancelled_at: string;
101
+ message: string | null;
102
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2-next.15",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,14 +13,14 @@
13
13
  ],
14
14
  "scripts": {
15
15
  "build": "tsc",
16
- "prepack": "npm run build",
17
16
  "test": "vitest --run",
18
17
  "test:file": "vitest --run",
19
18
  "test:watch": "vitest",
20
19
  "test:integration": "vitest --run --config vitest.integration.config.ts",
21
20
  "lint": "eslint src tests",
22
21
  "format": "prettier --write src tests",
23
- "type-check": "tsc --noEmit --project tsconfig.all.json"
22
+ "type-check": "tsc --noEmit --project tsconfig.all.json",
23
+ "prepublishOnly": "npm run build"
24
24
  },
25
25
  "dependencies": {
26
26
  "commander": "^12.0.0",
@@ -37,5 +37,8 @@
37
37
  },
38
38
  "engines": {
39
39
  "node": ">=24.0.0"
40
+ },
41
+ "publishConfig": {
42
+ "registry": "https://registry.npmjs.org/"
40
43
  }
41
44
  }