keepassxc-cli 1.3.0__tar.gz → 1.5.0__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.
Files changed (38) hide show
  1. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/CLAUDE.md +29 -11
  2. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/PKG-INFO +28 -5
  3. keepassxc_cli-1.3.0/keepassxc_cli.egg-info/PKG-INFO → keepassxc_cli-1.5.0/README.md +26 -18
  4. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/keepassxc_cli/__main__.py +22 -3
  5. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/keepassxc_cli/commands/add.py +6 -8
  6. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/keepassxc_cli/commands/clip.py +7 -5
  7. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/keepassxc_cli/commands/edit.py +9 -12
  8. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/keepassxc_cli/commands/group_uuid.py +4 -6
  9. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/keepassxc_cli/commands/lock.py +4 -2
  10. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/keepassxc_cli/commands/mkdir.py +3 -4
  11. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/keepassxc_cli/commands/rm.py +6 -8
  12. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/keepassxc_cli/commands/setup.py +7 -9
  13. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/keepassxc_cli/commands/show.py +4 -2
  14. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/keepassxc_cli/commands/status.py +9 -5
  15. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/keepassxc_cli/commands/totp.py +5 -3
  16. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/keepassxc_cli/config.py +12 -0
  17. keepassxc_cli-1.3.0/README.md → keepassxc_cli-1.5.0/keepassxc_cli.egg-info/PKG-INFO +41 -3
  18. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/keepassxc_cli.egg-info/requires.txt +1 -1
  19. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/pyproject.toml +7 -1
  20. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/tests/test_commands.py +79 -34
  21. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/.github/workflows/auto-merge-dependabot.yml +0 -0
  22. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/.github/workflows/auto-release.yml +0 -0
  23. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/.github/workflows/lint_and_test.yml +0 -0
  24. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/.github/workflows/pypi.yml +0 -0
  25. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/.gitignore +0 -0
  26. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/LICENSE +0 -0
  27. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/keepassxc_cli/__init__.py +0 -0
  28. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/keepassxc_cli/commands/__init__.py +0 -0
  29. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/keepassxc_cli/commands/version.py +0 -0
  30. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/keepassxc_cli/output.py +0 -0
  31. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/keepassxc_cli.egg-info/SOURCES.txt +0 -0
  32. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/keepassxc_cli.egg-info/dependency_links.txt +0 -0
  33. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/keepassxc_cli.egg-info/entry_points.txt +0 -0
  34. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/keepassxc_cli.egg-info/top_level.txt +0 -0
  35. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/setup.cfg +0 -0
  36. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/tests/conftest.py +0 -0
  37. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/tests/test_config.py +0 -0
  38. {keepassxc_cli-1.3.0 → keepassxc_cli-1.5.0}/tests/test_output.py +0 -0
@@ -1,12 +1,12 @@
1
- # CLAUDE.md — keepassxc-cli
1
+ # keepassxc-cli - Claude Code Instructions
2
2
 
3
- This document provides context for AI assistants working on this project.
4
-
5
- ## Project Purpose
3
+ ## Project Overview
6
4
 
7
5
  `keepassxc-cli` is a Python command-line tool that communicates with a running KeePassXC instance using the KeePassXC Browser Extension protocol (native messaging over a Unix socket). It enables terminal users to interact with KeePassXC — adding, editing, and deleting entries — while leveraging KeePassXC's biometric (TouchID/fingerprint) unlock support.
8
6
 
9
- ## Package Structure
7
+ ## Architecture
8
+
9
+ ### Package Structure
10
10
 
11
11
  ```
12
12
  keepassxc_cli/
@@ -34,7 +34,7 @@ tests/
34
34
  └── test_commands.py # command run() function tests with mocked BrowserClient
35
35
  ```
36
36
 
37
- ## Dependency: keepassxc-browser-api
37
+ ### Dependencies
38
38
 
39
39
  This package depends on `keepassxc-browser-api` (local package at `../mietzen-keepassxc-browser-api/`), which provides:
40
40
 
@@ -73,10 +73,9 @@ def run(
73
73
  ) -> int:
74
74
  ```
75
75
 
76
- ## How to Build, Test, and Lint
76
+ ## Commands
77
77
 
78
78
  ```bash
79
- # Create venv and install
80
79
  python3 -m venv .venv
81
80
  source .venv/bin/activate
82
81
  pip install ../mietzen-keepassxc-browser-api/ # local dependency
@@ -84,19 +83,31 @@ pip install -e ".[dev]"
84
83
 
85
84
  # Run tests
86
85
  pytest --tb=short -q
86
+
87
+ # Run tests with coverage
87
88
  pytest --cov=keepassxc_cli --cov-report=term-missing
88
89
 
89
90
  # Lint
90
91
  ruff check --ignore=E501 --exclude=__init__.py ./keepassxc_cli
91
92
  ```
92
93
 
93
- ## Key Conventions
94
+ ## Conventions
94
95
 
95
96
  - **`from __future__ import annotations`** must be the first line of every `.py` source file.
96
97
  - **Ruff**: `ruff check --ignore=E501 --exclude=__init__.py ./keepassxc_cli`
97
98
  - **No async code**: Everything is synchronous. No threads in the CLI.
98
- - **Output**: Use `print()` for normal output, `sys.stderr` for errors.
99
- - **Exit codes**: `run()` functions return `int` (0 = success, 1 = failure).
99
+ - **Output**: Use `print()` for normal (stdout) output. Error/warning messages use `logger.error()` / `logger.warning()` — never `print(file=sys.stderr)` directly.
100
+ - **Logging config**: Set by `__main__.py`. Non-verbose: `WARNING` level, `"%(message)s"` format, to `sys.stderr`. Verbose (`-v`): `DEBUG` level with timestamp format to `sys.stderr`.
101
+ - **Exit codes**: `run()` functions return `int` (0 = success, non-zero = failure). `__main__.py` maps exceptions to exit codes:
102
+
103
+ | Code | Meaning |
104
+ |---|---|
105
+ | `0` | Success |
106
+ | `1` | Generic error (`KeePassXCError`, `OSError`, `JSONDecodeError`, unknown `ProtocolError`) |
107
+ | `2` | `ConnectionError` — KeePassXC not running / socket not found |
108
+ | `3` | `DatabaseLockedError` — unlock timeout exceeded |
109
+ | `4` | `ProtocolError(error_code=6 or 19)` — access denied by user |
110
+
100
111
  - **Config permissions**: Config files are written with `0o600` (owner read/write only).
101
112
  - **Venv**: Always use `.venv` for development.
102
113
  - **Python ≥ 3.10** required.
@@ -112,3 +123,10 @@ ruff check --ignore=E501 --exclude=__init__.py ./keepassxc_cli
112
123
  ## Output Formats
113
124
 
114
125
  Two formats are supported: `table` (default) and `json`. The `-j / --json` flag on individual subcommands or `default_format` in `cli.json` controls the default.
126
+
127
+ ## CI
128
+
129
+ - `lint_and_test.yml` — Unit tests + ruff lint across Python 3.10–3.14
130
+ - `pypi.yml` — Build & publish on release, then dispatch to homebrew-tap to update the formula
131
+ - `auto-release.yml` — Auto-create patch release on dependabot merge
132
+ - `auto-merge-dependabot.yml` — Auto-merge dependabot PRs
@@ -1,19 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keepassxc-cli
3
- Version: 1.3.0
3
+ Version: 1.5.0
4
4
  Summary: CLI for KeePassXC using the browser extension protocol with biometric unlock
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
7
7
  Description-Content-Type: text/markdown
8
8
  License-File: LICENSE
9
- Requires-Dist: keepassxc-browser-api==1.2.0
9
+ Requires-Dist: keepassxc-browser-api==1.4.0
10
10
  Requires-Dist: pyperclip==1.8.0
11
11
  Provides-Extra: dev
12
12
  Requires-Dist: pytest>=7.0; extra == "dev"
13
13
  Requires-Dist: pytest-cov>=4.0; extra == "dev"
14
14
  Dynamic: license-file
15
15
 
16
- # keepassxc-cli
16
+ # KeepassXC CLI
17
17
 
18
18
  A command-line interface for [KeePassXC](https://keepassxc.org/) that communicates via the browser extension protocol, supporting biometric (TouchID/fingerprint) unlock on supported platforms.
19
19
 
@@ -35,7 +35,7 @@ A command-line interface for [KeePassXC](https://keepassxc.org/) that communicat
35
35
  2. A KeePassXC database must be open (or KeePassXC must be running with auto-open configured).
36
36
  3. Python ≥ 3.10
37
37
 
38
- ## Installation
38
+ ## Install
39
39
 
40
40
  ```bash
41
41
  pipx install keepassxc-cli
@@ -214,6 +214,26 @@ Shared with `keepassxc-browser-api`. Contains the association keys created durin
214
214
 
215
215
  Both config files are stored with `0o600` permissions (owner read/write only).
216
216
 
217
+ ## Exit codes
218
+
219
+ | Code | Meaning |
220
+ |------|---------|
221
+ | `0` | Success |
222
+ | `1` | Generic error (unexpected KeePassXC error, OS error, config parse error) |
223
+ | `2` | KeePassXC is not running or the socket is not found (`ConnectionError`) |
224
+ | `3` | Database unlock timed out (`DatabaseLockedError`) |
225
+ | `4` | Access denied by user — either the access prompt was cancelled or "Allow access to all entries" was denied |
226
+
227
+ These codes are stable and suitable for scripting, e.g.:
228
+
229
+ ```bash
230
+ keepassxc-cli show https://example.com || case $? in
231
+ 2) echo "Start KeePassXC first" ;;
232
+ 3) echo "Unlock timed out" ;;
233
+ 4) echo "Access denied" ;;
234
+ esac
235
+ ```
236
+
217
237
  ## Development
218
238
 
219
239
  ```bash
@@ -232,7 +252,10 @@ pip install -e ".[dev]"
232
252
  # Run tests
233
253
  pytest --tb=short -q
234
254
 
235
- # Run linter
255
+ # Run tests with coverage
256
+ pytest --cov=keepassxc_cli --cov-report=term-missing
257
+
258
+ # Lint
236
259
  ruff check --ignore=E501 --exclude=__init__.py ./keepassxc_cli
237
260
  ```
238
261
 
@@ -1,19 +1,4 @@
1
- Metadata-Version: 2.4
2
- Name: keepassxc-cli
3
- Version: 1.3.0
4
- Summary: CLI for KeePassXC using the browser extension protocol with biometric unlock
5
- License-Expression: MIT
6
- Requires-Python: >=3.10
7
- Description-Content-Type: text/markdown
8
- License-File: LICENSE
9
- Requires-Dist: keepassxc-browser-api==1.2.0
10
- Requires-Dist: pyperclip==1.8.0
11
- Provides-Extra: dev
12
- Requires-Dist: pytest>=7.0; extra == "dev"
13
- Requires-Dist: pytest-cov>=4.0; extra == "dev"
14
- Dynamic: license-file
15
-
16
- # keepassxc-cli
1
+ # KeepassXC CLI
17
2
 
18
3
  A command-line interface for [KeePassXC](https://keepassxc.org/) that communicates via the browser extension protocol, supporting biometric (TouchID/fingerprint) unlock on supported platforms.
19
4
 
@@ -35,7 +20,7 @@ A command-line interface for [KeePassXC](https://keepassxc.org/) that communicat
35
20
  2. A KeePassXC database must be open (or KeePassXC must be running with auto-open configured).
36
21
  3. Python ≥ 3.10
37
22
 
38
- ## Installation
23
+ ## Install
39
24
 
40
25
  ```bash
41
26
  pipx install keepassxc-cli
@@ -214,6 +199,26 @@ Shared with `keepassxc-browser-api`. Contains the association keys created durin
214
199
 
215
200
  Both config files are stored with `0o600` permissions (owner read/write only).
216
201
 
202
+ ## Exit codes
203
+
204
+ | Code | Meaning |
205
+ |------|---------|
206
+ | `0` | Success |
207
+ | `1` | Generic error (unexpected KeePassXC error, OS error, config parse error) |
208
+ | `2` | KeePassXC is not running or the socket is not found (`ConnectionError`) |
209
+ | `3` | Database unlock timed out (`DatabaseLockedError`) |
210
+ | `4` | Access denied by user — either the access prompt was cancelled or "Allow access to all entries" was denied |
211
+
212
+ These codes are stable and suitable for scripting, e.g.:
213
+
214
+ ```bash
215
+ keepassxc-cli show https://example.com || case $? in
216
+ 2) echo "Start KeePassXC first" ;;
217
+ 3) echo "Unlock timed out" ;;
218
+ 4) echo "Access denied" ;;
219
+ esac
220
+ ```
221
+
217
222
  ## Development
218
223
 
219
224
  ```bash
@@ -232,7 +237,10 @@ pip install -e ".[dev]"
232
237
  # Run tests
233
238
  pytest --tb=short -q
234
239
 
235
- # Run linter
240
+ # Run tests with coverage
241
+ pytest --cov=keepassxc_cli --cov-report=term-missing
242
+
243
+ # Lint
236
244
  ruff check --ignore=E501 --exclude=__init__.py ./keepassxc_cli
237
245
  ```
238
246
 
@@ -8,7 +8,7 @@ import sys
8
8
  from pathlib import Path
9
9
 
10
10
  from keepassxc_browser_api import BrowserClient, BrowserConfig
11
- from keepassxc_browser_api.exceptions import KeePassXCError, ConnectionError
11
+ from keepassxc_browser_api.exceptions import ConnectionError, DatabaseLockedError, KeePassXCError, ProtocolError
12
12
 
13
13
  from .config import CliConfig, DEFAULT_CLI_CONFIG_PATH
14
14
  from .commands import setup, status, show, add, edit, rm, totp, clip, lock, mkdir, group_uuid, version
@@ -61,7 +61,17 @@ def main() -> None:
61
61
  args = parser.parse_args()
62
62
 
63
63
  if args.verbose:
64
- logging.basicConfig(level=logging.DEBUG)
64
+ logging.basicConfig(
65
+ level=logging.DEBUG,
66
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
67
+ datefmt="%H:%M:%S",
68
+ )
69
+ else:
70
+ logging.basicConfig(
71
+ level=logging.WARNING,
72
+ format="%(message)s",
73
+ stream=sys.stderr,
74
+ )
65
75
 
66
76
  cli_config_path = Path(args.config)
67
77
  cli_config = CliConfig.load(cli_config_path)
@@ -74,7 +84,16 @@ def main() -> None:
74
84
  client = BrowserClient(browser_config)
75
85
  try:
76
86
  rc = args.func(client, args, cli_config, browser_config, browser_api_config_path, fmt=fmt)
77
- except (KeePassXCError, ConnectionError, OSError, json.JSONDecodeError) as e:
87
+ except ConnectionError as e:
88
+ print(f"Error: {e}", file=sys.stderr)
89
+ rc = 2
90
+ except DatabaseLockedError as e:
91
+ print(f"Error: {e}", file=sys.stderr)
92
+ rc = 3
93
+ except ProtocolError as e:
94
+ print(f"Error: {e}", file=sys.stderr)
95
+ rc = 4 if e.error_code in (6, 19) else 1
96
+ except (KeePassXCError, OSError, json.JSONDecodeError) as e:
78
97
  print(f"Error: {e}", file=sys.stderr)
79
98
  rc = 1
80
99
  sys.exit(rc)
@@ -2,13 +2,15 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import getpass
5
- import sys
5
+ import logging
6
6
  from pathlib import Path
7
7
 
8
8
  from keepassxc_browser_api import BrowserClient, BrowserConfig
9
9
 
10
10
  from keepassxc_cli.config import CliConfig
11
11
 
12
+ logger = logging.getLogger(__name__)
13
+
12
14
 
13
15
  def add_parser(subparsers: argparse._SubParsersAction) -> None:
14
16
  p = subparsers.add_parser("add", help="Add a new entry")
@@ -32,15 +34,11 @@ def run(
32
34
  if password is None:
33
35
  password = getpass.getpass("Password: ")
34
36
 
35
- success = client.set_login(
37
+ client.set_login(
36
38
  url=args.url,
37
39
  username=args.username,
38
40
  password=password,
39
41
  group_uuid=args.group_uuid,
40
42
  )
41
- if success:
42
- print("Entry added successfully.")
43
- return 0
44
- else:
45
- print("Failed to add entry.", file=sys.stderr)
46
- return 1
43
+ print("Entry added successfully.")
44
+ return 0
@@ -1,13 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
- import sys
4
+ import logging
5
5
  from pathlib import Path
6
6
 
7
7
  from keepassxc_browser_api import BrowserClient, BrowserConfig
8
8
 
9
9
  from keepassxc_cli.config import CliConfig
10
10
 
11
+ logger = logging.getLogger(__name__)
12
+
11
13
 
12
14
  def add_parser(subparsers: argparse._SubParsersAction) -> None:
13
15
  p = subparsers.add_parser("clip", help="Copy a field to clipboard")
@@ -32,12 +34,12 @@ def run(
32
34
  try:
33
35
  import pyperclip
34
36
  except ImportError:
35
- print("Error: pyperclip is required for clipboard support. Install it with: pip install pyperclip", file=sys.stderr)
37
+ logger.error("pyperclip is required for clipboard support. Install it with: pip install pyperclip")
36
38
  return 1
37
39
 
38
40
  entries = client.get_logins(args.url)
39
41
  if not entries:
40
- print(f"No entries found for: {args.url}", file=sys.stderr)
42
+ logger.warning("No entries found for: %s", args.url)
41
43
  return 1
42
44
 
43
45
  entry = entries[0]
@@ -49,10 +51,10 @@ def run(
49
51
  elif args.field == "totp":
50
52
  value = client.get_totp(entry.uuid)
51
53
  if value is None:
52
- print(f"No TOTP configured for: {entry.name}", file=sys.stderr)
54
+ logger.warning("No TOTP configured for: %s", entry.name)
53
55
  return 1
54
56
  else:
55
- print(f"Unknown field: {args.field}", file=sys.stderr)
57
+ logger.error("Unknown field: %s", args.field)
56
58
  return 1
57
59
 
58
60
  pyperclip.copy(value)
@@ -1,13 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
- import sys
4
+ import logging
5
5
  from pathlib import Path
6
6
 
7
7
  from keepassxc_browser_api import BrowserClient, BrowserConfig
8
8
 
9
9
  from keepassxc_cli.config import CliConfig
10
10
 
11
+ logger = logging.getLogger(__name__)
12
+
11
13
 
12
14
  def add_parser(subparsers: argparse._SubParsersAction) -> None:
13
15
  p = subparsers.add_parser(
@@ -39,14 +41,13 @@ def run(
39
41
  entries = client.get_logins(args.url)
40
42
  entry = next((e for e in entries if e.uuid == args.uuid), None)
41
43
  if entry is None:
42
- print(
43
- f"Entry {args.uuid} not found for URL: {args.url}\n"
44
- "Hint: use 'keepassxc-cli show <url>' to look up the UUID.",
45
- file=sys.stderr,
44
+ logger.error(
45
+ "Entry %s not found for URL: %s\nHint: use 'keepassxc-cli show <url>' to look up the UUID.",
46
+ args.uuid, args.url,
46
47
  )
47
48
  return 1
48
49
 
49
- success = client.set_login(
50
+ client.set_login(
50
51
  url=args.url,
51
52
  username=args.username if args.username is not None else entry.login,
52
53
  password=args.password if args.password is not None else entry.password,
@@ -54,9 +55,5 @@ def run(
54
55
  uuid=entry.uuid,
55
56
  group_uuid=entry.group_uuid,
56
57
  )
57
- if success:
58
- print("Entry updated successfully.")
59
- return 0
60
- else:
61
- print("Failed to update entry.", file=sys.stderr)
62
- return 1
58
+ print("Entry updated successfully.")
59
+ return 0
@@ -2,13 +2,15 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import json
5
- import sys
5
+ import logging
6
6
  from pathlib import Path
7
7
 
8
8
  from keepassxc_browser_api import BrowserClient, BrowserConfig
9
9
 
10
10
  from keepassxc_cli.config import CliConfig
11
11
 
12
+ logger = logging.getLogger(__name__)
13
+
12
14
 
13
15
  def add_parser(subparsers: argparse._SubParsersAction, fmt_parent: argparse.ArgumentParser | None = None) -> None:
14
16
  parents = [fmt_parent] if fmt_parent else []
@@ -34,10 +36,6 @@ def run(
34
36
  fmt: str = "table",
35
37
  ) -> int:
36
38
  groups = client.get_database_groups()
37
- if not groups:
38
- print("Failed to retrieve group tree.", file=sys.stderr)
39
- return 1
40
-
41
39
  root = groups[0]
42
40
  parts = args.path.split("/")
43
41
 
@@ -47,7 +45,7 @@ def run(
47
45
  for part in parts:
48
46
  matched = next((g for g in current if g.name == part), None)
49
47
  if matched is None:
50
- print(f"Group not found: {args.path!r}", file=sys.stderr)
48
+ logger.error("Group not found: %r", args.path)
51
49
  return 1
52
50
  current = matched.children
53
51
 
@@ -1,13 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
- import sys
4
+ import logging
5
5
  from pathlib import Path
6
6
 
7
7
  from keepassxc_browser_api import BrowserClient, BrowserConfig
8
8
 
9
9
  from keepassxc_cli.config import CliConfig
10
10
 
11
+ logger = logging.getLogger(__name__)
12
+
11
13
 
12
14
  def add_parser(subparsers: argparse._SubParsersAction) -> None:
13
15
  p = subparsers.add_parser("lock", help="Lock the KeePassXC database")
@@ -28,5 +30,5 @@ def run(
28
30
  print("Database locked.")
29
31
  return 0
30
32
  else:
31
- print("Failed to lock database.", file=sys.stderr)
33
+ logger.error("Failed to lock database.")
32
34
  return 1
@@ -1,13 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
- import sys
4
+ import logging
5
5
  from pathlib import Path
6
6
 
7
7
  from keepassxc_browser_api import BrowserClient, BrowserConfig
8
8
 
9
9
  from keepassxc_cli.config import CliConfig
10
10
 
11
+ logger = logging.getLogger(__name__)
12
+
11
13
 
12
14
  def add_parser(subparsers: argparse._SubParsersAction) -> None:
13
15
  p = subparsers.add_parser("mkdir", help="Create a new group")
@@ -28,8 +30,5 @@ def run(
28
30
  fmt: str = "table",
29
31
  ) -> int:
30
32
  group = client.create_group(args.name)
31
- if group is None:
32
- print("Failed to create group.", file=sys.stderr)
33
- return 1
34
33
  print(f"Group created: {group.name} [{group.uuid}]")
35
34
  return 0
@@ -1,13 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
- import sys
4
+ import logging
5
5
  from pathlib import Path
6
6
 
7
7
  from keepassxc_browser_api import BrowserClient, BrowserConfig
8
8
 
9
9
  from keepassxc_cli.config import CliConfig
10
10
 
11
+ logger = logging.getLogger(__name__)
12
+
11
13
 
12
14
  def add_parser(subparsers: argparse._SubParsersAction) -> None:
13
15
  p = subparsers.add_parser("rm", help="Delete an entry by UUID")
@@ -31,10 +33,6 @@ def run(
31
33
  print("Aborted.")
32
34
  return 1
33
35
 
34
- success = client.delete_entry(args.uuid)
35
- if success:
36
- print("Entry deleted.")
37
- return 0
38
- else:
39
- print("Failed to delete entry.", file=sys.stderr)
40
- return 1
36
+ client.delete_entry(args.uuid)
37
+ print("Entry deleted.")
38
+ return 0
@@ -1,13 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
- import sys
4
+ import logging
5
5
  from pathlib import Path
6
6
 
7
7
  from keepassxc_browser_api import BrowserClient, BrowserConfig
8
8
 
9
9
  from keepassxc_cli.config import CliConfig
10
10
 
11
+ logger = logging.getLogger(__name__)
12
+
11
13
 
12
14
  def add_parser(subparsers: argparse._SubParsersAction) -> None:
13
15
  p = subparsers.add_parser("setup", help="Associate with KeePassXC (key exchange)")
@@ -23,11 +25,7 @@ def run(
23
25
  *,
24
26
  fmt: str = "table",
25
27
  ) -> int:
26
- success = client.setup()
27
- if success:
28
- browser_config.save(browser_config_path)
29
- print("Successfully associated with KeePassXC.")
30
- return 0
31
- else:
32
- print("Failed to associate with KeePassXC.", file=sys.stderr)
33
- return 1
28
+ client.setup()
29
+ browser_config.save(browser_config_path)
30
+ print("Successfully associated with KeePassXC.")
31
+ return 0
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
- import sys
4
+ import logging
5
5
  from pathlib import Path
6
6
 
7
7
  from keepassxc_browser_api import BrowserClient, BrowserConfig
@@ -9,6 +9,8 @@ from keepassxc_browser_api import BrowserClient, BrowserConfig
9
9
  from keepassxc_cli.config import CliConfig
10
10
  from keepassxc_cli.output import print_entry_detail
11
11
 
12
+ logger = logging.getLogger(__name__)
13
+
12
14
 
13
15
  def add_parser(subparsers: argparse._SubParsersAction, fmt_parent: argparse.ArgumentParser | None = None) -> None:
14
16
  parents = [fmt_parent] if fmt_parent else []
@@ -30,7 +32,7 @@ def run(
30
32
  ) -> int:
31
33
  entries = client.get_logins(args.url)
32
34
  if not entries:
33
- print(f"No entries found for: {args.url}", file=sys.stderr)
35
+ logger.warning("No entries found for: %s", args.url)
34
36
  return 1
35
37
  for entry in entries:
36
38
  print_entry_detail(entry, fmt, show_password=args.show_password, show_kph_prefix=getattr(args, "show_kph_prefix", False))
@@ -1,14 +1,17 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import logging
4
5
  from pathlib import Path
5
6
 
6
7
  from keepassxc_browser_api import BrowserClient, BrowserConfig
7
- from keepassxc_browser_api.exceptions import KeePassXCError
8
+ from keepassxc_browser_api.exceptions import ConnectionError, KeePassXCError
8
9
 
9
10
  from keepassxc_cli.config import CliConfig
10
11
  from keepassxc_cli.output import print_status
11
12
 
13
+ logger = logging.getLogger(__name__)
14
+
12
15
 
13
16
  def add_parser(subparsers: argparse._SubParsersAction, fmt_parent: argparse.ArgumentParser | None = None) -> None:
14
17
  parents = [fmt_parent] if fmt_parent else []
@@ -27,10 +30,11 @@ def run(
27
30
  ) -> int:
28
31
  info: dict = {}
29
32
 
30
- connected = client.connect()
31
- info["Connected"] = "yes" if connected else "no"
32
-
33
- if not connected:
33
+ try:
34
+ client.connect()
35
+ info["Connected"] = "yes"
36
+ except ConnectionError:
37
+ info["Connected"] = "no"
34
38
  print_status(info, fmt)
35
39
  return 1
36
40
 
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
- import sys
4
+ import logging
5
5
  from pathlib import Path
6
6
 
7
7
  from keepassxc_browser_api import BrowserClient, BrowserConfig
@@ -9,6 +9,8 @@ from keepassxc_browser_api import BrowserClient, BrowserConfig
9
9
  from keepassxc_cli.config import CliConfig
10
10
  from keepassxc_cli.output import print_totp
11
11
 
12
+ logger = logging.getLogger(__name__)
13
+
12
14
 
13
15
  def add_parser(subparsers: argparse._SubParsersAction, fmt_parent: argparse.ArgumentParser | None = None) -> None:
14
16
  parents = [fmt_parent] if fmt_parent else []
@@ -28,12 +30,12 @@ def run(
28
30
  ) -> int:
29
31
  entries = client.get_logins(args.url)
30
32
  if not entries:
31
- print(f"No entries found for: {args.url}", file=sys.stderr)
33
+ logger.warning("No entries found for: %s", args.url)
32
34
  return 1
33
35
  entry = entries[0]
34
36
  totp = client.get_totp(entry.uuid)
35
37
  if totp is None:
36
- print(f"No TOTP configured for: {entry.name}", file=sys.stderr)
38
+ logger.warning("No TOTP configured for: %s", entry.name)
37
39
  return 1
38
40
  print_totp(totp, fmt)
39
41
  return 0
@@ -1,13 +1,17 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import logging
4
5
  import os
6
+ import stat
5
7
  from dataclasses import dataclass, field
6
8
  from pathlib import Path
7
9
 
8
10
  DEFAULT_BROWSER_API_CONFIG_PATH = str(Path.home() / ".keepassxc" / "browser-api.json")
9
11
  DEFAULT_CLI_CONFIG_PATH = Path.home() / ".keepassxc" / "cli.json"
10
12
 
13
+ logger = logging.getLogger(__name__)
14
+
11
15
 
12
16
  @dataclass
13
17
  class CliConfig:
@@ -32,6 +36,7 @@ class CliConfig:
32
36
  def save(self, path: Path | str) -> None:
33
37
  path = Path(path)
34
38
  path.parent.mkdir(parents=True, exist_ok=True)
39
+ os.chmod(str(path.parent), stat.S_IRWXU)
35
40
  data = json.dumps(self.to_dict(), indent=2)
36
41
  # Write with restricted permissions (0o600)
37
42
  fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
@@ -45,6 +50,13 @@ class CliConfig:
45
50
  path = Path(path)
46
51
  if not path.exists():
47
52
  return cls()
53
+ mode = path.stat().st_mode
54
+ if mode & 0o077:
55
+ logger.warning(
56
+ "Config file %s has insecure permissions %o; expected 0600. "
57
+ "Fix with: chmod 600 %s",
58
+ path, mode & 0o777, path,
59
+ )
48
60
  with open(path) as f:
49
61
  d = json.load(f)
50
62
  return cls.from_dict(d)
@@ -1,4 +1,19 @@
1
- # keepassxc-cli
1
+ Metadata-Version: 2.4
2
+ Name: keepassxc-cli
3
+ Version: 1.5.0
4
+ Summary: CLI for KeePassXC using the browser extension protocol with biometric unlock
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: keepassxc-browser-api==1.4.0
10
+ Requires-Dist: pyperclip==1.8.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=7.0; extra == "dev"
13
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
14
+ Dynamic: license-file
15
+
16
+ # KeepassXC CLI
2
17
 
3
18
  A command-line interface for [KeePassXC](https://keepassxc.org/) that communicates via the browser extension protocol, supporting biometric (TouchID/fingerprint) unlock on supported platforms.
4
19
 
@@ -20,7 +35,7 @@ A command-line interface for [KeePassXC](https://keepassxc.org/) that communicat
20
35
  2. A KeePassXC database must be open (or KeePassXC must be running with auto-open configured).
21
36
  3. Python ≥ 3.10
22
37
 
23
- ## Installation
38
+ ## Install
24
39
 
25
40
  ```bash
