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.
Files changed (61) hide show
  1. tweek/__init__.py +2 -2
  2. tweek/audit.py +2 -2
  3. tweek/cli.py +78 -6605
  4. tweek/cli_config.py +643 -0
  5. tweek/cli_configure.py +413 -0
  6. tweek/cli_core.py +718 -0
  7. tweek/cli_dry_run.py +390 -0
  8. tweek/cli_helpers.py +316 -0
  9. tweek/cli_install.py +1666 -0
  10. tweek/cli_logs.py +301 -0
  11. tweek/cli_mcp.py +148 -0
  12. tweek/cli_memory.py +343 -0
  13. tweek/cli_plugins.py +748 -0
  14. tweek/cli_protect.py +564 -0
  15. tweek/cli_proxy.py +405 -0
  16. tweek/cli_security.py +236 -0
  17. tweek/cli_skills.py +289 -0
  18. tweek/cli_uninstall.py +551 -0
  19. tweek/cli_vault.py +313 -0
  20. tweek/config/allowed_dirs.yaml +16 -17
  21. tweek/config/families.yaml +4 -1
  22. tweek/config/manager.py +17 -0
  23. tweek/config/patterns.yaml +29 -5
  24. tweek/config/templates/config.yaml.template +212 -0
  25. tweek/config/templates/env.template +45 -0
  26. tweek/config/templates/overrides.yaml.template +121 -0
  27. tweek/config/templates/tweek.yaml.template +20 -0
  28. tweek/config/templates.py +136 -0
  29. tweek/config/tiers.yaml +5 -4
  30. tweek/diagnostics.py +112 -32
  31. tweek/hooks/overrides.py +4 -0
  32. tweek/hooks/post_tool_use.py +46 -1
  33. tweek/hooks/pre_tool_use.py +149 -49
  34. tweek/integrations/openclaw.py +84 -0
  35. tweek/licensing.py +1 -1
  36. tweek/mcp/__init__.py +7 -9
  37. tweek/mcp/clients/chatgpt.py +2 -2
  38. tweek/mcp/clients/claude_desktop.py +2 -2
  39. tweek/mcp/clients/gemini.py +2 -2
  40. tweek/mcp/proxy.py +165 -1
  41. tweek/memory/provenance.py +438 -0
  42. tweek/memory/queries.py +2 -0
  43. tweek/memory/safety.py +23 -4
  44. tweek/memory/schemas.py +1 -0
  45. tweek/memory/store.py +101 -71
  46. tweek/plugins/screening/heuristic_scorer.py +1 -1
  47. tweek/security/integrity.py +77 -0
  48. tweek/security/llm_reviewer.py +170 -74
  49. tweek/security/local_reviewer.py +44 -2
  50. tweek/security/model_registry.py +73 -7
  51. tweek/skill_template/overrides-reference.md +1 -1
  52. tweek/skills/context.py +221 -0
  53. tweek/skills/scanner.py +2 -2
  54. {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/METADATA +8 -7
  55. {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/RECORD +60 -38
  56. tweek/mcp/server.py +0 -320
  57. {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/WHEEL +0 -0
  58. {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/entry_points.txt +0 -0
  59. {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/licenses/LICENSE +0 -0
  60. {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/licenses/NOTICE +0 -0
  61. {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/top_level.txt +0 -0
tweek/cli_dry_run.py ADDED
@@ -0,0 +1,390 @@
1
+ """Dry-run command group for Tweek CLI (renamed from sandbox).
2
+
3
+ Provides the ``tweek dry-run`` CLI surface for project-level sandbox
4
+ isolation management. The underlying sandbox subsystem modules
5
+ (``tweek.sandbox.*``) are unchanged -- only the user-facing command
6
+ hierarchy has been renamed from ``tweek sandbox`` to ``tweek dry-run``.
7
+ """
8
+
9
+ import os
10
+ import shutil
11
+ import json
12
+ from pathlib import Path
13
+
14
+ import click
15
+ from rich.table import Table
16
+
17
+ from tweek.cli_helpers import console
18
+
19
+
20
+ @click.group("dry-run")
21
+ def dry_run():
22
+ """Project-level dry-run isolation management.
23
+
24
+ Layer 2 provides per-project security state isolation:
25
+ - Separate security event logs per project
26
+ - Project-scoped pattern overrides (additive-only)
27
+ - Project-scoped skill fingerprints
28
+ - Project-scoped configuration
29
+
30
+ Project overrides can ADD security but NEVER weaken global settings.
31
+ """
32
+ pass
33
+
34
+
35
+ @dry_run.command("status")
36
+ def sandbox_status():
37
+ """Show current project's sandbox info."""
38
+ from tweek.sandbox.project import get_project_sandbox, _detect_project_dir
39
+ from tweek.sandbox.layers import get_layer_description, IsolationLayer
40
+
41
+ project_dir = _detect_project_dir(os.getcwd())
42
+ if not project_dir:
43
+ console.print("[yellow]Not inside a project directory (no .git/ or .claude/ found).[/yellow]")
44
+ return
45
+
46
+ sandbox = get_project_sandbox(os.getcwd())
47
+ if sandbox:
48
+ console.print(f"[bold]Project:[/bold] {sandbox.project_dir}")
49
+ console.print(f"[bold]Layer:[/bold] {sandbox.layer.value} ({sandbox.layer.name})")
50
+ console.print(f"[bold]Description:[/bold] {get_layer_description(sandbox.layer)}")
51
+ console.print(f"[bold]Tweek dir:[/bold] {sandbox.tweek_dir}")
52
+ console.print(f"[bold]Initialized:[/bold] {sandbox.is_initialized}")
53
+
54
+ if sandbox.is_initialized:
55
+ db_path = sandbox.tweek_dir / "security.db"
56
+ if db_path.exists():
57
+ size_kb = db_path.stat().st_size / 1024
58
+ console.print(f"[bold]Security DB:[/bold] {size_kb:.1f} KB")
59
+ else:
60
+ console.print(f"[bold]Project:[/bold] {project_dir}")
61
+ console.print(f"[bold]Layer:[/bold] 0-1 (no project isolation)")
62
+ console.print("[white]Run 'tweek dry-run init' to enable project isolation.[/white]")
63
+
64
+
65
+ @dry_run.command("init")
66
+ @click.option("--layer", type=int, default=2, help="Isolation layer (0=bypass, 1=skills, 2=project)")
67
+ def sandbox_init(layer: int):
68
+ """Initialize sandbox for current project."""
69
+ from tweek.sandbox.project import ProjectSandbox, _detect_project_dir
70
+ from tweek.sandbox.layers import IsolationLayer, get_layer_description
71
+ from tweek.logging.security_log import get_logger, EventType
72
+
73
+ project_dir = _detect_project_dir(os.getcwd())
74
+ if not project_dir:
75
+ console.print("[red]Not inside a project directory (no .git/ or .claude/ found).[/red]")
76
+ raise SystemExit(1)
77
+
78
+ isolation_layer = IsolationLayer.from_value(layer)
79
+ sandbox = ProjectSandbox(project_dir)
80
+ sandbox.config.layer = isolation_layer.value
81
+ sandbox.layer = isolation_layer
82
+
83
+ sandbox.initialize()
84
+
85
+ console.print(f"[green]Sandbox initialized for {project_dir}[/green]")
86
+ console.print(f"[bold]Layer:[/bold] {isolation_layer.value} ({isolation_layer.name})")
87
+ console.print(f"[bold]Description:[/bold] {get_layer_description(isolation_layer)}")
88
+ console.print(f"[bold]State directory:[/bold] {sandbox.tweek_dir}")
89
+
90
+ try:
91
+ logger = get_logger()
92
+ from tweek.logging.security_log import SecurityEvent
93
+ logger.log(SecurityEvent(
94
+ event_type=EventType.SANDBOX_PROJECT_INIT,
95
+ tool_name="cli",
96
+ decision="allow",
97
+ decision_reason=f"Project sandbox initialized at layer {isolation_layer.value}",
98
+ working_directory=str(project_dir),
99
+ ))
100
+ except Exception:
101
+ pass
102
+
103
+
104
+ @dry_run.command("layer")
105
+ @click.argument("level", type=int)
106
+ def sandbox_layer(level: int):
107
+ """Set isolation layer for current project (0=bypass, 1=skills, 2=project)."""
108
+ from tweek.sandbox.project import _detect_project_dir
109
+ from tweek.sandbox.layers import IsolationLayer, get_layer_description
110
+ from tweek.sandbox.registry import get_registry
111
+
112
+ project_dir = _detect_project_dir(os.getcwd())
113
+ if not project_dir:
114
+ console.print("[red]Not inside a project directory.[/red]")
115
+ raise SystemExit(1)
116
+
117
+ new_layer = IsolationLayer.from_value(level)
118
+ registry = get_registry()
119
+ registry.set_layer(project_dir, new_layer)
120
+
121
+ console.print(f"[green]Layer set to {new_layer.value} ({new_layer.name})[/green]")
122
+ console.print(f"[bold]Description:[/bold] {get_layer_description(new_layer)}")
123
+
124
+
125
+ @dry_run.command("list")
126
+ def sandbox_list():
127
+ """List all registered projects and their layers."""
128
+ from tweek.sandbox.registry import get_registry
129
+ from tweek.sandbox.layers import IsolationLayer
130
+
131
+ registry = get_registry()
132
+ projects = registry.list_projects()
133
+
134
+ if not projects:
135
+ console.print("[white]No projects registered. Run 'tweek dry-run init' in a project.[/white]")
136
+ return
137
+
138
+ table = Table(title="Registered Projects")
139
+ table.add_column("Project", style="cyan")
140
+ table.add_column("Layer", style="green")
141
+ table.add_column("Last Used")
142
+ table.add_column("Auto-Init")
143
+
144
+ for p in projects:
145
+ layer = p["layer"]
146
+ table.add_row(
147
+ p["path"],
148
+ f"{layer.value} ({layer.name})",
149
+ p.get("last_used", "")[:19],
150
+ "Yes" if p.get("auto_initialized") else "No",
151
+ )
152
+
153
+ console.print(table)
154
+
155
+
156
+ @dry_run.command("config")
157
+ def sandbox_config():
158
+ """Show effective merged config (global + project)."""
159
+ from tweek.sandbox.project import get_project_sandbox, _detect_project_dir
160
+
161
+ project_dir = _detect_project_dir(os.getcwd())
162
+ if not project_dir:
163
+ console.print("[red]Not inside a project directory.[/red]")
164
+ raise SystemExit(1)
165
+
166
+ sandbox = get_project_sandbox(os.getcwd())
167
+ if not sandbox:
168
+ console.print("[yellow]Project sandbox not active (layer < 2).[/yellow]")
169
+ return
170
+
171
+ console.print("[bold]Effective Configuration (global + project merge):[/bold]")
172
+ console.print(f" Layer: {sandbox.layer.value} ({sandbox.layer.name})")
173
+ console.print(f" Additive only: {sandbox.config.additive_only}")
174
+ console.print(f" Auto gitignore: {sandbox.config.auto_gitignore}")
175
+
176
+ overrides = sandbox.get_overrides()
177
+ if overrides:
178
+ console.print(f" Overrides loaded: Yes")
179
+ if hasattr(overrides, 'global_ovr') and hasattr(overrides, 'project_ovr'):
180
+ console.print(f" Merge type: Additive-only (global + project)")
181
+ else:
182
+ console.print(f" Merge type: Global only (no project overrides)")
183
+
184
+
185
+ @dry_run.command("logs")
186
+ @click.option("--global", "show_global", is_flag=True, help="Show global security log instead")
187
+ @click.option("--limit", default=20, help="Number of events to show")
188
+ def sandbox_logs(show_global: bool, limit: int):
189
+ """View project-scoped or global security log."""
190
+ from tweek.logging.security_log import SecurityLogger, get_logger
191
+
192
+ if show_global:
193
+ logger = get_logger()
194
+ console.print("[bold]Global Security Log[/bold]")
195
+ else:
196
+ from tweek.sandbox.project import get_project_sandbox
197
+ sandbox = get_project_sandbox(os.getcwd())
198
+ if sandbox:
199
+ logger = sandbox.get_logger()
200
+ console.print(f"[bold]Project Security Log[/bold] ({sandbox.project_dir})")
201
+ else:
202
+ logger = get_logger()
203
+ console.print("[bold]Global Security Log[/bold] (no project sandbox active)")
204
+
205
+ events = logger.get_recent_events(limit=limit)
206
+ if not events:
207
+ console.print("[white]No events found.[/white]")
208
+ return
209
+
210
+ table = Table()
211
+ table.add_column("Time", style="white")
212
+ table.add_column("Type")
213
+ table.add_column("Tool")
214
+ table.add_column("Decision", style="green")
215
+ table.add_column("Reason")
216
+
217
+ for e in events:
218
+ table.add_row(
219
+ str(e.get("timestamp", ""))[:19],
220
+ e.get("event_type", ""),
221
+ e.get("tool_name", ""),
222
+ e.get("decision", ""),
223
+ (e.get("decision_reason", "") or "")[:60],
224
+ )
225
+
226
+ console.print(table)
227
+
228
+
229
+ @dry_run.command("reset")
230
+ @click.option("--confirm", is_flag=True, help="Skip confirmation")
231
+ def sandbox_reset(confirm: bool):
232
+ """Remove project .tweek/ and deregister."""
233
+ from tweek.sandbox.project import get_project_sandbox, _detect_project_dir
234
+
235
+ project_dir = _detect_project_dir(os.getcwd())
236
+ if not project_dir:
237
+ console.print("[red]Not inside a project directory.[/red]")
238
+ raise SystemExit(1)
239
+
240
+ tweek_dir = project_dir / ".tweek"
241
+ if not tweek_dir.exists():
242
+ console.print("[yellow]No .tweek/ directory found in this project.[/yellow]")
243
+ return
244
+
245
+ if not confirm:
246
+ console.print(f"[yellow]This will remove {tweek_dir} and all project-scoped security state.[/yellow]")
247
+ if not click.confirm("Continue?"):
248
+ return
249
+
250
+ sandbox = get_project_sandbox(os.getcwd())
251
+ if sandbox:
252
+ sandbox.reset()
253
+ console.print(f"[green]Project sandbox removed: {tweek_dir}[/green]")
254
+ else:
255
+ # Manual cleanup
256
+ shutil.rmtree(tweek_dir, ignore_errors=True)
257
+ from tweek.sandbox.registry import get_registry
258
+ get_registry().deregister(project_dir)
259
+ console.print(f"[green]Removed: {tweek_dir}[/green]")
260
+
261
+
262
+ @dry_run.command("verify")
263
+ def sandbox_verify():
264
+ """Test that project isolation is working."""
265
+ from tweek.sandbox.project import get_project_sandbox, _detect_project_dir
266
+ from tweek.sandbox.layers import IsolationLayer
267
+
268
+ project_dir = _detect_project_dir(os.getcwd())
269
+ if not project_dir:
270
+ console.print("[red]Not inside a project directory.[/red]")
271
+ raise SystemExit(1)
272
+
273
+ sandbox = get_project_sandbox(os.getcwd())
274
+ checks_passed = 0
275
+ checks_total = 0
276
+
277
+ # Check 1: Project detected
278
+ checks_total += 1
279
+ console.print(f" Project detected: {project_dir}", end="")
280
+ console.print(" [green]OK[/green]")
281
+ checks_passed += 1
282
+
283
+ # Check 2: Sandbox initialized
284
+ checks_total += 1
285
+ if sandbox and sandbox.is_initialized:
286
+ console.print(f" Sandbox initialized: {sandbox.tweek_dir}", end="")
287
+ console.print(" [green]OK[/green]")
288
+ checks_passed += 1
289
+ else:
290
+ console.print(" Sandbox initialized: [red]NO[/red]")
291
+ console.print(" [white]Run 'tweek dry-run init' to enable.[/white]")
292
+
293
+ # Check 3: Layer
294
+ checks_total += 1
295
+ if sandbox and sandbox.layer >= IsolationLayer.PROJECT:
296
+ console.print(f" Isolation layer: {sandbox.layer.value} ({sandbox.layer.name})", end="")
297
+ console.print(" [green]OK[/green]")
298
+ checks_passed += 1
299
+ else:
300
+ layer_val = sandbox.layer.value if sandbox else 0
301
+ console.print(f" Isolation layer: {layer_val} [yellow]BELOW PROJECT[/yellow]")
302
+
303
+ # Check 4: Security DB exists
304
+ checks_total += 1
305
+ if sandbox and (sandbox.tweek_dir / "security.db").exists():
306
+ console.print(" Project security.db: exists", end="")
307
+ console.print(" [green]OK[/green]")
308
+ checks_passed += 1
309
+ elif sandbox:
310
+ console.print(" Project security.db: [yellow]NOT FOUND[/yellow]")
311
+ else:
312
+ console.print(" Project security.db: [white]N/A (sandbox inactive)[/white]")
313
+
314
+ # Check 5: .gitignore
315
+ checks_total += 1
316
+ gitignore = project_dir / ".gitignore"
317
+ if gitignore.exists() and ".tweek" in gitignore.read_text():
318
+ console.print(" .gitignore includes .tweek/:", end="")
319
+ console.print(" [green]OK[/green]")
320
+ checks_passed += 1
321
+ else:
322
+ console.print(" .gitignore includes .tweek/: [yellow]NO[/yellow]")
323
+
324
+ console.print(f"\n [bold]{checks_passed}/{checks_total} checks passed[/bold]")
325
+
326
+
327
+ # Docker bridge commands
328
+ @dry_run.group("docker")
329
+ def sandbox_docker():
330
+ """Docker integration for container-level isolation."""
331
+ pass
332
+
333
+
334
+ @sandbox_docker.command("init")
335
+ def docker_init():
336
+ """Generate Docker Sandbox config for this project."""
337
+ from tweek.sandbox.docker_bridge import DockerBridge
338
+
339
+ bridge = DockerBridge()
340
+ if not bridge.is_docker_available():
341
+ console.print("[red]Docker is not installed or not running.[/red]")
342
+ console.print("[white]Install Docker Desktop from https://www.docker.com/products/docker-desktop/[/white]")
343
+ raise SystemExit(1)
344
+
345
+ from tweek.sandbox.project import _detect_project_dir
346
+ project_dir = _detect_project_dir(os.getcwd())
347
+ if not project_dir:
348
+ console.print("[red]Not inside a project directory.[/red]")
349
+ raise SystemExit(1)
350
+
351
+ compose_path = bridge.init(project_dir)
352
+ console.print(f"[green]Docker Sandbox config generated: {compose_path}[/green]")
353
+ console.print("[white]Run 'tweek dry-run docker run' to start the container.[/white]")
354
+
355
+
356
+ @sandbox_docker.command("run")
357
+ def docker_run():
358
+ """Launch container-isolated session (requires Docker)."""
359
+ from tweek.sandbox.docker_bridge import DockerBridge
360
+ from tweek.sandbox.project import _detect_project_dir
361
+
362
+ bridge = DockerBridge()
363
+ if not bridge.is_docker_available():
364
+ console.print("[red]Docker is not available.[/red]")
365
+ raise SystemExit(1)
366
+
367
+ project_dir = _detect_project_dir(os.getcwd())
368
+ if not project_dir:
369
+ console.print("[red]Not inside a project directory.[/red]")
370
+ raise SystemExit(1)
371
+
372
+ console.print("[bold]Launching Docker sandbox...[/bold]")
373
+ bridge.run(project_dir)
374
+
375
+
376
+ @sandbox_docker.command("status")
377
+ def docker_status():
378
+ """Check Docker integration status."""
379
+ from tweek.sandbox.docker_bridge import DockerBridge
380
+
381
+ bridge = DockerBridge()
382
+ console.print(f"[bold]Docker available:[/bold] {bridge.is_docker_available()}")
383
+
384
+ from tweek.sandbox.project import _detect_project_dir
385
+ project_dir = _detect_project_dir(os.getcwd())
386
+ if project_dir:
387
+ compose = project_dir / ".tweek" / "docker-compose.yaml"
388
+ console.print(f"[bold]Docker config:[/bold] {'exists' if compose.exists() else 'not generated'}")
389
+ else:
390
+ console.print("[white]Not in a project directory.[/white]")