mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-09-01 05:26:36 +08:00
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
This commit is contained in:
committed by
Teo Gonzalez Collazo
parent
0ab6b0931e
commit
b1c9dd4f0d
@@ -207,3 +207,4 @@ assets/gen_typing_gif.py
|
||||
!/notebooklm/agent-harness/
|
||||
!/intelwatch/agent-harness/
|
||||
!/intelwatch/
|
||||
!/exa/
|
||||
|
||||
@@ -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 <query> Neural/keyword/deep web search
|
||||
├── similar <url> Find pages related to a URL
|
||||
├── contents <url> [url …] Fetch full-text or highlighted page content
|
||||
├── answer <query> 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)
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
# cli_anything.exa namespace package
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Allow running as python -m cli_anything.exa"""
|
||||
from cli_anything.exa.exa_cli import main
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
# cli_anything.exa.core
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 "<query>" [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 "<url>" [--num-results N] [--content highlights|text|summary|none]
|
||||
```
|
||||
|
||||
### contents — Fetch page contents
|
||||
```bash
|
||||
cli-anything-exa contents <url> [url ...] [--content text|highlights|summary] [--freshness smart|always|never]
|
||||
```
|
||||
|
||||
### answer — LLM-synthesised answer with citations
|
||||
```bash
|
||||
cli-anything-exa answer "<question>"
|
||||
```
|
||||
|
||||
### 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`
|
||||
@@ -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 <url>` returns results
|
||||
- [ ] Results contain `url` and `title`
|
||||
|
||||
### TestContentsE2E
|
||||
- [ ] `contents <url>` returns results
|
||||
- [ ] `--content text` result has non-empty `text`
|
||||
- [ ] Multiple URLs: at least 1 result returned
|
||||
|
||||
### TestAnswerE2E
|
||||
- [ ] `answer <question>` 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
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
# cli_anything.exa.tests
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# cli_anything.exa.utils
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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/<software>/utils/repl_skin.py
|
||||
|
||||
Usage:
|
||||
from cli_anything.<software>.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-<software>/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/<software>/utils/repl_skin.py (this file)
|
||||
# cli_anything/<software>/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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user