@ravox-ui/cli 0.1.0 → 0.2.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/README.md CHANGED
@@ -8,6 +8,7 @@ npx @ravox-ui/cli add button dialog # add components
8
8
  npx @ravox-ui/cli add dashboard-01 # add a block (a full screen, React)
9
9
  npx @ravox-ui/cli add --all # add every component
10
10
  npx @ravox-ui/cli list # see everything available
11
+ npx @ravox-ui/cli connect # answer the site editors' chat with your own Claude Code
11
12
  ```
12
13
 
13
14
  Also with `pnpm dlx @ravox-ui/cli …`, `yarn dlx @ravox-ui/cli …` and `bunx @ravox-ui/cli …`.
@@ -21,9 +22,23 @@ Under the hood it uses the [shadcn](https://ui.shadcn.com) CLI with the Ravox re
21
22
 
22
23
  - **Framework:** detected from `package.json` (`--framework react|vue|svelte|angular` to choose).
23
24
  React gets the shadcn items; Vue, Svelte and Angular get their own ports, with the same API.
25
+ - **React setup:** with Tailwind 4, a main CSS file and an import alias (`@/*`), `init` writes `components.json` itself,
26
+ so no component library (Radix, Base UI, React Aria) gets installed: the Kit uses none. Otherwise it runs `shadcn init`.
24
27
  - **Package manager:** shadcn runs with the one you used to call it (npx, pnpm, yarn or bun).
25
28
  - **Names:** `button`, `Button` or `button.json` all work, and typos get a suggestion.
26
29
 
30
+ ## Connect your Claude Code to the site editors
31
+
32
+ `connect` (or `conectar`) links the component editor at ui.ravoxlabs.com/criar (and the team's video editor) to the
33
+ [Claude Code](https://claude.com/claude-code) on your computer. You write in the editor's Claude chat; your Claude
34
+ answers right there, the code and the preview update live, and follow-ups keep the context. It uses your own Claude
35
+ subscription: no AI key, nothing to pay on the site.
36
+
37
+ - The first run opens the browser to sign in and click **Allow** (OAuth with PKCE; the token is saved in
38
+ `~/.config/ravox-ui/conector.json`). `--login` signs in again; `--site <url>` points to another site.
39
+ - Keep it open while you work (Ctrl+C turns it off). Each request runs `claude -p` with only the site's tools: it reads
40
+ and saves **your own drafts**, never publishes and touches no files on your computer.
41
+
27
42
  ## Options
28
43
 
29
44
  | Option | |
@@ -33,6 +48,9 @@ Under the hood it uses the [shadcn](https://ui.shadcn.com) CLI with the Ravox re
33
48
  | `-o, --overwrite` | overwrite existing files |
34
49
  | `-y, --yes` | skip confirmation prompts |
35
50
  | `--dry-run` | print the shadcn command, change nothing |
51
+ | `--site <url>` | `connect`: the site (default `https://ui.ravoxlabs.com`) |
52
+ | `--login` | `connect`: sign in again |
53
+ | `--model <name>` | `connect`: Claude model (default `sonnet`; `opus` for harder work) |
36
54
 
37
55
  Messages are in Portuguese when your system language is Portuguese (`LANG=pt_BR…`).
38
56
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ravox-ui/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Add Ravox Kit components, blocks and themes to your project (React, Vue, Svelte and Angular).",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -1,16 +1,18 @@
1
1
  import { spawn } from "node:child_process";
2
- import { readFileSync } from "node:fs";
3
- import { resolve } from "node:path";
2
+ import { readFileSync, writeFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { conectar } from "./connect.mjs";
4
5
  import { messages } from "./messages.mjs";
5
- import { detectFramework, FRAMEWORKS, hasComponentsJson, readPackageJson, runnerFor } from "./project.mjs";
6
+ import { detectFramework, FRAMEWORKS, hasComponentsJson, reactConfig, readPackageJson, runnerFor } from "./project.mjs";
6
7
  import { availableNames, fetchIndex, itemUrl, resolveNames, setupUrls } from "./registry.mjs";
7
8
  import { suggest } from "./suggest.mjs";
9
+ import { keepTheme } from "./theme.mjs";
8
10
 
9
11
  const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
10
12
 
11
13
  /** Parses `command [names...] [--flags]`. */
