tweek 0.4.1__py3-none-any.whl → 0.4.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.
Files changed (37) hide show
  1. tweek/__init__.py +1 -1
  2. tweek/cli_core.py +23 -6
  3. tweek/cli_install.py +439 -105
  4. tweek/cli_uninstall.py +119 -36
  5. tweek/config/families.yaml +13 -0
  6. tweek/config/models.py +31 -3
  7. tweek/config/patterns.yaml +126 -2
  8. tweek/diagnostics.py +124 -1
  9. tweek/hooks/break_glass.py +70 -47
  10. tweek/hooks/overrides.py +19 -1
  11. tweek/hooks/post_tool_use.py +6 -2
  12. tweek/hooks/pre_tool_use.py +19 -2
  13. tweek/hooks/wrapper_post_tool_use.py +121 -0
  14. tweek/hooks/wrapper_pre_tool_use.py +121 -0
  15. tweek/integrations/openclaw.py +70 -60
  16. tweek/integrations/openclaw_detection.py +140 -0
  17. tweek/integrations/openclaw_server.py +359 -86
  18. tweek/logging/security_log.py +22 -0
  19. tweek/memory/safety.py +7 -3
  20. tweek/memory/store.py +31 -10
  21. tweek/plugins/base.py +9 -1
  22. tweek/plugins/detectors/openclaw.py +31 -92
  23. tweek/plugins/screening/heuristic_scorer.py +12 -1
  24. tweek/plugins/screening/local_model_reviewer.py +9 -0
  25. tweek/security/language.py +2 -1
  26. tweek/security/llm_reviewer.py +45 -18
  27. tweek/security/local_model.py +21 -0
  28. tweek/security/model_registry.py +2 -2
  29. tweek/security/rate_limiter.py +99 -1
  30. tweek/skills/guard.py +30 -7
  31. {tweek-0.4.1.dist-info → tweek-0.4.3.dist-info}/METADATA +1 -1
  32. {tweek-0.4.1.dist-info → tweek-0.4.3.dist-info}/RECORD +37 -34
  33. {tweek-0.4.1.dist-info → tweek-0.4.3.dist-info}/WHEEL +0 -0
  34. {tweek-0.4.1.dist-info → tweek-0.4.3.dist-info}/entry_points.txt +0 -0
  35. {tweek-0.4.1.dist-info → tweek-0.4.3.dist-info}/licenses/LICENSE +0 -0
  36. {tweek-0.4.1.dist-info → tweek-0.4.3.dist-info}/licenses/NOTICE +0 -0
  37. {tweek-0.4.1.dist-info → tweek-0.4.3.dist-info}/top_level.txt +0 -0
tweek/cli_install.py CHANGED
@@ -36,6 +36,112 @@ from tweek.cli_helpers import (
36
36
  )
37
37
 
38
38
 
39
+ # ---------------------------------------------------------------------------
40
+ # Installed scope tracking
41
+ # ---------------------------------------------------------------------------
42
+
43
+ _INSTALLED_SCOPES_FILE = Path("~/.tweek/installed_scopes.json").expanduser()
44
+
45
+
46
+ def _record_installed_scope(target: Path) -> None:
47
+ """Record that hooks were installed at *target* (.claude/ directory).
48
+
49
+ Stored in ~/.tweek/installed_scopes.json so ``tweek uninstall --all``
50
+ can find and clean project-level hooks regardless of the user's cwd.
51
+ """
52
+ target_str = str(target.resolve())
53
+
54
+ existing: list = []
55
+ if _INSTALLED_SCOPES_FILE.exists():
56
+ try:
57
+ existing = json.loads(_INSTALLED_SCOPES_FILE.read_text()) or []
58
+ except (json.JSONDecodeError, IOError):
59
+ existing = []
60
+
61
+ if target_str not in existing:
62
+ existing.append(target_str)
63
+
64
+ _INSTALLED_SCOPES_FILE.parent.mkdir(parents=True, exist_ok=True)
65
+ _INSTALLED_SCOPES_FILE.write_text(json.dumps(existing, indent=2))
66
+
67
+
68
+ def _get_installed_scopes() -> list:
69
+ """Return all recorded .claude/ directories where hooks were installed."""
70
+ if not _INSTALLED_SCOPES_FILE.exists():
71
+ return []
72
+ try:
73
+ return json.loads(_INSTALLED_SCOPES_FILE.read_text()) or []
74
+ except (json.JSONDecodeError, IOError):
75
+ return []
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Hook wrapper deployment
80
+ # ---------------------------------------------------------------------------
81
+
82
+ _TWEEK_HOOKS_DIR = Path("~/.tweek/hooks").expanduser()
83
+
84
+
85
+ def _deploy_hook_wrappers() -> Path:
86
+ """Deploy self-healing hook wrappers to ~/.tweek/hooks/.
87
+
88
+ These wrappers are standalone Python scripts that delegate to the real
89
+ hook implementations in the tweek package. If the tweek package has been
90
+ removed (e.g. via ``pip uninstall``), the wrappers silently remove
91
+ themselves from settings.json and allow the tool call.
92
+
93
+ Returns:
94
+ Path to the hooks directory (~/.tweek/hooks/).
95
+ """
96
+ _TWEEK_HOOKS_DIR.mkdir(parents=True, exist_ok=True)
97
+
98
+ # Source wrappers live alongside the real hooks in the package
99
+ wrapper_src_dir = Path(__file__).resolve().parent / "hooks"
100
+
101
+ for wrapper_name in ("wrapper_pre_tool_use.py", "wrapper_post_tool_use.py"):
102
+ src = wrapper_src_dir / wrapper_name
103
+ # Deploy as the canonical name (without "wrapper_" prefix)
104
+ dest_name = wrapper_name.replace("wrapper_", "")
105
+ dest = _TWEEK_HOOKS_DIR / dest_name
106
+
107
+ if not src.exists():
108
+ raise FileNotFoundError(
109
+ f"Hook wrapper template not found: {src}\n"
110
+ f"Re-install tweek to restore missing files."
111
+ )
112
+
113
+ shutil.copy2(src, dest)
114
+
115
+ return _TWEEK_HOOKS_DIR
116
+
117
+
118
+ def _deploy_uninstall_script() -> Path:
119
+ """Deploy the standalone uninstall.sh to ~/.tweek/.
120
+
121
+ This shell script can clean up all Tweek state (hooks, skills, config,
122
+ .tweek.yaml files, MCP integrations, and the pip package) even when
123
+ the tweek Python package has already been removed.
124
+
125
+ Returns:
126
+ Path to the deployed uninstall.sh.
127
+ """
128
+ tweek_dir = Path("~/.tweek").expanduser()
129
+ tweek_dir.mkdir(parents=True, exist_ok=True)
130
+
131
+ src = Path(__file__).resolve().parent / "scripts" / "uninstall.sh"
132
+ dest = tweek_dir / "uninstall.sh"
133
+
134
+ if src.exists():
135
+ shutil.copy2(src, dest)
136
+ # Ensure executable
137
+ dest.chmod(dest.stat().st_mode | 0o111)
138
+ else:
139
+ # If source not found, skip gracefully
140
+ pass
141
+
142
+ return dest
143
+
144
+
39
145
  # ---------------------------------------------------------------------------
40
146
  # Utility functions for .env scanning
41
147
  # ---------------------------------------------------------------------------
@@ -125,11 +231,77 @@ def parse_env_keys(env_path: Path) -> List[str]:
125
231
  # ---------------------------------------------------------------------------
126
232
 
127
233
 
234
+ def _ensure_local_model_deps() -> bool:
235
+ """Install onnxruntime, tokenizers, numpy if missing.
236
+
237
+ Tries pip first, then uv pip (for uv-managed venvs that lack pip).
238
+
239
+ Returns True if deps are available after this call.
240
+ """
241
+ try:
242
+ import onnxruntime # noqa: F401
243
+ import tokenizers # noqa: F401
244
+ import numpy # noqa: F401
245
+ return True
246
+ except ImportError:
247
+ pass
248
+
249
+ import subprocess
250
+ import shutil
251
+
252
+ deps = ["onnxruntime>=1.16.0", "tokenizers>=0.15.0", "numpy>=1.24.0"]
253
+ console.print("\n[bold cyan]Installing classifier dependencies[/bold cyan]")
254
+ console.print(" [white]onnxruntime, tokenizers, numpy[/white]")
255
+ console.print()
256
+
257
+ # Method 1: Try pip (works for pip/pipx installs)
258
+ try:
259
+ result = subprocess.run(
260
+ [sys.executable, "-m", "pip", "install", *deps],
261
+ capture_output=True,
262
+ text=True,
263
+ timeout=300,
264
+ )
265
+ if result.returncode == 0:
266
+ console.print("[green]\u2713[/green] Classifier dependencies installed")
267
+ return True
268
+ except Exception:
269
+ pass
270
+
271
+ # Method 2: Try uv pip (works for uv tool installs where pip is absent)
272
+ uv_cmd = shutil.which("uv")
273
+ if uv_cmd:
274
+ console.print(" [white]pip not available in this environment, trying uv...[/white]")
275
+ try:
276
+ result = subprocess.run(
277
+ [uv_cmd, "pip", "install", "--python", sys.executable, *deps],
278
+ capture_output=True,
279
+ text=True,
280
+ timeout=300,
281
+ )
282
+ if result.returncode == 0:
283
+ console.print("[green]\u2713[/green] Classifier dependencies installed (via uv)")
284
+ return True
285
+ else:
286
+ console.print(f"[yellow]\u26a0[/yellow] uv pip install failed: {result.stderr.strip()[:200]}")
287
+ except Exception as e:
288
+ console.print(f"[yellow]\u26a0[/yellow] uv pip install failed: {e}")
289
+
290
+ console.print("[yellow]\u26a0[/yellow] Could not install classifier dependencies automatically")
291
+ console.print(" [white]Install manually:[/white]")
292
+ if uv_cmd:
293
+ console.print(f" uv pip install --python {sys.executable} tweek[local-models]")
294
+ else:
295
+ console.print(" pip install tweek[local-models]")
296
+ return False
297
+
298
+
128
299
  def _download_local_model(quick: bool) -> bool:
