@tiny-fish/cli 0.39.1-next.311 → 0.40.1-next.317

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,26 @@
1
+ import { type McpJsonServerEntry, type McpJsonWriteResult } from './mcp-json-config.js';
2
+ /** Mirrors pi's own getAgentDir: the override wins, else `~/.pi/agent`. */
3
+ export declare function piAgentDir(): string;
4
+ export declare function piMcpPath(): string;
5
+ /** Reports the header's shape, never its value. */
6
+ export declare function readPiTinyfishEntry(): McpJsonServerEntry;
7
+ /** Merges only the `tinyfish` key. */
8
+ export declare function writePiMcpConfig(mcpUrl: string, apiKey?: string): McpJsonWriteResult;
9
+ /** Dry-run description of the pending write; touches nothing. */
10
+ export declare function planPiWrite(mcpUrl: string, apiKey?: string): string;
11
+ /** Removes only the `tinyfish` key. */
12
+ export declare function removePiMcpServer(): McpJsonWriteResult;
13
+ export declare const PI_ADAPTER_INSTALL_COMMAND = "pi install npm:pi-mcp-adapter";
14
+ /** `unknown` keeps a settings file we could not read apart from one that lists no adapter. */
15
+ export type PiAdapterState = 'installed' | 'absent' | 'unknown';
16
+ /** Core pi has no MCP, so without this package the entry we write is never read. */
17
+ export declare function piMcpAdapterState(cwd?: string): PiAdapterState;
18
+ /** Tests share one process; the probe result must not. */
19
+ export declare function resetPiBinaryCache(): void;
20
+ /** npm ships an unrelated `pi` that prints `3`; only a semver answer is the harness. */
21
+ export declare function piBinaryIsPi(): boolean;
22
+ /** `skills` hardcodes the default dir, so an override leaves the skill where pi will not look. */
23
+ export declare function piSkillDirMismatch(): {
24
+ installedTo: string;
25
+ readFrom: string;
26
+ } | undefined;
@@ -0,0 +1,111 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import spawn from 'cross-spawn';
5
+ import { z } from 'zod';
6
+ import { HARNESS_PROBE_TIMEOUT_MS } from './constants.js';
7
+ import { planWrite, readTinyfishEntry, removeServer, writeMcpConfig, } from './mcp-json-config.js';
8
+ // pi expands `~` and `~/`; writing one literally would mkdir '~'.
9
+ function expandTilde(dir) {
10
+ if (dir === '~')
11
+ return os.homedir();
12
+ // nosemgrep: path-join-resolve-traversal -- $PI_CODING_AGENT_DIR names the caller's own dir, resolved as pi does
13
+ return dir.startsWith('~/') ? path.join(os.homedir(), dir.slice(2)) : dir;
14
+ }
15
+ /** Mirrors pi's own getAgentDir: the override wins, else `~/.pi/agent`. */
16
+ export function piAgentDir() {
17
+ const override = process.env['PI_CODING_AGENT_DIR'];
18
+ return override ? path.resolve(expandTilde(override)) : path.join(os.homedir(), '.pi', 'agent');
19
+ }
20
+ // Resolved per call, not cached: nothing here spawns, so drift is impossible.
21
+ const PI_MCP_TARGET = {
22
+ serverKey: 'tinyfish',
23
+ dir: piAgentDir,
24
+ file: () => path.join(piAgentDir(), 'mcp.json'),
25
+ };
26
+ export function piMcpPath() {
27
+ return PI_MCP_TARGET.file();
28
+ }
29
+ /** Reports the header's shape, never its value. */
30
+ export function readPiTinyfishEntry() {
31
+ return readTinyfishEntry(PI_MCP_TARGET);
32
+ }
33
+ /** Merges only the `tinyfish` key. */
34
+ export function writePiMcpConfig(mcpUrl, apiKey) {
35
+ return writeMcpConfig(PI_MCP_TARGET, mcpUrl, apiKey);
36
+ }
37
+ /** Dry-run description of the pending write; touches nothing. */
38
+ export function planPiWrite(mcpUrl, apiKey) {
39
+ return planWrite(PI_MCP_TARGET, mcpUrl, apiKey);
40
+ }
41
+ /** Removes only the `tinyfish` key. */
42
+ export function removePiMcpServer() {
43
+ return removeServer(PI_MCP_TARGET);
44
+ }
45
+ export const PI_ADAPTER_INSTALL_COMMAND = 'pi install npm:pi-mcp-adapter';
46
+ // Matches `npm:pi-mcp-adapter`, a pinned version, and git or path installs ending in the name.
47
+ const ADAPTER_SOURCE = /(?:^|[/:])pi-mcp-adapter(?:@|$)/;
48
+ // pi's PackageSource is a bare source or an object carrying it; `pi install` writes both.
49
+ const packageSourceSchema = z.union([z.string(), z.object({ source: z.string() })]);
50
+ const settingsSchema = z.object({ packages: z.array(packageSourceSchema).optional() });
51
+ function adapterInSettings(file) {
52
+ let raw;
53
+ try {
54
+ raw = fs.readFileSync(file, 'utf8');
55
+ }
56
+ catch (e) {
57
+ // No settings file means nothing is installed; any other read failure tells us nothing.
58
+ return e?.code === 'ENOENT' ? 'absent' : 'unknown';
59
+ }
60
+ let parsed;
61
+ try {
62
+ parsed = JSON.parse(raw);
63
+ }
64
+ catch {
65
+ return 'unknown';
66
+ }
67
+ const settings = settingsSchema.safeParse(parsed);
68
+ if (!settings.success)
69
+ return 'unknown';
70
+ const sources = (settings.data.packages ?? []).map((p) => (typeof p === 'string' ? p : p.source));
71
+ return sources.some((source) => ADAPTER_SOURCE.test(source)) ? 'installed' : 'absent';
72
+ }
73
+ /** Core pi has no MCP, so without this package the entry we write is never read. */
74
+ export function piMcpAdapterState(cwd = process.cwd()) {
75
+ // `pi install -l` records the adapter project-locally, where pi reads it just the same.
76
+ const scopes = [
77
+ path.join(piAgentDir(), 'settings.json'),
78
+ path.join(cwd, '.pi', 'settings.json'), // nosemgrep: path-join-resolve-traversal -- fixed names under the caller's cwd
79
+ ].map(adapterInSettings);
80
+ if (scopes.includes('installed'))
81
+ return 'installed';
82
+ return scopes.includes('unknown') ? 'unknown' : 'absent';
83
+ }
84
+ // Cached: detection runs more than once per command and the answer cannot change mid-run.
85
+ let cachedBinaryIsPi;
86
+ /** Tests share one process; the probe result must not. */
87
+ export function resetPiBinaryCache() {
88
+ cachedBinaryIsPi = undefined;
89
+ }
90
+ /** npm ships an unrelated `pi` that prints `3`; only a semver answer is the harness. */
91
+ export function piBinaryIsPi() {
92
+ cachedBinaryIsPi ??= { value: probeVersion() };
93
+ return cachedBinaryIsPi.value;
94
+ }
95
+ function probeVersion() {
96
+ const result = spawn.sync('pi', ['--version'], {
97
+ encoding: 'utf8',
98
+ timeout: HARNESS_PROBE_TIMEOUT_MS,
99
+ });
100
+ if (result.error || result.status !== 0)
101
+ return false;
102
+ return /^\d+\.\d+\.\d+/.test((result.stdout ?? '').trim());
103
+ }
104
+ /** `skills` hardcodes the default dir, so an override leaves the skill where pi will not look. */
105
+ export function piSkillDirMismatch() {
106
+ const agentDir = piAgentDir();
107
+ const fallback = path.join(os.homedir(), '.pi', 'agent');
108
+ if (agentDir === fallback)
109
+ return undefined;
110
+ return { installedTo: path.join(fallback, 'skills'), readFrom: path.join(agentDir, 'skills') };
111
+ }
@@ -8,6 +8,7 @@ import { HARNESS_PROBE_TIMEOUT_MS } from './constants.js';
8
8
  import { errLine } from './output.js';
