@tiny-fish/cli 0.41.2-next.326 → 0.41.2-next.328
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/dist/lib/bundled-skill.d.ts +2 -0
- package/dist/lib/bundled-skill.js +8 -0
- package/dist/lib/constants.d.ts +2 -0
- package/dist/lib/constants.js +2 -0
- package/dist/lib/notice.js +14 -5
- package/dist/lib/skill-install.js +2 -20
- package/dist/lib/skill-paths.d.ts +5 -0
- package/dist/lib/skill-paths.js +33 -0
- package/dist/lib/skill-vendor.d.ts +13 -0
- package/dist/lib/skill-vendor.js +187 -0
- package/package.json +2 -1
- package/skill/.source-commit +1 -0
- package/skill/use-tinyfish/SKILL.md +216 -0
|
@@ -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
|
+
}
|
package/dist/lib/constants.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/constants.js
CHANGED
|
@@ -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. */
|
package/dist/lib/notice.js
CHANGED
|
@@ -3,7 +3,9 @@ import { detectHumanInitiated } from './harness.js';
|
|
|
3
3
|
import { err, sanitizeLine, warnLine } from './output.js';
|
|
4
4
|
const NOTICE_HEADER = 'x-tf-notice';
|
|
5
5
|
const LEVEL_HEADER = 'x-tf-notice-level';
|
|
6
|
+
const LATEST_HEADER = 'x-tf-notice-latest';
|
|
6
7
|
const MAX_LENGTH = 300;
|
|
8
|
+
const PLAIN_VERSION = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
|
|
7
9
|
const VALID_LEVELS = new Set(['required', 'recommended']);
|
|
8
10
|
let captured;
|
|
9
11
|
let printed = false;
|
|
@@ -24,26 +26,33 @@ export function captureNotice(headers) {
|
|
|
24
26
|
if (!message)
|
|
25
27
|
return;
|
|
26
28
|
const rawLevel = headers.get(LEVEL_HEADER)?.trim().toLowerCase();
|
|
29
|
+
const rawLatest = headers.get(LATEST_HEADER)?.trim() ?? '';
|
|
27
30
|
// Spread rather than `level: undefined`, which exactOptionalPropertyTypes rejects.
|
|
28
|
-
captured = {
|
|
31
|
+
captured = {
|
|
32
|
+
message,
|
|
33
|
+
...(rawLevel && VALID_LEVELS.has(rawLevel) ? { level: rawLevel } : {}),
|
|
34
|
+
...(PLAIN_VERSION.test(rawLatest) ? { latest: rawLatest } : {}),
|
|
35
|
+
};
|
|
29
36
|
}
|
|
30
37
|
/** Built here, not by the server, so nothing it sends can reach an agent's context as instructions. */
|
|
31
|
-
function agentMessage(level) {
|
|
38
|
+
function agentMessage({ level, latest }) {
|
|
39
|
+
// Latest is the registry version, not the required floor; only the soft level names it.
|
|
40
|
+
const target = latest ? ` (latest ${latest})` : '';
|
|
32
41
|
return level === 'required'
|
|
33
42
|
? `This TinyFish CLI (${CLI_VERSION}) is below the required minimum. Run: tinyfish upgrade`
|
|
34
|
-
: `A TinyFish CLI update is available (you have ${CLI_VERSION}). Run: tinyfish upgrade`;
|
|
43
|
+
: `A TinyFish CLI update is available${target} (you have ${CLI_VERSION}). Run: tinyfish upgrade`;
|
|
35
44
|
}
|
|
36
45
|
/** Advisory only, never changes stdout, the exit code, or control flow. */
|
|
37
46
|
export function emitNotice() {
|
|
38
47
|
if (printed || !captured)
|
|
39
48
|
return;
|
|
40
49
|
printed = true;
|
|
41
|
-
const { message,
|
|
50
|
+
const { message, ...rest } = captured;
|
|
42
51
|
if (detectHumanInitiated()) {
|
|
43
52
|
warnLine(message);
|
|
44
53
|
return;
|
|
45
54
|
}
|
|
46
|
-
err({ notice: agentMessage(
|
|
55
|
+
err({ notice: agentMessage(rest), ...rest });
|
|
47
56
|
}
|
|
48
57
|
/** For commands that are the fix themselves, such as `upgrade`, where the nudge is noise. */
|
|
49
58
|
export function suppressNotice() {
|
|
@@ -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(
|
|
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.
|
|
3
|
+
"version": "0.41.2-next.328",
|
|
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.
|