129
- """Download the local classifier model if dependencies are available.
300
+ """Download the local classifier model, installing deps if needed.
130
301
 
131
302
  Called during ``tweek install`` to ensure the on-device prompt-injection
132
- classifier is ready to use immediately after installation.
303
+ classifier is ready to use immediately after installation. Dependencies
304
+ are installed automatically if missing.
133
305
 
134
306
  Args:
135
307
  quick: If True, skip informational output and just download.
@@ -139,7 +311,6 @@ def _download_local_model(quick: bool) -> bool:
139
311
  successfully), False otherwise.
140
312
  """
141
313
  try:
142
- from tweek.security.local_model import LOCAL_MODEL_AVAILABLE
143
314
  from tweek.security.model_registry import (
144
315
  ModelDownloadError,
145
316
  download_model,
@@ -152,37 +323,55 @@ def _download_local_model(quick: bool) -> bool:
152
323
  console.print("\n[white]Local model module not available — skipping model download[/white]")
153
324
  return False
154
325
 
326
+ console.print("\n[bold cyan]Local Classifier Setup[/bold cyan]")
327
+ console.print(" [white]The on-device classifier detects prompt injection without any API key.[/white]")
328
+ console.print()
329
+
330
+ # Step A: Ensure deps are installed
331
+ console.print(" [bold]1/2 Dependencies[/bold]")
332
+ deps_available = _ensure_local_model_deps()
333
+ if not deps_available:
334
+ return False
335
+
336
+ # Re-import after potential install
337
+ try:
338
+ from tweek.security.local_model import LOCAL_MODEL_AVAILABLE
339
+ except ImportError:
340
+ return False
341
+
155
342
  if not LOCAL_MODEL_AVAILABLE:
156
343
  if not quick:
157
- console.print("\n[white]Local model dependencies not installed (optional)[/white]")
158
- console.print(" [white]Install with: pip install tweek[local-models][/white]")
344
+ console.print(" [yellow]\u26a0[/yellow] Dependencies installed but not functional — restart may be needed")
159
345
  return False
160
346
 
347
+ # Step B: Download model
348
+ console.print()
349
+ console.print(" [bold]2/2 Model Download[/bold]")
350
+
161
351
  default_name = get_default_model_name()
162
352
 
163
353
  if is_model_installed(default_name):
164
- console.print(f"\n[green]\u2713[/green] Local classifier model already installed ({default_name})")
354
+ console.print(f" [green]\u2713[/green] Already installed ({default_name})")
165
355
  return True
166
356
 
167
357
  definition = get_model_definition(default_name)
168
358
  if definition is None:
169
359
  return False
170
360
 
171
- if not quick:
172
- console.print(f"\n[bold]Downloading local classifier model[/bold]")
173
- console.print(f" Model: {definition.display_name}")
174
- console.print(f" Size: ~{definition.size_mb:.0f} MB")
175
- console.print(f" License: {definition.license}")
176
- console.print(f" [white]This enables on-device prompt injection detection (no API key needed)[/white]")
177
- console.print()
361
+ console.print(f" Model: {definition.display_name}")
362
+ console.print(f" Size: ~{definition.size_mb:.0f} MB")
363
+ console.print(f" License: {definition.license}")
364
+ console.print()
178
365
 
179
- from rich.progress import Progress, BarColumn, DownloadColumn, TransferSpeedColumn
366
+ from rich.progress import Progress, BarColumn, DownloadColumn, TransferSpeedColumn, TimeRemainingColumn
180
367
 
181
368
  progress = Progress(
182
369
  "[progress.description]{task.description}",
183
370
  BarColumn(),
371
+ "[progress.percentage]{task.percentage:>3.0f}%",
184
372
  DownloadColumn(),
185
373
  TransferSpeedColumn(),
374
+ TimeRemainingColumn(),
186
375
  console=console,
187
376
  )
188
377
 
@@ -199,16 +388,16 @@ def _download_local_model(quick: bool) -> bool:
199
388
  with progress:
200
389
  download_model(default_name, progress_callback=progress_callback)
201
390
 
202
- console.print(f"[green]\u2713[/green] Local classifier model downloaded ({default_name})")
391
+ console.print(f" [green]\u2713[/green] Classifier model ready ({default_name})")
203
392
  return True
204
393
 
205
394
  except ModelDownloadError as e:
206
- console.print(f"\n[yellow]\u26a0[/yellow] Could not download local model: {e}")
207
- console.print(" [white]You can download it later with: tweek model download[/white]")
395
+ console.print(f"\n [yellow]\u26a0[/yellow] Could not download model: {e}")
396
+ console.print(" [white]Download later with: tweek model download[/white]")
208
397
  return False
209
398
  except Exception as e:
210
- console.print(f"\n[yellow]\u26a0[/yellow] Model download failed: {e}")
211
- console.print(" [white]You can download it later with: tweek model download[/white]")
399
+ console.print(f"\n [yellow]\u26a0[/yellow] Model download failed: {e}")
400
+ console.print(" [white]Download later with: tweek model download[/white]")
212
401
  return False
213
402
 
214
403
 
@@ -253,6 +442,12 @@ def _install_claude_code_hooks(install_global: bool, dev_test: bool, backup: boo
253
442
  "proxy": False,
254
443
  }
255
444
 
445
+ # ═══════════════════════════════════════════════════════════════
446
+ # PHASE 1: Environment Detection
447
+ # ═══════════════════════════════════════════════════════════════
448
+ console.print("[bold cyan]Phase 1/4: Environment Detection[/bold cyan]")
449
+ console.print()
450
+
256
451
  # ─────────────────────────────────────────────────────────────
257
452
  # Step 1: Detect Claude Code CLI
258
453
  # ─────────────────────────────────────────────────────────────
@@ -416,11 +611,39 @@ def _install_claude_code_hooks(install_global: bool, dev_test: bool, backup: boo
416
611
  except Exception as e:
417
612
  console.print(f"[white]Warning: Could not check for proxy conflicts: {e}[/white]")
418
613
 
614
+ # ═══════════════════════════════════════════════════════════════
615
+ # PHASE 2: Hook & Skill Installation
616
+ # ═══════════════════════════════════════════════════════════════
617
+ console.print()
618
+ console.print("[bold cyan]Phase 2/4: Hook & Skill Installation[/bold cyan]")
619
+ console.print()
620
+
419
621
  # ─────────────────────────────────────────────────────────────
420
622
  # Step 5: Install hooks into settings.json
421
623
  # ─────────────────────────────────────────────────────────────
422
- hook_script = Path(__file__).resolve().parent / "hooks" / "pre_tool_use.py"
423
- post_hook_script = Path(__file__).resolve().parent / "hooks" / "post_tool_use.py"
624
+
625
+ # Create Tweek data directory first (needed for hook deployment)
626
+ tweek_dir = Path("~/.tweek").expanduser()
627
+ tweek_dir.mkdir(parents=True, exist_ok=True)
628
+
629
+ # Deploy self-healing hook wrappers to ~/.tweek/hooks/
630
+ # These survive `pip uninstall tweek` and auto-clean settings.json
631
+ # if the tweek package is no longer available.
632
+ try:
633
+ hooks_dir = _deploy_hook_wrappers()
634
+ console.print(f"[green]\u2713[/green] Self-healing hooks deployed to: {hooks_dir}")
635
+ except FileNotFoundError as e:
636
+ console.print(f"[red]\u2717[/red] {e}")
637
+ return
638
+
639
+ # Deploy standalone uninstall script
640
+ uninstall_path = _deploy_uninstall_script()
641
+ if uninstall_path.exists():
642
+ console.print(f"[green]\u2713[/green] Standalone uninstall: {uninstall_path}")
643
+
644
+ # Hook paths now point to the deployed wrappers, not the package
645
+ hook_script = hooks_dir / "pre_tool_use.py"
646
+ post_hook_script = hooks_dir / "post_tool_use.py"
424
647
 
425
648
  # Backup existing hooks if requested
426
649
  if backup and target.exists():
@@ -460,7 +683,7 @@ def _install_claude_code_hooks(install_global: bool, dev_test: bool, backup: boo
460
683
  "hooks": [
461
684
  {
462
685
  "type": "command",
463
- "command": f"{python_exe} {hook_script.resolve()}"
686
+ "command": f"{python_exe} {hook_script}"
464
687
  }
465
688
  ]
466
689
  }
@@ -474,7 +697,7 @@ def _install_claude_code_hooks(install_global: bool, dev_test: bool, backup: boo
474
697
  "hooks": [
475
698
  {
476
699
  "type": "command",
477
- "command": f"{python_exe} {post_hook_script.resolve()}"
700
+ "command": f"{python_exe} {post_hook_script}"
478
701
  }
479
702
  ]
480
703
  }
@@ -486,9 +709,9 @@ def _install_claude_code_hooks(install_global: bool, dev_test: bool, backup: boo
486
709
  console.print(f"\n[green]\u2713[/green] PreToolUse hooks installed to: {target}")
487
710
  console.print(f"[green]\u2713[/green] PostToolUse content screening installed to: {target}")
488
711
 
489
- # Create Tweek data directory
490
- tweek_dir = Path("~/.tweek").expanduser()
491
- tweek_dir.mkdir(parents=True, exist_ok=True)
712
+ # Track this installation scope so `tweek uninstall --all` can find it
713
+ _record_installed_scope(target)
714
+
492
715
  console.print(f"[green]\u2713[/green] Tweek data directory: {tweek_dir}")
493
716
 
494
717
  # Create .tweek.yaml in the install directory (per-directory hook control)
@@ -558,6 +781,12 @@ def _install_claude_code_hooks(install_global: bool, dev_test: bool, backup: boo
558
781
  console.print(f"[white]Tweek skill source not found \u2014 skill not installed[/white]")
559
782
  console.print(f" [white]Skill can be installed manually from the tweek repository[/white]")
560
783
 
784
+ # ═══════════════════════════════════════════════════════════════
785
+ # PHASE 3: Classifier & Security
786
+ # ═══════════════════════════════════════════════════════════════
787
+ console.print()
788
+ console.print("[bold cyan]Phase 3/4: Classifier & Security Configuration[/bold cyan]")
789
+
561
790
  # ─────────────────────────────────────────────────────────────
562
791
  # Step 7: Download local classifier model
563
792
  # ─────────────────────────────────────────────────────────────
@@ -642,11 +871,21 @@ def _install_claude_code_hooks(install_global: bool, dev_test: bool, backup: boo
642
871
  # Full interactive configuration
643
872
  console.print("\n[bold]Security Configuration[/bold]")
644
873
  console.print("Choose how to configure security settings:\n")
645
- console.print(" [cyan]1.[/cyan] Paranoid - Maximum security, prompt on everything")
646
- console.print(" [cyan]2.[/cyan] Balanced - Smart defaults with provenance tracking [green](recommended)[/green]")
647
- console.print(" [cyan]3.[/cyan] Cautious - Prompt on risky operations")
648
- console.print(" [cyan]4.[/cyan] Trusted - Minimal prompts")
649
- console.print(" [cyan]5.[/cyan] Custom - Configure individually")
874
+ console.print(" [cyan]1.[/cyan] Paranoid")
875
+ console.print(" [white]Block everything suspicious. Manual approval required.[/white]")
876
+ console.print(" [dim]Best for: production systems, sensitive codebases[/dim]")
877
+ console.print(" [cyan]2.[/cyan] Balanced [green](recommended)[/green]")
878
+ console.print(" [white]Smart defaults with provenance tracking. Clean sessions[/white]")
879
+ console.print(" [white]get fewer prompts; tainted sessions get extra scrutiny.[/white]")
880
+ console.print(" [dim]Best for: most development workflows[/dim]")
881
+ console.print(" [cyan]3.[/cyan] Cautious")
882
+ console.print(" [white]Block high-risk actions, warn on medium-risk.[/white]")
883
+ console.print(" [dim]Best for: teams with mixed trust levels[/dim]")
884
+ console.print(" [cyan]4.[/cyan] Trusted")
885
+ console.print(" [white]Monitor only, never block. Logging still active.[/white]")
886
+ console.print(" [dim]Best for: air-gapped systems, solo trusted projects[/dim]")
887
+ console.print(" [cyan]5.[/cyan] Custom")
888
+ console.print(" [white]Configure tool and skill tiers individually.[/white]")
650
889
  console.print()
651
890
 
652
891
  choice = click.prompt("Select", type=click.IntRange(1, 5), default=2)
@@ -831,50 +1070,62 @@ def _install_claude_code_hooks(install_global: bool, dev_test: bool, backup: boo
831
1070
  except Exception as e:
832
1071
  console.print(f"\n[yellow]Warning: Could not save proxy config: {e}[/yellow]")
833
1072
 
1073
+ # ═══════════════════════════════════════════════════════════════
1074
+ # PHASE 4: Verification & Summary
1075
+ # ═══════════════════════════════════════════════════════════════
1076
+ console.print()
1077
+ console.print("[bold cyan]Phase 4/4: Verification & Summary[/bold cyan]")
1078
+ console.print()
1079
+
834
1080
  # ─────────────────────────────────────────────────────────────
835
1081
  # Step 13: Post-install verification and summary
836
1082
  # ─────────────────────────────────────────────────────────────
837
1083
  _print_install_summary(install_summary, target, tweek_dir, proxy_override_enabled)
838
1084
 
839
1085
  # ─────────────────────────────────────────────────────────────
840
- # Step 14: Scan for other AI tools and offer protection
1086
+ # Step 14: Detect all AI tools and offer protection
841
1087
  # ─────────────────────────────────────────────────────────────
842
1088
  if not quick:
843
- _offer_mcp_protection()
1089
+ unprotected = _detect_and_show_tools()
1090
+ if unprotected:
1091
+ _offer_tool_protection(unprotected)
844
1092
 
845
1093
 
846
1094
 
847
- def _offer_mcp_protection() -> None:
848
- """Scan for installed MCP-capable AI tools and offer to protect them.
849
-
850
- Detects Claude Desktop, Gemini CLI, and ChatGPT Desktop. For each tool
851
- that is installed but not yet protected, prompts the user to add Tweek
852
- as an MCP server.
853
- """
854
- from tweek.cli_protect import _protect_mcp_client
855
-
856
- # MCP client tool IDs to scan for (exclude claude-code and openclaw —
857
- # those are handled by their own install paths)
858
- mcp_tool_ids = {"claude-desktop", "chatgpt", "gemini"}
859
-
1095
+ def _detect_and_show_tools() -> list:
1096
+ """Detect all AI tools and display status. Returns unprotected tools."""
860
1097
  try:
861
1098
  all_tools = _detect_all_tools()
862
1099
  except Exception:
863
- return
1100
+ return []
1101
+
1102
+ console.print("\n[bold]Detected AI Tools[/bold]")
1103
+ for tool_id, label, installed, protected, detail in all_tools:
1104
+ if installed and protected:
1105
+ console.print(f" [green]\u2713[/green] {label:<20} [green]protected[/green]")
1106
+ elif installed:
1107
+ console.print(f" [yellow]\u25cb[/yellow] {label:<20} [yellow]not configured[/yellow]")
1108
+ else:
1109
+ console.print(f" [dim]\u2717 {label:<20} not found[/dim]")
1110
+ console.print()
864
1111
 
865
- unprotected = [
866
- (tool_id, label)
867
- for tool_id, label, installed, protected, _detail in all_tools
868
- if tool_id in mcp_tool_ids and installed and not protected
869
- ]
1112
+ return [t for t in all_tools if t[2] and not t[3]]
870
1113
 
871
- if not unprotected:
872
- return
873
1114
 
874
- console.print("\n[bold]Other AI tools detected[/bold]")
875
- console.print("Tweek can also protect these tools via MCP server integration:\n")
1115
+ def _offer_tool_protection(unprotected_tools: list) -> None:
1116
+ """Offer to protect each unprotected MCP-based AI tool."""
1117
+ from tweek.cli_protect import _protect_mcp_client
1118
+
1119
+ mcp_tool_ids = {"claude-desktop", "chatgpt", "gemini"}
1120
+ mcp_unprotected = [
1121
+ (tid, lbl) for tid, lbl, *_ in unprotected_tools if tid in mcp_tool_ids
1122
+ ]
1123
+ if not mcp_unprotected:
1124
+ return
876
1125
 
877
- for tool_id, label in unprotected:
1126
+ console.print("[bold]Configure MCP protection for other tools?[/bold]")
1127
+ console.print("Tweek can protect these via MCP server integration:\n")
1128
+ for tool_id, label in mcp_unprotected:
878
1129
  if click.confirm(f" Protect {label}?", default=True):
879
1130
  try:
880
1131
  _protect_mcp_client(tool_id)
@@ -882,10 +1133,16 @@ def _offer_mcp_protection() -> None:
882
1133
  console.print(f" [yellow]Could not configure {label}: {e}[/yellow]")
883
1134
  else:
884
1135
  console.print(f" [dim]Skipped {label}[/dim]")
885
-
886
1136
  console.print()
887
1137
 
888
1138
 
1139
+ def _offer_mcp_protection() -> None:
1140
+ """Legacy wrapper: detect + offer protection for MCP tools."""
1141
+ unprotected = _detect_and_show_tools()
1142
+ if unprotected:
1143
+ _offer_tool_protection(unprotected)
1144
+
1145
+
889
1146
  def _create_tweek_yaml(install_global: bool) -> None:
890
1147
  """Create .tweek.yaml in the project directory with hooks enabled.
