Pular para o conteúdo
MÓDULO 3.4

⌨️ Comandos, ferramentas e configuração

Um /comando de mod responde sem chamar o modelo: é rápido, não gasta cota e funciona até num claude -p. Aqui o Rafa registra comandos, desenha a saída deles, dá uma ferramenta ao modelo, lê as opções do /config e aprende a não ser derrubado por um nome repetido.

6
Tópicos
~35
Minutos
Médio
Nível
Prático
Tipo
0 de 60%
Versão conferida: CommandSpec, CommandRunResult, ToolSpec e o componente CommandOutput conferidos no claude-code.d.ts do Claude Code 2.1.289, em 05/10/2026. O /recibo do tópico 2 foi rodado nessa versão; o mod de exemplo do tópico 6 passou no claude plugin validate.
1

Registre um comando de barra

Um comando de mod nasce em duas partes, como o painel: $.command.register declara o /nome e um hook command.run com o matcher { command: 'nome' } responde. Sem o hook, rodar o comando só diz que ninguém respondeu.

O registro vai no session.start, que roda uma vez quando a sessão fica pronta, antes do primeiro prompt. O comando aparece na lista do / a partir da próxima tecla.

🆕 Novo aqui? Os campos de CommandSpec

  • name — sem a barra; letras, dígitos, _ e -, até 64. A pessoa digita /name.
  • description — a linha que a lista do / e o /help mostram.
  • argumentHint — a dica apagada depois do nome, quando o comando aceita argumentos.
  • immediate — roda na hora mesmo com um turno em andamento, em vez de esperar o turno acabar. O hook não pode supor nada sobre o turno.
📄 inema-mods/mods/recibo-sessao/hooks/register.tsx (trecho)
on('session.start', async ($, e, next) => {
  await $.command.register({
    name: 'recibo',
    description: 'Recibo da sessão: arquivos criados/alterados (args: ultimo | limpar)',
  })
  return next(e)
})
O que olhar: o registro é aguardado (await) antes do next. Registrar o mesmo nome de novo substitui; o nome de um comando embutido do Claude Code é recusado. O tópico 6 mostra o que falta aqui.
2

Responda o command.run

O e do command.run traz command, args (o texto depois do nome), origin (quem rodou) e presentation (isFullscreen e columns da tela). O hook devolve { text, context? } e nenhum modelo é chamado.

text vira a linha de saída na conversa, que o modelo também lê depois. context é uma lista de notas que só o modelo lê, gravadas depois da saída. E exitCode só vale quando o comando é o prompt inteiro de um claude -p: vira o código de saída do processo.

/reciboa pessoa digita command.runhook do mod responde textlinha na conversa: pessoa e modelo leem contextnota escondida: só o modelo lê nenhuma chamada ao modelo

Como ler o desenho: o comando sai da pessoa e volta para a conversa sem passar pelo modelo. As duas saídas do hook vão para leitores diferentes: a azul todo mundo vê, a cinza só o modelo, no próximo turno.

🎯 Objetivo: rodar um comando de mod sem abrir sessão nem gastar modelo

No terminal, na pasta onde você guarda projetos (uma linha de cada vez):

git clone https://github.com/inematds/inema-mods
cd inema-mods
claude -p "/recibo" --plugin-dir mods/recibo-sessao

Saída real (Claude Code 2.1.289, 05/10/2026):

recibo-sessao: Nesta sessão: nenhum arquivo criado ou alterado.
Como verificar: a linha começa com o nome do plugin (recibo-sessao:) seguido do text que o hook devolveu. Volta em poucos segundos: não houve chamada ao modelo. É o teste de fumaça mais barato que existe para um mod.
3

Desenhe a saída do comando

A linha de saída de um comando é um componente como outro qualquer: CommandOutput. Com um ui.render nele, o mod troca o texto simples por uma árvore desenhada dentro da conversa, onde ficariam as linhas de um comando embutido.

O matcher filtra pelo nome: { component: 'CommandOutput', props: { command: 'rafa-ping' } }. O que o modelo lê continua sendo o text que o command.run devolveu; a árvore é só para os olhos.

Prop de CommandOutputO que é
commandqual comando imprimiu a linha (sem barra); só leitura
argsos argumentos, como o eco acima da linha mostra
texto texto da linha, em markdown
isErroreda linha é o erro de um comando que falhou
📄 meus-comandos/hooks/register.tsx (trecho do mod do tópico 6)
on('ui.render', { component: 'CommandOutput', props: { command: 'rafa-ping' } }, async ($, e) => {
  const { Box, Text } = $.ui.resolve(e)
  return (
    <Box borderStyle="round" paddingX={1}>
      <Text color="magenta">{e.props.text}</Text>
    </Box>
  )
})
O que olhar: o render não recalcula nada; desenha o e.props.text numa caixa de borda redonda. round é um dos estilos de borda que o terminal conhece (módulo 3.1, tópico 5).

