@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.
@@ -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'];
@@ -0,0 +1,26 @@
1
+ import { type McpJsonServerEntry, type McpJsonWriteResult } from './mcp-json-config.js';
2
+ /** Mirrors pi's own getAgentDir: the override wins, else `~/.pi/agent`. */
3
+ export declare function piAgentDir(): string;
4
+ export declare function piMcpPath(): string;
5
+ /** Reports the header's shape, never its value. */
6
+ export declare function readPiTinyfishEntry(): McpJsonServerEntry;
7
+ /** Merges only the `tinyfish` key. */
8
+ export declare function writePiMcpConfig(mcpUrl: string, apiKey?: string): McpJsonWriteResult;
9
+ /** Dry-run description of the pending write; touches nothing. */
10
+ export declare function planPiWrite(mcpUrl: string, apiKey?: string): string;
11
+ /** Removes only the `tinyfish` key. */
12
+ export declare function removePiMcpServer(): McpJsonWriteResult;
13
+ export declare const PI_ADAPTER_INSTALL_COMMAND = "pi install npm:pi-mcp-adapter";
14
+ /** `unknown` keeps a settings file we could not read apart from one that lists no adapter. */
15
+ export type PiAdapterState = 'installed' | 'absent' | 'unknown';
16
+ /** Core pi has no MCP, so without this package the entry we write is never read. */
17
+ export declare function piMcpAdapterState(cwd?: string): PiAdapterState;
18
+ /** Tests share one process; the probe result must not. */
19
+ export declare function resetPiBinaryCache(): void;
20
+ /** npm ships an unrelated `pi` that prints `3`; only a semver answer is the harness. */
21
+ export declare function piBinaryIsPi(): boolean;
22
+ /** `skills` hardcodes the default dir, so an override leaves the skill where pi will not look. */
23
+ export declare function piSkillDirMismatch(): {
24
+ installedTo: string;
25
+ readFrom: string;
26
+ } | undefined;
@@ -0,0 +1,111 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import spawn from 'cross-spawn';
5
+ import { z } from 'zod';
6
+ import { HARNESS_PROBE_TIMEOUT_MS } from './constants.js';
7
+ import { planWrite, readTinyfishEntry, removeServer, writeMcpConfig, } from './mcp-json-config.js';
8
+ // pi expands `~` and `~/`; writing one literally would mkdir '~'.
9
+ function expandTilde(dir) {
10
+ if (dir === '~')
11
+ return os.homedir();
12
+ // nosemgrep: path-join-resolve-traversal -- $PI_CODING_AGENT_DIR names the caller's own dir, resolved as pi does
13
+ return dir.startsWith('~/') ? path.join(os.homedir(), dir.slice(2)) : dir;
14
+ }
15
+ /** Mirrors pi's own getAgentDir: the override wins, else `~/.pi/agent`. */
16
+ export function piAgentDir() {
17
+ const override = process.env['PI_CODING_AGENT_DIR'];
18
+ return override ? path.resolve(expandTilde(override)) : path.join(os.homedir(), '.pi', 'agent');
19
+ }
20
+ // Resolved per call, not cached: nothing here spawns, so drift is impossible.
21
+ const PI_MCP_TARGET = {
22
+ serverKey: 'tinyfish',
23
+ dir: piAgentDir,
24
+ file: () => path.join(piAgentDir(), 'mcp.json'),
25
+ };
26
+ export function piMcpPath() {
27
+ return PI_MCP_TARGET.file();
28
+ }
29
+ /** Reports the header's shape, never its value. */
30
+ export function readPiTinyfishEntry() {
31
+ return readTinyfishEntry(PI_MCP_TARGET);
32
+ }
33
+ /** Merges only the `tinyfish` key. */
34
+ export function writePiMcpConfig(mcpUrl, apiKey) {
35
+ return writeMcpConfig(PI_MCP_TARGET, mcpUrl, apiKey);
36
+ }
37
+ /** Dry-run description of the pending write; touches nothing. */
38
+ export function planPiWrite(mcpUrl, apiKey) {
39
+ return planWrite(PI_MCP_TARGET, mcpUrl, apiKey);
40
+ }
41
+ /** Removes only the `tinyfish` key. */
42
+ export function removePiMcpServer() {
43
+ return removeServer(PI_MCP_TARGET);
44
+ }
45
+ export const PI_ADAPTER_INSTALL_COMMAND = 'pi install npm:pi-mcp-adapter';
46
+ // Matches `npm:pi-mcp-adapter`, a pinned version, and git or path installs ending in the name.
47
+ const ADAPTER_SOURCE = /(?:^|[/:])pi-mcp-adapter(?:@|$)/;
48
+ // pi's PackageSource is a bare source or an object carrying it; `pi install` writes both.
49
+ const packageSourceSchema = z.union([z.string(), z.object({ source: z.string() })]);
50
+ const settingsSchema = z.object({ packages: z.array(packageSourceSchema).optional() });
51
+ function adapterInSettings(file) {
52
+ let raw;
53
+ try {
54
+ raw = fs.readFileSync(file, 'utf8');
55
+ }
56
+ catch (e) {
57
+ // No settings file means nothing is installed; any other read failure tells us nothing.
58
+ return e?.code === 'ENOENT' ? 'absent' : 'unknown';
59
+ }
60
+ let parsed;
61
+ try {
62
+ parsed = JSON.parse(raw);
63
+ }
64
+ catch {
65
+ return 'unknown';
66
+ }
67
+ const settings = settingsSchema.safeParse(parsed);
68
+ if (!settings.success)
69
+ return 'unknown';
70
+ const sources = (settings.data.packages ?? []).map((p) => (typeof p === 'string' ? p : p.source));
71
+ return sources.some((source) => ADAPTER_SOURCE.test(source)) ? 'installed' : 'absent';
72
+ }
73
+ /** Core pi has no MCP, so without this package the entry we write is never read. */
74
+ export function piMcpAdapterState(cwd = process.cwd()) {
75
+ // `pi install -l` records the adapter project-locally, where pi reads it just the same.
76
+ const scopes = [
77
+ path.join(piAgentDir(), 'settings.json'),
78
+ path.join(cwd, '.pi', 'settings.json'), // nosemgrep: path-join-resolve-traversal -- fixed names under the caller's cwd
79
+ ].map(adapterInSettings);
80
+ if (scopes.includes('installed'))
81
+ return 'installed';
82
+ return scopes.includes('unknown') ? 'unknown' : 'absent';
83
+ }
84
+ // Cached: detection runs more than once per command and the answer cannot change mid-run.
85
+ let cachedBinaryIsPi;
86
+ /** Tests share one process; the probe result must not. */
87
+ export function resetPiBinaryCache() {
88
+ cachedBinaryIsPi = undefined;
89
+ }
90
+ /** npm ships an unrelated `pi` that prints `3`; only a semver answer is the harness. */
91
+ export function piBinaryIsPi() {
92
+ cachedBinaryIsPi ??= { value: probeVersion() };
93
+ return cachedBinaryIsPi.value;
94
+ }
95
+ function probeVersion() {
96
+ const result = spawn.sync('pi', ['--version'], {
97
+ encoding: 'utf8',
98
+ timeout: HARNESS_PROBE_TIMEOUT_MS,
99
+ });
100
+ if (result.error || result.status !== 0)
101
+ return false;
102
+ return /^\d+\.\d+\.\d+/.test((result.stdout ?? '').trim());
103
+ }
104
+ /** `skills` hardcodes the default dir, so an override leaves the skill where pi will not look. */
105
+ export function piSkillDirMismatch() {
106
+ const agentDir = piAgentDir();
107
+ const fallback = path.join(os.homedir(), '.pi', 'agent');
108
+ if (agentDir === fallback)
109
+ return undefined;
110
+ return { installedTo: path.join(fallback, 'skills'), readFrom: path.join(agentDir, 'skills') };
111
+ }
@@ -8,6 +8,7 @@ import { HARNESS_PROBE_TIMEOUT_MS } from './constants.js';
8
8
  import { errLine } from './output.js';
