@tiny-fish/cli 0.12.1-next.135 → 0.12.1-next.138
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 +8 -0
- package/dist/commands/connect.d.ts +7 -1
- package/dist/commands/connect.js +65 -32
- package/dist/lib/client.d.ts +1 -1
- package/dist/lib/client.js +2 -2
- package/dist/lib/cursor-config.d.ts +13 -0
- package/dist/lib/cursor-config.js +115 -0
- package/dist/lib/harness-detect.d.ts +9 -0
- package/dist/lib/harness-detect.js +28 -0
- package/dist/lib/setup-telemetry.d.ts +50 -0
- package/dist/lib/setup-telemetry.js +107 -0
- package/dist/lib/verify.d.ts +10 -0
- package/dist/lib/verify.js +36 -0
- package/dist/lib/version-marker.d.ts +10 -0
- package/dist/lib/version-marker.js +109 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -277,6 +277,14 @@ TINYFISH_DEBUG=1 tinyfish agent run "..." --url https://example.com
|
|
|
277
277
|
tinyfish --debug agent run "..." --url https://example.com
|
|
278
278
|
```
|
|
279
279
|
|
|
280
|
+
### Telemetry
|
|
281
|
+
|
|
282
|
+
The CLI reports setup outcomes (which harnesses connected, whether verification passed) and
|
|
283
|
+
checks npm once a day for a newer CLI. Set `TINYFISH_NO_TELEMETRY` to any value other than
|
|
284
|
+
`0`/`false` to disable both. API keys are never sent — only a SHA-256 lookup hash, and only to
|
|
285
|
+
TinyFish-owned origins (`*.tinyfish.ai`, `*.tinyfish.io`) or a local development endpoint
|
|
286
|
+
(`localhost`, `127.0.0.1`, `[::1]`).
|
|
287
|
+
|
|
280
288
|
## CI/CD
|
|
281
289
|
|
|
282
290
|
```yaml
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
+
export declare const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
|
|
2
3
|
export declare const UPGRADE_HINT = "Run `tinyfish upgrade` any time to update the CLI and skill.";
|
|
3
4
|
export declare const DEFAULT_ONBOARDING_PROMPT: string;
|
|
4
5
|
export declare const OPENCLAW_ONBOARDING_PROMPT: string;
|
|
5
|
-
export type AgentClient = "claude-code" | "codex" | "hermes" | "openclaw";
|
|
6
|
+
export type AgentClient = "claude-code" | "codex" | "cursor" | "hermes" | "openclaw";
|
|
6
7
|
/** Ctrl+C/SIGTERM killed a setup child — abandonment, not error. */
|
|
7
8
|
export declare class ConnectInterruptedError extends Error {
|
|
8
9
|
}
|
|
@@ -36,5 +37,10 @@ export declare function connectOpenClaw(options: {
|
|
|
36
37
|
launch: boolean;
|
|
37
38
|
installCli?: boolean;
|
|
38
39
|
}): Promise<void>;
|
|
40
|
+
/** Writes mcp.json directly — no `cursor mcp add` exists. */
|
|
41
|
+
export declare function connectCursor(options: {
|
|
42
|
+
apiKey?: string;
|
|
43
|
+
mcpUrl: string;
|
|
44
|
+
}): Promise<void>;
|
|
39
45
|
export declare function launchAgent(client: AgentClient): void;
|
|
40
46
|
export declare function registerConnect(program: Command): void;
|
package/dist/commands/connect.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import spawn from "cross-spawn";
|
|
4
4
|
import { CONNECT_SOURCE, loadConfig, persistApiKeyToEnvironment, saveConnectContext, validateKeyFormat, writeConfig, } from "../lib/auth.js";
|
|
5
5
|
import { CLI_VERSION } from "../lib/constants.js";
|
|
6
|
+
import { cursorMcpPath, writeCursorMcpConfig } from "../lib/cursor-config.js";
|
|
6
7
|
import { errLine } from "../lib/output.js";
|
|
7
|
-
|
|
8
|
+
import { telemetryDisabled, telemetryHeaders } from "../lib/setup-telemetry.js";
|
|
9
|
+
import { recordInstalledVersion } from "../lib/version-marker.js";
|
|
10
|
+
export const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
|
|
8
11
|
const NON_INTERACTIVE_TIMEOUT_MS = 10_000;
|
|
9
12
|
const SKILL_INSTALL_TIMEOUT_MS = 120_000;
|
|
10
13
|
const HERMES_SEED_TIMEOUT_MS = 120_000;
|
|
@@ -219,27 +222,6 @@ const OPENCLAW = {
|
|
|
219
222
|
unavailableMessage: OPENCLAW_SKILL_UNAVAILABLE_MESSAGE,
|
|
220
223
|
},
|
|
221
224
|
};
|
|
222
|
-
// TinyFish-owned domains plus loopback are the only origins the API key may be sent to.
|
|
223
|
-
// `--url` accepts any http(s) URL, so a user-supplied origin must never receive the key.
|
|
224
|
-
const TRUSTED_TELEMETRY_HOST_SUFFIXES = [".tinyfish.ai", ".tinyfish.io"];
|
|
225
|
-
const TRUSTED_TELEMETRY_LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
|
226
|
-
function isTrustedTelemetryOrigin(endpoint) {
|
|
227
|
-
let url;
|
|
228
|
-
try {
|
|
229
|
-
url = new URL(endpoint);
|
|
230
|
-
}
|
|
231
|
-
catch {
|
|
232
|
-
return false;
|
|
233
|
-
}
|
|
234
|
-
const host = url.hostname.toLowerCase();
|
|
235
|
-
// Loopback may use http for local dev; every other trusted origin must be https so the
|
|
236
|
-
// key is never sent over cleartext.
|
|
237
|
-
if (TRUSTED_TELEMETRY_LOOPBACK_HOSTS.includes(host))
|
|
238
|
-
return true;
|
|
239
|
-
if (url.protocol !== "https:")
|
|
240
|
-
return false;
|
|
241
|
-
return TRUSTED_TELEMETRY_HOST_SUFFIXES.some((suffix) => host === suffix.slice(1) || host.endsWith(suffix));
|
|
242
|
-
}
|
|
243
225
|
function storedApiKey() {
|
|
244
226
|
try {
|
|
245
227
|
return loadConfig().api_key;
|
|
@@ -252,14 +234,11 @@ function createConnectTelemetry(mcpUrl, client, apiKey) {
|
|
|
252
234
|
const attemptId = randomUUID();
|
|
253
235
|
const endpoint = new URL("/api/cli/connect-event", mcpUrl).toString();
|
|
254
236
|
const telemetryKey = apiKey || process.env["TINYFISH_API_KEY"] || storedApiKey();
|
|
255
|
-
const headers =
|
|
256
|
-
// SHA-256 lookup key only — the raw key never rides a telemetry request
|
|
257
|
-
// (OWASP: keep credentials out of anything logging infra might capture).
|
|
258
|
-
if (telemetryKey && validateKeyFormat(telemetryKey) && isTrustedTelemetryOrigin(endpoint)) {
|
|
259
|
-
headers["x-api-key-lookup"] = createHash("sha256").update(telemetryKey).digest("hex");
|
|
260
|
-
}
|
|
237
|
+
const headers = telemetryHeaders(endpoint, telemetryKey);
|
|
261
238
|
const pending = [];
|
|
262
239
|
async function deliver(body) {
|
|
240
|
+
if (telemetryDisabled())
|
|
241
|
+
return;
|
|
263
242
|
// One retry: stale keep-alive sockets eat post-install POSTs. 5xx retries;
|
|
264
243
|
// 4xx never will (schema mismatch, e.g. new stages hitting an old server).
|
|
265
244
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
@@ -597,6 +576,7 @@ async function connectNativeMcpClient(client, options) {
|
|
|
597
576
|
// Persist once here (skill installed + authenticated) so both success paths, launch
|
|
598
577
|
// or not, record the install; buildConnectHeaders reads it on subsequent requests.
|
|
599
578
|
saveConnectContext(client.connectClient, telemetry.attemptId);
|
|
579
|
+
recordInstalledVersion();
|
|
600
580
|
errLine(UPGRADE_HINT);
|
|
601
581
|
if (!options.launch) {
|
|
602
582
|
errLine(`TinyFish is connected. Open ${client.displayName} to start using it.`);
|
|
@@ -675,6 +655,7 @@ export async function connectOpenClaw(options) {
|
|
|
675
655
|
}
|
|
676
656
|
telemetry.track("checkpoint", { phase: "authenticated" });
|
|
677
657
|
saveConnectContext("openclaw", telemetry.attemptId);
|
|
658
|
+
recordInstalledVersion();
|
|
678
659
|
errLine(UPGRADE_HINT);
|
|
679
660
|
if (!options.launch) {
|
|
680
661
|
errLine("TinyFish is connected. Open OpenClaw to start using it.");
|
|
@@ -687,6 +668,40 @@ export async function connectOpenClaw(options) {
|
|
|
687
668
|
settle(state, telemetry, "completed");
|
|
688
669
|
});
|
|
689
670
|
}
|
|
671
|
+
/** Writes mcp.json directly — no `cursor mcp add` exists. */
|
|
672
|
+
export async function connectCursor(options) {
|
|
673
|
+
const telemetry = createConnectTelemetry(options.mcpUrl, "cursor", options.apiKey);
|
|
674
|
+
const state = { stage: "prerequisite_check", settled: false };
|
|
675
|
+
await runGuarded(state, telemetry, () => {
|
|
676
|
+
telemetry.track("started");
|
|
677
|
+
telemetry.track("checkpoint", { phase: "prerequisite_ok" });
|
|
678
|
+
state.stage = "registration";
|
|
679
|
+
errLine("Adding TinyFish to Cursor...");
|
|
680
|
+
const mcpUrl = new URL(options.mcpUrl);
|
|
681
|
+
mcpUrl.searchParams.set("source", CONNECT_SOURCE);
|
|
682
|
+
mcpUrl.searchParams.set("client", "cursor");
|
|
683
|
+
// Attempt id rides telemetry only; persisting it would break idempotency.
|
|
684
|
+
const result = writeCursorMcpConfig(mcpUrl.toString());
|
|
685
|
+
if (result.status === "corrupt_skip") {
|
|
686
|
+
throw new Error(`Could not update ${cursorMcpPath()}: existing file could not be read or is not valid JSON (${result.error}). ` +
|
|
687
|
+
`Fix the file, then re-run: tinyfish connect cursor`);
|
|
688
|
+
}
|
|
689
|
+
if (result.backupPath) {
|
|
690
|
+
errLine(`Backed up existing Cursor MCP config to ${result.backupPath}`);
|
|
691
|
+
}
|
|
692
|
+
telemetry.track("checkpoint", { phase: "registered" });
|
|
693
|
+
state.stage = "authentication";
|
|
694
|
+
ensureCliAuthenticated("cursor", options.apiKey);
|
|
695
|
+
telemetry.track("checkpoint", { phase: "authenticated" });
|
|
696
|
+
saveConnectContext("cursor", telemetry.attemptId);
|
|
697
|
+
recordInstalledVersion();
|
|
698
|
+
// A server written into mcp.json lands unauthenticated: only Cursor's own "Add to
|
|
699
|
+
// Cursor" deep link auto-starts OAuth, so name the click or the install never completes.
|
|
700
|
+
errLine("Added to Cursor. Restart Cursor (or reload the window), then open");
|
|
701
|
+
errLine('Settings -> Tools & MCP and click Connect on "tinyfish" to sign in.');
|
|
702
|
+
settle(state, telemetry, "completed");
|
|
703
|
+
});
|
|
704
|
+
}
|
|
690
705
|
export function launchAgent(client) {
|
|
691
706
|
if (client === "claude-code") {
|
|
692
707
|
launchNativeMcpClient(CLAUDE_CODE);
|
|
@@ -697,15 +712,23 @@ export function launchAgent(client) {
|
|
|
697
712
|
else if (client === "hermes") {
|
|
698
713
|
launchNativeMcpClient(HERMES);
|
|
699
714
|
}
|
|
700
|
-
else {
|
|
715
|
+
else if (client === "openclaw") {
|
|
701
716
|
launchOpenClawWalkthrough();
|
|
702
717
|
}
|
|
718
|
+
else if (client === "cursor") {
|
|
719
|
+
// Cursor has no chat CLI to launch.
|
|
720
|
+
throw new Error("Cursor has no launchable walkthrough. Open Cursor and start chatting.");
|
|
721
|
+
}
|
|
722
|
+
else {
|
|
723
|
+
const unhandled = client;
|
|
724
|
+
throw new Error(`No launch support for client: ${String(unhandled)}`);
|
|
725
|
+
}
|
|
703
726
|
}
|
|
704
727
|
export function registerConnect(program) {
|
|
705
728
|
program
|
|
706
729
|
.command("connect")
|
|
707
730
|
.description("Connect TinyFish to an AI agent")
|
|
708
|
-
.argument("<client>", "Agent client to connect (claude-code, codex, hermes, or openclaw)")
|
|
731
|
+
.argument("<client>", "Agent client to connect (claude-code, codex, cursor, hermes, or openclaw)")
|
|
709
732
|
.option("--api-key <apiKey>", "TinyFish API key to persist for the installed skill")
|
|
710
733
|
.option("--launch", "Launch the agent and start the TinyFish walkthrough")
|
|
711
734
|
.option("--url <mcpUrl>", "MCP endpoint override")
|
|
@@ -740,8 +763,18 @@ export function registerConnect(program) {
|
|
|
740
763
|
else if (client === "openclaw") {
|
|
741
764
|
await connectOpenClaw({ mcpUrl: connectOptions.mcpUrl, launch: connectOptions.launch });
|
|
742
765
|
}
|
|
766
|
+
else if (client === "cursor") {
|
|
767
|
+
// `connect <client> --launch` is the shape every doc uses; connect anyway, then say
|
|
768
|
+
// why nothing launched rather than throwing away a working install.
|
|
769
|
+
await connectCursor({ apiKey: connectOptions.apiKey, mcpUrl: connectOptions.mcpUrl });
|
|
770
|
+
if (connectOptions.launch) {
|
|
771
|
+
errLine("Cursor has no launchable walkthrough. Open Cursor and start chatting.");
|
|
772
|
+
}
|
|
773
|
+
}
|
|
743
774
|
else {
|
|
744
|
-
throw new Error(
|
|
775
|
+
throw new Error("Unsupported client: " +
|
|
776
|
+
client +
|
|
777
|
+
". Supported clients: claude-code, codex, cursor, hermes, openclaw");
|
|
745
778
|
}
|
|
746
779
|
});
|
|
747
780
|
}
|
package/dist/lib/client.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export declare function buildConnectHeaders(connect: ConnectMap, caller: string
|
|
|
7
7
|
export declare function runSync(req: CliAgentRunParams, apiKey: string): Promise<AgentRunResponse>;
|
|
8
8
|
export declare function runAsync(req: CliAgentRunParams, apiKey: string): Promise<AgentRunAsyncResponse>;
|
|
9
9
|
export declare function runStream(req: CliAgentRunParams, apiKey: string, signal?: AbortSignal): AsyncGenerator<AgentRunWithStreamingResponse>;
|
|
10
|
-
export declare function listRuns(opts: RunListParams, apiKey: string): Promise<RunListResponse>;
|
|
10
|
+
export declare function listRuns(opts: RunListParams, apiKey: string, timeout?: number): Promise<RunListResponse>;
|
|
11
11
|
export declare function getRun(runId: string, apiKey: string): Promise<Run>;
|
|
12
12
|
export declare function getRunSteps(runId: string, apiKey: string): Promise<RunStepsResponse>;
|
|
13
13
|
export declare function searchQuery(params: SearchQueryParams, apiKey: string): Promise<SearchQueryResponse>;
|
package/dist/lib/client.js
CHANGED
|
@@ -256,9 +256,9 @@ export async function* runStream(req, apiKey, signal) {
|
|
|
256
256
|
}
|
|
257
257
|
}
|
|
258
258
|
}
|
|
259
|
-
export async function listRuns(opts, apiKey) {
|
|
259
|
+
export async function listRuns(opts, apiKey, timeout) {
|
|
260
260
|
try {
|
|
261
|
-
return await createSdkClient(apiKey).runs.list(opts);
|
|
261
|
+
return await createSdkClient(apiKey, timeout).runs.list(opts);
|
|
262
262
|
}
|
|
263
263
|
catch (error) {
|
|
264
264
|
rethrowSdkError(error);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare function cursorConfigDir(): string;
|
|
2
|
+
export declare function cursorMcpPath(): string;
|
|
3
|
+
export interface CursorMcpWriteResult {
|
|
4
|
+
status: "written" | "unchanged" | "corrupt_skip";
|
|
5
|
+
backupPath?: string;
|
|
6
|
+
error?: string;
|
|
7
|
+
}
|
|
8
|
+
/** Dry-run description of the pending write; touches nothing. */
|
|
9
|
+
export declare function planCursorWrite(mcpUrl: string): string;
|
|
10
|
+
/** Merges only the `tinyfish` key; skips unreadable/corrupt files rather than clobber. */
|
|
11
|
+
export declare function writeCursorMcpConfig(mcpUrl: string): CursorMcpWriteResult;
|
|
12
|
+
/** Removes only the `tinyfish` key. */
|
|
13
|
+
export declare function removeCursorMcpServer(): CursorMcpWriteResult;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
// The one config file the CLI writes directly — hence backup/merge rigor.
|
|
5
|
+
const TINYFISH_SERVER_KEY = "tinyfish";
|
|
6
|
+
export function cursorConfigDir() {
|
|
7
|
+
return path.join(os.homedir(), ".cursor");
|
|
8
|
+
}
|
|
9
|
+
export function cursorMcpPath() {
|
|
10
|
+
return path.join(cursorConfigDir(), "mcp.json");
|
|
11
|
+
}
|
|
12
|
+
function isPlainRecord(value) {
|
|
13
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
function buildTinyfishServerEntry(mcpUrl) {
|
|
16
|
+
return { url: mcpUrl };
|
|
17
|
+
}
|
|
18
|
+
function parseMcpJson(raw) {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(raw);
|
|
21
|
+
if (!isPlainRecord(parsed))
|
|
22
|
+
return { ok: false, error: "mcp.json root is not a JSON object" };
|
|
23
|
+
if ("mcpServers" in parsed && !isPlainRecord(parsed.mcpServers)) {
|
|
24
|
+
return { ok: false, error: "mcp.json mcpServers is not a JSON object" };
|
|
25
|
+
}
|
|
26
|
+
return { ok: true, value: parsed };
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// Non-ENOENT read failures count as corruption: skip, never clobber.
|
|
33
|
+
function readExisting() {
|
|
34
|
+
let raw;
|
|
35
|
+
try {
|
|
36
|
+
raw = fs.readFileSync(cursorMcpPath(), "utf8");
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
if (e?.code === "ENOENT")
|
|
40
|
+
return { parsed: {} };
|
|
41
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
42
|
+
}
|
|
43
|
+
const parsed = parseMcpJson(raw);
|
|
44
|
+
if (!parsed.ok)
|
|
45
|
+
return { error: parsed.error };
|
|
46
|
+
return { raw, parsed: parsed.value };
|
|
47
|
+
}
|
|
48
|
+
/** Dry-run description of the pending write; touches nothing. */
|
|
49
|
+
export function planCursorWrite(mcpUrl) {
|
|
50
|
+
const existing = readExisting();
|
|
51
|
+
const filePath = cursorMcpPath();
|
|
52
|
+
if ("error" in existing) {
|
|
53
|
+
return `${filePath}: existing file is corrupt (${existing.error}) — would skip and leave it untouched`;
|
|
54
|
+
}
|
|
55
|
+
const servers = existing.parsed.mcpServers;
|
|
56
|
+
const current = isPlainRecord(servers) ? servers[TINYFISH_SERVER_KEY] : undefined;
|
|
57
|
+
if (JSON.stringify(current) === JSON.stringify(buildTinyfishServerEntry(mcpUrl))) {
|
|
58
|
+
return `${filePath}: already has the tinyfish MCP entry — no change`;
|
|
59
|
+
}
|
|
60
|
+
return existing.raw === undefined
|
|
61
|
+
? `${filePath}: would create with a "tinyfish" MCP server entry`
|
|
62
|
+
: `${filePath}: would back up to a timestamped copy, then merge in the "tinyfish" MCP server entry`;
|
|
63
|
+
}
|
|
64
|
+
// Backup, then atomic temp+rename; pid avoids same-millisecond backup collisions.
|
|
65
|
+
function commitServers(filePath, existing, servers) {
|
|
66
|
+
fs.mkdirSync(cursorConfigDir(), { recursive: true, mode: 0o700 });
|
|
67
|
+
let backupPath;
|
|
68
|
+
if (existing.raw !== undefined) {
|
|
69
|
+
backupPath = `${filePath}.bak-${Date.now()}-${process.pid}`;
|
|
70
|
+
fs.writeFileSync(backupPath, existing.raw, { mode: 0o600 });
|
|
71
|
+
}
|
|
72
|
+
const next = { ...existing.parsed, mcpServers: servers };
|
|
73
|
+
const tmpPath = `${filePath}.tmp-${process.pid}`;
|
|
74
|
+
fs.writeFileSync(tmpPath, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
|
|
75
|
+
try {
|
|
76
|
+
fs.renameSync(tmpPath, filePath);
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
fs.rmSync(tmpPath, { force: true });
|
|
80
|
+
throw e;
|
|
81
|
+
}
|
|
82
|
+
return { status: "written", backupPath };
|
|
83
|
+
}
|
|
84
|
+
/** Merges only the `tinyfish` key; skips unreadable/corrupt files rather than clobber. */
|
|
85
|
+
export function writeCursorMcpConfig(mcpUrl) {
|
|
86
|
+
const filePath = cursorMcpPath();
|
|
87
|
+
const existing = readExisting();
|
|
88
|
+
if ("error" in existing)
|
|
89
|
+
return { status: "corrupt_skip", error: existing.error };
|
|
90
|
+
const servers = isPlainRecord(existing.parsed.mcpServers)
|
|
91
|
+
? { ...existing.parsed.mcpServers }
|
|
92
|
+
: {};
|
|
93
|
+
const nextEntry = buildTinyfishServerEntry(mcpUrl);
|
|
94
|
+
if (JSON.stringify(servers[TINYFISH_SERVER_KEY]) === JSON.stringify(nextEntry)) {
|
|
95
|
+
return { status: "unchanged" };
|
|
96
|
+
}
|
|
97
|
+
servers[TINYFISH_SERVER_KEY] = nextEntry;
|
|
98
|
+
return commitServers(filePath, existing, servers);
|
|
99
|
+
}
|
|
100
|
+
/** Removes only the `tinyfish` key. */
|
|
101
|
+
export function removeCursorMcpServer() {
|
|
102
|
+
const filePath = cursorMcpPath();
|
|
103
|
+
const existing = readExisting();
|
|
104
|
+
if ("error" in existing)
|
|
105
|
+
return { status: "corrupt_skip", error: existing.error };
|
|
106
|
+
if (existing.raw === undefined)
|
|
107
|
+
return { status: "unchanged" };
|
|
108
|
+
const servers = isPlainRecord(existing.parsed.mcpServers)
|
|
109
|
+
? { ...existing.parsed.mcpServers }
|
|
110
|
+
: {};
|
|
111
|
+
if (!(TINYFISH_SERVER_KEY in servers))
|
|
112
|
+
return { status: "unchanged" };
|
|
113
|
+
delete servers[TINYFISH_SERVER_KEY];
|
|
114
|
+
return commitServers(filePath, existing, servers);
|
|
115
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const FIVE_HARNESSES: readonly ["claude-code", "codex", "cursor", "hermes", "openclaw"];
|
|
2
|
+
export type FiveHarness = (typeof FIVE_HARNESSES)[number];
|
|
3
|
+
export declare function harnessConfigPath(harness: FiveHarness): string;
|
|
4
|
+
export interface HarnessDetection {
|
|
5
|
+
harness: FiveHarness;
|
|
6
|
+
detected: boolean;
|
|
7
|
+
configPath: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function detectInstalledHarnesses(): HarnessDetection[];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
export const FIVE_HARNESSES = ["claude-code", "codex", "cursor", "hermes", "openclaw"];
|
|
5
|
+
// Presence detection only — dir existence means "installed", nothing more.
|
|
6
|
+
const CONFIG_DIRS = {
|
|
7
|
+
"claude-code": ".claude",
|
|
8
|
+
codex: ".codex",
|
|
9
|
+
cursor: ".cursor",
|
|
10
|
+
hermes: ".hermes",
|
|
11
|
+
openclaw: ".openclaw",
|
|
12
|
+
};
|
|
13
|
+
export function harnessConfigPath(harness) {
|
|
14
|
+
return path.join(os.homedir(), CONFIG_DIRS[harness]); // nosemgrep: path-join-resolve-traversal -- fixed dir names under os.homedir()
|
|
15
|
+
}
|
|
16
|
+
export function detectInstalledHarnesses() {
|
|
17
|
+
return FIVE_HARNESSES.map((harness) => {
|
|
18
|
+
const configPath = harnessConfigPath(harness);
|
|
19
|
+
let detected;
|
|
20
|
+
try {
|
|
21
|
+
detected = fs.statSync(configPath).isDirectory();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
detected = false;
|
|
25
|
+
}
|
|
26
|
+
return { harness, detected, configPath };
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* `TINYFISH_NO_TELEMETRY` suppresses ALL CLI-emitted network analytics
|
|
4
|
+
* (setup_completed, connect-event, update check). Any value except `0`/`false`
|
|
5
|
+
* opts out — a privacy switch must not fail closed on `true`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function telemetryDisabled(): boolean;
|
|
8
|
+
export declare function isTrustedTelemetryOrigin(endpoint: string): boolean;
|
|
9
|
+
/** SHA-256 lookup key only, and only to trusted origins — the raw key never rides telemetry. */
|
|
10
|
+
export declare function telemetryHeaders(endpoint: string, apiKey?: string): Record<string, string>;
|
|
11
|
+
declare const harnessResultSchema: z.ZodObject<{
|
|
12
|
+
harness: z.ZodEnum<{
|
|
13
|
+
openclaw: "openclaw";
|
|
14
|
+
cursor: "cursor";
|
|
15
|
+
codex: "codex";
|
|
16
|
+
hermes: "hermes";
|
|
17
|
+
"claude-code": "claude-code";
|
|
18
|
+
}>;
|
|
19
|
+
detected: z.ZodBoolean;
|
|
20
|
+
installed: z.ZodBoolean;
|
|
21
|
+
verify_depth: z.ZodNullable<z.ZodEnum<{
|
|
22
|
+
health: "health";
|
|
23
|
+
"health+auth": "health+auth";
|
|
24
|
+
}>>;
|
|
25
|
+
verify_ok: z.ZodNullable<z.ZodBoolean>;
|
|
26
|
+
}, z.core.$strip>;
|
|
27
|
+
export declare const setupCompletedPayloadSchema: z.ZodObject<{
|
|
28
|
+
harnesses: z.ZodArray<z.ZodObject<{
|
|
29
|
+
harness: z.ZodEnum<{
|
|
30
|
+
openclaw: "openclaw";
|
|
31
|
+
cursor: "cursor";
|
|
32
|
+
codex: "codex";
|
|
33
|
+
hermes: "hermes";
|
|
34
|
+
"claude-code": "claude-code";
|
|
35
|
+
}>;
|
|
36
|
+
detected: z.ZodBoolean;
|
|
37
|
+
installed: z.ZodBoolean;
|
|
38
|
+
verify_depth: z.ZodNullable<z.ZodEnum<{
|
|
39
|
+
health: "health";
|
|
40
|
+
"health+auth": "health+auth";
|
|
41
|
+
}>>;
|
|
42
|
+
verify_ok: z.ZodNullable<z.ZodBoolean>;
|
|
43
|
+
}, z.core.$strip>>;
|
|
44
|
+
cli_version: z.ZodString;
|
|
45
|
+
}, z.core.$strip>;
|
|
46
|
+
export type SetupCompletedPayload = z.infer<typeof setupCompletedPayloadSchema>;
|
|
47
|
+
export type HarnessResult = z.infer<typeof harnessResultSchema>;
|
|
48
|
+
/** Fire-and-forget, 2s cap; raw keys never ride telemetry. */
|
|
49
|
+
export declare function sendSetupCompleted(mcpUrl: string, harnesses: HarnessResult[], apiKey?: string): Promise<void>;
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { validateKeyFormat } from "./auth.js";
|
|
4
|
+
import { CLI_VERSION } from "./constants.js";
|
|
5
|
+
import { FIVE_HARNESSES } from "./harness-detect.js";
|
|
6
|
+
import { errLine } from "./output.js";
|
|
7
|
+
const SETUP_TELEMETRY_TIMEOUT_MS = 2_000;
|
|
8
|
+
/**
|
|
9
|
+
* `TINYFISH_NO_TELEMETRY` suppresses ALL CLI-emitted network analytics
|
|
10
|
+
* (setup_completed, connect-event, update check). Any value except `0`/`false`
|
|
11
|
+
* opts out — a privacy switch must not fail closed on `true`.
|
|
12
|
+
*/
|
|
13
|
+
export function telemetryDisabled() {
|
|
14
|
+
const value = (process.env["TINYFISH_NO_TELEMETRY"] ?? "").trim().toLowerCase();
|
|
15
|
+
return value !== "" && value !== "0" && value !== "false";
|
|
16
|
+
}
|
|
17
|
+
// TinyFish-owned domains plus loopback are the only origins the API key may be sent to.
|
|
18
|
+
// `--url` accepts any http(s) URL, so a user-supplied origin must never receive the key.
|
|
19
|
+
const TRUSTED_TELEMETRY_HOST_SUFFIXES = [".tinyfish.ai", ".tinyfish.io"];
|
|
20
|
+
const TRUSTED_TELEMETRY_LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
|
21
|
+
export function isTrustedTelemetryOrigin(endpoint) {
|
|
22
|
+
let url;
|
|
23
|
+
try {
|
|
24
|
+
url = new URL(endpoint);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
const host = url.hostname.toLowerCase();
|
|
30
|
+
// Loopback may use http for local dev; every other trusted origin must be https so the
|
|
31
|
+
// key is never sent over cleartext.
|
|
32
|
+
if (TRUSTED_TELEMETRY_LOOPBACK_HOSTS.includes(host))
|
|
33
|
+
return true;
|
|
34
|
+
if (url.protocol !== "https:")
|
|
35
|
+
return false;
|
|
36
|
+
return TRUSTED_TELEMETRY_HOST_SUFFIXES.some((suffix) => host === suffix.slice(1) || host.endsWith(suffix));
|
|
37
|
+
}
|
|
38
|
+
/** SHA-256 lookup key only, and only to trusted origins — the raw key never rides telemetry. */
|
|
39
|
+
export function telemetryHeaders(endpoint, apiKey) {
|
|
40
|
+
const headers = { "content-type": "application/json" };
|
|
41
|
+
if (apiKey && validateKeyFormat(apiKey) && isTrustedTelemetryOrigin(endpoint)) {
|
|
42
|
+
headers["x-api-key-lookup"] = createHash("sha256").update(apiKey).digest("hex");
|
|
43
|
+
}
|
|
44
|
+
return headers;
|
|
45
|
+
}
|
|
46
|
+
const harnessResultSchema = z.object({
|
|
47
|
+
harness: z.enum(FIVE_HARNESSES),
|
|
48
|
+
detected: z.boolean(),
|
|
49
|
+
installed: z.boolean(),
|
|
50
|
+
verify_depth: z.enum(["health", "health+auth"]).nullable(),
|
|
51
|
+
verify_ok: z.boolean().nullable(),
|
|
52
|
+
});
|
|
53
|
+
export const setupCompletedPayloadSchema = z.object({
|
|
54
|
+
harnesses: z
|
|
55
|
+
.array(harnessResultSchema)
|
|
56
|
+
.length(FIVE_HARNESSES.length)
|
|
57
|
+
.refine((results) => new Set(results.map((r) => r.harness)).size === FIVE_HARNESSES.length, {
|
|
58
|
+
message: "expected one result per harness",
|
|
59
|
+
}),
|
|
60
|
+
cli_version: z.string(),
|
|
61
|
+
});
|
|
62
|
+
/** Fire-and-forget, 2s cap; raw keys never ride telemetry. */
|
|
63
|
+
export async function sendSetupCompleted(mcpUrl, harnesses, apiKey) {
|
|
64
|
+
if (telemetryDisabled())
|
|
65
|
+
return;
|
|
66
|
+
let endpoint;
|
|
67
|
+
let body;
|
|
68
|
+
try {
|
|
69
|
+
// Parse inside try — schema violation must not crash finished setup.
|
|
70
|
+
const payload = setupCompletedPayloadSchema.parse({ harnesses, cli_version: CLI_VERSION });
|
|
71
|
+
endpoint = new URL("/api/cli/connect-event", mcpUrl).toString();
|
|
72
|
+
body = JSON.stringify({ event: "setup_completed", ...payload });
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
if (process.env["TINYFISH_DEBUG"]) {
|
|
76
|
+
errLine(`setup telemetry skipped: ${e instanceof Error ? e.message : String(e)}`);
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const headers = telemetryHeaders(endpoint, apiKey);
|
|
81
|
+
// One retry: stale keep-alive sockets eat post-install POSTs, and this is the
|
|
82
|
+
// headline `--all` metric. 5xx retries; 4xx never will.
|
|
83
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
84
|
+
try {
|
|
85
|
+
const response = await fetch(endpoint, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers,
|
|
88
|
+
body,
|
|
89
|
+
redirect: "error",
|
|
90
|
+
signal: AbortSignal.timeout(SETUP_TELEMETRY_TIMEOUT_MS),
|
|
91
|
+
});
|
|
92
|
+
if (response.ok)
|
|
93
|
+
return;
|
|
94
|
+
if (response.status < 500) {
|
|
95
|
+
if (process.env["TINYFISH_DEBUG"]) {
|
|
96
|
+
errLine(`setup telemetry rejected: HTTP ${response.status}`);
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
if (process.env["TINYFISH_DEBUG"]) {
|
|
103
|
+
errLine(`setup telemetry failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type VerifyDepth = "health" | "health+auth";
|
|
2
|
+
export interface VerifyResult {
|
|
3
|
+
depth: VerifyDepth;
|
|
4
|
+
ok: boolean;
|
|
5
|
+
reason?: string;
|
|
6
|
+
}
|
|
7
|
+
/** Reachability check. Verify failure is a warning, never install failure. */
|
|
8
|
+
export declare function verifyMcpHealth(mcpUrl: string): Promise<VerifyResult>;
|
|
9
|
+
/** Authenticated check — only where the CLI holds the key (Cursor, OpenClaw). */
|
|
10
|
+
export declare function verifyMcpAuth(apiKey: string): Promise<VerifyResult>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { listRuns } from "./client.js";
|
|
2
|
+
const VERIFY_TIMEOUT_MS = 5_000;
|
|
3
|
+
/** Reachability check. Verify failure is a warning, never install failure. */
|
|
4
|
+
export async function verifyMcpHealth(mcpUrl) {
|
|
5
|
+
try {
|
|
6
|
+
const response = await fetch(mcpUrl, {
|
|
7
|
+
method: "GET",
|
|
8
|
+
// Redirect (captive portal, typo'd --url) is not healthy.
|
|
9
|
+
redirect: "error",
|
|
10
|
+
signal: AbortSignal.timeout(VERIFY_TIMEOUT_MS),
|
|
11
|
+
});
|
|
12
|
+
// MCP rejects bare GET with 4xx — still proves endpoint routed.
|
|
13
|
+
if (response.status >= 500) {
|
|
14
|
+
return { depth: "health", ok: false, reason: `endpoint returned HTTP ${response.status}` };
|
|
15
|
+
}
|
|
16
|
+
return { depth: "health", ok: true };
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
return {
|
|
20
|
+
depth: "health",
|
|
21
|
+
ok: false,
|
|
22
|
+
reason: e instanceof Error ? e.message : "network error reaching MCP endpoint",
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Authenticated check — only where the CLI holds the key (Cursor, OpenClaw). */
|
|
27
|
+
export async function verifyMcpAuth(apiKey) {
|
|
28
|
+
try {
|
|
29
|
+
// Bound it: the SDK default is 600s, and a warning-only check must never hang an install.
|
|
30
|
+
await listRuns({ limit: 1 }, apiKey, VERIFY_TIMEOUT_MS);
|
|
31
|
+
return { depth: "health+auth", ok: true };
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
return { depth: "health+auth", ok: false, reason: e instanceof Error ? e.message : String(e) };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Install-time, offline-safe; best-effort — never fails a finished install. */
|
|
2
|
+
export declare function recordInstalledVersion(): void;
|
|
3
|
+
interface UpdateCheckResult {
|
|
4
|
+
updateAvailable: boolean;
|
|
5
|
+
latest?: string;
|
|
6
|
+
}
|
|
7
|
+
/** Throttled npm-currency check; never auto-mutates the install. */
|
|
8
|
+
export declare function checkForCliUpdate(now?: number): Promise<UpdateCheckResult>;
|
|
9
|
+
export declare function printUpdateNoticeIfDrifted(): Promise<void>;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { configDir } from "./auth.js";
|
|
5
|
+
import { CLI_VERSION } from "./constants.js";
|
|
6
|
+
import { errLine } from "./output.js";
|
|
7
|
+
import { telemetryDisabled } from "./setup-telemetry.js";
|
|
8
|
+
const MARKER_FILE = "skill-version-marker.json";
|
|
9
|
+
const THROTTLE_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
const CHECK_TIMEOUT_MS = 5_000;
|
|
11
|
+
// npm is the only currency signal with a publisher behind it: the cookbook repo has no
|
|
12
|
+
// releases and no release automation, so a tag check would report "up to date" forever.
|
|
13
|
+
const REGISTRY_URL = "https://registry.npmjs.org/@tiny-fish/cli/latest";
|
|
14
|
+
const markerSchema = z.object({
|
|
15
|
+
last_checked_at: z.number().optional(),
|
|
16
|
+
last_known_latest: z.string().optional(),
|
|
17
|
+
});
|
|
18
|
+
function markerPath() {
|
|
19
|
+
return path.join(configDir(), MARKER_FILE);
|
|
20
|
+
}
|
|
21
|
+
function readMarker() {
|
|
22
|
+
try {
|
|
23
|
+
const parsed = markerSchema.safeParse(JSON.parse(fs.readFileSync(markerPath(), "utf8")));
|
|
24
|
+
return parsed.success ? parsed.data : undefined;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function writeMarker(state) {
|
|
31
|
+
fs.mkdirSync(configDir(), { recursive: true, mode: 0o700 });
|
|
32
|
+
const tmpPath = `${markerPath()}.tmp-${process.pid}`;
|
|
33
|
+
fs.writeFileSync(tmpPath, JSON.stringify(state), { mode: 0o600 });
|
|
34
|
+
fs.renameSync(tmpPath, markerPath());
|
|
35
|
+
}
|
|
36
|
+
/** Install-time, offline-safe; best-effort — never fails a finished install. */
|
|
37
|
+
export function recordInstalledVersion() {
|
|
38
|
+
try {
|
|
39
|
+
writeMarker({ ...readMarker() });
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// marker is a nice-to-have
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)$/;
|
|
46
|
+
/** Strictly newer; false on unparsable input. */
|
|
47
|
+
function isNewer(current, latest) {
|
|
48
|
+
const c = SEMVER.exec(current);
|
|
49
|
+
const l = SEMVER.exec(latest);
|
|
50
|
+
if (!c || !l)
|
|
51
|
+
return false;
|
|
52
|
+
for (let i = 1; i <= 3; i++) {
|
|
53
|
+
const cn = Number(c[i]);
|
|
54
|
+
const ln = Number(l[i]);
|
|
55
|
+
if (ln !== cn)
|
|
56
|
+
return ln > cn;
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
/** Throttled npm-currency check; never auto-mutates the install. */
|
|
61
|
+
export async function checkForCliUpdate(now = Date.now()) {
|
|
62
|
+
const marker = readMarker();
|
|
63
|
+
// No marker means the CLI never installed anything — nothing to nag about.
|
|
64
|
+
if (!marker || telemetryDisabled())
|
|
65
|
+
return { updateAvailable: false };
|
|
66
|
+
const cached = () => ({
|
|
67
|
+
updateAvailable: marker.last_known_latest !== undefined && isNewer(CLI_VERSION, marker.last_known_latest),
|
|
68
|
+
...(marker.last_known_latest !== undefined ? { latest: marker.last_known_latest } : {}),
|
|
69
|
+
});
|
|
70
|
+
// Time-based only — failed fetch must not stall every invocation.
|
|
71
|
+
if (marker.last_checked_at !== undefined && now - marker.last_checked_at < THROTTLE_MS) {
|
|
72
|
+
return cached();
|
|
73
|
+
}
|
|
74
|
+
let latest;
|
|
75
|
+
try {
|
|
76
|
+
const response = await fetch(REGISTRY_URL, {
|
|
77
|
+
headers: { accept: "application/json" },
|
|
78
|
+
signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
|
|
79
|
+
});
|
|
80
|
+
if (response.ok) {
|
|
81
|
+
const body = (await response.json());
|
|
82
|
+
if (typeof body.version === "string")
|
|
83
|
+
latest = body.version;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Unreachable registry — don't fail setup, don't erase the cache.
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
writeMarker({
|
|
91
|
+
...marker,
|
|
92
|
+
last_checked_at: now,
|
|
93
|
+
last_known_latest: latest ?? marker.last_known_latest,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// best-effort
|
|
98
|
+
}
|
|
99
|
+
// Fall back to the cache so consecutive invocations can't disagree.
|
|
100
|
+
if (!latest)
|
|
101
|
+
return cached();
|
|
102
|
+
return { updateAvailable: isNewer(CLI_VERSION, latest), latest };
|
|
103
|
+
}
|
|
104
|
+
export async function printUpdateNoticeIfDrifted() {
|
|
105
|
+
const { updateAvailable } = await checkForCliUpdate();
|
|
106
|
+
if (updateAvailable) {
|
|
107
|
+
errLine("a newer TinyFish CLI is available — re-run `npx @tiny-fish/cli connect --all`");
|
|
108
|
+
}
|
|
109
|
+
}
|