tweek 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. tweek/__init__.py +2 -2
  2. tweek/audit.py +2 -2
  3. tweek/cli.py +78 -6559
  4. tweek/cli_config.py +643 -0
  5. tweek/cli_configure.py +413 -0
  6. tweek/cli_core.py +718 -0
  7. tweek/cli_dry_run.py +390 -0
  8. tweek/cli_helpers.py +316 -0
  9. tweek/cli_install.py +1666 -0
  10. tweek/cli_logs.py +301 -0
  11. tweek/cli_mcp.py +148 -0
  12. tweek/cli_memory.py +343 -0
  13. tweek/cli_plugins.py +748 -0
  14. tweek/cli_protect.py +564 -0
  15. tweek/cli_proxy.py +405 -0
  16. tweek/cli_security.py +236 -0
  17. tweek/cli_skills.py +289 -0
  18. tweek/cli_uninstall.py +551 -0
  19. tweek/cli_vault.py +313 -0
  20. tweek/config/__init__.py +8 -0
  21. tweek/config/allowed_dirs.yaml +16 -17
  22. tweek/config/families.yaml +4 -1
  23. tweek/config/manager.py +49 -0
  24. tweek/config/models.py +307 -0
  25. tweek/config/patterns.yaml +29 -5
  26. tweek/config/templates/config.yaml.template +212 -0
  27. tweek/config/templates/env.template +45 -0
  28. tweek/config/templates/overrides.yaml.template +121 -0
  29. tweek/config/templates/tweek.yaml.template +20 -0
  30. tweek/config/templates.py +136 -0
  31. tweek/config/tiers.yaml +5 -4
  32. tweek/diagnostics.py +112 -32
  33. tweek/hooks/overrides.py +4 -0
  34. tweek/hooks/post_tool_use.py +46 -1
  35. tweek/hooks/pre_tool_use.py +149 -49
  36. tweek/integrations/openclaw.py +84 -0
  37. tweek/licensing.py +1 -1
  38. tweek/mcp/__init__.py +7 -9
  39. tweek/mcp/clients/chatgpt.py +2 -2
  40. tweek/mcp/clients/claude_desktop.py +2 -2
  41. tweek/mcp/clients/gemini.py +2 -2
  42. tweek/mcp/proxy.py +165 -1
  43. tweek/memory/provenance.py +438 -0
  44. tweek/memory/queries.py +2 -0
  45. tweek/memory/safety.py +23 -4
  46. tweek/memory/schemas.py +1 -0
  47. tweek/memory/store.py +101 -71
  48. tweek/plugins/screening/heuristic_scorer.py +1 -1
  49. tweek/security/integrity.py +77 -0
  50. tweek/security/llm_reviewer.py +162 -68
  51. tweek/security/local_reviewer.py +44 -2
  52. tweek/security/model_registry.py +73 -7
  53. tweek/skill_template/overrides-reference.md +1 -1
  54. tweek/skills/context.py +221 -0
  55. tweek/skills/scanner.py +2 -2
  56. {tweek-0.3.0.dist-info → tweek-0.4.0.dist-info}/METADATA +9 -7
  57. {tweek-0.3.0.dist-info → tweek-0.4.0.dist-info}/RECORD +62 -39
  58. tweek/mcp/server.py +0 -320
  59. {tweek-0.3.0.dist-info → tweek-0.4.0.dist-info}/WHEEL +0 -0
  60. {tweek-0.3.0.dist-info → tweek-0.4.0.dist-info}/entry_points.txt +0 -0
  61. {tweek-0.3.0.dist-info → tweek-0.4.0.dist-info}/licenses/LICENSE +0 -0
  62. {tweek-0.3.0.dist-info → tweek-0.4.0.dist-info}/licenses/NOTICE +0 -0
  63. {tweek-0.3.0.dist-info → tweek-0.4.0.dist-info}/top_level.txt +0 -0
