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.
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/helpmostram.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.
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)
})
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.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.
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.
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.
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.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 CommandOutput | O que é |
|---|---|
command | qual comando imprimiu a linha (sem barra); só leitura |
args | os argumentos, como o eco acima da linha mostra |
text | o texto da linha, em markdown |
isErrored | a linha é o erro de um comando que falhou |
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>
)
})
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.
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.
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.
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)) };
});
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
inputSchemacom sua própriadescription
✗ 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
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.
"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"]
}
}
export const register: Register = (on, options) => {
// ...
const abridor = String(options.abridor ?? 'xdg-open')
// ...
const r = await $.process.run([abridor, pasta]).catch(() => undefined)
Declare no plugin.json
type, title, description, default e, se for escolha, options.
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.
Para mod carregado por pasta
Os valores ficam nas configurações em pluginConfigs, com a chave do nome do plugin (ou <nome>@inline).
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.
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
.catchpor 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
awaitde registro sem.catchno mesmo hook
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
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
Próximo módulo:
4.1 — Mods que mudam o fluxo