Toda vez que eu precisava montar uma CLI interna, eu caía no mesmo impasse. Ou eu montava o menu interativo na mão com alguma lib de prompts, ou usava um parser de argv e perdia a navegação por setas. Nunca os dois juntos. E pior: quando uma ação dava erro no meio do caminho, o terminal ficava num estado quebrado e eu perdia o progresso. Foi daí que nasceu a vereda-cli. Você declara uma árvore de menu uma única vez e ganha menu navegável, roteamento de argv e execução segura de ações, tudo no mesmo config.

Ela é construída em cima do @clack/core, é ESM-only, roda em Node 20+ e tem apenas três dependências de runtime. Se você ainda não migrou seu projeto pra ESM e quer entender o terreno, eu falei sobre isso no artigo de Node.js em 2026. Neste post eu mostro o quickstart na prática.

O problema que ela resolve

Existem ferramentas ótimas pra cada metade do problema. O citty e o cac fazem roteamento de argv muito bem, mas não te dão menu interativo. O @clack/prompts faz prompt bonito, mas você monta a navegação na mão e não tem roteamento de argv nenhum. O resultado é que, na prática, você sempre escolhe um lado e vive sem o outro.

A vereda-cli junta config declarativa, menu navegável, roteamento de argv e execução segura como um produto só. O mesmo config que abre um menu bonito no seu terminal também roteia comandos direto num pipeline de CI, com fallback seguro entre TTY e non-TTY e modo CI sem nenhuma configuração extra.

Instalação

BASH
yarn add vereda-cli
# ou
npm install vereda-cli

Definindo o config

O coração da lib é o defineCLI. Você passa um nome e uma árvore de menu. Cada folha pode ter um command, um schema de args e uma action. Use o defineMenuItem para que o ctx.args daquela folha seja inferido a partir do schema que você declarou. Se a ideia de inferência de tipos ainda soa abstrata pra você, eu cobri o básico no artigo de TypeScript do zero.

TYPESCRIPT
// cli-config.ts
import { defineCLI, defineMenuItem } from 'vereda-cli';

export default defineCLI({
  name: 'mycli',
  menu: [
    defineMenuItem({
      label: 'Build',
      command: 'build',
      args: { watch: { type: 'boolean' } },
      action: async (ctx) => {
        // ctx.args.watch e inferido como boolean | undefined
        const s = ctx.spinner('Compilando...');
        try {
          await build(ctx.args.watch);
          s.success('Pronto.');
        } catch (e) {
          s.error('Falhou.');
          throw e;
        }
      },
    }),
    {
      label: 'Settings',
      children: [
        defineMenuItem({
          label: 'Edit config',
          command: 'config:edit',
          action: (ctx) => editConfig(),
        }),
      ],
    },
  ],
});

Repare no submenu. Itens com children viram pastas navegáveis no menu, sem nenhum esforço extra da sua parte. E o ctx.args.watch ali em cima já chega tipado como boolean | undefined, porque a folha foi declarada com defineMenuItem.

O ponto de entrada

O run valida o config, aplica o tema, escolhe o modo de execução e devolve um código de saída. É só repassar para o process.exit.

TYPESCRIPT
// bin.ts
#!/usr/bin/env node
import config from './cli-config.js';
import { run } from 'vereda-cli';

process.exit(await run(config, process.argv.slice(2)));

Um config, dois mundos

O mesmo config serve para os dois cenários. Sem argv, você cai no menu interativo. Com argv, a lib roteia direto para a ação, sem abrir menu nenhum. É isso que faz o mesmo binário funcionar tanto no seu terminal quanto num pipeline de CI.

BASH
$ mycli              # menu interativo
$ mycli build        # roteia direto, sem menu
$ mycli build --watch

A detecção de non-TTY respeita CI=1 e FORCE_NO_TTY=1, então num runner de CI ela nunca trava esperando input. Se você quiser forçar um comportamento, existem os modos auto (padrão), interactive-only e argv-only.

Args tipados

Cada folha declara seus argumentos no campo args. O comportamento muda conforme a origem. Vindo de argv, os valores são coeridos contra o schema, e um required ausente vira erro. No menu interativo, um arg só é perguntado quando é required e ainda não foi passado via argv. Args opcionais e booleanos não são perguntados por padrão.

TYPESCRIPT
args: {
  path:   { type: 'string' },                          // opcional
  file:   { type: 'string', required: true },          // obrigatorio
  env:    { type: 'enum', options: ['prod', 'dev'] },
  watch:  { type: 'boolean' },                          // ativado por --watch
  region: { type: 'string', default: 'us-east-1' },     // default silencioso
}

Esse desenho resolve aquele atrito clássico de arg que é aceito via flag mas nunca é perguntado. Declara o arg como opcional, passa --arg e ele é usado sem nunca abrir um prompt.

Execução segura

Esse foi um dos motivos principais de eu ter construído a lib. A vereda-cli nunca joga uma mensagem de erro crua na cara do usuário final. Quando uma ação lança um erro, o terminal é restaurado e você decide o que fazer através do onActionError: logar, tentar de novo, mandar pra telemetria ou mostrar uma mensagem amigável. Em modo loop o menu continua; em one-shot ou roteamento de argv, a CLI sai com código 1.

TYPESCRIPT
defineCLI({
  onActionError: (err, { command }) => {
    log.warn(`Comando ${command} falhou; tente de novo.`);
    sendToTelemetry(err);
  },
  /* ... */
});

O ctx

Toda ação recebe um contexto rico. Além do args tipado, você tem spinner, log e prompts secundários como text, confirm, select e multiselect. Esses prompts rodam na própria instância de @clack/prompts da vereda, então o tema configurado vale pra eles também. Cada um pode retornar um valor ou um sentinel de cancelamento, que você checa com ctx.isCancel.

TYPESCRIPT
action: async (ctx) => {
  const name = await ctx.text({ message: 'Nome do projeto?' });
  if (ctx.isCancel(name)) return; // name fica estreitado
  ctx.log.info(`Ola ${name}`);
},

Tema

O menu navegável aceita tema próprio: cores, símbolos e mensagens. As cores aceitam tanto um nome ANSI quanto uma função (text) => string. A lib respeita NO_COLOR=1 pra desligar cores e cai pra ASCII quando o terminal não suporta unicode (via VEREDA_NO_UNICODE=1 ou TERM=dumb). As mensagens e atalhos de tecla também fluem pros prompts secundários.

O que ela ainda não faz

Pra ser honesto sobre os limites: não tem --help auto-gerado por folha (em contexto non-TTY ela imprime uma lista plana de comandos), os args posicionais podem ser lidos crus via ctx._ mas ainda não podem ser declarados, e os identificadores de comando são strings simples como deploy ou config:edit, sem namespacing aninhado tipo aws s3 cp. Declarar posicionais já está no mapa.

Fechando

O projeto é open source sob licença MIT. O código está no GitHub e o pacote no npm. Se você testar e tiver feedback, abre uma issue. Toda ajuda é bem-vinda.