@tiny-fish/cli 0.30.1-next.247 → 0.30.1-next.250
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/dist/commands/connect.d.ts +4 -0
- package/dist/commands/connect.js +66 -8
- package/dist/lib/connect-all.js +38 -95
- package/dist/lib/connect-fallback.d.ts +24 -0
- package/dist/lib/connect-fallback.js +136 -0
- package/dist/lib/connect-runtime.d.ts +4 -2
- package/dist/lib/connect-runtime.js +6 -0
- package/dist/lib/harness-detect.d.ts +2 -0
- package/dist/lib/harness-detect.js +11 -0
- package/package.json +1 -1
|
@@ -21,6 +21,8 @@ export interface NativeConnectOptions {
|
|
|
21
21
|
verbose?: boolean;
|
|
22
22
|
/** Set by `--all`: repeated outro lines print once, after the summary. */
|
|
23
23
|
deferOutro?: (line: string) => void;
|
|
24
|
+
/** Targeted connects only: --all keeps its per-harness failed row. */
|
|
25
|
+
fallbackWhenMissing?: boolean;
|
|
24
26
|
}
|
|
25
27
|
export declare function connectClaudeCode(options: NativeConnectOptions): Promise<ConnectAuthMode | undefined>;
|
|
26
28
|
export declare function connectCodex(options: NativeConnectOptions): Promise<ConnectAuthMode | undefined>;
|
|
@@ -38,6 +40,8 @@ export declare function connectOpenClaw(options: {
|
|
|
38
40
|
verbose?: boolean;
|
|
39
41
|
/** Set by `--all`: repeated outro lines print once, after the summary. */
|
|
40
42
|
deferOutro?: (line: string) => void;
|
|
43
|
+
/** Targeted connects only: --all keeps its per-harness failed row. */
|
|
44
|
+
fallbackWhenMissing?: boolean;
|
|
41
45
|
}): Promise<void>;
|
|
42
46
|
/** Writes mcp.json directly — no `cursor mcp add` exists. */
|
|
43
47
|
export declare function connectCursor(options: {
|
package/dist/commands/connect.js
CHANGED
|
@@ -3,11 +3,12 @@ import { CONNECT_SOURCE, persistApiKeyToEnvironment, saveConnectContext, validat
|
|
|
3
3
|
import { CLAUDE_CODE, CODEX, CURSOR_SKILL_TARGET, GROK, HERMES, OPENCLAW, OPENCLAW_SKILL_INSTALL_ARGS, OPENCODE, launchNativeMcpClient, launchOpenClawWalkthrough, openExternalUrl, } from "../lib/connect-clients.js";
|
|
4
4
|
import { captureStdio, capturedOutput, HoistedFailureReportedError, installTinyFishCli, replay, } from "../lib/cli-install.js";
|
|
5
5
|
import { ensureCliAuthenticated } from "../lib/connect-auth.js";
|
|
6
|
+
import { createStdinPrompt, runCliFallback } from "../lib/connect-fallback.js";
|
|
6
7
|
import { installWebSkill } from "../lib/skill-install.js";
|
|
7
8
|
import { ConnectInterruptedError, ConnectStepError, createConnectTelemetry, finishSetupCommand, requireCommandSupport, runGuarded, settle, SignInTimeoutError, spawnStepError, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "../lib/connect-runtime.js";
|
|
8
9
|
import { cursorInstallDeeplink, cursorMcpPath, readCursorTinyfishEntry, writeCursorMcpConfig, } from "../lib/cursor-config.js";
|
|
9
10
|
import { runConnectAll } from "../lib/connect-all.js";
|
|
10
|
-
import { AuthMode } from "../lib/harness-detect.js";
|
|
11
|
+
import { AuthMode, RELOAD_ACTION } from "../lib/harness-detect.js";
|
|
11
12
|
import { detectHumanInitiated } from "../lib/harness.js";
|
|
12
13
|
import { TINYFISH_API_KEY_VAR } from "../lib/constants.js";
|
|
13
14
|
import { emitNotice } from "../lib/notice.js";
|
|
@@ -58,13 +59,62 @@ function signInStepError(message, result, bounded) {
|
|
|
58
59
|
? new SignInTimeoutError(error.message, error.cause)
|
|
59
60
|
: error;
|
|
60
61
|
}
|
|
62
|
+
function isMissingHarness(error) {
|
|
63
|
+
return error instanceof ConnectStepError && error.failureReason === "harness_not_installed";
|
|
64
|
+
}
|
|
65
|
+
/** Undefined means the rescue ran; the caller returns immediately. */
|
|
66
|
+
async function requireSupportOrRescue(client, connectClient, state, telemetry, options) {
|
|
67
|
+
try {
|
|
68
|
+
return requireCommandSupport(client);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (!options.fallbackWhenMissing || !isMissingHarness(error))
|
|
72
|
+
throw error;
|
|
73
|
+
await runMissingHarnessFallback(client.displayName, connectClient, state, telemetry, options);
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// The harness attempt stays failed; the rescue rides fallback_outcome (PF-3707).
|
|
78
|
+
async function runMissingHarnessFallback(displayName, connectClient, state, telemetry, options) {
|
|
79
|
+
// Stamped before any step: Ctrl+C settles before an outcome exists.
|
|
80
|
+
telemetry.setFallbackOutcome("abandoned");
|
|
81
|
+
errLine(`${displayName} was not found on PATH. Setting up the TinyFish CLI instead.`);
|
|
82
|
+
const outcome = await runCliFallback({
|
|
83
|
+
apiKey: options.apiKey,
|
|
84
|
+
verbose: options.verbose ?? false,
|
|
85
|
+
prompt: detectHumanInitiated() ? createStdinPrompt() : undefined,
|
|
86
|
+
retryCommand: `npx @tiny-fish/cli connect ${connectClient}`,
|
|
87
|
+
hooks: {
|
|
88
|
+
// Terminal detail is explicit below; abort rows still name the step.
|
|
89
|
+
onStepStart: (stage) => {
|
|
90
|
+
state.stage = stage;
|
|
91
|
+
},
|
|
92
|
+
onStepDone: (phase) => telemetry.track("checkpoint", { phase }),
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
telemetry.setFallbackOutcome(outcome);
|
|
96
|
+
// Never `completed`: dashboards read that as "the harness install worked".
|
|
97
|
+
settle(state, telemetry, "failed", {
|
|
98
|
+
failedStage: "prerequisite_check",
|
|
99
|
+
failureReason: "harness_not_installed",
|
|
100
|
+
});
|
|
101
|
+
if (outcome !== "cli_verified") {
|
|
102
|
+
process.exitCode = 1;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
errLine("The TinyFish CLI is installed and working.");
|
|
106
|
+
errLine("Using a different agent? Run: tinyfish connect --all");
|
|
107
|
+
}
|
|
61
108
|
async function connectNativeMcpClient(client, options) {
|
|
62
109
|
const telemetry = createConnectTelemetry(options.mcpUrl, client.connectClient, options);
|
|
63
110
|
const state = { stage: "prerequisite_check", settled: false };
|
|
64
111
|
const probeAuthenticated = SIGN_IN_PROBES[client.connectClient];
|
|
65
|
-
await runGuarded(state, telemetry, () => {
|
|
112
|
+
await runGuarded(state, telemetry, async () => {
|
|
66
113
|
telemetry.track("started");
|
|
67
|
-
const
|
|
114
|
+
const support = await requireSupportOrRescue(client, client.connectClient, state, telemetry, options);
|
|
115
|
+
if (!support)
|
|
116
|
+
return;
|
|
117
|
+
const { optionalSupported, keyAuthSupported, harnessVersion } = support;
|
|
68
118
|
telemetry.setHarnessVersion(harnessVersion);
|
|
69
119
|
state.harnessDegraded = !!client.loginArgs && !optionalSupported;
|
|
70
120
|
// Prod MCP accepts X-API-Key, so a stored key replaces the browser OAuth hop.
|
|
@@ -170,7 +220,8 @@ function connectedLine(client, signInDeferred) {
|
|
|
170
220
|
return (`TinyFish is configured in ${client.displayName}. ${client.displayName} will ask you ` +
|
|
171
221
|
"to sign in to TinyFish the first time you use it.");
|
|
172
222
|
}
|
|
173
|
-
return `TinyFish is connected. Open ${client.displayName}
|
|
223
|
+
return (`TinyFish is connected. Open ${client.displayName}; if it is already running, ` +
|
|
224
|
+
`${RELOAD_ACTION[client.connectClient]} to pick up the tools.`);
|
|
174
225
|
}
|
|
175
226
|
// Runs after `completed` settled: steps fail alone, the rest still run.
|
|
176
227
|
function runPostInstallSteps(client, options, state, telemetry, signInDeferred) {
|
|
@@ -293,9 +344,12 @@ export async function connectOpenClaw(options) {
|
|
|
293
344
|
settled: false,
|
|
294
345
|
harnessDegraded: false,
|
|
295
346
|
};
|
|
296
|
-
await runGuarded(state, telemetry, () => {
|
|
347
|
+
await runGuarded(state, telemetry, async () => {
|
|
297
348
|
telemetry.track("started");
|
|
298
|
-
|
|
349
|
+
const support = await requireSupportOrRescue(OPENCLAW, "openclaw", state, telemetry, options);
|
|
350
|
+
if (!support)
|
|
351
|
+
return;
|
|
352
|
+
telemetry.setHarnessVersion(support.harnessVersion);
|
|
299
353
|
telemetry.track("checkpoint", { phase: "prerequisite_ok" });
|
|
300
354
|
if (options.hoistedCliInstall) {
|
|
301
355
|
state.stage = "cli_install";
|
|
@@ -334,8 +388,10 @@ export async function connectOpenClaw(options) {
|
|
|
334
388
|
settle(state, telemetry, "completed");
|
|
335
389
|
if (!options.launch) {
|
|
336
390
|
// Under --all the summary already lists OpenClaw.
|
|
337
|
-
if (!options.deferOutro)
|
|
338
|
-
errLine("TinyFish is connected. Open OpenClaw
|
|
391
|
+
if (!options.deferOutro) {
|
|
392
|
+
errLine("TinyFish is connected. Open OpenClaw; if it is already running, " +
|
|
393
|
+
`${RELOAD_ACTION.openclaw} to pick up the skill.`);
|
|
394
|
+
}
|
|
339
395
|
return;
|
|
340
396
|
}
|
|
341
397
|
state.stage = "walkthrough_launch";
|
|
@@ -570,6 +626,8 @@ export function registerConnect(program) {
|
|
|
570
626
|
attemptId,
|
|
571
627
|
// A key that the install cannot use leaves only a browser hop no agent can finish.
|
|
572
628
|
keyAuthOnly: !detectHumanInitiated() && !!validatedApiKey(options.apiKey),
|
|
629
|
+
// Only here: a missing harness rescues via the CLI (PF-3707).
|
|
630
|
+
fallbackWhenMissing: true,
|
|
573
631
|
verbose: options.verbose ?? false,
|
|
574
632
|
};
|
|
575
633
|
const connector = CLIENT_CONNECTORS[client];
|
package/dist/lib/connect-all.js
CHANGED
|
@@ -2,13 +2,12 @@ import * as readline from "node:readline/promises";
|
|
|
2
2
|
import spawn from "cross-spawn";
|
|
3
3
|
import { connectClaudeCode, connectCodex, connectCursor, connectGrok, connectHermes, connectOpenClaw, connectOpencode, } from "../commands/connect.js";
|
|
4
4
|
import { loadConfig, validatedApiKey } from "./auth.js";
|
|
5
|
-
import { TINYFISH_API_KEY_VAR } from "./constants.js";
|
|
6
5
|
import { installTinyFishCli, } from "./cli-install.js";
|
|
7
6
|
import { HARNESS_CAPABILITIES, NATIVE_BY_HARNESS } from "./connect-clients.js";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
7
|
+
import { createStdinPrompt, runCliFallback } from "./connect-fallback.js";
|
|
8
|
+
import { ConnectInterruptedError, ConnectStepError, SignInTimeoutError, finishSetupCommand, } from "./connect-runtime.js";
|
|
10
9
|
import { pickHarnesses } from "./connect-picker.js";
|
|
11
|
-
import { commandOnPath, detectInstalledHarnesses, HARNESS_COMMANDS, HARNESS_DISPLAY_NAMES as DISPLAY_NAMES, ALL_HARNESSES, } from "./harness-detect.js";
|
|
10
|
+
import { commandOnPath, detectInstalledHarnesses, HARNESS_COMMANDS, HARNESS_DISPLAY_NAMES as DISPLAY_NAMES, ALL_HARNESSES, RELOAD_ACTION, } from "./harness-detect.js";
|
|
12
11
|
import { detectHumanInitiated } from "./harness.js";
|
|
13
12
|
import { emitNotice } from "./notice.js";
|
|
14
13
|
import { removeCursorMcpServer, planCursorWrite } from "./cursor-config.js";
|
|
@@ -184,9 +183,14 @@ async function processHarness(detection, opts, prompt, oauthBudget, hoistedCliIn
|
|
|
184
183
|
try {
|
|
185
184
|
await prompt(`Press enter to open the browser and sign in to TinyFish for ${DISPLAY_NAMES[harness]}... `);
|
|
186
185
|
}
|
|
187
|
-
catch {
|
|
188
|
-
// Ctrl+
|
|
189
|
-
|
|
186
|
+
catch (error) {
|
|
187
|
+
// Ctrl+C stops the run; Ctrl+D only skips this harness.
|
|
188
|
+
if (error instanceof ConnectInterruptedError) {
|
|
189
|
+
errLine("Setup interrupted — run the command again to finish.");
|
|
190
|
+
process.exitCode = 130;
|
|
191
|
+
return { ...base, outcome: "interrupted", fixCommand };
|
|
192
|
+
}
|
|
193
|
+
return { ...base, outcome: "no_tty_auth_skip", fixCommand };
|
|
190
194
|
}
|
|
191
195
|
}
|
|
192
196
|
// runGuarded swallows Ctrl+C and sets exitCode 130 instead of throwing.
|
|
@@ -316,6 +320,20 @@ function summaryLine(result, uninstall) {
|
|
|
316
320
|
}
|
|
317
321
|
return `✓ ${name} — installed, ${verifyLabel}`;
|
|
318
322
|
}
|
|
323
|
+
/** One line for every harness: seven near-identical paragraphs is what this replaces. */
|
|
324
|
+
function reloadHint(results) {
|
|
325
|
+
const byAction = new Map();
|
|
326
|
+
for (const result of results) {
|
|
327
|
+
if (!result.installed)
|
|
328
|
+
continue;
|
|
329
|
+
const action = RELOAD_ACTION[result.harness];
|
|
330
|
+
byAction.set(action, [...(byAction.get(action) ?? []), DISPLAY_NAMES[result.harness]]);
|
|
331
|
+
}
|
|
332
|
+
if (byAction.size === 0)
|
|
333
|
+
return undefined;
|
|
334
|
+
const parts = [...byAction].map(([action, names]) => `${names.join(", ")} — ${action}`);
|
|
335
|
+
return `TinyFish tools load when a harness starts. If one is already open: ${parts.join("; ")}.`;
|
|
336
|
+
}
|
|
319
337
|
/** Absent and unpicked harnesses are noise once something real happened. */
|
|
320
338
|
function renderSummary(results, uninstall) {
|
|
321
339
|
if (!results.some(actionable))
|
|
@@ -361,96 +379,10 @@ function computeExitCode(results) {
|
|
|
361
379
|
return 0;
|
|
362
380
|
return attempted.some((r) => r.outcome === "installed") ? 0 : 1;
|
|
363
381
|
}
|
|
364
|
-
const SEARCH_RETRY = 'tinyfish search query --pretty -- "<your query>"';
|
|
365
|
-
function searchRetry(query) {
|
|
366
|
-
if (process.platform === "win32")
|
|
367
|
-
return SEARCH_RETRY;
|
|
368
|
-
return `tinyfish search query --pretty -- '${query.replaceAll("'", "'\\''")}'`;
|
|
369
|
-
}
|
|
370
|
-
async function runCliFallback(opts, prompt) {
|
|
371
|
-
try {
|
|
372
|
-
installTinyFishCli({ verbose: opts.verbose ?? false, announce: true });
|
|
373
|
-
}
|
|
374
|
-
catch (error) {
|
|
375
|
-
if (error instanceof ConnectInterruptedError)
|
|
376
|
-
throw error;
|
|
377
|
-
errLine(error instanceof Error ? error.message : String(error));
|
|
378
|
-
outLine("Retry: npx @tiny-fish/cli connect --all");
|
|
379
|
-
return 1;
|
|
380
|
-
}
|
|
381
|
-
const apiKey = opts.apiKey ?? process.env[TINYFISH_API_KEY_VAR];
|
|
382
|
-
if (!prompt && !apiKey) {
|
|
383
|
-
outLine("Retry in a terminal: npx @tiny-fish/cli connect --all");
|
|
384
|
-
return 1;
|
|
385
|
-
}
|
|
386
|
-
try {
|
|
387
|
-
ensureCliAuthenticated("cli", apiKey);
|
|
388
|
-
}
|
|
389
|
-
catch (error) {
|
|
390
|
-
if (error instanceof ConnectInterruptedError)
|
|
391
|
-
throw error;
|
|
392
|
-
errLine(error instanceof Error ? error.message : String(error));
|
|
393
|
-
outLine(error instanceof ConnectStepError && error.failureReason === "command_not_found"
|
|
394
|
-
? "Retry in a new terminal: tinyfish auth login"
|
|
395
|
-
: "Retry: tinyfish auth login");
|
|
396
|
-
return 1;
|
|
397
|
-
}
|
|
398
|
-
if (!prompt) {
|
|
399
|
-
outLine(`Retry in a terminal: ${SEARCH_RETRY}`);
|
|
400
|
-
return 1;
|
|
401
|
-
}
|
|
402
|
-
let query;
|
|
403
|
-
try {
|
|
404
|
-
query = (await prompt("What would you like to search? ")).trim();
|
|
405
|
-
}
|
|
406
|
-
catch {
|
|
407
|
-
outLine(`Retry: ${SEARCH_RETRY}`);
|
|
408
|
-
return 1;
|
|
409
|
-
}
|
|
410
|
-
if (!query) {
|
|
411
|
-
outLine(`Retry: ${SEARCH_RETRY}`);
|
|
412
|
-
return 1;
|
|
413
|
-
}
|
|
414
|
-
const search = spawn.sync("tinyfish", ["search", "query", "--pretty", "--", query], {
|
|
415
|
-
stdio: "inherit",
|
|
416
|
-
env: { ...process.env, TF_SETUP_VERIFICATION: "1" },
|
|
417
|
-
});
|
|
418
|
-
if (commandNotFound(search.error)) {
|
|
419
|
-
errLine(TINYFISH_CLI_NOT_FOUND_MESSAGE);
|
|
420
|
-
outLine(`Retry in a new terminal: ${searchRetry(query)}`);
|
|
421
|
-
return 1;
|
|
422
|
-
}
|
|
423
|
-
throwIfInterrupted(search);
|
|
424
|
-
if (search.error || search.status !== 0) {
|
|
425
|
-
outLine(`Retry: ${searchRetry(query)}`);
|
|
426
|
-
return 1;
|
|
427
|
-
}
|
|
428
|
-
return 0;
|
|
429
|
-
}
|
|
430
382
|
export async function runConnectAll(opts) {
|
|
431
383
|
const isTTY = detectHumanInitiated();
|
|
432
|
-
let stdinClosed = false;
|
|
433
384
|
// Open interface holds stdin raw; hermes' input() then wedges.
|
|
434
|
-
const prompt = isTTY
|
|
435
|
-
? async (question) => {
|
|
436
|
-
if (stdinClosed)
|
|
437
|
-
throw new Error("stdin is closed");
|
|
438
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
439
|
-
// Without a close-linked signal, question() never settles on Ctrl+D.
|
|
440
|
-
const closed = new AbortController();
|
|
441
|
-
rl.on("close", () => closed.abort());
|
|
442
|
-
try {
|
|
443
|
-
return await rl.question(question, { signal: closed.signal });
|
|
444
|
-
}
|
|
445
|
-
catch (e) {
|
|
446
|
-
stdinClosed = true;
|
|
447
|
-
throw e;
|
|
448
|
-
}
|
|
449
|
-
finally {
|
|
450
|
-
rl.close();
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
: undefined;
|
|
385
|
+
const prompt = isTTY ? createStdinPrompt() : undefined;
|
|
454
386
|
const detections = detectInstalledHarnesses();
|
|
455
387
|
const gateKey = validatedApiKey(opts.apiKey);
|
|
456
388
|
// Keyed installs first: working tools land before sign-ins can block.
|
|
@@ -571,13 +503,24 @@ export async function runConnectAll(opts) {
|
|
|
571
503
|
outLine("TinyFish setup summary:");
|
|
572
504
|
for (const line of renderSummary(results, opts.uninstall))
|
|
573
505
|
outLine(line);
|
|
506
|
+
const reload = opts.uninstall ? undefined : reloadHint(results);
|
|
507
|
+
if (reload) {
|
|
508
|
+
outLine("");
|
|
509
|
+
outLine(reload);
|
|
510
|
+
}
|
|
574
511
|
// Before the launch handover: anything printed after it is never seen.
|
|
575
512
|
for (const line of outro)
|
|
576
513
|
errLine(line);
|
|
577
514
|
if (!results.some(actionable)) {
|
|
578
515
|
if (!opts.dryRun && !opts.uninstall) {
|
|
579
516
|
try {
|
|
580
|
-
|
|
517
|
+
const outcome = await runCliFallback({
|
|
518
|
+
apiKey: opts.apiKey,
|
|
519
|
+
verbose: opts.verbose ?? false,
|
|
520
|
+
prompt,
|
|
521
|
+
retryCommand: "npx @tiny-fish/cli connect --all",
|
|
522
|
+
});
|
|
523
|
+
return outcome === "cli_verified" ? 0 : 1;
|
|
581
524
|
}
|
|
582
525
|
catch (error) {
|
|
583
526
|
if (!(error instanceof ConnectInterruptedError))
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type CliFallbackOutcome, type ConnectCheckpoint, type ConnectFailureStage } from "./connect-runtime.js";
|
|
2
|
+
export type Prompt = (question: string) => Promise<string>;
|
|
3
|
+
export declare const SEARCH_RETRY = "tinyfish search query --pretty -- \"<your query>\"";
|
|
4
|
+
export declare const DEFAULT_VERIFICATION_QUERY = "today's top Hacker News story";
|
|
5
|
+
export declare const MALFORMED_ENV_KEY_MESSAGE = "TINYFISH_API_KEY is set but malformed; it will override any stored key. Fix or unset it, then re-run.";
|
|
6
|
+
export declare const MALFORMED_STORED_KEY_MESSAGE = "The stored TinyFish API key is malformed. Run: tinyfish auth login";
|
|
7
|
+
export interface CliFallbackHooks {
|
|
8
|
+
onStepStart?: (stage: ConnectFailureStage) => void;
|
|
9
|
+
onStepDone?: (phase: ConnectCheckpoint) => void;
|
|
10
|
+
}
|
|
11
|
+
export interface CliFallbackOptions {
|
|
12
|
+
apiKey?: string;
|
|
13
|
+
verbose: boolean;
|
|
14
|
+
/** Absent means headless: no interactive login, no query prompt. */
|
|
15
|
+
prompt?: Prompt;
|
|
16
|
+
/** Whole-command retry line, per entry point. */
|
|
17
|
+
retryCommand: string;
|
|
18
|
+
/** Absent under --all's zero-harness path, which has no telemetry object. */
|
|
19
|
+
hooks?: CliFallbackHooks;
|
|
20
|
+
}
|
|
21
|
+
/** One readline per question; a held-open interface wedges raw-stdin children. */
|
|
22
|
+
export declare function createStdinPrompt(): Prompt;
|
|
23
|
+
/** Steps print retry lines and return outcomes; only interrupts throw. */
|
|
24
|
+
export declare function runCliFallback(opts: CliFallbackOptions): Promise<CliFallbackOutcome>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import * as readline from "node:readline/promises";
|
|
2
|
+
import spawn from "cross-spawn";
|
|
3
|
+
import { resolvedApiKey, validateKeyFormat } from "./auth.js";
|
|
4
|
+
import { installTinyFishCli } from "./cli-install.js";
|
|
5
|
+
import { ensureCliAuthenticated, TINYFISH_CLI_NOT_FOUND_MESSAGE } from "./connect-auth.js";
|
|
6
|
+
import { TINYFISH_API_KEY_VAR } from "./constants.js";
|
|
7
|
+
import { commandNotFound, ConnectInterruptedError, ConnectStepError, throwIfInterrupted, } from "./connect-runtime.js";
|
|
8
|
+
import { errLine, outLine } from "./output.js";
|
|
9
|
+
export const SEARCH_RETRY = 'tinyfish search query --pretty -- "<your query>"';
|
|
10
|
+
// Echoes the finale suggestion, so headless rescues rehearse the same task.
|
|
11
|
+
export const DEFAULT_VERIFICATION_QUERY = "today's top Hacker News story";
|
|
12
|
+
// Must undercut agent tool-call budgets (Claude Code Bash defaults to 120s).
|
|
13
|
+
const HEADLESS_VERIFY_TIMEOUT_MS = 90_000;
|
|
14
|
+
// An exported var must be unset, not re-logged-in.
|
|
15
|
+
export const MALFORMED_ENV_KEY_MESSAGE = "TINYFISH_API_KEY is set but malformed; it will override any stored key. Fix or unset it, then re-run.";
|
|
16
|
+
export const MALFORMED_STORED_KEY_MESSAGE = "The stored TinyFish API key is malformed. Run: tinyfish auth login";
|
|
17
|
+
function searchRetry(query) {
|
|
18
|
+
if (process.platform === "win32")
|
|
19
|
+
return SEARCH_RETRY;
|
|
20
|
+
return `tinyfish search query --pretty -- '${query.replaceAll("'", "'\\''")}'`;
|
|
21
|
+
}
|
|
22
|
+
/** One readline per question; a held-open interface wedges raw-stdin children. */
|
|
23
|
+
export function createStdinPrompt() {
|
|
24
|
+
let stdinClosed = false;
|
|
25
|
+
return async (question) => {
|
|
26
|
+
if (stdinClosed)
|
|
27
|
+
throw new Error("stdin is closed");
|
|
28
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
29
|
+
// Without a close-linked signal, question() never settles on Ctrl+D.
|
|
30
|
+
const closed = new AbortController();
|
|
31
|
+
rl.on("close", () => closed.abort());
|
|
32
|
+
// Raw mode swallows Ctrl+C here; resurface it as the interrupt.
|
|
33
|
+
let interrupted = false;
|
|
34
|
+
rl.on("SIGINT", () => {
|
|
35
|
+
interrupted = true;
|
|
36
|
+
rl.close();
|
|
37
|
+
});
|
|
38
|
+
try {
|
|
39
|
+
return await rl.question(question, { signal: closed.signal });
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (interrupted)
|
|
43
|
+
throw new ConnectInterruptedError("Setup interrupted");
|
|
44
|
+
stdinClosed = true;
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
rl.close();
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Steps print retry lines and return outcomes; only interrupts throw. */
|
|
53
|
+
export async function runCliFallback(opts) {
|
|
54
|
+
const { hooks, prompt } = opts;
|
|
55
|
+
hooks?.onStepStart?.("cli_install");
|
|
56
|
+
try {
|
|
57
|
+
installTinyFishCli({ verbose: opts.verbose, announce: true });
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (error instanceof ConnectInterruptedError)
|
|
61
|
+
throw error;
|
|
62
|
+
errLine(error instanceof Error ? error.message : String(error));
|
|
63
|
+
outLine(`Retry: ${opts.retryCommand}`);
|
|
64
|
+
return "cli_install_failed";
|
|
65
|
+
}
|
|
66
|
+
hooks?.onStepDone?.("cli_installed");
|
|
67
|
+
hooks?.onStepStart?.("authentication");
|
|
68
|
+
// Resolved raw: validatedApiKey collapses a malformed key into "no key".
|
|
69
|
+
const rawKey = resolvedApiKey(opts.apiKey);
|
|
70
|
+
if (rawKey && !validateKeyFormat(rawKey)) {
|
|
71
|
+
// Terminal even in a TTY: login writes config; env shadows it.
|
|
72
|
+
errLine(process.env[TINYFISH_API_KEY_VAR] ? MALFORMED_ENV_KEY_MESSAGE : MALFORMED_STORED_KEY_MESSAGE);
|
|
73
|
+
outLine(`Retry: ${opts.retryCommand}`);
|
|
74
|
+
return "invalid_credentials";
|
|
75
|
+
}
|
|
76
|
+
if (!prompt && !rawKey) {
|
|
77
|
+
outLine(`Retry in a terminal: ${opts.retryCommand}`);
|
|
78
|
+
return "no_credentials";
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
// Gate-only widening: a config-only key must not re-persist anywhere.
|
|
82
|
+
// Headless has no one to answer a login prompt; never inherit stdio there.
|
|
83
|
+
ensureCliAuthenticated("cli", opts.apiKey ?? process.env[TINYFISH_API_KEY_VAR], {
|
|
84
|
+
interactiveLogin: !!prompt,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error instanceof ConnectInterruptedError)
|
|
89
|
+
throw error;
|
|
90
|
+
errLine(error instanceof Error ? error.message : String(error));
|
|
91
|
+
outLine(error instanceof ConnectStepError && error.failureReason === "command_not_found"
|
|
92
|
+
? "Retry in a new terminal: tinyfish auth login"
|
|
93
|
+
: "Retry: tinyfish auth login");
|
|
94
|
+
return "authentication_failed";
|
|
95
|
+
}
|
|
96
|
+
hooks?.onStepDone?.("authenticated");
|
|
97
|
+
hooks?.onStepStart?.("cli_verification");
|
|
98
|
+
let query;
|
|
99
|
+
if (prompt) {
|
|
100
|
+
try {
|
|
101
|
+
query = (await prompt("What would you like to search? ")).trim();
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (error instanceof ConnectInterruptedError)
|
|
105
|
+
throw error;
|
|
106
|
+
outLine(`Retry: ${SEARCH_RETRY}`);
|
|
107
|
+
return "abandoned";
|
|
108
|
+
}
|
|
109
|
+
if (!query) {
|
|
110
|
+
outLine(`Retry: ${SEARCH_RETRY}`);
|
|
111
|
+
return "abandoned";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
// Headless with a key: a canned query still proves the pipe.
|
|
116
|
+
query = DEFAULT_VERIFICATION_QUERY;
|
|
117
|
+
}
|
|
118
|
+
const search = spawn.sync("tinyfish", ["search", "query", "--pretty", "--", query], {
|
|
119
|
+
stdio: "inherit",
|
|
120
|
+
env: { ...process.env, TF_SETUP_VERIFICATION: "1" },
|
|
121
|
+
// A user-driven search must never be cut off.
|
|
122
|
+
timeout: prompt ? undefined : HEADLESS_VERIFY_TIMEOUT_MS,
|
|
123
|
+
});
|
|
124
|
+
if (commandNotFound(search.error)) {
|
|
125
|
+
errLine(TINYFISH_CLI_NOT_FOUND_MESSAGE);
|
|
126
|
+
outLine(`Retry in a new terminal: ${searchRetry(query)}`);
|
|
127
|
+
return "verification_failed";
|
|
128
|
+
}
|
|
129
|
+
throwIfInterrupted(search);
|
|
130
|
+
if (search.error || search.status !== 0) {
|
|
131
|
+
outLine(`Retry: ${searchRetry(query)}`);
|
|
132
|
+
return "verification_failed";
|
|
133
|
+
}
|
|
134
|
+
hooks?.onStepDone?.("cli_verified");
|
|
135
|
+
return "cli_verified";
|
|
136
|
+
}
|
|
@@ -2,11 +2,12 @@ import { AuthMode } from "./harness-detect.js";
|
|
|
2
2
|
export declare const NON_INTERACTIVE_TIMEOUT_MS = 10000;
|
|
3
3
|
export type AgentClient = "claude-code" | "codex" | "cursor" | "grok" | "hermes" | "openclaw" | "opencode";
|
|
4
4
|
type ConnectStage = "started" | "checkpoint" | "completed" | "failed" | "aborted" | "post_install_failed";
|
|
5
|
-
type ConnectFailureStage = "prerequisite_check" | "registration_cleanup" | "registration" | "registration_or_authentication" | "client_oauth" | "cli_install" | "skill_install" | "authentication" | "walkthrough_launch";
|
|
5
|
+
export type ConnectFailureStage = "prerequisite_check" | "registration_cleanup" | "registration" | "registration_or_authentication" | "client_oauth" | "cli_install" | "skill_install" | "authentication" | "walkthrough_launch" | "cli_verification";
|
|
6
6
|
export declare const CONNECT_FAILURE_REASONS: readonly ["harness_not_installed", "harness_command_unsupported", "harness_too_old", "key_auth_unsupported", "command_not_found", "timeout", "spawn_error", "nonzero_exit", "invalid_config", "user_abandoned", "process_terminated", "unexpected_error"];
|
|
7
7
|
export type ConnectFailureReason = (typeof CONNECT_FAILURE_REASONS)[number];
|
|
8
8
|
export type ConnectAuthMode = Exclude<AuthMode, AuthMode.Unknown> | "deferred";
|
|
9
|
-
type ConnectCheckpoint = "prerequisite_ok" | "registered" | "oauth_done" | "cli_installed" | "skill_installed" | "authenticated";
|
|
9
|
+
export type ConnectCheckpoint = "prerequisite_ok" | "registered" | "oauth_done" | "cli_installed" | "skill_installed" | "authenticated" | "cli_verified";
|
|
10
|
+
export type CliFallbackOutcome = "cli_verified" | "cli_install_failed" | "no_credentials" | "invalid_credentials" | "authentication_failed" | "verification_failed" | "abandoned";
|
|
10
11
|
/** Ctrl+C killed a setup child — abandonment, not error. */
|
|
11
12
|
export declare class ConnectInterruptedError extends Error {
|
|
12
13
|
}
|
|
@@ -60,6 +61,7 @@ export interface ConnectTelemetry {
|
|
|
60
61
|
attemptId: string;
|
|
61
62
|
track(stage: ConnectStage, detail?: ConnectStageDetail): void;
|
|
62
63
|
setHarnessVersion(version?: string): void;
|
|
64
|
+
setFallbackOutcome(outcome: CliFallbackOutcome): void;
|
|
63
65
|
flush(): Promise<void>;
|
|
64
66
|
}
|
|
65
67
|
/** Terminal events exactly once, whichever of flow or signal handler wins. */
|
|
@@ -210,6 +210,7 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
|
|
|
210
210
|
const pending = [];
|
|
211
211
|
let lastTrackAt;
|
|
212
212
|
let harnessVersion;
|
|
213
|
+
let fallbackOutcome;
|
|
213
214
|
async function deliver(body) {
|
|
214
215
|
// Ahead of the key read; postConnectEvent's own opt-out check is too late to skip it.
|
|
215
216
|
if (telemetryDisabled())
|
|
@@ -229,6 +230,9 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
|
|
|
229
230
|
setHarnessVersion(version) {
|
|
230
231
|
harnessVersion = version;
|
|
231
232
|
},
|
|
233
|
+
setFallbackOutcome(outcome) {
|
|
234
|
+
fallbackOutcome = outcome;
|
|
235
|
+
},
|
|
232
236
|
track(stage, detail) {
|
|
233
237
|
const now = Date.now();
|
|
234
238
|
// Clamped: a backward clock step would send a route-rejected negative.
|
|
@@ -244,6 +248,8 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
|
|
|
244
248
|
failed_stage: detail?.failedStage,
|
|
245
249
|
phase: detail?.phase,
|
|
246
250
|
failure_reason: detail?.failureReason,
|
|
251
|
+
// Terminal rows only: rescue checkpoints stay wire-identical to normal ones.
|
|
252
|
+
fallback_outcome: stage === "failed" || stage === "aborted" ? fallbackOutcome : undefined,
|
|
247
253
|
harness_version: detail?.harnessVersion ?? harnessVersion,
|
|
248
254
|
auth_mode: detail?.authMode,
|
|
249
255
|
harness_degraded: detail?.harnessDegraded,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export declare const ALL_HARNESSES: readonly ["claude-code", "codex", "cursor", "grok", "hermes", "openclaw", "opencode"];
|
|
2
2
|
export type Harness = (typeof ALL_HARNESSES)[number];
|
|
3
3
|
export declare const HARNESS_DISPLAY_NAMES: Record<Harness, string>;
|
|
4
|
+
/** MCP config is read at startup, so a running harness needs this to see TinyFish. */
|
|
5
|
+
export declare const RELOAD_ACTION: Record<Harness, string>;
|
|
4
6
|
export declare enum Registered {
|
|
5
7
|
Yes = "yes",
|
|
6
8
|
No = "no",
|
|
@@ -20,6 +20,17 @@ export const HARNESS_DISPLAY_NAMES = {
|
|
|
20
20
|
openclaw: "OpenClaw",
|
|
21
21
|
opencode: "OpenCode",
|
|
22
22
|
};
|
|
23
|
+
/** MCP config is read at startup, so a running harness needs this to see TinyFish. */
|
|
24
|
+
export const RELOAD_ACTION = {
|
|
25
|
+
"claude-code": "restart it",
|
|
26
|
+
codex: "restart it",
|
|
27
|
+
// docs.x.ai/build/features/mcp-servers: `r` in /mcps rereads the config.
|
|
28
|
+
grok: "run /mcps and press r",
|
|
29
|
+
hermes: "restart it",
|
|
30
|
+
openclaw: "restart it",
|
|
31
|
+
opencode: "restart it",
|
|
32
|
+
cursor: "reload the window",
|
|
33
|
+
};
|
|
23
34
|
// Values are the wire contract; the report schema derives from these.
|
|
24
35
|
export var Registered;
|
|
25
36
|
(function (Registered) {
|