omnispace 0.15.1 → 0.17.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/dist/auto-sync.js +99 -0
- package/dist/confirmar.js +96 -0
- package/dist/hooks.js +90 -0
- package/dist/include.js +3 -2
- package/dist/index.js +112 -47
- package/dist/llm.js +54 -11
- package/dist/mcp.js +289 -0
- package/package.json +5 -1
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { callApi } from "./api.js";
|
|
2
|
+
import { RETRY_DELAYS_MS } from "./retry.js";
|
|
3
|
+
import { buildManifest } from "./include.js";
|
|
4
|
+
import { unresolvedConflicts } from "./conflicts.js";
|
|
5
|
+
import { readJournal } from "./journal.js";
|
|
6
|
+
import { pullSpace, remoteMap } from "./pull.js";
|
|
7
|
+
import { pushSpace } from "./sync.js";
|
|
8
|
+
import { LockedError, withLock } from "./lock.js";
|
|
9
|
+
import { loadToken, readSpaceConfig } from "./config.js";
|
|
10
|
+
import { statusSpace, summarizeStatus } from "./status.js";
|
|
11
|
+
/** What to do given the three-way summary. A file changed on both sides stops everything. */
|
|
12
|
+
export function planAuto(summary, head, baseVersion, pullOnly) {
|
|
13
|
+
if (summary.both.length > 0)
|
|
14
|
+
return { kind: "diverged", files: summary.both };
|
|
15
|
+
const pull = head > baseVersion;
|
|
16
|
+
const push = !pullOnly && summary.local.length > 0;
|
|
17
|
+
return pull || push ? { kind: "sync", pull, push } : { kind: "nothing" };
|
|
18
|
+
}
|
|
19
|
+
/** Runs `fn` with console output captured: pull and push talk a lot, a hook must say one line. */
|
|
20
|
+
async function captured(fn) {
|
|
21
|
+
const lines = [];
|
|
22
|
+
const saved = { log: console.log, error: console.error };
|
|
23
|
+
const grab = (...args) => void lines.push(args.join(" "));
|
|
24
|
+
console.log = grab;
|
|
25
|
+
console.error = grab;
|
|
26
|
+
try {
|
|
27
|
+
const code = await fn();
|
|
28
|
+
const last = [...lines].reverse().map((l) => l.trim()).find((l) => l.length > 0) ?? "";
|
|
29
|
+
return { code, last };
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
console.log = saved.log;
|
|
33
|
+
console.error = saved.error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function autoOnce(root, opts, deps) {
|
|
37
|
+
const config = await readSpaceConfig(root);
|
|
38
|
+
if (!config?.spaceId)
|
|
39
|
+
return null;
|
|
40
|
+
const token = await loadToken(config.apiUrl);
|
|
41
|
+
if (!token)
|
|
42
|
+
return "CLI sem sessão nesta máquina; rode omnispace login (sync automático parado).";
|
|
43
|
+
const [journal, markers] = await Promise.all([readJournal(root), unresolvedConflicts(root, { readOnly: true })]);
|
|
44
|
+
if (journal)
|
|
45
|
+
return "um pull parou no meio; rode omnispace sync --pull.";
|
|
46
|
+
if (markers.length > 0)
|
|
47
|
+
return `${markers.length} arquivo(s) com marcador de conflito; resolva antes do sync automático.`;
|
|
48
|
+
const manifest = await buildManifest(root);
|
|
49
|
+
if (manifest.truncated)
|
|
50
|
+
return "pasta grande demais para o sync automático.";
|
|
51
|
+
const res = await callApi(config.apiUrl, token, "/api/cli/sync/status", { spaceId: config.spaceId, baseVersion: config.baseVersion }, { retryDelaysMs: RETRY_DELAYS_MS });
|
|
52
|
+
if (!res.ok)
|
|
53
|
+
return `servidor não consultado (${res.error}); sync automático adiado.`;
|
|
54
|
+
const head = res.head;
|
|
55
|
+
const headMap = remoteMap(res.files);
|
|
56
|
+
const baseMap = res.base ? remoteMap(res.base) : headMap;
|
|
57
|
+
const local = new Map(manifest.files.map((f) => [f.path, f.hash]));
|
|
58
|
+
const skip = (path) => manifest.unreadable.some((u) => path === u || path.startsWith(`${u}/`));
|
|
59
|
+
const readable = (map) => new Map([...map].filter(([path]) => !skip(path)));
|
|
60
|
+
const summary = summarizeStatus(local, readable(baseMap), readable(headMap));
|
|
61
|
+
const plan = planAuto(summary, head, config.baseVersion, Boolean(opts.pullOnly));
|
|
62
|
+
if (plan.kind === "nothing")
|
|
63
|
+
return null;
|
|
64
|
+
if (plan.kind === "diverged") {
|
|
65
|
+
return `${plan.files.length} arquivo(s) mudado(s) nesta pasta e no servidor (${plan.files.slice(0, 3).join(", ")}); sync automático parado, rode omnispace sync --pull para decidir.`;
|
|
66
|
+
}
|
|
67
|
+
const parts = [];
|
|
68
|
+
if (plan.pull) {
|
|
69
|
+
// conflito: "backup" only matters if a race creates a conflict after the check: it keeps both.
|
|
70
|
+
const r = await captured(() => deps.pull(root, { conflito: "backup" }));
|
|
71
|
+
if (r.code !== 0)
|
|
72
|
+
return `pull automático não terminou: ${r.last || "erro sem mensagem"}`;
|
|
73
|
+
if (summary.remote > 0)
|
|
74
|
+
parts.push(`trouxe ${summary.remote} arquivo(s) do servidor`);
|
|
75
|
+
}
|
|
76
|
+
if (plan.push) {
|
|
77
|
+
const r = await captured(() => deps.push(root, { yes: true }));
|
|
78
|
+
if (r.code !== 0)
|
|
79
|
+
return `${parts.length ? `${parts.join(", ")}; ` : ""}envio automático não feito: ${r.last || "erro sem mensagem"}`;
|
|
80
|
+
const after = await readSpaceConfig(root);
|
|
81
|
+
parts.push(`enviei ${summary.local.length} arquivo(s)${after ? ` (versão ${after.baseVersion})` : ""}`);
|
|
82
|
+
}
|
|
83
|
+
return parts.length > 0 ? parts.join(", ") : null;
|
|
84
|
+
}
|
|
85
|
+
export async function syncAuto(root, opts = {}, deps = { pull: pullSpace, push: pushSpace }) {
|
|
86
|
+
try {
|
|
87
|
+
const line = await withLock(root, () => autoOnce(root, opts, deps));
|
|
88
|
+
if (line)
|
|
89
|
+
console.log(`Omni: sync automático: ${line}`);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
// Another sync (another agent in the same folder) holds the lock: it does the work.
|
|
93
|
+
if (!(err instanceof LockedError))
|
|
94
|
+
console.log(`Omni: sync automático falhou (${err.message.slice(0, 160)}).`);
|
|
95
|
+
}
|
|
96
|
+
if (opts.resumo)
|
|
97
|
+
await statusSpace(root, { resumo: true });
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
@@ -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/hooks.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
// Hooks do Claude Code que mantêm a pasta em dia sem a pessoa pedir (checklist 2 de
|
|
4
|
+
// docs/plan/visao-jarvis-automacao-2026-09-28.md). Vão em `.claude/settings.json`, que sincroniza
|
|
5
|
+
// entre máquinas: os comandos são iguais em qualquer sistema, ao contrário dos arquivos de MCP.
|
|
6
|
+
// SessionStart: `omnispace sync --auto --pull-only --resumo` (traz o que chegou e diz o estado;
|
|
7
|
+
// troca o `omnispace status --resumo` antigo, que rodaria em paralelo e leria o estado velho)
|
|
8
|
+
// SessionEnd: `omnispace sync --auto` (traz e envia o que mudou, sem conflito)
|
|
9
|
+
export const START_COMMAND = "omnispace sync --auto --pull-only --resumo";
|
|
10
|
+
export const END_COMMAND = "omnispace sync --auto";
|
|
11
|
+
const OLD_START = /^\s*(omnispace|omni)\s+status\s+--resumo\s*$/;
|
|
12
|
+
function hasCommand(groups, test) {
|
|
13
|
+
return groups.some((g) => (g.hooks ?? []).some((h) => typeof h.command === "string" && test(h.command)));
|
|
14
|
+
}
|
|
15
|
+
/** Pure merge: returns the new settings and whether anything changed. Never removes another hook. */
|
|
16
|
+
export function mergeHooks(settings) {
|
|
17
|
+
const next = { ...settings, hooks: { ...(settings.hooks ?? {}) } };
|
|
18
|
+
const hooks = next.hooks;
|
|
19
|
+
let changed = false;
|
|
20
|
+
const start = (hooks.SessionStart ?? []).map((g) => ({ ...g, hooks: (g.hooks ?? []).map((h) => ({ ...h })) }));
|
|
21
|
+
if (!hasCommand(start, (c) => c.includes("sync --auto"))) {
|
|
22
|
+
let replaced = false;
|
|
23
|
+
for (const group of start) {
|
|
24
|
+
for (const h of group.hooks ?? []) {
|
|
25
|
+
if (typeof h.command === "string" && OLD_START.test(h.command)) {
|
|
26
|
+
h.command = START_COMMAND;
|
|
27
|
+
replaced = true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (!replaced)
|
|
32
|
+
start.push({ matcher: "startup|resume", hooks: [{ type: "command", command: START_COMMAND, timeout: 90 }] });
|
|
33
|
+
hooks.SessionStart = start;
|
|
34
|
+
changed = true;
|
|
35
|
+
}
|
|
36
|
+
const end = (hooks.SessionEnd ?? []).map((g) => ({ ...g, hooks: (g.hooks ?? []).map((h) => ({ ...h })) }));
|
|
37
|
+
if (!hasCommand(end, (c) => c.includes("sync --auto"))) {
|
|
38
|
+
end.push({ hooks: [{ type: "command", command: END_COMMAND, timeout: 90 }] });
|
|
39
|
+
hooks.SessionEnd = end;
|
|
40
|
+
changed = true;
|
|
41
|
+
}
|
|
42
|
+
return { next, changed };
|
|
43
|
+
}
|
|
44
|
+
/** `.claude/settings.json` of the folder. Unreadable (comments, broken JSON): untouched, reported. */
|
|
45
|
+
export async function installHooks(folder) {
|
|
46
|
+
const path = join(folder, ".claude", "settings.json");
|
|
47
|
+
let settings = {};
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = await readFile(path, "utf8");
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
raw = undefined;
|
|
54
|
+
}
|
|
55
|
+
if (raw !== undefined) {
|
|
56
|
+
try {
|
|
57
|
+
settings = JSON.parse(raw);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return { path, changed: false, skipped: true };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const { next, changed } = mergeHooks(settings);
|
|
64
|
+
if (!changed)
|
|
65
|
+
return { path, changed: false, skipped: false };
|
|
66
|
+
await mkdir(dirname(path), { recursive: true });
|
|
67
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
68
|
+
await writeFile(tmp, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
69
|
+
await rename(tmp, path);
|
|
70
|
+
return { path, changed: true, skipped: false };
|
|
71
|
+
}
|
|
72
|
+
// Depois de sync, clone, vincular e init (junto do MCP): liga os hooks sozinho na pasta de espaço.
|
|
73
|
+
// Só fala quando mudou algo; `OMNI_NO_HOOKS=1` desliga.
|
|
74
|
+
export async function autoWireHooks(folder) {
|
|
75
|
+
if (process.env.OMNI_NO_HOOKS)
|
|
76
|
+
return;
|
|
77
|
+
try {
|
|
78
|
+
if (!(await readFile(join(folder, ".omni", "space.json"), "utf8").then(() => true, () => false)))
|
|
79
|
+
return;
|
|
80
|
+
const r = await installHooks(folder);
|
|
81
|
+
if (r.changed) {
|
|
82
|
+
console.log(`\nSync automático ligado nos hooks do Claude Code (${r.path}): traz o que chegou ao abrir a sessão e envia o que mudou ao fechar. Conflito nunca é forçado.`);
|
|
83
|
+
}
|
|
84
|
+
if (r.skipped)
|
|
85
|
+
console.log(`\nNão mexi em ${r.path} (comentários ou JSON quebrado). Para ligar o sync automático, adicione os hooks à mão: rode omnispace hooks instalar depois de corrigir o arquivo.`);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// ligar os hooks nunca derruba o comando que a pessoa rodou
|
|
89
|
+
}
|
|
90
|
+
}
|
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,42 @@ 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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
omni
|
|
33
|
-
omni
|
|
34
|
-
omni
|
|
35
|
-
omni
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
omni
|
|
39
|
-
omni
|
|
40
|
-
[--
|
|
41
|
-
|
|
42
|
-
omni
|
|
43
|
-
omni
|
|
44
|
-
|
|
45
|
-
omni
|
|
46
|
-
omni
|
|
47
|
-
omni
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
24
|
+
import { confirmarSpace } from "./confirmar.js";
|
|
25
|
+
import { syncAuto } from "./auto-sync.js";
|
|
26
|
+
import { autoWireHooks, installHooks } from "./hooks.js";
|
|
27
|
+
import { autoWireMcp, detectEditors, installMcpConfig, mcpNextSteps, mcpSkippedNote, runMcpServer } from "./mcp.js";
|
|
28
|
+
const USAGE = `
|
|
29
|
+
Omni CLI
|
|
30
|
+
|
|
31
|
+
Uso:
|
|
32
|
+
omni login entra na sua conta pelo navegador (a sessão vale 14 dias sem uso)
|
|
33
|
+
omni logout encerra a sessão da CLI nesta máquina
|
|
34
|
+
omni whoami mostra a conta, a máquina e até quando a sessão vale
|
|
35
|
+
omni init [pasta] [--nome <nome>] com a CLI logada, cria o espaço na sua conta e liga a pasta
|
|
36
|
+
omni init [pasta] --scan-only varre a pasta e mostra o que encontrou
|
|
37
|
+
omni init [pasta] --link <código> varre a pasta e envia pro código mostrado no navegador
|
|
38
|
+
omni status [pasta] [--resumo] o que mudou aqui e no servidor, e o próximo comando (só lê)
|
|
39
|
+
omni ask "<pergunta>" [pasta] responde com base na versão publicada do espaço, com as fontes
|
|
40
|
+
[--fontes] só mostra quais arquivos seriam lidos, sem chamar o modelo
|
|
41
|
+
[--detalhes] mostra também custo, contexto e arquivos consultados
|
|
42
|
+
omni sync [pasta] --push [--yes] publica uma nova versão do espaço no servidor
|
|
43
|
+
omni sync [pasta] --pull traz para a pasta a versão mais nova do servidor
|
|
44
|
+
[--conflito=local|mesclar|backup] mesma resposta para todos os conflitos, sem perguntar
|
|
45
|
+
omni clone <código> <pasta> baixa um espaço da sua conta numa pasta nova desta máquina
|
|
46
|
+
omni vincular [pasta] [--espaco <nome>] liga uma pasta que já tem os arquivos a um espaço da conta
|
|
47
|
+
omni desvincular [pasta] tira esta máquina da conta e o .omni/ das pastas (pede 2 confirmações)
|
|
48
|
+
omni env push|list|rm [pasta] guarda chaves do .env no cofre do espaço, uma a uma (confirma no navegador)
|
|
49
|
+
omni permissoes [set <conector> <ação> <estado>] [pasta] o que o agente pode fazer sozinho em cada ação
|
|
50
|
+
omni uso tokens e custo de modelo da sua conta hoje e no mês, por rota e por chave
|
|
51
|
+
omni llm [set anthropic|rm] usar a sua chave da Anthropic no lugar do modelo do Omni, com teto diário
|
|
52
|
+
omni mcp servidor MCP local (stdio) pro Claude Code e pro Cursor lerem este espaço
|
|
53
|
+
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)
|
|
54
|
+
omni sync [pasta] --auto [--pull-only] [--resumo] o que os hooks rodam: traz e envia sem perguntar, para se houver conflito
|
|
55
|
+
omni hooks instalar [pasta] liga o sync automático nos hooks do Claude Code da pasta (mescla, nunca apaga outro)
|
|
56
|
+
omni confirmar [pedido] [pasta] decide s/n um pedido pendente de escrita, visto por inteiro no terminal
|
|
57
|
+
|
|
58
|
+
A CLI não reorganiza a sua pasta. Com --link, o que fazer com o que foi
|
|
59
|
+
varrido é decidido na tela de revisão, no navegador.
|
|
51
60
|
`;
|
|
52
61
|
async function main(argv) {
|
|
53
62
|
const args = argv.slice(2);
|
|
@@ -67,10 +76,20 @@ async function main(argv) {
|
|
|
67
76
|
return logout();
|
|
68
77
|
if (command === "whoami")
|
|
69
78
|
return printWhoami();
|
|
70
|
-
if (command === "sync")
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
79
|
+
if (command === "sync") {
|
|
80
|
+
const rest = args.slice(1);
|
|
81
|
+
if (rest.includes("--auto")) {
|
|
82
|
+
return syncAuto(resolve(rest.find((a) => !a.startsWith("-")) ?? process.cwd()), {
|
|
83
|
+
pullOnly: rest.includes("--pull-only"),
|
|
84
|
+
resumo: rest.includes("--resumo"),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return withMcp(await runSync(rest), resolve(rest.find((a) => !a.startsWith("-")) ?? process.cwd()));
|
|
88
|
+
}
|
|
89
|
+
if (command === "clone") {
|
|
90
|
+
const rest = args.slice(1);
|
|
91
|
+
return withMcp(await runClone(rest), resolve(rest.filter((a) => !a.startsWith("-"))[1] ?? process.cwd()));
|
|
92
|
+
}
|
|
74
93
|
if (command === "env")
|
|
75
94
|
return runEnv(args.slice(1), { openBrowser: process.stdout.isTTY });
|
|
76
95
|
if (command === "permissoes")
|
|
@@ -79,12 +98,51 @@ async function main(argv) {
|
|
|
79
98
|
return runUso(args.slice(1));
|
|
80
99
|
if (command === "llm")
|
|
81
100
|
return runLlm(args.slice(1), { openBrowser: process.stdout.isTTY });
|
|
101
|
+
if (command === "mcp") {
|
|
102
|
+
const rest = args.slice(1);
|
|
103
|
+
if (rest[0] === "instalar") {
|
|
104
|
+
const folder = resolve(rest.slice(1).find((a) => !a.startsWith("-")) ?? process.cwd());
|
|
105
|
+
const editors = await detectEditors(folder); // antes de instalar: o install cria .cursor/
|
|
106
|
+
const { paths, alreadyThere, skipped } = await installMcpConfig(folder);
|
|
107
|
+
const onde = paths.map((p) => ` ${p}`).join("\n");
|
|
108
|
+
const passos = mcpNextSteps(editors);
|
|
109
|
+
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`);
|
|
110
|
+
if (skipped.length)
|
|
111
|
+
console.log(mcpSkippedNote(skipped));
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
return runMcpServer(process.cwd());
|
|
115
|
+
}
|
|
116
|
+
if (command === "hooks") {
|
|
117
|
+
const rest = args.slice(1);
|
|
118
|
+
if (rest[0] !== "instalar") {
|
|
119
|
+
console.error("Use omnispace hooks instalar [pasta].");
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
const folder = resolve(rest.slice(1).find((a) => !a.startsWith("-")) ?? process.cwd());
|
|
123
|
+
const r = await installHooks(folder);
|
|
124
|
+
console.log(r.skipped ? `
|
|
125
|
+
Não mexi em ${r.path}: comentários ou JSON quebrado. Corrija e rode de novo.
|
|
126
|
+
` : r.changed ? `
|
|
127
|
+
Hooks de sync automático ligados em ${r.path}.
|
|
128
|
+
` : `
|
|
129
|
+
Os hooks já estavam em ${r.path}.
|
|
130
|
+
`);
|
|
131
|
+
return r.skipped ? 1 : 0;
|
|
132
|
+
}
|
|
133
|
+
if (command === "confirmar") {
|
|
134
|
+
const rest = args.slice(1);
|
|
135
|
+
const pedido = rest.find((a) => !a.startsWith("-"));
|
|
136
|
+
const folder = rest.filter((a) => !a.startsWith("-"))[1] ?? process.cwd();
|
|
137
|
+
return confirmarSpace({ pedido, folder });
|
|
138
|
+
}
|
|
82
139
|
if (command === "vincular") {
|
|
83
140
|
const rest = args.slice(1);
|
|
84
141
|
const espacoIndex = rest.indexOf("--espaco");
|
|
85
142
|
const espaco = rest.find((a) => a.startsWith("--espaco="))?.slice("--espaco=".length) ?? (espacoIndex >= 0 ? rest[espacoIndex + 1] : undefined);
|
|
86
143
|
const folder = rest.find((a, i) => !a.startsWith("-") && (espacoIndex < 0 || i !== espacoIndex + 1));
|
|
87
|
-
|
|
144
|
+
const linked = await linkExistingSpace(folder ?? process.cwd(), { espaco, yes: rest.includes("--yes") }, process.stdin.isTTY ? terminalAsk : null);
|
|
145
|
+
return withMcp(linked, resolve(folder ?? process.cwd()));
|
|
88
146
|
}
|
|
89
147
|
if (command === "desvincular")
|
|
90
148
|
return unlinkMachine(resolve(args.slice(1).find((a) => !a.startsWith("-")) ?? process.cwd()));
|
|
@@ -126,20 +184,20 @@ async function main(argv) {
|
|
|
126
184
|
if (!who.ok && !who.noToken)
|
|
127
185
|
console.error(`\n${who.error}`);
|
|
128
186
|
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
|
|
187
|
+
console.log(`
|
|
188
|
+
Omni CLI
|
|
189
|
+
|
|
190
|
+
Já tem conta no Omni? Entre por aqui e rode o init de novo:
|
|
191
|
+
npx -y omnispace@latest login
|
|
192
|
+
|
|
193
|
+
Primeira vez? Comece pelo navegador:
|
|
194
|
+
${site}/omnispace/escolha
|
|
195
|
+
|
|
196
|
+
Lá aparece um código (tipo ABCD-2345). Com ele, dentro da pasta do espaço, rode:
|
|
197
|
+
npx -y omnispace@latest init . --link <código>
|
|
198
|
+
|
|
199
|
+
Só quer ver o que o Omni leria nesta pasta, sem enviar nada?
|
|
200
|
+
npx -y omnispace@latest init . --scan-only
|
|
143
201
|
`);
|
|
144
202
|
return who.ok || who.noToken ? 0 : 1;
|
|
145
203
|
}
|
|
@@ -163,7 +221,7 @@ Só quer ver o que o Omni leria nesta pasta, sem enviar nada?
|
|
|
163
221
|
return 1;
|
|
164
222
|
}
|
|
165
223
|
if (me)
|
|
166
|
-
return initWithAccount(target, me, { name: nameArg, yes: rest.includes("--yes") });
|
|
224
|
+
return withMcp(await initWithAccount(target, me, { name: nameArg, yes: rest.includes("--yes") }), target);
|
|
167
225
|
const scan = await scanFolder(target);
|
|
168
226
|
printScanReport(scan, { linking: Boolean(linkCode) });
|
|
169
227
|
if (linkCode && scan.truncated) {
|
|
@@ -179,6 +237,13 @@ Só quer ver o que o Omni leria nesta pasta, sem enviar nada?
|
|
|
179
237
|
await linkAndSubmit(linkCode, target, scan);
|
|
180
238
|
return 0;
|
|
181
239
|
}
|
|
240
|
+
async function withMcp(code, folder) {
|
|
241
|
+
if (code === 0) {
|
|
242
|
+
await autoWireMcp(folder);
|
|
243
|
+
await autoWireHooks(folder);
|
|
244
|
+
}
|
|
245
|
+
return code;
|
|
246
|
+
}
|
|
182
247
|
async function runSync(rest) {
|
|
183
248
|
const push = rest.includes("--push");
|
|
184
249
|
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
|
-
/**
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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(
|
|
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
|
|
114
|
-
|
|
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.
|
|
3
|
+
"version": "0.17.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",
|