keepassxc-cli 1.4.0__tar.gz → 1.6.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 (41) hide show
  1. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/CLAUDE.md +17 -9
  2. {keepassxc_cli-1.4.0/keepassxc_cli.egg-info → keepassxc_cli-1.6.0}/PKG-INFO +28 -16
  3. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/README.md +26 -14
  4. keepassxc_cli-1.6.0/assets/demo.gif +0 -0
  5. keepassxc_cli-1.6.0/assets/settings-browser-integration.png +0 -0
  6. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/commands/add.py +20 -2
  7. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/commands/edit.py +25 -12
  8. keepassxc_cli-1.6.0/keepassxc_cli/commands/rm.py +59 -0
  9. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/config.py +12 -0
  10. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0/keepassxc_cli.egg-info}/PKG-INFO +28 -16
  11. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli.egg-info/SOURCES.txt +2 -0
  12. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli.egg-info/requires.txt +1 -1
  13. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/pyproject.toml +7 -1
  14. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/tests/test_commands.py +86 -10
  15. keepassxc_cli-1.4.0/keepassxc_cli/commands/rm.py +0 -38
  16. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/.github/workflows/auto-merge-dependabot.yml +0 -0
  17. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/.github/workflows/auto-release.yml +0 -0
  18. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/.github/workflows/lint_and_test.yml +0 -0
  19. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/.github/workflows/pypi.yml +0 -0
  20. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/.gitignore +0 -0
  21. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/LICENSE +0 -0
  22. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/__init__.py +0 -0
  23. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/__main__.py +0 -0
  24. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/commands/__init__.py +0 -0
  25. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/commands/clip.py +0 -0
  26. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/commands/group_uuid.py +0 -0
  27. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/commands/lock.py +0 -0
  28. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/commands/mkdir.py +0 -0
  29. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/commands/setup.py +0 -0
  30. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/commands/show.py +0 -0
  31. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/commands/status.py +0 -0
  32. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/commands/totp.py +0 -0
  33. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/commands/version.py +0 -0
  34. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/output.py +0 -0
  35. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli.egg-info/dependency_links.txt +0 -0
  36. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli.egg-info/entry_points.txt +0 -0
  37. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli.egg-info/top_level.txt +0 -0
  38. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/setup.cfg +0 -0
  39. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/tests/conftest.py +0 -0
  40. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/tests/test_config.py +0 -0
  41. {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.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,13 +83,15 @@ 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`
@@ -122,3 +123,10 @@ ruff check --ignore=E501 --exclude=__init__.py ./keepassxc_cli
122
123
  ## Output Formats
123
124
 
124
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,24 +1,22 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keepassxc-cli
3
- Version: 1.4.0
3
+ Version: 1.6.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.3.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
 
20
- ## What it is
21
-
22
20
  `keepassxc-cli` talks to a running KeePassXC instance using the same native messaging protocol used by the KeePassXC Browser extension. This means:
23
21
 
24
22
  - **Biometric unlock**: On macOS with TouchID (or similar) configured in KeePassXC, you can authenticate via fingerprint rather than typing your master password.
@@ -27,24 +25,32 @@ A command-line interface for [KeePassXC](https://keepassxc.org/) that communicat
27
25
  - **TOTP**: Retrieve time-based one-time passwords.
28
26
  - **Clipboard**: Copy credentials directly to the clipboard.
29
27
 
28
+ ![Functionality demonstration](assets/demo.gif)
29
+
30
+ KeePassXC CLI based on [KeePassXC Browser API](https://github.com/mietzen/keepassxc-browser-api).
31
+
30
32
  ## Prerequisites
31
33
 
32
- 1. **KeePassXC** 2.7 with the **Browser Integration** feature enabled:
33
- - Open KeePassXC → Tools → Settings → Browser Integration
34
- - Enable "Enable browser integration"
35
- 2. A KeePassXC database must be open (or KeePassXC must be running with auto-open configured).
36
- 3. Python 3.10
34
+ - **macOS** (uses Unix sockets and KeePassXC's browser extension socket)
35
+ - **Python >= 3.10**
36
+ - **KeePassXC** with:
37
+ - Browser Integration enabled (Settings > Browser Integration > Enable browser integration)
38
+ ![KeePassXC Browser Integration Settings screenshot](assets/settings-browser-integration.png)
37
39
 
38
- ## Installation
40
+ ## Install
39
41
 
40
- ```bash
41
- pipx install keepassxc-cli
42
+ ### Homebrew (recommended)
43
+
44
+ See **[homebrew homepage](https://brew.sh/)** on how to setup homebrew.
45
+
46
+ ```shell
47
+ brew install mietzen/tap/keepassxc-cli
42
48
  ```
43
49
 
44
- Or with pip:
50
+ ### pipx
45
51
 
46
52
  ```bash
47
- pip install keepassxc-cli
53
+ pipx install keepassxc-cli
48
54
  ```
49
55
 
50
56
  ## Setup
@@ -236,8 +242,11 @@ esac
236
242
 
237
243
  ## Development
238
244
 
245
+ This package depends on [`keepassxc-browser-api`](https://github.com/mietzen/keepassxc-browser-api), which handles the KeePassXC browser extension protocol. The browser API credentials are stored in `~/.keepassxc/browser-api.json` and are shared with `keepassxc-ssh-agent` if installed.
246
+
239
247
  ```bash
240
248
  git clone https://github.com/mietzen/keepassxc-cli
249
+ git clone https://github.com/mietzen/keepassxc-browser-api
241
250
  cd keepassxc-cli
242
251
 
243
252
  python3 -m venv .venv
@@ -252,7 +261,10 @@ pip install -e ".[dev]"
252
261
  # Run tests
253
262
  pytest --tb=short -q
254
263
 
255
- # Run linter
264
+ # Run tests with coverage
265
+ pytest --cov=keepassxc_cli --cov-report=term-missing
266
+
267
+ # Lint
256
268
  ruff check --ignore=E501 --exclude=__init__.py ./keepassxc_cli
257
269
  ```
258
270
 
@@ -1,9 +1,7 @@
1
- # keepassxc-cli
1
+ # KeePassXC CLI
2
2
 
3
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.
4
4
 
5
- ## What it is
6
-
7
5
  `keepassxc-cli` talks to a running KeePassXC instance using the same native messaging protocol used by the KeePassXC Browser extension. This means:
8
6
 
9
7
  - **Biometric unlock**: On macOS with TouchID (or similar) configured in KeePassXC, you can authenticate via fingerprint rather than typing your master password.
@@ -12,24 +10,32 @@ A command-line interface for [KeePassXC](https://keepassxc.org/) that communicat
12
10
  - **TOTP**: Retrieve time-based one-time passwords.
13
11
  - **Clipboard**: Copy credentials directly to the clipboard.
14
12
 
13
+ ![Functionality demonstration](assets/demo.gif)
14
+
15
+ KeePassXC CLI based on [KeePassXC Browser API](https://github.com/mietzen/keepassxc-browser-api).
16
+
15
17
  ## Prerequisites
16
18
 
17
- 1. **KeePassXC** 2.7 with the **Browser Integration** feature enabled:
18
- - Open KeePassXC → Tools → Settings → Browser Integration
19
- - Enable "Enable browser integration"
20
- 2. A KeePassXC database must be open (or KeePassXC must be running with auto-open configured).
21
- 3. Python 3.10
19
+ - **macOS** (uses Unix sockets and KeePassXC's browser extension socket)
20
+ - **Python >= 3.10**
21
+ - **KeePassXC** with:
22
+ - Browser Integration enabled (Settings > Browser Integration > Enable browser integration)
23
+ ![KeePassXC Browser Integration Settings screenshot](assets/settings-browser-integration.png)
22
24
 
23
- ## Installation
25
+ ## Install
24
26
 
25
- ```bash
26
- pipx install keepassxc-cli
27
+ ### Homebrew (recommended)
28
+
29
+ See **[homebrew homepage](https://brew.sh/)** on how to setup homebrew.
30
+
31
+ ```shell
32
+ brew install mietzen/tap/keepassxc-cli
27
33
  ```
28
34
 
29
- Or with pip:
35
+ ### pipx
30
36
 
31
37
  ```bash
32
- pip install keepassxc-cli
38
+ pipx install keepassxc-cli
33
39
  ```
34
40
 
35
41
  ## Setup
@@ -221,8 +227,11 @@ esac
221
227
 
222
228
  ## Development
223
229
 
230
+ This package depends on [`keepassxc-browser-api`](https://github.com/mietzen/keepassxc-browser-api), which handles the KeePassXC browser extension protocol. The browser API credentials are stored in `~/.keepassxc/browser-api.json` and are shared with `keepassxc-ssh-agent` if installed.
231
+
224
232
  ```bash
225
233
  git clone https://github.com/mietzen/keepassxc-cli
234
+ git clone https://github.com/mietzen/keepassxc-browser-api
226
235
  cd keepassxc-cli
227
236
 
228
237
  python3 -m venv .venv
@@ -237,7 +246,10 @@ pip install -e ".[dev]"
237
246
  # Run tests
238
247
  pytest --tb=short -q
239
248
 
240
- # Run linter
249
+ # Run tests with coverage
250
+ pytest --cov=keepassxc_cli --cov-report=term-missing
251
+
252
+ # Lint
241
253
  ruff check --ignore=E501 --exclude=__init__.py ./keepassxc_cli
242
254
  ```
243
255
 
Binary file
@@ -17,7 +17,9 @@ def add_parser(subparsers: argparse._SubParsersAction) -> None:
17
17
  p.add_argument("--url", required=True, help="Entry URL")
18
18
  p.add_argument("--username", required=True, help="Username")
19
19
  p.add_argument("--password", default=None, help="Password (prompted if omitted)")
20
- p.add_argument("--group-uuid", default="", help="Target group UUID")
20
+ group = p.add_mutually_exclusive_group()
21
+ group.add_argument("--group-uuid", default="", help="Target group UUID")
22
+ group.add_argument("--group", default=None, help="Target group path (e.g. 'Work/Projects')")
21
23
  p.set_defaults(func=run)
22
24
 
23
25
 
@@ -34,11 +36,27 @@ def run(
34
36
  if password is None:
35
37
  password = getpass.getpass("Password: ")
36
38
 
39
+ group_uuid = args.group_uuid
40
+
41
+ if args.group is not None:
42
+ groups = client.get_database_groups()
43
+ root = groups[0]
44
+ parts = args.group.split("/")
45
+ current = root.children
46
+ matched = None
47
+ for part in parts:
48
+ matched = next((g for g in current if g.name == part), None)
49
+ if matched is None:
50
+ logger.error("Group not found: %r", args.group)
51
+ return 1
52
+ current = matched.children
53
+ group_uuid = matched.uuid
54
+
37
55
  client.set_login(
38
56
  url=args.url,
39
57
  username=args.username,
40
58
  password=password,
41
- group_uuid=args.group_uuid,
59
+ group_uuid=group_uuid,
42
60
  )
43
61
  print("Entry added successfully.")
44
62
  return 0
@@ -14,17 +14,16 @@ logger = logging.getLogger(__name__)
14
14
  def add_parser(subparsers: argparse._SubParsersAction) -> None:
15
15
  p = subparsers.add_parser(
16
16
  "edit",
17
- help="Edit an existing entry by UUID",
17
+ help="Edit an existing entry",
18
18
  description=(
19
- "Edit an existing entry. The UUID must be known (use 'show' to find it).\n"
20
- "Provide --url so the entry can be resolved; omitted fields are left unchanged."
19
+ "Edit an existing entry. Provide --url to look up the entry; omitted fields are\n"
20
+ "left unchanged. If the URL matches multiple entries, specify --uuid to disambiguate."
21
21
  ),
22
22
  )
23
- p.add_argument("uuid", help="UUID of the entry to edit")
24
- p.add_argument("--url", required=True, help="URL of the entry (used to resolve current values)")
23
+ p.add_argument("--url", required=True, help="URL of the entry")
24
+ p.add_argument("--uuid", default=None, help="UUID of the entry (required when URL matches multiple entries)")
25
25
  p.add_argument("--username", default=None, help="New username")
26
26
  p.add_argument("--password", default=None, help="New password")
27
- p.add_argument("--title", default=None, help="New title")
28
27
  p.set_defaults(func=run)
29
28
 
30
29
 
@@ -37,21 +36,35 @@ def run(
37
36
  *,
38
37
  fmt: str = "table",
39
38
  ) -> int:
40
- # Resolve current entry values via get_logins (no special permission needed)
41
39
  entries = client.get_logins(args.url)
42
- entry = next((e for e in entries if e.uuid == args.uuid), None)
43
- if entry is None:
40
+
41
+ if not entries:
42
+ logger.error("No entries found for: %s", args.url)
43
+ return 1
44
+
45
+ if args.uuid is not None:
46
+ entry = next((e for e in entries if e.uuid == args.uuid), None)
47
+ if entry is None:
48
+ logger.error(
49
+ "Entry %s not found for URL: %s",
50
+ args.uuid, args.url,
51
+ )
52
+ return 1
53
+ elif len(entries) == 1:
54
+ entry = entries[0]
55
+ else:
44
56
  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,
57
+ "Multiple entries found for %s specify --uuid to disambiguate:",
58
+ args.url,
47
59
  )
60
+ for e in entries:
61
+ print(f" {e.uuid} {e.login} ({e.name})")
48
62
  return 1
49
63
 
50
64
  client.set_login(
51
65
  url=args.url,
52
66
  username=args.username if args.username is not None else entry.login,
53
67
  password=args.password if args.password is not None else entry.password,
54
- title=args.title if args.title is not None else entry.name,
55
68
  uuid=entry.uuid,
56
69
  group_uuid=entry.group_uuid,
57
70
  )
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ from keepassxc_browser_api import BrowserClient, BrowserConfig
8
+
9
+ from keepassxc_cli.config import CliConfig
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def add_parser(subparsers: argparse._SubParsersAction) -> None:
15
+ p = subparsers.add_parser("rm", help="Delete an entry")
16
+ group = p.add_mutually_exclusive_group(required=True)
17
+ group.add_argument("--uuid", default=None, help="UUID of the entry to delete")
18
+ group.add_argument("--url", default=None, help="URL of the entry to delete (must match exactly one entry)")
19
+ p.add_argument("-y", "--yes", action="store_true", help="Skip confirmation prompt")
20
+ p.set_defaults(func=run)
21
+
22
+
23
+ def run(
24
+ client: BrowserClient,
25
+ args: argparse.Namespace,
26
+ cli_config: CliConfig,
27
+ browser_config: BrowserConfig,
28
+ browser_config_path: Path,
29
+ *,
30
+ fmt: str = "table",
31
+ ) -> int:
32
+ if args.uuid is not None:
33
+ target_uuid = args.uuid
34
+ label = args.uuid
35
+ else:
36
+ entries = client.get_logins(args.url)
37
+ if not entries:
38
+ logger.error("No entries found for: %s", args.url)
39
+ return 1
40
+ if len(entries) > 1:
41
+ logger.error(
42
+ "Multiple entries found for %s \u2014 specify --uuid to disambiguate:",
43
+ args.url,
44
+ )
45
+ for e in entries:
46
+ print(f" {e.uuid} {e.login} ({e.name})")
47
+ return 1
48
+ target_uuid = entries[0].uuid
49
+ label = f"{entries[0].name} ({args.url})"
50
+
51
+ if not args.yes:
52
+ answer = input(f"Delete entry {label}? [y/N] ").strip().lower()
53
+ if answer != "y":
54
+ print("Aborted.")
55
+ return 1
56
+
57
+ client.delete_entry(target_uuid)
58
+ print("Entry deleted.")
59
+ 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,24 +1,22 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keepassxc-cli
3
- Version: 1.4.0
3
+ Version: 1.6.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.3.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
 
20
- ## What it is
21
-
22
20
  `keepassxc-cli` talks to a running KeePassXC instance using the same native messaging protocol used by the KeePassXC Browser extension. This means:
23
21
 
24
22
  - **Biometric unlock**: On macOS with TouchID (or similar) configured in KeePassXC, you can authenticate via fingerprint rather than typing your master password.
@@ -27,24 +25,32 @@ A command-line interface for [KeePassXC](https://keepassxc.org/) that communicat
27
25
  - **TOTP**: Retrieve time-based one-time passwords.
28
26
  - **Clipboard**: Copy credentials directly to the clipboard.
29
27
 
28
+ ![Functionality demonstration](assets/demo.gif)
29
+
30
+ KeePassXC CLI based on [KeePassXC Browser API](https://github.com/mietzen/keepassxc-browser-api).
31
+
30
32
  ## Prerequisites
31
33
 
32
- 1. **KeePassXC** 2.7 with the **Browser Integration** feature enabled:
33
- - Open KeePassXC → Tools → Settings → Browser Integration
34
- - Enable "Enable browser integration"
35
- 2. A KeePassXC database must be open (or KeePassXC must be running with auto-open configured).
36
- 3. Python 3.10
34
+ - **macOS** (uses Unix sockets and KeePassXC's browser extension socket)
35
+ - **Python >= 3.10**
36
+ - **KeePassXC** with:
37
+ - Browser Integration enabled (Settings > Browser Integration > Enable browser integration)
38
+ ![KeePassXC Browser Integration Settings screenshot](assets/settings-browser-integration.png)
37
39
 
38
- ## Installation
40
+ ## Install
39
41
 
40
- ```bash
41
- pipx install keepassxc-cli
42
+ ### Homebrew (recommended)
43
+
44
+ See **[homebrew homepage](https://brew.sh/)** on how to setup homebrew.
45
+
46
+ ```shell
47
+ brew install mietzen/tap/keepassxc-cli
42
48
  ```
43
49
 
44
- Or with pip:
50
+ ### pipx
45
51
 
46
52
  ```bash
47
- pip install keepassxc-cli
53
+ pipx install keepassxc-cli
48
54
  ```
49
55
 
50
56
  ## Setup
@@ -236,8 +242,11 @@ esac
236
242
 
237
243
  ## Development
238
244
 
245
+ This package depends on [`keepassxc-browser-api`](https://github.com/mietzen/keepassxc-browser-api), which handles the KeePassXC browser extension protocol. The browser API credentials are stored in `~/.keepassxc/browser-api.json` and are shared with `keepassxc-ssh-agent` if installed.
246
+
239
247
  ```bash
240
248
  git clone https://github.com/mietzen/keepassxc-cli
249
+ git clone https://github.com/mietzen/keepassxc-browser-api
241
250
  cd keepassxc-cli
242
251
 
243
252
  python3 -m venv .venv
@@ -252,7 +261,10 @@ pip install -e ".[dev]"
252
261
  # Run tests
253
262
  pytest --tb=short -q
254
263
 
255
- # Run linter
264
+ # Run tests with coverage
265
+ pytest --cov=keepassxc_cli --cov-report=term-missing
266
+
267
+ # Lint
256
268
  ruff check --ignore=E501 --exclude=__init__.py ./keepassxc_cli
257
269
  ```
258
270
 
@@ -7,6 +7,8 @@ pyproject.toml
7
7
  .github/workflows/auto-release.yml
8
8
  .github/workflows/lint_and_test.yml
9
9
  .github/workflows/pypi.yml
10
+ assets/demo.gif
11
+ assets/settings-browser-integration.png
10
12
  keepassxc_cli/__init__.py
11
13
  keepassxc_cli/__main__.py
12
14
  keepassxc_cli/config.py
@@ -1,4 +1,4 @@
1
- keepassxc-browser-api==1.3.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.3.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"]
@@ -40,6 +40,7 @@ def make_args(**kwargs) -> argparse.Namespace:
40
40
  "username": "user",
41
41
  "password": "pass",
42
42
  "group_uuid": "",
43
+ "group": None,
43
44
  "uuid": "abcdef12-0000-0000-0000-000000000000",
44
45
  "name": "NewGroup",
45
46
  "path": "Work",
@@ -141,15 +142,36 @@ class TestShowCommand:
141
142
  class TestAddCommand:
142
143
  def test_success(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
143
144
  mock_client.set_login.return_value = True
144
- args = make_args(url="https://example.com", username="u", password="p", group_uuid="")
145
+ args = make_args(url="https://example.com", username="u", password="p", group_uuid="", group=None)
145
146
  rc = add.run(mock_client, args, cli_config, browser_config, browser_config_path)
146
147
  assert rc == 0
147
148
  mock_client.set_login.assert_called_once()
148
149
  assert "added" in capsys.readouterr().out.lower()
149
150
 
151
+ def test_group_path_resolved(self, mock_client, cli_config, browser_config, browser_config_path, capsys, mock_group):
152
+ from keepassxc_browser_api import Group
153
+ projects = mock_group(uuid="proj-uuid", name="Projects")
154
+ work = mock_group(uuid="work-uuid", name="Work", children=[projects])
155
+ root = mock_group(uuid="root-uuid", name="Root", children=[work])
156
+ mock_client.get_database_groups.return_value = [root]
157
+ mock_client.set_login.return_value = True
158
+ args = make_args(url="https://example.com", username="u", password="p", group_uuid="", group="Work/Projects")
159
+ rc = add.run(mock_client, args, cli_config, browser_config, browser_config_path)
160
+ assert rc == 0
161
+ call_kwargs = mock_client.set_login.call_args.kwargs
162
+ assert call_kwargs["group_uuid"] == "proj-uuid"
163
+
164
+ def test_group_path_not_found(self, mock_client, cli_config, browser_config, browser_config_path, caplog, mock_group):
165
+ root = mock_group(uuid="root-uuid", name="Root", children=[])
166
+ mock_client.get_database_groups.return_value = [root]
167
+ args = make_args(url="https://example.com", username="u", password="p", group_uuid="", group="NonExistent")
168
+ rc = add.run(mock_client, args, cli_config, browser_config, browser_config_path)
169
+ assert rc == 1
170
+ assert any("not found" in r.message.lower() for r in caplog.records)
171
+
150
172
  def test_failure_propagates(self, mock_client, cli_config, browser_config, browser_config_path):
151
173
  mock_client.set_login.side_effect = ProtocolError("access denied", error_code=6)
152
- args = make_args(url="https://example.com", username="u", password="p", group_uuid="")
174
+ args = make_args(url="https://example.com", username="u", password="p", group_uuid="", group=None)
153
175
  with pytest.raises(ProtocolError):
154
176
  add.run(mock_client, args, cli_config, browser_config, browser_config_path)
155
177
 
@@ -157,37 +179,91 @@ class TestAddCommand:
157
179
  # --- edit ---
158
180
 
159
181
  class TestEditCommand:
160
- def test_entry_found_and_updated(self, mock_client, cli_config, browser_config, browser_config_path, capsys, mock_entry):
182
+ def test_uuid_specified_and_updated(self, mock_client, cli_config, browser_config, browser_config_path, capsys, mock_entry):
161
183
  entry = mock_entry()
162
184
  mock_client.get_logins.return_value = [entry]
163
185
  mock_client.set_login.return_value = True
164
- args = make_args(uuid=entry.uuid, url="https://example.com", username="newuser", password=None, title=None)
186
+ args = make_args(uuid=entry.uuid, url="https://example.com", username="newuser", password=None)
165
187
  rc = edit.run(mock_client, args, cli_config, browser_config, browser_config_path)
166
188
  assert rc == 0
167
189
  assert "updated" in capsys.readouterr().out.lower()
190
+ mock_client.set_login.assert_called_once()
191
+ call_kwargs = mock_client.set_login.call_args.kwargs
192
+ assert "title" not in call_kwargs
168
193
 
169
- def test_entry_not_found(self, mock_client, cli_config, browser_config, browser_config_path, caplog):
170
- mock_client.get_logins.return_value = []
171
- args = make_args(uuid="nonexistent-uuid", url="https://example.com", username=None, password=None, title=None)
194
+ def test_no_uuid_single_match_auto_selected(self, mock_client, cli_config, browser_config, browser_config_path, capsys, mock_entry):
195
+ entry = mock_entry()
196
+ mock_client.get_logins.return_value = [entry]
197
+ mock_client.set_login.return_value = True
198
+ args = make_args(uuid=None, url="https://example.com", username="newuser", password=None)
199
+ rc = edit.run(mock_client, args, cli_config, browser_config, browser_config_path)
200
+ assert rc == 0
201
+ assert "updated" in capsys.readouterr().out.lower()
202
+
203
+ def test_no_uuid_multiple_matches_error(self, mock_client, cli_config, browser_config, browser_config_path, caplog, mock_entry):
204
+ e1 = mock_entry(uuid="uuid-1", login="alice")
205
+ e2 = mock_entry(uuid="uuid-2", login="bob")
206
+ mock_client.get_logins.return_value = [e1, e2]
207
+ args = make_args(uuid=None, url="https://example.com", username="newuser", password=None)
208
+ rc = edit.run(mock_client, args, cli_config, browser_config, browser_config_path)
209
+ assert rc == 1
210
+ assert any("multiple" in r.message.lower() for r in caplog.records)
211
+
212
+ def test_uuid_not_in_results(self, mock_client, cli_config, browser_config, browser_config_path, caplog, mock_entry):
213
+ entry = mock_entry()
214
+ mock_client.get_logins.return_value = [entry]
215
+ args = make_args(uuid="nonexistent-uuid", url="https://example.com", username=None, password=None)
172
216
  rc = edit.run(mock_client, args, cli_config, browser_config, browser_config_path)
173
217
  assert rc == 1
174
218
  assert any("not found" in r.message.lower() for r in caplog.records)
175
219
 
220
+ def test_no_entries_found(self, mock_client, cli_config, browser_config, browser_config_path, caplog):
221
+ mock_client.get_logins.return_value = []
222
+ args = make_args(uuid=None, url="https://example.com", username=None, password=None)
223
+ rc = edit.run(mock_client, args, cli_config, browser_config, browser_config_path)
224
+ assert rc == 1
225
+ assert any("no entries" in r.message.lower() for r in caplog.records)
226
+
176
227
 
177
228
  # --- rm ---
178
229
 
179
230
  class TestRmCommand:
180
- def test_with_yes_flag(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
231
+ def test_uuid_with_yes_flag(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
181
232
  mock_client.delete_entry.return_value = True
182
- args = make_args(uuid="some-uuid", yes=True)
233
+ args = make_args(uuid="some-uuid", url=None, yes=True)
183
234
  rc = rm.run(mock_client, args, cli_config, browser_config, browser_config_path)
184
235
  assert rc == 0
185
236
  mock_client.delete_entry.assert_called_once_with("some-uuid")
186
237
  assert "deleted" in capsys.readouterr().out.lower()
187
238
 
239
+ def test_url_single_match_deletes(self, mock_client, cli_config, browser_config, browser_config_path, capsys, mock_entry):
240
+ entry = mock_entry(uuid="url-resolved-uuid")
241
+ mock_client.get_logins.return_value = [entry]
242
+ mock_client.delete_entry.return_value = True
243
+ args = make_args(uuid=None, url="https://example.com", yes=True)
244
+ rc = rm.run(mock_client, args, cli_config, browser_config, browser_config_path)
245
+ assert rc == 0
246
+ mock_client.delete_entry.assert_called_once_with("url-resolved-uuid")
247
+
248
+ def test_url_multiple_matches_error(self, mock_client, cli_config, browser_config, browser_config_path, caplog, mock_entry):
249
+ e1 = mock_entry(uuid="uuid-1", login="alice")
250
+ e2 = mock_entry(uuid="uuid-2", login="bob")
251
+ mock_client.get_logins.return_value = [e1, e2]
252
+ args = make_args(uuid=None, url="https://example.com", yes=True)
253
+ rc = rm.run(mock_client, args, cli_config, browser_config, browser_config_path)
254
+ assert rc == 1
255
+ assert any("multiple" in r.message.lower() for r in caplog.records)
256
+
257
+ def test_url_no_entries_error(self, mock_client, cli_config, browser_config, browser_config_path, caplog):
258
+ mock_client.get_logins.return_value = []
259
+ args = make_args(uuid=None, url="https://notfound.com", yes=True)
260
+ rc = rm.run(mock_client, args, cli_config, browser_config, browser_config_path)
261
+ assert rc == 1
262
+ assert any("no entries" in r.message.lower() for r in caplog.records)
263
+
188
264
  def test_failure_propagates(self, mock_client, cli_config, browser_config, browser_config_path):
189
265
  mock_client.delete_entry.side_effect = ProtocolError("access denied", error_code=6)
190
- args = make_args(uuid="some-uuid", yes=True)
266
+ args = make_args(uuid="some-uuid", url=None, yes=True)
191
267
  with pytest.raises(ProtocolError):
192
268
  rm.run(mock_client, args, cli_config, browser_config, browser_config_path)
193
269
 
@@ -1,38 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import argparse
4
- import logging
5
- from pathlib import Path
6
-
7
- from keepassxc_browser_api import BrowserClient, BrowserConfig
8
-
9
- from keepassxc_cli.config import CliConfig
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
-
14
- def add_parser(subparsers: argparse._SubParsersAction) -> None:
15
- p = subparsers.add_parser("rm", help="Delete an entry by UUID")
16
- p.add_argument("uuid", help="UUID of the entry to delete")
17
- p.add_argument("-y", "--yes", action="store_true", help="Skip confirmation prompt")
18
- p.set_defaults(func=run)
19
-
20
-
21
- def run(
22
- client: BrowserClient,
23
- args: argparse.Namespace,
24
- cli_config: CliConfig,
25
- browser_config: BrowserConfig,
26
- browser_config_path: Path,
27
- *,
28
- fmt: str = "table",
29
- ) -> int:
30
- if not args.yes:
31
- answer = input(f"Delete entry {args.uuid}? [y/N] ").strip().lower()
32
- if answer != "y":
33
- print("Aborted.")
34
- return 1
35
-
36
- client.delete_entry(args.uuid)
37
- print("Entry deleted.")
38
- return 0
File without changes
File without changes
File without changes