querit-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- querit_cli/__init__.py +3 -0
- querit_cli/cli.py +85 -0
- querit_cli/client.py +79 -0
- querit_cli/commands/__init__.py +0 -0
- querit_cli/commands/auth.py +54 -0
- querit_cli/commands/search.py +86 -0
- querit_cli/common.py +30 -0
- querit_cli/config.py +77 -0
- querit_cli/output.py +94 -0
- querit_cli/repl.py +143 -0
- querit_cli/theme.py +43 -0
- querit_cli-0.1.0.dist-info/METADATA +13 -0
- querit_cli-0.1.0.dist-info/RECORD +15 -0
- querit_cli-0.1.0.dist-info/WHEEL +4 -0
- querit_cli-0.1.0.dist-info/entry_points.txt +2 -0
querit_cli/__init__.py
ADDED
querit_cli/cli.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Main CLI entry point — wires all commands into the `querit` group."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from querit_cli import __version__
|
|
8
|
+
from querit_cli.commands.auth import auth_status, login, logout
|
|
9
|
+
from querit_cli.commands.search import search
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group(invoke_without_command=True)
|
|
13
|
+
@click.option("--version", is_flag=True, default=False, help="Show version and exit.")
|
|
14
|
+
@click.option("--status", "show_status", is_flag=True, default=False, help="Show version and auth status.")
|
|
15
|
+
@click.option("--json", "json_output", is_flag=True, default=False, help="Output as JSON (for agents and scripts).")
|
|
16
|
+
@click.pass_context
|
|
17
|
+
def cli(ctx: click.Context, version: bool, show_status: bool, json_output: bool) -> None:
|
|
18
|
+
"""Querit CLI — search the web from the command line.
|
|
19
|
+
|
|
20
|
+
Authenticate with: querit login --api-key YOUR_KEY
|
|
21
|
+
Or set the QUERIT_API_KEY environment variable.
|
|
22
|
+
"""
|
|
23
|
+
ctx.ensure_object(dict)
|
|
24
|
+
ctx.obj["json_output"] = json_output
|
|
25
|
+
|
|
26
|
+
if version:
|
|
27
|
+
if json_output:
|
|
28
|
+
import json
|
|
29
|
+
click.echo(json.dumps({"version": __version__}))
|
|
30
|
+
else:
|
|
31
|
+
click.echo(f"querit-cli {__version__}")
|
|
32
|
+
ctx.exit(0)
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
if show_status:
|
|
36
|
+
_print_status(json_output)
|
|
37
|
+
ctx.exit(0)
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
if ctx.invoked_subcommand is None:
|
|
41
|
+
from querit_cli.repl import run_repl
|
|
42
|
+
run_repl()
|
|
43
|
+
ctx.exit(0)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _print_status(json_output: bool) -> None:
|
|
47
|
+
"""Show version + auth status."""
|
|
48
|
+
import json as _json
|
|
49
|
+
import os
|
|
50
|
+
|
|
51
|
+
from querit_cli.config import get_api_key
|
|
52
|
+
|
|
53
|
+
key = get_api_key()
|
|
54
|
+
authenticated = key is not None
|
|
55
|
+
|
|
56
|
+
if json_output:
|
|
57
|
+
click.echo(_json.dumps({
|
|
58
|
+
"version": __version__,
|
|
59
|
+
"authenticated": authenticated,
|
|
60
|
+
}))
|
|
61
|
+
else:
|
|
62
|
+
from rich.console import Console
|
|
63
|
+
console = Console()
|
|
64
|
+
console.print(f" [bold #00C2C2]querit[/bold #00C2C2] v{__version__}")
|
|
65
|
+
console.print()
|
|
66
|
+
if authenticated:
|
|
67
|
+
source = "QUERIT_API_KEY env var" if os.environ.get("QUERIT_API_KEY") else "~/.querit/config.json"
|
|
68
|
+
console.print(f" [#9BC0AE]>[/#9BC0AE] Authenticated via {source}")
|
|
69
|
+
else:
|
|
70
|
+
console.print(" [#FAA2FB]>[/#FAA2FB] Not authenticated")
|
|
71
|
+
console.print(" Run: querit login --api-key YOUR_KEY")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
cli.add_command(login)
|
|
75
|
+
cli.add_command(logout)
|
|
76
|
+
cli.add_command(auth_status)
|
|
77
|
+
cli.add_command(search)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main() -> None:
|
|
81
|
+
cli()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
main()
|
querit_cli/client.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Querit API client — wraps the official querit Python SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from querit import QueritClient as _SDKClient
|
|
8
|
+
from querit.models.request import SearchRequest, SearchFilters, SiteFilter, GeoFilter
|
|
9
|
+
from querit.errors import QueritError
|
|
10
|
+
|
|
11
|
+
from querit_cli.common import QueritAPIError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class QueritClient:
|
|
15
|
+
"""Thin wrapper around the official Querit Python SDK."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, api_key: str, timeout: int = 30) -> None:
|
|
18
|
+
# SDK internally sets: Authorization: Bearer {api_key}
|
|
19
|
+
# so pass the raw key only — do NOT prepend "Bearer " here
|
|
20
|
+
self._sdk = _SDKClient(api_key=api_key, timeout=timeout)
|
|
21
|
+
|
|
22
|
+
def search(
|
|
23
|
+
self,
|
|
24
|
+
query: str,
|
|
25
|
+
*,
|
|
26
|
+
count: int | None = None,
|
|
27
|
+
sites_include: list[str] | None = None,
|
|
28
|
+
sites_exclude: list[str] | None = None,
|
|
29
|
+
time_range: str | None = None,
|
|
30
|
+
countries: list[str] | None = None,
|
|
31
|
+
languages: list[str] | None = None,
|
|
32
|
+
) -> dict[str, Any]:
|
|
33
|
+
"""Call the Querit search API via the official SDK."""
|
|
34
|
+
search_filters = None
|
|
35
|
+
if any([sites_include, sites_exclude, time_range, countries, languages]):
|
|
36
|
+
search_filters = SearchFilters(
|
|
37
|
+
sites=SiteFilter(
|
|
38
|
+
include=sites_include or [],
|
|
39
|
+
exclude=sites_exclude or [],
|
|
40
|
+
) if (sites_include or sites_exclude) else None,
|
|
41
|
+
geo=GeoFilter(
|
|
42
|
+
countries=countries,
|
|
43
|
+
) if countries else None,
|
|
44
|
+
time_range=time_range,
|
|
45
|
+
languages=languages,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
request = SearchRequest(
|
|
49
|
+
query=query,
|
|
50
|
+
**({} if count is None else {"count": count}),
|
|
51
|
+
**({"filters": search_filters} if search_filters else {}),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
response = self._sdk.search(request)
|
|
56
|
+
except QueritError as e:
|
|
57
|
+
raise QueritAPIError(str(e)) from e
|
|
58
|
+
except Exception as e:
|
|
59
|
+
raise QueritAPIError(f"Unexpected error: {e}") from e
|
|
60
|
+
|
|
61
|
+
# Normalise SDK response object → dict so output.py stays unchanged
|
|
62
|
+
results = [
|
|
63
|
+
{
|
|
64
|
+
"url": getattr(item, "url", ""),
|
|
65
|
+
"title": getattr(item, "title", ""),
|
|
66
|
+
"snippet": getattr(item, "snippet", "") or getattr(item, "description", ""),
|
|
67
|
+
"site_name": getattr(item, "site_name", ""),
|
|
68
|
+
"page_age": getattr(item, "page_age", ""),
|
|
69
|
+
"site_icon": getattr(item, "site_icon", ""),
|
|
70
|
+
}
|
|
71
|
+
for item in (response.results or [])
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
"took": getattr(response, "took", ""),
|
|
76
|
+
"search_id": getattr(response, "search_id", None),
|
|
77
|
+
"query_context": {"query": query},
|
|
78
|
+
"results": {"result": results},
|
|
79
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""querit login / logout / auth — credential management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.command()
|
|
9
|
+
@click.option("--api-key", default=None, help="Your Querit API key.")
|
|
10
|
+
def login(api_key: str | None) -> None:
|
|
11
|
+
"""Save your Querit API key locally."""
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from querit_cli.config import save_api_key
|
|
14
|
+
|
|
15
|
+
console = Console(stderr=True)
|
|
16
|
+
if not api_key:
|
|
17
|
+
api_key = click.prompt(" Enter your Querit API key", hide_input=True)
|
|
18
|
+
|
|
19
|
+
save_api_key(api_key.strip())
|
|
20
|
+
console.print(" [#9BC0AE]>[/#9BC0AE] API key saved.")
|
|
21
|
+
console.print(" [dim]Stored in ~/.querit/config.json[/dim]")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@click.command()
|
|
25
|
+
def logout() -> None:
|
|
26
|
+
"""Remove your saved Querit API key."""
|
|
27
|
+
from rich.console import Console
|
|
28
|
+
from querit_cli.config import clear_credentials
|
|
29
|
+
|
|
30
|
+
console = Console(stderr=True)
|
|
31
|
+
clear_credentials()
|
|
32
|
+
console.print(" [#9BC0AE]>[/#9BC0AE] Credentials cleared.")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@click.command("auth")
|
|
36
|
+
def auth_status() -> None:
|
|
37
|
+
"""Show current authentication status."""
|
|
38
|
+
import os
|
|
39
|
+
from rich.console import Console
|
|
40
|
+
from querit_cli.config import get_api_key
|
|
41
|
+
|
|
42
|
+
console = Console(stderr=True)
|
|
43
|
+
key = get_api_key()
|
|
44
|
+
|
|
45
|
+
console.print()
|
|
46
|
+
if key:
|
|
47
|
+
masked = key[:8] + "..." if len(key) > 8 else key
|
|
48
|
+
source = "QUERIT_API_KEY env var" if os.environ.get("QUERIT_API_KEY") else "~/.querit/config.json"
|
|
49
|
+
console.print(f" [#9BC0AE]>[/#9BC0AE] Authenticated via {source}")
|
|
50
|
+
console.print(f" [dim]Key: {masked}[/dim]")
|
|
51
|
+
else:
|
|
52
|
+
console.print(" [#FAA2FB]>[/#FAA2FB] Not authenticated")
|
|
53
|
+
console.print(" Run: [#00C2C2]querit login --api-key YOUR_KEY[/#00C2C2]")
|
|
54
|
+
console.print()
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""querit search — web search via the Querit API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from querit_cli.common import handle_api_error
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command()
|
|
11
|
+
@click.argument("query", required=False)
|
|
12
|
+
@click.option("--count", type=int, default=None,
|
|
13
|
+
help="Maximum number of results to return.")
|
|
14
|
+
@click.option("--sites-include", multiple=True, metavar="SITE",
|
|
15
|
+
help="Only include results from this site (repeatable). E.g. --sites-include bbc.com")
|
|
16
|
+
@click.option("--sites-exclude", multiple=True, metavar="SITE",
|
|
17
|
+
help="Exclude results from this site (repeatable). E.g. --sites-exclude reddit.com")
|
|
18
|
+
@click.option("--time-range", default=None, metavar="RANGE",
|
|
19
|
+
help=(
|
|
20
|
+
"Time range filter. Examples: "
|
|
21
|
+
"d7 (past 7 days), w2 (past 2 weeks), m3 (past 3 months), "
|
|
22
|
+
"y1 (past 1 year), 2024-01-01to2024-06-30 (date range)."
|
|
23
|
+
))
|
|
24
|
+
@click.option("--countries", multiple=True, metavar="CC",
|
|
25
|
+
help="Limit results to these countries (repeatable). E.g. --countries france --countries germany")
|
|
26
|
+
@click.option("--languages", multiple=True, metavar="LANG",
|
|
27
|
+
help="Limit results to these languages (repeatable). E.g. --languages en --languages zh")
|
|
28
|
+
@click.option("--output", "-o", "output_file", default=None,
|
|
29
|
+
help="Save full JSON response to this file.")
|
|
30
|
+
@click.option("--json", "json_flag", is_flag=True, default=False,
|
|
31
|
+
help="Output as JSON (for scripts and agents).")
|
|
32
|
+
@click.pass_context
|
|
33
|
+
def search(
|
|
34
|
+
ctx: click.Context,
|
|
35
|
+
query: str | None,
|
|
36
|
+
count: int | None,
|
|
37
|
+
sites_include: tuple[str, ...],
|
|
38
|
+
sites_exclude: tuple[str, ...],
|
|
39
|
+
time_range: str | None,
|
|
40
|
+
countries: tuple[str, ...],
|
|
41
|
+
languages: tuple[str, ...],
|
|
42
|
+
output_file: str | None,
|
|
43
|
+
json_flag: bool,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Search the web using the Querit API.
|
|
46
|
+
|
|
47
|
+
QUERY is your search term.
|
|
48
|
+
|
|
49
|
+
Examples:
|
|
50
|
+
|
|
51
|
+
querit search "climate change"
|
|
52
|
+
|
|
53
|
+
querit search "python tutorial" --count 5
|
|
54
|
+
|
|
55
|
+
querit search "news" --time-range d7 --countries US
|
|
56
|
+
|
|
57
|
+
querit search "site news" --sites-include bbc.com --sites-include reuters.com
|
|
58
|
+
"""
|
|
59
|
+
from querit_cli.config import get_client
|
|
60
|
+
from querit_cli.output import print_search_results
|
|
61
|
+
from querit_cli.theme import spinner
|
|
62
|
+
|
|
63
|
+
# Inherit --json from parent context (the global `querit --json` flag)
|
|
64
|
+
json_mode = json_flag or bool(ctx.obj and ctx.obj.get("json_output"))
|
|
65
|
+
|
|
66
|
+
if not query:
|
|
67
|
+
raise click.UsageError('QUERY is required. Example: querit search "your query"')
|
|
68
|
+
|
|
69
|
+
client = get_client()
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
with spinner("Searching...", json_mode=json_mode):
|
|
73
|
+
response = client.search(
|
|
74
|
+
query,
|
|
75
|
+
count=count,
|
|
76
|
+
sites_include=list(sites_include) or None,
|
|
77
|
+
sites_exclude=list(sites_exclude) or None,
|
|
78
|
+
time_range=time_range,
|
|
79
|
+
countries=list(countries) or None,
|
|
80
|
+
languages=list(languages) or None,
|
|
81
|
+
)
|
|
82
|
+
except Exception as e:
|
|
83
|
+
handle_api_error(e, json_mode)
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
print_search_results(response, json_mode=json_mode, output_file=output_file)
|
querit_cli/common.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Shared utilities: custom exception, error handler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from querit_cli.theme import err_console
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class QueritAPIError(Exception):
|
|
13
|
+
"""Structured error from the Querit API."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, message: str, *, status: int | None = None) -> None:
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.status = status
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def handle_api_error(e: Exception, json_mode: bool) -> None:
|
|
21
|
+
"""Print a formatted error message and exit."""
|
|
22
|
+
if json_mode:
|
|
23
|
+
click.echo(json.dumps({"error": str(e)}))
|
|
24
|
+
raise SystemExit(4)
|
|
25
|
+
if isinstance(e, QueritAPIError) and e.status == 429:
|
|
26
|
+
err_console.print(f" [#FFC769]>[/#FFC769] Rate limit exceeded: {e}")
|
|
27
|
+
err_console.print(" [dim]Check your plan at[/dim] [#00C2C2 link=https://www.querit.ai]querit.ai[/#00C2C2 link=https://www.querit.ai]")
|
|
28
|
+
raise SystemExit(3)
|
|
29
|
+
err_console.print(f" [#FAA2FB]> Error:[/#FAA2FB] {e}")
|
|
30
|
+
raise SystemExit(4)
|
querit_cli/config.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""API key storage and retrieval for the Querit CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
CONFIG_DIR = Path.home() / ".querit"
|
|
10
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
11
|
+
|
|
12
|
+
# Override base URL via environment variable if needed
|
|
13
|
+
BASE_URL = os.environ.get("QUERIT_BASE_URL", "https://api.querit.ai")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _read_config() -> dict:
|
|
17
|
+
if CONFIG_FILE.exists():
|
|
18
|
+
try:
|
|
19
|
+
return json.loads(CONFIG_FILE.read_text())
|
|
20
|
+
except (json.JSONDecodeError, OSError):
|
|
21
|
+
return {}
|
|
22
|
+
return {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _write_config(data: dict) -> None:
|
|
26
|
+
old_umask = os.umask(0o077)
|
|
27
|
+
try:
|
|
28
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
CONFIG_DIR.chmod(0o700)
|
|
30
|
+
CONFIG_FILE.write_text(json.dumps(data, indent=2) + "\n")
|
|
31
|
+
CONFIG_FILE.chmod(0o600)
|
|
32
|
+
finally:
|
|
33
|
+
os.umask(old_umask)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def save_api_key(api_key: str) -> None:
|
|
37
|
+
config = _read_config()
|
|
38
|
+
config["api_key"] = api_key
|
|
39
|
+
_write_config(config)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def clear_credentials() -> None:
|
|
43
|
+
if CONFIG_FILE.exists():
|
|
44
|
+
CONFIG_FILE.unlink()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_api_key() -> str | None:
|
|
48
|
+
"""Resolve the API key with precedence: env var > config file."""
|
|
49
|
+
key = os.environ.get("QUERIT_API_KEY")
|
|
50
|
+
if key:
|
|
51
|
+
return key
|
|
52
|
+
return _read_config().get("api_key")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_api_key_or_exit() -> str:
|
|
56
|
+
"""Get the API key or print an error and exit."""
|
|
57
|
+
import sys
|
|
58
|
+
|
|
59
|
+
key = get_api_key()
|
|
60
|
+
if not key:
|
|
61
|
+
from rich.console import Console
|
|
62
|
+
console = Console(stderr=True)
|
|
63
|
+
console.print(" [#FAA2FB]> Error:[/#FAA2FB] No Querit API key found.")
|
|
64
|
+
console.print()
|
|
65
|
+
console.print(" Set your API key using one of:")
|
|
66
|
+
console.print(" [#00C2C2]querit login --api-key YOUR_KEY[/#00C2C2]")
|
|
67
|
+
console.print(" [dim]export QUERIT_API_KEY=YOUR_KEY[/dim]")
|
|
68
|
+
console.print()
|
|
69
|
+
console.print(" Get a key at [link=https://www.querit.ai]querit.ai[/link]")
|
|
70
|
+
sys.exit(3)
|
|
71
|
+
return key
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_client():
|
|
75
|
+
"""Return an authenticated QueritClient."""
|
|
76
|
+
from querit_cli.client import QueritClient
|
|
77
|
+
return QueritClient(api_key=get_api_key_or_exit())
|
querit_cli/output.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Formatted output for Querit CLI search results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.rule import Rule
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
from rich.theme import Theme
|
|
13
|
+
|
|
14
|
+
_theme = Theme({
|
|
15
|
+
"markdown.h1": "bold #00C2C2",
|
|
16
|
+
"markdown.h2": "bold #00C2C2",
|
|
17
|
+
"markdown.h3": "bold #00C2C2",
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
console = Console(theme=_theme)
|
|
21
|
+
err_console = Console(stderr=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def emit(data: Any, *, json_mode: bool, output_file: str | None = None) -> None:
|
|
25
|
+
"""Serialise data as JSON to stdout or a file."""
|
|
26
|
+
text = json.dumps(data, indent=2, ensure_ascii=False)
|
|
27
|
+
if output_file:
|
|
28
|
+
with open(output_file, "w", encoding="utf-8") as f:
|
|
29
|
+
f.write(text + "\n")
|
|
30
|
+
else:
|
|
31
|
+
click.echo(text)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def print_search_results(
|
|
35
|
+
response: dict,
|
|
36
|
+
*,
|
|
37
|
+
json_mode: bool,
|
|
38
|
+
output_file: str | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Render Querit search results in human-readable or JSON format."""
|
|
41
|
+
if json_mode:
|
|
42
|
+
emit(response, json_mode=True, output_file=output_file)
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
results = response.get("results", {}).get("result", [])
|
|
46
|
+
took = response.get("took", "")
|
|
47
|
+
|
|
48
|
+
if not results:
|
|
49
|
+
console.print()
|
|
50
|
+
console.print(" [dim]No results found.[/dim]")
|
|
51
|
+
console.print()
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
console.print()
|
|
55
|
+
for i, r in enumerate(results, 1):
|
|
56
|
+
title = r.get("title") or r.get("url", "Untitled")
|
|
57
|
+
url = r.get("url", "")
|
|
58
|
+
snippet = r.get("snippet", "")
|
|
59
|
+
site = r.get("site_name", "")
|
|
60
|
+
page_age = r.get("page_age", "")
|
|
61
|
+
|
|
62
|
+
# Title
|
|
63
|
+
title_text = Text()
|
|
64
|
+
title_text.append(f" {i}. ", style="dim")
|
|
65
|
+
title_text.append(title, style="bold #00C2C2")
|
|
66
|
+
console.print(title_text)
|
|
67
|
+
|
|
68
|
+
# Meta: site name + age + url
|
|
69
|
+
meta = Text(" ")
|
|
70
|
+
if site:
|
|
71
|
+
meta.append(site, style="#9BC0AE")
|
|
72
|
+
if page_age:
|
|
73
|
+
meta.append(f" {page_age}", style="dim")
|
|
74
|
+
if url:
|
|
75
|
+
meta.append(f" {url}", style="dim underline")
|
|
76
|
+
console.print(meta)
|
|
77
|
+
|
|
78
|
+
# Snippet
|
|
79
|
+
if snippet:
|
|
80
|
+
short = snippet[:300] + ("…" if len(snippet) > 300 else "")
|
|
81
|
+
console.print(f" [dim]{short}[/dim]")
|
|
82
|
+
|
|
83
|
+
console.print()
|
|
84
|
+
|
|
85
|
+
# Footer: result count + server response time
|
|
86
|
+
footer_parts = [f"{len(results)} results"]
|
|
87
|
+
if took:
|
|
88
|
+
footer_parts.append(took)
|
|
89
|
+
console.print(Rule(f"[dim]{' | '.join(footer_parts)}[/dim]", style="dim"))
|
|
90
|
+
console.print()
|
|
91
|
+
|
|
92
|
+
if output_file:
|
|
93
|
+
emit(response, json_mode=True, output_file=output_file)
|
|
94
|
+
err_console.print(f" [dim]Saved to {output_file}[/dim]")
|
querit_cli/repl.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Interactive REPL — gives querit a clean, chat-like shell."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import readline # noqa: F401 — enables arrow-key history in input()
|
|
6
|
+
import shlex
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.rule import Rule
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from querit_cli import __version__
|
|
14
|
+
from querit_cli.config import get_api_key
|
|
15
|
+
from querit_cli.theme import LOGO
|
|
16
|
+
|
|
17
|
+
err_console = Console(stderr=True)
|
|
18
|
+
|
|
19
|
+
_REPL_COMMANDS = {"search", "login", "logout", "auth"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _print_banner() -> None:
|
|
23
|
+
"""Print the welcome banner inside the REPL."""
|
|
24
|
+
key = get_api_key()
|
|
25
|
+
|
|
26
|
+
err_console.print(LOGO)
|
|
27
|
+
err_console.print(f" [dim]v{__version__}[/dim]")
|
|
28
|
+
err_console.print()
|
|
29
|
+
|
|
30
|
+
if key:
|
|
31
|
+
err_console.print(" [#9BC0AE]>[/#9BC0AE] Authenticated")
|
|
32
|
+
else:
|
|
33
|
+
err_console.print(" [#FAA2FB]>[/#FAA2FB] Not authenticated")
|
|
34
|
+
err_console.print(" Type [#00C2C2]login[/#00C2C2] to set your API key.")
|
|
35
|
+
|
|
36
|
+
err_console.print()
|
|
37
|
+
|
|
38
|
+
tips = Text()
|
|
39
|
+
tips.append(" Tips: ", style="bold")
|
|
40
|
+
tips.append("search ", style="#00C2C2")
|
|
41
|
+
tips.append('"query"', style="dim")
|
|
42
|
+
tips.append(" | ", style="dim")
|
|
43
|
+
tips.append("search ", style="#00C2C2")
|
|
44
|
+
tips.append('"query" --count 5', style="dim")
|
|
45
|
+
tips.append(" | ", style="dim")
|
|
46
|
+
tips.append("help", style="#00C2C2")
|
|
47
|
+
tips.append(" | ", style="dim")
|
|
48
|
+
tips.append("exit", style="#00C2C2")
|
|
49
|
+
err_console.print(tips)
|
|
50
|
+
err_console.print()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _print_help() -> None:
|
|
54
|
+
"""Print REPL help."""
|
|
55
|
+
err_console.print()
|
|
56
|
+
cmds = Text()
|
|
57
|
+
cmds.append(" Commands\n\n", style="bold")
|
|
58
|
+
cmds.append(' search "your query"', style="#00C2C2")
|
|
59
|
+
cmds.append(" Web search\n")
|
|
60
|
+
cmds.append(' search "query" --count N', style="#00C2C2")
|
|
61
|
+
cmds.append(" Limit result count\n")
|
|
62
|
+
cmds.append(' search "query" --time-range d7', style="#00C2C2")
|
|
63
|
+
cmds.append(" Past 7 days\n")
|
|
64
|
+
cmds.append(' search "query" --sites-include bbc.com', style="#00C2C2")
|
|
65
|
+
cmds.append(" Site filter\n")
|
|
66
|
+
cmds.append(' search "query" --countries US --languages en', style="#00C2C2")
|
|
67
|
+
cmds.append(" Geo/lang filter\n")
|
|
68
|
+
cmds.append(" login", style="#00C2C2")
|
|
69
|
+
cmds.append(" Authenticate\n")
|
|
70
|
+
cmds.append(" logout", style="#00C2C2")
|
|
71
|
+
cmds.append(" Clear credentials\n")
|
|
72
|
+
cmds.append(" auth", style="#00C2C2")
|
|
73
|
+
cmds.append(" Auth status\n")
|
|
74
|
+
cmds.append(" exit / quit / Ctrl+C", style="#00C2C2")
|
|
75
|
+
cmds.append(" Leave\n")
|
|
76
|
+
err_console.print(cmds)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _prompt() -> str:
|
|
80
|
+
"""Print the separator + prompt and read a line of input."""
|
|
81
|
+
err_console.print(Rule(style="dim"))
|
|
82
|
+
try:
|
|
83
|
+
return input("\001\033[38;2;0;194;194m\002\u276f\001\033[0m\002 ")
|
|
84
|
+
except EOFError:
|
|
85
|
+
return "exit"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def run_repl() -> None:
|
|
89
|
+
"""Enter the interactive REPL loop."""
|
|
90
|
+
err_console.print()
|
|
91
|
+
_print_banner()
|
|
92
|
+
|
|
93
|
+
while True:
|
|
94
|
+
try:
|
|
95
|
+
line = _prompt()
|
|
96
|
+
except KeyboardInterrupt:
|
|
97
|
+
err_console.print()
|
|
98
|
+
err_console.print(" [dim]Farewell, hope to see you again![/dim]")
|
|
99
|
+
err_console.print()
|
|
100
|
+
break
|
|
101
|
+
|
|
102
|
+
line = line.strip()
|
|
103
|
+
if not line:
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
if line in ("exit", "quit", "q"):
|
|
107
|
+
err_console.print()
|
|
108
|
+
err_console.print(" [dim]Farewell, hope to see you again![/dim]")
|
|
109
|
+
err_console.print()
|
|
110
|
+
break
|
|
111
|
+
|
|
112
|
+
if line in ("help", "?"):
|
|
113
|
+
_print_help()
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
args = shlex.split(line)
|
|
118
|
+
except ValueError as e:
|
|
119
|
+
err_console.print(f" [#FAA2FB]Parse error:[/#FAA2FB] {e}")
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
# Allow "querit search ..." as well as plain "search ..."
|
|
123
|
+
if args and args[0] == "querit":
|
|
124
|
+
args = args[1:]
|
|
125
|
+
|
|
126
|
+
if not args:
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
from querit_cli.cli import cli
|
|
130
|
+
err_console.print()
|
|
131
|
+
try:
|
|
132
|
+
cli(args, standalone_mode=False)
|
|
133
|
+
except SystemExit:
|
|
134
|
+
pass
|
|
135
|
+
except KeyboardInterrupt:
|
|
136
|
+
err_console.print()
|
|
137
|
+
err_console.print(" [dim]Cancelled.[/dim]")
|
|
138
|
+
except click.exceptions.UsageError as e:
|
|
139
|
+
err_console.print(f" [#FAA2FB]>[/#FAA2FB] {e.format_message()}")
|
|
140
|
+
except Exception as e:
|
|
141
|
+
err_console.print(f" [#FAA2FB]> Error:[/#FAA2FB] {e}")
|
|
142
|
+
|
|
143
|
+
err_console.print()
|
querit_cli/theme.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Brand colours, Rich console instances, and the spinner helper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from typing import Generator
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
# Brand colours
|
|
11
|
+
TEAL = "#00C2C2"
|
|
12
|
+
PINK = "#FAA2FB"
|
|
13
|
+
YELLOW = "#FFC769"
|
|
14
|
+
BLUE = "#5B8AF9"
|
|
15
|
+
GREEN = "#9BC0AE"
|
|
16
|
+
|
|
17
|
+
BRAND = TEAL
|
|
18
|
+
ACCENT = BLUE
|
|
19
|
+
SUCCESS = GREEN
|
|
20
|
+
WARN = YELLOW
|
|
21
|
+
ERROR = PINK
|
|
22
|
+
|
|
23
|
+
console = Console()
|
|
24
|
+
err_console = Console(stderr=True)
|
|
25
|
+
|
|
26
|
+
LOGO = """\
|
|
27
|
+
[#00C2C2] ___ _ _ ___ ____ _ _____ [/#00C2C2]
|
|
28
|
+
[#5B8AF9] / _ \\| | | |/ _ \\| _ \\| ||_ _|[/#5B8AF9]
|
|
29
|
+
[#9BC0AE] | | | | | | | | | | |_) | | | | [/#9BC0AE]
|
|
30
|
+
[#FFC769] | |_| | |_| | |_| | _ <| |__| | [/#FFC769]
|
|
31
|
+
[#FAA2FB] \\__\\_\\\\___/ \\___/|_| \\_\\_____|_| [/#FAA2FB]"""
|
|
32
|
+
|
|
33
|
+
LOGO_COMPACT = "[#00C2C2 bold]querit[/#00C2C2 bold]"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@contextmanager
|
|
37
|
+
def spinner(message: str, *, json_mode: bool = False) -> Generator[None, None, None]:
|
|
38
|
+
"""Show a live spinner on stderr; silent in JSON mode."""
|
|
39
|
+
if json_mode:
|
|
40
|
+
yield
|
|
41
|
+
return
|
|
42
|
+
with err_console.status(f"[{TEAL}]{message}[/{TEAL}]", spinner="dots"):
|
|
43
|
+
yield
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: querit-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI tool for the Querit API — search from the command line.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Keywords: agents,ai,cli,querit,web-search
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: click>=8.1.0
|
|
9
|
+
Requires-Dist: querit>=0.1.5
|
|
10
|
+
Requires-Dist: rich>=13.0.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: ruff>=0.4.0; extra == 'dev'
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
querit_cli/__init__.py,sha256=1oJGdMGmm5qLzj0QhDXVw36YbqJlTZL6FmjAj5wvnd8,82
|
|
2
|
+
querit_cli/cli.py,sha256=mfe06EOX2gfR0UV0sppwXVwnAz6cyVczzZ2VWHdzd9Q,2530
|
|
3
|
+
querit_cli/client.py,sha256=vlEFARIs3Qh4UU5gMr-lgd2voHkkAb39QNEuqccJyow,2921
|
|
4
|
+
querit_cli/common.py,sha256=6xqHjH0qyVHCd8WPZ7WrWKOReKWqd-znQShIg1vZVIs,988
|
|
5
|
+
querit_cli/config.py,sha256=Di0AEbfNB9cRPLFUHHfS08hork7Ml3CBM6opb471Nns,2159
|
|
6
|
+
querit_cli/output.py,sha256=NBcpOVSNgATBAcE6qAk9gtFPxDpI1Ou9mZPrVAADY8o,2693
|
|
7
|
+
querit_cli/repl.py,sha256=RSZIyOvG8eBNq4djIwnBy6Ho2jdQTsObXgEIu5HWVWs,4535
|
|
8
|
+
querit_cli/theme.py,sha256=cI0STsSCuCJbNwOEdJSJ1NBPo-55ps0GoWVs6r3QqN4,1117
|
|
9
|
+
querit_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
querit_cli/commands/auth.py,sha256=Ok_AYS_UkeIYIHez-mzzvrSOtdBcX53mQV_rAnTKZJ0,1726
|
|
11
|
+
querit_cli/commands/search.py,sha256=3Vo7oeG57BDLmGeDJ2C9Nrl78dBxxOHcylq0h7mkcW0,3214
|
|
12
|
+
querit_cli-0.1.0.dist-info/METADATA,sha256=tv5j9llcHAhJRX2CfHCQv8ZDtiIFDBlj3W-ZkpfhUvU,408
|
|
13
|
+
querit_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
14
|
+
querit_cli-0.1.0.dist-info/entry_points.txt,sha256=QnIunfTOAkHr33TYxTQh3Y8SFSAPOSDAWvR5eyq0wEw,47
|
|
15
|
+
querit_cli-0.1.0.dist-info/RECORD,,
|