@tiny-fish/cli 0.33.1-next.280 → 0.34.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.
@@ -72,7 +72,7 @@ function refuseKeylessInstall(client, keyAuthSupported) {
72
72
  `Update it, then ${rerun}`, 'harness_too_old');
73
73
  }
74
74
  throw new ConnectStepError(`Connecting ${client.displayName} needs a TinyFish API key, and ${noSignIn}. ` +
75
- `Run: tinyfish auth login, then ${rerun}`, 'invalid_config');
75
+ `Run: tinyfish auth login, then ${rerun}`, 'invalid_config', { failureDetail: 'keyless_install_refused' });
76
76
  }
77
77
  /** Resolved before the removals, so a refusal strands no registration. */
78
78
  function keylessAddArgs(client, keyAuthSupported, oauthResource) {
@@ -92,8 +92,9 @@ async function verifyKeyBeforeSeeding(apiKey, displayName, mcpUrl) {
92
92
  throw new ConnectStepError(`Could not reach TinyFish to check your API key (${verified.code ?? 'unknown'}), so ` +
93
93
  `nothing was written to ${displayName}. Check your connection and retry.`, 'timeout');
94
94
  }
95
+ // The tag carries the HTTP status; `verified.code` is authored prose.
95
96
  throw new ConnectStepError(`Your TinyFish API key was rejected (${verified.code ?? 'unknown'}), so it was not ` +
96
- `written to ${displayName}. Run: tinyfish auth login`, 'invalid_config');
97
+ `written to ${displayName}. Run: tinyfish auth login`, 'invalid_config', { failureDetail: `key_rejected status=${verified.status}` });
97
98
  }
98
99
  /** A bounded spawn's timeout means the sign-in never finished. */
99
100
  function signInStepError(message, result, bounded) {
@@ -213,7 +214,9 @@ function performMcpAdd(client, options, plan, attemptId) {
213
214
  const registration = seeded?.confirmRegistered();
214
215
  if (registration && !registration.ok) {
215
216
  failAdd(() => new ConnectStepError(`${client.displayName} reported success but TinyFish is not enabled there` +
216
- `${registration.detail ? `: ${registration.detail}` : ''}.`, 'invalid_config'));
217
+ `${registration.detail ? `: ${registration.detail}` : ''}.`, 'invalid_config',
218
+ // The tag keeps the path-bearing prose out of telemetry.
219
+ { failureDetail: registration.tag }));
217
220
  }
218
221
  return seeded?.note;
219
222
  }
@@ -423,6 +426,7 @@ function trackPostInstallFailure(displayName, state, telemetry, error) {
423
426
  telemetry.track('post_install_failed', {
424
427
  failedStage: state.stage,
425
428
  failureReason: error instanceof ConnectStepError ? error.failureReason : 'unexpected_error',
429
+ failureDetail: error instanceof ConnectStepError ? (error.failureDetail ?? error.message) : undefined,
426
430
  stageDurationMs: stageDurationOf(error),
427
431
  });
428
432
  }
@@ -570,8 +574,9 @@ export async function connectCursor(options) {
570
574
  mcpUrl.searchParams.set('connect_attempt_id', telemetry.attemptId);
571
575
  const result = writeCursorMcpConfig(mcpUrl.toString(), resolvedKey);
572
576
  if (result.status === 'corrupt_skip') {
577
+ // The message fallback would leak the config path; tag instead.
573
578
  throw new ConnectStepError(`Could not update ${cursorMcpPath()}: existing file could not be read or is not valid JSON (${result.error}). ` +
574
- `Fix the file, then re-run: tinyfish connect cursor`, 'invalid_config');
579
+ `Fix the file, then re-run: tinyfish connect cursor`, 'invalid_config', { failureDetail: 'cursor_mcp_json_corrupt' });
575
580
  }
