tweek 0.4.1__py3-none-any.whl → 0.4.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. tweek/__init__.py +1 -1
  2. tweek/cli_core.py +23 -6
  3. tweek/cli_install.py +439 -105
  4. tweek/cli_uninstall.py +119 -36
  5. tweek/config/families.yaml +13 -0
  6. tweek/config/models.py +31 -3
  7. tweek/config/patterns.yaml +126 -2
  8. tweek/diagnostics.py +124 -1
  9. tweek/hooks/break_glass.py +70 -47
  10. tweek/hooks/overrides.py +19 -1
  11. tweek/hooks/post_tool_use.py +6 -2
  12. tweek/hooks/pre_tool_use.py +19 -2
  13. tweek/hooks/wrapper_post_tool_use.py +121 -0
  14. tweek/hooks/wrapper_pre_tool_use.py +121 -0
  15. tweek/integrations/openclaw.py +70 -60
  16. tweek/integrations/openclaw_detection.py +140 -0
  17. tweek/integrations/openclaw_server.py +359 -86
  18. tweek/logging/security_log.py +22 -0
  19. tweek/memory/safety.py +7 -3
  20. tweek/memory/store.py +31 -10
  21. tweek/plugins/base.py +9 -1
  22. tweek/plugins/detectors/openclaw.py +31 -92
  23. tweek/plugins/screening/heuristic_scorer.py +12 -1
  24. tweek/plugins/screening/local_model_reviewer.py +9 -0
  25. tweek/security/language.py +2 -1
  26. tweek/security/llm_reviewer.py +45 -18
  27. tweek/security/local_model.py +21 -0
  28. tweek/security/model_registry.py +2 -2
  29. tweek/security/rate_limiter.py +99 -1
  30. tweek/skills/guard.py +30 -7
  31. {tweek-0.4.1.dist-info → tweek-0.4.3.dist-info}/METADATA +1 -1
  32. {tweek-0.4.1.dist-info → tweek-0.4.3.dist-info}/RECORD +37 -34
  33. {tweek-0.4.1.dist-info → tweek-0.4.3.dist-info}/WHEEL +0 -0
  34. {tweek-0.4.1.dist-info → tweek-0.4.3.dist-info}/entry_points.txt +0 -0
  35. {tweek-0.4.1.dist-info → tweek-0.4.3.dist-info}/licenses/LICENSE +0 -0
  36. {tweek-0.4.1.dist-info → tweek-0.4.3.dist-info}/licenses/NOTICE +0 -0
  37. {tweek-0.4.1.dist-info → tweek-0.4.3.dist-info}/top_level.txt +0 -0
tweek/cli_uninstall.py CHANGED
@@ -7,17 +7,12 @@ Full removal of Tweek from the system:
7
7
  tweek uninstall --all Remove ALL Tweek data system-wide
8
8
  tweek uninstall --all --confirm Remove everything without prompts
9
9
 
