Mais cedo ou mais tarde, quem trabalha algumas semanas com o Claude Code acaba com uma pequena coleção de hábitos: um prompt que cola antes de todo commit, uma regra que repete sobre quais arquivos nunca tocar, uma revisão que pede tantas vezes que já sabe de cor. Esses hábitos moram num arquivo de anotações, ou na cabeça de alguém, e param na borda da máquina daquela pessoa. Um plugin é o jeito de tirá-los de lá, porque empacota comandos, agentes e hooks numa pasta que qualquer pessoa instala com dois comandos, e assim o seu jeito de trabalhar vira algo que você entrega em vez de algo que você explica.

Este tutorial constrói um a partir de um diretório vazio. O plugin se chama commit-helper e, no final, vai lhe dar um comando /commit-helper:commit que escreve uma mensagem de commit decente, um agente que revisa o diff preparado antes de a mensagem ser escrita, e um hook que se recusa a deixar passar um commit quando o diff acrescenta algo com cara de senha ou de chave de API. Cada um desses é um tipo diferente de componente de plugin, e é justamente essa a ideia: depois de construir um de cada, você sabe como todo plugin é montado. No caminho vou apontar para o foreman, um plugin que publiquei recentemente, só para mostrar como a mesma peça fica quando um plugin cresce.

Você precisa do Claude Code (isto foi verificado na versão 2.1.312), do git e do Python 3. Tudo abaixo foi testado no Linux; deve funcionar como está no macOS, e no Windows eu rodaria dentro do WSL.

O que é um plugin, no disco

Um plugin é um diretório, e o Claude Code encontra as partes dele pelo lugar em que estão. Nada é registrado em lugar nenhum e não há etapa de build, então a pasta que você está prestes a criar é a coisa inteira:

commit-helper/
├── .claude-plugin/
│   ├── plugin.json
│   └── marketplace.json
├── skills/
│   └── commit/
│       └── SKILL.md
├── agents/
│   └── diff-reviewer.md
└── hooks/
    ├── hooks.json
    └── secret_guard.py

A pasta .claude-plugin/ guarda metadados e mais nada, e essa é a primeira regra em que as pessoas tropeçam: skills, agentes e hooks ficam na raiz do plugin, ao lado dela, nunca dentro. Crie o esqueleto agora e vamos preenchê-lo um arquivo de cada vez.

mkdir -p commit-helper/{.claude-plugin,skills/commit,agents,hooks}
cd commit-helper

Passo 1: o manifesto

O manifesto diz ao Claude Code como o plugin se chama, e o nome importa mais do que parece, porque todo componente fica sob ele. É por isso que o comando que você vai escrever daqui a pouco roda como /commit-helper:commit e nunca colide com o commit de outra pessoa. Salve isto como .claude-plugin/plugin.json:

{
  "name": "commit-helper",
  "version": "0.1.0",
  "description": "Writes your commit messages, reviews the diff first, and refuses to commit secrets.",
  "author": {
    "name": "Your Name"
  },
  "license": "MIT",
  "keywords": ["git", "commit", "review"]
}

A rigor, só name é obrigatório, e o Claude Code carregaria o plugin até sem manifesto, tirando o nome da pasta; mas vale definir version desde o primeiro dia, já que é ela que mantém quem instala o seu plugin numa versão conhecida até você publicar a próxima de propósito. Use kebab-case e evite nomes começando com claude-, que são reservados e recusados pelo validador. Você pode conferir o trabalho a qualquer momento pelo terminal:

claude plugin validate .

Neste plugin a resposta é Validation passed with warnings, e o único aviso é a falta de ícone, que só importa se você listar o plugin no diretório da Anthropic. O manifesto do foreman é o mesmo arquivo com a URL do repositório e uma descrição mais longa; manifestos raramente ficam muito maiores que isso.

Passo 2: o comando

Um comando de barra num plugin é uma skill: uma pasta dentro de skills/ com um arquivo SKILL.md cujo frontmatter a descreve e cujo corpo é a instrução que o Claude segue quando ela roda. Existe uma pasta commands/, mais antiga, que ainda funciona, mas a documentação hoje recomenda skills para plugins novos, então é o que vamos usar. Salve isto como skills/commit/SKILL.md:

---
name: commit
description: Write a commit message for the staged changes, after a diff review, and commit when the user confirms.
disable-model-invocation: true
---

Commit the user's staged changes with a good message. Work in this order.

1. Run `git diff --cached --stat`. If nothing is staged, say so and stop; do not stage
   files on your own.
