Copie o starter-mod
Não edite o modelo dentro do kit. Copie para uma pasta sua, fora do kit, e trabalhe na cópia. Assim o original continua servindo de comparação quando algo der errado.
O Rafa chama a cópia de meu-contador e a deixa ao lado do kit. Os comandos deste módulo partem da raiz do kit e apontam para ../meu-contador.
Na raiz do kit (claude-mods-starter-kit):
cp -r templates/starter-mod ../meu-contador
meu-contador, com .claude-plugin/plugin.json, hooks/hooks.json, hooks/starter.mjs e tests/starter.test.ts. É a mesma árvore do módulo 1.2.💡 A pasta de tipos aparece sozinha
O tsconfig.json do starter-mod estende .claude-plugin/types/tsconfig.json. Numa cópia recém-clonada essa pasta ainda não existe: o Claude Code a escreve na primeira carga por --plugin-dir. O seu editor só reconhece os tipos depois disso.
cópia inteira
original intacto
do módulo 1.2
após a 1ª carga
Leia o código linha por linha
No módulo 1.1 você viu as três chamadas a on. Agora leia o que cada linha faz e em que ordem. São dezesseis linhas, sem tipos e sem import.
Repare na variável count: ela mora dentro de register. Cada recarga do módulo cria uma nova, valendo zero. Para um exemplo de ensino, tudo bem; na trilha 2 você troca por estado de verdade.
// Minimal teaching example. Counter is scoped to this module instance.
export function register(on) {
let count = 0;
on("session.start", async ($, e, next) => {
const result = await next(e);
count = 0;
await $.command.register({ name: "readcount", description: "Show successful reads observed by this example" });
return result;
});
on("tool.call", { tool: "Read" }, async ($, e, next) => {
const result = await next(e);
if (!result.isError && result.deny === undefined) count += 1;
return result;
});
on("command.run", { command: "readcount" }, async () => ({ text: `Successful reads observed: ${count}` }));
}
Linhas 2–3: register e o contador
register(on) não usa options, porque o manifesto não declara userConfig. count começa em zero.
Linhas 4–9: session.start
Primeiro deixa a sessão começar (await next(e)), depois zera o contador e registra o comando com $.command.register. Devolve o resultado do next sem mexer.
Linhas 10–14: tool.call só para Read
Deixa a leitura acontecer, olha o resultado e conta só se não deu erro (isError) nem foi negada (deny).
Linha 15: command.run responde
Não chama next: devolve { text } e o comando termina ali. É o que permite responder sem o modelo.
Como ler o desenho: a decisão de contar fica depois do next, porque antes dele o mod ainda não sabe como a leitura terminou. O hook só observa: não muda o resultado de ninguém.
Valide e teste
Dois comandos, antes de abrir qualquer sessão. O validate confere a forma. O test roda o arquivo tests/starter.test.ts contra o engine de verdade, sem modelo.
No teste, quem está "embaixo" do mod são hooks do próprio teste: eles fazem o papel do Claude Code. Um deles responde a tool.call com erro quando o caminho é /missing. Assim dá para provar que a leitura falha não conta.
import { test, expect } from "claude-code/testing";
test("counts successful reads and excludes failed ones", async ($, on) => {
on("session.start", ($, e) => ({ cwd: e.cwd }));
on("command.register", ($, e) => ({ value: { command: e.name } }));
on("tool.call", ($, e) => e.file_path === "/missing" ? { isError: true, result: "missing", text: "missing" } : { result: {}, text: "ok" });
await $.session.start({ surface: "terminal", isInteractive: true, cwd: "/work" } as any);
await $.tool.call({ tool: "Read", file_path: "/file" } as any);
await $.tool.call({ tool: "Read", file_path: "/missing" } as any);
const r = await $.command.run({ command: "readcount", args: "", origin: { kind: "composer" }, presentation: { isFullscreen: false, columns: 100 } } as any);
expect(r.text).toBe("Successful reads observed: 1");
});
on são o "mundo falso" (o teste ganha o seu próprio on). Depois vêm duas leituras, uma boa e uma ruim, e a conta esperada é 1.Na raiz do kit, primeiro no original:
claude plugin test templates/starter-mod
Saída real no Claude Code 2.1.289:
tests/starter.test.ts: (pass) counts successful reads and excludes failed ones [20.64ms] 1 pass 0 fail Ran 1 test across 1 file. [0.13s]
Depois, na sua cópia:
claude plugin validate ../meu-contador claude plugin test ../meu-contador
validate termina em ✔ Validation passed (a saída completa está no módulo 1.2) e o test mostra 1 pass e 0 fail. Os tempos entre colchetes mudam a cada execução.🆕 Novo aqui? claude-code/testing
É o pacote de teste que vem com o Claude Code: test, expect e mock. O corpo de cada test recebe o $ do engine e um on cujos hooks ficam embaixo do mod. $.tool.call e $.command.run disparam os eventos como uma sessão faria. A trilha 4 aprofunda.
Rode o comando sem gastar modelo
O claude -p roda uma vez e sai. Com um comando de mod como pedido, ele carrega o mod do zero, a sessão começa, o comando responde e pronto. O modelo não é chamado.
É o teste de fumaça mais barato que existe: prova que o módulo carrega numa sessão real e que o comando foi registrado. O kit INEMA usa o mesmo truque com o /recibo.
Na raiz do kit:
claude -p "/readcount" --plugin-dir templates/starter-mod
Saída real no Claude Code 2.1.289:
read-counter-example: Successful reads observed: 0
read-counter-example:, o name do plugin.json, e termina em 0. Se o mod não carregar, o claude -p avisa uma vez no stderr, com o motivo.Como ler o desenho: a linha de cima é tudo o que acontece. A caixa riscada é o caminho que um pedido normal faria. Para ver um número maior que zero, abra uma sessão interativa com claude --plugin-dir, peça ao Claude para ler um arquivo e rode /readcount.
✓ O claude -p prova
- ✓ o módulo carrega numa sessão de verdade
- ✓ o comando foi registrado
- ✓ o
command.runresponde sem modelo
✗ Não prova
- ✗ que o contador soma (isso é o teste)
- ✗ nada sobre a tela: não há tela
- ✗ o comportamento depois de uma recarga
Mude o contador para outra ferramenta
O Rafa não quer saber quantos arquivos o Claude leu. Quer saber quantas edições deram certo. A mudança é no matcher: { tool: "Read" } vira { tool: "Edit" }.
O nome da ferramenta vem da tabela de ferramentas desta build, em .claude-plugin/types/claude-code-tools/index.d.ts. Lá está o Edit, com o campo file_path. Mude o código e o teste juntos: o teste antigo ainda chama Read e passa a falhar.
Em meu-contador/hooks/starter.mjs, troque o matcher e o texto (as outras linhas ficam iguais):
on("tool.call", { tool: "Edit" }, async ($, e, next) => {
const result = await next(e);
if (!result.isError && result.deny === undefined) count += 1;
return result;
});
on("command.run", { command: "readcount" }, async () => ({ text: `Successful edits observed: ${count}` }));
Em meu-contador/tests/starter.test.ts, troque as duas chamadas e o texto esperado:
await $.tool.call({ tool: "Edit", file_path: "/file" } as any);
await $.tool.call({ tool: "Edit", file_path: "/missing" } as any);
// ...
expect(r.text).toBe("Successful edits observed: 1");
claude plugin validate ../meu-contador claude plugin test ../meu-contador
hooks: do validate mostra tool.call{tool=Edit} e termina em ✔ Validation passed; o test volta a 1 pass e 0 fail. O hook falso de tool.call do teste olha só o file_path, então não precisa mudar.⚠️ Confira o nome da ferramenta letra por letra
O validate mostra o matcher que você escreveu, não o que você quis escrever. Um { tool: "edit" }, em minúscula, não casa com a ferramenta Edit, e o contador fica parado. Copie o nome da tabela de ferramentas, não da memória.
✓ Mudança completa
- ✓ matcher
{ tool: "Edit" } - ✓ texto da resposta fala em edições
- ✓ teste chama
Edite espera o texto novo
✗ Mudança pela metade
- ✗ trocar o código e esquecer o teste:
1 fail - ✗ manter "reads" na resposta de um contador de edições
- ✗ inventar o nome da ferramenta
Renomeie para virar seu
O README do starter-mod pede: renomeie o plugin e o comando antes de transformar o exemplo em outra coisa. São dois nomes, em quatro lugares.
O Rafa escolhe contador-rafa para o plugin e rafa-edicoes para o comando. Um nome de comando pouco comum não é capricho, como mostra o alerta abaixo.
| Arquivo | De | Para |
|---|---|---|
.claude-plugin/plugin.json (name) | read-counter-example | contador-rafa |
hooks/starter.mjs ($.command.register) | name: "readcount" | name: "rafa-edicoes" |
hooks/starter.mjs (matcher do command.run) | { command: "readcount" } | { command: "rafa-edicoes" } |
tests/starter.test.ts ($.command.run) | command: "readcount" | command: "rafa-edicoes" |
O que olhar na tabela: o nome do comando aparece três vezes. Esquecer o matcher do command.run deixa o comando registrado e mudo. Troque também a description do plugin.json e do comando.
⚠️ Colisão com uma skill derruba o session.start
Visto ao vivo e registrado em inema-mods/docs/COMO-FAZER-UM-MOD.md: se você já tem uma skill ou comando com o mesmo nome, $.command.register é recusado, e o erro derruba o hook session.start inteiro. Escolha nomes pouco comuns e dê a cada registro o seu próprio .catch:
await $.command.register({ name: "rafa-edicoes", description: "Mostra quantas edições deram certo" })
.catch(() => {});
Na raiz do kit, depois das trocas:
claude plugin test ../meu-contador claude -p "/rafa-edicoes" --plugin-dir ../meu-contador
1 pass / 0 fail e o claude -p responde contador-rafa: Successful edits observed: 0. Se aparecer o aviso no stdin data received in 3s, acrescente < /dev/null ao fim do comando.Teste rápido (opcional): o Rafa rodou claude -p "/readcount" --plugin-dir templates/starter-mod logo depois de ler dez arquivos numa sessão interativa. Saiu 0. Por quê?
🎓 Resumo do módulo
Próxima trilha:
Trilha 2 — Eventos e estado (2.1 — O mapa de eventos)