Writing commands

ACTIVE
API: defineCommand
FILE: src/commands/<name>.ts

A complete command

src/commands/sync.ts
import { defineCommand, CliError, UsageError } from "../core/cli";

export const sync = defineCommand({
  name: "sync",
  summary: "Sync records from the API",
  description: "Fetches records changed since a date and writes them to ./data.",
  flags: {
    since: { type: "string", required: true, description: "ISO date, YYYY-MM-DD" },
    limit: { type: "string", default: "100", description: "Maximum records" },
    "dry-run": { type: "boolean", description: "Report without writing" },
  },
  examples: ["my-cli sync --since 2026-01-01 --json"],
  run: async ({ flags, log }) => {
    if (!/^\d{4}-\d{2}-\d{2}$/.test(flags.since)) {
      throw new UsageError(`Bad date "${flags.since}".`, "Use YYYY-MM-DD.");
    }
    const limit = Number(flags.limit);
    log(`fetching up to ${limit} records`);          // stderr
    const records = await fetchRecords(flags.since, limit);
    if (!records) throw new CliError("API unreachable.", { code: "api_down", hint: "Retry later." });
    return { synced: records.length, since: flags.since, dryRun: flags["dry-run"] };
  },
  render: (r) => `${r.dryRun ? "Would sync" : "Synced"} ${r.synced} records since ${r.since}`,
});

Then add sync to the array in src/commands/index.ts, or let mkcmd-agent add --name sync write the file and register it for you.

defineCommand

FieldRequiredDescription
nameyesWhat the user types. Must be unique. describe and help are taken.
summaryyesOne line for help and describe.
descriptionnoLonger text shown in --help.
flagsnoAn object of flag specs, keyed by flag name without the dashes.
argsnoUsage text for positionals, e.g. "<file...>". Positionals arrive in ctx.args.
examplesnoFull example invocations. The first one is shown when a required flag is missing.
runyesDoes the work. Return JSON-serialisable data; throw to fail.
rendernoTurns the result into text for humans. Without it, results print as key: value lines.

Flag specs

FieldDescription
type"string" or "boolean". There is no number type: take a string and convert it in run.
descriptionShown in help and describe. Required.
requiredMissing means a usage error (exit 2) that shows the first example.
defaultUsed when the flag is absent. A string flag with a default is typed as string, not string | undefined.
shortOne-letter alias, e.g. "n" for -n.

Types follow the specs. In the example above flags.since and flags.limit are string, flags["dry-run"] is boolean, and an optional string flag with no default would be string | undefined.

The run context

FieldDescription
flagsParsed, defaulted and validated flag values.
argsPositional arguments after the command name, as strings.
log(message)Write progress to stderr. Never use console.log for output: stdout is reserved for the result.
jsonTrue when --json was passed. Rarely needed: return data and let the framework format it.

Errors

throw new CliError(message, { code, hint, exitCode, details }) fails with exit code 1 (or exitCode). details carries structured data: agents get it as error.details, and people see it through render, so a failing check can still show its report. Always pass a stable snake_case code: agents branch on it, and without one they get the generic error. throw new UsageError(message, hint) is for bad input and exits 2 with code usage. Any other exception becomes code internal, exit 1.

Don't call process.exit or print errors yourself. The framework writes the JSON or text and sets the exit code.

Testing

Test commands the way agents use them: as a subprocess, reading stdout and the exit code. The generated test/cli.test.ts has a helper for this.

test/cli.test.ts
test("sync rejects a bad date", async () => {
  const { code, stdout } = await run("sync", "--since", "yesterday", "--json");
  expect(code).toBe(2);
  expect(JSON.parse(stdout).error.code).toBe("usage");
});
bun test