Writing commands
ACTIVEA complete command
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
| Field | Required | Description |
|---|---|---|
name | yes | What the user types. Must be unique. describe and help are taken. |
summary | yes | One line for help and describe. |
description | no | Longer text shown in --help. |
flags | no | An object of flag specs, keyed by flag name without the dashes. |
args | no | Usage text for positionals, e.g. "<file...>". Positionals arrive in ctx.args. |
examples | no | Full example invocations. The first one is shown when a required flag is missing. |
run | yes | Does the work. Return JSON-serialisable data; throw to fail. |
render | no | Turns the result into text for humans. Without it, results print as key: value lines. |
Flag specs
| Field | Description |
|---|---|
type | "string" or "boolean". There is no number type: take a string and convert it in run. |
description | Shown in help and describe. Required. |
required | Missing means a usage error (exit 2) that shows the first example. |
default | Used when the flag is absent. A string flag with a default is typed as string, not string | undefined. |
short | One-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
| Field | Description |
|---|---|
flags | Parsed, defaulted and validated flag values. |
args | Positional 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. |
json | True 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("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");
});