Skip to content

ethan-huo/argc

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

39 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

No packages published