keepassxc-cli 1.6.0__tar.gz → 2.0.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.

Potentially problematic release.


This version of keepassxc-cli might be problematic. Click here for more details.

Files changed (41) hide show
  1. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/CLAUDE.md +9 -6
  2. {keepassxc_cli-1.6.0/keepassxc_cli.egg-info → keepassxc_cli-2.0.0}/PKG-INFO +20 -16
  3. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/README.md +19 -15
  4. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/keepassxc_cli/__main__.py +6 -6
  5. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/keepassxc_cli/commands/add.py +9 -6
  6. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/keepassxc_cli/commands/clip.py +6 -4
  7. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/keepassxc_cli/commands/edit.py +11 -7
  8. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/keepassxc_cli/commands/lock.py +5 -3
  9. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/keepassxc_cli/commands/mkdir.py +9 -3
  10. keepassxc_cli-2.0.0/keepassxc_cli/commands/rm.py +63 -0
  11. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/keepassxc_cli/commands/show.py +4 -3
  12. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/keepassxc_cli/commands/totp.py +2 -2
  13. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/keepassxc_cli/output.py +18 -0
  14. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0/keepassxc_cli.egg-info}/PKG-INFO +20 -16
  15. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/tests/test_commands.py +42 -10
  16. keepassxc_cli-1.6.0/keepassxc_cli/commands/rm.py +0 -59
  17. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/.github/workflows/auto-merge-dependabot.yml +0 -0
  18. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/.github/workflows/auto-release.yml +0 -0
  19. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/.github/workflows/lint_and_test.yml +0 -0
  20. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/.github/workflows/pypi.yml +0 -0
  21. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/.gitignore +0 -0
  22. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/LICENSE +0 -0
  23. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/assets/demo.gif +0 -0
  24. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/assets/settings-browser-integration.png +0 -0
  25. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/keepassxc_cli/__init__.py +0 -0
  26. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/keepassxc_cli/commands/__init__.py +0 -0
  27. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/keepassxc_cli/commands/group_uuid.py +0 -0
  28. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/keepassxc_cli/commands/setup.py +0 -0
  29. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/keepassxc_cli/commands/status.py +0 -0
  30. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/keepassxc_cli/commands/version.py +0 -0
  31. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/keepassxc_cli/config.py +0 -0
  32. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/keepassxc_cli.egg-info/SOURCES.txt +0 -0
  33. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/keepassxc_cli.egg-info/dependency_links.txt +0 -0
  34. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/keepassxc_cli.egg-info/entry_points.txt +0 -0
  35. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/keepassxc_cli.egg-info/requires.txt +0 -0
  36. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/keepassxc_cli.egg-info/top_level.txt +0 -0
  37. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/pyproject.toml +0 -0
  38. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/setup.cfg +0 -0
  39. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/tests/conftest.py +0 -0
  40. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/tests/test_config.py +0 -0
  41. {keepassxc_cli-1.6.0 → keepassxc_cli-2.0.0}/tests/test_output.py +0 -0
@@ -13,19 +13,21 @@ keepassxc_cli/
13
13
  ├── __init__.py # empty (just from __future__ import annotations)
14
14
  ├── __main__.py # CLI entry point; argument parsing; dispatches to commands
15
15
  ├── config.py # CliConfig dataclass; save/load ~/.keepassxc/cli.json
16
- ├── output.py # Output formatting: table, json
16
+ ├── output.py # Output formatting: table, json; ensure_scheme() URL helper
17
17
  └── commands/
18
18
  ├── __init__.py # empty
19
19
  ├── setup.py # associate with KeePassXC
20
20
  ├── status.py # show connection/association status
21
21
  ├── show.py # show entries by URL
22
- ├── add.py # add new entry
23
- ├── edit.py # edit existing entry by UUID
24
- ├── rm.py # delete entry by UUID
22
+ ├── add.py # add new entry: positional url/username, --group/--group-uuid optional
23
+ ├── edit.py # edit existing entry: positional url, --uuid/--username/--password optional
24
+ ├── rm.py # delete entry: positional url, --uuid optional for disambiguation
25
25
  ├── totp.py # get TOTP code
26
- ├── clip.py # copy field to clipboard
26
+ ├── clip.py # copy field to clipboard: positional url then field
27
27
  ├── lock.py # lock database
28
- └── mkdir.py # create group
28
+ ├── mkdir.py # create group
29
+ ├── group_uuid.py # look up a group UUID by path
30
+ └── version.py # print installed package version
29
31
 
30
32
  tests/
31
33
  ├── conftest.py # fixtures: mock_entry, mock_group, mock_browser_config, mock_client
@@ -109,6 +111,7 @@ ruff check --ignore=E501 --exclude=__init__.py ./keepassxc_cli
109
111
  | `4` | `ProtocolError(error_code=6 or 19)` — access denied by user |
110
112
 
111
113
  - **Config permissions**: Config files are written with `0o600` (owner read/write only).
114
+ - **URL normalisation**: All URL-accepting command `run()` functions call `ensure_scheme(url)` (from `output.py`) before passing the URL to any `BrowserClient` method. This auto-prepends `https://` for bare hostnames (e.g. `example.com`) and emits a `logger.warning`. KeePassXC derives the entry title from `QUrl(url).host()`, which returns `""` for URLs without a scheme.
112
115
  - **Venv**: Always use `.venv` for development.
113
116
  - **Python ≥ 3.10** required.
114
117
  - **Password visibility**: `show` omits password and TOTP entirely when `-p` is not passed (no masking).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keepassxc-cli
3
- Version: 1.6.0
3
+ Version: 2.0.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
@@ -86,6 +86,8 @@ keepassxc-cli status -j
86
86
 
87
87
  ### Commands
88
88
 
89
+ > **URL scheme**: All commands that accept a URL argument require a scheme (`https://` or `http://`). If you pass a bare hostname (e.g. `example.com`), the CLI will automatically prepend `https://` and emit a warning. KeePassXC derives the entry title from `QUrl(url).host()`, which returns an empty string for URLs without a scheme.
90
+
89
91
  #### `setup` — Associate with KeePassXC
90
92
 
91
93
  ```bash
@@ -119,19 +121,20 @@ keepassxc-cli totp https://github.com -j
119
121
  #### `clip` — Copy a field to clipboard
120
122
 
121
123
  ```bash
122
- keepassxc-cli clip password https://github.com
123
- keepassxc-cli clip username https://github.com
124
- keepassxc-cli clip totp https://github.com
124
+ keepassxc-cli clip https://github.com password
125
+ keepassxc-cli clip https://github.com username
126
+ keepassxc-cli clip https://github.com totp
125
127
  ```
126
128
 
127
129
  #### `add` — Add a new entry
128
130
 
129
131
  ```bash
130
132
  # Password is prompted securely if --password is not given
131
- keepassxc-cli add --url https://example.com --username user@example.com
132
- keepassxc-cli add --url https://example.com --username user --password mypass
133
- # Place the entry in a specific group by UUID
134
- keepassxc-cli add --url https://example.com --username user --group-uuid <group-uuid>
133
+ keepassxc-cli add https://example.com user@example.com
134
+ keepassxc-cli add https://example.com user --password mypass
135
+ # Place the entry in a specific group by UUID or by path
136
+ keepassxc-cli add https://example.com user --group-uuid <group-uuid>
137
+ keepassxc-cli add https://example.com user --group "Work/Projects"
135
138
  ```
136
139
 
137
140
  > **Note**: The entry title is always derived from the URL hostname by KeePassXC. The protocol has no field to set a custom title.
@@ -139,19 +142,20 @@ keepassxc-cli add --url https://example.com --username user --group-uuid <group-
139
142
  #### `edit` — Edit an entry
140
143
 
141
144
  ```bash
142
- # Get the UUID first
143
- keepassxc-cli show https://github.com -p
144
-
145
- # Then edit — --url is required to resolve the current entry
146
- keepassxc-cli edit <uuid> --url https://github.com --username newuser
147
- keepassxc-cli edit <uuid> --url https://github.com --password newpass --title "New Title"
145
+ # URL is positional; --uuid is optional when the URL matches exactly one entry
146
+ keepassxc-cli edit https://github.com --username newuser
147
+ keepassxc-cli edit https://github.com --password newpass
148
+ # Specify --uuid explicitly when the URL matches multiple entries
149
+ keepassxc-cli edit https://github.com --uuid <uuid> --username newuser
148
150
  ```
149
151
 
150
152
  #### `rm` — Delete an entry
151
153
 
152
154
  ```bash