2. Run `git diff --cached`, dispatch the `diff-reviewer` agent with that diff in the
   prompt, and wait for its report. If the verdict is `concerns`, show the list to the
   user and ask whether to go on.
3. Write the message in Conventional Commits form: a subject line under 72 characters
   (`feat:`, `fix:`, `docs:`, `refactor:`, `test:` or `chore:`), a blank line, then a short
   body that explains why the change was made, not what each line does.
4. Show the message and ask the user to confirm or edit it.
5. Only after they confirm, run `git commit` with that message. If a hook blocks the
   commit, show its reason and stop.

Dois detalhes desse arquivo fazem a maior parte do trabalho. disable-model-invocation: true significa que a skill só roda quando você a digita; sem essa linha, o Claude poderia decidir sozinho que agora é um bom momento para commitar, que é exatamente o que você não quer de um comando que escreve histórico. A description é o que você vê ao lado do comando no menu /, e, numa skill que o Claude pode invocar por conta própria, é também o que ele compara com o pedido, então merece o mesmo cuidado que o corpo. O corpo em si são instruções simples, escritas para um colega capaz, numeradas porque aqui a ordem importa, e já menciona o agente e o hook que ainda não escrevemos, porque é assim que as três peças vão se encontrar.

Passo 3: o agente

Um agente é um especialista a quem o Claude pode passar uma tarefa. Ele roda no próprio contexto, com as próprias instruções e só as ferramentas que você der, e devolve um relatório quando termina. Esse isolamento é o que o torna útil como revisor: ele olha para o diff sem a conversa que o produziu, então ainda não decidiu que a mudança está boa. Salve isto como agents/diff-reviewer.md:

---
name: diff-reviewer
description: Reviews a staged git diff, passed in by the caller, before a commit and reports problems. Reports only; has no tool that can change files.
tools: Read, Grep, Glob
model: sonnet
---

You review a staged diff before it is committed. The diff is in the message that
dispatched you; open the surrounding files when a change needs context.

Look for things a reviewer would stop a commit for:

- debugging leftovers: print statements, commented-out code, `TODO` added in this diff
- a change that does something different from what its neighbours suggest
- files that look unrelated to the rest of the change and probably belong in another commit
- anything that looks like a credential or a private key

Report in this shape, and nothing else:

```
VERDICT: ok | concerns
- <file>:<line> — <what you saw and why it matters>
```

Write `VERDICT: ok` with no list when you find nothing. Your job ends at the report.

É no frontmatter que você torna o trabalho do agente seguro. tools concede só Read, Grep e Glob, e é por isso que a skill entrega o diff a ele em vez de deixá-lo rodar o git: sem Bash, Edit ou Write, não há ferramenta ao alcance que mude um arquivo, então até um revisor que entenda mal as instruções só consegue relatar. model: sonnet entrega uma revisão de rotina a um modelo que dá conta dela sem gastar o seu modelo mais caro nisso. O formato fixo do relatório existe por causa da skill, que precisa distinguir ok de concerns sem interpretar prosa. No foreman os agentes são o mesmo tipo de arquivo, só que mais longos, porque cada um carrega um papel num time (um explorador que só lê, um revisor que ataca uma especificação antes de o trabalho começar), e é no frontmatter que esses limites também são impostos.

Passo 4: o hook

Skills e agentes são instruções, e uma instrução pode ser ignorada ou esquecida. Um hook nem chega a ser enviado ao modelo: é um programa que o próprio Claude Code roda num ponto fixo do seu ciclo, então o modelo não tem como convencê-lo. Isso faz dele o lugar certo para a única regra deste plugin que não pode ceder, com uma ressalva honesta de que a regra só é tão boa quanto o script: o nosso reconhece um punhado de padrões, o que faz dele uma rede de segurança para o deslize comum e não um scanner de segredos, e um time que precise de cobertura de verdade deve rodar também uma ferramenta dedicada, como o gitleaks. Vamos usar um hook PreToolUse na ferramenta Bash, que roda antes de todo comando de shell que o Claude está prestes a executar, recebe o comando como JSON na entrada padrão e o bloqueia saindo com código 2. Salve o registro como hooks/hooks.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python3 \"${CLAUDE_PLUGIN_ROOT}\"/hooks/secret_guard.py"
          }
        ]
      }
    ]
  }
}

