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 @@
|
|
|
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()
|