diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..7b5ed513c --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(source:*)", + "Bash(ty)", + "Bash(ty check:*)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dda073d87..e131a1174 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -92,3 +92,12 @@ repos: - mdformat-ruff - mdformat-config - mdformat-pyproject + + - repo: local + hooks: + - id: ty-typechecker + name: ty typechecker + entry: bash -c 'source .venv/bin/activate && ty check' + language: system + pass_filenames: false + always_run: true diff --git a/docs/docs.json b/docs/docs.json index f5d8f21a6..ab2ef1049 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1,129 +1,136 @@ { - "$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 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');" + } + ] } 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..3298acda7 100644 --- a/src/codegen/cli/api/endpoints.py +++ b/src/codegen/cli/api/endpoints.py @@ -1,5 +1,6 @@ from codegen.cli.api.modal import MODAL_PREFIX +# DEPRECATED RUN_ENDPOINT = f"https://{MODAL_PREFIX}--cli-run.modal.run" DOCS_ENDPOINT = f"https://{MODAL_PREFIX}--cli-docs.modal.run" EXPERT_ENDPOINT = f"https://{MODAL_PREFIX}--cli-ask-expert.modal.run" @@ -11,3 +12,5 @@ PR_LOOKUP_ENDPOINT = f"https://{MODAL_PREFIX}--cli-pr-lookup.modal.run" CODEGEN_SYSTEM_PROMPT_URL = "https://gist.githubusercontent.com/jayhack/15681a2ceaccd726f19e6fdb3a44738b/raw/17c08054e3931b3b7fdf424458269c9e607541e8/codegen-system-prompt.txt" IMPROVE_ENDPOINT = f"https://{MODAL_PREFIX}--cli-improve.modal.run" +# API +API_ENDPOINT = f"https://{MODAL_PREFIX}--rest-api.modal.run/" diff --git a/src/codegen/cli/api/modal.py b/src/codegen/cli/api/modal.py index 16b4e3d62..344aa9bd9 100644 --- a/src/codegen/cli/api/modal.py +++ b/src/codegen/cli/api/modal.py @@ -9,7 +9,7 @@ def get_modal_workspace(): case Environment.STAGING: return "codegen-sh-staging" case Environment.DEVELOP: - return "codegen-sh-develop" + return "codegen-sh-develop-jay" case _: msg = f"Invalid environment: {global_env.ENV}" raise ValueError(msg) diff --git a/src/codegen/cli/auth/token_manager.py b/src/codegen/cli/auth/token_manager.py index 7e6b6470c..11e7dbb16 100644 --- a/src/codegen/cli/auth/token_manager.py +++ b/src/codegen/cli/auth/token_manager.py @@ -2,9 +2,7 @@ import os from pathlib import Path -from codegen.cli.api.client import RestAPI from codegen.cli.auth.constants import AUTH_FILE, CONFIG_DIR -from codegen.cli.errors import AuthError class TokenManager: @@ -22,14 +20,7 @@ def _ensure_config_dir(self): Path(self.config_dir).mkdir(parents=True, exist_ok=True) def authenticate_token(self, token: str) -> None: - """Authenticate the token with the api.""" - identity = RestAPI(token).identify() - if not identity: - msg = "No identity found for session" - raise AuthError(msg) - if identity.auth_context.status != "active": - msg = "Current session is not active. API Token may be invalid or may have expired." - raise AuthError(msg) + """Store the token locally.""" self.save_token(token) def save_token(self, token: str) -> None: diff --git a/src/codegen/cli/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..55f21294b --- /dev/null +++ b/src/codegen/cli/commands/claude/__init__.py @@ -0,0 +1,6 @@ +"""Claude Code integration commands.""" + +from .hooks import cleanup_claude_hook, ensure_claude_hook, get_codegen_url, wait_for_session_id +from .main import claude + +__all__ = ["claude", "cleanup_claude_hook", "ensure_claude_hook", "get_codegen_url", "wait_for_session_id"] diff --git a/src/codegen/cli/commands/claude/claude-cli.md b/src/codegen/cli/commands/claude/claude-cli.md new file mode 100644 index 000000000..35c0bf719 --- /dev/null +++ b/src/codegen/cli/commands/claude/claude-cli.md @@ -0,0 +1,338 @@ +# Claude Code CLI Integration Documentation + +## Overview + +The `codegen claude` command integrates Claude Code with our remote telemetry system, allowing us to monitor and analyze Claude Code usage patterns, tool executions, and API interactions. This integration uses Claude Code's built-in OpenTelemetry (OTEL) support to send telemetry data to our backend API. + +## Architecture + +``` +Claude Code Process + ↓ (OTLP/HTTP) +Local CLI Command (codegen claude) + ↓ (Environment Variables) +Remote Telemetry API (/v1/organizations/{org_id}/telemetry) + ↓ (Processing) +AgentRun & AgentRunLog Database Models + ↓ (Display) +Frontend Agent Trace Components +``` + +## File Structure + +- `main.py` - Main CLI command implementation +- `claude-cli.md` (this file) - Documentation +- Backend files (in `cloud/codegen-backend/`): + - `app/modal_app/dev_api/v1/telemetry.py` - OTLP endpoint handlers + - `app/modal_app/dev_api/v1/claude_types.py` - Pydantic models for OTLP data + - `app/modal_app/dev_api/v1/claude_session_utils.py` - AgentRun/AgentRunLog utilities + +## Command Usage + +### Basic Usage + +```bash +codegen claude # Normal mode with telemetry +codegen claude --normal-mode # No telemetry (normal Claude Code) +``` + +### Debug Modes + +```bash +codegen claude --debug-mode # Show debug info +codegen claude --debug-otel # Show ALL OTLP events (verbose) +codegen claude --debug-otel-actions-only # Show ONLY action events (clean) +codegen claude --verbose-telemetry # Enable backend verbose logging +``` + +### Security & Logging Options + +```bash +codegen claude --log-prompts # Log user prompt content (security risk) +codegen claude --console-output # Show telemetry in local console +``` + +### Advanced Options + +```bash +codegen claude --org-id 123 # Custom organization ID +codegen claude --export-interval 30 # Custom export interval (seconds) +``` + +## How It Works + +### 1. Environment Variable Configuration + +The CLI sets up Claude Code's OpenTelemetry configuration via environment variables: + +```bash +# Core telemetry +CLAUDE_CODE_ENABLE_TELEMETRY=1 +OTEL_EXPORTER_OTLP_ENDPOINT=https://api.codegen.com/v1/organizations/11/telemetry +OTEL_EXPORTER_OTLP_PROTOCOL=http/json +OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer ,X-Verbose-Telemetry=true + +# Export configuration +OTEL_METRICS_EXPORTER=otlp,console +OTEL_LOGS_EXPORTER=otlp,console +OTEL_METRIC_EXPORT_INTERVAL=60000 # 60 seconds in milliseconds +OTEL_LOGS_EXPORT_INTERVAL=60000 + +# Resource identification +OTEL_RESOURCE_ATTRIBUTES=service.name=claude-code,organization.id=11,user.id= + +# Feature flags +OTEL_METRICS_INCLUDE_SESSION_ID=true +OTEL_LOG_USER_PROMPTS=1 # Enable/disable prompt content logging +``` + +### 2. Telemetry Data Flow + +Claude Code automatically sends two types of telemetry: + +#### Metrics (Counters/Gauges) + +- `claude_code.session.count` - Active session tracking +- `claude_code.token.usage` - Token consumption by model/type +- `claude_code.cost.usage` - Estimated API costs +- `claude_code.active_time.total` - User activity time +- `claude_code.tool_decision.count` - Tool accept/reject counts + +#### Events (Logs) + +- `claude_code.user_prompt` - User interactions +- `claude_code.tool_result` - Tool executions with parameters +- `claude_code.tool_decision` - Tool permission decisions +- `claude_code.api_request` - LLM API calls with tokens/cost +- `claude_code.api_error` - API failures + +### 3. Backend Processing + +#### OTLP Endpoints + +- `POST /v1/organizations/{org_id}/telemetry/v1/metrics` - Receives metrics +- `POST /v1/organizations/{org_id}/telemetry/v1/logs` - Receives events + +#### Data Transformation Pipeline + +1. **Raw OTLP Parsing** (`telemetry.py`) - Handles camelCase/snake_case variations +2. **Type Validation** (`claude_types.py`) - Pydantic models for safe parsing +3. **Session Management** (`claude_session_utils.py`) - Maps sessions to AgentRuns +4. **Database Storage** - Creates AgentRun and AgentRunLog entries + +#### Key Database Models + +- `AgentRunModel` - Represents a Claude Code session + - `meta.claude_code_session_id` - Links to Claude session + - `agent_type = AgentType.CLAUDE_CODE` + - `source_type = AgentRunSourceType.API` +- `AgentRunLogModel` - Individual events within a session + - `type = AgentLogType.USER_MESSAGE | ACTION` + - `tool_input` - Parsed tool parameters as JSON + - `tool_data` - Additional metadata + +## Debug Modes Explained + +### `--debug-otel` (Full Debug) + +Shows ALL OpenTelemetry events in console, including: + +- Metrics (counters, timers, gauges) +- Events (tool usage, API calls, prompts) +- Raw OTLP JSON structures + +**Use when**: Deep debugging telemetry issues + +### `--debug-otel-actions-only` (Clean Mode - No Console Telemetry) + +Disables console telemetry output entirely for a clean Claude Code experience: + +- **No raw JSON output** cluttering the terminal +- **Telemetry still captured** and sent to backend for processing +- **Clean interactive experience** with Claude Code +- **Backend processing** of all telemetry events continues normally + +**Use when**: You want to use Claude Code without telemetry noise but still capture data remotely + +**Note**: This mode prioritizes user experience over real-time telemetry visibility. Use `--debug-otel` if you need to see telemetry events in real-time. + +### `--verbose-telemetry` (Backend Debug) + +Controls backend logging verbosity via `X-Verbose-Telemetry` header. + +- `false` (default): Clean logs, essential events only +- `true`: Detailed event-by-event logging with emojis + +**Use when**: Debugging backend processing issues + +## Tool Parameter Extraction + +One of the key features is extracting tool parameters from Claude Code events: + +### Flow + +1. Claude Code sends `tool_result` event with `tool_parameters` as JSON string +2. `ClaudeCodeEventAttributes.tool_parameters_json` property parses it +3. `claude_session_utils.py` stores parsed JSON in `AgentRunLog.tool_input` +4. Frontend components receive structured tool input data + +### Example + +Claude Code sends: + +```json +{ + "tool_parameters": "{\"bash_command\":\"find\",\"full_command\":\"find . -name '*.js'\",\"description\":\"Find JS files\"}" +} +``` + +We store in database: + +```json +{ + "bash_command": "find", + "full_command": "find . -name '*.js'", + "description": "Find JS files" +} +``` + +## Frontend Integration + +The telemetry data flows to our frontend agent trace system: + +### Components + +- `AgentTrace/Cards/ToolRenderers/ClaudeCode/` - Tool-specific renderers +- `ClaudeCodeTodoWriteConfig.tsx` - TodoWrite tool renderer +- `ClaudeCodeLsConfig.tsx` - File listing tool renderer + +### Schema Handling + +Frontend components use flexible Zod schemas (`z.any()`) to handle varying tool input structures from Claude Code. + +## Authentication & Security + +### API Authentication + +- Uses existing `codegen login` token system +- Token passed via `Authorization: Bearer ` header +- Organization membership verified on backend + +### Prompt Logging Security + +- **Default**: Prompt content redacted, only length tracked +- **`--log-prompts`**: Full prompt content logged (security risk) +- **Debug modes**: Auto-enable for troubleshooting + +## Error Handling + +### CLI Level + +- Tests Claude Code accessibility before starting +- Graceful Ctrl+C handling +- Clear error messages for missing authentication + +### Backend Level + +- Handles both camelCase and snake_case OTLP formats +- Fallback parsing for malformed JSON +- Transaction rollback on database errors + +### Frontend Level + +- Defensive parsing with optional chaining +- Debug sections for troubleshooting malformed data +- Flexible schemas to handle unexpected structures + +## Performance Considerations + +### Export Intervals + +- **Production**: 60 seconds (default) +- **Debug**: 5 seconds for faster feedback +- Configurable via `--export-interval` + +### Logging Verbosity + +- **Default**: Essential events only for clean logs +- **Verbose**: Full event details only when needed +- **Actions-only**: Filters noisy metrics in debug mode + +### Database Impact + +- Batched processing of events by session +- Efficient session lookup via meta field indexing +- Minimal AgentRun updates (only timestamp changes) + +## Common Issues & Solutions + +### "Input must be provided" Error + +- **Cause**: CLI capturing stdout/stderr preventing interactive mode +- **Solution**: Fixed by not capturing output in normal mode + +### Empty tool_input (`{}`) in Database + +- **Cause**: tool_parameters not being parsed as JSON +- **Solution**: Added `tool_parameters_json` property and improved parsing + +### Frontend Rendering Errors + +- **Cause**: Rigid Zod schemas expecting specific structures +- **Solution**: Made schemas flexible with `z.any()` and defensive parsing + +### CamelCase/Snake_Case Mismatches + +- **Cause**: OTLP data can come in either format +- **Solution**: Added fallback parsing for both formats + +## Extending the System + +### Adding New Tool Renderers + +1. Create new component in `ClaudeCode/tool/configs/` +2. Use flexible schema: `z.any()` or `z.union([...])` +3. Add defensive parsing with optional chaining +4. Include debug sections for troubleshooting + +### Adding New Event Types + +1. Update `ClaudeCodeEventAttributes` in `claude_types.py` +2. Add new `is_*` property to `ClaudeCodeEvent` +3. Handle in `log_claude_event_as_agent_run_log()` +4. Update frontend if needed + +### Debugging New Issues + +1. Start with `--debug-otel-actions-only` for clean action view +2. Use `--verbose-telemetry` for backend detail +3. Check raw OTLP data with `--debug-otel` if needed +4. Verify database entries in AgentRun/AgentRunLog tables + +## Future Improvements + +### Potential Enhancements + +- Real-time telemetry dashboard +- Cost tracking and budgeting +- Tool usage analytics +- Session replay functionality +- Claude Code version-specific optimizations + +### Architecture Considerations + +- Separate metrics and events processing +- Real-time vs batch processing modes +- Telemetry data retention policies +- Performance monitoring for high-volume usage + +## Related Documentation + +- [Claude Code OpenTelemetry Docs](https://docs.anthropic.com/en/docs/claude-code/monitoring-usage) +- [OpenTelemetry Protocol Specification](https://opentelemetry.io/docs/specs/otlp/) +- [Typer CLI Framework](https://typer.tiangolo.com/) [[memory:5707685]] +- [AgentRun Database Schema](../../../cloud/codegen-backend/app/db/models/agent/agent_run.py) + +--- + +_This documentation is maintained alongside the code. Update when making significant changes to the telemetry integration._ diff --git a/src/codegen/cli/commands/claude/hooks.py b/src/codegen/cli/commands/claude/hooks.py new file mode 100644 index 000000000..1e386cee4 --- /dev/null +++ b/src/codegen/cli/commands/claude/hooks.py @@ -0,0 +1,209 @@ +"""Claude hooks management for session tracking.""" + +import json +import os +import time +from pathlib import Path + +from rich.console import Console + +console = 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 hook is 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 startup hooks with our command + + Returns: + bool: True if hook was 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 write the session data + # Simple approach: just write to the session file + hook_command = f"mkdir -p {CODEGEN_DIR} && cat > {SESSION_FILE}" + + # 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"] = [] + + # Get existing session start hooks + session_start_hooks = hooks_config["hooks"]["SessionStart"] + + # Check if we're replacing an existing hook + replaced_existing = len(session_start_hooks) > 0 + + # Create the new hook structure (following Claude's format) + new_hook_group = {"hooks": [{"type": "command", "command": hook_command}]} + + # Replace all existing SessionStart hooks with our single hook + hooks_config["hooks"]["SessionStart"] = [new_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 startup hook", style="green") + else: + console.print("✅ Registered new Claude startup hook", style="green") + console.print(f" Hook command: {hook_command[:50]}...", style="dim") + + # Verify the hook was written correctly + try: + with open(HOOKS_CONFIG_FILE) as f: + verify_config = json.load(f) + + # Check that our hook is in the config + found_our_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_our_hook = True + break + + if found_our_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 wait_for_session_id(timeout: float = 10.0) -> str | None: + """Wait for Claude to write the session ID to disk. + + Args: + timeout: Maximum time to wait in seconds + + Returns: + Session ID if found, None otherwise + """ + start_time = time.time() + checked_count = 0 + + # Log where we're looking + if checked_count == 0: + console.print(f"📁 Looking for session file at: {SESSION_FILE}", style="dim") + + while time.time() - start_time < timeout: + if SESSION_FILE.exists(): + try: + with open(SESSION_FILE) as f: + content = f.read() + if content.strip(): + data = json.loads(content) + session_id = data.get("session_id") + if session_id: + console.print(f"🔍 Found session ID: {session_id}", style="dim") + return session_id + except (OSError, json.JSONDecodeError) as e: + # File might be in the process of being written + if checked_count % 10 == 0: # Log every second + console.print(f"⏳ Waiting for valid session data... ({e.__class__.__name__})", style="dim") + + checked_count += 1 + time.sleep(0.1) # Check every 100ms + + console.print(f"⏱️ Timeout waiting for session ID after {timeout}s", style="yellow") + return None + + +def cleanup_claude_hook() -> None: + """Remove the Codegen Claude hook 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 or "SessionStart" not in hooks_config["hooks"]: + return + + session_start_hooks = hooks_config["hooks"]["SessionStart"] + 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 the hooks only if we removed something + if modified: + hooks_config["hooks"]["SessionStart"] = new_session_hooks + + # Write updated config + 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 hook", 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..8876464ec --- /dev/null +++ b/src/codegen/cli/commands/claude/main.py @@ -0,0 +1,280 @@ +"""Claude Code command with OpenTelemetry monitoring.""" + +import os +import signal +import subprocess +import sys +import threading +import time + +import typer +from rich.console import Console + +from .hooks import cleanup_claude_hook, ensure_claude_hook, get_codegen_url, wait_for_session_id + +console = Console() + + +def claude( + org_id: int = typer.Option(11, help="Organization ID for telemetry endpoint"), + export_interval: int = typer.Option(60, help="Export interval in seconds (default: 60)"), + log_prompts: bool = typer.Option(False, help="Log user prompt content (security consideration)"), + console_output: bool = typer.Option(False, help="Output telemetry to console for debugging"), + normal_mode: bool = typer.Option(False, help="Run Claude Code without telemetry (normal mode)"), + debug_mode: bool = typer.Option(False, help="Show detailed debug information and run with verbose output"), + debug_otel: bool = typer.Option(False, help="Show real-time OpenTelemetry events and where they're being sent"), + debug_otel_actions_only: bool = typer.Option(False, help="Show only action-related OTLP events (tool usage, API calls) - cleaner than --debug-otel"), + verbose_telemetry: bool = typer.Option(False, help="Enable detailed telemetry event logging in backend (default: disabled for cleaner logs)"), +): + """Run Claude Code with OpenTelemetry monitoring and logging. + + This uses Claude Code's built-in OpenTelemetry support to monitor: + - API requests and responses + - Token usage and costs + - Tool usage and decisions + - Session metrics + - User interactions + """ + if normal_mode: + console.print("🚀 Running Claude Code in normal mode (no telemetry)...", style="blue") + try: + subprocess.run(["claude"], check=True) + except subprocess.CalledProcessError as e: + console.print(f"❌ Error running Claude Code: {e}", style="red") + raise typer.Exit(1) + except FileNotFoundError: + console.print("❌ Claude Code not found. Please install Claude Code first.", style="red") + console.print("💡 Visit: https://claude.ai/download", style="dim") + raise typer.Exit(1) + return + + console.print("🔍 Starting Claude Code with remote telemetry monitoring...", style="bold blue") + console.print(f"📊 Sending telemetry to organization {org_id}", style="dim") + + # Set up Claude hook for session tracking + if not ensure_claude_hook(): + console.print("⚠️ Failed to set up session tracking hook", style="yellow") + + # Set up environment variables for Claude Code OpenTelemetry + env = os.environ.copy() + + # Enable telemetry (required) + env["CLAUDE_CODE_ENABLE_TELEMETRY"] = "1" + + # Enable all telemetry features that might be disabled by default + env["OTEL_METRICS_INCLUDE_SESSION_ID"] = "true" + env["OTEL_METRICS_INCLUDE_ACCOUNT_UUID"] = "true" + env["OTEL_METRICS_INCLUDE_VERSION"] = "true" + + # Configure OTLP exporters to send to your remote endpoint + exporters = ["otlp"] + console_exporters = [] + + if console_output or debug_otel or debug_otel_actions_only: + console_exporters.append("console") + + # For actions-only mode, disable metrics console output to reduce noise + if debug_otel_actions_only: + env["OTEL_METRICS_EXPORTER"] = "otlp" # Only send metrics to remote, not console + env["OTEL_LOGS_EXPORTER"] = ",".join(["otlp", *console_exporters]) # Logs to both + console.print("🔇 Metrics console output disabled to reduce noise", style="dim") + else: + env["OTEL_METRICS_EXPORTER"] = ",".join(exporters) + env["OTEL_LOGS_EXPORTER"] = ",".join(exporters) + + # Configure OTLP endpoint to your remote API + from codegen.cli.api.endpoints import API_ENDPOINT + from codegen.cli.auth.token_manager import get_current_token + + # Set OTLP endpoint to your telemetry API + # Note: Claude Code will automatically append /v1/metrics and /v1/logs to this base URL + otlp_endpoint = f"{API_ENDPOINT.rstrip('/')}/v1/organizations/{org_id}/telemetry" + env["OTEL_EXPORTER_OTLP_ENDPOINT"] = otlp_endpoint + env["OTEL_EXPORTER_OTLP_PROTOCOL"] = "http/json" + + # Add authentication using your current token + token = get_current_token() + headers = [] + if token: + headers.append(f"Authorization=Bearer {token}") + console.print("🔐 Using stored authentication token", style="dim") + else: + console.print("⚠️ No authentication token found. Telemetry may fail.", style="yellow") + console.print("💡 Run 'codegen login' first", style="dim") + + # Add verbose telemetry flag + if verbose_telemetry: + headers.append("X-Verbose-Telemetry=true") + console.print("🔍 Enabled verbose telemetry logging", style="dim") + else: + console.print("🔇 Disabled verbose telemetry logging (use --verbose-telemetry to enable)", style="dim") + + if headers: + env["OTEL_EXPORTER_OTLP_HEADERS"] = ",".join(headers) + + # Set export intervals (in milliseconds) + # Use shorter intervals for faster telemetry delivery + env["OTEL_METRIC_EXPORT_INTERVAL"] = "5000" # 5 seconds + env["OTEL_LOGS_EXPORT_INTERVAL"] = "5000" # 5 seconds + + # Enable verbose OTLP debugging if requested + if debug_otel or debug_otel_actions_only: + env["OTEL_LOG_LEVEL"] = "DEBUG" + env["OTEL_EXPORTER_OTLP_DEBUG"] = "true" + env["OTEL_PYTHON_LOG_CORRELATION"] = "true" + + # Additional info for actions-only mode + if debug_otel_actions_only: + console.print("🎯 OTLP Debug: Actions-only mode enabled (metrics output filtered)", style="yellow") + + # Log user prompts if requested (security consideration) + # Also enable for debug_otel modes to help with troubleshooting + if log_prompts or debug_otel or debug_otel_actions_only: + env["OTEL_LOG_USER_PROMPTS"] = "1" + if (debug_otel or debug_otel_actions_only) and not log_prompts: + console.print("🐛 User prompt content logging ENABLED for debugging", style="yellow") + else: + console.print("⚠️ User prompt content logging is ENABLED", style="yellow") + else: + env["OTEL_LOG_USER_PROMPTS"] = "0" # Explicitly disable + console.print("🔒 User prompt content is redacted (only length tracked)", style="dim") + + # Add resource attributes for identification + resource_attrs = [ + "service.name=claude-code", + "service.version=monitored", + "deployment.environment=development", + f"organization.id={org_id}", + f"user.id={token}" if token else "user.id=unauthenticated", + ] + env["OTEL_RESOURCE_ATTRIBUTES"] = ",".join(resource_attrs) + + # Optional: Reduce cardinality for better performance + # env["OTEL_METRICS_INCLUDE_SESSION_ID"] = "false" + + # 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") + + console.print("🚀 Launching Claude Code with OpenTelemetry...", style="blue") + + # Debug output for telemetry configuration + if debug_mode or console_output or debug_otel: + console.print(f"[dim]Debug: OTLP Endpoint: {otlp_endpoint}[/dim]") + console.print(f"[dim]Debug: Metrics URL: {otlp_endpoint}/v1/metrics[/dim]") + console.print(f"[dim]Debug: Logs URL: {otlp_endpoint}/v1/logs[/dim]") + console.print(f"[dim]Debug: Organization ID: {org_id}[/dim]") + console.print(f"[dim]Debug: Resource Attributes: {','.join(resource_attrs)}[/dim]") + + if debug_otel: + console.print("[yellow]🐛 OTLP Debug Mode: Real-time telemetry events will be shown[/yellow]") + console.print("[dim]Export interval: 5 seconds (faster for debugging)[/dim]") + console.print("[dim]User prompts: enabled for debugging[/dim]") + console.print("[dim]Console export: enabled to see local events[/dim]") + + # Show key environment variables being set + console.print("[dim]Debug: Key environment variables:[/dim]") + otel_vars = {k: v for k, v in env.items() if k.startswith("OTEL_") or k.startswith("CLAUDE_CODE_")} + for key, value in otel_vars.items(): + if "HEADERS" in key and token: + # Mask the token for security + console.print(f"[dim] {key}=Authorization=Bearer ***[/dim]") + else: + console.print(f"[dim] {key}={value}[/dim]") + + # Create a panel with telemetry information + # Show minimal output unless in verbose mode + if console_output or debug_mode or debug_otel: + # Show detailed info only in debug modes + console.print(f"🌐 Telemetry endpoint: {otlp_endpoint}/v1/{{metrics|logs}}", style="dim") + console.print(f"🎯 Organization ID: {org_id}", style="dim") + console.print(f"⏱️ Export interval: {export_interval} seconds", style="dim") + else: + # Minimal output - we'll update with session ID once we have it + console.print("🔵 Starting Claude Code session...", style="blue") + + try: + # Launch Claude Code with telemetry enabled + if console_output or debug_mode or debug_otel: + # Run Claude Code with visible output - telemetry will appear in console + if debug_otel: + console.print("🐛 Running Claude Code with OTLP debug output - you'll see real-time telemetry events...\n", style="yellow") + console.print("📡 Look for HTTP requests to your telemetry API endpoints\n", style="dim") + console.print("💡 Try asking Claude Code a question or making a file edit to generate events\n", style="dim") + else: + console.print("📺 Running Claude Code with visible output for debugging...\n", style="dim") + process = subprocess.Popen(["claude"], env=env) + else: + # Run Claude Code in interactive mode but capture output for error handling + console.print("🎯 Running Claude Code in interactive mode with remote telemetry...\n", style="dim") + console.print("💡 Claude Code will start normally - telemetry data is being sent to your API\n", style="dim") + # Don't capture stdout/stderr - let Claude Code run normally + process = subprocess.Popen(["claude"], env=env) + + # Start monitoring for session ID in a background thread + session_id = None + session_url_printed = False + + def monitor_session(): + nonlocal session_id, session_url_printed + # Give Claude a moment to start up + time.sleep(2.0) + session_id = wait_for_session_id(timeout=30.0) # Wait up to 30 seconds + if session_id and not session_url_printed: + url = get_codegen_url(session_id) + console.print(f"\n🔵 Codegen URL: {url}\n", style="bold blue") + session_url_printed = True + + # Start session monitoring in background + session_thread = threading.Thread(target=monitor_session, daemon=True) + session_thread.start() + + # Handle Ctrl+C gracefully + def signal_handler(signum, frame): + console.print("\n🛑 Stopping Claude Code and telemetry collection...", style="yellow") + process.terminate() + cleanup_claude_hook() # Clean up our hook + sys.exit(0) + + signal.signal(signal.SIGINT, signal_handler) + + # Wait for Claude Code to finish + returncode = process.wait() + + 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") + raise typer.Exit(1) + except KeyboardInterrupt: + console.print("\n🛑 Interrupted by user", style="yellow") + except Exception as e: + console.print(f"❌ Error running Claude Code: {e}", style="red") + raise typer.Exit(1) + finally: + # Clean up hook + cleanup_claude_hook() + + if session_id: + url = get_codegen_url(session_id) + console.print(f"\n🔵 Session URL: {url}", style="bold blue") + + console.print(f"📊 Telemetry data was sent to: {otlp_endpoint}/v1/{{metrics|logs}}", style="dim") + console.print(f"🎯 Organization ID: {org_id}", style="dim") + console.print("💡 Check your backend logs to see the processed telemetry data", style="dim") + console.print("📖 Claude Code docs: https://docs.anthropic.com/en/docs/claude-code/monitoring-usage", style="dim") diff --git a/src/codegen/cli/commands/integrations/__init__.py b/src/codegen/cli/commands/integrations/__init__.py new file mode 100644 index 000000000..82f34a41d --- /dev/null +++ b/src/codegen/cli/commands/integrations/__init__.py @@ -0,0 +1 @@ +"""Integrations command module.""" diff --git a/src/codegen/cli/commands/integrations/main.py b/src/codegen/cli/commands/integrations/main.py new file mode 100644 index 000000000..246feaf0d --- /dev/null +++ b/src/codegen/cli/commands/integrations/main.py @@ -0,0 +1,136 @@ +"""Integrations command for the Codegen CLI.""" + +import webbrowser + +import requests +import typer +from rich.console import Console +from rich.table import Table + +from codegen.cli.api.endpoints import API_ENDPOINT +from codegen.cli.auth.token_manager import get_current_token +from codegen.cli.utils.url import generate_webapp_url + +console = Console() + +# Create the integrations app +integrations_app = typer.Typer(help="Manage Codegen integrations") + + +@integrations_app.command("list") +def list_integrations(): + """List organization integrations from the Codegen API.""" + console.print("🔌 Fetching organization integrations...", style="bold blue") + + # Get the current token + token = get_current_token() + if not token: + console.print("[red]Error:[/red] Not authenticated. Please run 'codegen login' first.") + raise typer.Exit(1) + + try: + # Make API request to list integrations + headers = {"Authorization": f"Bearer {token}"} + url = f"{API_ENDPOINT.rstrip('/')}/v1/organizations/11/integrations" + response = requests.get(url, headers=headers) + response.raise_for_status() + + response_data = response.json() + + # Extract integrations from the response structure + integrations_data = response_data.get("integrations", []) + organization_name = response_data.get("organization_name", "Unknown") + total_active = response_data.get("total_active_integrations", 0) + + if not integrations_data: + console.print("[yellow]No integrations found.[/yellow]") + return + + # Create a table to display integrations + table = Table( + title=f"Integrations for {organization_name}", + border_style="blue", + show_header=True, + title_justify="center", + ) + table.add_column("Integration", style="cyan", no_wrap=True) + table.add_column("Status", style="white", justify="center") + table.add_column("Type", style="magenta") + table.add_column("Details", style="dim") + + # Add integrations to table + for integration in integrations_data: + integration_type = integration.get("integration_type", "Unknown") + active = integration.get("active", False) + token_id = integration.get("token_id") + installation_id = integration.get("installation_id") + metadata = integration.get("metadata", {}) + + # Status with emoji + status = "✅ Active" if active else "❌ Inactive" + + # Determine integration category + if integration_type.endswith("_user"): + category = "User Token" + elif integration_type.endswith("_app"): + category = "App Install" + elif integration_type in ["github", "slack_app", "linear_app"]: + category = "App Install" + else: + category = "Token-based" + + # Build details string + details = [] + if token_id: + details.append(f"Token ID: {token_id}") + if installation_id: + details.append(f"Install ID: {installation_id}") + if metadata and isinstance(metadata, dict): + for key, value in metadata.items(): + if key == "webhook_secret": + details.append(f"{key}: ***secret***") + else: + details.append(f"{key}: {value}") + + details_str = ", ".join(details) if details else "No details" + if len(details_str) > 50: + details_str = details_str[:47] + "..." + + table.add_row(integration_type.replace("_", " ").title(), status, category, details_str) + + console.print(table) + console.print(f"\n[green]Total: {len(integrations_data)} integrations ({total_active} active)[/green]") + + except requests.RequestException as e: + console.print(f"[red]Error fetching integrations:[/red] {e}", style="bold red") + raise typer.Exit(1) + except Exception as e: + console.print(f"[red]Unexpected error:[/red] {e}", style="bold red") + raise typer.Exit(1) + + +@integrations_app.command("add") +def add_integration(): + """Open the Codegen integrations page in your browser to add new integrations.""" + console.print("🌐 Opening Codegen integrations page...", style="bold blue") + + # Generate the web URL using the environment-aware utility + web_url = generate_webapp_url("integrations") + + try: + webbrowser.open(web_url) + console.print(f"✅ Opened [link]{web_url}[/link] in your browser", style="green") + console.print("💡 You can add new integrations from the web interface", style="dim") + except Exception as e: + console.print(f"❌ Failed to open browser: {e}", style="red") + console.print(f"🔗 Please manually visit: {web_url}", style="yellow") + + +# Default callback for the integrations app +@integrations_app.callback(invoke_without_command=True) +def integrations_callback(ctx: typer.Context): + """Manage Codegen integrations.""" + if ctx.invoked_subcommand is None: + # If no subcommand is provided, show help + print(ctx.get_help()) + raise typer.Exit() diff --git a/src/codegen/cli/commands/login/main.py b/src/codegen/cli/commands/login/main.py index 3053a1a15..483547782 100644 --- a/src/codegen/cli/commands/login/main.py +++ b/src/codegen/cli/commands/login/main.py @@ -1,4 +1,3 @@ - import rich import typer diff --git a/src/codegen/cli/commands/mcp/__init__.py b/src/codegen/cli/commands/mcp/__init__.py index e69de29bb..ef4b55200 100644 --- a/src/codegen/cli/commands/mcp/__init__.py +++ b/src/codegen/cli/commands/mcp/__init__.py @@ -0,0 +1 @@ +"""MCP command module.""" diff --git a/src/codegen/cli/commands/mcp/main.py b/src/codegen/cli/commands/mcp/main.py index 4942b7353..5bcc230c8 100644 --- a/src/codegen/cli/commands/mcp/main.py +++ b/src/codegen/cli/commands/mcp/main.py @@ -1,12 +1,49 @@ """MCP server command for the Codegen CLI.""" +from typing import Any +import requests import typer from rich.console import Console +from codegen.cli.api.endpoints import API_ENDPOINT +from codegen.cli.auth.token_manager import get_current_token + console = Console() +def fetch_tools_for_mcp() -> list[dict[str, Any]]: + """Fetch available tools from the API for MCP server generation.""" + try: + token = get_current_token() + if not token: + console.print("[red]Error:[/red] Not authenticated. Please run 'codegen login' first.") + raise typer.Exit(1) + + console.print("🔧 Fetching available tools from API...", style="dim") + headers = {"Authorization": f"Bearer {token}"} + url = f"{API_ENDPOINT.rstrip('/')}/v1/organizations/11/tools" + response = requests.get(url, headers=headers) + response.raise_for_status() + + response_data = response.json() + + # Extract tools from the response structure + if isinstance(response_data, dict) and "tools" in response_data: + tools = response_data["tools"] + console.print(f"✅ Found {len(tools)} tools", style="green") + return tools + + return response_data if isinstance(response_data, list) else [] + + except requests.RequestException as e: + console.print(f"[red]Error fetching tools:[/red] {e}", style="bold red") + raise typer.Exit(1) + except Exception as e: + console.print(f"[red]Unexpected error:[/red] {e}", style="bold red") + raise typer.Exit(1) + + def mcp( host: str = typer.Option("localhost", help="Host to bind the MCP server to"), port: int | None = typer.Option(None, help="Port to bind the MCP server to (default: stdio transport)"), @@ -24,14 +61,20 @@ def mcp( # Validate transport if transport not in ["stdio", "http"]: - console.print(f"❌ Invalid transport: {transport}. Must be 'stdio' or 'http'", style="bold red") + console.print( + f"❌ Invalid transport: {transport}. Must be 'stdio' or 'http'", + style="bold red", + ) raise typer.Exit(1) + # Fetch tools from API before starting server + tools = fetch_tools_for_mcp() + # Import here to avoid circular imports and ensure dependencies are available from codegen.cli.mcp.server import run_server try: - run_server(transport=transport, host=host, port=port) + run_server(transport=transport, host=host, port=port, available_tools=tools) except KeyboardInterrupt: console.print("\n👋 MCP server stopped", style="yellow") except Exception as e: diff --git a/src/codegen/cli/commands/tools/__init__.py b/src/codegen/cli/commands/tools/__init__.py new file mode 100644 index 000000000..2fcf268de --- /dev/null +++ b/src/codegen/cli/commands/tools/__init__.py @@ -0,0 +1 @@ +"""Tools command module.""" diff --git a/src/codegen/cli/commands/tools/main.py b/src/codegen/cli/commands/tools/main.py new file mode 100644 index 000000000..623a3aceb --- /dev/null +++ b/src/codegen/cli/commands/tools/main.py @@ -0,0 +1,90 @@ +"""Tools command for the Codegen CLI.""" + +import requests +import typer +from rich.console import Console +from rich.table import Table + +from codegen.cli.api.endpoints import API_ENDPOINT +from codegen.cli.auth.token_manager import get_current_token + +console = Console() + + +def tools(): + """List available tools from the Codegen API.""" + console.print("🔧 Fetching available tools...", style="bold blue") + + # Get the current token + token = get_current_token() + if not token: + console.print("[red]Error:[/red] Not authenticated. Please run 'codegen login' first.") + raise typer.Exit(1) + + try: + # Make API request to list tools + headers = {"Authorization": f"Bearer {token}"} + url = f"{API_ENDPOINT.rstrip('/')}/v1/organizations/11/tools" + response = requests.get(url, headers=headers) + response.raise_for_status() + + response_data = response.json() + + # Extract tools from the response structure + if isinstance(response_data, dict) and "tools" in response_data: + tools_data = response_data["tools"] + total_count = response_data.get("total_count", len(tools_data)) + else: + tools_data = response_data + total_count = len(tools_data) if isinstance(tools_data, list) else 1 + + if not tools_data: + console.print("[yellow]No tools found.[/yellow]") + return + + # Handle case where response might be a list of strings vs list of objects + if isinstance(tools_data, list) and len(tools_data) > 0: + # Check if first item is a string or object + if isinstance(tools_data[0], str): + # Simple list of tool names + console.print(f"[green]Found {len(tools_data)} tools:[/green]") + for tool_name in tools_data: + console.print(f" • {tool_name}") + return + + # Create a table to display tools (for structured data) + table = Table( + title="Available Tools", + border_style="blue", + show_header=True, + title_justify="center", + ) + table.add_column("Tool Name", style="cyan", no_wrap=True) + table.add_column("Description", style="white") + table.add_column("Category", style="magenta") + + # Add tools to table + for tool in tools_data: + if isinstance(tool, dict): + tool_name = tool.get("name", "Unknown") + description = tool.get("description", "No description available") + category = tool.get("category", "General") + + # Truncate long descriptions + if len(description) > 80: + description = description[:77] + "..." + + table.add_row(tool_name, description, category) + else: + # Fallback for non-dict items + table.add_row(str(tool), "Unknown", "General") + + console.print(table) + console.print(f"\n[green]Found {total_count} tools available.[/green]") + + except requests.RequestException as e: + console.print(f"[red]Error fetching tools:[/red] {e}", style="bold red") + raise typer.Exit(1) + except Exception as e: + console.print(f"[red]Unexpected error:[/red] {e}", style="bold red") + raise typer.Exit(1) diff --git a/src/codegen/cli/mcp/api_client.py b/src/codegen/cli/mcp/api_client.py new file mode 100644 index 000000000..dfbaba19c --- /dev/null +++ b/src/codegen/cli/mcp/api_client.py @@ -0,0 +1,52 @@ +"""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 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 + + +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..ee604e3f1 --- /dev/null +++ b/src/codegen/cli/mcp/tools/executor.py @@ -0,0 +1,27 @@ +"""Tool execution logic for the Codegen MCP server.""" + +import requests + +from codegen.cli.api.endpoints import API_ENDPOINT +from codegen.cli.auth.token_manager import get_current_token + + +def execute_tool_via_api(tool_name: str, arguments: dict): + """Execute a tool via the Codegen API.""" + try: + token = get_current_token() + if not token: + return {"error": "Not authenticated. Please run 'codegen login' first."} + + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + url = f"{API_ENDPOINT.rstrip('/')}/v1/organizations/11/tools/execute" + + payload = {"tool_name": tool_name, "arguments": arguments} + + response = requests.post(url, json=payload, headers=headers) + response.raise_for_status() + + return response.json() + + except Exception as e: + return {"error": f"Error executing tool {tool_name}: {e}"} 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/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"