Claude Code Plugins

Community-maintained marketplace

Feedback
3
0

Build TypeScript CLIs with Commander.js, JSON output, keychain auth, and consistent architecture. Use when creating new CLIs, adding commands, or wrapping APIs. Covers project structure, API clients, error handling, output formatting, and authentication patterns.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name javascript-cli
description Build TypeScript CLIs with Commander.js, JSON output, keychain auth, and consistent architecture. Use when creating new CLIs, adding commands, or wrapping APIs. Covers project structure, API clients, error handling, output formatting, and authentication patterns.

JavaScript/TypeScript CLI Development

Build CLIs that feel like first-class developer tools. Apply this skill when creating command-line interfaces that wrap APIs or system integrations.

Prerequisite: Apply the javascript-tooling skill for build tooling (tsup, Biome, oxlint, Vitest, bun).

Philosophy

JSON-First Output. CLIs are integration points. Output JSON so users can pipe to jq, parse in scripts, or feed to other tools. Human-readable output limits composability—users who want pretty output can pipe through jq.

Single Source of Truth. One API client class handles all requests. One auth manager handles credentials. One output function formats responses. This consistency means bugs get fixed once and behavior stays predictable.

Fail Loudly with Context. Errors should be machine-parseable JSON with enough detail to diagnose. Redact secrets but preserve structure. Exit with appropriate codes so scripts can react.

Secure by Default. Store credentials in OS keychain, where they're protected by the system. Accept env vars as fallback but warn users—environment variables can leak to child processes and logs.

Commands Mirror Resources. Structure commands around API resources: mycli users list, mycli accounts view. Users can guess commands without reading docs because the CLI mirrors the mental model of the API.

Project Structure

my-cli/
├── src/
│   ├── cli.ts              # Entry point, command registration
│   ├── commands/           # One file per resource
│   │   ├── auth.ts
│   │   └── users.ts
│   ├── lib/
│   │   ├── api-client.ts   # Single API wrapper class
│   │   ├── auth.ts         # Keychain + env var auth
│   │   ├── config.ts       # Local settings (conf package)
│   │   ├── output.ts       # JSON formatting
│   │   ├── errors.ts       # Error handling + redaction
│   │   ├── command-utils.ts# withErrorHandling, requireConfirmation
│   │   └── dates.ts        # Date parsing with dayjs
│   └── types/
│       └── index.ts
├── package.json
├── tsup.config.ts
└── biome.json

Entry Point Pattern

Capture global flags via Commander's preAction hook before commands run:

#!/usr/bin/env bun
import { Command } from 'commander';
import { setOutputOptions } from './lib/output.js';
import { createUsersCommand } from './commands/users.js';

declare const __VERSION__: string;

const program = new Command();

program
  .name('mycli')
  .description('CLI for MyService API')
  .version(__VERSION__)
  .option('-c, --compact', 'Minified JSON output')
  .hook('preAction', (thisCommand) => {
    setOutputOptions({ compact: thisCommand.opts().compact });
  });

program.addCommand(createAuthCommand());
program.addCommand(createUsersCommand());
program.parse();

Command Pattern

Each command file exports a factory function. The withErrorHandling wrapper catches exceptions, formats them as JSON, and exits non-zero:

import { Command } from 'commander';
import { client } from '../lib/api-client.js';
import { outputJson } from '../lib/output.js';
import { withErrorHandling, requireConfirmation } from '../lib/command-utils.js';

export function createUsersCommand(): Command {
  const cmd = new Command('users').description('User operations');

  cmd.command('list')
    .option('-l, --limit <n>', 'Limit results', '50')
    .action(withErrorHandling(async (options) => {
      const users = await client.getUsers({ limit: Number(options.limit) });
      outputJson(users);
    }));

  cmd.command('view')
    .argument('<id>', 'User ID')
    .action(withErrorHandling(async (id) => {
      const user = await client.getUser(id);
      outputJson(user);
    }));

  cmd.command('delete')
    .argument('<id>', 'User ID')
    .option('-y, --yes', 'Skip confirmation')
    .action(withErrorHandling(async (id, options) => {
      requireConfirmation('user', options.yes);
      await client.deleteUser(id);
      outputJson({ deleted: id });
    }));

  return cmd;
}

API Client Pattern

Route all API calls through a single client class. This centralizes auth header injection, error handling, and response processing:

export class MyClient {
  private baseUrl = 'https://api.myservice.com/v1';

  private async getHeaders(): Promise<Record<string, string>> {
    const token = await auth.getAccessToken() || process.env.MY_API_KEY;
    if (!token) throw new MyCliError('Not authenticated. Run: mycli auth login', 401);
    return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
  }

