@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
@@ -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. */
@@ -67,17 +67,17 @@ export interface HarnessSpec {
67
67
  /** Printed after a successful connect, on both the launch and non-launch paths. */
68
68
  postConnectNote?: string;
69
69
  /** Sign-in is the CLI's own interactive `tinyfish auth login`. */
70
- signInViaCliLogin: boolean;
70
+ signInViaCliLogin?: true;
71
71
  /** Stored-key auth verification works against this harness's config. */
72
- canVerifyAuth: boolean;
72
+ canVerifyAuth?: true;
73
73
  /** The CLI holds or writes the key; no harness `mcp add` carries it. */
74
- keyHeldByCli: boolean;
74
+ keyHeldByCli?: true;
75
75
  /** Connect writes the MCP config file; no harness binary is spawned. */
76
- cliWritesConfig: boolean;
77
- /** Binary resolves the config path; absent binary, nothing reads it. */
78
- configPathFromBinary?: true;
76
+ cliWritesConfig?: 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
- authDeferredAtInstall: boolean;
80
+ authDeferredAtInstall?: true;
81
81
  }
82
82
  /** One entry per harness; every per-harness list derives from it. */
83
83
  export declare const HARNESS_SPECS: {
@@ -110,11 +110,6 @@ export declare const HARNESS_SPECS: {
110
110
  args: string[];
111
111
  label: string;
112
112
  }[];
113
- signInViaCliLogin: false;
114
- canVerifyAuth: false;
115
- keyHeldByCli: false;
116
- cliWritesConfig: false;
117
- authDeferredAtInstall: false;
118
113
  };
119
114
  codex: {
120
115
  command: string;
@@ -136,10 +131,6 @@ export declare const HARNESS_SPECS: {
136
131
  deferred: string;
137
132
  };
138
133
  loginArgs: string[];
139
- signInViaCliLogin: false;
140
- canVerifyAuth: false;
141
- keyHeldByCli: false;
142
- cliWritesConfig: false;
143
134
  authDeferredAtInstall: true;
144
135
  };
145
136
  cursor: {
@@ -148,11 +139,10 @@ export declare const HARNESS_SPECS: {
148
139
  configDir: string;
149
140
  reloadAction: string;
150
141
  skillAgent: "cursor";
151
- signInViaCliLogin: false;
142
+ readableWithoutBinary: true;
152
143
  canVerifyAuth: true;
153
144
  keyHeldByCli: true;
154
145
  cliWritesConfig: true;
155
- authDeferredAtInstall: false;
156
146
  };
157
147
  grok: {
158
148
  command: string;
@@ -175,11 +165,6 @@ export declare const HARNESS_SPECS: {
175
165
  viaEnv: true;
176
166
  };
177
167
  signInHint: string;
178
- signInViaCliLogin: false;
179
- canVerifyAuth: false;
180
- keyHeldByCli: false;
181
- cliWritesConfig: false;
182
- authDeferredAtInstall: false;
183
168
  };
184
169
  hermes: {
185
170
  command: string;
@@ -201,23 +186,16 @@ export declare const HARNESS_SPECS: {
201
186
  label: string;
202
187
  };
203
188
  postConnectNote: string;
204
- signInViaCliLogin: false;
205
- canVerifyAuth: false;
206
189
  keyHeldByCli: true;
207
- cliWritesConfig: false;
208
- authDeferredAtInstall: false;
209
190
  };
210
191
  omp: {
211
192
  command: string;
212
193
  displayName: string;
213
194
  configDir: string;
214
195
  reloadAction: string;
215
- configPathFromBinary: true;
216
- signInViaCliLogin: false;
217
196
  canVerifyAuth: true;
218
197
  keyHeldByCli: true;
219
198
  cliWritesConfig: true;
220
- authDeferredAtInstall: false;
221
199
  };
222
200
  openclaw: {
223
201
  command: string;
@@ -235,8 +213,6 @@ export declare const HARNESS_SPECS: {
235
213
  signInViaCliLogin: true;
236
214
  canVerifyAuth: true;
237
215
  keyHeldByCli: true;
238
- cliWritesConfig: false;
239
- authDeferredAtInstall: false;
240
216
  };
241
217
  opencode: {
242
218
  command: string;
@@ -259,11 +235,16 @@ export declare const HARNESS_SPECS: {
259
235
  loginArgs: string[];
260
236
  removals: never[];
261
237
  postConnectNote: string;
262
- signInViaCliLogin: false;
263
- canVerifyAuth: false;
264
- keyHeldByCli: false;
265
- cliWritesConfig: false;
266
- authDeferredAtInstall: false;
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;
267
248
  };
268
249
  };
269
250
  /** Derived from spec keys; one entry extends every union. */
@@ -275,4 +256,4 @@ export type NonNativeHarness = {
275
256
  [K in Harness]: 'urlStyle' extends keyof (typeof HARNESS_SPECS)[K] ? never : K;
276
257
  }[Harness];
277
258
  /** Native MCP harnesses carry add-generation fields; the rest override connect. */
278
- 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")[];
@@ -82,11 +82,6 @@ export const HARNESS_SPECS = {
82
82
  label: 'local TinyFish registration',
83
83
  },
84
84
  ],
85
- signInViaCliLogin: false,
86
- canVerifyAuth: false,
87
- keyHeldByCli: false,
88
- cliWritesConfig: false,
89
- authDeferredAtInstall: false,
90
85
  },
