@tiny-fish/cli 0.52.4-next.432 → 0.52.4
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/lib/config/mcp-json-config.d.ts +6 -0
- package/dist/lib/config/mcp-json-config.js +60 -6
- package/dist/lib/config/opencode-config.d.ts +10 -3
- package/dist/lib/config/opencode-config.js +56 -15
- package/dist/lib/connect-clients.d.ts +3 -3
- package/dist/lib/connect-clients.js +5 -4
- package/dist/lib/connect-native.d.ts +1 -1
- package/dist/lib/connect-native.js +74 -5
- package/dist/lib/connect-runtime.js +4 -2
- package/dist/lib/connect-steps.d.ts +2 -0
- package/dist/lib/harness/harness-detect.d.ts +1 -0
- package/dist/lib/harness/harness-detect.js +7 -3
- package/dist/lib/harness/harness-messages.d.ts +1 -0
- package/dist/lib/harness/harness-messages.js +3 -0
- package/dist/lib/harness/harness-spec.d.ts +3 -0
- package/dist/lib/harness/harness-spec.js +1 -0
- package/dist/lib/registration-detect.d.ts +2 -0
- package/dist/lib/registration-detect.js +23 -5
- package/package.json +1 -1
|
@@ -12,6 +12,10 @@ export interface McpJsonTarget {
|
|
|
12
12
|
transportKey?: string;
|
|
13
13
|
/** Read-side only; OpenCode and its Kilo fork nest servers under `mcp`. */
|
|
14
14
|
serversKey?: string;
|
|
15
|
+
/** Read-side only; OpenCode 2.x nests once more, under `mcp.servers`. */
|
|
16
|
+
nestedServersKey?: string;
|
|
17
|
+
/** Read-side only; the harness writes JSONC, so comments and trailing commas parse. */
|
|
18
|
+
jsonc?: true;
|
|
15
19
|
}
|
|
16
20
|
export interface McpJsonWriteResult {
|
|
17
21
|
status: 'written' | 'unchanged' | 'corrupt_skip';
|
|
@@ -22,6 +26,8 @@ export interface McpJsonWriteResult {
|
|
|
22
26
|
}
|
|
23
27
|
export declare function buildTinyfishServerEntry(mcpUrl: string, apiKey?: string): Record<string, unknown>;
|
|
24
28
|
export declare function buildTinyfishKeylessServerEntry(mcpUrl: string): Record<string, unknown>;
|
|
29
|
+
/** Comments and trailing commas only; strings pass through untouched. */
|
|
30
|
+
export declare function stripJsonComments(text: string): string;
|
|
25
31
|
/** Dry-run description of the pending write; touches nothing. */
|
|
26
32
|
export declare function planWrite(target: McpJsonTarget, mcpUrl: string, apiKey?: string, serverEntry?: Record<string, unknown>): string;
|
|
27
33
|
export interface McpJsonServerEntry {
|
|
@@ -11,9 +11,51 @@ export function buildTinyfishServerEntry(mcpUrl, apiKey) {
|
|
|
11
11
|
export function buildTinyfishKeylessServerEntry(mcpUrl) {
|
|
12
12
|
return { url: mcpUrl, headers: { [TINYFISH_ACCESS_MODE_HEADER]: TINYFISH_KEYLESS_ACCESS_MODE } };
|
|
13
13
|
}
|
|
14
|
-
|
|
14
|
+
const TRAILING_COMMA = /\s*[}\]]/y;
|
|
15
|
+
/** Comments and trailing commas only; strings pass through untouched. */
|
|
16
|
+
export function stripJsonComments(text) {
|
|
17
|
+
// Twice: a comment can sit between a trailing comma and its bracket.
|
|
18
|
+
return strip(strip(text));
|
|
19
|
+
}
|
|
20
|
+
function strip(text) {
|
|
21
|
+
let out = '';
|
|
22
|
+
for (let i = 0; i < text.length; i++) {
|
|
23
|
+
const ch = text[i];
|
|
24
|
+
if (ch === '"') {
|
|
25
|
+
const end = closingQuote(text, i);
|
|
26
|
+
out += text.slice(i, end + 1);
|
|
27
|
+
i = end;
|
|
28
|
+
}
|
|
29
|
+
else if (ch === '/' && text[i + 1] === '/') {
|
|
30
|
+
const end = text.indexOf('\n', i);
|
|
31
|
+
if (end === -1)
|
|
32
|
+
break;
|
|
33
|
+
i = end - 1;
|
|
34
|
+
}
|
|
35
|
+
else if (ch === '/' && text[i + 1] === '*') {
|
|
36
|
+
const end = text.indexOf('*/', i + 2);
|
|
37
|
+
i = end === -1 ? text.length : end + 1;
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
TRAILING_COMMA.lastIndex = i + 1;
|
|
41
|
+
if (ch !== ',' || !TRAILING_COMMA.test(text))
|
|
42
|
+
out += ch;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
function closingQuote(text, start) {
|
|
48
|
+
for (let i = start + 1; i < text.length; i++) {
|
|
49
|
+
if (text[i] === '\\')
|
|
50
|
+
i++;
|
|
51
|
+
else if (text[i] === '"')
|
|
52
|
+
return i;
|
|
53
|
+
}
|
|
54
|
+
return text.length - 1;
|
|
55
|
+
}
|
|
56
|
+
function parseMcpJson(raw, jsonc) {
|
|
15
57
|
try {
|
|
16
|
-
const parsed = JSON.parse(raw);
|
|
58
|
+
const parsed = JSON.parse(jsonc ? stripJsonComments(raw) : raw);
|
|
17
59
|
if (!isPlainRecord(parsed))
|
|
18
60
|
return { ok: false, error: 'mcp.json root is not a JSON object' };
|
|
19
61
|
if ('mcpServers' in parsed && !isPlainRecord(parsed.mcpServers)) {
|
|
@@ -36,7 +78,7 @@ function readExisting(target) {
|
|
|
36
78
|
return { parsed: {} };
|
|
37
79
|
return { error: e instanceof Error ? e.message : String(e) };
|
|
38
80
|
}
|
|
39
|
-
const parsed = parseMcpJson(raw);
|
|
81
|
+
const parsed = parseMcpJson(raw, target.jsonc);
|
|
40
82
|
if (!parsed.ok)
|
|
41
83
|
return { error: parsed.error };
|
|
42
84
|
return { raw, parsed: parsed.value };
|
|
@@ -76,9 +118,8 @@ export function readTinyfishEntry(target) {
|
|
|
76
118
|
if ('error' in existing) {
|
|
77
119
|
return { present: false, hasApiKeyHeader: false, error: existing.error };
|
|
78
120
|
}
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
if (!isPlainRecord(entry))
|
|
121
|
+
const entry = findEntry(existing.parsed, target);
|
|
122
|
+
if (!entry)
|
|
82
123
|
return { present: false, hasApiKeyHeader: false };
|
|
83
124
|
const fields = entryFields(entry, target.transportKey);
|
|
84
125
|
const key = readKeyHeader(fields.headers, target.keyHeader);
|
|
@@ -95,6 +136,19 @@ export function readTinyfishEntry(target) {
|
|
|
95
136
|
...(typeof fields.url === 'string' ? { url: fields.url } : {}),
|
|
96
137
|
};
|
|
97
138
|
}
|
|
139
|
+
/** 2.x `mcp.servers.<name>` wins over the flat 1.x `mcp.<name>`. */
|
|
140
|
+
function nestedServers(root, target) {
|
|
141
|
+
if (!target.nestedServersKey || !isPlainRecord(root))
|
|
142
|
+
return undefined;
|
|
143
|
+
const nested = root[target.nestedServersKey];
|
|
144
|
+
return isPlainRecord(nested) && target.serverKey in nested ? nested : undefined;
|
|
145
|
+
}
|
|
146
|
+
function findEntry(parsed, target) {
|
|
147
|
+
const root = parsed[target.serversKey ?? 'mcpServers'];
|
|
148
|
+
const servers = nestedServers(root, target) ?? root;
|
|
149
|
+
const entry = isPlainRecord(servers) ? servers[target.serverKey] : undefined;
|
|
150
|
+
return isPlainRecord(entry) ? entry : undefined;
|
|
151
|
+
}
|
|
98
152
|
/** Reports the key header's shape, never its value. */
|
|
99
153
|
function readKeyHeader(headers, keyHeader) {
|
|
100
154
|
const { name = 'x-api-key', valuePrefix = '' } = keyHeader ?? {};
|
|
@@ -3,7 +3,14 @@ import { type McpJsonServerEntry } from './mcp-json-config.js';
|
|
|
3
3
|
export type OpencodeStyleHarness = 'kilo' | 'opencode';
|
|
4
4
|
/** Narrows before any path is built, so a future removal-less harness cannot borrow these. */
|
|
5
5
|
export declare function isOpencodeStyle(harness: Harness): harness is OpencodeStyleHarness;
|
|
6
|
-
/** The file on disk, else the `.
|
|
7
|
-
export declare function opencodeStyleConfigPath(harness: OpencodeStyleHarness): string;
|
|
8
|
-
/** Reports the header's shape, never its value
|
|
6
|
+
/** The add target: the file on disk, else the `.json` default so messages still name something. */
|
|
7
|
+
export declare function opencodeStyleConfigPath(harness: OpencodeStyleHarness, dir?: string): string;
|
|
8
|
+
/** Reports the header's shape, never its value; the effective (last) definition, not the first. */
|
|
9
9
|
export declare function readOpencodeStyleEntry(harness: OpencodeStyleHarness): McpJsonServerEntry;
|
|
10
|
+
/** Project configs an older 2.x connect left the key in (PF-6613). */
|
|
11
|
+
export declare function strayProjectKeyFiles(cwd?: string): string[];
|
|
12
|
+
/**
|
|
13
|
+
* 2.1.4 walks to `/`, past the git root (1.x and Kilo stop there). Walking too far only
|
|
14
|
+
* withholds key credit; stopping early would grant it while a project key is live.
|
|
15
|
+
*/
|
|
16
|
+
export declare function projectDefinesTinyfish(harness: OpencodeStyleHarness, cwd?: string): boolean;
|
|
@@ -8,27 +8,68 @@ import { readTinyfishEntry, } from './mcp-json-config.js';
|
|
|
8
8
|
export function isOpencodeStyle(harness) {
|
|
9
9
|
return harness === 'kilo' || harness === 'opencode';
|
|
10
10
|
}
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
11
|
+
/** OpenCode 2.1 `mcp add` order; first existing wins, else the first. */
|
|
12
|
+
function candidatePaths(dir, stem) {
|
|
13
|
+
return [
|
|
14
|
+
path.join(dir, `${stem}.json`), // nosemgrep: path-join-resolve-traversal -- fixed names from the harness spec
|
|
15
|
+
path.join(dir, `${stem}.jsonc`), // nosemgrep: path-join-resolve-traversal -- fixed names from the harness spec
|
|
16
|
+
path.join(dir, `.${stem}`, `${stem}.json`), // nosemgrep: path-join-resolve-traversal -- fixed names from the harness spec
|
|
17
|
+
path.join(dir, `.${stem}`, `${stem}.jsonc`), // nosemgrep: path-join-resolve-traversal -- fixed names from the harness spec
|
|
18
|
+
];
|
|
19
|
+
}
|
|
20
|
+
/** The add target: the file on disk, else the `.json` default so messages still name something. */
|
|
21
|
+
export function opencodeStyleConfigPath(harness, dir) {
|
|
22
|
+
const candidates = candidatePaths(dir ?? harnessConfigPath(harness), harnessSpec(harness).command);
|
|
21
23
|
return candidates.find((file) => fs.existsSync(file)) ?? candidates[0];
|
|
22
24
|
}
|
|
23
|
-
function target(harness) {
|
|
25
|
+
function target(harness, file) {
|
|
24
26
|
return {
|
|
25
27
|
serverKey: 'tinyfish',
|
|
26
28
|
serversKey: 'mcp',
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
nestedServersKey: 'servers',
|
|
30
|
+
jsonc: true,
|
|
31
|
+
dir: () => path.dirname(file),
|
|
32
|
+
file: () => file,
|
|
29
33
|
};
|
|
30
34
|
}
|
|
31
|
-
/**
|
|
35
|
+
/** OpenCode loads `.json` then `.jsonc` from one dir and the later definition of a server wins. */
|
|
36
|
+
function loadedEntries(harness, dir, projectWalk = false) {
|
|
37
|
+
const candidates = candidatePaths(dir, harnessSpec(harness).command);
|
|
38
|
+
// The `.opencode/` pair is read only on the project walk, never from the global dir.
|
|
39
|
+
return (projectWalk ? candidates : candidates.slice(0, 2))
|
|
40
|
+
.filter((file) => fs.existsSync(file))
|
|
41
|
+
.map((file) => ({ file, entry: readTinyfishEntry(target(harness, file)) }));
|
|
42
|
+
}
|
|
43
|
+
/** Reports the header's shape, never its value; the effective (last) definition, not the first. */
|
|
32
44
|
export function readOpencodeStyleEntry(harness) {
|
|
33
|
-
|
|
45
|
+
const entries = loadedEntries(harness, harnessConfigPath(harness)).map(({ entry }) => entry);
|
|
46
|
+
const effective = entries.filter((entry) => entry.present).at(-1);
|
|
47
|
+
return (effective ?? entries.find((entry) => entry.error) ?? { present: false, hasApiKeyHeader: false });
|
|
48
|
+
}
|
|
49
|
+
/** Project configs an older 2.x connect left the key in (PF-6613). */
|
|
50
|
+
export function strayProjectKeyFiles(cwd = process.cwd()) {
|
|
51
|
+
return loadedEntries('opencode', cwd, true)
|
|
52
|
+
.filter(({ entry }) => entry.present && entry.hasApiKeyHeader)
|
|
53
|
+
.map(({ file }) => file);
|
|
54
|
+
}
|
|
55
|
+
const truthy = (value) => value === '1' || value?.toLowerCase() === 'true';
|
|
56
|
+
// OpenCode 2.x reads the newer name first; `??` mirrors its `server-process.ts`.
|
|
57
|
+
const PROJECT_CONFIG_DISABLED = {
|
|
58
|
+
opencode: () => truthy(process.env['OPENCODE_CONFIG_PROJECT_DISABLE'] ??
|
|
59
|
+
process.env['OPENCODE_DISABLE_PROJECT_CONFIG']),
|
|
60
|
+
kilo: () => truthy(process.env['KILO_DISABLE_PROJECT_CONFIG']),
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* 2.1.4 walks to `/`, past the git root (1.x and Kilo stop there). Walking too far only
|
|
64
|
+
* withholds key credit; stopping early would grant it while a project key is live.
|
|
65
|
+
*/
|
|
66
|
+
export function projectDefinesTinyfish(harness, cwd = process.cwd()) {
|
|
67
|
+
if (PROJECT_CONFIG_DISABLED[harness]())
|
|
68
|
+
return false;
|
|
69
|
+
for (let dir = cwd;; dir = path.dirname(dir)) {
|
|
70
|
+
if (loadedEntries(harness, dir, true).some(({ entry }) => entry.present))
|
|
71
|
+
return true;
|
|
72
|
+
if (path.dirname(dir) === dir)
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
34
75
|
}
|
|
@@ -39,7 +39,7 @@ interface SeededInstall {
|
|
|
39
39
|
tag?: string;
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
|
-
interface BaseMcpClient extends SupportedCommand, Pick<HarnessSpec, 'skillAgent' | 'nonInteractiveAdd' | 'signInHint' | 'degradedAuthNotes' | 'postConnectNote'> {
|
|
42
|
+
interface BaseMcpClient extends SupportedCommand, Pick<HarnessSpec, 'skillAgent' | 'nonInteractiveAdd' | 'signInHint' | 'degradedAuthNotes' | 'postConnectNote' | 'verifyListed'> {
|
|
43
43
|
connectClient: Exclude<AgentClient, 'openclaw'>;
|
|
44
44
|
/** Replaces `addArgs` when a stored key is available: key auth removes the OAuth hop. */
|
|
45
45
|
keyAuth?: {
|
|
@@ -56,8 +56,8 @@ interface BaseMcpClient extends SupportedCommand, Pick<HarnessSpec, 'skillAgent'
|
|
|
56
56
|
args: string[];
|
|
57
57
|
label: string;
|
|
58
58
|
}[];
|
|
59
|
-
/** Blocking terminal walkthrough; runs only behind the TTY gate. */
|
|
60
|
-
launchWalkthrough?: (prompt: string) => void;
|
|
59
|
+
/** Blocking terminal walkthrough; runs only behind the TTY gate. Spawns `command` (pinned). */
|
|
60
|
+
launchWalkthrough?: (prompt: string, command: string) => void;
|
|
61
61
|
/** Own window, never inherits stdio; reports its own outcome. */
|
|
62
62
|
detachedLaunch?: (prompt: string) => WalkthroughOutcome;
|
|
63
63
|
/** Post-install like the skill step: failures warn, never fail the attempt. */
|
|
@@ -212,7 +212,7 @@ function installHermesWebPlugin({ apiKey, verbose, seededHome, }) {
|
|
|
212
212
|
// These TUIs read a positional as a project directory, so a bare `<cmd> "<prompt>"` would be
|
|
213
213
|
// read as a folder. `--prompt` seeds the interactive TUI with a first message
|
|
214
214
|
// (opencode.ai/docs/cli), so the onboarding guide fires just like the other agents.
|
|
215
|
-
const launchWithPromptFlag = (
|
|
215
|
+
const launchWithPromptFlag = (displayName) => (prompt, command) => {
|
|
216
216
|
handOverTerminal(command, ['--prompt', prompt], displayName);
|
|
217
217
|
};
|
|
218
218
|
// A bare `cline "<prompt>"` is a one-shot act run that auto-approves every tool, so it would
|
|
@@ -273,8 +273,8 @@ const HARNESS_OVERRIDES = {
|
|
|
273
273
|
seedKey: seedHermesKey,
|
|
274
274
|
prepareKeylessInstall: prepareHermesKeylessInstall,
|
|
275
275
|
},
|
|
276
|
-
kilo: { launchWalkthrough: launchWithPromptFlag('
|
|
277
|
-
opencode: { launchWalkthrough: launchWithPromptFlag('
|
|
276
|
+
kilo: { launchWalkthrough: launchWithPromptFlag('Kilo Code') },
|
|
277
|
+
opencode: { launchWalkthrough: launchWithPromptFlag('OpenCode') },
|
|
278
278
|
};
|
|
279
279
|
function toNativeClient(harness) {
|
|
280
280
|
const spec = harnessSpec(harness);
|
|
@@ -306,6 +306,7 @@ function toNativeClient(harness) {
|
|
|
306
306
|
postConnectNote: spec.postConnectNote,
|
|
307
307
|
extraPostInstall: overrides.extraPostInstall,
|
|
308
308
|
uninstallExtra: spec.uninstallExtra,
|
|
309
|
+
verifyListed: spec.verifyListed,
|
|
309
310
|
};
|
|
310
311
|
if (spec.keyRequired) {
|
|
311
312
|
if (!keyAuth)
|
|
@@ -357,7 +358,7 @@ export async function launchNativeMcpClient(client, onDecided, prompt = DEFAULT_
|
|
|
357
358
|
return outcome;
|
|
358
359
|
}
|
|
359
360
|
return deliverWalkthrough(client.displayName, client.command, prompt, () => client.launchWalkthrough
|
|
360
|
-
? client.launchWalkthrough(prompt)
|
|
361
|
+
? client.launchWalkthrough(prompt, client.command)
|
|
361
362
|
: handOverTerminal(client.command, [prompt], client.displayName), onDecided);
|
|
362
363
|
}
|
|
363
364
|
// Via deliverWalkthrough: handOverTerminal alone would wedge a headless --launch.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { type NativeMcpClient } from './connect-clients.js';
|
|
2
2
|
import { type ConnectAuthMode } from './connect-runtime.js';
|
|
3
3
|
import { type NativeConnectOptions } from './connect-steps.js';
|
|
4
|
-
export declare function connectNativeMcpClient(
|
|
4
|
+
export declare function connectNativeMcpClient(unpinned: NativeMcpClient, options: NativeConnectOptions): Promise<ConnectAuthMode | undefined>;
|
|
@@ -5,14 +5,72 @@ import { captureStdio, capturedOutput, replay, SKILL_INSTALL_TIMEOUT_MS, STEP_MA
|
|
|
5
5
|
import { ensureCliAuthenticated } from './connect-auth.js';
|
|
6
6
|
import { installWebSkill } from './skill-install.js';
|
|
7
7
|
import { ConnectInterruptedError, ConnectStepError, createConnectTelemetry, runGuarded, settle, SignInTimeoutError, spawnRemoval, spawnStepError, } from './connect-runtime.js';
|
|
8
|
-
import { TINYFISH_API_KEY_VAR } from './constants.js';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
8
|
+
import { DASHBOARD_URL, TINYFISH_API_KEY_VAR } from './constants.js';
|
|
9
|
+
import { strayProjectKeyFiles } from './config/opencode-config.js';
|
|
10
|
+
import { AuthMode, HARNESS_COMMANDS, RELOAD_ACTION, resolveCommand, } from './harness/harness-detect.js';
|
|
11
|
+
import { opencodeStrayKeyNote } from './harness/harness-messages.js';
|
|
12
|
+
import { SIGN_IN_PROBES, TINYFISH_LIST_ROW } from './registration-detect.js';
|
|
13
|
+
import { errLine, sanitizeLine, warnLine } from './output.js';
|
|
12
14
|
import { verifyMcpAuth } from './verify.js';
|
|
13
15
|
import { dotenvMarkerHead, harnessDotenvPath, writeDotenvKey } from './dotenv-file.js';
|
|
14
16
|
import { apiBaseFromMcpUrl } from './mcp-endpoint.js';
|
|
15
17
|
import { finishSetupHint, requireKeylessMcp, requireSupportOrRescue, runCliInstallStep, trackPostInstallFailure, walkthroughPhase, } from './connect-steps.js';
|
|
18
|
+
/** Several installs can disagree; every later spawn reuses this one. */
|
|
19
|
+
function pinExecutable(client) {
|
|
20
|
+
const resolved = resolveCommand(client.command);
|
|
21
|
+
return resolved ? { ...client, command: resolved } : client;
|
|
22
|
+
}
|
|
23
|
+
// A fresh background service answers empty, then `pending`, then settles within seconds.
|
|
24
|
+
const LIST_SETTLE_TIMEOUT_MS = 20_000;
|
|
25
|
+
const LIST_POLL_MS = 1_000;
|
|
26
|
+
function listRowStatus(result) {
|
|
27
|
+
const output = sanitizeLine(`${result.stdout ?? ''}\n${result.stderr ?? ''}`);
|
|
28
|
+
return TINYFISH_LIST_ROW.exec(output)?.[1]?.trim();
|
|
29
|
+
}
|
|
30
|
+
/** Last settled status, or undefined when the row never appeared before the deadline. */
|
|
31
|
+
async function pollListStatus(client, options) {
|
|
32
|
+
const deadline = Date.now() + (options.verifyTimeoutMs ?? LIST_SETTLE_TIMEOUT_MS);
|
|
33
|
+
for (;;) {
|
|
34
|
+
// One hung `mcp list` must not outlive the verification deadline.
|
|
35
|
+
const remaining = Math.max(deadline - Date.now(), LIST_POLL_MS);
|
|
36
|
+
const result = spawn.sync(client.command, ['mcp', 'list'], {
|
|
37
|
+
encoding: 'utf8',
|
|
38
|
+
timeout: Math.min(remaining, client.supportCheck.timeoutMs ?? SKILL_INSTALL_TIMEOUT_MS),
|
|
39
|
+
env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' },
|
|
40
|
+
});
|
|
41
|
+
const expired = Date.now() >= deadline;
|
|
42
|
+
// A spawn error is permanent; a non-zero exit can be the service still starting.
|
|
43
|
+
if (result.error || (result.status !== 0 && expired)) {
|
|
44
|
+
throw spawnStepError(`Could not list ${client.displayName}'s MCP servers`, result);
|
|
45
|
+
}
|
|
46
|
+
const status = result.status === 0 ? listRowStatus(result) : undefined;
|
|
47
|
+
if ((status && status !== 'pending') || expired)
|
|
48
|
+
return status;
|
|
49
|
+
await new Promise((resolve) => setTimeout(resolve, LIST_POLL_MS));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** The TUI reads the background service, not the file; a stale service misreports both ways. */
|
|
53
|
+
async function requireListedAndConnected(client, options, authMode) {
|
|
54
|
+
const status = await pollListStatus(client, options);
|
|
55
|
+
if (status === 'connected')
|
|
56
|
+
return;
|
|
57
|
+
const command = HARNESS_COMMANDS[client.connectClient];
|
|
58
|
+
const restart = `\`${command} service restart\`, then re-run: tinyfish connect ${client.connectClient}`;
|
|
59
|
+
const fail = (tag, message) => {
|
|
60
|
+
throw new ConnectStepError(`${client.displayName} ${message}`, 'invalid_config', {
|
|
61
|
+
failureDetail: tag,
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
if (!status || status === 'pending') {
|
|
65
|
+
fail('mcp_list_missing', `does not list TinyFish after the add. Check OPENCODE_CONFIG_DIR, run ${restart}`);
|
|
66
|
+
}
|
|
67
|
+
if (status === 'needs authentication') {
|
|
68
|
+
const signIn = authMode === AuthMode.OAuth ? `${command} mcp auth tinyfish` : 'tinyfish auth login';
|
|
69
|
+
fail('mcp_list_needs_auth', `reports TinyFish needs authentication. Run ${signIn} and ${restart}`);
|
|
70
|
+
}
|
|
71
|
+
// The status text can quote URLs and paths, so only the tag reaches telemetry.
|
|
72
|
+
fail('mcp_list_unhealthy', `reports TinyFish as "${status}". Fix that, run ${restart}`);
|
|
73
|
+
}
|
|
16
74
|
function removeExistingRegistration(client, removal, verbose) {
|
|
17
75
|
const result = spawnRemoval(client.command, removal.args);
|
|
18
76
|
if (result.status === 0)
|
|
@@ -287,7 +345,8 @@ function completeAuthFlow(client, options, state, telemetry, ctx) {
|
|
|
287
345
|
: AuthMode.OAuth;
|
|
288
346
|
return { authMode, signInDeferred };
|
|
289
347
|
}
|
|
290
|
-
export async function connectNativeMcpClient(
|
|
348
|
+
export async function connectNativeMcpClient(unpinned, options) {
|
|
349
|
+
const client = pinExecutable(unpinned);
|
|
291
350
|
const telemetry = createConnectTelemetry(options.mcpUrl, client.connectClient, options);
|
|
292
351
|
const state = { stage: 'prerequisite_check', settled: false };
|
|
293
352
|
const probeAuthenticated = SIGN_IN_PROBES[client.connectClient];
|
|
@@ -316,6 +375,11 @@ export async function connectNativeMcpClient(client, options) {
|
|
|
316
375
|
loginArgs && !probeAuthenticated ? 'registration' : 'registration_or_authentication';
|
|
317
376
|
const { note: seededNote, seededHome } = performMcpAdd(client, options, plan, telemetry.attemptId);
|
|
318
377
|
telemetry.track('checkpoint', { phase: 'registered' });
|
|
378
|
+
// Only 2.x lists a variant, and only 2.x wrote the project file (PF-6613).
|
|
379
|
+
const twoPointX = client.verifyListed && matchedVariant !== undefined;
|
|
380
|
+
for (const stray of twoPointX ? strayProjectKeyFiles() : []) {
|
|
381
|
+
warnLine(opencodeStrayKeyNote(stray, DASHBOARD_URL));
|
|
382
|
+
}
|
|
319
383
|
const { authMode, signInDeferred } = completeAuthFlow(client, options, state, telemetry, {
|
|
320
384
|
useKeyAuth,
|
|
321
385
|
useKeyless,
|
|
@@ -324,6 +388,11 @@ export async function connectNativeMcpClient(client, options) {
|
|
|
324
388
|
probeAuthenticated,
|
|
325
389
|
seededNote,
|
|
326
390
|
});
|
|
391
|
+
// A deferred sign-in legitimately lists as `needs authentication`.
|
|
392
|
+
if (twoPointX && !signInDeferred) {
|
|
393
|
+
state.stage = 'registration_or_authentication';
|
|
394
|
+
await requireListedAndConnected(client, options, authMode);
|
|
395
|
+
}
|
|
327
396
|
state.authMode = authMode;
|
|
328
397
|
// Registration/OAuth make MCP work; the steps below must not fail the attempt.
|
|
329
398
|
settle(state, telemetry, 'completed', { authMode });
|
|
@@ -217,8 +217,10 @@ function throwProbeFailure(client, result) {
|
|
|
217
217
|
throw new ConnectStepError(unavailableMessage(client, harnessVersion), 'harness_command_unsupported', {
|
|
218
218
|
cause: result.error,
|
|
219
219
|
harnessVersion,
|
|
220
|
-
// A spawn error leaves stderr empty; its
|
|
221
|
-
failureDetail: result.error
|
|
220
|
+
// A spawn error leaves stderr empty; its code is the diagnosis (the message quotes paths).
|
|
221
|
+
failureDetail: result.error
|
|
222
|
+
? (result.error.code ?? 'spawn_error')
|
|
223
|
+
: spawnFailureDetail(result),
|
|
222
224
|
});
|
|
223
225
|
}
|
|
224
226
|
function supportProbeOutput(client) {
|
|
@@ -22,6 +22,8 @@ export interface NativeConnectOptions {
|
|
|
22
22
|
deferOutro?: (line: string) => void;
|
|
23
23
|
/** Targeted connects only: --all keeps its per-harness failed row. */
|
|
24
24
|
fallbackWhenMissing?: boolean;
|
|
25
|
+
/** Test seam: how long a 2.x `mcp list` may stay empty or `pending` after the add. */
|
|
26
|
+
verifyTimeoutMs?: number;
|
|
25
27
|
}
|
|
26
28
|
export declare function requireKeylessMcp(mcpUrl: string): Promise<void>;
|
|
27
29
|
/** Undefined means the rescue ran; the caller returns immediately. */
|
|
@@ -14,6 +14,7 @@ export declare enum AuthMode {
|
|
|
14
14
|
Unknown = "unknown"
|
|
15
15
|
}
|
|
16
16
|
export declare const HARNESS_COMMANDS: Record<Harness, string>;
|
|
17
|
+
export declare function resolveCommand(command: string): string | undefined;
|
|
17
18
|
export declare function commandOnPath(command: string): boolean;
|
|
18
19
|
export declare function harnessConfigPath(harness: Harness): string;
|
|
19
20
|
/** For reason strings that name a location; keeps them in sync with the spec. */
|
|
@@ -27,18 +27,22 @@ export var AuthMode;
|
|
|
27
27
|
// TODO(PF-3668): a same-named non-harness binary still reads as installed.
|
|
28
28
|
export const HARNESS_COMMANDS = Object.fromEntries(ALL_HARNESSES.map((harness) => [harness, harnessSpec(harness).command]));
|
|
29
29
|
// cross-spawn resolves the same way, so detection cannot disagree with the spawn.
|
|
30
|
-
export function
|
|
30
|
+
export function resolveCommand(command) {
|
|
31
31
|
try {
|
|
32
|
-
return which.sync(command, { nothrow: true })
|
|
32
|
+
return which.sync(command, { nothrow: true }) ?? undefined;
|
|
33
33
|
}
|
|
34
34
|
catch {
|
|
35
|
-
return
|
|
35
|
+
return undefined;
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
|
+
export function commandOnPath(command) {
|
|
39
|
+
return resolveCommand(command) !== undefined;
|
|
40
|
+
}
|
|
38
41
|
// Env vars move these dirs; a guessed path would go unread.
|
|
39
42
|
const CONFIG_PATH_RESOLVERS = {
|
|
40
43
|
cline: clineConfigDir,
|
|
41
44
|
codex: () => agentHome('CODEX_HOME', harnessSpec('codex').configDir),
|
|
45
|
+
opencode: () => process.env['OPENCODE_CONFIG_DIR'] || undefined,
|
|
42
46
|
pi: piAgentDir,
|
|
43
47
|
};
|
|
44
48
|
export function harnessConfigPath(harness) {
|
|
@@ -10,6 +10,7 @@ export declare const GROK_SIGN_IN_HINT: string;
|
|
|
10
10
|
export declare const OPENCLAW_SKILL_UNAVAILABLE_MESSAGE: string;
|
|
11
11
|
export declare const OPENCODE_MCP_ADD_UNAVAILABLE_MESSAGE: string;
|
|
12
12
|
export declare const OPENCODE_MODEL_NOTE: string;
|
|
13
|
+
export declare const opencodeStrayKeyNote: (file: string, apiKeysUrl: string) => string;
|
|
13
14
|
export declare const KILO_MCP_ADD_UNAVAILABLE_MESSAGE: string;
|
|
14
15
|
export declare const HERMES_RESTART_NOTE = "Restart your Hermes session to pick up TinyFish \u2014 Hermes discovers MCP servers at startup.";
|
|
15
16
|
export declare const CLINE_MCP_ADD_UNAVAILABLE_MESSAGE: string;
|
|
@@ -37,6 +37,9 @@ export const OPENCODE_MCP_ADD_UNAVAILABLE_MESSAGE = 'Could not confirm this Open
|
|
|
37
37
|
export const OPENCODE_MODEL_NOTE = 'Note: TinyFish runs on tool calls, so OpenCode needs a model that supports tool use. Image-only ' +
|
|
38
38
|
'models (e.g. Nano Banana Pro) will show "No endpoints found that support tool use" — switch ' +
|
|
39
39
|
"OpenCode's model if the walkthrough can't start.";
|
|
40
|
+
// PF-6613 decision: notice and rotation prompt, never delete.
|
|
41
|
+
export const opencodeStrayKeyNote = (file, apiKeysUrl) => `An earlier TinyFish connect saved your API key in ${file} (OpenCode 2.x project config). ` +
|
|
42
|
+
`Remove its tinyfish entry; if that file was shared or committed, rotate the key at ${apiKeysUrl}`;
|
|
40
43
|
export const KILO_MCP_ADD_UNAVAILABLE_MESSAGE = 'Could not confirm this Kilo Code installation supports remote MCP servers: `kilo mcp add ' +
|
|
41
44
|
'--help` did not list `--url`. Update Kilo Code with `kilo upgrade` and retry, or add ' +
|
|
42
45
|
'TinyFish manually with `kilo mcp add`.';
|
|
@@ -87,6 +87,8 @@ export interface HarnessSpec {
|
|
|
87
87
|
readableWithoutBinary?: true;
|
|
88
88
|
/** `mcp add` succeeds unauthenticated; OAuth lands at first tool use. */
|
|
89
89
|
authDeferredAtInstall?: true;
|
|
90
|
+
/** 2.x adds only: poll `mcp list` until the background service settles the tinyfish row. */
|
|
91
|
+
verifyListed?: true;
|
|
90
92
|
}
|
|
91
93
|
/** One entry per harness; every per-harness list derives from it. */
|
|
92
94
|
export declare const HARNESS_SPECS: {
|
|
@@ -310,6 +312,7 @@ export declare const HARNESS_SPECS: {
|
|
|
310
312
|
loginArgs: string[];
|
|
311
313
|
removals: never[];
|
|
312
314
|
postConnectNote: string;
|
|
315
|
+
verifyListed: true;
|
|
313
316
|
};
|
|
314
317
|
pi: {
|
|
315
318
|
command: string;
|
|
@@ -20,6 +20,8 @@ export interface RegistrationStatus {
|
|
|
20
20
|
recordedAuthMode?: RecordedAuthMode;
|
|
21
21
|
reason?: string;
|
|
22
22
|
}
|
|
23
|
+
/** Our row in any `mcp list`; group 1 is whatever status text follows the name. */
|
|
24
|
+
export declare const TINYFISH_LIST_ROW: RegExp;
|
|
23
25
|
/** Per-harness sign-in probes; connect composes these rather than the descriptor carrying them. */
|
|
24
26
|
export declare const SIGN_IN_PROBES: Partial<Record<AgentClient, () => boolean | undefined>>;
|
|
25
27
|
/** Never throws; a failed probe is `unknown` with a reason, so no caller can read it as healthy. */
|
|
@@ -10,7 +10,7 @@ import { readClineTinyfishEntry } from './config/cline-config.js';
|
|
|
10
10
|
import { readCommandCodeTinyfishEntry } from './config/command-code-config.js';
|
|
11
11
|
import { readCursorTinyfishEntry } from './config/cursor-config.js';
|
|
12
12
|
import { readOmpTinyfishEntry } from './config/omp-config.js';
|
|
13
|
-
import { readOpencodeStyleEntry } from './config/opencode-config.js';
|
|
13
|
+
import { projectDefinesTinyfish, readOpencodeStyleEntry, } from './config/opencode-config.js';
|
|
14
14
|
import { readPiTinyfishEntry } from './config/pi-config.js';
|
|
15
15
|
import { harnessSpec } from './harness/harness-spec.js';
|
|
16
16
|
import { readHermesKey, resolveHermesHome } from './hermes-env.js';
|
|
@@ -108,7 +108,8 @@ const MCP_LIST_TINYFISH_LINE = /^((?:\S*:)?tinyfish):\s+(https?:\S+)(.*)$/m;
|
|
|
108
108
|
const SERVER_NAMED_TINYFISH = /^(?:\S*:)?tinyfish$/;
|
|
109
109
|
// A bare substring also matches `not-tinyfish` and any row quoting a tinyfish URL, so the name
|
|
110
110
|
// is required to be the first word on its line, after whatever status glyph the CLI prints.
|
|
111
|
-
|
|
111
|
+
/** Our row in any `mcp list`; group 1 is whatever status text follows the name. */
|
|
112
|
+
export const TINYFISH_LIST_ROW = /^[^\w]*tinyfish(?![\w-])\s*(.*)$/m;
|
|
112
113
|
// Claude Code reports its own live handshake; a config file cannot.
|
|
113
114
|
const MCP_GET_STATUS = /^\s*Status:\s*(.+)$/m;
|
|
114
115
|
// `mcp get <missing>` names every configured server, with no health check.
|
|
@@ -403,7 +404,7 @@ const MCP_LIST_HEADER = /mcp servers/i;
|
|
|
403
404
|
// OpenCode and its Kilo fork print the endpoint under the row; Hermes truncates its column.
|
|
404
405
|
const MCP_LIST_ROW_URL = /^[^\w]*tinyfish(?![\w-]).*\n[^\w]*(https?:\S+)/m;
|
|
405
406
|
function fromMcpList(command, output, registeredMode, urlPattern) {
|
|
406
|
-
if (
|
|
407
|
+
if (TINYFISH_LIST_ROW.test(output)) {
|
|
407
408
|
const url = urlPattern?.exec(output)?.[1];
|
|
408
409
|
return {
|
|
409
410
|
registered: Registered.Yes,
|
|
@@ -472,6 +473,15 @@ function configFileAuth(harness) {
|
|
|
472
473
|
...(entry.keyMatchesCliKey ? { keyMatchesCliKey: true } : {}),
|
|
473
474
|
};
|
|
474
475
|
}
|
|
476
|
+
// 2.x rows carry the service's handshake; 1.x prints `remote`, which decides nothing.
|
|
477
|
+
function listHealth(output) {
|
|
478
|
+
const status = TINYFISH_LIST_ROW.exec(output)?.[1]?.trim();
|
|
479
|
+
if (status === 'connected')
|
|
480
|
+
return { connected: true };
|
|
481
|
+
if (status === 'needs authentication' || status?.startsWith('failed'))
|
|
482
|
+
return { connected: false };
|
|
483
|
+
return {};
|
|
484
|
+
}
|
|
475
485
|
// `mcp list` resolves project config too, so it stays the registration source of truth.
|
|
476
486
|
function probeMcpList(harness) {
|
|
477
487
|
const command = harnessSpec(harness).command;
|
|
@@ -484,8 +494,16 @@ function probeMcpList(harness) {
|
|
|
484
494
|
const listed = fromMcpList(command, probe.output, AuthMode.Unknown, MCP_LIST_ROW_URL);
|
|
485
495
|
if (listed.registered !== Registered.Yes)
|
|
486
496
|
return listed;
|
|
487
|
-
|
|
488
|
-
|
|
497
|
+
const health = listHealth(probe.output);
|
|
498
|
+
// The global key is not the active credential when a project entry overrides it.
|
|
499
|
+
if (projectDefinesTinyfish(harness)) {
|
|
500
|
+
return { ...listed, ...health, keyReason: 'a project config overrides the global entry' };
|
|
501
|
+
}
|
|
502
|
+
const fileAuth = configFileAuth(harness);
|
|
503
|
+
// A failed handshake disproves the global key no matter whose it is.
|
|
504
|
+
if (health.connected === false)
|
|
505
|
+
delete fileAuth.keyMatchesCliKey;
|
|
506
|
+
return { ...listed, ...health, ...fileAuth };
|
|
489
507
|
}
|
|
490
508
|
// Verified against grok 0.0.60: a list. Header values stay `unknown`; only names are read.
|
|
491
509
|
const grokEntrySchema = z.looseObject({
|