ape-linux 0.4.0__tar.gz → 0.4.2__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.0 → ape_linux-0.4.2}/CLAUDE.md +24 -21
- {ape_linux-0.4.0 → ape_linux-0.4.2}/PKG-INFO +7 -46
- {ape_linux-0.4.0 → ape_linux-0.4.2}/README.md +6 -43
- {ape_linux-0.4.0 → ape_linux-0.4.2}/ape_linux.py +57 -80
- {ape_linux-0.4.0 → ape_linux-0.4.2}/pyproject.toml +3 -4
- ape_linux-0.4.2/tests/test_app.py +172 -0
- {ape_linux-0.4.0 → ape_linux-0.4.2}/uv.lock +2 -75
- ape_linux-0.4.0/tests/test_app.py +0 -153
- {ape_linux-0.4.0 → ape_linux-0.4.2}/.github/workflows/publish.yaml +0 -0
- {ape_linux-0.4.0 → ape_linux-0.4.2}/.github/workflows/tests.yaml +0 -0
- {ape_linux-0.4.0 → ape_linux-0.4.2}/.gitignore +0 -0
- {ape_linux-0.4.0 → ape_linux-0.4.2}/.python-version +0 -0
- {ape_linux-0.4.0 → ape_linux-0.4.2}/LICENSE +0 -0
- {ape_linux-0.4.0 → ape_linux-0.4.2}/justfile +0 -0
|
@@ -11,7 +11,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|
|
11
11
|
|
|
12
12
|
Ape (`ape-linux` on PyPI) is a CLI that turns a natural-language description of a
|
|
13
13
|
Linux task into a shell command using an LLM. The entire implementation lives in a
|
|
14
|
-
single module, `ape_linux.py`, exposing
|
|
14
|
+
single module, `ape_linux.py`, exposing two console scripts: `ape` (→ `main()`) and
|
|
15
|
+
`ape-system-info` (→ `system_info()`). It has no CLI framework — argument handling is
|
|
16
|
+
plain `sys.argv`.
|
|
15
17
|
|
|
16
18
|
**Single-file rule:** all of Ape's implementation must stay in `ape_linux.py` — do
|
|
17
19
|
not split it into additional modules/packages. New behavior is added as functions in
|
|
@@ -45,20 +47,23 @@ uv build # build the wheel/sdist
|
|
|
45
47
|
|
|
46
48
|
The flow in `ape_linux.py` is intentionally minimal:
|
|
47
49
|
|
|
48
|
-
1. `main()` is the
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
`
|
|
50
|
+
1. `main()` is the `ape` entry point. It builds the query by joining `sys.argv[1:]`
|
|
51
|
+
with spaces, so both `ape "list files"` and `ape list files` work. With no
|
|
52
|
+
arguments it prints the `HELP` text and exits with code 1. There are no flags —
|
|
53
|
+
model selection, execution, version, and system-info are all gone. A separate
|
|
54
|
+
`system_info()` function backs the `ape-system-info` console script; it just prints
|
|
55
|
+
`detect_system_context()`.
|
|
53
56
|
2. A hard-coded `system_prompt` (with few-shot examples) constrains the model to emit
|
|
54
57
|
**only** a runnable command — no Markdown fences — or `echo "Please try again."`
|
|
55
58
|
for anything off-topic. This prompt is the core product behavior; changes to it
|
|
56
59
|
directly change what the tool outputs. `main()` then appends a system-context block
|
|
57
60
|
(see below) so suggestions match the current machine.
|
|
58
61
|
2a. `detect_system_context()` returns a best-effort, newline-separated `Key: value`
|
|
59
|
-
block describing the current machine (OS family + macOS/distro version,
|
|
60
|
-
userland, CPU arch, `$SHELL`, root-or-not, available
|
|
61
|
-
list of common tools).
|
|
62
|
+
block describing the current machine (OS family + macOS/distro version, whether the
|
|
63
|
+
Linux host is WSL, GNU-vs-BSD userland, CPU arch, `$SHELL`, root-or-not, available
|
|
64
|
+
package managers, and a probed list of common tools). WSL is detected (Linux only)
|
|
65
|
+
from the `WSL_DISTRO_NAME`/`WSL_INTEROP` env vars or `"microsoft"` in
|
|
66
|
+
`platform.uname().release`. Two invariants: it **never raises** (every probe is guarded,
|
|
62
67
|
so an unavailable API or missing file just omits that field instead of crashing at
|
|
63
68
|
startup), and it **never includes identifying info** (no username, hostname, working
|
|
64
69
|
directory, or home path). Everything is stdlib (`platform`, `os`, `shutil.which`) and
|
|
@@ -68,23 +73,21 @@ The flow in `ape_linux.py` is intentionally minimal:
|
|
|
68
73
|
3. `call_llm()` wraps `pydantic_ai.Agent`, which is the provider abstraction. Models
|
|
69
74
|
are passed through verbatim in `provider:name` form (e.g. `anthropic:claude-sonnet-4-5`),
|
|
70
75
|
so Ape supports any provider Pydantic AI supports without provider-specific code.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
4. Errors are flattened to one-line stderr messages with exit code 1 —
|
|
76
|
+
The model is resolved solely from the `APE_MODEL` env var → the `DEFAULT_MODEL`
|
|
77
|
+
constant (`openai-chat:gpt-4.1`); there is no CLI override. Credentials come from
|
|
78
|
+
each provider's standard env var (e.g. `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`).
|
|
79
|
+
4. Errors are flattened to one-line stderr messages, raising `SystemExit(1)` —
|
|
76
80
|
`ModelHTTPError` reports status/message; any other exception (bad credentials,
|
|
77
|
-
unknown provider) prints `str(error)`.
|
|
78
|
-
|
|
79
|
-
5. With `--execute`, the suggested command is run via `subprocess.check_call(..., shell=True)`.
|
|
81
|
+
unknown provider) prints `str(error)`. There is no CLI framework swallowing
|
|
82
|
+
tracebacks, so exceptions are caught explicitly.
|
|
80
83
|
|
|
81
84
|
## Testing
|
|
82
85
|
|
|
83
|
-
`tests/test_app.py`
|
|
86
|
+
`tests/test_app.py` drives `main()` directly via a `run()` helper that monkeypatches
|
|
87
|
+
`sys.argv` and returns the `SystemExit` code (0 on the success path, since `main()`
|
|
88
|
+
doesn't exit then), asserting on captured stdout/stderr with `capsys`. It monkeypatches
|
|
84
89
|
`ape_linux.call_llm` so no real network/LLM calls happen. The `mockenv` fixture sets a
|
|
85
|
-
dummy `OPENAI_API_KEY`.
|
|
86
|
-
return `ls` and actually executes it (harmless, but be aware when adding execute-path
|
|
87
|
-
tests — avoid destructive mocked commands).
|
|
90
|
+
dummy `OPENAI_API_KEY`.
|
|
88
91
|
|
|
89
92
|
`detect_system_context()` is covered by tests that assert it returns a string without
|
|
90
93
|
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.2
|
|
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,8 +15,6 @@ 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: rich>=14.0.0
|
|
19
|
-
Requires-Dist: typer>=0.15.2
|
|
20
18
|
Description-Content-Type: text/markdown
|
|
21
19
|
|
|
22
20
|
# Ape
|
|
@@ -86,32 +84,16 @@ you should get:
|
|
|
86
84
|
echo "Please try again."
|
|
87
85
|
```
|
|
88
86
|
|
|
89
|
-
You can change the model
|
|
90
|
-
`provider:name` form.
|
|
87
|
+
You can change the model with the `APE_MODEL` environment variable. Models are
|
|
88
|
+
specified in `provider:name` form.
|
|
91
89
|
See [here](https://ai.pydantic.dev/models/) for the supported providers and models.
|
|
92
90
|
For example:
|
|
93
91
|
|
|
94
|
-
```bash
|
|
95
|
-
ape "List the contents of the working directory with as much detail as possible" --model anthropic:claude-sonnet-4-5
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
The model is resolved as follows:
|
|
99
|
-
|
|
100
|
-
1. If `--model`/`-m` is given, that value is always used.
|
|
101
|
-
2. Otherwise, if the `APE_MODEL` environment variable is set, its value is used.
|
|
102
|
-
3. Otherwise, the default `openai-chat:gpt-4.1` is used.
|
|
103
|
-
|
|
104
|
-
So you can set a personal default without passing `--model` every time:
|
|
105
|
-
|
|
106
92
|
```bash
|
|
107
93
|
export APE_MODEL=anthropic:claude-sonnet-4-5
|
|
108
94
|
```
|
|
109
95
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
```text
|
|
113
|
-
ls -lha
|
|
114
|
-
```
|
|
96
|
+
If `APE_MODEL` is unset, the default `openai-chat:gpt-4.1` is used.
|
|
115
97
|
|
|
116
98
|
## System-aware suggestions
|
|
117
99
|
|
|
@@ -131,11 +113,11 @@ This is all gathered locally with the Python standard library and is best-effort
|
|
|
131
113
|
anything can't be determined it is simply left out. **No identifying information is
|
|
132
114
|
collected or sent** — never your username, hostname, working directory, or home path.
|
|
133
115
|
|
|
134
|
-
To see exactly what Ape detects and sends (without calling the model),
|
|
135
|
-
|
|
116
|
+
To see exactly what Ape detects and sends (without calling the model), run
|
|
117
|
+
`ape-system-info`:
|
|
136
118
|
|
|
137
119
|
```bash
|
|
138
|
-
ape
|
|
120
|
+
ape-system-info
|
|
139
121
|
```
|
|
140
122
|
|
|
141
123
|
Output (example):
|
|
@@ -151,25 +133,4 @@ Package manager(s): brew
|
|
|
151
133
|
Available tools: rg, fd, jq, git, curl, docker, tar, rsync, sed, awk
|
|
152
134
|
```
|
|
153
135
|
|
|
154
|
-
## Executing commands
|
|
155
|
-
|
|
156
|
-
If you pass `--execute` or `-e`, the tool will run the command for you after printing it! Be careful with this as LLMs often make mistakes:
|
|
157
|
-
|
|
158
|
-
```bash
|
|
159
|
-
ape "Who am I logged in as?"
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
Output:
|
|
163
|
-
|
|
164
|
-
```text
|
|
165
|
-
whoami
|
|
166
|
-
jdoe
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
For more help:
|
|
170
|
-
|
|
171
|
-
```bash
|
|
172
|
-
ape --help
|
|
173
|
-
```
|
|
174
|
-
|
|
175
136
|
See also: [Gorilla](https://github.com/gorilla-llm)
|
|
@@ -65,32 +65,16 @@ you should get:
|
|
|
65
65
|
echo "Please try again."
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
-
You can change the model
|
|
69
|
-
`provider:name` form.
|
|
68
|
+
You can change the model with the `APE_MODEL` environment variable. Models are
|
|
69
|
+
specified in `provider:name` form.
|
|
70
70
|
See [here](https://ai.pydantic.dev/models/) for the supported providers and models.
|
|
71
71
|
For example:
|
|
72
72
|
|
|
73
|
-
```bash
|
|
74
|
-
ape "List the contents of the working directory with as much detail as possible" --model anthropic:claude-sonnet-4-5
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
The model is resolved as follows:
|
|
78
|
-
|
|
79
|
-
1. If `--model`/`-m` is given, that value is always used.
|
|
80
|
-
2. Otherwise, if the `APE_MODEL` environment variable is set, its value is used.
|
|
81
|
-
3. Otherwise, the default `openai-chat:gpt-4.1` is used.
|
|
82
|
-
|
|
83
|
-
So you can set a personal default without passing `--model` every time:
|
|
84
|
-
|
|
85
73
|
```bash
|
|
86
74
|
export APE_MODEL=anthropic:claude-sonnet-4-5
|
|
87
75
|
```
|
|
88
76
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
```text
|
|
92
|
-
ls -lha
|
|
93
|
-
```
|
|
77
|
+
If `APE_MODEL` is unset, the default `openai-chat:gpt-4.1` is used.
|
|
94
78
|
|
|
95
79
|
## System-aware suggestions
|
|
96
80
|
|
|
@@ -110,11 +94,11 @@ This is all gathered locally with the Python standard library and is best-effort
|
|
|
110
94
|
anything can't be determined it is simply left out. **No identifying information is
|
|
111
95
|
collected or sent** — never your username, hostname, working directory, or home path.
|
|
112
96
|
|
|
113
|
-
To see exactly what Ape detects and sends (without calling the model),
|
|
114
|
-
|
|
97
|
+
To see exactly what Ape detects and sends (without calling the model), run
|
|
98
|
+
`ape-system-info`:
|
|
115
99
|
|
|
116
100
|
```bash
|
|
117
|
-
ape
|
|
101
|
+
ape-system-info
|
|
118
102
|
```
|
|
119
103
|
|
|
120
104
|
Output (example):
|
|
@@ -130,25 +114,4 @@ Package manager(s): brew
|
|
|
130
114
|
Available tools: rg, fd, jq, git, curl, docker, tar, rsync, sed, awk
|
|
131
115
|
```
|
|
132
116
|
|
|
133
|
-
## Executing commands
|
|
134
|
-
|
|
135
|
-
If you pass `--execute` or `-e`, the tool will run the command for you after printing it! Be careful with this as LLMs often make mistakes:
|
|
136
|
-
|
|
137
|
-
```bash
|
|
138
|
-
ape "Who am I logged in as?"
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
Output:
|
|
142
|
-
|
|
143
|
-
```text
|
|
144
|
-
whoami
|
|
145
|
-
jdoe
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
For more help:
|
|
149
|
-
|
|
150
|
-
```bash
|
|
151
|
-
ape --help
|
|
152
|
-
```
|
|
153
|
-
|
|
154
117
|
See also: [Gorilla](https://github.com/gorilla-llm)
|
|
@@ -3,32 +3,31 @@
|
|
|
3
3
|
import os
|
|
4
4
|
import platform
|
|
5
5
|
import shutil
|
|
6
|
-
import
|
|
7
|
-
from importlib.metadata import version
|
|
8
|
-
from typing import Annotated
|
|
6
|
+
import sys
|
|
9
7
|
|
|
10
|
-
import rich.console
|
|
11
|
-
import typer
|
|
12
8
|
from pydantic_ai import Agent
|
|
13
9
|
from pydantic_ai.exceptions import ModelHTTPError
|
|
14
10
|
|
|
15
|
-
__version__ = version("ape_linux")
|
|
16
|
-
|
|
17
11
|
DEFAULT_MODEL = "openai-chat:gpt-4.1"
|
|
18
12
|
|
|
19
|
-
|
|
13
|
+
HELP = """\
|
|
14
|
+
ape — AI for Linux commands.
|
|
15
|
+
|
|
16
|
+
Usage: ape QUERY
|
|
20
17
|
|
|
18
|
+
Describe a Linux task in QUERY and ape prints a shell command for it.
|
|
21
19
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
raise typer.Exit()
|
|
20
|
+
Example:
|
|
21
|
+
ape "Create a symbolic link named 'win' pointing to /mnt/c/Users/jdoe"
|
|
22
|
+
ln -s /mnt/c/Users/jdoe win
|
|
26
23
|
|
|
24
|
+
The model is read from the APE_MODEL environment variable in provider:name form
|
|
25
|
+
(e.g. anthropic:claude-sonnet-4-5), falling back to {default}. See
|
|
26
|
+
https://ai.pydantic.dev/models/. Credentials come from each provider's standard
|
|
27
|
+
environment variable (e.g. OPENAI_API_KEY, ANTHROPIC_API_KEY).
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
typer.echo(detect_system_context())
|
|
31
|
-
raise typer.Exit()
|
|
29
|
+
Run `ape-system-info` to print the detected system context sent to the model.\
|
|
30
|
+
""".format(default=DEFAULT_MODEL)
|
|
32
31
|
|
|
33
32
|
|
|
34
33
|
def call_llm(model: str, system_prompt: str, user_prompt: str) -> str | None:
|
|
@@ -92,6 +91,25 @@ def detect_system_context() -> str:
|
|
|
92
91
|
if distribution:
|
|
93
92
|
lines.append(f"Distribution: {distribution}")
|
|
94
93
|
|
|
94
|
+
# WSL (Windows Subsystem for Linux). WSL2 sets WSL_DISTRO_NAME /
|
|
95
|
+
# WSL_INTEROP, and both WSL1 and WSL2 ship a kernel whose release
|
|
96
|
+
# string contains "microsoft" (e.g. 5.15.0-microsoft-standard-WSL2).
|
|
97
|
+
# platform.uname() never raises. This matters because Windows drives
|
|
98
|
+
# are mounted under /mnt and interop tools like clip.exe are available.
|
|
99
|
+
try:
|
|
100
|
+
release = platform.uname().release
|
|
101
|
+
except Exception:
|
|
102
|
+
release = ""
|
|
103
|
+
if (
|
|
104
|
+
os.environ.get("WSL_DISTRO_NAME")
|
|
105
|
+
or os.environ.get("WSL_INTEROP")
|
|
106
|
+
or "microsoft" in release.lower()
|
|
107
|
+
):
|
|
108
|
+
lines.append(
|
|
109
|
+
"WSL: yes (Windows Subsystem for Linux; "
|
|
110
|
+
"Windows drives under /mnt, interop tools like clip.exe available)"
|
|
111
|
+
)
|
|
112
|
+
|
|
95
113
|
# CPU architecture (e.g. arm64 vs x86_64) — affects Homebrew prefixes and
|
|
96
114
|
# binary/platform names. Never raises; may be empty.
|
|
97
115
|
machine = platform.machine()
|
|
@@ -149,64 +167,26 @@ def detect_system_context() -> str:
|
|
|
149
167
|
return "\n".join(lines)
|
|
150
168
|
|
|
151
169
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
help=(
|
|
163
|
-
"Model in provider:name form, e.g. anthropic:claude-sonnet-4-5. "
|
|
164
|
-
"See https://ai.pydantic.dev/models/. If unset, the APE_MODEL "
|
|
165
|
-
f"env var is used, falling back to {DEFAULT_MODEL}."
|
|
166
|
-
),
|
|
167
|
-
),
|
|
168
|
-
] = None,
|
|
169
|
-
execute: Annotated[
|
|
170
|
-
bool,
|
|
171
|
-
typer.Option(
|
|
172
|
-
"--execute",
|
|
173
|
-
"-e",
|
|
174
|
-
help="Run the command if suggested. Dangerous!",
|
|
175
|
-
),
|
|
176
|
-
] = False,
|
|
177
|
-
system_info: Annotated[
|
|
178
|
-
bool | None,
|
|
179
|
-
typer.Option(
|
|
180
|
-
"--system-info",
|
|
181
|
-
"-s",
|
|
182
|
-
callback=system_info_callback,
|
|
183
|
-
help="Show the detected system context sent to the model, then exit.",
|
|
184
|
-
is_eager=True,
|
|
185
|
-
),
|
|
186
|
-
] = None,
|
|
187
|
-
version: Annotated[
|
|
188
|
-
bool | None,
|
|
189
|
-
typer.Option(
|
|
190
|
-
"--version",
|
|
191
|
-
"-v",
|
|
192
|
-
callback=version_callback,
|
|
193
|
-
help="Show the version and exit.",
|
|
194
|
-
is_eager=True,
|
|
195
|
-
),
|
|
196
|
-
] = None,
|
|
197
|
-
):
|
|
198
|
-
"""Suggest a command for a Linux task described in QUERY.
|
|
199
|
-
|
|
200
|
-
Example: ape "Create a symbolic link named 'win' pointing to /mnt/c/Users/jdoe"
|
|
201
|
-
|
|
202
|
-
Output : ln -s /mnt/c/Users/jdoe win
|
|
170
|
+
def system_info() -> None:
|
|
171
|
+
"""Entry point for `ape-system-info`: print the detected system context."""
|
|
172
|
+
print(detect_system_context())
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def main() -> None:
|
|
176
|
+
"""Entry point for `ape`: suggest a command for the task in the arguments.
|
|
177
|
+
|
|
178
|
+
The query is taken from the command-line arguments (``sys.argv``). With no
|
|
179
|
+
arguments, the help text is printed and the program exits non-zero.
|
|
203
180
|
"""
|
|
204
181
|
|
|
205
|
-
|
|
182
|
+
args = sys.argv[1:]
|
|
183
|
+
if not args:
|
|
184
|
+
print(HELP)
|
|
185
|
+
raise SystemExit(1)
|
|
186
|
+
query = " ".join(args)
|
|
206
187
|
|
|
207
|
-
#
|
|
208
|
-
|
|
209
|
-
model = os.environ.get("APE_MODEL") or DEFAULT_MODEL
|
|
188
|
+
# The model is read from APE_MODEL, falling back to the default.
|
|
189
|
+
model = os.environ.get("APE_MODEL") or DEFAULT_MODEL
|
|
210
190
|
|
|
211
191
|
system_prompt = """\
|
|
212
192
|
You are a Linux command assistant. You will be asked a question about how to
|
|
@@ -264,19 +244,16 @@ def main(
|
|
|
264
244
|
Answer:"""
|
|
265
245
|
|
|
266
246
|
try:
|
|
267
|
-
|
|
268
|
-
answer = call_llm(model, system_prompt, user_prompt)
|
|
247
|
+
answer = call_llm(model, system_prompt, user_prompt)
|
|
269
248
|
except ModelHTTPError as error:
|
|
270
|
-
|
|
271
|
-
raise
|
|
249
|
+
print(f"{error.status_code} error: {error.message}", file=sys.stderr)
|
|
250
|
+
raise SystemExit(1)
|
|
272
251
|
except Exception as error:
|
|
273
252
|
# Anything else (missing/invalid credentials, unknown provider, etc.)
|
|
274
253
|
# surfaces as a one-line message rather than a traceback.
|
|
275
|
-
|
|
276
|
-
raise
|
|
254
|
+
print(str(error), file=sys.stderr)
|
|
255
|
+
raise SystemExit(1)
|
|
277
256
|
|
|
278
257
|
if answer is None:
|
|
279
258
|
answer = 'echo "Please try again."'
|
|
280
|
-
|
|
281
|
-
if execute:
|
|
282
|
-
subprocess.check_call(answer, shell=True)
|
|
259
|
+
print(answer)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "ape-linux"
|
|
3
|
-
version = "0.4.
|
|
3
|
+
version = "0.4.2"
|
|
4
4
|
description = "AI for Linux commands"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = {text = "MIT License"}
|
|
@@ -20,8 +20,6 @@ classifiers = [
|
|
|
20
20
|
requires-python = ">=3.10"
|
|
21
21
|
dependencies = [
|
|
22
22
|
"pydantic-ai-slim[anthropic,google,groq,mistral,openai]>=1.104.0",
|
|
23
|
-
"rich>=14.0.0",
|
|
24
|
-
"typer>=0.15.2",
|
|
25
23
|
]
|
|
26
24
|
|
|
27
25
|
[project.urls]
|
|
@@ -29,7 +27,8 @@ Homepage = "https://github.com/sbalian/ape"
|
|
|
29
27
|
Repository = "https://github.com/sbalian/ape"
|
|
30
28
|
|
|
31
29
|
[project.scripts]
|
|
32
|
-
ape = "ape_linux:
|
|
30
|
+
ape = "ape_linux:main"
|
|
31
|
+
ape-system-info = "ape_linux:system_info"
|
|
33
32
|
|
|
34
33
|
[build-system]
|
|
35
34
|
requires = ["hatchling"]
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import getpass
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
import socket
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from pydantic_ai.exceptions import ModelHTTPError
|
|
8
|
+
|
|
9
|
+
import ape_linux
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def mockenv(monkeypatch):
|
|
14
|
+
monkeypatch.setenv("OPENAI_API_KEY", "key")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run(monkeypatch, argv):
|
|
18
|
+
"""Run ape_linux.main() with the given argv, returning the SystemExit code.
|
|
19
|
+
|
|
20
|
+
main() never calls sys.exit() on the success path, so a clean run returns 0.
|
|
21
|
+
"""
|
|
22
|
+
monkeypatch.setattr("sys.argv", ["ape", *argv])
|
|
23
|
+
try:
|
|
24
|
+
ape_linux.main()
|
|
25
|
+
except SystemExit as exit:
|
|
26
|
+
return exit.code or 0
|
|
27
|
+
return 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_app_for_suggestion(mockenv, monkeypatch, capsys):
|
|
31
|
+
monkeypatch.setattr("ape_linux.call_llm", lambda *args, **kwargs: "ls")
|
|
32
|
+
code = run(monkeypatch, ["list all the files"])
|
|
33
|
+
captured = capsys.readouterr()
|
|
34
|
+
assert captured.out == "ls\n"
|
|
35
|
+
assert captured.err == ""
|
|
36
|
+
assert code == 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_app_joins_multiple_args_into_query(mockenv, monkeypatch, capsys):
|
|
40
|
+
captured_prompt = {}
|
|
41
|
+
|
|
42
|
+
def mockreturn(model, system_prompt, user_prompt):
|
|
43
|
+
captured_prompt["user"] = user_prompt
|
|
44
|
+
return "ls"
|
|
45
|
+
|
|
46
|
+
monkeypatch.setattr("ape_linux.call_llm", mockreturn)
|
|
47
|
+
run(monkeypatch, ["list", "all", "the", "files"])
|
|
48
|
+
assert "list all the files" in captured_prompt["user"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_app_prints_help_and_exits_when_no_args(monkeypatch, capsys):
|
|
52
|
+
code = run(monkeypatch, [])
|
|
53
|
+
captured = capsys.readouterr()
|
|
54
|
+
assert "Usage: ape QUERY" in captured.out
|
|
55
|
+
assert code == 1
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_app_for_try_again_if_api_returns_none(mockenv, monkeypatch, capsys):
|
|
59
|
+
monkeypatch.setattr("ape_linux.call_llm", lambda *args, **kwargs: None)
|
|
60
|
+
code = run(monkeypatch, ["what is the capital of England?"])
|
|
61
|
+
captured = capsys.readouterr()
|
|
62
|
+
assert captured.out == 'echo "Please try again."\n'
|
|
63
|
+
assert captured.err == ""
|
|
64
|
+
assert code == 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_app_with_api_error(mockenv, monkeypatch, capsys):
|
|
68
|
+
def mockreturn(*args, **kwargs):
|
|
69
|
+
raise ModelHTTPError(
|
|
70
|
+
status_code=500, model_name="openai-chat:gpt-4o", body=None
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
monkeypatch.setattr("ape_linux.call_llm", mockreturn)
|
|
74
|
+
code = run(monkeypatch, ["list all the files"])
|
|
75
|
+
captured = capsys.readouterr()
|
|
76
|
+
assert captured.out == ""
|
|
77
|
+
assert code == 1
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_app_with_no_api_key(mockenv, monkeypatch, capsys):
|
|
81
|
+
monkeypatch.delenv("OPENAI_API_KEY")
|
|
82
|
+
code = run(monkeypatch, ["list all the files"])
|
|
83
|
+
captured = capsys.readouterr()
|
|
84
|
+
assert captured.out == ""
|
|
85
|
+
assert captured.err != ""
|
|
86
|
+
assert code == 1
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_app_uses_default_model_when_unset(mockenv, monkeypatch):
|
|
90
|
+
captured = {}
|
|
91
|
+
|
|
92
|
+
def mockreturn(model, *args, **kwargs):
|
|
93
|
+
captured["model"] = model
|
|
94
|
+
return "ls"
|
|
95
|
+
|
|
96
|
+
monkeypatch.delenv("APE_MODEL", raising=False)
|
|
97
|
+
monkeypatch.setattr("ape_linux.call_llm", mockreturn)
|
|
98
|
+
code = run(monkeypatch, ["list all the files"])
|
|
99
|
+
assert captured["model"] == ape_linux.DEFAULT_MODEL
|
|
100
|
+
assert ape_linux.DEFAULT_MODEL == "openai-chat:gpt-4.1"
|
|
101
|
+
assert code == 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_app_uses_ape_model_env_var(mockenv, monkeypatch):
|
|
105
|
+
captured = {}
|
|
106
|
+
|
|
107
|
+
def mockreturn(model, *args, **kwargs):
|
|
108
|
+
captured["model"] = model
|
|
109
|
+
return "ls"
|
|
110
|
+
|
|
111
|
+
monkeypatch.setenv("APE_MODEL", "anthropic:claude-sonnet-4-5")
|
|
112
|
+
monkeypatch.setattr("ape_linux.call_llm", mockreturn)
|
|
113
|
+
code = run(monkeypatch, ["list all the files"])
|
|
114
|
+
assert captured["model"] == "anthropic:claude-sonnet-4-5"
|
|
115
|
+
assert code == 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_system_info_entry_point(capsys):
|
|
119
|
+
ape_linux.system_info()
|
|
120
|
+
captured = capsys.readouterr()
|
|
121
|
+
assert captured.out == f"{ape_linux.detect_system_context()}\n"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_detect_system_context_returns_str_and_never_crashes():
|
|
125
|
+
context = ape_linux.detect_system_context()
|
|
126
|
+
assert isinstance(context, str)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_detect_system_context_reports_wsl(monkeypatch):
|
|
130
|
+
# Force the Linux branch and a WSL marker, regardless of the host.
|
|
131
|
+
monkeypatch.setattr(ape_linux.platform, "system", lambda: "Linux")
|
|
132
|
+
monkeypatch.setenv("WSL_DISTRO_NAME", "Ubuntu")
|
|
133
|
+
context = ape_linux.detect_system_context()
|
|
134
|
+
assert "WSL: yes" in context
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_detect_system_context_omits_wsl_when_absent(monkeypatch):
|
|
138
|
+
monkeypatch.setattr(ape_linux.platform, "system", lambda: "Linux")
|
|
139
|
+
monkeypatch.delenv("WSL_DISTRO_NAME", raising=False)
|
|
140
|
+
monkeypatch.delenv("WSL_INTEROP", raising=False)
|
|
141
|
+
monkeypatch.setattr(
|
|
142
|
+
ape_linux.platform, "uname", lambda: platform.uname_result(
|
|
143
|
+
"Linux", "host", "5.15.0-generic", "#1", "x86_64"
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
context = ape_linux.detect_system_context()
|
|
147
|
+
assert "WSL:" not in context
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_detect_system_context_reports_operating_system():
|
|
151
|
+
# platform.system() is non-empty on every supported platform.
|
|
152
|
+
context = ape_linux.detect_system_context()
|
|
153
|
+
assert "Operating system:" in context
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_detect_system_context_excludes_identifying_info():
|
|
157
|
+
context = ape_linux.detect_system_context()
|
|
158
|
+
|
|
159
|
+
# No working directory or home/directory structure.
|
|
160
|
+
assert os.getcwd() not in context
|
|
161
|
+
assert os.path.expanduser("~") not in context
|
|
162
|
+
|
|
163
|
+
# No hostname.
|
|
164
|
+
hostname = socket.gethostname()
|
|
165
|
+
if hostname:
|
|
166
|
+
assert hostname not in context
|
|
167
|
+
|
|
168
|
+
# No username. ("root" legitimately appears in the privilege line, so
|
|
169
|
+
# skip that unavoidable collision when running as root.)
|
|
170
|
+
username = getpass.getuser()
|
|
171
|
+
if username and username != "root":
|
|
172
|
+
assert username not in context
|
|
@@ -2,15 +2,6 @@ version = 1
|
|
|
2
2
|
revision = 3
|
|
3
3
|
requires-python = ">=3.10"
|
|
4
4
|
|
|
5
|
-
[[package]]
|
|
6
|
-
name = "annotated-doc"
|
|
7
|
-
version = "0.0.4"
|
|
8
|
-
source = { registry = "https://pypi.org/simple" }
|
|
9
|
-
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
|
|
10
|
-
wheels = [
|
|
11
|
-
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
|
|
12
|
-
]
|
|
13
|
-
|
|
14
5
|
[[package]]
|
|
15
6
|
name = "annotated-types"
|
|
16
7
|
version = "0.7.0"
|
|
@@ -55,12 +46,10 @@ wheels = [
|
|
|
55
46
|
|
|
56
47
|
[[package]]
|
|
57
48
|
name = "ape-linux"
|
|
58
|
-
version = "0.4.
|
|
49
|
+
version = "0.4.1"
|
|
59
50
|
source = { editable = "." }
|
|
60
51
|
dependencies = [
|
|
61
52
|
{ name = "pydantic-ai-slim", extra = ["anthropic", "google", "groq", "mistral", "openai"] },
|
|
62
|
-
{ name = "rich" },
|
|
63
|
-
{ name = "typer" },
|
|
64
53
|
]
|
|
65
54
|
|
|
66
55
|
[package.dev-dependencies]
|
|
@@ -71,11 +60,7 @@ dev = [
|
|
|
71
60
|
]
|
|
72
61
|
|
|
73
62
|
[package.metadata]
|
|
74
|
-
requires-dist = [
|
|
75
|
-
{ name = "pydantic-ai-slim", extras = ["anthropic", "google", "groq", "mistral", "openai"], specifier = ">=1.104.0" },
|
|
76
|
-
{ name = "rich", specifier = ">=14.0.0" },
|
|
77
|
-
{ name = "typer", specifier = ">=0.15.2" },
|
|
78
|
-
]
|
|
63
|
+
requires-dist = [{ name = "pydantic-ai-slim", extras = ["anthropic", "google", "groq", "mistral", "openai"], specifier = ">=1.104.0" }]
|
|
79
64
|
|
|
80
65
|
[package.metadata.requires-dev]
|
|
81
66
|
dev = [
|
|
@@ -682,27 +667,6 @@ wheels = [
|
|
|
682
667
|
{ url = "https://files.pythonhosted.org/packages/97/b9/639818adb83dad11e9b21b4ad1906883b98732b06959257e33f7d7f14db9/logfire_api-4.34.0-py3-none-any.whl", hash = "sha256:ca51498bb60e1e4505c90cc20f949d2d6dcf9d15cf5fd5a46f14016f2f638c4c", size = 130718, upload-time = "2026-05-26T18:09:50.551Z" },
|
|
683
668
|
]
|
|
684
669
|
|
|
685
|
-
[[package]]
|
|
686
|
-
name = "markdown-it-py"
|
|
687
|
-
version = "4.2.0"
|
|
688
|
-
source = { registry = "https://pypi.org/simple" }
|
|
689
|
-
dependencies = [
|
|
690
|
-
{ name = "mdurl" },
|
|
691
|
-
]
|
|
692
|
-
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
|
|
693
|
-
wheels = [
|
|
694
|
-
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
|
|
695
|
-
]
|
|
696
|
-
|
|
697
|
-
[[package]]
|
|
698
|
-
name = "mdurl"
|
|
699
|
-
version = "0.1.2"
|
|
700
|
-
source = { registry = "https://pypi.org/simple" }
|
|
701
|
-
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
|
702
|
-
wheels = [
|
|
703
|
-
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
|
704
|
-
]
|
|
705
|
-
|
|
706
670
|
[[package]]
|
|
707
671
|
name = "mistralai"
|
|
708
672
|
version = "2.4.8"
|
|
@@ -1173,19 +1137,6 @@ wheels = [
|
|
|
1173
1137
|
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
|
1174
1138
|
]
|
|
1175
1139
|
|
|
1176
|
-
[[package]]
|
|
1177
|
-
name = "rich"
|
|
1178
|
-
version = "15.0.0"
|
|
1179
|
-
source = { registry = "https://pypi.org/simple" }
|
|
1180
|
-
dependencies = [
|
|
1181
|
-
{ name = "markdown-it-py" },
|
|
1182
|
-
{ name = "pygments" },
|
|
1183
|
-
]
|
|
1184
|
-
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
|
|
1185
|
-
wheels = [
|
|
1186
|
-
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
|
1187
|
-
]
|
|
1188
|
-
|
|
1189
1140
|
[[package]]
|
|
1190
1141
|
name = "ruff"
|
|
1191
1142
|
version = "0.15.15"
|
|
@@ -1211,15 +1162,6 @@ wheels = [
|
|
|
1211
1162
|
{ url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" },
|
|
1212
1163
|
]
|
|
1213
1164
|
|
|
1214
|
-
[[package]]
|
|
1215
|
-
name = "shellingham"
|
|
1216
|
-
version = "1.5.4"
|
|
1217
|
-
source = { registry = "https://pypi.org/simple" }
|
|
1218
|
-
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
|
|
1219
|
-
wheels = [
|
|
1220
|
-
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
|
1221
|
-
]
|
|
1222
|
-
|
|
1223
1165
|
[[package]]
|
|
1224
1166
|
name = "six"
|
|
1225
1167
|
version = "1.17.0"
|
|
@@ -1399,21 +1341,6 @@ wheels = [
|
|
|
1399
1341
|
{ url = "https://files.pythonhosted.org/packages/04/87/369056ed46f1b235130ec0595393262f9cd2061ca3dab276d490980f9343/ty-0.0.40-py3-none-win_arm64.whl", hash = "sha256:07da2b09d9130e2c9a257d2a29beb53105835b0256ee5fdb288fe1aab83fee47", size = 11117369, upload-time = "2026-05-27T17:55:39.329Z" },
|
|
1400
1342
|
]
|
|
1401
1343
|
|
|
1402
|
-
[[package]]
|
|
1403
|
-
name = "typer"
|
|
1404
|
-
version = "0.26.4"
|
|
1405
|
-
source = { registry = "https://pypi.org/simple" }
|
|
1406
|
-
dependencies = [
|
|
1407
|
-
{ name = "annotated-doc" },
|
|
1408
|
-
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
1409
|
-
{ name = "rich" },
|
|
1410
|
-
{ name = "shellingham" },
|
|
1411
|
-
]
|
|
1412
|
-
sdist = { url = "https://files.pythonhosted.org/packages/8e/d3/90c1ee19209cb59f6ad185883fd4ccfcf72f8f0bfd549d5a8b70474611d0/typer-0.26.4.tar.gz", hash = "sha256:25b128964de66c5ea36d5ac82adc579e5e113509b17469edf9f5a4a1864ff2a9", size = 201191, upload-time = "2026-05-30T17:05:04.213Z" }
|
|
1413
|
-
wheels = [
|
|
1414
|
-
{ url = "https://files.pythonhosted.org/packages/f0/6d/5a525c69df4a90892135e5d490b00e9e46402491f3416d4395fcb0d0201e/typer-0.26.4-py3-none-any.whl", hash = "sha256:11bfd7b43557137e373c2b10f6967a555f9678a61ed72c808968b011d95534d6", size = 122436, upload-time = "2026-05-30T17:05:05.812Z" },
|
|
1415
|
-
]
|
|
1416
|
-
|
|
1417
1344
|
[[package]]
|
|
1418
1345
|
name = "typing-extensions"
|
|
1419
1346
|
version = "4.15.0"
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
import getpass
|
|
2
|
-
import os
|
|
3
|
-
import socket
|
|
4
|
-
|
|
5
|
-
import pytest
|
|
6
|
-
import typer.testing
|
|
7
|
-
from pydantic_ai.exceptions import ModelHTTPError
|
|
8
|
-
|
|
9
|
-
import ape_linux
|
|
10
|
-
|
|
11
|
-
runner = typer.testing.CliRunner()
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@pytest.fixture
|
|
15
|
-
def mockenv(monkeypatch):
|
|
16
|
-
monkeypatch.setenv("OPENAI_API_KEY", "key")
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def test_app_for_suggestion(mockenv, monkeypatch):
|
|
20
|
-
monkeypatch.setattr("ape_linux.call_llm", lambda *args, **kwargs: "ls")
|
|
21
|
-
result = runner.invoke(ape_linux.app, ["list all the files"])
|
|
22
|
-
assert result.stdout == "ls\n"
|
|
23
|
-
assert result.stderr == ""
|
|
24
|
-
assert result.exit_code == 0
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def test_app_for_try_again_if_api_returns_none(mockenv, monkeypatch):
|
|
28
|
-
monkeypatch.setattr("ape_linux.call_llm", lambda *args, **kwargs: None)
|
|
29
|
-
result = runner.invoke(ape_linux.app, ["what is the capital of England?"])
|
|
30
|
-
assert result.stdout == 'echo "Please try again."\n'
|
|
31
|
-
assert result.stderr == ""
|
|
32
|
-
assert result.exit_code == 0
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def test_app_with_api_error(mockenv, monkeypatch):
|
|
36
|
-
def mockreturn(*args, **kwargs):
|
|
37
|
-
raise ModelHTTPError(
|
|
38
|
-
status_code=500, model_name="openai-chat:gpt-4o", body=None
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
monkeypatch.setattr("ape_linux.call_llm", mockreturn)
|
|
42
|
-
result = runner.invoke(ape_linux.app, ["list all the files"])
|
|
43
|
-
assert result.stdout == ""
|
|
44
|
-
assert result.exit_code == 1
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def test_app_with_no_api_key(mockenv, monkeypatch):
|
|
48
|
-
monkeypatch.delenv("OPENAI_API_KEY")
|
|
49
|
-
result = runner.invoke(ape_linux.app, ["list all the files"])
|
|
50
|
-
assert result.stdout == ""
|
|
51
|
-
assert result.stderr != ""
|
|
52
|
-
assert result.exit_code == 1
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def test_app_for_suggestion_with_execute(mockenv, monkeypatch):
|
|
56
|
-
monkeypatch.setattr("ape_linux.call_llm", lambda *args, **kwargs: "ls")
|
|
57
|
-
result = runner.invoke(ape_linux.app, ["list all the files", "--execute"])
|
|
58
|
-
assert result.stdout == "ls\n" # careful, this will actually run
|
|
59
|
-
assert result.stderr == ""
|
|
60
|
-
assert result.exit_code == 0
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def test_app_uses_default_model_when_unset(mockenv, monkeypatch):
|
|
64
|
-
captured = {}
|
|
65
|
-
|
|
66
|
-
def mockreturn(model, *args, **kwargs):
|
|
67
|
-
captured["model"] = model
|
|
68
|
-
return "ls"
|
|
69
|
-
|
|
70
|
-
monkeypatch.delenv("APE_MODEL", raising=False)
|
|
71
|
-
monkeypatch.setattr("ape_linux.call_llm", mockreturn)
|
|
72
|
-
result = runner.invoke(ape_linux.app, ["list all the files"])
|
|
73
|
-
assert captured["model"] == ape_linux.DEFAULT_MODEL
|
|
74
|
-
assert ape_linux.DEFAULT_MODEL == "openai-chat:gpt-4.1"
|
|
75
|
-
assert result.exit_code == 0
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def test_app_uses_ape_model_env_var(mockenv, monkeypatch):
|
|
79
|
-
captured = {}
|
|
80
|
-
|
|
81
|
-
def mockreturn(model, *args, **kwargs):
|
|
82
|
-
captured["model"] = model
|
|
83
|
-
return "ls"
|
|
84
|
-
|
|
85
|
-
monkeypatch.setenv("APE_MODEL", "anthropic:claude-sonnet-4-5")
|
|
86
|
-
monkeypatch.setattr("ape_linux.call_llm", mockreturn)
|
|
87
|
-
result = runner.invoke(ape_linux.app, ["list all the files"])
|
|
88
|
-
assert captured["model"] == "anthropic:claude-sonnet-4-5"
|
|
89
|
-
assert result.exit_code == 0
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def test_app_model_option_overrides_env_var(mockenv, monkeypatch):
|
|
93
|
-
captured = {}
|
|
94
|
-
|
|
95
|
-
def mockreturn(model, *args, **kwargs):
|
|
96
|
-
captured["model"] = model
|
|
97
|
-
return "ls"
|
|
98
|
-
|
|
99
|
-
monkeypatch.setenv("APE_MODEL", "anthropic:claude-sonnet-4-5")
|
|
100
|
-
monkeypatch.setattr("ape_linux.call_llm", mockreturn)
|
|
101
|
-
result = runner.invoke(
|
|
102
|
-
ape_linux.app, ["list all the files", "--model", "groq:llama-3.3-70b"]
|
|
103
|
-
)
|
|
104
|
-
assert captured["model"] == "groq:llama-3.3-70b"
|
|
105
|
-
assert result.exit_code == 0
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def test_app_for_version(mockenv):
|
|
109
|
-
result = runner.invoke(ape_linux.app, ["--version"])
|
|
110
|
-
assert result.stdout == f"{ape_linux.__version__}\n"
|
|
111
|
-
assert result.stderr == ""
|
|
112
|
-
assert result.exit_code == 0
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def test_app_system_info_flag(mockenv, monkeypatch):
|
|
116
|
-
# The flag must print the context and exit without ever calling the LLM.
|
|
117
|
-
def fail(*args, **kwargs):
|
|
118
|
-
raise AssertionError("call_llm should not be invoked for --system-info")
|
|
119
|
-
|
|
120
|
-
monkeypatch.setattr("ape_linux.call_llm", fail)
|
|
121
|
-
result = runner.invoke(ape_linux.app, ["--system-info"])
|
|
122
|
-
assert result.stdout == f"{ape_linux.detect_system_context()}\n"
|
|
123
|
-
assert result.exit_code == 0
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def test_detect_system_context_returns_str_and_never_crashes():
|
|
127
|
-
context = ape_linux.detect_system_context()
|
|
128
|
-
assert isinstance(context, str)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def test_detect_system_context_reports_operating_system():
|
|
132
|
-
# platform.system() is non-empty on every supported platform.
|
|
133
|
-
context = ape_linux.detect_system_context()
|
|
134
|
-
assert "Operating system:" in context
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def test_detect_system_context_excludes_identifying_info():
|
|
138
|
-
context = ape_linux.detect_system_context()
|
|
139
|
-
|
|
140
|
-
# No working directory or home/directory structure.
|
|
141
|
-
assert os.getcwd() not in context
|
|
142
|
-
assert os.path.expanduser("~") not in context
|
|
143
|
-
|
|
144
|
-
# No hostname.
|
|
145
|
-
hostname = socket.gethostname()
|
|
146
|
-
if hostname:
|
|
147
|
-
assert hostname not in context
|
|
148
|
-
|
|
149
|
-
# No username. ("root" legitimately appears in the privilege line, so
|
|
150
|
-
# skip that unavoidable collision when running as root.)
|
|
151
|
-
username = getpass.getuser()
|
|
152
|
-
if username and username != "root":
|
|
153
|
-
assert username not in context
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|