153
- keepassxc-cli rm <uuid> # prompts for confirmation
154
- keepassxc-cli rm <uuid> --yes # skip confirmation
155
+ keepassxc-cli rm https://example.com # prompts for confirmation
156
+ keepassxc-cli rm https://example.com --yes # skip confirmation
157
+ # Specify --uuid when URL matches multiple entries
158
+ keepassxc-cli rm https://example.com --uuid <uuid> --yes
155
159
  ```
156
160
 
157
161
  #### `lock` — Lock the database
@@ -71,6 +71,8 @@ keepassxc-cli status -j
71
71
 
72
72
  ### Commands
73
73
 
74
+ > **URL scheme**: All commands that accept a URL argument require a scheme (`https://` or `http://`). If you pass a bare hostname (e.g. `example.com`), the CLI will automatically prepend `https://` and emit a warning. KeePassXC derives the entry title from `QUrl(url).host()`, which returns an empty string for URLs without a scheme.
75
+
74
76
  #### `setup` — Associate with KeePassXC
75
77
 
76
78
  ```bash
@@ -104,19 +106,20 @@ keepassxc-cli totp https://github.com -j
104
106
  #### `clip` — Copy a field to clipboard
105
107
 
106
108
  ```bash
107
- keepassxc-cli clip password https://github.com
108
- keepassxc-cli clip username https://github.com
109
- keepassxc-cli clip totp https://github.com
109
+ keepassxc-cli clip https://github.com password
110
+ keepassxc-cli clip https://github.com username
111
+ keepassxc-cli clip https://github.com totp
110
112
  ```
111
113
 
112
114
  #### `add` — Add a new entry
113
115
 
114
116
  ```bash
115
117
  # Password is prompted securely if --password is not given
116
- keepassxc-cli add --url https://example.com --username user@example.com
117
- keepassxc-cli add --url https://example.com --username user --password mypass
118
- # Place the entry in a specific group by UUID
119
- keepassxc-cli add --url https://example.com --username user --group-uuid <group-uuid>
118
+ keepassxc-cli add https://example.com user@example.com
119
+ keepassxc-cli add https://example.com user --password mypass
120
+ # Place the entry in a specific group by UUID or by path
121
+ keepassxc-cli add https://example.com user --group-uuid <group-uuid>
122
+ keepassxc-cli add https://example.com user --group "Work/Projects"
120
123
  ```
121
124
 
122
125
  > **Note**: The entry title is always derived from the URL hostname by KeePassXC. The protocol has no field to set a custom title.
@@ -124,19 +127,20 @@ keepassxc-cli add --url https://example.com --username user --group-uuid <group-
124
127
  #### `edit` — Edit an entry
125
128
 
126
129
  ```bash
127
- # Get the UUID first
128
- keepassxc-cli show https://github.com -p
129
-
130
- # Then edit — --url is required to resolve the current entry
131
- keepassxc-cli edit <uuid> --url https://github.com --username newuser
132
- keepassxc-cli edit <uuid> --url https://github.com --password newpass --title "New Title"
130
+ # URL is positional; --uuid is optional when the URL matches exactly one entry
131
+ keepassxc-cli edit https://github.com --username newuser
132
+ keepassxc-cli edit https://github.com --password newpass
133
+ # Specify --uuid explicitly when the URL matches multiple entries
134
+ keepassxc-cli edit https://github.com --uuid <uuid> --username newuser
133
135
  ```
134
136
 
135
137
  #### `rm` — Delete an entry
136
138
 
137
139
  ```bash
