@arki/cli for agents
For @arki/[email protected] · source docs/agent-guide.md
Read llms.txt first for the index. When this guide and llms.txt disagree, this guide wins; when this guide and the code disagree, the code in src/ wins.
The model
- A command is one
defineCommand({ describe, input?, args?, run, ... }).inputis an object Standard Schema with JSON Schema; its keys are the flags. - A program is
defineCli({ name, version, commands }).commandsis a tree: commands,group(describe, tree), and lazy entries() => import('./x.js'). program.main(argv, io)returns the exit code and never exits.run(program)is the process boundary: argv, stdio, terminal, signals,process.exit.- Output is a JSON envelope (
arki.cli/1) when stdout is not a terminal, text when it is.-o,--jsonandARKI_OUTPUToverride. runreturns data or an@arki/failurevalue. A returned failure exits 3, or 4 when its status is 429 or 503. A throw is a bug: exit 1.- Help and
--versionnever callrunorservices. Help imports one lazy level below the path asked; command help imports that command's module (and its schema library).
Task map
Add a command
-
Create
src/commands/<name>.tswith one default export:import { z } from 'zod'; import { defineCommand } from '@arki/cli'; export default defineCommand({ describe: 'List the users', input: z.object({ limit: z.int().min(1).default(20).describe('How many to list') }), effects: 'read', run: ({ input }) => ({ items: [{ id: 'usr_1', name: 'Ada' }].slice(0, input.limit) }), }); -
Register it in the program file (
src/cli.ts) with one line:users: () => import('./commands/users.js'),. -
Check it:
app users --help, thenapp users --json.
Group commands
-
Wrap the entries in
group(describe, tree). In an app the entries are lazy lines such aslist: () => import('./commands/users/list.js'); a group may itself be lazy.import { defineCli, defineCommand, group } from '@arki/cli'; const list = defineCommand({ describe: 'List the users', aliases: ['ls'], run: () => ({ items: [] }) }); export const program = defineCli({ name: 'app', version: '1.0.0', commands: { users: group('Manage users', { list }), }, }); -
aliases: ['ls']on a command adds another name at its level. A name that is also an alias at the same level makesdefineClithrow.
Return a failure
-
Define the failure once with
defineFailurefrom@arki/failure; give it astatus(503 makes it retryable) and aremediation. -
Return it from
run. Do not throw it.import { z } from 'zod'; import { defineCommand } from '@arki/cli'; import { defineFailure } from '@arki/failure'; const UserNotFound = defineFailure('user.not_found', { status: 404, message: 'no user {id}', data: z.object({ id: z.string() }), }); export default defineCommand({ describe: 'Show one user', input: z.object({ id: z.string() }), args: ['id'], run: ({ input }) => (input.id === 'usr_1' ? { id: input.id, name: 'Ada' } : UserNotFound({ id: input.id })), });
Offer a dry run
- Add
preview(ctx): same input, same success type asrun, no changes made. --dry-runthen runspreviewand setsmeta.dryRun: true. Withoutpreview,--dry-runfails withcli.preview_unsupported(exit 2).
Test a command
-
Call
mainwith your ownio; leavettyfalse to get the envelope.import { z } from 'zod'; import { defineCli, defineCommand } from '@arki/cli'; const program = defineCli({ name: 'app', version: '1.0.0', commands: { double: defineCommand({ describe: 'Double a number', input: z.object({ value: z.number() }), args: ['value'], run: ({ input }) => ({ doubled: input.value * 2 }), }), }, }); let stdout = ''; const exitCode = await program.main(['double', '21'], { stdout: text => { stdout += text; }, tty: false, }); if (exitCode !== 0 || !stdout.includes('"doubled":42')) throw new Error(stdout);
Rules that bite
- No type arguments at call sites:
defineCommandinfers input from the schema and output fromrun, failures removed. runoutput goes to the envelope; useui.outfor text-mode lines andui.info/ui.warnfor stderr. They are silent in json mode.- An input key named like a global flag (
output,json,fields,inputJson,inputFile,quiet,color,dryRun,help,version) throws at definition time. - Defaults belong to the schema. The parser leaves a missing flag out; validation fills it.
--input-json/--input-filereplace all flags; mixing is invalid input.- An array key in
argsmust be the last positional. - A command written inline in a
defineClitree seesctx.servicesasunknown; one in its own file sees{}. runconfigures nothing in@arki/log; configure logging in the bin file.
Errors
| Code | Exit | Meaning | What to do |
|---|---|---|---|
cli.unknown_command | 2 | No command by that name at that level | Use a name from data.suggestions or --help |
cli.invalid_input | 2 | The command line does not fit the input schema | Fix each flag in error.issues |
cli.preview_unsupported | 2 | --dry-run on a command without preview | Run without --dry-run, or add preview |
cli.input_required | 5 | A required value is missing and there is no terminal | Pass the flag in data.flag |
cli.confirmation_required | 5 | Confirmation needed and there is no terminal | Pass --yes |
cli.cancelled | 130 | The run ended after SIGINT/SIGTERM | Check what changed (outcome: unknown), then rerun |
cli.bug | 1 | Something threw | Read the log for the stack; fix the command |
Verify
bun run typecheck
bun run test
bun run test:docs
bun run test:runtimes