rubber-ducky 1.6.2__tar.gz → 1.6.4__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rubber-ducky
3
- Version: 1.6.2
3
+ Version: 1.6.4
4
4
  Summary: Quick CLI do-it-all tool. Use natural language to spit out bash commands
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -5,7 +5,6 @@ import asyncio
5
5
  import json
6
6
  import os
7
7
  import re
8
- import shlex
9
8
  import subprocess
10
9
  import sys
11
10
  from dataclasses import dataclass
@@ -15,7 +14,7 @@ from pathlib import Path
15
14
  from textwrap import dedent
16
15
  from typing import Any, Dict, List
17
16
 
18
- __version__ = "1.6.2"
17
+ __version__ = "1.6.4"
19
18
 
20
19
  from .config import ConfigManager
21
20
  from .crumb import CrumbManager
@@ -36,6 +35,7 @@ try: # prompt_toolkit is optional at runtime
36
35
  from prompt_toolkit.patch_stdout import patch_stdout
37
36
  from prompt_toolkit.styles import Style
38
37
  from prompt_toolkit.widgets import Box, Button, Dialog, Label, TextArea
38
+ from prompt_toolkit.formatted_text import PygmentsTokens
39
39
  except ImportError: # pragma: no cover - fallback mode
40
40
  PromptSession = None # type: ignore[assignment]
41
41
  FileHistory = None # type: ignore[assignment]
@@ -43,6 +43,9 @@ except ImportError: # pragma: no cover - fallback mode
43
43
 
44
44
  def patch_stdout() -> nullcontext:
45
45
  return nullcontext()
46
+ else:
47
+ def patch_stdout() -> nullcontext:
48
+ return nullcontext()
46
49
 
47
50
 
48
51
  @dataclass
@@ -123,22 +126,22 @@ def print_shell_result(result: ShellResult, truncate: bool = True) -> None:
123
126
  # Show first 8 lines of stdout
124
127
  show_lines = stdout_lines[:8]
125
128
  console.print('\n'.join(show_lines), highlight=False)
126
- console.print(f"... ({len(stdout_lines) - 8} more lines, use /expand to see full output)", style="dim cyan")
129
+ console.print(f"... ({len(stdout_lines) - 8} more lines, use /expand to see full output)", style="dim")
127
130
  else:
128
131
  console.print(result.stdout.rstrip(), highlight=False)
129
132
 
130
133
  if result.stderr.strip():
131
134
  if result.stdout.strip():
132
135
  console.print()
133
- console.print("[stderr]", style="bold red")
136
+ console.print("[stderr]", style="bold yellow")
134
137
  if should_truncate:
135
138
  # Show first 5 lines of stderr
136
139
  show_lines = stderr_lines[:5]
137
- console.print('\n'.join(show_lines), style="red", highlight=False)
140
+ console.print('\n'.join(show_lines), style="yellow", highlight=False)
138
141
  if len(stderr_lines) > 5:
139
- console.print(f"... ({len(stderr_lines) - 5} more lines)", style="dim red")
142
+ console.print(f"... ({len(stderr_lines) - 5} more lines)", style="dim")
140
143
  else:
141
- console.print(result.stderr.rstrip(), style="red", highlight=False)
144
+ console.print(result.stderr.rstrip(), style="yellow", highlight=False)
142
145
 
143
146
  if result.returncode != 0 or (not result.stdout.strip() and not result.stderr.strip()):
