@tiny-fish/cli 0.39.1-next.311 → 0.40.0

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.
@@ -1,6 +1,6 @@
1
1
  import spawn from 'cross-spawn';
2
2
  import { CONNECT_SOURCE, persistApiKeyToEnvironment, saveConnectContext, validateKeyFormat, validatedApiKey, } from '../lib/auth.js';
3
- import { CURSOR_SKILL_TARGET, OPENCLAW, openclawSkillInstallArgs, NATIVE_BY_HARNESS, launchNativeMcpClient, launchOmpWalkthrough, launchOpenClawWalkthrough, openExternalUrl, } from '../lib/connect-clients.js';
3
+ import { CURSOR_SKILL_TARGET, OPENCLAW, openclawSkillInstallArgs, NATIVE_BY_HARNESS, launchNativeMcpClient, launchOmpWalkthrough, launchPiWalkthrough, launchOpenClawWalkthrough, openExternalUrl, } from '../lib/connect-clients.js';
4
4
  import { captureStdio, capturedOutput, installTinyFishCli, replay, SKILL_INSTALL_TIMEOUT_MS, STEP_MAX_BUFFER, } from '../lib/cli-install.js';
5
5
  import { ensureCliAuthenticated } from '../lib/connect-auth.js';
6
6
  import { createStdinPrompt, runCliFallback } from '../lib/connect-fallback.js';
@@ -9,6 +9,7 @@ import { ConnectInterruptedError, ConnectStepError, HoistedFailureReportedError,
9
9
  import { cursorInstallDeeplink, cursorMcpPath, readCursorTinyfishEntry, writeCursorMcpConfig, } from '../lib/cursor-config.js';
10
10
  import { runConnectAll } from '../lib/connect-all.js';
11
11
  import { ompMcpPath, readOmpTinyfishEntry, writeOmpMcpConfig } from '../lib/omp-config.js';
12
+ import { PI_ADAPTER_INSTALL_COMMAND, piMcpAdapterState, piSkillDirMismatch, piMcpPath, readPiTinyfishEntry, writePiMcpConfig, } from '../lib/pi-config.js';
12
13
  import { ALL_HARNESSES, AuthMode, HARNESS_DISPLAY_NAMES, RELOAD_ACTION, } from '../lib/harness-detect.js';
13
14
  import { detectHumanInitiated } from '../lib/harness.js';
14
15
  import { TINYFISH_API_KEY_VAR } from '../lib/constants.js';
@@ -169,6 +170,22 @@ async function prepareInstallAuth(client, options, keyAuthSupported) {
169
170
  }
170
171
  return { keyedAdd, buildAddArgs };
171
172
  }
173
+ /** Inherit for an inline-OAuth add; capture otherwise, piping prompts when seeded. */
174
+ function addSpawnOptions(options, interactiveAdd, seeded) {
175
+ if (interactiveAdd)
176
+ return { stdio: 'inherit', timeout: options.authTimeoutMs };
177
+ if (!seeded)
178
+ return captureStdio(options.verbose ?? false);
179
+ return {
180
+ encoding: 'utf8',
181
+ // Without it, the 1 MiB default overflows into a misread Ctrl+C.
182
+ maxBuffer: STEP_MAX_BUFFER,
183
+ // The unattended path had no bound at all before; captureStdio's is the floor.
184
+ timeout: options.authTimeoutMs ?? SKILL_INSTALL_TIMEOUT_MS,
185
+ input: seeded.stdinInput,
186
+ env: seeded.env,
187
+ };
188
+ }
172
189
  /** Returns the seeded-credential note when a key was written into a user-owned file. */
