Skip to content

Walkthrough: Building a GitHub CLI

Let's build something real. We're going to recreate a miniature version of gh — GitHub's official CLI — using dreamcli.

By the end, you'll have touched every major feature: commands, groups, flags, arguments, derive, env vars, per-flag prompts, command-level interactive prompts, tables, JSON mode, spinners, and error handling.

The full source lives in examples/gh/src/main.ts with supporting modules under examples/gh/src/.

Inside this repo, run it with bun --cwd=examples/gh src/main.ts .... In a standalone project, you'd install @kjanat/dreamcli normally — the Bun workspace wiring here is just repo-local convenience.

What we're building

bash
$ gh pr list
#    TITLE                     STATE   AUTHOR
142  Add dark mode toggle      open    alice
141  Fix OAuth redirect loop   open    bob
137  Fix date parsing Safari   open    dave

$ gh pr list --state all --json
[{"#":142,"title":"Add dark mode toggle",...},...]

$ gh issue triage 89 --decision backlog
? Which labels still fit? bug, ui
#89 Login fails on Firefox
Status: open (backlog)
Labels: bug, ui

$ gh auth login
? Paste your GitHub token: ghp_abc123...
Logged in with token ghp_abc1...3def

Commands, flags, prompts, spinners, tables, JSON mode — all in one tool. Let's build it piece by piece.

Step 1: A single command

Start with the simplest useful thing — listing pull requests:

ts
import { ,  } from '@kjanat/dreamcli';

const  = ('list')
  .('List pull requests')
  .(({  }) => {
    .('PR #142: Add dark mode toggle');
    .('PR #141: Fix OAuth redirect loop');
  });

('gh').().();

That's a working CLI. But it's hardcoded and flat. Let's fix both.

Step 2: Data and flags

Real CLIs filter things. Let's add mock data and flags to filter by state:

ts
import { , ,  } from '@kjanat/dreamcli';

type  = {
  readonly : number;
  readonly : string;
  readonly : 'open' | 'closed' | 'merged';
  readonly : string;
};

const : readonly [] = [
  {
    : 142,
    : 'Add dark mode toggle',
    : 'open',
    : 'alice',
  },
  {
    : 141,
    : 'Fix OAuth redirect loop',
    : 'open',
    : 'bob',
  },
  {
    : 140,
    : 'Bump dependencies',
    : 'merged',
    : 'dependabot',
  },
  {
    : 139,
    : 'Add rate limiting',
    : 'closed',
    : 'carol',
  },
];

const  = ('list')
  .('List pull requests')
  .(
    'state',
    
      .(['open', 'closed', 'merged', 'all'])
      .('open')
      .('s')
      .('Filter by state'),
  )
  .(
    'limit',
    
      .()
      .(10)
      .('L')
      .('Maximum number of results'),
  )
  .(({ ,  }) => {
    let  = [...];
    if (. !== 'all') {
       = .(
        () => . === .,
      );
    }
     = .(0, .);

    for (const  of ) {
      .(`#${.} ${.} (${.})`);
    }
  });

Three things to notice:

  1. flag.enum([...]) constrains the value — flags.state is 'open' | 'closed' | 'merged' | 'all', not string.
    Try passing --state bogus and dreamcli rejects it with a "did you mean?" error.
  2. .default('open') means flags.state is always defined — no undefined to check.
  3. flag.number() parses --limit 5 into the number 5, not the string "5".
bash
$ gh pr list
#142 Add dark mode toggle (open)
#141 Fix OAuth redirect loop (open)

$ gh pr list --state all --limit 2
#142 Add dark mode toggle (open)
#141 Fix OAuth redirect loop (open)

Step 3: Tables and JSON mode

Printing lines is fine, but tabular data deserves a table. And scripts need JSON.

Replace the for loop with out.table():

ts
// Reuses the Step 2 PR setup.

.(({ ,  }) => {
  let  = [...];
  if (. !== 'all') {
     = .(
      () => . === .,
    );
  }
   = .(0, .);

  // out.table() renders a formatted table in TTY, JSON array in --json mode
  .(
    .(() => ({
      '#': .,
      : .,
      : .,
      : .,
    })),
  );
});

Now you get both:

bash
$ gh pr list
#    TITLE                     STATE   AUTHOR
142  Add dark mode toggle      open    alice
141  Fix OAuth redirect loop   open    bob

$ gh pr list --json
[{"#":142,"title":"Add dark mode toggle","state":"open","author":"alice"},...]

--json is handled automatically by cli(). out.table() renders a formatted table for humans and emits JSON when --json is active. out.log() is suppressed in JSON mode. For single-object responses (like pr view), branch on out.jsonMode: emit out.json(data) in machine mode, or human text otherwise. Don't mix both surfaces in the same response.

Step 4: Command groups

A flat list of commands doesn't scale. gh organizes commands into groups — pr list, issue triage, auth login. dreamcli has group() for this:

ts
import {
  ,
  ,
  ,
  ,
} from '@kjanat/dreamcli';

// Auth commands
const  = ('login')
  .('Authenticate with GitHub')
  .(({  }) => {
    .('Logging in...');
  });

const  = ('status')
  .('Show authentication status')
  .(({  }) => {
    .('Logged in');
  });

// PR commands
const  = ('list').(
  'List pull requests',
);
// ...flags and action from above...

// Issue commands
const  =
  ('list').('List issues');
const  = ('triage').(
  'Triage an issue',
);

// Groups
const  = ('auth')
  .('Manage authentication')
  .()
  .();

const  = ('pr')
  .('Manage pull requests')
  .();
const  = ('issue')
  .('Manage issues')
  .()
  .();

// Assemble
('gh')
  .('0.1.0')
  .('A minimal GitHub CLI clone')
  .()
  .()
  .()
  .();
bash
$ gh --help
A minimal GitHub CLI clone

Commands:
  auth    Manage authentication
  pr      Manage pull requests
  issue   Manage issues

$ gh pr --help
Manage pull requests

Commands:
  list    List pull requests

Groups are just commands that contain other commands. You can nest them as deep as you want.

Step 5: Arguments

gh pr view 142 takes a PR number as a positional argument — not a flag, not a named value, just the first thing after view:

ts
import { ,  } from '@kjanat/dreamcli';

// Reuses the Step 2 command imports, PR type, and pullRequests mock data.

const  = ('view')
  .('View a pull request')
  .('number', .().('PR number'))
  .(({ ,  }) => {
    const  = .(
      () => . === .,
    );

    if (!) {
      throw new (
        `Pull request #${.} not found`,
        {
          : 'NOT_FOUND',
          : 1,
          : 'Try: gh pr list',
        },
      );
    }

    if (.) {
      .();
      return;
    }

    .(`#${.} ${.}`);
    .(`State: ${.}  Author: ${.}`);
  });

arg.number() coerces the shell string to a number automatically — args.number is typed and validated as numeric at parse time. If someone passes abc, they get a parse error before the action ever runs. The CLIError with suggest gives the user a helpful nudge when things go wrong, and in --json mode it serializes as structured JSON on stdout so scripts and pipes receive parseable output. Single-object commands should pick one surface per run: human text by default, JSON when --json is active.

Step 6: Derive Context

Every pr and issue command needs authentication. You could check for a token in every single action handler, but that's repetitive and error-prone.

derive() solves this cleanly:

ts
import {  } from '@kjanat/dreamcli';

function (: string | undefined): {
  : string;
} {
  const  = ?.();
  if (!) {
    throw new ('Authentication required', {
      : 'AUTH_REQUIRED',
      : 'Run `gh auth login` or set GH_TOKEN',
      : 1,
    });
  }
  return { :  };
}

This assumes each protected command resolves a token value first, typically via flag.string().env('GH_TOKEN'), so derive can consume resolved input instead of reaching for process.env directly.

derive() is command-scoped and gets typed resolved flags and args. Returning { token } merges that value into ctx downstream. Now wire it up:

ts
import { ,  } from '@kjanat/dreamcli';


