tweek 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. tweek/__init__.py +2 -2
  2. tweek/audit.py +2 -2
  3. tweek/cli.py +78 -6559
  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/__init__.py +8 -0
  21. tweek/config/allowed_dirs.yaml +16 -17
  22. tweek/config/families.yaml +4 -1
  23. tweek/config/manager.py +49 -0
  24. tweek/config/models.py +307 -0
  25. tweek/config/patterns.yaml +29 -5
  26. tweek/config/templates/config.yaml.template +212 -0
  27. tweek/config/templates/env.template +45 -0
  28. tweek/config/templates/overrides.yaml.template +121 -0
  29. tweek/config/templates/tweek.yaml.template +20 -0
  30. tweek/config/templates.py +136 -0
  31. tweek/config/tiers.yaml +5 -4
  32. tweek/diagnostics.py +112 -32
  33. tweek/hooks/overrides.py +4 -0
  34. tweek/hooks/post_tool_use.py +46 -1
  35. tweek/hooks/pre_tool_use.py +149 -49
  36. tweek/integrations/openclaw.py +84 -0
  37. tweek/licensing.py +1 -1
  38. tweek/mcp/__init__.py +7 -9
  39. tweek/mcp/clients/chatgpt.py +2 -2
  40. tweek/mcp/clients/claude_desktop.py +2 -2
  41. tweek/mcp/clients/gemini.py +2 -2
  42. tweek/mcp/proxy.py +165 -1
  43. tweek/memory/provenance.py +438 -0
  44. tweek/memory/queries.py +2 -0
  45. tweek/memory/safety.py +23 -4
  46. tweek/memory/schemas.py +1 -0
  47. tweek/memory/store.py +101 -71
  48. tweek/plugins/screening/heuristic_scorer.py +1 -1
  49. tweek/security/integrity.py +77 -0
  50. tweek/security/llm_reviewer.py +162 -68
  51. tweek/security/local_reviewer.py +44 -2
  52. tweek/security/model_registry.py +73 -7
  53. tweek/skill_template/overrides-reference.md +1 -1
  54. tweek/skills/context.py +221 -0
  55. tweek/skills/scanner.py +2 -2
  56. {tweek-0.3.0.dist-info → tweek-0.4.0.dist-info}/METADATA +9 -7
  57. {tweek-0.3.0.dist-info → tweek-0.4.0.dist-info}/RECORD +62 -39
  58. tweek/mcp/server.py +0 -320
  59. {tweek-0.3.0.dist-info → tweek-0.4.0.dist-info}/WHEEL +0 -0
  60. {tweek-0.3.0.dist-info → tweek-0.4.0.dist-info}/entry_points.txt +0 -0
  61. {tweek-0.3.0.dist-info → tweek-0.4.0.dist-info}/licenses/LICENSE +0 -0
  62. {tweek-0.3.0.dist-info → tweek-0.4.0.dist-info}/licenses/NOTICE +0 -0
  63. {tweek-0.3.0.dist-info → tweek-0.4.0.dist-info}/top_level.txt +0 -0
tweek/cli_helpers.py CHANGED
@@ -7,9 +7,14 @@ Provides colored status messages, health banners, command example formatting,
7
7
  and progress spinners.
