@tiny-fish/cli 0.38.1-next.306 → 0.38.1-next.308
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/commands/connect.js +2 -2
- package/dist/commands/doctor.js +6 -273
- package/dist/lib/connect-clients.d.ts +1 -1
- package/dist/lib/connect-clients.js +4 -8
- package/dist/lib/connect-runtime.d.ts +3 -0
- package/dist/lib/connect-runtime.js +57 -22
- package/dist/lib/doctor-checks.d.ts +18 -0
- package/dist/lib/doctor-checks.js +274 -0
- package/dist/lib/harness-spec.d.ts +9 -0
- package/dist/lib/harness-spec.js +8 -4
- package/dist/lib/skill-install.js +28 -7
- package/package.json +1 -1
package/dist/commands/connect.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import spawn from 'cross-spawn';
|
|
2
2
|
import { CONNECT_SOURCE, persistApiKeyToEnvironment, saveConnectContext, validateKeyFormat, validatedApiKey, } from '../lib/auth.js';
|
|
3
|
-
import { CURSOR_SKILL_TARGET, OPENCLAW,
|
|
3
|
+
import { CURSOR_SKILL_TARGET, OPENCLAW, openclawSkillInstallArgs, NATIVE_BY_HARNESS, launchNativeMcpClient, launchOmpWalkthrough, launchOpenClawWalkthrough, openExternalUrl, } from '../lib/connect-clients.js';
|
|
4
4
|
import { captureStdio, capturedOutput, HoistedFailureReportedError, installTinyFishCli, replay, SKILL_INSTALL_TIMEOUT_MS, STEP_MAX_BUFFER, } from '../lib/cli-install.js';
|
|
5
5
|
import { ensureCliAuthenticated } from '../lib/connect-auth.js';
|
|
6
6
|
import { createStdinPrompt, runCliFallback } from '../lib/connect-fallback.js';
|
|
@@ -474,7 +474,7 @@ export async function connectOpenClaw(options) {
|
|
|
474
474
|
}
|
|
475
475
|
state.stage = 'skill_install';
|
|
476
476
|
errLine('Installing the TinyFish skill in OpenClaw...');
|
|
477
|
-
const skillInstallResult = spawn.sync('openclaw',
|
|
477
|
+
const skillInstallResult = spawn.sync('openclaw', openclawSkillInstallArgs(support.matchedVariant), captureStdio(options.verbose ?? false));
|
|
478
478
|
if (skillInstallResult.error || skillInstallResult.status !== 0) {
|
|
479
479
|
// Built first: its interrupt check must run before any replay.
|
|
480
480
|
const error = spawnStepError('Could not install the TinyFish skill in OpenClaw', skillInstallResult);
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,282 +1,15 @@
|
|
|
1
|
-
import { apiKeyStatus } from '../lib/auth.js';
|
|
2
1
|
import { CLI_VERSION } from '../lib/constants.js';
|
|
2
|
+
import { checkAuthCall, checkCliVersion, checkConnectivity, checkCredential, checkHermesPlugin, checkRegistration, provesHarnessReach, verifyHarnessKey, } from '../lib/doctor-checks.js';
|
|
3
3
|
import { DOCTOR_COULD_NOT_RUN, DOCTOR_INVALID_INPUT, DOCTOR_SCHEMA_VERSION, doctorReportSchema, exitCodeFor, renderPretty, verdictFor, } from '../lib/doctor-report.js';
|
|
4
4
|
import { applyRepairs, repairsFor } from '../lib/doctor-repairs.js';
|
|
5
5
|
import { couldNotRunPayload, telemetryKey, telemetryPayload } from '../lib/doctor-telemetry.js';
|
|
6
|
-
import { ALL_HARNESSES
|
|
7
|
-
import {
|
|
6
|
+
import { ALL_HARNESSES } from '../lib/harness-detect.js';
|
|
7
|
+
import { err, errLine, out, outLine } from '../lib/output.js';
|
|
8
8
|
import { detectRegistrations } from '../lib/registration-detect.js';
|
|
9
9
|
import { sendDoctorCompleted } from '../lib/setup-telemetry.js';
|
|
10
|
-
import { verifyMcpAuth
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { DEFAULT_MCP_URL, endpointOf, isDefaultEndpoint } from '../lib/mcp-endpoint.js';
|
|
14
|
-
// A green auth mode with a red auth call proves nothing, so the call's verdict gates the claim.
|
|
15
|
-
function provesHarnessReach(status, keyAuth, healthOk, mcpUrl) {
|
|
16
|
-
// An endpoint doctor could not reach proves nothing about a harness pointed at it.
|
|
17
|
-
if (!healthOk)
|
|
18
|
-
return false;
|
|
19
|
-
if (pointsElsewhere(status.registeredUrl, mcpUrl))
|
|
20
|
-
return false;
|
|
21
|
-
if (status.registered !== Registered.Yes)
|
|
22
|
-
return false;
|
|
23
|
-
if (status.connected === true)
|
|
24
|
-
return true;
|
|
25
|
-
// Symmetric with registrationVerdict: a harness saying it cannot reach us is decisive.
|
|
26
|
-
if (status.connected === false)
|
|
27
|
-
return false;
|
|
28
|
-
// The harness's own key must have verified; the CLI's verdict says nothing about it.
|
|
29
|
-
return status.authMode === AuthMode.ApiKey && keyAuth?.ok === true;
|
|
30
|
-
}
|
|
31
|
-
// Reports the version, never judges it: staleness is the server's call via X-TF-Notice.
|
|
32
|
-
function checkCliVersion() {
|
|
33
|
-
return {
|
|
34
|
-
id: 'cli-version',
|
|
35
|
-
title: `CLI ${CLI_VERSION}`,
|
|
36
|
-
status: 'pass',
|
|
37
|
-
detail: `${CLI_VERSION}; the server reports staleness via X-TF-Notice`,
|
|
38
|
-
harness: null,
|
|
39
|
-
scope: 'info',
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
async function checkConnectivity(mcpUrl) {
|
|
43
|
-
const health = await verifyMcpHealth(mcpUrl);
|
|
44
|
-
return {
|
|
45
|
-
id: 'mcp-connectivity',
|
|
46
|
-
title: 'MCP endpoint reachable',
|
|
47
|
-
status: health.ok ? 'pass' : 'fail',
|
|
48
|
-
detail: health.ok ? mcpUrl : (health.code ?? 'the endpoint is unreachable'),
|
|
49
|
-
// Harness-scoped: the CLI's own reachability is `cli-auth-call`'s claim.
|
|
50
|
-
harness: null,
|
|
51
|
-
scope: 'harness',
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
/** Host alone let `/wrong-path` and plain `http://` through and claim the harness reaches us. */
|
|
55
|
-
function pointsElsewhere(registeredUrl, mcpUrl) {
|
|
56
|
-
if (!registeredUrl)
|
|
57
|
-
return undefined;
|
|
58
|
-
try {
|
|
59
|
-
const registered = endpointOf(registeredUrl);
|
|
60
|
-
return registered === endpointOf(mcpUrl) ? undefined : registered;
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
// An endpoint that will not parse cannot be shown to match, so it does not.
|
|
64
|
-
return 'an unparseable endpoint';
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
/** Either it reads the CLI's key at runtime or it holds a copy: the CLI's own verdict covers both. */
|
|
68
|
-
function runsOnCliCredential(status) {
|
|
69
|
-
return status.usesCliKey === true || status.keyMatchesCliKey === true;
|
|
70
|
-
}
|
|
71
|
-
function keyedVerdict(status, keyAuth, cliKeyMissing) {
|
|
72
|
-
if (!keyAuth) {
|
|
73
|
-
// Its auth is the CLI credential; a warn would exit 0.
|
|
74
|
-
if (runsOnCliCredential(status)) {
|
|
75
|
-
return cliKeyMissing
|
|
76
|
-
? {
|
|
77
|
-
status: 'fail',
|
|
78
|
-
detail: 'registered, but it runs on the CLI credential, which is missing or unusable',
|
|
79
|
-
}
|
|
80
|
-
: // Reachable with a key only when `--url` skipped verification.
|
|
81
|
-
{
|
|
82
|
-
status: 'warn',
|
|
83
|
-
detail: 'registered, but its CLI credential was not verified against this endpoint',
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
// A bare warn beside exit 0 reads as a pass.
|
|
87
|
-
const because = status.keyReason ?? 'the CLI cannot verify a key it does not hold';
|
|
88
|
-
return { status: 'warn', detail: `registered, but ${because}, so its reach is unproven` };
|
|
89
|
-
}
|
|
90
|
-
if (keyAuth.ok) {
|
|
91
|
-
return {
|
|
92
|
-
status: 'pass',
|
|
93
|
-
detail: 'registered, auth mode api-key (key verified against the TinyFish API)',
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
if (keyAuth.status === 401) {
|
|
97
|
-
return { status: 'fail', detail: 'registered, but TinyFish rejects its API key' };
|
|
98
|
-
}
|
|
99
|
-
// Only 401 blames the key; connect would loop on the rest.
|
|
100
|
-
if (keyAuth.status === 403) {
|
|
101
|
-
return { status: 'warn', detail: 'registered, but its API key is not allowed to list runs' };
|
|
102
|
-
}
|
|
103
|
-
return { status: 'warn', detail: 'registered, key could not be verified' };
|
|
104
|
-
}
|
|
105
|
-
function registrationVerdict(status, mcpUrl, keyAuth, cliKeyMissing) {
|
|
106
|
-
if (!status.detected)
|
|
107
|
-
return { status: 'skip', detail: 'harness not installed' };
|
|
108
|
-
if (status.registered === Registered.Unknown) {
|
|
109
|
-
return { status: 'warn', detail: status.reason ?? 'could not determine registration' };
|
|
110
|
-
}
|
|
111
|
-
if (status.registered === Registered.Yes) {
|
|
112
|
-
// A `tinyfish` entry aimed at a dev server is registered and still cannot reach TinyFish.
|
|
113
|
-
const elsewhere = pointsElsewhere(status.registeredUrl, mcpUrl);
|
|
114
|
-
if (elsewhere) {
|
|
115
|
-
return {
|
|
116
|
-
status: 'fail',
|
|
117
|
-
detail: `registered, but points at ${elsewhere} rather than ${endpointOf(mcpUrl)}`,
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
// Ordered before the key check: the harness's own verdict wins.
|
|
121
|
-
if (status.connected === false) {
|
|
122
|
-
return {
|
|
123
|
-
status: 'warn',
|
|
124
|
-
detail: 'registered, but the harness reports it cannot reach TinyFish; sign in again there',
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
// Symmetric with provesHarnessReach, which already reads a handshake as decisive.
|
|
128
|
-
if (status.connected === true) {
|
|
129
|
-
return { status: 'pass', detail: 'registered, and the harness reports it reaches TinyFish' };
|
|
130
|
-
}
|
|
131
|
-
// Widening past api-key would warn every healthy oauth install.
|
|
132
|
-
if (status.authMode === AuthMode.ApiKey)
|
|
133
|
-
return keyedVerdict(status, keyAuth, cliKeyMissing);
|
|
134
|
-
// Recorded, not verified: it never feeds provesHarnessReach.
|
|
135
|
-
if (status.recordedAuthMode) {
|
|
136
|
-
return {
|
|
137
|
-
status: 'pass',
|
|
138
|
-
detail: `registered, auth mode ${status.recordedAuthMode} ` +
|
|
139
|
-
'(recorded at connect; this harness does not report its headers)',
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
// A probe that had to explain itself to reach `yes` is the only thing that explains `unknown`.
|
|
143
|
-
const because = status.reason ? ` (${status.reason})` : '';
|
|
144
|
-
return { status: 'pass', detail: `registered, auth mode ${status.authMode}${because}` };
|
|
145
|
-
}
|
|
146
|
-
// Connected once and now absent is a broken install; never connected is just not set up.
|
|
147
|
-
return status.connectedBefore
|
|
148
|
-
? { status: 'fail', detail: 'connected previously but TinyFish is no longer registered' }
|
|
149
|
-
: { status: 'warn', detail: 'installed but TinyFish was never connected' };
|
|
150
|
-
}
|
|
151
|
-
function checkRegistration(status, mcpUrl, keyAuth, cliKeyMissing) {
|
|
152
|
-
return {
|
|
153
|
-
id: 'harness-registration',
|
|
154
|
-
title: `${status.harness} registration`,
|
|
155
|
-
harness: status.harness,
|
|
156
|
-
scope: 'harness',
|
|
157
|
-
...registrationVerdict(status, mcpUrl, keyAuth, cliKeyMissing),
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
function checkCredential() {
|
|
161
|
-
const resolved = apiKeyStatus();
|
|
162
|
-
const check = {
|
|
163
|
-
id: 'cli-credential',
|
|
164
|
-
title: 'CLI credential',
|
|
165
|
-
status: 'key' in resolved ? 'pass' : 'fail',
|
|
166
|
-
detail: 'key' in resolved ? `key present from ${resolved.source}` : resolved.error,
|
|
167
|
-
harness: null,
|
|
168
|
-
scope: 'cli',
|
|
169
|
-
};
|
|
170
|
-
return 'key' in resolved ? { check, key: resolved.key } : { check };
|
|
171
|
-
}
|
|
172
|
-
function checkAuthCall(auth) {
|
|
173
|
-
if (!auth) {
|
|
174
|
-
return {
|
|
175
|
-
id: 'cli-auth-call',
|
|
176
|
-
title: 'Authenticated call',
|
|
177
|
-
status: 'skip',
|
|
178
|
-
detail: 'no credential to test',
|
|
179
|
-
harness: null,
|
|
180
|
-
scope: 'cli',
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
return {
|
|
184
|
-
id: 'cli-auth-call',
|
|
185
|
-
title: 'Authenticated call',
|
|
186
|
-
status: auth.ok ? 'pass' : 'fail',
|
|
187
|
-
detail: auth.ok ? 'listRuns succeeded' : (auth.code ?? 'the authenticated call failed'),
|
|
188
|
-
harness: null,
|
|
189
|
-
scope: 'cli',
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
function verifyHarnessKey(status, cliAuth, mcpUrl) {
|
|
193
|
-
if (!isDefaultEndpoint(mcpUrl))
|
|
194
|
-
return undefined;
|
|
195
|
-
// `pointsElsewhere` already fails; a second reason only misleads.
|
|
196
|
-
if (pointsElsewhere(status.registeredUrl, mcpUrl))
|
|
197
|
-
return undefined;
|
|
198
|
-
// The CLI never holds a harness's key, so only its own credential can be verified.
|
|
199
|
-
return runsOnCliCredential(status) ? cliAuth : undefined;
|
|
200
|
-
}
|
|
201
|
-
// `ok` is backends && key; provider_available re-reads the key through the provider.
|
|
202
|
-
function hermesPluginStatusVerdict(status) {
|
|
203
|
-
if (status.ok) {
|
|
204
|
-
return status.provider_available === false
|
|
205
|
-
? { status: 'warn', detail: 'plugin configured, but its provider cannot see the key' }
|
|
206
|
-
: {
|
|
207
|
-
status: 'pass',
|
|
208
|
-
detail: `plugin ${status.plugin_version ?? 'installed'}; backends and key configured`,
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
if (!status.web_backend_configured) {
|
|
212
|
-
return {
|
|
213
|
-
status: 'warn',
|
|
214
|
-
detail: 'plugin installed but the web backends do not point at tinyfish',
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
return {
|
|
218
|
-
status: 'fail',
|
|
219
|
-
detail: 'web backends point at tinyfish but no key resolves from ' +
|
|
220
|
-
'TINYFISH_API_KEY or MCP_TINYFISH_API_KEY',
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
/** An unreadable config cannot prove the backends are ours, so doctor never fails on it. */
|
|
224
|
-
function backendsOurs(home) {
|
|
225
|
-
try {
|
|
226
|
-
return hermesWebBackendsOurs(home);
|
|
227
|
-
}
|
|
228
|
-
catch {
|
|
229
|
-
return false;
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
function hermesPluginVerdict(home) {
|
|
233
|
-
const probe = readHermesPluginStatus(home);
|
|
234
|
-
if (probe.state === 'not_installed') {
|
|
235
|
-
// Backends naming an absent provider are broken; never-installed keeps exit 0.
|
|
236
|
-
return backendsOurs(home)
|
|
237
|
-
? {
|
|
238
|
-
status: 'fail',
|
|
239
|
-
detail: 'web backends point at tinyfish but the plugin is not installed or not enabled',
|
|
240
|
-
}
|
|
241
|
-
: { status: 'warn', detail: 'the TinyFish web plugin is not installed or not enabled' };
|
|
242
|
-
}
|
|
243
|
-
if (probe.state === 'unreachable') {
|
|
244
|
-
return { status: 'warn', detail: '`hermes tinyfish status` did not respond in time' };
|
|
245
|
-
}
|
|
246
|
-
if (probe.state === 'unparseable') {
|
|
247
|
-
return {
|
|
248
|
-
status: 'warn',
|
|
249
|
-
detail: '`hermes tinyfish status --json` returned unparseable output',
|
|
250
|
-
};
|
|
251
|
-
}
|
|
252
|
-
return {
|
|
253
|
-
...hermesPluginStatusVerdict(probe.status),
|
|
254
|
-
// Machine-supplied, so it is bounded here rather than trusted onward.
|
|
255
|
-
observedVersion: boundedVersion(probe.status.plugin_version ?? ''),
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
|
-
function checkHermesPlugin(status) {
|
|
259
|
-
const base = {
|
|
260
|
-
id: 'hermes-plugin',
|
|
261
|
-
title: 'Hermes web plugin',
|
|
262
|
-
harness: 'hermes',
|
|
263
|
-
scope: 'harness',
|
|
264
|
-
};
|
|
265
|
-
if (!status.detected) {
|
|
266
|
-
return { check: { ...base, status: 'skip', detail: 'harness not installed' } };
|
|
267
|
-
}
|
|
268
|
-
if (status.registered !== Registered.Yes) {
|
|
269
|
-
return { check: { ...base, status: 'skip', detail: 'TinyFish is not registered in Hermes' } };
|
|
270
|
-
}
|
|
271
|
-
const home = resolveHermesHome();
|
|
272
|
-
if (!home) {
|
|
273
|
-
return {
|
|
274
|
-
check: { ...base, status: 'warn', detail: "could not determine Hermes' home directory" },
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
|
-
const { observedVersion, ...verdict } = hermesPluginVerdict(home);
|
|
278
|
-
return { check: { ...base, ...verdict }, observedVersion };
|
|
279
|
-
}
|
|
10
|
+
import { verifyMcpAuth } from '../lib/verify.js';
|
|
11
|
+
import { HERMES_PLUGIN_VERSION } from '../lib/hermes-plugin.js';
|
|
12
|
+
import { DEFAULT_MCP_URL } from '../lib/mcp-endpoint.js';
|
|
280
13
|
/** Skips mean no Hermes; stamping those buries the drift share. */
|
|
281
14
|
function hermesPluginVersions(result) {
|
|
282
15
|
if (!result || result.check.status === 'skip')
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import spawn from 'cross-spawn';
|
|
2
2
|
import { type AgentClient, type SupportedCommand } from './connect-runtime.js';
|
|
3
3
|
import { type HarnessSpec, type SkillAgent } from './harness-spec.js';
|
|
4
|
-
export declare
|
|
4
|
+
export declare function openclawSkillInstallArgs(ackFlag: string | undefined): string[];
|
|
5
5
|
/** `openclaw skills` has no uninstall; ClawHub removes from the global dir. */
|
|
6
6
|
export declare function openclawSkillUninstall(): {
|
|
7
7
|
command: string;
|
|
@@ -12,14 +12,10 @@ import { HERMES_PLUGIN_VERSION, installHermesPlugin, setHermesWebBackends, } fro
|
|
|
12
12
|
const HERMES_SEED_TIMEOUT_MS = 120_000;
|
|
13
13
|
const OPENCLAW_SKILL = '@tinyfish/tinyfish';
|
|
14
14
|
// --force makes this an unconditional overwrite, so the same call installs and refreshes.
|
|
15
|
-
export
|
|
16
|
-
|
|
17
|
-
'install',
|
|
18
|
-
|
|
19
|
-
'--global',
|
|
20
|
-
'--force',
|
|
21
|
-
'--acknowledge-clawhub-risk',
|
|
22
|
-
];
|
|
15
|
+
export function openclawSkillInstallArgs(ackFlag) {
|
|
16
|
+
const ack = ackFlag === undefined ? [] : [ackFlag];
|
|
17
|
+
return ['skills', 'install', OPENCLAW_SKILL, '--global', '--force', ...ack];
|
|
18
|
+
}
|
|
23
19
|
/** `openclaw skills` has no uninstall; ClawHub removes from the global dir. */
|
|
24
20
|
export function openclawSkillUninstall() {
|
|
25
21
|
return {
|
|
@@ -92,9 +92,12 @@ export interface SupportedCommand {
|
|
|
92
92
|
displayName: string;
|
|
93
93
|
supportCheck: HarnessSupportCheck;
|
|
94
94
|
}
|
|
95
|
+
/** Refresh paths need the advertised alternative without connect's version probe. */
|
|
96
|
+
export declare function probeSupportVariant(client: SupportedCommand): string | undefined;
|
|
95
97
|
export declare function requireCommandSupport(client: SupportedCommand): {
|
|
96
98
|
optionalSupported: boolean;
|
|
97
99
|
keyAuthSupported: boolean;
|
|
100
|
+
matchedVariant?: string;
|
|
98
101
|
harnessVersion?: string;
|
|
99
102
|
};
|
|
100
103
|
export declare function createConnectTelemetry(mcpUrl: string, client: AgentClient, opts?: {
|
|
@@ -178,23 +178,26 @@ export async function runGuarded(state, telemetry, body) {
|
|
|
178
178
|
await telemetry.flush();
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
|
+
function probeTimeoutMs(client) {
|
|
182
|
+
return client.supportCheck.timeoutMs ?? NON_INTERACTIVE_TIMEOUT_MS;
|
|
183
|
+
}
|
|
181
184
|
function throwProbeFailure(client, result) {
|
|
182
185
|
throwIfInterrupted(result);
|
|
183
186
|
// A hung probe is a timeout, not a missing capability.
|
|
184
187
|
if (result.error?.code === 'ETIMEDOUT') {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
+
const probe = `${client.command} ${client.supportCheck.args.join(' ')}`;
|
|
189
|
+
throw new ConnectStepError(`${client.displayName} did not respond to \`${probe}\` within ` +
|
|
190
|
+
`${probeTimeoutMs(client) / 1000}s. Retry.`, 'timeout', { cause: result.error });
|
|
188
191
|
}
|
|
189
192
|
// It ran and exited non-zero, so the version is still answerable.
|
|
190
|
-
throw new PrerequisiteError(client.supportCheck.unavailableMessage, 'harness_command_unsupported', { cause: result.error, harnessVersion: probeHarnessVersion(client
|
|
193
|
+
throw new PrerequisiteError(client.supportCheck.unavailableMessage, 'harness_command_unsupported', { cause: result.error, harnessVersion: probeHarnessVersion(client) });
|
|
191
194
|
}
|
|
192
|
-
|
|
195
|
+
function supportProbeOutput(client) {
|
|
193
196
|
const result = spawn.sync(client.command, client.supportCheck.args, {
|
|
194
197
|
encoding: 'utf8',
|
|
195
198
|
// Agent harnesses export FORCE_COLOR, which makes clients colourise even a piped --help.
|
|
196
199
|
env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
|
|
197
|
-
timeout:
|
|
200
|
+
timeout: probeTimeoutMs(client),
|
|
198
201
|
});
|
|
199
202
|
// 127 is the shell's not-found code; a wrapper shim with no real binary behind it exits that.
|
|
200
203
|
if (commandNotFound(result.error) || result.status === 127) {
|
|
@@ -204,31 +207,63 @@ export function requireCommandSupport(client) {
|
|
|
204
207
|
throwProbeFailure(client, result);
|
|
205
208
|
}
|
|
206
209
|
// Belt and braces with the colour env: a client that ignores NO_COLOR still has to match.
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
210
|
+
return sanitizeLine(`${result.stdout ?? ''}\n${result.stderr ?? ''}`);
|
|
211
|
+
}
|
|
212
|
+
function debugProbeOutput(client, output) {
|
|
213
|
+
if (!process.env['TINYFISH_DEBUG'])
|
|
214
|
+
return;
|
|
215
|
+
errLine(`${client.command} ${client.supportCheck.args.join(' ')} printed:\n${output.trim()}`);
|
|
216
|
+
}
|
|
217
|
+
function requireEssential(client, output) {
|
|
218
|
+
if (client.supportCheck.patterns.every((pattern) => pattern.test(output)))
|
|
219
|
+
return;
|
|
220
|
+
debugProbeOutput(client, output);
|
|
221
|
+
throw new PrerequisiteError(client.supportCheck.unavailableMessage, 'harness_too_old', {
|
|
222
|
+
harnessVersion: probeHarnessVersion(client),
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
/** Absence is a fact; an unknown flag means an upstream rename. */
|
|
226
|
+
function resolveVariant(client, output) {
|
|
227
|
+
const check = client.supportCheck;
|
|
228
|
+
// Token equality, not a built regex: exact on both edges, and no dynamic RegExp.
|
|
229
|
+
const listed = new Set(output.split(/[\s,=]+/));
|
|
230
|
+
const matched = check.variants?.find((flag) => listed.has(flag));
|
|
231
|
+
if (matched)
|
|
232
|
+
return matched;
|
|
233
|
+
const unknown = check.unknownVariant?.exec(output)?.[0];
|
|
234
|
+
if (unknown === undefined)
|
|
235
|
+
return undefined;
|
|
236
|
+
// Unacknowledged policy warnings can block the install; fail before it does.
|
|
237
|
+
debugProbeOutput(client, output);
|
|
238
|
+
throw new PrerequisiteError(`${client.displayName} lists \`${unknown}\`, which this TinyFish CLI does not recognise. ` +
|
|
239
|
+
'Upgrade the TinyFish CLI with `tinyfish upgrade` and retry.', 'harness_too_old', { harnessVersion: probeHarnessVersion(client) });
|
|
240
|
+
}
|
|
241
|
+
/** Refresh paths need the advertised alternative without connect's version probe. */
|
|
242
|
+
export function probeSupportVariant(client) {
|
|
243
|
+
const output = supportProbeOutput(client);
|
|
244
|
+
requireEssential(client, output);
|
|
245
|
+
return resolveVariant(client, output);
|
|
246
|
+
}
|
|
247
|
+
export function requireCommandSupport(client) {
|
|
248
|
+
const output = supportProbeOutput(client);
|
|
249
|
+
requireEssential(client, output);
|
|
250
|
+
const optionalSupported = (client.supportCheck.optionalPatterns ?? []).every((pattern) => pattern.test(output));
|
|
211
251
|
const keyAuthPattern = client.supportCheck.keyAuthPattern;
|
|
212
252
|
const keyAuthSupported = keyAuthPattern ? keyAuthPattern.test(output) : true;
|
|
213
|
-
if (
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
if (!essential) {
|
|
217
|
-
throw new PrerequisiteError(client.supportCheck.unavailableMessage, 'harness_too_old', {
|
|
218
|
-
harnessVersion: probeHarnessVersion(client.command),
|
|
219
|
-
});
|
|
220
|
-
}
|
|
253
|
+
if (!optionalSupported || !keyAuthSupported)
|
|
254
|
+
debugProbeOutput(client, output);
|
|
221
255
|
return {
|
|
222
256
|
optionalSupported,
|
|
223
257
|
keyAuthSupported,
|
|
224
|
-
|
|
258
|
+
matchedVariant: resolveVariant(client, output),
|
|
259
|
+
harnessVersion: probeHarnessVersion(client),
|
|
225
260
|
};
|
|
226
261
|
}
|
|
227
262
|
/** Best-effort; the server rejects non-printable characters and >32 chars. */
|
|
228
|
-
function probeHarnessVersion(
|
|
229
|
-
const result = spawn.sync(command, ['--version'], {
|
|
263
|
+
function probeHarnessVersion(client) {
|
|
264
|
+
const result = spawn.sync(client.command, ['--version'], {
|
|
230
265
|
encoding: 'utf8',
|
|
231
|
-
timeout:
|
|
266
|
+
timeout: probeTimeoutMs(client),
|
|
232
267
|
});
|
|
233
268
|
if (result.error || result.status !== 0)
|
|
234
269
|
return undefined;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { DoctorCheck } from './doctor-report.js';
|
|
2
|
+
import type { RegistrationStatus } from './registration-detect.js';
|
|
3
|
+
import { type VerifyResult } from './verify.js';
|
|
4
|
+
export declare function provesHarnessReach(status: RegistrationStatus, keyAuth: VerifyResult | undefined, healthOk: boolean, mcpUrl: string): boolean;
|
|
5
|
+
export declare function checkCliVersion(): DoctorCheck;
|
|
6
|
+
export declare function checkConnectivity(mcpUrl: string): Promise<DoctorCheck>;
|
|
7
|
+
export declare function checkRegistration(status: RegistrationStatus, mcpUrl: string, keyAuth: VerifyResult | undefined, cliKeyMissing: boolean): DoctorCheck;
|
|
8
|
+
export declare function checkCredential(): {
|
|
9
|
+
check: DoctorCheck;
|
|
10
|
+
key?: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function checkAuthCall(auth: VerifyResult | undefined): DoctorCheck;
|
|
13
|
+
export declare function verifyHarnessKey(status: RegistrationStatus, cliAuth: Promise<VerifyResult> | undefined, mcpUrl: string): Promise<VerifyResult> | undefined;
|
|
14
|
+
export interface HermesPluginResult {
|
|
15
|
+
check: DoctorCheck;
|
|
16
|
+
observedVersion?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function checkHermesPlugin(status: RegistrationStatus): HermesPluginResult;
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { apiKeyStatus } from './auth.js';
|
|
2
|
+
import { CLI_VERSION } from './constants.js';
|
|
3
|
+
import { AuthMode, Registered } from './harness-detect.js';
|
|
4
|
+
import { resolveHermesHome } from './hermes-env.js';
|
|
5
|
+
import { hermesWebBackendsOurs, readHermesPluginStatus, } from './hermes-plugin.js';
|
|
6
|
+
import { endpointOf, isDefaultEndpoint } from './mcp-endpoint.js';
|
|
7
|
+
import { boundedVersion } from './output.js';
|
|
8
|
+
import { verifyMcpHealth } from './verify.js';
|
|
9
|
+
// A green auth mode with a red auth call proves nothing, so the call's verdict gates the claim.
|
|
10
|
+
export function provesHarnessReach(status, keyAuth, healthOk, mcpUrl) {
|
|
11
|
+
// An endpoint doctor could not reach proves nothing about a harness pointed at it.
|
|
12
|
+
if (!healthOk)
|
|
13
|
+
return false;
|
|
14
|
+
if (pointsElsewhere(status.registeredUrl, mcpUrl))
|
|
15
|
+
return false;
|
|
16
|
+
if (status.registered !== Registered.Yes)
|
|
17
|
+
return false;
|
|
18
|
+
if (status.connected === true)
|
|
19
|
+
return true;
|
|
20
|
+
// Symmetric with registrationVerdict: a harness saying it cannot reach us is decisive.
|
|
21
|
+
if (status.connected === false)
|
|
22
|
+
return false;
|
|
23
|
+
// The harness's own key must have verified; the CLI's verdict says nothing about it.
|
|
24
|
+
return status.authMode === AuthMode.ApiKey && keyAuth?.ok === true;
|
|
25
|
+
}
|
|
26
|
+
// Reports the version, never judges it: staleness is the server's call via X-TF-Notice.
|
|
27
|
+
export function checkCliVersion() {
|
|
28
|
+
return {
|
|
29
|
+
id: 'cli-version',
|
|
30
|
+
title: `CLI ${CLI_VERSION}`,
|
|
31
|
+
status: 'pass',
|
|
32
|
+
detail: `${CLI_VERSION}; the server reports staleness via X-TF-Notice`,
|
|
33
|
+
harness: null,
|
|
34
|
+
scope: 'info',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export async function checkConnectivity(mcpUrl) {
|
|
38
|
+
const health = await verifyMcpHealth(mcpUrl);
|
|
39
|
+
return {
|
|
40
|
+
id: 'mcp-connectivity',
|
|
41
|
+
title: 'MCP endpoint reachable',
|
|
42
|
+
status: health.ok ? 'pass' : 'fail',
|
|
43
|
+
detail: health.ok ? mcpUrl : (health.code ?? 'the endpoint is unreachable'),
|
|
44
|
+
// Harness-scoped: the CLI's own reachability is `cli-auth-call`'s claim.
|
|
45
|
+
harness: null,
|
|
46
|
+
scope: 'harness',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Host alone let `/wrong-path` and plain `http://` through and claim the harness reaches us. */
|
|
50
|
+
function pointsElsewhere(registeredUrl, mcpUrl) {
|
|
51
|
+
if (!registeredUrl)
|
|
52
|
+
return undefined;
|
|
53
|
+
try {
|
|
54
|
+
const registered = endpointOf(registeredUrl);
|
|
55
|
+
return registered === endpointOf(mcpUrl) ? undefined : registered;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// An endpoint that will not parse cannot be shown to match, so it does not.
|
|
59
|
+
return 'an unparseable endpoint';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Either it reads the CLI's key at runtime or it holds a copy: the CLI's own verdict covers both. */
|
|
63
|
+
function runsOnCliCredential(status) {
|
|
64
|
+
return status.usesCliKey === true || status.keyMatchesCliKey === true;
|
|
65
|
+
}
|
|
66
|
+
function keyedVerdict(status, keyAuth, cliKeyMissing) {
|
|
67
|
+
if (!keyAuth) {
|
|
68
|
+
// Its auth is the CLI credential; a warn would exit 0.
|
|
69
|
+
if (runsOnCliCredential(status)) {
|
|
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
|
+
}
|
|
81
|
+
// A bare warn beside exit 0 reads as a pass.
|
|
82
|
+
const because = status.keyReason ?? 'the CLI cannot verify a key it does not hold';
|
|
83
|
+
return { status: 'warn', detail: `registered, but ${because}, so its reach is unproven` };
|
|
84
|
+
}
|
|
85
|
+
if (keyAuth.ok) {
|
|
86
|
+
return {
|
|
87
|
+
status: 'pass',
|
|
88
|
+
detail: 'registered, auth mode api-key (key verified against the TinyFish API)',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (keyAuth.status === 401) {
|
|
92
|
+
return { status: 'fail', detail: 'registered, but TinyFish rejects its API key' };
|
|
93
|
+
}
|
|
94
|
+
// Only 401 blames the key; connect would loop on the rest.
|
|
95
|
+
if (keyAuth.status === 403) {
|
|
96
|
+
return { status: 'warn', detail: 'registered, but its API key is not allowed to list runs' };
|
|
97
|
+
}
|
|
98
|
+
return { status: 'warn', detail: 'registered, key could not be verified' };
|
|
99
|
+
}
|
|
100
|
+
function registrationVerdict(status, mcpUrl, keyAuth, cliKeyMissing) {
|
|
101
|
+
if (!status.detected)
|
|
102
|
+
return { status: 'skip', detail: 'harness not installed' };
|
|
103
|
+
if (status.registered === Registered.Unknown) {
|
|
104
|
+
return { status: 'warn', detail: status.reason ?? 'could not determine registration' };
|
|
105
|
+
}
|
|
106
|
+
if (status.registered === Registered.Yes) {
|
|
107
|
+
// A `tinyfish` entry aimed at a dev server is registered and still cannot reach TinyFish.
|
|
108
|
+
const elsewhere = pointsElsewhere(status.registeredUrl, mcpUrl);
|
|
109
|
+
if (elsewhere) {
|
|
110
|
+
return {
|
|
111
|
+
status: 'fail',
|
|
112
|
+
detail: `registered, but points at ${elsewhere} rather than ${endpointOf(mcpUrl)}`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
// Ordered before the key check: the harness's own verdict wins.
|
|
116
|
+
if (status.connected === false) {
|
|
117
|
+
return {
|
|
118
|
+
status: 'warn',
|
|
119
|
+
detail: 'registered, but the harness reports it cannot reach TinyFish; sign in again there',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
// Symmetric with provesHarnessReach, which already reads a handshake as decisive.
|
|
123
|
+
if (status.connected === true) {
|
|
124
|
+
return { status: 'pass', detail: 'registered, and the harness reports it reaches TinyFish' };
|
|
125
|
+
}
|
|
126
|
+
// Widening past api-key would warn every healthy oauth install.
|
|
127
|
+
if (status.authMode === AuthMode.ApiKey)
|
|
128
|
+
return keyedVerdict(status, keyAuth, cliKeyMissing);
|
|
129
|
+
// Recorded, not verified: it never feeds provesHarnessReach.
|
|
130
|
+
if (status.recordedAuthMode) {
|
|
131
|
+
return {
|
|
132
|
+
status: 'pass',
|
|
133
|
+
detail: `registered, auth mode ${status.recordedAuthMode} ` +
|
|
134
|
+
'(recorded at connect; this harness does not report its headers)',
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
// A probe that had to explain itself to reach `yes` is the only thing that explains `unknown`.
|
|
138
|
+
const because = status.reason ? ` (${status.reason})` : '';
|
|
139
|
+
return { status: 'pass', detail: `registered, auth mode ${status.authMode}${because}` };
|
|
140
|
+
}
|
|
141
|
+
// Connected once and now absent is a broken install; never connected is just not set up.
|
|
142
|
+
return status.connectedBefore
|
|
143
|
+
? { status: 'fail', detail: 'connected previously but TinyFish is no longer registered' }
|
|
144
|
+
: { status: 'warn', detail: 'installed but TinyFish was never connected' };
|
|
145
|
+
}
|
|
146
|
+
export function checkRegistration(status, mcpUrl, keyAuth, cliKeyMissing) {
|
|
147
|
+
return {
|
|
148
|
+
id: 'harness-registration',
|
|
149
|
+
title: `${status.harness} registration`,
|
|
150
|
+
harness: status.harness,
|
|
151
|
+
scope: 'harness',
|
|
152
|
+
...registrationVerdict(status, mcpUrl, keyAuth, cliKeyMissing),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
export function checkCredential() {
|
|
156
|
+
const resolved = apiKeyStatus();
|
|
157
|
+
const check = {
|
|
158
|
+
id: 'cli-credential',
|
|
159
|
+
title: 'CLI credential',
|
|
160
|
+
status: 'key' in resolved ? 'pass' : 'fail',
|
|
161
|
+
detail: 'key' in resolved ? `key present from ${resolved.source}` : resolved.error,
|
|
162
|
+
harness: null,
|
|
163
|
+
scope: 'cli',
|
|
164
|
+
};
|
|
165
|
+
return 'key' in resolved ? { check, key: resolved.key } : { check };
|
|
166
|
+
}
|
|
167
|
+
export function checkAuthCall(auth) {
|
|
168
|
+
if (!auth) {
|
|
169
|
+
return {
|
|
170
|
+
id: 'cli-auth-call',
|
|
171
|
+
title: 'Authenticated call',
|
|
172
|
+
status: 'skip',
|
|
173
|
+
detail: 'no credential to test',
|
|
174
|
+
harness: null,
|
|
175
|
+
scope: 'cli',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
id: 'cli-auth-call',
|
|
180
|
+
title: 'Authenticated call',
|
|
181
|
+
status: auth.ok ? 'pass' : 'fail',
|
|
182
|
+
detail: auth.ok ? 'listRuns succeeded' : (auth.code ?? 'the authenticated call failed'),
|
|
183
|
+
harness: null,
|
|
184
|
+
scope: 'cli',
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
export function verifyHarnessKey(status, cliAuth, mcpUrl) {
|
|
188
|
+
if (!isDefaultEndpoint(mcpUrl))
|
|
189
|
+
return undefined;
|
|
190
|
+
// `pointsElsewhere` already fails; a second reason only misleads.
|
|
191
|
+
if (pointsElsewhere(status.registeredUrl, mcpUrl))
|
|
192
|
+
return undefined;
|
|
193
|
+
// The CLI never holds a harness's key, so only its own credential can be verified.
|
|
194
|
+
return runsOnCliCredential(status) ? cliAuth : undefined;
|
|
195
|
+
}
|
|
196
|
+
// `ok` is backends && key; provider_available re-reads the key through the provider.
|
|
197
|
+
function hermesPluginStatusVerdict(status) {
|
|
198
|
+
if (status.ok) {
|
|
199
|
+
return status.provider_available === false
|
|
200
|
+
? { status: 'warn', detail: 'plugin configured, but its provider cannot see the key' }
|
|
201
|
+
: {
|
|
202
|
+
status: 'pass',
|
|
203
|
+
detail: `plugin ${status.plugin_version ?? 'installed'}; backends and key configured`,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (!status.web_backend_configured) {
|
|
207
|
+
return {
|
|
208
|
+
status: 'warn',
|
|
209
|
+
detail: 'plugin installed but the web backends do not point at tinyfish',
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
status: 'fail',
|
|
214
|
+
detail: 'web backends point at tinyfish but no key resolves from ' +
|
|
215
|
+
'TINYFISH_API_KEY or MCP_TINYFISH_API_KEY',
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/** An unreadable config cannot prove the backends are ours, so doctor never fails on it. */
|
|
219
|
+
function backendsOurs(home) {
|
|
220
|
+
try {
|
|
221
|
+
return hermesWebBackendsOurs(home);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function hermesPluginVerdict(home) {
|
|
228
|
+
const probe = readHermesPluginStatus(home);
|
|
229
|
+
if (probe.state === 'not_installed') {
|
|
230
|
+
// Backends naming an absent provider are broken; never-installed keeps exit 0.
|
|
231
|
+
return backendsOurs(home)
|
|
232
|
+
? {
|
|
233
|
+
status: 'fail',
|
|
234
|
+
detail: 'web backends point at tinyfish but the plugin is not installed or not enabled',
|
|
235
|
+
}
|
|
236
|
+
: { status: 'warn', detail: 'the TinyFish web plugin is not installed or not enabled' };
|
|
237
|
+
}
|
|
238
|
+
if (probe.state === 'unreachable') {
|
|
239
|
+
return { status: 'warn', detail: '`hermes tinyfish status` did not respond in time' };
|
|
240
|
+
}
|
|
241
|
+
if (probe.state === 'unparseable') {
|
|
242
|
+
return {
|
|
243
|
+
status: 'warn',
|
|
244
|
+
detail: '`hermes tinyfish status --json` returned unparseable output',
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
...hermesPluginStatusVerdict(probe.status),
|
|
249
|
+
// Machine-supplied, so it is bounded here rather than trusted onward.
|
|
250
|
+
observedVersion: boundedVersion(probe.status.plugin_version ?? ''),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
export function checkHermesPlugin(status) {
|
|
254
|
+
const base = {
|
|
255
|
+
id: 'hermes-plugin',
|
|
256
|
+
title: 'Hermes web plugin',
|
|
257
|
+
harness: 'hermes',
|
|
258
|
+
scope: 'harness',
|
|
259
|
+
};
|
|
260
|
+
if (!status.detected) {
|
|
261
|
+
return { check: { ...base, status: 'skip', detail: 'harness not installed' } };
|
|
262
|
+
}
|
|
263
|
+
if (status.registered !== Registered.Yes) {
|
|
264
|
+
return { check: { ...base, status: 'skip', detail: 'TinyFish is not registered in Hermes' } };
|
|
265
|
+
}
|
|
266
|
+
const home = resolveHermesHome();
|
|
267
|
+
if (!home) {
|
|
268
|
+
return {
|
|
269
|
+
check: { ...base, status: 'warn', detail: "could not determine Hermes' home directory" },
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
const { observedVersion, ...verdict } = hermesPluginVerdict(home);
|
|
273
|
+
return { check: { ...base, ...verdict }, observedVersion };
|
|
274
|
+
}
|
|
@@ -8,6 +8,12 @@ export interface HarnessSupportCheck {
|
|
|
8
8
|
optionalPatterns?: RegExp[];
|
|
9
9
|
/** Missing → this install cannot take a stored key; fall back to OAuth. */
|
|
10
10
|
keyAuthPattern?: RegExp;
|
|
11
|
+
/** Ordered alternative flags in one help output; first match wins. */
|
|
12
|
+
variants?: string[];
|
|
13
|
+
/** Matches an alternative we do not know; renames fail loudly. */
|
|
14
|
+
unknownVariant?: RegExp;
|
|
15
|
+
/** Defaults to NON_INTERACTIVE_TIMEOUT_MS; raise for a slow cold start. */
|
|
16
|
+
timeoutMs?: number;
|
|
11
17
|
unavailableMessage: string;
|
|
12
18
|
}
|
|
13
19
|
/** One entry per harness: every per-harness fact, and no behaviour. */
|
|
@@ -221,6 +227,9 @@ export declare const HARNESS_SPECS: {
|
|
|
221
227
|
supportCheck: {
|
|
222
228
|
args: string[];
|
|
223
229
|
patterns: RegExp[];
|
|
230
|
+
variants: string[];
|
|
231
|
+
unknownVariant: RegExp;
|
|
232
|
+
timeoutMs: number;
|
|
224
233
|
unavailableMessage: string;
|
|
225
234
|
};
|
|
226
235
|
signInViaCliLogin: true;
|
package/dist/lib/harness-spec.js
CHANGED
|
@@ -33,9 +33,8 @@ const GROK_HTTP_UNAVAILABLE_MESSAGE = 'Could not confirm this Grok installation
|
|
|
33
33
|
const GROK_SIGN_IN_HINT = 'TinyFish is registered in Grok. Open Grok, run `/mcps`, select tinyfish, and press `i` to ' +
|
|
34
34
|
'sign in.';
|
|
35
35
|
const OPENCLAW_SKILL_UNAVAILABLE_MESSAGE = 'Could not confirm this OpenClaw installation supports global skill installation: `openclaw ' +
|
|
36
|
-
'skills install --help` did not list `--global
|
|
37
|
-
'
|
|
38
|
-
'--global --acknowledge-clawhub-risk`.' +
|
|
36
|
+
'skills install --help` did not list `--global`. Update OpenClaw and retry, or install ' +
|
|
37
|
+
'manually with `openclaw skills install @tinyfish/tinyfish --global`.' +
|
|
39
38
|
SUPPORT_CHECK_DEBUG_HINT;
|
|
40
39
|
const OPENCODE_MCP_ADD_UNAVAILABLE_MESSAGE = 'Could not confirm this OpenCode installation supports one-command MCP setup: `opencode mcp ' +
|
|
41
40
|
'add --help` did not list `--url`. Update OpenCode and retry, or add TinyFish manually with ' +
|
|
@@ -202,7 +201,12 @@ export const HARNESS_SPECS = {
|
|
|
202
201
|
reloadAction: 'restart it',
|
|
203
202
|
supportCheck: {
|
|
204
203
|
args: ['skills', 'install', '--help'],
|
|
205
|
-
patterns: [/--global(?:\s|$)
|
|
204
|
+
patterns: [/--global(?:\s|$)/],
|
|
205
|
+
// Renamed in 2026.8.1; absent before 2026.7, where no trust gate existed.
|
|
206
|
+
variants: ['--acknowledge-install-policy-warning', '--acknowledge-clawhub-risk'],
|
|
207
|
+
unknownVariant: /--acknowledge-[a-z-]+/,
|
|
208
|
+
// 212MB dist; win32 cold starts drove most of the 10s timeouts.
|
|
209
|
+
timeoutMs: 30_000,
|
|
206
210
|
unavailableMessage: OPENCLAW_SKILL_UNAVAILABLE_MESSAGE,
|
|
207
211
|
},
|
|
208
212
|
signInViaCliLogin: true,
|
|
@@ -3,8 +3,8 @@ import spawn from 'cross-spawn';
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { loadConfig } from './auth.js';
|
|
5
5
|
import { captureStdio, capturedOutput, replay, SKILL_INSTALL_TIMEOUT_MS, STEP_MAX_BUFFER, } from './cli-install.js';
|
|
6
|
-
import { CURSOR_SKILL_TARGET, NATIVE_MCP_CLIENTS,
|
|
7
|
-
import {
|
|
6
|
+
import { CURSOR_SKILL_TARGET, NATIVE_MCP_CLIENTS, OPENCLAW, openclawSkillInstallArgs, } from './connect-clients.js';
|
|
7
|
+
import { ConnectInterruptedError, ConnectStepError, probeSupportVariant, spawnFailureDetail, spawnStepError, throwIfInterrupted, } from './connect-runtime.js';
|
|
8
8
|
import { CLI_AGENT_IDENTITY } from './constants.js';
|
|
9
9
|
import { errLine } from './output.js';
|
|
10
10
|
// Supports Hermes without node:util.styleText, so the installer still runs on Node 20.11.
|
|
@@ -128,16 +128,37 @@ export function updateWebSkill({ verbose }) {
|
|
|
128
128
|
refreshed = reinstallOpenClawSkill(verbose) || refreshed;
|
|
129
129
|
return refreshed;
|
|
130
130
|
}
|
|
131
|
+
/** OpenClaw rejects an unknown ack flag, so never guess. */
|
|
132
|
+
function openclawAckFlag() {
|
|
133
|
+
try {
|
|
134
|
+
return { ok: true, flag: probeSupportVariant(OPENCLAW) };
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
// Ctrl+C during the probe must still abort the upgrade, not skip a step.
|
|
138
|
+
if (error instanceof ConnectInterruptedError)
|
|
139
|
+
throw error;
|
|
140
|
+
// A stale context entry must not fail an otherwise successful upgrade.
|
|
141
|
+
if (error instanceof ConnectStepError) {
|
|
142
|
+
const detail = error.failureReason === 'harness_not_installed'
|
|
143
|
+
? 'OpenClaw is not on PATH.'
|
|
144
|
+
: error.message;
|
|
145
|
+
return { ok: false, reason: `${detail} Its TinyFish skill was left unchanged.` };
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
reason: "Could not read OpenClaw's skill installer options, so its TinyFish skill was left unchanged.",
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
131
153
|
function reinstallOpenClawSkill(verbose) {
|
|
132
154
|
if (verbose)
|
|
133
155
|
errLine('Refreshing the TinyFish skill in OpenClaw...');
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (commandNotFound(result.error)) {
|
|
138
|
-
errLine('OpenClaw is not on PATH, so its TinyFish skill was left unchanged.');
|
|
156
|
+
const ack = openclawAckFlag();
|
|
157
|
+
if (!ack.ok) {
|
|
158
|
+
errLine(ack.reason);
|
|
139
159
|
return false;
|
|
140
160
|
}
|
|
161
|
+
const result = spawn.sync('openclaw', openclawSkillInstallArgs(ack.flag), captureStdio(verbose));
|
|
141
162
|
if (result.error || result.status !== 0) {
|
|
142
163
|
throwIfInterrupted(result);
|
|
143
164
|
replay(capturedOutput(result));
|