wuptracker 0.1.0__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,561 @@
1
+ """`wuptracker config` — inspeciona e altera configurações e integrações."""
2
+
3
+ import os
4
+ import shutil
5
+
6
+ from . import common
7
+ from . import config
8
+ from . import paths
9
+ from . import profiles
10
+
11
+ ENV_PATH = paths.ENV_FILE
12
+
13
+ # chave -> (tipo, opções|None, ajuda)
14
+ SETTABLE = {
15
+ "BACKEND": ("choice",
16
+ ["claude_cli", "anthropic", "ollama", "openai", "gemini"],
17
+ "motor do LLM"),
18
+ "CLAUDE_MODEL": ("str", None, "modelo do backend 'anthropic'"),
19
+ "CLAUDE_CLI_MODEL": ("str_or_none", None,
20
+ "modelo p/ 'claude_cli' (vazio = padrão do CLI)"),
21
+ "OLLAMA_HOST": ("str", None, "URL do Ollama (backend 'ollama')"),
22
+ "OLLAMA_MODEL": ("str", None, "modelo com visão no Ollama (llava, llama3.2-vision…)"),
23
+ "OPENAI_BASE_URL": ("str", None, "endpoint compatível OpenAI (backend 'openai')"),
24
+ "OPENAI_MODEL": ("str", None, "modelo do backend 'openai'"),
25
+ "GEMINI_MODEL": ("str", None, "modelo do backend 'gemini' (gemini-2.0-flash…)"),
26
+ "DEFAULT_PROFILE": ("domain", None, "perfil padrão: thm | generico"),
27
+ "DEFAULT_STYLE": ("style", None,
28
+ "estilo(s) padrão do writeup, vírgula p/ vários"),
29
+ "CAPTURE_ACTIVE_MONITOR_ONLY": ("bool", None,
30
+ "capturar só o monitor sob o cursor"),
31
+ "CROP_IMAGES": ("bool", None, "recortar imagens para a região relevante"),
32
+ "CROP_PADDING": ("float", None, "margem do recorte (fração, ex: 0.03)"),
33
+ "EMBED_IMAGES": ("bool", None, "embutir imagens no writeup"),
34
+ "WEBP_IMAGES": ("bool", None, "converter imagens do writeup para WebP"),
35
+ "WEBP_QUALITY": ("int", None, "qualidade do WebP (0-100)"),
36
+ "IMAGE_MAX_WIDTH": ("int", None, "largura máx. das imagens do writeup (0 = livre)"),
37
+ "SCREENSHOT_QUALITY": ("int", None, "qualidade JPEG no envio à API (1-100)"),
38
+ "PLAY_SOUND": ("bool", None, "bip ao capturar"),
39
+ "SHOW_NOTIFICATION": ("bool", None, "notificação de desktop ao capturar"),
40
+ "OPEN_AFTER_GENERATE": ("bool", None, "abrir o writeup no editor após gerar"),
41
+ "SESSIONS_DIR": ("str", None, "pasta das sessões"),
42
+ "WRITEUPS_DIR": ("str", None, "pasta dos writeups"),
43
+ }
44
+
45
+ ALIASES = {
46
+ "backend": "BACKEND", "model": "CLAUDE_MODEL", "cli-model": "CLAUDE_CLI_MODEL",
47
+ "ollama-host": "OLLAMA_HOST", "ollama-model": "OLLAMA_MODEL",
48
+ "openai-url": "OPENAI_BASE_URL", "openai-model": "OPENAI_MODEL",
49
+ "gemini-model": "GEMINI_MODEL",
50
+ "profile": "DEFAULT_PROFILE", "style": "DEFAULT_STYLE",
51
+ "monitor": "CAPTURE_ACTIVE_MONITOR_ONLY", "crop": "CROP_IMAGES",
52
+ "images": "EMBED_IMAGES", "sound": "PLAY_SOUND", "notify": "SHOW_NOTIFICATION",
53
+ "open": "OPEN_AFTER_GENERATE", "webp": "WEBP_IMAGES",
54
+ "webp-quality": "WEBP_QUALITY", "max-width": "IMAGE_MAX_WIDTH",
55
+ }
56
+
57
+
58
+ def _resolve_key(key):
59
+ k = ALIASES.get(key.lower(), key.upper())
60
+ if k not in SETTABLE:
61
+ raise SystemExit(
62
+ f"[!] Chave desconhecida: {key!r}. Veja 'wuptracker config'.\n"
63
+ f" Disponíveis: {', '.join(sorted(SETTABLE))}")
64
+ return k
65
+
66
+
67
+ def _coerce(key, raw):
68
+ kind, opts, _ = SETTABLE[key]
69
+ if kind == "bool":
70
+ if raw.lower() in ("1", "true", "on", "yes", "sim", "y"):
71
+ return True
72
+ if raw.lower() in ("0", "false", "off", "no", "nao", "não", "n"):
73
+ return False
74
+ raise SystemExit(f"[!] {key} espera on/off (recebido {raw!r})")
75
+ if kind == "int":
76
+ return int(raw)
77
+ if kind == "float":
78
+ return float(raw)
79
+ if kind == "str_or_none":
80
+ return None if raw.strip().lower() in ("", "none", "-") else raw
81
+ if kind == "choice":
82
+ if raw not in opts:
83
+ raise SystemExit(f"[!] {key} deve ser um de: {', '.join(opts)}")
84
+ return raw
85
+ if kind == "domain":
86
+ return profiles.resolve_domain(raw)
87
+ if kind == "style":
88
+ return ",".join(profiles.resolve_style(s) for s in raw.split(","))
89
+ return raw
90
+
91
+
92
+ def _fmt(v):
93
+ if isinstance(v, bool):
94
+ return "on" if v else "off"
95
+ if v is None:
96
+ return "—"
97
+ return str(v)
98
+
99
+
100
+ # --------------------------------------------------------------------------
101
+ # .env / API keys
102
+ # --------------------------------------------------------------------------
103
+ BACKEND_ENV_KEY = {"anthropic": "ANTHROPIC_API_KEY", "openai": "OPENAI_API_KEY",
104
+ "gemini": "GEMINI_API_KEY"}
105
+
106
+
107
+ def read_env_key(name):
108
+ try:
109
+ with open(ENV_PATH, encoding="utf-8") as f:
110
+ for line in f:
111
+ if line.strip().startswith(f"{name}="):
112
+ return line.split("=", 1)[1].strip()
113
+ except OSError:
114
+ pass
115
+ return os.environ.get(name, "")
116
+
117
+
118
+ def set_env_key(name, value):
119
+ lines, found = [], False
120
+ try:
121
+ with open(ENV_PATH, encoding="utf-8") as f:
122
+ lines = f.read().splitlines()
123
+ except OSError:
124
+ pass
125
+ for i, line in enumerate(lines):
126
+ if line.strip().startswith(f"{name}="):
127
+ lines[i] = f"{name}={value}"
128
+ found = True
129
+ break
130
+ if not found:
131
+ lines.append(f"{name}={value}")
132
+ paths.ensure_dirs()
133
+ with open(ENV_PATH, "w", encoding="utf-8") as f:
134
+ f.write("\n".join(lines) + "\n")
135
+ os.chmod(ENV_PATH, 0o600)
136
+ print(common.c_green(f"✓ {name} gravada em {ENV_PATH} (chmod 600)"))
137
+
138
+
139
+ def set_api_key(value, name=None):
140
+ """`config api-key <valor>` — grava a chave do backend ativo (ou de `name`)."""
141
+ if name is None:
142
+ from . import llm
143
+
144
+ b = llm.resolve_backend(config.BACKEND)
145
+ name = BACKEND_ENV_KEY.get(b)
146
+ if not name:
147
+ raise SystemExit(
148
+ f"[!] o backend '{b}' não usa API key. "
149
+ "Troque para 'anthropic' ou 'openai' primeiro, ou use "
150
+ "'wuptracker config set <NOME_API_KEY> <valor>'.")
151
+ set_env_key(name, value)
152
+
153
+
154
+ # --------------------------------------------------------------------------
155
+ # ações
156
+ # --------------------------------------------------------------------------
157
+ def _yes(b):
158
+ return common.c_green("ok") if b else common.c_red("faltando")
159
+
160
+
161
+ def show():
162
+ from . import llm
163
+
164
+ local = config.load_local()
165
+ b = llm.resolve_backend(config.BACKEND)
166
+
167
+ print(common.c_cyan("Integrações"))
168
+ if b == "claude_cli":
169
+ print(f" LLM claude_cli · comando `claude`: "
170
+ f"{_yes(bool(shutil.which('claude')))} (grátis, usa sua assinatura)")
171
+ elif b == "anthropic":
172
+ k = read_env_key("ANTHROPIC_API_KEY")
173
+ print(f" LLM anthropic · {config.CLAUDE_MODEL} · "
174
+ f"ANTHROPIC_API_KEY: {_yes(bool(k))}")
175
+ elif b == "ollama":
176
+ host = config.OLLAMA_HOST
177
+ up = False
178
+ try:
179
+ import urllib.request
180
+ urllib.request.urlopen(f"{host.rstrip('/')}/api/tags", timeout=3)
181
+ up = True
182
+ except Exception:
183
+ up = False
184
+ print(f" LLM ollama · {config.OLLAMA_MODEL} · {host}: "
185
+ f"{_yes(up)} (local, offline, grátis)")
186
+ elif b == "openai":
187
+ base = config.OPENAI_BASE_URL
188
+ local_srv = "localhost" in base or "127.0.0.1" in base
189
+ k = read_env_key("OPENAI_API_KEY")
190
+ print(f" LLM openai · {config.OPENAI_MODEL} · {base} · "
191
+ f"OPENAI_API_KEY: {_yes(bool(k) or local_srv)}")
192
+ elif b == "gemini":
193
+ k = read_env_key("GEMINI_API_KEY") or read_env_key("GOOGLE_API_KEY")
194
+ print(f" LLM gemini · {config.GEMINI_MODEL} · "
195
+ f"GEMINI_API_KEY: {_yes(bool(k))} (tier grátis no Google AI Studio)")
196
+
197
+ cap = common.check_capture_backend() or common.c_red("nenhum!")
198
+ print(f" Captura {cap} · monitor ativo: "
199
+ f"{_fmt(config.CAPTURE_ACTIVE_MONITOR_ONLY)}")
200
+ ns = "notify-send" if shutil.which("notify-send") else "plyer (fallback)"
201
+ print(f" Notificação {ns} · som: {_fmt(config.PLAY_SOUND)} · "
202
+ f"desktop: {_fmt(config.SHOW_NOTIFICATION)}")
203
+
204
+ print(common.c_cyan("\nConfiguração") +
205
+ common.c_dim(" (local sobrepõe o padrão)"))
206
+ for k in SETTABLE:
207
+ cur = getattr(config, k, None)
208
+ src = common.c_yellow("local") if k in local else common.c_dim("padrão")
209
+ line = f" {k.lower().replace('_', '-'):<28} {_fmt(cur):<14} {src}"
210
+ if k in local and config.default_of(k) is not None:
211
+ line += common.c_dim(f" (padrão: {_fmt(config.default_of(k))})")
212
+ print(line)
213
+ print(common.c_dim("\n wuptracker config set <chave> <valor> · "
214
+ "config unset <chave> · config api-key <chave>"))
215
+ print(common.c_dim(" wuptracker config helper → assistente interativo "
216
+ "(passo a passo, com explicações)"))
217
+
218
+
219
+ def set_(key, value):
220
+ kl = key.lower().replace("-", "_")
221
+ if kl in ("api_key", "apikey"):
222
+ set_api_key(value)
223
+ return
224
+ if kl.endswith("_api_key"): # ex: OPENAI_API_KEY
225
+ set_env_key(key.upper(), value)
226
+ return
227
+
228
+ k = _resolve_key(key)
229
+ val = _coerce(k, value)
230
+ config.set_local(k, val)
231
+ print(common.c_green(f"✓ {k} = {_fmt(val)}") +
232
+ common.c_dim(f" → {config.CONFIG_LOCAL_PATH}"))
233
+
234
+ if k == "BACKEND":
235
+ from . import llm
236
+
237
+ b = llm.resolve_backend(val)
238
+ if b == "claude_cli" and not shutil.which("claude"):
239
+ print(common.c_yellow(" ⚠ comando `claude` não encontrado no PATH"))
240
+ if b == "anthropic" and not read_env_key("ANTHROPIC_API_KEY"):
241
+ print(common.c_yellow(" ⚠ falta a chave: wuptracker config api-key <chave>"))
242
+ if b == "openai" and not read_env_key("OPENAI_API_KEY") \
243
+ and "localhost" not in config.OPENAI_BASE_URL:
244
+ print(common.c_yellow(" ⚠ falta a chave: wuptracker config api-key <chave> "
245
+ "(ou aponte openai-url para um servidor local)"))
246
+ if b == "ollama":
247
+ print(common.c_dim(" → 'wuptracker config set ollama-model <modelo-com-visão>' "
248
+ "· 'ollama pull <modelo>'"))
249
+ if b == "gemini" and not (read_env_key("GEMINI_API_KEY")
250
+ or read_env_key("GOOGLE_API_KEY")):
251
+ print(common.c_yellow(" ⚠ falta a chave (grátis em "
252
+ "aistudio.google.com/apikey): "
253
+ "wuptracker config api-key <chave>"))
254
+
255
+
256
+ def unset_(key):
257
+ k = _resolve_key(key)
258
+ config.unset_local(k)
259
+ print(common.c_green(f"✓ {k} voltou ao padrão ({_fmt(config.default_of(k))})"))
260
+
261
+
262
+ # --------------------------------------------------------------------------
263
+ # wizard interativo — `wuptracker config helper`
264
+ # --------------------------------------------------------------------------
265
+ BACKEND_MORE = """
266
+ claude_cli Usa o CLI do Claude Code (comando `claude`). Consome a cota da sua
267
+ assinatura Pro/Max — zero custo por token, zero API key. As imagens
268
+ sobem para a Anthropic. É o padrão, e o mais simples de começar.
269
+
270
+ gemini API do Google Gemini. Tem um tier gratuito generoso, sem cartão —
271
+ pegue a chave em https://aistudio.google.com/apikey. Boa opção grátis
272
+ se você não usa Claude Code. As imagens sobem para o Google.
273
+
274
+ ollama Roda um modelo NA SUA máquina via Ollama. Nada sai do computador —
275
+ ideal para engajamentos onde as telas são confidenciais. Precisa de
276
+ um modelo com visão baixado (ex.: `ollama pull llama3.2-vision`).
277
+ Mais lento e um pouco menos preciso que os modelos de nuvem.
278
+
279
+ openai Qualquer endpoint no formato da API OpenAI: OpenAI, Groq (rápido,
280
+ tem tier grátis), OpenRouter (vários modelos), LM Studio / llama.cpp
281
+ (local). Você escolhe a URL, o modelo e a chave.
282
+
283
+ anthropic API paga da Anthropic, cobrada por token — use se quiser os modelos
284
+ Claude sem depender do CLI/assinatura.
285
+ """
286
+
287
+ PROFILE_MORE = """
288
+ thm Para pentest e CTF (TryHackMe, HTB...). Seções clássicas:
289
+ Reconhecimento, Enumeração, Acesso Inicial, Pós-Exploração,
290
+ Escalação de Privilégio, Flags, Lições Aprendidas. A análise de cada
291
+ tela foca em portas, serviços, comandos, credenciais e flags.
292
+
293
+ generico Para qualquer outra coisa: dev, debugging, pesquisa, configuração,
294
+ estudo. O writeup é um registro cronológico: Resumo, Linha do Tempo,
295
+ Descobertas, Problemas Encontrados, Estado Final. Não força jargão
296
+ de pentest onde não existe.
297
+
298
+ Dá pra trocar por sessão: wuptracker capture "Nome" --profile generico
299
+ """
300
+
301
+ STYLE_MORE = """
302
+ tecnico O mais completo: Markdown com frontmatter, seções fixas do perfil e
303
+ âncoras de horário [HH:MM:SS]. Bloco de código pra todo comando.
304
+ O formato "documentação". Embute as imagens.
305
+
306
+ corrido A mesma informação, em prosa — narrativa fluida, sem tabelas nem
307
+ lista de fases. Bom pra publicar como relato. Embute as imagens.
308
+
309
+ resumo Curto (~250 palavras): 1 parágrafo de contexto + bullets do que foi
310
+ feito/descoberto + conclusão. Sem imagens. Pra colar num chat/e-mail.
311
+
312
+ blog Artigo didático estilo dev.to/Medium: introdução que situa o leitor,
313
+ seções temáticas, código comentado, "o que aprendi". Embute imagens.
314
+
315
+ linkedin Post pronto pra publicar: 1ª pessoa, gancho na 1ª linha, bullets de
316
+ aprendizado, hashtags. Texto puro (sem markdown) e GENERALIZA dados
317
+ sensíveis (IPs, flags, nomes de cliente). Sem imagens.
318
+
319
+ Na geração dá pra pedir vários de uma vez:
320
+ wuptracker generate 1 --style tecnico,linkedin
321
+ """
322
+
323
+
324
+ def _hr():
325
+ print(common.c_dim("─" * 62))
326
+
327
+
328
+ def _ask(question, default=""):
329
+ hint = f" [{default}]" if default else ""
330
+ try:
331
+ r = input(common.c_cyan(f"› {question}{hint} ")).strip()
332
+ except EOFError:
333
+ r = ""
334
+ return r or default
335
+
336
+
337
+ def _ask_yesno(question, default=True):
338
+ hint = "S/n" if default else "s/N"
339
+ r = _ask(f"{question} ({hint})").strip().lower()
340
+ if not r:
341
+ return default
342
+ return r in ("s", "sim", "y", "yes", "1", "on")
343
+
344
+
345
+ def _ask_choice(question, options, more_text=None):
346
+ """options: [(valor, descrição curta), ...]. '?' mostra more_text."""
347
+ while True:
348
+ print(common.c_cyan(f"\n{question}"))
349
+ for i, (val, desc) in enumerate(options, 1):
350
+ print(f" {i}) {common.c_green(val)} — {desc}")
351
+ if more_text:
352
+ print(f" {common.c_dim('?) saiba mais sobre cada opção')}")
353
+ r = _ask("escolha", "1")
354
+ if r == "?" and more_text:
355
+ _hr()
356
+ print(more_text.strip("\n"))
357
+ _hr()
358
+ continue
359
+ if r.isdigit() and 1 <= int(r) <= len(options):
360
+ return options[int(r) - 1][0]
361
+ for val, _ in options:
362
+ if r.lower() == val.lower():
363
+ return val
364
+ print(common.c_red(" opção inválida, tente de novo"))
365
+
366
+
367
+ def _ask_model(question, presets, current, note=None):
368
+ """Menu de modelos sugeridos + opção de digitar outro nome.
369
+
370
+ presets: lista de strings. `current` (se não estiver em presets) aparece
371
+ marcado como o valor em uso. Retorna a string escolhida/digitada."""
372
+ options = list(presets)
373
+ if current and current not in options:
374
+ options = [current] + options
375
+ print(common.c_cyan(f"\n{question}"))
376
+ if note:
377
+ print(common.c_dim(f" {note}"))
378
+ for i, m in enumerate(options, 1):
379
+ tag = common.c_dim(" (em uso)") if m == current else ""
380
+ print(f" {i}) {m}{tag}")
381
+ other_idx = len(options) + 1
382
+ print(f" {other_idx}) outro… (digitar o nome do modelo)")
383
+
384
+ default_idx = str(options.index(current) + 1) if current in options else "1"
385
+ while True:
386
+ r = _ask("escolha", default_idx)
387
+ if r.isdigit():
388
+ n = int(r)
389
+ if 1 <= n <= len(options):
390
+ return options[n - 1]
391
+ if n == other_idx:
392
+ return _ask("Nome do modelo", current or "")
393
+ elif r: # também aceita digitar o nome direto
394
+ return r
395
+ print(common.c_red(" opção inválida, tente de novo"))
396
+
397
+
398
+ def _set_if_changed(key, value):
399
+ if value == config.default_of(key):
400
+ config.unset_local(key)
401
+ else:
402
+ config.set_local(key, value)
403
+
404
+
405
+ def wizard():
406
+ """`wuptracker config helper` — assistente interativo de configuração."""
407
+ print(common.c_cyan("╔══════════════════════════════════════════════╗"))
408
+ print(common.c_cyan("║ wuptracker — assistente de setup ║"))
409
+ print(common.c_cyan("╚══════════════════════════════════════════════╝"))
410
+ print("Enter aceita o valor entre [colchetes]. Digite " +
411
+ common.c_dim("?") + " numa pergunta de múltipla escolha para saber mais.")
412
+
413
+ cur = config
414
+
415
+ # 1. Backend de IA -----------------------------------------------------
416
+ backend = _ask_choice(
417
+ "Qual IA vai analisar as telas e escrever os writeups?",
418
+ [("claude_cli", "grátis, usa sua assinatura do Claude Code"),
419
+ ("gemini", "Google, tier gratuito generoso (precisa de key grátis)"),
420
+ ("ollama", "100% local e offline — nada sai da sua máquina"),
421
+ ("openai", "OpenAI / Groq / OpenRouter / LM Studio…"),
422
+ ("anthropic", "API paga da Anthropic")],
423
+ BACKEND_MORE)
424
+ _set_if_changed("BACKEND", backend)
425
+
426
+ if backend == "claude_cli":
427
+ if shutil.which("claude"):
428
+ print(common.c_green(" ✓ comando 'claude' encontrado no PATH"))
429
+ else:
430
+ print(common.c_yellow(" ⚠ 'claude' não encontrado — instale o Claude Code"))
431
+ model = _ask_model(
432
+ "Modelo do CLI",
433
+ ["padrão do CLI", "opus", "sonnet", "haiku"],
434
+ cur.CLAUDE_CLI_MODEL or "padrão do CLI")
435
+ _set_if_changed("CLAUDE_CLI_MODEL",
436
+ None if model == "padrão do CLI" else model)
437
+
438
+ elif backend == "gemini":
439
+ print(common.c_dim(" Chave grátis em https://aistudio.google.com/apikey"))
440
+ k = _ask("GEMINI_API_KEY (Enter para configurar depois)")
441
+ if k:
442
+ set_env_key("GEMINI_API_KEY", k)
443
+ model = _ask_model(
444
+ "Modelo Gemini",
445
+ ["gemini-2.0-flash", "gemini-2.5-flash", "gemini-2.5-pro"],
446
+ cur.GEMINI_MODEL)
447
+ _set_if_changed("GEMINI_MODEL", model)
448
+
449
+ elif backend == "ollama":
450
+ host = _ask("URL do Ollama", cur.OLLAMA_HOST)
451
+ _set_if_changed("OLLAMA_HOST", host)
452
+ model = _ask_model(
453
+ "Modelo (precisa ter visão)",
454
+ ["llama3.2-vision", "llava", "qwen2.5vl", "minicpm-v", "moondream"],
455
+ cur.OLLAMA_MODEL,
456
+ note="se ainda não tiver: ollama pull <modelo>")
457
+ _set_if_changed("OLLAMA_MODEL", model)
458
+ try:
459
+ import json as _j
460
+ import urllib.request
461
+
462
+ with urllib.request.urlopen(f"{host.rstrip('/')}/api/tags",
463
+ timeout=3) as r:
464
+ names = {m2.get("name", "").split(":")[0]
465
+ for m2 in _j.loads(r.read()).get("models", [])}
466
+ if model.split(":")[0] in names:
467
+ print(common.c_green(f" ✓ Ollama respondeu e '{model}' está baixado"))
468
+ else:
469
+ print(common.c_yellow(f" ⚠ Ollama respondeu, mas '{model}' não está "
470
+ f"baixado — rode: ollama pull {model}"))
471
+ except Exception:
472
+ print(common.c_yellow(f" ⚠ Ollama não respondeu em {host} — "
473
+ "rode 'ollama serve' antes de usar"))
474
+
475
+ elif backend == "openai":
476
+ provider = _ask_choice(
477
+ "Qual endpoint compatível com a API OpenAI?",
478
+ [("openai", "api.openai.com — modelos GPT oficiais"),
479
+ ("groq", "api.groq.com — bem rápido, tem tier grátis"),
480
+ ("openrouter", "openrouter.ai — vários modelos, um endpoint só"),
481
+ ("local", "LM Studio / llama.cpp / vLLM na sua máquina"),
482
+ ("outro", "digitar a URL manualmente")])
483
+ presets = {
484
+ "openai": ("https://api.openai.com/v1", ["gpt-4o-mini", "gpt-4o"]),
485
+ "groq": ("https://api.groq.com/openai/v1",
486
+ ["llama-3.2-90b-vision-preview",
487
+ "llama-3.2-11b-vision-preview"]),
488
+ "openrouter": ("https://openrouter.ai/api/v1",
489
+ ["google/gemini-2.0-flash-001", "openai/gpt-4o-mini"]),
490
+ "local": ("http://localhost:1234/v1", ["local-model"]),
491
+ }
492
+ if provider == "outro":
493
+ url = _ask("URL do endpoint (OPENAI_BASE_URL)", cur.OPENAI_BASE_URL)
494
+ model_presets = [cur.OPENAI_MODEL] if cur.OPENAI_MODEL else []
495
+ else:
496
+ url, model_presets = presets[provider]
497
+ _set_if_changed("OPENAI_BASE_URL", url)
498
+ model = _ask_model("Modelo", model_presets, cur.OPENAI_MODEL)
499
+ _set_if_changed("OPENAI_MODEL", model)
500
+
501
+ local_srv = "localhost" in url or "127.0.0.1" in url
502
+ prompt = "OPENAI_API_KEY" + (" (Enter para pular — endpoint local)"
503
+ if local_srv else " (Enter p/ configurar depois)")
504
+ k = _ask(prompt)
505
+ if k:
506
+ set_env_key("OPENAI_API_KEY", k)
507
+
508
+ elif backend == "anthropic":
509
+ k = _ask("ANTHROPIC_API_KEY (Enter para configurar depois)")
510
+ if k:
511
+ set_env_key("ANTHROPIC_API_KEY", k)
512
+ model = _ask_model(
513
+ "Modelo",
514
+ ["claude-sonnet-5", "claude-opus-5", "claude-haiku-4-5-20251001"],
515
+ cur.CLAUDE_MODEL)
516
+ _set_if_changed("CLAUDE_MODEL", model)
517
+
518
+ # 2. Perfil --------------------------------------------------------------
519
+ profile = _ask_choice(
520
+ "Perfil padrão dos writeups (dá pra trocar por sessão)?",
521
+ [("thm", "pentest / CTF — fases, flags, escalação de privilégio"),
522
+ ("generico", "qualquer trabalho no PC — linha do tempo")],
523
+ PROFILE_MORE)
524
+ _set_if_changed("DEFAULT_PROFILE", profile)
525
+
526
+ # 3. Estilo ----------------------------------------------------------------
527
+ style = _ask_choice(
528
+ "Estilo padrão do writeup (dá pra pedir vários na hora de gerar)?",
529
+ [("tecnico", "denso, seções fixas, com horários — o mais completo"),
530
+ ("corrido", "narrativa em texto corrido"),
531
+ ("resumo", "resumo executivo curto, sem imagens"),
532
+ ("blog", "artigo didático estilo dev.to/Medium"),
533
+ ("linkedin", "post pronto pro LinkedIn, texto puro")],
534
+ STYLE_MORE)
535
+ _set_if_changed("DEFAULT_STYLE", style)
536
+
537
+ # 4. Preferências rápidas --------------------------------------------------
538
+ print(common.c_cyan("\nUns últimos ajustes rápidos:"))
539
+ v = _ask_yesno(" Capturar só o monitor sob o cursor? (você tem +1 monitor?)",
540
+ cur.CAPTURE_ACTIVE_MONITOR_ONLY)
541
+ _set_if_changed("CAPTURE_ACTIVE_MONITOR_ONLY", v)
542
+
543
+ v = _ask_yesno(" Otimizar as imagens do writeup pra WebP (bem mais leve)?",
544
+ cur.WEBP_IMAGES)
545
+ _set_if_changed("WEBP_IMAGES", v)
546
+
547
+ v = _ask_yesno(" Notificação de desktop a cada captura (F9)?",
548
+ cur.SHOW_NOTIFICATION)
549
+ _set_if_changed("SHOW_NOTIFICATION", v)
550
+
551
+ # 5. Resultado -------------------------------------------------------------
552
+ print(common.c_green("\n✓ Configuração salva.\n"))
553
+ show()
554
+ try:
555
+ from . import llm
556
+
557
+ llm.preflight()
558
+ print(common.c_green("\n✓ Tudo pronto — rode 'wuptracker capture \"Nome\"' "
559
+ "pra começar."))
560
+ except SystemExit as e:
561
+ print(common.c_yellow(f"\n{e}"))