From b1c9dd4f0d12d7ff1ceafb3cc74c25ec5d1fe5c8 Mon Sep 17 00:00:00 2001 From: Teo Gonzalez Collazo Date: Wed, 1 Apr 2026 10:58:36 -0700 Subject: [PATCH] feat: add Exa CLI harness for AI-powered web search and answers - Web search with neural/fast/instant/deep/deep-reasoning modes - Find similar pages by URL - Fetch full-text or highlighted page contents - LLM-synthesised answers with cited sources - Category filters, domain filters, date filters, geo-bias - --json flag for agent-native output on all commands - Interactive REPL mode - 40 unit tests passing (no API calls required) - E2E test suite (requires EXA_API_KEY) - registry.json entry added --- .gitignore | 1 + exa/agent-harness/EXA.md | 107 ++++ exa/agent-harness/cli_anything/exa/README.md | 86 +++ .../cli_anything/exa/__init__.py | 1 + .../cli_anything/exa/__main__.py | 3 + .../cli_anything/exa/core/__init__.py | 1 + .../cli_anything/exa/core/answer.py | 48 ++ .../cli_anything/exa/core/search.py | 135 +++++ .../cli_anything/exa/core/session.py | 55 ++ exa/agent-harness/cli_anything/exa/exa_cli.py | 413 ++++++++++++++ .../cli_anything/exa/skills/SKILL.md | 128 +++++ .../cli_anything/exa/tests/TEST.md | 118 ++++ .../cli_anything/exa/tests/__init__.py | 1 + .../cli_anything/exa/tests/test_core.py | 396 +++++++++++++ .../cli_anything/exa/tests/test_full_e2e.py | 266 +++++++++ .../cli_anything/exa/utils/__init__.py | 1 + .../cli_anything/exa/utils/exa_backend.py | 105 ++++ .../cli_anything/exa/utils/repl_skin.py | 521 ++++++++++++++++++ exa/agent-harness/setup.py | 57 ++ registry.json | 14 + 20 files changed, 2457 insertions(+) create mode 100644 exa/agent-harness/EXA.md create mode 100644 exa/agent-harness/cli_anything/exa/README.md create mode 100644 exa/agent-harness/cli_anything/exa/__init__.py create mode 100644 exa/agent-harness/cli_anything/exa/__main__.py create mode 100644 exa/agent-harness/cli_anything/exa/core/__init__.py create mode 100644 exa/agent-harness/cli_anything/exa/core/answer.py create mode 100644 exa/agent-harness/cli_anything/exa/core/search.py create mode 100644 exa/agent-harness/cli_anything/exa/core/session.py create mode 100644 exa/agent-harness/cli_anything/exa/exa_cli.py create mode 100644 exa/agent-harness/cli_anything/exa/skills/SKILL.md create mode 100644 exa/agent-harness/cli_anything/exa/tests/TEST.md create mode 100644 exa/agent-harness/cli_anything/exa/tests/__init__.py create mode 100644 exa/agent-harness/cli_anything/exa/tests/test_core.py create mode 100644 exa/agent-harness/cli_anything/exa/tests/test_full_e2e.py create mode 100644 exa/agent-harness/cli_anything/exa/utils/__init__.py create mode 100644 exa/agent-harness/cli_anything/exa/utils/exa_backend.py create mode 100644 exa/agent-harness/cli_anything/exa/utils/repl_skin.py create mode 100644 exa/agent-harness/setup.py diff --git a/.gitignore b/.gitignore index 3119a2176..6f46ad437 100644 --- a/.gitignore +++ b/.gitignore @@ -207,3 +207,4 @@ assets/gen_typing_gif.py !/notebooklm/agent-harness/ !/intelwatch/agent-harness/ !/intelwatch/ +!/exa/ diff --git a/exa/agent-harness/EXA.md b/exa/agent-harness/EXA.md new file mode 100644 index 000000000..d6e44dd1c --- /dev/null +++ b/exa/agent-harness/EXA.md @@ -0,0 +1,107 @@ +# EXA — Architecture & Design + +## Overview + +`cli-anything-exa` is a CLI harness for the [Exa API](https://exa.ai), an AI-native search +engine built on neural embeddings rather than keyword matching. This harness makes all +core Exa capabilities available to any AI coding agent (Claude Code, Codex, OpenCode, etc.) +via a structured, stateful command-line interface. + +## Backend + +Exa exposes a REST API wrapped by the official `exa-py` Python SDK. +Authentication uses `EXA_API_KEY` from the environment — no server process is required. + +## Command Hierarchy + +``` +cli-anything-exa [--json] +├── search Neural/keyword/deep web search +├── similar Find pages related to a URL +├── contents [url …] Fetch full-text or highlighted page content +├── answer LLM-synthesised answer with citations +├── server status Verify API key and connectivity +└── session + ├── status Session summary + └── history Per-query history (most recent first) +``` + +## State Model + +Session state (search history) is held in memory in `core/session.py`. It is scoped to +the process lifetime and is intended for REPL use — agents in non-interactive mode +receive stateless JSON responses on each invocation. + +## Output Strategy + +All commands emit human-readable output by default and structured JSON when `--json` is +passed at the root level. JSON output is the recommended mode for agent pipelines. + +JSON result shape for search/similar/contents: +```json +{ + "results": [ + { + "title": "...", + "url": "...", + "published_date": "...", + "author": "...", + "highlights": ["..."], // when --content highlights + "text": "...", // when --content text + "summary": "..." // when --content summary + } + ], + "cost_dollars": {"total": 0.005} +} +``` + +JSON result shape for answer: +```json +{ + "answer": "...", + "citations": [{"title": "...", "url": "..."}], + "cost_dollars": {"total": 0.003} +} +``` + +## Design Decisions + +**`highlights` as default content mode** — Exa highlights are 10× more token-efficient +than full text and are sufficient for most agent retrieval tasks. Full text is available +via `--content text` when needed. + +**No streaming in v1** — The `answer` endpoint supports SSE streaming but CLI streaming +complicates JSON parsing for agents. Deferred to a future iteration. + +**Research commands deferred** — Exa's async deep researcher (`/research/v1`) has a +start→poll→get lifecycle that warrants a separate v2 PR with proper state persistence. + +**Category slugs use hyphens** — CLI uses `research-paper`, `personal-site`, +`financial-report` (hyphenated) for shell-friendliness; the backend maps these to the +API's space-separated values. + +## File Layout + +``` +exa/agent-harness/ +├── setup.py +├── EXA.md (this file) +└── cli_anything/exa/ + ├── __init__.py + ├── __main__.py + ├── exa_cli.py Entry point, Click command tree, REPL + ├── README.md Setup and usage guide + ├── core/ + │ ├── search.py web_search(), find_similar(), get_contents() + │ ├── answer.py get_answer() + │ └── session.py In-session history and status + ├── utils/ + │ ├── exa_backend.py SDK client init, contents/category helpers + │ └── repl_skin.py Shared REPL terminal UI + ├── skills/ + │ └── SKILL.md Agent-discoverable skill definition + └── tests/ + ├── TEST.md + ├── test_core.py Unit tests (no API calls) + └── test_full_e2e.py E2E tests (real API, requires EXA_API_KEY) +``` diff --git a/exa/agent-harness/cli_anything/exa/README.md b/exa/agent-harness/cli_anything/exa/README.md new file mode 100644 index 000000000..41763cbbf --- /dev/null +++ b/exa/agent-harness/cli_anything/exa/README.md @@ -0,0 +1,86 @@ +# cli-anything-exa + +Agent-native CLI harness for [Exa](https://exa.ai) — AI-powered web search, similar-page discovery, full-text content extraction, and LLM-synthesised answers. + +## HOW TO RUN + +### 1. Prerequisites + +- Python 3.10+ +- An Exa API key — get one free at [dashboard.exa.ai/api-keys](https://dashboard.exa.ai/api-keys) + +### 2. Install + +```bash +pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=exa/agent-harness +``` + +Or from source (development): + +```bash +cd exa/agent-harness +pip install -e . +``` + +### 3. Configure + +```bash +export EXA_API_KEY="your-api-key-here" +``` + +### 4. Verify + +```bash +cli-anything-exa server status +``` + +Expected output: +``` +[OK] API key valid — Exa reachable +``` + +### 5. Use + +**Web search:** +```bash +cli-anything-exa search "large language models 2024" --type deep --content highlights +``` + +**Find similar pages:** +```bash +cli-anything-exa similar https://arxiv.org/abs/2303.08774 --num-results 5 +``` + +**Fetch page contents:** +```bash +cli-anything-exa contents https://exa.ai --content text +``` + +**LLM answer with citations:** +```bash +cli-anything-exa answer "What makes Exa different from Google?" +``` + +**JSON output (for agents):** +```bash +cli-anything-exa --json search "AI safety papers" --num-results 3 +``` + +**Interactive REPL:** +```bash +cli-anything-exa +``` + +## Running Tests + +**Unit tests** (no API key required): +```bash +cd exa/agent-harness +pip install -e ".[dev]" +pytest cli_anything/exa/tests/test_core.py -v +``` + +**End-to-end tests** (requires `EXA_API_KEY`): +```bash +pytest cli_anything/exa/tests/test_full_e2e.py -v +``` diff --git a/exa/agent-harness/cli_anything/exa/__init__.py b/exa/agent-harness/cli_anything/exa/__init__.py new file mode 100644 index 000000000..9e01acd84 --- /dev/null +++ b/exa/agent-harness/cli_anything/exa/__init__.py @@ -0,0 +1 @@ +# cli_anything.exa namespace package diff --git a/exa/agent-harness/cli_anything/exa/__main__.py b/exa/agent-harness/cli_anything/exa/__main__.py new file mode 100644 index 000000000..e4f8723b9 --- /dev/null +++ b/exa/agent-harness/cli_anything/exa/__main__.py @@ -0,0 +1,3 @@ +"""Allow running as python -m cli_anything.exa""" +from cli_anything.exa.exa_cli import main +main() diff --git a/exa/agent-harness/cli_anything/exa/core/__init__.py b/exa/agent-harness/cli_anything/exa/core/__init__.py new file mode 100644 index 000000000..062dc5a63 --- /dev/null +++ b/exa/agent-harness/cli_anything/exa/core/__init__.py @@ -0,0 +1 @@ +# cli_anything.exa.core diff --git a/exa/agent-harness/cli_anything/exa/core/answer.py b/exa/agent-harness/cli_anything/exa/core/answer.py new file mode 100644 index 000000000..0801c5867 --- /dev/null +++ b/exa/agent-harness/cli_anything/exa/core/answer.py @@ -0,0 +1,48 @@ +""" +core/answer.py — LLM-synthesised answers with citations via Exa. +""" + +from __future__ import annotations + +from typing import Any + +from cli_anything.exa.utils.exa_backend import get_client + + +def get_answer(query: str) -> dict[str, Any]: + """Ask Exa a question and receive an LLM-synthesised answer with citations. + + Args: + query: Natural-language question. + + Returns: + Dict with keys: answer (str), citations (list[dict]). + """ + client = get_client() + response = client.answer(query, text=False) + return _answer_to_dict(response) + + +def _answer_to_dict(response: Any) -> dict[str, Any]: + """Convert an exa-py AnswerResponse to a plain dict.""" + out: dict[str, Any] = {} + + answer_text = getattr(response, "answer", None) + if answer_text is not None: + out["answer"] = answer_text + + citations = [] + for r in getattr(response, "results", []) or []: + cite: dict[str, Any] = {} + for attr in ("title", "url", "published_date", "author"): + val = getattr(r, attr, None) + if val is not None: + cite[attr] = val + citations.append(cite) + out["citations"] = citations + + cost = getattr(response, "cost_dollars", None) + if cost is not None: + out["cost_dollars"] = cost + + return out diff --git a/exa/agent-harness/cli_anything/exa/core/search.py b/exa/agent-harness/cli_anything/exa/core/search.py new file mode 100644 index 000000000..7390bc072 --- /dev/null +++ b/exa/agent-harness/cli_anything/exa/core/search.py @@ -0,0 +1,135 @@ +""" +core/search.py — Web search, find-similar, and get-contents operations. +""" + +from __future__ import annotations + +from typing import Any + +from cli_anything.exa.utils.exa_backend import ( + build_contents_param, + get_client, + CATEGORY_SLUG_MAP, +) + + +def web_search( + query: str, + *, + num_results: int = 10, + search_type: str = "auto", + category: str | None = None, + include_domains: tuple[str, ...] = (), + exclude_domains: tuple[str, ...] = (), + start_date: str | None = None, + end_date: str | None = None, + location: str | None = None, + content_mode: str = "highlights", + freshness: str = "smart", +) -> dict[str, Any]: + """Execute a web search via the Exa API. + + Returns the raw SearchResponse as a dict. + """ + client = get_client() + + api_category = CATEGORY_SLUG_MAP.get(category) if category else None + contents = build_contents_param(content_mode, freshness) + + kwargs: dict[str, Any] = { + "num_results": num_results, + "type": search_type, + } + if api_category: + kwargs["category"] = api_category + if include_domains: + kwargs["include_domains"] = list(include_domains) + if exclude_domains: + kwargs["exclude_domains"] = list(exclude_domains) + if start_date: + kwargs["start_published_date"] = start_date + if end_date: + kwargs["end_published_date"] = end_date + if location: + kwargs["user_location"] = location + if contents: + kwargs["contents"] = contents + + response = client.search(query, **kwargs) + return _response_to_dict(response) + + +def find_similar( + url: str, + *, + num_results: int = 10, + content_mode: str = "highlights", +) -> dict[str, Any]: + """Find pages similar to the given URL.""" + client = get_client() + contents = build_contents_param(content_mode, "smart") + + kwargs: dict[str, Any] = {"num_results": num_results} + if contents: + kwargs["contents"] = contents + + response = client.find_similar(url, **kwargs) + return _response_to_dict(response) + + +def get_contents( + urls: list[str], + *, + content_mode: str = "text", + freshness: str = "smart", +) -> dict[str, Any]: + """Fetch full page contents for one or more URLs.""" + client = get_client() + + kwargs: dict[str, Any] = {} + if content_mode == "text": + kwargs["text"] = {"max_characters": 10_000} + elif content_mode == "highlights": + kwargs["highlights"] = {"max_characters": 4_000} + elif content_mode == "summary": + kwargs["summary"] = True + + if freshness == "always": + kwargs["max_age_hours"] = 0 + elif freshness == "never": + kwargs["max_age_hours"] = -1 + + response = client.get_contents(urls, **kwargs) + return _response_to_dict(response) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _response_to_dict(response: Any) -> dict[str, Any]: + """Convert an exa-py response object to a plain dict.""" + if hasattr(response, "__dict__"): + raw = response.__dict__ + else: + return {"results": []} + + results = [] + for r in raw.get("results", []): + item: dict[str, Any] = {} + for attr in ( + "title", "url", "id", "published_date", "author", + "text", "highlights", "highlight_scores", "summary", + ): + val = getattr(r, attr, None) + if val is not None: + item[attr] = val + results.append(item) + + out: dict[str, Any] = {"results": results} + + cost = getattr(response, "cost_dollars", None) + if cost is not None: + out["cost_dollars"] = cost + + return out diff --git a/exa/agent-harness/cli_anything/exa/core/session.py b/exa/agent-harness/cli_anything/exa/core/session.py new file mode 100644 index 000000000..a53f8d672 --- /dev/null +++ b/exa/agent-harness/cli_anything/exa/core/session.py @@ -0,0 +1,55 @@ +""" +core/session.py — In-session state for the interactive REPL. + +Tracks search history and current context so the REPL banner and +`session history` command have something to display. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + + +@dataclass +class _SearchEntry: + query: str + command: str # "search" | "similar" | "contents" | "answer" + result_count: int + timestamp: str = field(default_factory=lambda: datetime.now().strftime("%H:%M:%S")) + + +_history: list[_SearchEntry] = [] + + +def record(query: str, command: str, result_count: int) -> None: + """Add an entry to the in-session history.""" + _history.append(_SearchEntry(query=query, command=command, result_count=result_count)) + + +def get_history() -> list[dict[str, Any]]: + """Return history as a list of plain dicts (most recent first).""" + return [ + { + "time": e.timestamp, + "command": e.command, + "query": e.query, + "results": e.result_count, + } + for e in reversed(_history) + ] + + +def get_status() -> dict[str, Any]: + """Return a summary of the current session.""" + return { + "total_queries": len(_history), + "commands_used": sorted({e.command for e in _history}) or [], + "last_query": _history[-1].query if _history else None, + } + + +def clear() -> None: + """Clear all history (used in tests).""" + _history.clear() diff --git a/exa/agent-harness/cli_anything/exa/exa_cli.py b/exa/agent-harness/cli_anything/exa/exa_cli.py new file mode 100644 index 000000000..4256997cf --- /dev/null +++ b/exa/agent-harness/cli_anything/exa/exa_cli.py @@ -0,0 +1,413 @@ +""" +exa_cli.py — CLI harness for Exa. + +Provides a stateful, agent-native command-line interface for the Exa API: + - web search (neural, keyword, or deep) + - find-similar pages + - fetch full page contents + - LLM-synthesised answers with citations + +Usage (non-interactive): + cli-anything-exa search "AI safety papers 2024" --type deep --content highlights + cli-anything-exa similar https://example.com --num-results 5 + cli-anything-exa contents https://example.com --content text + cli-anything-exa answer "What is Exa's neural search?" + cli-anything-exa --json search "latest LLM benchmarks" --num-results 3 + cli-anything-exa server status + +Usage (interactive REPL): + cli-anything-exa +""" + +from __future__ import annotations + +import json +import sys +from typing import Any + +import click + +from cli_anything.exa.core import answer as answer_core +from cli_anything.exa.core import search as search_core +from cli_anything.exa.core import session as session_core +from cli_anything.exa.utils.exa_backend import check_connectivity + +# --------------------------------------------------------------------------- +# Global state +# --------------------------------------------------------------------------- + +_json_output: bool = False + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _out(data: Any) -> None: + """Emit output in JSON or human-readable form.""" + if _json_output: + click.echo(json.dumps(data, indent=2, default=str)) + else: + _pretty(data) + + +def _pretty(data: Any) -> None: + """Render a result dict in a human-readable format.""" + if isinstance(data, dict): + if "results" in data: + _print_results(data) + elif "answer" in data: + _print_answer(data) + elif "ok" in data: + status = "OK" if data["ok"] else "ERROR" + click.echo(f"[{status}] {data.get('message', '')}") + elif "total_queries" in data: + click.echo(f"Queries this session : {data['total_queries']}") + click.echo(f"Commands used : {', '.join(data['commands_used']) or 'none'}") + if data["last_query"]: + click.echo(f"Last query : {data['last_query']}") + else: + # Generic dict fallback + for k, v in data.items(): + click.echo(f"{k}: {v}") + elif isinstance(data, list): + for item in data: + _pretty(item) + else: + click.echo(str(data)) + + +def _print_results(data: dict[str, Any]) -> None: + results = data.get("results", []) + if not results: + click.echo("No results returned.") + return + click.echo(f"{'─' * 72}") + for i, r in enumerate(results, 1): + title = r.get("title") or "(no title)" + url = r.get("url", "") + date = r.get("published_date", "") + author = r.get("author", "") + + click.echo(f"{i:>2}. {title}") + click.echo(f" {url}") + meta_parts = [] + if date: + meta_parts.append(date[:10]) + if author: + meta_parts.append(f"by {author}") + if meta_parts: + click.echo(f" {' · '.join(meta_parts)}") + + if r.get("highlights"): + for h in r["highlights"]: + click.echo(f" › {h.strip()}") + elif r.get("summary"): + click.echo(f" {r['summary'].strip()}") + elif r.get("text"): + snippet = r["text"][:300].replace("\n", " ").strip() + click.echo(f" {snippet}…") + click.echo() + + cost = data.get("cost_dollars") + if cost: + total = cost.get("total", "") if isinstance(cost, dict) else cost + click.echo(f"Cost: ${total}") + click.echo(f"{'─' * 72}") + + +def _print_answer(data: dict[str, Any]) -> None: + click.echo(f"\n{data.get('answer', '')}\n") + citations = data.get("citations", []) + if citations: + click.echo("Sources:") + for i, c in enumerate(citations, 1): + click.echo(f" {i}. {c.get('title') or c.get('url', '')}") + click.echo(f" {c.get('url', '')}") + cost = data.get("cost_dollars") + if cost: + total = cost.get("total", "") if isinstance(cost, dict) else cost + click.echo(f"\nCost: ${total}") + + +def _err(msg: str) -> None: + if _json_output: + click.echo(json.dumps({"error": msg})) + else: + click.echo(f"Error: {msg}", err=True) + + +def _handle_errors(fn): + """Decorator: catch RuntimeError / Exception and emit consistent errors.""" + import functools + + @functools.wraps(fn) + def wrapper(*args, **kwargs): + try: + return fn(*args, **kwargs) + except RuntimeError as exc: + _err(str(exc)) + sys.exit(1) + except Exception as exc: # noqa: BLE001 + _err(f"Unexpected error: {exc}") + sys.exit(1) + + return wrapper + + +# --------------------------------------------------------------------------- +# CLI root +# --------------------------------------------------------------------------- + +@click.group(invoke_without_command=True) +@click.option("--json", "use_json", is_flag=True, help="Emit machine-readable JSON output.") +@click.pass_context +def cli(ctx: click.Context, use_json: bool) -> None: + """CLI harness for Exa — AI-powered web search and answer engine. + + Run without a subcommand to enter the interactive REPL. + Set EXA_API_KEY in your environment before use. + """ + global _json_output + _json_output = use_json + ctx.ensure_object(dict) + + if ctx.invoked_subcommand is None: + ctx.invoke(repl) + + +# --------------------------------------------------------------------------- +# search +# --------------------------------------------------------------------------- + +_SEARCH_TYPES = click.Choice( + ["auto", "fast", "instant", "deep", "deep-reasoning"], + case_sensitive=False, +) +_CONTENT_CHOICES = click.Choice( + ["highlights", "text", "summary", "none"], + case_sensitive=False, +) +_FRESHNESS_CHOICES = click.Choice( + ["smart", "always", "never"], + case_sensitive=False, +) +_CATEGORY_CHOICES = click.Choice( + ["company", "people", "research-paper", "news", "personal-site", "financial-report"], + case_sensitive=False, +) + + +@cli.command("search") +@click.argument("query") +@click.option("--type", "search_type", default="auto", show_default=True, + type=_SEARCH_TYPES, help="Search mode.") +@click.option("--num-results", "-n", default=10, show_default=True, + type=click.IntRange(1, 100), help="Number of results (1–100).") +@click.option("--category", type=_CATEGORY_CHOICES, default=None, + help="Restrict to a specialised index.") +@click.option("--content", "content_mode", default="highlights", show_default=True, + type=_CONTENT_CHOICES, help="Content to include with each result.") +@click.option("--freshness", default="smart", show_default=True, + type=_FRESHNESS_CHOICES, + help="Livecrawl policy: smart=cache+fallback, always=force-fresh, never=cache-only.") +@click.option("--include-domains", multiple=True, metavar="DOMAIN", + help="Restrict results to these domains (repeatable).") +@click.option("--exclude-domains", multiple=True, metavar="DOMAIN", + help="Exclude results from these domains (repeatable).") +@click.option("--from", "start_date", default=None, metavar="DATE", + help="Only results published after this date (ISO 8601, e.g. 2024-01-01).") +@click.option("--to", "end_date", default=None, metavar="DATE", + help="Only results published before this date (ISO 8601).") +@click.option("--location", default=None, metavar="CC", + help="Geo-bias results to this two-letter country code (e.g. US).") +@_handle_errors +def search_cmd( + query: str, + search_type: str, + num_results: int, + category: str | None, + content_mode: str, + freshness: str, + include_domains: tuple[str, ...], + exclude_domains: tuple[str, ...], + start_date: str | None, + end_date: str | None, + location: str | None, +) -> None: + """Search the web using Exa's neural or deep search.""" + result = search_core.web_search( + query, + num_results=num_results, + search_type=search_type, + category=category, + include_domains=include_domains, + exclude_domains=exclude_domains, + start_date=start_date, + end_date=end_date, + location=location, + content_mode=content_mode, + freshness=freshness, + ) + session_core.record(query, "search", len(result.get("results", []))) + _out(result) + + +# --------------------------------------------------------------------------- +# similar +# --------------------------------------------------------------------------- + +@cli.command("similar") +@click.argument("url") +@click.option("--num-results", "-n", default=10, show_default=True, + type=click.IntRange(1, 100), help="Number of results.") +@click.option("--content", "content_mode", default="highlights", show_default=True, + type=_CONTENT_CHOICES, help="Content to include with each result.") +@_handle_errors +def similar_cmd(url: str, num_results: int, content_mode: str) -> None: + """Find pages similar to a given URL.""" + result = search_core.find_similar(url, num_results=num_results, content_mode=content_mode) + session_core.record(url, "similar", len(result.get("results", []))) + _out(result) + + +# --------------------------------------------------------------------------- +# contents +# --------------------------------------------------------------------------- + +@cli.command("contents") +@click.argument("urls", nargs=-1, required=True) +@click.option("--content", "content_mode", default="text", show_default=True, + type=click.Choice(["text", "highlights", "summary"], case_sensitive=False), + help="Content to retrieve.") +@click.option("--freshness", default="smart", show_default=True, + type=_FRESHNESS_CHOICES, + help="Livecrawl policy.") +@_handle_errors +def contents_cmd(urls: tuple[str, ...], content_mode: str, freshness: str) -> None: + """Fetch full page contents for one or more URLs.""" + result = search_core.get_contents(list(urls), content_mode=content_mode, freshness=freshness) + session_core.record(str(urls[0]), "contents", len(result.get("results", []))) + _out(result) + + +# --------------------------------------------------------------------------- +# answer +# --------------------------------------------------------------------------- + +@cli.command("answer") +@click.argument("query") +@_handle_errors +def answer_cmd(query: str) -> None: + """Get an LLM-synthesised answer with cited sources.""" + result = answer_core.get_answer(query) + session_core.record(query, "answer", len(result.get("citations", []))) + _out(result) + + +# --------------------------------------------------------------------------- +# server +# --------------------------------------------------------------------------- + +@cli.group("server") +def server_group() -> None: + """Server and API connectivity commands.""" + + +@server_group.command("status") +@_handle_errors +def server_status() -> None: + """Check that the Exa API is reachable with your API key.""" + result = check_connectivity() + _out(result) + + +# --------------------------------------------------------------------------- +# session +# --------------------------------------------------------------------------- + +@cli.group("session") +def session_group() -> None: + """Inspect the current REPL session state.""" + + +@session_group.command("status") +def session_status() -> None: + """Show a summary of activity in this session.""" + _out(session_core.get_status()) + + +@session_group.command("history") +def session_history() -> None: + """List queries made in this session (most recent first).""" + history = session_core.get_history() + if _json_output: + click.echo(json.dumps(history, indent=2)) + else: + if not history: + click.echo("No queries yet.") + return + click.echo(f"{'Time':<10} {'Cmd':<10} {'Results':<9} Query") + click.echo("─" * 72) + for entry in history: + click.echo( + f"{entry['time']:<10} {entry['command']:<10} " + f"{entry['results']:<9} {entry['query']}" + ) + + +# --------------------------------------------------------------------------- +# REPL +# --------------------------------------------------------------------------- + +@cli.command("repl") +def repl() -> None: + """Start the interactive REPL (default when no subcommand is given).""" + try: + from cli_anything.exa.utils.repl_skin import ReplSkin + except ImportError: + click.echo("prompt-toolkit is required for REPL mode: pip install prompt-toolkit") + sys.exit(1) + + skin = ReplSkin( + software_name="Exa", + version="1.0.0", + accent_color="cyan", + skill_package="cli_anything.exa", + ) + skin.print_banner() + + while True: + try: + user_input = skin.prompt() + except (KeyboardInterrupt, EOFError): + skin.print_goodbye() + break + + line = user_input.strip() + if not line: + continue + if line.lower() in ("exit", "quit", "q"): + skin.print_goodbye() + break + + # Dispatch the line as a CLI invocation + try: + args = line.split() + cli.main(args=args, standalone_mode=False) + except SystemExit: + pass + except Exception as exc: # noqa: BLE001 + _err(str(exc)) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main() -> None: + cli() + + +if __name__ == "__main__": + main() diff --git a/exa/agent-harness/cli_anything/exa/skills/SKILL.md b/exa/agent-harness/cli_anything/exa/skills/SKILL.md new file mode 100644 index 000000000..e57f81b89 --- /dev/null +++ b/exa/agent-harness/cli_anything/exa/skills/SKILL.md @@ -0,0 +1,128 @@ +# Exa CLI Skill + +## Identity +- **Name**: cli-anything-exa +- **Version**: 1.0.0 +- **Category**: search +- **Entry Point**: `cli-anything-exa` + +## What This CLI Does +Provides an agent-native command-line interface to the Exa API — a neural search engine +optimised for AI agent workflows. Supports web search across multiple modes (fast, deep, +deep-reasoning), finding similar pages, fetching full-text or highlighted page contents, +and getting LLM-synthesised answers with cited sources. + +## Prerequisites +- Python >= 3.10 +- `pip install cli-anything-exa` +- `export EXA_API_KEY="your-api-key"` (get one at https://dashboard.exa.ai/api-keys) + +## Installation +```bash +pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=exa/agent-harness +``` + +## Command Reference + +### search — Web search +```bash +cli-anything-exa search "" [OPTIONS] + +Options: + --type auto|fast|instant|deep|deep-reasoning (default: auto) + --num-results / -n 1–100 (default: 10) + --category company|people|research-paper|news|personal-site|financial-report + --content highlights|text|summary|none (default: highlights) + --freshness smart|always|never (default: smart) + --include-domains DOMAIN (repeatable) + --exclude-domains DOMAIN (repeatable) + --from DATE ISO 8601 start published date + --to DATE ISO 8601 end published date + --location CC Two-letter country code for geo-bias +``` + +### similar — Find similar pages +```bash +cli-anything-exa similar "" [--num-results N] [--content highlights|text|summary|none] +``` + +### contents — Fetch page contents +```bash +cli-anything-exa contents [url ...] [--content text|highlights|summary] [--freshness smart|always|never] +``` + +### answer — LLM-synthesised answer with citations +```bash +cli-anything-exa answer "" +``` + +### server status — Verify API key and connectivity +```bash +cli-anything-exa server status +``` + +### session — Inspect current REPL session +```bash +cli-anything-exa session status +cli-anything-exa session history +``` + +## JSON Output +All commands support `--json` at the root level for machine-readable output: +```bash +cli-anything-exa --json search "latest LLM papers" --num-results 5 +``` + +## Common Agent Patterns + +### Fast keyword lookup +```bash +cli-anything-exa --json search "site:arxiv.org transformer architectures" --type fast --content highlights +``` + +### Deep research on a topic +```bash +cli-anything-exa --json search "EU AI Act compliance requirements 2024" --type deep --content text +``` + +### Academic paper discovery +```bash +cli-anything-exa --json search "retrieval augmented generation" --category research-paper --num-results 20 +``` + +### Company intelligence +```bash +cli-anything-exa --json search "Anthropic funding history" --category company +``` + +### News monitoring +```bash +cli-anything-exa --json search "AI regulation news" --category news --from 2024-01-01 +``` + +### Find related resources +```bash +cli-anything-exa --json similar https://arxiv.org/abs/2303.08774 --num-results 10 +``` + +### Fetch full content for summarisation +```bash +cli-anything-exa --json contents https://example.com/article --content text +``` + +### Quick factual answer +```bash +cli-anything-exa --json answer "What is the context window of Claude 3.5 Sonnet?" +``` + +## Interactive REPL +```bash +cli-anything-exa # No subcommand → enters REPL +``` +Type commands without the `cli-anything-exa` prefix. Type `exit` or `quit` to leave. + +## Notes +- `highlights` content mode is 10× more token-efficient than `text` — prefer it for agent pipelines +- `--type deep` triggers multi-step reasoning; slower but synthesises across many sources +- `--category company` and `--category people` do not support date or domain-exclude filters +- Cost per query is included in JSON output under `cost_dollars` diff --git a/exa/agent-harness/cli_anything/exa/tests/TEST.md b/exa/agent-harness/cli_anything/exa/tests/TEST.md new file mode 100644 index 000000000..84152da3f --- /dev/null +++ b/exa/agent-harness/cli_anything/exa/tests/TEST.md @@ -0,0 +1,118 @@ +# TEST.md — Exa CLI Harness Test Plan & Results + +## Test Strategy + +| Layer | File | API calls | Purpose | +|-------|------|-----------|---------| +| Unit | test_core.py | None (mocked) | Logic, flag parsing, output formatting | +| E2E | test_full_e2e.py | Real Exa API | End-to-end correctness, field presence | + +## Unit Test Plan (`test_core.py`) + +### TestBuildContentsParam +- [x] `none` mode returns `None` +- [x] `highlights` mode sets `max_characters: 4000` +- [x] `text` mode sets `max_characters: 10000` +- [x] `summary` mode sets `summary: True` +- [x] `freshness=always` sets `max_age_hours: 0` +- [x] `freshness=never` sets `max_age_hours: -1` +- [x] `freshness=smart` omits `max_age_hours` + +### TestCategorySlugMap +- [x] Hyphenated slugs map correctly to API space-separated values +- [x] Simple slugs pass through unchanged + +### TestSession +- [x] Empty session returns zero totals +- [x] `record()` adds entries visible in `get_history()` +- [x] History returned most-recent-first +- [x] Status reflects all distinct commands used + +### TestCLIHelp +- [x] Root `--help` exits 0 and mentions "Exa" +- [x] `search --help` shows all flags +- [x] All subcommand `--help` exits exit code 0 + +### TestSearchCLI +- [x] Basic search calls `exa.search()` with correct query +- [x] `--json` flag produces parseable JSON with `results` key +- [x] `--type deep` is forwarded to SDK +- [x] `--num-results 5` is forwarded to SDK +- [x] `--include-domains` is forwarded to SDK +- [x] Invalid `--type` value is rejected (exit != 0) + +### TestSimilarCLI +- [x] Basic call invokes `exa.find_similar()` +- [x] `--json` produces parseable JSON + +### TestContentsCLI +- [x] Single URL invokes `exa.get_contents()` +- [x] Multiple URLs forwarded as list + +### TestAnswerCLI +- [x] Answer text appears in output +- [x] `--json` produces `answer` and `citations` keys + +### TestServerCLI +- [x] `[OK]` shown on success +- [x] `[ERROR]` shown on failure +- [x] `--json` produces `{"ok": true}` + +### TestSessionCLI +- [x] `session status` shows query count after search +- [x] `session history` shows "No queries" when empty +- [x] `session history --json` returns a list + +### TestErrorHandling +- [x] `RuntimeError` from backend produces `{"error": "..."}` in JSON mode +- [x] Missing required argument exits non-zero + +## E2E Test Plan (`test_full_e2e.py`) + +Skipped automatically when `EXA_API_KEY` is not set. + +### TestServerStatusE2E +- [ ] `server status` exits 0 and shows `[OK]` +- [ ] `--json server status` returns `{"ok": true}` + +### TestSearchE2E +- [ ] Basic search returns at least 1 result +- [ ] Result objects contain `url` and `title` +- [ ] `--content highlights` produces `highlights` array +- [ ] `--content text` produces non-empty `text` string +- [ ] `--category news` returns results +- [ ] `--include-domains arxiv.org` — all result URLs contain `arxiv.org` +- [ ] `--num-results 5` returns ≤ 5 results +- [ ] Human-readable output contains `http` +- [ ] Session is updated after search + +### TestSimilarE2E +- [ ] `similar ` returns results +- [ ] Results contain `url` and `title` + +### TestContentsE2E +- [ ] `contents ` returns results +- [ ] `--content text` result has non-empty `text` +- [ ] Multiple URLs: at least 1 result returned + +### TestAnswerE2E +- [ ] `answer ` returns `answer` string (> 10 chars) +- [ ] `answer` result has `citations` list +- [ ] Human-readable output is non-empty + +### TestEntryPoint +- [ ] `cli-anything-exa --help` exits 0 via subprocess +- [ ] `cli-anything-exa --json search ...` returns valid JSON via subprocess + +## Running Tests + +```bash +# Unit tests only +pytest cli_anything/exa/tests/test_core.py -v + +# E2E tests (requires EXA_API_KEY) +EXA_API_KEY=your-key pytest cli_anything/exa/tests/test_full_e2e.py -v + +# All tests +pytest cli_anything/exa/tests/ -v +``` diff --git a/exa/agent-harness/cli_anything/exa/tests/__init__.py b/exa/agent-harness/cli_anything/exa/tests/__init__.py new file mode 100644 index 000000000..4e91dc8ed --- /dev/null +++ b/exa/agent-harness/cli_anything/exa/tests/__init__.py @@ -0,0 +1 @@ +# cli_anything.exa.tests diff --git a/exa/agent-harness/cli_anything/exa/tests/test_core.py b/exa/agent-harness/cli_anything/exa/tests/test_core.py new file mode 100644 index 000000000..32f4e2c9c --- /dev/null +++ b/exa/agent-harness/cli_anything/exa/tests/test_core.py @@ -0,0 +1,396 @@ +""" +test_core.py — Unit tests for the Exa CLI harness. + +All tests use mocks; no real API calls are made. +Run with: pytest tests/test_core.py -v +""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from cli_anything.exa.exa_cli import cli +from cli_anything.exa.core import session as session_core +from cli_anything.exa.utils.exa_backend import build_contents_param, CATEGORY_SLUG_MAP + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def clear_session(): + """Reset session history before each test.""" + session_core.clear() + yield + session_core.clear() + + +@pytest.fixture() +def runner(): + return CliRunner() + + +def _mock_result(n: int = 2): + """Build a fake exa-py search result object using SimpleNamespace.""" + results = [ + SimpleNamespace( + title=f"Result {i + 1}", + url=f"https://example.com/{i + 1}", + id=f"https://example.com/{i + 1}", + published_date="2024-01-01", + author=None, + text=f"Body text for result {i + 1}.", + highlights=[f"Highlight for result {i + 1}."], + highlight_scores=[0.9], + summary=None, + ) + for i in range(n) + ] + return SimpleNamespace(results=results, cost_dollars={"total": 0.005}) + + +def _mock_answer(): + citation = SimpleNamespace( + title="Some Paper", + url="https://example.com/paper", + published_date="2024-01-01", + author="Jane Doe", + ) + return SimpleNamespace( + answer="Exa uses neural embeddings for search.", + results=[citation], + cost_dollars={"total": 0.003}, + ) + return response + + +# --------------------------------------------------------------------------- +# Backend unit tests +# --------------------------------------------------------------------------- + +class TestBuildContentsParam: + def test_none_mode_returns_none(self): + assert build_contents_param("none") is None + + def test_highlights_mode(self): + result = build_contents_param("highlights") + assert "highlights" in result + assert result["highlights"]["max_characters"] == 4_000 + + def test_text_mode(self): + result = build_contents_param("text") + assert "text" in result + assert result["text"]["max_characters"] == 10_000 + + def test_summary_mode(self): + result = build_contents_param("summary") + assert result["summary"] is True + + def test_freshness_always(self): + result = build_contents_param("highlights", freshness="always") + assert result["max_age_hours"] == 0 + + def test_freshness_never(self): + result = build_contents_param("highlights", freshness="never") + assert result["max_age_hours"] == -1 + + def test_freshness_smart_omits_key(self): + result = build_contents_param("highlights", freshness="smart") + assert "max_age_hours" not in result + + +class TestCategorySlugMap: + def test_hyphenated_slugs_map_to_api_values(self): + assert CATEGORY_SLUG_MAP["research-paper"] == "research paper" + assert CATEGORY_SLUG_MAP["personal-site"] == "personal site" + assert CATEGORY_SLUG_MAP["financial-report"] == "financial report" + + def test_simple_slugs_pass_through(self): + assert CATEGORY_SLUG_MAP["news"] == "news" + assert CATEGORY_SLUG_MAP["company"] == "company" + + +# --------------------------------------------------------------------------- +# Session unit tests +# --------------------------------------------------------------------------- + +class TestSession: + def test_empty_session(self): + status = session_core.get_status() + assert status["total_queries"] == 0 + assert status["last_query"] is None + + def test_record_and_history(self): + session_core.record("test query", "search", 5) + history = session_core.get_history() + assert len(history) == 1 + assert history[0]["query"] == "test query" + assert history[0]["results"] == 5 + + def test_history_most_recent_first(self): + session_core.record("first", "search", 1) + session_core.record("second", "answer", 2) + history = session_core.get_history() + assert history[0]["query"] == "second" + assert history[1]["query"] == "first" + + def test_status_after_records(self): + session_core.record("q1", "search", 3) + session_core.record("q2", "answer", 1) + status = session_core.get_status() + assert status["total_queries"] == 2 + assert "search" in status["commands_used"] + assert "answer" in status["commands_used"] + assert status["last_query"] == "q2" + + +# --------------------------------------------------------------------------- +# CLI parsing tests +# --------------------------------------------------------------------------- + +class TestCLIHelp: + def test_root_help(self, runner): + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "Exa" in result.output + + def test_search_help(self, runner): + result = runner.invoke(cli, ["search", "--help"]) + assert result.exit_code == 0 + assert "--type" in result.output + assert "--num-results" in result.output + assert "--content" in result.output + + def test_similar_help(self, runner): + result = runner.invoke(cli, ["similar", "--help"]) + assert result.exit_code == 0 + assert "--num-results" in result.output + + def test_contents_help(self, runner): + result = runner.invoke(cli, ["contents", "--help"]) + assert result.exit_code == 0 + + def test_answer_help(self, runner): + result = runner.invoke(cli, ["answer", "--help"]) + assert result.exit_code == 0 + + def test_server_status_help(self, runner): + result = runner.invoke(cli, ["server", "status", "--help"]) + assert result.exit_code == 0 + + def test_session_help(self, runner): + result = runner.invoke(cli, ["session", "--help"]) + assert result.exit_code == 0 + + +class TestSearchCLI: + @patch("cli_anything.exa.core.search.get_client") + def test_basic_search(self, mock_get_client, runner): + mock_client = MagicMock() + mock_client.search.return_value = _mock_result(2) + mock_get_client.return_value = mock_client + + result = runner.invoke(cli, ["search", "AI research"]) + assert result.exit_code == 0 + mock_client.search.assert_called_once() + call_kwargs = mock_client.search.call_args + assert call_kwargs[0][0] == "AI research" + + @patch("cli_anything.exa.core.search.get_client") + def test_search_json_output(self, mock_get_client, runner): + mock_client = MagicMock() + mock_client.search.return_value = _mock_result(1) + mock_get_client.return_value = mock_client + + result = runner.invoke(cli, ["--json", "search", "test query"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert "results" in data + assert len(data["results"]) == 1 + + @patch("cli_anything.exa.core.search.get_client") + def test_search_type_flag(self, mock_get_client, runner): + mock_client = MagicMock() + mock_client.search.return_value = _mock_result(1) + mock_get_client.return_value = mock_client + + result = runner.invoke(cli, ["search", "deep query", "--type", "deep"]) + assert result.exit_code == 0 + _, kwargs = mock_client.search.call_args + assert kwargs.get("type") == "deep" + + @patch("cli_anything.exa.core.search.get_client") + def test_search_num_results_flag(self, mock_get_client, runner): + mock_client = MagicMock() + mock_client.search.return_value = _mock_result(5) + mock_get_client.return_value = mock_client + + result = runner.invoke(cli, ["search", "test", "--num-results", "5"]) + assert result.exit_code == 0 + _, kwargs = mock_client.search.call_args + assert kwargs.get("num_results") == 5 + + @patch("cli_anything.exa.core.search.get_client") + def test_search_include_domains(self, mock_get_client, runner): + mock_client = MagicMock() + mock_client.search.return_value = _mock_result(1) + mock_get_client.return_value = mock_client + + result = runner.invoke( + cli, ["search", "test", "--include-domains", "arxiv.org", "--include-domains", "nature.com"] + ) + assert result.exit_code == 0 + _, kwargs = mock_client.search.call_args + assert "arxiv.org" in kwargs.get("include_domains", []) + + @patch("cli_anything.exa.core.search.get_client") + def test_search_invalid_type_rejected(self, mock_get_client, runner): + result = runner.invoke(cli, ["search", "test", "--type", "bogus"]) + assert result.exit_code != 0 + + +class TestSimilarCLI: + @patch("cli_anything.exa.core.search.get_client") + def test_basic_similar(self, mock_get_client, runner): + mock_client = MagicMock() + mock_client.find_similar.return_value = _mock_result(3) + mock_get_client.return_value = mock_client + + result = runner.invoke(cli, ["similar", "https://example.com"]) + assert result.exit_code == 0 + mock_client.find_similar.assert_called_once() + + @patch("cli_anything.exa.core.search.get_client") + def test_similar_json_output(self, mock_get_client, runner): + mock_client = MagicMock() + mock_client.find_similar.return_value = _mock_result(2) + mock_get_client.return_value = mock_client + + result = runner.invoke(cli, ["--json", "similar", "https://example.com"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert "results" in data + + +class TestContentsCLI: + @patch("cli_anything.exa.core.search.get_client") + def test_basic_contents(self, mock_get_client, runner): + mock_client = MagicMock() + mock_client.get_contents.return_value = _mock_result(1) + mock_get_client.return_value = mock_client + + result = runner.invoke(cli, ["contents", "https://example.com"]) + assert result.exit_code == 0 + mock_client.get_contents.assert_called_once() + + @patch("cli_anything.exa.core.search.get_client") + def test_contents_multiple_urls(self, mock_get_client, runner): + mock_client = MagicMock() + mock_client.get_contents.return_value = _mock_result(2) + mock_get_client.return_value = mock_client + + result = runner.invoke( + cli, ["contents", "https://example.com/a", "https://example.com/b"] + ) + assert result.exit_code == 0 + args, _ = mock_client.get_contents.call_args + assert len(args[0]) == 2 + + +class TestAnswerCLI: + @patch("cli_anything.exa.core.answer.get_client") + def test_basic_answer(self, mock_get_client, runner): + mock_client = MagicMock() + mock_client.answer.return_value = _mock_answer() + mock_get_client.return_value = mock_client + + result = runner.invoke(cli, ["answer", "What is Exa?"]) + assert result.exit_code == 0 + assert "neural embeddings" in result.output + + @patch("cli_anything.exa.core.answer.get_client") + def test_answer_json_output(self, mock_get_client, runner): + mock_client = MagicMock() + mock_client.answer.return_value = _mock_answer() + mock_get_client.return_value = mock_client + + result = runner.invoke(cli, ["--json", "answer", "What is Exa?"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert "answer" in data + assert "citations" in data + + +class TestServerCLI: + @patch("cli_anything.exa.exa_cli.check_connectivity") + def test_server_status_ok(self, mock_check, runner): + mock_check.return_value = {"ok": True, "message": "API key valid — Exa reachable"} + result = runner.invoke(cli, ["server", "status"]) + assert result.exit_code == 0 + assert "OK" in result.output + + @patch("cli_anything.exa.exa_cli.check_connectivity") + def test_server_status_error(self, mock_check, runner): + mock_check.return_value = {"ok": False, "message": "EXA_API_KEY not set"} + result = runner.invoke(cli, ["server", "status"]) + assert result.exit_code == 0 + assert "ERROR" in result.output + + @patch("cli_anything.exa.exa_cli.check_connectivity") + def test_server_status_json(self, mock_check, runner): + mock_check.return_value = {"ok": True, "message": "OK"} + result = runner.invoke(cli, ["--json", "server", "status"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["ok"] is True + + +class TestSessionCLI: + @patch("cli_anything.exa.core.search.get_client") + def test_session_status_after_search(self, mock_get_client, runner): + mock_client = MagicMock() + mock_client.search.return_value = _mock_result(3) + mock_get_client.return_value = mock_client + + runner.invoke(cli, ["search", "test query"]) + result = runner.invoke(cli, ["session", "status"]) + assert result.exit_code == 0 + assert "1" in result.output # total_queries + + def test_session_history_empty(self, runner): + result = runner.invoke(cli, ["session", "history"]) + assert result.exit_code == 0 + assert "No queries" in result.output + + @patch("cli_anything.exa.core.search.get_client") + def test_session_history_json(self, mock_get_client, runner): + mock_client = MagicMock() + mock_client.search.return_value = _mock_result(2) + mock_get_client.return_value = mock_client + + runner.invoke(cli, ["search", "test"]) + result = runner.invoke(cli, ["--json", "session", "history"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert isinstance(data, list) + assert data[0]["query"] == "test" + + +class TestErrorHandling: + @patch("cli_anything.exa.core.search.get_client") + def test_runtime_error_produces_json_error(self, mock_get_client, runner): + mock_get_client.side_effect = RuntimeError("EXA_API_KEY environment variable is not set.") + result = runner.invoke(cli, ["--json", "search", "test"]) + assert result.exit_code == 1 + data = json.loads(result.output) + assert "error" in data + + def test_missing_query_argument(self, runner): + result = runner.invoke(cli, ["search"]) + assert result.exit_code != 0 diff --git a/exa/agent-harness/cli_anything/exa/tests/test_full_e2e.py b/exa/agent-harness/cli_anything/exa/tests/test_full_e2e.py new file mode 100644 index 000000000..c2191974e --- /dev/null +++ b/exa/agent-harness/cli_anything/exa/tests/test_full_e2e.py @@ -0,0 +1,266 @@ +""" +test_full_e2e.py — End-to-end tests against the real Exa API. + +Requires: EXA_API_KEY set in the environment. +Run with: pytest tests/test_full_e2e.py -v + +These tests make real API calls and consume credits. They verify that: + - The CLI produces parseable JSON output + - Results contain expected fields + - All subcommands route correctly to the API +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys + +import pytest + +from click.testing import CliRunner +from cli_anything.exa.exa_cli import cli +from cli_anything.exa.core import session as session_core + + +# --------------------------------------------------------------------------- +# Skip guard +# --------------------------------------------------------------------------- + +pytestmark = pytest.mark.skipif( + not os.environ.get("EXA_API_KEY"), + reason="EXA_API_KEY not set — skipping E2E tests", +) + + +@pytest.fixture(autouse=True) +def clear_session(): + session_core.clear() + yield + session_core.clear() + + +@pytest.fixture() +def runner(): + return CliRunner() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _resolve_cli() -> list[str]: + """Return the command prefix to invoke cli-anything-exa.""" + cmd = "cli-anything-exa" + result = subprocess.run(["which", cmd], capture_output=True, text=True) + if result.returncode == 0: + return [cmd] + # Fall back to module invocation + return [sys.executable, "-m", "cli_anything.exa"] + + +def _run(*args: str) -> subprocess.CompletedProcess: + prefix = _resolve_cli() + return subprocess.run( + prefix + list(args), + capture_output=True, + text=True, + env={**os.environ}, + ) + + +# --------------------------------------------------------------------------- +# server status +# --------------------------------------------------------------------------- + +class TestServerStatusE2E: + def test_status_ok(self): + proc = _run("server", "status") + assert proc.returncode == 0 + assert "OK" in proc.stdout + + def test_status_json(self): + proc = _run("--json", "server", "status") + assert proc.returncode == 0 + data = json.loads(proc.stdout) + assert data["ok"] is True + assert "message" in data + + +# --------------------------------------------------------------------------- +# search +# --------------------------------------------------------------------------- + +class TestSearchE2E: + def test_basic_search_returns_results(self, runner): + result = runner.invoke(cli, ["--json", "search", "large language models 2024"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert "results" in data + assert len(data["results"]) > 0 + + def test_search_result_has_required_fields(self, runner): + result = runner.invoke(cli, ["--json", "search", "AI safety research", "--num-results", "3"]) + assert result.exit_code == 0 + data = json.loads(result.output) + r = data["results"][0] + assert "url" in r + assert "title" in r + + def test_search_highlights_content(self, runner): + result = runner.invoke( + cli, ["--json", "search", "neural search algorithms", "--content", "highlights"] + ) + assert result.exit_code == 0 + data = json.loads(result.output) + r = data["results"][0] + assert "highlights" in r + assert isinstance(r["highlights"], list) + assert len(r["highlights"]) > 0 + + def test_search_text_content(self, runner): + result = runner.invoke( + cli, ["--json", "search", "machine learning overview", "--content", "text", "--num-results", "1"] + ) + assert result.exit_code == 0 + data = json.loads(result.output) + r = data["results"][0] + assert "text" in r + assert len(r["text"]) > 50 + + def test_search_category_news(self, runner): + result = runner.invoke( + cli, ["--json", "search", "AI regulation", "--category", "news", "--num-results", "3"] + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert len(data["results"]) > 0 + + def test_search_domain_filter(self, runner): + result = runner.invoke( + cli, + ["--json", "search", "machine learning", "--include-domains", "arxiv.org", "--num-results", "3"], + ) + assert result.exit_code == 0 + data = json.loads(result.output) + for r in data["results"]: + assert "arxiv.org" in r["url"] + + def test_search_num_results_respected(self, runner): + result = runner.invoke(cli, ["--json", "search", "AI research", "--num-results", "5"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert len(data["results"]) <= 5 + + def test_search_human_readable_output(self, runner): + result = runner.invoke(cli, ["search", "Exa search API"]) + assert result.exit_code == 0 + assert "http" in result.output # URL is shown + + def test_search_records_session(self, runner): + runner.invoke(cli, ["--json", "search", "test query"]) + status = session_core.get_status() + assert status["total_queries"] == 1 + assert status["last_query"] == "test query" + + +# --------------------------------------------------------------------------- +# similar +# --------------------------------------------------------------------------- + +class TestSimilarE2E: + def test_basic_similar(self, runner): + result = runner.invoke( + cli, ["--json", "similar", "https://arxiv.org/abs/2303.08774", "--num-results", "3"] + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert "results" in data + assert len(data["results"]) > 0 + + def test_similar_result_fields(self, runner): + result = runner.invoke( + cli, ["--json", "similar", "https://openai.com/research/gpt-4", "--num-results", "2"] + ) + assert result.exit_code == 0 + data = json.loads(result.output) + r = data["results"][0] + assert "url" in r + assert "title" in r + + +# --------------------------------------------------------------------------- +# contents +# --------------------------------------------------------------------------- + +class TestContentsE2E: + def test_basic_contents(self, runner): + result = runner.invoke( + cli, ["--json", "contents", "https://exa.ai", "--content", "text"] + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert "results" in data + assert len(data["results"]) > 0 + + def test_contents_text_field_present(self, runner): + result = runner.invoke( + cli, ["--json", "contents", "https://exa.ai", "--content", "text"] + ) + assert result.exit_code == 0 + data = json.loads(result.output) + r = data["results"][0] + assert "text" in r + assert len(r["text"]) > 0 + + def test_contents_multiple_urls(self, runner): + result = runner.invoke( + cli, + ["--json", "contents", "https://exa.ai", "https://arxiv.org", "--content", "highlights"], + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert len(data["results"]) >= 1 + + +# --------------------------------------------------------------------------- +# answer +# --------------------------------------------------------------------------- + +class TestAnswerE2E: + def test_basic_answer(self, runner): + result = runner.invoke(cli, ["--json", "answer", "What is Exa's neural search?"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert "answer" in data + assert len(data["answer"]) > 10 + + def test_answer_has_citations(self, runner): + result = runner.invoke(cli, ["--json", "answer", "How does Exa differ from Google?"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert "citations" in data + assert isinstance(data["citations"], list) + + def test_answer_human_readable(self, runner): + result = runner.invoke(cli, ["answer", "What is RAG in AI?"]) + assert result.exit_code == 0 + assert len(result.output.strip()) > 0 + + +# --------------------------------------------------------------------------- +# subprocess (entry-point) smoke test +# --------------------------------------------------------------------------- + +class TestEntryPoint: + def test_cli_entry_point_help(self): + proc = _run("--help") + assert proc.returncode == 0 + assert "Exa" in proc.stdout + + def test_cli_entry_point_search(self): + proc = _run("--json", "search", "Exa API overview", "--num-results", "2") + assert proc.returncode == 0 + data = json.loads(proc.stdout) + assert "results" in data diff --git a/exa/agent-harness/cli_anything/exa/utils/__init__.py b/exa/agent-harness/cli_anything/exa/utils/__init__.py new file mode 100644 index 000000000..d71040bc2 --- /dev/null +++ b/exa/agent-harness/cli_anything/exa/utils/__init__.py @@ -0,0 +1 @@ +# cli_anything.exa.utils diff --git a/exa/agent-harness/cli_anything/exa/utils/exa_backend.py b/exa/agent-harness/cli_anything/exa/utils/exa_backend.py new file mode 100644 index 000000000..c9589eb30 --- /dev/null +++ b/exa/agent-harness/cli_anything/exa/utils/exa_backend.py @@ -0,0 +1,105 @@ +""" +exa_backend.py — Exa API client wrapper. + +Initialises the exa-py SDK client from EXA_API_KEY and exposes lightweight +helper functions used by the core modules. +""" + +from __future__ import annotations + +import os +from typing import Any + +try: + from exa_py import Exa +except ImportError as exc: # pragma: no cover + raise ImportError( + "exa-py is required: pip install exa-py" + ) from exc + + +def get_client() -> Exa: + """Return an authenticated Exa client. + + Raises: + SystemExit: if EXA_API_KEY is not set in the environment. + """ + api_key = os.environ.get("EXA_API_KEY", "").strip() + if not api_key: + raise RuntimeError( + "EXA_API_KEY environment variable is not set.\n" + "Get a free key at https://dashboard.exa.ai/api-keys" + ) + return Exa(api_key=api_key) + + +def check_connectivity() -> dict[str, Any]: + """Verify the API key is valid by running a minimal search. + + Returns a dict with keys: ok (bool), message (str). + """ + try: + client = get_client() + client.search("test", num_results=1) + return {"ok": True, "message": "API key valid — Exa reachable"} + except RuntimeError as exc: + return {"ok": False, "message": str(exc)} + except Exception as exc: # noqa: BLE001 + return {"ok": False, "message": f"Exa API error: {exc}"} + + +def build_contents_param( + content_mode: str, + freshness: str = "smart", +) -> dict[str, Any] | None: + """Translate CLI content/freshness flags into an exa-py `contents` dict. + + Args: + content_mode: "text" | "highlights" | "summary" | "none" + freshness: "smart" | "always" | "never" + + Returns: + A contents dict suitable for passing to exa.search() / exa.find_similar(), + or None if content_mode is "none". + """ + if content_mode == "none": + return None + + contents: dict[str, Any] = {} + + if content_mode == "text": + contents["text"] = {"max_characters": 10_000} + elif content_mode == "highlights": + contents["highlights"] = {"max_characters": 4_000} + elif content_mode == "summary": + contents["summary"] = True + + # Freshness maps to max_age_hours + if freshness == "always": + contents["max_age_hours"] = 0 + elif freshness == "never": + contents["max_age_hours"] = -1 + # "smart" → omit max_age_hours (SDK default: cache + livecrawl fallback) + + return contents or None + + +# Category values accepted by the Exa API +VALID_CATEGORIES = { + "company", + "people", + "research paper", + "news", + "personal site", + "financial report", +} + +# CLI slug → API value (hyphens to spaces for multi-word categories) +CATEGORY_SLUG_MAP: dict[str, str] = { + "company": "company", + "people": "people", + "research-paper": "research paper", + "news": "news", + "personal-site": "personal site", + "financial-report": "financial report", +} diff --git a/exa/agent-harness/cli_anything/exa/utils/repl_skin.py b/exa/agent-harness/cli_anything/exa/utils/repl_skin.py new file mode 100644 index 000000000..c7312348a --- /dev/null +++ b/exa/agent-harness/cli_anything/exa/utils/repl_skin.py @@ -0,0 +1,521 @@ +"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses. + +Copy this file into your CLI package at: + cli_anything//utils/repl_skin.py + +Usage: + from cli_anything..utils.repl_skin import ReplSkin + + skin = ReplSkin("shotcut", version="1.0.0") + skin.print_banner() # auto-detects skills/SKILL.md inside the package + prompt_text = skin.prompt(project_name="my_video.mlt", modified=True) + skin.success("Project saved") + skin.error("File not found") + skin.warning("Unsaved changes") + skin.info("Processing 24 clips...") + skin.status("Track 1", "3 clips, 00:02:30") + skin.table(headers, rows) + skin.print_goodbye() +""" + +import os +import sys + +# ── ANSI color codes (no external deps for core styling) ────────────── + +_RESET = "\033[0m" +_BOLD = "\033[1m" +_DIM = "\033[2m" +_ITALIC = "\033[3m" +_UNDERLINE = "\033[4m" + +# Brand colors +_CYAN = "\033[38;5;80m" # cli-anything brand cyan +_CYAN_BG = "\033[48;5;80m" +_WHITE = "\033[97m" +_GRAY = "\033[38;5;245m" +_DARK_GRAY = "\033[38;5;240m" +_LIGHT_GRAY = "\033[38;5;250m" + +# Software accent colors — each software gets a unique accent +_ACCENT_COLORS = { + "gimp": "\033[38;5;214m", # warm orange + "blender": "\033[38;5;208m", # deep orange + "inkscape": "\033[38;5;39m", # bright blue + "audacity": "\033[38;5;33m", # navy blue + "libreoffice": "\033[38;5;40m", # green + "obs_studio": "\033[38;5;55m", # purple + "kdenlive": "\033[38;5;69m", # slate blue + "shotcut": "\033[38;5;35m", # teal green +} +_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue + +# Status colors +_GREEN = "\033[38;5;78m" +_YELLOW = "\033[38;5;220m" +_RED = "\033[38;5;196m" +_BLUE = "\033[38;5;75m" +_MAGENTA = "\033[38;5;176m" + +# ── Brand icon ──────────────────────────────────────────────────────── + +# The cli-anything icon: a small colored diamond/chevron mark +_ICON = f"{_CYAN}{_BOLD}◆{_RESET}" +_ICON_SMALL = f"{_CYAN}▸{_RESET}" + +# ── Box drawing characters ──────────────────────────────────────────── + +_H_LINE = "─" +_V_LINE = "│" +_TL = "╭" +_TR = "╮" +_BL = "╰" +_BR = "╯" +_T_DOWN = "┬" +_T_UP = "┴" +_T_RIGHT = "├" +_T_LEFT = "┤" +_CROSS = "┼" + + +def _strip_ansi(text: str) -> str: + """Remove ANSI escape codes for length calculation.""" + import re + return re.sub(r"\033\[[^m]*m", "", text) + + +def _visible_len(text: str) -> int: + """Get visible length of text (excluding ANSI codes).""" + return len(_strip_ansi(text)) + + +class ReplSkin: + """Unified REPL skin for cli-anything CLIs. + + Provides consistent branding, prompts, and message formatting + across all CLI harnesses built with the cli-anything methodology. + """ + + def __init__(self, software: str, version: str = "1.0.0", + history_file: str | None = None, skill_path: str | None = None): + """Initialize the REPL skin. + + Args: + software: Software name (e.g., "gimp", "shotcut", "blender"). + version: CLI version string. + history_file: Path for persistent command history. + Defaults to ~/.cli-anything-/history + skill_path: Path to the SKILL.md file for agent discovery. + Auto-detected from the package's skills/ directory if not provided. + Displayed in banner for AI agents to know where to read skill info. + """ + self.software = software.lower().replace("-", "_") + self.display_name = software.replace("_", " ").title() + self.version = version + + # Auto-detect skill path from package layout: + # cli_anything//utils/repl_skin.py (this file) + # cli_anything//skills/SKILL.md (target) + if skill_path is None: + from pathlib import Path + _auto = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md" + if _auto.is_file(): + skill_path = str(_auto) + self.skill_path = skill_path + self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT) + + # History file + if history_file is None: + from pathlib import Path + hist_dir = Path.home() / f".cli-anything-{self.software}" + hist_dir.mkdir(parents=True, exist_ok=True) + self.history_file = str(hist_dir / "history") + else: + self.history_file = history_file + + # Detect terminal capabilities + self._color = self._detect_color_support() + + def _detect_color_support(self) -> bool: + """Check if terminal supports color.""" + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("CLI_ANYTHING_NO_COLOR"): + return False + if not hasattr(sys.stdout, "isatty"): + return False + return sys.stdout.isatty() + + def _c(self, code: str, text: str) -> str: + """Apply color code if colors are supported.""" + if not self._color: + return text + return f"{code}{text}{_RESET}" + + # ── Banner ──────────────────────────────────────────────────────── + + def print_banner(self): + """Print the startup banner with branding.""" + inner = 54 + + def _box_line(content: str) -> str: + """Wrap content in box drawing, padding to inner width.""" + pad = inner - _visible_len(content) + vl = self._c(_DARK_GRAY, _V_LINE) + return f"{vl}{content}{' ' * max(0, pad)}{vl}" + + top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}") + bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}") + + # Title: ◆ cli-anything · Shotcut + icon = self._c(_CYAN + _BOLD, "◆") + brand = self._c(_CYAN + _BOLD, "cli-anything") + dot = self._c(_DARK_GRAY, "·") + name = self._c(self.accent + _BOLD, self.display_name) + title = f" {icon} {brand} {dot} {name}" + + ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}" + tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}" + empty = "" + + # Skill path for agent discovery + skill_line = None + if self.skill_path: + skill_icon = self._c(_MAGENTA, "◇") + skill_label = self._c(_DARK_GRAY, " Skill:") + skill_path_display = self._c(_LIGHT_GRAY, self.skill_path) + skill_line = f" {skill_icon} {skill_label} {skill_path_display}" + + print(top) + print(_box_line(title)) + print(_box_line(ver)) + if skill_line: + print(_box_line(skill_line)) + print(_box_line(empty)) + print(_box_line(tip)) + print(bot) + print() + + # ── Prompt ──────────────────────────────────────────────────────── + + def prompt(self, project_name: str = "", modified: bool = False, + context: str = "") -> str: + """Build a styled prompt string for prompt_toolkit or input(). + + Args: + project_name: Current project name (empty if none open). + modified: Whether the project has unsaved changes. + context: Optional extra context to show in prompt. + + Returns: + Formatted prompt string. + """ + parts = [] + + # Icon + if self._color: + parts.append(f"{_CYAN}◆{_RESET} ") + else: + parts.append("> ") + + # Software name + parts.append(self._c(self.accent + _BOLD, self.software)) + + # Project context + if project_name or context: + ctx = context or project_name + mod = "*" if modified else "" + parts.append(f" {self._c(_DARK_GRAY, '[')}") + parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}")) + parts.append(self._c(_DARK_GRAY, ']')) + + parts.append(self._c(_GRAY, " ❯ ")) + + return "".join(parts) + + def prompt_tokens(self, project_name: str = "", modified: bool = False, + context: str = ""): + """Build prompt_toolkit formatted text tokens for the prompt. + + Use with prompt_toolkit's FormattedText for proper ANSI handling. + + Returns: + list of (style, text) tuples for prompt_toolkit. + """ + accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff") + tokens = [] + + tokens.append(("class:icon", "◆ ")) + tokens.append(("class:software", self.software)) + + if project_name or context: + ctx = context or project_name + mod = "*" if modified else "" + tokens.append(("class:bracket", " [")) + tokens.append(("class:context", f"{ctx}{mod}")) + tokens.append(("class:bracket", "]")) + + tokens.append(("class:arrow", " ❯ ")) + + return tokens + + def get_prompt_style(self): + """Get a prompt_toolkit Style object matching the skin. + + Returns: + prompt_toolkit.styles.Style + """ + try: + from prompt_toolkit.styles import Style + except ImportError: + return None + + accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff") + + return Style.from_dict({ + "icon": "#5fdfdf bold", # cyan brand color + "software": f"{accent_hex} bold", + "bracket": "#585858", + "context": "#bcbcbc", + "arrow": "#808080", + # Completion menu + "completion-menu.completion": "bg:#303030 #bcbcbc", + "completion-menu.completion.current": f"bg:{accent_hex} #000000", + "completion-menu.meta.completion": "bg:#303030 #808080", + "completion-menu.meta.completion.current": f"bg:{accent_hex} #000000", + # Auto-suggest + "auto-suggest": "#585858", + # Bottom toolbar + "bottom-toolbar": "bg:#1c1c1c #808080", + "bottom-toolbar.text": "#808080", + }) + + # ── Messages ────────────────────────────────────────────────────── + + def success(self, message: str): + """Print a success message with green checkmark.""" + icon = self._c(_GREEN + _BOLD, "✓") + print(f" {icon} {self._c(_GREEN, message)}") + + def error(self, message: str): + """Print an error message with red cross.""" + icon = self._c(_RED + _BOLD, "✗") + print(f" {icon} {self._c(_RED, message)}", file=sys.stderr) + + def warning(self, message: str): + """Print a warning message with yellow triangle.""" + icon = self._c(_YELLOW + _BOLD, "⚠") + print(f" {icon} {self._c(_YELLOW, message)}") + + def info(self, message: str): + """Print an info message with blue dot.""" + icon = self._c(_BLUE, "●") + print(f" {icon} {self._c(_LIGHT_GRAY, message)}") + + def hint(self, message: str): + """Print a subtle hint message.""" + print(f" {self._c(_DARK_GRAY, message)}") + + def section(self, title: str): + """Print a section header.""" + print() + print(f" {self._c(self.accent + _BOLD, title)}") + print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}") + + # ── Status display ──────────────────────────────────────────────── + + def status(self, label: str, value: str): + """Print a key-value status line.""" + lbl = self._c(_GRAY, f" {label}:") + val = self._c(_WHITE, f" {value}") + print(f"{lbl}{val}") + + def status_block(self, items: dict[str, str], title: str = ""): + """Print a block of status key-value pairs. + + Args: + items: Dict of label -> value pairs. + title: Optional title for the block. + """ + if title: + self.section(title) + + max_key = max(len(k) for k in items) if items else 0 + for label, value in items.items(): + lbl = self._c(_GRAY, f" {label:<{max_key}}") + val = self._c(_WHITE, f" {value}") + print(f"{lbl}{val}") + + def progress(self, current: int, total: int, label: str = ""): + """Print a simple progress indicator. + + Args: + current: Current step number. + total: Total number of steps. + label: Optional label for the progress. + """ + pct = int(current / total * 100) if total > 0 else 0 + bar_width = 20 + filled = int(bar_width * current / total) if total > 0 else 0 + bar = "█" * filled + "░" * (bar_width - filled) + text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}" + if label: + text += f" {self._c(_LIGHT_GRAY, label)}" + print(text) + + # ── Table display ───────────────────────────────────────────────── + + def table(self, headers: list[str], rows: list[list[str]], + max_col_width: int = 40): + """Print a formatted table with box-drawing characters. + + Args: + headers: Column header strings. + rows: List of rows, each a list of cell strings. + max_col_width: Maximum column width before truncation. + """ + if not headers: + return + + # Calculate column widths + col_widths = [min(len(h), max_col_width) for h in headers] + for row in rows: + for i, cell in enumerate(row): + if i < len(col_widths): + col_widths[i] = min( + max(col_widths[i], len(str(cell))), max_col_width + ) + + def pad(text: str, width: int) -> str: + t = str(text)[:width] + return t + " " * (width - len(t)) + + # Header + header_cells = [ + self._c(_CYAN + _BOLD, pad(h, col_widths[i])) + for i, h in enumerate(headers) + ] + sep = self._c(_DARK_GRAY, f" {_V_LINE} ") + header_line = f" {sep.join(header_cells)}" + print(header_line) + + # Separator + sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths] + sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}") + print(sep_line) + + # Rows + for row in rows: + cells = [] + for i, cell in enumerate(row): + if i < len(col_widths): + cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i]))) + row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ") + print(f" {row_sep.join(cells)}") + + # ── Help display ────────────────────────────────────────────────── + + def help(self, commands: dict[str, str]): + """Print a formatted help listing. + + Args: + commands: Dict of command -> description pairs. + """ + self.section("Commands") + max_cmd = max(len(c) for c in commands) if commands else 0 + for cmd, desc in commands.items(): + cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}") + desc_styled = self._c(_GRAY, f" {desc}") + print(f"{cmd_styled}{desc_styled}") + print() + + # ── Goodbye ─────────────────────────────────────────────────────── + + def print_goodbye(self): + """Print a styled goodbye message.""" + print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n") + + # ── Prompt toolkit session factory ──────────────────────────────── + + def create_prompt_session(self): + """Create a prompt_toolkit PromptSession with skin styling. + + Returns: + A configured PromptSession, or None if prompt_toolkit unavailable. + """ + try: + from prompt_toolkit import PromptSession + from prompt_toolkit.history import FileHistory + from prompt_toolkit.auto_suggest import AutoSuggestFromHistory + from prompt_toolkit.formatted_text import FormattedText + + style = self.get_prompt_style() + + session = PromptSession( + history=FileHistory(self.history_file), + auto_suggest=AutoSuggestFromHistory(), + style=style, + enable_history_search=True, + ) + return session + except ImportError: + return None + + def get_input(self, pt_session, project_name: str = "", + modified: bool = False, context: str = "") -> str: + """Get input from user using prompt_toolkit or fallback. + + Args: + pt_session: A prompt_toolkit PromptSession (or None). + project_name: Current project name. + modified: Whether project has unsaved changes. + context: Optional context string. + + Returns: + User input string (stripped). + """ + if pt_session is not None: + from prompt_toolkit.formatted_text import FormattedText + tokens = self.prompt_tokens(project_name, modified, context) + return pt_session.prompt(FormattedText(tokens)).strip() + else: + raw_prompt = self.prompt(project_name, modified, context) + return input(raw_prompt).strip() + + # ── Toolbar builder ─────────────────────────────────────────────── + + def bottom_toolbar(self, items: dict[str, str]): + """Create a bottom toolbar callback for prompt_toolkit. + + Args: + items: Dict of label -> value pairs to show in toolbar. + + Returns: + A callable that returns FormattedText for the toolbar. + """ + def toolbar(): + from prompt_toolkit.formatted_text import FormattedText + parts = [] + for i, (k, v) in enumerate(items.items()): + if i > 0: + parts.append(("class:bottom-toolbar.text", " │ ")) + parts.append(("class:bottom-toolbar.text", f" {k}: ")) + parts.append(("class:bottom-toolbar", v)) + return FormattedText(parts) + return toolbar + + +# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ───────── + +_ANSI_256_TO_HEX = { + "\033[38;5;33m": "#0087ff", # audacity navy blue + "\033[38;5;35m": "#00af5f", # shotcut teal + "\033[38;5;39m": "#00afff", # inkscape bright blue + "\033[38;5;40m": "#00d700", # libreoffice green + "\033[38;5;55m": "#5f00af", # obs purple + "\033[38;5;69m": "#5f87ff", # kdenlive slate blue + "\033[38;5;75m": "#5fafff", # default sky blue + "\033[38;5;80m": "#5fd7d7", # brand cyan + "\033[38;5;208m": "#ff8700", # blender deep orange + "\033[38;5;214m": "#ffaf00", # gimp warm orange +} diff --git a/exa/agent-harness/setup.py b/exa/agent-harness/setup.py new file mode 100644 index 000000000..1f4d98455 --- /dev/null +++ b/exa/agent-harness/setup.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +""" +setup.py for cli-anything-exa + +Install with: pip install -e . +Or publish to PyPI: python -m build && twine upload dist/* +""" + +from setuptools import setup, find_namespace_packages + +with open("cli_anything/exa/README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +setup( + name="cli-anything-exa", + version="1.0.0", + author="cli-anything contributors", + author_email="", + description="CLI harness for Exa — AI-powered web search, similar-page discovery, and LLM-synthesized answers via the Exa API", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/HKUDS/CLI-Anything", + packages=find_namespace_packages(include=["cli_anything.*"]), + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Internet :: WWW/HTTP :: Indexing/Search", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + ], + python_requires=">=3.10", + install_requires=[ + "click>=8.0.0", + "prompt-toolkit>=3.0.0", + "exa-py>=1.0.0", + ], + extras_require={ + "dev": [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + ], + }, + entry_points={ + "console_scripts": [ + "cli-anything-exa=cli_anything.exa.exa_cli:main", + ], + }, + package_data={ + "cli_anything.exa": ["skills/*.md"], + }, + include_package_data=True, + zip_safe=False, +) diff --git a/registry.json b/registry.json index a69bb4335..b341b174a 100644 --- a/registry.json +++ b/registry.json @@ -466,6 +466,20 @@ "category": "graphics", "contributor": "Taeyoung96", "contributor_url": "https://github.com/Taeyoung96" + }, + { + "name": "exa", + "display_name": "Exa", + "version": "1.0.0", + "description": "AI-powered web search, similar-page discovery, and LLM-synthesised answers via the Exa API", + "requires": "EXA_API_KEY (free tier at exa.ai)", + "homepage": "https://exa.ai", + "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=exa/agent-harness", + "entry_point": "cli-anything-exa", + "skill_md": "exa/agent-harness/cli_anything/exa/skills/SKILL.md", + "category": "search", + "contributor": "tgonzalezc5", + "contributor_url": "https://github.com/tgonzalezc5" } ] }