Skip to content

Commit 6a9a54e

Browse files
authored
Merge pull request #1 from CodeAlive-AI/feature/claude-desktop-self-hosted-best-practices
Normalize self-hosted CodeAlive URLs and add skills runtime tests
2 parents e0e7fd4 + f2c545c commit 6a9a54e

9 files changed

Lines changed: 402 additions & 8 deletions

File tree

.github/workflows/ci.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
test:
14+
name: Test Skills Runtime
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
18+
19+
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
20+
with:
21+
python-version: '3.11'
22+
cache: 'pip'
23+
24+
- name: Install test dependencies
25+
run: |
26+
python -m pip install --upgrade pip
27+
pip install pytest pytest-cov
28+
29+
- name: Run runtime tests
30+
run: |
31+
python -m pytest tests -v --cov=skills --cov-report=term-missing

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ The API key is resolved in this order:
7474

7575
The key is stored once and shared across all agents on the same machine.
7676

77-
**Self-hosted instance:** set `CODEALIVE_BASE_URL` env var to your instance URL.
77+
**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.
7878

7979
## Usage
8080

hooks/scripts/check_auth.sh

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,14 @@ fi
1616

1717
if [ -z "$KEY" ]; then
1818
# Find setup.py relative to plugin root
19-
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(dirname "$(dirname "$0")")}"
19+
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(dirname "$(dirname "$(dirname "$0")")")}"
2020
SETUP_PATH="${PLUGIN_ROOT}/skills/codealive-context-engine/setup.py"
21+
BASE_URL="${CODEALIVE_BASE_URL:-https://app.codealive.ai}"
22+
BASE_URL="${BASE_URL%/}"
23+
BASE_URL="${BASE_URL%/api}"
2124

2225
cat <<EOF
23-
{"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"}}
26+
{"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"}}
2427
EOF
2528
fi
2629

