@tiny-fish/cli 0.42.1 → 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.
- package/README.md +11 -7
- package/dist/commands/auth.js +11 -1
- package/dist/commands/connect.js +111 -46
- package/dist/commands/doctor.js +2 -1
- package/dist/commands/run.js +5 -2
- package/dist/lib/auth.d.ts +2 -1
- package/dist/lib/auth.js +3 -1
- package/dist/lib/client.d.ts +2 -1
- package/dist/lib/client.js +50 -9
- package/dist/lib/connect-all-auth.js +3 -0
- package/dist/lib/connect-all-uninstall.js +3 -1
- package/dist/lib/connect-all.js +10 -4
- package/dist/lib/connect-clients.d.ts +12 -5
- package/dist/lib/connect-clients.js +48 -23
- package/dist/lib/constants.d.ts +2 -0
- package/dist/lib/constants.js +2 -0
- package/dist/lib/doctor-checks.js +4 -0
- package/dist/lib/doctor-report.d.ts +2 -0
- package/dist/lib/doctor-report.js +1 -1
- package/dist/lib/harness-detect.d.ts +1 -0
- package/dist/lib/harness-detect.js +1 -0
- package/dist/lib/harness-spec.d.ts +4 -0
- package/dist/lib/harness-spec.js +3 -0
- package/dist/lib/hermes-config.d.ts +4 -0
- package/dist/lib/hermes-config.js +12 -3
- package/dist/lib/hermes-plugin.d.ts +1 -1
- package/dist/lib/hermes-plugin.js +6 -6
- package/dist/lib/mcp-json-config.d.ts +4 -2
- package/dist/lib/mcp-json-config.js +20 -10
- package/dist/lib/omp-config.js +5 -3
- package/dist/lib/output.js +7 -1
- package/dist/lib/registration-detect.js +12 -4
- package/dist/lib/setup-telemetry.d.ts +12 -0
- package/dist/lib/setup-telemetry.js +1 -0
- package/dist/lib/verify.d.ts +1 -1
- package/dist/lib/verify.js +29 -9
- package/package.json +2 -2
package/dist/lib/connect-all.js
CHANGED
|
@@ -8,10 +8,11 @@ import { authGate, mapConnectError, OAUTH_SIGN_IN_TIMEOUT_MS } from './connect-a
|
|
|
8
8
|
import { actionable, computeExitCode, reloadHint, renderSummary } from './connect-all-summary.js';
|
|
9
9
|
import { planText, uninstallHarness, uninstallPlanText } from './connect-all-uninstall.js';
|
|
10
10
|
import { createStdinPrompt, runCliFallback } from './connect-fallback.js';
|
|
11
|
+
import { KEYLESS_ONBOARDING_PROMPT } from './connect-clients.js';
|
|
11
12
|
import { gateApiKey } from './connect-preflight.js';
|
|
12
13
|
import { ConnectInterruptedError } from './connect-runtime.js';
|
|
13
14
|
import { pickHarnesses } from './connect-picker.js';
|
|
14
|
-
import { commandOnPath, detectInstalledHarnesses, HARNESS_COMMANDS, HARNESS_DISPLAY_NAMES as DISPLAY_NAMES, ALL_HARNESSES, } from './harness-detect.js';
|
|
15
|
+
import { commandOnPath, detectInstalledHarnesses, AuthMode, HARNESS_COMMANDS, HARNESS_DISPLAY_NAMES as DISPLAY_NAMES, ALL_HARNESSES, } from './harness-detect.js';
|
|
15
16
|
import { detectHumanInitiated } from './harness.js';
|
|
16
17
|
import { emitNotice } from './notice.js';
|
|
17
18
|
import { errLine, outLine, setErrIndent } from './output.js';
|
|
@@ -44,7 +45,7 @@ async function verifyHarness(harness, mcpUrl, keyed, apiKey) {
|
|
|
44
45
|
return health;
|
|
45
46
|
if (!apiKey)
|
|
46
47
|
return { depth: 'health+auth', ok: false, reason: 'no stored API key' };
|
|
47
|
-
return verifyMcpAuth(apiKey);
|
|
48
|
+
return verifyMcpAuth(apiKey, new URL(mcpUrl).origin);
|
|
48
49
|
}
|
|
49
50
|
/** The non-connect settlements: undetected, stale, dry-run, uninstall. */
|
|
50
51
|
function settleWithoutConnect(detection, opts, base) {
|
|
@@ -109,7 +110,9 @@ async function processHarness(detection, opts, prompt, oauthBudget, hoistedCliIn
|
|
|
109
110
|
}
|
|
110
111
|
// The probe, not the flag, decides: a harness that degraded to OAuth is not key-authed.
|
|
111
112
|
const keyAuthed = authMode ? authMode === 'api-key' : keyed;
|
|
112
|
-
const verify =
|
|
113
|
+
const verify = authMode === AuthMode.Keyless
|
|
114
|
+
? { depth: 'health', ok: true }
|
|
115
|
+
: await verifyHarness(harness, opts.mcpUrl, keyAuthed, apiKey);
|
|
113
116
|
return {
|
|
114
117
|
...base,
|
|
115
118
|
installed: true,
|
|
@@ -426,6 +429,9 @@ async function launchFirstTask(results) {
|
|
|
426
429
|
if (!chosen)
|
|
427
430
|
return false;
|
|
428
431
|
outLine(`Starting ${DISPLAY_NAMES[chosen.harness]} with your first TinyFish task...`);
|
|
429
|
-
const
|
|
432
|
+
const keyless = chosen.harness === 'omp' &&
|
|
433
|
+
results.some((result) => result.harness === 'omp' && !result.keyAuthed);
|
|
434
|
+
const args = keyless ? [KEYLESS_ONBOARDING_PROMPT] : chosen.args;
|
|
435
|
+
const result = spawn.sync(HARNESS_COMMANDS[chosen.harness], args, { stdio: 'inherit' });
|
|
430
436
|
return !result.error;
|
|
431
437
|
}
|
|
@@ -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',
|
|
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',
|
|
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',
|
|
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.
|
|
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
|
-
|
|
112
|
-
|
|
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',
|
|
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,
|
|
341
|
-
? client.launchWalkthrough()
|
|
342
|
-
: handOverTerminal(client.command, [
|
|
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',
|
|
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);
|
package/dist/lib/constants.d.ts
CHANGED
|
@@ -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 */
|
package/dist/lib/constants.js
CHANGED
|
@@ -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
|
|
@@ -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[];
|
package/dist/lib/harness-spec.js
CHANGED
|
@@ -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
|
|
72
|
-
?
|
|
73
|
-
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
48
|
-
?
|
|
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(
|
|
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
|
-
|
|
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] =
|
|
139
|
+
servers[target.serverKey] = serverEntry;
|
|
130
140
|
return { ...commitServers(target, existing, servers), repaired };
|
|
131
141
|
}
|
|
132
142
|
/** Removes only the target's key. */
|
package/dist/lib/omp-config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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() {
|