omnispace 0.15.0 → 0.16.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.
@@ -0,0 +1,96 @@
1
+ import { resolve } from "node:path";
2
+ import { createInterface } from "node:readline/promises";
3
+ import { callApi } from "./api.js";
4
+ import { loadToken, readSpaceConfig } from "./config.js";
5
+ import { terminalSafe } from "./ask.js";
6
+ // `omnispace confirmar [pedido] [pasta]` (ADR 009, item 5 da fatia 1 do MCP): a pessoa vê o pedido
7
+ // pendente inteiro (0033) e decide s/n no terminal. Sem ferramenta de confirmar pelo MCP — um pedido
8
+ // registrado por `omni_pedir` só sai do estado "pendente" por aqui, inclusive de dentro do Claude
9
+ // Code com `! omnispace confirmar`.
10
+ export const CONFIRMAR_USAGE = "Uso: omnispace confirmar [pedido] [pasta]";
11
+ async function askTerminal(question) {
12
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
13
+ try {
14
+ return await rl.question(question);
15
+ }
16
+ finally {
17
+ rl.close();
18
+ }
19
+ }
20
+ const hhmm = (iso) => {
21
+ const d = new Date(iso);
22
+ return Number.isNaN(d.getTime()) ? iso : d.toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" });
23
+ };
24
+ function formatPendingRow(p) {
25
+ const out = [`Pedido ${terminalSafe(p.ref)}, aguardando decisão (vale até ${hhmm(p.expiresAt)}):`, ` ${terminalSafe(p.connectorId)}: ${terminalSafe(p.actionKey)}`];
26
+ const input = p.input && typeof p.input === "object" && !Array.isArray(p.input) ? p.input : { entrada: p.input };
27
+ for (const [key, value] of Object.entries(input)) {
28
+ const line = typeof value === "string" ? value : JSON.stringify(value);
29
+ out.push(` ${terminalSafe(key)}: ${terminalSafe(line ?? "")}`);
30
+ }
31
+ if (Object.keys(input).length === 0)
32
+ out.push(" (sem entrada)");
33
+ return out;
34
+ }
35
+ export async function confirmarSpace(args, opts = {}) {
36
+ const folder = resolve(args.folder);
37
+ const config = await readSpaceConfig(folder);
38
+ if (!config?.spaceId) {
39
+ console.log(`\n${folder} não tem um espaço Omni com conta criada. Rode de dentro da pasta do espaço.\n`);
40
+ return 1;
41
+ }
42
+ const token = await loadToken(config.apiUrl);
43
+ if (!token) {
44
+ console.log(`\nNenhuma sessão da CLI nesta máquina para ${config.apiUrl}. Rode: omnispace login\n`);
45
+ return 1;
46
+ }
47
+ const list = await callApi(config.apiUrl, token, "/api/cli/actions/pending", { spaceId: config.spaceId });
48
+ if (!list.ok) {
49
+ console.log(`\nNão foi possível listar os pedidos: ${terminalSafe(String(list.error))}\n`);
50
+ return 1;
51
+ }
52
+ const pending = list.pending ?? [];
53
+ if (pending.length === 0) {
54
+ console.log("\nNenhum pedido em aberto neste espaço.\n");
55
+ return 0;
56
+ }
57
+ const targets = args.pedido ? pending.filter((p) => p.ref === args.pedido || p.id === args.pedido) : pending;
58
+ if (args.pedido && targets.length === 0) {
59
+ console.log(`\nPedido "${args.pedido}" não encontrado (decidido ou vencido). Em aberto:`);
60
+ for (const p of pending)
61
+ console.log(` ${p.ref} ${p.connectorId}: ${p.actionKey}`);
62
+ console.log("");
63
+ return 1;
64
+ }
65
+ const interactive = opts.interactive ?? Boolean(process.stdin.isTTY);
66
+ const ask = opts.ask ?? askTerminal;
67
+ let failed = false;
68
+ for (const p of targets) {
69
+ console.log("");
70
+ for (const line of formatPendingRow(p))
71
+ console.log(line);
72
+ if (!interactive) {
73
+ console.log(" Sem terminal para confirmar. Rode de novo num terminal.");
74
+ failed = true;
75
+ continue;
76
+ }
77
+ const answer = (await ask(" Executar? [s/n] ")).trim().toLowerCase();
78
+ const decision = answer === "s" || answer === "sim" ? "s" : "n";
79
+ const res = await callApi(config.apiUrl, token, "/api/cli/actions/confirm", { spaceId: config.spaceId, requestId: p.id, decision });
80
+ if (decision === "n") {
81
+ console.log(res.ok ? " Recusado. Nada foi executado." : ` ${terminalSafe(String(res.error))}`);
82
+ continue;
83
+ }
84
+ if (res.ok) {
85
+ console.log(" ✓ Executado.");
86
+ if (typeof res.output === "string")
87
+ console.log(` Resultado: ${terminalSafe(res.output)}`);
88
+ }
89
+ else {
90
+ console.log(` ✗ Não executado: ${terminalSafe(String(res.error))}`);
91
+ failed = true;
92
+ }
93
+ }
94
+ console.log("");
95
+ return failed ? 1 : 0;
96
+ }
package/dist/include.js CHANGED
@@ -70,9 +70,10 @@ export function isSecret(name) {
70
70
  * Settings of a tool on this machine that happen to live in the folder: Claude Code keeps
71
71
  * permissions and local choices in `.claude/settings.local.json` (the shared `settings.json`
72
72
  * does sync). Out of the sync since 2026-09-27 (Lucas, P1 fatia 2); it can carry values
73
- * specific to one machine.
73
+ * specific to one machine. Same for the MCP config of each editor (`.mcp.json`, `.cursor/`, `.vscode/`, `.trae/`):
74
+ * it holds this machine's command (`cmd /c omni` on Windows), so each machine runs `omnispace mcp instalar`.
74
75
  */
75
- const MACHINE_CONFIG_RE = /(^|\/)\.claude\/settings\.local\.json$/i;
76
+ const MACHINE_CONFIG_RE = /(^|\/)(\.claude\/settings\.local\.json|\.mcp\.json|\.(cursor|vscode|trae)\/mcp\.json|\.zed\/settings\.json|\.codex\/config\.toml)$/i;
76
77
  export function isMachineConfig(path) {
77
78
  return MACHINE_CONFIG_RE.test(path);
78
79
  }
package/dist/index.js CHANGED
@@ -21,33 +21,38 @@ import { runEnv } from "./env.js";
21
21
  import { runPermissoes } from "./permissoes.js";
22
22
  import { runUso } from "./uso.js";
23
23
  import { runLlm } from "./llm.js";
24
- const USAGE = `
25
- Omni CLI
26
-
27
- Uso:
28
- omni login entra na sua conta pelo navegador (a sessão vale 14 dias sem uso)
29
- omni logout encerra a sessão da CLI nesta máquina
30
- omni whoami mostra a conta, a máquina e até quando a sessão vale
31
- omni init [pasta] [--nome <nome>] com a CLI logada, cria o espaço na sua conta e liga a pasta
32
- omni init [pasta] --scan-only varre a pasta e mostra o que encontrou
33
- omni init [pasta] --link <código> varre a pasta e envia pro código mostrado no navegador
34
- omni status [pasta] [--resumo] o que mudou aqui e no servidor, e o próximo comando (só lê)
35
- omni ask "<pergunta>" [pasta] responde com base na versão publicada do espaço, com as fontes
36
- [--fontes] só mostra quais arquivos seriam lidos, sem chamar o modelo
37
- [--detalhes] mostra também custo, contexto e arquivos consultados
38
- omni sync [pasta] --push [--yes] publica uma nova versão do espaço no servidor
39
- omni sync [pasta] --pull traz para a pasta a versão mais nova do servidor
40
- [--conflito=local|mesclar|backup] mesma resposta para todos os conflitos, sem perguntar
41
- omni clone <código> <pasta> baixa um espaço da sua conta numa pasta nova desta máquina
42
- omni vincular [pasta] [--espaco <nome>] liga uma pasta que já tem os arquivos a um espaço da conta
43
- omni desvincular [pasta] tira esta máquina da conta e o .omni/ das pastas (pede 2 confirmações)
44
- omni env push|list|rm [pasta] guarda chaves do .env no cofre do espaço, uma a uma (confirma no navegador)
45
- omni permissoes [set <conector> <ação> <estado>] [pasta] o que o agente pode fazer sozinho em cada ação
46
- omni uso tokens e custo de modelo da sua conta hoje e no mês, por rota e por chave
47
- omni llm [set anthropic|rm] usar a sua chave da Anthropic no lugar do modelo do Omni, com teto diário
48
-
49
- A CLI não reorganiza a sua pasta. Com --link, o que fazer com o que foi
50
- varrido é decidido na tela de revisão, no navegador.
24
+ import { confirmarSpace } from "./confirmar.js";
25
+ import { autoWireMcp, detectEditors, installMcpConfig, mcpNextSteps, mcpSkippedNote, runMcpServer } from "./mcp.js";
26
+ const USAGE = `
27
+ Omni CLI
28
+
29
+ Uso:
30
+ omni login entra na sua conta pelo navegador (a sessão vale 14 dias sem uso)
31
+ omni logout encerra a sessão da CLI nesta máquina
32
+ omni whoami mostra a conta, a máquina e até quando a sessão vale
33
+ omni init [pasta] [--nome <nome>] com a CLI logada, cria o espaço na sua conta e liga a pasta
34
+ omni init [pasta] --scan-only varre a pasta e mostra o que encontrou
35
+ omni init [pasta] --link <código> varre a pasta e envia pro código mostrado no navegador
36
+ omni status [pasta] [--resumo] o que mudou aqui e no servidor, e o próximo comando (só lê)
37
+ omni ask "<pergunta>" [pasta] responde com base na versão publicada do espaço, com as fontes
38
+ [--fontes] só mostra quais arquivos seriam lidos, sem chamar o modelo
39
+ [--detalhes] mostra também custo, contexto e arquivos consultados
40
+ omni sync [pasta] --push [--yes] publica uma nova versão do espaço no servidor
41
+ omni sync [pasta] --pull traz para a pasta a versão mais nova do servidor
42
+ [--conflito=local|mesclar|backup] mesma resposta para todos os conflitos, sem perguntar
43
+ omni clone <código> <pasta> baixa um espaço da sua conta numa pasta nova desta máquina
44
+ omni vincular [pasta] [--espaco <nome>] liga uma pasta que já tem os arquivos a um espaço da conta
45
+ omni desvincular [pasta] tira esta máquina da conta e o .omni/ das pastas (pede 2 confirmações)
46
+ omni env push|list|rm [pasta] guarda chaves do .env no cofre do espaço, uma a uma (confirma no navegador)
47
+ omni permissoes [set <conector> <ação> <estado>] [pasta] o que o agente pode fazer sozinho em cada ação
48
+ omni uso tokens e custo de modelo da sua conta hoje e no mês, por rota e por chave
49
+ omni llm [set anthropic|rm] usar a sua chave da Anthropic no lugar do modelo do Omni, com teto diário
50
+ omni mcp servidor MCP local (stdio) pro Claude Code e pro Cursor lerem este espaço
51
+ omni mcp instalar [pasta] acrescenta o servidor omni ao MCP do Claude Code, Cursor (e VS Code, Trae se a pasta usa) (mescla, nunca apaga outro)
52
+ omni confirmar [pedido] [pasta] decide s/n um pedido pendente de escrita, visto por inteiro no terminal
53
+
54
+ A CLI não reorganiza a sua pasta. Com --link, o que fazer com o que foi
55
+ varrido é decidido na tela de revisão, no navegador.
51
56
  `;
52
57
  async function main(argv) {
53
58
  const args = argv.slice(2);
@@ -67,10 +72,14 @@ async function main(argv) {
67
72
  return logout();
68
73
  if (command === "whoami")
69
74
  return printWhoami();
70
- if (command === "sync")
71
- return runSync(args.slice(1));
72
- if (command === "clone")
73
- return runClone(args.slice(1));
75
+ if (command === "sync") {
76
+ const rest = args.slice(1);
77
+ return withMcp(await runSync(rest), resolve(rest.find((a) => !a.startsWith("-")) ?? process.cwd()));
78
+ }
79
+ if (command === "clone") {
80
+ const rest = args.slice(1);
81
+ return withMcp(await runClone(rest), resolve(rest.filter((a) => !a.startsWith("-"))[1] ?? process.cwd()));
82
+ }
74
83
  if (command === "env")
75
84
  return runEnv(args.slice(1), { openBrowser: process.stdout.isTTY });
76
85
  if (command === "permissoes")
@@ -79,12 +88,34 @@ async function main(argv) {
79
88
  return runUso(args.slice(1));
80
89
  if (command === "llm")
81
90
  return runLlm(args.slice(1), { openBrowser: process.stdout.isTTY });
91
+ if (command === "mcp") {
92
+ const rest = args.slice(1);
93
+ if (rest[0] === "instalar") {
94
+ const folder = resolve(rest.slice(1).find((a) => !a.startsWith("-")) ?? process.cwd());
95
+ const editors = await detectEditors(folder); // antes de instalar: o install cria .cursor/
96
+ const { paths, alreadyThere, skipped } = await installMcpConfig(folder);
97
+ const onde = paths.map((p) => ` ${p}`).join("\n");
98
+ const passos = mcpNextSteps(editors);
99
+ console.log(`\n${alreadyThere ? "Atualizado" : "Servidor omni acrescentado"} em:\n${onde}\n\n${passos}\nWindsurf só lê o arquivo global do usuário (~/.codeium/windsurf/mcp_config.json): cole lá a entrada de .mcp.json.\n`);
100
+ if (skipped.length)
101
+ console.log(mcpSkippedNote(skipped));
102
+ return 0;
103
+ }
104
+ return runMcpServer(process.cwd());
105
+ }
106
+ if (command === "confirmar") {
107
+ const rest = args.slice(1);
108
+ const pedido = rest.find((a) => !a.startsWith("-"));
109
+ const folder = rest.filter((a) => !a.startsWith("-"))[1] ?? process.cwd();
110
+ return confirmarSpace({ pedido, folder });
111
+ }
82
112
  if (command === "vincular") {
83
113
  const rest = args.slice(1);
84
114
  const espacoIndex = rest.indexOf("--espaco");
85
115
  const espaco = rest.find((a) => a.startsWith("--espaco="))?.slice("--espaco=".length) ?? (espacoIndex >= 0 ? rest[espacoIndex + 1] : undefined);
86
116
  const folder = rest.find((a, i) => !a.startsWith("-") && (espacoIndex < 0 || i !== espacoIndex + 1));
87
- return linkExistingSpace(folder ?? process.cwd(), { espaco, yes: rest.includes("--yes") }, process.stdin.isTTY ? terminalAsk : null);
117
+ const linked = await linkExistingSpace(folder ?? process.cwd(), { espaco, yes: rest.includes("--yes") }, process.stdin.isTTY ? terminalAsk : null);
118
+ return withMcp(linked, resolve(folder ?? process.cwd()));
88
119
  }
89
120
  if (command === "desvincular")
90
121
  return unlinkMachine(resolve(args.slice(1).find((a) => !a.startsWith("-")) ?? process.cwd()));
@@ -126,20 +157,20 @@ async function main(argv) {
126
157
  if (!who.ok && !who.noToken)
127
158
  console.error(`\n${who.error}`);
128
159
  const site = defaultApiUrl();
129
- console.log(`
130
- Omni CLI
131
-
132
- Já tem conta no Omni? Entre por aqui e rode o init de novo:
133
- npx -y omnispace@latest login
134
-
135
- Primeira vez? Comece pelo navegador:
136
- ${site}/omnispace/escolha
137
-
138
- Lá aparece um código (tipo ABCD-2345). Com ele, dentro da pasta do espaço, rode:
139
- npx -y omnispace@latest init . --link <código>
140
-
141
- Só quer ver o que o Omni leria nesta pasta, sem enviar nada?
142
- npx -y omnispace@latest init . --scan-only
160
+ console.log(`
161
+ Omni CLI
162
+
163
+ Já tem conta no Omni? Entre por aqui e rode o init de novo:
164
+ npx -y omnispace@latest login
165
+
166
+ Primeira vez? Comece pelo navegador:
167
+ ${site}/omnispace/escolha
168
+
169
+ Lá aparece um código (tipo ABCD-2345). Com ele, dentro da pasta do espaço, rode:
170
+ npx -y omnispace@latest init . --link <código>
171
+
172
+ Só quer ver o que o Omni leria nesta pasta, sem enviar nada?
173
+ npx -y omnispace@latest init . --scan-only
143
174
  `);
144
175
  return who.ok || who.noToken ? 0 : 1;
145
176
  }
