@tiny-fish/cli 0.42.1 → 0.45.1-next.352

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.
Files changed (38) hide show
  1. package/README.md +11 -7
  2. package/dist/commands/auth.js +11 -1
  3. package/dist/commands/connect.js +112 -50
  4. package/dist/commands/doctor.js +4 -7
  5. package/dist/commands/run.js +5 -2
  6. package/dist/lib/auth.d.ts +2 -1
  7. package/dist/lib/auth.js +3 -1
  8. package/dist/lib/client.d.ts +2 -1
  9. package/dist/lib/client.js +50 -9
  10. package/dist/lib/connect-all-auth.js +3 -0
  11. package/dist/lib/connect-all-uninstall.js +6 -4
  12. package/dist/lib/connect-all.js +10 -4
  13. package/dist/lib/connect-clients.d.ts +13 -8
  14. package/dist/lib/connect-clients.js +51 -27
  15. package/dist/lib/constants.d.ts +2 -0
  16. package/dist/lib/constants.js +2 -0
  17. package/dist/lib/doctor-checks.js +4 -0
  18. package/dist/lib/doctor-report.d.ts +2 -1
  19. package/dist/lib/doctor-report.js +1 -2
  20. package/dist/lib/doctor-telemetry.js +0 -3
  21. package/dist/lib/harness-detect.d.ts +1 -0
  22. package/dist/lib/harness-detect.js +1 -0
  23. package/dist/lib/harness-spec.d.ts +4 -0
  24. package/dist/lib/harness-spec.js +3 -0
  25. package/dist/lib/hermes-config.d.ts +4 -0
  26. package/dist/lib/hermes-config.js +12 -3
  27. package/dist/lib/hermes-plugin.d.ts +4 -5
  28. package/dist/lib/hermes-plugin.js +92 -36
  29. package/dist/lib/mcp-json-config.d.ts +4 -2
  30. package/dist/lib/mcp-json-config.js +20 -10
  31. package/dist/lib/omp-config.js +5 -3
  32. package/dist/lib/output.js +7 -1
  33. package/dist/lib/registration-detect.js +12 -4
  34. package/dist/lib/setup-telemetry.d.ts +12 -2
  35. package/dist/lib/setup-telemetry.js +1 -1
  36. package/dist/lib/verify.d.ts +1 -1
  37. package/dist/lib/verify.js +29 -9
  38. package/package.json +2 -2
@@ -6,19 +6,15 @@ import spawn from 'cross-spawn';
6
6
  import { z } from 'zod';
7
7
  import { capturedOutput, replay, SKILL_INSTALL_TIMEOUT_MS, STEP_MAX_BUFFER, } from './cli-install.js';
8
8
  import { commandNotFound, ConnectStepError, spawnStepError, throwIfInterrupted, } from './connect-runtime.js';
9
- import { HARNESS_PROBE_TIMEOUT_MS } from './constants.js';
9
+ import { HARNESS_PROBE_TIMEOUT_MS, TINYFISH_ACCESS_MODE_HEADER, TINYFISH_KEYLESS_ACCESS_MODE, } from './constants.js';
10
10
  import { HERMES_HEADER_TEMPLATE, HERMES_MCP_SERVER_KEY } from './hermes-config.js';
11
11
  import { errLine, parseJson } from './output.js';
12
- // Bump per CLI release.
13
- export const HERMES_PLUGIN_SHA = '496cd63fefd982bbaa8a85ce78ef2270d700984f';
14
- // Bump with the SHA; the source is `hermes/plugin.yaml` at that commit.
15
- export const HERMES_PLUGIN_VERSION = '0.1.0';
16
- // Manifest `name:`, not the repo subdir; `plugins uninstall` keys on it.
17
- const PLUGIN_SOURCE = 'tinyfish-io/tinyfish-web-agent-integrations/hermes';
18
- const PLUGIN_REPO_URL = 'https://github.com/tinyfish-io/tinyfish-web-agent-integrations.git';
19
- export const HERMES_PLUGIN_MANUAL_INSTALL = `hermes plugins install ${PLUGIN_SOURCE} --ref ${HERMES_PLUGIN_SHA} --enable`;
20
- // Anchored to --ref: another rejected flag would fail the clone path too.
12
+ // Resolved at connect time, so plugin releases need no CLI release.
13
+ export const HERMES_PLUGIN_PACKAGE = '@tiny-fish/hermes';
14
+ // Anchored to --ref: an older Hermes rejects the flag and needs the plain URL.
21
15
  const REF_UNSUPPORTED_PATTERN = /unrecognized arguments:[^\n]*--ref/;
