@tiny-fish/cli 0.43.0 → 0.44.0

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.
@@ -11,6 +11,7 @@ export declare function openclawSkillUninstall(): {
11
11
  };
12
12
  export declare const DEFAULT_ONBOARDING_PROMPT: string;
13
13
  export declare const OPENCLAW_ONBOARDING_PROMPT: string;
14
+ export declare const KEYLESS_ONBOARDING_PROMPT: string;
14
15
  export type { SkillAgent };
15
16
  /** Cursor writes its own MCP config, so it has no descriptor to carry this. */
16
17
  export declare const CURSOR_SKILL_TARGET: {
@@ -48,15 +49,17 @@ interface BaseMcpClient extends SupportedCommand, Pick<HarnessSpec, 'skillAgent'
48
49
  /** Writes into the harness's store, so connect verifies the key first. */
49
50
  seedKey?: (apiKey: string) => SeededInstall;
50
51
  };
52
+ keylessAddArgs?: (mcpUrl: string) => string[];
53
+ prepareKeylessInstall?: () => DirectInstall;
51
54
  loginArgs?: string[];
52
55
  removals: {
53
56
  args: string[];
54
57
  label: string;
55
58
  }[];
56
59
  /** Blocking terminal walkthrough; runs only behind the TTY gate. */
57
- launchWalkthrough?: () => void;
60
+ launchWalkthrough?: (prompt: string) => void;
58
61
  /** Own window, never inherits stdio; reports its own outcome. */
59
- detachedLaunch?: () => WalkthroughOutcome;
62
+ detachedLaunch?: (prompt: string) => WalkthroughOutcome;
60
63
  /** Post-install like the skill step: failures warn, never fail the attempt. */
