askai-py 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.
askai_py-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Peconi Federico
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: askai-py
3
+ Version: 0.1.0
4
+ Summary: CLI for one-shot, stateless questions to an LLM.
5
+ Author: Peconi Federico
6
+ Author-email: Peconi Federico <fpswe@tuta.io>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Dist: platformdirs>=4.9.6
10
+ Requires-Dist: typer>=0.24.1
11
+ Requires-Python: >=3.12
12
+ Project-URL: Homepage, https://codeberg.org/peconif/askai
13
+ Project-URL: Repository, https://codeberg.org/peconif/askai
14
+ Project-URL: Issues, https://codeberg.org/peconif/askai/issues
15
+ Project-URL: GitHub Mirror, https://github.com/arbiter1elegantiae/askai
16
+ Description-Content-Type: text/markdown
17
+
18
+ # Askai
19
+
20
+ > Development happens on [Codeberg](https://codeberg.org/peconif/askai).
21
+ > GitHub is a read-only mirror for discovery — please open issues and pull requests on Codeberg.
22
+
23
+ CLI for one-shot, stateless questions to an LLM.
24
+
25
+ Meant for engineers living inside terminals, `askai` aims to fill those small information gaps that typically pop up while working, such as:
26
+ - commands syntax / flag names;
27
+ - tiny code examples;
28
+ - quick conceptual clarifications.
29
+ Instead of wasting time switching to a browser or a dedicated AI app, simply:
30
+ ```bash
31
+ askai "What does set -euo pipefail do?"
32
+ ```
33
+ will return a concise, fluff-free answer from your favorite model, minimizing distractions and speeding up your workflow.
34
+
35
+ ## Installation
36
+
37
+ Install with [`pipx`](https://pypa.github.io/pipx/) (recommended):
38
+
39
+ ```bash
40
+ pipx install askai-py
41
+ ```
42
+
43
+ Or with [`uv`](https://docs.astral.sh/uv/):
44
+
45
+ ```bash
46
+ uv tool install askai-py
47
+ ```
48
+
49
+ Then set your Groq API key (Groq is the default backend):
50
+
51
+ ```bash
52
+ export GROQ_API_KEY="your-key"
53
+ askai "How to rename a branch in Git?"
54
+ ```
55
+
56
+ ## Backends
57
+
58
+ askai supports two backends:
59
+
60
+ - **`groq`** (default) — API adapter calling Groq's OpenAI-compatible endpoint. Requires `GROQ_API_KEY`.
61
+ - **`codex`** — CLI adapter wrapping the Codex CLI. Requires a local Codex installation.
62
+
63
+ Switch backend:
64
+
65
+ ```bash
66
+ askai config set backend codex
67
+ askai doctor
68
+ ```
69
+
70
+ ### Adding a Backend
71
+
72
+ Contributions for new backends are welcome. To add one, you need to:
73
+
74
+ 1. Implement the `BackendAdapter` protocol in `src/askai/backends/`
75
+ 2. Register it in `build_backend_adapters()` (`src/askai/backends/__init__.py`)
76
+ 3. Add the backend name to `KNOWN_BACKENDS` (`src/askai/config.py`)
77
+ 4. Add tests under `tests/`
78
+ 5. Document it in `docs/CLI_CONTRACT.md`
79
+
80
+ ## Corporate VPN / Proxy (Self-Signed Certificates)
81
+
82
+ If you are behind a corporate VPN or intercepting proxy, set `ASKAI_CA_BUNDLE` to your company's CA certificate so askai trusts the TLS connection:
83
+
84
+ ```bash
85
+ export ASKAI_CA_BUNDLE=/path/to/company-ca.pem
86
+ askai "List Tmux useful commands"
87
+ ```
88
+
89
+ This adds your CA as a trust root alongside the system defaults, so normal HTTPS still works.
@@ -0,0 +1,72 @@
1
+ # Askai
2
+
3
+ > Development happens on [Codeberg](https://codeberg.org/peconif/askai).
4
+ > GitHub is a read-only mirror for discovery — please open issues and pull requests on Codeberg.
5
+
6
+ CLI for one-shot, stateless questions to an LLM.
7
+
8
+ Meant for engineers living inside terminals, `askai` aims to fill those small information gaps that typically pop up while working, such as:
9
+ - commands syntax / flag names;
10
+ - tiny code examples;
11
+ - quick conceptual clarifications.
12
+ Instead of wasting time switching to a browser or a dedicated AI app, simply:
13
+ ```bash
14
+ askai "What does set -euo pipefail do?"
15
+ ```
16
+ will return a concise, fluff-free answer from your favorite model, minimizing distractions and speeding up your workflow.
17
+
18
+ ## Installation
19
+
20
+ Install with [`pipx`](https://pypa.github.io/pipx/) (recommended):
21
+
22
+ ```bash
23
+ pipx install askai-py
24
+ ```
25
+
26
+ Or with [`uv`](https://docs.astral.sh/uv/):
27
+
28
+ ```bash
29
+ uv tool install askai-py
30
+ ```
31
+
32
+ Then set your Groq API key (Groq is the default backend):
33
+
34
+ ```bash
35
+ export GROQ_API_KEY="your-key"
36
+ askai "How to rename a branch in Git?"
37
+ ```
38
+
39
+ ## Backends
40
+
41
+ askai supports two backends:
42
+
43
+ - **`groq`** (default) — API adapter calling Groq's OpenAI-compatible endpoint. Requires `GROQ_API_KEY`.
44
+ - **`codex`** — CLI adapter wrapping the Codex CLI. Requires a local Codex installation.
45
+
46
+ Switch backend:
47
+
48
+ ```bash
49
+ askai config set backend codex
50
+ askai doctor
51
+ ```
52
+
53
+ ### Adding a Backend
54
+
55
+ Contributions for new backends are welcome. To add one, you need to:
56
+
57
+ 1. Implement the `BackendAdapter` protocol in `src/askai/backends/`
58
+ 2. Register it in `build_backend_adapters()` (`src/askai/backends/__init__.py`)
59
+ 3. Add the backend name to `KNOWN_BACKENDS` (`src/askai/config.py`)
60
+ 4. Add tests under `tests/`
61
+ 5. Document it in `docs/CLI_CONTRACT.md`
62
+
63
+ ## Corporate VPN / Proxy (Self-Signed Certificates)
64
+
65
+ If you are behind a corporate VPN or intercepting proxy, set `ASKAI_CA_BUNDLE` to your company's CA certificate so askai trusts the TLS connection:
66
+
67
+ ```bash
68
+ export ASKAI_CA_BUNDLE=/path/to/company-ca.pem
69
+ askai "List Tmux useful commands"
70
+ ```
71
+
72
+ This adds your CA as a trust root alongside the system defaults, so normal HTTPS still works.
@@ -0,0 +1,55 @@
1
+ [project]
2
+ name = "askai-py"
3
+ version = "0.1.0"
4
+ description = "CLI for one-shot, stateless questions to an LLM."
5
+ authors = [
6
+ {name = "Peconi Federico", email = "fpswe@tuta.io"},
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "platformdirs>=4.9.6",
12
+ "typer>=0.24.1",
13
+ ]
14
+ license = "MIT"
15
+ license-files = ["LICENSE"]
16
+
17
+ [project.urls]
18
+ Homepage = "https://codeberg.org/peconif/askai"
19
+ Repository = "https://codeberg.org/peconif/askai"
20
+ Issues = "https://codeberg.org/peconif/askai/issues"
21
+ "GitHub Mirror" = "https://github.com/arbiter1elegantiae/askai"
22
+
23
+ [project.scripts]
24
+ askai = "askai.cli:run"
25
+
26
+ [build-system]
27
+ requires = ["uv_build>=0.11.8,<0.12"]
28
+ build-backend = "uv_build"
29
+
30
+ [tool.uv.build-backend]
31
+ module-name = "askai"
32
+
33
+ [dependency-groups]
34
+ dev = [
35
+ "pytest>=9.0.3",
36
+ "ruff>=0.15.11",
37
+ "ty>=0.0.32",
38
+ ]
39
+
40
+ [tool.ruff]
41
+ target-version = "py312"
42
+ line-length = 88
43
+ src = ["src"]
44
+
45
+ [tool.ruff.lint]
46
+ select = ["E4", "E7", "E9", "F", "I", "UP", "B"]
47
+
48
+ [tool.ty.environment]
49
+ python-version = "3.12"
50
+ root = ["./src"]
51
+
52
+ [tool.ty.src]
53
+ include = ["src", "tests"]
54
+ [tool.ty.terminal]
55
+ output-format = "concise"
File without changes
@@ -0,0 +1,8 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("askai-py")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = ["__version__"]
@@ -0,0 +1,3 @@
1
+ from askai.cli import run
2
+
3
+ run()
@@ -0,0 +1,152 @@
1
+ from collections.abc import Mapping
2
+ from dataclasses import dataclass
3
+ from typing import Protocol
4
+
5
+ from askai.policy import AskaiPrompt
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class BackendRequest:
10
+ prompt: AskaiPrompt
11
+ model: str | None = None
12
+ max_lines: int | None = None
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class BackendResponse:
17
+ raw_output: str
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class BackendAvailability:
22
+ available: bool
23
+ executable: str | None = None
24
+ message: str | None = None
25
+
26
+
27
+ class BackendAdapter(Protocol):
28
+ name: str
29
+
30
+ def check_availability(self) -> BackendAvailability: ...
31
+
32
+ def execute(self, request: BackendRequest) -> BackendResponse: ...
33
+
34
+
35
+ class BackendError(Exception):
36
+ pass
37
+
38
+
39
+ class BackendNotConfiguredError(BackendError):
40
+ def __init__(self) -> None:
41
+ super().__init__(
42
+ "Backend is not configured. Run `askai config set backend BACKEND`."
43
+ )
44
+
45
+
46
+ class BackendExecutableNotFoundError(BackendError):
47
+ def __init__(
48
+ self,
49
+ backend: str,
50
+ *,
51
+ executable: str | None = None,
52
+ detail: str | None = None,
53
+ ) -> None:
54
+ target = (
55
+ f"Backend executable '{executable}' for backend '{backend}'"
56
+ if executable is not None
57
+ else f"Backend executable for backend '{backend}'"
58
+ )
59
+ detail_text = f": {detail}" if detail else ""
60
+ super().__init__(f"{target} is unavailable{detail_text}. Run `askai doctor`.")
61
+
62
+
63
+ class BackendCredentialsNotFoundError(BackendError):
64
+ def __init__(self, backend: str, *, detail: str | None = None) -> None:
65
+ detail_text = f": {detail}" if detail else ""
66
+ super().__init__(
67
+ f"Backend '{backend}' credentials are missing or rejected{detail_text}."
68
+ )
69
+
70
+
71
+ class BackendUnsupportedModelError(BackendError):
72
+ def __init__(self, backend: str, model: str | None = None) -> None:
73
+ if model is None:
74
+ message = f"Backend '{backend}' does not support model overrides."
75
+ else:
76
+ message = f"Backend '{backend}' does not support model override '{model}'."
77
+
78
+ super().__init__(message)
79
+
80
+
81
+ class BackendTimeoutError(BackendError):
82
+ def __init__(self, backend: str) -> None:
83
+ super().__init__(f"Backend '{backend}' timed out.")
84
+
85
+
86
+ class BackendInvocationError(BackendError):
87
+ def __init__(self, backend: str, detail: str | None = None) -> None:
88
+ detail_text = f": {detail}" if detail else ""
89
+ super().__init__(f"Backend '{backend}' invocation failed{detail_text}.")
90
+
91
+
92
+ def build_backend_adapters() -> dict[str, BackendAdapter]:
93
+ from askai.backends.codex import CodexBackendAdapter
94
+ from askai.backends.groq import GroqBackendAdapter
95
+
96
+ codex_adapter = CodexBackendAdapter()
97
+ groq_adapter = GroqBackendAdapter()
98
+ return {
99
+ codex_adapter.name: codex_adapter,
100
+ groq_adapter.name: groq_adapter,
101
+ }
102
+
103
+
104
+ BACKEND_ADAPTERS: dict[str, BackendAdapter] = build_backend_adapters()
105
+
106
+
107
+ def resolve_backend_adapter(
108
+ backend: str | None,
109
+ *,
110
+ adapters: Mapping[str, BackendAdapter] | None = None,
111
+ ) -> BackendAdapter:
112
+ if backend is None:
113
+ raise BackendNotConfiguredError()
114
+
115
+ adapter_registry = BACKEND_ADAPTERS if adapters is None else adapters
116
+ adapter = adapter_registry.get(backend)
117
+ if adapter is None:
118
+ raise BackendInvocationError(backend, "no adapter is registered")
119
+
120
+ return adapter
121
+
122
+
123
+ def check_backend_availability(
124
+ backend: str | None,
125
+ *,
126
+ adapters: Mapping[str, BackendAdapter] | None = None,
127
+ ) -> BackendAvailability:
128
+ adapter = resolve_backend_adapter(backend, adapters=adapters)
129
+ return adapter.check_availability()
130
+
131
+
132
+ def execute_backend(
133
+ backend: str | None,
134
+ request: BackendRequest,
135
+ *,
136
+ adapters: Mapping[str, BackendAdapter] | None = None,
137
+ ) -> BackendResponse:
138
+ adapter = resolve_backend_adapter(backend, adapters=adapters)
139
+ availability = adapter.check_availability()
140
+ if not availability.available:
141
+ if availability.executable is not None:
142
+ raise BackendExecutableNotFoundError(
143
+ backend or adapter.name,
144
+ executable=availability.executable,
145
+ detail=availability.message,
146
+ )
147
+ raise BackendCredentialsNotFoundError(
148
+ backend or adapter.name,
149
+ detail=availability.message,
150
+ )
151
+
152
+ return adapter.execute(request)
@@ -0,0 +1,156 @@
1
+ import shutil
2
+ import subprocess
3
+ import tempfile
4
+ from pathlib import Path
5
+
6
+ from askai.backends import (
7
+ BackendAvailability,
8
+ BackendExecutableNotFoundError,
9
+ BackendInvocationError,
10
+ BackendRequest,
11
+ BackendResponse,
12
+ BackendTimeoutError,
13
+ )
14
+
15
+ CODEX_BACKEND_NAME = "codex"
16
+ CODEX_EXECUTABLE = "codex"
17
+ CODEX_TIMEOUT_SECONDS = 60
18
+
19
+
20
+ class CodexBackendAdapter:
21
+ name = CODEX_BACKEND_NAME
22
+
23
+ def __init__(
24
+ self,
25
+ *,
26
+ executable: str = CODEX_EXECUTABLE,
27
+ timeout_seconds: int = CODEX_TIMEOUT_SECONDS,
28
+ ) -> None:
29
+ self.executable = executable
30
+ self.timeout_seconds = timeout_seconds
31
+
32
+ def check_availability(self) -> BackendAvailability:
33
+ executable_path = shutil.which(self.executable)
34
+ if executable_path is None:
35
+ return BackendAvailability(
36
+ available=False,
37
+ executable=self.executable,
38
+ message=f"{self.executable} executable not found on PATH",
39
+ )
40
+
41
+ return BackendAvailability(available=True, executable=executable_path)
42
+
43
+ def execute(self, request: BackendRequest) -> BackendResponse:
44
+ availability = self.check_availability()
45
+ if not availability.available:
46
+ raise BackendExecutableNotFoundError(
47
+ self.name,
48
+ executable=availability.executable,
49
+ detail=availability.message,
50
+ )
51
+
52
+ executable_path = availability.executable or self.executable
53
+ with tempfile.TemporaryDirectory(prefix="askai-codex-") as temp_dir:
54
+ answer_path = Path(temp_dir) / "answer.txt"
55
+ command = build_codex_command(
56
+ executable_path,
57
+ work_dir=temp_dir,
58
+ answer_path=answer_path,
59
+ model=request.model,
60
+ )
61
+ run_codex_command(
62
+ command,
63
+ prompt=request.prompt.render_for_single_prompt(),
64
+ work_dir=temp_dir,
65
+ timeout_seconds=self.timeout_seconds,
66
+ )
67
+
68
+ return BackendResponse(raw_output=read_codex_answer(answer_path))
69
+
70
+
71
+ def build_codex_command(
72
+ executable: str,
73
+ *,
74
+ work_dir: str,
75
+ answer_path: Path,
76
+ model: str | None,
77
+ ) -> list[str]:
78
+ command = [
79
+ executable,
80
+ "exec",
81
+ "--cd",
82
+ work_dir,
83
+ "--skip-git-repo-check",
84
+ "--ephemeral",
85
+ "--ignore-rules",
86
+ "--ignore-user-config",
87
+ "--sandbox",
88
+ "read-only",
89
+ "--color",
90
+ "never",
91
+ ]
92
+
93
+ if model is not None:
94
+ command.extend(["--model", model])
95
+
96
+ command.extend(["--output-last-message", str(answer_path), "-"])
97
+ return command
98
+
99
+
100
+ def run_codex_command(
101
+ command: list[str],
102
+ *,
103
+ prompt: str,
104
+ work_dir: str,
105
+ timeout_seconds: int,
106
+ ) -> None:
107
+ try:
108
+ completed_process = subprocess.run(
109
+ command,
110
+ input=prompt,
111
+ capture_output=True,
112
+ text=True,
113
+ shell=False,
114
+ cwd=work_dir,
115
+ timeout=timeout_seconds,
116
+ check=False,
117
+ )
118
+ except subprocess.TimeoutExpired as error:
119
+ raise BackendTimeoutError(CODEX_BACKEND_NAME) from error
120
+ except OSError as error:
121
+ raise BackendInvocationError(CODEX_BACKEND_NAME, str(error)) from error
122
+
123
+ if completed_process.returncode != 0:
124
+ detail = summarize_process_failure(
125
+ completed_process.stderr,
126
+ completed_process.stdout,
127
+ )
128
+ raise BackendInvocationError(CODEX_BACKEND_NAME, detail)
129
+
130
+
131
+ def summarize_process_failure(stderr: str, stdout: str) -> str:
132
+ for output in (stderr, stdout):
133
+ detail = first_non_empty_line(output)
134
+ if detail:
135
+ return detail
136
+
137
+ return "process exited with no diagnostic output"
138
+
139
+
140
+ def first_non_empty_line(value: str) -> str | None:
141
+ for line in value.splitlines():
142
+ detail = line.strip()
143
+ if detail:
144
+ return detail
145
+
146
+ return None
147
+
148
+
149
+ def read_codex_answer(answer_path: Path) -> str:
150
+ try:
151
+ return answer_path.read_text(encoding="utf-8")
152
+ except OSError as error:
153
+ raise BackendInvocationError(
154
+ CODEX_BACKEND_NAME,
155
+ f"could not read final answer file: {error}",
156
+ ) from error