576
581
  if (result.backupPath) {
577
582
  errLine(`Backed up existing Cursor MCP config to ${result.backupPath}`);
@@ -8,7 +8,9 @@ export function ensureCliAuthenticated(source, apiKey, opts) {
8
8
  const envKey = apiKey ?? process.env[TINYFISH_API_KEY_VAR];
9
9
  if (envKey) {
10
10
  if (!validateKeyFormat(envKey)) {
11
- throw new ConnectStepError('TINYFISH_API_KEY has invalid format', 'invalid_config');
11
+ throw new ConnectStepError('TINYFISH_API_KEY has invalid format', 'invalid_config', {
12
+ failureDetail: 'env_key_invalid_format',
13
+ });
12
14
  }
13
15
  // Throwing variant: a write failure must reach connect's catch/flush, not exit(1) past it.
14
16
  writeConfig(envKey);
@@ -32,6 +32,7 @@ export interface SeededInstall {
32
32
  confirmRegistered: () => {
33
33
  ok: boolean;
34
34
  detail?: string;
35
+ tag?: string;
35
36
  };
36
37
  }
37
38
  interface BaseMcpClient extends SupportedCommand {
@@ -97,6 +98,7 @@ export declare const GROK: NativeMcpClient;
97
98
  export declare function hermesRegistrationEnabled(home: string): {
98
99
  ok: boolean;
99
100
  detail?: string;
101
+ tag?: string;
100
102
  };
101
103
  export declare const HERMES: KeyRequiredMcpClient;
102
104
  export declare const OPENCODE: NativeMcpClient;
@@ -256,13 +256,14 @@ export function hermesRegistrationEnabled(home) {
256
256
  unreadable: `${hermesConfigPath(home)} could not be read`,
257
257
  unparseable: `${hermesConfigPath(home)} could not be parsed`,
258
258
  }[entry.state];
259
- return { ok: false, detail };
259
+ // The tag keeps the path-bearing prose out of telemetry.
260
+ return { ok: false, detail, tag: `hermes_entry_${entry.state}` };
260
261
  }
261
262
  function seedHermesKey(apiKey) {
262
263
  const home = resolveHermesHome();
263
264
  // A plain Error settles unexpected_error; cli_connect_stage is load-bearing.
264
265
  if (!home) {
265
- throw new ConnectStepError("Could not determine Hermes' home directory from `hermes dump`", 'invalid_config');
266
+ throw new ConnectStepError("Could not determine Hermes' home directory from `hermes dump`", 'invalid_config', { failureDetail: 'hermes_home_undetermined' });
266
267
  }
267
268
  const restore = captureHermesKeyRestore(home);
268
269
  writeHermesKey(home, apiKey);
@@ -18,9 +18,11 @@ export declare class HoistedFailureReportedError extends Error {
18
18
  export declare class ConnectStepError extends Error {
19
19
  readonly failureReason: ConnectFailureReason;
20
20
  readonly harnessVersion?: string;
21
+ readonly failureDetail?: string;
21
22
  constructor(message: string, failureReason: ConnectFailureReason, opts?: {
22
23
  harnessVersion?: string;
23
24
  cause?: unknown;
25
+ failureDetail?: string;
24
26
  });
25
27
  }
26
28
  /** Hoisted install predates this attempt's telemetry; the gap reads ~0 ms. */
@@ -38,11 +40,15 @@ export interface SpawnStepResult {
38
40
  status?: number | null;
39
41
  signal?: string | null;
40
42
  error?: Error;
43
+ /** Inherited stdio leaves this null; only piped runs yield text. */
44
+ stderr?: unknown;
41
45
  }
42
46
  /** Bounded removal spawn shared by install cleanup and --uninstall; Ctrl+C throws. */
43
47
  export declare function spawnRemoval(command: string, args: readonly string[], timeoutMs?: number): import("child_process").SpawnSyncReturns<string>;
48
+ /** Outcome plus a bounded stderr tail; the route sanitizes and caps the rest. */
49
+ export declare function spawnFailureDetail(result: SpawnStepResult): string;
44
50
  /** Classifies a spawn failure; interrupts throw instead. */
45
- export declare function spawnStepError(message: string, result: SpawnStepResult): ConnectStepError;
51
+ export declare function spawnStepError(message: string, result: SpawnStepResult, failureDetail?: string): ConnectStepError;
46
52
  export declare function commandNotFound(error: unknown): boolean;
47
53
  /** One command redoes every post-install step for a client. */
48
54
  export declare function finishSetupCommand(client: AgentClient): string;
@@ -64,6 +70,8 @@ interface ConnectStageDetail {
64
70
  harnessDegraded?: boolean;
65
71
  /** Overrides the event-gap duration; the hoisted CLI install measures itself. */
66
72
  stageDurationMs?: number;
73
+ /** Dropped from the wire unless `failedStage` is set (route refine). */
74
+ failureDetail?: string;
67
75
  }
68
76
  export interface ConnectTelemetry {
69
77
  attemptId: string;
@@ -32,10 +32,12 @@ export class HoistedFailureReportedError extends Error {
32
32
  export class ConnectStepError extends Error {
33
33
  failureReason;
34
34
  harnessVersion;
35
+ failureDetail;
35
36
  constructor(message, failureReason, opts) {
36
37
  super(message, { cause: opts?.cause });
37
38
  this.failureReason = failureReason;
38
39
  this.harnessVersion = opts?.harnessVersion;
40
+ this.failureDetail = opts?.failureDetail;
39
41
  }
40
42
  }
41
43
  /** Hoisted install predates this attempt's telemetry; the gap reads ~0 ms. */
@@ -76,8 +78,19 @@ export function spawnRemoval(command, args, timeoutMs = NON_INTERACTIVE_TIMEOUT_
76
78
  throwIfInterrupted(result);
77
79
  return result;
78
80
  }
81
+ // Sanitized before the cut so the count is not spent on invisible bytes.
82
+ const STDERR_TAIL_MAX_CHARS = 400;
83
+ /** Outcome plus a bounded stderr tail; the route sanitizes and caps the rest. */
84
+ export function spawnFailureDetail(result) {
85
+ const stderr = typeof result.stderr === 'string'
86
+ ? sanitizeLine(result.stderr.trim()).slice(-STDERR_TAIL_MAX_CHARS)
87
+ : '';
88
+ // A signal kill leaves status null, where the signal is the diagnosis.
89
+ const outcome = result.signal ? `signal=${result.signal}` : `exit=${result.status}`;
90
+ return `${outcome}${stderr ? ` | ${stderr}` : ''}`;
91
+ }
79
92
  /** Classifies a spawn failure; interrupts throw instead. */
80
- export function spawnStepError(message, result) {
93
+ export function spawnStepError(message, result, failureDetail) {
81
94
  throwIfInterrupted(result);
82
95
  const code = result.error?.code;
83
96
  const reason = !result.error
@@ -87,7 +100,8 @@ export function spawnStepError(message, result) {
87
100
  : code === 'ETIMEDOUT'
88
101
  ? 'timeout'
89
102
  : 'spawn_error';
90
- return new ConnectStepError(message, reason, { cause: result.error });
103
+ const detail = failureDetail ?? (reason === 'nonzero_exit' ? spawnFailureDetail(result) : undefined);
104
+ return new ConnectStepError(message, reason, { cause: result.error, failureDetail: detail });
91
105
  }
92
106
  export function commandNotFound(error) {
93
107
  return error?.code === 'ENOENT';
@@ -136,7 +150,11 @@ export async function runGuarded(state, telemetry, body) {
136
150
  }
137
151
  // An elapsed sign-in wait is abandonment, not breakage.
138
152
  if (error instanceof SignInTimeoutError) {
139
- settle(state, telemetry, 'aborted', { failedStage: state.stage, failureReason: 'timeout' });
153
+ settle(state, telemetry, 'aborted', {
154
+ failedStage: state.stage,
155
+ failureReason: 'timeout',
156
+ failureDetail: error.failureDetail ?? error.message,
157
+ });
140
158
  throw error;
141
159
  }
142
160
  // An earlier attempt owns the shared failure's terminal row.
@@ -145,7 +163,12 @@ export async function runGuarded(state, telemetry, body) {
145
163
  settle(state, telemetry, 'failed', {
146
164
  failedStage: state.stage,
147
165
  failureReason: error instanceof ConnectStepError ? error.failureReason : 'unexpected_error',
148
- ...(error instanceof ConnectStepError ? { harnessVersion: error.harnessVersion } : {}),
166
+ ...(error instanceof ConnectStepError
167
+ ? {
168
+ harnessVersion: error.harnessVersion,
169
+ failureDetail: error.failureDetail ?? error.message,
170
+ }
171
+ : {}),
149
172
  stageDurationMs: stageDurationOf(error),
150
173
  });
151
174
  throw error;
@@ -227,6 +250,12 @@ function takeSeedAttemptId() {
227
250
  const parked = takePendingConnectAttempt();
228
251
  return fromEnv && isAttemptId(fromEnv) ? fromEnv : parked;
229
252
  }
253
+ // postConnectEvent is total, so this only fires on a bug inside deliver.
254
+ function reportDeliverBug(error) {
255
+ if (!process.env['TINYFISH_DEBUG'])
256
+ return;
257
+ errLine(`connect telemetry failed: ${error instanceof Error ? error.message : String(error)}`);
258
+ }
230
259
  export function createConnectTelemetry(mcpUrl, client, opts) {
231
260
  // Consumed even when --url already supplied an id: leaving it behind would let a stale
232
261
  // seed reach the agent this connect launches, or the next connect on this machine.
@@ -252,6 +281,33 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
252
281
  label: 'connect',
253
282
  });
254
283
  }
284
+ function eventBody(stage, detail, stageDurationMs) {
285
+ return JSON.stringify({
286
+ attempt_id: attemptId,
287
+ client,
288
+ stage,
289
+ failed_stage: detail?.failedStage,
290
+ phase: detail?.phase,
291
+ failure_reason: detail?.failureReason,
292
+ // Only with failed_stage: the route refine 400s the event otherwise.
293
+ failure_detail: detail?.failedStage ? detail.failureDetail : undefined,
294
+ // Terminal rows only: rescue checkpoints stay wire-identical to normal ones.
295
+ fallback_outcome: stage === 'failed' || stage === 'aborted' ? fallbackOutcome : undefined,
296
+ harness_version: detail?.harnessVersion ?? harnessVersion,
297
+ auth_mode: detail?.authMode,
298
+ harness_degraded: detail?.harnessDegraded,
299
+ stage_duration_ms: stageDurationMs,
300
+ runtime_platform: process.platform,
301
+ node_version: process.version,
302
+ cli_version: CLI_VERSION,
303
+ // Same signal the usage events carry, so TTY and headless connects can be split.
304
+ is_human_initiated: detectHumanInitiated(),
305
+ // "none", not absent — absent has to keep meaning "CLI too old".
306
+ calling_harness: detectHarness() ?? 'none',
307
+ cli_invocation_id: CLI_INVOCATION_ID,
308
+ runtime_env: detectRuntimeEnv(),
309
+ });
310
+ }
255
311
  return {
256
312
  attemptId,
257
313
  // Fire-and-forget; awaiting each event stalls setup when telemetry down.
@@ -269,35 +325,7 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
269
325
  lastTrackAt = now;
270
326
  // Absorbed here, not in flush: flush runs in runGuarded's finally, so a rejection would
271
327
  // replace the error the flow is already reporting.
272
- pending.push(deliver(JSON.stringify({
273
- attempt_id: attemptId,
274
- client,
275
- stage,
276
- failed_stage: detail?.failedStage,
277
- phase: detail?.phase,
278
- failure_reason: detail?.failureReason,
279
- // Terminal rows only: rescue checkpoints stay wire-identical to normal ones.
280
- fallback_outcome: stage === 'failed' || stage === 'aborted' ? fallbackOutcome : undefined,
281
- harness_version: detail?.harnessVersion ?? harnessVersion,
282
- auth_mode: detail?.authMode,
283
- harness_degraded: detail?.harnessDegraded,
284
- stage_duration_ms: stageDurationMs,
285
- runtime_platform: process.platform,
286
- node_version: process.version,
287
- cli_version: CLI_VERSION,
288
- // Same signal the usage events carry, so TTY and headless connects can be split.
289
- is_human_initiated: detectHumanInitiated(),
290
- // "none", not absent — absent has to keep meaning "CLI too old".
291
- calling_harness: detectHarness() ?? 'none',
292
- cli_invocation_id: CLI_INVOCATION_ID,
293
- runtime_env: detectRuntimeEnv(),
294
- })).catch((error) => {
295
- // postConnectEvent is total, so this only fires if something inside deliver starts
296
- // throwing. Same channel postConnectEvent uses for its own failures.
297
- if (!process.env['TINYFISH_DEBUG'])
298
- return;
299
- errLine(`connect telemetry failed: ${error instanceof Error ? error.message : String(error)}`);
300
- }));
328
+ pending.push(deliver(eventBody(stage, detail, stageDurationMs)).catch(reportDeliverBug));
301
329
  },
302
330
  // Awaited in finally so in-flight events land before exit. Drains, so the signal-guard
303
331
  // flush and the finally flush do not re-await the same events.
@@ -4,7 +4,7 @@ import { z } from 'zod';
4
4
  import { loadConfig } from './auth.js';
5
5
  import { captureStdio, capturedOutput, replay, SKILL_INSTALL_TIMEOUT_MS, STEP_MAX_BUFFER, } from './cli-install.js';
6
6
  import { CURSOR_SKILL_TARGET, NATIVE_MCP_CLIENTS, OPENCLAW_SKILL_INSTALL_ARGS, } from './connect-clients.js';
7
- import { commandNotFound, spawnStepError, throwIfInterrupted } from './connect-runtime.js';
7
+ import { commandNotFound, spawnFailureDetail, spawnStepError, throwIfInterrupted, } from './connect-runtime.js';
8
8
  import { CLI_AGENT_IDENTITY } from './constants.js';
9
9
  import { errLine } from './output.js';
10
10
  // Supports Hermes without node:util.styleText, so the installer still runs on Node 20.11.
@@ -53,8 +53,12 @@ function failedListVerdict() {
53
53
  maxBuffer: STEP_MAX_BUFFER,
54
54
  timeout: SKILL_INSTALL_TIMEOUT_MS,
55
55
  });
56
- const verified = !list.error && list.status === 0 && skillListed(list.stdout ?? '');
57
- return verified ? null : list;
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 };
58
62
  }
59
63
  // Entry presence is the whole verdict: `agents` names the harnesses `skills` detects,
60
64
  // so an install for a harness with no config dir yet lists none (PF-3680).
@@ -78,10 +82,12 @@ export function installWebSkill(client, { verbose }) {
78
82
  ...captureStdio(verbose),
79
83
  env: skillSpawnEnv(),
80
84
  });
81
- const failed = add.error || add.status !== 0 ? add : failedListVerdict();
85
+ const addFailed = Boolean(add.error) || add.status !== 0;
86
+ const verdict = addFailed ? null : failedListVerdict();
87
+ const failed = addFailed ? add : verdict?.result;
82
88
  if (failed) {
83
89
  // Built first: its interrupt check must run before any replay.
84
- const error = spawnStepError(`Could not install the TinyFish web skill in ${client.displayName}`, failed);
90
+ const error = spawnStepError(`Could not install the TinyFish web skill in ${client.displayName}`, failed, verdict?.tag);
85
91
  // The add carries the failure prose; the list is JSON only.
86
92
  replay(capturedOutput(add));
87
93
  throw error;
@@ -154,12 +160,12 @@ function reinstallWebSkill(skillAgents, verbose) {
154
160
  const failedList = addFailed ? null : failedListVerdict();
155
161
  const failed = addFailed || failedList !== null;
156
162
  if (failed)
157
- throwIfInterrupted(failedList ?? result);
163
+ throwIfInterrupted(failedList?.result ?? result);
158
164
  if (verbose || failed)
159
165
  replay(output);
160
166
  if (failed) {
161
167
  throw new Error('Could not refresh the TinyFish web skill', {
162
- cause: (failedList ?? result).error,
168
+ cause: (failedList?.result ?? result).error,
163
169
  });
164
170
  }
165
171
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.33.1-next.280",
3
+ "version": "0.34.0",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {