@tiny-fish/cli 0.21.1-next.195 → 0.21.1-next.197
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 +3 -0
- package/dist/commands/connect.js +3 -2
- package/dist/commands/doctor.js +30 -10
- package/dist/commands/upgrade.d.ts +2 -2
- package/dist/commands/upgrade.js +14 -10
- package/dist/lib/connect-install.d.ts +8 -2
- package/dist/lib/connect-install.js +73 -37
- package/dist/lib/connect-runtime.js +4 -1
- package/dist/lib/doctor-report.d.ts +24 -2
- package/dist/lib/doctor-report.js +40 -7
- package/dist/lib/registration-detect.d.ts +2 -0
- package/dist/lib/registration-detect.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -80,6 +80,9 @@ Brings both halves of an install current: the global `@tiny-fish/cli` package an
|
|
|
80
80
|
`use-tinyfish` skill. Use this rather than remembering `npm i -g @tiny-fish/cli@latest` and
|
|
81
81
|
`skills update -g` separately.
|
|
82
82
|
|
|
83
|
+
Successful steps are summarised in one line each. Add `--verbose` to see the full npm and skills
|
|
84
|
+
output; a failing step prints its output either way.
|
|
85
|
+
|
|
83
86
|
The skill step refreshes only the TinyFish skill, and only for the agents you ran
|
|
84
87
|
`tinyfish connect` against. Other global skills, and agents you never connected, are left alone.
|
|
85
88
|
The refresh overwrites your copy of the skill, including any local edits to it. Restart your
|
package/dist/commands/connect.js
CHANGED
|
@@ -144,7 +144,8 @@ function connectedLine(client, signInDeferred) {
|
|
|
144
144
|
function runPostInstallSteps(client, options, state, telemetry, signInDeferred) {
|
|
145
145
|
try {
|
|
146
146
|
state.stage = "cli_install";
|
|
147
|
-
|
|
147
|
+
// Verbose on purpose: first-run setup must not go silent.
|
|
148
|
+
installTinyFishCli({ verbose: true });
|
|
148
149
|
telemetry.track("checkpoint", { phase: "cli_installed" });
|
|
149
150
|
state.stage = "skill_install";
|
|
150
151
|
installWebSkill(client);
|
|
@@ -207,7 +208,7 @@ export async function connectOpenClaw(options) {
|
|
|
207
208
|
telemetry.track("checkpoint", { phase: "prerequisite_ok" });
|
|
208
209
|
if (options.installCli !== false) {
|
|
209
210
|
state.stage = "cli_install";
|
|
210
|
-
installTinyFishCli();
|
|
211
|
+
installTinyFishCli({ verbose: true });
|
|
211
212
|
telemetry.track("checkpoint", { phase: "cli_installed" });
|
|
212
213
|
}
|
|
213
214
|
state.stage = "skill_install";
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import spawn from "cross-spawn";
|
|
2
2
|
import { apiKeyStatus } from "../lib/auth.js";
|
|
3
3
|
import { CLI_VERSION } from "../lib/constants.js";
|
|
4
|
-
import { DOCTOR_COULD_NOT_RUN, DOCTOR_SCHEMA_VERSION, doctorReportSchema, exitCodeFor, renderPretty, } from "../lib/doctor-report.js";
|
|
4
|
+
import { DOCTOR_COULD_NOT_RUN, DOCTOR_SCHEMA_VERSION, doctorReportSchema, exitCodeFor, renderPretty, verdictFor, } from "../lib/doctor-report.js";
|
|
5
5
|
import { ALL_HARNESSES, AuthMode, Registered } from "../lib/harness-detect.js";
|
|
6
6
|
import { detectHumanInitiated } from "../lib/harness.js";
|
|
7
7
|
import { err, errLine, out, outLine } from "../lib/output.js";
|
|
@@ -30,6 +30,7 @@ function checkCliVersion() {
|
|
|
30
30
|
status: "pass",
|
|
31
31
|
detail: `${CLI_VERSION}; the server reports staleness via X-TF-Notice`,
|
|
32
32
|
harness: null,
|
|
33
|
+
scope: "info",
|
|
33
34
|
};
|
|
34
35
|
}
|
|
35
36
|
async function checkConnectivity(mcpUrl) {
|
|
@@ -39,7 +40,9 @@ async function checkConnectivity(mcpUrl) {
|
|
|
39
40
|
title: "MCP endpoint reachable",
|
|
40
41
|
status: health.ok ? "pass" : "fail",
|
|
41
42
|
detail: health.ok ? mcpUrl : (health.code ?? "the endpoint is unreachable"),
|
|
43
|
+
// Harness-scoped: the CLI's own reachability is `cli-auth-call`'s claim.
|
|
42
44
|
harness: null,
|
|
45
|
+
scope: "harness",
|
|
43
46
|
};
|
|
44
47
|
}
|
|
45
48
|
// Query strings carry the connect attempt id, so they differ on every healthy install.
|
|
@@ -60,8 +63,21 @@ function pointsElsewhere(registeredUrl, mcpUrl) {
|
|
|
60
63
|
return "an unparseable endpoint";
|
|
61
64
|
}
|
|
62
65
|
}
|
|
63
|
-
function keyedVerdict(status, keyAuth) {
|
|
66
|
+
function keyedVerdict(status, keyAuth, cliKeyMissing) {
|
|
64
67
|
if (!keyAuth) {
|
|
68
|
+
// Its auth is the CLI credential; a warn would exit 0.
|
|
69
|
+
if (status.usesCliKey) {
|
|
70
|
+
return cliKeyMissing
|
|
71
|
+
? {
|
|
72
|
+
status: "fail",
|
|
73
|
+
detail: "registered, but it runs on the CLI credential, which is missing or unusable",
|
|
74
|
+
}
|
|
75
|
+
: // Reachable with a key only when `--url` skipped verification.
|
|
76
|
+
{
|
|
77
|
+
status: "warn",
|
|
78
|
+
detail: "registered, but its CLI credential was not verified against this endpoint",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
65
81
|
// A bare warn beside exit 0 reads as a pass.
|
|
66
82
|
const because = status.keyReason ?? "its key is not readable here";
|
|
67
83
|
return { status: "warn", detail: `registered, but ${because}, so its reach is unproven` };
|
|
@@ -88,7 +104,7 @@ function keyedVerdict(status, keyAuth) {
|
|
|
88
104
|
}
|
|
89
105
|
return { status: "warn", detail: "registered, key could not be verified" };
|
|
90
106
|
}
|
|
91
|
-
function registrationVerdict(status, mcpUrl, keyAuth) {
|
|
107
|
+
function registrationVerdict(status, mcpUrl, keyAuth, cliKeyMissing) {
|
|
92
108
|
if (!status.detected)
|
|
93
109
|
return { status: "skip", detail: "harness not installed" };
|
|
94
110
|
if (status.registered === Registered.Unknown) {
|
|
@@ -116,7 +132,7 @@ function registrationVerdict(status, mcpUrl, keyAuth) {
|
|
|
116
132
|
}
|
|
117
133
|
// Widening past api-key would warn every healthy oauth install.
|
|
118
134
|
if (status.authMode === AuthMode.ApiKey)
|
|
119
|
-
return keyedVerdict(status, keyAuth);
|
|
135
|
+
return keyedVerdict(status, keyAuth, cliKeyMissing);
|
|
120
136
|
// A probe that had to explain itself to reach `yes` is the only thing that explains `unknown`.
|
|
121
137
|
const because = status.reason ? ` (${status.reason})` : "";
|
|
122
138
|
return { status: "pass", detail: `registered, auth mode ${status.authMode}${because}` };
|
|
@@ -126,12 +142,13 @@ function registrationVerdict(status, mcpUrl, keyAuth) {
|
|
|
126
142
|
? { status: "fail", detail: "connected previously but TinyFish is no longer registered" }
|
|
127
143
|
: { status: "warn", detail: "installed but TinyFish was never connected" };
|
|
128
144
|
}
|
|
129
|
-
function checkRegistration(status, mcpUrl, keyAuth) {
|
|
145
|
+
function checkRegistration(status, mcpUrl, keyAuth, cliKeyMissing) {
|
|
130
146
|
return {
|
|
131
147
|
id: "harness-registration",
|
|
132
148
|
title: `${status.harness} registration`,
|
|
133
149
|
harness: status.harness,
|
|
134
|
-
|
|
150
|
+
scope: "harness",
|
|
151
|
+
...registrationVerdict(status, mcpUrl, keyAuth, cliKeyMissing),
|
|
135
152
|
};
|
|
136
153
|
}
|
|
137
154
|
function checkCredential() {
|
|
@@ -142,6 +159,7 @@ function checkCredential() {
|
|
|
142
159
|
status: "key" in resolved ? "pass" : "fail",
|
|
143
160
|
detail: "key" in resolved ? `key present from ${resolved.source}` : resolved.error,
|
|
144
161
|
harness: null,
|
|
162
|
+
scope: "cli",
|
|
145
163
|
};
|
|
146
164
|
return "key" in resolved ? { check, key: resolved.key } : { check };
|
|
147
165
|
}
|
|
@@ -153,6 +171,7 @@ function checkAuthCall(auth) {
|
|
|
153
171
|
status: "skip",
|
|
154
172
|
detail: "no credential to test",
|
|
155
173
|
harness: null,
|
|
174
|
+
scope: "cli",
|
|
156
175
|
};
|
|
157
176
|
}
|
|
158
177
|
return {
|
|
@@ -161,6 +180,7 @@ function checkAuthCall(auth) {
|
|
|
161
180
|
status: auth.ok ? "pass" : "fail",
|
|
162
181
|
detail: auth.ok ? "listRuns succeeded" : (auth.code ?? "the authenticated call failed"),
|
|
163
182
|
harness: null,
|
|
183
|
+
scope: "cli",
|
|
164
184
|
};
|
|
165
185
|
}
|
|
166
186
|
// `verifyMcpAuth` hits BASE_URL; a sandbox key 401s against prod.
|
|
@@ -178,8 +198,8 @@ function verifyHarnessKey(status, cliKey, cliAuth, mcpUrl) {
|
|
|
178
198
|
// `pointsElsewhere` already fails; a second reason only misleads.
|
|
179
199
|
if (pointsElsewhere(status.registeredUrl, mcpUrl))
|
|
180
200
|
return undefined;
|
|
181
|
-
//
|
|
182
|
-
if (status.
|
|
201
|
+
// A harness with no key of its own reads the CLI's.
|
|
202
|
+
if (status.usesCliKey)
|
|
183
203
|
return cliAuth;
|
|
184
204
|
if (!status.apiKey)
|
|
185
205
|
return undefined;
|
|
@@ -237,7 +257,7 @@ export async function runDoctor(options) {
|
|
|
237
257
|
const checks = [
|
|
238
258
|
checkCliVersion(),
|
|
239
259
|
connectivity,
|
|
240
|
-
...statuses.map((status, i) => checkRegistration(status, options.mcpUrl, keyAuths[i])),
|
|
260
|
+
...statuses.map((status, i) => checkRegistration(status, options.mcpUrl, keyAuths[i], !credential.key)),
|
|
241
261
|
credential.check,
|
|
242
262
|
checkAuthCall(cliAuthResult),
|
|
243
263
|
];
|
|
@@ -252,7 +272,7 @@ export async function runDoctor(options) {
|
|
|
252
272
|
const body = {
|
|
253
273
|
schema_version: DOCTOR_SCHEMA_VERSION,
|
|
254
274
|
cli_version: CLI_VERSION,
|
|
255
|
-
|
|
275
|
+
...verdictFor(checks),
|
|
256
276
|
checks,
|
|
257
277
|
harnesses,
|
|
258
278
|
repairs: repairsFor(checks, statuses),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
/** Returns the version now installed, so the caller reports exactly what the user was told. */
|
|
3
|
-
export declare function runUpgrade(): string | null;
|
|
3
|
+
export declare function runUpgrade(verbose?: boolean): string | null;
|
|
4
4
|
/** `runUpgrade` plus its outcome telemetry, which must never change what the upgrade does. */
|
|
5
|
-
export declare function runUpgradeCommand(): Promise<void>;
|
|
5
|
+
export declare function runUpgradeCommand(verbose?: boolean): Promise<void>;
|
|
6
6
|
export declare function registerUpgrade(program: Command): void;
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -26,13 +26,16 @@ function attempt(label, run) {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
/** Returns the version now installed, so the caller reports exactly what the user was told. */
|
|
29
|
-
export function runUpgrade() {
|
|
29
|
+
export function runUpgrade(verbose = false) {
|
|
30
30
|
errLine("Upgrading TinyFish...");
|
|
31
|
+
let refreshed = false;
|
|
31
32
|
try {
|
|
32
33
|
// Both steps always run: neither artifact should stay stale because the other failed.
|
|
33
34
|
const failures = [
|
|
34
|
-
attempt("update the TinyFish CLI", installTinyFishCli),
|
|
35
|
-
attempt("refresh the TinyFish web skill",
|
|
35
|
+
attempt("update the TinyFish CLI", () => installTinyFishCli({ verbose })),
|
|
36
|
+
attempt("refresh the TinyFish web skill", () => {
|
|
37
|
+
refreshed = updateWebSkill({ verbose });
|
|
38
|
+
}),
|
|
36
39
|
].filter((failure) => failure !== undefined);
|
|
37
40
|
if (failures.length > 0)
|
|
38
41
|
throw new Error(`tinyfish upgrade failed: ${failures.join("; ")}`);
|
|
@@ -45,9 +48,9 @@ export function runUpgrade() {
|
|
|
45
48
|
return null;
|
|
46
49
|
}
|
|
47
50
|
const version = installedCliVersion();
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
const upToDate = version ? `TinyFish CLI ${version} is up to date.` : "TinyFish is up to date.";
|
|
52
|
+
// Only a rewritten skill needs an agent restart.
|
|
53
|
+
errLine(refreshed ? `${upToDate} Restart your agent to pick up the refreshed skill.` : upToDate);
|
|
51
54
|
return version;
|
|
52
55
|
}
|
|
53
56
|
/** The version now on disk. Only the upgrade's own npm call knows what "latest" resolved to. */
|
|
@@ -67,7 +70,7 @@ function globalCliVersion() {
|
|
|
67
70
|
}
|
|
68
71
|
}
|
|
69
72
|
/** `runUpgrade` plus its outcome telemetry, which must never change what the upgrade does. */
|
|
70
|
-
export async function runUpgradeCommand() {
|
|
73
|
+
export async function runUpgradeCommand(verbose = false) {
|
|
71
74
|
// This command is the upgrade, so a trailing "an update is available" would be noise.
|
|
72
75
|
suppressNotice();
|
|
73
76
|
let reported = false;
|
|
@@ -90,7 +93,7 @@ export async function runUpgradeCommand() {
|
|
|
90
93
|
});
|
|
91
94
|
let outcome = "updated";
|
|
92
95
|
try {
|
|
93
|
-
printedVersion = runUpgrade();
|
|
96
|
+
printedVersion = runUpgrade(verbose);
|
|
94
97
|
if (process.exitCode === SIGNAL_EXIT_CODES.SIGINT)
|
|
95
98
|
outcome = "interrupted";
|
|
96
99
|
}
|
|
@@ -108,7 +111,8 @@ export function registerUpgrade(program) {
|
|
|
108
111
|
program
|
|
109
112
|
.command("upgrade")
|
|
110
113
|
.description("Update the TinyFish CLI and the use-tinyfish skill to the latest versions")
|
|
111
|
-
.
|
|
112
|
-
|
|
114
|
+
.option("--verbose", "Print the full npm and skills output instead of only on failure")
|
|
115
|
+
.action(async (options) => {
|
|
116
|
+
await runUpgradeCommand(Boolean(options.verbose));
|
|
113
117
|
});
|
|
114
118
|
}
|
|
@@ -3,7 +3,11 @@ import { type AgentClient } from "./connect-runtime.js";
|
|
|
3
3
|
export declare const SKILL_INSTALL_TIMEOUT_MS = 120000;
|
|
4
4
|
export declare const TINYFISH_CLI_NOT_FOUND_MESSAGE = "TinyFish CLI installed but is not available on PATH. Open a new terminal and retry.";
|
|
5
5
|
export declare const UPGRADE_HINT = "Run `tinyfish upgrade` any time to update the CLI and skill.";
|
|
6
|
-
|
|
6
|
+
/** Quiet captures subprocess output and replays it only on failure. */
|
|
7
|
+
export type InstallOptions = {
|
|
8
|
+
verbose: boolean;
|
|
9
|
+
};
|
|
10
|
+
export declare function installTinyFishCli({ verbose }: InstallOptions): void;
|
|
7
11
|
export declare function installWebSkill(client: NativeMcpClient): void;
|
|
8
12
|
/**
|
|
9
13
|
* Refresh the skill we installed, for the harnesses we installed it in.
|
|
@@ -12,6 +16,8 @@ export declare function installWebSkill(client: NativeMcpClient): void;
|
|
|
12
16
|
* only in prose, it repairs a copy whose lock entry lost its hash, and it cannot spread the
|
|
13
17
|
* skill to agents the user never connected (`update` re-adds with no --agent, which targets
|
|
14
18
|
* every agent on the machine).
|
|
19
|
+
*
|
|
20
|
+
* Returns whether a skill was actually rewritten.
|
|
15
21
|
*/
|
|
16
|
-
export declare function updateWebSkill():
|
|
22
|
+
export declare function updateWebSkill({ verbose }: InstallOptions): boolean;
|
|
17
23
|
export declare function ensureCliAuthenticated(source: AgentClient, apiKey?: string): void;
|
|
@@ -14,17 +14,37 @@ export const UPGRADE_HINT = "Run `tinyfish upgrade` any time to update the CLI a
|
|
|
14
14
|
const SKILLS_CLI_PACKAGE = "skills@1.5.15";
|
|
15
15
|
const TINYFISH_WEB_SKILL_SOURCE = "tinyfish-io/tinyfish-cookbook";
|
|
16
16
|
const TINYFISH_WEB_SKILL = "use-tinyfish";
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// spawnSync caps piped output at 1 MiB by default.
|
|
18
|
+
const STEP_MAX_BUFFER = 10 * 1024 * 1024;
|
|
19
|
+
function captureStdio(verbose) {
|
|
20
|
+
return verbose
|
|
21
|
+
? { stdio: "inherit", timeout: SKILL_INSTALL_TIMEOUT_MS }
|
|
22
|
+
: { encoding: "utf8", maxBuffer: STEP_MAX_BUFFER, timeout: SKILL_INSTALL_TIMEOUT_MS };
|
|
23
|
+
}
|
|
24
|
+
/** Inherited stdio leaves these null; only piped runs yield text. */
|
|
25
|
+
function capturedOutput(result) {
|
|
26
|
+
return `${asText(result.stdout)}${asText(result.stderr)}`;
|
|
27
|
+
}
|
|
28
|
+
function asText(stream) {
|
|
29
|
+
return typeof stream === "string" ? stream : "";
|
|
30
|
+
}
|
|
31
|
+
/** A verbose run captured nothing, so this is a no-op. */
|
|
32
|
+
function replay(output) {
|
|
33
|
+
if (output.trim())
|
|
34
|
+
errLine(output.trimEnd());
|
|
35
|
+
}
|
|
36
|
+
export function installTinyFishCli({ verbose }) {
|
|
37
|
+
if (verbose)
|
|
38
|
+
errLine("Installing the TinyFish CLI...");
|
|
19
39
|
// Without the prefix, a curl install upgrades npm's global tree and never the PATH binary.
|
|
20
40
|
const root = installRoot();
|
|
21
41
|
const prefixArgs = root ? ["--prefix", root.prefix] : [];
|
|
22
|
-
const result = spawn.sync("npm", ["install", "--global", ...prefixArgs, TINYFISH_CLI_INSTALL_SPEC],
|
|
23
|
-
stdio: "inherit",
|
|
24
|
-
timeout: SKILL_INSTALL_TIMEOUT_MS,
|
|
25
|
-
});
|
|
42
|
+
const result = spawn.sync("npm", ["install", "--global", ...prefixArgs, TINYFISH_CLI_INSTALL_SPEC], captureStdio(verbose));
|
|
26
43
|
if (result.error || result.status !== 0) {
|
|
27
|
-
|
|
44
|
+
// Built first: it runs the interrupt check, and abandonment must not replay.
|
|
45
|
+
const error = spawnStepError("Could not install the TinyFish CLI", result);
|
|
46
|
+
replay(capturedOutput(result));
|
|
47
|
+
throw error;
|
|
28
48
|
}
|
|
29
49
|
}
|
|
30
50
|
/** `skills add` is an unconditional overwrite, so it doubles as the refresh path. */
|
|
@@ -53,6 +73,8 @@ function skillSpawnEnv() {
|
|
|
53
73
|
// Safe to match on because SKILLS_CLI_PACKAGE is pinned.
|
|
54
74
|
const SKILL_UPDATE_FAILURE_PATTERN = /Failed to (?:update|check|fetch)/;
|
|
55
75
|
const SKILL_NOT_INSTALLED_PATTERN = /No installed skills found matching/;
|
|
76
|
+
// Nothing was rewritten, so nothing needs an agent restart.
|
|
77
|
+
const SKILL_ALREADY_CURRENT_PATTERN = /All global skills are up to date/;
|
|
56
78
|
// A lock entry with no recorded hash is untrackable, so `skills` reports it as skipped rather
|
|
57
79
|
// than failed. Left undetected that reads as "up to date" while nothing was refreshed.
|
|
58
80
|
const SKILL_UNCHECKABLE_PATTERN = /cannot be checked automatically/;
|
|
@@ -65,6 +87,7 @@ export function installWebSkill(client) {
|
|
|
65
87
|
const result = spawn.sync("npx", skillAddArgs([client.skillAgent]), {
|
|
66
88
|
encoding: "utf8",
|
|
67
89
|
env: skillSpawnEnv(),
|
|
90
|
+
maxBuffer: STEP_MAX_BUFFER,
|
|
68
91
|
timeout: SKILL_INSTALL_TIMEOUT_MS,
|
|
69
92
|
});
|
|
70
93
|
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
@@ -81,8 +104,10 @@ export function installWebSkill(client) {
|
|
|
81
104
|
* only in prose, it repairs a copy whose lock entry lost its hash, and it cannot spread the
|
|
82
105
|
* skill to agents the user never connected (`update` re-adds with no --agent, which targets
|
|
83
106
|
* every agent on the machine).
|
|
107
|
+
*
|
|
108
|
+
* Returns whether a skill was actually rewritten.
|
|
84
109
|
*/
|
|
85
|
-
export function updateWebSkill() {
|
|
110
|
+
export function updateWebSkill({ verbose }) {
|
|
86
111
|
// One entry per `tinyfish connect <client>`, persisted by PF-3169.
|
|
87
112
|
const connected = loadConfig().connect ?? {};
|
|
88
113
|
const skillAgents = NATIVE_MCP_CLIENTS
|
|
@@ -90,71 +115,82 @@ export function updateWebSkill() {
|
|
|
90
115
|
.map((client) => client.skillAgent);
|
|
91
116
|
const hasOpenClaw = Boolean(connected["openclaw"]);
|
|
92
117
|
// Installs predating PF-3169 recorded nothing; ask `skills` to refresh what it tracks.
|
|
93
|
-
if (skillAgents.length === 0 && !hasOpenClaw)
|
|
94
|
-
refreshWebSkillByHash();
|
|
95
|
-
|
|
118
|
+
if (skillAgents.length === 0 && !hasOpenClaw)
|
|
119
|
+
return refreshWebSkillByHash(verbose);
|
|
120
|
+
let refreshed = false;
|
|
121
|
+
if (skillAgents.length > 0) {
|
|
122
|
+
reinstallWebSkill(skillAgents, verbose);
|
|
123
|
+
refreshed = true;
|
|
96
124
|
}
|
|
97
|
-
if (skillAgents.length > 0)
|
|
98
|
-
reinstallWebSkill(skillAgents);
|
|
99
125
|
// OpenClaw keeps its skill under its own CLI, so it needs its own refresh.
|
|
100
126
|
if (hasOpenClaw)
|
|
101
|
-
reinstallOpenClawSkill();
|
|
127
|
+
refreshed = reinstallOpenClawSkill(verbose) || refreshed;
|
|
128
|
+
return refreshed;
|
|
102
129
|
}
|
|
103
|
-
function reinstallOpenClawSkill() {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
timeout: SKILL_INSTALL_TIMEOUT_MS,
|
|
108
|
-
});
|
|
130
|
+
function reinstallOpenClawSkill(verbose) {
|
|
131
|
+
if (verbose)
|
|
132
|
+
errLine("Refreshing the TinyFish skill in OpenClaw...");
|
|
133
|
+
const result = spawn.sync("openclaw", OPENCLAW_SKILL_INSTALL_ARGS, captureStdio(verbose));
|
|
109
134
|
// OpenClaw may have been removed since connect; a stale context entry must not fail an
|
|
110
135
|
// upgrade that otherwise succeeded.
|
|
111
136
|
if (commandNotFound(result.error)) {
|
|
112
137
|
errLine("OpenClaw is not on PATH, so its TinyFish skill was left unchanged.");
|
|
113
|
-
return;
|
|
138
|
+
return false;
|
|
114
139
|
}
|
|
115
140
|
if (result.error || result.status !== 0) {
|
|
116
141
|
throwIfInterrupted(result);
|
|
142
|
+
replay(capturedOutput(result));
|
|
117
143
|
throw new Error("Could not refresh the TinyFish skill in OpenClaw", { cause: result.error });
|
|
118
144
|
}
|
|
145
|
+
return true;
|
|
119
146
|
}
|
|
120
|
-
function reinstallWebSkill(skillAgents) {
|
|
121
|
-
|
|
147
|
+
function reinstallWebSkill(skillAgents, verbose) {
|
|
148
|
+
if (verbose)
|
|
149
|
+
errLine(`Refreshing the TinyFish web skill for ${skillAgents.join(", ")}...`);
|
|
122
150
|
const result = spawn.sync("npx", skillAddArgs(skillAgents), {
|
|
123
151
|
encoding: "utf8",
|
|
124
152
|
env: skillSpawnEnv(),
|
|
153
|
+
maxBuffer: STEP_MAX_BUFFER,
|
|
125
154
|
timeout: SKILL_INSTALL_TIMEOUT_MS,
|
|
126
155
|
});
|
|
127
|
-
const output =
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (result.error || result.status !== 0 || SKILL_INSTALL_FAILURE_PATTERN.test(output)) {
|
|
156
|
+
const output = capturedOutput(result);
|
|
157
|
+
const failed = Boolean(result.error) || result.status !== 0 || SKILL_INSTALL_FAILURE_PATTERN.test(output);
|
|
158
|
+
if (failed)
|
|
131
159
|
throwIfInterrupted(result);
|
|
160
|
+
if (verbose || failed)
|
|
161
|
+
replay(output);
|
|
162
|
+
if (failed)
|
|
132
163
|
throw new Error("Could not refresh the TinyFish web skill", { cause: result.error });
|
|
133
|
-
}
|
|
134
164
|
}
|
|
135
165
|
/** Fallback when no harness is recorded: ask `skills` to refresh whatever it tracks. */
|
|
136
|
-
function refreshWebSkillByHash() {
|
|
137
|
-
|
|
166
|
+
function refreshWebSkillByHash(verbose) {
|
|
167
|
+
if (verbose)
|
|
168
|
+
errLine("Refreshing the TinyFish web skill...");
|
|
138
169
|
const result = spawn.sync("npx", ["-y", SKILLS_CLI_PACKAGE, "update", TINYFISH_WEB_SKILL, "--global", "--yes"], {
|
|
139
170
|
encoding: "utf8",
|
|
140
171
|
env: skillSpawnEnv(),
|
|
172
|
+
maxBuffer: STEP_MAX_BUFFER,
|
|
141
173
|
timeout: SKILL_INSTALL_TIMEOUT_MS,
|
|
142
174
|
});
|
|
143
|
-
// Piped
|
|
144
|
-
const output =
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (
|
|
175
|
+
// Piped so prose-only failures can be matched; skills exits 0 anyway.
|
|
176
|
+
const output = capturedOutput(result);
|
|
177
|
+
const unchecked = SKILL_UNCHECKABLE_PATTERN.test(output);
|
|
178
|
+
const failed = Boolean(result.error) || result.status !== 0 || SKILL_UPDATE_FAILURE_PATTERN.test(output);
|
|
179
|
+
if (failed)
|
|
148
180
|
throwIfInterrupted(result);
|
|
181
|
+
if (verbose || failed || unchecked)
|
|
182
|
+
replay(output);
|
|
183
|
+
if (failed)
|
|
149
184
|
throw new Error("Could not refresh the TinyFish web skill", { cause: result.error });
|
|
150
|
-
|
|
151
|
-
if (SKILL_UNCHECKABLE_PATTERN.test(output)) {
|
|
185
|
+
if (unchecked) {
|
|
152
186
|
errLine("Run `tinyfish connect <client>` to reinstall the skill and restore update tracking.");
|
|
153
187
|
throw new Error("The TinyFish web skill cannot be checked for updates");
|
|
154
188
|
}
|
|
155
189
|
if (SKILL_NOT_INSTALLED_PATTERN.test(output)) {
|
|
156
190
|
errLine("No use-tinyfish skill installed. Run `tinyfish connect <client>` to add it.");
|
|
191
|
+
return false;
|
|
157
192
|
}
|
|
193
|
+
return !SKILL_ALREADY_CURRENT_PATTERN.test(output);
|
|
158
194
|
}
|
|
159
195
|
export function ensureCliAuthenticated(source, apiKey) {
|
|
160
196
|
const envKey = apiKey ?? process.env["TINYFISH_API_KEY"];
|
|
@@ -33,10 +33,13 @@ export class ConnectStepError extends Error {
|
|
|
33
33
|
}
|
|
34
34
|
class PrerequisiteError extends ConnectStepError {
|
|
35
35
|
}
|
|
36
|
+
// A maxBuffer overflow is also killed with SIGTERM, and is a failure too.
|
|
37
|
+
const NOT_INTERRUPTIONS = new Set(["ETIMEDOUT", "ENOBUFS"]);
|
|
36
38
|
export function throwIfInterrupted(result) {
|
|
37
39
|
// spawn.sync kills a timed-out child with SIGTERM, so signal alone would misread a slow
|
|
38
40
|
// network as the user walking away. A timeout is a failure and must report as one.
|
|
39
|
-
|
|
41
|
+
const code = result.error?.code;
|
|
42
|
+
if (code && NOT_INTERRUPTIONS.has(code))
|
|
40
43
|
return;
|
|
41
44
|
if (result.signal === "SIGINT" || result.signal === "SIGTERM") {
|
|
42
45
|
throw new ConnectInterruptedError("Setup interrupted");
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { AuthMode, Registered } from "./harness-detect.js";
|
|
3
3
|
/** Bumped whenever a consumer could misread the payload; the cookbook skill releases separately. */
|
|
4
|
-
export declare const DOCTOR_SCHEMA_VERSION =
|
|
4
|
+
export declare const DOCTOR_SCHEMA_VERSION = 3;
|
|
5
5
|
declare const checkStatusSchema: z.ZodEnum<{
|
|
6
6
|
pass: "pass";
|
|
7
7
|
fail: "fail";
|
|
8
8
|
warn: "warn";
|
|
9
9
|
skip: "skip";
|
|
10
10
|
}>;
|
|
11
|
+
declare const checkScopeSchema: z.ZodEnum<{
|
|
12
|
+
cli: "cli";
|
|
13
|
+
harness: "harness";
|
|
14
|
+
info: "info";
|
|
15
|
+
}>;
|
|
11
16
|
declare const doctorCheckSchema: z.ZodObject<{
|
|
12
17
|
id: z.ZodString;
|
|
13
18
|
title: z.ZodString;
|
|
@@ -26,6 +31,11 @@ declare const doctorCheckSchema: z.ZodObject<{
|
|
|
26
31
|
opencode: "opencode";
|
|
27
32
|
"claude-code": "claude-code";
|
|
28
33
|
}>>;
|
|
34
|
+
scope: z.ZodEnum<{
|
|
35
|
+
cli: "cli";
|
|
36
|
+
harness: "harness";
|
|
37
|
+
info: "info";
|
|
38
|
+
}>;
|
|
29
39
|
}, z.core.$strip>;
|
|
30
40
|
declare const doctorHarnessSchema: z.ZodObject<{
|
|
31
41
|
harness: z.ZodEnum<{
|
|
@@ -61,7 +71,8 @@ declare const doctorRepairSchema: z.ZodObject<{
|
|
|
61
71
|
export declare const doctorReportSchema: z.ZodObject<{
|
|
62
72
|
schema_version: z.ZodInt;
|
|
63
73
|
cli_version: z.ZodString;
|
|
64
|
-
|
|
74
|
+
ok_harnesses: z.ZodBoolean;
|
|
75
|
+
ok_cli: z.ZodBoolean;
|
|
65
76
|
checks: z.ZodArray<z.ZodObject<{
|
|
66
77
|
id: z.ZodString;
|
|
67
78
|
title: z.ZodString;
|
|
@@ -80,6 +91,11 @@ export declare const doctorReportSchema: z.ZodObject<{
|
|
|
80
91
|
opencode: "opencode";
|
|
81
92
|
"claude-code": "claude-code";
|
|
82
93
|
}>>;
|
|
94
|
+
scope: z.ZodEnum<{
|
|
95
|
+
cli: "cli";
|
|
96
|
+
harness: "harness";
|
|
97
|
+
info: "info";
|
|
98
|
+
}>;
|
|
83
99
|
}, z.core.$strip>>;
|
|
84
100
|
harnesses: z.ZodArray<z.ZodObject<{
|
|
85
101
|
harness: z.ZodEnum<{
|
|
@@ -114,6 +130,7 @@ export declare const doctorReportSchema: z.ZodObject<{
|
|
|
114
130
|
}, z.core.$strip>>;
|
|
115
131
|
}, z.core.$strip>;
|
|
116
132
|
export type CheckStatus = z.infer<typeof checkStatusSchema>;
|
|
133
|
+
export type CheckScope = z.infer<typeof checkScopeSchema>;
|
|
117
134
|
export type DoctorCheck = z.infer<typeof doctorCheckSchema>;
|
|
118
135
|
export type DoctorHarness = z.infer<typeof doctorHarnessSchema>;
|
|
119
136
|
export type DoctorRepair = z.infer<typeof doctorRepairSchema>;
|
|
@@ -121,6 +138,11 @@ export type DoctorReport = z.infer<typeof doctorReportSchema>;
|
|
|
121
138
|
/** Doctor itself broke, which is a different claim from "your setup is broken". */
|
|
122
139
|
export declare const DOCTOR_COULD_NOT_RUN = 2;
|
|
123
140
|
/** Skips never fail the run; a skip means not applicable, not broken. */
|
|
141
|
+
export declare function verdictFor(checks: DoctorCheck[]): {
|
|
142
|
+
ok_harnesses: boolean;
|
|
143
|
+
ok_cli: boolean;
|
|
144
|
+
};
|
|
145
|
+
/** Harness scope only: the caller asked about its agent, not us. */
|
|
124
146
|
export declare function exitCodeFor(checks: DoctorCheck[]): 0 | 1;
|
|
125
147
|
export declare function renderPretty(report: DoctorReport): string;
|
|
126
148
|
export {};
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { ALL_HARNESSES, AuthMode, Registered } from "./harness-detect.js";
|
|
3
3
|
/** Bumped whenever a consumer could misread the payload; the cookbook skill releases separately. */
|
|
4
|
-
export const DOCTOR_SCHEMA_VERSION =
|
|
4
|
+
export const DOCTOR_SCHEMA_VERSION = 3;
|
|
5
5
|
const checkStatusSchema = z.enum(["pass", "fail", "warn", "skip"]);
|
|
6
6
|
const harnessSchema = z.enum(ALL_HARNESSES);
|
|
7
|
+
// Not derived from `harness`: connectivity is harness-null yet harness-scoped.
|
|
8
|
+
const checkScopeSchema = z.enum(["harness", "cli", "info"]);
|
|
7
9
|
const doctorCheckSchema = z.object({
|
|
8
10
|
id: z.string(),
|
|
9
11
|
title: z.string(),
|
|
10
12
|
status: checkStatusSchema,
|
|
11
13
|
detail: z.string(),
|
|
12
14
|
harness: harnessSchema.nullable(),
|
|
15
|
+
scope: checkScopeSchema,
|
|
13
16
|
});
|
|
14
17
|
const doctorHarnessSchema = z.object({
|
|
15
18
|
harness: harnessSchema,
|
|
@@ -27,27 +30,54 @@ const doctorRepairSchema = z.object({
|
|
|
27
30
|
command: z.string(),
|
|
28
31
|
unattended_safe: z.boolean(),
|
|
29
32
|
});
|
|
33
|
+
function hasFailure(checks, scope) {
|
|
34
|
+
return checks.some((check) => check.scope === scope && check.status === "fail");
|
|
35
|
+
}
|
|
36
|
+
// One `ok` could not answer both questions, and answered neither.
|
|
30
37
|
export const doctorReportSchema = z
|
|
31
38
|
.object({
|
|
32
39
|
// Not `z.literal`: a consumer must be able to read a future version and say so.
|
|
33
40
|
schema_version: z.int().positive(),
|
|
34
41
|
cli_version: z.string(),
|
|
35
|
-
|
|
42
|
+
ok_harnesses: z.boolean(),
|
|
43
|
+
ok_cli: z.boolean(),
|
|
36
44
|
checks: z.array(doctorCheckSchema),
|
|
37
45
|
harnesses: z.array(doctorHarnessSchema),
|
|
38
46
|
repairs: z.array(doctorRepairSchema),
|
|
39
47
|
})
|
|
40
|
-
//
|
|
41
|
-
.refine((report) => !report.
|
|
42
|
-
message: "
|
|
43
|
-
path: ["
|
|
48
|
+
// Claiming ok while that scope failed is worse than no report.
|
|
49
|
+
.refine((report) => !report.ok_harnesses || !hasFailure(report.checks, "harness"), {
|
|
50
|
+
message: "ok_harnesses cannot be true while a harness check has failed",
|
|
51
|
+
path: ["ok_harnesses"],
|
|
52
|
+
})
|
|
53
|
+
.refine((report) => !report.ok_cli || !hasFailure(report.checks, "cli"), {
|
|
54
|
+
message: "ok_cli cannot be true while a CLI check has failed",
|
|
55
|
+
path: ["ok_cli"],
|
|
44
56
|
});
|
|
45
57
|
const GLYPHS = { pass: "✓", fail: "✗", warn: "⚠", skip: "…" };
|
|
46
58
|
/** Doctor itself broke, which is a different claim from "your setup is broken". */
|
|
47
59
|
export const DOCTOR_COULD_NOT_RUN = 2;
|
|
48
60
|
/** Skips never fail the run; a skip means not applicable, not broken. */
|
|
61
|
+
export function verdictFor(checks) {
|
|
62
|
+
return { ok_harnesses: !hasFailure(checks, "harness"), ok_cli: !hasFailure(checks, "cli") };
|
|
63
|
+
}
|
|
64
|
+
/** Harness scope only: the caller asked about its agent, not us. */
|
|
49
65
|
export function exitCodeFor(checks) {
|
|
50
|
-
return checks
|
|
66
|
+
return verdictFor(checks).ok_harnesses ? 0 : 1;
|
|
67
|
+
}
|
|
68
|
+
// A red line above exit 0 reads as a contradiction.
|
|
69
|
+
function footnotes(report) {
|
|
70
|
+
const notes = [];
|
|
71
|
+
// Alongside exit 1 this is noise; something did fail.
|
|
72
|
+
if (hasFailure(report.checks, "cli") && report.ok_harnesses) {
|
|
73
|
+
notes.push("The CLI's own credential is broken; that does not affect the exit code.");
|
|
74
|
+
}
|
|
75
|
+
// Per-harness rows, not the scope: connectivity is harness-scoped and never skips.
|
|
76
|
+
const harnessChecks = report.checks.filter((c) => c.harness !== null);
|
|
77
|
+
if (harnessChecks.length > 0 && harnessChecks.every((c) => c.status === "skip")) {
|
|
78
|
+
notes.push("No harness was checked here, so a green harness verdict proves nothing.");
|
|
79
|
+
}
|
|
80
|
+
return notes;
|
|
51
81
|
}
|
|
52
82
|
export function renderPretty(report) {
|
|
53
83
|
const lines = report.checks.map((c) => `${GLYPHS[c.status]} ${c.title}${c.detail ? ` — ${c.detail}` : ""}`);
|
|
@@ -56,5 +86,8 @@ export function renderPretty(report) {
|
|
|
56
86
|
for (const repair of report.repairs)
|
|
57
87
|
lines.push(` ${repair.command}`);
|
|
58
88
|
}
|
|
89
|
+
const notes = footnotes(report);
|
|
90
|
+
if (notes.length > 0)
|
|
91
|
+
lines.push("", ...notes);
|
|
59
92
|
return lines.join("\n");
|
|
60
93
|
}
|
|
@@ -11,6 +11,8 @@ export interface RegistrationStatus {
|
|
|
11
11
|
apiKey?: string;
|
|
12
12
|
/** From the environment, not the config, so a 401 is ambiguous. */
|
|
13
13
|
keyIsIndirect?: boolean;
|
|
14
|
+
/** Holds no key; reads the CLI's at runtime. */
|
|
15
|
+
usesCliKey?: boolean;
|
|
14
16
|
connected?: boolean;
|
|
15
17
|
/** Why a keyed registration's key could not be read; not `reason`. */
|
|
16
18
|
keyReason?: string;
|
|
@@ -324,7 +324,7 @@ function probeOpenClaw() {
|
|
|
324
324
|
}
|
|
325
325
|
// A bare substring also matched a skill merely named `not-tinyfish-thing`.
|
|
326
326
|
return entries.some((name) => SKILL_DIR_IS_TINYFISH.test(name))
|
|
327
|
-
? { registered: Registered.Yes, authMode: AuthMode.ApiKey }
|
|
327
|
+
? { registered: Registered.Yes, authMode: AuthMode.ApiKey, usesCliKey: true }
|
|
328
328
|
: { registered: Registered.No, authMode: AuthMode.Unknown };
|
|
329
329
|
}
|
|
330
330
|
// `opencode mcp list` prints no headers, so a key-authed registration is indistinguishable.
|