ape-linux 0.4.5__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.
@@ -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 (uv run ruff check .)
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
- **only** a runnable command — no Markdown fences or `echo "Please try again."`
58
- for anything off-topic. This prompt is the core product behavior; changes to it
59
- directly change what the tool outputs. `main()` then appends a system-context block
60
- (see below) so suggestions match the current machine.
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. Models
74
- are passed through verbatim in `provider:name` form (e.g. `anthropic:claude-sonnet-4-5`),
75
- so Ape supports any provider Pydantic AI supports without provider-specific code.
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`).
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. The `mockenv` fixture sets a
90
- dummy `OPENAI_API_KEY`.
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.5
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 try to ask something unrelated to Linux commands:
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
- you should get:
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
- echo "Please try again."
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 try to ask something unrelated to Linux commands:
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
- you should get:
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
- echo "Please try again."
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 pydantic_ai import Agent
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
- def call_llm(model: str, system_prompt: str, user_prompt: str) -> str | None:
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
- model_settings={"temperature": 0.2},
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 agent.run_sync(user_prompt).output
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
- system_prompt = """\
192
- You are a Linux command assistant. You will be asked a question about how to
193
- perform a task in Linux or Unix-like operating systems. You should only include
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
- ```sh
201
- cd projects
202
- ls
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
- Instead, your output should be a command that is to be entered directly into the
206
- command line. For the example above this is: cd projects && ls
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
- You are also allowed to use \\ for command continuation.
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
- Answer: ls ~/projects
269
+ Command: ls ~/projects
214
270
 
215
271
  Question: Navigate to projects and list its contents
216
- Answer: cd projects && ls
272
+ Command: cd projects && ls
217
273
 
218
274
  Question: What is my username?
219
- Answer: whoami
275
+ Command: whoami
220
276
 
221
277
  Question: Find all files with the extension .txt under the current working directory
222
- Answer: find . -name "*.txt"
278
+ Command: find . -name "*.txt"
223
279
 
224
- Question: What is the captial of France?
225
- Answer: echo "Please try again."
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
- Answer: echo "Please try again.\""""
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
- answer = call_llm(model, system_prompt, user_prompt)
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
- if answer is None:
258
- answer = 'echo "Please try again."'
259
- print(answer)
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,7 @@
1
1
  # Lint with ruff.
2
2
  lint:
3
3
  uv run ruff check .
4
+ uv run ruff format --check .
4
5
 
5
6
  # Type check with ty.
6
7
  type-check:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ape-linux"
3
- version = "0.4.5"
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", "I001"]
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("ape_linux.call_llm", lambda *args, **kwargs: "ls")
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 test_app_for_try_again_if_api_returns_none(mockenv, monkeypatch, capsys):
59
- monkeypatch.setattr("ape_linux.call_llm", lambda *args, **kwargs: None)
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
- assert captured.out == 'echo "Please try again."\n'
63
- assert captured.err == ""
64
- assert code == 0
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, "uname", lambda: platform.uname_result(
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