tweek/cli_install.py ADDED
@@ -0,0 +1,1666 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tweek CLI Install Command
4
+
5
+ The `tweek install` command provides the full Tweek onboarding experience:
6
+ 1. Install hooks (global or project scope)
7
+ 2. Choose a security preset
8
+ 3. Verify credential vault
9
+ 4. Optional MCP proxy setup
10
+
11
+ This is the Tweek *package* lifecycle command. For per-tool protection,
12
+ use `tweek protect [tool]` instead.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import click
17
+ import json
18
+ import os
19
+ import re
20
+ import shutil
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import List, Tuple
24
+
25
+ from rich.console import Console
26
+ from rich.table import Table
27
+
28
+ from tweek import __version__
29
+ from tweek.cli_helpers import (
30
+ console,
31
+ TWEEK_BANNER,
32
+ print_success,
33
+ print_warning,
34
+ _has_tweek_hooks,
35
+ _detect_all_tools,
36
+ )
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Utility functions for .env scanning
41
+ # ---------------------------------------------------------------------------
42
+
43
+ def scan_for_env_files() -> List[Tuple[Path, List[str]]]:
44
+ """
45
+ Scan common locations for .env files.
46
+
47
+ Returns:
48
+ List of (path, credential_keys) tuples
49
+ """
50
+ locations = [
51
+ Path.cwd() / ".env",
52
+ Path.home() / ".env",
53
+ Path.cwd() / ".env.local",
54
+ Path.cwd() / ".env.production",
55
+ Path.cwd() / ".env.development",
56
+ ]
57
+
58
+ # Also check parent directories up to 3 levels
59
+ parent = Path.cwd().parent
60
+ for _ in range(3):
61
+ if parent != parent.parent:
62
+ locations.append(parent / ".env")
63
+ parent = parent.parent
64
+
65
+ found = []
66
+ seen_paths = set()
67
+
68
+ for path in locations:
69
+ try:
70
+ resolved = path.resolve()
71
+ if resolved in seen_paths:
72
+ continue
73
+ seen_paths.add(resolved)
74
+
75
+ if path.exists() and path.is_file():
76
+ keys = parse_env_keys(path)
77
+ if keys:
78
+ found.append((path, keys))
79
+ except (PermissionError, OSError):
80
+ continue
81
+
82
+ return found
83
+
84
+
85
+ def parse_env_keys(env_path: Path) -> List[str]:
86
+ """
87
+ Parse .env file and return list of credential keys.
88
+
89
+ Only returns keys that look like credentials (contain KEY, SECRET,
90
+ PASSWORD, TOKEN, API, AUTH, etc.)
91
+ """
92
+ credential_patterns = [
93
+ r'.*KEY.*', r'.*SECRET.*', r'.*PASSWORD.*', r'.*TOKEN.*',
94
+ r'.*API.*', r'.*AUTH.*', r'.*CREDENTIAL.*', r'.*PRIVATE.*',
95
+ r'.*ACCESS.*', r'.*CONN.*STRING.*', r'.*DB_.*', r'.*DATABASE.*',
96
+ ]
97
+
98
+ keys = []
99
+ try:
100
+ content = env_path.read_text()
101
+ for line in content.splitlines():
102
+ line = line.strip()
103
+ if not line or line.startswith("#") or "=" not in line:
104
+ continue
105
+
106
+ key = line.split("=", 1)[0].strip()
107
+
108
+ # Check if it looks like a credential
109
+ key_upper = key.upper()
110
+ is_credential = any(
111
+ re.match(pattern, key_upper, re.IGNORECASE)
112
+ for pattern in credential_patterns
113
+ )
114
+
115
+ if is_credential:
116
+ keys.append(key)
117
+ except (PermissionError, OSError):
118
+ pass
119
+
120
+ return keys
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Install helpers
125
+ # ---------------------------------------------------------------------------
126
+
127
+
128
+ def _download_local_model(quick: bool) -> bool:
129
+ """Download the local classifier model if dependencies are available.
130
+
131
+ Called during ``tweek install`` to ensure the on-device prompt-injection
132
+ classifier is ready to use immediately after installation.
133
+
134
+ Args:
135
+ quick: If True, skip informational output and just download.
136
+
137
+ Returns:
138
+ True if the model is installed (was already present or downloaded
139
+ successfully), False otherwise.
140
+ """
141
+ try:
142
+ from tweek.security.local_model import LOCAL_MODEL_AVAILABLE
143
+ from tweek.security.model_registry import (
144
+ ModelDownloadError,
145
+ download_model,
146
+ get_default_model_name,
147
+ get_model_definition,
148
+ is_model_installed,
149
+ )
150
+ except ImportError:
151
+ if not quick:
152
+ console.print("\n[white]Local model module not available — skipping model download[/white]")
153
+ return False
154
+
155
+ if not LOCAL_MODEL_AVAILABLE:
156
+ 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]")
159
+ return False
160
+
161
+ default_name = get_default_model_name()
162
+
163
+ if is_model_installed(default_name):
164
+ console.print(f"\n[green]\u2713[/green] Local classifier model already installed ({default_name})")
165
+ return True
166
+
167
+ definition = get_model_definition(default_name)
168
+ if definition is None:
169
+ return False
170
+
171
+ if not quick:
172
+ console.print(f"\n[bold]Downloading local classifier model[/bold]")
173
+ console.print(f" Model: {definition.display_name}")
174
+ console.print(f" Size: ~{definition.size_mb:.0f} MB")
175
+ console.print(f" License: {definition.license}")
176
+ console.print(f" [white]This enables on-device prompt injection detection (no API key needed)[/white]")
177
+ console.print()
178
+
179
+ from rich.progress import Progress, BarColumn, DownloadColumn, TransferSpeedColumn
180
+
181
+ progress = Progress(
182
+ "[progress.description]{task.description}",
183
+ BarColumn(),
184
+ DownloadColumn(),
185
+ TransferSpeedColumn(),
186
+ console=console,
187
+ )
188
+
189
+ tasks = {}
190
+
191
+ def progress_callback(filename: str, downloaded: int, total: int):
192
+ if filename not in tasks:
193
+ tasks[filename] = progress.add_task(
194
+ f" {filename}", total=total or None
195
+ )
196
+ progress.update(tasks[filename], completed=downloaded)
197
+
198
+ try:
199
+ with progress:
200
+ download_model(default_name, progress_callback=progress_callback)
201
+
202
+ console.print(f"[green]\u2713[/green] Local classifier model downloaded ({default_name})")
203
+ return True
204
+
205
+ except ModelDownloadError as e:
206
+ console.print(f"\n[yellow]\u26a0[/yellow] Could not download local model: {e}")
207
+ console.print(" [white]You can download it later with: tweek model download[/white]")
208
+ return False
209
+ except Exception as e:
210
+ console.print(f"\n[yellow]\u26a0[/yellow] Model download failed: {e}")
211
+ console.print(" [white]You can download it later with: tweek model download[/white]")
212
+ return False
213
+
214
+
215
+ def _install_claude_code_hooks(install_global: bool, dev_test: bool, backup: bool, skip_env_scan: bool, interactive: bool, preset: str, ai_defaults: bool, with_sandbox: bool, force_proxy: bool, skip_proxy_check: bool, quick: bool):
216
+ """Install Tweek hooks into Claude Code.
217
+
218
+ By default, installs to the current project (./.claude/).
219
+ Use --global to install system-wide (~/.claude/).
220
+
221
+ Configuration options:
222
+ --interactive : Walk through configuration prompts
223
+ --preset : Apply paranoid/cautious/trusted preset
224
+ --ai-defaults : Auto-configure based on detected skills
225
+ --quick : Zero-prompt install with cautious defaults
226
+ --with-sandbox : Install sandbox tool if needed (Linux: firejail)
227
+ """
228
+ import json
229
+ import shutil
230
+ from tweek.platform import IS_LINUX, get_capabilities
231
+ from tweek.config.manager import ConfigManager, SecurityTier
232
+
233
+ # --quick implies non-interactive defaults
234
+ if quick:
235
+ skip_env_scan = True
236
+ skip_proxy_check = True
237
+ if not preset:
238
+ preset = "balanced"
239
+
240
+ console.print(TWEEK_BANNER, style="cyan")
241
+
242
+ # ─────────────────────────────────────────────────────────────
243
+ # Pre-flight: Python version check
244
+ # ─────────────────────────────────────────────────────────────
245
+ _check_python_version(console, quick)
246
+
247
+ # Track install summary for verification output
248
+ install_summary = {
249
+ "scope": "project",
250
+ "preset": None,
251
+ "llm_provider": None,
252
+ "llm_model": None,
253
+ "proxy": False,
254
+ }
255
+
256
+ # ─────────────────────────────────────────────────────────────
257
+ # Step 1: Detect Claude Code CLI
258
+ # ─────────────────────────────────────────────────────────────
259
+ claude_path = shutil.which("claude")
260
+ if claude_path:
261
+ console.print(f"[green]\u2713[/green] Claude Code detected ({claude_path})")
262
+ else:
263
+ console.print()
264
+ console.print("[yellow]\u26a0 Claude Code not detected on this system[/yellow]")
265
+ console.print(" [white]Tweek hooks require Claude Code to function.[/white]")
266
+ console.print(" [white]https://docs.anthropic.com/en/docs/claude-code[/white]")
267
+ console.print()
268
+ if quick or not click.confirm("Continue installing hooks anyway?", default=False):
269
+ if not quick:
270
+ console.print()
271
+ console.print("[white]Run 'tweek install' later after installing Claude Code.[/white]")
272
+ return
273
+ console.print()
274
+
275
+ # ─────────────────────────────────────────────────────────────
276
+ # Step 2: Scope selection (always shown unless --global or --quick)
277
+ # ─────────────────────────────────────────────────────────────
278
+ if not install_global and not dev_test and not quick:
279
+ console.print()
280
+ console.print("[bold]Installation Scope[/bold]")
281
+ console.print()
282
+ console.print(" [cyan]1.[/cyan] All projects globally (~/.claude/) [green](recommended)[/green]")
283
+ console.print(" [white]Protects every project on this machine[/white]")
284
+ console.print(" [cyan]2.[/cyan] This directory only (./.claude/)")
285
+ console.print(" [white]Protects only the current directory[/white]")
286
+ console.print()
287
+ scope_choice = click.prompt("Select", type=click.IntRange(1, 2), default=1)
288
+ if scope_choice == 1:
289
+ install_global = True
290
+ console.print()
291
+
292
+ # Determine target directory based on scope
293
+ if dev_test:
294
+ console.print("[yellow]Installing in DEV TEST mode (isolated environment)[/yellow]")
295
+ target = Path("~/AI/tweek/test-environment/.claude").expanduser()
296
+ install_summary["scope"] = "dev-test"
297
+ elif install_global:
298
+ target = Path("~/.claude").expanduser()
299
+ console.print(f"[cyan]Scope: global[/cyan] \u2014 Hooks will protect all projects")
300
+ install_summary["scope"] = "global"
301
+ else: # project (default)
302
+ target = Path.cwd() / ".claude"
303
+ console.print(f"[cyan]Scope: project[/cyan] \u2014 Hooks will protect this project only")
304
+ install_summary["scope"] = "project"
305
+
306
+ # ─────────────────────────────────────────────────────────────
307
+ # Step 3: Scope conflict detection
308
+ # ─────────────────────────────────────────────────────────────
309
+ if not dev_test:
310
+ try:
311
+ if install_global:
312
+ # Installing globally — check if project-level hooks exist here
313
+ project_settings = Path.cwd() / ".claude" / "settings.json"
314
+ if project_settings.exists():
315
+ with open(project_settings) as f:
316
+ project_config = json.load(f)
317
+ if _has_tweek_hooks(project_config):
318
+ console.print("[white]Note: Tweek is also installed in this project.[/white]")
319
+ console.print("[white]Project-level settings take precedence over global.[/white]")
320
+ console.print()
321
+ else:
322
+ # Installing per-project — check if global hooks exist
323
+ global_settings = Path("~/.claude/settings.json").expanduser()
324
+ if global_settings.exists():
325
+ with open(global_settings) as f:
326
+ global_config = json.load(f)
327
+ if _has_tweek_hooks(global_config):
328
+ console.print("[white]Note: Tweek is also installed globally.[/white]")
329
+ console.print("[white]Project-level settings will take precedence in this directory.[/white]")
330
+ console.print()
331
+ except (json.JSONDecodeError, IOError):
332
+ pass
333
+
334
+ # ─────────────────────────────────────────────────────────────
335
+ # Step 4: Detect OpenClaw and offer protection options
336
+ # ─────────────────────────────────────────────────────────────
337
+ proxy_override_enabled = force_proxy
338
+ if not skip_proxy_check:
339
+ try:
340
+ from tweek.proxy import (
341
+ detect_proxy_conflicts,
342
+ get_openclaw_status,
343
+ OPENCLAW_DEFAULT_PORT,
344
+ TWEEK_DEFAULT_PORT,
345
+ )
346
+
347
+ openclaw_status = get_openclaw_status()
348
+
349
+ if openclaw_status["installed"]:
350
+ console.print()
351
+ console.print("[green]\u2713[/green] OpenClaw detected on this system")
352
+
353
+ if openclaw_status["gateway_active"]:
354
+ console.print(f" Gateway running on port {openclaw_status['port']}")
355
+ elif openclaw_status["running"]:
356
+ console.print(f" [white]Process running (gateway may start on port {openclaw_status['port']})[/white]")
357
+ else:
358
+ console.print(f" [white]Installed but not currently running[/white]")
359
+
360
+ if openclaw_status["config_path"]:
361
+ console.print(f" [white]Config: {openclaw_status['config_path']}[/white]")
362
+
363
+ console.print()
364
+
365
+ if force_proxy:
366
+ proxy_override_enabled = True
367
+ console.print("[green]\u2713[/green] Force proxy enabled - Tweek will override openclaw")
368
+ console.print()
369
+ else:
370
+ console.print("[cyan]Tweek can protect OpenClaw tool calls. Choose a method:[/cyan]")
371
+ console.print()
372
+ console.print(" [cyan]1.[/cyan] Protect via [bold]tweek-security[/bold] ClawHub skill")
373
+ console.print(" [white]Screens tool calls through Tweek as a ClawHub skill[/white]")
374
+ console.print(" [cyan]2.[/cyan] Protect via [bold]tweek protect openclaw[/bold]")
375
+ console.print(" [white]Wraps the OpenClaw gateway with Tweek's proxy[/white]")
376
+ console.print(" [cyan]3.[/cyan] Skip for now")
377
+ console.print(" [white]You can set up OpenClaw protection later[/white]")
378
+ console.print()
379
+
380
+ choice = click.prompt(
381
+ "Select",
382
+ type=click.IntRange(1, 3),
383
+ default=3,
384
+ )
385
+
386
+ if choice == 1:
387
+ console.print()
388
+ console.print("[green]\u2713[/green] To add OpenClaw protection via the skill, run:")
389
+ console.print(" [bold]openclaw protect tweek-security[/bold]")
390
+ console.print()
391
+ elif choice == 2:
392
+ proxy_override_enabled = True
393
+ console.print()
394
+ console.print("[green]\u2713[/green] OpenClaw proxy protection will be configured")
395
+ console.print(f" [white]Run 'tweek protect openclaw' after installation to complete setup[/white]")
396
+ console.print()
397
+ else:
398
+ console.print()
399
+ console.print("[white]Skipped. Run 'tweek protect openclaw' or add the[/white]")
400
+ console.print("[white]tweek-security skill later to protect OpenClaw.[/white]")
401
+ console.print()
402
+
403
+ # Check for other proxy conflicts
404
+ conflicts = detect_proxy_conflicts()
405
+ non_openclaw_conflicts = [c for c in conflicts if c.tool_name != "openclaw"]
406
+
407
+ if non_openclaw_conflicts:
408
+ console.print("[yellow]\u26a0 Other proxy conflicts detected:[/yellow]")
409
+ for conflict in non_openclaw_conflicts:
410
+ console.print(f" \u2022 {conflict.description}")
411
+ console.print()
412
+
413
+ except ImportError:
414
+ # Proxy module not fully available, skip detection
415
+ pass
416
+ except Exception as e:
417
+ console.print(f"[white]Warning: Could not check for proxy conflicts: {e}[/white]")
418
+
419
+ # ─────────────────────────────────────────────────────────────
420
+ # Step 5: Install hooks into settings.json
421
+ # ─────────────────────────────────────────────────────────────
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"
424
+
425
+ # Backup existing hooks if requested
426
+ if backup and target.exists():
427
+ settings_file = target / "settings.json"
428
+ if settings_file.exists():
429
+ backup_path = settings_file.with_suffix(".json.tweek-backup")
430
+ shutil.copy(settings_file, backup_path)
431
+ console.print(f"[white]Backed up existing settings to {backup_path}[/white]")
432
+
433
+ # Create target directory
434
+ target.mkdir(parents=True, exist_ok=True)
435
+
436
+ # Install hooks configuration
437
+ settings_file = target / "settings.json"
438
+
439
+ # Load existing settings or create new
440
+ if settings_file.exists():
441
+ with open(settings_file) as f:
442
+ settings = json.load(f)
443
+ else:
444
+ settings = {}
445
+
446
+ # Add Tweek hooks
447
+ settings["hooks"] = settings.get("hooks", {})
448
+
449
+ # Use the exact Python that ran `tweek install` so hooks work even when
450
+ # /usr/bin/env python3 resolves to a different interpreter (e.g., system
451
+ # Python 3.9 while Tweek was installed via pyenv/Homebrew Python 3.12).
452
+ python_exe = sys.executable
453
+
454
+ # PreToolUse: screen tool requests before execution
455
+ # Match ALL security-relevant tools, not just Bash — Write/Edit/Read/WebFetch
456
+ # all have screening logic in pre_tool_use.py that must be reachable
457
+ settings["hooks"]["PreToolUse"] = [
458
+ {
459
+ "matcher": "Bash|Write|Edit|Read|WebFetch|NotebookEdit|WebSearch",
460
+ "hooks": [
461
+ {
462
+ "type": "command",
463
+ "command": f"{python_exe} {hook_script.resolve()}"
464
+ }
465
+ ]
466
+ }
467
+ ]
468
+
469
+ # PostToolUse: screen content returned by tools for injection
470
+ # Include WebSearch and Grep for content injection detection
471
+ settings["hooks"]["PostToolUse"] = [
472
+ {
473
+ "matcher": "Read|WebFetch|Bash|Grep|WebSearch",
474
+ "hooks": [
475
+ {
476
+ "type": "command",
477
+ "command": f"{python_exe} {post_hook_script.resolve()}"
478
+ }
479
+ ]
480
+ }
481
+ ]
482
+
483
+ with open(settings_file, "w") as f:
484
+ json.dump(settings, f, indent=2)
485
+
486
+ console.print(f"\n[green]\u2713[/green] PreToolUse hooks installed to: {target}")
487
+ console.print(f"[green]\u2713[/green] PostToolUse content screening installed to: {target}")
488
+
489
+ # Create Tweek data directory
490
+ tweek_dir = Path("~/.tweek").expanduser()
491
+ tweek_dir.mkdir(parents=True, exist_ok=True)
492
+ console.print(f"[green]\u2713[/green] Tweek data directory: {tweek_dir}")
493
+
494
+ # Create .tweek.yaml in the install directory (per-directory hook control)
495
+ _create_tweek_yaml(install_global)
496
+
497
+ # Deploy self-documenting config templates (skip .tweek.yaml — handled above)
498
+ try:
499
+ from tweek.config.templates import deploy_all_templates
500
+ for name, path, created in deploy_all_templates(global_scope=install_global):
501
+ if created:
502
+ console.print(f"[green]\u2713[/green] {name}: {path}")
503
+ except Exception:
504
+ pass # Template deployment is best-effort
505
+
506
+ # ─────────────────────────────────────────────────────────────
507
+ # Step 6: Install Tweek skill for Claude Code
508
+ # ─────────────────────────────────────────────────────────────
509
+ skill_source = Path(__file__).resolve().parent / "skill_template"
510
+ skill_target = target / "skills" / "tweek"
511
+
512
+ if skill_source.is_dir() and (skill_source / "SKILL.md").exists():
513
+ # Copy skill files to target (overwrite if exists)
514
+ if skill_target.exists():
515
+ shutil.rmtree(skill_target)
516
+ shutil.copytree(skill_source, skill_target)
517
+ console.print(f"[green]\u2713[/green] Tweek skill installed to: {skill_target}")
518
+ console.print(f" [white]Claude now understands Tweek warnings and commands[/white]")
519
+
520
+ # Add whitelist entry for the skill directory in overrides
521
+ try:
522
+ import yaml
523
+
524
+ overrides_path = tweek_dir / "overrides.yaml"
525
+ overrides = {}
526
+ if overrides_path.exists():
527
+ with open(overrides_path) as f:
528
+ overrides = yaml.safe_load(f) or {}
529
+
530
+ whitelist = overrides.get("whitelist", [])
531
+
532
+ # Check if skill path is already whitelisted
533
+ skill_target_str = str(skill_target)
534
+ already_whitelisted = any(
535
+ entry.get("path", "").rstrip("/") == skill_target_str.rstrip("/")
536
+ for entry in whitelist
537
+ if isinstance(entry, dict)
538
+ )
539
+
540
+ if not already_whitelisted:
541
+ whitelist.append({
542
+ "path": skill_target_str,
543
+ "tools": ["Read", "Grep"],
544
+ "reason": "Tweek skill files shipped with package",
545
+ })
546
+ overrides["whitelist"] = whitelist
547
+
548
+ with open(overrides_path, "w") as f:
549
+ yaml.dump(overrides, f, default_flow_style=False, sort_keys=False)
550
+
551
+ console.print(f"[green]\u2713[/green] Skill directory whitelisted in overrides")
552
+
553
+ except ImportError:
554
+ console.print(f"[white]Note: PyYAML not available \u2014 skill whitelist not added to overrides[/white]")
555
+ except Exception as e:
556
+ console.print(f"[white]Warning: Could not update overrides whitelist: {e}[/white]")
557
+ else:
558
+ console.print(f"[white]Tweek skill source not found \u2014 skill not installed[/white]")
559
+ console.print(f" [white]Skill can be installed manually from the tweek repository[/white]")
560
+
561
+ # ─────────────────────────────────────────────────────────────
562
+ # Step 7: Download local classifier model
563
+ # ─────────────────────────────────────────────────────────────
564
+ _download_local_model(quick)
565
+
566
+ # ─────────────────────────────────────────────────────────────
567
+ # Step 8: Security Configuration
568
+ # ─────────────────────────────────────────────────────────────
569
+ cfg = ConfigManager()
570
+
571
+ if preset:
572
+ # Apply preset directly
573
+ cfg.apply_preset(preset)
574
+ console.print(f"\n[green]\u2713[/green] Applied [bold]{preset}[/bold] security preset")
575
+ install_summary["preset"] = preset
576
+
577
+ elif ai_defaults:
578
+ # AI-assisted defaults: detect skills and suggest tiers
579
+ console.print("\n[cyan]Detecting installed skills...[/cyan]")
580
+
581
+ # Try to detect skills from Claude Code config
582
+ detected_skills = []
583
+ claude_settings = Path("~/.claude/settings.json").expanduser()
584
+ if claude_settings.exists():
585
+ try:
586
+ with open(claude_settings) as f:
587
+ claude_config = json.load(f)
588
+ # Look for plugins, skills, or custom hooks
589
+ plugins = claude_config.get("enabledPlugins", {})
590
+ detected_skills.extend(plugins.keys())
591
+ except Exception:
592
+ pass
593
+
594
+ # Also check for common skill directories
595
+ skill_dirs = [
596
+ Path("~/.claude/skills").expanduser(),
597
+ Path("~/.claude/commands").expanduser(),
598
+ ]
599
+ for skill_dir in skill_dirs:
600
+ if skill_dir.exists():
601
+ for item in skill_dir.iterdir():
602
+ if item.is_dir() or item.suffix == ".md":
603
+ detected_skills.append(item.stem)
604
+
605
+ # Find unknown skills
606
+ unknown_skills = cfg.get_unknown_skills(detected_skills)
607
+
608
+ if unknown_skills:
609
+ console.print(f"\n[yellow]Found {len(unknown_skills)} new skills not in config:[/yellow]")
610
+ for skill in unknown_skills[:10]: # Limit display
611
+ console.print(f" \u2022 {skill}")
612
+ if len(unknown_skills) > 10:
613
+ console.print(f" ... and {len(unknown_skills) - 10} more")
614
+
615
+ # Suggest defaults based on skill names
616
+ console.print("\n[cyan]Applying AI-suggested defaults:[/cyan]")
617
+ for skill in unknown_skills:
618
+ # Simple heuristics for tier suggestion
619
+ skill_lower = skill.lower()
620
+ if any(x in skill_lower for x in ["deploy", "publish", "release", "prod"]):
621
+ suggested = SecurityTier.DANGEROUS
622
+ elif any(x in skill_lower for x in ["web", "fetch", "api", "external", "browser"]):
623
+ suggested = SecurityTier.RISKY
624
+ elif any(x in skill_lower for x in ["review", "read", "explore", "search", "list"]):
625
+ suggested = SecurityTier.SAFE
626
+ else:
627
+ suggested = SecurityTier.DEFAULT
628
+
629
+ cfg.set_skill_tier(skill, suggested)
630
+ console.print(f" {skill}: {suggested.value}")
631
+
632
+ console.print(f"\n[green]\u2713[/green] Configured {len(unknown_skills)} skills")
633
+ else:
634
+ console.print("[white]All detected skills already configured[/white]")
635
+
636
+ # Apply cautious preset as base
637
+ cfg.apply_preset("cautious")
638
+ console.print("[green]\u2713[/green] Applied [bold]cautious[/bold] base preset")
639
+ install_summary["preset"] = "cautious (ai-defaults)"
640
+
641
+ elif interactive:
642
+ # Full interactive configuration
643
+ console.print("\n[bold]Security Configuration[/bold]")
644
+ 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")
650
+ console.print()
651
+
652
+ choice = click.prompt("Select", type=click.IntRange(1, 5), default=2)
653
+
654
+ if choice == 1:
655
+ cfg.apply_preset("paranoid")
656
+ console.print("[green]\u2713[/green] Applied paranoid preset")
657
+ install_summary["preset"] = "paranoid"
658
+ elif choice == 2:
659
+ cfg.apply_preset("balanced")
660
+ console.print("[green]\u2713[/green] Applied balanced preset")
661
+ console.print("[white] Clean sessions get fewer prompts; tainted sessions get extra scrutiny[/white]")
662
+ install_summary["preset"] = "balanced"
663
+ elif choice == 3:
664
+ cfg.apply_preset("cautious")
665
+ console.print("[green]\u2713[/green] Applied cautious preset")
666
+ install_summary["preset"] = "cautious"
667
+ elif choice == 4:
668
+ cfg.apply_preset("trusted")
669
+ console.print("[green]\u2713[/green] Applied trusted preset")
670
+ install_summary["preset"] = "trusted"
671
+ else:
672
+ # Custom: ask about key tools
673
+ console.print("\n[bold]Configure key tools:[/bold]")
674
+ console.print("[white](safe/default/risky/dangerous)[/white]\n")
675
+
676
+ for tool in ["Bash", "WebFetch", "Edit"]:
677
+ current = cfg.get_tool_tier(tool)
678
+ new_tier = click.prompt(
679
+ f" {tool}",
680
+ default=current.value,
681
+ type=click.Choice(["safe", "default", "risky", "dangerous"])
682
+ )
683
+ cfg.set_tool_tier(tool, SecurityTier.from_string(new_tier))
684
+
685
+ console.print("[green]\u2713[/green] Custom configuration saved")
686
+ install_summary["preset"] = "custom"
687
+
688
+ else:
689
+ # Default: apply cautious preset silently
690
+ if not cfg.export_config("user"):
691
+ cfg.apply_preset("cautious")
692
+ console.print("\n[green]\u2713[/green] Applied default [bold]cautious[/bold] security preset")
693
+ console.print("[white]Run 'tweek config interactive' to customize[/white]")
694
+ install_summary["preset"] = "cautious"
695
+ else:
696
+ install_summary["preset"] = "existing"
697
+
698
+ # ─────────────────────────────────────────────────────────────
699
+ # Step 9: LLM Review Provider Selection
700
+ # ─────────────────────────────────────────────────────────────
701
+ llm_config = _configure_llm_provider(tweek_dir, interactive, quick)
702
+ install_summary["llm_provider"] = llm_config.get("provider_display", "auto-detect")
703
+ install_summary["llm_model"] = llm_config.get("model_display")
704
+
705
+ # ─────────────────────────────────────────────────────────────
706
+ # Step 10: Scan for .env files (moved after security config)
707
+ # ─────────────────────────────────────────────────────────────
708
+ if not skip_env_scan:
709
+ console.print("\n[cyan]Scanning for .env files with credentials...[/cyan]\n")
710
+
711
+ env_files = scan_for_env_files()
712
+
713
+ if env_files:
714
+ table = Table(title="Found .env Files")
715
+ table.add_column("#", style="white")
716
+ table.add_column("Path")
717
+ table.add_column("Credentials", justify="right")
718
+
719
+ for i, (path, keys) in enumerate(env_files, 1):
720
+ # Show relative path if possible
721
+ try:
722
+ display_path = path.relative_to(Path.cwd())
723
+ except ValueError:
724
+ display_path = path
725
+
726
+ table.add_row(str(i), str(display_path), str(len(keys)))
727
+
728
+ console.print(table)
729
+
730
+ console.print("\n[yellow]Migrate these credentials to secure storage?[/yellow] ", end="")
731
+ if click.confirm(""):
732
+ from tweek.vault import get_vault, VAULT_AVAILABLE
733
+ if not VAULT_AVAILABLE:
734
+ console.print("[red]\u2717[/red] Vault not available. Install keyring: pip install keyring")
735
+ else:
736
+ vault = get_vault()
737
+
738
+ for path, keys in env_files:
739
+ try:
740
+ display_path = path.relative_to(Path.cwd())
741
+ except ValueError:
742
+ display_path = path
743
+
744
+ console.print(f"\n[cyan]{display_path}[/cyan]")
745
+
746
+ # Suggest skill name from directory
747
+ suggested_skill = path.parent.name
748
+ if suggested_skill in (".", "", "~"):
749
+ suggested_skill = "default"
750
+
751
+ skill = click.prompt(
752
+ " Skill name",
753
+ default=suggested_skill
754
+ )
755
+
756
+ # Show dry-run preview
757
+ console.print(f" [white]Preview - credentials to migrate:[/white]")
758
+ for key in keys:
759
+ console.print(f" \u2022 {key}")
760
+
761
+ if click.confirm(f" Migrate {len(keys)} credentials to '{skill}'?"):
762
+ try:
763
+ from tweek.vault import migrate_env_to_vault
764
+ results = migrate_env_to_vault(path, skill, vault, dry_run=False)
765
+ successful = sum(1 for _, s in results if s)
766
+ total = len(results)
767
+ console.print(f" [green]\u2713[/green] Migrated {successful} credentials")
768
+
769
+ if successful == total and path.exists():
770
+ # All credentials migrated — offer to remove the .env file
771
+ if click.confirm(f" Remove {display_path}? (credentials are now in the vault)"):
772
+ path.unlink()
773
+ console.print(f" [green]\u2713[/green] Removed {display_path}")
774
+ else:
775
+ console.print(f" [yellow]\u26a0[/yellow] {display_path} still contains plaintext credentials")
776
+ elif successful < total:
777
+ failed = total - successful
778
+ console.print(f" [yellow]\u26a0[/yellow] {failed} credential(s) failed to migrate \u2014 keeping {display_path}")
779
+ except Exception as e:
780
+ console.print(f" [red]\u2717[/red] Migration failed: {e}")
781
+ else:
782
+ console.print(f" [white]Skipped[/white]")
783
+ else:
784
+ console.print("[white]No .env files with credentials found[/white]")
785
+
786
+ # ─────────────────────────────────────────────────────────────
787
+ # Step 11: Linux: Prompt for firejail installation
788
+ # ─────────────────────────────────────────────────────────────
789
+ if IS_LINUX:
790
+ caps = get_capabilities()
791
+ if not caps.sandbox_available:
792
+ if with_sandbox or (interactive and not quick):
793
+ from tweek.sandbox.linux import prompt_install_firejail
794
+ prompt_install_firejail(console)
795
+ else:
796
+ console.print("\n[yellow]Note:[/yellow] Sandbox (firejail) not installed.")
797
+ console.print(f"[white]Install with: {caps.sandbox_install_hint}[/white]")
798
+ console.print("[white]Or run 'tweek install --with-sandbox' to install now[/white]")
799
+
800
+ # ─────────────────────────────────────────────────────────────
801
+ # Step 12: Configure Tweek proxy if override was enabled
802
+ # ─────────────────────────────────────────────────────────────
803
+ if proxy_override_enabled:
804
+ try:
805
+ import yaml
806
+ from tweek.proxy import TWEEK_DEFAULT_PORT
807
+
808
+ proxy_config_path = tweek_dir / "config.yaml"
809
+
810
+ # Load existing config or create new
811
+ if proxy_config_path.exists():
812
+ with open(proxy_config_path) as f:
813
+ tweek_config = yaml.safe_load(f) or {}
814
+ else:
815
+ tweek_config = {}
816
+
817
+ # Enable proxy with override settings
818
+ tweek_config["proxy"] = tweek_config.get("proxy", {})
819
+ tweek_config["proxy"]["enabled"] = True
820
+ tweek_config["proxy"]["port"] = TWEEK_DEFAULT_PORT
821
+ tweek_config["proxy"]["override_openclaw"] = True
822
+ tweek_config["proxy"]["auto_start"] = False # User must explicitly start
823
+
824
+ with open(proxy_config_path, "w") as f:
825
+ yaml.dump(tweek_config, f, default_flow_style=False)
826
+
827
+ console.print("\n[green]\u2713[/green] Proxy override configured")
828
+ console.print(f" [white]Config saved to: {proxy_config_path}[/white]")
829
+ console.print(" [yellow]Run 'tweek proxy start' to begin intercepting API calls[/yellow]")
830
+ install_summary["proxy"] = True
831
+ except Exception as e:
832
+ console.print(f"\n[yellow]Warning: Could not save proxy config: {e}[/yellow]")
833
+
834
+ # ─────────────────────────────────────────────────────────────
835
+ # Step 13: Post-install verification and summary
836
+ # ─────────────────────────────────────────────────────────────
837
+ _print_install_summary(install_summary, target, tweek_dir, proxy_override_enabled)
838
+
839
+ # ─────────────────────────────────────────────────────────────
840
+ # Step 14: Scan for other AI tools and offer protection
841
+ # ─────────────────────────────────────────────────────────────
842
+ if not quick:
843
+ _offer_mcp_protection()
844
+
845
+
846
+
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
+
860
+ try:
861
+ all_tools = _detect_all_tools()
862
+ except Exception:
863
+ return
864
+
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
+ ]
870
+
871
+ if not unprotected:
872
+ return
873
+
874
+ console.print("\n[bold]Other AI tools detected[/bold]")
875
+ console.print("Tweek can also protect these tools via MCP server integration:\n")
876
+
877
+ for tool_id, label in unprotected:
878
+ if click.confirm(f" Protect {label}?", default=True):
879
+ try:
880
+ _protect_mcp_client(tool_id)
881
+ except Exception as e:
882
+ console.print(f" [yellow]Could not configure {label}: {e}[/yellow]")
883
+ else:
884
+ console.print(f" [dim]Skipped {label}[/dim]")
885
+
886
+ console.print()
887
+
888
+
889
+ def _create_tweek_yaml(install_global: bool) -> None:
890
+ """Create .tweek.yaml in the project directory with hooks enabled.
891
+
892
+ This file controls whether PreToolUse and PostToolUse hooks run in
893
+ this directory. Created with both enabled by default — the user must
894
+ manually set them to false to disable protection.
895
+
896
+ For global installs, creates in the home directory so it applies
897
+ as the fallback when no project-level .tweek.yaml exists.
898
+ """
899
+ import yaml
900
+
901
+ if install_global:
902
+ tweek_yaml_path = Path("~/.tweek.yaml").expanduser()
903
+ else:
904
+ tweek_yaml_path = Path.cwd() / ".tweek.yaml"
905
+
906
+ # Don't overwrite if it already exists (user may have customized it)
907
+ if tweek_yaml_path.exists():
908
+ return
909
+
910
+ config = {
911
+ "hooks": {
912
+ "pre_tool_use": True,
913
+ "post_tool_use": True,
914
+ },
915
+ }
916
+
917
+ try:
918
+ with open(tweek_yaml_path, "w") as f:
919
+ f.write("# Tweek per-directory hook configuration\n")
920
+ f.write("# Set to false to disable screening in this directory.\n")
921
+ f.write("# This file is protected — only a human can edit it.\n")
922
+ yaml.dump(config, f, default_flow_style=False)
923
+ console.print(f"[green]\u2713[/green] Hook config: {tweek_yaml_path}")
924
+ except Exception as e:
925
+ console.print(f"[yellow]Warning: Could not create {tweek_yaml_path}: {e}[/yellow]")
926
+
927
+
928
+ def _check_python_version(console: Console, quick: bool) -> None:
929
+ """Show Python version and warn about path mismatches.
930
+
931
+ The hard version gate lives in scripts/install.sh. This function only
932
+ reports the running Python and warns if the system python3 differs
933
+ (which affects hook execution).
934
+ """
935
+ current = sys.version_info[:2]
936
+ console.print(f"[green]\u2713[/green] Python {current[0]}.{current[1]} ({sys.executable})")
937
+
938
+ # Warn if system python3 differs from install Python
939
+ # This matters because hooks run via the Python path stored in settings.json
940
+ system_python3 = shutil.which("python3")
941
+ if system_python3:
942
+ try:
943
+ resolved_install = Path(sys.executable).resolve()
944
+ resolved_system = Path(system_python3).resolve()
945
+
946
+ if resolved_install != resolved_system:
947
+ console.print(f"[white] Note: system python3 is {resolved_system}[/white]")
948
+ console.print(f"[white] Hooks will use {resolved_install} (the Python running this install)[/white]")
949
+ except (OSError, ValueError):
950
+ pass
951
+ else:
952
+ if not quick:
953
+ console.print("[yellow] Note: python3 not found on PATH[/yellow]")
954
+ console.print(f"[white] Hooks will use {sys.executable} directly[/white]")
955
+
956
+
957
+ def _configure_llm_provider(tweek_dir: Path, interactive: bool, quick: bool) -> dict:
958
+ """Configure LLM review provider during installation.
959
+
960
+ Returns a dict with provider configuration details for the install summary.
961
+ """
962
+ import os
963
+ import yaml
964
+
965
+ result = {
966
+ "provider": "auto",
967
+ "model": "auto",
968
+ "base_url": None,
969
+ "api_key_env": None,
970
+ "provider_display": None,
971
+ "model_display": None,
972
+ }
973
+
974
+ # Provider display names and default models
975
+ provider_defaults = {
976
+ "anthropic": ("Anthropic", "claude-3-5-haiku-latest", "ANTHROPIC_API_KEY"),
977
+ "openai": ("OpenAI", "gpt-4o-mini", "OPENAI_API_KEY"),
978
+ "google": ("Google", "gemini-2.0-flash", "GOOGLE_API_KEY"),
979
+ }
980
+
981
+ if not quick:
982
+ # Check local model availability for menu display
983
+ local_model_ready = False
984
+ local_model_name = None
985
+ try:
986
+ from tweek.security.local_model import LOCAL_MODEL_AVAILABLE
987
+ from tweek.security.model_registry import is_model_installed, get_default_model_name
988
+
989
+ if LOCAL_MODEL_AVAILABLE:
990
+ local_model_name = get_default_model_name()
991
+ local_model_ready = is_model_installed(local_model_name)
992
+ except ImportError:
993
+ pass
994
+
995
+ console.print()
996
+ console.print("[bold]Security Screening Provider[/bold] (Layer 3 \u2014 semantic analysis)")
997
+ console.print()
998
+ console.print(" Tweek can analyze suspicious commands for deeper security screening.")
999
+ console.print(" A local on-device model is preferred (no API key needed), with")
1000
+ console.print(" optional cloud LLM escalation for uncertain cases.")
1001
+ console.print()
1002
+ console.print(" [cyan]1.[/cyan] Auto-detect (recommended)")
1003
+ if local_model_ready:
1004
+ console.print(f" [white]Local model installed ({local_model_name}) \u2014 will use it first[/white]")
1005
+ else:
1006
+ console.print(" [white]Uses first available: Local model > Google > OpenAI > Anthropic[/white]")
1007
+ console.print(" [cyan]2.[/cyan] Anthropic (Claude Haiku) [yellow]— billed separately from Max/Pro plans[/yellow]")
1008
+ console.print(" [cyan]3.[/cyan] OpenAI (GPT-4o-mini)")
1009
+ console.print(" [cyan]4.[/cyan] Google (Gemini 2.0 Flash) [green]— free tier available[/green]")
1010
+ console.print(" [cyan]5.[/cyan] Custom endpoint (Ollama, LM Studio, Together, Groq, etc.)")
1011
+ console.print(" [cyan]6.[/cyan] Disable screening")
1012
+ if not local_model_ready:
1013
+ console.print()
1014
+ console.print(" [white]Tip: Run 'tweek model download' to install the local model[/white]")
1015
+ console.print(" [white] (on-device, no API key, ~45MB download)[/white]")
1016
+ console.print()
1017
+
1018
+ choice = click.prompt("Select", type=click.IntRange(1, 6), default=1)
1019
+
1020
+ if choice == 1:
1021
+ result["provider"] = "auto"
1022
+ elif choice == 2:
1023
+ console.print()
1024
+ console.print("[yellow] Note: Anthropic API keys are billed per-token, separately from[/yellow]")
1025
+ console.print("[yellow] Claude Pro/Max subscriptions. Consider Google Gemini (option 4)[/yellow]")
1026
+ console.print("[yellow] for a free tier alternative.[/yellow]")
1027
+ if not click.confirm(" Continue with Anthropic?", default=True):
1028
+ result["provider"] = "google"
1029
+ result["model"] = "gemini-2.0-flash"
1030
+ console.print(" Switched to Google Gemini 2.0 Flash")
1031
+ else:
1032
+ result["provider"] = "anthropic"
1033
+ result["model"] = "claude-3-5-haiku-latest"
1034
+ elif choice == 3:
1035
+ result["provider"] = "openai"
1036
+ result["model"] = "gpt-4o-mini"
1037
+ elif choice == 4:
1038
+ result["provider"] = "google"
1039
+ result["model"] = "gemini-2.0-flash"
1040
+ elif choice == 5:
1041
+ # Custom endpoint configuration
1042
+ console.print()
1043
+ console.print("[bold]Custom Endpoint Configuration[/bold]")
1044
+ console.print("[white]Most local servers (Ollama, LM Studio, vLLM) and cloud providers[/white]")
1045
+ console.print("[white](Together, Groq, Mistral) expose an OpenAI-compatible API.[/white]")
1046
+ console.print()
1047
+
1048
+ result["provider"] = "openai"
1049
+ result["base_url"] = click.prompt(
1050
+ " Base URL",
1051
+ default="http://localhost:11434/v1",
1052
+ )
1053
+ result["model"] = click.prompt(
1054
+ " Model name",
1055
+ default="llama3.2",
1056
+ )
1057
+ api_key_env = click.prompt(
1058
+ " API key env var (blank for local/no auth)",
1059
+ default="",
1060
+ )
1061
+ if api_key_env:
1062
+ result["api_key_env"] = api_key_env
1063
+ console.print()
1064
+ elif choice == 6:
1065
+ result["provider"] = "disabled"
1066
+ console.print("[white]Screening disabled. Pattern matching and other layers remain active.[/white]")
1067
+ # else: quick mode — leave as auto
1068
+
1069
+ # Resolve display names for summary
1070
+ if result["provider"] == "auto":
1071
+ # Run auto-detection to show what was actually selected
1072
+ detected = _detect_llm_provider()
1073
+ if detected:
1074
+ result["provider_display"] = detected["name"]
1075
+ result["model_display"] = detected["model"]
1076
+ else:
1077
+ result["provider_display"] = "disabled (no provider found)"
1078
+ result["model_display"] = None
1079
+ elif result["provider"] == "disabled":
1080
+ result["provider_display"] = "disabled"
1081
+ result["model_display"] = None
1082
+ elif result["provider"] in provider_defaults:
1083
+ display_name, default_model, _ = provider_defaults[result["provider"]]
1084
+ result["provider_display"] = display_name
1085
+ result["model_display"] = result["model"] if result["model"] != "auto" else default_model
1086
+ else:
1087
+ result["provider_display"] = result["provider"]
1088
+ result["model_display"] = result["model"]
1089
+
1090
+ # If custom endpoint, show base_url in display
1091
+ if result.get("base_url"):
1092
+ result["provider_display"] = f"OpenAI-compatible ({result['base_url']})"
1093
+
1094
+ # Validate connectivity if provider was explicitly selected (not auto, not disabled)
1095
+ if result["provider"] not in ("auto", "disabled") and not quick:
1096
+ _validate_llm_provider(result)
1097
+
1098
+ # Warn if no LLM provider was found (auto or quick mode)
1099
+ if result.get("provider_display") and "disabled" in (result.get("provider_display") or "").lower():
1100
+ _warn_no_llm_provider(quick)
1101
+
1102
+ # Save LLM config to ~/.tweek/config.yaml
1103
+ # Uses append_active_section to preserve template comments instead of yaml.dump
1104
+ if result["provider"] != "auto" or result.get("base_url"):
1105
+ try:
1106
+ from tweek.config.templates import append_active_section
1107
+ config_path = tweek_dir / "config.yaml"
1108
+
1109
+ # Build the active YAML section
1110
+ lines = ["llm_review:"]
1111
+ if result["provider"] == "disabled":
1112
+ lines.append(" enabled: false")
1113
+ else:
1114
+ lines.append(" enabled: true")
1115
+ lines.append(f" provider: {result['provider']}")
1116
+ if result["model"] != "auto":
1117
+ lines.append(f" model: {result['model']}")
1118
+ if result.get("base_url"):
1119
+ lines.append(f" base_url: {result['base_url']}")
1120
+ if result.get("api_key_env"):
1121
+ lines.append(f" api_key_env: {result['api_key_env']}")
1122
+
1123
+ append_active_section(config_path, "\n".join(lines))
1124
+
1125
+ if result["provider"] == "disabled":
1126
+ console.print("[green]\u2713[/green] LLM review disabled in config")
1127
+ else:
1128
+ console.print(f"[green]\u2713[/green] LLM provider configured: {result['provider_display']}")
1129
+ except Exception as e:
1130
+ console.print(f"[white]Warning: Could not save LLM config: {e}[/white]")
1131
+ else:
1132
+ if result["provider_display"] and "disabled" not in (result["provider_display"] or ""):
1133
+ console.print(f"[green]\u2713[/green] LLM provider: {result['provider_display']} ({result.get('model_display', 'auto')})")
1134
+ elif result["provider"] == "auto":
1135
+ console.print(f"[green]\u2713[/green] LLM provider: {result['provider_display']}")
1136
+
1137
+ return result
1138
+
1139
+
1140
+ def _warn_no_llm_provider(quick: bool) -> None:
1141
+ """Warn user when no LLM provider is available for semantic analysis.
1142
+
1143
+ This runs in auto and quick modes when auto-detection finds no API key.
1144
+ Pattern matching (262 patterns) still works, but the deeper semantic
1145
+ analysis layer (Layer 3) will be inactive.
1146
+ """
1147
+ console.print()
1148
+ console.print("[yellow] LLM review is not available.[/yellow]")
1149
+ console.print(" Pattern matching is still active, but LLM semantic analysis requires an API key.")
1150
+ console.print()
1151
+ console.print(" [bold]Recommended:[/bold] Google Gemini (free tier available)")
1152
+ console.print(" 1. Get a free key at: https://aistudio.google.com/apikey")
1153
+ console.print(" 2. Run: [cyan]tweek config edit env[/cyan]")
1154
+ console.print(" Uncomment the GOOGLE_API_KEY line and paste your key.")
1155
+ console.print()
1156
+ console.print(" [yellow]Note:[/yellow] Anthropic API keys are billed separately from Claude Pro/Max plans.")
1157
+ console.print(" Google Gemini's free tier is recommended for most users.")
1158
+ console.print()
1159
+ console.print(" All provider options are documented in ~/.tweek/.env")
1160
+
1161
+
1162
+ def _detect_llm_provider():
1163
+ """Detect which LLM provider is available based on environment.
1164
+
1165
+ Priority: Local ONNX model > Anthropic > OpenAI > Google.
1166
+ Returns dict with 'name' and 'model', or None if none available.
1167
+ """
1168
+ import os
1169
+
1170
+ # Check local ONNX model first (no API key needed)
1171
+ try:
1172
+ from tweek.security.local_model import LOCAL_MODEL_AVAILABLE
1173
+ from tweek.security.model_registry import is_model_installed, get_default_model_name
1174
+
1175
+ if LOCAL_MODEL_AVAILABLE:
1176
+ default_model = get_default_model_name()
1177
+ if is_model_installed(default_model):
1178
+ return {"name": "Local model", "model": default_model, "env_var": None}
1179
+ except ImportError:
1180
+ pass
1181
+
1182
+ # Cloud providers — Google first (free tier), then others (pay-per-token)
1183
+ 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"),
1189
+ ]
1190
+
1191
+ for env_var, name, model in checks:
1192
+ if os.environ.get(env_var):
1193
+ return {"name": name, "model": model, "env_var": env_var}
1194
+
1195
+ return None
1196
+
1197
+
1198
+ def _validate_llm_provider(llm_config: dict) -> None:
1199
+ """Validate LLM provider connectivity after selection.
1200
+
1201
+ Checks if the required API key is available and attempts a quick
1202
+ availability check. Offers fallback options if validation fails.
1203
+ """
1204
+ import os
1205
+
1206
+ provider = llm_config.get("provider", "auto")
1207
+
1208
+ # Map provider to expected env vars
1209
+ env_var_map = {
1210
+ "anthropic": ["ANTHROPIC_API_KEY"],
1211
+ "openai": ["OPENAI_API_KEY"],
1212
+ "google": ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
1213
+ }
1214
+
1215
+ # For custom endpoints with api_key_env, check that env var
1216
+ if llm_config.get("api_key_env"):
1217
+ expected_vars = [llm_config["api_key_env"]]
1218
+ elif llm_config.get("base_url"):
1219
+ # Local endpoints (Ollama etc.) don't need an API key
1220
+ console.print(f" [white]Checking endpoint: {llm_config['base_url']}[/white]")
1221
+ try:
1222
+ from tweek.security.llm_reviewer import resolve_provider
1223
+ test_provider = resolve_provider(
1224
+ provider="openai",
1225
+ model=llm_config.get("model", "auto"),
1226
+ base_url=llm_config["base_url"],
1227
+ timeout=3.0,
1228
+ )
1229
+ if test_provider and test_provider.is_available():
1230
+ console.print(f" [green]\u2713[/green] Endpoint reachable")
1231
+ else:
1232
+ console.print(f" [yellow]\u26a0[/yellow] Could not verify endpoint")
1233
+ console.print(f" [white]Tweek will try this endpoint at runtime[/white]")
1234
+ except Exception:
1235
+ console.print(f" [yellow]\u26a0[/yellow] Could not verify endpoint")
1236
+ console.print(f" [white]Tweek will try this endpoint at runtime[/white]")
1237
+ return
1238
+ else:
1239
+ expected_vars = env_var_map.get(provider, [])
1240
+
1241
+ if not expected_vars:
1242
+ return
1243
+
1244
+ # Check if any expected env var is set in environment or vault
1245
+ found_key = False
1246
+ key_source = None
1247
+
1248
+ for var in expected_vars:
1249
+ if os.environ.get(var):
1250
+ found_key = True
1251
+ key_source = "environment"
1252
+ console.print(f" [green]\u2713[/green] {var} found in environment")
1253
+ break
1254
+
1255
+ # Check vault if not in environment
1256
+ if not found_key:
1257
+ try:
1258
+ from tweek.vault import get_vault, VAULT_AVAILABLE
1259
+ if VAULT_AVAILABLE and get_vault:
1260
+ vault = get_vault()
1261
+ for var in expected_vars:
1262
+ if vault.get("tweek-security", var):
1263
+ found_key = True
1264
+ key_source = "vault"
1265
+ console.print(f" [green]\u2713[/green] {var} found in vault")
1266
+ break
1267
+ except Exception:
1268
+ pass
1269
+
1270
+ if not found_key:
1271
+ var_list = " or ".join(expected_vars)
1272
+ console.print(f" [yellow]\u26a0[/yellow] {var_list} not found")
1273
+ console.print()
1274
+
1275
+ # Offer to store the key in the vault
1276
+ store_key = click.confirm(" Enter your API key now? (stored securely in system vault)", default=True)
1277
+ if store_key:
1278
+ key_name = expected_vars[0]
1279
+ api_key_value = click.prompt(f" {key_name}", hide_input=True)
1280
+ if api_key_value:
1281
+ try:
1282
+ from tweek.vault import get_vault, VAULT_AVAILABLE
1283
+ if VAULT_AVAILABLE and get_vault:
1284
+ vault = get_vault()
1285
+ vault.store("tweek-security", key_name, api_key_value)
1286
+ console.print(f" [green]\u2713[/green] {key_name} stored in vault")
1287
+ found_key = True
1288
+ else:
1289
+ console.print(f" [yellow]\u26a0[/yellow] Vault not available. Set {key_name} in your shell profile instead.")
1290
+ except Exception as e:
1291
+ console.print(f" [yellow]\u26a0[/yellow] Could not store in vault: {e}")
1292
+ console.print(f" [white]Set {key_name} in your shell profile instead.[/white]")
1293
+
1294
+ if not found_key:
1295
+ console.print(f" [white]LLM review will be disabled until a key is available.[/white]")
1296
+
1297
+ # Offer fallback
1298
+ console.print()
1299
+ fallback = click.prompt(
1300
+ " Continue with this provider or switch to auto-detect?",
1301
+ type=click.Choice(["continue", "auto"]),
1302
+ default="continue",
1303
+ )
1304
+ if fallback == "auto":
1305
+ llm_config["provider"] = "auto"
1306
+ detected = _detect_llm_provider()
1307
+ if detected:
1308
+ llm_config["provider_display"] = detected["name"]
1309
+ llm_config["model_display"] = detected["model"]
1310
+ console.print(f" [green]\u2713[/green] Switched to auto-detect: {detected['name']}")
1311
+ else:
1312
+ llm_config["provider_display"] = "disabled (no API key found)"
1313
+ llm_config["model_display"] = None
1314
+ console.print(f" [white]No API keys found \u2014 LLM review will be disabled[/white]")
1315
+
1316
+
1317
+ def _print_install_summary(
1318
+ summary: dict,
1319
+ target: Path,
1320
+ tweek_dir: Path,
1321
+ proxy_override_enabled: bool,
1322
+ ) -> None:
1323
+ """Print post-install verification and summary."""
1324
+ from tweek.platform import get_capabilities
1325
+
1326
+ console.print()
1327
+ console.print("[green]Installation complete![/green]")
1328
+ console.print()
1329
+
1330
+ # Verification checks
1331
+ console.print("[bold]Verification[/bold]")
1332
+
1333
+ # Check hooks are installed and Python path is valid
1334
+ settings_file = target / "settings.json"
1335
+ hook_python = None
1336
+ if settings_file.exists():
1337
+ try:
1338
+ import json
1339
+ with open(settings_file) as f:
1340
+ settings = json.load(f)
1341
+ hooks = settings.get("hooks", {})
1342
+ has_pre = "PreToolUse" in hooks
1343
+ has_post = "PostToolUse" in hooks
1344
+ if has_pre and has_post:
1345
+ console.print(" [green]\u2713[/green] PreToolUse + PostToolUse hooks active")
1346
+ # Extract Python path from hook command to verify it exists
1347
+ try:
1348
+ cmd = hooks["PreToolUse"][0]["hooks"][0]["command"]
1349
+ hook_python = cmd.split()[0]
1350
+ if Path(hook_python).exists():
1351
+ console.print(f" [green]\u2713[/green] Hook Python: {hook_python}")
1352
+ else:
1353
+ console.print(f" [yellow]\u26a0[/yellow] Hook Python not found: {hook_python}")
1354
+ console.print(f" [white]Run 'tweek install' again if Python was reinstalled[/white]")
1355
+ except (IndexError, KeyError):
1356
+ pass
1357
+ elif has_pre:
1358
+ console.print(" [green]\u2713[/green] PreToolUse hook active")
1359
+ console.print(" [yellow]\u26a0[/yellow] PostToolUse hook missing")
1360
+ else:
1361
+ console.print(" [yellow]\u26a0[/yellow] Hooks may not be installed correctly")
1362
+ except Exception:
1363
+ console.print(" [yellow]\u26a0[/yellow] Could not verify hook installation")
1364
+ else:
1365
+ console.print(" [yellow]\u26a0[/yellow] Settings file not found")
1366
+
1367
+ # Check pattern database
1368
+ patterns_file = Path(__file__).resolve().parent / "config" / "patterns.yaml"
1369
+ pattern_count = 0
1370
+ if patterns_file.exists():
1371
+ try:
1372
+ import yaml
1373
+ with open(patterns_file) as f:
1374
+ pdata = yaml.safe_load(f) or {}
1375
+ pattern_count = len(pdata.get("patterns", []))
1376
+ console.print(f" [green]\u2713[/green] Pattern database loaded ({pattern_count} patterns)")
1377
+ except Exception:
1378
+ console.print(" [yellow]\u26a0[/yellow] Could not load pattern database")
1379
+ else:
1380
+ console.print(" [yellow]\u26a0[/yellow] Pattern database not found")
1381
+
1382
+ # LLM reviewer status
1383
+ llm_display = summary.get("llm_provider", "auto-detect")
1384
+ llm_model = summary.get("llm_model")
1385
+ if llm_model:
1386
+ console.print(f" [green]\u2713[/green] LLM reviewer: {llm_display} ({llm_model})")
1387
+ elif llm_display and "disabled" not in llm_display:
1388
+ console.print(f" [green]\u2713[/green] LLM reviewer: {llm_display}")
1389
+ else:
1390
+ console.print(f" [white]\u25cb[/white] LLM reviewer: {llm_display}")
1391
+
1392
+ # Sandbox status
1393
+ caps = get_capabilities()
1394
+ if caps.sandbox_available:
1395
+ console.print(f" [green]\u2713[/green] Sandbox: {caps.sandbox_tool}")
1396
+ else:
1397
+ console.print(f" [white]\u25cb[/white] Sandbox: not available ({caps.platform.value})")
1398
+
1399
+ # Summary table
1400
+ console.print()
1401
+ console.print("[bold]Summary[/bold]")
1402
+
1403
+ scope_display = summary.get("scope", "project")
1404
+ if scope_display == "project":
1405
+ scope_display = f"project ({target})"
1406
+ elif scope_display == "global":
1407
+ scope_display = f"global (~/.claude/)"
1408
+
1409
+ py_ver = f"{sys.version_info[0]}.{sys.version_info[1]}.{sys.version_info[2]}"
1410
+ console.print(f" Python: {py_ver} ({sys.executable})")
1411
+ console.print(f" Scope: {scope_display}")
1412
+ console.print(f" Preset: {summary.get('preset', 'cautious')}")
1413
+
1414
+ llm_summary = llm_display
1415
+ if llm_model:
1416
+ llm_summary = f"{llm_display} ({llm_model})"
1417
+ console.print(f" LLM: {llm_summary}")
1418
+
1419
+ console.print(f" Patterns: {pattern_count}")
1420
+ console.print(f" Sandbox: {'available' if caps.sandbox_available else 'not available'}")
1421
+ console.print(f" Proxy: {'configured' if proxy_override_enabled else 'not configured'}")
1422
+
1423
+ # Scope-specific guidance
1424
+ scope = summary.get("scope", "project")
1425
+ if scope == "project":
1426
+ console.print()
1427
+ console.print("[white]This installation protects only the current project.[/white]")
1428
+ console.print("[white]To protect all projects on this machine, run:[/white]")
1429
+ console.print("[bold] tweek protect claude-code --global[/bold]")
1430
+ elif scope == "global":
1431
+ console.print()
1432
+ console.print("[white]This installation protects all projects globally.[/white]")
1433
+ console.print("[white]To protect only a specific project instead, run from that directory:[/white]")
1434
+ console.print("[bold] tweek protect claude-code[/bold]")
1435
+
1436
+ # Next steps
1437
+ console.print()
1438
+ console.print("[white]Next steps:[/white]")
1439
+ console.print("[white] tweek doctor \u2014 Verify installation[/white]")
1440
+ console.print("[white] tweek update \u2014 Get latest attack patterns[/white]")
1441
+ console.print("[white] tweek configure \u2014 Tune LLM, vault, proxy, sandbox[/white]")
1442
+ console.print("[white] tweek config list \u2014 See security settings[/white]")
1443
+ if proxy_override_enabled:
1444
+ console.print("[white] tweek proxy start \u2014 Enable API interception[/white]")
1445
+
1446
+
1447
+ # ---------------------------------------------------------------------------
1448
+ # Install command (absorbs quickstart)
1449
+ # ---------------------------------------------------------------------------
1450
+
1451
+ @click.command(
1452
+ epilog="""\b
1453
+ Examples:
1454
+ tweek install Interactive setup wizard
1455
+ tweek install --scope global Install globally (all projects)
1456
+ tweek install --scope project Install for current project only
1457
+ tweek install --preset paranoid Apply paranoid security preset
1458
+ tweek install --quick Zero-prompt install with defaults
1459
+ """
1460
+ )
1461
+ @click.option("--scope", type=click.Choice(["global", "project", "both"]),
1462
+ default=None, help="Installation scope (interactive if not specified)")
1463
+ @click.option("--preset", type=click.Choice(["paranoid", "cautious", "balanced", "trusted"]),
1464
+ default=None, help="Security preset (interactive if not specified)")
1465
+ @click.option("--quick", is_flag=True,
1466
+ help="Non-interactive install with cautious defaults")
1467
+ @click.option("--backup/--no-backup", default=True,
1468
+ help="Backup existing hooks before installation")
1469
+ @click.option("--skip-env-scan", is_flag=True,
1470
+ help="Skip scanning for .env files to migrate")
1471
+ @click.option("--interactive", "-i", is_flag=True,
1472
+ help="Interactively configure security settings")
1473
+ @click.option("--ai-defaults", is_flag=True,
1474
+ help="Let AI suggest default settings based on detected skills")
1475
+ @click.option("--with-sandbox", is_flag=True,
1476
+ help="Prompt to install sandbox tool if not available (Linux only)")
1477
+ @click.option("--force-proxy", is_flag=True,
1478
+ help="Force Tweek proxy to override existing proxy configurations")
1479
+ @click.option("--skip-proxy-check", is_flag=True,
1480
+ help="Skip checking for existing proxy configurations")
1481
+ def install(scope, preset, quick, backup, skip_env_scan, interactive, ai_defaults, with_sandbox, force_proxy, skip_proxy_check):
1482
+ """Install Tweek security on your system.
1483
+
1484
+ Sets up hooks, applies a security preset, verifies credential vault,
1485
+ and offers optional MCP proxy configuration.
1486
+
1487
+ This is the full onboarding wizard. For tool-specific protection,
1488
+ use 'tweek protect [tool]' instead.
1489
+ """
1490
+ from tweek.cli_helpers import print_success, print_warning, spinner
1491
+
1492
+ if quick:
1493
+ # Quick mode: just install hooks with defaults
1494
+ install_global = scope == "global" if scope else True
1495
+ _install_claude_code_hooks(
1496
+ install_global=install_global,
1497
+ dev_test=False,
1498
+ backup=backup,
1499
+ skip_env_scan=True,
1500
+ interactive=False,
1501
+ preset=preset or "balanced",
1502
+ ai_defaults=False,
1503
+ with_sandbox=False,
1504
+ force_proxy=force_proxy,
1505
+ skip_proxy_check=True,
1506
+ quick=True,
1507
+ )
1508
+ return
1509
+
1510
+ # Full wizard mode
1511
+ console.print(TWEEK_BANNER, style="cyan")
1512
+ console.print("[bold]Welcome to Tweek![/bold]")
1513
+ console.print()
1514
+ console.print("This wizard will help you set up Tweek step by step.")
1515
+ console.print(" 1. Install hooks")
1516
+ console.print(" 2. Choose a security preset")
1517
+ console.print(" 3. Download classifier model")
1518
+ console.print(" 4. Verify credential vault")
1519
+ console.print(" 5. Optional MCP proxy")
1520
+ console.print()
1521
+
1522
+ # Step 1: Install hooks
1523
+ console.print("[bold cyan]Step 1/5: Hook Installation[/bold cyan]")
1524
+ if scope is None:
1525
+ scope_choice = click.prompt(
1526
+ "Where should Tweek protect?",
1527
+ type=click.Choice(["global", "project", "both"]),
1528
+ default="global",
1529
+ )
1530
+ else:
1531
+ scope_choice = scope
1532
+
1533
+ scopes = ["global", "project"] if scope_choice == "both" else [scope_choice]
1534
+ for s in scopes:
1535
+ try:
1536
+ _quickstart_install_hooks(s)
1537
+ print_success(f"Hooks installed ({s})")
1538
+ except Exception as e:
1539
+ print_warning(f"Could not install hooks ({s}): {e}")
1540
+ console.print()
1541
+
1542
+ # Step 2: Security preset
1543
+ console.print("[bold cyan]Step 2/5: Security Preset[/bold cyan]")
1544
+ 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")
1549
+ console.print()
1550
+
1551
+ preset_choice = click.prompt(
1552
+ "Select preset",
1553
+ type=click.Choice(["1", "2", "3", "4"]),
1554
+ default="2",
1555
+ )
1556
+ preset_map = {"1": "paranoid", "2": "balanced", "3": "cautious", "4": "trusted"}
1557
+ preset_name = preset_map[preset_choice]
1558
+ else:
1559
+ preset_name = preset
1560
+
1561
+ try:
1562
+ from tweek.config.manager import ConfigManager
1563
+ cfg = ConfigManager()
1564
+ cfg.apply_preset(preset_name)
1565
+ print_success(f"Applied {preset_name} preset")
1566
+ except Exception as e:
1567
+ print_warning(f"Could not apply preset: {e}")
1568
+ console.print()
1569
+
1570
+ # Step 3: Download classifier model
1571
+ console.print("[bold cyan]Step 3/5: Local Classifier Model[/bold cyan]")
1572
+ _download_local_model(quick=False)
1573
+ console.print()
1574
+
1575
+ # Step 4: Credential vault
1576
+ console.print("[bold cyan]Step 4/5: Credential Vault[/bold cyan]")
1577
+ try:
1578
+ from tweek.platform import get_capabilities
1579
+ caps = get_capabilities()
1580
+ if caps.vault_available:
1581
+ print_success(f"{caps.vault_backend} detected. No configuration needed.")
1582
+ else:
1583
+ print_warning("No vault backend available. Credentials will use fallback storage.")
1584
+ except Exception:
1585
+ print_warning("Could not check vault availability.")
1586
+ console.print()
1587
+
1588
+ # Step 5: Optional MCP proxy
1589
+ console.print("[bold cyan]Step 5/5: MCP Proxy (optional)[/bold cyan]")
1590
+ setup_mcp = click.confirm("Set up MCP proxy for Claude Desktop?", default=False)
1591
+ if setup_mcp:
1592
+ try:
1593
+ import mcp # noqa: F401
1594
+ console.print("[white]MCP package available. Configure upstream servers in ~/.tweek/config.yaml[/white]")
1595
+ console.print("[white]Then run: tweek mcp proxy[/white]")
1596
+ except ImportError:
1597
+ print_warning("MCP package not installed. Install with: pip install tweek[mcp]")
1598
+ else:
1599
+ console.print("[white]Skipped.[/white]")
1600
+
1601
+ # Scan for other AI tools
1602
+ _offer_mcp_protection()
1603
+
1604
+ console.print()
1605
+ console.print("[bold green]Setup complete![/bold green]")
1606
+ console.print(" Run [cyan]tweek doctor[/cyan] to verify your installation")
1607
+
1608
+
1609
+ def _quickstart_install_hooks(scope: str) -> None:
1610
+ """Install hooks for quickstart wizard (simplified version)."""
1611
+ import json
1612
+
1613
+ if scope == "global":
1614
+ target_dir = Path("~/.claude").expanduser()
1615
+ else:
1616
+ target_dir = Path.cwd() / ".claude"
1617
+
1618
+ hooks_dir = target_dir / "hooks"
1619
+ hooks_dir.mkdir(parents=True, exist_ok=True)
1620
+
1621
+ settings_path = target_dir / "settings.json"
1622
+ settings = {}
1623
+ if settings_path.exists():
1624
+ try:
1625
+ with open(settings_path) as f:
1626
+ settings = json.load(f)
1627
+ except (json.JSONDecodeError, IOError):
1628
+ pass
1629
+
1630
+ if "hooks" not in settings:
1631
+ settings["hooks"] = {}
1632
+
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
+
1647
+ 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
+ })
1664
+
1665
+ with open(settings_path, "w") as f:
1666
+ json.dump(settings, f, indent=2)