keepassxc-cli 1.3.0__tar.gz → 1.4.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.4.0}/CLAUDE.md +12 -2
  2. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/PKG-INFO +22 -2
  3. keepassxc_cli-1.3.0/keepassxc_cli.egg-info/PKG-INFO → keepassxc_cli-1.4.0/README.md +20 -15
  4. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/__main__.py +22 -3
  5. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/add.py +6 -8
  6. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/clip.py +7 -5
  7. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/edit.py +9 -12
  8. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/group_uuid.py +4 -6
  9. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/lock.py +4 -2
  10. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/mkdir.py +3 -4
  11. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/rm.py +6 -8
  12. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/setup.py +7 -9
  13. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/show.py +4 -2
  14. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/status.py +9 -5
  15. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/totp.py +5 -3
  16. keepassxc_cli-1.3.0/README.md → keepassxc_cli-1.4.0/keepassxc_cli.egg-info/PKG-INFO +35 -0
  17. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli.egg-info/requires.txt +1 -1
  18. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/pyproject.toml +1 -1
  19. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/tests/test_commands.py +79 -34
  20. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/.github/workflows/auto-merge-dependabot.yml +0 -0
  21. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/.github/workflows/auto-release.yml +0 -0
  22. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/.github/workflows/lint_and_test.yml +0 -0
  23. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/.github/workflows/pypi.yml +0 -0
  24. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/.gitignore +0 -0
  25. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/LICENSE +0 -0
  26. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/__init__.py +0 -0
  27. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/__init__.py +0 -0
  28. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/version.py +0 -0
  29. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/config.py +0 -0
  30. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/output.py +0 -0
  31. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli.egg-info/SOURCES.txt +0 -0
  32. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli.egg-info/dependency_links.txt +0 -0
  33. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli.egg-info/entry_points.txt +0 -0
  34. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli.egg-info/top_level.txt +0 -0
  35. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/setup.cfg +0 -0
  36. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/tests/conftest.py +0 -0
  37. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/tests/test_config.py +0 -0
  38. {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/tests/test_output.py +0 -0
@@ -95,8 +95,18 @@ ruff check --ignore=E501 --exclude=__init__.py ./keepassxc_cli
95
95
  - **`from __future__ import annotations`** must be the first line of every `.py` source file.
96
96
  - **Ruff**: `ruff check --ignore=E501 --exclude=__init__.py ./keepassxc_cli`
97
97
  - **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).
98
+ - **Output**: Use `print()` for normal (stdout) output. Error/warning messages use `logger.error()` / `logger.warning()` — never `print(file=sys.stderr)` directly.
99
+ - **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`.
100
+ - **Exit codes**: `run()` functions return `int` (0 = success, non-zero = failure). `__main__.py` maps exceptions to exit codes:
101
+
102
+ | Code | Meaning |
103
+ |---|---|
104
+ | `0` | Success |
105
+ | `1` | Generic error (`KeePassXCError`, `OSError`, `JSONDecodeError`, unknown `ProtocolError`) |
106
+ | `2` | `ConnectionError` — KeePassXC not running / socket not found |
107
+ | `3` | `DatabaseLockedError` — unlock timeout exceeded |
108
+ | `4` | `ProtocolError(error_code=6 or 19)` — access denied by user |
109
+
100
110
  - **Config permissions**: Config files are written with `0o600` (owner read/write only).
101
111
  - **Venv**: Always use `.venv` for development.
102
112
  - **Python ≥ 3.10** required.
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keepassxc-cli
3
- Version: 1.3.0
3
+ Version: 1.4.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.3.0
10
10
  Requires-Dist: pyperclip==1.8.0
11
11
  Provides-Extra: dev
12
12
  Requires-Dist: pytest>=7.0; extra == "dev"
@@ -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
@@ -1,18 +1,3 @@
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
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.
@@ -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
@@ -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,3 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: keepassxc-cli
3
+ Version: 1.4.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.3.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
+
1
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.
@@ -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
@@ -1,4 +1,4 @@
1
- keepassxc-browser-api==1.2.0
1
+ keepassxc-browser-api==1.3.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.3.0",
14
14
  "pyperclip==1.8.0",
15
15
  ]
16
16
 
@@ -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