"""Argument parsing and main entry point for the ChemGraph CLI. Supports three usage styles: 1. **Legacy** (no subcommand) -- ``chemgraph -q "..." -m gpt-4o`` 2. **Subcommand** -- ``chemgraph run ...``, ``chemgraph eval ...``, ``chemgraph session ...``, ``chemgraph models`` 3. **Standalone eval** -- ``chemgraph-eval`` via its own entry point. """ from __future__ import annotations import argparse import sys from typing import Any, Dict import toml from chemgraph.models.supported_models import all_supported_models from chemgraph.utils.config_utils import ( flatten_config, get_argo_user_from_flat_config, get_base_url_for_model_from_flat_config, ) from chemgraph.cli.commands import ( ALL_WORKFLOW_TYPES, WORKFLOW_ALIASES, resolve_workflow, delete_session_cmd, initialize_agent, interactive_mode, list_sessions, run_query, save_output, show_session, ) from chemgraph.cli.formatting import ( check_api_keys_status, console, create_banner, format_response, list_models, ) # --------------------------------------------------------------------------- # Argument parser construction # --------------------------------------------------------------------------- # Workflow choices exposed to the user. We include common aliases # (e.g. ``python_repl``) so that users don't have to know the # internal ``python_relp`` name. _WORKFLOW_CHOICES = sorted(set(ALL_WORKFLOW_TYPES) | set(WORKFLOW_ALIASES.keys())) def _add_run_args(parser: argparse.ArgumentParser) -> None: """Add query/run-specific arguments to *parser*. Used by both the ``run`` subcommand and the legacy (no subcommand) argument parser for backward compatibility. Parameters ---------- parser : argparse.ArgumentParser Parser or subparser to receive query/run arguments. """ parser.add_argument( "-q", "--query", type=str, help="The computational chemistry query to execute" ) parser.add_argument( "-m", "--model", type=str, default="gpt-4o-mini", help="LLM model to use (default: gpt-4o-mini)", ) parser.add_argument( "-w", "--workflow", type=str, choices=_WORKFLOW_CHOICES, default="single_agent", help="Workflow type (default: single_agent)", ) parser.add_argument( "-o", "--output", type=str, choices=["state", "last_message"], default="state", help="Output format (default: state)", ) parser.add_argument( "-s", "--structured", action="store_true", help="Use structured output format" ) parser.add_argument( "-r", "--report", action="store_true", help="Generate detailed report" ) parser.add_argument( "--human-supervised", action="store_true", help="Enable the ask_human tool for human-in-the-loop interaction", ) parser.add_argument( "--recursion-limit", type=int, default=20, help="Recursion limit for agent workflows (default: 20)", ) parser.add_argument( "--interactive", action="store_true", help="Start interactive mode" ) parser.add_argument( "--list-models", action="store_true", help="List all available models" ) parser.add_argument( "--check-keys", action="store_true", help="Check API key availability" ) parser.add_argument( "--list-sessions", action="store_true", help="List recent sessions from the memory database", ) parser.add_argument( "--show-session", type=str, metavar="ID", help="Show conversation for a session (supports prefix matching)", ) parser.add_argument( "--delete-session", type=str, metavar="ID", help="Delete a session from the memory database", ) parser.add_argument( "--resume", type=str, metavar="ID", help="Resume from a previous session (injects context into new query)", ) parser.add_argument( "-v", "--verbose", action="count", default=0, help="Increase verbosity (-v for INFO, -vv for DEBUG)", ) parser.add_argument("--output-file", type=str, help="Save output to file") parser.add_argument("--config", type=str, help="Load configuration from TOML file") parser.add_argument( "--base-url", type=str, default=None, help="Base URL for the LLM API endpoint (overrides config file)", ) parser.add_argument( "--mcp-url", type=str, default=None, help="MCP server URL for streamable_http transport (e.g. http://localhost:9003/mcp/)", ) parser.add_argument( "--mcp-command", type=str, default=None, help="MCP server command for stdio transport (e.g. 'python -m chemgraph.mcp.mcp_tools')", ) parser.add_argument( "--mcp-server-name", type=str, default="ChemGraph General Tools", help="Display name for the MCP server connection (default: 'ChemGraph General Tools')", ) def create_argument_parser() -> argparse.ArgumentParser: """Create and configure the argument parser with subcommands.""" parser = argparse.ArgumentParser( prog="chemgraph", description="ChemGraph CLI - AI Agents for Computational Chemistry", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Legacy style (still works) %(prog)s -q "What is the SMILES string for water?" %(prog)s --interactive %(prog)s --list-models # Subcommand style %(prog)s run -q "Optimize water geometry" -m gpt-4o %(prog)s eval --profile quick --models gpt-4o-mini --config config.toml %(prog)s eval --models gpt-4o --dataset ground_truth.json %(prog)s session list %(prog)s session show a3b2 %(prog)s models """, ) subparsers = parser.add_subparsers(dest="command") # ---- "run" subcommand ------------------------------------------------ run_parser = subparsers.add_parser( "run", help="Run a single query or start interactive mode.", formatter_class=argparse.RawDescriptionHelpFormatter, ) _add_run_args(run_parser) # ---- "eval" subcommand ----------------------------------------------- eval_parser = subparsers.add_parser( "eval", help="Run evaluation benchmarks against ground-truth datasets.", formatter_class=argparse.RawDescriptionHelpFormatter, ) # Import here to avoid circular imports at module level from chemgraph.eval.cli import add_eval_args add_eval_args(eval_parser) # ---- "session" subcommand -------------------------------------------- session_parser = subparsers.add_parser( "session", help="Manage conversation sessions.", ) session_sub = session_parser.add_subparsers(dest="session_command") session_sub.add_parser("list", help="List recent sessions.") show_parser = session_sub.add_parser("show", help="Show a session's conversation.") show_parser.add_argument("id", help="Session ID (prefix matching supported).") delete_parser = session_sub.add_parser("delete", help="Delete a session.") delete_parser.add_argument("id", help="Session ID to delete.") # ---- "models" subcommand --------------------------------------------- subparsers.add_parser("models", help="List all available LLM models.") # ---- Legacy fallback args ------------------------------------------- # Also add run args to the top-level parser so that # `chemgraph -q "..."` keeps working without a subcommand. _add_run_args(parser) return parser # --------------------------------------------------------------------------- # Config loading # --------------------------------------------------------------------------- def load_config(config_file: str) -> Dict[str, Any]: """Load and flatten a TOML configuration file. Merges missing keys from a sensible default so that partial config files don't crash the CLI (addresses Bug 4 -- parity with the Streamlit config loader). Parameters ---------- config_file : str Path to a TOML configuration file. Returns ------- dict[str, Any] Flattened configuration dictionary with defaults filled in. """ try: with open(config_file, "r") as f: raw_config = toml.load(f) console.print(f"[green]Configuration loaded from {config_file}[/green]") # Merge defaults for required sections so partial configs work. _DEFAULT_SECTIONS = { "general": { "model": "gpt-4o-mini", "workflow": "single_agent", "output": "state", "structured": False, "report": False, "thread": 1, "recursion_limit": 20, "human_supervised": False, "verbose": False, }, "api": {}, "chemistry": {}, "output": {}, "mcp": {}, } for section, defaults in _DEFAULT_SECTIONS.items(): if section not in raw_config: raw_config[section] = defaults elif isinstance(defaults, dict): for key, value in defaults.items(): raw_config[section].setdefault(key, value) flat = flatten_config(raw_config) # Inject MCP config keys (not handled by flatten_config). if "mcp" in raw_config and isinstance(raw_config["mcp"], dict): for key, value in raw_config["mcp"].items(): flat[f"mcp_{key}"] = value return flat except FileNotFoundError: console.print(f"[red]Configuration file not found: {config_file}[/red]") sys.exit(1) except toml.TomlDecodeError as e: console.print(f"[red]Invalid TOML in configuration file: {e}[/red]") sys.exit(1) # --------------------------------------------------------------------------- # Subcommand handlers # --------------------------------------------------------------------------- def _handle_run(args: argparse.Namespace) -> None: """Handle the ``run`` subcommand and legacy no-subcommand mode. Parameters ---------- args : argparse.Namespace Parsed CLI arguments. """ # Handle special commands first if getattr(args, "list_models", False): list_models() return if getattr(args, "check_keys", False): check_api_keys_status() return if getattr(args, "list_sessions", False): list_sessions() return if getattr(args, "show_session", None): show_session(args.show_session) return if getattr(args, "delete_session", None): delete_session_cmd(args.delete_session) return # Load configuration if specified config: Dict[str, Any] = {} if args.config: config = load_config(args.config) # Override args with config values (only when the user hasn't # explicitly set them on the command line). for key, value in config.items(): if hasattr(args, key) and getattr(args, key) is None: setattr(args, key, value) # Honour config recursion_limit unless user gave explicit flag. if "recursion_limit" in config and "--recursion-limit" not in sys.argv: args.recursion_limit = config["recursion_limit"] # ---- Configure logging verbosity -------------------------------- import logging as _logging from chemgraph.utils.logging_config import configure_logging # Start from config baseline (default: WARNING = quiet). _log_level_name = config.get("logging_level", "WARNING").upper() if config else "WARNING" _log_level = getattr(_logging, _log_level_name, _logging.WARNING) # CLI -v / -vv overrides the config value. if args.verbose >= 2: _log_level = _logging.DEBUG elif args.verbose >= 1: _log_level = _logging.INFO configure_logging(_log_level) base_url = args.base_url or ( get_base_url_for_model_from_flat_config(args.model, config) if config else None ) argo_user = get_argo_user_from_flat_config(config) if config else None # Resolve workflow alias (e.g. python_repl -> python_relp) args.workflow = resolve_workflow(args.workflow) # ---- MCP tool loading ---------------------------------------------- mcp_tools = None mcp_url = getattr(args, "mcp_url", None) or config.get("mcp_url") mcp_command = getattr(args, "mcp_command", None) or config.get("mcp_command") mcp_server_name = ( getattr(args, "mcp_server_name", None) or config.get("mcp_server_name", "ChemGraph General Tools") ) if mcp_url or mcp_command: from chemgraph.cli.mcp_utils import load_mcp_tools_from_config mcp_tools = load_mcp_tools_from_config( url=mcp_url, command=mcp_command, server_name=mcp_server_name, verbose=(args.verbose > 0), ) if mcp_tools is None: sys.exit(1) if getattr(args, "interactive", False): interactive_mode( model=args.model, workflow=args.workflow, structured=args.structured, return_option=args.output, generate_report=args.report, human_supervised=args.human_supervised, recursion_limit=args.recursion_limit, base_url=base_url, argo_user=argo_user, verbose=(args.verbose > 0), tools=mcp_tools, ) return if args.model not in all_supported_models: console.print( f"[yellow]Using custom model ID: {args.model} (not in curated list)[/yellow]" ) # Require query for non-interactive mode if not args.query: console.print("[red]Query is required. Use -q or --query to specify.[/red]") console.print( "Use --help for more information or --interactive for interactive mode." ) sys.exit(1) # Show banner console.print(create_banner()) # Initialize agent agent = initialize_agent( args.model, args.workflow, args.structured, args.output, args.report, args.recursion_limit, base_url=base_url, argo_user=argo_user, verbose=(args.verbose > 0), human_supervised=args.human_supervised, tools=mcp_tools, ) if not agent: sys.exit(1) # Execute query console.print(f"[bold blue]Query:[/bold blue] {args.query}") if args.resume: console.print(f"[bold blue]Resuming from:[/bold blue] {args.resume}") result = run_query( agent, args.query, verbose=(args.verbose > 0), resume_from=args.resume ) if result: format_response(result, verbose=(args.verbose > 0)) # Save output if requested if args.output_file: output_content = str(result) save_output(output_content, args.output_file) if hasattr(agent, "session_id") and agent.session_id: console.print( f"\n[dim]Session: {agent.session_id}" f" | Resume: chemgraph -q \"\" --resume {agent.session_id}[/dim]" ) console.print("[dim]Thank you for using ChemGraph CLI![/dim]") # --------------------------------------------------------------------------- # Main entry point # --------------------------------------------------------------------------- def main() -> None: """Main CLI entry point. Dispatches to the appropriate subcommand handler, or falls back to the legacy behaviour when no subcommand is given. """ parser = create_argument_parser() args = parser.parse_args() if args.command == "eval": from chemgraph.eval.cli import run_eval run_eval(args) elif args.command == "session": sc = getattr(args, "session_command", None) if sc == "list": list_sessions() elif sc == "show": show_session(args.id) elif sc == "delete": delete_session_cmd(args.id) else: console.print( "Usage: chemgraph session {list,show,delete}. Use --help for details." ) elif args.command == "models": list_models() elif args.command == "run": _handle_run(args) else: # No subcommand given -- legacy behaviour. _handle_run(args) if __name__ == "__main__": main()