9
9
  import { readCursorTinyfishEntry } from './cursor-config.js';
10
10
  import { readOmpTinyfishEntry } from './omp-config.js';
11
+ import { readPiTinyfishEntry } from './pi-config.js';
11
12
  import { readHermesKey, resolveHermesHome } from './hermes-env.js';
12
13
  import { readHermesEntry } from './hermes-config.js';
13
14
  import { detectInstalledHarnesses, harnessConfigPath, harnessDisplayPath, AuthMode, Registered, } from './harness-detect.js';
@@ -315,6 +316,30 @@ function probeOmp() {
315
316
  ...keyVerdict,
316
317
  };
317
318
  }
319
+ // Whether anything reads the entry is the pi-adapter check's question, not this one.
320
+ function probePi() {
321
+ const entry = readPiTinyfishEntry();
322
+ if (entry.error) {
323
+ return {
324
+ registered: Registered.Unknown,
325
+ authMode: AuthMode.Unknown,
326
+ reason: 'mcp.json exists but could not be read or parsed',
327
+ };
328
+ }
329
+ if (!entry.present)
330
+ return { registered: Registered.No, authMode: AuthMode.Unknown };
331
+ const keyVerdict = entry.keyTemplateVar
332
+ ? envKeyVerdict(entry.keyTemplateVar)
333
+ : entry.keyMatchesCliKey
334
+ ? { keyMatchesCliKey: true }
335
+ : {};
336
+ return {
337
+ registered: Registered.Yes,
338
+ authMode: entry.hasApiKeyHeader ? AuthMode.ApiKey : AuthMode.Unknown,
339
+ ...(entry.url ? { registeredUrl: entry.url } : {}),
340
+ ...keyVerdict,
341
+ };
342
+ }
318
343
  // Both list commands print this header in every state, including "no servers configured".