138
- keepassxc-cli rm <uuid> # prompts for confirmation
139
- keepassxc-cli rm <uuid> --yes # skip confirmation
140
+ keepassxc-cli rm https://example.com # prompts for confirmation
141
+ keepassxc-cli rm https://example.com --yes # skip confirmation
142
+ # Specify --uuid when URL matches multiple entries
143
+ keepassxc-cli rm https://example.com --uuid <uuid> --yes
140
144
  ```
141
145
 
142
146
  #### `lock` — Lock the database
@@ -48,13 +48,13 @@ def main() -> None:
48
48
  setup.add_parser(subparsers)
49
49
  status.add_parser(subparsers, fmt_parent)
50
50
  show.add_parser(subparsers, fmt_parent)
51
- add.add_parser(subparsers)
52
- edit.add_parser(subparsers)
53
- rm.add_parser(subparsers)
51
+ add.add_parser(subparsers, fmt_parent)
52
+ edit.add_parser(subparsers, fmt_parent)
53
+ rm.add_parser(subparsers, fmt_parent)
54
54
  totp.add_parser(subparsers, fmt_parent)
55
- clip.add_parser(subparsers)
56
- lock.add_parser(subparsers)
57
- mkdir.add_parser(subparsers)
55
+ clip.add_parser(subparsers, fmt_parent)
56
+ lock.add_parser(subparsers, fmt_parent)
57
+ mkdir.add_parser(subparsers, fmt_parent)
58
58
  group_uuid.add_parser(subparsers, fmt_parent)
59
59
  version.add_parser(subparsers)
60
60
 
@@ -8,14 +8,16 @@ from pathlib import Path
8
8
  from keepassxc_browser_api import BrowserClient, BrowserConfig
9
9
 
10
10
  from keepassxc_cli.config import CliConfig
11
+ from keepassxc_cli.output import ensure_scheme, print_result
11
12
 
12
13
  logger = logging.getLogger(__name__)
13
14
 
14
15
 
15
- def add_parser(subparsers: argparse._SubParsersAction) -> None:
16
- p = subparsers.add_parser("add", help="Add a new entry")
17
- p.add_argument("--url", required=True, help="Entry URL")
18
- p.add_argument("--username", required=True, help="Username")
16
+ def add_parser(subparsers: argparse._SubParsersAction, fmt_parent: argparse.ArgumentParser | None = None) -> None:
17
+ parents = [fmt_parent] if fmt_parent else []
18
+ p = subparsers.add_parser("add", parents=parents, help="Add a new entry")
19
+ p.add_argument("url", help="Entry URL")
20
+ p.add_argument("username", help="Username")
19
21
  p.add_argument("--password", default=None, help="Password (prompted if omitted)")
20
22
  group = p.add_mutually_exclusive_group()
21
23
  group.add_argument("--group-uuid", default="", help="Target group UUID")
@@ -32,6 +34,7 @@ def run(
32
34
  *,
33
35
  fmt: str = "table",
34
36
  ) -> int:
37
+ url = ensure_scheme(args.url)
35
38
  password = args.password
36
39
  if password is None:
37
40
  password = getpass.getpass("Password: ")
@@ -53,10 +56,10 @@ def run(
53
56
  group_uuid = matched.uuid
54
57
 
55
58
  client.set_login(
56
- url=args.url,
59
+ url=url,
57
60
  username=args.username,
58
61
  password=password,
59
62
  group_uuid=group_uuid,
60
63
  )
61
- print("Entry added successfully.")
64
+ print_result("Entry added.", fmt)
62
65
  return 0
@@ -7,18 +7,20 @@ from pathlib import Path
7
7
  from keepassxc_browser_api import BrowserClient, BrowserConfig
8
8
 
9
9
  from keepassxc_cli.config import CliConfig
10
+ from keepassxc_cli.output import ensure_scheme
10
11
 
11
12
  logger = logging.getLogger(__name__)
12
13
 
13
14
 
14
- def add_parser(subparsers: argparse._SubParsersAction) -> None:
15
- p = subparsers.add_parser("clip", help="Copy a field to clipboard")
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("clip", parents=parents, help="Copy a field to clipboard")
18
+ p.add_argument("url", help="URL to look up")
16
19
  p.add_argument(
17
20
  "field",
18
21
  choices=["password", "username", "totp"],
19
22
  help="Field to copy: password, username, or totp",
20
23
  )
21
- p.add_argument("url", help="URL to look up")
22
24
  p.set_defaults(func=run)
23
25
 
24
26
 
@@ -37,7 +39,7 @@ def run(
37
39
  logger.error("pyperclip is required for clipboard support. Install it with: pip install pyperclip")
38
40
  return 1
39
41
 
40
- entries = client.get_logins(args.url)
42
+ entries = client.get_logins(ensure_scheme(args.url))
41
43
  if not entries:
42
44
  logger.warning("No entries found for: %s", args.url)
43
45
  return 1
@@ -7,20 +7,23 @@ from pathlib import Path
7
7
  from keepassxc_browser_api import BrowserClient, BrowserConfig
8
8
 
9
9
  from keepassxc_cli.config import CliConfig
10
+ from keepassxc_cli.output import ensure_scheme, print_result
10
11
 
11
12
  logger = logging.getLogger(__name__)
12
13
 
13
14
 
14
- def add_parser(subparsers: argparse._SubParsersAction) -> None:
15
+ def add_parser(subparsers: argparse._SubParsersAction, fmt_parent: argparse.ArgumentParser | None = None) -> None:
16
+ parents = [fmt_parent] if fmt_parent else []
15
17
  p = subparsers.add_parser(
16
18
  "edit",
19
+ parents=parents,
17
20
  help="Edit an existing entry",
18
21
  description=(
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."
22
+ "Edit an existing entry by URL. Omitted fields are left unchanged.\n"
23
+ "If the URL matches multiple entries, specify --uuid to disambiguate."
21
24
  ),
22
25
  )
23
- p.add_argument("--url", required=True, help="URL of the entry")
26
+ p.add_argument("url", help="URL of the entry")
24
27
  p.add_argument("--uuid", default=None, help="UUID of the entry (required when URL matches multiple entries)")
25
28
  p.add_argument("--username", default=None, help="New username")
26
29
  p.add_argument("--password", default=None, help="New password")
@@ -36,7 +39,8 @@ def run(
36
39
  *,
37
40
  fmt: str = "table",
38
41
  ) -> int:
39
- entries = client.get_logins(args.url)
42
+ url = ensure_scheme(args.url)
43
+ entries = client.get_logins(url)
40
44
 
41
45
  if not entries:
42
46
  logger.error("No entries found for: %s", args.url)
@@ -62,11 +66,11 @@ def run(
62
66
  return 1
63
67
 
64
68
  client.set_login(
65
- url=args.url,
69
+ url=url,
66
70
  username=args.username if args.username is not None else entry.login,
67
71
  password=args.password if args.password is not None else entry.password,
68
72
  uuid=entry.uuid,
69
73
  group_uuid=entry.group_uuid,
70
74
  )
71
- print("Entry updated successfully.")
75
+ print_result("Entry updated.", fmt)
72
76
  return 0
@@ -7,12 +7,14 @@ from pathlib import Path
7
7
  from keepassxc_browser_api import BrowserClient, BrowserConfig
8
8
 
9
9
  from keepassxc_cli.config import CliConfig
10
+ from keepassxc_cli.output import print_result
10
11
 
11
12
  logger = logging.getLogger(__name__)
12
13
 
13
14
 
14
- def add_parser(subparsers: argparse._SubParsersAction) -> None:
15
- p = subparsers.add_parser("lock", help="Lock the KeePassXC database")
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("lock", parents=parents, help="Lock the KeePassXC database")
16
18
  p.set_defaults(func=run)
17
19
 
18
20
 
@@ -27,7 +29,7 @@ def run(
27
29
  ) -> int:
28
30
  success = client.lock_database()
29
31
  if success:
30
- print("Database locked.")
32
+ print_result("Database locked.", fmt)
31
33
  return 0
32
34
  else:
33
35
  logger.error("Failed to lock database.")
@@ -1,18 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import json
4
5
  import logging
5
6
  from pathlib import Path
6
7
 
7
8
  from keepassxc_browser_api import BrowserClient, BrowserConfig
8
9
 
9
10
  from keepassxc_cli.config import CliConfig
11
+ from keepassxc_cli.output import print_result
10
12
 
11
13
  logger = logging.getLogger(__name__)
12
14
 
13
15
 
14
- def add_parser(subparsers: argparse._SubParsersAction) -> None:
15
- p = subparsers.add_parser("mkdir", help="Create a new group")
16
+ def add_parser(subparsers: argparse._SubParsersAction, fmt_parent: argparse.ArgumentParser | None = None) -> None:
17
+ parents = [fmt_parent] if fmt_parent else []
18
+ p = subparsers.add_parser("mkdir", parents=parents, help="Create a new group")
16
19
  p.add_argument(
17
20
  "name",
18
21
  help="Group name or path. Use '/' to create nested groups (e.g. 'Work/Projects').",
@@ -30,5 +33,8 @@ def run(
30
33
  fmt: str = "table",
31
34
  ) -> int:
32
35
  group = client.create_group(args.name)
33
- print(f"Group created: {group.name} [{group.uuid}]")
36
+ if fmt == "json":
37
+ print(json.dumps({"name": group.name, "uuid": group.uuid}, indent=2))
38
+ else:
39
+ print_result(f"Group created: {group.name} [{group.uuid}]", fmt)
34
40
  return 0
@@ -0,0 +1,63 @@
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
+ from keepassxc_cli.output import ensure_scheme, print_result
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("rm", parents=parents, help="Delete an entry")
18
+ p.add_argument("url", help="URL of the entry to delete")
19
+ p.add_argument("--uuid", default=None, help="UUID to disambiguate when URL matches multiple entries")
20
+ p.add_argument("-y", "--yes", action="store_true", help="Skip confirmation prompt")
21
+ p.set_defaults(func=run)
22
+
23
+
24
+ def run(
25
+ client: BrowserClient,
26
+ args: argparse.Namespace,
27
+ cli_config: CliConfig,
28
+ browser_config: BrowserConfig,
29
+ browser_config_path: Path,
30
+ *,
31
+ fmt: str = "table",
32
+ ) -> int:
33
+ entries = client.get_logins(ensure_scheme(args.url))
34
+ if not entries:
35
+ logger.error("No entries found for: %s", args.url)
36
+ return 1
37
+
38
+ if args.uuid is not None:
39
+ entry = next((e for e in entries if e.uuid == args.uuid), None)
40
+ if entry is None:
41
+ logger.error("Entry %s not found for URL: %s", args.uuid, args.url)
42
+ return 1
43
+ elif len(entries) == 1:
44
+ entry = entries[0]
45
+ else:
46
+ logger.error(
47
+ "Multiple entries found for %s \u2014 specify --uuid to disambiguate:",
48
+ args.url,
49
+ )
50
+ for e in entries:
51
+ print(f" {e.uuid} {e.login} ({e.name})")
52
+ return 1
53
+
54
+ label = f"{entry.name} ({args.url})"
55
+ if not args.yes:
56
+ answer = input(f"Delete entry {label}? [y/N] ").strip().lower()
57
+ if answer != "y":
58
+ print("Aborted.")
59
+ return 1
60
+
61
+ client.delete_entry(entry.uuid)
62
+ print_result("Entry deleted.", fmt)
63
+ return 0
@@ -7,7 +7,7 @@ from pathlib import Path
7
7
  from keepassxc_browser_api import BrowserClient, BrowserConfig
8
8
 
9
9
  from keepassxc_cli.config import CliConfig
10
- from keepassxc_cli.output import print_entry_detail
10
+ from keepassxc_cli.output import ensure_scheme, print_entry_detail
11
11
 
12
12
  logger = logging.getLogger(__name__)
13
13
 
@@ -30,9 +30,10 @@ def run(
30
30
  *,
31
31
  fmt: str = "table",
32
32
  ) -> int:
33
- entries = client.get_logins(args.url)
33
+ url = ensure_scheme(args.url)
34
+ entries = client.get_logins(url)
34
35
  if not entries:
35
- logger.warning("No entries found for: %s", args.url)
36
+ logger.warning("No entries found for: %s", url)
36
37
  return 1
37
38
  for entry in entries:
38
39
  print_entry_detail(entry, fmt, show_password=args.show_password, show_kph_prefix=getattr(args, "show_kph_prefix", False))
@@ -7,7 +7,7 @@ from pathlib import Path
7
7
  from keepassxc_browser_api import BrowserClient, BrowserConfig
8
8
 
9
9
  from keepassxc_cli.config import CliConfig
10
- from keepassxc_cli.output import print_totp
10
+ from keepassxc_cli.output import ensure_scheme, print_totp
11
11
 
12
12
  logger = logging.getLogger(__name__)
13
13
 
@@ -28,7 +28,7 @@ def run(
28
28
  *,
29
29
  fmt: str = "table",
30
30
  ) -> int:
31
- entries = client.get_logins(args.url)
31
+ entries = client.get_logins(ensure_scheme(args.url))
32
32
  if not entries:
33
33
  logger.warning("No entries found for: %s", args.url)
34
34
  return 1
@@ -1,11 +1,22 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import logging
4
5
 
5
6
  from keepassxc_browser_api import Entry
6
7
 
7
8
  _KPH_PREFIX = "KPH: "
8
9
 
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def ensure_scheme(url: str) -> str:
14
+ """Return url with a scheme. Prepends https:// with a warning if none is present."""
15
+ if url.startswith("http://") or url.startswith("https://"):
16
+ return url
17
+ logger.warning("URL %r has no scheme, assuming https://", url)
18
+ return "https://" + url
19
+
9
20
 
10
21
  def _strip_kph(key: str) -> str:
11
22
  return key[len(_KPH_PREFIX):] if key.startswith(_KPH_PREFIX) else key
@@ -64,6 +75,13 @@ def print_totp(totp: str, fmt: str = "table") -> None:
64
75
  print(totp)
65
76
 
66
77
 
78
+ def print_result(message: str, fmt: str = "table") -> None:
79
+ if fmt == "json":
80
+ print(json.dumps({"status": "ok", "message": message}, indent=2))
81
+ return
82
+ print(message)
83
+
84
+
67
85
  def print_status(info: dict, fmt: str = "table") -> None:
68
86
  if fmt == "json":
69
87
  print(json.dumps(info, indent=2))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keepassxc-cli
3
- Version: 1.6.0
3
+ Version: 2.0.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
@@ -86,6 +86,8 @@ keepassxc-cli status -j
86
86
 
87
87
  ### Commands
88
88
 
89
+ > **URL scheme**: All commands that accept a URL argument require a scheme (`https://` or `http://`). If you pass a bare hostname (e.g. `example.com`), the CLI will automatically prepend `https://` and emit a warning. KeePassXC derives the entry title from `QUrl(url).host()`, which returns an empty string for URLs without a scheme.
90
+
89
91
  #### `setup` — Associate with KeePassXC
90
92
 
91
93
  ```bash
@@ -119,19 +121,20 @@ keepassxc-cli totp https://github.com -j
119
121
  #### `clip` — Copy a field to clipboard
120
122
 
121
123
  ```bash
122
- keepassxc-cli clip password https://github.com
123
- keepassxc-cli clip username https://github.com
124
- keepassxc-cli clip totp https://github.com
124
+ keepassxc-cli clip https://github.com password
125
+ keepassxc-cli clip https://github.com username
126
+ keepassxc-cli clip https://github.com totp
125
127
  ```
126
128
 
127
129
  #### `add` — Add a new entry
128
130
 
129
131
  ```bash
130
132
  # Password is prompted securely if --password is not given
131
- keepassxc-cli add --url https://example.com --username user@example.com
132
- keepassxc-cli add --url https://example.com --username user --password mypass
133
- # Place the entry in a specific group by UUID
134
- keepassxc-cli add --url https://example.com --username user --group-uuid <group-uuid>
133
+ keepassxc-cli add https://example.com user@example.com
134
+ keepassxc-cli add https://example.com user --password mypass
135
+ # Place the entry in a specific group by UUID or by path
136
+ keepassxc-cli add https://example.com user --group-uuid <group-uuid>
137
+ keepassxc-cli add https://example.com user --group "Work/Projects"
135
138
  ```
136
139
 
137
140
  > **Note**: The entry title is always derived from the URL hostname by KeePassXC. The protocol has no field to set a custom title.
@@ -139,19 +142,20 @@ keepassxc-cli add --url https://example.com --username user --group-uuid <group-
139
142
  #### `edit` — Edit an entry
140
143
 
141
144
  ```bash