173
190
  function performMcpAdd(client, options, plan, attemptId) {
174
191
  const { keyedAdd, buildAddArgs } = plan;
@@ -183,20 +200,11 @@ function performMcpAdd(client, options, plan, attemptId) {
183
200
  const interactiveAdd = !(client.nonInteractiveAdd || keyedAdd);
184
201
  // After the removals: an interrupt there leaves no key behind.
185
202
  const seeded = keyedAdd?.spec.seedKey?.(keyedAdd.key);
186
- const addOptions = interactiveAdd
187
- ? { stdio: 'inherit', timeout: options.authTimeoutMs }
188
- : seeded
189
- ? {
190
- encoding: 'utf8',
191
- // Without it, the 1 MiB default overflows into a misread Ctrl+C.
192
- maxBuffer: STEP_MAX_BUFFER,
193
- // The unattended path had no bound at all before; captureStdio's is the floor.
194
- timeout: options.authTimeoutMs ?? SKILL_INSTALL_TIMEOUT_MS,
195
- input: seeded.stdinInput,
196
- env: seeded.env,
197
- }
198
- : captureStdio(options.verbose ?? false);
199
- const addResult = spawn.sync(client.command, addArgs, addOptions);
203
+ // Writing the entry ourselves skips the harness probe that saves it disabled.
204
+ if (seeded?.register(mcpUrl.toString())) {
205
+ return { note: seeded.note, seededHome: seeded.home };
206
+ }
207
+ const addResult = spawn.sync(client.command, addArgs, addSpawnOptions(options, interactiveAdd, seeded));
200
208
  // Rolls back first: signInStepError throws on a Ctrl+C.
201
209
  const failAdd = (build) => {
202
210
  seeded?.rollback();
@@ -214,7 +222,7 @@ function performMcpAdd(client, options, plan, attemptId) {
214
222
  // The tag keeps the path-bearing prose out of telemetry.
215
223
  { failureDetail: registration.tag }));
216
224
  }
217
- return seeded?.note;
225
+ return { note: seeded?.note, seededHome: seeded?.home };
218
226
  }
