@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 +1 -1
- package/dist/commands/auth.js +4 -1
- package/dist/commands/connect.js +49 -2
- package/dist/commands/doctor.d.ts +1 -14
- package/dist/commands/doctor.js +3 -173
- package/dist/lib/auth.d.ts +2 -1
- package/dist/lib/auth.js +3 -3
- package/dist/lib/connect-all-uninstall.js +25 -7
- package/dist/lib/connect-all.js +34 -3
- package/dist/lib/connect-clients.d.ts +2 -1
- package/dist/lib/connect-clients.js +4 -0
- package/dist/lib/connect-preflight.d.ts +15 -0
- package/dist/lib/connect-preflight.js +67 -0
- package/dist/lib/doctor-repairs.d.ts +9 -0
- package/dist/lib/doctor-repairs.js +119 -0
- package/dist/lib/doctor-report.d.ts +13 -1
- package/dist/lib/doctor-telemetry.d.ts +9 -0
- package/dist/lib/doctor-telemetry.js +61 -0
- package/dist/lib/harness-spec.d.ts +15 -1
- package/dist/lib/harness-spec.js +12 -0
- package/dist/lib/mcp-json-config.d.ts +2 -0
- package/dist/lib/mcp-json-config.js +4 -0
- package/dist/lib/omp-config.d.ts +16 -0
- package/dist/lib/omp-config.js +71 -0
- package/dist/lib/registration-detect.js +34 -0
- package/dist/lib/setup-telemetry.d.ts +23 -1
- package/dist/lib/setup-telemetry.js +24 -0
- package/package.json +1 -1
|
@@ -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")[];
|
package/dist/lib/harness-spec.js
CHANGED
|
@@ -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";
|