const  = ('list')
  .('List pull requests')
  .(
    'token',
    .().('GH_TOKEN').('GitHub token'),
  )
  .(({  }) => (.))
  .(
    'state',
    
      .(['open', 'closed', 'merged', 'all'])
      .('open'),
  )
  .(({ ,  }) => {
    // ctx.token is typed as `string` — guaranteed by derive
    .(
      `Authenticated with ${..(0, 8)}...`,
    );
  });
ts
import {
  ,
  ,
  ,
  ,
} from '@kjanat/dreamcli';

const  = <{ : string }>(
  ({ ,  }) => {
    if (typeof . !== 'string') {
      throw new ('Authentication required', {
        : 'AUTH_REQUIRED',
        : 'Run `gh auth login` or set GH_TOKEN',
        : 1,
      });
    }

    return ({ : . });
  },
);

const  = ('list')
  .('List pull requests')
  .(
    'token',
    .().('GH_TOKEN').('GitHub token'),
  )
  .()
  .(
    'state',
    
      .(['open', 'closed', 'merged', 'all'])
      .('open'),
  )
  .(({ ,  }) => {
    .(
      `Authenticated with ${..(0, 8)}...`,
    );
  });

If no token resolves, the derive handler throws before the action runs. No token check needed in the handler. The auth commands (login, status) don't use derive, so they work without a token.

Technically you could also do this with middleware, but it has to narrow flags.token itself because middleware is reusable and command-agnostic. Use derive() when you need typed resolved input. Use middleware() when you need to wrap downstream execution for timing, logging, retries, cleanup, or error boundaries.

Step 7: Env vars and prompts

The real gh auth login lets you paste a token interactively or set GH_TOKEN in your environment.

dreamcli's resolution chain handles this naturally:

ts
import { ,  } from '@kjanat/dreamcli';


const  = ( = 'GitHub token') =>
  
    .()
    .('GH_TOKEN')
    .()
    .({
      : 'input',
      : 'Paste your GitHub token:',
    })
    .();

const  = (: string) =>
  ()
    .('token', ())
    .(({  }) => (.));

const  = ('login')
  .('Authenticate with GitHub')
  .('token', ('Authentication token'))
  .(({ ,  }) => {
    const  = `${..(0, 8)}...${..(-4)}`;
    .(`Logged in with token ${}`);
  });

The resolution chain tries each source in order:

  1. --token ghp_abc (explicit flag) — highest priority
  2. GH_TOKEN=ghp_abc (env var via .env())
  3. Interactive prompt (via .prompt()) — only in TTY
  4. If none: error (no default, no way to resolve)

So all of these work:

bash
$ gh auth login --token ghp_abc123        # flag
$ GH_TOKEN=ghp_abc123 gh auth login       # env var
$ gh auth login                           # prompts interactively
? Paste your GitHub token: ghp_abc123...

One flag definition. Three ways to provide the value. The user picks what's convenient. That same tokenFlag() helper also powers auth status and every protected command, so the example sticks to one input story all the way through.

Step 8: Guided workflows

Per-flag prompts are great when every command always asks the same question. issue triage needs a different follow-up depending on the primary decision:

  • --decision backlog should ask which labels still fit
  • --decision close should ask whether to post a follow-up comment

That's what .interactive() is for:

ts
import {  } from '@kjanat/dreamcli';


const  = [
  { : 'bug' },
  { : 'ui' },
  { : 'needs-info' },
] as ;

const  = ('triage')
  .('Triage an issue with guided prompts')
  .('number', .().('Issue number'))
  .(
    'decision',
    
      .(['backlog', 'close'])
      .()
      .('How to handle the issue'),
  )
  .(
    'label',
    
      .(.())
      .(
        'Labels to keep when leaving the issue open',
      ),
  )
  .(
    'comment',
    .().('Post a follow-up comment'),
  )
  .(({  }) => {
    const  = . ?? [];

    return {
      : . === 'backlog' &&
        . === 0 && {
          : 'multiselect',
          : 'Which labels still fit?',
          : ,
        },
      : . === 'close' &&
        !. && {
          : 'confirm',
          : 'Post a follow-up comment?',
        },
    };
  });
bash
$ gh issue triage 89 --decision backlog
? Which labels still fit? bug, ui
#89 Login fails on Firefox
Status: open (backlog)
Labels: bug, ui

