@tiny-fish/cli 0.39.1-next.309 → 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.
Files changed (51) hide show
  1. package/dist/commands/connect.d.ts +2 -14
  2. package/dist/commands/connect.js +109 -75
  3. package/dist/commands/doctor.js +3 -1
  4. package/dist/commands/profile.d.ts +0 -3
  5. package/dist/commands/profile.js +7 -22
  6. package/dist/commands/run.js +20 -40
  7. package/dist/commands/runs.js +24 -58
  8. package/dist/lib/auth.d.ts +1 -3
  9. package/dist/lib/auth.js +1 -1
  10. package/dist/lib/claude-config.d.ts +0 -4
  11. package/dist/lib/claude-config.js +4 -4
  12. package/dist/lib/cli-install.d.ts +0 -1
  13. package/dist/lib/cli-install.js +0 -1
  14. package/dist/lib/client.js +82 -147
  15. package/dist/lib/connect-all-auth.js +1 -1
  16. package/dist/lib/connect-all-summary.js +4 -1
  17. package/dist/lib/connect-all-uninstall.js +12 -1
  18. package/dist/lib/connect-all.d.ts +4 -2
  19. package/dist/lib/connect-all.js +21 -32
  20. package/dist/lib/connect-clients.d.ts +11 -8
  21. package/dist/lib/connect-clients.js +53 -13
  22. package/dist/lib/connect-fallback.d.ts +2 -1
  23. package/dist/lib/connect-picker.d.ts +3 -2
  24. package/dist/lib/connect-runtime.js +4 -6
  25. package/dist/lib/doctor-checks.d.ts +1 -0
  26. package/dist/lib/doctor-checks.js +38 -3
  27. package/dist/lib/doctor-report.d.ts +6 -0
  28. package/dist/lib/harness-detect.d.ts +2 -0
  29. package/dist/lib/harness-detect.js +11 -3
  30. package/dist/lib/harness-spec.d.ts +20 -39
  31. package/dist/lib/harness-spec.js +12 -31
  32. package/dist/lib/harness.js +2 -0
  33. package/dist/lib/hermes-config.d.ts +3 -0
  34. package/dist/lib/hermes-config.js +8 -4
  35. package/dist/lib/hermes-env.d.ts +7 -2
  36. package/dist/lib/hermes-env.js +7 -6
  37. package/dist/lib/hermes-plugin.d.ts +4 -0
  38. package/dist/lib/hermes-plugin.js +17 -9
  39. package/dist/lib/output.d.ts +2 -0
  40. package/dist/lib/output.js +9 -0
  41. package/dist/lib/pi-config.d.ts +26 -0
  42. package/dist/lib/pi-config.js +111 -0
  43. package/dist/lib/registration-detect.d.ts +0 -1
  44. package/dist/lib/registration-detect.js +60 -81
  45. package/dist/lib/setup-telemetry.d.ts +13 -7
  46. package/dist/lib/setup-telemetry.js +5 -5
  47. package/dist/lib/skill-install.d.ts +1 -0
  48. package/dist/lib/skill-install.js +43 -44
  49. package/dist/lib/types.d.ts +4 -3
  50. package/dist/program.js +2 -3
  51. package/package.json +1 -2
@@ -7,7 +7,6 @@ import { installTinyFishCli, } from './cli-install.js';
7
7
  import { authGate, mapConnectError, OAUTH_SIGN_IN_TIMEOUT_MS } from './connect-all-auth.js';
8
8
  import { actionable, computeExitCode, reloadHint, renderSummary } from './connect-all-summary.js';
9
9
  import { planText, uninstallHarness, uninstallPlanText } from './connect-all-uninstall.js';
10
- import { NATIVE_BY_HARNESS } from './connect-clients.js';
11
10
  import { createStdinPrompt, runCliFallback } from './connect-fallback.js';
12
11
  import { gateApiKey } from './connect-preflight.js';
13
12
  import { ConnectInterruptedError } from './connect-runtime.js';
@@ -19,21 +18,6 @@ import { errLine, outLine, setErrIndent } from './output.js';
19
18
  import { sendSetupCompleted, sendSetupStarted, } from './setup-telemetry.js';
