npm.io
0.6.1 • Published yesterday

@ls-stack/cli

Licence
MIT
Version
0.6.1
Deps
2
Size
138 kB
Vulns
0
Weekly
0

@ls-stack/cli

A TypeScript library for building interactive command-line interfaces with type-safe prompts and ESC-to-cancel support.

Installation

pnpm add @ls-stack/cli
# or
yarn add @ls-stack/cli

Requirements: Node.js >= 21.5.0

Quick Start

import { cliInput } from '@ls-stack/cli';

const name = await cliInput.text('What is your name?');
const proceed = await cliInput.confirm('Continue?', { initial: true });

console.log(`Hello, ${name}!`);

Interactive prompts require stdin and stdout to be connected to a TTY. They exit with an error in non-interactive environments instead of waiting for input that cannot be provided.

API Reference

Types
type ValidateFn = (
  value: string,
) => boolean | string | Promise<boolean | string>;

type SelectOption<T extends string> = {
  value: T;
  label?: string;
  hint?: string;
};
cliInput.select()

Single selection from a list of options.

const choice = await cliInput.select<'dev' | 'staging' | 'prod'>(
  'Select environment',
  {
    options: [
      { value: 'dev', label: 'Development', hint: 'Local development server' },
      { value: 'staging', label: 'Staging', hint: 'Pre-production testing' },
      { value: 'prod', label: 'Production', hint: 'Live environment' },
    ],
  },
);
// choice: 'dev' | 'staging' | 'prod'

Parameters:

Name Type Description
title string The prompt message
options.options SelectOption<T>[] Array of selectable options

Returns: Promise<T> - The selected option's value

cliInput.multipleSelect()

Multi-select with checkboxes. Requires at least one selection.

const features = await cliInput.multipleSelect<'ts' | 'eslint' | 'prettier'>(
  'Select features to enable',
  {
    options: [
      { value: 'ts', label: 'TypeScript' },
      { value: 'eslint', label: 'ESLint', hint: 'Code linting' },
      { value: 'prettier', label: 'Prettier', hint: 'Code formatting' },
    ],
  },
);
// features: ('ts' | 'eslint' | 'prettier')[]

Parameters:

Name Type Description
title string The prompt message
options.options SelectOption<T>[] Array of selectable options

Returns: Promise<T[]> - Array of selected values

cliInput.text()

Text input with optional validation.

const projectName = await cliInput.text('Enter project name', {
  initial: 'my-project',
  validate: (value) => {
    if (!/^[a-z0-9-]+$/.test(value)) {
      return 'Only lowercase letters, numbers, and hyphens allowed';
    }
    return true;
  },
});

Parameters:

Name Type Description
title string The prompt message
options.initial string? Default value
options.validate ValidateFn? Validation function

Returns: Promise<string> - The entered text

cliInput.textWithAutocomplete()

Text input with autocomplete suggestions. Searches across value, label, and hint.

const framework = await cliInput.textWithAutocomplete<
  'react' | 'vue' | 'svelte'
>('Select a framework', {
  options: [
    {
      value: 'react',
      label: 'React',
      hint: 'A JavaScript library for building UIs',
    },
    {
      value: 'vue',
      label: 'Vue',
      hint: 'The progressive JavaScript framework',
    },
    {
      value: 'svelte',
      label: 'Svelte',
      hint: 'Cybernetically enhanced web apps',
    },
  ],
  validate: (value) => value.length > 0 || 'Please select a framework',
});

Parameters:

Name Type Description
title string The prompt message
options.options SelectOption<T>[] Array of autocomplete suggestions
options.validate ValidateFn? Validation function

Returns: Promise<T> - The selected or entered value

cliInput.confirm()

Yes/No boolean prompt.

const shouldDeploy = await cliInput.confirm('Deploy to production?', {
  initial: false,
});
// shouldDeploy: boolean

Parameters:

Name Type Description
title string The prompt message
options.initial boolean? Default value (true for yes, false for no)

Returns: Promise<boolean> - The user's choice

cliInput.number()

Numeric input.

