Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 5 additions & 2 deletions hooks/scripts/check_auth.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<EOF
{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"[CodeAlive] API key is not configured. The codealive-context-engine skill requires authentication.\n\nOption 1 (recommended): run interactive setup: python ${SETUP_PATH}\nOption 2 (not recommended — key visible in chat history): ask the user to paste their key, then run: python ${SETUP_PATH} --key THE_KEY\nGet key at: https://app.codealive.ai/settings/api-keys"}}
{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"[CodeAlive] API key is not configured. The codealive-context-engine skill requires authentication.\n\nOption 1 (recommended): run interactive setup: python ${SETUP_PATH}\nOption 2 (not recommended — key visible in chat history): ask the user to paste their key, then run: python ${SETUP_PATH} --key THE_KEY\nGet key at: ${BASE_URL}/settings/api-keys"}}
EOF
fi

Expand Down
2 changes: 2 additions & 0 deletions skills/codealive-context-engine/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ cmdkey /generic:codealive-api-key /user:codealive /pass:"YOUR_API_KEY"
export CODEALIVE_BASE_URL="https://your-instance.example.com"
```

For self-hosted CodeAlive, use your deployment origin. `https://your-instance.example.com` is preferred, but `https://your-instance.example.com/api` is also accepted and normalized automatically.

Get API keys at: https://app.codealive.ai/settings/api-keys

## Using with CodeAlive MCP Server
Expand Down
28 changes: 25 additions & 3 deletions skills/codealive-context-engine/scripts/lib/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"""

import os
import urllib.parse
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This import is redundant as urllib.parse is already imported on line 12.

import sys
import json
import urllib.request
Expand Down Expand Up @@ -90,6 +91,26 @@ class CREDENTIAL(ctypes.Structure):
finally:
advapi32.CredFree(cred_ptr)

@staticmethod
def _normalize_base_url(base_url: Optional[str]) -> 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("/")
Comment on lines +94 to +112
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The URL normalization logic can be improved for robustness:

  1. Missing Scheme: If a URL is provided without a scheme (e.g., codealive.example.com), the current logic returns it as-is. This will cause urllib.request to fail with a ValueError: unknown url type. It should default to https:// in such cases.
  2. Query/Fragment Stripping: A base URL or deployment origin should not include query parameters or fragments. These should be cleared during reconstruction to ensure a clean base URL for subsequent API calls.
    @staticmethod
    def _normalize_base_url(base_url: Optional[str]) -> 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:
            raw = f"https://{raw}"

        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, "", "")).rstrip("/")


def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None):
"""
Initialize the CodeAlive API client.
Expand All @@ -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"))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The base URL is resolved and normalized here, and then again at line 143. It would be cleaner to resolve self.base_url once at the beginning of __init__ and reuse it throughout the method, including in the error message at line 140.

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(
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
29 changes: 27 additions & 2 deletions skills/codealive-context-engine/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("/")
Comment on lines +29 to +49
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The URL normalization logic here should be updated to handle missing schemes and strip query parameters/fragments, matching the improvements suggested for the API client to ensure consistency and robustness.

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:
        raw = f"https://{raw}"

    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, "", "")).rstrip("/")



# ── Credential store helpers ──────────────────────────────────────────────────

def read_existing_key() -> str | None:
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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")
Expand Down
71 changes: 71 additions & 0 deletions tests/helpers.py
Original file line number Diff line number Diff line change
@@ -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)
124 changes: 124 additions & 0 deletions tests/test_cli_smoke.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading