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.
@@ -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 a Typer app as the `ape` console script.
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 single Typer command. It takes the `query` argument plus
49
- `--model/-m`, `--execute/-e`, `--system-info/-s`, and `--version/-v` options.
50
- `--system-info` is an eager flag (like `--version`) whose callback prints
51
- `detect_system_context()` and exits before the LLM is called, so it works without a
52
- `query`.
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, GNU-vs-BSD
60
- userland, CPU arch, `$SHELL`, root-or-not, available package managers, and a probed
61
- list of common tools). Two invariants: it **never raises** (every probe is guarded,
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
- `--model` defaults to `None`; `main()` resolves the model as `--model` (if given)
72
- → `APE_MODEL` env var → the `DEFAULT_MODEL` constant (`openai-chat:gpt-4.1`).
73
- Credentials come from each provider's standard env var (e.g. `OPENAI_API_KEY`,
74
- `ANTHROPIC_API_KEY`).
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)`. Tracebacks are suppressed via
78
- `pretty_exceptions_enable=False` on the Typer app.
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` uses `typer.testing.CliRunner` and monkeypatches
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`. Note `test_app_for_suggestion_with_execute` mocks the LLM to
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.0
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 using `--model` or `-m`. Models are specified in
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
- Output:
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), use
135
- `--system-info` or `-s`:
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 --system-info
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 using `--model` or `-m`. Models are specified in
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
- Output:
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), use
114
- `--system-info` or `-s`:
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 --system-info
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 subprocess
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
- app = typer.Typer(add_completion=False, pretty_exceptions_enable=False)
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
- def version_callback(value: bool) -> None:
23
- if value:
24
- typer.echo(__version__)
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
- def system_info_callback(value: bool) -> None:
29
- if value:
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
- @app.command()
153
- def main(
154
- query: Annotated[
155
- str, typer.Argument(help="Query describing a Linux task.", show_default=False)
156
- ],
157
- model: Annotated[
158
- str | None,
159
- typer.Option(
160
- "--model",
161
- "-m",
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
- console = rich.console.Console()
182
+ args = sys.argv[1:]
183
+ if not args:
184
+ print(HELP)
185
+ raise SystemExit(1)
186
+ query = " ".join(args)
206
187
 
207
- # An explicit --model wins. Otherwise fall back to APE_MODEL, then the default.
208
- if model is None:
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
- with console.status("[bold][blue]Processing ...", spinner="monkey"):
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
- typer.echo(f"{error.status_code} error: {error.message}", err=True)
271
- raise typer.Exit(1)
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
- typer.echo(str(error), err=True)
276
- raise typer.Exit(1)
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
- typer.echo(answer)
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.0"
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:app"
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.0"
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