novyr 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +18 -0
- package/README.md +54 -0
- package/bin/cli.js +145 -0
- package/lib/engine.js +53 -0
- package/lib/format-line.js +51 -0
- package/lib/resolve-binary.js +46 -0
- package/lib/setup.js +250 -0
- package/lib/targets.cjs +43 -0
- package/lib/telemetry.js +306 -0
- package/package.json +41 -0
- package/scripts/postinstall.js +34 -0
package/NOTICE
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
novyr
|
|
2
|
+
Copyright (c) Novyr
|
|
3
|
+
|
|
4
|
+
Este produto inclui e distribui um build MODIFICADO do engine "rtk" (rtk-ai/rtk),
|
|
5
|
+
licenciado sob a Apache License 2.0.
|
|
6
|
+
|
|
7
|
+
rtk — https://github.com/rtk-ai/rtk
|
|
8
|
+
Licença: Apache License 2.0 — https://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Modificações (NOTICE conforme Apache-2.0, seção 4):
|
|
11
|
+
O código do rtk é compilado a partir de uma versão pinada (ver RTK_VERSION no
|
|
12
|
+
workflow de release) com um patch de rebrand aplicado no build
|
|
13
|
+
(cli/scripts/rebrand-rtk.sh), que renomeia a identidade visual de "rtk" para
|
|
14
|
+
"nvr" (nome do binário, prefixo de reescrita de comandos, mensagens, arquivo de
|
|
15
|
+
awareness e diretório de config). O mecanismo de compressão de tokens é do rtk.
|
|
16
|
+
|
|
17
|
+
Cada pacote de plataforma (@novyr/nvr-<os>-<cpu>) contém esse binário; os termos
|
|
18
|
+
da Apache-2.0 (incluindo esta atribuição) se aplicam a ele.
|
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# novyr
|
|
2
|
+
|
|
3
|
+
**Corte 60–90% dos tokens do seu Claude Code.** O `novyr` instala o engine de
|
|
4
|
+
compressão (`nvr`) e mostra, em tempo real, quanto você economizou — sem API key,
|
|
5
|
+
usando a sua assinatura Claude Code (ou API, se preferir).
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g novyr
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Só isso. Em instalação global, o `novyr` já ativa tudo no Claude Code (hook de
|
|
12
|
+
compressão + statusLine de economia) num passo só. Recarregue o Claude Code e o
|
|
13
|
+
rodapé passa a mostrar:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
novyr ⚡ ~68% · 4.1M poupados
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## O que ele faz
|
|
20
|
+
|
|
21
|
+
O Claude Code gasta a maior parte dos tokens lendo saída de comando (`git status`,
|
|
22
|
+
`cargo test`, `grep`, `ls`…). O `novyr` intercepta esses comandos por um hook e
|
|
23
|
+
entrega a saída **comprimida** antes de chegar ao modelo — 60–90% menos tokens,
|
|
24
|
+
sem perder o que importa. E te dá a visão do que foi economizado.
|
|
25
|
+
|
|
26
|
+
- **Compressão** — engine `nvr` (Rust, <10ms) via hook `PreToolUse`.
|
|
27
|
+
- **Economia visível** — statusLine no Claude Code, atualizando enquanto você usa.
|
|
28
|
+
- **Sem API key** — funciona na sua assinatura Claude Code Pro/Max.
|
|
29
|
+
- **Model/harness-agnostic** — opera na camada de saída do shell.
|
|
30
|
+
|
|
31
|
+
## Comandos
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
novyr setup # (re)ativa o hook + statusLine no Claude Code
|
|
35
|
+
novyr line # a linha de economia (usada pela statusLine)
|
|
36
|
+
novyr stats # dashboard de economia (hoje / histórico / por comando)
|
|
37
|
+
novyr scan # oportunidades de economia ainda não capturadas
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Qualquer outro subcomando é repassado ao engine.
|
|
41
|
+
|
|
42
|
+
## Privacidade
|
|
43
|
+
|
|
44
|
+
A compressão é **local** — o `novyr` não envia seu código a lugar nenhum; ele só
|
|
45
|
+
encolhe a saída de comando antes de ela entrar no contexto do modelo que **você**
|
|
46
|
+
já usa.
|
|
47
|
+
|
|
48
|
+
## Sob o capô
|
|
49
|
+
|
|
50
|
+
O engine de compressão é o [**rtk**](https://github.com/rtk-ai/rtk) (Apache-2.0),
|
|
51
|
+
distribuído num build rebrandado para `nvr`. Veja `NOTICE` para a atribuição.
|
|
52
|
+
|
|
53
|
+
> A esteira de agentes autônoma (task → PR) é um produto à parte e **não** está
|
|
54
|
+
> neste pacote.
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// novyr — camada sobre o engine de compressão (rtk oficial; fork nvr na
|
|
4
|
+
// transição). Os nomes `novyr`, `nvr` e `rtk` apontam todos para este shim
|
|
5
|
+
// (ver "bin" no package.json), porque o hook do Claude Code chama o engine
|
|
6
|
+
// puro no PATH (`<engine> agent claude`) e a statusLine chama `nvr line`.
|
|
7
|
+
//
|
|
8
|
+
// Despacho:
|
|
9
|
+
// novyr line → lê o JSON do engine (gain/stats) e imprime a statusLine
|
|
10
|
+
// novyr setup → ativa hook + statusLine no Claude Code
|
|
11
|
+
// * → repassa pro binário do engine (inclui o hook `agent claude`)
|
|
12
|
+
|
|
13
|
+
const { runEngine, engineJsonFirst } = require("../lib/engine.js");
|
|
14
|
+
const { formatLine } = require("../lib/format-line.js");
|
|
15
|
+
|
|
16
|
+
const argv = process.argv.slice(2);
|
|
17
|
+
const sub = argv[0];
|
|
18
|
+
|
|
19
|
+
if (sub === "line") {
|
|
20
|
+
// Statusline: nunca pode "errar" — na dúvida, imprime o estado ocioso.
|
|
21
|
+
let json = null;
|
|
22
|
+
try {
|
|
23
|
+
json = engineJsonFirst([
|
|
24
|
+
["gain", "--format", "json"], // rtk
|
|
25
|
+
["stats", "--format", "json"], // fork nvr
|
|
26
|
+
]);
|
|
27
|
+
} catch {
|
|
28
|
+
json = null;
|
|
29
|
+
}
|
|
30
|
+
process.stdout.write(formatLine(json) + "\n");
|
|
31
|
+
// Telemetria opt-in: dispara um worker destacado (não trava a statusLine).
|
|
32
|
+
try {
|
|
33
|
+
require("../lib/telemetry.js").maybeSendInBackground();
|
|
34
|
+
} catch {
|
|
35
|
+
/* telemetria nunca quebra a statusLine */
|
|
36
|
+
}
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (sub === "telemetry") {
|
|
41
|
+
// Opt-in de telemetria agregada (privacy-clean). Ver lib/telemetry.js.
|
|
42
|
+
const tel = require("../lib/telemetry.js");
|
|
43
|
+
const action = argv[1];
|
|
44
|
+
if (action === "on") {
|
|
45
|
+
tel.enable();
|
|
46
|
+
console.log(
|
|
47
|
+
"novyr: telemetria LIGADA — só contadores agregados, nunca código.\n" +
|
|
48
|
+
` Endpoint: ${tel.endpoint()}\n` +
|
|
49
|
+
` Config: ${tel.configPath()}\n` +
|
|
50
|
+
" Desligue quando quiser: novyr telemetry off"
|
|
51
|
+
);
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
|
54
|
+
if (action === "off") {
|
|
55
|
+
tel.disable();
|
|
56
|
+
console.log("novyr: telemetria DESLIGADA.");
|
|
57
|
+
process.exit(0);
|
|
58
|
+
}
|
|
59
|
+
if (action === "status") {
|
|
60
|
+
console.log(JSON.stringify(tel.status(), null, 2));
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
63
|
+
if (action === "send") {
|
|
64
|
+
// Worker interno (disparado pela statusLine). Best-effort, silencioso.
|
|
65
|
+
tel.runSend().finally(() => process.exit(0));
|
|
66
|
+
} else {
|
|
67
|
+
console.error(
|
|
68
|
+
"uso: novyr telemetry <on|off|status>\n" +
|
|
69
|
+
" on compartilha contadores agregados de economia (opt-in)\n" +
|
|
70
|
+
" off para de compartilhar\n" +
|
|
71
|
+
" status mostra estado atual"
|
|
72
|
+
);
|
|
73
|
+
process.exit(action ? 1 : 0);
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (sub === "login") {
|
|
79
|
+
// Linka o CLI a uma conta cloud. Sem key, faz o device flow (abre o navegador
|
|
80
|
+
// pra autorizar); com key, linka direto. Salva a accountKey em
|
|
81
|
+
// ~/.config/novyr/telemetry.json — é o token que o launcher do pipeline usa
|
|
82
|
+
// pra falar com o gateway do modelo gerenciado.
|
|
83
|
+
const tel = require("../lib/telemetry.js");
|
|
84
|
+
tel
|
|
85
|
+
.login(argv[1])
|
|
86
|
+
.then((r) => {
|
|
87
|
+
console.log(
|
|
88
|
+
"novyr: conta linkada.\n" +
|
|
89
|
+
` Dashboard: ${r.dashboardUrl}\n` +
|
|
90
|
+
" Modelo gerenciado (Pro): selecione com `novyr provider glm`."
|
|
91
|
+
);
|
|
92
|
+
process.exit(0);
|
|
93
|
+
})
|
|
94
|
+
.catch((e) => {
|
|
95
|
+
console.error(`novyr: login falhou: ${e.message || e}`);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (sub === "logout") {
|
|
102
|
+
require("../lib/telemetry.js").logout();
|
|
103
|
+
console.log("novyr: conta deslinkada (para de enviar ao dashboard).");
|
|
104
|
+
process.exit(0);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (sub === "pipeline") {
|
|
108
|
+
// Namespace: este pacote é o companion (compressão + economia no Claude Code).
|
|
109
|
+
// A esteira de agentes (Caminho 2, sobre o opencode) é um produto à parte e
|
|
110
|
+
// não vai neste pacote — reservamos o nome pra não confundir com o engine.
|
|
111
|
+
console.error(
|
|
112
|
+
"novyr: a esteira de agentes (pipeline / Caminho 2, opencode) não está " +
|
|
113
|
+
"incluída neste pacote.\nEste pacote é o companion: compressão + economia " +
|
|
114
|
+
"de tokens no Claude Code (`novyr setup`, `novyr line`, `novyr stats`)."
|
|
115
|
+
);
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (sub === "setup") {
|
|
120
|
+
// require tardio: só carrega o setup quando necessário (mantém o hook leve).
|
|
121
|
+
const { runSetup } = require("../lib/setup.js");
|
|
122
|
+
try {
|
|
123
|
+
runSetup();
|
|
124
|
+
process.exit(0);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
console.error(`novyr: setup falhou: ${err.message || err}`);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Tudo o mais → engine (inclui o hook do Claude Code, que repassa stdin/stdout).
|
|
132
|
+
// `stats` é alias amigável pro `gain` do engine (mantém o README e o hábito).
|
|
133
|
+
const forwardArgs = sub === "stats" ? ["gain", ...argv.slice(1)] : argv;
|
|
134
|
+
let result;
|
|
135
|
+
try {
|
|
136
|
+
result = runEngine(forwardArgs);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
console.error(err.message || String(err));
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
if (result.error) {
|
|
142
|
+
console.error(`novyr: falha ao executar o engine: ${result.error.message}`);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
process.exit(result.status === null ? 1 : result.status);
|
package/lib/engine.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Wrapper de execução do engine (rtk oficial; durante a transição, o fork nvr).
|
|
3
|
+
// O shim resolve o binário empacotado e repassa; este módulo concentra as
|
|
4
|
+
// chamadas que o wrapper faz por conta própria (statusline, setup).
|
|
5
|
+
|
|
6
|
+
const { spawnSync } = require("node:child_process");
|
|
7
|
+
const { resolveBinary } = require("./resolve-binary.js");
|
|
8
|
+
|
|
9
|
+
// Executa o engine repassando stdio (uso geral / forward).
|
|
10
|
+
function runEngine(args, opts = {}) {
|
|
11
|
+
const bin = resolveBinary();
|
|
12
|
+
return spawnSync(bin, args, { stdio: "inherit", ...opts });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Executa candidatos de subcomando até um sair 0 com JSON válido no stdout.
|
|
16
|
+
// Cobre a divergência de nomes entre rtk (`gain`) e o fork (`stats`).
|
|
17
|
+
function engineJsonFirst(candidates) {
|
|
18
|
+
const bin = resolveBinary();
|
|
19
|
+
for (const args of candidates) {
|
|
20
|
+
const r = spawnSync(bin, args, { encoding: "utf8" });
|
|
21
|
+
if (r.status === 0 && r.stdout) {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(r.stdout);
|
|
24
|
+
} catch {
|
|
25
|
+
/* tenta o próximo candidato */
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Executa o primeiro candidato de argv que sair com sucesso (status 0/null).
|
|
33
|
+
// Cobre rtk (`init -g`) vs fork (`setup --global`). O stdout do candidato é
|
|
34
|
+
// repassado (a mensagem de sucesso aparece), mas o stderr das tentativas que
|
|
35
|
+
// falham é engolido — só vaza se TODOS falharem.
|
|
36
|
+
function runEngineFirst(candidates) {
|
|
37
|
+
const bin = resolveBinary();
|
|
38
|
+
let lastErr = "";
|
|
39
|
+
for (const args of candidates) {
|
|
40
|
+
const r = spawnSync(bin, args, {
|
|
41
|
+
stdio: ["inherit", "inherit", "pipe"],
|
|
42
|
+
encoding: "utf8",
|
|
43
|
+
});
|
|
44
|
+
if (!r.error && (r.status === 0 || r.status === null)) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
lastErr = (r.stderr || "").toString();
|
|
48
|
+
}
|
|
49
|
+
if (lastErr) process.stderr.write(lastErr);
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { runEngine, engineJsonFirst, runEngineFirst, resolveBinary };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Formata a linha de economia da statusLine a partir do JSON do `gain`/`stats`
|
|
3
|
+
// do engine. Texto neutro e honesto: "~" porque a contagem de tokens é
|
|
4
|
+
// estimativa (heurística de chars), e a economia em R$ é uma *equivalência de
|
|
5
|
+
// custo de API* — vale como ordem de grandeza, não como fatura.
|
|
6
|
+
//
|
|
7
|
+
// IMPORTANTE (honestidade da claim): o valor em R$ é "quanto custaria, em API,
|
|
8
|
+
// os tokens que o novyr evitou de mandar pro modelo". Quem está em assinatura
|
|
9
|
+
// flat (ex.: Claude Pro) não paga isso diretamente; e prompt caching do provider
|
|
10
|
+
// reduz o custo real de tokens repetidos. Por isso o "~" e o rótulo "equiv.".
|
|
11
|
+
|
|
12
|
+
// Preço padrão por milhão de tokens economizados, em BRL. Os tokens poupados são
|
|
13
|
+
// essencialmente *input* (saída de comando que entraria no contexto). Default
|
|
14
|
+
// ancorado em input de modelo de ponta (~US$3/Mtok × ~R$5,4/US$ ≈ R$16/Mtok).
|
|
15
|
+
// Sobrescrevível por `NOVYR_BRL_PER_MTOK` (ex.: outro modelo/câmbio).
|
|
16
|
+
const DEFAULT_BRL_PER_MTOK = 16;
|
|
17
|
+
|
|
18
|
+
function brlPerMtok() {
|
|
19
|
+
const v = Number(process.env.NOVYR_BRL_PER_MTOK);
|
|
20
|
+
return Number.isFinite(v) && v > 0 ? v : DEFAULT_BRL_PER_MTOK;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function formatTokens(n) {
|
|
24
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
|
25
|
+
if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
|
|
26
|
+
return String(n);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// R$ a partir de tokens poupados. Compacto: "R$ 81", "R$ 1,2k".
|
|
30
|
+
function savedBRL(savedTokens) {
|
|
31
|
+
return (savedTokens / 1_000_000) * brlPerMtok();
|
|
32
|
+
}
|
|
33
|
+
function formatBRL(value) {
|
|
34
|
+
if (value >= 1000) return "R$ " + (value / 1000).toFixed(1).replace(".", ",") + "k";
|
|
35
|
+
return "R$ " + Math.round(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// json: shape do engine → { summary: { total_commands, total_saved, avg_savings_pct, ... } }
|
|
39
|
+
function formatLine(json) {
|
|
40
|
+
const s = json && json.summary;
|
|
41
|
+
if (!s || !s.total_commands) {
|
|
42
|
+
return "novyr ⚡ pronto";
|
|
43
|
+
}
|
|
44
|
+
const pct = Math.round(s.avg_savings_pct || 0);
|
|
45
|
+
const saved = formatTokens(s.total_saved || 0);
|
|
46
|
+
const brl = formatBRL(savedBRL(s.total_saved || 0));
|
|
47
|
+
// novyr ⚡ ~R$ 81 · ~80% · 5.0M tok
|
|
48
|
+
return `novyr ⚡ ~${brl} · ~${pct}% · ${saved} tok`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { formatLine, formatTokens, formatBRL, savedBRL, brlPerMtok };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Resolves the path to the bundled `nvr` binary for the current platform.
|
|
3
|
+
//
|
|
4
|
+
// The binary is delivered as a per-platform optional dependency
|
|
5
|
+
// (@novyr/nvr-<os>-<cpu>) — the esbuild/biome/turbo pattern. npm installs only
|
|
6
|
+
// the package whose os/cpu match the host, so exactly one is present.
|
|
7
|
+
|
|
8
|
+
const targets = require("./targets.cjs");
|
|
9
|
+
|
|
10
|
+
const SCOPE = "@novyr";
|
|
11
|
+
const BIN = "nvr";
|
|
12
|
+
|
|
13
|
+
function platformKey() {
|
|
14
|
+
return `${process.platform}-${process.arch}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function packageName(key = platformKey()) {
|
|
18
|
+
return `${SCOPE}/${BIN}-${key}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Returns the absolute path to the nvr binary, or throws a helpful error.
|
|
22
|
+
function resolveBinary() {
|
|
23
|
+
const key = platformKey();
|
|
24
|
+
const target = targets[key];
|
|
25
|
+
if (!target) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`novyr: plataforma não suportada (${key}).\n` +
|
|
28
|
+
`Alvos disponíveis: ${Object.keys(targets).join(", ")}.`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const pkg = packageName(key);
|
|
33
|
+
const file = `${BIN}${target.exe}`;
|
|
34
|
+
try {
|
|
35
|
+
// The platform package ships the binary at its package root.
|
|
36
|
+
return require.resolve(`${pkg}/${file}`);
|
|
37
|
+
} catch (_e) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`novyr: binário do nvr não encontrado para ${key} (${pkg}).\n` +
|
|
40
|
+
`Reinstale com: npm install -g novyr\n` +
|
|
41
|
+
`Se você instalou com --ignore-scripts ou atrás de um proxy, veja NPM_PACKAGING.md.`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { resolveBinary, packageName, platformKey, targets };
|
package/lib/setup.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// `novyr setup`: ativa o engine no Claude Code e crava a statusLine de economia.
|
|
3
|
+
//
|
|
4
|
+
// 1) Roda o setup NATIVO do engine (rtk `init -g` / fork `setup --global`), que
|
|
5
|
+
// escreve o hook correto para aquela versão + a awareness + filtros.
|
|
6
|
+
// 2) Acrescenta nossa statusLine (`nvr line`) no ~/.claude/settings.json —
|
|
7
|
+
// idempotente, com backup .bak, sem clobberar uma statusLine custom.
|
|
8
|
+
|
|
9
|
+
const fs = require("node:fs");
|
|
10
|
+
const os = require("node:os");
|
|
11
|
+
const path = require("node:path");
|
|
12
|
+
const { spawnSync } = require("node:child_process");
|
|
13
|
+
const { runEngineFirst } = require("./engine.js");
|
|
14
|
+
const { resolveBinary } = require("./resolve-binary.js");
|
|
15
|
+
|
|
16
|
+
// Usa o nome de marca `novyr` (evita conflito com um `nvr`/`rtk` pré-existente
|
|
17
|
+
// no PATH; em produção os dois resolvem pro mesmo shim).
|
|
18
|
+
const STATUSLINE_COMMAND = "novyr line";
|
|
19
|
+
const ENGINE_NAMES = new Set(["nvr", "rtk", "novyr"]);
|
|
20
|
+
|
|
21
|
+
function claudeDir() {
|
|
22
|
+
// Mesmo override que o engine respeita, pra permitir teste isolado.
|
|
23
|
+
return process.env.TKF_CLAUDE_DIR || path.join(os.homedir(), ".claude");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function runSetup() {
|
|
27
|
+
// 1. Setup nativo do engine (instala o hook da versão correta + awareness).
|
|
28
|
+
const ok = runEngineFirst([
|
|
29
|
+
["init", "-g", "--auto-patch"], // rtk
|
|
30
|
+
["setup", "--global", "--auto-patch"], // fork nvr
|
|
31
|
+
]);
|
|
32
|
+
if (!ok) {
|
|
33
|
+
console.error(
|
|
34
|
+
"novyr: não consegui rodar o setup do engine — verifique a instalação."
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 2. Tira o node do hot-path: o hook passa a chamar o binário por caminho
|
|
39
|
+
// absoluto (o setup do engine o escreve com nome bare, que iria pelo shim) e
|
|
40
|
+
// com o subcomando que o engine RESOLVIDO realmente entende. Falha aqui não é
|
|
41
|
+
// silenciosa: sem o hook normalizado o filtro pode nem rodar.
|
|
42
|
+
try {
|
|
43
|
+
rewriteHook(resolveBinary());
|
|
44
|
+
} catch (err) {
|
|
45
|
+
const msg = (err && err.message) || String(err);
|
|
46
|
+
console.error(
|
|
47
|
+
`novyr: não consegui normalizar o hook (${msg}).\n` +
|
|
48
|
+
" O engine pode não estar instalado via npm — rode `npm install -g novyr` e `novyr setup` de novo."
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 2.5. Multi-tool: instala o hook do nvr nas OUTRAS ferramentas de coding que
|
|
53
|
+
// o usuário já tem (Cursor/Windsurf/Gemini). Tira o risco de apostar só no
|
|
54
|
+
// Claude Code. Só roda pra ferramenta presente (config dir existe) — não cria
|
|
55
|
+
// config pra tool que o usuário não usa.
|
|
56
|
+
setupExtraAgents();
|
|
57
|
+
|
|
58
|
+
// 3. Nossa statusLine (específica do Claude Code).
|
|
59
|
+
ensureStatusLine();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Agents extras que o engine 0.42.4 configura de forma LIMPA e GLOBAL.
|
|
63
|
+
// Só Gemini entra hoje: `--gemini` instala um hook global em ~/.gemini (exit 0,
|
|
64
|
+
// sem efeito colateral). Cursor e Windsurf ficam de fora de propósito:
|
|
65
|
+
// - `--agent cursor` reescreve ~/.claude (re-registra o hook do Claude) —
|
|
66
|
+
// desfaria o nosso hook único/absoluto. Pior que não fazer nada.
|
|
67
|
+
// - `--agent windsurf` grava `.windsurfrules` *project-local* no cwd, não um
|
|
68
|
+
// hook global — efeito colateral indesejado num `setup` global.
|
|
69
|
+
// Reavaliar quando o engine ganhar suporte global de verdade pra esses dois.
|
|
70
|
+
const EXTRA_AGENTS = [
|
|
71
|
+
{ name: "gemini", probe: [".gemini"], args: ["init", "-g", "--auto-patch", "--gemini"] },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
function homeHas(rel) {
|
|
75
|
+
try {
|
|
76
|
+
return fs.existsSync(path.join(os.homedir(), rel));
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Quais agents extras configurar. Default: auto-detecta pelas ferramentas
|
|
83
|
+
// presentes. Override explícito por `NOVYR_SETUP_AGENTS=cursor,windsurf`
|
|
84
|
+
// (string vazia => nenhum extra, útil pra ambiente limpo/teste).
|
|
85
|
+
function extraAgentsToSetup() {
|
|
86
|
+
const forced = process.env.NOVYR_SETUP_AGENTS;
|
|
87
|
+
if (forced != null) {
|
|
88
|
+
const want = new Set(forced.split(",").map((s) => s.trim()).filter(Boolean));
|
|
89
|
+
return EXTRA_AGENTS.filter((a) => want.has(a.name));
|
|
90
|
+
}
|
|
91
|
+
return EXTRA_AGENTS.filter((a) => a.probe.some(homeHas));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function setupExtraAgents() {
|
|
95
|
+
let bin;
|
|
96
|
+
try {
|
|
97
|
+
bin = resolveBinary();
|
|
98
|
+
} catch {
|
|
99
|
+
return; // sem engine resolvido: nada a fazer
|
|
100
|
+
}
|
|
101
|
+
for (const agent of extraAgentsToSetup()) {
|
|
102
|
+
const r = spawnSync(bin, agent.args, {
|
|
103
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
104
|
+
encoding: "utf8",
|
|
105
|
+
});
|
|
106
|
+
if (!r.error && (r.status === 0 || r.status === null)) {
|
|
107
|
+
console.log(` ${agent.name}: hook do nvr instalado`);
|
|
108
|
+
} else {
|
|
109
|
+
const why = ((r.stderr || (r.error && r.error.message) || "").trim().split("\n")[0]) || "erro";
|
|
110
|
+
console.error(` ${agent.name}: setup falhou (${why}) — ignorado`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function firstToken(cmd) {
|
|
116
|
+
return (cmd.trim().split(/\s+/)[0] || "").replace(/^["']|["']$/g, "");
|
|
117
|
+
}
|
|
118
|
+
function isEngineCommand(cmd) {
|
|
119
|
+
const base = firstToken(cmd).split(/[\\/]/).pop();
|
|
120
|
+
return ENGINE_NAMES.has(base);
|
|
121
|
+
}
|
|
122
|
+
function subcmdOf(cmd) {
|
|
123
|
+
return cmd.trim().split(/\s+/).slice(1).join(" ");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Descobre qual subcomando de hook o engine RESOLVIDO entende, perguntando ao
|
|
127
|
+
// próprio binário em vez de confiar em entradas antigas do settings (que podem
|
|
128
|
+
// ser de um engine já desinstalado). Engines divergem: o rtk/oficial usa
|
|
129
|
+
// `hook claude`, o fork usa `agent claude`. Testamos cada candidato com um input
|
|
130
|
+
// de PreToolUse e aceitamos o que devolver o JSON de rewrite esperado.
|
|
131
|
+
function detectHookSubcmd(absEngine) {
|
|
132
|
+
const probe = JSON.stringify({
|
|
133
|
+
tool_name: "Bash",
|
|
134
|
+
tool_input: { command: "git status" },
|
|
135
|
+
});
|
|
136
|
+
for (const subcmd of ["hook claude", "agent claude"]) {
|
|
137
|
+
const r = spawnSync(absEngine, subcmd.split(" "), {
|
|
138
|
+
input: probe,
|
|
139
|
+
encoding: "utf8",
|
|
140
|
+
});
|
|
141
|
+
if (r.status !== 0 || !r.stdout) continue;
|
|
142
|
+
try {
|
|
143
|
+
if (JSON.parse(r.stdout).hookSpecificOutput) return subcmd;
|
|
144
|
+
} catch {
|
|
145
|
+
/* não é o subcomando certo: tenta o próximo */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Herda o subcomando de uma entrada de engine já existente no settings.
|
|
152
|
+
function existingSubcmd(pre) {
|
|
153
|
+
for (const entry of pre) {
|
|
154
|
+
if (!entry || !Array.isArray(entry.hooks)) continue;
|
|
155
|
+
for (const h of entry.hooks) {
|
|
156
|
+
const cmd = (h && h.command) || "";
|
|
157
|
+
if (isEngineCommand(cmd)) {
|
|
158
|
+
const s = subcmdOf(cmd);
|
|
159
|
+
if (s) return s;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return "";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Reescreve o hook do engine para o caminho absoluto do binário e DEDUPA.
|
|
167
|
+
// Idempotente: como o setup do engine re-adiciona o comando bare a cada run
|
|
168
|
+
// (não reconhece o nosso absoluto), aqui colapsamos TODAS as entradas de engine
|
|
169
|
+
// — de qualquer engine/subcomando — numa única, com o subcomando correto.
|
|
170
|
+
function rewriteHook(absEngine) {
|
|
171
|
+
const file = path.join(claudeDir(), "settings.json");
|
|
172
|
+
if (!fs.existsSync(file)) return;
|
|
173
|
+
let root;
|
|
174
|
+
try {
|
|
175
|
+
root = JSON.parse(fs.readFileSync(file, "utf8") || "{}");
|
|
176
|
+
} catch {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const pre = root && root.hooks && root.hooks.PreToolUse;
|
|
180
|
+
if (!Array.isArray(pre)) return;
|
|
181
|
+
|
|
182
|
+
// Subcomando: pergunta ao engine; se não der, herda do settings; por fim,
|
|
183
|
+
// cai no default do rtk.
|
|
184
|
+
const subcmd =
|
|
185
|
+
detectHookSubcmd(absEngine) || existingSubcmd(pre) || "hook claude";
|
|
186
|
+
|
|
187
|
+
// Remove toda entrada de engine (qualquer subcomando) — recriamos uma só.
|
|
188
|
+
for (const entry of pre) {
|
|
189
|
+
if (!entry || !Array.isArray(entry.hooks)) continue;
|
|
190
|
+
entry.hooks = entry.hooks.filter(
|
|
191
|
+
(h) => !isEngineCommand((h && h.command) || "")
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
// Mantém entradas de terceiros; descarta as que ficaram vazias.
|
|
195
|
+
root.hooks.PreToolUse = pre.filter(
|
|
196
|
+
(e) => e && Array.isArray(e.hooks) && e.hooks.length > 0
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
root.hooks.PreToolUse.push({
|
|
200
|
+
matcher: "Bash",
|
|
201
|
+
hooks: [{ type: "command", command: `"${absEngine}" ${subcmd}` }],
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
fs.writeFileSync(file, JSON.stringify(root, null, 2) + "\n");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function ensureStatusLine() {
|
|
208
|
+
const dir = claudeDir();
|
|
209
|
+
const file = path.join(dir, "settings.json");
|
|
210
|
+
|
|
211
|
+
let root = {};
|
|
212
|
+
if (fs.existsSync(file)) {
|
|
213
|
+
const content = fs.readFileSync(file, "utf8");
|
|
214
|
+
if (content.trim()) {
|
|
215
|
+
try {
|
|
216
|
+
root = JSON.parse(content);
|
|
217
|
+
} catch {
|
|
218
|
+
console.error(`novyr: ${file} inválido — statusLine não adicionada.`);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Respeita uma statusLine existente; só a nossa é idempotente.
|
|
227
|
+
if (root.statusLine) {
|
|
228
|
+
const cmd = (root.statusLine.command || "").toString();
|
|
229
|
+
if (!(cmd.includes("nvr") && cmd.includes("line"))) {
|
|
230
|
+
console.log(
|
|
231
|
+
" settings.json: statusLine custom mantida (economia via `novyr stats`)"
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (fs.existsSync(file)) {
|
|
238
|
+
try {
|
|
239
|
+
fs.copyFileSync(file, file + ".bak");
|
|
240
|
+
} catch {
|
|
241
|
+
/* backup best-effort */
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
root.statusLine = { type: "command", command: STATUSLINE_COMMAND };
|
|
246
|
+
fs.writeFileSync(file, JSON.stringify(root, null, 2) + "\n");
|
|
247
|
+
console.log(" settings.json: statusLine de economia adicionada (novyr line)");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
module.exports = { runSetup, ensureStatusLine, STATUSLINE_COMMAND };
|
package/lib/targets.cjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Single source of truth for the platforms we ship the nvr binary for.
|
|
2
|
+
//
|
|
3
|
+
// key = "<node-platform>-<node-arch>" (process.platform + process.arch),
|
|
4
|
+
// also used to name the npm package: @novyr/nvr-<key>
|
|
5
|
+
// rustTarget = the Rust target triple CI cross-compiles for
|
|
6
|
+
// os/cpu = npm package fields so npm installs only the matching one
|
|
7
|
+
// exe = binary extension on that platform
|
|
8
|
+
//
|
|
9
|
+
// Used by: lib/resolve-binary.js (runtime), scripts/build-platform-packages.mjs
|
|
10
|
+
// (CI assembly), and mirrored in package.json optionalDependencies.
|
|
11
|
+
module.exports = {
|
|
12
|
+
"linux-x64": {
|
|
13
|
+
// rtk publica o linux-x64 como musl (estático/portátil); arm64 é gnu.
|
|
14
|
+
rustTarget: "x86_64-unknown-linux-musl",
|
|
15
|
+
os: "linux",
|
|
16
|
+
cpu: "x64",
|
|
17
|
+
exe: "",
|
|
18
|
+
},
|
|
19
|
+
"linux-arm64": {
|
|
20
|
+
rustTarget: "aarch64-unknown-linux-gnu",
|
|
21
|
+
os: "linux",
|
|
22
|
+
cpu: "arm64",
|
|
23
|
+
exe: "",
|
|
24
|
+
},
|
|
25
|
+
"darwin-x64": {
|
|
26
|
+
rustTarget: "x86_64-apple-darwin",
|
|
27
|
+
os: "darwin",
|
|
28
|
+
cpu: "x64",
|
|
29
|
+
exe: "",
|
|
30
|
+
},
|
|
31
|
+
"darwin-arm64": {
|
|
32
|
+
rustTarget: "aarch64-apple-darwin",
|
|
33
|
+
os: "darwin",
|
|
34
|
+
cpu: "arm64",
|
|
35
|
+
exe: "",
|
|
36
|
+
},
|
|
37
|
+
"win32-x64": {
|
|
38
|
+
rustTarget: "x86_64-pc-windows-msvc",
|
|
39
|
+
os: "win32",
|
|
40
|
+
cpu: "x64",
|
|
41
|
+
exe: ".exe",
|
|
42
|
+
},
|
|
43
|
+
};
|
package/lib/telemetry.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Telemetria de economia — OPT-IN e privacy-clean. É a fundação do produto:
|
|
3
|
+
// sem dado agregado não dá pra mostrar economia na nuvem nem benchmark entre
|
|
4
|
+
// times. Mas a promessa é zero-retention de código, então aqui SÓ trafegam
|
|
5
|
+
// contadores agregados + um id anônimo aleatório. Nunca: trechos de código,
|
|
6
|
+
// caminhos de arquivo, nomes de repo, texto de comando.
|
|
7
|
+
//
|
|
8
|
+
// Fluxo:
|
|
9
|
+
// `novyr telemetry on|off|status` → gerencia o opt-in
|
|
10
|
+
// `novyr line` → se ligado e "vencido", dispara um worker
|
|
11
|
+
// destacado (`novyr telemetry send`) sem
|
|
12
|
+
// travar a statusLine
|
|
13
|
+
// `novyr telemetry send` → o worker: lê o JSON do engine, monta o
|
|
14
|
+
// payload agregado e faz POST
|
|
15
|
+
|
|
16
|
+
const fs = require("node:fs");
|
|
17
|
+
const os = require("node:os");
|
|
18
|
+
const path = require("node:path");
|
|
19
|
+
const crypto = require("node:crypto");
|
|
20
|
+
const { spawn } = require("node:child_process");
|
|
21
|
+
|
|
22
|
+
const DEFAULT_BASE = "https://novyr.dev";
|
|
23
|
+
const SEND_INTERVAL_MS = 6 * 60 * 60 * 1000; // no máximo 1 envio a cada 6h
|
|
24
|
+
|
|
25
|
+
function apiBase() {
|
|
26
|
+
return process.env.NOVYR_API_BASE || DEFAULT_BASE;
|
|
27
|
+
}
|
|
28
|
+
function deviceCodeUrl() {
|
|
29
|
+
return apiBase() + "/api/cli/device-code";
|
|
30
|
+
}
|
|
31
|
+
function tokenUrl() {
|
|
32
|
+
return apiBase() + "/api/cli/token";
|
|
33
|
+
}
|
|
34
|
+
// Dashboard é autenticado por sessão (Clerk) — não carrega mais a key na URL.
|
|
35
|
+
function dashboardUrl() {
|
|
36
|
+
return apiBase() + "/dashboard";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function configDir() {
|
|
40
|
+
const base =
|
|
41
|
+
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
42
|
+
return path.join(base, "novyr");
|
|
43
|
+
}
|
|
44
|
+
function configPath() {
|
|
45
|
+
return path.join(configDir(), "telemetry.json");
|
|
46
|
+
}
|
|
47
|
+
function endpoint() {
|
|
48
|
+
return process.env.NOVYR_TELEMETRY_URL || apiBase() + "/api/telemetry";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function loadConfig() {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(fs.readFileSync(configPath(), "utf8"));
|
|
54
|
+
} catch {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function saveConfig(cfg) {
|
|
59
|
+
fs.mkdirSync(configDir(), { recursive: true });
|
|
60
|
+
fs.writeFileSync(configPath(), JSON.stringify(cfg, null, 2) + "\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// id anônimo estável por máquina — aleatório, não derivado de nada do usuário.
|
|
64
|
+
function ensureAnonId(cfg) {
|
|
65
|
+
if (!cfg.anonId) {
|
|
66
|
+
cfg.anonId = crypto.randomUUID();
|
|
67
|
+
saveConfig(cfg);
|
|
68
|
+
}
|
|
69
|
+
return cfg.anonId;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isEnabled() {
|
|
73
|
+
return loadConfig().enabled === true;
|
|
74
|
+
}
|
|
75
|
+
function enable() {
|
|
76
|
+
const cfg = loadConfig();
|
|
77
|
+
cfg.enabled = true;
|
|
78
|
+
ensureAnonId(cfg);
|
|
79
|
+
saveConfig(cfg);
|
|
80
|
+
}
|
|
81
|
+
function disable() {
|
|
82
|
+
const cfg = loadConfig();
|
|
83
|
+
cfg.enabled = false;
|
|
84
|
+
saveConfig(cfg);
|
|
85
|
+
}
|
|
86
|
+
function status() {
|
|
87
|
+
const cfg = loadConfig();
|
|
88
|
+
return {
|
|
89
|
+
enabled: cfg.enabled === true,
|
|
90
|
+
anonId: cfg.anonId || null,
|
|
91
|
+
accountKey: cfg.accountKey || null,
|
|
92
|
+
dashboardUrl: cfg.accountKey ? dashboardUrl() : null,
|
|
93
|
+
lastSentAt: cfg.lastSentAt || null,
|
|
94
|
+
endpoint: endpoint(),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// POST JSON best-effort com timeout. Lança em falha (login precisa saber).
|
|
99
|
+
async function postJson(url, body) {
|
|
100
|
+
const ac = new AbortController();
|
|
101
|
+
const t = setTimeout(() => ac.abort(), 8000);
|
|
102
|
+
try {
|
|
103
|
+
const r = await fetch(url, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers: { "content-type": "application/json" },
|
|
106
|
+
body: JSON.stringify(body || {}),
|
|
107
|
+
signal: ac.signal,
|
|
108
|
+
});
|
|
109
|
+
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
110
|
+
return await r.json();
|
|
111
|
+
} finally {
|
|
112
|
+
clearTimeout(t);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
117
|
+
|
|
118
|
+
// Abre o navegador na URL de autorização (best-effort). Se falhar, o usuário
|
|
119
|
+
// abre manualmente — o código/URL já foram impressos.
|
|
120
|
+
function tryOpenBrowser(url) {
|
|
121
|
+
try {
|
|
122
|
+
let cmd, args;
|
|
123
|
+
if (process.platform === "darwin") {
|
|
124
|
+
cmd = "open";
|
|
125
|
+
args = [url];
|
|
126
|
+
} else if (process.platform === "win32") {
|
|
127
|
+
cmd = "cmd";
|
|
128
|
+
args = ["/c", "start", "", url];
|
|
129
|
+
} else {
|
|
130
|
+
cmd = "xdg-open";
|
|
131
|
+
args = [url];
|
|
132
|
+
}
|
|
133
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
134
|
+
child.on("error", () => {});
|
|
135
|
+
child.unref();
|
|
136
|
+
} catch {
|
|
137
|
+
/* sem navegador disponível; segue no manual */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Device flow (estilo OAuth device code): pede um par device/user code, manda o
|
|
142
|
+
// usuário autorizar no navegador (logado no Clerk) e faz polling até liberar a
|
|
143
|
+
// key. Retorna { key }.
|
|
144
|
+
async function deviceFlow() {
|
|
145
|
+
const start = await postJson(deviceCodeUrl(), {});
|
|
146
|
+
if (!start || !start.device_code || !start.user_code) {
|
|
147
|
+
throw new Error("resposta inválida do device-code");
|
|
148
|
+
}
|
|
149
|
+
const openUrl = start.verification_uri_complete || start.verification_uri;
|
|
150
|
+
console.log(
|
|
151
|
+
"\nPra autorizar este CLI, abra no navegador (logado na novyr):\n" +
|
|
152
|
+
" " + (start.verification_uri || openUrl) + "\n" +
|
|
153
|
+
" código: " + start.user_code + "\n"
|
|
154
|
+
);
|
|
155
|
+
tryOpenBrowser(openUrl);
|
|
156
|
+
|
|
157
|
+
const intervalMs = Math.max(2, Number(start.interval) || 5) * 1000;
|
|
158
|
+
const deadline = Date.now() + (Number(start.expires_in) || 600) * 1000;
|
|
159
|
+
process.stdout.write("Aguardando autorização");
|
|
160
|
+
while (Date.now() < deadline) {
|
|
161
|
+
await sleep(intervalMs);
|
|
162
|
+
process.stdout.write(".");
|
|
163
|
+
let poll;
|
|
164
|
+
try {
|
|
165
|
+
poll = await postJson(tokenUrl(), { device_code: start.device_code });
|
|
166
|
+
} catch (e) {
|
|
167
|
+
// 410 = expirado/inexistente; demais erros são transitórios (segue).
|
|
168
|
+
if (String(e.message || e).includes("410")) break;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (poll && poll.status === "authorized" && poll.key) {
|
|
172
|
+
process.stdout.write("\n");
|
|
173
|
+
return { key: poll.key };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
process.stdout.write("\n");
|
|
177
|
+
throw new Error("autorização expirou — rode `novyr login` de novo");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// `novyr login [key]`: linka o CLI a uma conta cloud. Sem key, faz o device flow
|
|
181
|
+
// (abre o navegador pra autorizar). Com key, linka direto (fallback manual / CI).
|
|
182
|
+
// Linkar implica ligar a telemetria (é o que alimenta o dashboard).
|
|
183
|
+
// Retorna { key, dashboardUrl }.
|
|
184
|
+
async function login(providedKey) {
|
|
185
|
+
const cfg = loadConfig();
|
|
186
|
+
let key = providedKey;
|
|
187
|
+
if (!key) {
|
|
188
|
+
const res = await deviceFlow();
|
|
189
|
+
key = res.key;
|
|
190
|
+
}
|
|
191
|
+
cfg.accountKey = key;
|
|
192
|
+
cfg.enabled = true;
|
|
193
|
+
ensureAnonId(cfg);
|
|
194
|
+
saveConfig(cfg);
|
|
195
|
+
return { key, dashboardUrl: dashboardUrl() };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Desliga o vínculo com a conta (para de mandar dado pro dashboard). Não mexe
|
|
199
|
+
// no opt-in geral de telemetria.
|
|
200
|
+
function logout() {
|
|
201
|
+
const cfg = loadConfig();
|
|
202
|
+
delete cfg.accountKey;
|
|
203
|
+
saveConfig(cfg);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isDue(cfg) {
|
|
207
|
+
if (!cfg.lastSentAt) return true;
|
|
208
|
+
return Date.now() - Number(cfg.lastSentAt) >= SEND_INTERVAL_MS;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Monta o payload AGREGADO. Lista de campos é um allowlist explícito: nada além
|
|
212
|
+
// de contadores numéricos sai daqui.
|
|
213
|
+
function buildPayload(json, anonId) {
|
|
214
|
+
const s = (json && json.summary) || {};
|
|
215
|
+
const num = (x) => (Number.isFinite(Number(x)) ? Number(x) : 0);
|
|
216
|
+
return {
|
|
217
|
+
v: 1,
|
|
218
|
+
anonId,
|
|
219
|
+
ts: Date.now(),
|
|
220
|
+
platform: `${process.platform}-${process.arch}`,
|
|
221
|
+
summary: {
|
|
222
|
+
total_commands: num(s.total_commands),
|
|
223
|
+
total_input: num(s.total_input),
|
|
224
|
+
total_output: num(s.total_output),
|
|
225
|
+
total_saved: num(s.total_saved),
|
|
226
|
+
avg_savings_pct: num(s.avg_savings_pct),
|
|
227
|
+
total_time_ms: num(s.total_time_ms),
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// POST best-effort com timeout curto. Nunca lança (telemetria não pode quebrar
|
|
233
|
+
// o fluxo do usuário).
|
|
234
|
+
async function postPayload(payload) {
|
|
235
|
+
const ac = new AbortController();
|
|
236
|
+
const t = setTimeout(() => ac.abort(), 4000);
|
|
237
|
+
try {
|
|
238
|
+
await fetch(endpoint(), {
|
|
239
|
+
method: "POST",
|
|
240
|
+
headers: { "content-type": "application/json" },
|
|
241
|
+
body: JSON.stringify(payload),
|
|
242
|
+
signal: ac.signal,
|
|
243
|
+
});
|
|
244
|
+
return true;
|
|
245
|
+
} catch {
|
|
246
|
+
return false;
|
|
247
|
+
} finally {
|
|
248
|
+
clearTimeout(t);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Worker (`novyr telemetry send`): roda destacado, lê o engine, envia, marca.
|
|
253
|
+
async function runSend() {
|
|
254
|
+
const cfg = loadConfig();
|
|
255
|
+
if (cfg.enabled !== true) return;
|
|
256
|
+
const anonId = ensureAnonId(cfg);
|
|
257
|
+
|
|
258
|
+
let json = null;
|
|
259
|
+
try {
|
|
260
|
+
const { engineJsonFirst } = require("./engine.js");
|
|
261
|
+
json = engineJsonFirst([
|
|
262
|
+
["gain", "--format", "json"],
|
|
263
|
+
["stats", "--format", "json"],
|
|
264
|
+
]);
|
|
265
|
+
} catch {
|
|
266
|
+
json = null;
|
|
267
|
+
}
|
|
268
|
+
if (!json || !json.summary) return;
|
|
269
|
+
|
|
270
|
+
await postPayload(buildPayload(json, anonId));
|
|
271
|
+
cfg.lastSentAt = Date.now();
|
|
272
|
+
saveConfig(cfg);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Chamado pela statusLine: se ligado e vencido, dispara o worker SEM travar.
|
|
276
|
+
// `detached + unref` deixa o processo viver além do `novyr line` que já saiu.
|
|
277
|
+
function maybeSendInBackground() {
|
|
278
|
+
try {
|
|
279
|
+
const cfg = loadConfig();
|
|
280
|
+
if (cfg.enabled !== true || !isDue(cfg)) return;
|
|
281
|
+
// marca otimisticamente pra não disparar N workers em rajada de statusLine
|
|
282
|
+
cfg.lastSentAt = Date.now();
|
|
283
|
+
saveConfig(cfg);
|
|
284
|
+
const child = spawn(process.execPath, [__dirname + "/../bin/cli.js", "telemetry", "send"], {
|
|
285
|
+
detached: true,
|
|
286
|
+
stdio: "ignore",
|
|
287
|
+
});
|
|
288
|
+
child.unref();
|
|
289
|
+
} catch {
|
|
290
|
+
/* nunca quebra a statusLine */
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
module.exports = {
|
|
295
|
+
isEnabled,
|
|
296
|
+
enable,
|
|
297
|
+
disable,
|
|
298
|
+
status,
|
|
299
|
+
login,
|
|
300
|
+
logout,
|
|
301
|
+
buildPayload,
|
|
302
|
+
runSend,
|
|
303
|
+
maybeSendInBackground,
|
|
304
|
+
configPath,
|
|
305
|
+
endpoint,
|
|
306
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "novyr",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Novyr — corte 60-90% dos tokens do seu Claude Code. Traz o nvr (engine de compressão) e mostra a economia em tempo real na statusline.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://novyr.dev",
|
|
7
|
+
"bin": {
|
|
8
|
+
"novyr": "./bin/cli.js",
|
|
9
|
+
"nvr": "./bin/cli.js",
|
|
10
|
+
"rtk": "./bin/cli.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"postinstall": "node scripts/postinstall.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin/",
|
|
17
|
+
"lib/",
|
|
18
|
+
"scripts/postinstall.js",
|
|
19
|
+
"README.md",
|
|
20
|
+
"NOTICE"
|
|
21
|
+
],
|
|
22
|
+
"optionalDependencies": {
|
|
23
|
+
"@novyr/nvr-linux-x64": "0.1.0",
|
|
24
|
+
"@novyr/nvr-linux-arm64": "0.1.0",
|
|
25
|
+
"@novyr/nvr-darwin-x64": "0.1.0",
|
|
26
|
+
"@novyr/nvr-darwin-arm64": "0.1.0",
|
|
27
|
+
"@novyr/nvr-win32-x64": "0.1.0"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"claude-code",
|
|
34
|
+
"tokens",
|
|
35
|
+
"token-savings",
|
|
36
|
+
"nvr",
|
|
37
|
+
"cli",
|
|
38
|
+
"statusline",
|
|
39
|
+
"claude"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// Pós-instalação: ativa o engine no Claude Code automaticamente em instalações
|
|
4
|
+
// GLOBAIS (`npm install -g novyr`), pra o usuário já sair com o CLI E o nvr
|
|
5
|
+
// rodando no Claude Code, sem um passo manual.
|
|
6
|
+
//
|
|
7
|
+
// Princípios (importam pra não estragar a experiência):
|
|
8
|
+
// - Só auto-ativa em install GLOBAL — nunca mexe na config de quem usa novyr
|
|
9
|
+
// como dependência local de um projeto.
|
|
10
|
+
// - NUNCA falha o `npm install`: qualquer erro vira aviso e sai 0.
|
|
11
|
+
// - Idempotente: o setup preserva config existente e faz backup (.bak).
|
|
12
|
+
// - Respeita `--ignore-scripts`: se não rodar, `novyr setup` faz o mesmo na mão.
|
|
13
|
+
|
|
14
|
+
function tip(msg) {
|
|
15
|
+
console.log(`\nnovyr: ${msg}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Só auto-ativa em instalação global. (Instalação local como dependência não
|
|
19
|
+
// deve tocar no ~/.claude do usuário.) Também cobre o `npm ci` do CI, que é local.
|
|
20
|
+
if (process.env.npm_config_global !== "true") {
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
// Chama o MESMO setup do wrapper (engine init + nossa statusLine), em
|
|
26
|
+
// processo — não o setup do engine direto, senão a statusLine não entra.
|
|
27
|
+
const { runSetup } = require("../lib/setup.js");
|
|
28
|
+
runSetup();
|
|
29
|
+
} catch (_e) {
|
|
30
|
+
tip("ativação automática pulada — rode `novyr setup` para ativar.");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Sempre sai 0: jamais queremos quebrar o `npm install`.
|
|
34
|
+
process.exit(0);
|