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.
- tweek/__init__.py +1 -1
- tweek/cli_core.py +23 -6
- tweek/cli_install.py +361 -91
- tweek/cli_uninstall.py +119 -36
- tweek/config/families.yaml +13 -0
- tweek/config/models.py +31 -3
- tweek/config/patterns.yaml +126 -2
- tweek/diagnostics.py +124 -1
- tweek/hooks/break_glass.py +70 -47
- tweek/hooks/overrides.py +19 -1
- tweek/hooks/post_tool_use.py +6 -2
- tweek/hooks/pre_tool_use.py +19 -2
- tweek/hooks/wrapper_post_tool_use.py +121 -0
- tweek/hooks/wrapper_pre_tool_use.py +121 -0
- tweek/integrations/openclaw.py +70 -60
- tweek/integrations/openclaw_detection.py +140 -0
- tweek/integrations/openclaw_server.py +359 -86
- tweek/logging/security_log.py +22 -0
- tweek/memory/safety.py +7 -3
- tweek/memory/store.py +31 -10
- tweek/plugins/base.py +9 -1
- tweek/plugins/detectors/openclaw.py +31 -92
- tweek/plugins/screening/heuristic_scorer.py +12 -1
- tweek/plugins/screening/local_model_reviewer.py +9 -0
- tweek/security/language.py +2 -1
- tweek/security/llm_reviewer.py +45 -18
- tweek/security/local_model.py +21 -0
- tweek/security/model_registry.py +2 -2
- tweek/security/rate_limiter.py +99 -1
- tweek/skills/guard.py +30 -7
- {tweek-0.4.1.dist-info → tweek-0.4.2.dist-info}/METADATA +1 -1
- {tweek-0.4.1.dist-info → tweek-0.4.2.dist-info}/RECORD +37 -34
- {tweek-0.4.1.dist-info → tweek-0.4.2.dist-info}/WHEEL +0 -0
- {tweek-0.4.1.dist-info → tweek-0.4.2.dist-info}/entry_points.txt +0 -0
- {tweek-0.4.1.dist-info → tweek-0.4.2.dist-info}/licenses/LICENSE +0 -0
- {tweek-0.4.1.dist-info → tweek-0.4.2.dist-info}/licenses/NOTICE +0 -0
- {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
|
|
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[
|
|
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
|
-
|
|
423
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
490
|
-
|
|
491
|
-
|
|
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
|
|
646
|
-
console.print("
|
|
647
|
-
console.print("
|
|
648
|
-
console.print(" [cyan]
|
|
649
|
-
console.print("
|
|
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:
|
|
1022
|
+
# Step 14: Detect all AI tools and offer protection
|
|
841
1023
|
# ─────────────────────────────────────────────────────────────
|
|
842
1024
|
if not quick:
|
|
843
|
-
|
|
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
|
-
|
|
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
|
-
|
|
875
|
-
|
|
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
|
-
|
|
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 >
|
|
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 —
|
|
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
|
-
|
|
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
|
|
1546
|
-
console.print("
|
|
1547
|
-
console.print("
|
|
1548
|
-
console.print(" [cyan]
|
|
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
|
-
|
|
1619
|
-
|
|
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
|
-
|
|
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
|
|
1649
|
-
settings["hooks"][hook_type] = [
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
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)
|