diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1a7cdb5..50e0fdc 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "codealive", "description": "CodeAlive context engine for semantic code search and AI-powered codebase Q&A. Enables AI coding agents to understand entire codebases beyond just open files — search across all indexed repositories, trace cross-service dependencies, discover usage patterns, and get synthesized answers to architectural questions. Includes a lightweight code exploration subagent, authentication hooks, and multiple search modes (fast lexical, semantic, and deep cross-cutting). Works standalone or alongside the CodeAlive MCP server for direct tool access via the Model Context Protocol.", - "version": "2.1.0", + "version": "3.0.0", "author": { "name": "CodeAlive AI", "email": "hello@codealive.ai" diff --git a/agents/codealive-context-explorer.md b/agents/codealive-context-explorer.md index f338ebe..06bc309 100644 --- a/agents/codealive-context-explorer.md +++ b/agents/codealive-context-explorer.md @@ -89,7 +89,7 @@ When unsure, start with `search.py` — it covers more ground; pivot to `grep.py - Use only local `Grep`/`Glob` when the question is about an external repo or a cross-repo concept. - Trust the `description` field of search results as ground truth — always fetch or read the real source. - Run a single empty search and conclude "nothing found" — try at least 2 different query phrasings before giving up. -- Run `chat.py`. Only do so when the user explicitly asks (e.g. "use chat", "use codebase_consultant"). +- Run `chat.py`. Only do so when the user explicitly asks (e.g. "use chat", "call the chat tool"). ## Output Format diff --git a/skills/codealive-context-engine/SKILL.md b/skills/codealive-context-engine/SKILL.md index 509e683..68ae6fe 100644 --- a/skills/codealive-context-engine/SKILL.md +++ b/skills/codealive-context-engine/SKILL.md @@ -40,15 +40,25 @@ Do NOT retry the failed script until setup completes successfully. | **List Data Sources** | `datasources.py` | Instant | Free | Discovering indexed repos and workspaces. With `--query "task"`, runs an AI relevance filter (low cost, not instant) returning only the relevant sources | | **Semantic Search** | `search.py` | Fast | Low | Default discovery — finds code by meaning (concepts, behavior, architecture) | | **Grep Search** | `grep.py` | Fast | Low | Finds code containing a specific string or regex (identifiers, literals, patterns) | -| **Fetch Artifacts** | `fetch.py` | Fast | Low | Retrieving full content; function-like artifacts also include up to 3 outgoing/incoming calls as a preview | -| **Artifact Relationships** | `relationships.py` | Fast | Low | Full call graph (past the fetch preview's 3-cap), inheritance, or symbol references for one artifact | -| **Chat with Codebase** | `chat.py` | Slow | High | **Not recommended.** Call ONLY when the user explicitly asks (e.g. "use chat"). | +| **Repository Ontology** | `ontology.py` | Fast | Low | High-level orientation for exactly one repository | +| **File Tree** | `tree.py` | Fast | Free | Bounded repository tree inspection | +| **Read File** | `read_file.py` | Fast | Free | Read one repository-relative file path, optionally with a line range | +| **Fetch Artifacts** | `fetch.py` | Fast | Free | Retrieve full content for search result identifiers | +| **Artifact Relationships** | `relationships.py` | Fast | Free | Full call graph, inheritance, or symbol references for one artifact | +| **ArtifactQuery Schema** | `schema.py` | Fast | Free | Inspect supported metadata query entities, fields, and examples | +| **Artifact Metadata Query** | `metadata.py` | Fast | Low | Read-only aggregate/query analytics across indexed repositories | +| **Chat with Codebase** | `chat.py` | Slow | High | Stateless synthesized Q&A. Call ONLY when the user explicitly asks. | **Cost guidance:** `semantic_search` and `grep_search` are the default starting point — fast and cheap. Use `fetch_artifacts` to load full source and `get_artifact_relationships` to trace call graphs. All four tools are low-cost. -**Chat is not recommended:** `chat.py` invokes an LLM on the server side, can take up to 30 seconds, and is significantly more expensive per call. Do NOT call it unless the user has explicitly requested it (e.g. "use chat", "use codebase_consultant", "call the chat tool"). Phrases like "ask CodeAlive" or "search CodeAlive" do NOT qualify — they refer to search tools. +**Chat is not recommended:** `chat.py` invokes an LLM on the server side, can take substantially longer than retrieval, and is significantly more expensive per call. It is stateless in v3: include prior findings, artifact identifiers, assumptions, scope, and constraints in each question. Do NOT call it unless the user has explicitly requested it (e.g. "use chat", "call the chat tool"). Phrases like "ask CodeAlive" or "search CodeAlive" do NOT qualify — they refer to search tools. -**Highest-confidence guidance:** If your agent supports subagents and the task needs maximum reliability or depth, prefer a subagent-driven workflow that combines `search.py`, `grep.py`, `fetch.py`, `relationships.py`, and local file reads. +**Repairable tool errors:** Treat a returned `` as a failed call, +not as an empty successful result. Follow its `` guidance, repair the +arguments, and retry only when the `` field permits it. Tool API v3 +always preserves the same error in `obj.error` for JSON-mode automation. + +**Highest-confidence guidance:** If your agent supports subagents and the task needs maximum reliability or depth, prefer a subagent-driven workflow that combines `ontology.py`, `search.py`, `grep.py`, `fetch.py`, `tree.py`/`read_file.py`, `relationships.py`, `metadata.py`, and local file reads. **Three-step workflow (search → triage → load real content):** 1. **Search** — find relevant code locations with descriptions and identifiers @@ -144,11 +154,11 @@ python scripts/relationships.py "my-org/backend::src/svc.py::Service" --profile ### 5. Chat with codebase (not recommended — only if user explicitly asks) ```bash -python scripts/chat.py "Explain the authentication flow" my-backend -python scripts/chat.py "What about security considerations?" --continue CONV_ID +python scripts/chat.py "Explain the authentication flow. Prior context: none." my-backend +python scripts/chat.py "Given these prior findings and identifiers: ..., what about security considerations?" my-backend ``` -**Do not call chat unless the user explicitly asks for it.** Use search, grep, fetch, and relationships for all other tasks. +**Do not call chat unless the user explicitly asks for it.** v3 chat is stateless and has no `conversation_id`; include all needed context in each question. Use ontology, search, grep, fetch/read, relationships, and metadata queries for all other tasks. ## Tool Reference @@ -223,7 +233,7 @@ python scripts/fetch.py [identifier2...] [--data-source NAME_OR_ID | Constraint | Value | |-----------|-------| -| Max identifiers per request | 20 | +| Max identifiers per request | 50 | | Identifiers source | `identifier` field from search results | | Identifier format | `{owner/repo}::{path}::{symbol}` (symbols), `{owner/repo}::{path}` (files) | | `--data-source NAME_OR_ID` | Optional. Data source Name or Id (from a result's `Source:` line) to disambiguate an identifier indexed in more than one data source | @@ -295,23 +305,17 @@ don't match the artifact's real logic. ### `chat.py` — Chat with Codebase (not recommended) -**Do NOT call unless the user explicitly asks** (e.g. "use chat", "use codebase_consultant", "call the chat tool"). Phrases like "ask CodeAlive" or "search CodeAlive" refer to search tools, not chat. +**Do NOT call unless the user explicitly asks** (e.g. "use chat", "call the chat tool"). Phrases like "ask CodeAlive" or "search CodeAlive" refer to search tools, not chat. -Sends your question to an AI consultant that has full context of the indexed codebase. Returns synthesized, ready-to-use answers. Supports conversation continuity for follow-ups. +Sends your self-contained question to an AI consultant that has full context of the selected indexed codebase. Returns synthesized, ready-to-use answers. -**This is slow and expensive** — runs an LLM on the server side, up to 30 seconds per call. For all standard tasks (finding code, understanding architecture, debugging), use `search.py`, `grep.py`, `fetch.py`, and `relationships.py` instead. +**This is slow and expensive** — runs an LLM on the server side and can take substantially longer than retrieval. It is stateless in v3, so include prior findings, identifiers, assumptions, scope, and constraints in each question. For all standard tasks (finding code, understanding architecture, debugging), use ontology, search, grep, fetch/read, relationships, and metadata queries instead. ```bash python scripts/chat.py [options] ``` -| Option | Description | -|--------|-------------| -| `--continue ` | Continue a previous conversation (saves context and cost) | - -**Conversation continuity:** Every successful response includes a `conversation_id` (a 24-character hex Mongo ObjectId, e.g. `69fceb3e7b2a6a7efdd18180`) and a `message_id` of the same format. Pass `--continue ` for follow-up questions — this preserves context and is cheaper than starting fresh. - -Format guarantee: any value not matching `^[0-9a-fA-F]{24}$` is rejected client-side before the request is sent. +There is no public `conversation_id` in v3. For follow-ups, restate the relevant context in the next `question`. ## Data Sources diff --git a/skills/codealive-context-engine/scripts/chat.py b/skills/codealive-context-engine/scripts/chat.py index bf19c74..74a232f 100755 --- a/skills/codealive-context-engine/scripts/chat.py +++ b/skills/codealive-context-engine/scripts/chat.py @@ -8,7 +8,7 @@ Usage: python chat.py "How does authentication work?" my-repo python chat.py "Explain the database schema" workspace:backend-team - python chat.py "What's the best way to add caching?" --continue CONV_ID + python chat.py "What's the best way to add caching? Prior context: ..." my-repo Examples: # Ask about current project @@ -20,15 +20,14 @@ # Ask about dependencies/libraries python chat.py "How does lodash debounce work internally?" lodash - # Continue previous conversation - python chat.py "What about error handling?" --continue 69fceb3e7b2a6a7efdd18180 + # v3 chat is stateless: include prior findings and constraints in every question. + python chat.py "Given prior finding X, what about error handling?" my-backend # Cross-project learning python chat.py "Show me authentication patterns across our org" workspace:all-backend """ import sys -import json from pathlib import Path # Add lib directory to path @@ -41,7 +40,7 @@ def main(): """CLI interface for codebase consultant.""" if len(sys.argv) < 2: print("Error: Missing required arguments.", file=sys.stderr) - print("Usage: python chat.py [data_source2...] [--continue ]", file=sys.stderr) + print("Usage: python chat.py [data_source2...]", file=sys.stderr) sys.exit(1) if sys.argv[1] == "--help": @@ -49,25 +48,21 @@ def main(): sys.exit(0) question = sys.argv[1] - conversation_id = None data_sources = [] # Parse arguments i = 2 while i < len(sys.argv): arg = sys.argv[i] - if arg == "--continue" and i + 1 < len(sys.argv): - conversation_id = sys.argv[i + 1] - i += 2 - elif arg == "--conversation-id" and i + 1 < len(sys.argv): - conversation_id = sys.argv[i + 1] - i += 2 + if arg in {"--continue", "--conversation-id"}: + print("Error: chat is stateless in v3; include prior context in the question instead.", file=sys.stderr) + sys.exit(1) else: data_sources.append(arg) i += 1 - if not conversation_id and not data_sources: - print("Error: Either data sources or --continue is required.", file=sys.stderr) + if not data_sources: + print("Error: At least one data source is required.", file=sys.stderr) print("Run datasources.py to see available sources.", file=sys.stderr) sys.exit(1) @@ -75,29 +70,21 @@ def main(): client = CodeAliveClient() print(f"💬 Question: {question}", file=sys.stderr) - if conversation_id: - print(f"🔄 Continuing conversation: {conversation_id}", file=sys.stderr) - else: - print(f"📚 Analyzing: {', '.join(data_sources)}", file=sys.stderr) + print(f"📚 Analyzing: {', '.join(data_sources)}", file=sys.stderr) + print("ℹ️ v3 chat is stateless; each question must include needed prior context.", file=sys.stderr) print(file=sys.stderr) print("🤔 Thinking...", file=sys.stderr) print(file=sys.stderr) result = client.chat( question=question, - data_sources=data_sources if data_sources else None, - conversation_id=conversation_id + data_sources=data_sources, ) print("="*80) - print(result["answer"]) + print(result) print("="*80) - if result.get("conversation_id"): - print() - print(f"💾 Conversation ID: {result['conversation_id']}") - print(f" Use --continue {result['conversation_id']} to ask follow-up questions") - except Exception as e: print(f"❌ Error: {e}", file=sys.stderr) sys.exit(1) diff --git a/skills/codealive-context-engine/scripts/datasources.py b/skills/codealive-context-engine/scripts/datasources.py index 91413be..73f2409 100755 --- a/skills/codealive-context-engine/scripts/datasources.py +++ b/skills/codealive-context-engine/scripts/datasources.py @@ -112,7 +112,7 @@ def format_datasources(datasources: list, as_json: bool = False, message: str = def main(): """CLI interface for listing data sources.""" - alive_only = True + ready_only = True as_json = False query = None @@ -121,7 +121,7 @@ def main(): while i < len(args): arg = args[i] if arg == "--all": - alive_only = False + ready_only = False elif arg == "--json": as_json = True elif arg == "--query": @@ -137,14 +137,15 @@ def main(): try: client = CodeAliveClient() - result = client.get_datasources(alive_only=alive_only, query=query) - if isinstance(result, dict): - datasources = result.get("dataSources", []) - message = result.get("message", "") + result = client.get_datasources( + ready_only=ready_only, + query=query, + output_format="json" if as_json else "agentic", + ) + if as_json: + print(json.dumps(result, indent=2)) else: - datasources = result - message = "" - print(format_datasources(datasources, as_json, message)) + print(result) except Exception as e: print(f"❌ Error: {e}", file=sys.stderr) diff --git a/skills/codealive-context-engine/scripts/fetch.py b/skills/codealive-context-engine/scripts/fetch.py index 89102f2..20ad5f7 100644 --- a/skills/codealive-context-engine/scripts/fetch.py +++ b/skills/codealive-context-engine/scripts/fetch.py @@ -27,11 +27,10 @@ to disambiguate an identifier that exists in more than one data source. Without it, an ambiguous identifier returns a 409 listing the candidate data sources. -Maximum 20 identifiers per request. +Maximum 50 identifiers per request. """ import sys -import json from pathlib import Path # Add lib directory to path @@ -40,172 +39,6 @@ from api_client import CodeAliveClient -def _add_line_numbers(content: str, start_line: int = 1) -> str: - """Add line numbers to content for easier navigation.""" - if not content: - return content - lines = content.split("\n") - width = len(str(start_line + len(lines) - 1)) - numbered = [f"{start_line + i:>{width}} | {line}" for i, line in enumerate(lines)] - return "\n".join(numbered) - - -def _has_any_calls(relationships: dict) -> bool: - """True if a relationships preview has at least one outgoing/incoming call.""" - for key in ("outgoingCallsCount", "incomingCallsCount"): - count = relationships.get(key) - if count and count > 0: - return True - return False - - -def _format_relationships_preview(relationships: dict) -> list: - """Format the inline preview of call relationships returned with each artifact. - - Returns a list of output lines (possibly empty). - """ - lines: list = [] - - for direction, key, label in ( - ("outgoing", "outgoingCalls", "↗ outgoing_calls"), - ("incoming", "incomingCalls", "↙ incoming_calls"), - ): - count = relationships.get(f"{key}Count") - if count is None: - continue - calls = relationships.get(key) or [] - - lines.append(f" {label} ({count}):") - if not calls: - lines.append(" (none in preview)") - continue - for call in calls: - ident = call.get("identifier", "") - summary = call.get("summary") - if summary: - lines.append(f" • {ident}") - lines.append(f" 📝 {summary}") - else: - lines.append(f" • {ident}") - - return lines - - -def _data_source_miss_hint(data_source: str) -> str: - """Recovery hint when a data-source-scoped fetch returns nothing.""" - return ( - f'\n💡 Hint: nothing was found in data source "{data_source}". The identifier may belong to a ' - "different data source, or the --data-source value may be wrong. Try: re-run with --data-source " - "set to a different candidate (use the Source name or id from your search results, or run " - "datasources.py), or drop --data-source entirely — an ambiguous identifier then returns a 409 " - "listing the candidate data sources to choose from." - ) - - -def _not_found_lines(not_found: list) -> list: - """Lines listing requested identifiers the backend could not resolve or that are - outside the caller's access scope, with a re-check/retry hint.""" - lines = [ - f"\n{'='*60}", - f"⚠️ {len(not_found)} requested identifier(s) not found or inaccessible:", - ] - for identifier in not_found: - lines.append(f" • {identifier}") - lines.append(f"{'='*60}") - lines.append( - "💡 Do NOT silently omit these. A not-found entry means the identifier did not " - "resolve, or points outside the data sources this key can read — it is NOT proof " - "the code is absent. Re-check those exact identifiers, re-run search.py or grep.py " - "to get fresh ids, then re-fetch the problematic ones; if they still cannot be " - "retrieved, tell the user which artifacts could not be fetched." - ) - return lines - - -def format_artifacts(data: dict, data_source: str = None, requested: list = None) -> str: - """Format fetched artifacts for display. - - Requested identifiers the backend could not resolve — or that are outside the caller's - access scope — come back with ``found: false`` (older backends omit the flag and return - ``content: null``). They are NOT dropped silently: each concrete identifier is listed in - a "not found" section with a hint to re-check the ids and retry the problematic ones. - A ``found: true`` artifact with empty content is still shown (it was located). - ``requested`` is the original identifier list; it backstops the diff so an id the - backend never echoed back is still surfaced as not-found. - """ - artifacts = data.get("artifacts", []) - - output = [] - count = 0 - has_any_relationships = False - returned_identifiers = set() - not_found = [] - - for artifact in artifacts: - identifier = artifact.get("identifier", "unknown") - returned_identifiers.add(identifier) - - content = artifact.get("content") - # Prefer the backend's explicit `found` flag; fall back to content-is-null for - # older backends that don't emit it yet. - found = artifact.get("found") - is_missing = (found is False) if found is not None else (content is None) - if is_missing: - not_found.append(identifier) - continue - - count += 1 - content_byte_size = artifact.get("contentByteSize") - - size_str = f" ({content_byte_size} bytes)" if content_byte_size else "" - output.append(f"\n{'='*60}") - output.append(f"📄 {identifier}{size_str}") - output.append(f"{'='*60}") - start_line = artifact.get("startLine") or 1 - output.append(_add_line_numbers(content or "", start_line)) - - relationships = artifact.get("relationships") - if relationships is not None: - preview_lines = _format_relationships_preview(relationships) - if preview_lines: - output.append("\n--- relationships (preview) ---") - output.extend(preview_lines) - if _has_any_calls(relationships): - has_any_relationships = True - - # Backstop: any requested identifier the backend never echoed back is also missing. - if requested: - for identifier in requested: - if identifier not in returned_identifiers and identifier not in not_found: - not_found.append(identifier) - - if count > 0: - output.append(f"\n({count} artifact(s))") - - if has_any_relationships: - output.append( - "\n💡 Hint: the relationships shown above are a preview (up to 3 calls " - "per direction).\n" - " To see the full call graph, inheritance, or references for an " - "artifact, run:\n" - " python relationships.py " - "[--profile callsOnly|inheritanceOnly|allRelevant|referencesOnly]" - ) - - if not_found: - output.extend(_not_found_lines(not_found)) - - if count == 0: - # Nothing was actually fetched. Keep the data-source-specific recovery hint when a - # selector was supplied; the not-found section above already lists the ids. - if data_source: - output.append(_data_source_miss_hint(data_source)) - if not output: - return "No artifacts returned." - - return "\n".join(output) - - def main(): """CLI interface for fetching artifacts.""" if len(sys.argv) < 2 or sys.argv[1] == "--help": @@ -235,8 +68,8 @@ def main(): print("Error: At least one identifier is required.", file=sys.stderr) sys.exit(1) - if len(identifiers) > 20: - print("Error: Maximum 20 identifiers per request.", file=sys.stderr) + if len(identifiers) > 50: + print("Error: Maximum 50 identifiers per request.", file=sys.stderr) sys.exit(1) try: @@ -248,8 +81,7 @@ def main(): print(file=sys.stderr) result = client.fetch_artifacts(identifiers=identifiers, data_source=data_source) - - print(format_artifacts(result, data_source=data_source, requested=identifiers)) + print(result) except Exception as e: print(f"❌ Error: {e}", file=sys.stderr) diff --git a/skills/codealive-context-engine/scripts/grep.py b/skills/codealive-context-engine/scripts/grep.py index 2982d85..f51c234 100755 --- a/skills/codealive-context-engine/scripts/grep.py +++ b/skills/codealive-context-engine/scripts/grep.py @@ -125,7 +125,7 @@ def main(): try: client = CodeAliveClient() - results = client.grep_search( + result = client.grep_search( query=query, data_sources=data_sources, paths=paths or None, @@ -133,7 +133,7 @@ def main(): max_results=max_results, regex=regex, ) - print(format_grep_results(results)) + print(result) except Exception as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) diff --git a/skills/codealive-context-engine/scripts/lib/api_client.py b/skills/codealive-context-engine/scripts/lib/api_client.py index 8ebda86..0e21b74 100644 --- a/skills/codealive-context-engine/scripts/lib/api_client.py +++ b/skills/codealive-context-engine/scripts/lib/api_client.py @@ -4,7 +4,6 @@ """ import os -import re import urllib.parse import sys import json @@ -14,10 +13,7 @@ from typing import Optional, Dict, Any, List -# 24-character hex Mongo ObjectId. The CodeAlive REST API rejects any other -# shape for conversation_id / message_id with a 400; preflight locally so -# agents get an actionable error before the network round-trip. -_OBJECT_ID_RE = re.compile(r"^[0-9a-fA-F]{24}$") +CLIENT_VERSION = "skills-v3" # Pre-filter scoped candidate count, emitted by the backend only on relevance-filtered # (query'd) data source listings. Lowercase because _make_request lowercases header @@ -331,7 +327,8 @@ def _make_request( endpoint: str, params: Optional[Dict[str, Any]] = None, body: Optional[Dict[str, Any]] = None, - return_headers: bool = False + return_headers: bool = False, + extra_headers: Optional[Dict[str, str]] = None, ) -> Any: """ Make an HTTP request to the CodeAlive API. @@ -359,6 +356,8 @@ def _make_request( "Content-Type": "application/json", "Accept": "application/json, application/problem+json", } + if extra_headers: + headers.update(extra_headers) data = None if body: @@ -416,14 +415,50 @@ def _make_request( f"Check your network connection and CODEALIVE_BASE_URL setting." ) + def tool( + self, + name: str, + payload: Optional[Dict[str, Any]] = None, + output_format: str = "agentic", + ) -> Any: + """Call a CodeAlive Tool API v3 tool. + + Skills default to backend-rendered agentic output. Use output_format="json" + for scripts that need the canonical obj envelope. + """ + clean_payload = { + key: value + for key, value in (payload or {}).items() + if value is not None and value != [] and value != "" + } + clean_payload["output_format"] = output_format + envelope = self._make_request( + "POST", + f"/api/tools/{name}", + body=clean_payload, + extra_headers={ + "X-CodeAlive-Integration": "skills", + "X-CodeAlive-Tool": name, + "X-CodeAlive-Client": CLIENT_VERSION, + }, + ) + if output_format == "agentic": + return envelope.get("rendered", "") + if output_format == "json": + return envelope.get("obj", {}) + return envelope + def get_datasources( - self, alive_only: bool = True, query: Optional[str] = None + self, + ready_only: bool = True, + query: Optional[str] = None, + output_format: str = "agentic", ) -> Any: """ Get available data sources (repositories and workspaces). Args: - alive_only: If True, only return data sources ready for use. If False, return all. + ready_only: If True, only return data sources ready for use. If False, return all. query: Optional natural-language task/intent (e.g. "add OAuth to checkout"). When provided, the backend runs an agentic relevance filter and returns ONLY the data sources relevant to that intent, each with a `relevanceReason` explaining why. @@ -434,46 +469,26 @@ def get_datasources( sources were omitted as non-relevant (and how many of the total) or that relevance filtering was unavailable and the FULL list is returned. """ - endpoint = "/api/datasources/ready" if alive_only else "/api/datasources/all" - if not query or not query.strip(): - return self._make_request("GET", endpoint) - - datasources, headers = self._make_request( - "GET", endpoint, params={"query": query}, return_headers=True + return self.tool( + "get_data_sources", + {"ready_only": ready_only, "query": query}, + output_format=output_format, ) - return { - "dataSources": datasources, - "message": relevance_message(datasources, headers.get(_TOTAL_DATA_SOURCES_HEADER)), - } def search( self, query: str, data_sources: List[str], mode: str = "auto", - description_detail: str = "short" - ) -> Dict[str, Any]: - """ - Search for code using natural language queries. - - Args: - query: Natural language description of what to find - data_sources: List of repository or workspace names to search - mode: Search mode - "auto" (default), "fast", or "deep" - description_detail: Detail level for descriptions - "short" (default) or "full" - - Returns: - Search results with file paths, line numbers, descriptions, and identifiers - """ - detail_map = {"short": "Short", "full": "Full"} - params = { - "Query": query, - "Mode": mode, - "IncludeContent": "false", - "DescriptionDetail": detail_map.get(description_detail.lower(), "Short"), - "Names": data_sources - } - return self._make_request("GET", "/api/search", params=params) + description_detail: str = "short", + output_format: str = "agentic", + ) -> Any: + """Compatibility wrapper for the v3 semantic_search tool.""" + return self.semantic_search( + query=query, + data_sources=data_sources, + output_format=output_format, + ) def semantic_search( self, @@ -482,20 +497,22 @@ def semantic_search( paths: Optional[List[str]] = None, extensions: Optional[List[str]] = None, max_results: Optional[int] = None, - ) -> Dict[str, Any]: - """Search indexed artifacts semantically using the canonical API.""" - params: Dict[str, Any] = { - "Query": query, - "Names": data_sources, - } - if paths: - params["Paths"] = paths - if extensions: - params["Extensions"] = extensions - if max_results is not None: - params["MaxResults"] = max_results - - return self._make_request("GET", "/api/search/semantic", params=params) + exclude_markdown: bool = False, + output_format: str = "agentic", + ) -> Any: + """Search indexed artifacts semantically using Tool API v3.""" + return self.tool( + "semantic_search", + { + "question": query, + "data_sources": data_sources, + "paths": paths, + "extensions": extensions, + "max_results": max_results, + "exclude_markdown": exclude_markdown, + }, + output_format=output_format, + ) def grep_search( self, @@ -505,27 +522,30 @@ def grep_search( extensions: Optional[List[str]] = None, max_results: Optional[int] = None, regex: bool = False, - ) -> Dict[str, Any]: - """Search indexed artifacts by exact text or regex using the canonical API.""" - params: Dict[str, Any] = { - "Query": query, - "Names": data_sources, - "Regex": str(regex).lower(), - } - if paths: - params["Paths"] = paths - if extensions: - params["Extensions"] = extensions - if max_results is not None: - params["MaxResults"] = max_results - - return self._make_request("GET", "/api/search/grep", params=params) + exclude_markdown: bool = False, + output_format: str = "agentic", + ) -> Any: + """Search indexed artifacts by exact text or regex using Tool API v3.""" + return self.tool( + "grep_search", + { + "query": query, + "data_sources": data_sources, + "paths": paths, + "extensions": extensions, + "max_results": max_results, + "exclude_markdown": exclude_markdown, + "regex": regex, + }, + output_format=output_format, + ) def fetch_artifacts( self, identifiers: List[str], data_source: Optional[str] = None, - ) -> Dict[str, Any]: + output_format: str = "agentic", + ) -> Any: """ Retrieve full content for code artifacts by their identifiers. @@ -536,7 +556,7 @@ def fetch_artifacts( {owner/repo}::{path} (for files) Args: - identifiers: List of artifact identifiers from search results (max 20) + identifiers: List of artifact identifiers from search results (max 50) data_source: Optional data-source Name or Id to disambiguate an identifier that exists in more than one data source. Copy the `dataSource.name`/`dataSource.id` from a search result. Omit for normal lookups; an ambiguous identifier without @@ -549,10 +569,11 @@ def fetch_artifacts( calls per direction). Use ``get_artifact_relationships()`` to retrieve the full list and other relationship profiles. """ - body: Dict[str, Any] = {"identifiers": identifiers} - if data_source: - body["dataSource"] = data_source - return self._make_request("POST", "/api/search/artifacts", body=body) + return self.tool( + "fetch_artifacts", + {"identifiers": identifiers, "data_source": data_source}, + output_format=output_format, + ) def get_artifact_relationships( self, @@ -560,7 +581,8 @@ def get_artifact_relationships( profile: str = "callsOnly", max_count_per_type: int = 50, data_source: Optional[str] = None, - ) -> Dict[str, Any]: + output_format: str = "agentic", + ) -> Any: """ Retrieve relationship groups for a single artifact by profile. @@ -588,10 +610,10 @@ def get_artifact_relationships( (identifier, filePath, startLine, shortSummary). """ profile_map = { - "callsOnly": "CallsOnly", - "inheritanceOnly": "InheritanceOnly", - "allRelevant": "AllRelevant", - "referencesOnly": "ReferencesOnly", + "callsOnly": "calls_only", + "inheritanceOnly": "inheritance_only", + "allRelevant": "all_relevant", + "referencesOnly": "references_only", } api_profile = profile_map.get(profile) if api_profile is None: @@ -600,73 +622,104 @@ def get_artifact_relationships( f'Unsupported profile "{profile}". Use one of: {supported}' ) - body: Dict[str, Any] = { - "identifier": identifier, - "profile": api_profile, - "maxCountPerType": max_count_per_type, - } - if data_source: - body["dataSource"] = data_source - return self._make_request( - "POST", "/api/search/artifact-relationships", body=body + return self.tool( + "get_artifact_relationships", + { + "identifier": identifier, + "profile": api_profile, + "max_count_per_type": max_count_per_type, + "data_source": data_source, + }, + output_format=output_format, ) - def chat( + def get_repository_ontology( self, - question: str, - data_sources: Optional[List[str]] = None, - conversation_id: Optional[str] = None - ) -> Dict[str, Any]: - """ - Ask questions about the codebase to an AI consultant. + data_source: Optional[str] = None, + output_format: str = "agentic", + ) -> Any: + return self.tool( + "get_repository_ontology", + {"data_source": data_source}, + output_format=output_format, + ) - Args: - question: Question about the codebase - data_sources: List of repository or workspace names to analyze - conversation_id: ID to continue a previous conversation - (24-character hex Mongo ObjectId) + def get_file_tree( + self, + data_source: Optional[str] = None, + path: Optional[str] = None, + max_depth: Optional[int] = None, + max_nodes: Optional[int] = None, + output_depth: Optional[int] = None, + output_format: str = "agentic", + ) -> Any: + return self.tool( + "get_file_tree", + { + "data_source": data_source, + "path": path, + "max_depth": max_depth, + "max_nodes": max_nodes, + "output_depth": output_depth, + }, + output_format=output_format, + ) - Returns: - Response with ``answer``, ``conversation_id``, ``message_id``, - and ``full_response``. - """ - # Preflight: reject GUIDs / random strings before the network round-trip. - # Pre-Phase-3 servers used to return Guid.NewGuid() as response.id and - # rejected it on the next turn — this catches that footgun locally. - if conversation_id and not _OBJECT_ID_RE.match(conversation_id): - raise ValueError( - f"conversation_id {conversation_id!r} is not a 24-character hex " - f"Mongo ObjectId; pass the value from a previous " - f"response.conversation_id" - ) + def read_file( + self, + path: str, + data_source: Optional[str] = None, + start_line: Optional[int] = None, + end_line: Optional[int] = None, + output_format: str = "agentic", + ) -> Any: + return self.tool( + "read_file", + { + "path": path, + "data_source": data_source, + "start_line": start_line, + "end_line": end_line, + }, + output_format=output_format, + ) - body: Dict[str, Any] = { - "messages": [{"role": "user", "content": question}], - "stream": False, - } + def get_artifact_query_schema( + self, + entity: Optional[str] = None, + include_examples: bool = True, + output_format: str = "agentic", + ) -> Any: + return self.tool( + "get_artifact_query_schema", + {"entity": entity, "include_examples": include_examples}, + output_format=output_format, + ) - if conversation_id: - body["conversationId"] = conversation_id - elif data_sources: - body["names"] = data_sources - else: - raise ValueError("Either conversation_id or data_sources must be provided") - - response = self._make_request("POST", "/api/chat/completions", body=body) - - # Prefer the documented Phase-3 shape ({content, conversationId, messageId}); - # fall back to the legacy OpenAI-style envelope so this client keeps working - # against pre-Phase-3 servers during incremental rollout. - answer = response.get("content") - if not answer: - answer = (response.get("choices") or [{}])[0].get("message", {}).get("content", "") - - return { - "answer": answer, - "conversation_id": response.get("conversationId") or response.get("id"), - "message_id": response.get("messageId"), - "full_response": response, - } + def query_artifact_metadata( + self, + statement: str, + data_sources: Optional[List[str]] = None, + output_format: str = "agentic", + ) -> Any: + return self.tool( + "query_artifact_metadata", + {"statement": statement, "data_sources": data_sources}, + output_format=output_format, + ) + + def chat( + self, + question: str, + data_sources: Optional[List[str]] = None, + output_format: str = "agentic", + ) -> Any: + """Ask stateless CodeAlive chat through Tool API v3.""" + return self.tool( + "chat", + {"question": question, "data_sources": data_sources}, + output_format=output_format, + ) def main(): @@ -675,12 +728,17 @@ def main(): print("Usage: python api_client.py [args...]") print("Commands:") print(" datasources [--all] [--query TASK]") - print(" search [data_source2...] [--mode auto|fast|deep] [--description-detail short|full]") + print(" search [data_source2...]") print(" semantic-search [data_source2...] [--path PATH] [--ext EXT] [--max-results N]") print(" grep-search [data_source2...] [--regex] [--path PATH] [--ext EXT] [--max-results N]") print(" fetch [identifier2...] [--data-source NAME_OR_ID]") print(" relationships [--profile callsOnly|inheritanceOnly|allRelevant|referencesOnly] [--max-count N] [--data-source NAME_OR_ID]") - print(" chat [data_source2...] [--conversation-id ID]") + print(" ontology [data_source]") + print(" tree [data_source]") + print(" read-file [data_source]") + print(" schema") + print(" metadata [data_source1] [data_source2...]") + print(" chat [data_source2...]") sys.exit(1) client = CodeAliveClient() @@ -688,7 +746,7 @@ def main(): try: if command == "datasources": - alive_only = "--all" not in sys.argv + ready_only = "--all" not in sys.argv query = None if "--query" in sys.argv: query_index = sys.argv.index("--query") @@ -696,12 +754,12 @@ def main(): print("Usage: datasources [--all] [--query TASK]") sys.exit(1) query = sys.argv[query_index + 1] - result = client.get_datasources(alive_only=alive_only, query=query) + result = client.get_datasources(ready_only=ready_only, query=query, output_format="json") print(json.dumps(result, indent=2)) elif command == "search": if len(sys.argv) < 4: - print("Usage: search [data_source2...] [--mode MODE] [--description-detail short|full]") + print("Usage: search [data_source2...]") sys.exit(1) query = sys.argv[2] @@ -868,34 +926,49 @@ def main(): ) print(json.dumps(result, indent=2)) + elif command == "ontology": + data_source = sys.argv[2] if len(sys.argv) > 2 else None + print(client.get_repository_ontology(data_source=data_source)) + + elif command == "tree": + data_source = sys.argv[2] if len(sys.argv) > 2 else None + print(client.get_file_tree(data_source=data_source)) + + elif command == "read-file": + if len(sys.argv) < 3: + print("Usage: read-file [data_source]") + sys.exit(1) + data_source = sys.argv[3] if len(sys.argv) > 3 else None + print(client.read_file(sys.argv[2], data_source=data_source)) + + elif command == "schema": + print(client.get_artifact_query_schema()) + + elif command == "metadata": + if len(sys.argv) < 3: + print("Usage: metadata [data_source1] [data_source2...]") + sys.exit(1) + print(client.query_artifact_metadata(sys.argv[2], sys.argv[3:] or None)) + elif command == "chat": if len(sys.argv) < 4: - print("Usage: chat [data_source2...] [--conversation-id ID]") + print("Usage: chat [data_source2...]") sys.exit(1) question = sys.argv[2] - conversation_id = None data_sources = [] i = 3 while i < len(sys.argv): arg = sys.argv[i] - if arg == "--conversation-id": - # Match the flag first, then require a value — otherwise a trailing - # "--conversation-id" with no value would be silently appended as a data source. - if i + 1 >= len(sys.argv): - print("Error: --conversation-id requires a value.", file=sys.stderr) - sys.exit(1) - conversation_id = sys.argv[i + 1] - i += 2 + if arg in {"--conversation-id", "--continue"}: + print("Error: chat is stateless in v3; include prior context in the question instead.", file=sys.stderr) + sys.exit(1) else: data_sources.append(arg) i += 1 - result = client.chat(question, data_sources if data_sources else None, conversation_id) - print(result["answer"]) - if result.get("conversation_id"): - print(f"\nConversation ID: {result['conversation_id']}") + print(client.chat(question, data_sources if data_sources else None)) else: print(f"Unknown command: {command}") diff --git a/skills/codealive-context-engine/scripts/metadata.py b/skills/codealive-context-engine/scripts/metadata.py new file mode 100644 index 0000000..3a38ece --- /dev/null +++ b/skills/codealive-context-engine/scripts/metadata.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Run a read-only ArtifactQuery metadata statement.""" + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent / "lib")) + +from api_client import CodeAliveClient + + +def main(): + if len(sys.argv) < 2 or sys.argv[1] == "--help": + print("Usage: python metadata.py [data_source ...] [--json]") + sys.exit(0 if len(sys.argv) >= 2 else 1) + + statement = sys.argv[1] + data_sources = [] + as_json = False + for arg in sys.argv[2:]: + if arg == "--json": + as_json = True + else: + data_sources.append(arg) + + try: + result = CodeAliveClient().query_artifact_metadata( + statement=statement, + data_sources=data_sources or None, + output_format="json" if as_json else "agentic", + ) + print(json.dumps(result, indent=2) if as_json else result) + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/skills/codealive-context-engine/scripts/ontology.py b/skills/codealive-context-engine/scripts/ontology.py new file mode 100644 index 0000000..2e94671 --- /dev/null +++ b/skills/codealive-context-engine/scripts/ontology.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Get repository ontology and high-level orientation for one repository.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent / "lib")) + +from api_client import CodeAliveClient + + +def main(): + if len(sys.argv) > 2 or (len(sys.argv) == 2 and sys.argv[1] == "--help"): + print("Usage: python ontology.py [data_source]", file=sys.stderr) + sys.exit(0 if len(sys.argv) == 2 else 1) + + data_source = sys.argv[1] if len(sys.argv) == 2 else None + try: + print(CodeAliveClient().get_repository_ontology(data_source=data_source)) + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/skills/codealive-context-engine/scripts/read_file.py b/skills/codealive-context-engine/scripts/read_file.py new file mode 100644 index 0000000..ba8943e --- /dev/null +++ b/skills/codealive-context-engine/scripts/read_file.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Read one repository-relative file path.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent / "lib")) + +from api_client import CodeAliveClient + + +def parse_int_option(name: str, value: str) -> int: + try: + return int(value) + except ValueError: + print(f"Error: {name} expects an integer, got '{value}'", file=sys.stderr) + sys.exit(1) + + +def main(): + if len(sys.argv) < 2 or sys.argv[1] == "--help": + print("Usage: python read_file.py [--data-source NAME_OR_ID] [--start-line N] [--end-line N]") + sys.exit(0 if len(sys.argv) >= 2 else 1) + + file_path = sys.argv[1] + data_source = None + start_line = None + end_line = None + + i = 2 + while i < len(sys.argv): + arg = sys.argv[i] + if arg in {"--data-source", "--start-line", "--end-line"}: + if i + 1 >= len(sys.argv): + print(f"Error: {arg} requires a value.", file=sys.stderr) + sys.exit(1) + value = sys.argv[i + 1] + if arg == "--data-source": + data_source = value + elif arg == "--start-line": + start_line = parse_int_option(arg, value) + else: + end_line = parse_int_option(arg, value) + i += 2 + else: + print(f"Error: unknown argument '{arg}'", file=sys.stderr) + sys.exit(1) + + try: + print(CodeAliveClient().read_file( + path=file_path, + data_source=data_source, + start_line=start_line, + end_line=end_line, + )) + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/skills/codealive-context-engine/scripts/relationships.py b/skills/codealive-context-engine/scripts/relationships.py index 273c970..f615883 100755 --- a/skills/codealive-context-engine/scripts/relationships.py +++ b/skills/codealive-context-engine/scripts/relationships.py @@ -208,12 +208,13 @@ def main(): profile=profile, max_count_per_type=max_count, data_source=data_source, + output_format="json" if as_json else "agentic", ) if as_json: print(json.dumps(result, indent=2)) else: - print(format_relationships(result, data_source=data_source)) + print(result) except Exception as e: print(f"❌ Error: {e}", file=sys.stderr) diff --git a/skills/codealive-context-engine/scripts/schema.py b/skills/codealive-context-engine/scripts/schema.py new file mode 100644 index 0000000..d80f7c0 --- /dev/null +++ b/skills/codealive-context-engine/scripts/schema.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Get the ArtifactQuery schema.""" + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent / "lib")) + +from api_client import CodeAliveClient + + +def main(): + entity = None + include_examples = True + as_json = False + + args = sys.argv[1:] + i = 0 + while i < len(args): + arg = args[i] + if arg == "--help": + print("Usage: python schema.py [--entity ENTITY] [--no-examples] [--json]") + return + if arg == "--entity": + if i + 1 >= len(args): + print("Error: --entity requires a value.", file=sys.stderr) + sys.exit(1) + entity = args[i + 1] + i += 2 + elif arg == "--no-examples": + include_examples = False + i += 1 + elif arg == "--json": + as_json = True + i += 1 + else: + print(f"Error: unknown argument '{arg}'", file=sys.stderr) + sys.exit(1) + + try: + result = CodeAliveClient().get_artifact_query_schema( + entity=entity, + include_examples=include_examples, + output_format="json" if as_json else "agentic", + ) + print(json.dumps(result, indent=2) if as_json else result) + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/skills/codealive-context-engine/scripts/search.py b/skills/codealive-context-engine/scripts/search.py index 99a21be..850b750 100755 --- a/skills/codealive-context-engine/scripts/search.py +++ b/skills/codealive-context-engine/scripts/search.py @@ -168,7 +168,7 @@ def main(): print(f"Extensions: {', '.join(extensions)}", file=sys.stderr) print(file=sys.stderr) - results = client.semantic_search( + result = client.semantic_search( query=query, data_sources=data_sources, paths=paths or None, @@ -176,7 +176,7 @@ def main(): max_results=max_results, ) - print(format_search_results(results)) + print(result) except Exception as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) diff --git a/skills/codealive-context-engine/scripts/tree.py b/skills/codealive-context-engine/scripts/tree.py new file mode 100644 index 0000000..d1863a1 --- /dev/null +++ b/skills/codealive-context-engine/scripts/tree.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Get a bounded repository file tree.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent / "lib")) + +from api_client import CodeAliveClient + + +def parse_int_option(name: str, value: str) -> int: + try: + return int(value) + except ValueError: + print(f"Error: {name} expects an integer, got '{value}'", file=sys.stderr) + sys.exit(1) + + +def main(): + data_source = None + path = None + max_depth = None + max_nodes = None + output_depth = None + + args = sys.argv[1:] + i = 0 + while i < len(args): + arg = args[i] + if arg == "--help": + print("Usage: python tree.py [--data-source NAME_OR_ID] [--path PATH] [--max-depth N] [--max-nodes N] [--output-depth N]") + return + if arg in {"--data-source", "--path", "--max-depth", "--max-nodes", "--output-depth"}: + if i + 1 >= len(args): + print(f"Error: {arg} requires a value.", file=sys.stderr) + sys.exit(1) + value = args[i + 1] + if arg == "--data-source": + data_source = value + elif arg == "--path": + path = value + elif arg == "--max-depth": + max_depth = parse_int_option(arg, value) + elif arg == "--max-nodes": + max_nodes = parse_int_option(arg, value) + else: + output_depth = parse_int_option(arg, value) + i += 2 + else: + print(f"Error: unknown argument '{arg}'", file=sys.stderr) + sys.exit(1) + + try: + print(CodeAliveClient().get_file_tree( + data_source=data_source, + path=path, + max_depth=max_depth, + max_nodes=max_nodes, + output_depth=output_depth, + )) + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/skills/codealive-context-engine/setup.py b/skills/codealive-context-engine/setup.py index 915d5a6..591098c 100755 --- a/skills/codealive-context-engine/setup.py +++ b/skills/codealive-context-engine/setup.py @@ -230,16 +230,23 @@ 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).""" normalized_base_url = normalize_base_url(base_url) - url = f"{normalized_base_url}/api/datasources/ready" + url = f"{normalized_base_url}/api/tools/get_data_sources" headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", + "Accept": "application/json, application/problem+json", + "X-CodeAlive-Integration": "skills", + "X-CodeAlive-Tool": "get_data_sources", + "X-CodeAlive-Client": "skills-v3-setup", } - req = urllib.request.Request(url, headers=headers, method="GET") + body = json.dumps({"ready_only": True, "output_format": "json"}).encode("utf-8") + req = urllib.request.Request(url, data=body, headers=headers, method="POST") try: with urllib.request.urlopen(req, timeout=15) as resp: data = json.loads(resp.read().decode("utf-8")) - count = len(data) if isinstance(data, list) else 0 + obj = data.get("obj", {}) if isinstance(data, dict) else {} + sources = obj.get("data_sources", []) if isinstance(obj, dict) else [] + count = len(sources) if isinstance(sources, list) else 0 return True, f"Connected. {count} data source{'s' if count != 1 else ''} available." except urllib.error.HTTPError as e: if e.code == 401: diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py deleted file mode 100644 index 00492ad..0000000 --- a/tests/test_cli_smoke.py +++ /dev/null @@ -1,392 +0,0 @@ -"""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.login", - "content": "def login(user, pwd):\n return True\n", - "startLine": 10, - "contentByteSize": 38, - "relationships": { - "outgoingCallsCount": 5, - "outgoingCalls": [ - {"identifier": "org/repo::src/db.py::query", "summary": "Runs SQL"}, - ], - "incomingCallsCount": 0, - "incomingCalls": None, - }, - } - ] - }, {} - - 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/semantic?Query=auth&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.login", 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 - # search must surface the "description is only a triage pointer" hint - assert "triage" in search.stdout - assert "fetch.py" in search.stdout - assert "before drawing conclusions" in search.stdout - - assert fetch.returncode == 0, fetch.stderr - assert "AuthService.login" in fetch.stdout - assert "10 | def login(user, pwd):" in fetch.stdout - # fetch must surface the relationships preview and the drill-down hint - assert "relationships (preview)" in fetch.stdout - assert "outgoing_calls (5)" in fetch.stdout - assert "Runs SQL" in fetch.stdout - assert "relationships.py" 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/semantic?Query=auth&Names=backend", - "/api/search/artifacts", - "/api/chat/completions", - ] - - -def test_datasources_script_query_flag_renders_relevance_shortlist(): - def datasources_handler(_request): - return 200, [ - { - "id": "repo-1", - "name": "backend", - "type": "Repository", - "description": "Main backend", - "relevanceReason": "Implements the checkout flow", - } - ], {"X-CodeAlive-Total-Data-Sources": "3"} - - with mock_codealive_server( - {("GET", "/api/datasources/ready?query=add+OAuth+to+checkout"): datasources_handler} - ) as (base_url, requests): - env = { - **os.environ, - "CODEALIVE_API_KEY": "skill-test-key", - "CODEALIVE_BASE_URL": f"{base_url}/api", - } - - formatted = _run("datasources.py", "--query", "add OAuth to checkout", env=env) - as_json = _run("datasources.py", "--query", "add OAuth to checkout", "--json", env=env) - - assert formatted.returncode == 0, formatted.stderr - assert "backend" in formatted.stdout - assert "Implements the checkout flow" in formatted.stdout - assert "1 of 3 available data sources are relevant" in formatted.stdout - assert "the other 2 were omitted" in formatted.stdout - - assert as_json.returncode == 0, as_json.stderr - envelope = json.loads(as_json.stdout) - assert envelope["dataSources"][0]["relevanceReason"] == "Implements the checkout flow" - assert "1 of 3" in envelope["message"] - - assert [request["path"] for request in requests] == [ - "/api/datasources/ready?query=add+OAuth+to+checkout", - "/api/datasources/ready?query=add+OAuth+to+checkout", - ] - - -def test_datasources_script_query_fail_open_warns_full_list(): - with mock_codealive_server( - { - ("GET", "/api/datasources/ready?query=add+OAuth"): ( - 200, - [ - {"id": "repo-1", "name": "backend", "type": "Repository"}, - {"id": "repo-2", "name": "frontend", "type": "Repository"}, - ], - ) - } - ) as (base_url, _requests): - env = { - **os.environ, - "CODEALIVE_API_KEY": "skill-test-key", - "CODEALIVE_BASE_URL": f"{base_url}/api", - } - - result = _run("datasources.py", "--query", "add OAuth", env=env) - - assert result.returncode == 0, result.stderr - assert "FULL unfiltered list" in result.stdout - - -def test_relationships_script_works_against_mock_backend(): - def relationships_handler(request): - body = json.loads(request["body"]) - assert body["identifier"] == "org/repo::src/svc.py::Service" - assert body["profile"] == "CallsOnly" - assert body["maxCountPerType"] == 50 - return 200, { - "sourceIdentifier": body["identifier"], - "profile": body["profile"], - "found": True, - "relationships": [ - { - "relationType": "OutgoingCalls", - "totalCount": 3, - "returnedCount": 3, - "truncated": False, - "items": [ - { - "identifier": "org/repo::src/db.py::query", - "filePath": "src/db.py", - "startLine": 42, - "shortSummary": "Runs SQL", - }, - { - "identifier": "org/repo::src/cache.py::get", - "filePath": "src/cache.py", - "startLine": 10, - }, - ], - }, - { - "relationType": "IncomingCalls", - "totalCount": 1, - "returnedCount": 1, - "truncated": False, - "items": [ - { - "identifier": "org/repo::src/main.py::run", - "filePath": "src/main.py", - "startLine": 5, - } - ], - }, - ], - }, {} - - with mock_codealive_server( - {("POST", "/api/search/artifact-relationships"): relationships_handler} - ) as (base_url, requests): - env = { - **os.environ, - "CODEALIVE_API_KEY": "skill-test-key", - "CODEALIVE_BASE_URL": f"{base_url}/api", - } - - result = _run("relationships.py", "org/repo::src/svc.py::Service", env=env) - - assert result.returncode == 0, result.stderr - # Source identifier and profile in the header - assert "org/repo::src/svc.py::Service" in result.stdout - assert "callsOnly" in result.stdout - # Both groups rendered with snake_case labels - assert "outgoing_calls" in result.stdout - assert "incoming_calls" in result.stdout - # Items + locations + summaries - assert "src/db.py:42" in result.stdout - assert "Runs SQL" in result.stdout - assert "src/main.py:5" in result.stdout - # Single backend call - assert [request["path"] for request in requests] == [ - "/api/search/artifact-relationships", - ] - - -def test_relationships_script_emits_json_when_flag_set(): - def relationships_handler(_request): - return 200, { - "sourceIdentifier": "org/repo::Missing", - "profile": "InheritanceOnly", - "found": False, - }, {} - - with mock_codealive_server( - {("POST", "/api/search/artifact-relationships"): relationships_handler} - ) as (base_url, _requests): - env = { - **os.environ, - "CODEALIVE_API_KEY": "skill-test-key", - "CODEALIVE_BASE_URL": f"{base_url}/api", - } - - result = _run( - "relationships.py", - "org/repo::Missing", - "--profile", "inheritanceOnly", - "--json", - env=env, - ) - - assert result.returncode == 0, result.stderr - parsed = json.loads(result.stdout) - assert parsed["found"] is False - assert parsed["sourceIdentifier"] == "org/repo::Missing" - - -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 - - -def test_fetch_and_relationships_scripts_hint_when_data_source_misses(): - """A --data-source-scoped request that finds nothing must hint: try another source or drop it.""" - def fetch_handler(request): - body = json.loads(request["body"]) - assert body["dataSource"] == "backend" - # Scoped to a source that doesn't contain the identifier -> null content. - return 200, {"artifacts": [{"identifier": body["identifiers"][0], "content": None}]}, {} - - def relationships_handler(request): - body = json.loads(request["body"]) - assert body["dataSource"] == "backend" - return 200, { - "sourceIdentifier": body["identifier"], - "profile": body["profile"], - "found": False, - "relationships": [], - }, {} - - with mock_codealive_server( - { - ("POST", "/api/search/artifacts"): fetch_handler, - ("POST", "/api/search/artifact-relationships"): relationships_handler, - } - ) as (base_url, _requests): - env = { - **os.environ, - "CODEALIVE_API_KEY": "skill-test-key", - "CODEALIVE_BASE_URL": f"{base_url}/api", - } - - fetch = _run( - "fetch.py", "org/repo::src/auth.py::Missing", "--data-source", "backend", env=env - ) - rel = _run( - "relationships.py", "org/repo::src/auth.py::Missing", "--data-source", "backend", env=env - ) - - assert fetch.returncode == 0, fetch.stderr - assert "backend" in fetch.stdout - assert "--data-source" in fetch.stdout - assert "drop --data-source" in fetch.stdout - - assert rel.returncode == 0, rel.stderr - assert "backend" in rel.stdout - assert "drop --data-source" in rel.stdout - - -def test_fetch_and_relationships_scripts_error_on_data_source_without_value(): - """A trailing --data-source with no value must error, not be silently treated as an identifier.""" - env = {**os.environ, "CODEALIVE_API_KEY": "skill-test-key"} - - fetch = _run("fetch.py", "org/repo::src/auth.py::AuthService", "--data-source", env=env) - assert fetch.returncode != 0 - assert "--data-source requires a value" in fetch.stderr - - rel = _run("relationships.py", "org/repo::src/auth.py::AuthService", "--data-source", env=env) - assert rel.returncode != 0 - assert "--data-source requires a value" in rel.stderr - - -def test_grep_format_surfaces_dict_and_string_datasource(): - """format_grep_results surfaces the source whether dataSource is a {name,id} object or a string.""" - import importlib.util - - grep_path = SKILL_ROOT / "scripts" / "grep.py" - spec = importlib.util.spec_from_file_location("grep_under_test", grep_path) - grep_mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(grep_mod) - - dict_out = grep_mod.format_grep_results( - {"results": [{"identifier": "org/repo::a.py::F", "dataSource": {"name": "backend", "id": "abc123"}}]} - ) - assert "Source: backend (id: abc123)" in dict_out - - # A bare-string dataSource must still be surfaced (was silently dropped before). - str_out = grep_mod.format_grep_results( - {"results": [{"identifier": "org/repo::a.py::F", "dataSource": "backend"}]} - ) - assert "Source: backend" in str_out diff --git a/tests/test_fetch_format.py b/tests/test_fetch_format.py deleted file mode 100644 index 54c0575..0000000 --- a/tests/test_fetch_format.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Unit tests for fetch.py artifact formatting — not-found surfacing. - -A requested identifier the backend cannot resolve (or that is outside the caller's scope) -must never be dropped silently: it has to appear in an explicit "not found" section with the -concrete identifier and a re-check/retry hint. -""" - -from __future__ import annotations - -import importlib.util -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[1] -FETCH_PATH = REPO_ROOT / "skills" / "codealive-context-engine" / "scripts" / "fetch.py" - - -def _load_fetch(): - spec = importlib.util.spec_from_file_location("codealive_fetch", FETCH_PATH) - module = importlib.util.module_from_spec(spec) - assert spec.loader is not None - spec.loader.exec_module(module) - return module - - -fetch = _load_fetch() -format_artifacts = fetch.format_artifacts - - -def test_found_artifact_is_rendered(): - data = {"artifacts": [ - {"identifier": "org/repo::a.py::F", "found": True, "content": "def f():\n pass", "contentByteSize": 17}, - ]} - out = format_artifacts(data, requested=["org/repo::a.py::F"]) - assert "org/repo::a.py::F" in out - assert "not found or inaccessible" not in out - - -def test_explicit_not_found_is_surfaced_with_concrete_id_and_hint(): - data = {"artifacts": [ - {"identifier": "org/repo::a.py::F", "found": True, "content": "x"}, - {"identifier": "org/repo::missing.py::G", "found": False, "content": None}, - ]} - out = format_artifacts(data, requested=["org/repo::a.py::F", "org/repo::missing.py::G"]) - assert "1 requested identifier(s) not found" in out - assert "org/repo::missing.py::G" in out - assert "Do NOT silently omit" in out - # the found one is still rendered - assert "📄 org/repo::a.py::F" in out - - -def test_legacy_backend_without_found_flag_falls_back_to_null_content(): - data = {"artifacts": [ - {"identifier": "org/repo::missing.py::G", "content": None}, - ]} - out = format_artifacts(data, requested=["org/repo::missing.py::G"]) - assert "not found or inaccessible" in out - assert "org/repo::missing.py::G" in out - - -def test_found_but_empty_content_is_rendered_not_missing(): - data = {"artifacts": [ - {"identifier": "org/repo::a.py::F", "found": True, "content": ""}, - ]} - out = format_artifacts(data, requested=["org/repo::a.py::F"]) - assert "📄 org/repo::a.py::F" in out - assert "not found or inaccessible" not in out - - -def test_all_found_has_no_not_found_section(): - data = {"artifacts": [ - {"identifier": "org/repo::a.py::F", "found": True, "content": "x"}, - ]} - out = format_artifacts(data, requested=["org/repo::a.py::F"]) - assert "not found or inaccessible" not in out - - -def test_backstop_surfaces_id_backend_never_echoed(): - data = {"artifacts": [ - {"identifier": "org/repo::a.py::F", "found": True, "content": "x"}, - ]} - out = format_artifacts(data, requested=["org/repo::a.py::F", "org/repo::ghost.py::Z"]) - assert "org/repo::ghost.py::Z" in out - assert "1 requested identifier(s) not found" in out diff --git a/tests/test_setup_and_client.py b/tests/test_setup_and_client.py deleted file mode 100644 index fbc507b..0000000 --- a/tests/test_setup_and_client.py +++ /dev/null @@ -1,515 +0,0 @@ -"""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, format_codealive_error # 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_get_datasources_with_query_sends_param_and_reports_omitted_count(): - def datasources_handler(_request): - return 200, [ - { - "id": "repo-1", - "name": "backend", - "type": "Repository", - "relevanceReason": "Implements the checkout flow", - } - ], {"x-codealive-total-data-sources": "3"} # lowercase: proxies may normalize casing - - with mock_codealive_server( - {("GET", "/api/datasources/ready?query=add+OAuth"): datasources_handler} - ) as (base_url, requests): - client = CodeAliveClient(api_key="skill-test-key", base_url=base_url) - result = client.get_datasources(query="add OAuth") - - assert requests[0]["path"] == "/api/datasources/ready?query=add+OAuth" - assert result["dataSources"][0]["relevanceReason"] == "Implements the checkout flow" - assert "1 of 3 available data sources are relevant" in result["message"] - assert "the other 2 were omitted" in result["message"] - - -def test_get_datasources_query_fail_open_warns_full_list_returned(): - # No item carries relevanceReason and no total header: the backend filter did not - # run (fail-open / disabled / older backend) and returned the full list. - with mock_codealive_server( - { - ("GET", "/api/datasources/ready?query=add+OAuth"): ( - 200, - [ - {"id": "repo-1", "name": "backend", "type": "Repository"}, - {"id": "repo-2", "name": "frontend", "type": "Repository"}, - ], - ) - } - ) as (base_url, _requests): - client = CodeAliveClient(api_key="skill-test-key", base_url=base_url) - result = client.get_datasources(query="add OAuth") - - assert len(result["dataSources"]) == 2 - assert "FULL unfiltered list" in result["message"] - - -def test_get_datasources_query_confident_empty_reports_nothing_relevant(): - # Empty list + total header: the filter ran and confidently matched nothing. - # Must NOT be mistaken for fail-open (fail-open returns the full, non-empty list). - def datasources_handler(_request): - return 200, [], {"X-CodeAlive-Total-Data-Sources": "3"} - - with mock_codealive_server( - {("GET", "/api/datasources/ready?query=add+OAuth"): datasources_handler} - ) as (base_url, _requests): - client = CodeAliveClient(api_key="skill-test-key", base_url=base_url) - result = client.get_datasources(query="add OAuth") - - assert result["dataSources"] == [] - assert "None of the 3 available data sources are relevant" in result["message"] - assert "List without a query" in result["message"] - - -def test_get_datasources_query_empty_org_reports_no_sources(): - # Empty list and total header reports zero candidates: the org simply has no - # data sources — not a relevance verdict, not a filter failure. - def datasources_handler(_request): - return 200, [], {"X-CodeAlive-Total-Data-Sources": "0"} - - with mock_codealive_server( - {("GET", "/api/datasources/ready?query=add+OAuth"): datasources_handler} - ) as (base_url, _requests): - client = CodeAliveClient(api_key="skill-test-key", base_url=base_url) - result = client.get_datasources(query="add OAuth") - - assert result["dataSources"] == [] - assert result["message"] == "No data sources are available." - - -def test_get_datasources_blank_query_behaves_like_no_query(): - 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=base_url) - result = client.get_datasources(query=" ") - - assert result == [{"id": "repo-1", "name": "backend", "type": "Repository"}] - assert requests[0]["path"] == "/api/datasources/ready" - - -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"] - assert request["headers"]["Accept"] == "application/json, application/problem+json" - 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" - - -def test_api_client_canonical_search_endpoints_use_scope_params(): - def semantic_handler(request): - assert "Query=auth" in request["path"] - assert "Names=backend" in request["path"] - assert "Paths=src%2Fauth.py" in request["path"] - assert "Extensions=.py" in request["path"] - assert "MaxResults=7" in request["path"] - return 200, { - "results": [ - { - "identifier": "org/repo::src/auth.py::AuthService", - "location": {"path": "src/auth.py"}, - } - ] - }, {} - - def grep_handler(request): - assert "Query=AuthService" in request["path"] - assert "Regex=true" in request["path"] - return 200, { - "results": [ - { - "identifier": "org/repo::src/auth.py", - "matchCount": 1, - "matches": [{"lineNumber": 12, "lineText": "class AuthService:"}], - } - ] - }, {} - - with mock_codealive_server( - { - ("GET", "/api/search/semantic?Query=auth&Names=backend&Paths=src%2Fauth.py&Extensions=.py&MaxResults=7"): semantic_handler, - ("GET", "/api/search/grep?Query=AuthService&Names=backend&Regex=true"): grep_handler, - } - ) as (base_url, _requests): - client = CodeAliveClient(api_key="skill-test-key", base_url=base_url) - semantic_result = client.semantic_search( - "auth", - ["backend"], - paths=["src/auth.py"], - extensions=[".py"], - max_results=7, - ) - grep_result = client.grep_search("AuthService", ["backend"], regex=True) - - assert semantic_result["results"][0]["identifier"] == "org/repo::src/auth.py::AuthService" - assert grep_result["results"][0]["matchCount"] == 1 - - -def test_api_client_get_artifact_relationships_posts_expected_body(): - received_bodies: list = [] - - def relationships_handler(request): - body = json.loads(request["body"]) - received_bodies.append(body) - return 200, { - "sourceIdentifier": body["identifier"], - "profile": body["profile"], - "found": True, - "relationships": [ - { - "relationType": "OutgoingCalls", - "totalCount": 2, - "returnedCount": 2, - "truncated": False, - "items": [ - { - "identifier": "org/repo::src/db.py::query", - "filePath": "src/db.py", - "startLine": 42, - "shortSummary": "Runs SQL", - }, - { - "identifier": "org/repo::src/cache.py::get", - "filePath": "src/cache.py", - "startLine": 10, - }, - ], - } - ], - }, {} - - with mock_codealive_server( - {("POST", "/api/search/artifact-relationships"): relationships_handler} - ) as (base_url, _requests): - client = CodeAliveClient(api_key="skill-test-key", base_url=base_url) - result = client.get_artifact_relationships( - "org/repo::src/svc.py::Service", - profile="allRelevant", - max_count_per_type=25, - ) - - # Body translates MCP-friendly profile to backend enum + carries max-count cap - assert received_bodies == [ - { - "identifier": "org/repo::src/svc.py::Service", - "profile": "AllRelevant", - "maxCountPerType": 25, - } - ] - assert result["found"] is True - assert result["relationships"][0]["relationType"] == "OutgoingCalls" - assert len(result["relationships"][0]["items"]) == 2 - - -def test_api_client_get_artifact_relationships_rejects_unknown_profile(): - client = CodeAliveClient(api_key="skill-test-key", base_url="https://test.local") - try: - client.get_artifact_relationships("id", profile="bogus") - except ValueError as e: - assert "Unsupported profile" in str(e) - else: - raise AssertionError("ValueError was not raised for unknown profile") - - -def test_api_client_fetch_artifacts_forwards_data_source(): - received_bodies: list = [] - - def fetch_handler(request): - body = json.loads(request["body"]) - received_bodies.append(body) - return 200, {"artifacts": []}, {} - - with mock_codealive_server( - {("POST", "/api/search/artifacts"): fetch_handler} - ) as (base_url, _requests): - client = CodeAliveClient(api_key="skill-test-key", base_url=base_url) - # Omitted by default… - client.fetch_artifacts(["org/repo::src/a.py::F"]) - # …forwarded as DataSource when provided. - client.fetch_artifacts(["org/repo::src/a.py::F"], data_source="backend") - - assert "dataSource" not in received_bodies[0] - assert received_bodies[1]["dataSource"] == "backend" - - -def test_api_client_get_artifact_relationships_forwards_data_source(): - received_bodies: list = [] - - def relationships_handler(request): - body = json.loads(request["body"]) - received_bodies.append(body) - return 200, {"sourceIdentifier": body["identifier"], "profile": body["profile"], "found": True, "relationships": []}, {} - - with mock_codealive_server( - {("POST", "/api/search/artifact-relationships"): relationships_handler} - ) as (base_url, _requests): - client = CodeAliveClient(api_key="skill-test-key", base_url=base_url) - client.get_artifact_relationships("org/repo::src/a.py::F") - client.get_artifact_relationships("org/repo::src/a.py::F", data_source="ds-main") - - assert "dataSource" not in received_bodies[0] - assert received_bodies[1]["dataSource"] == "ds-main" - - -def test_api_client_ambiguous_409_surfaces_candidate_data_sources(): - # When an identifier is ambiguous and no data_source is supplied, the backend returns a 409 - # whose detail lists the candidate data sources. The client must surface those candidates so - # the agent can retry with --data-source rather than inventing a result. - def fetch_handler(request): - return 409, { - "title": "Ambiguous data source", - "detail": "Identifier matches 2 data sources: Name='backend' Id='ds-main', Name='backend-legacy' Id='ds-master'", - }, {} - - with mock_codealive_server( - {("POST", "/api/search/artifacts"): fetch_handler} - ) as (base_url, _requests): - client = CodeAliveClient(api_key="skill-test-key", base_url=base_url) - try: - client.fetch_artifacts(["org/repo::src/a.py::F"]) - except Exception as e: - message = str(e) - assert "409" in message - assert "backend" in message and "backend-legacy" in message - else: - raise AssertionError("Expected an exception for the ambiguous 409 response") - - -# ===== Phase 1 — error contract & ObjectId preflight ===== - -def test_format_codealive_error_renders_rfc9457_problem_details(): - body = json.dumps({ - "type": "https://app.codealive.ai/errors/validation", - "title": "Validation failed", - "status": 400, - "detail": "conversationId: must be a 24-character hex Mongo ObjectId", - "instance": "POST /api/chat/completions", - "errors": { - "conversationId": ["must be a 24-character hex Mongo ObjectId"], - }, - "requestId": "0HNLBU64JB822:00000001", - }).encode("utf-8") - - rendered = format_codealive_error(400, body) - - assert "Validation failed" in rendered - assert "conversationId: must be a 24-character hex Mongo ObjectId" in rendered - assert "Details: conversationId: must be a 24-character hex Mongo ObjectId" in rendered - assert "requestId=0HNLBU64JB822:00000001" in rendered - assert "type=https://app.codealive.ai/errors/validation" in rendered - - -def test_format_codealive_error_renders_legacy_validation_errors_alias(): - # Pre-Phase-2 servers still in flight: only {message, validationErrors[]}. - body = json.dumps({ - "message": "Validation failed", - "validationErrors": ["Invalid conversation ID format"], - "requestId": "0HNLBU64JB822:00000001", - }).encode("utf-8") - - rendered = format_codealive_error(400, body) - - assert "Validation failed" in rendered - assert "Details: Invalid conversation ID format" in rendered - assert "requestId=0HNLBU64JB822:00000001" in rendered - - -def test_format_codealive_error_handles_non_json_body_and_str_input(): - assert format_codealive_error(502, b"Bad Gateway") == "HTTP 502: Bad Gateway" - assert format_codealive_error(503, "") == "HTTP 503" - # str input must also be tolerated (e.g. when callers re-decode bytes) - assert format_codealive_error(503, "plain text body") == "HTTP 503: plain text body" - - -def test_make_request_400_uses_helper_and_surfaces_field_errors(): - def bad_handler(_request): - return 400, { - "type": "https://app.codealive.ai/errors/validation", - "title": "Validation failed", - "status": 400, - "detail": "conversationId: must be a 24-character hex Mongo ObjectId", - "errors": { - "conversationId": ["must be a 24-character hex Mongo ObjectId"], - }, - "requestId": "abc123", - }, {} - - with mock_codealive_server( - {("POST", "/api/chat/completions"): bad_handler} - ) as (base_url, _requests): - client = CodeAliveClient(api_key="skill-test-key", base_url=base_url) - try: - client.chat("hi", data_sources=["backend"]) - except Exception as e: - msg = str(e) - assert "Bad request (400)" in msg - assert "conversationId: must be a 24-character hex Mongo ObjectId" in msg - assert "requestId=abc123" in msg - else: - raise AssertionError("Expected client.chat to raise on 400") - - -def test_chat_preflight_rejects_non_objectid_conversation_id_without_request(): - # Tracks the exact GUID from the §2 incident reproduction. - guid = "c8d2c10f-ce4b-43f7-ae24-072c60aacc1e" - client = CodeAliveClient(api_key="skill-test-key", base_url="https://test.local") - try: - client.chat("hi", conversation_id=guid) - except ValueError as e: - msg = str(e) - assert "24-character hex Mongo ObjectId" in msg - assert guid in msg - else: - raise AssertionError("Expected ValueError for GUID conversation_id") - - -def test_chat_accepts_phase3_response_shape_with_conversationid_and_messageid(): - def chat_handler(_request): - return 200, { - "content": "Auth is in AuthService.", - "conversationId": "69fceb3e7b2a6a7efdd18180", - "messageId": "69fceb3e7b2a6a7efdd18181", - }, {} - - with mock_codealive_server( - {("POST", "/api/chat/completions"): chat_handler} - ) as (base_url, _requests): - client = CodeAliveClient(api_key="skill-test-key", base_url=base_url) - result = client.chat("How does auth work?", data_sources=["backend"]) - - assert result["answer"] == "Auth is in AuthService." - assert result["conversation_id"] == "69fceb3e7b2a6a7efdd18180" - assert result["message_id"] == "69fceb3e7b2a6a7efdd18181" - - -def test_chat_falls_back_to_legacy_id_envelope_when_phase3_fields_absent(): - # Pre-Phase-3 server: OpenAI-shaped envelope with id+choices only. - def chat_handler(_request): - return 200, { - "id": "conv_legacy", - "choices": [{"message": {"content": "legacy answer"}}], - }, {} - - with mock_codealive_server( - {("POST", "/api/chat/completions"): chat_handler} - ) as (base_url, _requests): - client = CodeAliveClient(api_key="skill-test-key", base_url=base_url) - result = client.chat("hi", data_sources=["backend"]) - - assert result["answer"] == "legacy answer" - assert result["conversation_id"] == "conv_legacy" - assert result["message_id"] is None # not present in legacy envelope diff --git a/tests/test_tool_api_v3.py b/tests/test_tool_api_v3.py new file mode 100644 index 0000000..740479b --- /dev/null +++ b/tests/test_tool_api_v3.py @@ -0,0 +1,246 @@ +"""Tool API v3 tests for the CodeAlive skills package.""" + +from __future__ import annotations + +import importlib.util +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" +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_v3") +sys.path.insert(0, str(LIB_ROOT)) +from api_client import CodeAliveClient, format_codealive_error # noqa: E402 + + +def _tool_response(name: str = "ok"): + return {"rendered": f"<{name}>rendered", "obj": {"name": name}} + + +def _header(headers: dict[str, str], name: str) -> str | None: + return next((value for key, value in headers.items() if key.lower() == name.lower()), None) + + +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_tool_api_v3_and_normalizes_base_url(): + with mock_codealive_server( + { + ("POST", "/api/tools/get_data_sources"): ( + 200, + {"obj": {"data_sources": [{"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 requests[0]["method"] == "POST" + assert requests[0]["path"] == "/api/tools/get_data_sources" + assert requests[0]["headers"]["Authorization"] == "Bearer skill-test-key" + assert _header(requests[0]["headers"], "X-CodeAlive-Integration") == "skills" + assert _header(requests[0]["headers"], "X-CodeAlive-Tool") == "get_data_sources" + assert json.loads(requests[0]["body"]) == {"ready_only": True, "output_format": "json"} + + +def test_client_posts_canonical_payload_and_returns_rendered_by_default(): + def semantic_handler(request): + payload = json.loads(request["body"]) + assert payload == { + "question": "How does auth work?", + "data_sources": ["backend"], + "paths": ["src"], + "extensions": [".py"], + "max_results": 7, + "exclude_markdown": True, + "output_format": "agentic", + } + assert _header(request["headers"], "X-CodeAlive-Integration") == "skills" + assert _header(request["headers"], "X-CodeAlive-Tool") == "semantic_search" + assert _header(request["headers"], "X-CodeAlive-Client") == "skills-v3" + return 200, _tool_response("semantic"), {} + + with mock_codealive_server({("POST", "/api/tools/semantic_search"): semantic_handler}) as (base_url, requests): + client = CodeAliveClient(api_key="skill-test-key", base_url=base_url) + result = client.semantic_search( + "How does auth work?", + ["backend"], + paths=["src"], + extensions=[".py"], + max_results=7, + exclude_markdown=True, + ) + + assert result == "rendered" + assert len(requests) == 1 + + +def test_client_json_mode_returns_obj(): + with mock_codealive_server( + {("POST", "/api/tools/get_artifact_query_schema"): (200, _tool_response("schema"))} + ) as (base_url, _requests): + client = CodeAliveClient(api_key="skill-test-key", base_url=base_url) + result = client.get_artifact_query_schema(output_format="json") + + assert result == {"name": "schema"} + + +def test_client_preserves_both_repairable_error_projections(): + error_obj = { + "error": { + "code": "invalid_tool_arguments", + "message": "question is required", + "retry": "yes - repair the tool arguments and call the tool again", + "try": "Provide question and retry.", + } + } + envelope = { + "obj": error_obj, + "rendered": "invalid_tool_arguments", + } + routes = {("POST", "/api/tools/semantic_search"): (200, envelope)} + + with mock_codealive_server(routes) as (base_url, _requests): + client = CodeAliveClient(api_key="skill-test-key", base_url=base_url) + agentic = client.semantic_search("valid locally", ["backend"], output_format="agentic") + structured = client.semantic_search("valid locally", ["backend"], output_format="json") + + assert agentic == envelope["rendered"] + assert structured == error_obj + + +def test_client_methods_cover_all_v3_tools(): + seen = [] + + def handler(request): + seen.append((request["path"], json.loads(request["body"]), _header(request["headers"], "X-CodeAlive-Tool"))) + tool_name = request["path"].rsplit("/", 1)[1] + return 200, _tool_response(tool_name), {} + + routes = { + ("POST", f"/api/tools/{tool}"): handler + for tool in [ + "get_data_sources", + "semantic_search", + "grep_search", + "get_repository_ontology", + "get_file_tree", + "read_file", + "fetch_artifacts", + "get_artifact_relationships", + "get_artifact_query_schema", + "query_artifact_metadata", + "chat", + ] + } + with mock_codealive_server(routes) as (base_url, _requests): + client = CodeAliveClient(api_key="skill-test-key", base_url=base_url) + assert client.get_datasources(query="auth") + assert client.semantic_search("How auth works?", ["backend"]) + assert client.grep_search("AuthService", ["backend"], regex=True) + assert client.get_repository_ontology("backend") + assert client.get_file_tree("backend", path="src", max_depth=2) + assert client.read_file("README.md", data_source="backend", start_line=1, end_line=5) + assert client.fetch_artifacts(["repo::README.md"], data_source="backend") + assert client.get_artifact_relationships("repo::Foo", profile="allRelevant", data_source="backend") + assert client.get_artifact_query_schema(entity="files") + assert client.query_artifact_metadata("SELECT path FROM files LIMIT 5", ["backend"]) + assert client.chat("Summarize auth. Prior context: none.", ["backend"]) + + assert [item[2] for item in seen] == [ + "get_data_sources", + "semantic_search", + "grep_search", + "get_repository_ontology", + "get_file_tree", + "read_file", + "fetch_artifacts", + "get_artifact_relationships", + "get_artifact_query_schema", + "query_artifact_metadata", + "chat", + ] + assert all(item[1]["output_format"] == "agentic" for item in seen) + assert seen[7][1]["max_count_per_type"] == 50 + assert "conversation_id" not in seen[-1][1] + + +def test_cli_scripts_call_tool_api_v3(): + def handler(request): + tool_name = request["path"].rsplit("/", 1)[1] + return 200, _tool_response(tool_name), {} + + tools = [ + ("datasources.py", [], "get_data_sources"), + ("search.py", ["How auth works?", "backend"], "semantic_search"), + ("grep.py", ["AuthService", "backend"], "grep_search"), + ("ontology.py", ["backend"], "get_repository_ontology"), + ("tree.py", ["--data-source", "backend", "--max-depth", "2"], "get_file_tree"), + ("read_file.py", ["README.md", "--data-source", "backend"], "read_file"), + ("fetch.py", ["repo::README.md", "--data-source", "backend"], "fetch_artifacts"), + ("relationships.py", ["repo::Foo", "--profile", "callsOnly"], "get_artifact_relationships"), + ("schema.py", [], "get_artifact_query_schema"), + ("metadata.py", ["SELECT path FROM files LIMIT 5", "backend"], "query_artifact_metadata"), + ("chat.py", ["Summarize auth", "backend"], "chat"), + ] + routes = {("POST", f"/api/tools/{tool_name}"): handler for _, _, tool_name in tools} + + with mock_codealive_server(routes) as (base_url, requests): + env = { + **os.environ, + "CODEALIVE_API_KEY": "skill-test-key", + "CODEALIVE_BASE_URL": base_url, + } + for script, args, tool_name in tools: + result = subprocess.run( + [sys.executable, str(SKILL_ROOT / "scripts" / script), *args], + env=env, + text=True, + capture_output=True, + timeout=15, + ) + assert result.returncode == 0, result.stderr + assert f"<{tool_name}>rendered" in result.stdout + + assert [_header(request["headers"], "X-CodeAlive-Tool") for request in requests] == [tool for _, _, tool in tools] + assert all(json.loads(request["body"])["output_format"] == "agentic" for request in requests) + + +def test_format_codealive_error_reads_problem_details(): + body = json.dumps( + { + "title": "Bad request", + "detail": "Missing question", + "errors": {"question": ["required"]}, + "requestId": "req-1", + } + ) + + message = format_codealive_error(400, body) + + assert "Bad request" in message + assert "Missing question" in message + assert "question: required" in message + assert "requestId=req-1" in message