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
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,34 @@ arena channel contents --help
| `--yes` | Bypass destructive confirmation prompts |
| `--help` | Show help |

## Output & Errors

- `stdout`:
- Successful command output.
- `--json` returns JSON objects; `import --json` returns NDJSON progress events.
- `--quiet` only changes JSON formatting (compact, single-line); it does **not** change response fields.
- `stderr`:
- Human-readable failures in non-interactive mode.
- Structured JSON errors in `--json` mode.
- JSON error shape:
- `{"error": string, "code": number | null, "type": string, "hint"?: string}`
- Common `type` values include: `unknown_command`, `unknown_subcommand`, `json_not_supported`, API-derived types like `not_found`.
- Exit codes:
- `0` success
- `1` client/usage errors and unknown command/subcommand
- `2` unauthorized (`401`)
- `3` not found (`404`)
- `4` validation/bad request (`400`, `422`)
- `5` rate limited (`429`)
- `6` forbidden (`403`)

### Non-Interactive Behavior

- Interactive session mode only starts when **both** `stdin` and `stdout` are TTYs.
- In non-interactive contexts (pipes/CI), output is deterministic and unknown commands fail fast with non-zero exits.
- `batch` is JSON-only; use `--json`.
- For stdin-driven automation (for example piping content into `add`), use `--json`.

## Command Reference

Examples are shown first, then options.
Expand Down Expand Up @@ -337,7 +365,7 @@ arena add my-channel "Hello world"
arena add my-channel "Hello" --title "Greeting" --description "Pinned note"
arena add my-channel https://example.com --alt-text "Cover image" --insert-at 1
arena add my-channel https://example.com --original-source-url https://source.com --original-source-title "Original"
echo "piped text" | arena add my-channel
echo "piped text" | arena --json add my-channel
```

Options:
Expand Down Expand Up @@ -370,6 +398,8 @@ Options:

Create many blocks asynchronously.

`batch` is available in `--json` mode.

Examples:

```bash
Expand Down
10 changes: 10 additions & 0 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import createClient, { type Middleware } from "openapi-fetch";
import { loadEnv } from "../lib/env";
import { config } from "../lib/config";
import { vcrFetch } from "../lib/vcr";
import { withRequestSignal } from "../lib/network";
import type { paths } from "./schema";

// Ensure env is populated before reading API URL at module init time.
Expand Down Expand Up @@ -59,6 +60,14 @@ const authMiddleware: Middleware = {
},
};

const timeoutMiddleware: Middleware = {
async onRequest({ request }) {
return new Request(request, {
signal: withRequestSignal(request.signal),
});
},
};