12
14
  export function parseArgs(argv) {
13
- const opts = { command: null, names: [], framework: null, cwd: process.cwd(), overwrite: false, yes: false, all: false, dryRun: false, help: false, version: false };
15
+ const opts = { command: null, names: [], framework: null, cwd: process.cwd(), overwrite: false, yes: false, all: false, dryRun: false, help: false, version: false, site: null, login: false };
14
16
  for (let i = 0; i < argv.length; i++) {
15
17
  const a = argv[i];
16
18
  const value = () => {
@@ -27,6 +29,10 @@ export function parseArgs(argv) {
27
29
  else if (a === "-y" || a === "--yes") opts.yes = true;
28
30
  else if (a === "-a" || a === "--all") opts.all = true;
29
31
  else if (a === "--dry-run") opts.dryRun = true;
32
+ else if (a === "--site") opts.site = value();
33
+ else if (a.startsWith("--site=")) opts.site = a.slice(7);
34
+ else if (a === "--login") opts.login = true;
35
+ else if (a === "--model") opts.model = value();
30
36
  else if (!opts.command) opts.command = a;
31
37
  else opts.names.push(a);
32
38
  }
@@ -61,13 +67,27 @@ function frameworkOf(opts, t) {
61
67
  return fw;
62
68
  }
63
69
 
70
+ /** Installs the theme into the main CSS without touching the variables the project already has (unless --overwrite). */
71
+ async function runTheme(cmd, opts, t) {
72
+ if (opts.overwrite || opts.dryRun) return run(cmd, opts, t);
73
+ const { code, kept } = await keepTheme(opts.cwd, () => run(cmd, opts, t));
74
+ if (kept) console.log(t.keptVariables(kept));
75
+ return code;
76
+ }
77
+
64
78
  async function init(opts, t) {
65
79
  const fw = frameworkOf(opts, t);
66
80
  if (fw === "react") {
67
81
  console.log(t.initReact);
68
- // A project without components.json gets shadcn init (config + cn()) and the theme in one go.
69
- const args = hasComponentsJson(opts.cwd) ? ["add", ...setupUrls(fw)] : ["init", ...setupUrls(fw)];
70
- return run(shadcn(args, opts), opts, t);
82
+ if (hasComponentsJson(opts.cwd)) return runTheme(shadcn(["add", ...setupUrls(fw)], opts), opts, t);
83
+ // No components.json: we write it ourselves when the project is clear (Tailwind 4, CSS file, alias), so no
84
+ // base library comes along; the theme brings cn(). Otherwise shadcn init asks the user.
85
+ const config = reactConfig(opts.cwd, readPackageJson(opts.cwd));
86
+ if (!config) return runTheme(shadcn(["init", ...setupUrls(fw)], opts), opts, t);
87
+ if (opts.dryRun) console.log(`${t.dryRun} components.json ${JSON.stringify(config.aliases)}`);
88
+ else writeFileSync(join(opts.cwd, "components.json"), `${JSON.stringify(config, null, 2)}\n`);
89
+ console.log(t.wroteConfig(config.tailwind.css));
90
+ return runTheme(shadcn(["add", ...setupUrls(fw)], opts), opts, t);
71
91
  }
72
92
  console.log(t.initOther(fw));
73
93
  const code = await run(shadcn(["add", ...setupUrls(fw)], opts, t), opts, t);
@@ -128,6 +148,7 @@ export async function main(argv) {
128
148
  if (opts.command === "init") return init(opts, t);
129
149
  if (opts.command === "add") return add(opts, t);
130
150
  if (opts.command === "list" || opts.command === "ls") return list(opts, t);
151
+ if (opts.command === "conectar" || opts.command === "connect") return conectar(opts);
131
152
  console.error(t.unknownCommand(opts.command));
132
153
  return 1;
133
154
  }
@@ -0,0 +1,384 @@
1
+ // `ravox conectar`: links the Ravox UI site to the Claude Code installed on this computer. The editors' chat on the
2
+ // site (components and video) gets answered right there, live, by the person's own Claude (their subscription),
3
+ // with no AI key on the server. Login is the site's OAuth (PKCE, browser "Allow" page); each request runs
4
+ // `claude -p` with the site's MCP tools and its progress goes back to the chat.
5
+
6
+ import { execFile, spawn } from "node:child_process";
7
+ import { createHash, randomBytes } from "node:crypto";
8
+ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { createServer } from "node:http";
10
+ import { homedir, tmpdir } from "node:os";
11
+ import { dirname, join } from "node:path";
12
+ import { servidorLocal } from "./local.mjs";
13
+
14
+ const CONFIG = join(homedir(), ".config", "ravox-ui", "conector.json");
15
+ const INTERVALO = 2000;
16
+ const PROGRESSO = 1200;
17
+
18
+ const pt = /^pt/i.test(process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || Intl.DateTimeFormat().resolvedOptions().locale);
19
+ const t = pt
20
+ ? {
21
+ semClaude: "Não achei o Claude Code neste computador. Instale com: npm i -g @anthropic-ai/claude-code (e entre com claude uma vez).",
22
+ abrindo: "Abrindo o navegador para você permitir o acesso (se não abrir, use este link):",
23
+ esperando: "Esperando você clicar em Permitir…",
24
+ recusado: "Acesso recusado no navegador.",
25
+ ligado: (site, quem) => `Conector ligado em ${site}${quem ? ` (${quem})` : ""}. Peça no chat do editor; deixe esta janela aberta. Ctrl+C desliga.`,
26
+ sessaoVencida: "A conexão venceu ou foi revogada; vamos entrar de novo.",
27
+ pedido: (texto) => `→ ${texto}`,
28
+ feito: " feito",
29
+ erro: (m) => ` não deu: ${m}`,
30
+ falhaRede: (m) => `Sem conexão com o site (${m}); tento de novo em instantes.`,
31
+ pausado: "Desconectado no site: não pego pedidos até você clicar em Conectar no chat.",
32
+ retomado: "Conectado de novo no site.",
33
+ saiu: "Você saiu deste computador pelo site. Para ligar de novo: ravox conectar",
34
+ }
35
+ : {
36
+ semClaude: "Claude Code was not found on this computer. Install it with: npm i -g @anthropic-ai/claude-code (and sign in with claude once).",
37
+ abrindo: "Opening the browser so you can allow access (if it does not open, use this link):",
38
+ esperando: "Waiting for you to click Allow…",
39
+ recusado: "Access denied in the browser.",
40
+ ligado: (site, quem) => `Connector on for ${site}${quem ? ` (${quem})` : ""}. Ask in the editor chat; keep this window open. Ctrl+C turns it off.`,
41
+ sessaoVencida: "The connection expired or was revoked; signing in again.",
42
+ pedido: (texto) => `→ ${texto}`,
43
+ feito: " done",
44
+ erro: (m) => ` failed: ${m}`,
45
+ falhaRede: (m) => `Cannot reach the site (${m}); retrying shortly.`,
46
+ pausado: "Disconnected on the site: no requests until you click Connect in the chat.",
47
+ retomado: "Connected again on the site.",
48
+ saiu: "You signed this computer out on the site. To turn it on again: ravox connect",
49
+ };
50
+
51
+ const base64url = (b) => b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
52
+
53
+ /* ---------------------------------------------------------------- saved login */
54
+
55
+ function lerConfig() {
56
+ try {
57
+ return JSON.parse(readFileSync(CONFIG, "utf8"));
58
+ } catch {
59
+ return {};
60
+ }
61
+ }
62
+
63
+ function salvarToken(site, token) {
64
+ const c = lerConfig();
65
+ c[site] = { token, em: new Date().toISOString() };
66
+ mkdirSync(dirname(CONFIG), { recursive: true });
67
+ writeFileSync(CONFIG, JSON.stringify(c, null, 2), { mode: 0o600 });
68
+ }
69
+
70
+ /* ---------------------------------------------------------------- login (OAuth + PKCE, localhost callback) */
71
+
72
+ function abrirNavegador(url) {
73
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
74
+ execFile(cmd, args, () => {});
75
+ }
76
+
77
+ export async function entrar(site) {
78
+ const verificador = base64url(randomBytes(32));
79
+ const desafio = base64url(createHash("sha256").update(verificador).digest());
80
+ const estado = base64url(randomBytes(16));
81
+
82
+ let receber;
83
+ const chegou = new Promise((r) => (receber = r));
84
+ // The browser waits on this response while the token is exchanged, then goes to the site's "connected" page.
85
+ const servidor = createServer((req, res) => {
86
+ const u = new URL(req.url ?? "/", "http://127.0.0.1");
87
+ if (u.pathname !== "/callback") return res.writeHead(404).end();
88
+ receber({ params: u.searchParams, res });
89
+ });
90
+ await new Promise((r) => servidor.listen(0, "127.0.0.1", r));
91
+ const volta = `http://127.0.0.1:${servidor.address().port}/callback`;
92
+
93
+ try {
94
+ const reg = await fetch(`${site}/api/oauth/registrar`, {
95
+ method: "POST",
96
+ headers: { "content-type": "application/json" },
97
+ body: JSON.stringify({ client_name: "Ravox Conector", redirect_uris: [volta] }),
98
+ }).then((r) => r.json());
99
+ if (!reg.client_id) throw new Error(reg.error_description ?? "registro recusado");
100
+
101
+ const link = new URL(`${site}/oauth/autorizar`);
102
+ for (const [k, v] of Object.entries({
103
+ response_type: "code",
104
+ client_id: reg.client_id,
105
+ redirect_uri: volta,
106
+ code_challenge: desafio,
107
+ code_challenge_method: "S256",
108
+ state: estado,
109
+ resource: `${site}/api/mcp`,
110
+ }))
111
+ link.searchParams.set(k, v);
112
+ console.log(`${t.abrindo}\n ${link}\n`);
113
+ abrirNavegador(link.toString());
114
+ console.log(t.esperando);
115
+
116
+ const { params: p, res } = await chegou;
117
+ const voltar = (ok) => res.writeHead(302, { location: `${site}/conector/pronto${ok ? "" : "?erro=1"}` }).end();
118
+ if (p.get("error") || p.get("state") !== estado || !p.get("code")) {
119
+ voltar(false);
120
+ throw new Error(t.recusado);
121
+ }
122
+ const tok = await fetch(`${site}/api/oauth/token`, {
123
+ method: "POST",
124
+ headers: { "content-type": "application/x-www-form-urlencoded" },
125
+ body: new URLSearchParams({ grant_type: "authorization_code", code: p.get("code"), code_verifier: verificador, client_id: reg.client_id, redirect_uri: volta }),
126
+ }).then((r) => r.json());
127
+ if (!tok.access_token) {
128
+ voltar(false);
129
+ throw new Error(tok.error_description ?? "token recusado");
130
+ }
131
+ salvarToken(site, tok.access_token);
132
+ voltar(true);
133
+ return tok.access_token;
134
+ } finally {
135
+ servidor.close();
136
+ }
137
+ }
138
+
139
+ /* ---------------------------------------------------------------- the site's MCP */
140
+
141
+ export class SemAcesso extends Error {}
142
+
143
+ export async function chamar(site, token, nome, args = {}) {
144
+ const r = await fetch(`${site}/api/mcp`, {
145
+ method: "POST",
146
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json", accept: "application/json, text/event-stream" },
147
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: nome === "tools/list" ? "tools/list" : "tools/call", params: nome === "tools/list" ? {} : { name: nome, arguments: args } }),
148
+ });
149
+ if (r.status === 401) throw new SemAcesso();
150
+ const j = await r.json();
151
+ if (j.error) throw new Error(j.error.message);
152
+ if (nome === "tools/list") return j.result.tools.map((f) => f.name);
153
+ if (j.result.isError) throw new Error(j.result.content?.[0]?.text ?? "erro");
154
+ return j.result.structuredContent;
155
+ }
156
+
157
+ /* ---------------------------------------------------------------- one request → claude -p */
158
+
159
+ const SISTEMA = `Você atende pedidos escritos no editor do site Ravox UI pela dona ou dono da conta. Trabalhe só com as ferramentas do Ravox (mcp__ravox__* e, quando houver, mcp__local__*, que rodam neste computador): elas gravam direto no editor, que atualiza sozinho na tela da pessoa. Não chame as ferramentas responder_pedido_*: quem responde é o conector. Termine com 1 a 3 frases curtas em português do Brasil dizendo o que mudou (sem markdown pesado).`;
160
+
161
+ /** What Claude gets for each request: the request, the target and (for components) the current code and the rules. */
162
+ export function montarPedido(tipo, p, guia, comConversa = false, local = null) {
163
+ if (tipo === "componente") {
164
+ const c = p.componente;
165
+ const antes = comConversa && p.conversaAnterior?.length
166
+ ? `Conversa anterior neste componente (mais antiga primeiro):\n${p.conversaAnterior.map((x) => `- Pessoa: ${x.pedido}\n Você: ${x.resposta ?? "(sem resposta)"}`).join("\n")}`
167
+ : "";
168
+ return [
169
+ antes,
170
+ `Pedido: ${p.texto}`,
171
+ `Componente ${c.id} ("${c.nome || "sem nome"}", ${c.categoria}, ${c.tecnologia}). Arquivo aberto no editor: ${p.arquivoAberto ?? "html"}.`,
172
+ `Código atual:\n<html>\n${c.html}\n</html>${c.tecnologia === "css" ? `\n<css>\n${c.css}\n</css>` : ""}`,
173
+ guia ?? "",
174
+ `Mude só o que o pedido pede. Se for conversa ou pergunta ("oi", "o que dá para fazer?"), só responda, sem gravar nada. Para mudar, grave com salvar_meu_componente (id ${c.id}), mandando o html e/ou o css inteiros; nome, descrição e tags só se o pedido falar deles. Se a pessoa pedir OUTRO componente (\"abre outro\", \"agora um toggle\"), crie com criar_meu_componente (nome, categoria, código) e ponha o link do editor na resposta. O que está aberto já fica salvo sozinho.`,
175
+ ].join("\n\n");
176
+ }
177
+ return [
178
+ `Pedido: ${p.texto}`,
179
+ `Projeto de vídeo ${p.projeto.id} ("${p.projeto.nome}").${p.clipeSelecionado ? ` Clipe selecionado: ${JSON.stringify(p.clipeSelecionado)}.` : ""}${p.cabecote != null ? ` Cabeçote em ${p.cabecote}s.` : ""}`,
180
+ `Leia com ver_projeto_video e edite com editar_projeto_video (operações pontuais).${local?.includes("gerar_voz") ? " Para narração, gere a fala com gerar_voz (neste computador) e ponha o asset que ela devolve no projeto." : ""}`,
181
+ ].join("\n\n");
182
+ }
183
+
184
+ const FERRAMENTAS = {
185
+ componente: ["ver_meu_componente", "salvar_meu_componente", "criar_meu_componente", "buscar_componentes", "ver_componente"],
186
+ video: ["ver_projeto_video", "editar_projeto_video", "salvar_projeto_video", "criar_projeto_video"],
187
+ };
188
+
189
+ /** Reads `claude -p --output-format stream-json` lines: text as it streams, tools in use, the session and the end. */
190
+ export function lerEvento(linha, estado) {
191
+ let e;
192
+ try {
193
+ e = JSON.parse(linha);
194
+ } catch {
195
+ return;
196
+ }
197
+ if (e.session_id) estado.sessao = e.session_id;
198
+ if (e.type === "stream_event" && e.event?.type === "content_block_delta" && e.event.delta?.type === "text_delta") estado.texto += e.event.delta.text;
199
+ else if (e.type === "stream_event" && e.event?.type === "content_block_delta" && e.event.delta?.type === "thinking_delta")
200
+ estado.pensamento = (estado.pensamento ?? "") + e.event.delta.thinking;
201
+ else if (e.type === "stream_event" && e.event?.type === "content_block_start" && estado.texto && !estado.texto.endsWith("\n")) estado.texto += "\n";
202
+ else if (e.type === "assistant") {
203
+ for (const b of e.message?.content ?? [])
204
+ if (b.type === "tool_use") {
205
+ estado.ferramenta = String(b.name).replace(/^mcp__(ravox|local)__/, "");
206
+ estado.etapas = [...(estado.etapas ?? []).map((x) => ({ ...x, status: "feito" })), { texto: etapaDe(estado.ferramenta), status: "fazendo" }];
207
+ // The text before a tool call is Claude explaining what it will do: it goes to the "thinking" block.
208
+ if (estado.texto.trim()) estado.pensamento = `${estado.pensamento ?? ""}${estado.pensamento ? "\n\n" : ""}${estado.texto.trim()}`;
209
+ estado.texto = "";
210
+ }
211
+ } else if (e.type === "result") {
212
+ estado.fim = true;
213
+ estado.erro = e.is_error ? String(e.result ?? e.subtype ?? "erro") : null;
214
+ estado.final = typeof e.result === "string" ? e.result : estado.texto;
215
+ }
216
+ }
217
+
218
+ /** Each Ravox tool Claude uses becomes a step in the chat (the site is in Portuguese). */
219
+ const ETAPA = {
220
+ ver_meu_componente: "Leu o componente",
221
+ salvar_meu_componente: "Gravou no editor",
222
+ criar_meu_componente: "Criou um componente novo",
223
+ buscar_componentes: "Buscou componentes na galeria",
224
+ ver_componente: "Olhou um componente da galeria",
225
+ ver_projeto_video: "Leu o projeto",
226
+ editar_projeto_video: "Editou a linha do tempo",
227
+ salvar_projeto_video: "Gravou a linha do tempo",
228
+ criar_projeto_video: "Criou um projeto",
229
+ gerar_voz: "Gravou uma narração",
230
+ };
231
+ export const etapaDe = (f) => ETAPA[f] ?? `Usou ${f}`;
232
+
233
+ export function rodarClaude({ site, token, prompt, tipo, modelo, aoProgresso, local = null }) {
234
+ // Only the tools this request needs (the whole Ravox MCP made the safety filter flag even a "hi").
235
+ const url = `${site}/api/mcp?so=${FERRAMENTAS[tipo].join(",")}`;
236
+ // The computer's own tools (gerar_voz) only for video requests.
237
+ const locais = tipo === "video" && local?.ferramentas.length ? local.ferramentas : [];
238
+ const servidores = { ravox: { type: "http", url, headers: { Authorization: `Bearer ${token}` } } };
239
+ if (locais.length) servidores.local = { type: "http", url: `${local.base}/mcp` };
240
+ const mcp = JSON.stringify({ mcpServers: servidores });
241
+ const args = [
242
+ "-p",
243
+ prompt,
244
+ "--output-format",
245
+ "stream-json",
246
+ "--verbose",
247
+ "--include-partial-messages",
248
+ "--mcp-config",
249
+ mcp,
250
+ "--strict-mcp-config",
251
+ // Lean run: none of the person's plugins, hooks, skills, CLAUDE.md or other settings (with them a one-line
252
+ // request carried ~200k tokens of context; without, ~3k). The subscription login still works.
253
+ "--setting-sources",
254
+ "",
255
+ "--disable-slash-commands",
256
+ "--tools",
257
+ "",
258
+ "--allowedTools",
259
+ ...FERRAMENTAS[tipo].map((f) => `mcp__ravox__${f}`),
260
+ ...locais.map((f) => `mcp__local__${f}`),
261
+ "--append-system-prompt",
262
+ SISTEMA,
263
+ ...(modelo ? ["--model", modelo] : []),
264
+ ];
265
+ const pasta = mkdtempSync(join(tmpdir(), "ravox-conector-"));
266
+ return new Promise((resolve) => {
267
+ const estado = { texto: "", ferramenta: null, sessao: null, fim: false, erro: null, final: "", etapas: [], pensamento: null };
268
+ const filho = spawn("claude", args, { cwd: pasta, stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
269
+ let resto = "";
270
+ let erroTexto = "";
271
+ const tique = setInterval(() => aoProgresso(estado), PROGRESSO);
272
+ filho.stdout.on("data", (b) => {
273
+ resto += b.toString();
274
+ const linhas = resto.split("\n");
275
+ resto = linhas.pop() ?? "";
276
+ for (const l of linhas) lerEvento(l, estado);
277
+ });
278
+ filho.stderr.on("data", (b) => (erroTexto += b.toString()));
279
+ filho.on("error", (e) => {
280
+ clearInterval(tique);
281
+ resolve({ ...estado, erro: e.message });
282
+ });
283
+ filho.on("close", (codigo) => {
284
+ clearInterval(tique);
285
+ if (resto) lerEvento(resto, estado);
286
+ if (!estado.fim) estado.erro = erroTexto.trim().split("\n").pop() || `claude saiu com código ${codigo}`;
287
+ resolve(estado);
288
+ });
289
+ });
290
+ }
291
+
292
+ /** Claude Code errors in words the chat can show (the site is in Portuguese). */
293
+ export function explicarErro(erro) {
294
+ if (/safeguards flagged|usage policy/i.test(erro))
295
+ return "O filtro de segurança do Claude barrou este pedido (às vezes acontece por engano). Tente escrever de outro jeito.";
296
+ if (/rate limit|usage limit|limit reached|quota/i.test(erro)) return "Você chegou no limite de uso do Claude por agora. Tente de novo mais tarde.";
297
+ if (/not logged in|login|authenticate|\/login/i.test(erro)) return "O Claude Code deste computador não está logado. Rode claude no terminal e entre na sua conta.";
298
+ if (/overloaded|529|timeout|ECONN/i.test(erro)) return "O Claude está sobrecarregado agora. Tente de novo em instantes.";
299
+ return erro.slice(0, 600);
300
+ }
301
+
302
+ /* ---------------------------------------------------------------- the loop */
303
+
304
+ const temClaude = () => new Promise((r) => execFile("claude", ["--version"], { shell: process.platform === "win32" }, (e) => r(!e)));
305
+ const espera = (ms) => new Promise((r) => setTimeout(r, ms));
306
+
307
+ export async function conectar(opts) {
308
+ const site = (opts.site ?? process.env.RAVOX_SITE ?? process.env.RAVOX_REGISTRY ?? "https://ui.ravoxlabs.com").replace(/\/$/, "");
309
+ if (!(await temClaude())) throw new Error(t.semClaude);
310
+ let token = lerConfig()[site]?.token;
311
+ if (!token || opts.login) token = await entrar(site);
312
+
313
+ let tipos;
314
+ try {
315
+ tipos = await chamar(site, token, "tools/list");
316
+ } catch (e) {
317
+ if (!(e instanceof SemAcesso)) throw e;
318
+ console.log(t.sessaoVencida);
319
+ token = await entrar(site);
320
+ tipos = await chamar(site, token, "tools/list");
321
+ }
322
+ const filas = [["componente", "pedidos_componente", "responder_pedido_componente"], ["video", "pedidos_video", "responder_pedido_video"]].filter(([, f]) => tipos.includes(f));
323
+ const local = await servidorLocal(new URL(site).origin).catch(() => null);
324
+ console.log(t.ligado(site));
325
+
326
+ // Every request is a fresh, small session: the last exchanges of that component go in the prompt instead of
327
+ // resuming the session (a resumed one piles up every version of the code, costs more and trips the safety filter).
328
+ let parar = false;
329
+ let pausado = false;
330
+ process.once("SIGINT", () => {
331
+ parar = true;
332
+ console.log("");
333
+ process.exit(0);
334
+ });
335
+
336
+ while (!parar) {
337
+ try {
338
+ for (const [tipo, listar, responder] of filas) {
339
+ const r = await chamar(site, token, listar, { conector: true });
340
+ if (!!r.pausado !== pausado) {
341
+ pausado = !!r.pausado;
342
+ console.log(pausado ? t.pausado : t.retomado);
343
+ }
344
+ for (const p of r.pedidos.filter((x) => x.status === "pendente")) {
345
+ // Another connector (another window or computer) may have taken it first.
346
+ const assumiu = await chamar(site, token, responder, { id: p.id, status: "fazendo", assumir: true }).then(() => true, () => false);
347
+ if (!assumiu) continue;
348
+ console.log(t.pedido(p.texto));
349
+ let ultimo = "";
350
+ const aoProgresso = (e) => {
351
+ const dados = { resposta: e.texto.trim().slice(-1900), etapas: e.etapas, pensamento: (e.pensamento ?? "").slice(-7900) };
352
+ const chave = JSON.stringify(dados);
353
+ if (chave === ultimo) return;
354
+ ultimo = chave;
355
+ chamar(site, token, responder, { id: p.id, status: "fazendo", ...dados }).catch(() => {});
356
+ };
357
+ const rodar = () => rodarClaude({ site, token, prompt: montarPedido(tipo, p, r.guia, true, local?.ferramentas), tipo, modelo: opts.model ?? "sonnet", aoProgresso, local });
358
+ let fim = await rodar();
359
+ // The safety filter sometimes flags a normal request by mistake: one more try before giving up.
360
+ if (fim.erro && /safeguards flagged/i.test(fim.erro)) fim = await rodar();
361
+ const etapas = fim.etapas.map((x) => ({ ...x, status: "feito" }));
362
+ const pensamento = (fim.pensamento ?? "").slice(-7900);
363
+ if (fim.erro) {
364
+ console.log(t.erro(fim.erro));
365
+ await chamar(site, token, responder, { id: p.id, status: "erro", resposta: explicarErro(fim.erro), etapas, pensamento });
366
+ } else {
367
+ console.log(t.feito);
368
+ await chamar(site, token, responder, { id: p.id, status: "feito", resposta: (fim.final || fim.texto).trim().slice(0, 1990), etapas, pensamento });
369
+ }
370
+ }
371
+ }
372
+ } catch (e) {
373
+ // Revogada com o conector aberto = "Sair deste computador" no site: fecha (não abre o login sozinho).
374
+ if (e instanceof SemAcesso) {
375
+ console.log(t.saiu);
376
+ return 0;
377
+ }
378
+ console.log(t.falhaRede(e instanceof Error ? e.message : e));
379
+ await espera(INTERVALO * 3);
380
+ }
381
+ await espera(INTERVALO);
382
+ }
383
+ return 0;
384
+ }
package/src/local.mjs ADDED
@@ -0,0 +1,293 @@
1
+ // What the connector does on this computer: a tiny server on 127.0.0.1 that
2
+ // - gives the Claude it runs an MCP tool that makes files here (gerar_voz: text → speech);
3
+ // - lets the video editor (only the site's page) list, preview and generate voices and connect Azure;
4
+ // - hands the generated files to the editor, which saves them in the browser (IndexedDB).
5
+ // Nothing goes through the site. Voices: Microsoft's neural voices (Francisca, Antônio, Thalita). Without setup they
6
+ // come from the Edge read-aloud service (`edge-tts`, free, light, no official license for ads); with an Azure key
7
+ // (free tier F0, official, commercial use allowed; kept in ~/.config/ravox-ui/voz.json) the SAME voices come from
8
+ // Azure, with nothing else changing. The macOS voices were dropped (robotic, and Apple's license is personal only).
9
+
10
+ import { execFile } from "node:child_process";
11
+ import { randomBytes } from "node:crypto";
12
+ import { createReadStream, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
13
+ import { createServer } from "node:http";
14
+ import { homedir } from "node:os";
15
+ import { dirname, join } from "node:path";
16
+
17
+ const PASTA = join(homedir(), ".cache", "ravox-ui", "arquivos");
18
+ const CONFIG = join(homedir(), ".config", "ravox-ui", "voz.json");
19
+ export const PORTA = 47811;
20
+ const EXEMPLO = "Oi! Esta é a minha voz. Fica assim na narração do seu vídeo.";
21
+ const TIPOS = { mp3: "audio/mpeg" };
22
+
23
+ const rodar = (cmd, args) =>
24
+ new Promise((ok, erro) => execFile(cmd, args, { timeout: 60000 }, (e, saida, err) => (e ? erro(new Error(String(err || e.message).trim().split("\n").pop())) : ok(saida))));
25
+
26
+ /** The Edge voices in Brazilian Portuguese (the ones Azure also has, same names). */
27
+ const EDGE = [
28
+ { voz: "pt-BR-FranciscaNeural", nome: "Francisca", genero: "feminina" },
29
+ { voz: "pt-BR-AntonioNeural", nome: "Antônio", genero: "masculina" },
30
+ { voz: "pt-BR-ThalitaMultilingualNeural", nome: "Thalita", genero: "feminina" },
31
+ ];
32
+
33
+ /** How to run edge-tts here: installed, or through uv (`uvx` downloads it once, small). Null when neither. */
34
+ let comandoEdge;
35
+ const edgeTts = () =>
36
+ (comandoEdge ??= rodar("edge-tts", ["--help"]).then(
37
+ () => ["edge-tts"],
38
+ () => rodar("uvx", ["--version"]).then(() => ["uvx", "edge-tts"], () => null),
39
+ ));
40
+
41
+ /* ---------------------------------------------------------------- config (Azure key) */
42
+
43
+ function lerConfig() {
44
+ try {
45
+ return JSON.parse(readFileSync(CONFIG, "utf8"));
46
+ } catch {
47
+ return {};
48
+ }
49
+ }
50
+ function gravarConfig(c) {
51
+ mkdirSync(dirname(CONFIG), { recursive: true });
52
+ writeFileSync(CONFIG, JSON.stringify(c, null, 2), { mode: 0o600 });
53
+ }
54
+
55
+ /* ---------------------------------------------------------------- voices */
56
+
57
+ const escapar = (t) => t.replace(/[<>&'"]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", "'": "&apos;", '"': "&quot;" })[c]);
58
+
59
+ let cacheAzure = null;
60
+ /** Azure neural voices that speak Brazilian Portuguese (pt-BR ones first, then multilingual ones). */
61
+ async function vozesAzure(conf = lerConfig().azure) {
62
+ if (!conf?.chave) return [];
63
+ if (cacheAzure?.chave === conf.chave) return cacheAzure.vozes;
64
+ const r = await fetch(`https://${conf.regiao}.tts.speech.microsoft.com/cognitiveservices/voices/list`, { headers: { "Ocp-Apim-Subscription-Key": conf.chave } });
65
+ if (r.status === 401 || r.status === 403) throw new Error("A chave da Azure foi recusada (confira a chave e a região).");
66
+ if (!r.ok) throw new Error(`A Azure respondeu ${r.status}.`);
67
+ const lista = await r.json();
68
+ const vozes = lista
69
+ .filter((v) => v.Locale === "pt-BR" || (v.SecondaryLocaleList ?? []).includes("pt-BR"))
70
+ .sort((a, b) => Number(b.Locale === "pt-BR") - Number(a.Locale === "pt-BR") || a.LocalName.localeCompare(b.LocalName))
71
+ .map((v) => ({
72
+ id: `azure:${v.ShortName}`,
73
+ nome: v.LocalName,
74
+ genero: v.Gender === "Female" ? "feminina" : v.Gender === "Male" ? "masculina" : null,
75
+ provedor: "azure",
76
+ descricao: v.Locale === "pt-BR" ? "Azure · pt-BR" : "Azure · multilíngue",
77
+ }));
78
+ cacheAzure = { chave: conf.chave, vozes };
79
+ return vozes;
80
+ }
81
+
82
+ export async function listarVozes() {
83
+ const conf = lerConfig().azure;
84
+ let azure = [];
85
+ let erroAzure = null;
86
+ try {
87
+ azure = await vozesAzure(conf);
88
+ } catch (e) {
89
+ erroAzure = e.message;
90
+ }
91
+ // Com a Azure ligada, as vozes vêm dela (oficial); sem, as mesmas vozes pelo Edge.
92
+ const edge = !azure.length && (await edgeTts()) ? EDGE.map((v) => ({ id: `edge:${v.voz}`, nome: v.nome, genero: v.genero, provedor: "edge", descricao: "Voz neural da Microsoft" })) : [];
93
+ return { azure: { ligado: !!conf?.chave, regiao: conf?.regiao ?? null, erro: erroAzure }, vozes: [...azure, ...edge] };
94
+ }
95
+
96
+ /** Saves the Azure key after checking it lists voices (so a typo shows up right away). */
97
+ export async function ligarAzure({ chave, regiao }) {
98
+ chave = String(chave ?? "").trim();
99
+ regiao = String(regiao ?? "").trim().toLowerCase();
100
+ if (!/^[\w-]{16,128}$/.test(chave) || !/^[a-z0-9]{3,30}$/.test(regiao)) throw new Error("Chave ou região inválida.");
101
+ cacheAzure = null;
102
+ await vozesAzure({ chave, regiao });
103
+ gravarConfig({ ...lerConfig(), azure: { chave, regiao } });
104
+ return listarVozes();
105
+ }
106
+ export function desligarAzure() {
107
+ const c = lerConfig();
108
+ delete c.azure;
109
+ gravarConfig(c);
110
+ cacheAzure = null;
111
+ }
112
+
113
+ const padrao = async () => {
114
+ const { vozes } = await listarVozes();
115
+ return vozes[0]?.id ?? null;
116
+ };
117
+
118
+ /** Text → MP3 in the cache folder (Azure); returns the file name and the length (s). */
119
+ export async function gerarVoz({ texto, voz, velocidade = 1 }) {
120
+ texto = String(texto ?? "").trim().slice(0, 3000);
121
+ if (!texto) throw new Error("texto vazio");
122
+ voz ||= await padrao();
123
+ if (!voz) throw new Error("Nenhuma voz ligada. Ligue a Azure no editor (aba Voz).");
124
+ const v = Math.min(1.6, Math.max(0.6, Number(velocidade) || 1));
125
+ mkdirSync(PASTA, { recursive: true });
126
+ const id = `voz-${randomBytes(5).toString("hex")}`;
127
+ let [provedor, nome] = [voz.slice(0, voz.indexOf(":")), voz.slice(voz.indexOf(":") + 1)];
128
+ const taxa = `${v >= 1 ? "+" : ""}${Math.round((v - 1) * 100)}%`;
129
+ // A mesma voz pelo caminho oficial quando a Azure está ligada.
130
+ if (provedor === "edge" && lerConfig().azure?.chave) provedor = "azure";
131
+
132
+ if (provedor === "edge") {
133
+ const cmd = await edgeTts();
134
+ if (!cmd) throw new Error("Para gerar voz, instale o uv (https://docs.astral.sh/uv) ou ligue a Azure na aba Voz.");
135
+ const arquivo = join(PASTA, `${id}.mp3`);
136
+ await rodar(cmd[0], [...cmd.slice(1), "--voice", nome, `--rate=${taxa}`, `--text=${texto}`, "--write-media", arquivo]).catch((e) => {
137
+ throw new Error(`Não deu para gerar a voz agora (${e.message.slice(0, 120)}). Tente de novo em instantes.`);
138
+ });
139
+ // MP3 de taxa constante (48 kbit/s): a duração sai do tamanho.
140
+ return { id, arquivo: `${id}.mp3`, duracao: Math.round(((statSync(arquivo).size * 8) / 48000) * 100) / 100 };
141
+ }
142
+
143
+ if (provedor === "azure") {
144
+ const conf = lerConfig().azure;
145
+ if (!conf?.chave) throw new Error("A Azure não está ligada neste computador (aba Voz do editor).");
146
+ const ssml = `<speak version="1.0" xml:lang="pt-BR"><voice name="${escapar(nome)}"><lang xml:lang="pt-BR"><prosody rate="${taxa}">${escapar(texto)}</prosody></lang></voice></speak>`;
147
+ const r = await fetch(`https://${conf.regiao}.tts.speech.microsoft.com/cognitiveservices/v1`, {
148
+ method: "POST",
149
+ headers: {
150
+ "Ocp-Apim-Subscription-Key": conf.chave,
151
+ "Content-Type": "application/ssml+xml",
152
+ "X-Microsoft-OutputFormat": "audio-48khz-192kbitrate-mono-mp3",
153
+ "User-Agent": "ravox-conector",
154
+ },
155
+ body: ssml,
156
+ });
157
+ if (r.status === 429) throw new Error("A Azure pediu para esperar um pouco (limite do plano grátis por minuto).");
158
+ if (!r.ok) throw new Error(`A Azure recusou (${r.status}). ${r.status === 401 ? "Confira a chave." : ""}`.trim());
159
+ const dados = Buffer.from(await r.arrayBuffer());
160
+ writeFileSync(join(PASTA, `${id}.mp3`), dados);
161
+ // MP3 de taxa constante (192 kbit/s): a duração sai do tamanho.
162
+ return { id, arquivo: `${id}.mp3`, duracao: Math.round(((dados.length * 8) / 192000) * 100) / 100 };
163
+ }
164
+
165
+ throw new Error(`Voz desconhecida: ${voz}`);
166
+ }
167
+
168
+ const resumo = (t) => (t.length > 40 ? `${t.slice(0, 40).trim()}…` : t);
169
+ const assetDe = (v, texto, base) => ({ id: v.id, nome: `Voz: ${resumo(texto)}`, tipo: "audio", duracao: v.duracao, url: `${base}/arquivos/${v.arquivo}` });
170
+
171
+ /* ---------------------------------------------------------------- MCP tools for Claude */
172
+
173
+ async function ferramentasLocais() {
174
+ const { vozes } = await listarVozes();
175
+ // Sem a Azure ligada não há voz: o Claude avisa (pela descrição de ver_projeto_video) que é preciso ligar na aba Voz.
176
+ if (!vozes.length) return [];
177
+ return [
178
+ {
179
+ name: "gerar_voz",
180
+ description: `Gera narração em português do Brasil neste computador e devolve o asset pronto para o projeto de vídeo: ponha com editar_projeto_video { op: "asset", asset } e um clipe de áudio com a duração dele. Use a voz e a velocidade do projeto (campo voz) se houver; se a pessoa pedir voz feminina ou masculina, escolha pelo gênero (${vozes.map((v) => `${v.id} = ${v.nome}, ${v.genero}`).join("; ")}) e grave a escolha com { op: "voz" }. Uma fala por trecho (até ~2 frases).`,
181
+ inputSchema: {
182
+ type: "object",
183
+ properties: {
184
+ texto: { type: "string", maxLength: 3000, description: "o que a voz fala" },
185
+ voz: { type: "string", enum: vozes.map((v) => v.id), description: `padrão ${vozes[0].id}` },
186
+ velocidade: { type: "number", minimum: 0.6, maximum: 1.6, description: "1 = normal; 0.9 mais calma; 1.15 mais rápida" },
187
+ },
188
+ required: ["texto"],
189
+ },
190
+ async executar(args, base) {
191
+ const v = await gerarVoz(args);
192
+ return { asset: assetDe(v, String(args.texto), base) };
193
+ },
194
+ },
195
+ ];
196
+ }
197
+
198
+ /* ---------------------------------------------------------------- the server */
199
+
200
+ /** Starts the local server. `origem` = the site allowed to use it from the browser. */
201
+ export async function servidorLocal(origem) {
202
+ let base = "";
203
+ const cors = { "access-control-allow-origin": origem, "access-control-allow-private-network": "true", vary: "origin" };
204
+ const json = (res, status, corpo, extra = {}) => res.writeHead(status, { "content-type": "application/json", ...extra }).end(JSON.stringify(corpo));
205
+ const lerCorpo = async (req) => {
206
+ let corpo = "";
207
+ for await (const b of req) {
208
+ corpo += b;
209
+ if (corpo.length > 20000) throw new Error("grande demais");
210
+ }
211
+ return corpo ? JSON.parse(corpo) : {};
212
+ };
213
+
214
+ async function mcp(req, res) {
215
+ let m;
216
+ try {
217
+ m = await lerCorpo(req);
218
+ } catch {
219
+ return json(res, 400, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
220
+ }
221
+ if (m.id === undefined) return res.writeHead(202).end();
222
+ const ok = (result) => json(res, 200, { jsonrpc: "2.0", id: m.id, result });
223
+ if (m.method === "initialize")
224
+ return ok({ protocolVersion: m.params?.protocolVersion ?? "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "ravox-local", version: "2" } });
225
+ if (m.method === "ping") return ok({});
226
+ const ferramentas = await ferramentasLocais();
227
+ if (m.method === "tools/list") return ok({ tools: ferramentas.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
228
+ if (m.method === "tools/call") {
229
+ const f = ferramentas.find((x) => x.name === m.params?.name);
230
+ if (!f) return json(res, 200, { jsonrpc: "2.0", id: m.id, error: { code: -32602, message: "ferramenta desconhecida" } });
231
+ try {
232
+ const saida = await f.executar(m.params.arguments ?? {}, base);
233
+ return ok({ content: [{ type: "text", text: JSON.stringify(saida) }], structuredContent: saida });
234
+ } catch (e) {
235
+ return ok({ content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }], isError: true });
236
+ }
237
+ }
238
+ return json(res, 200, { jsonrpc: "2.0", id: m.id, error: { code: -32601, message: "método desconhecido" } });
239
+ }
240
+
241
+ /** What the editor page uses (only from the site: the Origin is checked, not just CORS). */
242
+ async function editor(req, res, caminho) {
243
+ if (req.method === "OPTIONS") return res.writeHead(204, { ...cors, "access-control-allow-methods": "GET, POST, DELETE", "access-control-allow-headers": "content-type" }).end();
244
+ if (req.headers.origin !== origem) return res.writeHead(403).end();
245
+ try {
246
+ if (caminho === "/voz" && req.method === "GET") return json(res, 200, await listarVozes(), cors);
247
+ if (caminho === "/voz/azure" && req.method === "POST") return json(res, 200, await ligarAzure(await lerCorpo(req)), cors);
248
+ if (caminho === "/voz/azure" && req.method === "DELETE") {
249
+ desligarAzure();
250
+ return json(res, 200, await listarVozes(), cors);
251
+ }
252
+ if (caminho === "/voz/ouvir" && req.method === "POST") {
253
+ const corpo = await lerCorpo(req);
254
+ const v = await gerarVoz({ ...corpo, texto: corpo.texto || EXEMPLO });
255
+ const arquivo = join(PASTA, v.arquivo);
256
+ const dados = readFileSync(arquivo);
257
+ unlinkSync(arquivo);
258
+ return res.writeHead(200, { ...cors, "content-type": TIPOS[v.arquivo.split(".").pop()] }).end(dados);
259
+ }
260
+ if (caminho === "/voz/gerar" && req.method === "POST") {
261
+ const corpo = await lerCorpo(req);
262
+ const v = await gerarVoz(corpo);
263
+ return json(res, 200, { asset: assetDe(v, String(corpo.texto), base) }, cors);
264
+ }
265
+ } catch (e) {
266
+ return json(res, 400, { erro: e instanceof Error ? e.message : String(e) }, cors);
267
+ }
268
+ return res.writeHead(404, cors).end();
269
+ }
270
+
271
+ const servidor = createServer((req, res) => {
272
+ const u = new URL(req.url ?? "/", "http://127.0.0.1");
273
+ if (u.pathname.startsWith("/arquivos/")) {
274
+ if (req.method === "OPTIONS") return res.writeHead(204, { ...cors, "access-control-allow-methods": "GET" }).end();
275
+ const nome = u.pathname.slice("/arquivos/".length);
276
+ const caminho = join(PASTA, nome);
277
+ if (!/^[\w.-]+$/.test(nome) || !existsSync(caminho)) return res.writeHead(404, cors).end();
278
+ res.writeHead(200, { ...cors, "content-type": TIPOS[nome.split(".").pop()] ?? "application/octet-stream", "content-length": statSync(caminho).size });
279
+ return createReadStream(caminho).pipe(res);
280
+ }
281
+ if (u.pathname.startsWith("/voz")) return void editor(req, res, u.pathname);
282
+ // MCP only for local processes (Claude Code sends no Origin; a web page would).
283
+ if (u.pathname === "/mcp" && req.method === "POST" && !req.headers.origin) return void mcp(req, res);
284
+ res.writeHead(404).end();
285
+ });
286
+ await new Promise((ok) => {
287
+ servidor.once("error", () => servidor.listen(0, "127.0.0.1", ok));
288
+ servidor.listen(PORTA, "127.0.0.1", ok);
289
+ });
290
+ base = `http://127.0.0.1:${servidor.address().port}`;
291
+ // Always allowed: the tool only shows up for Claude when there is a voice (connecting Azure later just works).
292
+ return { base, ferramentas: ["gerar_voz"], fechar: () => servidor.close() };
293
+ }
package/src/messages.mjs CHANGED
@@ -10,13 +10,17 @@ Usage
10
10
  ravox add <name...> add components or blocks (ravox add button dialog login-01)
11
11
  ravox add --all add every component
12
12
  ravox list list components and blocks
13
+ ravox connect answer the site editors' chat with your own Claude Code (keep it open)
13
14
 
14
15
  Options
15
16
  -f, --framework <name> react, vue, svelte or angular (detected from package.json)
16
17
  -c, --cwd <dir> project folder (default: current folder)
17
- -o, --overwrite overwrite existing files
18
+ -o, --overwrite overwrite existing files and theme variables
18
19
  -y, --yes skip confirmation prompts
19
20
  --dry-run show what would run, change nothing
21
+ --site <url> site for connect (default: https://ui.ravoxlabs.com)
22
+ --login connect: sign in again
23
+ --model <name> connect: Claude model (default: sonnet)
20
24
  -v, --version print the version
21
25
 
22
26
  Docs: https://ui.ravoxlabs.com/en/ui`,
@@ -27,6 +31,8 @@ Docs: https://ui.ravoxlabs.com/en/ui`,
27
31
  blocksReactOnly: "Blocks are React only for now.",
28
32
  adding: (n, fw) => `Adding ${n} ${n === 1 ? "item" : "items"} to your ${fw} project…`,
29
33
  initReact: "Setting up the project and the Ravox theme…",
34
+ wroteConfig: (css) => `Created components.json (theme in ${css}).`,
35
+ keptVariables: (n) => `Kept ${n} CSS ${n === 1 ? "variable" : "variables"} you already had (only the missing ones were added; --overwrite takes the Ravox values).`,
30
36
  initOther: (fw) => `Adding the cn() helper and the Ravox theme to your ${fw} project…`,
31
37
  importTheme: 'Import the theme once in your main CSS, after Tailwind: @import "./ravox-tema.css";',
32
38
  needInit: "No components.json here yet: running ravox init first.",
@@ -48,13 +54,17 @@ Uso
48
54
  ravox add <nome...> adiciona componentes ou blocos (ravox add button dialog login-01)
49
55
  ravox add --all adiciona todos os componentes
50
56
  ravox list lista componentes e blocos
57
+ ravox conectar responde o chat dos editores do site com o seu Claude Code (deixe aberto)
51
58
 
52
59
  Opções
53
60
  -f, --framework <nome> react, vue, svelte ou angular (detectado pelo package.json)
54
61
  -c, --cwd <pasta> pasta do projeto (padrão: a pasta atual)
55
- -o, --overwrite sobrescreve arquivos que já existem
62
+ -o, --overwrite sobrescreve arquivos e variáveis do tema que já existem
56
63
  -y, --yes pula as confirmações
57
64
  --dry-run mostra o que rodaria, sem mudar nada
65
+ --site <url> site do conectar (padrão: https://ui.ravoxlabs.com)
66
+ --login conectar: entrar de novo
67
+ --model <nome> conectar: modelo do Claude (padrão: sonnet)
58
68
  -v, --version mostra a versão
59
69
 
60
70
  Documentação: https://ui.ravoxlabs.com/ui`,
@@ -65,6 +75,8 @@ Documentação: https://ui.ravoxlabs.com/ui`,
65
75
  blocksReactOnly: "Por enquanto os blocos são só para React.",
66
76
  adding: (n, fw) => `Adicionando ${n} ${n === 1 ? "item" : "itens"} ao seu projeto ${fw}…`,
67
77
  initReact: "Preparando o projeto e o tema do Ravox…",
78
+ wroteConfig: (css) => `components.json criado (tema em ${css}).`,
79
+ keptVariables: (n) => `${n === 1 ? "Mantive 1 variável" : `Mantive ${n} variáveis`} de CSS que você já tinha (só as que faltavam entraram; --overwrite usa os valores do Ravox).`,
68
80
  initOther: (fw) => `Adicionando a função cn() e o tema do Ravox ao seu projeto ${fw}…`,
69
81
  importTheme: 'Importe o tema uma vez no CSS principal, depois do Tailwind: @import "./ravox-tema.css";',
70
82
  needInit: "Ainda não há components.json aqui: rodando o ravox init antes.",
package/src/project.mjs CHANGED
@@ -42,3 +42,53 @@ export function runnerFor(userAgent = "") {
42
42
 
43
43
  /** A React project already set up for shadcn (has components.json). */
44
44
  export const hasComponentsJson = (cwd) => existsSync(join(cwd, "components.json"));
45
+
46
+ /** The main CSS file of a React project (where Tailwind is imported), relative to the project. */
47
+ const CSS_FILES = [
48
+ "src/app/globals.css",
49
+ "app/globals.css",
50
+ "src/styles/globals.css",
51
+ "styles/globals.css",
52
+ "src/index.css",
53
+ "src/styles.css",
54
+ "src/app.css",
55
+ "app/app.css",
56
+ "resources/css/app.css",
57
+ ];
58
+
59
+ /** The import alias from tsconfig paths ("@/*": ["./src/*"] → "@"), or null. Comments in tsconfig are fine. */
60
+ export function aliasFrom(tsconfigText) {
61
+ const m = /"([^"*]+)\/\*"\s*:\s*\[\s*"(?:\.\/)?(?:src\/)?\*"/.exec(tsconfigText ?? "");
62
+ return m ? m[1] : null;
63
+ }
64
+
65
+ /**
66
+ * components.json for a React project with Tailwind 4, written by us instead of `shadcn init`: init always installs a
67
+ * base library (Radix, Base UI or React Aria) and the Ravox Kit uses none. Returns null when the project is not
68
+ * clear enough (no Tailwind 4, no CSS file, no alias): then `shadcn init` asks the user.
69
+ */
70
+ export function reactConfig(cwd, pkg) {
71
+ const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
72
+ const tailwind = String(deps.tailwindcss ?? "");
73
+ if (!/^[\^~]?4/.test(tailwind) && !deps["@tailwindcss/vite"] && !deps["@tailwindcss/postcss"]) return null;
74
+ const css = CSS_FILES.find((f) => existsSync(join(cwd, f)));
75
+ if (!css) return null;
76
+ const tsconfig = ["tsconfig.json", "tsconfig.app.json"].map((f) => join(cwd, f)).filter((f) => existsSync(f));
77
+ const alias = tsconfig.map((f) => aliasFrom(readFileSync(f, "utf8"))).find(Boolean);
78
+ if (!alias) return null;
79
+ return {
80
+ $schema: "https://ui.shadcn.com/schema.json",
81
+ style: "new-york",
82
+ rsc: Boolean(deps.next),
83
+ tsx: tsconfig.length > 0,
84
+ tailwind: { config: "", css, baseColor: "neutral", cssVariables: true, prefix: "" },
85
+ iconLibrary: "lucide",
86
+ aliases: {
87
+ components: `${alias}/components`,
88
+ utils: `${alias}/lib/utils`,
89
+ ui: `${alias}/components/ui`,
90
+ lib: `${alias}/lib`,
91
+ hooks: `${alias}/hooks`,
92
+ },
93
+ };
94
+ }
package/src/theme.mjs ADDED
@@ -0,0 +1,80 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * The theme is installed by `shadcn add vault-tema.json`, which writes its CSS variables into the project's main CSS
6
+ * and replaces the ones with the same name. A project that already has its colors keeps them: after shadcn runs,
7
+ * every variable that existed before goes back to its old value, so only the missing ones are added.
8
+ */
9
+
10
+ /** Every `--name: value` with the selector path it lives in (`@layer base > :root`), in order. */
11
+ export function cssVariables(css) {
12
+ const found = [];
13
+ const stack = [];
14
+ let start = 0;
15
+ let i = 0;
16
+ while (i < css.length) {
17
+ const ch = css[i];
18
+ if (ch === "/" && css[i + 1] === "*") {
19
+ const end = css.indexOf("*/", i + 2);
20
+ i = end < 0 ? css.length : end + 2;
21
+ start = i;
22
+ continue;
23
+ }
24
+ if (ch === '"' || ch === "'") {
25
+ const end = css.indexOf(ch, i + 1);
26
+ i = end < 0 ? css.length : end + 1;
27
+ continue;
28
+ }
29
+ if (ch === "{") {
30
+ stack.push(css.slice(start, i).trim().replace(/\s+/g, " "));
31
+ start = i + 1;
32
+ } else if (ch === "}" || ch === ";") {
33
+ const decl = css.slice(start, i);
34
+ const m = /^(\s*)(--[\w-]+)(\s*:\s*)([\s\S]*?)\s*$/.exec(decl);
35
+ if (m) {
36
+ const valueStart = start + m[1].length + m[2].length + m[3].length;
37
+ found.push({ path: stack.join(" > "), name: m[2], value: m[4], start: valueStart, end: valueStart + m[4].length });
38
+ }
39
+ if (ch === "}") stack.pop();
40
+ start = i + 1;
41
+ }
42
+ i++;
43
+ }
44
+ return found;
45
+ }
46
+
47
+ /** `after` with the variables that already existed in `before` (same block, same name) back to their old value. */
48
+ export function keepExistingVariables(before, after) {
49
+ const old = new Map(cssVariables(before).map((v) => [`${v.path}|${v.name}`, v.value]));
50
+ let css = after;
51
+ let kept = 0;
52
+ for (const v of cssVariables(after).reverse()) {
53
+ const previous = old.get(`${v.path}|${v.name}`);
54
+ if (previous === undefined || previous === v.value) continue;
55
+ css = css.slice(0, v.start) + previous + css.slice(v.end);
56
+ kept++;
57
+ }
58
+ return { css, kept };
59
+ }
60
+
61
+ /** The main CSS file named in components.json, if it exists. */
62
+ export function mainCss(cwd) {
63
+ try {
64
+ const css = JSON.parse(readFileSync(join(cwd, "components.json"), "utf8"))?.tailwind?.css;
65
+ return css && existsSync(join(cwd, css)) ? join(cwd, css) : null;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /** Runs `install` (which writes the theme) and then restores the variables the project already had. */
72
+ export async function keepTheme(cwd, install) {
73
+ const file = mainCss(cwd);
74
+ const before = file ? readFileSync(file, "utf8") : null;
75
+ const code = await install();
76
+ if (!file || before === null || !existsSync(file)) return { code, kept: 0 };
77
+ const { css, kept } = keepExistingVariables(before, readFileSync(file, "utf8"));
78
+ if (kept) writeFileSync(file, css);
79
+ return { code, kept };
80
+ }