@tiny-fish/cli 0.37.1-next.298 → 0.37.1-next.299

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.
@@ -8,7 +8,7 @@ import { installWebSkill } from '../lib/skill-install.js';
8
8
  import { ConnectInterruptedError, ConnectStepError, createConnectTelemetry, finishSetupCommand, requireCommandSupport, runGuarded, settle, SignInTimeoutError, spawnStepError, stageDurationOf, withStageDuration, spawnRemoval, } from '../lib/connect-runtime.js';
9
9
  import { cursorInstallDeeplink, cursorMcpPath, readCursorTinyfishEntry, writeCursorMcpConfig, } from '../lib/cursor-config.js';
10
10
  import { runConnectAll } from '../lib/connect-all.js';
11
- import { ALL_HARNESSES, AuthMode, RELOAD_ACTION } from '../lib/harness-detect.js';
11
+ import { ALL_HARNESSES, AuthMode, HARNESS_DISPLAY_NAMES, RELOAD_ACTION, } from '../lib/harness-detect.js';
12
12
  import { detectHumanInitiated } from '../lib/harness.js';
13
13
  import { TINYFISH_API_KEY_VAR } from '../lib/constants.js';
14
14
  import { emitNotice } from '../lib/notice.js';
@@ -506,10 +506,28 @@ export async function connectOpenClaw(options) {
506
506
  }
507
507
  });
508
508
  }
509
- /** Writes mcp.json directly — no `cursor mcp add` exists. */
510
- export async function connectCursor(options) {
509
+ const CONFIG_FILE_HARNESSES = {
510
+ cursor: {
511
+ skillTarget: CURSOR_SKILL_TARGET,
512
+ registeredUrl: () => readCursorTinyfishEntry().url,
513
+ write: writeCursorMcpConfig,
514
+ configPath: cursorMcpPath,
515
+ tryLaunch: launchCursorDeeplink,
516
+ copy: {
517
+ verified: "TinyFish is connected and verified (health+auth). Reload the Cursor window (or restart cursor-agent) and it's live.",
518
+ authFailed: (reason) => `TinyFish is configured, but the authenticated check failed (${reason}). ` +
519
+ 'Fix: rotate/re-enter your key with `tinyfish auth login`, then re-run: tinyfish connect cursor',
520
+ launched: 'Cursor should pop a TinyFish install confirmation — approve it, then sign in when prompted.',
521
+ reload: 'TinyFish is connected. Reload the Cursor window, then approve/sign in under Settings → MCP.',
522
+ },
523
+ },
524
+ };
525
+ /** Writes the harness's MCP config file itself; no `mcp add` exists. */
526
+ async function connectConfigFileHarness(harness, options) {
527
+ const spec = CONFIG_FILE_HARNESSES[harness];
528
+ const displayName = HARNESS_DISPLAY_NAMES[harness];
511
529
  let fallbackAttemptId;
512
- const registeredUrl = readCursorTinyfishEntry().url;
530
+ const registeredUrl = spec.registeredUrl();
513
531
  if (registeredUrl) {
514
532
  try {
515
533
  const registered = new URL(registeredUrl);
@@ -525,7 +543,7 @@ export async function connectCursor(options) {
525
543
  requested.searchParams.sort();
526
544
  if (registered.toString() === requested.toString() &&
527
545
  registeredSource === CONNECT_SOURCE &&
528
- registeredClient === 'cursor' &&
546
+ registeredClient === harness &&
529
547
  z.uuid().safeParse(registeredId).success) {
530
548
  fallbackAttemptId = registeredId ?? undefined;
531
549
  }
@@ -534,12 +552,12 @@ export async function connectCursor(options) {
534
552
  // A hand-edited invalid URL is repaired below with a fresh attempt id.
535
553
  }
536
554
  }
537
- const telemetry = createConnectTelemetry(options.mcpUrl, 'cursor', {
555
+ const telemetry = createConnectTelemetry(options.mcpUrl, harness, {
538
556
  ...options,
539
557
  fallbackAttemptId,
540
558
  });
541
559
  // Never degraded: no sign-in command to be missing. False, not absent, so a `= false`
542
- // filter still catches Cursor.
560
+ // filter still catches these harnesses.
543
561
  const state = {
544
562
  stage: 'prerequisite_check',
545
563
  settled: false,
@@ -549,11 +567,11 @@ export async function connectCursor(options) {
549
567
  telemetry.track('started');
550
568
  telemetry.track('checkpoint', { phase: 'prerequisite_ok' });
551
569
  state.stage = 'skill_install';
552
- installWebSkill(CURSOR_SKILL_TARGET, { verbose: options.verbose ?? false });
570
+ installWebSkill(spec.skillTarget, { verbose: options.verbose ?? false });
553
571
  telemetry.track('checkpoint', { phase: 'skill_installed' });
554
572
  // Auth first: a key lets the config carry an X-API-Key header, removing the sign-in hand-back.
555
573
  state.stage = 'authentication';
556
- ensureCliAuthenticated('cursor', options.apiKey);
574
+ ensureCliAuthenticated(harness, options.apiKey);
557
575
  telemetry.track('checkpoint', { phase: 'authenticated' });
558
576
  const resolvedKey = validatedApiKey(options.apiKey);
559
577
  // ensureCliAuthenticated leaves a key stored, and `auth status` exits 0 on a malformed one.
@@ -561,45 +579,48 @@ export async function connectCursor(options) {
561
579
  errLine('Ignoring the stored API key: invalid format. Run: tinyfish auth login');
562
580
  }
563
581
  state.stage = 'registration';
564
- errLine('Adding TinyFish to Cursor...');
582
+ errLine(`Adding TinyFish to ${displayName}...`);
565
583
  const mcpUrl = new URL(options.mcpUrl);
566
584
  mcpUrl.searchParams.set('source', CONNECT_SOURCE);
567
- mcpUrl.searchParams.set('client', 'cursor');
585
+ mcpUrl.searchParams.set('client', harness);
568
586
  mcpUrl.searchParams.set('connect_attempt_id', telemetry.attemptId);
569
- const result = writeCursorMcpConfig(mcpUrl.toString(), resolvedKey);
587
+ const result = spec.write(mcpUrl.toString(), resolvedKey);
570
588
  if (result.status === 'corrupt_skip') {
571
589
  // The message fallback would leak the config path; tag instead.
572
- throw new ConnectStepError(`Could not update ${cursorMcpPath()}: existing file could not be read or is not valid JSON (${result.error}). ` +
573
- `Fix the file, then re-run: tinyfish connect cursor`, 'invalid_config', { failureDetail: 'cursor_mcp_json_corrupt' });
590
+ throw new ConnectStepError(`Could not update ${spec.configPath()}: existing file could not be read or is not valid JSON (${result.error}). ` +
591
+ `Fix the file, then re-run: tinyfish connect ${harness}`, 'invalid_config', { failureDetail: `${harness}_mcp_json_corrupt` });
574
592
  }
575
593
  if (result.backupPath) {
576
- errLine(`Backed up existing Cursor MCP config to ${result.backupPath}`);
594
+ errLine(`Backed up existing ${displayName} MCP config to ${result.backupPath}`);
577
595
  }
578
596
  if (result.repaired) {
579
597
  errLine('Repaired the existing TinyFish entry (updated auth/config to current).');
580
598
  }
581
599
  telemetry.track('checkpoint', { phase: 'registered' });
582
- saveConnectContext('cursor', telemetry.attemptId);
600
+ saveConnectContext(harness, telemetry.attemptId);
583
601
  if (resolvedKey) {
584
602
  // Deeplink would embed the key in a URL (process args, LaunchServices logs) — reload instead.
585
603
  const verify = await verifyMcpAuth(resolvedKey);
586
604
  if (verify.ok) {
587
- errLine("TinyFish is connected and verified (health+auth). Reload the Cursor window (or restart cursor-agent) and it's live.");
605
+ errLine(spec.copy.verified);
588
606
  }
589
607
  else {
590
- errLine(`TinyFish is configured, but the authenticated check failed (${verify.reason ?? 'unknown'}). ` +
591
- 'Fix: rotate/re-enter your key with `tinyfish auth login`, then re-run: tinyfish connect cursor');
608
+ errLine(spec.copy.authFailed(verify.reason ?? 'unknown'));
592
609
  }
593
610
  }
594
- else if (detectHumanInitiated() && launchCursorDeeplink(mcpUrl.toString())) {
595
- errLine('Cursor should pop a TinyFish install confirmation — approve it, then sign in when prompted.');
611
+ else if (detectHumanInitiated() && spec.tryLaunch(mcpUrl.toString())) {
612
+ errLine(spec.copy.launched);
596
613
  }
597
614
  else {
598
- errLine('TinyFish is connected. Reload the Cursor window, then approve/sign in under Settings → MCP.');
615
+ errLine(spec.copy.reload);
599
616
  }
600
617
  settle(state, telemetry, 'completed', { authMode: resolvedKey ? AuthMode.ApiKey : 'deferred' });
601
618
  });
602
619
  }
620
+ /** Writes mcp.json directly — no `cursor mcp add` exists. */
621
+ export async function connectCursor(options) {
622
+ await connectConfigFileHarness('cursor', options);
623
+ }
603
624
  /** Best-effort: false when no handler/open fails — caller falls back to reload copy. */
604
625
  function launchCursorDeeplink(mcpUrl) {
605
626
  const result = openExternalUrl(cursorInstallDeeplink(mcpUrl));
@@ -1,27 +1,14 @@
1
- export declare function cursorConfigDir(): string;
1
+ import { type McpJsonServerEntry, type McpJsonWriteResult } from './mcp-json-config.js';
2
+ export type CursorMcpWriteResult = McpJsonWriteResult;
3
+ export type CursorTinyfishEntry = McpJsonServerEntry;
2
4
  export declare function cursorMcpPath(): string;
3
- export interface CursorMcpWriteResult {
4
- status: 'written' | 'unchanged' | 'corrupt_skip';
5
- backupPath?: string;
6
- error?: string;
7
- /** A pre-existing tinyfish entry was replaced — a repair, not a fresh install. */
8
- repaired?: boolean;
9
- }
10
5
  /** Dry-run description of the pending write; touches nothing. */
11
6
  export declare function planCursorWrite(mcpUrl: string, apiKey?: string): string;
12
- export interface CursorTinyfishEntry {
13
- present: boolean;
14
- hasApiKeyHeader: boolean;
15
- /** Registered endpoint, so a caller can tell "registered" from "registered at the right place". */
16
- url?: string;
17
- keyMatchesCliKey?: boolean;
18
- error?: string;
19
- }
20
7
  /** Reports the header's shape, never its value. */
21
8
  export declare function readCursorTinyfishEntry(): CursorTinyfishEntry;
22
9
  /** Merges only the `tinyfish` key; skips unreadable/corrupt files rather than clobber. */
23
10
  export declare function writeCursorMcpConfig(mcpUrl: string, apiKey?: string): CursorMcpWriteResult;
24
- /** Cursor deeplink format: cursor://anysphere.cursor-deeplink/mcp/install?name=$NAME&config=$BASE64 */
25
- export declare function cursorInstallDeeplink(mcpUrl: string): string;
26
11
  /** Removes only the `tinyfish` key. */
27
12
  export declare function removeCursorMcpServer(): CursorMcpWriteResult;
13
+ /** Cursor deeplink format: cursor://anysphere.cursor-deeplink/mcp/install?name=$NAME&config=$BASE64 */
14
+ export declare function cursorInstallDeeplink(mcpUrl: string): string;
@@ -1,148 +1,32 @@
1
- import * as fs from 'fs';
2
1
  import * as os from 'os';
3
2
  import * as path from 'path';
4
- import { matchesCliKey } from './auth.js';
5
- // The one config file the CLI writes directly — hence backup/merge rigor.
6
- const TINYFISH_SERVER_KEY = 'tinyfish';
7
- export function cursorConfigDir() {
8
- return path.join(os.homedir(), '.cursor');
9
- }
3
+ import { buildTinyfishServerEntry, planWrite, readTinyfishEntry, removeServer, writeMcpConfig, } from './mcp-json-config.js';
4
+ const CURSOR_TARGET = {
5
+ serverKey: 'tinyfish',
6
+ dir: () => path.join(os.homedir(), '.cursor'),
7
+ file: () => path.join(os.homedir(), '.cursor', 'mcp.json'),
8
+ };
10
9
  export function cursorMcpPath() {
11
- return path.join(cursorConfigDir(), 'mcp.json');
12
- }
13
- function isPlainRecord(value) {
14
- return value !== null && typeof value === 'object' && !Array.isArray(value);
15
- }
16
- // Footgun: the key lands plaintext in mcp.json (0600) — per-device keys are the GA hardening.
17
- function buildTinyfishServerEntry(mcpUrl, apiKey) {
18
- return apiKey ? { url: mcpUrl, headers: { 'X-API-Key': apiKey } } : { url: mcpUrl };
19
- }
20
- function parseMcpJson(raw) {
21
- try {
22
- const parsed = JSON.parse(raw);
23
- if (!isPlainRecord(parsed))
24
- return { ok: false, error: 'mcp.json root is not a JSON object' };
25
- if ('mcpServers' in parsed && !isPlainRecord(parsed.mcpServers)) {
26
- return { ok: false, error: 'mcp.json mcpServers is not a JSON object' };
27
- }
28
- return { ok: true, value: parsed };
29
- }
30
- catch (e) {
31
- return { ok: false, error: e instanceof Error ? e.message : String(e) };
32
- }
33
- }
34
- // Non-ENOENT read failures count as corruption: skip, never clobber.
35
- function readExisting() {
36
- let raw;
37
- try {
38
- raw = fs.readFileSync(cursorMcpPath(), 'utf8');
39
- }
40
- catch (e) {
41
- if (e?.code === 'ENOENT')
42
- return { parsed: {} };
43
- return { error: e instanceof Error ? e.message : String(e) };
44
- }
45
- const parsed = parseMcpJson(raw);
46
- if (!parsed.ok)
47
- return { error: parsed.error };
48
- return { raw, parsed: parsed.value };
10
+ return CURSOR_TARGET.file();
49
11
  }
50
12
  /** Dry-run description of the pending write; touches nothing. */
51
13
  export function planCursorWrite(mcpUrl, apiKey) {
52
- const existing = readExisting();
53
- const filePath = cursorMcpPath();
54
- if ('error' in existing) {
55
- return `${filePath}: existing file is corrupt (${existing.error}) — would skip and leave it untouched`;
56
- }
57
- const authNote = apiKey
58
- ? ' with an API-key header — the key is stored in plaintext in that file (value not shown here)'
59
- : '';
60
- const servers = existing.parsed.mcpServers;
61
- const current = isPlainRecord(servers) ? servers[TINYFISH_SERVER_KEY] : undefined;
62
- if (JSON.stringify(current) === JSON.stringify(buildTinyfishServerEntry(mcpUrl, apiKey))) {
63
- return `${filePath}: already has the tinyfish MCP entry — no change`;
64
- }
65
- return existing.raw === undefined
66
- ? `${filePath}: would create with a "tinyfish" MCP server entry${authNote}`
67
- : `${filePath}: would back up to a timestamped copy, then merge in the "tinyfish" MCP server entry${authNote}`;
14
+ return planWrite(CURSOR_TARGET, mcpUrl, apiKey);
68
15
  }
69
16
  /** Reports the header's shape, never its value. */
70
17
  export function readCursorTinyfishEntry() {
71
- const existing = readExisting();
72
- if ('error' in existing)
73
- return { present: false, hasApiKeyHeader: false, error: existing.error };
74
- const servers = existing.parsed.mcpServers;
75
- const entry = isPlainRecord(servers) ? servers[TINYFISH_SERVER_KEY] : undefined;
76
- if (!isPlainRecord(entry))
77
- return { present: false, hasApiKeyHeader: false };
78
- const headers = entry.headers;
79
- // Header names are case-insensitive, and this file is hand-editable — matching only the
80
- // casing we write would understate auth mode for a user who typed it differently.
81
- const keyHeader = isPlainRecord(headers)
82
- ? Object.entries(headers).find((entry) => entry[0].toLowerCase() === 'x-api-key' && typeof entry[1] === 'string')
83
- : undefined;
84
- return {
85
- present: true,
86
- hasApiKeyHeader: keyHeader !== undefined,
87
- ...(matchesCliKey(keyHeader?.[1]) ? { keyMatchesCliKey: true } : {}),
88
- ...(typeof entry.url === 'string' ? { url: entry.url } : {}),
89
- };
90
- }
91
- // Backup, then atomic temp+rename; pid avoids same-millisecond backup collisions.
92
- function commitServers(filePath, existing, servers) {
93
- fs.mkdirSync(cursorConfigDir(), { recursive: true, mode: 0o700 });
94
- let backupPath;
95
- if (existing.raw !== undefined) {
96
- backupPath = `${filePath}.bak-${Date.now()}-${process.pid}`;
97
- fs.writeFileSync(backupPath, existing.raw, { mode: 0o600 });
98
- }
99
- const next = { ...existing.parsed, mcpServers: servers };
100
- const tmpPath = `${filePath}.tmp-${process.pid}`;
101
- fs.writeFileSync(tmpPath, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
102
- try {
103
- fs.renameSync(tmpPath, filePath);
104
- }
105
- catch (e) {
106
- fs.rmSync(tmpPath, { force: true });
107
- throw e;
108
- }
109
- return { status: 'written', backupPath };
18
+ return readTinyfishEntry(CURSOR_TARGET);
110
19
  }
111
20
  /** Merges only the `tinyfish` key; skips unreadable/corrupt files rather than clobber. */
112
21
  export function writeCursorMcpConfig(mcpUrl, apiKey) {
113
- const filePath = cursorMcpPath();
114
- const existing = readExisting();
115
- if ('error' in existing)
116
- return { status: 'corrupt_skip', error: existing.error };
117
- const servers = isPlainRecord(existing.parsed.mcpServers)
118
- ? { ...existing.parsed.mcpServers }
119
- : {};
120
- const nextEntry = buildTinyfishServerEntry(mcpUrl, apiKey);
121
- if (JSON.stringify(servers[TINYFISH_SERVER_KEY]) === JSON.stringify(nextEntry)) {
122
- return { status: 'unchanged' };
123
- }
124
- const repaired = TINYFISH_SERVER_KEY in servers;
125
- servers[TINYFISH_SERVER_KEY] = nextEntry;
126
- return { ...commitServers(filePath, existing, servers), repaired };
22
+ return writeMcpConfig(CURSOR_TARGET, mcpUrl, apiKey);
23
+ }
24
+ /** Removes only the `tinyfish` key. */
25
+ export function removeCursorMcpServer() {
26
+ return removeServer(CURSOR_TARGET);
127
27
  }
128
28
  /** Cursor deeplink format: cursor://anysphere.cursor-deeplink/mcp/install?name=$NAME&config=$BASE64 */
129
29
  export function cursorInstallDeeplink(mcpUrl) {
130
30
  const config = Buffer.from(JSON.stringify(buildTinyfishServerEntry(mcpUrl))).toString('base64');
131
- return `cursor://anysphere.cursor-deeplink/mcp/install?name=${TINYFISH_SERVER_KEY}&config=${encodeURIComponent(config)}`;
132
- }
133
- /** Removes only the `tinyfish` key. */
134
- export function removeCursorMcpServer() {
135
- const filePath = cursorMcpPath();
136
- const existing = readExisting();
137
- if ('error' in existing)
138
- return { status: 'corrupt_skip', error: existing.error };
139
- if (existing.raw === undefined)
140
- return { status: 'unchanged' };
141
- const servers = isPlainRecord(existing.parsed.mcpServers)
142
- ? { ...existing.parsed.mcpServers }
143
- : {};
144
- if (!(TINYFISH_SERVER_KEY in servers))
145
- return { status: 'unchanged' };
146
- delete servers[TINYFISH_SERVER_KEY];
147
- return commitServers(filePath, existing, servers);
31
+ return `cursor://anysphere.cursor-deeplink/mcp/install?name=${CURSOR_TARGET.serverKey}&config=${encodeURIComponent(config)}`;
148
32
  }
@@ -0,0 +1,30 @@
1
+ export interface McpJsonTarget {
2
+ serverKey: string;
3
+ /** Functions so paths resolve at call time. */
4
+ dir(): string;
5
+ file(): string;
6
+ }
7
+ export interface McpJsonWriteResult {
8
+ status: 'written' | 'unchanged' | 'corrupt_skip';
9
+ backupPath?: string;
10
+ error?: string;
11
+ /** A pre-existing entry was replaced — a repair, not a fresh install. */
12
+ repaired?: boolean;
13
+ }
14
+ export declare function buildTinyfishServerEntry(mcpUrl: string, apiKey?: string): Record<string, unknown>;
15
+ /** Dry-run description of the pending write; touches nothing. */
16
+ export declare function planWrite(target: McpJsonTarget, mcpUrl: string, apiKey?: string): string;
17
+ export interface McpJsonServerEntry {
18
+ present: boolean;
19
+ hasApiKeyHeader: boolean;
20
+ /** Registered endpoint, so a caller can tell "registered" from "registered at the right place". */
21
+ url?: string;
22
+ keyMatchesCliKey?: boolean;
23
+ error?: string;
24
+ }
25
+ /** Reports the header's shape, never its value. */
26
+ export declare function readTinyfishEntry(target: McpJsonTarget): McpJsonServerEntry;
27
+ /** Merges only the target's key; skips unreadable/corrupt files rather than clobber. */
28
+ export declare function writeMcpConfig(target: McpJsonTarget, mcpUrl: string, apiKey?: string): McpJsonWriteResult;
29
+ /** Removes only the target's key. */
30
+ export declare function removeServer(target: McpJsonTarget): McpJsonWriteResult;
@@ -0,0 +1,132 @@
1
+ import * as fs from 'fs';
2
+ import { matchesCliKey } from './auth.js';
3
+ function isPlainRecord(value) {
4
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
5
+ }
6
+ // Footgun: the key lands plaintext in mcp.json (0600) — per-device keys are the GA hardening.
7
+ export function buildTinyfishServerEntry(mcpUrl, apiKey) {
8
+ return apiKey ? { url: mcpUrl, headers: { 'X-API-Key': apiKey } } : { url: mcpUrl };
9
+ }
10
+ function parseMcpJson(raw) {
11
+ try {
12
+ const parsed = JSON.parse(raw);
13
+ if (!isPlainRecord(parsed))
14
+ return { ok: false, error: 'mcp.json root is not a JSON object' };
15
+ if ('mcpServers' in parsed && !isPlainRecord(parsed.mcpServers)) {
16
+ return { ok: false, error: 'mcp.json mcpServers is not a JSON object' };
17
+ }
18
+ return { ok: true, value: parsed };
19
+ }
20
+ catch (e) {
21
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
22
+ }
23
+ }
24
+ // Non-ENOENT read failures count as corruption: skip, never clobber.
25
+ function readExisting(target) {
26
+ let raw;
27
+ try {
28
+ raw = fs.readFileSync(target.file(), 'utf8');
29
+ }
30
+ catch (e) {
31
+ if (e?.code === 'ENOENT')
32
+ return { parsed: {} };
33
+ return { error: e instanceof Error ? e.message : String(e) };
34
+ }
35
+ const parsed = parseMcpJson(raw);
36
+ if (!parsed.ok)
37
+ return { error: parsed.error };
38
+ return { raw, parsed: parsed.value };
39
+ }
40
+ /** Dry-run description of the pending write; touches nothing. */
41
+ export function planWrite(target, mcpUrl, apiKey) {
42
+ const existing = readExisting(target);
43
+ const filePath = target.file();
44
+ if ('error' in existing) {
45
+ return `${filePath}: existing file is corrupt (${existing.error}) — would skip and leave it untouched`;
46
+ }
47
+ const authNote = apiKey
48
+ ? ' with an API-key header — the key is stored in plaintext in that file (value not shown here)'
49
+ : '';
50
+ const servers = existing.parsed.mcpServers;
51
+ const current = isPlainRecord(servers) ? servers[target.serverKey] : undefined;
52
+ if (JSON.stringify(current) === JSON.stringify(buildTinyfishServerEntry(mcpUrl, apiKey))) {
53
+ return `${filePath}: already has the ${target.serverKey} MCP entry — no change`;
54
+ }
55
+ return existing.raw === undefined
56
+ ? `${filePath}: would create with a "${target.serverKey}" MCP server entry${authNote}`
57
+ : `${filePath}: would back up to a timestamped copy, then merge in the "${target.serverKey}" MCP server entry${authNote}`;
58
+ }
59
+ /** Reports the header's shape, never its value. */
60
+ export function readTinyfishEntry(target) {
61
+ const existing = readExisting(target);
62
+ if ('error' in existing)
63
+ return { present: false, hasApiKeyHeader: false, error: existing.error };
64
+ const servers = existing.parsed.mcpServers;
65
+ const entry = isPlainRecord(servers) ? servers[target.serverKey] : undefined;
66
+ if (!isPlainRecord(entry))
67
+ return { present: false, hasApiKeyHeader: false };
68
+ const headers = entry.headers;
69
+ // Header names are case-insensitive, and this file is hand-editable — matching only the
70
+ // casing we write would understate auth mode for a user who typed it differently.
71
+ const keyHeader = isPlainRecord(headers)
72
+ ? Object.entries(headers).find((entry) => entry[0].toLowerCase() === 'x-api-key' && typeof entry[1] === 'string')
73
+ : undefined;
74
+ return {
75
+ present: true,
76
+ hasApiKeyHeader: keyHeader !== undefined,
77
+ ...(matchesCliKey(keyHeader?.[1]) ? { keyMatchesCliKey: true } : {}),
78
+ ...(typeof entry.url === 'string' ? { url: entry.url } : {}),
79
+ };
80
+ }
81
+ // Backup, then atomic temp+rename; pid avoids same-millisecond backup collisions.
82
+ function commitServers(target, existing, servers) {
83
+ const filePath = target.file();
84
+ fs.mkdirSync(target.dir(), { recursive: true, mode: 0o700 });
85
+ let backupPath;
86
+ if (existing.raw !== undefined) {
87
+ backupPath = `${filePath}.bak-${Date.now()}-${process.pid}`;
88
+ fs.writeFileSync(backupPath, existing.raw, { mode: 0o600 });
89
+ }
90
+ const next = { ...existing.parsed, mcpServers: servers };
91
+ const tmpPath = `${filePath}.tmp-${process.pid}`;
92
+ fs.writeFileSync(tmpPath, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
93
+ try {
94
+ fs.renameSync(tmpPath, filePath);
95
+ }
96
+ catch (e) {
97
+ fs.rmSync(tmpPath, { force: true });
98
+ throw e;
99
+ }
100
+ return { status: 'written', backupPath };
101
+ }
102
+ /** Merges only the target's key; skips unreadable/corrupt files rather than clobber. */
103
+ export function writeMcpConfig(target, mcpUrl, apiKey) {
104
+ const existing = readExisting(target);
105
+ if ('error' in existing)
106
+ return { status: 'corrupt_skip', error: existing.error };
107
+ const servers = isPlainRecord(existing.parsed.mcpServers)
108
+ ? { ...existing.parsed.mcpServers }
109
+ : {};
110
+ const nextEntry = buildTinyfishServerEntry(mcpUrl, apiKey);
111
+ if (JSON.stringify(servers[target.serverKey]) === JSON.stringify(nextEntry)) {
112
+ return { status: 'unchanged' };
113
+ }
114
+ const repaired = target.serverKey in servers;
115
+ servers[target.serverKey] = nextEntry;
116
+ return { ...commitServers(target, existing, servers), repaired };
117
+ }
118
+ /** Removes only the target's key. */
119
+ export function removeServer(target) {
120
+ const existing = readExisting(target);
121
+ if ('error' in existing)
122
+ return { status: 'corrupt_skip', error: existing.error };
123
+ if (existing.raw === undefined)
124
+ return { status: 'unchanged' };
125
+ const servers = isPlainRecord(existing.parsed.mcpServers)
126
+ ? { ...existing.parsed.mcpServers }
127
+ : {};
128
+ if (!(target.serverKey in servers))
129
+ return { status: 'unchanged' };
130
+ delete servers[target.serverKey];
131
+ return commitServers(target, existing, servers);
132
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.37.1-next.298",
3
+ "version": "0.37.1-next.299",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {