keepassxc-cli 1.2.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.
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/CLAUDE.md +12 -2
- {keepassxc_cli-1.2.0/keepassxc_cli.egg-info → keepassxc_cli-1.4.0}/PKG-INFO +49 -2
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/README.md +47 -0
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/__main__.py +25 -4
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/add.py +6 -8
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/clip.py +7 -5
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/edit.py +9 -12
- keepassxc_cli-1.4.0/keepassxc_cli/commands/group_uuid.py +56 -0
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/lock.py +4 -2
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/mkdir.py +3 -4
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/rm.py +6 -8
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/setup.py +7 -9
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/show.py +4 -2
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/status.py +9 -5
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/totp.py +5 -3
- keepassxc_cli-1.4.0/keepassxc_cli/commands/version.py +31 -0
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0/keepassxc_cli.egg-info}/PKG-INFO +49 -2
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli.egg-info/SOURCES.txt +2 -0
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli.egg-info/requires.txt +1 -1
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/pyproject.toml +1 -1
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/tests/conftest.py +1 -0
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/tests/test_commands.py +155 -26
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/.github/workflows/auto-merge-dependabot.yml +0 -0
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/.github/workflows/auto-release.yml +0 -0
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/.github/workflows/lint_and_test.yml +0 -0
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/.github/workflows/pypi.yml +0 -0
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/.gitignore +0 -0
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/LICENSE +0 -0
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/__init__.py +0 -0
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/__init__.py +0 -0
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/config.py +0 -0
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/output.py +0 -0
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli.egg-info/dependency_links.txt +0 -0
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli.egg-info/entry_points.txt +0 -0
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli.egg-info/top_level.txt +0 -0
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/setup.cfg +0 -0
- {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/tests/test_config.py +0 -0
- {keepassxc_cli-1.2.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
|
|
99
|
-
- **
|
|
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
|
+
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.
|
|
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"
|
|
@@ -163,6 +163,33 @@ keepassxc-cli mkdir "Work/Projects" # create Projects inside Work
|
|
|
163
163
|
|
|
164
164
|
Use `/`-separated paths to create nested groups. KeePassXC creates any missing path segments automatically.
|
|
165
165
|
|
|
166
|
+
#### `group-uuid` — Look up a group's UUID by path
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
keepassxc-cli group-uuid "Work"
|
|
170
|
+
keepassxc-cli group-uuid "Work/Projects"
|
|
171
|
+
keepassxc-cli group-uuid "Work/Projects" -j
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Returns the UUID for the group at the given path (relative to the database root). Useful for scripting — pipe the UUID into `add --group-uuid`.
|
|
175
|
+
|
|
176
|
+
JSON output (`-j`):
|
|
177
|
+
```json
|
|
178
|
+
{
|
|
179
|
+
"path": "Work/Projects",
|
|
180
|
+
"name": "Projects",
|
|
181
|
+
"uuid": "<uuid>"
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
#### `version` — Show the CLI version
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
keepassxc-cli version
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Does not require a running KeePassXC instance.
|
|
192
|
+
|
|
166
193
|
## Configuration
|
|
167
194
|
|
|
168
195
|
### CLI config (`~/.keepassxc/cli.json`)
|
|
@@ -187,6 +214,26 @@ Shared with `keepassxc-browser-api`. Contains the association keys created durin
|
|
|
187
214
|
|
|
188
215
|
Both config files are stored with `0o600` permissions (owner read/write only).
|
|
189
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
|
+
|
|
190
237
|
## Development
|
|
191
238
|
|
|
192
239
|
```bash
|
|
@@ -148,6 +148,33 @@ keepassxc-cli mkdir "Work/Projects" # create Projects inside Work
|
|
|
148
148
|
|
|
149
149
|
Use `/`-separated paths to create nested groups. KeePassXC creates any missing path segments automatically.
|
|
150
150
|
|
|
151
|
+
#### `group-uuid` — Look up a group's UUID by path
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
keepassxc-cli group-uuid "Work"
|
|
155
|
+
keepassxc-cli group-uuid "Work/Projects"
|
|
156
|
+
keepassxc-cli group-uuid "Work/Projects" -j
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Returns the UUID for the group at the given path (relative to the database root). Useful for scripting — pipe the UUID into `add --group-uuid`.
|
|
160
|
+
|
|
161
|
+
JSON output (`-j`):
|
|
162
|
+
```json
|
|
163
|
+
{
|
|
164
|
+
"path": "Work/Projects",
|
|
165
|
+
"name": "Projects",
|
|
166
|
+
"uuid": "<uuid>"
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
#### `version` — Show the CLI version
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
keepassxc-cli version
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Does not require a running KeePassXC instance.
|
|
177
|
+
|
|
151
178
|
## Configuration
|
|
152
179
|
|
|
153
180
|
### CLI config (`~/.keepassxc/cli.json`)
|
|
@@ -172,6 +199,26 @@ Shared with `keepassxc-browser-api`. Contains the association keys created durin
|
|
|
172
199
|
|
|
173
200
|
Both config files are stored with `0o600` permissions (owner read/write only).
|
|
174
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
|
+
|
|
175
222
|
## Development
|
|
176
223
|
|
|
177
224
|
```bash
|
|
@@ -8,10 +8,10 @@ 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,
|
|
11
|
+
from keepassxc_browser_api.exceptions import ConnectionError, DatabaseLockedError, KeePassXCError, ProtocolError
|
|
12
12
|
|
|
13
13
|
from .config import CliConfig, DEFAULT_CLI_CONFIG_PATH
|
|
14
|
-
from .commands import setup, status, show, add, edit, rm, totp, clip, lock, mkdir
|
|
14
|
+
from .commands import setup, status, show, add, edit, rm, totp, clip, lock, mkdir, group_uuid, version
|
|
15
15
|
|
|
16
16
|
# Shared parent parser that injects -j/--json into each subparser that supports it.
|
|
17
17
|
# Defined at module level so command modules can import it if needed.
|
|
@@ -55,11 +55,23 @@ def main() -> None:
|
|
|
55
55
|
clip.add_parser(subparsers)
|
|
56
56
|
lock.add_parser(subparsers)
|
|
57
57
|
mkdir.add_parser(subparsers)
|
|
58
|
+
group_uuid.add_parser(subparsers, fmt_parent)
|
|
59
|
+
version.add_parser(subparsers)
|
|
58
60
|
|
|
59
61
|
args = parser.parse_args()
|
|
60
62
|
|
|
61
63
|
if args.verbose:
|
|
62
|
-
logging.basicConfig(
|
|
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
|
+
)
|
|
63
75
|
|
|
64
76
|
cli_config_path = Path(args.config)
|
|
65
77
|
cli_config = CliConfig.load(cli_config_path)
|
|
@@ -72,7 +84,16 @@ def main() -> None:
|
|
|
72
84
|
client = BrowserClient(browser_config)
|
|
73
85
|
try:
|
|
74
86
|
rc = args.func(client, args, cli_config, browser_config, browser_api_config_path, fmt=fmt)
|
|
75
|
-
except
|
|
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:
|
|
76
97
|
print(f"Error: {e}", file=sys.stderr)
|
|
77
98
|
rc = 1
|
|
78
99
|
sys.exit(rc)
|
|
@@ -2,13 +2,15 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
import getpass
|
|
5
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
+
logger.warning("No TOTP configured for: %s", entry.name)
|
|
53
55
|
return 1
|
|
54
56
|
else:
|
|
55
|
-
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from keepassxc_browser_api import BrowserClient, BrowserConfig
|
|
9
|
+
|
|
10
|
+
from keepassxc_cli.config import CliConfig
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def add_parser(subparsers: argparse._SubParsersAction, fmt_parent: argparse.ArgumentParser | None = None) -> None:
|
|
16
|
+
parents = [fmt_parent] if fmt_parent else []
|
|
17
|
+
p = subparsers.add_parser(
|
|
18
|
+
"group-uuid",
|
|
19
|
+
parents=parents,
|
|
20
|
+
help="Look up the UUID of a group by its path",
|
|
21
|
+
)
|
|
22
|
+
p.add_argument(
|
|
23
|
+
"path",
|
|
24
|
+
help="Group path relative to the database root (e.g. 'Work/Projects')",
|
|
25
|
+
)
|
|
26
|
+
p.set_defaults(func=run)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run(
|
|
30
|
+
client: BrowserClient,
|
|
31
|
+
args: argparse.Namespace,
|
|
32
|
+
cli_config: CliConfig,
|
|
33
|
+
browser_config: BrowserConfig,
|
|
34
|
+
browser_config_path: Path,
|
|
35
|
+
*,
|
|
36
|
+
fmt: str = "table",
|
|
37
|
+
) -> int:
|
|
38
|
+
groups = client.get_database_groups()
|
|
39
|
+
root = groups[0]
|
|
40
|
+
parts = args.path.split("/")
|
|
41
|
+
|
|
42
|
+
# Paths are root-relative: traverse root.children, not the root itself.
|
|
43
|
+
current = root.children
|
|
44
|
+
matched = None
|
|
45
|
+
for part in parts:
|
|
46
|
+
matched = next((g for g in current if g.name == part), None)
|
|
47
|
+
if matched is None:
|
|
48
|
+
logger.error("Group not found: %r", args.path)
|
|
49
|
+
return 1
|
|
50
|
+
current = matched.children
|
|
51
|
+
|
|
52
|
+
if fmt == "json":
|
|
53
|
+
print(json.dumps({"path": args.path, "name": matched.name, "uuid": matched.uuid}, indent=2))
|
|
54
|
+
else:
|
|
55
|
+
print(f"{args.path} [{matched.uuid}]")
|
|
56
|
+
return 0
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
-
import
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
38
|
+
logger.warning("No TOTP configured for: %s", entry.name)
|
|
37
39
|
return 1
|
|
38
40
|
print_totp(totp, fmt)
|
|
39
41
|
return 0
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from keepassxc_browser_api import BrowserClient, BrowserConfig
|
|
8
|
+
|
|
9
|
+
from keepassxc_cli.config import CliConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def add_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
13
|
+
p = subparsers.add_parser("version", help="Show the keepassxc-cli version")
|
|
14
|
+
p.set_defaults(func=run)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run(
|
|
18
|
+
client: BrowserClient,
|
|
19
|
+
args: argparse.Namespace,
|
|
20
|
+
cli_config: CliConfig,
|
|
21
|
+
browser_config: BrowserConfig,
|
|
22
|
+
browser_config_path: Path,
|
|
23
|
+
*,
|
|
24
|
+
fmt: str = "table",
|
|
25
|
+
) -> int:
|
|
26
|
+
try:
|
|
27
|
+
ver = version("keepassxc-cli")
|
|
28
|
+
except PackageNotFoundError:
|
|
29
|
+
ver = "unknown"
|
|
30
|
+
print(f"keepassxc-cli {ver}")
|
|
31
|
+
return 0
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: keepassxc-cli
|
|
3
|
-
Version: 1.
|
|
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.
|
|
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"
|
|
@@ -163,6 +163,33 @@ keepassxc-cli mkdir "Work/Projects" # create Projects inside Work
|
|
|
163
163
|
|
|
164
164
|
Use `/`-separated paths to create nested groups. KeePassXC creates any missing path segments automatically.
|
|
165
165
|
|
|
166
|
+
#### `group-uuid` — Look up a group's UUID by path
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
keepassxc-cli group-uuid "Work"
|
|
170
|
+
keepassxc-cli group-uuid "Work/Projects"
|
|
171
|
+
keepassxc-cli group-uuid "Work/Projects" -j
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Returns the UUID for the group at the given path (relative to the database root). Useful for scripting — pipe the UUID into `add --group-uuid`.
|
|
175
|
+
|
|
176
|
+
JSON output (`-j`):
|
|
177
|
+
```json
|
|
178
|
+
{
|
|
179
|
+
"path": "Work/Projects",
|
|
180
|
+
"name": "Projects",
|
|
181
|
+
"uuid": "<uuid>"
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
#### `version` — Show the CLI version
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
keepassxc-cli version
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Does not require a running KeePassXC instance.
|
|
192
|
+
|
|
166
193
|
## Configuration
|
|
167
194
|
|
|
168
195
|
### CLI config (`~/.keepassxc/cli.json`)
|
|
@@ -187,6 +214,26 @@ Shared with `keepassxc-browser-api`. Contains the association keys created durin
|
|
|
187
214
|
|
|
188
215
|
Both config files are stored with `0o600` permissions (owner read/write only).
|
|
189
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
|
+
|
|
190
237
|
## Development
|
|
191
238
|
|
|
192
239
|
```bash
|
|
@@ -21,6 +21,7 @@ keepassxc_cli/commands/__init__.py
|
|
|
21
21
|
keepassxc_cli/commands/add.py
|
|
22
22
|
keepassxc_cli/commands/clip.py
|
|
23
23
|
keepassxc_cli/commands/edit.py
|
|
24
|
+
keepassxc_cli/commands/group_uuid.py
|
|
24
25
|
keepassxc_cli/commands/lock.py
|
|
25
26
|
keepassxc_cli/commands/mkdir.py
|
|
26
27
|
keepassxc_cli/commands/rm.py
|
|
@@ -28,6 +29,7 @@ keepassxc_cli/commands/setup.py
|
|
|
28
29
|
keepassxc_cli/commands/show.py
|
|
29
30
|
keepassxc_cli/commands/status.py
|
|
30
31
|
keepassxc_cli/commands/totp.py
|
|
32
|
+
keepassxc_cli/commands/version.py
|
|
31
33
|
tests/conftest.py
|
|
32
34
|
tests/test_commands.py
|
|
33
35
|
tests/test_config.py
|
|
@@ -71,6 +71,7 @@ def mock_client():
|
|
|
71
71
|
client.get_logins.return_value = [make_entry()]
|
|
72
72
|
client.set_login.return_value = True
|
|
73
73
|
client.create_group.return_value = make_group(uuid="new-uuid", name="NewGroup")
|
|
74
|
+
client.get_database_groups.return_value = [make_group()]
|
|
74
75
|
client.get_totp.return_value = "123456"
|
|
75
76
|
client.delete_entry.return_value = True
|
|
76
77
|
client.lock_database.return_value = True
|
|
@@ -8,9 +8,10 @@ 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
|
-
setup, status, show, add, edit, rm, totp, clip, lock, mkdir,
|
|
14
|
+
setup, status, show, add, edit, rm, totp, clip, lock, mkdir, group_uuid, version,
|
|
14
15
|
)
|
|
15
16
|
|
|
16
17
|
|
|
@@ -41,6 +42,7 @@ def make_args(**kwargs) -> argparse.Namespace:
|
|
|
41
42
|
"group_uuid": "",
|
|
42
43
|
"uuid": "abcdef12-0000-0000-0000-000000000000",
|
|
43
44
|
"name": "NewGroup",
|
|
45
|
+
"path": "Work",
|
|
44
46
|
}
|
|
45
47
|
defaults.update(kwargs)
|
|
46
48
|
return argparse.Namespace(**defaults)
|
|
@@ -49,19 +51,18 @@ def make_args(**kwargs) -> argparse.Namespace:
|
|
|
49
51
|
# --- setup ---
|
|
50
52
|
|
|
51
53
|
class TestSetupCommand:
|
|
52
|
-
def test_success(self, mock_client, cli_config, browser_config, browser_config_path):
|
|
53
|
-
mock_client.setup.return_value =
|
|
54
|
+
def test_success(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
|
|
55
|
+
mock_client.setup.return_value = None
|
|
54
56
|
args = make_args()
|
|
55
57
|
rc = setup.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
56
58
|
assert rc == 0
|
|
57
59
|
mock_client.setup.assert_called_once()
|
|
58
60
|
|
|
59
|
-
def
|
|
60
|
-
mock_client.setup.
|
|
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")
|
|
61
63
|
args = make_args()
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
assert "Failed" in capsys.readouterr().err
|
|
64
|
+
with pytest.raises(ConnectionError):
|
|
65
|
+
setup.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
65
66
|
|
|
66
67
|
|
|
67
68
|
# --- status ---
|
|
@@ -77,7 +78,7 @@ class TestStatusCommand:
|
|
|
77
78
|
assert "yes" in out
|
|
78
79
|
|
|
79
80
|
def test_not_connected(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
|
|
80
|
-
mock_client.connect.
|
|
81
|
+
mock_client.connect.side_effect = ConnectionError("not available")
|
|
81
82
|
args = make_args()
|
|
82
83
|
rc = status.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
83
84
|
assert rc == 1
|
|
@@ -127,12 +128,12 @@ class TestShowCommand:
|
|
|
127
128
|
assert rc == 0
|
|
128
129
|
assert "KPH: url: https://github.com" in capsys.readouterr().out
|
|
129
130
|
|
|
130
|
-
def test_no_entries(self, mock_client, cli_config, browser_config, browser_config_path,
|
|
131
|
+
def test_no_entries(self, mock_client, cli_config, browser_config, browser_config_path, caplog):
|
|
131
132
|
mock_client.get_logins.return_value = []
|
|
132
133
|
args = make_args(url="https://notfound.com")
|
|
133
134
|
rc = show.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
134
135
|
assert rc == 1
|
|
135
|
-
assert "No entries" in
|
|
136
|
+
assert any("No entries" in r.message for r in caplog.records)
|
|
136
137
|
|
|
137
138
|
|
|
138
139
|
# --- add ---
|
|
@@ -146,11 +147,11 @@ class TestAddCommand:
|
|
|
146
147
|
mock_client.set_login.assert_called_once()
|
|
147
148
|
assert "added" in capsys.readouterr().out.lower()
|
|
148
149
|
|
|
149
|
-
def
|
|
150
|
-
mock_client.set_login.
|
|
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)
|
|
151
152
|
args = make_args(url="https://example.com", username="u", password="p", group_uuid="")
|
|
152
|
-
|
|
153
|
-
|
|
153
|
+
with pytest.raises(ProtocolError):
|
|
154
|
+
add.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
154
155
|
|
|
155
156
|
|
|
156
157
|
# --- edit ---
|
|
@@ -165,12 +166,12 @@ class TestEditCommand:
|
|
|
165
166
|
assert rc == 0
|
|
166
167
|
assert "updated" in capsys.readouterr().out.lower()
|
|
167
168
|
|
|
168
|
-
def test_entry_not_found(self, mock_client, cli_config, browser_config, browser_config_path,
|
|
169
|
+
def test_entry_not_found(self, mock_client, cli_config, browser_config, browser_config_path, caplog):
|
|
169
170
|
mock_client.get_logins.return_value = []
|
|
170
171
|
args = make_args(uuid="nonexistent-uuid", url="https://example.com", username=None, password=None, title=None)
|
|
171
172
|
rc = edit.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
172
173
|
assert rc == 1
|
|
173
|
-
assert "not found" in
|
|
174
|
+
assert any("not found" in r.message.lower() for r in caplog.records)
|
|
174
175
|
|
|
175
176
|
|
|
176
177
|
# --- rm ---
|
|
@@ -184,11 +185,11 @@ class TestRmCommand:
|
|
|
184
185
|
mock_client.delete_entry.assert_called_once_with("some-uuid")
|
|
185
186
|
assert "deleted" in capsys.readouterr().out.lower()
|
|
186
187
|
|
|
187
|
-
def
|
|
188
|
-
mock_client.delete_entry.
|
|
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)
|
|
189
190
|
args = make_args(uuid="some-uuid", yes=True)
|
|
190
|
-
|
|
191
|
-
|
|
191
|
+
with pytest.raises(ProtocolError):
|
|
192
|
+
rm.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
192
193
|
|
|
193
194
|
|
|
194
195
|
# --- totp ---
|
|
@@ -237,12 +238,12 @@ class TestClipCommand:
|
|
|
237
238
|
rc = clip.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
238
239
|
assert rc == 1
|
|
239
240
|
|
|
240
|
-
def test_pyperclip_missing(self, mock_client, cli_config, browser_config, browser_config_path,
|
|
241
|
+
def test_pyperclip_missing(self, mock_client, cli_config, browser_config, browser_config_path, caplog):
|
|
241
242
|
args = make_args(url="https://example.com", field="password")
|
|
242
243
|
with patch.dict("sys.modules", {"pyperclip": None}):
|
|
243
244
|
rc = clip.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
244
245
|
assert rc == 1
|
|
245
|
-
assert "pyperclip" in
|
|
246
|
+
assert any("pyperclip" in r.message for r in caplog.records)
|
|
246
247
|
|
|
247
248
|
|
|
248
249
|
# --- lock ---
|
|
@@ -282,8 +283,136 @@ class TestMkdirCommand:
|
|
|
282
283
|
assert rc == 0
|
|
283
284
|
mock_client.create_group.assert_called_once_with("Work/Projects")
|
|
284
285
|
|
|
285
|
-
def
|
|
286
|
-
mock_client.create_group.
|
|
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")
|
|
287
288
|
args = make_args(name="MyGroup")
|
|
288
|
-
|
|
289
|
+
with pytest.raises(ConnectionError):
|
|
290
|
+
mkdir.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# --- group-uuid ---
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class TestGroupUuidCommand:
|
|
297
|
+
def _make_tree(self, mock_group):
|
|
298
|
+
from keepassxc_browser_api import Group
|
|
299
|
+
projects = mock_group(uuid="projects-uuid", name="Projects")
|
|
300
|
+
work = mock_group(uuid="work-uuid", name="Work", children=[projects])
|
|
301
|
+
root = mock_group(uuid="root-uuid", name="Root", children=[work])
|
|
302
|
+
return [root]
|
|
303
|
+
|
|
304
|
+
def test_found_top_level(self, mock_client, cli_config, browser_config, browser_config_path, capsys, mock_group):
|
|
305
|
+
mock_client.get_database_groups.return_value = self._make_tree(mock_group)
|
|
306
|
+
args = make_args(path="Work")
|
|
307
|
+
rc = group_uuid.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
308
|
+
assert rc == 0
|
|
309
|
+
out = capsys.readouterr().out
|
|
310
|
+
assert "work-uuid" in out
|
|
311
|
+
assert "Work" in out
|
|
312
|
+
|
|
313
|
+
def test_found_nested_path(self, mock_client, cli_config, browser_config, browser_config_path, capsys, mock_group):
|
|
314
|
+
mock_client.get_database_groups.return_value = self._make_tree(mock_group)
|
|
315
|
+
args = make_args(path="Work/Projects")
|
|
316
|
+
rc = group_uuid.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
317
|
+
assert rc == 0
|
|
318
|
+
out = capsys.readouterr().out
|
|
319
|
+
assert "projects-uuid" in out
|
|
320
|
+
|
|
321
|
+
def test_not_found(self, mock_client, cli_config, browser_config, browser_config_path, caplog, mock_group):
|
|
322
|
+
mock_client.get_database_groups.return_value = self._make_tree(mock_group)
|
|
323
|
+
args = make_args(path="Nonexistent")
|
|
324
|
+
rc = group_uuid.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
325
|
+
assert rc == 1
|
|
326
|
+
assert any("not found" in r.message.lower() for r in caplog.records)
|
|
327
|
+
|
|
328
|
+
def test_not_found_mid_path(self, mock_client, cli_config, browser_config, browser_config_path, capsys, mock_group):
|
|
329
|
+
mock_client.get_database_groups.return_value = self._make_tree(mock_group)
|
|
330
|
+
args = make_args(path="Work/Nope/Projects")
|
|
331
|
+
rc = group_uuid.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
332
|
+
assert rc == 1
|
|
333
|
+
|
|
334
|
+
def test_json_output(self, mock_client, cli_config, browser_config, browser_config_path, capsys, mock_group):
|
|
335
|
+
import json
|
|
336
|
+
mock_client.get_database_groups.return_value = self._make_tree(mock_group)
|
|
337
|
+
args = make_args(path="Work/Projects")
|
|
338
|
+
rc = group_uuid.run(mock_client, args, cli_config, browser_config, browser_config_path, fmt="json")
|
|
339
|
+
assert rc == 0
|
|
340
|
+
data = json.loads(capsys.readouterr().out)
|
|
341
|
+
assert data["path"] == "Work/Projects"
|
|
342
|
+
assert data["name"] == "Projects"
|
|
343
|
+
assert data["uuid"] == "projects-uuid"
|
|
344
|
+
|
|
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")
|
|
347
|
+
args = make_args(path="Work")
|
|
348
|
+
with pytest.raises(ConnectionError):
|
|
349
|
+
group_uuid.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
350
|
+
|
|
351
|
+
# --- version ---
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class TestVersionCommand:
|
|
355
|
+
def test_prints_version(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
|
|
356
|
+
with patch("keepassxc_cli.commands.version.version", return_value="1.3.0"):
|
|
357
|
+
args = make_args()
|
|
358
|
+
rc = version.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
359
|
+
assert rc == 0
|
|
360
|
+
out = capsys.readouterr().out
|
|
361
|
+
assert "keepassxc-cli" in out
|
|
362
|
+
assert "1.3.0" in out
|
|
363
|
+
|
|
364
|
+
def test_package_not_found(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
|
|
365
|
+
from importlib.metadata import PackageNotFoundError
|
|
366
|
+
with patch("keepassxc_cli.commands.version.version", side_effect=PackageNotFoundError):
|
|
367
|
+
args = make_args()
|
|
368
|
+
rc = version.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
369
|
+
assert rc == 0
|
|
370
|
+
out = capsys.readouterr().out
|
|
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)
|
|
289
418
|
assert rc == 1
|
|
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
|