basic-password-manager 0.1.0__tar.gz → 0.2.1__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 (16) hide show
  1. {basic_password_manager-0.1.0 → basic_password_manager-0.2.1}/LICENSE +1 -1
  2. {basic_password_manager-0.1.0 → basic_password_manager-0.2.1}/PKG-INFO +20 -2
  3. {basic_password_manager-0.1.0 → basic_password_manager-0.2.1}/README.md +18 -1
  4. {basic_password_manager-0.1.0 → basic_password_manager-0.2.1}/basic_password_manager.egg-info/PKG-INFO +20 -2
  5. {basic_password_manager-0.1.0 → basic_password_manager-0.2.1}/basic_password_manager.egg-info/SOURCES.txt +1 -0
  6. {basic_password_manager-0.1.0 → basic_password_manager-0.2.1}/basic_password_manager.egg-info/requires.txt +1 -0
  7. {basic_password_manager-0.1.0 → basic_password_manager-0.2.1}/pw_manager/cli.py +40 -2
  8. basic_password_manager-0.2.1/pw_manager/tui.py +399 -0
  9. {basic_password_manager-0.1.0 → basic_password_manager-0.2.1}/pyproject.toml +2 -1
  10. {basic_password_manager-0.1.0 → basic_password_manager-0.2.1}/basic_password_manager.egg-info/dependency_links.txt +0 -0
  11. {basic_password_manager-0.1.0 → basic_password_manager-0.2.1}/basic_password_manager.egg-info/entry_points.txt +0 -0
  12. {basic_password_manager-0.1.0 → basic_password_manager-0.2.1}/basic_password_manager.egg-info/top_level.txt +0 -0
  13. {basic_password_manager-0.1.0 → basic_password_manager-0.2.1}/pw_manager/__init__.py +0 -0
  14. {basic_password_manager-0.1.0 → basic_password_manager-0.2.1}/pw_manager/crypto.py +0 -0
  15. {basic_password_manager-0.1.0 → basic_password_manager-0.2.1}/pw_manager/vault.py +0 -0
  16. {basic_password_manager-0.1.0 → basic_password_manager-0.2.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Darsh Thakur (Daisentaur)
3
+ Copyright (c) 2026 Daisentaur
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: basic-password-manager
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: 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
@@ -9,6 +9,7 @@ Description-Content-Type: text/markdown
9
9
  License-File: LICENSE
10
10
  Requires-Dist: argon2-cffi
11
11
  Requires-Dist: cryptography
12
+ Requires-Dist: textual
12
13
  Dynamic: license-file
13
14
 
14
15
  # pw-manager
@@ -44,12 +45,25 @@ For development: `python3 -m venv venv && ./venv/bin/pip install -e .`
44
45
 
45
46
  ## Usage
46
47
 
48
+ Run `pw` with no arguments for the interactive TUI: unlock, then `/` to
49
+ filter, Enter to copy the selected password, `n` to add, `e` to edit,
50
+ `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.
53
+ The bundled themes — muted-slate (default), dawn, matrix — are borrowed with
54
+ admiration from [tuxedo](https://github.com/webstonehq/tuxedo); Textual's
55
+ built-in themes are in the picker too.
56
+
57
+ Or use the subcommands:
58
+
47
59
  | Command | What it does |
48
60
  |---|---|
49
61
  | `pw init` | create a new empty vault |
50
62
  | `pw add github` | store an entry (prompts for username/password/notes) |
51
63
  | `pw add github --gen` | same, but generates a strong random password |
52
64
  | `pw get github` | copy the password to the clipboard, show the username |
65
+ | `pw edit github` | update an entry field by field (Enter keeps current) |
66
+ | `pw passwd` | change the master password |
53
67
  | `pw ls` | list entry names |
54
68
  | `pw rm github` | delete an entry |
55
69
  | `pw find bank` | search entry names, usernames, and notes |
@@ -109,7 +123,11 @@ can't destroy the vault.
109
123
  Your master password is never stored anywhere, in any form. "Wrong password"
110
124
  is detected purely by GCM authentication failing.
111
125
 
112
- ### 3. `cli.py` — the CLI
126
+ ### 3. `cli.py` and `tui.py` — the interfaces
127
+
128
+ Both are thin skins over the same two modules — `tui.py` (built with
129
+ [Textual](https://textual.textualize.io)) holds the decrypted entries in
130
+ memory for the session; the CLI re-derives the key per command.
113
131
 
114
132
  argparse subcommands over the two modules above. Passwords are copied to the
115
133
  clipboard (`wl-copy`/`xclip`/`xsel`, whichever exists) rather than printed,
@@ -31,12 +31,25 @@ For development: `python3 -m venv venv && ./venv/bin/pip install -e .`
31
31
 
32
32
  ## Usage
33
33
 
34
+ Run `pw` with no arguments for the interactive TUI: unlock, then `/` to
35
+ filter, Enter to copy the selected password, `n` to add, `e` to edit,
36
+ `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.
39
+ The bundled themes — muted-slate (default), dawn, matrix — are borrowed with
40
+ admiration from [tuxedo](https://github.com/webstonehq/tuxedo); Textual's
41
+ built-in themes are in the picker too.
42
+
43
+ Or use the subcommands:
44
+
34
45
  | Command | What it does |
35
46
  |---|---|
36
47
  | `pw init` | create a new empty vault |
37
48
  | `pw add github` | store an entry (prompts for username/password/notes) |
38
49
  | `pw add github --gen` | same, but generates a strong random password |
39
50
  | `pw get github` | copy the password to the clipboard, show the username |
51
+ | `pw edit github` | update an entry field by field (Enter keeps current) |
52
+ | `pw passwd` | change the master password |
40
53
  | `pw ls` | list entry names |
41
54
  | `pw rm github` | delete an entry |
42
55
  | `pw find bank` | search entry names, usernames, and notes |
@@ -96,7 +109,11 @@ can't destroy the vault.
96
109
  Your master password is never stored anywhere, in any form. "Wrong password"
97
110
  is detected purely by GCM authentication failing.
98
111
 
99
- ### 3. `cli.py` — the CLI
112
+ ### 3. `cli.py` and `tui.py` — the interfaces
113
+
114
+ Both are thin skins over the same two modules — `tui.py` (built with
115
+ [Textual](https://textual.textualize.io)) holds the decrypted entries in
116
+ memory for the session; the CLI re-derives the key per command.
100
117
 
101
118
  argparse subcommands over the two modules above. Passwords are copied to the
102
119
  clipboard (`wl-copy`/`xclip`/`xsel`, whichever exists) rather than printed,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: basic-password-manager
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: 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
@@ -9,6 +9,7 @@ Description-Content-Type: text/markdown
9
9
  License-File: LICENSE
10
10
  Requires-Dist: argon2-cffi
11
11
  Requires-Dist: cryptography
12
+ Requires-Dist: textual
12
13
  Dynamic: license-file
13
14
 
14
15
  # pw-manager
@@ -44,12 +45,25 @@ For development: `python3 -m venv venv && ./venv/bin/pip install -e .`
44
45
 
45
46
  ## Usage
46
47
 
48
+ Run `pw` with no arguments for the interactive TUI: unlock, then `/` to
49
+ filter, Enter to copy the selected password, `n` to add, `e` to edit,
50
+ `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.
53
+ The bundled themes — muted-slate (default), dawn, matrix — are borrowed with
54
+ admiration from [tuxedo](https://github.com/webstonehq/tuxedo); Textual's
55
+ built-in themes are in the picker too.
56
+
57
+ Or use the subcommands:
58
+
47
59
  | Command | What it does |
48
60
  |---|---|
49
61
  | `pw init` | create a new empty vault |
50
62
  | `pw add github` | store an entry (prompts for username/password/notes) |
51
63
  | `pw add github --gen` | same, but generates a strong random password |
52
64
  | `pw get github` | copy the password to the clipboard, show the username |
65
+ | `pw edit github` | update an entry field by field (Enter keeps current) |
66
+ | `pw passwd` | change the master password |
53
67
  | `pw ls` | list entry names |
54
68
  | `pw rm github` | delete an entry |
55
69
  | `pw find bank` | search entry names, usernames, and notes |
@@ -109,7 +123,11 @@ can't destroy the vault.
109
123
  Your master password is never stored anywhere, in any form. "Wrong password"
110
124
  is detected purely by GCM authentication failing.
111
125
 
112
- ### 3. `cli.py` — the CLI
126
+ ### 3. `cli.py` and `tui.py` — the interfaces
127
+
128
+ Both are thin skins over the same two modules — `tui.py` (built with
129
+ [Textual](https://textual.textualize.io)) holds the decrypted entries in
130
+ memory for the session; the CLI re-derives the key per command.
113
131
 
114
132
  argparse subcommands over the two modules above. Passwords are copied to the
115
133
  clipboard (`wl-copy`/`xclip`/`xsel`, whichever exists) rather than printed,
@@ -10,4 +10,5 @@ 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/tui.py
13
14
  pw_manager/vault.py
@@ -84,6 +84,34 @@ def cmd_get(args):
84
84
  print(f"password: {e['pw']}")
85
85
 
86
86
 
87
+ def cmd_edit(args):
88
+ """Per-field update; Enter keeps the shown current value."""
89
+ master = ask_master()
90
+ entries = vault.load(VAULT_PATH, master)
91
+ if args.name not in entries:
92
+ sys.exit(f"no entry '{args.name}' — try 'ls'")
93
+ e = entries[args.name]
94
+ user = input(f"username [{e['user']}]: ") or e["user"]
95
+ pw = getpass.getpass("password (empty = keep current): ") or e["pw"]
96
+ notes = input(f"notes [{e.get('notes', '')}]: ") or e.get("notes", "")
97
+ entries[args.name] = {"user": user, "pw": pw, "notes": notes}
98
+ vault.save(VAULT_PATH, master, entries)
99
+ print(f"updated '{args.name}'")
100
+
101
+
102
+ def cmd_passwd(args):
103
+ """Change the master password: decrypt with old, re-encrypt with new."""
104
+ entries = vault.load(VAULT_PATH, ask_master())
105
+ print("choose a new master password")
106
+ new = getpass.getpass("new master password: ")
107
+ if getpass.getpass("confirm new master password: ") != new:
108
+ sys.exit("passwords do not match")
109
+ if len(new) < 8:
110
+ sys.exit("master password must be at least 8 characters")
111
+ vault.save(VAULT_PATH, new, entries)
112
+ print("master password changed (old one is now useless)")
113
+
114
+
87
115
  def cmd_ls(args):
88
116
  entries = vault.load(VAULT_PATH, ask_master())
89
117
  for name in sorted(entries):
@@ -142,8 +170,10 @@ def cmd_import(args):
142
170
 
143
171
 
144
172
  def main():
145
- p = argparse.ArgumentParser(description="local encrypted password manager")
146
- sub = p.add_subparsers(dest="command", required=True)
173
+ p = argparse.ArgumentParser(
174
+ description="local encrypted password manager (run bare for the TUI)"
175
+ )
176
+ sub = p.add_subparsers(dest="command")
147
177
 
148
178
  sub.add_parser("init", help="create a new empty vault")
149
179
  a = sub.add_parser("add", help="add or update an entry")
@@ -151,6 +181,9 @@ def main():
151
181
  a.add_argument("--gen", action="store_true", help="generate the password")
152
182
  g = sub.add_parser("get", help="copy an entry's password to clipboard")
153
183
  g.add_argument("name")
184
+ e = sub.add_parser("edit", help="update an entry field by field")
185
+ e.add_argument("name")
186
+ sub.add_parser("passwd", help="change the master password")
154
187
  sub.add_parser("ls", help="list entry names")
155
188
  r = sub.add_parser("rm", help="delete an entry")
156
189
  r.add_argument("name")
@@ -162,6 +195,11 @@ def main():
162
195
  i.add_argument("csv_file")
163
196
 
164
197
  args = p.parse_args()
198
+ if args.command is None:
199
+ from . import tui # imported lazily: textual is heavy, subcommands skip it
200
+
201
+ tui.main()
202
+ return
165
203
  try:
166
204
  globals()[f"cmd_{args.command}"](args)
167
205
  except vault.VaultError as e:
@@ -0,0 +1,399 @@
1
+ """Interactive TUI, launched by running `pw` with no arguments.
2
+
3
+ Theme palettes copied from tuxedo (github.com/webstonehq/tuxedo,
4
+ src/theme.rs); "muted-slate" is the default. `t` opens the theme picker,
5
+ and Textual's own built-in themes are available there too.
6
+ """
7
+
8
+ import os
9
+ from typing import Iterable
10
+
11
+ from textual.app import App, ComposeResult, SystemCommand
12
+ from textual.binding import Binding
13
+ from textual.containers import Vertical
14
+ from textual.screen import ModalScreen, Screen
15
+ from textual.theme import Theme
16
+ from textual.widgets import DataTable, Footer, Input, Label, Static
17
+
18
+ from . import vault
19
+ from .cli import VAULT_PATH, generate, to_clipboard
20
+
21
+ THEME_FILE = os.path.expanduser("~/.local/share/pw-manager/theme")
22
+
23
+ TUXEDO_THEMES = [
24
+ Theme(
25
+ name="muted-slate",
26
+ primary="#8aa9c9",
27
+ secondary="#7fb3a8",
28
+ accent="#9a8fc4",
29
+ foreground="#c8ccd4",
30
+ background="#1a1d23",
31
+ surface="#1f232b",
32
+ panel="#252a33",
33
+ success="#7aa67a",
34
+ warning="#d4b06a",
35
+ error="#e07a7a",
36
+ dark=True,
37
+ ),
38
+ Theme(
39
+ name="dawn",
40
+ primary="#a35d3a",
41
+ secondary="#3a7a6a",
42
+ accent="#7a4a8a",
43
+ foreground="#3d3528",
44
+ background="#faf6f0",
45
+ surface="#f3ede2",
46
+ panel="#ede2cc",
47
+ success="#5a7a3a",
48
+ warning="#a3722a",
49
+ error="#b8483a",
50
+ dark=False,
51
+ ),
52
+ Theme(
53
+ name="matrix",
54
+ primary="#9fff9f",
55
+ secondary="#7fcc7f",
56
+ accent="#cf9fff",
57
+ foreground="#7fcc7f",
58
+ background="#0a120a",
59
+ surface="#0f1a0f",
60
+ panel="#101c10",
61
+ success="#9fff9f",
62
+ warning="#ffd66e",
63
+ error="#ff8c8c",
64
+ dark=True,
65
+ ),
66
+ ]
67
+
68
+
69
+ class UnlockScreen(Screen):
70
+ """Master password prompt shown before anything else."""
71
+
72
+ def compose(self) -> ComposeResult:
73
+ with Vertical(id="unlock-box"):
74
+ yield Label("pw-manager", id="unlock-title")
75
+ yield Input(password=True, placeholder="master password", id="master")
76
+ yield Static("", id="unlock-error")
77
+
78
+ def on_input_submitted(self, event: Input.Submitted) -> None:
79
+ self.query_one("#unlock-error", Static).update("unlocking…")
80
+ try:
81
+ self.app.entries = vault.load(VAULT_PATH, event.value)
82
+ except vault.VaultError as e:
83
+ self.query_one("#unlock-error", Static).update(str(e))
84
+ self.query_one("#master", Input).clear()
85
+ return
86
+ self.app.master = event.value
87
+ self.app.push_screen(MainScreen())
88
+
89
+
90
+ class EntryModal(ModalScreen):
91
+ """Add or edit an entry. Empty password = generate (add) / keep current (edit)."""
92
+
93
+ BINDINGS = [Binding("escape", "dismiss", "cancel")]
94
+
95
+ def __init__(self, name: str = "", entry: dict | None = None) -> None:
96
+ super().__init__()
97
+ self.initial_name = name
98
+ self.entry = entry or {}
99
+
100
+ def compose(self) -> ComposeResult:
101
+ editing = bool(self.entry)
102
+ with Vertical(classes="modal-box"):
103
+ yield Label("edit entry" if editing else "new entry", classes="modal-title")
104
+ yield Input(value=self.initial_name, placeholder="name (e.g. github)", id="e-name")
105
+ yield Input(value=self.entry.get("user", ""), placeholder="username", id="e-user")
106
+ yield Input(
107
+ password=True,
108
+ placeholder="password (empty = keep current)" if editing else "password (empty = generate)",
109
+ id="e-pw",
110
+ )
111
+ yield Input(value=self.entry.get("notes", ""), placeholder="notes (optional)", id="e-notes")
112
+ yield Static("enter saves · esc cancels", classes="modal-hint")
113
+
114
+ def on_input_submitted(self, event: Input.Submitted) -> None:
115
+ name = self.query_one("#e-name", Input).value.strip()
116
+ if not name:
117
+ self.query_one("#e-name", Input).focus()
118
+ return
119
+ self.dismiss(
120
+ {
121
+ "name": name,
122
+ "user": self.query_one("#e-user", Input).value,
123
+ "pw": self.query_one("#e-pw", Input).value or self.entry.get("pw") or generate(),
124
+ "notes": self.query_one("#e-notes", Input).value,
125
+ }
126
+ )
127
+
128
+
129
+ class PasswdModal(ModalScreen):
130
+ """Change the master password. Asks for the current one again so a
131
+ walked-up-to unlocked session can't lock the real owner out."""
132
+
133
+ BINDINGS = [Binding("escape", "dismiss", "cancel")]
134
+
135
+ def compose(self) -> ComposeResult:
136
+ with Vertical(classes="modal-box"):
137
+ yield Label("change master password", classes="modal-title")
138
+ yield Input(password=True, placeholder="current master password", id="p-current")
139
+ yield Input(password=True, placeholder="new master password (8+ chars)", id="p-new")
140
+ yield Input(password=True, placeholder="confirm new master password", id="p-confirm")
141
+ yield Static("enter saves · esc cancels", classes="modal-hint")
142
+ yield Static("", id="p-error")
143
+
144
+ def on_input_submitted(self, event: Input.Submitted) -> None:
145
+ current = self.query_one("#p-current", Input).value
146
+ new = self.query_one("#p-new", Input).value
147
+ error = self.query_one("#p-error", Static)
148
+ if current != self.app.master:
149
+ error.update("current master password is wrong")
150
+ return
151
+ if len(new) < 8:
152
+ error.update("new master password must be at least 8 characters")
153
+ return
154
+ if new != self.query_one("#p-confirm", Input).value:
155
+ error.update("new passwords do not match")
156
+ return
157
+ self.dismiss(new)
158
+
159
+
160
+ class ConfirmModal(ModalScreen):
161
+ """y/n confirmation."""
162
+
163
+ BINDINGS = [
164
+ Binding("y", "yes", "yes"),
165
+ Binding("n,escape", "no", "no"),
166
+ ]
167
+
168
+ def __init__(self, question: str) -> None:
169
+ super().__init__()
170
+ self.question = question
171
+
172
+ def compose(self) -> ComposeResult:
173
+ with Vertical(classes="modal-box"):
174
+ yield Label(self.question, classes="modal-title")
175
+ yield Static("y confirms · n cancels", classes="modal-hint")
176
+
177
+ def action_yes(self) -> None:
178
+ self.dismiss(True)
179
+
180
+ def action_no(self) -> None:
181
+ self.dismiss(False)
182
+
183
+
184
+ class MainScreen(Screen):
185
+ BINDINGS = [
186
+ Binding("enter", "copy", "copy password", priority=True),
187
+ Binding("n", "new", "new entry"),
188
+ Binding("e", "edit", "edit"),
189
+ Binding("d", "delete", "delete"),
190
+ Binding("slash", "search", "search"),
191
+ Binding("t", "app.change_theme", "theme"),
192
+ Binding("escape", "clear_search", show=False),
193
+ Binding("q", "quit", "quit"),
194
+ ]
195
+
196
+ def compose(self) -> ComposeResult:
197
+ yield Input(placeholder="/ to search", id="search")
198
+ yield DataTable(id="table", cursor_type="row")
199
+ yield Footer()
200
+
201
+ def on_mount(self) -> None:
202
+ table = self.query_one(DataTable)
203
+ table.add_columns("name", "username", "notes")
204
+ self.refresh_table()
205
+ table.focus()
206
+
207
+ def refresh_table(self) -> None:
208
+ table = self.query_one(DataTable)
209
+ table.clear()
210
+ term = self.query_one("#search", Input).value.lower()
211
+ 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)
218
+
219
+ def save(self) -> None:
220
+ vault.save(VAULT_PATH, self.app.master, self.app.entries)
221
+
222
+ def selected_name(self) -> str | None:
223
+ table = self.query_one(DataTable)
224
+ if table.row_count == 0:
225
+ return None
226
+ return table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value
227
+
228
+ def on_input_changed(self, event: Input.Changed) -> None:
229
+ self.refresh_table()
230
+
231
+ def on_input_submitted(self, event: Input.Submitted) -> None:
232
+ self.query_one(DataTable).focus()
233
+
234
+ def action_copy(self) -> None:
235
+ name = self.selected_name()
236
+ if name is None:
237
+ return
238
+ if to_clipboard(self.app.entries[name]["pw"]):
239
+ self.notify(f"password for '{name}' copied", severity="information")
240
+ else:
241
+ self.notify("no clipboard tool (install wl-clipboard)", severity="error")
242
+
243
+ def action_new(self) -> None:
244
+ def done(entry: dict | None) -> None:
245
+ if entry:
246
+ name = entry.pop("name")
247
+ self.app.entries[name] = entry
248
+ self.save()
249
+ self.refresh_table()
250
+ self.notify(f"stored '{name}'")
251
+
252
+ self.app.push_screen(EntryModal(), done)
253
+
254
+ def action_edit(self) -> None:
255
+ name = self.selected_name()
256
+ if name is None:
257
+ return
258
+
259
+ def done(entry: dict | None) -> None:
260
+ if not entry:
261
+ return
262
+ new_name = entry.pop("name")
263
+ if new_name != name:
264
+ if new_name in self.app.entries:
265
+ self.notify(f"'{new_name}' already exists", severity="error")
266
+ return
267
+ del self.app.entries[name]
268
+ self.app.entries[new_name] = entry
269
+ self.save()
270
+ self.refresh_table()
271
+ self.notify(f"updated '{new_name}'")
272
+
273
+ self.app.push_screen(EntryModal(name, dict(self.app.entries[name])), done)
274
+
275
+ def action_delete(self) -> None:
276
+ name = self.selected_name()
277
+ if name is None:
278
+ return
279
+
280
+ def done(confirmed: bool | None) -> None:
281
+ if confirmed:
282
+ del self.app.entries[name]
283
+ self.save()
284
+ self.refresh_table()
285
+ self.notify(f"removed '{name}'")
286
+
287
+ self.app.push_screen(ConfirmModal(f"delete '{name}'?"), done)
288
+
289
+ def action_search(self) -> None:
290
+ self.query_one("#search", Input).focus()
291
+
292
+ def action_clear_search(self) -> None:
293
+ search = self.query_one("#search", Input)
294
+ search.clear()
295
+ self.query_one(DataTable).focus()
296
+
297
+
298
+ class PwApp(App):
299
+ TITLE = "pw-manager"
300
+
301
+ # Colors come from $theme-variables so every theme applies everywhere;
302
+ # widgets without rules here (table, footer, toasts) use Textual's own
303
+ # theme-aware defaults.
304
+ CSS = """
305
+ #unlock-box {
306
+ align: center middle;
307
+ width: 44;
308
+ height: auto;
309
+ padding: 1 2;
310
+ background: $surface;
311
+ border: round $panel;
312
+ }
313
+ #unlock-title {
314
+ color: $primary;
315
+ text-style: bold;
316
+ margin-bottom: 1;
317
+ }
318
+ #unlock-error {
319
+ color: $error;
320
+ margin-top: 1;
321
+ }
322
+ UnlockScreen {
323
+ align: center middle;
324
+ }
325
+ Input {
326
+ background: $surface;
327
+ border: round $panel;
328
+ }
329
+ Input:focus {
330
+ border: round $primary;
331
+ }
332
+ ModalScreen {
333
+ align: center middle;
334
+ background: $background 60%;
335
+ }
336
+ .modal-box {
337
+ width: 52;
338
+ height: auto;
339
+ padding: 1 2;
340
+ background: $surface;
341
+ border: round $primary;
342
+ }
343
+ .modal-title {
344
+ color: $primary;
345
+ text-style: bold;
346
+ margin-bottom: 1;
347
+ }
348
+ .modal-hint {
349
+ color: $text-muted;
350
+ margin-top: 1;
351
+ }
352
+ """
353
+
354
+ def __init__(self) -> None:
355
+ super().__init__()
356
+ self.entries: dict = {}
357
+ self.master: str = ""
358
+
359
+ def on_mount(self) -> None:
360
+ for theme in TUXEDO_THEMES:
361
+ self.register_theme(theme)
362
+ try:
363
+ saved = open(THEME_FILE).read().strip()
364
+ except OSError:
365
+ saved = ""
366
+ self.theme = saved if saved in self.available_themes else "muted-slate"
367
+ self.theme_changed_signal.subscribe(self, self._remember_theme)
368
+ self.push_screen(UnlockScreen())
369
+
370
+ def _remember_theme(self, theme: Theme) -> None:
371
+ os.makedirs(os.path.dirname(THEME_FILE), exist_ok=True)
372
+ with open(THEME_FILE, "w") as f:
373
+ f.write(theme.name)
374
+
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
+ )
383
+
384
+ def _change_master(self) -> None:
385
+ def done(new: str | None) -> None:
386
+ if new:
387
+ self.master = new
388
+ vault.save(VAULT_PATH, self.master, self.entries)
389
+ self.notify("master password changed (old one is now useless)")
390
+
391
+ self.push_screen(PasswdModal(), done)
392
+
393
+
394
+ def main() -> None:
395
+ PwApp().run()
396
+
397
+
398
+ if __name__ == "__main__":
399
+ main()
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "basic-password-manager"
7
- version = "0.1.0"
7
+ version = "0.2.1"
8
8
  description = "Local encrypted password manager that lives in your terminal"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -12,6 +12,7 @@ requires-python = ">=3.10"
12
12
  dependencies = [
13
13
  "argon2-cffi",
14
14
  "cryptography",
15
+ "textual",
15
16
  ]
16
17
 
17
18
  [project.scripts]