tweek 0.3.1__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.
- 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 +162 -68
- 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.0.dist-info}/METADATA +8 -7
- {tweek-0.3.1.dist-info → tweek-0.4.0.dist-info}/RECORD +60 -38
- tweek/mcp/server.py +0 -320
- {tweek-0.3.1.dist-info → tweek-0.4.0.dist-info}/WHEEL +0 -0
- {tweek-0.3.1.dist-info → tweek-0.4.0.dist-info}/entry_points.txt +0 -0
- {tweek-0.3.1.dist-info → tweek-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {tweek-0.3.1.dist-info → tweek-0.4.0.dist-info}/licenses/NOTICE +0 -0
- {tweek-0.3.1.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)
|