@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.
@@ -0,0 +1,67 @@
1
+ import { apiKeyStatus, configFile, validateKeyFormat } from './auth.js';
2
+ import { TINYFISH_API_KEY_VAR } from './constants.js';
3
+ import { apiBaseFromMcpUrl } from './mcp-endpoint.js';
4
+ import { errLine, warnLine } from './output.js';
5
+ import { sendSetupBlocked, } from './setup-telemetry.js';
6
+ import { verifyMcpAuth } from './verify.js';
7
+ function sourceAdvice(source) {
8
+ switch (source) {
9
+ case 'env':
10
+ return `The key came from ${TINYFISH_API_KEY_VAR}, which overrides a saved key — fix or unset that export.`;
11
+ case 'explicit':
12
+ return 'The key came from --api-key.';
13
+ default:
14
+ return `The key came from ${configFile()}. Save a working one: tinyfish auth login, or pipe a key to tinyfish auth set.`;
15
+ }
16
+ }
17
+ async function block(detail, options) {
18
+ errLine(`${detail.message} Nothing was installed.`);
19
+ errLine(sourceAdvice(detail.source));
20
+ errLine(`Then re-run: ${options.retryCommand}`);
21
+ await sendSetupBlocked(options.mcpUrl, {
22
+ blockReason: detail.reason,
23
+ keySource: detail.source,
24
+ ...(detail.status === undefined ? {} : { status: detail.status }),
25
+ mode: options.mode,
26
+ attemptId: options.attemptId,
27
+ }, detail.key);
28
+ return 'blocked';
29
+ }
30
+ /**
31
+ * Checks the key every harness would install with, once, before anything is written.
32
+ * A bad key otherwise fails all seven installs the slow way, and Hermes refuses outright.
33
+ */
34
+ export async function gateApiKey(options) {
35
+ const status = apiKeyStatus(options.apiKey);
36
+ // No key at all is a valid answer: the OAuth harnesses still connect without one.
37
+ if (status.source === 'none')
38
+ return 'ok';
39
+ const source = status.source;
40
+ if (!('key' in status)) {
41
+ return block({ reason: 'key_unreadable', source, message: `${status.error}.` }, options);
42
+ }
43
+ // Downstream this key is dropped as unusable, which reads as "no key" and silently
44
+ // downgrades every install to a browser sign-in Hermes cannot take.
45
+ if (!validateKeyFormat(status.key)) {
46
+ return block({
47
+ reason: 'key_malformed',
48
+ source,
49
+ message: 'Your TinyFish API key is not shaped like one (expected sk-tinyfish-... or sk-mino-...).',
50
+ }, options);
51
+ }
52
+ const verified = await verifyMcpAuth(status.key, apiBaseFromMcpUrl(options.mcpUrl));
53
+ if (verified.ok)
54
+ return 'ok';
55
+ // No status means the network failed, and an outage must not block a whole setup.
56
+ if (verified.status === undefined) {
57
+ warnLine(`Could not check your TinyFish API key (${verified.code ?? 'unknown'}); continuing.`);
58
+ return 'ok';
59
+ }
60
+ return block({
61
+ reason: 'key_rejected',
62
+ source,
63
+ message: `Your TinyFish API key was rejected (${verified.code ?? 'unknown'}).`,
64
+ key: status.key,
65
+ status: verified.status,
66
+ }, options);
67
+ }
@@ -0,0 +1,9 @@
1
+ import type { DoctorCheck, DoctorOptions, DoctorRepair } from './doctor-report.js';
2
+ import type { RegistrationStatus } from './registration-detect.js';
3
+ export declare function repairsFor(checks: DoctorCheck[], statuses: RegistrationStatus[]): DoctorRepair[];
4
+ export interface RepairOutcome {
5
+ repair: DoctorRepair;
6
+ status: 'repaired' | 'skipped' | 'failed';
7
+ reason?: string;
8
+ }
9
+ export declare function applyRepairs(repairs: DoctorRepair[], options: DoctorOptions): Promise<RepairOutcome[]>;
@@ -0,0 +1,119 @@
1
+ import spawn from 'cross-spawn';
2
+ import { connectHarness } from '../commands/connect.js';
3
+ import { ALL_HARNESSES, Registered } from './harness-detect.js';
4
+ import { harnessSpec } from './harness-spec.js';
5
+ import { detectHumanInitiated } from './harness.js';
6
+ import { errLine } from './output.js';
7
+ // Config-file repairs are local writes; the rest need browser sign-in.
8
+ const UNATTENDED_SAFE = new Set(ALL_HARNESSES.filter((harness) => harnessSpec(harness).cliWritesConfig));
9
+ export function repairsFor(checks, statuses) {
10
+ const repairs = [];
11
+ // A revoked-but-well-formed key passes the credential check and fails the call; both need login.
12
+ const credentialBroken = checks.some((c) => (c.id === 'cli-credential' || c.id === 'cli-auth-call') && c.status === 'fail');
13
+ // connect writes the stored key, so replace a dead one first.
14
+ if (credentialBroken) {
15
+ repairs.push({
16
+ for: 'cli-credential-invalid',
17
+ action: 'auth-login',
18
+ harness: null,
19
+ command: 'tinyfish auth login',
20
+ unattended_safe: false,
21
+ });
22
+ }
23
+ const authCallPassed = checks.some((c) => c.id === 'cli-auth-call' && c.status === 'pass');
24
+ for (const status of statuses) {
25
+ // `unknown` earns nothing: connect would contradict the check's detail.
26
+ // A registered entry that still fails earns one too.
27
+ // `connected: false` is the harness's own no; it only warns, so it earns a repair here.
28
+ const brokenRegistration = status.connected === false ||
29
+ checks.some((c) => c.harness === status.harness && c.id !== 'hermes-plugin' && c.status === 'fail');
30
+ if (!status.detected || (status.registered !== Registered.No && !brokenRegistration))
31
+ continue;
32
+ repairs.push({
33
+ for: status.registered === Registered.Yes
34
+ ? `${status.harness}-registration-broken`
35
+ : status.connectedBefore
36
+ ? `${status.harness}-registration-lost`
37
+ : `${status.harness}-not-connected`,
38
+ action: 'connect',
39
+ harness: status.harness,
40
+ command: `tinyfish connect ${status.harness}`,
41
+ // A dead well-formed key passes the format check.
42
+ unattended_safe: UNATTENDED_SAFE.has(status.harness) && authCallPassed,
43
+ });
44
+ }
45
+ // connect hermes reinstalls the plugin; a second row would double it.
46
+ const pluginBroken = checks.some((c) => c.id === 'hermes-plugin' && c.status === 'fail');
47
+ if (pluginBroken && !repairs.some((repair) => repair.harness === 'hermes')) {
48
+ repairs.push({
49
+ for: 'hermes-plugin-broken',
50
+ action: 'connect',
51
+ harness: 'hermes',
52
+ command: 'tinyfish connect hermes',
53
+ unattended_safe: false,
54
+ });
55
+ }
56
+ return repairs;
57
+ }
58
+ // Shell out rather than import the login flow: `auth login` owns its own browser and prompts.
59
+ // Re-invoke this same entry script, never a global `tinyfish` — under `npx` there is no such
60
+ // binary on PATH, and npx is the install-nothing path doctor exists to serve.
61
+ function runAuthLogin() {
62
+ const entry = process.argv[1];
63
+ if (!entry)
64
+ throw new Error('Could not locate the TinyFish CLI entry point to re-invoke');
65
+ const result = spawn.sync(process.execPath, [entry, 'auth', 'login', '--source', 'doctor'], {
66
+ stdio: 'inherit',
67
+ });
68
+ if (result.error)
69
+ throw new Error('Could not run `tinyfish auth login`', { cause: result.error });
70
+ if (result.status !== 0) {
71
+ throw new Error(`\`tinyfish auth login\` exited ${result.status ?? 'unknown'}`);
72
+ }
73
+ }
74
+ /** Dispatches on `action`, never on a null harness: that conflation made the credential fix a no-op. */
75
+ async function runRepair(repair, mcpUrl) {
76
+ if (repair.action === 'auth-login')
77
+ return runAuthLogin();
78
+ if (repair.harness)
79
+ await connectHarness(repair.harness, { mcpUrl, launch: false });
80
+ }
81
+ export async function applyRepairs(repairs, options) {
82
+ const interactive = detectHumanInitiated();
83
+ const outcomes = [];
84
+ for (const repair of repairs) {
85
+ if (!interactive && !options.yes) {
86
+ outcomes.push({
87
+ repair,
88
+ status: 'skipped',
89
+ reason: 'no terminal; re-run with --yes to repair unattended',
90
+ });
91
+ continue;
92
+ }
93
+ if (!interactive && !repair.unattended_safe) {
94
+ outcomes.push({
95
+ repair,
96
+ status: 'skipped',
97
+ reason: 'needs a browser sign-in, which has no unattended path',
98
+ });
99
+ continue;
100
+ }
101
+ try {
102
+ await runRepair(repair, options.mcpUrl);
103
+ outcomes.push({ repair, status: 'repaired' });
104
+ }
105
+ catch (e) {
106
+ // Connect quotes absolute config paths in its errors, so the raw text stays behind
107
+ // --debug and the user is told where to find it rather than handed a generic dead end.
108
+ if (process.env['TINYFISH_DEBUG']) {
109
+ errLine(e instanceof Error ? (e.stack ?? e.message) : String(e));
110
+ }
111
+ outcomes.push({
112
+ repair,
113
+ status: 'failed',
114
+ reason: `\`${repair.command}\` failed; re-run it directly, or --debug for the raw error`,
115
+ });
116
+ }
117
+ }
118
+ return outcomes;
119
+ }
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { AuthMode, Registered } from './harness-detect.js';
2
+ import { AuthMode, type Harness, Registered } from './harness-detect.js';
3
3
  /** Bumped whenever a consumer could misread the payload; the cookbook skill releases separately. */