const errorMiddleware: Middleware = {
async onResponse({ response }) {
if (!response.ok) {
Expand All @@ -85,4 +94,5 @@ export const client = createClient<paths>({

client.use(baseUrlMiddleware);
client.use(authMiddleware);
client.use(timeoutMiddleware);
client.use(errorMiddleware);
72 changes: 72 additions & 0 deletions src/cli.contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";

const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(__dirname, "..");

function runCli(args: string[]) {
return spawnSync(
process.execPath,
["--import", "tsx", "src/cli.tsx", ...args],
{
cwd: projectRoot,
encoding: "utf8",
env: process.env,
},
);
}

test("json unknown command returns typed error and non-zero exit", () => {
const result = runCli(["--json", "pign"]);
assert.equal(result.status, 1);
const payload = JSON.parse(result.stdout || result.stderr) as {
type: string;
hint?: string;
};
assert.equal(payload.type, "unknown_command");
assert.ok(payload.hint?.includes("arena ping"));
});

test("json unknown subcommand returns typed error and non-zero exit", () => {
const result = runCli(["--json", "channel", "contnts", "slug"]);
assert.equal(result.status, 1);
const payload = JSON.parse(result.stdout || result.stderr) as {
type: string;
hint?: string;
};
assert.equal(payload.type, "unknown_subcommand");
assert.ok(payload.hint?.includes("arena channel contents"));
});

test("json unsupported command returns json_not_supported type", () => {
const result = runCli(["--json", "login"]);
assert.equal(result.status, 1);
const payload = JSON.parse(result.stdout || result.stderr) as {
type: string;
};
assert.equal(payload.type, "json_not_supported");
});

test("plain unknown command fails non-interactive with stderr error", () => {
const result = runCli(["pign"]);
assert.equal(result.status, 1);
assert.ok(result.stderr.includes("Unknown command: pign"));
});

test("--quiet keeps schema while compacting JSON", () => {
const quiet = runCli(["--json", "--quiet", "version"]);
const pretty = runCli(["--json", "version"]);

assert.equal(quiet.status, 0);
assert.equal(pretty.status, 0);

const quietPayload = JSON.parse(quiet.stdout) as Record<string, unknown>;
const prettyPayload = JSON.parse(pretty.stdout) as Record<string, unknown>;

assert.deepEqual(quietPayload, prettyPayload);
assert.ok(!quiet.stdout.includes("\n "), "expected compact JSON output");
assert.ok(pretty.stdout.includes("\n "), "expected pretty JSON output");
});
176 changes: 160 additions & 16 deletions src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { render, Box, Text, useApp } from "ink";
import { SWRConfig } from "swr";
import { parseArgs, type Flags } from "./lib/args";
import {
commands,
commandMap,
commandHelpDocs,
type CommandHelpDoc,
Expand All @@ -14,6 +15,9 @@ import { exitCodeFromError, formatJsonError } from "./lib/exit-codes";
import { CLI_PACKAGE_NAME, getCliVersion } from "./lib/version";
import { confirmDestructiveIfNeeded } from "./lib/destructive-confirmation";
import { SessionMode } from "./commands/session";
import { initCancellationHandling } from "./lib/network";

initCancellationHandling();

// ── Help ──

Expand Down Expand Up @@ -185,26 +189,124 @@ function RenderError({ message }: { message: string }) {
return <Text color="red">✕ {message}</Text>;
}

function quietResult(result: unknown): unknown {
if (!result || typeof result !== "object") return result;
if (Array.isArray(result)) return result.map(quietResult);
const obj = result as Record<string, unknown>;
if ("id" in obj) return { id: obj.id };
if ("slug" in obj) return { slug: obj.slug };
return result;
function levenshtein(a: string, b: string): number {
const rows = a.length + 1;
const cols = b.length + 1;
const dist = Array.from({ length: rows }, () => Array<number>(cols).fill(0));

for (let i = 0; i < rows; i++) dist[i]![0] = i;
for (let j = 0; j < cols; j++) dist[0]![j] = j;

for (let i = 1; i < rows; i++) {
for (let j = 1; j < cols; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
dist[i]![j] = Math.min(
dist[i - 1]![j]! + 1,
dist[i]![j - 1]! + 1,
dist[i - 1]![j - 1]! + cost,
);
}
}

return dist[rows - 1]![cols - 1]!;
}

function nearest(input: string, candidates: string[]): string | undefined {
if (!input || candidates.length === 0) return undefined;
const normalized = input.toLowerCase();
let best: { candidate: string; score: number } | undefined;

for (const candidate of candidates) {
const score = levenshtein(normalized, candidate.toLowerCase());
if (!best || score < best.score) {
best = { candidate, score };
}
}

if (!best) return undefined;
const threshold = Math.max(2, Math.floor(normalized.length / 3));
return best.score <= threshold ? best.candidate : undefined;
}

function allCanonicalCommands(): string[] {
return [...new Set(commands.map((command) => command.name))];
}

function knownSubcommands(commandName: string): string[] {
return Object.keys(commandHelpDocs[commandName]?.subcommands ?? {});
}

function validateSubcommand(
commandName: string,
args: string[],
): {
badSubcommand?: string;
suggestion?: string;
} {
const subcommands = knownSubcommands(commandName);
if (subcommands.length === 0 || args.length <= 1) return {};
const provided = args[0];
if (!provided || subcommands.includes(provided) || provided.startsWith("-")) {
return {};
}
return {
badSubcommand: provided,
suggestion: nearest(provided, subcommands),
};
}

function unknownCommandPayload(command: string) {
const suggestion = nearest(command, allCanonicalCommands());
return {
error: `Unknown command: ${command}`,
code: null,
type: "unknown_command",
hint: suggestion
? `Did you mean "arena ${suggestion}"?`
: "Run: arena --help",
};
}

function unknownSubcommandPayload(command: string, subcommand: string) {
const suggestion = nearest(subcommand, knownSubcommands(command));
return {
error: `Unknown subcommand: ${command} ${subcommand}`,
code: null,
type: "unknown_subcommand",
hint: suggestion
? `Did you mean "arena ${command} ${suggestion}"?`
: `Run: arena help ${command}`,
};
}

// ── JSON handler ──

async function handleJson(command: string, args: string[], flags: Flags) {
const def = commandMap.get(command);

if (!def || (!def.json && !def.jsonStream)) {
if (!def) {
process.stderr.write(JSON.stringify(unknownCommandPayload(command)) + "\n");
process.exit(1);
}

const canonicalCommand = def.name;
const { badSubcommand } = validateSubcommand(canonicalCommand, args);
if (badSubcommand) {
process.stderr.write(
JSON.stringify(
unknownSubcommandPayload(canonicalCommand, badSubcommand),
) + "\n",
);
process.exit(1);
}

if (!def.json && !def.jsonStream) {
process.stderr.write(
JSON.stringify({
error: `Unknown command: ${command}`,
error: `Command does not support --json: ${canonicalCommand}`,
code: null,
type: "unknown_command",
type: "json_not_supported",
hint: `Run without --json or use: arena help ${canonicalCommand}`,
}) + "\n",
);
process.exit(1);
Expand All @@ -223,13 +325,12 @@ async function handleJson(command: string, args: string[], flags: Flags) {
}

if (!def.json) {
throw new Error(`Unknown command: ${command}`);
throw new Error(`Command does not support --json: ${canonicalCommand}`);
}

const result = await def.json(args, flags);
const output = flags.quiet ? quietResult(result) : result;
const indent = flags.quiet ? undefined : 2;
process.stdout.write(JSON.stringify(output, null, indent) + "\n");
process.stdout.write(JSON.stringify(result, null, indent) + "\n");
} catch (err: unknown) {
process.stderr.write(JSON.stringify(formatJsonError(err)) + "\n");
process.exit(exitCodeFromError(err));
Expand All @@ -246,7 +347,24 @@ function routeCommand(
const def = commandMap.get(command);

if (!def) {
return <TopLevelHelp />;
const payload = unknownCommandPayload(command);
return <RenderError message={`${payload.error}. ${payload.hint}`} />;
}

const canonicalCommand = def.name;
const { badSubcommand, suggestion } = validateSubcommand(
canonicalCommand,
rest,
);
if (badSubcommand) {
const hint = suggestion
? `Did you mean "arena ${canonicalCommand} ${suggestion}"?`
: `Run: arena help ${canonicalCommand}`;
return (
<RenderError
message={`Unknown subcommand: ${canonicalCommand} ${badSubcommand}. ${hint}`}
/>
);
}

try {
Expand All @@ -261,6 +379,9 @@ function routeCommand(

const { args, flags } = parseArgs(process.argv.slice(2));
const [command, ...rest] = args;
const isInteractiveTerminal = Boolean(
process.stdin.isTTY && process.stdout.isTTY,
);

const SWR_OPTIONS = {
revalidateOnFocus: false,
Expand Down Expand Up @@ -327,11 +448,34 @@ if (flags.json && command) {
);
}
} else if (!command) {
const element = process.stdin.isTTY ? <SessionMode /> : <TopLevelHelp />;
const element = isInteractiveTerminal ? <SessionMode /> : <TopLevelHelp />;
await runInk(<App>{element}</App>, {
fullscreen: Boolean(process.stdin.isTTY && process.stdout.isTTY),
fullscreen: isInteractiveTerminal,
});
} else {
if (!isInteractiveTerminal) {
const maybeDef = commandMap.get(command);
if (!maybeDef) {
const payload = unknownCommandPayload(command);
process.stderr.write(`${payload.error}. ${payload.hint}\n`);
process.exit(1);
}

const { badSubcommand, suggestion } = validateSubcommand(
maybeDef.name,
rest,
);
if (badSubcommand) {
const hint = suggestion
? `Did you mean "arena ${maybeDef.name} ${suggestion}"?`
: `Run: arena help ${maybeDef.name}`;
process.stderr.write(
`Unknown subcommand: ${maybeDef.name} ${badSubcommand}. ${hint}\n`,
);
process.exit(1);
}
}

const def = commandMap.get(command);
let element: React.JSX.Element;
try {
Expand Down
Loading
Loading