319
344
  // Exit 0 with output we cannot recognise is not evidence of absence: reporting `no` there would
320
345
  // earn a connect repair off a parse failure, which is the bug class this command exists to kill.
@@ -337,10 +362,11 @@ function fromMcpList(command, output, registeredMode, urlPattern) {
337
362
  }
338
363
  // `hermes mcp list` only pretty-prints this file, so connect's gate reads it too.
339
364
  function probeHermes() {
340
- const home = resolveHermesHome();
341
- if (!home) {
365
+ const resolved = resolveHermesHome();
366
+ if (typeof resolved !== 'string') {
342
367
  return unverified('`hermes dump` did not report a home directory');
343
368
  }
369
+ const home = resolved;
344
370
  const entry = readHermesEntry(home);
345
371
  if (entry.state === 'unreadable' || entry.state === 'unparseable') {
346
372
  return unverified(`Hermes' config.yaml could not be ${entry.state === 'unreadable' ? 'read' : 'parsed'}`);
@@ -479,6 +505,7 @@ const PROBES = {
479
505
  omp: probeOmp,
480
506
  openclaw: probeOpenClaw,
481
507
  opencode: probeOpencode,
508
+ pi: probePi,
482
509
  };
483
510
  /** Never throws; a failed probe is `unknown` with a reason, so no caller can read it as healthy. */
484
511
  export function detectRegistrations(harnesses) {
@@ -29,6 +29,7 @@ declare const harnessResultSchema: z.ZodObject<{
29
29
  codex: "codex";
30
30
  hermes: "hermes";
31
31
  opencode: "opencode";
32
+ pi: "pi";
32
33
  "claude-code": "claude-code";
33
34
  }>;
34
35
  detected: z.ZodBoolean;
@@ -63,6 +64,7 @@ export declare const setupCompletedPayloadSchema: z.ZodObject<{
63
64
  codex: "codex";
64
65
  hermes: "hermes";
65
66
  opencode: "opencode";
67
+ pi: "pi";
66
68
  "claude-code": "claude-code";
67
69
  }>;
68
70
  detected: z.ZodBoolean;
@@ -124,6 +126,7 @@ declare const doctorCompletedPayloadSchema: z.ZodObject<{
124
126
  codex: "codex";
125
127
  hermes: "hermes";
126
128
  opencode: "opencode";
129
+ pi: "pi";
127
130
  "claude-code": "claude-code";
128
131
  all: "all";
129
132
  }>;
@@ -137,6 +140,7 @@ declare const doctorCompletedPayloadSchema: z.ZodObject<{
137
140
  codex: "codex";
138
141
  hermes: "hermes";
139
142
  opencode: "opencode";
143
+ pi: "pi";
140
144
  "claude-code": "claude-code";
141
145
  }>>;
142
146
  harnesses: z.ZodArray<z.ZodObject<{
@@ -148,6 +152,7 @@ declare const doctorCompletedPayloadSchema: z.ZodObject<{
148
152
  codex: "codex";
149
153
  hermes: "hermes";
150
154
  opencode: "opencode";
155
+ pi: "pi";
151
156
  "claude-code": "claude-code";
152
157
  }>;
153
158
  detected: z.ZodBoolean;
@@ -183,6 +188,7 @@ declare const doctorCouldNotRunPayloadSchema: z.ZodObject<{
183
188
  codex: "codex";
184
189
  hermes: "hermes";
185
190
  opencode: "opencode";
191
+ pi: "pi";
186
192
  "claude-code": "claude-code";
187
193
  all: "all";
188
194
  }>;
@@ -219,6 +225,7 @@ declare const doctorPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
219
225
  codex: "codex";
220
226
  hermes: "hermes";
221
227
  opencode: "opencode";
228
+ pi: "pi";
222
229
  "claude-code": "claude-code";
223
230
  all: "all";
224
231
  }>;
@@ -232,6 +239,7 @@ declare const doctorPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
232
239
  codex: "codex";
233
240
  hermes: "hermes";
234
241
  opencode: "opencode";
242
+ pi: "pi";
235
243
  "claude-code": "claude-code";
236
244
  }>>;
237
245
  harnesses: z.ZodArray<z.ZodObject<{
@@ -243,6 +251,7 @@ declare const doctorPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
243
251
  codex: "codex";
244
252
  hermes: "hermes";
245
253
  opencode: "opencode";
254
+ pi: "pi";
246
255
  "claude-code": "claude-code";
247
256
  }>;
248
257
  detected: z.ZodBoolean;
@@ -277,6 +286,7 @@ declare const doctorPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
277
286
  codex: "codex";
278
287
  hermes: "hermes";
279
288
  opencode: "opencode";
289
+ pi: "pi";
280
290
  "claude-code": "claude-code";
281
291
  all: "all";
282
292
  }>;
@@ -1,5 +1,6 @@
1
1
  import { type InstallOptions } from './cli-install.js';
2
2
  import { type SkillAgent } from './connect-clients.js';
3
+ export declare const SKILLS_CLI_PACKAGE = "skills@1.5.15";
3
4
  export declare function installWebSkill(client: {
4
5
  skillAgent?: SkillAgent;
5
6
  displayName: string;
@@ -1,14 +1,15 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
1
3
  import * as path from 'node:path';
2
4
  import spawn from 'cross-spawn';
3
- import { z } from 'zod';
4
5
  import { loadConfig } from './auth.js';
5
6
  import { captureStdio, capturedOutput, replay, SKILL_INSTALL_TIMEOUT_MS, STEP_MAX_BUFFER, } from './cli-install.js';
6
7
  import { CURSOR_SKILL_TARGET, NATIVE_MCP_CLIENTS, OPENCLAW, openclawSkillInstallArgs, } from './connect-clients.js';
7
- import { ConnectInterruptedError, ConnectStepError, probeSupportVariant, spawnFailureDetail, spawnStepError, throwIfInterrupted, } from './connect-runtime.js';
8
+ import { ConnectInterruptedError, ConnectStepError, probeSupportVariant, spawnStepError, throwIfInterrupted, } from './connect-runtime.js';
8
9
  import { CLI_AGENT_IDENTITY } from './constants.js';
9
- import { errLine, parseJson } from './output.js';
10
+ import { errLine, sanitizeLine } from './output.js';
10
11
  // Supports Hermes without node:util.styleText, so the installer still runs on Node 20.11.
11
- const SKILLS_CLI_PACKAGE = 'skills@1.5.15';
12
+ export const SKILLS_CLI_PACKAGE = 'skills@1.5.15';
12
13
  const TINYFISH_WEB_SKILL_SOURCE = 'tinyfish-io/tinyfish-cookbook';
13
14
  const TINYFISH_WEB_SKILL = 'use-tinyfish';
14
15
  /** `skills add` is an unconditional overwrite, so it doubles as the refresh path. */
@@ -44,27 +45,34 @@ const SKILL_ALREADY_CURRENT_PATTERN = /All global skills are up to date/;
44
45
  // A lock entry with no recorded hash is untrackable, so `skills` reports it as skipped rather
45
46
  // than failed. Left undetected that reads as "up to date" while nothing was refreshed.
46
47
  const SKILL_UNCHECKABLE_PATTERN = /cannot be checked automatically/;
47
- const skillListSchema = z.array(z.object({ name: z.string() }));
48
- // `add` exits 0 on per-skill failure; the piped list is the verdict.
49
- function failedListVerdict() {
50
- const list = spawn.sync('npx', ['-y', SKILLS_CLI_PACKAGE, 'list', '--global', '--json'], {
51
- encoding: 'utf8',
52
- env: skillSpawnEnv(),
53
- maxBuffer: STEP_MAX_BUFFER,
54
- timeout: SKILL_INSTALL_TIMEOUT_MS,
55
- });
56
- const listRan = !list.error && list.status === 0;
57
- if (listRan && skillListed(list.stdout ?? ''))
58
- return null;
59
- // A clean list that omits the skill is not a real exit (PF-3680).
60
- const tag = listRan ? 'skill_absent_after_add' : `skill_list_failed ${spawnFailureDetail(list)}`;
61
- return { result: list, tag };
48
+ // `skills` writes here for the universal agents, whose own config dirs it never touches.
49
+ function canonicalSkillsDir() {
50
+ return path.join(os.homedir(), '.agents', 'skills'); // nosemgrep: path-join-resolve-traversal -- fixed dir names under os.homedir()
62
51
  }
63
- // Entry presence is the whole verdict: `agents` names the harnesses `skills` detects,
64
- // so an install for a harness with no config dir yet lists none (PF-3680).
65
- function skillListed(stdout) {
66
- const parsed = skillListSchema.safeParse(parseJson(stdout));
67
- return parsed.success && parsed.data.some((skill) => skill.name === TINYFISH_WEB_SKILL);
52
+ // Where the harness reads. Both of the modes `add` picks land the skill here.
53
+ const SKILL_DIR_BY_AGENT = {
54
+ 'claude-code': () => path.join(agentHome('CLAUDE_CONFIG_DIR', '.claude'), 'skills'),
55
+ // The env value, not resolveHermesHome(): the child we spawn reads the env.
56
+ 'hermes-agent': () => path.join(agentHome('HERMES_HOME', '.hermes'), 'skills'),
57
+ codex: canonicalSkillsDir,
58
+ cursor: canonicalSkillsDir,
59
+ opencode: canonicalSkillsDir,
60
+ // `skills` writes pi's here whatever PI_CODING_AGENT_DIR says.
61
+ pi: () => path.join(os.homedir(), '.pi', 'agent', 'skills'), // nosemgrep: path-join-resolve-traversal -- fixed dir names under os.homedir()
62
+ };
63
+ function agentHome(override, fallback) {
64
+ return process.env[override]?.trim() || path.join(os.homedir(), fallback); // nosemgrep: path-join-resolve-traversal -- fixed dir names under os.homedir()
65
+ }
66
+ /** `add` exits 0 on per-agent failure, so the file it should have written is the verdict. */
67
+ function skillOnDisk(agent) {
68
+ // SKILL.md, not the dir: `skills` mkdirs before it copies, so a failed copy leaves one.
69
+ return fs.existsSync(path.join(SKILL_DIR_BY_AGENT[agent](), TINYFISH_WEB_SKILL, 'SKILL.md'));
70
+ }
71
+ // The route tail-slices to 500, so an over-long tail would cut the tag off the front.
72
+ const SKILL_DETAIL_TAIL_MAX_CHARS = 425;
73
+ function absentDetail(addOutput) {
74
+ const tail = sanitizeLine(addOutput).trim().slice(-SKILL_DETAIL_TAIL_MAX_CHARS);
75
+ return tail ? `skill_absent_after_add | ${tail}` : 'skill_absent_after_add';
68
76
  }
69
77
  export function installWebSkill(client, { verbose }) {
70
78
  if (!client.skillAgent)
@@ -74,14 +82,13 @@ export function installWebSkill(client, { verbose }) {
74
82
  ...captureStdio(verbose),
75
83
  env: skillSpawnEnv(),
76
84
  });
85
+ const output = capturedOutput(add);
77
86
  const addFailed = Boolean(add.error) || add.status !== 0;
78
- const verdict = addFailed ? null : failedListVerdict();
79
- const failed = addFailed ? add : verdict?.result;
80
- if (failed) {
87
+ const absent = !addFailed && !skillOnDisk(client.skillAgent);
88
+ if (addFailed || absent) {
81
89
  // Built first: its interrupt check must run before any replay.
82
- const error = spawnStepError(`Could not install the TinyFish web skill in ${client.displayName}`, failed, verdict?.tag);
83
- // The add carries the failure prose; the list is JSON only.
84
- replay(capturedOutput(add));
90
+ const error = spawnStepError(`Could not install the TinyFish web skill in ${client.displayName}`, add, absent ? absentDetail(output) : undefined);
91
+ replay(output);
85
92
  throw error;
86
93
  }
87
94
  }
@@ -168,18 +175,18 @@ function reinstallWebSkill(skillAgents, verbose) {
168
175
  timeout: SKILL_INSTALL_TIMEOUT_MS,
169
176
  });
170
177
  const output = capturedOutput(result);
171
- // Quiet capture stays (#4434); the list verdict replaced prose matching.
178
+ // Quiet capture stays (#4434); the on-disk check replaced prose matching.
172
179
  const addFailed = Boolean(result.error) || result.status !== 0;
173
- const failedList = addFailed ? null : failedListVerdict();
174
- const failed = addFailed || failedList !== null;
180
+ const missing = addFailed ? undefined : skillAgents.find((agent) => !skillOnDisk(agent));
181
+ const failed = addFailed || missing !== undefined;
175
182
  if (failed)
176
- throwIfInterrupted(failedList?.result ?? result);
183
+ throwIfInterrupted(result);
177
184
  if (verbose || failed)
178
185
  replay(output);
179
186
  if (failed) {
180
- throw new Error('Could not refresh the TinyFish web skill', {
181
- cause: (failedList?.result ?? result).error,
182
- });
187
+ // Upgrade telemetry carries no detail, so the agent has to ride the message.
188
+ const scope = missing ? ` for ${missing}` : '';
189
+ throw new Error(`Could not refresh the TinyFish web skill${scope}`, { cause: result.error });
183
190
  }
184
191
  }
185
192
  /** Fallback when no skill-bearing harness is recorded: refresh whatever `skills` tracks. */
@@ -1,4 +1,4 @@
1
- import type { AgentRunParams, BrowserProfile, Run, RunStatus } from '@tiny-fish/sdk';
1
+ import type { AgentRunParams, BrowserProfile, FetchGetContentsParams, Run, RunStatus } from '@tiny-fish/sdk';
2
2
  export type OutputSchema = Record<string, unknown>;
3
3
  export interface CliAgentRunParams extends AgentRunParams {
4
4
  output_schema?: OutputSchema;
@@ -91,6 +91,15 @@ export interface RunStepsResponse {
91
91
  status: RunStatus;
92
92
  steps: RunStep[];
93
93
  }
94
+ export interface CliFetchHighlightsParams {
95
+ query: string;
96
+ max_snippets?: number;
97
+ max_characters?: number;
98
+ include_full_page_text?: boolean;
99
+ }
100
+ export interface CliFetchGetContentsParams extends FetchGetContentsParams {
101
+ highlights?: CliFetchHighlightsParams;
102
+ }
94
103
  export interface CliBrowserSessionCreateParams {
95
104
  url?: string;
96
105
  browser_profile?: 'lite' | 'stealth';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.39.1-next.311",
3
+ "version": "0.40.1-next.317",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {