Skip to content

ethan-huo/argc

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

41 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

argc

Schema-first CLI framework for Bun. Define once, get type-safe handlers + AI-readable schema.

Features

  • Schema-first - Your schema IS the CLI definition
  • Transform inputs - Convert strings to rich objects (Bun.file(), dates, etc.)
  • Arrays & Objects - --tag a --tag b and --db.host localhost syntax
  • AI-native schema - --schema outputs TypeScript-like types, compact outlines, and jq-like selectors
  • Command aliases - ls, list style 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/spec as peer

Install

bun add github:ethan-huo/argc

Quick Start

import { 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!

Positional Arguments (use sparingly)

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 secret

Variadic 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.txt

Note: ... must be used on the last positional argument.

Transform: Schema Superpowers

The killer feature. Your schema transforms CLI strings into rich objects:

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 → 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)),
)

Arrays & Nested Objects

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.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: number }>  (use --db.<key>)

JSON Input

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.json

You 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).

Scripting Mode

You can run scripts against your CLI handlers via global flags:

# Inline block
$ myapp --eval "await argc.handlers.user.create({ name: 'alice' })"

# File (TS/JS)
$ myapp --script ./scripts/seed.ts

# Read --eval code from stdin
$ cat ./scripts/seed-snippet.js | myapp --eval

The script receives an argc object with:

  • argc.handlers - your handlers as functions, matching your schema shape
  • argc.call - flat map ('user.create' -> fn)
  • argc.globals - validated global options
  • argc.args - extra positionals passed to the script (use -- to pass through values that look like flags)

Notes:

  • Scripts do not receive context directly; they can only call handlers.
  • --script modules can export either default or main:
    • export default async function (argc) { ... }
    • export async function main(argc) { ... }
  • For --script, argc is also available as globalThis.__argcScript for modules that run via side effects.

Example passing args:

$ myapp --script ./scripts/batch.ts -- user1 user2 user3

AI Agent Integration

Run --schema to get a TypeScript-like type definition:

$ myapp --schema
CLI 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

Command Aliases

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 user

Routing works automatically:

$ myapp user ls      # routes to 'list' handler
$ myapp user l       # routes to 'list' handler
$ myapp user list    # routes to 'list' handler

Nested Command Groups

Unlimited 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-2

Global Options → Context

Transform 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...

Git-Style Unknown Command

Helpful suggestions for typos:

$ myapp usr
myapp: 'usr' is not a myapp command. See 'myapp --help'.

The most similar command is
        user

API Reference

c - Command Builder

c.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() - Command Group

group({ description: 'Group description' }, {
  subcommand1: c.meta(...).input(...),
  subcommand2: c.meta(...).input(...),
  nested: group({ ... }, { ... }),  // can nest groups
})

cli() - Create CLI

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
  schemaMaxLines: 100,    // optional: --schema switches to outline above this (default: 100)
})

// Handler types inferred from app (includes context type)
type AppHandlers = typeof app.Handlers

.run() - Execute

app.run({
  handlers: { ... },  // required: type-safe command handlers
})

Each handler receives { input, context, meta }:

  • input - validated command input (typed from schema)
  • context - value returned by context() option (or undefined)
  • meta.path - command path as array (['user', 'create'])
  • meta.command - command path as string ('user create')
  • meta.raw - original argv before parsing

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 }) => { ... },
  },
})

Built-in Flags

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
--eval <code> Root only Run inline script with handler API
--script <file> Root only Run script file with handler API
--completions <shell> Root only Generate shell completion script

Shell Completions

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.fish

Schema Libraries

argc 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() })))

Handlers in Separate Files

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'>

Complete Example

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')
			},
		},
	},
})

License

MIT

About

Schema-first CLI framework for Bun

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors