weco 0.2.19__py3-none-any.whl → 0.2.22__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.
weco/cli.py CHANGED
@@ -1,255 +1,19 @@
1
1
  import argparse
2
2
  import sys
3
3
  import pathlib
4
- import math
5
- import time
6
- import requests
7
- import webbrowser
8
- import threading
9
- import signal
10
- import traceback
11
4
  from rich.console import Console
12
- from rich.live import Live
13
- from rich.panel import Panel
14
5
  from rich.traceback import install
15
- from rich.prompt import Prompt
16
- from .api import (
17
- start_optimization_session,
18
- evaluate_feedback_then_suggest_next_solution,
19
- get_optimization_session_status,
20
- handle_api_error,
21
- send_heartbeat,
22
- report_termination,
23
- )
24
6
 
25
- from . import __base_url__
26
- from .auth import load_weco_api_key, save_api_key, clear_api_key
27
- from .panels import (
28
- SummaryPanel,
29
- PlanPanel,
30
- Node,
31
- MetricTreePanel,
32
- EvaluationOutputPanel,
33
- SolutionPanels,
34
- create_optimization_layout,
35
- create_end_optimization_layout,
36
- )
37
- from .utils import (
38
- read_api_keys_from_env,
39
- read_additional_instructions,
40
- read_from_path,
41
- write_to_path,
42
- run_evaluation,
43
- smooth_update,
44
- format_number,
45
- check_for_cli_updates,
46
- )
7
+ from .auth import clear_api_key
8
+ from .utils import check_for_cli_updates
47
9
 
48
10
  install(show_locals=True)
49
11
  console = Console()
50
12
 
51
- # --- Global variable for heartbeat thread ---
52
- heartbeat_thread = None
53
- stop_heartbeat_event = threading.Event()
54
- current_session_id_for_heartbeat = None
55
- current_auth_headers_for_heartbeat = {}
56
13
 