A chave "hooks" de fora parece redundante, mas o arquivo de hooks de um plugin precisa dela, e sem ela o arquivo não carrega. ${CLAUDE_PLUGIN_ROOT} é trocado pelo lugar onde o plugin acabar instalado, e as aspas em volta mantêm inteiro um caminho com espaços. O script em si é curto o bastante para ler de uma vez; salve como hooks/secret_guard.py:

#!/usr/bin/env python3
"""PreToolUse hook: block a git commit whose diff adds something that looks like a secret.

Claude Code sends the tool call as JSON on stdin. Exit 0 lets it through;
exit 2 blocks it, and whatever we print on stderr is shown to Claude.
"""
from __future__ import annotations

import json
import os
import re
import shlex
import subprocess
import sys

PATTERNS = [
    re.compile(r"AKIA[0-9A-Z]{16}"),                      # AWS access key
    re.compile(r"gh[pousr]_[A-Za-z0-9]{36}"),             # GitHub token
    re.compile(r"sk-ant-[A-Za-z0-9_-]{20,}"),             # Anthropic API key
    re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),    # PEM private key
    re.compile(r"(password|secret|token|api_key)\s*[:=]\s*['\"][^'\"]{8,}['\"]", re.I),
]

GLOBAL_OPTIONS_WITH_VALUE = {"-C", "-c", "--git-dir", "--work-tree", "--namespace"}


def find_commit(command: str) -> tuple[str | None, bool] | None:
    """Find a `git commit` in the command; return (directory from -C, commits unstaged too)."""
    for segment in re.split(r"&&|\|\||[;|\n]", command):
        try:
            words = shlex.split(segment)
        except ValueError:
            continue
        while words and os.path.basename(words[0]) != "git":
            words = words[1:]
        directory, i = None, 1
        while i < len(words) and words[i].startswith("-"):  # git's own options, before the subcommand
            if words[i] == "-C" and i + 1 < len(words):
                directory = words[i + 1]
            i += 2 if words[i] in GLOBAL_OPTIONS_WITH_VALUE else 1
        if i < len(words) and words[i] == "commit":
            args = words[i + 1:]
            every_tracked_file = any(a == "--all" or re.fullmatch(r"-[a-zA-Z]*a[a-zA-Z]*", a) for a in args)
            return directory, every_tracked_file
    return None


def find_secrets(diff: str) -> list[str]:
    added = (line[1:].strip() for line in diff.splitlines()
             if line.startswith("+") and not line.startswith("+++"))
    return [line for line in added if any(p.search(line) for p in PATTERNS)]


def git_diff(cwd: str, *args: str) -> str:
    try:
        result = subprocess.run(["git", "--no-pager", "diff", "--no-color", "--no-ext-diff", *args],
                                cwd=cwd, capture_output=True, text=True)
    except OSError:  # git missing, or the directory doesn't exist
        return ""
    return result.stdout if result.returncode == 0 else ""


def main(stdin_text: str) -> int:
    try:
        event = json.loads(stdin_text)
        commit = find_commit(event["tool_input"]["command"])
    except (ValueError, KeyError, TypeError, AttributeError):
        return 0  # not something we understand: never get in the way
    if commit is None:
        return 0
    directory, every_tracked_file = commit
    cwd = os.path.join(event.get("cwd") or ".", directory or "")
    diff = git_diff(cwd, "--cached")
    if every_tracked_file:
        diff += git_diff(cwd)  # changes not staged yet, which `commit -a` will include
    hits = find_secrets(diff)
    if not hits:
        return 0
    print("commit-helper blocked this commit: the diff adds what looks like a secret:", file=sys.stderr)
    for line in hits:
        print(f"  {line[:80]}", file=sys.stderr)
    print("Remove it (or move it to an environment variable) and commit again.", file=sys.stderr)
    return 2


if __name__ == "__main__":
    sys.exit(main(sys.stdin.read()))

Quase tudo o que torna este script seguro para rodar antes de cada comando de shell está nas pequenas decisões. find_commit divide o comando em &&, ; e pipes e lê cada pedaço como um shell leria, então encontra o git commit no fim de uma cadeia git add . && … ou atrás de um git -C alguma/pasta, que o Claude escreve com frequência, enquanto um echo que só menciona commitar fica em paz. O diff é lido com --no-color, porque quem usa color.ui=always entregaria ao script linhas que começam com um código de escape em vez de um +, e, quando o commit usa -a, entram também as mudanças ainda não preparadas, já que elas também vão para o commit. Só contam as linhas que o diff acrescenta, então apagar uma chave vazada nunca é bloqueado, e sempre que o script encontra algo que não entende, como uma entrada malformada ou uma pasta que não é repositório, sai com 0 e sai da frente, já que um hook que trava todo comando é pior do que hook nenhum. Usa só a biblioteca padrão, e o import de __future__ deixa as anotações de tipo rodarem no Python 3.9 que vem no macOS. Como main recebe a entrada como string, a lógica é fácil de cobrir com testes comuns de pytest; a versão deste tutorial tem dezenove, e cada caso deste parágrafo é um deles.

Passo 5: instale e veja funcionar

O Claude Code instala plugins a partir de um marketplace, que soa mais grandioso do que é: um arquivo marketplace.json que lista um ou mais plugins e onde encontrá-los. Para instalar o seu próprio plugin, você cria um marketplace de um só, dentro da própria pasta do plugin. Salve isto como .claude-plugin/marketplace.json:

{
  "name": "commit-helper-dev",
  "owner": {
    "name": "Your Name"
  },
  "plugins": [
    {
      "name": "commit-helper",
      "source": "./",
      "description": "Writes your commit messages, reviews the diff first, and refuses to commit secrets."
    }
  ]
}

Dentro do Claude Code, adicione o marketplace com /plugin marketplace add seguido do caminho da pasta, depois abra /plugin e instale o commit-helper pela lista. Se preferir ficar no terminal, os mesmos dois passos são comandos, e o terceiro mostra o que foi instalado:

claude plugin marketplace add /path/to/commit-helper
claude plugin install commit-helper@commit-helper-dev
claude plugin details commit-helper

O último comando deve listar exatamente as três peças que você escreveu:

Component inventory
  Skills (1)  commit
  Agents (1)  diff-reviewer
  Hooks (1)  PreToolUse  (harness-only — no model context cost)

Agora tente quebrá-lo. Em qualquer repositório git, prepare um arquivo contendo AWS_KEY = 'AKIAABCDEFGHIJKLMNOP' (uma chave falsa no formato da AWS) e peça ao Claude para commitá-lo. Quando eu fiz isso, o Claude respondeu:

The commit didn't happen. A PreToolUse hook (`secret_guard.py` from the
`commit-helper` plugin) stopped `git commit -m 'add config'` before it could run.
The hook says the staged `config.py` adds what looks like an AWS access key ...

e o git log confirmou que nada tinha sido commitado. Depois troque a chave por os.environ["AWS_KEY"], prepare o arquivo de novo e digite /commit-helper:commit. A skill roda o revisor, recebe VERDICT: ok e volta com uma mensagem proposta (chore: add config module reading AWS key from environment, na minha execução) e uma pergunta, porque não commita até você confirmar. Enquanto você itera, não precisa reinstalar nada: um plugin adicionado a partir de uma pasta local é carregado no lugar, então uma edição vale assim que você roda /reload-plugins ou abre uma nova sessão, e claude --plugin-dir ./commit-helper deixa você testar uma versão por uma sessão sem instalá-la.

Passo 6: compartilhe

Tudo o que fez a instalação local funcionar também torna o plugin compartilhável, porque um marketplace mora num repositório git tão bem quanto numa pasta. Envie o diretório commit-helper para um repositório, e um colega o instala com /plugin marketplace add sua-org/commit-helper seguido da mesma instalação. Daí em diante, a version do manifesto é a sua válvula de lançamento: aumente-a quando quiser que as pessoas recebam o comportamento novo. O foreman é distribuído exatamente assim, com um marketplace.json na raiz do repositório no GitHub listando um único plugin cujo source é ./, então o arquivo que você acabou de escrever é o mesmo que um plugin publicado usa, só com outro nome dentro. (Se tiver curiosidade sobre por que o foreman existe, escrevi sobre isso em A Cultura que se Instala.)

Para onde ir daqui depende do que o seu time vive repetindo. Se é um procedimento, vira skill; se é um especialista a quem você vive explicando a tarefa, vira agente; e se é uma regra que precisa valer mesmo quando ninguém se lembra dela, vira hook. O plugin é só a pasta que leva tudo isso da sua máquina para a de todo mundo.

Se quiser o plugin pronto para comparar com o seu, ou para instalá-lo como está, ele está num gist. Um gist não guarda pastas, então os arquivos lá ficam soltos, e um setup.sh curto coloca cada um onde o Claude Code espera antes de instalar:

git clone https://gist.github.com/barrosef/170367c55782a6223f59b89cc7102342.git commit-helper-gist
sh commit-helper-gist/setup.sh