@tiny-fish/cli 0.30.2-next.259 → 0.30.2-next.264
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/batch.js +140 -141
- package/dist/commands/connect.d.ts +0 -1
- package/dist/commands/connect.js +71 -30
- package/dist/commands/doctor.js +5 -15
- package/dist/commands/profile.d.ts +2 -1
- package/dist/commands/profile.js +49 -45
- package/dist/commands/runs.js +194 -192
- package/dist/lib/auth.js +15 -13
- package/dist/lib/cli-install.d.ts +2 -3
- package/dist/lib/cli-install.js +1 -3
- package/dist/lib/connect-all.js +61 -6
- package/dist/lib/connect-clients.d.ts +23 -0
- package/dist/lib/connect-clients.js +55 -4
- package/dist/lib/connect-fallback.js +57 -29
- package/dist/lib/connect-picker.js +25 -8
- package/dist/lib/connect-runtime.d.ts +6 -0
- package/dist/lib/connect-runtime.js +28 -9
- package/dist/lib/hermes-config.d.ts +18 -0
- package/dist/lib/hermes-config.js +70 -0
- package/dist/lib/hermes-env.d.ts +6 -0
- package/dist/lib/hermes-env.js +50 -5
- package/dist/lib/mcp-endpoint.d.ts +4 -0
- package/dist/lib/mcp-endpoint.js +15 -0
- package/dist/lib/registration-detect.js +33 -7
- package/dist/lib/verify.d.ts +1 -1
- package/dist/lib/verify.js +1 -1
- package/package.json +2 -1
|
@@ -2,9 +2,11 @@ import spawn from 'cross-spawn';
|
|
|
2
2
|
import { SKILL_INSTALL_TIMEOUT_MS } from './cli-install.js';
|
|
3
3
|
import { detectHumanInitiated } from './harness.js';
|
|
4
4
|
import { harnessConfigPath } from './harness-detect.js';
|
|
5
|
-
import { errLine, sanitizeLine } from './output.js';
|
|
6
|
-
import { spawnStepError } from './connect-runtime.js';
|
|
5
|
+
import { errLine, sanitizeLine, warnLine } from './output.js';
|
|
6
|
+
import { ConnectStepError, spawnStepError, } from './connect-runtime.js';
|
|
7
7
|
import { TINYFISH_API_KEY_VAR } from './constants.js';
|
|
8
|
+
import { HERMES_KEY_VAR, captureHermesKeyRestore, hermesEnvPath, resolveHermesHome, writeHermesKey, } from './hermes-env.js';
|
|
9
|
+
import { hermesConfigPath, readHermesEntry } from './hermes-config.js';
|
|
8
10
|
const HERMES_SEED_TIMEOUT_MS = 120_000;
|
|
9
11
|
// An old install is only one reason a flag can go unseen, so no message asserts the cause.
|
|
10
12
|
const SUPPORT_CHECK_DEBUG_HINT = ' Set TINYFISH_DEBUG=1 and retry to print the help output TinyFish read.';
|
|
@@ -242,6 +244,48 @@ function launchHermesWalkthrough() {
|
|
|
242
244
|
}
|
|
243
245
|
handOverTerminal('hermes', ['--resume', sessionId], 'Hermes');
|
|
244
246
|
}
|
|
247
|
+
/** `mcp add` exits 0 after saving nothing, or a disabled entry. */
|
|
248
|
+
export function hermesRegistrationEnabled(home) {
|
|
249
|
+
const entry = readHermesEntry(home);
|
|
250
|
+
if (entry.state === 'enabled')
|
|
251
|
+
return { ok: true };
|
|
252
|
+
// Each of these is a different repair, so none of them share wording.
|
|
253
|
+
const detail = {
|
|
254
|
+
disabled: 'the entry was saved disabled',
|
|
255
|
+
absent: 'no tinyfish entry was saved',
|
|
256
|
+
unreadable: `${hermesConfigPath(home)} could not be read`,
|
|
257
|
+
unparseable: `${hermesConfigPath(home)} could not be parsed`,
|
|
258
|
+
}[entry.state];
|
|
259
|
+
return { ok: false, detail };
|
|
260
|
+
}
|
|
261
|
+
function seedHermesKey(apiKey) {
|
|
262
|
+
const home = resolveHermesHome();
|
|
263
|
+
// A plain Error settles unexpected_error; cli_connect_stage is load-bearing.
|
|
264
|
+
if (!home) {
|
|
265
|
+
throw new ConnectStepError("Could not determine Hermes' home directory from `hermes dump`", 'invalid_config');
|
|
266
|
+
}
|
|
267
|
+
const restore = captureHermesKeyRestore(home);
|
|
268
|
+
writeHermesKey(home, apiKey);
|
|
269
|
+
return {
|
|
270
|
+
// Pins HERMES_HOME so a sticky profile can't shadow this key.
|
|
271
|
+
env: { ...process.env, HERMES_HOME: home },
|
|
272
|
+
// Three: a failed `mcp remove` adds an extra Overwrite prompt.
|
|
273
|
+
stdinInput: 'y\ny\ny\n',
|
|
274
|
+
note: `Wrote ${HERMES_KEY_VAR} to ${hermesEnvPath(home)} — Hermes reads the key from there.`,
|
|
275
|
+
rollback: () => rollbackHermesKey(home, restore),
|
|
276
|
+
confirmRegistered: () => hermesRegistrationEnabled(home),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
// A failed re-run must not cost a working key.
|
|
280
|
+
function rollbackHermesKey(home, restore) {
|
|
281
|
+
try {
|
|
282
|
+
// The next `hermes mcp add` silently reuses whatever value survives here.
|
|
283
|
+
restore();
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
warnLine(`Could not restore ${HERMES_KEY_VAR} in ${hermesEnvPath(home)}; check it by hand.`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
245
289
|
export const HERMES = {
|
|
246
290
|
command: 'hermes',
|
|
247
291
|
connectClient: 'hermes',
|
|
@@ -250,14 +294,20 @@ export const HERMES = {
|
|
|
250
294
|
supportCheck: {
|
|
251
295
|
args: ['mcp', 'add', '--help'],
|
|
252
296
|
patterns: [/--auth\s+\{[^}]*oauth[^}]*\}/],
|
|
297
|
+
keyAuthPattern: /--auth\s+\{[^}]*header[^}]*\}/,
|
|
253
298
|
unavailableMessage: HERMES_OAUTH_UNAVAILABLE_MESSAGE,
|
|
254
299
|
},
|
|
255
|
-
// No
|
|
300
|
+
// No envVar: key goes into Hermes' .env, not the shell.
|
|
301
|
+
keyAuth: {
|
|
302
|
+
addArgs: (mcpUrl) => [...urlAddArgs(mcpUrl), '--auth', 'header'],
|
|
303
|
+
seedKey: seedHermesKey,
|
|
304
|
+
},
|
|
256
305
|
removals: [{ args: ['mcp', 'remove', 'tinyfish'], label: 'TinyFish registration from Hermes' }],
|
|
257
306
|
// Hermes completes OAuth while adding the server; a separate `mcp login` would authenticate twice.
|
|
258
307
|
oauthInAdd: true,
|
|
259
308
|
addArgs: (mcpUrl) => [...urlAddArgs(mcpUrl), '--auth', 'oauth'],
|
|
260
309
|
launchWalkthrough: launchHermesWalkthrough,
|
|
310
|
+
postConnectNote: 'Restart your Hermes session to pick up TinyFish — Hermes discovers MCP servers at startup.',
|
|
261
311
|
};
|
|
262
312
|
// OpenCode's TUI takes a positional as a project directory, so a bare `opencode "<prompt>"`
|
|
263
313
|
// would be read as a folder. The `--prompt` flag seeds the interactive TUI with a first message
|
|
@@ -338,8 +388,9 @@ export const HARNESS_CAPABILITIES = {
|
|
|
338
388
|
},
|
|
339
389
|
hermes: {
|
|
340
390
|
signInViaCliLogin: false,
|
|
391
|
+
// Stays false: keyed already covers auth; true breaks keyless installs.
|
|
341
392
|
canVerifyAuth: false,
|
|
342
|
-
keyHeldByCli:
|
|
393
|
+
keyHeldByCli: true,
|
|
343
394
|
cliWritesConfig: false,
|
|
344
395
|
authDeferredAtInstall: false,
|
|
345
396
|
},
|
|
@@ -49,12 +49,10 @@ export function createStdinPrompt() {
|
|
|
49
49
|
}
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
|
-
|
|
53
|
-
export async function runCliFallback(opts) {
|
|
54
|
-
const { hooks, prompt } = opts;
|
|
55
|
-
hooks?.onStepStart?.('cli_install');
|
|
52
|
+
function installStep(opts) {
|
|
56
53
|
try {
|
|
57
54
|
installTinyFishCli({ verbose: opts.verbose, announce: true });
|
|
55
|
+
return null;
|
|
58
56
|
}
|
|
59
57
|
catch (error) {
|
|
60
58
|
if (error instanceof ConnectInterruptedError)
|
|
@@ -63,8 +61,8 @@ export async function runCliFallback(opts) {
|
|
|
63
61
|
outLine(`Retry: ${opts.retryCommand}`);
|
|
64
62
|
return 'cli_install_failed';
|
|
65
63
|
}
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
}
|
|
65
|
+
function authStep(opts) {
|
|
68
66
|
// Resolved raw: validatedApiKey collapses a malformed key into "no key".
|
|
69
67
|
const rawKey = resolvedApiKey(opts.apiKey);
|
|
70
68
|
if (rawKey && !validateKeyFormat(rawKey)) {
|
|
@@ -73,7 +71,7 @@ export async function runCliFallback(opts) {
|
|
|
73
71
|
outLine(`Retry: ${opts.retryCommand}`);
|
|
74
72
|
return 'invalid_credentials';
|
|
75
73
|
}
|
|
76
|
-
if (!prompt && !rawKey) {
|
|
74
|
+
if (!opts.prompt && !rawKey) {
|
|
77
75
|
outLine(`Retry in a terminal: ${opts.retryCommand}`);
|
|
78
76
|
return 'no_credentials';
|
|
79
77
|
}
|
|
@@ -81,8 +79,9 @@ export async function runCliFallback(opts) {
|
|
|
81
79
|
// Gate-only widening: a config-only key must not re-persist anywhere.
|
|
82
80
|
// Headless has no one to answer a login prompt; never inherit stdio there.
|
|
83
81
|
ensureCliAuthenticated('cli', opts.apiKey ?? process.env[TINYFISH_API_KEY_VAR], {
|
|
84
|
-
interactiveLogin: !!prompt,
|
|
82
|
+
interactiveLogin: !!opts.prompt,
|
|
85
83
|
});
|
|
84
|
+
return null;
|
|
86
85
|
}
|
|
87
86
|
catch (error) {
|
|
88
87
|
if (error instanceof ConnectInterruptedError)
|
|
@@ -93,33 +92,31 @@ export async function runCliFallback(opts) {
|
|
|
93
92
|
: 'Retry: tinyfish auth login');
|
|
94
93
|
return 'authentication_failed';
|
|
95
94
|
}
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
}
|
|
96
|
+
/** null = abandoned; the retry line is already printed. */
|
|
97
|
+
async function promptQuery(prompt) {
|
|
98
98
|
let query;
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
query = (await prompt('What would you like to search? ')).trim();
|
|
102
|
-
}
|
|
103
|
-
catch (error) {
|
|
104
|
-
if (error instanceof ConnectInterruptedError)
|
|
105
|
-
throw error;
|
|
106
|
-
outLine(`Retry: ${SEARCH_RETRY}`);
|
|
107
|
-
return 'abandoned';
|
|
108
|
-
}
|
|
109
|
-
if (!query) {
|
|
110
|
-
outLine(`Retry: ${SEARCH_RETRY}`);
|
|
111
|
-
return 'abandoned';
|
|
112
|
-
}
|
|
99
|
+
try {
|
|
100
|
+
query = (await prompt('What would you like to search? ')).trim();
|
|
113
101
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (error instanceof ConnectInterruptedError)
|
|
104
|
+
throw error;
|
|
105
|
+
outLine(`Retry: ${SEARCH_RETRY}`);
|
|
106
|
+
return null;
|
|
117
107
|
}
|
|
108
|
+
if (!query) {
|
|
109
|
+
outLine(`Retry: ${SEARCH_RETRY}`);
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return query;
|
|
113
|
+
}
|
|
114
|
+
function verifyStep(query, interactive) {
|
|
118
115
|
const search = spawn.sync('tinyfish', ['search', 'query', '--pretty', '--', query], {
|
|
119
116
|
stdio: 'inherit',
|
|
120
117
|
env: { ...process.env, TF_SETUP_VERIFICATION: '1' },
|
|
121
118
|
// A user-driven search must never be cut off.
|
|
122
|
-
timeout:
|
|
119
|
+
timeout: interactive ? undefined : HEADLESS_VERIFY_TIMEOUT_MS,
|
|
123
120
|
});
|
|
124
121
|
if (commandNotFound(search.error)) {
|
|
125
122
|
errLine(TINYFISH_CLI_NOT_FOUND_MESSAGE);
|
|
@@ -131,6 +128,37 @@ export async function runCliFallback(opts) {
|
|
|
131
128
|
outLine(`Retry: ${searchRetry(query)}`);
|
|
132
129
|
return 'verification_failed';
|
|
133
130
|
}
|
|
134
|
-
hooks?.onStepDone?.('cli_verified');
|
|
135
131
|
return 'cli_verified';
|
|
136
132
|
}
|
|
133
|
+
/** Steps print retry lines and return outcomes; only interrupts throw. */
|
|
134
|
+
export async function runCliFallback(opts) {
|
|
135
|
+
const { hooks, prompt } = opts;
|
|
136
|
+
const stepStart = hooks?.onStepStart ?? (() => { });
|
|
137
|
+
const stepDone = hooks?.onStepDone ?? (() => { });
|
|
138
|
+
stepStart('cli_install');
|
|
139
|
+
const installFailure = installStep(opts);
|
|
140
|
+
if (installFailure)
|
|
141
|
+
return installFailure;
|
|
142
|
+
stepDone('cli_installed');
|
|
143
|
+
stepStart('authentication');
|
|
144
|
+
const authFailure = authStep(opts);
|
|
145
|
+
if (authFailure)
|
|
146
|
+
return authFailure;
|
|
147
|
+
stepDone('authenticated');
|
|
148
|
+
stepStart('cli_verification');
|
|
149
|
+
let query;
|
|
150
|
+
if (prompt) {
|
|
151
|
+
const answered = await promptQuery(prompt);
|
|
152
|
+
if (answered === null)
|
|
153
|
+
return 'abandoned';
|
|
154
|
+
query = answered;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
// Headless with a key: a canned query still proves the pipe.
|
|
158
|
+
query = DEFAULT_VERIFICATION_QUERY;
|
|
159
|
+
}
|
|
160
|
+
const outcome = verifyStep(query, !!prompt);
|
|
161
|
+
if (outcome === 'cli_verified')
|
|
162
|
+
stepDone('cli_verified');
|
|
163
|
+
return outcome;
|
|
164
|
+
}
|
|
@@ -19,6 +19,24 @@ function frame(items, checked, cursor) {
|
|
|
19
19
|
const count = checked.filter(Boolean).length;
|
|
20
20
|
return [...rows, '', ` ${enterHint(count, items.length)}`, KEY_HELP, ''].join('\n');
|
|
21
21
|
}
|
|
22
|
+
function keyCommand(key) {
|
|
23
|
+
const name = key.name ?? '';
|
|
24
|
+
// Raw mode eats terminal SIGINT, so Ctrl+C arrives only here.
|
|
25
|
+
if ((key.ctrl && (name === 'c' || name === 'd')) || name === 'q' || name === 'escape') {
|
|
26
|
+
return 'cancel';
|
|
27
|
+
}
|
|
28
|
+
if (name === 'return' || name === 'enter')
|
|
29
|
+
return 'submit';
|
|
30
|
+
if (name === 'up' || name === 'k')
|
|
31
|
+
return 'up';
|
|
32
|
+
if (name === 'down' || name === 'j')
|
|
33
|
+
return 'down';
|
|
34
|
+
if (name === 'space')
|
|
35
|
+
return 'toggle';
|
|
36
|
+
if (name === 'a')
|
|
37
|
+
return 'toggle-all';
|
|
38
|
+
return 'none';
|
|
39
|
+
}
|
|
22
40
|
async function pickWithKeys(items, stdin, write) {
|
|
23
41
|
const checked = items.map(() => true);
|
|
24
42
|
let cursor = 0;
|
|
@@ -38,24 +56,23 @@ async function pickWithKeys(items, stdin, write) {
|
|
|
38
56
|
return await new Promise((resolve, reject) => {
|
|
39
57
|
onKeypress = (_chunk, key = {}) => {
|
|
40
58
|
try {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
if ((key.ctrl && (name === 'c' || name === 'd')) || name === 'q' || name === 'escape') {
|
|
59
|
+
const command = keyCommand(key);
|
|
60
|
+
if (command === 'cancel') {
|
|
44
61
|
resolve('cancel');
|
|
45
62
|
return;
|
|
46
63
|
}
|
|
47
|
-
if (
|
|
64
|
+
if (command === 'submit') {
|
|
48
65
|
const picked = items.filter((_, i) => checked[i]).map((item) => item.harness);
|
|
49
66
|
resolve(picked.length > 0 ? picked : 'cancel');
|
|
50
67
|
return;
|
|
51
68
|
}
|
|
52
|
-
if (
|
|
69
|
+
if (command === 'up')
|
|
53
70
|
cursor = (cursor + items.length - 1) % items.length;
|
|
54
|
-
else if (
|
|
71
|
+
else if (command === 'down')
|
|
55
72
|
cursor = (cursor + 1) % items.length;
|
|
56
|
-
else if (
|
|
73
|
+
else if (command === 'toggle')
|
|
57
74
|
checked[cursor] = !checked[cursor];
|
|
58
|
-
else if (
|
|
75
|
+
else if (command === 'toggle-all')
|
|
59
76
|
checked.fill(!checked.every(Boolean));
|
|
60
77
|
paint();
|
|
61
78
|
}
|
|
@@ -11,6 +11,9 @@ export type CliFallbackOutcome = 'cli_verified' | 'cli_install_failed' | 'no_cre
|
|
|
11
11
|
/** Ctrl+C killed a setup child — abandonment, not error. */
|
|
12
12
|
export declare class ConnectInterruptedError extends Error {
|
|
13
13
|
}
|
|
14
|
+
/** Carried hoisted-install failure an earlier harness already reported. */
|
|
15
|
+
export declare class HoistedFailureReportedError extends Error {
|
|
16
|
+
}
|
|
14
17
|
/** A classified step failure; runGuarded forwards the reason. */
|
|
15
18
|
export declare class ConnectStepError extends Error {
|
|
16
19
|
readonly failureReason: ConnectFailureReason;
|
|
@@ -20,6 +23,9 @@ export declare class ConnectStepError extends Error {
|
|
|
20
23
|
cause?: unknown;
|
|
21
24
|
});
|
|
22
25
|
}
|
|
26
|
+
/** Hoisted install predates this attempt's telemetry; the gap reads ~0 ms. */
|
|
27
|
+
export declare function withStageDuration(error: Error, stageDurationMs: number): Error;
|
|
28
|
+
export declare function stageDurationOf(error: unknown): number | undefined;
|
|
23
29
|
/** A bounded sign-in wait elapsed — unfinished, not broken. */
|
|
24
30
|
export declare class SignInTimeoutError extends ConnectStepError {
|
|
25
31
|
constructor(message: string, cause?: unknown);
|
|
@@ -25,6 +25,9 @@ export const CONNECT_FAILURE_REASONS = [
|
|
|
25
25
|
/** Ctrl+C killed a setup child — abandonment, not error. */
|
|
26
26
|
export class ConnectInterruptedError extends Error {
|
|
27
27
|
}
|
|
28
|
+
/** Carried hoisted-install failure an earlier harness already reported. */
|
|
29
|
+
export class HoistedFailureReportedError extends Error {
|
|
30
|
+
}
|
|
28
31
|
/** A classified step failure; runGuarded forwards the reason. */
|
|
29
32
|
export class ConnectStepError extends Error {
|
|
30
33
|
failureReason;
|
|
@@ -35,6 +38,15 @@ export class ConnectStepError extends Error {
|
|
|
35
38
|
this.harnessVersion = opts?.harnessVersion;
|
|
36
39
|
}
|
|
37
40
|
}
|
|
41
|
+
/** Hoisted install predates this attempt's telemetry; the gap reads ~0 ms. */
|
|
42
|
+
export function withStageDuration(error, stageDurationMs) {
|
|
43
|
+
error.stageDurationMs = stageDurationMs;
|
|
44
|
+
return error;
|
|
45
|
+
}
|
|
46
|
+
export function stageDurationOf(error) {
|
|
47
|
+
const value = error?.stageDurationMs;
|
|
48
|
+
return typeof value === 'number' ? value : undefined;
|
|
49
|
+
}
|
|
38
50
|
class PrerequisiteError extends ConnectStepError {
|
|
39
51
|
}
|
|
40
52
|
/** A bounded sign-in wait elapsed — unfinished, not broken. */
|
|
@@ -127,10 +139,14 @@ export async function runGuarded(state, telemetry, body) {
|
|
|
127
139
|
settle(state, telemetry, 'aborted', { failedStage: state.stage, failureReason: 'timeout' });
|
|
128
140
|
throw error;
|
|
129
141
|
}
|
|
142
|
+
// An earlier attempt owns the shared failure's terminal row.
|
|
143
|
+
if (error instanceof HoistedFailureReportedError)
|
|
144
|
+
throw error;
|
|
130
145
|
settle(state, telemetry, 'failed', {
|
|
131
146
|
failedStage: state.stage,
|
|
132
147
|
failureReason: error instanceof ConnectStepError ? error.failureReason : 'unexpected_error',
|
|
133
148
|
...(error instanceof ConnectStepError ? { harnessVersion: error.harnessVersion } : {}),
|
|
149
|
+
stageDurationMs: stageDurationOf(error),
|
|
134
150
|
});
|
|
135
151
|
throw error;
|
|
136
152
|
}
|
|
@@ -139,6 +155,17 @@ export async function runGuarded(state, telemetry, body) {
|
|
|
139
155
|
await telemetry.flush();
|
|
140
156
|
}
|
|
141
157
|
}
|
|
158
|
+
function throwProbeFailure(client, result) {
|
|
159
|
+
throwIfInterrupted(result);
|
|
160
|
+
// A hung probe is a timeout, not a missing capability.
|
|
161
|
+
if (result.error?.code === 'ETIMEDOUT') {
|
|
162
|
+
throw new ConnectStepError(client.supportCheck.unavailableMessage, 'timeout', {
|
|
163
|
+
cause: result.error,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
// It ran and exited non-zero, so the version is still answerable.
|
|
167
|
+
throw new PrerequisiteError(client.supportCheck.unavailableMessage, 'harness_command_unsupported', { cause: result.error, harnessVersion: probeHarnessVersion(client.command) });
|
|
168
|
+
}
|
|
142
169
|
export function requireCommandSupport(client) {
|
|
143
170
|
const result = spawn.sync(client.command, client.supportCheck.args, {
|
|
144
171
|
encoding: 'utf8',
|
|
@@ -151,15 +178,7 @@ export function requireCommandSupport(client) {
|
|
|
151
178
|
throw new PrerequisiteError(`${client.displayName} is not installed or not available on PATH.`, 'harness_not_installed', { cause: result.error });
|
|
152
179
|
}
|
|
153
180
|
if (result.error || result.status !== 0) {
|
|
154
|
-
|
|
155
|
-
// A hung probe is a timeout, not a missing capability.
|
|
156
|
-
if (result.error?.code === 'ETIMEDOUT') {
|
|
157
|
-
throw new ConnectStepError(client.supportCheck.unavailableMessage, 'timeout', {
|
|
158
|
-
cause: result.error,
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
// It ran and exited non-zero, so the version is still answerable.
|
|
162
|
-
throw new PrerequisiteError(client.supportCheck.unavailableMessage, 'harness_command_unsupported', { cause: result.error, harnessVersion: probeHarnessVersion(client.command) });
|
|
181
|
+
throwProbeFailure(client, result);
|
|
163
182
|
}
|
|
164
183
|
// Belt and braces with the colour env: a client that ignores NO_COLOR still has to match.
|
|
165
184
|
const output = sanitizeLine(`${result.stdout ?? ''}\n${result.stderr ?? ''}`);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Every message about the entry names this path. */
|
|
2
|
+
export declare function hermesConfigPath(home: string): string;
|
|
3
|
+
/** Callers branch on these; each is a different repair. */
|
|
4
|
+
export type HermesEntry = {
|
|
5
|
+
state: 'unreadable';
|
|
6
|
+
} | {
|
|
7
|
+
state: 'unparseable';
|
|
8
|
+
} | {
|
|
9
|
+
state: 'absent';
|
|
10
|
+
} | {
|
|
11
|
+
state: 'disabled';
|
|
12
|
+
usesKeyHeader: boolean;
|
|
13
|
+
} | {
|
|
14
|
+
state: 'enabled';
|
|
15
|
+
usesKeyHeader: boolean;
|
|
16
|
+
};
|
|
17
|
+
/** Connect's gate and doctor's probe share this, so they cannot disagree. */
|
|
18
|
+
export declare function readHermesEntry(home: string): HermesEntry;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parse } from 'yaml';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { HERMES_KEY_VAR } from './hermes-env.js';
|
|
6
|
+
/** Every message about the entry names this path. */
|
|
7
|
+
export function hermesConfigPath(home) {
|
|
8
|
+
return path.join(home, 'config.yaml');
|
|
9
|
+
}
|
|
10
|
+
// Hermes persists the key as an interpolation template (mcp_config.py:174-182).
|
|
11
|
+
const HEADER_TEMPLATE = `Bearer \${${HERMES_KEY_VAR}}`;
|
|
12
|
+
// Foreign file: model only what we read, tolerate the rest.
|
|
13
|
+
const entrySchema = z.looseObject({
|
|
14
|
+
// Anything goes: the runtime defaults unrecognised values to enabled.
|
|
15
|
+
enabled: z.unknown().optional(),
|
|
16
|
+
headers: z.record(z.string(), z.unknown()).optional(),
|
|
17
|
+
});
|
|
18
|
+
const configSchema = z
|
|
19
|
+
.looseObject({ mcp_servers: z.looseObject({ tinyfish: entrySchema.nullish() }).nullish() })
|
|
20
|
+
.nullish();
|
|
21
|
+
const TRUTHY_STRINGS = new Set(['true', '1', 'yes', 'on']);
|
|
22
|
+
const FALSY_STRINGS = new Set(['false', '0', 'no', 'off']);
|
|
23
|
+
/**
|
|
24
|
+
* Mirrors the runtime's _parse_boolish (mcp_tool.py:5656), which decides whether
|
|
25
|
+
* tools are served. `hermes mcp list` coerces differently and disagrees on
|
|
26
|
+
* numbers and "on"; the runtime wins because it is what actually serves.
|
|
27
|
+
*/
|
|
28
|
+
function isEnabled(value) {
|
|
29
|
+
if (value === undefined || value === null)
|
|
30
|
+
return true;
|
|
31
|
+
if (typeof value === 'boolean')
|
|
32
|
+
return value;
|
|
33
|
+
if (typeof value === 'string') {
|
|
34
|
+
const lowered = value.trim().toLowerCase();
|
|
35
|
+
if (TRUTHY_STRINGS.has(lowered))
|
|
36
|
+
return true;
|
|
37
|
+
if (FALSY_STRINGS.has(lowered))
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
/** Only the header template proves this entry uses the seeded key. */
|
|
43
|
+
function hasKeyHeader(headers) {
|
|
44
|
+
const authorization = Object.entries(headers ?? {}).find(([name]) => name.toLowerCase() === 'authorization');
|
|
45
|
+
return authorization?.[1] === HEADER_TEMPLATE;
|
|
46
|
+
}
|
|
47
|
+
/** Connect's gate and doctor's probe share this, so they cannot disagree. */
|
|
48
|
+
export function readHermesEntry(home) {
|
|
49
|
+
let raw;
|
|
50
|
+
try {
|
|
51
|
+
raw = fs.readFileSync(hermesConfigPath(home), 'utf8');
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return { state: 'unreadable' };
|
|
55
|
+
}
|
|
56
|
+
let config;
|
|
57
|
+
try {
|
|
58
|
+
config = configSchema.parse(parse(raw));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return { state: 'unparseable' };
|
|
62
|
+
}
|
|
63
|
+
const entry = config?.mcp_servers?.tinyfish;
|
|
64
|
+
if (!entry)
|
|
65
|
+
return { state: 'absent' };
|
|
66
|
+
const usesKeyHeader = hasKeyHeader(entry.headers);
|
|
67
|
+
return isEnabled(entry.enabled)
|
|
68
|
+
? { state: 'enabled', usesKeyHeader }
|
|
69
|
+
: { state: 'disabled', usesKeyHeader };
|
|
70
|
+
}
|
package/dist/lib/hermes-env.d.ts
CHANGED
|
@@ -2,5 +2,11 @@ export declare const HERMES_PROBE_TIMEOUT_MS = 15000;
|
|
|
2
2
|
/** Asking Hermes beats reimplementing its profile override, which we'd drift from. */
|
|
3
3
|
export declare function resolveHermesHome(): string | undefined;
|
|
4
4
|
export declare const HERMES_KEY_VAR = "MCP_TINYFISH_API_KEY";
|
|
5
|
+
/** Named in every message about the seeded key, so no caller rebuilds the path. */
|
|
6
|
+
export declare function hermesEnvPath(home: string): string;
|
|
5
7
|
export declare function writeHermesKey(home: string, key: string): void;
|
|
8
|
+
/** Doctor needs this to tell key auth from OAuth. */
|
|
9
|
+
export declare function readHermesKey(home: string): string | undefined;
|
|
10
|
+
/** Restores the exact line writeHermesKey replaces, quotes and comment included. */
|
|
11
|
+
export declare function captureHermesKeyRestore(home: string): () => void;
|
|
6
12
|
export declare function removeHermesKey(home: string): boolean;
|
package/dist/lib/hermes-env.js
CHANGED
|
@@ -25,6 +25,10 @@ export function resolveHermesHome() {
|
|
|
25
25
|
return path.isAbsolute(expanded) ? expanded : undefined;
|
|
26
26
|
}
|
|
27
27
|
export const HERMES_KEY_VAR = 'MCP_TINYFISH_API_KEY';
|
|
28
|
+
/** Named in every message about the seeded key, so no caller rebuilds the path. */
|
|
29
|
+
export function hermesEnvPath(home) {
|
|
30
|
+
return path.join(home, '.env');
|
|
31
|
+
}
|
|
28
32
|
const MARKER_HEAD = `# tinyfish-cli: TinyFish MCP key, managed by \`tinyfish connect hermes\``;
|
|
29
33
|
const MARKER_TAIL = '# tinyfish-cli: end';
|
|
30
34
|
// Keys are `sk-tinyfish-<alnum>`; refusing anything else beats shipping unreachable escaping.
|
|
@@ -37,12 +41,18 @@ function definesKey(line) {
|
|
|
37
41
|
function isMarker(line) {
|
|
38
42
|
return line.trim() === MARKER_HEAD || line.trim() === MARKER_TAIL;
|
|
39
43
|
}
|
|
40
|
-
//
|
|
44
|
+
// A leading BOM would otherwise ride into the first key's name.
|
|
41
45
|
function readLines(envPath) {
|
|
42
46
|
if (!fs.existsSync(envPath))
|
|
43
47
|
return [];
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
const raw = fs.readFileSync(envPath);
|
|
49
|
+
// A valid UTF-8 .env has no NUL; every wide encoding does.
|
|
50
|
+
if (raw.includes(0)) {
|
|
51
|
+
throw new Error(`${envPath} contains NUL bytes, so it is a wide encoding or corrupt; TinyFish can only ` +
|
|
52
|
+
`edit a UTF-8 .env. Re-save it as UTF-8 and retry.`);
|
|
53
|
+
}
|
|
54
|
+
return raw
|
|
55
|
+
.toString('utf8')
|
|
46
56
|
.replace(/^\uFEFF/, '')
|
|
47
57
|
.split(/(?<=\n)/);
|
|
48
58
|
}
|
|
@@ -75,7 +85,7 @@ export function writeHermesKey(home, key) {
|
|
|
75
85
|
if (!BARE_SAFE.test(clean)) {
|
|
76
86
|
throw new Error('TinyFish API key contains characters that cannot be written to a .env file');
|
|
77
87
|
}
|
|
78
|
-
const envPath =
|
|
88
|
+
const envPath = hermesEnvPath(home);
|
|
79
89
|
fs.mkdirSync(home, { recursive: true });
|
|
80
90
|
const lines = readLines(envPath);
|
|
81
91
|
const assignment = `${HERMES_KEY_VAR}=${clean}\n`;
|
|
@@ -91,8 +101,43 @@ export function writeHermesKey(home, key) {
|
|
|
91
101
|
// load_env() is last-wins; a surviving duplicate would beat this value.
|
|
92
102
|
writeAtomic(envPath, lines.filter((line, i) => i === index || !definesKey(line)));
|
|
93
103
|
}
|
|
104
|
+
/** Doctor needs this to tell key auth from OAuth. */
|
|
105
|
+
export function readHermesKey(home) {
|
|
106
|
+
const line = readLines(hermesEnvPath(home)).find(definesKey);
|
|
107
|
+
if (!line)
|
|
108
|
+
return undefined;
|
|
109
|
+
// We never write quoted values, so a quoted one isn't ours.
|
|
110
|
+
const raw = line
|
|
111
|
+
.trim()
|
|
112
|
+
.replace(/^export\s+/, '')
|
|
113
|
+
.slice(HERMES_KEY_VAR.length + 1)
|
|
114
|
+
.trim();
|
|
115
|
+
return BARE_SAFE.test(raw) ? raw : undefined;
|
|
116
|
+
}
|
|
117
|
+
/** Restores the exact line writeHermesKey replaces, quotes and comment included. */
|
|
118
|
+
export function captureHermesKeyRestore(home) {
|
|
119
|
+
// Raw, not readHermesKey: its parse drops quoted and comment-trailed values.
|
|
120
|
+
const previous = readLines(hermesEnvPath(home)).find(definesKey);
|
|
121
|
+
return () => {
|
|
122
|
+
if (previous === undefined) {
|
|
123
|
+
removeHermesKey(home);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const envPath = hermesEnvPath(home);
|
|
127
|
+
let restored = false;
|
|
128
|
+
const lines = readLines(envPath).flatMap((line) => {
|
|
129
|
+
if (!definesKey(line))
|
|
130
|
+
return [line];
|
|
131
|
+
if (restored)
|
|
132
|
+
return [];
|
|
133
|
+
restored = true;
|
|
134
|
+
return [previous];
|
|
135
|
+
});
|
|
136
|
+
writeAtomic(envPath, restored ? lines : [...lines, previous]);
|
|
137
|
+
};
|
|
138
|
+
}
|
|
94
139
|
export function removeHermesKey(home) {
|
|
95
|
-
const envPath =
|
|
140
|
+
const envPath = hermesEnvPath(home);
|
|
96
141
|
const lines = readLines(envPath);
|
|
97
142
|
if (!lines.length)
|
|
98
143
|
return false;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
|
|
2
|
+
export declare function endpointOf(url: string): string;
|
|
3
|
+
/** `verifyMcpAuth` hits BASE_URL, so a sandbox key would 401 against prod. */
|
|
4
|
+
export declare function isDefaultEndpoint(mcpUrl: string): boolean;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const DEFAULT_MCP_URL = 'https://agent.tinyfish.ai/mcp';
|
|
2
|
+
// Query strings carry the connect attempt id, so they differ on every healthy install.
|
|
3
|
+
export function endpointOf(url) {
|
|
4
|
+
const parsed = new URL(url);
|
|
5
|
+
return `${parsed.origin}${parsed.pathname.replace(/\/$/, '')}`;
|
|
6
|
+
}
|
|
7
|
+
/** `verifyMcpAuth` hits BASE_URL, so a sandbox key would 401 against prod. */
|
|
8
|
+
export function isDefaultEndpoint(mcpUrl) {
|
|
9
|
+
try {
|
|
10
|
+
return endpointOf(mcpUrl) === endpointOf(DEFAULT_MCP_URL);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -7,6 +7,8 @@ import { NATIVE_BY_HARNESS } from './connect-clients.js';
|
|
|
7
7
|
import { HARNESS_PROBE_TIMEOUT_MS } from './constants.js';
|
|
8
8
|
import { errLine } from './output.js';
|
|
9
9
|
import { readCursorTinyfishEntry } from './cursor-config.js';
|
|
10
|
+
import { readHermesKey, resolveHermesHome } from './hermes-env.js';
|
|
11
|
+
import { readHermesEntry } from './hermes-config.js';
|
|
10
12
|
import { detectInstalledHarnesses, harnessConfigPath, harnessDisplayPath, AuthMode, Registered, } from './harness-detect.js';
|
|
11
13
|
// A list, verified against codex 0.146. A name-keyed map was accepted here too, but every
|
|
12
14
|
// field is optional, so that arm parsed *any* object-of-objects: a wrapper like
|
|
@@ -303,15 +305,39 @@ function fromMcpList(command, output, registeredMode, urlPattern) {
|
|
|
303
305
|
}
|
|
304
306
|
return { registered: Registered.No, authMode: AuthMode.Unknown };
|
|
305
307
|
}
|
|
306
|
-
//
|
|
308
|
+
// `hermes mcp list` only pretty-prints this file, so connect's gate reads it too.
|
|
307
309
|
function probeHermes() {
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
310
|
+
const home = resolveHermesHome();
|
|
311
|
+
if (!home) {
|
|
312
|
+
return {
|
|
313
|
+
registered: Registered.Unknown,
|
|
314
|
+
authMode: AuthMode.Unknown,
|
|
315
|
+
reason: '`hermes dump` did not report a home directory',
|
|
316
|
+
};
|
|
313
317
|
}
|
|
314
|
-
|
|
318
|
+
const entry = readHermesEntry(home);
|
|
319
|
+
if (entry.state === 'unreadable' || entry.state === 'unparseable') {
|
|
320
|
+
return {
|
|
321
|
+
registered: Registered.Unknown,
|
|
322
|
+
authMode: AuthMode.Unknown,
|
|
323
|
+
reason: `Hermes' config.yaml could not be ${entry.state === 'unreadable' ? 'read' : 'parsed'}`,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (entry.state === 'absent')
|
|
327
|
+
return { registered: Registered.No, authMode: AuthMode.Unknown };
|
|
328
|
+
// `mcp add` saves a disabled entry on a failed connect, which connect refuses too.
|
|
329
|
+
const enabled = entry.state === 'enabled' ? {} : { connected: false };
|
|
330
|
+
// The .env key is Hermes-wide; the header template ties it here.
|
|
331
|
+
const storedKey = entry.usesKeyHeader ? readHermesKey(home) : undefined;
|
|
332
|
+
if (!storedKey) {
|
|
333
|
+
return { registered: Registered.Yes, authMode: AuthMode.OAuth, ...enabled };
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
registered: Registered.Yes,
|
|
337
|
+
authMode: AuthMode.ApiKey,
|
|
338
|
+
...enabled,
|
|
339
|
+
...(matchesCliKey(storedKey) ? { keyMatchesCliKey: true } : {}),
|
|
340
|
+
};
|
|
315
341
|
}
|
|
316
342
|
const SKILL_DIR_IS_TINYFISH = /^(?:@tinyfish[/_-])?tinyfish(?![\w-])/i;
|
|
317
343
|
// OpenClaw installs a skill, not an MCP server; the skill shells the CLI, so auth is the CLI key.
|
package/dist/lib/verify.d.ts
CHANGED
|
@@ -10,5 +10,5 @@ export interface VerifyResult {
|
|
|
10
10
|
}
|
|
11
11
|
/** Reachability check. Verify failure is a warning, never install failure. */
|
|
12
12
|
export declare function verifyMcpHealth(mcpUrl: string): Promise<VerifyResult>;
|
|
13
|
-
/** Authenticated check —
|
|
13
|
+
/** Authenticated check — where the CLI holds the key (Cursor, OpenClaw, Hermes). */
|
|
14
14
|
export declare function verifyMcpAuth(apiKey: string): Promise<VerifyResult>;
|