  async getUsers(params?: { limit?: number }) {
    const query = params?.limit ? `?limit=${params.limit}` : '';
    const response = await fetch(`${this.baseUrl}/users${query}`, {
      headers: await this.getHeaders(),
    });
    if (!response.ok) throw await this.handleError(response);
    return response.json();
  }

  async getUser(id: string) {
    const response = await fetch(`${this.baseUrl}/users/${id}`, {
      headers: await this.getHeaders(),
    });
    if (!response.ok) throw await this.handleError(response);
    return response.json();
  }
}

export const client = new MyClient();

Authentication Pattern

Keychain storage via @napi-rs/keyring with environment variable fallback:

import { Entry } from '@napi-rs/keyring';

const SERVICE = 'my-cli';
const ACCOUNT = 'access-token';

export class AuthManager {
  async getAccessToken(): Promise<string | null> {
    try {
      return new Entry(SERVICE, ACCOUNT).getPassword();
    } catch {
      return null;
    }
  }

  async setAccessToken(token: string): Promise<void> {
    new Entry(SERVICE, ACCOUNT).setPassword(token);
  }

  async deleteAccessToken(): Promise<boolean> {
    try {
      return new Entry(SERVICE, ACCOUNT).deletePassword();
    } catch {
      return false;
    }
  }
}

export const auth = new AuthManager();

Error Handling Pattern

Structured JSON errors with secret redaction:

export class MyCliError extends Error {
  constructor(message: string, public statusCode?: number) {
    super(message);
    this.name = 'MyCliError';
  }
}

export function sanitizeErrorMessage(message: string): string {
  return message
    .replace(/Bearer\s+[\w\-._~+/]+=*/gi, '[REDACTED]')
    .replace(/api[_-]?key[=:]\s*[\w\-._~+/]+=*/gi, '[REDACTED]');
}

export function handleApiError(error: unknown): never {
  const detail = error instanceof Error ? sanitizeErrorMessage(error.message) : 'Unknown error';
  const statusCode = error instanceof MyCliError ? error.statusCode : 1;
  outputJson({ error: { name: 'api_error', detail, statusCode } });
  process.exit(1);
}

Output Pattern

Global options with domain-specific transforms:

let globalOptions: OutputOptions = {};

export function setOutputOptions(options: OutputOptions): void {
  globalOptions = options;
}

export function outputJson(data: unknown): void {
  // Apply domain transforms here (currency conversion, HTML stripping, etc.)
  const json = globalOptions.compact
    ? JSON.stringify(data)
    : JSON.stringify(data, null, 2);
  console.log(json);
}

Conventions Reference

Concern Pattern
List commands Return arrays directly: [{...}, {...}]
Destructive actions Require --yes flag
Default resource ID Config → flag → env var
Credentials Keychain → env var (with warning)
Date input Flexible formats via dayjs, store as ISO
Global flags --compact for minified JSON
Error shape { error: { name, detail, statusCode } }

Correct Patterns

Instead of Use
{ data: users, meta: {...} } users (array directly)
console.log("Created user") outputJson({ created: id })
fetch() in command handler client.createUser()
Token in ~/.myclirc @napi-rs/keyring
Silent catch returning null Throw structured error
{ message: "..." } { error: { name, detail, statusCode } }

Domain Transforms

When APIs have quirks, add recursive transforms in output.ts:

Domain Transform
Currency (YNAB) Milliunits → dollars: amount / 1000
HTML content (HelpScout) Strip to plain text
HAL responses Remove _links, _embedded
Person objects Compute name from first + last

Dependencies

{
  "dependencies": {
    "@napi-rs/keyring": "^1.1.0",
    "commander": "^12.0.0",
    "conf": "^13.0.0",
    "dayjs": "^1.11.0",
    "dotenv": "^16.0.0"
  }
}

Testing Focus

Test the boundaries, not the happy path:

// Error sanitization: secrets get redacted
expect(sanitizeErrorMessage('Bearer abc123')).toBe('[REDACTED]');

// Domain transforms: amounts convert correctly
expect(convertMilliunits({ amount: 50000 })).toEqual({ amount: 50 });

// Auth fallback: keyring checked before env var

Provide a reset function for keyring state in tests:

export function resetKeyringForTesting(): void {
  keyring = undefined;
}

Auth Command Template

export function createAuthCommand(): Command {
  const cmd = new Command('auth').description('Authentication');

  cmd.command('login')
    .argument('<api-key>', 'Your API key')
    .action(withErrorHandling(async (apiKey) => {
      await auth.setAccessToken(apiKey);
      outputJson({ status: 'authenticated' });
    }));

  cmd.command('logout')
    .action(withErrorHandling(async () => {
      await auth.deleteAccessToken();
      outputJson({ status: 'logged_out' });
    }));

  cmd.command('status')
    .action(withErrorHandling(async () => {
      outputJson({ authenticated: await auth.isAuthenticated() });
    }));

  return cmd;
}