💡 Saída longa? Abra um painel

A linha de saída fica na conversa para sempre. Uma lista grande ou algo com botões cabe melhor num painel aberto pelo próprio comando, como o /recibo faz: abre o painel e devolve um text curto de resumo.

4

Dê uma ferramenta ao modelo

O comando é para a pessoa. Para o modelo, o mod declara uma ferramenta com $.tool.register({ name, description, inputSchema }). Ela aparece na lista do modelo como mcp__<plugin>__<name>, sem servidor MCP nenhum.

Quem atende é um hook tool.call com o matcher no nome completo. Os campos de entrada chegam direto no e; o hook devolve { result }. Registrada e aguardada no session.start, a ferramenta já está na lista no primeiro turno.

session.starttool.register o modelo vêmcp__meus-comandos__contar_linhas tool.call (matcher)o hook devolve { result } 1. declarar 2. aparecer na lista 3. atender a chamada chamada que nenhum hook atende falha dizendo isso

Como ler o desenho: as duas caixas roxas são código do mod; a azul é o que o modelo enxerga. O nome do meio é montado pelo engine com o nome do plugin, e é esse nome inteiro que vai no matcher do tool.call.

📄 claude-mods-starter-kit/plugins/session-bookmarks/hooks/session-bookmarks.mjs (Prompt Advisers, MIT; trechos)
await $.tool.register({
  name: TOOL_FIND,
  description:
    "Search the user's saved session bookmarks by words in the title, note or project name. " + ...,
  inputSchema: {
    type: "object",
    properties: { query: { type: "string", description: "Words to look for; empty lists every bookmark" } },
  },
});
// ...
on("tool.call", { tool: "mcp__session-bookmarks__bookmark_session" }, async ($, e) => {
  // ...
  return { result: withWarning(saved.warning, savedText(saved)) };
});
O que olhar: a description é o que o modelo lê para decidir quando chamar; a do kit diz até quando não chamar. O hook do bookmark_session lê e.title e e.note, os campos do inputSchema dela, direto do e.

✓ description boa

  • ✓ Diz o que a ferramenta faz e o que devolve
  • ✓ Diz quando chamar e quando não chamar
  • ✓ Cada campo do inputSchema com sua própria description

✗ description vaga

  • ✗ "Ferramenta útil do Rafa"
  • ✗ Sem limite: o modelo chama por conta própria
  • ✗ Campos sem tipo: chegam do jeito que o modelo inventar
5

Leia as opções do /config

O que a pessoa pode ajustar vai no userConfig do plugin.json (módulo 1.2). Cada campo que não é segredo vira uma linha no /config. O valor escolhido chega no segundo parâmetro de register(on, options).

Mudar a opção no /config recarrega o módulo com o novo options. Por isso o mod lê as opções no corpo do register e não precisa vigiar nada. Um campo string com options vira um seletor só com aqueles valores.

