Build a Type-Safe API Client CLI with Node.js
Every developer has APIs they interact with regularly — GitHub, Stripe, Vercel, your company's internal services. Wrapping these APIs in a CLI tool saves time, enables scripting, and makes complex API workflows accessible from the terminal.
This article shows how to build a CLI that wraps any REST API with type safety, authentication management, and response formatting — patterns you can apply to any API.
What We're Building
apicli — a framework for building API client CLIs that handles:
- Authentication (API keys, OAuth tokens, bearer tokens)
- Request building with typed parameters
- Response formatting (JSON, table, minimal)
- Pagination handling
- Error mapping from HTTP status codes to helpful messages
- Credential storage
Step 1: Credential Management
Store API credentials securely in the user's home directory:
// lib/credentials.ts
import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
const CRED_DIR = join(homedir(), '.apicli');
const CRED_FILE = join(CRED_DIR, 'credentials.json');
interface Credentials {
[service: string]: {
type: 'api-key' | 'bearer' | 'basic';
value: string;
expiresAt?: string;
};
}
export async function saveCredential(
service: string,
type: Credentials[string]['type'],
value: string
): Promise<void> {
await mkdir(CRED_DIR, { recursive: true });
const creds = await loadCredentials();
creds[service] = { type, value };
await writeFile(CRED_FILE, JSON.stringify(creds, null, 2));
await chmod(CRED_FILE, 0o600); // Owner read/write only
}
export async function getCredential(service: string): Promise<Credentials[string] | null> {
const creds = await loadCredentials();
return creds[service] || null;
}
async function loadCredentials(): Promise<Credentials> {
try {
return JSON.parse(await readFile(CRED_FILE, 'utf-8'));
} catch {
return {};
}
}
The chmod 0o600 is critical — it ensures only the file owner can read credentials, matching the convention used by SSH keys and AWS credentials.
Step 2: The API Client Base
// lib/client.ts
interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
path: string;
query?: Record<string, string | number | boolean>;
body?: unknown;
headers?: Record<string, string>;
}
export class ApiClient {
constructor(
private baseUrl: string,
private auth: { type: string; value: string },
) {}
async request<T = unknown>(options: RequestOptions): Promise<T> {
const url = new URL(options.path, this.baseUrl);
if (options.query) {
for (const [key, value] of Object.entries(options.query)) {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
}
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'User-Agent': 'apicli/1.0.0',
...options.headers,
};
// Apply auth
if (this.auth.type === 'bearer') {
headers['Authorization'] = `Bearer ${this.auth.value}`;
} else if (this.auth.type === 'api-key') {
headers['X-API-Key'] = this.auth.value;
} else if (this.auth.type === 'basic') {
headers['Authorization'] = `Basic ${Buffer.from(this.auth.value).toString('base64')}`;
}
const response = await fetch(url.toString(), {
method: options.method || 'GET',
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
});
if (!response.ok) {
await handleApiError(response, url.pathname);
}
return response.json() as Promise<T>;
}
async paginate<T>(
options: RequestOptions,
extract: (response: unknown) => { items: T[]; nextPage?: string },
): Promise<T[]> {
const allItems: T[] = [];
let page: string | undefined;
do {
const query = { ...options.query, ...(page ? { page } : {}) };
const response = await this.request({ ...options, query });
const { items, nextPage } = extract(response);
allItems.push(...items);
page = nextPage;
} while (page);
return allItems;
}
}
Step 3: Error Mapping
Turn HTTP errors into actionable CLI messages:
async function handleApiError(response: Response, path: string): Promise<never> {
let body: string;
try {
body = await response.text();
} catch {
body = '';
}
const messages: Record<number, { message: string; suggestion: string }> = {
400: {
message: 'Bad request — the API rejected your input',
suggestion: 'Check your parameters with --verbose',
},
401: {
message: 'Authentication failed',
suggestion: 'Run `apicli auth login` to update your credentials',
},
403: {
message: 'Permission denied',
suggestion: 'Your API key may not have access to this resource',
},
404: {
message: `Resource not found: ${path}`,
suggestion: 'Check the resource ID or path',
},
429: {
message: 'Rate limited',
suggestion: 'Wait a moment and try again, or use --retry',
},
500: {
message: 'Server error — the API is having issues',
suggestion: 'Try again later. If persistent, check the service status page',
},
};
const info = messages[response.status] || {
message: `API error: ${response.status} ${response.statusText}`,
suggestion: 'Use --verbose for full response details',
};
throw new CliError(info.message, {
code: response.status >= 500 ? 3 : 2,
suggestion: info.suggestion,
details: body,
});
}
Step 4: Response Formatting
// lib/formatter.ts
import Table from 'cli-table3';
import chalk from 'chalk';
type Format = 'json' | 'table' | 'minimal' | 'ids';
export function formatResponse(
data: unknown,
format: Format,
columns?: string[],
): string {
switch (format) {
case 'json':
return JSON.stringify(data, null, 2);
case 'ids':
if (Array.isArray(data)) {
return data.map(item => item.id || item.name || item).join('\n');
}
return String((data as any).id || data);
case 'minimal':
if (Array.isArray(data)) {
return data.map(item => summarize(item)).join('\n');
}
return summarize(data);
case 'table':
return formatTable(data, columns);
default:
return JSON.stringify(data, null, 2);
}
}
function summarize(item: any): string {
const id = item.id || item.name || '?';
const title = item.title || item.name || item.description || '';
const status = item.status || item.state || '';
return `${chalk.cyan(id)} ${title} ${status ? chalk.gray(`(${status})`) : ''}`;
}
function formatTable(data: unknown, columns?: string[]): string {
const items = Array.isArray(data) ? data : [data];
if (items.length === 0) return 'No results';
const cols = columns || Object.keys(items[0]).slice(0, 6);
const table = new Table({
head: cols.map(c => chalk.cyan(c)),
style: { head: [] },
});
for (const item of items) {
table.push(cols.map(c => String(item[c] ?? '')));
}
return table.toString();
}
Step 5: CLI Commands
#!/usr/bin/env node
import { program } from 'commander';
import { ApiClient } from '../lib/client.js';
import { saveCredential, getCredential } from '../lib/credentials.js';
import { formatResponse } from '../lib/formatter.js';
program.name('apicli').version('1.0.0');
// Auth commands
program
.command('auth')
.command('login <service>')
.option('--token <token>', 'API token or key')
.option('--type <type>', 'Auth type: api-key, bearer, basic', 'bearer')
.action(async (service, options) => {
const token = options.token || await promptForToken();
await saveCredential(service, options.type, token);
console.log(chalk.green(`✓ Credentials saved for ${service}`));
});
// Generic API request
program
.command('request <service> <path>')
.option('-m, --method <method>', 'HTTP method', 'GET')
.option('-d, --data <json>', 'Request body (JSON string)')
.option('-q, --query <params>', 'Query parameters (key=value,...)')
.option('-f, --format <fmt>', 'Output format: json, table, minimal, ids', 'json')
.option('--columns <cols>', 'Table columns (comma-separated)')
.action(async (service, path, options) => {
const cred = await getCredential(service);
if (!cred) {
throw new CliError(`No credentials for "${service}"`, {
suggestion: `Run: apicli auth login ${service} --token YOUR_TOKEN`,
});
}
const client = new ApiClient(getBaseUrl(service), cred);
const query = options.query
? Object.fromEntries(options.query.split(',').map(p => p.split('=')))
: undefined;
const result = await client.request({
method: options.method,
path,
query,
body: options.data ? JSON.parse(options.data) : undefined,
});
const output = formatResponse(
result,
options.format,
options.columns?.split(','),
);
console.log(output);
});
program.parse();
Usage Examples
# Save credentials
apicli auth login github --token ghp_xxx --type bearer
apicli auth login stripe --token sk_test_xxx --type bearer
# GitHub API
apicli request github /user --format minimal
apicli request github /repos/facebook/react/issues --format table --columns number,title,state
# Any REST API
apicli request myservice /api/v1/users --format table
apicli request myservice /api/v1/users -m POST -d '{"name":"Alice"}'
# Scripting
REPO_IDS=$(apicli request github /user/repos --format ids)
for id in $REPO_IDS; do
apicli request github /repos/$id/issues --format minimal
done
Extending for Specific APIs
The generic framework can be specialized for any API:
// commands/github.ts
program
.command('gh:repos')
.description('List your GitHub repositories')
.option('--sort <field>', 'Sort by: created, updated, pushed, full_name', 'updated')
.option('--limit <n>', 'Max results', parseInt, 30)
.action(async (options) => {
const client = await getClient('github');
const repos = await client.request({
path: '/user/repos',
query: { sort: options.sort, per_page: options.limit },
});
console.log(formatResponse(repos, options.format));
});
Conclusion
Every API you interact with regularly deserves a CLI wrapper. The patterns here — credential management, typed clients, error mapping, response formatting, pagination — apply to any REST API. Build the framework once, then adding new API commands takes minutes, not hours.
Wilson Xu builds developer CLI tools and API integrations. Find his 10+ tools at npm and follow at dev.to/chengyixu.
Top comments (0)