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.
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 +162 -68
  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.0.dist-info}/METADATA +8 -7
  55. {tweek-0.3.1.dist-info → tweek-0.4.0.dist-info}/RECORD +60 -38
  56. tweek/mcp/server.py +0 -320
  57. {tweek-0.3.1.dist-info → tweek-0.4.0.dist-info}/WHEEL +0 -0
  58. {tweek-0.3.1.dist-info → tweek-0.4.0.dist-info}/entry_points.txt +0 -0
  59. {tweek-0.3.1.dist-info → tweek-0.4.0.dist-info}/licenses/LICENSE +0 -0
  60. {tweek-0.3.1.dist-info → tweek-0.4.0.dist-info}/licenses/NOTICE +0 -0
  61. {tweek-0.3.1.dist-info → tweek-0.4.0.dist-info}/top_level.txt +0 -0
tweek/cli_config.py ADDED
@@ -0,0 +1,643 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tweek CLI Config Management
4
+
5
+ Commands for configuring Tweek security policies:
6
+ tweek config list List all tools and skills with security tiers
7
+ tweek config set Set security tier for a skill or tool
8
+ tweek config preset Apply a configuration preset
9
+ tweek config reset Reset configuration to defaults
10
+ tweek config validate Validate configuration for errors
11
+ tweek config diff Show what would change if a preset were applied
12
+ tweek config llm Show LLM review configuration and provider status
13
+ tweek config edit Open config files in your editor
14
+ tweek config show-defaults View bundled default configuration
15
+ """
16
+
17
+ import click
18
+ from rich.panel import Panel
19
+ from rich.table import Table
20
+
21
+ from tweek.cli_helpers import console, TWEEK_BANNER
22
+
23
+
24
+ @click.group()
25
+ def config():
26
+ """Configure Tweek security policies."""
27
+ pass
28
+
29
+
30
+ @config.command("list",
31
+ epilog="""\b
32
+ Examples:
33
+ tweek config list List all tools and skills
34
+ tweek config list --tools Show only tool security tiers
35
+ tweek config list --skills Show only skill security tiers
36
+ tweek config list --summary Show tier counts and overrides summary
37
+ """
38
+ )
39
+ @click.option("--tools", "show_tools", is_flag=True, help="Show tools only")
40
+ @click.option("--skills", "show_skills", is_flag=True, help="Show skills only")
41
+ @click.option("--summary", is_flag=True, help="Show configuration summary instead of full list")
42
+ def config_list(show_tools: bool, show_skills: bool, summary: bool):
43
+ """List all tools and skills with their security tiers."""
44
+ from tweek.config.manager import ConfigManager
45
+
46
+ cfg = ConfigManager()
47
+
48
+ # Handle summary mode
49
+ if summary:
50
+ # Count by tier
51
+ tool_tiers = {}
52
+ for tool in cfg.list_tools():
53
+ tier = tool.tier.value
54
+ tool_tiers[tier] = tool_tiers.get(tier, 0) + 1
55
+
56
+ skill_tiers = {}
57
+ for skill in cfg.list_skills():
58
+ tier = skill.tier.value
59
+ skill_tiers[tier] = skill_tiers.get(tier, 0) + 1
60
+
61
+ # User overrides
62
+ user_config = cfg.export_config("user")
63
+ user_tools = user_config.get("tools", {})
64
+ user_skills = user_config.get("skills", {})
65
+
66
+ summary_text = f"[cyan]Default Tier:[/cyan] {cfg.get_default_tier().value}\n\n"
67
+
68
+ summary_text += "[cyan]Tools by Tier:[/cyan]\n"
69
+ for tier in ["safe", "default", "risky", "dangerous"]:
70
+ count = tool_tiers.get(tier, 0)
71
+ if count:
72
+ summary_text += f" {tier}: {count}\n"
73
+
74
+ summary_text += "\n[cyan]Skills by Tier:[/cyan]\n"
75
+ for tier in ["safe", "default", "risky", "dangerous"]:
76
+ count = skill_tiers.get(tier, 0)
77
+ if count:
78
+ summary_text += f" {tier}: {count}\n"
79
+
80
+ if user_tools or user_skills:
81
+ summary_text += "\n[cyan]User Overrides:[/cyan]\n"
82
+ for tool_name, tier in user_tools.items():
83
+ summary_text += f" {tool_name}: {tier}\n"
84
+ for skill_name, tier in user_skills.items():
85
+ summary_text += f" {skill_name}: {tier}\n"
86
+ else:
87
+ summary_text += "\n[cyan]User Overrides:[/cyan] (none)"
88
+
89
+ console.print(Panel.fit(summary_text, title="Tweek Configuration"))
90
+ return
91
+
92
+ # Default to showing both if neither specified
93
+ if not show_tools and not show_skills:
94
+ show_tools = show_skills = True
95
+
96
+ tier_styles = {
97
+ "safe": "green",
98
+ "default": "blue",
99
+ "risky": "yellow",
100
+ "dangerous": "red",
101
+ }
102
+
103
+ source_styles = {
104
+ "default": "white",
105
+ "user": "cyan",
106
+ "project": "magenta",
107
+ }
108
+
109
+ if show_tools:
110
+ table = Table(title="Tool Security Tiers")
111
+ table.add_column("Tool", style="bold")
112
+ table.add_column("Tier")
113
+ table.add_column("Source", style="white")
114
+ table.add_column("Description")
115
+
116
+ for tool in cfg.list_tools():
117
+ tier_style = tier_styles.get(tool.tier.value, "white")
118
+ source_style = source_styles.get(tool.source, "white")
119
+ table.add_row(
120
+ tool.name,
121
+ f"[{tier_style}]{tool.tier.value}[/{tier_style}]",
122
+ f"[{source_style}]{tool.source}[/{source_style}]",
123
+ tool.description or ""
124
+ )
125
+
126
+ console.print(table)
127
+ console.print()
128
+
129
+ if show_skills:
130
+ table = Table(title="Skill Security Tiers")
131
+ table.add_column("Skill", style="bold")
132
+ table.add_column("Tier")
133
+ table.add_column("Source", style="white")
134
+ table.add_column("Description")
135
+
136
+ for skill in cfg.list_skills():
137
+ tier_style = tier_styles.get(skill.tier.value, "white")
138
+ source_style = source_styles.get(skill.source, "white")
139
+ table.add_row(
140
+ skill.name,
141
+ f"[{tier_style}]{skill.tier.value}[/{tier_style}]",
142
+ f"[{source_style}]{skill.source}[/{source_style}]",
143
+ skill.description or ""
144
+ )
145
+
146
+ console.print(table)
147
+
148
+ console.print("\n[white]Tiers: safe (no checks) \u2192 default (regex) \u2192 risky (+LLM) \u2192 dangerous (+sandbox)[/white]")
149
+ console.print("[white]Sources: default (built-in), user (~/.tweek/config.yaml), project (.tweek/config.yaml)[/white]")
150
+
151
+
152
+ @config.command("set",
153
+ epilog="""\b
154
+ Examples:
155
+ tweek config set --tool Bash --tier dangerous Mark Bash as dangerous
156
+ tweek config set --skill web-fetch --tier risky Set skill to risky tier
157
+ tweek config set --tier cautious Set default tier for all
158
+ tweek config set --tool Edit --tier safe --scope project Project-level override
159
+ """
160
+ )
161
+ @click.option("--skill", help="Skill name to configure")
162
+ @click.option("--tool", help="Tool name to configure")
163
+ @click.option("--tier", type=click.Choice(["safe", "default", "risky", "dangerous"]), required=True,
164
+ help="Security tier to set")
165
+ @click.option("--scope", type=click.Choice(["user", "project"]), default="user",
166
+ help="Config scope (user=global, project=this directory)")
167
+ def config_set(skill: str, tool: str, tier: str, scope: str):
168
+ """Set security tier for a skill or tool."""
169
+ from tweek.config.manager import ConfigManager, SecurityTier
170
+
171
+ cfg = ConfigManager()
172
+ tier_enum = SecurityTier.from_string(tier)
173
+
174
+ if skill:
175
+ cfg.set_skill_tier(skill, tier_enum, scope=scope)
176
+ console.print(f"[green]\u2713[/green] Set skill '{skill}' to [bold]{tier}[/bold] tier ({scope} config)")
177
+ elif tool:
178
+ cfg.set_tool_tier(tool, tier_enum, scope=scope)
179
+ console.print(f"[green]\u2713[/green] Set tool '{tool}' to [bold]{tier}[/bold] tier ({scope} config)")
180
+ else:
181
+ cfg.set_default_tier(tier_enum, scope=scope)
182
+ console.print(f"[green]\u2713[/green] Set default tier to [bold]{tier}[/bold] ({scope} config)")
183
+
184
+
185
+ @config.command("preset",
186
+ epilog="""\b
187
+ Examples:
188
+ tweek config preset paranoid Maximum security, prompt for everything
189
+ tweek config preset cautious Balanced security (recommended)
190
+ tweek config preset trusted Minimal prompts, trust AI decisions
191
+ tweek config preset paranoid --scope project Apply preset to project only
192
+ """
193
+ )
194
+ @click.argument("preset_name", type=click.Choice(["paranoid", "cautious", "balanced", "trusted"]))
195
+ @click.option("--scope", type=click.Choice(["user", "project"]), default="user")
196
+ def config_preset(preset_name: str, scope: str):
197
+ """Apply a configuration preset.
198
+
199
+ Presets:
200
+ paranoid - Maximum security, prompt for everything
201
+ cautious - Balanced security (recommended)
202
+ trusted - Minimal prompts, trust AI decisions
203
+ """
204
+ from tweek.config.manager import ConfigManager
205
+
206
+ cfg = ConfigManager()
207
+ cfg.apply_preset(preset_name, scope=scope)
208
+
209
+ console.print(f"[green]\u2713[/green] Applied [bold]{preset_name}[/bold] preset ({scope} config)")
210
+
211
+ if preset_name == "paranoid":
212
+ console.print("[white]All tools require screening, Bash commands always sandboxed[/white]")
213
+ elif preset_name == "cautious":
214
+ console.print("[white]Balanced: read-only tools safe, Bash dangerous[/white]")
215
+ elif preset_name == "trusted":
216
+ console.print("[white]Minimal prompts: only high-risk patterns trigger alerts[/white]")
217
+
218
+
219
+ @config.command("reset",
220
+ epilog="""\b
221
+ Examples:
222
+ tweek config reset --tool Bash Reset Bash to default tier
223
+ tweek config reset --skill web-fetch Reset a skill to default tier
224
+ tweek config reset --all Reset all user configuration
225
+ tweek config reset --all --confirm Reset all without confirmation prompt
226
+ """
227
+ )
228
+ @click.option("--skill", help="Reset specific skill to default")
229
+ @click.option("--tool", help="Reset specific tool to default")
230
+ @click.option("--all", "reset_all", is_flag=True, help="Reset all user configuration")
231
+ @click.option("--scope", type=click.Choice(["user", "project"]), default="user")
232
+ @click.option("--confirm", is_flag=True, help="Skip confirmation prompt")
233
+ def config_reset(skill: str, tool: str, reset_all: bool, scope: str, confirm: bool):
234
+ """Reset configuration to defaults."""
235
+ from tweek.config.manager import ConfigManager
236
+
237
+ cfg = ConfigManager()
238
+
239
+ if reset_all:
240
+ if not confirm and not click.confirm(f"Reset ALL {scope} configuration?"):
241
+ console.print("[white]Cancelled[/white]")
242
+ return
243
+ cfg.reset_all(scope=scope)
244
+ console.print(f"[green]\u2713[/green] Reset all {scope} configuration to defaults")
245
+ elif skill:
246
+ if cfg.reset_skill(skill, scope=scope):
247
+ console.print(f"[green]\u2713[/green] Reset skill '{skill}' to default")
248
+ else:
249
+ console.print(f"[yellow]![/yellow] Skill '{skill}' has no {scope} override")
250
+ elif tool:
251
+ if cfg.reset_tool(tool, scope=scope):
252
+ console.print(f"[green]\u2713[/green] Reset tool '{tool}' to default")
253
+ else:
254
+ console.print(f"[yellow]![/yellow] Tool '{tool}' has no {scope} override")
255
+ else:
256
+ console.print("[red]Specify --skill, --tool, or --all[/red]")
257
+
258
+
259
+ @config.command("validate",
260
+ epilog="""\b
261
+ Examples:
262
+ tweek config validate Validate merged configuration
263
+ tweek config validate --scope user Validate only user-level config
264
+ tweek config validate --scope project Validate only project-level config
265
+ tweek config validate --json Output validation results as JSON
266
+ """
267
+ )
268
+ @click.option("--scope", type=click.Choice(["user", "project", "merged"]), default="merged",
269
+ help="Which config scope to validate")
270
+ @click.option("--json-output", "--json", "json_out", is_flag=True, help="Output as JSON")
271
+ def config_validate(scope: str, json_out: bool):
272
+ """Validate configuration for errors and typos.
273
+
274
+ Checks for unknown keys, invalid tier values, unknown tool/skill names,
275
+ and suggests corrections for typos.
276
+ """
277
+ from tweek.config.manager import ConfigManager
278
+
279
+ cfg = ConfigManager()
280
+ issues = cfg.validate_config(scope=scope)
281
+
282
+ if json_out:
283
+ import json as json_mod
284
+ output = [
285
+ {
286
+ "level": i.level,
287
+ "key": i.key,
288
+ "message": i.message,
289
+ "suggestion": i.suggestion,
290
+ }
291
+ for i in issues
292
+ ]
293
+ console.print_json(json_mod.dumps(output, indent=2))
294
+ return
295
+
296
+ console.print()
297
+ console.print("[bold]Configuration Validation[/bold]")
298
+ console.print("\u2500" * 40)
299
+ console.print(f"[white]Scope: {scope}[/white]")
300
+ console.print()
301
+
302
+ if not issues:
303
+ tools = cfg.list_tools()
304
+ skills = cfg.list_skills()
305
+ console.print(f" [green]OK[/green] Configuration valid ({len(tools)} tools, {len(skills)} skills)")
306
+ console.print()
307
+ return
308
+
309
+ errors = [i for i in issues if i.level == "error"]
310
+ warnings = [i for i in issues if i.level == "warning"]
311
+
312
+ level_styles = {
313
+ "error": "[red]ERROR[/red]",
314
+ "warning": "[yellow]WARN[/yellow] ",
315
+ "info": "[white]INFO[/white] ",
316
+ }
317
+
318
+ for issue in issues:
319
+ style = level_styles.get(issue.level, "[white]???[/white] ")
320
+ msg = f" {style} {issue.key} \u2192 {issue.message}"
321
+ if issue.suggestion:
322
+ msg += f" {issue.suggestion}"
323
+ console.print(msg)
324
+
325
+ console.print()
326
+ parts = []
327
+ if errors:
328
+ parts.append(f"{len(errors)} error{'s' if len(errors) != 1 else ''}")
329
+ if warnings:
330
+ parts.append(f"{len(warnings)} warning{'s' if len(warnings) != 1 else ''}")
331
+ console.print(f" Result: {', '.join(parts)}")
332
+ console.print()
333
+
334
+
335
+ @config.command("diff",
336
+ epilog="""\b
337
+ Examples:
338
+ tweek config diff paranoid Show changes if paranoid preset applied
339
+ tweek config diff cautious Show changes if cautious preset applied
340
+ tweek config diff trusted Show changes if trusted preset applied
341
+ """
342
+ )
343
+ @click.argument("preset_name", type=click.Choice(["paranoid", "cautious", "balanced", "trusted"]))
344
+ def config_diff(preset_name: str):
345
+ """Show what would change if a preset were applied.
346
+
347
+ Compare your current configuration against a preset to see
348
+ exactly which settings would be modified.
349
+ """
350
+ from tweek.config.manager import ConfigManager
351
+
352
+ cfg = ConfigManager()
353
+
354
+ try:
355
+ changes = cfg.diff_preset(preset_name)
356
+ except ValueError as e:
357
+ console.print(f"[red]Error: {e}[/red]")
358
+ return
359
+
360
+ console.print()
361
+ console.print(f"[bold]Changes if '{preset_name}' preset is applied:[/bold]")
362
+ console.print("\u2500" * 50)
363
+
364
+ if not changes:
365
+ console.print()
366
+ console.print(" [green]No changes[/green] \u2014 your config already matches this preset.")
367
+ console.print()
368
+ return
369
+
370
+ table = Table(show_header=True, show_edge=False, pad_edge=False)
371
+ table.add_column("Setting", style="cyan", min_width=25)
372
+ table.add_column("Current", min_width=12)
373
+ table.add_column("", min_width=3)
374
+ table.add_column("New", min_width=12)
375
+
376
+ tier_colors = {"safe": "green", "default": "white", "risky": "yellow", "dangerous": "red"}
377
+
378
+ for change in changes:
379
+ cur_color = tier_colors.get(str(change.current_value), "white")
380
+ new_color = tier_colors.get(str(change.new_value), "white")
381
+ table.add_row(
382
+ change.key,
383
+ f"[{cur_color}]{change.current_value}[/{cur_color}]",
384
+ "\u2192",
385
+ f"[{new_color}]{change.new_value}[/{new_color}]",
386
+ )
387
+
388
+ console.print()
389
+ console.print(table)
390
+ console.print()
391
+ console.print(f" {len(changes)} change{'s' if len(changes) != 1 else ''} would be made. "
392
+ f"Apply with: [cyan]tweek config preset {preset_name}[/cyan]")
393
+ console.print()
394
+
395
+
396
+ @config.command("llm",
397
+ epilog="""\b
398
+ Examples:
399
+ tweek config llm Show current LLM provider status
400
+ tweek config llm --verbose Show detailed provider information
401
+ tweek config llm --validate Re-run local model validation suite
402
+ """
403
+ )
404
+ @click.option("--verbose", "-v", is_flag=True, help="Show detailed provider info")
405
+ @click.option("--validate", is_flag=True, help="Re-run local model validation suite")
406
+ def config_llm(verbose: bool, validate: bool):
407
+ """Show LLM review configuration and provider status.
408
+
409
+ Displays the current LLM review provider, model, and availability.
410
+ With --verbose, shows local server detection and fallback chain details.
411
+ With --validate, re-runs the validation suite against local models.
412
+ """
413
+ from tweek.security.llm_reviewer import (
414
+ get_llm_reviewer,
415
+ _detect_local_server,
416
+ _validate_local_model,
417
+ FallbackReviewProvider,
418
+ LOCAL_MODEL_PREFERENCES,
419
+ )
420
+
421
+ console.print()
422
+ console.print("[bold]LLM Review Configuration[/bold]")
423
+ console.print("\u2500" * 45)
424
+
425
+ reviewer = get_llm_reviewer()
426
+
427
+ if not reviewer.enabled:
428
+ console.print()
429
+ console.print(" [yellow]Status:[/yellow] Disabled (no provider available)")
430
+ console.print()
431
+ console.print(" [white]To enable, set one of:[/white]")
432
+ console.print(" ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY")
433
+ console.print(" Or install Ollama: [cyan]https://ollama.ai[/cyan]")
434
+ console.print()
435
+ return
436
+
437
+ console.print()
438
+ console.print(f" [green]Status:[/green] Enabled")
439
+ console.print(f" [cyan]Provider:[/cyan] {reviewer.provider_name}")
440
+ console.print(f" [cyan]Model:[/cyan] {reviewer.model}")
441
+
442
+ # Check for fallback chain
443
+ provider = reviewer._provider_instance
444
+ if isinstance(provider, FallbackReviewProvider):
445
+ console.print(f" [cyan]Chain:[/cyan] {provider.provider_count} providers in fallback chain")
446
+ if provider.active_provider:
447
+ console.print(f" [cyan]Active:[/cyan] {provider.active_provider.name}")
448
+
449
+ # Local server detection
450
+ if verbose:
451
+ console.print()
452
+ console.print("[bold]Local LLM Servers[/bold]")
453
+ console.print("\u2500" * 45)
454
+
455
+ try:
456
+ server = _detect_local_server()
457
+ if server:
458
+ console.print(f" [green]Detected:[/green] {server.server_type}")
459
+ console.print(f" [cyan]URL:[/cyan] {server.base_url}")
460
+ console.print(f" [cyan]Model:[/cyan] {server.model}")
461
+ console.print(f" [cyan]Available:[/cyan] {len(server.all_models)} model{'s' if len(server.all_models) != 1 else ''}")
462
+ if len(server.all_models) <= 10:
463
+ for m in server.all_models:
464
+ console.print(f" - {m}")
465
+ else:
466
+ console.print(" [white]No local LLM server detected[/white]")
467
+ console.print(" [white]Checked: Ollama (localhost:11434), LM Studio (localhost:1234)[/white]")
468
+ except Exception as e:
469
+ console.print(f" [yellow]Detection error: {e}[/yellow]")
470
+
471
+ console.print()
472
+ console.print("[bold]Recommended Local Models[/bold]")
473
+ console.print("\u2500" * 45)
474
+ for i, model_name in enumerate(LOCAL_MODEL_PREFERENCES[:5], 1):
475
+ console.print(f" {i}. {model_name}")
476
+
477
+ # Validation mode
478
+ if validate:
479
+ console.print()
480
+ console.print("[bold]Model Validation[/bold]")
481
+ console.print("\u2500" * 45)
482
+
483
+ try:
484
+ server = _detect_local_server()
485
+ if not server:
486
+ console.print(" [yellow]No local server detected. Nothing to validate.[/yellow]")
487
+ console.print()
488
+ return
489
+
490
+ from tweek.security.llm_reviewer import OpenAIReviewProvider
491
+ local_prov = OpenAIReviewProvider(
492
+ model=server.model,
493
+ api_key="not-needed",
494
+ timeout=10.0,
495
+ base_url=server.base_url,
496
+ )
497
+
498
+ console.print(f" Validating [cyan]{server.model}[/cyan] on {server.server_type}...")
499
+ passed, score = _validate_local_model(local_prov, server.model)
500
+
501
+ if passed:
502
+ console.print(f" [green]PASSED[/green] ({score:.0%})")
503
+ else:
504
+ console.print(f" [red]FAILED[/red] ({score:.0%}, minimum: 60%)")
505
+ console.print(" [white]This model may not reliably classify security threats.[/white]")
506
+ console.print(" [white]Try a larger model: ollama pull qwen2.5:7b-instruct[/white]")
507
+ except Exception as e:
508
+ console.print(f" [red]Validation error: {e}[/red]")
509
+
510
+ console.print()
511
+
512
+
513
+ @config.command("edit",
514
+ epilog="""\b
515
+ Examples:
516
+ tweek config edit Open interactive file selector
517
+ tweek config edit config Open security settings directly
518
+ tweek config edit env Open API keys file
519
+ tweek config edit overrides Open security overrides
520
+ tweek config edit hooks Open hook control file
521
+ tweek config edit --create Create missing files from templates first
522
+ """
523
+ )
524
+ @click.argument("file_id", required=False, default=None)
525
+ @click.option("--create", "create_missing", is_flag=True,
526
+ help="Create missing config files from templates")
527
+ def config_edit(file_id: str, create_missing: bool):
528
+ """Open Tweek configuration files in your editor.
529
+
530
+ Lists all config files with descriptions and status, then opens
531
+ the selected file in $VISUAL, $EDITOR, or a platform default.
532
+ """
533
+ import os
534
+ import shutil
535
+ import subprocess
536
+ from pathlib import Path
537
+ from tweek.config.templates import CONFIG_FILES, deploy_template, resolve_target_path
538
+
539
+ # Determine editor
540
+ editor = os.environ.get("VISUAL") or os.environ.get("EDITOR")
541
+ if not editor:
542
+ for candidate in ["nano", "vim", "vi"]:
543
+ if shutil.which(candidate):
544
+ editor = candidate
545
+ break
546
+ if not editor:
547
+ console.print("[red]No editor found. Set $EDITOR or $VISUAL.[/red]")
548
+ return
549
+
550
+ # Build file list with resolved paths and existence status
551
+ entries = []
552
+ for entry in CONFIG_FILES:
553
+ target = resolve_target_path(entry)
554
+ entries.append({**entry, "resolved_path": target, "exists": target.exists()})
555
+
556
+ # Direct access by ID
557
+ if file_id:
558
+ valid_ids = [e["id"] for e in entries]
559
+ if file_id not in valid_ids:
560
+ console.print(f"[red]Unknown file: {file_id}[/red]")
561
+ console.print(f"[white]Valid options: {', '.join(valid_ids)}[/white]")
562
+ return
563
+ selected = next(e for e in entries if e["id"] == file_id)
564
+ _open_config_file(selected, editor, create_missing)
565
+ return
566
+
567
+ # Interactive: show file list
568
+ console.print()
569
+ console.print("[bold]Tweek Configuration Files[/bold]")
570
+ console.print("\u2500" * 70)
571
+
572
+ for i, entry in enumerate(entries, 1):
573
+ if not entry["editable"]:
574
+ status = "[dim](read-only)[/dim]"
575
+ elif entry["exists"]:
576
+ status = "[green]\u2713 exists[/green]"
577
+ else:
578
+ status = "[yellow]\u2717 missing[/yellow]"
579
+
580
+ path_display = str(entry["resolved_path"]).replace(str(Path.home()), "~")
581
+ console.print(f" [cyan]{i}.[/cyan] {entry['name']:<22s} {path_display}")
582
+ console.print(f" {status} [dim]{entry['description']}[/dim]")
583
+
584
+ console.print()
585
+
586
+ choice = click.prompt(
587
+ f"Select file (1-{len(entries)})",
588
+ type=click.IntRange(1, len(entries)),
589
+ )
590
+
591
+ selected = entries[choice - 1]
592
+ _open_config_file(selected, editor, create_missing)
593
+
594
+
595
+ def _open_config_file(entry: dict, editor: str, create_missing: bool):
596
+ """Open a single config file in the user's editor."""
597
+ import os
598
+ import subprocess
599
+ from tweek.config.templates import deploy_template
600
+
601
+ target = entry["resolved_path"]
602
+
603
+ if not entry["editable"]:
604
+ pager = os.environ.get("PAGER", "less")
605
+ console.print(f"[white]Opening read-only reference: {target}[/white]")
606
+ subprocess.run([pager, str(target)])
607
+ return
608
+
609
+ if not target.exists():
610
+ if create_missing or click.confirm(
611
+ f" {entry['name']} does not exist. Create from template?", default=True
612
+ ):
613
+ if entry.get("template"):
614
+ deploy_template(entry["template"], target)
615
+ console.print(f"[green]\u2713[/green] Created {target} from template")
616
+ else:
617
+ console.print(f"[yellow]No template available for {entry['name']}[/yellow]")
618
+ return
619
+ else:
620
+ return
621
+
622
+ console.print(f"[white]Opening: {target}[/white]")
623
+ subprocess.run([editor, str(target)])
624
+
625
+
626
+ @config.command("show-defaults")
627
+ def config_show_defaults():
628
+ """Display the bundled default configuration.
629
+
630
+ Shows all available options with their default values from tiers.yaml.
631
+ This is read-only — to override, edit ~/.tweek/config.yaml.
632
+ """
633
+ import os
634
+ import subprocess
635
+ from pathlib import Path
636
+
637
+ defaults_path = Path(__file__).resolve().parent / "config" / "tiers.yaml"
638
+ if not defaults_path.exists():
639
+ console.print("[red]Default configuration not found[/red]")
640
+ return
641
+
642
+ pager = os.environ.get("PAGER", "less")
643
+ subprocess.run([pager, str(defaults_path)])