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.
- tweek/__init__.py +1 -1
- tweek/cli_core.py +23 -6
- tweek/cli_install.py +439 -105
- 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.3.dist-info}/METADATA +1 -1
- {tweek-0.4.1.dist-info → tweek-0.4.3.dist-info}/RECORD +37 -34
- {tweek-0.4.1.dist-info → tweek-0.4.3.dist-info}/WHEEL +0 -0
- {tweek-0.4.1.dist-info → tweek-0.4.3.dist-info}/entry_points.txt +0 -0
- {tweek-0.4.1.dist-info → tweek-0.4.3.dist-info}/licenses/LICENSE +0 -0
- {tweek-0.4.1.dist-info → tweek-0.4.3.dist-info}/licenses/NOTICE +0 -0
- {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
|
|
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("\
|
|
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"
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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]
|
|
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
|
|
207
|
-
console.print("
|
|
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("
|
|
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
|
-
|
|
423
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
490
|
-
|
|
491
|
-
|
|
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
|
|
646
|
-
console.print("
|
|
647
|
-
console.print("
|
|
648
|
-
console.print(" [cyan]
|
|
649
|
-
console.print("
|
|
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:
|
|
1086
|
+
# Step 14: Detect all AI tools and offer protection
|
|
841
1087
|
# ─────────────────────────────────────────────────────────────
|
|
842
1088
|
if not quick:
|
|
843
|
-
|
|
1089
|
+
unprotected = _detect_and_show_tools()
|
|
1090
|
+
if unprotected:
|
|
1091
|
+
_offer_tool_protection(unprotected)
|
|
844
1092
|
|
|
845
1093
|
|
|
846
1094
|
|
|
847
|
-
def
|
|
848
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
875
|
-
|
|
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
|
-
|
|
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 >
|
|
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 —
|
|
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
|
-
|
|
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
|
|
1546
|
-
console.print("
|
|
1547
|
-
console.print("
|
|
1548
|
-
console.print(" [cyan]
|
|
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
|
-
|
|
1619
|
-
|
|
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
|
-
|
|
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
|
|
1649
|
-
settings["hooks"][hook_type] = [
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
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)
|