tweek 0.1.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 (85) hide show
  1. tweek/__init__.py +16 -0
  2. tweek/cli.py +3390 -0
  3. tweek/cli_helpers.py +193 -0
  4. tweek/config/__init__.py +13 -0
  5. tweek/config/allowed_dirs.yaml +23 -0
  6. tweek/config/manager.py +1064 -0
  7. tweek/config/patterns.yaml +751 -0
  8. tweek/config/tiers.yaml +129 -0
  9. tweek/diagnostics.py +589 -0
  10. tweek/hooks/__init__.py +1 -0
  11. tweek/hooks/pre_tool_use.py +861 -0
  12. tweek/integrations/__init__.py +3 -0
  13. tweek/integrations/moltbot.py +243 -0
  14. tweek/licensing.py +398 -0
  15. tweek/logging/__init__.py +9 -0
  16. tweek/logging/bundle.py +350 -0
  17. tweek/logging/json_logger.py +150 -0
  18. tweek/logging/security_log.py +745 -0
  19. tweek/mcp/__init__.py +24 -0
  20. tweek/mcp/approval.py +456 -0
  21. tweek/mcp/approval_cli.py +356 -0
  22. tweek/mcp/clients/__init__.py +37 -0
  23. tweek/mcp/clients/chatgpt.py +112 -0
  24. tweek/mcp/clients/claude_desktop.py +203 -0
  25. tweek/mcp/clients/gemini.py +178 -0
  26. tweek/mcp/proxy.py +667 -0
  27. tweek/mcp/screening.py +175 -0
  28. tweek/mcp/server.py +317 -0
  29. tweek/platform/__init__.py +131 -0
  30. tweek/plugins/__init__.py +835 -0
  31. tweek/plugins/base.py +1080 -0
  32. tweek/plugins/compliance/__init__.py +30 -0
  33. tweek/plugins/compliance/gdpr.py +333 -0
  34. tweek/plugins/compliance/gov.py +324 -0
  35. tweek/plugins/compliance/hipaa.py +285 -0
  36. tweek/plugins/compliance/legal.py +322 -0
  37. tweek/plugins/compliance/pci.py +361 -0
  38. tweek/plugins/compliance/soc2.py +275 -0
  39. tweek/plugins/detectors/__init__.py +30 -0
  40. tweek/plugins/detectors/continue_dev.py +206 -0
  41. tweek/plugins/detectors/copilot.py +254 -0
  42. tweek/plugins/detectors/cursor.py +192 -0
  43. tweek/plugins/detectors/moltbot.py +205 -0
  44. tweek/plugins/detectors/windsurf.py +214 -0
  45. tweek/plugins/git_discovery.py +395 -0
  46. tweek/plugins/git_installer.py +491 -0
  47. tweek/plugins/git_lockfile.py +338 -0
  48. tweek/plugins/git_registry.py +503 -0
  49. tweek/plugins/git_security.py +482 -0
  50. tweek/plugins/providers/__init__.py +30 -0
  51. tweek/plugins/providers/anthropic.py +181 -0
  52. tweek/plugins/providers/azure_openai.py +289 -0
  53. tweek/plugins/providers/bedrock.py +248 -0
  54. tweek/plugins/providers/google.py +197 -0
  55. tweek/plugins/providers/openai.py +230 -0
  56. tweek/plugins/scope.py +130 -0
  57. tweek/plugins/screening/__init__.py +26 -0
  58. tweek/plugins/screening/llm_reviewer.py +149 -0
  59. tweek/plugins/screening/pattern_matcher.py +273 -0
  60. tweek/plugins/screening/rate_limiter.py +174 -0
  61. tweek/plugins/screening/session_analyzer.py +159 -0
  62. tweek/proxy/__init__.py +302 -0
  63. tweek/proxy/addon.py +223 -0
  64. tweek/proxy/interceptor.py +313 -0
  65. tweek/proxy/server.py +315 -0
  66. tweek/sandbox/__init__.py +71 -0
  67. tweek/sandbox/executor.py +382 -0
  68. tweek/sandbox/linux.py +278 -0
  69. tweek/sandbox/profile_generator.py +323 -0
  70. tweek/screening/__init__.py +13 -0
  71. tweek/screening/context.py +81 -0
  72. tweek/security/__init__.py +22 -0
  73. tweek/security/llm_reviewer.py +348 -0
  74. tweek/security/rate_limiter.py +682 -0
  75. tweek/security/secret_scanner.py +506 -0
  76. tweek/security/session_analyzer.py +600 -0
  77. tweek/vault/__init__.py +40 -0
  78. tweek/vault/cross_platform.py +251 -0
  79. tweek/vault/keychain.py +288 -0
  80. tweek-0.1.0.dist-info/METADATA +335 -0
  81. tweek-0.1.0.dist-info/RECORD +85 -0
  82. tweek-0.1.0.dist-info/WHEEL +5 -0
  83. tweek-0.1.0.dist-info/entry_points.txt +25 -0
  84. tweek-0.1.0.dist-info/licenses/LICENSE +190 -0
  85. tweek-0.1.0.dist-info/top_level.txt +1 -0
