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.
- tweek/__init__.py +16 -0
- tweek/cli.py +3390 -0
- tweek/cli_helpers.py +193 -0
- tweek/config/__init__.py +13 -0
- tweek/config/allowed_dirs.yaml +23 -0
- tweek/config/manager.py +1064 -0
- tweek/config/patterns.yaml +751 -0
- tweek/config/tiers.yaml +129 -0
- tweek/diagnostics.py +589 -0
- tweek/hooks/__init__.py +1 -0
- tweek/hooks/pre_tool_use.py +861 -0
- tweek/integrations/__init__.py +3 -0
- tweek/integrations/moltbot.py +243 -0
- tweek/licensing.py +398 -0
- tweek/logging/__init__.py +9 -0
- tweek/logging/bundle.py +350 -0
- tweek/logging/json_logger.py +150 -0
- tweek/logging/security_log.py +745 -0
- tweek/mcp/__init__.py +24 -0
- tweek/mcp/approval.py +456 -0
- tweek/mcp/approval_cli.py +356 -0
- tweek/mcp/clients/__init__.py +37 -0
- tweek/mcp/clients/chatgpt.py +112 -0
- tweek/mcp/clients/claude_desktop.py +203 -0
- tweek/mcp/clients/gemini.py +178 -0
- tweek/mcp/proxy.py +667 -0
- tweek/mcp/screening.py +175 -0
- tweek/mcp/server.py +317 -0
- tweek/platform/__init__.py +131 -0
- tweek/plugins/__init__.py +835 -0
- tweek/plugins/base.py +1080 -0
- tweek/plugins/compliance/__init__.py +30 -0
- tweek/plugins/compliance/gdpr.py +333 -0
- tweek/plugins/compliance/gov.py +324 -0
- tweek/plugins/compliance/hipaa.py +285 -0
- tweek/plugins/compliance/legal.py +322 -0
- tweek/plugins/compliance/pci.py +361 -0
- tweek/plugins/compliance/soc2.py +275 -0
- tweek/plugins/detectors/__init__.py +30 -0
- tweek/plugins/detectors/continue_dev.py +206 -0
- tweek/plugins/detectors/copilot.py +254 -0
- tweek/plugins/detectors/cursor.py +192 -0
- tweek/plugins/detectors/moltbot.py +205 -0
- tweek/plugins/detectors/windsurf.py +214 -0
- tweek/plugins/git_discovery.py +395 -0
- tweek/plugins/git_installer.py +491 -0
- tweek/plugins/git_lockfile.py +338 -0
- tweek/plugins/git_registry.py +503 -0
- tweek/plugins/git_security.py +482 -0
- tweek/plugins/providers/__init__.py +30 -0
- tweek/plugins/providers/anthropic.py +181 -0
- tweek/plugins/providers/azure_openai.py +289 -0
- tweek/plugins/providers/bedrock.py +248 -0
- tweek/plugins/providers/google.py +197 -0
- tweek/plugins/providers/openai.py +230 -0
- tweek/plugins/scope.py +130 -0
- tweek/plugins/screening/__init__.py +26 -0
- tweek/plugins/screening/llm_reviewer.py +149 -0
- tweek/plugins/screening/pattern_matcher.py +273 -0
- tweek/plugins/screening/rate_limiter.py +174 -0
- tweek/plugins/screening/session_analyzer.py +159 -0
- tweek/proxy/__init__.py +302 -0
- tweek/proxy/addon.py +223 -0
- tweek/proxy/interceptor.py +313 -0
- tweek/proxy/server.py +315 -0
- tweek/sandbox/__init__.py +71 -0
- tweek/sandbox/executor.py +382 -0
- tweek/sandbox/linux.py +278 -0
- tweek/sandbox/profile_generator.py +323 -0
- tweek/screening/__init__.py +13 -0
- tweek/screening/context.py +81 -0
- tweek/security/__init__.py +22 -0
- tweek/security/llm_reviewer.py +348 -0
- tweek/security/rate_limiter.py +682 -0
- tweek/security/secret_scanner.py +506 -0
- tweek/security/session_analyzer.py +600 -0
- tweek/vault/__init__.py +40 -0
- tweek/vault/cross_platform.py +251 -0
- tweek/vault/keychain.py +288 -0
- tweek-0.1.0.dist-info/METADATA +335 -0
- tweek-0.1.0.dist-info/RECORD +85 -0
- tweek-0.1.0.dist-info/WHEEL +5 -0
- tweek-0.1.0.dist-info/entry_points.txt +25 -0
- tweek-0.1.0.dist-info/licenses/LICENSE +190 -0
- 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()
|