Generating A TypeScript CLI From OpenCLI Specs

Turn a declarative OpenCLI Specification into production-ready CLI code using the Yargs framework.

Tip: Support is currently available for

Want to add support for your favorite CLI framework? Open an issue or submit a pull request.

1

Install the CLI

If you haven't already, install the ocli tool:

sh
$ go install github.com/bcdxn/opencli/cmd/ocli@latest
2

Define your OpenCLI Document

Every OpenCLI-powered project starts with a spec-compliant YAML (or JSON) file. For this walkthrough we'll use the pleasantries-cli.ocs.yaml example from the OpenCLI GitHub repository, a small CLI for greeting and bidding farewell to people by name.

yaml
opencliVersion: 1.0.0-alpha.14

info:
  title: Pleasantries
  summary: A fun CLI to greet or bid farewell
  version: 1.0.0
  binary: pleasantries

commands:
  pleasantries {command} <arguments> [flags]:
    kind: group

  pleasantries greet <name> [flags]:
    summary: "Say hello"
    args:
      - name: "name"
        summary: "A name to include in the greeting"
        required: true
        type: "string"
    flags:
      - name: "language"
        summary: "The language of the greeting"
        type: "string"
        choices:
          - value: "english"
          - value: "spanish"
        default: "english"

  pleasantries farewell <name> [flags]:
    summary: "Say goodbye"
    # ... same shape as greet, but for farewells

You can find the full example document here and explore the complete specification schema at opencli.dev/specification.

3

Initialize the Project

Set up a fresh Node.js project and pull in the pleasantries spec:

sh
$ mkdir pleasantries && cd pleasantries
$ npm init -y
# pull in the full example ocs file (or use your own)
$ curl -O https://raw.githubusercontent.com/bcdxn/opencli/refs/heads/main/examples/pleasantries-cli.ocs.yaml

Then install the runtime and dev dependencies:

sh
$ npm i yargs command-line-usage
$ npm i -D typescript @types/yargs @types/node @types/command-line-usage

The generated code uses command-line-usage to render help and usage output, so it's a required dependency.

That's it for setup — one spec file, one package.

4

Generate Boilerplate Code

A single ocli gen cli command produces all the scaffolding:

sh
$ ocli gen cli \
  --framework yargs \
  --out ./src \
  ./pleasantries-cli.ocs.yaml
# → Reading spec:        ./pleasantries-cli.ocs.yaml
# → Generating CLI code: framework=yargs, output=./src
# ✓ CLI Code written to: ./src

All generated code is encapsulated in the gencli directory. Each command gets its own file, plus supporting files for bootstrapping, error handling, and help rendering:

plain
package.json
pleasantries-cli.ocs.yaml
src/
└── gencli/
    ├── actions.ts            ActionsInterface & command signatures
    ├── cmd-pleasantries-greet.ts     Generated yargs command definitions
    ├── cmd-pleasantries-farewell.ts  Generated yargs command definitions
    ├── errors.ts             CLI error types & exit codes
    ├── help.ts               Default help/usage rendering
    ├── params.ts             Command args, flags & choice enums
    ├── types.ts              Shared command metadata types
    └── run.ts                CLI entry point (run function)

Key insight: the generated code defines an ActionsInterface. The interface creates a contract that maps methods one-to-one with every command in your spec along with some convenience methods. Your job is simply to implement that contract and those methods.

Let's take a look at the all-important src/gencli/actions.ts. It defines one method per command, plus helpers for help and usage:

ts
// src/gencli/actions.ts
export interface ActionsInterface {
  PleasantriesGreet(args: PleasantriesGreetArgs, flags: PleasantriesGreetFlags): Promise<void>;
  PleasantriesFarewell(args: PleasantriesFarewellArgs, flags: PleasantriesFarewellFlags): Promise<void>;
  help(cmd: CommandPrintData): void;
  usage(cmd: CommandPrintData): void;
}

Look at your generated src/gencli/actions.ts to see the full interface we'll need to implement.

Notice that the methods we need to implement have no framework-dependencies injected. We could reuse our same ActionsInterface implementation for multiple frameworks within the same language (or port it across languages entirely).

The generated types for args and flags are strongly typed, so you get compile-time safety — no more typos in flag names or mismatched types. Flags with choices even become enums:

ts
// src/gencli/params.ts
export enum PleasantriesGreetLanguage {
  ENGLISH = "english",
  SPANISH = "spanish",
}

export interface PleasantriesGreetArgs {
  name: string;
}

export interface PleasantriesGreetFlags {
  language?: PleasantriesGreetLanguage | undefined;
}

