"""Loyal Companion CLI - Main entry point.""" import sys from pathlib import Path import typer from typing_extensions import Annotated from cli.client import APIError, LoyalCompanionClient from cli.config import CLIConfig from cli.formatters import ResponseFormatter from cli.session import SessionManager app = typer.Typer( name="loyal-companion", help="Loyal Companion CLI - A quiet, terminal-based interface for conversations.", add_completion=False, ) def _ensure_authenticated(config: CLIConfig) -> tuple[CLIConfig, str]: """Ensure user is authenticated. Args: config: CLI configuration Returns: tuple: (config, auth_token) Raises: typer.Exit: If authentication fails """ auth_token = config.get_auth_token() if not auth_token: # Need to authenticate email = config.email if not email: email = typer.prompt("Email address") config.email = email config.save() # Request token try: client = LoyalCompanionClient(config.get_api_url()) response = client.request_token(email) auth_token = response.get("token") if not auth_token: typer.echo(f"Error: {response.get('message', 'No token received')}", err=True) raise typer.Exit(1) # Save token config.auth_token = auth_token config.save() typer.echo(f"Authenticated as {email}") except APIError as e: typer.echo(f"Authentication failed: {e}", err=True) raise typer.Exit(1) return config, auth_token @app.command() def talk( session_name: Annotated[str, typer.Option("--session", "-s", help="Session name")] = "default", new: Annotated[bool, typer.Option("--new", "-n", help="Start a new session")] = False, show_mood: Annotated[ bool, typer.Option("--mood/--no-mood", help="Show mood information") ] = True, show_relationship: Annotated[ bool, typer.Option("--relationship/--no-relationship", help="Show relationship info") ] = False, ): """Start or resume a conversation. Examples: lc talk # Resume default session lc talk --new # Start fresh default session lc talk -s work # Resume 'work' session lc talk -s personal --new # Start fresh 'personal' session """ # Load config config = CLIConfig.load() # Ensure authenticated config, auth_token = _ensure_authenticated(config) # Initialize client client = LoyalCompanionClient(config.get_api_url(), auth_token) # Initialize session manager session_manager = SessionManager(config.sessions_file) # Get or create session if new: # Delete old session if exists session_manager.delete_session(session_name) session = session_manager.get_or_create_session(session_name) # Initialize formatter formatter = ResponseFormatter( show_mood=show_mood, show_relationship=show_relationship, show_facts=config.show_facts, show_timestamps=config.show_timestamps, ) # Print welcome message formatter.print_info("Bartender is here.") if session.message_count > 0: formatter.print_info( f"Resuming session '{session.name}' ({session.message_count} messages)" ) formatter.print_info("Type your message and press Enter. Press Ctrl+D to end.\n") # Conversation loop try: while True: # Get user input try: user_message = typer.prompt("You", prompt_suffix=": ") except (EOFError, KeyboardInterrupt): # User pressed Ctrl+D or Ctrl+C break if not user_message.strip(): continue # Send message try: response = client.send_message(session.session_id, user_message) # Format and display response formatter.format_response(response) print() # Empty line for spacing # Update session session_manager.update_last_active(session.name) except APIError as e: formatter.print_error(str(e)) continue except KeyboardInterrupt: pass # Goodbye message print() # Empty line formatter.print_info("Session saved.") client.close() @app.command() def history( session_name: Annotated[str, typer.Option("--session", "-s", help="Session name")] = "default", limit: Annotated[int, typer.Option("--limit", "-n", help="Number of messages")] = 50, ): """Show conversation history for a session. Examples: lc history # Show default session history lc history -s work # Show 'work' session history lc history -n 10 # Show last 10 messages """ # Load config config = CLIConfig.load() # Ensure authenticated config, auth_token = _ensure_authenticated(config) # Initialize client client = LoyalCompanionClient(config.get_api_url(), auth_token) # Initialize session manager session_manager = SessionManager(config.sessions_file) # Get session session = session_manager.get_session(session_name) if not session: typer.echo(f"Session '{session_name}' not found", err=True) raise typer.Exit(1) # Get history try: response = client.get_history(session.session_id, limit) messages = response.get("messages", []) if not messages: typer.echo("No messages in this session yet.") raise typer.Exit(0) # Format and display formatter = ResponseFormatter( show_timestamps=True, use_rich=True, ) typer.echo(f"History for session '{session.name}' ({len(messages)} messages):\n") for message in messages: formatter.format_history_message(message) print() # Spacing between messages except APIError as e: typer.echo(f"Failed to get history: {e}", err=True) raise typer.Exit(1) client.close() @app.command() def sessions( delete: Annotated[str | None, typer.Option("--delete", "-d", help="Delete a session")] = None, ): """List all sessions or delete a specific session. Examples: lc sessions # List all sessions lc sessions -d work # Delete 'work' session """ # Load config config = CLIConfig.load() # Initialize session manager session_manager = SessionManager(config.sessions_file) if delete: # Delete session if session_manager.delete_session(delete): typer.echo(f"Deleted session '{delete}'") else: typer.echo(f"Session '{delete}' not found", err=True) raise typer.Exit(1) return # List sessions all_sessions = session_manager.list_sessions() if not all_sessions: typer.echo("No sessions found.") return typer.echo(f"Found {len(all_sessions)} session(s):\n") for session in all_sessions: typer.echo(f" {session.name}") typer.echo(f" Created: {session.created_at}") typer.echo(f" Last active: {session.last_active}") typer.echo(f" Messages: {session.message_count}") typer.echo() @app.command() def config_cmd( show: Annotated[bool, typer.Option("--show", help="Show current configuration")] = False, set_api_url: Annotated[str | None, typer.Option("--api-url", help="Set API URL")] = None, set_email: Annotated[str | None, typer.Option("--email", help="Set email")] = None, reset: Annotated[bool, typer.Option("--reset", help="Reset configuration")] = False, ): """Manage CLI configuration. Examples: lc config --show # Show current config lc config --api-url http://localhost:8080 # Set API URL lc config --email user@example.com # Set email lc config --reset # Reset to defaults """ config = CLIConfig.load() if reset: # Delete config file if config.config_file.exists(): config.config_file.unlink() typer.echo("Configuration reset to defaults") return if set_api_url: config.api_url = set_api_url config.save() typer.echo(f"API URL set to: {set_api_url}") if set_email: config.email = set_email # Clear token when email changes config.auth_token = None config.save() typer.echo(f"Email set to: {set_email}") typer.echo("(Auth token cleared - you'll need to re-authenticate)") if show or (not set_api_url and not set_email and not reset): # Show config typer.echo("Current configuration:\n") typer.echo(f" API URL: {config.get_api_url()}") typer.echo(f" Email: {config.email or '(not set)'}") typer.echo(f" Authenticated: {'Yes' if config.get_auth_token() else 'No'}") typer.echo(f" Config file: {config.config_file}") typer.echo(f" Sessions file: {config.sessions_file}") @app.command() def auth( logout: Annotated[bool, typer.Option("--logout", help="Clear authentication")] = False, ): """Manage authentication. Examples: lc auth # Show auth status lc auth --logout # Clear stored token """ config = CLIConfig.load() if logout: config.auth_token = None config.save() typer.echo("Authentication cleared") return # Show auth status if config.get_auth_token(): typer.echo(f"Authenticated as: {config.email}") else: typer.echo("Not authenticated") typer.echo("Run 'lc talk' to authenticate") @app.command() def health(): """Check API health status. Examples: lc health # Check if API is reachable """ config = CLIConfig.load() try: client = LoyalCompanionClient(config.get_api_url()) response = client.health_check() typer.echo(f"API Status: {response.get('status', 'unknown')}") typer.echo(f"Platform: {response.get('platform', 'unknown')}") typer.echo(f"Version: {response.get('version', 'unknown')}") except APIError as e: typer.echo(f"Health check failed: {e}", err=True) raise typer.Exit(1) def main(): """Main entry point.""" app() if __name__ == "__main__": main()