4
4
  export declare const DOCTOR_SCHEMA_VERSION = 3;
5
5
  declare const checkStatusSchema: z.ZodEnum<{
@@ -25,6 +25,7 @@ declare const doctorCheckSchema: z.ZodObject<{
25
25
  detail: z.ZodString;
26
26
  harness: z.ZodNullable<z.ZodEnum<{
27
27
  openclaw: "openclaw";
28
+ omp: "omp";
28
29
  grok: "grok";
29
30
  cursor: "cursor";
30
31
  codex: "codex";
@@ -41,6 +42,7 @@ declare const doctorCheckSchema: z.ZodObject<{
41
42
  declare const doctorHarnessSchema: z.ZodObject<{
42
43
  harness: z.ZodEnum<{
43
44
  openclaw: "openclaw";
45
+ omp: "omp";
44
46
  grok: "grok";
45
47
  cursor: "cursor";
46
48
  codex: "codex";
@@ -66,6 +68,7 @@ declare const doctorRepairSchema: z.ZodObject<{
66
68
  }>;
67
69
  harness: z.ZodNullable<z.ZodEnum<{
68
70
  openclaw: "openclaw";
71
+ omp: "omp";
69
72
  grok: "grok";
70
73
  cursor: "cursor";
71
74
  codex: "codex";
@@ -93,6 +96,7 @@ export declare const doctorReportSchema: z.ZodObject<{
93
96
  detail: z.ZodString;
94
97
  harness: z.ZodNullable<z.ZodEnum<{
95
98
  openclaw: "openclaw";
99
+ omp: "omp";
96
100
  grok: "grok";
97
101
  cursor: "cursor";
98
102
  codex: "codex";
@@ -109,6 +113,7 @@ export declare const doctorReportSchema: z.ZodObject<{
109
113
  harnesses: z.ZodArray<z.ZodObject<{
110
114
  harness: z.ZodEnum<{
111
115
  openclaw: "openclaw";
116
+ omp: "omp";
112
117
  grok: "grok";
113
118
  cursor: "cursor";
114
119
  codex: "codex";
@@ -134,6 +139,7 @@ export declare const doctorReportSchema: z.ZodObject<{
134
139
  }>;
135
140
  harness: z.ZodNullable<z.ZodEnum<{
136
141
  openclaw: "openclaw";
142
+ omp: "omp";
137
143
  grok: "grok";
138
144
  cursor: "cursor";
139
145
  codex: "codex";
@@ -151,6 +157,12 @@ export type DoctorCheck = z.infer<typeof doctorCheckSchema>;
151
157
  export type DoctorHarness = z.infer<typeof doctorHarnessSchema>;
152
158
  export type DoctorRepair = z.infer<typeof doctorRepairSchema>;
153
159
  export type DoctorReport = z.infer<typeof doctorReportSchema>;
160
+ export interface DoctorOptions {
161
+ mcpUrl: string;
162
+ harness?: Harness;
163
+ fix?: boolean;
164
+ yes?: boolean;
165
+ }
154
166
  /** Doctor itself broke, which is a different claim from "your setup is broken". */
155
167
  export declare const DOCTOR_COULD_NOT_RUN = 2;
156
168
  /** The invocation was wrong, not the machine; exit 2 stays "doctor broke". */
@@ -0,0 +1,9 @@
1
+ import { type DoctorOptions, type DoctorRepair, type DoctorReport } from './doctor-report.js';
2
+ import type { RepairOutcome } from './doctor-repairs.js';
3
+ import { type DoctorCompletedPayload, type DoctorCouldNotRunPayload } from './setup-telemetry.js';
4
+ /** Enumerated fields only; check `detail` text never leaves the machine. */
5
+ export declare function telemetryPayload(report: DoctorReport, offered: DoctorRepair[], outcomes: RepairOutcome[], options: DoctorOptions, durationMs: number): DoctorCompletedPayload;
6
+ /** Both paths send keyless when there is no key; the route records those anonymously. */
7
+ export declare function telemetryKey(): string | undefined;
8
+ /** Enumerated outcome and timing only; the raw error stays behind --debug. */
9
+ export declare function couldNotRunPayload(error: unknown, options: DoctorOptions, durationMs: number): DoctorCouldNotRunPayload;
@@ -0,0 +1,61 @@
1
+ import { apiKeyStatus } from './auth.js';
2
+ import { CLI_VERSION } from './constants.js';
3
+ import { DOCTOR_SCHEMA_VERSION, } from './doctor-report.js';
4
+ import { detectHumanInitiated } from './harness.js';
5
+ import { doctorErrorClass, } from './setup-telemetry.js';
6
+ /** Mirrors the route's cap; over it the payload would 400 and never be retried. */
7
+ const MAX_DURATION_MS = 3_600_000;
8
+ function fixModeOf(options) {
9
+ return !options.fix ? 'none' : options.yes ? 'fix_yes' : 'fix';
10
+ }
11
+ /** Enumerated fields only; check `detail` text never leaves the machine. */
12
+ export function telemetryPayload(report, offered, outcomes, options, durationMs) {
13
+ const count = (status) => report.checks.filter((check) => check.status === status).length;
14
+ const failed = report.checks.filter((check) => check.status === 'fail');
15
+ return {
16
+ outcome: 'completed',
17
+ schema_version: report.schema_version,
18
+ cli_version: report.cli_version,
19
+ ok_harnesses: report.ok_harnesses,
20
+ ok_cli: report.ok_cli,
21
+ checks_pass: count('pass'),
22
+ checks_fail: count('fail'),
23
+ checks_warn: count('warn'),
24
+ checks_skip: count('skip'),
25
+ // From the pre-repair report; the verdicts above describe the machine after it.
26
+ repairs_offered: offered.length,
27
+ repair_actions: [...new Set(offered.map((repair) => repair.action))],
28
+ repairs_attempted: outcomes.filter((outcome) => outcome.status !== 'skipped').length,
29
+ repairs_succeeded: outcomes.filter((outcome) => outcome.status === 'repaired').length,
30
+ fix_mode: fixModeOf(options),
31
+ // --harness scopes detection, so the counts above cover that harness alone.
32
+ harness_scope: options.harness ?? 'all',
33
+ // Same signal cli_connect_stage carries, so agent and human doctor runs split.
34
+ is_human_initiated: detectHumanInitiated(),
35
+ // Two flat arrays rather than one of pairs: PostHog groups by element, not by field.
36
+ failed_check_ids: [...new Set(failed.map((check) => check.id))],
37
+ failed_harnesses: [...new Set(failed.map((check) => check.harness).filter((h) => h !== null))],
38
+ harnesses: report.harnesses,
39
+ // Clamped, not validated: a --fix parked at a prompt must not fail the parse and drop the run.
40
+ duration_ms: Math.min(durationMs, MAX_DURATION_MS),
41
+ };
42
+ }
43
+ /** Both paths send keyless when there is no key; the route records those anonymously. */
44
+ export function telemetryKey() {
45
+ const resolved = apiKeyStatus();
46
+ return 'key' in resolved ? resolved.key : undefined;
47
+ }
48
+ /** Enumerated outcome and timing only; the raw error stays behind --debug. */
49
+ export function couldNotRunPayload(error, options, durationMs) {
50
+ return {
51
+ outcome: 'could_not_run',
52
+ error_class: doctorErrorClass(error),
53
+ schema_version: DOCTOR_SCHEMA_VERSION,
54
+ cli_version: CLI_VERSION,
55
+ fix_mode: fixModeOf(options),
56
+ harness_scope: options.harness ?? 'all',
57
+ is_human_initiated: detectHumanInitiated(),
58
+ // Clamped like the completed row: over the cap the payload would 400 un-retried.
59
+ duration_ms: Math.min(durationMs, MAX_DURATION_MS),
60
+ };
61
+ }
@@ -68,6 +68,8 @@ export interface HarnessSpec {
68
68
  keyHeldByCli: boolean;
69
69
  /** Connect writes the MCP config file; no harness binary is spawned. */
70
70
  cliWritesConfig: boolean;
71
+ /** Binary resolves the config path; absent binary, nothing reads it. */
72
+ configPathFromBinary?: true;
71
73
  /** `mcp add` succeeds unauthenticated; OAuth lands at first tool use. */
72
74
  authDeferredAtInstall: boolean;
73
75
  }
@@ -199,6 +201,18 @@ export declare const HARNESS_SPECS: {
199
201
  cliWritesConfig: false;
200
202
  authDeferredAtInstall: false;
201
203
  };
204
+ omp: {
205
+ command: string;
206
+ displayName: string;
207
+ configDir: string;
208
+ reloadAction: string;
209
+ configPathFromBinary: true;
210
+ signInViaCliLogin: false;
211
+ canVerifyAuth: true;
212
+ keyHeldByCli: true;
213
+ cliWritesConfig: true;
214
+ authDeferredAtInstall: false;
215
+ };
202
216
  openclaw: {
203
217
  command: string;
204
218
  displayName: string;
@@ -252,4 +266,4 @@ export type NonNativeHarness = {
252
266
  [K in Harness]: 'urlStyle' extends keyof (typeof HARNESS_SPECS)[K] ? never : K;
253
267
  }[Harness];
254
268
  /** Native MCP harnesses carry add-generation fields; the rest override connect. */
255
- export declare const NATIVE_HARNESSES: ("openclaw" | "grok" | "cursor" | "codex" | "hermes" | "opencode" | "claude-code")[];
269
+ export declare const NATIVE_HARNESSES: ("openclaw" | "omp" | "grok" | "cursor" | "codex" | "hermes" | "opencode" | "claude-code")[];
@@ -183,6 +183,18 @@ export const HARNESS_SPECS = {
183
183
  cliWritesConfig: false,
184
184
  authDeferredAtInstall: false,
185
185
  },
186
+ omp: {
187
+ command: 'omp',
188
+ displayName: 'omp',
189
+ configDir: '.omp',
190
+ reloadAction: 'restart it',
191
+ configPathFromBinary: true,
192
+ signInViaCliLogin: false,
193
+ canVerifyAuth: true,
194
+ keyHeldByCli: true,
195
+ cliWritesConfig: true,
196
+ authDeferredAtInstall: false,
197
+ },
186
198
  openclaw: {
187
199
  command: 'openclaw',
188
200
  displayName: 'OpenClaw',
@@ -20,6 +20,8 @@ export interface McpJsonServerEntry {
20
20
  /** Registered endpoint, so a caller can tell "registered" from "registered at the right place". */
21
21
  url?: string;
22
22
  keyMatchesCliKey?: boolean;
23
+ /** Whole-value `${VAR}` template: the variable name, never a key. */
24
+ keyTemplateVar?: string;
23
25
  error?: string;
24
26
  }
25
27
  /** Reports the header's shape, never its value. */
@@ -56,6 +56,8 @@ export function planWrite(target, mcpUrl, apiKey) {
56
56
  ? `${filePath}: would create with a "${target.serverKey}" MCP server entry${authNote}`
57
57
  : `${filePath}: would back up to a timestamped copy, then merge in the "${target.serverKey}" MCP server entry${authNote}`;
58
58
  }
59
+ // omp expands `${VAR}` / `${VAR:-default}` header values at load.
60
+ const ENV_TEMPLATE_VALUE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}$/;
59
61
  /** Reports the header's shape, never its value. */
60
62
  export function readTinyfishEntry(target) {
61
63
  const existing = readExisting(target);
@@ -71,10 +73,12 @@ export function readTinyfishEntry(target) {
71
73
  const keyHeader = isPlainRecord(headers)
72
74
  ? Object.entries(headers).find((entry) => entry[0].toLowerCase() === 'x-api-key' && typeof entry[1] === 'string')
73
75
  : undefined;
76
+ const templateVar = keyHeader ? ENV_TEMPLATE_VALUE.exec(keyHeader[1])?.[1] : undefined;
74
77
  return {
75
78
  present: true,
76
79
  hasApiKeyHeader: keyHeader !== undefined,
77
80
  ...(matchesCliKey(keyHeader?.[1]) ? { keyMatchesCliKey: true } : {}),
81
+ ...(templateVar ? { keyTemplateVar: templateVar } : {}),
78
82
  ...(typeof entry.url === 'string' ? { url: entry.url } : {}),
79
83
  };
80
84
  }
@@ -0,0 +1,16 @@
1
+ import { type McpJsonServerEntry, type McpJsonTarget, type McpJsonWriteResult } from './mcp-json-config.js';
2
+ /** Tests share one process; the resolver result must not. */
3
+ export declare function resetOmpAgentDirCache(): void;
4
+ /** Profiles move the dir; a guessed path is written unread. */
5
+ export declare function ompAgentDir(): string | undefined;
6
+ /** Undefined means unresolved; connect fails closed via requireOmpTarget. */
7
+ export declare function ompMcpTarget(): McpJsonTarget | undefined;
8
+ export declare function ompMcpPath(): string;
9
+ /** Reports the header's shape, never its value; undefined means unresolved. */
10
+ export declare function readOmpTinyfishEntry(): McpJsonServerEntry | undefined;
11
+ /** Merges only the `tinyfish` key; throws when the dir is unresolved. */
12
+ export declare function writeOmpMcpConfig(mcpUrl: string, apiKey?: string): McpJsonWriteResult;
13
+ /** Dry-run description of the pending write; touches nothing. */
14
+ export declare function planOmpWrite(mcpUrl: string, apiKey?: string): string;
15
+ /** Removes only the `tinyfish` key. */
16
+ export declare function removeOmpMcpServer(): McpJsonWriteResult;
@@ -0,0 +1,71 @@
1
+ import * as path from 'path';
2
+ import spawn from 'cross-spawn';
3
+ import { ConnectStepError } from './connect-runtime.js';
4
+ import { HARNESS_PROBE_TIMEOUT_MS } from './constants.js';
5
+ import { planWrite, readTinyfishEntry, removeServer, writeMcpConfig, } from './mcp-json-config.js';
6
+ const UNRESOLVED_REASON = '`omp config path` did not report a config directory';
7
+ // Cached: every later read must agree with the write.
8
+ let cachedAgentDir;
9
+ /** Tests share one process; the resolver result must not. */
10
+ export function resetOmpAgentDirCache() {
11
+ cachedAgentDir = undefined;
12
+ }
13
+ /** Profiles move the dir; a guessed path is written unread. */
14
+ export function ompAgentDir() {
15
+ cachedAgentDir ??= { dir: resolveAgentDir() };
16
+ return cachedAgentDir.dir;
17
+ }
18
+ function resolveAgentDir() {
19
+ const result = spawn.sync('omp', ['config', 'path'], {
20
+ encoding: 'utf8',
21
+ timeout: HARNESS_PROBE_TIMEOUT_MS,
22
+ });
23
+ if (result.error || result.status !== 0)
24
+ return undefined;
25
+ const dir = (result.stdout ?? '')
26
+ .split('\n')
27
+ .map((line) => line.trim())
28
+ .filter(Boolean)
29
+ .at(-1);
30
+ return dir && path.isAbsolute(dir) ? dir : undefined;
31
+ }
32
+ /** Undefined means unresolved; connect fails closed via requireOmpTarget. */
33
+ export function ompMcpTarget() {
34
+ const dir = ompAgentDir();
35
+ if (!dir)
36
+ return undefined;
37
+ return { serverKey: 'tinyfish', dir: () => dir, file: () => path.join(dir, 'mcp.json') };
38
+ }
39
+ function requireOmpTarget() {
40
+ const target = ompMcpTarget();
41
+ if (target)
42
+ return target;
43
+ throw new ConnectStepError("Could not locate omp's config directory: `omp config path` did not print one. " +
44
+ 'Check that omp is installed and `omp config path` works, then re-run: tinyfish connect omp', 'invalid_config', { failureDetail: 'omp_agent_dir_unresolved' });
45
+ }
46
+ export function ompMcpPath() {
47
+ return requireOmpTarget().file();
48
+ }
49
+ /** Reports the header's shape, never its value; undefined means unresolved. */
50
+ export function readOmpTinyfishEntry() {
51
+ const target = ompMcpTarget();
52
+ return target ? readTinyfishEntry(target) : undefined;
53
+ }
54
+ /** Merges only the `tinyfish` key; throws when the dir is unresolved. */
55
+ export function writeOmpMcpConfig(mcpUrl, apiKey) {
56
+ return writeMcpConfig(requireOmpTarget(), mcpUrl, apiKey);
57
+ }
58
+ /** Dry-run description of the pending write; touches nothing. */
59
+ export function planOmpWrite(mcpUrl, apiKey) {
60
+ const target = ompMcpTarget();
61
+ if (!target)
62
+ return `${UNRESOLVED_REASON} — connect omp would fail the same way`;
63
+ return planWrite(target, mcpUrl, apiKey);
64
+ }
65
+ /** Removes only the `tinyfish` key. */
66
+ export function removeOmpMcpServer() {
67
+ const target = ompMcpTarget();
68
+ if (!target)
69
+ return { status: 'corrupt_skip', error: UNRESOLVED_REASON };
70
+ return removeServer(target);
71
+ }
@@ -7,6 +7,7 @@ import { NATIVE_BY_HARNESS } from './connect-clients.js';
7
7
  import { HARNESS_PROBE_TIMEOUT_MS } from './constants.js';
8
8
  import { errLine } from './output.js';
9
9
  import { readCursorTinyfishEntry } from './cursor-config.js';
10
+ import { readOmpTinyfishEntry } from './omp-config.js';
10
11
  import { readHermesKey, resolveHermesHome } from './hermes-env.js';
11
12
  import { readHermesEntry } from './hermes-config.js';
12
13
  import { detectInstalledHarnesses, harnessConfigPath, harnessDisplayPath, AuthMode, Registered, } from './harness-detect.js';
@@ -293,6 +294,38 @@ function probeCursor() {
293
294
  ...(entry.keyMatchesCliKey ? { keyMatchesCliKey: true } : {}),
294
295
  };
295
296
  }
297
+ // omp shares Cursor's mcp.json shape; only `omp config path` resolves it.
298
+ function probeOmp() {
299
+ const entry = readOmpTinyfishEntry();
300
+ if (!entry) {
301
+ return {
302
+ registered: Registered.Unknown,
303
+ authMode: AuthMode.Unknown,
304
+ reason: '`omp config path` did not report a config directory',
305
+ };
306
+ }
307
+ if (entry.error) {
308
+ return {
309
+ registered: Registered.Unknown,
310
+ authMode: AuthMode.Unknown,
311
+ reason: 'mcp.json exists but could not be read or parsed',
312
+ };
313
+ }
314
+ if (!entry.present)
315
+ return { registered: Registered.No, authMode: AuthMode.Unknown };
316
+ // A hand-written `${VAR}` header resolves like Codex's env-var key.
317
+ const keyVerdict = entry.keyTemplateVar
318
+ ? envKeyVerdict(entry.keyTemplateVar)
319
+ : entry.keyMatchesCliKey
320
+ ? { keyMatchesCliKey: true }
321
+ : {};
322
+ return {
323
+ registered: Registered.Yes,
324
+ authMode: entry.hasApiKeyHeader ? AuthMode.ApiKey : AuthMode.Unknown,
325
+ ...(entry.url ? { registeredUrl: entry.url } : {}),
326
+ ...keyVerdict,
327
+ };
328
+ }
296
329
  // Both list commands print this header in every state, including "no servers configured".
297
330
  // Exit 0 with output we cannot recognise is not evidence of absence: reporting `no` there would
298
331
  // earn a connect repair off a parse failure, which is the bug class this command exists to kill.
@@ -486,6 +519,7 @@ const PROBES = {
486
519
  cursor: probeCursor,
487
520
  grok: probeGrok,
488
521
  hermes: probeHermes,
522
+ omp: probeOmp,
489
523
  openclaw: probeOpenClaw,
490
524
  opencode: probeOpencode,
491
525
  };
@@ -26,6 +26,7 @@ export declare function postConnectEvent(options: {
26
26
  declare const harnessResultSchema: z.ZodObject<{
27
27
  harness: z.ZodEnum<{
28
28
  openclaw: "openclaw";
29
+ omp: "omp";
29
30
  grok: "grok";
30
31
  cursor: "cursor";
31
32
  codex: "codex";
@@ -43,10 +44,23 @@ declare const harnessResultSchema: z.ZodObject<{
43
44
  }, z.core.$strip>;
44
45
  /** First act of a `--all` run; everything upstream of it stays invisible. */
45
46
  export declare function sendSetupStarted(mcpUrl: string, attemptId?: string, apiKey?: string): Promise<void>;
47
+ export type SetupBlockReason = 'key_rejected' | 'key_malformed' | 'key_unreadable';
48
+ export type SetupBlockedMode = 'all' | 'pick' | 'single';
49
+ export interface SetupBlockedDetail {
50
+ blockReason: SetupBlockReason;
51
+ keySource: 'env' | 'config' | 'explicit';
52
+ /** Only a server answer has one; a malformed key never reaches the API. */
53
+ status?: number;
54
+ mode: SetupBlockedMode;
55
+ attemptId?: string;
56
+ }
57
+ /** Terminal state for a run the key gate stopped; fires instead of setup_started. */
58
+ export declare function sendSetupBlocked(mcpUrl: string, detail: SetupBlockedDetail, apiKey?: string): Promise<void>;
46
59
  export declare const setupCompletedPayloadSchema: z.ZodObject<{
47
60
  harnesses: z.ZodArray<z.ZodObject<{
48
61
  harness: z.ZodEnum<{
49
62
  openclaw: "openclaw";
63
+ omp: "omp";
50
64
  grok: "grok";
51
65
  cursor: "cursor";
52
66
  codex: "codex";
@@ -79,7 +93,7 @@ export type UpgradeScope = 'both' | 'cli' | 'skill';
79
93
  /** `toVersion` is null when the run was interrupted or the installed version could not be read. */
80
94
  export declare function sendUpgradeCompleted(fromVersion: string, toVersion: string | null, outcome: UpgradeOutcome, scope: UpgradeScope, apiKey?: string): Promise<void>;
81
95
  export declare function sendSetupCompleted(mcpUrl: string, harnesses: HarnessResult[], apiKey?: string, mode?: SetupMode, attemptId?: string): Promise<void>;
82
- export declare const DOCTOR_HARNESS_SCOPES: readonly ["all", ...("openclaw" | "grok" | "cursor" | "codex" | "hermes" | "opencode" | "claude-code")[]];
96
+ export declare const DOCTOR_HARNESS_SCOPES: readonly ["all", ...("openclaw" | "omp" | "grok" | "cursor" | "codex" | "hermes" | "opencode" | "claude-code")[]];
83
97
  export declare const DOCTOR_ERROR_CLASSES: readonly ["command_not_found", "timeout", "spawn_error", "nonzero_exit", "unexpected_error"];
84
98
  export type DoctorErrorClass = (typeof DOCTOR_ERROR_CLASSES)[number];
85
99
  /** Derived, not read: the catch is generic, so only the code is trustworthy. */
@@ -108,6 +122,7 @@ export declare const doctorCompletedPayloadSchema: z.ZodObject<{
108
122
  }>;
109
123
  harness_scope: z.ZodEnum<{
110
124
  openclaw: "openclaw";
125
+ omp: "omp";
111
126
  grok: "grok";
112
127
  cursor: "cursor";
113
128
  codex: "codex";
@@ -120,6 +135,7 @@ export declare const doctorCompletedPayloadSchema: z.ZodObject<{
120
135
  failed_check_ids: z.ZodArray<z.ZodString>;
121
136
  failed_harnesses: z.ZodArray<z.ZodEnum<{
122
137
  openclaw: "openclaw";
138
+ omp: "omp";
123
139
  grok: "grok";
124
140
  cursor: "cursor";
125
141
  codex: "codex";
@@ -130,6 +146,7 @@ export declare const doctorCompletedPayloadSchema: z.ZodObject<{
130
146
  harnesses: z.ZodArray<z.ZodObject<{
131
147
  harness: z.ZodEnum<{
132
148
  openclaw: "openclaw";
149
+ omp: "omp";
133
150
  grok: "grok";
134
151
  cursor: "cursor";
135
152
  codex: "codex";
@@ -162,6 +179,7 @@ export declare const doctorCouldNotRunPayloadSchema: z.ZodObject<{
162
179
  }>;
163
180
  harness_scope: z.ZodEnum<{
164
181
  openclaw: "openclaw";
182
+ omp: "omp";
165
183
  grok: "grok";
166
184
  cursor: "cursor";
167
185
  codex: "codex";
@@ -197,6 +215,7 @@ declare const doctorPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
197
215
  }>;
198
216
  harness_scope: z.ZodEnum<{
199
217
  openclaw: "openclaw";
218
+ omp: "omp";
200
219
  grok: "grok";
201
220
  cursor: "cursor";
202
221
  codex: "codex";
@@ -209,6 +228,7 @@ declare const doctorPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
209
228
  failed_check_ids: z.ZodArray<z.ZodString>;
210
229
  failed_harnesses: z.ZodArray<z.ZodEnum<{
211
230
  openclaw: "openclaw";
231
+ omp: "omp";
212
232
  grok: "grok";
213
233
  cursor: "cursor";
214
234
  codex: "codex";
@@ -219,6 +239,7 @@ declare const doctorPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
219
239
  harnesses: z.ZodArray<z.ZodObject<{
220
240
  harness: z.ZodEnum<{
221
241
  openclaw: "openclaw";
242
+ omp: "omp";
222
243
  grok: "grok";
223
244
  cursor: "cursor";
224
245
  codex: "codex";
@@ -250,6 +271,7 @@ declare const doctorPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
250
271
  }>;
251
272
  harness_scope: z.ZodEnum<{
252
273
  openclaw: "openclaw";
274
+ omp: "omp";
253
275
  grok: "grok";
254
276
  cursor: "cursor";
255
277
  codex: "codex";