authenticator-tui 1.0.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.
File without changes
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from .app import AuthenticatorApp
7
+
8
+
9
+ def main() -> None:
10
+ file_path = Path(sys.argv[1]).expanduser() if len(sys.argv) > 1 else None
11
+ app = AuthenticatorApp(file_path=file_path)
12
+ app.run()
13
+
14
+
15
+ if __name__ == "__main__":
16
+ main()
@@ -0,0 +1,300 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from textual.app import App, ComposeResult
6
+ from textual.binding import Binding
7
+ from textual.containers import Vertical, Horizontal
8
+ from textual.screen import ModalScreen
9
+ from textual.widgets import Button, Footer, Header, Input, Label, ListView
10
+
11
+ from .models import OTPEntry
12
+ from .otp_file import load_file, save_file
13
+ from .widgets.otp_item import OTPItem
14
+ from .widgets.add_modal import AddOTPModal
15
+ from .widgets.big_code_modal import BigCodeModal
16
+
17
+
18
+ class FilePickerModal(ModalScreen[Path | None]):
19
+ DEFAULT_CSS = """
20
+ FilePickerModal {
21
+ align: center middle;
22
+ }
23
+ FilePickerModal > Vertical {
24
+ width: 60;
25
+ height: auto;
26
+ background: $surface;
27
+ border: thick $accent;
28
+ padding: 1 2;
29
+ }
30
+ FilePickerModal .modal-title {
31
+ text-style: bold;
32
+ text-align: center;
33
+ margin-bottom: 1;
34
+ }
35
+ FilePickerModal .error-msg {
36
+ color: $error;
37
+ height: 1;
38
+ margin-top: 1;
39
+ }
40
+ FilePickerModal .buttons {
41
+ margin-top: 2;
42
+ align: right middle;
43
+ height: 3;
44
+ }
45
+ FilePickerModal Button {
46
+ margin-left: 1;
47
+ }
48
+ """
49
+
50
+ def __init__(self, default_path: str = "") -> None:
51
+ super().__init__()
52
+ self._default_path = default_path
53
+
54
+ def compose(self) -> ComposeResult:
55
+ with Vertical():
56
+ yield Label("Open OTP file", classes="modal-title")
57
+ yield Label("File path:", classes="field-label")
58
+ yield Input(value=self._default_path, placeholder="path/to/otp.txt", id="path-input")
59
+ yield Label("", classes="error-msg", id="path-error")
60
+ with Horizontal(classes="buttons"):
61
+ yield Button("Cancel", variant="default", id="cancel")
62
+ yield Button("Open", variant="primary", id="open")
63
+
64
+ def on_mount(self) -> None:
65
+ self.query_one("#path-input", Input).focus()
66
+
67
+ def on_button_pressed(self, event: Button.Pressed) -> None:
68
+ if event.button.id == "cancel":
69
+ self.dismiss(None)
70
+ return
71
+ self._submit()
72
+
73
+ def on_input_submitted(self, _event: Input.Submitted) -> None:
74
+ self._submit()
75
+
76
+ def _submit(self) -> None:
77
+ raw = self.query_one("#path-input", Input).value.strip()
78
+ path = Path(raw).expanduser()
79
+ if not path.exists():
80
+ self.query_one("#path-error", Label).update(f"File not found: {path}")
81
+ return
82
+ self.dismiss(path)
83
+
84
+ def on_key(self, event) -> None:
85
+ if event.key == "escape":
86
+ event.stop()
87
+ self.dismiss(None)
88
+
89
+
90
+ class ExportModal(ModalScreen[Path | None]):
91
+ DEFAULT_CSS = """
92
+ ExportModal {
93
+ align: center middle;
94
+ }
95
+ ExportModal > Vertical {
96
+ width: 60;
97
+ height: auto;
98
+ background: $surface;
99
+ border: thick $accent;
100
+ padding: 1 2;
101
+ }
102
+ ExportModal .modal-title {
103
+ text-style: bold;
104
+ text-align: center;
105
+ margin-bottom: 1;
106
+ }
107
+ ExportModal .error-msg {
108
+ color: $error;
109
+ height: 1;
110
+ margin-top: 1;
111
+ }
112
+ ExportModal .buttons {
113
+ margin-top: 2;
114
+ align: right middle;
115
+ height: 3;
116
+ }
117
+ ExportModal Button {
118
+ margin-left: 1;
119
+ }
120
+ """
121
+
122
+ def __init__(self, default_path: str = "") -> None:
123
+ super().__init__()
124
+ self._default_path = default_path
125
+
126
+ def compose(self) -> ComposeResult:
127
+ with Vertical():
128
+ yield Label("Export OTP file", classes="modal-title")
129
+ yield Label("Export path:", classes="field-label")
130
+ yield Input(value=self._default_path, placeholder="path/to/export.txt", id="export-path")
131
+ yield Label("", classes="error-msg", id="export-error")
132
+ with Horizontal(classes="buttons"):
133
+ yield Button("Cancel", variant="default", id="cancel")
134
+ yield Button("Export", variant="primary", id="export")
135
+
136
+ def on_mount(self) -> None:
137
+ self.query_one("#export-path", Input).focus()
138
+
139
+ def on_button_pressed(self, event: Button.Pressed) -> None:
140
+ if event.button.id == "cancel":
141
+ self.dismiss(None)
142
+ return
143
+ self._submit()
144
+
145
+ def on_input_submitted(self, _event: Input.Submitted) -> None:
146
+ self._submit()
147
+
148
+ def _submit(self) -> None:
149
+ raw = self.query_one("#export-path", Input).value.strip()
150
+ if not raw:
151
+ self.query_one("#export-error", Label).update("Please enter a path.")
152
+ return
153
+ self.dismiss(Path(raw).expanduser())
154
+
155
+ def on_key(self, event) -> None:
156
+ if event.key == "escape":
157
+ event.stop()
158
+ self.dismiss(None)
159
+
160
+
161
+ class AuthenticatorApp(App[None]):
162
+ TITLE = "Authenticator TUI"
163
+ CSS = """
164
+ #filter-input {
165
+ dock: top;
166
+ margin: 0 1;
167
+ }
168
+ #otp-list {
169
+ margin: 0 1;
170
+ }
171
+ """
172
+
173
+ BINDINGS = [
174
+ Binding("a", "add_otp", "Add"),
175
+ Binding("d", "delete_otp", "Delete"),
176
+ Binding("e", "export", "Export"),
177
+ Binding("f", "focus_filter", "Filter"),
178
+ Binding("escape", "blur_filter", "Back to list", show=False),
179
+ Binding("q", "quit", "Quit"),
180
+ ]
181
+
182
+ def __init__(self, file_path: Path | None = None) -> None:
183
+ super().__init__()
184
+ self._file_path: Path | None = file_path
185
+ self._entries: list[OTPEntry] = []
186
+
187
+ def compose(self) -> ComposeResult:
188
+ yield Header()
189
+ yield Input(placeholder="Filter by issuer or account...", id="filter-input")
190
+ yield ListView(id="otp-list")
191
+ yield Footer()
192
+
193
+ def on_mount(self) -> None:
194
+ if self._file_path and self._file_path.exists():
195
+ self._load(self._file_path)
196
+ else:
197
+ default = ""
198
+ if Path("otp.txt").exists():
199
+ default = "otp.txt"
200
+ self.push_screen(FilePickerModal(default), self._on_file_picked)
201
+
202
+ def _on_file_picked(self, path: Path | None) -> None:
203
+ if path is None:
204
+ self.exit()
205
+ return
206
+ self._file_path = path
207
+ self._load(path)
208
+
209
+ def _load(self, path: Path) -> None:
210
+ try:
211
+ self._entries = load_file(path)
212
+ self._populate_list(self._entries)
213
+ self.query_one("#filter-input", Input).focus()
214
+ self.notify(f"Loaded {len(self._entries)} entries from {path.name}", timeout=3)
215
+ except Exception as e:
216
+ self.notify(f"Error loading file: {e}", severity="error")
217
+
218
+ def _populate_list(self, entries: list[OTPEntry]) -> None:
219
+ lst = self.query_one("#otp-list", ListView)
220
+ lst.clear()
221
+ for entry in entries:
222
+ lst.append(OTPItem(entry))
223
+
224
+ def _save(self) -> None:
225
+ if self._file_path:
226
+ try:
227
+ save_file(self._file_path, self._entries)
228
+ except Exception as e:
229
+ self.notify(f"Save error: {e}", severity="error")
230
+
231
+ def on_input_changed(self, event: Input.Changed) -> None:
232
+ if event.input.id == "filter-input":
233
+ text = event.value
234
+ filtered = [e for e in self._entries if not text or e.matches_filter(text)]
235
+ self._populate_list(filtered)
236
+
237
+ def action_add_otp(self) -> None:
238
+ self.push_screen(AddOTPModal(), self._on_entry_added)
239
+
240
+ def _on_entry_added(self, entry: OTPEntry | None) -> None:
241
+ if entry is None:
242
+ return
243
+ self._entries.append(entry)
244
+ self._save()
245
+ filter_text = self.query_one("#filter-input", Input).value
246
+ if not filter_text or entry.matches_filter(filter_text):
247
+ self.query_one("#otp-list", ListView).append(OTPItem(entry))
248
+ self.notify(f"Added {entry.issuer or entry.account}", timeout=2)
249
+
250
+ def action_delete_otp(self) -> None:
251
+ lst = self.query_one("#otp-list", ListView)
252
+ idx = lst.index
253
+ if idx is None:
254
+ return
255
+ items = list(lst.query(OTPItem))
256
+ if not items or idx >= len(items):
257
+ return
258
+ entry = items[idx].entry
259
+ self._entries = [e for e in self._entries if e is not entry]
260
+ self._save()
261
+ lst.remove_items([idx])
262
+ self.notify(f"Deleted {entry.issuer or entry.account}", timeout=2)
263
+
264
+ def action_export(self) -> None:
265
+ default = str(self._file_path) if self._file_path else "otp.txt"
266
+ self.push_screen(ExportModal(default), self._on_export_path)
267
+
268
+ def _on_export_path(self, path: Path | None) -> None:
269
+ if path is None:
270
+ return
271
+ try:
272
+ save_file(path, self._entries)
273
+ self.notify(f"Exported {len(self._entries)} entries to {path}", timeout=3)
274
+ except Exception as e:
275
+ self.notify(f"Export error: {e}", severity="error")
276
+
277
+ def action_focus_filter(self) -> None:
278
+ self.query_one("#filter-input", Input).focus()
279
+
280
+ def action_blur_filter(self) -> None:
281
+ inp = self.query_one("#filter-input", Input)
282
+ if inp.has_focus:
283
+ inp.clear()
284
+ self.query_one("#otp-list", ListView).focus()
285
+ else:
286
+ self.exit()
287
+
288
+ def on_key(self, event) -> None:
289
+ inp = self.query_one("#filter-input", Input)
290
+ lst = self.query_one("#otp-list", ListView)
291
+ if event.key == "down" and inp.has_focus:
292
+ lst.focus()
293
+ if lst.index is None:
294
+ lst.action_cursor_down()
295
+ elif event.key == "up" and lst.has_focus and (lst.index is None or lst.index == 0):
296
+ inp.focus()
297
+
298
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
299
+ if isinstance(event.item, OTPItem):
300
+ self.push_screen(BigCodeModal(event.item.entry))
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass
5
+ from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse
6
+
7
+ import pyotp
8
+
9
+ _URLENCODE_KWARGS = {"quote_via": quote}
10
+
11
+
12
+ @dataclass
13
+ class OTPEntry:
14
+ otp_type: str # "totp" or "hotp"
15
+ label: str
16
+ issuer: str
17
+ account: str
18
+ secret: str
19
+ algorithm: str = "SHA1"
20
+ digits: int = 6
21
+ period: int = 30
22
+ counter: int = 0
23
+
24
+ @staticmethod
25
+ def from_url(url: str) -> OTPEntry:
26
+ parsed = urlparse(url)
27
+ if parsed.scheme != "otpauth":
28
+ raise ValueError(f"Not an otpauth URL: {url}")
29
+
30
+ otp_type = parsed.netloc # "totp" or "hotp"
31
+ label = unquote(parsed.path.lstrip("/"))
32
+
33
+ # Split issuer:account from label
34
+ if ":" in label:
35
+ issuer_from_label, account = label.split(":", 1)
36
+ else:
37
+ issuer_from_label, account = "", label
38
+
39
+ params = parse_qs(parsed.query)
40
+
41
+ def first(key: str, default: str = "") -> str:
42
+ return params.get(key, [default])[0]
43
+
44
+ issuer = first("issuer") or issuer_from_label
45
+ secret = first("secret")
46
+ algorithm = first("algorithm", "SHA1").upper()
47
+ digits = int(first("digits", "6"))
48
+ period = int(first("period", "30"))
49
+ counter = int(first("counter", "0"))
50
+
51
+ return OTPEntry(
52
+ otp_type=otp_type,
53
+ label=label,
54
+ issuer=issuer,
55
+ account=account,
56
+ secret=secret,
57
+ algorithm=algorithm,
58
+ digits=digits,
59
+ period=period,
60
+ counter=counter,
61
+ )
62
+
63
+ def to_url(self) -> str:
64
+ label = quote(self.label, safe="")
65
+ params: dict[str, str] = {"secret": self.secret, "issuer": self.issuer}
66
+ if self.algorithm != "SHA1":
67
+ params["algorithm"] = self.algorithm
68
+ if self.digits != 6:
69
+ params["digits"] = str(self.digits)
70
+ if self.otp_type == "totp" and self.period != 30:
71
+ params["period"] = str(self.period)
72
+ if self.otp_type == "hotp":
73
+ params["counter"] = str(self.counter)
74
+ return f"otpauth://{self.otp_type}/{label}?{urlencode(params, quote_via=quote)}"
75
+
76
+ def get_code(self) -> str:
77
+ if self.otp_type == "totp":
78
+ totp = pyotp.TOTP(
79
+ self.secret,
80
+ digits=self.digits,
81
+ digest=_digest(self.algorithm),
82
+ interval=self.period,
83
+ )
84
+ code = totp.now()
85
+ else:
86
+ hotp = pyotp.HOTP(self.secret, digits=self.digits, digest=_digest(self.algorithm))
87
+ code = hotp.at(self.counter)
88
+
89
+ # Insert a space in the middle for readability
90
+ mid = len(code) // 2
91
+ return code[:mid] + " " + code[mid:]
92
+
93
+ def time_remaining(self) -> int:
94
+ if self.otp_type != "totp":
95
+ return 0
96
+ return self.period - int(time.time()) % self.period
97
+
98
+ def matches_filter(self, text: str) -> bool:
99
+ text = text.lower()
100
+ return text in self.issuer.lower() or text in self.account.lower()
101
+
102
+
103
+ def _digest(algorithm: str):
104
+ import hashlib
105
+
106
+ return {
107
+ "SHA1": hashlib.sha1,
108
+ "SHA256": hashlib.sha256,
109
+ "SHA512": hashlib.sha512,
110
+ }.get(algorithm, hashlib.sha1)
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from .models import OTPEntry
6
+
7
+
8
+ def load_file(path: Path) -> list[OTPEntry]:
9
+ entries = []
10
+ for line in path.read_text().splitlines():
11
+ line = line.strip()
12
+ if not line or line.startswith("#"):
13
+ continue
14
+ try:
15
+ entries.append(OTPEntry.from_url(line))
16
+ except Exception as e:
17
+ raise ValueError(f"Invalid OTP URL: {line!r}") from e
18
+ return entries
19
+
20
+
21
+ def save_file(path: Path, entries: list[OTPEntry]) -> None:
22
+ tmp = path.with_suffix(path.suffix + ".tmp")
23
+ tmp.write_text("\n".join(e.to_url() for e in entries) + "\n")
24
+ tmp.rename(path)
@@ -0,0 +1,3 @@
1
+ from .otp_item import OTPItem
2
+
3
+ __all__ = ["OTPItem"]
@@ -0,0 +1,156 @@
1
+ from __future__ import annotations
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import Vertical, Horizontal
5
+ from textual.screen import ModalScreen
6
+ from textual.widgets import Button, Input, Label, Select, TabbedContent, TabPane, TextArea
7
+
8
+ from ..models import OTPEntry
9
+
10
+
11
+ class AddOTPModal(ModalScreen[OTPEntry | None]):
12
+ DEFAULT_CSS = """
13
+ AddOTPModal {
14
+ align: center middle;
15
+ }
16
+ AddOTPModal > Vertical {
17
+ width: 70;
18
+ height: auto;
19
+ max-height: 80%;
20
+ background: $surface;
21
+ border: thick $accent;
22
+ padding: 1 2;
23
+ }
24
+ AddOTPModal .modal-title {
25
+ text-style: bold;
26
+ text-align: center;
27
+ margin-bottom: 1;
28
+ }
29
+ AddOTPModal .field-label {
30
+ margin-top: 1;
31
+ color: $text-muted;
32
+ }
33
+ AddOTPModal .buttons {
34
+ margin-top: 2;
35
+ align: right middle;
36
+ height: 3;
37
+ }
38
+ AddOTPModal Button {
39
+ margin-left: 1;
40
+ }
41
+ AddOTPModal .error-msg {
42
+ color: $error;
43
+ height: 1;
44
+ margin-top: 1;
45
+ }
46
+ """
47
+
48
+ def compose(self) -> ComposeResult:
49
+ with Vertical():
50
+ yield Label("Add OTP", classes="modal-title")
51
+ with TabbedContent():
52
+ with TabPane("Paste URL", id="tab-url"):
53
+ yield Label("Paste an otpauth:// URL:", classes="field-label")
54
+ yield TextArea(id="url-input")
55
+ yield Label("", classes="error-msg", id="url-error")
56
+
57
+ with TabPane("Manual", id="tab-manual"):
58
+ yield Label("Issuer", classes="field-label")
59
+ yield Input(placeholder="e.g. GitHub", id="issuer")
60
+ yield Label("Account", classes="field-label")
61
+ yield Input(placeholder="e.g. user@example.com", id="account")
62
+ yield Label("Secret (required)", classes="field-label")
63
+ yield Input(placeholder="Base32 secret", id="secret")
64
+ yield Label("Algorithm", classes="field-label")
65
+ yield Select(
66
+ [("SHA1", "SHA1"), ("SHA256", "SHA256"), ("SHA512", "SHA512")],
67
+ value="SHA1",
68
+ id="algorithm",
69
+ )
70
+ yield Label("Digits", classes="field-label")
71
+ yield Select(
72
+ [("6", "6"), ("8", "8")],
73
+ value="6",
74
+ id="digits",
75
+ )
76
+ yield Label("Period (seconds)", classes="field-label")
77
+ yield Input(value="30", id="period")
78
+ yield Label("", classes="error-msg", id="manual-error")
79
+
80
+ with Horizontal(classes="buttons"):
81
+ yield Button("Cancel", variant="default", id="cancel")
82
+ yield Button("Add", variant="primary", id="add")
83
+
84
+ def on_button_pressed(self, event: Button.Pressed) -> None:
85
+ if event.button.id == "cancel":
86
+ self.dismiss(None)
87
+ return
88
+
89
+ tabs = self.query_one(TabbedContent)
90
+ active = tabs.active
91
+
92
+ if active == "tab-url":
93
+ self._submit_url()
94
+ else:
95
+ self._submit_manual()
96
+
97
+ def _submit_url(self) -> None:
98
+ error_label = self.query_one("#url-error", Label)
99
+ text = self.query_one("#url-input", TextArea).text.strip()
100
+ if not text:
101
+ error_label.update("Please paste an otpauth:// URL.")
102
+ return
103
+ try:
104
+ entry = OTPEntry.from_url(text)
105
+ self.dismiss(entry)
106
+ except Exception as e:
107
+ error_label.update(f"Invalid URL: {e}")
108
+
109
+ def _submit_manual(self) -> None:
110
+ error_label = self.query_one("#manual-error", Label)
111
+
112
+ secret = self.query_one("#secret", Input).value.strip()
113
+ if not secret:
114
+ error_label.update("Secret is required.")
115
+ return
116
+
117
+ issuer = self.query_one("#issuer", Input).value.strip()
118
+ account = self.query_one("#account", Input).value.strip()
119
+ algorithm = str(self.query_one("#algorithm", Select).value)
120
+ digits_str = str(self.query_one("#digits", Select).value)
121
+ period_str = self.query_one("#period", Input).value.strip()
122
+
123
+ try:
124
+ period = int(period_str)
125
+ except ValueError:
126
+ error_label.update("Period must be an integer.")
127
+ return
128
+
129
+ if issuer and account:
130
+ label = f"{issuer}:{account}"
131
+ else:
132
+ label = account or issuer or secret
133
+
134
+ entry = OTPEntry(
135
+ otp_type="totp",
136
+ label=label,
137
+ issuer=issuer,
138
+ account=account,
139
+ secret=secret,
140
+ algorithm=algorithm,
141
+ digits=int(digits_str),
142
+ period=period,
143
+ )
144
+
145
+ try:
146
+ entry.get_code() # validate that secret works
147
+ except Exception as e:
148
+ error_label.update(f"Invalid secret: {e}")
149
+ return
150
+
151
+ self.dismiss(entry)
152
+
153
+ def on_key(self, event) -> None:
154
+ if event.key == "escape":
155
+ event.stop()
156
+ self.dismiss(None)
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.screen import ModalScreen
5
+ from textual.widgets import Digits, Label, Static
6
+ from textual.containers import Vertical
7
+
8
+ from ..models import OTPEntry
9
+
10
+
11
+ class BigCodeModal(ModalScreen[None]):
12
+ DEFAULT_CSS = """
13
+ BigCodeModal {
14
+ align: center middle;
15
+ }
16
+ BigCodeModal > Vertical {
17
+ width: auto;
18
+ height: auto;
19
+ background: $surface;
20
+ border: thick $accent;
21
+ padding: 2 4;
22
+ }
23
+ BigCodeModal .entry-label {
24
+ text-align: center;
25
+ color: $text-muted;
26
+ margin-bottom: 1;
27
+ }
28
+ BigCodeModal Digits {
29
+ color: $success;
30
+ width: auto;
31
+ }
32
+ BigCodeModal #big-timer {
33
+ text-align: center;
34
+ color: $accent;
35
+ margin-top: 1;
36
+ }
37
+ BigCodeModal .dismiss-hint {
38
+ text-align: center;
39
+ color: $text-muted;
40
+ margin-top: 1;
41
+ }
42
+ """
43
+
44
+ def __init__(self, entry: OTPEntry) -> None:
45
+ super().__init__()
46
+ self._entry = entry
47
+
48
+ def compose(self) -> ComposeResult:
49
+ label = (
50
+ f"{self._entry.issuer} / {self._entry.account}"
51
+ if self._entry.issuer
52
+ else self._entry.account
53
+ )
54
+ with Vertical():
55
+ yield Label(label, classes="entry-label")
56
+ yield Digits("", id="big-code")
57
+ yield Static("", id="big-timer")
58
+ yield Label("Press any key or click to dismiss", classes="dismiss-hint")
59
+
60
+ def on_mount(self) -> None:
61
+ self._refresh()
62
+ self.set_interval(1, self._refresh)
63
+
64
+ def _refresh(self) -> None:
65
+ try:
66
+ code = self._entry.get_code()
67
+ remaining = self._entry.time_remaining()
68
+ period = self._entry.period
69
+ bar_width = 30
70
+ filled = round((remaining / period) * bar_width)
71
+ bar = "=" * filled + "-" * (bar_width - filled)
72
+ self.query_one("#big-code", Digits).update(code)
73
+ self.query_one("#big-timer", Static).update(f"[{bar}] {remaining}s")
74
+ except Exception:
75
+ self.query_one("#big-code", Digits).update("---")
76
+
77
+ def on_key(self, event) -> None:
78
+ event.stop()
79
+ self.dismiss()
80
+
81
+ def on_click(self) -> None:
82
+ self.dismiss()
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.widgets import ListItem, Label, Static
5
+ from textual.reactive import reactive
6
+
7
+ from ..models import OTPEntry
8
+
9
+
10
+ BAR_WIDTH = 10
11
+
12
+
13
+ def _progress_bar(remaining: int, period: int) -> str:
14
+ filled = round((remaining / period) * BAR_WIDTH)
15
+ return "█" * filled + "░" * (BAR_WIDTH - filled)
16
+
17
+
18
+ class OTPItem(ListItem):
19
+ DEFAULT_CSS = """
20
+ OTPItem {
21
+ height: 3;
22
+ padding: 0 1;
23
+ }
24
+ OTPItem > .otp-row {
25
+ layout: horizontal;
26
+ height: 1;
27
+ }
28
+ OTPItem .otp-label {
29
+ width: 1fr;
30
+ color: $text;
31
+ }
32
+ OTPItem .otp-code {
33
+ width: 10;
34
+ text-align: center;
35
+ color: $success;
36
+ text-style: bold;
37
+ }
38
+ OTPItem .otp-bar {
39
+ width: 14;
40
+ text-align: right;
41
+ color: $accent;
42
+ }
43
+ OTPItem.--highlight .otp-code {
44
+ color: $success-lighten-2;
45
+ }
46
+ """
47
+
48
+ code: reactive[str] = reactive("", layout=False)
49
+ remaining: reactive[int] = reactive(30, layout=False)
50
+
51
+ def __init__(self, entry: OTPEntry) -> None:
52
+ super().__init__()
53
+ self.entry = entry
54
+
55
+ def compose(self) -> ComposeResult:
56
+ label = f"{self.entry.issuer} / {self.entry.account}" if self.entry.issuer else self.entry.account
57
+ with Static(classes="otp-row"):
58
+ yield Label(label, classes="otp-label")
59
+ yield Label("", classes="otp-code", id=f"code-{id(self)}")
60
+ yield Label("", classes="otp-bar", id=f"bar-{id(self)}")
61
+
62
+ def on_mount(self) -> None:
63
+ self._refresh_code()
64
+ self.set_interval(1, self._refresh_code)
65
+
66
+ def _refresh_code(self) -> None:
67
+ try:
68
+ code = self.entry.get_code()
69
+ remaining = self.entry.time_remaining()
70
+ bar = _progress_bar(remaining, self.entry.period)
71
+ self.query_one(f"#code-{id(self)}", Label).update(code)
72
+ self.query_one(f"#bar-{id(self)}", Label).update(f"{bar} {remaining:2d}s")
73
+ except Exception:
74
+ self.query_one(f"#code-{id(self)}", Label).update("ERROR")
75
+ self.query_one(f"#bar-{id(self)}", Label).update("")
76
+
77
+ def on_click(self) -> None:
78
+ try:
79
+ import pyperclip
80
+ code = self.entry.get_code().replace(" ", "")
81
+ pyperclip.copy(code)
82
+ self.app.notify(f"Copied: {code}", timeout=2)
83
+ except Exception:
84
+ pass
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: authenticator-tui
3
+ Version: 1.0.0
4
+ Summary: A keyboard-driven terminal UI for managing TOTP/HOTP one-time passwords
5
+ Project-URL: Homepage, https://gitlab.com/riphixel/authenticator-tui
6
+ Project-URL: Repository, https://gitlab.com/riphixel/authenticator-tui
7
+ Project-URL: Issues, https://gitlab.com/riphixel/authenticator-tui/-/issues
8
+ Author-email: Pierre Gronlier <pierre@gronlier.fr>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: 2fa,authenticator,hotp,mfa,otp,terminal,textual,totp,tui
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Environment :: Console :: Curses
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Intended Audience :: End Users/Desktop
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Security
22
+ Classifier: Topic :: Terminals
23
+ Classifier: Topic :: Utilities
24
+ Requires-Python: >=3.13
25
+ Requires-Dist: pyotp>=2.10.0
26
+ Requires-Dist: pyperclip>=1.11.0
27
+ Requires-Dist: textual>=8.2.7
28
+ Description-Content-Type: text/markdown
29
+
30
+ # authenticator-tui
31
+
32
+ A terminal UI for managing TOTP/HOTP one-time passwords stored as `otpauth://` URLs.
33
+
34
+ <img width="700" src="screenshots/main.svg" alt="Main screen">
35
+
36
+ ## Features
37
+
38
+ - **Live codes** — TOTP codes refresh every second with a countdown progress bar
39
+ - **Filter** — press `f` and type to narrow the list by issuer or account
40
+ - **Add** — paste an `otpauth://` URL or fill in fields manually
41
+ - **Delete** — remove an entry with auto-save
42
+ - **Big code view** — press Enter on any entry to display the code in large digits
43
+ - **Clipboard** — click any entry to copy the code
44
+ - **Export** — write all entries to a file as `otpauth://` URLs
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ uv tool install git+https://gitlab.com/riphixel/authenticator-tui
50
+ ```
51
+
52
+ Or run directly without installing:
53
+
54
+ ```bash
55
+ uvx --from git+https://gitlab.com/riphixel/authenticator-tui authenticator-tui otp.txt
56
+ ```
57
+
58
+ ## Usage
59
+
60
+ ```bash
61
+ authenticator-tui [path/to/otp.txt]
62
+ ```
63
+
64
+ If no file is given (or the path doesn't exist) a file picker appears on startup.
65
+ The file is auto-saved on every add or delete.
66
+
67
+ ## Keybindings
68
+
69
+ | Key | Action |
70
+ |---|---|
71
+ | `f` | Focus filter input |
72
+ | `↓` in filter | Jump to first list entry |
73
+ | `↑` on first entry | Jump back to filter |
74
+ | `a` | Add OTP (modal) |
75
+ | `d` | Delete selected OTP |
76
+ | `e` | Export to file (modal) |
77
+ | Enter | Show code in big digits |
78
+ | Click | Copy code to clipboard |
79
+ | Escape | Clear filter / close modal |
80
+ | `q` | Quit |
81
+
82
+ ## Screenshots
83
+
84
+ ### Filter
85
+
86
+ <img width="700" src="screenshots/filter.svg" alt="Filter">
87
+
88
+ ### Add OTP
89
+
90
+ <img width="700" src="screenshots/add_modal.svg" alt="Add OTP modal">
91
+
92
+ ### Big code view
93
+
94
+ <img width="700" src="screenshots/big_code.svg" alt="Big code">
95
+
96
+ ## File format
97
+
98
+ One `otpauth://` URL per line. Blank lines and lines starting with `#` are ignored.
99
+
100
+ ```
101
+ otpauth://totp/GitHub:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=GitHub
102
+ otpauth://totp/PayPal:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=PayPal
103
+ ```
104
+
105
+ ## Development
106
+
107
+ ```bash
108
+ uv sync
109
+ uv run authenticator-tui otp.txt
110
+
111
+ # tests
112
+ uv run pytest
113
+
114
+ # lint
115
+ uvx ruff check .
116
+ uvx ruff format --check .
117
+ ```
@@ -0,0 +1,14 @@
1
+ authenticator_tui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ authenticator_tui/__main__.py,sha256=H2JUwM1uECoSGut0E8zn5bOBBTkK5sEqzEoIIWKQ5PA,309
3
+ authenticator_tui/app.py,sha256=kKVHMwL3R8lglsz2dxxxgLpVulFDVlSJZcz4YRjEnho,9742
4
+ authenticator_tui/models.py,sha256=gKH5mNioyAdopDGChTvZChAoAkLHZ5twQfn_cR-UxMs,3303
5
+ authenticator_tui/otp_file.py,sha256=BSeLGz9Gqhc4mxvwxhgutjxE30mSTP_XuKYStlW4Q_Q,675
6
+ authenticator_tui/widgets/__init__.py,sha256=2ED13TyjDXYMZbPJiYiJRzJ5cxFQQBIOBhATFzE340E,53
7
+ authenticator_tui/widgets/add_modal.py,sha256=DGjpL98mbR4CfN8ihf-QdNz5fFre6VdalaCJSCsT44s,5186
8
+ authenticator_tui/widgets/big_code_modal.py,sha256=01qFxxyKX9IGXLz0DtgcsVk8jm8bq83Koe8hfeFJwiI,2305
9
+ authenticator_tui/widgets/otp_item.py,sha256=EsMq5h7598_VGWkuxfUOZKStj0_o6T60E_-E7cPLekM,2449
10
+ authenticator_tui-1.0.0.dist-info/METADATA,sha256=U_yZd4bKEs7UhyXK3wIY21b9r1ldzanOmfZLXd1a9Mo,3320
11
+ authenticator_tui-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
12
+ authenticator_tui-1.0.0.dist-info/entry_points.txt,sha256=lZ4DKLsRls5w7HbT_kwlZOKlIB-hmaOcXE_ZQfdK-1w,70
13
+ authenticator_tui-1.0.0.dist-info/licenses/LICENSE,sha256=mEzskvKveEYNqkkVrP9Q81V836L1XzdWDIz3FGs8Hc8,1072
14
+ authenticator_tui-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ authenticator-tui = authenticator_tui.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pierre Gronlier
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.