57
-
58
- # --- Heartbeat Sender Class ---
59
- class HeartbeatSender(threading.Thread):
60
- def __init__(self, session_id: str, auth_headers: dict, stop_event: threading.Event, interval: int = 30):
61
- super().__init__(daemon=True) # Daemon thread exits when main thread exits
62
- self.session_id = session_id
63
- self.auth_headers = auth_headers
64
- self.interval = interval
65
- self.stop_event = stop_event
66
-
67
- def run(self):
68
- try:
69
- while not self.stop_event.is_set():
70
- if not send_heartbeat(self.session_id, self.auth_headers):
71
- # send_heartbeat itself prints errors to stderr if it returns False
72
- # No explicit HeartbeatSender log needed here unless more detail is desired for a False return
73
- pass # Continue trying as per original logic
74
-
75
- if self.stop_event.is_set(): # Check before waiting for responsiveness
76
- break
77
-
78
- self.stop_event.wait(self.interval) # Wait for interval or stop signal
79
-
80
- except Exception as e:
81
- # Catch any unexpected error in the loop to prevent silent thread death
82
- print(
83
- f"[ERROR HeartbeatSender] Unhandled exception in run loop for session {self.session_id}: {e}", file=sys.stderr
84
- )
85
- traceback.print_exc(file=sys.stderr)
86
- # The loop will break due to the exception, and thread will terminate via finally.
87
-
88
-
89
- # --- Signal Handling ---
90
- def signal_handler(signum, frame):
91
- signal_name = signal.Signals(signum).name
92
- console.print(f"\n[bold yellow]Termination signal ({signal_name}) received. Shutting down...[/]")
93
-
94
- # Stop heartbeat thread
95
- stop_heartbeat_event.set()
96
- if heartbeat_thread and heartbeat_thread.is_alive():
97
- heartbeat_thread.join(timeout=2) # Give it a moment to stop
98
-
99
- # Report termination (best effort)
100
- if current_session_id_for_heartbeat:
101
- report_termination(
102
- session_id=current_session_id_for_heartbeat,
103
- status_update="terminated",
104
- reason=f"user_terminated_{signal_name.lower()}",
105
- details=f"Process terminated by signal {signal_name} ({signum}).",
106
- auth_headers=current_auth_headers_for_heartbeat,
107
- )
108
-
109
- # Exit gracefully
110
- sys.exit(0)
111
-
112
-
113
- def perform_login(console: Console):
114
- """Handles the device login flow."""
115
- try:
116
- # 1. Initiate device login
117
- console.print("Initiating login...")
118
- init_response = requests.post(f"{__base_url__}/auth/device/initiate")
119
- init_response.raise_for_status()
120
- init_data = init_response.json()
121
-
122
- device_code = init_data["device_code"]
123
- verification_uri = init_data["verification_uri"]
124
- expires_in = init_data["expires_in"]
125
- interval = init_data["interval"]
126
-
127
- # 2. Display instructions
128
- console.print("\n[bold yellow]Action Required:[/]")
129
- console.print("Please open the following URL in your browser to authenticate:")
130
- console.print(f"[link={verification_uri}]{verification_uri}[/link]")
131
- console.print(f"This request will expire in {expires_in // 60} minutes.")
132
- console.print("Attempting to open the authentication page in your default browser...") # Notify user
133
-
134
- # Automatically open the browser
135
- try:
136
- if not webbrowser.open(verification_uri):
137
- console.print("[yellow]Could not automatically open the browser. Please open the link manually.[/]")
138
- except Exception as browser_err:
139
- console.print(
140
- f"[yellow]Could not automatically open the browser ({browser_err}). Please open the link manually.[/]"
141
- )
142
-
143
- console.print("Waiting for authentication...", end="")
144
-
145
- # 3. Poll for token
146
- start_time = time.time()
147
- # Use a simple text update instead of Spinner within Live for potentially better compatibility
148
- polling_status = "Waiting..."
149
- with Live(polling_status, refresh_per_second=1, transient=True, console=console) as live_status:
150
- while True:
151
- # Check for timeout
152
- if time.time() - start_time > expires_in:
153
- console.print("\n[bold red]Error:[/] Login request timed out.")
154
- return False
155
-
156
- time.sleep(interval)
157
- live_status.update("Waiting... (checking status)")
158
-
159
- try:
160
- token_response = requests.post(
161
- f"{__base_url__}/auth/device/token", # REMOVED /v1 prefix
162
- json={"grant_type": "urn:ietf:params:oauth:grant-type:device_code", "device_code": device_code},
163
- )
164
-
165
- # Check for 202 Accepted - Authorization Pending
166
- if token_response.status_code == 202:
167
- token_data = token_response.json()
168
- if token_data.get("error") == "authorization_pending":
169
- live_status.update("Waiting... (authorization pending)")
170
- continue # Continue polling
171
- else:
172
- # Unexpected 202 response format
173
- console.print(f"\n[bold red]Error:[/] Received unexpected 202 response: {token_data}")
174
- return False
175
-
176
- # Check for standard OAuth2 errors (often 400 Bad Request)
177
- elif token_response.status_code == 400:
178
- token_data = token_response.json()
179
- error_code = token_data.get("error", "unknown_error")
180
- # NOTE: Removed "authorization_pending" check from here
181
- if error_code == "slow_down":
182
- interval += 5 # Increase polling interval if instructed
183
- live_status.update(f"Waiting... (slowing down polling to {interval}s)")
184
- continue
185
- elif error_code == "expired_token":
186
- console.print("\n[bold red]Error:[/] Login request expired.")
187
- return False
188
- elif error_code == "access_denied":
189
- console.print("\n[bold red]Error:[/] Authorization denied by user.")
190
- return False
191
- else: # invalid_grant, etc.
192
- error_desc = token_data.get("error_description", "Unknown error during polling.")
193
- console.print(f"\n[bold red]Error:[/] {error_desc} ({error_code})")
194
- return False
195
-
196
- # Check for other non-200/non-202/non-400 HTTP errors
197
- token_response.raise_for_status()
198
-
199
- # If successful (200 OK and no 'error' field)
200
- token_data = token_response.json()
201
- if "access_token" in token_data:
202
- api_key = token_data["access_token"]
203
- save_api_key(api_key)
204
- console.print("\n[bold green]Login successful![/]")
205
- return True
206
- else:
207
- # Unexpected successful response format
208
- console.print("\n[bold red]Error:[/] Received unexpected response from server during polling.")
209
- print(token_data) # Log for debugging
210
- return False
211
-
212
- except requests.exceptions.RequestException as e:
213
- # Handle network errors during polling gracefully
214
- live_status.update("Waiting... (network error, retrying)")
215
- console.print(f"\n[bold yellow]Warning:[/] Network error during polling: {e}. Retrying...")
216
- # Optional: implement backoff strategy
217
- time.sleep(interval * 2) # Simple backoff
218
-
219
- except requests.exceptions.HTTPError as e: # Catch HTTPError specifically for handle_api_error
220
- handle_api_error(e, console)
221
- except requests.exceptions.RequestException as e: # Catch other request errors
222
- console.print(f"\n[bold red]Network Error:[/] {e}")
223
- return False
224
- except Exception as e:
225
- console.print(f"\n[bold red]An unexpected error occurred during login:[/] {e}")
226
- return False
227
-
228
-
229
- def main() -> None:
230
- """Main function for the Weco CLI."""
231
- # Setup signal handlers
232
- signal.signal(signal.SIGINT, signal_handler)
233
- signal.signal(signal.SIGTERM, signal_handler)
234
-
235
- # --- Perform Update Check ---
236
- # Import __pkg_version__ here to avoid circular import issues if it's also used in modules imported by cli.py
237
- from . import __pkg_version__
238
-
239
- check_for_cli_updates(__pkg_version__) # Call the imported function
240
-
241
- # --- Argument Parsing ---
242
- parser = argparse.ArgumentParser(
243
- description="[bold cyan]Weco CLI[/]", formatter_class=argparse.RawDescriptionHelpFormatter
244
- )
245
- # Add subparsers for commands like 'run' and 'logout'
246
- subparsers = parser.add_subparsers(dest="command", help="Available commands", required=True) # Make command required
247
-
248
- # --- Run Command ---
249
- run_parser = subparsers.add_parser(
250
- "run", help="Run code optimization", formatter_class=argparse.RawDescriptionHelpFormatter, allow_abbrev=False
251
- )
252
- # Add arguments specific to the 'run' command to the run_parser
14
+ # Function to define and return the run_parser (or configure it on a passed subparser object)
15
+ # This helps keep main() cleaner and centralizes run command arg definitions.
16
+ def configure_run_parser(run_parser: argparse.ArgumentParser) -> None:
253
17
  run_parser.add_argument(
254
18
  "-s",
255
19
  "--source",
@@ -285,7 +49,7 @@ def main() -> None:
285
49
  "--model",
286
50
  type=str,
287
51
  default=None,
288
- help="Model to use for optimization. Defaults to `o4-mini` when `OPENAI_API_KEY` is set, `claude-3-7-sonnet-20250219` when `ANTHROPIC_API_KEY` is set, and `gemini-2.5-pro-exp-03-25` when `GEMINI_API_KEY` is set. When multiple keys are set, the priority is `OPENAI_API_KEY` > `ANTHROPIC_API_KEY` > `GEMINI_API_KEY`.",
52
+ help="Model to use for optimization. Defaults to `o4-mini` when `OPENAI_API_KEY` is set, `claude-sonnet-4-0` when `ANTHROPIC_API_KEY` is set, and `gemini-2.5-pro` when `GEMINI_API_KEY` is set. When multiple keys are set, the priority is `OPENAI_API_KEY` > `ANTHROPIC_API_KEY` > `GEMINI_API_KEY`.",
289
53
  )
