diff --git a/docs/docs.json b/docs/docs.json index f5d8f21a6..52b11ea2b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1,129 +1,138 @@ { - "$schema": "https://mintlify.com/docs.json", - "theme": "maple", - "name": "Codegen Documentation", - "colors": { - "primary": "#a277ff", - "light": "#a277ff", - "dark": "#a277ff" - }, - "favicon": "/favicon.svg", - "navigation": { - "tabs": [ - { - "tab": "Documentation", - "groups": [ - { - "group": "Overview", - "pages": [ - "introduction/overview", - "introduction/api", - "introduction/prompting", - "introduction/community", - "introduction/about", - "introduction/faq" - ] - }, - { - "group": "Capabilities", - "pages": ["capabilities/capabilities", "capabilities/wake-up", "capabilities/interrupts"] - }, - { - "group": "Integrations", - "pages": [ - "integrations/github", - "integrations/slack", - "integrations/linear", - "integrations/jira", - "integrations/notion", - "integrations/figma", - "integrations/circleci", - "integrations/web-search", - "integrations/postgres", - "integrations/mcp" - ] - }, - { - "group": "Sandboxes", - "pages": [ - "sandboxes/overview", - "sandboxes/setup-commands", - "sandboxes/image-snapshots", - "sandboxes/environment-variables", - "sandboxes/secrets", - "sandboxes/editor", - "sandboxes/web-preview" - ] - }, - { - "group": "Settings", - "pages": ["settings/repo-rules", "settings/model-configuration"] - } - ] - }, - { - "tab": "API Reference", - "groups": [ - { - "group": "Endpoints", - "openapi": { - "source": "/api-reference/openapi3.json", - "directory": "api-reference" - } - }, - { - "group": "Guides", - "pages": ["api-reference/agent-run-logs", "api-reference/github-actions"] - } - ] - } - ] - }, - "logo": { - "light": "https://cdn.prod.website-files.com/67070304751b9b01bf6a161c/679bcf45bf55446746125835_Codegen_Logomark_Light.svg", - "dark": "https://cdn.prod.website-files.com/67070304751b9b01bf6a161c/679bcf45a3e32761c42b324b_Codegen_Logomark_Dark.svg" - }, - "appearance": { - "default": "dark" - }, - "background": { - "decoration": "gradient" - }, - "navbar": { - "primary": { - "type": "button", - "label": "GitHub", - "href": "https://github.com/codegen-sh/codegen-sdk" - } - }, - "seo": { - "metatags": { - "og:site_name": "Codegen Documentation", - "og:title": "Codegen Documentation - 10x Your Engineering", - "og:description": "Complete documentation for Codegen, the AI-powered software engineering agent. Learn how to integrate with GitHub, Slack, Linear, and more.", - "og:url": "https://docs.codegen.com", - "og:locale": "en_US", - "og:logo": "https://i.imgur.com/f4OVOqI.png", - "article:publisher": "Codegen, Inc.", - "twitter:site": "@codegen" - }, - "indexing": "navigable" - }, - "footer": { - "socials": { - "x": "https://x.com/codegen", - "linkedin": "https://linkedin.com/company/codegen-dot-com" - } - }, - "integrations": { - "posthog": { - "apiKey": "phc_GLxaINoQJnuyCyxDmTciQqzdKBYFVDkY7bRBO4bDdso" - } - }, - "head": [ - { - "tag": "script", - "content": "(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','GTM-T7G5S78D');" - } - ] + "$schema": "https://mintlify.com/docs.json", + "theme": "maple", + "name": "Codegen", + "colors": { + "primary": "#a277ff", + "light": "#a277ff", + "dark": "#a277ff" + }, + "favicon": "/favicon.svg", + "navigation": { + "tabs": [ + { + "tab": "Documentation", + "groups": [ + { + "group": "Overview", + "pages": [ + "introduction/overview", + "introduction/api", + "introduction/prompting", + "introduction/community", + "introduction/about", + "introduction/faq" + ] + }, + { + "group": "Capabilities", + "pages": ["capabilities/capabilities", "capabilities/wake-up"] + }, + { + "group": "Integrations", + "pages": [ + "integrations/github", + "integrations/slack", + "integrations/linear", + "integrations/notion", + "integrations/figma", + "integrations/circleci", + "integrations/web-search", + "integrations/postgres" + ] + }, + { + "group": "Sandboxes", + "pages": [ + "sandboxes/overview", + "sandboxes/setup-commands", + "sandboxes/environment-variables", + "sandboxes/secrets", + "sandboxes/editor", + "sandboxes/web-preview" + ] + }, + { + "group": "Settings", + "pages": ["settings/repo-rules", "settings/model-configuration"] + } + ] + }, + { + "tab": "API Reference", + "groups": [ + { + "group": "Endpoints", + "openapi": { + "source": "/api-reference/openapi3.json", + "directory": "api-reference" + } + }, + { + "group": "Guides", + "pages": ["api-reference/agent-run-logs"] + } + ] + }, + { + "tab": "Blog", + "groups": [ + { + "group": "Blog", + "pages": ["blog/posts", "blog/devin", "blog/act-via-code"] + } + ] + }, + { + "tab": "Changelog", + "groups": [ + { + "group": "Changelog", + "pages": ["changelog/changelog"] + } + ] + } + ] + }, + "logo": { + "light": "https://cdn.prod.website-files.com/67070304751b9b01bf6a161c/679bcf45bf55446746125835_Codegen_Logomark_Light.svg", + "dark": "https://cdn.prod.website-files.com/67070304751b9b01bf6a161c/679bcf45a3e32761c42b324b_Codegen_Logomark_Dark.svg" + }, + "appearance": { + "default": "dark" + }, + "background": { + "decoration": "gradient" + }, + "navbar": { + "primary": { + "type": "button", + "label": "GitHub", + "href": "https://github.com/codegen-sh/codegen-sdk" + } + }, + "seo": { + "metatags": { + "og:site_name": "Codegen", + "og:title": "Codegen - The SWE that Never Sleeps", + "og:description": "Code agents accessible via API, Slack, Linear, Github, and more.", + "og:url": "https://docs.codegen.com", + "og:locale": "en_US", + "og:logo": "https://i.imgur.com/f4OVOqI.png", + "article:publisher": "Codegen, Inc.", + "twitter:site": "@codegen" + }, + "indexing": "navigable" + }, + "footer": { + "socials": { + "x": "https://x.com/codegen", + "linkedin": "https://linkedin.com/company/codegen-dot-com" + } + }, + "integrations": { + "posthog": { + "apiKey": "phc_GLxaINoQJnuyCyxDmTciQqzdKBYFVDkY7bRBO4bDdso" + } + } } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..0a58e05a9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "codegen-sdk", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/src/codegen/agents/agent.py b/src/codegen/agents/agent.py index 03e98f1dc..78179396d 100644 --- a/src/codegen/agents/agent.py +++ b/src/codegen/agents/agent.py @@ -8,6 +8,7 @@ from codegen_api_client.models.create_agent_run_input import CreateAgentRunInput from codegen.agents.constants import CODEGEN_BASE_API_URL +from codegen.cli.utils.org import resolve_org_id class AgentTask: @@ -53,7 +54,11 @@ def __init__(self, token: str | None, org_id: int | None = None, base_url: str | org_id: Optional organization ID. If not provided, default org will be used. """ self.token = token - self.org_id = org_id or int(os.environ.get("CODEGEN_ORG_ID", "1")) # Default to org ID 1 if not specified + resolved_org = resolve_org_id(org_id) + if resolved_org is None: + # Keep previous behavior only as last resort to avoid exceptions in legacy paths + resolved_org = int(os.environ.get("CODEGEN_ORG_ID", "1")) + self.org_id = resolved_org # Configure API client config = Configuration(host=base_url, access_token=token) diff --git a/src/codegen/agents/constants.py b/src/codegen/agents/constants.py index ec94f38e5..243ab197b 100644 --- a/src/codegen/agents/constants.py +++ b/src/codegen/agents/constants.py @@ -1 +1,6 @@ -CODEGEN_BASE_API_URL = "https://codegen-sh--rest-api.modal.run" +import os + +from codegen.cli.api.endpoints import API_ENDPOINT + +# Prefer explicit override; fall back to the CLI's unified API endpoint +CODEGEN_BASE_API_URL = os.environ.get("CODEGEN_API_BASE_URL", API_ENDPOINT.rstrip("/")) 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..9bdb69695 100644 --- a/src/codegen/cli/api/endpoints.py +++ b/src/codegen/cli/api/endpoints.py @@ -1,3 +1,5 @@ +import os + from codegen.cli.api.modal import MODAL_PREFIX RUN_ENDPOINT = f"https://{MODAL_PREFIX}--cli-run.modal.run" @@ -11,3 +13,8 @@ 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" +MCP_SERVER_ENDPOINT = f"https://{MODAL_PREFIX}--codegen-mcp-server.modal.run/mcp" + +# API ENDPOINT +# Prefer explicit override via CODEGEN_API_BASE_URL; fallback to Modal-derived URL for current ENV +API_ENDPOINT = os.environ.get("CODEGEN_API_BASE_URL", f"https://{MODAL_PREFIX}--rest-api.modal.run/") 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/claude/__init__.py b/src/codegen/cli/claude/__init__.py new file mode 100644 index 000000000..f16cc633c --- /dev/null +++ b/src/codegen/cli/claude/__init__.py @@ -0,0 +1 @@ +"""Claude Code proxy server and utilities.""" diff --git a/src/codegen/cli/cli.py b/src/codegen/cli/cli.py index 3778d0360..862ecd0b6 100644 --- a/src/codegen/cli/cli.py +++ b/src/codegen/cli/cli.py @@ -3,16 +3,19 @@ from codegen import __version__ +# 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 - -# 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) @@ -29,16 +32,19 @@ def version_callback(value: bool): main = typer.Typer(name="codegen", help="Codegen CLI - Transform your code with AI.", 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) -# 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/claude/__init__.py b/src/codegen/cli/commands/claude/__init__.py new file mode 100644 index 000000000..82ffa52a8 --- /dev/null +++ b/src/codegen/cli/commands/claude/__init__.py @@ -0,0 +1,2 @@ +"""Claude Code integration commands.""" + diff --git a/src/codegen/cli/commands/claude/claude_log_utils.py b/src/codegen/cli/commands/claude/claude_log_utils.py new file mode 100644 index 000000000..a043aa84b --- /dev/null +++ b/src/codegen/cli/commands/claude/claude_log_utils.py @@ -0,0 +1,126 @@ +"""Utilities for Claude Code session log management.""" + +import json +import os +import re +from pathlib import Path +from typing import Dict, Any, Optional + + +def get_hyphenated_cwd() -> str: + """Convert current working directory to hyphenated format for Claude log path. + + Returns: + Hyphenated directory name (e.g., "/Users/john/project" -> "users-john-project") + """ + cwd = os.getcwd() + # Remove leading slash and replace slashes and spaces with hyphens + hyphenated = cwd.replace('/', '-').replace(' ', '-') + # Remove any double hyphens + hyphenated = re.sub(r'-+', '-', hyphenated) + return hyphenated + + +def get_claude_session_log_path(session_id: str) -> Path: + """Get the path to the Claude session log file. + + Args: + session_id: The Claude session ID + + Returns: + Path to the session log file + """ + claude_dir = Path.home() / ".claude" + projects_dir = claude_dir / "projects" + hyphenated_cwd = get_hyphenated_cwd() + project_dir = projects_dir / hyphenated_cwd + + log_file = project_dir / f"{session_id}.jsonl" + return log_file + + +def parse_jsonl_line(line: str) -> Optional[Dict[str, Any]]: + """Parse a single line from a JSONL file. + + Args: + line: Raw line from JSONL file + + Returns: + Parsed JSON object or None if parsing fails + """ + line = line.strip() + if not line: + return None + + try: + return json.loads(line) + except json.JSONDecodeError: + return None + + +def ensure_log_directory(session_id: str) -> Path: + """Ensure the log directory exists and return the log file path. + + Args: + session_id: The Claude session ID + + Returns: + Path to the session log file + """ + log_path = get_claude_session_log_path(session_id) + log_path.parent.mkdir(parents=True, exist_ok=True) + return log_path + + +def read_existing_log_lines(log_path: Path) -> int: + """Count existing lines in a log file. + + Args: + log_path: Path to the log file + + Returns: + Number of existing lines + """ + if not log_path.exists(): + return 0 + + try: + with open(log_path, 'r', encoding='utf-8') as f: + return sum(1 for _ in f) + except (OSError, UnicodeDecodeError): + return 0 + + +def validate_log_entry(log_entry: Dict[str, Any]) -> bool: + """Validate a log entry before sending to API. + + Args: + log_entry: The log entry to validate + + Returns: + True if valid, False otherwise + """ + if not isinstance(log_entry, dict): + return False + + # Basic validation - ensure it has some content + if not log_entry: + return False + + # Optionally validate specific fields that Claude Code uses + # This can be expanded based on actual Claude log format + return True + + +def format_log_for_api(log_entry: Dict[str, Any]) -> Dict[str, Any]: + """Format a log entry for sending to the API. + + Args: + log_entry: Raw log entry from Claude + + Returns: + Formatted log entry ready for API + """ + # For now, pass through as-is since API expects dict[str, Any] + # This can be enhanced to transform or filter fields as needed + return log_entry \ No newline at end of file diff --git a/src/codegen/cli/commands/claude/claude_log_watcher.py b/src/codegen/cli/commands/claude/claude_log_watcher.py new file mode 100644 index 000000000..7ddd8ffe7 --- /dev/null +++ b/src/codegen/cli/commands/claude/claude_log_watcher.py @@ -0,0 +1,303 @@ +"""Claude Code session log watcher implementation.""" + +import time +import threading +from pathlib import Path +from typing import Optional, Callable, Dict, Any + +from .quiet_console import console + +from .claude_log_utils import ( + get_claude_session_log_path, + parse_jsonl_line, + read_existing_log_lines, + validate_log_entry, + format_log_for_api +) +from .claude_session_api import send_claude_session_log + + +class ClaudeLogWatcher: + """Watches Claude Code session log files for new entries and sends them to the API.""" + + def __init__( + self, + session_id: str, + org_id: Optional[int] = None, + poll_interval: float = 1.0, + on_log_entry: Optional[Callable[[Dict[str, Any]], None]] = None + ): + """Initialize the log watcher. + + Args: + session_id: The Claude session ID to watch + org_id: Organization ID for API calls + poll_interval: How often to check for new entries (seconds) + on_log_entry: Optional callback for each new log entry + """ + self.session_id = session_id + self.org_id = org_id + self.poll_interval = poll_interval + self.on_log_entry = on_log_entry + + self.log_path = get_claude_session_log_path(session_id) + self.last_line_count = 0 + self.is_running = False + self.watcher_thread: Optional[threading.Thread] = None + + # Stats + self.total_entries_processed = 0 + self.total_entries_sent = 0 + self.total_send_failures = 0 + + def start(self) -> bool: + """Start the log watcher in a background thread. + + Returns: + True if started successfully, False otherwise + """ + if self.is_running: + console.print(f"āš ļø Log watcher for session {self.session_id[:8]}... is already running", style="yellow") + return False + + # Initialize line count + self.last_line_count = read_existing_log_lines(self.log_path) + + self.is_running = True + self.watcher_thread = threading.Thread(target=self._watch_loop, daemon=True) + self.watcher_thread.start() + + console.print(f"šŸ“‹ Started log watcher for session {self.session_id[:8]}...", style="green") + console.print(f" Log file: {self.log_path}", style="dim") + console.print(f" Starting from line: {self.last_line_count + 1}", style="dim") + + return True + + def stop(self) -> None: + """Stop the log watcher.""" + if not self.is_running: + return + + self.is_running = False + + if self.watcher_thread and self.watcher_thread.is_alive(): + self.watcher_thread.join(timeout=2.0) + + console.print(f"šŸ“‹ Stopped log watcher for session {self.session_id[:8]}...", style="dim") + console.print(f" Processed: {self.total_entries_processed} entries", style="dim") + console.print(f" Sent: {self.total_entries_sent} entries", style="dim") + if self.total_send_failures > 0: + console.print(f" Failures: {self.total_send_failures} entries", style="yellow") + + def _watch_loop(self) -> None: + """Main watching loop that runs in a background thread.""" + while self.is_running: + try: + self._check_for_new_entries() + time.sleep(self.poll_interval) + except Exception as e: + console.print(f"āš ļø Error in log watcher: {e}", style="yellow") + time.sleep(self.poll_interval * 2) # Back off on errors + + def _check_for_new_entries(self) -> None: + """Check for new log entries and process them.""" + if not self.log_path.exists(): + return + + try: + current_line_count = read_existing_log_lines(self.log_path) + + if current_line_count > self.last_line_count: + new_entries = self._read_new_lines(self.last_line_count, current_line_count) + + for entry in new_entries: + self._process_log_entry(entry) + + self.last_line_count = current_line_count + + except Exception as e: + console.print(f"āš ļø Error reading log file: {e}", style="yellow") + + def _read_new_lines(self, start_line: int, end_line: int) -> list[Dict[str, Any]]: + """Read new lines from the log file. + + Args: + start_line: Line number to start from (0-indexed) + end_line: Line number to end at (0-indexed, exclusive) + + Returns: + List of parsed log entries + """ + entries = [] + + try: + with open(self.log_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + + # Read only the new lines + for i in range(start_line, min(end_line, len(lines))): + line = lines[i] + entry = parse_jsonl_line(line) + + if entry is not None: + entries.append(entry) + + except (OSError, UnicodeDecodeError) as e: + console.print(f"āš ļø Error reading log file: {e}", style="yellow") + + return entries + + def _process_log_entry(self, log_entry: Dict[str, Any]) -> None: + """Process a single log entry. + + Args: + log_entry: The parsed log entry + """ + self.total_entries_processed += 1 + + # Validate the entry + if not validate_log_entry(log_entry): + console.print(f"āš ļø Invalid log entry skipped: {log_entry}", style="yellow") + return + + # Format for API + formatted_entry = format_log_for_api(log_entry) + + # Call optional callback + if self.on_log_entry: + try: + self.on_log_entry(formatted_entry) + except Exception as e: + console.print(f"āš ļø Error in log entry callback: {e}", style="yellow") + + # Send to API + self._send_log_entry(formatted_entry) + + def _send_log_entry(self, log_entry: Dict[str, Any]) -> None: + """Send a log entry to the API. + + Args: + log_entry: The formatted log entry + """ + try: + success = send_claude_session_log(self.session_id, log_entry, self.org_id) + + if success: + self.total_entries_sent += 1 + # Only show verbose output in debug mode + console.print(f"šŸ“¤ Sent log entry: {log_entry.get('type', 'unknown')}", style="dim") + else: + self.total_send_failures += 1 + + except Exception as e: + self.total_send_failures += 1 + console.print(f"āš ļø Failed to send log entry: {e}", style="yellow") + + def get_stats(self) -> Dict[str, Any]: + """Get watcher statistics. + + Returns: + Dictionary with watcher stats + """ + return { + "session_id": self.session_id, + "is_running": self.is_running, + "log_path": str(self.log_path), + "log_file_exists": self.log_path.exists(), + "last_line_count": self.last_line_count, + "total_entries_processed": self.total_entries_processed, + "total_entries_sent": self.total_entries_sent, + "total_send_failures": self.total_send_failures, + "success_rate": ( + self.total_entries_sent / max(1, self.total_entries_processed) * 100 + if self.total_entries_processed > 0 else 0 + ) + } + + +class ClaudeLogWatcherManager: + """Manages multiple log watchers for different sessions.""" + + def __init__(self): + self.watchers: Dict[str, ClaudeLogWatcher] = {} + + def start_watcher( + self, + session_id: str, + org_id: Optional[int] = None, + poll_interval: float = 1.0, + on_log_entry: Optional[Callable[[Dict[str, Any]], None]] = None + ) -> bool: + """Start a log watcher for a session. + + Args: + session_id: The Claude session ID + org_id: Organization ID for API calls + poll_interval: How often to check for new entries (seconds) + on_log_entry: Optional callback for each new log entry + + Returns: + True if started successfully, False otherwise + """ + if session_id in self.watchers: + console.print(f"āš ļø Watcher for session {session_id[:8]}... already exists", style="yellow") + return False + + watcher = ClaudeLogWatcher( + session_id=session_id, + org_id=org_id, + poll_interval=poll_interval, + on_log_entry=on_log_entry + ) + + if watcher.start(): + self.watchers[session_id] = watcher + return True + return False + + def stop_watcher(self, session_id: str) -> None: + """Stop a log watcher for a session. + + Args: + session_id: The Claude session ID + """ + if session_id in self.watchers: + self.watchers[session_id].stop() + del self.watchers[session_id] + + def stop_all_watchers(self) -> None: + """Stop all active watchers.""" + for session_id in list(self.watchers.keys()): + self.stop_watcher(session_id) + + def get_active_sessions(self) -> list[str]: + """Get list of active session IDs being watched. + + Returns: + List of session IDs + """ + return list(self.watchers.keys()) + + def get_watcher_stats(self, session_id: str) -> Optional[Dict[str, Any]]: + """Get stats for a specific watcher. + + Args: + session_id: The Claude session ID + + Returns: + Watcher stats or None if not found + """ + if session_id in self.watchers: + return self.watchers[session_id].get_stats() + return None + + def get_all_stats(self) -> Dict[str, Dict[str, Any]]: + """Get stats for all active watchers. + + Returns: + Dictionary mapping session IDs to their stats + """ + return { + session_id: watcher.get_stats() + for session_id, watcher in self.watchers.items() + } \ No newline at end of file diff --git a/src/codegen/cli/commands/claude/claude_session_api.py b/src/codegen/cli/commands/claude/claude_session_api.py new file mode 100644 index 000000000..a931f0561 --- /dev/null +++ b/src/codegen/cli/commands/claude/claude_session_api.py @@ -0,0 +1,227 @@ +"""API client for Claude Code session management.""" + +import json +import uuid +from typing import Optional + +import requests +from .quiet_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 + + + +class ClaudeSessionAPIError(Exception): + """Exception raised for Claude session API errors.""" + pass + + +def generate_session_id() -> str: + """Generate a unique session ID for Claude Code session tracking.""" + return str(uuid.uuid4()) + + +def create_claude_session(session_id: str, org_id: Optional[int] = None) -> Optional[str]: + """Create a new Claude Code session in the backend. + + Args: + session_id: The session ID to register + org_id: Organization ID (will be resolved if None) + + Returns: + Agent run ID if successful, None if failed + + Raises: + ClaudeSessionAPIError: If the API call fails + """ + try: + # Resolve org_id + resolved_org_id = resolve_org_id(org_id) + if resolved_org_id is None: + console.print("āš ļø Could not resolve organization ID for session creation", style="yellow") + return None + + # Get authentication token + token = get_current_token() + if not token: + console.print("āš ļø No authentication token found for session creation", style="yellow") + return None + + # Prepare API request + url = f"{API_ENDPOINT.rstrip('/')}/v1/organizations/{resolved_org_id}/claude_code/session" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + payload = {"session_id": session_id} + + # Make API request + response = requests.post(url, json=payload, headers=headers, timeout=30) + + if response.status_code == 200: + try: + result = response.json() + agent_run_id = result.get("agent_run_id") + return agent_run_id + except (json.JSONDecodeError, KeyError) as e: + console.print(f"āš ļø Invalid response format from session creation: {e}", style="yellow") + return None + else: + error_msg = f"HTTP {response.status_code}" + try: + error_detail = response.json().get("detail", response.text) + error_msg = f"{error_msg}: {error_detail}" + except Exception: + error_msg = f"{error_msg}: {response.text}" + + console.print(f"āš ļø Failed to create Claude session: {error_msg}", style="yellow") + return None + + except requests.RequestException as e: + console.print(f"āš ļø Network error creating Claude session: {e}", style="yellow") + return None + except Exception as e: + console.print(f"āš ļø Unexpected error creating Claude session: {e}", style="yellow") + return None + + +def end_claude_session(session_id: str, status: str, org_id: Optional[int] = None) -> bool: + """End a Claude Code session in the backend. + + Args: + session_id: The session ID to end + status: Completion status ("COMPLETE" or "ERROR") + org_id: Organization ID (will be resolved if None) + + Returns: + True if successful, False if failed + """ + try: + # Resolve org_id + resolved_org_id = resolve_org_id(org_id) + if resolved_org_id is None: + console.print("āš ļø Could not resolve organization ID for session completion", style="yellow") + return False + + # Get authentication token + token = get_current_token() + if not token: + console.print("āš ļø No authentication token found for session completion", style="yellow") + return False + + # Prepare API request + url = f"{API_ENDPOINT.rstrip('/')}/v1/organizations/{resolved_org_id}/claude_code/session/{session_id}/status" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + payload = {"status": status} + + # Make API request + response = requests.post(url, json=payload, headers=headers, timeout=30) + + if response.status_code == 200: + status_emoji = "āœ…" if status == "COMPLETE" else "āŒ" + console.print(f"{status_emoji} Ended Claude session {session_id[:8]}... with status {status}", style="green") + return True + else: + error_msg = f"HTTP {response.status_code}" + try: + error_detail = response.json().get("detail", response.text) + error_msg = f"{error_msg}: {error_detail}" + except Exception: + error_msg = f"{error_msg}: {response.text}" + + console.print(f"āš ļø Failed to end Claude session: {error_msg}", style="yellow") + return False + + except requests.RequestException as e: + console.print(f"āš ļø Network error ending Claude session: {e}", style="yellow") + return False + except Exception as e: + console.print(f"āš ļø Unexpected error ending Claude session: {e}", style="yellow") + return False + + +def send_claude_session_log(session_id: str, log_entry: dict, org_id: Optional[int] = None) -> bool: + """Send a log entry to the Claude Code session log endpoint. + + Args: + session_id: The session ID + log_entry: The log entry to send (dict) + org_id: Organization ID (will be resolved if None) + + Returns: + True if successful, False if failed + """ + try: + # Resolve org_id + resolved_org_id = resolve_org_id(org_id) + if resolved_org_id is None: + console.print("āš ļø Could not resolve organization ID for log sending", style="yellow") + return False + + # Get authentication token + token = get_current_token() + if not token: + console.print("āš ļø No authentication token found for log sending", style="yellow") + return False + + # Prepare API request + url = f"{API_ENDPOINT.rstrip('/')}/v1/organizations/{resolved_org_id}/claude_code/session/{session_id}/log" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + payload = {"log": log_entry} + + # Make API request + response = requests.post(url, json=payload, headers=headers, timeout=30) + + if response.status_code == 200: + return True + else: + error_msg = f"HTTP {response.status_code}" + try: + error_detail = response.json().get("detail", response.text) + error_msg = f"{error_msg}: {error_detail}" + except Exception: + error_msg = f"{error_msg}: {response.text}" + + console.print(f"āš ļø Failed to send log entry: {error_msg}", style="yellow") + return False + + except requests.RequestException as e: + console.print(f"āš ļø Network error sending log entry: {e}", style="yellow") + return False + except Exception as e: + console.print(f"āš ļø Unexpected error sending log entry: {e}", style="yellow") + return False + + +def write_session_hook_data(session_id: str, org_id: Optional[int] = None) -> str: + """Write session data for Claude hook and create session via API. + + This function is called by the Claude hook to both write session data locally + and create the session in the backend API. + + Args: + session_id: The session ID + org_id: Organization ID + + Returns: + JSON string to write to the session file + """ + # Create session in backend API + agent_run_id = create_claude_session(session_id, org_id) + + # Prepare session data + session_data = { + "session_id": session_id, + "agent_run_id": agent_run_id, + "org_id": resolve_org_id(org_id) + } + + return json.dumps(session_data, indent=2) \ No newline at end of file diff --git a/src/codegen/cli/commands/claude/config/claude_session_active_hook.py b/src/codegen/cli/commands/claude/config/claude_session_active_hook.py new file mode 100644 index 000000000..23404ba68 --- /dev/null +++ b/src/codegen/cli/commands/claude/config/claude_session_active_hook.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Claude Code user prompt submit hook for API integration. + +This script is called by Claude Code on UserPromptSubmit to: +1. Read the session context (session_id, org_id) +2. Send an ACTIVE status to the backend API +""" + +import json +import os +import sys +from pathlib import Path + +# Add the codegen CLI to the path so we can import from it +script_dir = Path(__file__).parent +codegen_cli_dir = script_dir.parent.parent.parent +sys.path.insert(0, str(codegen_cli_dir)) + +try: + from codegen.cli.commands.claude.claude_session_api import end_claude_session +except ImportError: + end_claude_session = None + + +def read_session_file() -> dict: + """Read session data written by the SessionStart hook, if available.""" + session_path = Path.home() / ".codegen" / "claude-session.json" + if not session_path.exists(): + return {} + try: + with open(session_path) as f: + return json.load(f) + except Exception: + return {} + + +def main(): + try: + # Prefer environment variables set by the CLI wrapper + session_id = os.environ.get("CODEGEN_CLAUDE_SESSION_ID") + org_id = os.environ.get("CODEGEN_CLAUDE_ORG_ID") + + # Fallback to reading the session file + if not session_id or not org_id: + data = read_session_file() + session_id = session_id or data.get("session_id") + org_id = org_id or data.get("org_id") + + # Normalize org_id type + if isinstance(org_id, str): + try: + org_id = int(org_id) + except ValueError: + org_id = None + + if end_claude_session and session_id: + end_claude_session(session_id, "ACTIVE", org_id) + + # Print minimal output + print(json.dumps({ + "session_id": session_id, + "status": "ACTIVE" + })) + + except Exception as e: + print(json.dumps({"error": str(e)})) + + +if __name__ == "__main__": + main() + diff --git a/src/codegen/cli/commands/claude/config/claude_session_hook.py b/src/codegen/cli/commands/claude/config/claude_session_hook.py new file mode 100755 index 000000000..d72dde9ab --- /dev/null +++ b/src/codegen/cli/commands/claude/config/claude_session_hook.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Claude Code session hook script for API integration. + +This script is called by Claude Code on SessionStart to: +1. Create a session in the backend API +2. Write session data to local file for tracking +""" + +import json +import os +import sys +from pathlib import Path + +# Add the codegen CLI to the path so we can import from it +script_dir = Path(__file__).parent +codegen_cli_dir = script_dir.parent.parent.parent +sys.path.insert(0, str(codegen_cli_dir)) + +try: + from codegen.cli.commands.claude.claude_session_api import create_claude_session + from codegen.cli.utils.org import resolve_org_id +except ImportError: + # Fallback if imports fail - just write basic session data + create_claude_session = None + resolve_org_id = None + + +def main(): + """Main hook function called by Claude Code.""" + try: + # Read hook input from stdin (Claude passes JSON data) + input_data = {} + try: + if not sys.stdin.isatty(): + input_text = sys.stdin.read().strip() + if input_text: + input_data = json.loads(input_text) + except (json.JSONDecodeError, Exception): + # If we can't read the input, continue with empty data + pass + + # Get session ID from environment variable (set by main.py) + session_id = os.environ.get("CODEGEN_CLAUDE_SESSION_ID") + if not session_id: + # Fallback: try to extract from input data + session_id = input_data.get("session_id") + + if not session_id: + # Generate a basic session ID if none available + import uuid + session_id = str(uuid.uuid4()) + + # Get org_id from environment variable (set by main.py) + org_id_str = os.environ.get("CODEGEN_CLAUDE_ORG_ID") + org_id = None + if org_id_str: + try: + org_id = int(org_id_str) + except ValueError: + pass + + # If we don't have org_id, try to resolve it + if org_id is None and resolve_org_id: + org_id = resolve_org_id(None) + + # Create session via API if available + agent_run_id = None + if create_claude_session and org_id: + agent_run_id = create_claude_session(session_id, org_id) + + # Prepare session data + session_data = { + "session_id": session_id, + "agent_run_id": agent_run_id, + "org_id": org_id, + "hook_event": input_data.get("hook_event_name"), + "timestamp": input_data.get("timestamp") + } + + # Output the session data (this gets written to the session file by the hook command) + print(json.dumps(session_data, indent=2)) + + except Exception as e: + # If anything fails, at least output basic session data + session_id = os.environ.get("CODEGEN_CLAUDE_SESSION_ID", "unknown") + fallback_data = { + "session_id": session_id, + "error": str(e), + "agent_run_id": None, + "org_id": None + } + print(json.dumps(fallback_data, indent=2)) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/codegen/cli/commands/claude/config/claude_session_stop_hook.py b/src/codegen/cli/commands/claude/config/claude_session_stop_hook.py new file mode 100644 index 000000000..9ea1e03ef --- /dev/null +++ b/src/codegen/cli/commands/claude/config/claude_session_stop_hook.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Claude Code stop hook script for API integration. + +This script is called by Claude Code on Stop to: +1. Read the session context (session_id, org_id) +2. Send a COMPLETE status to the backend API +""" + +import json +import os +import sys +from pathlib import Path + +# Add the codegen CLI to the path so we can import from it +script_dir = Path(__file__).parent +codegen_cli_dir = script_dir.parent.parent.parent +sys.path.insert(0, str(codegen_cli_dir)) + +try: + from codegen.cli.commands.claude.claude_session_api import end_claude_session +except ImportError: + end_claude_session = None + + +def read_session_file() -> dict: + """Read session data written by the SessionStart hook, if available.""" + session_path = Path.home() / ".codegen" / "claude-session.json" + if not session_path.exists(): + return {} + try: + with open(session_path) as f: + return json.load(f) + except Exception: + return {} + + +def main(): + try: + # Prefer environment variables set by the CLI wrapper + session_id = os.environ.get("CODEGEN_CLAUDE_SESSION_ID") + org_id = os.environ.get("CODEGEN_CLAUDE_ORG_ID") + + # Fallback to reading the session file + if not session_id or not org_id: + data = read_session_file() + session_id = session_id or data.get("session_id") + org_id = org_id or data.get("org_id") + + # Normalize org_id type + if isinstance(org_id, str): + try: + org_id = int(org_id) + except ValueError: + org_id = None + + if end_claude_session and session_id: + end_claude_session(session_id, "COMPLETE", org_id) + + # Print minimal output to avoid noisy hooks + print(json.dumps({ + "session_id": session_id, + "status": "COMPLETE" + })) + + except Exception as e: + # Ensure hook doesn't fail Claude if something goes wrong + print(json.dumps({"error": str(e)})) + + +if __name__ == "__main__": + main() + diff --git a/src/codegen/cli/commands/claude/config/mcp_setup.py b/src/codegen/cli/commands/claude/config/mcp_setup.py new file mode 100644 index 000000000..852046fca --- /dev/null +++ b/src/codegen/cli/commands/claude/config/mcp_setup.py @@ -0,0 +1,57 @@ + +import subprocess + +from codegen.cli.api.endpoints import MCP_SERVER_ENDPOINT +from codegen.cli.commands.claude.quiet_console import console +from codegen.cli.auth.token_manager import get_current_token + + +def add_codegen_mcp_server(): + console.print("šŸ”§ Configuring MCP server 'codegen-tools'...", style="blue") + try: + token = get_current_token() + if not token: + console.print("āš ļø No authentication token found. Please run 'codegen login' first.", style="yellow") + return + + add_result = subprocess.run( + [ + "claude", + "mcp", + "add", + "--transport", + "http", + "codegen-tools", + MCP_SERVER_ENDPOINT, + "--header", + f"Authorization: Bearer {token}", + ], + capture_output=True, + text=True, + timeout=15, + ) + if add_result.returncode == 0: + console.print("āœ… MCP server added: codegen-tools -> http", style="green") + else: + stderr = add_result.stderr.strip() if add_result.stderr else add_result.stdout.strip() + console.print(f"āš ļø Failed to add MCP server (code {add_result.returncode}): {stderr}", style="yellow") + except subprocess.TimeoutExpired: + console.print("āš ļø MCP server add timed out", style="yellow") + except FileNotFoundError: + console.print("āš ļø 'claude' CLI not found to add MCP server", style="yellow") + except Exception as e: + console.print(f"āš ļø Error adding MCP server: {e}", style="yellow") + + +def cleanup_codegen_mcp_server(): + try: + subprocess.run( + [ + "claude", + "mcp", + "remove", + "codegen-tools", + ], + ) + except Exception as e: + console.print(f"āš ļø Error removing MCP server: {e}", style="yellow") \ No newline at end of file diff --git a/src/codegen/cli/commands/claude/hooks.py b/src/codegen/cli/commands/claude/hooks.py new file mode 100644 index 000000000..e0d82b969 --- /dev/null +++ b/src/codegen/cli/commands/claude/hooks.py @@ -0,0 +1,236 @@ +"""Claude hooks management for session tracking.""" + +import json +import os +import time +from pathlib import Path + +from codegen.cli.commands.claude.quiet_console import console + + +CLAUDE_CONFIG_DIR = Path.home() / ".claude" +HOOKS_CONFIG_FILE = CLAUDE_CONFIG_DIR / "settings.json" +CODEGEN_DIR = Path.home() / ".codegen" +SESSION_FILE = CODEGEN_DIR / "claude-session.json" +SESSION_LOG_FILE = CODEGEN_DIR / "claude-sessions.log" + + +def ensure_claude_hook() -> bool: + """Ensure the Claude hooks are properly set up for session tracking. + + This function will: + 1. Create necessary directories + 2. Create the hooks file if it doesn't exist + 3. Always overwrite any existing SessionStart and Stop hooks with our commands + + Returns: + bool: True if hooks were set up successfully, False otherwise + """ + try: + # Create .codegen directory if it doesn't exist + CODEGEN_DIR.mkdir(exist_ok=True) + + # Clean up old session file if it exists + if SESSION_FILE.exists(): + SESSION_FILE.unlink() + + # Ensure Claude config directory exists + CLAUDE_CONFIG_DIR.mkdir(exist_ok=True) + + # Build the shell command that will create session via API and write session data + start_hook_script_path = Path(__file__).parent / "config" / "claude_session_hook.py" + start_hook_command = f"mkdir -p {CODEGEN_DIR} && python3 {start_hook_script_path} > {SESSION_FILE}" + + # Build the stop hook command to mark session COMPLETE + stop_hook_script_path = Path(__file__).parent / "config" / "claude_session_stop_hook.py" + stop_hook_command = f"python3 {stop_hook_script_path}" + + # Build the user prompt submit hook to set status ACTIVE + active_hook_script_path = Path(__file__).parent / "config" / "claude_session_active_hook.py" + active_hook_command = f"python3 {active_hook_script_path}" + + # Read existing hooks config or create new one + hooks_config = {} + if HOOKS_CONFIG_FILE.exists(): + try: + with open(HOOKS_CONFIG_FILE) as f: + content = f.read().strip() + if content: + hooks_config = json.loads(content) + else: + console.print("āš ļø Hooks file is empty, creating new configuration", style="yellow") + except (OSError, json.JSONDecodeError) as e: + console.print(f"āš ļø Could not read existing hooks file: {e}, creating new one", style="yellow") + + # Ensure proper structure exists + if "hooks" not in hooks_config: + hooks_config["hooks"] = {} + if "SessionStart" not in hooks_config["hooks"]: + hooks_config["hooks"]["SessionStart"] = [] + if "Stop" not in hooks_config["hooks"]: + hooks_config["hooks"]["Stop"] = [] + if "UserPromptSubmit" not in hooks_config["hooks"]: + hooks_config["hooks"]["UserPromptSubmit"] = [] + + # Get existing hooks + session_start_hooks = hooks_config["hooks"]["SessionStart"] + stop_hooks = hooks_config["hooks"]["Stop"] + active_hooks = hooks_config["hooks"]["UserPromptSubmit"] + + # Check if we're replacing existing hooks + replaced_existing = (len(session_start_hooks) > 0) or (len(stop_hooks) > 0) or (len(active_hooks) > 0) + + # Create the new hook structures (following Claude's format) + new_start_hook_group = {"hooks": [{"type": "command", "command": start_hook_command}]} + new_stop_hook_group = {"hooks": [{"type": "command", "command": stop_hook_command}]} + new_active_hook_group = {"hooks": [{"type": "command", "command": active_hook_command}]} + + # Replace all existing hooks with our single hook per event + hooks_config["hooks"]["SessionStart"] = [new_start_hook_group] + hooks_config["hooks"]["Stop"] = [new_stop_hook_group] + hooks_config["hooks"]["UserPromptSubmit"] = [new_active_hook_group] + + # Write updated config with nice formatting + with open(HOOKS_CONFIG_FILE, "w") as f: + json.dump(hooks_config, f, indent=2) + f.write("\n") # Add trailing newline for cleaner file + + if replaced_existing: + console.print("āœ… Replaced existing Claude hooks (SessionStart, Stop)", style="green") + else: + console.print("āœ… Registered new Claude hooks (SessionStart, Stop)", style="green") + console.print(f" Start hook: {start_hook_command[:50]}...", style="dim") + console.print(f" Stop hook: {stop_hook_command}", style="dim") + console.print(f" Active hook:{' ' if len('Active hook:')<1 else ''} {active_hook_command}", style="dim") + + # Verify the hook was written correctly + try: + with open(HOOKS_CONFIG_FILE) as f: + verify_config = json.load(f) + + # Check that our hooks are in the config + found_start_hook = False + for hook_group in verify_config.get("hooks", {}).get("SessionStart", []): + for hook in hook_group.get("hooks", []): + if SESSION_FILE.name in hook.get("command", ""): + found_start_hook = True + break + found_stop_hook = False + for hook_group in verify_config.get("hooks", {}).get("Stop", []): + for hook in hook_group.get("hooks", []): + if "claude_session_stop_hook.py" in hook.get("command", ""): + found_stop_hook = True + break + found_active_hook = False + for hook_group in verify_config.get("hooks", {}).get("UserPromptSubmit", []): + for hook in hook_group.get("hooks", []): + if "claude_session_active_hook.py" in hook.get("command", ""): + found_active_hook = True + break + + if found_start_hook and found_stop_hook and found_active_hook: + console.print("āœ… Hook configuration verified", style="dim") + else: + console.print("āš ļø Hook was written but verification failed", style="yellow") + return False + + except Exception as e: + console.print(f"āš ļø Could not verify hook configuration: {e}", style="yellow") + return False + + return True + + except Exception as e: + console.print(f"āŒ Failed to set up Claude hook: {e}", style="red") + return False + + +def cleanup_claude_hook() -> None: + """Remove the Codegen Claude hooks from the hooks configuration.""" + try: + if not HOOKS_CONFIG_FILE.exists(): + return + + with open(HOOKS_CONFIG_FILE) as f: + hooks_config = json.load(f) + + if "hooks" not in hooks_config: + return + + session_start_hooks = hooks_config["hooks"].get("SessionStart", []) + stop_hooks = hooks_config["hooks"].get("Stop", []) + active_hooks = hooks_config["hooks"].get("UserPromptSubmit", []) + modified = False + + # Filter out any hook groups that contain our command + new_session_hooks = [] + for hook_group in session_start_hooks: + # Check if this group contains our hook + contains_our_hook = False + for hook in hook_group.get("hooks", []): + if hook.get("command") and "claude-session.json" in hook.get("command", ""): + contains_our_hook = True + modified = True + break + + # Keep hook groups that don't contain our hook + if not contains_our_hook: + new_session_hooks.append(hook_group) + + # Update SessionStart hooks if we removed something + if modified: + hooks_config["hooks"]["SessionStart"] = new_session_hooks + + # Now also remove Stop hook referencing our stop script + new_stop_hooks = [] + for hook_group in stop_hooks: + contains_stop = False + for hook in hook_group.get("hooks", []): + if hook.get("command") and "claude_session_stop_hook.py" in hook.get("command", ""): + contains_stop = True + break + if not contains_stop: + new_stop_hooks.append(hook_group) + else: + modified = True + + if stop_hooks is not None: + hooks_config["hooks"]["Stop"] = new_stop_hooks + + # Remove UserPromptSubmit hook referencing our active script + new_active_hooks = [] + for hook_group in active_hooks: + contains_active = False + for hook in hook_group.get("hooks", []): + if hook.get("command") and "claude_session_active_hook.py" in hook.get("command", ""): + contains_active = True + break + if not contains_active: + new_active_hooks.append(hook_group) + else: + modified = True + + if active_hooks is not None: + hooks_config["hooks"]["UserPromptSubmit"] = new_active_hooks + + # Write updated config if anything changed + if modified: + with open(HOOKS_CONFIG_FILE, "w") as f: + json.dump(hooks_config, f, indent=2) + f.write("\n") # Add trailing newline + console.print("āœ… Removed Claude hooks", style="dim") + + # Clean up session files + if SESSION_FILE.exists(): + SESSION_FILE.unlink() + + except Exception as e: + console.print(f"āš ļø Error cleaning up hook: {e}", style="yellow") + + +def get_codegen_url(session_id: str) -> str: + """Get the Codegen URL for a session ID.""" + # You can customize this based on your environment + base_url = os.environ.get("CODEGEN_BASE_URL", "https://codegen.com") + # Use the format: codegen.com/claude-code/{session-id} + return f"{base_url}/claude-code/{session_id}" diff --git a/src/codegen/cli/commands/claude/main.py b/src/codegen/cli/commands/claude/main.py new file mode 100644 index 000000000..e14a64b23 --- /dev/null +++ b/src/codegen/cli/commands/claude/main.py @@ -0,0 +1,151 @@ +"""Claude Code command with session tracking.""" + +import os +import signal +import subprocess +import sys +import threading +import time + +import typer +from codegen.cli.commands.claude.claude_log_watcher import ClaudeLogWatcherManager +from codegen.cli.commands.claude.claude_session_api import end_claude_session, generate_session_id +from codegen.cli.commands.claude.config.mcp_setup import add_codegen_mcp_server, cleanup_codegen_mcp_server +from codegen.cli.commands.claude.hooks import cleanup_claude_hook, ensure_claude_hook, get_codegen_url +from codegen.cli.commands.claude.quiet_console import console +from codegen.cli.utils.org import resolve_org_id + + +def claude( + org_id: int | None = typer.Option(None, help="Organization ID (defaults to CODEGEN_ORG_ID/REPOSITORY_ORG_ID or auto-detect)"), + no_mcp: bool | None = typer.Option(False, "--no-mcp", help="Disable Codegen's MCP server with additional capabilities over HTTP"), +): + """Run Claude Code with session tracking. + + This command runs Claude Code and tracks the session in the backend API: + - Generates a unique session ID + - Creates an agent run when Claude starts + - Updates the agent run status when Claude exits + """ + # Generate session ID for tracking + session_id = generate_session_id() + console.print(f"šŸ†” Generated session ID: {session_id[:8]}...", style="dim") + + # Resolve org_id early for session management + 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("šŸš€ Starting Claude Code with session tracking...", style="blue") + console.print(f"šŸŽÆ Organization ID: {resolved_org_id}", style="dim") + + # Set up environment variables for hooks to access session information + os.environ["CODEGEN_CLAUDE_SESSION_ID"] = session_id + os.environ["CODEGEN_CLAUDE_ORG_ID"] = str(resolved_org_id) + + # Set up Claude hook for session tracking + if not ensure_claude_hook(): + console.print("āš ļø Failed to set up session tracking hook", style="yellow") + + # Initialize log watcher manager + log_watcher_manager = ClaudeLogWatcherManager() + + # Test if Claude Code is accessible first + console.print("šŸ” Testing Claude Code accessibility...", style="blue") + try: + test_result = subprocess.run(["claude", "--version"], capture_output=True, text=True, timeout=10) + if test_result.returncode == 0: + console.print(f"āœ… Claude Code found: {test_result.stdout.strip()}", style="green") + else: + console.print(f"āš ļø Claude Code test failed with code {test_result.returncode}", style="yellow") + if test_result.stderr: + console.print(f"Error: {test_result.stderr.strip()}", style="red") + except subprocess.TimeoutExpired: + console.print("āš ļø Claude Code version check timed out", style="yellow") + except Exception as e: + console.print(f"āš ļø Claude Code test error: {e}", style="yellow") + + # If MCP endpoint provided, register MCP server via Claude CLI before launch + if not no_mcp: + add_codegen_mcp_server() + + console.print("šŸ”µ Starting Claude Code session...", style="blue") + + try: + # Launch Claude Code with our session ID + console.print(f"šŸš€ Launching Claude Code with session ID: {session_id[:8]}...", style="blue") + + url = get_codegen_url(session_id) + console.print(f"\nšŸ”µ Codegen URL: {url}\n", style="bold blue") + + + process = subprocess.Popen(["claude", "--session-id", session_id]) + + + # Start log watcher for the session + console.print("šŸ“‹ Starting log watcher...", style="blue") + log_watcher_started = log_watcher_manager.start_watcher( + session_id=session_id, + org_id=resolved_org_id, + poll_interval=1.0, # Check every second + on_log_entry=None + ) + + if not log_watcher_started: + console.print("āš ļø Failed to start log watcher", style="yellow") + + # Handle Ctrl+C gracefully + def signal_handler(signum, frame): + console.print("\nšŸ›‘ Stopping Claude Code...", style="yellow") + log_watcher_manager.stop_all_watchers() # Stop log watchers + process.terminate() + cleanup_claude_hook() # Clean up our hook + cleanup_codegen_mcp_server() # Clean up MCP Server + end_claude_session(session_id, "ERROR", resolved_org_id) + sys.exit(0) + + signal.signal(signal.SIGINT, signal_handler) + + # Wait for Claude Code to finish + returncode = process.wait() + + # Handle session completion based on exit code + session_status = "COMPLETE" if returncode == 0 else "ERROR" + end_claude_session(session_id, session_status, resolved_org_id) + + if returncode != 0: + console.print(f"āŒ Claude Code exited with error code {returncode}", style="red") + else: + console.print("āœ… Claude Code finished successfully", style="green") + + except FileNotFoundError: + console.print("āŒ Claude Code not found. Please install Claude Code first.", style="red") + console.print("šŸ’” Visit: https://claude.ai/download", style="dim") + log_watcher_manager.stop_all_watchers() + end_claude_session(session_id, "ERROR", resolved_org_id) + raise typer.Exit(1) + except KeyboardInterrupt: + console.print("\nšŸ›‘ Interrupted by user", style="yellow") + log_watcher_manager.stop_all_watchers() + end_claude_session(session_id, "ERROR", resolved_org_id) + except Exception as e: + console.print(f"āŒ Error running Claude Code: {e}", style="red") + log_watcher_manager.stop_all_watchers() + end_claude_session(session_id, "ERROR", resolved_org_id) + raise typer.Exit(1) + finally: + # Clean up resources + try: + log_watcher_manager.stop_all_watchers() + except Exception as e: + console.print(f"āš ļø Error stopping log watchers: {e}", style="yellow") + + cleanup_claude_hook() + + # Show final session info + url = get_codegen_url(session_id) + console.print(f"\nšŸ”µ Session URL: {url}", style="bold blue") + console.print(f"šŸ†” Session ID: {session_id}", style="dim") + console.print(f"šŸŽÆ Organization ID: {resolved_org_id}", style="dim") + console.print("šŸ’” Check your backend to see the session data", style="dim") \ No newline at end of file diff --git a/src/codegen/cli/commands/claude/quiet_console.py b/src/codegen/cli/commands/claude/quiet_console.py new file mode 100644 index 000000000..b9a040c3d --- /dev/null +++ b/src/codegen/cli/commands/claude/quiet_console.py @@ -0,0 +1,33 @@ +"""Silent console utilities for Claude CLI. + +This module provides a shared Rich console instance that is silent by default +to avoid interfering with Claude's terminal UI. +""" + +from __future__ import annotations + +import io +import os +from rich.console import Console + + +def _create_console() -> Console: + """Create a console instance. + + If CODEGEN_CLAUDE_VERBOSE is set to a truthy value, return a normal + Console for debugging; otherwise, return a Console that writes to an + in-memory buffer so nothing is emitted to stdout/stderr. + """ + verbose = os.environ.get("CODEGEN_CLAUDE_VERBOSE", "").strip().lower() + is_verbose = verbose in ("1", "true", "yes", "on") + + if is_verbose: + return Console() + + # Silent console: sink all output + return Console(file=io.StringIO()) + + +# Shared console used across Claude CLI modules +console = _create_console() + 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..8a287dfa0 --- /dev/null +++ b/src/codegen/cli/commands/integrations/main.py @@ -0,0 +1,143 @@ +"""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 +from codegen.cli.utils.org import resolve_org_id + +console = Console() + +# Create the integrations app +integrations_app = typer.Typer(help="Manage Codegen integrations") + + +@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: + 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 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() + + 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..52cf1f0bf 100644 --- a/src/codegen/cli/commands/mcp/main.py +++ b/src/codegen/cli/commands/mcp/main.py @@ -1,16 +1,61 @@ """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") @@ -24,14 +69,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(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) + 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..856767d1c --- /dev/null +++ b/src/codegen/cli/commands/tools/main.py @@ -0,0 +1,97 @@ +"""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 +from codegen.cli.utils.org import resolve_org_id + +console = Console() + + +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: + 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 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() + + # 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/api_client.py b/src/codegen/cli/mcp/api_client.py new file mode 100644 index 000000000..8894dad18 --- /dev/null +++ b/src/codegen/cli/mcp/api_client.py @@ -0,0 +1,54 @@ +"""API client management for the Codegen MCP server.""" + +import os + +# Import API client components +try: + from codegen_api_client import ApiClient, Configuration + from codegen_api_client.api import AgentsApi, OrganizationsApi, UsersApi + + API_CLIENT_AVAILABLE = True +except ImportError: + API_CLIENT_AVAILABLE = False + +# Global API client instances +_api_client = None +_agents_api = None +_organizations_api = None +_users_api = None + + +def get_api_client(): + """Get or create the API client instance.""" + global _api_client, _agents_api, _organizations_api, _users_api + + if not API_CLIENT_AVAILABLE: + msg = "codegen-api-client is not available" + raise RuntimeError(msg) + + if _api_client is None: + # Configure the API client + configuration = Configuration() + + # Set base URL from environment or use the CLI endpoint for consistency + # Prefer explicit env override; else match API_ENDPOINT used by CLI commands + from codegen.cli.api.endpoints import API_ENDPOINT + base_url = os.getenv("CODEGEN_API_BASE_URL", API_ENDPOINT.rstrip("/")) + configuration.host = base_url + + # Set authentication + api_key = os.getenv("CODEGEN_API_KEY") + if api_key: + configuration.api_key = {"Authorization": f"Bearer {api_key}"} + + _api_client = ApiClient(configuration) + _agents_api = AgentsApi(_api_client) + _organizations_api = OrganizationsApi(_api_client) + _users_api = UsersApi(_api_client) + + return _api_client, _agents_api, _organizations_api, _users_api + + +def is_api_client_available() -> bool: + """Check if the API client is available.""" + return API_CLIENT_AVAILABLE diff --git a/src/codegen/cli/mcp/prompts.py b/src/codegen/cli/mcp/prompts.py new file mode 100644 index 000000000..5f70fea70 --- /dev/null +++ b/src/codegen/cli/mcp/prompts.py @@ -0,0 +1,14 @@ +"""Prompts and instructions for the Codegen MCP server.""" + +MCP_SERVER_INSTRUCTIONS = ( + "Codegen is an operating system for agents. " + "It allows organizations to run Claude Code instances with superpowers, including unified observability, " + "dynamic sandboxes, powerful MCP integrations, security and more.\n\n" + "This MCP server provides permissioned access to integrations configured by your organization. " + "All tools shown (GitHub, Linear, ClickUp, Notion, Sentry, etc.) are pre-configured and ready to use - " + "they've been provisioned based on your organization's setup and your role permissions. " + "You can confidently use any available tool without worrying about authentication or configuration.\n\n" + "Learn more at https://codegen.com.\n" + "For documentation, visit https://docs.codegen.com/integrations/mcp.\n" + "To install and authenticate this server, run: `uv tool install codegen` then `codegen login`." +) diff --git a/src/codegen/cli/mcp/resources.py b/src/codegen/cli/mcp/resources.py new file mode 100644 index 000000000..4192a0a1a --- /dev/null +++ b/src/codegen/cli/mcp/resources.py @@ -0,0 +1,18 @@ +"""MCP resources for the Codegen server.""" + +from typing import Any + +from fastmcp import FastMCP + + +def register_resources(mcp: FastMCP): + """Register MCP resources with the server.""" + + @mcp.resource("system://manifest", mime_type="application/json") + def get_service_config() -> dict[str, Any]: + """Get the service config.""" + return { + "name": "mcp-codegen", + "version": "0.1.0", + "description": "The MCP server for the Codegen platform API integration.", + } diff --git a/src/codegen/cli/mcp/runner.py b/src/codegen/cli/mcp/runner.py new file mode 100644 index 000000000..1dedba45d --- /dev/null +++ b/src/codegen/cli/mcp/runner.py @@ -0,0 +1,43 @@ +"""MCP server runner for the Codegen platform.""" + +from fastmcp import FastMCP + +from .resources import register_resources +from .tools.dynamic import register_dynamic_tools +from .tools.static import register_static_tools + + +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.""" + from .prompts import MCP_SERVER_INSTRUCTIONS + + # Initialize FastMCP server + mcp = FastMCP( + "codegen-mcp", + instructions=MCP_SERVER_INSTRUCTIONS, + ) + + # Register all components + register_resources(mcp) + register_static_tools(mcp) + + # Register dynamic tools if provided + if available_tools: + print("šŸ”§ Registering dynamic tools from API...") + register_dynamic_tools(mcp, available_tools) + print(f"āœ… Registered {len(available_tools)} dynamic tools") + + if transport == "stdio": + print("šŸš€ MCP server running on stdio transport") + mcp.run(transport="stdio") + elif transport == "http": + if port is None: + port = 8000 + print(f"šŸš€ MCP server running on http://{host}:{port}") + # Note: FastMCP may not support HTTP transport directly + # This is a placeholder for future HTTP transport support + print(f"HTTP transport not yet implemented. Would run on {host}:{port}") + mcp.run(transport="stdio") # Fallback to stdio for now + else: + msg = f"Unsupported transport: {transport}" + raise ValueError(msg) diff --git a/src/codegen/cli/mcp/server.py b/src/codegen/cli/mcp/server.py index d2ffa0ee8..dcf41514d 100644 --- a/src/codegen/cli/mcp/server.py +++ b/src/codegen/cli/mcp/server.py @@ -1,234 +1,16 @@ -import json -import os -from typing import Annotated, Any +"""Main MCP server entry point for the Codegen platform. -from fastmcp import Context, FastMCP +This module provides the main entry point for the Codegen MCP server. +The actual server functionality is distributed across several modules: -# Import API client components -try: - from codegen_api_client import ApiClient, Configuration - from codegen_api_client.api import AgentsApi, OrganizationsApi, UsersApi - from codegen_api_client.models import CreateAgentRunInput - - API_CLIENT_AVAILABLE = True -except ImportError: - API_CLIENT_AVAILABLE = False - -# Initialize FastMCP server -mcp = FastMCP( - "codegen-mcp", - instructions="MCP server for the Codegen platform. Use the tools and resources to interact with Codegen APIs and manage your development workflow.", -) - -# Global API client instances -_api_client = None -_agents_api = None -_organizations_api = None -_users_api = None - - -def get_api_client(): - """Get or create the API client instance.""" - global _api_client, _agents_api, _organizations_api, _users_api - - if not API_CLIENT_AVAILABLE: - msg = "codegen-api-client is not available" - raise RuntimeError(msg) - - if _api_client is None: - # Configure the API client - configuration = Configuration() - - # Set base URL from environment or use default - base_url = os.getenv("CODEGEN_API_BASE_URL", "https://api.codegen.com") - configuration.host = base_url - - # Set authentication - api_key = os.getenv("CODEGEN_API_KEY") - if api_key: - configuration.api_key = {"Authorization": f"Bearer {api_key}"} - - _api_client = ApiClient(configuration) - _agents_api = AgentsApi(_api_client) - _organizations_api = OrganizationsApi(_api_client) - _users_api = UsersApi(_api_client) - - return _api_client, _agents_api, _organizations_api, _users_api - - -# ----- RESOURCES ----- - - -@mcp.resource("system://manifest", mime_type="application/json") -def get_service_config() -> dict[str, Any]: - """Get the service config.""" - return { - "name": "mcp-codegen", - "version": "0.1.0", - "description": "The MCP server for the Codegen platform API integration.", - } - - -# ----- TOOLS ----- - - -# ----- CODEGEN API TOOLS ----- - - -@mcp.tool() -def create_agent_run( - org_id: Annotated[int, "Organization ID"], - prompt: Annotated[str, "The prompt/task for the agent to execute"], - repo_name: Annotated[str | None, "Repository name (optional)"] = None, - branch_name: Annotated[str | None, "Branch name (optional)"] = None, - ctx: Context | None = None, -) -> str: - """Create a new agent run in the specified organization.""" - try: - _, agents_api, _, _ = get_api_client() - - # Create the input object - agent_input = CreateAgentRunInput(prompt=prompt) - # Make the API call - response = agents_api.create_agent_run_v1_organizations_org_id_agent_run_post(org_id=org_id, create_agent_run_input=agent_input) - - return json.dumps( - { - "id": response.id, - "status": response.status, - "created_at": response.created_at.isoformat() if response.created_at else None, - "prompt": response.prompt, - "repo_name": response.repo_name, - "branch_name": response.branch_name, - }, - indent=2, - ) - - except Exception as e: - return f"Error creating agent run: {e}" - - -@mcp.tool() -def get_agent_run( - org_id: Annotated[int, "Organization ID"], - agent_run_id: Annotated[int, "Agent run ID"], - ctx: Context | None = None, -) -> str: - """Get details of a specific agent run.""" - try: - _, agents_api, _, _ = get_api_client() - - response = agents_api.get_agent_run_v1_organizations_org_id_agent_run_agent_run_id_get(org_id=org_id, agent_run_id=agent_run_id) - - return json.dumps( - { - "id": response.id, - "status": response.status, - "created_at": response.created_at.isoformat() if response.created_at else None, - "updated_at": response.updated_at.isoformat() if response.updated_at else None, - "prompt": response.prompt, - "repo_name": response.repo_name, - "branch_name": response.branch_name, - "result": response.result, - }, - indent=2, - ) - - except Exception as e: - return f"Error getting agent run: {e}" - - -@mcp.tool() -def get_organizations( - page: Annotated[int, "Page number (default: 1)"] = 1, - limit: Annotated[int, "Number of organizations per page (default: 10)"] = 10, - ctx: Context | None = None, -) -> str: - """Get list of organizations the user has access to.""" - try: - _, _, organizations_api, _ = get_api_client() - - response = organizations_api.get_organizations_v1_organizations_get() - - # Format the response - organizations = [] - for org in response.items: - organizations.append({"id": org.id, "name": org.name, "slug": org.slug, "created_at": org.created_at.isoformat() if org.created_at else None}) - - return json.dumps({"organizations": organizations, "total": response.total, "page": response.page, "limit": response.limit}, indent=2) - - except Exception as e: - return f"Error getting organizations: {e}" - - -@mcp.tool() -def get_users( - org_id: Annotated[int, "Organization ID"], - page: Annotated[int, "Page number (default: 1)"] = 1, - limit: Annotated[int, "Number of users per page (default: 10)"] = 10, - ctx: Context | None = None, -) -> str: - """Get list of users in an organization.""" - try: - _, _, _, users_api = get_api_client() - - response = users_api.get_users_v1_organizations_org_id_users_get(org_id=org_id) - - # Format the response - users = [] - for user in response.items: - users.append({"id": user.id, "email": user.email, "name": user.name, "created_at": user.created_at.isoformat() if user.created_at else None}) - - return json.dumps({"users": users, "total": response.total, "page": response.page, "limit": response.limit}, indent=2) - - except Exception as e: - return f"Error getting users: {e}" - - -@mcp.tool() -def get_user( - org_id: Annotated[int, "Organization ID"], - user_id: Annotated[int, "User ID"], - ctx: Context | None = None, -) -> str: - """Get details of a specific user in an organization.""" - try: - _, _, _, users_api = get_api_client() - - response = users_api.get_user_v1_organizations_org_id_users_user_id_get(org_id=org_id, user_id=user_id) - - return json.dumps( - { - "id": response.id, - "email": response.email, - "name": response.name, - "created_at": response.created_at.isoformat() if response.created_at else None, - "updated_at": response.updated_at.isoformat() if response.updated_at else None, - }, - indent=2, - ) - - except Exception as e: - return f"Error getting user: {e}" - - -def run_server(transport: str = "stdio", host: str = "localhost", port: int | None = None): - """Run the MCP server with the specified transport.""" - if transport == "stdio": - print("šŸš€ MCP server running on stdio transport") - mcp.run(transport="stdio") - elif transport == "http": - if port is None: - port = 8000 - print(f"šŸš€ MCP server running on http://{host}:{port}") - # Note: FastMCP may not support HTTP transport directly - # This is a placeholder for future HTTP transport support - print(f"HTTP transport not yet implemented. Would run on {host}:{port}") - mcp.run(transport="stdio") # Fallback to stdio for now - else: - msg = f"Unsupported transport: {transport}" - raise ValueError(msg) +- api_client.py: API client management +- prompts.py: Server instructions and prompts +- resources.py: MCP resources +- tools/: Tool modules (static and dynamic) +- runner.py: Server runner and configuration +""" +from .runner import run_server if __name__ == "__main__": # Initialize and run the server diff --git a/src/codegen/cli/mcp/tools/__init__.py b/src/codegen/cli/mcp/tools/__init__.py new file mode 100644 index 000000000..c2da70d78 --- /dev/null +++ b/src/codegen/cli/mcp/tools/__init__.py @@ -0,0 +1 @@ +"""Tools module for the Codegen MCP server.""" diff --git a/src/codegen/cli/mcp/tools/dynamic.py b/src/codegen/cli/mcp/tools/dynamic.py new file mode 100644 index 000000000..0b350ac29 --- /dev/null +++ b/src/codegen/cli/mcp/tools/dynamic.py @@ -0,0 +1,174 @@ +"""Dynamic tool registration for the Codegen MCP server.""" + +import json +from typing import Annotated + +from fastmcp import FastMCP + +from .executor import execute_tool_via_api + + +def register_dynamic_tools(mcp: FastMCP, 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}") diff --git a/src/codegen/cli/mcp/tools/executor.py b/src/codegen/cli/mcp/tools/executor.py new file mode 100644 index 000000000..e9113e8e2 --- /dev/null +++ b/src/codegen/cli/mcp/tools/executor.py @@ -0,0 +1,40 @@ +import json +import requests + +from codegen.cli.api.endpoints import API_ENDPOINT + +import requests + +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 + + +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"} + + # Determine org id: prefer explicit in arguments, else resolve from env/config/API + org_id = None + if isinstance(arguments, dict): + org_id = arguments.get("org_id") + org_id = resolve_org_id(org_id) + if org_id is None: + return {"error": "Organization ID not provided. Include org_id argument, or set CODEGEN_ORG_ID/REPOSITORY_ORG_ID."} + + url = f"{API_ENDPOINT.rstrip('/')}/v1/organizations/{org_id}/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}"} diff --git a/src/codegen/cli/mcp/tools/static.py b/src/codegen/cli/mcp/tools/static.py new file mode 100644 index 000000000..98c9ec98c --- /dev/null +++ b/src/codegen/cli/mcp/tools/static.py @@ -0,0 +1,176 @@ +"""Static Codegen API tools for the MCP server.""" + +import json +from typing import Annotated + +from fastmcp import Context, FastMCP + +from ..api_client import get_api_client + + +def register_static_tools(mcp: FastMCP): + """Register static Codegen API tools with the MCP server.""" + + @mcp.tool() + def create_agent_run( + org_id: Annotated[int, "Organization ID"], + prompt: Annotated[str, "The prompt/task for the agent to execute"], + repo_name: Annotated[str | None, "Repository name (optional)"] = None, + branch_name: Annotated[str | None, "Branch name (optional)"] = None, + ctx: Context | None = None, + ) -> str: + """Create a new agent run in the specified organization.""" + try: + from codegen_api_client.models import CreateAgentRunInput + + _, agents_api, _, _ = get_api_client() + + # Create the input object + agent_input = CreateAgentRunInput(prompt=prompt) + # Make the API call + response = agents_api.create_agent_run_v1_organizations_org_id_agent_run_post(org_id=org_id, create_agent_run_input=agent_input) + + return json.dumps( + { + "id": response.id, + "status": response.status, + "created_at": response.created_at.isoformat() if response.created_at else None, + "prompt": response.prompt, + "repo_name": response.repo_name, + "branch_name": response.branch_name, + }, + indent=2, + ) + + except Exception as e: + return f"Error creating agent run: {e}" + + @mcp.tool() + def get_agent_run( + org_id: Annotated[int, "Organization ID"], + agent_run_id: Annotated[int, "Agent run ID"], + ctx: Context | None = None, + ) -> str: + """Get details of a specific agent run.""" + try: + _, agents_api, _, _ = get_api_client() + + response = agents_api.get_agent_run_v1_organizations_org_id_agent_run_agent_run_id_get(org_id=org_id, agent_run_id=agent_run_id) + + return json.dumps( + { + "id": response.id, + "status": response.status, + "created_at": response.created_at.isoformat() if response.created_at else None, + "updated_at": response.updated_at.isoformat() if response.updated_at else None, + "prompt": response.prompt, + "repo_name": response.repo_name, + "branch_name": response.branch_name, + "result": response.result, + }, + indent=2, + ) + + except Exception as e: + return f"Error getting agent run: {e}" + + @mcp.tool() + def get_organizations( + page: Annotated[int, "Page number (default: 1)"] = 1, + limit: Annotated[int, "Number of organizations per page (default: 10)"] = 10, + ctx: Context | None = None, + ) -> str: + """Get list of organizations the user has access to.""" + try: + _, _, organizations_api, _ = get_api_client() + + response = organizations_api.get_organizations_v1_organizations_get() + + # Format the response + organizations = [] + for org in response.items: + organizations.append( + { + "id": org.id, + "name": org.name, + "slug": org.slug, + "created_at": org.created_at.isoformat() if org.created_at else None, + } + ) + + return json.dumps( + { + "organizations": organizations, + "total": response.total, + "page": response.page, + "limit": response.limit, + }, + indent=2, + ) + + except Exception as e: + return f"Error getting organizations: {e}" + + @mcp.tool() + def get_users( + org_id: Annotated[int, "Organization ID"], + page: Annotated[int, "Page number (default: 1)"] = 1, + limit: Annotated[int, "Number of users per page (default: 10)"] = 10, + ctx: Context | None = None, + ) -> str: + """Get list of users in an organization.""" + try: + _, _, _, users_api = get_api_client() + + response = users_api.get_users_v1_organizations_org_id_users_get(org_id=org_id) + + # Format the response + users = [] + for user in response.items: + users.append( + { + "id": user.id, + "email": user.email, + "name": user.name, + "created_at": user.created_at.isoformat() if user.created_at else None, + } + ) + + return json.dumps( + { + "users": users, + "total": response.total, + "page": response.page, + "limit": response.limit, + }, + indent=2, + ) + + except Exception as e: + return f"Error getting users: {e}" + + @mcp.tool() + def get_user( + org_id: Annotated[int, "Organization ID"], + user_id: Annotated[int, "User ID"], + ctx: Context | None = None, + ) -> str: + """Get details of a specific user in an organization.""" + try: + _, _, _, users_api = get_api_client() + + response = users_api.get_user_v1_organizations_org_id_users_user_id_get(org_id=org_id, user_id=user_id) + + return json.dumps( + { + "id": response.id, + "email": response.email, + "name": response.name, + "created_at": response.created_at.isoformat() if response.created_at else None, + "updated_at": response.updated_at.isoformat() if response.updated_at else None, + }, + indent=2, + ) + + except Exception as e: + return f"Error getting user: {e}" diff --git a/src/codegen/cli/utils/org.py b/src/codegen/cli/utils/org.py new file mode 100644 index 000000000..06c2f7f57 --- /dev/null +++ b/src/codegen/cli/utils/org.py @@ -0,0 +1,64 @@ +"""Organization resolution utilities for CLI commands.""" + +import os +from typing import Optional + +import requests + +from codegen.cli.api.endpoints import API_ENDPOINT +from codegen.cli.auth.token_manager import get_current_token +from codegen.cli.commands.claude.quiet_console import console + +def resolve_org_id(explicit_org_id: Optional[int] = None) -> Optional[int]: + """Resolve the organization id from CLI input or environment. + + Order of precedence: + 1) explicit_org_id passed by the caller + 2) CODEGEN_ORG_ID environment variable (dotenv is loaded by global_env) + + Returns None if not found. + """ + + if explicit_org_id is not None: + return explicit_org_id + + env_val = os.environ.get("CODEGEN_ORG_ID") + if env_val is None or env_val == "": + # Try repository-scoped org id from .env + repo_org = os.environ.get("REPOSITORY_ORG_ID") + if repo_org: + try: + return int(repo_org) + except ValueError: + pass + + # Attempt auto-detection via API: if user belongs to organizations, use the first + try: + token = get_current_token() + if not token: + print("No token found") + return None + headers = {"Authorization": f"Bearer {token}"} + url = f"{API_ENDPOINT.rstrip('/')}/v1/organizations" + resp = requests.get(url, headers=headers, timeout=10) + resp.raise_for_status() + data = resp.json() + items = data.get("items") or [] + if isinstance(items, list) and len(items) >= 1: + org = items[0] + org_id = org.get("id") + try: + return int(org_id) + except Exception: + return None + # None returned + return None + except Exception as e: + console.print(f"Exception: {e}") + return None + + try: + return int(env_val) + except ValueError: + return None + 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" diff --git a/uv.lock b/uv.lock index 198ee4bd9..9fe7a7fc6 100644 --- a/uv.lock +++ b/uv.lock @@ -430,7 +430,6 @@ dependencies = [ { name = "hatch-vcs" }, { name = "hatchling" }, { name = "humanize" }, - { name = "mcp", extra = ["cli"] }, { name = "packaging" }, { name = "psutil" }, { name = "pydantic" }, @@ -492,7 +491,6 @@ requires-dist = [ { name = "hatch-vcs", specifier = ">=0.4.0" }, { name = "hatchling", specifier = ">=1.25.0" }, { name = "humanize", specifier = ">=4.10.0" }, - { name = "mcp", extras = ["cli"], specifier = "==1.9.4" }, { name = "packaging", specifier = ">=24.2" }, { name = "psutil", specifier = ">=5.8.0" }, { name = "pydantic", specifier = ">=2.9.2" }, @@ -1591,12 +1589,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/fc/80e655c955137393c443842ffcc4feccab5b12fa7cb8de9ced90f90e6998/mcp-1.9.4-py3-none-any.whl", hash = "sha256:7fcf36b62936adb8e63f89346bccca1268eeca9bf6dfb562ee10b1dfbda9dac0", size = 130232, upload-time = "2025-06-12T08:20:28.551Z" }, ] -[package.optional-dependencies] -cli = [ - { name = "python-dotenv" }, - { name = "typer" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -2548,20 +2540,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" }, ] -[[package]] -name = "rich-click" -version = "1.8.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "rich" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a6/7a/4b78c5997f2a799a8c5c07f3b2145bbcda40115c4d35c76fbadd418a3c89/rich_click-1.8.8.tar.gz", hash = "sha256:547c618dea916620af05d4a6456da797fbde904c97901f44d2f32f89d85d6c84", size = 39066, upload-time = "2025-03-09T23:20:31.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/69/963f0bf44a654f6465bdb66fb5a91051b0d7af9f742b5bd7202607165036/rich_click-1.8.8-py3-none-any.whl", hash = "sha256:205aabd5a98e64ab2c105dee9e368be27480ba004c7dfa2accd0ed44f9f1550e", size = 35747, upload-time = "2025-03-09T23:20:29.831Z" }, -] - [[package]] name = "rpds-py" version = "0.25.0"