📄 recibo-sessao/.claude-plugin/plugin.json (trecho)
"userConfig": {
  "abridor": {
    "type": "string",
    "title": "Programa que abre pastas",
    "description": "xdg-open no Linux, open no Mac, explorer no Windows.",
    "default": "xdg-open",
    "options": ["xdg-open", "open", "explorer"]
  }
}
📄 recibo-sessao/hooks/register.tsx (trechos)
export const register: Register = (on, options) => {
  // ...
  const abridor = String(options.abridor ?? 'xdg-open')
  // ...
  const r = await $.process.run([abridor, pasta]).catch(() => undefined)
1

Declare no plugin.json

type, title, description, default e, se for escolha, options.

2

Leia com padrão

String(options.x ?? 'padrão'). O tipo de options é texto, número, booleano ou lista de texto: converta antes de usar.

3

Para mod carregado por pasta

Os valores ficam nas configurações em pluginConfigs, com a chave do nome do plugin (ou <nome>@inline).

4

No teste, passe as opções

test(nome, { options }, corpo) entrega os valores como se viessem das configurações; sem isso, valem os default do manifesto (módulo 4.2).

💡 E as opções dos outros?

Cada linha do /config, do engine e de todo plugin, pode ser lida com $.config.list(). Útil para um mod que se adapta ao tema da pessoa. Mudar a de outro ($.config.set) é possível, mas é o tipo de coisa que precisa estar escrita no README.

6

Evite colisão de nomes

Visto ao vivo no kit INEMA: $.command.register é recusado quando a pessoa já tem uma skill ou comando com o mesmo nome. A recusa rejeita a promessa e, sem tratamento, derruba o hook session.start inteiro: os registros que viriam depois não acontecem.

O registro do recibo-sessao (tópico 1) não tem essa proteção: ele só registra um comando, mas com uma skill /recibo instalada o hook falharia. A correção é pequena: cada registro com o seu próprio .catch, e nomes pouco comuns.

✓ Nome que sobrevive

  • ✓ Prefixo seu: /rafa-ping, /rafa-palavras
  • ✓ Um .catch por registro, com linha no debug log
  • ✓ Conferir $.command.list() se quiser escolher outro nome

✗ Nome que colide

  • ✗ Palavras comuns: /status, /clima, /deploy
  • ✗ Nome de comando embutido: sempre recusado
  • ✗ Vários await de registro sem .catch no mesmo hook
🎯 Objetivo: dois comandos e uma ferramenta que não derrubam um ao outro

Crie a pasta meus-comandos/ com estes três arquivos (o caminho está no comentário):

// meus-comandos/.claude-plugin/plugin.json
{
  "name": "meus-comandos",
  "version": "0.1.0",
  "description": "Comandos e uma ferramenta do Rafa, sem gastar modelo",
  "author": { "name": "Rafa" },
  "userConfig": {
    "saudacao": {
      "type": "string",
      "title": "Saudação",
      "description": "Primeira palavra da resposta do /rafa-ping",
      "default": "Oi",
      "options": ["Oi", "Olá", "E aí"]
    }
  }
}

// meus-comandos/hooks/hooks.json
{ "modules": ["./register.tsx"] }

// meus-comandos/hooks/register.tsx
import type { Register } from 'claude-code'

export const register: Register = (on, options) => {
  const saudacao = String(options.saudacao ?? 'Oi')

  on('session.start', async ($, e, next) => {
    await $.command
      .register({ name: 'rafa-ping', description: 'Responde sem chamar o modelo', argumentHint: '[texto]' })
      .catch(err => $.ui.log(`rafa-ping não registrado: ${String(err)}`, { to: 'debug' }))
    await $.command
      .register({ name: 'rafa-palavras', description: 'Conta as palavras do texto dado' })
      .catch(err => $.ui.log(`rafa-palavras não registrado: ${String(err)}`, { to: 'debug' }))
    await $.tool
      .register({
        name: 'contar_linhas',
        description: 'Conta as linhas de um texto. Use só quando o usuário pedir.',
        inputSchema: { type: 'object', properties: { texto: { type: 'string' } }, required: ['texto'] },
      })
      .catch(err => $.ui.log(`contar_linhas não registrada: ${String(err)}`, { to: 'debug' }))
    return next(e)
  })

  on('command.run', { command: 'rafa-ping' }, async ($, e) => ({ text: `${saudacao}, Rafa. Você mandou: "${e.args}"` }))

  on('command.run', { command: 'rafa-palavras' }, async ($, e) => ({
    text: `${e.args.split(/\s+/).filter(Boolean).length} palavra(s)`,
    context: ['O usuário contou palavras com /rafa-palavras; não precisa comentar.'],
  }))

  on('tool.call', { tool: 'mcp__meus-comandos__contar_linhas' }, async ($, e) => ({
    result: String(e.texto).split('\n').length,
  }))

  on('ui.render', { component: 'CommandOutput', props: { command: 'rafa-ping' } }, async ($, e) => {
    const { Box, Text } = $.ui.resolve(e)
    return (
      <Box borderStyle="round" paddingX={1}>
        <Text color="magenta">{e.props.text}</Text>
      </Box>
    )
  })
}

Depois, na pasta que contém meus-comandos/:

claude plugin validate meus-comandos
claude -p "/rafa-palavras um dois três" --plugin-dir meus-comandos
Como verificar: rodado aqui (2.1.289): o validate lista calls: $.command.register, $.tool.register, $.ui.log, $.ui.resolve e termina em ✔ Validation passed; o claude -p "/rafa-palavras um dois três" --plugin-dir meus-comandos respondeu meus-comandos: 3 palavra(s). Se você tiver uma skill chamada rafa-ping, o /rafa-palavras continua funcionando.

⚠️ Um .catch por registro, não por hook

O .catch da registration do on(...) (módulo 2.2) responde pelo hook inteiro: é tarde demais para salvar o segundo comando. Aqui o .catch é o da promessa de cada register, para uma recusa não levar as outras junto.

Teste rápido (opcional): o mod do Rafa registra /status e depois /rafa-relatorio no mesmo session.start, sem .catch. Nenhum dos dois aparece. O que houve?

🎓 Resumo do módulo

✓
Comando = register + command.run — registrado no session.start.
✓
{ text, context } sem modelo — claude -p "/x" é o teste de fumaça.
✓
CommandOutput desenha a saída — o modelo segue lendo o text.
✓
Ferramenta = tool.register + tool.call — listada como mcp__plugin__nome.
✓
Um .catch por registro — nome repetido não derruba o resto.

Próximo módulo:

4.1 — Mods que mudam o fluxo