290
54
  run_parser.add_argument(
291
55
  "-l", "--log-dir", type=str, default=".runs", help="Directory to store logs and results. Defaults to `.runs`."
@@ -298,459 +62,139 @@ def main() -> None:
298
62
  help="Description of additional instruction or path to a file containing additional instructions. Defaults to None.",
299
63
  )
300
64
 
301
- # --- Logout Command ---
302
- _ = subparsers.add_parser("logout", help="Log out from Weco and clear saved API key.")
303
-
304
- args = parser.parse_args()
305
-
306
- # --- Handle Logout Command ---
307
- if args.command == "logout":
308
- clear_api_key()
309
- sys.exit(0)
310
-
311
- # --- Handle Run Command ---
312
- elif args.command == "run":
313
- global heartbeat_thread, current_session_id_for_heartbeat, current_auth_headers_for_heartbeat # Allow modification of globals
314
-
315
- session_id = None # Initialize session_id
316
- optimization_completed_normally = False # Flag for finally block
317
- # --- Check Authentication ---
318
- weco_api_key = load_weco_api_key()
319
- llm_api_keys = read_api_keys_from_env() # Read keys from client environment
320
-
321
- if not weco_api_key:
322
- login_choice = Prompt.ask(
323
- "Log in to Weco to save run history or use anonymously? ([bold]L[/]ogin / [bold]S[/]kip)",
324
- choices=["l", "s"],
325
- default="s",
326
- ).lower()
327
-
328
- if login_choice == "l":
329
- console.print("[cyan]Starting login process...[/]")
330
- if not perform_login(console):
331
- console.print("[bold red]Login process failed or was cancelled.[/]")
332
- sys.exit(1)
333
- weco_api_key = load_weco_api_key()
334
- if not weco_api_key:
335
- console.print("[bold red]Error: Login completed but failed to retrieve API key.[/]")
336
- sys.exit(1)
337
-
338
- elif login_choice == "s":
339
- console.print("[yellow]Proceeding anonymously. LLM API keys must be provided via environment variables.[/]")
340
- if not llm_api_keys:
341
- console.print(
342
- "[bold red]Error:[/] No LLM API keys found in environment (e.g., OPENAI_API_KEY). Cannot proceed anonymously."
343
- )
344
- sys.exit(1)
345
-
346
- # --- Prepare API Call Arguments ---
347
- auth_headers = {}
348
- if weco_api_key:
349
- auth_headers["Authorization"] = f"Bearer {weco_api_key}"
350
- current_auth_headers_for_heartbeat = auth_headers # Store for signal handler
351
-
352
- # --- Main Run Logic ---
353
- try:
354
- # --- Configuration Loading ---
355
- evaluation_command = args.eval_command
356
- metric_name = args.metric
357
- maximize = args.goal in ["maximize", "max"]
358
- steps = args.steps
359
- # Determine the model to use
360
- if args.model is None:
361
- if "OPENAI_API_KEY" in llm_api_keys:
362
- args.model = "o4-mini"
363
- elif "ANTHROPIC_API_KEY" in llm_api_keys:
364
- args.model = "claude-3-7-sonnet-20250219"
365
- elif "GEMINI_API_KEY" in llm_api_keys:
366
- args.model = "gemini-2.5-pro-exp-03-25"
367
- else:
368
- raise ValueError(
369
- "No LLM API keys found in environment. Please set one of the following: OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY."
370
- )
371
- code_generator_config = {"model": args.model}
372
- evaluator_config = {"model": args.model, "include_analysis": True}
373
- search_policy_config = {
374
- "num_drafts": max(1, math.ceil(0.15 * steps)),
375
- "debug_prob": 0.5,
376
- "max_debug_depth": max(1, math.ceil(0.1 * steps)),
377
- }
378
- # API request timeout
379
- timeout = 800
380
- # Read additional instructions
381
- additional_instructions = read_additional_instructions(additional_instructions=args.additional_instructions)
382
- # Read source code path
383
- source_fp = pathlib.Path(args.source)
384
- # Read source code content
385
- source_code = read_from_path(fp=source_fp, is_json=False)
386
65
 
