From 698048e5b95315abc9da78c9530a76e2598c088a Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 5 Oct 2026 20:33:10 -0700 Subject: [PATCH 1/2] fix(cli): name the field and the read failure in --from request errors rule create/improve and feedback send share one reader that reports the resolved path with the read error's code, the JSON parser's message, and each zod issue's field path. The CLI entry registers zod's English locale, which bundling dropped because zod declares itself side-effect free. Fixes #456 --- .changeset/from-file-error-detail.md | 5 +++ packages/cli/src/commands/feedback.ts | 31 +------------ packages/cli/src/commands/rules.ts | 56 +++++------------------ packages/cli/src/index.ts | 7 +++ packages/cli/src/util/json-input.ts | 63 ++++++++++++++++++++++++++ packages/cli/test/json-input.test.ts | 65 +++++++++++++++++++++++++++ packages/cli/test/rule-from.test.ts | 6 ++- 7 files changed, 157 insertions(+), 76 deletions(-) create mode 100644 .changeset/from-file-error-detail.md create mode 100644 packages/cli/src/util/json-input.ts create mode 100644 packages/cli/test/json-input.test.ts diff --git a/.changeset/from-file-error-detail.md b/.changeset/from-file-error-detail.md new file mode 100644 index 00000000..2a6702c9 --- /dev/null +++ b/.changeset/from-file-error-detail.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +`rule create --from`, `rule improve --from` and `feedback send --from` now say what is wrong with a request file. A validation error names each failing field (`prompt: Invalid input: expected string, received undefined; successCases.0: …`) instead of repeating bare messages, a read failure gives the resolved path and the reason (`ENOENT`, `EISDIR`), and a JSON error carries the parser's position. `rule meta` names fields the same way. The built CLI also registers zod's English messages explicitly, since zod declares itself side-effect free and bundling had dropped them, leaving every issue as a bare "Invalid input". diff --git a/packages/cli/src/commands/feedback.ts b/packages/cli/src/commands/feedback.ts index ec474b62..ce35c70c 100644 --- a/packages/cli/src/commands/feedback.ts +++ b/packages/cli/src/commands/feedback.ts @@ -1,9 +1,7 @@ -import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import process from "node:process"; import { defineCommand } from "citty"; -import { ZodError } from "zod"; import { inputSchema, type FeedbackInput } from "../schemas/feedback"; import { writeNextAsk } from "../survey/cadence"; @@ -15,6 +13,7 @@ import { import { getTelemetry, isTelemetryEnabled } from "../telemetry"; import { type CLIErrorCode, writeJsonError } from "../types/errors"; import { CLIError } from "../util/cli-error"; +import { readJsonInput } from "../util/json-input"; import { getCliPrefix } from "../util/package-manager"; /** @@ -117,36 +116,10 @@ const sendCommand = defineCommand({ ); } - const filePath = resolve(cwd, args.from); - let fileContent: string; - try { - fileContent = await readFile(filePath, "utf8"); - } catch { - fail(`Could not read file "${args.from}".`, "INVALID_INPUT"); - } - - let rawJson: unknown; - try { - rawJson = JSON.parse(fileContent) as unknown; - } catch { - fail(`"${args.from}" is not valid JSON.`, "INVALID_INPUT"); - } - let input: FeedbackInput; try { - input = inputSchema.parse(rawJson); + input = await readJsonInput(resolve(cwd, args.from), inputSchema); } catch (error) { - if (error instanceof ZodError) { - fail( - `Invalid input: ${error.issues - .map( - (issue) => - `${issue.path.join(".") || "payload"}: ${issue.message}` - ) - .join(", ")}`, - "INVALID_INPUT" - ); - } fail( error instanceof Error ? error.message : String(error), "INVALID_INPUT" diff --git a/packages/cli/src/commands/rules.ts b/packages/cli/src/commands/rules.ts index 7cd8cdd7..4d080e77 100644 --- a/packages/cli/src/commands/rules.ts +++ b/packages/cli/src/commands/rules.ts @@ -1,5 +1,4 @@ import { resolve } from "node:path"; -import { readFile } from "node:fs/promises"; import process from "node:process"; import { defineCommand } from "citty"; @@ -48,6 +47,7 @@ import { } from "../rules/recover"; import { getTelemetry } from "../telemetry"; import { CLIError } from "../util/cli-error"; +import { formatZodIssues, readJsonInput } from "../util/json-input"; import { type CLIErrorCode, writeJsonError } from "../types/errors"; import { getCliPrefix } from "../util/package-manager"; @@ -283,31 +283,13 @@ const createCommand = defineCommand({ ); } - const filePath = resolve(cwd, args.from); - let fileContent: string; - try { - fileContent = await readFile(filePath, "utf8"); - } catch { - fail(`Could not read file "${args.from}".`, "INVALID_INPUT"); - } - - let rawJson: unknown; - try { - rawJson = JSON.parse(fileContent) as unknown; - } catch { - fail(`"${args.from}" is not valid JSON.`, "INVALID_INPUT"); - } - let request: ReturnType; try { - request = createInputSchema.parse(rawJson); + request = await readJsonInput( + resolve(cwd, args.from), + createInputSchema + ); } catch (error) { - if (error instanceof ZodError) { - fail( - `Invalid input: ${error.issues.map((issue) => issue.message).join(", ")}`, - "INVALID_INPUT" - ); - } fail( error instanceof Error ? error.message : String(error), "INVALID_INPUT" @@ -429,31 +411,13 @@ const improveCommand = defineCommand({ ); } - const filePath = resolve(cwd, args.from); - let fileContent: string; - try { - fileContent = await readFile(filePath, "utf8"); - } catch { - fail(`Could not read file "${args.from}".`, "INVALID_INPUT"); - } - - let rawJson: unknown; - try { - rawJson = JSON.parse(fileContent) as unknown; - } catch { - fail(`"${args.from}" is not valid JSON.`, "INVALID_INPUT"); - } - let request: ReturnType; try { - request = improveInputSchema.parse(rawJson); + request = await readJsonInput( + resolve(cwd, args.from), + improveInputSchema + ); } catch (error) { - if (error instanceof ZodError) { - fail( - `Invalid input: ${error.issues.map((issue) => issue.message).join(", ")}`, - "INVALID_INPUT" - ); - } fail( error instanceof Error ? error.message : String(error), "INVALID_INPUT" @@ -578,7 +542,7 @@ const metaCommand = defineCommand({ } catch (error) { if (error instanceof ZodError) { fail( - `Invalid metadata for rule "${args.id}": ${error.issues.map((issue) => issue.message).join(", ")}`, + `Invalid metadata for rule "${args.id}": ${formatZodIssues(error)}`, "INVALID_INPUT" ); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index edded0c3..294d597f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,6 +1,7 @@ import process from "node:process"; import { defineCommand, runCommand, showUsage } from "citty"; +import { z } from "zod"; import { createAgentCommand } from "./commands/agent"; import { authCommand } from "./commands/auth"; @@ -34,6 +35,12 @@ import { showResolvedUsage } from "./util/help"; import { getCliPrefix } from "./util/package-manager"; import { CLIError } from "./util/cli-error"; +// zod registers its English messages as a side effect of importing "zod", but +// its package.json declares `"sideEffects": false`, so the bundler drops that +// registration from dist/. Every issue then reads a bare "Invalid input", with +// no "expected string, received undefined" to say what was wrong. +z.config(z.locales.en()); + // `satisfies` against the name list (minus `agent`, which is constructed from // this record below) is what keeps `SUBCOMMAND_NAMES` honest: adding a command // here without naming it there — or naming one there that is never registered — diff --git a/packages/cli/src/util/json-input.ts b/packages/cli/src/util/json-input.ts new file mode 100644 index 00000000..b9ac7d17 --- /dev/null +++ b/packages/cli/src/util/json-input.ts @@ -0,0 +1,63 @@ +import { readFile } from "node:fs/promises"; + +import type { ZodError, ZodType } from "zod"; + +/** + * One line per issue, each led by the field path it is about. A request file + * is usually written by an agent, and "expected string, received undefined" + * is only actionable once it says which field. Issues are joined with "; " + * because zod's own messages already contain commas. + */ +export function formatZodIssues(error: ZodError): string { + return error.issues + .map((issue) => `${issue.path.join(".") || "payload"}: ${issue.message}`) + .join("; "); +} + +/** + * Node's read errors end in `, open ''`, repeating the path the caller + * already names. Keep the code and its description: "ENOENT: no such file or + * directory". + */ +function readReason(error: unknown): string { + if (!(error instanceof Error)) return String(error); + const { syscall, path } = error as NodeJS.ErrnoException; + return syscall && path + ? error.message.replace(`, ${syscall} '${path}'`, "") + : error.message; +} + +/** + * Read a `--from` request file, parse it as JSON and validate it. + * + * Throws an `Error` whose message is ready to show the user: it names the + * resolved path, and carries the underlying reason (the `ENOENT`/`EISDIR` + * from the read, the parser's position, the failing field paths) rather than + * collapsing every failure into one sentence. + */ +export async function readJsonInput( + filePath: string, + schema: ZodType +): Promise { + let fileContent: string; + try { + fileContent = await readFile(filePath, "utf8"); + } catch (error) { + throw new Error(`Could not read file "${filePath}": ${readReason(error)}`); + } + + let rawJson: unknown; + try { + rawJson = JSON.parse(fileContent) as unknown; + } catch (error) { + throw new Error( + `"${filePath}" is not valid JSON: ${error instanceof Error ? error.message : String(error)}` + ); + } + + const result = schema.safeParse(rawJson); + if (!result.success) { + throw new Error(`Invalid input: ${formatZodIssues(result.error)}`); + } + return result.data; +} diff --git a/packages/cli/test/json-input.test.ts b/packages/cli/test/json-input.test.ts new file mode 100644 index 00000000..5a7a8b5c --- /dev/null +++ b/packages/cli/test/json-input.test.ts @@ -0,0 +1,65 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { inputSchema as createInputSchema } from "../src/schemas/rules-create"; +import { readJsonInput } from "../src/util/json-input"; + +describe("readJsonInput", () => { + let temporaryDirectory: string; + + beforeEach(async () => { + temporaryDirectory = await mkdtemp(join(tmpdir(), "taskless-json-input-")); + }); + + afterEach(async () => { + await rm(temporaryDirectory, { recursive: true, force: true }); + }); + + it("names the resolved path and the reason when the file is missing", async () => { + const filePath = join(temporaryDirectory, "missing.json"); + await expect(readJsonInput(filePath, z.unknown())).rejects.toThrow( + `Could not read file "${filePath}": ENOENT: no such file or directory` + ); + }); + + it("tells a directory apart from a missing file", async () => { + await expect( + readJsonInput(temporaryDirectory, z.unknown()) + ).rejects.toThrow("EISDIR"); + }); + + it("carries the parser's message when the file is not JSON", async () => { + const filePath = join(temporaryDirectory, "bad.json"); + await writeFile(filePath, "not json at all"); + await expect(readJsonInput(filePath, z.unknown())).rejects.toThrow( + `"${filePath}" is not valid JSON: Unexpected token` + ); + }); + + it("names every failing field path", async () => { + const filePath = join(temporaryDirectory, "request.json"); + await writeFile(filePath, JSON.stringify({ successCases: [1] })); + await expect(readJsonInput(filePath, createInputSchema)).rejects.toThrow( + /^Invalid input: prompt: .+; successCases\.0: / + ); + }); + + it("labels an issue on the whole document as the payload", async () => { + const filePath = join(temporaryDirectory, "array.json"); + await writeFile(filePath, "[]"); + await expect(readJsonInput(filePath, createInputSchema)).rejects.toThrow( + "Invalid input: payload: " + ); + }); + + it("returns the parsed value when the request is valid", async () => { + const filePath = join(temporaryDirectory, "ok.json"); + await writeFile(filePath, JSON.stringify({ prompt: "no console.log" })); + await expect(readJsonInput(filePath, createInputSchema)).resolves.toEqual( + expect.objectContaining({ prompt: "no console.log" }) + ); + }); +}); diff --git a/packages/cli/test/rule-from.test.ts b/packages/cli/test/rule-from.test.ts index 60e50505..7ac9a1dd 100644 --- a/packages/cli/test/rule-from.test.ts +++ b/packages/cli/test/rule-from.test.ts @@ -105,7 +105,11 @@ describe("rules create --from", () => { expect.fail("should have exited with non-zero code"); } catch (error) { const execError = error as { stderr: string }; - expect(execError.stderr).toContain("Invalid input"); + // The field path, and zod's English detail. The latter only survives + // bundling because the CLI entry registers the locale explicitly. + expect(execError.stderr).toContain( + "prompt: Invalid input: expected string, received undefined" + ); } }); }); From c2a82ea15d9df54ef7cbf127259632ed7001ab96 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 5 Oct 2026 21:27:17 -0700 Subject: [PATCH 2/2] fix(cli): print "Invalid input" once per zod validation error zod opens its built-in messages with "Invalid input: ", which the caller already prints as the line's lead. Strip it per issue; custom messages pass through unchanged. --- .changeset/from-file-error-detail.md | 2 +- packages/cli/src/util/json-input.ts | 10 +++++++++- packages/cli/test/json-input.test.ts | 10 +++++++++- packages/cli/test/rule-from.test.ts | 2 +- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.changeset/from-file-error-detail.md b/.changeset/from-file-error-detail.md index 2a6702c9..4f8fe3bb 100644 --- a/.changeset/from-file-error-detail.md +++ b/.changeset/from-file-error-detail.md @@ -2,4 +2,4 @@ "@taskless/cli": patch --- -`rule create --from`, `rule improve --from` and `feedback send --from` now say what is wrong with a request file. A validation error names each failing field (`prompt: Invalid input: expected string, received undefined; successCases.0: …`) instead of repeating bare messages, a read failure gives the resolved path and the reason (`ENOENT`, `EISDIR`), and a JSON error carries the parser's position. `rule meta` names fields the same way. The built CLI also registers zod's English messages explicitly, since zod declares itself side-effect free and bundling had dropped them, leaving every issue as a bare "Invalid input". +`rule create --from`, `rule improve --from` and `feedback send --from` now say what is wrong with a request file. A validation error names each failing field (`prompt: expected string, received undefined; successCases.0: …`) instead of repeating bare messages, a read failure gives the resolved path and the reason (`ENOENT`, `EISDIR`), and a JSON error carries the parser's position. `rule meta` names fields the same way. The built CLI also registers zod's English messages explicitly, since zod declares itself side-effect free and bundling had dropped them, leaving every issue as a bare "Invalid input". diff --git a/packages/cli/src/util/json-input.ts b/packages/cli/src/util/json-input.ts index b9ac7d17..dddde6dc 100644 --- a/packages/cli/src/util/json-input.ts +++ b/packages/cli/src/util/json-input.ts @@ -7,10 +7,18 @@ import type { ZodError, ZodType } from "zod"; * is usually written by an agent, and "expected string, received undefined" * is only actionable once it says which field. Issues are joined with "; " * because zod's own messages already contain commas. + * + * zod opens its built-in messages with "Invalid input: ", which every caller + * already prints once as the line's lead, so it is dropped per issue. A custom + * message ("prompt must be a non-empty string") does not carry it and passes + * through unchanged. */ export function formatZodIssues(error: ZodError): string { return error.issues - .map((issue) => `${issue.path.join(".") || "payload"}: ${issue.message}`) + .map( + (issue) => + `${issue.path.join(".") || "payload"}: ${issue.message.replace(/^Invalid input: /, "")}` + ) .join("; "); } diff --git a/packages/cli/test/json-input.test.ts b/packages/cli/test/json-input.test.ts index 5a7a8b5c..1cf485fb 100644 --- a/packages/cli/test/json-input.test.ts +++ b/packages/cli/test/json-input.test.ts @@ -43,7 +43,15 @@ describe("readJsonInput", () => { const filePath = join(temporaryDirectory, "request.json"); await writeFile(filePath, JSON.stringify({ successCases: [1] })); await expect(readJsonInput(filePath, createInputSchema)).rejects.toThrow( - /^Invalid input: prompt: .+; successCases\.0: / + "Invalid input: prompt: expected string, received undefined; successCases.0: expected string, received number" + ); + }); + + it("passes a custom message through unchanged", async () => { + const filePath = join(temporaryDirectory, "empty.json"); + await writeFile(filePath, JSON.stringify({ prompt: " " })); + await expect(readJsonInput(filePath, createInputSchema)).rejects.toThrow( + "Invalid input: prompt: prompt must be a non-empty string" ); }); diff --git a/packages/cli/test/rule-from.test.ts b/packages/cli/test/rule-from.test.ts index 7ac9a1dd..a86c47bb 100644 --- a/packages/cli/test/rule-from.test.ts +++ b/packages/cli/test/rule-from.test.ts @@ -108,7 +108,7 @@ describe("rules create --from", () => { // The field path, and zod's English detail. The latter only survives // bundling because the CLI entry registers the locale explicitly. expect(execError.stderr).toContain( - "prompt: Invalid input: expected string, received undefined" + "Invalid input: prompt: expected string, received undefined" ); } });