diff --git a/.changeset/from-file-error-detail.md b/.changeset/from-file-error-detail.md new file mode 100644 index 00000000..4f8fe3bb --- /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: 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..dddde6dc --- /dev/null +++ b/packages/cli/src/util/json-input.ts @@ -0,0 +1,71 @@ +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. + * + * 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.replace(/^Invalid input: /, "")}` + ) + .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..1cf485fb --- /dev/null +++ b/packages/cli/test/json-input.test.ts @@ -0,0 +1,73 @@ +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: 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" + ); + }); + + 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..a86c47bb 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( + "Invalid input: prompt: expected string, received undefined" + ); } }); });