$ gh issue triage 89 --decision close
? Post a follow-up comment? yes
#89 Login fails on Firefox
Status: closed

The key difference from .prompt() is timing. .interactive() runs after CLI/env/config resolution and only decides which prompts to show for the still-missing flags. Use .prompt() for unconditional fallback input. Use .interactive() when the set of prompts itself depends on earlier resolved flags. That's also why issue stays smaller than pr: pr teaches the core API-shaped commands, and issue triage teaches the guided-workflow pattern without repeating view and create all over again.

Step 9: Spinners

Creating a PR involves an API call. In a real terminal, you'd show a spinner:

ts

const  = ('create')
  .('Create a pull request')
  .(
    'title',
    
      .()
      .('t')
      .('PR title')
      .({ : 'input', : 'Title:' })
      .(),
  )
  .(
    'body',
    
      .()
      .('b')
      .('PR body')
      .({ : 'input', : 'Body:' })
      .(),
  )
  .(
    'draft',
    
      .()
      .('d')
      .(false)
      .('Create as draft'),
  )
  .(async ({ ,  }) => {
    const  = .('Creating pull request...');

    // Simulate API call
    await new (() => (, 1500));

    .('Pull request created');

    const  = {
      : 143,
      : .,
      : 'https://github.com/you/repo/pull/143',
    };

    if (.) {
      .();
      return;
    }

    .(`#${.} ${.}`);
    .(.);
  });

out.spinner() returns a handle with .update(), .succeed(), .stop(), and .wrap(). In a TTY, you get an animated spinner. When piped or in --json mode, spinners are suppressed automatically — no garbage escape codes in your logs.

Step 10: Testing

This is where it gets interesting.
You don't want to spawn subprocesses to test a CLI. dreamcli's testkit lets you run commands in-process with full control:

ts
import {  } from '@kjanat/dreamcli/testkit';

// Test that pr list returns open PRs by default
const  = await (, [
  '--state',
  'open',
]);
(.).(0);
(..('')).('dark mode');

You can inject env vars, config, and prompt answers:

ts
import {  } from '@kjanat/dreamcli/testkit';

// Test that derive blocks unauthenticated access
const  = await (, []);
(.).(1);
(..('')).(
  'Authentication required',
);

// Test with a token
const  = await (, [], {
  : { : 'ghp_test_token' },
});
(.).(0);

// Test guided prompts
const  = await (
  ,
  ['89', '--decision', 'backlog'],
  {
    : { : 'ghp_test_token' },
    : [['bug', 'ui']],
  },
);
(.).(0);
(..('')).('Labels: bug, ui');

No subprocesses. No process.argv mutation. No shell scripts. Each test is isolated — inject what you need, assert what you expect.

Putting it together

Here's the final assembly — all the commands wired into groups:

ts
import {
  ,
  ,
  ,
  ,
  ,
  ,
} from '@kjanat/dreamcli';

const  = ('auth')
  .('Manage authentication')
  .()
  .();

const  = ('pr')
  .('Manage pull requests')
  .()
  .()
  .();

const  = ('issue')
  .('Manage issues')
  .()
  .();

('gh')
  .('0.1.0')
  .('A minimal GitHub CLI clone')
  .()
  .()
  .()
  .();

That's a CLI with:

  • 7 commands across 3 groups
  • Enum, string, number, and boolean flags
  • Array flags with multiselect prompts
  • Positional arguments
  • Auth derive with typed context
  • Env var resolution (GH_TOKEN)
  • Interactive prompts with resolution chain fallback
  • Command-level interactive resolver for guided follow-up questions
  • Table output with automatic JSON mode
  • Spinners with TTY-aware suppression
  • Structured errors with suggestions and error codes
  • Full testability via in-process test harness

The complete source lives in examples/gh/src/main.ts with the rest of the example package under examples/gh/src/.

What's next?

  • Commands — everything about command builders, nesting, and groups
  • Flags — all flag types, modifiers, and the resolution chain in detail
  • Middleware — context accumulation, short-circuit, onion model
  • Testing — the full testkit API

Released under the MIT License.