10
- IMPORTANT: Users MUST run ``tweek unprotect --all`` (or ``tweek uninstall``)
11
- BEFORE removing the pip package with ``pip uninstall tweek``. If the package
12
- is removed first, the Claude Code hooks in ~/.claude/settings.json become
13
- orphaned they still reference the hook scripts on disk but the ``tweek``
14
- CLI is no longer available to clean them up. Orphaned hooks cause spurious
15
- security warnings in every Claude Code session.
16
-
17
- To manually clean up orphaned hooks, remove the ``PreToolUse`` and
18
- ``PostToolUse`` entries that reference ``tweek`` from:
19
- ~/.claude/settings.json (global hooks)
20
- <project>/.claude/settings.json (project-level hooks)
10
+ Resilience against ``pip uninstall tweek`` running first:
11
+ - Hooks in settings.json point to self-healing wrappers at ~/.tweek/hooks/
12
+ (outside the pip package). If tweek is gone, the wrappers silently remove
13
+ themselves from settings.json and allow the tool call.
14
+ - A standalone cleanup script at ~/.tweek/uninstall.sh can remove all
15
+ Tweek state without requiring the Python package.
21
16
  """
22
17
  from __future__ import annotations
23
18
 
@@ -289,6 +284,8 @@ def _remove_tweek_data_dir(tweek_dir: Path) -> list:
289
284
  ("config.yaml", "configuration"),
290
285
  ("overrides.yaml", "security overrides"),
291
286
  ("security.db", "security log database"),
287
+ ("uninstall.sh", "standalone uninstall script"),
288
+ ("installed_scopes.json", "installation scope tracking"),
292
289
  ]
293
290
  for filename, label in items:
294
291
  filepath = tweek_dir / filename
@@ -297,6 +294,7 @@ def _remove_tweek_data_dir(tweek_dir: Path) -> list:
297
294
  removed.append(label)
298
295
 
299
296
  dirs = [
297
+ ("hooks", "self-healing hook wrappers"),
300
298
  ("patterns", "pattern repository"),
301
299
  ("chamber", "skill isolation chamber"),
302
300
  ("jail", "skill jail"),
@@ -329,6 +327,40 @@ def _remove_tweek_data_dir(tweek_dir: Path) -> list:
329
327
  return removed
330
328
 
331
329
 
330
+ def _remove_tweek_yaml_files(tweek_dir: Path) -> list:
331
+ """Remove .tweek.yaml control files. Returns list of paths removed."""
332
+ removed = []
333
+
334
+ # Global .tweek.yaml
335
+ global_yaml = Path("~/.tweek.yaml").expanduser()
336
+ if global_yaml.exists():
337
+ global_yaml.unlink()
338
+ removed.append(str(global_yaml))
339
+
340
+ # Project-level .tweek.yaml files from recorded scopes
341
+ scopes_file = tweek_dir / "installed_scopes.json"
342
+ if scopes_file.exists():
343
+ try:
344
+ scopes = json.loads(scopes_file.read_text()) or []
345
+ for scope_str in scopes:
346
+ # .tweek.yaml is in the parent of the .claude/ directory
347
+ scope_parent = Path(scope_str).parent
348
+ project_yaml = scope_parent / ".tweek.yaml"
349
+ if project_yaml.exists():
350
+ project_yaml.unlink()
351
+ removed.append(str(project_yaml))
352
+ except (json.JSONDecodeError, IOError):
353
+ pass
354
+
355
+ # Current directory .tweek.yaml
356
+ cwd_yaml = Path.cwd() / ".tweek.yaml"
357
+ if cwd_yaml.exists() and str(cwd_yaml) not in removed:
358
+ cwd_yaml.unlink()
359
+ removed.append(str(cwd_yaml))
360
+
361
+ return removed
362
+
363
+
332
364
  def _remove_mcp_integrations() -> list:
333
365
  """Remove MCP integrations for all known clients. Returns list of clients removed."""
334
366
  removed = []
@@ -460,14 +492,52 @@ def _uninstall_scope(target: Path, tweek_dir: Path, confirm: bool, scope_label:
460
492
  console.print("[white]Tweek data directory (~/.tweek/) was preserved.[/white]")
461
493
 
462
494
 
495
+ def _get_all_project_scopes(project_target: Path) -> list:
496
+ """Get all project-level .claude/ directories that may have tweek hooks.
497
+
498
+ Merges: (1) the current project, (2) any recorded install scopes from
499
+ ~/.tweek/installed_scopes.json. Deduplicates by resolved path.
500
+ """
501
+ seen = set()
502
+ result = []
503
+
504
+ # Always include current project
505
+ resolved_cwd = str(project_target.resolve())
506
+ seen.add(resolved_cwd)
507
+ result.append(project_target)
508
+
509
+ # Include all recorded scopes from install-time tracking
510
+ try:
511
+ from tweek.cli_install import _get_installed_scopes
512
+ for scope_str in _get_installed_scopes():
513
+ scope = Path(scope_str)
514
+ resolved = str(scope.resolve())
515
+ if resolved not in seen and scope.exists():
516
+ seen.add(resolved)
517
+ result.append(scope)
518
+ except (ImportError, Exception):
519
+ pass
520
+
521
+ return result
522
+
523
+
463
524
  def _uninstall_everything(global_target: Path, project_target: Path, tweek_dir: Path, confirm: bool):
464
525
  """Full system removal of all Tweek data."""
526
+ # Discover all project scopes (current + recorded from install)
527
+ all_project_scopes = _get_all_project_scopes(project_target)
528
+
465
529
  console.print("[bold yellow]FULL REMOVAL[/bold yellow] \u2014 This will remove ALL Tweek data:\n")
466
- console.print(" [white]\u2022[/white] Hooks from current project (.claude/settings.json)")
530
+ if len(all_project_scopes) > 1:
531
+ console.print(f" [white]\u2022[/white] Hooks from {len(all_project_scopes)} project(s):")
532
+ for scope in all_project_scopes:
533
+ console.print(f" [white]{scope}/settings.json[/white]")
534
+ else:
535
+ console.print(" [white]\u2022[/white] Hooks from current project (.claude/settings.json)")
467
536
  console.print(" [white]\u2022[/white] Hooks from global installation (~/.claude/settings.json)")
468
537
  console.print(" [white]\u2022[/white] Tweek skill directories (project + global)")
469
538
  console.print(" [white]\u2022[/white] All backup files")
470
- console.print(" [white]\u2022[/white] Tweek data directory (~/.tweek/)")
539
+ console.print(" [white]\u2022[/white] .tweek.yaml control files")
540
+ console.print(" [white]\u2022[/white] Tweek data directory (~/.tweek/) including hook wrappers")
471
541
 
472
542
  # Show what exists in ~/.tweek/
473
543
  if tweek_dir.exists():
@@ -489,23 +559,25 @@ def _uninstall_everything(global_target: Path, project_target: Path, tweek_dir:
489
559
 
490
560
  console.print()
491
561
 
492
- # ── Project scope ──
493
- console.print("[bold]Project scope (.claude/):[/bold]")
494
- removed_hooks = _remove_hooks_from_settings(project_target / "settings.json")
495
- for hook_type in removed_hooks:
496
- console.print(f" [green]\u2713[/green] Removed {hook_type} hook from project settings.json")
497
- if not removed_hooks:
498
- console.print(f" [white]-[/white] Skipped: no project hooks found")
499
-
500
- if _remove_skill_directory(project_target):
501
- console.print(f" [green]\u2713[/green] Removed Tweek skill from project")
502
- else:
503
- console.print(f" [white]-[/white] Skipped: no project skill directory")
562
+ # ── Project scopes (current + recorded from install) ──
563
+ for scope in all_project_scopes:
564
+ scope_label = str(scope)
565
+ console.print(f"[bold]Project scope ({scope_label}):[/bold]")
566
+ removed_hooks = _remove_hooks_from_settings(scope / "settings.json")
567
+ for hook_type in removed_hooks:
568
+ console.print(f" [green]\u2713[/green] Removed {hook_type} hook from {scope_label}/settings.json")
569
+ if not removed_hooks:
570
+ console.print(f" [white]-[/white] Skipped: no hooks found")
571
+
572
+ if _remove_skill_directory(scope):
573
+ console.print(f" [green]\u2713[/green] Removed Tweek skill directory")
574
+ else:
575
+ console.print(f" [white]-[/white] Skipped: no skill directory")
504
576
 
505
- if _remove_backup_file(project_target):
506
- console.print(f" [green]\u2713[/green] Removed project backup file")
507
- else:
508
- console.print(f" [white]-[/white] Skipped: no project backup file")
577
+ if _remove_backup_file(scope):
578
+ console.print(f" [green]\u2713[/green] Removed backup file")
579
+ else:
580
+ console.print(f" [white]-[/white] Skipped: no backup file")
509
581
 
510
582
  console.print()
511
583
 
@@ -529,13 +601,14 @@ def _uninstall_everything(global_target: Path, project_target: Path, tweek_dir:
529
601
 
530
602
  console.print()
531
603
 
532
- # ── Tweek data directory ──
533
- console.print("[bold]Tweek data (~/.tweek/):[/bold]")
534
- data_removed = _remove_tweek_data_dir(tweek_dir)
535
- for item in data_removed:
536
- console.print(f" [green]\u2713[/green] Removed {item}")
537
- if not data_removed:
538
- console.print(f" [white]-[/white] Skipped: no data directory found")
604
+ # ── .tweek.yaml control files ──
605
+ # Must run before data directory removal (needs installed_scopes.json)
606
+ console.print("[bold]Control files (.tweek.yaml):[/bold]")
607
+ yaml_removed = _remove_tweek_yaml_files(tweek_dir)
608
+ for yaml_path in yaml_removed:
609
+ console.print(f" [green]\u2713[/green] Removed {yaml_path}")
610
+ if not yaml_removed:
611
+ console.print(f" [white]-[/white] Skipped: no .tweek.yaml files found")
539
612
 
540
613
  console.print()
541
614
 
@@ -547,5 +620,15 @@ def _uninstall_everything(global_target: Path, project_target: Path, tweek_dir:
547
620
  if not mcp_removed:
548
621
  console.print(f" [white]-[/white] Skipped: no MCP integrations found")
549
622
 
623
+ console.print()
624
+
625
+ # ── Tweek data directory (last — other steps need installed_scopes.json) ──
626
+ console.print("[bold]Tweek data (~/.tweek/):[/bold]")
627
+ data_removed = _remove_tweek_data_dir(tweek_dir)
628
+ for item in data_removed:
629
+ console.print(f" [green]\u2713[/green] Removed {item}")
630
+ if not data_removed:
631
+ console.print(f" [white]-[/white] Skipped: no data directory found")
632
+
550
633
  console.print()
551
634
  console.print("[green]All Tweek data has been removed.[/green]")
@@ -128,6 +128,8 @@ families:
128
128
  - 218 # reverse_shell_perl_ruby
129
129
  - 219 # reverse_shell_mkfifo
130
130
  - 220 # reverse_shell_encoded
131
+ - 272 # shortened_url_to_shell
132
+ - 275 # raw_ip_url_to_shell
131
133
  heuristic_signals:
132
134
  exfil_verbs:
133
135
  - "curl"
@@ -308,6 +310,15 @@ families:
308
310
  - 113 # gzip_obfuscation
309
311
  - 125 # importlib_evasion
310
312
  - 126 # variable_indirection
313
+ - 263 # punycode_domain
314
+ - 264 # non_ascii_hostname
315
+ - 265 # cyrillic_in_url
316
+ - 266 # greek_in_url
317
+ - 267 # bidi_isolate_chars
318
+ - 268 # url_userinfo_trick
319
+ - 269 # lookalike_tld
320
+ - 270 # fullwidth_dot_in_url
321
+ - 274 # double_encoding_in_url
311
322
  heuristic_signals:
312
323
  evasion_indicators:
313
324
  - "eval("
@@ -391,6 +402,7 @@ families:
391
402
  - 96 # login_item_persistence
392
403
  - 118 # curl_write_sensitive
393
404
  - 227 # privesc_cron_inject
405
+ - 273 # dotfile_overwrite_via_redirect
394
406
  heuristic_signals:
395
407
  persistence_paths:
396
408
  - "LaunchAgents"
@@ -582,6 +594,7 @@ families:
582
594
  - 167 # pnpm_symlink_traversal
583
595
  - 248 # supply_chain_typosquat_ai
584
596
  - 249 # supply_chain_postinstall_exec
597
+ - 271 # insecure_tls_flags
585
598
  heuristic_signals:
586
599
  supply_chain_indicators:
587
600
  - "--pre"
tweek/config/models.py CHANGED
@@ -12,7 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  import re
14
14
  from enum import Enum
15
- from typing import Any, Dict, List, Optional
15
+ from typing import Any, Dict, List, Literal, Optional
16
16
 
17
17
  from pydantic import BaseModel, Field, field_validator, model_validator
18
18
 
@@ -53,6 +53,21 @@ class PatternConfidence(str, Enum):
53
53
  CONTEXTUAL = "contextual"
54
54
 
55
55
 
56
+ class LLMFailMode(str, Enum):
57
+ """Behavior when LLM review is unavailable (provider errors, timeouts).
58
+
59
+ OPEN: Degrade gracefully, return SAFE (current default behavior).
60
+ Pattern matching remains the primary defense.
61
+ CLOSED: Hard block -- return DANGEROUS with should_prompt=True.
62
+ Use when LLM review is considered a mandatory gate.
63
+ ESCALATE: Return SUSPICIOUS with should_prompt=True (triggers ASK).
64
+ Recommended middle ground for security-sensitive environments.
65
+ """
66
+ OPEN = "open"
67
+ CLOSED = "closed"
68
+ ESCALATE = "escalate"
69
+
70
+
56
71
  # ============================================================================
57
72
  # Configuration Section Models
58
73
  # ============================================================================
@@ -88,6 +103,10 @@ class LLMReviewConfig(BaseModel):
88
103
  base_url: Optional[str] = None
89
104
  api_key_env: Optional[str] = None
90
105
  timeout_seconds: float = Field(default=15.0, gt=0)
106
+ fail_mode: LLMFailMode = Field(
107
+ default=LLMFailMode.OPEN,
108
+ description="Behavior when LLM review is unavailable: open, closed, or escalate",
109
+ )
91
110
  local: LLMReviewLocalConfig = Field(default_factory=LLMReviewLocalConfig)
92
111
  fallback: LLMReviewFallbackConfig = Field(default_factory=LLMReviewFallbackConfig)
93
112
 
@@ -186,9 +205,18 @@ class OpenClawConfig(BaseModel):
186
205
  gateway_port: int = Field(default=18789, gt=0, le=65535)
187
206
  scanner_port: int = Field(default=9878, gt=0, le=65535)
188
207
  plugin_installed: bool = False
189
- preset: str = "cautious"
208
+ preset: Literal["paranoid", "cautious", "balanced", "trusted"] = "cautious"
190
209
 
191
- model_config = {"extra": "allow"}
210
+ model_config = {"extra": "forbid"}
211
+
212
+ @model_validator(mode="after")
213
+ def check_port_collision(self) -> "OpenClawConfig":
214
+ if self.gateway_port == self.scanner_port:
215
+ raise ValueError(
216
+ f"gateway_port and scanner_port must differ "
217
+ f"(both set to {self.gateway_port})"
218
+ )
219
+ return self
192
220
 
193
221
 
194
222
  # ============================================================================
@@ -24,8 +24,8 @@
24
24
  # contextual - Depends on surrounding context; broad behavioral pattern
25
25
  # PRO tier adds: LLM review, session analysis, rate limiting
26
26
 
27
- version: 5
28
- pattern_count: 262
27
+ version: 6
28
+ pattern_count: 275
29
29
 
30
30
  patterns:
31
31
  # ============================================================================
@@ -2275,3 +2275,127 @@ patterns:
2275
2275
  severity: medium
2276
2276
  confidence: contextual
2277
2277
  family: prompt_injection
2278
+
2279
+ # ============================================================================
2280
+ # TERMINAL & URL SECURITY PATTERNS (263-275)
2281
+ # 13 patterns covering IDN/punycode, homoglyph scripts, bidi text direction,
2282
+ # URL spoofing, insecure transport, dotfile persistence, and encoding attacks.
2283
+ # Inspired by Tirith terminal security research.
2284
+ # Added: 2026-02-03
2285
+ # ============================================================================
2286
+
2287
+ # --- IDN / Punycode Attacks (263-264) ---
2288
+
2289
+ - id: 263
2290
+ name: punycode_domain
2291
+ description: "Internationalized domain name (punycode) in URL"
2292
+ regex: 'https?://[^\s/]*xn--[^\s/]*'
2293
+ severity: high
2294
+ confidence: heuristic
2295
+ family: evasion_techniques
2296
+
2297
+ - id: 264
2298
+ name: non_ascii_hostname
2299
+ description: "Non-ASCII characters in URL hostname (potential homoglyph)"
2300
+ regex: 'https?://[^\s/]*[^\x00-\x7f][^\s/]*'
2301
+ severity: high
2302
+ confidence: heuristic
2303
+ family: evasion_techniques
2304
+
2305
+ # --- Homoglyph Script Detection (265-266) ---
2306
+
2307
+ - id: 265
2308
+ name: cyrillic_in_url
2309
+ description: "Cyrillic characters in URL (homoglyph spoofing)"
2310
+ regex: 'https?://[^\s]*[\u0400-\u04ff]'
2311
+ severity: high
2312
+ confidence: heuristic
2313
+ family: evasion_techniques
2314
+
2315
+ - id: 266
2316
+ name: greek_in_url
2317
+ description: "Greek characters in URL (homoglyph spoofing)"
2318
+ regex: 'https?://[^\s]*[\u0370-\u03ff]'
2319
+ severity: medium
2320
+ confidence: heuristic
2321
+ family: evasion_techniques
2322
+
2323
+ # --- Bidi Text Direction Attacks (267) ---
2324
+
2325
+ - id: 267
2326
+ name: bidi_isolate_chars
2327
+ description: "Bidi isolate characters that manipulate text display direction"
2328
+ regex: '[\u2066-\u2069]'
2329
+ severity: high
2330
+ confidence: heuristic
2331
+ family: evasion_techniques
2332
+
2333
+ # --- URL Spoofing (268-270) ---
2334
+
2335
+ - id: 268
2336
+ name: url_userinfo_trick
2337
+ description: "URL with userinfo@ to disguise real hostname"
2338
+ regex: 'https?://[^\s/@]+\.[^\s/@]+@[^\s/]+'
2339
+ severity: high
2340
+ confidence: heuristic
2341
+ family: evasion_techniques
2342
+
2343
+ - id: 269
2344
+ name: lookalike_tld
2345
+ description: "URL with .zip or .mov TLD that mimics file extension"
2346
+ regex: 'https?://[^\s/]+\.(zip|mov)(/|\s|$)'
2347
+ severity: medium
2348
+ confidence: contextual
2349
+ family: evasion_techniques
2350
+
2351
+ - id: 270
2352
+ name: fullwidth_dot_in_url
2353
+ description: "Fullwidth or ideographic dots in URL to bypass domain parsing"
2354
+ regex: 'https?://[^\s]*[\uff0e\u3002\uff61]'
2355
+ severity: high
2356
+ confidence: heuristic
2357
+ family: evasion_techniques
2358
+
2359
+ # --- Insecure Transport (271-272) ---
2360
+
2361
+ - id: 271
2362
+ name: insecure_tls_flags
2363
+ description: "TLS certificate verification disabled (MITM risk)"
2364
+ regex: '(curl\s+.*(-k\b|--insecure)|wget\s+.*--no-check-certificate)'
2365
+ severity: high
2366
+ confidence: heuristic
2367
+ family: supply_chain
2368
+
2369
+ - id: 272
2370
+ name: shortened_url_to_shell
2371
+ description: "Shortened URL piped to interpreter (hidden destination)"
2372
+ regex: '(curl|wget)\s+.*https?://(bit\.ly|t\.co|tinyurl\.com|is\.gd|rb\.gy|shorturl\.at)[^\s]*.*\|\s*(bash|sh|zsh|python|perl|ruby)'
2373
+ severity: critical
2374
+ confidence: deterministic
2375
+ family: data_exfiltration
2376
+
2377
+ # --- Dotfile Persistence & Encoding (273-275) ---
2378
+
2379
+ - id: 273
2380
+ name: dotfile_overwrite_via_redirect
2381
+ description: "Download command redirecting output to shell config dotfile"
2382
+ regex: '(curl|wget)\s+[^|]*?(>|>>)\s*~?/?\.(bashrc|zshrc|profile|bash_profile|gitconfig)'
2383
+ severity: critical
2384
+ confidence: deterministic
2385
+ family: persistence
2386
+
2387
+ - id: 274
2388
+ name: double_encoding_in_url
2389
+ description: "Double percent-encoding to bypass URL security filters"
2390
+ regex: '%25[0-9a-fA-F]{2}'
2391
+ severity: medium
2392
+ confidence: heuristic
2393
+ family: evasion_techniques
2394
+
2395
+ - id: 275
2396
+ name: raw_ip_url_to_shell
2397
+ description: "Raw IP address URL piped to interpreter"
2398
+ regex: '(curl|wget)\s+.*https?://\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}[^\s]*.*\|\s*(bash|sh|zsh|python)'
2399
+ severity: high
2400
+ confidence: heuristic
2401
+ family: data_exfiltration
tweek/diagnostics.py CHANGED
@@ -33,12 +33,13 @@ class HealthCheck:
33
33
  fix_hint: str = "" # Recovery: "Run: tweek protect claude-code --global"
34
34
 
35
35
 
36
- def run_health_checks(verbose: bool = False) -> List[HealthCheck]:
36
+ def run_health_checks(verbose: bool = False, interactive: bool = False) -> List[HealthCheck]:
37
37
  """
