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
$ 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...3defCommands, 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:
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:
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:
flag.enum([...])constrains the value —flags.stateis'open' | 'closed' | 'merged' | 'all', notstring.
Try passing--state bogusand dreamcli rejects it with a "did you mean?" error..default('open')meansflags.stateis always defined — noundefinedto check.flag.number()parses--limit 5into the number5, not the string"5".
$ 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():
// 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:
$ 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:
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')
.()
.()
.()
.();$ 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 requestsGroups 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:
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:
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:
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)}...`,
);
});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:
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:
--token ghp_abc(explicit flag) — highest priorityGH_TOKEN=ghp_abc(env var via.env())- Interactive prompt (via
.prompt()) — only in TTY - If none: error (no default, no way to resolve)
So all of these work:
$ 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 backlogshould ask which labels still fit--decision closeshould ask whether to post a follow-up comment
That's what .interactive() is for:
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?',
},
};
});$ 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: closedThe 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:
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:
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:
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:
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