askai-py 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.
askai/.gitkeep ADDED
File without changes
askai/__init__.py ADDED
@@ -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__"]
askai/__main__.py ADDED
@@ -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
askai/backends/groq.py ADDED
@@ -0,0 +1,237 @@
1
+ import json
2
+ import os
3
+ import ssl
4
+ import urllib.error
5
+ import urllib.request
6
+ from typing import Any, cast
7
+
8
+ from askai import __version__
9
+ from askai.backends import (
10
+ BackendAvailability,
11
+ BackendCredentialsNotFoundError,
12
+ BackendError,
13
+ BackendInvocationError,
14
+ BackendRequest,
15
+ BackendResponse,
16
+ BackendTimeoutError,
17
+ )
18
+ from askai.policy import AskaiPrompt
19
+
20
+ GROQ_BACKEND_NAME = "groq"
21
+ GROQ_API_KEY_ENV = "GROQ_API_KEY"
22
+ GROQ_ENDPOINT = "https://api.groq.com/openai/v1/chat/completions"
23
+ GROQ_DEFAULT_MODEL = "llama-3.1-8b-instant"
24
+ GROQ_TIMEOUT_SECONDS = 30
25
+ GROQ_TEMPERATURE = 0.2
26
+ GROQ_TOKENS_PER_LINE = 32
27
+ GROQ_FALLBACK_MAX_LINES = 6
28
+ ASKAI_CA_BUNDLE_ENV = "ASKAI_CA_BUNDLE"
29
+
30
+
31
+ class GroqBackendAdapter:
32
+ name = GROQ_BACKEND_NAME
33
+
34
+ def __init__(
35
+ self,
36
+ *,
37
+ endpoint: str = GROQ_ENDPOINT,
38
+ timeout_seconds: int = GROQ_TIMEOUT_SECONDS,
39
+ ) -> None:
40
+ self.endpoint = endpoint
41
+ self.timeout_seconds = timeout_seconds
42
+
43
+ def check_availability(self) -> BackendAvailability:
44
+ if not os.environ.get(GROQ_API_KEY_ENV):
45
+ return BackendAvailability(
46
+ available=False,
47
+ message=f"{GROQ_API_KEY_ENV} environment variable is not set",
48
+ )
49
+
50
+ return BackendAvailability(available=True)
51
+
52
+ def execute(self, request: BackendRequest) -> BackendResponse:
53
+ api_key = os.environ.get(GROQ_API_KEY_ENV)
54
+ if not api_key:
55
+ raise BackendCredentialsNotFoundError(
56
+ GROQ_BACKEND_NAME,
57
+ detail=f"{GROQ_API_KEY_ENV} environment variable is not set",
58
+ )
59
+
60
+ model = request.model if request.model is not None else GROQ_DEFAULT_MODEL
61
+ max_tokens = estimate_max_tokens(request.max_lines)
62
+ payload = build_groq_payload(
63
+ prompt=request.prompt,
64
+ model=model,
65
+ max_tokens=max_tokens,
66
+ )
67
+
68
+ response_body = send_groq_request(
69
+ self.endpoint,
70
+ api_key=api_key,
71
+ payload=payload,
72
+ timeout_seconds=self.timeout_seconds,
73
+ )
74
+
75
+ return BackendResponse(raw_output=extract_groq_answer(response_body))
76
+
77
+
78
+ def estimate_max_tokens(max_lines: int | None) -> int:
79
+ effective_lines = max_lines if max_lines is not None else GROQ_FALLBACK_MAX_LINES
80
+ return effective_lines * GROQ_TOKENS_PER_LINE
81
+
82
+
83
+ def build_groq_payload(
84
+ *,
85
+ prompt: AskaiPrompt,
86
+ model: str,
87
+ max_tokens: int,
88
+ ) -> dict[str, Any]:
89
+ return {
90
+ "model": model,
91
+ "messages": [
92
+ {"role": "system", "content": prompt.system},
93
+ {"role": "user", "content": prompt.user},
94
+ ],
95
+ "max_tokens": max_tokens,
96
+ "temperature": GROQ_TEMPERATURE,
97
+ }
98
+
99
+
100
+ def build_ssl_context() -> ssl.SSLContext | None:
101
+ ca_bundle = os.environ.get(ASKAI_CA_BUNDLE_ENV)
102
+ if not ca_bundle:
103
+ return None
104
+
105
+ context = ssl.create_default_context()
106
+ context.load_verify_locations(cafile=ca_bundle)
107
+ return context
108
+
109
+
110
+ def send_groq_request(
111
+ endpoint: str,
112
+ *,
113
+ api_key: str,
114
+ payload: dict[str, Any],
115
+ timeout_seconds: int,
116
+ ) -> dict[str, Any]:
117
+ body = json.dumps(payload).encode("utf-8")
118
+ request = urllib.request.Request(
119
+ endpoint,
120
+ data=body,
121
+ headers={
122
+ "Authorization": f"Bearer {api_key}",
123
+ "Content-Type": "application/json",
124
+ "User-Agent": f"askai/{__version__}",
125
+ },
126
+ method="POST",
127
+ )
128
+
129
+ ssl_context = build_ssl_context()
130
+
131
+ try:
132
+ with urllib.request.urlopen(
133
+ request, timeout=timeout_seconds, context=ssl_context
134
+ ) as response:
135
+ response_text = response.read().decode("utf-8")
136
+ except urllib.error.HTTPError as error:
137
+ error_text = error.read().decode("utf-8", errors="replace")
138
+ raise map_groq_http_error(error.code, error_text) from error
139
+ except TimeoutError as error:
140
+ raise BackendTimeoutError(GROQ_BACKEND_NAME) from error
141
+ except urllib.error.URLError as error:
142
+ if isinstance(error.reason, TimeoutError):
143
+ raise BackendTimeoutError(GROQ_BACKEND_NAME) from error
144
+ raise BackendInvocationError(
145
+ GROQ_BACKEND_NAME,
146
+ str(error.reason),
147
+ ) from error
148
+
149
+ try:
150
+ response_data: Any = json.loads(response_text)
151
+ except json.JSONDecodeError as error:
152
+ raise BackendInvocationError(
153
+ GROQ_BACKEND_NAME,
154
+ f"could not parse response: {error}",
155
+ ) from error
156
+
157
+ if not isinstance(response_data, dict):
158
+ raise BackendInvocationError(
159
+ GROQ_BACKEND_NAME,
160
+ "response was not a JSON object",
161
+ )
162
+
163
+ return cast(dict[str, Any], response_data)
164
+
165
+
166
+ def map_groq_http_error(status: int, body: str) -> BackendError:
167
+ detail = extract_groq_error_message(body)
168
+
169
+ if status in (401, 403):
170
+ return BackendCredentialsNotFoundError(
171
+ GROQ_BACKEND_NAME,
172
+ detail="API key was rejected by Groq",
173
+ )
174
+ if status == 429:
175
+ return BackendInvocationError(
176
+ GROQ_BACKEND_NAME,
177
+ detail="rate limit reached, retry shortly",
178
+ )
179
+ if detail:
180
+ return BackendInvocationError(
181
+ GROQ_BACKEND_NAME,
182
+ detail=f"HTTP {status}: {detail}",
183
+ )
184
+ return BackendInvocationError(GROQ_BACKEND_NAME, detail=f"HTTP {status}")
185
+
186
+
187
+ def extract_groq_error_message(body: str) -> str | None:
188
+ try:
189
+ data = json.loads(body)
190
+ except json.JSONDecodeError:
191
+ return None
192
+
193
+ error = data.get("error")
194
+ if isinstance(error, dict):
195
+ message = error.get("message")
196
+ if isinstance(message, str):
197
+ return message
198
+
199
+ return None
200
+
201
+
202
+ def extract_groq_answer(response_body: dict[str, Any]) -> str:
203
+ choices = response_body.get("choices")
204
+ if not isinstance(choices, list) or not choices:
205
+ raise BackendInvocationError(
206
+ GROQ_BACKEND_NAME,
207
+ "response contained no choices",
208
+ )
209
+
210
+ first_choice = choices[0]
211
+ if not isinstance(first_choice, dict):
212
+ raise BackendInvocationError(
213
+ GROQ_BACKEND_NAME,
214
+ "malformed response choice",
215
+ )
216
+
217
+ if first_choice.get("finish_reason") == "length":
218
+ raise BackendInvocationError(
219
+ GROQ_BACKEND_NAME,
220
+ "response hit token limit before completing",
221
+ )
222
+
223
+ message = first_choice.get("message")
224
+ if not isinstance(message, dict):
225
+ raise BackendInvocationError(
226
+ GROQ_BACKEND_NAME,
227
+ "malformed response message",
228
+ )
229
+
230
+ content = message.get("content")
231
+ if not isinstance(content, str):
232
+ raise BackendInvocationError(
233
+ GROQ_BACKEND_NAME,
234
+ "response had no text content",
235
+ )
236
+
237
+ return content
askai/cli.py ADDED
@@ -0,0 +1,332 @@
1
+ import sys
2
+ from collections.abc import Sequence
3
+ from typing import Annotated, Never
4
+
5
+ import typer
6
+
7
+ from askai import __version__
8
+ from askai.backends import (
9
+ BACKEND_ADAPTERS,
10
+ BackendAvailability,
11
+ BackendError,
12
+ BackendRequest,
13
+ check_backend_availability,
14
+ execute_backend,
15
+ )
16
+ from askai.config import (
17
+ MAX_ANSWER_LINES,
18
+ ConfigError,
19
+ get_effective_config,
20
+ set_backend,
21
+ set_max_lines,
22
+ set_model,
23
+ validate_backend,
24
+ validate_max_lines,
25
+ validate_model,
26
+ )
27
+ from askai.policy import build_prompt, normalize_output
28
+
29
+ EXIT_RUNTIME_ERROR = 1
30
+ EXIT_USAGE_ERROR = 2
31
+
32
+ app = typer.Typer(
33
+ help="Ask one-shot, stateless questions to an LLM from the terminal.",
34
+ invoke_without_command=True,
35
+ no_args_is_help=False,
36
+ )
37
+ config_app = typer.Typer(help="Inspect and update local askai configuration.")
38
+ config_set_app = typer.Typer(help="Set local askai configuration values.")
39
+
40
+ app.add_typer(config_app, name="config")
41
+ config_app.add_typer(config_set_app, name="set")
42
+
43
+ ROOT_PASSTHROUGH_ARGS = {
44
+ "--help",
45
+ "-h",
46
+ "--version",
47
+ "--install-completion",
48
+ "--show-completion",
49
+ "config",
50
+ "doctor",
51
+ }
52
+
53
+
54
+ def _fail(message: str, code: int = EXIT_RUNTIME_ERROR) -> Never:
55
+ typer.echo(f"Error: {message}", err=True)
56
+ raise typer.Exit(code)
57
+
58
+
59
+ def _validate_max_lines(value: int, name: str) -> None:
60
+ try:
61
+ validate_max_lines(value)
62
+ except ConfigError:
63
+ _fail(
64
+ f"{name} must be between 1 and {MAX_ANSWER_LINES}.",
65
+ EXIT_USAGE_ERROR,
66
+ )
67
+
68
+
69
+ def _fail_config_error(error: ConfigError, code: int = EXIT_RUNTIME_ERROR) -> Never:
70
+ _fail(str(error), code)
71
+
72
+
73
+ def _fail_backend_error(error: BackendError) -> Never:
74
+ _fail(str(error), EXIT_RUNTIME_ERROR)
75
+
76
+
77
+ def _validate_backend_option(backend: str | None) -> None:
78
+ if backend is None:
79
+ return
80
+
81
+ try:
82
+ validate_backend(backend)
83
+ except ConfigError as error:
84
+ _fail_config_error(error)
85
+
86
+
87
+ def _validate_model_option(model: str | None) -> None:
88
+ if model is None:
89
+ return
90
+
91
+ try:
92
+ validate_model(model)
93
+ except ConfigError:
94
+ _fail("--model cannot be empty.", EXIT_USAGE_ERROR)
95
+
96
+
97
+ def _validate_runtime_overrides(
98
+ *,
99
+ backend: str | None,
100
+ model: str | None,
101
+ max_lines: int | None,
102
+ ) -> None:
103
+ _validate_backend_option(backend)
104
+ _validate_model_option(model)
105
+
106
+ if max_lines is not None:
107
+ _validate_max_lines(max_lines, "--max-lines")
108
+
109
+
110
+ def build_typer_args(argv: Sequence[str]) -> list[str]:
111
+ args = list(argv)
112
+ if args and args[0] in ROOT_PASSTHROUGH_ARGS:
113
+ return args
114
+
115
+ return ["query", *args]
116
+
117
+
118
+ @app.callback()
119
+ def main(
120
+ backend: Annotated[
121
+ str | None,
122
+ typer.Option("--backend", help="Override the configured backend."),
123
+ ] = None,
124
+ model: Annotated[
125
+ str | None,
126
+ typer.Option("--model", help="Override the configured model."),
127
+ ] = None,
128
+ max_lines: Annotated[
129
+ int | None,
130
+ typer.Option("--max-lines", help="Override the maximum answer lines."),
131
+ ] = None,
132
+ show_version: Annotated[
133
+ bool,
134
+ typer.Option(
135
+ "--version",
136
+ help="Print the installed askai version.",
137
+ is_eager=True,
138
+ ),
139
+ ] = False,
140
+ ) -> None:
141
+ del backend, model, max_lines
142
+
143
+ if show_version:
144
+ typer.echo(f"askai {__version__}")
145
+ raise typer.Exit()
146
+
147
+
148
+ @app.command("query", hidden=True)
149
+ def query(
150
+ question: Annotated[str, typer.Argument(help="One-shot question to ask.")],
151
+ backend: Annotated[
152
+ str | None,
153
+ typer.Option("--backend", help="Override the configured backend."),
154
+ ] = None,
155
+ model: Annotated[
156
+ str | None,
157
+ typer.Option("--model", help="Override the configured model."),
158
+ ] = None,
159
+ max_lines: Annotated[
160
+ int | None,
161
+ typer.Option("--max-lines", help="Override the maximum answer lines."),
162
+ ] = None,
163
+ ) -> None:
164
+ question = question.strip()
165
+ if not question:
166
+ _fail("The question cannot be empty.", EXIT_USAGE_ERROR)
167
+
168
+ _validate_runtime_overrides(
169
+ backend=backend,
170
+ model=model,
171
+ max_lines=max_lines,
172
+ )
173
+
174
+ try:
175
+ _effective_config = get_effective_config(
176
+ backend=backend,
177
+ model=model,
178
+ max_lines=max_lines,
179
+ )
180
+ except ConfigError as error:
181
+ _fail_config_error(error)
182
+
183
+ prompt = build_prompt(question, max_lines=_effective_config.max_lines)
184
+ request = BackendRequest(
185
+ prompt=prompt,
186
+ model=_effective_config.model,
187
+ max_lines=_effective_config.max_lines,
188
+ )
189
+
190
+ try:
191
+ response = execute_backend(
192
+ _effective_config.backend,
193
+ request,
194
+ adapters=BACKEND_ADAPTERS,
195
+ )
196
+ except BackendError as error:
197
+ _fail_backend_error(error)
198
+
199
+ answer = normalize_output(response.raw_output, _effective_config.max_lines)
200
+ if answer:
201
+ typer.echo(answer)
202
+
203
+
204
+ def _format_backend_availability_status(
205
+ availability: BackendAvailability | None,
206
+ ) -> str:
207
+ if availability is None:
208
+ return "not checked"
209
+
210
+ if availability.available:
211
+ return availability.executable or "available"
212
+
213
+ if availability.executable:
214
+ return f"{availability.executable} unavailable"
215
+
216
+ return "unavailable"
217
+
218
+
219
+ def _format_backend_availability_problem(
220
+ availability: BackendAvailability,
221
+ ) -> str:
222
+ if availability.message:
223
+ return f"backend is unavailable: {availability.message}"
224
+
225
+ return "backend is unavailable"
226
+
227
+
228
+ @config_app.command("show")
229
+ def config_show() -> None:
230
+ try:
231
+ effective_config = get_effective_config()
232
+ except ConfigError as error:
233
+ _fail_config_error(error)
234
+
235
+ typer.echo(f"config file: {effective_config.config_path}")
236
+ typer.echo(f"backend: {effective_config.backend or 'not configured'}")
237
+ typer.echo(f"model: {effective_config.model or 'backend default'}")
238
+ typer.echo(f"max lines: {effective_config.max_lines}")
239
+
240
+
241
+ @config_set_app.command("backend")
242
+ def config_set_backend(
243
+ backend: Annotated[str, typer.Argument(help="Backend name to use by default.")],
244
+ ) -> None:
245
+ backend = backend.strip()
246
+ if not backend:
247
+ _fail("backend cannot be empty.", EXIT_USAGE_ERROR)
248
+
249
+ try:
250
+ config_path = set_backend(backend)
251
+ except ConfigError as error:
252
+ _fail_config_error(error)
253
+
254
+ typer.echo(f"backend set to '{backend}'")
255
+ typer.echo(f"config file: {config_path}")
256
+ typer.echo("run `askai doctor` after configuring a backend")
257
+
258
+
259
+ @config_set_app.command("model")
260
+ def config_set_model(
261
+ model: Annotated[str, typer.Argument(help="Model name to use by default.")],
262
+ ) -> None:
263
+ model = model.strip()
264
+ if not model:
265
+ _fail("model cannot be empty.", EXIT_USAGE_ERROR)
266
+
267
+ try:
268
+ config_path = set_model(model)
269
+ except ConfigError as error:
270
+ _fail_config_error(error)
271
+
272
+ typer.echo(f"model set to '{model}'")
273
+ typer.echo(f"config file: {config_path}")
274
+ typer.echo("run `askai doctor` after configuring a model")
275
+
276
+
277
+ @config_set_app.command("max-lines")
278
+ def config_set_max_lines(
279
+ max_lines: Annotated[int, typer.Argument(help="Maximum answer lines.")],
280
+ ) -> None:
281
+ _validate_max_lines(max_lines, "max-lines")
282
+
283
+ try:
284
+ config_path = set_max_lines(max_lines)
285
+ except ConfigError as error:
286
+ _fail_config_error(error)
287
+
288
+ typer.echo(f"max lines set to {max_lines}")
289
+ typer.echo(f"config file: {config_path}")
290
+
291
+
292
+ @app.command()
293
+ def doctor() -> None:
294
+ try:
295
+ effective_config = get_effective_config()
296
+ except ConfigError as error:
297
+ _fail_config_error(error)
298
+
299
+ backend_availability: BackendAvailability | None = None
300
+ setup_problem: str | None = None
301
+ try:
302
+ backend_availability = check_backend_availability(
303
+ effective_config.backend,
304
+ adapters=BACKEND_ADAPTERS,
305
+ )
306
+ except BackendError as error:
307
+ setup_problem = str(error)
308
+
309
+ if backend_availability is not None and not backend_availability.available:
310
+ setup_problem = _format_backend_availability_problem(backend_availability)
311
+
312
+ typer.echo(f"askai version: {__version__}")
313
+ typer.echo(f"config file: {effective_config.config_path}")
314
+ typer.echo(f"backend: {effective_config.backend or 'not configured'}")
315
+ typer.echo(
316
+ f"backend availability: {_format_backend_availability_status(backend_availability)}"
317
+ )
318
+ typer.echo(f"model: {effective_config.model or 'backend default'}")
319
+ typer.echo(f"max lines: {effective_config.max_lines}")
320
+
321
+ if setup_problem is not None:
322
+ typer.echo(f"setup problem: {setup_problem}")
323
+ raise typer.Exit(EXIT_RUNTIME_ERROR)
324
+
325
+
326
+ def run(argv: Sequence[str] | None = None) -> None:
327
+ args = list(sys.argv[1:] if argv is None else argv)
328
+ if not args:
329
+ typer.echo("Error: A question is required.", err=True)
330
+ raise SystemExit(EXIT_USAGE_ERROR)
331
+
332
+ app(args=build_typer_args(args), prog_name="askai")
File without changes
askai/config.py ADDED
@@ -0,0 +1,234 @@
1
+ import json
2
+ import os
3
+ import tomllib
4
+ from dataclasses import dataclass, replace
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from platformdirs import user_config_dir
9
+
10
+ APP_NAME = "askai"
11
+ CONFIG_FILENAME = "config.toml"
12
+ CONFIG_FILE_ENV = "ASKAI_CONFIG_FILE"
13
+
14
+ DEFAULT_BACKEND = "groq"
15
+ DEFAULT_MODEL: str | None = None
16
+ DEFAULT_MAX_LINES = 6
17
+ MAX_ANSWER_LINES = 20
18
+ KNOWN_BACKENDS = ("codex", "groq")
19
+
20
+
21
+ class ConfigError(Exception):
22
+ pass
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class UserConfig:
27
+ backend: str | None = None
28
+ model: str | None = None
29
+ max_lines: int | None = None
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class EffectiveConfig:
34
+ config_path: Path
35
+ backend: str | None
36
+ model: str | None
37
+ max_lines: int
38
+
39
+
40
+ def get_config_path(config_path: Path | None = None) -> Path:
41
+ if config_path is not None:
42
+ return config_path
43
+
44
+ env_config_path = os.environ.get(CONFIG_FILE_ENV)
45
+ if env_config_path:
46
+ return Path(env_config_path).expanduser()
47
+
48
+ return Path(user_config_dir(APP_NAME)) / CONFIG_FILENAME
49
+
50
+
51
+ def load_user_config(config_path: Path | None = None) -> UserConfig:
52
+ path = get_config_path(config_path)
53
+ if not path.exists():
54
+ return UserConfig()
55
+
56
+ try:
57
+ raw_config = tomllib.loads(path.read_text(encoding="utf-8"))
58
+ except tomllib.TOMLDecodeError as error:
59
+ raise ConfigError(f"Invalid config file {path}: {error}") from error
60
+ except OSError as error:
61
+ raise ConfigError(f"Could not read config file {path}: {error}") from error
62
+
63
+ return parse_user_config(raw_config, path)
64
+
65
+
66
+ def get_effective_config(
67
+ *,
68
+ backend: str | None = None,
69
+ model: str | None = None,
70
+ max_lines: int | None = None,
71
+ config_path: Path | None = None,
72
+ ) -> EffectiveConfig:
73
+ path = get_config_path(config_path)
74
+ user_config = load_user_config(path)
75
+
76
+ effective_backend = (
77
+ validate_backend(backend)
78
+ if backend is not None
79
+ else user_config.backend or DEFAULT_BACKEND
80
+ )
81
+ effective_model = (
82
+ validate_model(model)
83
+ if model is not None
84
+ else user_config.model or DEFAULT_MODEL
85
+ )
86
+ effective_max_lines = (
87
+ validate_max_lines(max_lines)
88
+ if max_lines is not None
89
+ else user_config.max_lines or DEFAULT_MAX_LINES
90
+ )
91
+
92
+ return EffectiveConfig(
93
+ config_path=path,
94
+ backend=effective_backend,
95
+ model=effective_model,
96
+ max_lines=effective_max_lines,
97
+ )
98
+
99
+
100
+ def set_backend(backend: str, config_path: Path | None = None) -> Path:
101
+ validated_backend = validate_backend(backend)
102
+ user_config = load_user_config(config_path)
103
+ return write_user_config(
104
+ replace(user_config, backend=validated_backend), config_path
105
+ )
106
+
107
+
108
+ def set_model(model: str, config_path: Path | None = None) -> Path:
109
+ validated_model = validate_model(model)
110
+ user_config = load_user_config(config_path)
111
+ return write_user_config(replace(user_config, model=validated_model), config_path)
112
+
113
+
114
+ def set_max_lines(max_lines: int, config_path: Path | None = None) -> Path:
115
+ validated_max_lines = validate_max_lines(max_lines)
116
+ user_config = load_user_config(config_path)
117
+ return write_user_config(
118
+ replace(user_config, max_lines=validated_max_lines),
119
+ config_path,
120
+ )
121
+
122
+
123
+ def write_user_config(
124
+ user_config: UserConfig,
125
+ config_path: Path | None = None,
126
+ ) -> Path:
127
+ path = get_config_path(config_path)
128
+
129
+ try:
130
+ path.parent.mkdir(parents=True, exist_ok=True)
131
+ path.write_text(format_user_config(user_config), encoding="utf-8")
132
+ except OSError as error:
133
+ raise ConfigError(f"Could not write config file {path}: {error}") from error
134
+
135
+ return path
136
+
137
+
138
+ def parse_user_config(raw_config: dict[str, Any], path: Path) -> UserConfig:
139
+ backend = parse_backend(raw_config, path)
140
+ model = parse_model(raw_config, path)
141
+ max_lines = parse_max_lines(raw_config, path)
142
+
143
+ return UserConfig(backend=backend, model=model, max_lines=max_lines)
144
+
145
+
146
+ def parse_backend(raw_config: dict[str, Any], path: Path) -> str | None:
147
+ value = raw_config.get("backend")
148
+ if value is None:
149
+ return None
150
+ if not isinstance(value, str):
151
+ raise ConfigError(f"Invalid config file {path}: backend must be a string.")
152
+
153
+ try:
154
+ return validate_backend(value)
155
+ except ConfigError as error:
156
+ raise ConfigError(f"Invalid config file {path}: {error}") from error
157
+
158
+
159
+ def parse_model(raw_config: dict[str, Any], path: Path) -> str | None:
160
+ value = raw_config.get("model")
161
+ if value is None:
162
+ return None
163
+ if not isinstance(value, str):
164
+ raise ConfigError(f"Invalid config file {path}: model must be a string.")
165
+
166
+ try:
167
+ return validate_model(value)
168
+ except ConfigError as error:
169
+ raise ConfigError(f"Invalid config file {path}: {error}") from error
170
+
171
+
172
+ def parse_max_lines(raw_config: dict[str, Any], path: Path) -> int | None:
173
+ value = raw_config.get("max_lines")
174
+ if value is None:
175
+ return None
176
+ if type(value) is not int:
177
+ raise ConfigError(f"Invalid config file {path}: max_lines must be an integer.")
178
+
179
+ try:
180
+ return validate_max_lines(value)
181
+ except ConfigError as error:
182
+ raise ConfigError(f"Invalid config file {path}: {error}") from error
183
+
184
+
185
+ def format_user_config(user_config: UserConfig) -> str:
186
+ lines: list[str] = []
187
+
188
+ if user_config.backend is not None:
189
+ lines.append(f"backend = {json.dumps(user_config.backend)}")
190
+ if user_config.model is not None:
191
+ lines.append(f"model = {json.dumps(user_config.model)}")
192
+ if user_config.max_lines is not None:
193
+ lines.append(f"max_lines = {user_config.max_lines}")
194
+
195
+ if not lines:
196
+ return ""
197
+
198
+ return "\n".join(lines) + "\n"
199
+
200
+
201
+ def validate_backend(backend: str) -> str:
202
+ value = backend.strip()
203
+ if not value:
204
+ raise ConfigError("backend cannot be empty.")
205
+ if not is_supported_backend(value):
206
+ raise ConfigError(
207
+ f"Unsupported backend '{value}'. Supported backends: "
208
+ f"{supported_backends_text()}.",
209
+ )
210
+
211
+ return value
212
+
213
+
214
+ def validate_model(model: str) -> str:
215
+ value = model.strip()
216
+ if not value:
217
+ raise ConfigError("model cannot be empty.")
218
+
219
+ return value
220
+
221
+
222
+ def validate_max_lines(max_lines: int) -> int:
223
+ if max_lines <= 0 or max_lines > MAX_ANSWER_LINES:
224
+ raise ConfigError(f"max_lines must be between 1 and {MAX_ANSWER_LINES}.")
225
+
226
+ return max_lines
227
+
228
+
229
+ def is_supported_backend(backend: str) -> bool:
230
+ return backend in KNOWN_BACKENDS
231
+
232
+
233
+ def supported_backends_text() -> str:
234
+ return ", ".join(KNOWN_BACKENDS)
askai/policy.py ADDED
@@ -0,0 +1,81 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+
4
+ ANSI_ESCAPE_PATTERN = re.compile(
5
+ r"\x1b(?:"
6
+ r"[@-Z\\-_]"
7
+ r"|\[[0-?]*[ -/]*[@-~]"
8
+ r"|\][^\x07]*(?:\x07|\x1b\\)"
9
+ r")"
10
+ )
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class AskaiPrompt:
15
+ system: str
16
+ user: str
17
+
18
+ def render_for_single_prompt(self) -> str:
19
+ return f"{self.system}\n\nUser question:\n{self.user}"
20
+
21
+
22
+ def build_prompt(question: str, *, max_lines: int) -> AskaiPrompt:
23
+ return AskaiPrompt(
24
+ system=build_system_prompt(max_lines),
25
+ user=question,
26
+ )
27
+
28
+
29
+ def build_system_prompt(max_lines: int) -> str:
30
+ return "\n".join(
31
+ [
32
+ "You are askai, a terminal-first one-shot AI assistant for engineers.",
33
+ f"Answer in at most {max_lines} lines.",
34
+ "Fit the complete answer within the line limit; do not trail off.",
35
+ "Answer directly. Do not include preambles like 'Sure' or 'Certainly'.",
36
+ "Use plain text by default. Avoid Markdown unless it improves clarity.",
37
+ "Prefer immediately useful commands, flags, or examples when relevant.",
38
+ "Keep explanations concise and terminal-friendly.",
39
+ "If you are unsure, say so briefly instead of guessing.",
40
+ "Do not ask follow-up questions and do not assume conversation history.",
41
+ ]
42
+ )
43
+
44
+
45
+ def normalize_output(raw_output: str, max_lines: int) -> str:
46
+ lines = [line.rstrip() for line in strip_ansi(raw_output).splitlines()]
47
+ lines = trim_blank_edges(lines)
48
+ lines = collapse_blank_lines(lines)
49
+
50
+ return "\n".join(lines[:max_lines])
51
+
52
+
53
+ def strip_ansi(value: str) -> str:
54
+ return ANSI_ESCAPE_PATTERN.sub("", value)
55
+
56
+
57
+ def trim_blank_edges(lines: list[str]) -> list[str]:
58
+ start = 0
59
+ end = len(lines)
60
+
61
+ while start < end and not lines[start].strip():
62
+ start += 1
63
+ while end > start and not lines[end - 1].strip():
64
+ end -= 1
65
+
66
+ return lines[start:end]
67
+
68
+
69
+ def collapse_blank_lines(lines: list[str]) -> list[str]:
70
+ collapsed_lines: list[str] = []
71
+ previous_line_was_blank = False
72
+
73
+ for line in lines:
74
+ line_is_blank = not line.strip()
75
+ if line_is_blank and previous_line_was_blank:
76
+ continue
77
+
78
+ collapsed_lines.append("" if line_is_blank else line)
79
+ previous_line_was_blank = line_is_blank
80
+
81
+ return collapsed_lines
askai/utils/.gitkeep ADDED
File without changes
@@ -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,16 @@
1
+ askai/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ askai/__init__.py,sha256=GQHazRM8263wjb41pUlMaiZMEWksYe8Pl_liIetbYgo,187
3
+ askai/__main__.py,sha256=GP9PbQQzJ1Fo1qNIZnx7CvjqsefL6APsE9zofZ8v9ZA,33
4
+ askai/backends/__init__.py,sha256=wkeDDT9YnIestW2zLUtUrUSJrmJKhkU6Yr2z3LGjHBs,4394
5
+ askai/backends/codex.py,sha256=0KF2cyjI9F_fYc3JFsbix_nqfmoVU_BvAc5ZgeChBBU,4303
6
+ askai/backends/groq.py,sha256=cie4wE53-IoyZNGNEFGdt5_p1hb76JECNV6g0Xy1S3c,6837
7
+ askai/cli.py,sha256=TRes7doKWCE_CgEX3JPqcSorJ70YcK5q2RrZQ2tp-7w,9014
8
+ askai/commands/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ askai/config.py,sha256=ocp1MSQJ7ReGuUV02RIsupShN9csS-TKseK03c3xMGg,6617
10
+ askai/policy.py,sha256=qqnaR5Fo0LSkV9QZzZjIJUjUhG_O2XeIpZwnRvRt7Yw,2341
11
+ askai/utils/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ askai_py-0.1.0.dist-info/licenses/LICENSE,sha256=dTtuA-ZNd-s3702n3IN-V2uhjyuLMeTn11AmQ3U3Q0E,1072
13
+ askai_py-0.1.0.dist-info/WHEEL,sha256=uOqnPWqgFlbov4NeTCercq7cBQ2UN7xh5fiW55lOnAg,81
14
+ askai_py-0.1.0.dist-info/entry_points.txt,sha256=MeWR8FjjBNOzxf2AAXrHjVbwHX-6qqjQLH8nC_joDhs,41
15
+ askai_py-0.1.0.dist-info/METADATA,sha256=zVBMxC3IoBKB2rj4eqmT1v8fF0wVuDf4qGlm4wA8UF4,2752
16
+ askai_py-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.26
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ askai = askai.cli:run
3
+
@@ -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.