@tiny-fish/cli 0.41.2-next.327 → 0.41.2-next.329

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.
package/README.md CHANGED
@@ -97,6 +97,18 @@ Hermes completes OAuth while adding the MCP server. `hermes mcp add` accepts no
97
97
  interactively. After setup, the command starts the walkthrough in a Hermes session and leaves that
98
98
  session open for your replies.
99
99
 
100
+ ### Connect Command Code
101
+
102
+ Register TinyFish user-wide with your API key:
103
+
104
+ ```bash
105
+ npx -y @tiny-fish/cli@latest connect command-code --api-key sk-tinyfish-...
106
+ ```
107
+
108
+ Command Code takes an API key only. `cmd mcp add` opens a browser sign-in even when a header is
109
+ passed, so TinyFish registers through `mcp add-json`, which writes `~/.commandcode/mcp.json`
110
+ without an auth probe. Without a key the command refuses rather than falling back to a sign-in.
111
+
100
112
  ### Connect OpenClaw
101
113
 
102
114
  Install the TinyFish skill, sign in with an API key, and start an interactive walkthrough with one
@@ -836,7 +836,7 @@ export function registerConnect(program) {
836
836
  program
837
837
  .command('connect')
838
838
  .description('Connect TinyFish to an AI agent')
839
- .argument('[client]', 'Agent client to connect (claude-code, codex, cursor, grok, hermes, omp, openclaw, or opencode); omit to pick from the agents detected on this machine')
839
+ .argument('[client]', `Agent client to connect (${ALL_HARNESSES.join(', ')}); omit to pick from the agents detected on this machine`)
840
840
  .option('--all', 'Detect and connect every supported harness found on this machine')
841
841
  .option('--dry-run', 'Print planned writes per harness without touching anything (--all only)')
842
842
  .option('--uninstall', 'Remove TinyFish entries written by connect --all')
@@ -0,0 +1,2 @@
1
+ /** Two levels up from dist/lib/ is the package root; tsc mirrors src/. */
2
+ export declare function bundledSkillDir(): string;
@@ -0,0 +1,8 @@
1
+ import * as path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { WEB_SKILL_NAME } from './constants.js';
4
+ /** Two levels up from dist/lib/ is the package root; tsc mirrors src/. */
5
+ export function bundledSkillDir() {
6
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
7
+ return path.join(packageRoot, 'skill', WEB_SKILL_NAME);
8
+ }
@@ -0,0 +1,3 @@
1
+ import { type McpJsonServerEntry } from './mcp-json-config.js';
2
+ /** Reports the header's shape, never its value. */
3
+ export declare function readCommandCodeTinyfishEntry(): McpJsonServerEntry;
@@ -0,0 +1,14 @@
1
+ import * as os from 'os';
2
+ import * as path from 'path';
3
+ import { readTinyfishEntry, } from './mcp-json-config.js';
4
+ // Read-only: Command Code writes this file itself, through `cmd mcp add-json --scope user`.
5
+ const COMMAND_CODE_TARGET = {
6
+ serverKey: 'tinyfish',
7
+ dir: () => path.join(os.homedir(), '.commandcode'),
8
+ file: () => path.join(os.homedir(), '.commandcode', 'mcp.json'),
9
+ keyHeader: { name: 'Authorization', valuePrefix: 'Bearer ' },
10
+ };
11
+ /** Reports the header's shape, never its value. */
12
+ export function readCommandCodeTinyfishEntry() {
13
+ return readTinyfishEntry(COMMAND_CODE_TARGET);
14
+ }
@@ -99,7 +99,7 @@ export declare function hermesRegistrationEnabled(home: string): {
99
99
  };
100
100
  export declare const NATIVE_MCP_CLIENTS: readonly NativeMcpClient[];
101
101
  /** Native descriptor by harness id; Cursor and OpenClaw have none. */
102
- export declare const NATIVE_BY_HARNESS: Map<"openclaw" | "omp" | "grok" | "cursor" | "codex" | "hermes" | "opencode" | "pi" | "claude-code", NativeMcpClient>;
102
+ export declare const NATIVE_BY_HARNESS: Map<"openclaw" | "omp" | "grok" | "cursor" | "codex" | "hermes" | "command-code" | "opencode" | "pi" | "claude-code", NativeMcpClient>;
103
103
  export declare const OPENCLAW: SupportedCommand;
104
104
  /** "printed" = handed to the user to paste; the walkthrough was never started for them. */
105
105
  export type WalkthroughOutcome = 'launched' | 'printed';
@@ -206,8 +206,21 @@ function specAddArgs(spec, mcpUrl) {
206
206
  ? ['mcp', 'add', ...flags, 'tinyfish', mcpUrl]
207
207
  : ['mcp', 'add', 'tinyfish', '--url', mcpUrl, ...flags];
208
208
  }
209
+ /** `mcp add` probes the server and runs OAuth; add-json only writes config. */
210
+ function jsonAddArgs(spec, mcpUrl, apiKey) {
211
+ const { header } = spec;
212
+ const body = {
213
+ transport: 'http',
214
+ url: mcpUrl,
215
+ ...(header ? { headers: { [header.name]: `${header.valuePrefix ?? ''}${apiKey}` } } : {}),
216
+ };
217
+ return ['mcp', 'add-json', ...(spec.keyedAddJsonFlags ?? []), 'tinyfish', JSON.stringify(body)];
218
+ }
209
219
  function specKeyAuth(spec) {
210
220
  const { header, keyEnvFlag, keyAddFlags } = spec;
221
+ if (spec.keyedAddJsonFlags) {
222
+ return { addArgs: (mcpUrl, apiKey) => jsonAddArgs(spec, mcpUrl, apiKey) };
223
+ }
211
224
  if (keyEnvFlag) {
212
225
  return {
213
226
  envVar: TINYFISH_API_KEY_VAR,
@@ -4,6 +4,8 @@ export declare const CLI_INVOCATION_ID: string;
4
4
  /** Unversioned: the install site appends `@latest`, the version read must not. */
5
5
  export declare const TINYFISH_CLI_PACKAGE = "@tiny-fish/cli";
6
6
  export declare const CLI_AGENT_IDENTITY = "tinyfish-cli";
7
+ /** Names the bundled dir, every install target dir, and the lock entry. */
8
+ export declare const WEB_SKILL_NAME = "use-tinyfish";
7
9
  /** The descriptor hands this name to harnesses; the probes resolve it. */
8
10
  export declare const TINYFISH_API_KEY_VAR = "TINYFISH_API_KEY";
9
11
  export declare const BASE_URL: string;
@@ -9,6 +9,8 @@ export const CLI_INVOCATION_ID = randomUUID();
9
9
  export const TINYFISH_CLI_PACKAGE = '@tiny-fish/cli';
10
10
  // How we identify ourselves to other tools; `skills` echoes it back.
11
11
  export const CLI_AGENT_IDENTITY = 'tinyfish-cli';
12
+ /** Names the bundled dir, every install target dir, and the lock entry. */
13
+ export const WEB_SKILL_NAME = 'use-tinyfish';
12
14
  /** The descriptor hands this name to harnesses; the probes resolve it. */
13
15
  export const TINYFISH_API_KEY_VAR = 'TINYFISH_API_KEY';
14
16
  /** Base URL for the TinyFish API. Override with TINYFISH_API_URL for staging/self-hosted. */
@@ -30,6 +30,7 @@ declare const doctorCheckSchema: z.ZodObject<{
30
30
  cursor: "cursor";
31
31
  codex: "codex";
32
32
  hermes: "hermes";
33
+ "command-code": "command-code";
33
34
  opencode: "opencode";
34
35
  pi: "pi";
35
36
  "claude-code": "claude-code";
@@ -48,6 +49,7 @@ declare const doctorHarnessSchema: z.ZodObject<{
48
49
  cursor: "cursor";
49
50
  codex: "codex";
50
51
  hermes: "hermes";
52
+ "command-code": "command-code";
51
53
  opencode: "opencode";
52
54
  pi: "pi";
53
55
  "claude-code": "claude-code";
@@ -75,6 +77,7 @@ declare const doctorRepairSchema: z.ZodObject<{
75
77
  cursor: "cursor";
76
78
  codex: "codex";
77
79
  hermes: "hermes";
80
+ "command-code": "command-code";
78
81
  opencode: "opencode";
79
82
  pi: "pi";
80
83
  "claude-code": "claude-code";
@@ -104,6 +107,7 @@ export declare const doctorReportSchema: z.ZodObject<{
104
107
  cursor: "cursor";
105
108
  codex: "codex";
106
109
  hermes: "hermes";
110
+ "command-code": "command-code";
107
111
  opencode: "opencode";
108
112
  pi: "pi";
109
113
  "claude-code": "claude-code";
@@ -122,6 +126,7 @@ export declare const doctorReportSchema: z.ZodObject<{
122
126
  cursor: "cursor";
123
127
  codex: "codex";
124
128
  hermes: "hermes";
129
+ "command-code": "command-code";
125
130
  opencode: "opencode";
126
131
  pi: "pi";
127
132
  "claude-code": "claude-code";
@@ -149,6 +154,7 @@ export declare const doctorReportSchema: z.ZodObject<{
149
154
  cursor: "cursor";
150
155
  codex: "codex";
151
156
  hermes: "hermes";
157
+ "command-code": "command-code";
152
158
  opencode: "opencode";
153
159
  pi: "pi";
154
160
  "claude-code": "claude-code";
@@ -44,6 +44,8 @@ export interface HarnessSpec {
44
44
  keyEnvFlag?: string;
45
45
  /** Keyed adds append these flags; the key itself is seeded elsewhere (Hermes). */
46
46
  keyAddFlags?: string[];
47
+ /** Keyed adds go through `mcp add-json`, which takes the URL and header in its body. */
48
+ keyedAddJsonFlags?: string[];
47
49
  /** No sign-in exists here, so connect refuses a keyless install (Hermes). */
48
50
  keyRequired?: true;
49
51
  loginArgs?: string[];
@@ -133,6 +135,29 @@ export declare const HARNESS_SPECS: {
133
135
  loginArgs: string[];
134
136
  authDeferredAtInstall: true;
135
137
  };
138
+ 'command-code': {
139
+ command: string;
140
+ displayName: string;
141
+ configDir: string;
142
+ reloadAction: string;
143
+ supportCheck: {
144
+ args: string[];
145
+ patterns: RegExp[];
146
+ timeoutMs: number;
147
+ unavailableMessage: string;
148
+ };
149
+ keyRequired: true;
150
+ header: {
151
+ name: string;
152
+ sep: ": ";
153
+ valuePrefix: string;
154
+ };
155
+ keyedAddJsonFlags: string[];
156
+ removals: {
157
+ args: string[];
158
+ label: string;
159
+ }[];
160
+ };
136
161
  cursor: {
137
162
  command: string;
138
163
  displayName: string;
@@ -253,7 +278,7 @@ export declare const ALL_HARNESSES: readonly Harness[];
253
278
  export declare function harnessSpec(harness: Harness): HarnessSpec;
254
279
  /** The rest of the union: every one of these must supply a connect and launch override. */
255
280
  export type NonNativeHarness = {
256
- [K in Harness]: 'urlStyle' extends keyof (typeof HARNESS_SPECS)[K] ? never : K;
281
+ [K in Harness]: 'urlStyle' extends keyof (typeof HARNESS_SPECS)[K] ? never : 'keyRequired' extends keyof (typeof HARNESS_SPECS)[K] ? never : K;
257
282
  }[Harness];
258
- /** Native MCP harnesses carry add-generation fields; the rest override connect. */
259
- export declare const NATIVE_HARNESSES: ("openclaw" | "omp" | "grok" | "cursor" | "codex" | "hermes" | "opencode" | "pi" | "claude-code")[];
283
+ /** Native MCP harnesses generate an add; key-only ones generate only the keyed form. */
284
+ export declare const NATIVE_HARNESSES: ("openclaw" | "omp" | "grok" | "cursor" | "codex" | "hermes" | "command-code" | "opencode" | "pi" | "claude-code")[];
@@ -46,7 +46,11 @@ const OPENCODE_MODEL_NOTE = 'Note: TinyFish runs on tool calls, so OpenCode need
46
46
  'models (e.g. Nano Banana Pro) will show "No endpoints found that support tool use" — switch ' +
47
47
  "OpenCode's model if the walkthrough can't start.";
48
48
  const HERMES_RESTART_NOTE = 'Restart your Hermes session to pick up TinyFish — Hermes discovers MCP servers at startup.';
49
+ const COMMAND_CODE_ADD_JSON_UNAVAILABLE_MESSAGE = 'Could not confirm this Command Code installation supports keyed MCP setup: `commandcode mcp ' +
50
+ '--help` did not list `add-json`. Run `commandcode update` and retry.' +
51
+ SUPPORT_CHECK_DEBUG_HINT;
49
52
  const HEADER_FLAG = /(?:^|\s)--header(?:[\s<=]|$)/m;
53
+ const ADD_JSON_COMMAND = /^\s*add-json(?:\s|\[)/m;
50
54
  /** One entry per harness; every per-harness list derives from it. */
51
55
  export const HARNESS_SPECS = {
52
56
  'claude-code': {
@@ -108,6 +112,32 @@ export const HARNESS_SPECS = {
108
112
  // TODO(PF-3580): connect now probes Codex sign-in, so this over-warns a signed-in install.
109
113
  authDeferredAtInstall: true,
110
114
  },
115
+ 'command-code': {
116
+ // Same package ships `cmd`, but that is cmd.exe on Windows; this alias collides with nothing.
117
+ command: 'commandcode',
118
+ displayName: 'Command Code',
119
+ configDir: '.commandcode',
120
+ reloadAction: 'restart it',
121
+ // add-json is the only command this harness uses, so it is what must exist.
122
+ supportCheck: {
123
+ args: ['mcp', '--help'],
124
+ patterns: [ADD_JSON_COMMAND],
125
+ // Command Code self-updates inside the probe, which outlasts the default budget.
126
+ timeoutMs: 30_000,
127
+ unavailableMessage: COMMAND_CODE_ADD_JSON_UNAVAILABLE_MESSAGE,
128
+ },
129
+ // Key-only: `cmd mcp add` runs browser OAuth even when a header is supplied.
130
+ keyRequired: true,
131
+ header: { name: 'Authorization', sep: ': ', valuePrefix: 'Bearer ' },
132
+ // Default scope is `local`, which writes a per-project entry instead of the user's.
133
+ keyedAddJsonFlags: ['--scope', 'user'],
134
+ removals: [
135
+ {
136
+ args: ['mcp', 'remove', 'tinyfish', '--scope', 'user'],
137
+ label: 'user TinyFish registration',
138
+ },
139
+ ],
140
+ },
111
141
  cursor: {
112
142
  command: 'cursor-agent',
113
143
  displayName: 'Cursor',
@@ -228,5 +258,8 @@ export const ALL_HARNESSES = Object.keys(HARNESS_SPECS);
228
258
  export function harnessSpec(harness) {
229
259
  return HARNESS_SPECS[harness];
230
260
  }
231
- /** Native MCP harnesses carry add-generation fields; the rest override connect. */
232
- export const NATIVE_HARNESSES = ALL_HARNESSES.filter((harness) => harnessSpec(harness).urlStyle !== undefined);
261
+ /** Native MCP harnesses generate an add; key-only ones generate only the keyed form. */
262
+ export const NATIVE_HARNESSES = ALL_HARNESSES.filter((harness) => {
263
+ const spec = harnessSpec(harness);
264
+ return spec.urlStyle !== undefined || spec.keyRequired === true;
265
+ });
@@ -26,6 +26,8 @@ const HARNESS_FINGERPRINTS = [
26
26
  // prefix so we catch a Codex run regardless of which of its vars are set.
27
27
  { name: 'codex', matches: (env) => hasVarWithPrefix(env, 'CODEX_') },
28
28
  { name: 'hermes', matches: (env) => hasVarWithPrefix(env, 'HERMES_') },
29
+ // COMMANDCODE_SCRATCHPAD lands on every child; a disabled scratchpad leaves no marker.
30
+ { name: 'command-code', matches: (env) => hasVarWithPrefix(env, 'COMMANDCODE_') },
29
31
  // Exact key, not a prefix: opencode writes OPENCODE=1 at startup, reads every OPENCODE_* as config.
30
32
  { name: 'opencode', matches: (env) => Boolean(env['OPENCODE']) },
31
33
  // Observed: pi launched from Claude Code inherits CLAUDECODE=1.
@@ -3,6 +3,11 @@ export interface McpJsonTarget {
3
3
  /** Functions so paths resolve at call time. */
4
4
  dir(): string;
5
5
  file(): string;
6
+ /** Read-side only; defaults to what the write path emits (Command Code writes its own). */
7
+ keyHeader?: {
8
+ name: string;
9
+ valuePrefix?: string;
10
+ };
6
11
  }
7
12
  export interface McpJsonWriteResult {
8
13
  status: 'written' | 'unchanged' | 'corrupt_skip';
@@ -17,6 +22,8 @@ export declare function planWrite(target: McpJsonTarget, mcpUrl: string, apiKey?
17
22
  export interface McpJsonServerEntry {
18
23
  present: boolean;
19
24
  hasApiKeyHeader: boolean;
25
+ /** Only ever false: harnesses with a disable toggle write it, the rest omit it. */
26
+ enabled?: false;
20
27
  /** Registered endpoint, so a caller can tell "registered" from "registered at the right place". */
21
28
  url?: string;
22
29
  keyMatchesCliKey?: boolean;
@@ -67,21 +67,31 @@ export function readTinyfishEntry(target) {
67
67
  const entry = isPlainRecord(servers) ? servers[target.serverKey] : undefined;
68
68
  if (!isPlainRecord(entry))
69
69
  return { present: false, hasApiKeyHeader: false };
70
- const headers = entry.headers;
71
- // Header names are case-insensitive, and this file is hand-editable — matching only the
72
- // casing we write would understate auth mode for a user who typed it differently.
73
- const keyHeader = isPlainRecord(headers)
74
- ? Object.entries(headers).find((entry) => entry[0].toLowerCase() === 'x-api-key' && typeof entry[1] === 'string')
75
- : undefined;
76
- const templateVar = keyHeader ? ENV_TEMPLATE_VALUE.exec(keyHeader[1])?.[1] : undefined;
70
+ const key = readKeyHeader(entry.headers, target.keyHeader);
71
+ const templateVar = key ? ENV_TEMPLATE_VALUE.exec(key.value)?.[1] : undefined;
77
72
  return {
78
73
  present: true,
79
- hasApiKeyHeader: keyHeader !== undefined,
80
- ...(matchesCliKey(keyHeader?.[1]) ? { keyMatchesCliKey: true } : {}),
74
+ hasApiKeyHeader: key !== undefined,
75
+ ...(entry.enabled === false ? { enabled: false } : {}),
76
+ ...(matchesCliKey(key?.value) ? { keyMatchesCliKey: true } : {}),
81
77
  ...(templateVar ? { keyTemplateVar: templateVar } : {}),
82
78
  ...(typeof entry.url === 'string' ? { url: entry.url } : {}),
83
79
  };
84
80
  }
81
+ /** Reports the key header's shape, never its value. */
82
+ function readKeyHeader(headers, keyHeader) {
83
+ const { name = 'x-api-key', valuePrefix = '' } = keyHeader ?? {};
84
+ // Header names are case-insensitive, and this file is hand-editable — matching only the
85
+ // casing we write would understate auth mode for a user who typed it differently.
86
+ const found = isPlainRecord(headers)
87
+ ? Object.entries(headers).find((entry) => entry[0].toLowerCase() === name.toLowerCase() && typeof entry[1] === 'string')
88
+ : undefined;
89
+ if (!found)
90
+ return undefined;
91
+ // The scheme is case-insensitive too, and a hand-edited header may omit it entirely.
92
+ const hasPrefix = found[1].toLowerCase().startsWith(valuePrefix.toLowerCase());
93
+ return { value: hasPrefix ? found[1].slice(valuePrefix.length) : found[1] };
94
+ }
85
95
  // Backup, then atomic temp+rename; pid avoids same-millisecond backup collisions.
86
96
  function commitServers(target, existing, servers) {
87
97
  const filePath = target.file();
@@ -6,6 +6,7 @@ import { loadConfig, matchesCliKey } from './auth.js';
6
6
  import { NATIVE_BY_HARNESS } from './connect-clients.js';
7
7
  import { HARNESS_PROBE_TIMEOUT_MS } from './constants.js';
8
8
  import { errLine } from './output.js';
9
+ import { readCommandCodeTinyfishEntry } from './command-code-config.js';
9
10
  import { readCursorTinyfishEntry } from './cursor-config.js';
10
11
  import { readOmpTinyfishEntry } from './omp-config.js';
11
12
  import { readPiTinyfishEntry } from './pi-config.js';
@@ -278,6 +279,22 @@ function probeCodex() {
278
279
  ...(envVar ? envKeyVerdict(envVar) : {}),
279
280
  };
280
281
  }
282
+ function probeCommandCode() {
283
+ const entry = readCommandCodeTinyfishEntry();
284
+ if (entry.error)
285
+ return unverified('mcp.json exists but could not be read or parsed');
286
+ if (!entry.present)
287
+ return NOT_REGISTERED;
288
+ // The /mcp toggle disables an entry in place, and Command Code then ignores it.
289
+ return {
290
+ registered: Registered.Yes,
291
+ authMode: entry.hasApiKeyHeader ? AuthMode.ApiKey : AuthMode.Unknown,
292
+ // connect only ever writes a keyed entry here, so a headerless one was not written by us.
293
+ ...(entry.enabled === false ? { connected: false } : {}),
294
+ ...(entry.url ? { registeredUrl: entry.url } : {}),
295
+ ...(entry.keyMatchesCliKey ? { keyMatchesCliKey: true } : {}),
296
+ };
297
+ }
281
298
  function probeCursor() {
282
299
  const entry = readCursorTinyfishEntry();
283
300
  if (entry.error) {
@@ -499,6 +516,7 @@ function probeGrok() {
499
516
  const PROBES = {
500
517
  'claude-code': () => fromMcpGet('claude'),
501
518
  codex: probeCodex,
519
+ 'command-code': probeCommandCode,
502
520
  cursor: probeCursor,
503
521
  grok: probeGrok,
504
522
  hermes: probeHermes,
@@ -28,6 +28,7 @@ declare const harnessResultSchema: z.ZodObject<{
28
28
  cursor: "cursor";
29
29
  codex: "codex";
30
30
  hermes: "hermes";
31
+ "command-code": "command-code";
31
32
  opencode: "opencode";
32
33
  pi: "pi";
33
34
  "claude-code": "claude-code";
@@ -63,6 +64,7 @@ export declare const setupCompletedPayloadSchema: z.ZodObject<{
63
64
  cursor: "cursor";
64
65
  codex: "codex";
65
66
  hermes: "hermes";
67
+ "command-code": "command-code";
66
68
  opencode: "opencode";
67
69
  pi: "pi";
68
70
  "claude-code": "claude-code";
@@ -125,6 +127,7 @@ declare const doctorCompletedPayloadSchema: z.ZodObject<{
125
127
  cursor: "cursor";
126
128
  codex: "codex";
127
129
  hermes: "hermes";
130
+ "command-code": "command-code";
128
131
  opencode: "opencode";
129
132
  pi: "pi";
130
133
  "claude-code": "claude-code";
@@ -139,6 +142,7 @@ declare const doctorCompletedPayloadSchema: z.ZodObject<{
139
142
  cursor: "cursor";
140
143
  codex: "codex";
141
144
  hermes: "hermes";
145
+ "command-code": "command-code";
142
146
  opencode: "opencode";
143
147
  pi: "pi";
144
148
  "claude-code": "claude-code";
@@ -151,6 +155,7 @@ declare const doctorCompletedPayloadSchema: z.ZodObject<{
151
155
  cursor: "cursor";
152
156
  codex: "codex";
153
157
  hermes: "hermes";
158
+ "command-code": "command-code";
154
159
  opencode: "opencode";
155
160
  pi: "pi";
156
161
  "claude-code": "claude-code";
@@ -187,6 +192,7 @@ declare const doctorCouldNotRunPayloadSchema: z.ZodObject<{
187
192
  cursor: "cursor";
188
193
  codex: "codex";
189
194
  hermes: "hermes";
195
+ "command-code": "command-code";
190
196
  opencode: "opencode";
191
197
  pi: "pi";
192
198
  "claude-code": "claude-code";
@@ -224,6 +230,7 @@ declare const doctorPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
224
230
  cursor: "cursor";
225
231
  codex: "codex";
226
232
  hermes: "hermes";
233
+ "command-code": "command-code";
227
234
  opencode: "opencode";
228
235
  pi: "pi";
229
236
  "claude-code": "claude-code";
@@ -238,6 +245,7 @@ declare const doctorPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
238
245
  cursor: "cursor";
239
246
  codex: "codex";
240
247
  hermes: "hermes";
248
+ "command-code": "command-code";
241
249
  opencode: "opencode";
242
250
  pi: "pi";
243
251
  "claude-code": "claude-code";
@@ -250,6 +258,7 @@ declare const doctorPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
250
258
  cursor: "cursor";
251
259
  codex: "codex";
252
260
  hermes: "hermes";
261
+ "command-code": "command-code";
253
262
  opencode: "opencode";
254
263
  pi: "pi";
255
264
  "claude-code": "claude-code";
@@ -285,6 +294,7 @@ declare const doctorPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
285
294
  cursor: "cursor";
286
295
  codex: "codex";
287
296
  hermes: "hermes";
297
+ "command-code": "command-code";
288
298
  opencode: "opencode";
289
299
  pi: "pi";
290
300
  "claude-code": "claude-code";
@@ -1,5 +1,4 @@
1
1
  import * as fs from 'node:fs';
2
- import * as os from 'node:os';
3
2
  import * as path from 'node:path';
4
3
  import spawn from 'cross-spawn';
5
4
  import { loadConfig } from './auth.js';
@@ -8,6 +7,7 @@ import { CURSOR_SKILL_TARGET, NATIVE_MCP_CLIENTS, OPENCLAW, openclawSkillInstall
8
7
  import { ConnectInterruptedError, ConnectStepError, probeSupportVariant, spawnStepError, throwIfInterrupted, } from './connect-runtime.js';
9
8
  import { CLI_AGENT_IDENTITY } from './constants.js';
10
9
  import { errLine, sanitizeLine } from './output.js';
10
+ import { skillTargetDir } from './skill-paths.js';
11
11
  // Supports Hermes without node:util.styleText, so the installer still runs on Node 20.11.
12
12
  export const SKILLS_CLI_PACKAGE = 'skills@1.5.15';
13
13
  const TINYFISH_WEB_SKILL_SOURCE = 'tinyfish-io/tinyfish-cookbook';
@@ -45,28 +45,10 @@ const SKILL_ALREADY_CURRENT_PATTERN = /All global skills are up to date/;
45
45
  // A lock entry with no recorded hash is untrackable, so `skills` reports it as skipped rather
46
46
  // than failed. Left undetected that reads as "up to date" while nothing was refreshed.
47
47
  const SKILL_UNCHECKABLE_PATTERN = /cannot be checked automatically/;
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()
51
- }
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
48
  /** `add` exits 0 on per-agent failure, so the file it should have written is the verdict. */
67
49
  function skillOnDisk(agent) {
68
50
  // 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'));
51
+ return fs.existsSync(path.join(skillTargetDir(agent), 'SKILL.md'));
70
52
  }
71
53
  // The route tail-slices to 500, so an over-long tail would cut the tag off the front.
72
54
  const SKILL_DETAIL_TAIL_MAX_CHARS = 425;
@@ -0,0 +1,5 @@
1
+ import type { SkillAgent } from './harness-spec.js';
2
+ /** The skill's own dir under where skills@1.5.15 wrote for this agent. */
3
+ export declare function skillTargetDir(agent: SkillAgent): string;
4
+ /** Mirrors skills@1.5.15 getSkillLockPath(); missing the XDG branch strands entries. */
5
+ export declare function skillLockPath(): string;
@@ -0,0 +1,33 @@
1
+ import * as os from 'node:os';
2
+ import * as path from 'node:path';
3
+ import { WEB_SKILL_NAME } from './constants.js';
4
+ // Mirrors skills@1.5.15 exactly: it trims and falls back on empty values.
5
+ function agentHome(override, fallback) {
6
+ return process.env[override]?.trim() || path.join(os.homedir(), fallback); // nosemgrep: path-join-resolve-traversal -- fixed dir names under os.homedir()
7
+ }
8
+ // `skills` writes here for the universal agents, whose own config dirs it never touches.
9
+ function canonicalSkillsDir() {
10
+ return path.join(os.homedir(), '.agents', 'skills'); // nosemgrep: path-join-resolve-traversal -- fixed dir names under os.homedir()
11
+ }
12
+ // Where the harness reads. Both of the modes `add` picks land the skill here.
13
+ const SKILL_DIR_BY_AGENT = {
14
+ 'claude-code': () => path.join(agentHome('CLAUDE_CONFIG_DIR', '.claude'), 'skills'),
15
+ // The env value, not resolveHermesHome(): skills@1.5.15 reads $HERMES_HOME directly.
16
+ 'hermes-agent': () => path.join(agentHome('HERMES_HOME', '.hermes'), 'skills'),
17
+ codex: canonicalSkillsDir,
18
+ cursor: canonicalSkillsDir,
19
+ opencode: canonicalSkillsDir,
20
+ // `skills` writes pi's here whatever PI_CODING_AGENT_DIR says.
21
+ pi: () => path.join(os.homedir(), '.pi', 'agent', 'skills'), // nosemgrep: path-join-resolve-traversal -- fixed dir names under os.homedir()
22
+ };
23
+ /** The skill's own dir under where skills@1.5.15 wrote for this agent. */
24
+ export function skillTargetDir(agent) {
25
+ return path.join(SKILL_DIR_BY_AGENT[agent](), WEB_SKILL_NAME);
26
+ }
27
+ /** Mirrors skills@1.5.15 getSkillLockPath(); missing the XDG branch strands entries. */
28
+ export function skillLockPath() {
29
+ const xdgStateHome = process.env.XDG_STATE_HOME?.trim();
30
+ if (xdgStateHome)
31
+ return path.join(xdgStateHome, 'skills', '.skill-lock.json');
32
+ return path.join(os.homedir(), '.agents', '.skill-lock.json');
33
+ }
@@ -0,0 +1,13 @@
1
+ import type { SkillAgent } from './harness-spec.js';
2
+ /**
3
+ * Writes the bundled skill where each agent reads it; true iff bytes changed.
4
+ *
5
+ * Synchronous on purpose: connect flows are sync, a new await breaks them.
6
+ */
7
+ export declare function writeWebSkill(agents: SkillAgent[], opts?: {
8
+ sourceDir?: string;
9
+ }): boolean;
10
+ /** Best effort: a lock cleanup failure must never fail an install. */
11
+ export declare function clearSkillLockEntry(opts?: {
12
+ verbose?: boolean;
13
+ }): void;
@@ -0,0 +1,187 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { bundledSkillDir } from './bundled-skill.js';
4
+ import { WEB_SKILL_NAME } from './constants.js';
5
+ import { ConnectStepError } from './connect-runtime.js';
6
+ import { errLine } from './output.js';
7
+ import { skillLockPath, skillTargetDir } from './skill-paths.js';
8
+ // A younger staging dir may belong to a live concurrent connect.
9
+ const STAGING_SWEEP_MIN_AGE_MS = 5 * 60_000;
10
+ // Under the route's 500-char tail-slice; margin for caller prefixes.
11
+ const FAILURE_DETAIL_MAX_CHARS = 425;
12
+ /**
13
+ * Writes the bundled skill where each agent reads it; true iff bytes changed.
14
+ *
15
+ * Synchronous on purpose: connect flows are sync, a new await breaks them.
16
+ */
17
+ export function writeWebSkill(agents, opts) {
18
+ const sourceDir = opts?.sourceDir ?? bundledSkillDir();
19
+ const targetDirs = [...new Set(agents.map(skillTargetDir))];
20
+ let changed = false;
21
+ for (const targetDir of targetDirs) {
22
+ try {
23
+ changed = writeSkillDir(sourceDir, targetDir) || changed;
24
+ }
25
+ catch (error) {
26
+ throw skillWriteError(error, targetDir);
27
+ }
28
+ }
29
+ return changed;
30
+ }
31
+ function writeSkillDir(sourceDir, targetDir) {
32
+ const parent = path.dirname(targetDir);
33
+ fs.mkdirSync(parent, { recursive: true });
34
+ sweepStaleStaging(parent);
35
+ if (matchesSource(sourceDir, targetDir))
36
+ return false;
37
+ // Stage-then-swap: an early rm would strand the user skill-less on failure.
38
+ const stagingDir = path.join(parent, `${WEB_SKILL_NAME}.tmp-${process.pid}`);
39
+ fs.rmSync(stagingDir, { recursive: true, force: true });
40
+ fs.mkdirSync(stagingDir);
41
+ for (const name of fs.readdirSync(sourceDir)) {
42
+ fs.copyFileSync(path.join(sourceDir, name), path.join(stagingDir, name));
43
+ }
44
+ swapIntoPlace(stagingDir, targetDir, sourceDir);
45
+ return true;
46
+ }
47
+ // The old copy survives as a sibling backup until the new dir lands.
48
+ function swapIntoPlace(stagingDir, targetDir, sourceDir) {
49
+ const backupDir = moveAsideExisting(targetDir);
50
+ try {
51
+ try {
52
+ fs.renameSync(stagingDir, targetDir);
53
+ }
54
+ catch (error) {
55
+ // A concurrent connect can recreate the target mid-swap.
56
+ if (!isErrnoCode(error, 'ENOTEMPTY') && !isErrnoCode(error, 'EEXIST'))
57
+ throw error;
58
+ if (matchesSource(sourceDir, targetDir)) {
59
+ // The concurrent writer already landed these bytes; ours can go.
60
+ fs.rmSync(stagingDir, { recursive: true, force: true });
61
+ }
62
+ else {
63
+ removeExistingEntry(targetDir);
64
+ fs.renameSync(stagingDir, targetDir);
65
+ }
66
+ }
67
+ }
68
+ catch (error) {
69
+ fs.rmSync(stagingDir, { recursive: true, force: true });
70
+ restoreBackup(backupDir, targetDir);
71
+ throw error;
72
+ }
73
+ if (backupDir)
74
+ removeExistingEntry(backupDir);
75
+ }
76
+ // Sweep-prefixed name, so an orphaned backup is eventually cleaned.
77
+ function moveAsideExisting(targetDir) {
78
+ try {
79
+ fs.lstatSync(targetDir);
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ const backupDir = `${targetDir}.tmp-${process.pid}-prev`;
85
+ removeExistingEntry(backupDir);
86
+ fs.renameSync(targetDir, backupDir);
87
+ return backupDir;
88
+ }
89
+ function restoreBackup(backupDir, targetDir) {
90
+ if (!backupDir)
91
+ return;
92
+ try {
93
+ fs.renameSync(backupDir, targetDir);
94
+ }
95
+ catch {
96
+ // A concurrent writer owns the target now; leave its copy in place.
97
+ }
98
+ }
99
+ /** Only a real dir with the bundle's exact file set and bytes skips. */
100
+ function matchesSource(sourceDir, targetDir) {
101
+ let stat;
102
+ try {
103
+ stat = fs.lstatSync(targetDir);
104
+ }
105
+ catch {
106
+ return false;
107
+ }
108
+ if (!stat.isDirectory())
109
+ return false;
110
+ const sourceFiles = fs.readdirSync(sourceDir).sort();
111
+ const targetFiles = fs.readdirSync(targetDir).sort();
112
+ if (sourceFiles.join('\n') !== targetFiles.join('\n'))
113
+ return false;
114
+ return sourceFiles.every((name) => fs.readFileSync(path.join(sourceDir, name)).equals(fs.readFileSync(path.join(targetDir, name))));
115
+ }
116
+ /** lstat first: a skills-CLI symlink must be unlinked, never followed. */
117
+ function removeExistingEntry(targetDir) {
118
+ let stat;
119
+ try {
120
+ stat = fs.lstatSync(targetDir);
121
+ }
122
+ catch {
123
+ return;
124
+ }
125
+ if (stat.isDirectory()) {
126
+ fs.rmSync(targetDir, { recursive: true });
127
+ return;
128
+ }
129
+ try {
130
+ fs.unlinkSync(targetDir);
131
+ }
132
+ catch (error) {
133
+ // Windows junctions: unlink is EPERM there, rmdir removes the link.
134
+ if (isErrnoCode(error, 'EPERM') || isErrnoCode(error, 'EISDIR'))
135
+ fs.rmdirSync(targetDir);
136
+ else
137
+ throw error;
138
+ }
139
+ }
140
+ function sweepStaleStaging(parent) {
141
+ let entries;
142
+ try {
143
+ entries = fs.readdirSync(parent);
144
+ }
145
+ catch {
146
+ return;
147
+ }
148
+ for (const name of entries) {
149
+ if (!name.startsWith(`${WEB_SKILL_NAME}.tmp-`))
150
+ continue;
151
+ try {
152
+ const staged = path.join(parent, name);
153
+ if (Date.now() - fs.lstatSync(staged).mtimeMs < STAGING_SWEEP_MIN_AGE_MS)
154
+ continue;
155
+ fs.rmSync(staged, { recursive: true, force: true });
156
+ }
157
+ catch {
158
+ // Best effort; a sweep failure must never fail the install.
159
+ }
160
+ }
161
+ }
162
+ // Reason stays in the closed enum; the errno rides failure_detail instead.
163
+ function skillWriteError(error, targetDir) {
164
+ const code = error?.code ?? 'unknown';
165
+ return new ConnectStepError('Could not write the TinyFish web skill', 'unexpected_error', {
166
+ cause: error,
167
+ failureDetail: `skill_write_failed ${code} ${targetDir}`.slice(0, FAILURE_DETAIL_MAX_CHARS),
168
+ });
169
+ }
170
+ function isErrnoCode(error, code) {
171
+ return error?.code === code;
172
+ }
173
+ /** Best effort: a lock cleanup failure must never fail an install. */
174
+ export function clearSkillLockEntry(opts) {
175
+ const lockPath = skillLockPath();
176
+ try {
177
+ const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
178
+ if (!lock.skills || !(WEB_SKILL_NAME in lock.skills))
179
+ return;
180
+ delete lock.skills[WEB_SKILL_NAME];
181
+ fs.writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`);
182
+ }
183
+ catch {
184
+ if (opts?.verbose)
185
+ errLine('Skipped cleaning the skills lock file.');
186
+ }
187
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.41.2-next.327",
3
+ "version": "0.41.2-next.329",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "dist/",
11
+ "skill/",
11
12
  "README.md",
12
13
  "LICENSE"
13
14
  ],
@@ -0,0 +1 @@
1
+ 8615317f6db58ae776dd53817ac30668c1db5ef8
@@ -0,0 +1,216 @@
1
+ ---
2
+ name: use-tinyfish
3
+ description: Use TinyFish for web search, fetching URLs, reading pages, current information, source-backed answers, research, docs, pricing/product pages, extraction, scraping, and browser automation. Use whenever the user asks to search, find, look up, research, compare, get information from the web, summarize a URL, fetch page content, or automate a website.
4
+ ---
5
+
6
+ # TinyFish CLI
7
+
8
+ You have access to the TinyFish CLI (`tinyfish`) — a suite of web tools you can call from the terminal.
9
+
10
+ If not installed: `npm install -g @tiny-fish/cli`
11
+ If not authenticated: `tinyfish auth login --source openclaw` or set `TINYFISH_API_KEY` env var.
12
+
13
+ ---
14
+
15
+ ## When This Skill Should Trigger
16
+
17
+ Use TinyFish whenever a request depends on live web information or page content. Do not wait for the user to say "TinyFish" or "scrape".
18
+
19
+ Strong triggers include:
20
+
21
+ - Search or discovery: search, find, look up, research, compare, latest, current, news, docs, pricing, product details, best options.
22
+ - URL/page reading: fetch, read, summarize, extract from this page, inspect this URL, get the content, pull links or metadata.
23
+ - Source-backed answers: answer using web sources, verify a fact, check whether something changed, gather information from the web.
24
+ - Website work: interact with a site, click through pages, fill forms, log in, collect structured data, handle bot-protected pages.
25
+
26
+ Default to the lightest tool that can answer:
27
+
28
+ - No URL and the user needs web information: `search`, then `fetch` the best result(s) if more detail is needed.
29
+ - URL provided and only content is needed: `fetch`.
30
+ - Page interaction or dynamic extraction is needed: `agent`.
31
+ - Raw CDP/Playwright-style control is needed: `browser`.
32
+
33
+ ---
34
+
35
+ ## Picking the Right Tool
36
+
37
+ TinyFish has four tools. Start with the lightest one that can do the job and escalate only when needed.
38
+
39
+ ```
40
+ search → fetch → agent → browser
41
+ lightest heaviest
42
+ ```
43
+
44
+ | Tool | When to use | Speed | Cost |
45
+ |------|-------------|-------|------|
46
+ | **search** | You need to find URLs, current facts, docs, pricing, product details, or a quick source-backed answer | Fastest | Lowest |
47
+ | **fetch** | You have URLs and need clean page content, summaries, article text, docs, product pages, links, or metadata | Fast | Low |
48
+ | **agent** | You need to interact with a page — click, fill forms, navigate, extract structured data from dynamic sites | Slower | Higher |
49
+ | **browser** | Agent isn't enough — you need raw programmatic browser control via CDP | Slowest | Highest |
50
+
51
+ ### Common Patterns
52
+
53
+ **Research: search → fetch**
54
+ Search for a topic, then fetch the best results to read their full content.
55
+
56
+ ```bash
57
+ # 1. Find URLs
58
+ tinyfish search query "best React state management libraries 2026"
59
+
60
+ # 2. Read the top results
61
+ tinyfish fetch content get --format markdown "https://result1.com" "https://result2.com"
62
+ ```
63
+
64
+ **Deep extraction: search → agent**
65
+ Search to find the right site, then use agent to interact with it and extract structured data.
66
+
67
+ ```bash
68
+ # 1. Find the site
69
+ tinyfish search query "Nike running shoes official store"
70
+
71
+ # 2. Automate extraction on it
72
+ tinyfish agent run --url "https://nike.com/running" \
73
+ "Extract all running shoes as JSON: [{\"name\": str, \"price\": str, \"colors\": [str]}]"
74
+ ```
75
+
76
+ **Escalation: fetch → agent**
77
+ Try fetch first. If the page is dynamic/JS-heavy and fetch returns empty or incomplete content, escalate to agent.
78
+
79
+ **Full control: agent → browser**
80
+ If agent can't handle a complex multi-step workflow, spin up a raw browser session and automate it yourself via CDP.
81
+
82
+ ---
83
+
84
+ ## Commands
85
+
86
+ ### `tinyfish search query`
87
+
88
+ Web search. Returns ranked results with titles, URLs, and snippets.
89
+
90
+ ```bash
91
+ tinyfish search query "<query>" [--location <hint>] [--language <hint>] [--pretty]
92
+ ```
93
+
94
+ - Returns 10 results by default
95
+ - Use `--location` and `--language` for geo-targeted results
96
+ - Default output is JSON; `--pretty` for human-readable
97
+
98
+ ```bash
99
+ tinyfish search query "best pho in Ho Chi Minh City" --location "Vietnam" --language "en"
100
+ ```
101
+
102
+ ---
103
+
104
+ ### `tinyfish fetch content get`
105
+
106
+ Fetch clean, extracted content from one or more URLs. Strips ads, nav, boilerplate — returns just the content.
107
+
108
+ ```bash
109
+ tinyfish fetch content get <urls...> [--format markdown|html|json] [--links] [--image-links] [--pretty]
110
+ ```
111
+
112
+ - Accepts **multiple URLs** in a single call — they are fetched in parallel server-side
113
+ - `--format markdown` (default) — clean readable text
114
+ - `--format json` — structured document tree
115
+ - `--links` — include all extracted links from the page
116
+ - `--image-links` — include extracted image URLs
117
+ - Response includes: `url`, `final_url`, `title`, `language`, `author`, `published_date`, `text`, `latency_ms`
118
+
119
+ ```bash
120
+ # Fetch one page as markdown
121
+ tinyfish fetch content get --format markdown "https://example.com/article"
122
+
123
+ # Fetch multiple pages with links
124
+ tinyfish fetch content get --links "https://site-a.com" "https://site-b.com" "https://site-c.com"
125
+ ```
126
+
127
+ ---
128
+
129
+ ### `tinyfish agent run`
130
+
131
+ Run a browser automation using a natural language goal. The agent opens a real browser, navigates, clicks, fills forms, and extracts data.
132
+
133
+ ```bash
134
+ tinyfish agent run --url <url> "<goal>" [--sync] [--async] [--pretty]
135
+ ```
136
+
137
+ | Flag | Purpose |
138
+ |------|---------|
139
+ | `--url <url>` | Target URL (bare hostnames get `https://` auto-prepended) |
140
+ | `--sync` | Wait for full result without streaming steps |
141
+ | `--async` | Submit and return immediately |
142
+ | `--pretty` | Human-readable output |
143
+
144
+ **Output:** Default streams `data: {...}` SSE lines. The final result is the event where `type == "COMPLETE"` and `status == "COMPLETED"` — the extracted data is in the `resultJson` field. Read the raw output directly; no script-side parsing is needed.
145
+
146
+ **Always specify the JSON structure you want in the goal:**
147
+
148
+ ```bash
149
+ tinyfish agent run --url "https://example.com/products" \
150
+ "Extract all products as JSON array: [{\"name\": str, \"price\": str, \"url\": str}]"
151
+
152
+ tinyfish agent run --url "https://example.com/search" \
153
+ "Search for 'wireless headphones', filter under $50, extract top 5 as JSON: [{\"name\": str, \"price\": str, \"rating\": str}]"
154
+ ```
155
+
156
+ **Parallel extraction — when hitting multiple independent sites, make separate calls. Do NOT combine into one goal.**
157
+
158
+ Good — parallel calls (run simultaneously):
159
+ ```bash
160
+ tinyfish agent run --url "https://pizzahut.com" \
161
+ "Extract pizza prices as JSON: [{\"name\": str, \"price\": str}]"
162
+
163
+ tinyfish agent run --url "https://dominos.com" \
164
+ "Extract pizza prices as JSON: [{\"name\": str, \"price\": str}]"
165
+ ```
166
+
167
+ Bad — single combined call:
168
+ ```bash
169
+ # Don't do this — less reliable and slower
170
+ tinyfish agent run --url "https://pizzahut.com" \
171
+ "Extract prices from Pizza Hut and also go to Dominos..."
172
+ ```
173
+
174
+ **Managing runs:**
175
+
176
+ ```bash
177
+ tinyfish agent run list [--status PENDING|RUNNING|COMPLETED|FAILED|CANCELLED] [--limit N]
178
+ tinyfish agent run get <run_id>
179
+ tinyfish agent run cancel <run_id>
180
+ ```
181
+
182
+ **Batch operations** — submit many runs from a CSV file (`url,goal` columns):
183
+
184
+ ```bash
185
+ tinyfish agent batch run --input runs.csv
186
+ tinyfish agent batch list
187
+ tinyfish agent batch get <batch_id>
188
+ tinyfish agent batch cancel <batch_id>
189
+ ```
190
+
191
+ ---
192
+
193
+ ### `tinyfish browser session create`
194
+
195
+ Spin up a remote browser instance. Returns a CDP WebSocket URL for programmatic control.
196
+
197
+ ```bash
198
+ tinyfish browser session create [--url <url>] [--pretty]
199
+ ```
200
+
201
+ - `--url` optionally navigates to a page after creation
202
+ - Returns `session_id`, `cdp_url` (WebSocket), and `base_url`
203
+ - Use the `cdp_url` with Playwright, Puppeteer, or any CDP client
204
+
205
+ ```bash
206
+ tinyfish browser session create --url "https://example.com"
207
+ # Returns: { session_id, cdp_url: "wss://...", base_url: "https://..." }
208
+ ```
209
+
210
+ ---
211
+
212
+ ## General Notes
213
+
214
+ - **Match the user's language**: Respond in whatever language the user writes in.
215
+ - All commands support `--pretty` for human-readable output. Default is JSON.
216
+ - Use `--debug` on the root command or set `TINYFISH_DEBUG=1` to log HTTP requests to stderr.