ape-linux 0.4.4__tar.gz → 0.4.6__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.
- {ape_linux-0.4.4 → ape_linux-0.4.6}/.github/workflows/publish.yaml +2 -2
- {ape_linux-0.4.4 → ape_linux-0.4.6}/.github/workflows/tests.yaml +5 -5
- {ape_linux-0.4.4 → ape_linux-0.4.6}/CLAUDE.md +32 -14
- {ape_linux-0.4.4 → ape_linux-0.4.6}/PKG-INFO +17 -4
- {ape_linux-0.4.4 → ape_linux-0.4.6}/README.md +15 -3
- {ape_linux-0.4.4 → ape_linux-0.4.6}/ape_linux.py +91 -31
- {ape_linux-0.4.4 → ape_linux-0.4.6}/justfile +1 -0
- {ape_linux-0.4.4 → ape_linux-0.4.6}/pyproject.toml +4 -2
- {ape_linux-0.4.4 → ape_linux-0.4.6}/tests/test_app.py +82 -12
- ape_linux-0.4.6/uv.lock +1746 -0
- ape_linux-0.4.4/uv.lock +0 -1456
- {ape_linux-0.4.4 → ape_linux-0.4.6}/.gitignore +0 -0
- {ape_linux-0.4.4 → ape_linux-0.4.6}/.python-version +0 -0
- {ape_linux-0.4.4 → ape_linux-0.4.6}/LICENSE +0 -0
|
@@ -3,7 +3,7 @@ name: Publish
|
|
|
3
3
|
on:
|
|
4
4
|
push:
|
|
5
5
|
tags:
|
|
6
|
-
-
|
|
6
|
+
- "*"
|
|
7
7
|
|
|
8
8
|
jobs:
|
|
9
9
|
release:
|
|
@@ -14,7 +14,7 @@ jobs:
|
|
|
14
14
|
environment: release
|
|
15
15
|
steps:
|
|
16
16
|
- name: Checkout repo
|
|
17
|
-
uses: actions/checkout@
|
|
17
|
+
uses: actions/checkout@v7
|
|
18
18
|
- name: Install uv
|
|
19
19
|
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
20
20
|
- name: Build
|
|
@@ -9,19 +9,19 @@ jobs:
|
|
|
9
9
|
strategy:
|
|
10
10
|
matrix:
|
|
11
11
|
os: [ubuntu-latest, macos-latest]
|
|
12
|
-
python-version: [
|
|
12
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
13
13
|
defaults:
|
|
14
14
|
run:
|
|
15
15
|
shell: bash
|
|
16
16
|
steps:
|
|
17
17
|
- name: Checkout repo
|
|
18
|
-
uses: actions/checkout@
|
|
18
|
+
uses: actions/checkout@v7
|
|
19
19
|
- name: Install uv
|
|
20
20
|
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
21
21
|
- name: Set up Python ${{ matrix.python-version }}
|
|
22
22
|
run: uv python install ${{ matrix.python-version }}
|
|
23
23
|
- name: Set up just
|
|
24
|
-
uses: extractions/setup-just@
|
|
24
|
+
uses: extractions/setup-just@v4
|
|
25
25
|
- name: Run type check
|
|
26
26
|
run: just type-check
|
|
27
27
|
- name: Run tests
|
|
@@ -32,12 +32,12 @@ jobs:
|
|
|
32
32
|
runs-on: ubuntu-latest
|
|
33
33
|
steps:
|
|
34
34
|
- name: Checkout repo
|
|
35
|
-
uses: actions/checkout@
|
|
35
|
+
uses: actions/checkout@v7
|
|
36
36
|
- name: Install uv
|
|
37
37
|
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
38
38
|
- name: Set up Python 3.13
|
|
39
39
|
run: uv python install 3.13
|
|
40
40
|
- name: Set up just
|
|
41
|
-
uses: extractions/setup-just@
|
|
41
|
+
uses: extractions/setup-just@v4
|
|
42
42
|
- name: Ruff
|
|
43
43
|
run: just lint
|
|
@@ -25,7 +25,7 @@ This project uses [`uv`](https://docs.astral.sh/uv/) for everything, with
|
|
|
25
25
|
[`just`](https://github.com/casey/just) wrapping the CI-relevant tasks.
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
just lint # lint (
|
|
28
|
+
just lint # lint + format check (ruff check . && ruff format --check .)
|
|
29
29
|
just type-check # type check with ty (uv run ty check)
|
|
30
30
|
just test # run all tests (uv run pytest)
|
|
31
31
|
```
|
|
@@ -54,10 +54,14 @@ The flow in `ape_linux.py` is intentionally minimal:
|
|
|
54
54
|
`system_info()` function backs the `ape-system-info` console script; it just prints
|
|
55
55
|
`detect_system_context()`.
|
|
56
56
|
2. A hard-coded `system_prompt` (with few-shot examples) constrains the model to emit
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
a runnable command — no Markdown fences. Rather than a raw string, the agent uses a
|
|
58
|
+
**structured output** union, `Command | CannotHelp` (both `pydantic.BaseModel`s
|
|
59
|
+
defined in `ape_linux.py`): a `Command` carries the shell command in `.command`,
|
|
60
|
+
and a `CannotHelp` carries a short `.reason` for off-topic/unanswerable requests
|
|
61
|
+
(replacing the old `echo "Please try again."` string). This prompt is the core
|
|
62
|
+
product behavior; changes to it directly change what the tool outputs. `main()`
|
|
63
|
+
then appends a system-context block (see below) so suggestions match the current
|
|
64
|
+
machine.
|
|
61
65
|
2a. `detect_system_context()` returns a best-effort, newline-separated `Key: value`
|
|
62
66
|
block describing the current machine (OS family + macOS/distro version, whether the
|
|
63
67
|
Linux host is WSL, GNU-vs-BSD userland, CPU arch, `$SHELL`, root-or-not, available
|
|
@@ -70,24 +74,38 @@ The flow in `ape_linux.py` is intentionally minimal:
|
|
|
70
74
|
stat-based — no subprocesses — to keep startup fast. Notable guards:
|
|
71
75
|
`platform.freedesktop_os_release()` raises `OSError` on macOS/minimal containers, and
|
|
72
76
|
`os.geteuid()` is absent on non-Unix platforms (`hasattr` check).
|
|
73
|
-
3. `call_llm()` wraps `pydantic_ai.Agent`, which is the provider abstraction.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
3. `call_llm()` wraps `pydantic_ai.Agent`, which is the provider abstraction. It sets
|
|
78
|
+
`output_type=Command | CannotHelp`, so the agent returns one of those structured
|
|
79
|
+
objects instead of raw text (no string-sniffing in `main()`), and forwards a
|
|
80
|
+
`model_settings` mapping (or `None`). Models are passed through verbatim in
|
|
81
|
+
`provider:name` form (e.g. `anthropic:claude-sonnet-4-5`), so Ape supports any
|
|
82
|
+
provider Pydantic AI supports without provider-specific code. The model is resolved
|
|
83
|
+
solely from the `APE_MODEL` env var → the `DEFAULT_MODEL` constant
|
|
84
|
+
(`openai-chat:gpt-4.1`); there is no CLI override. The sampling temperature is
|
|
85
|
+
resolved by `resolve_model_settings()` from the `APE_TEMPERATURE` env var → the
|
|
86
|
+
`DEFAULT_TEMPERATURE` constant (`0.2`); the literal `"undefined"` (case-insensitive)
|
|
87
|
+
yields `None` so **no** `model_settings` are sent — deliberately, because a
|
|
88
|
+
hard-coded temperature crashes models that reject sampling settings (some reasoning
|
|
89
|
+
models, Claude Opus 4.7/4.8) — and an unparseable value exits `1` before any LLM
|
|
90
|
+
call. Credentials come from each provider's standard env var (e.g. `OPENAI_API_KEY`,
|
|
91
|
+
`ANTHROPIC_API_KEY`).
|
|
79
92
|
4. Errors are flattened to one-line stderr messages, raising `SystemExit(1)` —
|
|
80
93
|
`ModelHTTPError` reports status/message; any other exception (bad credentials,
|
|
81
94
|
unknown provider) prints `str(error)`. There is no CLI framework swallowing
|
|
82
|
-
tracebacks, so exceptions are caught explicitly.
|
|
95
|
+
tracebacks, so exceptions are caught explicitly. On a successful call, a `Command`
|
|
96
|
+
prints its `.command` to stdout and exits 0, while a `CannotHelp` prints
|
|
97
|
+
`ape: <reason>` to **stderr** and exits `2` — a distinct code so callers can tell
|
|
98
|
+
"can't turn this into a command" apart from operational errors, and stdout stays
|
|
99
|
+
reserved for runnable commands.
|
|
83
100
|
|
|
84
101
|
## Testing
|
|
85
102
|
|
|
86
103
|
`tests/test_app.py` drives `main()` directly via a `run()` helper that monkeypatches
|
|
87
104
|
`sys.argv` and returns the `SystemExit` code (0 on the success path, since `main()`
|
|
88
105
|
doesn't exit then), asserting on captured stdout/stderr with `capsys`. It monkeypatches
|
|
89
|
-
`ape_linux.call_llm` so no real network/LLM calls happen
|
|
90
|
-
|
|
106
|
+
`ape_linux.call_llm` so no real network/LLM calls happen; the mock returns a `Command`
|
|
107
|
+
(printed to stdout, exit 0) or a `CannotHelp` to exercise the refusal path (printed to
|
|
108
|
+
stderr, exit `2`). The `mockenv` fixture sets a dummy `OPENAI_API_KEY`.
|
|
91
109
|
|
|
92
110
|
`detect_system_context()` is covered by tests that assert it returns a string without
|
|
93
111
|
crashing, reports the OS, and — importantly — excludes the current username, hostname,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ape-linux
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.6
|
|
4
4
|
Summary: AI for Linux commands
|
|
5
5
|
Project-URL: Homepage, https://github.com/sbalian/ape
|
|
6
6
|
Project-URL: Repository, https://github.com/sbalian/ape
|
|
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python
|
|
|
15
15
|
Classifier: Topic :: Software Development
|
|
16
16
|
Requires-Python: >=3.10
|
|
17
17
|
Requires-Dist: pydantic-ai-slim[anthropic,google,groq,mistral,openai]>=1.104.0
|
|
18
|
+
Requires-Dist: pydantic>=2
|
|
18
19
|
Description-Content-Type: text/markdown
|
|
19
20
|
|
|
20
21
|
# Ape
|
|
@@ -72,16 +73,18 @@ Output:
|
|
|
72
73
|
find projects/ -type d -name ".venv" -exec rm -rf {} +
|
|
73
74
|
```
|
|
74
75
|
|
|
75
|
-
If you
|
|
76
|
+
If you ask for something that isn't a Linux command task:
|
|
76
77
|
|
|
77
78
|
```bash
|
|
78
79
|
ape Tell me about monkeys
|
|
79
80
|
```
|
|
80
81
|
|
|
81
|
-
|
|
82
|
+
Ape prints a short explanation to standard error and exits with status `2`.
|
|
83
|
+
Commands only ever go to standard output, so a refusal is never mistaken for one
|
|
84
|
+
(and won't run if you pipe Ape into a shell):
|
|
82
85
|
|
|
83
86
|
```text
|
|
84
|
-
|
|
87
|
+
ape: I can only help with Linux and Unix command-line tasks.
|
|
85
88
|
```
|
|
86
89
|
|
|
87
90
|
You can change the model with the `APE_MODEL` environment variable. Models are
|
|
@@ -95,6 +98,16 @@ export APE_MODEL=anthropic:claude-sonnet-4-5
|
|
|
95
98
|
|
|
96
99
|
If `APE_MODEL` is unset, the default `openai-chat:gpt-4.1` is used.
|
|
97
100
|
|
|
101
|
+
You can set the sampling temperature with the `APE_TEMPERATURE` environment
|
|
102
|
+
variable (default `0.2`). Some models — for example certain reasoning models —
|
|
103
|
+
reject a temperature; set `APE_TEMPERATURE=undefined` to send none at all and let
|
|
104
|
+
the model use its own default:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
export APE_TEMPERATURE=0.7 # use a specific temperature
|
|
108
|
+
export APE_TEMPERATURE=undefined # send no temperature at all
|
|
109
|
+
```
|
|
110
|
+
|
|
98
111
|
## System-aware suggestions
|
|
99
112
|
|
|
100
113
|
Ape automatically detects a few facts about your machine and adds them to the prompt so
|
|
@@ -53,16 +53,18 @@ Output:
|
|
|
53
53
|
find projects/ -type d -name ".venv" -exec rm -rf {} +
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
If you
|
|
56
|
+
If you ask for something that isn't a Linux command task:
|
|
57
57
|
|
|
58
58
|
```bash
|
|
59
59
|
ape Tell me about monkeys
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
Ape prints a short explanation to standard error and exits with status `2`.
|
|
63
|
+
Commands only ever go to standard output, so a refusal is never mistaken for one
|
|
64
|
+
(and won't run if you pipe Ape into a shell):
|
|
63
65
|
|
|
64
66
|
```text
|
|
65
|
-
|
|
67
|
+
ape: I can only help with Linux and Unix command-line tasks.
|
|
66
68
|
```
|
|
67
69
|
|
|
68
70
|
You can change the model with the `APE_MODEL` environment variable. Models are
|
|
@@ -76,6 +78,16 @@ export APE_MODEL=anthropic:claude-sonnet-4-5
|
|
|
76
78
|
|
|
77
79
|
If `APE_MODEL` is unset, the default `openai-chat:gpt-4.1` is used.
|
|
78
80
|
|
|
81
|
+
You can set the sampling temperature with the `APE_TEMPERATURE` environment
|
|
82
|
+
variable (default `0.2`). Some models — for example certain reasoning models —
|
|
83
|
+
reject a temperature; set `APE_TEMPERATURE=undefined` to send none at all and let
|
|
84
|
+
the model use its own default:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
export APE_TEMPERATURE=0.7 # use a specific temperature
|
|
88
|
+
export APE_TEMPERATURE=undefined # send no temperature at all
|
|
89
|
+
```
|
|
90
|
+
|
|
79
91
|
## System-aware suggestions
|
|
80
92
|
|
|
81
93
|
Ape automatically detects a few facts about your machine and adds them to the prompt so
|
|
@@ -4,11 +4,14 @@ import os
|
|
|
4
4
|
import platform
|
|
5
5
|
import shutil
|
|
6
6
|
import sys
|
|
7
|
+
from typing import cast
|
|
7
8
|
|
|
8
|
-
from
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
from pydantic_ai import Agent, ModelSettings
|
|
9
11
|
from pydantic_ai.exceptions import ModelHTTPError
|
|
10
12
|
|
|
11
13
|
DEFAULT_MODEL = "openai-chat:gpt-4.1"
|
|
14
|
+
DEFAULT_TEMPERATURE = 0.2
|
|
12
15
|
|
|
13
16
|
HELP = """\
|
|
14
17
|
ape — AI for Linux commands.
|
|
@@ -26,17 +29,69 @@ The model is read from the APE_MODEL environment variable in provider:name form
|
|
|
26
29
|
https://ai.pydantic.dev/models/. Credentials come from each provider's standard
|
|
27
30
|
environment variable (e.g. OPENAI_API_KEY, ANTHROPIC_API_KEY).
|
|
28
31
|
|
|
32
|
+
The sampling temperature is read from APE_TEMPERATURE (default {temperature}). Set
|
|
33
|
+
it to "undefined" to send no temperature at all, which some models require.
|
|
34
|
+
|
|
29
35
|
Run `ape-system-info` to print the detected system context sent to the model.\
|
|
30
|
-
""".format(default=DEFAULT_MODEL)
|
|
36
|
+
""".format(default=DEFAULT_MODEL, temperature=DEFAULT_TEMPERATURE)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Command(BaseModel):
|
|
40
|
+
"""A shell command (or && / \\-chained commands) for the requested task."""
|
|
41
|
+
|
|
42
|
+
command: str
|
|
43
|
+
|
|
31
44
|
|
|
45
|
+
class CannotHelp(BaseModel):
|
|
46
|
+
"""A refusal: the request is not a Linux/Unix task that maps to a command."""
|
|
32
47
|
|
|
33
|
-
|
|
48
|
+
reason: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def call_llm(
|
|
52
|
+
model: str,
|
|
53
|
+
system_prompt: str,
|
|
54
|
+
user_prompt: str,
|
|
55
|
+
model_settings: ModelSettings | None,
|
|
56
|
+
) -> Command | CannotHelp:
|
|
34
57
|
agent = Agent(
|
|
35
58
|
model,
|
|
36
59
|
system_prompt=system_prompt,
|
|
37
|
-
|
|
60
|
+
output_type=Command | CannotHelp,
|
|
61
|
+
# None means no settings are sent (see resolve_model_settings), so the model
|
|
62
|
+
# uses its own defaults — needed for models that reject a temperature.
|
|
63
|
+
model_settings=model_settings,
|
|
38
64
|
)
|
|
39
|
-
return
|
|
65
|
+
# `output_type` makes the agent return a `Command | CannotHelp` at runtime, but
|
|
66
|
+
# the type checker (ty) doesn't resolve the union through pydantic-ai's overloads
|
|
67
|
+
# and infers `str`, so restate the type here. (Pyright resolves it without a cast.)
|
|
68
|
+
return cast(Command | CannotHelp, agent.run_sync(user_prompt).output)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def resolve_model_settings() -> ModelSettings | None:
|
|
72
|
+
"""Resolve model settings from the APE_TEMPERATURE environment variable.
|
|
73
|
+
|
|
74
|
+
Unset (or empty) uses the default temperature. The literal ``"undefined"``
|
|
75
|
+
(case-insensitive) returns ``None`` so that no ``model_settings`` — and thus no
|
|
76
|
+
temperature — is sent to the model; this is what models that reject sampling
|
|
77
|
+
settings (e.g. some reasoning models) need. Any other value must parse as a
|
|
78
|
+
float, otherwise the program exits with a one-line error.
|
|
79
|
+
"""
|
|
80
|
+
raw = os.environ.get("APE_TEMPERATURE")
|
|
81
|
+
if raw is None or not raw.strip():
|
|
82
|
+
return {"temperature": DEFAULT_TEMPERATURE}
|
|
83
|
+
value = raw.strip()
|
|
84
|
+
if value.lower() == "undefined":
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
return {"temperature": float(value)}
|
|
88
|
+
except ValueError:
|
|
89
|
+
print(
|
|
90
|
+
f"ape: invalid APE_TEMPERATURE {value!r}: expected a number or "
|
|
91
|
+
'"undefined".',
|
|
92
|
+
file=sys.stderr,
|
|
93
|
+
)
|
|
94
|
+
raise SystemExit(1)
|
|
40
95
|
|
|
41
96
|
|
|
42
97
|
def detect_system_context() -> str:
|
|
@@ -188,44 +243,45 @@ def main() -> None:
|
|
|
188
243
|
# The model is read from APE_MODEL, falling back to the default.
|
|
189
244
|
model = os.environ.get("APE_MODEL") or DEFAULT_MODEL
|
|
190
245
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
in your answer the command or commands to perform the task. If you do not know how
|
|
195
|
-
to perform the task, output "echo "Please try again."".
|
|
196
|
-
|
|
197
|
-
It is important that you do not output commands enclosed in ``` ``` Markdown
|
|
198
|
-
blocks. For example, do not output:
|
|
246
|
+
# The sampling temperature is read from APE_TEMPERATURE (see
|
|
247
|
+
# resolve_model_settings); an invalid value exits here before any LLM call.
|
|
248
|
+
model_settings = resolve_model_settings()
|
|
199
249
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
250
|
+
system_prompt = """\
|
|
251
|
+
You are a Linux command assistant. You will be asked how to perform a task on
|
|
252
|
+
Linux or a Unix-like operating system. Respond with one of two structured
|
|
253
|
+
outputs:
|
|
204
254
|
|
|
205
|
-
|
|
206
|
-
|
|
255
|
+
- Command: the shell command (or commands) that perform the task, when the
|
|
256
|
+
request is a Linux/Unix task.
|
|
257
|
+
- CannotHelp: a short reason, when the request is not something you can turn into
|
|
258
|
+
a shell command (for example a general-knowledge question or anything
|
|
259
|
+
off-topic).
|
|
207
260
|
|
|
208
|
-
|
|
261
|
+
For a Command, put something that can be entered directly into the command line
|
|
262
|
+
in the `command` field. Do not wrap it in ``` ``` Markdown code fences. Chain
|
|
263
|
+
multiple steps with && (for example: cd projects && ls) and use \\ for command
|
|
264
|
+
continuation.
|
|
209
265
|
|
|
210
266
|
Here are a few examples.
|
|
211
267
|
|
|
212
268
|
Question: List all the files and directories in projects in my home directory
|
|
213
|
-
|
|
269
|
+
Command: ls ~/projects
|
|
214
270
|
|
|
215
271
|
Question: Navigate to projects and list its contents
|
|
216
|
-
|
|
272
|
+
Command: cd projects && ls
|
|
217
273
|
|
|
218
274
|
Question: What is my username?
|
|
219
|
-
|
|
275
|
+
Command: whoami
|
|
220
276
|
|
|
221
277
|
Question: Find all files with the extension .txt under the current working directory
|
|
222
|
-
|
|
278
|
+
Command: find . -name "*.txt"
|
|
223
279
|
|
|
224
|
-
Question: What is the
|
|
225
|
-
|
|
280
|
+
Question: What is the capital of France?
|
|
281
|
+
CannotHelp: I can only help with Linux and Unix command-line tasks.
|
|
226
282
|
|
|
227
283
|
Question: Tell me a story
|
|
228
|
-
|
|
284
|
+
CannotHelp: I can only help with Linux and Unix command-line tasks."""
|
|
229
285
|
|
|
230
286
|
# Append best-effort facts about the current machine so the model can
|
|
231
287
|
# tailor flags, package managers and tool choices to this environment.
|
|
@@ -244,7 +300,7 @@ def main() -> None:
|
|
|
244
300
|
Answer:"""
|
|
245
301
|
|
|
246
302
|
try:
|
|
247
|
-
|
|
303
|
+
result = call_llm(model, system_prompt, user_prompt, model_settings)
|
|
248
304
|
except ModelHTTPError as error:
|
|
249
305
|
print(f"{error.status_code} error: {error.message}", file=sys.stderr)
|
|
250
306
|
raise SystemExit(1)
|
|
@@ -254,6 +310,10 @@ def main() -> None:
|
|
|
254
310
|
print(str(error), file=sys.stderr)
|
|
255
311
|
raise SystemExit(1)
|
|
256
312
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
313
|
+
# The agent returns a structured result: either a runnable command or a
|
|
314
|
+
# refusal. A refusal goes to stderr and exits 2 (distinct from the code-1
|
|
315
|
+
# operational errors above) so it is never mistaken for a command on stdout.
|
|
316
|
+
if isinstance(result, CannotHelp):
|
|
317
|
+
print(f"ape: {result.reason}", file=sys.stderr)
|
|
318
|
+
raise SystemExit(2)
|
|
319
|
+
print(result.command)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "ape-linux"
|
|
3
|
-
version = "0.4.
|
|
3
|
+
version = "0.4.6"
|
|
4
4
|
description = "AI for Linux commands"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = {text = "MIT License"}
|
|
@@ -19,6 +19,7 @@ classifiers = [
|
|
|
19
19
|
]
|
|
20
20
|
requires-python = ">=3.10"
|
|
21
21
|
dependencies = [
|
|
22
|
+
"pydantic>=2",
|
|
22
23
|
"pydantic-ai-slim[anthropic,google,groq,mistral,openai]>=1.104.0",
|
|
23
24
|
]
|
|
24
25
|
|
|
@@ -36,6 +37,7 @@ build-backend = "hatchling.build"
|
|
|
36
37
|
|
|
37
38
|
[dependency-groups]
|
|
38
39
|
dev = [
|
|
40
|
+
"ipython>=8.39.0",
|
|
39
41
|
"pytest>=8.3.5",
|
|
40
42
|
"ruff>=0.11.7",
|
|
41
43
|
"ty>=0.0.40",
|
|
@@ -45,7 +47,7 @@ dev = [
|
|
|
45
47
|
target-version = "py314"
|
|
46
48
|
|
|
47
49
|
[tool.ruff.lint]
|
|
48
|
-
select = ["F", "E", "W", "
|
|
50
|
+
select = ["F", "E", "W", "I"]
|
|
49
51
|
|
|
50
52
|
[tool.ruff.lint.isort]
|
|
51
53
|
known-first-party = ["ape_linux"]
|
|
@@ -28,7 +28,10 @@ def run(monkeypatch, argv):
|
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
def test_app_for_suggestion(mockenv, monkeypatch, capsys):
|
|
31
|
-
monkeypatch.setattr(
|
|
31
|
+
monkeypatch.setattr(
|
|
32
|
+
"ape_linux.call_llm",
|
|
33
|
+
lambda *args, **kwargs: ape_linux.Command(command="ls"),
|
|
34
|
+
)
|
|
32
35
|
code = run(monkeypatch, ["list all the files"])
|
|
33
36
|
captured = capsys.readouterr()
|
|
34
37
|
assert captured.out == "ls\n"
|
|
@@ -39,9 +42,9 @@ def test_app_for_suggestion(mockenv, monkeypatch, capsys):
|
|
|
39
42
|
def test_app_joins_multiple_args_into_query(mockenv, monkeypatch, capsys):
|
|
40
43
|
captured_prompt = {}
|
|
41
44
|
|
|
42
|
-
def mockreturn(model, system_prompt, user_prompt):
|
|
45
|
+
def mockreturn(model, system_prompt, user_prompt, model_settings):
|
|
43
46
|
captured_prompt["user"] = user_prompt
|
|
44
|
-
return "ls"
|
|
47
|
+
return ape_linux.Command(command="ls")
|
|
45
48
|
|
|
46
49
|
monkeypatch.setattr("ape_linux.call_llm", mockreturn)
|
|
47
50
|
run(monkeypatch, ["list", "all", "the", "files"])
|
|
@@ -55,13 +58,20 @@ def test_app_prints_help_and_exits_when_no_args(monkeypatch, capsys):
|
|
|
55
58
|
assert code == 1
|
|
56
59
|
|
|
57
60
|
|
|
58
|
-
def
|
|
59
|
-
monkeypatch.setattr(
|
|
61
|
+
def test_app_reports_when_model_cannot_help(mockenv, monkeypatch, capsys):
|
|
62
|
+
monkeypatch.setattr(
|
|
63
|
+
"ape_linux.call_llm",
|
|
64
|
+
lambda *args, **kwargs: ape_linux.CannotHelp(
|
|
65
|
+
reason="I can only help with Linux and Unix command-line tasks."
|
|
66
|
+
),
|
|
67
|
+
)
|
|
60
68
|
code = run(monkeypatch, ["what is the capital of England?"])
|
|
61
69
|
captured = capsys.readouterr()
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
assert
|
|
70
|
+
# A refusal never lands on stdout (so it can't be mistaken for a command);
|
|
71
|
+
# it goes to stderr and exits with the dedicated code 2.
|
|
72
|
+
assert captured.out == ""
|
|
73
|
+
assert "I can only help with Linux and Unix command-line tasks." in captured.err
|
|
74
|
+
assert code == 2
|
|
65
75
|
|
|
66
76
|
|
|
67
77
|
def test_app_with_api_error(mockenv, monkeypatch, capsys):
|
|
@@ -91,7 +101,7 @@ def test_app_uses_default_model_when_unset(mockenv, monkeypatch):
|
|
|
91
101
|
|
|
92
102
|
def mockreturn(model, *args, **kwargs):
|
|
93
103
|
captured["model"] = model
|
|
94
|
-
return "ls"
|
|
104
|
+
return ape_linux.Command(command="ls")
|
|
95
105
|
|
|
96
106
|
monkeypatch.delenv("APE_MODEL", raising=False)
|
|
97
107
|
monkeypatch.setattr("ape_linux.call_llm", mockreturn)
|
|
@@ -106,7 +116,7 @@ def test_app_uses_ape_model_env_var(mockenv, monkeypatch):
|
|
|
106
116
|
|
|
107
117
|
def mockreturn(model, *args, **kwargs):
|
|
108
118
|
captured["model"] = model
|
|
109
|
-
return "ls"
|
|
119
|
+
return ape_linux.Command(command="ls")
|
|
110
120
|
|
|
111
121
|
monkeypatch.setenv("APE_MODEL", "anthropic:claude-sonnet-4-5")
|
|
112
122
|
monkeypatch.setattr("ape_linux.call_llm", mockreturn)
|
|
@@ -115,6 +125,64 @@ def test_app_uses_ape_model_env_var(mockenv, monkeypatch):
|
|
|
115
125
|
assert code == 0
|
|
116
126
|
|
|
117
127
|
|
|
128
|
+
def test_app_uses_default_temperature_when_unset(mockenv, monkeypatch):
|
|
129
|
+
captured = {}
|
|
130
|
+
|
|
131
|
+
def mockreturn(model, system_prompt, user_prompt, model_settings):
|
|
132
|
+
captured["settings"] = model_settings
|
|
133
|
+
return ape_linux.Command(command="ls")
|
|
134
|
+
|
|
135
|
+
monkeypatch.delenv("APE_TEMPERATURE", raising=False)
|
|
136
|
+
monkeypatch.setattr("ape_linux.call_llm", mockreturn)
|
|
137
|
+
code = run(monkeypatch, ["list all the files"])
|
|
138
|
+
assert captured["settings"] == {"temperature": ape_linux.DEFAULT_TEMPERATURE}
|
|
139
|
+
assert ape_linux.DEFAULT_TEMPERATURE == 0.2
|
|
140
|
+
assert code == 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_app_uses_ape_temperature_env_var(mockenv, monkeypatch):
|
|
144
|
+
captured = {}
|
|
145
|
+
|
|
146
|
+
def mockreturn(model, system_prompt, user_prompt, model_settings):
|
|
147
|
+
captured["settings"] = model_settings
|
|
148
|
+
return ape_linux.Command(command="ls")
|
|
149
|
+
|
|
150
|
+
monkeypatch.setenv("APE_TEMPERATURE", "0.7")
|
|
151
|
+
monkeypatch.setattr("ape_linux.call_llm", mockreturn)
|
|
152
|
+
code = run(monkeypatch, ["list all the files"])
|
|
153
|
+
assert captured["settings"] == {"temperature": 0.7}
|
|
154
|
+
assert code == 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_app_undefined_temperature_sends_no_settings(mockenv, monkeypatch):
|
|
158
|
+
captured = {}
|
|
159
|
+
|
|
160
|
+
def mockreturn(model, system_prompt, user_prompt, model_settings):
|
|
161
|
+
captured["settings"] = model_settings
|
|
162
|
+
return ape_linux.Command(command="ls")
|
|
163
|
+
|
|
164
|
+
# "undefined" (any case) means: send no model_settings at all.
|
|
165
|
+
monkeypatch.setenv("APE_TEMPERATURE", "Undefined")
|
|
166
|
+
monkeypatch.setattr("ape_linux.call_llm", mockreturn)
|
|
167
|
+
code = run(monkeypatch, ["list all the files"])
|
|
168
|
+
assert captured["settings"] is None
|
|
169
|
+
assert code == 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_app_invalid_temperature_exits_before_llm(mockenv, monkeypatch, capsys):
|
|
173
|
+
monkeypatch.setenv("APE_TEMPERATURE", "hot")
|
|
174
|
+
# An invalid temperature must be caught before any LLM call is attempted.
|
|
175
|
+
monkeypatch.setattr(
|
|
176
|
+
"ape_linux.call_llm",
|
|
177
|
+
lambda *args, **kwargs: pytest.fail("call_llm should not be called"),
|
|
178
|
+
)
|
|
179
|
+
code = run(monkeypatch, ["list all the files"])
|
|
180
|
+
captured = capsys.readouterr()
|
|
181
|
+
assert captured.out == ""
|
|
182
|
+
assert "APE_TEMPERATURE" in captured.err
|
|
183
|
+
assert code == 1
|
|
184
|
+
|
|
185
|
+
|
|
118
186
|
def test_system_info_entry_point(capsys):
|
|
119
187
|
ape_linux.system_info()
|
|
120
188
|
captured = capsys.readouterr()
|
|
@@ -139,9 +207,11 @@ def test_detect_system_context_omits_wsl_when_absent(monkeypatch):
|
|
|
139
207
|
monkeypatch.delenv("WSL_DISTRO_NAME", raising=False)
|
|
140
208
|
monkeypatch.delenv("WSL_INTEROP", raising=False)
|
|
141
209
|
monkeypatch.setattr(
|
|
142
|
-
ape_linux.platform,
|
|
210
|
+
ape_linux.platform,
|
|
211
|
+
"uname",
|
|
212
|
+
lambda: platform.uname_result(
|
|
143
213
|
"Linux", "host", "5.15.0-generic", "#1", "x86_64"
|
|
144
|
-
)
|
|
214
|
+
),
|
|
145
215
|
)
|
|
146
216
|
context = ape_linux.detect_system_context()
|
|
147
217
|
assert "WSL:" not in context
|