diff --git a/src/codegen/cli/api/client.py b/src/codegen/cli/api/client.py index 33b925267..3854a0b31 100644 --- a/src/codegen/cli/api/client.py +++ b/src/codegen/cli/api/client.py @@ -87,9 +87,3 @@ def _make_request( except requests.RequestException as e: msg = f"Network error: {e!s}" raise ServerError(msg) - - def identify(self) -> Identity: - """Get user identity information.""" - # TODO: Implement actual API call to identity endpoint - # For now, return a mock identity with active status - return Identity(auth_context=AuthContext(status="active")) diff --git a/src/codegen/cli/api/endpoints.py b/src/codegen/cli/api/endpoints.py index a51b38bdb..5b7fb5a57 100644 --- a/src/codegen/cli/api/endpoints.py +++ b/src/codegen/cli/api/endpoints.py @@ -11,3 +11,7 @@ PR_LOOKUP_ENDPOINT = f"https://{MODAL_PREFIX}--cli-pr-lookup.modal.run" CODEGEN_SYSTEM_PROMPT_URL = "https://gist.githubusercontent.com/jayhack/15681a2ceaccd726f19e6fdb3a44738b/raw/17c08054e3931b3b7fdf424458269c9e607541e8/codegen-system-prompt.txt" IMPROVE_ENDPOINT = f"https://{MODAL_PREFIX}--cli-improve.modal.run" + +# API ENDPOINT +# API_ENDPOINT = "https://codegen-sh-develop-jay--rest-api.modal.run/" +API_ENDPOINT = "https://codegen-sh-staging--rest-api.modal.run/" diff --git a/src/codegen/cli/api/modal.py b/src/codegen/cli/api/modal.py index 16b4e3d62..344aa9bd9 100644 --- a/src/codegen/cli/api/modal.py +++ b/src/codegen/cli/api/modal.py @@ -9,7 +9,7 @@ def get_modal_workspace(): case Environment.STAGING: return "codegen-sh-staging" case Environment.DEVELOP: - return "codegen-sh-develop" + return "codegen-sh-develop-jay" case _: msg = f"Invalid environment: {global_env.ENV}" raise ValueError(msg) diff --git a/src/codegen/cli/auth/token_manager.py b/src/codegen/cli/auth/token_manager.py index 7e6b6470c..11e7dbb16 100644 --- a/src/codegen/cli/auth/token_manager.py +++ b/src/codegen/cli/auth/token_manager.py @@ -2,9 +2,7 @@ import os from pathlib import Path -from codegen.cli.api.client import RestAPI from codegen.cli.auth.constants import AUTH_FILE, CONFIG_DIR -from codegen.cli.errors import AuthError class TokenManager: @@ -22,14 +20,7 @@ def _ensure_config_dir(self): Path(self.config_dir).mkdir(parents=True, exist_ok=True) def authenticate_token(self, token: str) -> None: - """Authenticate the token with the api.""" - identity = RestAPI(token).identify() - if not identity: - msg = "No identity found for session" - raise AuthError(msg) - if identity.auth_context.status != "active": - msg = "Current session is not active. API Token may be invalid or may have expired." - raise AuthError(msg) + """Store the token locally.""" self.save_token(token) def save_token(self, token: str) -> None: diff --git a/src/codegen/cli/cli.py b/src/codegen/cli/cli.py index 3778d0360..c67d9c921 100644 --- a/src/codegen/cli/cli.py +++ b/src/codegen/cli/cli.py @@ -8,11 +8,13 @@ # Import the actual command functions 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 from codegen.cli.commands.update.main import update install(show_locals=True) @@ -35,10 +37,12 @@ def version_callback(value: bool): 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) -# Config is a group, so add it as a typer +# Add Typer apps as sub-applications main.add_typer(config_command, name="config") +main.add_typer(integrations_app, name="integrations") @main.callback() diff --git a/src/codegen/cli/commands/integrations/__init__.py b/src/codegen/cli/commands/integrations/__init__.py new file mode 100644 index 000000000..82f34a41d --- /dev/null +++ b/src/codegen/cli/commands/integrations/__init__.py @@ -0,0 +1 @@ +"""Integrations command module.""" diff --git a/src/codegen/cli/commands/integrations/main.py b/src/codegen/cli/commands/integrations/main.py new file mode 100644 index 000000000..246feaf0d --- /dev/null +++ b/src/codegen/cli/commands/integrations/main.py @@ -0,0 +1,136 @@ +"""Integrations command for the Codegen CLI.""" + +import webbrowser + +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.utils.url import generate_webapp_url + +console = Console() + +# Create the integrations app +integrations_app = typer.Typer(help="Manage Codegen integrations") + + +@integrations_app.command("list") +def list_integrations(): + """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: + console.print("[red]Error:[/red] Not authenticated. Please run 'codegen login' first.") + raise typer.Exit(1) + + try: + # Make API request to list integrations + headers = {"Authorization": f"Bearer {token}"} + url = f"{API_ENDPOINT.rstrip('/')}/v1/organizations/11/integrations" + response = requests.get(url, headers=headers) + response.raise_for_status() + + response_data = response.json() + + # Extract integrations from the response structure + integrations_data = response_data.get("integrations", []) + organization_name = response_data.get("organization_name", "Unknown") + total_active = response_data.get("total_active_integrations", 0) + + if not integrations_data: + console.print("[yellow]No integrations found.[/yellow]") + return + + # Create a table to display integrations + table = Table( + title=f"Integrations for {organization_name}", + border_style="blue", + show_header=True, + title_justify="center", + ) + table.add_column("Integration", style="cyan", no_wrap=True) + table.add_column("Status", style="white", justify="center") + table.add_column("Type", style="magenta") + table.add_column("Details", style="dim") + + # Add integrations to table + for integration in integrations_data: + integration_type = integration.get("integration_type", "Unknown") + active = integration.get("active", False) + token_id = integration.get("token_id") + installation_id = integration.get("installation_id") + metadata = integration.get("metadata", {}) + + # Status with emoji + status = "āœ… Active" if active else "āŒ Inactive" + + # Determine integration category + if integration_type.endswith("_user"): + category = "User Token" + elif integration_type.endswith("_app"): + category = "App Install" + elif integration_type in ["github", "slack_app", "linear_app"]: + category = "App Install" + else: + category = "Token-based" + + # Build details string + details = [] + if token_id: + details.append(f"Token ID: {token_id}") + if installation_id: + details.append(f"Install ID: {installation_id}") + if metadata and isinstance(metadata, dict): + for key, value in metadata.items(): + if key == "webhook_secret": + details.append(f"{key}: ***secret***") + else: + details.append(f"{key}: {value}") + + details_str = ", ".join(details) if details else "No details" + if len(details_str) > 50: + details_str = details_str[:47] + "..." + + table.add_row(integration_type.replace("_", " ").title(), status, category, details_str) + + console.print(table) + console.print(f"\n[green]Total: {len(integrations_data)} integrations ({total_active} active)[/green]") + + except requests.RequestException as e: + console.print(f"[red]Error fetching integrations:[/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) + + +@integrations_app.command("add") +def add_integration(): + """Open the Codegen integrations page in your browser to add new integrations.""" + console.print("🌐 Opening Codegen integrations page...", style="bold blue") + + # Generate the web URL using the environment-aware utility + web_url = generate_webapp_url("integrations") + + try: + webbrowser.open(web_url) + console.print(f"āœ… Opened [link]{web_url}[/link] in your browser", style="green") + console.print("šŸ’” You can add new integrations from the web interface", style="dim") + except Exception as e: + console.print(f"āŒ Failed to open browser: {e}", style="red") + console.print(f"šŸ”— Please manually visit: {web_url}", style="yellow") + + +# Default callback for the integrations app +@integrations_app.callback(invoke_without_command=True) +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() diff --git a/src/codegen/cli/commands/login/main.py b/src/codegen/cli/commands/login/main.py index 3053a1a15..483547782 100644 --- a/src/codegen/cli/commands/login/main.py +++ b/src/codegen/cli/commands/login/main.py @@ -1,4 +1,3 @@ - import rich import typer diff --git a/src/codegen/cli/commands/mcp/__init__.py b/src/codegen/cli/commands/mcp/__init__.py index e69de29bb..ef4b55200 100644 --- a/src/codegen/cli/commands/mcp/__init__.py +++ b/src/codegen/cli/commands/mcp/__init__.py @@ -0,0 +1 @@ +"""MCP command module.""" diff --git a/src/codegen/cli/commands/mcp/main.py b/src/codegen/cli/commands/mcp/main.py index 4942b7353..5bcc230c8 100644 --- a/src/codegen/cli/commands/mcp/main.py +++ b/src/codegen/cli/commands/mcp/main.py @@ -1,12 +1,49 @@ """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 + console = Console() +def fetch_tools_for_mcp() -> 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) + + console.print("šŸ”§ Fetching available tools from API...", style="dim") + headers = {"Authorization": f"Bearer {token}"} + url = f"{API_ENDPOINT.rstrip('/')}/v1/organizations/11/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)"), @@ -24,14 +61,20 @@ def mcp( # Validate transport if transport not in ["stdio", "http"]: - console.print(f"āŒ Invalid transport: {transport}. Must be 'stdio' or 'http'", style="bold red") + 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() + # 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) + 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: diff --git a/src/codegen/cli/commands/tools/__init__.py b/src/codegen/cli/commands/tools/__init__.py new file mode 100644 index 000000000..2fcf268de --- /dev/null +++ b/src/codegen/cli/commands/tools/__init__.py @@ -0,0 +1 @@ +"""Tools command module.""" diff --git a/src/codegen/cli/commands/tools/main.py b/src/codegen/cli/commands/tools/main.py new file mode 100644 index 000000000..623a3aceb --- /dev/null +++ b/src/codegen/cli/commands/tools/main.py @@ -0,0 +1,90 @@ +"""Tools 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 + +console = Console() + + +def tools(): + """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: + console.print("[red]Error:[/red] Not authenticated. Please run 'codegen login' first.") + raise typer.Exit(1) + + try: + # Make API request to list tools + headers = {"Authorization": f"Bearer {token}"} + url = f"{API_ENDPOINT.rstrip('/')}/v1/organizations/11/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_data = response_data["tools"] + total_count = response_data.get("total_count", len(tools_data)) + else: + tools_data = response_data + total_count = len(tools_data) if isinstance(tools_data, list) else 1 + + if not tools_data: + console.print("[yellow]No tools found.[/yellow]") + return + + # Handle case where response might be a list of strings vs list of objects + if isinstance(tools_data, list) and len(tools_data) > 0: + # Check if first item is a string or object + if isinstance(tools_data[0], str): + # Simple list of tool names + console.print(f"[green]Found {len(tools_data)} tools:[/green]") + for tool_name in tools_data: + console.print(f" • {tool_name}") + return + + # Create a table to display tools (for structured data) + table = Table( + title="Available Tools", + border_style="blue", + show_header=True, + title_justify="center", + ) + table.add_column("Tool Name", style="cyan", no_wrap=True) + table.add_column("Description", style="white") + table.add_column("Category", style="magenta") + + # Add tools to table + for tool in tools_data: + if isinstance(tool, dict): + tool_name = tool.get("name", "Unknown") + description = tool.get("description", "No description available") + category = tool.get("category", "General") + + # Truncate long descriptions + if len(description) > 80: + description = description[:77] + "..." + + table.add_row(tool_name, description, category) + else: + # Fallback for non-dict items + table.add_row(str(tool), "Unknown", "General") + + console.print(table) + console.print(f"\n[green]Found {total_count} tools available.[/green]") + + 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) diff --git a/src/codegen/cli/mcp/server.py b/src/codegen/cli/mcp/server.py index d2ffa0ee8..3239aa805 100644 --- a/src/codegen/cli/mcp/server.py +++ b/src/codegen/cli/mcp/server.py @@ -2,6 +2,7 @@ import os from typing import Annotated, Any +import requests from fastmcp import Context, FastMCP # Import API client components @@ -14,6 +15,10 @@ except ImportError: API_CLIENT_AVAILABLE = False +# Import our own API utilities +from codegen.cli.api.endpoints import API_ENDPOINT +from codegen.cli.auth.token_manager import get_current_token + # Initialize FastMCP server mcp = FastMCP( "codegen-mcp", @@ -56,6 +61,193 @@ def get_api_client(): return _api_client, _agents_api, _organizations_api, _users_api +def execute_tool_via_api(tool_name: str, arguments: dict): + """Execute a tool via the Codegen API.""" + try: + token = get_current_token() + if not token: + return {"error": "Not authenticated. Please run 'codegen login' first."} + + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + url = f"{API_ENDPOINT.rstrip('/')}/v1/organizations/11/tools/execute" + + payload = {"tool_name": tool_name, "arguments": arguments} + + response = requests.post(url, json=payload, headers=headers) + response.raise_for_status() + + return response.json() + + except Exception as e: + return {"error": f"Error executing tool {tool_name}: {e}"} + + +def register_dynamic_tools(available_tools: list): + """Register all available tools from the API as individual MCP tools.""" + import inspect + + for i, tool_info in enumerate(available_tools): + # Skip None or invalid tool entries + if not tool_info or not isinstance(tool_info, dict): + print(f"āš ļø Skipping invalid tool entry at index {i}: {tool_info}") + continue + + try: + tool_name = tool_info.get("name", "unknown_tool") + tool_description = tool_info.get("description", "No description available").replace("'", '"').replace('"', '\\"') + tool_parameters = tool_info.get("parameters", {}) + + # Parse the parameter schema + if tool_parameters is None: + tool_parameters = {} + properties = tool_parameters.get("properties", {}) + required = tool_parameters.get("required", []) + except Exception as e: + print(f"āŒ Error processing tool at index {i}: {e}") + print(f"Tool data: {tool_info}") + continue + + def make_tool_function(name: str, description: str, props: dict, req: list): + # Create function dynamically with proper parameters + def create_dynamic_function(): + # Build parameter list for the function + param_list = [] + param_annotations = {} + + # Collect required and optional parameters separately + required_params = [] + optional_params = [] + + # Add other parameters from schema + for param_name, param_info in props.items(): + param_type = param_info.get("type", "string") + param_desc = param_info.get("description", f"Parameter {param_name}").replace("'", '"').replace('"', '\\"') + is_required = param_name in req + + # Special handling for tool_call_id - always make it optional + if param_name == "tool_call_id": + optional_params.append("tool_call_id: Annotated[str, 'Unique identifier for this tool call'] = 'mcp_call'") + continue + + # Convert JSON schema types to Python types + if param_type == "string": + py_type = "str" + elif param_type == "integer": + py_type = "int" + elif param_type == "number": + py_type = "float" + elif param_type == "boolean": + py_type = "bool" + elif param_type == "array": + items_type = param_info.get("items", {}).get("type", "string") + if items_type == "string": + py_type = "list[str]" + else: + py_type = "list" + else: + py_type = "str" # Default fallback + + # Handle optional parameters (anyOf with null) + if "anyOf" in param_info: + py_type = f"{py_type} | None" + if not is_required: + default_val = param_info.get("default", "None") + if isinstance(default_val, str) and default_val != "None": + default_val = f'"{default_val}"' + optional_params.append(f"{param_name}: Annotated[{py_type}, '{param_desc}'] = {default_val}") + else: + required_params.append(f"{param_name}: Annotated[{py_type}, '{param_desc}']") + elif is_required: + required_params.append(f"{param_name}: Annotated[{py_type}, '{param_desc}']") + else: + # Optional parameter with default + default_val = param_info.get("default", "None") + if isinstance(default_val, str) and default_val not in ["None", "null"]: + default_val = f'"{default_val}"' + elif isinstance(default_val, bool): + default_val = str(default_val) + elif default_val is None or default_val == "null": + default_val = "None" + optional_params.append(f"{param_name}: Annotated[{py_type}, '{param_desc}'] = {default_val}") + + # Only add tool_call_id if it wasn't already in the schema + tool_call_id_found = any("tool_call_id" in param for param in optional_params) + if not tool_call_id_found: + optional_params.append("tool_call_id: Annotated[str, 'Unique identifier for this tool call'] = 'mcp_call'") + + # Combine required params first, then optional params + param_list = required_params + optional_params + + # Create the function code + params_str = ", ".join(param_list) + + # Create a list of parameter names for the function + param_names = [] + for param in param_list: + # Extract parameter name from the type annotation + param_name = param.split(":")[0].strip() + param_names.append(param_name) + + param_names_str = repr(param_names) + + func_code = f""" +def tool_function({params_str}) -> str: + '''Dynamically created tool function: {description}''' + # Collect all parameters by name to avoid circular references + param_names = {param_names_str} + arguments = {{}} + + # Get the current frame's local variables + import inspect + frame = inspect.currentframe() + try: + locals_dict = frame.f_locals + for param_name in param_names: + if param_name in locals_dict: + value = locals_dict[param_name] + # Handle None values and ensure JSON serializable + if value is not None: + arguments[param_name] = value + finally: + del frame + + # Execute the tool via API + result = execute_tool_via_api('{name}', arguments) + + # Return formatted result + return json.dumps(result, indent=2) +""" + + # Execute the function code to create the function + namespace = {"Annotated": Annotated, "json": json, "execute_tool_via_api": execute_tool_via_api, "inspect": inspect} + try: + exec(func_code, namespace) + func = namespace["tool_function"] + except SyntaxError as e: + print(f"āŒ Syntax error in tool {name}:") + print(f"Error: {e}") + print("Generated code:") + for i, line in enumerate(func_code.split("\n"), 1): + print(f"{i:3}: {line}") + raise + + # Set metadata + func.__name__ = name.replace("-", "_") + func.__doc__ = description + + return func + + return create_dynamic_function() + + # Create the tool function + tool_func = make_tool_function(tool_name, tool_description, properties, required) + + # Register with FastMCP using the decorator + decorated_func = mcp.tool()(tool_func) + + print(f"āœ… Registered dynamic tool: {tool_name}") + + # ----- RESOURCES ----- @@ -69,10 +261,7 @@ def get_service_config() -> dict[str, Any]: } -# ----- TOOLS ----- - - -# ----- CODEGEN API TOOLS ----- +# ----- STATIC CODEGEN API TOOLS ----- @mcp.tool() @@ -212,8 +401,14 @@ def get_user( return f"Error getting user: {e}" -def run_server(transport: str = "stdio", host: str = "localhost", port: int | None = None): +def run_server(transport: str = "stdio", host: str = "localhost", port: int | None = None, available_tools: list | None = None): """Run the MCP server with the specified transport.""" + # Register dynamic tools if provided + if available_tools: + print("šŸ”§ Registering dynamic tools from API...") + register_dynamic_tools(available_tools) + print(f"āœ… Registered {len(available_tools)} dynamic tools") + if transport == "stdio": print("šŸš€ MCP server running on stdio transport") mcp.run(transport="stdio") diff --git a/src/codegen/cli/utils/url.py b/src/codegen/cli/utils/url.py index deda09c8d..d12b3de83 100644 --- a/src/codegen/cli/utils/url.py +++ b/src/codegen/cli/utils/url.py @@ -6,7 +6,7 @@ class DomainRegistry(Enum): STAGING = "chadcode.sh" - PRODUCTION = "codegen.sh" + PRODUCTION = "codegen.com" LOCAL = "localhost:3000"