61
64
  extraPostInstall?: (options: {
62
65
  apiKey?: string;
@@ -85,6 +88,10 @@ interface KeyRequiredMcpClient extends BaseMcpClient {
85
88
  loginArgs?: never;
86
89
  }
87
90
  export type NativeMcpClient = OauthCapableMcpClient | KeyRequiredMcpClient;
91
+ export interface DirectInstall {
92
+ home?: string;
93
+ register: (mcpUrl: string) => ReturnType<typeof hermesRegistrationEnabled>;
94
+ }
88
95
  /**
89
96
  * `cmd` re-parses its command line after `/c`, so an unquoted `&` in the query string ends the
90
97
  * command and truncates the URL. Quoting needs windowsVerbatimArguments, or Node escapes the
@@ -92,7 +99,7 @@ export type NativeMcpClient = OauthCapableMcpClient | KeyRequiredMcpClient;
92
99
  */
93
100
  export declare function openExternalUrl(url: string): ReturnType<typeof spawn.sync>;
94
101
  /** Writes exit 0 having saved nothing, a disabled entry, or a keyless one. */
95
- export declare function hermesRegistrationEnabled(home: string): {
102
+ export declare function hermesRegistrationEnabled(home: string, mode?: 'api-key' | 'keyless', expectedUrl?: string): {
96
103
  ok: boolean;
97
104
  detail?: string;
98
105
  tag?: string;
@@ -105,7 +112,7 @@ export declare const OPENCLAW: SupportedCommand;
105
112
  export type WalkthroughOutcome = 'launched' | 'printed';
106
113
  /** Awaited before the handover; track+flush must land pre-block. */
107
114
  type OnWalkthroughDecided = (outcome: WalkthroughOutcome) => void | Promise<void>;
108
- export declare function launchNativeMcpClient(client: NativeMcpClient, onDecided?: OnWalkthroughDecided): Promise<WalkthroughOutcome>;
109
- export declare function launchOmpWalkthrough(onDecided?: OnWalkthroughDecided): Promise<WalkthroughOutcome>;
115
+ export declare function launchNativeMcpClient(client: NativeMcpClient, onDecided?: OnWalkthroughDecided, prompt?: string): Promise<WalkthroughOutcome>;
116
+ export declare function launchOmpWalkthrough(onDecided?: OnWalkthroughDecided, prompt?: string): Promise<WalkthroughOutcome>;
110
117
  export declare function launchPiWalkthrough(onDecided?: OnWalkthroughDecided): Promise<WalkthroughOutcome>;
111
118
  export declare function launchOpenClawWalkthrough(onDecided?: OnWalkthroughDecided): Promise<WalkthroughOutcome>;
@@ -4,7 +4,7 @@ import { detectHumanInitiated } from './harness.js';
4
4
  import { harnessConfigPath } from './harness-detect.js';
5
5
  import { errLine, sanitizeLine, warnLine } from './output.js';
6
6
  import { ConnectInterruptedError, ConnectStepError, spawnStepError, } from './connect-runtime.js';
7
- import { TINYFISH_API_KEY_VAR } from './constants.js';
7
+ import { TINYFISH_ACCESS_MODE_HEADER, TINYFISH_API_KEY_VAR, TINYFISH_KEYLESS_ACCESS_MODE, } from './constants.js';
8
8
  import { HARNESS_SPECS, NATIVE_HARNESSES, harnessSpec, } from './harness-spec.js';
9
9
  import { HERMES_KEY_VAR, captureHermesKeyRestore, hermesEnvPath, resolveHermesHome, writeHermesKey, } from './hermes-env.js';
10
10
  import { hermesConfigPath, readHermesEntry } from './hermes-config.js';
@@ -44,6 +44,8 @@ export const OPENCLAW_ONBOARDING_PROMPT = 'I just installed the TinyFish skill.
44
44
  'want to search and wait for my reply before using TinyFish Search. Show me the results, ask ' +
45
45
  'which result I want to read, and wait before using TinyFish Fetch. Then ask what browser task ' +
46
46
  'I want to complete and wait before using TinyFish Agent. Never skip ahead or choose for me.';
47
+ export const KEYLESS_ONBOARDING_PROMPT = 'I just connected TinyFish Search. Ask what I want to look up and wait for my reply. Then search ' +
48
+ 'with the `tinyfish` MCP server, show results, and explain signing up unlocks TinyFish Fetch and Agent.';
47
49
  /** Cursor writes its own MCP config, so it has no descriptor to carry this. */
48
50
  export const CURSOR_SKILL_TARGET = {
49
51
  skillAgent: HARNESS_SPECS.cursor.skillAgent,
@@ -65,14 +67,14 @@ export function openExternalUrl(url) {
65
67
  }
66
68
  return spawn.sync('xdg-open', [url], { stdio: 'ignore' });
67
69
  }
68
- function launchCodexWalkthrough() {
70
+ function launchCodexWalkthrough(prompt) {
69
71
  const deepLink = new URL('codex://new');
70
- deepLink.searchParams.set('prompt', DEFAULT_ONBOARDING_PROMPT);
72
+ deepLink.searchParams.set('prompt', prompt);
71
73
  deepLink.searchParams.set('path', process.cwd());
72
74
  const result = openExternalUrl(deepLink.toString());
73
75
  if (result.error || result.status !== 0) {
74
76
  // Headless box: nothing can open a window; recover like the gated clients.
75
- printPromptForHandoff('Codex', 'codex', DEFAULT_ONBOARDING_PROMPT, 'Codex could not be opened here (no `codex://` handler)');
77
+ printPromptForHandoff('Codex', 'codex', prompt, 'Codex could not be opened here (no `codex://` handler)');
76
78
  return 'printed';
77
79
  }
78
80
  errLine('Codex opened with the TinyFish walkthrough ready. Send the prompt to start.');
@@ -85,8 +87,8 @@ function handOverTerminal(command, args, displayName) {
85
87
  throw spawnStepError(`Could not launch ${displayName}`, result);
86
88
  }
87
89
  }
88
- function launchHermesWalkthrough() {
89
- const seedResult = spawn.sync('hermes', ['chat', '-Q', '-q', DEFAULT_ONBOARDING_PROMPT], {
90
+ function launchHermesWalkthrough(prompt) {
91
+ const seedResult = spawn.sync('hermes', ['chat', '-Q', '-q', prompt], {
90
92
  encoding: 'utf8',
91
93
  timeout: HERMES_SEED_TIMEOUT_MS,
92
94
  });
@@ -100,18 +102,23 @@ function launchHermesWalkthrough() {
100
102
  }
101
103
  handOverTerminal('hermes', ['--resume', sessionId], 'Hermes');
102
104
  }
105
+ function failedGate(detail, tag) {
106
+ return { ok: false, detail, tag };
107
+ }
103
108
  /** Writes exit 0 having saved nothing, a disabled entry, or a keyless one. */
104
- export function hermesRegistrationEnabled(home) {
109
+ export function hermesRegistrationEnabled(home, mode = 'api-key', expectedUrl) {
105
110
  const entry = readHermesEntry(home);
106
111
  if (entry.state === 'enabled') {
107
- if (entry.usesKeyHeader)
112
+ if (expectedUrl && entry.url !== expectedUrl) {
113
+ return failedGate(`the entry still points at ${entry.url ?? 'an unknown URL'}`, 'hermes_entry_url_mismatch');
114
+ }
115
+ const validAuth = mode === 'keyless' ? entry.keyless : entry.usesKeyHeader;
116
+ if (validAuth)
108
117
  return { ok: true };
109
118
  // Exit codes carry no signal, so a corrupt entry can only be caught here.
110
- return {
111
- ok: false,
112
- detail: 'the entry does not read the seeded key',
113
- tag: 'hermes_entry_no_key_header',
114
- };
119
+ return mode === 'keyless'
120
+ ? failedGate('the entry does not carry the keyless access header', 'hermes_entry_no_keyless_header')
121
+ : failedGate('the entry does not read the seeded key', 'hermes_entry_no_key_header');
115
122
  }
116
123
  // Each of these is a different repair, so none of them share wording.
117
124
  const detail = {
@@ -141,7 +148,15 @@ function registerHermesEntry(home, mcpUrl) {
141
148
  throw error;
142
149
  return false;
143
150
  }
144
- return hermesRegistrationEnabled(home).ok;
151
+ return hermesRegistrationEnabled(home, 'api-key', mcpUrl).ok;
152
+ }
153
+ function registerHermesKeyless(home, mcpUrl) {
154
+ writeHermesMcpEntry(home, mcpUrl, 'keyless');
155
+ return hermesRegistrationEnabled(home, 'keyless', mcpUrl);
156
+ }
157
+ function prepareHermesKeylessInstall() {
158
+ const home = requireHermesHome();
159
+ return { home, register: (mcpUrl) => registerHermesKeyless(home, mcpUrl) };
145
160
  }
146
161
  function seedHermesKey(apiKey) {
147
162
  const home = requireHermesHome();
@@ -196,8 +211,8 @@ function installHermesWebPlugin({ apiKey, verbose, seededHome, }) {
196
211
  // OpenCode's TUI takes a positional as a project directory, so a bare `opencode "<prompt>"`
197
212
  // would be read as a folder. The `--prompt` flag seeds the interactive TUI with a first message
198
213
  // (opencode.ai/docs/cli), so the onboarding guide fires just like the other agents.
199
- function launchOpencode() {
200
- handOverTerminal('opencode', ['--prompt', DEFAULT_ONBOARDING_PROMPT], 'OpenCode');
214
+ function launchOpencode(prompt) {
215
+ handOverTerminal('opencode', ['--prompt', prompt], 'OpenCode');
201
216
  }
202
217
  /** "positional": flags before `tinyfish <url>`. "flag": flags after `--url`. */
203
218
  function specAddArgs(spec, mcpUrl) {
@@ -247,6 +262,13 @@ function specKeyAuth(spec) {
247
262
  addArgs: (mcpUrl, apiKey) => [...specAddArgs(spec, mcpUrl), '--header', value(apiKey)],
248
263
  };
249
264
  }
265
+ function specKeylessAddArgs(spec) {
266
+ const { header } = spec;
267
+ if (!spec.keylessFallback || !header)
268
+ return undefined;
269
+ const value = `${TINYFISH_ACCESS_MODE_HEADER}${header.sep}${TINYFISH_KEYLESS_ACCESS_MODE}`;
270
+ return (mcpUrl) => specAddArgs(spec, mcpUrl).concat('--header', value);
271
+ }
250
272
  /** Descriptor behaviour the spec cannot hold; every other field is spec data. */
251
273
  const HARNESS_OVERRIDES = {
252
274
  codex: { detachedLaunch: launchCodexWalkthrough },
@@ -255,6 +277,7 @@ const HARNESS_OVERRIDES = {
255
277
  extraPostInstall: installHermesWebPlugin,
256
278
  pluginVersion: HERMES_PLUGIN_VERSION,
257
279
  seedKey: seedHermesKey,
280
+ prepareKeylessInstall: prepareHermesKeylessInstall,
258
281
  },
259
282
  opencode: { launchWalkthrough: launchOpencode },
260
283
  };
@@ -273,6 +296,8 @@ function toNativeClient(harness) {
273
296
  skillAgent: spec.skillAgent,
274
297
  nonInteractiveAdd: spec.nonInteractiveAdd,
275
298
  keyAuth,
299
+ keylessAddArgs: specKeylessAddArgs(spec),
300
+ prepareKeylessInstall: overrides.prepareKeylessInstall,
276
301
  signInHint: spec.signInHint,
277
302
  degradedAuthNotes: spec.degradedAuthNotes,
278
303
  removals: spec.removals ?? [
@@ -329,21 +354,21 @@ async function deliverWalkthrough(displayName, command, prompt, handover, onDeci
329
354
  handover();
330
355
  return 'launched';
331
356
  }
332
- export async function launchNativeMcpClient(client, onDecided) {
357
+ export async function launchNativeMcpClient(client, onDecided, prompt = DEFAULT_ONBOARDING_PROMPT) {
333
358
  if (client.detachedLaunch) {
334
359
  // Detached: the open attempt never blocks, so decide after it.
335
360
  errLine(`Starting the TinyFish walkthrough in ${client.displayName}...`);
336
- const outcome = client.detachedLaunch();
361
+ const outcome = client.detachedLaunch(prompt);
337
362
  await onDecided?.(outcome);
338
363
  return outcome;
339
364
  }
340
- return deliverWalkthrough(client.displayName, client.command, DEFAULT_ONBOARDING_PROMPT, () => client.launchWalkthrough
341
- ? client.launchWalkthrough()
342
- : handOverTerminal(client.command, [DEFAULT_ONBOARDING_PROMPT], client.displayName), onDecided);
365
+ return deliverWalkthrough(client.displayName, client.command, prompt, () => client.launchWalkthrough
366
+ ? client.launchWalkthrough(prompt)
367
+ : handOverTerminal(client.command, [prompt], client.displayName), onDecided);
343
368
  }
344
369
  // Via deliverWalkthrough: handOverTerminal alone would wedge a headless --launch.
345
- export function launchOmpWalkthrough(onDecided) {
346
- return deliverWalkthrough('omp', 'omp', DEFAULT_ONBOARDING_PROMPT, () => handOverTerminal('omp', [DEFAULT_ONBOARDING_PROMPT], 'omp'), onDecided);
370
+ export function launchOmpWalkthrough(onDecided, prompt = DEFAULT_ONBOARDING_PROMPT) {
371
+ return deliverWalkthrough('omp', 'omp', prompt, () => handOverTerminal('omp', [prompt], 'omp'), onDecided);
347
372
  }
348
373
  export function launchPiWalkthrough(onDecided) {
349
374
  return deliverWalkthrough('Pi', 'pi', DEFAULT_ONBOARDING_PROMPT, () => handOverTerminal('pi', [DEFAULT_ONBOARDING_PROMPT], 'Pi'), onDecided);
@@ -8,6 +8,8 @@ export declare const CLI_AGENT_IDENTITY = "tinyfish-cli";
8
8
  export declare const WEB_SKILL_NAME = "use-tinyfish";
9
9
  /** The descriptor hands this name to harnesses; the probes resolve it. */
10
10
  export declare const TINYFISH_API_KEY_VAR = "TINYFISH_API_KEY";
11
+ export declare const TINYFISH_ACCESS_MODE_HEADER = "X-TinyFish-Access-Mode";
12
+ export declare const TINYFISH_KEYLESS_ACCESS_MODE = "keyless";
11
13
  export declare const BASE_URL: string;
12
14
  export declare const API_URL_OVERRIDE: string | undefined;
13
15
  /** URL where users can create and manage API keys */
@@ -13,6 +13,8 @@ export const CLI_AGENT_IDENTITY = 'tinyfish-cli';
13
13
  export const WEB_SKILL_NAME = 'use-tinyfish';
14
14
  /** The descriptor hands this name to harnesses; the probes resolve it. */
15
15
  export const TINYFISH_API_KEY_VAR = 'TINYFISH_API_KEY';
16
+ export const TINYFISH_ACCESS_MODE_HEADER = 'X-TinyFish-Access-Mode';
17
+ export const TINYFISH_KEYLESS_ACCESS_MODE = 'keyless';
16
18
  /** Base URL for the TinyFish API. Override with TINYFISH_API_URL for staging/self-hosted. */
17
19
  const apiUrlOverride = process.env['TINYFISH_API_URL']?.trim() || undefined;
18
20
  if (apiUrlOverride) {
@@ -298,6 +298,10 @@ export function checkHermesPlugin(status) {
298
298
  if (status.registered !== Registered.Yes) {
299
299
  return { check: { ...base, status: 'skip', detail: 'TinyFish is not registered in Hermes' } };
300
300
  }
301
+ const authMode = status.authMode === AuthMode.Unknown ? status.recordedAuthMode : status.authMode;
302
+ if (authMode === AuthMode.Keyless) {
303
+ return { check: { ...base, status: 'skip', detail: 'not needed for keyless Search' } };
304
+ }
301
305
  const resolved = resolveHermesHome();
302
306
  if (typeof resolved !== 'string') {
303
307
  return {
@@ -58,6 +58,7 @@ declare const doctorHarnessSchema: z.ZodObject<{
58
58
  registered: z.ZodEnum<typeof Registered>;
59
59
  auth_mode: z.ZodEnum<typeof AuthMode>;
60
60
  recorded_auth_mode: z.ZodOptional<z.ZodEnum<{
61
+ keyless: "keyless";
61
62
  "api-key": "api-key";
62
63
  oauth: "oauth";
63
64
  deferred: "deferred";
@@ -135,6 +136,7 @@ export declare const doctorReportSchema: z.ZodObject<{
135
136
  registered: z.ZodEnum<typeof Registered>;
136
137
  auth_mode: z.ZodEnum<typeof AuthMode>;
137
138
  recorded_auth_mode: z.ZodOptional<z.ZodEnum<{
139
+ keyless: "keyless";
138
140
  "api-key": "api-key";
139
141
  oauth: "oauth";
140
142
  deferred: "deferred";
@@ -20,7 +20,7 @@ const doctorHarnessSchema = z.object({
20
20
  registered: z.enum(Registered),
21
21
  auth_mode: z.enum(AuthMode),
22
22
  // Additive under schema 3: what connect wrote when the harness cannot echo it back.
23
- recorded_auth_mode: z.enum(['api-key', 'oauth', 'deferred']).optional(),
23
+ recorded_auth_mode: z.enum(['api-key', 'keyless', 'oauth', 'deferred']).optional(),
24
24
  proves_harness_reach: z.boolean(),
25
25
  });
26
26
  // `action` is what `--fix` dispatches on, not the harness field: keying off a null harness
@@ -9,6 +9,7 @@ export declare enum Registered {
9
9
  }
10
10
  export declare enum AuthMode {
11
11
  ApiKey = "api-key",
12
+ Keyless = "keyless",
12
13
  OAuth = "oauth",
13
14
  Unknown = "unknown"
14
15
  }
@@ -17,6 +17,7 @@ export var Registered;
17
17
  export var AuthMode;
18
18
  (function (AuthMode) {
19
19
  AuthMode["ApiKey"] = "api-key";
20
+ AuthMode["Keyless"] = "keyless";
20
21
  AuthMode["OAuth"] = "oauth";
21
22
  AuthMode["Unknown"] = "unknown";
22
23
  })(AuthMode || (AuthMode = {}));
@@ -48,6 +48,7 @@ export interface HarnessSpec {
48
48
  keyedAddJsonFlags?: string[];
49
49
  /** No sign-in exists here, so connect refuses a keyless install (Hermes). */
50
50
  keyRequired?: true;
51
+ keylessFallback?: true;
51
52
  loginArgs?: string[];
52
53
  /** Default: one `mcp remove tinyfish` labeled from displayName. */
53
54
  removals?: {
@@ -199,6 +200,7 @@ export declare const HARNESS_SPECS: {
199
200
  reloadAction: string;
200
201
  skillAgent: "hermes-agent";
201
202
  keyRequired: true;
203
+ keylessFallback: true;
202
204
  supportCheck: {
203
205
  args: string[];
204
206
  patterns: RegExp[];
@@ -219,6 +221,7 @@ export declare const HARNESS_SPECS: {
219
221
  displayName: string;
220
222
  configDir: string;
221
223
  reloadAction: string;
224
+ keylessFallback: true;
222
225
  canVerifyAuth: true;
223
226
  keyHeldByCli: true;
224
227
  cliWritesConfig: true;
@@ -247,6 +250,7 @@ export declare const HARNESS_SPECS: {
247
250
  reloadAction: string;
248
251
  skillAgent: "opencode";
249
252
  nonInteractiveAdd: true;
253
+ keylessFallback: true;
250
254
  supportCheck: {
251
255
  args: string[];
252
256
  patterns: RegExp[];
@@ -177,6 +177,7 @@ export const HARNESS_SPECS = {
177
177
  reloadAction: 'restart it',
178
178
  skillAgent: 'hermes-agent',
179
179
  keyRequired: true,
180
+ keylessFallback: true,
180
181
  supportCheck: {
181
182
  args: ['mcp', 'add', '--help'],
182
183
  patterns: [/--auth\s+\{[^}]*oauth[^}]*\}/],
@@ -199,6 +200,7 @@ export const HARNESS_SPECS = {
199
200
  displayName: 'omp',
200
201
  configDir: '.omp',
201
202
  reloadAction: 'restart it',
203
+ keylessFallback: true,
202
204
  canVerifyAuth: true,
203
205
  keyHeldByCli: true,
204
206
  cliWritesConfig: true,
@@ -229,6 +231,7 @@ export const HARNESS_SPECS = {
229
231
  reloadAction: 'restart it',
230
232
  skillAgent: 'opencode',
231
233
  nonInteractiveAdd: true,
234
+ keylessFallback: true,
232
235
  supportCheck: {
233
236
  args: ['mcp', 'add', '--help'],
234
237
  patterns: [/--url(?:\s|$)/m],
@@ -13,9 +13,13 @@ export type HermesEntry = {
13
13
  } | {
14
14
  state: 'disabled';
15
15
  usesKeyHeader: boolean;
16
+ keyless?: true;
17
+ url?: string;
16
18
  } | {
17
19
  state: 'enabled';
18
20
  usesKeyHeader: boolean;
21
+ keyless?: true;
22
+ url?: string;
19
23
  };
20
24
  /** Connect's gate and doctor's probe share this, so they cannot disagree. */
21
25
  export declare function readHermesEntry(home: string): HermesEntry;
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { parse } from 'yaml';
4
4
  import { z } from 'zod';
5
+ import { TINYFISH_ACCESS_MODE_HEADER, TINYFISH_KEYLESS_ACCESS_MODE } from './constants.js';
5
6
  import { HERMES_KEY_VAR } from './hermes-env.js';
6
7
  /** Every message about the entry names this path. */
7
8
  export function hermesConfigPath(home) {
@@ -16,6 +17,7 @@ const entrySchema = z.looseObject({
16
17
  // Anything goes: the runtime defaults unrecognised values to enabled.
17
18
  enabled: z.unknown().optional(),
18
19
  headers: z.record(z.string(), z.unknown()).optional(),
20
+ url: z.unknown().optional(),
19
21
  });
20
22
  const configSchema = z
21
23
  .looseObject({
@@ -48,6 +50,10 @@ function hasKeyHeader(headers) {
48
50
  const authorization = Object.entries(headers ?? {}).find(([name]) => name.toLowerCase() === 'authorization');
49
51
  return authorization?.[1] === HERMES_HEADER_TEMPLATE;
50
52
  }
53
+ function hasKeylessHeader(headers) {
54
+ const value = Object.entries(headers ?? {}).find(([name]) => name.toLowerCase() === TINYFISH_ACCESS_MODE_HEADER.toLowerCase())?.[1];
55
+ return typeof value === 'string' && value.trim().toLowerCase() === TINYFISH_KEYLESS_ACCESS_MODE;
56
+ }
51
57
  /** Connect's gate and doctor's probe share this, so they cannot disagree. */
52
58
  export function readHermesEntry(home) {
53
59
  let raw;
@@ -68,7 +74,10 @@ export function readHermesEntry(home) {
68
74
  if (!entry)
69
75
  return { state: 'absent' };
70
76
  const usesKeyHeader = hasKeyHeader(entry.headers);
71
- return isEnabled(entry.enabled)
72
- ? { state: 'enabled', usesKeyHeader }
73
- : { state: 'disabled', usesKeyHeader };
77
+ return {
78
+ state: isEnabled(entry.enabled) ? 'enabled' : 'disabled',
79
+ usesKeyHeader,
80
+ ...(hasKeylessHeader(entry.headers) ? { keyless: true } : {}),
81
+ ...(typeof entry.url === 'string' ? { url: entry.url } : {}),
82
+ };
74
83
  }
@@ -5,7 +5,7 @@ export declare const HERMES_PLUGIN_VERSION = "0.1.0";
5
5
  export declare const HERMES_PLUGIN_MANUAL_INSTALL = "hermes plugins install tinyfish-io/tinyfish-web-agent-integrations/hermes --ref 496cd63fefd982bbaa8a85ce78ef2270d700984f --enable";
6
6
  export declare function installHermesPlugin(home: string, apiKey: string, { verbose }: Pick<InstallOptions, 'verbose'>): void;
7
7
  /** Absent `enabled` reads as enabled, so never write this field-by-field. */
8
- export declare function writeHermesMcpEntry(home: string, mcpUrl: string): void;
8
+ export declare function writeHermesMcpEntry(home: string, mcpUrl: string, mode?: 'api-key' | 'keyless'): void;
9
9
  /** Retracts our row; an absent `enabled` reads as enabled, so a partial one is live. */
10
10
  export declare function removeHermesMcpEntry(home: string): void;
11
11
  /** After install only: backends must never name an absent provider. */
@@ -6,7 +6,7 @@ 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
12
  // Bump per CLI release.
@@ -97,15 +97,15 @@ function configWrite(home, args) {
97
97
  throw error;
98
98
  }
99
99
  /** Absent `enabled` reads as enabled, so never write this field-by-field. */
100
- export function writeHermesMcpEntry(home, mcpUrl) {
100
+ export function writeHermesMcpEntry(home, mcpUrl, mode = 'api-key') {
101
101
  const entry = {
102
102
  url: mcpUrl,
103
- headers: { Authorization: HERMES_HEADER_TEMPLATE },
103
+ headers: mode === 'keyless'
104
+ ? { [TINYFISH_ACCESS_MODE_HEADER]: TINYFISH_KEYLESS_ACCESS_MODE }
105
+ : { Authorization: HERMES_HEADER_TEMPLATE },
104
106
  enabled: true,
105
107
  };
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);
108
+ configWrite(home, ['set', `mcp_servers.${HERMES_MCP_SERVER_KEY}`, JSON.stringify(entry)]);
109
109
  }
110
110
  /** Retracts our row; an absent `enabled` reads as enabled, so a partial one is live. */
111
111
  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() {
@@ -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
  }