sijur-cli 2.0.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 +138 -0
- package/bin/sijur.js +99 -0
- package/package.json +22 -0
- package/src/audit.js +13 -0
- package/src/auth.js +202 -0
- package/src/cmds/chamar.js +85 -0
- package/src/cmds/sessao.js +79 -0
- package/src/cmds/tools.js +60 -0
- package/src/config.js +51 -0
- package/src/exit.js +18 -0
- package/src/keychain.js +75 -0
- package/src/mcp.js +180 -0
package/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# sijur — CLI
|
|
2
|
+
|
|
3
|
+
Espelho de linha de comando das ferramentas MCP do SIJUR. Cada ferramenta da MCP e um comando
|
|
4
|
+
aqui, com o mesmo nome e os mesmos parametros.
|
|
5
|
+
|
|
6
|
+
A CLI **nao decide nada**. Ela autentica, converte os argumentos conforme o schema que a propria
|
|
7
|
+
MCP publica, chama a ferramenta e imprime o que voltou. Nao tem regra de negocio, nao le nem
|
|
8
|
+
escreve arquivo seu, nao compoe chamadas. Ferramenta nova na MCP aparece aqui sozinha; parametro
|
|
9
|
+
que mudar de tipo passa a ser convertido pelo tipo novo — nao ha lista de comandos para manter
|
|
10
|
+
sincronizada.
|
|
11
|
+
|
|
12
|
+
## Instalacao
|
|
13
|
+
|
|
14
|
+
Precisa de Node.js 18 ou mais novo (`node -v`).
|
|
15
|
+
|
|
16
|
+
### Para quem vai usar
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install -g sijur-cli
|
|
20
|
+
sijur login
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
> Enquanto o pacote nao estiver publicado no npm, use um dos caminhos abaixo.
|
|
24
|
+
|
|
25
|
+
### Publicar no npm (uma vez, por quem mantem)
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
cd cli
|
|
29
|
+
npm login # conta com direito de publicar o nome `sijur-cli`
|
|
30
|
+
npm publish --access public
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
O pacote nao carrega segredo nenhum: a credencial de cada pessoa e obtida no `sijur login` e fica
|
|
34
|
+
no Keychain da maquina dela. Publicar e so distribuir o cliente.
|
|
35
|
+
|
|
36
|
+
### Sem publicar — tarball servido pelo proprio SIJUR
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
cd cli && npm pack # gera sijur-cli-2.0.0.tgz
|
|
40
|
+
# suba o .tgz para um endereco que os usuarios alcancem, e entao, na maquina de cada um:
|
|
41
|
+
npm install -g https://sijur.com.br/downloads/sijur-cli-2.0.0.tgz
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Sem publicar — a partir do repositorio
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
git clone git@github.com:grgbrasil/sijur_2025.git
|
|
48
|
+
cd sijur_2025/cli && npm install --omit=dev && npm pack
|
|
49
|
+
npm install -g ./sijur-cli-2.0.0.tgz
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Confira com `sijur --help`. Desinstalar: `npm uninstall -g sijur-cli`.
|
|
53
|
+
|
|
54
|
+
## Sessao
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
sijur login # abre o navegador; autorize com a sua conta
|
|
58
|
+
sijur whoami # escopos, validade e ferramentas visiveis
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
OAuth 2.1 com PKCE. O refresh token fica no **Keychain do macOS** (servico `sijur-cli`); fora do
|
|
62
|
+
macOS, em `~/.sijur-cli/credentials.json` com permissao `600`.
|
|
63
|
+
|
|
64
|
+
**Escopos:** a CLI pede todos os que o servidor anuncia em `scopes_supported` — hoje 26 — e a tela
|
|
65
|
+
de consentimento mostra uma caixa por escopo, todas marcadas. Quem decide o que entra e voce, ali:
|
|
66
|
+
desmarcar um escopo tira do token as ferramentas daquele dominio, e o `sijur tools` passa a nao
|
|
67
|
+
lista-las. Nao ha lista de escopos no codigo da CLI; escopo novo no servidor aparece sozinho.
|
|
68
|
+
|
|
69
|
+
### Trocar de escritorio
|
|
70
|
+
|
|
71
|
+
**O token e por escritorio**, e o escritorio se escolhe na tela de autorizacao. `sijur login` com
|
|
72
|
+
credencial ainda valida **nao passa por ela** — renova em silencio e voce continua no escritorio
|
|
73
|
+
anterior (o comando avisa quando isso acontece). Para trocar:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
sijur login --trocar
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Derrubar so esta maquina: `sijur logout --revogar`.
|
|
80
|
+
|
|
81
|
+
## Uso
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
sijur tools # o que este token enxerga
|
|
85
|
+
sijur tools tarefas_encerrar # parametros, tipos e descricao
|
|
86
|
+
sijur <ferramenta> [--param valor ...]
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
O catalogo vem filtrado por escopo pelo proprio servidor: `sijur tools` lista exatamente o que
|
|
90
|
+
este token pode chamar.
|
|
91
|
+
|
|
92
|
+
### Argumentos
|
|
93
|
+
|
|
94
|
+
Convertidos pelo schema da ferramenta, nunca por adivinhacao — por isso `--tarefa_id 123456` vira
|
|
95
|
+
numero e `--processo_numero 1234567-89.2024.4.04.7100` continua texto.
|
|
96
|
+
|
|
97
|
+
| tipo no schema | como se escreve |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `string` | `--relatorio "Protocolado RExt"` |
|
|
100
|
+
| `number` | `--tarefa_id 123456` |
|
|
101
|
+
| `boolean` | `--dry_run` (verdadeiro) ou `--dry_run false` |
|
|
102
|
+
| `array` | `--fechar_pendencias tarefa:123400,tarefa_prazo:123401` ou JSON `'["a","b"]'` |
|
|
103
|
+
| `object` | JSON: `--filtros '[{"campo":"x","operador":"=","valor":"y"}]'` |
|
|
104
|
+
|
|
105
|
+
Parametro que a ferramenta nao tem e erro, com a lista dos validos. Obrigatorio faltando tambem.
|
|
106
|
+
|
|
107
|
+
### Saida
|
|
108
|
+
|
|
109
|
+
O objeto que a ferramenta devolveu, identado. Com `--json`, numa linha so.
|
|
110
|
+
|
|
111
|
+
| codigo | significado |
|
|
112
|
+
|---|---|
|
|
113
|
+
| 0 | ok |
|
|
114
|
+
| 1 | erro de uso (parametro invalido, obrigatorio faltando, ferramenta fora do escopo) |
|
|
115
|
+
| 2 | autenticacao: sem token, expirado, escopo insuficiente |
|
|
116
|
+
| 4 | erro da ferramenta ou da rede |
|
|
117
|
+
|
|
118
|
+
## Exemplos
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
sijur prazos_do_processo --processo_numero 1234567-89.2024.4.04.7100
|
|
122
|
+
sijur prazos_do_processo --processo_numero 7654321-12.2023.4.04.7112 --incluir_fechados
|
|
123
|
+
sijur tarefas_momentos_listar --processo_id 33418
|
|
124
|
+
sijur tarefas_encerrar --tarefa_id 123456 --relatorio "Protocolado RExt" \
|
|
125
|
+
--fechar_pendencias tarefa:123400,tarefa_prazo:123401 \
|
|
126
|
+
--fechar_ns_entidade_pai --momento_processual 46 --dry_run
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Auditoria
|
|
130
|
+
|
|
131
|
+
Toda chamada apenda uma linha em `~/.sijur-cli/audit.jsonl`: timestamp, ferramenta, argumentos e
|
|
132
|
+
se deu certo.
|
|
133
|
+
|
|
134
|
+
## Variaveis de ambiente
|
|
135
|
+
|
|
136
|
+
| variavel | padrao |
|
|
137
|
+
|---|---|
|
|
138
|
+
| `SIJUR_MCP_URL` | `https://sijur.com.br/mcp` |
|
package/bin/sijur.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { EXIT, CliError, erroUso } from '../src/exit.js';
|
|
3
|
+
import { fechar } from '../src/mcp.js';
|
|
4
|
+
|
|
5
|
+
const AJUDA = `sijur — espelho de linha de comando das ferramentas MCP do SIJUR
|
|
6
|
+
|
|
7
|
+
Cada ferramenta da MCP e um comando aqui, com os mesmos nomes e os mesmos
|
|
8
|
+
parametros. A CLI nao decide nada por conta propria: ela autentica, converte os
|
|
9
|
+
argumentos conforme o schema da ferramenta, chama, e imprime o que voltou.
|
|
10
|
+
|
|
11
|
+
USO
|
|
12
|
+
sijur <ferramenta> [--parametro valor ...] chama a ferramenta
|
|
13
|
+
sijur tools lista o que este token enxerga
|
|
14
|
+
sijur tools <ferramenta> parametros e descricao dela
|
|
15
|
+
|
|
16
|
+
SESSAO
|
|
17
|
+
sijur login [--trocar] autoriza esta maquina. --trocar forca a tela de novo,
|
|
18
|
+
que e o unico jeito de mudar de escritorio
|
|
19
|
+
sijur logout [--revogar] apaga a credencial local; --revogar derruba no servidor
|
|
20
|
+
sijur whoami escopos, validade e ferramentas visiveis
|
|
21
|
+
|
|
22
|
+
GLOBAIS
|
|
23
|
+
--json imprime numa linha so (o padrao e identado)
|
|
24
|
+
-h, --help esta ajuda
|
|
25
|
+
|
|
26
|
+
CODIGOS DE SAIDA
|
|
27
|
+
0 ok 1 erro de uso 2 autenticacao/escopo 4 erro da ferramenta ou da rede
|
|
28
|
+
|
|
29
|
+
EXEMPLOS
|
|
30
|
+
sijur tools
|
|
31
|
+
sijur tools tarefas_encerrar
|
|
32
|
+
sijur prazos_do_processo --processo_numero 1234567-89.2024.4.04.7100
|
|
33
|
+
sijur tarefas_encerrar --tarefa_id 123456 --relatorio "Protocolado RExt" --dry_run
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
const GLOBAIS = new Set(['--json', '--help', '-h', '--trocar', '--revogar']);
|
|
37
|
+
const SESSAO = new Set(['login', 'logout', 'whoami', 'tools']);
|
|
38
|
+
|
|
39
|
+
// Sem lista fixa de parametros: o schema da ferramenta e que manda. Aqui so separamos
|
|
40
|
+
// `--chave valor` de `--flag`, e a coercao acontece depois, contra o schema.
|
|
41
|
+
function parsear(argv) {
|
|
42
|
+
const globais = {};
|
|
43
|
+
const params = {};
|
|
44
|
+
const soltos = [];
|
|
45
|
+
for (let i = 0; i < argv.length; i++) {
|
|
46
|
+
const a = argv[i];
|
|
47
|
+
if (GLOBAIS.has(a)) { globais[a] = true; continue; }
|
|
48
|
+
if (a.startsWith('--')) {
|
|
49
|
+
const chave = a.slice(2);
|
|
50
|
+
if (!chave) throw erroUso('`--` sozinho nao e parametro.');
|
|
51
|
+
const proximo = argv[i + 1];
|
|
52
|
+
if (proximo === undefined || proximo.startsWith('--')) params[chave] = true;
|
|
53
|
+
else { params[chave] = proximo; i++; }
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (a.startsWith('-')) throw erroUso(`Opcao desconhecida: ${a}`);
|
|
57
|
+
soltos.push(a);
|
|
58
|
+
}
|
|
59
|
+
return { globais, params, soltos };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function principal() {
|
|
63
|
+
const { globais, params, soltos } = parsear(process.argv.slice(2));
|
|
64
|
+
const comando = soltos[0];
|
|
65
|
+
const op = { json: !!globais['--json'] };
|
|
66
|
+
|
|
67
|
+
if (globais['-h'] || globais['--help'] || !comando) {
|
|
68
|
+
process.stdout.write(AJUDA);
|
|
69
|
+
return EXIT.OK;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (SESSAO.has(comando)) {
|
|
73
|
+
const s = await import('../src/cmds/sessao.js');
|
|
74
|
+
if (comando === 'login') return s.cmdLogin({ ...op, trocar: !!globais['--trocar'] });
|
|
75
|
+
if (comando === 'logout') return s.cmdLogout({ ...op, revogar: !!globais['--revogar'] });
|
|
76
|
+
if (comando === 'whoami') return s.cmdWhoami(op);
|
|
77
|
+
return (await import('../src/cmds/tools.js')).cmdTools(soltos[1], op);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (soltos.length > 1) {
|
|
81
|
+
throw erroUso(`So uma ferramenta por invocacao (recebi "${soltos.join('", "')}"). ` +
|
|
82
|
+
`Valores com espaco vao entre aspas.`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return (await import('../src/cmds/chamar.js')).cmdChamar(comando, params, op);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
principal()
|
|
89
|
+
.then(async code => { await fechar(); process.exit(code ?? EXIT.OK); })
|
|
90
|
+
.catch(async err => {
|
|
91
|
+
await fechar().catch(() => {});
|
|
92
|
+
const code = err instanceof CliError ? err.code : EXIT.API;
|
|
93
|
+
if (process.argv.includes('--json')) {
|
|
94
|
+
process.stdout.write(JSON.stringify({ ok: false, erro: err.message, exit: code }) + '\n');
|
|
95
|
+
} else {
|
|
96
|
+
process.stderr.write(`ERRO (${code}): ${err.message}\n`);
|
|
97
|
+
}
|
|
98
|
+
process.exit(code);
|
|
99
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sijur-cli",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Espelho de linha de comando das ferramentas MCP do SIJUR — um comando por ferramenta, sem regra propria",
|
|
6
|
+
"bin": {
|
|
7
|
+
"sijur": "bin/sijur.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18.0.0"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@modelcontextprotocol/sdk": "^1.26.0"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin",
|
|
17
|
+
"src",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"license": "UNLICENSED",
|
|
21
|
+
"author": "SIJUR"
|
|
22
|
+
}
|
package/src/audit.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { AUDIT_PATH, garantirHome } from './config.js';
|
|
3
|
+
|
|
4
|
+
// Append-only. Toda escrita passa por aqui, inclusive a que falhou.
|
|
5
|
+
export function auditar(registro) {
|
|
6
|
+
try {
|
|
7
|
+
garantirHome();
|
|
8
|
+
const linha = JSON.stringify({ ts: new Date().toISOString(), ...registro });
|
|
9
|
+
fs.appendFileSync(AUDIT_PATH, linha + '\n', { mode: 0o600 });
|
|
10
|
+
} catch (err) {
|
|
11
|
+
process.stderr.write(`[audit] falhou ao gravar: ${err.message}\n`);
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { auth } from '@modelcontextprotocol/sdk/client/auth.js';
|
|
6
|
+
import { SERVIDOR, escoposSuportados } from './config.js';
|
|
7
|
+
import * as cofre from './keychain.js';
|
|
8
|
+
import { erroAuth } from './exit.js';
|
|
9
|
+
|
|
10
|
+
const PORTAS = [53682, 53683, 53684, 53685, 53686];
|
|
11
|
+
const REDIRECTS = PORTAS.map(p => `http://127.0.0.1:${p}/callback`);
|
|
12
|
+
|
|
13
|
+
function portaLivre(porta) {
|
|
14
|
+
return new Promise(resolve => {
|
|
15
|
+
const s = net.createServer();
|
|
16
|
+
s.once('error', () => resolve(false));
|
|
17
|
+
s.once('listening', () => s.close(() => resolve(true)));
|
|
18
|
+
s.listen(porta, '127.0.0.1');
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function escolherPorta() {
|
|
23
|
+
for (const p of PORTAS) {
|
|
24
|
+
if (await portaLivre(p)) return p;
|
|
25
|
+
}
|
|
26
|
+
throw erroAuth(`Nenhuma porta livre em ${PORTAS.join(', ')} para receber o retorno do login.`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function abrirNavegador(url) {
|
|
30
|
+
const cmd = process.platform === 'darwin' ? 'open'
|
|
31
|
+
: process.platform === 'win32' ? 'cmd'
|
|
32
|
+
: 'xdg-open';
|
|
33
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
34
|
+
execFile(cmd, args, () => { /* se falhar, o usuario abre a URL na mao */ });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
class ProvedorSijur {
|
|
38
|
+
constructor(porta, escopos = []) {
|
|
39
|
+
this._redirect = `http://127.0.0.1:${porta}/callback`;
|
|
40
|
+
this._urlAutorizacao = null;
|
|
41
|
+
this._escopos = escopos;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get redirectUrl() { return this._redirect; }
|
|
45
|
+
|
|
46
|
+
get clientMetadata() {
|
|
47
|
+
return {
|
|
48
|
+
client_name: 'SIJUR CLI',
|
|
49
|
+
redirect_uris: REDIRECTS,
|
|
50
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
51
|
+
response_types: ['code'],
|
|
52
|
+
token_endpoint_auth_method: 'none',
|
|
53
|
+
scope: (this._escopos || []).join(' ')
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
state() {
|
|
58
|
+
const s = crypto.randomBytes(16).toString('base64url');
|
|
59
|
+
this._state = s;
|
|
60
|
+
return s;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
clientInformation() { return cofre.ler()?.client; }
|
|
64
|
+
saveClientInformation(info) { cofre.atualizar({ client: info }); }
|
|
65
|
+
|
|
66
|
+
tokens() { return cofre.ler()?.tokens; }
|
|
67
|
+
saveTokens(t) {
|
|
68
|
+
cofre.atualizar({
|
|
69
|
+
tokens: t,
|
|
70
|
+
obtido_em: new Date().toISOString(),
|
|
71
|
+
expira_em: t.expires_in ? new Date(Date.now() + t.expires_in * 1000).toISOString() : null
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
redirectToAuthorization(url) { this._urlAutorizacao = url; }
|
|
76
|
+
|
|
77
|
+
saveCodeVerifier(v) { cofre.atualizar({ code_verifier: v }); }
|
|
78
|
+
codeVerifier() {
|
|
79
|
+
const v = cofre.ler()?.code_verifier;
|
|
80
|
+
if (!v) throw erroAuth('Verificador PKCE ausente. Rode `sijur login` de novo.');
|
|
81
|
+
return v;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
invalidateCredentials(escopo) {
|
|
85
|
+
if (escopo === 'all') return cofre.apagar();
|
|
86
|
+
const atual = cofre.ler() || {};
|
|
87
|
+
if (escopo === 'tokens') delete atual.tokens;
|
|
88
|
+
if (escopo === 'client') delete atual.client;
|
|
89
|
+
if (escopo === 'verifier') delete atual.code_verifier;
|
|
90
|
+
cofre.gravar(atual);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Recebe UMA requisicao no loopback e devolve o code.
|
|
95
|
+
function esperarRetorno(porta, estadoEsperado, timeoutMs = 300000) {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const servidor = http.createServer((req, res) => {
|
|
98
|
+
const url = new URL(req.url, `http://127.0.0.1:${porta}`);
|
|
99
|
+
if (url.pathname !== '/callback') {
|
|
100
|
+
res.writeHead(404).end();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const code = url.searchParams.get('code');
|
|
104
|
+
const erro = url.searchParams.get('error');
|
|
105
|
+
const estado = url.searchParams.get('state');
|
|
106
|
+
const responder = (titulo, corpo) => {
|
|
107
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
108
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>${titulo}</title>
|
|
109
|
+
<body style="font-family:system-ui;padding:48px;text-align:center">
|
|
110
|
+
<h2>${titulo}</h2><p>${corpo}</p></body>`);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
if (erro) {
|
|
114
|
+
responder('Autorizacao negada', 'Pode fechar esta aba e voltar ao terminal.');
|
|
115
|
+
encerrar();
|
|
116
|
+
reject(erroAuth(`Autorizacao negada pelo servidor: ${erro}`));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (estadoEsperado && estado !== estadoEsperado) {
|
|
120
|
+
responder('Estado invalido', 'O parametro state nao confere. Login abortado.');
|
|
121
|
+
encerrar();
|
|
122
|
+
reject(erroAuth('Parametro `state` divergente — possivel CSRF. Login abortado.'));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (!code) {
|
|
126
|
+
responder('Retorno sem codigo', 'Pode fechar esta aba e voltar ao terminal.');
|
|
127
|
+
encerrar();
|
|
128
|
+
reject(erroAuth('O servidor voltou sem `code`.'));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
responder('SIJUR CLI autorizado', 'Pode fechar esta aba e voltar ao terminal.');
|
|
132
|
+
encerrar();
|
|
133
|
+
resolve(code);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const relogio = setTimeout(() => {
|
|
137
|
+
encerrar();
|
|
138
|
+
reject(erroAuth('Tempo esgotado esperando a autorizacao no navegador (5 min).'));
|
|
139
|
+
}, timeoutMs);
|
|
140
|
+
|
|
141
|
+
function encerrar() {
|
|
142
|
+
clearTimeout(relogio);
|
|
143
|
+
servidor.close();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
servidor.on('error', err => { encerrar(); reject(erroAuth(`Falha no servidor local: ${err.message}`)); });
|
|
147
|
+
servidor.listen(porta, '127.0.0.1');
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function fazerLogin({ trocar = false, escrever = s => process.stderr.write(s) } = {}) {
|
|
152
|
+
const porta = await escolherPorta();
|
|
153
|
+
const escopos = await escoposSuportados();
|
|
154
|
+
const p = new ProvedorSijur(porta, escopos);
|
|
155
|
+
|
|
156
|
+
// WHY `--trocar`: com token valido no cofre, `auth()` devolve AUTHORIZED sem passar pela tela.
|
|
157
|
+
// Isso e correto para renovar, e ARMADILHA para trocar de escritorio — o token e por escritorio,
|
|
158
|
+
// e quem roda `login` de novo esperando trocar continua no anterior sem nenhum sinal. Descartar o
|
|
159
|
+
// token (mantendo o registro do cliente) forca a tela, que e onde o escritorio se escolhe.
|
|
160
|
+
if (trocar) {
|
|
161
|
+
const atual = cofre.ler() || {};
|
|
162
|
+
delete atual.tokens;
|
|
163
|
+
delete atual.code_verifier;
|
|
164
|
+
cofre.gravar(atual);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const r = await auth(p, { serverUrl: SERVIDOR, scope: escopos.join(' ') });
|
|
168
|
+
if (r === 'AUTHORIZED') return { ja_autorizado: true, escopos_pedidos: escopos };
|
|
169
|
+
if (!p._urlAutorizacao) throw erroAuth('O SDK nao produziu URL de autorizacao.');
|
|
170
|
+
|
|
171
|
+
const espera = esperarRetorno(porta, p._state);
|
|
172
|
+
escrever(`Abrindo o navegador para autorizar.\nSe nao abrir, acesse:\n${p._urlAutorizacao}\n\n`);
|
|
173
|
+
abrirNavegador(p._urlAutorizacao.toString());
|
|
174
|
+
|
|
175
|
+
const code = await espera;
|
|
176
|
+
const r2 = await auth(p, { serverUrl: SERVIDOR, authorizationCode: code, scope: escopos.join(' ') });
|
|
177
|
+
if (r2 !== 'AUTHORIZED') throw erroAuth(`Troca do codigo falhou (${r2}).`);
|
|
178
|
+
return { ja_autorizado: false, escopos_pedidos: escopos };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function provedorParaTransporte() {
|
|
182
|
+
return new ProvedorSijur(PORTAS[0]);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function revogar() {
|
|
186
|
+
const blob = cofre.ler();
|
|
187
|
+
if (!blob?.tokens?.refresh_token) return { revogado: false, motivo: 'sem_token' };
|
|
188
|
+
const meta = await fetch(new URL('/.well-known/oauth-authorization-server', SERVIDOR).toString())
|
|
189
|
+
.then(r => r.json()).catch(() => null);
|
|
190
|
+
const endpoint = meta?.revocation_endpoint;
|
|
191
|
+
if (!endpoint) return { revogado: false, motivo: 'sem_endpoint' };
|
|
192
|
+
const corpo = new URLSearchParams({
|
|
193
|
+
token: blob.tokens.refresh_token,
|
|
194
|
+
client_id: blob.client?.client_id || ''
|
|
195
|
+
});
|
|
196
|
+
const res = await fetch(endpoint, {
|
|
197
|
+
method: 'POST',
|
|
198
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
199
|
+
body: corpo
|
|
200
|
+
});
|
|
201
|
+
return { revogado: res.ok, status: res.status };
|
|
202
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { chamarBruto, listarToolsCompleto } from '../mcp.js';
|
|
2
|
+
import { EXIT, erroUso } from '../exit.js';
|
|
3
|
+
import { auditar } from '../audit.js';
|
|
4
|
+
|
|
5
|
+
function tiposDe(prop) {
|
|
6
|
+
if (prop?.type) return Array.isArray(prop.type) ? prop.type : [prop.type];
|
|
7
|
+
if (Array.isArray(prop?.anyOf)) return prop.anyOf.flatMap(x => (x.type ? [x.type] : []));
|
|
8
|
+
return [];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Converte o texto do terminal para o tipo que o schema da tool pede.
|
|
12
|
+
// WHY pelo schema e nao por heuristica: `--tarefa_id 123456` tem de virar number, e
|
|
13
|
+
// `--processo_numero 1234567-89...` tem de continuar string. So o schema sabe a diferenca.
|
|
14
|
+
export function coagir(valor, prop, chave) {
|
|
15
|
+
const tipos = tiposDe(prop);
|
|
16
|
+
|
|
17
|
+
if (tipos.includes('boolean') && tipos.length === 1) {
|
|
18
|
+
if (valor === true || valor === 'true' || valor === '1') return true;
|
|
19
|
+
if (valor === 'false' || valor === '0') return false;
|
|
20
|
+
throw erroUso(`--${chave} e booleano: use --${chave} (verdadeiro) ou --${chave} false.`);
|
|
21
|
+
}
|
|
22
|
+
if (valor === true) {
|
|
23
|
+
// flag sem valor num parametro nao-booleano
|
|
24
|
+
if (tipos.includes('boolean')) return true;
|
|
25
|
+
throw erroUso(`--${chave} precisa de um valor.`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const texto = String(valor);
|
|
29
|
+
|
|
30
|
+
if (tipos.includes('number') || tipos.includes('integer')) {
|
|
31
|
+
if (tipos.length === 1) {
|
|
32
|
+
const n = Number(texto);
|
|
33
|
+
if (!Number.isFinite(n)) throw erroUso(`--${chave} espera numero, recebeu "${texto}".`);
|
|
34
|
+
return n;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (tipos.includes('array') || tipos.includes('object')) {
|
|
38
|
+
const t = texto.trim();
|
|
39
|
+
if (t.startsWith('[') || t.startsWith('{')) {
|
|
40
|
+
try { return JSON.parse(t); } catch { throw erroUso(`--${chave}: JSON invalido — ${t.slice(0, 60)}`); }
|
|
41
|
+
}
|
|
42
|
+
if (tipos.includes('array')) return texto.split(',').map(x => x.trim()).filter(Boolean);
|
|
43
|
+
}
|
|
44
|
+
if (tipos.includes('string')) return texto;
|
|
45
|
+
|
|
46
|
+
// anyOf/sem tipo: aceita JSON quando parecer JSON, senao texto
|
|
47
|
+
const t = texto.trim();
|
|
48
|
+
if (t.startsWith('[') || t.startsWith('{')) { try { return JSON.parse(t); } catch { /* texto mesmo */ } }
|
|
49
|
+
if (/^-?\d+(\.\d+)?$/.test(t)) return Number(t);
|
|
50
|
+
if (t === 'true') return true;
|
|
51
|
+
if (t === 'false') return false;
|
|
52
|
+
return texto;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function cmdChamar(nome, brutos, op) {
|
|
56
|
+
const tools = await listarToolsCompleto();
|
|
57
|
+
const tool = tools.find(t => t.name === nome);
|
|
58
|
+
if (!tool) {
|
|
59
|
+
throw erroUso(`Ferramenta "${nome}" nao existe ou nao esta no escopo deste token. Rode \`sijur tools\`.`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const props = tool.inputSchema?.properties || {};
|
|
63
|
+
const argumentos = {};
|
|
64
|
+
for (const [chave, valor] of Object.entries(brutos)) {
|
|
65
|
+
if (!(chave in props)) {
|
|
66
|
+
const validos = Object.keys(props).map(k => `--${k}`).join(' ') || '(nenhum)';
|
|
67
|
+
throw erroUso(`--${chave} nao e parametro de ${nome}. Validos: ${validos}`);
|
|
68
|
+
}
|
|
69
|
+
argumentos[chave] = coagir(valor, props[chave], chave);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const faltando = (tool.inputSchema?.required || []).filter(k => !(k in argumentos));
|
|
73
|
+
if (faltando.length) {
|
|
74
|
+
throw erroUso(`${nome} exige ${faltando.map(k => `--${k}`).join(', ')}. Detalhe: sijur tools ${nome}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const resultado = await chamarBruto(nome, argumentos);
|
|
78
|
+
|
|
79
|
+
process.stdout.write(op.json
|
|
80
|
+
? JSON.stringify(resultado) + '\n'
|
|
81
|
+
: JSON.stringify(resultado, null, 2) + '\n');
|
|
82
|
+
|
|
83
|
+
auditar({ comando: nome, argumentos, ok: resultado?.success !== false });
|
|
84
|
+
return resultado?.success === false ? EXIT.API : EXIT.OK;
|
|
85
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { fazerLogin, revogar } from '../auth.js';
|
|
2
|
+
import * as cofre from '../keychain.js';
|
|
3
|
+
import { SERVIDOR, escoposSuportados } from '../config.js';
|
|
4
|
+
import { listarTools, fechar } from '../mcp.js';
|
|
5
|
+
|
|
6
|
+
import { EXIT, erroAuth } from '../exit.js';
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
function emitir(obj, op) {
|
|
10
|
+
process.stdout.write(op.json ? JSON.stringify(obj) + '\n' : JSON.stringify(obj, null, 2) + '\n');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function cmdLogin(op) {
|
|
14
|
+
const r = await fazerLogin({ trocar: op.trocar });
|
|
15
|
+
const blob = cofre.ler();
|
|
16
|
+
const avisos = [];
|
|
17
|
+
if (r.ja_autorizado) {
|
|
18
|
+
avisos.push('Ja havia credencial valida — NAO passei pela tela de autorizacao, ' +
|
|
19
|
+
'entao o escritorio continua o mesmo de antes. Para trocar de escritorio: `sijur login --trocar`.');
|
|
20
|
+
}
|
|
21
|
+
emitir({
|
|
22
|
+
ok: true,
|
|
23
|
+
servidor: SERVIDOR,
|
|
24
|
+
ja_estava_autorizado: r.ja_autorizado,
|
|
25
|
+
autorizou_agora: !r.ja_autorizado,
|
|
26
|
+
escopos_pedidos: r.escopos_pedidos || [],
|
|
27
|
+
escopos_concedidos: (blob?.tokens?.scope || '').split(' ').filter(Boolean),
|
|
28
|
+
guardado_em: cofre.ondeGuarda,
|
|
29
|
+
expira_em: blob?.expira_em || null,
|
|
30
|
+
avisos
|
|
31
|
+
}, op);
|
|
32
|
+
return EXIT.OK;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function cmdLogout(op) {
|
|
36
|
+
let revogado = { revogado: false, motivo: 'nao_pedido' };
|
|
37
|
+
if (op.revogar) revogado = await revogar();
|
|
38
|
+
cofre.apagar();
|
|
39
|
+
emitir({ ok: true, credenciais_apagadas: true, revogado_no_servidor: revogado.revogado, detalhe: revogado }, op);
|
|
40
|
+
return EXIT.OK;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function cmdWhoami(op) {
|
|
44
|
+
const blob = cofre.ler();
|
|
45
|
+
if (!blob?.tokens?.access_token) throw erroAuth('Sem credencial nesta maquina. Rode `sijur login`.');
|
|
46
|
+
|
|
47
|
+
const concedidos = (blob.tokens.scope || '').split(' ').filter(Boolean);
|
|
48
|
+
// WHY nao tratar escopo nao-concedido como erro: desmarcar caixa no consentimento e decisao
|
|
49
|
+
// legitima do dono. O que a CLI mostra e o que ele concedeu e o que isso da de ferramenta.
|
|
50
|
+
const suportados = await escoposSuportados().catch(() => []);
|
|
51
|
+
const naoConcedidos = suportados.filter(e => !concedidos.includes(e));
|
|
52
|
+
|
|
53
|
+
let tools = null;
|
|
54
|
+
let vivo = false;
|
|
55
|
+
try {
|
|
56
|
+
tools = await listarTools();
|
|
57
|
+
vivo = true;
|
|
58
|
+
} catch (err) {
|
|
59
|
+
tools = `nao consegui listar: ${err.message}`;
|
|
60
|
+
} finally {
|
|
61
|
+
await fechar();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
emitir({
|
|
65
|
+
ok: vivo,
|
|
66
|
+
servidor: SERVIDOR,
|
|
67
|
+
cliente: blob.client?.client_id || null,
|
|
68
|
+
guardado_em: cofre.ondeGuarda,
|
|
69
|
+
obtido_em: blob.obtido_em || null,
|
|
70
|
+
expira_em: blob.expira_em || null,
|
|
71
|
+
escopos_concedidos: concedidos,
|
|
72
|
+
escopos_nao_concedidos: naoConcedidos,
|
|
73
|
+
sessao_viva: vivo,
|
|
74
|
+
total_ferramentas: Array.isArray(tools) ? tools.length : null,
|
|
75
|
+
tools_visiveis: tools
|
|
76
|
+
}, op);
|
|
77
|
+
|
|
78
|
+
return vivo ? EXIT.OK : EXIT.AUTH;
|
|
79
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { listarToolsCompleto } from '../mcp.js';
|
|
2
|
+
import { EXIT, erroUso } from '../exit.js';
|
|
3
|
+
|
|
4
|
+
function tiposDe(prop) {
|
|
5
|
+
if (prop?.type) return Array.isArray(prop.type) ? prop.type : [prop.type];
|
|
6
|
+
if (Array.isArray(prop?.anyOf)) return prop.anyOf.flatMap(x => x.type ? [x.type] : []);
|
|
7
|
+
return [];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function cmdTools(nome, op) {
|
|
11
|
+
const tools = await listarToolsCompleto();
|
|
12
|
+
|
|
13
|
+
if (!nome) {
|
|
14
|
+
if (op.json) {
|
|
15
|
+
process.stdout.write(JSON.stringify({ ok: true, total: tools.length, tools: tools.map(t => ({ nome: t.name, descricao: t.description })) }) + '\n');
|
|
16
|
+
return EXIT.OK;
|
|
17
|
+
}
|
|
18
|
+
process.stdout.write(`${tools.length} ferramentas visiveis para este token:\n\n`);
|
|
19
|
+
for (const t of tools) {
|
|
20
|
+
const primeira = String(t.description || '').split(/(?<=\.)\s/)[0];
|
|
21
|
+
process.stdout.write(` ${t.name.padEnd(30)} ${primeira.slice(0, 90)}\n`);
|
|
22
|
+
}
|
|
23
|
+
process.stdout.write(`\nDetalhe de uma: sijur tools <nome>\n`);
|
|
24
|
+
return EXIT.OK;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const t = tools.find(x => x.name === nome);
|
|
28
|
+
if (!t) throw erroUso(`Ferramenta "${nome}" nao existe ou nao esta no escopo deste token. Rode \`sijur tools\`.`);
|
|
29
|
+
|
|
30
|
+
if (op.json) {
|
|
31
|
+
process.stdout.write(JSON.stringify({ ok: true, nome: t.name, descricao: t.description, schema: t.inputSchema }) + '\n');
|
|
32
|
+
return EXIT.OK;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const props = t.inputSchema?.properties || {};
|
|
36
|
+
const obrig = new Set(t.inputSchema?.required || []);
|
|
37
|
+
process.stdout.write(`${t.name}\n\n${t.description || '(sem descricao)'}\n\nParametros:\n`);
|
|
38
|
+
if (!Object.keys(props).length) process.stdout.write(' (nenhum)\n');
|
|
39
|
+
for (const [k, v] of Object.entries(props)) {
|
|
40
|
+
const tipos = tiposDe(v).join('|') || 'qualquer';
|
|
41
|
+
const marca = obrig.has(k) ? ' (obrigatorio)' : '';
|
|
42
|
+
process.stdout.write(` --${k} <${tipos}>${marca}\n`);
|
|
43
|
+
if (v.description) {
|
|
44
|
+
for (const linha of quebrar(String(v.description), 84)) process.stdout.write(` ${linha}\n`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
process.stdout.write(`\nExemplo: sijur ${t.name}${Object.keys(props).slice(0, 2).map(k => ` --${k} <valor>`).join('')}\n`);
|
|
48
|
+
return EXIT.OK;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function quebrar(txt, largura) {
|
|
52
|
+
const out = [];
|
|
53
|
+
let linha = '';
|
|
54
|
+
for (const p of txt.split(/\s+/)) {
|
|
55
|
+
if ((linha + ' ' + p).trim().length > largura) { out.push(linha.trim()); linha = p; }
|
|
56
|
+
else linha += ' ' + p;
|
|
57
|
+
}
|
|
58
|
+
if (linha.trim()) out.push(linha.trim());
|
|
59
|
+
return out;
|
|
60
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
|
|
5
|
+
export const SERVIDOR = process.env.SIJUR_MCP_URL || 'https://sijur.com.br/mcp';
|
|
6
|
+
|
|
7
|
+
// WHY descobrir em vez de listar: a CLI e espelho. Escopo novo no servidor tem de aparecer no
|
|
8
|
+
// consentimento sozinho — lista fixa aqui significaria uma ferramenta nova invisivel ate alguem
|
|
9
|
+
// lembrar de editar este arquivo. `scopes_supported` e o que o proprio servidor anuncia como
|
|
10
|
+
// concedivel (ele ja remove os `extension:*`, que nao valem para cliente OAuth).
|
|
11
|
+
// Quem decide o que ENTRA e o dono, na tela de consentimento, marcando as caixas.
|
|
12
|
+
const ESCOPOS_RESERVA = ['service:pendencias', 'service:pendencias:write'];
|
|
13
|
+
let _escopos = null;
|
|
14
|
+
|
|
15
|
+
export async function escoposSuportados() {
|
|
16
|
+
if (_escopos) return _escopos;
|
|
17
|
+
try {
|
|
18
|
+
const meta = await fetch(new URL('/.well-known/oauth-protected-resource', SERVIDOR).toString())
|
|
19
|
+
.then(r => (r.ok ? r.json() : null));
|
|
20
|
+
const lista = meta?.scopes_supported;
|
|
21
|
+
if (Array.isArray(lista) && lista.length) {
|
|
22
|
+
_escopos = lista;
|
|
23
|
+
return _escopos;
|
|
24
|
+
}
|
|
25
|
+
} catch { /* offline ou servidor antigo: cai na reserva */ }
|
|
26
|
+
_escopos = ESCOPOS_RESERVA;
|
|
27
|
+
return _escopos;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// WHY `.sijur-cli` e nao `.sijur`: `~/.sijur/` ja e o diretorio de runtime da automacao do
|
|
31
|
+
// SIJUR nesta maquina (esteira, followup-fila, plan-exec, debt-drain — logs e launchers vivos).
|
|
32
|
+
// A spec pedia `~/.sijur/audit.jsonl` pela razao certa (ficar FORA da pasta do lote, que o agente
|
|
33
|
+
// le); um diretorio proprio cumpre isso sem disputar espaco com outro sistema.
|
|
34
|
+
export const HOME_SIJUR = path.join(homedir(), '.sijur-cli');
|
|
35
|
+
export const AUDIT_PATH = path.join(HOME_SIJUR, 'audit.jsonl');
|
|
36
|
+
export const STATE_PATH = path.join(HOME_SIJUR, 'state.json');
|
|
37
|
+
|
|
38
|
+
export function garantirHome() {
|
|
39
|
+
fs.mkdirSync(HOME_SIJUR, { recursive: true, mode: 0o700 });
|
|
40
|
+
return HOME_SIJUR;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// O lote e o diretorio que contem MANIFESTO_PECAS.csv. Ordem: --lote, SIJUR_LOTE, cwd.
|
|
44
|
+
export function resolverLote(flagLote) {
|
|
45
|
+
const cand = flagLote || process.env.SIJUR_LOTE || process.cwd();
|
|
46
|
+
return path.resolve(cand);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const ARQ_MANIFESTO = 'MANIFESTO_PECAS.csv';
|
|
50
|
+
export const ARQ_CONTROLE = 'PROTOCOLADOS.md';
|
|
51
|
+
export const DIR_PROTOCOLADO = '01_JA_PROTOCOLADO';
|
package/src/exit.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const EXIT = {
|
|
2
|
+
OK: 0,
|
|
3
|
+
USO: 1,
|
|
4
|
+
AUTH: 2,
|
|
5
|
+
API: 4
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export class CliError extends Error {
|
|
9
|
+
constructor(code, message, extra = {}) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.extra = extra;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const erroUso = (m, e) => new CliError(EXIT.USO, m, e);
|
|
17
|
+
export const erroAuth = (m, e) => new CliError(EXIT.AUTH, m, e);
|
|
18
|
+
export const erroApi = (m, e) => new CliError(EXIT.API, m, e);
|
package/src/keychain.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { HOME_SIJUR, SERVIDOR, garantirHome } from './config.js';
|
|
5
|
+
|
|
6
|
+
const SERVICO = 'sijur-cli';
|
|
7
|
+
const CONTA = new URL(SERVIDOR).host;
|
|
8
|
+
const NO_MAC = process.platform === 'darwin';
|
|
9
|
+
const ARQ_FALLBACK = path.join(HOME_SIJUR, 'credentials.json');
|
|
10
|
+
|
|
11
|
+
function lerKeychain() {
|
|
12
|
+
try {
|
|
13
|
+
const out = execFileSync('security',
|
|
14
|
+
['find-generic-password', '-a', CONTA, '-s', SERVICO, '-w'],
|
|
15
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
16
|
+
return JSON.parse(out.trim());
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function gravarKeychain(blob) {
|
|
23
|
+
execFileSync('security',
|
|
24
|
+
['add-generic-password', '-a', CONTA, '-s', SERVICO, '-U',
|
|
25
|
+
'-D', 'sijur-cli credentials', '-w', JSON.stringify(blob)],
|
|
26
|
+
{ stdio: ['ignore', 'ignore', 'pipe'] });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function apagarKeychain() {
|
|
30
|
+
try {
|
|
31
|
+
execFileSync('security', ['delete-generic-password', '-a', CONTA, '-s', SERVICO],
|
|
32
|
+
{ stdio: 'ignore' });
|
|
33
|
+
} catch { /* nao existia */ }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function lerArquivo() {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(fs.readFileSync(ARQ_FALLBACK, 'utf8'));
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function gravarArquivo(blob) {
|
|
45
|
+
garantirHome();
|
|
46
|
+
fs.writeFileSync(ARQ_FALLBACK, JSON.stringify(blob, null, 2), { mode: 0o600 });
|
|
47
|
+
fs.chmodSync(ARQ_FALLBACK, 0o600);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function apagarArquivo() {
|
|
51
|
+
try { fs.unlinkSync(ARQ_FALLBACK); } catch { /* nao existia */ }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function ler() {
|
|
55
|
+
return NO_MAC ? lerKeychain() : lerArquivo();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function gravar(blob) {
|
|
59
|
+
return NO_MAC ? gravarKeychain(blob) : gravarArquivo(blob);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function apagar() {
|
|
63
|
+
return NO_MAC ? apagarKeychain() : apagarArquivo();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function atualizar(patch) {
|
|
67
|
+
const atual = ler() || {};
|
|
68
|
+
const novo = { ...atual, ...patch };
|
|
69
|
+
gravar(novo);
|
|
70
|
+
return novo;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const ondeGuarda = NO_MAC
|
|
74
|
+
? `Keychain do macOS (servico "${SERVICO}", conta "${CONTA}")`
|
|
75
|
+
: ARQ_FALLBACK;
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { auth } from '@modelcontextprotocol/sdk/client/auth.js';
|
|
2
|
+
import { SERVIDOR } from './config.js';
|
|
3
|
+
import { provedorParaTransporte } from './auth.js';
|
|
4
|
+
import * as cofre from './keychain.js';
|
|
5
|
+
import { erroAuth, erroApi } from './exit.js';
|
|
6
|
+
|
|
7
|
+
// WHY cliente proprio em vez do StreamableHTTPClientTransport do SDK:
|
|
8
|
+
// o transporte do SDK abre, logo apos o handshake, um GET de longa duracao para o canal
|
|
9
|
+
// servidor->cliente. Sob HTTP/1.1 esse GET fica ocupando a conexao e o POST seguinte espera
|
|
10
|
+
// atras dele — medido: `tools/call` com o stream aberto nao responde em 12s; o MESMO POST, sem
|
|
11
|
+
// o stream, responde em ~240ms. A CLI e pedido-resposta pura: nao consome notificacao do
|
|
12
|
+
// servidor, entao o canal so tinha custo. Sem ele, some a classe inteira de timeout.
|
|
13
|
+
// O SDK continua sendo a fonte do OAuth (`auth`), que e onde ele e solido.
|
|
14
|
+
|
|
15
|
+
const PROTOCOLO = '2025-06-18';
|
|
16
|
+
let _sessao = null;
|
|
17
|
+
let _proximoId = 1;
|
|
18
|
+
|
|
19
|
+
function tokenGuardado() {
|
|
20
|
+
return cofre.ler()?.tokens?.access_token || null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function cabecalhos(token, sessao) {
|
|
24
|
+
return {
|
|
25
|
+
'Authorization': `Bearer ${token}`,
|
|
26
|
+
'Content-Type': 'application/json',
|
|
27
|
+
'Accept': 'application/json, text/event-stream',
|
|
28
|
+
'MCP-Protocol-Version': PROTOCOLO,
|
|
29
|
+
...(sessao ? { 'mcp-session-id': sessao } : {})
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A resposta vem como um frame SSE (`event: message` + `data: {...}`) ou como JSON puro.
|
|
34
|
+
function extrairEnvelope(texto, id) {
|
|
35
|
+
const bruto = texto.trim();
|
|
36
|
+
if (!bruto) return null;
|
|
37
|
+
if (bruto.startsWith('{')) {
|
|
38
|
+
try { return JSON.parse(bruto); } catch { return null; }
|
|
39
|
+
}
|
|
40
|
+
for (const linha of bruto.split('\n')) {
|
|
41
|
+
if (!linha.startsWith('data:')) continue;
|
|
42
|
+
try {
|
|
43
|
+
const obj = JSON.parse(linha.slice(5).trim());
|
|
44
|
+
if (obj.id === id || id === undefined) return obj;
|
|
45
|
+
} catch { /* frame parcial */ }
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function renovarToken() {
|
|
51
|
+
const p = provedorParaTransporte();
|
|
52
|
+
const r = await auth(p, { serverUrl: SERVIDOR });
|
|
53
|
+
if (r !== 'AUTHORIZED') throw erroAuth('Token expirado e nao consegui renovar. Rode `sijur login`.');
|
|
54
|
+
const t = tokenGuardado();
|
|
55
|
+
if (!t) throw erroAuth('Renovacao nao devolveu token. Rode `sijur login`.');
|
|
56
|
+
return t;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function postar(corpo, { token, sessao }) {
|
|
60
|
+
return fetch(SERVIDOR, { method: 'POST', headers: cabecalhos(token, sessao), body: JSON.stringify(corpo) });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Uma tentativa; devolve { resposta, token } ja com renovacao feita se tomou 401.
|
|
64
|
+
async function postarComRenovacao(corpo, sessao) {
|
|
65
|
+
let token = tokenGuardado();
|
|
66
|
+
if (!token) throw erroAuth('Sem credencial nesta maquina. Rode `sijur login`.');
|
|
67
|
+
let r = await postar(corpo, { token, sessao });
|
|
68
|
+
if (r.status === 401) {
|
|
69
|
+
token = await renovarToken();
|
|
70
|
+
r = await postar(corpo, { token, sessao });
|
|
71
|
+
if (r.status === 401) throw erroAuth('Token recusado pelo servidor. Rode `sijur login`.');
|
|
72
|
+
}
|
|
73
|
+
return { resposta: r, token };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function conectar() {
|
|
77
|
+
if (_sessao) return _sessao;
|
|
78
|
+
|
|
79
|
+
const id = _proximoId++;
|
|
80
|
+
const { resposta } = await postarComRenovacao({
|
|
81
|
+
jsonrpc: '2.0', id, method: 'initialize',
|
|
82
|
+
params: { protocolVersion: PROTOCOLO, capabilities: {}, clientInfo: { name: 'sijur-cli', version: '1.0.0' } }
|
|
83
|
+
}, null);
|
|
84
|
+
|
|
85
|
+
if (!resposta.ok) {
|
|
86
|
+
throw erroApi(`Handshake recusado por ${SERVIDOR}: HTTP ${resposta.status}`);
|
|
87
|
+
}
|
|
88
|
+
const sessao = resposta.headers.get('mcp-session-id');
|
|
89
|
+
const env = extrairEnvelope(await resposta.text(), id);
|
|
90
|
+
if (env?.error) throw erroApi(`Handshake: ${env.error.message || JSON.stringify(env.error)}`);
|
|
91
|
+
if (!sessao) throw erroApi('Servidor nao devolveu mcp-session-id no handshake.');
|
|
92
|
+
|
|
93
|
+
_sessao = sessao;
|
|
94
|
+
// Notificacao obrigatoria do protocolo; nao tem resposta.
|
|
95
|
+
await postarComRenovacao({ jsonrpc: '2.0', method: 'notifications/initialized' }, _sessao).catch(() => {});
|
|
96
|
+
return _sessao;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function fechar() {
|
|
100
|
+
_sessao = null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const RE_SCOPE = /Scope insuficiente\.?\s*Requer:\s*([^\s"'}]+)/i;
|
|
104
|
+
|
|
105
|
+
function checarEscopo(texto) {
|
|
106
|
+
const m = RE_SCOPE.exec(texto || '');
|
|
107
|
+
if (m) throw erroAuth(`Escopo faltando: ${m[1]}. Rode \`sijur login\` para reautorizar com esse escopo.`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const chamarBruto = (nome, argumentos) => _chamar(nome, argumentos, true);
|
|
111
|
+
export const chamar = (nome, argumentos) => _chamar(nome, argumentos, false);
|
|
112
|
+
|
|
113
|
+
async function _chamar(nome, argumentos, bruto) {
|
|
114
|
+
const sessao = await conectar();
|
|
115
|
+
const id = _proximoId++;
|
|
116
|
+
|
|
117
|
+
const { resposta } = await postarComRenovacao({
|
|
118
|
+
jsonrpc: '2.0', id, method: 'tools/call', params: { name: nome, arguments: argumentos }
|
|
119
|
+
}, sessao);
|
|
120
|
+
|
|
121
|
+
if (resposta.status === 429) {
|
|
122
|
+
throw erroApi(`Limite de requisicoes do SIJUR atingido (HTTP 429). Espere e repita.`);
|
|
123
|
+
}
|
|
124
|
+
const texto = await resposta.text();
|
|
125
|
+
if (!resposta.ok) {
|
|
126
|
+
checarEscopo(texto);
|
|
127
|
+
throw erroApi(`Tool ${nome}: HTTP ${resposta.status} — ${texto.slice(0, 300)}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const env = extrairEnvelope(texto, id);
|
|
131
|
+
if (!env) throw erroApi(`Tool ${nome}: nao entendi a resposta do servidor: ${texto.slice(0, 200)}`);
|
|
132
|
+
if (env.error) {
|
|
133
|
+
checarEscopo(env.error.message || '');
|
|
134
|
+
throw erroApi(`Tool ${nome}: ${env.error.message || JSON.stringify(env.error)}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const conteudo = (env.result?.content || []).filter(c => c.type === 'text').map(c => c.text).join('\n');
|
|
138
|
+
checarEscopo(conteudo);
|
|
139
|
+
|
|
140
|
+
if (env.result?.structuredContent && typeof env.result.structuredContent === 'object') {
|
|
141
|
+
return env.result.structuredContent;
|
|
142
|
+
}
|
|
143
|
+
if (!conteudo) throw erroApi(`Tool ${nome} devolveu resposta vazia.`);
|
|
144
|
+
|
|
145
|
+
let obj;
|
|
146
|
+
try {
|
|
147
|
+
obj = JSON.parse(conteudo);
|
|
148
|
+
} catch {
|
|
149
|
+
if (env.result?.isError) throw erroApi(`Tool ${nome}: ${conteudo.slice(0, 400)}`);
|
|
150
|
+
throw erroApi(`Tool ${nome} devolveu texto que nao e JSON: ${conteudo.slice(0, 200)}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (bruto) return obj;
|
|
154
|
+
|
|
155
|
+
if (env.result?.isError || obj?.success === false) {
|
|
156
|
+
throw erroApi(`Tool ${nome}: ${obj?.error || obj?.motivo || conteudo.slice(0, 300)}`, { resposta: obj });
|
|
157
|
+
}
|
|
158
|
+
return obj;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// O servidor ja filtra o catalogo por escopo do token — o que volta aqui e exatamente
|
|
162
|
+
// o que este token pode chamar. E a fonte do espelho: nomes, descricoes e schemas.
|
|
163
|
+
let _catalogo = null;
|
|
164
|
+
|
|
165
|
+
export async function listarToolsCompleto() {
|
|
166
|
+
if (_catalogo) return _catalogo;
|
|
167
|
+
const sessao = await conectar();
|
|
168
|
+
const id = _proximoId++;
|
|
169
|
+
const { resposta } = await postarComRenovacao({ jsonrpc: '2.0', id, method: 'tools/list', params: {} }, sessao);
|
|
170
|
+
const texto = await resposta.text();
|
|
171
|
+
if (!resposta.ok) throw erroApi(`tools/list: HTTP ${resposta.status}`);
|
|
172
|
+
const env = extrairEnvelope(texto, id);
|
|
173
|
+
if (env?.error) throw erroApi(`tools/list: ${env.error.message}`);
|
|
174
|
+
_catalogo = (env?.result?.tools || []).slice().sort((a, b) => a.name.localeCompare(b.name));
|
|
175
|
+
return _catalogo;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function listarTools() {
|
|
179
|
+
return (await listarToolsCompleto()).map(t => t.name);
|
|
180
|
+
}
|