26
41
  pipx install keepassxc-cli
@@ -199,6 +214,26 @@ Shared with `keepassxc-browser-api`. Contains the association keys created durin
199
214
 
200
215
  Both config files are stored with `0o600` permissions (owner read/write only).
201
216
 
217
+ ## Exit codes
218
+
219
+ | Code | Meaning |
220
+ |------|---------|
221
+ | `0` | Success |
222
+ | `1` | Generic error (unexpected KeePassXC error, OS error, config parse error) |
223
+ | `2` | KeePassXC is not running or the socket is not found (`ConnectionError`) |
224
+ | `3` | Database unlock timed out (`DatabaseLockedError`) |
225
+ | `4` | Access denied by user — either the access prompt was cancelled or "Allow access to all entries" was denied |
226
+
227
+ These codes are stable and suitable for scripting, e.g.:
228
+
229
+ ```bash
230
+ keepassxc-cli show https://example.com || case $? in
231
+ 2) echo "Start KeePassXC first" ;;
232
+ 3) echo "Unlock timed out" ;;
233
+ 4) echo "Access denied" ;;
234
+ esac
235
+ ```
236
+
202
237
  ## Development
203
238
 
204
239
  ```bash
@@ -217,7 +252,10 @@ pip install -e ".[dev]"
217
252
  # Run tests
218
253
  pytest --tb=short -q
219
254
 
220
- # Run linter
255
+ # Run tests with coverage
256
+ pytest --cov=keepassxc_cli --cov-report=term-missing
257
+
258
+ # Lint
221
259
  ruff check --ignore=E501 --exclude=__init__.py ./keepassxc_cli
222
260
  ```