144
147
  suffix = (
@@ -159,7 +162,7 @@ async def run_shell_and_print(
159
162
  if not command:
160
163
  console.print("No command provided.", style="yellow")
161
164
  return ShellResult(command="", stdout="", stderr="", returncode=-1)
162
- console.print(f"$ {command}", style="bold magenta")
165
+ console.print(f"$ {command}", style="bold white")
163
166
  result = await assistant.run_shell_command(command)
164
167
  print_shell_result(result)
165
168
  if logger:
@@ -330,7 +333,7 @@ class RubberDuck:
330
333
  models.append(m.model)
331
334
  return models
332
335
  except Exception as e:
333
- console.print(f"Error listing models: {e}", style="red")
336
+ console.print(f"Error listing models: {e}", style="yellow")
334
337
  return []
335
338
  finally:
336
339
  # Restore original host
@@ -359,13 +362,27 @@ class RubberDuck:
359
362
  os.environ["OLLAMA_HOST"] = "http://localhost:11434"
360
363
  self.client = AsyncClient()
361
364
 
362
- console.print(f"Switched to model: {model_name}", style="green")
365
+ console.print(f"Switched to model: {model_name}", style="yellow")
363
366
 
364
367
  def clear_history(self) -> None:
365
368
  """Reset conversation history to the initial system prompt."""
366
369
  if self.messages:
367
370
  self.messages = [self.messages[0]]
368
- console.print("Conversation history cleared.", style="green")
371
+ console.print("Conversation history cleared.", style="yellow")
372
+
373
+ async def check_connection(self) -> tuple[bool, str]:
374
+ """Check if Ollama host is reachable. Returns (is_connected, message)."""
375
+ try:
376
+ models = await self.list_models()
377
+ return True, f"Connected ({len(models)} models available)"
378
+ except Exception as e:
379
+ error_msg = str(e).lower()
380
+ if "refused" in error_msg:
381
+ return False, "Connection refused - is Ollama running?"
382
+ elif "timeout" in error_msg:
383
+ return False, "Connection timeout - check network/host"
384
+ else:
385
+ return False, f"Error: {e}"
369
386
 
370
387
 
371
388
  class InlineInterface:
@@ -374,6 +391,7 @@ class InlineInterface:
374
391
  assistant: RubberDuck,
375
392
  logger: ConversationLogger | None = None,
376
393
  code: str | None = None,
394
+ quiet_mode: bool = False,
377
395
  ) -> None:
378
396
  ensure_history_dir()
379
397
  self.assistant = assistant
@@ -387,6 +405,7 @@ class InlineInterface:
387
405
  self.session: PromptSession | None = None
388
406
  self.selected_model: str | None = None
389
407
  self.crumb_manager = CrumbManager()
408
+ self.quiet_mode = quiet_mode
390
409
 
391
410
  if (
392
411
  PromptSession is not None
@@ -426,6 +445,36 @@ class InlineInterface:
426
445
 
427
446
  return kb
428
447
 
448
+ def _print_banner(self) -> None:
449
+ """Print the startup banner with version, model info, and crumb count."""
450
+ from .ducky import __version__
451
+
452
+ # Determine model display with host indicator
453
+ model = self.assistant.model
454
+ host = os.environ.get("OLLAMA_HOST", "")
455
+ if host == "https://ollama.com":
456
+ model_display = f"{model}:cloud"
457
+ elif "localhost" in host:
458
+ model_display = f"{model}:local"
459
+ else:
460
+ model_display = model
461
+
462
+ # Get crumb count
463
+ crumbs = self.crumb_manager.list_crumbs()
464
+ crumb_count = len(crumbs)
465
+
466
+ # Print banner with yellow/white color scheme
467
+ console.print(f"Ducky v{__version__}", style="yellow")
468
+ console.print(
469
+ f"Model: [bold white]{model_display}[/bold white] | Crumbs: {crumb_count}"
470
+ )
471
+ console.print()
472
+ console.print(
473
+ "Enter submits • !cmd=shell • Ctrl+D=exit • /help=commands",
474
+ style="dim",
475
+ )
476
+ console.print()
477
+
429
478
  async def run(self) -> None:
430
479
  if self.session is None:
431
480
  console.print(
@@ -435,10 +484,10 @@ class InlineInterface:
435
484
  await self._run_basic_loop()
436
485
  return
437
486
 
438
- console.print(
439
- "Enter submits • empty Enter reruns the last suggested command (or explains the last shell output) • '!<cmd>' runs shell • Ctrl+D exits • Ctrl+S copies last command",
440
- style="dim",
441
- )
487
+ # Print banner if not in quiet mode
488
+ if not self.quiet_mode:
489
+ self._print_banner()
490
+
442
491
  while True:
443
492
  try:
444
493
  with patch_stdout():
@@ -497,9 +546,9 @@ class InlineInterface:
497
546
  )
498
547
  process.communicate(input=command_to_copy)
499
548
 
500
- console.print(f"Copied to clipboard: {command_to_copy}", style="green")
549
+ console.print(f"Copied to clipboard: {command_to_copy}", style="yellow")
501
550
  except Exception as e:
502
- console.print(f"Failed to copy to clipboard: {e}", style="red")
551
+ console.print(f"Failed to copy to clipboard: {e}", style="yellow")
503
552
  console.print("You can manually copy the last command:", style="dim")
504
553
  console.print(f" {self.last_command}", style="bold")
505
554
 
@@ -533,7 +582,7 @@ class InlineInterface:
533
582
  return
534
583
 
535
584
  console.print()
536
- console.print(f"[Full output for: {self.last_shell_result.command}]", style="bold cyan")
585
+ console.print(f"[Full output for: {self.last_shell_result.command}]", style="bold white")
537
586
  console.print()
538
587
  print_shell_result(self.last_shell_result, truncate=False)
539
588
  console.print()
@@ -554,8 +603,8 @@ class InlineInterface:
554
603
  first_word = stripped.split()[0].lower()
555
604
  if self.crumb_manager.has_crumb(first_word):
556
605
  # Extract additional arguments after the crumb name
557
- parts = stripped.split()
558
- args = parts[1:]
606
+ parts = stripped.split(maxsplit=1)
607
+ args = parts[1:] if len(parts) > 1 else []
559
608
  await self._use_crumb(first_word, args)
560
609
  return
561
610
 
@@ -636,8 +685,8 @@ class InlineInterface:
636
685
 
637
686
  async def _show_help(self) -> None:
638
687
  """Display help information for all available commands."""
639
- console.print("\nDucky CLI Help", style="bold blue")
640
- console.print("===============", style="bold blue")
688
+ console.print("\nDucky CLI Help", style="bold white")
689
+ console.print("===============", style="bold white")
641
690
  console.print()
642
691
 
643
692
  commands = [
@@ -692,8 +741,8 @@ class InlineInterface:
692
741
  console.print("No crumbs saved yet. Use '/crumb <name>' to save a command.", style="yellow")
693
742
  return
694
743
 
695
- console.print("\nSaved Crumbs", style="bold blue")
696
- console.print("=============", style="bold blue")
744
+ console.print("\nSaved Crumbs", style="bold white")
745
+ console.print("=============", style="bold white")
697
746
  console.print()
698
747
 
699
748
  # Calculate max name length for alignment
@@ -706,7 +755,7 @@ class InlineInterface:
706
755
 
707
756
  # Format: name | explanation | command
708
757
  console.print(
709
- f"[bold]{name:<{max_name_len}}[/bold] | [cyan]{explanation}[/cyan] | [dim]{command}[/dim]"
758
+ f"[bold yellow]{name:<{max_name_len}}[/bold yellow] | [white]{explanation}[/white] | [dim]{command}[/dim]"
710
759
  )
711
760
 
712
761
  console.print(f"\n[dim]Total: {len(crumbs)} crumbs[/dim]")
@@ -823,7 +872,7 @@ class InlineInterface:
823
872
  command=self.assistant.last_result.command,
824
873
  )
825
874
 
826
- console.print(f"Saved crumb '{name}'!", style="green")
875
+ console.print(f"Saved crumb '{name}'!", style="yellow")
827
876
  console.print("Generating explanation...", style="dim")
828
877
 
829
878
  # Spawn subprocess to generate explanation asynchronously
@@ -852,7 +901,7 @@ class InlineInterface:
852
901
  clean_explanation = re.sub(r'\x1b\[([0-9;]*[mGK])', '', explanation)
853
902
 
854
903
  text = Text()
855
- text.append("Explanation added: ", style="cyan")
904
+ text.append("Explanation added: ", style="white")
856
905
  text.append(clean_explanation)
857
906
  console.print(text)
858
907
  except Exception as e:
@@ -867,7 +916,7 @@ class InlineInterface:
867
916
  command=command,
868
917
  )
869
918
 
870
- console.print(f"Added crumb '{name}'!", style="green")
919
+ console.print(f"Added crumb '{name}'!", style="yellow")
871
920
  console.print("Generating explanation...", style="dim")
872
921
 
873
922
  # Spawn subprocess to generate explanation asynchronously
@@ -876,7 +925,7 @@ class InlineInterface:
876
925
  async def _delete_crumb(self, name: str) -> None:
877
926
  """Delete a crumb."""
878
927
  if self.crumb_manager.delete_crumb(name):
879
- console.print(f"Deleted crumb '{name}'.", style="green")
928
+ console.print(f"Deleted crumb '{name}'.", style="yellow")
880
929
  else:
881
930
  console.print(f"Crumb '{name}' not found.", style="yellow")
882
931
 
@@ -899,9 +948,13 @@ class InlineInterface:
899
948
  if args and command != "No command":
900
949
  command = substitute_placeholders(command, args)
901
950
 
902
- console.print(f"\n[bold cyan]Crumb: {name}[/bold cyan]")
903
- console.print(f"Explanation: {explanation}", style="green")
904
- console.print("Command: ", style="cyan", end="")
951
+ from rich.text import Text
952
+ crumb_text = Text()
953
+ crumb_text.append("Crumb: ", style="bold yellow")
954
+ crumb_text.append(name, style="bold yellow")
955
+ console.print(f"\n{crumb_text}")
956
+ console.print(f"Explanation: {explanation}", style="yellow")
957
+ console.print("Command: ", style="white", end="")
905
958
  console.print(command, highlight=False)
906
959
 
907
960
  if command and command != "No command":
@@ -918,7 +971,7 @@ class InlineInterface:
918
971
  return
919
972
 
920
973
  # Show current model
921
- console.print(f"Current model: {self.assistant.model}", style="bold green")
974
+ console.print(f"Current model: {self.assistant.model}", style="bold yellow")
922
975
 
923
976
  # If no host specified, give user a choice between local and cloud
924
977
  if not host:
@@ -940,17 +993,17 @@ class InlineInterface:
940
993
  elif choice == "2":
941
994
  host = "https://ollama.com"
942
995
  else:
943
- console.print("Invalid choice. Please select 1 or 2.", style="red")
996
+ console.print("Invalid choice. Please select 1 or 2.", style="yellow")
944
997
  return
945
998
  except (ValueError, EOFError):
946
- console.print("Invalid input.", style="red")
999
+ console.print("Invalid input.", style="yellow")
947
1000
  return
948
1001
 
949
1002
  models = await self.assistant.list_models(host)
950
1003
  if not models:
951
1004
  if host == "http://localhost:11434":
952
1005
  console.print(
953
- "No local models available. Is Ollama running?", style="red"
1006
+ "No local models available. Is Ollama running?", style="yellow"
954
1007
  )
955
1008
  console.print("Start Ollama with: ollama serve", style="yellow")
956
1009
  else:
@@ -966,7 +1019,7 @@ class InlineInterface:
966
1019
 
967
1020
  for i, model in enumerate(models, 1):
968
1021
  if model == self.assistant.model:
969
- console.print(f"{i}. {model} (current)", style="green")
1022
+ console.print(f"{i}. [bold yellow]{model}[/bold yellow] (current)", style="yellow")
970
1023
  else:
971
1024
  console.print(f"{i}. {model}")
972
1025
 
@@ -985,14 +1038,14 @@ class InlineInterface:
985
1038
  if 0 <= index < len(models):
986
1039
  selected_model = models[index]
987
1040
  else:
988
- console.print("Invalid model number.", style="red")
1041
+ console.print("Invalid model number.", style="yellow")
989
1042
  return
990
1043
  else:
991
1044
  # Check if it's a model name
992
1045
  if choice in models:
993
1046
  selected_model = choice
994
1047
  else:
995
- console.print("Invalid model name.", style="red")
1048
+ console.print("Invalid model name.", style="yellow")
996
1049
  return
997
1050
 
998
1051
  self.assistant.switch_model(selected_model, host)
@@ -1004,7 +1057,7 @@ class InlineInterface:
1004
1057
  host or os.environ.get("OLLAMA_HOST", "http://localhost:11434"),
1005
1058
  )
