termchat-ai 0.1.0__tar.gz
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.
- termchat_ai-0.1.0/.gitignore +16 -0
- termchat_ai-0.1.0/PKG-INFO +48 -0
- termchat_ai-0.1.0/README.md +30 -0
- termchat_ai-0.1.0/pyproject.toml +38 -0
- termchat_ai-0.1.0/src/aiterminal/__init__.py +4 -0
- termchat_ai-0.1.0/src/aiterminal/client.py +81 -0
- termchat_ai-0.1.0/src/aiterminal/main.py +64 -0
- termchat_ai-0.1.0/src/aiterminal/terminal.py +15 -0
- termchat_ai-0.1.0/tests/test_client.py +83 -0
- termchat_ai-0.1.0/tests/test_main.py +70 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: termchat-ai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A no-login terminal client for the AITerminal cloud AI service
|
|
5
|
+
Author: AITerminal contributors
|
|
6
|
+
Keywords: ai,cli,groq,netlify,terminal
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: httpx<1,>=0.27
|
|
15
|
+
Requires-Dist: rich<15,>=13.7
|
|
16
|
+
Requires-Dist: typer<1,>=0.12
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# AITerminal CLI
|
|
20
|
+
|
|
21
|
+
This package provides the `aiterminal` terminal command. It connects directly to the public AITerminal chat function; it does not ask users to log in and does not contain a Groq API key.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
The package is named `termchat-ai` and is not published yet. After publication:
|
|
26
|
+
|
|
27
|
+
```powershell
|
|
28
|
+
py -m pip install termchat-ai
|
|
29
|
+
aiterminal
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Linux and macOS:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
python -m pip install termchat-ai
|
|
36
|
+
aiterminal
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
For development from a checkout:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
python -m pip install ./cli
|
|
43
|
+
aiterminal
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The client uses `https://aiterminal-cloud.netlify.app` by default. Override with `AIT_TERMINAL_API_URL` if needed.
|
|
47
|
+
|
|
48
|
+
Use `clear` or `/clear` to clear the screen and temporary conversation context; use `exit` or `quit` to end the session. Context is memory-only and is discarded when the process exits.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# AITerminal CLI
|
|
2
|
+
|
|
3
|
+
This package provides the `aiterminal` terminal command. It connects directly to the public AITerminal chat function; it does not ask users to log in and does not contain a Groq API key.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
The package is named `termchat-ai` and is not published yet. After publication:
|
|
8
|
+
|
|
9
|
+
```powershell
|
|
10
|
+
py -m pip install termchat-ai
|
|
11
|
+
aiterminal
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Linux and macOS:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
python -m pip install termchat-ai
|
|
18
|
+
aiterminal
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
For development from a checkout:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
python -m pip install ./cli
|
|
25
|
+
aiterminal
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The client uses `https://aiterminal-cloud.netlify.app` by default. Override with `AIT_TERMINAL_API_URL` if needed.
|
|
29
|
+
|
|
30
|
+
Use `clear` or `/clear` to clear the screen and temporary conversation context; use `exit` or `quit` to end the session. Context is memory-only and is discarded when the process exits.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "termchat-ai"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A no-login terminal client for the AITerminal cloud AI service"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [{ name = "AITerminal contributors" }]
|
|
12
|
+
keywords = ["ai", "terminal", "groq", "netlify", "cli"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"httpx>=0.27,<1",
|
|
23
|
+
"rich>=13.7,<15",
|
|
24
|
+
"typer>=0.12,<1",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
aiterminal = "aiterminal.main:run"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/aiterminal"]
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.sdist]
|
|
34
|
+
only-include = ["README.md", "pyproject.toml", "src/aiterminal", "tests"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
pythonpath = ["src"]
|
|
38
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""HTTP client for the AITerminal Netlify Functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
DEFAULT_API_URL = "https://aiterminal-cloud.netlify.app"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AITerminalError(Exception):
|
|
14
|
+
"""Base exception for user-safe service errors."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ServiceError(AITerminalError):
|
|
18
|
+
"""The backend returned a safe, user-facing error."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AITerminalClient:
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
base_url: str | None = None,
|
|
25
|
+
*,
|
|
26
|
+
transport: httpx.BaseTransport | None = None,
|
|
27
|
+
timeout: float = 35.0,
|
|
28
|
+
) -> None:
|
|
29
|
+
configured_url = base_url or os.getenv("AIT_TERMINAL_API_URL") or DEFAULT_API_URL
|
|
30
|
+
self.base_url = configured_url.rstrip("/")
|
|
31
|
+
parsed = urlparse(self.base_url)
|
|
32
|
+
if parsed.scheme != "https" and parsed.hostname not in {"localhost", "127.0.0.1", "::1"}:
|
|
33
|
+
raise ValueError("AIT_TERMINAL_API_URL must use HTTPS (HTTP is allowed only for localhost).")
|
|
34
|
+
if not parsed.netloc:
|
|
35
|
+
raise ValueError("AIT_TERMINAL_API_URL must be an absolute URL.")
|
|
36
|
+
self._http = httpx.Client(base_url=self.base_url, timeout=timeout, transport=transport)
|
|
37
|
+
|
|
38
|
+
def ask(self, message: str, history: list[dict[str, str]]) -> str:
|
|
39
|
+
try:
|
|
40
|
+
result = self._http.post(
|
|
41
|
+
"/.netlify/functions/chat",
|
|
42
|
+
json={"message": message, "history": history},
|
|
43
|
+
)
|
|
44
|
+
except httpx.TimeoutException as exc:
|
|
45
|
+
raise ServiceError("The request timed out. Please try again.") from exc
|
|
46
|
+
except httpx.RequestError as exc:
|
|
47
|
+
raise ServiceError("Could not connect to AITerminal. Check your internet connection and try again.") from exc
|
|
48
|
+
if result.status_code == 401:
|
|
49
|
+
raise ServiceError("The request was not accepted.")
|
|
50
|
+
if result.status_code == 429:
|
|
51
|
+
raise ServiceError(self._error_message(result, "Too many requests. Please wait and try again."))
|
|
52
|
+
if result.status_code >= 500:
|
|
53
|
+
raise ServiceError(self._error_message(result, "AITerminal is temporarily unavailable."))
|
|
54
|
+
if result.status_code != 200:
|
|
55
|
+
raise ServiceError(self._error_message(result, "The request was rejected."))
|
|
56
|
+
try:
|
|
57
|
+
payload = result.json()
|
|
58
|
+
except ValueError as exc:
|
|
59
|
+
raise ServiceError("AITerminal returned an invalid response.") from exc
|
|
60
|
+
answer = payload.get("answer") if isinstance(payload, dict) else None
|
|
61
|
+
if not isinstance(answer, str) or not answer.strip():
|
|
62
|
+
raise ServiceError("AITerminal returned an invalid response.")
|
|
63
|
+
return answer
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def _error_message(result: httpx.Response, fallback: str) -> str:
|
|
67
|
+
try:
|
|
68
|
+
payload = result.json()
|
|
69
|
+
except ValueError:
|
|
70
|
+
return fallback
|
|
71
|
+
message = payload.get("error") if isinstance(payload, dict) else None
|
|
72
|
+
return message if isinstance(message, str) and message else fallback
|
|
73
|
+
|
|
74
|
+
def close(self) -> None:
|
|
75
|
+
self._http.close()
|
|
76
|
+
|
|
77
|
+
def __enter__(self) -> AITerminalClient:
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def __exit__(self, *_: object) -> None:
|
|
81
|
+
self.close()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Interactive AITerminal CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from .client import AITerminalClient, AITerminalError
|
|
8
|
+
from .terminal import ask_question, console, show_answer
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(add_completion=False, pretty_exceptions_enable=False)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_conversation(client: AITerminalClient) -> None:
|
|
14
|
+
history: list[dict[str, str]] = []
|
|
15
|
+
while True:
|
|
16
|
+
try:
|
|
17
|
+
question = ask_question().strip()
|
|
18
|
+
except (EOFError, KeyboardInterrupt):
|
|
19
|
+
console.print("\nGoodbye!")
|
|
20
|
+
return
|
|
21
|
+
command = question.lower()
|
|
22
|
+
if command in {"exit", "quit"}:
|
|
23
|
+
console.print("Goodbye!")
|
|
24
|
+
return
|
|
25
|
+
if command in {"clear", "/clear"}:
|
|
26
|
+
history.clear()
|
|
27
|
+
console.clear()
|
|
28
|
+
console.print("[bold]AITerminal ready![/bold]")
|
|
29
|
+
continue
|
|
30
|
+
if not question:
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
with console.status("Thinking...", spinner="dots"):
|
|
35
|
+
answer = client.ask(question, history)
|
|
36
|
+
except AITerminalError as exc:
|
|
37
|
+
console.print(f"[bold red]{exc}[/bold red]")
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
show_answer(answer)
|
|
41
|
+
history.extend(({"role": "user", "content": question}, {"role": "assistant", "content": answer}))
|
|
42
|
+
del history[:-20]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.callback(invoke_without_command=True)
|
|
46
|
+
def main() -> None:
|
|
47
|
+
"""Ask questions of the AITerminal cloud service."""
|
|
48
|
+
console.print("Connecting to AITerminal Cloud...")
|
|
49
|
+
try:
|
|
50
|
+
with AITerminalClient() as client:
|
|
51
|
+
console.print("[bold]AITerminal ready![/bold]")
|
|
52
|
+
run_conversation(client)
|
|
53
|
+
except KeyboardInterrupt:
|
|
54
|
+
console.print("\nGoodbye!")
|
|
55
|
+
except ValueError as exc:
|
|
56
|
+
console.print(f"[bold red]{exc}[/bold red]")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def run() -> None:
|
|
60
|
+
app()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
run()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Small Rich helpers for the interactive terminal."""
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.markdown import Markdown
|
|
5
|
+
|
|
6
|
+
console = Console()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def ask_question() -> str:
|
|
10
|
+
return console.input("[bold cyan]You> [/bold cyan]")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def show_answer(answer: str) -> None:
|
|
14
|
+
console.print("[bold green]AI>[/bold green]")
|
|
15
|
+
console.print(Markdown(answer))
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from aiterminal.client import AITerminalClient, DEFAULT_API_URL, ServiceError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def client_for(handler):
|
|
10
|
+
transport = httpx.MockTransport(handler)
|
|
11
|
+
return AITerminalClient("https://example.netlify.app", transport=transport)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_chat_sends_question_and_history_without_authentication():
|
|
15
|
+
seen = []
|
|
16
|
+
|
|
17
|
+
def handler(request):
|
|
18
|
+
seen.append(request)
|
|
19
|
+
assert request.url.path == "/.netlify/functions/chat"
|
|
20
|
+
assert "authorization" not in request.headers
|
|
21
|
+
assert json.loads(request.read()) == {
|
|
22
|
+
"message": "Follow up",
|
|
23
|
+
"history": [{"role": "user", "content": "Earlier"}],
|
|
24
|
+
}
|
|
25
|
+
return httpx.Response(200, json={"answer": "A response"})
|
|
26
|
+
|
|
27
|
+
with client_for(handler) as client:
|
|
28
|
+
assert client.ask("Follow up", [{"role": "user", "content": "Earlier"}]) == "A response"
|
|
29
|
+
|
|
30
|
+
assert len(seen) == 1
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_rate_limit_and_server_errors_are_reported():
|
|
34
|
+
with client_for(lambda _request: httpx.Response(429, json={"error": "Request limit reached."})) as client:
|
|
35
|
+
with pytest.raises(ServiceError, match="Request limit reached"):
|
|
36
|
+
client.ask("hello", [])
|
|
37
|
+
|
|
38
|
+
with client_for(lambda _request: httpx.Response(503, json={"error": "Shared rate limiting unavailable."})) as client:
|
|
39
|
+
with pytest.raises(ServiceError, match="Shared rate limiting unavailable"):
|
|
40
|
+
client.ask("hello", [])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_network_timeout_and_invalid_response_errors_are_reported():
|
|
44
|
+
def network_error(_request):
|
|
45
|
+
raise httpx.ConnectError("private connection detail")
|
|
46
|
+
|
|
47
|
+
with client_for(network_error) as client:
|
|
48
|
+
with pytest.raises(ServiceError, match="Could not connect"):
|
|
49
|
+
client.ask("hello", [])
|
|
50
|
+
|
|
51
|
+
def timeout(_request):
|
|
52
|
+
raise httpx.ReadTimeout("private timeout detail")
|
|
53
|
+
|
|
54
|
+
with client_for(timeout) as client:
|
|
55
|
+
with pytest.raises(ServiceError, match="timed out"):
|
|
56
|
+
client.ask("hello", [])
|
|
57
|
+
|
|
58
|
+
with client_for(lambda _request: httpx.Response(200, json={"unexpected": True})) as client:
|
|
59
|
+
with pytest.raises(ServiceError, match="invalid response"):
|
|
60
|
+
client.ask("hello", [])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_https_is_required_outside_local_development():
|
|
64
|
+
with pytest.raises(ValueError, match="HTTPS"):
|
|
65
|
+
AITerminalClient("http://api.example.com")
|
|
66
|
+
with AITerminalClient("http://localhost:8888"):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_default_backend_url_is_used_without_configuration(monkeypatch):
|
|
71
|
+
monkeypatch.delenv("AIT_TERMINAL_API_URL", raising=False)
|
|
72
|
+
seen = []
|
|
73
|
+
|
|
74
|
+
def handler(request):
|
|
75
|
+
seen.append(request)
|
|
76
|
+
assert request.url == f"{DEFAULT_API_URL}/.netlify/functions/chat"
|
|
77
|
+
assert "authorization" not in request.headers
|
|
78
|
+
return httpx.Response(200, json={"answer": "connected"})
|
|
79
|
+
|
|
80
|
+
with AITerminalClient(transport=httpx.MockTransport(handler)) as client:
|
|
81
|
+
assert client.base_url == "https://aiterminal-cloud.netlify.app"
|
|
82
|
+
assert client.ask("hello", []) == "connected"
|
|
83
|
+
assert len(seen) == 1
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from unittest.mock import patch
|
|
2
|
+
|
|
3
|
+
from typer.testing import CliRunner
|
|
4
|
+
|
|
5
|
+
from aiterminal import main as main_module
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FakeClient:
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self.history_seen = []
|
|
11
|
+
|
|
12
|
+
def __enter__(self):
|
|
13
|
+
return self
|
|
14
|
+
|
|
15
|
+
def __exit__(self, *_args):
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
def ask(self, question, history):
|
|
19
|
+
self.history_seen.append((question, list(history)))
|
|
20
|
+
return "answer"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_cli_starts_without_authentication():
|
|
24
|
+
client = FakeClient()
|
|
25
|
+
runner = CliRunner()
|
|
26
|
+
with (
|
|
27
|
+
patch.object(main_module, "AITerminalClient", return_value=client),
|
|
28
|
+
patch.object(main_module, "run_conversation") as conversation,
|
|
29
|
+
):
|
|
30
|
+
result = runner.invoke(main_module.app, [])
|
|
31
|
+
assert result.exit_code == 0
|
|
32
|
+
assert "Connecting to AITerminal Cloud..." in result.output
|
|
33
|
+
assert "AITerminal ready!" in result.output
|
|
34
|
+
assert "password" not in result.output.lower()
|
|
35
|
+
conversation.assert_called_once_with(client)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_conversation_exit_and_context_are_session_local():
|
|
39
|
+
client = FakeClient()
|
|
40
|
+
questions = iter(["first", "second", "exit"])
|
|
41
|
+
with (
|
|
42
|
+
patch.object(main_module, "ask_question", side_effect=lambda: next(questions)),
|
|
43
|
+
patch.object(main_module, "show_answer"),
|
|
44
|
+
patch.object(main_module.console, "status") as status,
|
|
45
|
+
patch.object(main_module.console, "print"),
|
|
46
|
+
):
|
|
47
|
+
status.return_value.__enter__.return_value = None
|
|
48
|
+
main_module.run_conversation(client)
|
|
49
|
+
assert client.history_seen[0] == ("first", [])
|
|
50
|
+
assert client.history_seen[1] == (
|
|
51
|
+
"second",
|
|
52
|
+
[{"role": "user", "content": "first"}, {"role": "assistant", "content": "answer"}],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_clear_command_resets_context_and_clears_screen():
|
|
57
|
+
client = FakeClient()
|
|
58
|
+
questions = iter(["first", "clear", "second", "quit"])
|
|
59
|
+
with (
|
|
60
|
+
patch.object(main_module, "ask_question", side_effect=lambda: next(questions)),
|
|
61
|
+
patch.object(main_module, "show_answer"),
|
|
62
|
+
patch.object(main_module.console, "status") as status,
|
|
63
|
+
patch.object(main_module.console, "clear") as clear_screen,
|
|
64
|
+
patch.object(main_module.console, "print"),
|
|
65
|
+
):
|
|
66
|
+
status.return_value.__enter__.return_value = None
|
|
67
|
+
main_module.run_conversation(client)
|
|
68
|
+
assert client.history_seen[0] == ("first", [])
|
|
69
|
+
assert client.history_seen[1] == ("second", [])
|
|
70
|
+
clear_screen.assert_called_once_with()
|