8
8
  """
9
9
 
10
+ import json
11
+ import shutil
10
12
  from contextlib import contextmanager
13
+ from pathlib import Path
11
14
  from typing import List, Tuple
12
15
 
16
+ import click
17
+
13
18
  from rich.console import Console
14
19
  from rich.panel import Panel
15
20
  from rich.table import Table
@@ -191,3 +196,314 @@ def print_doctor_json(checks: "List") -> None:
191
196
  }
192
197
 
193
198
  console.print_json(json.dumps(output, indent=2))
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # Cross-module constants and helpers
203
+ # ---------------------------------------------------------------------------
204
+
205
+ TWEEK_BANNER = """
206
+ ████████╗██╗ ██╗███████╗███████╗██╗ ██╗
207
+ ╚══██╔══╝██║ ██║██╔════╝██╔════╝██║ ██╔╝
208
+ ██║ ██║ █╗ ██║█████╗ █████╗ █████╔╝
209
+ ██║ ██║███╗██║██╔══╝ ██╔══╝ ██╔═██╗
210
+ ██║ ╚███╔███╔╝███████╗███████╗██║ ██╗
211
+ ╚═╝ ╚══╝╚══╝ ╚══════╝╚══════╝╚═╝ ╚═╝
212
+
213
+ GAH! Security for AI agents
214
+ "Because paranoia is a feature, not a bug"
215
+ """
216
+
217
+
218
+ def _has_tweek_hooks(settings: dict) -> bool:
219
+ """Check if a settings dict contains Tweek hooks."""
220
+ hooks = settings.get("hooks", {})
221
+ for hook_type in ("PreToolUse", "PostToolUse"):
222
+ for hook_config in hooks.get(hook_type, []):
223
+ for hook in hook_config.get("hooks", []):
224
+ if "tweek" in hook.get("command", "").lower():
225
+ return True
226
+ return False
227
+
228
+
229
+ def _has_tweek_at(target: Path) -> bool:
230
+ """Check if Tweek is installed at a .claude/ target path."""
231
+ import json
232
+
233
+ if (target / "skills" / "tweek").exists():
234
+ return True
235
+ if (target / "settings.json.tweek-backup").exists():
236
+ return True
237
+ settings_file = target / "settings.json"
238
+ if settings_file.exists():
239
+ try:
240
+ with open(settings_file) as f:
241
+ settings = json.load(f)
242
+ if _has_tweek_hooks(settings):
243
+ return True
244
+ except (json.JSONDecodeError, IOError):
245
+ pass
246
+ return False
247
+
248
+
249
+ def _detect_all_tools():
250
+ """Detect all supported AI tools and their protection status.
251
+
252
+ Returns list of (tool_id, label, installed, protected, detail) tuples.
253
+ """
254
+ import shutil
255
+ import json
256
+
257
+ tools = []
258
+
259
+ # Claude Code
260
+ claude_installed = shutil.which("claude") is not None
261
+ claude_protected = _has_tweek_at(Path("~/.claude").expanduser()) if claude_installed else False
262
+ tools.append((
263
+ "claude-code", "Claude Code", claude_installed, claude_protected,
264
+ "Hooks in ~/.claude/settings.json" if claude_protected else "",
265
+ ))
266
+
267
+ # OpenClaw
268
+ oc_installed = False
269
+ oc_protected = False
270
+ oc_detail = ""
271
+ try:
272
+ from tweek.integrations.openclaw import detect_openclaw_installation
273
+ openclaw = detect_openclaw_installation()
274
+ oc_installed = openclaw.get("installed", False)
275
+ if oc_installed:
276
+ oc_protected = openclaw.get("tweek_configured", False)
277
+ oc_detail = f"Gateway port {openclaw.get('gateway_port', '?')}"
278
+ except Exception:
279
+ pass
280
+ tools.append(("openclaw", "OpenClaw", oc_installed, oc_protected, oc_detail))
281
+
282
+ # MCP clients
283
+ mcp_configs = [
284
+ ("claude-desktop", "Claude Desktop",
285
+ Path("~/Library/Application Support/Claude/claude_desktop_config.json").expanduser()),
286
+ ("chatgpt", "ChatGPT Desktop",
287
+ Path("~/Library/Application Support/com.openai.chat/developer_settings.json").expanduser()),
288
+ ("gemini", "Gemini CLI",
289
+ Path("~/.gemini/settings.json").expanduser()),
290
+ ]
291
+ for tool_id, label, config_path in mcp_configs:
292
+ installed = config_path.exists()
293
+ protected = False
294
+ if installed:
295
+ try:
296
+ with open(config_path) as f:
297
+ data = json.load(f)
298
+ mcp_servers = data.get("mcpServers", {})
299
+ protected = "tweek-security" in mcp_servers or "tweek" in mcp_servers
300
+ except Exception:
301
+ pass
302
+ detail = str(config_path) if protected else ""
303
+ tools.append((tool_id, label, installed, protected, detail))
304
+
305
+ return tools
306
+
307
+
308
+ # ---------------------------------------------------------------------------
309
+ # TieredGroup — progressive disclosure for CLI help
310
+ # ---------------------------------------------------------------------------
311
+
312
+ # Command tiers: shown in order in default --help output.
313
+ # Commands not listed here go into "All other commands" (compressed).
314
+ COMMAND_TIERS = {
315
+ "Getting Started": [
316
+ "protect",
317
+ "status",
318
+ "doctor",
319
+ "update",
320
+ "upgrade",
321
+ "configure",
322
+ ],
323
+ "Security & Trust": [
324
+ "trust",
325
+ "untrust",
326
+ "config",
327
+ "audit",
328
+ ],
329
+ }
330
+
331
+ # All commands that appear in an explicit tier above
332
+ _TIERED_COMMANDS = {cmd for cmds in COMMAND_TIERS.values() for cmd in cmds}
333
+
334
+
335
+ class TieredGroup(click.Group):
336
+ """A Click Group that displays commands in tiered categories.
337
+
338
+ Default ``--help`` shows core commands with full descriptions and
339
+ compresses remaining commands into a single line. Pass ``--help-all``
340
+ to see every command with its description, grouped by category.
341
+ """
342
+
343
+ def __init__(self, *args, **kwargs):
344
+ super().__init__(*args, **kwargs)
345
+ self.params.append(
346
+ click.Option(
347
+ ["--help-all"],
348
+ is_flag=True,
349
+ expose_value=False,
350
+ is_eager=True,
351
+ callback=self._show_help_all,
352
+ help="Show all commands with full descriptions.",
353
+ )
354
+ )
355
+
356
+ def _show_help_all(self, ctx, _param, value):
357
+ if not value:
358
+ return
359
+ # Build the full categorised help text and print it
360
+ formatter = ctx.make_formatter()
361
+ self.format_usage(ctx, formatter)
362
+ self.format_help_text(ctx, formatter)
363
+ self._format_all_commands(ctx, formatter)
364
+ formatter.write("\n")
365
+ click.echo(formatter.getvalue().rstrip("\n"))
366
+ ctx.exit(0)
367
+
368
+ # -- default help (tiered) ------------------------------------------------
369
+
370
+ def format_commands(self, ctx, formatter):
371
+ """Override: render tiered command list instead of flat alphabetical."""
372
+ commands = self._sorted_commands(ctx)
373
+ if not commands:
374
+ return
375
+
376
+ cmd_map = {name: cmd for name, cmd in commands}
377
+ max_len = max(len(name) for name, _ in commands)
378
+
379
+ # Tier 1+2: explicit categories with full descriptions
380
+ for tier_name, tier_cmds in COMMAND_TIERS.items():
381
+ rows = []
382
+ for name in tier_cmds:
383
+ cmd = cmd_map.get(name)
384
+ if cmd is None:
385
+ continue
386
+ help_text = cmd.get_short_help_str(limit=150)
387
+ rows.append((name, help_text))
388
+ if rows:
389
+ with formatter.section(tier_name):
390
+ formatter.write_dl(rows)
391
+
392
+ # Remaining: compressed into a single line
393
+ other_names = [name for name, _ in commands if name not in _TIERED_COMMANDS]
394
+ if other_names:
395
+ with formatter.section("All other commands"):
396
+ # Wrap the names at ~60 chars per line for readability
397
+ lines = _wrap_names(other_names, width=60)
398
+ for line in lines:
399
+ formatter.write(f" {line}\n")
400
+
401
+ formatter.write(
402
+ "\n Run 'tweek <command> --help' for details on any command.\n"
403
+ " Run 'tweek --help-all' for the full command list.\n"
404
+ )
405
+
406
+ # -- --help-all output (full categorised) ---------------------------------
407
+
408
+ _FULL_TIERS = {
409
+ **COMMAND_TIERS,
410
+ "Diagnostics": [
411
+ "logs",
412
+ "feedback",
413
+ "override",
414
+ ],
415
+ "Infrastructure": [
416
+ "vault",
417
+ "proxy",
418
+ "mcp",
419
+ "plugins",
420
+ "skills",
421
+ "dry-run",
422
+ "memory",
423
+ "model",
424
+ ],
425
+ "Lifecycle": [
426
+ "install",
427
+ "uninstall",
428
+ "unprotect",
429
+ "license",
430
+ ],
431
+ }
432
+
433
+ def _format_all_commands(self, ctx, formatter):
434
+ """Render every command grouped into categories."""
435
+ commands = self._sorted_commands(ctx)
436
+ if not commands:
437
+ return
438
+ cmd_map = {name: cmd for name, cmd in commands}
439
+ shown = set()
440
+
441
+ for tier_name, tier_cmds in self._FULL_TIERS.items():
442
+ rows = []
443
+ for name in tier_cmds:
444
+ cmd = cmd_map.get(name)
445
+ if cmd is None:
446
+ continue
447
+ rows.append((name, cmd.get_short_help_str(limit=150)))
448
+ shown.add(name)
449
+ if rows:
450
+ with formatter.section(tier_name):
451
+ formatter.write_dl(rows)
452
+
453
+ # Catch-all for anything not in _FULL_TIERS (future-proofing)
454
+ leftover = [(n, c.get_short_help_str(limit=150))
455
+ for n, c in commands if n not in shown]
456
+ if leftover:
457
+ with formatter.section("Other"):
458
+ formatter.write_dl(leftover)
459
+
460
+ # -- helpers --------------------------------------------------------------
461
+
462
+ def _sorted_commands(self, ctx):
463
+ """Return (name, command) pairs sorted alphabetically, skipping hidden."""
464
+ source = self.list_commands(ctx)
465
+ pairs = []
466
+ for name in source:
467
+ cmd = self.get_command(ctx, name)
468
+ if cmd is None or cmd.hidden:
469
+ continue
470
+ pairs.append((name, cmd))
471
+ return pairs
472
+
473
+
474
+ def _wrap_names(names, width=60):
475
+ """Wrap a list of command names into lines of roughly *width* chars."""
476
+ lines = []
477
+ current = ""
478
+ for name in names:
479
+ candidate = f"{current}, {name}" if current else name
480
+ if len(candidate) > width and current:
481
+ lines.append(current)
482
+ current = name
483
+ else:
484
+ current = candidate
485
+ if current:
486
+ lines.append(current)
487
+ return lines
488
+
489
+
490
+ def _load_overrides_yaml() -> tuple:
491
+ """Load ~/.tweek/overrides.yaml. Returns (data_dict, file_path)."""
492
+ import yaml
493
+
494
+ overrides_path = Path("~/.tweek/overrides.yaml").expanduser()
495
+ if overrides_path.exists():
496
+ with open(overrides_path) as f:
497
+ data = yaml.safe_load(f) or {}
498
+ else:
499
+ data = {}
500
+ return data, overrides_path
501
+
502
+
503
+ def _save_overrides_yaml(data: dict, overrides_path: Path):
504
+ """Write data to ~/.tweek/overrides.yaml."""
505
+ import yaml
506
+
507
+ overrides_path.parent.mkdir(parents=True, exist_ok=True)
508
+ with open(overrides_path, "w") as f:
509
+ yaml.dump(data, f, default_flow_style=False, sort_keys=False)