91
86
  codex: {
92
87
  command: 'codex',
@@ -110,10 +105,6 @@ export const HARNESS_SPECS = {
110
105
  // `--oauth-resource` duplicates the resource Codex discovers; Clerk rejects both.
111
106
  // `mcp add` still runs OAuth inline, so `loginArgs` stays a retry.
112
107
  loginArgs: ['mcp', 'login', 'tinyfish'],
113
- signInViaCliLogin: false,
114
- canVerifyAuth: false,
115
- keyHeldByCli: false,
116
- cliWritesConfig: false,
117
108
  // TODO(PF-3580): connect now probes Codex sign-in, so this over-warns a signed-in install.
118
109
  authDeferredAtInstall: true,
119
110
  },
@@ -123,11 +114,10 @@ export const HARNESS_SPECS = {
123
114
  configDir: '.cursor',
124
115
  reloadAction: 'reload the window',
125
116
  skillAgent: 'cursor',
126
- signInViaCliLogin: false,
117
+ readableWithoutBinary: true,
127
118
  canVerifyAuth: true,
128
119
  keyHeldByCli: true,
129
120
  cliWritesConfig: true,
130
- authDeferredAtInstall: false,
131
121
  },
132
122
  grok: {
133
123
  command: 'grok',
@@ -148,11 +138,6 @@ export const HARNESS_SPECS = {
148
138
  // Grok expands `${VAR}` in headers at load, so the key stays out of argv and config.toml.
149
139
  header: { name: 'Authorization', sep: ': ', valuePrefix: 'Bearer ', viaEnv: true },
150
140
  signInHint: GROK_SIGN_IN_HINT,
151
- signInViaCliLogin: false,
152
- canVerifyAuth: false,
153
- keyHeldByCli: false,
154
- cliWritesConfig: false,
155
- authDeferredAtInstall: false,
156
141
  },
157
142
  hermes: {
158
143
  command: 'hermes',
@@ -175,24 +160,17 @@ export const HARNESS_SPECS = {
175
160
  label: 'TinyFish web plugin from Hermes',
176
161
  },
177
162
  postConnectNote: HERMES_RESTART_NOTE,
178
- signInViaCliLogin: false,
179
- // Stays false: keyed already covers auth; true breaks keyless installs.
180
- canVerifyAuth: false,
163
+ // No canVerifyAuth: keyed already covers auth; true breaks keyless installs.
181
164
  keyHeldByCli: true,
182
- cliWritesConfig: false,
183
- authDeferredAtInstall: false,
184
165
  },
185
166
  omp: {
186
167
  command: 'omp',
187
168
  displayName: 'omp',
188
169
  configDir: '.omp',
189
170
  reloadAction: 'restart it',
190
- configPathFromBinary: true,
191
- signInViaCliLogin: false,
192
171
  canVerifyAuth: true,
193
172
  keyHeldByCli: true,
194
173
  cliWritesConfig: true,
195
- authDeferredAtInstall: false,
196
174
  },
197
175
  openclaw: {
198
176
  command: 'openclaw',
@@ -212,8 +190,6 @@ export const HARNESS_SPECS = {
212
190
  signInViaCliLogin: true,
213
191
  canVerifyAuth: true,
214
192
  keyHeldByCli: true,
215
- cliWritesConfig: false,
216
- authDeferredAtInstall: false,
217
193
  },
218
194
  opencode: {
219
195
  command: 'opencode',
@@ -236,11 +212,16 @@ export const HARNESS_SPECS = {
236
212
  // `opencode mcp add` upserts by name and ships no `mcp remove`.
237
213
  removals: [],
238
214
  postConnectNote: OPENCODE_MODEL_NOTE,
239
- signInViaCliLogin: false,
240
- canVerifyAuth: false,
241
- keyHeldByCli: false,
242
- cliWritesConfig: false,
243
- authDeferredAtInstall: false,
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,
244
225
  },
245
226
  };
246
227
  export const ALL_HARNESSES = Object.keys(HARNESS_SPECS);
@@ -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,6 +1,11 @@
1
- export declare const HERMES_PROBE_TIMEOUT_MS = 15000;
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
+ }
2
7
  /** Asking Hermes beats reimplementing its profile override, which we'd drift from. */
3
- export declare function resolveHermesHome(): string | undefined;
8
+ export declare function resolveHermesHome(): string | HermesHomeFailure;
4
9
  export declare const HERMES_KEY_VAR = "MCP_TINYFISH_API_KEY";
5
10
  /** Named in every message about the seeded key, so no caller rebuilds the path. */
6
11
  export declare function hermesEnvPath(home: string): string;
@@ -7,22 +7,23 @@ import { HARNESS_PROBE_TIMEOUT_MS } from './constants.js';
7
7
  import { sanitizeLine } from './output.js';
8
8
  // display_hermes_home() abbreviates under $HOME, so the value is not always absolute.
9
9
  const HERMES_HOME_LINE = /^\s*hermes_home:\s*(\S.*?)\s*$/m;
10
- // One value: the next cold-start bump must move both probes together.
11
- export const HERMES_PROBE_TIMEOUT_MS = HARNESS_PROBE_TIMEOUT_MS;
12
10
  /** Asking Hermes beats reimplementing its profile override, which we'd drift from. */
13
11
  export function resolveHermesHome() {
14
12
  const result = spawn.sync('hermes', ['dump'], {
15
13
  encoding: 'utf8',
16
14
  env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
17
- timeout: HERMES_PROBE_TIMEOUT_MS,
15
+ timeout: HARNESS_PROBE_TIMEOUT_MS,
18
16
  });
17
+ if (result.error?.code === 'ETIMEDOUT') {
18
+ return { reason: 'timeout' };
19
+ }
19
20
  if (result.error || result.status !== 0)
20
- return undefined;
21
+ return { reason: 'exit' };
21
22
  const raw = HERMES_HOME_LINE.exec(sanitizeLine(`${result.stdout ?? ''}\n${result.stderr ?? ''}`))?.[1];
22
23
  if (!raw)
23
- return undefined;
24
+ return { reason: 'no_line' };
24
25
  const expanded = raw === '~' || raw.startsWith('~/') ? path.join(os.homedir(), raw.slice(2)) : raw;
25
- return path.isAbsolute(expanded) ? expanded : undefined;
26
+ return path.isAbsolute(expanded) ? expanded : { reason: 'relative' };
26
27
  }
27
28
  export const HERMES_KEY_VAR = 'MCP_TINYFISH_API_KEY';
28
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,7 +7,8 @@ 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 { errLine } from './output.js';
10
+ import { HERMES_HEADER_TEMPLATE, HERMES_MCP_SERVER_KEY } from './hermes-config.js';
11
+ import { errLine, parseJson } from './output.js';
11
12
  // Bump per CLI release.
12
13
  export const HERMES_PLUGIN_SHA = '496cd63fefd982bbaa8a85ce78ef2270d700984f';
13
14
  // Bump with the SHA; the source is `hermes/plugin.yaml` at that commit.
@@ -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'];
@@ -127,14 +143,6 @@ const hermesPluginStatusSchema = z.looseObject({
127
143
  provider_available: z.boolean().nullish(),
128
144
  web_backend_configured: z.boolean(),
129
145
  });
130
- function parseJson(text) {
131
- try {
132
- return JSON.parse(text);
133
- }
134
- catch {
135
- return undefined;
136
- }
137
- }
138
146
  /** `status --json` always exits 0; a non-zero exit means the subcommand is absent. */
139
147
  export function readHermesPluginStatus(home) {
140
148
  const result = spawn.sync('hermes', ['tinyfish', 'status', '--json'], {
@@ -16,3 +16,5 @@ export declare function errLine(line: string): void;
16
16
  export declare function warnLine(line: string): void;
17
17
  export declare function err(data: unknown): void;
18
18
  export declare function handleApiError(e: unknown): never;
19
+ /** Undefined, not a throw: callers hand the value to a Zod safeParse. */
20
+ export declare function parseJson(text: string): unknown;
@@ -70,3 +70,12 @@ export function handleApiError(e) {
70
70
  }
71
71
  process.exit(1);
72
72
  }
73
+ /** Undefined, not a throw: callers hand the value to a Zod safeParse. */
74
+ export function parseJson(text) {
75
+ try {
76
+ return JSON.parse(text);
77
+ }
78
+ catch {
79
+ return undefined;
80
+ }
81
+ }
@@ -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
+ }
@@ -20,7 +20,6 @@ export interface RegistrationStatus {
20
20
  recordedAuthMode?: RecordedAuthMode;
21
21
  reason?: string;
22
22
  }
23
- export declare function codexOauthCompleted(): boolean | undefined;
24
23
  /** Per-harness sign-in probes; connect composes these rather than the descriptor carrying them. */
25
24
  export declare const SIGN_IN_PROBES: Partial<Record<AgentClient, () => boolean | undefined>>;
26
25
  /** Never throws; a failed probe is `unknown` with a reason, so no caller can read it as healthy. */