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.
@@ -0,0 +1,16 @@
1
+ .env
2
+ .env.*
3
+ !.env.example
4
+ .netlify
5
+ node_modules/
6
+ .venv
7
+ test-env/
8
+ venv
9
+ __pycache__/
10
+ .pytest_cache/
11
+ *.py[cod]
12
+ build/
13
+ dist/
14
+ *.egg-info/
15
+ .coverage
16
+ htmlcov/
@@ -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,4 @@
1
+ """AITerminal command-line client."""
2
+
3
+ __version__ = "0.1.0"
4
+
@@ -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()