@tiny-fish/cli 0.12.0 → 0.12.1-next.137
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.
|
@@ -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
|
@@ -3,8 +3,9 @@ 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
|
-
const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
|
|
8
|
+
export const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
|
|
8
9
|
const NON_INTERACTIVE_TIMEOUT_MS = 10_000;
|
|
9
10
|
const SKILL_INSTALL_TIMEOUT_MS = 120_000;
|
|
10
11
|
const HERMES_SEED_TIMEOUT_MS = 120_000;
|
|
@@ -687,6 +688,39 @@ export async function connectOpenClaw(options) {
|
|
|
687
688
|
settle(state, telemetry, "completed");
|
|
688
689
|
});
|
|
689
690
|
}
|
|
691
|
+
/** Writes mcp.json directly — no `cursor mcp add` exists. */
|
|
692
|
+
export async function connectCursor(options) {
|
|
693
|
+
const telemetry = createConnectTelemetry(options.mcpUrl, "cursor", options.apiKey);
|
|
694
|
+
const state = { stage: "prerequisite_check", settled: false };
|
|
695
|
+
await runGuarded(state, telemetry, () => {
|
|
696
|
+
telemetry.track("started");
|
|
697
|
+
telemetry.track("checkpoint", { phase: "prerequisite_ok" });
|
|
698
|
+
state.stage = "registration";
|
|
699
|
+
errLine("Adding TinyFish to Cursor...");
|
|
700
|
+
const mcpUrl = new URL(options.mcpUrl);
|
|
701
|
+
mcpUrl.searchParams.set("source", CONNECT_SOURCE);
|
|
702
|
+
mcpUrl.searchParams.set("client", "cursor");
|
|
703
|
+
// Attempt id rides telemetry only; persisting it would break idempotency.
|
|
704
|
+
const result = writeCursorMcpConfig(mcpUrl.toString());
|
|
705
|
+
if (result.status === "corrupt_skip") {
|
|
706
|
+
throw new Error(`Could not update ${cursorMcpPath()}: existing file could not be read or is not valid JSON (${result.error}). ` +
|
|
707
|
+
`Fix the file, then re-run: tinyfish connect cursor`);
|
|
708
|
+
}
|
|
709
|
+
if (result.backupPath) {
|
|
710
|
+
errLine(`Backed up existing Cursor MCP config to ${result.backupPath}`);
|
|
711
|
+
}
|
|
712
|
+
telemetry.track("checkpoint", { phase: "registered" });
|
|
713
|
+
state.stage = "authentication";
|
|
714
|
+
ensureCliAuthenticated("cursor", options.apiKey);
|
|
715
|
+
telemetry.track("checkpoint", { phase: "authenticated" });
|
|
716
|
+
saveConnectContext("cursor", telemetry.attemptId);
|
|
717
|
+
// A server written into mcp.json lands unauthenticated: only Cursor's own "Add to
|
|
718
|
+
// Cursor" deep link auto-starts OAuth, so name the click or the install never completes.
|
|
719
|
+
errLine("Added to Cursor. Restart Cursor (or reload the window), then open");
|
|
720
|
+
errLine('Settings -> Tools & MCP and click Connect on "tinyfish" to sign in.');
|
|
721
|
+
settle(state, telemetry, "completed");
|
|
722
|
+
});
|
|
723
|
+
}
|
|
690
724
|
export function launchAgent(client) {
|
|
691
725
|
if (client === "claude-code") {
|
|
692
726
|
launchNativeMcpClient(CLAUDE_CODE);
|
|
@@ -697,15 +731,23 @@ export function launchAgent(client) {
|
|
|
697
731
|
else if (client === "hermes") {
|
|
698
732
|
launchNativeMcpClient(HERMES);
|
|
699
733
|
}
|
|
700
|
-
else {
|
|
734
|
+
else if (client === "openclaw") {
|
|
701
735
|
launchOpenClawWalkthrough();
|
|
702
736
|
}
|
|
737
|
+
else if (client === "cursor") {
|
|
738
|
+
// Cursor has no chat CLI to launch.
|
|
739
|
+
throw new Error("Cursor has no launchable walkthrough. Open Cursor and start chatting.");
|
|
740
|
+
}
|
|
741
|
+
else {
|
|
742
|
+
const unhandled = client;
|
|
743
|
+
throw new Error(`No launch support for client: ${String(unhandled)}`);
|
|
744
|
+
}
|
|
703
745
|
}
|
|
704
746
|
export function registerConnect(program) {
|
|
705
747
|
program
|
|
706
748
|
.command("connect")
|
|
707
749
|
.description("Connect TinyFish to an AI agent")
|
|
708
|
-
.argument("<client>", "Agent client to connect (claude-code, codex, hermes, or openclaw)")
|
|
750
|
+
.argument("<client>", "Agent client to connect (claude-code, codex, cursor, hermes, or openclaw)")
|
|
709
751
|
.option("--api-key <apiKey>", "TinyFish API key to persist for the installed skill")
|
|
710
752
|
.option("--launch", "Launch the agent and start the TinyFish walkthrough")
|
|
711
753
|
.option("--url <mcpUrl>", "MCP endpoint override")
|
|
@@ -740,8 +782,18 @@ export function registerConnect(program) {
|
|
|
740
782
|
else if (client === "openclaw") {
|
|
741
783
|
await connectOpenClaw({ mcpUrl: connectOptions.mcpUrl, launch: connectOptions.launch });
|
|
742
784
|
}
|
|
785
|
+
else if (client === "cursor") {
|
|
786
|
+
// `connect <client> --launch` is the shape every doc uses; connect anyway, then say
|
|
787
|
+
// why nothing launched rather than throwing away a working install.
|
|
788
|
+
await connectCursor({ apiKey: connectOptions.apiKey, mcpUrl: connectOptions.mcpUrl });
|
|
789
|
+
if (connectOptions.launch) {
|
|
790
|
+
errLine("Cursor has no launchable walkthrough. Open Cursor and start chatting.");
|
|
791
|
+
}
|
|
792
|
+
}
|
|
743
793
|
else {
|
|
744
|
-
throw new Error(
|
|
794
|
+
throw new Error("Unsupported client: " +
|
|
795
|
+
client +
|
|
796
|
+
". Supported clients: claude-code, codex, cursor, hermes, openclaw");
|
|
745
797
|
}
|
|
746
798
|
});
|
|
747
799
|
}
|
|
@@ -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
|
+
}
|