38
38
  Run all health checks and return results.
39
39
 
40
40
  Args:
41
41
  verbose: If True, include extra detail in messages.
42
+ interactive: If True, offer to fix issues interactively.
42
43
 
43
44
  Returns:
44
45
  List of HealthCheck results in check order.
@@ -72,6 +73,10 @@ def run_health_checks(verbose: bool = False) -> List[HealthCheck]:
72
73
  fix_hint="This may indicate a corrupted installation. Try: pip install --force-reinstall tweek",
73
74
  ))
74
75
 
76
+ # Interactive fix mode — offer to resolve fixable issues
77
+ if interactive:
78
+ _offer_interactive_fixes(results)
79
+
75
80
  # Log health check results
76
81
  try:
77
82
  from tweek.logging.security_log import get_logger, SecurityEvent, EventType
@@ -788,3 +793,121 @@ def _check_llm_review(verbose: bool = False) -> HealthCheck:
788
793
  status=CheckStatus.WARNING,
789
794
  message=f"Cannot check LLM review: {e}",
790
795
  )
796
+
797
+
798
+ # ==================== Interactive Fix Mode ====================
799
+
800
+
801
+ def _offer_interactive_fixes(results: List[HealthCheck]) -> None:
802
+ """Offer to fix issues found during health checks.
803
+
804
+ Only runs when ``tweek doctor --fix`` is used. Prompts for each
805
+ fixable issue and attempts automatic remediation.
806
+ """
807
+ import click
808
+ from rich.console import Console
809
+
810
+ console = Console()
811
+ fixable = [r for r in results if r.status in (CheckStatus.WARNING, CheckStatus.ERROR)]
812
+
813
+ if not fixable:
814
+ return
815
+
816
+ console.print("\n[bold]Interactive Fix Mode[/bold]")
817
+ console.print(f"Found {len(fixable)} issue(s) that may be fixable:\n")
818
+
819
+ for check in fixable:
820
+ fixed = False
821
+
822
+ if check.name == "local_model" and "not downloaded" in check.message:
823
+ if click.confirm(f" [{check.name}] Download local model now?", default=True):
824
+ fixed = _fix_download_model()
825
+
826
+ elif check.name == "local_model" and "Dependencies" in check.message:
827
+ if click.confirm(f" [{check.name}] Install local model dependencies?", default=True):
828
+ fixed = _fix_install_model_deps()
829
+
830
+ elif check.name == "local_model" and "SHA-256" in check.message:
831
+ if click.confirm(f" [{check.name}] Re-download model (integrity check failed)?", default=True):
832
+ fixed = _fix_redownload_model()
833
+
834
+ elif check.name == "llm_review" and "No LLM provider" in check.message:
835
+ if click.confirm(f" [{check.name}] Configure LLM provider now?", default=True):
836
+ fixed = _fix_configure_llm()
837
+
838
+ elif check.name == "hooks_installed" and "No hooks" in check.message:
839
+ if click.confirm(f" [{check.name}] Install hooks now?", default=True):
840
+ fixed = _fix_install_hooks()
841
+
842
+ else:
843
+ if check.fix_hint:
844
+ console.print(f" [{check.name}] {check.message}")
845
+ console.print(f" [dim]Hint: {check.fix_hint}[/dim]")
846
+
847
+ if fixed:
848
+ console.print(f" [green]\u2713[/green] Fixed: {check.name}")
849
+ console.print()
850
+
851
+
852
+ def _fix_download_model() -> bool:
853
+ """Download the local classifier model."""
854
+ try:
855
+ from tweek.cli_install import _download_local_model
856
+ return _download_local_model(quick=False)
857
+ except Exception as e:
858
+ from rich.console import Console
859
+ Console().print(f" [red]\u2717[/red] Failed: {e}")
860
+ return False
861
+
862
+
863
+ def _fix_install_model_deps() -> bool:
864
+ """Install onnxruntime, tokenizers, numpy."""
865
+ try:
866
+ from tweek.cli_install import _ensure_local_model_deps
867
+ return _ensure_local_model_deps()
868
+ except Exception as e:
869
+ from rich.console import Console
870
+ Console().print(f" [red]\u2717[/red] Failed: {e}")
871
+ return False
872
+
873
+
874
+ def _fix_redownload_model() -> bool:
875
+ """Force re-download the local model."""
876
+ try:
877
+ from tweek.security.model_registry import download_model, get_default_model_name
878
+ download_model(get_default_model_name(), force=True)
879
+ return True
880
+ except Exception as e:
881
+ from rich.console import Console
882
+ Console().print(f" [red]\u2717[/red] Failed: {e}")
883
+ return False
884
+
885
+
886
+ def _fix_configure_llm() -> bool:
887
+ """Run the LLM provider configuration wizard."""
888
+ try:
889
+ from tweek.cli_install import _configure_llm_provider
890
+ tweek_dir = Path("~/.tweek").expanduser()
891
+ _configure_llm_provider(tweek_dir, interactive=True, quick=False)
892
+ return True
893
+ except Exception as e:
894
+ from rich.console import Console
895
+ Console().print(f" [red]\u2717[/red] Failed: {e}")
896
+ return False
897
+
898
+
899
+ def _fix_install_hooks() -> bool:
900
+ """Run hook installation."""
901
+ try:
902
+ from tweek.cli_install import _install_claude_code_hooks
903
+ _install_claude_code_hooks(
904
+ install_global=True, dev_test=False, backup=True,
905
+ skip_env_scan=True, interactive=False, preset="cautious",
906
+ ai_defaults=False, with_sandbox=False, force_proxy=False,
907
+ skip_proxy_check=True, quick=True,
908
+ )
909
+ return True
910
+ except Exception as e:
911
+ from rich.console import Console
912
+ Console().print(f" [red]\u2717[/red] Failed: {e}")
913
+ return False