387
- # --- Panel Initialization ---
388
- summary_panel = SummaryPanel(
389
- maximize=maximize, metric_name=metric_name, total_steps=steps, model=args.model, runs_dir=args.log_dir
390
- )
391
- plan_panel = PlanPanel()
392
- solution_panels = SolutionPanels(metric_name=metric_name, source_fp=source_fp)
393
- eval_output_panel = EvaluationOutputPanel()
394
- tree_panel = MetricTreePanel(maximize=maximize)
395
- layout = create_optimization_layout()
396
- end_optimization_layout = create_end_optimization_layout()
397
-
398
- # --- Start Optimization Session ---
399
- session_response = start_optimization_session(
400
- console=console,
401
- source_code=source_code,
402
- evaluation_command=evaluation_command,
403
- metric_name=metric_name,
404
- maximize=maximize,
405
- steps=steps,
406
- code_generator_config=code_generator_config,
407
- evaluator_config=evaluator_config,
408
- search_policy_config=search_policy_config,
409
- additional_instructions=additional_instructions,
410
- api_keys=llm_api_keys,
411
- auth_headers=auth_headers,
412
- timeout=timeout,
413
- )
414
- session_id = session_response["session_id"]
415
- current_session_id_for_heartbeat = session_id # Store for signal handler/finally
416
-
417
- # --- Start Heartbeat Thread ---
418
- stop_heartbeat_event.clear() # Ensure event is clear before starting
419
- heartbeat_thread = HeartbeatSender(session_id, auth_headers, stop_heartbeat_event)
420
- heartbeat_thread.start()
421
-
422
- # --- Live Update Loop ---
423
- refresh_rate = 4
424
- with Live(layout, refresh_per_second=refresh_rate, screen=True) as live:
425
- # Define the runs directory (.runs/<session-id>)
426
- runs_dir = pathlib.Path(args.log_dir) / session_id
427
- runs_dir.mkdir(parents=True, exist_ok=True)
428
-
429
- # Write the initial code string to the logs
430
- write_to_path(fp=runs_dir / f"step_0{source_fp.suffix}", content=session_response["code"])
431
-
432
- # Write the initial code string to the source file path
433
- write_to_path(fp=source_fp, content=session_response["code"])
434
-
435
- # Update the panels with the initial solution
436
- summary_panel.set_session_id(session_id=session_id) # Add session id now that we have it
437
- # Set the step of the progress bar
438
- summary_panel.set_step(step=0)
439
- # Update the token counts
440
- summary_panel.update_token_counts(usage=session_response["usage"])
441
- # Update the plan
442
- plan_panel.update(plan=session_response["plan"])
443
- # Build the metric tree
444
- tree_panel.build_metric_tree(
445
- nodes=[
446
- {
447
- "solution_id": session_response["solution_id"],
448
- "parent_id": None,
449
- "code": session_response["code"],
450
- "step": 0,
451
- "metric_value": None,
452
- "is_buggy": False,
453
- }
454
- ]
455
- )
456
- # Set the current solution as unevaluated since we haven't run the evaluation function and fed it back to the model yet
457
- tree_panel.set_unevaluated_node(node_id=session_response["solution_id"])
458
- # Update the solution panels with the initial solution and get the panel displays
459
- solution_panels.update(
460
- current_node=Node(
461
- id=session_response["solution_id"],
462
- parent_id=None,
463
- code=session_response["code"],
464
- metric=None,
465
- is_buggy=False,
466
- ),
467
- best_node=None,
468
- )
469
- current_solution_panel, best_solution_panel = solution_panels.get_display(current_step=0)
470
- # Update the live layout with the initial solution panels
471
- smooth_update(
472
- live=live,
473
- layout=layout,
474
- sections_to_update=[
475
- ("summary", summary_panel.get_display()),
476
- ("plan", plan_panel.get_display()),
477
- ("tree", tree_panel.get_display(is_done=False)),
478
- ("current_solution", current_solution_panel),
479
- ("best_solution", best_solution_panel),
480
- ("eval_output", eval_output_panel.get_display()),
481
- ],
482
- transition_delay=0.1,
483
- )
484
-
485
- # # Send initial heartbeat immediately after starting
486
- # send_heartbeat(session_id, auth_headers)
487
-
488
- # Run evaluation on the initial solution
489
- term_out = run_evaluation(eval_command=args.eval_command)
490
-
491
- # Update the evaluation output panel
492
- eval_output_panel.update(output=term_out)
493
- smooth_update(
494
- live=live,
495
- layout=layout,
496
- sections_to_update=[("eval_output", eval_output_panel.get_display())],
497
- transition_delay=0.1,
498
- )
499
-
500
- # Starting from step 1 to steps (inclusive) because the baseline solution is step 0, so we want to optimize for steps worth of steps
501
- for step in range(1, steps + 1):
502
- # Re-read instructions from the original source (file path or string) BEFORE each suggest call
503
- current_additional_instructions = read_additional_instructions(
504
- additional_instructions=args.additional_instructions
505
- )
506
-
507
- # Send feedback and get next suggestion
508
- eval_and_next_solution_response = evaluate_feedback_then_suggest_next_solution(
509
- session_id=session_id,
510
- execution_output=term_out,
511
- additional_instructions=current_additional_instructions, # Pass current instructions
512
- api_keys=llm_api_keys, # Pass client LLM keys
513
- auth_headers=auth_headers, # Pass Weco key if logged in
514
- timeout=timeout,
515
- )
516
-
517
- # Save next solution (.runs/<session-id>/step_<step>.<extension>)
518
- write_to_path(
519
- fp=runs_dir / f"step_{step}{source_fp.suffix}", content=eval_and_next_solution_response["code"]
520
- )
521
-
522
- # Write the next solution to the source file
523
- write_to_path(fp=source_fp, content=eval_and_next_solution_response["code"])
524
-
525
- # Get the optimization session status for
526
- # the best solution, its score, and the history to plot the tree
527
- status_response = get_optimization_session_status(
528
- session_id=session_id, include_history=True, timeout=timeout, auth_headers=auth_headers
529
- )
530
-
531
- # Update the step of the progress bar
532
- summary_panel.set_step(step=step)
533
- # Update the token counts
534
- summary_panel.update_token_counts(usage=eval_and_next_solution_response["usage"])
535
- # Update the plan
536
- plan_panel.update(plan=eval_and_next_solution_response["plan"])
537
- # Build the metric tree
538
- tree_panel.build_metric_tree(nodes=status_response["history"])
539
- # Set the current solution as unevaluated since we haven't run the evaluation function and fed it back to the model yet
540
- tree_panel.set_unevaluated_node(node_id=eval_and_next_solution_response["solution_id"])
541
-
542
- # Update the solution panels with the next solution and best solution (and score)
543
- # Figure out if we have a best solution so far
544
- if status_response["best_result"] is not None:
545
- best_solution_node = Node(
546
- id=status_response["best_result"]["solution_id"],
547
- parent_id=status_response["best_result"]["parent_id"],
548
- code=status_response["best_result"]["code"],
549
- metric=status_response["best_result"]["metric_value"],
550
- is_buggy=status_response["best_result"]["is_buggy"],
551
- )
552
- else:
553
- best_solution_node = None
554
-
555
- # Create a node for the current solution
556
- current_solution_node = None
557
- for node in status_response["history"]:
558
- if node["solution_id"] == eval_and_next_solution_response["solution_id"]:
559
- current_solution_node = Node(
560
- id=node["solution_id"],
561
- parent_id=node["parent_id"],
562
- code=node["code"],
563
- metric=node["metric_value"],
564
- is_buggy=node["is_buggy"],
565
- )
566
- if current_solution_node is None:
567
- raise ValueError("Current solution node not found in history")
568
- # Update the solution panels with the current and best solution
569
- solution_panels.update(current_node=current_solution_node, best_node=best_solution_node)
570
- current_solution_panel, best_solution_panel = solution_panels.get_display(current_step=step)
571
-
572
- # Clear evaluation output since we are running a evaluation on a new solution
573
- eval_output_panel.clear()
574
-
575
- # Update displays with smooth transitions
576
- smooth_update(
577
- live=live,
578
- layout=layout,
579
- sections_to_update=[
580
- ("summary", summary_panel.get_display()),
581
- ("plan", plan_panel.get_display()),
582
- ("tree", tree_panel.get_display(is_done=False)),
583
- ("current_solution", current_solution_panel),
584
- ("best_solution", best_solution_panel),
585
- ("eval_output", eval_output_panel.get_display()),
586
- ],
587
- transition_delay=0.08, # Slightly longer delay for more noticeable transitions
588
- )
589
-
590
- # Run evaluation on the current solution
591
- term_out = run_evaluation(eval_command=args.eval_command)
592
- eval_output_panel.update(output=term_out)
593
-
594
- # Update evaluation output with a smooth transition
595
- smooth_update(
596
- live=live,
597
- layout=layout,
598
- sections_to_update=[("eval_output", eval_output_panel.get_display())],
599
- transition_delay=0.1, # Slightly longer delay for evaluation results
600
- )
601
-
602
- # Re-read instructions from the original source (file path or string) BEFORE each suggest call
603
- current_additional_instructions = read_additional_instructions(
604
- additional_instructions=args.additional_instructions
605
- )
606
-
607
- # Final evaluation report
608
- eval_and_next_solution_response = evaluate_feedback_then_suggest_next_solution(
609
- session_id=session_id,
610
- execution_output=term_out,
611
- additional_instructions=current_additional_instructions,
612
- api_keys=llm_api_keys,
613
- timeout=timeout,
614
- auth_headers=auth_headers,
615
- )
616
-
617
- # Update the progress bar
618
- summary_panel.set_step(step=steps)
619
- # Update the token counts
620
- summary_panel.update_token_counts(usage=eval_and_next_solution_response["usage"])
621
- # No need to update the plan panel since we have finished the optimization
622
- # Get the optimization session status for
623
- # the best solution, its score, and the history to plot the tree
624
- status_response = get_optimization_session_status(
625
- session_id=session_id, include_history=True, timeout=timeout, auth_headers=auth_headers
626
- )
627
- # Build the metric tree
628
- tree_panel.build_metric_tree(nodes=status_response["history"])
629
- # No need to set any solution to unevaluated since we have finished the optimization
630
- # and all solutions have been evaluated
631
- # No neeed to update the current solution panel since we have finished the optimization
632
- # We only need to update the best solution panel
633
- # Figure out if we have a best solution so far
634
- if status_response["best_result"] is not None:
635
- best_solution_node = Node(
636
- id=status_response["best_result"]["solution_id"],
637
- parent_id=status_response["best_result"]["parent_id"],
638
- code=status_response["best_result"]["code"],
639
- metric=status_response["best_result"]["metric_value"],
640
- is_buggy=status_response["best_result"]["is_buggy"],
641
- )
642
- else:
643
- best_solution_node = None
644
- solution_panels.update(current_node=None, best_node=best_solution_node)
645
- _, best_solution_panel = solution_panels.get_display(current_step=steps)
646
-
647
- # Update the end optimization layout
648
- final_message = (
649
- f"{summary_panel.metric_name.capitalize()} {'maximized' if summary_panel.maximize else 'minimized'}! Best solution {summary_panel.metric_name.lower()} = [green]{status_response['best_result']['metric_value']}[/] 🏆"
650
- if best_solution_node is not None and best_solution_node.metric is not None
651
- else "[red] No valid solution found.[/]"
652
- )
653
- end_optimization_layout["summary"].update(summary_panel.get_display(final_message=final_message))
654
- end_optimization_layout["tree"].update(tree_panel.get_display(is_done=True))
655
- end_optimization_layout["best_solution"].update(best_solution_panel)
656
-
657
- # Save optimization results
658
- # If the best solution does not exist or is has not been measured at the end of the optimization
659
- # save the original solution as the best solution
660
- if best_solution_node is not None:
661
- best_solution_code = best_solution_node.code
662
- best_solution_score = best_solution_node.metric
663
- else:
664
- best_solution_code = None
665
- best_solution_score = None
666
-
667
- if best_solution_code is None or best_solution_score is None:
668
- best_solution_content = f"# Weco could not find a better solution\n\n{read_from_path(fp=runs_dir / f'step_0{source_fp.suffix}', is_json=False)}"
669
- else:
670
- # Format score for the comment
671
- best_score_str = (
672
- format_number(best_solution_score)
673
- if best_solution_score is not None and isinstance(best_solution_score, (int, float))
674
- else "N/A"
675
- )
676
- best_solution_content = (
677
- f"# Best solution from Weco with a score of {best_score_str}\n\n{best_solution_code}"
678
- )
679
-
680
- # Save best solution to .runs/<session-id>/best.<extension>
681
- write_to_path(fp=runs_dir / f"best{source_fp.suffix}", content=best_solution_content)
682
-
683
- # write the best solution to the source file
684
- write_to_path(fp=source_fp, content=best_solution_content)
66
+ def execute_run_command(args: argparse.Namespace) -> None:
67
+ """Execute the 'weco run' command with all its logic."""
68
+ from .optimizer import execute_optimization # Moved import inside
69
+
70
+ success = execute_optimization(
71
+ source=args.source,
72
+ eval_command=args.eval_command,
73
+ metric=args.metric,
74
+ goal=args.goal,
75
+ steps=args.steps,
76
+ model=args.model,
77
+ log_dir=args.log_dir,
78
+ additional_instructions=args.additional_instructions,
79
+ console=console,
80
+ )
81
+ exit_code = 0 if success else 1
82
+ sys.exit(exit_code)
685
83
 
