codefreedom 0.1.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.
@@ -0,0 +1,325 @@
1
+ """Top-level CLI entry point — parses args and dispatches to subcommands.
2
+
3
+ Entry point: codefreedom | cf
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import shutil
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ _CODEFREEDOM_DIR = Path.home() / ".codefreedom"
14
+
15
+
16
+ def _find_project_root() -> Path:
17
+ """Find the project root directory (where profiles.examples/ and litellm.examples/ live)."""
18
+ return Path(__file__).resolve().parent.parent.parent.parent
19
+
20
+
21
+ def _find_bundled_examples() -> Path:
22
+ """Find the bundled examples directory inside the installed package."""
23
+ return Path(__file__).resolve().parent.parent / "examples"
24
+
25
+
26
+ def _init_codefreedom(
27
+ force: bool = False,
28
+ project_root: Path | None = None,
29
+ cf_dir: Path | None = None,
30
+ ) -> int:
31
+ """Initialize ~/.codefreedom/ with default profiles and proxy configs.
32
+
33
+ Copies from the project's examples directories (profiles.examples/
34
+ and litellm.examples/) or from the bundled package examples into
35
+ ~/.codefreedom/.
36
+
37
+ Args:
38
+ force: Overwrite existing files.
39
+ project_root: Override the project root (for testing).
40
+ cf_dir: Override the ~/.codefreedom directory (for testing).
41
+ """
42
+ if project_root is None:
43
+ project_root = _find_project_root()
44
+ if cf_dir is None:
45
+ cf_dir = _CODEFREEDOM_DIR
46
+
47
+ bundled = _find_bundled_examples()
48
+
49
+ profiles_src = project_root / "profiles.examples" / "claude-code-profiles.json"
50
+ schema_src = project_root / "profiles.examples" / "claude-code-profiles.schema.json"
51
+ proxy_src = project_root / "litellm.examples"
52
+
53
+ # Fall back to bundled examples if project-root sources don't exist
54
+ if not profiles_src.exists():
55
+ profiles_src = bundled / "profiles" / "claude-code-profiles.json"
56
+ if not schema_src.exists():
57
+ schema_src = bundled / "profiles" / "claude-code-profiles.schema.json"
58
+ if not proxy_src.exists():
59
+ proxy_src = bundled / "proxy"
60
+
61
+ profiles_dst_dir = cf_dir / "profiles"
62
+ profiles_dst = profiles_dst_dir / "claude-code.json"
63
+ schema_dst = profiles_dst_dir / "claude-code-profiles.schema.json"
64
+ proxy_dst = cf_dir / "proxy"
65
+
66
+ created_any = False
67
+ skipped_any = False
68
+
69
+ # ── Profiles ───────────────────────────────────────────────────────────
70
+ if not force and profiles_dst.exists():
71
+ print(f"[init] Profiles already exist: {profiles_dst}")
72
+ print(" Use --init --force to overwrite.")
73
+ skipped_any = True
74
+ elif profiles_src.exists():
75
+ profiles_dst_dir.mkdir(parents=True, exist_ok=True)
76
+ shutil.copy2(profiles_src, profiles_dst)
77
+ print(f"[init] ✓ Created {profiles_dst}")
78
+ created_any = True
79
+ else:
80
+ print(f"[init] ✖ Profiles example not found: {profiles_src}")
81
+ print(" Make sure profiles.examples/claude-code-profiles.json exists.")
82
+
83
+ # ── Schema ─────────────────────────────────────────────────────────────
84
+ if not force and schema_dst.exists():
85
+ print(f"[init] Schema already exists: {schema_dst}")
86
+ skipped_any = True
87
+ elif schema_src.exists():
88
+ profiles_dst_dir.mkdir(parents=True, exist_ok=True)
89
+ shutil.copy2(schema_src, schema_dst)
90
+ print(f"[init] ✓ Created {schema_dst}")
91
+ created_any = True
92
+ else:
93
+ print(f"[init] ✖ Schema example not found: {schema_src}")
94
+ print(
95
+ " Make sure profiles.examples/claude-code-profiles.schema.json exists."
96
+ )
97
+
98
+ # ── Proxy configs (litellm) ─────────────────────────────────────────────
99
+ if not force and proxy_dst.exists():
100
+ print(f"[init] Proxy configs already exist: {proxy_dst}")
101
+ print(" Use --init --force to overwrite.")
102
+ skipped_any = True
103
+ elif proxy_src.exists():
104
+ if proxy_dst.exists() and force:
105
+ shutil.rmtree(proxy_dst)
106
+
107
+ # Source layout (litellm.examples/ or bundled proxy/):
108
+ # config.yaml → proxy/config/config.yaml
109
+ # docker-compose.yml → proxy/docker-compose.yml
110
+ # providers/ → proxy/config/providers/
111
+ proxy_config_dir = proxy_dst / "config"
112
+ proxy_config_dir.mkdir(parents=True, exist_ok=True)
113
+
114
+ # Copy config.yaml into config/ subdirectory
115
+ src_config = proxy_src / "config.yaml"
116
+ if src_config.exists():
117
+ shutil.copy2(src_config, proxy_config_dir / "config.yaml")
118
+
119
+ # Copy docker-compose.yml to proxy root
120
+ src_compose = proxy_src / "docker-compose.yml"
121
+ if src_compose.exists():
122
+ shutil.copy2(src_compose, proxy_dst / "docker-compose.yml")
123
+
124
+ # Copy providers into config/providers/
125
+ src_providers = proxy_src / "providers"
126
+ if src_providers.exists():
127
+ dst_providers = proxy_config_dir / "providers"
128
+ if dst_providers.exists() and force:
129
+ shutil.rmtree(dst_providers)
130
+ shutil.copytree(src_providers, dst_providers, dirs_exist_ok=True)
131
+
132
+ print(f"[init] ✓ Created {proxy_dst}")
133
+ created_any = True
134
+ else:
135
+ print(f"[init] ✖ Proxy examples not found: {proxy_src}")
136
+ print(" Make sure litellm.examples/ exists.")
137
+
138
+ # ── Environment files (.env / .env.secrets) ────────────────────────────
139
+ # Copy fully-commented templates from project root or bundled examples.
140
+ # These are only created if the destination file doesn't already exist
141
+ # (never overwritten — user edits them manually).
142
+ env_src = project_root / ".env.example"
143
+ secrets_src = project_root / ".env.secrets.example"
144
+
145
+ if not env_src.exists():
146
+ env_src = bundled / ".env.example"
147
+ if not secrets_src.exists():
148
+ secrets_src = bundled / ".env.secrets.example"
149
+
150
+ env_dst = cf_dir / ".env"
151
+ secrets_dst = cf_dir / ".env.secrets"
152
+
153
+ if env_dst.exists():
154
+ print(f"[init] .env already exists: {env_dst} (skipping — edit it manually)")
155
+ skipped_any = True
156
+ elif env_src.exists():
157
+ cf_dir.mkdir(parents=True, exist_ok=True)
158
+ shutil.copy2(env_src, env_dst)
159
+ print(
160
+ f"[init] ✓ Created {env_dst} (fully commented — uncomment variables you need)"
161
+ )
162
+ created_any = True
163
+ else:
164
+ print(f"[init] ✖ .env.example not found: {env_src}")
165
+
166
+ # .env.secrets is optional — only copy if source exists and dest doesn't
167
+ if secrets_dst.exists():
168
+ print(f"[init] .env.secrets already exists: {secrets_dst} (skipping)")
169
+ elif secrets_src.exists():
170
+ cf_dir.mkdir(parents=True, exist_ok=True)
171
+ shutil.copy2(secrets_src, secrets_dst)
172
+ print(f"[init] ✓ Created {secrets_dst} (fully commented — add your API keys)")
173
+ created_any = True
174
+
175
+ if created_any:
176
+ print()
177
+ print("[init] CodeFreedom is initialized!")
178
+ print(f" Profiles: {profiles_dst_dir}")
179
+ print(f" - claude-code.json")
180
+ print(f" - claude-code-profiles.schema.json")
181
+ print(f" Proxy: {proxy_dst}")
182
+ print(f" Env: {cf_dir}")
183
+ print(f" - .env (fully commented)")
184
+ print(f" - .env.secrets (fully commented)")
185
+ print(" Edit these files to customize your setup.")
186
+ elif skipped_any:
187
+ print()
188
+ print("[init] Nothing to do — all files already exist.")
189
+ else:
190
+ print()
191
+ print("[init] No source files found to copy.")
192
+
193
+ return 0
194
+
195
+
196
+ def main() -> None:
197
+ """Top-level CLI entry point: codefreedom | cf."""
198
+ parser = argparse.ArgumentParser(
199
+ prog="codefreedom",
200
+ description="CodeFreedom — Claude Code launcher and LiteLLM proxy management.",
201
+ )
202
+ parser.add_argument(
203
+ "--init",
204
+ action="store_true",
205
+ help="Initialize ~/.codefreedom/ with default profiles and proxy configs",
206
+ )
207
+ parser.add_argument(
208
+ "--force",
209
+ action="store_true",
210
+ help="Force overwrite existing configs (use with --init)",
211
+ )
212
+ subparsers = parser.add_subparsers(dest="command", title="commands")
213
+
214
+ # ── claude subcommand ──────────────────────────────────────────────────
215
+ claude_parser = subparsers.add_parser(
216
+ "claude",
217
+ aliases=["cc"],
218
+ help="Launch Claude Code with profile-based model routing",
219
+ description="Run Claude Code natively (default) or in a sandboxed Docker container.",
220
+ )
221
+ claude_parser.add_argument(
222
+ "--sandbox",
223
+ action="store_true",
224
+ help="Run Claude Code inside a sandboxed Docker container (default: native)",
225
+ )
226
+ claude_parser.add_argument(
227
+ "--native-models",
228
+ action="store_true",
229
+ help="Use native Anthropic models/auth (/login) — strips ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN",
230
+ )
231
+ claude_parser.add_argument(
232
+ "--profile",
233
+ type=str,
234
+ default="default",
235
+ metavar="NAME",
236
+ help="Load a named profile (default: 'default')",
237
+ )
238
+ claude_parser.add_argument(
239
+ "--stop",
240
+ action="store_true",
241
+ help="Stop and remove the persistent Docker container",
242
+ )
243
+ claude_parser.add_argument(
244
+ "--status",
245
+ action="store_true",
246
+ help="Show persistent container status and exit",
247
+ )
248
+ claude_parser.add_argument(
249
+ "--list-profiles",
250
+ action="store_true",
251
+ help="List available profiles and exit",
252
+ )
253
+ claude_parser.add_argument(
254
+ "claude_args",
255
+ nargs=argparse.REMAINDER,
256
+ help="Arguments forwarded to the 'claude' CLI",
257
+ )
258
+
259
+ # ── proxy subcommand ───────────────────────────────────────────────────
260
+ proxy_parser = subparsers.add_parser(
261
+ "proxy",
262
+ aliases=["px"],
263
+ help="Manage the LiteLLM proxy (start, stop, validate, status)",
264
+ description="Manage the LiteLLM proxy lifecycle.",
265
+ )
266
+ proxy_parser.add_argument(
267
+ "--up",
268
+ action="store_true",
269
+ help="Start the LiteLLM proxy (native by default; use --docker for Compose)",
270
+ )
271
+ proxy_parser.add_argument(
272
+ "--down",
273
+ action="store_true",
274
+ help="Stop the LiteLLM proxy",
275
+ )
276
+ proxy_parser.add_argument(
277
+ "--status",
278
+ action="store_true",
279
+ help="Show LiteLLM proxy status",
280
+ )
281
+ proxy_parser.add_argument(
282
+ "--validate",
283
+ action="store_true",
284
+ help="Validate the LiteLLM configuration",
285
+ )
286
+ proxy_parser.add_argument(
287
+ "--docker",
288
+ action="store_true",
289
+ help="Run via Docker Compose instead of native Python",
290
+ )
291
+ proxy_parser.add_argument(
292
+ "--port",
293
+ type=int,
294
+ default=4000,
295
+ help="Port for proxy (default: 4000)",
296
+ )
297
+ proxy_parser.add_argument(
298
+ "--host",
299
+ type=str,
300
+ default="0.0.0.0",
301
+ help="Bind host for proxy (default: 0.0.0.0)",
302
+ )
303
+
304
+ args = parser.parse_args()
305
+
306
+ # ── --init: bootstrap ~/.codefreedom/ ───────────────────────────────────
307
+ if args.init:
308
+ sys.exit(_init_codefreedom(force=args.force))
309
+
310
+ if args.command in ("claude", "cc"):
311
+ # Lazy import to keep CLI startup fast
312
+ from codefreedom.cli.claude import run as claude_run
313
+
314
+ sys.exit(claude_run(args))
315
+ elif args.command in ("proxy", "px"):
316
+ from codefreedom.cli.proxy import run as proxy_run
317
+
318
+ sys.exit(proxy_run(args))
319
+ else:
320
+ parser.print_help()
321
+ sys.exit(0)
322
+
323
+
324
+ if __name__ == "__main__":
325
+ main()
@@ -0,0 +1,332 @@
1
+ """Proxy subcommand — manage the LiteLLM proxy.
2
+
3
+ Usage:
4
+ codefreedom proxy --up Start the LiteLLM proxy (native, default)
5
+ codefreedom proxy --up --docker Start via Docker Compose
6
+ codefreedom proxy --down Stop the LiteLLM proxy
7
+ codefreedom proxy --status Show proxy status
8
+ codefreedom proxy --validate Validate LiteLLM configuration
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import List, Optional
19
+
20
+ from codefreedom.env_loader import eprint
21
+
22
+ # ── Path resolution ──────────────────────────────────────────────────────────
23
+
24
+ _CODEFREEDOM_DIR = Path.home() / ".codefreedom"
25
+
26
+
27
+ def _find_compose_file() -> Optional[Path]:
28
+ """Find the LiteLLM docker-compose file in ~/.codefreedom/proxy/."""
29
+ candidate = _CODEFREEDOM_DIR / "proxy" / "docker-compose.yml"
30
+ if candidate.exists():
31
+ return candidate
32
+ return None
33
+
34
+
35
+ def _find_config_file() -> Optional[Path]:
36
+ """Find the LiteLLM config.yaml in ~/.codefreedom/proxy/config/."""
37
+ candidate = _CODEFREEDOM_DIR / "proxy" / "config" / "config.yaml"
38
+ if candidate.exists():
39
+ return candidate
40
+ return None
41
+
42
+
43
+ # ── Entry point ──────────────────────────────────────────────────────────────
44
+
45
+
46
+ def run(args: argparse.Namespace) -> int:
47
+ """Execute the proxy subcommand. Returns exit code."""
48
+
49
+ if args.up:
50
+ return _start(args)
51
+ elif args.down:
52
+ return _stop()
53
+ elif args.status:
54
+ return _status()
55
+ elif args.validate:
56
+ return _validate()
57
+ else:
58
+ eprint(
59
+ "[proxy] No action specified. Use --up, --down, --status, or --validate."
60
+ )
61
+ return 1
62
+
63
+
64
+ # ── Start ────────────────────────────────────────────────────────────────────
65
+
66
+
67
+ def _start(args: argparse.Namespace) -> int:
68
+ """Start the LiteLLM proxy. Defaults to native; --docker uses Compose."""
69
+ if args.docker:
70
+ return _start_compose()
71
+ else:
72
+ return _start_native(args)
73
+
74
+
75
+ def _start_compose() -> int:
76
+ """Start LiteLLM via docker compose."""
77
+ compose_file = _find_compose_file()
78
+ if not compose_file:
79
+ eprint("[ERROR] Could not find ~/.codefreedom/proxy/docker-compose.yml")
80
+ eprint(" Run: codefreedom --init")
81
+ return 1
82
+
83
+ eprint(f"[proxy] Starting LiteLLM via Docker Compose ({compose_file})...")
84
+ result = subprocess.run(
85
+ [
86
+ "docker",
87
+ "compose",
88
+ "-f",
89
+ str(compose_file),
90
+ "--profile",
91
+ "litellm",
92
+ "up",
93
+ "-d",
94
+ ],
95
+ capture_output=False,
96
+ check=False,
97
+ )
98
+ if result.returncode == 0:
99
+ eprint("[proxy] ✓ Proxy started at http://localhost:4000")
100
+ else:
101
+ eprint("[proxy] ✖ Failed to start. Check docker logs.")
102
+ return result.returncode
103
+
104
+
105
+ def _start_native(args: argparse.Namespace) -> int:
106
+ """Start LiteLLM directly as a Python process."""
107
+ try:
108
+ __import__("litellm")
109
+ except ImportError:
110
+ eprint("[ERROR] litellm package not installed.")
111
+ eprint(" Install: pip install codefreedom[litellm]")
112
+ eprint(" This installs litellm with proxy extras (websockets, etc.).")
113
+ eprint(" Or run without --native to use Docker Compose.")
114
+ return 1
115
+
116
+ litellm_bin = shutil.which("litellm")
117
+ if not litellm_bin:
118
+ eprint("[ERROR] litellm CLI not found on PATH.")
119
+ eprint(" Ensure litellm is installed: pip install codefreedom[litellm]")
120
+ return 1
121
+
122
+ config_file = _find_config_file()
123
+ if not config_file:
124
+ eprint("[ERROR] Could not find ~/.codefreedom/proxy/config/config.yaml")
125
+ eprint(" Run: codefreedom --init")
126
+ return 1
127
+
128
+ port = args.port or 4000
129
+ host = args.host or "0.0.0.0"
130
+
131
+ eprint(f"[proxy] Starting natively on {host}:{port}...")
132
+ eprint(f"[proxy] Config: {config_file}")
133
+
134
+ cmd = [
135
+ litellm_bin,
136
+ "--config",
137
+ str(config_file),
138
+ "--port",
139
+ str(port),
140
+ "--host",
141
+ host,
142
+ ]
143
+
144
+ try:
145
+ proc = subprocess.Popen(cmd)
146
+ eprint(f"[proxy] ✓ Proxy starting (PID: {proc.pid})")
147
+ eprint("[proxy] Press Ctrl+C to stop.")
148
+ proc.wait()
149
+ return proc.returncode
150
+ except KeyboardInterrupt:
151
+ eprint("\n[proxy] Proxy stopped.")
152
+ return 0
153
+ except FileNotFoundError:
154
+ eprint("[ERROR] Could not find litellm executable.")
155
+ return 1
156
+
157
+
158
+ # ── Stop ─────────────────────────────────────────────────────────────────────
159
+
160
+
161
+ def _stop() -> int:
162
+ """Stop the LiteLLM proxy."""
163
+ compose_file = _find_compose_file()
164
+ if not compose_file:
165
+ eprint("[ERROR] Could not find ~/.codefreedom/proxy/docker-compose.yml")
166
+ eprint(" Run: codefreedom --init")
167
+ return 1
168
+
169
+ eprint("[proxy] Stopping LiteLLM proxy...")
170
+ result = subprocess.run(
171
+ ["docker", "compose", "-f", str(compose_file), "--profile", "litellm", "down"],
172
+ capture_output=False,
173
+ check=False,
174
+ )
175
+ if result.returncode == 0:
176
+ eprint("[proxy] ✓ Proxy stopped.")
177
+ return result.returncode
178
+
179
+
180
+ # ── Status ───────────────────────────────────────────────────────────────────
181
+
182
+
183
+ def _status() -> int:
184
+ """Show LiteLLM proxy status."""
185
+ compose_file = _find_compose_file()
186
+ if not compose_file:
187
+ eprint("[ERROR] Could not find ~/.codefreedom/proxy/docker-compose.yml")
188
+ eprint(" Run: codefreedom --init")
189
+ return 1
190
+
191
+ result = subprocess.run(
192
+ ["docker", "compose", "-f", str(compose_file), "--profile", "litellm", "ps"],
193
+ capture_output=False,
194
+ check=False,
195
+ )
196
+ return result.returncode
197
+
198
+
199
+ # ── Validate ─────────────────────────────────────────────────────────────────
200
+
201
+
202
+ def _validate() -> int:
203
+ """Validate the LiteLLM configuration."""
204
+ config_file = _find_config_file()
205
+ if not config_file:
206
+ eprint("[ERROR] Could not find ~/.codefreedom/proxy/config/config.yaml")
207
+ eprint(" Run: codefreedom --init")
208
+ return 1
209
+
210
+ errors: List[str] = []
211
+
212
+ eprint(f"[proxy] Validating {config_file}...")
213
+ eprint()
214
+
215
+ try:
216
+ import yaml
217
+ except ImportError:
218
+ eprint("[WARN] PyYAML not installed. Using basic validation only.")
219
+ eprint(" Install: pip install pyyaml")
220
+ _validate_basic(config_file, errors)
221
+ _print_validation_result(errors)
222
+ return 0 if not errors else 1
223
+
224
+ try:
225
+ with open(config_file, encoding="utf-8") as f:
226
+ config = yaml.safe_load(f)
227
+ except yaml.YAMLError as e:
228
+ eprint(f" ✖ YAML parse error: {e}")
229
+ return 1
230
+ except FileNotFoundError:
231
+ eprint(f" ✖ File not found: {config_file}")
232
+ return 1
233
+
234
+ if not isinstance(config, dict):
235
+ eprint(" ✖ Config must be a YAML dictionary.")
236
+ return 1
237
+
238
+ includes = config.get("include", [])
239
+ if not includes:
240
+ eprint(" ⚠ No provider includes found in config.yaml")
241
+ else:
242
+ config_dir = config_file.parent
243
+ for inc in includes:
244
+ provider_file = config_dir / inc
245
+ if provider_file.exists():
246
+ eprint(f" ✓ {inc}")
247
+ try:
248
+ with open(provider_file, encoding="utf-8") as f:
249
+ provider_config = yaml.safe_load(f)
250
+ models = provider_config.get("model_list", [])
251
+ for m in models:
252
+ name = m.get("model_name", "?")
253
+ params = m.get("litellm_params", {})
254
+ api_key_ref = params.get("api_key", "")
255
+ if api_key_ref.startswith("os.environ/"):
256
+ env_var = api_key_ref[len("os.environ/") :]
257
+ if not _env_is_set(env_var):
258
+ eprint(f" ⚠ {name}: env var {env_var} is not set")
259
+ else:
260
+ eprint(f" ✓ {name} (auth: {env_var} ✓)")
261
+ else:
262
+ eprint(f" ✓ {name}")
263
+ except yaml.YAMLError as e:
264
+ eprint(f" ✖ {inc}: YAML error — {e}")
265
+ errors.append(f"YAML error in {inc}: {e}")
266
+ else:
267
+ eprint(f" ✖ {inc} — file not found")
268
+ errors.append(f"Missing provider file: {inc}")
269
+
270
+ general = config.get("general_settings", {})
271
+ if not general.get("database_url"):
272
+ eprint(" ⚠ database_url not set (stateless mode)")
273
+
274
+ router = config.get("router_settings", {})
275
+ aliases = router.get("model_group_alias", {})
276
+ if aliases:
277
+ eprint(f" ✓ Model aliases: {len(aliases)} defined")
278
+ for alias, model in aliases.items():
279
+ eprint(f" {alias} → {model}")
280
+ else:
281
+ eprint(" ⚠ No model_group_alias defined")
282
+
283
+ eprint()
284
+ _print_validation_result(errors)
285
+ return 0 if not errors else 1
286
+
287
+
288
+ def _validate_basic(config_file: Path, errors: List[str]) -> None:
289
+ """Basic validation without PyYAML."""
290
+ content = config_file.read_text()
291
+ checks = [
292
+ ("include:", "provider includes"),
293
+ ("general_settings:", "general_settings section"),
294
+ ("router_settings:", "router_settings section"),
295
+ ("litellm_settings:", "litellm_settings section"),
296
+ ("model_group_alias:", "model aliases"),
297
+ ]
298
+ for marker, label in checks:
299
+ if marker in content:
300
+ eprint(f" ✓ {label} found")
301
+ else:
302
+ eprint(f" ✖ {label} missing")
303
+ errors.append(f"Missing: {label}")
304
+
305
+ config_dir = config_file.parent
306
+ for line in content.split("\n"):
307
+ line = line.strip()
308
+ if line.startswith("- providers/"):
309
+ provider_file = line[2:].strip()
310
+ full = config_dir / provider_file
311
+ if full.exists():
312
+ eprint(f" ✓ {provider_file}")
313
+ else:
314
+ eprint(f" ✖ {provider_file} — not found")
315
+ errors.append(f"Missing: {provider_file}")
316
+
317
+
318
+ def _env_is_set(var_name: str) -> bool:
319
+ """Check if an environment variable is set and non-empty."""
320
+ import os
321
+
322
+ return bool(os.environ.get(var_name))
323
+
324
+
325
+ def _print_validation_result(errors: List[str]) -> None:
326
+ """Print validation summary."""
327
+ if errors:
328
+ eprint(f" ✖ {len(errors)} issue(s) found.")
329
+ for e in errors:
330
+ eprint(f" - {e}")
331
+ else:
332
+ eprint(" ✓ Configuration looks good!")