tweek/cli.py ADDED
@@ -0,0 +1,3390 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tweek CLI - GAH! Security for your Claude Code skills.
4
+
5
+ Usage:
6
+ tweek install [--scope global|project]
7
+ tweek uninstall [--scope global|project]
8
+ tweek status
9
+ tweek config [--skill NAME] [--preset paranoid|cautious|trusted]
10
+ tweek vault store SKILL KEY VALUE
11
+ tweek vault get SKILL KEY
12
+ tweek vault migrate-env [--dry-run]
13
+ tweek logs [--limit N] [--type TYPE]
14
+ tweek logs stats [--days N]
15
+ tweek logs export [--days N] [--output FILE]
16
+ """
17
+
18
+ import click
19
+ import json
20
+ import os
21
+ import re
22
+ from datetime import datetime
23
+ from pathlib import Path
24
+ from typing import List, Tuple, Dict
25
+ from rich.console import Console
26
+ from rich.table import Table
27
+ from rich.panel import Panel
28
+
29
+ from tweek import __version__
30
+
31
+ console = Console()
32
+
33
+
34
+ def scan_for_env_files() -> List[Tuple[Path, List[str]]]:
35
+ """
36
+ Scan common locations for .env files.
37
+
38
+ Returns:
39
+ List of (path, credential_keys) tuples
40
+ """
41
+ locations = [
42
+ Path.cwd() / ".env",
43
+ Path.home() / ".env",
44
+ Path.cwd() / ".env.local",
45
+ Path.cwd() / ".env.production",
46
+ Path.cwd() / ".env.development",
47
+ ]
48
+
49
+ # Also check parent directories up to 3 levels
50
+ parent = Path.cwd().parent
51
+ for _ in range(3):
52
+ if parent != parent.parent:
53
+ locations.append(parent / ".env")
54
+ parent = parent.parent
55
+
56
+ found = []
57
+ seen_paths = set()
58
+
59
+ for path in locations:
60
+ try:
61
+ resolved = path.resolve()
62
+ if resolved in seen_paths:
63
+ continue
64
+ seen_paths.add(resolved)
65
+
66
+ if path.exists() and path.is_file():
67
+ keys = parse_env_keys(path)
68
+ if keys:
69
+ found.append((path, keys))
70
+ except (PermissionError, OSError):
71
+ continue
72
+
73
+ return found
74
+
75
+
76
+ def parse_env_keys(env_path: Path) -> List[str]:
77
+ """
78
+ Parse .env file and return list of credential keys.
79
+
80
+ Only returns keys that look like credentials (contain KEY, SECRET,
81
+ PASSWORD, TOKEN, API, AUTH, etc.)
82
+ """
83
+ credential_patterns = [
84
+ r'.*KEY.*', r'.*SECRET.*', r'.*PASSWORD.*', r'.*TOKEN.*',
85
+ r'.*API.*', r'.*AUTH.*', r'.*CREDENTIAL.*', r'.*PRIVATE.*',
86
+ r'.*ACCESS.*', r'.*CONN.*STRING.*', r'.*DB_.*', r'.*DATABASE.*',
87
+ ]
88
+
89
+ keys = []
90
+ try:
91
+ content = env_path.read_text()
92
+ for line in content.splitlines():
93
+ line = line.strip()
94
+ if not line or line.startswith("#") or "=" not in line:
95
+ continue
96
+
97
+ key = line.split("=", 1)[0].strip()
98
+
99
+ # Check if it looks like a credential
100
+ key_upper = key.upper()
101
+ is_credential = any(
102
+ re.match(pattern, key_upper, re.IGNORECASE)
103
+ for pattern in credential_patterns
104
+ )
105
+
106
+ if is_credential:
107
+ keys.append(key)
108
+ except (PermissionError, OSError):
109
+ pass
110
+
111
+ return keys
112
+
113
+ TWEEK_BANNER = """
114
+ ████████╗██╗ ██╗███████╗███████╗██╗ ██╗
115
+ ╚══██╔══╝██║ ██║██╔════╝██╔════╝██║ ██╔╝
116
+ ██║ ██║ █╗ ██║█████╗ █████╗ █████╔╝
117
+ ██║ ██║███╗██║██╔══╝ ██╔══╝ ██╔═██╗
118
+ ██║ ╚███╔███╔╝███████╗███████╗██║ ██╗
119
+ ╚═╝ ╚══╝╚══╝ ╚══════╝╚══════╝╚═╝ ╚═╝
120
+
121
+ GAH! Security sandboxing for Claude Code
122
+ "Because paranoia is a feature, not a bug"
123
+ """
124
+
125
+
126
+ @click.group()
127
+ @click.version_option(version=__version__, prog_name="tweek")
128
+ def main():
129
+ """Tweek - Security sandboxing for Claude Code skills.
130
+
131
+ GAH! TOO MUCH PRESSURE on your credentials!
132
+ """
133
+ pass
134
+
135
+
136
+ @main.command(
137
+ epilog="""\b
138
+ Examples:
139
+ tweek install Install globally with default settings
140
+ tweek install --scope project Install for current project only
141
+ tweek install --interactive Walk through configuration prompts
142
+ tweek install --preset paranoid Apply paranoid security preset
143
+ tweek install --with-sandbox Install sandbox tool if needed (Linux)
144
+ tweek install --force-proxy Override existing proxy configurations
145
+ """
146
+ )
147
+ @click.option("--scope", type=click.Choice(["global", "project"]), default="global",
148
+ help="Installation scope: global (~/.claude) or project (./.claude)")
149
+ @click.option("--dev-test", is_flag=True, hidden=True,
150
+ help="Install to test environment (for Tweek development only)")
151
+ @click.option("--backup/--no-backup", default=True,
152
+ help="Backup existing hooks before installation")
153
+ @click.option("--skip-env-scan", is_flag=True,
154
+ help="Skip scanning for .env files to migrate")
155
+ @click.option("--interactive", "-i", is_flag=True,
156
+ help="Interactively configure security settings")
157
+ @click.option("--preset", type=click.Choice(["paranoid", "cautious", "trusted"]),
158
+ help="Apply a security preset (skip interactive)")
159
+ @click.option("--ai-defaults", is_flag=True,
160
+ help="Let AI suggest default settings based on detected skills")
161
+ @click.option("--with-sandbox", is_flag=True,
162
+ help="Prompt to install sandbox tool if not available (Linux only)")
163
+ @click.option("--force-proxy", is_flag=True,
164
+ help="Force Tweek proxy to override existing proxy configurations (e.g., moltbot)")
165
+ @click.option("--skip-proxy-check", is_flag=True,
166
+ help="Skip checking for existing proxy configurations")
167
+ def install(scope: str, 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):
168
+ """Install Tweek hooks into Claude Code.
169
+
170
+ Scope options:
171
+ --scope global : Install to ~/.claude/ (protects all projects)
172
+ --scope project : Install to ./.claude/ (protects this project only)
173
+
174
+ Configuration options:
175
+ --interactive : Walk through configuration prompts
176
+ --preset : Apply paranoid/cautious/trusted preset
177
+ --ai-defaults : Auto-configure based on detected skills
178
+ --with-sandbox : Install sandbox tool if needed (Linux: firejail)
179
+ """
180
+ import json
181
+ import shutil
182
+ from tweek.platform import IS_LINUX, get_capabilities
183
+ from tweek.config.manager import ConfigManager, SecurityTier
184
+
185
+ console.print(TWEEK_BANNER, style="cyan")
186
+
187
+ # ─────────────────────────────────────────────────────────────
188
+ # Check for existing proxy configurations (moltbot, etc.)
189
+ # ─────────────────────────────────────────────────────────────
190
+ proxy_override_enabled = force_proxy
191
+ if not skip_proxy_check:
192
+ try:
193
+ from tweek.proxy import (
194
+ detect_proxy_conflicts,
195
+ get_moltbot_status,
196
+ MOLTBOT_DEFAULT_PORT,
197
+ TWEEK_DEFAULT_PORT,
198
+ )
199
+
200
+ moltbot_status = get_moltbot_status()
201
+
202
+ if moltbot_status["installed"]:
203
+ console.print()
204
+ console.print("[yellow]⚠ Moltbot detected on this system[/yellow]")
205
+
206
+ if moltbot_status["gateway_active"]:
207
+ console.print(f" [red]Gateway is running on port {moltbot_status['port']}[/red]")
208
+ elif moltbot_status["running"]:
209
+ console.print(f" [dim]Process is running (gateway may start on port {moltbot_status['port']})[/dim]")
210
+ else:
211
+ console.print(f" [dim]Installed but not currently running[/dim]")
212
+
213
+ if moltbot_status["config_path"]:
214
+ console.print(f" [dim]Config: {moltbot_status['config_path']}[/dim]")
215
+
216
+ console.print()
217
+
218
+ if not force_proxy:
219
+ console.print("[cyan]Tweek can work alongside moltbot, or you can configure[/cyan]")
220
+ console.print("[cyan]Tweek's proxy to intercept API calls instead.[/cyan]")
221
+ console.print()
222
+
223
+ if click.confirm(
224
+ "[yellow]Enable Tweek proxy to override moltbot's gateway?[/yellow]",
225
+ default=False
226
+ ):
227
+ proxy_override_enabled = True
228
+ console.print("[green]✓[/green] Tweek proxy will be configured to intercept API calls")
229
+ console.print(f" [dim]Run 'tweek proxy start' after installation[/dim]")
230
+ else:
231
+ console.print("[dim]Tweek will work without proxy interception[/dim]")
232
+ console.print("[dim]You can enable it later with 'tweek proxy enable'[/dim]")
233
+ else:
234
+ console.print("[green]✓[/green] Force proxy enabled - Tweek will override moltbot")
235
+
236
+ console.print()
237
+
238
+ # Check for other proxy conflicts
239
+ conflicts = detect_proxy_conflicts()
240
+ non_moltbot_conflicts = [c for c in conflicts if c.tool_name != "moltbot"]
241
+
242
+ if non_moltbot_conflicts:
243
+ console.print("[yellow]⚠ Other proxy conflicts detected:[/yellow]")
244
+ for conflict in non_moltbot_conflicts:
245
+ console.print(f" • {conflict.description}")
246
+ console.print()
247
+
248
+ except ImportError:
249
+ # Proxy module not fully available, skip detection
250
+ pass
251
+ except Exception as e:
252
+ console.print(f"[dim]Warning: Could not check for proxy conflicts: {e}[/dim]")
253
+
254
+ # Determine target directory based on scope
255
+ if dev_test:
256
+ console.print("[yellow]Installing in DEV TEST mode (isolated environment)[/yellow]")
257
+ target = Path("~/AI/tweek/test-environment/.claude").expanduser()
258
+ elif scope == "global":
259
+ target = Path("~/.claude").expanduser()
260
+ console.print(f"[cyan]Scope: global[/cyan] - Hooks will protect all projects")
261
+ else: # project
262
+ target = Path.cwd() / ".claude"
263
+ console.print(f"[cyan]Scope: project[/cyan] - Hooks will protect this project only")
264
+
265
+ hook_script = Path(__file__).parent / "hooks" / "pre_tool_use.py"
266
+
267
+ # Backup existing hooks if requested
268
+ if backup and target.exists():
269
+ settings_file = target / "settings.json"
270
+ if settings_file.exists():
271
+ backup_path = settings_file.with_suffix(".json.tweek-backup")
272
+ shutil.copy(settings_file, backup_path)
273
+ console.print(f"[dim]Backed up existing settings to {backup_path}[/dim]")
274
+
275
+ # Create target directory
276
+ target.mkdir(parents=True, exist_ok=True)
277
+
278
+ # Install hooks configuration
279
+ settings_file = target / "settings.json"
280
+
281
+ # Load existing settings or create new
282
+ if settings_file.exists():
283
+ with open(settings_file) as f:
284
+ settings = json.load(f)
285
+ else:
286
+ settings = {}
287
+
288
+ # Add Tweek hooks
289
+ settings["hooks"] = settings.get("hooks", {})
290
+ settings["hooks"]["PreToolUse"] = [
291
+ {
292
+ "matcher": "Bash",
293
+ "hooks": [
294
+ {
295
+ "type": "command",
296
+ "command": f"/usr/bin/env python3 {hook_script.resolve()}"
297
+ }
298
+ ]
299
+ }
300
+ ]
301
+
302
+ with open(settings_file, "w") as f:
303
+ json.dump(settings, f, indent=2)
304
+
305
+ console.print(f"\n[green]✓[/green] Hooks installed to: {target}")
306
+
307
+ # Create Tweek data directory
308
+ tweek_dir = Path("~/.tweek").expanduser()
309
+ tweek_dir.mkdir(parents=True, exist_ok=True)
310
+ console.print(f"[green]✓[/green] Tweek data directory: {tweek_dir}")
311
+
312
+ # Scan for .env files
313
+ if not skip_env_scan:
314
+ console.print("\n[cyan]Scanning for .env files with credentials...[/cyan]\n")
315
+
316
+ env_files = scan_for_env_files()
317
+
318
+ if env_files:
319
+ table = Table(title="Found .env Files")
320
+ table.add_column("#", style="dim")
321
+ table.add_column("Path")
322
+ table.add_column("Credentials", justify="right")
323
+
324
+ for i, (path, keys) in enumerate(env_files, 1):
325
+ # Show relative path if possible
326
+ try:
327
+ display_path = path.relative_to(Path.cwd())
328
+ except ValueError:
329
+ display_path = path
330
+
331
+ table.add_row(str(i), str(display_path), str(len(keys)))
332
+
333
+ console.print(table)
334
+
335
+ if click.confirm("\n[yellow]Migrate these credentials to secure storage?[/yellow]"):
336
+ from tweek.vault import get_vault, VAULT_AVAILABLE
337
+ if not VAULT_AVAILABLE:
338
+ console.print("[red]✗[/red] Vault not available. Install keyring: pip install keyring")
339
+ else:
340
+ vault = get_vault()
341
+
342
+ for path, keys in env_files:
343
+ try:
344
+ display_path = path.relative_to(Path.cwd())
345
+ except ValueError:
346
+ display_path = path
347
+
348
+ console.print(f"\n[cyan]{display_path}[/cyan]")
349
+
350
+ # Suggest skill name from directory
351
+ suggested_skill = path.parent.name
352
+ if suggested_skill in (".", "", "~"):
353
+ suggested_skill = "default"
354
+
355
+ skill = click.prompt(
356
+ " Skill name",
357
+ default=suggested_skill
358
+ )
359
+
360
+ # Show dry-run preview
361
+ console.print(f" [dim]Preview - credentials to migrate:[/dim]")
362
+ for key in keys:
363
+ console.print(f" • {key}")
364
+
365
+ if click.confirm(f" Migrate {len(keys)} credentials to '{skill}'?"):
366
+ try:
367
+ from tweek.vault import migrate_env_to_vault
368
+ results = migrate_env_to_vault(path, skill, vault, dry_run=False)
369
+ successful = sum(1 for _, s in results if s)
370
+ console.print(f" [green]✓[/green] Migrated {successful} credentials")
371
+ except Exception as e:
372
+ console.print(f" [red]✗[/red] Migration failed: {e}")
373
+ else:
374
+ console.print(f" [dim]Skipped[/dim]")
375
+ else:
376
+ console.print("[dim]No .env files with credentials found[/dim]")
377
+
378
+ # ─────────────────────────────────────────────────────────────
379
+ # Security Configuration
380
+ # ─────────────────────────────────────────────────────────────
381
+ cfg = ConfigManager()
382
+
383
+ if preset:
384
+ # Apply preset directly
385
+ cfg.apply_preset(preset)
386
+ console.print(f"\n[green]✓[/green] Applied [bold]{preset}[/bold] security preset")
387
+
388
+ elif ai_defaults:
389
+ # AI-assisted defaults: detect skills and suggest tiers
390
+ console.print("\n[cyan]Detecting installed skills...[/cyan]")
391
+
392
+ # Try to detect skills from Claude Code config
393
+ detected_skills = []
394
+ claude_settings = Path("~/.claude/settings.json").expanduser()
395
+ if claude_settings.exists():
396
+ try:
397
+ with open(claude_settings) as f:
398
+ claude_config = json.load(f)
399
+ # Look for plugins, skills, or custom hooks
400
+ plugins = claude_config.get("enabledPlugins", {})
401
+ detected_skills.extend(plugins.keys())
402
+ except Exception:
403
+ pass
404
+
405
+ # Also check for common skill directories
406
+ skill_dirs = [
407
+ Path("~/.claude/skills").expanduser(),
408
+ Path("~/.claude/commands").expanduser(),
409
+ ]
410
+ for skill_dir in skill_dirs:
411
+ if skill_dir.exists():
412
+ for item in skill_dir.iterdir():
413
+ if item.is_dir() or item.suffix == ".md":
414
+ detected_skills.append(item.stem)
415
+
416
+ # Find unknown skills
417
+ unknown_skills = cfg.get_unknown_skills(detected_skills)
418
+
419
+ if unknown_skills:
420
+ console.print(f"\n[yellow]Found {len(unknown_skills)} new skills not in config:[/yellow]")
421
+ for skill in unknown_skills[:10]: # Limit display
422
+ console.print(f" • {skill}")
423
+ if len(unknown_skills) > 10:
424
+ console.print(f" ... and {len(unknown_skills) - 10} more")
425
+
426
+ # Suggest defaults based on skill names
427
+ console.print("\n[cyan]Applying AI-suggested defaults:[/cyan]")
428
+ for skill in unknown_skills:
429
+ # Simple heuristics for tier suggestion
430
+ skill_lower = skill.lower()
431
+ if any(x in skill_lower for x in ["deploy", "publish", "release", "prod"]):
432
+ suggested = SecurityTier.DANGEROUS
433
+ elif any(x in skill_lower for x in ["web", "fetch", "api", "external", "browser"]):
434
+ suggested = SecurityTier.RISKY
435
+ elif any(x in skill_lower for x in ["review", "read", "explore", "search", "list"]):
436
+ suggested = SecurityTier.SAFE
437
+ else:
438
+ suggested = SecurityTier.DEFAULT
439
+
440
+ cfg.set_skill_tier(skill, suggested)
441
+ console.print(f" {skill}: {suggested.value}")
442
+
443
+ console.print(f"\n[green]✓[/green] Configured {len(unknown_skills)} skills")
444
+ else:
445
+ console.print("[dim]All detected skills already configured[/dim]")
446
+
447
+ # Apply cautious preset as base
448
+ cfg.apply_preset("cautious")
449
+ console.print("[green]✓[/green] Applied [bold]cautious[/bold] base preset")
450
+
451
+ elif interactive:
452
+ # Full interactive configuration
453
+ console.print("\n[bold]Security Configuration[/bold]")
454
+ console.print("Choose how to configure security settings:\n")
455
+ console.print(" [cyan]1.[/cyan] Paranoid - Maximum security")
456
+ console.print(" [cyan]2.[/cyan] Cautious - Balanced (recommended)")
457
+ console.print(" [cyan]3.[/cyan] Trusted - Minimal prompts")
458
+ console.print(" [cyan]4.[/cyan] Custom - Configure individually")
459
+ console.print()
460
+
461
+ choice = click.prompt("Select", type=click.IntRange(1, 4), default=2)
462
+
463
+ if choice == 1:
464
+ cfg.apply_preset("paranoid")
465
+ console.print("[green]✓[/green] Applied paranoid preset")
466
+ elif choice == 2:
467
+ cfg.apply_preset("cautious")
468
+ console.print("[green]✓[/green] Applied cautious preset")
469
+ elif choice == 3:
470
+ cfg.apply_preset("trusted")
471
+ console.print("[green]✓[/green] Applied trusted preset")
472
+ else:
473
+ # Custom: ask about key tools
474
+ console.print("\n[bold]Configure key tools:[/bold]")
475
+ console.print("[dim](safe/default/risky/dangerous)[/dim]\n")
476
+
477
+ for tool in ["Bash", "WebFetch", "Edit"]:
478
+ current = cfg.get_tool_tier(tool)
479
+ new_tier = click.prompt(
480
+ f" {tool}",
481
+ default=current.value,
482
+ type=click.Choice(["safe", "default", "risky", "dangerous"])
483
+ )
484
+ cfg.set_tool_tier(tool, SecurityTier.from_string(new_tier))
485
+
486
+ console.print("[green]✓[/green] Custom configuration saved")
487
+
488
+ else:
489
+ # Default: apply cautious preset silently
490
+ if not cfg.export_config("user"):
491
+ cfg.apply_preset("cautious")
492
+ console.print("\n[green]✓[/green] Applied default [bold]cautious[/bold] security preset")
493
+ console.print("[dim]Run 'tweek config interactive' to customize[/dim]")
494
+
495
+ # ─────────────────────────────────────────────────────────────
496
+ # Linux: Prompt for firejail installation
497
+ # ─────────────────────────────────────────────────────────────
498
+ if IS_LINUX:
499
+ caps = get_capabilities()
500
+ if not caps.sandbox_available:
501
+ if with_sandbox or interactive:
502
+ from tweek.sandbox.linux import prompt_install_firejail
503
+ prompt_install_firejail(console)
504
+ else:
505
+ console.print("\n[yellow]Note:[/yellow] Sandbox (firejail) not installed.")
506
+ console.print(f"[dim]Install with: {caps.sandbox_install_hint}[/dim]")
507
+ console.print("[dim]Or run 'tweek install --with-sandbox' to install now[/dim]")
508
+
509
+ # ─────────────────────────────────────────────────────────────
510
+ # Configure Tweek proxy if override was enabled
511
+ # ─────────────────────────────────────────────────────────────
512
+ if proxy_override_enabled:
513
+ try:
514
+ import yaml
515
+ from tweek.proxy import TWEEK_DEFAULT_PORT
516
+
517
+ proxy_config_path = tweek_dir / "config.yaml"
518
+
519
+ # Load existing config or create new
520
+ if proxy_config_path.exists():
521
+ with open(proxy_config_path) as f:
522
+ tweek_config = yaml.safe_load(f) or {}
523
+ else:
524
+ tweek_config = {}
525
+
526
+ # Enable proxy with override settings
527
+ tweek_config["proxy"] = tweek_config.get("proxy", {})
528
+ tweek_config["proxy"]["enabled"] = True
529
+ tweek_config["proxy"]["port"] = TWEEK_DEFAULT_PORT
530
+ tweek_config["proxy"]["override_moltbot"] = True
531
+ tweek_config["proxy"]["auto_start"] = False # User must explicitly start
532
+
533
+ with open(proxy_config_path, "w") as f:
534
+ yaml.dump(tweek_config, f, default_flow_style=False)
535
+
536
+ console.print("\n[green]✓[/green] Proxy override configured")
537
+ console.print(f" [dim]Config saved to: {proxy_config_path}[/dim]")
538
+ console.print(" [yellow]Run 'tweek proxy start' to begin intercepting API calls[/yellow]")
539
+ except Exception as e:
540
+ console.print(f"\n[yellow]Warning: Could not save proxy config: {e}[/yellow]")
541
+
542
+ console.print("\n[green]Installation complete![/green]")
543
+ console.print("[dim]Run 'tweek status' to verify installation[/dim]")
544
+ console.print("[dim]Run 'tweek update' to get latest attack patterns[/dim]")
545
+ console.print("[dim]Run 'tweek config list' to see security settings[/dim]")
546
+ if proxy_override_enabled:
547
+ console.print("[dim]Run 'tweek proxy start' to enable API interception[/dim]")
548
+
549
+
550
+ @main.command(
551
+ epilog="""\b
552
+ Examples:
553
+ tweek uninstall Remove from global installation
554
+ tweek uninstall --scope project Remove from current project only
555
+ tweek uninstall --confirm Skip confirmation prompt
556
+ """
557
+ )
558
+ @click.option("--scope", type=click.Choice(["global", "project"]), default="global",
559
+ help="Uninstall scope: global (~/.claude) or project (./.claude)")
560
+ @click.option("--confirm", is_flag=True, help="Skip confirmation prompt")
561
+ def uninstall(scope: str, confirm: bool):
562
+ """Remove Tweek hooks from Claude Code.
563
+
564
+ Scope options:
565
+ --scope global : Remove from ~/.claude/ (affects all projects)
566
+ --scope project : Remove from ./.claude/ (this project only)
567
+ """
568
+ import json
569
+
570
+ console.print(TWEEK_BANNER, style="cyan")
571
+
572
+ # Determine target directory based on scope
573
+ if scope == "global":
574
+ target = Path("~/.claude").expanduser()
575
+ else: # project
576
+ target = Path.cwd() / ".claude"
577
+
578
+ # Check if Tweek is installed at target
579
+ settings_file = target / "settings.json"
580
+ tweek_installed = False
581
+
582
+ if settings_file.exists():
583
+ try:
584
+ with open(settings_file) as f:
585
+ settings = json.load(f)
586
+ if "hooks" in settings and "PreToolUse" in settings.get("hooks", {}):
587
+ for hook_config in settings["hooks"]["PreToolUse"]:
588
+ for hook in hook_config.get("hooks", []):
589
+ if "tweek" in hook.get("command", "").lower():
590
+ tweek_installed = True
591
+ break
592
+ except (json.JSONDecodeError, IOError):
593
+ pass
594
+
595
+ if not tweek_installed:
596
+ console.print(f"[yellow]No Tweek installation found at {target}[/yellow]")
597
+ return
598
+
599
+ console.print(f"[bold]Found Tweek installation at:[/bold] {target}")
600
+ console.print()
601
+
602
+ if not confirm:
603
+ if not click.confirm("[yellow]Remove Tweek hooks?[/yellow]"):
604
+ console.print("[dim]Cancelled[/dim]")
605
+ return
606
+
607
+ # Remove hooks
608
+ try:
609
+ with open(settings_file) as f:
610
+ settings = json.load(f)
611
+
612
+ # Remove Tweek PreToolUse hooks
613
+ if "hooks" in settings and "PreToolUse" in settings["hooks"]:
614
+ # Filter out Tweek hooks
615
+ pre_tool_hooks = settings["hooks"]["PreToolUse"]
616
+ filtered_hooks = []
617
+ for hook_config in pre_tool_hooks:
618
+ filtered_inner = []
619
+ for hook in hook_config.get("hooks", []):
620
+ if "tweek" not in hook.get("command", "").lower():
621
+ filtered_inner.append(hook)
622
+ if filtered_inner:
623
+ hook_config["hooks"] = filtered_inner
624
+ filtered_hooks.append(hook_config)
625
+
626
+ if filtered_hooks:
627
+ settings["hooks"]["PreToolUse"] = filtered_hooks
628
+ else:
629
+ del settings["hooks"]["PreToolUse"]
630
+
631
+ # Clean up empty hooks dict
632
+ if not settings["hooks"]:
633
+ del settings["hooks"]
634
+
635
+ with open(settings_file, "w") as f:
636
+ json.dump(settings, f, indent=2)
637
+
638
+ console.print(f"[green]✓[/green] Removed Tweek hooks from: {target}")
639
+
640
+ except Exception as e:
641
+ console.print(f"[red]✗[/red] Failed to update {target}: {e}")
642
+
643
+ console.print("\n[green]Uninstall complete![/green]")
644
+ console.print("[dim]Tweek data directory (~/.tweek) was preserved. Remove manually if desired.[/dim]")
645
+
646
+
647
+ @main.command(
648
+ epilog="""\b
649
+ Examples:
650
+ tweek update Download/update attack patterns
651
+ tweek update --check Check for updates without installing
652
+ """
653
+ )
654
+ @click.option("--check", is_flag=True, help="Check for updates without installing")
655
+ def update(check: bool):
656
+ """Update attack patterns from GitHub.
657
+
658
+ Patterns are stored in ~/.tweek/patterns/ and can be updated
659
+ independently of the Tweek application.
660
+
661
+ All 116 patterns are included free. PRO tier adds LLM review,
662
+ session analysis, and rate limiting.
663
+ """
664
+ import subprocess
665
+
666
+ patterns_dir = Path("~/.tweek/patterns").expanduser()
667
+ patterns_repo = "https://github.com/gettweek/tweek-patterns.git"
668
+
669
+ console.print(TWEEK_BANNER, style="cyan")
670
+
671
+ if not patterns_dir.exists():
672
+ # First time: clone the repo
673
+ if check:
674
+ console.print("[yellow]Patterns not installed.[/yellow]")
675
+ console.print(f"[dim]Run 'tweek update' to install from {patterns_repo}[/dim]")
676
+ return
677
+
678
+ console.print(f"[cyan]Installing patterns from {patterns_repo}...[/cyan]")
679
+
680
+ try:
681
+ result = subprocess.run(
682
+ ["git", "clone", "--depth", "1", patterns_repo, str(patterns_dir)],
683
+ capture_output=True,
684
+ text=True,
685
+ check=True
686
+ )
687
+ console.print("[green]✓[/green] Patterns installed successfully")
688
+
689
+ # Show pattern count
690
+ patterns_file = patterns_dir / "patterns.yaml"
691
+ if patterns_file.exists():
692
+ import yaml
693
+ with open(patterns_file) as f:
694
+ data = yaml.safe_load(f)
695
+ count = data.get("pattern_count", len(data.get("patterns", [])))
696
+ free_max = data.get("free_tier_max", 23)
697
+ console.print(f"[dim]Installed {count} patterns ({free_max} free, {count - free_max} pro)[/dim]")
698
+
699
+ except subprocess.CalledProcessError as e:
700
+ console.print(f"[red]✗[/red] Failed to clone patterns: {e.stderr}")
701
+ return
702
+ except FileNotFoundError:
703
+ console.print("[red]\u2717[/red] git not found.")
704
+ console.print(" [dim]Hint: Install git from https://git-scm.com/downloads[/dim]")
705
+ console.print(" [dim]On macOS: xcode-select --install[/dim]")
706
+ return
707
+
708
+ else:
709
+ # Update existing repo
710
+ if check:
711
+ console.print("[cyan]Checking for pattern updates...[/cyan]")
712
+ try:
713
+ result = subprocess.run(
714
+ ["git", "-C", str(patterns_dir), "fetch", "--dry-run"],
715
+ capture_output=True,
716
+ text=True
717
+ )
718
+ # Check if there are updates
719
+ result2 = subprocess.run(
720
+ ["git", "-C", str(patterns_dir), "status", "-uno"],
721
+ capture_output=True,
722
+ text=True
723
+ )
724
+ if "behind" in result2.stdout:
725
+ console.print("[yellow]Updates available.[/yellow]")
726
+ console.print("[dim]Run 'tweek update' to install[/dim]")
727
+ else:
728
+ console.print("[green]✓[/green] Patterns are up to date")
729
+ except Exception as e:
730
+ console.print(f"[red]✗[/red] Failed to check for updates: {e}")
731
+ return
732
+
733
+ console.print("[cyan]Updating patterns...[/cyan]")
734
+
735
+ try:
736
+ result = subprocess.run(
737
+ ["git", "-C", str(patterns_dir), "pull", "--ff-only"],
738
+ capture_output=True,
739
+ text=True,
740
+ check=True
741
+ )
742
+
743
+ if "Already up to date" in result.stdout:
744
+ console.print("[green]✓[/green] Patterns already up to date")
745
+ else:
746
+ console.print("[green]✓[/green] Patterns updated successfully")
747
+
748
+ # Show what changed
749
+ if result.stdout.strip():
750
+ console.print(f"[dim]{result.stdout.strip()}[/dim]")
751
+
752
+ except subprocess.CalledProcessError as e:
753
+ console.print(f"[red]✗[/red] Failed to update patterns: {e.stderr}")
754
+ console.print("[dim]Try: rm -rf ~/.tweek/patterns && tweek update[/dim]")
755
+ return
756
+
757
+ # Show current version info
758
+ patterns_file = patterns_dir / "patterns.yaml"
759
+ if patterns_file.exists():
760
+ import yaml
761
+ try:
762
+ with open(patterns_file) as f:
763
+ data = yaml.safe_load(f)
764
+ version = data.get("version", "?")
765
+ count = data.get("pattern_count", len(data.get("patterns", [])))
766
+
767
+ console.print()
768
+ console.print(f"[cyan]Pattern version:[/cyan] {version}")
769
+ console.print(f"[cyan]Total patterns:[/cyan] {count} (all included free)")
770
+
771
+ console.print(f"[cyan]All features:[/cyan] LLM review, session analysis, rate limiting, sandbox (open source)")
772
+ console.print(f"[dim]Pro (teams) and Enterprise (compliance) coming soon: gettweek.com[/dim]")
773
+
774
+ except Exception:
775
+ pass
776
+
777
+
778
+ @main.command(
779
+ epilog="""\b
780
+ Examples:
781
+ tweek doctor Run all health checks
782
+ tweek doctor --verbose Show detailed check information
783
+ tweek doctor --json Output results as JSON for scripting
784
+ """
785
+ )
786
+ @click.option("--verbose", "-v", is_flag=True, help="Show detailed check information")
787
+ @click.option("--json-output", "--json", "json_out", is_flag=True, help="Output results as JSON")
788
+ def doctor(verbose: bool, json_out: bool):
789
+ """Run health checks on your Tweek installation.
790
+
791
+ Checks hooks, configuration, patterns, database, vault, sandbox,
792
+ license, MCP, proxy, and plugin integrity.
793
+ """
794
+ from tweek.diagnostics import run_health_checks
795
+ from tweek.cli_helpers import print_doctor_results, print_doctor_json
796
+
797
+ checks = run_health_checks(verbose=verbose)
798
+
799
+ if json_out:
800
+ print_doctor_json(checks)
801
+ else:
802
+ print_doctor_results(checks)
803
+
804
+
805
+ @main.command(
806
+ epilog="""\b
807
+ Examples:
808
+ tweek quickstart Launch interactive setup wizard
809
+ """
810
+ )
811
+ def quickstart():
812
+ """Interactive first-run setup wizard.
813
+
814
+ Walks you through:
815
+ 1. Installing hooks (global or project scope)
816
+ 2. Choosing a security preset
817
+ 3. Verifying credential vault
818
+ 4. Optional MCP proxy setup
819
+ """
820
+ from tweek.config.manager import ConfigManager
821
+ from tweek.cli_helpers import print_success, print_warning, spinner
822
+
823
+ console.print(TWEEK_BANNER, style="cyan")
824
+ console.print("[bold]Welcome to Tweek![/bold]")
825
+ console.print()
826
+ console.print("This wizard will help you set up Tweek step by step.")
827
+ console.print(" 1. Install hooks")
828
+ console.print(" 2. Choose a security preset")
829
+ console.print(" 3. Verify credential vault")
830
+ console.print(" 4. Optional MCP proxy")
831
+ console.print()
832
+
833
+ # Step 1: Install hooks
834
+ console.print("[bold cyan]Step 1/4: Hook Installation[/bold cyan]")
835
+ scope_choice = click.prompt(
836
+ "Where should Tweek protect?",
837
+ type=click.Choice(["global", "project", "both"]),
838
+ default="global",
839
+ )
840
+
841
+ scopes = ["global", "project"] if scope_choice == "both" else [scope_choice]
842
+ for s in scopes:
843
+ try:
844
+ _quickstart_install_hooks(s)
845
+ print_success(f"Hooks installed ({s})")
846
+ except Exception as e:
847
+ print_warning(f"Could not install hooks ({s}): {e}")
848
+ console.print()
849
+
850
+ # Step 2: Security preset
851
+ console.print("[bold cyan]Step 2/4: Security Preset[/bold cyan]")
852
+ console.print(" [cyan]1.[/cyan] paranoid \u2014 Block everything suspicious, prompt on risky")
853
+ console.print(" [cyan]2.[/cyan] cautious \u2014 Block dangerous, prompt on risky [dim](recommended)[/dim]")
854
+ console.print(" [cyan]3.[/cyan] trusted \u2014 Allow most operations, block only dangerous")
855
+ console.print()
856
+
857
+ preset_choice = click.prompt(
858
+ "Select preset",
859
+ type=click.Choice(["1", "2", "3"]),
860
+ default="2",
861
+ )
862
+ preset_map = {"1": "paranoid", "2": "cautious", "3": "trusted"}
863
+ preset_name = preset_map[preset_choice]
864
+
865
+ try:
866
+ cfg = ConfigManager()
867
+ cfg.apply_preset(preset_name)
868
+ print_success(f"Applied {preset_name} preset")
869
+ except Exception as e:
870
+ print_warning(f"Could not apply preset: {e}")
871
+ console.print()
872
+
873
+ # Step 3: Credential vault
874
+ console.print("[bold cyan]Step 3/4: Credential Vault[/bold cyan]")
875
+ try:
876
+ from tweek.platform import get_capabilities
877
+ caps = get_capabilities()
878
+ if caps.vault_available:
879
+ print_success(f"{caps.vault_backend} detected. No configuration needed.")
880
+ else:
881
+ print_warning("No vault backend available. Credentials will use fallback storage.")
882
+ except Exception:
883
+ print_warning("Could not check vault availability.")
884
+ console.print()
885
+
886
+ # Step 4: Optional MCP proxy
887
+ console.print("[bold cyan]Step 4/4: MCP Proxy (optional)[/bold cyan]")
888
+ setup_mcp = click.confirm("Set up MCP proxy for Claude Desktop?", default=False)
889
+ if setup_mcp:
890
+ try:
891
+ import mcp # noqa: F401
892
+ console.print("[dim]MCP package available. Configure upstream servers in ~/.tweek/config.yaml[/dim]")
893
+ console.print("[dim]Then run: tweek mcp proxy[/dim]")
894
+ except ImportError:
895
+ print_warning("MCP package not installed. Install with: pip install tweek[mcp]")
896
+ else:
897
+ console.print("[dim]Skipped.[/dim]")
898
+
899
+ console.print()
900
+ console.print("[bold green]Setup complete![/bold green]")
901
+ console.print(" Run [cyan]tweek doctor[/cyan] to verify your installation")
902
+ console.print(" Run [cyan]tweek status[/cyan] to see protection status")
903
+
904
+
905
+ def _quickstart_install_hooks(scope: str) -> None:
906
+ """Install hooks for quickstart wizard (simplified version)."""
907
+ import json
908
+
909
+ if scope == "global":
910
+ target_dir = Path("~/.claude").expanduser()
911
+ else:
912
+ target_dir = Path.cwd() / ".claude"
913
+
914
+ hooks_dir = target_dir / "hooks"
915
+ hooks_dir.mkdir(parents=True, exist_ok=True)
916
+
917
+ settings_path = target_dir / "settings.json"
918
+ settings = {}
919
+ if settings_path.exists():
920
+ try:
921
+ with open(settings_path) as f:
922
+ settings = json.load(f)
923
+ except (json.JSONDecodeError, IOError):
924
+ pass
925
+
926
+ if "hooks" not in settings:
927
+ settings["hooks"] = {}
928
+
929
+ hook_entry = {
930
+ "type": "command",
931
+ "command": "tweek hook pre-tool-use $TOOL_NAME",
932
+ }
933
+
934
+ for hook_type in ["PreToolUse"]:
935
+ if hook_type not in settings["hooks"]:
936
+ settings["hooks"][hook_type] = []
937
+
938
+ # Check if tweek hooks already present
939
+ already_installed = False
940
+ for hook_config in settings["hooks"][hook_type]:
941
+ for h in hook_config.get("hooks", []):
942
+ if "tweek" in h.get("command", "").lower():
943
+ already_installed = True
944
+ break
945
+
946
+ if not already_installed:
947
+ settings["hooks"][hook_type].append({
948
+ "matcher": "",
949
+ "hooks": [hook_entry],
950
+ })
951
+
952
+ with open(settings_path, "w") as f:
953
+ json.dump(settings, f, indent=2)
954
+
955
+
956
+ # =============================================================================
957
+ # PROTECT COMMANDS - One-command setup for supported AI agents
958
+ # =============================================================================
959
+
960
+ @main.group(
961
+ epilog="""\b
962
+ Examples:
963
+ tweek protect moltbot One-command Moltbot protection
964
+ tweek protect moltbot --paranoid Use paranoid security preset
965
+ tweek protect moltbot --port 9999 Override gateway port
966
+ tweek protect claude Install Claude Code hooks (alias for tweek install)
967
+ """
968
+ )
969
+ def protect():
970
+ """Set up Tweek protection for a specific AI agent.
971
+
972
+ One-command setup that auto-detects, configures, and starts
973
+ screening all tool calls for your AI assistant.
974
+ """
975
+ pass
976
+
977
+
978
+ @protect.command(
979
+ "moltbot",
980
+ epilog="""\b
981
+ Examples:
982
+ tweek protect moltbot Auto-detect and protect Moltbot
983
+ tweek protect moltbot --paranoid Maximum security preset
984
+ tweek protect moltbot --port 9999 Custom gateway port
985
+ """
986
+ )
987
+ @click.option("--port", default=None, type=int,
988
+ help="Moltbot gateway port (default: auto-detect)")
989
+ @click.option("--paranoid", is_flag=True,
990
+ help="Use paranoid security preset (default: cautious)")
991
+ @click.option("--preset", type=click.Choice(["paranoid", "cautious", "trusted"]),
992
+ default=None, help="Security preset to apply")
993
+ def protect_moltbot(port, paranoid, preset):
994
+ """One-command Moltbot protection setup.
995
+
996
+ Auto-detects Moltbot, configures proxy wrapping,
997
+ and starts screening all tool calls through Tweek's
998
+ five-layer defense pipeline.
999
+ """
1000
+ from tweek.integrations.moltbot import (
1001
+ detect_moltbot_installation,
1002
+ setup_moltbot_protection,
1003
+ )
1004
+
1005
+ console.print(TWEEK_BANNER, style="cyan")
1006
+
1007
+ # Resolve preset
1008
+ if paranoid:
1009
+ effective_preset = "paranoid"
1010
+ elif preset:
1011
+ effective_preset = preset
1012
+ else:
1013
+ effective_preset = "cautious"
1014
+
1015
+ # Step 1: Detect Moltbot
1016
+ console.print("[cyan]Detecting Moltbot...[/cyan]")
1017
+ moltbot = detect_moltbot_installation()
1018
+
1019
+ if not moltbot["installed"]:
1020
+ console.print()
1021
+ console.print("[red]Moltbot not detected on this system.[/red]")
1022
+ console.print()
1023
+ console.print("[dim]Install Moltbot first:[/dim]")
1024
+ console.print(" npm install -g moltbot")
1025
+ console.print()
1026
+ console.print("[dim]Or if Moltbot is installed in a non-standard location,[/dim]")
1027
+ console.print("[dim]specify the gateway port manually:[/dim]")
1028
+ console.print(" tweek protect moltbot --port 18789")
1029
+ return
1030
+
1031
+ # Show detection results
1032
+ console.print()
1033
+ console.print(" [green]Moltbot detected[/green]")
1034
+
1035
+ if moltbot["version"]:
1036
+ console.print(f" Version: {moltbot['version']}")
1037
+
1038
+ console.print(f" Gateway: port {moltbot['gateway_port']}", end="")
1039
+ if moltbot["gateway_active"]:
1040
+ console.print(" [green](running)[/green]")
1041
+ elif moltbot["process_running"]:
1042
+ console.print(" [yellow](process running, gateway inactive)[/yellow]")
1043
+ else:
1044
+ console.print(" [dim](not running)[/dim]")
1045
+
1046
+ if moltbot["config_path"]:
1047
+ console.print(f" Config: {moltbot['config_path']}")
1048
+
1049
+ console.print()
1050
+
1051
+ # Step 2: Configure protection
1052
+ console.print("[cyan]Configuring Tweek protection...[/cyan]")
1053
+ result = setup_moltbot_protection(port=port, preset=effective_preset)
1054
+
1055
+ if not result.success:
1056
+ console.print(f"\n[red]Setup failed: {result.error}[/red]")
1057
+ return
1058
+
1059
+ # Show configuration
1060
+ console.print(f" Proxy: port {result.proxy_port} -> wrapping Moltbot gateway")
1061
+ console.print(f" Preset: {result.preset} (116 patterns + rate limiting)")
1062
+
1063
+ # Check for API key
1064
+ anthropic_key = os.environ.get("ANTHROPIC_API_KEY")
1065
+ if anthropic_key:
1066
+ console.print(" LLM Review: [green]active[/green] (ANTHROPIC_API_KEY found)")
1067
+ else:
1068
+ console.print(" LLM Review: [dim]available (set ANTHROPIC_API_KEY for semantic analysis)[/dim]")
1069
+
1070
+ # Show warnings
1071
+ for warning in result.warnings:
1072
+ console.print(f"\n [yellow]Warning: {warning}[/yellow]")
1073
+
1074
+ console.print()
1075
+
1076
+ if not moltbot["gateway_active"]:
1077
+ console.print("[yellow]Note: Moltbot gateway is not currently running.[/yellow]")
1078
+ console.print("[dim]Protection will activate when Moltbot starts.[/dim]")
1079
+ console.print()
1080
+
1081
+ console.print("[green]Protection configured.[/green] Screening all Moltbot tool calls.")
1082
+ console.print()
1083
+ console.print("[dim]Verify: tweek doctor[/dim]")
1084
+ console.print("[dim]Logs: tweek logs show[/dim]")
1085
+ console.print("[dim]Stop: tweek proxy stop[/dim]")
1086
+
1087
+
1088
+ @protect.command(
1089
+ "claude",
1090
+ epilog="""\b
1091
+ Examples:
1092
+ tweek protect claude Install Claude Code hooks (global)
1093
+ tweek protect claude --scope project Install for current project only
1094
+ """
1095
+ )
1096
+ @click.option("--scope", type=click.Choice(["global", "project"]), default="global",
1097
+ help="Installation scope: global (~/.claude) or project (./.claude)")
1098
+ @click.option("--preset", type=click.Choice(["paranoid", "cautious", "trusted"]),
1099
+ default=None, help="Security preset to apply")
1100
+ @click.pass_context
1101
+ def protect_claude(ctx, scope, preset):
1102
+ """Install Tweek hooks for Claude Code.
1103
+
1104
+ This is equivalent to 'tweek install' -- installs PreToolUse
1105
+ and PostToolUse hooks to screen all Claude Code tool calls.
1106
+ """
1107
+ # Delegate to the main install command
1108
+ # (use main.commands lookup to avoid name shadowing by mcp install)
1109
+ install_cmd = main.commands['install']
1110
+ ctx.invoke(
1111
+ install_cmd,
1112
+ scope=scope,
1113
+ dev_test=False,
1114
+ backup=True,
1115
+ skip_env_scan=False,
1116
+ interactive=False,
1117
+ preset=preset,
1118
+ ai_defaults=False,
1119
+ with_sandbox=False,
1120
+ force_proxy=False,
1121
+ skip_proxy_check=False,
1122
+ )
1123
+
1124
+
1125
+ # =============================================================================
1126
+ # CONFIG COMMANDS
1127
+ # =============================================================================
1128
+
1129
+ @main.group()
1130
+ def config():
1131
+ """Configure Tweek security policies."""
1132
+ pass
1133
+
1134
+
1135
+ @config.command("list",
1136
+ epilog="""\b
1137
+ Examples:
1138
+ tweek config list List all tools and skills
1139
+ tweek config list --tools Show only tool security tiers
1140
+ tweek config list --skills Show only skill security tiers
1141
+ tweek config list --summary Show tier counts and overrides summary
1142
+ """
1143
+ )
1144
+ @click.option("--tools", "show_tools", is_flag=True, help="Show tools only")
1145
+ @click.option("--skills", "show_skills", is_flag=True, help="Show skills only")
1146
+ @click.option("--summary", is_flag=True, help="Show configuration summary instead of full list")
1147
+ def config_list(show_tools: bool, show_skills: bool, summary: bool):
1148
+ """List all tools and skills with their security tiers."""
1149
+ from tweek.config.manager import ConfigManager
1150
+
1151
+ cfg = ConfigManager()
1152
+
1153
+ # Handle summary mode
1154
+ if summary:
1155
+ # Count by tier
1156
+ tool_tiers = {}
1157
+ for tool in cfg.list_tools():
1158
+ tier = tool.tier.value
1159
+ tool_tiers[tier] = tool_tiers.get(tier, 0) + 1
1160
+
1161
+ skill_tiers = {}
1162
+ for skill in cfg.list_skills():
1163
+ tier = skill.tier.value
1164
+ skill_tiers[tier] = skill_tiers.get(tier, 0) + 1
1165
+
1166
+ # User overrides
1167
+ user_config = cfg.export_config("user")
1168
+ user_tools = user_config.get("tools", {})
1169
+ user_skills = user_config.get("skills", {})
1170
+
1171
+ summary_text = f"[cyan]Default Tier:[/cyan] {cfg.get_default_tier().value}\n\n"
1172
+
1173
+ summary_text += "[cyan]Tools by Tier:[/cyan]\n"
1174
+ for tier in ["safe", "default", "risky", "dangerous"]:
1175
+ count = tool_tiers.get(tier, 0)
1176
+ if count:
1177
+ summary_text += f" {tier}: {count}\n"
1178
+
1179
+ summary_text += "\n[cyan]Skills by Tier:[/cyan]\n"
1180
+ for tier in ["safe", "default", "risky", "dangerous"]:
1181
+ count = skill_tiers.get(tier, 0)
1182
+ if count:
1183
+ summary_text += f" {tier}: {count}\n"
1184
+
1185
+ if user_tools or user_skills:
1186
+ summary_text += "\n[cyan]User Overrides:[/cyan]\n"
1187
+ for tool_name, tier in user_tools.items():
1188
+ summary_text += f" {tool_name}: {tier}\n"
1189
+ for skill_name, tier in user_skills.items():
1190
+ summary_text += f" {skill_name}: {tier}\n"
1191
+ else:
1192
+ summary_text += "\n[cyan]User Overrides:[/cyan] (none)"
1193
+
1194
+ console.print(Panel.fit(summary_text, title="Tweek Configuration"))
1195
+ return
1196
+
1197
+ # Default to showing both if neither specified
1198
+ if not show_tools and not show_skills:
1199
+ show_tools = show_skills = True
1200
+
1201
+ tier_styles = {
1202
+ "safe": "green",
1203
+ "default": "blue",
1204
+ "risky": "yellow",
1205
+ "dangerous": "red",
1206
+ }
1207
+
1208
+ source_styles = {
1209
+ "default": "dim",
1210
+ "user": "cyan",
1211
+ "project": "magenta",
1212
+ }
1213
+
1214
+ if show_tools:
1215
+ table = Table(title="Tool Security Tiers")
1216
+ table.add_column("Tool", style="bold")
1217
+ table.add_column("Tier")
1218
+ table.add_column("Source", style="dim")
1219
+ table.add_column("Description")
1220
+
1221
+ for tool in cfg.list_tools():
1222
+ tier_style = tier_styles.get(tool.tier.value, "white")
1223
+ source_style = source_styles.get(tool.source, "white")
1224
+ table.add_row(
1225
+ tool.name,
1226
+ f"[{tier_style}]{tool.tier.value}[/{tier_style}]",
1227
+ f"[{source_style}]{tool.source}[/{source_style}]",
1228
+ tool.description or ""
1229
+ )
1230
+
1231
+ console.print(table)
1232
+ console.print()
1233
+
1234
+ if show_skills:
1235
+ table = Table(title="Skill Security Tiers")
1236
+ table.add_column("Skill", style="bold")
1237
+ table.add_column("Tier")
1238
+ table.add_column("Source", style="dim")
1239
+ table.add_column("Description")
1240
+
1241
+ for skill in cfg.list_skills():
1242
+ tier_style = tier_styles.get(skill.tier.value, "white")
1243
+ source_style = source_styles.get(skill.source, "white")
1244
+ table.add_row(
1245
+ skill.name,
1246
+ f"[{tier_style}]{skill.tier.value}[/{tier_style}]",
1247
+ f"[{source_style}]{skill.source}[/{source_style}]",
1248
+ skill.description or ""
1249
+ )
1250
+
1251
+ console.print(table)
1252
+
1253
+ console.print("\n[dim]Tiers: safe (no checks) → default (regex) → risky (+LLM) → dangerous (+sandbox)[/dim]")
1254
+ console.print("[dim]Sources: default (built-in), user (~/.tweek/config.yaml), project (.tweek/config.yaml)[/dim]")
1255
+
1256
+
1257
+ @config.command("set",
1258
+ epilog="""\b
1259
+ Examples:
1260
+ tweek config set --tool Bash --tier dangerous Mark Bash as dangerous
1261
+ tweek config set --skill web-fetch --tier risky Set skill to risky tier
1262
+ tweek config set --tier cautious Set default tier for all
1263
+ tweek config set --tool Edit --tier safe --scope project Project-level override
1264
+ """
1265
+ )
1266
+ @click.option("--skill", help="Skill name to configure")
1267
+ @click.option("--tool", help="Tool name to configure")
1268
+ @click.option("--tier", type=click.Choice(["safe", "default", "risky", "dangerous"]), required=True,
1269
+ help="Security tier to set")
1270
+ @click.option("--scope", type=click.Choice(["user", "project"]), default="user",
1271
+ help="Config scope (user=global, project=this directory)")
1272
+ def config_set(skill: str, tool: str, tier: str, scope: str):
1273
+ """Set security tier for a skill or tool."""
1274
+ from tweek.config.manager import ConfigManager, SecurityTier
1275
+
1276
+ cfg = ConfigManager()
1277
+ tier_enum = SecurityTier.from_string(tier)
1278
+
1279
+ if skill:
1280
+ cfg.set_skill_tier(skill, tier_enum, scope=scope)
1281
+ console.print(f"[green]✓[/green] Set skill '{skill}' to [bold]{tier}[/bold] tier ({scope} config)")
1282
+ elif tool:
1283
+ cfg.set_tool_tier(tool, tier_enum, scope=scope)
1284
+ console.print(f"[green]✓[/green] Set tool '{tool}' to [bold]{tier}[/bold] tier ({scope} config)")
1285
+ else:
1286
+ cfg.set_default_tier(tier_enum, scope=scope)
1287
+ console.print(f"[green]✓[/green] Set default tier to [bold]{tier}[/bold] ({scope} config)")
1288
+
1289
+
1290
+ @config.command("preset",
1291
+ epilog="""\b
1292
+ Examples:
1293
+ tweek config preset paranoid Maximum security, prompt for everything
1294
+ tweek config preset cautious Balanced security (recommended)
1295
+ tweek config preset trusted Minimal prompts, trust AI decisions
1296
+ tweek config preset paranoid --scope project Apply preset to project only
1297
+ """
1298
+ )
1299
+ @click.argument("preset_name", type=click.Choice(["paranoid", "cautious", "trusted"]))
1300
+ @click.option("--scope", type=click.Choice(["user", "project"]), default="user")
1301
+ def config_preset(preset_name: str, scope: str):
1302
+ """Apply a configuration preset.
1303
+
1304
+ Presets:
1305
+ paranoid - Maximum security, prompt for everything
1306
+ cautious - Balanced security (recommended)
1307
+ trusted - Minimal prompts, trust AI decisions
1308
+ """
1309
+ from tweek.config.manager import ConfigManager
1310
+
1311
+ cfg = ConfigManager()
1312
+ cfg.apply_preset(preset_name, scope=scope)
1313
+
1314
+ console.print(f"[green]✓[/green] Applied [bold]{preset_name}[/bold] preset ({scope} config)")
1315
+
1316
+ if preset_name == "paranoid":
1317
+ console.print("[dim]All tools require screening, Bash commands always sandboxed[/dim]")
1318
+ elif preset_name == "cautious":
1319
+ console.print("[dim]Balanced: read-only tools safe, Bash dangerous[/dim]")
1320
+ elif preset_name == "trusted":
1321
+ console.print("[dim]Minimal prompts: only high-risk patterns trigger alerts[/dim]")
1322
+
1323
+
1324
+ @config.command("reset",
1325
+ epilog="""\b
1326
+ Examples:
1327
+ tweek config reset --tool Bash Reset Bash to default tier
1328
+ tweek config reset --skill web-fetch Reset a skill to default tier
1329
+ tweek config reset --all Reset all user configuration
1330
+ tweek config reset --all --confirm Reset all without confirmation prompt
1331
+ """
1332
+ )
1333
+ @click.option("--skill", help="Reset specific skill to default")
1334
+ @click.option("--tool", help="Reset specific tool to default")
1335
+ @click.option("--all", "reset_all", is_flag=True, help="Reset all user configuration")
1336
+ @click.option("--scope", type=click.Choice(["user", "project"]), default="user")
1337
+ @click.option("--confirm", is_flag=True, help="Skip confirmation prompt")
1338
+ def config_reset(skill: str, tool: str, reset_all: bool, scope: str, confirm: bool):
1339
+ """Reset configuration to defaults."""
1340
+ from tweek.config.manager import ConfigManager
1341
+
1342
+ cfg = ConfigManager()
1343
+
1344
+ if reset_all:
1345
+ if not confirm and not click.confirm(f"Reset ALL {scope} configuration?"):
1346
+ console.print("[dim]Cancelled[/dim]")
1347
+ return
1348
+ cfg.reset_all(scope=scope)
1349
+ console.print(f"[green]✓[/green] Reset all {scope} configuration to defaults")
1350
+ elif skill:
1351
+ if cfg.reset_skill(skill, scope=scope):
1352
+ console.print(f"[green]✓[/green] Reset skill '{skill}' to default")
1353
+ else:
1354
+ console.print(f"[yellow]![/yellow] Skill '{skill}' has no {scope} override")
1355
+ elif tool:
1356
+ if cfg.reset_tool(tool, scope=scope):
1357
+ console.print(f"[green]✓[/green] Reset tool '{tool}' to default")
1358
+ else:
1359
+ console.print(f"[yellow]![/yellow] Tool '{tool}' has no {scope} override")
1360
+ else:
1361
+ console.print("[red]Specify --skill, --tool, or --all[/red]")
1362
+
1363
+
1364
+ @config.command("validate",
1365
+ epilog="""\b
1366
+ Examples:
1367
+ tweek config validate Validate merged configuration
1368
+ tweek config validate --scope user Validate only user-level config
1369
+ tweek config validate --scope project Validate only project-level config
1370
+ tweek config validate --json Output validation results as JSON
1371
+ """
1372
+ )
1373
+ @click.option("--scope", type=click.Choice(["user", "project", "merged"]), default="merged",
1374
+ help="Which config scope to validate")
1375
+ @click.option("--json-output", "--json", "json_out", is_flag=True, help="Output as JSON")
1376
+ def config_validate(scope: str, json_out: bool):
1377
+ """Validate configuration for errors and typos.
1378
+
1379
+ Checks for unknown keys, invalid tier values, unknown tool/skill names,
1380
+ and suggests corrections for typos.
1381
+ """
1382
+ from tweek.config.manager import ConfigManager
1383
+
1384
+ cfg = ConfigManager()
1385
+ issues = cfg.validate_config(scope=scope)
1386
+
1387
+ if json_out:
1388
+ import json as json_mod
1389
+ output = [
1390
+ {
1391
+ "level": i.level,
1392
+ "key": i.key,
1393
+ "message": i.message,
1394
+ "suggestion": i.suggestion,
1395
+ }
1396
+ for i in issues
1397
+ ]
1398
+ console.print_json(json_mod.dumps(output, indent=2))
1399
+ return
1400
+
1401
+ console.print()
1402
+ console.print("[bold]Configuration Validation[/bold]")
1403
+ console.print("\u2500" * 40)
1404
+ console.print(f"[dim]Scope: {scope}[/dim]")
1405
+ console.print()
1406
+
1407
+ if not issues:
1408
+ tools = cfg.list_tools()
1409
+ skills = cfg.list_skills()
1410
+ console.print(f" [green]OK[/green] Configuration valid ({len(tools)} tools, {len(skills)} skills)")
1411
+ console.print()
1412
+ return
1413
+
1414
+ errors = [i for i in issues if i.level == "error"]
1415
+ warnings = [i for i in issues if i.level == "warning"]
1416
+
1417
+ level_styles = {
1418
+ "error": "[red]ERROR[/red]",
1419
+ "warning": "[yellow]WARN[/yellow] ",
1420
+ "info": "[dim]INFO[/dim] ",
1421
+ }
1422
+
1423
+ for issue in issues:
1424
+ style = level_styles.get(issue.level, "[dim]???[/dim] ")
1425
+ msg = f" {style} {issue.key} \u2192 {issue.message}"
1426
+ if issue.suggestion:
1427
+ msg += f" {issue.suggestion}"
1428
+ console.print(msg)
1429
+
1430
+ console.print()
1431
+ parts = []
1432
+ if errors:
1433
+ parts.append(f"{len(errors)} error{'s' if len(errors) != 1 else ''}")
1434
+ if warnings:
1435
+ parts.append(f"{len(warnings)} warning{'s' if len(warnings) != 1 else ''}")
1436
+ console.print(f" Result: {', '.join(parts)}")
1437
+ console.print()
1438
+
1439
+
1440
+ @config.command("diff",
1441
+ epilog="""\b
1442
+ Examples:
1443
+ tweek config diff paranoid Show changes if paranoid preset applied
1444
+ tweek config diff cautious Show changes if cautious preset applied
1445
+ tweek config diff trusted Show changes if trusted preset applied
1446
+ """
1447
+ )
1448
+ @click.argument("preset_name", type=click.Choice(["paranoid", "cautious", "trusted"]))
1449
+ def config_diff(preset_name: str):
1450
+ """Show what would change if a preset were applied.
1451
+
1452
+ Compare your current configuration against a preset to see
1453
+ exactly which settings would be modified.
1454
+ """
1455
+ from tweek.config.manager import ConfigManager
1456
+
1457
+ cfg = ConfigManager()
1458
+
1459
+ try:
1460
+ changes = cfg.diff_preset(preset_name)
1461
+ except ValueError as e:
1462
+ console.print(f"[red]Error: {e}[/red]")
1463
+ return
1464
+
1465
+ console.print()
1466
+ console.print(f"[bold]Changes if '{preset_name}' preset is applied:[/bold]")
1467
+ console.print("\u2500" * 50)
1468
+
1469
+ if not changes:
1470
+ console.print()
1471
+ console.print(" [green]No changes[/green] \u2014 your config already matches this preset.")
1472
+ console.print()
1473
+ return
1474
+
1475
+ table = Table(show_header=True, show_edge=False, pad_edge=False)
1476
+ table.add_column("Setting", style="cyan", min_width=25)
1477
+ table.add_column("Current", min_width=12)
1478
+ table.add_column("", min_width=3)
1479
+ table.add_column("New", min_width=12)
1480
+
1481
+ tier_colors = {"safe": "green", "default": "white", "risky": "yellow", "dangerous": "red"}
1482
+
1483
+ for change in changes:
1484
+ cur_color = tier_colors.get(str(change.current_value), "white")
1485
+ new_color = tier_colors.get(str(change.new_value), "white")
1486
+ table.add_row(
1487
+ change.key,
1488
+ f"[{cur_color}]{change.current_value}[/{cur_color}]",
1489
+ "\u2192",
1490
+ f"[{new_color}]{change.new_value}[/{new_color}]",
1491
+ )
1492
+
1493
+ console.print()
1494
+ console.print(table)
1495
+ console.print()
1496
+ console.print(f" {len(changes)} change{'s' if len(changes) != 1 else ''} would be made. "
1497
+ f"Apply with: [cyan]tweek config preset {preset_name}[/cyan]")
1498
+ console.print()
1499
+
1500
+
1501
+ @main.group()
1502
+ def vault():
1503
+ """Manage credentials in secure storage (Keychain on macOS, Secret Service on Linux)."""
1504
+ pass
1505
+
1506
+
1507
+ @vault.command("store",
1508
+ epilog="""\b
1509
+ Examples:
1510
+ tweek vault store myskill API_KEY sk-abc123 Store an API key
1511
+ tweek vault store deploy AWS_SECRET s3cr3t Store a deployment secret
1512
+ """
1513
+ )
1514
+ @click.argument("skill")
1515
+ @click.argument("key")
1516
+ @click.argument("value")
1517
+ def vault_store(skill: str, key: str, value: str):
1518
+ """Store a credential securely for a skill."""
1519
+ from tweek.vault import get_vault, VAULT_AVAILABLE
1520
+ from tweek.platform import get_capabilities
1521
+
1522
+ if not VAULT_AVAILABLE:
1523
+ console.print("[red]\u2717[/red] Vault not available.")
1524
+ console.print(" [dim]Hint: Install keyring support: pip install keyring[/dim]")
1525
+ console.print(" [dim]On macOS, keyring uses Keychain. On Linux, install gnome-keyring or kwallet.[/dim]")
1526
+ return
1527
+
1528
+ caps = get_capabilities()
1529
+
1530
+ try:
1531
+ vault_instance = get_vault()
1532
+ if vault_instance.store(skill, key, value):
1533
+ console.print(f"[green]\u2713[/green] Stored {key} for skill '{skill}'")
1534
+ console.print(f"[dim]Backend: {caps.vault_backend}[/dim]")
1535
+ else:
1536
+ console.print(f"[red]\u2717[/red] Failed to store credential")
1537
+ console.print(" [dim]Hint: Check your keyring backend is unlocked and accessible[/dim]")
1538
+ except Exception as e:
1539
+ console.print(f"[red]\u2717[/red] Failed to store credential: {e}")
1540
+ console.print(" [dim]Hint: Check your keyring backend is unlocked and accessible[/dim]")
1541
+
1542
+
1543
+ @vault.command("get",
1544
+ epilog="""\b
1545
+ Examples:
1546
+ tweek vault get myskill API_KEY Retrieve a stored credential
1547
+ tweek vault get deploy AWS_SECRET Retrieve a deployment secret
1548
+ """
1549
+ )
1550
+ @click.argument("skill")
1551
+ @click.argument("key")
1552
+ def vault_get(skill: str, key: str):
1553
+ """Retrieve a credential from secure storage."""
1554
+ from tweek.vault import get_vault, VAULT_AVAILABLE
1555
+
1556
+ if not VAULT_AVAILABLE:
1557
+ console.print("[red]\u2717[/red] Vault not available.")
1558
+ console.print(" [dim]Hint: Install keyring support: pip install keyring[/dim]")
1559
+ return
1560
+
1561
+ vault_instance = get_vault()
1562
+ value = vault_instance.get(skill, key)
1563
+
1564
+ if value is not None:
1565
+ console.print(f"[yellow]GAH![/yellow] Credential access logged")
1566
+ console.print(value)
1567
+ else:
1568
+ console.print(f"[red]\u2717[/red] Credential not found: {key} for skill '{skill}'")
1569
+ console.print(" [dim]Hint: Store it with: tweek vault store {skill} {key} <value>[/dim]".format(skill=skill, key=key))
1570
+
1571
+
1572
+ @vault.command("migrate-env",
1573
+ epilog="""\b
1574
+ Examples:
1575
+ tweek vault migrate-env --skill myapp Migrate .env to vault
1576
+ tweek vault migrate-env --skill myapp --dry-run Preview without changes
1577
+ tweek vault migrate-env --skill deploy --env-file .env.production Migrate specific file
1578
+ """
1579
+ )
1580
+ @click.option("--dry-run", is_flag=True, help="Show what would be migrated without doing it")
1581
+ @click.option("--env-file", default=".env", help="Path to .env file")
1582
+ @click.option("--skill", required=True, help="Skill name to store credentials under")
1583
+ def vault_migrate_env(dry_run: bool, env_file: str, skill: str):
1584
+ """Migrate credentials from .env file to secure storage."""
1585
+ from tweek.vault import get_vault, migrate_env_to_vault, VAULT_AVAILABLE
1586
+
1587
+ if not VAULT_AVAILABLE:
1588
+ console.print("[red]✗[/red] Vault not available. Install keyring: pip install keyring")
1589
+ return
1590
+
1591
+ env_path = Path(env_file)
1592
+ console.print(f"[cyan]Scanning {env_path} for credentials...[/cyan]")
1593
+
1594
+ if dry_run:
1595
+ console.print("\n[yellow]DRY RUN - No changes will be made[/yellow]\n")
1596
+
1597
+ try:
1598
+ vault_instance = get_vault()
1599
+ results = migrate_env_to_vault(env_path, skill, vault_instance, dry_run=dry_run)
1600
+
1601
+ if results:
1602
+ console.print(f"\n[green]{'Would migrate' if dry_run else 'Migrated'}:[/green]")
1603
+ for key, success in results:
1604
+ status = "✓" if success else "✗"
1605
+ console.print(f" {status} {key}")
1606
+ successful = sum(1 for _, s in results if s)
1607
+ console.print(f"\n[green]✓[/green] {'Would migrate' if dry_run else 'Migrated'} {successful} credentials to skill '{skill}'")
1608
+ else:
1609
+ console.print("[dim]No credentials found to migrate[/dim]")
1610
+
1611
+ except Exception as e:
1612
+ console.print(f"[red]✗[/red] Migration failed: {e}")
1613
+
1614
+
1615
+ @vault.command("delete",
1616
+ epilog="""\b
1617
+ Examples:
1618
+ tweek vault delete myskill API_KEY Delete a stored credential
1619
+ tweek vault delete deploy AWS_SECRET Remove a deployment secret
1620
+ """
1621
+ )
1622
+ @click.argument("skill")
1623
+ @click.argument("key")
1624
+ def vault_delete(skill: str, key: str):
1625
+ """Delete a credential from secure storage."""
1626
+ from tweek.vault import get_vault, VAULT_AVAILABLE
1627
+
1628
+ if not VAULT_AVAILABLE:
1629
+ console.print("[red]✗[/red] Vault not available. Install keyring: pip install keyring")
1630
+ return
1631
+
1632
+ vault_instance = get_vault()
1633
+ deleted = vault_instance.delete(skill, key)
1634
+
1635
+ if deleted:
1636
+ console.print(f"[green]✓[/green] Deleted {key} from skill '{skill}'")
1637
+ else:
1638
+ console.print(f"[yellow]![/yellow] Credential not found: {key} for skill '{skill}'")
1639
+
1640
+
1641
+ # ============================================================
1642
+ # LICENSE COMMANDS
1643
+ # ============================================================
1644
+
1645
+ @main.group()
1646
+ def license():
1647
+ """Manage Tweek license and features."""
1648
+ pass
1649
+
1650
+
1651
+ @license.command("status",
1652
+ epilog="""\b
1653
+ Examples:
1654
+ tweek license status Show license tier and features
1655
+ """
1656
+ )
1657
+ def license_status():
1658
+ """Show current license status and available features."""
1659
+ from tweek.licensing import get_license, TIER_FEATURES, Tier
1660
+
1661
+ console.print(TWEEK_BANNER, style="cyan")
1662
+
1663
+ lic = get_license()
1664
+ info = lic.info
1665
+
1666
+ # License info
1667
+ tier_colors = {
1668
+ Tier.FREE: "white",
1669
+ Tier.PRO: "cyan",
1670
+ }
1671
+
1672
+ tier_color = tier_colors.get(lic.tier, "white")
1673
+ console.print(f"[bold]License Tier:[/bold] [{tier_color}]{lic.tier.value.upper()}[/{tier_color}]")
1674
+
1675
+ if info:
1676
+ console.print(f"[dim]Licensed to: {info.email}[/dim]")
1677
+ if info.expires_at:
1678
+ from datetime import datetime
1679
+ exp_date = datetime.fromtimestamp(info.expires_at).strftime("%Y-%m-%d")
1680
+ if info.is_expired:
1681
+ console.print(f"[red]Expired: {exp_date}[/red]")
1682
+ else:
1683
+ console.print(f"[dim]Expires: {exp_date}[/dim]")
1684
+ else:
1685
+ console.print("[dim]Expires: Never[/dim]")
1686
+ console.print()
1687
+
1688
+ # Features table
1689
+ table = Table(title="Feature Availability")
1690
+ table.add_column("Feature", style="cyan")
1691
+ table.add_column("Status")
1692
+ table.add_column("Tier Required")
1693
+
1694
+ # Collect all features and their required tiers
1695
+ feature_tiers = {}
1696
+ for tier in [Tier.FREE, Tier.PRO]:
1697
+ for feature in TIER_FEATURES.get(tier, []):
1698
+ feature_tiers[feature] = tier
1699
+
1700
+ for feature, required_tier in feature_tiers.items():
1701
+ has_it = lic.has_feature(feature)
1702
+ status = "[green]✓[/green]" if has_it else "[dim]○[/dim]"
1703
+ tier_display = required_tier.value.upper()
1704
+ if required_tier == Tier.PRO:
1705
+ tier_display = f"[cyan]{tier_display}[/cyan]"
1706
+
1707
+ table.add_row(feature, status, tier_display)
1708
+
1709
+ console.print(table)
1710
+
1711
+ if lic.tier == Tier.FREE:
1712
+ console.print()
1713
+ console.print("[green]All security features are included free and open source.[/green]")
1714
+ console.print("[dim]Pro (teams) and Enterprise (compliance) coming soon: gettweek.com[/dim]")
1715
+
1716
+
1717
+ @license.command("activate",
1718
+ epilog="""\b
1719
+ Examples:
1720
+ tweek license activate YOUR_KEY Activate a license key (Pro/Enterprise coming soon)
1721
+ """
1722
+ )
1723
+ @click.argument("license_key")
1724
+ def license_activate(license_key: str):
1725
+ """Activate a license key."""
1726
+ from tweek.licensing import get_license
1727
+
1728
+ lic = get_license()
1729
+ success, message = lic.activate(license_key)
1730
+
1731
+ if success:
1732
+ console.print(f"[green]✓[/green] {message}")
1733
+ console.print()
1734
+ console.print("[dim]Run 'tweek license status' to see available features[/dim]")
1735
+ else:
1736
+ console.print(f"[red]✗[/red] {message}")
1737
+
1738
+
1739
+ @license.command("deactivate",
1740
+ epilog="""\b
1741
+ Examples:
1742
+ tweek license deactivate Deactivate license (with prompt)
1743
+ tweek license deactivate --confirm Deactivate without confirmation
1744
+ """
1745
+ )
1746
+ @click.option("--confirm", is_flag=True, help="Skip confirmation prompt")
1747
+ def license_deactivate(confirm: bool):
1748
+ """Remove current license and revert to FREE tier."""
1749
+ from tweek.licensing import get_license
1750
+
1751
+ if not confirm:
1752
+ if not click.confirm("[yellow]Deactivate license and revert to FREE tier?[/yellow]"):
1753
+ console.print("[dim]Cancelled[/dim]")
1754
+ return
1755
+
1756
+ lic = get_license()
1757
+ success, message = lic.deactivate()
1758
+
1759
+ if success:
1760
+ console.print(f"[green]✓[/green] {message}")
1761
+ else:
1762
+ console.print(f"[red]✗[/red] {message}")
1763
+
1764
+
1765
+ # ============================================================
1766
+ # LOGS COMMANDS
1767
+ # ============================================================
1768
+
1769
+ @main.group()
1770
+ def logs():
1771
+ """View and manage security logs."""
1772
+ pass
1773
+
1774
+
1775
+ @logs.command("show",
1776
+ epilog="""\b
1777
+ Examples:
1778
+ tweek logs show Show last 20 security events
1779
+ tweek logs show -n 50 Show last 50 events
1780
+ tweek logs show --type block Filter by event type
1781
+ tweek logs show --blocked Show only blocked/flagged events
1782
+ tweek logs show --stats Show security statistics summary
1783
+ tweek logs show --stats --days 30 Statistics for the last 30 days
1784
+ """
1785
+ )
1786
+ @click.option("--limit", "-n", default=20, help="Number of events to show")
1787
+ @click.option("--type", "-t", "event_type", help="Filter by event type")
1788
+ @click.option("--tool", help="Filter by tool name")
1789
+ @click.option("--blocked", is_flag=True, help="Show only blocked/flagged events")
1790
+ @click.option("--stats", is_flag=True, help="Show security statistics instead of events")
1791
+ @click.option("--days", "-d", default=7, help="Number of days to analyze (with --stats)")
1792
+ def logs_show(limit: int, event_type: str, tool: str, blocked: bool, stats: bool, days: int):
1793
+ """Show recent security events."""
1794
+ from tweek.logging.security_log import get_logger
1795
+
1796
+ console.print(TWEEK_BANNER, style="cyan")
1797
+
1798
+ logger = get_logger()
1799
+
1800
+ # Handle stats mode
1801
+ if stats:
1802
+ stat_data = logger.get_stats(days=days)
1803
+
1804
+ console.print(Panel.fit(
1805
+ f"[cyan]Period:[/cyan] Last {days} days\n"
1806
+ f"[cyan]Total Events:[/cyan] {stat_data['total_events']}",
1807
+ title="Security Statistics"
1808
+ ))
1809
+
1810
+ # Decisions breakdown
1811
+ if stat_data['by_decision']:
1812
+ table = Table(title="Decisions")
1813
+ table.add_column("Decision", style="cyan")
1814
+ table.add_column("Count", justify="right")
1815
+
1816
+ decision_styles = {"allow": "green", "block": "red", "ask": "yellow", "deny": "red"}
1817
+ for decision, count in stat_data['by_decision'].items():
1818
+ style = decision_styles.get(decision, "white")
1819
+ table.add_row(f"[{style}]{decision}[/{style}]", str(count))
1820
+
1821
+ console.print(table)
1822
+ console.print()
1823
+
1824
+ # Top triggered patterns
1825
+ if stat_data['top_patterns']:
1826
+ table = Table(title="Top Triggered Patterns")
1827
+ table.add_column("Pattern", style="cyan")
1828
+ table.add_column("Severity")
1829
+ table.add_column("Count", justify="right")
1830
+
1831
+ severity_styles = {"critical": "red", "high": "yellow", "medium": "blue", "low": "dim"}
1832
+ for pattern in stat_data['top_patterns']:
1833
+ sev = pattern['severity'] or "unknown"
1834
+ style = severity_styles.get(sev, "white")
1835
+ table.add_row(
1836
+ pattern['name'] or "unknown",
1837
+ f"[{style}]{sev}[/{style}]",
1838
+ str(pattern['count'])
1839
+ )
1840
+
1841
+ console.print(table)
1842
+ console.print()
1843
+
1844
+ # By tool
1845
+ if stat_data['by_tool']:
1846
+ table = Table(title="Events by Tool")
1847
+ table.add_column("Tool", style="green")
1848
+ table.add_column("Count", justify="right")
1849
+
1850
+ for tool_name, count in stat_data['by_tool'].items():
1851
+ table.add_row(tool_name, str(count))
1852
+
1853
+ console.print(table)
1854
+ return
1855
+
1856
+ from tweek.logging.security_log import EventType
1857
+
1858
+ if blocked:
1859
+ events = logger.get_blocked_commands(limit=limit)
1860
+ title = "Recent Blocked/Flagged Commands"
1861
+ else:
1862
+ et = None
1863
+ if event_type:
1864
+ try:
1865
+ et = EventType(event_type)
1866
+ except ValueError:
1867
+ console.print(f"[red]Unknown event type: {event_type}[/red]")
1868
+ console.print(f"[dim]Valid types: {', '.join(e.value for e in EventType)}[/dim]")
1869
+ return
1870
+
1871
+ events = logger.get_recent_events(limit=limit, event_type=et, tool_name=tool)
1872
+ title = "Recent Security Events"
1873
+
1874
+ if not events:
1875
+ console.print("[yellow]No events found[/yellow]")
1876
+ return
1877
+
1878
+ table = Table(title=title)
1879
+ table.add_column("Time", style="dim")
1880
+ table.add_column("Type", style="cyan")
1881
+ table.add_column("Tool", style="green")
1882
+ table.add_column("Tier")
1883
+ table.add_column("Decision")
1884
+ table.add_column("Pattern/Reason", max_width=30)
1885
+
1886
+ decision_styles = {
1887
+ "allow": "green",
1888
+ "block": "red",
1889
+ "ask": "yellow",
1890
+ "deny": "red",
1891
+ }
1892
+
1893
+ for event in events:
1894
+ timestamp = event.get("timestamp", "")
1895
+ if timestamp:
1896
+ # Format timestamp nicely
1897
+ try:
1898
+ dt = datetime.fromisoformat(timestamp)
1899
+ timestamp = dt.strftime("%m/%d %H:%M:%S")
1900
+ except (ValueError, TypeError):
1901
+ pass
1902
+
1903
+ decision = event.get("decision", "")
1904
+ decision_style = decision_styles.get(decision, "white")
1905
+
1906
+ reason = event.get("pattern_name") or event.get("decision_reason", "")
1907
+ if len(str(reason)) > 30:
1908
+ reason = str(reason)[:27] + "..."
1909
+
1910
+ table.add_row(
1911
+ timestamp,
1912
+ event.get("event_type", ""),
1913
+ event.get("tool_name", ""),
1914
+ event.get("tier", ""),
1915
+ f"[{decision_style}]{decision}[/{decision_style}]" if decision else "",
1916
+ str(reason)
1917
+ )
1918
+
1919
+ console.print(table)
1920
+ console.print(f"\n[dim]Showing {len(events)} events. Use --limit to see more.[/dim]")
1921
+
1922
+
1923
+ @logs.command("export",
1924
+ epilog="""\b
1925
+ Examples:
1926
+ tweek logs export Export all logs to tweek_security_log.csv
1927
+ tweek logs export --days 7 Export only the last 7 days
1928
+ tweek logs export -o audit.csv Export to a custom file path
1929
+ tweek logs export --days 30 -o monthly.csv Last 30 days to custom file
1930
+ """
1931
+ )
1932
+ @click.option("--days", "-d", type=int, help="Limit to last N days")
1933
+ @click.option("--output", "-o", default="tweek_security_log.csv", help="Output file path")
1934
+ def logs_export(days: int, output: str):
1935
+ """Export security logs to CSV."""
1936
+ from tweek.logging.security_log import get_logger
1937
+
1938
+ logger = get_logger()
1939
+ output_path = Path(output)
1940
+
1941
+ count = logger.export_csv(output_path, days=days)
1942
+
1943
+ if count > 0:
1944
+ console.print(f"[green]✓[/green] Exported {count} events to {output_path}")
1945
+ else:
1946
+ console.print("[yellow]No events to export[/yellow]")
1947
+
1948
+
1949
+ @logs.command("clear",
1950
+ epilog="""\b
1951
+ Examples:
1952
+ tweek logs clear Clear all security logs (with prompt)
1953
+ tweek logs clear --days 30 Clear logs older than 30 days
1954
+ tweek logs clear --confirm Clear all logs without confirmation
1955
+ """
1956
+ )
1957
+ @click.option("--days", "-d", type=int, help="Clear events older than N days")
1958
+ @click.option("--confirm", is_flag=True, help="Skip confirmation prompt")
1959
+ def logs_clear(days: int, confirm: bool):
1960
+ """Clear security logs."""
1961
+ from tweek.logging.security_log import get_logger
1962
+
1963
+ if not confirm:
1964
+ if days:
1965
+ msg = f"Clear all events older than {days} days?"
1966
+ else:
1967
+ msg = "Clear ALL security logs?"
1968
+
1969
+ if not click.confirm(f"[yellow]{msg}[/yellow]"):
1970
+ console.print("[dim]Cancelled[/dim]")
1971
+ return
1972
+
1973
+ logger = get_logger()
1974
+ deleted = logger.delete_events(days=days)
1975
+
1976
+ if deleted > 0:
1977
+ if days:
1978
+ console.print(f"[green]Cleared {deleted} event(s) older than {days} days[/green]")
1979
+ else:
1980
+ console.print(f"[green]Cleared {deleted} event(s)[/green]")
1981
+ else:
1982
+ console.print("[dim]No events to clear[/dim]")
1983
+
1984
+
1985
+ @logs.command("bundle",
1986
+ epilog="""\b
1987
+ Examples:
1988
+ tweek logs bundle Create diagnostic bundle
1989
+ tweek logs bundle -o /tmp/diag.zip Specify output path
1990
+ tweek logs bundle --days 7 Only last 7 days of events
1991
+ tweek logs bundle --dry-run Show what would be collected
1992
+ """
1993
+ )
1994
+ @click.option("--output", "-o", type=click.Path(), help="Output zip file path")
1995
+ @click.option("--days", "-d", type=int, help="Only include events from last N days")
1996
+ @click.option("--no-redact", is_flag=True, help="Skip redaction (for internal debugging)")
1997
+ @click.option("--dry-run", is_flag=True, help="Show what would be collected")
1998
+ def logs_bundle(output: str, days: int, no_redact: bool, dry_run: bool):
1999
+ """Create a diagnostic bundle for support.
2000
+
2001
+ Collects security logs, configs (redacted), system info, and
2002
+ doctor output into a zip file suitable for sending to Tweek support.
2003
+
2004
+ Sensitive data (API keys, passwords, tokens) is automatically
2005
+ redacted before inclusion.
2006
+ """
2007
+ from tweek.logging.bundle import BundleCollector
2008
+
2009
+ collector = BundleCollector(redact=not no_redact, days=days)
2010
+
2011
+ if dry_run:
2012
+ report = collector.get_dry_run_report()
2013
+ console.print("[bold]Diagnostic Bundle - Dry Run[/bold]\n")
2014
+ for item in report:
2015
+ status = item.get("status", "unknown")
2016
+ name = item.get("file", "?")
2017
+ size = item.get("size")
2018
+ size_str = f" ({size:,} bytes)" if size else ""
2019
+ if "not found" in status:
2020
+ console.print(f" [dim] SKIP {name} ({status})[/dim]")
2021
+ else:
2022
+ console.print(f" [green] ADD {name}{size_str}[/green]")
2023
+ console.print()
2024
+ console.print("[dim]No files will be collected in dry-run mode.[/dim]")
2025
+ return
2026
+
2027
+ # Determine output path
2028
+ if not output:
2029
+ ts = datetime.now().strftime("%Y-%m-%d_%H%M%S")
2030
+ output = f"tweek_diagnostic_bundle_{ts}.zip"
2031
+
2032
+ from pathlib import Path
2033
+ from datetime import datetime
2034
+ output_path = Path(output)
2035
+
2036
+ console.print("[bold]Creating diagnostic bundle...[/bold]")
2037
+
2038
+ try:
2039
+ result = collector.create_bundle(output_path)
2040
+ size = result.stat().st_size
2041
+ console.print(f"\n[green]Bundle created: {result}[/green]")
2042
+ console.print(f"[dim]Size: {size:,} bytes[/dim]")
2043
+ if not no_redact:
2044
+ console.print("[dim]Sensitive data has been redacted.[/dim]")
2045
+ console.print(f"\n[bold]Send this file to Tweek support for analysis.[/bold]")
2046
+ except Exception as e:
2047
+ console.print(f"[red]Failed to create bundle: {e}[/red]")
2048
+
2049
+
2050
+ # ============================================================
2051
+ # PROXY COMMANDS (Optional - requires pip install tweek[proxy])
2052
+ # ============================================================
2053
+
2054
+ @main.group()
2055
+ def proxy():
2056
+ """LLM API security proxy for universal protection.
2057
+
2058
+ The proxy intercepts LLM API traffic and screens for dangerous tool calls.
2059
+ Works with any application that calls Anthropic, OpenAI, or other LLM APIs.
2060
+
2061
+ \b
2062
+ Install dependencies: pip install tweek[proxy]
2063
+ Quick start:
2064
+ tweek proxy start # Start the proxy
2065
+ tweek proxy trust # Install CA certificate
2066
+ tweek proxy wrap moltbot "npm start" # Wrap an app
2067
+ """
2068
+ pass
2069
+
2070
+
2071
+ @proxy.command("start",
2072
+ epilog="""\b
2073
+ Examples:
2074
+ tweek proxy start Start proxy on default port (9877)
2075
+ tweek proxy start --port 8080 Start proxy on custom port
2076
+ tweek proxy start --foreground Run in foreground for debugging
2077
+ tweek proxy start --log-only Log traffic without blocking
2078
+ """
2079
+ )
2080
+ @click.option("--port", "-p", default=9877, help="Port for proxy to listen on")
2081
+ @click.option("--web-port", type=int, help="Port for web interface (disabled by default)")
2082
+ @click.option("--foreground", "-f", is_flag=True, help="Run in foreground (for debugging)")
2083
+ @click.option("--log-only", is_flag=True, help="Log only, don't block dangerous requests")
2084
+ def proxy_start(port: int, web_port: int, foreground: bool, log_only: bool):
2085
+ """Start the Tweek LLM security proxy."""
2086
+ from tweek.proxy import PROXY_AVAILABLE, PROXY_MISSING_DEPS
2087
+
2088
+ if not PROXY_AVAILABLE:
2089
+ console.print("[red]\u2717[/red] Proxy dependencies not installed.")
2090
+ console.print(" [dim]Hint: Install with: pip install tweek[proxy][/dim]")
2091
+ console.print(" [dim]This adds mitmproxy for HTTP(S) interception.[/dim]")
2092
+ return
2093
+
2094
+ from tweek.proxy.server import start_proxy
2095
+
2096
+ console.print(f"[cyan]Starting Tweek proxy on port {port}...[/cyan]")
2097
+
2098
+ success, message = start_proxy(
2099
+ port=port,
2100
+ web_port=web_port,
2101
+ log_only=log_only,
2102
+ foreground=foreground,
2103
+ )
2104
+
2105
+ if success:
2106
+ console.print(f"[green]✓[/green] {message}")
2107
+ console.print()
2108
+ console.print("[bold]To use the proxy:[/bold]")
2109
+ console.print(f" export HTTPS_PROXY=http://127.0.0.1:{port}")
2110
+ console.print(f" export HTTP_PROXY=http://127.0.0.1:{port}")
2111
+ console.print()
2112
+ console.print("[dim]Or use 'tweek proxy wrap' to create a wrapper script[/dim]")
2113
+ else:
2114
+ console.print(f"[red]✗[/red] {message}")
2115
+
2116
+
2117
+ @proxy.command("stop",
2118
+ epilog="""\b
2119
+ Examples:
2120
+ tweek proxy stop Stop the running proxy server
2121
+ """
2122
+ )
2123
+ def proxy_stop():
2124
+ """Stop the Tweek LLM security proxy."""
2125
+ from tweek.proxy import PROXY_AVAILABLE
2126
+
2127
+ if not PROXY_AVAILABLE:
2128
+ console.print("[red]✗[/red] Proxy dependencies not installed.")
2129
+ return
2130
+
2131
+ from tweek.proxy.server import stop_proxy
2132
+
2133
+ success, message = stop_proxy()
2134
+
2135
+ if success:
2136
+ console.print(f"[green]✓[/green] {message}")
2137
+ else:
2138
+ console.print(f"[yellow]![/yellow] {message}")
2139
+
2140
+
2141
+ @proxy.command("trust",
2142
+ epilog="""\b
2143
+ Examples:
2144
+ tweek proxy trust Install CA certificate for HTTPS interception
2145
+ """
2146
+ )
2147
+ def proxy_trust():
2148
+ """Install the proxy CA certificate in system trust store.
2149
+
2150
+ This is required for HTTPS interception to work. The certificate
2151
+ is generated locally and only used for local proxy traffic.
2152
+ """
2153
+ from tweek.proxy import PROXY_AVAILABLE
2154
+
2155
+ if not PROXY_AVAILABLE:
2156
+ console.print("[red]✗[/red] Proxy dependencies not installed.")
2157
+ console.print("[dim]Run: pip install tweek\\[proxy][/dim]")
2158
+ return
2159
+
2160
+ from tweek.proxy.server import install_ca_certificate, get_proxy_info
2161
+
2162
+ info = get_proxy_info()
2163
+
2164
+ console.print("[bold]Tweek Proxy Certificate Installation[/bold]")
2165
+ console.print()
2166
+ console.print("This will install a local CA certificate to enable HTTPS interception.")
2167
+ console.print("The certificate is generated on YOUR machine and never transmitted.")
2168
+ console.print()
2169
+ console.print(f"[dim]Certificate location: {info['ca_cert']}[/dim]")
2170
+ console.print()
2171
+
2172
+ if not click.confirm("Install certificate? (requires admin password)"):
2173
+ console.print("[dim]Cancelled[/dim]")
2174
+ return
2175
+
2176
+ success, message = install_ca_certificate()
2177
+
2178
+ if success:
2179
+ console.print(f"[green]✓[/green] {message}")
2180
+ else:
2181
+ console.print(f"[red]✗[/red] {message}")
2182
+
2183
+
2184
+ @proxy.command("config",
2185
+ epilog="""\b
2186
+ Examples:
2187
+ tweek proxy config --enabled Enable proxy in configuration
2188
+ tweek proxy config --disabled Disable proxy in configuration
2189
+ tweek proxy config --enabled --port 8080 Enable proxy on custom port
2190
+ """
2191
+ )
2192
+ @click.option("--enabled", "set_enabled", is_flag=True, help="Enable proxy in configuration")
2193
+ @click.option("--disabled", "set_disabled", is_flag=True, help="Disable proxy in configuration")
2194
+ @click.option("--port", "-p", default=9877, help="Port for proxy")
2195
+ def proxy_config(set_enabled, set_disabled, port):
2196
+ """Configure proxy settings."""
2197
+ if not set_enabled and not set_disabled:
2198
+ console.print("[red]Specify --enabled or --disabled[/red]")
2199
+ return
2200
+
2201
+ import yaml
2202
+ config_path = Path.home() / ".tweek" / "config.yaml"
2203
+ config_path.parent.mkdir(parents=True, exist_ok=True)
2204
+
2205
+ config = {}
2206
+ if config_path.exists():
2207
+ try:
2208
+ with open(config_path) as f:
2209
+ config = yaml.safe_load(f) or {}
2210
+ except Exception:
2211
+ pass
2212
+
2213
+ if set_enabled:
2214
+ config["proxy"] = {
2215
+ "enabled": True,
2216
+ "port": port,
2217
+ "block_mode": True,
2218
+ "log_only": False,
2219
+ }
2220
+
2221
+ with open(config_path, "w") as f:
2222
+ yaml.dump(config, f, default_flow_style=False)
2223
+
2224
+ console.print(f"[green]✓[/green] Proxy mode enabled (port {port})")
2225
+ console.print("[dim]Run 'tweek proxy start' to start the proxy[/dim]")
2226
+
2227
+ elif set_disabled:
2228
+ if "proxy" in config:
2229
+ config["proxy"]["enabled"] = False
2230
+
2231
+ with open(config_path, "w") as f:
2232
+ yaml.dump(config, f, default_flow_style=False)
2233
+
2234
+ console.print("[green]✓[/green] Proxy mode disabled")
2235
+
2236
+
2237
+ @proxy.command("wrap",
2238
+ epilog="""\b
2239
+ Examples:
2240
+ tweek proxy wrap moltbot "npm start" Wrap a Node.js app
2241
+ tweek proxy wrap cursor "/Applications/Cursor.app/Contents/MacOS/Cursor"
2242
+ tweek proxy wrap myapp "python serve.py" -o run.sh Custom output path
2243
+ tweek proxy wrap myapp "npm start" --port 8080 Use custom proxy port
2244
+ """
2245
+ )
2246
+ @click.argument("app_name")
2247
+ @click.argument("command")
2248
+ @click.option("--output", "-o", help="Output script path (default: ./run-{app_name}-protected.sh)")
2249
+ @click.option("--port", "-p", default=9877, help="Proxy port")
2250
+ def proxy_wrap(app_name: str, command: str, output: str, port: int):
2251
+ """Generate a wrapper script to run an app through the proxy."""
2252
+ from tweek.proxy.server import generate_wrapper_script
2253
+
2254
+ if output:
2255
+ output_path = Path(output)
2256
+ else:
2257
+ output_path = Path(f"./run-{app_name}-protected.sh")
2258
+
2259
+ script = generate_wrapper_script(command, port=port, output_path=output_path)
2260
+
2261
+ console.print(f"[green]✓[/green] Created wrapper script: {output_path}")
2262
+ console.print()
2263
+ console.print("[bold]Usage:[/bold]")
2264
+ console.print(f" chmod +x {output_path}")
2265
+ console.print(f" ./{output_path.name}")
2266
+ console.print()
2267
+ console.print("[dim]The script will:[/dim]")
2268
+ console.print("[dim] 1. Start Tweek proxy if not running[/dim]")
2269
+ console.print("[dim] 2. Set proxy environment variables[/dim]")
2270
+ console.print(f"[dim] 3. Run: {command}[/dim]")
2271
+
2272
+
2273
+ @proxy.command("setup",
2274
+ epilog="""\b
2275
+ Examples:
2276
+ tweek proxy setup Launch interactive proxy setup wizard
2277
+ """
2278
+ )
2279
+ def proxy_setup():
2280
+ """Interactive setup wizard for the HTTP proxy.
2281
+
2282
+ Walks through:
2283
+ 1. Detecting LLM tools to protect
2284
+ 2. Generating and trusting CA certificate
2285
+ 3. Configuring shell environment variables
2286
+ """
2287
+ from tweek.cli_helpers import print_success, print_warning, print_error, spinner
2288
+
2289
+ console.print()
2290
+ console.print("[bold]HTTP Proxy Setup[/bold]")
2291
+ console.print("\u2500" * 30)
2292
+ console.print()
2293
+
2294
+ # Check dependencies
2295
+ try:
2296
+ from tweek.proxy import PROXY_AVAILABLE, PROXY_MISSING_DEPS
2297
+ except ImportError:
2298
+ print_error(
2299
+ "Proxy module not available",
2300
+ fix_hint="Install with: pip install tweek[proxy]",
2301
+ )
2302
+ return
2303
+
2304
+ if not PROXY_AVAILABLE:
2305
+ print_error(
2306
+ "Proxy dependencies not installed",
2307
+ fix_hint="Install with: pip install tweek[proxy]",
2308
+ )
2309
+ return
2310
+
2311
+ # Step 1: Detect tools
2312
+ console.print("[bold cyan]Step 1/3: Detect LLM Tools[/bold cyan]")
2313
+ try:
2314
+ from tweek.proxy import detect_supported_tools
2315
+ with spinner("Scanning for LLM tools"):
2316
+ tools = detect_supported_tools()
2317
+
2318
+ detected = [(name, info) for name, info in tools.items() if info]
2319
+ if detected:
2320
+ for name, info in detected:
2321
+ print_success(f"Found {name.capitalize()}")
2322
+ else:
2323
+ print_warning("No LLM tools detected. You can still set up the proxy manually.")
2324
+ except Exception as e:
2325
+ print_warning(f"Could not detect tools: {e}")
2326
+ console.print()
2327
+
2328
+ # Step 2: CA Certificate
2329
+ console.print("[bold cyan]Step 2/3: CA Certificate[/bold cyan]")
2330
+ setup_cert = click.confirm("Generate and trust Tweek CA certificate?", default=True)
2331
+ if setup_cert:
2332
+ try:
2333
+ from tweek.proxy.cert import generate_ca, trust_ca
2334
+ with spinner("Generating CA certificate"):
2335
+ generate_ca()
2336
+ print_success("CA certificate generated")
2337
+
2338
+ with spinner("Installing to system trust store"):
2339
+ trust_ca()
2340
+ print_success("Certificate trusted")
2341
+ except ImportError:
2342
+ print_warning("Certificate module not available. Run: tweek proxy trust")
2343
+ except Exception as e:
2344
+ print_warning(f"Could not set up certificate: {e}")
2345
+ console.print(" [dim]You can do this later with: tweek proxy trust[/dim]")
2346
+ else:
2347
+ console.print(" [dim]Skipped. Run 'tweek proxy trust' later.[/dim]")
2348
+ console.print()
2349
+
2350
+ # Step 3: Shell environment
2351
+ console.print("[bold cyan]Step 3/3: Environment Variables[/bold cyan]")
2352
+ port = click.prompt("Proxy port", default=9877, type=int)
2353
+
2354
+ shell_rc = _detect_shell_rc()
2355
+ if shell_rc:
2356
+ console.print(f" Detected shell config: {shell_rc}")
2357
+ console.print(f" Will add:")
2358
+ console.print(f" export HTTP_PROXY=http://127.0.0.1:{port}")
2359
+ console.print(f" export HTTPS_PROXY=http://127.0.0.1:{port}")
2360
+ console.print()
2361
+
2362
+ apply_env = click.confirm(f"Add to {shell_rc}?", default=True)
2363
+ if apply_env:
2364
+ try:
2365
+ rc_path = Path(shell_rc).expanduser()
2366
+ with open(rc_path, "a") as f:
2367
+ f.write(f"\n# Tweek proxy environment\n")
2368
+ f.write(f"export HTTP_PROXY=http://127.0.0.1:{port}\n")
2369
+ f.write(f"export HTTPS_PROXY=http://127.0.0.1:{port}\n")
2370
+ print_success(f"Added to {shell_rc}")
2371
+ console.print(f" [dim]Restart your shell or run: source {shell_rc}[/dim]")
2372
+ except Exception as e:
2373
+ print_warning(f"Could not write to {shell_rc}: {e}")
2374
+ else:
2375
+ console.print(" [dim]Skipped. Set HTTP_PROXY and HTTPS_PROXY manually.[/dim]")
2376
+ else:
2377
+ console.print(" [dim]Could not detect shell config file.[/dim]")
2378
+ console.print(f" Add these to your shell profile:")
2379
+ console.print(f" export HTTP_PROXY=http://127.0.0.1:{port}")
2380
+ console.print(f" export HTTPS_PROXY=http://127.0.0.1:{port}")
2381
+
2382
+ console.print()
2383
+ console.print("[bold green]Proxy configured![/bold green]")
2384
+ console.print(" Start with: [cyan]tweek proxy start[/cyan]")
2385
+ console.print()
2386
+
2387
+
2388
+ def _detect_shell_rc() -> str:
2389
+ """Detect the user's shell config file."""
2390
+ shell = os.environ.get("SHELL", "")
2391
+ home = Path.home()
2392
+
2393
+ if "zsh" in shell:
2394
+ return "~/.zshrc"
2395
+ elif "bash" in shell:
2396
+ if (home / ".bash_profile").exists():
2397
+ return "~/.bash_profile"
2398
+ return "~/.bashrc"
2399
+ elif "fish" in shell:
2400
+ return "~/.config/fish/config.fish"
2401
+ return ""
2402
+
2403
+
2404
+ # ============================================================
2405
+ # PLUGINS COMMANDS
2406
+ # ============================================================
2407
+
2408
+ @main.group()
2409
+ def plugins():
2410
+ """Manage Tweek plugins (compliance, providers, detectors, screening)."""
2411
+ pass
2412
+
2413
+
2414
+ @plugins.command("list",
2415
+ epilog="""\b
2416
+ Examples:
2417
+ tweek plugins list List all enabled plugins
2418
+ tweek plugins list --all Include disabled plugins
2419
+ tweek plugins list -c compliance Show only compliance plugins
2420
+ tweek plugins list -c screening Show only screening plugins
2421
+ """
2422
+ )
2423
+ @click.option("--category", "-c", type=click.Choice(["compliance", "providers", "detectors", "screening"]),
2424
+ help="Filter by plugin category")
2425
+ @click.option("--all", "show_all", is_flag=True, help="Show all plugins including disabled")
2426
+ def plugins_list(category: str, show_all: bool):
2427
+ """List installed plugins."""
2428
+ try:
2429
+ from tweek.plugins import get_registry, init_plugins, PluginCategory, LicenseTier
2430
+ from tweek.config.manager import ConfigManager
2431
+
2432
+ init_plugins()
2433
+ registry = get_registry()
2434
+ cfg = ConfigManager()
2435
+
2436
+ category_map = {
2437
+ "compliance": PluginCategory.COMPLIANCE,
2438
+ "providers": PluginCategory.LLM_PROVIDER,
2439
+ "detectors": PluginCategory.TOOL_DETECTOR,
2440
+ "screening": PluginCategory.SCREENING,
2441
+ }
2442
+
2443
+ categories = [category_map[category]] if category else list(PluginCategory)
2444
+
2445
+ for cat in categories:
2446
+ cat_name = cat.value.split(".")[-1]
2447
+ plugins_list = registry.list_plugins(cat)
2448
+
2449
+ if not plugins_list and not show_all:
2450
+ continue
2451
+
2452
+ table = Table(title=f"{cat_name.replace('_', ' ').title()} Plugins")
2453
+ table.add_column("Name", style="cyan")
2454
+ table.add_column("Version")
2455
+ table.add_column("Source")
2456
+ table.add_column("Enabled")
2457
+ table.add_column("License")
2458
+ table.add_column("Description", max_width=40)
2459
+
2460
+ for info in plugins_list:
2461
+ if not show_all and not info.enabled:
2462
+ continue
2463
+
2464
+ # Get config status
2465
+ plugin_cfg = cfg.get_plugin_config(cat_name, info.name)
2466
+
2467
+ license_tier = info.metadata.requires_license
2468
+ license_style = "green" if license_tier == LicenseTier.FREE else "cyan"
2469
+
2470
+ source_str = info.source.value if hasattr(info, 'source') else "builtin"
2471
+ source_style = "blue" if source_str == "git" else "dim"
2472
+
2473
+ table.add_row(
2474
+ info.name,
2475
+ info.metadata.version,
2476
+ f"[{source_style}]{source_str}[/{source_style}]",
2477
+ "[green]✓[/green]" if info.enabled else "[red]✗[/red]",
2478
+ f"[{license_style}]{license_tier.value}[/{license_style}]",
2479
+ info.metadata.description[:40] + "..." if len(info.metadata.description) > 40 else info.metadata.description,
2480
+ )
2481
+
2482
+ console.print(table)
2483
+ console.print()
2484
+
2485
+ except ImportError as e:
2486
+ console.print(f"[red]Plugin system not available: {e}[/red]")
2487
+
2488
+
2489
+ @plugins.command("info",
2490
+ epilog="""\b
2491
+ Examples:
2492
+ tweek plugins info hipaa Show details for the hipaa plugin
2493
+ tweek plugins info pii -c compliance Specify category explicitly
2494
+ """
2495
+ )
2496
+ @click.argument("plugin_name")
2497
+ @click.option("--category", "-c", type=click.Choice(["compliance", "providers", "detectors", "screening"]),
2498
+ help="Plugin category (auto-detected if not specified)")
2499
+ def plugins_info(plugin_name: str, category: str):
2500
+ """Show detailed information about a plugin."""
2501
+ try:
2502
+ from tweek.plugins import get_registry, init_plugins, PluginCategory
2503
+ from tweek.config.manager import ConfigManager
2504
+
2505
+ init_plugins()
2506
+ registry = get_registry()
2507
+ cfg = ConfigManager()
2508
+
2509
+ category_map = {
2510
+ "compliance": PluginCategory.COMPLIANCE,
2511
+ "providers": PluginCategory.LLM_PROVIDER,
2512
+ "detectors": PluginCategory.TOOL_DETECTOR,
2513
+ "screening": PluginCategory.SCREENING,
2514
+ }
2515
+
2516
+ # Find the plugin
2517
+ found_info = None
2518
+ found_cat = None
2519
+
2520
+ if category:
2521
+ cat_enum = category_map[category]
2522
+ found_info = registry.get_info(plugin_name, cat_enum)
2523
+ found_cat = category
2524
+ else:
2525
+ # Search all categories
2526
+ for cat_name, cat_enum in category_map.items():
2527
+ info = registry.get_info(plugin_name, cat_enum)
2528
+ if info:
2529
+ found_info = info
2530
+ found_cat = cat_name
2531
+ break
2532
+
2533
+ if not found_info:
2534
+ console.print(f"[red]Plugin not found: {plugin_name}[/red]")
2535
+ return
2536
+
2537
+ # Get config
2538
+ plugin_cfg = cfg.get_plugin_config(found_cat, plugin_name)
2539
+
2540
+ console.print(f"\n[bold]{found_info.name}[/bold] ({found_cat})")
2541
+ console.print(f"[dim]{found_info.metadata.description}[/dim]")
2542
+ console.print()
2543
+
2544
+ table = Table(show_header=False)
2545
+ table.add_column("Key", style="cyan")
2546
+ table.add_column("Value")
2547
+
2548
+ table.add_row("Version", found_info.metadata.version)
2549
+ table.add_row("Author", found_info.metadata.author or "Unknown")
2550
+ table.add_row("License Required", found_info.metadata.requires_license.value.upper())
2551
+ table.add_row("Enabled", "Yes" if found_info.enabled else "No")
2552
+ table.add_row("Config Source", plugin_cfg.source)
2553
+
2554
+ if found_info.metadata.tags:
2555
+ table.add_row("Tags", ", ".join(found_info.metadata.tags))
2556
+
2557
+ if plugin_cfg.settings:
2558
+ table.add_row("Settings", str(plugin_cfg.settings))
2559
+
2560
+ if found_info.load_error:
2561
+ table.add_row("[red]Load Error[/red]", found_info.load_error)
2562
+
2563
+ console.print(table)
2564
+
2565
+ except ImportError as e:
2566
+ console.print(f"[red]Plugin system not available: {e}[/red]")
2567
+
2568
+
2569
+ @plugins.command("set",
2570
+ epilog="""\b
2571
+ Examples:
2572
+ tweek plugins set hipaa --enabled -c compliance Enable a plugin
2573
+ tweek plugins set hipaa --disabled -c compliance Disable a plugin
2574
+ tweek plugins set hipaa threshold 0.8 -c compliance Set a config value
2575
+ tweek plugins set hipaa --scope-tools Bash,Edit -c compliance Scope to tools
2576
+ tweek plugins set hipaa --scope-clear -c compliance Clear scoping
2577
+ """
2578
+ )
2579
+ @click.argument("plugin_name")
2580
+ @click.argument("key", required=False)
2581
+ @click.argument("value", required=False)
2582
+ @click.option("--category", "-c", type=click.Choice(["compliance", "providers", "detectors", "screening"]),
2583
+ required=True, help="Plugin category")
2584
+ @click.option("--scope", type=click.Choice(["user", "project"]), default="user")
2585
+ @click.option("--enabled", "set_enabled", is_flag=True, help="Enable the plugin")
2586
+ @click.option("--disabled", "set_disabled", is_flag=True, help="Disable the plugin")
2587
+ @click.option("--scope-tools", help="Comma-separated tool names for scoping")
2588
+ @click.option("--scope-skills", help="Comma-separated skill names for scoping")
2589
+ @click.option("--scope-tiers", help="Comma-separated tiers for scoping")
2590
+ @click.option("--scope-clear", is_flag=True, help="Clear all scoping")
2591
+ def plugins_set(plugin_name: str, key: str, value: str, category: str, scope: str,
2592
+ set_enabled: bool, set_disabled: bool, scope_tools: str,
2593
+ scope_skills: str, scope_tiers: str, scope_clear: bool):
2594
+ """Set a plugin configuration value, enable/disable, or configure scope."""
2595
+ from tweek.config.manager import ConfigManager
2596
+ import json
2597
+
2598
+ cfg = ConfigManager()
2599
+
2600
+ # Handle enable/disable
2601
+ if set_enabled:
2602
+ cfg.set_plugin_enabled(category, plugin_name, True, scope=scope)
2603
+ console.print(f"[green]✓[/green] Enabled plugin '{plugin_name}' ({category}) - {scope} config")
2604
+ return
2605
+ if set_disabled:
2606
+ cfg.set_plugin_enabled(category, plugin_name, False, scope=scope)
2607
+ console.print(f"[green]✓[/green] Disabled plugin '{plugin_name}' ({category}) - {scope} config")
2608
+ return
2609
+
2610
+ # Handle scope configuration
2611
+ if scope_clear:
2612
+ cfg.set_plugin_scope(plugin_name, None)
2613
+ console.print(f"[green]✓[/green] Cleared scope for {plugin_name} (now global)")
2614
+ return
2615
+
2616
+ if any([scope_tools, scope_skills, scope_tiers]):
2617
+ scope_config = {}
2618
+ if scope_tools:
2619
+ scope_config["tools"] = [t.strip() for t in scope_tools.split(",")]
2620
+ if scope_skills:
2621
+ scope_config["skills"] = [s.strip() for s in scope_skills.split(",")]
2622
+ if scope_tiers:
2623
+ scope_config["tiers"] = [t.strip() for t in scope_tiers.split(",")]
2624
+ cfg.set_plugin_scope(plugin_name, scope_config)
2625
+ console.print(f"[green]✓[/green] Updated scope for {plugin_name}")
2626
+ return
2627
+
2628
+ # Handle key=value setting
2629
+ if not key or not value:
2630
+ console.print("[red]Specify key and value, or use --enabled/--disabled/--scope-* flags[/red]")
2631
+ return
2632
+
2633
+ # Try to parse value as JSON (for booleans, numbers, objects)
2634
+ try:
2635
+ parsed_value = json.loads(value)
2636
+ except json.JSONDecodeError:
2637
+ parsed_value = value
2638
+
2639
+ cfg.set_plugin_setting(category, plugin_name, key, parsed_value, scope=scope)
2640
+ console.print(f"[green]✓[/green] Set {plugin_name}.{key} = {parsed_value} ({scope} config)")
2641
+
2642
+
2643
+ @plugins.command("reset",
2644
+ epilog="""\b
2645
+ Examples:
2646
+ tweek plugins reset hipaa -c compliance Reset hipaa plugin to defaults
2647
+ tweek plugins reset pii -c compliance --scope project Reset project-level config
2648
+ """
2649
+ )
2650
+ @click.argument("plugin_name")
2651
+ @click.option("--category", "-c", type=click.Choice(["compliance", "providers", "detectors", "screening"]),
2652
+ required=True, help="Plugin category")
2653
+ @click.option("--scope", type=click.Choice(["user", "project"]), default="user")
2654
+ def plugins_reset(plugin_name: str, category: str, scope: str):
2655
+ """Reset a plugin to default configuration."""
2656
+ from tweek.config.manager import ConfigManager
2657
+
2658
+ cfg = ConfigManager()
2659
+
2660
+ if cfg.reset_plugin(category, plugin_name, scope=scope):
2661
+ console.print(f"[green]✓[/green] Reset plugin '{plugin_name}' to defaults ({scope} config)")
2662
+ else:
2663
+ console.print(f"[yellow]![/yellow] Plugin '{plugin_name}' has no {scope} configuration to reset")
2664
+
2665
+
2666
+ @plugins.command("scan",
2667
+ epilog="""\b
2668
+ Examples:
2669
+ tweek plugins scan "This is TOP SECRET//NOFORN" Scan text for compliance
2670
+ tweek plugins scan "Patient MRN: 123456" --plugin hipaa Use specific plugin
2671
+ tweek plugins scan @file.txt Scan file contents
2672
+ tweek plugins scan "SSN: 123-45-6789" -d input Scan incoming data
2673
+ """
2674
+ )
2675
+ @click.argument("content")
2676
+ @click.option("--direction", "-d", type=click.Choice(["input", "output"]), default="output",
2677
+ help="Scan direction (input=incoming data, output=LLM response)")
2678
+ @click.option("--plugin", "-p", help="Specific compliance plugin to use (default: all enabled)")
2679
+ def plugins_scan(content: str, direction: str, plugin: str):
2680
+ """Run compliance scan on content."""
2681
+ try:
2682
+ from tweek.plugins import get_registry, init_plugins, PluginCategory
2683
+ from tweek.plugins.base import ScanDirection
2684
+
2685
+ # Handle file input
2686
+ if content.startswith("@"):
2687
+ file_path = Path(content[1:])
2688
+ if file_path.exists():
2689
+ content = file_path.read_text()
2690
+ else:
2691
+ console.print(f"[red]File not found: {file_path}[/red]")
2692
+ return
2693
+
2694
+ init_plugins()
2695
+ registry = get_registry()
2696
+ direction_enum = ScanDirection(direction)
2697
+
2698
+ total_findings = []
2699
+
2700
+ if plugin:
2701
+ # Scan with specific plugin
2702
+ plugin_instance = registry.get(plugin, PluginCategory.COMPLIANCE)
2703
+ if not plugin_instance:
2704
+ console.print(f"[red]Plugin not found: {plugin}[/red]")
2705
+ return
2706
+ plugins_to_use = [plugin_instance]
2707
+ else:
2708
+ # Use all enabled compliance plugins
2709
+ plugins_to_use = registry.get_all(PluginCategory.COMPLIANCE)
2710
+
2711
+ if not plugins_to_use:
2712
+ console.print("[yellow]No compliance plugins enabled.[/yellow]")
2713
+ console.print("[dim]Enable plugins with: tweek plugins enable <name> -c compliance[/dim]")
2714
+ return
2715
+
2716
+ for p in plugins_to_use:
2717
+ result = p.scan(content, direction_enum)
2718
+
2719
+ if result.findings:
2720
+ console.print(f"\n[bold]{p.name.upper()}[/bold]: {len(result.findings)} finding(s)")
2721
+
2722
+ for finding in result.findings:
2723
+ severity_styles = {
2724
+ "critical": "red bold",
2725
+ "high": "red",
2726
+ "medium": "yellow",
2727
+ "low": "dim",
2728
+ }
2729
+ style = severity_styles.get(finding.severity.value, "white")
2730
+
2731
+ console.print(f" [{style}]{finding.severity.value.upper()}[/{style}] {finding.pattern_name}")
2732
+ console.print(f" [dim]Matched: {finding.matched_text[:60]}{'...' if len(finding.matched_text) > 60 else ''}[/dim]")
2733
+ if finding.description:
2734
+ console.print(f" {finding.description}")
2735
+
2736
+ total_findings.extend(result.findings)
2737
+
2738
+ if not total_findings:
2739
+ console.print("[green]✓[/green] No compliance issues found")
2740
+ else:
2741
+ console.print(f"\n[yellow]Total: {len(total_findings)} finding(s)[/yellow]")
2742
+
2743
+ except ImportError as e:
2744
+ console.print(f"[red]Plugin system not available: {e}[/red]")
2745
+
2746
+
2747
+ # ============================================================
2748
+ # GIT PLUGIN MANAGEMENT COMMANDS
2749
+ # ============================================================
2750
+
2751
+ @plugins.command("install",
2752
+ epilog="""\b
2753
+ Examples:
2754
+ tweek plugins install hipaa-scanner Install a plugin by name
2755
+ tweek plugins install hipaa-scanner -v 1.2.0 Install a specific version
2756
+ tweek plugins install _ --from-lockfile Install all from lockfile
2757
+ tweek plugins install hipaa-scanner --no-verify Skip verification (not recommended)
2758
+ """
2759
+ )
2760
+ @click.argument("name")
2761
+ @click.option("--version", "-v", "version", default=None, help="Specific version to install")
2762
+ @click.option("--from-lockfile", is_flag=True, help="Install all plugins from lockfile")
2763
+ @click.option("--no-verify", is_flag=True, help="Skip security verification (not recommended)")
2764
+ def plugins_install(name: str, version: str, from_lockfile: bool, no_verify: bool):
2765
+ """Install a plugin from the Tweek registry."""
2766
+ try:
2767
+ from tweek.plugins.git_installer import GitPluginInstaller
2768
+ from tweek.plugins.git_registry import PluginRegistryClient
2769
+ from tweek.plugins.git_lockfile import PluginLockfile
2770
+
2771
+ if from_lockfile:
2772
+ lockfile = PluginLockfile()
2773
+ if not lockfile.has_lockfile:
2774
+ console.print("[red]No lockfile found. Run 'tweek plugins lock' first.[/red]")
2775
+ return
2776
+
2777
+ locks = lockfile.load()
2778
+ registry = PluginRegistryClient()
2779
+ installer = GitPluginInstaller(registry_client=registry)
2780
+
2781
+ for plugin_name, lock in locks.items():
2782
+ console.print(f"Installing {plugin_name} v{lock.version}...")
2783
+ success, msg = installer.install(
2784
+ plugin_name,
2785
+ version=lock.version,
2786
+ verify=not no_verify,
2787
+ )
2788
+ if success:
2789
+ console.print(f" [green]✓[/green] {msg}")
2790
+ else:
2791
+ console.print(f" [red]✗[/red] {msg}")
2792
+ return
2793
+
2794
+ registry = PluginRegistryClient()
2795
+ installer = GitPluginInstaller(registry_client=registry)
2796
+
2797
+ from tweek.cli_helpers import spinner as cli_spinner
2798
+
2799
+ with cli_spinner(f"Installing {name}"):
2800
+ success, msg = installer.install(name, version=version, verify=not no_verify)
2801
+
2802
+ if success:
2803
+ console.print(f"[green]\u2713[/green] {msg}")
2804
+ else:
2805
+ console.print(f"[red]\u2717[/red] {msg}")
2806
+ console.print(f" [dim]Hint: Check network connectivity or try: tweek plugins registry --refresh[/dim]")
2807
+
2808
+ except Exception as e:
2809
+ console.print(f"[red]Error: {e}[/red]")
2810
+ console.print(f" [dim]Hint: Check network connectivity and try again[/dim]")
2811
+
2812
+
2813
+ @plugins.command("update",
2814
+ epilog="""\b
2815
+ Examples:
2816
+ tweek plugins update hipaa-scanner Update a specific plugin
2817
+ tweek plugins update --all Update all installed plugins
2818
+ tweek plugins update --check Check for available updates
2819
+ tweek plugins update hipaa-scanner -v 2.0.0 Update to specific version
2820
+ """
2821
+ )
2822
+ @click.argument("name", required=False)
2823
+ @click.option("--all", "update_all", is_flag=True, help="Update all installed plugins")
2824
+ @click.option("--check", "check_only", is_flag=True, help="Check for updates without installing")
2825
+ @click.option("--version", "-v", "version", default=None, help="Specific version to update to")
2826
+ @click.option("--no-verify", is_flag=True, help="Skip security verification")
2827
+ def plugins_update(name: str, update_all: bool, check_only: bool, version: str, no_verify: bool):
2828
+ """Update installed plugins."""
2829
+ try:
2830
+ from tweek.plugins.git_installer import GitPluginInstaller
2831
+ from tweek.plugins.git_registry import PluginRegistryClient
2832
+
2833
+ registry = PluginRegistryClient()
2834
+ installer = GitPluginInstaller(registry_client=registry)
2835
+
2836
+ if check_only:
2837
+ console.print("Checking for updates...")
2838
+ updates = installer.check_updates()
2839
+ if not updates:
2840
+ console.print("[green]All plugins are up to date.[/green]")
2841
+ else:
2842
+ table = Table(title="Available Updates")
2843
+ table.add_column("Plugin", style="cyan")
2844
+ table.add_column("Current")
2845
+ table.add_column("Latest", style="green")
2846
+ for u in updates:
2847
+ table.add_row(u["name"], u["current_version"], u["latest_version"])
2848
+ console.print(table)
2849
+ return
2850
+
2851
+ if update_all:
2852
+ installed = installer.list_installed()
2853
+ if not installed:
2854
+ console.print("No git plugins installed.")
2855
+ return
2856
+ for plugin in installed:
2857
+ console.print(f"Updating {plugin['name']}...")
2858
+ success, msg = installer.update(
2859
+ plugin["name"],
2860
+ verify=not no_verify,
2861
+ )
2862
+ if success:
2863
+ console.print(f" [green]✓[/green] {msg}")
2864
+ else:
2865
+ console.print(f" [yellow]![/yellow] {msg}")
2866
+ return
2867
+
2868
+ if not name:
2869
+ console.print("[red]Specify a plugin name or use --all[/red]")
2870
+ return
2871
+
2872
+ success, msg = installer.update(name, version=version, verify=not no_verify)
2873
+ if success:
2874
+ console.print(f"[green]✓[/green] {msg}")
2875
+ else:
2876
+ console.print(f"[red]✗[/red] {msg}")
2877
+
2878
+ except Exception as e:
2879
+ console.print(f"[red]Error: {e}[/red]")
2880
+
2881
+
2882
+ @plugins.command("remove",
2883
+ epilog="""\b
2884
+ Examples:
2885
+ tweek plugins remove hipaa-scanner Remove a plugin (with confirmation)
2886
+ tweek plugins remove hipaa-scanner -f Remove without confirmation
2887
+ """
2888
+ )
2889
+ @click.argument("name")
2890
+ @click.option("--force", "-f", is_flag=True, help="Skip confirmation")
2891
+ def plugins_remove(name: str, force: bool):
2892
+ """Remove an installed git plugin."""
2893
+ try:
2894
+ from tweek.plugins.git_installer import GitPluginInstaller
2895
+ from tweek.plugins.git_registry import PluginRegistryClient
2896
+
2897
+ installer = GitPluginInstaller(registry_client=PluginRegistryClient())
2898
+
2899
+ if not force:
2900
+ if not click.confirm(f"Remove plugin '{name}'?"):
2901
+ return
2902
+
2903
+ success, msg = installer.remove(name)
2904
+ if success:
2905
+ console.print(f"[green]✓[/green] {msg}")
2906
+ else:
2907
+ console.print(f"[red]✗[/red] {msg}")
2908
+
2909
+ except Exception as e:
2910
+ console.print(f"[red]Error: {e}[/red]")
2911
+
2912
+
2913
+ @plugins.command("search",
2914
+ epilog="""\b
2915
+ Examples:
2916
+ tweek plugins search hipaa Search for plugins by name
2917
+ tweek plugins search -c compliance Browse all compliance plugins
2918
+ tweek plugins search -t free Show only free-tier plugins
2919
+ tweek plugins search pii --include-deprecated Include deprecated results
2920
+ """
2921
+ )
2922
+ @click.argument("query", required=False)
2923
+ @click.option("--category", "-c", type=click.Choice(["compliance", "providers", "detectors", "screening"]),
2924
+ help="Filter by category")
2925
+ @click.option("--tier", "-t", type=click.Choice(["free", "pro", "enterprise"]),
2926
+ help="Filter by license tier")
2927
+ @click.option("--include-deprecated", is_flag=True, help="Include deprecated plugins")
2928
+ def plugins_search(query: str, category: str, tier: str, include_deprecated: bool):
2929
+ """Search the Tweek plugin registry."""
2930
+ try:
2931
+ from tweek.plugins.git_registry import PluginRegistryClient
2932
+
2933
+ registry = PluginRegistryClient()
2934
+ console.print("Searching registry...")
2935
+ results = registry.search(
2936
+ query=query,
2937
+ category=category,
2938
+ tier=tier,
2939
+ include_deprecated=include_deprecated,
2940
+ )
2941
+
2942
+ if not results:
2943
+ console.print("[yellow]No plugins found matching your criteria.[/yellow]")
2944
+ return
2945
+
2946
+ table = Table(title=f"Registry Results ({len(results)} found)")
2947
+ table.add_column("Name", style="cyan")
2948
+ table.add_column("Version")
2949
+ table.add_column("Category")
2950
+ table.add_column("Tier")
2951
+ table.add_column("Description", max_width=40)
2952
+
2953
+ for entry in results:
2954
+ table.add_row(
2955
+ entry.name,
2956
+ entry.latest_version,
2957
+ entry.category,
2958
+ entry.requires_license_tier,
2959
+ entry.description[:40] + "..." if len(entry.description) > 40 else entry.description,
2960
+ )
2961
+
2962
+ console.print(table)
2963
+
2964
+ except Exception as e:
2965
+ console.print(f"[red]Error: {e}[/red]")
2966
+
2967
+
2968
+ @plugins.command("lock",
2969
+ epilog="""\b
2970
+ Examples:
2971
+ tweek plugins lock Generate lockfile for all plugins
2972
+ tweek plugins lock -p hipaa -v 1.2.0 Lock a specific plugin to a version
2973
+ tweek plugins lock --project Create project-level lockfile
2974
+ """
2975
+ )
2976
+ @click.option("--plugin", "-p", "plugin_name", default=None, help="Lock a specific plugin")
2977
+ @click.option("--version", "-v", "version", default=None, help="Lock to specific version")
2978
+ @click.option("--project", is_flag=True, help="Create project-level lockfile (.tweek/plugins.lock.json)")
2979
+ def plugins_lock(plugin_name: str, version: str, project: bool):
2980
+ """Generate or update a plugin version lockfile."""
2981
+ try:
2982
+ from tweek.plugins.git_lockfile import PluginLockfile
2983
+
2984
+ lockfile = PluginLockfile()
2985
+ target = "project" if project else "user"
2986
+
2987
+ specific = None
2988
+ if plugin_name:
2989
+ specific = {plugin_name: version or "latest"}
2990
+
2991
+ path = lockfile.generate(target=target, specific_plugins=specific)
2992
+ console.print(f"[green]✓[/green] Lockfile generated: {path}")
2993
+
2994
+ # Show lock contents
2995
+ locks = lockfile.load()
2996
+ if locks:
2997
+ table = Table(title="Locked Plugins")
2998
+ table.add_column("Plugin", style="cyan")
2999
+ table.add_column("Version")
3000
+ table.add_column("Commit")
3001
+ for name, lock in locks.items():
3002
+ table.add_row(
3003
+ name,
3004
+ lock.version,
3005
+ lock.commit_sha[:12] if lock.commit_sha else "n/a",
3006
+ )
3007
+ console.print(table)
3008
+
3009
+ except Exception as e:
3010
+ console.print(f"[red]Error: {e}[/red]")
3011
+
3012
+
3013
+ @plugins.command("verify",
3014
+ epilog="""\b
3015
+ Examples:
3016
+ tweek plugins verify hipaa-scanner Verify a specific plugin's integrity
3017
+ tweek plugins verify --all Verify all installed plugins
3018
+ """
3019
+ )
3020
+ @click.argument("name", required=False)
3021
+ @click.option("--all", "verify_all", is_flag=True, help="Verify all installed plugins")
3022
+ def plugins_verify(name: str, verify_all: bool):
3023
+ """Verify integrity of installed git plugins."""
3024
+ try:
3025
+ from tweek.plugins.git_installer import GitPluginInstaller
3026
+ from tweek.plugins.git_registry import PluginRegistryClient
3027
+
3028
+ from tweek.cli_helpers import spinner as cli_spinner
3029
+
3030
+ installer = GitPluginInstaller(registry_client=PluginRegistryClient())
3031
+
3032
+ if verify_all:
3033
+ with cli_spinner("Verifying plugin integrity"):
3034
+ results = installer.verify_all()
3035
+ if not results:
3036
+ console.print("No git plugins installed.")
3037
+ return
3038
+
3039
+ all_valid = True
3040
+ for plugin_name, (valid, issues) in results.items():
3041
+ if valid:
3042
+ console.print(f" [green]✓[/green] {plugin_name}: integrity verified")
3043
+ else:
3044
+ all_valid = False
3045
+ console.print(f" [red]✗[/red] {plugin_name}: {len(issues)} issue(s)")
3046
+ for issue in issues:
3047
+ console.print(f" - {issue}")
3048
+
3049
+ if all_valid:
3050
+ console.print(f"\n[green]All {len(results)} plugin(s) verified.[/green]")
3051
+ return
3052
+
3053
+ if not name:
3054
+ console.print("[red]Specify a plugin name or use --all[/red]")
3055
+ return
3056
+
3057
+ valid, issues = installer.verify_plugin(name)
3058
+ if valid:
3059
+ console.print(f"[green]✓[/green] Plugin '{name}' integrity verified")
3060
+ else:
3061
+ console.print(f"[red]✗[/red] Plugin '{name}' failed verification:")
3062
+ for issue in issues:
3063
+ console.print(f" - {issue}")
3064
+
3065
+ except Exception as e:
3066
+ console.print(f"[red]Error: {e}[/red]")
3067
+
3068
+
3069
+ @plugins.command("registry",
3070
+ epilog="""\b
3071
+ Examples:
3072
+ tweek plugins registry Show registry summary
3073
+ tweek plugins registry --refresh Force refresh the registry cache
3074
+ tweek plugins registry --info Show detailed registry metadata
3075
+ """
3076
+ )
3077
+ @click.option("--refresh", is_flag=True, help="Force refresh the registry cache")
3078
+ @click.option("--info", "show_info", is_flag=True, help="Show registry metadata")
3079
+ def plugins_registry(refresh: bool, show_info: bool):
3080
+ """Manage the plugin registry cache."""
3081
+ try:
3082
+ from tweek.plugins.git_registry import PluginRegistryClient
3083
+
3084
+ registry = PluginRegistryClient()
3085
+
3086
+ if refresh:
3087
+ console.print("Refreshing registry...")
3088
+ try:
3089
+ entries = registry.fetch(force_refresh=True)
3090
+ console.print(f"[green]✓[/green] Registry refreshed: {len(entries)} plugins available")
3091
+ except Exception as e:
3092
+ console.print(f"[red]✗[/red] Failed to refresh: {e}")
3093
+ return
3094
+
3095
+ if show_info:
3096
+ info = registry.get_registry_info()
3097
+ panel_content = "\n".join([
3098
+ f"URL: {info.get('url', 'unknown')}",
3099
+ f"Cache: {info.get('cache_path', 'unknown')}",
3100
+ f"Cache TTL: {info.get('cache_ttl_seconds', 0)}s",
3101
+ f"Cache valid: {info.get('cache_valid', False)}",
3102
+ f"Schema version: {info.get('schema_version', 'unknown')}",
3103
+ f"Last updated: {info.get('updated_at', 'unknown')}",
3104
+ f"Total plugins: {info.get('total_plugins', 'unknown')}",
3105
+ f"Cache fetched: {info.get('cache_fetched_at', 'never')}",
3106
+ ])
3107
+ console.print(Panel(panel_content, title="Registry Info"))
3108
+ return
3109
+
3110
+ # Default: show summary
3111
+ try:
3112
+ entries = registry.fetch()
3113
+ verified = [e for e in entries.values() if e.verified and not e.deprecated]
3114
+ console.print(f"Registry: {len(verified)} verified plugins available")
3115
+ console.print("Use 'tweek plugins search' to browse or 'tweek plugins registry --refresh' to update cache")
3116
+ except Exception as e:
3117
+ console.print(f"[yellow]Registry unavailable: {e}[/yellow]")
3118
+
3119
+ except Exception as e:
3120
+ console.print(f"[red]Error: {e}[/red]")
3121
+
3122
+
3123
+ # =============================================================================
3124
+ # MCP GATEWAY COMMANDS
3125
+ # =============================================================================
3126
+
3127
+ @main.group()
3128
+ def mcp():
3129
+ """MCP Security Gateway for desktop LLM applications.
3130
+
3131
+ Provides security-screened tools via the Model Context Protocol (MCP).
3132
+ Supports Claude Desktop, ChatGPT Desktop, and Gemini CLI.
3133
+ """
3134
+ pass
3135
+
3136
+
3137
+ @mcp.command(
3138
+ epilog="""\b
3139
+ Examples:
3140
+ tweek mcp serve Start MCP gateway on stdio transport
3141
+ """
3142
+ )
3143
+ def serve():
3144
+ """Start MCP gateway server (stdio transport).
3145
+
3146
+ This is the command desktop clients call to launch the MCP server.
3147
+ Used as the 'command' in client MCP configurations.
3148
+
3149
+ Example Claude Desktop config:
3150
+ {"mcpServers": {"tweek-security": {"command": "tweek", "args": ["mcp", "serve"]}}}
3151
+ """
3152
+ import asyncio
3153
+
3154
+ try:
3155
+ from tweek.mcp.server import run_server, MCP_AVAILABLE
3156
+
3157
+ if not MCP_AVAILABLE:
3158
+ console.print("[red]MCP SDK not installed.[/red]")
3159
+ console.print("Install with: pip install 'tweek[mcp]' or pip install mcp")
3160
+ return
3161
+
3162
+ # Load config
3163
+ try:
3164
+ from tweek.config.manager import ConfigManager
3165
+ cfg = ConfigManager()
3166
+ config = cfg.get_full_config()
3167
+ except Exception:
3168
+ config = {}
3169
+
3170
+ asyncio.run(run_server(config=config))
3171
+
3172
+ except KeyboardInterrupt:
3173
+ pass
3174
+ except Exception as e:
3175
+ console.print(f"[red]MCP server error: {e}[/red]")
3176
+
3177
+
3178
+ @mcp.command(
3179
+ epilog="""\b
3180
+ Examples:
3181
+ tweek mcp install claude-desktop Configure Claude Desktop integration
3182
+ tweek mcp install chatgpt Set up ChatGPT Desktop integration
3183
+ tweek mcp install gemini Configure Gemini CLI integration
3184
+ """
3185
+ )
3186
+ @click.argument("client", type=click.Choice(["claude-desktop", "chatgpt", "gemini"]))
3187
+ def install(client):
3188
+ """Install Tweek as MCP server for a desktop client.
3189
+
3190
+ Supported clients:
3191
+ claude-desktop - Auto-configures Claude Desktop
3192
+ chatgpt - Provides Developer Mode setup instructions
3193
+ gemini - Auto-configures Gemini CLI settings
3194
+ """
3195
+ try:
3196
+ from tweek.mcp.clients import get_client
3197
+
3198
+ handler = get_client(client)
3199
+ result = handler.install()
3200
+
3201
+ if result.get("success"):
3202
+ console.print(f"[green]✅ {result.get('message', 'Installed successfully')}[/green]")
3203
+
3204
+ if result.get("config_path"):
3205
+ console.print(f" Config: {result['config_path']}")
3206
+
3207
+ if result.get("backup"):
3208
+ console.print(f" Backup: {result['backup']}")
3209
+
3210
+ # Show instructions for manual setup clients
3211
+ if result.get("instructions"):
3212
+ console.print()
3213
+ for line in result["instructions"]:
3214
+ console.print(f" {line}")
3215
+ else:
3216
+ console.print(f"[red]❌ {result.get('error', 'Installation failed')}[/red]")
3217
+
3218
+ except Exception as e:
3219
+ console.print(f"[red]Error: {e}[/red]")
3220
+
3221
+
3222
+ @mcp.command(
3223
+ epilog="""\b
3224
+ Examples:
3225
+ tweek mcp uninstall claude-desktop Remove from Claude Desktop
3226
+ tweek mcp uninstall chatgpt Remove from ChatGPT Desktop
3227
+ tweek mcp uninstall gemini Remove from Gemini CLI
3228
+ """
3229
+ )
3230
+ @click.argument("client", type=click.Choice(["claude-desktop", "chatgpt", "gemini"]))
3231
+ def uninstall(client):
3232
+ """Remove Tweek MCP server from a desktop client.
3233
+
3234
+ Supported clients: claude-desktop, chatgpt, gemini
3235
+ """
3236
+ try:
3237
+ from tweek.mcp.clients import get_client
3238
+
3239
+ handler = get_client(client)
3240
+ result = handler.uninstall()
3241
+
3242
+ if result.get("success"):
3243
+ console.print(f"[green]✅ {result.get('message', 'Uninstalled successfully')}[/green]")
3244
+
3245
+ if result.get("backup"):
3246
+ console.print(f" Backup: {result['backup']}")
3247
+
3248
+ if result.get("instructions"):
3249
+ console.print()
3250
+ for line in result["instructions"]:
3251
+ console.print(f" {line}")
3252
+ else:
3253
+ console.print(f"[red]❌ {result.get('error', 'Uninstallation failed')}[/red]")
3254
+
3255
+ except Exception as e:
3256
+ console.print(f"[red]Error: {e}[/red]")
3257
+
3258
+
3259
+ # =============================================================================
3260
+ # MCP PROXY COMMANDS
3261
+ # =============================================================================
3262
+
3263
+ @mcp.command("proxy",
3264
+ epilog="""\b
3265
+ Examples:
3266
+ tweek mcp proxy Start MCP proxy on stdio transport
3267
+ """
3268
+ )
3269
+ def mcp_proxy():
3270
+ """Start MCP proxy server (stdio transport).
3271
+
3272
+ Connects to upstream MCP servers configured in config.yaml,
3273
+ screens all tool calls through Tweek's security pipeline,
3274
+ and queues flagged operations for human approval.
3275
+
3276
+ Configure upstreams in ~/.tweek/config.yaml:
3277
+ mcp:
3278
+ proxy:
3279
+ upstreams:
3280
+ filesystem:
3281
+ command: "npx"
3282
+ args: ["-y", "@modelcontextprotocol/server-filesystem", "/path"]
3283
+
3284
+ Example Claude Desktop config:
3285
+ {"mcpServers": {"tweek-proxy": {"command": "tweek", "args": ["mcp", "proxy"]}}}
3286
+ """
3287
+ import asyncio
3288
+
3289
+ try:
3290
+ from tweek.mcp.proxy import run_proxy, MCP_AVAILABLE
3291
+
3292
+ if not MCP_AVAILABLE:
3293
+ console.print("[red]MCP SDK not installed.[/red]")
3294
+ console.print("Install with: pip install 'tweek[mcp]' or pip install mcp")
3295
+ return
3296
+
3297
+ # Load config
3298
+ try:
3299
+ from tweek.config.manager import ConfigManager
3300
+ cfg = ConfigManager()
3301
+ config = cfg.get_full_config()
3302
+ except Exception:
3303
+ config = {}
3304
+
3305
+ asyncio.run(run_proxy(config=config))
3306
+
3307
+ except KeyboardInterrupt:
3308
+ pass
3309
+ except Exception as e:
3310
+ console.print(f"[red]MCP proxy error: {e}[/red]")
3311
+
3312
+
3313
+ @mcp.command("approve",
3314
+ epilog="""\b
3315
+ Examples:
3316
+ tweek mcp approve Start approval daemon (interactive)
3317
+ tweek mcp approve --list List pending requests and exit
3318
+ tweek mcp approve -p 5 Poll every 5 seconds
3319
+ """
3320
+ )
3321
+ @click.option("--poll-interval", "-p", default=2.0, type=float,
3322
+ help="Seconds between polls for new requests")
3323
+ @click.option("--list", "list_pending", is_flag=True, help="List pending requests and exit")
3324
+ def mcp_approve(poll_interval, list_pending):
3325
+ """Start the approval daemon for MCP proxy requests.
3326
+
3327
+ Shows pending requests and allows approve/deny decisions.
3328
+ Press Ctrl+C to exit.
3329
+
3330
+ Run this in a separate terminal while 'tweek mcp proxy' is serving.
3331
+ Use --list to show pending requests without starting the daemon.
3332
+ """
3333
+ if list_pending:
3334
+ try:
3335
+ from tweek.mcp.approval import ApprovalQueue
3336
+ from tweek.mcp.approval_cli import display_pending
3337
+ queue = ApprovalQueue()
3338
+ display_pending(queue)
3339
+ except Exception as e:
3340
+ console.print(f"[red]Error: {e}[/red]")
3341
+ return
3342
+
3343
+ try:
3344
+ from tweek.mcp.approval import ApprovalQueue
3345
+ from tweek.mcp.approval_cli import run_approval_daemon
3346
+
3347
+ queue = ApprovalQueue()
3348
+ run_approval_daemon(queue, poll_interval=poll_interval)
3349
+
3350
+ except KeyboardInterrupt:
3351
+ pass
3352
+ except Exception as e:
3353
+ console.print(f"[red]Approval daemon error: {e}[/red]")
3354
+
3355
+
3356
+ @mcp.command("decide",
3357
+ epilog="""\b
3358
+ Examples:
3359
+ tweek mcp decide abc12345 approve Approve a request
3360
+ tweek mcp decide abc12345 deny Deny a request
3361
+ tweek mcp decide abc12345 deny -n "Not authorized" Deny with notes
3362
+ """
3363
+ )
3364
+ @click.argument("request_id")
3365
+ @click.argument("decision", type=click.Choice(["approve", "deny"]))
3366
+ @click.option("--notes", "-n", help="Decision notes")
3367
+ def mcp_decide(request_id, decision, notes):
3368
+ """Approve or deny a specific approval request.
3369
+
3370
+ REQUEST_ID can be the full UUID or the first 8 characters.
3371
+ """
3372
+ try:
3373
+ from tweek.mcp.approval import ApprovalQueue
3374
+ from tweek.mcp.approval_cli import decide_request
3375
+
3376
+ queue = ApprovalQueue()
3377
+ success = decide_request(queue, request_id, decision, notes=notes)
3378
+
3379
+ if success:
3380
+ verb = "Approved" if decision == "approve" else "Denied"
3381
+ style = "green" if decision == "approve" else "red"
3382
+ console.print(f"[{style}]{verb} request {request_id}[/{style}]")
3383
+ else:
3384
+ console.print(f"[yellow]Could not {decision} request {request_id}[/yellow]")
3385
+
3386
+ except Exception as e:
3387
+ console.print(f"[red]Error: {e}[/red]")
3388
+
3389
+ if __name__ == "__main__":
3390
+ main()