DEV Community

Wilson Xu
Wilson Xu

Posted on

Build a Type-Safe API Client CLI with Node.js

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:

  1. Authentication (API keys, OAuth tokens, bearer tokens)
  2. Request building with typed parameters
  3. Response formatting (JSON, table, minimal)
  4. Pagination handling
  5. Error mapping from HTTP status codes to helpful messages
  6. 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 {};
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
  });
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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));
  });
Enter fullscreen mode Exit fullscreen mode

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)