grucli 3.3.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.
grucli/config.py ADDED
@@ -0,0 +1,262 @@
1
+ import os
2
+ import json
3
+ import base64
4
+ from cryptography.fernet import Fernet
5
+ from cryptography.hazmat.primitives import hashes
6
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
7
+ import getpass
8
+ import sys
9
+ from . import interrupt
10
+
11
+ CONFIG_DIR_NAME = '.grucli'
12
+
13
+ def get_config_dir():
14
+ home_dir = os.path.expanduser("~")
15
+ config_dir = os.path.join(home_dir, CONFIG_DIR_NAME)
16
+
17
+ os.makedirs(config_dir, exist_ok=True)
18
+
19
+ return config_dir
20
+
21
+ def get_key_storage_path(api_type='gemini'):
22
+ config_dir = get_config_dir()
23
+ if api_type == 'gemini':
24
+ return os.path.join(config_dir, 'api_keys_gemini.enc')
25
+ elif api_type == 'cerebras':
26
+ return os.path.join(config_dir, 'api_keys_cerebras.enc')
27
+ elif api_type == 'anthropic':
28
+ return os.path.join(config_dir, 'api_keys_anthropic.enc')
29
+ elif api_type == 'openai':
30
+ return os.path.join(config_dir, 'api_keys_openai.enc')
31
+ elif api_type == 'ollama':
32
+ return os.path.join(config_dir, 'api_keys_ollama.enc')
33
+ else:
34
+ return os.path.join(config_dir, 'api_keys.enc')
35
+
36
+
37
+ def get_settings_path():
38
+ config_dir = get_config_dir()
39
+ return os.path.join(config_dir, 'settings.json')
40
+
41
+ def load_settings():
42
+ path = get_settings_path()
43
+ if os.path.exists(path):
44
+ try:
45
+ with open(path, 'r') as f:
46
+ return json.load(f)
47
+ except Exception:
48
+ return {}
49
+ return {}
50
+
51
+ def save_settings(settings):
52
+ path = get_settings_path()
53
+ with open(path, 'w') as f:
54
+ json.dump(settings, f, indent=2)
55
+
56
+ def is_using_google_auth():
57
+ settings = load_settings()
58
+ return settings.get('use_google_auth', False)
59
+
60
+ def set_use_google_auth(use_google_auth: bool):
61
+ settings = load_settings()
62
+ settings['use_google_auth'] = use_google_auth
63
+ save_settings(settings)
64
+
65
+ def get_google_cloud_project():
66
+ settings = load_settings()
67
+ return settings.get('google_cloud_project')
68
+
69
+ def set_google_cloud_project(project_id: str):
70
+ settings = load_settings()
71
+ settings['google_cloud_project'] = project_id
72
+ save_settings(settings)
73
+
74
+ def get_history_file_path():
75
+ config_dir = get_config_dir()
76
+ return os.path.join(config_dir, 'history.txt')
77
+
78
+ def prune_history(file_path, max_entries=25):
79
+ if not os.path.exists(file_path):
80
+ return
81
+ try:
82
+ with open(file_path, 'r', encoding='utf-8') as f:
83
+ lines = f.readlines()
84
+
85
+ entries = []
86
+ current_entry = []
87
+ for line in lines:
88
+ if line.startswith('#'):
89
+ if current_entry:
90
+ entries.append(current_entry)
91
+ current_entry = [line]
92
+ else:
93
+ current_entry.append(line)
94
+ if current_entry:
95
+ entries.append(current_entry)
96
+
97
+ if len(entries) > max_entries:
98
+ entries = entries[-max_entries:]
99
+ with open(file_path, 'w', encoding='utf-8') as f:
100
+ for entry in entries:
101
+ f.writelines(entry)
102
+ except Exception:
103
+ pass
104
+
105
+ def derive_key_from_password(password: str, salt: bytes) -> bytes:
106
+ kdf = PBKDF2HMAC(
107
+ algorithm=hashes.SHA256(),
108
+ length=32,
109
+ salt=salt,
110
+ iterations=100000,
111
+ )
112
+ key = base64.urlsafe_b64encode(kdf.derive(password.encode()))
113
+ return key
114
+
115
+ def encrypt_data(data: str, master_password: str) -> tuple[bytes, bytes]:
116
+ salt = os.urandom(16)
117
+
118
+ key = derive_key_from_password(master_password, salt)
119
+
120
+ cipher = Fernet(key)
121
+ encrypted_data = cipher.encrypt(data.encode())
122
+
123
+ return encrypted_data, salt
124
+
125
+ def decrypt_data(encrypted_data: bytes, master_password: str, salt: bytes) -> str:
126
+ key = derive_key_from_password(master_password, salt)
127
+
128
+ cipher = Fernet(key)
129
+ decrypted_data = cipher.decrypt(encrypted_data)
130
+
131
+ return decrypted_data.decode()
132
+
133
+ def save_encrypted_api_key(api_key: str, api_type='gemini'):
134
+ print("\nTo securely store your API key, please set a password.")
135
+ print("This password will be used to encrypt your API key.")
136
+
137
+ while True:
138
+ password = interrupt.safe_getpass("Set password: ")
139
+ confirm_password = interrupt.safe_getpass("Confirm password: ")
140
+
141
+ if password == confirm_password:
142
+ break
143
+ else:
144
+ print("Passwords do not match. Please try again.")
145
+
146
+ encrypted_data, salt = encrypt_data(api_key, password)
147
+
148
+ storage_path = get_key_storage_path(api_type)
149
+ key_name = f'{api_type}_api_key'
150
+ storage_data = {
151
+ key_name: base64.b64encode(encrypted_data).decode(),
152
+ 'salt': base64.b64encode(salt).decode(),
153
+ }
154
+
155
+ with open(storage_path, 'w') as f:
156
+ json.dump(storage_data, f)
157
+
158
+ print(f"API key saved securely in {storage_path}")
159
+
160
+
161
+ def change_encrypted_api_key_password(api_type='gemini'):
162
+ storage_path = get_key_storage_path(api_type)
163
+
164
+ if not os.path.exists(storage_path):
165
+ print("No saved API key found.")
166
+ return False
167
+
168
+ try:
169
+ with open(storage_path, 'r') as f:
170
+ storage_data = json.load(f)
171
+
172
+ key_name = f'{api_type}_api_key'
173
+ if key_name not in storage_data or 'salt' not in storage_data:
174
+ print("Saved API key data is corrupted.")
175
+ return False
176
+
177
+ encrypted_data = base64.b64decode(storage_data[key_name])
178
+ salt = base64.b64decode(storage_data['salt'])
179
+
180
+ current_password = interrupt.safe_getpass("\nEnter current password: ")
181
+ decrypted_api_key = decrypt_data(encrypted_data, current_password, salt)
182
+
183
+ while True:
184
+ new_password = interrupt.safe_getpass("Enter new password: ")
185
+ confirm_new_password = interrupt.safe_getpass("Confirm new password: ")
186
+
187
+ if new_password == confirm_new_password:
188
+ break
189
+ else:
190
+ print("Passwords do not match. Please try again.")
191
+
192
+ new_encrypted_data, new_salt = encrypt_data(decrypted_api_key, new_password)
193
+
194
+ key_name = f'{api_type}_api_key'
195
+ new_storage_data = {
196
+ key_name: base64.b64encode(new_encrypted_data).decode(),
197
+ 'salt': base64.b64encode(new_salt).decode(),
198
+ }
199
+
200
+ with open(storage_path, 'w') as f:
201
+ json.dump(new_storage_data, f)
202
+
203
+ print("Password changed successfully.")
204
+ return True
205
+
206
+ except Exception as e:
207
+ print(f"Error changing password: {e}")
208
+ return False
209
+
210
+
211
+ def remove_saved_api_key(api_type='gemini'):
212
+ storage_path = get_key_storage_path(api_type)
213
+
214
+ if not os.path.exists(storage_path):
215
+ print("No saved API key found.")
216
+ return False
217
+
218
+ confirm = interrupt.safe_input(f"Are you sure you want to remove the saved API key? (y/N): ").strip().lower()
219
+ if confirm == 'y':
220
+ try:
221
+ os.remove(storage_path)
222
+ print("Saved API key removed successfully.")
223
+ return True
224
+ except Exception as e:
225
+ print(f"Error removing saved API key: {e}")
226
+ return False
227
+ else:
228
+ print("Operation cancelled.")
229
+ return False
230
+
231
+
232
+ def load_decrypted_api_key(api_type='gemini') -> str:
233
+ storage_path = get_key_storage_path(api_type)
234
+
235
+ if not os.path.exists(storage_path):
236
+ return None
237
+
238
+ try:
239
+ with open(storage_path, 'r') as f:
240
+ storage_data = json.load(f)
241
+
242
+ key_name = f'{api_type}_api_key'
243
+ if key_name not in storage_data or 'salt' not in storage_data:
244
+ return None
245
+
246
+ encrypted_data = base64.b64decode(storage_data[key_name])
247
+ salt = base64.b64decode(storage_data['salt'])
248
+
249
+ password = interrupt.safe_getpass("\nEnter password to unlock API key: ")
250
+
251
+ decrypted_api_key = decrypt_data(encrypted_data, password, salt)
252
+ return decrypted_api_key
253
+
254
+ except Exception as e:
255
+ print(f"Error loading API key: {e}")
256
+ return None
257
+
258
+
259
+ def has_saved_api_key(api_type='gemini') -> bool:
260
+ storage_path = get_key_storage_path(api_type)
261
+ return os.path.exists(storage_path)
262
+
grucli/handlers.py ADDED
@@ -0,0 +1,75 @@
1
+ """
2
+ Key binding handlers for grucli chat interface.
3
+ """
4
+
5
+ import sys
6
+ import time
7
+ import asyncio
8
+ from prompt_toolkit.key_binding import KeyBindings
9
+ from prompt_toolkit.keys import Keys
10
+ from prompt_toolkit.formatted_text import HTML
11
+ from prompt_toolkit.filters import has_completions
12
+ from . import interrupt
13
+ from . import permissions
14
+ from .theme import Colors
15
+
16
+
17
+ def get_chat_bindings(state):
18
+ """Get key bindings for the chat prompt."""
19
+ kb = KeyBindings()
20
+
21
+ @kb.add(Keys.BackTab)
22
+ def _(event):
23
+ # Toggle accepting edits (Shift+Tab)
24
+ store = permissions.PERMISSION_STORE
25
+ if store.is_allowed(permissions.PermissionGroup.WRITE):
26
+ store.revoke_always(permissions.PermissionGroup.WRITE)
27
+ store.revoke_always(permissions.PermissionGroup.READ)
28
+ else:
29
+ store.allow_always(permissions.PermissionGroup.WRITE)
30
+ # allow_always(WRITE) automatically grants READ in permissions.py
31
+ event.app.invalidate()
32
+
33
+ @kb.add(Keys.ControlC)
34
+ @kb.add(Keys.Escape)
35
+ def _(event):
36
+ # Clear current input first
37
+ if event.current_buffer.text:
38
+ event.current_buffer.text = ""
39
+ return
40
+
41
+ if interrupt.should_quit():
42
+ interrupt.clear_bottom_warning()
43
+ sys.exit(0)
44
+
45
+ # Show styled warning in bottom toolbar
46
+ state['toolbar'] = HTML(
47
+ f'<style fg="#ffff00">{interrupt.get_quit_hint()}</style>'
48
+ )
49
+
50
+ # Background task to clear the message after 3 seconds
51
+ async def clear_warning():
52
+ await asyncio.sleep(3)
53
+ # Only clear if it hasn't been set to something else
54
+ if state.get('toolbar') and "ctrl+c" in str(state['toolbar']).lower():
55
+ state['toolbar'] = None
56
+ event.app.invalidate()
57
+
58
+ event.app.create_background_task(clear_warning())
59
+
60
+ @kb.add(Keys.ControlJ)
61
+ def _(event):
62
+ # Ctrl+J for newline
63
+ event.current_buffer.insert_text('\n')
64
+
65
+ @kb.add(Keys.Enter, filter=has_completions)
66
+ def _(event):
67
+ # If completion menu is open, Enter selects the current item
68
+ buf = event.current_buffer
69
+ if buf.complete_state and buf.complete_state.current_completion:
70
+ buf.apply_completion(buf.complete_state.current_completion)
71
+ else:
72
+ # Fallback in case filter was true but state vanished or no item selected
73
+ event.current_buffer.validate_and_handle()
74
+
75
+ return kb
grucli/interrupt.py ADDED
@@ -0,0 +1,179 @@
1
+ """
2
+ Interrupt handling and input utilities for grucli.
3
+ """
4
+
5
+ import sys
6
+ import time
7
+ import shutil
8
+ import threading
9
+
10
+ try:
11
+ import termios
12
+ except ImportError:
13
+ termios = None
14
+
15
+ _last_interrupt_time = 0
16
+ _warning_stop_event = threading.Event()
17
+ _warning_stop_event.set() # Initially stopped
18
+ _warning_lock = threading.Lock()
19
+
20
+
21
+ class BackSignal(Exception):
22
+ """Signal to go back to previous menu."""
23
+ pass
24
+
25
+
26
+ def get_quit_hint() -> str:
27
+ """Get the quit hint message."""
28
+ return "Press Ctrl+C again to quit"
29
+
30
+
31
+ def hide_control_chars():
32
+ """Hide control character echo in terminal."""
33
+ try:
34
+ attrs = termios.tcgetattr(sys.stdin)
35
+ attrs[3] &= ~termios.ECHOCTL
36
+ termios.tcsetattr(sys.stdin, termios.TCSANOW, attrs)
37
+ except Exception:
38
+ pass
39
+
40
+
41
+ def show_control_chars():
42
+ """Show control character echo in terminal."""
43
+ try:
44
+ attrs = termios.tcgetattr(sys.stdin)
45
+ attrs[3] |= termios.ECHOCTL
46
+ termios.tcsetattr(sys.stdin, termios.TCSANOW, attrs)
47
+ except Exception:
48
+ pass
49
+
50
+
51
+ def set_echo(enable: bool):
52
+ """Disable or enable terminal echo."""
53
+ if not termios:
54
+ return
55
+ try:
56
+ attrs = termios.tcgetattr(sys.stdin)
57
+ if enable:
58
+ attrs[3] |= termios.ECHO
59
+ else:
60
+ attrs[3] &= ~termios.ECHO
61
+ termios.tcsetattr(sys.stdin, termios.TCSANOW, attrs)
62
+ except Exception:
63
+ pass
64
+
65
+
66
+ def flush_input():
67
+ """Flush pending terminal input."""
68
+ if not termios:
69
+ return
70
+ try:
71
+ termios.tcflush(sys.stdin, termios.TCIFLUSH)
72
+ except Exception:
73
+ pass
74
+
75
+
76
+ def should_quit():
77
+ """Check if user has pressed Ctrl+C twice within 3 seconds."""
78
+ global _last_interrupt_time
79
+ now = time.time()
80
+ if now - _last_interrupt_time < 3:
81
+ return True
82
+ _last_interrupt_time = now
83
+ return False
84
+
85
+
86
+ def _warning_loop():
87
+ """Background thread that displays the quit warning."""
88
+ while not _warning_stop_event.is_set():
89
+ try:
90
+ columns, rows = shutil.get_terminal_size()
91
+ target_row = max(1, rows - 1)
92
+ msg = get_quit_hint()
93
+ # \033[0;93m resets everything and then sets foreground to bright yellow
94
+ sys.stdout.write(f"\0337\033[{target_row};1H\033[0;93m{msg}\033[0m\0338")
95
+ sys.stdout.flush()
96
+ except Exception:
97
+ pass
98
+
99
+ for _ in range(5):
100
+ if _warning_stop_event.is_set():
101
+ break
102
+ time.sleep(0.1)
103
+
104
+
105
+ def clear_bottom_warning():
106
+ """Clear the bottom warning bar."""
107
+ _warning_stop_event.set()
108
+ try:
109
+ _, rows = shutil.get_terminal_size()
110
+ target_row = max(1, rows - 1)
111
+ sys.stdout.write(f"\0337\033[{target_row};1H\033[K\0338")
112
+ sys.stdout.flush()
113
+ except Exception:
114
+ pass
115
+
116
+
117
+ def show_bottom_warning():
118
+ """Show the bottom warning bar."""
119
+ with _warning_lock:
120
+ if not _warning_stop_event.is_set():
121
+ return
122
+ _warning_stop_event.clear()
123
+ threading.Thread(target=_warning_loop, daemon=True).start()
124
+
125
+ def stop_later():
126
+ time.sleep(3)
127
+ clear_bottom_warning()
128
+
129
+ threading.Thread(target=stop_later, daemon=True).start()
130
+
131
+
132
+ def handle_interrupt(exit_msg="\nbye"):
133
+ """Handle keyboard interrupt with double-press detection."""
134
+ if should_quit():
135
+ clear_bottom_warning()
136
+ if exit_msg:
137
+ print(exit_msg)
138
+ sys.exit(0)
139
+
140
+ show_bottom_warning()
141
+ return get_quit_hint()
142
+
143
+
144
+ def prompt_input(prompt_text, is_password=False):
145
+ """Get user input with styled prompt."""
146
+ from prompt_toolkit import PromptSession
147
+ from prompt_toolkit.key_binding import KeyBindings
148
+ from prompt_toolkit.keys import Keys
149
+ from prompt_toolkit.formatted_text import ANSI
150
+
151
+ kb = KeyBindings()
152
+
153
+ @kb.add(Keys.ControlC)
154
+ @kb.add(Keys.Escape)
155
+ def _(event):
156
+ event.app.exit(exception=KeyboardInterrupt)
157
+
158
+ session = PromptSession(key_bindings=kb)
159
+ return session.prompt(ANSI(prompt_text), is_password=is_password)
160
+
161
+
162
+ def safe_input(prompt):
163
+ """Get input with interrupt handling."""
164
+ while True:
165
+ try:
166
+ return prompt_input(prompt)
167
+ except KeyboardInterrupt:
168
+ handle_interrupt()
169
+ print()
170
+
171
+
172
+ def safe_getpass(prompt):
173
+ """Get password input with interrupt handling."""
174
+ while True:
175
+ try:
176
+ return prompt_input(prompt, is_password=True)
177
+ except KeyboardInterrupt:
178
+ handle_interrupt()
179
+ print()