chatflux-gestao-agente 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # `gestao` — o portal pela linha de comando e por agente (MCP)
2
+
3
+ Deixa um agente (Claude Code, Claude Desktop, qualquer harness que fale MCP)
4
+ operar o Gestão **com a sua identidade e o seu papel** — nada além do que você já
5
+ faz na tela, e tudo no histórico com o seu nome.
6
+
7
+ ## Instalar (equipe — um comando, sem clonar nada)
8
+
9
+ ```sh
10
+ claude mcp add --scope user gestao -- npx -y chatflux-gestao-agente mcp
11
+ ```
12
+
13
+ Pronto. Abra o Claude Code e peça qualquer coisa do portal: na primeira vez ele
14
+ mesmo abre o navegador para você autorizar. **Você não digita mais nenhum
15
+ comando** — nem `login`, nem `status`.
16
+
17
+ ### Instalar (desenvolvimento, dentro deste repo)
18
+
19
+ ```sh
20
+ npm link
21
+ claude mcp add --scope user gestao -- gestao mcp
22
+ ```
23
+
24
+ **`--scope user` é o ponto:** registra no seu `~/.claude.json` e vale em
25
+ **qualquer diretório**. Não crie `.mcp.json` na raiz de um repo — aquilo é escopo
26
+ `project`, viaja no git e amarra o Gestão a um repositório só.
27
+
28
+ Para apontar ao dev: `claude mcp add --scope user -e GESTAO_ENV=dev gestao -- gestao mcp`.
29
+
30
+ No Claude Desktop (que não tem a CLI), a mesma coisa em
31
+ `~/Library/Application Support/Claude/claude_desktop_config.json`:
32
+
33
+ ```json
34
+ { "mcpServers": { "gestao": { "command": "gestao", "args": ["mcp"] } } }
35
+ ```
36
+
37
+ ## Como a identidade funciona
38
+
39
+ ```
40
+ gestao login
41
+ └─ sobe um listener em 127.0.0.1:<porta efêmera>
42
+ └─ abre <portal>/agente/login?port=…&state=…
43
+ └─ a página (só staff) mostra quem você é e pede confirmação
44
+ └─ o browser — que JÁ tem sua sessão do Clerk — emite o JWT do
45
+ template `convex` e entrega no loopback
46
+ └─ guarda no Keychain (macOS) ou em ~/.config/gestao/token.json (0600)
47
+ ```
48
+
49
+ Vale **1 hora**. E na prática você não faz nada disso à mão: o próprio agente
50
+ chama a tool `gestao_conectar` quando precisa, o navegador abre, você clica em
51
+ **Autorizar** e ele refaz a ação. O `gestao login` no terminal existe como
52
+ alternativa, não como o caminho normal.
53
+
54
+ **O servidor NÃO morre quando a sessão expira.** Ele sobe sempre: lista as tools a
55
+ partir do último contrato buscado (cache em `~/.config/gestao/`), e `gestao_conectar`
56
+ fica SEMPRE disponível — é justamente quando a sessão vence que ela precisa existir.
57
+ Sem isso, a pessoa veria "Failed to connect — Connection closed" de hora em hora,
58
+ sem pista do motivo.
59
+
60
+ O cache é conveniência, nunca fonte: qualquer ação de verdade exige token válido.
61
+
62
+ **Sem segredo compartilhado.** Este pacote nunca vê secret key nem chave de
63
+ serviço. O que ele guarda é um token seu, de vida curta, que o Convex valida
64
+ sozinho. Arquivar sua conta (`active:false`) revoga o agente junto.
65
+
66
+ Por que não OAuth: o Clerk não documenta caminho de credencial OAuth → JWT com
67
+ `aud: "convex"`, e emitir pelo backend exigiria secret key **mais** um
68
+ `session_id` que o token OAuth não carrega. O browser é o único lugar que já tem
69
+ sessão — daí o loopback.
70
+
71
+ ## As tools não estão aqui
72
+
73
+ Elas vêm do **contrato servido pelo deployment** (`/chatflux/contrato`), montado a
74
+ partir das capacidades do L2. Consequências:
75
+
76
+ - capacidade nova no portal vira tool **sem release deste pacote**;
77
+ - nunca há cópia do contrato para apodrecer;
78
+ - o `inputSchema` de cada tool é o JSON Schema gerado do validador Convex real —
79
+ enums de verdade, não `string` solta.
80
+
81
+ Uma tool por **endpoint**, nomeada pela **capacidade** (`gestao_projeto_criar`).
82
+ Capacidade com mais de um endpoint dá o nome limpo ao principal e sufixo aos
83
+ outros (`gestao_projeto_editar` e `gestao_projeto_editar_generate_cover_upload_url`).
84
+
85
+ Tools de `efeito: destrutivo` vêm com `destructiveHint` e um aviso no texto:
86
+ arquivar empresa e encerrar projeto **cascateiam e não têm desfazer**.
87
+
88
+ ## Arquivos
89
+
90
+ | arquivo | o quê |
91
+ |---|---|
92
+ | `bin/gestao.mjs` | CLI: `login`, `status`, `logout`, `mcp` |
93
+ | `lib/login.mjs` | loopback + `state` + abre o navegador |
94
+ | `lib/config.mjs` | ambientes e guarda do token (Keychain / arquivo 0600) |
95
+ | `lib/gestao.mjs` | busca o contrato e chama o Convex por nome |
96
+ | `lib/tools.mjs` | contrato → lista de tools MCP |
97
+ | `lib/mcp.mjs` | servidor MCP (stdio, JSON-RPC) |
98
+
99
+ Zero dependências de propósito: isto roda na máquina de cada pessoa, e cada
100
+ dependência é superfície.
package/bin/gestao.mjs ADDED
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ // CLI do Gestão. Três verbos: `login`, `status`, `mcp`.
3
+
4
+ import { login } from "../lib/login.mjs";
5
+ import { servir } from "../lib/mcp.mjs";
6
+ import { contrato, euSou, NaoAutenticado } from "../lib/gestao.mjs";
7
+ import {
8
+ ambiente, lerToken, apagarToken, expiraEm, tokenValido,
9
+ } from "../lib/config.mjs";
10
+ import { montarTools } from "../lib/tools.mjs";
11
+
12
+ const AJUDA = `gestao — portal Gestão pela linha de comando e por agente (MCP)
13
+
14
+ gestao login autoriza este computador a agir como você (abre o navegador)
15
+ gestao status mostra quem está conectado e quanto tempo falta
16
+ gestao logout esquece a credencial desta máquina
17
+ gestao mcp roda o servidor MCP (é o que o Claude Code/Desktop chama)
18
+
19
+ Ambiente: ${ambiente().nome} (GESTAO_ENV=dev para apontar ao dev)
20
+
21
+ No Claude Code/Desktop, registre assim:
22
+ { "mcpServers": { "gestao": { "command": "gestao", "args": ["mcp"] } } }
23
+ `;
24
+
25
+ const [, , verbo] = process.argv;
26
+
27
+ try {
28
+ switch (verbo) {
29
+ case "login": {
30
+ const { email, onde } = await login();
31
+ const eu = await euSou();
32
+ console.log(
33
+ `Conectado como ${eu?.name ?? email ?? "?"} (${eu?.role ?? "?"}) no ambiente ${ambiente().nome}.`,
34
+ );
35
+ console.log(`Credencial guardada em: ${onde}`);
36
+ console.log("Vale por 1 hora. Quando expirar, rode `gestao login` de novo.");
37
+ break;
38
+ }
39
+
40
+ case "status": {
41
+ const dados = lerToken();
42
+ if (!dados) {
43
+ console.log("Sem credencial nesta máquina. Rode `gestao login`.");
44
+ process.exit(1);
45
+ }
46
+ const restaMin = Math.round((expiraEm(dados.token) - Date.now()) / 60000);
47
+ if (!tokenValido(dados)) {
48
+ console.log(`Credencial EXPIRADA (${dados.email ?? "?"}). Rode \`gestao login\`.`);
49
+ process.exit(1);
50
+ }
51
+ const eu = await euSou();
52
+ const tools = montarTools(await contrato());
53
+ console.log(`Conectado: ${eu?.name ?? "?"} <${eu?.email ?? "?"}> — papel ${eu?.role ?? "?"}`);
54
+ console.log(`Ambiente: ${ambiente().nome}`);
55
+ console.log(`Expira em: ${restaMin} min`);
56
+ console.log(`Tools: ${tools.length} (do contrato servido pelo deployment)`);
57
+ break;
58
+ }
59
+
60
+ case "logout":
61
+ apagarToken();
62
+ console.log("Credencial removida desta máquina.");
63
+ break;
64
+
65
+ case "mcp":
66
+ await servir();
67
+ break;
68
+
69
+ default:
70
+ console.log(AJUDA);
71
+ process.exit(verbo ? 1 : 0);
72
+ }
73
+ } catch (e) {
74
+ if (e instanceof NaoAutenticado) {
75
+ console.error(e.message);
76
+ process.exit(1);
77
+ }
78
+ console.error(`Erro: ${e.message}`);
79
+ process.exit(1);
80
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,102 @@
1
+ // Endereços e armazenamento do token. Sem dependência externa de propósito:
2
+ // este pacote roda na máquina de cada pessoa, e cada dependência é superfície.
3
+
4
+ import { execFileSync } from "node:child_process";
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+
9
+ const AMBIENTES = {
10
+ prod: {
11
+ convex: "https://dependable-stingray-656.convex.cloud",
12
+ site: "https://dependable-stingray-656.convex.site",
13
+ portal: "https://portal.chatflux.ai",
14
+ },
15
+ dev: {
16
+ convex: "https://agreeable-woodpecker-190.convex.cloud",
17
+ site: "https://agreeable-woodpecker-190.convex.site",
18
+ portal: "http://localhost:3000",
19
+ },
20
+ };
21
+
22
+ /** `GESTAO_ENV=dev` troca de ambiente inteiro. Default prod: quem usa isto é a
23
+ * equipe, não o dev do portal. */
24
+ export function ambiente() {
25
+ const nome = process.env.GESTAO_ENV === "dev" ? "dev" : "prod";
26
+ return { nome, ...AMBIENTES[nome] };
27
+ }
28
+
29
+ // ---- token ------------------------------------------------------------------
30
+ //
31
+ // Keychain do macOS quando houver (é onde o SO já guarda segredo e o usuário
32
+ // controla o acesso); arquivo 0600 como fallback nos outros sistemas. O token
33
+ // vale 1h — vazamento tem janela curta, mas ainda assim não fica em texto puro
34
+ // no macOS, que é a máquina real da equipe.
35
+
36
+ const SERVICO = "chatflux-gestao";
37
+ const ARQUIVO = path.join(os.homedir(), ".config", "gestao", "token.json");
38
+ const temKeychain = () => process.platform === "darwin";
39
+
40
+ export function salvarToken(dados) {
41
+ const json = JSON.stringify(dados);
42
+ if (temKeychain()) {
43
+ try {
44
+ execFileSync("security", [
45
+ "add-generic-password", "-U",
46
+ "-s", SERVICO, "-a", ambiente().nome, "-w", json,
47
+ ], { stdio: "ignore" });
48
+ return "keychain";
49
+ } catch {
50
+ /* cai no arquivo */
51
+ }
52
+ }
53
+ fs.mkdirSync(path.dirname(ARQUIVO), { recursive: true });
54
+ fs.writeFileSync(ARQUIVO, json, { mode: 0o600 });
55
+ return ARQUIVO;
56
+ }
57
+
58
+ export function lerToken() {
59
+ if (temKeychain()) {
60
+ try {
61
+ const out = execFileSync("security", [
62
+ "find-generic-password", "-s", SERVICO, "-a", ambiente().nome, "-w",
63
+ ], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
64
+ return JSON.parse(out.trim());
65
+ } catch {
66
+ /* cai no arquivo */
67
+ }
68
+ }
69
+ try {
70
+ return JSON.parse(fs.readFileSync(ARQUIVO, "utf8"));
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ export function apagarToken() {
77
+ if (temKeychain()) {
78
+ try {
79
+ execFileSync("security", [
80
+ "delete-generic-password", "-s", SERVICO, "-a", ambiente().nome,
81
+ ], { stdio: "ignore" });
82
+ } catch { /* pode não existir */ }
83
+ }
84
+ try { fs.unlinkSync(ARQUIVO); } catch { /* idem */ }
85
+ }
86
+
87
+ /** `exp` do JWT, em ms. Sem verificar assinatura: quem valida é o Convex — aqui
88
+ * só queremos saber se já venceu, pra dar mensagem boa em vez de erro cru. */
89
+ export function expiraEm(jwt) {
90
+ try {
91
+ const payload = JSON.parse(
92
+ Buffer.from(jwt.split(".")[1], "base64url").toString("utf8"),
93
+ );
94
+ return typeof payload.exp === "number" ? payload.exp * 1000 : 0;
95
+ } catch {
96
+ return 0;
97
+ }
98
+ }
99
+
100
+ export function tokenValido(dados, margemMs = 60_000) {
101
+ return Boolean(dados?.token) && expiraEm(dados.token) - margemMs > Date.now();
102
+ }
package/lib/gestao.mjs ADDED
@@ -0,0 +1,125 @@
1
+ // Cliente do Gestão: busca o contrato e chama função do Convex COMO A PESSOA.
2
+ //
3
+ // Chamamos por NOME (`"ops/tasks:create"`) via a HTTP API do Convex, sem o SDK e
4
+ // sem o `_generated/api` do repo — este pacote não tem (nem quer) acoplamento com
5
+ // o codegen do portal. As tools vêm do contrato servido; capacidade nova aparece
6
+ // aqui sem release novo.
7
+
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { ambiente, lerToken, tokenValido } from "./config.mjs";
12
+
13
+ const arquivoCache = () =>
14
+ path.join(os.homedir(), ".config", "gestao", `contrato-${ambiente().nome}.json`);
15
+
16
+ export class NaoAutenticado extends Error {
17
+ constructor(msg = "Sessão expirada ou ausente. Rode `gestao login`.") {
18
+ super(msg);
19
+ this.name = "NaoAutenticado";
20
+ }
21
+ }
22
+
23
+ function token() {
24
+ const dados = lerToken();
25
+ if (!tokenValido(dados)) throw new NaoAutenticado();
26
+ return dados.token;
27
+ }
28
+
29
+ /** `ops.tasks.create` (id do contrato) → `ops/tasks:create` (nome no Convex). */
30
+ export const paraNomeConvex = (id) => {
31
+ const p = id.split(".");
32
+ return `${p.slice(0, -1).join("/")}:${p[p.length - 1]}`;
33
+ };
34
+
35
+ /** Chama query/mutation/action pública. `tipo` vem do contrato (`kind`). */
36
+ export async function chamar(id, args, tipo = "mutation") {
37
+ const rota = tipo === "query" ? "query" : tipo === "action" ? "action" : "mutation";
38
+ const res = await fetch(`${ambiente().convex}/api/${rota}`, {
39
+ method: "POST",
40
+ headers: {
41
+ "Content-Type": "application/json",
42
+ Authorization: `Bearer ${token()}`,
43
+ },
44
+ body: JSON.stringify({
45
+ path: paraNomeConvex(id),
46
+ args: args ?? {},
47
+ format: "json",
48
+ }),
49
+ });
50
+ const corpo = await res.json().catch(() => ({}));
51
+ if (!res.ok) {
52
+ throw new Error(corpo?.message ?? `HTTP ${res.status} em ${id}`);
53
+ }
54
+ // A HTTP API devolve {status:"success"|"error", value|errorMessage}.
55
+ if (corpo.status === "error") {
56
+ // Erro de regra de negócio (ex.: "Empresa arquivada…") chega aqui. É
57
+ // informação para o modelo, não falha de transporte — propaga a mensagem.
58
+ throw new Error(corpo.errorMessage ?? `Erro em ${id}`);
59
+ }
60
+ return corpo.value;
61
+ }
62
+
63
+ let cache = null;
64
+
65
+ /**
66
+ * Contrato servido pelo deployment. Nunca embarcamos cópia NO PACOTE (cópia
67
+ * apodrece e o gate do repo não a enxerga), mas guardamos o último buscado em
68
+ * disco — é cache, não fonte: só serve pra listar tools quando a credencial
69
+ * expirou, e qualquer chamada de verdade continua exigindo login.
70
+ *
71
+ * Isso existe porque o token vale 1h: sem o cache, a pessoa veria o servidor
72
+ * MCP "falhar ao conectar" toda hora, sem pista do motivo.
73
+ */
74
+ export async function contrato({ permitirCache = false } = {}) {
75
+ const emDisco = () => {
76
+ if (cache) return cache;
77
+ try {
78
+ cache = JSON.parse(fs.readFileSync(arquivoCache(), "utf8"));
79
+ return cache;
80
+ } catch {
81
+ return null;
82
+ }
83
+ };
84
+
85
+ let jwt;
86
+ try {
87
+ jwt = token();
88
+ } catch (e) {
89
+ const c = permitirCache && emDisco();
90
+ if (c) return c;
91
+ throw e;
92
+ }
93
+
94
+ const atual = cache ?? emDisco();
95
+ const res = await fetch(
96
+ `${ambiente().site}/chatflux/contrato${atual ? `?versao=${atual.versao}` : ""}`,
97
+ { headers: { Authorization: `Bearer ${jwt}` } },
98
+ );
99
+ if (res.status === 304 && atual) return atual;
100
+ if (res.status === 401 || res.status === 403) {
101
+ const c = permitirCache && atual;
102
+ if (c) return c;
103
+ throw new NaoAutenticado(
104
+ "O portal recusou a credencial ao buscar o contrato. Rode `gestao login`.",
105
+ );
106
+ }
107
+ if (!res.ok) {
108
+ const c = permitirCache && atual;
109
+ if (c) return c;
110
+ throw new Error(`Falha ao buscar o contrato: HTTP ${res.status}`);
111
+ }
112
+ cache = await res.json();
113
+ try {
114
+ fs.mkdirSync(path.dirname(arquivoCache()), { recursive: true });
115
+ fs.writeFileSync(arquivoCache(), JSON.stringify(cache));
116
+ } catch {
117
+ /* cache é conveniência; falhar em gravar não é erro */
118
+ }
119
+ return cache;
120
+ }
121
+
122
+ /** Quem sou eu, segundo o portal (papel real, não o que o token diz). */
123
+ export async function euSou() {
124
+ return await chamar("ops.users.current", {}, "query");
125
+ }
package/lib/login.mjs ADDED
@@ -0,0 +1,95 @@
1
+ // `gestao login` — o handshake de identidade.
2
+ //
3
+ // Sobe um listener em 127.0.0.1 numa porta efêmera, abre o portal com `port` e
4
+ // `state`, e espera a página devolver o JWT do template `convex`. O browser já
5
+ // tem a sessão do Clerk; nós nunca vemos senha, e o backend não ganha rota nova.
6
+ //
7
+ // Por que NÃO OAuth: o Clerk não documenta caminho de credencial OAuth → JWT com
8
+ // `aud: "convex"`, e emitir pelo backend exigiria secret key mais um `session_id`
9
+ // que o token OAuth não carrega. O browser é o único lugar que já tem sessão.
10
+
11
+ import { createServer } from "node:http";
12
+ import crypto from "node:crypto";
13
+ import { execFile } from "node:child_process";
14
+ import { ambiente, salvarToken } from "./config.mjs";
15
+
16
+ const ESPERA_MS = 120_000;
17
+
18
+ function abrirNavegador(url) {
19
+ const cmd =
20
+ process.platform === "darwin" ? "open"
21
+ : process.platform === "win32" ? "start"
22
+ : "xdg-open";
23
+ execFile(cmd, [url], () => {});
24
+ }
25
+
26
+ export function login({ silencioso = false } = {}) {
27
+ return new Promise((resolve, reject) => {
28
+ const state = crypto.randomBytes(16).toString("hex");
29
+ let terminou = false;
30
+
31
+ const server = createServer((req, res) => {
32
+ // A página roda em outra origem — sem CORS o POST nem sai.
33
+ const cors = {
34
+ "Access-Control-Allow-Origin": "*",
35
+ "Access-Control-Allow-Headers": "Content-Type",
36
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
37
+ };
38
+ if (req.method === "OPTIONS") {
39
+ res.writeHead(204, cors);
40
+ return res.end();
41
+ }
42
+ if (req.method !== "POST" || !req.url.startsWith("/callback")) {
43
+ res.writeHead(404, cors);
44
+ return res.end();
45
+ }
46
+ let corpo = "";
47
+ req.on("data", (c) => {
48
+ corpo += c;
49
+ // Um POST legítimo tem ~2 KB. Corta qualquer coisa absurda.
50
+ if (corpo.length > 64_000) req.destroy();
51
+ });
52
+ req.on("end", () => {
53
+ let dados;
54
+ try {
55
+ dados = JSON.parse(corpo);
56
+ } catch {
57
+ res.writeHead(400, cors);
58
+ return res.end();
59
+ }
60
+ // O `state` amarra esta resposta ao NOSSO pedido: sem ele, outra aba (ou
61
+ // outro processo local) conseguiria despejar um token aqui.
62
+ if (dados.state !== state || typeof dados.token !== "string") {
63
+ res.writeHead(403, cors);
64
+ return res.end();
65
+ }
66
+ res.writeHead(200, { ...cors, "Content-Type": "application/json" });
67
+ res.end(JSON.stringify({ ok: true }));
68
+ terminou = true;
69
+ const onde = salvarToken({
70
+ token: dados.token,
71
+ email: dados.email ?? null,
72
+ env: ambiente().nome,
73
+ });
74
+ server.close();
75
+ resolve({ email: dados.email ?? null, onde });
76
+ });
77
+ });
78
+
79
+ server.listen(0, "127.0.0.1", () => {
80
+ const porta = server.address().port;
81
+ const url = `${ambiente().portal}/agente/login?port=${porta}&state=${state}`;
82
+ if (!silencioso) {
83
+ console.error(`Abrindo o navegador para autorizar…\n ${url}\n`);
84
+ }
85
+ abrirNavegador(url);
86
+ });
87
+
88
+ server.on("error", reject);
89
+ setTimeout(() => {
90
+ if (terminou) return;
91
+ server.close();
92
+ reject(new Error("Tempo esgotado (2 min) esperando a autorização."));
93
+ }, ESPERA_MS).unref();
94
+ });
95
+ }
package/lib/mcp.mjs ADDED
@@ -0,0 +1,193 @@
1
+ // Servidor MCP do Gestão (stdio, JSON-RPC 2.0).
2
+ //
3
+ // Sem SDK de propósito: a fatia do protocolo que um servidor de tools usa é
4
+ // `initialize` + `tools/list` + `tools/call` + `ping`. Escrever isso são ~80
5
+ // linhas e zero dependência — e este pacote roda na máquina de cada pessoa, onde
6
+ // cada dependência é superfície e mais uma coisa pra travar numa versão.
7
+ //
8
+ // As tools NÃO são hardcoded: vêm do contrato servido pelo deployment. Capacidade
9
+ // nova no L2 vira tool sem release deste pacote.
10
+
11
+ import { contrato, chamar, euSou } from "./gestao.mjs";
12
+ import { login } from "./login.mjs";
13
+ import { ambiente } from "./config.mjs";
14
+ import { montarTools } from "./tools.mjs";
15
+
16
+ const PROTOCOLO = "2024-11-05";
17
+ const escrever = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
18
+ const ok = (id, result) => escrever({ jsonrpc: "2.0", id, result });
19
+ const falha = (id, code, message) =>
20
+ escrever({ jsonrpc: "2.0", id, error: { code, message } });
21
+
22
+ /** Resultado de tool: MCP quer conteúdo textual; JSON legível serve ao modelo. */
23
+ const texto = (v) => ({
24
+ content: [
25
+ { type: "text", text: typeof v === "string" ? v : JSON.stringify(v, null, 2) },
26
+ ],
27
+ });
28
+
29
+ let tools = null;
30
+
31
+ /**
32
+ * A tool que CONECTA — não a que pede pra pessoa digitar comando.
33
+ *
34
+ * Fica SEMPRE na lista, autenticado ou não, porque a sessão vence de hora em
35
+ * hora: quando qualquer ação falhar por sessão, o modelo chama esta e a pessoa
36
+ * só clica no navegador. Ela nunca precisa saber que existe uma CLI.
37
+ */
38
+ const TOOL_CONECTAR = {
39
+ name: "gestao_conectar",
40
+ description:
41
+ "Conecta esta máquina ao Gestão. ABRE O NAVEGADOR para a pessoa autorizar com " +
42
+ "o login dela (leva segundos) e vale 1 hora.\n\n" +
43
+ "CHAME ISTO SEM PEDIR PERMISSÃO sempre que uma ação do Gestão falhar por " +
44
+ "sessão expirada ou ausente, ou quando não houver nenhuma outra tool do Gestão " +
45
+ "disponível. Depois de conectar, refaça a ação que falhou.\n\n" +
46
+ "Avise a pessoa em uma frase que o navegador vai abrir e que ela precisa " +
47
+ "clicar em Autorizar.",
48
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
49
+ annotations: { readOnlyHint: false, openWorldHint: true },
50
+ _endpoint: null,
51
+ };
52
+
53
+ /** Estamos no estado degradado (só a tool de conectar)? */
54
+ const soConectar = (t) => t?.length === 1 && t[0]._endpoint === null;
55
+
56
+ async function carregarTools() {
57
+ // Cache normal — MENOS quando estamos degradados. Ordem real das coisas: o
58
+ // harness sobe o servidor ao abrir, e a conexão só acontece depois. Se o
59
+ // fallback ficasse cacheado, a pessoa veria 1 tool até reiniciar o Claude Code
60
+ // inteiro, sem nenhuma pista de que era isso.
61
+ if (tools && !soConectar(tools)) return tools;
62
+ // `permitirCache`: com o token expirado (acontece de hora em hora) ainda
63
+ // listamos as tools do último contrato buscado. A CHAMADA continua exigindo
64
+ // sessão — o que muda é o modelo poder chamar `gestao_conectar` em vez de o
65
+ // servidor morrer com "Connection closed".
66
+ try {
67
+ tools = [TOOL_CONECTAR, ...montarTools(await contrato({ permitirCache: true }))];
68
+ } catch {
69
+ tools = [TOOL_CONECTAR];
70
+ }
71
+ return tools;
72
+ }
73
+
74
+ /** Refaz a lista após conectar e avisa o harness pra reler (senão ele fica com a
75
+ * lista velha, de 1 tool, e a pessoa não entende por que nada apareceu). */
76
+ async function recarregarEAvisar() {
77
+ tools = null;
78
+ await carregarTools();
79
+ escrever({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
80
+ }
81
+
82
+ async function tratar(msg) {
83
+ const { id, method, params } = msg;
84
+
85
+ if (method === "initialize") {
86
+ const env = ambiente().nome;
87
+ // O ambiente é a PRIMEIRA coisa que o modelo lê, e em dev vem como aviso.
88
+ // Motivo: o dev é uma cópia do prod — mesmos clientes, mesmos nomes. Escrever
89
+ // no banco errado não dá erro, dá resultado plausível, e ninguém percebe.
90
+ const aviso =
91
+ env === "prod"
92
+ ? "AMBIENTE: PRODUÇÃO — o que você fizer aqui é real e a equipe vê."
93
+ : "AMBIENTE: DESENVOLVIMENTO (cópia desatualizada do portal real). " +
94
+ "Os nomes de cliente são reais, mas NADA daqui aparece pra equipe. " +
95
+ "AVISE a pessoa que está em dev antes de qualquer escrita — ela pode " +
96
+ "achar que está operando o portal de verdade.";
97
+ return ok(id, {
98
+ protocolVersion: PROTOCOLO,
99
+ capabilities: { tools: { listChanged: true } },
100
+ serverInfo: { name: `gestao (${env})`, version: "0.1.0" },
101
+ instructions:
102
+ `${aviso}\n\n` +
103
+ "Portal interno da ChatFlux: empresas-cliente → projetos → entregáveis. " +
104
+ "Você age COM A IDENTIDADE E O PAPEL da pessoa que autorizou (`gestao login`) — " +
105
+ "nada além do que ela já pode fazer na tela, e tudo fica no histórico com o nome dela. " +
106
+ "IDs nunca são adivinháveis: use as tools de leitura para resolver nome → id antes de escrever. " +
107
+ "Tools marcadas como DESTRUTIVO cascateiam e não têm desfazer — confirme com a pessoa antes.",
108
+ });
109
+ }
110
+ if (method === "notifications/initialized") return; // notificação: sem resposta
111
+ if (method === "ping") return ok(id, {});
112
+
113
+ if (method === "tools/list") {
114
+ const lista = await carregarTools();
115
+ return ok(id, {
116
+ tools: lista.map(({ _endpoint, _kind, _efeito, ...t }) => t),
117
+ });
118
+ }
119
+
120
+ if (method === "tools/call") {
121
+ const lista = await carregarTools();
122
+ const tool = lista.find((t) => t.name === params?.name);
123
+ if (!tool) return falha(id, -32602, `Tool desconhecida: ${params?.name}`);
124
+ // `gestao_conectar`: executa o login de verdade — abre o navegador, espera a
125
+ // pessoa autorizar, guarda a credencial e republica as tools.
126
+ if (tool._endpoint === null) {
127
+ try {
128
+ await login({ silencioso: true });
129
+ const eu = await euSou();
130
+ await recarregarEAvisar();
131
+ return ok(
132
+ id,
133
+ texto(
134
+ `Conectado ao Gestão (${ambiente().nome}) como ${eu?.name ?? "?"} ` +
135
+ `— papel ${eu?.role ?? "?"}. Vale 1 hora. ` +
136
+ `As ferramentas do Gestão já estão disponíveis: refaça a ação que falhou.`,
137
+ ),
138
+ );
139
+ } catch (e) {
140
+ return ok(id, {
141
+ ...texto(
142
+ `Não consegui conectar: ${e.message}\n` +
143
+ `A pessoa precisa clicar em "Autorizar" na aba que abriu no navegador. ` +
144
+ `Se nenhuma aba abriu, ela pode rodar \`gestao login\` no terminal.`,
145
+ ),
146
+ isError: true,
147
+ });
148
+ }
149
+ }
150
+ try {
151
+ const valor = await chamar(tool._endpoint, params.arguments ?? {}, tool._kind);
152
+ return ok(id, texto(valor ?? { ok: true }));
153
+ } catch (e) {
154
+ // Erro de REGRA (ex.: "Empresa arquivada…") volta como conteúdo, não como
155
+ // erro de protocolo: é informação que o modelo deve ler e agir em cima.
156
+ return ok(id, { ...texto(`ERRO: ${e.message}`), isError: true });
157
+ }
158
+ }
159
+
160
+ if (id !== undefined) falha(id, -32601, `Método não suportado: ${method}`);
161
+ }
162
+
163
+ export async function servir() {
164
+ // NUNCA sai por falta de credencial. O token vale 1h; se o servidor morresse
165
+ // ao expirar, a pessoa veria "Failed to connect — Connection closed" de hora
166
+ // em hora, sem pista do motivo. Aqui ele sobe sempre, e a falta de sessão
167
+ // aparece como uma tool que EXPLICA o que fazer.
168
+ await carregarTools();
169
+
170
+ let buffer = "";
171
+ process.stdin.setEncoding("utf8");
172
+ process.stdin.on("data", async (chunk) => {
173
+ buffer += chunk;
174
+ let quebra;
175
+ while ((quebra = buffer.indexOf("\n")) !== -1) {
176
+ const linha = buffer.slice(0, quebra).trim();
177
+ buffer = buffer.slice(quebra + 1);
178
+ if (!linha) continue;
179
+ let msg;
180
+ try {
181
+ msg = JSON.parse(linha);
182
+ } catch {
183
+ continue;
184
+ }
185
+ try {
186
+ await tratar(msg);
187
+ } catch (e) {
188
+ if (msg.id !== undefined) falha(msg.id, -32603, e.message);
189
+ }
190
+ }
191
+ });
192
+ process.stdin.on("end", () => process.exit(0));
193
+ }
package/lib/tools.mjs ADDED
@@ -0,0 +1,95 @@
1
+ // Contrato → lista de tools MCP.
2
+ //
3
+ // GRANULARIDADE: uma tool por ENDPOINT exposto (não por capacidade). Medimos: 36
4
+ // capacidades expostas embrulham 69 endpoints distintos, e 10 delas têm mais de
5
+ // um endpoint de ESCRITA (`cap.trilha.esforcos` tem seis). Uma tool por
6
+ // capacidade obrigaria um discriminador `acao` + união de schemas — mais peça,
7
+ // pior validação, e o modelo escolhendo duas vezes. Uma por endpoint mantém o
8
+ // JSON Schema exato de cada chamada e nenhuma máquina nova.
9
+ //
10
+ // A capacidade não some: ela dá o NOME, a descrição, o `efeito` e o `cuidado:` —
11
+ // é o que faz a tool ser escolhida pela intenção da pessoa e não pelo nome da
12
+ // função do backend.
13
+
14
+ const EFEITO_AVISO = {
15
+ leitura: "",
16
+ escrita: "",
17
+ destrutivo:
18
+ "\n\n⚠️ DESTRUTIVO E EM CASCATA: desfazer NÃO restaura o que isto leva. " +
19
+ "CONFIRME com a pessoa, em texto, antes de chamar.",
20
+ };
21
+
22
+ /** `ops.tasks.create` → `criar`; usado como sufixo quando a capacidade tem mais
23
+ * de um endpoint (senão duas tools teriam o mesmo nome). */
24
+ const sufixo = (id) =>
25
+ id
26
+ .split(".")
27
+ .pop()
28
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
29
+ .toLowerCase();
30
+
31
+ const base = (capId) =>
32
+ "gestao_" + capId.replace(/^cap\./, "").replace(/[.-]/g, "_");
33
+
34
+ export function montarTools(contrato) {
35
+ const tools = [];
36
+ const vistos = new Set();
37
+
38
+ const caps = Object.values(contrato.capabilities)
39
+ .filter((c) => c.agente === "exposta")
40
+ .sort((a, b) => a.id.localeCompare(b.id));
41
+
42
+ for (const cap of caps) {
43
+ const ends = cap.endpoints.filter((id) => contrato.endpoints[id]);
44
+ // O endpoint PRINCIPAL fica com o nome limpo da capacidade; os outros ganham
45
+ // sufixo. `gestao_projeto_editar` lê muito melhor que
46
+ // `gestao_projeto_editar_update`, e é por esse nome que o modelo escolhe.
47
+ // Principal = a única escrita, quando há exatamente uma (o caso comum: a
48
+ // capacidade age e as leituras são apoio); senão, o primeiro que o autor da
49
+ // capacidade listou — a ordem no L2 é deliberada.
50
+ const escritas = ends.filter((id) => contrato.endpoints[id].kind !== "query");
51
+ const principal = escritas.length === 1 ? escritas[0] : ends[0];
52
+
53
+ for (const id of ends) {
54
+ const ep = contrato.endpoints[id];
55
+ // Leituras se repetem entre capacidades (`ops.companies.list` aparece em
56
+ // várias). Dedup por endpoint: a primeira capacidade que o cita nomeia.
57
+ if (vistos.has(id)) continue;
58
+ vistos.add(id);
59
+
60
+ const nome =
61
+ ends.length === 1 || id === principal
62
+ ? base(cap.id)
63
+ : `${base(cap.id)}_${sufixo(id)}`;
64
+
65
+ const descricao = [
66
+ cap.fala ?? cap.id,
67
+ "",
68
+ `Capacidade: ${cap.id} · papel mínimo: ${cap.papel} · ${ep.kind}`,
69
+ cap.cuidado ? `\nATENÇÃO: ${cap.cuidado}` : "",
70
+ cap.antes?.length ? `\nCostuma vir depois de: ${cap.antes.join(", ")}.` : "",
71
+ cap.depois?.length ? `\nCostuma levar a: ${cap.depois.join(", ")}.` : "",
72
+ EFEITO_AVISO[cap.efeito] ?? "",
73
+ ]
74
+ .filter(Boolean)
75
+ .join("\n");
76
+
77
+ tools.push({
78
+ name: nome.slice(0, 64),
79
+ description: descricao,
80
+ inputSchema: ep.inputSchema,
81
+ // Metadados que o servidor usa no dispatch e o harness usa pra decidir
82
+ // se pede confirmação.
83
+ _endpoint: id,
84
+ _kind: ep.kind,
85
+ _efeito: cap.efeito,
86
+ annotations: {
87
+ readOnlyHint: ep.kind === "query",
88
+ destructiveHint: cap.efeito === "destrutivo",
89
+ idempotentHint: ep.kind === "query",
90
+ },
91
+ });
92
+ }
93
+ }
94
+ return tools;
95
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "chatflux-gestao-agente",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Opere o portal Gestão da ChatFlux pelo Claude Code, Claude Desktop ou qualquer harness MCP — com a sua identidade e o seu papel.",
6
+ "keywords": [
7
+ "mcp",
8
+ "chatflux",
9
+ "gestao",
10
+ "claude"
11
+ ],
12
+ "license": "UNLICENSED",
13
+ "bin": {
14
+ "gestao": "bin/gestao.mjs"
15
+ },
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "files": [
20
+ "bin",
21
+ "lib",
22
+ "README.md"
23
+ ],
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/Danielmtex/chatflux-gestao.git",
27
+ "directory": "agente"
28
+ }
29
+ }