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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ passe1 = passe1:main
@@ -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())