686
- # Mark as completed normally for the finally block
687
- optimization_completed_normally = True
688
84
 
689
- console.print(end_optimization_layout)
85
+ def main() -> None:
86
+ """Main function for the Weco CLI."""
87
+ check_for_cli_updates()
690
88
 
691
- except Exception as e:
692
- # Catch errors during the main optimization loop or setup
693
- try:
694
- error_message = e.response.json()["detail"] # Try to get API error detail
695
- except Exception:
696
- error_message = str(e) # Otherwise, use the exception string
697
- console.print(Panel(f"[bold red]Error: {error_message}", title="[bold red]Optimization Error", border_style="red"))
698
- # Print traceback for debugging if needed (can be noisy)
699
- # console.print_exception(show_locals=False)
89
+ parser = argparse.ArgumentParser(
90
+ description="[bold cyan]Weco CLI[/]\nEnhance your code with AI-driven optimization.",
91
+ formatter_class=argparse.RawDescriptionHelpFormatter,
92
+ )
700
93
 
701
- # Ensure optimization_completed_normally is False
702
- optimization_completed_normally = False
94
+ # Add global model argument
95
+ parser.add_argument(
96
+ "-M",
97
+ "--model",
98
+ type=str,
99
+ default=None,
100
+ help="Model to use for optimization. Defaults to `o4-mini` when `OPENAI_API_KEY` is set, `claude-sonnet-4-0` when `ANTHROPIC_API_KEY` is set, and `gemini-2.5-pro` when `GEMINI_API_KEY` is set. When multiple keys are set, the priority is `OPENAI_API_KEY` > `ANTHROPIC_API_KEY` > `GEMINI_API_KEY`.",
101
+ )
703
102
 
