diff --git a/src/codegen/cli/auth/constants.py b/src/codegen/cli/auth/constants.py index 84849c81c..5d394bd96 100644 --- a/src/codegen/cli/auth/constants.py +++ b/src/codegen/cli/auth/constants.py @@ -1,7 +1,7 @@ from pathlib import Path # Base directories -CONFIG_DIR = Path("~/.config/codegen-sh").expanduser() +CONFIG_DIR = Path("~/.codegen").expanduser() CODEGEN_DIR = Path(".codegen") PROMPTS_DIR = CODEGEN_DIR / "prompts" diff --git a/src/codegen/cli/auth/login.py b/src/codegen/cli/auth/login.py index ab310d855..49fddd5c8 100644 --- a/src/codegen/cli/auth/login.py +++ b/src/codegen/cli/auth/login.py @@ -40,8 +40,6 @@ def login_routine(token: str | None = None) -> str: token_manager = TokenManager() token_manager.authenticate_token(token) rich.print(f"[green]āœ“ Stored token to:[/green] {token_manager.token_file}") - rich.print("[cyan]šŸ“Š Hey![/cyan] We collect anonymous usage data to improve your experience šŸ”’") - rich.print("To opt out, set [green]telemetry_enabled = false[/green] in [cyan]~/.config/codegen-sh/analytics.json[/cyan] ✨") return token except AuthError as e: rich.print(f"[red]Error:[/red] {e!s}") diff --git a/src/codegen/cli/cli.py b/src/codegen/cli/cli.py index 862ecd0b6..0ce5f873d 100644 --- a/src/codegen/cli/cli.py +++ b/src/codegen/cli/cli.py @@ -3,16 +3,16 @@ from codegen import __version__ +# Import config command (still a Typer app) +from codegen.cli.commands.agents.main import agents_app + # Import the actual command functions from codegen.cli.commands.claude.main import claude - -# Import config command (still a Typer app) from codegen.cli.commands.config.main import config_command from codegen.cli.commands.init.main import init from codegen.cli.commands.integrations.main import integrations_app from codegen.cli.commands.login.main import login from codegen.cli.commands.logout.main import logout -from codegen.cli.commands.mcp.main import mcp from codegen.cli.commands.profile.main import profile from codegen.cli.commands.style_debug.main import style_debug from codegen.cli.commands.tools.main import tools @@ -29,27 +29,27 @@ def version_callback(value: bool): # Create the main Typer app -main = typer.Typer(name="codegen", help="Codegen CLI - Transform your code with AI.", rich_markup_mode="rich") +main = typer.Typer(name="codegen", help="Codegen - the Operating System for Code Agents.", rich_markup_mode="rich") # Add individual commands to the main app main.command("claude", help="Run Claude Code with OpenTelemetry monitoring and logging.")(claude) main.command("init", help="Initialize or update the Codegen folder.")(init) main.command("login", help="Store authentication token.")(login) main.command("logout", help="Clear stored authentication token.")(logout) -main.command("mcp", help="Start the Codegen MCP server.")(mcp) main.command("profile", help="Display information about the currently authenticated user.")(profile) main.command("style-debug", help="Debug command to visualize CLI styling (spinners, etc).")(style_debug) main.command("tools", help="List available tools from the Codegen API.")(tools) main.command("update", help="Update Codegen to the latest or specified version")(update) # Add Typer apps as sub-applications +main.add_typer(agents_app, name="agents") main.add_typer(config_command, name="config") main.add_typer(integrations_app, name="integrations") @main.callback() def main_callback(version: bool = typer.Option(False, "--version", callback=version_callback, is_eager=True, help="Show version and exit")): - """Codegen CLI - Transform your code with AI.""" + """Codegen - the Operating System for Code Agents""" pass diff --git a/src/codegen/cli/commands/agents/__init__.py b/src/codegen/cli/commands/agents/__init__.py new file mode 100644 index 000000000..14f40ba0c --- /dev/null +++ b/src/codegen/cli/commands/agents/__init__.py @@ -0,0 +1 @@ +"""Agents command module.""" diff --git a/src/codegen/cli/commands/agents/main.py b/src/codegen/cli/commands/agents/main.py new file mode 100644 index 000000000..dcbe74da9 --- /dev/null +++ b/src/codegen/cli/commands/agents/main.py @@ -0,0 +1,127 @@ +"""Agents command for the Codegen CLI.""" + +import requests +import typer +from rich.console import Console +from rich.table import Table + +from codegen.cli.api.endpoints import API_ENDPOINT +from codegen.cli.auth.token_manager import get_current_token +from codegen.cli.rich.spinners import create_spinner +from codegen.cli.utils.org import resolve_org_id + +console = Console() + +# Create the agents app +agents_app = typer.Typer(help="Manage Codegen agents") + + +@agents_app.command("list") +def list_agents(org_id: int | None = typer.Option(None, help="Organization ID (defaults to CODEGEN_ORG_ID/REPOSITORY_ORG_ID or auto-detect)")): + """List agent runs from the Codegen API.""" + # Get the current token + token = get_current_token() + if not token: + console.print("[red]Error:[/red] Not authenticated. Please run 'codegen login' first.") + raise typer.Exit(1) + + try: + # Resolve org id + resolved_org_id = resolve_org_id(org_id) + if resolved_org_id is None: + console.print("[red]Error:[/red] Organization ID not provided. Pass --org-id, set CODEGEN_ORG_ID, or REPOSITORY_ORG_ID.") + raise typer.Exit(1) + + # Make API request to list agent runs with spinner + spinner = create_spinner("Fetching agent runs...") + spinner.start() + + try: + headers = {"Authorization": f"Bearer {token}"} + url = f"{API_ENDPOINT.rstrip('/')}/v1/organizations/{resolved_org_id}/agent/runs" + response = requests.get(url, headers=headers) + response.raise_for_status() + response_data = response.json() + finally: + spinner.stop() + + # Extract agent runs from the response structure + agent_runs = response_data.get("items", []) + total = response_data.get("total", 0) + page = response_data.get("page", 1) + page_size = response_data.get("page_size", 10) + + if not agent_runs: + console.print("[yellow]No agent runs found.[/yellow]") + return + + # Create a table to display agent runs + table = Table( + title=f"Agent Runs (Page {page}, Total: {total})", + border_style="blue", + show_header=True, + title_justify="center", + ) + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Status", style="white", justify="center") + table.add_column("Source", style="magenta") + table.add_column("Created", style="dim") + table.add_column("Result", style="green") + + # Add agent runs to table + for agent_run in agent_runs: + run_id = str(agent_run.get("id", "Unknown")) + status = agent_run.get("status", "Unknown") + source_type = agent_run.get("source_type", "Unknown") + created_at = agent_run.get("created_at", "Unknown") + result = agent_run.get("result", "") + + # Status with emoji + status_display = status + if status == "COMPLETE": + status_display = "āœ… Complete" + elif status == "RUNNING": + status_display = "šŸƒ Running" + elif status == "FAILED": + status_display = "āŒ Failed" + elif status == "STOPPED": + status_display = "ā¹ļø Stopped" + elif status == "PENDING": + status_display = "ā³ Pending" + + # Format created date (just show date and time, not full timestamp) + if created_at and created_at != "Unknown": + try: + # Parse and format the timestamp to be more readable + from datetime import datetime + + dt = datetime.fromisoformat(created_at.replace("Z", "+00:00")) + created_display = dt.strftime("%m/%d %H:%M") + except (ValueError, TypeError): + created_display = created_at[:16] if len(created_at) > 16 else created_at + else: + created_display = created_at + + # Truncate result if too long + result_display = result[:50] + "..." if result and len(result) > 50 else result or "No result" + + table.add_row(run_id, status_display, source_type, created_display, result_display) + + console.print(table) + console.print(f"\n[green]Showing {len(agent_runs)} of {total} agent runs[/green]") + + except requests.RequestException as e: + console.print(f"[red]Error fetching agent runs:[/red] {e}", style="bold red") + raise typer.Exit(1) + except Exception as e: + console.print(f"[red]Unexpected error:[/red] {e}", style="bold red") + raise typer.Exit(1) + + +# Default callback for the agents app +@agents_app.callback(invoke_without_command=True) +def agents_callback(ctx: typer.Context): + """Manage Codegen agents.""" + if ctx.invoked_subcommand is None: + # If no subcommand is provided, run list by default + list_agents(org_id=None) diff --git a/src/codegen/cli/commands/integrations/main.py b/src/codegen/cli/commands/integrations/main.py index 8a287dfa0..8b27ca341 100644 --- a/src/codegen/cli/commands/integrations/main.py +++ b/src/codegen/cli/commands/integrations/main.py @@ -9,8 +9,9 @@ from codegen.cli.api.endpoints import API_ENDPOINT from codegen.cli.auth.token_manager import get_current_token -from codegen.cli.utils.url import generate_webapp_url +from codegen.cli.rich.spinners import create_spinner from codegen.cli.utils.org import resolve_org_id +from codegen.cli.utils.url import generate_webapp_url console = Console() @@ -21,8 +22,6 @@ @integrations_app.command("list") def list_integrations(org_id: int | None = typer.Option(None, help="Organization ID (defaults to CODEGEN_ORG_ID/REPOSITORY_ORG_ID or auto-detect)")): """List organization integrations from the Codegen API.""" - console.print("šŸ”Œ Fetching organization integrations...", style="bold blue") - # Get the current token token = get_current_token() if not token: @@ -36,13 +35,18 @@ def list_integrations(org_id: int | None = typer.Option(None, help="Organization console.print("[red]Error:[/red] Organization ID not provided. Pass --org-id, set CODEGEN_ORG_ID, or REPOSITORY_ORG_ID.") raise typer.Exit(1) - # Make API request to list integrations - headers = {"Authorization": f"Bearer {token}"} - url = f"{API_ENDPOINT.rstrip('/')}/v1/organizations/{resolved_org_id}/integrations" - response = requests.get(url, headers=headers) - response.raise_for_status() + # Make API request to list integrations with spinner + spinner = create_spinner("Fetching organization integrations...") + spinner.start() - response_data = response.json() + try: + headers = {"Authorization": f"Bearer {token}"} + url = f"{API_ENDPOINT.rstrip('/')}/v1/organizations/{resolved_org_id}/integrations" + response = requests.get(url, headers=headers) + response.raise_for_status() + response_data = response.json() + finally: + spinner.stop() # Extract integrations from the response structure integrations_data = response_data.get("integrations", []) @@ -138,6 +142,5 @@ def add_integration(): def integrations_callback(ctx: typer.Context): """Manage Codegen integrations.""" if ctx.invoked_subcommand is None: - # If no subcommand is provided, show help - print(ctx.get_help()) - raise typer.Exit() + # If no subcommand is provided, run list by default + list_integrations(org_id=None) diff --git a/src/codegen/cli/commands/login/main.py b/src/codegen/cli/commands/login/main.py index 483547782..3c6137539 100644 --- a/src/codegen/cli/commands/login/main.py +++ b/src/codegen/cli/commands/login/main.py @@ -9,7 +9,6 @@ def login(token: str | None = typer.Option(None, help="API token for authenticat """Store authentication token.""" # Check if already authenticated if get_current_token(): - rich.print("[yellow]Warning:[/yellow] Already authenticated. Use 'codegen logout' to clear the token.") - raise typer.Exit(1) + rich.print("[yellow]Info:[/yellow] You already have a token stored. Proceeding with re-authentication...") login_routine(token) diff --git a/src/codegen/cli/commands/mcp/__init__.py b/src/codegen/cli/commands/mcp/__init__.py deleted file mode 100644 index ef4b55200..000000000 --- a/src/codegen/cli/commands/mcp/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""MCP command module.""" diff --git a/src/codegen/cli/commands/mcp/main.py b/src/codegen/cli/commands/mcp/main.py deleted file mode 100644 index 52cf1f0bf..000000000 --- a/src/codegen/cli/commands/mcp/main.py +++ /dev/null @@ -1,90 +0,0 @@ -"""MCP server command for the Codegen CLI.""" - -from typing import Any - -import requests -import typer -from rich.console import Console - -from codegen.cli.api.endpoints import API_ENDPOINT -from codegen.cli.auth.token_manager import get_current_token -from codegen.cli.utils.org import resolve_org_id - -console = Console() - - -def fetch_tools_for_mcp(org_id: int | None) -> list[dict[str, Any]]: - """Fetch available tools from the API for MCP server generation.""" - try: - token = get_current_token() - if not token: - console.print("[red]Error:[/red] Not authenticated. Please run 'codegen login' first.") - raise typer.Exit(1) - - # Resolve org id - resolved_org_id = resolve_org_id(org_id) - if resolved_org_id is None: - console.print("[red]Error:[/red] Organization ID not provided. Pass --org-id, set CODEGEN_ORG_ID, or REPOSITORY_ORG_ID.") - raise typer.Exit(1) - - console.print("šŸ”§ Fetching available tools from API...", style="dim") - headers = {"Authorization": f"Bearer {token}"} - url = f"{API_ENDPOINT.rstrip('/')}/v1/organizations/{resolved_org_id}/tools" - response = requests.get(url, headers=headers) - response.raise_for_status() - - response_data = response.json() - - # Extract tools from the response structure - if isinstance(response_data, dict) and "tools" in response_data: - tools = response_data["tools"] - console.print(f"āœ… Found {len(tools)} tools", style="green") - return tools - - return response_data if isinstance(response_data, list) else [] - - except requests.RequestException as e: - console.print(f"[red]Error fetching tools:[/red] {e}", style="bold red") - raise typer.Exit(1) - except Exception as e: - console.print(f"[red]Unexpected error:[/red] {e}", style="bold red") - raise typer.Exit(1) - - -def mcp( - host: str = typer.Option("localhost", help="Host to bind the MCP server to"), - port: int | None = typer.Option(None, help="Port to bind the MCP server to (default: stdio transport)"), - transport: str = typer.Option("stdio", help="Transport protocol to use (stdio or http)"), - org_id: int | None = typer.Option(None, help="Organization ID (defaults to CODEGEN_ORG_ID/REPOSITORY_ORG_ID or auto-detect)"), -): - """Start the Codegen MCP server.""" - console.print("šŸš€ Starting Codegen MCP server...", style="bold green") - - if transport == "stdio": - console.print("šŸ“” Using stdio transport", style="dim") - else: - if port is None: - port = 8000 - console.print(f"šŸ“” Using HTTP transport on {host}:{port}", style="dim") - - # Validate transport - if transport not in ["stdio", "http"]: - console.print( - f"āŒ Invalid transport: {transport}. Must be 'stdio' or 'http'", - style="bold red", - ) - raise typer.Exit(1) - - # Fetch tools from API before starting server - tools = fetch_tools_for_mcp(org_id) - - # Import here to avoid circular imports and ensure dependencies are available - from codegen.cli.mcp.server import run_server - - try: - run_server(transport=transport, host=host, port=port, available_tools=tools) - except KeyboardInterrupt: - console.print("\nšŸ‘‹ MCP server stopped", style="yellow") - except Exception as e: - console.print(f"āŒ Error starting MCP server: {e}", style="bold red") - raise typer.Exit(1) diff --git a/src/codegen/cli/commands/tools/main.py b/src/codegen/cli/commands/tools/main.py index 856767d1c..3b1ef81b4 100644 --- a/src/codegen/cli/commands/tools/main.py +++ b/src/codegen/cli/commands/tools/main.py @@ -7,6 +7,7 @@ from codegen.cli.api.endpoints import API_ENDPOINT from codegen.cli.auth.token_manager import get_current_token +from codegen.cli.rich.spinners import create_spinner from codegen.cli.utils.org import resolve_org_id console = Console() @@ -14,8 +15,6 @@ def tools(org_id: int | None = typer.Option(None, help="Organization ID (defaults to CODEGEN_ORG_ID/REPOSITORY_ORG_ID or auto-detect)")): """List available tools from the Codegen API.""" - console.print("šŸ”§ Fetching available tools...", style="bold blue") - # Get the current token token = get_current_token() if not token: @@ -29,13 +28,18 @@ def tools(org_id: int | None = typer.Option(None, help="Organization ID (default console.print("[red]Error:[/red] Organization ID not provided. Pass --org-id, set CODEGEN_ORG_ID, or REPOSITORY_ORG_ID.") raise typer.Exit(1) - # Make API request to list tools - headers = {"Authorization": f"Bearer {token}"} - url = f"{API_ENDPOINT.rstrip('/')}/v1/organizations/{resolved_org_id}/tools" - response = requests.get(url, headers=headers) - response.raise_for_status() - - response_data = response.json() + # Make API request to list tools with spinner + spinner = create_spinner("Fetching available tools...") + spinner.start() + + try: + headers = {"Authorization": f"Bearer {token}"} + url = f"{API_ENDPOINT.rstrip('/')}/v1/organizations/{resolved_org_id}/tools" + response = requests.get(url, headers=headers) + response.raise_for_status() + response_data = response.json() + finally: + spinner.stop() # Extract tools from the response structure if isinstance(response_data, dict) and "tools" in response_data: diff --git a/tests/unit/codegen/test_cli_basic.py b/tests/unit/codegen/test_cli_basic.py index 5bcc96393..3a17c3f37 100644 --- a/tests/unit/codegen/test_cli_basic.py +++ b/tests/unit/codegen/test_cli_basic.py @@ -19,7 +19,6 @@ def test_cli_help_works(): assert result.returncode == 0 # Should contain basic help text - assert "Codegen CLI - Transform your code with AI" in result.stdout assert "Commands" in result.stdout assert "init" in result.stdout assert "login" in result.stdout