@tiny-fish/cli 0.30.2-next.258 → 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.
@@ -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 `keyAuth`: `hermes mcp add` has no --header, and `--auth header` prompts interactively.
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: false,
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
- /** Steps print retry lines and return outcomes; only interrupts throw. */
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
- hooks?.onStepDone?.('cli_installed');
67
- hooks?.onStepStart?.('authentication');
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
- hooks?.onStepDone?.('authenticated');
97
- hooks?.onStepStart?.('cli_verification');
95
+ }
96
+ /** null = abandoned; the retry line is already printed. */
97
+ async function promptQuery(prompt) {
98
98
  let query;
99
- if (prompt) {
100
- try {
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
- else {
115
- // Headless with a key: a canned query still proves the pipe.
116
- query = DEFAULT_VERIFICATION_QUERY;
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: prompt ? undefined : HEADLESS_VERIFY_TIMEOUT_MS,
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 name = key.name ?? '';
42
- // Raw mode eats terminal SIGINT, so Ctrl+C arrives only here.
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 (name === 'return' || name === 'enter') {
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 (name === 'up' || name === 'k')
69
+ if (command === 'up')
53
70
  cursor = (cursor + items.length - 1) % items.length;
54
- else if (name === 'down' || name === 'j')
71
+ else if (command === 'down')
55
72
  cursor = (cursor + 1) % items.length;
56
- else if (name === 'space')
73
+ else if (command === 'toggle')
57
74
  checked[cursor] = !checked[cursor];
58
- else if (name === 'a')
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
- throwIfInterrupted(result);
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
+ }
@@ -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;
@@ -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
- // utf-8 read keeps the BOM; rewriting it breaks Hermes' first key.
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
- return fs
45
- .readFileSync(envPath, 'utf8')
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 = path.join(home, '.env');
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 = path.join(home, '.env');
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
- // connect only ever writes an OAuth Hermes entry, so `oauth` is a fact, not a detection gap.
308
+ // `hermes mcp list` only pretty-prints this file, so connect's gate reads it too.
307
309
  function probeHermes() {
308
- const args = ['mcp', 'list'];
309
- const probe = runProbe('hermes', args);
310
- if (probe.outcome !== 'ran' || probe.exitCode !== 0) {
311
- const reason = probeReason('hermes', args, probe);
312
- return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason };
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
- return fromMcpList('hermes', probe.output, AuthMode.OAuth);
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.
@@ -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 — only where the CLI holds the key (Cursor, OpenClaw). */
13
+ /** Authenticated check — where the CLI holds the key (Cursor, OpenClaw, Hermes). */
14
14
  export declare function verifyMcpAuth(apiKey: string): Promise<VerifyResult>;