16
+ // Tracks the repo head; npm @latest can trail a merge by one publish run.
17
+ export const HERMES_PLUGIN_MANUAL_INSTALL = 'hermes plugins install tinyfish-io/tinyfish-web-agent-integrations/hermes --enable';
22
18
  function installSpawnEnv(home, apiKey) {
23
19
  // Hermes' install-time requires_env check reads os.environ, not our .env seed.
24
20
  return { ...process.env, HERMES_HOME: home, TINYFISH_API_KEY: apiKey };
@@ -42,42 +38,102 @@ function runGit(args) {
42
38
  timeout: SKILL_INSTALL_TIMEOUT_MS,
43
39
  });
44
40
  if (!result.error && result.status === 0)
45
- return;
41
+ return result;
46
42
  if (commandNotFound(result.error)) {
47
- throw new ConnectStepError('git is required to stage the pinned plugin; install git and run connect again', 'command_not_found');
43
+ throw new ConnectStepError('git is required to stage the plugin for Hermes; install git and run connect again', 'command_not_found');
48
44
  }
49
45
  // Built first: its interrupt check must run before any replay.
50
46
  const error = spawnStepError('Could not stage the TinyFish Hermes plugin', result);
51
47
  replay(capturedOutput(result));
52
48
  throw error;
53
49
  }
54
- // Pre-`--ref` Hermes: stage the pin ourselves, mirroring `--ref` internals.
55
- function installFromClone(env) {
56
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tinyfish-hermes-plugin-'));
50
+ function runNpmDownload(tmp) {
51
+ // --prefix both downloads and extracts, so no tar dependency anywhere.
52
+ const result = spawn.sync('npm', [
53
+ 'install',
54
+ `${HERMES_PLUGIN_PACKAGE}@latest`,
55
+ '--prefix',
56
+ tmp,
57
+ // Lifecycle scripts would run arbitrary code with the key in env.
58
+ '--ignore-scripts',
59
+ '--no-save',
60
+ '--no-audit',
61
+ '--no-fund',
62
+ '--loglevel=error',
63
+ ], { encoding: 'utf8', maxBuffer: STEP_MAX_BUFFER, timeout: SKILL_INSTALL_TIMEOUT_MS });
64
+ if (!result.error && result.status === 0)
65
+ return;
66
+ if (commandNotFound(result.error)) {
67
+ throw new ConnectStepError(`npm is required to download the TinyFish Hermes plugin; to install it yourself run: ${HERMES_PLUGIN_MANUAL_INSTALL}`, 'command_not_found');
68
+ }
69
+ // Built first: its interrupt check must run before any replay.
70
+ const error = spawnStepError(`Could not download ${HERMES_PLUGIN_PACKAGE} from npm; to install it yourself run: ${HERMES_PLUGIN_MANUAL_INSTALL}`, result);
71
+ replay(capturedOutput(result));
72
+ throw error;
73
+ }
74
+ function stagedPluginVersion(dir) {
57
75
  try {
58
- runGit(['clone', '--depth', '1', PLUGIN_REPO_URL, tmp]);
59
- runGit(['-C', tmp, 'fetch', '--depth', '1', 'origin', HERMES_PLUGIN_SHA]);
60
- runGit(['-C', tmp, 'checkout', '--detach', HERMES_PLUGIN_SHA]);
61
- return runPluginInstall([`${pathToFileURL(tmp).href}#hermes`], env);
76
+ const parsed = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
77
+ const version = parsed.version;
78
+ return typeof version === 'string' && version ? version : undefined;
62
79
  }
63
- finally {
64
- fs.rmSync(tmp, { recursive: true, force: true });
80
+ catch {
81
+ return undefined;
65
82
  }
66
83
  }
84
+ // Hermes installs plugins only by git clone, so the payload gets a throwaway repo.
85
+ function stagePluginFromNpm(tmp) {
86
+ runNpmDownload(tmp);
87
+ const dir = path.join(tmp, 'node_modules', ...HERMES_PLUGIN_PACKAGE.split('/'));
88
+ // Empty hooksPath: a global hooks dir or init template must not run here.
89
+ const hooksDir = path.join(tmp, 'git-hooks');
90
+ fs.mkdirSync(hooksDir);
91
+ runGit(['-C', dir, 'init', '--quiet']);
92
+ runGit(['-C', dir, 'add', '-A']);
93
+ // Inline identity: fresh machines have no git config and commit fails without it.
94
+ runGit([
95
+ '-C',
96
+ dir,
97
+ '-c',
98
+ `core.hooksPath=${hooksDir}`,
99
+ '-c',
100
+ 'user.name=tinyfish-cli',
101
+ '-c',
102
+ 'user.email=support@tinyfish.io',
103
+ 'commit',
104
+ '--quiet',
105
+ // A global commit.gpgSign would summon a signer this commit must not need.
106
+ '--no-gpg-sign',
107
+ '-m',
108
+ 'stage @tiny-fish/hermes for install',
109
+ ]);
110
+ const sha = String(runGit(['-C', dir, 'rev-parse', 'HEAD']).stdout ?? '').trim();
111
+ return { dir, sha, version: stagedPluginVersion(dir) };
112
+ }
67
113
  export function installHermesPlugin(home, apiKey, { verbose }) {
68
114
  errLine('Installing the TinyFish web plugin in Hermes...');
69
115
  const env = installSpawnEnv(home, apiKey);
70
- // --ref always: a same-source reinstall without it keeps the previous pin.
71
- const primary = runPluginInstall([PLUGIN_SOURCE, '--ref', HERMES_PLUGIN_SHA], env);
72
- const result = refUnsupported(primary) ? installFromClone(env) : primary;
73
- if (result.error || result.status !== 0) {
74
- // A declined security scan exits 1 here, and reconnecting repeats it.
75
- const error = spawnStepError(`Could not install the TinyFish web plugin in Hermes; to install it yourself run: ${HERMES_PLUGIN_MANUAL_INSTALL}`, result);
76
- replay(capturedOutput(result));
77
- throw error;
116
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tinyfish-hermes-plugin-'));
117
+ try {
118
+ const staged = stagePluginFromNpm(tmp);
119
+ const url = pathToFileURL(staged.dir).href;
120
+ // --ref: a prior pinned install rejects an unpinned reinstall of the same name.
121
+ const primary = runPluginInstall([url, '--ref', staged.sha], env);
122
+ // A pre---ref Hermes never pins, so the plain URL is safe there.
123
+ const result = refUnsupported(primary) ? runPluginInstall([url], env) : primary;
124
+ if (result.error || result.status !== 0) {
125
+ // A declined security scan exits 1 here, and reconnecting repeats it.
126
+ const error = spawnStepError(`Could not install the TinyFish web plugin in Hermes; to install it yourself run: ${HERMES_PLUGIN_MANUAL_INSTALL}`, result);
127
+ replay(capturedOutput(result));
128
+ throw error;
129
+ }
130
+ if (verbose)
131
+ replay(capturedOutput(result));
132
+ return staged.version;
133
+ }
134
+ finally {
135
+ fs.rmSync(tmp, { recursive: true, force: true });
78
136
  }
79
- if (verbose)
80
- replay(capturedOutput(result));
81
137
  }
82
138
  function hermesConfig(args, home) {
83
139
  return spawn.sync('hermes', ['config', ...args], {
@@ -97,15 +153,15 @@ function configWrite(home, args) {
97
153
  throw error;
98
154
  }
99
155
  /** Absent `enabled` reads as enabled, so never write this field-by-field. */
100
- export function writeHermesMcpEntry(home, mcpUrl) {
156
+ export function writeHermesMcpEntry(home, mcpUrl, mode = 'api-key') {
101
157
  const entry = {
102
158
  url: mcpUrl,
103
- headers: { Authorization: HERMES_HEADER_TEMPLATE },
159
+ headers: mode === 'keyless'
160
+ ? { [TINYFISH_ACCESS_MODE_HEADER]: TINYFISH_KEYLESS_ACCESS_MODE }
161
+ : { Authorization: HERMES_HEADER_TEMPLATE },
104
162
  enabled: true,
105
163
  };
106
- const result = hermesConfig(['set', `mcp_servers.${HERMES_MCP_SERVER_KEY}`, JSON.stringify(entry)], home);
107
- // `config set` exits 0 on a bad key, so only an interrupt is worth raising here.
108
- throwIfInterrupted(result);
164
+ configWrite(home, ['set', `mcp_servers.${HERMES_MCP_SERVER_KEY}`, JSON.stringify(entry)]);
109
165
  }
110
166
  /** Retracts our row; an absent `enabled` reads as enabled, so a partial one is live. */
111
167
  export function removeHermesMcpEntry(home) {
@@ -17,11 +17,13 @@ export interface McpJsonWriteResult {
17
17
  repaired?: boolean;
18
18
  }
19
19
  export declare function buildTinyfishServerEntry(mcpUrl: string, apiKey?: string): Record<string, unknown>;
20
+ export declare function buildTinyfishKeylessServerEntry(mcpUrl: string): Record<string, unknown>;
20
21
  /** Dry-run description of the pending write; touches nothing. */
21
- export declare function planWrite(target: McpJsonTarget, mcpUrl: string, apiKey?: string): string;
22
+ export declare function planWrite(target: McpJsonTarget, mcpUrl: string, apiKey?: string, serverEntry?: Record<string, unknown>): string;
22
23
  export interface McpJsonServerEntry {
23
24
  present: boolean;
24
25
  hasApiKeyHeader: boolean;
26
+ keyless?: true;
25
27
  /** Only ever false: harnesses with a disable toggle write it, the rest omit it. */
26
28
  enabled?: false;
27
29
  /** Registered endpoint, so a caller can tell "registered" from "registered at the right place". */
@@ -34,6 +36,6 @@ export interface McpJsonServerEntry {
34
36
  /** Reports the header's shape, never its value. */
35
37
  export declare function readTinyfishEntry(target: McpJsonTarget): McpJsonServerEntry;
36
38
  /** Merges only the target's key; skips unreadable/corrupt files rather than clobber. */
37
- export declare function writeMcpConfig(target: McpJsonTarget, mcpUrl: string, apiKey?: string): McpJsonWriteResult;
39
+ export declare function writeMcpConfig(target: McpJsonTarget, mcpUrl: string, apiKey?: string, serverEntry?: Record<string, unknown>): McpJsonWriteResult;
38
40
  /** Removes only the target's key. */
39
41
  export declare function removeServer(target: McpJsonTarget): McpJsonWriteResult;
@@ -1,5 +1,6 @@
1
1
  import * as fs from 'fs';
2
2
  import { matchesCliKey } from './auth.js';
3
+ import { TINYFISH_ACCESS_MODE_HEADER, TINYFISH_KEYLESS_ACCESS_MODE } from './constants.js';
3
4
  function isPlainRecord(value) {
4
5
  return value !== null && typeof value === 'object' && !Array.isArray(value);
5
6
  }
@@ -7,6 +8,9 @@ function isPlainRecord(value) {
7
8
  export function buildTinyfishServerEntry(mcpUrl, apiKey) {
8
9
  return apiKey ? { url: mcpUrl, headers: { 'X-API-Key': apiKey } } : { url: mcpUrl };
9
10
  }
11
+ export function buildTinyfishKeylessServerEntry(mcpUrl) {
12
+ return { url: mcpUrl, headers: { [TINYFISH_ACCESS_MODE_HEADER]: TINYFISH_KEYLESS_ACCESS_MODE } };
13
+ }
10
14
  function parseMcpJson(raw) {
11
15
  try {
12
16
  const parsed = JSON.parse(raw);
@@ -38,18 +42,21 @@ function readExisting(target) {
38
42
  return { raw, parsed: parsed.value };
39
43
  }
40
44
  /** Dry-run description of the pending write; touches nothing. */
41
- export function planWrite(target, mcpUrl, apiKey) {
45
+ export function planWrite(target, mcpUrl, apiKey, serverEntry) {
42
46
  const existing = readExisting(target);
43
47
  const filePath = target.file();
44
48
  if ('error' in existing) {
45
49
  return `${filePath}: existing file is corrupt (${existing.error}) — would skip and leave it untouched`;
46
50
  }
47
- const authNote = apiKey
48
- ? ' with an API-key header — the key is stored in plaintext in that file (value not shown here)'
49
- : '';
51
+ const authNote = serverEntry
52
+ ? ` with an ${TINYFISH_ACCESS_MODE_HEADER}: ${TINYFISH_KEYLESS_ACCESS_MODE} header`
53
+ : apiKey
54
+ ? ' with an API-key header — the key is stored in plaintext in that file (value not shown here)'
55
+ : '';
56
+ const nextEntry = serverEntry ?? buildTinyfishServerEntry(mcpUrl, apiKey);
50
57
  const servers = existing.parsed.mcpServers;
51
58
  const current = isPlainRecord(servers) ? servers[target.serverKey] : undefined;
52
- if (JSON.stringify(current) === JSON.stringify(buildTinyfishServerEntry(mcpUrl, apiKey))) {
59
+ if (JSON.stringify(current) === JSON.stringify(nextEntry)) {
53
60
  return `${filePath}: already has the ${target.serverKey} MCP entry — no change`;
54
61
  }
55
62
  return existing.raw === undefined
@@ -61,17 +68,21 @@ const ENV_TEMPLATE_VALUE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}$/;
61
68
  /** Reports the header's shape, never its value. */
62
69
  export function readTinyfishEntry(target) {
63
70
  const existing = readExisting(target);
64
- if ('error' in existing)
71
+ if ('error' in existing) {
65
72
  return { present: false, hasApiKeyHeader: false, error: existing.error };
73
+ }
66
74
  const servers = existing.parsed.mcpServers;
67
75
  const entry = isPlainRecord(servers) ? servers[target.serverKey] : undefined;
68
76
  if (!isPlainRecord(entry))
69
77
  return { present: false, hasApiKeyHeader: false };
70
78
  const key = readKeyHeader(entry.headers, target.keyHeader);
71
79
  const templateVar = key ? ENV_TEMPLATE_VALUE.exec(key.value)?.[1] : undefined;
80
+ const accessMode = readKeyHeader(entry.headers, { name: TINYFISH_ACCESS_MODE_HEADER })?.value;
81
+ const keyless = accessMode?.trim().toLowerCase() === TINYFISH_KEYLESS_ACCESS_MODE;
72
82
  return {
73
83
  present: true,
74
84
  hasApiKeyHeader: key !== undefined,
85
+ ...(keyless ? { keyless: true } : {}),
75
86
  ...(entry.enabled === false ? { enabled: false } : {}),
76
87
  ...(matchesCliKey(key?.value) ? { keyMatchesCliKey: true } : {}),
77
88
  ...(templateVar ? { keyTemplateVar: templateVar } : {}),
@@ -114,19 +125,18 @@ function commitServers(target, existing, servers) {
114
125
  return { status: 'written', backupPath };
115
126
  }
116
127
  /** Merges only the target's key; skips unreadable/corrupt files rather than clobber. */
117
- export function writeMcpConfig(target, mcpUrl, apiKey) {
128
+ export function writeMcpConfig(target, mcpUrl, apiKey, serverEntry = buildTinyfishServerEntry(mcpUrl, apiKey)) {
118
129
  const existing = readExisting(target);
119
130
  if ('error' in existing)
120
131
  return { status: 'corrupt_skip', error: existing.error };
121
132
  const servers = isPlainRecord(existing.parsed.mcpServers)
122
133
  ? { ...existing.parsed.mcpServers }
123
134
  : {};
124
- const nextEntry = buildTinyfishServerEntry(mcpUrl, apiKey);
125
- if (JSON.stringify(servers[target.serverKey]) === JSON.stringify(nextEntry)) {
135
+ if (JSON.stringify(servers[target.serverKey]) === JSON.stringify(serverEntry)) {
126
136
  return { status: 'unchanged' };
127
137
  }
128
138
  const repaired = target.serverKey in servers;
129
- servers[target.serverKey] = nextEntry;
139
+ servers[target.serverKey] = serverEntry;
130
140
  return { ...commitServers(target, existing, servers), repaired };
131
141
  }
132
142
  /** Removes only the target's key. */
@@ -2,7 +2,7 @@ import * as path from 'path';
2
2
  import spawn from 'cross-spawn';
3
3
  import { ConnectStepError } from './connect-runtime.js';
4
4
  import { HARNESS_PROBE_TIMEOUT_MS } from './constants.js';
5
- import { planWrite, readTinyfishEntry, removeServer, writeMcpConfig, } from './mcp-json-config.js';
5
+ import { buildTinyfishKeylessServerEntry, planWrite, readTinyfishEntry, removeServer, writeMcpConfig, } from './mcp-json-config.js';
6
6
  const UNRESOLVED_REASON = '`omp config path` did not report a config directory';
7
7
  // Cached: every later read must agree with the write.
8
8
  let cachedAgentDir;
@@ -53,14 +53,16 @@ export function readOmpTinyfishEntry() {
53
53
  }
54
54
  /** Merges only the `tinyfish` key; throws when the dir is unresolved. */
55
55
  export function writeOmpMcpConfig(mcpUrl, apiKey) {
56
- return writeMcpConfig(requireOmpTarget(), mcpUrl, apiKey);
56
+ const entry = apiKey ? undefined : buildTinyfishKeylessServerEntry(mcpUrl);
57
+ return writeMcpConfig(requireOmpTarget(), mcpUrl, apiKey, entry);
57
58
  }
58
59
  /** Dry-run description of the pending write; touches nothing. */
59
60
  export function planOmpWrite(mcpUrl, apiKey) {
60
61
  const target = ompMcpTarget();
61
62
  if (!target)
62
63
  return `${UNRESOLVED_REASON} — connect omp would fail the same way`;
63
- return planWrite(target, mcpUrl, apiKey);
64
+ const entry = apiKey ? undefined : buildTinyfishKeylessServerEntry(mcpUrl);
65
+ return planWrite(target, mcpUrl, apiKey, entry);
64
66
  }
65
67
  /** Removes only the `tinyfish` key. */
66
68
  export function removeOmpMcpServer() {
@@ -41,6 +41,7 @@ export function errLine(line) {
41
41
  }
42
42
  const WARN_ON = '\x1b[33m';
43
43
  const WARN_OFF = '\x1b[39m';
44
+ const VAULT_RECONNECT_REQUIRED = 'VAULT_RECONNECT_REQUIRED';
44
45
  /** Advisory stderr line, yellow on a terminal and plain everywhere else so piped output stays clean. */
45
46
  export function warnLine(line) {
46
47
  const text = sanitizeLine(line);
@@ -57,9 +58,14 @@ export function handleApiError(e) {
57
58
  const payload = { error: e.message, status: e.status };
58
59
  if (e.code)
59
60
  payload.code = e.code;
60
- if (e.status === 401)
61
+ if (e.code === VAULT_RECONNECT_REQUIRED) {
62
+ payload.hint =
63
+ 'Reconnect the provider with `tinyfish vault connection add --provider 1password` or `tinyfish vault connection add --provider bitwarden --client-id <client-id>`. Vault secrets are read from TINYFISH_VAULT_TOKEN, TINYFISH_VAULT_CLIENT_SECRET, and TINYFISH_VAULT_MASTER_PASSWORD.';
64
+ }
65
+ else if (e.status === 401) {
61
66
  payload.hint =
62
67
  'Clear the TINYFISH_API_KEY environment variable, run `tinyfish auth login`, then try again.';
68
+ }
63
69
  err(payload);
64
70
  }
65
71
  else if (e instanceof Error) {
@@ -326,9 +326,14 @@ function probeOmp() {
326
326
  : entry.keyMatchesCliKey
327
327
  ? { keyMatchesCliKey: true }
328
328
  : {};
329
+ let authMode = AuthMode.Unknown;
330
+ if (entry.hasApiKeyHeader)
331
+ authMode = AuthMode.ApiKey;
332
+ else if (entry.keyless)
333
+ authMode = AuthMode.Keyless;
329
334
  return {
330
335
  registered: Registered.Yes,
331
- authMode: entry.hasApiKeyHeader ? AuthMode.ApiKey : AuthMode.Unknown,
336
+ authMode,
332
337
  ...(entry.url ? { registeredUrl: entry.url } : {}),
333
338
  ...keyVerdict,
334
339
  };
@@ -391,16 +396,19 @@ function probeHermes() {
391
396
  if (entry.state === 'absent')
392
397
  return NOT_REGISTERED;
393
398
  // `mcp add` saves a disabled entry on a failed connect, which connect refuses too.
394
- const enabled = entry.state === 'enabled' ? {} : { connected: false };
399
+ const details = entry.state === 'enabled' ? {} : { connected: false };
400
+ const registeredUrl = entry.url ? { registeredUrl: entry.url } : {};
395
401
  // The .env key is Hermes-wide; the header template ties it here.
396
402
  const storedKey = entry.usesKeyHeader ? readHermesKey(home) : undefined;
397
403
  if (!storedKey) {
398
- return { registered: Registered.Yes, authMode: AuthMode.OAuth, ...enabled };
404
+ const authMode = entry.keyless ? AuthMode.Keyless : AuthMode.OAuth;
405
+ return { registered: Registered.Yes, authMode, ...details, ...registeredUrl };
399
406
  }
400
407
  return {
401
408
  registered: Registered.Yes,
402
409
  authMode: AuthMode.ApiKey,
403
- ...enabled,
410
+ ...details,
411
+ ...registeredUrl,
404
412
  ...(matchesCliKey(storedKey) ? { keyMatchesCliKey: true } : {}),
405
413
  };
406
414
  }
@@ -163,11 +163,16 @@ declare const doctorCompletedPayloadSchema: z.ZodObject<{
163
163
  detected: z.ZodBoolean;
164
164
  registered: z.ZodEnum<typeof Registered>;
165
165
  auth_mode: z.ZodEnum<typeof AuthMode>;
166
+ recorded_auth_mode: z.ZodOptional<z.ZodEnum<{
167
+ keyless: "keyless";
168
+ "api-key": "api-key";
169
+ oauth: "oauth";
170
+ deferred: "deferred";
171
+ }>>;
166
172
  proves_harness_reach: z.ZodBoolean;
167
173
  }, z.core.$strip>>;
168
174
  duration_ms: z.ZodInt;
169
175
  hermes_plugin_version: z.ZodOptional<z.ZodString>;
170
- hermes_plugin_expected_version: z.ZodOptional<z.ZodString>;
171
176
  }, z.core.$strip>;
172
177
  declare const doctorCouldNotRunPayloadSchema: z.ZodObject<{
173
178
  outcome: z.ZodLiteral<"could_not_run">;
@@ -266,11 +271,16 @@ declare const doctorPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
266
271
  detected: z.ZodBoolean;
267
272
  registered: z.ZodEnum<typeof Registered>;
268
273
  auth_mode: z.ZodEnum<typeof AuthMode>;
274
+ recorded_auth_mode: z.ZodOptional<z.ZodEnum<{
275
+ keyless: "keyless";
276
+ "api-key": "api-key";
277
+ oauth: "oauth";
278
+ deferred: "deferred";
279
+ }>>;
269
280
  proves_harness_reach: z.ZodBoolean;
270
281
  }, z.core.$strip>>;
271
282
  duration_ms: z.ZodInt;
272
283
  hermes_plugin_version: z.ZodOptional<z.ZodString>;
273
- hermes_plugin_expected_version: z.ZodOptional<z.ZodString>;
274
284
  }, z.core.$strip>, z.ZodObject<{
275
285
  outcome: z.ZodLiteral<"could_not_run">;
276
286
  error_class: z.ZodEnum<{
@@ -265,13 +265,13 @@ const doctorCompletedPayloadSchema = z.object({
265
265
  detected: z.boolean(),
266
266
  registered: z.enum(Registered),
267
267
  auth_mode: z.enum(AuthMode),
268
+ recorded_auth_mode: z.enum(['api-key', 'keyless', 'oauth', 'deferred']).optional(),
268
269
  proves_harness_reach: z.boolean(),
269
270
  }))
270
271
  .max(16),
271
272
  duration_ms: z.int().nonnegative().max(3_600_000),
272
273
  /** Bounded here, not on the report: a reject drops only telemetry. */
273
274
  hermes_plugin_version: versionSchema.optional(),
274
- hermes_plugin_expected_version: versionSchema.optional(),
275
275
  });
276
276
  // Detection threw, so there is no report to aggregate: enumerated outcome and timing only.
277
277
  const doctorCouldNotRunPayloadSchema = z.object({
@@ -9,6 +9,6 @@ export interface VerifyResult {
9
9
  status?: number;
10
10
  }
11
11
  /** Reachability check. Verify failure is a warning, never install failure. */
12
- export declare function verifyMcpHealth(mcpUrl: string): Promise<VerifyResult>;
12
+ export declare function verifyMcpHealth(mcpUrl: string, keyless?: boolean): Promise<VerifyResult>;
13
13
  /** Authenticated check — where the CLI holds the key (Cursor, OpenClaw, Hermes). */
14
14
  export declare function verifyMcpAuth(apiKey: string, apiBaseUrl?: string): Promise<VerifyResult>;
@@ -1,6 +1,16 @@
1
1
  import { listRuns } from './client.js';
2
+ import { TINYFISH_ACCESS_MODE_HEADER, TINYFISH_KEYLESS_ACCESS_MODE } from './constants.js';
2
3
  import { ApiError } from './output.js';
4
+ import { z } from 'zod';
3
5
  const VERIFY_TIMEOUT_MS = 5_000;
6
+ const keylessInitializeSchema = z.object({
7
+ jsonrpc: z.literal('2.0'),
8
+ id: z.literal(1),
9
+ result: z.object({
10
+ capabilities: z.object({}),
11
+ serverInfo: z.object({ name: z.literal('tinyfish') }),
12
+ }),
13
+ });
4
14
  // `fetch` collapses every network fault to "fetch failed" and hides the cause on `e.cause`.
5
15
  function networkCode(e) {
6
16
  const cause = e?.cause;
@@ -32,24 +42,34 @@ function authCode(e) {
32
42
  return 'rate limited';
33
43
  return e.status >= 500 ? 'TinyFish returned a server error' : `the API returned HTTP ${e.status}`;
34
44
  }
45
+ function failedHealth(reason) {
46
+ return { depth: 'health', ok: false, reason, code: reason };
47
+ }
35
48
  /** Reachability check. Verify failure is a warning, never install failure. */
36
- export async function verifyMcpHealth(mcpUrl) {
49
+ export async function verifyMcpHealth(mcpUrl, keyless = false) {
37
50
  try {
38
51
  const response = await fetch(mcpUrl, {
39
- method: 'GET',
52
+ method: keyless ? 'POST' : 'GET',
53
+ ...(keyless
54
+ ? {
55
+ headers: {
56
+ [TINYFISH_ACCESS_MODE_HEADER]: TINYFISH_KEYLESS_ACCESS_MODE,
57
+ 'Content-Type': 'application/json',
58
+ Accept: 'application/json, text/event-stream',
59
+ },
60
+ body: '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}',
61
+ }
62
+ : {}),
40
63
  // Redirect (captive portal, typo'd --url) is not healthy.
41
64
  redirect: 'error',
42
65
  signal: AbortSignal.timeout(VERIFY_TIMEOUT_MS),
43
66
  });
44
67
  // MCP rejects bare GET with 4xx — still proves endpoint routed.
45
- if (response.status >= 500) {
46
- return {
47
- depth: 'health',
48
- ok: false,
49
- reason: `endpoint returned HTTP ${response.status}`,
50
- code: `the endpoint returned HTTP ${response.status}`,
51
- };
68
+ if (response.status >= 500 || (keyless && response.status !== 200)) {
69
+ return failedHealth(`endpoint returned HTTP ${response.status}`);
52
70
  }
71
+ if (keyless && !keylessInitializeSchema.safeParse(await response.json()).success)
72
+ return failedHealth('endpoint returned an invalid MCP initialize response');
53
73
  return { depth: 'health', ok: true };
54
74
  }
55
75
  catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.42.1",
3
+ "version": "0.45.1-next.352",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -35,7 +35,7 @@
35
35
  "format:check": "oxfmt --check ."
36
36
  },
37
37
  "dependencies": {
38
- "@tiny-fish/sdk": "^0.6.0",
38
+ "@tiny-fish/sdk": "^0.7.0",
39
39
  "commander": "^12.0.0",
40
40
  "cross-spawn": "^7.0.6",
41
41
  "tldts": "^6.1.86",