@tiny-fish/cli 0.39.1-next.311 → 0.40.1-next.317

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.
@@ -127,13 +127,19 @@ function noWorkOutcome(d, uninstall) {
127
127
  return 'not_detected';
128
128
  if (!uninstall && isStaleConfig(d))
129
129
  return 'stale_config';
130
+ if (!uninstall && needsFirstRun(d))
131
+ return 'needs_first_run';
130
132
  return undefined;
131
133
  }
134
+ /** The harness has never created its dir, so connecting would build one it does not own. */
135
+ export function needsFirstRun(detection) {
136
+ return detection.detected && !detection.configDirExists;
137
+ }
132
138
  /** A config-writing connect needs no binary; every other harness spawns one. */
133
139
  function isStaleConfig(detection) {
134
140
  const spec = harnessSpec(detection.harness);
135
- // Cursor's IDE reads mcp.json binary-less; omp's path needs the binary.
136
- const binaryless = spec.cliWritesConfig && !spec.configPathFromBinary;
141
+ // Cursor's IDE reads mcp.json binary-less; omp and pi have no second reader.
142
+ const binaryless = spec.cliWritesConfig && spec.readableWithoutBinary;
137
143
  return detection.detected && !detection.binaryOnPath && !binaryless;
138
144
  }
139
145
  async function resolvePick(detections, opts, prompt) {
@@ -141,7 +147,7 @@ async function resolvePick(detections, opts, prompt) {
141
147
  let mode = 'all';
142
148
  // Before willAttempt/cliNeeded: a cursor-only pick must not hoist the CLI.
143
149
  // A stale config dir has no binary to connect, so never offer it as a choice.
144
- const offerable = detections.filter((d) => d.detected && !isStaleConfig(d));
150
+ const offerable = detections.filter((d) => d.detected && !isStaleConfig(d) && !needsFirstRun(d));
145
151
  if (opts.pick && offerable.length > 0) {
146
152
  const connected = new Set(Object.keys(loadConfig().connect ?? {}));
147
153
  const pick = await pickHarnesses(offerable, connected, { prompt });
@@ -236,13 +242,14 @@ async function connectLoop(detections, opts, deselected, io) {
236
242
  if (results.some((r) => r.harness === detection.harness))
237
243
  continue;
238
244
  const { harness, detected, configPath } = detection;
239
- const staleConfig = isStaleConfig(detection);
245
+ // One source of truth with `stepped`, or a skipped harness lands on the wrong row.
246
+ const noWork = noWorkOutcome(detection, opts.uninstall);
240
247
  results.push({
241
248
  harness,
242
249
  detected,
243
250
  configPath,
244
251
  installed: false,
245
- outcome: staleConfig ? 'stale_config' : detected ? 'not_reached' : 'not_detected',
252
+ outcome: noWork ?? 'not_reached',
246
253
  fixCommand: `tinyfish connect ${detection.harness}`,
247
254
  });
248
255
  }
@@ -389,6 +396,7 @@ const LAUNCHERS = [
389
396
  { harness: 'cursor', args: [FIRST_TASK_PROMPT] },
390
397
  { harness: 'claude-code', args: [FIRST_TASK_PROMPT] },
391
398
  { harness: 'omp', args: [FIRST_TASK_PROMPT] },
399
+ { harness: 'pi', args: [FIRST_TASK_PROMPT] },
392
400
  ];
393
401
  async function launchFirstTask(results) {
394
402
  const candidates = LAUNCHERS.filter((l) => results.some((r) => r.harness === l.harness && r.installed) &&
@@ -19,6 +19,8 @@ export declare const CURSOR_SKILL_TARGET: {
19
19
  };
20
20
  /** The harness reads the key from its own store. */
21
21
  interface SeededInstall {
22
+ /** Reused by the plugin step, so a second probe cannot disagree. */
23
+ home: string;
22
24
  /** Pins the add child at the home the key landed in. */
23
25
  env: typeof process.env;
24
26
  /** Answers prompts `mcp add` still asks once the key is seeded. */
@@ -27,6 +29,8 @@ interface SeededInstall {
27
29
  note: string;
28
30
  /** Undoes the seed, carrying the value it replaced. */
29
31
  rollback: () => void;
32
+ /** Writes the entry ourselves; false falls back to the harness command. */
33
+ register: (mcpUrl: string) => boolean;
30
34
  /** `mcp add` exits 0 even having saved nothing; the code lies. */
31
35
  confirmRegistered: () => {
32
36
  ok: boolean;
@@ -57,6 +61,7 @@ interface BaseMcpClient extends SupportedCommand, Pick<HarnessSpec, 'skillAgent'
57
61
  extraPostInstall?: (options: {
58
62
  apiKey?: string;
59
63
  verbose: boolean;
64
+ seededHome?: string;
60
65
  }) => void;
61
66
  /** Pin `extraPostInstall` installs, reported on the plugin_installed checkpoint. */
62
67
  pluginVersion?: string;
@@ -86,7 +91,7 @@ export type NativeMcpClient = OauthCapableMcpClient | KeyRequiredMcpClient;
86
91
  * quotes back out. Untested on Windows.
87
92
  */
88
93
  export declare function openExternalUrl(url: string): ReturnType<typeof spawn.sync>;
89
- /** `mcp add` exits 0 after saving nothing, or a disabled entry. */
94
+ /** Writes exit 0 having saved nothing, a disabled entry, or a keyless one. */
90
95
  export declare function hermesRegistrationEnabled(home: string): {
91
96
  ok: boolean;
92
97
  detail?: string;
@@ -94,7 +99,7 @@ export declare function hermesRegistrationEnabled(home: string): {
94
99
  };
95
100
  export declare const NATIVE_MCP_CLIENTS: readonly NativeMcpClient[];
96
101
  /** Native descriptor by harness id; Cursor and OpenClaw have none. */
97
- export declare const NATIVE_BY_HARNESS: Map<"openclaw" | "omp" | "grok" | "cursor" | "codex" | "hermes" | "opencode" | "claude-code", NativeMcpClient>;
102
+ export declare const NATIVE_BY_HARNESS: Map<"openclaw" | "omp" | "grok" | "cursor" | "codex" | "hermes" | "opencode" | "pi" | "claude-code", NativeMcpClient>;
98
103
  export declare const OPENCLAW: SupportedCommand;
99
104
  /** "printed" = handed to the user to paste; the walkthrough was never started for them. */
100
105
  export type WalkthroughOutcome = 'launched' | 'printed';
@@ -102,4 +107,5 @@ export type WalkthroughOutcome = 'launched' | 'printed';
102
107
  type OnWalkthroughDecided = (outcome: WalkthroughOutcome) => void | Promise<void>;
103
108
  export declare function launchNativeMcpClient(client: NativeMcpClient, onDecided?: OnWalkthroughDecided): Promise<WalkthroughOutcome>;
104
109
  export declare function launchOmpWalkthrough(onDecided?: OnWalkthroughDecided): Promise<WalkthroughOutcome>;
110
+ export declare function launchPiWalkthrough(onDecided?: OnWalkthroughDecided): Promise<WalkthroughOutcome>;
105
111
  export declare function launchOpenClawWalkthrough(onDecided?: OnWalkthroughDecided): Promise<WalkthroughOutcome>;
@@ -3,12 +3,12 @@ import { SKILL_INSTALL_TIMEOUT_MS } from './cli-install.js';
3
3
  import { detectHumanInitiated } from './harness.js';
4
4
  import { harnessConfigPath } from './harness-detect.js';
5
5
  import { errLine, sanitizeLine, warnLine } from './output.js';
6
- import { ConnectStepError, spawnStepError, } from './connect-runtime.js';
6
+ import { ConnectInterruptedError, ConnectStepError, spawnStepError, } from './connect-runtime.js';
7
7
  import { TINYFISH_API_KEY_VAR } from './constants.js';
8
8
  import { HARNESS_SPECS, NATIVE_HARNESSES, harnessSpec, } from './harness-spec.js';
9
9
  import { HERMES_KEY_VAR, captureHermesKeyRestore, hermesEnvPath, resolveHermesHome, writeHermesKey, } from './hermes-env.js';
10
10
  import { hermesConfigPath, readHermesEntry } from './hermes-config.js';
11
- import { HERMES_PLUGIN_VERSION, installHermesPlugin, setHermesWebBackends, } from './hermes-plugin.js';
11
+ import { HERMES_PLUGIN_VERSION, installHermesPlugin, removeHermesMcpEntry, setHermesWebBackends, writeHermesMcpEntry, } from './hermes-plugin.js';
12
12
  const HERMES_SEED_TIMEOUT_MS = 120_000;
13
13
  const OPENCLAW_SKILL = '@tinyfish/tinyfish';
14
14
  // --force makes this an unconditional overwrite, so the same call installs and refreshes.
@@ -100,11 +100,19 @@ function launchHermesWalkthrough() {
100
100
  }
101
101
  handOverTerminal('hermes', ['--resume', sessionId], 'Hermes');
102
102
  }
103
- /** `mcp add` exits 0 after saving nothing, or a disabled entry. */
103
+ /** Writes exit 0 having saved nothing, a disabled entry, or a keyless one. */
104
104
  export function hermesRegistrationEnabled(home) {
105
105
  const entry = readHermesEntry(home);
106
- if (entry.state === 'enabled')
107
- return { ok: true };
106
+ if (entry.state === 'enabled') {
107
+ if (entry.usesKeyHeader)
108
+ return { ok: true };
109
+ // Exit codes carry no signal, so a corrupt entry can only be caught here.
110
+ return {
111
+ ok: false,
112
+ detail: 'the entry does not read the seeded key',
113
+ tag: 'hermes_entry_no_key_header',
114
+ };
115
+ }
108
116
  // Each of these is a different repair, so none of them share wording.
109
117
  const detail = {
110
118
  disabled: 'the entry was saved disabled',
@@ -117,28 +125,47 @@ export function hermesRegistrationEnabled(home) {
117
125
  }
118
126
  // A plain Error settles unexpected_error; cli_connect_stage is load-bearing.
119
127
  function requireHermesHome() {
120
- const home = resolveHermesHome();
121
- if (!home) {
122
- throw new ConnectStepError("Could not determine Hermes' home directory from `hermes dump`", 'invalid_config', { failureDetail: 'hermes_home_undetermined' });
128
+ const resolved = resolveHermesHome();
129
+ if (typeof resolved === 'string')
130
+ return resolved;
131
+ throw new ConnectStepError("Could not determine Hermes' home directory from `hermes dump`", 'invalid_config', { failureDetail: `hermes_home_undetermined_${resolved.reason}` });
132
+ }
133
+ /** Our own write, so `mcp add`'s install-time probe never disables the entry. */
134
+ function registerHermesEntry(home, mcpUrl) {
135
+ try {
136
+ writeHermesMcpEntry(home, mcpUrl);
137
+ }
138
+ catch (error) {
139
+ // A Ctrl+C must abandon the attempt, not fall through to `mcp add`.
140
+ if (error instanceof ConnectInterruptedError)
141
+ throw error;
142
+ return false;
123
143
  }
124
- return home;
144
+ return hermesRegistrationEnabled(home).ok;
125
145
  }
126
146
  function seedHermesKey(apiKey) {
127
147
  const home = requireHermesHome();
128
148
  const restore = captureHermesKeyRestore(home);
129
149
  writeHermesKey(home, apiKey);
150
+ // Set before the write, which can land even when it later throws.
151
+ let wroteEntry = false;
130
152
  return {
153
+ home,
131
154
  // Pins HERMES_HOME so a sticky profile can't shadow this key.
132
155
  env: { ...process.env, HERMES_HOME: home },
133
156
  // Three: a failed `mcp remove` adds an extra Overwrite prompt.
134
157
  stdinInput: 'y\ny\ny\n',
135
158
  note: `Wrote ${HERMES_KEY_VAR} to ${hermesEnvPath(home)} — Hermes reads the key from there.`,
136
- rollback: () => rollbackHermesKey(home, restore),
159
+ rollback: () => rollbackHermesKey(home, restore, wroteEntry),
160
+ register: (mcpUrl) => {
161
+ wroteEntry = true;
162
+ return registerHermesEntry(home, mcpUrl);
163
+ },
137
164
  confirmRegistered: () => hermesRegistrationEnabled(home),
138
165
  };
139
166
  }
140
167
  // A failed re-run must not cost a working key.
141
- function rollbackHermesKey(home, restore) {
168
+ function rollbackHermesKey(home, restore, wroteEntry) {
142
169
  try {
143
170
  // The next `hermes mcp add` silently reuses whatever value survives here.
144
171
  restore();
@@ -146,13 +173,23 @@ function rollbackHermesKey(home, restore) {
146
173
  catch {
147
174
  warnLine(`Could not restore ${HERMES_KEY_VAR} in ${hermesEnvPath(home)}; check it by hand.`);
148
175
  }
176
+ if (!wroteEntry)
177
+ return;
178
+ try {
179
+ // Left behind, the row stays enabled with no key behind it.
180
+ removeHermesMcpEntry(home);
181
+ }
182
+ catch {
183
+ warnLine(`Could not remove the tinyfish entry in ${hermesConfigPath(home)}; check it by hand.`);
184
+ }
149
185
  }
150
186
  // Hermes is keyRequired; a missing key here is a resolution bug.
151
- function installHermesWebPlugin({ apiKey, verbose }) {
187
+ function installHermesWebPlugin({ apiKey, verbose, seededHome, }) {
152
188
  if (!apiKey) {
153
189
  throw new ConnectStepError('No TinyFish API key was available to install the Hermes web plugin', 'invalid_config');
154
190
  }
155
- const home = requireHermesHome();
191
+ // Reuses the seed's home: a second `hermes dump` can disagree with the first.
192
+ const home = seededHome ?? requireHermesHome();
156
193
  installHermesPlugin(home, apiKey, { verbose });
157
194
  setHermesWebBackends(home);
158
195
  }
@@ -295,6 +332,9 @@ export async function launchNativeMcpClient(client, onDecided) {
295
332
  export function launchOmpWalkthrough(onDecided) {
296
333
  return deliverWalkthrough('omp', 'omp', DEFAULT_ONBOARDING_PROMPT, () => handOverTerminal('omp', [DEFAULT_ONBOARDING_PROMPT], 'omp'), onDecided);
297
334
  }
335
+ export function launchPiWalkthrough(onDecided) {
336
+ return deliverWalkthrough('Pi', 'pi', DEFAULT_ONBOARDING_PROMPT, () => handOverTerminal('pi', [DEFAULT_ONBOARDING_PROMPT], 'Pi'), onDecided);
337
+ }
298
338
  export function launchOpenClawWalkthrough(onDecided) {
299
339
  return deliverWalkthrough('OpenClaw', 'openclaw', OPENCLAW_ONBOARDING_PROMPT, () => handOverTerminal('openclaw', ['chat', '--message', OPENCLAW_ONBOARDING_PROMPT], 'OpenClaw'), onDecided);
300
340
  }
@@ -11,6 +11,7 @@ export declare function checkCredential(): {
11
11
  };
12
12
  export declare function checkAuthCall(auth: VerifyResult | undefined): DoctorCheck;
13
13
  export declare function verifyHarnessKey(status: RegistrationStatus, cliAuth: Promise<VerifyResult> | undefined, mcpUrl: string): Promise<VerifyResult> | undefined;
14
+ export declare function checkPiAdapter(status: RegistrationStatus): DoctorCheck;
14
15
  export interface HermesPluginResult {
15
16
  check: DoctorCheck;
16
17
  observedVersion?: string;
@@ -4,6 +4,7 @@ import { AuthMode, Registered } from './harness-detect.js';
4
4
  import { resolveHermesHome } from './hermes-env.js';
5
5
  import { hermesWebBackendsOurs, readHermesPluginStatus, } from './hermes-plugin.js';
6
6
  import { endpointOf, isDefaultEndpoint } from './mcp-endpoint.js';
7
+ import { PI_ADAPTER_INSTALL_COMMAND, piMcpAdapterState } from './pi-config.js';
7
8
  import { boundedVersion } from './output.js';
8
9
  import { verifyMcpHealth } from './verify.js';
9
10
  // A green auth mode with a red auth call proves nothing, so the call's verdict gates the claim.
@@ -250,6 +251,40 @@ function hermesPluginVerdict(home) {
250
251
  observedVersion: boundedVersion(probe.status.plugin_version ?? ''),
251
252
  };
252
253
  }
254
+ // Warn, not fail: the TinyFish skill works in pi with no adapter, so nothing is broken.
255
+ export function checkPiAdapter(status) {
256
+ const base = {
257
+ id: 'pi-adapter',
258
+ title: 'Pi MCP adapter',
259
+ harness: 'pi',
260
+ scope: 'harness',
261
+ };
262
+ if (!status.detected)
263
+ return { ...base, status: 'skip', detail: 'harness not installed' };
264
+ if (status.registered !== Registered.Yes) {
265
+ const why = status.registered === Registered.Unknown
266
+ ? 'pi registration could not be determined'
267
+ : 'TinyFish is not registered in pi';
268
+ return { ...base, status: 'skip', detail: why };
269
+ }
270
+ const state = piMcpAdapterState();
271
+ if (state === 'installed') {
272
+ return { ...base, status: 'pass', detail: 'installed, so pi reads the tinyfish MCP entry' };
273
+ }
274
+ if (state === 'unknown') {
275
+ return {
276
+ ...base,
277
+ status: 'warn',
278
+ detail: "could not read pi's settings.json, so whether the adapter is installed is unknown",
279
+ };
280
+ }
281
+ return {
282
+ ...base,
283
+ status: 'warn',
284
+ detail: 'not installed, so the tinyfish MCP entry is dormant (the TinyFish skill works without it). ' +
285
+ `Fix: ${PI_ADAPTER_INSTALL_COMMAND}`,
286
+ };
287
+ }
253
288
  export function checkHermesPlugin(status) {
254
289
  const base = {
255
290
  id: 'hermes-plugin',
@@ -263,12 +298,12 @@ export function checkHermesPlugin(status) {
263
298
  if (status.registered !== Registered.Yes) {
264
299
  return { check: { ...base, status: 'skip', detail: 'TinyFish is not registered in Hermes' } };
265
300
  }
266
- const home = resolveHermesHome();
267
- if (!home) {
301
+ const resolved = resolveHermesHome();
302
+ if (typeof resolved !== 'string') {
268
303
  return {
269
304
  check: { ...base, status: 'warn', detail: "could not determine Hermes' home directory" },
270
305
  };
271
306
  }
272
- const { observedVersion, ...verdict } = hermesPluginVerdict(home);
307
+ const { observedVersion, ...verdict } = hermesPluginVerdict(resolved);
273
308
  return { check: { ...base, ...verdict }, observedVersion };
274
309
  }
@@ -31,6 +31,7 @@ declare const doctorCheckSchema: z.ZodObject<{
31
31
  codex: "codex";
32
32
  hermes: "hermes";
33
33
  opencode: "opencode";
34
+ pi: "pi";
34
35
  "claude-code": "claude-code";
35
36
  }>>;
36
37
  scope: z.ZodEnum<{
@@ -48,6 +49,7 @@ declare const doctorHarnessSchema: z.ZodObject<{
48
49
  codex: "codex";
49
50
  hermes: "hermes";
50
51
  opencode: "opencode";
52
+ pi: "pi";
51
53
  "claude-code": "claude-code";
52
54
  }>;
53
55
  detected: z.ZodBoolean;
@@ -74,6 +76,7 @@ declare const doctorRepairSchema: z.ZodObject<{
74
76
  codex: "codex";
75
77
  hermes: "hermes";
76
78
  opencode: "opencode";
79
+ pi: "pi";
77
80
  "claude-code": "claude-code";
78
81
  }>>;
79
82
  command: z.ZodString;
@@ -102,6 +105,7 @@ export declare const doctorReportSchema: z.ZodObject<{
102
105
  codex: "codex";
103
106
  hermes: "hermes";
104
107
  opencode: "opencode";
108
+ pi: "pi";
105
109
  "claude-code": "claude-code";
106
110
  }>>;
107
111
  scope: z.ZodEnum<{
@@ -119,6 +123,7 @@ export declare const doctorReportSchema: z.ZodObject<{
119
123
  codex: "codex";
120
124
  hermes: "hermes";
121
125
  opencode: "opencode";
126
+ pi: "pi";
122
127
  "claude-code": "claude-code";
123
128
  }>;
124
129
  detected: z.ZodBoolean;
@@ -145,6 +150,7 @@ export declare const doctorReportSchema: z.ZodObject<{
145
150
  codex: "codex";
146
151
  hermes: "hermes";
147
152
  opencode: "opencode";
153
+ pi: "pi";
148
154
  "claude-code": "claude-code";
149
155
  }>>;
150
156
  command: z.ZodString;
@@ -23,5 +23,7 @@ export interface HarnessDetection {
23
23
  configPath: string;
24
24
  /** False with `detected` true means a stale config dir: nothing to run. */
25
25
  binaryOnPath: boolean;
26
+ /** True with no config dir means installed but never launched. */
27
+ configDirExists: boolean;
26
28
  }
27
29
  export declare function detectInstalledHarnesses(): HarnessDetection[];
@@ -3,6 +3,7 @@ import * as os from 'os';
3
3
  import * as path from 'path';
4
4
  import which from 'which';
5
5
  import { ALL_HARNESSES, harnessSpec } from './harness-spec.js';
6
+ import { piAgentDir, piBinaryIsPi } from './pi-config.js';
6
7
  export { ALL_HARNESSES };
7
8
  export const HARNESS_DISPLAY_NAMES = Object.fromEntries(ALL_HARNESSES.map((harness) => [harness, harnessSpec(harness).displayName]));
8
9
  export const RELOAD_ACTION = Object.fromEntries(ALL_HARNESSES.map((harness) => [harness, harnessSpec(harness).reloadAction]));
@@ -31,13 +32,18 @@ export function commandOnPath(command) {
31
32
  return false;
32
33
  }
33
34
  }
35
+ // PI_CODING_AGENT_DIR moves pi's dir; a guessed path would be written unread.
36
+ const CONFIG_PATH_RESOLVERS = { pi: piAgentDir };
34
37
  export function harnessConfigPath(harness) {
35
- return path.join(os.homedir(), harnessSpec(harness).configDir); // nosemgrep: path-join-resolve-traversal -- fixed dir names under os.homedir()
38
+ const resolved = CONFIG_PATH_RESOLVERS[harness]?.();
39
+ return resolved ?? path.join(os.homedir(), harnessSpec(harness).configDir); // nosemgrep: path-join-resolve-traversal -- fixed dir names under os.homedir()
36
40
  }
37
41
  /** For reason strings that name a location; keeps them in sync with the spec. */
38
42
  export function harnessDisplayPath(harness) {
39
43
  return `~/${harnessSpec(harness).configDir}`;
40
44
  }
45
+ // `pi` is a two-letter name an unrelated npm package also claims; confirm before trusting it.
46
+ const BINARY_VERIFIERS = { pi: piBinaryIsPi };
41
47
  export function detectInstalledHarnesses() {
42
48
  return ALL_HARNESSES.map((harness) => {
43
49
  const configPath = harnessConfigPath(harness);
@@ -48,13 +54,15 @@ export function detectInstalledHarnesses() {
48
54
  catch {
49
55
  configDirExists = false;
50
56
  }
51
- // Config dir means ran once; PATH means runnable now.
52
- const binaryOnPath = commandOnPath(HARNESS_COMMANDS[harness]);
57
+ // Config dir means ran once; a verified binary means runnable now.
58
+ const onPath = commandOnPath(HARNESS_COMMANDS[harness]);
59
+ const binaryOnPath = onPath && (BINARY_VERIFIERS[harness]?.() ?? true);
53
60
  return {
54
61
  harness,
55
62
  detected: configDirExists || binaryOnPath,
56
63
  configPath,
57
64
  binaryOnPath,
65
+ configDirExists,
58
66
  };
59
67
  });
60
68
  }
@@ -1,5 +1,5 @@
1
1
  /** The `skills` CLI's own agent names, not ours. */
2
- export type SkillAgent = 'claude-code' | 'codex' | 'cursor' | 'hermes-agent' | 'opencode';
2
+ export type SkillAgent = 'claude-code' | 'codex' | 'cursor' | 'hermes-agent' | 'opencode' | 'pi';
3
3
  export interface HarnessSupportCheck {
4
4
  args: string[];
5
5
  /** Missing → nothing can work; setup fails. */
@@ -74,8 +74,8 @@ export interface HarnessSpec {
74
74
  keyHeldByCli?: true;
75
75
  /** Connect writes the MCP config file; no harness binary is spawned. */
76
76
  cliWritesConfig?: true;
77
- /** Binary resolves the config path; absent binary, nothing reads it. */
78
- configPathFromBinary?: true;
77
+ /** A second reader (Cursor's IDE) loads the config with no harness binary on PATH. */
78
+ readableWithoutBinary?: true;
79
79
  /** `mcp add` succeeds unauthenticated; OAuth lands at first tool use. */
80
80
  authDeferredAtInstall?: true;
81
81
  }
@@ -139,6 +139,7 @@ export declare const HARNESS_SPECS: {
139
139
  configDir: string;
140
140
  reloadAction: string;
141
141
  skillAgent: "cursor";
142
+ readableWithoutBinary: true;
142
143
  canVerifyAuth: true;
143
144
  keyHeldByCli: true;
144
145
  cliWritesConfig: true;
@@ -192,7 +193,6 @@ export declare const HARNESS_SPECS: {
192
193
  displayName: string;
193
194
  configDir: string;
194
195
  reloadAction: string;
195
- configPathFromBinary: true;
196
196
  canVerifyAuth: true;
197
197
  keyHeldByCli: true;
198
198
  cliWritesConfig: true;
@@ -236,6 +236,16 @@ export declare const HARNESS_SPECS: {
236
236
  removals: never[];
237
237
  postConnectNote: string;
238
238
  };
239
+ pi: {
240
+ command: string;
241
+ displayName: string;
242
+ configDir: string;
243
+ reloadAction: string;
244
+ skillAgent: "pi";
245
+ canVerifyAuth: true;
246
+ keyHeldByCli: true;
247
+ cliWritesConfig: true;
248
+ };
239
249
  };
240
250
  /** Derived from spec keys; one entry extends every union. */
241
251
  export type Harness = keyof typeof HARNESS_SPECS;
@@ -246,4 +256,4 @@ export type NonNativeHarness = {
246
256
  [K in Harness]: 'urlStyle' extends keyof (typeof HARNESS_SPECS)[K] ? never : K;
247
257
  }[Harness];
248
258
  /** Native MCP harnesses carry add-generation fields; the rest override connect. */
249
- export declare const NATIVE_HARNESSES: ("openclaw" | "omp" | "grok" | "cursor" | "codex" | "hermes" | "opencode" | "claude-code")[];
259
+ export declare const NATIVE_HARNESSES: ("openclaw" | "omp" | "grok" | "cursor" | "codex" | "hermes" | "opencode" | "pi" | "claude-code")[];
@@ -114,6 +114,7 @@ export const HARNESS_SPECS = {
114
114
  configDir: '.cursor',
115
115
  reloadAction: 'reload the window',
116
116
  skillAgent: 'cursor',
117
+ readableWithoutBinary: true,
117
118
  canVerifyAuth: true,
118
119
  keyHeldByCli: true,
119
120
  cliWritesConfig: true,
@@ -167,7 +168,6 @@ export const HARNESS_SPECS = {
167
168
  displayName: 'omp',
168
169
  configDir: '.omp',
169
170
  reloadAction: 'restart it',
170
- configPathFromBinary: true,
171
171
  canVerifyAuth: true,
172
172
  keyHeldByCli: true,
173
173
  cliWritesConfig: true,
@@ -213,6 +213,16 @@ export const HARNESS_SPECS = {
213
213
  removals: [],
214
214
  postConnectNote: OPENCODE_MODEL_NOTE,
215
215
  },
216
+ pi: {
217
+ command: 'pi',
218
+ displayName: 'Pi',
219
+ configDir: '.pi/agent',
220
+ reloadAction: 'restart it',
221
+ skillAgent: 'pi',
222
+ canVerifyAuth: true,
223
+ keyHeldByCli: true,
224
+ cliWritesConfig: true,
225
+ },
216
226
  };
217
227
  export const ALL_HARNESSES = Object.keys(HARNESS_SPECS);
218
228
  export function harnessSpec(harness) {
@@ -28,6 +28,8 @@ const HARNESS_FINGERPRINTS = [
28
28
  { name: 'hermes', matches: (env) => hasVarWithPrefix(env, 'HERMES_') },
29
29
  // Exact key, not a prefix: opencode writes OPENCODE=1 at startup, reads every OPENCODE_* as config.
30
30
  { name: 'opencode', matches: (env) => Boolean(env['OPENCODE']) },
31
+ // Observed: pi launched from Claude Code inherits CLAUDECODE=1.
32
+ { name: 'pi', matches: (env) => env['PI_CODING_AGENT'] === 'true' },
31
33
  {
32
34
  name: 'claude-code',
33
35
  matches: (env) => env['CLAUDECODE'] === '1' || Boolean(env['CLAUDE_CODE_ENTRYPOINT']),
@@ -1,5 +1,8 @@
1
1
  /** Every message about the entry names this path. */
2
2
  export declare function hermesConfigPath(home: string): string;
3
+ export declare const HERMES_HEADER_TEMPLATE = "Bearer ${MCP_TINYFISH_API_KEY}";
4
+ /** Both the writer and the gate key on this, so they cannot disagree. */
5
+ export declare const HERMES_MCP_SERVER_KEY = "tinyfish";
3
6
  /** Callers branch on these; each is a different repair. */
4
7
  export type HermesEntry = {
5
8
  state: 'unreadable';
@@ -8,7 +8,9 @@ export function hermesConfigPath(home) {
8
8
  return path.join(home, 'config.yaml');
9
9
  }
10
10
  // Hermes persists the key as an interpolation template (mcp_config.py:174-182).
11
- const HEADER_TEMPLATE = `Bearer \${${HERMES_KEY_VAR}}`;
11
+ export const HERMES_HEADER_TEMPLATE = `Bearer \${${HERMES_KEY_VAR}}`;
12
+ /** Both the writer and the gate key on this, so they cannot disagree. */
13
+ export const HERMES_MCP_SERVER_KEY = 'tinyfish';
12
14
  // Foreign file: model only what we read, tolerate the rest.
13
15
  const entrySchema = z.looseObject({
14
16
  // Anything goes: the runtime defaults unrecognised values to enabled.
@@ -16,7 +18,9 @@ const entrySchema = z.looseObject({
16
18
  headers: z.record(z.string(), z.unknown()).optional(),
17
19
  });
18
20
  const configSchema = z
19
- .looseObject({ mcp_servers: z.looseObject({ tinyfish: entrySchema.nullish() }).nullish() })
21
+ .looseObject({
22
+ mcp_servers: z.looseObject({ [HERMES_MCP_SERVER_KEY]: entrySchema.nullish() }).nullish(),
23
+ })
20
24
  .nullish();
21
25
  const TRUTHY_STRINGS = new Set(['true', '1', 'yes', 'on']);
22
26
  const FALSY_STRINGS = new Set(['false', '0', 'no', 'off']);
@@ -42,7 +46,7 @@ function isEnabled(value) {
42
46
  /** Only the header template proves this entry uses the seeded key. */
43
47
  function hasKeyHeader(headers) {
44
48
  const authorization = Object.entries(headers ?? {}).find(([name]) => name.toLowerCase() === 'authorization');
45
- return authorization?.[1] === HEADER_TEMPLATE;
49
+ return authorization?.[1] === HERMES_HEADER_TEMPLATE;
46
50
  }
47
51
  /** Connect's gate and doctor's probe share this, so they cannot disagree. */
48
52
  export function readHermesEntry(home) {
@@ -60,7 +64,7 @@ export function readHermesEntry(home) {
60
64
  catch {
61
65
  return { state: 'unparseable' };
62
66
  }
63
- const entry = config?.mcp_servers?.tinyfish;
67
+ const entry = config?.mcp_servers?.[HERMES_MCP_SERVER_KEY];
64
68
  if (!entry)
65
69
  return { state: 'absent' };
66
70
  const usesKeyHeader = hasKeyHeader(entry.headers);
@@ -1,5 +1,11 @@
1
+ /** Each reason is a different repair, so telemetry keeps them apart. */
2
+ export type HermesHomeReason = 'timeout' | 'exit' | 'no_line' | 'relative';
3
+ /** Failure carries why, so connect tags it without a second probe. */
4
+ export interface HermesHomeFailure {
5
+ reason: HermesHomeReason;
6
+ }
1
7
  /** Asking Hermes beats reimplementing its profile override, which we'd drift from. */
2
- export declare function resolveHermesHome(): string | undefined;
8
+ export declare function resolveHermesHome(): string | HermesHomeFailure;
3
9
  export declare const HERMES_KEY_VAR = "MCP_TINYFISH_API_KEY";
4
10
  /** Named in every message about the seeded key, so no caller rebuilds the path. */
5
11
  export declare function hermesEnvPath(home: string): string;
@@ -14,13 +14,16 @@ export function resolveHermesHome() {
14
14
  env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
15
15
  timeout: HARNESS_PROBE_TIMEOUT_MS,
16
16
  });
17
+ if (result.error?.code === 'ETIMEDOUT') {
18
+ return { reason: 'timeout' };
19
+ }
17
20
  if (result.error || result.status !== 0)
18
- return undefined;
21
+ return { reason: 'exit' };
19
22
  const raw = HERMES_HOME_LINE.exec(sanitizeLine(`${result.stdout ?? ''}\n${result.stderr ?? ''}`))?.[1];
20
23
  if (!raw)
21
- return undefined;
24
+ return { reason: 'no_line' };
22
25
  const expanded = raw === '~' || raw.startsWith('~/') ? path.join(os.homedir(), raw.slice(2)) : raw;
23
- return path.isAbsolute(expanded) ? expanded : undefined;
26
+ return path.isAbsolute(expanded) ? expanded : { reason: 'relative' };
24
27
  }
25
28
  export const HERMES_KEY_VAR = 'MCP_TINYFISH_API_KEY';
26
29
  /** Named in every message about the seeded key, so no caller rebuilds the path. */
@@ -4,6 +4,10 @@ export declare const HERMES_PLUGIN_SHA = "496cd63fefd982bbaa8a85ce78ef2270d70098
4
4
  export declare const HERMES_PLUGIN_VERSION = "0.1.0";
5
5
  export declare const HERMES_PLUGIN_MANUAL_INSTALL = "hermes plugins install tinyfish-io/tinyfish-web-agent-integrations/hermes --ref 496cd63fefd982bbaa8a85ce78ef2270d700984f --enable";
6
6
  export declare function installHermesPlugin(home: string, apiKey: string, { verbose }: Pick<InstallOptions, 'verbose'>): void;
7
+ /** Absent `enabled` reads as enabled, so never write this field-by-field. */
8
+ export declare function writeHermesMcpEntry(home: string, mcpUrl: string): void;
9
+ /** Retracts our row; an absent `enabled` reads as enabled, so a partial one is live. */
10
+ export declare function removeHermesMcpEntry(home: string): void;
7
11
  /** After install only: backends must never name an absent provider. */
8
12
  export declare function setHermesWebBackends(home: string): void;
9
13
  /** Ours-only unsets: a user-chosen backend value survives the uninstall. */
@@ -7,6 +7,7 @@ import { z } from 'zod';
7
7
  import { capturedOutput, replay, SKILL_INSTALL_TIMEOUT_MS, STEP_MAX_BUFFER, } from './cli-install.js';
8
8
  import { commandNotFound, ConnectStepError, spawnStepError, throwIfInterrupted, } from './connect-runtime.js';
9
9
  import { HARNESS_PROBE_TIMEOUT_MS } from './constants.js';
10
+ import { HERMES_HEADER_TEMPLATE, HERMES_MCP_SERVER_KEY } from './hermes-config.js';
10
11
  import { errLine, parseJson } from './output.js';
11
12
  // Bump per CLI release.
12
13
  export const HERMES_PLUGIN_SHA = '496cd63fefd982bbaa8a85ce78ef2270d700984f';
@@ -95,6 +96,21 @@ function configWrite(home, args) {
95
96
  replay(capturedOutput(result));
96
97
  throw error;
97
98
  }
99
+ /** Absent `enabled` reads as enabled, so never write this field-by-field. */
100
+ export function writeHermesMcpEntry(home, mcpUrl) {
101
+ const entry = {
102
+ url: mcpUrl,
103
+ headers: { Authorization: HERMES_HEADER_TEMPLATE },
104
+ enabled: true,
105
+ };
106
+ const result = hermesConfig(['set', `mcp_servers.${HERMES_MCP_SERVER_KEY}`, JSON.stringify(entry)], home);
107
+ // `config set` exits 0 on a bad key, so only an interrupt is worth raising here.
108
+ throwIfInterrupted(result);
109
+ }
110
+ /** Retracts our row; an absent `enabled` reads as enabled, so a partial one is live. */
111
+ export function removeHermesMcpEntry(home) {
112
+ configWrite(home, ['unset', `mcp_servers.${HERMES_MCP_SERVER_KEY}`]);
113
+ }
98
114
  const HERMES_BACKEND_KEYS = ['web.search_backend', 'web.extract_backend'];
99
115
  // Dispatch falls back to web.backend, and the plugin's own setup can set it.
100
116
  const HERMES_OWNED_BACKEND_KEYS = [...HERMES_BACKEND_KEYS, 'web.backend'];