704
- # Prepare details for termination report
705
- error_details = traceback.format_exc()
103
+ subparsers = parser.add_subparsers(
104
+ dest="command", help="Available commands"
105
+ ) # Removed required=True for now to handle chatbot case easily
706
106
 
707
- # Exit code will be handled by finally block or sys.exit below
708
- exit_code = 1 # Indicate error
709
- # No sys.exit here, let finally block run
107
+ # --- Run Command Parser Setup ---
108
+ run_parser = subparsers.add_parser(
109
+ "run", help="Run code optimization", formatter_class=argparse.RawDescriptionHelpFormatter, allow_abbrev=False
110
+ )
111
+ configure_run_parser(run_parser) # Use the helper to add arguments
710
112
 
711
- finally:
712
- # This block runs whether the try block completed normally or raised an exception
113
+ # --- Logout Command Parser Setup ---
114
+ _ = subparsers.add_parser("logout", help="Log out from Weco and clear saved API key.")
713
115
 
714
- # Stop heartbeat thread
715
- stop_heartbeat_event.set()
716
- if heartbeat_thread and heartbeat_thread.is_alive():
717
- heartbeat_thread.join(timeout=2) # Give it a moment to stop
116
+ # Check if we should run the chatbot
117
+ # This logic needs to be robust. If 'run' or 'logout' is present, or -h/--help, don't run chatbot.
118
+ # Otherwise, if it's just 'weco' or 'weco <path>' (with optional --model), run chatbot.
119
+
120
+ def should_run_chatbot(args_list):
121
+ """Determine if we should run chatbot by filtering out model arguments."""
122
+ filtered = []
123
+ i = 0
124
+ while i < len(args_list):
125
+ if args_list[i] in ["-M", "--model"]:
126
+ # Skip the model argument and its value (if it exists)
127
+ i += 1 # Skip the model flag
128
+ if i < len(args_list): # Skip the model value if it exists
129
+ i += 1
130
+ elif args_list[i].startswith("--model="):
131
+ i += 1 # Skip --model=value format
132
+ else:
133
+ filtered.append(args_list[i])
134
+ i += 1
135
+
136
+ # Apply existing chatbot detection logic to filtered args
137
+ return len(filtered) == 0 or (len(filtered) == 1 and not filtered[0].startswith("-"))
138
+
139
+ # Check for known commands by looking at the first non-option argument
140
+ def get_first_non_option_arg():
141
+ for arg in sys.argv[1:]:
142
+ if not arg.startswith("-"):
143
+ return arg
144
+ return None
145
+
146
+ first_non_option = get_first_non_option_arg()
147
+ is_known_command = first_non_option in ["run", "logout"]
148
+ is_help_command = len(sys.argv) > 1 and sys.argv[1] in ["-h", "--help"] # Check for global help
149
+
150
+ should_run_chatbot_result = should_run_chatbot(sys.argv[1:])
151
+ should_run_chatbot_flag = not is_known_command and not is_help_command and should_run_chatbot_result
152
+
153
+ if should_run_chatbot_flag:
154
+ from .chatbot import run_onboarding_chatbot # Moved import inside
155
+
156
+ # Create a simple parser just for extracting the model argument
157
+ model_parser = argparse.ArgumentParser(add_help=False)
158
+ model_parser.add_argument("-M", "--model", type=str, default=None)
159
+
160
+ # Parse args to extract model
161
+ args, unknown = model_parser.parse_known_args()
162
+
163
+ # Determine project path from remaining arguments
164
+ filtered_args = []
165
+ i = 1
166
+ while i < len(sys.argv):
167
+ if sys.argv[i] in ["-M", "--model"]:
168
+ # Skip the model argument and its value (if it exists)
169
+ i += 1 # Skip the model flag
170
+ if i < len(sys.argv): # Skip the model value if it exists
171
+ i += 1
172
+ elif sys.argv[i].startswith("--model="):
173
+ i += 1 # Skip --model=value format
174
+ else:
175
+ filtered_args.append(sys.argv[i])
176
+ i += 1
718
177
 
