Como construir uma CLI em TypeScript com Commander ou oclif

1. Introdução às CLIs em TypeScript e escolha do framework

Ferramentas de linha de comando (CLI) são essenciais para automatizar tarefas no desenvolvimento de software. TypeScript eleva a criação de CLIs com tipagem segura, autocomplete inteligente e manutenibilidade superior. Ao escolher um framework, duas opções se destacam:

Commander.js é uma biblioteca leve e flexível, ideal para projetos pequenos e médios. Sua API minimalista permite definir comandos, opções e argumentos com poucas linhas de código.

oclif (Open CLI Framework) da Heroku é uma solução opinativa e escalável, projetada para CLIs complexas com suporte nativo a plugins, múltiplos comandos e scaffolding automático.

A escolha depende do escopo: Commander para simplicidade, oclif para crescimento.

2. Configuração inicial do projeto

Inicializando o projeto TypeScript

mkdir minha-cli
cd minha-cli
npm init -y
npm install typescript @types/node --save-dev
npx tsc --init

Configure o tsconfig.json com:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "declaration": true
  },
  "include": ["src/**/*"]
}

Configuração com Commander

npm install commander

Estrutura básica do arquivo src/index.ts:

#!/usr/bin/env node
import { Command } from 'commander';

const program = new Command();

program
  .name('minha-cli')
  .description('CLI de exemplo com Commander')
  .version('1.0.0');

program.parse(process.argv);

Configuração com oclif

npx oclif generate minha-cli
cd minha-cli
npm install

O gerador cria automaticamente a estrutura de diretórios, comandos de exemplo e configuração completa.

3. Estrutura de comandos e argumentos

Commander: comandos principais e subcomandos

// src/index.ts
import { Command } from 'commander';

const program = new Command();

program
  .command('criar <nome>')
  .description('Cria um novo recurso')
  .option('-t, --tipo <tipo>', 'Tipo do recurso', 'padrao')
  .action((nome, options) => {
    console.log(`Recurso "${nome}" criado como tipo "${options.tipo}"`);
  });

program
  .command('listar')
  .description('Lista recursos disponíveis')
  .action(() => {
    console.log('Listando recursos...');
  });

program.parse(process.argv);

oclif: comandos aninhados e namespaces

// src/commands/criar.ts
import { Command, Flags } from '@oclif/core';

export default class Criar extends Command {
  static description = 'Cria um novo recurso';

  static flags = {
    tipo: Flags.string({ char: 't', description: 'Tipo do recurso', default: 'padrao' }),
  };

  static args = [{ name: 'nome', required: true }];

  async run(): Promise<void> {
    const { args, flags } = await this.parse(Criar);
    this.log(`Recurso "${args.nome}" criado como tipo "${flags.tipo}"`);
  }
}

Para subcomandos, crie diretórios aninhados: src/commands/recurso/criar.ts.

Manipulação de argumentos e flags

Commander:

.option('-d, --debug', 'Modo debug', false)
.option('-n, --numero <valor>', 'Número inteiro', parseInt)
.argument('<arquivo>', 'Arquivo de entrada')
.argument('[diretorio]', 'Diretório de saída', '.')

oclif:

static flags = {
  debug: Flags.boolean({ char: 'd', default: false }),
  numero: Flags.integer({ char: 'n', description: 'Número inteiro' }),
};

static args = {
  arquivo: Args.string({ required: true, description: 'Arquivo de entrada' }),
  diretorio: Args.string({ default: '.', description: 'Diretório de saída' }),
};

4. Validação de entrada e tratamento de erros

Validadores customizados (Commander)

function validarEmail(valor: string): string {
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(valor)) {
    throw new Error('Email inválido');
  }
  return valor;
}

program
  .command('enviar-email <email>')
  .argument('<email>', 'Email do destinatário', validarEmail)
  .action((email) => {
    console.log(`Email enviado para ${email}`);
  });

Mensagens de erro amigáveis

Commander:

program.exitOverride();
program.configureOutput({
  outputError: (str, write) => write(`❌ Erro: ${str}`)
});

oclif:

async run(): Promise<void> {
  try {
    // lógica principal
  } catch (error) {
    this.error('Falha ao executar comando', { exit: 1 });
  }
}

Exibição de help automático

Ambos frameworks geram help automaticamente com --help ou -h.

5. Interação com o usuário e saída formatada

Leitura de input com enquirer

npm install enquirer
import { Input, Select } from 'enquirer';