891
1148
 
@@ -1160,12 +1417,17 @@ def _warn_no_llm_provider(quick: bool) -> None:
1160
1417
 
1161
1418
 
1162
1419
  def _detect_llm_provider():
1163
- """Detect which LLM provider is available based on environment.
1420
+ """Detect which LLM provider is available based on environment and SDK.
1164
1421
 
1165
- Priority: Local ONNX model > Anthropic > OpenAI > Google.
1422
+ Priority: Local ONNX model > Google > OpenAI > xAI > Anthropic.
1166
1423
  Returns dict with 'name' and 'model', or None if none available.
1424
+
1425
+ Both the API key AND the SDK must be available for cloud providers.
1426
+ This aligns with the doctor's detection logic to avoid contradictions
1427
+ where install says a provider is configured but doctor says it isn't.
1167
1428
  """
1168
1429
  import os
1430
+ from importlib import import_module
1169
1431
 
1170
1432
  # Check local ONNX model first (no API key needed)
1171
1433
  try:
@@ -1179,18 +1441,24 @@ def _detect_llm_provider():
1179
1441
  except ImportError:
1180
1442
  pass
1181
1443
 
1182
- # Cloud providers — Google first (free tier), then others (pay-per-token)
1444
+ # Cloud providers — check both API key AND SDK importability
1445
+ # sdk_module is the Python package that must be importable
1183
1446
  checks = [
1184
- ("GOOGLE_API_KEY", "Google", "gemini-2.0-flash"),
1185
- ("GEMINI_API_KEY", "Google", "gemini-2.0-flash"),
1186
- ("OPENAI_API_KEY", "OpenAI", "gpt-4o-mini"),
1187
- ("XAI_API_KEY", "xAI (Grok)", "grok-2"),
1188
- ("ANTHROPIC_API_KEY", "Anthropic", "claude-3-5-haiku-latest"),
1447
+ ("GOOGLE_API_KEY", "Google", "gemini-2.0-flash", "google.generativeai"),
1448
+ ("GEMINI_API_KEY", "Google", "gemini-2.0-flash", "google.generativeai"),
1449
+ ("OPENAI_API_KEY", "OpenAI", "gpt-4o-mini", "openai"),
1450
+ ("XAI_API_KEY", "xAI (Grok)", "grok-2", "openai"),
1451
+ ("ANTHROPIC_API_KEY", "Anthropic", "claude-3-5-haiku-latest", "anthropic"),
1189
1452
  ]
1190
1453
 
1191
- for env_var, name, model in checks:
1454
+ for env_var, name, model, sdk_module in checks:
1192
1455
  if os.environ.get(env_var):
1193
- return {"name": name, "model": model, "env_var": env_var}
1456
+ try:
1457
+ import_module(sdk_module)
1458
+ return {"name": name, "model": model, "env_var": env_var}
1459
+ except ImportError:
1460
+ # Key exists but SDK not installed — skip this provider
1461
+ continue
1194
1462
 
1195
1463
  return None
1196
1464
 
@@ -1291,6 +1559,10 @@ def _validate_llm_provider(llm_config: dict) -> None:
1291
1559
  console.print(f" [yellow]\u26a0[/yellow] Could not store in vault: {e}")
1292
1560
  console.print(f" [white]Set {key_name} in your shell profile instead.[/white]")
1293
1561
 
1562
+ # Validate the key actually works with a lightweight API call
1563
+ if found_key:
1564
+ _validate_api_key(provider, llm_config)
1565
+
1294
1566
  if not found_key:
1295
1567
  console.print(f" [white]LLM review will be disabled until a key is available.[/white]")
1296
1568
 
@@ -1314,6 +1586,42 @@ def _validate_llm_provider(llm_config: dict) -> None:
1314
1586
  console.print(f" [white]No API keys found \u2014 LLM review will be disabled[/white]")
1315
1587
 
1316
1588
 
1589
+ def _validate_api_key(provider: str, llm_config: dict) -> None:
1590
+ """Make a lightweight API call to verify the key works.
1591
+
1592
+ This catches invalid/expired keys during install rather than at runtime.
1593
+ On failure, warns but does not block — the key might work later.
1594
+ """
1595
+ console.print(" [white]Validating API key...[/white]", end="")
1596
+ try:
1597
+ if provider == "google":
1598
+ import google.generativeai as genai
1599
+ key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY", "")
1600
+ genai.configure(api_key=key)
1601
+ list(genai.list_models())
1602
+ console.print(f"\r [green]\u2713[/green] API key validated ")
1603
+ elif provider == "openai":
1604
+ import openai
1605
+ client = openai.OpenAI(timeout=5.0)
1606
+ client.models.list()
1607
+ console.print(f"\r [green]\u2713[/green] API key validated ")
1608
+ elif provider == "anthropic":
1609
+ import anthropic
1610
+ client = anthropic.Anthropic(timeout=5.0)
1611
+ client.messages.create(
1612
+ model="claude-3-5-haiku-latest",
1613
+ max_tokens=1,
1614
+ messages=[{"role": "user", "content": "hi"}],
1615
+ )
1616
+ console.print(f"\r [green]\u2713[/green] API key validated ")
1617
+ else:
1618
+ console.print(f"\r [white]\u25cb[/white] Validation skipped (unknown provider)")
1619
+ except Exception as e:
1620
+ err_msg = str(e)[:100]
1621
+ console.print(f"\r [yellow]\u26a0[/yellow] Could not validate key: {err_msg}")
1622
+ console.print(" [white]Tweek will retry at runtime. Key may still work.[/white]")
1623
+
1624
+
1317
1625
  def _print_install_summary(
1318
1626
  summary: dict,
1319
1627
  target: Path,
@@ -1542,10 +1850,18 @@ def install(scope, preset, quick, backup, skip_env_scan, interactive, ai_default
1542
1850
  # Step 2: Security preset
1543
1851
  console.print("[bold cyan]Step 2/5: Security Preset[/bold cyan]")
1544
1852
  if preset is None:
1545
- console.print(" [cyan]1.[/cyan] paranoid \u2014 Block everything suspicious, prompt on risky")
1546
- console.print(" [cyan]2.[/cyan] balanced \u2014 Smart defaults with provenance tracking [white](recommended)[/white]")
1547
- console.print(" [cyan]3.[/cyan] cautious \u2014 Block dangerous, prompt on risky")
1548
- console.print(" [cyan]4.[/cyan] trusted \u2014 Allow most operations, block only dangerous")
1853
+ console.print(" [cyan]1.[/cyan] paranoid")
1854
+ console.print(" [white]Block everything suspicious. Manual approval required.[/white]")
1855
+ console.print(" [dim]Best for: production systems, sensitive codebases[/dim]")
1856
+ console.print(" [cyan]2.[/cyan] balanced [white](recommended)[/white]")
1857
+ console.print(" [white]Smart defaults with provenance tracking.[/white]")
1858
+ console.print(" [dim]Best for: most development workflows[/dim]")
1859
+ console.print(" [cyan]3.[/cyan] cautious")
1860
+ console.print(" [white]Block high-risk, warn on medium-risk.[/white]")
1861
+ console.print(" [dim]Best for: teams with mixed trust levels[/dim]")
1862
+ console.print(" [cyan]4.[/cyan] trusted")
1863
+ console.print(" [white]Monitor only, never block. Logging still active.[/white]")
1864
+ console.print(" [dim]Best for: air-gapped systems, solo projects[/dim]")
1549
1865
  console.print()
1550
1866
 
1551
1867
  preset_choice = click.prompt(
@@ -1607,7 +1923,12 @@ def install(scope, preset, quick, backup, skip_env_scan, interactive, ai_default
1607
1923
 
1608
1924
 
1609
1925
  def _quickstart_install_hooks(scope: str) -> None:
1610
- """Install hooks for quickstart wizard (simplified version)."""
1926
+ """Install hooks for quickstart wizard (simplified version).
1927
+
1928
+ Uses the same self-healing wrapper deployment as the main install flow.
1929
+ Hook wrappers are deployed to ~/.tweek/hooks/ and referenced from
1930
+ settings.json — they survive ``pip uninstall tweek``.
1931
+ """
1611
1932
  import json
1612
1933
 
1613
1934
  if scope == "global":
@@ -1615,8 +1936,15 @@ def _quickstart_install_hooks(scope: str) -> None:
1615
1936
  else:
1616
1937
  target_dir = Path.cwd() / ".claude"
1617
1938
 
1618
- hooks_dir = target_dir / "hooks"
1619
- hooks_dir.mkdir(parents=True, exist_ok=True)
1939
+ target_dir.mkdir(parents=True, exist_ok=True)
1940
+
1941
+ # Deploy self-healing hook wrappers to ~/.tweek/hooks/
1942
+ hooks_dir = _deploy_hook_wrappers()
1943
+ _deploy_uninstall_script()
1944
+
1945
+ python_exe = sys.executable
1946
+ pre_hook_path = hooks_dir / "pre_tool_use.py"
1947
+ post_hook_path = hooks_dir / "post_tool_use.py"
1620
1948
 
1621
1949
  settings_path = target_dir / "settings.json"
1622
1950
  settings = {}
@@ -1630,37 +1958,43 @@ def _quickstart_install_hooks(scope: str) -> None:
1630
1958
  if "hooks" not in settings:
1631
1959
  settings["hooks"] = {}
1632
1960
 
1633
- pre_hook_entry = {
1634
- "type": "command",
1635
- "command": "tweek hook pre-tool-use $TOOL_NAME",
1636
- }
1637
- post_hook_entry = {
1638
- "type": "command",
1639
- "command": "tweek hook post-tool-use $TOOL_NAME",
1640
- }
1641
-
1642
- hook_entries = {
1643
- "PreToolUse": pre_hook_entry,
1644
- "PostToolUse": post_hook_entry,
1645
- }
1646
-
1961
+ # Remove any existing tweek hooks (clean install)
1647
1962
  for hook_type in ["PreToolUse", "PostToolUse"]:
1648
- if hook_type not in settings["hooks"]:
1649
- settings["hooks"][hook_type] = []
1650
-
1651
- # Check if tweek hooks already present
1652
- already_installed = False
1653
- for hook_config in settings["hooks"][hook_type]:
1654
- for h in hook_config.get("hooks", []):
1655
- if "tweek" in h.get("command", "").lower():
1656
- already_installed = True
1657
- break
1658
-
1659
- if not already_installed:
1660
- settings["hooks"][hook_type].append({
1661
- "matcher": "",
1662
- "hooks": [hook_entries[hook_type]],
1663
- })
1963
+ if hook_type in settings["hooks"]:
1964
+ settings["hooks"][hook_type] = [
1965
+ hc for hc in settings["hooks"][hook_type]
1966
+ if not any(
1967
+ "tweek" in h.get("command", "").lower()
1968
+ for h in hc.get("hooks", [])
1969
+ )
1970
+ ]
1971
+
1972
+ # Install hooks pointing to deployed wrappers
1973
+ settings["hooks"]["PreToolUse"] = settings["hooks"].get("PreToolUse", []) + [
1974
+ {
1975
+ "matcher": "Bash|Write|Edit|Read|WebFetch|NotebookEdit|WebSearch",
1976
+ "hooks": [
1977
+ {
1978
+ "type": "command",
1979
+ "command": f"{python_exe} {pre_hook_path}",
1980
+ }
1981
+ ],
1982
+ }
1983
+ ]
1984
+ settings["hooks"]["PostToolUse"] = settings["hooks"].get("PostToolUse", []) + [
1985
+ {
1986
+ "matcher": "Read|WebFetch|Bash|Grep|WebSearch",
1987
+ "hooks": [
1988
+ {
1989
+ "type": "command",
1990
+ "command": f"{python_exe} {post_hook_path}",
1991
+ }
1992
+ ],
1993
+ }
1994
+ ]
1664
1995
 
1665
1996
  with open(settings_path, "w") as f:
1666
1997
  json.dump(settings, f, indent=2)
1998
+
1999
+ # Track this installation scope
2000
+ _record_installed_scope(target_dir)