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
yarn add vereda-cli
# ou
npm install vereda-cliDefinindo 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.
// 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.
// 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.
$ mycli # menu interativo
$ mycli build # roteia direto, sem menu
$ mycli build --watchA 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.
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.
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.
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.