xenfra 0.3.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.
@@ -0,0 +1,233 @@
1
+ """
2
+ Security configuration commands for Xenfra CLI.
3
+ """
4
+
5
+ import os
6
+
7
+ import click
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.table import Table
11
+
12
+ from ..utils.security import ALLOWED_DOMAINS, security_config, validate_and_get_api_url
13
+
14
+ console = Console()
15
+
16
+
17
+ @click.group(hidden=True)
18
+ def security():
19
+ """Security and configuration management (for debugging/advanced users)."""
20
+ pass
21
+
22
+
23
+ @security.command()
24
+ def check():
25
+ """Display current security configuration."""
26
+ # Get current API URL
27
+ try:
28
+ api_url = validate_and_get_api_url()
29
+ except click.Abort:
30
+ api_url = os.getenv("XENFRA_API_URL", "Not set")
31
+
32
+ # Display configuration
33
+ console.print("\n[bold cyan]🔒 Xenfra CLI Security Configuration[/bold cyan]\n")
34
+
35
+ # API URL
36
+ console.print(f"[bold]API URL:[/bold] {api_url}")
37
+
38
+ # Environment
39
+ env_color = "green" if security_config.is_production() else "yellow"
40
+ console.print(
41
+ f"[bold]Environment:[/bold] [{env_color}]{security_config.environment}[/{env_color}]"
42
+ )
43
+
44
+ console.print()
45
+
46
+ # Security features table
47
+ table = Table(title="Security Features", show_header=True)
48
+ table.add_column("Feature", style="cyan")
49
+ table.add_column("Status", style="white")
50
+ table.add_column("Description", style="dim")
51
+
52
+ features = [
53
+ (
54
+ "HTTPS Enforcement",
55
+ "✅ Enabled" if security_config.enforce_https else "⚠️ Disabled",
56
+ "Blocks HTTP connections (except localhost)",
57
+ ),
58
+ (
59
+ "Domain Whitelist",
60
+ "✅ Enforced" if security_config.enforce_whitelist else "⚠️ Warning Only",
61
+ "Restricts connections to approved domains",
62
+ ),
63
+ (
64
+ "HTTP Warning",
65
+ "✅ Enabled" if security_config.warn_on_http else "❌ Disabled",
66
+ "Warns when using insecure HTTP",
67
+ ),
68
+ (
69
+ "Certificate Pinning",
70
+ "✅ Enabled" if security_config.enable_cert_pinning else "❌ Disabled",
71
+ "Validates SSL certificate fingerprints",
72
+ ),
73
+ ]
74
+
75
+ for feature, status, description in features:
76
+ table.add_row(feature, status, description)
77
+
78
+ console.print(table)
79
+ console.print()
80
+
81
+ # Whitelisted domains
82
+ console.print("[bold]Whitelisted Domains:[/bold]")
83
+ for domain in ALLOWED_DOMAINS:
84
+ console.print(f" • {domain}")
85
+
86
+ console.print()
87
+
88
+ # Environment variables
89
+ console.print("[bold]Configuration via Environment Variables:[/bold]")
90
+ env_vars = [
91
+ ("XENFRA_ENV", os.getenv("XENFRA_ENV", "development")),
92
+ ("XENFRA_API_URL", os.getenv("XENFRA_API_URL", "http://localhost:8000")),
93
+ ("XENFRA_ENFORCE_HTTPS", os.getenv("XENFRA_ENFORCE_HTTPS", "auto")),
94
+ ("XENFRA_ENFORCE_WHITELIST", os.getenv("XENFRA_ENFORCE_WHITELIST", "auto")),
95
+ ("XENFRA_ENABLE_CERT_PINNING", os.getenv("XENFRA_ENABLE_CERT_PINNING", "auto")),
96
+ ("XENFRA_WARN_ON_HTTP", os.getenv("XENFRA_WARN_ON_HTTP", "true")),
97
+ ]
98
+
99
+ for var, value in env_vars:
100
+ console.print(f" {var}=[cyan]{value}[/cyan]")
101
+
102
+ console.print()
103
+
104
+ # Security recommendations
105
+ if not security_config.is_production():
106
+ console.print(
107
+ Panel(
108
+ "[yellow]⚠️ Development Mode Active[/yellow]\n\n"
109
+ "For production use:\n"
110
+ "1. Set XENFRA_ENV=production\n"
111
+ "2. Use HTTPS API URL\n"
112
+ "3. All security features will auto-enable",
113
+ title="Recommendation",
114
+ border_style="yellow",
115
+ )
116
+ )
117
+ else:
118
+ console.print(
119
+ Panel(
120
+ "[green]✅ Production Security Enabled[/green]\n\n"
121
+ "All security features are active.\n"
122
+ "Your credentials and data are protected.",
123
+ title="Status",
124
+ border_style="green",
125
+ )
126
+ )
127
+
128
+
129
+ @security.command()
130
+ @click.argument("url")
131
+ def validate(url):
132
+ """Validate an API URL against security policies."""
133
+ console.print(f"\n[cyan]Validating URL:[/cyan] {url}\n")
134
+
135
+ try:
136
+ validated_url = validate_and_get_api_url(url)
137
+ console.print("[bold green]✅ URL is valid and passed all security checks![/bold green]")
138
+ console.print(f"[dim]Validated URL: {validated_url}[/dim]")
139
+ except click.Abort:
140
+ console.print("[bold red]❌ URL failed security validation[/bold red]")
141
+ except Exception as e:
142
+ console.print(f"[bold red]❌ Validation error: {e}[/bold red]")
143
+
144
+
145
+ @security.command()
146
+ def docs():
147
+ """Show security documentation."""
148
+ docs_text = """
149
+ [bold cyan]Xenfra CLI Security Guide[/bold cyan]
150
+
151
+ [bold]Environment Detection:[/bold]
152
+ The CLI automatically adjusts security based on the environment:
153
+
154
+ • [green]production[/green]: All security features enforced
155
+ • [yellow]staging[/yellow]: HTTPS required, whitelist warnings
156
+ • [blue]development[/blue]: Permissive (localhost allowed)
157
+
158
+ [bold]Security Features:[/bold]
159
+
160
+ 1. [cyan]URL Validation[/cyan]
161
+ - Prevents malicious URL patterns
162
+ - Blocks URLs with embedded credentials
163
+ - Validates scheme (http/https only)
164
+
165
+ 2. [cyan]Domain Whitelist[/cyan]
166
+ - Restricts connections to approved domains
167
+ - Prevents credential theft via fake APIs
168
+ - Can be disabled for self-hosted instances
169
+
170
+ 3. [cyan]HTTPS Enforcement[/cyan]
171
+ - Requires encrypted connections in production
172
+ - Warns on insecure HTTP (non-localhost)
173
+ - Protects credentials and data in transit
174
+
175
+ 4. [cyan]Certificate Pinning[/cyan]
176
+ - Validates SSL certificate fingerprints
177
+ - Prevents man-in-the-middle attacks
178
+ - Optional (enabled in production by default)
179
+
180
+ [bold]Configuration Examples:[/bold]
181
+
182
+ [yellow]Development (default):[/yellow]
183
+ $ xenfra login
184
+ # Uses http://localhost:8000
185
+
186
+ [yellow]Self-hosted instance:[/yellow]
187
+ $ export XENFRA_API_URL=https://xenfra.mycompany.com
188
+ $ export XENFRA_ENFORCE_WHITELIST=false
189
+ $ xenfra login
190
+
191
+ [yellow]Production (strict):[/yellow]
192
+ $ export XENFRA_ENV=production
193
+ $ xenfra login
194
+ # All security features enabled
195
+
196
+ [bold]Environment Variables:[/bold]
197
+
198
+ XENFRA_ENV
199
+ Values: production | staging | development
200
+ Default: development
201
+
202
+ XENFRA_API_URL
203
+ Default: http://localhost:8000 (dev), https://api.xenfra.tech (prod)
204
+
205
+ XENFRA_ENFORCE_HTTPS
206
+ Values: true | false
207
+ Default: false (dev), true (prod)
208
+
209
+ XENFRA_ENFORCE_WHITELIST
210
+ Values: true | false
211
+ Default: false (dev), true (prod)
212
+
213
+ XENFRA_ENABLE_CERT_PINNING
214
+ Values: true | false
215
+ Default: false (dev), true (prod)
216
+
217
+ XENFRA_WARN_ON_HTTP
218
+ Values: true | false
219
+ Default: true
220
+
221
+ [bold]Security Best Practices:[/bold]
222
+
223
+ 1. Always use HTTPS in production
224
+ 2. Never disable security features without understanding risks
225
+ 3. Keep whitelisted domains list updated
226
+ 4. Rotate credentials if you suspect compromise
227
+ 5. Use environment-specific configurations
228
+ 6. Enable all features for production deployments
229
+
230
+ [dim]For more information: https://docs.xenfra.tech/security[/dim]
231
+ """
232
+
233
+ console.print(Panel(docs_text, border_style="cyan", padding=(1, 2)))
xenfra/main.py ADDED
@@ -0,0 +1,75 @@
1
+ """
2
+ Xenfra CLI - Main entry point.
3
+
4
+ A modern, AI-powered CLI for deploying Python apps to DigitalOcean.
5
+ """
6
+
7
+ import os
8
+
9
+ import click
10
+ from rich.console import Console
11
+
12
+ from .commands.auth import auth
13
+ from .commands.deployments import deploy, logs, report, status
14
+ from .commands.intelligence import analyze, diagnose, init
15
+ from .commands.projects import projects
16
+ from .commands.security_cmd import security
17
+
18
+ console = Console()
19
+
20
+ # Production-ready: API URL is hardcoded as https://api.xenfra.tech
21
+ # No configuration needed - works out of the box after pip install
22
+
23
+
24
+ @click.group()
25
+ @click.version_option(version="0.2.9")
26
+ def cli():
27
+ """
28
+ Xenfra CLI: Deploy Python apps to DigitalOcean with zero configuration.
29
+
30
+ Quick Start:
31
+ xenfra auth login # Authenticate with Xenfra
32
+ xenfra init # Initialize your project (AI-powered)
33
+ xenfra deploy # Deploy to DigitalOcean
34
+
35
+ Commands:
36
+ auth Authentication (login, logout, whoami)
37
+ projects Manage projects (list, show, delete)
38
+ init Smart project initialization (AI-powered)
39
+ diagnose Diagnose deployment failures (AI-powered)
40
+ analyze Analyze codebase without creating config
41
+
42
+ For help on a specific command:
43
+ xenfra <command> --help
44
+ """
45
+ # Configure keyring backend
46
+ os.environ["KEYRING_BACKEND"] = "keyrings.alt.file.PlaintextKeyring"
47
+
48
+ # Security works silently in the background
49
+ # Only shows warnings if there's an actual security issue
50
+
51
+
52
+ # Register command groups
53
+ cli.add_command(auth)
54
+ cli.add_command(projects)
55
+ cli.add_command(security)
56
+
57
+ # Register intelligence commands at root level
58
+ cli.add_command(init)
59
+ cli.add_command(diagnose)
60
+ cli.add_command(analyze)
61
+
62
+ # Register deployment commands at root level
63
+ cli.add_command(deploy)
64
+ cli.add_command(status)
65
+ cli.add_command(logs)
66
+ cli.add_command(report)
67
+
68
+
69
+ def main():
70
+ """Main entry point."""
71
+ cli()
72
+
73
+
74
+ if __name__ == "__main__":
75
+ main()
@@ -0,0 +1,3 @@
1
+ """
2
+ Utility functions for Xenfra CLI.
3
+ """
xenfra/utils/auth.py ADDED
@@ -0,0 +1,243 @@
1
+ """
2
+ Authentication utilities for Xenfra CLI.
3
+ Handles OAuth2 PKCE flow and token management.
4
+ """
5
+
6
+ from http.server import BaseHTTPRequestHandler, HTTPServer
7
+ from urllib.parse import parse_qs, urlparse
8
+
9
+ import httpx
10
+ import keyring
11
+ from rich.console import Console
12
+ from tenacity import (
13
+ retry,
14
+ retry_if_exception_type,
15
+ stop_after_attempt,
16
+ wait_exponential,
17
+ )
18
+
19
+ from .security import validate_and_get_api_url
20
+
21
+ console = Console()
22
+
23
+ # Get validated API URL (includes all security checks)
24
+ API_BASE_URL = validate_and_get_api_url()
25
+ SERVICE_ID = "xenfra"
26
+
27
+ # CLI OAuth2 Configuration
28
+ CLI_CLIENT_ID = "xenfra-cli"
29
+ CLI_REDIRECT_PATH = "/auth/callback"
30
+ CLI_LOCAL_SERVER_START_PORT = 8001
31
+ CLI_LOCAL_SERVER_END_PORT = 8005
32
+
33
+ # HTTP request timeout (30 seconds)
34
+ HTTP_TIMEOUT = 30.0
35
+
36
+ # Global storage for OAuth callback data
37
+ oauth_data = {"code": None, "state": None, "error": None}
38
+
39
+
40
+ class AuthCallbackHandler(BaseHTTPRequestHandler):
41
+ """HTTP handler for OAuth redirect callback."""
42
+
43
+ def do_GET(self):
44
+ global oauth_data
45
+ self.send_response(200)
46
+ self.send_header("Content-type", "text/html")
47
+ self.end_headers()
48
+
49
+ query_params = parse_qs(urlparse(self.path).query)
50
+
51
+ if "code" in query_params:
52
+ oauth_data["code"] = query_params["code"][0]
53
+ oauth_data["state"] = query_params["state"][0] if "state" in query_params else None
54
+ self.wfile.write(
55
+ b"<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>"
56
+ )
57
+ elif "error" in query_params:
58
+ oauth_data["error"] = query_params["error"][0]
59
+ self.wfile.write(
60
+ f"<html><body><h1>Authentication failed!</h1><p>Error: {oauth_data['error']}</p></body></html>".encode()
61
+ )
62
+ else:
63
+ self.wfile.write(
64
+ b"<html><body><h1>Authentication callback received.</h1><p>Waiting for code...</p></body></html>"
65
+ )
66
+
67
+ # Shut down the server after processing
68
+ self.server.shutdown() # type: ignore
69
+
70
+
71
+ def run_local_oauth_server(port: int, redirect_path: str):
72
+ """Start a local HTTP server to capture the OAuth redirect."""
73
+ server_address = ("127.0.0.1", port)
74
+ httpd = HTTPServer(server_address, AuthCallbackHandler)
75
+ httpd.timeout = 30 # seconds
76
+ console.print(
77
+ f"[dim]Listening for OAuth redirect on http://localhost:{port}{redirect_path}...[/dim]"
78
+ )
79
+
80
+ # Store the server instance in the handler for shutdown
81
+ AuthCallbackHandler.server = httpd # type: ignore
82
+
83
+ # Handle a single request (blocking call)
84
+ httpd.handle_request()
85
+ console.print("[dim]Local OAuth server shut down.[/dim]")
86
+
87
+
88
+ @retry(
89
+ stop=stop_after_attempt(3),
90
+ wait=wait_exponential(multiplier=1, min=2, max=10),
91
+ retry=retry_if_exception_type((httpx.TimeoutException, httpx.NetworkError)),
92
+ reraise=True,
93
+ )
94
+ def _refresh_token_with_retry(refresh_token: str) -> dict:
95
+ """
96
+ Refresh access token with retry logic.
97
+
98
+ Returns token data dictionary.
99
+ """
100
+ with httpx.Client(timeout=HTTP_TIMEOUT) as client:
101
+ response = client.post(
102
+ f"{API_BASE_URL}/auth/refresh",
103
+ data={"refresh_token": refresh_token, "client_id": CLI_CLIENT_ID},
104
+ headers={"Accept": "application/json"},
105
+ )
106
+ response.raise_for_status()
107
+
108
+ # Safe JSON parsing with content-type check
109
+ content_type = response.headers.get("content-type", "")
110
+ if "application/json" not in content_type:
111
+ raise ValueError(f"Expected JSON response, got {content_type}")
112
+
113
+ try:
114
+ token_data = response.json()
115
+ except (ValueError, TypeError) as e:
116
+ raise ValueError(f"Failed to parse JSON response: {e}")
117
+
118
+ return token_data
119
+
120
+
121
+ def get_auth_token() -> str | None:
122
+ """
123
+ Retrieve a valid access token, refreshing it if necessary.
124
+
125
+ Returns:
126
+ Valid access token or None if not authenticated
127
+ """
128
+ try:
129
+ access_token = keyring.get_password(SERVICE_ID, "access_token")
130
+ refresh_token = keyring.get_password(SERVICE_ID, "refresh_token")
131
+ except keyring.errors.KeyringError as e:
132
+ console.print(f"[yellow]Warning: Could not access keyring: {e}[/yellow]")
133
+ return None
134
+
135
+ if not access_token:
136
+ return None
137
+
138
+ # Check if access token is expired
139
+ # Manually decode JWT payload to check expiration without verifying signature
140
+ try:
141
+ import base64
142
+ import json
143
+
144
+ # JWT format: header.payload.signature
145
+ parts = access_token.split(".")
146
+ if len(parts) != 3:
147
+ claims = None
148
+ else:
149
+ # Decode payload (second part)
150
+ payload_b64 = parts[1]
151
+ # Add padding if needed
152
+ padding = 4 - len(payload_b64) % 4
153
+ if padding != 4:
154
+ payload_b64 += "=" * padding
155
+ payload_bytes = base64.urlsafe_b64decode(payload_b64)
156
+ claims = json.loads(payload_bytes)
157
+
158
+ # Check expiration manually
159
+ exp = claims.get("exp")
160
+ if exp:
161
+ import time
162
+ if time.time() >= exp:
163
+ claims = None # Token expired
164
+ except Exception:
165
+ claims = None
166
+
167
+ # Refresh token if expired
168
+ if not claims and refresh_token:
169
+ console.print("[dim]Access token expired. Attempting to refresh...[/dim]")
170
+ try:
171
+ token_data = _refresh_token_with_retry(refresh_token)
172
+ new_access_token = token_data.get("access_token")
173
+ new_refresh_token = token_data.get("refresh_token")
174
+
175
+ if new_access_token:
176
+ try:
177
+ keyring.set_password(SERVICE_ID, "access_token", new_access_token)
178
+ if new_refresh_token:
179
+ keyring.set_password(SERVICE_ID, "refresh_token", new_refresh_token)
180
+ console.print("[bold green]Token refreshed successfully.[/bold green]")
181
+ return new_access_token
182
+ except keyring.errors.KeyringError as e:
183
+ console.print(
184
+ f"[yellow]Warning: Could not save refreshed token to keyring: {e}[/yellow]"
185
+ )
186
+ # Return the token anyway, but warn user
187
+ return new_access_token
188
+ else:
189
+ console.print("[bold red]Failed to get new access token.[/bold red]")
190
+ return None
191
+
192
+ except httpx.TimeoutException:
193
+ console.print("[bold red]Token refresh failed: Request timed out.[/bold red]")
194
+ return None
195
+ except httpx.NetworkError:
196
+ console.print("[bold red]Token refresh failed: Network error.[/bold red]")
197
+ return None
198
+ except httpx.HTTPStatusError as exc:
199
+ if exc.response.status_code == 400:
200
+ console.print("[bold red]Refresh token expired. Please log in again.[/bold red]")
201
+ else:
202
+ error_detail = "Unknown error"
203
+ try:
204
+ if exc.response.content:
205
+ content_type = exc.response.headers.get("content-type", "")
206
+ if "application/json" in content_type:
207
+ error_data = exc.response.json()
208
+ error_detail = error_data.get("detail", str(error_data))
209
+ except Exception:
210
+ error_detail = exc.response.text[:200] if exc.response.text else "Unknown error"
211
+
212
+ console.print(
213
+ f"[bold red]Token refresh failed: {exc.response.status_code} - {error_detail}[/bold red]"
214
+ )
215
+
216
+ # Clear tokens on refresh failure
217
+ try:
218
+ keyring.delete_password(SERVICE_ID, "access_token")
219
+ keyring.delete_password(SERVICE_ID, "refresh_token")
220
+ except keyring.errors.KeyringError:
221
+ pass # Ignore errors when clearing
222
+ return None
223
+ except ValueError as e:
224
+ console.print(f"[bold red]Token refresh failed: {e}[/bold red]")
225
+ return None
226
+ except Exception as e:
227
+ console.print(
228
+ f"[bold red]Token refresh failed: Unexpected error - {type(e).__name__}[/bold red]"
229
+ )
230
+ return None
231
+
232
+ return access_token
233
+
234
+
235
+ def clear_tokens():
236
+ """Clear stored access and refresh tokens."""
237
+ try:
238
+ keyring.delete_password(SERVICE_ID, "access_token")
239
+ keyring.delete_password(SERVICE_ID, "refresh_token")
240
+ except keyring.errors.PasswordDeleteError:
241
+ pass # Tokens already cleared
242
+ except keyring.errors.KeyringError as e:
243
+ console.print(f"[yellow]Warning: Could not clear tokens from keyring: {e}[/yellow]")
@@ -0,0 +1,126 @@
1
+ """
2
+ Codebase scanning utilities for AI-powered project initialization.
3
+ """
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ def scan_codebase(max_files: int = 10, max_size: int = 50000) -> dict[str, str]:
10
+ """
11
+ Scan current directory for important code files.
12
+
13
+ Args:
14
+ max_files: Maximum number of files to include (validated: 1-100)
15
+ max_size: Maximum file size in bytes (validated: 1KB-10MB)
16
+
17
+ Returns:
18
+ Dictionary of filename -> content for AI analysis
19
+ """
20
+ # Validate limits
21
+ from .validation import validate_codebase_scan_limits
22
+
23
+ is_valid, error_msg = validate_codebase_scan_limits(max_files, max_size)
24
+ if not is_valid:
25
+ raise ValueError(f"Invalid scan limits: {error_msg}")
26
+
27
+ # Ensure limits are within bounds
28
+ max_files = max(1, min(100, max_files))
29
+ max_size = max(1024, min(10 * 1024 * 1024, max_size))
30
+ """
31
+ Scan current directory for important code files.
32
+
33
+ Args:
34
+ max_files: Maximum number of files to include
35
+ max_size: Maximum file size in bytes (default 50KB)
36
+
37
+ Returns:
38
+ Dictionary of filename -> content for AI analysis
39
+ """
40
+ code_snippets = {}
41
+
42
+ # Priority files to scan (in order)
43
+ important_files = [
44
+ # Python entry points
45
+ "main.py",
46
+ "app.py",
47
+ "wsgi.py",
48
+ "asgi.py",
49
+ "manage.py",
50
+ # Configuration files
51
+ "requirements.txt",
52
+ "pyproject.toml",
53
+ "Pipfile",
54
+ "setup.py",
55
+ # Django/Flask specific
56
+ "settings.py",
57
+ "config.py",
58
+ # Docker
59
+ "Dockerfile",
60
+ "docker-compose.yml",
61
+ # Xenfra config
62
+ "xenfra.yaml",
63
+ "xenfra.yml",
64
+ ]
65
+
66
+ # Scan for important files in current directory
67
+ for filename in important_files:
68
+ if len(code_snippets) >= max_files:
69
+ break
70
+
71
+ if os.path.exists(filename) and os.path.isfile(filename):
72
+ try:
73
+ file_size = os.path.getsize(filename)
74
+ if file_size > max_size:
75
+ continue
76
+
77
+ with open(filename, "r", encoding="utf-8") as f:
78
+ content = f.read(max_size)
79
+ code_snippets[filename] = content
80
+ except (IOError, OSError, PermissionError, UnicodeDecodeError) as e:
81
+ # Skip files that can't be read (log but don't crash)
82
+ import logging
83
+
84
+ logger = logging.getLogger(__name__)
85
+ logger.debug(f"Skipping file {filename}: {type(e).__name__}")
86
+ continue
87
+
88
+ # If we haven't found enough files, look for Python files in common locations
89
+ if len(code_snippets) < 3:
90
+ search_patterns = [
91
+ "src/**/*.py",
92
+ "app/**/*.py",
93
+ "*.py",
94
+ ]
95
+
96
+ for pattern in search_patterns:
97
+ if len(code_snippets) >= max_files:
98
+ break
99
+
100
+ for filepath in Path(".").glob(pattern):
101
+ if len(code_snippets) >= max_files:
102
+ break
103
+
104
+ if filepath.is_file() and filepath.name not in code_snippets:
105
+ try:
106
+ file_size = filepath.stat().st_size
107
+ if file_size > max_size:
108
+ continue
109
+
110
+ with open(filepath, "r", encoding="utf-8") as f:
111
+ content = f.read(max_size)
112
+ code_snippets[str(filepath)] = content
113
+ except (IOError, OSError, PermissionError, UnicodeDecodeError) as e:
114
+ # Skip files that can't be read
115
+ import logging
116
+
117
+ logger = logging.getLogger(__name__)
118
+ logger.debug(f"Skipping file {filepath}: {type(e).__name__}")
119
+ continue
120
+
121
+ return code_snippets
122
+
123
+
124
+ def has_xenfra_config() -> bool:
125
+ """Check if xenfra.yaml already exists."""
126
+ return os.path.exists("xenfra.yaml") or os.path.exists("xenfra.yml")