9
9
  import { readCursorTinyfishEntry } from './cursor-config.js';
10
10
  import { readOmpTinyfishEntry } from './omp-config.js';
11
+ import { readPiTinyfishEntry } from './pi-config.js';
11
12
  import { readHermesKey, resolveHermesHome } from './hermes-env.js';
12
13
  import { readHermesEntry } from './hermes-config.js';
13
14
  import { detectInstalledHarnesses, harnessConfigPath, harnessDisplayPath, AuthMode, Registered, } from './harness-detect.js';
@@ -315,6 +316,30 @@ function probeOmp() {
315
316
  ...keyVerdict,
316
317
  };
317
318
  }
319
+ // Whether anything reads the entry is the pi-adapter check's question, not this one.
320
+ function probePi() {
321
+ const entry = readPiTinyfishEntry();
322
+ if (entry.error) {
323
+ return {
324
+ registered: Registered.Unknown,
325
+ authMode: AuthMode.Unknown,
326
+ reason: 'mcp.json exists but could not be read or parsed',
327
+ };
328
+ }
329
+ if (!entry.present)
330
+ return { registered: Registered.No, authMode: AuthMode.Unknown };
331
+ const keyVerdict = entry.keyTemplateVar
332
+ ? envKeyVerdict(entry.keyTemplateVar)
333
+ : entry.keyMatchesCliKey
334
+ ? { keyMatchesCliKey: true }
335
+ : {};
336
+ return {
337
+ registered: Registered.Yes,
338
+ authMode: entry.hasApiKeyHeader ? AuthMode.ApiKey : AuthMode.Unknown,
339
+ ...(entry.url ? { registeredUrl: entry.url } : {}),
340
+ ...keyVerdict,
341
+ };
342
+ }
318
343
  // Both list commands print this header in every state, including "no servers configured".
319
344
  // Exit 0 with output we cannot recognise is not evidence of absence: reporting `no` there would
320
345
  // earn a connect repair off a parse failure, which is the bug class this command exists to kill.