skills/codealive-context-engine/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,8 @@ cmdkey /generic:codealive-api-key /user:codealive /pass:"YOUR_API_KEY"
205205
export CODEALIVE_BASE_URL="https://your-instance.example.com"
206206
```
207207

208+
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.
209+
208210
Get API keys at: https://app.codealive.ai/settings/api-keys
209211

210212
## Using with CodeAlive MCP Server

skills/codealive-context-engine/scripts/lib/api_client.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"""
55

66
import os
7+
import urllib.parse
78
import sys
89
import json
910
import urllib.request
@@ -90,6 +91,26 @@ class CREDENTIAL(ctypes.Structure):
9091
finally:
9192
advapi32.CredFree(cred_ptr)
9293

94+
@staticmethod
95+
def _normalize_base_url(base_url: Optional[str]) -> str:
96+
"""Normalize a CodeAlive base URL to the deployment origin."""
97+
raw = (base_url or "https://app.codealive.ai").strip()
98+
if not raw:
99+
raw = "https://app.codealive.ai"
100+
101+
if "://" not in raw:
102+
normalized = raw.rstrip("/")
103+
if normalized.endswith("/api"):
104+
normalized = normalized[:-4]
105+
return normalized
106+
107+
parts = urllib.parse.urlsplit(raw)
108+
path = parts.path.rstrip("/")
109+
if path.endswith("/api"):
110+
path = path[:-4]
111+
112+
return urllib.parse.urlunsplit((parts.scheme, parts.netloc, path, parts.query, parts.fragment)).rstrip("/")
113+
93114
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None):
94115
"""
95116
Initialize the CodeAlive API client.
@@ -103,6 +124,7 @@ def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None
103124
"""
104125
self.api_key = api_key or os.getenv("CODEALIVE_API_KEY") or self._get_key_from_keychain()
105126
if not self.api_key:
127+
resolved_base_url = self._normalize_base_url(base_url or os.getenv("CODEALIVE_BASE_URL", "https://app.codealive.ai"))
106128
skill_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
107129
setup_path = os.path.join(skill_dir, "setup.py")
108130
raise ValueError(
@@ -115,10 +137,10 @@ def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None
115137
" Ask the user to paste their API key, then run:\n"
116138
f" python {setup_path} --key THE_KEY\n"
117139
"\n"
118-
"Get API key at: https://app.codealive.ai/settings/api-keys"
140+
f"Get API key at: {resolved_base_url}/settings/api-keys"
119141
)
120142

121-
self.base_url = base_url or os.getenv("CODEALIVE_BASE_URL", "https://app.codealive.ai")
143+
self.base_url = self._normalize_base_url(base_url or os.getenv("CODEALIVE_BASE_URL", "https://app.codealive.ai"))
122144
self.timeout = 60
123145

124146
def _make_request(
@@ -214,7 +236,7 @@ def get_datasources(self, alive_only: bool = True) -> List[Dict[str, Any]]:
214236
Returns:
215237
List of data source objects with id, name, description, type, etc.
216238
"""
217-
endpoint = "/api/datasources/alive" if alive_only else "/api/datasources/all"
239+
endpoint = "/api/datasources/ready" if alive_only else "/api/datasources/all"
218240
return self._make_request("GET", endpoint)
219241

220242
def search(

skills/codealive-context-engine/setup.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,36 @@
1919
import json
2020
import urllib.request
2121
import urllib.error
22+
import urllib.parse
2223

2324
SKILL_DIR = os.path.dirname(os.path.abspath(__file__))
2425
SERVICE_NAME = "codealive-api-key"
2526
DEFAULT_BASE_URL = "https://app.codealive.ai"
2627

2728

29+
def normalize_base_url(base_url: str | None) -> str:
30+
"""Normalize a CodeAlive base URL to the deployment origin.
31+
32+
Accepts both deployment origins and URLs that already end with `/api`.
33+
"""
34+
raw = (base_url or DEFAULT_BASE_URL).strip()
35+
if not raw:
36+
raw = DEFAULT_BASE_URL
37+
38+
if "://" not in raw:
39+
normalized = raw.rstrip("/")
40+
if normalized.endswith("/api"):
41+
normalized = normalized[:-4]
42+
return normalized
43+
44+
parts = urllib.parse.urlsplit(raw)
45+
path = parts.path.rstrip("/")
46+
if path.endswith("/api"):
47+
path = path[:-4]
48+
49+
return urllib.parse.urlunsplit((parts.scheme, parts.netloc, path, parts.query, parts.fragment)).rstrip("/")
50+
51+
2852
# ── Credential store helpers ──────────────────────────────────────────────────
2953

3054
def read_existing_key() -> str | None:
@@ -135,7 +159,8 @@ def store_key(api_key: str) -> bool:
135159

136160
def verify_key(api_key: str, base_url: str = DEFAULT_BASE_URL) -> tuple[bool, str]:
137161
"""Test the API key by fetching data sources. Returns (success, message)."""
138-
url = f"{base_url}/api/datasources/alive"
162+
normalized_base_url = normalize_base_url(base_url)
163+
url = f"{normalized_base_url}/api/datasources/ready"
139164
headers = {
140165
"Authorization": f"Bearer {api_key}",
141166
"Content-Type": "application/json",
@@ -176,7 +201,7 @@ def main():
176201
sys.exit(0)
177202

178203
system = platform.system()
179-
base_url = os.getenv("CODEALIVE_BASE_URL", DEFAULT_BASE_URL)
204+
base_url = normalize_base_url(os.getenv("CODEALIVE_BASE_URL", DEFAULT_BASE_URL))
180205

181206
print()
182207
print(" CodeAlive Context Engine — Setup")

tests/helpers.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Test helpers for CodeAlive skills runtime tests."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import threading
7+
from contextlib import contextmanager
8+
from http.server import BaseHTTPRequestHandler, HTTPServer
9+
10+
11+
@contextmanager
12+
def mock_codealive_server(routes):
13+
"""Start a local mock HTTP server.
14+
15+
``routes`` maps ``(method, path)`` to either:
16+
- ``(status_code, payload)``, where payload is JSON-serializable
17+
- a callable ``handler(request_info) -> (status_code, payload, headers)``
18+
"""
19+
20+
requests = []
21+
22+
class Handler(BaseHTTPRequestHandler):
23+
def _handle(self, method: str):
24+
request_info = {
25+
"method": method,
26+
"path": self.path,
27+
"headers": {k: v for k, v in self.headers.items()},
28+
"body": self.rfile.read(int(self.headers.get("Content-Length", "0"))).decode("utf-8")
29+
if method in {"POST", "PUT", "PATCH"}
30+
else "",
31+
}
32+
requests.append(request_info)
33+
34+
route = routes.get((method, self.path))
35+
if route is None:
36+
self.send_response(404)
37+
self.end_headers()
38+
return
39+
40+
if callable(route):
41+
status, payload, headers = route(request_info)
42+
else:
43+
status, payload = route
44+
headers = {}
45+
46+
body = json.dumps(payload).encode("utf-8")
47+
self.send_response(status)
48+
self.send_header("Content-Type", "application/json")
49+
self.send_header("Content-Length", str(len(body)))
50+
for key, value in headers.items():
51+
self.send_header(key, value)
52+
self.end_headers()
53+
self.wfile.write(body)
54+
55+
def do_GET(self):
56+
self._handle("GET")
57+
58+
def do_POST(self):
59+
self._handle("POST")
60+
61+
def log_message(self, format, *args):
62+
pass
63+
64+
server = HTTPServer(("127.0.0.1", 0), Handler)
65+
thread = threading.Thread(target=server.serve_forever, daemon=True)
66+
thread.start()
67+
try:
68+
yield f"http://127.0.0.1:{server.server_address[1]}", requests
69+
finally:
70+
server.shutdown()
71+
thread.join(timeout=1)

tests/test_cli_smoke.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""CLI smoke tests for the CodeAlive skill scripts."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import os
7+
import subprocess
8+
import sys
9+
from pathlib import Path
10+
11+
from helpers import mock_codealive_server
12+
13+
14+
REPO_ROOT = Path(__file__).resolve().parents[1]
15+
SKILL_ROOT = REPO_ROOT / "skills" / "codealive-context-engine"
16+
17+
18+
def _run(script_name: str, *args: str, env: dict[str, str]) -> subprocess.CompletedProcess[str]:
19+
script = SKILL_ROOT / "scripts" / script_name
20+
return subprocess.run(
21+
[sys.executable, str(script), *args],
22+
text=True,
23+
capture_output=True,
24+
env=env,
25+
check=False,
26+
)
27+
28+
29+
def test_datasources_search_fetch_and_chat_scripts_work_against_mock_backend():
30+
def search_handler(_request):
31+
return 200, {
32+
"results": [
33+
{
34+
"identifier": "org/repo::src/auth.py::AuthService",
35+
"kind": "Class",
36+
"description": "Handles auth",
37+
"location": {"path": "src/auth.py", "range": {"start": {"line": 10}, "end": {"line": 20}}},
38+
"contentByteSize": 2048,
39+
}
40+
]
41+
}, {}
42+
43+
def fetch_handler(_request):
44+
return 200, {
45+
"artifacts": [
46+
{
47+
"identifier": "org/repo::src/auth.py::AuthService",
48+
"content": "class AuthService:\n pass\n",
49+
"startLine": 10,
50+
"contentByteSize": 28,
51+
}
52+
]
53+
}, {}
54+
55+
def chat_handler(_request):
56+
return 200, {
57+
"id": "conv_123",
58+
"choices": [{"message": {"content": "Auth is handled in AuthService."}}],
59+
}, {}
60+
61+
with mock_codealive_server(
62+
{
63+
("GET", "/api/datasources/ready"): (
64+
200,
65+
[{"id": "repo-1", "name": "backend", "type": "Repository", "description": "Main backend"}],
66+
),
67+
("GET", "/api/search?Query=auth&Mode=auto&IncludeContent=false&DescriptionDetail=Short&Names=backend"): search_handler,
68+
("POST", "/api/search/artifacts"): fetch_handler,
69+
("POST", "/api/chat/completions"): chat_handler,
70+
}
71+
) as (base_url, requests):
72+
env = {
73+
**os.environ,
74+
"CODEALIVE_API_KEY": "skill-test-key",
75+
"CODEALIVE_BASE_URL": f"{base_url}/api",
76+
}
77+
78+
datasources = _run("datasources.py", "--json", env=env)
79+
search = _run("search.py", "auth", "backend", env=env)
80+
fetch = _run("fetch.py", "org/repo::src/auth.py::AuthService", env=env)
81+
chat = _run("chat.py", "How does auth work?", "backend", env=env)
82+
83+
assert datasources.returncode == 0, datasources.stderr
84+
assert json.loads(datasources.stdout)[0]["name"] == "backend"
85+
86+
assert search.returncode == 0, search.stderr
87+
assert "src/auth.py:10-20" in search.stdout
88+
assert "Handles auth" in search.stdout
89+
90+
assert fetch.returncode == 0, fetch.stderr
91+
assert "AuthService" in fetch.stdout
92+
assert "10 | class AuthService:" in fetch.stdout
93+
94+
assert chat.returncode == 0, chat.stderr
95+
assert "Auth is handled in AuthService." in chat.stdout
96+
assert "Conversation ID: conv_123" in chat.stdout
97+
98+
assert [request["path"] for request in requests] == [
99+
"/api/datasources/ready",
100+
"/api/search?Query=auth&Mode=auto&IncludeContent=false&DescriptionDetail=Short&Names=backend",
101+
"/api/search/artifacts",
102+
"/api/chat/completions",
103+
]
104+
105+
106+
def test_check_auth_hook_normalizes_base_url_and_uses_repo_root_fallback():
107+
script = REPO_ROOT / "hooks" / "scripts" / "check_auth.sh"
108+
env = {
109+
"PATH": "/usr/bin:/bin",
110+
"USER": "codealive-skills-test",
111+
"CODEALIVE_BASE_URL": "https://codealive.example.com/api",
112+
}
113+
114+
result = subprocess.run(
115+
["/bin/bash", str(script)],
116+
text=True,
117+
capture_output=True,
118+
env=env,
119+
check=False,
120+
)
121+
122+
assert result.returncode == 0
123+
assert "https://codealive.example.com/settings/api-keys" in result.stdout
124+
assert str(REPO_ROOT / "skills" / "codealive-context-engine" / "setup.py") in result.stdout

0 commit comments

Comments
 (0)