const port = await cliInput.number('Enter port number', {
  initial: 3000,
});
// port: number | null

Parameters:

Name Type Description
title string The prompt message
options.initial number? Default value

Returns: Promise<number | null> - The entered number, or null on error


CLI Framework

Build complete CLI applications with typed commands, automatic help generation, and interactive mode.

Quick Start
import { createCLI, createCmd } from '@ls-stack/cli';

await createCLI(
  { name: 'My CLI', baseCmd: 'my-cli' },
  {
    hello: createCmd({
      short: 'hi',
      description: 'Say hello',
      run: async () => {
        console.log('Hello, World!');
      },
    }),
  },
);
Argument Types
type Arg =
  | { type: 'positional-string'; name: string; description: string; default?: string }
  | { type: 'positional-number'; name: string; description: string; default?: number }
  | { type: 'flag'; name: string; description: string }
  | { type: 'value-string-flag'; name: string; description: string; default?: string }
  | { type: 'value-number-flag'; name: string; description: string; default?: number };
Type CLI Usage TypeScript Type
positional-string my-cli cmd value string (or string | undefined if no default)
positional-number my-cli cmd 42 number (or number | undefined if no default)
flag my-cli cmd --verbose boolean (defaults to false)
value-string-flag my-cli cmd --env dev string | undefined (or string if default)
value-number-flag my-cli cmd --port 80 number | undefined (or number if default)
createCmd()

Creates a type-safe command definition.

const deploy = createCmd({
  short: 'd',
  description: 'Deploy the application',
  args: {
    env: {
      type: 'positional-string',
      name: 'env',
      description: 'Target environment',
    },
    port: {
      type: 'value-number-flag',
      name: 'port',
      description: 'Port number',
      default: 3000,
    },
    verbose: {
      type: 'flag',
      name: 'verbose',
      description: 'Enable verbose logging',
    },
  },
  examples: [
    { args: ['production'], description: 'Deploy to production' },
    { args: ['staging', '--port', '8080'], description: 'Deploy to staging on port 8080' },
  ],
  run: async ({ env, port, verbose }) => {
    // Types are inferred: env: string, port: number, verbose: boolean
    console.log(`Deploying to ${env} on port ${port}`);
    if (verbose) console.log('Verbose mode enabled');
  },
});

Parameters:

Name Type Description
description string Command description shown in help
short string? Single-character alias (cannot be 'i' or 'h')
args Record<string, Arg>? Typed argument definitions
run (args) => void | Promise Handler function receiving parsed arguments
examples { args, description }[]? Usage examples for help text
createCLI()

Creates and runs a CLI application.

await createCLI(
  {
    name: 'My CLI',
    baseCmd: 'my-cli',
    sort: ['deploy', 'build', 'test'], // Optional: custom command order
  },
  {
    deploy: deployCmd,
    build: buildCmd,
    test: testCmd,
  },
);

Parameters:

Name Type Description
name string CLI display name shown in header
baseCmd string Command prefix for help text
sort string[]? Custom command display order
cmds Record<C, Cmd> Commands created with createCmd
Built-in Commands
Command Description
h, --help Show help with all commands
i Interactive mode (select from list)
<command> -h Show help for a specific command
CLI Usage Examples
my-cli                    # Show interactive menu
my-cli h                  # Show help
my-cli --help             # Show help
my-cli i                  # Interactive mode
my-cli deploy prod        # Run deploy with positional arg
my-cli d prod             # Run deploy via short alias
my-cli deploy -h          # Show deploy command help
my-cli deploy prod --port 8080 --verbose

When no command is provided, the interactive menu is opened only if both stdin and stdout are connected to a TTY. In non-interactive environments such as CI or piped commands, the CLI prints its help instead. Explicitly requesting i without a TTY exits with an error.

Complete Example
import { createCLI, createCmd } from '@ls-stack/cli';