223
261
 
@@ -1,4 +1,4 @@
1
- keepassxc-browser-api==1.2.0
1
+ keepassxc-browser-api==1.4.0
2
2
  pyperclip==1.8.0
3
3
 
4
4
  [dev]
@@ -10,7 +10,7 @@ readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = "MIT"
12
12
  dependencies = [
13
- "keepassxc-browser-api==1.2.0",
13
+ "keepassxc-browser-api==1.4.0",
14
14
  "pyperclip==1.8.0",
15
15
  ]
16
16
 
@@ -32,3 +32,9 @@ local_scheme = "no-local-version"
32
32
 
33
33
  [tool.pytest.ini_options]
34
34
  testpaths = ["tests"]
35
+
36
+ [tool.ruff]
37
+ exclude = ["**/__init__.py"]
38
+
39
+ [tool.ruff.lint]
40
+ ignore = ["E501"]
@@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch
8
8
  import pytest
9
9
 
10
10
  from keepassxc_browser_api import Entry, BrowserConfig, Association
11
+ from keepassxc_browser_api.exceptions import ConnectionError, DatabaseLockedError, ProtocolError
11
12
  from keepassxc_cli.config import CliConfig
12
13
  from keepassxc_cli.commands import (
13
14
  setup, status, show, add, edit, rm, totp, clip, lock, mkdir, group_uuid, version,
@@ -50,19 +51,18 @@ def make_args(**kwargs) -> argparse.Namespace:
50
51
  # --- setup ---
51
52
 
52
53
  class TestSetupCommand:
53
- def test_success(self, mock_client, cli_config, browser_config, browser_config_path):
54
- mock_client.setup.return_value = True
54
+ def test_success(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
55
+ mock_client.setup.return_value = None
55
56
  args = make_args()
56
57
  rc = setup.run(mock_client, args, cli_config, browser_config, browser_config_path)
57
58
  assert rc == 0
58
59
  mock_client.setup.assert_called_once()
59
60
 
60
- def test_failure(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
61
- mock_client.setup.return_value = False
61
+ def test_failure_propagates(self, mock_client, cli_config, browser_config, browser_config_path):
62
+ mock_client.setup.side_effect = ConnectionError("KeePassXC not running")
62
63
  args = make_args()
63
- rc = setup.run(mock_client, args, cli_config, browser_config, browser_config_path)
64
- assert rc == 1
65
- assert "Failed" in capsys.readouterr().err
64
+ with pytest.raises(ConnectionError):
65
+ setup.run(mock_client, args, cli_config, browser_config, browser_config_path)
66
66
 
67
67
 
68
68
  # --- status ---
@@ -78,7 +78,7 @@ class TestStatusCommand:
78
78
  assert "yes" in out
79
79
 
80
80
  def test_not_connected(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
81
- mock_client.connect.return_value = False
81
+ mock_client.connect.side_effect = ConnectionError("not available")
82
82
  args = make_args()
83
83
  rc = status.run(mock_client, args, cli_config, browser_config, browser_config_path)
84
84
  assert rc == 1
@@ -128,12 +128,12 @@ class TestShowCommand:
128
128
  assert rc == 0
129
129
  assert "KPH: url: https://github.com" in capsys.readouterr().out
130
130
 
131
- def test_no_entries(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
131
+ def test_no_entries(self, mock_client, cli_config, browser_config, browser_config_path, caplog):
132
132
  mock_client.get_logins.return_value = []
133
133
  args = make_args(url="https://notfound.com")
134
134
  rc = show.run(mock_client, args, cli_config, browser_config, browser_config_path)
135
135
  assert rc == 1
136
- assert "No entries" in capsys.readouterr().err
136
+ assert any("No entries" in r.message for r in caplog.records)
137
137
 
138
138
 
139
139
  # --- add ---
@@ -147,11 +147,11 @@ class TestAddCommand:
147
147
  mock_client.set_login.assert_called_once()
148
148
  assert "added" in capsys.readouterr().out.lower()
149
149
 
150
- def test_failure(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
151
- mock_client.set_login.return_value = False
150
+ def test_failure_propagates(self, mock_client, cli_config, browser_config, browser_config_path):
151
+ mock_client.set_login.side_effect = ProtocolError("access denied", error_code=6)
152
152
  args = make_args(url="https://example.com", username="u", password="p", group_uuid="")
153
- rc = add.run(mock_client, args, cli_config, browser_config, browser_config_path)
154
- assert rc == 1
153
+ with pytest.raises(ProtocolError):
154
+ add.run(mock_client, args, cli_config, browser_config, browser_config_path)
155
155
 
156
156
 
157
157
  # --- edit ---
@@ -166,12 +166,12 @@ class TestEditCommand:
166
166
  assert rc == 0
167
167
  assert "updated" in capsys.readouterr().out.lower()
168
168
 
169
- def test_entry_not_found(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
169
+ def test_entry_not_found(self, mock_client, cli_config, browser_config, browser_config_path, caplog):
170
170
  mock_client.get_logins.return_value = []
171
171
  args = make_args(uuid="nonexistent-uuid", url="https://example.com", username=None, password=None, title=None)
172
172
  rc = edit.run(mock_client, args, cli_config, browser_config, browser_config_path)
173
173
  assert rc == 1
174
- assert "not found" in capsys.readouterr().err.lower()
174
+ assert any("not found" in r.message.lower() for r in caplog.records)
175
175
 
176
176
 
177
177
  # --- rm ---
@@ -185,11 +185,11 @@ class TestRmCommand:
185
185
  mock_client.delete_entry.assert_called_once_with("some-uuid")
186
186
  assert "deleted" in capsys.readouterr().out.lower()
187
187
 
188
- def test_failure(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
189
- mock_client.delete_entry.return_value = False
188
+ def test_failure_propagates(self, mock_client, cli_config, browser_config, browser_config_path):
189
+ mock_client.delete_entry.side_effect = ProtocolError("access denied", error_code=6)
190
190
  args = make_args(uuid="some-uuid", yes=True)
191
- rc = rm.run(mock_client, args, cli_config, browser_config, browser_config_path)
192
- assert rc == 1
191
+ with pytest.raises(ProtocolError):
192
+ rm.run(mock_client, args, cli_config, browser_config, browser_config_path)
193
193
 
194
194
 
195
195
  # --- totp ---
@@ -238,12 +238,12 @@ class TestClipCommand:
238
238
  rc = clip.run(mock_client, args, cli_config, browser_config, browser_config_path)
239
239
  assert rc == 1
240
240
 
241
- def test_pyperclip_missing(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
241
+ def test_pyperclip_missing(self, mock_client, cli_config, browser_config, browser_config_path, caplog):
242
242
  args = make_args(url="https://example.com", field="password")
243
243
  with patch.dict("sys.modules", {"pyperclip": None}):
244
244
  rc = clip.run(mock_client, args, cli_config, browser_config, browser_config_path)
245
245
  assert rc == 1
246
- assert "pyperclip" in capsys.readouterr().err
246
+ assert any("pyperclip" in r.message for r in caplog.records)
247
247
 
248
248
 
249
249
  # --- lock ---
@@ -283,11 +283,11 @@ class TestMkdirCommand:
283
283
  assert rc == 0
284
284
  mock_client.create_group.assert_called_once_with("Work/Projects")
285
285
 
286
- def test_failure(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
287
- mock_client.create_group.return_value = None
286
+ def test_failure_propagates(self, mock_client, cli_config, browser_config, browser_config_path):
287
+ mock_client.create_group.side_effect = ConnectionError("KeePassXC not running")
288
288
  args = make_args(name="MyGroup")
289
- rc = mkdir.run(mock_client, args, cli_config, browser_config, browser_config_path)
290
- assert rc == 1
289
+ with pytest.raises(ConnectionError):
290
+ mkdir.run(mock_client, args, cli_config, browser_config, browser_config_path)
291
291
 
292
292
 
293
293
  # --- group-uuid ---
@@ -318,13 +318,12 @@ class TestGroupUuidCommand:
318
318
  out = capsys.readouterr().out
319
319
  assert "projects-uuid" in out
320
320
 
321
- def test_not_found(self, mock_client, cli_config, browser_config, browser_config_path, capsys, mock_group):
321
+ def test_not_found(self, mock_client, cli_config, browser_config, browser_config_path, caplog, mock_group):
322
322
  mock_client.get_database_groups.return_value = self._make_tree(mock_group)
323
323
  args = make_args(path="Nonexistent")
324
324
  rc = group_uuid.run(mock_client, args, cli_config, browser_config, browser_config_path)
325
325
  assert rc == 1
326
- err = capsys.readouterr().err
327
- assert "not found" in err.lower()
326
+ assert any("not found" in r.message.lower() for r in caplog.records)
328
327
 
329
328
  def test_not_found_mid_path(self, mock_client, cli_config, browser_config, browser_config_path, capsys, mock_group):
330
329
  mock_client.get_database_groups.return_value = self._make_tree(mock_group)
@@ -343,12 +342,11 @@ class TestGroupUuidCommand:
343
342
  assert data["name"] == "Projects"
344
343
  assert data["uuid"] == "projects-uuid"
345
344
 
346
- def test_get_database_groups_failure(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
347
- mock_client.get_database_groups.return_value = []
345
+ def test_get_database_groups_failure_propagates(self, mock_client, cli_config, browser_config, browser_config_path, mock_group):
346
+ mock_client.get_database_groups.side_effect = ConnectionError("KeePassXC not running")
348
347
  args = make_args(path="Work")
349
- rc = group_uuid.run(mock_client, args, cli_config, browser_config, browser_config_path)
350
- assert rc == 1
351
-
348
+ with pytest.raises(ConnectionError):
349
+ group_uuid.run(mock_client, args, cli_config, browser_config, browser_config_path)
352
350
 
353
351
  # --- version ---
354
352
 
@@ -371,3 +369,50 @@ class TestVersionCommand:
371
369
  assert rc == 0
372
370
  out = capsys.readouterr().out
373
371
  assert "unknown" in out
372
+
373
+
374
+ # --- exit codes ---
375
+
376
+ class TestExitCodes:
377
+ """Test that __main__.main() maps exceptions to the correct exit codes."""
378
+
379
+ def _run_main(self, exception, monkeypatch, capsys, tmp_path):
380
+ from keepassxc_cli.__main__ import main
381
+
382
+ config_path = tmp_path / "cli.json"
383
+ monkeypatch.setattr("sys.argv", [
384
+ "keepassxc-cli", "--config", str(config_path),
385
+ "show", "https://example.com",
386
+ ])
387
+
388
+ mock_client_instance = MagicMock()
389
+ mock_client_instance.get_logins.side_effect = exception
390
+
391
+ with patch("keepassxc_cli.__main__.BrowserClient", return_value=mock_client_instance):
392
+ with patch("keepassxc_cli.__main__.BrowserConfig"):
393
+ with patch("keepassxc_cli.__main__.CliConfig"):
394
+ with pytest.raises(SystemExit) as exc_info:
395
+ main()
396
+
397
+ return exc_info.value.code, capsys.readouterr()
398
+
399
+ def test_connection_error_rc2(self, monkeypatch, capsys, tmp_path):
400
+ rc, out = self._run_main(ConnectionError("not running"), monkeypatch, capsys, tmp_path)
401
+ assert rc == 2
402
+ assert "Error:" in out.err
403
+
404
+ def test_database_locked_rc3(self, monkeypatch, capsys, tmp_path):
405
+ rc, out = self._run_main(DatabaseLockedError("locked"), monkeypatch, capsys, tmp_path)
406
+ assert rc == 3
407
+
408
+ def test_access_denied_rc4(self, monkeypatch, capsys, tmp_path):
409
+ rc, out = self._run_main(ProtocolError("denied", error_code=6), monkeypatch, capsys, tmp_path)
410
+ assert rc == 4
411
+
412
+ def test_access_denied_code19_rc4(self, monkeypatch, capsys, tmp_path):
413
+ rc, out = self._run_main(ProtocolError("denied", error_code=19), monkeypatch, capsys, tmp_path)
414
+ assert rc == 4
415
+
416
+ def test_other_protocol_error_rc1(self, monkeypatch, capsys, tmp_path):
417
+ rc, out = self._run_main(ProtocolError("other error", error_code=7), monkeypatch, capsys, tmp_path)
418
+ assert rc == 1
File without changes
File without changes
File without changes