diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6be4557 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + test: + name: Test Skills Runtime + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install test dependencies + run: | + python -m pip install --upgrade pip + pip install pytest pytest-cov + + - name: Run runtime tests + run: | + python -m pytest tests -v --cov=skills --cov-report=term-missing diff --git a/README.md b/README.md index 98aeec5..82eca03 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ The API key is resolved in this order: The key is stored once and shared across all agents on the same machine. -**Self-hosted instance:** set `CODEALIVE_BASE_URL` env var to your instance URL. +**Self-hosted instance:** set `CODEALIVE_BASE_URL` to your deployment origin, for example `https://codealive.yourcompany.com`. The setup script accepts both `https://host` and `https://host/api`, but the origin form is preferred. ## Usage diff --git a/hooks/scripts/check_auth.sh b/hooks/scripts/check_auth.sh index 0713ca7..ba9d20f 100755 --- a/hooks/scripts/check_auth.sh +++ b/hooks/scripts/check_auth.sh @@ -16,11 +16,14 @@ fi if [ -z "$KEY" ]; then # Find setup.py relative to plugin root - PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(dirname "$(dirname "$0")")}" + PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(dirname "$(dirname "$(dirname "$0")")")}" SETUP_PATH="${PLUGIN_ROOT}/skills/codealive-context-engine/setup.py" + BASE_URL="${CODEALIVE_BASE_URL:-https://app.codealive.ai}" + BASE_URL="${BASE_URL%/}" + BASE_URL="${BASE_URL%/api}" cat < str: + """Normalize a CodeAlive base URL to the deployment origin.""" + raw = (base_url or "https://app.codealive.ai").strip() + if not raw: + raw = "https://app.codealive.ai" + + if "://" not in raw: + normalized = raw.rstrip("/") + if normalized.endswith("/api"): + normalized = normalized[:-4] + return normalized + + parts = urllib.parse.urlsplit(raw) + path = parts.path.rstrip("/") + if path.endswith("/api"): + path = path[:-4] + + return urllib.parse.urlunsplit((parts.scheme, parts.netloc, path, parts.query, parts.fragment)).rstrip("/") + def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None): """ Initialize the CodeAlive API client. @@ -103,6 +124,7 @@ def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None """ self.api_key = api_key or os.getenv("CODEALIVE_API_KEY") or self._get_key_from_keychain() if not self.api_key: + resolved_base_url = self._normalize_base_url(base_url or os.getenv("CODEALIVE_BASE_URL", "https://app.codealive.ai")) skill_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) setup_path = os.path.join(skill_dir, "setup.py") raise ValueError( @@ -115,10 +137,10 @@ def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None " Ask the user to paste their API key, then run:\n" f" python {setup_path} --key THE_KEY\n" "\n" - "Get API key at: https://app.codealive.ai/settings/api-keys" + f"Get API key at: {resolved_base_url}/settings/api-keys" ) - self.base_url = base_url or os.getenv("CODEALIVE_BASE_URL", "https://app.codealive.ai") + self.base_url = self._normalize_base_url(base_url or os.getenv("CODEALIVE_BASE_URL", "https://app.codealive.ai")) self.timeout = 60 def _make_request( @@ -214,7 +236,7 @@ def get_datasources(self, alive_only: bool = True) -> List[Dict[str, Any]]: Returns: List of data source objects with id, name, description, type, etc. """ - endpoint = "/api/datasources/alive" if alive_only else "/api/datasources/all" + endpoint = "/api/datasources/ready" if alive_only else "/api/datasources/all" return self._make_request("GET", endpoint) def search( diff --git a/skills/codealive-context-engine/setup.py b/skills/codealive-context-engine/setup.py index 4b4b6ea..862318c 100755 --- a/skills/codealive-context-engine/setup.py +++ b/skills/codealive-context-engine/setup.py @@ -19,12 +19,36 @@ import json import urllib.request import urllib.error +import urllib.parse SKILL_DIR = os.path.dirname(os.path.abspath(__file__)) SERVICE_NAME = "codealive-api-key" DEFAULT_BASE_URL = "https://app.codealive.ai" +def normalize_base_url(base_url: str | None) -> str: + """Normalize a CodeAlive base URL to the deployment origin. + + Accepts both deployment origins and URLs that already end with `/api`. + """ + raw = (base_url or DEFAULT_BASE_URL).strip() + if not raw: + raw = DEFAULT_BASE_URL + + if "://" not in raw: + normalized = raw.rstrip("/") + if normalized.endswith("/api"): + normalized = normalized[:-4] + return normalized + + parts = urllib.parse.urlsplit(raw) + path = parts.path.rstrip("/") + if path.endswith("/api"): + path = path[:-4] + + return urllib.parse.urlunsplit((parts.scheme, parts.netloc, path, parts.query, parts.fragment)).rstrip("/") + + # ── Credential store helpers ────────────────────────────────────────────────── def read_existing_key() -> str | None: @@ -135,7 +159,8 @@ def store_key(api_key: str) -> bool: def verify_key(api_key: str, base_url: str = DEFAULT_BASE_URL) -> tuple[bool, str]: """Test the API key by fetching data sources. Returns (success, message).""" - url = f"{base_url}/api/datasources/alive" + normalized_base_url = normalize_base_url(base_url) + url = f"{normalized_base_url}/api/datasources/ready" headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", @@ -176,7 +201,7 @@ def main(): sys.exit(0) system = platform.system() - base_url = os.getenv("CODEALIVE_BASE_URL", DEFAULT_BASE_URL) + base_url = normalize_base_url(os.getenv("CODEALIVE_BASE_URL", DEFAULT_BASE_URL)) print() print(" CodeAlive Context Engine — Setup") diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..fa649d8 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,71 @@ +"""Test helpers for CodeAlive skills runtime tests.""" + +from __future__ import annotations + +import json +import threading +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, HTTPServer + + +@contextmanager +def mock_codealive_server(routes): + """Start a local mock HTTP server. + + ``routes`` maps ``(method, path)`` to either: + - ``(status_code, payload)``, where payload is JSON-serializable + - a callable ``handler(request_info) -> (status_code, payload, headers)`` + """ + + requests = [] + + class Handler(BaseHTTPRequestHandler): + def _handle(self, method: str): + request_info = { + "method": method, + "path": self.path, + "headers": {k: v for k, v in self.headers.items()}, + "body": self.rfile.read(int(self.headers.get("Content-Length", "0"))).decode("utf-8") + if method in {"POST", "PUT", "PATCH"} + else "", + } + requests.append(request_info) + + route = routes.get((method, self.path)) + if route is None: + self.send_response(404) + self.end_headers() + return + + if callable(route): + status, payload, headers = route(request_info) + else: + status, payload = route + headers = {} + + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + for key, value in headers.items(): + self.send_header(key, value) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + self._handle("GET") + + def do_POST(self): + self._handle("POST") + + def log_message(self, format, *args): + pass + + server = HTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_address[1]}", requests + finally: + server.shutdown() + thread.join(timeout=1) diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py new file mode 100644 index 0000000..acdc4fd --- /dev/null +++ b/tests/test_cli_smoke.py @@ -0,0 +1,124 @@ +"""CLI smoke tests for the CodeAlive skill scripts.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +from helpers import mock_codealive_server + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SKILL_ROOT = REPO_ROOT / "skills" / "codealive-context-engine" + + +def _run(script_name: str, *args: str, env: dict[str, str]) -> subprocess.CompletedProcess[str]: + script = SKILL_ROOT / "scripts" / script_name + return subprocess.run( + [sys.executable, str(script), *args], + text=True, + capture_output=True, + env=env, + check=False, + ) + + +def test_datasources_search_fetch_and_chat_scripts_work_against_mock_backend(): + def search_handler(_request): + return 200, { + "results": [ + { + "identifier": "org/repo::src/auth.py::AuthService", + "kind": "Class", + "description": "Handles auth", + "location": {"path": "src/auth.py", "range": {"start": {"line": 10}, "end": {"line": 20}}}, + "contentByteSize": 2048, + } + ] + }, {} + + def fetch_handler(_request): + return 200, { + "artifacts": [ + { + "identifier": "org/repo::src/auth.py::AuthService", + "content": "class AuthService:\n pass\n", + "startLine": 10, + "contentByteSize": 28, + } + ] + }, {} + + def chat_handler(_request): + return 200, { + "id": "conv_123", + "choices": [{"message": {"content": "Auth is handled in AuthService."}}], + }, {} + + with mock_codealive_server( + { + ("GET", "/api/datasources/ready"): ( + 200, + [{"id": "repo-1", "name": "backend", "type": "Repository", "description": "Main backend"}], + ), + ("GET", "/api/search?Query=auth&Mode=auto&IncludeContent=false&DescriptionDetail=Short&Names=backend"): search_handler, + ("POST", "/api/search/artifacts"): fetch_handler, + ("POST", "/api/chat/completions"): chat_handler, + } + ) as (base_url, requests): + env = { + **os.environ, + "CODEALIVE_API_KEY": "skill-test-key", + "CODEALIVE_BASE_URL": f"{base_url}/api", + } + + datasources = _run("datasources.py", "--json", env=env) + search = _run("search.py", "auth", "backend", env=env) + fetch = _run("fetch.py", "org/repo::src/auth.py::AuthService", env=env) + chat = _run("chat.py", "How does auth work?", "backend", env=env) + + assert datasources.returncode == 0, datasources.stderr + assert json.loads(datasources.stdout)[0]["name"] == "backend" + + assert search.returncode == 0, search.stderr + assert "src/auth.py:10-20" in search.stdout + assert "Handles auth" in search.stdout + + assert fetch.returncode == 0, fetch.stderr + assert "AuthService" in fetch.stdout + assert "10 | class AuthService:" in fetch.stdout + + assert chat.returncode == 0, chat.stderr + assert "Auth is handled in AuthService." in chat.stdout + assert "Conversation ID: conv_123" in chat.stdout + + assert [request["path"] for request in requests] == [ + "/api/datasources/ready", + "/api/search?Query=auth&Mode=auto&IncludeContent=false&DescriptionDetail=Short&Names=backend", + "/api/search/artifacts", + "/api/chat/completions", + ] + + +def test_check_auth_hook_normalizes_base_url_and_uses_repo_root_fallback(): + script = REPO_ROOT / "hooks" / "scripts" / "check_auth.sh" + env = { + "PATH": "/usr/bin:/bin", + "USER": "codealive-skills-test", + "CODEALIVE_BASE_URL": "https://codealive.example.com/api", + } + + result = subprocess.run( + ["/bin/bash", str(script)], + text=True, + capture_output=True, + env=env, + check=False, + ) + + assert result.returncode == 0 + assert "https://codealive.example.com/settings/api-keys" in result.stdout + assert str(REPO_ROOT / "skills" / "codealive-context-engine" / "setup.py") in result.stdout diff --git a/tests/test_setup_and_client.py b/tests/test_setup_and_client.py new file mode 100644 index 0000000..d37c60b --- /dev/null +++ b/tests/test_setup_and_client.py @@ -0,0 +1,116 @@ +"""Tests for setup.py and the shared API client.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +from helpers import mock_codealive_server + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SKILL_ROOT = REPO_ROOT / "skills" / "codealive-context-engine" +LIB_ROOT = SKILL_ROOT / "scripts" / "lib" + + +def _load_module(path: Path, name: str): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +skill_setup_module = _load_module(SKILL_ROOT / "setup.py", "codealive_skill_setup") +sys.path.insert(0, str(LIB_ROOT)) +from api_client import CodeAliveClient # noqa: E402 + + +def test_setup_normalize_base_url_accepts_origin_and_api_suffix(): + assert skill_setup_module.normalize_base_url("https://codealive.example.com") == "https://codealive.example.com" + assert skill_setup_module.normalize_base_url("https://codealive.example.com/api") == "https://codealive.example.com" + assert skill_setup_module.normalize_base_url("https://codealive.example.com/internal/api/") == "https://codealive.example.com/internal" + + +def test_verify_key_uses_ready_endpoint_and_normalizes_base_url(): + with mock_codealive_server( + { + ("GET", "/api/datasources/ready"): ( + 200, + [{"id": "repo-1", "name": "backend", "type": "Repository"}], + ) + } + ) as (base_url, requests): + ok, message = skill_setup_module.verify_key("skill-test-key", f"{base_url}/api") + + assert ok is True + assert "1 data source available" in message + assert len(requests) == 1 + assert requests[0]["method"] == "GET" + assert requests[0]["path"] == "/api/datasources/ready" + assert requests[0]["headers"]["Authorization"] == "Bearer skill-test-key" + assert requests[0]["headers"]["Content-Type"] == "application/json" + assert requests[0]["body"] == "" + + +def test_api_client_normalizes_base_url_and_calls_ready_endpoint(): + with mock_codealive_server( + { + ("GET", "/api/datasources/ready"): ( + 200, + [{"id": "repo-1", "name": "backend", "type": "Repository"}], + ) + } + ) as (base_url, requests): + client = CodeAliveClient(api_key="skill-test-key", base_url=f"{base_url}/api") + result = client.get_datasources() + + assert result == [{"id": "repo-1", "name": "backend", "type": "Repository"}] + assert requests[0]["path"] == "/api/datasources/ready" + assert requests[0]["headers"]["Authorization"] == "Bearer skill-test-key" + + +def test_api_client_search_fetch_and_chat_use_expected_endpoints(): + def search_handler(request): + assert "Query=auth" in request["path"] + assert "Names=backend" in request["path"] + return 200, { + "results": [ + { + "identifier": "org/repo::src/auth.py::AuthService", + "kind": "Class", + "description": "Handles auth", + "location": {"path": "src/auth.py", "range": {"start": {"line": 10}, "end": {"line": 20}}}, + } + ] + }, {} + + def fetch_handler(request): + payload = json.loads(request["body"]) + assert payload["identifiers"] == ["org/repo::src/auth.py::AuthService"] + return 200, {"artifacts": [{"identifier": payload["identifiers"][0], "content": "class AuthService:\n pass\n"}]}, {} + + def chat_handler(request): + payload = json.loads(request["body"]) + assert payload["messages"][0]["content"] == "How does auth work?" + assert payload["names"] == ["backend"] + return 200, {"id": "conv_123", "choices": [{"message": {"content": "Auth is handled in AuthService."}}]}, {} + + with mock_codealive_server( + { + ("GET", "/api/search?Query=auth&Mode=auto&IncludeContent=false&DescriptionDetail=Short&Names=backend"): search_handler, + ("POST", "/api/search/artifacts"): fetch_handler, + ("POST", "/api/chat/completions"): chat_handler, + } + ) as (base_url, _requests): + client = CodeAliveClient(api_key="skill-test-key", base_url=base_url) + search_result = client.search("auth", ["backend"]) + fetch_result = client.fetch_artifacts(["org/repo::src/auth.py::AuthService"]) + chat_result = client.chat("How does auth work?", data_sources=["backend"]) + + assert search_result["results"][0]["identifier"] == "org/repo::src/auth.py::AuthService" + assert fetch_result["artifacts"][0]["identifier"] == "org/repo::src/auth.py::AuthService" + assert chat_result["answer"] == "Auth is handled in AuthService." + assert chat_result["conversation_id"] == "conv_123"