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.
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/CLAUDE.md +17 -9
- {keepassxc_cli-1.4.0/keepassxc_cli.egg-info → keepassxc_cli-1.6.0}/PKG-INFO +28 -16
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/README.md +26 -14
- keepassxc_cli-1.6.0/assets/demo.gif +0 -0
- keepassxc_cli-1.6.0/assets/settings-browser-integration.png +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/commands/add.py +20 -2
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/commands/edit.py +25 -12
- keepassxc_cli-1.6.0/keepassxc_cli/commands/rm.py +59 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/config.py +12 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0/keepassxc_cli.egg-info}/PKG-INFO +28 -16
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli.egg-info/SOURCES.txt +2 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli.egg-info/requires.txt +1 -1
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/pyproject.toml +7 -1
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/tests/test_commands.py +86 -10
- keepassxc_cli-1.4.0/keepassxc_cli/commands/rm.py +0 -38
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/.github/workflows/auto-merge-dependabot.yml +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/.github/workflows/auto-release.yml +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/.github/workflows/lint_and_test.yml +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/.github/workflows/pypi.yml +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/.gitignore +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/LICENSE +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/__init__.py +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/__main__.py +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/commands/__init__.py +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/commands/clip.py +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/commands/group_uuid.py +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/commands/lock.py +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/commands/mkdir.py +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/commands/setup.py +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/commands/show.py +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/commands/status.py +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/commands/totp.py +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/commands/version.py +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli/output.py +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli.egg-info/dependency_links.txt +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli.egg-info/entry_points.txt +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/keepassxc_cli.egg-info/top_level.txt +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/setup.cfg +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/tests/conftest.py +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/tests/test_config.py +0 -0
- {keepassxc_cli-1.4.0 → keepassxc_cli-1.6.0}/tests/test_output.py +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
#
|
|
1
|
+
# keepassxc-cli - Claude Code Instructions
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
##
|
|
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.
|
|
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.
|
|
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
|
-
#
|
|
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
|
+

|
|
29
|
+
|
|
30
|
+
KeePassXC CLI based on [KeePassXC Browser API](https://github.com/mietzen/keepassxc-browser-api).
|
|
31
|
+
|
|
30
32
|
## Prerequisites
|
|
31
33
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
+

|
|
37
39
|
|
|
38
|
-
##
|
|
40
|
+
## Install
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
50
|
+
### pipx
|
|
45
51
|
|
|
46
52
|
```bash
|
|
47
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
+

|
|
14
|
+
|
|
15
|
+
KeePassXC CLI based on [KeePassXC Browser API](https://github.com/mietzen/keepassxc-browser-api).
|
|
16
|
+
|
|
15
17
|
## Prerequisites
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
+

|
|
22
24
|
|
|
23
|
-
##
|
|
25
|
+
## Install
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
35
|
+
### pipx
|
|
30
36
|
|
|
31
37
|
```bash
|
|
32
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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=
|
|
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
|
|
17
|
+
help="Edit an existing entry",
|
|
18
18
|
description=(
|
|
19
|
-
"Edit an existing entry.
|
|
20
|
-
"
|
|
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("
|
|
24
|
-
p.add_argument("--
|
|
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
|
-
|
|
43
|
-
if
|
|
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
|
-
"
|
|
46
|
-
args.
|
|
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.
|
|
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.
|
|
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
|
-
#
|
|
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
|
+

|
|
29
|
+
|
|
30
|
+
KeePassXC CLI based on [KeePassXC Browser API](https://github.com/mietzen/keepassxc-browser-api).
|
|
31
|
+
|
|
30
32
|
## Prerequisites
|
|
31
33
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
+

|
|
37
39
|
|
|
38
|
-
##
|
|
40
|
+
## Install
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
50
|
+
### pipx
|
|
45
51
|
|
|
46
52
|
```bash
|
|
47
|
-
|
|
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
|
|
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
|
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|