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/ui.py ADDED
@@ -0,0 +1,496 @@
1
+ """
2
+ UI components for grucli - polished terminal interface.
3
+ """
4
+ import re
5
+ import time
6
+ import shutil
7
+ import threading
8
+
9
+ from prompt_toolkit.application import Application
10
+ from prompt_toolkit.key_binding import KeyBindings
11
+ from prompt_toolkit.keys import Keys
12
+ from prompt_toolkit.layout.containers import Window
13
+ from prompt_toolkit.layout.controls import FormattedTextControl
14
+ from prompt_toolkit.layout.layout import Layout
15
+ from prompt_toolkit.styles import Style
16
+ from . import interrupt
17
+ from .theme import Colors, Borders, Icons, Styles, get_terminal_width
18
+
19
+
20
+ def print_messages(messages):
21
+ """Print chat history to the terminal."""
22
+ from . import tools # avoid circular import
23
+
24
+ for msg in messages:
25
+ role = msg.get('role')
26
+ content = msg.get('content', '')
27
+ reasoning = msg.get('reasoning')
28
+
29
+ if role == 'system':
30
+ continue
31
+
32
+ if role == 'user':
33
+ # Remove the attached files part for display if it exists
34
+ display_content = content
35
+ if "--- ATTACHED FILES ---" in content:
36
+ display_content = content.split("--- ATTACHED FILES ---")[0].strip()
37
+
38
+ print(f"\n{Colors.PRIMARY}> {Colors.RESET}{display_content}")
39
+
40
+ elif role == 'assistant':
41
+ ai_prefix = f"{Colors.SECONDARY}{Icons.DIAMOND}{Colors.RESET} "
42
+ print(f"\n{ai_prefix}", end="")
43
+
44
+ # Show reasoning duration if available
45
+ duration = msg.get('thinking_duration')
46
+ if duration:
47
+ print(f"{Colors.MUTED}[Thought for {duration:.0f}s]{Colors.RESET}")
48
+ elif reasoning:
49
+ print(f"{Colors.MUTED}[Thought for history]{Colors.RESET}")
50
+
51
+ # Clean content from <think> tags if they exist (some models output them in text)
52
+ display_content = content
53
+ if "<think>" in content and "</think>" in content:
54
+ # Remove everything between and including <think> tags
55
+ display_content = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL).strip()
56
+
57
+ # Find tool calls in content
58
+ cmds = tools.parse_commands(display_content)
59
+
60
+ if not cmds:
61
+ print(display_content)
62
+ else:
63
+ # Split content by tool calls to print them with their boxes
64
+ last_idx = 0
65
+ for cmd in cmds:
66
+ tool_str = cmd['original']
67
+ start_pos = display_content.find(tool_str, last_idx)
68
+
69
+ if start_pos != -1:
70
+ # Print text before tool
71
+ pre_text = display_content[last_idx:start_pos]
72
+ if pre_text:
73
+ print(pre_text)
74
+
75
+ # Print tool box
76
+ name = cmd['name']
77
+ args = cmd['args']
78
+
79
+ from .main import get_tool_category
80
+ category = get_tool_category(name)
81
+ print(format_tool_call_block(name, args, category))
82
+
83
+ last_idx = start_pos + len(tool_str)
84
+
85
+ # Print remaining text
86
+ remaining = display_content[last_idx:]
87
+ if remaining:
88
+ print(remaining)
89
+ print()
90
+
91
+
92
+ def print_ascii_art():
93
+ """Print a generated ASCII art logo for GRU CLI with a seamless vertical gradient."""
94
+ # Font style: ANSI Shadow
95
+ logo = [
96
+ " ██████╗ ██████╗ ██╗ ██╗ ██████╗██╗ ██╗",
97
+ "██╔════╝ ██╔══██╗██║ ██║██╔════╝██║ ██║",
98
+ "██║ ███╗██████╔╝██║ ██║██║ ██║ ██║",
99
+ "██║ ██║██╔══██╗██║ ██║██║ ██║ ██║",
100
+ "╚██████╔╝██║ ██║╚██████╔╝╚██████╗███████╗██║",
101
+ " ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚═╝"
102
+ ]
103
+
104
+ # 6-step Gradient: Blue -> Light Blue -> Purple/Pink
105
+ # Maps exactly to the 6 lines of the logo
106
+ gradient = [
107
+ "\033[38;5;33m", # DodgerBlue1
108
+ "\033[38;5;39m", # DeepSkyBlue1
109
+ "\033[38;5;75m", # SteelBlue1
110
+ "\033[38;5;111m", # SkyBlue2
111
+ "\033[38;5;141m", # MediumPurple1
112
+ "\033[38;5;177m" # Violet
113
+ ]
114
+
115
+ indent = " "
116
+ print()
117
+ for i, line in enumerate(logo):
118
+ # Use the corresponding color for the line, default to last color if out of bounds
119
+ color = gradient[i] if i < len(gradient) else gradient[-1]
120
+ print(f"{indent}{color}{line}{Colors.RESET}")
121
+
122
+ from . import __version__
123
+ print(f"{indent}{Colors.MUTED}Version {__version__}{Colors.RESET}")
124
+ print()
125
+
126
+
127
+ def select_option(options, title="Select an option:", is_root=False):
128
+ """
129
+ Display a styled selection menu with keyboard navigation.
130
+
131
+ Args:
132
+ options: List of options (strings or (style, text) tuples)
133
+ title: Header text for the menu
134
+ is_root: If True, Ctrl+C exits app; otherwise goes back
135
+
136
+ Returns:
137
+ (selected_value, selected_index) tuple
138
+ """
139
+ if not options:
140
+ raise ValueError("No options provided")
141
+
142
+ selected_index = 0
143
+ result = None
144
+
145
+ from prompt_toolkit.formatted_text import ANSI, to_formatted_text
146
+
147
+ def get_text():
148
+ text = []
149
+ # Title with accent color
150
+ text.extend(to_formatted_text(ANSI(f" {Colors.HEADER}{title}{Colors.RESET}\n\n")))
151
+
152
+ for i, option in enumerate(options):
153
+ if isinstance(option, tuple):
154
+ _, opt_text = option
155
+ else:
156
+ opt_text = option
157
+
158
+ if i == selected_index:
159
+ # Selected item: highlighted with arrow
160
+ prefix = f" {Icons.ARROW_RIGHT} "
161
+ # Bold white text on purple background
162
+ line = f"\033[38;5;255;48;5;61;1m{prefix}{opt_text}\033[0m\n"
163
+ text.extend(to_formatted_text(ANSI(line)))
164
+ else:
165
+ prefix = " "
166
+ text.extend(to_formatted_text(ANSI(f"{prefix}{opt_text}\n")))
167
+
168
+ # Footer hint
169
+ text.extend(to_formatted_text(ANSI(f"\n {Colors.MUTED}{Icons.BULLET} Use arrows to navigate, Enter to select{Colors.RESET}\n")))
170
+
171
+ return text
172
+
173
+ kb = KeyBindings()
174
+
175
+ @kb.add(Keys.Up)
176
+ @kb.add('k') # vim-style
177
+ def _(event):
178
+ nonlocal selected_index
179
+ selected_index = (selected_index - 1) % len(options)
180
+
181
+ @kb.add(Keys.Down)
182
+ @kb.add('j') # vim-style
183
+ def _(event):
184
+ nonlocal selected_index
185
+ selected_index = (selected_index + 1) % len(options)
186
+
187
+ @kb.add(Keys.Enter)
188
+ def _(event):
189
+ nonlocal result
190
+ val = options[selected_index][1] if isinstance(options[selected_index], tuple) else options[selected_index]
191
+ result = (val, selected_index)
192
+ event.app.exit()
193
+
194
+ @kb.add(Keys.ControlC)
195
+ @kb.add(Keys.Escape, eager=True)
196
+ def _(event):
197
+ if not is_root:
198
+ event.app.exit(exception=interrupt.BackSignal)
199
+ return
200
+
201
+ if interrupt.should_quit():
202
+ interrupt.clear_bottom_warning()
203
+ event.app.exit(exception=KeyboardInterrupt)
204
+ else:
205
+ interrupt.show_bottom_warning()
206
+
207
+ style = Style.from_dict({
208
+ '': '#eeeeee bg:#1c1c1c', # Default: light gray on dark gray
209
+ 'title': '#af5fff bold', # Purple, bold
210
+ 'selected': '#ffffff bg:#5f5faf', # White on purple
211
+ 'option': '#eeeeee', # Near white
212
+ 'cloud': '#0087ff', # Blue for cloud
213
+ 'hint': '#8a8a8a', # Medium gray
214
+ })
215
+
216
+ app = Application(
217
+ layout=Layout(Window(content=FormattedTextControl(get_text))),
218
+ key_bindings=kb,
219
+ style=style,
220
+ full_screen=True,
221
+ )
222
+
223
+ app.run()
224
+ return result
225
+
226
+
227
+ def strip_ansi(text: str) -> str:
228
+ """Remove ANSI escape codes for length calculation."""
229
+ return re.sub(r'\033\[[0-9;]*m', '', text)
230
+
231
+
232
+ def get_visible_len(text: str) -> int:
233
+ """Get the visible length of a string containing ANSI escape codes."""
234
+ return len(strip_ansi(text))
235
+
236
+
237
+ def print_header(title: str, subtitle: str = None):
238
+ """Print a styled header with optional subtitle."""
239
+ width = get_terminal_width()
240
+
241
+ print()
242
+ print(f"{Colors.PRIMARY}{Colors.BOLD}{title}{Colors.RESET}")
243
+ if subtitle:
244
+ print(f"{Colors.MUTED}{subtitle}{Colors.RESET}")
245
+ print(f"{Colors.MUTED}{Borders.HORIZONTAL * min(width - 4, 60)}{Colors.RESET}")
246
+ print()
247
+
248
+
249
+ def print_box(content: str, style: str = "info", title: str = None):
250
+ """
251
+ Print content in a styled box.
252
+
253
+ Args:
254
+ content: Text content (can be multiline)
255
+ style: "info", "success", "warning", "error"
256
+ title: Optional title for the box
257
+ """
258
+ width = min(get_terminal_width() - 4, 70)
259
+
260
+ style_colors = {
261
+ "info": Colors.INFO,
262
+ "success": Colors.SUCCESS,
263
+ "warning": Colors.WARNING,
264
+ "error": Colors.ERROR,
265
+ }
266
+ color = style_colors.get(style, Colors.INFO)
267
+
268
+ lines = content.split('\n')
269
+
270
+ # Top border
271
+ if title:
272
+ title_text = f" {title} "
273
+ padding = width - len(title_text) - 2
274
+ print(f"{color}{Borders.TOP_LEFT}{Borders.HORIZONTAL}{title_text}{Borders.HORIZONTAL * padding}{Borders.TOP_RIGHT}{Colors.RESET}")
275
+ else:
276
+ print(f"{color}{Borders.TOP_LEFT}{Borders.HORIZONTAL * width}{Borders.TOP_RIGHT}{Colors.RESET}")
277
+
278
+ # Content lines
279
+ for line in lines:
280
+ visible = strip_ansi(line)
281
+ if len(visible) > width - 2:
282
+ line = line[:width - 5] + "..."
283
+ visible = strip_ansi(line)
284
+ padding = " " * (width - len(visible) - 2)
285
+ print(f"{color}{Borders.VERTICAL}{Colors.RESET} {line}{padding}{color}{Borders.VERTICAL}{Colors.RESET}")
286
+
287
+ # Bottom border
288
+ print(f"{color}{Borders.BOTTOM_LEFT}{Borders.HORIZONTAL * width}{Borders.BOTTOM_RIGHT}{Colors.RESET}")
289
+
290
+
291
+ def print_divider(char: str = None, color: str = None):
292
+ """Print a horizontal divider line."""
293
+ width = min(get_terminal_width() - 4, 60)
294
+ c = char or Borders.HORIZONTAL
295
+ col = color or Colors.MUTED
296
+ print(f"{col}{c * width}{Colors.RESET}")
297
+
298
+
299
+ def print_tips():
300
+ """Print the getting started tips (inspired by reference image)."""
301
+ print(f"{Colors.MUTED}Tips for getting started:{Colors.RESET}")
302
+ print(f"{Colors.MUTED}1. Ask questions, edit files, or run commands.{Colors.RESET}")
303
+ print(f"{Colors.MUTED}2. Be specific for the best results.{Colors.RESET}")
304
+ print(f"{Colors.MUTED}3. Use {Colors.SECONDARY}@file{Colors.MUTED} to reference files in your prompt.{Colors.RESET}")
305
+ print(f"{Colors.MUTED}4. {Colors.SECONDARY}/help{Colors.MUTED} for more information.{Colors.RESET}")
306
+ print()
307
+
308
+
309
+ def format_tool_call_block(name: str, args: dict, category: str = "read") -> str:
310
+ """
311
+ Format a tool call as a styled block.
312
+
313
+ Args:
314
+ name: Tool name
315
+ args: Tool arguments
316
+ category: "read", "write", or "destructive"
317
+ """
318
+ width = min(get_terminal_width() - 4, 65)
319
+
320
+ colors = {
321
+ "read": Colors.READ_OP,
322
+ "write": Colors.WRITE_OP,
323
+ "destructive": Colors.DESTRUCTIVE_OP,
324
+ }
325
+ icons = {
326
+ "read": Icons.READ,
327
+ "write": Icons.WRITE,
328
+ "destructive": Icons.DELETE,
329
+ }
330
+
331
+ color = colors.get(category, Colors.INFO)
332
+ icon = icons.get(category, Icons.INFO)
333
+
334
+ lines = []
335
+
336
+ # Header
337
+ header = f" {icon} {category.upper()} "
338
+ padding = width - len(header) - 2
339
+ lines.append(f"{color}{Borders.TOP_LEFT}{Borders.HORIZONTAL}{header}{Borders.HORIZONTAL * padding}{Borders.TOP_RIGHT}{Colors.RESET}")
340
+
341
+ # Tool name
342
+ tool_line_content = f" {Colors.BOLD}{name}{Colors.RESET}"
343
+ visible_tool_len = get_visible_len(tool_line_content)
344
+ padding_tool = " " * (width - visible_tool_len)
345
+ lines.append(f"{color}{Borders.VERTICAL}{Colors.RESET}{tool_line_content}{padding_tool}{color}{Borders.VERTICAL}{Colors.RESET}")
346
+
347
+ # Arguments
348
+ display_args = args.copy()
349
+ if name == 'edit_file' and not display_args.get('new_string'):
350
+ display_args['new_string'] = "(empty)"
351
+
352
+ for key, value in display_args.items():
353
+ if isinstance(value, str):
354
+ # Special handling for 'content' in create_file (show multiline)
355
+ if name == 'create_file' and key == 'content':
356
+ content_lines = value.split('\n')
357
+ display_lines = content_lines[:20] # Cap at 20 lines
358
+ has_more = len(content_lines) > 20
359
+
360
+ # Label row
361
+ arg_label = f" {Colors.MUTED}{key}:{Colors.RESET}"
362
+ visible_label_len = get_visible_len(arg_label)
363
+ padding_label = " " * (width - visible_label_len)
364
+ lines.append(f"{color}{Borders.VERTICAL}{Colors.RESET}{arg_label}{padding_label}{color}{Borders.VERTICAL}{Colors.RESET}")
365
+
366
+ # Content rows
367
+ for subline in display_lines:
368
+ # Truncate line width
369
+ if len(subline) > width - 6:
370
+ subline = subline[:width - 9] + "..."
371
+
372
+ row = f" {Colors.MUTED}{subline}{Colors.RESET}"
373
+ visible_row_len = get_visible_len(row)
374
+ padding_row = " " * (width - visible_row_len)
375
+ lines.append(f"{color}{Borders.VERTICAL}{Colors.RESET}{row}{padding_row}{color}{Borders.VERTICAL}{Colors.RESET}")
376
+
377
+ if has_more:
378
+ more_row = f" {Colors.MUTED}... ({len(content_lines) - 20} more lines){Colors.RESET}"
379
+ visible_more_len = get_visible_len(more_row)
380
+ padding_more = " " * (width - visible_more_len)
381
+ lines.append(f"{color}{Borders.VERTICAL}{Colors.RESET}{more_row}{padding_more}{color}{Borders.VERTICAL}{Colors.RESET}")
382
+ continue
383
+
384
+ # Default truncation for other string args
385
+ display = value[:width - 15] + "..." if len(value) > width - 12 else value
386
+ if '\n' in display:
387
+ display = display.split('\n')[0] + "..."
388
+ else:
389
+ display = repr(value)
390
+
391
+ arg_line_content = f" {Colors.MUTED}{key}:{Colors.RESET} {display}"
392
+ visible_arg_len = get_visible_len(arg_line_content)
393
+
394
+ if visible_arg_len > width - 2:
395
+ arg_line_content = arg_line_content[:width - 5] + "..."
396
+ visible_arg_len = get_visible_len(arg_line_content)
397
+
398
+ padding_arg = " " * (width - visible_arg_len)
399
+ lines.append(f"{color}{Borders.VERTICAL}{Colors.RESET}{arg_line_content}{padding_arg}{color}{Borders.VERTICAL}{Colors.RESET}")
400
+
401
+ # Footer
402
+ lines.append(f"{color}{Borders.BOTTOM_LEFT}{Borders.HORIZONTAL * width}{Borders.BOTTOM_RIGHT}{Colors.RESET}")
403
+
404
+ return "\n".join(lines)
405
+
406
+
407
+ def format_diff(old_content: str, new_content: str, filename: str) -> str:
408
+ """
409
+ Format file changes as a GitHub-style diff.
410
+
411
+ Args:
412
+ old_content: Original content being replaced
413
+ new_content: New content
414
+ filename: Name of the file being edited
415
+ """
416
+ width = min(get_terminal_width() - 4, 70)
417
+
418
+ lines = []
419
+
420
+ # Header
421
+ header = f" {Icons.WRITE} EDIT: {filename} "
422
+ padding = max(0, width - len(header) - 2)
423
+ lines.append(f"{Colors.WRITE_OP}{Borders.TOP_LEFT}{Borders.HORIZONTAL}{header}{Borders.HORIZONTAL * padding}{Borders.TOP_RIGHT}{Colors.RESET}")
424
+
425
+ # Old content (red, prefixed with -)
426
+ old_lines = old_content.split('\n')
427
+ for old_line in old_lines[:10]: # Limit display
428
+ display = old_line[:width - 6] if len(old_line) > width - 6 else old_line
429
+ lines.append(f"{Colors.WRITE_OP}{Borders.VERTICAL}{Colors.RESET} {Colors.ERROR}- {display}{Colors.RESET}")
430
+ if len(old_lines) > 10:
431
+ lines.append(f"{Colors.WRITE_OP}{Borders.VERTICAL}{Colors.RESET} {Colors.MUTED} ... ({len(old_lines) - 10} more lines){Colors.RESET}")
432
+
433
+ # Separator
434
+ lines.append(f"{Colors.WRITE_OP}{Borders.VERTICAL}{Colors.MUTED}{'─' * (width - 1)}{Colors.RESET}")
435
+
436
+ # New content (green, prefixed with +)
437
+ if new_content is None:
438
+ new_content = ""
439
+
440
+ new_lines = new_content.split('\n')
441
+ for new_line in new_lines[:10]:
442
+ display = new_line[:width - 6] if len(new_line) > width - 6 else new_line
443
+ lines.append(f"{Colors.WRITE_OP}{Borders.VERTICAL}{Colors.RESET} {Colors.SUCCESS}+ {display}{Colors.RESET}")
444
+ if len(new_lines) > 10:
445
+ lines.append(f"{Colors.WRITE_OP}{Borders.VERTICAL}{Colors.RESET} {Colors.MUTED} ... ({len(new_lines) - 10} more lines){Colors.RESET}")
446
+
447
+ # Footer
448
+ lines.append(f"{Colors.WRITE_OP}{Borders.BOTTOM_LEFT}{Borders.HORIZONTAL * width}{Borders.BOTTOM_RIGHT}{Colors.RESET}")
449
+
450
+ return "\n".join(lines)
451
+
452
+
453
+ class Spinner:
454
+ """Animated spinner for LLM generation feedback."""
455
+
456
+ def __init__(self, message: str = "Generating"):
457
+ self.message = message
458
+ self.frames = Icons.SPINNER_FRAMES
459
+ self.frame_idx = 0
460
+ self._running = False
461
+ self._thread = None
462
+
463
+ def _animate(self):
464
+ import sys
465
+ import time
466
+
467
+ while self._running:
468
+ frame = self.frames[self.frame_idx % len(self.frames)]
469
+ sys.stdout.write(f"\r{Colors.SECONDARY}{frame}{Colors.RESET} {Colors.MUTED}{self.message}...{Colors.RESET} ")
470
+ sys.stdout.flush()
471
+ self.frame_idx += 1
472
+ time.sleep(0.1)
473
+
474
+ def start(self):
475
+ import threading
476
+
477
+ self._running = True
478
+ self._thread = threading.Thread(target=self._animate, daemon=True)
479
+ self._thread.start()
480
+
481
+ def stop(self):
482
+ import sys
483
+
484
+ self._running = False
485
+ if self._thread:
486
+ self._thread.join(timeout=0.2)
487
+ # Clear the spinner line
488
+ sys.stdout.write(f"\r{' ' * 40}\r")
489
+ sys.stdout.flush()
490
+
491
+ def __enter__(self):
492
+ self.start()
493
+ return self
494
+
495
+ def __exit__(self, *args):
496
+ self.stop()
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: grucli
3
+ Version: 3.3.0
4
+ Summary: A command-line interface for interacting with local and remote LLMs.
5
+ Home-page: https://github.com/grufr/grucli
6
+ Author: grufr
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.6
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Dynamic: author
14
+ Dynamic: classifier
15
+ Dynamic: description
16
+ Dynamic: description-content-type
17
+ Dynamic: home-page
18
+ Dynamic: license-file
19
+ Dynamic: requires-python
20
+ Dynamic: summary
21
+
22
+ # gru-cli -- Version 3.3.0
23
+
24
+ `grucli` is a CLI tool for agentic LLMs that works with both local LLMs and API-based models such as Claude, Gemini, GPT-5 and others.
25
+
26
+ ![gru-cli preview image](/public/assets/preview-version-330.png "gru-cli preview")
27
+
28
+ ## why?
29
+ I hate how other CLI tools have big ass 3000 line+ system prompts and don't allow you to run local models or use custom APIs.
30
+
31
+ So I decided to make my own CLI tool for *professional vibe coders🔥🔥🔥*.
32
+
33
+ I'll try to improve on it over time, if I don't give up on this project that is.
34
+
35
+ ## Features
36
+
37
+ * **Interactive Chat:** Have a conversation with your chosen LLM right from your terminal.
38
+ * **Model Selection:** Easily switch between different locally-served models.
39
+ * **Command System:** Use slash commands (`/help`, `/model`, `/clear`, `/manage-api-keys`) for quick actions.
40
+ * **Context-Aware:** Automatically injects your project's file tree into the system prompt to give the LLM better context.
41
+ * **Encrypted API keys** You don't need to worry about someone snooping your API keys, they're encrypted :)
42
+
43
+ ## Supported APIs
44
+
45
+ GruCLI supports models from the following providers:
46
+
47
+ * **OpenAI:** All available models, including GPT-5.2
48
+ * **Anthropic:** All available models, including Claude Opus and Sonnet 4.5.
49
+ * **Gemini:** Supports BOTH Gemini API <bold>AND</bold> Google OAUTH
50
+ * **Ollama:** Supports both local and cloud models.
51
+ * **LM Studio:** Supports all local models.
52
+ * **Cerebras:** Supports their API available models.
53
+
54
+ * If a model doesn't show up on your selected API, you can just type it out.
55
+
56
+ ## Installation
57
+
58
+ To install `grucli`, clone this repository and use `pip` to install the package.
59
+
60
+ 1. **Clone the repository (or download the source):**
61
+ ```bash
62
+ git clone https://github.com/grufr/grucli.git
63
+ cd gru-agentic-cli
64
+ ```
65
+
66
+ 2. **Install the package:**
67
+ * **For regular use:** This installs the CLI like any other tool.
68
+ ```bash
69
+ pip install .
70
+ ```
71
+ * **For development:** This links the command to your source code for instant updates.
72
+ ```bash
73
+ pip install -e .
74
+ ```
75
+
76
+ This will install the `grucli` command and all its dependencies.
77
+
78
+ ## WARNING !!
79
+ <div align="right"><sub style="position:relative; top:15px;">windows sucks anyways...</sub></div>
80
+ This project was **NOT** tested on Windows, it was only tested on Linux and macOS
81
+
82
+
83
+ ## Usage
84
+
85
+ Once installed, you can start the CLI by simply running:
86
+
87
+ ```bash
88
+ grucli
89
+ ```
90
+
91
+ This will ask you to select an available model and then take you to the chatting interface.
92
+
93
+ ### Commands
94
+
95
+ * `/help`: Show available commands.
96
+ * `/model`: Re-open the model selection menu.
97
+ * `/clear`: Clear the current chat history.
98
+ * `/manage-api-keys`: Manage your saved API keys.
99
+ * `/gemini-login`: Login with your Google account.
100
+ * `/gemini-auth-mode`: Switch Gemini authentication between API Key and Google Auth.
101
+ * `/exit`: Exit the application.
102
+
103
+ ## Development
104
+
105
+ If you want to contribute to or modify `grucli`, you should install it in "editable" mode as described above (`pip install -e .`).
106
+
107
+ This allows you to edit the Python files in the `src/grucli/` directory and have your changes be live the next time you run the `grucli` command. There is no need to run `pip install .` again after every code change.
108
+
109
+ If you change dependencies in `requirements.txt` or settings in `setup.py`, you should run `pip install -e .` again to apply those changes.
110
+ ## Testing
111
+
112
+ `grucli` uses `pytest` for testing. To run the tests, you must install `pytest` manually as it is not included in `requirements.txt` to keep the production installation lightweight for end-users.
113
+
114
+ ```bash
115
+ pip install pytest
116
+ pytest tests/
117
+ ```
118
+
119
+ ### Current Tests
120
+
121
+ The test suite covers:
122
+ * **API Interactions:** Validating stream processing and tool call parsing.
123
+ * **UI Formatting:** Ensuring tool blocks, diffs, and spinners render correctly.
124
+ * **Permissions System:** Verifying that tool execution permissions are respected.
125
+ * **ANSI Utilities:** Testing color and icon formatting.
126
+
127
+ If you want to contribute your code must pass all tests.
128
+
129
+ ## Uninstallation
130
+
131
+ To remove `grucli` from your system, simply run:
132
+
133
+ ```bash
134
+ pip uninstall grucli
135
+ ```
136
+
137
+ ## License
138
+
139
+ This project is licensed under the MIT License. See the `LICENSE` file for details.
140
+
141
+ ## funny
142
+
143
+ some of the code here was generated by grucli itself :)
144
+
145
+ My first open source project 💖🎀
@@ -0,0 +1,21 @@
1
+ grucli/__init__.py,sha256=MPSbkJS_-QSkLK7sMlFXFf5iWlbcSL18Dj2ZiMCtS9c,22
2
+ grucli/api.py,sha256=gLSy-4I1XKdg_6IIqfG_cZJmnrw0vQFuiJPnVnb-2AU,28031
3
+ grucli/auth.py,sha256=aHsxv3SDpVcA-KViJvIf6kRZjGBVH0oVq7YuqhNcSBc,3992
4
+ grucli/chat_manager.py,sha256=xfPv9d7-0JtS0mGE7ELLCjfUdjGvpIi-_roKQlNvUrw,6790
5
+ grucli/commands.py,sha256=p50HUvRTnY72ULtIagvqtONWKhFOF5qb5j2ZPXAxwU8,13640
6
+ grucli/config.py,sha256=c0J4jZJqYjIT03JaGscw1BS8LJrLyDp4s6Ib0I38-k4,8048
7
+ grucli/handlers.py,sha256=BXGCEQ39MFEN11y-tpLDVhLnlLcJmc8UUNsEZen3WDU,2520
8
+ grucli/interrupt.py,sha256=bf1WYKaXtXWogC10aSSinJ9kQAd4w663tr39N60lkng,4558
9
+ grucli/main.py,sha256=9wBuPY8ulTcslJMgm0ny8SCEiKqDUDnyCNc_xk4gNnw,23807
10
+ grucli/permissions.py,sha256=2vP9CSIhSKuyZYcU5aqn7RuL_a2pzb6l3Ckpbv7sxTw,6287
11
+ grucli/stats.py,sha256=PMBPTjxTGQ9_x1U5C-ikHe8baCwKioqiAy5twyHKk00,4269
12
+ grucli/theme.py,sha256=UypI5vF2XGPd_NULxUZZQ8YQyF2FYfU8XOqVk4U-6Lo,3845
13
+ grucli/tools.py,sha256=jvj6TgwseX5Kj7bm7zeInALsluG2F51wj1yeEJDTFGU,13261
14
+ grucli/ui.py,sha256=kbb2nua-iUxKnZF7WhrCbNoNYgkSIUpssJzkl5xRsgo,18774
15
+ grucli/sysprompts/main_sysprompt.txt,sha256=UwytPbRbCKKnaeQ06upELNM2UZOhCm7y2n4ZKVwlRRM,4984
16
+ grucli-3.3.0.dist-info/licenses/LICENSE,sha256=supNcZqGUNNSRFpxuC7mFg2phvHT0lsE-hirZxEVj0o,1062
17
+ grucli-3.3.0.dist-info/METADATA,sha256=EMAMQc0Om8QS7JEHbPZfUY5FheTBHNOPtQxJBBqN_7Y,5022
18
+ grucli-3.3.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
19
+ grucli-3.3.0.dist-info/entry_points.txt,sha256=1QpTF511iXtt2AA7Ux-X39e10YyyQ44PJfRs2j0jbV4,44
20
+ grucli-3.3.0.dist-info/top_level.txt,sha256=oqE29Dh1Mn0uBqx4wdXDD_W7PAMMF1pdCcV4PJwqj1M,7
21
+ grucli-3.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ grucli = grucli.main:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 grufr
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT of OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.