20
19
  import { verifyMcpAuth, verifyMcpHealth } from './verify.js';
21
20
  const STEP_INDENT = ' ';
22
- // Derived: a native add carries the key, or the CLI holds it itself.
23
- const KEY_AUTH_CAPABLE = new Set(ALL_HARNESSES.filter((harness) => !!NATIVE_BY_HARNESS.get(harness)?.keyAuth || harnessSpec(harness).keyHeldByCli));
24
- async function runHarnessConnect(harness, opts, extras) {
25
- return connectHarness(harness, {
26
- apiKey: opts.apiKey,
27
- mcpUrl: opts.mcpUrl,
28
- launch: false,
29
- keyAuthOnly: extras.keyAuthOnly,
30
- authTimeoutMs: extras.authTimeoutMs,
31
- hoistedCliInstall: extras.hoistedCliInstall,
32
- onPostInstallFailed: extras.onPostInstallFailed,
33
- verbose: opts.verbose,
34
- deferOutro: extras.deferOutro,
35
- });
36
- }
37
21
  /** Undefined means Ctrl+C: the caller stops the whole run. */
38
22
  function runHoistedCliInstall(verbose) {
39
23
  const startedAt = Date.now();
@@ -90,8 +74,8 @@ async function processHarness(detection, opts, prompt, oauthBudget, hoistedCliIn
90
74
  return settled;
91
75
  const isTTY = detectHumanInitiated();
92
76
  const apiKey = validatedApiKey(opts.apiKey);
93
- // A key only leaves the own-OAuth set where the harness can actually carry one.
94
- const keyed = !!apiKey && KEY_AUTH_CAPABLE.has(harness);
77
+ // All harnesses carry a key; one that cannot fails harness_too_old.
78
+ const keyed = !!apiKey;
95
79
  const fixCommand = `tinyfish connect ${harness}`;
96
80
  const gate = await authGate(base, oauthBudget, fixCommand, { prompt, isTTY, keyed });
97
81
  if (gate.result)
@@ -103,7 +87,11 @@ async function processHarness(detection, opts, prompt, oauthBudget, hoistedCliIn
103
87
  let postInstallFailed = false;
104
88
  try {
105
89
  // A keyed install too old for key auth fails, no browser.
106
- authMode = await runHarnessConnect(harness, opts, {
90
+ authMode = await connectHarness(harness, {
91
+ apiKey: opts.apiKey,
92
+ mcpUrl: opts.mcpUrl,
93
+ launch: false,
94
+ verbose: opts.verbose,
107
95
  keyAuthOnly: keyed && !isTTY,
108
96
  authTimeoutMs: headlessAuth ? OAUTH_SIGN_IN_TIMEOUT_MS : undefined,
109
97
  hoistedCliInstall,
@@ -139,13 +127,19 @@ function noWorkOutcome(d, uninstall) {
139
127
  return 'not_detected';
140
128
  if (!uninstall && isStaleConfig(d))
141
129
  return 'stale_config';
130
+ if (!uninstall && needsFirstRun(d))
131
+ return 'needs_first_run';
142
132
  return undefined;
143
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
+ }
144
138
  /** A config-writing connect needs no binary; every other harness spawns one. */
145
139
  function isStaleConfig(detection) {
146
140
  const spec = harnessSpec(detection.harness);
147
- // Cursor's IDE reads mcp.json binary-less; omp's path needs the binary.
148
- 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;
149
143
  return detection.detected && !detection.binaryOnPath && !binaryless;
150
144
  }
151
145
  async function resolvePick(detections, opts, prompt) {
@@ -153,7 +147,7 @@ async function resolvePick(detections, opts, prompt) {
153
147
  let mode = 'all';
154
148
  // Before willAttempt/cliNeeded: a cursor-only pick must not hoist the CLI.
155
149
  // A stale config dir has no binary to connect, so never offer it as a choice.
156
- const offerable = detections.filter((d) => d.detected && !isStaleConfig(d));
150
+ const offerable = detections.filter((d) => d.detected && !isStaleConfig(d) && !needsFirstRun(d));
157
151
  if (opts.pick && offerable.length > 0) {
158
152
  const connected = new Set(Object.keys(loadConfig().connect ?? {}));
159
153
  const pick = await pickHarnesses(offerable, connected, { prompt });
@@ -248,13 +242,14 @@ async function connectLoop(detections, opts, deselected, io) {
248
242
  if (results.some((r) => r.harness === detection.harness))
249
243
  continue;
250
244
  const { harness, detected, configPath } = detection;
251
- 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);
252
247
  results.push({
253
248
  harness,
254
249
  detected,
255
250
  configPath,
256
251
  installed: false,
257
- outcome: staleConfig ? 'stale_config' : detected ? 'not_reached' : 'not_detected',
252
+ outcome: noWork ?? 'not_reached',
258
253
  fixCommand: `tinyfish connect ${detection.harness}`,
259
254
  });
260
255
  }
@@ -337,10 +332,6 @@ export async function runConnectAll(opts) {
337
332
  const prompt = isTTY ? createStdinPrompt() : undefined;
338
333
  const detections = detectInstalledHarnesses();
339
334
  const gateKey = validatedApiKey(opts.apiKey);
340
- // Keyed installs first: working tools land before sign-ins can block.
341
- if (gateKey) {
342
- detections.sort((a, b) => Number(KEY_AUTH_CAPABLE.has(b.harness)) - Number(KEY_AUTH_CAPABLE.has(a.harness)));
343
- }
344
335
  const pick = await resolvePick(detections, opts, prompt);
345
336
  if (pick.cancelled) {
346
337
  // Before summary and telemetry: an explicit nothing is not a setup.
@@ -349,10 +340,7 @@ export async function runConnectAll(opts) {
349
340
  }
350
341
  const { deselected, mode } = pick;
351
342
  // Mirrors processHarness's gate: headless, only keyless CLI-login harnesses skip.
352
- const willAttempt = (harness) => !deselected.has(harness) &&
353
- (isTTY ||
354
- !harnessSpec(harness).signInViaCliLogin ||
355
- (!!gateKey && KEY_AUTH_CAPABLE.has(harness)));
343
+ const willAttempt = (harness) => !deselected.has(harness) && (isTTY || !harnessSpec(harness).signInViaCliLogin || !!gateKey);
356
344
  const hoisted = hoistCliIfNeeded(detections, opts, willAttempt);
357
345
  if (hoisted === 'interrupted') {
358
346
  errLine('Setup interrupted — run the command again to finish.');
@@ -408,6 +396,7 @@ const LAUNCHERS = [
408
396
  { harness: 'cursor', args: [FIRST_TASK_PROMPT] },
409
397
  { harness: 'claude-code', args: [FIRST_TASK_PROMPT] },
410
398
  { harness: 'omp', args: [FIRST_TASK_PROMPT] },
399
+ { harness: 'pi', args: [FIRST_TASK_PROMPT] },
411
400
  ];
412
401
  async function launchFirstTask(results) {
413
402
  const candidates = LAUNCHERS.filter((l) => results.some((r) => r.harness === l.harness && r.installed) &&
@@ -18,7 +18,9 @@ export declare const CURSOR_SKILL_TARGET: {
18
18
  readonly displayName: string;
19
19
  };
20
20
  /** The harness reads the key from its own store. */
21
- export interface SeededInstall {
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 @@ export 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;
@@ -45,8 +49,6 @@ interface BaseMcpClient extends SupportedCommand, Pick<HarnessSpec, 'skillAgent'
45
49
  seedKey?: (apiKey: string) => SeededInstall;
46
50
  };
47
51
  loginArgs?: string[];
48
- /** Keyless `mcp add` itself blocks on the browser OAuth. */
49
- oauthInAdd?: boolean;
50
52
  removals: {
51
53
  args: string[];
52
54
  label: string;
@@ -59,6 +61,7 @@ interface BaseMcpClient extends SupportedCommand, Pick<HarnessSpec, 'skillAgent'
59
61
  extraPostInstall?: (options: {
60
62
  apiKey?: string;
61
63
  verbose: boolean;
64
+ seededHome?: string;
62
65
  }) => void;
63
66
  /** Pin `extraPostInstall` installs, reported on the plugin_installed checkpoint. */
64
67
  pluginVersion?: string;
@@ -69,17 +72,16 @@ interface BaseMcpClient extends SupportedCommand, Pick<HarnessSpec, 'skillAgent'
69
72
  };
70
73
  }
71
74
  /** Keyless installs use this argv; the harness signs itself in. */
72
- export interface OauthCapableMcpClient extends BaseMcpClient {
75
+ interface OauthCapableMcpClient extends BaseMcpClient {
73
76
  keyRequired?: false;
74
77
  addArgs: (mcpUrl: string) => string[];
75
78
  }
76
79
  /** No sign-in exists here, so connect refuses a keyless install (Hermes). */
77
- export interface KeyRequiredMcpClient extends BaseMcpClient {
80
+ interface KeyRequiredMcpClient extends BaseMcpClient {
78
81
  keyRequired: true;
79
82
  keyAuth: NonNullable<BaseMcpClient['keyAuth']>;
80
83
  /** Restoring any of these three would restore the OAuth fallback. */
81
84
  addArgs?: never;
82
- oauthInAdd?: never;
83
85
  loginArgs?: never;
84
86
  }
85
87
  export type NativeMcpClient = OauthCapableMcpClient | KeyRequiredMcpClient;
@@ -89,7 +91,7 @@ export type NativeMcpClient = OauthCapableMcpClient | KeyRequiredMcpClient;
89
91
  * quotes back out. Untested on Windows.
90
92
  */
91
93
  export declare function openExternalUrl(url: string): ReturnType<typeof spawn.sync>;
92
- /** `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. */
93
95
  export declare function hermesRegistrationEnabled(home: string): {
94
96
  ok: boolean;
95
97
  detail?: string;
@@ -97,7 +99,7 @@ export declare function hermesRegistrationEnabled(home: string): {
97
99
  };
98
100
  export declare const NATIVE_MCP_CLIENTS: readonly NativeMcpClient[];
99
101
  /** Native descriptor by harness id; Cursor and OpenClaw have none. */
100
- 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>;
101
103
  export declare const OPENCLAW: SupportedCommand;
102
104
  /** "printed" = handed to the user to paste; the walkthrough was never started for them. */
103
105
  export type WalkthroughOutcome = 'launched' | 'printed';
@@ -105,4 +107,5 @@ export type WalkthroughOutcome = 'launched' | 'printed';
105
107
  type OnWalkthroughDecided = (outcome: WalkthroughOutcome) => void | Promise<void>;
106
108
  export declare function launchNativeMcpClient(client: NativeMcpClient, onDecided?: OnWalkthroughDecided): Promise<WalkthroughOutcome>;
107
109
  export declare function launchOmpWalkthrough(onDecided?: OnWalkthroughDecided): Promise<WalkthroughOutcome>;
110
+ export declare function launchPiWalkthrough(onDecided?: OnWalkthroughDecided): Promise<WalkthroughOutcome>;
108
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
  }
@@ -4,7 +4,7 @@ export declare const SEARCH_RETRY = "tinyfish search query --pretty -- \"<your q
4
4
  export declare const DEFAULT_VERIFICATION_QUERY = "today's top Hacker News story";
5
5
  export declare const MALFORMED_ENV_KEY_MESSAGE = "TINYFISH_API_KEY is set but malformed; it will override any stored key. Fix or unset it, then re-run.";
6
6
  export declare const MALFORMED_STORED_KEY_MESSAGE = "The stored TinyFish API key is malformed. Run: tinyfish auth login";
7
- export interface CliFallbackHooks {
7
+ interface CliFallbackHooks {
8
8
  onStepStart?: (stage: ConnectFailureStage) => void;
9
9
  onStepDone?: (phase: ConnectCheckpoint) => void;
10
10
  }
@@ -22,3 +22,4 @@ export interface CliFallbackOptions {
22
22
  export declare function createStdinPrompt(): Prompt;
23
23
  /** Steps print retry lines and return outcomes; only interrupts throw. */
24
24
  export declare function runCliFallback(opts: CliFallbackOptions): Promise<CliFallbackOutcome>;
25
+ export {};
@@ -1,11 +1,11 @@
1
1
  import type { Readable } from 'node:stream';
2
2
  import { type Harness, type HarnessDetection } from './harness-detect.js';
3
- export type PickSelection = Harness[] | 'cancel';
3
+ type PickSelection = Harness[] | 'cancel';
4
4
  export interface PickResult {
5
5
  selection: PickSelection;
6
6
  mode: 'pick' | 'pick_numeric';
7
7
  }
8
- export type PickerStdin = Readable & {
8
+ type PickerStdin = Readable & {
9
9
  setRawMode?: (raw: boolean) => unknown;
10
10
  };
11
11
  export interface PickerIo {
@@ -14,3 +14,4 @@ export interface PickerIo {
14
14
  prompt?: (question: string) => Promise<string>;
15
15
  }
16
16
  export declare function pickHarnesses(detections: HarnessDetection[], connectedBefore: ReadonlySet<string>, io?: PickerIo): Promise<PickResult>;
17
+ export {};
@@ -49,8 +49,6 @@ export function stageDurationOf(error) {
49
49
  const value = error?.stageDurationMs;
50
50
  return typeof value === 'number' ? value : undefined;
51
51
  }
52
- class PrerequisiteError extends ConnectStepError {
53
- }
54
52
  /** A bounded sign-in wait elapsed — unfinished, not broken. */
55
53
  export class SignInTimeoutError extends ConnectStepError {
56
54
  constructor(message, cause) {
@@ -190,7 +188,7 @@ function throwProbeFailure(client, result) {
190
188
  `${probeTimeoutMs(client) / 1000}s. Retry.`, 'timeout', { cause: result.error });
191
189
  }
192
190
  // It ran and exited non-zero, so the version is still answerable.
193
- throw new PrerequisiteError(client.supportCheck.unavailableMessage, 'harness_command_unsupported', { cause: result.error, harnessVersion: probeHarnessVersion(client) });
191
+ throw new ConnectStepError(client.supportCheck.unavailableMessage, 'harness_command_unsupported', { cause: result.error, harnessVersion: probeHarnessVersion(client) });
194
192
  }
195
193
  function supportProbeOutput(client) {
196
194
  const result = spawn.sync(client.command, client.supportCheck.args, {
@@ -201,7 +199,7 @@ function supportProbeOutput(client) {
201
199
  });
202
200
  // 127 is the shell's not-found code; a wrapper shim with no real binary behind it exits that.
203
201
  if (commandNotFound(result.error) || result.status === 127) {
204
- throw new PrerequisiteError(`${client.displayName} is not installed or not available on PATH.`, 'harness_not_installed', { cause: result.error });
202
+ throw new ConnectStepError(`${client.displayName} is not installed or not available on PATH.`, 'harness_not_installed', { cause: result.error });
205
203
  }
206
204
  if (result.error || result.status !== 0) {
207
205
  throwProbeFailure(client, result);
@@ -218,7 +216,7 @@ function requireEssential(client, output) {
218
216
  if (client.supportCheck.patterns.every((pattern) => pattern.test(output)))
219
217
  return;
220
218
  debugProbeOutput(client, output);
221
- throw new PrerequisiteError(client.supportCheck.unavailableMessage, 'harness_too_old', {
219
+ throw new ConnectStepError(client.supportCheck.unavailableMessage, 'harness_too_old', {
222
220
  harnessVersion: probeHarnessVersion(client),
223
221
  });
224
222
  }
@@ -235,7 +233,7 @@ function resolveVariant(client, output) {
235
233
  return undefined;
236
234
  // Unacknowledged policy warnings can block the install; fail before it does.
237
235
  debugProbeOutput(client, output);
238
- throw new PrerequisiteError(`${client.displayName} lists \`${unknown}\`, which this TinyFish CLI does not recognise. ` +
236
+ throw new ConnectStepError(`${client.displayName} lists \`${unknown}\`, which this TinyFish CLI does not recognise. ` +
239
237
  'Upgrade the TinyFish CLI with `tinyfish upgrade` and retry.', 'harness_too_old', { harnessVersion: probeHarnessVersion(client) });
240
238
  }
241
239
  /** Refresh paths need the advertised alternative without connect's version probe. */
@@ -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
  }