@tiny-fish/cli 0.45.2-next.354 → 0.45.2-next.356

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.
@@ -3,7 +3,7 @@ import { getApiKey, saveConfig } from '../lib/auth.js';
3
3
  import { fetchContentGet, searchQuery } from '../lib/client.js';
4
4
  import { BASE_URL, TINYFISH_API_KEY_VAR } from '../lib/constants.js';
5
5
  import { errLine, handleApiError, outLine } from '../lib/output.js';
6
- import { connectHarness, launchAgent } from './connect.js';
6
+ import { connectHarness, launchAgent } from '../lib/connect-harness.js';
7
7
  import { HARNESS_DISPLAY_NAMES } from '../lib/harness-detect.js';
8
8
  const EXAMPLE_QUERIES = [
9
9
  'What are recent changes in the AI dev stack? Give me the top 5 changes that matter, why they matter, and any action needed.',
@@ -1,7 +1,7 @@
1
1
  import * as readline from 'node:readline/promises';
2
2
  import { harnessSpec } from './harness-spec.js';
3
3
  import spawn from 'cross-spawn';
4
- import { connectHarness } from '../commands/connect.js';
4
+ import { connectHarness } from './connect-harness.js';
5
5
  import { loadConfig, validatedApiKey } from './auth.js';
6
6
  import { installTinyFishCli, } from './cli-install.js';
7
7
  import { authGate, mapConnectError, OAUTH_SIGN_IN_TIMEOUT_MS } from './connect-all-auth.js';
@@ -0,0 +1,76 @@
1
+ import { type ConnectAuthMode } from './connect-runtime.js';
2
+ import { cursorMcpPath, writeCursorMcpConfig } from './cursor-config.js';
3
+ import { ompMcpPath, writeOmpMcpConfig } from './omp-config.js';
4
+ import { piMcpPath, writePiMcpConfig } from './pi-config.js';
5
+ declare function piPostConnectNotes(): string | undefined;
6
+ declare const CONFIG_FILE_HARNESSES: {
7
+ cursor: {
8
+ skillTarget: {
9
+ readonly skillAgent: "cursor";
10
+ readonly displayName: string;
11
+ };
12
+ registeredUrl: () => string | undefined;
13
+ write: typeof writeCursorMcpConfig;
14
+ configPath: typeof cursorMcpPath;
15
+ tryLaunch: typeof launchCursorDeeplink;
16
+ copy: {
17
+ verified: string;
18
+ authFailed: (reason: string) => string;
19
+ launched: string;
20
+ reload: string;
21
+ };
22
+ };
23
+ omp: {
24
+ skillTarget: {
25
+ displayName: string;
26
+ };
27
+ registeredUrl: () => string | undefined;
28
+ write: typeof writeOmpMcpConfig;
29
+ configPath: typeof ompMcpPath;
30
+ tryLaunch: () => false;
31
+ copy: {
32
+ verified: string;
33
+ authFailed: (reason: string) => string;
34
+ launched: string;
35
+ reload: string;
36
+ };
37
+ };
38
+ pi: {
39
+ skillTarget: {
40
+ skillAgent: "pi";
41
+ displayName: string;
42
+ };
43
+ registeredUrl: () => string | undefined;
44
+ write: typeof writePiMcpConfig;
45
+ configPath: typeof piMcpPath;
46
+ tryLaunch: () => false;
47
+ postConnectNote: typeof piPostConnectNotes;
48
+ copy: {
49
+ verified: string;
50
+ authFailed: (reason: string) => string;
51
+ launched: string;
52
+ reload: string;
53
+ };
54
+ };
55
+ };
56
+ /** Writes the harness's MCP config file itself; no `mcp add` exists. */
57
+ export declare function connectConfigFileHarness(harness: keyof typeof CONFIG_FILE_HARNESSES, options: {
58
+ apiKey?: string;
59
+ mcpUrl: string;
60
+ attemptId?: string;
61
+ verbose?: boolean;
62
+ authTimeoutMs?: number;
63
+ onPostInstallFailed?: () => void;
64
+ }): Promise<ConnectAuthMode | undefined>;
65
+ /** Writes mcp.json directly — no `cursor mcp add` exists. */
66
+ export declare function connectCursor(options: {
67
+ apiKey?: string;
68
+ mcpUrl: string;
69
+ attemptId?: string;
70
+ verbose?: boolean;
71
+ authTimeoutMs?: number;
72
+ onPostInstallFailed?: () => void;
73
+ }): Promise<void>;
74
+ /** Best-effort: false when no handler/open fails — caller falls back to reload copy. */
75
+ declare function launchCursorDeeplink(mcpUrl: string): boolean;
76
+ export {};
@@ -0,0 +1,241 @@
1
+ import { z } from 'zod';
2
+ import { CONNECT_SOURCE, saveConnectContext, validatedApiKey } from './auth.js';
3
+ import { CURSOR_SKILL_TARGET, openExternalUrl } from './connect-clients.js';
4
+ import { ensureCliAuthenticated } from './connect-auth.js';
5
+ import { installWebSkill } from './skill-install.js';
6
+ import { ConnectStepError, createConnectTelemetry, runGuarded, settle, } from './connect-runtime.js';
7
+ import { cursorInstallDeeplink, cursorMcpPath, readCursorTinyfishEntry, writeCursorMcpConfig, } from './cursor-config.js';
8
+ import { ompMcpPath, readOmpTinyfishEntry, writeOmpMcpConfig } from './omp-config.js';
9
+ import { PI_ADAPTER_INSTALL_COMMAND, piMcpAdapterState, piSkillDirMismatch, piMcpPath, readPiTinyfishEntry, writePiMcpConfig, } from './pi-config.js';
10
+ import { AuthMode, HARNESS_DISPLAY_NAMES } from './harness-detect.js';
11
+ import { detectHumanInitiated } from './harness.js';
12
+ import { errLine } from './output.js';
13
+ import { verifyMcpAuth } from './verify.js';
14
+ import { apiBaseFromMcpUrl } from './mcp-endpoint.js';
15
+ import { finishSetupHint, requireKeylessMcp, trackPostInstallFailure } from './connect-steps.js';
16
+ const PI_RELOAD_COPY = 'TinyFish is configured in pi. Restart pi, then run `/mcp-auth tinyfish` there if it asks you ' +
17
+ 'to sign in.';
18
+ // The skill is the working path either way, so the note must not read as a failure.
19
+ const PI_ADAPTER_DORMANT_NOTE = 'Note: pi has no built-in MCP support, so the entry in its mcp.json stays dormant until you ' +
20
+ `run \`${PI_ADAPTER_INSTALL_COMMAND}\`. The TinyFish skill works without it.`;
21
+ // Only the dormant case was spoken for, leaving a working MCP install silent.
22
+ const PI_ADAPTER_LIVE_NOTE = 'pi-mcp-adapter is installed, so pi loads the TinyFish MCP tools as well as the skill once ' +
23
+ 'you restart it.';
24
+ function piAdapterNote() {
25
+ const state = piMcpAdapterState();
26
+ if (state === 'installed')
27
+ return PI_ADAPTER_LIVE_NOTE;
28
+ return state === 'absent' ? PI_ADAPTER_DORMANT_NOTE : undefined;
29
+ }
30
+ // The `skills` CLI writes to the default dir even when pi reads another one.
31
+ function piPostConnectNotes() {
32
+ const notes = [piAdapterNote()];
33
+ const skills = piSkillDirMismatch();
34
+ if (skills) {
35
+ notes.push(`Note: PI_CODING_AGENT_DIR points pi at ${skills.readFrom}, but the TinyFish skill ` +
36
+ `installed to ${skills.installedTo}. Copy or symlink it across for pi to load it.`);
37
+ }
38
+ const printable = notes.filter(Boolean);
39
+ return printable.length > 0 ? printable.join('\n') : undefined;
40
+ }
41
+ // Only interactive omp wires the OAuth handler; headless cannot sign in.
42
+ const OMP_KEYLESS_COPY = 'TinyFish is configured in omp. Open omp and sign in to TinyFish when prompted, or run ' +
43
+ '`/mcp reauth tinyfish` there.';
44
+ const CONFIG_FILE_HARNESSES = {
45
+ cursor: {
46
+ skillTarget: CURSOR_SKILL_TARGET,
47
+ registeredUrl: () => readCursorTinyfishEntry().url,
48
+ write: writeCursorMcpConfig,
49
+ configPath: cursorMcpPath,
50
+ tryLaunch: launchCursorDeeplink,
51
+ copy: {
52
+ verified: "TinyFish is connected and verified (health+auth). Reload the Cursor window (or restart cursor-agent) and it's live.",
53
+ authFailed: (reason) => `TinyFish is configured, but the authenticated check failed (${reason}). ` +
54
+ 'Fix: rotate/re-enter your key with `tinyfish auth login`, then re-run: tinyfish connect cursor',
55
+ launched: 'Cursor should pop a TinyFish install confirmation — approve it, then sign in when prompted.',
56
+ reload: 'TinyFish is connected. Reload the Cursor window, then approve/sign in under Settings → MCP.',
57
+ },
58
+ },
59
+ omp: {
60
+ // No skill exists for omp; installWebSkill returns without one.
61
+ skillTarget: { displayName: HARNESS_DISPLAY_NAMES.omp },
62
+ registeredUrl: () => readOmpTinyfishEntry()?.url,
63
+ write: writeOmpMcpConfig,
64
+ configPath: ompMcpPath,
65
+ // No install deeplink exists; the keyless copy carries the sign-in.
66
+ tryLaunch: () => false,
67
+ copy: {
68
+ verified: "TinyFish is connected and verified (health+auth). Restart omp and it's live.",
69
+ authFailed: (reason) => `TinyFish is configured, but the authenticated check failed (${reason}). ` +
70
+ 'Fix: rotate/re-enter your key with `tinyfish auth login`, then re-run: tinyfish connect omp',
71
+ launched: OMP_KEYLESS_COPY,
72
+ reload: OMP_KEYLESS_COPY,
73
+ },
74
+ },
75
+ pi: {
76
+ skillTarget: { skillAgent: 'pi', displayName: HARNESS_DISPLAY_NAMES.pi },
77
+ registeredUrl: () => readPiTinyfishEntry().url,
78
+ write: writePiMcpConfig,
79
+ configPath: piMcpPath,
80
+ // No install deeplink exists; the copy carries the restart instead.
81
+ tryLaunch: () => false,
82
+ postConnectNote: piPostConnectNotes,
83
+ copy: {
84
+ verified: "TinyFish is connected and verified (health+auth). Restart pi and it's live.",
85
+ authFailed: (reason) => `TinyFish is configured, but the authenticated check failed (${reason}). ` +
86
+ 'Fix: rotate/re-enter your key with `tinyfish auth login`, then re-run: tinyfish connect pi',
87
+ launched: PI_RELOAD_COPY,
88
+ reload: PI_RELOAD_COPY,
89
+ },
90
+ },
91
+ };
92
+ /** Cosmetic and post-settle: a skill failure must not fail the connection. */
93
+ function installSkillPostSettle(spec, displayName, state, telemetry, options) {
94
+ if (!spec.skillTarget.skillAgent)
95
+ return false;
96
+ state.stage = 'skill_install';
97
+ try {
98
+ installWebSkill(spec.skillTarget, { verbose: options.verbose ?? false });
99
+ telemetry.track('checkpoint', { phase: 'skill_installed' });
100
+ return false;
101
+ }
102
+ catch (error) {
103
+ trackPostInstallFailure(displayName, state, telemetry, error);
104
+ return true;
105
+ }
106
+ }
107
+ /** Prints last: the outro must own the final line. */
108
+ function reportPostInstallFailure(harness, failed, onPostInstallFailed) {
109
+ if (!failed)
110
+ return;
111
+ errLine(finishSetupHint(harness));
112
+ onPostInstallFailed?.();
113
+ }
114
+ /** Writes the harness's MCP config file itself; no `mcp add` exists. */
115
+ export async function connectConfigFileHarness(harness, options) {
116
+ const spec = CONFIG_FILE_HARNESSES[harness];
117
+ const displayName = HARNESS_DISPLAY_NAMES[harness];
118
+ let fallbackAttemptId;
119
+ const registeredUrl = spec.registeredUrl();
120
+ if (registeredUrl) {
121
+ try {
122
+ const registered = new URL(registeredUrl);
123
+ const requested = new URL(options.mcpUrl);
124
+ const registeredId = registered.searchParams.get('connect_attempt_id');
125
+ const registeredSource = registered.searchParams.get('source');
126
+ const registeredClient = registered.searchParams.get('client');
127
+ for (const key of ['source', 'client', 'connect_attempt_id']) {
128
+ registered.searchParams.delete(key);
129
+ requested.searchParams.delete(key);
130
+ }
131
+ registered.searchParams.sort();
132
+ requested.searchParams.sort();
133
+ if (registered.toString() === requested.toString() &&
134
+ registeredSource === CONNECT_SOURCE &&
135
+ registeredClient === harness &&
136
+ z.uuid().safeParse(registeredId).success) {
137
+ fallbackAttemptId = registeredId ?? undefined;
138
+ }
139
+ }
140
+ catch {
141
+ // A hand-edited invalid URL is repaired below with a fresh attempt id.
142
+ }
143
+ }
144
+ const telemetry = createConnectTelemetry(options.mcpUrl, harness, {
145
+ ...options,
146
+ fallbackAttemptId,
147
+ });
148
+ // Never degraded: no sign-in command to be missing. False, not absent, so a `= false`
149
+ // filter still catches these harnesses.
150
+ const state = {
151
+ stage: 'prerequisite_check',
152
+ settled: false,
153
+ harnessDegraded: false,
154
+ };
155
+ await runGuarded(state, telemetry, async () => {
156
+ telemetry.track('started');
157
+ telemetry.track('checkpoint', { phase: 'prerequisite_ok' });
158
+ const resolveAuth = async () => {
159
+ let resolvedKey = validatedApiKey(options.apiKey);
160
+ const keyless = !resolvedKey && harness === 'omp';
161
+ if (keyless) {
162
+ await requireKeylessMcp(options.mcpUrl);
163
+ }
164
+ else {
165
+ state.stage = 'authentication';
166
+ ensureCliAuthenticated(harness, options.apiKey, {
167
+ interactiveLogin: options.authTimeoutMs === undefined,
168
+ });
169
+ resolvedKey = validatedApiKey(options.apiKey);
170
+ telemetry.track('checkpoint', { phase: 'authenticated' });
171
+ if (!resolvedKey) {
172
+ errLine('Ignoring the stored API key: invalid format. Run: tinyfish auth login');
173
+ }
174
+ }
175
+ return { resolvedKey, keyless };
176
+ };
177
+ const { resolvedKey, keyless } = await resolveAuth();
178
+ state.stage = 'registration';
179
+ errLine(`Adding TinyFish to ${displayName}...`);
180
+ const mcpUrl = new URL(options.mcpUrl);
181
+ mcpUrl.searchParams.set('source', CONNECT_SOURCE);
182
+ mcpUrl.searchParams.set('client', harness);
183
+ mcpUrl.searchParams.set('connect_attempt_id', telemetry.attemptId);
184
+ const result = spec.write(mcpUrl.toString(), resolvedKey);
185
+ if (result.status === 'corrupt_skip') {
186
+ // The message fallback would leak the config path; tag instead.
187
+ throw new ConnectStepError(`Could not update ${spec.configPath()}: existing file could not be read or is not valid JSON (${result.error}). ` +
188
+ `Fix the file, then re-run: tinyfish connect ${harness}`, 'invalid_config', { failureDetail: `${harness}_mcp_json_corrupt` });
189
+ }
190
+ if (result.backupPath) {
191
+ errLine(`Backed up existing ${displayName} MCP config to ${result.backupPath}`);
192
+ }
193
+ if (result.repaired) {
194
+ errLine('Repaired the existing TinyFish entry (updated auth/config to current).');
195
+ }
196
+ telemetry.track('checkpoint', { phase: 'registered' });
197
+ let authMode = keyless ? AuthMode.Keyless : 'deferred';
198
+ if (resolvedKey)
199
+ authMode = AuthMode.ApiKey;
200
+ state.authMode = authMode;
201
+ if (keyless)
202
+ saveConnectContext(harness, telemetry.attemptId, AuthMode.Keyless);
203
+ else
204
+ saveConnectContext(harness, telemetry.attemptId);
205
+ settle(state, telemetry, 'completed', { authMode });
206
+ const postInstallFailed = installSkillPostSettle(spec, displayName, state, telemetry, options);
207
+ if (keyless) {
208
+ errLine(`TinyFish keyless Search is connected in ${displayName}.`);
209
+ }
210
+ else if (resolvedKey) {
211
+ // Deeplink would embed the key in a URL (process args, LaunchServices logs) — reload instead.
212
+ const verify = await verifyMcpAuth(resolvedKey, apiBaseFromMcpUrl(options.mcpUrl));
213
+ if (verify.ok) {
214
+ errLine(spec.copy.verified);
215
+ }
216
+ else {
217
+ errLine(spec.copy.authFailed(verify.reason ?? 'unknown'));
218
+ }
219
+ }
220
+ else if (detectHumanInitiated() && spec.tryLaunch(mcpUrl.toString())) {
221
+ errLine(spec.copy.launched);
222
+ }
223
+ else {
224
+ errLine(spec.copy.reload);
225
+ }
226
+ const note = spec.postConnectNote?.();
227
+ if (note)
228
+ errLine(note);
229
+ reportPostInstallFailure(harness, postInstallFailed, options.onPostInstallFailed);
230
+ });
231
+ return state.authMode;
232
+ }
233
+ /** Writes mcp.json directly — no `cursor mcp add` exists. */
234
+ export async function connectCursor(options) {
235
+ await connectConfigFileHarness('cursor', options);
236
+ }
237
+ /** Best-effort: false when no handler/open fails — caller falls back to reload copy. */
238
+ function launchCursorDeeplink(mcpUrl) {
239
+ const result = openExternalUrl(cursorInstallDeeplink(mcpUrl));
240
+ return !result.error && result.status === 0;
241
+ }
@@ -0,0 +1,4 @@
1
+ import type { AgentClient, ConnectAuthMode } from './connect-runtime.js';
2
+ import { type NativeConnectOptions } from './connect-steps.js';
3
+ export declare function launchAgent(client: AgentClient): Promise<void>;
4
+ export declare function connectHarness(harness: AgentClient, options: NativeConnectOptions): Promise<ConnectAuthMode | undefined>;
@@ -0,0 +1,76 @@
1
+ import { KEYLESS_ONBOARDING_PROMPT, NATIVE_BY_HARNESS, launchNativeMcpClient, launchOmpWalkthrough, launchPiWalkthrough, launchOpenClawWalkthrough, } from './connect-clients.js';
2
+ import { AuthMode } from './harness-detect.js';
3
+ import { errLine } from './output.js';
4
+ import { connectNativeMcpClient } from './connect-native.js';
5
+ import { connectOpenClaw } from './connect-openclaw.js';
6
+ import { connectConfigFileHarness, connectCursor } from './connect-config-file.js';
7
+ import { finishSetupHint } from './connect-steps.js';
8
+ /** Total over the non-native harnesses: a new one fails to compile until wired here. */
9
+ const LAUNCH_OVERRIDES = {
10
+ omp: launchOmpWalkthrough,
11
+ pi: launchPiWalkthrough,
12
+ openclaw: launchOpenClawWalkthrough,
13
+ // Cursor has no chat CLI to launch.
14
+ cursor: () => {
15
+ throw new Error('Cursor has no launchable walkthrough. Open Cursor and start chatting.');
16
+ },
17
+ };
18
+ const isNonNative = (harness) => harness in LAUNCH_OVERRIDES;
19
+ export async function launchAgent(client) {
20
+ if (isNonNative(client))
21
+ return void (await LAUNCH_OVERRIDES[client]());
22
+ await launchNativeMcpClient(requireNative(client));
23
+ }
24
+ /** Total over the non-native harnesses; native harnesses use the descriptor engine. */
25
+ const CONNECTOR_OVERRIDES = {
26
+ openclaw: async (options) => {
27
+ await connectOpenClaw(options);
28
+ return undefined;
29
+ },
30
+ cursor: async (options) => {
31
+ await connectCursor(options);
32
+ // `connect cursor --launch` still connects; it just cannot launch after.
33
+ if (options.launch) {
34
+ errLine('Cursor has no launchable walkthrough. Open Cursor and start chatting.');
35
+ }
36
+ return undefined;
37
+ },
38
+ omp: async (options) => {
39
+ const authMode = await connectConfigFileHarness('omp', options);
40
+ if (options.launch) {
41
+ try {
42
+ await launchOmpWalkthrough(undefined, authMode === AuthMode.Keyless ? KEYLESS_ONBOARDING_PROMPT : undefined);
43
+ }
44
+ catch (error) {
45
+ // Post-settle, like the native walkthrough step: never fail the connect.
46
+ errLine(error instanceof Error ? error.message : String(error));
47
+ errLine(finishSetupHint('omp'));
48
+ }
49
+ }
50
+ return authMode;
51
+ },
52
+ pi: async (options) => {
53
+ await connectConfigFileHarness('pi', options);
54
+ if (options.launch) {
55
+ try {
56
+ await launchPiWalkthrough();
57
+ }
58
+ catch (error) {
59
+ errLine(error instanceof Error ? error.message : String(error));
60
+ errLine(finishSetupHint('pi'));
61
+ }
62
+ }
63
+ return undefined;
64
+ },
65
+ };
66
+ function requireNative(harness) {
67
+ const client = NATIVE_BY_HARNESS.get(harness);
68
+ if (!client)
69
+ throw new Error(`No native MCP descriptor for ${harness}`);
70
+ return client;
71
+ }
72
+ export async function connectHarness(harness, options) {
73
+ if (isNonNative(harness))
74
+ return CONNECTOR_OVERRIDES[harness](options);
75
+ return connectNativeMcpClient(requireNative(harness), options);
76
+ }
@@ -0,0 +1,4 @@
1
+ import { type NativeMcpClient } from './connect-clients.js';
2
+ import { type ConnectAuthMode } from './connect-runtime.js';
3
+ import { type NativeConnectOptions } from './connect-steps.js';
4
+ export declare function connectNativeMcpClient(client: NativeMcpClient, options: NativeConnectOptions): Promise<ConnectAuthMode | undefined>;