@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.
@@ -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
- function parseMcpJson(raw) {
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 servers = existing.parsed[target.serversKey ?? 'mcpServers'];
80
- const entry = isPlainRecord(servers) ? servers[target.serverKey] : undefined;
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 `.jsonc` default so messages still name something. */
7
- export declare function opencodeStyleConfigPath(harness: OpencodeStyleHarness): string;
8
- /** Reports the header's shape, never its value. JSONC comments read as unparseable. */
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
- /** Both extensions are valid; `mcp add` keeps whichever already exists. */
12
- const EXTENSIONS = ['.jsonc', '.json'];
13
- function candidatePaths(harness) {
14
- const dir = harnessConfigPath(harness);
15
- const stem = harnessSpec(harness).command;
16
- return EXTENSIONS.map((ext) => path.join(dir, `${stem}${ext}`)); // nosemgrep: path-join-resolve-traversal -- fixed names from the harness spec
17
- }
18
- /** The file on disk, else the `.jsonc` default so messages still name something. */
19
- export function opencodeStyleConfigPath(harness) {
20
- const candidates = candidatePaths(harness);
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
- dir: () => harnessConfigPath(harness),
28
- file: () => opencodeStyleConfigPath(harness),
29
+ nestedServersKey: 'servers',
30
+ jsonc: true,
31
+ dir: () => path.dirname(file),
32
+ file: () => file,
29
33
  };
30
34
  }
31
- /** Reports the header's shape, never its value. JSONC comments read as unparseable. */
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
- return readTinyfishEntry(target(harness));
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 = (command, displayName) => (prompt) => {
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('kilo', 'Kilo Code') },
277
- opencode: { launchWalkthrough: launchWithPromptFlag('opencode', 'OpenCode') },
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(client: NativeMcpClient, options: NativeConnectOptions): Promise<ConnectAuthMode | undefined>;
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 { AuthMode, RELOAD_ACTION } from './harness/harness-detect.js';
10
- import { SIGN_IN_PROBES } from './registration-detect.js';
11
- import { errLine, warnLine } from './output.js';
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 && !exportApiKey(keyed.key, client.displayName, deferOutro)) {
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(client, options) {
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 message is the diagnosis.
221
- failureDetail: result.error?.message ?? spawnFailureDetail(result),
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): void;
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;
@@ -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 commandOnPath(command) {
30
+ export function resolveCommand(command) {
30
31
  try {
31
- return which.sync(command, { nothrow: true }) !== null;
32
+ return which.sync(command, { nothrow: true }) ?? undefined;
32
33
  }
33
34
  catch {
34
- return false;
35
+ return undefined;
35
36
  }
36
37
  }
37
- // PI_CODING_AGENT_DIR and CLINE_DIR move these dirs; a guessed path would be written unread.
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
- const LISTED_AS_TINYFISH = /^[^\w]*tinyfish(?![\w-])/m;
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
- if (matchesCliKey(process.env[envVar]))
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: process.env[envVar]
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 (LISTED_AS_TINYFISH.test(output)) {
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
- // A project-scoped entry is listed but absent here, so auth mode stays unknown.
472
- return { ...listed, ...configFileAuth(harness) };
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. */
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.52.4-next.431",
3
+ "version": "0.52.4-next.433",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {