notion-vault-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,223 @@
1
+ Metadata-Version: 2.4
2
+ Name: notion-vault-cli
3
+ Version: 0.1.0
4
+ Summary: Local encrypted password vault with natural-language CLI
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: cryptography>=48.0.0
8
+ Requires-Dist: prompt-toolkit>=3.0.52
9
+ Requires-Dist: pyperclip>=1.11.0
10
+ Requires-Dist: rich>=15.0.0
11
+
12
+ # Notion Vault CLI
13
+
14
+ Notion Vault CLI is a local, encrypted password vault you talk to in plain English.
15
+
16
+ It stores passwords, usernames, and small private notes on your machine only. There is no cloud sync, no browser extension, and no account setup. You open the vault with one master password, then ask for what you need with commands like:
17
+
18
+ ```text
19
+ save github in personal as ali
20
+ what's my github password in personal
21
+ list all
22
+ remember my uni email is ali@student.edu
23
+ ```
24
+
25
+ ## Features
26
+
27
+ - Local encrypted storage using `cryptography`
28
+ - Natural-language style CLI commands
29
+ - Folder-based organization such as `Personal`, `Education`, `Business`, or custom folders
30
+ - Multi-word folder and service names such as `borlo labs` or `notion vault`
31
+ - Password generation with clipboard auto-clear
32
+ - Search, move, delete, and quick listing commands
33
+ - Small fact memory for things like emails, IDs, or Wi-Fi labels
34
+ - Rich terminal panels and tables for a more interactive CLI experience
35
+
36
+ ## Requirements
37
+
38
+ - Python `3.14+`
39
+ - `uv`
40
+
41
+ ## Install
42
+
43
+ ```powershell
44
+ uv sync
45
+ ```
46
+
47
+ ## Run
48
+
49
+ ```powershell
50
+ uv run notion-vault
51
+ ```
52
+
53
+ This project also installs:
54
+
55
+ ```powershell
56
+ uv run notion-vault-cli
57
+ ```
58
+
59
+ ## First Run
60
+
61
+ On first launch, Notion Vault CLI will ask you to create a master password.
62
+
63
+ It stores local files in your home directory:
64
+
65
+ - `~/.notion_vault.db` for new vaults
66
+ - `~/.notion_vault.salt` for the key-derivation salt
67
+ - `~/.notion_vault.history` for prompt history
68
+
69
+ It also recognizes the older legacy files:
70
+
71
+ - `~/.pma_vault.db`
72
+ - `~/.pma_salt`
73
+ - `~/.pma_history`
74
+
75
+ If those older files already exist, the CLI will continue using them.
76
+
77
+ ## Command Guide
78
+
79
+ ### Save a login
80
+
81
+ ```text
82
+ save github in personal as ali
83
+ save notion vault in borlo labs as ali.work
84
+ add figma to client work
85
+ ```
86
+
87
+ If you leave the password blank when prompted, the CLI generates one for you.
88
+
89
+ ### Retrieve a password
90
+
91
+ ```text
92
+ what's my github password in personal
93
+ show github
94
+ get notion vault from borlo labs
95
+ ```
96
+
97
+ ### List saved entries
98
+
99
+ ```text
100
+ list all
101
+ list personal
102
+ list borlo labs
103
+ show passwords
104
+ ```
105
+
106
+ ### Search
107
+
108
+ ```text
109
+ find github
110
+ search for aws
111
+ ```
112
+
113
+ ### Move between folders
114
+
115
+ ```text
116
+ move aws from business to client work
117
+ ```
118
+
119
+ ### Delete an entry
120
+
121
+ ```text
122
+ delete github from personal
123
+ ```
124
+
125
+ ### Generate a password
126
+
127
+ ```text
128
+ generate password
129
+ generate password 24
130
+ ```
131
+
132
+ ### Copy a password to clipboard
133
+
134
+ ```text
135
+ copy github
136
+ copy my github password from personal
137
+ ```
138
+
139
+ If clipboard support is available, the copied password is cleared after 15 seconds.
140
+
141
+ ### Store and recall facts
142
+
143
+ ```text
144
+ remember my uni email is ali@student.edu
145
+ what is my uni email
146
+ recall my uni email
147
+ ```
148
+
149
+ ### Built-in utility views
150
+
151
+ ```text
152
+ help
153
+ folders
154
+ summary
155
+ quit
156
+ ```
157
+
158
+ ## Folder Behavior
159
+
160
+ Notion Vault starts with:
161
+
162
+ - `Personal`
163
+ - `Education`
164
+ - `Business`
165
+
166
+ You can create a new folder simply by saving something into it:
167
+
168
+ ```text
169
+ save figma in borlo labs as ali
170
+ ```
171
+
172
+ That creates `Borlo Labs` automatically if it does not already exist.
173
+
174
+ ## Security Notes
175
+
176
+ - Passwords are encrypted before being stored in SQLite
177
+ - The salt is stored separately in your home directory
178
+ - Everything runs locally on your machine
179
+ - Clipboard clearing is best-effort and depends on system clipboard support
180
+ - If you lose your master password, there is no recovery flow in this version
181
+
182
+ ## Project Layout
183
+
184
+ - [notion_vault_cli.py](./notion_vault_cli.py) contains the full CLI
185
+ - [pyproject.toml](./pyproject.toml) defines packaging and command entry points
186
+ - [uv.lock](./uv.lock) locks dependencies
187
+ - [Password-agent-design.html](./Password-agent-design.html) is a design/manual mockup
188
+
189
+ ## Development
190
+
191
+ Install dependencies:
192
+
193
+ ```powershell
194
+ uv sync
195
+ ```
196
+
197
+ Run the module directly:
198
+
199
+ ```powershell
200
+ uv run python notion_vault_cli.py
201
+ ```
202
+
203
+ Check syntax:
204
+
205
+ ```powershell
206
+ uv run python -m py_compile notion_vault_cli.py
207
+ ```
208
+
209
+ ## Current Scope
210
+
211
+ This is a single-file CLI application. It is intentionally simple and currently focuses on:
212
+
213
+ - local vault storage
214
+ - natural-language commands
215
+ - terminal interaction
216
+
217
+ It does not currently include:
218
+
219
+ - cloud sync
220
+ - browser autofill
221
+ - multi-user accounts
222
+ - export/import workflows
223
+ - a GUI
@@ -0,0 +1,6 @@
1
+ notion_vault_cli.py,sha256=Ch7uFZPgii8urqr7eeXuQVfpg5KF6mSM75xu2PNqiZk,27999
2
+ notion_vault_cli-0.1.0.dist-info/METADATA,sha256=8AdBJwyreu17y-rnzQ9Bjoic7qdF4fT0-u0oJysNYFc,4682
3
+ notion_vault_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
4
+ notion_vault_cli-0.1.0.dist-info/entry_points.txt,sha256=JvwurIhk9264PQG2ce-jxhZPe86YhzMpTTLUchhByvQ,96
5
+ notion_vault_cli-0.1.0.dist-info/top_level.txt,sha256=wfbVWpxBEHB3kCV-fzwYLiUkO9YIRpURvZp1b6mQMCM,17
6
+ notion_vault_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,3 @@
1
+ [console_scripts]
2
+ notion-vault = notion_vault_cli:main
3
+ notion-vault-cli = notion_vault_cli:main
@@ -0,0 +1 @@
1
+ notion_vault_cli
notion_vault_cli.py ADDED
@@ -0,0 +1,770 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Notion Vault CLI
4
+ Natural-language password vault with local encrypted storage.
5
+ """
6
+
7
+ import base64
8
+ import difflib
9
+ import getpass
10
+ import os
11
+ import re
12
+ import secrets
13
+ import sqlite3
14
+ import string
15
+ import sys
16
+ import threading
17
+ import time
18
+ from pathlib import Path
19
+
20
+ from cryptography.fernet import Fernet, InvalidToken
21
+ from cryptography.hazmat.primitives import hashes
22
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
23
+
24
+ try:
25
+ from rich import box
26
+ from rich.console import Console
27
+ from rich.panel import Panel
28
+ from rich.table import Table
29
+ from rich.text import Text
30
+
31
+ console = Console()
32
+ except Exception:
33
+ print("pip install rich")
34
+ sys.exit(1)
35
+
36
+ try:
37
+ from prompt_toolkit import PromptSession
38
+ from prompt_toolkit.history import FileHistory
39
+
40
+ PTK = True
41
+ except Exception:
42
+ PTK = False
43
+
44
+ try:
45
+ import pyperclip
46
+
47
+ HAS_CLIP = True
48
+ except Exception:
49
+ HAS_CLIP = False
50
+
51
+ APP_NAME = "Notion Vault"
52
+ APP_TAGLINE = "Local, encrypted, natural-language password storage"
53
+
54
+ LEGACY_VAULT = Path.home() / ".pma_vault.db"
55
+ LEGACY_SALT = Path.home() / ".pma_salt"
56
+ LEGACY_HIST = Path.home() / ".pma_history"
57
+
58
+ DEFAULT_VAULT = Path.home() / ".notion_vault.db"
59
+ DEFAULT_SALT = Path.home() / ".notion_vault.salt"
60
+ DEFAULT_HIST = Path.home() / ".notion_vault.history"
61
+
62
+
63
+ def choose_path(default_path: Path, legacy_path: Path) -> Path:
64
+ if default_path.exists():
65
+ return default_path
66
+ if legacy_path.exists():
67
+ return legacy_path
68
+ return default_path
69
+
70
+
71
+ VAULT = choose_path(DEFAULT_VAULT, LEGACY_VAULT)
72
+ SALT = choose_path(DEFAULT_SALT, LEGACY_SALT)
73
+ HIST = choose_path(DEFAULT_HIST, LEGACY_HIST)
74
+
75
+ FOLDER_CHOICES = ("Personal", "Education", "Business")
76
+ COMMAND_EXAMPLES = [
77
+ "save github in personal as ali",
78
+ "save notion vault in borlo labs as ali.work",
79
+ "what's my github password in personal",
80
+ "list all",
81
+ "move aws from business to client work",
82
+ "remember my uni email is ali@student.edu",
83
+ ]
84
+
85
+
86
+ def format_name(value: str) -> str:
87
+ return " ".join(word.capitalize() for word in value.strip().split())
88
+
89
+
90
+ def safe_chmod(path: Path, mode: int):
91
+ try:
92
+ os.chmod(path, mode)
93
+ except OSError:
94
+ pass
95
+
96
+
97
+ def derive_key(master: str) -> bytes:
98
+ if not SALT.exists():
99
+ SALT.write_bytes(os.urandom(16))
100
+ safe_chmod(SALT, 0o600)
101
+ salt = SALT.read_bytes()
102
+ kdf = PBKDF2HMAC(
103
+ algorithm=hashes.SHA256(),
104
+ length=32,
105
+ salt=salt,
106
+ iterations=390000,
107
+ )
108
+ return base64.urlsafe_b64encode(kdf.derive(master.encode()))
109
+
110
+
111
+ def get_conn():
112
+ conn = sqlite3.connect(VAULT)
113
+ conn.row_factory = sqlite3.Row
114
+ conn.execute("PRAGMA foreign_keys=ON")
115
+ return conn
116
+
117
+
118
+ def init_db():
119
+ conn = get_conn()
120
+ conn.executescript(
121
+ """
122
+ CREATE TABLE IF NOT EXISTS categories(
123
+ id INTEGER PRIMARY KEY,
124
+ name TEXT UNIQUE COLLATE NOCASE,
125
+ created_at REAL
126
+ );
127
+ CREATE TABLE IF NOT EXISTS entries(
128
+ id INTEGER PRIMARY KEY,
129
+ category_id INTEGER,
130
+ service TEXT COLLATE NOCASE,
131
+ username TEXT,
132
+ password BLOB,
133
+ notes TEXT,
134
+ created_at REAL,
135
+ updated_at REAL,
136
+ accessed_at REAL,
137
+ UNIQUE(category_id, service),
138
+ FOREIGN KEY(category_id) REFERENCES categories(id) ON DELETE CASCADE
139
+ );
140
+ CREATE TABLE IF NOT EXISTS facts(
141
+ key TEXT PRIMARY KEY COLLATE NOCASE,
142
+ value TEXT,
143
+ updated_at REAL
144
+ );
145
+ CREATE TABLE IF NOT EXISTS audit(
146
+ ts REAL,
147
+ action TEXT,
148
+ detail TEXT
149
+ );
150
+ CREATE TABLE IF NOT EXISTS meta(
151
+ key TEXT PRIMARY KEY COLLATE NOCASE,
152
+ value BLOB
153
+ );
154
+ """
155
+ )
156
+ for folder in FOLDER_CHOICES:
157
+ conn.execute(
158
+ "INSERT OR IGNORE INTO categories(name, created_at) VALUES(?, ?)",
159
+ (folder, time.time()),
160
+ )
161
+ conn.commit()
162
+ conn.close()
163
+ safe_chmod(VAULT, 0o600)
164
+
165
+
166
+ def encrypt_value(fernet: Fernet, text: str) -> bytes:
167
+ return fernet.encrypt(text.encode())
168
+
169
+
170
+ def decrypt_value(fernet: Fernet, blob: bytes) -> str:
171
+ return fernet.decrypt(blob).decode()
172
+
173
+
174
+ def generate_password(length: int = 16) -> str:
175
+ if length < 8 or length > 256:
176
+ raise ValueError("Password length must be between 8 and 256 characters.")
177
+ alphabet = string.ascii_letters + string.digits + "!@#$%^&*-_"
178
+ return "".join(secrets.choice(alphabet) for _ in range(length))
179
+
180
+
181
+ def copy_with_timeout(text: str, seconds: int = 15) -> bool:
182
+ if not HAS_CLIP:
183
+ return False
184
+ pyperclip.copy(text)
185
+
186
+ def clear_clipboard():
187
+ time.sleep(seconds)
188
+ if pyperclip.paste() == text:
189
+ pyperclip.copy("")
190
+
191
+ threading.Thread(target=clear_clipboard, daemon=True).start()
192
+ return True
193
+
194
+
195
+ class NotionVaultCLI:
196
+ def __init__(self, fernet: Fernet):
197
+ self.fernet = fernet
198
+ self.conn = get_conn()
199
+ self.last_folder = None
200
+ self.last_service = None
201
+
202
+ def log(self, action: str, detail: str = ""):
203
+ self.conn.execute("INSERT INTO audit VALUES (?, ?, ?)", (time.time(), action, detail))
204
+
205
+ def categories(self):
206
+ return [row[0] for row in self.conn.execute("SELECT name FROM categories ORDER BY name")]
207
+
208
+ def services(self):
209
+ return [row[0] for row in self.conn.execute("SELECT DISTINCT service FROM entries ORDER BY service")]
210
+
211
+ def counts(self):
212
+ row = self.conn.execute(
213
+ """
214
+ SELECT
215
+ (SELECT COUNT(*) FROM categories) AS folders,
216
+ (SELECT COUNT(*) FROM entries) AS entries,
217
+ (SELECT COUNT(*) FROM facts) AS facts
218
+ """
219
+ ).fetchone()
220
+ return row["folders"], row["entries"], row["facts"]
221
+
222
+ def close(self):
223
+ self.conn.close()
224
+
225
+ def ensure_master_password(self):
226
+ token = "notion-vault-check"
227
+ self.conn.execute(
228
+ """
229
+ CREATE TABLE IF NOT EXISTS meta(
230
+ key TEXT PRIMARY KEY COLLATE NOCASE,
231
+ value BLOB
232
+ )
233
+ """
234
+ )
235
+ row = self.conn.execute("SELECT value FROM meta WHERE key = 'vault-check'").fetchone()
236
+ if row:
237
+ try:
238
+ return decrypt_value(self.fernet, row["value"]) == token
239
+ except InvalidToken:
240
+ return False
241
+
242
+ sample = self.conn.execute("SELECT password FROM entries LIMIT 1").fetchone()
243
+ if sample:
244
+ try:
245
+ decrypt_value(self.fernet, sample["password"])
246
+ except InvalidToken:
247
+ return False
248
+
249
+ self.conn.execute(
250
+ "INSERT OR REPLACE INTO meta(key, value) VALUES(?, ?)",
251
+ ("vault-check", encrypt_value(self.fernet, token)),
252
+ )
253
+ self.conn.commit()
254
+ return True
255
+
256
+ def fuzzy(self, name: str, options):
257
+ if not options:
258
+ return name
259
+ exact = {option.lower(): option for option in options}
260
+ if name.lower() in exact:
261
+ return exact[name.lower()]
262
+ lowered = [option.lower() for option in options]
263
+ match = difflib.get_close_matches(name.lower(), lowered, n=1, cutoff=0.7)
264
+ if not match:
265
+ return name
266
+ index = lowered.index(match[0])
267
+ return options[index]
268
+
269
+ def resolve_folder(self, folder: str) -> str:
270
+ folder = " ".join(folder.strip().split())
271
+ return format_name(self.fuzzy(folder, self.categories()))
272
+
273
+ def resolve_service(self, service: str) -> str:
274
+ service = " ".join(service.strip().split()).lower()
275
+ return self.fuzzy(service, self.services()).lower()
276
+
277
+ def save_entry(self, folder: str, service: str, username: str, password: str = None):
278
+ folder = format_name(" ".join(folder.strip().split()))
279
+ service = " ".join(service.strip().split()).lower()
280
+ username = username.strip()
281
+ if not password:
282
+ password = generate_password()
283
+ category = self.conn.execute("SELECT id FROM categories WHERE name = ?", (folder,)).fetchone()
284
+ if not category:
285
+ self.conn.execute(
286
+ "INSERT INTO categories(name, created_at) VALUES(?, ?)",
287
+ (folder, time.time()),
288
+ )
289
+ category = self.conn.execute("SELECT id FROM categories WHERE name = ?", (folder,)).fetchone()
290
+ self.conn.execute(
291
+ """
292
+ INSERT INTO entries(category_id, service, username, password, created_at, updated_at)
293
+ VALUES(?, ?, ?, ?, ?, ?)
294
+ ON CONFLICT(category_id, service) DO UPDATE SET
295
+ username = excluded.username,
296
+ password = excluded.password,
297
+ updated_at = excluded.updated_at
298
+ """,
299
+ (
300
+ category[0],
301
+ service,
302
+ username,
303
+ encrypt_value(self.fernet, password),
304
+ time.time(),
305
+ time.time(),
306
+ ),
307
+ )
308
+ self.log("ADD", f"{folder}/{service}")
309
+ self.conn.commit()
310
+ self.last_folder = folder
311
+ self.last_service = service
312
+ return folder, service, password
313
+
314
+ def get_entry(self, folder: str, service: str):
315
+ folder = self.resolve_folder(folder)
316
+ service = self.resolve_service(service)
317
+ row = self.conn.execute(
318
+ """
319
+ SELECT e.username, e.password
320
+ FROM entries e
321
+ JOIN categories c ON e.category_id = c.id
322
+ WHERE c.name = ? AND e.service = ?
323
+ """,
324
+ (folder, service),
325
+ ).fetchone()
326
+ if not row:
327
+ return None, None, folder, service
328
+ self.last_folder = folder
329
+ self.last_service = service
330
+ self.log("GET", f"{folder}/{service}")
331
+ self.conn.commit()
332
+ return row["username"], decrypt_value(self.fernet, row["password"]), folder, service
333
+
334
+ def list_folder(self, folder: str = None):
335
+ if folder:
336
+ folder = self.resolve_folder(folder)
337
+ rows = self.conn.execute(
338
+ """
339
+ SELECT service, username
340
+ FROM entries e
341
+ JOIN categories c ON e.category_id = c.id
342
+ WHERE c.name = ?
343
+ ORDER BY service
344
+ """,
345
+ (folder,),
346
+ ).fetchall()
347
+ title = f"{folder} vault"
348
+ table = Table(title=title, box=box.ROUNDED, header_style="bold cyan")
349
+ table.add_column("Service", style="white")
350
+ table.add_column("Username", style="green")
351
+ for row in rows:
352
+ table.add_row(row["service"], row["username"] or "-")
353
+ return table, rows
354
+
355
+ rows = self.conn.execute(
356
+ """
357
+ SELECT c.name, e.service, e.username
358
+ FROM entries e
359
+ JOIN categories c ON e.category_id = c.id
360
+ ORDER BY c.name, e.service
361
+ """
362
+ ).fetchall()
363
+ table = Table(title="All saved logins", box=box.ROUNDED, header_style="bold cyan")
364
+ table.add_column("Folder", style="magenta")
365
+ table.add_column("Service", style="white")
366
+ table.add_column("Username", style="green")
367
+ for row in rows:
368
+ table.add_row(row["name"], row["service"], row["username"] or "-")
369
+ return table, rows
370
+
371
+ def search_entries(self, query: str):
372
+ rows = self.conn.execute(
373
+ """
374
+ SELECT c.name, e.service, e.username
375
+ FROM entries e
376
+ JOIN categories c ON e.category_id = c.id
377
+ WHERE e.service LIKE ? OR e.username LIKE ?
378
+ ORDER BY c.name, e.service
379
+ """,
380
+ (f"%{query}%", f"%{query}%"),
381
+ ).fetchall()
382
+ return rows
383
+
384
+ def move_entry(self, service: str, source: str, destination: str):
385
+ service = self.resolve_service(service)
386
+ source = self.resolve_folder(source)
387
+ destination = self.resolve_folder(destination)
388
+ destination_row = self.conn.execute("SELECT id FROM categories WHERE name = ?", (destination,)).fetchone()
389
+ if not destination_row:
390
+ self.conn.execute(
391
+ "INSERT INTO categories(name, created_at) VALUES(?, ?)",
392
+ (destination, time.time()),
393
+ )
394
+ destination_row = self.conn.execute(
395
+ "SELECT id FROM categories WHERE name = ?",
396
+ (destination,),
397
+ ).fetchone()
398
+ result = self.conn.execute(
399
+ """
400
+ UPDATE entries
401
+ SET category_id = ?, updated_at = ?
402
+ WHERE service = ?
403
+ AND category_id = (SELECT id FROM categories WHERE name = ?)
404
+ """,
405
+ (destination_row[0], time.time(), service, source),
406
+ )
407
+ self.conn.commit()
408
+ return result.rowcount, service, source, destination
409
+
410
+ def delete_entry(self, folder: str, service: str):
411
+ folder = self.resolve_folder(folder)
412
+ service = self.resolve_service(service)
413
+ result = self.conn.execute(
414
+ """
415
+ DELETE FROM entries
416
+ WHERE service = ?
417
+ AND category_id = (SELECT id FROM categories WHERE name = ?)
418
+ """,
419
+ (service, folder),
420
+ )
421
+ self.conn.commit()
422
+ return result.rowcount, folder, service
423
+
424
+ def remember_fact(self, key: str, value: str):
425
+ self.conn.execute(
426
+ """
427
+ INSERT INTO facts VALUES(?, ?, ?)
428
+ ON CONFLICT(key) DO UPDATE SET
429
+ value = excluded.value,
430
+ updated_at = excluded.updated_at
431
+ """,
432
+ (key.lower(), value, time.time()),
433
+ )
434
+ self.conn.commit()
435
+
436
+ def recall_fact(self, key: str):
437
+ row = self.conn.execute("SELECT value FROM facts WHERE key = ?", (key.lower(),)).fetchone()
438
+ return row["value"] if row else None
439
+
440
+ def parse(self, text: str):
441
+ normalized = " ".join(text.strip().split())
442
+ lower = normalized.lower()
443
+
444
+ if lower in {"help", "commands", "?"}:
445
+ return "help", []
446
+ if lower in {"folders", "categories"}:
447
+ return "folders", []
448
+ if lower in {"summary", "stats", "dashboard"}:
449
+ return "summary", []
450
+
451
+ match = re.match(r"remember (?:that )?(.+?) is (.+)$", normalized, re.IGNORECASE)
452
+ if match:
453
+ return "remember", [match.group(1).strip(), match.group(2).strip()]
454
+
455
+ match = re.match(r"(?:what is|recall|what's) (?:my )?(.+)$", normalized, re.IGNORECASE)
456
+ if match and "password" not in lower:
457
+ return "recall", [match.group(1).strip()]
458
+
459
+ match = re.match(
460
+ r"(?:save|store|add) (?:my )?(.+?)(?: password)? (?:in|to|for|under) (.+?)(?: as (.+))?$",
461
+ normalized,
462
+ re.IGNORECASE,
463
+ )
464
+ if match:
465
+ return "add", [match.group(2).strip(), match.group(1).strip(), (match.group(3) or "").strip()]
466
+
467
+ match = re.match(r"add (.+?) to (.+)$", normalized, re.IGNORECASE)
468
+ if match:
469
+ return "add", [match.group(2).strip(), match.group(1).strip(), ""]
470
+
471
+ match = re.match(
472
+ r"(?:get|show|what is|what's) (?:my )?(.+?) password (?:in|from|for) (.+)$",
473
+ normalized,
474
+ re.IGNORECASE,
475
+ )
476
+ if match:
477
+ return "get", [match.group(2).strip(), match.group(1).strip()]
478
+
479
+ if re.search(r"what passwords do i have|show passwords", lower):
480
+ return "list", []
481
+
482
+ match = re.match(r"(?:get|show) (.+?)(?: from (.+))?$", normalized, re.IGNORECASE)
483
+ if match:
484
+ folder = (match.group(2) or self.last_folder or "Personal").strip()
485
+ return "get", [folder, match.group(1).strip()]
486
+
487
+ match = re.match(r"(?:list)(?: (.+))?$", normalized, re.IGNORECASE)
488
+ if match:
489
+ folder = (match.group(1) or "").strip()
490
+ if not folder or folder.lower() == "all":
491
+ return "list", []
492
+ if folder.lower() == "passwords":
493
+ return "list", []
494
+ return "list", [folder]
495
+
496
+ match = re.match(r"(?:find|search for|search) (.+)$", normalized, re.IGNORECASE)
497
+ if match:
498
+ return "find", [match.group(1).strip()]
499
+
500
+ match = re.match(r"move (.+?) from (.+?) to (.+)$", normalized, re.IGNORECASE)
501
+ if match:
502
+ return "move", [match.group(1).strip(), match.group(2).strip(), match.group(3).strip()]
503
+
504
+ match = re.match(r"delete (.+?)(?: from| in) (.+)$", normalized, re.IGNORECASE)
505
+ if match:
506
+ return "delete", [match.group(2).strip(), match.group(1).strip()]
507
+
508
+ match = re.match(r"(?:generate|create)(?: a)? password(?: (\d+))?$", lower, re.IGNORECASE)
509
+ if match:
510
+ return "generate", [match.group(1) or "16"]
511
+
512
+ match = re.match(r"copy (?:my )?(.+?)(?: password)?(?: from (.+))?$", normalized, re.IGNORECASE)
513
+ if match:
514
+ folder = (match.group(2) or self.last_folder or "Personal").strip()
515
+ return "copy", [folder, match.group(1).strip()]
516
+
517
+ return "unknown", []
518
+
519
+ def render_summary(self):
520
+ folders, entries, facts = self.counts()
521
+ table = Table(title="Vault overview", box=box.ROUNDED, show_header=True, header_style="bold cyan")
522
+ table.add_column("Metric")
523
+ table.add_column("Value", justify="right")
524
+ table.add_row("Folders", str(folders))
525
+ table.add_row("Passwords", str(entries))
526
+ table.add_row("Facts", str(facts))
527
+ return table
528
+
529
+ def render_folders(self):
530
+ table = Table(title="Folders", box=box.ROUNDED, header_style="bold cyan")
531
+ table.add_column("Name", style="magenta")
532
+ for folder in self.categories():
533
+ table.add_row(folder)
534
+ return table
535
+
536
+ def render_help(self):
537
+ table = Table(title="Command guide", box=box.ROUNDED, header_style="bold cyan")
538
+ table.add_column("Action", style="magenta")
539
+ table.add_column("Example", style="white")
540
+ table.add_row("Save a login", "save github in personal as ali")
541
+ table.add_row("Create a new folder", "save figma in borlo labs as ali")
542
+ table.add_row("Fetch a password", "what's my github password in personal")
543
+ table.add_row("Quick fetch", "show github")
544
+ table.add_row("List everything", "list all")
545
+ table.add_row("List one folder", "list borlo labs")
546
+ table.add_row("Move a login", "move aws from business to client work")
547
+ table.add_row("Remember a fact", "remember wifi code is delta-42")
548
+ table.add_row("Generate a password", "generate password 24")
549
+ table.add_row("Show dashboard", "summary")
550
+ return table
551
+
552
+ def handle(self, text: str):
553
+ intent, args = self.parse(text)
554
+
555
+ if intent == "help":
556
+ console.print(self.render_help())
557
+ return
558
+
559
+ if intent == "folders":
560
+ console.print(self.render_folders())
561
+ return
562
+
563
+ if intent == "summary":
564
+ console.print(self.render_summary())
565
+ return
566
+
567
+ if intent == "add":
568
+ folder, service, username = args
569
+ if not username:
570
+ username = console.input(f"[cyan]Username for {service} in {folder}:[/cyan] ").strip()
571
+ password = getpass.getpass("Password (leave blank to generate): ")
572
+ if not password:
573
+ password = generate_password()
574
+ folder, service, saved_password = self.save_entry(folder, service, username, password)
575
+ body = (
576
+ f"[bold]{service}[/bold] saved in [bold magenta]{folder}[/bold magenta]\n"
577
+ f"Username: [green]{username}[/green]\n"
578
+ f"Password: [yellow]{saved_password}[/yellow]"
579
+ )
580
+ console.print(Panel(body, title="Saved", border_style="green"))
581
+ if copy_with_timeout(saved_password):
582
+ console.print("[dim]Password copied to clipboard for 15 seconds.[/dim]")
583
+ return
584
+
585
+ if intent == "get":
586
+ username, password, folder, service = self.get_entry(args[0], args[1])
587
+ if not username:
588
+ console.print(Panel("Nothing matched that request. Try `list all` or `find github`.", title="Not found", border_style="red"))
589
+ return
590
+ body = (
591
+ f"[bold magenta]{folder}[/bold magenta] / [bold]{service}[/bold]\n"
592
+ f"Username: [green]{username}[/green]\n"
593
+ f"Password: [yellow]{password}[/yellow]"
594
+ )
595
+ console.print(Panel(body, title="Vault item", border_style="cyan"))
596
+ if copy_with_timeout(password):
597
+ console.print("[dim]Password copied to clipboard for 15 seconds.[/dim]")
598
+ return
599
+
600
+ if intent == "list":
601
+ table, rows = self.list_folder(args[0] if args else None)
602
+ if not rows:
603
+ console.print(Panel("No saved passwords here yet.", title="Empty", border_style="yellow"))
604
+ return
605
+ console.print(table)
606
+ return
607
+
608
+ if intent == "find":
609
+ rows = self.search_entries(args[0])
610
+ if not rows:
611
+ console.print(Panel(f"No matches for [bold]{args[0]}[/bold].", title="Search", border_style="yellow"))
612
+ return
613
+ table = Table(title=f"Matches for '{args[0]}'", box=box.ROUNDED, header_style="bold cyan")
614
+ table.add_column("Folder", style="magenta")
615
+ table.add_column("Service", style="white")
616
+ table.add_column("Username", style="green")
617
+ for row in rows:
618
+ table.add_row(row["name"], row["service"], row["username"] or "-")
619
+ console.print(table)
620
+ return
621
+
622
+ if intent == "move":
623
+ changed, service, source, destination = self.move_entry(args[0], args[1], args[2])
624
+ if not changed:
625
+ console.print(Panel("I could not find that login in the source folder.", title="Move failed", border_style="red"))
626
+ return
627
+ console.print(Panel(f"[bold]{service}[/bold] moved from [magenta]{source}[/magenta] to [magenta]{destination}[/magenta].", title="Moved", border_style="green"))
628
+ return
629
+
630
+ if intent == "delete":
631
+ changed, folder, service = self.delete_entry(args[0], args[1])
632
+ if not changed:
633
+ console.print(Panel("Nothing matched that delete request.", title="Delete failed", border_style="red"))
634
+ return
635
+ console.print(Panel(f"[bold]{service}[/bold] removed from [magenta]{folder}[/magenta].", title="Deleted", border_style="green"))
636
+ return
637
+
638
+ if intent == "remember":
639
+ key, value = args
640
+ self.remember_fact(key, value)
641
+ console.print(Panel(f"I'll remember that [bold]{key}[/bold] is [green]{value}[/green].", title="Stored", border_style="green"))
642
+ return
643
+
644
+ if intent == "recall":
645
+ value = self.recall_fact(args[0])
646
+ if not value:
647
+ console.print(Panel("I do not have that fact yet.", title="No match", border_style="yellow"))
648
+ return
649
+ console.print(Panel(f"[green]{value}[/green]", title=args[0], border_style="cyan"))
650
+ return
651
+
652
+ if intent == "generate":
653
+ try:
654
+ length = int(args[0])
655
+ password = generate_password(length)
656
+ except ValueError as exc:
657
+ console.print(Panel(str(exc), title="Invalid length", border_style="red"))
658
+ return
659
+ console.print(Panel(f"[yellow]{password}[/yellow]", title=f"Generated password ({length})", border_style="cyan"))
660
+ if copy_with_timeout(password):
661
+ console.print("[dim]Password copied to clipboard for 15 seconds.[/dim]")
662
+ return
663
+
664
+ if intent == "copy":
665
+ username, password, folder, service = self.get_entry(args[0], args[1])
666
+ if not password:
667
+ console.print(Panel("Nothing matched that copy request.", title="Copy failed", border_style="red"))
668
+ return
669
+ if copy_with_timeout(password):
670
+ console.print(Panel(f"Copied [bold]{service}[/bold] from [magenta]{folder}[/magenta].", title="Clipboard", border_style="green"))
671
+ else:
672
+ console.print(Panel("Clipboard support is not available on this machine.", title="Clipboard unavailable", border_style="yellow"))
673
+ return
674
+
675
+ console.print(
676
+ Panel(
677
+ "Try `help`, `list all`, `save github in personal as ali`, or `what's my github password in personal`.",
678
+ title="Command not understood",
679
+ border_style="yellow",
680
+ )
681
+ )
682
+
683
+
684
+ def render_welcome():
685
+ examples = "\n".join(f"- {example}" for example in COMMAND_EXAMPLES)
686
+ message = (
687
+ f"[bold cyan]{APP_NAME}[/bold cyan]\n"
688
+ f"{APP_TAGLINE}\n\n"
689
+ f"Vault file: [magenta]{VAULT}[/magenta]\n"
690
+ f"Salt file: [magenta]{SALT}[/magenta]\n\n"
691
+ f"[bold]Try these:[/bold]\n{examples}"
692
+ )
693
+ console.print(Panel(message, title="Welcome", border_style="blue"))
694
+
695
+
696
+ def render_session_banner(agent: NotionVaultCLI):
697
+ folders, entries, facts = agent.counts()
698
+ summary = Table(box=box.SIMPLE, show_header=False, pad_edge=False)
699
+ summary.add_column(style="cyan")
700
+ summary.add_column(style="white")
701
+ summary.add_row("Folders", str(folders))
702
+ summary.add_row("Passwords", str(entries))
703
+ summary.add_row("Facts", str(facts))
704
+
705
+ hint = Text()
706
+ hint.append("Type ", style="white")
707
+ hint.append("help", style="bold cyan")
708
+ hint.append(" for commands, ", style="white")
709
+ hint.append("summary", style="bold cyan")
710
+ hint.append(" for stats, or ", style="white")
711
+ hint.append("quit", style="bold cyan")
712
+ hint.append(" to lock the vault.", style="white")
713
+
714
+ console.print(Panel(summary, title="Session", border_style="blue"))
715
+ console.print(Panel(hint, title="Quick start", border_style="cyan"))
716
+
717
+
718
+ def prompt_session():
719
+ if PTK:
720
+ return PromptSession(history=FileHistory(str(HIST)))
721
+ return None
722
+
723
+
724
+ def main():
725
+ render_welcome()
726
+
727
+ if not VAULT.exists():
728
+ console.print(Panel("Create a master password to initialize your local vault.", title="First run", border_style="magenta"))
729
+ first = getpass.getpass("Choose master password: ")
730
+ second = getpass.getpass("Confirm master password: ")
731
+ if first != second:
732
+ console.print("[red]Master password mismatch.[/red]")
733
+ return
734
+ fernet = Fernet(derive_key(first))
735
+ init_db()
736
+ else:
737
+ master = getpass.getpass("Master password: ")
738
+ fernet = Fernet(derive_key(master))
739
+
740
+ init_db()
741
+ agent = NotionVaultCLI(fernet)
742
+ if not agent.ensure_master_password():
743
+ console.print("[red]Unable to unlock vault. Check your master password.[/red]")
744
+ return
745
+ render_session_banner(agent)
746
+ session = prompt_session()
747
+
748
+ while True:
749
+ try:
750
+ prompt_label = "vault> "
751
+ text = session.prompt(prompt_label) if session else console.input(prompt_label)
752
+ if text.lower().strip() in {"exit", "quit", "bye"}:
753
+ break
754
+ if not text.strip():
755
+ continue
756
+ agent.handle(text)
757
+ except KeyboardInterrupt:
758
+ console.print("\n[dim]Use `quit` to lock the vault.[/dim]")
759
+ continue
760
+ except EOFError:
761
+ break
762
+ except Exception as exc:
763
+ console.print(Panel(str(exc), title="Error", border_style="red"))
764
+
765
+ agent.close()
766
+ console.print(Panel("Vault locked. See you next time.", title="Goodbye", border_style="blue"))
767
+
768
+
769
+ if __name__ == "__main__":
770
+ main()