@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
@@ -8,9 +8,19 @@ 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';
15
+ const NOT_REGISTERED = Object.freeze({
16
+ registered: Registered.No,
17
+ authMode: AuthMode.Unknown,
18
+ });
19
+ const unverified = (reason) => ({
20
+ registered: Registered.Unknown,
21
+ authMode: AuthMode.Unknown,
22
+ reason,
23
+ });
14
24
  // A list, verified against codex 0.146. A name-keyed map was accepted here too, but every
15
25
  // field is optional, so that arm parsed *any* object-of-objects: a wrapper like
16
26
  // `{"servers": {...}}` decoded as one entry named `servers` and reported TinyFish absent.
@@ -151,15 +161,11 @@ function parseMcpGet(output) {
151
161
  function fromMcpGet(command) {
152
162
  const probe = runProbe(command, ['mcp', 'get', 'tinyfish']);
153
163
  if (probe.outcome === 'unavailable') {
154
- return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason: probe.reason };
164
+ return unverified(probe.reason);
155
165
  }
156
166
  if (probe.exitCode !== 0) {
157
167
  if (UNSUPPORTED_SUBCOMMAND.test(probe.output)) {
158
- return {
159
- registered: Registered.Unknown,
160
- authMode: AuthMode.Unknown,
161
- reason: `\`${command} mcp get\` is unsupported by this version`,
162
- };
168
+ return unverified(`\`${command} mcp get\` is unsupported by this version`);
163
169
  }
164
170
  // The roster is free; `mcp list` health-checks every server.
165
171
  const scoped = tinyfishServerName(probe.output);
@@ -178,13 +184,9 @@ function fromMcpGet(command) {
178
184
  return plugin;
179
185
  // A broken CLI also exits nonzero, and reading that as absence earns a spurious repair.
180
186
  if (!NO_SUCH_SERVER.test(probe.output)) {
181
- return {
182
- registered: Registered.Unknown,
183
- authMode: AuthMode.Unknown,
184
- reason: `\`${command} mcp get\` failed without reporting the server as absent`,
185
- };
187
+ return unverified(`\`${command} mcp get\` failed without reporting the server as absent`);
186
188
  }
187
- return { registered: Registered.No, authMode: AuthMode.Unknown };
189
+ return NOT_REGISTERED;
188
190
  }
189
191
  return parseMcpGet(probe.output);
190
192
  }
@@ -218,7 +220,7 @@ function readCodexEntry() {
218
220
  }
219
221
  // `mcp add` exits 0 whether or not its inline OAuth finished, so `not_logged_in` is the only
220
222
  // evidence of an abandoned sign-in; every other state, older Codex included, is unknown.
221
- export function codexOauthCompleted() {
223
+ function codexOauthCompleted() {
222
224
  const read = readCodexEntry();
223
225
  if ('reason' in read)
224
226
  return undefined;
@@ -255,10 +257,10 @@ function envKeyVerdict(envVar) {
255
257
  function probeCodex() {
256
258
  const read = readCodexEntry();
257
259
  if ('reason' in read)
258
- return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason: read.reason };
260
+ return unverified(read.reason);
259
261
  const entry = read.entry;
260
262
  if (!entry)
261
- return { registered: Registered.No, authMode: AuthMode.Unknown };
263
+ return NOT_REGISTERED;
262
264
  const codexUrl = entry.transport?.url;
263
265
  // Read from this entry: codex emits `bearer_token_env_var` on every HTTP server, so testing
264
266
  // the whole payload reports api-key for TinyFish because some other server carries a key.
@@ -279,14 +281,10 @@ function probeCodex() {
279
281
  function probeCursor() {
280
282
  const entry = readCursorTinyfishEntry();
281
283
  if (entry.error) {
282
- return {
283
- registered: Registered.Unknown,
284
- authMode: AuthMode.Unknown,
285
- reason: 'mcp.json exists but could not be read or parsed',
286
- };
284
+ return unverified('mcp.json exists but could not be read or parsed');
287
285
  }
288
286
  if (!entry.present)
289
- return { registered: Registered.No, authMode: AuthMode.Unknown };
287
+ return NOT_REGISTERED;
290
288
  return {
291
289
  registered: Registered.Yes,
292
290
  authMode: entry.hasApiKeyHeader ? AuthMode.ApiKey : AuthMode.Unknown,
@@ -298,12 +296,29 @@ function probeCursor() {
298
296
  function probeOmp() {
299
297
  const entry = readOmpTinyfishEntry();
300
298
  if (!entry) {
301
- return {
302
- registered: Registered.Unknown,
303
- authMode: AuthMode.Unknown,
304
- reason: '`omp config path` did not report a config directory',
305
- };
299
+ return unverified('`omp config path` did not report a config directory');
300
+ }
301
+ if (entry.error) {
302
+ return unverified('mcp.json exists but could not be read or parsed');
306
303
  }
304
+ if (!entry.present)
305
+ return NOT_REGISTERED;
306
+ // A hand-written `${VAR}` header resolves like Codex's env-var key.
307
+ const keyVerdict = entry.keyTemplateVar
308
+ ? envKeyVerdict(entry.keyTemplateVar)
309
+ : entry.keyMatchesCliKey
310
+ ? { keyMatchesCliKey: true }
311
+ : {};
312
+ return {
313
+ registered: Registered.Yes,
314
+ authMode: entry.hasApiKeyHeader ? AuthMode.ApiKey : AuthMode.Unknown,
315
+ ...(entry.url ? { registeredUrl: entry.url } : {}),
316
+ ...keyVerdict,
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();
307
322
  if (entry.error) {
308
323
  return {
309
324
  registered: Registered.Unknown,
@@ -313,7 +328,6 @@ function probeOmp() {
313
328
  }
314
329
  if (!entry.present)
315
330
  return { registered: Registered.No, authMode: AuthMode.Unknown };
316
- // A hand-written `${VAR}` header resolves like Codex's env-var key.
317
331
  const keyVerdict = entry.keyTemplateVar
318
332
  ? envKeyVerdict(entry.keyTemplateVar)
319
333
  : entry.keyMatchesCliKey
@@ -342,34 +356,23 @@ function fromMcpList(command, output, registeredMode, urlPattern) {
342
356
  };
343
357
  }
344
358
  if (!MCP_LIST_HEADER.test(output)) {
345
- return {
346
- registered: Registered.Unknown,
347
- authMode: AuthMode.Unknown,
348
- reason: `could not interpret \`${command} mcp list\` output`,
349
- };
359
+ return unverified(`could not interpret \`${command} mcp list\` output`);
350
360
  }
351
- return { registered: Registered.No, authMode: AuthMode.Unknown };
361
+ return NOT_REGISTERED;
352
362
  }
353
363
  // `hermes mcp list` only pretty-prints this file, so connect's gate reads it too.
354
364
  function probeHermes() {
355
- const home = resolveHermesHome();
356
- if (!home) {
357
- return {
358
- registered: Registered.Unknown,
359
- authMode: AuthMode.Unknown,
360
- reason: '`hermes dump` did not report a home directory',
361
- };
365
+ const resolved = resolveHermesHome();
366
+ if (typeof resolved !== 'string') {
367
+ return unverified('`hermes dump` did not report a home directory');
362
368
  }
369
+ const home = resolved;
363
370
  const entry = readHermesEntry(home);
364
371
  if (entry.state === 'unreadable' || entry.state === 'unparseable') {
365
- return {
366
- registered: Registered.Unknown,
367
- authMode: AuthMode.Unknown,
368
- reason: `Hermes' config.yaml could not be ${entry.state === 'unreadable' ? 'read' : 'parsed'}`,
369
- };
372
+ return unverified(`Hermes' config.yaml could not be ${entry.state === 'unreadable' ? 'read' : 'parsed'}`);
370
373
  }
371
374
  if (entry.state === 'absent')
372
- return { registered: Registered.No, authMode: AuthMode.Unknown };
375
+ return NOT_REGISTERED;
373
376
  // `mcp add` saves a disabled entry on a failed connect, which connect refuses too.
374
377
  const enabled = entry.state === 'enabled' ? {} : { connected: false };
375
378
  // The .env key is Hermes-wide; the header template ties it here.
@@ -393,16 +396,12 @@ function probeOpenClaw() {
393
396
  entries = fs.readdirSync(skillsDir);
394
397
  }
395
398
  catch {
396
- return {
397
- registered: Registered.Unknown,
398
- authMode: AuthMode.Unknown,
399
- reason: `no global skills directory at ${harnessDisplayPath('openclaw')}/skills; OpenClaw layout is unverified`,
400
- };
399
+ return unverified(`no global skills directory at ${harnessDisplayPath('openclaw')}/skills; OpenClaw layout is unverified`);
401
400
  }
402
401
  // A bare substring also matched a skill merely named `not-tinyfish-thing`.
403
402
  return entries.some((name) => SKILL_DIR_IS_TINYFISH.test(name))
404
403
  ? { registered: Registered.Yes, authMode: AuthMode.ApiKey, usesCliKey: true }
405
- : { registered: Registered.No, authMode: AuthMode.Unknown };
404
+ : NOT_REGISTERED;
406
405
  }
407
406
  // `opencode mcp list` prints no headers, so a key-authed registration is indistinguishable.
408
407
  function probeOpencode() {
@@ -410,7 +409,7 @@ function probeOpencode() {
410
409
  const probe = runProbe('opencode', args);
411
410
  if (probe.outcome !== 'ran' || probe.exitCode !== 0) {
412
411
  const reason = probeReason('opencode', args, probe);
413
- return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason };
412
+ return unverified(reason);
414
413
  }
415
414
  return fromMcpList('opencode', probe.output, AuthMode.Unknown, OPENCODE_ROW_URL);
416
415
  }
@@ -440,11 +439,7 @@ function fromGrokPlugin() {
440
439
  // repair that double-registers a working one. Anything but a list is unknown, not absent.
441
440
  const shape = grokPluginSchema.safeParse(listed);
442
441
  if (!shape.success) {
443
- return {
444
- registered: Registered.Unknown,
445
- authMode: AuthMode.Unknown,
446
- reason: '`grok plugin list --json` returned an unexpected shape',
447
- };
442
+ return unverified('`grok plugin list --json` returned an unexpected shape');
448
443
  }
449
444
  if (!shape.data.some((plugin) => plugin.name === 'tinyfish'))
450
445
  return undefined;
@@ -477,36 +472,24 @@ function probeGrok() {
477
472
  const args = ['mcp', 'list', '--json'];
478
473
  const probe = runProbe('grok', args);
479
474
  if (probe.outcome === 'unavailable') {
480
- return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason: probe.reason };
475
+ return unverified(probe.reason);
481
476
  }
482
477
  if (probe.exitCode !== 0) {
483
478
  if (UNSUPPORTED_SUBCOMMAND.test(probe.output)) {
484
- return {
485
- registered: Registered.Unknown,
486
- authMode: AuthMode.Unknown,
487
- reason: 'this Grok build has no `mcp list --json`',
488
- };
479
+ return unverified('this Grok build has no `mcp list --json`');
489
480
  }
490
- return {
491
- registered: Registered.Unknown,
492
- authMode: AuthMode.Unknown,
493
- reason: probeReason('grok', args, probe),
494
- };
481
+ return unverified(probeReason('grok', args, probe));
495
482
  }
496
483
  let entries;
497
484
  try {
498
485
  entries = grokListSchema.parse(JSON.parse(probe.stdout));
499
486
  }
500
487
  catch {
501
- return {
502
- registered: Registered.Unknown,
503
- authMode: AuthMode.Unknown,
504
- reason: '`grok mcp list --json` did not return the expected shape',
505
- };
488
+ return unverified('`grok mcp list --json` did not return the expected shape');
506
489
  }
507
490
  const entry = entries.find((server) => server.name === 'tinyfish');
508
491
  if (!entry)
509
- return fromGrokPlugin() ?? { registered: Registered.No, authMode: AuthMode.Unknown };
492
+ return fromGrokPlugin() ?? NOT_REGISTERED;
510
493
  return {
511
494
  registered: Registered.Yes,
512
495
  ...grokKeyAuth(entry.headers ?? {}),
@@ -522,6 +505,7 @@ const PROBES = {
522
505
  omp: probeOmp,
523
506
  openclaw: probeOpenClaw,
524
507
  opencode: probeOpencode,
508
+ pi: probePi,
525
509
  };
526
510
  /** Never throws; a failed probe is `unknown` with a reason, so no caller can read it as healthy. */
527
511
  export function detectRegistrations(harnesses) {
@@ -549,12 +533,7 @@ export function detectRegistrations(harnesses) {
549
533
  if (process.env['TINYFISH_DEBUG']) {
550
534
  errLine(`probe ${detection.harness} threw: ${e instanceof Error ? e.message : String(e)}`);
551
535
  }
552
- return {
553
- ...base,
554
- registered: Registered.Unknown,
555
- authMode: AuthMode.Unknown,
556
- reason: 'the probe failed unexpectedly',
557
- };
536
+ return { ...base, ...unverified('the probe failed unexpectedly') };
558
537
  }
559
538
  });
560
539
  }
@@ -6,9 +6,6 @@ import { AuthMode, Registered } from './harness-detect.js';
6
6
  * opts out — a privacy switch must not fail closed on `true`.
7
7
  */
8
8
  export declare function telemetryDisabled(): boolean;
9
- export declare function isTrustedTelemetryOrigin(endpoint: string): boolean;
10
- /** SHA-256 lookup key only, and only to trusted origins — the raw key never rides telemetry. */
11
- export declare function telemetryHeaders(endpoint: string, apiKey?: string): Record<string, string>;
12
9
  /**
13
10
  * The one POST to `/api/cli/connect-event`, and the only place its security-relevant
14
11
  * properties live: trusted-origin key gating, `redirect: "error"`, the 2s cap, notice
@@ -32,6 +29,7 @@ declare const harnessResultSchema: z.ZodObject<{
32
29
  codex: "codex";
33
30
  hermes: "hermes";
34
31
  opencode: "opencode";
32
+ pi: "pi";
35
33
  "claude-code": "claude-code";
36
34
  }>;
37
35
  detected: z.ZodBoolean;
@@ -66,6 +64,7 @@ export declare const setupCompletedPayloadSchema: z.ZodObject<{
66
64
  codex: "codex";
67
65
  hermes: "hermes";
68
66
  opencode: "opencode";
67
+ pi: "pi";
69
68
  "claude-code": "claude-code";
70
69
  }>;
71
70
  detected: z.ZodBoolean;
@@ -84,7 +83,7 @@ export declare const setupCompletedPayloadSchema: z.ZodObject<{
84
83
  }>>;
85
84
  connect_attempt_id: z.ZodOptional<z.ZodUUID>;
86
85
  }, z.core.$strip>;
87
- export type SetupCompletedPayload = z.infer<typeof setupCompletedPayloadSchema>;
86
+ type SetupCompletedPayload = z.infer<typeof setupCompletedPayloadSchema>;
88
87
  export type HarnessResult = z.infer<typeof harnessResultSchema>;
89
88
  export type SetupMode = NonNullable<SetupCompletedPayload['mode']>;
90
89
  export type UpgradeOutcome = 'updated' | 'failed' | 'interrupted';
@@ -93,12 +92,11 @@ export type UpgradeScope = 'both' | 'cli' | 'skill';
93
92
  /** `toVersion` is null when the run was interrupted or the installed version could not be read. */
94
93
  export declare function sendUpgradeCompleted(fromVersion: string, toVersion: string | null, outcome: UpgradeOutcome, scope: UpgradeScope, apiKey?: string): Promise<void>;
95
94
  export declare function sendSetupCompleted(mcpUrl: string, harnesses: HarnessResult[], apiKey?: string, mode?: SetupMode, attemptId?: string): Promise<void>;
96
- export declare const DOCTOR_HARNESS_SCOPES: readonly ["all", ...("openclaw" | "omp" | "grok" | "cursor" | "codex" | "hermes" | "opencode" | "claude-code")[]];
97
95
  export declare const DOCTOR_ERROR_CLASSES: readonly ["command_not_found", "timeout", "spawn_error", "nonzero_exit", "unexpected_error"];
98
96
  export type DoctorErrorClass = (typeof DOCTOR_ERROR_CLASSES)[number];
99
97
  /** Derived, not read: the catch is generic, so only the code is trustworthy. */
100
98
  export declare function doctorErrorClass(error: unknown): DoctorErrorClass;
101
- export declare const doctorCompletedPayloadSchema: z.ZodObject<{
99
+ declare const doctorCompletedPayloadSchema: z.ZodObject<{
102
100
  outcome: z.ZodLiteral<"completed">;
103
101
  schema_version: z.ZodInt;
104
102
  cli_version: z.ZodString;
@@ -128,6 +126,7 @@ export declare const doctorCompletedPayloadSchema: z.ZodObject<{
128
126
  codex: "codex";
129
127
  hermes: "hermes";
130
128
  opencode: "opencode";
129
+ pi: "pi";
131
130
  "claude-code": "claude-code";
132
131
  all: "all";
133
132
  }>;
@@ -141,6 +140,7 @@ export declare const doctorCompletedPayloadSchema: z.ZodObject<{
141
140
  codex: "codex";
142
141
  hermes: "hermes";
143
142
  opencode: "opencode";
143
+ pi: "pi";
144
144
  "claude-code": "claude-code";
145
145
  }>>;
146
146
  harnesses: z.ZodArray<z.ZodObject<{
@@ -152,6 +152,7 @@ export declare const doctorCompletedPayloadSchema: z.ZodObject<{
152
152
  codex: "codex";
153
153
  hermes: "hermes";
154
154
  opencode: "opencode";
155
+ pi: "pi";
155
156
  "claude-code": "claude-code";
156
157
  }>;
157
158
  detected: z.ZodBoolean;
@@ -163,7 +164,7 @@ export declare const doctorCompletedPayloadSchema: z.ZodObject<{
163
164
  hermes_plugin_version: z.ZodOptional<z.ZodString>;
164
165
  hermes_plugin_expected_version: z.ZodOptional<z.ZodString>;
165
166
  }, z.core.$strip>;
166
- export declare const doctorCouldNotRunPayloadSchema: z.ZodObject<{
167
+ declare const doctorCouldNotRunPayloadSchema: z.ZodObject<{
167
168
  outcome: z.ZodLiteral<"could_not_run">;
168
169
  error_class: z.ZodEnum<{
169
170
  timeout: "timeout";
@@ -187,6 +188,7 @@ export declare const doctorCouldNotRunPayloadSchema: z.ZodObject<{
187
188
  codex: "codex";
188
189
  hermes: "hermes";
189
190
  opencode: "opencode";
191
+ pi: "pi";
190
192
  "claude-code": "claude-code";
191
193
  all: "all";
192
194
  }>;
@@ -223,6 +225,7 @@ declare const doctorPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
223
225
  codex: "codex";
224
226
  hermes: "hermes";
225
227
  opencode: "opencode";
228
+ pi: "pi";
226
229
  "claude-code": "claude-code";
227
230
  all: "all";
228
231
  }>;
@@ -236,6 +239,7 @@ declare const doctorPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
236
239
  codex: "codex";
237
240
  hermes: "hermes";
238
241
  opencode: "opencode";
242
+ pi: "pi";
239
243
  "claude-code": "claude-code";
240
244
  }>>;
241
245
  harnesses: z.ZodArray<z.ZodObject<{
@@ -247,6 +251,7 @@ declare const doctorPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
247
251
  codex: "codex";
248
252
  hermes: "hermes";
249
253
  opencode: "opencode";
254
+ pi: "pi";
250
255
  "claude-code": "claude-code";
251
256
  }>;
252
257
  detected: z.ZodBoolean;
@@ -281,6 +286,7 @@ declare const doctorPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
281
286
  codex: "codex";
282
287
  hermes: "hermes";
283
288
  opencode: "opencode";
289
+ pi: "pi";
284
290
  "claude-code": "claude-code";
285
291
  all: "all";
286
292
  }>;
@@ -20,7 +20,7 @@ export function telemetryDisabled() {
20
20
  // `--url` accepts any http(s) URL, so a user-supplied origin must never receive the key.
21
21
  const TRUSTED_TELEMETRY_HOST_SUFFIXES = ['.tinyfish.ai', '.tinyfish.io'];
22
22
  const TRUSTED_TELEMETRY_LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]'];
23
- export function isTrustedTelemetryOrigin(endpoint) {
23
+ function isTrustedTelemetryOrigin(endpoint) {
24
24
  let url;
25
25
  try {
26
26
  url = new URL(endpoint);
@@ -38,7 +38,7 @@ export function isTrustedTelemetryOrigin(endpoint) {
38
38
  return TRUSTED_TELEMETRY_HOST_SUFFIXES.some((suffix) => host === suffix.slice(1) || host.endsWith(suffix));
39
39
  }
40
40
  /** SHA-256 lookup key only, and only to trusted origins — the raw key never rides telemetry. */
41
- export function telemetryHeaders(endpoint, apiKey) {
41
+ function telemetryHeaders(endpoint, apiKey) {
42
42
  const headers = { 'content-type': 'application/json' };
43
43
  if (apiKey && validateKeyFormat(apiKey) && isTrustedTelemetryOrigin(endpoint)) {
44
44
  headers['x-api-key-lookup'] = createHash('sha256').update(apiKey).digest('hex');
@@ -210,7 +210,7 @@ export async function sendSetupCompleted(mcpUrl, harnesses, apiKey, mode, attemp
210
210
  }
211
211
  await postConnectEvent({ endpoint, body, attempts: 2, label: 'setup', apiKey });
212
212
  }
213
- export const DOCTOR_HARNESS_SCOPES = ['all', ...ALL_HARNESSES];
213
+ const DOCTOR_HARNESS_SCOPES = ['all', ...ALL_HARNESSES];
214
214
  // Mirrors the route's cap; drops what the route would 400 un-retried.
215
215
  const versionSchema = z
216
216
  .string()
@@ -237,7 +237,7 @@ export function doctorErrorClass(error) {
237
237
  return 'timeout';
238
238
  return code ? 'spawn_error' : 'unexpected_error';
239
239
  }
240
- export const doctorCompletedPayloadSchema = z.object({
240
+ const doctorCompletedPayloadSchema = z.object({
241
241
  outcome: z.literal('completed'),
242
242
  schema_version: z.int().positive(),
243
243
  cli_version: z.string().max(32),
@@ -274,7 +274,7 @@ export const doctorCompletedPayloadSchema = z.object({
274
274
  hermes_plugin_expected_version: versionSchema.optional(),
275
275
  });
276
276
  // Detection threw, so there is no report to aggregate: enumerated outcome and timing only.
277
- export const doctorCouldNotRunPayloadSchema = z.object({
277
+ const doctorCouldNotRunPayloadSchema = z.object({
278
278
  outcome: z.literal('could_not_run'),
279
279
  error_class: z.enum(DOCTOR_ERROR_CLASSES),
280
280
  schema_version: z.int().positive(),
@@ -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;
@@ -1,14 +1,15 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
1
3
  import * as path from 'node:path';
2
4
  import spawn from 'cross-spawn';
3
- import { z } from 'zod';
4
5
  import { loadConfig } from './auth.js';
5
6
  import { captureStdio, capturedOutput, replay, SKILL_INSTALL_TIMEOUT_MS, STEP_MAX_BUFFER, } from './cli-install.js';
6
7
  import { CURSOR_SKILL_TARGET, NATIVE_MCP_CLIENTS, OPENCLAW, openclawSkillInstallArgs, } from './connect-clients.js';
7
- import { ConnectInterruptedError, ConnectStepError, probeSupportVariant, spawnFailureDetail, spawnStepError, throwIfInterrupted, } from './connect-runtime.js';
8
+ import { ConnectInterruptedError, ConnectStepError, probeSupportVariant, spawnStepError, throwIfInterrupted, } from './connect-runtime.js';
8
9
  import { CLI_AGENT_IDENTITY } from './constants.js';
9
- import { errLine } from './output.js';
10
+ import { errLine, sanitizeLine } from './output.js';
10
11
  // Supports Hermes without node:util.styleText, so the installer still runs on Node 20.11.
11
- const SKILLS_CLI_PACKAGE = 'skills@1.5.15';
12
+ export const SKILLS_CLI_PACKAGE = 'skills@1.5.15';
12
13
  const TINYFISH_WEB_SKILL_SOURCE = 'tinyfish-io/tinyfish-cookbook';
13
14
  const TINYFISH_WEB_SKILL = 'use-tinyfish';
14
15
  /** `skills add` is an unconditional overwrite, so it doubles as the refresh path. */
@@ -44,35 +45,34 @@ const SKILL_ALREADY_CURRENT_PATTERN = /All global skills are up to date/;
44
45
  // A lock entry with no recorded hash is untrackable, so `skills` reports it as skipped rather
45
46
  // than failed. Left undetected that reads as "up to date" while nothing was refreshed.
46
47
  const SKILL_UNCHECKABLE_PATTERN = /cannot be checked automatically/;
47
- const skillListSchema = z.array(z.object({ name: z.string() }));
48
- // `add` exits 0 on per-skill failure; the piped list is the verdict.
49
- function failedListVerdict() {
50
- const list = spawn.sync('npx', ['-y', SKILLS_CLI_PACKAGE, 'list', '--global', '--json'], {
51
- encoding: 'utf8',
52
- env: skillSpawnEnv(),
53
- maxBuffer: STEP_MAX_BUFFER,
54
- timeout: SKILL_INSTALL_TIMEOUT_MS,
55
- });
56
- const listRan = !list.error && list.status === 0;
57
- if (listRan && skillListed(list.stdout ?? ''))
58
- return null;
59
- // A clean list that omits the skill is not a real exit (PF-3680).
60
- const tag = listRan ? 'skill_absent_after_add' : `skill_list_failed ${spawnFailureDetail(list)}`;
61
- return { result: list, tag };
48
+ // `skills` writes here for the universal agents, whose own config dirs it never touches.
49
+ function canonicalSkillsDir() {
50
+ return path.join(os.homedir(), '.agents', 'skills'); // nosemgrep: path-join-resolve-traversal -- fixed dir names under os.homedir()
62
51
  }
63
- // Entry presence is the whole verdict: `agents` names the harnesses `skills` detects,
64
- // so an install for a harness with no config dir yet lists none (PF-3680).
65
- function skillListed(stdout) {
66
- const parsed = skillListSchema.safeParse(parseJson(stdout));
67
- return parsed.success && parsed.data.some((skill) => skill.name === TINYFISH_WEB_SKILL);
52
+ // Where the harness reads. Both of the modes `add` picks land the skill here.
53
+ const SKILL_DIR_BY_AGENT = {
54
+ 'claude-code': () => path.join(agentHome('CLAUDE_CONFIG_DIR', '.claude'), 'skills'),
55
+ // The env value, not resolveHermesHome(): the child we spawn reads the env.
56
+ 'hermes-agent': () => path.join(agentHome('HERMES_HOME', '.hermes'), 'skills'),
57
+ codex: canonicalSkillsDir,
58
+ cursor: canonicalSkillsDir,
59
+ opencode: canonicalSkillsDir,
60
+ // `skills` writes pi's here whatever PI_CODING_AGENT_DIR says.
61
+ pi: () => path.join(os.homedir(), '.pi', 'agent', 'skills'), // nosemgrep: path-join-resolve-traversal -- fixed dir names under os.homedir()
62
+ };
63
+ function agentHome(override, fallback) {
64
+ return process.env[override]?.trim() || path.join(os.homedir(), fallback); // nosemgrep: path-join-resolve-traversal -- fixed dir names under os.homedir()
68
65
  }
69
- function parseJson(text) {
70
- try {
71
- return JSON.parse(text);
72
- }
73
- catch {
74
- return undefined;
75
- }
66
+ /** `add` exits 0 on per-agent failure, so the file it should have written is the verdict. */
67
+ function skillOnDisk(agent) {
68
+ // SKILL.md, not the dir: `skills` mkdirs before it copies, so a failed copy leaves one.
69
+ return fs.existsSync(path.join(SKILL_DIR_BY_AGENT[agent](), TINYFISH_WEB_SKILL, 'SKILL.md'));
70
+ }
71
+ // The route tail-slices to 500, so an over-long tail would cut the tag off the front.
72
+ const SKILL_DETAIL_TAIL_MAX_CHARS = 425;
73
+ function absentDetail(addOutput) {
74
+ const tail = sanitizeLine(addOutput).trim().slice(-SKILL_DETAIL_TAIL_MAX_CHARS);
75
+ return tail ? `skill_absent_after_add | ${tail}` : 'skill_absent_after_add';
76
76
  }
77
77
  export function installWebSkill(client, { verbose }) {
78
78
  if (!client.skillAgent)
@@ -82,14 +82,13 @@ export function installWebSkill(client, { verbose }) {
82
82
  ...captureStdio(verbose),
83
83
  env: skillSpawnEnv(),
84
84
  });
85
+ const output = capturedOutput(add);
85
86
  const addFailed = Boolean(add.error) || add.status !== 0;
86
- const verdict = addFailed ? null : failedListVerdict();
87
- const failed = addFailed ? add : verdict?.result;
88
- if (failed) {
87
+ const absent = !addFailed && !skillOnDisk(client.skillAgent);
88
+ if (addFailed || absent) {
89
89
  // Built first: its interrupt check must run before any replay.
90
- const error = spawnStepError(`Could not install the TinyFish web skill in ${client.displayName}`, failed, verdict?.tag);
91
- // The add carries the failure prose; the list is JSON only.
92
- replay(capturedOutput(add));
90
+ const error = spawnStepError(`Could not install the TinyFish web skill in ${client.displayName}`, add, absent ? absentDetail(output) : undefined);
91
+ replay(output);
93
92
  throw error;
94
93
  }
95
94
  }
@@ -176,18 +175,18 @@ function reinstallWebSkill(skillAgents, verbose) {
176
175
  timeout: SKILL_INSTALL_TIMEOUT_MS,
177
176
  });
178
177
  const output = capturedOutput(result);
179
- // Quiet capture stays (#4434); the list verdict replaced prose matching.
178
+ // Quiet capture stays (#4434); the on-disk check replaced prose matching.
180
179
  const addFailed = Boolean(result.error) || result.status !== 0;
181
- const failedList = addFailed ? null : failedListVerdict();
182
- const failed = addFailed || failedList !== null;
180
+ const missing = addFailed ? undefined : skillAgents.find((agent) => !skillOnDisk(agent));
181
+ const failed = addFailed || missing !== undefined;
183
182
  if (failed)
184
- throwIfInterrupted(failedList?.result ?? result);
183
+ throwIfInterrupted(result);
185
184
  if (verbose || failed)
186
185
  replay(output);
187
186
  if (failed) {
188
- throw new Error('Could not refresh the TinyFish web skill', {
189
- cause: (failedList?.result ?? result).error,
190
- });
187
+ // Upgrade telemetry carries no detail, so the agent has to ride the message.
188
+ const scope = missing ? ` for ${missing}` : '';
189
+ throw new Error(`Could not refresh the TinyFish web skill${scope}`, { cause: result.error });
191
190
  }
192
191
  }
193
192
  /** Fallback when no skill-bearing harness is recorded: refresh whatever `skills` tracks. */
@@ -21,14 +21,14 @@ export interface CliAgentRunParams extends AgentRunParams {
21
21
  use_vault?: boolean;
22
22
  credential_item_ids?: string[];
23
23
  }
24
- export type VaultProvider = '1password' | 'bitwarden';
24
+ type VaultProvider = '1password' | 'bitwarden';
25
25
  export interface VaultConnection {
26
26
  id: string;
27
27
  provider: VaultProvider;
28
28
  connectionStatus: string;
29
29
  lastValidatedAt: string | null;
30
30
  }
31
- export interface VaultFieldMetadata {
31
+ interface VaultFieldMetadata {
32
32
  fieldId: string;
33
33
  label: string;
34
34
  type: 'STRING' | 'CONCEALED' | 'OTP';
@@ -119,7 +119,7 @@ export interface BatchGetResponse {
119
119
  data: Run[];
120
120
  not_found: string[] | null;
121
121
  }
122
- export interface BatchCancelResult {
122
+ interface BatchCancelResult {
123
123
  run_id: string;
124
124
  status: string;
125
125
  cancelled_at: string | null;
@@ -157,3 +157,4 @@ export interface ProfileUploadResponse {
157
157
  domains_updated: string[];
158
158
  domains_failed?: string[];
159
159
  }
160
+ export {};
package/dist/program.js CHANGED
@@ -1,5 +1,5 @@
1
- import { createRequire } from 'module';
2
1
  import { Command } from 'commander';
2
+ import { CLI_VERSION } from './lib/constants.js';
3
3
  import { registerAuth } from './commands/auth.js';
4
4
  import { registerBatch } from './commands/batch.js';
5
5
  import { registerFetch } from './commands/fetch.js';
@@ -18,12 +18,11 @@ import { registerOnboard } from './commands/onboard.js';
18
18
  import { installNoticeEmit } from './lib/notice.js';
19
19
  /** Split from index.ts so tests can drive the real program instead of hand-assembling one. */
20
20
  export function buildProgram() {
21
- const { version } = createRequire(import.meta.url)('../package.json');
22
21
  const program = new Command();
23
22
  program
24
23
  .name('tinyfish')
25
24
  .description('TinyFish CLI — run web automations from your terminal or agent.')
26
- .version(version, '-V, --version', 'Show version')
25
+ .version(CLI_VERSION, '-V, --version', 'Show version')
27
26
  .helpOption('-h, --help', 'Show help')
28
27
  .addHelpCommand(false)
29
28
  .enablePositionalOptions()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.39.1-next.309",
3
+ "version": "0.40.0",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,7 +37,6 @@
37
37
  "@tiny-fish/sdk": "^0.5.0",
38
38
  "commander": "^12.0.0",
39
39
  "cross-spawn": "^7.0.6",
40
- "globals": "^17.4.0",
41
40
  "tldts": "^6.1.86",
42
41
  "which": "^2.0.2",
43
42
  "yaml": "^2.9.0",