Schema-first CLI framework for Bun. Define once, get type-safe handlers + AI-readable schema.
- Schema-first - Your schema IS the CLI definition
- Transform inputs - Convert strings to rich objects (
Bun.file(), dates, etc.) - Arrays & Objects -
--tag a --tag band--db.host localhostsyntax - AI-native schema -
--schemaoutputs TypeScript-like types, compact outlines, and jq-like selectors - Hook events - Handlers can emit structured events for agent runtimes and UIs
- Command aliases -
ls, liststyle display - Nested groups - Unlimited depth (
deploy aws lambda) - Lazy validation - Transform only runs for executed command
- Global → Context - Transform globals into injected context
- Zero runtime deps - only
@standard-schema/specas peer
bun add github:ethan-huo/argcimport { toStandardJsonSchema } from '@valibot/to-json-schema'
import * as v from 'valibot'
import { c, cli } from 'argc'
const s = toStandardJsonSchema
const schema = {
greet: c.meta({ description: 'Greet someone' }).input(
s(
v.object({
name: v.pipe(v.string(), v.minLength(2)),
loud: v.optional(v.boolean(), false),
}),
),
),
}
cli(schema, { name: 'hello', version: '1.0.0' }).run({
handlers: {
greet: ({ input }) => {
const msg = `Hello, ${input.name}!`
console.log(input.loud ? msg.toUpperCase() : msg)
},
},
})$ hello greet --name world --loud
HELLO, WORLD!Prefer input() flags for agent-friendly schemas. Use positional args only when they make the CLI clearer for humans. Positional args are always required. For optional parameters, use flags or --input.
const schema = {
env: c
.meta({ description: 'Set an env var' })
.args('key', 'value')
.input(
s(
v.object({
key: v.string(),
value: v.string(),
}),
),
),
}$ myapp env API_KEY secretVariadic positional args are supported by adding ... to the last arg name:
const schema = {
join: c
.meta({ description: 'Join files' })
.args('files...')
.input(s(v.object({ files: v.array(v.string()) }))),
}$ myapp join a.txt b.txt c.txtNote: ... must be used on the last positional argument.
The killer feature. Your schema transforms CLI strings into rich objects:
Explicit flag values stay as strings until your schema transforms them. The only built-in exception is boolean flag presence: --flag becomes true, and --no-flag becomes false.
const schema = {
seed: c
.meta({ description: 'Seed database from file' })
.input(s(v.object({
file: v.pipe(
v.string(),
v.endsWith('.json'),
v.transform((path) => Bun.file(path).json()), // string → Promise<object>
),
}))),
}
// Handler receives the transformed value
handlers: {
seed: async ({ input }) => {
const data = await input.file // Already parsed JSON!
console.log('Seeding:', data)
},
}$ myapp seed --file ./data.json
Seeding: { users: [...], products: [...] }More transform examples:
// String → number for CLI flags
port: v.pipe(
v.string(),
v.transform((s) => Number(s)),
v.number(),
)
// String → Date
startDate: v.pipe(
v.string(),
v.transform((s) => new Date(s)),
)
// String → URL with validation
endpoint: v.pipe(
v.string(),
v.url(),
v.transform((s) => new URL(s)),
)
// String → Glob patterns
pattern: v.pipe(
v.string(),
v.transform((p) => new Bun.Glob(p)),
)Define complex types in your schema - argc handles the CLI input automatically.
Arrays - repeat the flag:
c.input(
s(
v.object({
tags: v.array(v.string()),
}),
),
)$ myapp create --tags admin --tags dev
# input.tags = ['admin', 'dev']Nested objects - use dot notation:
c.input(
s(
v.object({
db: v.object({
host: v.string(),
port: v.pipe(
v.string(),
v.transform((s) => Number(s)),
v.number(),
),
}),
}),
),
)$ myapp connect --db.host localhost --db.port 5432
# input.db = { host: 'localhost', port: 5432 }Help output shows usage hints:
--tags <string[]> (repeatable)
--db <{ host: string, port: string }> (use --db.<key>)
Commands can accept a full JSON object via --input (useful for agents or generated payloads).
$ myapp user set --input '{"name":"alice","role":"admin"}'You can also load JSON from a file:
$ myapp user set --input @payload.jsonYou can also pipe JSON from stdin:
$ echo '{"name":"alice","role":"admin"}' | myapp user set --input--input also accepts JSONC/JSON5 (comments, trailing commas, single quotes, unquoted keys, Infinity, .5, etc.):
$ myapp user set --input "{ name: 'alice', /* comment */ role: 'admin', }"When using --input, do not pass other command flags or positionals (global options are still allowed).
You can run code against your CLI handlers via a global flag:
--run "..."treats the value as inline code--run @./file.tstreats the value as a module file--runor--run -reads code from stdin
# Inline block
$ myapp --run "await argc.handlers.user.create({ name: 'alice' })"
# Module file (TS/JS)
$ myapp --run @./scripts/seed.ts
# Read code from stdin
$ cat ./scripts/seed-snippet.js | myapp --run
# Explicit stdin
$ myapp --run -The script receives an argc object with:
argc.handlers- your handlers as functions, matching your schema shapeargc.call- flat map ('user.create' -> fn)argc.globals- validated global optionsargc.args- extra positionals passed to the script (use--to pass through values that look like flags)
Notes:
- Scripts do not receive
contextdirectly; they can only call handlers. --run @filemodules can export eitherdefaultormain:export default async function (argc) { ... }export async function main(argc) { ... }
- For
--run @file,argcis also available asglobalThis.__argcRunfor modules that run via side effects.
Example passing args:
$ myapp --run @./scripts/batch.ts -- user1 user2 user3Run --schema to get a TypeScript-like type definition:
$ myapp --schemaCLI Syntax:
arrays: --tag a --tag b → tag: ["a", "b"]
objects: --user.name x --user.age 1 → user: { name: "x", age: 1 }
My CLI app
type Myapp = {
// Global options available to all commands
$globals: { verbose?: boolean = false }
// User management
user: {
// List all users
list(all?: boolean = false, format?: "json" | "table" = "table")
// Create a new user
// $ myapp user create --name john --email john@example.com
create(name: string, email?: string)
}
}
If the schema is large (>schemaMaxLines, default 100), --schema prints a compact outline and hints for exploration.
Use jq-like selectors to narrow the output:
| Pattern | Meaning | Example |
|---|---|---|
.name |
Navigate to child | --schema=.user.create |
.* |
All children | --schema=.user.* |
.{a,b} |
Specific children | --schema=.{user,deploy} |
..name |
Recursive search | --schema=..create |
Patterns compose: --schema=.deploy..lambda, --schema=.*.list
CLI stdout is for the agent reading the command result. Hook events are for the system around the agent: runtime logs, UI rendering, progress panels, audit trails, or tool-call replay.
Handlers receive an emit(data) function and a generated meta.callId:
app.run({
handlers: {
migrate: async ({ input, emit, meta }) => {
emit({ step: 'running', current: 0, total: input.steps })
await runMigrations(input.steps)
emit({ step: 'done', applied: input.steps, callId: meta.callId })
console.log(`Migrated ${input.steps} steps`) // stdout is still for the agent
},
},
})emit() is always available. If no hook transport is configured, it is a no-op. Use it when the handler knows something structured that stdout should not have to encode, such as progress, generated asset metadata, IDs, or preview payloads.
argc does not automatically send command input. Tool authors decide what is safe and useful to expose:
// Good: explicit, minimal, safe
emit({ artifact: 'image', path: outputPath, width, height })
// Avoid: may leak prompts, tokens, file paths, or customer data
emit(input)Agent runtimes can enable zero-config delivery with an env var:
ARGC_HOOK_URL=http://localhost:9090/events myapp image generate --prompt "..."argc sends JSON batches with events like:
[
{
"callId": "01JSD9Y7N4J3W2V5Z8QK6M1R0A",
"seq": 1,
"app": "myapp",
"command": "image generate",
"path": ["image", "generate"],
"kind": "call",
"data": null,
"at": 1760000000000
},
{
"callId": "01JSD9Y7N4J3W2V5Z8QK6M1R0A",
"seq": 2,
"app": "myapp",
"command": "image generate",
"path": ["image", "generate"],
"kind": "call.emit",
"data": { "artifact": "image", "path": "./out.png" },
"at": 1760000000015
},
{
"callId": "01JSD9Y7N4J3W2V5Z8QK6M1R0A",
"seq": 3,
"app": "myapp",
"command": "image generate",
"path": ["image", "generate"],
"kind": "call.end",
"data": { "duration": 128, "ok": true },
"at": 1760000000128
}
]Events are batched and delivered fire-and-forget. seq is monotonic within one CLI.run() dispatcher; consumers should group by callId and sort by seq.
You can override the env var with CLIOptions.hook:
const app = cli(schema, {
name: 'myapp',
version: '1.0.0',
hook: async (events) => {
await sendToRuntime(events)
},
hookTimeoutMs: 2000, // default
})Use hook: false to explicitly disable ARGC_HOOK_URL auto-observation for an app.
Define command aliases:
list: c
.meta({ description: 'List users', aliases: ['ls', 'l'] })
.input(s(v.object({ ... })))$ myapp user --help
Commands:
ls, l, list List users # aliases shown first
create Create a userRouting works automatically:
$ myapp user ls # routes to 'list' handler
$ myapp user l # routes to 'list' handler
$ myapp user list # routes to 'list' handlerUnlimited nesting depth:
const schema = {
deploy: group({ description: 'Deployment' }, {
aws: group({ description: 'AWS deployment' }, {
lambda: c.meta({ description: 'Deploy to Lambda' }).input(...),
s3: c.meta({ description: 'Deploy to S3' }).input(...),
}),
vercel: c.meta({ description: 'Deploy to Vercel' }).input(...),
}),
}$ myapp deploy aws lambda --region us-west-2Transform global options into a typed context available in all handlers:
const app = cli(schema, {
name: 'myapp',
version: '1.0.0',
globals: s(
v.object({
env: v.optional(v.picklist(['dev', 'staging', 'prod']), 'dev'),
verbose: v.optional(v.boolean(), false),
}),
),
// Transform globals into context (type inferred from return value)
context: (globals) => ({
env: globals.env,
log: globals.verbose
? (msg: string) => console.log(`[${globals.env}]`, msg)
: () => {},
}),
})
app.run({
handlers: {
deploy: ({ input, context }) => {
context.log('Starting deployment...') // Only logs if --verbose
// context.env is typed as 'dev' | 'staging' | 'prod'
},
},
})$ myapp deploy --env prod --verbose
[prod] Starting deployment...Helpful suggestions for typos:
$ myapp usr
myapp: 'usr' is not a myapp command. See 'myapp --help'.
The most similar command is
userc.meta({
description: 'Command description',
aliases: ['alias1', 'alias2'],
examples: ['myapp cmd --flag value'],
deprecated: true, // shows warning
hidden: true, // hides from help
})
.args('positional1', 'positional2') // positional arguments (in order)
.input(schema) // Standard JSON Schema (still required)group({ description: 'Group description' }, {
subcommand1: c.meta(...).input(...),
subcommand2: c.meta(...).input(...),
nested: group({ ... }, { ... }), // can nest groups
})const app = cli(schema, {
name: 'myapp', // required
version: '1.0.0', // required (shown with -v)
description: 'My CLI', // optional (shown in help)
globals: globalsSchema, // optional (global options schema)
context: (globals) => ({ ... }), // optional: transform globals to context
hook: (events) => { ... }, // optional: batch hook event transport
hookTimeoutMs: 2000, // optional: drain timeout for hook delivery (default: 2000)
schemaMaxLines: 100, // optional: --schema switches to outline above this (default: 100)
})
// Handler types inferred from app (includes context type)
type AppHandlers = typeof app.Handlersapp.run({
handlers: { ... }, // required: type-safe command handlers
})Each handler receives { input, context, meta, emit }:
input- validated command input (typed from schema)context- value returned bycontext()option (orundefined)meta.path- command path as array (['user', 'create'])meta.command- command path as string ('user create')meta.raw- original argv before parsingmeta.callId- generated ULID shared by hook events for this command callemit(data)- sends a structuredcall.emithook event, or no-ops when no hook transport is configured
Handlers can be registered as nested objects or flat dot-notation:
app.run({
handlers: {
// Nested
user: {
get: ({ input }) => { ... },
create: ({ input }) => { ... },
},
// Flat (can mix with nested)
'deploy.aws.lambda': ({ input }) => { ... },
},
})| Flag | Scope | Description |
|---|---|---|
-h, --help |
Everywhere | Show help |
-v, --version |
Root only | Show version |
--schema[=selector] |
Root only | Typed CLI spec for AI agents |
--input <json|@file> |
Command level | Pass input as JSON/JSON5 string, file, or stdin |
--run <code|@file|-> |
Root only | Run inline code, stdin, or a module file |
--completions <shell> |
Root only | Generate shell completion script |
Generate and install completion scripts:
# bash
myapp --completions bash > ~/.local/share/bash-completion/completions/myapp
# zsh
myapp --completions zsh > ~/.zfunc/_myapp # ensure ~/.zfunc is in $fpath
# fish
myapp --completions fish > ~/.config/fish/completions/myapp.fishargc requires schemas that implement both StandardSchemaV1 (validation) and StandardJSONSchemaV1 (type introspection).
Zod and ArkType natively support Standard JSON Schema - no wrapper needed:
// zod - works directly
import { z } from 'zod'
c.input(z.object({ name: z.string() }))
// arktype - works directly
import { type } from 'arktype'
c.input(type({ name: 'string' }))Valibot requires a wrapper (to keep core bundle small):
import { toStandardJsonSchema } from '@valibot/to-json-schema'
import * as v from 'valibot'
const s = toStandardJsonSchema
c.input(s(v.object({ name: v.string() })))When handlers are split across multiple files, use typeof app.Handlers to get type-safe handlers. Handler types support both nested and dot-notation access:
// cli.ts
import { c, cli, group } from 'argc'
const schema = {
user: group({ description: 'User management' }, {
get: c.meta({ ... }).input(...),
create: c.meta({ ... }).input(...),
}),
deploy: group({ description: 'Deployment' }, {
aws: group({ description: 'AWS' }, {
lambda: c.meta({ ... }).input(...),
}),
}),
}
export const app = cli(schema, {
name: 'myapp',
version: '1.0.0',
context: (globals) => ({
db: createDbConnection(),
log: console.log,
}),
})
// Handler types support both nested and dot-notation access
export type AppHandlers = typeof app.Handlers// commands/user-get.ts
import type { AppHandlers } from '../cli'
// Dot-notation for single handlers
export const runUserGet: AppHandlers['user.get'] = async ({ input, context }) => {
context.log(input.key) // fully typed
}
// Nested access for handler groups
export const userHandlers: AppHandlers['user'] = {
get: async ({ input, context }) => { ... },
create: async ({ input, context }) => { ... },
}
// Works for deeply nested commands too
export const runLambda: AppHandlers['deploy.aws.lambda'] = async ({ input, context }) => {
// ...
}For input types only, use InferInput with the same dot-notation:
import type { InferInput } from 'argc'
type UserCreateInput = InferInput<typeof schema, 'user.create'>
type LambdaInput = InferInput<typeof schema, 'deploy.aws.lambda'>See full working example: examples/demo.ts
import { toStandardJsonSchema } from '@valibot/to-json-schema'
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as v from 'valibot'
import { c, cli, group } from 'argc'
import * as tables from './db/schema'
const s = toStandardJsonSchema
const schema = {
user: group(
{ description: 'User management' },
{
list: c.meta({ description: 'List users', aliases: ['ls'] }).input(
s(
v.object({
format: v.optional(v.picklist(['json', 'table']), 'table'),
}),
),
),
create: c
.meta({
description: 'Create user',
examples: ['myapp user create --name john --email john@example.com'],
})
.input(
s(
v.object({
name: v.pipe(v.string(), v.minLength(3)),
email: v.optional(v.pipe(v.string(), v.email())),
}),
),
),
},
),
db: group(
{ description: 'Database operations' },
{
seed: c
.meta({ description: 'Seed from JSON file' })
.args('file')
.input(
s(
v.object({
file: v.pipe(
v.string(),
v.endsWith('.json'),
v.transform((path) => Bun.file(path).json()),
),
}),
),
),
},
),
}
// Create app with context (type inferred from return value)
const app = cli(schema, {
name: 'myapp',
version: '1.0.0',
globals: s(
v.object({
verbose: v.optional(v.boolean(), false),
}),
),
context: (globals) => ({
db: drizzle(postgres(process.env.DATABASE_URL!)),
log: globals.verbose ? console.log : () => {},
}),
})
// Handler types include context
export type AppHandlers = typeof app.Handlers
// Run with handlers only
app.run({
handlers: {
user: {
list: async ({ input, context }) => {
context.log('Listing users...')
const users = await context.db.select().from(tables.users)
console.log(input.format === 'json' ? JSON.stringify(users) : users)
},
create: async ({ input, context }) => {
context.log('Creating user...')
await context.db.insert(tables.users).values({
name: input.name,
email: input.email,
})
console.log('Created:', input.name)
},
},
db: {
seed: async ({ input, context }) => {
const data = await input.file
context.log('Seeding database...')
await context.db.insert(tables.users).values(data.users)
console.log('Seeded:', data.users.length, 'users')
},
},
},
})MIT