omnispace 0.11.0 → 0.13.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
@@ -84,6 +84,21 @@ Tirar esta máquina da conta e o `.omni/` das pastas dela (pede duas confirmaç
84
84
  npx -y omnispace@latest desvincular
85
85
  ```
86
86
 
87
+ Ver e mudar o que o agente pode fazer sozinho com cada ação dos conectores do espaço. Por padrão, leitura é automática, escrita pede confirmação e ação destrutiva fica bloqueada. Restringir vale na hora; liberar pede confirmação no navegador:
88
+
89
+ ```
90
+ npx -y omnispace@latest permissoes
91
+ npx -y omnispace@latest permissoes set slack send_message bloqueado
92
+ ```
93
+
94
+ Perguntar sobre o espaço. A resposta usa a versão publicada, com as fontes (arquivo e versão) e os limites da consulta:
95
+
96
+ ```
97
+ npx -y omnispace@latest ask "Qual foi a última decisão deste projeto?"
98
+ ```
99
+
100
+ Com conectores ligados no espaço, o agente também consulta os conectores: leitura automática roda na hora e aparece como `[C1]`, com o horário da consulta. Uma ação que pede confirmação (como criar uma task ou mandar uma mensagem) não roda sozinha: a CLI mostra o pedido inteiro e pergunta `Executar? [s/n]`. Só `s` executa, uma vez; o pedido vale 10 minutos. Sem terminal, nada é executado.
101
+
87
102
  No Windows, se o caminho tiver espaço ou apóstrofo, entre na pasta com aspas duplas: `cd "C:\caminho\da\pasta"`.
88
103
 
89
104
  ## O que o Omni lê e envia
@@ -103,5 +118,6 @@ npx -y omnispace@latest env push
103
118
 
104
119
  Com a CLI logada, dentro da pasta do espaço: a CLI lê os arquivos `.env` da raiz (menos `.env.example`, `.env.sample` e `.env.template`) e pergunta chave por chave se ela vai para o cofre do espaço. Mostra o nome, o arquivo e no máximo os 4 últimos caracteres do valor. Antes de enviar, pede que você confirme no navegador, logado na sua conta, digitando o código do terminal. O valor vai cifrado para o cofre (Vault) e não aparece em tela, log nem resposta. Sem terminal, nada é enviado.
105
120
 
121
+ - Chaves que formam um conector já ligam o conector do espaço, depois de um teste de conexão: Supabase com `<PREFIXO>_URL` e `<PREFIXO>_SERVICE_ROLE_KEY` (ou `_SERVICE_KEY`), como `SUPABASE_URL` ou `SUPABASE_TOOLS_URL`; ClickUp com o token (`CLICKUP_API_KEY` ou `CLICKUP_<NOME>_TOKEN`) e o `CLICKUP_TEAM_ID`. Se houver mais de um projeto para o mesmo conector, a CLI pergunta qual liga.
106
122
  - `env list` mostra as chaves guardadas (só nomes).
107
123
  - `env rm <CHAVE> [--arquivo .env.local]` apaga do cofre de vez, também com confirmação no navegador. Se um conector usava a chave, ele fica desligado.
package/dist/ask.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { basename, resolve } from "node:path";
2
+ import { createInterface } from "node:readline/promises";
2
3
  import { callApi } from "./api.js";
3
4
  import { loadToken, readSpaceConfig } from "./config.js";
4
5
  export const ASK_USAGE = 'Uso: omnispace ask "<pergunta>" [pasta] [--fontes] [--detalhes]';
@@ -19,6 +20,32 @@ export function parseAskArgs(rest, cwd = process.cwd()) {
19
20
  details: rest.includes("--detalhes"),
20
21
  };
21
22
  }
23
+ /**
24
+ * Text that came from the model or a connector, made safe for the terminal: no escape sequences or
25
+ * control characters (an ANSI code could hide part of what the person is about to confirm).
26
+ */
27
+ export function terminalSafe(text) {
28
+ return text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
29
+ }
30
+ const hhmm = (iso) => {
31
+ const d = new Date(iso);
32
+ return Number.isNaN(d.getTime()) ? iso : d.toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" });
33
+ };
34
+ /** A pending request shown in full: connector, action and every field of the input, as it will run. */
35
+ export function formatPending(p) {
36
+ const out = [`Pedido ${terminalSafe(p.ref)}, aguardando sua decisão (vale até ${hhmm(p.expiresAt)}):`, ` ${terminalSafe(p.connector)}: ${terminalSafe(p.actionName)} (${terminalSafe(p.action)})`];
37
+ const input = p.input && typeof p.input === "object" && !Array.isArray(p.input) ? p.input : { entrada: p.input };
38
+ for (const [key, value] of Object.entries(input)) {
39
+ const text = typeof value === "string" ? value : JSON.stringify(value);
40
+ const lines = terminalSafe(text ?? "").split("\n");
41
+ out.push(` ${terminalSafe(key)}: ${lines[0]}`);
42
+ for (const line of lines.slice(1))
43
+ out.push(` ${line}`);
44
+ }
45
+ if (Object.keys(input).length === 0)
46
+ out.push(" (sem entrada)");
47
+ return out;
48
+ }
22
49
  const REASON = { index: "index.md", log: "log.md", link: "apontado no index", termo: "busca por termo" };
23
50
  /** What `omnispace ask` prints, as lines. `baseVersion`: the version this folder is on. */
24
51
  export function formatAsk(res, opts) {
@@ -35,16 +62,24 @@ export function formatAsk(res, opts) {
35
62
  consultedList();
36
63
  }
37
64
  else {
38
- out.push("Resposta:", res.answer ?? "", "");
65
+ out.push("Resposta:", terminalSafe(res.answer ?? ""), "");
39
66
  const sources = res.sources ?? [];
67
+ const calls = res.connectorCalls ?? [];
40
68
  if (sources.length > 0) {
41
69
  out.push("Fontes:");
42
70
  for (const s of sources)
43
71
  out.push(` [${s.id}] ${s.path} (versão ${s.version})`);
44
72
  }
45
- else {
73
+ else if (!calls.some((c) => c.cited)) {
46
74
  out.push("Fontes: nenhuma citada. Esta resposta não se apoia em arquivo do espaço.");
47
75
  }
76
+ if (calls.length > 0) {
77
+ out.push("Conectores consultados:");
78
+ for (const c of calls) {
79
+ const status = c.ok ? "" : `, falhou: ${terminalSafe(c.error ?? "sem detalhe")}`;
80
+ out.push(` [${c.id}] ${terminalSafe(c.connector)}, ${terminalSafe(c.actionName)} (${hhmm(c.at)}${c.cited ? "" : ", não citado"}${status})`);
81
+ }
82
+ }
48
83
  }
49
84
  out.push("", "Limites:");
50
85
  for (const limit of res.limits)
@@ -70,12 +105,50 @@ export function formatAsk(res, opts) {
70
105
  }
71
106
  return out;
72
107
  }
108
+ async function askTerminal(question) {
109
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
110
+ try {
111
+ return (await rl.question(question)).trim();
112
+ }
113
+ finally {
114
+ rl.close();
115
+ }
116
+ }
73
117
  /**
74
- * `omnispace ask`: a question about this folder's space, answered by the server from the latest
75
- * published version. Reads nothing from the folder besides .omni/space.json, and writes nothing.
76
- * Never retried: an answer costs tokens.
118
+ * The s/n for each pending request (C4): the person sees it in full and decides. Only "s" runs it;
119
+ * anything else is a refusal. Without a terminal nothing is asked and nothing runs: the request
120
+ * just expires. The confirmation is never retried: it runs an action.
77
121
  */
78
- export async function askSpace(args) {
122
+ export async function decidePending(pending, ctx, opts) {
123
+ if (pending.length === 0)
124
+ return;
125
+ const interactive = opts.interactive ?? Boolean(process.stdin.isTTY);
126
+ for (const p of pending) {
127
+ for (const line of formatPending(p))
128
+ console.log(line);
129
+ if (!interactive) {
130
+ console.log(" Nada foi executado: sem terminal para confirmar. O pedido vence sozinho; para fazer, rode a pergunta de novo num terminal.\n");
131
+ continue;
132
+ }
133
+ const answer = (await (opts.ask ?? askTerminal)(" Executar? [s/n] ")).trim().toLowerCase();
134
+ const decision = answer === "s" || answer === "sim" ? "s" : "n";
135
+ const res = await callApi(ctx.apiUrl, ctx.token, "/api/cli/actions/confirm", { spaceId: ctx.spaceId, requestId: p.id, decision });
136
+ if (decision === "n") {
137
+ console.log(res.ok ? " Recusado. Nada foi executado.\n" : ` ${terminalSafe(String(res.error))}\n`);
138
+ continue;
139
+ }
140
+ if (res.ok) {
141
+ console.log(" ✓ Executado.");
142
+ if (typeof res.output === "string")
143
+ console.log(` Resultado: ${terminalSafe(res.output)}`);
144
+ console.log("");
145
+ }
146
+ else {
147
+ console.log(` ✗ Não executado: ${terminalSafe(String(res.error))}\n`);
148
+ }
149
+ }
150
+ }
151
+ export async function askSpace(args, opts = {}) {
79
152
  const config = await readSpaceConfig(args.folder);
80
153
  if (!config) {
81
154
  console.log(`\n${basename(args.folder)} não é um espaço Omni (não tem .omni/space.json).`);
@@ -107,9 +180,10 @@ export async function askSpace(args) {
107
180
  return 1;
108
181
  }
109
182
  console.log("");
110
- const opts = { baseVersion: config.baseVersion, sourcesOnly: args.sourcesOnly, details: args.details };
111
- for (const line of formatAsk(res, opts))
183
+ const answer = res;
184
+ for (const line of formatAsk(answer, { baseVersion: config.baseVersion, sourcesOnly: args.sourcesOnly, details: args.details }))
112
185
  console.log(line);
113
186
  console.log("");
187
+ await decidePending(answer.pending ?? [], { apiUrl: config.apiUrl, token, spaceId: config.spaceId }, opts);
114
188
  return 0;
115
189
  }
@@ -0,0 +1,88 @@
1
+ // Copy of apps/omni/src/lib/env-connectors.ts (C4 item 1, docs/plan/c4-ferramentas-no-ask.md): the
2
+ // server decides what it binds; the CLI uses the same rules only to ask, before sending, which
3
+ // group turns on the connector when the chosen keys have more than one. Change both together; the
4
+ // tests on each side use the same cases.
5
+ const SUPABASE_KEY_SUFFIXES = ["_SERVICE_ROLE_KEY", "_SERVICE_KEY"];
6
+ const CLICKUP_TOKEN = /^CLICKUP(?:_[A-Z0-9]+)*_(?:API_KEY|API_TOKEN|TOKEN)$/;
7
+ const CLICKUP_TEAM_KEYS = ["CLICKUP_TEAM_ID", "CLICKUP_WORKSPACE_ID"];
8
+ /**
9
+ * Supabase: `<P>_URL` with `<P>_SERVICE_ROLE_KEY` or `<P>_SERVICE_KEY`, where P has SUPABASE in it
10
+ * (SUPABASE, SUPABASE_TOOLS, NOVODASH_SUPABASE...). `NEXT_PUBLIC_<P>_URL` counts as `<P>_URL`, and
11
+ * `<P>_URL` wins when both exist. ClickUp: a token key (`CLICKUP_API_KEY`, `CLICKUP_API_TOKEN`,
12
+ * `CLICKUP_TOKEN`, `CLICKUP_<X>_TOKEN`) with `CLICKUP_TEAM_ID` (or `CLICKUP_WORKSPACE_ID`).
13
+ */
14
+ export function detectEnvGroups(keyNames) {
15
+ const keys = new Set(keyNames);
16
+ const groups = [];
17
+ const partial = [];
18
+ // Supabase
19
+ const urlByPrefix = new Map();
20
+ for (const key of keys) {
21
+ const m = /^(?:NEXT_PUBLIC_)?(.*SUPABASE.*)_URL$/.exec(key);
22
+ if (!m)
23
+ continue;
24
+ const prefix = m[1];
25
+ const current = urlByPrefix.get(prefix);
26
+ if (!current || current.startsWith("NEXT_PUBLIC_"))
27
+ urlByPrefix.set(prefix, key);
28
+ }
29
+ for (const [prefix, urlKey] of [...urlByPrefix].sort(([a], [b]) => a.localeCompare(b))) {
30
+ const serviceKey = SUPABASE_KEY_SUFFIXES.map((s) => `${prefix}${s}`).find((k) => keys.has(k));
31
+ if (!serviceKey)
32
+ continue;
33
+ groups.push({ connectorId: "supabase", label: prefix, credential: { service_role_key: serviceKey }, config: { project_url: urlKey } });
34
+ }
35
+ if (!groups.some((g) => g.connectorId === "supabase")) {
36
+ const loose = [...keys]
37
+ .filter((k) => /SUPABASE/.test(k) && (/_URL$/.test(k) || SUPABASE_KEY_SUFFIXES.some((s) => k.endsWith(s))))
38
+ .sort();
39
+ // A public URL alone is what every Supabase front end has: not a half-done connector.
40
+ if (loose.some((k) => !k.startsWith("NEXT_PUBLIC_")))
41
+ partial.push({ connectorId: "supabase", keys: loose });
42
+ }
43
+ // ClickUp
44
+ const tokens = [...keys].filter((k) => CLICKUP_TOKEN.test(k)).sort();
45
+ const team = CLICKUP_TEAM_KEYS.find((k) => keys.has(k));
46
+ if (tokens.length > 0 && team) {
47
+ for (const token of tokens) {
48
+ groups.push({ connectorId: "clickup", label: token, credential: { api_key: token }, config: { team_id: team } });
49
+ }
50
+ }
51
+ else if (tokens.length > 0 || team) {
52
+ partial.push({ connectorId: "clickup", keys: [...tokens, ...(team ? [team] : [])] });
53
+ }
54
+ return { groups, partial };
55
+ }
56
+ /**
57
+ * The group that turns on each connector: the only one, or the one the person picked. `null` in
58
+ * `choice` means the person said none. More than one and no valid pick: ambiguous, nothing is bound.
59
+ */
60
+ export function chooseGroups(groups, choice = {}) {
61
+ const chosen = [];
62
+ const ambiguous = [];
63
+ const byConnector = new Map();
64
+ for (const g of groups)
65
+ byConnector.set(g.connectorId, [...(byConnector.get(g.connectorId) ?? []), g]);
66
+ for (const [connectorId, list] of byConnector) {
67
+ if (connectorId in choice) {
68
+ const picked = choice[connectorId];
69
+ if (picked === null)
70
+ continue;
71
+ const match = list.find((g) => g.label === picked);
72
+ if (match)
73
+ chosen.push(match);
74
+ else
75
+ ambiguous.push({ connectorId, labels: list.map((g) => g.label) });
76
+ continue;
77
+ }
78
+ if (list.length === 1)
79
+ chosen.push(list[0]);
80
+ else
81
+ ambiguous.push({ connectorId, labels: list.map((g) => g.label) });
82
+ }
83
+ return { chosen, ambiguous };
84
+ }
85
+ /** A ClickUp team (workspace) id: digits only. */
86
+ export function isClickUpTeamId(value) {
87
+ return /^\d{1,20}$/.test(value.trim());
88
+ }
package/dist/env.js CHANGED
@@ -3,6 +3,7 @@ import { createInterface } from "node:readline/promises";
3
3
  import { callApi } from "./api.js";
4
4
  import { insecureApiUrl, loadToken, readSpaceConfig } from "./config.js";
5
5
  import { maskValue, readEnvVars } from "./dotenv.js";
6
+ import { chooseGroups, detectEnvGroups } from "./env-connectors.js";
6
7
  import { openBrowser } from "./login.js";
7
8
  import { RETRY_DELAYS_MS } from "./retry.js";
8
9
  const STATUS_TEXT = {
@@ -11,9 +12,9 @@ const STATUS_TEXT = {
11
12
  error: "com erro",
12
13
  disabled: "desligado",
13
14
  };
14
- const USAGE = `Uso:
15
- omnispace env push [pasta] pergunta chave por chave o que vai para o cofre do espaço
16
- omnispace env list [pasta] mostra as chaves guardadas (só nomes)
15
+ const USAGE = `Uso:
16
+ omnispace env push [pasta] pergunta chave por chave o que vai para o cofre do espaço
17
+ omnispace env list [pasta] mostra as chaves guardadas (só nomes)
17
18
  omnispace env rm <CHAVE>... [--arquivo .env] [--pasta <pasta>] apaga do cofre, de vez`;
18
19
  async function askTerminal(question) {
19
20
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -25,7 +26,7 @@ async function askTerminal(question) {
25
26
  }
26
27
  }
27
28
  /** The space of this folder and this machine's session, or a printed reason why not. */
28
- async function context(root) {
29
+ export async function context(root) {
29
30
  const config = await readSpaceConfig(root);
30
31
  if (!config?.spaceId) {
31
32
  console.error(config ? "O espaço desta pasta ainda não foi criado. Termine a conta no navegador." : "Esta pasta não é um espaço Omni. Rode omnispace init primeiro.");
@@ -44,7 +45,7 @@ async function context(root) {
44
45
  }
45
46
  return { apiUrl: config.apiUrl, token, spaceId: config.spaceId };
46
47
  }
47
- function refused(res, what) {
48
+ export function refused(res, what) {
48
49
  console.error(`${what}: ${res.error}`);
49
50
  if (res.expired || res.status === 401)
50
51
  console.error("Para entrar de novo: omnispace login");
@@ -53,7 +54,7 @@ function refused(res, what) {
53
54
  * Asks for the on-the-spot verification and waits for the browser. Returns the device code to send
54
55
  * with the action, or null (refused, expired, failed).
55
56
  */
56
- async function verify(ctx, action, keys, opts) {
57
+ export async function verify(ctx, action, keys, opts) {
57
58
  const log = opts.log ?? ((line) => console.log(line));
58
59
  const start = await callApi(ctx.apiUrl, ctx.token, "/api/cli/verify/start", { spaceId: ctx.spaceId, action, keys }, { retryDelaysMs: RETRY_DELAYS_MS });
59
60
  if (!start.ok) {
@@ -93,6 +94,31 @@ async function readVault(ctx) {
93
94
  }
94
95
  return { keys: res.keys, connectors: (res.connectors ?? []) };
95
96
  }
97
+ const CONNECTOR_NAME = { supabase: "Supabase", clickup: "ClickUp" };
98
+ /**
99
+ * When the chosen keys hold more than one set for the same connector (the Novo Dash has two Supabase
100
+ * projects), the person picks which one turns it on; the others stay only in the vault (C4 item 1).
101
+ * An answer that is not on the list counts as none: nothing is bound by guess.
102
+ */
103
+ export async function chooseConnectorGroups(accepted, ask, log) {
104
+ const { groups } = detectEnvGroups(accepted.map((v) => v.key));
105
+ const { ambiguous } = chooseGroups(groups);
106
+ const connect = {};
107
+ for (const { connectorId, labels } of ambiguous) {
108
+ const name = CONNECTOR_NAME[connectorId];
109
+ log("");
110
+ log(`Há mais de um conjunto de chaves para o ${name}. Qual liga o conector ${name} deste espaço?`);
111
+ labels.forEach((label, i) => log(` ${i + 1}) ${label}`));
112
+ log(" n) nenhum (as chaves ficam só no cofre)");
113
+ const answer = (await ask(`Escolha [1-${labels.length}/n] `)).trim().toLowerCase();
114
+ const index = Number.parseInt(answer, 10);
115
+ const picked = /^\d+$/.test(answer) && index >= 1 && index <= labels.length ? labels[index - 1] : null;
116
+ if (!picked && answer !== "n")
117
+ log(`Resposta fora da lista: o ${name} não será ligado agora.`);
118
+ connect[connectorId] = picked;
119
+ }
120
+ return connect;
121
+ }
96
122
  async function push(root, opts) {
97
123
  const log = opts.log ?? ((line) => console.log(line));
98
124
  const ask = opts.ask ?? askTerminal;
@@ -133,6 +159,7 @@ async function push(root, opts) {
133
159
  log("\nNada escolhido. Nada foi enviado.");
134
160
  return 0;
135
161
  }
162
+ const connect = await chooseConnectorGroups(accepted, ask, log);
136
163
  const keys = accepted.map(({ file, key }) => ({ file, key }));
137
164
  const deviceCode = await verify(ctx, "env_push", keys, opts);
138
165
  if (!deviceCode)
@@ -141,6 +168,7 @@ async function push(root, opts) {
141
168
  spaceId: ctx.spaceId,
142
169
  verification: deviceCode,
143
170
  entries: accepted.map(({ file, key, value }) => ({ file, key, value })),
171
+ ...(Object.keys(connect).length > 0 ? { connect } : {}),
144
172
  });
145
173
  if (!res.ok) {
146
174
  refused(res, "O cofre recusou o envio");
package/dist/include.js CHANGED
@@ -9,6 +9,15 @@ export const SKIP_DIRS = new Set([
9
9
  "node_modules", ".git", ".next", ".vercel", "dist", "build", "out", "coverage",
10
10
  ".turbo", ".cache", "venv", ".venv", "__pycache__", ".idea", ".vscode", "target",
11
11
  ]);
12
+ /**
13
+ * Folders skipped by where they are, not by their name alone. `.claude/worktrees/` holds full copies
14
+ * of a repo that Claude Code sessions work on in parallel (Lucas, 2026-09-28): synced, every copy
15
+ * would go up as text. Matches at any depth (a repo inside the space can have its own).
16
+ */
17
+ const SKIP_DIR_PATHS_RE = /(^|\/)\.claude\/worktrees$/i;
18
+ export function isSkippedDirPath(path) {
19
+ return SKIP_DIR_PATHS_RE.test(path);
20
+ }
12
21
  // Local state of the Omni link itself: machine specific, never part of the space. `.omni/tmp/`
13
22
  // holds downloads of an unfinished pull (fatia 3); `.omni/conflicts.json` and `.omni/backups/`
14
23
  // are the conflict resolution of this machine (fatia 4); `.omni/pull-pending.json` and
@@ -74,7 +83,8 @@ export function isMachineConfig(path) {
74
83
  */
75
84
  export function neverSyncs(path) {
76
85
  const name = path.slice(path.lastIndexOf("/") + 1);
77
- return isSecret(name) || isMachineConfig(path) || isLocalOnly(path);
86
+ const inSkippedDir = /(^|\/)\.claude\/worktrees\//i.test(path);
87
+ return isSecret(name) || isMachineConfig(path) || isLocalOnly(path) || inSkippedDir;
78
88
  }
79
89
  export function isText(name) {
80
90
  const lower = name.toLowerCase();
@@ -143,7 +153,7 @@ export async function buildManifest(root) {
143
153
  const abs = join(dir, entry.name);
144
154
  const relParts = [...parts, entry.name];
145
155
  if (entry.isDirectory()) {
146
- if (SKIP_DIRS.has(entry.name))
156
+ if (SKIP_DIRS.has(entry.name) || isSkippedDirPath(normalizePath(relParts)))
147
157
  continue;
148
158
  // A subfolder with its own .git (repo, submodule or worktree) DOES sync, text only: the
149
159
  // agent needs the code to act on it (find a bug, fix it, report back). Lucas, 2026-09-23,
package/dist/index.js CHANGED
@@ -18,30 +18,32 @@ import { terminalAsk, unlinkMachine } from "./desvincular.js";
18
18
  import { linkExistingSpace } from "./vincular.js";
19
19
  import { askSpace, parseAskArgs } from "./ask.js";
20
20
  import { runEnv } from "./env.js";
21
- const USAGE = `
22
- Omni CLI
23
-
24
- Uso:
25
- omni login entra na sua conta pelo navegador (a sessão vale 14 dias sem uso)
26
- omni logout encerra a sessão da CLI nesta máquina
27
- omni whoami mostra a conta, a máquina e até quando a sessão vale
28
- omni init [pasta] [--nome <nome>] com a CLI logada, cria o espaço na sua conta e liga a pasta
29
- omni init [pasta] --scan-only varre a pasta e mostra o que encontrou
30
- omni init [pasta] --link <código> varre a pasta e envia pro código mostrado no navegador
31
- omni status [pasta] [--resumo] o que mudou aqui e no servidor, e o próximo comando (só lê)
32
- omni ask "<pergunta>" [pasta] responde com base na versão publicada do espaço, com as fontes
33
- [--fontes] só mostra quais arquivos seriam lidos, sem chamar o modelo
34
- [--detalhes] mostra também custo, contexto e arquivos consultados
35
- omni sync [pasta] --push [--yes] publica uma nova versão do espaço no servidor
36
- omni sync [pasta] --pull traz para a pasta a versão mais nova do servidor
37
- [--conflito=local|mesclar|backup] mesma resposta para todos os conflitos, sem perguntar
38
- omni clone <código> <pasta> baixa um espaço da sua conta numa pasta nova desta máquina
39
- omni vincular [pasta] [--espaco <nome>] liga uma pasta que já tem os arquivos a um espaço da conta
40
- omni desvincular [pasta] tira esta máquina da conta e o .omni/ das pastas (pede 2 confirmações)
41
- omni env push|list|rm [pasta] guarda chaves do .env no cofre do espaço, uma a uma (confirma no navegador)
42
-
43
- A CLI não reorganiza a sua pasta. Com --link, o que fazer com o que foi
44
- varrido é decidido na tela de revisão, no navegador.
21
+ import { runPermissoes } from "./permissoes.js";
22
+ const USAGE = `
23
+ Omni CLI
24
+
25
+ Uso:
26
+ omni login entra na sua conta pelo navegador (a sessão vale 14 dias sem uso)
27
+ omni logout encerra a sessão da CLI nesta máquina
28
+ omni whoami mostra a conta, a máquina e até quando a sessão vale
29
+ omni init [pasta] [--nome <nome>] com a CLI logada, cria o espaço na sua conta e liga a pasta
30
+ omni init [pasta] --scan-only varre a pasta e mostra o que encontrou
31
+ omni init [pasta] --link <código> varre a pasta e envia pro código mostrado no navegador
32
+ omni status [pasta] [--resumo] o que mudou aqui e no servidor, e o próximo comando (só lê)
33
+ omni ask "<pergunta>" [pasta] responde com base na versão publicada do espaço, com as fontes
34
+ [--fontes] só mostra quais arquivos seriam lidos, sem chamar o modelo
35
+ [--detalhes] mostra também custo, contexto e arquivos consultados
36
+ omni sync [pasta] --push [--yes] publica uma nova versão do espaço no servidor
37
+ omni sync [pasta] --pull traz para a pasta a versão mais nova do servidor
38
+ [--conflito=local|mesclar|backup] mesma resposta para todos os conflitos, sem perguntar
39
+ omni clone <código> <pasta> baixa um espaço da sua conta numa pasta nova desta máquina
40
+ omni vincular [pasta] [--espaco <nome>] liga uma pasta que já tem os arquivos a um espaço da conta
41
+ omni desvincular [pasta] tira esta máquina da conta e o .omni/ das pastas (pede 2 confirmações)
42
+ omni env push|list|rm [pasta] guarda chaves do .env no cofre do espaço, uma a uma (confirma no navegador)
43
+ omni permissoes [set <conector> <ação> <estado>] [pasta] o que o agente pode fazer sozinho em cada ação
44
+
45
+ A CLI não reorganiza a sua pasta. Com --link, o que fazer com o que foi
46
+ varrido é decidido na tela de revisão, no navegador.
45
47
  `;
46
48
  async function main(argv) {
47
49
  const args = argv.slice(2);
@@ -67,6 +69,8 @@ async function main(argv) {
67
69
  return runClone(args.slice(1));
68
70
  if (command === "env")
69
71
  return runEnv(args.slice(1), { openBrowser: process.stdout.isTTY });
72
+ if (command === "permissoes")
73
+ return runPermissoes(args.slice(1), { openBrowser: process.stdout.isTTY });
70
74
  if (command === "vincular") {
71
75
  const rest = args.slice(1);
72
76
  const espacoIndex = rest.indexOf("--espaco");
@@ -114,20 +118,20 @@ async function main(argv) {
114
118
  if (!who.ok && !who.noToken)
115
119
  console.error(`\n${who.error}`);
116
120
  const site = defaultApiUrl();
117
- console.log(`
118
- Omni CLI
119
-
120
- Já tem conta no Omni? Entre por aqui e rode o init de novo:
121
- npx -y omnispace@latest login
122
-
123
- Primeira vez? Comece pelo navegador:
124
- ${site}/omnispace/escolha
125
-
126
- Lá aparece um código (tipo ABCD-2345). Com ele, dentro da pasta do espaço, rode:
127
- npx -y omnispace@latest init . --link <código>
128
-
129
- Só quer ver o que o Omni leria nesta pasta, sem enviar nada?
130
- npx -y omnispace@latest init . --scan-only
121
+ console.log(`
122
+ Omni CLI
123
+
124
+ Já tem conta no Omni? Entre por aqui e rode o init de novo:
125
+ npx -y omnispace@latest login
126
+
127
+ Primeira vez? Comece pelo navegador:
128
+ ${site}/omnispace/escolha
129
+
130
+ Lá aparece um código (tipo ABCD-2345). Com ele, dentro da pasta do espaço, rode:
131
+ npx -y omnispace@latest init . --link <código>
132
+
133
+ Só quer ver o que o Omni leria nesta pasta, sem enviar nada?
134
+ npx -y omnispace@latest init . --scan-only
131
135
  `);
132
136
  return who.ok || who.noToken ? 0 : 1;
133
137
  }
@@ -0,0 +1,92 @@
1
+ import { resolve } from "node:path";
2
+ import { callApi } from "./api.js";
3
+ import { RETRY_DELAYS_MS } from "./retry.js";
4
+ import { context, refused, verify } from "./env.js";
5
+ // `omnispace permissoes` (docs/plan/permissoes-por-acao.md; Lucas, 2026-09-28): what the agent may
6
+ // do on its own with each connector action of the space. Tightening applies right away; loosening
7
+ // asks for the confirmation in the browser, like a credential.
8
+ const STATES = ["automatico", "confirmar", "bloqueado"];
9
+ const STATE_LABEL = { automatico: "automático", confirmar: "pedir confirmação", bloqueado: "bloqueado" };
10
+ const ORIGIN_LABEL = { padrao: "padrão do Omni", usuario: "definida por você", importacao: "importada" };
11
+ const USAGE = `Uso:
12
+ omnispace permissoes [pasta] lista as ações dos conectores do espaço e o que o agente pode fazer
13
+ omnispace permissoes set <conector> <ação> <estado> [pasta] muda uma ação: automatico, confirmar ou bloqueado
14
+ Liberar (bloqueado para confirmar, ou para automático) pede confirmação no navegador; restringir vale na hora.`;
15
+ export function parseStateArg(value) {
16
+ const plain = (value ?? "").toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
17
+ return STATES.find((s) => s === plain) ?? null;
18
+ }
19
+ async function list(root, opts) {
20
+ const log = opts.log ?? ((line) => console.log(line));
21
+ const ctx = await context(root);
22
+ if (!ctx)
23
+ return 1;
24
+ const res = await callApi(ctx.apiUrl, ctx.token, "/api/cli/permissions/list", { spaceId: ctx.spaceId }, { retryDelaysMs: RETRY_DELAYS_MS });
25
+ if (!res.ok) {
26
+ refused(res, "Falha ao ler as permissões");
27
+ return 1;
28
+ }
29
+ const rows = res.permissions;
30
+ if (rows.length === 0) {
31
+ log("Este espaço ainda não tem conector. As permissões aparecem aqui quando houver um (omnispace env push).");
32
+ return 0;
33
+ }
34
+ let current = "";
35
+ for (const r of rows) {
36
+ if (r.connectorId !== current) {
37
+ current = r.connectorId;
38
+ log(`\n${r.connectorName} (${r.connectorId})`);
39
+ }
40
+ log(` ${r.actionKey.padEnd(22)} ${r.actionName.padEnd(26)} ${r.kind.padEnd(10)} ${STATE_LABEL[r.state].padEnd(18)} ${ORIGIN_LABEL[r.origin] ?? r.origin}`);
41
+ }
42
+ log("\nPara mudar: omnispace permissoes set <conector> <ação> <automatico|confirmar|bloqueado>");
43
+ return 0;
44
+ }
45
+ async function set(root, connectorId, actionKey, state, opts) {
46
+ const log = opts.log ?? ((line) => console.log(line));
47
+ const ctx = await context(root);
48
+ if (!ctx)
49
+ return 1;
50
+ const changes = [{ connectorId, actionKey, state }];
51
+ // Not retried: a repeat after a cut connection is harmless (same state), but it is reported.
52
+ let res = await callApi(ctx.apiUrl, ctx.token, "/api/cli/permissions/set", { spaceId: ctx.spaceId, changes });
53
+ if (!res.ok && res.needsVerification) {
54
+ log(`Liberar ${connectorId}:${actionKey} (${STATE_LABEL[state]}) deixa o agente fazer mais sozinho.`);
55
+ const interactive = opts.interactive ?? Boolean(process.stdin.isTTY);
56
+ if (!interactive) {
57
+ console.error("Sem terminal para acompanhar a confirmação no navegador. Nada mudou.");
58
+ return 1;
59
+ }
60
+ const code = await verify(ctx, "perm_set", [{ file: `${connectorId}:${actionKey}`, key: state }], opts);
61
+ if (!code)
62
+ return 1;
63
+ res = await callApi(ctx.apiUrl, ctx.token, "/api/cli/permissions/set", { spaceId: ctx.spaceId, changes, verification: code });
64
+ }
65
+ if (!res.ok) {
66
+ refused(res, "A permissão não mudou");
67
+ return 1;
68
+ }
69
+ const applied = res.applied;
70
+ if (applied.length === 0)
71
+ log(`${connectorId}:${actionKey} já estava como ${STATE_LABEL[state]}. Nada mudou.`);
72
+ else
73
+ log(`✓ ${connectorId}:${actionKey}: ${STATE_LABEL[applied[0].from]} → ${STATE_LABEL[applied[0].to]}.`);
74
+ return 0;
75
+ }
76
+ export async function runPermissoes(args, opts = {}) {
77
+ const [first, ...rest] = args;
78
+ if (first === "set") {
79
+ const [connectorId, actionKey, stateArg, folder] = rest;
80
+ const state = parseStateArg(stateArg);
81
+ if (!connectorId || !actionKey || !state) {
82
+ console.error(USAGE);
83
+ return 1;
84
+ }
85
+ return set(resolve(folder ?? process.cwd()), connectorId, actionKey, state, opts);
86
+ }
87
+ if (first === "--help" || first === "-h" || first === "help") {
88
+ console.log(USAGE);
89
+ return 0;
90
+ }
91
+ return list(resolve(first && first !== "list" ? first : (rest[0] ?? process.cwd())), opts);
92
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omnispace",
3
- "version": "0.11.0",
3
+ "version": "0.13.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": {