Skip to content
Closed
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
10 changes: 8 additions & 2 deletions packages/use-agently/src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

import { cli } from "./cli";
import { checkAutoUpdate } from "./commands/update.js";
import { handleCliError, resolveOutputFormat } from "./errors.js";

await cli.parseAsync();
await checkAutoUpdate();
try {
await cli.parseAsync();
await checkAutoUpdate();
} catch (err) {
const output = resolveOutputFormat(cli.optsWithGlobals().output);
handleCliError(err, { output });
}
122 changes: 122 additions & 0 deletions packages/use-agently/src/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, expect, test } from "bun:test";

import { cli } from "./cli";
import { handleCliError, resolveOutputFormat } from "./errors";
import type { OutputFormat } from "./output";
import { captureOutput } from "./testing";

describe("CLI error formatting", () => {
const out = captureOutput();

test("formats CLI errors in TUI mode without stack traces", async () => {
const restoreTty = mockStderrTty(true);
try {
const exitCode = await runAndCapture(
[
"node",
"use-agently",
"-o",
"tui",
"mcp",
"call",
"--uri",
"http://example.com/mcp",
"--tool",
"echo",
"--args",
"{invalid",
],
"tui",
);
expect(exitCode).toBe(1);
expect(out.stderr).toContain("Invalid JSON in --args");
expect(out.stderr.split("\n").length).toBeGreaterThan(1);
expect(out.stderr).toContain("Error");
expect(out.stderr).not.toMatch(/\sat\s.+/);
} finally {
restoreTty();
}
});

test("formats errors as json when requested", async () => {
const exitCode = await runAndCapture(
[
"node",
"use-agently",
"-o",
"json",
"mcp",
"call",
"--uri",
"http://example.com/mcp",
"--tool",
"echo",
"--args",
"{invalid",
],
"json",
);
expect(exitCode).toBe(1);
expect(JSON.parse(out.stderr)).toHaveProperty("error");
});

test("formats errors according to resolved output format", async () => {
const exitCode = await runAndCapture([
"node",
"use-agently",
"mcp",
"call",
"--uri",
"http://example.com/mcp",
"--tool",
"echo",
"--args",
"{invalid",
]);
expect(exitCode).toBe(1);
const expectedFormat = resolveOutputFormat();
if (expectedFormat === "json") {
expect(JSON.parse(out.stderr).error).toContain("Invalid JSON in --args");
} else {
expect(out.stderr).toContain("Invalid JSON in --args");
}
});
});

function mockStderrTty(value: boolean): () => void {
const stderr = process.stderr as NodeJS.WriteStream & { isTTY?: boolean };
const original = stderr.isTTY;
Object.defineProperty(stderr, "isTTY", { configurable: true, value });
return () => {
Object.defineProperty(stderr, "isTTY", { configurable: true, value: original });
};
}

async function runCliWithErrorFormatting(argv: string[], output?: OutputFormat) {
try {
await cli.parseAsync(argv);
} catch (err) {
handleCliError(err, { output });
}
}

async function runAndCapture(argv: string[], output?: OutputFormat): Promise<number | undefined> {
let exitCode: number | undefined;
const exitSignal = Symbol("exit");
const originalExit = process.exit.bind(process);
const mockExit: (code?: number) => never = (code) => {
exitCode = code;
throw exitSignal;
};
process.exit = mockExit as typeof process.exit;

try {
await runCliWithErrorFormatting(argv, output);
} catch (err) {
if (err !== exitSignal) throw err;
} finally {
process.exit = originalExit;
}

return exitCode;
}
54 changes: 54 additions & 0 deletions packages/use-agently/src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import boxen from "boxen";
import type { OutputFormat } from "./output.js";

function formatErrorMessage(err: unknown): string {
if (err instanceof AggregateError) {
if (err.errors.length > 0) {
return err.errors.map((e) => formatErrorMessage(e)).join("; ");
}
return err.message || "An aggregate error occurred with no error details.";
}
if (err instanceof Error) {
return err.message || err.toString();
}
if (typeof err === "string") return err;
const errType = err === null ? "null" : typeof err;
let detail: string;
if (errType === "object") detail = "non-error object";
else if (errType === "undefined") detail = "undefined";
else detail = String(err);
return `An unknown error occurred. Received: ${detail} (${errType}).`;
}

function isStderrTty(): boolean {
return Boolean(process.stderr.isTTY);
}

export function resolveOutputFormat(output?: OutputFormat): OutputFormat {
if (output === "json" || output === "tui") return output;
// Agent-first default: when stdout is not a TTY (piped to another process or file), fall back to JSON for machine-readable output.
return process.stdout.isTTY ? "tui" : "json";
}

export function handleCliError(err: unknown, options?: { output?: OutputFormat }): never {
const output = resolveOutputFormat(options?.output);
const message = formatErrorMessage(err);
const tty = isStderrTty();

if (output === "json") {
console.error(JSON.stringify({ error: message }));
} else if (tty) {
console.error(
boxen(message, {
title: "Error",
titleAlignment: "center",
borderColor: "red",
padding: 1,
}),
);
} else {
console.error(`Error: ${message}`);
}

process.exit(1);
}
Loading