1006
1059
  except (ValueError, EOFError):
1007
- console.print("Invalid input.", style="red")
1060
+ console.print("Invalid input.", style="yellow")
1008
1061
 
1009
1062
  async def _run_basic_loop(self) -> None: # pragma: no cover - fallback path
1010
1063
  while True:
@@ -1062,10 +1115,10 @@ async def run_single_prompt(
1062
1115
 
1063
1116
  console.print("\nOptions:", style="bold")
1064
1117
  console.print(" 1. Use --local flag to access local models:", style="dim")
1065
- console.print(" ducky --local", style="cyan")
1118
+ console.print(" ducky --local", style="white")
1066
1119
  console.print(" 2. Select a local model with /local command", style="dim")
1067
1120
  console.print(" 3. Set up Ollama cloud API credentials:", style="dim")
1068
- console.print(" export OLLAMA_API_KEY='your-api-key-here'", style="cyan")
1121
+ console.print(" export OLLAMA_API_KEY='your-api-key-here'", style="white")
1069
1122
  console.print("\nGet your API key from: https://ollama.com/account/api-keys", style="dim")
1070
1123
  console.print()
1071
1124
  raise
@@ -1073,12 +1126,12 @@ async def run_single_prompt(
1073
1126
  raise
1074
1127
 
1075
1128
  content = result.content or "(No content returned.)"
1076
- console.print(content, style="green", highlight=False)
1129
+ console.print(content, style="dim", highlight=False)
1077
1130
  if logger:
1078
1131
  logger.log_assistant(content, result.command)
1079
1132
  if result.command and not suppress_suggestion:
1080
- console.print("\nSuggested command:", style="cyan", highlight=False)
1081
- console.print(result.command, style="bold cyan", highlight=False)
1133
+ console.print("\nSuggested command:", style="yellow", highlight=False)
1134
+ console.print(result.command, style="bold yellow", highlight=False)
1082
1135
  return result
1083
1136
 
1084
1137
 
@@ -1108,8 +1161,9 @@ async def interactive_session(
1108
1161
  rubber_ducky: RubberDuck,
1109
1162
  logger: ConversationLogger | None = None,
1110
1163
  code: str | None = None,
1164
+ quiet_mode: bool = False,
1111
1165
  ) -> None:
1112
- ui = InlineInterface(rubber_ducky, logger=logger, code=code)
1166
+ ui = InlineInterface(rubber_ducky, logger=logger, code=code, quiet_mode=quiet_mode)
1113
1167
  await ui.run()
1114
1168
 
1115
1169
 
@@ -1134,6 +1188,12 @@ async def ducky() -> None:
1134
1188
  action="store_true",
1135
1189
  help=" Automatically run the suggested command without confirmation",
1136
1190
  )
1191
+ parser.add_argument(
1192
+ "--quiet",
1193
+ "-q",
1194
+ action="store_true",
1195
+ help="Suppress startup messages and help text",
1196
+ )
1137
1197
  parser.add_argument(
1138
1198
  "single_prompt",
1139
1199
  nargs="*",
@@ -1193,8 +1253,31 @@ async def ducky() -> None:
1193
1253
  console.print("No input received from stdin.", style="yellow")
1194
1254
  return
1195
1255
 
1196
- # Handle crumb invocation mode
1256
+ # Handle crumb list command
1197
1257
  crumb_manager = CrumbManager()
1258
+ if args.single_prompt and args.single_prompt[0] == "crumbs":
1259
+ crumbs = crumb_manager.list_crumbs()
1260
+
1261
+ if not crumbs:
1262
+ console.print("No crumbs saved yet.", style="yellow")
1263
+ else:
1264
+ console.print("Saved Crumbs", style="bold white")
1265
+ console.print("=============", style="bold white")
1266
+ console.print()
1267
+
1268
+ max_name_len = max(len(name) for name in crumbs.keys())
1269
+
1270
+ for name, data in sorted(crumbs.items()):
1271
+ explanation = data.get("explanation", "") or "No explanation yet"
1272
+ command = data.get("command", "") or "No command"
1273
+
1274
+ console.print(
1275
+ f"[bold yellow]{name:<{max_name_len}}[/bold yellow] | [white]{explanation}[/white] | [dim]{command}[/dim]"
1276
+ )
1277
+ console.print(f"\n[dim]Total: {len(crumbs)} crumbs[/dim]")
1278
+ return
1279
+
1280
+ # Handle crumb invocation mode
1198
1281
  if args.single_prompt:
1199
1282
  first_arg = args.single_prompt[0]
1200
1283
  if crumb_manager.has_crumb(first_arg):
@@ -1210,9 +1293,13 @@ async def ducky() -> None:
1210
1293
  if crumb_args and command != "No command":
1211
1294
  command = substitute_placeholders(command, crumb_args)
1212
1295
 
1213
- console.print(f"\n[bold cyan]Crumb: {first_arg}[/bold cyan]")
1214
- console.print(f"Explanation: {explanation}", style="green")
1215
- console.print("Command: ", style="cyan", end="")
1296
+ from rich.text import Text
1297
+ crumb_text = Text()
1298
+ crumb_text.append("Crumb: ", style="bold yellow")
1299
+ crumb_text.append(first_arg, style="bold yellow")
1300
+ console.print(f"\n{crumb_text}")
1301
+ console.print(f"Explanation: {explanation}", style="yellow")
1302
+ console.print("Command: ", style="white", end="")
1216
1303
  console.print(command, highlight=False)
1217
1304
 
1218
1305
  if command and command != "No command":
@@ -1243,7 +1330,19 @@ async def ducky() -> None:
1243
1330
  console.print("\n[green]✓[/green] Command copied to clipboard")
1244
1331
  return
1245
1332
 
1246
- await interactive_session(rubber_ducky, logger=logger, code=code)
1333
+ # Validate model is available if using local
1334
+ if not args.single_prompt and not piped_prompt and last_host == "http://localhost:11434":
1335
+ connected = True
1336
+ try:
1337
+ models = await rubber_ducky.list_models()
1338
+ if args.model not in models:
1339
+ console.print(f"Model '{args.model}' not found locally.", style="yellow")
1340
+ console.print(f"Available: {', '.join(models[:5])}...", style="dim")
1341
+ console.print("Use /model to select, or run 'ollama pull <model>'", style="yellow")
1342
+ except Exception:
1343
+ pass
1344
+
1345
+ await interactive_session(rubber_ducky, logger=logger, code=code, quiet_mode=args.quiet)
1247
1346
 
1248
1347
 
1249
1348
  def substitute_placeholders(command: str, args: list[str]) -> str:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rubber-ducky"
3
- version = "1.6.2"
3
+ version = "1.6.4"
4
4
  description = "Quick CLI do-it-all tool. Use natural language to spit out bash commands"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rubber-ducky
3
- Version: 1.6.2
3
+ Version: 1.6.4
4
4
  Summary: Quick CLI do-it-all tool. Use natural language to spit out bash commands
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
File without changes
File without changes
File without changes
File without changes