@@ -337,10 +362,11 @@ function fromMcpList(command, output, registeredMode, urlPattern) {
337
362
  }
338
363
  // `hermes mcp list` only pretty-prints this file, so connect's gate reads it too.
339
364
  function probeHermes() {
340
- const home = resolveHermesHome();
341
- if (!home) {
365
+ const resolved = resolveHermesHome();
366
+ if (typeof resolved !== 'string') {
342
367
  return unverified('`hermes dump` did not report a home directory');
343
368
  }
369
+ const home = resolved;
344
370
  const entry = readHermesEntry(home);
345
371
  if (entry.state === 'unreadable' || entry.state === 'unparseable') {
346
372
  return unverified(`Hermes' config.yaml could not be ${entry.state === 'unreadable' ? 'read' : 'parsed'}`);
@@ -479,6 +505,7 @@ const PROBES = {
479
505
  omp: probeOmp,
480
506
  openclaw: probeOpenClaw,
481
507
  opencode: probeOpencode,
508
+ pi: probePi,
482
509
  };
483
510
  /** Never throws; a failed probe is `unknown` with a reason, so no caller can read it as healthy. */
484
511
  export function detectRegistrations(harnesses) {
@@ -29,6 +29,7 @@ declare const harnessResultSchema: z.ZodObject<{
29
29
  codex: "codex";
30
30
  hermes: "hermes";
31
31
  opencode: "opencode";
32
+ pi: "pi";
32
33
  "claude-code": "claude-code";
33
34
  }>;
34
35
  detected: z.ZodBoolean;
@@ -63,6 +64,7 @@ export declare const setupCompletedPayloadSchema: z.ZodObject<{
63
64
  codex: "codex";
64
65
  hermes: "hermes";
65
66
  opencode: "opencode";
67
+ pi: "pi";
66
68
  "claude-code": "claude-code";
67
69
  }>;
68
70
  detected: z.ZodBoolean;
@@ -124,6 +126,7 @@ declare const doctorCompletedPayloadSchema: z.ZodObject<{
124
126
  codex: "codex";
125
127
  hermes: "hermes";
126
128
  opencode: "opencode";
129
+ pi: "pi";
127
130
  "claude-code": "claude-code";
128
131
  all: "all";
129
132
  }>;
@@ -137,6 +140,7 @@ declare const doctorCompletedPayloadSchema: z.ZodObject<{
137
140
  codex: "codex";
138
141
  hermes: "hermes";
139
142
  opencode: "opencode";
143
+ pi: "pi";
140
144
  "claude-code": "claude-code";
141
145
  }>>;
142
146
  harnesses: z.ZodArray<z.ZodObject<{
@@ -148,6 +152,7 @@ declare const doctorCompletedPayloadSchema: z.ZodObject<{
148
152
  codex: "codex";
149
153
  hermes: "hermes";
150
154
  opencode: "opencode";
155
+ pi: "pi";
151
156
  "claude-code": "claude-code";
152
157
  }>;
153
158
  detected: z.ZodBoolean;
@@ -183,6 +188,7 @@ declare const doctorCouldNotRunPayloadSchema: z.ZodObject<{
183
188
  codex: "codex";
184
189
  hermes: "hermes";
185
190
  opencode: "opencode";
191
+ pi: "pi";
186
192
  "claude-code": "claude-code";
187
193
  all: "all";
188
194
  }>;
@@ -219,6 +225,7 @@ declare const doctorPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
219
225
  codex: "codex";
220
226
  hermes: "hermes";
221
227
  opencode: "opencode";
228
+ pi: "pi";
222
229
  "claude-code": "claude-code";
223
230
  all: "all";
224
231
  }>;
@@ -232,6 +239,7 @@ declare const doctorPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
232
239
  codex: "codex";
233
240
  hermes: "hermes";
234
241
  opencode: "opencode";
242
+ pi: "pi";
235
243
  "claude-code": "claude-code";
236
244
  }>>;
237
245
  harnesses: z.ZodArray<z.ZodObject<{
@@ -243,6 +251,7 @@ declare const doctorPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
243
251
  codex: "codex";
244
252
  hermes: "hermes";
245
253
  opencode: "opencode";
254
+ pi: "pi";
246
255
  "claude-code": "claude-code";
247
256
  }>;
248
257
  detected: z.ZodBoolean;
@@ -277,6 +286,7 @@ declare const doctorPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
277
286
  codex: "codex";
278
287
  hermes: "hermes";
279
288
  opencode: "opencode";
289
+ pi: "pi";
280
290
  "claude-code": "claude-code";
281
291
  all: "all";
282
292
  }>;
@@ -1,5 +1,6 @@
1
1
  import { type InstallOptions } from './cli-install.js';
2
2
  import { type SkillAgent } from './connect-clients.js';
3
+ export declare const SKILLS_CLI_PACKAGE = "skills@1.5.15";
3
4
  export declare function installWebSkill(client: {
4
5
  skillAgent?: SkillAgent;
5
6
  displayName: string;