repr-cli 0.1.0__py3-none-any.whl → 0.2.2__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.
- repr/__init__.py +1 -1
- repr/__main__.py +6 -0
- repr/api.py +127 -1
- repr/auth.py +66 -2
- repr/cli.py +2143 -663
- repr/config.py +658 -32
- repr/discovery.py +5 -0
- repr/doctor.py +458 -0
- repr/hooks.py +634 -0
- repr/keychain.py +255 -0
- repr/llm.py +506 -0
- repr/openai_analysis.py +92 -21
- repr/privacy.py +333 -0
- repr/storage.py +527 -0
- repr/templates.py +229 -0
- repr/tools.py +202 -0
- repr/ui.py +79 -364
- repr_cli-0.2.2.dist-info/METADATA +263 -0
- repr_cli-0.2.2.dist-info/RECORD +24 -0
- {repr_cli-0.1.0.dist-info → repr_cli-0.2.2.dist-info}/licenses/LICENSE +1 -1
- repr/analyzer.py +0 -915
- repr/highlights.py +0 -712
- repr_cli-0.1.0.dist-info/METADATA +0 -326
- repr_cli-0.1.0.dist-info/RECORD +0 -18
- {repr_cli-0.1.0.dist-info → repr_cli-0.2.2.dist-info}/WHEEL +0 -0
- {repr_cli-0.1.0.dist-info → repr_cli-0.2.2.dist-info}/entry_points.txt +0 -0
- {repr_cli-0.1.0.dist-info → repr_cli-0.2.2.dist-info}/top_level.txt +0 -0
repr/keychain.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Secure credential storage using OS keychain.
|
|
3
|
+
|
|
4
|
+
Supports:
|
|
5
|
+
- macOS: Keychain
|
|
6
|
+
- Windows: Credential Manager
|
|
7
|
+
- Linux: Secret Service (libsecret)
|
|
8
|
+
|
|
9
|
+
Falls back to encrypted file storage if keychain is unavailable.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import secrets
|
|
17
|
+
import tempfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
# Service name for keychain entries
|
|
22
|
+
SERVICE_NAME = "repr-cli"
|
|
23
|
+
|
|
24
|
+
# Fallback storage location
|
|
25
|
+
REPR_HOME = Path(os.getenv("REPR_HOME", Path.home() / ".repr"))
|
|
26
|
+
SECRETS_FILE = REPR_HOME / ".secrets.enc"
|
|
27
|
+
KEY_FILE = REPR_HOME / ".secrets.key"
|
|
28
|
+
|
|
29
|
+
# Try to import keyring
|
|
30
|
+
_keyring_available = False
|
|
31
|
+
try:
|
|
32
|
+
import keyring
|
|
33
|
+
from keyring.errors import KeyringError, PasswordDeleteError
|
|
34
|
+
_keyring_available = True
|
|
35
|
+
except ImportError:
|
|
36
|
+
keyring = None
|
|
37
|
+
KeyringError = Exception
|
|
38
|
+
PasswordDeleteError = Exception
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_keyring_available() -> bool:
|
|
42
|
+
"""Check if OS keychain is available and functional."""
|
|
43
|
+
if not _keyring_available:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
# Test keychain access with a dummy operation
|
|
48
|
+
test_key = f"repr_keychain_test_{secrets.token_hex(4)}"
|
|
49
|
+
keyring.set_password(SERVICE_NAME, test_key, "test")
|
|
50
|
+
result = keyring.get_password(SERVICE_NAME, test_key)
|
|
51
|
+
keyring.delete_password(SERVICE_NAME, test_key)
|
|
52
|
+
return result == "test"
|
|
53
|
+
except Exception:
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _get_encryption_key() -> bytes:
|
|
58
|
+
"""Get or generate encryption key for fallback storage."""
|
|
59
|
+
REPR_HOME.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
|
|
61
|
+
if KEY_FILE.exists():
|
|
62
|
+
return base64.b64decode(KEY_FILE.read_text().strip())
|
|
63
|
+
|
|
64
|
+
# Generate new key
|
|
65
|
+
key = secrets.token_bytes(32)
|
|
66
|
+
KEY_FILE.write_text(base64.b64encode(key).decode())
|
|
67
|
+
KEY_FILE.chmod(0o600) # Owner read/write only
|
|
68
|
+
return key
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _simple_encrypt(data: str, key: bytes) -> str:
|
|
72
|
+
"""Simple XOR encryption with key derivation.
|
|
73
|
+
|
|
74
|
+
Note: This is NOT cryptographically secure for serious use.
|
|
75
|
+
It's a fallback when keychain is unavailable.
|
|
76
|
+
For production, consider using cryptography library.
|
|
77
|
+
"""
|
|
78
|
+
# Derive a longer key using hash
|
|
79
|
+
derived = hashlib.sha256(key).digest()
|
|
80
|
+
data_bytes = data.encode('utf-8')
|
|
81
|
+
|
|
82
|
+
# XOR with repeating key
|
|
83
|
+
encrypted = bytes(
|
|
84
|
+
b ^ derived[i % len(derived)]
|
|
85
|
+
for i, b in enumerate(data_bytes)
|
|
86
|
+
)
|
|
87
|
+
return base64.b64encode(encrypted).decode()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _simple_decrypt(encrypted: str, key: bytes) -> str:
|
|
91
|
+
"""Decrypt data encrypted with _simple_encrypt."""
|
|
92
|
+
derived = hashlib.sha256(key).digest()
|
|
93
|
+
encrypted_bytes = base64.b64decode(encrypted)
|
|
94
|
+
|
|
95
|
+
decrypted = bytes(
|
|
96
|
+
b ^ derived[i % len(derived)]
|
|
97
|
+
for i, b in enumerate(encrypted_bytes)
|
|
98
|
+
)
|
|
99
|
+
return decrypted.decode('utf-8')
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _load_fallback_secrets() -> dict[str, str]:
|
|
103
|
+
"""Load secrets from fallback encrypted file."""
|
|
104
|
+
if not SECRETS_FILE.exists():
|
|
105
|
+
return {}
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
key = _get_encryption_key()
|
|
109
|
+
encrypted = SECRETS_FILE.read_text().strip()
|
|
110
|
+
decrypted = _simple_decrypt(encrypted, key)
|
|
111
|
+
return json.loads(decrypted)
|
|
112
|
+
except Exception:
|
|
113
|
+
return {}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _save_fallback_secrets(secrets_dict: dict[str, str]) -> None:
|
|
117
|
+
"""Save secrets to fallback encrypted file."""
|
|
118
|
+
REPR_HOME.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
key = _get_encryption_key()
|
|
120
|
+
encrypted = _simple_encrypt(json.dumps(secrets_dict), key)
|
|
121
|
+
|
|
122
|
+
# Atomic write
|
|
123
|
+
fd, tmp_path = tempfile.mkstemp(dir=REPR_HOME, suffix=".tmp")
|
|
124
|
+
try:
|
|
125
|
+
with os.fdopen(fd, "w") as f:
|
|
126
|
+
f.write(encrypted)
|
|
127
|
+
os.replace(tmp_path, SECRETS_FILE)
|
|
128
|
+
SECRETS_FILE.chmod(0o600)
|
|
129
|
+
except Exception:
|
|
130
|
+
if os.path.exists(tmp_path):
|
|
131
|
+
os.unlink(tmp_path)
|
|
132
|
+
raise
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def store_secret(key: str, value: str) -> bool:
|
|
136
|
+
"""
|
|
137
|
+
Store a secret in the OS keychain or fallback storage.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
key: Unique identifier for the secret (e.g., 'auth_token', 'byok_openai')
|
|
141
|
+
value: Secret value to store
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
True if stored successfully
|
|
145
|
+
"""
|
|
146
|
+
if _keyring_available:
|
|
147
|
+
try:
|
|
148
|
+
keyring.set_password(SERVICE_NAME, key, value)
|
|
149
|
+
return True
|
|
150
|
+
except KeyringError:
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
# Fallback to encrypted file
|
|
154
|
+
secrets_dict = _load_fallback_secrets()
|
|
155
|
+
secrets_dict[key] = value
|
|
156
|
+
_save_fallback_secrets(secrets_dict)
|
|
157
|
+
return True
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def get_secret(key: str) -> str | None:
|
|
161
|
+
"""
|
|
162
|
+
Retrieve a secret from the OS keychain or fallback storage.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
key: Unique identifier for the secret
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Secret value or None if not found
|
|
169
|
+
"""
|
|
170
|
+
if _keyring_available:
|
|
171
|
+
try:
|
|
172
|
+
value = keyring.get_password(SERVICE_NAME, key)
|
|
173
|
+
if value is not None:
|
|
174
|
+
return value
|
|
175
|
+
except KeyringError:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
# Fallback to encrypted file
|
|
179
|
+
secrets_dict = _load_fallback_secrets()
|
|
180
|
+
return secrets_dict.get(key)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def delete_secret(key: str) -> bool:
|
|
184
|
+
"""
|
|
185
|
+
Delete a secret from the OS keychain or fallback storage.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
key: Unique identifier for the secret
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
True if deleted, False if not found
|
|
192
|
+
"""
|
|
193
|
+
deleted = False
|
|
194
|
+
|
|
195
|
+
if _keyring_available:
|
|
196
|
+
try:
|
|
197
|
+
keyring.delete_password(SERVICE_NAME, key)
|
|
198
|
+
deleted = True
|
|
199
|
+
except (KeyringError, PasswordDeleteError):
|
|
200
|
+
pass
|
|
201
|
+
|
|
202
|
+
# Also remove from fallback if present
|
|
203
|
+
secrets_dict = _load_fallback_secrets()
|
|
204
|
+
if key in secrets_dict:
|
|
205
|
+
del secrets_dict[key]
|
|
206
|
+
_save_fallback_secrets(secrets_dict)
|
|
207
|
+
deleted = True
|
|
208
|
+
|
|
209
|
+
return deleted
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def list_secrets() -> list[str]:
|
|
213
|
+
"""
|
|
214
|
+
List all secret keys (not values).
|
|
215
|
+
|
|
216
|
+
Note: OS keychain doesn't support listing, so this only works for fallback storage.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
List of secret keys
|
|
220
|
+
"""
|
|
221
|
+
secrets_dict = _load_fallback_secrets()
|
|
222
|
+
return list(secrets_dict.keys())
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def migrate_plaintext_token(plaintext_token: str, key: str = "auth_token") -> bool:
|
|
226
|
+
"""
|
|
227
|
+
Migrate a plaintext token to secure storage.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
plaintext_token: The token to migrate
|
|
231
|
+
key: Key to store under (default: 'auth_token')
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
True if migration successful
|
|
235
|
+
"""
|
|
236
|
+
return store_secret(key, plaintext_token)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def get_storage_info() -> dict[str, Any]:
|
|
240
|
+
"""
|
|
241
|
+
Get information about current storage backend.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Dict with storage type and details
|
|
245
|
+
"""
|
|
246
|
+
using_keyring = _keyring_available and is_keyring_available()
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
"backend": "keyring" if using_keyring else "encrypted_file",
|
|
250
|
+
"keyring_available": _keyring_available,
|
|
251
|
+
"keyring_functional": is_keyring_available() if _keyring_available else False,
|
|
252
|
+
"fallback_file": str(SECRETS_FILE) if SECRETS_FILE.exists() else None,
|
|
253
|
+
"service_name": SERVICE_NAME,
|
|
254
|
+
}
|
|
255
|
+
|