diff --git a/CLAUDE.md b/CLAUDE.md index a2241b1..265f004 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,28 @@ From [obra/superpowers](https://github.com/obra/superpowers): - **DeepWiki** - AI-powered documentation for any GitHub repository - **Exa** - Web search and code context retrieval +## AI CLI Tools + +Three AI coding CLIs are pre-configured with Databricks Model Serving: + +| CLI | Command | Protocol | Models | +|-----|---------|----------|--------| +| **Claude Code** | `claude` | Anthropic-native | Claude Sonnet 4.5 | +| **OpenCode** | `opencode` | OpenAI-compatible | Claude, Gemini, Llama | +| **Gemini CLI** | `gemini` | Google Gemini-native | Gemini 2.5 Flash/Pro | + +Switch models in OpenCode: +```bash +opencode -m databricks/databricks-gemini-2-5-flash +opencode -m databricks/databricks-claude-sonnet-4-5 +``` + +Switch models in Gemini CLI: +```bash +gemini -m gemini-2.5-flash +gemini -m gemini-2.5-pro +``` + ## Databricks CLI The Databricks CLI is pre-configured with your credentials. Test it: diff --git a/README.md b/README.md index f44f2e6..6801768 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,10 @@ Just use it all on Databricks, from the browser. Wired up to model serving endpo ✅ **Claude Code CLI** - Pre-configured to use Databricks hosted models as the API endpoint +✅ **OpenCode CLI** - Pre-configured with Databricks as an OpenAI-compatible provider (Gemini, Claude, Llama models) + +✅ **Gemini CLI** - Pre-configured with Databricks-hosted Gemini models via Google-native endpoint + ✅ **Configurable Model** - Switch between Claude models via `app.yaml` (default: `databricks-claude-sonnet-4-5`) ✅ **Micro Editor** - Ships with [micro](https://micro-editor.github.io/), a modern terminal-based text editor @@ -156,7 +160,10 @@ claude-code-cli-bricks/ ├── CLAUDE.md # Claude Code welcome message ├── requirements.txt # Python dependencies ├── setup_claude.py # Claude Code CLI + MCP configuration +├── setup_opencode.py # OpenCode CLI + Databricks provider config +├── setup_gemini.py # Gemini CLI + Databricks endpoint config ├── setup_databricks.py # Databricks CLI configuration +├── test_integrations.py # Integration tests for all CLI tools ├── sync_to_workspace.py # Git hook for Databricks sync ├── static/ │ ├── index.html # Terminal UI @@ -253,11 +260,60 @@ When deployed, git commits automatically sync your projects to Databricks Worksp This is enabled via a git post-commit hook configured by `setup_claude.py`. +## AI CLI Tools + +Three AI coding CLIs are pre-configured to use Databricks Model Serving: + +### Claude Code +Default CLI. Uses Anthropic-native protocol via `/serving-endpoints/anthropic`. +```bash +claude # Start Claude Code +``` + +### OpenCode +Uses OpenAI-compatible protocol via `/serving-endpoints`. Supports multiple model providers. +```bash +opencode # Start with default model +opencode -m databricks/databricks-gemini-2-5-flash # Use Gemini Flash +opencode -m databricks/databricks-claude-sonnet-4-5 # Use Claude +opencode -m databricks/databricks-meta-llama-3-3-70b-instruct # Use Llama +``` + +### Gemini CLI +Uses Google Gemini-native protocol via `/serving-endpoints/google`. +```bash +gemini # Start Gemini CLI +gemini -m gemini-2.5-flash # Use Gemini 2.5 Flash +gemini -m gemini-2.5-pro # Use Gemini 2.5 Pro +``` + +### Available Databricks-Hosted Models + +| Model | OpenCode Name | Gemini CLI Name | +|-------|---------------|-----------------| +| Claude Sonnet 4.5 | `databricks/databricks-claude-sonnet-4-5` | N/A (use Claude Code) | +| Gemini 2.5 Flash | `databricks/databricks-gemini-2-5-flash` | `gemini-2.5-flash` | +| Gemini 2.5 Pro | `databricks/databricks-gemini-2-5-pro` | `gemini-2.5-pro` | +| Llama 3.3 70B | `databricks/databricks-meta-llama-3-3-70b-instruct` | N/A | + +### Testing Integrations + +Run the integration test suite: +```bash +# Structural tests (no credentials needed) +python test_integrations.py + +# Live API tests (with Databricks credentials) +DATABRICKS_HOST=https://your-workspace.cloud.databricks.com \ +DATABRICKS_TOKEN=dapi-xxx \ +python test_integrations.py +``` + ## Technologies - **Backend**: Flask, Python PTY/termios - **Frontend**: xterm.js, FitAddon -- **Integration**: Databricks SDK, Claude Agent SDK +- **Integration**: Databricks SDK, Claude Agent SDK, OpenCode, Gemini CLI ## License diff --git a/app.yaml b/app.yaml index 345676f..f56cd2f 100644 --- a/app.yaml +++ b/app.yaml @@ -1,7 +1,7 @@ command: - bash - -c - - "mkdir -p ~/.local/bin && bash install_micro.sh && mv micro ~/.local/bin/ 2>/dev/null || true && python setup_claude.py && python setup_databricks.py && python app.py" + - "mkdir -p ~/.local/bin && bash install_micro.sh && mv micro ~/.local/bin/ 2>/dev/null || true && python setup_claude.py && python setup_opencode.py && python setup_gemini.py && python setup_databricks.py && python app.py" env: - name: HOME value: /app/python/source_code diff --git a/app.yaml.template b/app.yaml.template index 398491d..63817ad 100644 --- a/app.yaml.template +++ b/app.yaml.template @@ -1,7 +1,7 @@ command: - bash - -c - - "mkdir -p ~/.local/bin && bash install_micro.sh && mv micro ~/.local/bin/ 2>/dev/null || true && python setup_claude.py && python setup_databricks.py && python app.py" + - "mkdir -p ~/.local/bin && bash install_micro.sh && mv micro ~/.local/bin/ 2>/dev/null || true && python setup_claude.py && python setup_opencode.py && python setup_gemini.py && python setup_databricks.py && python app.py" env: - name: HOME value: /app/python/source_code diff --git a/setup_gemini.py b/setup_gemini.py new file mode 100644 index 0000000..d853b79 --- /dev/null +++ b/setup_gemini.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python +"""Configure Gemini CLI with Databricks Model Serving. + +Gemini CLI uses the Google Generative Language API protocol, not OpenAI-compatible. +Databricks provides a Google-native endpoint at /serving-endpoints/google +(similar to /serving-endpoints/anthropic for Claude). + +PR #11893 (by Databricks engineer AarushiShah) added auto-detection of *.databricks.com +URLs, switching to Bearer token auth automatically. + +Auth: GEMINI_API_KEY_AUTH_MECHANISM=bearer sends Databricks PAT as Bearer token. +""" +import os +import json +import subprocess +from pathlib import Path + +# Set HOME if not properly set +if not os.environ.get("HOME") or os.environ["HOME"] == "/": + os.environ["HOME"] = "/app/python/source_code" + +home = Path(os.environ["HOME"]) + +host = os.environ.get("DATABRICKS_HOST", "") +token = os.environ.get("DATABRICKS_TOKEN", "") + +if not host or not token: + print("Warning: DATABRICKS_HOST or DATABRICKS_TOKEN not set, skipping Gemini CLI config") + exit(0) + +# Strip trailing slash from host +host = host.rstrip("/") + +# 1. Install Gemini CLI if not present +gemini_installed = subprocess.run( + ["which", "gemini"], capture_output=True, text=True +).returncode == 0 + +if not gemini_installed: + print("Installing Gemini CLI...") + result = subprocess.run( + ["npm", "install", "-g", "@google/gemini-cli"], + capture_output=True, text=True, + env={**os.environ, "HOME": str(home)} + ) + if result.returncode == 0: + print("Gemini CLI installed successfully") + else: + print(f"Gemini CLI install warning: {result.stderr}") +else: + print("Gemini CLI already installed") + +# 2. Create ~/.gemini directory and configure environment +gemini_dir = home / ".gemini" +gemini_dir.mkdir(exist_ok=True) + +# Write .env file with Databricks endpoint configuration +# Gemini CLI auto-loads env from ~/.gemini/.env +# The Google-native endpoint on Databricks mirrors /serving-endpoints/anthropic +env_content = f"""# Databricks Model Serving - Google Gemini native endpoint +GOOGLE_GEMINI_BASE_URL={host}/serving-endpoints/google +GEMINI_API_KEY={token} +GEMINI_API_KEY_AUTH_MECHANISM=bearer +""" + +env_path = gemini_dir / ".env" +env_path.write_text(env_content) +env_path.chmod(0o600) +print(f"Gemini CLI env configured: {env_path}") + +# 3. Write settings.json with model preferences +settings = { + "theme": "Default", + "selectedAuthType": "api-key" +} + +settings_path = gemini_dir / "settings.json" +settings_path.write_text(json.dumps(settings, indent=2)) +print(f"Gemini CLI settings configured: {settings_path}") + +print("\nGemini CLI ready! Usage:") +print(" gemini # Start Gemini CLI") +print(f" gemini -m gemini-2.5-flash # Use Gemini 2.5 Flash") +print(f" gemini -m gemini-2.5-pro # Use Gemini 2.5 Pro") +print(f"\nEndpoint: {host}/serving-endpoints/google") +print("Auth: Bearer token (Databricks PAT)") diff --git a/setup_opencode.py b/setup_opencode.py new file mode 100644 index 0000000..0689e68 --- /dev/null +++ b/setup_opencode.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python +"""Configure OpenCode CLI with Databricks Model Serving as an OpenAI-compatible provider.""" +import os +import json +import subprocess +from pathlib import Path + +# Set HOME if not properly set +if not os.environ.get("HOME") or os.environ["HOME"] == "/": + os.environ["HOME"] = "/app/python/source_code" + +home = Path(os.environ["HOME"]) + +host = os.environ.get("DATABRICKS_HOST", "") +token = os.environ.get("DATABRICKS_TOKEN", "") + +if not host or not token: + print("Warning: DATABRICKS_HOST or DATABRICKS_TOKEN not set, skipping OpenCode config") + exit(0) + +# Strip trailing slash from host +host = host.rstrip("/") + +# 1. Install OpenCode CLI if not present +opencode_bin = home / ".local" / "bin" / "opencode" +npm_global_bin = subprocess.run( + ["npm", "config", "get", "prefix"], + capture_output=True, text=True +).stdout.strip() + +# Check if opencode is already installed anywhere on PATH +opencode_installed = subprocess.run( + ["which", "opencode"], capture_output=True, text=True +).returncode == 0 + +if not opencode_installed: + print("Installing OpenCode CLI...") + result = subprocess.run( + ["npm", "install", "-g", "opencode-ai@latest"], + capture_output=True, text=True, + env={**os.environ, "HOME": str(home)} + ) + if result.returncode == 0: + print("OpenCode CLI installed successfully") + else: + print(f"OpenCode install warning: {result.stderr}") +else: + print("OpenCode CLI already installed") + +# 2. Write global opencode.json config +# OpenCode looks for config at ~/.config/opencode/opencode.json (global) +# and ./opencode.json (project-level) +opencode_config_dir = home / ".config" / "opencode" +opencode_config_dir.mkdir(parents=True, exist_ok=True) + +# Databricks OpenAI-compatible endpoint: {host}/serving-endpoints +# Model names follow: databricks-- +opencode_config = { + "$schema": "https://opencode.ai/config.json", + "provider": { + "databricks": { + "npm": "@ai-sdk/openai-compatible", + "name": "Databricks Model Serving", + "options": { + "baseURL": f"{host}/serving-endpoints", + "apiKey": "{env:DATABRICKS_TOKEN}" + }, + "models": { + "databricks-claude-sonnet-4-5": { + "name": "Claude Sonnet 4.5 (Databricks)", + "limit": { + "context": 200000, + "output": 8192 + } + }, + "databricks-gemini-2-5-flash": { + "name": "Gemini 2.5 Flash (Databricks)", + "limit": { + "context": 1000000, + "output": 8192 + } + }, + "databricks-gemini-2-5-pro": { + "name": "Gemini 2.5 Pro (Databricks)", + "limit": { + "context": 1000000, + "output": 8192 + } + }, + "databricks-meta-llama-3-3-70b-instruct": { + "name": "Llama 3.3 70B (Databricks)", + "limit": { + "context": 128000, + "output": 4096 + } + } + } + } + }, + "model": "databricks/databricks-claude-sonnet-4-5" +} + +config_path = opencode_config_dir / "opencode.json" +config_path.write_text(json.dumps(opencode_config, indent=2)) +print(f"OpenCode configured: {config_path}") + +# 3. Also create auth credentials for the databricks provider +# OpenCode stores credentials at ~/.local/share/opencode/auth.json +opencode_data_dir = home / ".local" / "share" / "opencode" +opencode_data_dir.mkdir(parents=True, exist_ok=True) + +auth_data = { + "databricks": { + "api_key": token + } +} + +auth_path = opencode_data_dir / "auth.json" +auth_path.write_text(json.dumps(auth_data, indent=2)) +auth_path.chmod(0o600) +print(f"OpenCode auth configured: {auth_path}") + +print("\nOpenCode ready! Usage:") +print(" opencode # Start OpenCode TUI") +print(" opencode -m databricks/databricks-gemini-2-5-flash # Use Gemini") +print(" opencode -m databricks/databricks-claude-sonnet-4-5 # Use Claude") diff --git a/test_integrations.py b/test_integrations.py new file mode 100644 index 0000000..9e0bcfc --- /dev/null +++ b/test_integrations.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python +"""Integration tests for OpenCode and Gemini CLI setup scripts. + +Tests config file generation, CLI installation, and endpoint configuration. +Run with: python test_integrations.py + +For live API tests, set DATABRICKS_HOST and DATABRICKS_TOKEN environment variables. +""" +import os +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +# Test configuration +TEST_HOST = os.environ.get("DATABRICKS_HOST", "https://test-workspace.cloud.databricks.com") +TEST_TOKEN = os.environ.get("DATABRICKS_TOKEN", "dapi-test-token-12345") +LIVE_MODE = bool(os.environ.get("DATABRICKS_HOST") and os.environ.get("DATABRICKS_TOKEN")) + +passed = 0 +failed = 0 + + +def test(name, condition, detail=""): + global passed, failed + if condition: + passed += 1 + print(f" PASS: {name}") + else: + failed += 1 + print(f" FAIL: {name}" + (f" - {detail}" if detail else "")) + + +def section(name): + print(f"\n{'=' * 60}") + print(f" {name}") + print(f"{'=' * 60}") + + +# ========================================== +# 1. CLI Installation Tests +# ========================================== +section("CLI Installation") + +opencode_result = subprocess.run(["which", "opencode"], capture_output=True, text=True) +test("OpenCode CLI is installed", opencode_result.returncode == 0, + f"not found (expected in PATH)") + +gemini_result = subprocess.run(["which", "gemini"], capture_output=True, text=True) +test("Gemini CLI is installed", gemini_result.returncode == 0, + f"not found (expected in PATH)") + +opencode_ver = subprocess.run(["opencode", "--version"], capture_output=True, text=True) +test("OpenCode CLI runs", opencode_ver.returncode == 0 and opencode_ver.stdout.strip(), + f"version: {opencode_ver.stdout.strip()}") + +gemini_ver = subprocess.run(["gemini", "--version"], capture_output=True, text=True) +test("Gemini CLI runs", gemini_ver.returncode == 0 and gemini_ver.stdout.strip(), + f"version: {gemini_ver.stdout.strip()}") + + +# ========================================== +# 2. Setup Script Tests +# ========================================== +section("Setup Script Execution") + +# Run setup scripts with test/real credentials +env = {**os.environ, "DATABRICKS_HOST": TEST_HOST, "DATABRICKS_TOKEN": TEST_TOKEN} + +opencode_setup = subprocess.run( + [sys.executable, "setup_opencode.py"], + capture_output=True, text=True, env=env, + cwd="/home/user/claude-code-cli-bricks" +) +test("setup_opencode.py runs successfully", opencode_setup.returncode == 0, + opencode_setup.stderr if opencode_setup.returncode != 0 else "") + +gemini_setup = subprocess.run( + [sys.executable, "setup_gemini.py"], + capture_output=True, text=True, env=env, + cwd="/home/user/claude-code-cli-bricks" +) +test("setup_gemini.py runs successfully", gemini_setup.returncode == 0, + gemini_setup.stderr if gemini_setup.returncode != 0 else "") + + +# ========================================== +# 3. OpenCode Config Validation +# ========================================== +section("OpenCode Configuration") + +home = Path(os.environ.get("HOME", "/root")) +opencode_config_path = home / ".config" / "opencode" / "opencode.json" + +test("OpenCode config file exists", opencode_config_path.exists()) + +if opencode_config_path.exists(): + config = json.loads(opencode_config_path.read_text()) + + # Provider config + test("Databricks provider defined", + "databricks" in config.get("provider", {})) + + db_provider = config.get("provider", {}).get("databricks", {}) + test("Uses @ai-sdk/openai-compatible", + db_provider.get("npm") == "@ai-sdk/openai-compatible") + + base_url = db_provider.get("options", {}).get("baseURL", "") + test("Base URL points to /serving-endpoints", + base_url.endswith("/serving-endpoints"), + f"got: {base_url}") + test("Base URL contains workspace host", + TEST_HOST.replace("https://", "") in base_url, + f"got: {base_url}") + + api_key = db_provider.get("options", {}).get("apiKey", "") + test("API key uses env var reference", + api_key == "{env:DATABRICKS_TOKEN}", + f"got: {api_key}") + + # Models + models = db_provider.get("models", {}) + test("Claude model defined", "databricks-claude-sonnet-4-5" in models) + test("Gemini Flash model defined", "databricks-gemini-2-5-flash" in models) + test("Gemini Pro model defined", "databricks-gemini-2-5-pro" in models) + test("Llama model defined", "databricks-meta-llama-3-3-70b-instruct" in models) + + # Default model + test("Default model set", + config.get("model", "").startswith("databricks/"), + f"got: {config.get('model')}") + + # Verify models visible to opencode + models_result = subprocess.run( + ["opencode", "models", "databricks"], + capture_output=True, text=True, + env={**os.environ, "DATABRICKS_TOKEN": TEST_TOKEN} + ) + if models_result.returncode == 0: + output = models_result.stdout.strip() + test("OpenCode lists Gemini Flash model", + "databricks-gemini-2-5-flash" in output, output) + test("OpenCode lists Claude model", + "databricks-claude-sonnet-4-5" in output, output) + + +# ========================================== +# 4. Gemini CLI Config Validation +# ========================================== +section("Gemini CLI Configuration") + +gemini_env_path = home / ".gemini" / ".env" +gemini_settings_path = home / ".gemini" / "settings.json" + +test("Gemini .env file exists", gemini_env_path.exists()) +test("Gemini settings.json exists", gemini_settings_path.exists()) + +if gemini_env_path.exists(): + env_content = gemini_env_path.read_text() + test("GOOGLE_GEMINI_BASE_URL set", + "GOOGLE_GEMINI_BASE_URL=" in env_content) + test("Base URL contains /serving-endpoints/google", + "/serving-endpoints/google" in env_content, + f"content: {env_content.strip()}") + test("GEMINI_API_KEY set", + "GEMINI_API_KEY=" in env_content) + test("Bearer auth mechanism configured", + "GEMINI_API_KEY_AUTH_MECHANISM=bearer" in env_content) + + # Check permissions + import stat + mode = gemini_env_path.stat().st_mode + test(".env file has restricted permissions", + not (mode & stat.S_IROTH) and not (mode & stat.S_IWOTH), + f"mode: {oct(mode)}") + +if gemini_settings_path.exists(): + settings = json.loads(gemini_settings_path.read_text()) + test("Auth type set to api-key", + settings.get("selectedAuthType") == "api-key") + + +# ========================================== +# 5. Live API Tests (only with real credentials) +# ========================================== +if LIVE_MODE: + section("Live API Tests (Databricks)") + + # Test OpenAI-compatible endpoint with curl + import urllib.request + import urllib.error + + # Test OpenCode endpoint (OpenAI-compatible) + openai_url = f"{TEST_HOST}/serving-endpoints/chat/completions" + try: + req = urllib.request.Request( + openai_url, + data=json.dumps({ + "model": "databricks-gemini-2-5-flash", + "messages": [{"role": "user", "content": "Say hello in one word."}], + "max_tokens": 10 + }).encode(), + headers={ + "Authorization": f"Bearer {TEST_TOKEN}", + "Content-Type": "application/json" + } + ) + resp = urllib.request.urlopen(req, timeout=30) + data = json.loads(resp.read()) + content = data.get("choices", [{}])[0].get("message", {}).get("content", "") + test("OpenAI-compatible endpoint works (Gemini Flash)", + bool(content), f"response: {content[:100]}") + except urllib.error.HTTPError as e: + body = e.read().decode() if e.fp else "" + test("OpenAI-compatible endpoint works (Gemini Flash)", + False, f"HTTP {e.code}: {body[:200]}") + except Exception as e: + test("OpenAI-compatible endpoint works (Gemini Flash)", + False, str(e)) + + # Test Gemini-native endpoint + gemini_url = f"{TEST_HOST}/serving-endpoints/google/v1beta/models/gemini-2.5-flash:generateContent" + try: + req = urllib.request.Request( + gemini_url, + data=json.dumps({ + "contents": [{"role": "user", "parts": [{"text": "Say hello in one word."}]}], + "generationConfig": {"maxOutputTokens": 10} + }).encode(), + headers={ + "Authorization": f"Bearer {TEST_TOKEN}", + "Content-Type": "application/json" + } + ) + resp = urllib.request.urlopen(req, timeout=30) + data = json.loads(resp.read()) + content = data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "") + test("Gemini-native endpoint works (/serving-endpoints/google)", + bool(content), f"response: {content[:100]}") + except urllib.error.HTTPError as e: + body = e.read().decode() if e.fp else "" + test("Gemini-native endpoint works (/serving-endpoints/google)", + False, f"HTTP {e.code}: {body[:200]}") + except Exception as e: + test("Gemini-native endpoint works (/serving-endpoints/google)", + False, str(e)) + +else: + section("Live API Tests (SKIPPED - no credentials)") + print(" Set DATABRICKS_HOST and DATABRICKS_TOKEN to run live tests") + + +# ========================================== +# Summary +# ========================================== +print(f"\n{'=' * 60}") +print(f" Results: {passed} passed, {failed} failed, {passed + failed} total") +print(f"{'=' * 60}") + +sys.exit(1 if failed > 0 else 0)