Pular para o conteúdo
MÓDULO 1.4

🚀 Seu primeiro mod: comando e contador

Hora de pôr a mão. Você copia o starter-mod, lê cada linha, valida, testa, roda o comando sem gastar modelo e depois transforma o contador de leituras num contador de edições com o seu nome.

6
Tópicos
~35
Minutos
Base
Nível
Prático
Tipo
0 de 60%
1

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.

🎯 Objetivo: ter uma cópia do starter-mod que é sua

Na raiz do kit (claude-mods-starter-kit):

cp -r templates/starter-mod ../meu-contador
Como verificar: ao lado da pasta do kit existe 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.

📋
cp -r

cópia inteira

📁
fora do kit

original intacto

🧬
mesma árvore

do módulo 1.2

🔤
tipos

após a 1ª carga

2

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.

📄 meu-contador/hooks/starter.mjs (cópia do starter-mod, inteiro)
// 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}` }));
}
1

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.

2

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.

3

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).

4

Linha 15: command.run responde

Não chama next: devolve { text } e o comando termina ali. É o que permite responder sem o modelo.

Read chegamatcher tool=Read await next(e)a leitura acontece resultisError? deny? deu certo: count += 1 falhou: não conta nos dois ramos o hook devolve o mesmo result que recebeu

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.

3

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.

📄 templates/starter-mod/tests/starter.test.ts (inteiro)
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");
});
O que olhar: as três primeiras chamadas a 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.
🎯 Objetivo: provar que o mod está inteiro antes de abrir sessão

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
Como verificar: o 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.

4

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.

🎯 Objetivo: ver o comando responder numa sessão real

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
Como verificar: a linha começa com 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.
carrega do zero session.startregistra /readcount command.runresponde { text } count = 0 turno → modelo → ferramenta Read sem turno não há Read; sem Read o contador fica em zero

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.run responde 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
5

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.

🎯 Objetivo: contar edições em vez de leituras

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
Como verificar: rodado aqui (2.1.289): a linha 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 Edit e 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
6

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.

ArquivoDePara
.claude-plugin/plugin.json (name)read-counter-examplecontador-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(() => {});
🎯 Objetivo: o mod renomeado respondendo

Na raiz do kit, depois das trocas:

claude plugin test ../meu-contador
claude -p "/rafa-edicoes" --plugin-dir ../meu-contador
Como verificar: rodado aqui (2.1.289): o teste segue em 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

✓
Copie, não edite o modelo — meu-contador fora do kit.
✓
Conte depois do next — só então o resultado existe.
✓
validate e test antes da sessão — engine de verdade, sem modelo.
✓
claude -p é fumaça barata — carrega do zero, por isso 0.
✓
Dois nomes, quatro lugares — e .catch em cada register.

Próxima trilha:

Trilha 2 — Eventos e estado (2.1 — O mapa de eventos)