tweek 0.4.1__py3-none-any.whl → 0.4.2__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 +361 -91
  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.2.dist-info}/METADATA +1 -1
  32. {tweek-0.4.1.dist-info → tweek-0.4.2.dist-info}/RECORD +37 -34
  33. {tweek-0.4.1.dist-info → tweek-0.4.2.dist-info}/WHEEL +0 -0
  34. {tweek-0.4.1.dist-info → tweek-0.4.2.dist-info}/entry_points.txt +0 -0
  35. {tweek-0.4.1.dist-info → tweek-0.4.2.dist-info}/licenses/LICENSE +0 -0
  36. {tweek-0.4.1.dist-info → tweek-0.4.2.dist-info}/licenses/NOTICE +0 -0
  37. {tweek-0.4.1.dist-info → tweek-0.4.2.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,47 @@ 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
+ Returns True if deps are available after this call.
238
+ """
239
+ try:
240
+ import onnxruntime # noqa: F401
241
+ import tokenizers # noqa: F401
242
+ import numpy # noqa: F401
243
+ return True
244
+ except ImportError:
245
+ pass
246
+
247
+ console.print("\n[cyan]Installing local model dependencies...[/cyan]")
248
+ try:
249
+ import subprocess
250
+ result = subprocess.run(
251
+ [sys.executable, "-m", "pip", "install", "onnxruntime>=1.16.0", "tokenizers>=0.15.0", "numpy>=1.24.0"],
252
+ capture_output=True,
253
+ text=True,
254
+ timeout=300,
255
+ )
256
+ if result.returncode == 0:
257
+ console.print("[green]\u2713[/green] Local model dependencies installed")
258
+ return True
259
+ else:
260
+ console.print(f"[yellow]\u26a0[/yellow] Could not install dependencies: {result.stderr.strip()[:200]}")
261
+ console.print(" [white]Install manually with: pip install tweek[local-models][/white]")
262
+ return False
263
+ except Exception as e:
264
+ console.print(f"[yellow]\u26a0[/yellow] Could not install dependencies: {e}")
265
+ console.print(" [white]Install manually with: pip install tweek[local-models][/white]")
266
+ return False
267
+
268
+
128
269
  def _download_local_model(quick: bool) -> bool:
129
- """Download the local classifier model if dependencies are available.
270
+ """Download the local classifier model, installing deps if needed.
130
271
 
131
272
  Called during ``tweek install`` to ensure the on-device prompt-injection
132
- classifier is ready to use immediately after installation.
273
+ classifier is ready to use immediately after installation. Dependencies
274
+ are installed automatically if missing.
133
275
 
134
276
  Args:
135
277
  quick: If True, skip informational output and just download.
@@ -139,7 +281,6 @@ def _download_local_model(quick: bool) -> bool:
139
281
  successfully), False otherwise.
140
282
  """
141
283
  try:
142
- from tweek.security.local_model import LOCAL_MODEL_AVAILABLE
143
284
  from tweek.security.model_registry import (
144
285
  ModelDownloadError,
145
286
  download_model,
@@ -152,10 +293,20 @@ def _download_local_model(quick: bool) -> bool:
152
293
  console.print("\n[white]Local model module not available — skipping model download[/white]")
153
294
  return False
154
295
 
296
+ # Auto-install deps if missing (onnxruntime, tokenizers, numpy)
297
+ deps_available = _ensure_local_model_deps()
298
+ if not deps_available:
299
+ return False
300
+
301
+ # Re-import after potential install
302
+ try:
303
+ from tweek.security.local_model import LOCAL_MODEL_AVAILABLE
304
+ except ImportError:
305
+ return False
306
+
155
307
  if not LOCAL_MODEL_AVAILABLE:
156
308
  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]")
309
+ console.print("\n[yellow]\u26a0[/yellow] Local model dependencies not fully functional after install")
159
310
  return False
160
311
 
161
312
  default_name = get_default_model_name()
@@ -419,8 +570,29 @@ def _install_claude_code_hooks(install_global: bool, dev_test: bool, backup: boo
419
570
  # ─────────────────────────────────────────────────────────────
420
571
  # Step 5: Install hooks into settings.json
421
572
  # ─────────────────────────────────────────────────────────────
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"
573
+
574
+ # Create Tweek data directory first (needed for hook deployment)
575
+ tweek_dir = Path("~/.tweek").expanduser()
576
+ tweek_dir.mkdir(parents=True, exist_ok=True)
577
+
578
+ # Deploy self-healing hook wrappers to ~/.tweek/hooks/
579
+ # These survive `pip uninstall tweek` and auto-clean settings.json
580
+ # if the tweek package is no longer available.
581
+ try:
582
+ hooks_dir = _deploy_hook_wrappers()
583
+ console.print(f"[green]\u2713[/green] Self-healing hooks deployed to: {hooks_dir}")
584
+ except FileNotFoundError as e:
585
+ console.print(f"[red]\u2717[/red] {e}")
586
+ return
587
+
588
+ # Deploy standalone uninstall script
589
+ uninstall_path = _deploy_uninstall_script()
590
+ if uninstall_path.exists():
591
+ console.print(f"[green]\u2713[/green] Standalone uninstall: {uninstall_path}")
592
+
593
+ # Hook paths now point to the deployed wrappers, not the package
594
+ hook_script = hooks_dir / "pre_tool_use.py"
595
+ post_hook_script = hooks_dir / "post_tool_use.py"
424
596
 
425
597
  # Backup existing hooks if requested
426
598
  if backup and target.exists():
@@ -460,7 +632,7 @@ def _install_claude_code_hooks(install_global: bool, dev_test: bool, backup: boo
460
632
  "hooks": [
461
633
  {
462
634
  "type": "command",
463
- "command": f"{python_exe} {hook_script.resolve()}"
635
+ "command": f"{python_exe} {hook_script}"
464
636
  }
465
637
  ]
466
638
  }
@@ -474,7 +646,7 @@ def _install_claude_code_hooks(install_global: bool, dev_test: bool, backup: boo
474
646
  "hooks": [
475
647
  {
476
648
  "type": "command",
477
- "command": f"{python_exe} {post_hook_script.resolve()}"
649
+ "command": f"{python_exe} {post_hook_script}"
478
650
  }
479
651
  ]
480
652
  }
@@ -486,9 +658,9 @@ def _install_claude_code_hooks(install_global: bool, dev_test: bool, backup: boo
486
658
  console.print(f"\n[green]\u2713[/green] PreToolUse hooks installed to: {target}")
487
659
  console.print(f"[green]\u2713[/green] PostToolUse content screening installed to: {target}")
488
660
 
489
- # Create Tweek data directory
490
- tweek_dir = Path("~/.tweek").expanduser()
491
- tweek_dir.mkdir(parents=True, exist_ok=True)
661
+ # Track this installation scope so `tweek uninstall --all` can find it
662
+ _record_installed_scope(target)
663
+
492
664
  console.print(f"[green]\u2713[/green] Tweek data directory: {tweek_dir}")
493
665
 
494
666
  # Create .tweek.yaml in the install directory (per-directory hook control)
@@ -642,11 +814,21 @@ def _install_claude_code_hooks(install_global: bool, dev_test: bool, backup: boo
642
814
  # Full interactive configuration
643
815
  console.print("\n[bold]Security Configuration[/bold]")
644
816
  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")
817
+ console.print(" [cyan]1.[/cyan] Paranoid")
818
+ console.print(" [white]Block everything suspicious. Manual approval required.[/white]")
819
+ console.print(" [dim]Best for: production systems, sensitive codebases[/dim]")
820
+ console.print(" [cyan]2.[/cyan] Balanced [green](recommended)[/green]")
821
+ console.print(" [white]Smart defaults with provenance tracking. Clean sessions[/white]")
822
+ console.print(" [white]get fewer prompts; tainted sessions get extra scrutiny.[/white]")
823
+ console.print(" [dim]Best for: most development workflows[/dim]")
824
+ console.print(" [cyan]3.[/cyan] Cautious")
825
+ console.print(" [white]Block high-risk actions, warn on medium-risk.[/white]")
826
+ console.print(" [dim]Best for: teams with mixed trust levels[/dim]")
827
+ console.print(" [cyan]4.[/cyan] Trusted")
828
+ console.print(" [white]Monitor only, never block. Logging still active.[/white]")
829
+ console.print(" [dim]Best for: air-gapped systems, solo trusted projects[/dim]")
830
+ console.print(" [cyan]5.[/cyan] Custom")
831
+ console.print(" [white]Configure tool and skill tiers individually.[/white]")
650
832
  console.print()
651
833
 
652
834
  choice = click.prompt("Select", type=click.IntRange(1, 5), default=2)
@@ -837,44 +1019,49 @@ def _install_claude_code_hooks(install_global: bool, dev_test: bool, backup: boo
837
1019
  _print_install_summary(install_summary, target, tweek_dir, proxy_override_enabled)
838
1020
 
839
1021
  # ─────────────────────────────────────────────────────────────
840
- # Step 14: Scan for other AI tools and offer protection
1022
+ # Step 14: Detect all AI tools and offer protection
841
1023
  # ─────────────────────────────────────────────────────────────
842
1024
  if not quick:
843
- _offer_mcp_protection()
844
-
1025
+ unprotected = _detect_and_show_tools()
1026
+ if unprotected:
1027
+ _offer_tool_protection(unprotected)
845
1028
 
846
1029
 
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
1030
 
1031
+ def _detect_and_show_tools() -> list:
1032
+ """Detect all AI tools and display status. Returns unprotected tools."""
860
1033
  try:
861
1034
  all_tools = _detect_all_tools()
862
1035
  except Exception:
863
- return
1036
+ return []
1037
+
1038
+ console.print("\n[bold]Detected AI Tools[/bold]")
1039
+ for tool_id, label, installed, protected, detail in all_tools:
1040
+ if installed and protected:
1041
+ console.print(f" [green]\u2713[/green] {label:<20} [green]protected[/green]")
1042
+ elif installed:
1043
+ console.print(f" [yellow]\u25cb[/yellow] {label:<20} [yellow]not configured[/yellow]")
1044
+ else:
1045
+ console.print(f" [dim]\u2717 {label:<20} not found[/dim]")
1046
+ console.print()
864
1047
 
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
- ]
1048
+ return [t for t in all_tools if t[2] and not t[3]]
870
1049
 
871
- if not unprotected:
872
- return
873
1050
 
874
- console.print("\n[bold]Other AI tools detected[/bold]")
875
- console.print("Tweek can also protect these tools via MCP server integration:\n")
1051
+ def _offer_tool_protection(unprotected_tools: list) -> None:
1052
+ """Offer to protect each unprotected MCP-based AI tool."""
1053
+ from tweek.cli_protect import _protect_mcp_client
876
1054
 
877
- for tool_id, label in unprotected:
1055
+ mcp_tool_ids = {"claude-desktop", "chatgpt", "gemini"}
1056
+ mcp_unprotected = [
1057
+ (tid, lbl) for tid, lbl, *_ in unprotected_tools if tid in mcp_tool_ids
1058
+ ]
1059
+ if not mcp_unprotected:
1060
+ return
1061
+
1062
+ console.print("[bold]Configure MCP protection for other tools?[/bold]")
1063
+ console.print("Tweek can protect these via MCP server integration:\n")
1064
+ for tool_id, label in mcp_unprotected:
878
1065
  if click.confirm(f" Protect {label}?", default=True):
879
1066
  try:
880
1067
  _protect_mcp_client(tool_id)
@@ -882,10 +1069,16 @@ def _offer_mcp_protection() -> None:
882
1069
  console.print(f" [yellow]Could not configure {label}: {e}[/yellow]")
883
1070
  else:
884
1071
  console.print(f" [dim]Skipped {label}[/dim]")
885
-
886
1072
  console.print()
887
1073
 
888
1074
 
1075
+ def _offer_mcp_protection() -> None:
1076
+ """Legacy wrapper: detect + offer protection for MCP tools."""
1077
+ unprotected = _detect_and_show_tools()
1078
+ if unprotected:
1079
+ _offer_tool_protection(unprotected)
1080
+
1081
+
889
1082
  def _create_tweek_yaml(install_global: bool) -> None:
890
1083
  """Create .tweek.yaml in the project directory with hooks enabled.
891
1084
 
@@ -1160,12 +1353,17 @@ def _warn_no_llm_provider(quick: bool) -> None:
1160
1353
 
1161
1354
 
1162
1355
  def _detect_llm_provider():
1163
- """Detect which LLM provider is available based on environment.
1356
+ """Detect which LLM provider is available based on environment and SDK.
1164
1357
 
1165
- Priority: Local ONNX model > Anthropic > OpenAI > Google.
1358
+ Priority: Local ONNX model > Google > OpenAI > xAI > Anthropic.
1166
1359
  Returns dict with 'name' and 'model', or None if none available.
1360
+
1361
+ Both the API key AND the SDK must be available for cloud providers.
1362
+ This aligns with the doctor's detection logic to avoid contradictions
1363
+ where install says a provider is configured but doctor says it isn't.
1167
1364
  """
1168
1365
  import os
1366
+ from importlib import import_module
1169
1367
 
1170
1368
  # Check local ONNX model first (no API key needed)
1171
1369
  try:
@@ -1179,18 +1377,24 @@ def _detect_llm_provider():
1179
1377
  except ImportError:
1180
1378
  pass
1181
1379
 
1182
- # Cloud providers — Google first (free tier), then others (pay-per-token)
1380
+ # Cloud providers — check both API key AND SDK importability
1381
+ # sdk_module is the Python package that must be importable
1183
1382
  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"),
1383
+ ("GOOGLE_API_KEY", "Google", "gemini-2.0-flash", "google.generativeai"),
1384
+ ("GEMINI_API_KEY", "Google", "gemini-2.0-flash", "google.generativeai"),
1385
+ ("OPENAI_API_KEY", "OpenAI", "gpt-4o-mini", "openai"),
1386
+ ("XAI_API_KEY", "xAI (Grok)", "grok-2", "openai"),
1387
+ ("ANTHROPIC_API_KEY", "Anthropic", "claude-3-5-haiku-latest", "anthropic"),
1189
1388
  ]
1190
1389
 
1191
- for env_var, name, model in checks:
1390
+ for env_var, name, model, sdk_module in checks:
1192
1391
  if os.environ.get(env_var):
1193
- return {"name": name, "model": model, "env_var": env_var}
1392
+ try:
1393
+ import_module(sdk_module)
1394
+ return {"name": name, "model": model, "env_var": env_var}
1395
+ except ImportError:
1396
+ # Key exists but SDK not installed — skip this provider
1397
+ continue
1194
1398
 
1195
1399
  return None
1196
1400
 
@@ -1291,6 +1495,10 @@ def _validate_llm_provider(llm_config: dict) -> None:
1291
1495
  console.print(f" [yellow]\u26a0[/yellow] Could not store in vault: {e}")
1292
1496
  console.print(f" [white]Set {key_name} in your shell profile instead.[/white]")
1293
1497
 
1498
+ # Validate the key actually works with a lightweight API call
1499
+ if found_key:
1500
+ _validate_api_key(provider, llm_config)
1501
+
1294
1502
  if not found_key:
1295
1503
  console.print(f" [white]LLM review will be disabled until a key is available.[/white]")
1296
1504
 
@@ -1314,6 +1522,42 @@ def _validate_llm_provider(llm_config: dict) -> None:
1314
1522
  console.print(f" [white]No API keys found \u2014 LLM review will be disabled[/white]")
1315
1523
 
1316
1524
 
1525
+ def _validate_api_key(provider: str, llm_config: dict) -> None:
1526
+ """Make a lightweight API call to verify the key works.
1527
+
1528
+ This catches invalid/expired keys during install rather than at runtime.
1529
+ On failure, warns but does not block — the key might work later.
1530
+ """
1531
+ console.print(" [white]Validating API key...[/white]", end="")
1532
+ try:
1533
+ if provider == "google":
1534
+ import google.generativeai as genai
1535
+ key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY", "")
1536
+ genai.configure(api_key=key)
1537
+ list(genai.list_models())
1538
+ console.print(f"\r [green]\u2713[/green] API key validated ")
1539
+ elif provider == "openai":
1540
+ import openai
1541
+ client = openai.OpenAI(timeout=5.0)
1542
+ client.models.list()
1543
+ console.print(f"\r [green]\u2713[/green] API key validated ")
1544
+ elif provider == "anthropic":
1545
+ import anthropic
1546
+ client = anthropic.Anthropic(timeout=5.0)
1547
+ client.messages.create(
1548
+ model="claude-3-5-haiku-latest",
1549
+ max_tokens=1,
1550
+ messages=[{"role": "user", "content": "hi"}],
1551
+ )
1552
+ console.print(f"\r [green]\u2713[/green] API key validated ")
1553
+ else:
1554
+ console.print(f"\r [white]\u25cb[/white] Validation skipped (unknown provider)")
1555
+ except Exception as e:
1556
+ err_msg = str(e)[:100]
1557
+ console.print(f"\r [yellow]\u26a0[/yellow] Could not validate key: {err_msg}")
1558
+ console.print(" [white]Tweek will retry at runtime. Key may still work.[/white]")
1559
+
1560
+
1317
1561
  def _print_install_summary(
1318
1562
  summary: dict,
1319
1563
  target: Path,
@@ -1542,10 +1786,18 @@ def install(scope, preset, quick, backup, skip_env_scan, interactive, ai_default
1542
1786
  # Step 2: Security preset
1543
1787
  console.print("[bold cyan]Step 2/5: Security Preset[/bold cyan]")
1544
1788
  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")
1789
+ console.print(" [cyan]1.[/cyan] paranoid")
1790
+ console.print(" [white]Block everything suspicious. Manual approval required.[/white]")
1791
+ console.print(" [dim]Best for: production systems, sensitive codebases[/dim]")
1792
+ console.print(" [cyan]2.[/cyan] balanced [white](recommended)[/white]")
1793
+ console.print(" [white]Smart defaults with provenance tracking.[/white]")
1794
+ console.print(" [dim]Best for: most development workflows[/dim]")
1795
+ console.print(" [cyan]3.[/cyan] cautious")
1796
+ console.print(" [white]Block high-risk, warn on medium-risk.[/white]")
1797
+ console.print(" [dim]Best for: teams with mixed trust levels[/dim]")
1798
+ console.print(" [cyan]4.[/cyan] trusted")
1799
+ console.print(" [white]Monitor only, never block. Logging still active.[/white]")
1800
+ console.print(" [dim]Best for: air-gapped systems, solo projects[/dim]")
1549
1801
  console.print()
1550
1802
 
1551
1803
  preset_choice = click.prompt(
@@ -1607,7 +1859,12 @@ def install(scope, preset, quick, backup, skip_env_scan, interactive, ai_default
1607
1859
 
1608
1860
 
1609
1861
  def _quickstart_install_hooks(scope: str) -> None:
1610
- """Install hooks for quickstart wizard (simplified version)."""
1862
+ """Install hooks for quickstart wizard (simplified version).
1863
+
1864
+ Uses the same self-healing wrapper deployment as the main install flow.
1865
+ Hook wrappers are deployed to ~/.tweek/hooks/ and referenced from
1866
+ settings.json — they survive ``pip uninstall tweek``.
1867
+ """
1611
1868
  import json
1612
1869
 
1613
1870
  if scope == "global":
@@ -1615,8 +1872,15 @@ def _quickstart_install_hooks(scope: str) -> None:
1615
1872
  else:
1616
1873
  target_dir = Path.cwd() / ".claude"
1617
1874
 
1618
- hooks_dir = target_dir / "hooks"
1619
- hooks_dir.mkdir(parents=True, exist_ok=True)
1875
+ target_dir.mkdir(parents=True, exist_ok=True)
1876
+
1877
+ # Deploy self-healing hook wrappers to ~/.tweek/hooks/
1878
+ hooks_dir = _deploy_hook_wrappers()
1879
+ _deploy_uninstall_script()
1880
+
1881
+ python_exe = sys.executable
1882
+ pre_hook_path = hooks_dir / "pre_tool_use.py"
1883
+ post_hook_path = hooks_dir / "post_tool_use.py"
1620
1884
 
1621
1885
  settings_path = target_dir / "settings.json"
1622
1886
  settings = {}
@@ -1630,37 +1894,43 @@ def _quickstart_install_hooks(scope: str) -> None:
1630
1894
  if "hooks" not in settings:
1631
1895
  settings["hooks"] = {}
1632
1896
 
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
-
1897
+ # Remove any existing tweek hooks (clean install)
1647
1898
  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
- })
1899
+ if hook_type in settings["hooks"]:
1900
+ settings["hooks"][hook_type] = [
1901
+ hc for hc in settings["hooks"][hook_type]
1902
+ if not any(
1903
+ "tweek" in h.get("command", "").lower()
1904
+ for h in hc.get("hooks", [])
1905
+ )
1906
+ ]
1907
+
1908
+ # Install hooks pointing to deployed wrappers
1909
+ settings["hooks"]["PreToolUse"] = settings["hooks"].get("PreToolUse", []) + [
1910
+ {
1911
+ "matcher": "Bash|Write|Edit|Read|WebFetch|NotebookEdit|WebSearch",
1912
+ "hooks": [
1913
+ {
1914
+ "type": "command",
1915
+ "command": f"{python_exe} {pre_hook_path}",
1916
+ }
1917
+ ],
1918
+ }
1919
+ ]
1920
+ settings["hooks"]["PostToolUse"] = settings["hooks"].get("PostToolUse", []) + [
1921
+ {
1922
+ "matcher": "Read|WebFetch|Bash|Grep|WebSearch",
1923
+ "hooks": [
1924
+ {
1925
+ "type": "command",
1926
+ "command": f"{python_exe} {post_hook_path}",
1927
+ }
1928
+ ],
1929
+ }
1930
+ ]
1664
1931
 
1665
1932
  with open(settings_path, "w") as f:
1666
1933
  json.dump(settings, f, indent=2)
1934
+
1935
+ # Track this installation scope
1936
+ _record_installed_scope(target_dir)