keepassxc-cli 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -0,0 +1,77 @@
1
+ """CLI entry point for keepassxc-cli."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import logging
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from keepassxc_browser_api import BrowserClient, BrowserConfig
11
+ from keepassxc_browser_api.exceptions import KeePassXCError, ConnectionError
12
+
13
+ from .config import CliConfig, DEFAULT_CLI_CONFIG_PATH
14
+ from .commands import setup, status, show, search, ls, add, edit, rm, totp, clip, generate, lock, mkdir
15
+
16
+
17
+ def main() -> None:
18
+ parser = argparse.ArgumentParser(
19
+ prog="keepassxc-cli",
20
+ description="CLI for KeePassXC using the browser extension protocol with biometric unlock",
21
+ )
22
+ parser.add_argument(
23
+ "--config",
24
+ default=str(DEFAULT_CLI_CONFIG_PATH),
25
+ help="Path to CLI config file (default: %(default)s)",
26
+ )
27
+ parser.add_argument(
28
+ "--browser-api-config",
29
+ default=None,
30
+ help="Path to browser API config file",
31
+ )
32
+ parser.add_argument(
33
+ "--format",
34
+ choices=["table", "json", "tsv"],
35
+ default=None,
36
+ dest="fmt",
37
+ help="Output format (default: table)",
38
+ )
39
+ parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose logging")
40
+
41
+ subparsers = parser.add_subparsers(dest="command", metavar="COMMAND")
42
+ subparsers.required = True
43
+
44
+ setup.add_parser(subparsers)
45
+ status.add_parser(subparsers)
46
+ show.add_parser(subparsers)
47
+ search.add_parser(subparsers)
48
+ ls.add_parser(subparsers)
49
+ add.add_parser(subparsers)
50
+ edit.add_parser(subparsers)
51
+ rm.add_parser(subparsers)
52
+ totp.add_parser(subparsers)
53
+ clip.add_parser(subparsers)
54
+ generate.add_parser(subparsers)
55
+ lock.add_parser(subparsers)
56
+ mkdir.add_parser(subparsers)
57
+
58
+ args = parser.parse_args()
59
+
60
+ if args.verbose:
61
+ logging.basicConfig(level=logging.DEBUG)
62
+
63
+ cli_config_path = Path(args.config)
64
+ cli_config = CliConfig.load(cli_config_path)
65
+
66
+ browser_api_config_path = Path(args.browser_api_config or cli_config.browser_api_config_path)
67
+ browser_config = BrowserConfig.load(browser_api_config_path)
68
+
69
+ fmt = args.fmt or cli_config.default_format
70
+
71
+ client = BrowserClient(browser_config)
72
+ try:
73
+ rc = args.func(client, args, cli_config, browser_config, browser_api_config_path, fmt=fmt)
74
+ except (KeePassXCError, ConnectionError, OSError, json.JSONDecodeError) as e:
75
+ print(f"Error: {e}", file=sys.stderr)
76
+ rc = 1
77
+ sys.exit(rc)
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import getpass
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from keepassxc_browser_api import BrowserClient, BrowserConfig
9
+
10
+ from keepassxc_cli.config import CliConfig
11
+
12
+
13
+ def add_parser(subparsers: argparse._SubParsersAction) -> None:
14
+ p = subparsers.add_parser("add", help="Add a new entry")
15
+ p.add_argument("--url", required=True, help="Entry URL")
16
+ p.add_argument("--username", required=True, help="Username")
17
+ p.add_argument("--password", default=None, help="Password (prompted if omitted)")
18
+ p.add_argument("--title", default="", help="Entry title")
19
+ p.add_argument("--group-uuid", default="", help="Target group UUID")
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
+ password = args.password
33
+ if password is None:
34
+ password = getpass.getpass("Password: ")
35
+
36
+ success = client.set_login(
37
+ url=args.url,
38
+ username=args.username,
39
+ password=password,
40
+ title=args.title,
41
+ group_uuid=args.group_uuid,
42
+ )
43
+ if success:
44
+ print("Entry added successfully.")
45
+ return 0
46
+ else:
47
+ print("Failed to add entry.", file=sys.stderr)
48
+ return 1
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
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("clip", help="Copy a field to clipboard")
14
+ p.add_argument("url", help="URL to look up")
15
+ p.add_argument(
16
+ "--field",
17
+ choices=["password", "username", "totp"],
18
+ default="password",
19
+ help="Field to copy (default: password)",
20
+ )
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
+ try:
34
+ import pyperclip
35
+ except ImportError:
36
+ print("Error: pyperclip is required for clipboard support. Install it with: pip install pyperclip", file=sys.stderr)
37
+ return 1
38
+
39
+ entries = client.get_logins(args.url)
40
+ if not entries:
41
+ print(f"No entries found for: {args.url}", file=sys.stderr)
42
+ return 1
43
+
44
+ entry = entries[0]
45
+
46
+ if args.field == "password":
47
+ value = entry.password
48
+ elif args.field == "username":
49
+ value = entry.login
50
+ elif args.field == "totp":
51
+ value = client.get_totp(entry.uuid)
52
+ if value is None:
53
+ print(f"No TOTP for entry: {entry.uuid}", file=sys.stderr)
54
+ return 1
55
+ else:
56
+ print(f"Unknown field: {args.field}", file=sys.stderr)
57
+ return 1
58
+
59
+ pyperclip.copy(value)
60
+ print(f"Copied {args.field} to clipboard.")
61
+ return 0
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
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("edit", help="Edit an existing entry by UUID")
14
+ p.add_argument("uuid", help="UUID of the entry to edit")
15
+ p.add_argument("--url", default=None, help="New URL")
16
+ p.add_argument("--username", default=None, help="New username")
17
+ p.add_argument("--password", default=None, help="New password")
18
+ p.add_argument("--title", default=None, help="New title")
19
+ p.set_defaults(func=run)
20
+
21
+
22
+ def run(
23
+ client: BrowserClient,
24
+ args: argparse.Namespace,
25
+ cli_config: CliConfig,
26
+ browser_config: BrowserConfig,
27
+ browser_config_path: Path,
28
+ *,
29
+ fmt: str = "table",
30
+ ) -> int:
31
+ all_entries = client.get_database_entries()
32
+ entry = next((e for e in all_entries if e.uuid == args.uuid), None)
33
+ if entry is None:
34
+ print(f"Entry not found: {args.uuid}", file=sys.stderr)
35
+ return 1
36
+
37
+ # Determine the URL to use for set_login (required by the API)
38
+ url = args.url or next(
39
+ (sf.get("KPH: url", "") for sf in entry.string_fields if "KPH: url" in sf),
40
+ "",
41
+ )
42
+
43
+ success = client.set_login(
44
+ url=url,
45
+ username=args.username if args.username is not None else entry.login,
46
+ password=args.password if args.password is not None else entry.password,
47
+ title=args.title if args.title is not None else entry.name,
48
+ uuid=entry.uuid,
49
+ group_uuid=entry.group_uuid,
50
+ )
51
+ if success:
52
+ print("Entry updated successfully.")
53
+ return 0
54
+ else:
55
+ print("Failed to update entry.", file=sys.stderr)
56
+ return 1
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
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 print_password
11
+
12
+
13
+ def add_parser(subparsers: argparse._SubParsersAction) -> None:
14
+ p = subparsers.add_parser(
15
+ "generate",
16
+ help="Generate a password using KeePassXC's configured password profile",
17
+ )
18
+ p.add_argument("--clip", action="store_true", help="Copy to clipboard instead of printing")
19
+ p.set_defaults(func=run)
20
+
21
+
22
+ def run(
23
+ client: BrowserClient,
24
+ args: argparse.Namespace,
25
+ cli_config: CliConfig,
26
+ browser_config: BrowserConfig,
27
+ browser_config_path: Path,
28
+ *,
29
+ fmt: str = "table",
30
+ ) -> int:
31
+ password = client.generate_password()
32
+ if password is None:
33
+ print("Failed to generate password.", file=sys.stderr)
34
+ return 1
35
+
36
+ if args.clip:
37
+ try:
38
+ import pyperclip
39
+ pyperclip.copy(password)
40
+ print("Password copied to clipboard.")
41
+ except ImportError:
42
+ print("Error: pyperclip is required for clipboard support. Install it with: pip install pyperclip", file=sys.stderr)
43
+ return 1
44
+ else:
45
+ print_password(password, fmt)
46
+
47
+ return 0
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
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("lock", help="Lock the KeePassXC database")
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
+ success = client.lock_database()
27
+ if success:
28
+ print("Database locked.")
29
+ return 0
30
+ else:
31
+ print("Failed to lock database.", file=sys.stderr)
32
+ return 1
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from keepassxc_browser_api import BrowserClient, BrowserConfig
7
+
8
+ from keepassxc_cli.config import CliConfig
9
+ from keepassxc_cli.output import print_entries, print_groups
10
+
11
+
12
+ def add_parser(subparsers: argparse._SubParsersAction) -> None:
13
+ p = subparsers.add_parser("ls", help="List database entries or groups")
14
+ p.add_argument("--groups", action="store_true", help="List groups instead of entries")
15
+ p.set_defaults(func=run)
16
+
17
+
18
+ def run(
19
+ client: BrowserClient,
20
+ args: argparse.Namespace,
21
+ cli_config: CliConfig,
22
+ browser_config: BrowserConfig,
23
+ browser_config_path: Path,
24
+ *,
25
+ fmt: str = "table",
26
+ ) -> int:
27
+ if args.groups:
28
+ groups = client.get_database_groups()
29
+ print_groups(groups, fmt)
30
+ else:
31
+ entries = client.get_database_entries()
32
+ print_entries(entries, fmt)
33
+ return 0
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
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("mkdir", help="Create a new group")
14
+ p.add_argument("name", help="Group name")
15
+ p.add_argument("--parent-uuid", default="", help="Parent group UUID")
16
+ p.set_defaults(func=run)
17
+
18
+
19
+ def run(
20
+ client: BrowserClient,
21
+ args: argparse.Namespace,
22
+ cli_config: CliConfig,
23
+ browser_config: BrowserConfig,
24
+ browser_config_path: Path,
25
+ *,
26
+ fmt: str = "table",
27
+ ) -> int:
28
+ group = client.create_group(args.name, parent_group_uuid=args.parent_uuid)
29
+ if group is None:
30
+ print("Failed to create group.", file=sys.stderr)
31
+ return 1
32
+ print(f"Group created: {group.name} [{group.uuid}]")
33
+ return 0
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
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("rm", help="Delete an entry by UUID")
14
+ p.add_argument("uuid", help="UUID of the entry to delete")
15
+ p.add_argument("-y", "--yes", action="store_true", help="Skip confirmation prompt")
16
+ p.set_defaults(func=run)
17
+
18
+
19
+ def run(
20
+ client: BrowserClient,
21
+ args: argparse.Namespace,
22
+ cli_config: CliConfig,
23
+ browser_config: BrowserConfig,
24
+ browser_config_path: Path,
25
+ *,
26
+ fmt: str = "table",
27
+ ) -> int:
28
+ if not args.yes:
29
+ answer = input(f"Delete entry {args.uuid}? [y/N] ").strip().lower()
30
+ if answer != "y":
31
+ print("Aborted.")
32
+ return 1
33
+
34
+ success = client.delete_entry(args.uuid)
35
+ if success:
36
+ print("Entry deleted.")
37
+ return 0
38
+ else:
39
+ print("Failed to delete entry.", file=sys.stderr)
40
+ return 1
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
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 print_entries
11
+
12
+
13
+ def add_parser(subparsers: argparse._SubParsersAction) -> None:
14
+ p = subparsers.add_parser("search", help="Search all database entries")
15
+ p.add_argument("query", help="Search query (case-insensitive match on title, username, or URL)")
16
+ p.add_argument("-p", "--show-password", action="store_true", help="Reveal password")
17
+ p.set_defaults(func=run)
18
+
19
+
20
+ def run(
21
+ client: BrowserClient,
22
+ args: argparse.Namespace,
23
+ cli_config: CliConfig,
24
+ browser_config: BrowserConfig,
25
+ browser_config_path: Path,
26
+ *,
27
+ fmt: str = "table",
28
+ ) -> int:
29
+ query = args.query.lower()
30
+ all_entries = client.get_database_entries()
31
+ matches = [
32
+ e for e in all_entries
33
+ if query in e.name.lower()
34
+ or query in e.login.lower()
35
+ or any(query in v.lower() for sf in e.string_fields for v in sf.values() if v is not None)
36
+ ]
37
+ if not matches:
38
+ print(f"No entries found matching: {args.query}", file=sys.stderr)
39
+ return 1
40
+ print_entries(matches, fmt, show_password=args.show_password)
41
+ return 0
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
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("setup", help="Associate with KeePassXC (key exchange)")
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
+ success = client.setup()
27
+ if success:
28
+ browser_config.save(browser_config_path)
29
+ print("Successfully associated with KeePassXC.")
30
+ return 0
31
+ else:
32
+ print("Failed to associate with KeePassXC.", file=sys.stderr)
33
+ return 1
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
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 print_entry_detail
11
+
12
+
13
+ def add_parser(subparsers: argparse._SubParsersAction) -> None:
14
+ p = subparsers.add_parser("show", help="Show entries matching a URL")
15
+ p.add_argument("url", help="URL or search string")
16
+ p.add_argument("-p", "--show-password", action="store_true", help="Reveal password")
17
+ p.set_defaults(func=run)
18
+
19
+
20
+ def run(
21
+ client: BrowserClient,
22
+ args: argparse.Namespace,
23
+ cli_config: CliConfig,
24
+ browser_config: BrowserConfig,
25
+ browser_config_path: Path,
26
+ *,
27
+ fmt: str = "table",
28
+ ) -> int:
29
+ entries = client.get_logins(args.url)
30
+ if not entries:
31
+ print(f"No entries found for: {args.url}", file=sys.stderr)
32
+ return 1
33
+ for entry in entries:
34
+ print_entry_detail(entry, fmt, show_password=args.show_password)
35
+ if fmt == "table" and len(entries) > 1:
36
+ print()
37
+ return 0
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from keepassxc_browser_api import BrowserClient, BrowserConfig
7
+ from keepassxc_browser_api.exceptions import KeePassXCError
8
+
9
+ from keepassxc_cli.config import CliConfig
10
+ from keepassxc_cli.output import print_status
11
+
12
+
13
+ def add_parser(subparsers: argparse._SubParsersAction) -> None:
14
+ p = subparsers.add_parser("status", help="Show KeePassXC connection and association status")
15
+ p.set_defaults(func=run)
16
+
17
+
18
+ def run(
19
+ client: BrowserClient,
20
+ args: argparse.Namespace,
21
+ cli_config: CliConfig,
22
+ browser_config: BrowserConfig,
23
+ browser_config_path: Path,
24
+ *,
25
+ fmt: str = "table",
26
+ ) -> int:
27
+ info: dict = {}
28
+
29
+ connected = client.connect()
30
+ info["Connected"] = "yes" if connected else "no"
31
+
32
+ if not connected:
33
+ print_status(info, fmt)
34
+ return 1
35
+
36
+ associated = False
37
+ if browser_config.associations:
38
+ try:
39
+ association = next(iter(browser_config.associations.values()))
40
+ associated = client.test_associate(association)
41
+ except KeePassXCError:
42
+ associated = False
43
+
44
+ info["Associated"] = "yes" if associated else "no"
45
+ info["Unlock timeout"] = str(browser_config.unlock_timeout)
46
+
47
+ client.disconnect()
48
+ print_status(info, fmt)
49
+ return 0
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
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 print_totp
11
+
12
+
13
+ def add_parser(subparsers: argparse._SubParsersAction) -> None:
14
+ p = subparsers.add_parser("totp", help="Get TOTP code for an entry")
15
+ p.add_argument("uuid", help="UUID of the entry")
16
+ p.set_defaults(func=run)
17
+
18
+
19
+ def run(
20
+ client: BrowserClient,
21
+ args: argparse.Namespace,
22
+ cli_config: CliConfig,
23
+ browser_config: BrowserConfig,
24
+ browser_config_path: Path,
25
+ *,
26
+ fmt: str = "table",
27
+ ) -> int:
28
+ totp = client.get_totp(args.uuid)
29
+ if totp is None:
30
+ print(f"No TOTP for entry: {args.uuid}", file=sys.stderr)
31
+ return 1
32
+ print_totp(totp, fmt)
33
+ return 0
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ DEFAULT_BROWSER_API_CONFIG_PATH = str(Path.home() / ".keepassxc" / "browser-api.json")
9
+ DEFAULT_CLI_CONFIG_PATH = Path.home() / ".keepassxc" / "cli.json"
10
+
11
+
12
+ @dataclass
13
+ class CliConfig:
14
+ browser_api_config_path: str = field(default_factory=lambda: DEFAULT_BROWSER_API_CONFIG_PATH)
15
+ default_format: str = "table"
16
+
17
+ def to_dict(self) -> dict:
18
+ d: dict = {}
19
+ if self.browser_api_config_path != DEFAULT_BROWSER_API_CONFIG_PATH:
20
+ d["browser_api_config_path"] = self.browser_api_config_path
21
+ if self.default_format != "table":
22
+ d["default_format"] = self.default_format
23
+ return d
24
+
25
+ @classmethod
26
+ def from_dict(cls, d: dict) -> CliConfig:
27
+ return cls(
28
+ browser_api_config_path=d.get("browser_api_config_path", DEFAULT_BROWSER_API_CONFIG_PATH),
29
+ default_format=d.get("default_format", "table"),
30
+ )
31
+
32
+ def save(self, path: Path | str) -> None:
33
+ path = Path(path)
34
+ path.parent.mkdir(parents=True, exist_ok=True)
35
+ data = json.dumps(self.to_dict(), indent=2)
36
+ # Write with restricted permissions (0o600)
37
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
38
+ try:
39
+ os.write(fd, data.encode())
40
+ finally:
41
+ os.close(fd)
42
+
43
+ @classmethod
44
+ def load(cls, path: Path | str) -> CliConfig:
45
+ path = Path(path)
46
+ if not path.exists():
47
+ return cls()
48
+ with open(path) as f:
49
+ d = json.load(f)
50
+ return cls.from_dict(d)
@@ -0,0 +1,167 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from keepassxc_browser_api import Entry, Group
6
+
7
+
8
+ def _truncate(s: str, n: int) -> str:
9
+ if len(s) > n:
10
+ return s[: n - 1] + "…"
11
+ return s
12
+
13
+
14
+ def _table_row(cols: list[str], widths: list[int]) -> str:
15
+ return "| " + " | ".join(v.ljust(w) for v, w in zip(cols, widths)) + " |"
16
+
17
+
18
+ def _table_sep(widths: list[int]) -> str:
19
+ return "+-" + "-+-".join("-" * w for w in widths) + "-+"
20
+
21
+
22
+ def _print_table(headers: list[str], rows: list[list[str]]) -> None:
23
+ widths = [len(h) for h in headers]
24
+ for row in rows:
25
+ for i, cell in enumerate(row):
26
+ widths[i] = max(widths[i], len(cell))
27
+ sep = _table_sep(widths)
28
+ print(sep)
29
+ print(_table_row(headers, widths))
30
+ print(sep)
31
+ for row in rows:
32
+ print(_table_row(row, widths))
33
+ print(sep)
34
+
35
+
36
+ def print_entries(entries: list[Entry], fmt: str = "table", show_password: bool = False) -> None:
37
+ if fmt == "json":
38
+ data = [
39
+ {
40
+ "uuid": e.uuid,
41
+ "name": e.name,
42
+ "login": e.login,
43
+ "password": e.password if show_password else "***",
44
+ "url": next((sf.get("KPH: url", "") for sf in e.string_fields if "KPH: url" in sf), ""),
45
+ "group": e.group,
46
+ }
47
+ for e in entries
48
+ ]
49
+ print(json.dumps(data, indent=2))
50
+ return
51
+
52
+ if fmt == "tsv":
53
+ headers = ["UUID", "Title", "Username", "URL", "Group"]
54
+ print("\t".join(headers))
55
+ for e in entries:
56
+ url = next((sf.get("KPH: url", "") for sf in e.string_fields if "KPH: url" in sf), "")
57
+ print("\t".join([e.uuid, e.name, e.login, url, e.group]))
58
+ return
59
+
60
+ # table
61
+ headers = ["UUID", "Title", "Username", "URL", "Group"]
62
+ rows = []
63
+ for e in entries:
64
+ url = next((sf.get("KPH: url", "") for sf in e.string_fields if "KPH: url" in sf), "")
65
+ rows.append([_truncate(e.uuid, 9), e.name, e.login, url, e.group])
66
+ _print_table(headers, rows)
67
+
68
+
69
+ def _print_group_tree(group: Group, indent: int = 0) -> None:
70
+ prefix = " " * indent
71
+ print(f"{prefix}{group.name} [{_truncate(group.uuid, 9)}]")
72
+ for child in group.children:
73
+ _print_group_tree(child, indent + 1)
74
+
75
+
76
+ def print_groups(groups: list[Group], fmt: str = "table") -> None:
77
+ if fmt == "json":
78
+ def _g2d(g: Group) -> dict:
79
+ return {"uuid": g.uuid, "name": g.name, "children": [_g2d(c) for c in g.children]}
80
+ print(json.dumps([_g2d(g) for g in groups], indent=2))
81
+ return
82
+
83
+ if fmt == "tsv":
84
+ print("UUID\tName")
85
+ for g in groups:
86
+ for flat in g.flat_list():
87
+ print(f"{flat.uuid}\t{flat.name}")
88
+ return
89
+
90
+ for g in groups:
91
+ _print_group_tree(g)
92
+
93
+
94
+ def print_entry_detail(entry: Entry, fmt: str = "table", show_password: bool = False) -> None:
95
+ password = entry.password if show_password else "***"
96
+ if fmt == "json":
97
+ data = {
98
+ "uuid": entry.uuid,
99
+ "name": entry.name,
100
+ "login": entry.login,
101
+ "password": password,
102
+ "totp": entry.totp,
103
+ "group": entry.group,
104
+ "group_uuid": entry.group_uuid,
105
+ "string_fields": entry.string_fields,
106
+ }
107
+ print(json.dumps(data, indent=2))
108
+ return
109
+
110
+ if fmt == "tsv":
111
+ print("Field\tValue")
112
+ print(f"UUID\t{entry.uuid}")
113
+ print(f"Title\t{entry.name}")
114
+ print(f"Username\t{entry.login}")
115
+ print(f"Password\t{password}")
116
+ print(f"TOTP\t{entry.totp}")
117
+ print(f"Group\t{entry.group}")
118
+ print(f"Group UUID\t{entry.group_uuid}")
119
+ return
120
+
121
+ print(f"UUID: {entry.uuid}")
122
+ print(f"Title: {entry.name}")
123
+ print(f"Username: {entry.login}")
124
+ print(f"Password: {password}")
125
+ if entry.totp:
126
+ print(f"TOTP: {entry.totp}")
127
+ if entry.group:
128
+ print(f"Group: {entry.group}")
129
+ if entry.group_uuid:
130
+ print(f"Group UUID: {entry.group_uuid}")
131
+ if entry.string_fields:
132
+ for sf in entry.string_fields:
133
+ for k, v in sf.items():
134
+ print(f"{k}: {v}")
135
+
136
+
137
+ def print_totp(totp: str, fmt: str = "table") -> None:
138
+ if fmt == "json":
139
+ print(json.dumps({"totp": totp}, indent=2))
140
+ return
141
+ if fmt == "tsv":
142
+ print(f"TOTP\t{totp}")
143
+ return
144
+ print(totp)
145
+
146
+
147
+ def print_password(password: str, fmt: str = "table") -> None:
148
+ if fmt == "json":
149
+ print(json.dumps({"password": password}, indent=2))
150
+ return
151
+ if fmt == "tsv":
152
+ print(f"Password\t{password}")
153
+ return
154
+ print(password)
155
+
156
+
157
+ def print_status(info: dict, fmt: str = "table") -> None:
158
+ if fmt == "json":
159
+ print(json.dumps(info, indent=2))
160
+ return
161
+ if fmt == "tsv":
162
+ print("Key\tValue")
163
+ for k, v in info.items():
164
+ print(f"{k}\t{v}")
165
+ return
166
+ for k, v in info.items():
167
+ print(f"{k}: {v}")
@@ -0,0 +1,226 @@
1
+ Metadata-Version: 2.4
2
+ Name: keepassxc-cli
3
+ Version: 0.1.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==0.1.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
+ # keepassxc-cli
17
+
18
+ A command-line interface for [KeePassXC](https://keepassxc.org/) that communicates via the browser extension protocol, supporting biometric (TouchID/fingerprint) unlock on supported platforms.
19
+
20
+ ## What it is
21
+
22
+ `keepassxc-cli` talks to a running KeePassXC instance using the same native messaging protocol used by the KeePassXC Browser extension. This means:
23
+
24
+ - **Biometric unlock**: On macOS with TouchID (or similar) configured in KeePassXC, you can authenticate via fingerprint rather than typing your master password.
25
+ - **No master password in shell history**: Authentication happens through KeePassXC's GUI, not the terminal.
26
+ - **Full CRUD**: List, search, add, edit, delete entries and groups.
27
+ - **TOTP**: Retrieve time-based one-time passwords.
28
+ - **Clipboard**: Copy credentials directly to the clipboard.
29
+
30
+ ## Prerequisites
31
+
32
+ 1. **KeePassXC** ≥ 2.7 with the **Browser Integration** feature enabled:
33
+ - Open KeePassXC → Tools → Settings → Browser Integration
34
+ - Enable "Enable browser integration"
35
+ 2. A KeePassXC database must be open (or KeePassXC must be running with auto-open configured).
36
+ 3. Python ≥ 3.10
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ pipx install keepassxc-cli
42
+ ```
43
+
44
+ Or with pip:
45
+
46
+ ```bash
47
+ pip install keepassxc-cli
48
+ ```
49
+
50
+ ## Setup
51
+
52
+ Before using `keepassxc-cli`, associate it with your KeePassXC instance:
53
+
54
+ ```bash
55
+ keepassxc-cli setup
56
+ ```
57
+
58
+ This performs a key exchange with KeePassXC (you will be prompted to allow the association in the KeePassXC GUI). The association is saved to `~/.keepassxc/browser-api.json`.
59
+
60
+ ## Usage
61
+
62
+ ### Global options
63
+
64
+ ```
65
+ keepassxc-cli [--config PATH] [--browser-api-config PATH] [--format {table,json,tsv}] [-v] COMMAND
66
+ ```
67
+
68
+ | Option | Description |
69
+ |--------|-------------|
70
+ | `--config` | Path to CLI config file (default: `~/.keepassxc/cli.json`) |
71
+ | `--browser-api-config` | Path to browser API config file (default: `~/.keepassxc/browser-api.json`) |
72
+ | `--format` | Output format: `table` (default), `json`, or `tsv` |
73
+ | `-v, --verbose` | Enable verbose/debug logging |
74
+
75
+ ### Commands
76
+
77
+ #### `setup` — Associate with KeePassXC
78
+
79
+ ```bash
80
+ keepassxc-cli setup
81
+ ```
82
+
83
+ #### `status` — Connection and association status
84
+
85
+ ```bash
86
+ keepassxc-cli status
87
+ ```
88
+
89
+ #### `ls` — List entries or groups
90
+
91
+ ```bash
92
+ keepassxc-cli ls # list all entries (includes UUID column)
93
+ keepassxc-cli ls --groups # list groups (tree view)
94
+ keepassxc-cli ls --format json # output as JSON
95
+ ```
96
+
97
+ UUIDs shown in the output are needed for `edit`, `rm`, `totp`, and `clip --field totp`.
98
+
99
+ #### `search` — Search entries
100
+
101
+ ```bash
102
+ keepassxc-cli search github
103
+ keepassxc-cli search "my bank" --show-password
104
+ ```
105
+
106
+ Searches case-insensitively across title, username, and URL fields.
107
+
108
+ #### `show` — Show entries for a URL
109
+
110
+ ```bash
111
+ keepassxc-cli show https://github.com
112
+ keepassxc-cli show https://github.com -p # reveal password
113
+ ```
114
+
115
+ #### `add` — Add a new entry
116
+
117
+ ```bash
118
+ keepassxc-cli add --url https://example.com --username user@example.com --title "Example"
119
+ # Password will be prompted securely if --password is not given
120
+ keepassxc-cli add --url https://example.com --username user --password mypass
121
+ ```
122
+
123
+ #### `edit` — Edit an entry
124
+
125
+ > **Finding a UUID**: Use `keepassxc-cli ls` or `keepassxc-cli search <query>` to list entries with their UUIDs.
126
+
127
+ ```bash
128
+ keepassxc-cli edit <uuid> --username newuser
129
+ keepassxc-cli edit <uuid> --password newpass --title "New Title"
130
+ ```
131
+
132
+ #### `rm` — Delete an entry
133
+
134
+ ```bash
135
+ keepassxc-cli rm <uuid> # prompts for confirmation
136
+ keepassxc-cli rm <uuid> --yes # skip confirmation
137
+ ```
138
+
139
+ #### `totp` — Get TOTP code
140
+
141
+ ```bash
142
+ keepassxc-cli totp <uuid>
143
+ ```
144
+
145
+ #### `clip` — Copy to clipboard
146
+
147
+ ```bash
148
+ keepassxc-cli clip https://github.com # copies password
149
+ keepassxc-cli clip https://github.com --field username
150
+ keepassxc-cli clip https://github.com --field totp
151
+ ```
152
+
153
+ #### `generate` — Generate a password
154
+
155
+ ```bash
156
+ keepassxc-cli generate
157
+ keepassxc-cli generate --length 32 --symbols
158
+ keepassxc-cli generate --no-numbers --no-uppercase
159
+ keepassxc-cli generate --clip # copy to clipboard instead of printing
160
+ ```
161
+
162
+ #### `lock` — Lock the database
163
+
164
+ ```bash
165
+ keepassxc-cli lock
166
+ ```
167
+
168
+ #### `mkdir` — Create a group
169
+
170
+ ```bash
171
+ keepassxc-cli mkdir "Work"
172
+ keepassxc-cli mkdir "Projects" --parent-uuid <parent-group-uuid>
173
+ ```
174
+
175
+ ## Configuration
176
+
177
+ ### CLI config (`~/.keepassxc/cli.json`)
178
+
179
+ Only non-default values are stored. Available options:
180
+
181
+ | Key | Default | Description |
182
+ |-----|---------|-------------|
183
+ | `browser_api_config_path` | `~/.keepassxc/browser-api.json` | Path to the browser API config |
184
+ | `default_format` | `table` | Default output format (`table`, `json`, `tsv`) |
185
+
186
+ Example `~/.keepassxc/cli.json`:
187
+ ```json
188
+ {
189
+ "default_format": "json"
190
+ }
191
+ ```
192
+
193
+ ### Browser API config (`~/.keepassxc/browser-api.json`)
194
+
195
+ Shared with `keepassxc-browser-api`. Contains the association keys created during `keepassxc-cli setup`. This file is automatically created and updated by the `setup` command.
196
+
197
+ Both config files are stored with `0o600` permissions (owner read/write only).
198
+
199
+ ## Development
200
+
201
+ ```bash
202
+ git clone https://github.com/mietzen/keepassxc-cli
203
+ cd keepassxc-cli
204
+
205
+ python3 -m venv .venv
206
+ source .venv/bin/activate
207
+
208
+ # Install local keepassxc-browser-api dependency first
209
+ pip install ../mietzen-keepassxc-browser-api/
210
+
211
+ # Install in editable mode with dev dependencies
212
+ pip install -e ".[dev]"
213
+
214
+ # Run tests
215
+ pytest --tb=short -q
216
+
217
+ # Run linter
218
+ ruff check --ignore=E501 --exclude=__init__.py ./keepassxc_cli
219
+ ```
220
+
221
+ ## Known Limitations
222
+
223
+ - Requires KeePassXC to be **running** and the database to be **open** (or biometric auto-unlock configured).
224
+ - The `clip` and `generate --clip` commands require `pyperclip` and a working clipboard (e.g., `xclip`/`xsel` on Linux, built-in on macOS/Windows).
225
+ - The browser integration protocol does not support moving entries between groups directly.
226
+ - Entry URLs in the database are stored as `KPH: url` string fields; entries without a URL field may not appear in `show` results.
@@ -0,0 +1,24 @@
1
+ keepassxc_cli/__init__.py,sha256=U4S_2y3zgLZVfMenHRaJFBW8yqh2mUBuI291LGQVOJ8,35
2
+ keepassxc_cli/__main__.py,sha256=kVXXp8Ts8LmotfJw0nL71M6kfe_3YRTekrvyVK-KNkg,2469
3
+ keepassxc_cli/config.py,sha256=bgSllV9Vz6j-Xou_BLAvkoi_TWhXEeBsLlWteZAgxXc,1653
4
+ keepassxc_cli/output.py,sha256=RXgmXSnyNyd6gr59_TdHFkR_xR-jcyBuITUopXuBdWE,5019
5
+ keepassxc_cli/commands/__init__.py,sha256=U4S_2y3zgLZVfMenHRaJFBW8yqh2mUBuI291LGQVOJ8,35
6
+ keepassxc_cli/commands/add.py,sha256=wAJ_V0sH1kXE3Dkvt7uEiBh2J7ZAKbgTveq7Eppib8I,1359
7
+ keepassxc_cli/commands/clip.py,sha256=PvLT2ZKnr7phphPgQEZ0mTU7EwLDFiEbIaywF1XIXjw,1658
8
+ keepassxc_cli/commands/edit.py,sha256=HnzyDZ7_6pxWBhh_alNtA48sUcTCtkC1-LREKcHJlSs,1816
9
+ keepassxc_cli/commands/generate.py,sha256=dY1gE3aPHGVq09V0Vo-3TRlI7wsj-ZO84_2_frhZKac,1312
10
+ keepassxc_cli/commands/lock.py,sha256=kHKd_H4z-gtCsyl_Tf9Tk8cbm2oeamyFFcgv-Nh7LZk,757
11
+ keepassxc_cli/commands/ls.py,sha256=ZuQcxTrDt9CHzHBVTTXBbf9gq-27UL-JV613ffap1gg,917
12
+ keepassxc_cli/commands/mkdir.py,sha256=mk8Fj2_QurccoLSjncZUxzpR9CkfYL7B2EkK1DtWqFs,925
13
+ keepassxc_cli/commands/rm.py,sha256=r-iRFlID6f41pmKhAK9YAFjE-v7iW7ofSTWsfmf_6Vw,1082
14
+ keepassxc_cli/commands/search.py,sha256=Wvdzb3L56mIA-XtophNlswuJBmVG081Us_eiB82dkks,1299
15
+ keepassxc_cli/commands/setup.py,sha256=m8hyRpyPaRY07-iQItQXkrgbu5E6dwauplyxjI4oR8E,845
16
+ keepassxc_cli/commands/show.py,sha256=AxhWLyCSFc3drFkU1iUySZuoKRqoifPuQHV4Nl1B55Q,1090
17
+ keepassxc_cli/commands/status.py,sha256=f-5DJK1yAUWzaGe_EnqOWliwhlQo8e7Ugy1fqmxXO24,1320
18
+ keepassxc_cli/commands/totp.py,sha256=jE6C8azlqMpuHoQa1y0JV27bVo0Pj-wewvP_WRdsfuM,844
19
+ keepassxc_cli-0.1.0.dist-info/licenses/LICENSE,sha256=i7l1iI-cTPEtMrRCNzPD2_ZMZV0LQna7gPvYG0lVoCs,1061
20
+ keepassxc_cli-0.1.0.dist-info/METADATA,sha256=dgvHoyFe4oc6RMdGPsAHN45XmWBRURasWSdYfJS0gFA,6471
21
+ keepassxc_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
22
+ keepassxc_cli-0.1.0.dist-info/entry_points.txt,sha256=nWY3_lURlCrN2WmUwkYzgEQByM204jRVL0NRlfpm2LM,62
23
+ keepassxc_cli-0.1.0.dist-info/top_level.txt,sha256=Ac6eDBVHsCdXRS6v48vVgGyU0xVGoXXIc4DuNtFc6CY,14
24
+ keepassxc_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ keepassxc-cli = keepassxc_cli.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nils
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ keepassxc_cli