219
227
  function performLoginStep(client, pendingLogin, options) {
220
228
  errLine('Signing in to TinyFish...');
@@ -289,7 +297,7 @@ async function connectNativeMcpClient(client, options) {
289
297
  // A probed client authenticates inside `mcp add` too, so a failure there is either.
290
298
  state.stage =
291
299
  loginArgs && !probeAuthenticated ? 'registration' : 'registration_or_authentication';
292
- const seededNote = performMcpAdd(client, options, plan, telemetry.attemptId);
300
+ const { note: seededNote, seededHome } = performMcpAdd(client, options, plan, telemetry.attemptId);
293
301
  telemetry.track('checkpoint', { phase: 'registered' });
294
302
  const { authMode, signInDeferred } = completeAuthFlow(client, options, state, telemetry, {
295
303
  useKeyAuth,
@@ -301,7 +309,7 @@ async function connectNativeMcpClient(client, options) {
301
309
  state.authMode = authMode;
302
310
  // Registration/OAuth make MCP work; later steps are cosmetic and must not fail the attempt.
303
311
  settle(state, telemetry, 'completed', { authMode });
304
- await runPostInstallSteps(client, options, state, telemetry, signInDeferred);
312
+ await runPostInstallSteps(client, options, state, telemetry, signInDeferred, seededHome);
305
313
  });
306
314
  return state.authMode;
307
315
  }
@@ -317,7 +325,7 @@ function connectedLine(client, signInDeferred) {
317
325
  `${RELOAD_ACTION[client.connectClient]} to pick up the tools.`);
318
326
  }
319
327
  // Runs after `completed` settled: steps fail alone, the rest still run.
320
- async function runPostInstallSteps(client, options, state, telemetry, signInDeferred) {
328
+ async function runPostInstallSteps(client, options, state, telemetry, signInDeferred, seededHome) {
321
329
  let failed = false;
322
330
  // Modelled on upgrade's attempt(); interrupts rethrow and abandon remaining steps.
323
331
  const attempt = async (stage, run) => {
@@ -348,6 +356,7 @@ async function runPostInstallSteps(client, options, state, telemetry, signInDefe
348
356
  extraPostInstall({
349
357
  apiKey: validatedApiKey(options.apiKey),
350
358
  verbose: options.verbose ?? false,
359
+ seededHome,
351
360
  });
352
361
  telemetry.track('checkpoint', {
353
362
  phase: 'plugin_installed',
@@ -501,6 +510,31 @@ export async function connectOpenClaw(options) {
501
510
  }
502
511
  });
503
512
  }
513
+ const PI_RELOAD_COPY = 'TinyFish is configured in pi. Restart pi, then run `/mcp-auth tinyfish` there if it asks you ' +
514
+ 'to sign in.';
515
+ // The skill is the working path either way, so the note must not read as a failure.
516
+ const PI_ADAPTER_DORMANT_NOTE = 'Note: pi has no built-in MCP support, so the entry in its mcp.json stays dormant until you ' +
517
+ `run \`${PI_ADAPTER_INSTALL_COMMAND}\`. The TinyFish skill works without it.`;
518
+ // Only the dormant case was spoken for, leaving a working MCP install silent.
519
+ const PI_ADAPTER_LIVE_NOTE = 'pi-mcp-adapter is installed, so pi loads the TinyFish MCP tools as well as the skill once ' +
520
+ 'you restart it.';
521
+ function piAdapterNote() {
522
+ const state = piMcpAdapterState();
523
+ if (state === 'installed')
524
+ return PI_ADAPTER_LIVE_NOTE;
525
+ return state === 'absent' ? PI_ADAPTER_DORMANT_NOTE : undefined;
526
+ }
527
+ // The `skills` CLI writes to the default dir even when pi reads another one.
528
+ function piPostConnectNotes() {
529
+ const notes = [piAdapterNote()];
530
+ const skills = piSkillDirMismatch();
531
+ if (skills) {
532
+ notes.push(`Note: PI_CODING_AGENT_DIR points pi at ${skills.readFrom}, but the TinyFish skill ` +
533
+ `installed to ${skills.installedTo}. Copy or symlink it across for pi to load it.`);
534
+ }
535
+ const printable = notes.filter(Boolean);
536
+ return printable.length > 0 ? printable.join('\n') : undefined;
537
+ }
504
538
  // Only interactive omp wires the OAuth handler; headless cannot sign in.
505
539
  const OMP_KEYLESS_COPY = 'TinyFish is configured in omp. Open omp and sign in to TinyFish when prompted, or run ' +
506
540
  '`/mcp reauth tinyfish` there.';
@@ -535,6 +569,22 @@ const CONFIG_FILE_HARNESSES = {
535
569
  reload: OMP_KEYLESS_COPY,
536
570
  },
537
571
  },
572
+ pi: {
573
+ skillTarget: { skillAgent: 'pi', displayName: HARNESS_DISPLAY_NAMES.pi },
574
+ registeredUrl: () => readPiTinyfishEntry().url,
575
+ write: writePiMcpConfig,
576
+ configPath: piMcpPath,
577
+ // No install deeplink exists; the copy carries the restart instead.
578
+ tryLaunch: () => false,
579
+ postConnectNote: piPostConnectNotes,
580
+ copy: {
581
+ verified: "TinyFish is connected and verified (health+auth). Restart pi and it's live.",
582
+ authFailed: (reason) => `TinyFish is configured, but the authenticated check failed (${reason}). ` +
583
+ 'Fix: rotate/re-enter your key with `tinyfish auth login`, then re-run: tinyfish connect pi',
584
+ launched: PI_RELOAD_COPY,
585
+ reload: PI_RELOAD_COPY,
586
+ },
587
+ },
538
588
  };
539
589
  /** Writes the harness's MCP config file itself; no `mcp add` exists. */
540
590
  async function connectConfigFileHarness(harness, options) {
@@ -628,6 +678,9 @@ async function connectConfigFileHarness(harness, options) {
628
678
  else {
629
679
  errLine(spec.copy.reload);
630
680
  }
681
+ const note = spec.postConnectNote?.();
682
+ if (note)
683
+ errLine(note);
631
684
  settle(state, telemetry, 'completed', { authMode: resolvedKey ? AuthMode.ApiKey : 'deferred' });
632
685
  });
633
686
  }
@@ -643,6 +696,7 @@ function launchCursorDeeplink(mcpUrl) {
643
696
  /** Total over the non-native harnesses: a new one fails to compile until wired here. */
644
697
  const LAUNCH_OVERRIDES = {
645
698
  omp: launchOmpWalkthrough,
699
+ pi: launchPiWalkthrough,
646
700
  openclaw: launchOpenClawWalkthrough,
647
701
  // Cursor has no chat CLI to launch.
648
702
  cursor: () => {
@@ -683,6 +737,19 @@ const CONNECTOR_OVERRIDES = {
683
737
  }
684
738
  return undefined;
685
739
  },
740
+ pi: async (options) => {
741
+ await connectConfigFileHarness('pi', options);
742
+ if (options.launch) {
743
+ try {
744
+ await launchPiWalkthrough();
745
+ }
746
+ catch (error) {
747
+ errLine(error instanceof Error ? error.message : String(error));
748
+ errLine(finishSetupHint('pi'));
749
+ }
750
+ }
751
+ return undefined;
752
+ },
686
753
  };
687
754
  function requireNative(harness) {
688
755
  const client = NATIVE_BY_HARNESS.get(harness);
@@ -1,5 +1,5 @@
1
1
  import { CLI_VERSION } from '../lib/constants.js';
2
- import { checkAuthCall, checkCliVersion, checkConnectivity, checkCredential, checkHermesPlugin, checkRegistration, provesHarnessReach, verifyHarnessKey, } from '../lib/doctor-checks.js';
2
+ import { checkAuthCall, checkCliVersion, checkConnectivity, checkCredential, checkHermesPlugin, checkPiAdapter, checkRegistration, provesHarnessReach, verifyHarnessKey, } from '../lib/doctor-checks.js';
3
3
  import { DOCTOR_COULD_NOT_RUN, DOCTOR_INVALID_INPUT, DOCTOR_SCHEMA_VERSION, doctorReportSchema, exitCodeFor, renderPretty, verdictFor, } from '../lib/doctor-report.js';
4
4
  import { applyRepairs, repairsFor } from '../lib/doctor-repairs.js';
5
5
  import { couldNotRunPayload, telemetryKey, telemetryPayload } from '../lib/doctor-telemetry.js';
@@ -30,11 +30,13 @@ export async function runDoctor(options) {
30
30
  ]);
31
31
  const hermesStatus = statuses.find((status) => status.harness === 'hermes');
32
32
  const hermesPlugin = hermesStatus ? checkHermesPlugin(hermesStatus) : undefined;
33
+ const piStatus = statuses.find((status) => status.harness === 'pi');
33
34
  const checks = [
34
35
  checkCliVersion(),
35
36
  connectivity,
36
37
  ...statuses.map((status, i) => checkRegistration(status, options.mcpUrl, keyAuths[i], !credential.key)),
37
38
  ...(hermesPlugin ? [hermesPlugin.check] : []),
39
+ ...(piStatus ? [checkPiAdapter(piStatus)] : []),
38
40
  credential.check,
39
41
  checkAuthCall(cliAuthResult),
40
42
  ];
@@ -9,7 +9,7 @@ const HIDDEN_ONCE_SOMETHING_WORKED = new Set([
9
9
  ]);
10
10
  /** A stale config dir is a detection signal with nothing behind it. */
11
11
  export function actionable(result) {
12
- return result.detected && result.outcome !== 'stale_config';
12
+ return (result.detected && result.outcome !== 'stale_config' && result.outcome !== 'needs_first_run');
13
13
  }
14
14
  function absentSummaryLine(result, uninstall) {
15
15
  const name = DISPLAY_NAMES[result.harness];
@@ -29,6 +29,9 @@ function skippedSummaryLine(result) {
29
29
  if (result.outcome === 'stale_config') {
30
30
  return `… ${name} — config at ${result.configPath} but no \`${HARNESS_COMMANDS[result.harness]}\` on PATH, skipped. Install ${name}, then run: ${result.fixCommand}`;
31
31
  }
32
+ if (result.outcome === 'needs_first_run') {
33
+ return `… ${name} — installed but never run, so ${result.configPath} does not exist yet, skipped. Run \`${HARNESS_COMMANDS[result.harness]}\` once, then: ${result.fixCommand}`;
34
+ }
32
35
  if (result.outcome === 'not_reached') {
33
36
  return `… ${name} — not reached, the run stopped early. Fix: ${result.fixCommand}`;
34
37
  }
@@ -3,6 +3,7 @@ import { NATIVE_BY_HARNESS, openclawSkillUninstall } from './connect-clients.js'
3
3
  import { ConnectInterruptedError, spawnRemoval } from './connect-runtime.js';
4
4
  import { removeCursorMcpServer, planCursorWrite } from './cursor-config.js';
5
5
  import { planOmpWrite, removeOmpMcpServer } from './omp-config.js';
6
+ import { piMcpPath, planPiWrite, removePiMcpServer } from './pi-config.js';
6
7
  import { HERMES_KEY_VAR, hermesEnvPath, removeHermesKey, resolveHermesHome } from './hermes-env.js';
7
8
  import { clearHermesWebBackends, HERMES_PLUGIN_SHA } from './hermes-plugin.js';
8
9
  import { HARNESS_DISPLAY_NAMES as DISPLAY_NAMES } from './harness-detect.js';
@@ -14,6 +15,8 @@ export function planText(harness, mcpUrl, apiKey) {
14
15
  return planCursorWrite(mcpUrl, apiKey);
15
16
  if (harness === 'omp')
16
17
  return planOmpWrite(mcpUrl, apiKey);
18
+ if (harness === 'pi')
19
+ return planPiWrite(mcpUrl, apiKey);
17
20
  // Only the keyed path seeds the .env; a keyless Hermes install writes nothing.
18
21
  if (harness === 'hermes' && apiKey) {
19
22
  return (`would run \`tinyfish connect hermes\` (harness-owned MCP write; the CLI writes ` +
@@ -29,6 +32,9 @@ export function uninstallPlanText(harness) {
29
32
  if (harness === 'omp') {
30
33
  return "would remove the tinyfish entry from omp's mcp.json (located via `omp config path`)";
31
34
  }
35
+ if (harness === 'pi') {
36
+ return `would remove the tinyfish entry from ${piMcpPath()}`;
37
+ }
32
38
  if (!uninstallRuns(harness)) {
33
39
  return `would print \`${uninstallPointer(harness)}\` (no removal command exists)`;
34
40
  }
@@ -62,7 +68,8 @@ function uninstallPointer(harness) {
62
68
  }
63
69
  /** `mcp remove` clears neither the key nor the backends we wrote. */
64
70
  function clearHermesLocalState() {
65
- const home = resolveHermesHome();
71
+ const resolved = resolveHermesHome();
72
+ const home = typeof resolved === 'string' ? resolved : undefined;
66
73
  const advisories = [clearHermesEnvKey(home), clearHermesBackends(home)].filter((advisory) => advisory !== undefined);
67
74
  return advisories.length > 0 ? advisories.join(' ') : undefined;
68
75
  }
@@ -186,6 +193,10 @@ const MCP_JSON_UNINSTALLS = {
186
193
  remove: removeOmpMcpServer,
187
194
  fix: "Fix omp's mcp.json (at `omp config path`) by hand, then re-run: tinyfish connect --all --uninstall",
188
195
  },
196
+ pi: {
197
+ remove: removePiMcpServer,
198
+ fix: `Fix ${piMcpPath()} by hand, then re-run: tinyfish connect --all --uninstall`,
199
+ },
189
200
  };
190
201
  function uninstallMcpJson(detection, spec) {
191
202
  let result;
@@ -1,6 +1,6 @@
1
- import { type Harness } from './harness-detect.js';
1
+ import { type Harness, type HarnessDetection } from './harness-detect.js';
2
2
  import { type VerifyDepth } from './verify.js';
3
- export type Outcome = 'not_detected' | 'not_installed' | 'stale_config' | 'installed' | 'failed' | 'harness_too_old' | 'interrupted' | 'auth_pending' | 'no_tty_auth_skip' | 'uninstalled' | 'uninstall_noop' | 'uninstall_pointer' | 'dry_run' | 'not_reached' | 'deselected';
3
+ export type Outcome = 'not_detected' | 'not_installed' | 'stale_config' | 'needs_first_run' | 'installed' | 'failed' | 'harness_too_old' | 'interrupted' | 'auth_pending' | 'no_tty_auth_skip' | 'uninstalled' | 'uninstall_noop' | 'uninstall_pointer' | 'dry_run' | 'not_reached' | 'deselected';
4
4
  export type HarnessResultBase = {
5
5
  harness: Harness;
6
6
  detected: boolean;
@@ -34,5 +34,7 @@ export interface ConnectAllOptions {
34
34
  /** Setup-page id riding in on --url; joins "copied the command" to this run. */
35
35
  attemptId?: string;
36
36
  }
37
+ /** The harness has never created its dir, so connecting would build one it does not own. */
38
+ export declare function needsFirstRun(detection: HarnessDetection): boolean;
37
39
  export declare function runConnectAll(opts: ConnectAllOptions): Promise<number>;
38
40
  export declare const FIRST_TASK_PROMPT: string;
@@ -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
  }