xenfra 0.2.3__py3-none-any.whl → 0.2.5__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.
xenfra/commands/auth.py CHANGED
@@ -13,6 +13,12 @@ import click
13
13
  import httpx
14
14
  import keyring
15
15
  from rich.console import Console
16
+ from tenacity import (
17
+ retry,
18
+ stop_after_attempt,
19
+ wait_exponential,
20
+ retry_if_exception_type,
21
+ )
16
22
 
17
23
  from ..utils.auth import (
18
24
  API_BASE_URL,
@@ -28,11 +34,49 @@ from ..utils.auth import (
28
34
 
29
35
  console = Console()
30
36
 
37
+ # HTTP request timeout (30 seconds)
38
+ HTTP_TIMEOUT = 30.0
39
+
40
+
41
+ @retry(
42
+ stop=stop_after_attempt(3),
43
+ wait=wait_exponential(multiplier=1, min=2, max=10),
44
+ retry=retry_if_exception_type((httpx.TimeoutException, httpx.NetworkError)),
45
+ reraise=True,
46
+ )
47
+ def _exchange_code_for_tokens_with_retry(
48
+ code: str, code_verifier: str, redirect_uri: str
49
+ ) -> dict:
50
+ """
51
+ Exchange authorization code for tokens with retry logic.
52
+
53
+ Returns token data dictionary.
54
+ """
55
+ with httpx.Client(timeout=HTTP_TIMEOUT) as client:
56
+ response = client.post(
57
+ f"{API_BASE_URL}/auth/token",
58
+ data={
59
+ "grant_type": "authorization_code",
60
+ "client_id": CLI_CLIENT_ID,
61
+ "code": code,
62
+ "code_verifier": code_verifier,
63
+ "redirect_uri": redirect_uri,
64
+ },
65
+ headers={"Accept": "application/json"},
66
+ )
67
+ response.raise_for_status()
68
+
69
+ # Safe JSON parsing with content-type check
70
+ content_type = response.headers.get("content-type", "")
71
+ if "application/json" not in content_type:
72
+ raise ValueError(f"Expected JSON response, got {content_type}")
73
+
74
+ try:
75
+ token_data = response.json()
76
+ except (ValueError, TypeError) as e:
77
+ raise ValueError(f"Failed to parse JSON response: {e}")
31
78
 
32
- @click.group()
33
- def auth():
34
- """Authentication commands."""
35
- pass
79
+ return token_data
36
80
 
37
81
 
38
82
  @auth.command()
@@ -55,101 +99,160 @@ def login():
55
99
  # 3. Start local HTTP server
56
100
  server_port = None
57
101
  httpd_instance = None
58
- for port in range(CLI_LOCAL_SERVER_START_PORT, CLI_LOCAL_SERVER_END_PORT + 1):
59
- try:
60
- server_address = ("127.0.0.1", port)
61
- httpd_instance = HTTPServer(server_address, AuthCallbackHandler)
62
- server_port = port
63
- break
64
- except OSError:
65
- continue
66
-
67
- if not server_port:
102
+ try:
103
+ for port in range(CLI_LOCAL_SERVER_START_PORT, CLI_LOCAL_SERVER_END_PORT + 1):
104
+ try:
105
+ server_address = ("127.0.0.1", port)
106
+ httpd_instance = HTTPServer(server_address, AuthCallbackHandler)
107
+ server_port = port
108
+ break
109
+ except OSError:
110
+ continue
111
+ except Exception as e:
112
+ console.print(f"[yellow]Warning: Failed to bind to port {port}: {e}[/yellow]")
113
+ continue
114
+
115
+ if not server_port:
116
+ console.print(
117
+ f"[bold red]Error: No available ports in range {CLI_LOCAL_SERVER_START_PORT}-{CLI_LOCAL_SERVER_END_PORT}[/bold red]"
118
+ )
119
+ return
120
+
121
+ redirect_uri = f"http://localhost:{server_port}{CLI_REDIRECT_PATH}"
122
+
123
+ # 4. Construct Authorization URL
124
+ auth_url = (
125
+ f"{API_BASE_URL}/auth/authorize?"
126
+ f"client_id={CLI_CLIENT_ID}&"
127
+ f"redirect_uri={urllib.parse.quote(redirect_uri)}&"
128
+ f"response_type=code&"
129
+ f"scope={urllib.parse.quote('openid profile')}&"
130
+ f"state={state}&"
131
+ f"code_challenge={code_challenge}&"
132
+ f"code_challenge_method=S256"
133
+ )
134
+
135
+ console.print("[bold blue]Opening browser for login...[/bold blue]")
68
136
  console.print(
69
- f"[bold red]Error: No available ports in range {CLI_LOCAL_SERVER_START_PORT}-{CLI_LOCAL_SERVER_END_PORT}[/bold red]"
137
+ f"[dim]If browser doesn't open, navigate to:[/dim]\n[link={auth_url}]{auth_url}[/link]"
70
138
  )
71
- return
72
139
 
73
- redirect_uri = f"http://localhost:{server_port}{CLI_REDIRECT_PATH}"
74
-
75
- # 4. Construct Authorization URL
76
- auth_url = (
77
- f"{API_BASE_URL}/auth/authorize?"
78
- f"client_id={CLI_CLIENT_ID}&"
79
- f"redirect_uri={urllib.parse.quote(redirect_uri)}&"
80
- f"response_type=code&"
81
- f"scope={urllib.parse.quote('openid profile')}&"
82
- f"state={state}&"
83
- f"code_challenge={code_challenge}&"
84
- f"code_challenge_method=S256"
85
- )
140
+ # Try to open browser, handle errors gracefully
141
+ try:
142
+ webbrowser.open(auth_url)
143
+ except Exception as e:
144
+ console.print(
145
+ f"[yellow]Warning: Could not open browser automatically: {e}[/yellow]"
146
+ )
147
+ console.print(f"[dim]Please open the URL manually: {auth_url}[/dim]")
86
148
 
87
- console.print("[bold blue]Opening browser for login...[/bold blue]")
88
- console.print(
89
- f"[dim]If browser doesn't open, navigate to:[/dim]\n[link={auth_url}]{auth_url}[/link]"
90
- )
91
- webbrowser.open(auth_url)
149
+ # 5. Run local server to capture redirect
150
+ try:
151
+ AuthCallbackHandler.server = httpd_instance # type: ignore
152
+ httpd_instance.handle_request() # type: ignore
153
+ console.print("[dim]Local OAuth server shut down.[/dim]")
154
+ except Exception as e:
155
+ console.print(f"[bold red]Error running OAuth server: {e}[/bold red]")
156
+ return
157
+ finally:
158
+ # Ensure server is closed
159
+ if httpd_instance:
160
+ try:
161
+ httpd_instance.server_close()
162
+ except Exception:
163
+ pass
92
164
 
93
- # 5. Run local server to capture redirect
94
- try:
95
- AuthCallbackHandler.server = httpd_instance # type: ignore
96
- httpd_instance.handle_request() # type: ignore
97
- console.print("[dim]Local OAuth server shut down.[/dim]")
98
- except Exception as e:
99
- console.print(f"[bold red]Error running OAuth server: {e}[/bold red]")
100
- if httpd_instance:
101
- httpd_instance.server_close()
102
- return
165
+ if oauth_data["error"]:
166
+ console.print(f"[bold red]Login failed: {oauth_data['error']}[/bold red]")
167
+ return
103
168
 
104
- if oauth_data["error"]:
105
- console.print(f"[bold red]Login failed: {oauth_data['error']}[/bold red]")
106
- return
169
+ if not oauth_data["code"]:
170
+ console.print("[bold red]Login failed: No authorization code received.[/bold red]")
171
+ return
107
172
 
108
- if not oauth_data["code"]:
109
- console.print("[bold red]Login failed: No authorization code received.[/bold red]")
110
- return
173
+ # 6. Verify state (CSRF protection)
174
+ if not oauth_data.get("state"):
175
+ console.print(
176
+ "[bold red]Login failed: State parameter missing in callback (possible CSRF attack)[/bold red]"
177
+ )
178
+ return
111
179
 
112
- # 6. Verify state
113
- if oauth_data["state"] != state:
114
- console.print("[bold red]Login failed: State mismatch (possible CSRF attack)[/bold red]")
115
- return
180
+ if oauth_data["state"] != state:
181
+ console.print(
182
+ "[bold red]Login failed: State mismatch (possible CSRF attack)[/bold red]"
183
+ )
184
+ return
116
185
 
117
- # 7. Exchange code for tokens
118
- console.print("[bold cyan]Exchanging authorization code for tokens...[/bold cyan]")
119
- try:
120
- with httpx.Client() as client:
121
- response = client.post(
122
- f"{API_BASE_URL}/auth/token",
123
- data={
124
- "grant_type": "authorization_code",
125
- "client_id": CLI_CLIENT_ID,
126
- "code": oauth_data["code"],
127
- "code_verifier": code_verifier,
128
- "redirect_uri": redirect_uri,
129
- },
186
+ # 7. Exchange code for tokens with retry logic
187
+ console.print("[bold cyan]Exchanging authorization code for tokens...[/bold cyan]")
188
+ try:
189
+ token_data = _exchange_code_for_tokens_with_retry(
190
+ oauth_data["code"], code_verifier, redirect_uri
130
191
  )
131
- response.raise_for_status()
132
- token_data = response.json()
192
+
133
193
  access_token = token_data.get("access_token")
134
194
  refresh_token = token_data.get("refresh_token")
135
195
 
136
196
  if access_token and refresh_token:
137
- keyring.set_password(SERVICE_ID, "access_token", access_token)
138
- keyring.set_password(SERVICE_ID, "refresh_token", refresh_token)
139
- console.print("[bold green]Login successful! Tokens saved securely.[/bold green]")
197
+ try:
198
+ keyring.set_password(SERVICE_ID, "access_token", access_token)
199
+ keyring.set_password(SERVICE_ID, "refresh_token", refresh_token)
200
+ console.print("[bold green]Login successful! Tokens saved securely.[/bold green]")
201
+ except keyring.errors.KeyringError as e:
202
+ console.print(
203
+ f"[bold red]Failed to save tokens to keyring: {e}[/bold red]"
204
+ )
205
+ console.print("[yellow]Tokens were received but not saved.[/yellow]")
140
206
  else:
141
207
  console.print("[bold red]Login failed: No tokens received.[/bold red]")
142
- except httpx.RequestError as exc:
143
- console.print(f"[bold red]Token exchange failed: {exc}[/bold red]")
144
- except httpx.HTTPStatusError as exc:
145
- console.print(f"[bold red]Token exchange failed: {exc.response.status_code}[/bold red]")
208
+
209
+ except httpx.TimeoutException:
210
+ console.print(
211
+ "[bold red]Token exchange failed: Request timed out. Please try again.[/bold red]"
212
+ )
213
+ except httpx.NetworkError as e:
214
+ console.print(
215
+ f"[bold red]Token exchange failed: Network error - {type(e).__name__}[/bold red]"
216
+ )
217
+ except httpx.HTTPStatusError as exc:
218
+ error_detail = "Unknown error"
219
+ try:
220
+ if exc.response.content:
221
+ content_type = exc.response.headers.get("content-type", "")
222
+ if "application/json" in content_type:
223
+ error_data = exc.response.json()
224
+ error_detail = error_data.get("detail", str(error_data))
225
+ except Exception:
226
+ error_detail = exc.response.text[:200] if exc.response.text else "Unknown error"
227
+
228
+ console.print(
229
+ f"[bold red]Token exchange failed: {exc.response.status_code} - {error_detail}[/bold red]"
230
+ )
231
+ except ValueError as e:
232
+ console.print(f"[bold red]Token exchange failed: {e}[/bold red]")
233
+ except Exception as e:
234
+ console.print(
235
+ f"[bold red]Token exchange failed: Unexpected error - {type(e).__name__}[/bold red]"
236
+ )
237
+
238
+ except Exception as e:
239
+ console.print(f"[bold red]Login failed: {type(e).__name__} - {e}[/bold red]")
240
+ if httpd_instance:
241
+ try:
242
+ httpd_instance.server_close()
243
+ except Exception:
244
+ pass
146
245
 
147
246
 
148
247
  @auth.command()
149
248
  def logout():
150
249
  """Logout and clear stored tokens."""
151
- clear_tokens()
152
- console.print("[bold green]Logged out successfully.[/bold green]")
250
+ try:
251
+ clear_tokens()
252
+ console.print("[bold green]Logged out successfully.[/bold green]")
253
+ except Exception as e:
254
+ console.print(f"[yellow]Warning: Error during logout: {e}[/yellow]")
255
+ console.print("[dim]Tokens may still be stored in keyring.[/dim]")
153
256
 
154
257
 
155
258
  @auth.command()
@@ -15,6 +15,13 @@ from xenfra_sdk.privacy import scrub_logs
15
15
  from ..utils.auth import API_BASE_URL, get_auth_token
16
16
  from ..utils.codebase import has_xenfra_config
17
17
  from ..utils.config import apply_patch
18
+ from ..utils.validation import (
19
+ validate_branch_name,
20
+ validate_deployment_id,
21
+ validate_framework,
22
+ validate_git_repo_url,
23
+ validate_project_name,
24
+ )
18
25
 
19
26
  console = Console()
20
27
 
@@ -147,8 +154,18 @@ def deploy(project_name, git_repo, branch, framework, no_heal):
147
154
  if not project_name:
148
155
  project_name = os.path.basename(os.getcwd())
149
156
 
150
- # Determine deployment source
157
+ # Validate project name
158
+ is_valid, error_msg = validate_project_name(project_name)
159
+ if not is_valid:
160
+ console.print(f"[bold red]Invalid project name: {error_msg}[/bold red]")
161
+ raise click.Abort()
162
+
163
+ # Validate git repo if provided
151
164
  if git_repo:
165
+ is_valid, error_msg = validate_git_repo_url(git_repo)
166
+ if not is_valid:
167
+ console.print(f"[bold red]Invalid git repository URL: {error_msg}[/bold red]")
168
+ raise click.Abort()
152
169
  console.print(f"[cyan]Deploying {project_name} from git repository...[/cyan]")
153
170
  else:
154
171
  console.print(f"[cyan]Deploying {project_name} from local directory...[/cyan]")
@@ -158,6 +175,19 @@ def deploy(project_name, git_repo, branch, framework, no_heal):
158
175
  console.print("[dim]Please use --git-repo for now.[/dim]")
159
176
  return
160
177
 
178
+ # Validate branch name
179
+ is_valid, error_msg = validate_branch_name(branch)
180
+ if not is_valid:
181
+ console.print(f"[bold red]Invalid branch name: {error_msg}[/bold red]")
182
+ raise click.Abort()
183
+
184
+ # Validate framework if provided
185
+ if framework:
186
+ is_valid, error_msg = validate_framework(framework)
187
+ if not is_valid:
188
+ console.print(f"[bold red]Invalid framework: {error_msg}[/bold red]")
189
+ raise click.Abort()
190
+
161
191
  # Retry loop for auto-healing
162
192
  attempt = 0
163
193
  deployment_id = None
@@ -288,6 +318,11 @@ def deploy(project_name, git_repo, branch, framework, no_heal):
288
318
  @click.option("--follow", "-f", is_flag=True, help="Follow log output (stream)")
289
319
  @click.option("--tail", type=int, help="Show last N lines")
290
320
  def logs(deployment_id, follow, tail):
321
+ # Validate deployment ID
322
+ is_valid, error_msg = validate_deployment_id(deployment_id)
323
+ if not is_valid:
324
+ console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
325
+ raise click.Abort()
291
326
  """
292
327
  Stream deployment logs.
293
328
 
@@ -357,6 +392,12 @@ def status(deployment_id, watch):
357
392
  console.print("[dim]Usage: xenfra status <deployment-id>[/dim]")
358
393
  return
359
394
 
395
+ # Validate deployment ID
396
+ is_valid, error_msg = validate_deployment_id(deployment_id)
397
+ if not is_valid:
398
+ console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
399
+ raise click.Abort()
400
+
360
401
  with get_client() as client:
361
402
  console.print(f"[cyan]Fetching status for deployment {deployment_id}...[/cyan]")
362
403
 
@@ -2,6 +2,7 @@
2
2
  AI-powered intelligence commands for Xenfra CLI.
3
3
  Includes smart initialization, deployment diagnosis, and codebase analysis.
4
4
  """
5
+
5
6
  import os
6
7
 
7
8
  import click
@@ -15,7 +16,13 @@ from xenfra_sdk.privacy import scrub_logs
15
16
 
16
17
  from ..utils.auth import API_BASE_URL, get_auth_token
17
18
  from ..utils.codebase import has_xenfra_config, scan_codebase
18
- from ..utils.config import apply_patch, generate_xenfra_yaml, manual_prompt_for_config, read_xenfra_yaml
19
+ from ..utils.config import (
20
+ apply_patch,
21
+ generate_xenfra_yaml,
22
+ manual_prompt_for_config,
23
+ read_xenfra_yaml,
24
+ )
25
+ from ..utils.validation import validate_deployment_id
19
26
 
20
27
  console = Console()
21
28
 
@@ -31,8 +38,8 @@ def get_client() -> XenfraClient:
31
38
 
32
39
 
33
40
  @click.command()
34
- @click.option('--manual', is_flag=True, help='Skip AI detection, use interactive mode')
35
- @click.option('--accept-all', is_flag=True, help='Accept AI suggestions without confirmation')
41
+ @click.option("--manual", is_flag=True, help="Skip AI detection, use interactive mode")
42
+ @click.option("--accept-all", is_flag=True, help="Accept AI suggestions without confirmation")
36
43
  def init(manual, accept_all):
37
44
  """
38
45
  Initialize Xenfra configuration (AI-powered by default).
@@ -51,7 +58,7 @@ def init(manual, accept_all):
51
58
  return
52
59
 
53
60
  # Check for XENFRA_NO_AI environment variable
54
- no_ai = os.environ.get('XENFRA_NO_AI', '0') == '1'
61
+ no_ai = os.environ.get("XENFRA_NO_AI", "0") == "1"
55
62
  if no_ai and not manual:
56
63
  console.print("[yellow]XENFRA_NO_AI is set. Using manual mode.[/yellow]")
57
64
  manual = True
@@ -60,9 +67,9 @@ def init(manual, accept_all):
60
67
  if manual:
61
68
  console.print("[cyan]Manual configuration mode[/cyan]\n")
62
69
  try:
63
- filename = manual_prompt_for_config()
64
- console.print(f"\n[bold green]✓ xenfra.yaml created successfully![/bold green]")
65
- console.print(f"[dim]Run 'xenfra deploy' to deploy your project.[/dim]")
70
+ manual_prompt_for_config()
71
+ console.print("\n[bold green]✓ xenfra.yaml created successfully![/bold green]")
72
+ console.print("[dim]Run 'xenfra deploy' to deploy your project.[/dim]")
66
73
  except KeyboardInterrupt:
67
74
  console.print("\n[dim]Cancelled.[/dim]")
68
75
  except Exception as e:
@@ -107,7 +114,7 @@ def init(manual, accept_all):
107
114
  choice = Prompt.ask(
108
115
  "\nWhich package manager do you want to use?",
109
116
  choices=[str(i) for i in range(1, len(analysis.detected_package_managers) + 1)],
110
- default="1"
117
+ default="1",
111
118
  )
112
119
 
113
120
  # Update selection based on user choice
@@ -115,7 +122,9 @@ def init(manual, accept_all):
115
122
  selected_package_manager = selected_option.manager
116
123
  selected_dependency_file = selected_option.file
117
124
 
118
- console.print(f"\n[green]Using {selected_package_manager} ({selected_dependency_file})[/green]\n")
125
+ console.print(
126
+ f"\n[green]Using {selected_package_manager} ({selected_dependency_file})[/green]\n"
127
+ )
119
128
 
120
129
  table = Table(show_header=False, box=None)
121
130
  table.add_column("Property", style="cyan")
@@ -146,9 +155,9 @@ def init(manual, accept_all):
146
155
  confirmed = Confirm.ask("\nCreate xenfra.yaml with this configuration?", default=True)
147
156
 
148
157
  if confirmed:
149
- filename = generate_xenfra_yaml(analysis)
150
- console.print(f"[bold green]xenfra.yaml created successfully![/bold green]")
151
- console.print(f"[dim]Run 'xenfra deploy' to deploy your project.[/dim]")
158
+ generate_xenfra_yaml(analysis)
159
+ console.print("[bold green]xenfra.yaml created successfully![/bold green]")
160
+ console.print("[dim]Run 'xenfra deploy' to deploy your project.[/dim]")
152
161
  else:
153
162
  console.print("[yellow]Configuration cancelled.[/yellow]")
154
163
 
@@ -163,9 +172,9 @@ def init(manual, accept_all):
163
172
 
164
173
 
165
174
  @click.command()
166
- @click.argument('deployment-id', required=False)
167
- @click.option('--apply', is_flag=True, help='Auto-apply suggested patch (with confirmation)')
168
- @click.option('--logs', type=click.File('r'), help='Diagnose from log file instead of deployment')
175
+ @click.argument("deployment-id", required=False)
176
+ @click.option("--apply", is_flag=True, help="Auto-apply suggested patch (with confirmation)")
177
+ @click.option("--logs", type=click.File("r"), help="Diagnose from log file instead of deployment")
169
178
  def diagnose(deployment_id, apply, logs):
170
179
  """
171
180
  Diagnose deployment failures using AI.
@@ -179,8 +188,14 @@ def diagnose(deployment_id, apply, logs):
179
188
  # Get logs
180
189
  if logs:
181
190
  log_content = logs.read()
182
- console.print(f"[cyan]Analyzing logs from file...[/cyan]")
191
+ console.print("[cyan]Analyzing logs from file...[/cyan]")
183
192
  elif deployment_id:
193
+ # Validate deployment ID
194
+ is_valid, error_msg = validate_deployment_id(deployment_id)
195
+ if not is_valid:
196
+ console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
197
+ return
198
+
184
199
  console.print(f"[cyan]Fetching logs for deployment {deployment_id}...[/cyan]")
185
200
  log_content = client.deployments.get_logs(deployment_id)
186
201
 
@@ -188,8 +203,12 @@ def diagnose(deployment_id, apply, logs):
188
203
  console.print("[yellow]No logs found for this deployment.[/yellow]")
189
204
  return
190
205
  else:
191
- console.print("[bold red]Please specify a deployment ID or use --logs <file>[/bold red]")
192
- console.print("[dim]Usage: xenfra diagnose <deployment-id> or xenfra diagnose --logs error.log[/dim]")
206
+ console.print(
207
+ "[bold red]Please specify a deployment ID or use --logs <file>[/bold red]"
208
+ )
209
+ console.print(
210
+ "[dim]Usage: xenfra diagnose <deployment-id> or xenfra diagnose --logs error.log[/dim]"
211
+ )
193
212
  return
194
213
 
195
214
  # Scrub sensitive data
@@ -200,27 +219,37 @@ def diagnose(deployment_id, apply, logs):
200
219
  dependency_file = None
201
220
  try:
202
221
  config = read_xenfra_yaml()
203
- package_manager = config.get('package_manager')
204
- dependency_file = config.get('dependency_file')
222
+ package_manager = config.get("package_manager")
223
+ dependency_file = config.get("dependency_file")
205
224
 
206
225
  if package_manager and dependency_file:
207
- console.print(f"[dim]Using context: {package_manager} ({dependency_file})[/dim]")
226
+ console.print(
227
+ f"[dim]Using context: {package_manager} ({dependency_file})[/dim]"
228
+ )
208
229
  except FileNotFoundError:
209
230
  # No config file - diagnosis will infer from logs
210
- console.print("[dim]No xenfra.yaml found - inferring package manager from logs[/dim]")
231
+ console.print(
232
+ "[dim]No xenfra.yaml found - inferring package manager from logs[/dim]"
233
+ )
211
234
 
212
235
  # Diagnose with context
213
236
  console.print("[cyan]Analyzing failure...[/cyan]")
214
237
  result = client.intelligence.diagnose(
215
- logs=scrubbed_logs,
216
- package_manager=package_manager,
217
- dependency_file=dependency_file
238
+ logs=scrubbed_logs, package_manager=package_manager, dependency_file=dependency_file
218
239
  )
219
240
 
220
241
  # Display diagnosis
221
242
  console.print("\n")
222
- console.print(Panel(result.diagnosis, title="[bold red]Diagnosis[/bold red]", border_style="red"))
223
- console.print(Panel(result.suggestion, title="[bold yellow]Suggestion[/bold yellow]", border_style="yellow"))
243
+ console.print(
244
+ Panel(result.diagnosis, title="[bold red]Diagnosis[/bold red]", border_style="red")
245
+ )
246
+ console.print(
247
+ Panel(
248
+ result.suggestion,
249
+ title="[bold yellow]Suggestion[/bold yellow]",
250
+ border_style="yellow",
251
+ )
252
+ )
224
253
 
225
254
  # Handle patch
226
255
  if result.patch and result.patch.file:
@@ -300,7 +329,9 @@ def analyze():
300
329
  if analysis.notes:
301
330
  console.print(f"\n[dim]Notes: {analysis.notes}[/dim]")
302
331
 
303
- console.print(f"\n[dim]Run 'xenfra init' to create xenfra.yaml with this configuration.[/dim]")
332
+ console.print(
333
+ "\n[dim]Run 'xenfra init' to create xenfra.yaml with this configuration.[/dim]"
334
+ )
304
335
 
305
336
  except XenfraAPIError as e:
306
337
  console.print(f"[bold red]API Error: {e.detail}[/bold red]")