await createCLI(
  { name: 'Project CLI', baseCmd: 'project' },
  {
    create: createCmd({
      short: 'c',
      description: 'Create a new project',
      args: {
        name: {
          type: 'positional-string',
          name: 'name',
          description: 'Project name',
        },
        template: {
          type: 'value-string-flag',
          name: 'template',
          description: 'Project template',
          default: 'basic',
        },
      },
      examples: [
        { args: ['my-app'], description: 'Create with default template' },
        { args: ['my-app', '--template', 'react'], description: 'Create React project' },
      ],
      run: async ({ name, template }) => {
        console.log(`Creating ${name} with template: ${template}`);
      },
    }),

    build: createCmd({
      short: 'b',
      description: 'Build the project',
      args: {
        watch: {
          type: 'flag',
          name: 'watch',
          description: 'Watch for changes',
        },
      },
      run: async ({ watch }) => {
        console.log(watch ? 'Building in watch mode...' : 'Building...');
      },
    }),

    serve: createCmd({
      short: 's',
      description: 'Start development server',
      args: {
        port: {
          type: 'value-number-flag',
          name: 'port',
          description: 'Port number',
          default: 3000,
        },
      },
      run: async ({ port }) => {
        console.log(`Server running on http://localhost:${port}`);
      },
    }),
  },
);

Features

ESC-to-Cancel

All prompts support pressing ESC to cancel. When cancelled, the process exits cleanly with code 0.

Type Safety

All prompts are fully typed. When using generic type parameters with select, multipleSelect, or textWithAutocomplete, the return type is narrowed to the union of option values.

// Return type is automatically 'small' | 'medium' | 'large'
const size = await cliInput.select<'small' | 'medium' | 'large'>(
  'Select size',
  {
    options: [{ value: 'small' }, { value: 'medium' }, { value: 'large' }],
  },
);
Validation

Text inputs support synchronous or asynchronous validation:

const email = await cliInput.text('Enter email', {
  validate: async (value) => {
    if (!value.includes('@')) {
      return 'Invalid email format';
    }
    const exists = await checkEmailExists(value);
    if (exists) {
      return 'Email already registered';
    }
    return true;
  },
});

Examples

Interactive Setup Wizard
import { cliInput } from '@ls-stack/cli';

async function setupWizard() {
  const projectName = await cliInput.text('Project name', {
    validate: (v) => v.length >= 3 || 'Name must be at least 3 characters',
  });

  const template = await cliInput.select('Select template', {
    options: [
      { value: 'blank', label: 'Blank', hint: 'Empty project' },
      { value: 'react', label: 'React', hint: 'React with Vite' },
      { value: 'next', label: 'Next.js', hint: 'Full-stack React' },
    ],
  });

  const features = await cliInput.multipleSelect('Enable features', {
    options: [
      { value: 'typescript', label: 'TypeScript' },
      { value: 'eslint', label: 'ESLint' },
      { value: 'prettier', label: 'Prettier' },
      { value: 'testing', label: 'Testing (Vitest)' },
    ],
  });

  const installDeps = await cliInput.confirm('Install dependencies?', {
    initial: true,
  });

  return { projectName, template, features, installDeps };
}
Configuration Menu
import { cliInput } from '@ls-stack/cli';

async function configMenu() {
  const action = await cliInput.select('What would you like to configure?', {
    options: [
      { value: 'port', label: 'Server Port' },
      { value: 'host', label: 'Host Address' },
      { value: 'timeout', label: 'Request Timeout' },
    ],
  });

  switch (action) {
    case 'port': {
      const port = await cliInput.number('Enter port', { initial: 3000 });
      console.log(`Port set to ${port}`);
      break;
    }
    case 'host': {
      const host = await cliInput.text('Enter host', { initial: 'localhost' });
      console.log(`Host set to ${host}`);
      break;
    }
    case 'timeout': {
      const timeout = await cliInput.number('Timeout (seconds)', {
        initial: 30,
      });
      console.log(`Timeout set to ${timeout}s`);
      break;
    }
  }
}

Error Handling

All prompts handle errors gracefully:

  • User cancellation (ESC/Ctrl+C): Process exits with code 0
  • Other errors: Error is logged and process exits with code 1

For the number() prompt specifically, non-cancellation errors return null instead of exiting.

License

MIT