basic-password-manager 0.2.1__tar.gz → 0.2.3__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.
Files changed (17) hide show
  1. {basic_password_manager-0.2.1/basic_password_manager.egg-info → basic_password_manager-0.2.3}/PKG-INFO +22 -9
  2. basic_password_manager-0.2.1/PKG-INFO → basic_password_manager-0.2.3/README.md +19 -21
  3. basic_password_manager-0.2.1/README.md → basic_password_manager-0.2.3/basic_password_manager.egg-info/PKG-INFO +34 -7
  4. {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/basic_password_manager.egg-info/SOURCES.txt +1 -0
  5. {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/pw_manager/cli.py +39 -12
  6. basic_password_manager-0.2.3/pw_manager/logo.py +88 -0
  7. {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/pw_manager/tui.py +113 -21
  8. {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/pyproject.toml +6 -2
  9. {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/LICENSE +0 -0
  10. {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/basic_password_manager.egg-info/dependency_links.txt +0 -0
  11. {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/basic_password_manager.egg-info/entry_points.txt +0 -0
  12. {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/basic_password_manager.egg-info/requires.txt +0 -0
  13. {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/basic_password_manager.egg-info/top_level.txt +0 -0
  14. {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/pw_manager/__init__.py +0 -0
  15. {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/pw_manager/crypto.py +0 -0
  16. {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/pw_manager/vault.py +0 -0
  17. {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/setup.cfg +0 -0
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: basic-password-manager
3
- Version: 0.2.1
4
- Summary: Local encrypted password manager that lives in your terminal
3
+ Version: 0.2.3
4
+ Summary: The Paladin — a local encrypted password manager that lives in your terminal
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/Daisentaur/password-manager
7
+ Project-URL: Changelog, https://github.com/Daisentaur/password-manager/blob/main/CHANGELOG.md
7
8
  Requires-Python: >=3.10
8
9
  Description-Content-Type: text/markdown
9
10
  License-File: LICENSE
@@ -12,11 +13,17 @@ Requires-Dist: cryptography
12
13
  Requires-Dist: textual
13
14
  Dynamic: license-file
14
15
 
15
- # pw-manager
16
+ <p align="center">
17
+ <img src="assets/logo.png" width="160" alt="The Paladin — a pixel-art knight" />
18
+ </p>
19
+
20
+ <h1 align="center">The Paladin</h1>
21
+
22
+ <p align="center"><em>Your passwords, guarded locally. No browser, no cloud, no mercy.</em></p>
16
23
 
17
24
  A local, encrypted password manager that lives in your terminal. No browser,
18
25
  no cloud, no daemon — one encrypted file on disk and a small Python CLI in
19
- front of it.
26
+ front of it. (Installs as `basic-password-manager`; answers to `pw`.)
20
27
 
21
28
  ```
22
29
  $ python pw.py get github
@@ -48,8 +55,12 @@ For development: `python3 -m venv venv && ./venv/bin/pip install -e .`
48
55
  Run `pw` with no arguments for the interactive TUI: unlock, then `/` to
49
56
  filter, Enter to copy the selected password, `n` to add, `e` to edit,
50
57
  `d` to delete, `t` to switch themes (persisted across runs), `q` to quit.
51
- Rarer operations live in the command palette (`ctrl+p`), e.g. changing the
52
- master password.
58
+ The search box matches every word of your query anywhere in an entry —
59
+ name, username, notes, and the password itself — with matches highlighted
60
+ in the theme's accent color (password matches show the row but highlight
61
+ nothing, since passwords are never displayed). Rarer operations live in the
62
+ command palette (`ctrl+p`): importing a browser CSV, changing the master
63
+ password.
53
64
  The bundled themes — muted-slate (default), dawn, matrix — are borrowed with
54
65
  admiration from [tuxedo](https://github.com/webstonehq/tuxedo); Textual's
55
66
  built-in themes are in the picker too.
@@ -66,9 +77,10 @@ Or use the subcommands:
66
77
  | `pw passwd` | change the master password |
67
78
  | `pw ls` | list entry names |
68
79
  | `pw rm github` | delete an entry |
69
- | `pw find bank` | search entry names, usernames, and notes |
80
+ | `pw find "dt bank"` | search names, usernames, notes — every word must match, any order |
70
81
  | `pw gen -l 32` | just print a random password |
71
82
  | `pw import passwords.csv` | import a browser CSV export (see below) |
83
+ | `pw about` | meet the knight |
72
84
 
73
85
  The vault lives at `~/.local/share/pw-manager/vault`. Set the `PW_VAULT`
74
86
  environment variable to put it somewhere else.
@@ -131,9 +143,10 @@ memory for the session; the CLI re-derives the key per command.
131
143
 
132
144
  argparse subcommands over the two modules above. Passwords are copied to the
133
145
  clipboard (`wl-copy`/`xclip`/`xsel`, whichever exists) rather than printed,
134
- so they don't sit in your terminal scrollback. `find` searches names,
146
+ so they don't sit in your terminal scrollback. CLI `find` searches names,
135
147
  usernames, and notes — deliberately *not* passwords, so fragments of secrets
136
- never land in your shell history.
148
+ never land in your shell history; the TUI search box does cover passwords,
149
+ because nothing typed there is logged anywhere.
137
150
 
138
151
  ## Backups
139
152
 
@@ -1,22 +1,14 @@
1
- Metadata-Version: 2.4
2
- Name: basic-password-manager
3
- Version: 0.2.1
4
- Summary: Local encrypted password manager that lives in your terminal
5
- License-Expression: MIT
6
- Project-URL: Repository, https://github.com/Daisentaur/password-manager
7
- Requires-Python: >=3.10
8
- Description-Content-Type: text/markdown
9
- License-File: LICENSE
10
- Requires-Dist: argon2-cffi
11
- Requires-Dist: cryptography
12
- Requires-Dist: textual
13
- Dynamic: license-file
14
-
15
- # pw-manager
1
+ <p align="center">
2
+ <img src="assets/logo.png" width="160" alt="The Paladin — a pixel-art knight" />
3
+ </p>
4
+
5
+ <h1 align="center">The Paladin</h1>
6
+
7
+ <p align="center"><em>Your passwords, guarded locally. No browser, no cloud, no mercy.</em></p>
16
8
 
17
9
  A local, encrypted password manager that lives in your terminal. No browser,
18
10
  no cloud, no daemon — one encrypted file on disk and a small Python CLI in
19
- front of it.
11
+ front of it. (Installs as `basic-password-manager`; answers to `pw`.)
20
12
 
21
13
  ```
22
14
  $ python pw.py get github
@@ -48,8 +40,12 @@ For development: `python3 -m venv venv && ./venv/bin/pip install -e .`
48
40
  Run `pw` with no arguments for the interactive TUI: unlock, then `/` to
49
41
  filter, Enter to copy the selected password, `n` to add, `e` to edit,
50
42
  `d` to delete, `t` to switch themes (persisted across runs), `q` to quit.
51
- Rarer operations live in the command palette (`ctrl+p`), e.g. changing the
52
- master password.
43
+ The search box matches every word of your query anywhere in an entry —
44
+ name, username, notes, and the password itself — with matches highlighted
45
+ in the theme's accent color (password matches show the row but highlight
46
+ nothing, since passwords are never displayed). Rarer operations live in the
47
+ command palette (`ctrl+p`): importing a browser CSV, changing the master
48
+ password.
53
49
  The bundled themes — muted-slate (default), dawn, matrix — are borrowed with
54
50
  admiration from [tuxedo](https://github.com/webstonehq/tuxedo); Textual's
55
51
  built-in themes are in the picker too.
@@ -66,9 +62,10 @@ Or use the subcommands:
66
62
  | `pw passwd` | change the master password |
67
63
  | `pw ls` | list entry names |
68
64
  | `pw rm github` | delete an entry |
69
- | `pw find bank` | search entry names, usernames, and notes |
65
+ | `pw find "dt bank"` | search names, usernames, notes — every word must match, any order |
70
66
  | `pw gen -l 32` | just print a random password |
71
67
  | `pw import passwords.csv` | import a browser CSV export (see below) |
68
+ | `pw about` | meet the knight |
72
69
 
73
70
  The vault lives at `~/.local/share/pw-manager/vault`. Set the `PW_VAULT`
74
71
  environment variable to put it somewhere else.
@@ -131,9 +128,10 @@ memory for the session; the CLI re-derives the key per command.
131
128
 
132
129
  argparse subcommands over the two modules above. Passwords are copied to the
133
130
  clipboard (`wl-copy`/`xclip`/`xsel`, whichever exists) rather than printed,
134
- so they don't sit in your terminal scrollback. `find` searches names,
131
+ so they don't sit in your terminal scrollback. CLI `find` searches names,
135
132
  usernames, and notes — deliberately *not* passwords, so fragments of secrets
136
- never land in your shell history.
133
+ never land in your shell history; the TUI search box does cover passwords,
134
+ because nothing typed there is logged anywhere.
137
135
 
138
136
  ## Backups
139
137
 
@@ -1,8 +1,29 @@
1
- # pw-manager
1
+ Metadata-Version: 2.4
2
+ Name: basic-password-manager
3
+ Version: 0.2.3
4
+ Summary: The Paladin — a local encrypted password manager that lives in your terminal
5
+ License-Expression: MIT
6
+ Project-URL: Repository, https://github.com/Daisentaur/password-manager
7
+ Project-URL: Changelog, https://github.com/Daisentaur/password-manager/blob/main/CHANGELOG.md
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: argon2-cffi
12
+ Requires-Dist: cryptography
13
+ Requires-Dist: textual
14
+ Dynamic: license-file
15
+
16
+ <p align="center">
17
+ <img src="assets/logo.png" width="160" alt="The Paladin — a pixel-art knight" />
18
+ </p>
19
+
20
+ <h1 align="center">The Paladin</h1>
21
+
22
+ <p align="center"><em>Your passwords, guarded locally. No browser, no cloud, no mercy.</em></p>
2
23
 
3
24
  A local, encrypted password manager that lives in your terminal. No browser,
4
25
  no cloud, no daemon — one encrypted file on disk and a small Python CLI in
5
- front of it.
26
+ front of it. (Installs as `basic-password-manager`; answers to `pw`.)
6
27
 
7
28
  ```
8
29
  $ python pw.py get github
@@ -34,8 +55,12 @@ For development: `python3 -m venv venv && ./venv/bin/pip install -e .`
34
55
  Run `pw` with no arguments for the interactive TUI: unlock, then `/` to
35
56
  filter, Enter to copy the selected password, `n` to add, `e` to edit,
36
57
  `d` to delete, `t` to switch themes (persisted across runs), `q` to quit.
37
- Rarer operations live in the command palette (`ctrl+p`), e.g. changing the
38
- master password.
58
+ The search box matches every word of your query anywhere in an entry —
59
+ name, username, notes, and the password itself — with matches highlighted
60
+ in the theme's accent color (password matches show the row but highlight
61
+ nothing, since passwords are never displayed). Rarer operations live in the
62
+ command palette (`ctrl+p`): importing a browser CSV, changing the master
63
+ password.
39
64
  The bundled themes — muted-slate (default), dawn, matrix — are borrowed with
40
65
  admiration from [tuxedo](https://github.com/webstonehq/tuxedo); Textual's
41
66
  built-in themes are in the picker too.
@@ -52,9 +77,10 @@ Or use the subcommands:
52
77
  | `pw passwd` | change the master password |
53
78
  | `pw ls` | list entry names |
54
79
  | `pw rm github` | delete an entry |
55
- | `pw find bank` | search entry names, usernames, and notes |
80
+ | `pw find "dt bank"` | search names, usernames, notes — every word must match, any order |
56
81
  | `pw gen -l 32` | just print a random password |
57
82
  | `pw import passwords.csv` | import a browser CSV export (see below) |
83
+ | `pw about` | meet the knight |
58
84
 
59
85
  The vault lives at `~/.local/share/pw-manager/vault`. Set the `PW_VAULT`
60
86
  environment variable to put it somewhere else.
@@ -117,9 +143,10 @@ memory for the session; the CLI re-derives the key per command.
117
143
 
118
144
  argparse subcommands over the two modules above. Passwords are copied to the
119
145
  clipboard (`wl-copy`/`xclip`/`xsel`, whichever exists) rather than printed,
120
- so they don't sit in your terminal scrollback. `find` searches names,
146
+ so they don't sit in your terminal scrollback. CLI `find` searches names,
121
147
  usernames, and notes — deliberately *not* passwords, so fragments of secrets
122
- never land in your shell history.
148
+ never land in your shell history; the TUI search box does cover passwords,
149
+ because nothing typed there is logged anywhere.
123
150
 
124
151
  ## Backups
125
152
 
@@ -10,5 +10,6 @@ basic_password_manager.egg-info/top_level.txt
10
10
  pw_manager/__init__.py
11
11
  pw_manager/cli.py
12
12
  pw_manager/crypto.py
13
+ pw_manager/logo.py
13
14
  pw_manager/tui.py
14
15
  pw_manager/vault.py
@@ -47,7 +47,28 @@ def cmd_init(args):
47
47
  sys.exit(f"vault already exists at {VAULT_PATH}")
48
48
  os.makedirs(os.path.dirname(VAULT_PATH), exist_ok=True)
49
49
  vault.save(VAULT_PATH, ask_master(confirm=True), {})
50
- print(f"empty vault created at {VAULT_PATH}")
50
+ if sys.stdout.isatty():
51
+ from . import logo
52
+
53
+ print(logo.says(f"empty vault created at {VAULT_PATH}"))
54
+ else:
55
+ print(f"empty vault created at {VAULT_PATH}")
56
+
57
+
58
+ def cmd_about(args):
59
+ from importlib.metadata import PackageNotFoundError, version
60
+
61
+ try:
62
+ v = version("basic-password-manager")
63
+ except PackageNotFoundError:
64
+ v = "dev" # running from source without an install
65
+ if sys.stdout.isatty():
66
+ from . import logo
67
+
68
+ print(logo.ansi())
69
+ print(f"The Paladin ♞ v{v}")
70
+ print("your passwords, guarded locally — no browser, no cloud, no mercy")
71
+ print("https://github.com/Daisentaur/password-manager")
51
72
 
52
73
 
53
74
  def cmd_add(args):
@@ -135,16 +156,15 @@ def cmd_gen(args):
135
156
 
136
157
 
137
158
  def cmd_find(args):
138
- """Substring search over names, usernames, notes deliberately not over
139
- passwords: a password fragment typed as an argument lands in shell history."""
159
+ """Every word of the query must appear somewhere in name/username/notes.
160
+ Deliberately not over passwords: a password fragment typed as a CLI
161
+ argument lands in shell history (the TUI search does cover passwords)."""
140
162
  entries = vault.load(VAULT_PATH, ask_master())
141
- term = args.term.lower()
163
+ words = args.term.lower().split()
142
164
  hits = {
143
165
  name: e
144
166
  for name, e in sorted(entries.items())
145
- if term in name.lower()
146
- or term in e["user"].lower()
147
- or term in e.get("notes", "").lower()
167
+ if all(w in f"{name} {e['user']} {e.get('notes', '')}".lower() for w in words)
148
168
  }
149
169
  for name, e in hits.items():
150
170
  print(f"{name} ({e['user']})")
@@ -152,12 +172,11 @@ def cmd_find(args):
152
172
  sys.exit(f"nothing matches '{args.term}'")
153
173
 
154
174
 
155
- def cmd_import(args):
156
- """Browser CSV export (Chrome: name,url,username,password / Firefox: url,...)."""
157
- master = ask_master()
158
- entries = vault.load(VAULT_PATH, master)
175
+ def import_csv(csv_file: str, entries: dict) -> int:
176
+ """Merge a browser CSV export (Chrome: name,url,username,password /
177
+ Firefox: url,...) into entries. Returns how many were added."""
159
178
  added = 0
160
- with open(args.csv_file, newline="") as f:
179
+ with open(csv_file, newline="") as f:
161
180
  for row in csv.DictReader(f):
162
181
  name = row.get("name") or urlparse(row.get("url", "")).netloc
163
182
  pw = row.get("password")
@@ -165,6 +184,13 @@ def cmd_import(args):
165
184
  continue
166
185
  entries[name] = {"user": row.get("username", ""), "pw": pw, "notes": ""}
167
186
  added += 1
187
+ return added
188
+
189
+
190
+ def cmd_import(args):
191
+ master = ask_master()
192
+ entries = vault.load(VAULT_PATH, master)
193
+ added = import_csv(args.csv_file, entries)
168
194
  vault.save(VAULT_PATH, master, entries)
169
195
  print(f"imported {added} entries — now delete {args.csv_file} securely")
170
196
 
@@ -193,6 +219,7 @@ def main():
193
219
  f.add_argument("term")
194
220
  i = sub.add_parser("import", help="import a browser CSV export")
195
221
  i.add_argument("csv_file")
222
+ sub.add_parser("about", help="meet the knight")
196
223
 
197
224
  args = p.parse_args()
198
225
  if args.command is None:
@@ -0,0 +1,88 @@
1
+ """The Paladin, as terminal pixel art.
2
+
3
+ Extracted once from assets/logo.png (each letter below is one pixel; dev-time
4
+ script, no image libraries at runtime). Rendered with half-block characters:
5
+ one character cell shows two pixels — top half foreground, bottom half
6
+ background.
7
+ """
8
+
9
+ from rich.style import Style
10
+ from rich.text import Text
11
+
12
+ PALETTE = {
13
+ "k": (0, 0, 0), "s": (140, 139, 147), "w": (194, 191, 198),
14
+ "o": (243, 243, 245), "g": (86, 85, 91), "d": (32, 32, 32),
15
+ "l": (158, 157, 163), "b": (246, 120, 59), "r": (143, 64, 47),
16
+ }
17
+
18
+ ROWS = [
19
+ "......kkk.........",
20
+ ".....kswwkk.......",
21
+ "....wwwwwwwk......",
22
+ "...kwwwwwwwok.....",
23
+ "...kowwwwwwwk.....",
24
+ "...kgdooodgwk.....",
25
+ "...kdddddddwk...k.",
26
+ "...kwwdddwwwk..kok",
27
+ "..kkwwwddwwwk.kgl.",
28
+ ".ookoowddookk.glk.",
29
+ "kgskkkwookkkbblk..",
30
+ "kssswwkkksskkrk...",
31
+ ".kkkssgggwwksk....",
32
+ "...ksswwwwwkk.....",
33
+ "...kgkkkkggk......",
34
+ "....k.....k.......",
35
+ ]
36
+
37
+
38
+ def _pixel_pairs():
39
+ """Yield rows of (top_color, bottom_color) tuples, one per char cell."""
40
+ for top, bottom in zip(ROWS[::2], ROWS[1::2]):
41
+ yield [
42
+ (PALETTE.get(t), PALETTE.get(b))
43
+ for t, b in zip(top, bottom)
44
+ ]
45
+
46
+
47
+ def ansi(indent: str = "") -> str:
48
+ """The knight as a raw ANSI truecolor string, for print()."""
49
+ out = []
50
+ for row in _pixel_pairs():
51
+ line = indent
52
+ for top, bottom in row:
53
+ if top is None and bottom is None:
54
+ line += "\x1b[0m "
55
+ elif bottom is None:
56
+ line += "\x1b[0m\x1b[38;2;%d;%d;%dm▀" % top
57
+ elif top is None:
58
+ line += "\x1b[0m\x1b[38;2;%d;%d;%dm▄" % bottom
59
+ else:
60
+ line += "\x1b[38;2;%d;%d;%dm\x1b[48;2;%d;%d;%dm▀" % (top + bottom)
61
+ out.append(line + "\x1b[0m")
62
+ return "\n".join(out)
63
+
64
+
65
+ def rich_text() -> Text:
66
+ """The knight as a Rich Text, for Textual widgets."""
67
+ text = Text()
68
+ for row in _pixel_pairs():
69
+ for top, bottom in row:
70
+ if top is None and bottom is None:
71
+ text.append(" ")
72
+ elif bottom is None:
73
+ text.append("▀", Style(color="rgb(%d,%d,%d)" % top))
74
+ elif top is None:
75
+ text.append("▄", Style(color="rgb(%d,%d,%d)" % bottom))
76
+ else:
77
+ text.append("▀", Style(color="rgb(%d,%d,%d)" % top, bgcolor="rgb(%d,%d,%d)" % bottom))
78
+ text.append("\n")
79
+ return text
80
+
81
+
82
+ def says(message: str) -> str:
83
+ """Cowsay, but it's a knight."""
84
+ border = "_" * (len(message) + 2)
85
+ return (
86
+ f" {border}\n< {message} >\n {'-' * (len(message) + 2)}\n"
87
+ f" \\\n" + ansi(indent=" ")
88
+ )
@@ -5,21 +5,26 @@ src/theme.rs); "muted-slate" is the default. `t` opens the theme picker,
5
5
  and Textual's own built-in themes are available there too.
6
6
  """
7
7
 
8
+ import csv
8
9
  import os
9
- from typing import Iterable
10
10
 
11
- from textual.app import App, ComposeResult, SystemCommand
11
+ from rich.text import Text
12
+ from textual.app import App, ComposeResult
12
13
  from textual.binding import Binding
14
+ from textual.command import DiscoveryHit, Hit, Hits, Provider
13
15
  from textual.containers import Vertical
14
16
  from textual.screen import ModalScreen, Screen
15
17
  from textual.theme import Theme
16
18
  from textual.widgets import DataTable, Footer, Input, Label, Static
17
19
 
18
- from . import vault
19
- from .cli import VAULT_PATH, generate, to_clipboard
20
+ from . import logo, vault
21
+ from .cli import VAULT_PATH, generate, import_csv, to_clipboard
20
22
 
21
23
  THEME_FILE = os.path.expanduser("~/.local/share/pw-manager/theme")
22
24
 
25
+ APP_NAME = "The Paladin"
26
+ APP_ICON = "♞"
27
+
23
28
  TUXEDO_THEMES = [
24
29
  Theme(
25
30
  name="muted-slate",
@@ -71,7 +76,8 @@ class UnlockScreen(Screen):
71
76
 
72
77
  def compose(self) -> ComposeResult:
73
78
  with Vertical(id="unlock-box"):
74
- yield Label("pw-manager", id="unlock-title")
79
+ yield Static(logo.rich_text(), id="unlock-logo")
80
+ yield Label(f"{APP_ICON} {APP_NAME}", id="unlock-title")
75
81
  yield Input(password=True, placeholder="master password", id="master")
76
82
  yield Static("", id="unlock-error")
77
83
 
@@ -126,6 +132,22 @@ class EntryModal(ModalScreen):
126
132
  )
127
133
 
128
134
 
135
+ class ImportModal(ModalScreen):
136
+ """Ask for the path of a browser CSV export."""
137
+
138
+ BINDINGS = [Binding("escape", "dismiss", "cancel")]
139
+
140
+ def compose(self) -> ComposeResult:
141
+ with Vertical(classes="modal-box"):
142
+ yield Label("import browser CSV", classes="modal-title")
143
+ yield Input(placeholder="path to export, e.g. ~/Downloads/passwords.csv", id="i-path")
144
+ yield Static("enter imports · esc cancels", classes="modal-hint")
145
+
146
+ def on_input_submitted(self, event: Input.Submitted) -> None:
147
+ if event.value.strip():
148
+ self.dismiss(os.path.expanduser(event.value.strip()))
149
+
150
+
129
151
  class PasswdModal(ModalScreen):
130
152
  """Change the master password. Asks for the current one again so a
131
153
  walked-up-to unlocked session can't lock the real owner out."""
@@ -194,6 +216,7 @@ class MainScreen(Screen):
194
216
  ]
195
217
 
196
218
  def compose(self) -> ComposeResult:
219
+ yield Static(f" {APP_ICON} {APP_NAME}", id="topbar")
197
220
  yield Input(placeholder="/ to search", id="search")
198
221
  yield DataTable(id="table", cursor_type="row")
199
222
  yield Footer()
@@ -203,18 +226,40 @@ class MainScreen(Screen):
203
226
  table.add_columns("name", "username", "notes")
204
227
  self.refresh_table()
205
228
  table.focus()
229
+ # match-highlight color comes from the theme, so repaint on switch
230
+ self.app.theme_changed_signal.subscribe(self, self._on_theme_change)
231
+
232
+ def _on_theme_change(self, _theme: Theme) -> None:
233
+ self.refresh_table()
234
+
235
+ @staticmethod
236
+ def _highlight(value: str, words: list[str], style: str) -> Text:
237
+ text = Text(value)
238
+ low = value.lower()
239
+ for w in words:
240
+ start = 0
241
+ while (i := low.find(w, start)) != -1:
242
+ text.stylize(style, i, i + len(w))
243
+ start = i + 1
244
+ return text
206
245
 
207
246
  def refresh_table(self) -> None:
247
+ """Every search word must appear somewhere in the entry — name,
248
+ username, notes, or the password itself. Passwords are searched but
249
+ never displayed, so matches there can't be highlighted (by design)."""
208
250
  table = self.query_one(DataTable)
209
251
  table.clear()
210
- term = self.query_one("#search", Input).value.lower()
252
+ words = self.query_one("#search", Input).value.lower().split()
253
+ style = f"bold {self.app.current_theme.primary}"
211
254
  for name, e in sorted(self.app.entries.items()):
212
- if (
213
- term in name.lower()
214
- or term in e["user"].lower()
215
- or term in e.get("notes", "").lower()
216
- ):
217
- table.add_row(name, e["user"], e.get("notes", ""), key=name)
255
+ hay = f"{name} {e['user']} {e.get('notes', '')} {e['pw']}".lower()
256
+ if all(w in hay for w in words):
257
+ table.add_row(
258
+ self._highlight(name, words, style),
259
+ self._highlight(e["user"], words, style),
260
+ self._highlight(e.get("notes", ""), words, style),
261
+ key=name,
262
+ )
218
263
 
219
264
  def save(self) -> None:
220
265
  vault.save(VAULT_PATH, self.app.master, self.app.entries)
@@ -295,13 +340,46 @@ class MainScreen(Screen):
295
340
  self.query_one(DataTable).focus()
296
341
 
297
342
 
343
+ class VaultCommands(Provider):
344
+ """Palette commands for the unlocked vault. Unlike Textual's built-in
345
+ provider, the query matches descriptions too, not just titles."""
346
+
347
+ @property
348
+ def _commands(self):
349
+ if not isinstance(self.screen, MainScreen):
350
+ return []
351
+ app = self.app
352
+ return [
353
+ ("Import browser CSV", "add every entry from a Chrome/Firefox password export", app._import_csv),
354
+ ("Change master password", "re-encrypt the vault under a new master password", app._change_master),
355
+ ]
356
+
357
+ async def search(self, query: str) -> Hits:
358
+ matcher = self.matcher(query)
359
+ for title, help_text, callback in self._commands:
360
+ score = matcher.match(f"{title} {help_text}")
361
+ if score > 0:
362
+ yield Hit(score, matcher.highlight(title), callback, help=help_text)
363
+
364
+ async def discover(self) -> Hits:
365
+ for title, help_text, callback in self._commands:
366
+ yield DiscoveryHit(title, callback, help=help_text)
367
+
368
+
298
369
  class PwApp(App):
299
- TITLE = "pw-manager"
370
+ TITLE = APP_NAME
371
+ COMMANDS = App.COMMANDS | {VaultCommands}
300
372
 
301
373
  # Colors come from $theme-variables so every theme applies everywhere;
302
374
  # widgets without rules here (table, footer, toasts) use Textual's own
303
375
  # theme-aware defaults.
304
376
  CSS = """
377
+ #topbar {
378
+ height: 1;
379
+ background: $panel;
380
+ color: $primary;
381
+ text-style: bold;
382
+ }
305
383
  #unlock-box {
306
384
  align: center middle;
307
385
  width: 44;
@@ -310,7 +388,14 @@ class PwApp(App):
310
388
  background: $surface;
311
389
  border: round $panel;
312
390
  }
391
+ #unlock-logo {
392
+ width: 100%;
393
+ text-align: center;
394
+ margin-bottom: 1;
395
+ }
313
396
  #unlock-title {
397
+ width: 100%;
398
+ text-align: center;
314
399
  color: $primary;
315
400
  text-style: bold;
316
401
  margin-bottom: 1;
@@ -372,14 +457,21 @@ class PwApp(App):
372
457
  with open(THEME_FILE, "w") as f:
373
458
  f.write(theme.name)
374
459
 
375
- def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
376
- yield from super().get_system_commands(screen)
377
- if isinstance(screen, MainScreen):
378
- yield SystemCommand(
379
- "Change master password",
380
- "re-encrypt the vault under a new master password",
381
- self._change_master,
382
- )
460
+ def _import_csv(self) -> None:
461
+ def done(path: str | None) -> None:
462
+ if not path:
463
+ return
464
+ try:
465
+ added = import_csv(path, self.entries)
466
+ except (OSError, csv.Error) as e:
467
+ self.notify(str(e), severity="error")
468
+ return
469
+ vault.save(VAULT_PATH, self.master, self.entries)
470
+ if isinstance(self.screen, MainScreen):
471
+ self.screen.refresh_table()
472
+ self.notify(f"imported {added} entries — delete the CSV securely")
473
+
474
+ self.push_screen(ImportModal(), done)
383
475
 
384
476
  def _change_master(self) -> None:
385
477
  def done(new: str | None) -> None:
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "basic-password-manager"
7
- version = "0.2.1"
8
- description = "Local encrypted password manager that lives in your terminal"
7
+ version = "0.2.3"
8
+ description = "The Paladin — a local encrypted password manager that lives in your terminal"
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  requires-python = ">=3.10"
@@ -15,8 +15,12 @@ dependencies = [
15
15
  "textual",
16
16
  ]
17
17
 
18
+ [tool.setuptools]
19
+ packages = ["pw_manager"]
20
+
18
21
  [project.scripts]
19
22
  pw = "pw_manager.cli:main"
20
23
 
21
24
  [project.urls]
22
25
  Repository = "https://github.com/Daisentaur/password-manager"
26
+ Changelog = "https://github.com/Daisentaur/password-manager/blob/main/CHANGELOG.md"