142
- # Get the UUID first
143
- keepassxc-cli show https://github.com -p
144
-
145
- # Then edit — --url is required to resolve the current entry
146
- keepassxc-cli edit <uuid> --url https://github.com --username newuser
147
- keepassxc-cli edit <uuid> --url https://github.com --password newpass --title "New Title"
145
+ # URL is positional; --uuid is optional when the URL matches exactly one entry
146
+ keepassxc-cli edit https://github.com --username newuser
147
+ keepassxc-cli edit https://github.com --password newpass
148
+ # Specify --uuid explicitly when the URL matches multiple entries
149
+ keepassxc-cli edit https://github.com --uuid <uuid> --username newuser
148
150
  ```
149
151
 
150
152
  #### `rm` — Delete an entry
151
153
 
152
154
  ```bash
153
- keepassxc-cli rm <uuid> # prompts for confirmation
154
- keepassxc-cli rm <uuid> --yes # skip confirmation
155
+ keepassxc-cli rm https://example.com # prompts for confirmation
156
+ keepassxc-cli rm https://example.com --yes # skip confirmation
157
+ # Specify --uuid when URL matches multiple entries
158
+ keepassxc-cli rm https://example.com --uuid <uuid> --yes
155
159
  ```
156
160
 
157
161
  #### `lock` — Lock the database
@@ -44,6 +44,7 @@ def make_args(**kwargs) -> argparse.Namespace:
44
44
  "uuid": "abcdef12-0000-0000-0000-000000000000",
45
45
  "name": "NewGroup",
46
46
  "path": "Work",
47
+ "json_output": False,
47
48
  }
48
49
  defaults.update(kwargs)
49
50
  return argparse.Namespace(**defaults)
@@ -136,6 +137,15 @@ class TestShowCommand:
136
137
  assert rc == 1
137
138
  assert any("No entries" in r.message for r in caplog.records)
138
139
 
140
+ def test_url_no_scheme_prefixed(self, mock_client, cli_config, browser_config, browser_config_path, caplog, mock_entry):
141
+ entry = mock_entry()
142
+ mock_client.get_logins.return_value = [entry]
143
+ args = make_args(url="example.com", show_password=False)
144
+ rc = show.run(mock_client, args, cli_config, browser_config, browser_config_path)
145
+ assert rc == 0
146
+ mock_client.get_logins.assert_called_once_with("https://example.com")
147
+ assert any("no scheme" in r.message.lower() for r in caplog.records)
148
+
139
149
 
140
150
  # --- add ---
141
151
 
@@ -169,6 +179,15 @@ class TestAddCommand:
169
179
  assert rc == 1
170
180
  assert any("not found" in r.message.lower() for r in caplog.records)
171
181
 
182
+ def test_url_no_scheme_prefixed(self, mock_client, cli_config, browser_config, browser_config_path, caplog):
183
+ mock_client.set_login.return_value = True
184
+ args = make_args(url="example.com", username="u", password="p", group_uuid="", group=None)
185
+ rc = add.run(mock_client, args, cli_config, browser_config, browser_config_path)
186
+ assert rc == 0
187
+ call_kwargs = mock_client.set_login.call_args.kwargs
188
+ assert call_kwargs["url"] == "https://example.com"
189
+ assert any("no scheme" in r.message.lower() for r in caplog.records)
190
+
172
191
  def test_failure_propagates(self, mock_client, cli_config, browser_config, browser_config_path):
173
192
  mock_client.set_login.side_effect = ProtocolError("access denied", error_code=6)
174
193
  args = make_args(url="https://example.com", username="u", password="p", group_uuid="", group=None)
@@ -228,22 +247,25 @@ class TestEditCommand:
228
247
  # --- rm ---
229
248
 
230
249
  class TestRmCommand:
231
- def test_uuid_with_yes_flag(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
250
+ def test_url_single_match_auto_deletes(self, mock_client, cli_config, browser_config, browser_config_path, capsys, mock_entry):
251
+ entry = mock_entry(uuid="url-resolved-uuid")
252
+ mock_client.get_logins.return_value = [entry]
232
253
  mock_client.delete_entry.return_value = True
233
- args = make_args(uuid="some-uuid", url=None, yes=True)
254
+ args = make_args(url="https://example.com", uuid=None, yes=True)
234
255
  rc = rm.run(mock_client, args, cli_config, browser_config, browser_config_path)
235
256
  assert rc == 0
236
- mock_client.delete_entry.assert_called_once_with("some-uuid")
257
+ mock_client.delete_entry.assert_called_once_with("url-resolved-uuid")
237
258
  assert "deleted" in capsys.readouterr().out.lower()
238
259
 
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]
260
+ def test_url_with_uuid_disambiguates(self, mock_client, cli_config, browser_config, browser_config_path, capsys, mock_entry):
261
+ e1 = mock_entry(uuid="uuid-1", login="alice")
262
+ e2 = mock_entry(uuid="uuid-2", login="bob")
263
+ mock_client.get_logins.return_value = [e1, e2]
242
264
  mock_client.delete_entry.return_value = True
243
- args = make_args(uuid=None, url="https://example.com", yes=True)
265
+ args = make_args(url="https://example.com", uuid="uuid-2", yes=True)
244
266
  rc = rm.run(mock_client, args, cli_config, browser_config, browser_config_path)
245
267
  assert rc == 0
246
- mock_client.delete_entry.assert_called_once_with("url-resolved-uuid")
268
+ mock_client.delete_entry.assert_called_once_with("uuid-2")
247
269
 
248
270
  def test_url_multiple_matches_error(self, mock_client, cli_config, browser_config, browser_config_path, caplog, mock_entry):
249
271
  e1 = mock_entry(uuid="uuid-1", login="alice")
@@ -261,9 +283,19 @@ class TestRmCommand:
261
283
  assert rc == 1
262
284
  assert any("no entries" in r.message.lower() for r in caplog.records)
263
285
 
264
- def test_failure_propagates(self, mock_client, cli_config, browser_config, browser_config_path):
286
+ def test_uuid_not_in_results_error(self, mock_client, cli_config, browser_config, browser_config_path, caplog, mock_entry):
287
+ entry = mock_entry(uuid="uuid-1")
288
+ mock_client.get_logins.return_value = [entry]
289
+ args = make_args(url="https://example.com", uuid="nonexistent-uuid", yes=True)
290
+ rc = rm.run(mock_client, args, cli_config, browser_config, browser_config_path)
291
+ assert rc == 1
292
+ assert any("not found" in r.message.lower() for r in caplog.records)
293
+
294
+ def test_failure_propagates(self, mock_client, cli_config, browser_config, browser_config_path, mock_entry):
295
+ entry = mock_entry(uuid="some-uuid")
296
+ mock_client.get_logins.return_value = [entry]
265
297
  mock_client.delete_entry.side_effect = ProtocolError("access denied", error_code=6)
266
- args = make_args(uuid="some-uuid", url=None, yes=True)
298
+ args = make_args(url="https://example.com", uuid=None, yes=True)
267
299
  with pytest.raises(ProtocolError):
268
300
  rm.run(mock_client, args, cli_config, browser_config, browser_config_path)
269
301
 
@@ -1,59 +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")
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
File without changes
File without changes
File without changes