keepassxc-cli 1.3.0__tar.gz → 1.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/CLAUDE.md +12 -2
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/PKG-INFO +22 -2
- keepassxc_cli-1.3.0/keepassxc_cli.egg-info/PKG-INFO → keepassxc_cli-1.4.0/README.md +20 -15
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/__main__.py +22 -3
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/add.py +6 -8
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/clip.py +7 -5
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/edit.py +9 -12
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/group_uuid.py +4 -6
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/lock.py +4 -2
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/mkdir.py +3 -4
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/rm.py +6 -8
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/setup.py +7 -9
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/show.py +4 -2
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/status.py +9 -5
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/totp.py +5 -3
- keepassxc_cli-1.3.0/README.md → keepassxc_cli-1.4.0/keepassxc_cli.egg-info/PKG-INFO +35 -0
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli.egg-info/requires.txt +1 -1
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/pyproject.toml +1 -1
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/tests/test_commands.py +79 -34
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/.github/workflows/auto-merge-dependabot.yml +0 -0
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/.github/workflows/auto-release.yml +0 -0
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/.github/workflows/lint_and_test.yml +0 -0
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/.github/workflows/pypi.yml +0 -0
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/.gitignore +0 -0
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/LICENSE +0 -0
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/__init__.py +0 -0
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/__init__.py +0 -0
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/version.py +0 -0
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/config.py +0 -0
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli/output.py +0 -0
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli.egg-info/SOURCES.txt +0 -0
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli.egg-info/dependency_links.txt +0 -0
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli.egg-info/entry_points.txt +0 -0
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/keepassxc_cli.egg-info/top_level.txt +0 -0
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/setup.cfg +0 -0
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/tests/conftest.py +0 -0
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/tests/test_config.py +0 -0
- {keepassxc_cli-1.3.0 → keepassxc_cli-1.4.0}/tests/test_output.py +0 -0
|
@@ -95,8 +95,18 @@ ruff check --ignore=E501 --exclude=__init__.py ./keepassxc_cli
|
|
|
95
95
|
- **`from __future__ import annotations`** must be the first line of every `.py` source file.
|
|
96
96
|
- **Ruff**: `ruff check --ignore=E501 --exclude=__init__.py ./keepassxc_cli`
|
|
97
97
|
- **No async code**: Everything is synchronous. No threads in the CLI.
|
|
98
|
-
- **Output**: Use `print()` for normal output
|
|
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"
|
|
@@ -214,6 +214,26 @@ Shared with `keepassxc-browser-api`. Contains the association keys created durin
|
|
|
214
214
|
|
|
215
215
|
Both config files are stored with `0o600` permissions (owner read/write only).
|
|
216
216
|
|
|
217
|
+
## Exit codes
|
|
218
|
+
|
|
219
|
+
| Code | Meaning |
|
|
220
|
+
|------|---------|
|
|
221
|
+
| `0` | Success |
|
|
222
|
+
| `1` | Generic error (unexpected KeePassXC error, OS error, config parse error) |
|
|
223
|
+
| `2` | KeePassXC is not running or the socket is not found (`ConnectionError`) |
|
|
224
|
+
| `3` | Database unlock timed out (`DatabaseLockedError`) |
|
|
225
|
+
| `4` | Access denied by user — either the access prompt was cancelled or "Allow access to all entries" was denied |
|
|
226
|
+
|
|
227
|
+
These codes are stable and suitable for scripting, e.g.:
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
keepassxc-cli show https://example.com || case $? in
|
|
231
|
+
2) echo "Start KeePassXC first" ;;
|
|
232
|
+
3) echo "Unlock timed out" ;;
|
|
233
|
+
4) echo "Access denied" ;;
|
|
234
|
+
esac
|
|
235
|
+
```
|
|
236
|
+
|
|
217
237
|
## Development
|
|
218
238
|
|
|
219
239
|
```bash
|
|
@@ -1,18 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: keepassxc-cli
|
|
3
|
-
Version: 1.3.0
|
|
4
|
-
Summary: CLI for KeePassXC using the browser extension protocol with biometric unlock
|
|
5
|
-
License-Expression: MIT
|
|
6
|
-
Requires-Python: >=3.10
|
|
7
|
-
Description-Content-Type: text/markdown
|
|
8
|
-
License-File: LICENSE
|
|
9
|
-
Requires-Dist: keepassxc-browser-api==1.2.0
|
|
10
|
-
Requires-Dist: pyperclip==1.8.0
|
|
11
|
-
Provides-Extra: dev
|
|
12
|
-
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
13
|
-
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
14
|
-
Dynamic: license-file
|
|
15
|
-
|
|
16
1
|
# keepassxc-cli
|
|
17
2
|
|
|
18
3
|
A command-line interface for [KeePassXC](https://keepassxc.org/) that communicates via the browser extension protocol, supporting biometric (TouchID/fingerprint) unlock on supported platforms.
|
|
@@ -214,6 +199,26 @@ Shared with `keepassxc-browser-api`. Contains the association keys created durin
|
|
|
214
199
|
|
|
215
200
|
Both config files are stored with `0o600` permissions (owner read/write only).
|
|
216
201
|
|
|
202
|
+
## Exit codes
|
|
203
|
+
|
|
204
|
+
| Code | Meaning |
|
|
205
|
+
|------|---------|
|
|
206
|
+
| `0` | Success |
|
|
207
|
+
| `1` | Generic error (unexpected KeePassXC error, OS error, config parse error) |
|
|
208
|
+
| `2` | KeePassXC is not running or the socket is not found (`ConnectionError`) |
|
|
209
|
+
| `3` | Database unlock timed out (`DatabaseLockedError`) |
|
|
210
|
+
| `4` | Access denied by user — either the access prompt was cancelled or "Allow access to all entries" was denied |
|
|
211
|
+
|
|
212
|
+
These codes are stable and suitable for scripting, e.g.:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
keepassxc-cli show https://example.com || case $? in
|
|
216
|
+
2) echo "Start KeePassXC first" ;;
|
|
217
|
+
3) echo "Unlock timed out" ;;
|
|
218
|
+
4) echo "Access denied" ;;
|
|
219
|
+
esac
|
|
220
|
+
```
|
|
221
|
+
|
|
217
222
|
## Development
|
|
218
223
|
|
|
219
224
|
```bash
|
|
@@ -8,7 +8,7 @@ import sys
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
10
|
from keepassxc_browser_api import BrowserClient, BrowserConfig
|
|
11
|
-
from keepassxc_browser_api.exceptions import KeePassXCError,
|
|
11
|
+
from keepassxc_browser_api.exceptions import ConnectionError, DatabaseLockedError, KeePassXCError, ProtocolError
|
|
12
12
|
|
|
13
13
|
from .config import CliConfig, DEFAULT_CLI_CONFIG_PATH
|
|
14
14
|
from .commands import setup, status, show, add, edit, rm, totp, clip, lock, mkdir, group_uuid, version
|
|
@@ -61,7 +61,17 @@ def main() -> None:
|
|
|
61
61
|
args = parser.parse_args()
|
|
62
62
|
|
|
63
63
|
if args.verbose:
|
|
64
|
-
logging.basicConfig(
|
|
64
|
+
logging.basicConfig(
|
|
65
|
+
level=logging.DEBUG,
|
|
66
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
67
|
+
datefmt="%H:%M:%S",
|
|
68
|
+
)
|
|
69
|
+
else:
|
|
70
|
+
logging.basicConfig(
|
|
71
|
+
level=logging.WARNING,
|
|
72
|
+
format="%(message)s",
|
|
73
|
+
stream=sys.stderr,
|
|
74
|
+
)
|
|
65
75
|
|
|
66
76
|
cli_config_path = Path(args.config)
|
|
67
77
|
cli_config = CliConfig.load(cli_config_path)
|
|
@@ -74,7 +84,16 @@ def main() -> None:
|
|
|
74
84
|
client = BrowserClient(browser_config)
|
|
75
85
|
try:
|
|
76
86
|
rc = args.func(client, args, cli_config, browser_config, browser_api_config_path, fmt=fmt)
|
|
77
|
-
except
|
|
87
|
+
except ConnectionError as e:
|
|
88
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
89
|
+
rc = 2
|
|
90
|
+
except DatabaseLockedError as e:
|
|
91
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
92
|
+
rc = 3
|
|
93
|
+
except ProtocolError as e:
|
|
94
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
95
|
+
rc = 4 if e.error_code in (6, 19) else 1
|
|
96
|
+
except (KeePassXCError, OSError, json.JSONDecodeError) as e:
|
|
78
97
|
print(f"Error: {e}", file=sys.stderr)
|
|
79
98
|
rc = 1
|
|
80
99
|
sys.exit(rc)
|
|
@@ -2,13 +2,15 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
import getpass
|
|
5
|
-
import
|
|
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
|
|
@@ -2,13 +2,15 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
import json
|
|
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, fmt_parent: argparse.ArgumentParser | None = None) -> None:
|
|
14
16
|
parents = [fmt_parent] if fmt_parent else []
|
|
@@ -34,10 +36,6 @@ def run(
|
|
|
34
36
|
fmt: str = "table",
|
|
35
37
|
) -> int:
|
|
36
38
|
groups = client.get_database_groups()
|
|
37
|
-
if not groups:
|
|
38
|
-
print("Failed to retrieve group tree.", file=sys.stderr)
|
|
39
|
-
return 1
|
|
40
|
-
|
|
41
39
|
root = groups[0]
|
|
42
40
|
parts = args.path.split("/")
|
|
43
41
|
|
|
@@ -47,7 +45,7 @@ def run(
|
|
|
47
45
|
for part in parts:
|
|
48
46
|
matched = next((g for g in current if g.name == part), None)
|
|
49
47
|
if matched is None:
|
|
50
|
-
|
|
48
|
+
logger.error("Group not found: %r", args.path)
|
|
51
49
|
return 1
|
|
52
50
|
current = matched.children
|
|
53
51
|
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
-
import
|
|
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
|
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keepassxc-cli
|
|
3
|
+
Version: 1.4.0
|
|
4
|
+
Summary: CLI for KeePassXC using the browser extension protocol with biometric unlock
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: keepassxc-browser-api==1.3.0
|
|
10
|
+
Requires-Dist: pyperclip==1.8.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
13
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
1
16
|
# keepassxc-cli
|
|
2
17
|
|
|
3
18
|
A command-line interface for [KeePassXC](https://keepassxc.org/) that communicates via the browser extension protocol, supporting biometric (TouchID/fingerprint) unlock on supported platforms.
|
|
@@ -199,6 +214,26 @@ Shared with `keepassxc-browser-api`. Contains the association keys created durin
|
|
|
199
214
|
|
|
200
215
|
Both config files are stored with `0o600` permissions (owner read/write only).
|
|
201
216
|
|
|
217
|
+
## Exit codes
|
|
218
|
+
|
|
219
|
+
| Code | Meaning |
|
|
220
|
+
|------|---------|
|
|
221
|
+
| `0` | Success |
|
|
222
|
+
| `1` | Generic error (unexpected KeePassXC error, OS error, config parse error) |
|
|
223
|
+
| `2` | KeePassXC is not running or the socket is not found (`ConnectionError`) |
|
|
224
|
+
| `3` | Database unlock timed out (`DatabaseLockedError`) |
|
|
225
|
+
| `4` | Access denied by user — either the access prompt was cancelled or "Allow access to all entries" was denied |
|
|
226
|
+
|
|
227
|
+
These codes are stable and suitable for scripting, e.g.:
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
keepassxc-cli show https://example.com || case $? in
|
|
231
|
+
2) echo "Start KeePassXC first" ;;
|
|
232
|
+
3) echo "Unlock timed out" ;;
|
|
233
|
+
4) echo "Access denied" ;;
|
|
234
|
+
esac
|
|
235
|
+
```
|
|
236
|
+
|
|
202
237
|
## Development
|
|
203
238
|
|
|
204
239
|
```bash
|
|
@@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch
|
|
|
8
8
|
import pytest
|
|
9
9
|
|
|
10
10
|
from keepassxc_browser_api import Entry, BrowserConfig, Association
|
|
11
|
+
from keepassxc_browser_api.exceptions import ConnectionError, DatabaseLockedError, ProtocolError
|
|
11
12
|
from keepassxc_cli.config import CliConfig
|
|
12
13
|
from keepassxc_cli.commands import (
|
|
13
14
|
setup, status, show, add, edit, rm, totp, clip, lock, mkdir, group_uuid, version,
|
|
@@ -50,19 +51,18 @@ def make_args(**kwargs) -> argparse.Namespace:
|
|
|
50
51
|
# --- setup ---
|
|
51
52
|
|
|
52
53
|
class TestSetupCommand:
|
|
53
|
-
def test_success(self, mock_client, cli_config, browser_config, browser_config_path):
|
|
54
|
-
mock_client.setup.return_value =
|
|
54
|
+
def test_success(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
|
|
55
|
+
mock_client.setup.return_value = None
|
|
55
56
|
args = make_args()
|
|
56
57
|
rc = setup.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
57
58
|
assert rc == 0
|
|
58
59
|
mock_client.setup.assert_called_once()
|
|
59
60
|
|
|
60
|
-
def
|
|
61
|
-
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")
|
|
62
63
|
args = make_args()
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
assert "Failed" in capsys.readouterr().err
|
|
64
|
+
with pytest.raises(ConnectionError):
|
|
65
|
+
setup.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
66
66
|
|
|
67
67
|
|
|
68
68
|
# --- status ---
|
|
@@ -78,7 +78,7 @@ class TestStatusCommand:
|
|
|
78
78
|
assert "yes" in out
|
|
79
79
|
|
|
80
80
|
def test_not_connected(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
|
|
81
|
-
mock_client.connect.
|
|
81
|
+
mock_client.connect.side_effect = ConnectionError("not available")
|
|
82
82
|
args = make_args()
|
|
83
83
|
rc = status.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
84
84
|
assert rc == 1
|
|
@@ -128,12 +128,12 @@ class TestShowCommand:
|
|
|
128
128
|
assert rc == 0
|
|
129
129
|
assert "KPH: url: https://github.com" in capsys.readouterr().out
|
|
130
130
|
|
|
131
|
-
def test_no_entries(self, mock_client, cli_config, browser_config, browser_config_path,
|
|
131
|
+
def test_no_entries(self, mock_client, cli_config, browser_config, browser_config_path, caplog):
|
|
132
132
|
mock_client.get_logins.return_value = []
|
|
133
133
|
args = make_args(url="https://notfound.com")
|
|
134
134
|
rc = show.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
135
135
|
assert rc == 1
|
|
136
|
-
assert "No entries" in
|
|
136
|
+
assert any("No entries" in r.message for r in caplog.records)
|
|
137
137
|
|
|
138
138
|
|
|
139
139
|
# --- add ---
|
|
@@ -147,11 +147,11 @@ class TestAddCommand:
|
|
|
147
147
|
mock_client.set_login.assert_called_once()
|
|
148
148
|
assert "added" in capsys.readouterr().out.lower()
|
|
149
149
|
|
|
150
|
-
def
|
|
151
|
-
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)
|
|
152
152
|
args = make_args(url="https://example.com", username="u", password="p", group_uuid="")
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
with pytest.raises(ProtocolError):
|
|
154
|
+
add.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
155
155
|
|
|
156
156
|
|
|
157
157
|
# --- edit ---
|
|
@@ -166,12 +166,12 @@ class TestEditCommand:
|
|
|
166
166
|
assert rc == 0
|
|
167
167
|
assert "updated" in capsys.readouterr().out.lower()
|
|
168
168
|
|
|
169
|
-
def test_entry_not_found(self, mock_client, cli_config, browser_config, browser_config_path,
|
|
169
|
+
def test_entry_not_found(self, mock_client, cli_config, browser_config, browser_config_path, caplog):
|
|
170
170
|
mock_client.get_logins.return_value = []
|
|
171
171
|
args = make_args(uuid="nonexistent-uuid", url="https://example.com", username=None, password=None, title=None)
|
|
172
172
|
rc = edit.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
173
173
|
assert rc == 1
|
|
174
|
-
assert "not found" in
|
|
174
|
+
assert any("not found" in r.message.lower() for r in caplog.records)
|
|
175
175
|
|
|
176
176
|
|
|
177
177
|
# --- rm ---
|
|
@@ -185,11 +185,11 @@ class TestRmCommand:
|
|
|
185
185
|
mock_client.delete_entry.assert_called_once_with("some-uuid")
|
|
186
186
|
assert "deleted" in capsys.readouterr().out.lower()
|
|
187
187
|
|
|
188
|
-
def
|
|
189
|
-
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)
|
|
190
190
|
args = make_args(uuid="some-uuid", yes=True)
|
|
191
|
-
|
|
192
|
-
|
|
191
|
+
with pytest.raises(ProtocolError):
|
|
192
|
+
rm.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
193
193
|
|
|
194
194
|
|
|
195
195
|
# --- totp ---
|
|
@@ -238,12 +238,12 @@ class TestClipCommand:
|
|
|
238
238
|
rc = clip.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
239
239
|
assert rc == 1
|
|
240
240
|
|
|
241
|
-
def test_pyperclip_missing(self, mock_client, cli_config, browser_config, browser_config_path,
|
|
241
|
+
def test_pyperclip_missing(self, mock_client, cli_config, browser_config, browser_config_path, caplog):
|
|
242
242
|
args = make_args(url="https://example.com", field="password")
|
|
243
243
|
with patch.dict("sys.modules", {"pyperclip": None}):
|
|
244
244
|
rc = clip.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
245
245
|
assert rc == 1
|
|
246
|
-
assert "pyperclip" in
|
|
246
|
+
assert any("pyperclip" in r.message for r in caplog.records)
|
|
247
247
|
|
|
248
248
|
|
|
249
249
|
# --- lock ---
|
|
@@ -283,11 +283,11 @@ class TestMkdirCommand:
|
|
|
283
283
|
assert rc == 0
|
|
284
284
|
mock_client.create_group.assert_called_once_with("Work/Projects")
|
|
285
285
|
|
|
286
|
-
def
|
|
287
|
-
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")
|
|
288
288
|
args = make_args(name="MyGroup")
|
|
289
|
-
|
|
290
|
-
|
|
289
|
+
with pytest.raises(ConnectionError):
|
|
290
|
+
mkdir.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
291
291
|
|
|
292
292
|
|
|
293
293
|
# --- group-uuid ---
|
|
@@ -318,13 +318,12 @@ class TestGroupUuidCommand:
|
|
|
318
318
|
out = capsys.readouterr().out
|
|
319
319
|
assert "projects-uuid" in out
|
|
320
320
|
|
|
321
|
-
def test_not_found(self, mock_client, cli_config, browser_config, browser_config_path,
|
|
321
|
+
def test_not_found(self, mock_client, cli_config, browser_config, browser_config_path, caplog, mock_group):
|
|
322
322
|
mock_client.get_database_groups.return_value = self._make_tree(mock_group)
|
|
323
323
|
args = make_args(path="Nonexistent")
|
|
324
324
|
rc = group_uuid.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
325
325
|
assert rc == 1
|
|
326
|
-
|
|
327
|
-
assert "not found" in err.lower()
|
|
326
|
+
assert any("not found" in r.message.lower() for r in caplog.records)
|
|
328
327
|
|
|
329
328
|
def test_not_found_mid_path(self, mock_client, cli_config, browser_config, browser_config_path, capsys, mock_group):
|
|
330
329
|
mock_client.get_database_groups.return_value = self._make_tree(mock_group)
|
|
@@ -343,12 +342,11 @@ class TestGroupUuidCommand:
|
|
|
343
342
|
assert data["name"] == "Projects"
|
|
344
343
|
assert data["uuid"] == "projects-uuid"
|
|
345
344
|
|
|
346
|
-
def
|
|
347
|
-
mock_client.get_database_groups.
|
|
345
|
+
def test_get_database_groups_failure_propagates(self, mock_client, cli_config, browser_config, browser_config_path, mock_group):
|
|
346
|
+
mock_client.get_database_groups.side_effect = ConnectionError("KeePassXC not running")
|
|
348
347
|
args = make_args(path="Work")
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
348
|
+
with pytest.raises(ConnectionError):
|
|
349
|
+
group_uuid.run(mock_client, args, cli_config, browser_config, browser_config_path)
|
|
352
350
|
|
|
353
351
|
# --- version ---
|
|
354
352
|
|
|
@@ -371,3 +369,50 @@ class TestVersionCommand:
|
|
|
371
369
|
assert rc == 0
|
|
372
370
|
out = capsys.readouterr().out
|
|
373
371
|
assert "unknown" in out
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# --- exit codes ---
|
|
375
|
+
|
|
376
|
+
class TestExitCodes:
|
|
377
|
+
"""Test that __main__.main() maps exceptions to the correct exit codes."""
|
|
378
|
+
|
|
379
|
+
def _run_main(self, exception, monkeypatch, capsys, tmp_path):
|
|
380
|
+
from keepassxc_cli.__main__ import main
|
|
381
|
+
|
|
382
|
+
config_path = tmp_path / "cli.json"
|
|
383
|
+
monkeypatch.setattr("sys.argv", [
|
|
384
|
+
"keepassxc-cli", "--config", str(config_path),
|
|
385
|
+
"show", "https://example.com",
|
|
386
|
+
])
|
|
387
|
+
|
|
388
|
+
mock_client_instance = MagicMock()
|
|
389
|
+
mock_client_instance.get_logins.side_effect = exception
|
|
390
|
+
|
|
391
|
+
with patch("keepassxc_cli.__main__.BrowserClient", return_value=mock_client_instance):
|
|
392
|
+
with patch("keepassxc_cli.__main__.BrowserConfig"):
|
|
393
|
+
with patch("keepassxc_cli.__main__.CliConfig"):
|
|
394
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
395
|
+
main()
|
|
396
|
+
|
|
397
|
+
return exc_info.value.code, capsys.readouterr()
|
|
398
|
+
|
|
399
|
+
def test_connection_error_rc2(self, monkeypatch, capsys, tmp_path):
|
|
400
|
+
rc, out = self._run_main(ConnectionError("not running"), monkeypatch, capsys, tmp_path)
|
|
401
|
+
assert rc == 2
|
|
402
|
+
assert "Error:" in out.err
|
|
403
|
+
|
|
404
|
+
def test_database_locked_rc3(self, monkeypatch, capsys, tmp_path):
|
|
405
|
+
rc, out = self._run_main(DatabaseLockedError("locked"), monkeypatch, capsys, tmp_path)
|
|
406
|
+
assert rc == 3
|
|
407
|
+
|
|
408
|
+
def test_access_denied_rc4(self, monkeypatch, capsys, tmp_path):
|
|
409
|
+
rc, out = self._run_main(ProtocolError("denied", error_code=6), monkeypatch, capsys, tmp_path)
|
|
410
|
+
assert rc == 4
|
|
411
|
+
|
|
412
|
+
def test_access_denied_code19_rc4(self, monkeypatch, capsys, tmp_path):
|
|
413
|
+
rc, out = self._run_main(ProtocolError("denied", error_code=19), monkeypatch, capsys, tmp_path)
|
|
414
|
+
assert rc == 4
|
|
415
|
+
|
|
416
|
+
def test_other_protocol_error_rc1(self, monkeypatch, capsys, tmp_path):
|
|
417
|
+
rc, out = self._run_main(ProtocolError("other error", error_code=7), monkeypatch, capsys, tmp_path)
|
|
418
|
+
assert rc == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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
|