tweek 0.3.1__py3-none-any.whl → 0.4.1__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 +2 -2
- tweek/audit.py +2 -2
- tweek/cli.py +78 -6605
- tweek/cli_config.py +643 -0
- tweek/cli_configure.py +413 -0
- tweek/cli_core.py +718 -0
- tweek/cli_dry_run.py +390 -0
- tweek/cli_helpers.py +316 -0
- tweek/cli_install.py +1666 -0
- tweek/cli_logs.py +301 -0
- tweek/cli_mcp.py +148 -0
- tweek/cli_memory.py +343 -0
- tweek/cli_plugins.py +748 -0
- tweek/cli_protect.py +564 -0
- tweek/cli_proxy.py +405 -0
- tweek/cli_security.py +236 -0
- tweek/cli_skills.py +289 -0
- tweek/cli_uninstall.py +551 -0
- tweek/cli_vault.py +313 -0
- tweek/config/allowed_dirs.yaml +16 -17
- tweek/config/families.yaml +4 -1
- tweek/config/manager.py +17 -0
- tweek/config/patterns.yaml +29 -5
- tweek/config/templates/config.yaml.template +212 -0
- tweek/config/templates/env.template +45 -0
- tweek/config/templates/overrides.yaml.template +121 -0
- tweek/config/templates/tweek.yaml.template +20 -0
- tweek/config/templates.py +136 -0
- tweek/config/tiers.yaml +5 -4
- tweek/diagnostics.py +112 -32
- tweek/hooks/overrides.py +4 -0
- tweek/hooks/post_tool_use.py +46 -1
- tweek/hooks/pre_tool_use.py +149 -49
- tweek/integrations/openclaw.py +84 -0
- tweek/licensing.py +1 -1
- tweek/mcp/__init__.py +7 -9
- tweek/mcp/clients/chatgpt.py +2 -2
- tweek/mcp/clients/claude_desktop.py +2 -2
- tweek/mcp/clients/gemini.py +2 -2
- tweek/mcp/proxy.py +165 -1
- tweek/memory/provenance.py +438 -0
- tweek/memory/queries.py +2 -0
- tweek/memory/safety.py +23 -4
- tweek/memory/schemas.py +1 -0
- tweek/memory/store.py +101 -71
- tweek/plugins/screening/heuristic_scorer.py +1 -1
- tweek/security/integrity.py +77 -0
- tweek/security/llm_reviewer.py +170 -74
- tweek/security/local_reviewer.py +44 -2
- tweek/security/model_registry.py +73 -7
- tweek/skill_template/overrides-reference.md +1 -1
- tweek/skills/context.py +221 -0
- tweek/skills/scanner.py +2 -2
- {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/METADATA +8 -7
- {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/RECORD +60 -38
- tweek/mcp/server.py +0 -320
- {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/WHEEL +0 -0
- {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/entry_points.txt +0 -0
- {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/licenses/LICENSE +0 -0
- {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/licenses/NOTICE +0 -0
- {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/top_level.txt +0 -0
tweek/cli_core.py
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Tweek CLI Core Commands
|
|
4
|
+
|
|
5
|
+
Standalone commands for system management:
|
|
6
|
+
tweek status Show protection status dashboard
|
|
7
|
+
tweek trust Trust a project directory
|
|
8
|
+
tweek untrust Remove trust from a directory
|
|
9
|
+
tweek update Update attack patterns
|
|
10
|
+
tweek doctor Run health checks
|
|
11
|
+
tweek upgrade Upgrade Tweek to latest version
|
|
12
|
+
tweek audit Audit skills for security risks
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
|
|
21
|
+
from tweek.cli_helpers import (
|
|
22
|
+
TWEEK_BANNER,
|
|
23
|
+
_detect_all_tools,
|
|
24
|
+
_has_tweek_at,
|
|
25
|
+
_load_overrides_yaml,
|
|
26
|
+
_save_overrides_yaml,
|
|
27
|
+
console,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# =============================================================================
|
|
32
|
+
# STATUS
|
|
33
|
+
# =============================================================================
|
|
34
|
+
|
|
35
|
+
@click.command()
|
|
36
|
+
def status():
|
|
37
|
+
"""Show Tweek protection status dashboard.
|
|
38
|
+
|
|
39
|
+
Scans for all supported AI tools and displays which are
|
|
40
|
+
detected, which are protected by Tweek, and configuration details.
|
|
41
|
+
"""
|
|
42
|
+
_show_protection_status()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _show_protection_status():
|
|
46
|
+
"""Show protection status dashboard for all AI tools."""
|
|
47
|
+
console.print(TWEEK_BANNER, style="cyan")
|
|
48
|
+
|
|
49
|
+
tools = _detect_all_tools()
|
|
50
|
+
|
|
51
|
+
from rich.table import Table
|
|
52
|
+
|
|
53
|
+
table = Table(title="Protection Status")
|
|
54
|
+
table.add_column("Tool", style="cyan")
|
|
55
|
+
table.add_column("Installed", justify="center")
|
|
56
|
+
table.add_column("Protected", justify="center")
|
|
57
|
+
table.add_column("Details")
|
|
58
|
+
|
|
59
|
+
for tool_id, label, installed, protected, detail in tools:
|
|
60
|
+
inst_str = "[green]yes[/green]" if installed else "[white]no[/white]"
|
|
61
|
+
prot_str = "[green]yes[/green]" if protected else ("[yellow]no[/yellow]" if installed else "[white]-[/white]")
|
|
62
|
+
table.add_row(label, inst_str, prot_str, detail)
|
|
63
|
+
|
|
64
|
+
console.print(table)
|
|
65
|
+
console.print()
|
|
66
|
+
console.print("[white]Run 'tweek protect' to set up protection for unprotected tools.[/white]")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# =============================================================================
|
|
70
|
+
# TRUST / UNTRUST
|
|
71
|
+
# =============================================================================
|
|
72
|
+
|
|
73
|
+
@click.command(
|
|
74
|
+
epilog="""\b
|
|
75
|
+
Examples:
|
|
76
|
+
tweek trust Trust the current project
|
|
77
|
+
tweek trust /path/to/project Trust a specific directory
|
|
78
|
+
tweek trust --list Show all trusted paths
|
|
79
|
+
tweek trust . --reason "My safe repo" Trust with an explanation
|
|
80
|
+
"""
|
|
81
|
+
)
|
|
82
|
+
@click.argument("path", default=".", type=click.Path(exists=True), required=False)
|
|
83
|
+
@click.option("--reason", "-r", default=None, help="Why this path is trusted")
|
|
84
|
+
@click.option("--list", "list_trusted", is_flag=True, help="List all trusted paths")
|
|
85
|
+
def trust(path: str, reason: str, list_trusted: bool):
|
|
86
|
+
"""Trust a project directory — skip all screening for files in this path.
|
|
87
|
+
|
|
88
|
+
Adds the directory to the whitelist in ~/.tweek/overrides.yaml.
|
|
89
|
+
All tool calls operating on files within this path will be allowed
|
|
90
|
+
without screening.
|
|
91
|
+
|
|
92
|
+
This is useful for temporarily pausing Tweek in a specific project,
|
|
93
|
+
or for permanently trusting a known-safe directory.
|
|
94
|
+
|
|
95
|
+
To resume screening, use: tweek untrust
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
overrides, overrides_path = _load_overrides_yaml()
|
|
99
|
+
except ImportError:
|
|
100
|
+
console.print("[red]✗[/red] PyYAML is required. Install with: pip install pyyaml")
|
|
101
|
+
return
|
|
102
|
+
except Exception as e:
|
|
103
|
+
console.print(f"[red]✗[/red] Could not load overrides: {e}")
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
whitelist = overrides.get("whitelist", [])
|
|
107
|
+
|
|
108
|
+
# --list mode: show all trusted paths
|
|
109
|
+
if list_trusted:
|
|
110
|
+
trusted_entries = [
|
|
111
|
+
entry for entry in whitelist
|
|
112
|
+
if isinstance(entry, dict) and "path" in entry and not entry.get("tools")
|
|
113
|
+
]
|
|
114
|
+
tool_scoped = [
|
|
115
|
+
entry for entry in whitelist
|
|
116
|
+
if isinstance(entry, dict) and "path" in entry and entry.get("tools")
|
|
117
|
+
]
|
|
118
|
+
other_entries = [
|
|
119
|
+
entry for entry in whitelist
|
|
120
|
+
if isinstance(entry, dict) and "path" not in entry
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
if not whitelist:
|
|
124
|
+
console.print("[white]No trusted paths configured.[/white]")
|
|
125
|
+
console.print("[white]Use 'tweek trust' to trust the current project.[/white]")
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
if trusted_entries:
|
|
129
|
+
console.print("[bold]Trusted project directories[/bold] (all tools exempt):\n")
|
|
130
|
+
for entry in trusted_entries:
|
|
131
|
+
entry_reason = entry.get("reason", "")
|
|
132
|
+
console.print(f" [green]✓[/green] {entry['path']}")
|
|
133
|
+
if entry_reason:
|
|
134
|
+
console.print(f" [white]{entry_reason}[/white]")
|
|
135
|
+
|
|
136
|
+
if tool_scoped:
|
|
137
|
+
console.print("\n[bold]Tool-scoped whitelist entries:[/bold]\n")
|
|
138
|
+
for entry in tool_scoped:
|
|
139
|
+
tools_str = ", ".join(entry.get("tools", []))
|
|
140
|
+
entry_reason = entry.get("reason", "")
|
|
141
|
+
console.print(f" [cyan]○[/cyan] {entry['path']} [white]({tools_str})[/white]")
|
|
142
|
+
if entry_reason:
|
|
143
|
+
console.print(f" [white]{entry_reason}[/white]")
|
|
144
|
+
|
|
145
|
+
if other_entries:
|
|
146
|
+
console.print("\n[bold]Other whitelist entries:[/bold]\n")
|
|
147
|
+
for entry in other_entries:
|
|
148
|
+
if entry.get("url_prefix"):
|
|
149
|
+
console.print(f" [cyan]○[/cyan] URL: {entry['url_prefix']}")
|
|
150
|
+
elif entry.get("command_prefix"):
|
|
151
|
+
console.print(f" [cyan]○[/cyan] Command: {entry['command_prefix']}")
|
|
152
|
+
entry_reason = entry.get("reason", "")
|
|
153
|
+
if entry_reason:
|
|
154
|
+
console.print(f" [white]{entry_reason}[/white]")
|
|
155
|
+
|
|
156
|
+
console.print(f"\n[white]Config: {overrides_path}[/white]")
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
# Resolve path to absolute
|
|
160
|
+
resolved = Path(path).resolve()
|
|
161
|
+
resolved_str = str(resolved)
|
|
162
|
+
|
|
163
|
+
# Check if already whitelisted
|
|
164
|
+
already_trusted = any(
|
|
165
|
+
isinstance(entry, dict)
|
|
166
|
+
and entry.get("path", "").rstrip("/") == resolved_str.rstrip("/")
|
|
167
|
+
and not entry.get("tools") # full trust, not tool-scoped
|
|
168
|
+
for entry in whitelist
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if already_trusted:
|
|
172
|
+
console.print(f"[green]✓[/green] Already trusted: {resolved}")
|
|
173
|
+
console.print("[white]Use 'tweek untrust' to remove.[/white]")
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
# Add whitelist entry (no tools restriction = all tools exempt)
|
|
177
|
+
entry = {
|
|
178
|
+
"path": resolved_str,
|
|
179
|
+
"reason": reason or "Trusted via tweek trust",
|
|
180
|
+
}
|
|
181
|
+
whitelist.append(entry)
|
|
182
|
+
overrides["whitelist"] = whitelist
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
_save_overrides_yaml(overrides, overrides_path)
|
|
186
|
+
except Exception as e:
|
|
187
|
+
console.print(f"[red]✗[/red] Could not save overrides: {e}")
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
console.print(f"[green]✓[/green] Trusted: {resolved}")
|
|
191
|
+
console.print(f" [white]All screening is now skipped for files in this directory.[/white]")
|
|
192
|
+
console.print(f" [white]To resume screening: tweek untrust {path}[/white]")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@click.command(
|
|
196
|
+
epilog="""\b
|
|
197
|
+
Examples:
|
|
198
|
+
tweek untrust Untrust the current project
|
|
199
|
+
tweek untrust /path/to/project Untrust a specific directory
|
|
200
|
+
"""
|
|
201
|
+
)
|
|
202
|
+
@click.argument("path", default=".", type=click.Path(exists=True), required=False)
|
|
203
|
+
def untrust(path: str):
|
|
204
|
+
"""Remove trust from a project directory — resume screening.
|
|
205
|
+
|
|
206
|
+
Removes the directory from the whitelist in ~/.tweek/overrides.yaml.
|
|
207
|
+
Tweek will resume screening tool calls for files in this path.
|
|
208
|
+
"""
|
|
209
|
+
try:
|
|
210
|
+
overrides, overrides_path = _load_overrides_yaml()
|
|
211
|
+
except ImportError:
|
|
212
|
+
console.print("[red]✗[/red] PyYAML is required. Install with: pip install pyyaml")
|
|
213
|
+
return
|
|
214
|
+
except Exception as e:
|
|
215
|
+
console.print(f"[red]✗[/red] Could not load overrides: {e}")
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
whitelist = overrides.get("whitelist", [])
|
|
219
|
+
if not whitelist:
|
|
220
|
+
console.print("[yellow]This path is not currently trusted.[/yellow]")
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
# Resolve path to absolute
|
|
224
|
+
resolved = Path(path).resolve()
|
|
225
|
+
resolved_str = str(resolved)
|
|
226
|
+
|
|
227
|
+
# Find and remove matching entry (full trust only, not tool-scoped)
|
|
228
|
+
original_len = len(whitelist)
|
|
229
|
+
whitelist = [
|
|
230
|
+
entry for entry in whitelist
|
|
231
|
+
if not (
|
|
232
|
+
isinstance(entry, dict)
|
|
233
|
+
and entry.get("path", "").rstrip("/") == resolved_str.rstrip("/")
|
|
234
|
+
and not entry.get("tools") # only remove full trust, not tool-scoped entries
|
|
235
|
+
)
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
if len(whitelist) == original_len:
|
|
239
|
+
console.print(f"[yellow]This path is not currently trusted:[/yellow] {resolved}")
|
|
240
|
+
console.print("[white]Use 'tweek trust --list' to see all trusted paths.[/white]")
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
overrides["whitelist"] = whitelist
|
|
244
|
+
|
|
245
|
+
# Clean up empty whitelist
|
|
246
|
+
if not whitelist:
|
|
247
|
+
del overrides["whitelist"]
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
_save_overrides_yaml(overrides, overrides_path)
|
|
251
|
+
except Exception as e:
|
|
252
|
+
console.print(f"[red]✗[/red] Could not save overrides: {e}")
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
console.print(f"[green]✓[/green] Removed trust: {resolved}")
|
|
256
|
+
console.print(f" [white]Tweek will now screen tool calls for files in this directory.[/white]")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# =============================================================================
|
|
260
|
+
# UPDATE
|
|
261
|
+
# =============================================================================
|
|
262
|
+
|
|
263
|
+
@click.command(
|
|
264
|
+
epilog="""\b
|
|
265
|
+
Examples:
|
|
266
|
+
tweek update Download/update attack patterns
|
|
267
|
+
tweek update --check Check for updates without installing
|
|
268
|
+
"""
|
|
269
|
+
)
|
|
270
|
+
@click.option("--check", is_flag=True, help="Check for updates without installing")
|
|
271
|
+
def update(check: bool):
|
|
272
|
+
"""Update attack patterns from GitHub.
|
|
273
|
+
|
|
274
|
+
Patterns are stored in ~/.tweek/patterns/ and can be updated
|
|
275
|
+
independently of the Tweek application.
|
|
276
|
+
|
|
277
|
+
All 262 patterns are included free. PRO tier adds LLM review,
|
|
278
|
+
session analysis, and rate limiting.
|
|
279
|
+
"""
|
|
280
|
+
import subprocess
|
|
281
|
+
|
|
282
|
+
patterns_dir = Path("~/.tweek/patterns").expanduser()
|
|
283
|
+
patterns_repo = "https://github.com/gettweek/tweek.git"
|
|
284
|
+
|
|
285
|
+
console.print(TWEEK_BANNER, style="cyan")
|
|
286
|
+
|
|
287
|
+
if not patterns_dir.exists():
|
|
288
|
+
# First time: clone the repo
|
|
289
|
+
if check:
|
|
290
|
+
console.print("[yellow]Patterns not installed.[/yellow]")
|
|
291
|
+
console.print(f"[white]Run 'tweek update' to install from {patterns_repo}[/white]")
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
console.print(f"[cyan]Installing patterns from {patterns_repo}...[/cyan]")
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
result = subprocess.run(
|
|
298
|
+
["git", "clone", "--depth", "1", patterns_repo, str(patterns_dir)],
|
|
299
|
+
capture_output=True,
|
|
300
|
+
text=True,
|
|
301
|
+
check=True
|
|
302
|
+
)
|
|
303
|
+
console.print("[green]✓[/green] Patterns installed successfully")
|
|
304
|
+
|
|
305
|
+
# Show pattern count
|
|
306
|
+
patterns_file = patterns_dir / "patterns.yaml"
|
|
307
|
+
if patterns_file.exists():
|
|
308
|
+
import yaml
|
|
309
|
+
with open(patterns_file) as f:
|
|
310
|
+
data = yaml.safe_load(f)
|
|
311
|
+
count = data.get("pattern_count", len(data.get("patterns", [])))
|
|
312
|
+
free_max = data.get("free_tier_max", 23)
|
|
313
|
+
console.print(f"[white]Installed {count} patterns ({free_max} free, {count - free_max} pro)[/white]")
|
|
314
|
+
|
|
315
|
+
except subprocess.CalledProcessError as e:
|
|
316
|
+
console.print(f"[red]✗[/red] Failed to clone patterns: {e.stderr}")
|
|
317
|
+
return
|
|
318
|
+
except FileNotFoundError:
|
|
319
|
+
console.print("[red]✗[/red] git not found.")
|
|
320
|
+
console.print(" [white]Hint: Install git from https://git-scm.com/downloads[/white]")
|
|
321
|
+
console.print(" [white]On macOS: xcode-select --install[/white]")
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
else:
|
|
325
|
+
# Update existing repo
|
|
326
|
+
if check:
|
|
327
|
+
console.print("[cyan]Checking for pattern updates...[/cyan]")
|
|
328
|
+
try:
|
|
329
|
+
result = subprocess.run(
|
|
330
|
+
["git", "-C", str(patterns_dir), "fetch", "--dry-run"],
|
|
331
|
+
capture_output=True,
|
|
332
|
+
text=True
|
|
333
|
+
)
|
|
334
|
+
# Check if there are updates
|
|
335
|
+
result2 = subprocess.run(
|
|
336
|
+
["git", "-C", str(patterns_dir), "status", "-uno"],
|
|
337
|
+
capture_output=True,
|
|
338
|
+
text=True
|
|
339
|
+
)
|
|
340
|
+
if "behind" in result2.stdout:
|
|
341
|
+
console.print("[yellow]Updates available.[/yellow]")
|
|
342
|
+
console.print("[white]Run 'tweek update' to install[/white]")
|
|
343
|
+
else:
|
|
344
|
+
console.print("[green]✓[/green] Patterns are up to date")
|
|
345
|
+
except Exception as e:
|
|
346
|
+
console.print(f"[red]✗[/red] Failed to check for updates: {e}")
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
console.print("[cyan]Updating patterns...[/cyan]")
|
|
350
|
+
|
|
351
|
+
try:
|
|
352
|
+
result = subprocess.run(
|
|
353
|
+
["git", "-C", str(patterns_dir), "pull", "--ff-only"],
|
|
354
|
+
capture_output=True,
|
|
355
|
+
text=True,
|
|
356
|
+
check=True
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
if "Already up to date" in result.stdout:
|
|
360
|
+
console.print("[green]✓[/green] Patterns already up to date")
|
|
361
|
+
else:
|
|
362
|
+
console.print("[green]✓[/green] Patterns updated successfully")
|
|
363
|
+
|
|
364
|
+
# Show what changed
|
|
365
|
+
if result.stdout.strip():
|
|
366
|
+
console.print(f"[white]{result.stdout.strip()}[/white]")
|
|
367
|
+
|
|
368
|
+
except subprocess.CalledProcessError as e:
|
|
369
|
+
console.print(f"[red]✗[/red] Failed to update patterns: {e.stderr}")
|
|
370
|
+
console.print("[white]Try: rm -rf ~/.tweek/patterns && tweek update[/white]")
|
|
371
|
+
return
|
|
372
|
+
|
|
373
|
+
# Show current version info
|
|
374
|
+
patterns_file = patterns_dir / "patterns.yaml"
|
|
375
|
+
if patterns_file.exists():
|
|
376
|
+
import yaml
|
|
377
|
+
try:
|
|
378
|
+
with open(patterns_file) as f:
|
|
379
|
+
data = yaml.safe_load(f)
|
|
380
|
+
version = data.get("version", "?")
|
|
381
|
+
count = data.get("pattern_count", len(data.get("patterns", [])))
|
|
382
|
+
|
|
383
|
+
console.print()
|
|
384
|
+
console.print(f"[cyan]Pattern version:[/cyan] {version}")
|
|
385
|
+
console.print(f"[cyan]Total patterns:[/cyan] {count} (all included free)")
|
|
386
|
+
|
|
387
|
+
console.print(f"[cyan]All features:[/cyan] LLM review, session analysis, rate limiting, sandbox (open source)")
|
|
388
|
+
console.print(f"[white]Pro (teams) and Enterprise (compliance) coming soon: gettweek.com[/white]")
|
|
389
|
+
|
|
390
|
+
except Exception:
|
|
391
|
+
pass
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# =============================================================================
|
|
395
|
+
# DOCTOR
|
|
396
|
+
# =============================================================================
|
|
397
|
+
|
|
398
|
+
@click.command(
|
|
399
|
+
epilog="""\b
|
|
400
|
+
Examples:
|
|
401
|
+
tweek doctor Run all health checks
|
|
402
|
+
tweek doctor --verbose Show detailed check information
|
|
403
|
+
tweek doctor --json Output results as JSON for scripting
|
|
404
|
+
"""
|
|
405
|
+
)
|
|
406
|
+
@click.option("--verbose", "-v", is_flag=True, help="Show detailed check information")
|
|
407
|
+
@click.option("--json-output", "--json", "json_out", is_flag=True, help="Output results as JSON")
|
|
408
|
+
def doctor(verbose: bool, json_out: bool):
|
|
409
|
+
"""Run health checks on your Tweek installation.
|
|
410
|
+
|
|
411
|
+
Checks hooks, configuration, patterns, database, vault, sandbox,
|
|
412
|
+
license, MCP, proxy, and plugin integrity.
|
|
413
|
+
"""
|
|
414
|
+
from tweek.diagnostics import run_health_checks
|
|
415
|
+
from tweek.cli_helpers import print_doctor_results, print_doctor_json
|
|
416
|
+
|
|
417
|
+
checks = run_health_checks(verbose=verbose)
|
|
418
|
+
|
|
419
|
+
if json_out:
|
|
420
|
+
print_doctor_json(checks)
|
|
421
|
+
else:
|
|
422
|
+
print_doctor_results(checks)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# =============================================================================
|
|
426
|
+
# UPGRADE
|
|
427
|
+
# =============================================================================
|
|
428
|
+
|
|
429
|
+
@click.command("upgrade")
|
|
430
|
+
def upgrade():
|
|
431
|
+
"""Upgrade Tweek to the latest version from PyPI.
|
|
432
|
+
|
|
433
|
+
Detects how Tweek was installed (uv, pipx, or pip) and runs
|
|
434
|
+
the appropriate upgrade command.
|
|
435
|
+
"""
|
|
436
|
+
import subprocess
|
|
437
|
+
|
|
438
|
+
console.print("[cyan]Checking for updates...[/cyan]")
|
|
439
|
+
console.print()
|
|
440
|
+
|
|
441
|
+
current_version = None
|
|
442
|
+
try:
|
|
443
|
+
from tweek import __version__
|
|
444
|
+
current_version = __version__
|
|
445
|
+
console.print(f" Current version: [bold]{current_version}[/bold]")
|
|
446
|
+
except ImportError:
|
|
447
|
+
pass
|
|
448
|
+
|
|
449
|
+
# Detect install method and upgrade
|
|
450
|
+
upgraded = False
|
|
451
|
+
|
|
452
|
+
# Try uv first
|
|
453
|
+
try:
|
|
454
|
+
result = subprocess.run(
|
|
455
|
+
["uv", "tool", "list"], capture_output=True, text=True, timeout=10
|
|
456
|
+
)
|
|
457
|
+
if result.returncode == 0 and "tweek" in result.stdout:
|
|
458
|
+
console.print(" Install method: [cyan]uv[/cyan]")
|
|
459
|
+
console.print()
|
|
460
|
+
console.print("[white]Upgrading via uv...[/white]")
|
|
461
|
+
proc = subprocess.run(
|
|
462
|
+
["uv", "tool", "upgrade", "tweek"],
|
|
463
|
+
capture_output=False, timeout=120
|
|
464
|
+
)
|
|
465
|
+
if proc.returncode == 0:
|
|
466
|
+
upgraded = True
|
|
467
|
+
else:
|
|
468
|
+
console.print("[yellow]uv upgrade failed, trying reinstall...[/yellow]")
|
|
469
|
+
subprocess.run(
|
|
470
|
+
["uv", "tool", "install", "--force", "tweek"],
|
|
471
|
+
capture_output=False, timeout=120
|
|
472
|
+
)
|
|
473
|
+
upgraded = True
|
|
474
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
475
|
+
pass
|
|
476
|
+
|
|
477
|
+
# Try pipx
|
|
478
|
+
if not upgraded:
|
|
479
|
+
try:
|
|
480
|
+
result = subprocess.run(
|
|
481
|
+
["pipx", "list"], capture_output=True, text=True, timeout=10
|
|
482
|
+
)
|
|
483
|
+
if result.returncode == 0 and "tweek" in result.stdout:
|
|
484
|
+
console.print(" Install method: [cyan]pipx[/cyan]")
|
|
485
|
+
console.print()
|
|
486
|
+
console.print("[white]Upgrading via pipx...[/white]")
|
|
487
|
+
proc = subprocess.run(
|
|
488
|
+
["pipx", "upgrade", "tweek"],
|
|
489
|
+
capture_output=False, timeout=120
|
|
490
|
+
)
|
|
491
|
+
upgraded = proc.returncode == 0
|
|
492
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
493
|
+
pass
|
|
494
|
+
|
|
495
|
+
# Try pip
|
|
496
|
+
if not upgraded:
|
|
497
|
+
try:
|
|
498
|
+
result = subprocess.run(
|
|
499
|
+
[sys.executable, "-m", "pip", "show", "tweek"],
|
|
500
|
+
capture_output=True, text=True, timeout=10
|
|
501
|
+
)
|
|
502
|
+
if result.returncode == 0:
|
|
503
|
+
console.print(" Install method: [cyan]pip[/cyan]")
|
|
504
|
+
console.print()
|
|
505
|
+
console.print("[white]Upgrading via pip...[/white]")
|
|
506
|
+
proc = subprocess.run(
|
|
507
|
+
[sys.executable, "-m", "pip", "install", "--upgrade", "tweek"],
|
|
508
|
+
capture_output=False, timeout=120
|
|
509
|
+
)
|
|
510
|
+
upgraded = proc.returncode == 0
|
|
511
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
512
|
+
pass
|
|
513
|
+
|
|
514
|
+
if not upgraded:
|
|
515
|
+
console.print("[red]Could not determine install method.[/red]")
|
|
516
|
+
console.print("[white]Try manually:[/white]")
|
|
517
|
+
console.print(" uv tool upgrade tweek")
|
|
518
|
+
console.print(" pipx upgrade tweek")
|
|
519
|
+
console.print(" pip install --upgrade tweek")
|
|
520
|
+
return
|
|
521
|
+
|
|
522
|
+
# Show new version
|
|
523
|
+
console.print()
|
|
524
|
+
try:
|
|
525
|
+
result = subprocess.run(
|
|
526
|
+
["tweek", "--version"], capture_output=True, text=True, timeout=10
|
|
527
|
+
)
|
|
528
|
+
if result.returncode == 0:
|
|
529
|
+
new_version = result.stdout.strip()
|
|
530
|
+
console.print(f"[green]✓[/green] Updated to {new_version}")
|
|
531
|
+
else:
|
|
532
|
+
console.print("[green]✓[/green] Update complete")
|
|
533
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
534
|
+
console.print("[green]✓[/green] Update complete")
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
# =============================================================================
|
|
538
|
+
# AUDIT
|
|
539
|
+
# =============================================================================
|
|
540
|
+
|
|
541
|
+
@click.command(
|
|
542
|
+
epilog="""\b
|
|
543
|
+
Examples:
|
|
544
|
+
tweek audit Scan all installed skills
|
|
545
|
+
tweek audit ./skills/my-skill/SKILL.md Audit a specific file
|
|
546
|
+
tweek audit --no-translate Skip translation of non-English content
|
|
547
|
+
tweek audit --json Machine-readable JSON output
|
|
548
|
+
"""
|
|
549
|
+
)
|
|
550
|
+
@click.argument("path", required=False, default=None, type=click.Path())
|
|
551
|
+
@click.option("--translate/--no-translate", default=True,
|
|
552
|
+
help="Translate non-English content before pattern analysis (default: auto)")
|
|
553
|
+
@click.option("--llm-review/--no-llm-review", default=True,
|
|
554
|
+
help="Run LLM semantic review (requires ANTHROPIC_API_KEY)")
|
|
555
|
+
@click.option("--json-output", "--json", "json_out", is_flag=True,
|
|
556
|
+
help="Output results as JSON")
|
|
557
|
+
def audit(path, translate, llm_review, json_out):
|
|
558
|
+
"""Audit skills and tool files for security risks.
|
|
559
|
+
|
|
560
|
+
Scans skill files (SKILL.md, tool descriptions) for prompt injection,
|
|
561
|
+
credential theft, data exfiltration, and other attack patterns.
|
|
562
|
+
|
|
563
|
+
Non-English content is detected and translated to English before
|
|
564
|
+
running all 262 regex patterns. LLM semantic review provides
|
|
565
|
+
additional analysis for obfuscated attacks.
|
|
566
|
+
|
|
567
|
+
\b
|
|
568
|
+
Without arguments, scans all installed skills in:
|
|
569
|
+
~/.claude/skills/
|
|
570
|
+
~/.openclaw/workspace/skills/
|
|
571
|
+
./.claude/skills/
|
|
572
|
+
"""
|
|
573
|
+
from tweek.audit import scan_installed_skills, audit_skill, audit_content
|
|
574
|
+
from rich.table import Table
|
|
575
|
+
|
|
576
|
+
if path:
|
|
577
|
+
# Audit a specific file
|
|
578
|
+
target = Path(path)
|
|
579
|
+
if not target.exists():
|
|
580
|
+
console.print(f"[red]File not found: {target}[/red]")
|
|
581
|
+
return
|
|
582
|
+
|
|
583
|
+
console.print(f"[cyan]Auditing {target}...[/cyan]")
|
|
584
|
+
console.print()
|
|
585
|
+
|
|
586
|
+
result = audit_skill(target, translate=translate, llm_review=llm_review)
|
|
587
|
+
|
|
588
|
+
if json_out:
|
|
589
|
+
_print_audit_json([result])
|
|
590
|
+
else:
|
|
591
|
+
_print_audit_result(result)
|
|
592
|
+
else:
|
|
593
|
+
# Scan all installed skills
|
|
594
|
+
console.print("[cyan]Scanning for installed skills...[/cyan]")
|
|
595
|
+
skills_found = scan_installed_skills()
|
|
596
|
+
|
|
597
|
+
if not skills_found:
|
|
598
|
+
console.print("[white]No installed skills found.[/white]")
|
|
599
|
+
console.print("[white]Specify a file path to audit: tweek audit <path>[/white]")
|
|
600
|
+
return
|
|
601
|
+
|
|
602
|
+
console.print(f"Found {len(skills_found)} skill(s)")
|
|
603
|
+
console.print()
|
|
604
|
+
|
|
605
|
+
results = []
|
|
606
|
+
for skill_info in skills_found:
|
|
607
|
+
if skill_info.get("error") or skill_info.get("content") is None:
|
|
608
|
+
console.print(f"[yellow]Skipping {skill_info['name']}: {skill_info.get('error', 'no content')}[/yellow]")
|
|
609
|
+
continue
|
|
610
|
+
|
|
611
|
+
console.print(f"[cyan]Auditing {skill_info['name']}...[/cyan]")
|
|
612
|
+
result = audit_content(
|
|
613
|
+
content=skill_info["content"],
|
|
614
|
+
name=skill_info["name"],
|
|
615
|
+
path=skill_info["path"],
|
|
616
|
+
translate=translate,
|
|
617
|
+
llm_review=llm_review,
|
|
618
|
+
)
|
|
619
|
+
results.append(result)
|
|
620
|
+
|
|
621
|
+
if json_out:
|
|
622
|
+
_print_audit_json(results)
|
|
623
|
+
else:
|
|
624
|
+
for result in results:
|
|
625
|
+
_print_audit_result(result)
|
|
626
|
+
console.print()
|
|
627
|
+
|
|
628
|
+
# Summary
|
|
629
|
+
total = len(results)
|
|
630
|
+
dangerous = sum(1 for r in results if r.risk_level == "dangerous")
|
|
631
|
+
suspicious = sum(1 for r in results if r.risk_level == "suspicious")
|
|
632
|
+
safe = sum(1 for r in results if r.risk_level == "safe")
|
|
633
|
+
|
|
634
|
+
console.print("[bold]Summary[/bold]")
|
|
635
|
+
console.print(f" Skills scanned: {total}")
|
|
636
|
+
if dangerous:
|
|
637
|
+
console.print(f" [red]Dangerous: {dangerous}[/red]")
|
|
638
|
+
if suspicious:
|
|
639
|
+
console.print(f" [yellow]Suspicious: {suspicious}[/yellow]")
|
|
640
|
+
console.print(f" [green]Safe: {safe}[/green]")
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _print_audit_result(result):
|
|
644
|
+
"""Print a formatted audit result."""
|
|
645
|
+
from rich.table import Table
|
|
646
|
+
|
|
647
|
+
risk_icons = {"safe": "[green]SAFE[/green]", "suspicious": "[yellow]SUSPICIOUS[/yellow]", "dangerous": "[red]DANGEROUS[/red]"}
|
|
648
|
+
|
|
649
|
+
console.print(f" [bold]{result.skill_name}[/bold] — {risk_icons.get(result.risk_level, result.risk_level)}")
|
|
650
|
+
console.print(f" [white]{result.skill_path}[/white]")
|
|
651
|
+
|
|
652
|
+
if result.error:
|
|
653
|
+
console.print(f" [red]Error: {result.error}[/red]")
|
|
654
|
+
return
|
|
655
|
+
|
|
656
|
+
if result.non_english_detected:
|
|
657
|
+
lang = result.detected_language or "unknown"
|
|
658
|
+
if result.translated:
|
|
659
|
+
console.print(f" [cyan]Non-English detected ({lang}) — translated for analysis[/cyan]")
|
|
660
|
+
else:
|
|
661
|
+
console.print(f" [yellow]Non-English detected ({lang}) — translation skipped[/yellow]")
|
|
662
|
+
|
|
663
|
+
if result.findings:
|
|
664
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
|
|
665
|
+
table.add_column("Severity", style="white")
|
|
666
|
+
table.add_column("Pattern")
|
|
667
|
+
table.add_column("Description")
|
|
668
|
+
table.add_column("Match", style="white")
|
|
669
|
+
|
|
670
|
+
severity_styles = {"critical": "red bold", "high": "red", "medium": "yellow", "low": "white"}
|
|
671
|
+
|
|
672
|
+
for finding in result.findings:
|
|
673
|
+
table.add_row(
|
|
674
|
+
f"[{severity_styles.get(finding.severity, '')}]{finding.severity.upper()}[/]",
|
|
675
|
+
finding.pattern_name,
|
|
676
|
+
finding.description,
|
|
677
|
+
finding.matched_text[:40] if finding.matched_text else "",
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
console.print(table)
|
|
681
|
+
else:
|
|
682
|
+
console.print(" [green]No patterns matched[/green]")
|
|
683
|
+
|
|
684
|
+
if result.llm_review:
|
|
685
|
+
review = result.llm_review
|
|
686
|
+
console.print(f" LLM Review: {review.get('risk_level', 'N/A')} ({review.get('confidence', 0):.0%}) — {review.get('reason', '')}")
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _print_audit_json(results):
|
|
690
|
+
"""Print audit results as JSON."""
|
|
691
|
+
import json
|
|
692
|
+
output = []
|
|
693
|
+
for r in results:
|
|
694
|
+
output.append({
|
|
695
|
+
"skill_name": r.skill_name,
|
|
696
|
+
"skill_path": str(r.skill_path),
|
|
697
|
+
"risk_level": r.risk_level,
|
|
698
|
+
"content_length": r.content_length,
|
|
699
|
+
"non_english_detected": r.non_english_detected,
|
|
700
|
+
"detected_language": r.detected_language,
|
|
701
|
+
"translated": r.translated,
|
|
702
|
+
"finding_count": r.finding_count,
|
|
703
|
+
"critical_count": r.critical_count,
|
|
704
|
+
"high_count": r.high_count,
|
|
705
|
+
"findings": [
|
|
706
|
+
{
|
|
707
|
+
"pattern_id": f.pattern_id,
|
|
708
|
+
"pattern_name": f.pattern_name,
|
|
709
|
+
"severity": f.severity,
|
|
710
|
+
"description": f.description,
|
|
711
|
+
"matched_text": f.matched_text,
|
|
712
|
+
}
|
|
713
|
+
for f in r.findings
|
|
714
|
+
],
|
|
715
|
+
"llm_review": r.llm_review,
|
|
716
|
+
"error": r.error,
|
|
717
|
+
})
|
|
718
|
+
console.print_json(json.dumps(output, indent=2))
|