Next we can take a look at the generated command files, like src/gencli/cmd-pleasantries-greet.ts. Each generated command file adapts our ActionsInterface methods, handling the framework specifics of parsing args and flags and passing them to our framework-agnostic implementations. If you're interested, you can look at a generated file to see how the handler delegates to the corresponding method on your class implementing the ActionsInterface. But in general you can treat these generated command files as black boxes.

ts
handler: async (argv) => {
  const cmdArgs: PleasantriesGreetArgs = { name: argv.name };
  const cmdFlags: PleasantriesGreetFlags = { language: argv.language as PleasantriesGreetLanguage };
  return actions.PleasantriesGreet(cmdArgs, cmdFlags);
},
5

Implement the Actions Interface

This is where you write your actual business logic. Create a class that satisfies ActionsInterface. The pattern feels familiar if you've used oapi-codegen with OpenAPI specs.

Start by creating a new file for your implementation to keep it separate from the generated code in the gencli package:

sh
$ touch ./src/actions.ts

Define your Actions class:

ts
// src/actions.ts
import { ActionsInterface } from "./gencli/actions";
import { CommandPrintData } from "./gencli/types";
import { PleasantriesGreetArgs, PleasantriesGreetFlags, ... } from "./gencli/params";
import { defaultHelpFn, defaultUsageFn } from "./gencli/help";

export class Actions implements ActionsInterface {
  // ... implement each command method below
}

Now implement each method to fulfill the interface. For demonstration we'll keep the bodies simple — in a real project this is where you'd call your API, hit a database, or orchestrate whatever your CLI is designed to do:

ts
async PleasantriesGreet(args: PleasantriesGreetArgs, flags: PleasantriesGreetFlags): Promise<void> {
  if (flags.language == "english") {
    console.log("hello", args.name);
  } else {
    console.log("hola", args.name);
  }
}

async PleasantriesFarewell(args: PleasantriesFarewellArgs, flags: PleasantriesFarewellFlags): Promise<void> {
  if (flags.language == "english") {
    console.log("good bye", args.name);
  } else {
    console.log("adios", args.name);
  }
}

You can download a full example implementation here.

If your OpenCLI document declares root-level global flags, they're not passed to action methods either. Yargs has no context object, so codegen instead exports a pair of accessors from params.ts: the generated handler calls setGlobalFlags(...) immediately before invoking your action, and you read them with getGlobalFlags() inside:

ts
import { getGlobalFlags } from "./gencli/params";

// inside your Actions class...
async PleasantriesGreet(args: PleasantriesGreetArgs, flags: PleasantriesGreetFlags): Promise<void> {
  const name = args.name; // positional arg — arrives as a parameter

  // Global (root-level) flags come from the module accessor, not the method signature.
  const global = getGlobalFlags();
  if (global.debug) { // e.g., for a root-level --debug flag declared in your spec
    console.error(`greeting ${name} in debug mode`);
  }
}

These types and accessors are only emitted when your document declares root-level flags. Yargs parameter fields are typed T | undefined even for required args, so guard values with ?? or an explicit check rather than assuming presence (e.g., global.timeout ?? 30). The accessor is a module-level singleton set per invocation by the generated handler — safe under normal sequential CLI use.

Finally, wire up the helper methods using sensible defaults provided by the generated code (or replace them with custom implementations if you need tailored behavior):

ts
help(cmd: CommandPrintData): void {
  defaultHelpFn(cmd);
}

usage(cmd: CommandPrintData): void {
  defaultUsageFn(cmd);
}

Benefits of this approach: your spec is the contract, your business logic has zero dependencies on any CLI framework, and documentation stays in sync with the OpenCLI Spec document as the source of truth.

6

Wire Up the Entry Point

The final piece is a minimal src/index.ts:

ts
#!/usr/bin/env node
import { run } from "./gencli/run";
import { Actions } from "./actions";

async function main() {
  const actions = new Actions();
  await run(process.argv, actions);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

Just a handful of lines of substance, and critically — no framework dependencies in your user-land code.

7

Try It Out

That's the entire application. Let's build and run it:

sh
$ npx tsc   # compiles src/ → dist/

$ node dist/index.js greet John --language spanish
# hola John
sh
$ node dist/index.js farewell Alice
# good bye Alice

A fully functional CLI with zero framework coupling in your business logic. The spec defined the interface, ocli generated the scaffolding, and you implemented the business logic.

What's next?

Are you an AI crawler? Checkout OpenCLI Specification's LLM Metadata