@tiny-fish/cli 0.37.1-next.300 → 0.37.1-next.302

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 CHANGED
@@ -19,7 +19,7 @@ npx -y @tiny-fish/cli@latest connect
19
19
  ```
20
20
 
21
21
  It detects the agent configs on your machine (Claude Code, Codex, Cursor, Grok, Hermes,
22
- OpenClaw, OpenCode) and opens a checklist with everything pre-selected. Press enter to connect
22
+ omp, OpenClaw, OpenCode) and opens a checklist with everything pre-selected. Press enter to connect
23
23
  them all, or use the arrow keys and space to narrow the set first; `q` cancels without touching
24
24
  anything. Terminals without raw-key support get a numbered prompt instead.
25
25
 
@@ -128,7 +128,10 @@ export function registerAuth(program) {
128
128
  // Read from stdin pipe (CI/CD) or hidden TTY prompt (interactive)
129
129
  const key = await readKeyFromStdinOrPrompt();
130
130
  if (!key) {
131
- err('API key cannot be empty');
131
+ // `echo $KEY | tinyfish auth set` with KEY unset pipes a blank line, not a key.
132
+ err(process.stdin.isTTY
133
+ ? 'API key cannot be empty'
134
+ : "No key received on stdin. Example: echo 'sk-tinyfish-...' | tinyfish auth set");
132
135
  process.exit(1);
133
136
  }
134
137
  if (!validateKeyFormat(key)) {
@@ -1,6 +1,6 @@
1
1
  import spawn from 'cross-spawn';
2
2
  import { CONNECT_SOURCE, persistApiKeyToEnvironment, saveConnectContext, validateKeyFormat, validatedApiKey, } from '../lib/auth.js';
3
- import { CURSOR_SKILL_TARGET, OPENCLAW, OPENCLAW_SKILL_INSTALL_ARGS, NATIVE_BY_HARNESS, launchNativeMcpClient, launchOpenClawWalkthrough, openExternalUrl, } from '../lib/connect-clients.js';
3
+ import { CURSOR_SKILL_TARGET, OPENCLAW, OPENCLAW_SKILL_INSTALL_ARGS, NATIVE_BY_HARNESS, launchNativeMcpClient, launchOmpWalkthrough, launchOpenClawWalkthrough, openExternalUrl, } from '../lib/connect-clients.js';
4
4
  import { captureStdio, capturedOutput, HoistedFailureReportedError, installTinyFishCli, replay, SKILL_INSTALL_TIMEOUT_MS, STEP_MAX_BUFFER, } from '../lib/cli-install.js';
5
5
  import { ensureCliAuthenticated } from '../lib/connect-auth.js';
6
6
  import { createStdinPrompt, runCliFallback } from '../lib/connect-fallback.js';
@@ -8,6 +8,7 @@ import { installWebSkill } from '../lib/skill-install.js';
8
8
  import { ConnectInterruptedError, ConnectStepError, createConnectTelemetry, finishSetupCommand, requireCommandSupport, runGuarded, settle, SignInTimeoutError, spawnStepError, stageDurationOf, withStageDuration, spawnRemoval, } from '../lib/connect-runtime.js';
9
9
  import { cursorInstallDeeplink, cursorMcpPath, readCursorTinyfishEntry, writeCursorMcpConfig, } from '../lib/cursor-config.js';
10
10
  import { runConnectAll } from '../lib/connect-all.js';
11
+ import { ompMcpPath, readOmpTinyfishEntry, writeOmpMcpConfig } from '../lib/omp-config.js';
11
12
  import { ALL_HARNESSES, AuthMode, HARNESS_DISPLAY_NAMES, RELOAD_ACTION, } from '../lib/harness-detect.js';
12
13
  import { detectHumanInitiated } from '../lib/harness.js';
13
14
  import { TINYFISH_API_KEY_VAR } from '../lib/constants.js';
@@ -16,6 +17,7 @@ import { SIGN_IN_PROBES } from '../lib/registration-detect.js';
16
17
  import { errLine, warnLine } from '../lib/output.js';
17
18
  import { z } from 'zod';
18
19
  import { verifyMcpAuth } from '../lib/verify.js';
20
+ import { gateApiKey } from '../lib/connect-preflight.js';
19
21
  import { apiBaseFromMcpUrl, DEFAULT_MCP_URL } from '../lib/mcp-endpoint.js';
20
22
  function removeExistingRegistration(client, removal, verbose) {
21
23
  const result = spawnRemoval(client.command, removal.args);
@@ -506,6 +508,9 @@ export async function connectOpenClaw(options) {
506
508
  }
507
509
  });
508
510
  }
511
+ // Only interactive omp wires the OAuth handler; headless cannot sign in.
512
+ const OMP_KEYLESS_COPY = 'TinyFish is configured in omp. Open omp and sign in to TinyFish when prompted, or run ' +
513
+ '`/mcp reauth tinyfish` there.';
509
514
  const CONFIG_FILE_HARNESSES = {
510
515
  cursor: {
511
516
  skillTarget: CURSOR_SKILL_TARGET,
@@ -521,6 +526,22 @@ const CONFIG_FILE_HARNESSES = {
521
526
  reload: 'TinyFish is connected. Reload the Cursor window, then approve/sign in under Settings → MCP.',
522
527
  },
523
528
  },
529
+ omp: {
530
+ // No skill exists for omp; installWebSkill returns without one.
531
+ skillTarget: { displayName: HARNESS_DISPLAY_NAMES.omp },
532
+ registeredUrl: () => readOmpTinyfishEntry()?.url,
533
+ write: writeOmpMcpConfig,
534
+ configPath: ompMcpPath,
535
+ // No install deeplink exists; the keyless copy carries the sign-in.
536
+ tryLaunch: () => false,
537
+ copy: {
538
+ verified: "TinyFish is connected and verified (health+auth). Restart omp and it's live.",
539
+ authFailed: (reason) => `TinyFish is configured, but the authenticated check failed (${reason}). ` +
540
+ 'Fix: rotate/re-enter your key with `tinyfish auth login`, then re-run: tinyfish connect omp',
541
+ launched: OMP_KEYLESS_COPY,
542
+ reload: OMP_KEYLESS_COPY,
543
+ },
544
+ },
524
545
  };
525
546
  /** Writes the harness's MCP config file itself; no `mcp add` exists. */
526
547
  async function connectConfigFileHarness(harness, options) {
@@ -628,6 +649,7 @@ function launchCursorDeeplink(mcpUrl) {
628
649
  }
629
650
  /** Total over the non-native harnesses: a new one fails to compile until wired here. */
630
651
  const LAUNCH_OVERRIDES = {
652
+ omp: launchOmpWalkthrough,
631
653
  openclaw: launchOpenClawWalkthrough,
632
654
  // Cursor has no chat CLI to launch.
633
655
  cursor: () => {
@@ -654,6 +676,20 @@ const CONNECTOR_OVERRIDES = {
654
676
  }
655
677
  return undefined;
656
678
  },
679
+ omp: async (options) => {
680
+ await connectConfigFileHarness('omp', options);
681
+ if (options.launch) {
682
+ try {
683
+ await launchOmpWalkthrough();
684
+ }
685
+ catch (error) {
686
+ // Post-settle, like the native walkthrough step: never fail the connect.
687
+ errLine(error instanceof Error ? error.message : String(error));
688
+ errLine(finishSetupHint('omp'));
689
+ }
690
+ }
691
+ return undefined;
692
+ },
657
693
  };
658
694
  function requireNative(harness) {
659
695
  const client = NATIVE_BY_HARNESS.get(harness);
@@ -746,13 +782,24 @@ async function connectSingleClient(client, options, mcpUrl, attemptId) {
746
782
  if (!ALL_HARNESSES.includes(client)) {
747
783
  throw new Error('Unsupported client: ' + client + '. Supported clients: ' + ALL_HARNESSES.join(', '));
748
784
  }
785
+ const gate = await gateApiKey({
786
+ apiKey: options.apiKey,
787
+ mcpUrl,
788
+ retryCommand: `tinyfish connect ${client}`,
789
+ mode: 'single',
790
+ attemptId,
791
+ });
792
+ if (gate === 'blocked') {
793
+ process.exitCode = 1;
794
+ return;
795
+ }
749
796
  await connectHarness(client, connectOptions);
750
797
  }
751
798
  export function registerConnect(program) {
752
799
  program
753
800
  .command('connect')
754
801
  .description('Connect TinyFish to an AI agent')
755
- .argument('[client]', 'Agent client to connect (claude-code, codex, cursor, grok, hermes, openclaw, or opencode); omit to pick from the agents detected on this machine')
802
+ .argument('[client]', 'Agent client to connect (claude-code, codex, cursor, grok, hermes, omp, openclaw, or opencode); omit to pick from the agents detected on this machine')
756
803
  .option('--all', 'Detect and connect every supported harness found on this machine')
757
804
  .option('--dry-run', 'Print planned writes per harness without touching anything (--all only)')
758
805
  .option('--uninstall', 'Remove TinyFish entries written by connect --all')
@@ -1,17 +1,4 @@
1
1
  import type { Command } from 'commander';
2
- import { type DoctorRepair, type DoctorReport } from '../lib/doctor-report.js';
3
- import { type Harness } from '../lib/harness-detect.js';
4
- export interface DoctorOptions {
5
- mcpUrl: string;
6
- harness?: Harness;
7
- fix?: boolean;
8
- yes?: boolean;
9
- }
2
+ import { type DoctorOptions, type DoctorReport } from '../lib/doctor-report.js';
10
3
  export declare function runDoctor(options: DoctorOptions): Promise<DoctorReport>;
11
- export interface RepairOutcome {
12
- repair: DoctorRepair;
13
- status: 'repaired' | 'skipped' | 'failed';
14
- reason?: string;
15
- }
16
- export declare function applyRepairs(repairs: DoctorRepair[], options: DoctorOptions): Promise<RepairOutcome[]>;
17
4
  export declare function registerDoctor(program: Command): void;
@@ -1,17 +1,16 @@
1
- import spawn from 'cross-spawn';
2
1
  import { apiKeyStatus } from '../lib/auth.js';
3
2
  import { CLI_VERSION } from '../lib/constants.js';
4
3
  import { DOCTOR_COULD_NOT_RUN, DOCTOR_INVALID_INPUT, DOCTOR_SCHEMA_VERSION, doctorReportSchema, exitCodeFor, renderPretty, verdictFor, } from '../lib/doctor-report.js';
4
+ import { applyRepairs, repairsFor } from '../lib/doctor-repairs.js';
5
+ import { couldNotRunPayload, telemetryKey, telemetryPayload } from '../lib/doctor-telemetry.js';
5
6
  import { ALL_HARNESSES, AuthMode, Registered } from '../lib/harness-detect.js';
6
- import { detectHumanInitiated } from '../lib/harness.js';
7
7
  import { err, errLine, out, outLine } from '../lib/output.js';
8
8
  import { detectRegistrations } from '../lib/registration-detect.js';
9
- import { doctorErrorClass, sendDoctorCompleted, } from '../lib/setup-telemetry.js';
9
+ import { sendDoctorCompleted } from '../lib/setup-telemetry.js';
10
10
  import { verifyMcpAuth, verifyMcpHealth } from '../lib/verify.js';
11
11
  import { resolveHermesHome } from '../lib/hermes-env.js';
12
12
  import { hermesWebBackendsOurs, readHermesPluginStatus, } from '../lib/hermes-plugin.js';
13
13
  import { DEFAULT_MCP_URL, endpointOf, isDefaultEndpoint } from '../lib/mcp-endpoint.js';
14
- import { connectHarness } from './connect.js';
15
14
  // A green auth mode with a red auth call proves nothing, so the call's verdict gates the claim.
16
15
  function provesHarnessReach(status, keyAuth, healthOk, mcpUrl) {
17
16
  // An endpoint doctor could not reach proves nothing about a harness pointed at it.
@@ -270,57 +269,6 @@ function checkHermesPlugin(status) {
270
269
  }
271
270
  return { ...check, ...hermesPluginVerdict(home) };
272
271
  }
273
- // Only Cursor's repair is a pure local file write; every other one needs a browser sign-in.
274
- const UNATTENDED_SAFE = new Set(['cursor']);
275
- function repairsFor(checks, statuses) {
276
- const repairs = [];
277
- // A revoked-but-well-formed key passes the credential check and fails the call; both need login.
278
- const credentialBroken = checks.some((c) => (c.id === 'cli-credential' || c.id === 'cli-auth-call') && c.status === 'fail');
279
- // connect writes the stored key, so replace a dead one first.
280
- if (credentialBroken) {
281
- repairs.push({
282
- for: 'cli-credential-invalid',
283
- action: 'auth-login',
284
- harness: null,
285
- command: 'tinyfish auth login',
286
- unattended_safe: false,
287
- });
288
- }
289
- const authCallPassed = checks.some((c) => c.id === 'cli-auth-call' && c.status === 'pass');
290
- for (const status of statuses) {
291
- // `unknown` earns nothing: connect would contradict the check's detail.
292
- // A registered entry that still fails earns one too.
293
- // `connected: false` is the harness's own no; it only warns, so it earns a repair here.
294
- const brokenRegistration = status.connected === false ||
295
- checks.some((c) => c.harness === status.harness && c.id !== 'hermes-plugin' && c.status === 'fail');
296
- if (!status.detected || (status.registered !== Registered.No && !brokenRegistration))
297
- continue;
298
- repairs.push({
299
- for: status.registered === Registered.Yes
300
- ? `${status.harness}-registration-broken`
301
- : status.connectedBefore
302
- ? `${status.harness}-registration-lost`
303
- : `${status.harness}-not-connected`,
304
- action: 'connect',
305
- harness: status.harness,
306
- command: `tinyfish connect ${status.harness}`,
307
- // A dead well-formed key passes the format check.
308
- unattended_safe: UNATTENDED_SAFE.has(status.harness) && authCallPassed,
309
- });
310
- }
311
- // connect hermes reinstalls the plugin; a second row would double it.
312
- const pluginBroken = checks.some((c) => c.id === 'hermes-plugin' && c.status === 'fail');
313
- if (pluginBroken && !repairs.some((repair) => repair.harness === 'hermes')) {
314
- repairs.push({
315
- for: 'hermes-plugin-broken',
316
- action: 'connect',
317
- harness: 'hermes',
318
- command: 'tinyfish connect hermes',
319
- unattended_safe: false,
320
- });
321
- }
322
- return repairs;
323
- }
324
272
  export async function runDoctor(options) {
325
273
  const statuses = detectRegistrations(options.harness ? [options.harness] : undefined);
326
274
  const credential = checkCredential();
@@ -359,124 +307,6 @@ export async function runDoctor(options) {
359
307
  // Nothing to scrub: every string above is authored here or enumerated at its source.
360
308
  return doctorReportSchema.parse(body);
361
309
  }
362
- // Shell out rather than import the login flow: `auth login` owns its own browser and prompts.
363
- // Re-invoke this same entry script, never a global `tinyfish` — under `npx` there is no such
364
- // binary on PATH, and npx is the install-nothing path doctor exists to serve.
365
- function runAuthLogin() {
366
- const entry = process.argv[1];
367
- if (!entry)
368
- throw new Error('Could not locate the TinyFish CLI entry point to re-invoke');
369
- const result = spawn.sync(process.execPath, [entry, 'auth', 'login', '--source', 'doctor'], {
370
- stdio: 'inherit',
371
- });
372
- if (result.error)
373
- throw new Error('Could not run `tinyfish auth login`', { cause: result.error });
374
- if (result.status !== 0) {
375
- throw new Error(`\`tinyfish auth login\` exited ${result.status ?? 'unknown'}`);
376
- }
377
- }
378
- /** Dispatches on `action`, never on a null harness: that conflation made the credential fix a no-op. */
379
- async function runRepair(repair, mcpUrl) {
380
- if (repair.action === 'auth-login')
381
- return runAuthLogin();
382
- if (repair.harness)
383
- await connectHarness(repair.harness, { mcpUrl, launch: false });
384
- }
385
- export async function applyRepairs(repairs, options) {
386
- const interactive = detectHumanInitiated();
387
- const outcomes = [];
388
- for (const repair of repairs) {
389
- if (!interactive && !options.yes) {
390
- outcomes.push({
391
- repair,
392
- status: 'skipped',
393
- reason: 'no terminal; re-run with --yes to repair unattended',
394
- });
395
- continue;
396
- }
397
- if (!interactive && !repair.unattended_safe) {
398
- outcomes.push({
399
- repair,
400
- status: 'skipped',
401
- reason: 'needs a browser sign-in, which has no unattended path',
402
- });
403
- continue;
404
- }
405
- try {
406
- await runRepair(repair, options.mcpUrl);
407
- outcomes.push({ repair, status: 'repaired' });
408
- }
409
- catch (e) {
410
- // Connect quotes absolute config paths in its errors, so the raw text stays behind
411
- // --debug and the user is told where to find it rather than handed a generic dead end.
412
- if (process.env['TINYFISH_DEBUG']) {
413
- errLine(e instanceof Error ? (e.stack ?? e.message) : String(e));
414
- }
415
- outcomes.push({
416
- repair,
417
- status: 'failed',
418
- reason: `\`${repair.command}\` failed; re-run it directly, or --debug for the raw error`,
419
- });
420
- }
421
- }
422
- return outcomes;
423
- }
424
- /** Mirrors the route's cap; over it the payload would 400 and never be retried. */
425
- const MAX_DURATION_MS = 3_600_000;
426
- function fixModeOf(options) {
427
- return !options.fix ? 'none' : options.yes ? 'fix_yes' : 'fix';
428
- }
429
- /** Enumerated fields only; check `detail` text never leaves the machine. */
430
- function telemetryPayload(report, offered, outcomes, options, durationMs) {
431
- const count = (status) => report.checks.filter((check) => check.status === status).length;
432
- const failed = report.checks.filter((check) => check.status === 'fail');
433
- return {
434
- outcome: 'completed',
435
- schema_version: report.schema_version,
436
- cli_version: report.cli_version,
437
- ok_harnesses: report.ok_harnesses,
438
- ok_cli: report.ok_cli,
439
- checks_pass: count('pass'),
440
- checks_fail: count('fail'),
441
- checks_warn: count('warn'),
442
- checks_skip: count('skip'),
443
- // From the pre-repair report; the verdicts above describe the machine after it.
444
- repairs_offered: offered.length,
445
- repair_actions: [...new Set(offered.map((repair) => repair.action))],
446
- repairs_attempted: outcomes.filter((outcome) => outcome.status !== 'skipped').length,
447
- repairs_succeeded: outcomes.filter((outcome) => outcome.status === 'repaired').length,
448
- fix_mode: fixModeOf(options),
449
- // --harness scopes detection, so the counts above cover that harness alone.
450
- harness_scope: options.harness ?? 'all',
451
- // Same signal cli_connect_stage carries, so agent and human doctor runs split.
452
- is_human_initiated: detectHumanInitiated(),
453
- // Two flat arrays rather than one of pairs: PostHog groups by element, not by field.
454
- failed_check_ids: [...new Set(failed.map((check) => check.id))],
455
- failed_harnesses: [...new Set(failed.map((check) => check.harness).filter((h) => h !== null))],
456
- harnesses: report.harnesses,
457
- // Clamped, not validated: a --fix parked at a prompt must not fail the parse and drop the run.
458
- duration_ms: Math.min(durationMs, MAX_DURATION_MS),
459
- };
460
- }
461
- /** Both paths send keyless when there is no key; the route records those anonymously. */
462
- function telemetryKey() {
463
- const resolved = apiKeyStatus();
464
- return 'key' in resolved ? resolved.key : undefined;
465
- }
466
- /** Enumerated outcome and timing only; the raw error stays behind --debug. */
467
- function couldNotRunPayload(error, options, durationMs) {
468
- return {
469
- outcome: 'could_not_run',
470
- error_class: doctorErrorClass(error),
471
- schema_version: DOCTOR_SCHEMA_VERSION,
472
- cli_version: CLI_VERSION,
473
- fix_mode: fixModeOf(options),
474
- harness_scope: options.harness ?? 'all',
475
- is_human_initiated: detectHumanInitiated(),
476
- // Clamped like the completed row: over the cap the payload would 400 un-retried.
477
- duration_ms: Math.min(durationMs, MAX_DURATION_MS),
478
- };
479
- }
480
310
  export function registerDoctor(program) {
481
311
  program
482
312
  .command('doctor')
@@ -1,5 +1,6 @@
1
1
  export { DASHBOARD_URL, getDashboardUrl } from './constants.js';
2
2
  export declare function configDir(): string;
3
+ export declare function configFile(): string;
3
4
  /** Install channel baked onto connect context; the connect flow is always CLI-driven. */
4
5
  export declare const CONNECT_SOURCE = "tinyfish_cli";
5
6
  export declare const CONNECT_ATTEMPT_ENV = "TINYFISH_CONNECT_ATTEMPT_ID";
@@ -65,5 +66,5 @@ export declare function validatedApiKey(explicitApiKey?: string): string | undef
65
66
  * The diagnosis, not the key: doctor must report a bad credential and say where it came from
66
67
  * rather than exit like getApiKey does.
67
68
  */
68
- export declare function apiKeyStatus(): ResolvedKey | UnresolvedKey;
69
+ export declare function apiKeyStatus(explicitApiKey?: string): ResolvedKey | UnresolvedKey;
69
70
  export declare function getApiKey(): string;
package/dist/lib/auth.js CHANGED
@@ -11,7 +11,7 @@ export function configDir() {
11
11
  // os.homedir() is cross-platform; process.env.HOME is undefined on Windows
12
12
  return path.join(os.homedir(), '.tinyfish');
13
13
  }
14
- function configFile() {
14
+ export function configFile() {
15
15
  return path.join(configDir(), 'config.json');
16
16
  }
17
17
  /** Install channel baked onto connect context; the connect flow is always CLI-driven. */
@@ -283,8 +283,8 @@ export function validatedApiKey(explicitApiKey) {
283
283
  * The diagnosis, not the key: doctor must report a bad credential and say where it came from
284
284
  * rather than exit like getApiKey does.
285
285
  */
286
- export function apiKeyStatus() {
287
- const { value, source } = lookupApiKey();
286
+ export function apiKeyStatus(explicitApiKey) {
287
+ const { value, source } = lookupApiKey(explicitApiKey);
288
288
  if (!value || source === 'none') {
289
289
  return { error: 'No API key found. Run: tinyfish auth login', source: 'none' };
290
290
  }
@@ -2,6 +2,7 @@ import { clearConnectContext, connectContextState } from './auth.js';
2
2
  import { NATIVE_BY_HARNESS, openclawSkillUninstall } from './connect-clients.js';
3
3
  import { ConnectInterruptedError, spawnRemoval } from './connect-runtime.js';
4
4
  import { removeCursorMcpServer, planCursorWrite } from './cursor-config.js';
5
+ import { planOmpWrite, removeOmpMcpServer } from './omp-config.js';
5
6
  import { HERMES_KEY_VAR, hermesEnvPath, removeHermesKey, resolveHermesHome } from './hermes-env.js';
6
7
  import { clearHermesWebBackends, HERMES_PLUGIN_SHA } from './hermes-plugin.js';
7
8
  import { HARNESS_DISPLAY_NAMES as DISPLAY_NAMES } from './harness-detect.js';
@@ -11,6 +12,8 @@ const OPENCODE_UNINSTALL_POINTER = 'remove the tinyfish entry from ~/.config/ope
11
12
  export function planText(harness, mcpUrl, apiKey) {
12
13
  if (harness === 'cursor')
13
14
  return planCursorWrite(mcpUrl, apiKey);
15
+ if (harness === 'omp')
16
+ return planOmpWrite(mcpUrl, apiKey);
14
17
  // Only the keyed path seeds the .env; a keyless Hermes install writes nothing.
15
18
  if (harness === 'hermes' && apiKey) {
16
19
  return (`would run \`tinyfish connect hermes\` (harness-owned MCP write; the CLI writes ` +
@@ -23,6 +26,9 @@ export function uninstallPlanText(harness) {
23
26
  if (harness === 'cursor') {
24
27
  return 'would remove the tinyfish entry from ~/.cursor/mcp.json';
25
28
  }
29
+ if (harness === 'omp') {
30
+ return "would remove the tinyfish entry from omp's mcp.json (located via `omp config path`)";
31
+ }
26
32
  if (!uninstallRuns(harness)) {
27
33
  return `would print \`${uninstallPointer(harness)}\` (no removal command exists)`;
28
34
  }
@@ -170,10 +176,21 @@ function uninstallViaCommands(detection, verbose, binaryOnPath) {
170
176
  fixCommand: `Retry by hand: ${uninstallPointer(harness)}`,
171
177
  };
172
178
  }
173
- function uninstallCursor(detection) {
179
+ /** The CLI wrote these mcp.json entries, so it removes them. */
180
+ const MCP_JSON_UNINSTALLS = {
181
+ cursor: {
182
+ remove: removeCursorMcpServer,
183
+ fix: 'Fix ~/.cursor/mcp.json by hand, then re-run: tinyfish connect --all --uninstall',
184
+ },
185
+ omp: {
186
+ remove: removeOmpMcpServer,
187
+ fix: "Fix omp's mcp.json (at `omp config path`) by hand, then re-run: tinyfish connect --all --uninstall",
188
+ },
189
+ };
190
+ function uninstallMcpJson(detection, spec) {
174
191
  let result;
175
192
  try {
176
- result = removeCursorMcpServer();
193
+ result = spec.remove();
177
194
  }
178
195
  catch (e) {
179
196
  result = { status: 'corrupt_skip', error: e instanceof Error ? e.message : String(e) };
@@ -183,20 +200,21 @@ function uninstallCursor(detection) {
183
200
  ...detection,
184
201
  outcome: 'uninstalled',
185
202
  removalFailed: true,
186
- errorMessage: `could not update Cursor's mcp.json (${result.error}) — nothing removed.`,
187
- fixCommand: 'Fix ~/.cursor/mcp.json by hand, then re-run: tinyfish connect --all --uninstall',
203
+ errorMessage: `could not update ${DISPLAY_NAMES[detection.harness]}'s mcp.json (${result.error}) — nothing removed.`,
204
+ fixCommand: spec.fix,
188
205
  };
189
206
  }
190
207
  if (result.status === 'unchanged') {
191
208
  return { ...detection, outcome: 'uninstall_noop' };
192
209
  }
193
- clearConnectContext('cursor');
210
+ clearConnectContext(detection.harness);
194
211
  return { ...detection, outcome: 'uninstalled' };
195
212
  }
196
213
  export function uninstallHarness(harness, configPath, verbose, binaryOnPath) {
197
214
  const detection = { harness, detected: true, configPath, installed: false };
198
- const result = harness === 'cursor'
199
- ? uninstallCursor(detection)
215
+ const mcpJson = MCP_JSON_UNINSTALLS[harness];
216
+ const result = mcpJson
217
+ ? uninstallMcpJson(detection, mcpJson)
200
218
  : uninstallViaCommands(detection, verbose, binaryOnPath);
201
219
  if (harness !== 'hermes')
202
220
  return result;
@@ -9,6 +9,7 @@ import { actionable, computeExitCode, reloadHint, renderSummary } from './connec
9
9
  import { planText, uninstallHarness, uninstallPlanText } from './connect-all-uninstall.js';
10
10
  import { NATIVE_BY_HARNESS } from './connect-clients.js';
11
11
  import { createStdinPrompt, runCliFallback } from './connect-fallback.js';
12
+ import { gateApiKey } from './connect-preflight.js';
12
13
  import { ConnectInterruptedError } from './connect-runtime.js';
13
14
  import { pickHarnesses } from './connect-picker.js';
14
15
  import { commandOnPath, detectInstalledHarnesses, HARNESS_COMMANDS, HARNESS_DISPLAY_NAMES as DISPLAY_NAMES, ALL_HARNESSES, } from './harness-detect.js';
@@ -142,7 +143,10 @@ function noWorkOutcome(d, uninstall) {
142
143
  }
143
144
  /** A config-writing connect needs no binary; every other harness spawns one. */
144
145
  function isStaleConfig(detection) {
145
- return (detection.detected && !detection.binaryOnPath && !harnessSpec(detection.harness).cliWritesConfig);
146
+ const spec = harnessSpec(detection.harness);
147
+ // Cursor's IDE reads mcp.json binary-less; omp's path needs the binary.
148
+ const binaryless = spec.cliWritesConfig && !spec.configPathFromBinary;
149
+ return detection.detected && !detection.binaryOnPath && !binaryless;
146
150
  }
147
151
  async function resolvePick(detections, opts, prompt) {
148
152
  let deselected = new Set();
@@ -212,8 +216,13 @@ async function connectLoop(detections, opts, deselected, io) {
212
216
  try {
213
217
  setErrIndent(STEP_INDENT);
214
218
  result = await processHarness(detection, opts, io.prompt, oauthBudget, io.takeHoistedCli, (line) => io.outro.add(line));
219
+ // A failed harness prints nothing of its own, so its step reads as skipped.
220
+ if (result.outcome === 'failed' && result.errorMessage)
221
+ errLine(result.errorMessage);
215
222
  }
216
223
  catch (e) {
224
+ const errorMessage = e instanceof Error ? e.message : String(e);
225
+ errLine(errorMessage);
217
226
  // Isolation: one harness throwing never stops the others.
218
227
  result = {
219
228
  harness,
@@ -221,7 +230,7 @@ async function connectLoop(detections, opts, deselected, io) {
221
230
  configPath: detection.configPath,
222
231
  installed: false,
223
232
  outcome: 'failed',
224
- errorMessage: e instanceof Error ? e.message : String(e),
233
+ errorMessage,
225
234
  fixCommand: `tinyfish connect ${harness}`,
226
235
  };
227
236
  }
@@ -295,11 +304,32 @@ async function finishRun(results, opts, isTTY) {
295
304
  }
296
305
  return exitCode;
297
306
  }
307
+ /** Neither path needs a live key, and neither writes a registration. */
308
+ function keyGateApplies(opts) {
309
+ return !opts.uninstall && !opts.dryRun;
310
+ }
311
+ async function blockedByKeyGate(opts) {
312
+ if (!keyGateApplies(opts))
313
+ return false;
314
+ const pick = opts.pick === true;
315
+ const gate = await gateApiKey({
316
+ apiKey: opts.apiKey,
317
+ mcpUrl: opts.mcpUrl,
318
+ // The picker path is reached by a bare `tinyfish connect`.
319
+ retryCommand: pick ? 'tinyfish connect' : 'tinyfish connect --all',
320
+ mode: pick ? 'pick' : 'all',
321
+ attemptId: opts.attemptId,
322
+ });
323
+ return gate === 'blocked';
324
+ }
298
325
  export async function runConnectAll(opts) {
299
326
  const isTTY = detectHumanInitiated();
327
+ // Above the setup event and every write: a blocked run is not a setup attempt.
328
+ if (await blockedByKeyGate(opts))
329
+ return 1;
300
330
  // Ahead of detection and the hoisted install; either can end the process silently.
301
331
  // Not on the picker path: an explicit cancel is not a setup.
302
- if (!opts.uninstall && !opts.dryRun && !opts.pick) {
332
+ if (keyGateApplies(opts) && !opts.pick) {
303
333
  // validatedApiKey falls back to env/config; opts.apiKey alone splits the run's two ends.
304
334
  await sendSetupStarted(opts.mcpUrl, opts.attemptId, validatedApiKey(opts.apiKey));
305
335
  }
@@ -377,6 +407,7 @@ export const FIRST_TASK_PROMPT = 'My TinyFish setup just finished — verify it
377
407
  const LAUNCHERS = [
378
408
  { harness: 'cursor', args: [FIRST_TASK_PROMPT] },
379
409
  { harness: 'claude-code', args: [FIRST_TASK_PROMPT] },
410
+ { harness: 'omp', args: [FIRST_TASK_PROMPT] },
380
411
  ];
381
412
  async function launchFirstTask(results) {
382
413
  const candidates = LAUNCHERS.filter((l) => results.some((r) => r.harness === l.harness && r.installed) &&
@@ -95,11 +95,12 @@ export declare function hermesRegistrationEnabled(home: string): {
95
95
  };
96
96
  export declare const NATIVE_MCP_CLIENTS: readonly NativeMcpClient[];
97
97
  /** Native descriptor by harness id; Cursor and OpenClaw have none. */
98
- export declare const NATIVE_BY_HARNESS: Map<"openclaw" | "grok" | "cursor" | "codex" | "hermes" | "opencode" | "claude-code", NativeMcpClient>;
98
+ export declare const NATIVE_BY_HARNESS: Map<"openclaw" | "omp" | "grok" | "cursor" | "codex" | "hermes" | "opencode" | "claude-code", NativeMcpClient>;
99
99
  export declare const OPENCLAW: SupportedCommand;
100
100
  /** "printed" = handed to the user to paste; the walkthrough was never started for them. */
101
101
  export type WalkthroughOutcome = 'launched' | 'printed';
102
102
  /** Awaited before the handover; track+flush must land pre-block. */
103
103
  type OnWalkthroughDecided = (outcome: WalkthroughOutcome) => void | Promise<void>;
104
104
  export declare function launchNativeMcpClient(client: NativeMcpClient, onDecided?: OnWalkthroughDecided): Promise<WalkthroughOutcome>;
105
+ export declare function launchOmpWalkthrough(onDecided?: OnWalkthroughDecided): Promise<WalkthroughOutcome>;
105
106
  export declare function launchOpenClawWalkthrough(onDecided?: OnWalkthroughDecided): Promise<WalkthroughOutcome>;
@@ -293,6 +293,10 @@ export async function launchNativeMcpClient(client, onDecided) {
293
293
  ? client.launchWalkthrough()
294
294
  : handOverTerminal(client.command, [DEFAULT_ONBOARDING_PROMPT], client.displayName), onDecided);
295
295
  }
296
+ // Via deliverWalkthrough: handOverTerminal alone would wedge a headless --launch.
297
+ export function launchOmpWalkthrough(onDecided) {
298
+ return deliverWalkthrough('omp', 'omp', DEFAULT_ONBOARDING_PROMPT, () => handOverTerminal('omp', [DEFAULT_ONBOARDING_PROMPT], 'omp'), onDecided);
299
+ }
296
300
  export function launchOpenClawWalkthrough(onDecided) {
297
301
  return deliverWalkthrough('OpenClaw', 'openclaw', OPENCLAW_ONBOARDING_PROMPT, () => handOverTerminal('openclaw', ['chat', '--message', OPENCLAW_ONBOARDING_PROMPT], 'OpenClaw'), onDecided);
298
302
  }
@@ -0,0 +1,15 @@
1
+ import { type SetupBlockedMode } from './setup-telemetry.js';
2
+ export type PreflightOutcome = 'ok' | 'blocked';
3
+ interface PreflightOptions {
4
+ apiKey?: string;
5
+ mcpUrl: string;
6
+ retryCommand: string;
7
+ mode: SetupBlockedMode;
8
+ attemptId?: string;
9
+ }
10
+ /**
11
+ * Checks the key every harness would install with, once, before anything is written.
12
+ * A bad key otherwise fails all seven installs the slow way, and Hermes refuses outright.
13
+ */
14
+ export declare function gateApiKey(options: PreflightOptions): Promise<PreflightOutcome>;
15
+ export {};