719
- # Report final status if a session was started
720
- if session_id:
721
- final_status = "unknown"
722
- final_reason = "unknown_termination"
723
- final_details = None
178
+ project_path = pathlib.Path(filtered_args[0]) if filtered_args else pathlib.Path.cwd()
179
+ if not project_path.is_dir():
180
+ console.print(f"[bold red]Error:[/] Path '{project_path}' is not a valid directory.")
181
+ sys.exit(1)
724
182
 
725
- if optimization_completed_normally:
726
- final_status = "completed"
727
- final_reason = "completed_successfully"
728
- else:
729
- # If an exception was caught and we have details
730
- if "error_details" in locals():
731
- final_status = "error"
732
- final_reason = "error_cli_internal"
733
- final_details = error_details
734
- # else: # Should have been handled by signal handler if terminated by user
735
- # Keep default 'unknown' if we somehow end up here without error/completion/signal
183
+ # Pass the run_parser and model to the chatbot
184
+ run_onboarding_chatbot(project_path, console, run_parser, model=args.model)
185
+ sys.exit(0)
736
186
 
737
- # Avoid reporting if terminated by signal handler (already reported)
738
- # Check a flag or rely on status not being 'unknown'
739
- if final_status != "unknown":
740
- report_termination(
741
- session_id=session_id,
742
- status_update=final_status,
743
- reason=final_reason,
744
- details=final_details,
745
- auth_headers=auth_headers,
746
- )
187
+ # If not running chatbot, proceed with normal arg parsing
188
+ # If we reached here, a command (run, logout) or help is expected.
189
+ args = parser.parse_args()
747
190
 
748
- # Ensure proper exit code if an error occurred
749
- if not optimization_completed_normally and "exit_code" in locals() and exit_code != 0:
750
- sys.exit(exit_code)
751
- elif not optimization_completed_normally:
752
- # Generic error exit if no specific code was set but try block failed
753
- sys.exit(1)
754
- else:
755
- # Normal exit
756
- sys.exit(0)
191
+ if args.command == "logout":
192
+ clear_api_key()
193
+ sys.exit(0)
194
+ elif args.command == "run":
195
+ execute_run_command(args)
196
+ else:
197
+ # This case should be hit if 'weco' is run alone and chatbot logic didn't catch it,
198
+ # or if an invalid command is provided.
199
+ parser.print_help() # Default action if no command given and not chatbot.
200
+ sys.exit(1)