passe1 0.2.3__py3-none-any.whl
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.
- passe1-0.2.3.dist-info/METADATA +77 -0
- passe1-0.2.3.dist-info/RECORD +6 -0
- passe1-0.2.3.dist-info/WHEEL +5 -0
- passe1-0.2.3.dist-info/entry_points.txt +2 -0
- passe1-0.2.3.dist-info/top_level.txt +1 -0
- passe1.py +778 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: passe1
|
|
3
|
+
Version: 0.2.3
|
|
4
|
+
Summary: CLI do Passe1: seu passe de IA no terminal.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
|
|
9
|
+
# CLI do Passe1 (fatia 2)
|
|
10
|
+
|
|
11
|
+
Seu passe de IA no terminal: valida a chave, conversa com streaming,
|
|
12
|
+
lista modelos e skills, e aponta seus harnesses para o gateway.
|
|
13
|
+
|
|
14
|
+
## Instalacao
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
curl -fsSL https://passe.dehor.com.br/cli/install.sh | sh
|
|
18
|
+
|
|
19
|
+
# depois use direto, de qualquer pasta:
|
|
20
|
+
passe1 login
|
|
21
|
+
passe1 chat "explique closures em 3 linhas"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Desenvolvedores (dentro do repo): `pip install ./cli`, ou rode sem
|
|
25
|
+
instalar com `python cli/passe1.py ...`.
|
|
26
|
+
|
|
27
|
+
## Uso
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
# entrar uma vez (a chave fica em ~/.config/passe1/key, modo 600)
|
|
31
|
+
passe1 login
|
|
32
|
+
passe1 login sk-passe1-...
|
|
33
|
+
|
|
34
|
+
# conversar (uma pergunta, stdin ou REPL; /sair encerra, /limpar zera)
|
|
35
|
+
passe1 chat "explique closures em 3 linhas"
|
|
36
|
+
passe1 chat
|
|
37
|
+
passe1 chat --model mimo-v2.5 "oi"
|
|
38
|
+
echo "resuma isto" | passe1 chat
|
|
39
|
+
|
|
40
|
+
# skills locais (SKILL.md do Claude/Codex ou de ~/.config/passe1/skills)
|
|
41
|
+
passe1 skills
|
|
42
|
+
passe1 chat --skill revisor "revise este texto"
|
|
43
|
+
# no REPL: /skill NOME ativa, /skill mostra a ativa, /skill off desativa
|
|
44
|
+
# (a ativa fica salva e vale para as proximas sessoes)
|
|
45
|
+
# /model mostra o atual, /model ID troca sem sair do chat
|
|
46
|
+
|
|
47
|
+
# modelos (com visao e janela) e status (passe, validade, uso)
|
|
48
|
+
passe1 models
|
|
49
|
+
passe1 status
|
|
50
|
+
passe1 models --json # saida maquina para scripts
|
|
51
|
+
passe1 status --json
|
|
52
|
+
passe1 logout
|
|
53
|
+
|
|
54
|
+
# apontar um harness para o Passe1 (faz backup do config atual)
|
|
55
|
+
passe1 setup claude
|
|
56
|
+
passe1 setup codex
|
|
57
|
+
passe1 setup verboo # gera ~/.config/passe1/verboo.sh, importe com source
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Opções globais: `--base-url` (aponta para outro gateway, ex. o local) e
|
|
61
|
+
`--versao`. A variável `PASSE1_MODEL` troca o modelo padrão do chat.
|
|
62
|
+
|
|
63
|
+
## Testes
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
pytest cli/tests -q # sem rede (httpx MockTransport)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Notas
|
|
70
|
+
|
|
71
|
+
- A chave nunca aparece em log nem em erro: só os 4 últimos caracteres.
|
|
72
|
+
- O chat usa streaming (SSE); se o gateway responder JSON simples, a CLI
|
|
73
|
+
entende mesmo assim.
|
|
74
|
+
- O REPL guarda historico em ~/.config/passe1/history (setas funcionam).
|
|
75
|
+
- `setup codex` pede `export PASSE1_API_KEY=$(cat ~/.config/passe1/key)` no
|
|
76
|
+
terminal; `setup verboo` gera snippet para `source` (o Verboo CLI lê
|
|
77
|
+
provedor por variável de ambiente).
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
passe1.py,sha256=Cqlh1dPdfAImz8et5MoA78s8gVYtv7TsXk6kTdE2uEQ,26475
|
|
2
|
+
passe1-0.2.3.dist-info/METADATA,sha256=-oZXBwZ_Qdt0JSvsNJzK6hFJcSXRTHyCzLbT5tFfXP8,2312
|
|
3
|
+
passe1-0.2.3.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
4
|
+
passe1-0.2.3.dist-info/entry_points.txt,sha256=XRLEilahXJJNdCRnfXN9zXAhENGfH4yEElBdoJubYvU,39
|
|
5
|
+
passe1-0.2.3.dist-info/top_level.txt,sha256=1aqH2GHCqGly_tCRAMyE8p1A4rS7SjtmT4AUHE-JTgA,7
|
|
6
|
+
passe1-0.2.3.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
passe1
|
passe1.py
ADDED
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""CLI do Passe1: login, chat no terminal, setup de harnesses e status.
|
|
3
|
+
|
|
4
|
+
Fatia 1: o essencial para o cliente usar o passe fora do navegador.
|
|
5
|
+
Fatia 2: models, status rico (/v1/me), chat com streaming, historico
|
|
6
|
+
do terminal e skills locais (SKILL.md).
|
|
7
|
+
Toda conversa com o gateway usa o formato OpenAI em ``/v1``.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import datetime
|
|
13
|
+
import getpass
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import shlex
|
|
17
|
+
import shutil
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Callable
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
VERSAO = "0.2.3"
|
|
25
|
+
SISTEMA_PADRAO = "Você é o assistente do Passe1. Responda SEMPRE em português do Brasil, de forma clara e direta. Nunca misture idiomas na mesma resposta. Só use outro idioma se a pessoa pedir explicitamente."
|
|
26
|
+
BASE_URL_PADRAO = "https://passe.dehor.com.br/v1"
|
|
27
|
+
PREFIXO_CHAVE = "sk-passe1-"
|
|
28
|
+
MODELO_PADRAO = "deepseek-v4-flash"
|
|
29
|
+
TIMEOUT_MODELOS = 15.0
|
|
30
|
+
TIMEOUT_CHAT = 60.0
|
|
31
|
+
|
|
32
|
+
# Transporte injetavel: os testes trocam por httpx.MockTransport para
|
|
33
|
+
# nunca tocar a rede. Em uso normal fica None (rede de verdade).
|
|
34
|
+
_transporte = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _hoje() -> Path:
|
|
38
|
+
"""Home do usuario; PASSE1_HOME existe so para os testes isolarem."""
|
|
39
|
+
return Path(os.environ.get("PASSE1_HOME") or Path.home())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _dir_config() -> Path:
|
|
43
|
+
return Path(os.environ.get("PASSE1_CONFIG_DIR") or _hoje() / ".config" / "passe1")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _arquivo_chave() -> Path:
|
|
47
|
+
return _dir_config() / "key"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _base_url(args_base: str | None = None) -> str:
|
|
51
|
+
return (args_base or os.environ.get("PASSE1_BASE_URL") or BASE_URL_PADRAO).rstrip("/")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _cliente(base_url: str, timeout: float) -> httpx.Client:
|
|
55
|
+
return httpx.Client(base_url=base_url, timeout=timeout, transport=_transporte)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _ler_chave() -> str | None:
|
|
59
|
+
try:
|
|
60
|
+
texto = _arquivo_chave().read_text(encoding="utf-8").strip()
|
|
61
|
+
except OSError:
|
|
62
|
+
return None
|
|
63
|
+
return texto or None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _guardar_chave(chave: str) -> None:
|
|
67
|
+
destino = _arquivo_chave()
|
|
68
|
+
destino.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
os.chmod(destino.parent, 0o700)
|
|
70
|
+
fd = os.open(destino, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
71
|
+
try:
|
|
72
|
+
os.write(fd, (chave.strip() + "\n").encode("utf-8"))
|
|
73
|
+
finally:
|
|
74
|
+
os.close(fd)
|
|
75
|
+
os.chmod(destino, 0o600)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _codigo_erro(resp: httpx.Response) -> tuple[str, str]:
|
|
79
|
+
"""Extrai (code, message) do envelope de erro do gateway, com fallback."""
|
|
80
|
+
try:
|
|
81
|
+
erro = resp.json().get("error", {})
|
|
82
|
+
except ValueError:
|
|
83
|
+
return "", f"o gateway respondeu HTTP {resp.status_code} sem JSON."
|
|
84
|
+
if not isinstance(erro, dict):
|
|
85
|
+
return "", f"o gateway respondeu HTTP {resp.status_code}."
|
|
86
|
+
return str(erro.get("code", "")), str(erro.get("message", ""))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _msg_401(codigo: str, detalhe: str) -> str:
|
|
90
|
+
if codigo == "pass_expired":
|
|
91
|
+
return "Passe expirado. Renove em https://passe.dehor.com.br/comprar."
|
|
92
|
+
if codigo == "api_key_revoked":
|
|
93
|
+
return "Essa chave foi revogada. Gere outra no painel e faca login de novo."
|
|
94
|
+
if codigo == "invalid_api_key":
|
|
95
|
+
return "Chave desconhecida. Confira se voce copiou a chave inteira."
|
|
96
|
+
return detalhe or "Chave rejeitada pelo gateway (HTTP 401)."
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _sem_login() -> int:
|
|
100
|
+
print("Voce ainda nao fez login. Rode: passe1 login")
|
|
101
|
+
return 1
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _plural(n: int, um: str, varios: str) -> str:
|
|
105
|
+
return f"{n} {um if n == 1 else varios}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def cmd_login(args: argparse.Namespace) -> int:
|
|
109
|
+
chave = args.chave or getpass.getpass("Cole sua chave (sk-passe1-...): ")
|
|
110
|
+
chave = chave.strip()
|
|
111
|
+
if not chave.startswith(PREFIXO_CHAVE):
|
|
112
|
+
print(f"Formato invalido: a chave precisa comecar com '{PREFIXO_CHAVE}'.")
|
|
113
|
+
return 1
|
|
114
|
+
base = _base_url(args.base_url)
|
|
115
|
+
try:
|
|
116
|
+
resp = _cliente(base, TIMEOUT_MODELOS).get(
|
|
117
|
+
"/models", headers={"Authorization": f"Bearer {chave}"}
|
|
118
|
+
)
|
|
119
|
+
except httpx.HTTPError as exc:
|
|
120
|
+
print(f"Nao consegui falar com o gateway ({exc.__class__.__name__}).")
|
|
121
|
+
print("Confira sua conexao e tente de novo.")
|
|
122
|
+
return 1
|
|
123
|
+
if resp.status_code == 200:
|
|
124
|
+
_guardar_chave(chave)
|
|
125
|
+
print("Login ok! Passe valido e chave guardada com seguranca.")
|
|
126
|
+
return 0
|
|
127
|
+
if resp.status_code == 401:
|
|
128
|
+
codigo, detalhe = _codigo_erro(resp)
|
|
129
|
+
print(_msg_401(codigo, detalhe))
|
|
130
|
+
return 1
|
|
131
|
+
codigo, detalhe = _codigo_erro(resp)
|
|
132
|
+
print(detalhe or f"O gateway respondeu HTTP {resp.status_code}.")
|
|
133
|
+
return 1
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def cmd_logout(args: argparse.Namespace) -> int:
|
|
137
|
+
_ = args
|
|
138
|
+
try:
|
|
139
|
+
_arquivo_chave().unlink()
|
|
140
|
+
except FileNotFoundError:
|
|
141
|
+
print("Voce ja estava deslogado.")
|
|
142
|
+
return 0
|
|
143
|
+
except OSError as exc:
|
|
144
|
+
print(f"Nao consegui apagar a chave guardada: {exc}.")
|
|
145
|
+
return 1
|
|
146
|
+
print("Logout ok! Chave apagada deste computador.")
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def cmd_models(args: argparse.Namespace) -> int:
|
|
151
|
+
chave = _ler_chave()
|
|
152
|
+
if chave is None:
|
|
153
|
+
return _sem_login()
|
|
154
|
+
base = _base_url(args.base_url)
|
|
155
|
+
try:
|
|
156
|
+
resp = _cliente(base, TIMEOUT_MODELOS).get(
|
|
157
|
+
"/models", headers={"Authorization": f"Bearer {chave}"}
|
|
158
|
+
)
|
|
159
|
+
except httpx.HTTPError as exc:
|
|
160
|
+
print(f"Nao consegui falar com o gateway ({exc.__class__.__name__}).")
|
|
161
|
+
return 1
|
|
162
|
+
if resp.status_code == 401:
|
|
163
|
+
codigo, detalhe = _codigo_erro(resp)
|
|
164
|
+
print(_msg_401(codigo, detalhe))
|
|
165
|
+
return 1
|
|
166
|
+
if resp.status_code != 200:
|
|
167
|
+
_, detalhe = _codigo_erro(resp)
|
|
168
|
+
print(detalhe or f"O gateway respondeu HTTP {resp.status_code}.")
|
|
169
|
+
return 1
|
|
170
|
+
try:
|
|
171
|
+
dados = resp.json().get("data", [])
|
|
172
|
+
except ValueError:
|
|
173
|
+
dados = []
|
|
174
|
+
if not isinstance(dados, list):
|
|
175
|
+
dados = []
|
|
176
|
+
if args.json:
|
|
177
|
+
print(json.dumps(dados, ensure_ascii=False))
|
|
178
|
+
return 0
|
|
179
|
+
print(f"Modelos disponiveis: {len(dados)}")
|
|
180
|
+
for modelo in dados:
|
|
181
|
+
if not isinstance(modelo, dict) or "id" not in modelo:
|
|
182
|
+
continue
|
|
183
|
+
partes = [f" - {modelo['id']}"]
|
|
184
|
+
if modelo.get("vision") is True:
|
|
185
|
+
partes.append("[visao]")
|
|
186
|
+
janela = modelo.get("context_window")
|
|
187
|
+
if janela:
|
|
188
|
+
partes.append(f"(janela: {janela} tokens)")
|
|
189
|
+
print(" ".join(partes))
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _linha_uso(rotulo: str, bloco) -> None:
|
|
194
|
+
try:
|
|
195
|
+
req = int(bloco["requisicoes"])
|
|
196
|
+
tok = int(bloco["tokens"])
|
|
197
|
+
err = int(bloco["erros"])
|
|
198
|
+
except (KeyError, TypeError, ValueError):
|
|
199
|
+
print(f"{rotulo}: dado indisponivel.")
|
|
200
|
+
return
|
|
201
|
+
print(
|
|
202
|
+
f"{rotulo}: {_plural(req, 'requisicao', 'requisicoes')}, "
|
|
203
|
+
f"{_plural(tok, 'token', 'tokens')}, {_plural(err, 'erro', 'erros')}."
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
208
|
+
chave = _ler_chave()
|
|
209
|
+
if chave is None:
|
|
210
|
+
return _sem_login()
|
|
211
|
+
base = _base_url(args.base_url)
|
|
212
|
+
try:
|
|
213
|
+
resp = _cliente(base, TIMEOUT_MODELOS).get(
|
|
214
|
+
"/me", headers={"Authorization": f"Bearer {chave}"}
|
|
215
|
+
)
|
|
216
|
+
except httpx.HTTPError as exc:
|
|
217
|
+
print(f"Nao consegui falar com o gateway ({exc.__class__.__name__}).")
|
|
218
|
+
return 1
|
|
219
|
+
if resp.status_code == 401:
|
|
220
|
+
codigo, detalhe = _codigo_erro(resp)
|
|
221
|
+
print(_msg_401(codigo, detalhe))
|
|
222
|
+
return 1
|
|
223
|
+
if resp.status_code != 200:
|
|
224
|
+
_, detalhe = _codigo_erro(resp)
|
|
225
|
+
print(detalhe or f"O gateway respondeu HTTP {resp.status_code}.")
|
|
226
|
+
return 1
|
|
227
|
+
try:
|
|
228
|
+
corpo = resp.json()
|
|
229
|
+
except ValueError:
|
|
230
|
+
corpo = None
|
|
231
|
+
if args.json:
|
|
232
|
+
if not isinstance(corpo, dict):
|
|
233
|
+
print("O gateway respondeu num formato que nao entendi.")
|
|
234
|
+
return 1
|
|
235
|
+
print(json.dumps(corpo, ensure_ascii=False))
|
|
236
|
+
return 0
|
|
237
|
+
try:
|
|
238
|
+
passe = corpo["passe"]
|
|
239
|
+
uso = corpo["uso"]
|
|
240
|
+
dia = uso["ultimas_24h"]
|
|
241
|
+
semana = uso["ultimos_7d"]
|
|
242
|
+
except (KeyError, TypeError):
|
|
243
|
+
print("O gateway respondeu num formato que nao entendi.")
|
|
244
|
+
return 1
|
|
245
|
+
print("Passe valido." if passe.get("ativo", True) else "Passe inativo.")
|
|
246
|
+
print(f"Plano: {passe.get('tier') or '-'}.")
|
|
247
|
+
dias = passe.get("dias_restantes")
|
|
248
|
+
expira = passe.get("expira_em")
|
|
249
|
+
data = ""
|
|
250
|
+
if expira:
|
|
251
|
+
try:
|
|
252
|
+
quando = datetime.datetime.fromtimestamp(float(expira))
|
|
253
|
+
data = f" (ate {quando.strftime('%d/%m/%Y')})"
|
|
254
|
+
except (ValueError, TypeError, OSError, OverflowError):
|
|
255
|
+
data = ""
|
|
256
|
+
if dias is not None:
|
|
257
|
+
try:
|
|
258
|
+
print(f"Valido por mais {_plural(int(dias), 'dia', 'dias')}{data}.")
|
|
259
|
+
except (TypeError, ValueError):
|
|
260
|
+
print("Sem data de expiracao.")
|
|
261
|
+
elif data:
|
|
262
|
+
print(f"Expira{data}.")
|
|
263
|
+
else:
|
|
264
|
+
print("Sem data de expiracao.")
|
|
265
|
+
_linha_uso("Uso nas ultimas 24h", dia)
|
|
266
|
+
_linha_uso("Uso nos ultimos 7 dias", semana)
|
|
267
|
+
return 0
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _post_chat(base: str, chave: str, modelo: str, mensagens: list) -> httpx.Response:
|
|
271
|
+
return _cliente(base, TIMEOUT_CHAT).post(
|
|
272
|
+
"/chat/completions",
|
|
273
|
+
headers={"Authorization": f"Bearer {chave}"},
|
|
274
|
+
json={"model": modelo, "messages": mensagens, "stream": True},
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _texto_resposta(resp: httpx.Response) -> tuple[str | None, str | None]:
|
|
279
|
+
"""Retorna (texto, erro): um dos dois sempre vem preenchido."""
|
|
280
|
+
if resp.status_code == 401:
|
|
281
|
+
codigo, detalhe = _codigo_erro(resp)
|
|
282
|
+
return None, _msg_401(codigo, detalhe)
|
|
283
|
+
if resp.status_code == 429:
|
|
284
|
+
_, detalhe = _codigo_erro(resp)
|
|
285
|
+
return None, detalhe or "Limite atingido. Aguarde um pouco e tente de novo."
|
|
286
|
+
if resp.status_code != 200:
|
|
287
|
+
_, detalhe = _codigo_erro(resp)
|
|
288
|
+
return None, detalhe or f"O gateway respondeu HTTP {resp.status_code}."
|
|
289
|
+
try:
|
|
290
|
+
corpo = resp.json()
|
|
291
|
+
texto = corpo["choices"][0]["message"]["content"]
|
|
292
|
+
except (ValueError, KeyError, IndexError, TypeError):
|
|
293
|
+
return None, "O gateway respondeu num formato que nao entendi."
|
|
294
|
+
return str(texto), None
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _imprimir_sse(texto: str, prefixo: str) -> str | None:
|
|
298
|
+
"""Imprime os tokens de um corpo SSE conforme parseia.
|
|
299
|
+
|
|
300
|
+
Retorna o texto completo, ou None se o corpo nao for SSE (nesse
|
|
301
|
+
caso nada e impresso e o chamador tenta o JSON simples).
|
|
302
|
+
"""
|
|
303
|
+
partes: list[str] = []
|
|
304
|
+
viu_sse = False
|
|
305
|
+
comecou = False
|
|
306
|
+
for linha in texto.splitlines():
|
|
307
|
+
linha = linha.strip()
|
|
308
|
+
if not linha.startswith("data:"):
|
|
309
|
+
continue
|
|
310
|
+
viu_sse = True
|
|
311
|
+
dado = linha[5:].strip()
|
|
312
|
+
if dado == "[DONE]":
|
|
313
|
+
break
|
|
314
|
+
try:
|
|
315
|
+
delta = json.loads(dado)["choices"][0]["delta"].get("content") or ""
|
|
316
|
+
except (ValueError, KeyError, IndexError, TypeError):
|
|
317
|
+
continue
|
|
318
|
+
if delta:
|
|
319
|
+
if not comecou:
|
|
320
|
+
print(prefixo, end="", flush=True)
|
|
321
|
+
comecou = True
|
|
322
|
+
print(delta, end="", flush=True)
|
|
323
|
+
partes.append(str(delta))
|
|
324
|
+
if not viu_sse:
|
|
325
|
+
return None
|
|
326
|
+
if comecou:
|
|
327
|
+
print()
|
|
328
|
+
else:
|
|
329
|
+
print(prefixo)
|
|
330
|
+
return "".join(partes)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _mostrar_resposta_chat(resp: httpx.Response, prefixo: str = "") -> tuple[str | None, str | None]:
|
|
334
|
+
"""Imprime a resposta do chat e retorna (texto, erro).
|
|
335
|
+
|
|
336
|
+
Tenta o stream SSE primeiro; se o gateway mandou JSON simples,
|
|
337
|
+
cai no parse normal sem quebrar.
|
|
338
|
+
"""
|
|
339
|
+
if resp.status_code != 200:
|
|
340
|
+
return _texto_resposta(resp)
|
|
341
|
+
texto = _imprimir_sse(resp.text, prefixo)
|
|
342
|
+
if texto is not None:
|
|
343
|
+
return texto, None
|
|
344
|
+
texto, erro = _texto_resposta(resp)
|
|
345
|
+
if texto is not None:
|
|
346
|
+
print(f"{prefixo}{texto}")
|
|
347
|
+
return texto, erro
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _uma_pergunta(
|
|
351
|
+
base: str, chave: str, modelo: str, texto: str, sistema: str | None = None
|
|
352
|
+
) -> int:
|
|
353
|
+
mensagens: list = []
|
|
354
|
+
if sistema:
|
|
355
|
+
mensagens.append({"role": "system", "content": sistema})
|
|
356
|
+
mensagens.append({"role": "user", "content": texto})
|
|
357
|
+
try:
|
|
358
|
+
resp = _post_chat(base, chave, modelo, mensagens)
|
|
359
|
+
except httpx.HTTPError as exc:
|
|
360
|
+
print(f"Nao consegui falar com o gateway ({exc.__class__.__name__}).")
|
|
361
|
+
return 1
|
|
362
|
+
_, erro = _mostrar_resposta_chat(resp)
|
|
363
|
+
if erro is not None:
|
|
364
|
+
print(erro)
|
|
365
|
+
return 1
|
|
366
|
+
return 0
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _arquivo_historico() -> Path:
|
|
370
|
+
return _dir_config() / "history"
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _configurar_historico() -> Callable[[], None] | None:
|
|
374
|
+
"""Liga o historico do terminal (setas); None se readline nao existir."""
|
|
375
|
+
try:
|
|
376
|
+
import readline
|
|
377
|
+
except ImportError:
|
|
378
|
+
return None
|
|
379
|
+
try:
|
|
380
|
+
alvo = _arquivo_historico()
|
|
381
|
+
alvo.parent.mkdir(parents=True, exist_ok=True)
|
|
382
|
+
try:
|
|
383
|
+
readline.read_history_file(str(alvo))
|
|
384
|
+
except OSError:
|
|
385
|
+
pass
|
|
386
|
+
try:
|
|
387
|
+
readline.set_history_length(500)
|
|
388
|
+
except (OSError, ValueError):
|
|
389
|
+
pass
|
|
390
|
+
|
|
391
|
+
def _salvar() -> None:
|
|
392
|
+
try:
|
|
393
|
+
readline.write_history_file(str(alvo))
|
|
394
|
+
except OSError:
|
|
395
|
+
pass
|
|
396
|
+
|
|
397
|
+
return _salvar
|
|
398
|
+
except OSError:
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _dirs_skills() -> list[Path]:
|
|
403
|
+
casa = _hoje()
|
|
404
|
+
return [
|
|
405
|
+
casa / ".claude" / "skills",
|
|
406
|
+
casa / ".codex" / "skills",
|
|
407
|
+
_dir_config() / "skills",
|
|
408
|
+
]
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _listar_skills() -> dict[str, Path]:
|
|
412
|
+
"""Nome (nome do diretorio) para SKILL.md; o dir do passe1 ganha empates."""
|
|
413
|
+
achadas: dict[str, Path] = {}
|
|
414
|
+
for base in _dirs_skills():
|
|
415
|
+
try:
|
|
416
|
+
pastas = sorted(p for p in base.iterdir() if p.is_dir())
|
|
417
|
+
except OSError:
|
|
418
|
+
continue
|
|
419
|
+
for pasta in pastas:
|
|
420
|
+
alvo = pasta / "SKILL.md"
|
|
421
|
+
if alvo.is_file():
|
|
422
|
+
achadas[pasta.name] = alvo
|
|
423
|
+
return achadas
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _ler_skill(nome: str) -> str | None:
|
|
427
|
+
alvo = _listar_skills().get(nome)
|
|
428
|
+
if alvo is None:
|
|
429
|
+
return None
|
|
430
|
+
try:
|
|
431
|
+
texto = alvo.read_text(encoding="utf-8").strip()
|
|
432
|
+
except OSError:
|
|
433
|
+
return None
|
|
434
|
+
return texto or None
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _arquivo_skill_ativa() -> Path:
|
|
438
|
+
return _dir_config() / "skill"
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _ler_skill_ativa() -> str | None:
|
|
442
|
+
try:
|
|
443
|
+
texto = _arquivo_skill_ativa().read_text(encoding="utf-8").strip()
|
|
444
|
+
except OSError:
|
|
445
|
+
return None
|
|
446
|
+
return texto or None
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _guardar_skill_ativa(nome: str) -> None:
|
|
450
|
+
destino = _arquivo_skill_ativa()
|
|
451
|
+
destino.parent.mkdir(parents=True, exist_ok=True)
|
|
452
|
+
destino.write_text(nome.strip() + "\n", encoding="utf-8")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _limpar_skill_ativa() -> None:
|
|
456
|
+
try:
|
|
457
|
+
_arquivo_skill_ativa().unlink()
|
|
458
|
+
except OSError:
|
|
459
|
+
pass
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def cmd_skills(args: argparse.Namespace) -> int:
|
|
463
|
+
_ = args
|
|
464
|
+
achadas = _listar_skills()
|
|
465
|
+
if not achadas:
|
|
466
|
+
print("Nenhuma skill encontrada.")
|
|
467
|
+
print("Coloque um SKILL.md em ~/.config/passe1/skills/NOME/SKILL.md,")
|
|
468
|
+
print("ou reaproveite as de ~/.claude/skills e ~/.codex/skills.")
|
|
469
|
+
return 0
|
|
470
|
+
ativa = _ler_skill_ativa()
|
|
471
|
+
print(f"Skills disponiveis: {len(achadas)}")
|
|
472
|
+
for nome in sorted(achadas):
|
|
473
|
+
marca = " (ativa)" if nome == ativa else ""
|
|
474
|
+
print(f" - {nome}{marca}")
|
|
475
|
+
return 0
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
AJUDA_CHAT = (
|
|
479
|
+
"Conversa com o passe. Comandos: /sair termina, /limpar zera o contexto,"
|
|
480
|
+
" /skill NOME ativa uma skill, /model [ID] mostra ou troca o modelo."
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _msg_system(sistema: str | None) -> list:
|
|
485
|
+
if sistema:
|
|
486
|
+
return [{"role": "system", "content": sistema}]
|
|
487
|
+
return []
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _trocar_skill_repl(
|
|
491
|
+
texto: str, nome_atual: str | None, sistema_atual: str | None, historico: list
|
|
492
|
+
) -> tuple[str | None, str | None, list]:
|
|
493
|
+
"""Trata o /skill no REPL; devolve (nome, sistema, historico)."""
|
|
494
|
+
partes = texto.split(None, 1)
|
|
495
|
+
if len(partes) == 1:
|
|
496
|
+
if nome_atual:
|
|
497
|
+
print(f"Skill ativa: {nome_atual}.")
|
|
498
|
+
else:
|
|
499
|
+
print("Nenhuma skill ativa. Use: /skill NOME (veja com: passe1 skills).")
|
|
500
|
+
return nome_atual, sistema_atual, historico
|
|
501
|
+
pedido = partes[1].strip()
|
|
502
|
+
if pedido == "off":
|
|
503
|
+
msg = {"role": "system", "content": SISTEMA_PADRAO}
|
|
504
|
+
if historico and historico[0].get("role") == "system":
|
|
505
|
+
historico[0] = msg
|
|
506
|
+
else:
|
|
507
|
+
historico.insert(0, msg)
|
|
508
|
+
_limpar_skill_ativa()
|
|
509
|
+
print("Skill desativada.")
|
|
510
|
+
return None, SISTEMA_PADRAO, historico
|
|
511
|
+
conteudo = _ler_skill(pedido)
|
|
512
|
+
if conteudo is None:
|
|
513
|
+
print(f"Skill '{pedido}' nao encontrada. Veja com: passe1 skills.")
|
|
514
|
+
return nome_atual, sistema_atual, historico
|
|
515
|
+
msg = {"role": "system", "content": conteudo}
|
|
516
|
+
if historico and historico[0].get("role") == "system":
|
|
517
|
+
historico[0] = msg
|
|
518
|
+
else:
|
|
519
|
+
historico.insert(0, msg)
|
|
520
|
+
_guardar_skill_ativa(pedido)
|
|
521
|
+
print(f"Skill '{pedido}' ativada.")
|
|
522
|
+
return pedido, conteudo, historico
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _repl(
|
|
526
|
+
base: str,
|
|
527
|
+
chave: str,
|
|
528
|
+
modelo: str,
|
|
529
|
+
ler: Callable[[str], str],
|
|
530
|
+
skill: str | None = None,
|
|
531
|
+
) -> int:
|
|
532
|
+
salvar_historico = _configurar_historico()
|
|
533
|
+
try:
|
|
534
|
+
print(AJUDA_CHAT)
|
|
535
|
+
nome_skill = skill or _ler_skill_ativa()
|
|
536
|
+
sistema = _ler_skill(nome_skill) if nome_skill else SISTEMA_PADRAO
|
|
537
|
+
if nome_skill is not None and sistema is None:
|
|
538
|
+
# So chega aqui pela skill salva: a do --skill o cmd_chat ja validou.
|
|
539
|
+
print(f"A skill salva '{nome_skill}' sumiu; seguindo sem skill.")
|
|
540
|
+
_limpar_skill_ativa()
|
|
541
|
+
nome_skill = None
|
|
542
|
+
sistema = SISTEMA_PADRAO
|
|
543
|
+
if skill is not None and nome_skill is not None:
|
|
544
|
+
_guardar_skill_ativa(nome_skill)
|
|
545
|
+
if nome_skill is not None:
|
|
546
|
+
print(f"Skill ativa: {nome_skill}.")
|
|
547
|
+
print(f"Modelo: {modelo}.")
|
|
548
|
+
historico: list = _msg_system(sistema)
|
|
549
|
+
while True:
|
|
550
|
+
try:
|
|
551
|
+
linha = ler("voce: ")
|
|
552
|
+
except (EOFError, KeyboardInterrupt):
|
|
553
|
+
print("\nAte logo!")
|
|
554
|
+
return 0
|
|
555
|
+
texto = linha.strip()
|
|
556
|
+
if not texto:
|
|
557
|
+
continue
|
|
558
|
+
if texto in ("/sair", "/quit", "/exit"):
|
|
559
|
+
print("Ate logo!")
|
|
560
|
+
return 0
|
|
561
|
+
if texto == "/limpar":
|
|
562
|
+
historico = _msg_system(sistema)
|
|
563
|
+
print("Contexto zerado. Pode comecar de novo.")
|
|
564
|
+
continue
|
|
565
|
+
if texto == "/skill" or texto.startswith("/skill "):
|
|
566
|
+
nome_skill, sistema, historico = _trocar_skill_repl(
|
|
567
|
+
texto, nome_skill, sistema, historico
|
|
568
|
+
)
|
|
569
|
+
continue
|
|
570
|
+
if texto == "/model" or texto.startswith("/model "):
|
|
571
|
+
partes = texto.split(None, 1)
|
|
572
|
+
if len(partes) == 1:
|
|
573
|
+
print(f"Modelo atual: {modelo}.")
|
|
574
|
+
else:
|
|
575
|
+
modelo = partes[1].strip()
|
|
576
|
+
print(f"Modelo agora: {modelo}.")
|
|
577
|
+
continue
|
|
578
|
+
historico.append({"role": "user", "content": texto})
|
|
579
|
+
try:
|
|
580
|
+
resp = _post_chat(base, chave, modelo, historico)
|
|
581
|
+
except httpx.HTTPError as exc:
|
|
582
|
+
historico.pop()
|
|
583
|
+
print(f"Nao consegui falar com o gateway ({exc.__class__.__name__}).")
|
|
584
|
+
continue
|
|
585
|
+
resposta, erro = _mostrar_resposta_chat(resp, prefixo="passe1: ")
|
|
586
|
+
if erro is not None:
|
|
587
|
+
historico.pop()
|
|
588
|
+
print(erro)
|
|
589
|
+
continue
|
|
590
|
+
historico.append({"role": "assistant", "content": resposta})
|
|
591
|
+
finally:
|
|
592
|
+
if salvar_historico is not None:
|
|
593
|
+
salvar_historico()
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def cmd_chat(args: argparse.Namespace) -> int:
|
|
597
|
+
chave = _ler_chave()
|
|
598
|
+
if chave is None:
|
|
599
|
+
return _sem_login()
|
|
600
|
+
modelo = args.model or os.environ.get("PASSE1_MODEL") or MODELO_PADRAO
|
|
601
|
+
base = _base_url(args.base_url)
|
|
602
|
+
sistema = SISTEMA_PADRAO
|
|
603
|
+
if args.skill:
|
|
604
|
+
sistema = _ler_skill(args.skill)
|
|
605
|
+
if sistema is None:
|
|
606
|
+
print(f"Skill '{args.skill}' nao encontrada. Veja com: passe1 skills.")
|
|
607
|
+
return 1
|
|
608
|
+
if args.mensagem:
|
|
609
|
+
return _uma_pergunta(base, chave, modelo, " ".join(args.mensagem), sistema)
|
|
610
|
+
if not sys.stdin.isatty():
|
|
611
|
+
texto = sys.stdin.read().strip()
|
|
612
|
+
if not texto:
|
|
613
|
+
print("Nada para enviar: passe a mensagem ou digite no terminal.")
|
|
614
|
+
return 1
|
|
615
|
+
return _uma_pergunta(base, chave, modelo, texto, sistema)
|
|
616
|
+
return _repl(base, chave, modelo, input, args.skill)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _backup(caminho: Path) -> Path:
|
|
620
|
+
copia = caminho.with_suffix(caminho.suffix + ".bak")
|
|
621
|
+
shutil.copy2(caminho, copia)
|
|
622
|
+
return copia
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
BLOCO_CODEX = """\
|
|
626
|
+
# gerenciado por "passe1 setup codex" (edite por sua conta e risco)
|
|
627
|
+
model_provider = "passe1"
|
|
628
|
+
|
|
629
|
+
[model_providers.passe1]
|
|
630
|
+
name = "Passe1"
|
|
631
|
+
base_url = "{base}"
|
|
632
|
+
env_key = "PASSE1_API_KEY"
|
|
633
|
+
"""
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _setup_codex(base: str) -> int:
|
|
637
|
+
alvo = _hoje() / ".codex" / "config.toml"
|
|
638
|
+
if alvo.exists() and "model_providers.passe1" in alvo.read_text(encoding="utf-8"):
|
|
639
|
+
print("Codex ja aponta para o Passe1. Nada a fazer.")
|
|
640
|
+
return 0
|
|
641
|
+
alvo.parent.mkdir(parents=True, exist_ok=True)
|
|
642
|
+
if alvo.exists():
|
|
643
|
+
print(f"Backup do config atual em {_backup(alvo)}.")
|
|
644
|
+
with alvo.open("a", encoding="utf-8") as fh:
|
|
645
|
+
fh.write("\n" + BLOCO_CODEX.format(base=base))
|
|
646
|
+
else:
|
|
647
|
+
alvo.write_text(BLOCO_CODEX.format(base=base), encoding="utf-8")
|
|
648
|
+
print("Codex configurado para usar o Passe1.")
|
|
649
|
+
print("Falta exportar a chave no terminal:")
|
|
650
|
+
print(" export PASSE1_API_KEY=$(cat ~/.config/passe1/key)")
|
|
651
|
+
return 0
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _setup_claude(base: str, chave: str) -> int:
|
|
655
|
+
# O Claude fala o formato Anthropic (/v1/messages), entao a base dele
|
|
656
|
+
# e a raiz do gateway, sem o sufixo /v1 do formato OpenAI.
|
|
657
|
+
base_anthropic = base[:-3] if base.endswith("/v1") else base
|
|
658
|
+
alvo = _hoje() / ".claude" / "settings.json"
|
|
659
|
+
alvo.parent.mkdir(parents=True, exist_ok=True)
|
|
660
|
+
atual: dict = {}
|
|
661
|
+
if alvo.exists():
|
|
662
|
+
print(f"Backup do config atual em {_backup(alvo)}.")
|
|
663
|
+
try:
|
|
664
|
+
atual = json.loads(alvo.read_text(encoding="utf-8"))
|
|
665
|
+
except ValueError:
|
|
666
|
+
print("O settings.json atual nao e JSON valido. Nada foi alterado,")
|
|
667
|
+
print("arrume o arquivo a mao e rode o setup de novo.")
|
|
668
|
+
return 1
|
|
669
|
+
if not isinstance(atual, dict):
|
|
670
|
+
print("O settings.json atual nao e um objeto. Nada foi alterado.")
|
|
671
|
+
return 1
|
|
672
|
+
env = atual.setdefault("env", {})
|
|
673
|
+
if not isinstance(env, dict):
|
|
674
|
+
print("O bloco 'env' do settings.json nao e um objeto. Nada foi alterado.")
|
|
675
|
+
return 1
|
|
676
|
+
env["ANTHROPIC_BASE_URL"] = base_anthropic
|
|
677
|
+
env["ANTHROPIC_AUTH_TOKEN"] = chave
|
|
678
|
+
alvo.write_text(json.dumps(atual, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
679
|
+
print("Claude configurado para usar o Passe1.")
|
|
680
|
+
return 0
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _setup_verboo(base: str, chave: str) -> int:
|
|
684
|
+
# O Verboo CLI aceita provedor OpenAI-compativel por variaveis de
|
|
685
|
+
# ambiente: geramos um snippet para importar no shell, com a chave
|
|
686
|
+
# guardada em arquivo 600 (nunca no .zshrc).
|
|
687
|
+
alvo = _hoje() / ".config" / "passe1" / "verboo.sh"
|
|
688
|
+
alvo.parent.mkdir(parents=True, exist_ok=True)
|
|
689
|
+
if alvo.exists():
|
|
690
|
+
print(f"Backup do snippet atual em {_backup(alvo)}.")
|
|
691
|
+
alvo.write_text(
|
|
692
|
+
"# gerado por 'passe1 setup verboo' (contem sua chave, nao compartilhe)\n"
|
|
693
|
+
"export CLAUDE_CODE_USE_OPENAI=1\n"
|
|
694
|
+
f"export OPENAI_API_KEY={shlex.quote(chave)}\n"
|
|
695
|
+
f"export OPENAI_BASE_URL={shlex.quote(base)}\n"
|
|
696
|
+
f"export OPENAI_MODEL={shlex.quote(MODELO_PADRAO)}\n",
|
|
697
|
+
encoding="utf-8",
|
|
698
|
+
)
|
|
699
|
+
os.chmod(alvo, 0o600)
|
|
700
|
+
print(f"Snippet escrito em {alvo}.")
|
|
701
|
+
print("Para usar nesta sessao:")
|
|
702
|
+
print(f" source {alvo}")
|
|
703
|
+
print("Para valer sempre, adicione essa linha ao ~/.zshrc.")
|
|
704
|
+
return 0
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
ALVOS_SETUP = {
|
|
708
|
+
"codex": _setup_codex,
|
|
709
|
+
"claude": _setup_claude,
|
|
710
|
+
"verboo": _setup_verboo,
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def cmd_setup(args: argparse.Namespace) -> int:
|
|
715
|
+
chave = _ler_chave()
|
|
716
|
+
if chave is None:
|
|
717
|
+
return _sem_login()
|
|
718
|
+
base = _base_url(args.base_url)
|
|
719
|
+
alvo = (args.alvo or "").lower()
|
|
720
|
+
if alvo == "codex":
|
|
721
|
+
return _setup_codex(base)
|
|
722
|
+
if alvo == "claude":
|
|
723
|
+
return _setup_claude(base, chave)
|
|
724
|
+
if alvo == "verboo":
|
|
725
|
+
return _setup_verboo(base, chave)
|
|
726
|
+
print(f"Alvo desconhecido: '{args.alvo}'. Use: claude, codex ou verboo.")
|
|
727
|
+
return 1
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def montar_parser() -> argparse.ArgumentParser:
|
|
731
|
+
parser = argparse.ArgumentParser(
|
|
732
|
+
prog="passe1", description="CLI do Passe1: seu passe de IA no terminal."
|
|
733
|
+
)
|
|
734
|
+
parser.add_argument("--versao", action="version", version=f"%(prog)s {VERSAO}")
|
|
735
|
+
parser.add_argument(
|
|
736
|
+
"--base-url",
|
|
737
|
+
default=None,
|
|
738
|
+
help=f"Base do gateway (padrao: {BASE_URL_PADRAO}).",
|
|
739
|
+
)
|
|
740
|
+
sub = parser.add_subparsers(dest="comando", required=True)
|
|
741
|
+
|
|
742
|
+
p_login = sub.add_parser("login", help="Validar a chave e guardar neste computador.")
|
|
743
|
+
p_login.add_argument("chave", nargs="?", help="Chave sk-passe1-... (ou digite a seguir).")
|
|
744
|
+
p_login.set_defaults(func=cmd_login)
|
|
745
|
+
|
|
746
|
+
p_chat = sub.add_parser("chat", help="Conversar no terminal.")
|
|
747
|
+
p_chat.add_argument("mensagem", nargs="*", help="Mensagem unica (sem ela, abre o chat).")
|
|
748
|
+
p_chat.add_argument("--model", default=None, help=f"Modelo (padrao: {MODELO_PADRAO}).")
|
|
749
|
+
p_chat.add_argument("--skill", default=None, help="Skill (SKILL.md) para guiar a conversa.")
|
|
750
|
+
p_chat.set_defaults(func=cmd_chat)
|
|
751
|
+
|
|
752
|
+
p_setup = sub.add_parser("setup", help="Apontar um harness para o Passe1.")
|
|
753
|
+
p_setup.add_argument("alvo", help="Harness a configurar: claude, codex ou verboo.")
|
|
754
|
+
p_setup.set_defaults(func=cmd_setup)
|
|
755
|
+
|
|
756
|
+
p_models = sub.add_parser("models", help="Listar os modelos disponiveis.")
|
|
757
|
+
p_models.add_argument("--json", action="store_true", help="Saida em JSON (para scripts).")
|
|
758
|
+
p_models.set_defaults(func=cmd_models)
|
|
759
|
+
|
|
760
|
+
p_skills = sub.add_parser("skills", help="Listar skills locais (SKILL.md).")
|
|
761
|
+
p_skills.set_defaults(func=cmd_skills)
|
|
762
|
+
|
|
763
|
+
p_status = sub.add_parser("status", help="Mostrar passe, validade e uso.")
|
|
764
|
+
p_status.add_argument("--json", action="store_true", help="Saida em JSON (para scripts).")
|
|
765
|
+
p_status.set_defaults(func=cmd_status)
|
|
766
|
+
|
|
767
|
+
p_logout = sub.add_parser("logout", help="Apagar a chave guardada.")
|
|
768
|
+
p_logout.set_defaults(func=cmd_logout)
|
|
769
|
+
return parser
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def main(argv: list[str] | None = None) -> int:
|
|
773
|
+
args = montar_parser().parse_args(argv)
|
|
774
|
+
return int(args.func(args))
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
if __name__ == "__main__":
|
|
778
|
+
raise SystemExit(main())
|