@tiny-fish/cli 0.21.1-next.196 → 0.22.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.
@@ -1,7 +1,7 @@
1
1
  import spawn from "cross-spawn";
2
2
  import { apiKeyStatus } from "../lib/auth.js";
3
3
  import { CLI_VERSION } from "../lib/constants.js";
4
- import { DOCTOR_COULD_NOT_RUN, DOCTOR_SCHEMA_VERSION, doctorReportSchema, exitCodeFor, renderPretty, } from "../lib/doctor-report.js";
4
+ import { DOCTOR_COULD_NOT_RUN, DOCTOR_SCHEMA_VERSION, doctorReportSchema, exitCodeFor, renderPretty, verdictFor, } from "../lib/doctor-report.js";
5
5
  import { ALL_HARNESSES, AuthMode, Registered } from "../lib/harness-detect.js";
6
6
  import { detectHumanInitiated } from "../lib/harness.js";
7
7
  import { err, errLine, out, outLine } from "../lib/output.js";
@@ -30,6 +30,7 @@ function checkCliVersion() {
30
30
  status: "pass",
31
31
  detail: `${CLI_VERSION}; the server reports staleness via X-TF-Notice`,
32
32
  harness: null,
33
+ scope: "info",
33
34
  };
34
35
  }
35
36
  async function checkConnectivity(mcpUrl) {
@@ -39,7 +40,9 @@ async function checkConnectivity(mcpUrl) {
39
40
  title: "MCP endpoint reachable",
40
41
  status: health.ok ? "pass" : "fail",
41
42
  detail: health.ok ? mcpUrl : (health.code ?? "the endpoint is unreachable"),
43
+ // Harness-scoped: the CLI's own reachability is `cli-auth-call`'s claim.
42
44
  harness: null,
45
+ scope: "harness",
43
46
  };
44
47
  }
45
48
  // Query strings carry the connect attempt id, so they differ on every healthy install.
@@ -60,8 +63,21 @@ function pointsElsewhere(registeredUrl, mcpUrl) {
60
63
  return "an unparseable endpoint";
61
64
  }
62
65
  }
63
- function keyedVerdict(status, keyAuth) {
66
+ function keyedVerdict(status, keyAuth, cliKeyMissing) {
64
67
  if (!keyAuth) {
68
+ // Its auth is the CLI credential; a warn would exit 0.
69
+ if (status.usesCliKey) {
70
+ return cliKeyMissing
71
+ ? {
72
+ status: "fail",
73
+ detail: "registered, but it runs on the CLI credential, which is missing or unusable",
74
+ }
75
+ : // Reachable with a key only when `--url` skipped verification.
76
+ {
77
+ status: "warn",
78
+ detail: "registered, but its CLI credential was not verified against this endpoint",
79
+ };
80
+ }
65
81
  // A bare warn beside exit 0 reads as a pass.
66
82
  const because = status.keyReason ?? "its key is not readable here";
67
83
  return { status: "warn", detail: `registered, but ${because}, so its reach is unproven` };
@@ -88,7 +104,7 @@ function keyedVerdict(status, keyAuth) {
88
104
  }
89
105
  return { status: "warn", detail: "registered, key could not be verified" };
90
106
  }
91
- function registrationVerdict(status, mcpUrl, keyAuth) {
107
+ function registrationVerdict(status, mcpUrl, keyAuth, cliKeyMissing) {
92
108
  if (!status.detected)
93
109
  return { status: "skip", detail: "harness not installed" };
94
110
  if (status.registered === Registered.Unknown) {
@@ -116,7 +132,7 @@ function registrationVerdict(status, mcpUrl, keyAuth) {
116
132
  }
117
133
  // Widening past api-key would warn every healthy oauth install.
118
134
  if (status.authMode === AuthMode.ApiKey)
119
- return keyedVerdict(status, keyAuth);
135
+ return keyedVerdict(status, keyAuth, cliKeyMissing);
120
136
  // A probe that had to explain itself to reach `yes` is the only thing that explains `unknown`.
121
137
  const because = status.reason ? ` (${status.reason})` : "";
122
138
  return { status: "pass", detail: `registered, auth mode ${status.authMode}${because}` };
@@ -126,12 +142,13 @@ function registrationVerdict(status, mcpUrl, keyAuth) {
126
142
  ? { status: "fail", detail: "connected previously but TinyFish is no longer registered" }
127
143
  : { status: "warn", detail: "installed but TinyFish was never connected" };
128
144
  }
129
- function checkRegistration(status, mcpUrl, keyAuth) {
145
+ function checkRegistration(status, mcpUrl, keyAuth, cliKeyMissing) {
130
146
  return {
131
147
  id: "harness-registration",
132
148
  title: `${status.harness} registration`,
133
149
  harness: status.harness,
134
- ...registrationVerdict(status, mcpUrl, keyAuth),
150
+ scope: "harness",
151
+ ...registrationVerdict(status, mcpUrl, keyAuth, cliKeyMissing),
135
152
  };
136
153
  }
137
154
  function checkCredential() {
@@ -142,6 +159,7 @@ function checkCredential() {
142
159
  status: "key" in resolved ? "pass" : "fail",
143
160
  detail: "key" in resolved ? `key present from ${resolved.source}` : resolved.error,
144
161
  harness: null,
162
+ scope: "cli",
145
163
  };
146
164
  return "key" in resolved ? { check, key: resolved.key } : { check };
147
165
  }
@@ -153,6 +171,7 @@ function checkAuthCall(auth) {
153
171
  status: "skip",
154
172
  detail: "no credential to test",
155
173
  harness: null,
174
+ scope: "cli",
156
175
  };
157
176
  }
158
177
  return {
@@ -161,6 +180,7 @@ function checkAuthCall(auth) {
161
180
  status: auth.ok ? "pass" : "fail",
162
181
  detail: auth.ok ? "listRuns succeeded" : (auth.code ?? "the authenticated call failed"),
163
182
  harness: null,
183
+ scope: "cli",
164
184
  };
165
185
  }
166
186
  // `verifyMcpAuth` hits BASE_URL; a sandbox key 401s against prod.
@@ -178,8 +198,8 @@ function verifyHarnessKey(status, cliKey, cliAuth, mcpUrl) {
178
198
  // `pointsElsewhere` already fails; a second reason only misleads.
179
199
  if (pointsElsewhere(status.registeredUrl, mcpUrl))
180
200
  return undefined;
181
- // OpenClaw's skill shells this CLI, so its verdict stands in.
182
- if (status.harness === "openclaw")
201
+ // A harness with no key of its own reads the CLI's.
202
+ if (status.usesCliKey)
183
203
  return cliAuth;
184
204
  if (!status.apiKey)
185
205
  return undefined;
@@ -237,7 +257,7 @@ export async function runDoctor(options) {
237
257
  const checks = [
238
258
  checkCliVersion(),
239
259
  connectivity,
240
- ...statuses.map((status, i) => checkRegistration(status, options.mcpUrl, keyAuths[i])),
260
+ ...statuses.map((status, i) => checkRegistration(status, options.mcpUrl, keyAuths[i], !credential.key)),
241
261
  credential.check,
242
262
  checkAuthCall(cliAuthResult),
243
263
  ];
@@ -252,7 +272,7 @@ export async function runDoctor(options) {
252
272
  const body = {
253
273
  schema_version: DOCTOR_SCHEMA_VERSION,
254
274
  cli_version: CLI_VERSION,
255
- ok: exitCodeFor(checks) === 0,
275
+ ...verdictFor(checks),
256
276
  checks,
257
277
  harnesses,
258
278
  repairs: repairsFor(checks, statuses),
@@ -1,13 +1,18 @@
1
1
  import { z } from "zod";
2
2
  import { AuthMode, Registered } from "./harness-detect.js";
3
3
  /** Bumped whenever a consumer could misread the payload; the cookbook skill releases separately. */
4
- export declare const DOCTOR_SCHEMA_VERSION = 2;
4
+ export declare const DOCTOR_SCHEMA_VERSION = 3;
5
5
  declare const checkStatusSchema: z.ZodEnum<{
6
6
  pass: "pass";
7
7
  fail: "fail";
8
8
  warn: "warn";
9
9
  skip: "skip";
10
10
  }>;
11
+ declare const checkScopeSchema: z.ZodEnum<{
12
+ cli: "cli";
13
+ harness: "harness";
14
+ info: "info";
15
+ }>;
11
16
  declare const doctorCheckSchema: z.ZodObject<{
12
17
  id: z.ZodString;
13
18
  title: z.ZodString;
@@ -26,6 +31,11 @@ declare const doctorCheckSchema: z.ZodObject<{
26
31
  opencode: "opencode";
27
32
  "claude-code": "claude-code";
28
33
  }>>;
34
+ scope: z.ZodEnum<{
35
+ cli: "cli";
36
+ harness: "harness";
37
+ info: "info";
38
+ }>;
29
39
  }, z.core.$strip>;
30
40
  declare const doctorHarnessSchema: z.ZodObject<{
31
41
  harness: z.ZodEnum<{
@@ -61,7 +71,8 @@ declare const doctorRepairSchema: z.ZodObject<{
61
71
  export declare const doctorReportSchema: z.ZodObject<{
62
72
  schema_version: z.ZodInt;
63
73
  cli_version: z.ZodString;
64
- ok: z.ZodBoolean;
74
+ ok_harnesses: z.ZodBoolean;
75
+ ok_cli: z.ZodBoolean;
65
76
  checks: z.ZodArray<z.ZodObject<{
66
77
  id: z.ZodString;
67
78
  title: z.ZodString;
@@ -80,6 +91,11 @@ export declare const doctorReportSchema: z.ZodObject<{
80
91
  opencode: "opencode";
81
92
  "claude-code": "claude-code";
82
93
  }>>;
94
+ scope: z.ZodEnum<{
95
+ cli: "cli";
96
+ harness: "harness";
97
+ info: "info";
98
+ }>;
83
99
  }, z.core.$strip>>;
84
100
  harnesses: z.ZodArray<z.ZodObject<{
85
101
  harness: z.ZodEnum<{
@@ -114,6 +130,7 @@ export declare const doctorReportSchema: z.ZodObject<{
114
130
  }, z.core.$strip>>;
115
131
  }, z.core.$strip>;
116
132
  export type CheckStatus = z.infer<typeof checkStatusSchema>;
133
+ export type CheckScope = z.infer<typeof checkScopeSchema>;
117
134
  export type DoctorCheck = z.infer<typeof doctorCheckSchema>;
118
135
  export type DoctorHarness = z.infer<typeof doctorHarnessSchema>;
119
136
  export type DoctorRepair = z.infer<typeof doctorRepairSchema>;
@@ -121,6 +138,11 @@ export type DoctorReport = z.infer<typeof doctorReportSchema>;
121
138
  /** Doctor itself broke, which is a different claim from "your setup is broken". */
122
139
  export declare const DOCTOR_COULD_NOT_RUN = 2;
123
140
  /** Skips never fail the run; a skip means not applicable, not broken. */
141
+ export declare function verdictFor(checks: DoctorCheck[]): {
142
+ ok_harnesses: boolean;
143
+ ok_cli: boolean;
144
+ };
145
+ /** Harness scope only: the caller asked about its agent, not us. */
124
146
  export declare function exitCodeFor(checks: DoctorCheck[]): 0 | 1;
125
147
  export declare function renderPretty(report: DoctorReport): string;
126
148
  export {};
@@ -1,15 +1,18 @@
1
1
  import { z } from "zod";
2
2
  import { ALL_HARNESSES, AuthMode, Registered } from "./harness-detect.js";
3
3
  /** Bumped whenever a consumer could misread the payload; the cookbook skill releases separately. */
4
- export const DOCTOR_SCHEMA_VERSION = 2;
4
+ export const DOCTOR_SCHEMA_VERSION = 3;
5
5
  const checkStatusSchema = z.enum(["pass", "fail", "warn", "skip"]);
6
6
  const harnessSchema = z.enum(ALL_HARNESSES);
7
+ // Not derived from `harness`: connectivity is harness-null yet harness-scoped.
8
+ const checkScopeSchema = z.enum(["harness", "cli", "info"]);
7
9
  const doctorCheckSchema = z.object({
8
10
  id: z.string(),
9
11
  title: z.string(),
10
12
  status: checkStatusSchema,
11
13
  detail: z.string(),
12
14
  harness: harnessSchema.nullable(),
15
+ scope: checkScopeSchema,
13
16
  });
14
17
  const doctorHarnessSchema = z.object({
15
18
  harness: harnessSchema,
@@ -27,27 +30,54 @@ const doctorRepairSchema = z.object({
27
30
  command: z.string(),
28
31
  unattended_safe: z.boolean(),
29
32
  });
33
+ function hasFailure(checks, scope) {
34
+ return checks.some((check) => check.scope === scope && check.status === "fail");
35
+ }
36
+ // One `ok` could not answer both questions, and answered neither.
30
37
  export const doctorReportSchema = z
31
38
  .object({
32
39
  // Not `z.literal`: a consumer must be able to read a future version and say so.
33
40
  schema_version: z.int().positive(),
34
41
  cli_version: z.string(),
35
- ok: z.boolean(),
42
+ ok_harnesses: z.boolean(),
43
+ ok_cli: z.boolean(),
36
44
  checks: z.array(doctorCheckSchema),
37
45
  harnesses: z.array(doctorHarnessSchema),
38
46
  repairs: z.array(doctorRepairSchema),
39
47
  })
40
- // A report that says ok while a check failed is worse than no report.
41
- .refine((report) => !report.ok || report.checks.every((check) => check.status !== "fail"), {
42
- message: "ok cannot be true while a check has failed",
43
- path: ["ok"],
48
+ // Claiming ok while that scope failed is worse than no report.
49
+ .refine((report) => !report.ok_harnesses || !hasFailure(report.checks, "harness"), {
50
+ message: "ok_harnesses cannot be true while a harness check has failed",
51
+ path: ["ok_harnesses"],
52
+ })
53
+ .refine((report) => !report.ok_cli || !hasFailure(report.checks, "cli"), {
54
+ message: "ok_cli cannot be true while a CLI check has failed",
55
+ path: ["ok_cli"],
44
56
  });
45
57
  const GLYPHS = { pass: "✓", fail: "✗", warn: "⚠", skip: "…" };
46
58
  /** Doctor itself broke, which is a different claim from "your setup is broken". */
47
59
  export const DOCTOR_COULD_NOT_RUN = 2;
48
60
  /** Skips never fail the run; a skip means not applicable, not broken. */
61
+ export function verdictFor(checks) {
62
+ return { ok_harnesses: !hasFailure(checks, "harness"), ok_cli: !hasFailure(checks, "cli") };
63
+ }
64
+ /** Harness scope only: the caller asked about its agent, not us. */
49
65
  export function exitCodeFor(checks) {
50
- return checks.some((check) => check.status === "fail") ? 1 : 0;
66
+ return verdictFor(checks).ok_harnesses ? 0 : 1;
67
+ }
68
+ // A red line above exit 0 reads as a contradiction.
69
+ function footnotes(report) {
70
+ const notes = [];
71
+ // Alongside exit 1 this is noise; something did fail.
72
+ if (hasFailure(report.checks, "cli") && report.ok_harnesses) {
73
+ notes.push("The CLI's own credential is broken; that does not affect the exit code.");
74
+ }
75
+ // Per-harness rows, not the scope: connectivity is harness-scoped and never skips.
76
+ const harnessChecks = report.checks.filter((c) => c.harness !== null);
77
+ if (harnessChecks.length > 0 && harnessChecks.every((c) => c.status === "skip")) {
78
+ notes.push("No harness was checked here, so a green harness verdict proves nothing.");
79
+ }
80
+ return notes;
51
81
  }
52
82
  export function renderPretty(report) {
53
83
  const lines = report.checks.map((c) => `${GLYPHS[c.status]} ${c.title}${c.detail ? ` — ${c.detail}` : ""}`);
@@ -56,5 +86,8 @@ export function renderPretty(report) {
56
86
  for (const repair of report.repairs)
57
87
  lines.push(` ${repair.command}`);
58
88
  }
89
+ const notes = footnotes(report);
90
+ if (notes.length > 0)
91
+ lines.push("", ...notes);
59
92
  return lines.join("\n");
60
93
  }
@@ -11,6 +11,8 @@ export interface RegistrationStatus {
11
11
  apiKey?: string;
12
12
  /** From the environment, not the config, so a 401 is ambiguous. */
13
13
  keyIsIndirect?: boolean;
14
+ /** Holds no key; reads the CLI's at runtime. */
15
+ usesCliKey?: boolean;
14
16
  connected?: boolean;
15
17
  /** Why a keyed registration's key could not be read; not `reason`. */
16
18
  keyReason?: string;
@@ -324,7 +324,7 @@ function probeOpenClaw() {
324
324
  }
325
325
  // A bare substring also matched a skill merely named `not-tinyfish-thing`.
326
326
  return entries.some((name) => SKILL_DIR_IS_TINYFISH.test(name))
327
- ? { registered: Registered.Yes, authMode: AuthMode.ApiKey }
327
+ ? { registered: Registered.Yes, authMode: AuthMode.ApiKey, usesCliKey: true }
328
328
  : { registered: Registered.No, authMode: AuthMode.Unknown };
329
329
  }
330
330
  // `opencode mcp list` prints no headers, so a key-authed registration is indistinguishable.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.21.1-next.196",
3
+ "version": "0.22.0",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {