Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/from-file-error-detail.md
Original file line number Diff line number Diff line change
@@ -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".
31 changes: 2 additions & 29 deletions packages/cli/src/commands/feedback.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

/**
Expand Down Expand Up @@ -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"
Expand Down
56 changes: 10 additions & 46 deletions packages/cli/src/commands/rules.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { resolve } from "node:path";
import { readFile } from "node:fs/promises";
import process from "node:process";
import { defineCommand } from "citty";

Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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<typeof createInputSchema.parse>;
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"
Expand Down Expand Up @@ -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<typeof improveInputSchema.parse>;
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"
Expand Down Expand Up @@ -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"
);
}
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 —
Expand Down
71 changes: 71 additions & 0 deletions packages/cli/src/util/json-input.ts
Original file line number Diff line number Diff line change
@@ -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 '<path>'`, 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<T>(
filePath: string,
schema: ZodType<T>
): Promise<T> {
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;
}
73 changes: 73 additions & 0 deletions packages/cli/test/json-input.test.ts
Original file line number Diff line number Diff line change
@@ -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" })
);
});
});
6 changes: 5 additions & 1 deletion packages/cli/test/rule-from.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
});
});
Expand Down
Loading