@tiny-fish/cli 0.52.4-next.431 → 0.52.4-next.433
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 +99 -6
- package/dist/lib/connect-runtime.js +4 -2
- package/dist/lib/connect-steps.d.ts +2 -0
- package/dist/lib/doctor-checks.js +2 -0
- package/dist/lib/dotenv-file.d.ts +5 -1
- package/dist/lib/dotenv-file.js +11 -4
- package/dist/lib/harness/harness-detect.d.ts +1 -0
- package/dist/lib/harness/harness-detect.js +10 -4
- 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 +6 -0
- package/dist/lib/harness/harness-spec.js +3 -0
- package/dist/lib/registration-detect.d.ts +2 -0
- package/dist/lib/registration-detect.js +43 -9
- package/dist/lib/skill-paths.d.ts +1 -0
- package/dist/lib/skill-paths.js +1 -1
- 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,13 +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';
|
|
15
|
+
import { dotenvMarkerHead, harnessDotenvPath, writeDotenvKey } from './dotenv-file.js';
|
|
13
16
|
import { apiBaseFromMcpUrl } from './mcp-endpoint.js';
|
|
14
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
|
+
}
|
|
15
74
|
function removeExistingRegistration(client, removal, verbose) {
|
|
16
75
|
const result = spawnRemoval(client.command, removal.args);
|
|
17
76
|
if (result.status === 0)
|
|
@@ -43,12 +102,35 @@ function exportApiKey(apiKey, displayName, deferOutro) {
|
|
|
43
102
|
}
|
|
44
103
|
return true;
|
|
45
104
|
}
|
|
105
|
+
/** Best-effort: the profile export still covers terminal launches. */
|
|
106
|
+
function seedHarnessDotenv(client, keyed, deferOutro) {
|
|
107
|
+
const { envVar } = keyed.spec;
|
|
108
|
+
const envPath = harnessDotenvPath(client.connectClient);
|
|
109
|
+
if (!envPath || !envVar)
|
|
110
|
+
return false;
|
|
111
|
+
try {
|
|
112
|
+
// Unlike Hermes, nothing else needs this file group-readable.
|
|
113
|
+
writeDotenvKey(envPath, envVar, keyed.key, dotenvMarkerHead(client.connectClient), {
|
|
114
|
+
ownerOnly: true,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
warnLine(`Could not write ${envVar} to ${envPath} (${error instanceof Error ? error.message : String(error)}).`);
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
const line = `Wrote ${envVar} to ${envPath} — ${client.displayName} reads the key from there.`;
|
|
122
|
+
(deferOutro ?? errLine)(line);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
46
125
|
function resolveKeyedAdd(client, storedKey, keyAuthSupported, keyAuthOnly, deferOutro) {
|
|
47
126
|
if (!storedKey || !keyAuthSupported || !client.keyAuth)
|
|
48
127
|
return undefined;
|
|
49
128
|
const keyed = { key: storedKey, spec: client.keyAuth };
|
|
129
|
+
const seededDotenv = seedHarnessDotenv(client, keyed, deferOutro);
|
|
50
130
|
// Before the removals: a failed export must not strand the old registration.
|
|
51
|
-
if (keyed.spec.envVar &&
|
|
131
|
+
if (keyed.spec.envVar &&
|
|
132
|
+
!exportApiKey(keyed.key, client.displayName, deferOutro) &&
|
|
133
|
+
!seededDotenv) {
|
|
52
134
|
if (keyAuthOnly)
|
|
53
135
|
refuseFailedKeyExport(client);
|
|
54
136
|
errLine(`Signing in to ${client.displayName} instead.`);
|
|
@@ -263,7 +345,8 @@ function completeAuthFlow(client, options, state, telemetry, ctx) {
|
|
|
263
345
|
: AuthMode.OAuth;
|
|
264
346
|
return { authMode, signInDeferred };
|
|
265
347
|
}
|
|
266
|
-
export async function connectNativeMcpClient(
|
|
348
|
+
export async function connectNativeMcpClient(unpinned, options) {
|
|
349
|
+
const client = pinExecutable(unpinned);
|
|
267
350
|
const telemetry = createConnectTelemetry(options.mcpUrl, client.connectClient, options);
|
|
268
351
|
const state = { stage: 'prerequisite_check', settled: false };
|
|
269
352
|
const probeAuthenticated = SIGN_IN_PROBES[client.connectClient];
|
|
@@ -292,6 +375,11 @@ export async function connectNativeMcpClient(client, options) {
|
|
|
292
375
|
loginArgs && !probeAuthenticated ? 'registration' : 'registration_or_authentication';
|
|
293
376
|
const { note: seededNote, seededHome } = performMcpAdd(client, options, plan, telemetry.attemptId);
|
|
294
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
|
+
}
|
|
295
383
|
const { authMode, signInDeferred } = completeAuthFlow(client, options, state, telemetry, {
|
|
296
384
|
useKeyAuth,
|
|
297
385
|
useKeyless,
|
|
@@ -300,6 +388,11 @@ export async function connectNativeMcpClient(client, options) {
|
|
|
300
388
|
probeAuthenticated,
|
|
301
389
|
seededNote,
|
|
302
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
|
+
}
|
|
303
396
|
state.authMode = authMode;
|
|
304
397
|
// Registration/OAuth make MCP work; the steps below must not fail the attempt.
|
|
305
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. */
|
|
@@ -116,6 +116,8 @@ function registrationVerdict(status, mcpUrl, keyAuth, cliKeyMissing) {
|
|
|
116
116
|
}
|
|
117
117
|
// Ordered before the key check: the harness's own verdict wins.
|
|
118
118
|
if (status.connected === false) {
|
|
119
|
+
if (status.keyReason)
|
|
120
|
+
return { status: 'warn', detail: `registered, but ${status.keyReason}` };
|
|
119
121
|
return {
|
|
120
122
|
status: 'warn',
|
|
121
123
|
detail: 'registered, but the harness reports it cannot reach TinyFish; sign in again there',
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { type Harness } from './harness/harness-spec.js';
|
|
2
|
+
export declare function harnessDotenvPath(harness: Harness): string | undefined;
|
|
1
3
|
export declare function dotenvMarkerHead(client: string): string;
|
|
2
|
-
export declare function writeDotenvKey(envPath: string, varName: string, key: string, markerHead: string
|
|
4
|
+
export declare function writeDotenvKey(envPath: string, varName: string, key: string, markerHead: string, { ownerOnly }?: {
|
|
5
|
+
ownerOnly?: boolean;
|
|
6
|
+
}): void;
|
|
3
7
|
export declare function readDotenvKey(envPath: string, varName: string): string | undefined;
|
|
4
8
|
/** Restores the exact line writeDotenvKey replaces, quotes and comment included. */
|
|
5
9
|
export declare function captureDotenvKeyRestore(envPath: string, varName: string, markerHead: string): () => void;
|
package/dist/lib/dotenv-file.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { harnessSpec } from './harness/harness-spec.js';
|
|
5
|
+
import { harnessConfigPath } from './harness/harness-detect.js';
|
|
4
6
|
const MARKER_TAIL = '# tinyfish-cli: end';
|
|
5
7
|
// Keys are `sk-tinyfish-<alnum>`; refusing anything else beats shipping unreachable escaping.
|
|
6
8
|
const BARE_SAFE = /^[A-Za-z0-9._\-/:+=]+$/;
|
|
9
|
+
export function harnessDotenvPath(harness) {
|
|
10
|
+
return harnessSpec(harness).keyDotenv ? path.join(harnessConfigPath(harness), '.env') : undefined;
|
|
11
|
+
}
|
|
7
12
|
export function dotenvMarkerHead(client) {
|
|
8
13
|
return `# tinyfish-cli: TinyFish MCP key, managed by \`tinyfish connect ${client}\``;
|
|
9
14
|
}
|
|
@@ -28,7 +33,7 @@ function readLines(envPath) {
|
|
|
28
33
|
.split(/(?<=\n)/);
|
|
29
34
|
}
|
|
30
35
|
// Hermes preserves the original mode so Docker volume mounts survive (config.py:3921-3929).
|
|
31
|
-
function writeAtomic(envPath, lines) {
|
|
36
|
+
function writeAtomic(envPath, lines, ownerOnly = false) {
|
|
32
37
|
let mode = 0o600;
|
|
33
38
|
try {
|
|
34
39
|
mode = fs.statSync(envPath).mode & 0o777;
|
|
@@ -36,6 +41,8 @@ function writeAtomic(envPath, lines) {
|
|
|
36
41
|
catch {
|
|
37
42
|
// No existing file: 0600 is the right default for credentials.
|
|
38
43
|
}
|
|
44
|
+
if (ownerOnly)
|
|
45
|
+
mode &= 0o700;
|
|
39
46
|
const tmp = `${envPath}.${randomUUID()}.tmp`;
|
|
40
47
|
try {
|
|
41
48
|
fs.writeFileSync(tmp, lines.join(''), { encoding: 'utf8', mode });
|
|
@@ -48,7 +55,7 @@ function writeAtomic(envPath, lines) {
|
|
|
48
55
|
throw error;
|
|
49
56
|
}
|
|
50
57
|
}
|
|
51
|
-
export function writeDotenvKey(envPath, varName, key, markerHead) {
|
|
58
|
+
export function writeDotenvKey(envPath, varName, key, markerHead, { ownerOnly = false } = {}) {
|
|
52
59
|
const clean = key.replace(/[\r\n]/g, '').trim();
|
|
53
60
|
// An empty key reads as "bad characters" without its own branch.
|
|
54
61
|
if (!clean)
|
|
@@ -64,12 +71,12 @@ export function writeDotenvKey(envPath, varName, key, markerHead) {
|
|
|
64
71
|
if (lines.length && !lines[lines.length - 1].endsWith('\n'))
|
|
65
72
|
lines[lines.length - 1] += '\n';
|
|
66
73
|
lines.push(`${markerHead}\n`, assignment, `${MARKER_TAIL}\n`);
|
|
67
|
-
writeAtomic(envPath, lines);
|
|
74
|
+
writeAtomic(envPath, lines, ownerOnly);
|
|
68
75
|
return;
|
|
69
76
|
}
|
|
70
77
|
lines[index] = assignment;
|
|
71
78
|
// Dotenv loaders are last-wins; a surviving duplicate would beat this value.
|
|
72
|
-
writeAtomic(envPath, lines.filter((line, i) => i === index || !definesKey(line, varName)));
|
|
79
|
+
writeAtomic(envPath, lines.filter((line, i) => i === index || !definesKey(line, varName)), ownerOnly);
|
|
73
80
|
}
|
|
74
81
|
export function readDotenvKey(envPath, varName) {
|
|
75
82
|
const line = readLines(envPath).find((l) => definesKey(l, varName));
|
|
@@ -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. */
|
|
@@ -5,6 +5,7 @@ import which from 'which';
|
|
|
5
5
|
import { ALL_HARNESSES, harnessSpec } from './harness-spec.js';
|
|
6
6
|
import { clineConfigDir } from '../config/cline-config.js';
|
|
7
7
|
import { piAgentDir, piBinaryIsPi } from '../config/pi-config.js';
|
|
8
|
+
import { agentHome } from '../skill-paths.js';
|
|
8
9
|
export { ALL_HARNESSES };
|
|
9
10
|
export const HARNESS_DISPLAY_NAMES = Object.fromEntries(ALL_HARNESSES.map((harness) => [harness, harnessSpec(harness).displayName]));
|
|
10
11
|
export const RELOAD_ACTION = Object.fromEntries(ALL_HARNESSES.map((harness) => [harness, harnessSpec(harness).reloadAction]));
|
|
@@ -26,17 +27,22 @@ export var AuthMode;
|
|
|
26
27
|
// TODO(PF-3668): a same-named non-harness binary still reads as installed.
|
|
27
28
|
export const HARNESS_COMMANDS = Object.fromEntries(ALL_HARNESSES.map((harness) => [harness, harnessSpec(harness).command]));
|
|
28
29
|
// cross-spawn resolves the same way, so detection cannot disagree with the spawn.
|
|
29
|
-
export function
|
|
30
|
+
export function resolveCommand(command) {
|
|
30
31
|
try {
|
|
31
|
-
return which.sync(command, { nothrow: true })
|
|
32
|
+
return which.sync(command, { nothrow: true }) ?? undefined;
|
|
32
33
|
}
|
|
33
34
|
catch {
|
|
34
|
-
return
|
|
35
|
+
return undefined;
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
|
-
|
|
38
|
+
export function commandOnPath(command) {
|
|
39
|
+
return resolveCommand(command) !== undefined;
|
|
40
|
+
}
|
|
41
|
+
// Env vars move these dirs; a guessed path would go unread.
|
|
38
42
|
const CONFIG_PATH_RESOLVERS = {
|
|
39
43
|
cline: clineConfigDir,
|
|
44
|
+
codex: () => agentHome('CODEX_HOME', harnessSpec('codex').configDir),
|
|
45
|
+
opencode: () => process.env['OPENCODE_CONFIG_DIR'] || undefined,
|
|
40
46
|
pi: piAgentDir,
|
|
41
47
|
};
|
|
42
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`.';
|
|
@@ -48,6 +48,8 @@ export interface HarnessSpec {
|
|
|
48
48
|
};
|
|
49
49
|
/** Keyed adds append this flag plus the env var name (Codex). */
|
|
50
50
|
keyEnvFlag?: string;
|
|
51
|
+
/** The harness loads `<config dir>/.env` at launch (Codex). */
|
|
52
|
+
keyDotenv?: boolean;
|
|
51
53
|
/** Keyed adds append these flags; the key itself is seeded elsewhere (Hermes). */
|
|
52
54
|
keyAddFlags?: string[];
|
|
53
55
|
/** No sign-in exists here, so connect refuses a keyless install (Hermes). */
|
|
@@ -85,6 +87,8 @@ export interface HarnessSpec {
|
|
|
85
87
|
readableWithoutBinary?: true;
|
|
86
88
|
/** `mcp add` succeeds unauthenticated; OAuth lands at first tool use. */
|
|
87
89
|
authDeferredAtInstall?: true;
|
|
90
|
+
/** 2.x adds only: poll `mcp list` until the background service settles the tinyfish row. */
|
|
91
|
+
verifyListed?: true;
|
|
88
92
|
}
|
|
89
93
|
/** One entry per harness; every per-harness list derives from it. */
|
|
90
94
|
export declare const HARNESS_SPECS: {
|
|
@@ -156,6 +160,7 @@ export declare const HARNESS_SPECS: {
|
|
|
156
160
|
};
|
|
157
161
|
urlStyle: "flag";
|
|
158
162
|
keyEnvFlag: string;
|
|
163
|
+
keyDotenv: true;
|
|
159
164
|
degradedAuthNotes: {
|
|
160
165
|
keyed: string;
|
|
161
166
|
deferred: string;
|
|
@@ -307,6 +312,7 @@ export declare const HARNESS_SPECS: {
|
|
|
307
312
|
loginArgs: string[];
|
|
308
313
|
removals: never[];
|
|
309
314
|
postConnectNote: string;
|
|
315
|
+
verifyListed: true;
|
|
310
316
|
};
|
|
311
317
|
pi: {
|
|
312
318
|
command: string;
|
|
@@ -78,6 +78,8 @@ export const HARNESS_SPECS = {
|
|
|
78
78
|
urlStyle: 'flag',
|
|
79
79
|
// Codex has no `--header`; the MCP server takes a key as `Bearer sk-…`.
|
|
80
80
|
keyEnvFlag: '--bearer-token-env-var',
|
|
81
|
+
// GUI launches skip the shell profile; unset variable drops the server.
|
|
82
|
+
keyDotenv: true,
|
|
81
83
|
degradedAuthNotes: { keyed: CODEX_KEYED_FALLBACK_NOTE, deferred: CODEX_DEFERRED_AUTH_NOTE },
|
|
82
84
|
// `--oauth-resource` duplicates the resource Codex discovers; Clerk rejects both.
|
|
83
85
|
// `mcp add` still runs OAuth inline, so `loginArgs` stays a retry.
|
|
@@ -238,6 +240,7 @@ export const HARNESS_SPECS = {
|
|
|
238
240
|
// `opencode mcp add` upserts by name and ships no `mcp remove`.
|
|
239
241
|
removals: [],
|
|
240
242
|
postConnectNote: OPENCODE_MODEL_NOTE,
|
|
243
|
+
verifyListed: true,
|
|
241
244
|
},
|
|
242
245
|
pi: {
|
|
243
246
|
command: 'pi',
|
|
@@ -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,10 +10,11 @@ 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';
|
|
17
|
+
import { harnessDotenvPath, readDotenvKey } from './dotenv-file.js';
|
|
17
18
|
import { readHermesEntry } from './config/hermes-config.js';
|
|
18
19
|
import { detectInstalledHarnesses, harnessConfigPath, harnessDisplayPath, AuthMode, Registered, } from './harness/harness-detect.js';
|
|
19
20
|
const NOT_REGISTERED = Object.freeze({
|
|
@@ -107,7 +108,8 @@ const MCP_LIST_TINYFISH_LINE = /^((?:\S*:)?tinyfish):\s+(https?:\S+)(.*)$/m;
|
|
|
107
108
|
const SERVER_NAMED_TINYFISH = /^(?:\S*:)?tinyfish$/;
|
|
108
109
|
// A bare substring also matches `not-tinyfish` and any row quoting a tinyfish URL, so the name
|
|
109
110
|
// is required to be the first word on its line, after whatever status glyph the CLI prints.
|
|
110
|
-
|
|
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;
|
|
111
113
|
// Claude Code reports its own live handshake; a config file cannot.
|
|
112
114
|
const MCP_GET_STATUS = /^\s*Status:\s*(.+)$/m;
|
|
113
115
|
// `mcp get <missing>` names every configured server, with no health check.
|
|
@@ -245,15 +247,30 @@ const ENV_VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
|
245
247
|
function declaredKeyEnvVar(harness) {
|
|
246
248
|
return NATIVE_BY_HARNESS.get(harness)?.keyAuth?.envVar;
|
|
247
249
|
}
|
|
250
|
+
/** An unreadable .env reads as unset, not as a failed probe. */
|
|
251
|
+
function readDotenvKeySafe(dotenvPath, envVar) {
|
|
252
|
+
try {
|
|
253
|
+
return readDotenvKey(dotenvPath, envVar);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
248
259
|
/** Codex stores a variable name; resolving it only answers whether it is the CLI's key. */
|
|
249
|
-
function envKeyVerdict(envVar) {
|
|
260
|
+
function envKeyVerdict(envVar, dotenvPath) {
|
|
250
261
|
if (!ENV_VAR_NAME.test(envVar)) {
|
|
251
262
|
return { keyReason: 'its key comes from an unusable variable name' };
|
|
252
263
|
}
|
|
253
|
-
|
|
264
|
+
const fromDotenv = dotenvPath ? readDotenvKeySafe(dotenvPath, envVar) : undefined;
|
|
265
|
+
// GUI launches read only the .env; a shell export masks that.
|
|
266
|
+
if (dotenvPath && !fromDotenv) {
|
|
267
|
+
return { connected: false, keyReason: `$${envVar} is not in ${dotenvPath}` };
|
|
268
|
+
}
|
|
269
|
+
const value = process.env[envVar] || fromDotenv;
|
|
270
|
+
if (matchesCliKey(value))
|
|
254
271
|
return { keyMatchesCliKey: true };
|
|
255
272
|
return {
|
|
256
|
-
keyReason:
|
|
273
|
+
keyReason: value
|
|
257
274
|
? `$${envVar} holds a key this CLI cannot verify`
|
|
258
275
|
: `$${envVar} is not set here`,
|
|
259
276
|
};
|
|
@@ -279,7 +296,7 @@ function probeCodex() {
|
|
|
279
296
|
// Codex's own verdict: a stale `oauth_resource` strands installs here.
|
|
280
297
|
...(entry.auth_status === CodexAuthStatus.NotLoggedIn && !envVar ? { connected: false } : {}),
|
|
281
298
|
...(codexUrl ? { registeredUrl: codexUrl } : {}),
|
|
282
|
-
...(envVar ? envKeyVerdict(envVar) : {}),
|
|
299
|
+
...(envVar ? envKeyVerdict(envVar, harnessDotenvPath('codex')) : {}),
|
|
283
300
|
};
|
|
284
301
|
}
|
|
285
302
|
// No `cline mcp list`, so the settings file is the only registration evidence.
|
|
@@ -387,7 +404,7 @@ const MCP_LIST_HEADER = /mcp servers/i;
|
|
|
387
404
|
// OpenCode and its Kilo fork print the endpoint under the row; Hermes truncates its column.
|
|
388
405
|
const MCP_LIST_ROW_URL = /^[^\w]*tinyfish(?![\w-]).*\n[^\w]*(https?:\S+)/m;
|
|
389
406
|
function fromMcpList(command, output, registeredMode, urlPattern) {
|
|
390
|
-
if (
|
|
407
|
+
if (TINYFISH_LIST_ROW.test(output)) {
|
|
391
408
|
const url = urlPattern?.exec(output)?.[1];
|
|
392
409
|
return {
|
|
393
410
|
registered: Registered.Yes,
|
|
@@ -456,6 +473,15 @@ function configFileAuth(harness) {
|
|
|
456
473
|
...(entry.keyMatchesCliKey ? { keyMatchesCliKey: true } : {}),
|
|
457
474
|
};
|
|
458
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
|
+
}
|
|
459
485
|
// `mcp list` resolves project config too, so it stays the registration source of truth.
|
|
460
486
|
function probeMcpList(harness) {
|
|
461
487
|
const command = harnessSpec(harness).command;
|
|
@@ -468,8 +494,16 @@ function probeMcpList(harness) {
|
|
|
468
494
|
const listed = fromMcpList(command, probe.output, AuthMode.Unknown, MCP_LIST_ROW_URL);
|
|
469
495
|
if (listed.registered !== Registered.Yes)
|
|
470
496
|
return listed;
|
|
471
|
-
|
|
472
|
-
|
|
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 };
|
|
473
507
|
}
|
|
474
508
|
// Verified against grok 0.0.60: a list. Header values stay `unknown`; only names are read.
|
|
475
509
|
const grokEntrySchema = z.looseObject({
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SkillAgent } from './harness/harness-spec.js';
|
|
2
|
+
export declare function agentHome(override: string, fallback: string): string;
|
|
2
3
|
/** Where skills@1.5.15 wrote skill dirs for this agent. */
|
|
3
4
|
export declare function skillRootDir(agent: SkillAgent): string;
|
|
4
5
|
/** Mirrors skills@1.5.15 getSkillLockPath(); missing the XDG branch strands entries. */
|
package/dist/lib/skill-paths.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as os from 'node:os';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
// Mirrors skills@1.5.15 exactly: it trims and falls back on empty values.
|
|
4
|
-
function agentHome(override, fallback) {
|
|
4
|
+
export function agentHome(override, fallback) {
|
|
5
5
|
return process.env[override]?.trim() || path.join(os.homedir(), fallback); // nosemgrep: path-join-resolve-traversal -- fixed dir names under os.homedir()
|
|
6
6
|
}
|
|
7
7
|
// `skills` writes here for the universal agents, whose own config dirs it never touches.
|