@@ -163,7 +194,7 @@ Só quer ver o que o Omni leria nesta pasta, sem enviar nada?
163
194
  return 1;
164
195
  }
165
196
  if (me)
166
- return initWithAccount(target, me, { name: nameArg, yes: rest.includes("--yes") });
197
+ return withMcp(await initWithAccount(target, me, { name: nameArg, yes: rest.includes("--yes") }), target);
167
198
  const scan = await scanFolder(target);
168
199
  printScanReport(scan, { linking: Boolean(linkCode) });
169
200
  if (linkCode && scan.truncated) {
@@ -179,6 +210,11 @@ Só quer ver o que o Omni leria nesta pasta, sem enviar nada?
179
210
  await linkAndSubmit(linkCode, target, scan);
180
211
  return 0;
181
212
  }
213
+ async function withMcp(code, folder) {
214
+ if (code === 0)
215
+ await autoWireMcp(folder);
216
+ return code;
217
+ }
182
218
  async function runSync(rest) {
183
219
  const push = rest.includes("--push");
184
220
  const pull = rest.includes("--pull");
package/dist/llm.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { resolve } from "node:path";
2
2
  import { createInterface } from "node:readline";
3
+ import { createInterface as createPromptInterface } from "node:readline/promises";
3
4
  import { callApi } from "./api.js";
4
5
  import { defaultApiUrl, loadToken } from "./config.js";
5
6
  import { context, refused, verify } from "./env.js";
6
7
  import { RETRY_DELAYS_MS } from "./retry.js";
8
+ import { maskValue, readEnvVars } from "./dotenv.js";
7
9
  // `omnispace llm` (docs/plan/byok-e-medicao.md): the person's own LLM key. With it on, questions
8
10
  // run on their model and the cost comes out of their provider account, up to a daily ceiling in
9
11
  // US$ checked before each call. The key is typed hidden (never an argument: it would stay in the
@@ -14,7 +16,8 @@ export const LLM_USAGE = `Uso:
14
16
  omnispace llm set anthropic [--modelo haiku|sonnet] [--teto <US$ por dia>] [pasta]
15
17
  liga a sua chave da Anthropic (pedida escondida no terminal)
16
18
  omnispace llm rm [pasta] apaga a sua chave; as perguntas voltam para o modelo do Omni
17
- Gravar e apagar a chave pedem confirmação no navegador. Padrão: haiku, teto de US$ 2 por dia.`;
19
+ Gravar e apagar a chave pedem confirmação no navegador. Padrão: haiku, teto de US$ 2 por dia.
20
+ Mais seguro que colar no terminal: ponha ANTHROPIC_API_KEY no .env da pasta; a CLI mostra só os 4 últimos caracteres e pergunta.`;
18
21
  export function formatLlmStatus(s) {
19
22
  if (!s.provider) {
20
23
  return [
@@ -29,22 +32,57 @@ export function formatLlmStatus(s) {
29
32
  out.push(s.engine === "pessoa" ? "As suas perguntas rodam na sua chave." : "As suas perguntas rodam no modelo do Omni.");
30
33
  return out;
31
34
  }
32
- /** Reads a line without echoing it: the key never appears on screen. */
35
+ /**
36
+ * Reads a line without echoing it: the key never appears on screen. The question goes out through
37
+ * readline itself and only then is the output muted (Lucas, 2026-09-28: written before the prompt,
38
+ * readline's line refresh erased it and the terminal looked stuck).
39
+ */
40
+ async function askLine(question) {
41
+ const rl = createPromptInterface({ input: process.stdin, output: process.stdout });
42
+ try {
43
+ return (await rl.question(question)).trim();
44
+ }
45
+ finally {
46
+ rl.close();
47
+ }
48
+ }
33
49
  function askHidden(question) {
34
50
  return new Promise((done) => {
35
51
  const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
36
- const write = rl._writeToOutput;
37
- process.stdout.write(question);
38
- rl._writeToOutput = () => { };
39
- rl.question("", (answer) => {
40
- if (write)
41
- rl._writeToOutput = write;
52
+ let muted = false;
53
+ rl._writeToOutput = (s) => {
54
+ if (!muted)
55
+ process.stdout.write(s);
56
+ };
57
+ // An empty line (a paste that starts with a line break) does not end the reading: otherwise the
58
+ // rest of the paste falls into the shell prompt, visible (Lucas, 2026-09-28, the key leaked so).
59
+ rl.on("line", (line) => {
60
+ const value = line.trim();
61
+ if (!value)
62
+ return;
63
+ muted = false;
42
64
  rl.close();
43
65
  process.stdout.write("\n");
44
- done(answer.trim());
66
+ done(value);
45
67
  });
68
+ process.stdout.write(question);
69
+ muted = true;
46
70
  });
47
71
  }
72
+ const KEY_RE = /^sk-ant-[A-Za-z0-9_-]{20,200}$/;
73
+ /**
74
+ * The key from the folder's .env (ANTHROPIC_API_KEY), shown only by its last 4 characters and used
75
+ * only with an s: no paste in the terminal at all.
76
+ */
77
+ export async function keyFromEnvFile(folder, ask, log) {
78
+ const { vars } = await readEnvVars(folder).catch(() => ({ vars: [] }));
79
+ const found = vars.find((v) => v.key === "ANTHROPIC_API_KEY" && KEY_RE.test(v.value.trim()));
80
+ if (!found)
81
+ return null;
82
+ log(`Achei ANTHROPIC_API_KEY em ${found.file} (${maskValue(found.value.trim())}).`);
83
+ const answer = (await ask("Usar esta chave? [s/n] ")).trim().toLowerCase();
84
+ return answer === "s" || answer === "sim" ? found.value.trim() : null;
85
+ }
48
86
  export function parseSetArgs(args) {
49
87
  const rest = [...args];
50
88
  const provider = rest.shift();
@@ -110,8 +148,13 @@ export async function runLlm(args, opts = {}) {
110
148
  if (!ctx)
111
149
  return 1;
112
150
  log(`Modelo ${parsed.model}, teto de US$ ${Number(parsed.cap).toFixed(2)} por dia. Cada pergunta passa a ser cobrada na sua conta da Anthropic.`);
113
- const key = opts.ask ? await opts.ask("Chave da API da Anthropic (sk-ant-...): ") : await askHidden("Chave da API da Anthropic (sk-ant-..., não aparece na tela): ");
114
- if (!/^sk-ant-[A-Za-z0-9_-]{20,200}$/.test(key)) {
151
+ const ask = opts.ask ?? askLine;
152
+ const fromFile = await keyFromEnvFile(resolve(parsed.folder), ask, log);
153
+ const key = fromFile ??
154
+ (opts.ask
155
+ ? await opts.ask("Chave da API da Anthropic (sk-ant-...): ")
156
+ : await askHidden("Cole a chave da API da Anthropic (sk-ant-...) e aperte Enter; ela não aparece na tela: "));
157
+ if (!KEY_RE.test(key)) {
115
158
  console.error("Isso não parece uma chave da API da Anthropic (sk-ant-...). Nada foi enviado.");
116
159
  return 1;
117
160
  }
package/dist/mcp.js ADDED
@@ -0,0 +1,289 @@
1
+ import { appendFile, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { z } from "zod";
4
+ import { McpServer } from "@modelcontextprotocol/server";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
6
+ import { callApi } from "./api.js";
7
+ import { loadToken, readSpaceConfig } from "./config.js";
8
+ import { statusSpace } from "./status.js";
9
+ import { terminalSafe } from "./ask.js";
10
+ async function resolveContext(folder) {
11
+ const config = await readSpaceConfig(folder);
12
+ if (!config)
13
+ return { error: `${folder} não é um espaço Omni (falta .omni/space.json). Rode aqui dentro de uma pasta ligada com omnispace init.` };
14
+ if (!config.spaceId)
15
+ return { error: "Esta pasta foi vinculada pelo navegador, mas o espaço ainda não foi criado." };
16
+ const token = await loadToken(config.apiUrl);
17
+ if (!token)
18
+ return { error: `Nenhuma sessão da CLI nesta máquina para ${config.apiUrl}. Rode: omnispace login` };
19
+ return { apiUrl: config.apiUrl, token, spaceId: config.spaceId };
20
+ }
21
+ function text(value) {
22
+ return { content: [{ type: "text", text: value }] };
23
+ }
24
+ function apiError(res) {
25
+ const msg = terminalSafe(String(res.error ?? "falha desconhecida"));
26
+ if (res.expired || res.status === 401)
27
+ return `${msg} (rode omnispace login nesta máquina)`;
28
+ return msg;
29
+ }
30
+ /** `console.log` volta a ser `console.log` mesmo se `fn` lançar: nunca fica capturando por engano. */
31
+ async function captureLog(fn) {
32
+ const original = console.log;
33
+ const lines = [];
34
+ console.log = (...args) => {
35
+ lines.push(args.map(String).join(" "));
36
+ };
37
+ try {
38
+ await fn();
39
+ }
40
+ finally {
41
+ console.log = original;
42
+ }
43
+ return lines.join("\n").trim();
44
+ }
45
+ export async function buildOmniMcpServer(folder) {
46
+ const server = new McpServer({ name: "omni", version: "1.0.0" });
47
+ server.registerTool("omni_status", {
48
+ title: "Status do espaço",
49
+ description: "A pasta e o servidor do Omni estão em dia? O que falta enviar ou trazer, em uma linha.",
50
+ inputSchema: z.object({}),
51
+ }, async () => {
52
+ const summary = await captureLog(() => statusSpace(folder, { resumo: true }));
53
+ return text(summary || "Sem resumo.");
54
+ });
55
+ server.registerTool("omni_conectores", {
56
+ title: "Conectores do espaço",
57
+ description: "Conectores ligados neste espaço e o que cada ação pode fazer: automático, pede confirmação ou bloqueado.",
58
+ inputSchema: z.object({}),
59
+ }, async () => {
60
+ const ctx = await resolveContext(folder);
61
+ if ("error" in ctx)
62
+ return text(ctx.error);
63
+ const res = await callApi(ctx.apiUrl, ctx.token, "/api/cli/permissions/list", { spaceId: ctx.spaceId });
64
+ if (!res.ok)
65
+ return text(apiError(res));
66
+ const rows = res.permissions ?? [];
67
+ if (rows.length === 0)
68
+ return text("Nenhum conector ligado neste espaço.");
69
+ const byConnector = new Map();
70
+ for (const r of rows) {
71
+ const list = byConnector.get(r.connectorId) ?? [];
72
+ list.push(`${r.actionKey} (${r.state})`);
73
+ byConnector.set(r.connectorId, list);
74
+ }
75
+ const lines = [...byConnector.entries()].map(([id, actions]) => `${id}: ${actions.join(", ")}`);
76
+ return text(lines.join("\n"));
77
+ });
78
+ server.registerTool("omni_ler", {
79
+ title: "Ler pelo conector",
80
+ description: "Roda uma ação de leitura (ou outra já automática neste espaço) de um conector, na hora. Uma ação em confirmar ou bloqueada é recusada; use omni_pedir para uma que pede confirmação.",
81
+ inputSchema: z.object({
82
+ conector: z.string().describe("id do conector, ex.: clickup, supabase, gohighlevel"),
83
+ acao: z.string().describe("chave da ação, ex.: list_tasks, query_table"),
84
+ entrada: z.record(z.string(), z.unknown()).optional().describe("parâmetros da ação"),
85
+ }),
86
+ }, async ({ conector, acao, entrada }) => {
87
+ const ctx = await resolveContext(folder);
88
+ if ("error" in ctx)
89
+ return text(ctx.error);
90
+ const res = await callApi(ctx.apiUrl, ctx.token, "/api/cli/actions/run", {
91
+ spaceId: ctx.spaceId,
92
+ connectorId: conector,
93
+ actionKey: acao,
94
+ input: entrada ?? {},
95
+ });
96
+ return text(res.ok ? String(res.output ?? "feito") : apiError(res));
97
+ });
98
+ server.registerTool("omni_pedir", {
99
+ title: "Pedir uma escrita",
100
+ description: "Registra um pedido pendente para uma ação de escrita (nunca executa). A pessoa confirma ou recusa depois no terminal, com omnispace confirmar. Não existe ferramenta pra confirmar por aqui.",
101
+ inputSchema: z.object({
102
+ conector: z.string().describe("id do conector, ex.: clickup, gohighlevel"),
103
+ acao: z.string().describe("chave da ação, ex.: create_task, create_contact"),
104
+ entrada: z.record(z.string(), z.unknown()).optional().describe("parâmetros da ação"),
105
+ }),
106
+ }, async ({ conector, acao, entrada }) => {
107
+ const ctx = await resolveContext(folder);
108
+ if ("error" in ctx)
109
+ return text(ctx.error);
110
+ const res = await callApi(ctx.apiUrl, ctx.token, "/api/cli/actions/request", {
111
+ spaceId: ctx.spaceId,
112
+ connectorId: conector,
113
+ actionKey: acao,
114
+ input: entrada ?? {},
115
+ });
116
+ if (!res.ok)
117
+ return text(apiError(res));
118
+ return text(`Pedido ${res.ref} registrado. Nada foi executado. Confirme ou recuse no terminal: omnispace confirmar ${res.ref}`);
119
+ });
120
+ server.registerTool("omni_uso", {
121
+ title: "Uso de modelo",
122
+ description: "Gasto de modelo da conta hoje e no mês, custo total.",
123
+ inputSchema: z.object({}),
124
+ }, async () => {
125
+ const ctx = await resolveContext(folder);
126
+ if ("error" in ctx)
127
+ return text(ctx.error);
128
+ const res = await callApi(ctx.apiUrl, ctx.token, "/api/cli/usage", {});
129
+ if (!res.ok)
130
+ return text(apiError(res));
131
+ const today = res.today;
132
+ const month = res.thisMonth;
133
+ return text(`Hoje: ${today.calls} chamada(s), US$ ${today.costUsd.toFixed(4)}. No mês: ${month.calls} chamada(s), US$ ${month.costUsd.toFixed(4)}.`);
134
+ });
135
+ return server;
136
+ }
137
+ export async function runMcpServer(folder) {
138
+ const server = await buildOmniMcpServer(folder);
139
+ const transport = new StdioServerTransport();
140
+ await server.connect(transport);
141
+ console.error("Omni MCP: servidor rodando por stdio.");
142
+ return new Promise(() => { }); // fica no ar até o processo pai (Claude Code) encerrar
143
+ }
144
+ // No Windows o `omni` global é um .cmd; clientes que abrem o processo sem shell (Claude Code nativo)
145
+ // não o acham, então vai por `cmd /c`.
146
+ export function omniServerEntry(platform = process.platform) {
147
+ return platform === "win32" ? { command: "cmd", args: ["/c", "omni", "mcp"] } : { command: "omni", args: ["mcp"] };
148
+ }
149
+ const MCP_TARGETS = [
150
+ { path: ".mcp.json", rootKey: "mcpServers", entry: omniServerEntry }, // Claude Code (terminal, extensão, app)
151
+ { path: ".cursor/mcp.json", rootKey: "mcpServers", entry: omniServerEntry }, // Cursor: não lê o .mcp.json
152
+ { path: ".vscode/mcp.json", rootKey: "servers", entry: () => ({ type: "stdio", ...omniServerEntry() }), onlyIfDir: ".vscode" },
153
+ { path: ".trae/mcp.json", rootKey: "mcpServers", entry: omniServerEntry, onlyIfDir: ".trae" },
154
+ // Zed chama MCP de "context servers" e lê o .zed/settings.json do projeto.
155
+ { path: ".zed/settings.json", rootKey: "context_servers", entry: () => ({ ...omniServerEntry(), env: {} }), onlyIfDir: ".zed" },
156
+ // Codex: TOML, `.codex/config.toml` do projeto (só vale em projeto confiável). O global fica manual.
157
+ { path: ".codex/config.toml", rootKey: "mcpServers", entry: omniServerEntry, onlyIfDir: ".codex", toml: true },
158
+ ];
159
+ async function mergeServerInto(path, target) {
160
+ let raw;
161
+ try {
162
+ raw = await readFile(path, "utf8");
163
+ }
164
+ catch {
165
+ raw = undefined;
166
+ }
167
+ await mkdir(dirname(path), { recursive: true });
168
+ if (target.toml) {
169
+ // TOML sem parser: só acrescenta o bloco (append), nunca reescreve o que já existe.
170
+ if (raw !== undefined && /^\s*\[mcp_servers\.omni\]/m.test(raw))
171
+ return "updated";
172
+ const e = target.entry();
173
+ const block = `[mcp_servers.omni]\ncommand = ${JSON.stringify(e.command)}\nargs = ${JSON.stringify(e.args)}\n`;
174
+ const sep = raw === undefined || raw === "" ? "" : raw.endsWith("\n") ? "\n" : "\n\n";
175
+ await appendFile(path, `${sep}${block}`, "utf8");
176
+ return "added";
177
+ }
178
+ let existing = {};
179
+ if (raw !== undefined) {
180
+ try {
181
+ existing = JSON.parse(raw);
182
+ }
183
+ catch {
184
+ // Ilegível ou com comentários (comum no VS Code e no Zed): não mexe, para não perder as outras
185
+ // configurações da pessoa. O aviso diz o que colar.
186
+ return "skipped";
187
+ }
188
+ }
189
+ const servers = { ...(existing[target.rootKey] ?? {}) };
190
+ const had = Boolean(servers.omni);
191
+ servers.omni = target.entry();
192
+ const next = { ...existing, [target.rootKey]: servers };
193
+ const tmp = `${path}.${process.pid}.tmp`;
194
+ await writeFile(tmp, `${JSON.stringify(next, null, 2)}\n`, "utf8");
195
+ await rename(tmp, path);
196
+ return had ? "updated" : "added";
197
+ }
198
+ async function isDir(path) {
199
+ try {
200
+ return (await stat(path)).isDirectory();
201
+ }
202
+ catch {
203
+ return false;
204
+ }
205
+ }
206
+ // `.mcp.json` e `.cursor/mcp.json` sempre; os demais só se a pasta já é usada naquele editor.
207
+ // `alreadyThere` é falso se algum arquivo ganhou o servidor agora; `skipped` são arquivos ilegíveis
208
+ // (não tocados).
209
+ export async function installMcpConfig(folder) {
210
+ const paths = [];
211
+ const skipped = [];
212
+ let alreadyThere = true;
213
+ for (const target of MCP_TARGETS) {
214
+ if (target.onlyIfDir && !(await isDir(join(folder, target.onlyIfDir))))
215
+ continue;
216
+ const path = join(folder, target.path);
217
+ const result = await mergeServerInto(path, target);
218
+ if (result === "skipped") {
219
+ skipped.push(path);
220
+ continue;
221
+ }
222
+ if (result === "added")
223
+ alreadyThere = false;
224
+ paths.push(path);
225
+ }
226
+ return { paths, alreadyThere, skipped };
227
+ }
228
+ // Rastros de cada editor: pasta própria no projeto ou variável que o próprio editor põe no terminal
229
+ // integrado. Sem rastro nenhum, o texto lista todos.
230
+ export async function detectEditors(folder, env = process.env) {
231
+ const found = [];
232
+ if ((await isDir(join(folder, ".cursor"))) || env.CURSOR_TRACE_ID)
233
+ found.push("cursor");
234
+ if ((await isDir(join(folder, ".claude"))) || env.CLAUDECODE)
235
+ found.push("claude-code");
236
+ if ((await isDir(join(folder, ".vscode"))) || (env.TERM_PROGRAM === "vscode" && !env.CURSOR_TRACE_ID))
237
+ found.push("vscode");
238
+ if (await isDir(join(folder, ".trae")))
239
+ found.push("trae");
240
+ if (await isDir(join(folder, ".zed")))
241
+ found.push("zed");
242
+ if ((await isDir(join(folder, ".codex"))) || env.CODEX_HOME || env.CODEX_SANDBOX)
243
+ found.push("codex");
244
+ return found;
245
+ }
246
+ const EDITOR_STEPS = {
247
+ cursor: "Cursor: Ctrl+Shift+P, \"Developer: Reload Window\"; depois Settings, Tools & MCP, e ligue o toggle do omni.",
248
+ "claude-code": "Claude Code: abra um chat novo na pasta e rode /mcp; aprove o servidor omni.",
249
+ vscode: "VS Code: Ctrl+Shift+P, \"MCP: List Servers\", e inicie o omni (confirme que confia nele).",
250
+ trae: "Trae: Settings, MCP, e ligue o MCP do projeto (Enable Project MCP); o omni aparece na lista.",
251
+ zed: "Zed: Settings, AI, MCP Servers; o omni aparece na lista (o ponto ao lado fica verde quando conecta).",
252
+ codex: "Codex: confie no projeto (só assim ele lê o .codex/config.toml) e rode /mcp; ou, para valer em todo projeto, `codex mcp add omni -- omni mcp`.",
253
+ };
254
+ export function mcpNextSteps(detected) {
255
+ if (detected.length === 0) {
256
+ return ["Não vi qual editor você usa. Onde ativar:", ...Object.values(EDITOR_STEPS).map((l) => ` ${l}`)].join("\n");
257
+ }
258
+ const names = detected.map((e) => EDITOR_STEPS[e].split(":")[0]).join(" e ");
259
+ return [`${detected.length > 1 ? "Detectados" : "Detectado"}: ${names}. Para ativar:`, ...detected.map((e) => ` ${EDITOR_STEPS[e]}`)].join("\n");
260
+ }
261
+ export function mcpSkippedNote(skipped) {
262
+ return [
263
+ "Não mexi nestes arquivos porque não consegui ler (comentários ou JSON quebrado), para não perder suas outras configurações:",
264
+ ...skipped.map((p) => ` ${p}`),
265
+ "Rode `omnispace mcp` para ver o comando e acrescente o servidor omni neles à mão.",
266
+ ].join("\n");
267
+ }
268
+ // Depois de sync, clone, vincular e init: liga o MCP nesta máquina sem a pessoa pedir. Os arquivos de
269
+ // MCP levam o comando desta máquina (cmd /c no Windows) e por isso não sincronizam: cada máquina
270
+ // se configura sozinha. Só fala quando algo foi criado; `OMNI_NO_MCP=1` desliga.
271
+ export async function autoWireMcp(folder) {
272
+ if (process.env.OMNI_NO_MCP)
273
+ return;
274
+ try {
275
+ if (!(await isDir(join(folder, ".omni"))))
276
+ return;
277
+ const editors = await detectEditors(folder); // antes de instalar: o próprio install cria .cursor/
278
+ const { paths, alreadyThere, skipped } = await installMcpConfig(folder);
279
+ if (!alreadyThere) {
280
+ console.log(`\nMCP do Omni ligado nesta máquina (${paths.length} arquivo(s) de configuração).`);
281
+ console.log(mcpNextSteps(editors));
282
+ }
283
+ if (skipped.length)
284
+ console.log(`\n${mcpSkippedNote(skipped)}`);
285
+ }
286
+ catch {
287
+ // ligar o MCP nunca derruba o comando que a pessoa rodou
288
+ }
289
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omnispace",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Omni CLI (alpha): transforma uma pasta da sua máquina em um espaço Omni e sincroniza entre máquinas.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,6 +20,10 @@
20
20
  "engines": {
21
21
  "node": ">=20"
22
22
  },
23
+ "dependencies": {
24
+ "@modelcontextprotocol/server": "^2.0.0",
25
+ "zod": "^4.6.5"
26
+ },
23
27
  "keywords": [
24
28
  "omni",
25
29
  "tzolkin",