backstay
v0.1.2
Published
Instala o Backstay num repositório: traduz hooks do Claude Code em eventos, em menos de 3 minutos, sem conta no modo --local.
Readme
backstay
Traduz os hooks do Claude Code em eventos, para você saber o que seus agentes fizeram.
Do comando ao primeiro evento visível: menos de 3 minutos. No modo local, que é o padrão, sem conta nenhuma.
npx backstay initSem flag nenhuma ele instala em modo local e diz que escolheu isso por você, o que implica e como mudar. Um padrão silencioso seria o defeito que este programa existe para acusar.
Versão pré-1.0: a interface pode mudar entre versões 0.x.
Ainda não há repositório público nem rastreador de issues. O desenvolvimento acontece num repositório privado, e este pacote é um recorte dele. O código-fonte vai inteiro aqui dentro, sob Apache-2.0:
npm pack backstaye você lê tudo o que roda na sua máquina — são 13 arquivos em texto puro, sem passo de build e sem dependência.Dizemos isso em vez de deixar você procurar um link de "Repository" que não existe.
🔒 O que este programa NUNCA lê e NUNCA envia
Esta é a primeira seção do README de propósito: é a pergunta que um engenheiro de plataforma faz antes de qualquer outra, e ela merece uma resposta verificável, não uma promessa.
Nunca lidos, nunca enviados:
ANTHROPIC_API_KEY,ANTHROPIC_AUTH_TOKEN- credenciais de cloud —
AWS_*,GOOGLE_APPLICATION_CREDENTIALS,AZURE_OPENAI_API_KEY, … - conteúdo de qualquer arquivo do seu projeto
- o texto das mensagens do modelo (vai o tamanho em caracteres, não o texto)
- a URL completa do seu gateway ou do seu git remote (vai só o hostname)
O que é enviado: metadado de evento — tipo (SessionStart, Stop, …), id de sessão, diretório,
os dois carimbos de tempo, e um payload recortado por evento.
Como conferir em vez de acreditar
- Leia o programa inteiro. Depois de instalar,
.backstay/backstay-hook.mjsé o arquivo que roda a cada evento. Ele não tem dependência nenhuma e cabe numa tela e meia. É uma cópia verbatim desrc/hook-runtime.mjs. - A superfície da coleta é uma função só.
recortarPayload(emhook-runtime.mjs) tem umswitchpor tipo de evento, e odefaultdevolve{}— evento desconhecido emite metadado vazio em vez de repassar o payload bruto. O que não está escrito ali não sai da sua máquina. process.envé lido em exatamente um lugar no runtime do hook, e é uma flag nossa.- A regra é testada, não declarada.
tests/prova-adaptador.mjsmonta um ambiente com todas as variáveis sensíveis preenchidas com valores-sentinela e afirma que nenhum deles aparece na saída — incluindo uma URL de gateway com senha e chave embutidas. E, para o verde valer, há uma perturbação deliberada: um metadado propositalmente vazado, que o mesmo critério tem de acusar. Guarda que nunca reprovou não é guarda.
Por que isso importa para você: é o que permite instalar isto numa empresa regulada sem abrir uma exceção de segurança. Não é preferência de design — é requisito.
O que o init faz, em ordem
- Detecta o ambiente — Claude Code presente,
settings.jsonde projeto ou de usuário, git remote, e o provider (Bedrock, Vertex, Foundry, gateway, ou direto). - Declara o modo de instalação e por quê — veja "Os hooks valem para quem clonar", abaixo.
- Mostra o diff literal do que vai escrever, e pergunta uma vez. Nunca escreve calado.
- Escreve preservando os seus hooks. Handler alheio nunca é tocado, e grupo com
matcherque não é nosso nunca é usado. - Emite um evento de teste e confirma que ele chegou onde deveria — no buffer (modo local) ou confirmado pelo hub com 2xx (modo hospedado). No hospedado, evento que cai no buffer é falha de entrega e sai com exit ≠ 0: o buffer ali é a rede de segurança, não o destino.
- Imprime a URL com o evento já lá.
npx backstay eject desfaz tudo — e prova por diff que o settings.json voltou ao estado
anterior, inclusive quando você já tinha hooks antes. Se o .claude/settings.json não existia
antes de nós e ficou vazio, ele é apagado em vez de ficar como um {} para você remover à mão;
a pasta sai junto se ficou vazia. O que já era seu nunca é tocado. O buffer fica: apagá-lo não é
desinstalar, é perder dado.
Onde ele escreve — e o que ele NUNCA faz sem você pedir
No .claude/settings.json da pasta em que você o rodou. Se ali houver um repositório git, na
raiz dele; se não houver, na própria pasta — o Claude Code lê o .claude/ do diretório em que é
iniciado, com ou sem git, e é isso que o init usa.
🔒 O settings.json de usuário — o global, que vale para TODOS os seus projetos — só é tocado
quando você digita --usuario. Não achar um repositório git nunca é motivo para escrever no
global: escopo global é decisão sua, e decisão sua a gente pede, não assume. E se você rodar o
init dentro da sua própria pasta de usuário, onde os dois escopos são o mesmo arquivo, ele
recusa e explica em vez de escolher por você — inclusive com --sim.
Os hooks valem para quem clonar — e agora isso é verdade
No settings.json de projeto, o caminho do hook é relativo (.backstay/backstay-hook.mjs),
com / mesmo no Windows. Esse arquivo é versionado, então um caminho absoluto não sobreviveria ao
git clone: seu colega receberia um hook apontando para um diretório que não existe na máquina
dele, e nada avisaria. (No settings.json de usuário o caminho é absoluto de propósito —
ele vale para todos os seus projetos, e um relativo ali apontaria para o repositório aberto no
momento.)
O init diz qual modo detectou, e a declaração não é cortesia:
| modo | quando | o que ele declara |
|---|---|---|
| node | há package.json na raiz | que não declara dependência nossa no seu package.json — o hook é autocontido e funciona sem node_modules |
| poliglota | não há package.json | que o hook é um programa Node, e portanto node precisa estar no PATH |
A segunda linha existe porque assumir Node em silêncio num repositório Java, Python ou Go é a forma de descobrir o requisito depois que ele já falhou.
⚠️ O caminho relativo só resolve se o arquivo viajar junto. Se o seu .gitignore ignora o
.backstay/, o init avisa 🔴 — e o doctor, na máquina de quem clonou, acusa.
Já tem hooks de telemetria nos mesmos eventos?
O init avisa antes de escrever, dizendo quais eventos, quantos emissores e qual é o comando de
cada um. Por padrão ele acrescenta e convive — é a promessa "só adiciono", e é ela que torna
este instalador aceitável na sua máquina.
Se você preferir que ele substitua o caminho antigo em vez de conviver:
npx backstay init --local --substituirÉ a única coisa que este programa faz além de acrescentar, e por isso é opt-in:
- antes de você usar a flag, o
initjá lista nominalmente o que ela removeria — convite sem a consequência declarada é o mesmo silêncio, com um passo de confirmação em cima. Odoctormostra a mesma lista a qualquer momento; - cada remoção aparece como linha
-no diff, antes de você confirmar; - gate nunca é tocado — nem se algo pedir. Hook de política é o que menos pode sumir em silêncio;
- hook alheio que não emite telemetria (um formatador, um linter) também não é tocado;
ejectdevolve cada um deles à posição original, e continua provando diff vazio.
A detecção do que é telemetria é heurística sobre o texto do seu comando — provável, não certa. Confira o diff. Se algo ali não for telemetria, cancele.
backstay doctor — o controle está ativo AGORA?
npx backstay doctorA pergunta não é "foi instalado?" — isso o init respondeu uma vez, no passado. É sobre o
presente, e ela existe porque o modo de falha que mais dói não é "não funciona": é "parou de
proteger e ninguém avisou". Um controle que nunca funcionou é consertado na terça. Um controle que
parou, continuando a aparecer no seu settings.json e no seu diagrama de arquitetura, é
descoberto durante uma auditoria.
Numa tela, o doctor responde:
- quais hooks estão declarados — e quais são nossos, quais já estavam lá;
- 🔴 algum hook aponta para um arquivo que NÃO EXISTE nesta máquina? — ele está declarado e não
roda: nada falha, nada reclama. É o caso mais puro de "parou de proteger e ninguém avisou", e o
vetor mais comum dele é o
git clone(veja abaixo); - 🟡 algum caminho é ABSOLUTO num arquivo VERSIONADO? — acusa mesmo quando o arquivo existe aqui, porque é aqui que ele existe: o defeito aparece na máquina de quem clonar;
- 🔴 algum evento tem MAIS DE UM EMISSOR? — o caso que ninguém vê, porque não gera erro nenhum;
- 🟡 o que
--substituirREMOVERIA — nominalmente, antes de você decidir usá-lo; - algum gate está declarado
async? — se estiver, ele não está barrando nada; - o buffer está drenando ou acumulando — buffer parado é entrega atrasada;
- 🟡 QUAL CÓDIGO O SEU HUB ESTÁ RODANDO — ele pergunta a identidade ao hub e a compara com o repositório: quantos commits atrás está, ou se o hub sequer sabe responder. Identidade declarada e nunca comparada não é controle, é decoração;
- 🟡 este adaptador é a última versão publicada? — adaptador velho diagnostica com as regras da época dele;
- onde ele procurou — as três camadas de
settings.json, sempre listadas, inclusive as que não existem, e se cada uma vai ou não para o git.
Quando ele não sabe, ele diz que não sabe. Caminho que não deu para conferir, git que não
respondeu, hub fora do ar, buffer sem modo declarado: nada disso vira achado — vira uma linha
dizendo que a conferência não aconteceu. Julgar por dúvida é o defeito que este comando existe
para combater; calar sobre a dúvida é o outro.
Ele só lê. Não escreve e não altera nada. Ele faz duas perguntas pela rede, e nenhuma
delas carrega qualquer coisa sua: "que código você está rodando?" ao seu próprio hub, e "qual é
a última versão deste pacote?" ao registro público do npm. Teto de 3 s cada, em paralelo; falha de
rede nunca vira acusação. --sem-rede desliga as duas — e o relatório diz que não conferiu,
para que a ausência de acusação não seja lida como "está em dia".
Ele distingue certeza de palpite, e diz qual é qual
| | O que é | Como ele sabe | |---|---|---| | nosso | CERTO | a marca no comando é dado nosso | | emissor preexistente | PROVÁVEL | heurística sobre o texto do seu comando | | convivendo | informativo | hook alheio sem sinal de telemetria — não é acusação |
O terceiro nível existe porque um diagnóstico que grita em todo repositório é ignorado em todos —
e aí o silêncio volta pela porta dos fundos. Um formatador declarado em Stop não duplica evento
nenhum, e apontá-lo seria inventar achado.
Exit ≠ 0 só para o que é certo (duplicação nossa, gate async). O que é heurística informa e
não reprova: um comando que falha CI por palpite é desligado, e um diagnóstico desligado não
diagnostica.
O desenho, e por que ele não é o óbvio
O init escreve handler type: "command" com async: true, e é o próprio comando que faz o
POST, com timeout curto, caindo para um .jsonl local quando o POST falha.
O caminho óbvio seria type: "http" apontando direto para o servidor. Ele foi medido e
eliminado, por um motivo que não é a latência:
O hook
httpé executado pelo próprio Claude Code. Se o servidor estiver fora do ar, ninguém escreve o buffer e o evento simplesmente some.
Com command, o pior caso é o evento ficar no disco esperando a drenagem — uma linha do tempo
atrasada, não uma linha do tempo furada. O drenador deixa de ser o caminho normal e vira caminho de
recuperação.
Dois fatos que economizam sua pesquisa, porque a documentação é fácil de ler errado:
asynceasyncRewakesão campos exclusivos do hookcommand. O hookhttpnão os aceita.- Não existe
asyncTimeout. O campo chama-setimeout.
⚠️ async serve a hooks de OBSERVAÇÃO, nunca a hooks de GATE
Um hook assíncrono não barra a chamada de ferramenta. Se você tem um hook de política em
PreToolUse — um gate, um aprovador, um bloqueador —, ele precisa continuar síncrono: declarado
async, ele deixa de decidir em silêncio e continua parecendo ativo.
O adaptador nunca escreve gate, e recusa a instalação se o resultado tivesse um gate declarado
async (gatesDeclaradosAsync, em src/config-logic.mjs).
Modo local — o padrão
npx backstay init # o mesmo que `init --local`Grava em .backstay/eventos.jsonl e sobe um painel local. Sem conta, sem token, sem rede. É o
caminho para ver se isto vale alguma coisa antes de decidir qualquer coisa.
O modo hospedado (--hub <url> --token <t>) exige credencial, porque é estado compartilhado — e a
recusa vem antes de qualquer escrita, dizendo isso. Hoje não há cadastro aberto no hub
hospedado: quem emite a credencial é quem opera o hub. Está escrito aqui em vez de existir uma
página de cadastro que não existe.
O painel sempre declara onde procurou — inclusive quando não encontrou nada. Instrumento que responde "nada encontrado" sem dizer onde olhou é indistinguível de instrumento que olhou no lugar errado.
Comandos
| | |
|---|---|
| backstay init | instala em modo local (o padrão), sem conta — e declara que escolheu |
| backstay init --hub <url> --token <t> | instala apontando para um hub; o token é obrigatório |
| backstay doctor | o controle está ativo agora? — só lê, e não toca a rede |
| backstay eject | remove, provado por diff, preservando os seus hooks |
| backstay drenar | varre todos os buffers do repositório e declara onde procurou |
| backstay painel | sobe só o painel local |
Se a sua tela mostrar caracteres estranhos
Num console Windows com code page legada (≠ 65001), o adaptador rebaixa a saída sozinho para
ascii — sem emoji e sem acento — e declara que fez isso na primeira linha. Nenhuma mensagem
deste programa depende de emoji para ser compreendida: emoji aqui é ênfase, jamais significado.
Para mandar no nível você mesmo:
BACKSTAY_SAIDA=completo # emoji e acento
BACKSTAY_SAIDA=sem-emoji # sem emoji, com acento
BACKSTAY_SAIDA=ascii # só ASCIIComuns: --sim (não pergunta — para CI), --usuario (settings do usuário), --porta <n>,
--sem-painel (não deixa o painel de pé). Só do init: --substituir (remove emissor
preexistente em vez de conviver — opt-in, visível no diff, reversível pelo eject).
--help imprime isto e sai, sem executar nada.
Requisitos
Node 18+. Roda em Windows, macOS, Linux e dentro de container de CI.
Licença: Apache-2.0.