const resposta = await new Input({
  name: 'nome',
  message: 'Qual seu nome?'
}).run();

const cor = await new Select({
  name: 'cor',
  message: 'Escolha uma cor',
  choices: ['Vermelho', 'Azul', 'Verde']
}).run();

Formatação com chalk e cli-table3

npm install chalk cli-table3
import chalk from 'chalk';
import Table from 'cli-table3';

const tabela = new Table({
  head: ['Nome', 'Idade', 'Cargo'],
  colWidths: [20, 10, 15]
});

tabela.push(
  ['João Silva', 30, 'Desenvolvedor'],
  ['Maria Souza', 28, 'Designer']
);

console.log(chalk.green.bold('Funcionários:'));
console.log(tabela.toString());
console.log(chalk.yellow(`Total: 2 registros`));

Barras de progresso com ora e cli-progress

npm install ora cli-progress
import ora from 'ora';
import cliProgress from 'cli-progress';

const spinner = ora('Processando...').start();
// simula trabalho
setTimeout(() => {
  spinner.succeed('Concluído!');
}, 2000);

const bar = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic);
bar.start(100, 0);
for (let i = 0; i <= 100; i++) {
  bar.update(i);
  // simula progresso
}
bar.stop();

6. Testes e depuração da CLI

Testes unitários com Jest

npm install jest ts-jest @types/jest --save-dev
// src/__tests__/comandos.test.ts
import { Command } from 'commander';

test('comando criar deve aceitar nome', () => {
  const program = new Command();
  program
    .command('criar <nome>')
    .action((nome) => {
      expect(nome).toBe('meu-recurso');
    });

  program.parse(['node', 'test', 'criar', 'meu-recurso']);
});

Testes de integração com execa

npm install execa --save-dev
import { execa } from 'execa';

test('CLI deve executar comando listar', async () => {
  const { stdout } = await execa('node', ['./dist/index.js', 'listar']);
  expect(stdout).toContain('Listando recursos');
});

Depuração com Node.js inspect

node --inspect-brk dist/index.js meu-comando

Use Chrome DevTools para depuração visual, breakpoints e inspeção de variáveis.

7. Empacotamento e distribuição

Configuração do bin no package.json

{
  "bin": {
    "minha-cli": "./dist/index.js"
  },
  "scripts": {
    "build": "tsc",
    "prepublishOnly": "npm run build"
  }
}

Publicação no npm

npm login
npm publish
npm install -g minha-cli

Executáveis standalone com pkg

npm install -g pkg
pkg dist/index.js --targets node18-linux-x64,node18-win-x64,node18-macos-x64

Isso gera binários independentes que não requerem Node.js instalado.

8. Comparação prática: Commander vs oclif em projetos reais

Exemplo: CLI de gerenciamento de tarefas

Commander (simplicidade):

// tarefas-cli/src/index.ts
const program = new Command();

program
  .command('adicionar <tarefa>')
  .option('-p, --prioridade <nivel>', 'Prioridade (alta, media, baixa)', 'media')
  .action((tarefa, options) => {
    console.log(`Tarefa "${tarefa}" adicionada com prioridade ${options.prioridade}`);
  });

program
  .command('listar')
  .option('--prioridade <nivel>', 'Filtrar por prioridade')
  .action((options) => {
    console.log(`Listando tarefas${options.prioridade ? ` (prioridade: ${options.prioridade})` : ''}`);
  });

program.parse();

oclif (escalabilidade):

// tarefas-cli/src/commands/adicionar.ts
export default class Adicionar extends Command {
  static description = 'Adiciona nova tarefa';
  static args = { tarefa: Args.string({ required: true }) };
  static flags = {
    prioridade: Flags.string({ options: ['alta', 'media', 'baixa'], default: 'media' })
  };

  async run() {
    const { args, flags } = await this.parse(Adicionar);
    this.log(`Tarefa "${args.tarefa}" adicionada com prioridade ${flags.prioridade}`);
  }
}

Quando usar cada um

Característica Commander oclif
Complexidade Baixa Alta
Curva de aprendizado Rápida Moderada
Plugins Manual Nativo
Scaffolding Manual Automático
Ideal para Scripts rápidos, CLIs pequenas CLIs empresariais, multi-comando

Commander é perfeito para ferramentas internas, scripts de automação e projetos onde a simplicidade é prioridade.

oclif brilha em CLIs que crescerão com o tempo, necessitam de plugins, hooks e comandos aninhados complexos.

Referências