rubber-ducky 1.6.2__tar.gz → 1.6.5__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.5
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:
@@ -235,8 +238,9 @@ class RubberDuck:
235
238
 
236
239
  if effective_command_mode:
237
240
  instruction = (
238
- "Return a single bash command that accomplishes the task. Unless user wants something els"
239
- "Do not include explanations or formatting other than the command itself."
241
+ "Return a single bash command that accomplishes the task wrapped in <command></command> tags. "
242
+ "You can write explanatory text before or after the command tags for the user, "
243
+ "but the command itself must be within the <command></command> tags and nothing else."
240
244
  )
241
245
  user_content = (
242
246
  f"{user_content}\n\n{instruction}" if user_content else instruction
@@ -285,10 +289,20 @@ class RubberDuck:
285
289
  )
286
290
 
287
291
  def _extract_command(self, content: str) -> str | None:
288
- lines = content.strip().splitlines()
289
- if not lines:
292
+ content = content.strip()
293
+ if not content:
290
294
  return None
291
295
 
296
+ # First, try to extract command from <command></command> tags
297
+ command_match = re.search(r"<command>(.*?)</command>", content, re.DOTALL)
298
+ if command_match:
299
+ command = command_match.group(1).strip()
300
+ # Strip backticks if present
301
+ command = self._strip_backticks(command)
302
+ return command or None
303
+
304
+ # Fallback to code block detection
305
+ lines = content.splitlines()
292
306
  command_lines: List[str] = []
293
307
  in_block = False
294
308
 
@@ -313,9 +327,20 @@ class RubberDuck:
313
327
 
314
328
  # Join all command lines with newlines for multi-line commands
315
329
  command = "\n".join(command_lines)
330
+ # Strip backticks if present
331
+ command = self._strip_backticks(command)
316
332
 
317
333
  return command or None
318
334
 
335
+ def _strip_backticks(self, command: str) -> str:
336
+ """Strip surrounding backticks from a command string."""
337
+ command = command.strip()
338
+ if command.startswith("`"):
339
+ command = command[1:]
340
+ if command.endswith("`"):
341
+ command = command[:-1]
342
+ return command.strip()
343
+
319
344
  async def list_models(self, host: str = "") -> list[str]:
320
345
  """List available Ollama models."""
321
346
  # Set the host temporarily for this operation
@@ -330,7 +355,7 @@ class RubberDuck:
330
355
  models.append(m.model)
331
356
  return models
332
357
  except Exception as e:
333
- console.print(f"Error listing models: {e}", style="red")
358
+ console.print(f"Error listing models: {e}", style="yellow")
334
359
  return []
335
360
  finally:
336
361
  # Restore original host
@@ -359,13 +384,27 @@ class RubberDuck:
359
384
  os.environ["OLLAMA_HOST"] = "http://localhost:11434"
360
385
  self.client = AsyncClient()
361
386
 
362
- console.print(f"Switched to model: {model_name}", style="green")
387
+ console.print(f"Switched to model: {model_name}", style="yellow")
363
388
 
364
389
  def clear_history(self) -> None:
365
390
  """Reset conversation history to the initial system prompt."""
366
391
  if self.messages:
367
392
  self.messages = [self.messages[0]]
368
- console.print("Conversation history cleared.", style="green")
393
+ console.print("Conversation history cleared.", style="yellow")
394
+
395
+ async def check_connection(self) -> tuple[bool, str]:
396
+ """Check if Ollama host is reachable. Returns (is_connected, message)."""
397
+ try:
398
+ models = await self.list_models()
399
+ return True, f"Connected ({len(models)} models available)"
400
+ except Exception as e:
401
+ error_msg = str(e).lower()
402
+ if "refused" in error_msg:
403
+ return False, "Connection refused - is Ollama running?"
404
+ elif "timeout" in error_msg:
405
+ return False, "Connection timeout - check network/host"
406
+ else:
407
+ return False, f"Error: {e}"
369
408
 
370
409
 
371
410
  class InlineInterface:
@@ -374,6 +413,7 @@ class InlineInterface:
374
413
  assistant: RubberDuck,
375
414
  logger: ConversationLogger | None = None,
376
415
  code: str | None = None,
416
+ quiet_mode: bool = False,
377
417
  ) -> None:
378
418
  ensure_history_dir()
379
419
  self.assistant = assistant
@@ -387,6 +427,7 @@ class InlineInterface:
387
427
  self.session: PromptSession | None = None
388
428
  self.selected_model: str | None = None
389
429
  self.crumb_manager = CrumbManager()
430
+ self.quiet_mode = quiet_mode
390
431
 
391
432
  if (
392
433
  PromptSession is not None
@@ -426,6 +467,36 @@ class InlineInterface:
426
467
 
427
468
  return kb
428
469
 
470
+ def _print_banner(self) -> None:
471
+ """Print the startup banner with version, model info, and crumb count."""
472
+ from .ducky import __version__
473
+
474
+ # Determine model display with host indicator
475
+ model = self.assistant.model
476
+ host = os.environ.get("OLLAMA_HOST", "")
477
+ if host == "https://ollama.com":
478
+ model_display = f"{model}:cloud"
479
+ elif "localhost" in host:
480
+ model_display = f"{model}:local"
481
+ else:
482
+ model_display = model
483
+
484
+ # Get crumb count
485
+ crumbs = self.crumb_manager.list_crumbs()
486
+ crumb_count = len(crumbs)
487
+
488
+ # Print banner with yellow/white color scheme
489
+ console.print(f"Ducky v{__version__}", style="yellow")
490
+ console.print(
491
+ f"Model: [bold white]{model_display}[/bold white] | Crumbs: {crumb_count}"
492
+ )
493
+ console.print()
494
+ console.print(
495
+ "Enter submits • !cmd=shell • Ctrl+D=exit • /help=commands",
496
+ style="dim",
497
+ )
498
+ console.print()
499
+
429
500
  async def run(self) -> None:
430
501
  if self.session is None:
431
502
  console.print(
@@ -435,10 +506,10 @@ class InlineInterface:
435
506
  await self._run_basic_loop()
436
507
  return
437
508
 
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
- )
509
+ # Print banner if not in quiet mode
510
+ if not self.quiet_mode:
511
+ self._print_banner()
512
+
442
513
  while True:
443
514
  try:
444
515
  with patch_stdout():
@@ -497,9 +568,9 @@ class InlineInterface:
497
568
  )
498
569
  process.communicate(input=command_to_copy)
499
570
 
500
- console.print(f"Copied to clipboard: {command_to_copy}", style="green")
571
+ console.print(f"Copied to clipboard: {command_to_copy}", style="yellow")
501
572
  except Exception as e:
502
- console.print(f"Failed to copy to clipboard: {e}", style="red")
573
+ console.print(f"Failed to copy to clipboard: {e}", style="yellow")
503
574
  console.print("You can manually copy the last command:", style="dim")
504
575
  console.print(f" {self.last_command}", style="bold")
505
576
 
@@ -533,7 +604,7 @@ class InlineInterface:
533
604
  return
534
605
 
535
606
  console.print()
536
- console.print(f"[Full output for: {self.last_shell_result.command}]", style="bold cyan")
607
+ console.print(f"[Full output for: {self.last_shell_result.command}]", style="bold white")
537
608
  console.print()
538
609
  print_shell_result(self.last_shell_result, truncate=False)
539
610
  console.print()
@@ -554,8 +625,8 @@ class InlineInterface:
554
625
  first_word = stripped.split()[0].lower()
555
626
  if self.crumb_manager.has_crumb(first_word):
556
627
  # Extract additional arguments after the crumb name
557
- parts = stripped.split()
558
- args = parts[1:]
628
+ parts = stripped.split(maxsplit=1)
629
+ args = parts[1:] if len(parts) > 1 else []
559
630
  await self._use_crumb(first_word, args)
560
631
  return
561
632
 
@@ -636,8 +707,8 @@ class InlineInterface:
636
707
 
637
708
  async def _show_help(self) -> None:
638
709
  """Display help information for all available commands."""
639
- console.print("\nDucky CLI Help", style="bold blue")
640
- console.print("===============", style="bold blue")
710
+ console.print("\nDucky CLI Help", style="bold white")
711
+ console.print("===============", style="bold white")
641
712
  console.print()
642
713
 
643
714
  commands = [
@@ -692,8 +763,8 @@ class InlineInterface:
692
763
  console.print("No crumbs saved yet. Use '/crumb <name>' to save a command.", style="yellow")
693
764
  return
694
765
 
695
- console.print("\nSaved Crumbs", style="bold blue")
696
- console.print("=============", style="bold blue")
766
+ console.print("\nSaved Crumbs", style="bold white")
767
+ console.print("=============", style="bold white")
697
768
  console.print()
698
769
 
699
770
  # Calculate max name length for alignment
@@ -706,7 +777,7 @@ class InlineInterface:
706
777
 
707
778
  # Format: name | explanation | command
708
779
  console.print(
709
- f"[bold]{name:<{max_name_len}}[/bold] | [cyan]{explanation}[/cyan] | [dim]{command}[/dim]"
780
+ f"[bold yellow]{name:<{max_name_len}}[/bold yellow] | [white]{explanation}[/white] | [dim]{command}[/dim]"
710
781
  )
711
782
 
712
783
  console.print(f"\n[dim]Total: {len(crumbs)} crumbs[/dim]")
@@ -823,7 +894,7 @@ class InlineInterface:
823
894
  command=self.assistant.last_result.command,
824
895
  )
825
896
 
826
- console.print(f"Saved crumb '{name}'!", style="green")
897
+ console.print(f"Saved crumb '{name}'!", style="yellow")
827
898
  console.print("Generating explanation...", style="dim")
828
899
 
829
900
  # Spawn subprocess to generate explanation asynchronously
@@ -852,7 +923,7 @@ class InlineInterface:
852
923
  clean_explanation = re.sub(r'\x1b\[([0-9;]*[mGK])', '', explanation)
853
924
 
854
925
  text = Text()
855
- text.append("Explanation added: ", style="cyan")
926
+ text.append("Explanation added: ", style="white")
856
927
  text.append(clean_explanation)
857
928
  console.print(text)
858
929
  except Exception as e:
@@ -867,7 +938,7 @@ class InlineInterface:
867
938
  command=command,
868
939
  )
869
940
 
870
- console.print(f"Added crumb '{name}'!", style="green")
941
+ console.print(f"Added crumb '{name}'!", style="yellow")
871
942
  console.print("Generating explanation...", style="dim")
872
943
 
873
944
  # Spawn subprocess to generate explanation asynchronously
@@ -876,7 +947,7 @@ class InlineInterface:
876
947
  async def _delete_crumb(self, name: str) -> None:
877
948
  """Delete a crumb."""
878
949
  if self.crumb_manager.delete_crumb(name):
879
- console.print(f"Deleted crumb '{name}'.", style="green")
950
+ console.print(f"Deleted crumb '{name}'.", style="yellow")
880
951
  else:
881
952
  console.print(f"Crumb '{name}' not found.", style="yellow")
882
953
 
@@ -899,9 +970,13 @@ class InlineInterface:
899
970
  if args and command != "No command":
900
971
  command = substitute_placeholders(command, args)
901
972
 
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="")
973
+ from rich.text import Text
974
+ crumb_text = Text()
975
+ crumb_text.append("Crumb: ", style="bold yellow")
976
+ crumb_text.append(name, style="bold yellow")
977
+ console.print(f"\n{crumb_text}")
978
+ console.print(f"Explanation: {explanation}", style="yellow")
979
+ console.print("Command: ", style="white", end="")
905
980
  console.print(command, highlight=False)
906
981
 
907
982
  if command and command != "No command":
@@ -918,7 +993,7 @@ class InlineInterface:
918
993
  return
919
994
 
920
995
  # Show current model
921
- console.print(f"Current model: {self.assistant.model}", style="bold green")
996
+ console.print(f"Current model: {self.assistant.model}", style="bold yellow")
922
997
 
923
998
  # If no host specified, give user a choice between local and cloud
924
999
  if not host:
@@ -940,17 +1015,17 @@ class InlineInterface:
940
1015
  elif choice == "2":
941
1016
  host = "https://ollama.com"
942
1017
  else:
943
- console.print("Invalid choice. Please select 1 or 2.", style="red")
1018
+ console.print("Invalid choice. Please select 1 or 2.", style="yellow")
944
1019
  return
945
1020
  except (ValueError, EOFError):
946
- console.print("Invalid input.", style="red")
1021
+ console.print("Invalid input.", style="yellow")
947
1022
  return
948
1023
 
949
1024
  models = await self.assistant.list_models(host)
950
1025
  if not models:
951
1026
  if host == "http://localhost:11434":
952
1027
  console.print(
953
- "No local models available. Is Ollama running?", style="red"
1028
+ "No local models available. Is Ollama running?", style="yellow"
954
1029
  )
955
1030
  console.print("Start Ollama with: ollama serve", style="yellow")
956
1031
  else:
@@ -966,7 +1041,7 @@ class InlineInterface:
966
1041
 
967
1042
  for i, model in enumerate(models, 1):
968
1043
  if model == self.assistant.model:
969
- console.print(f"{i}. {model} (current)", style="green")
1044
+ console.print(f"{i}. [bold yellow]{model}[/bold yellow] (current)", style="yellow")
970
1045
  else:
971
1046
  console.print(f"{i}. {model}")
972
1047
 
@@ -985,14 +1060,14 @@ class InlineInterface:
985
1060
  if 0 <= index < len(models):
986
1061
  selected_model = models[index]
987
1062
  else:
988
- console.print("Invalid model number.", style="red")
1063
+ console.print("Invalid model number.", style="yellow")
989
1064
  return
990
1065
  else:
991
1066
  # Check if it's a model name
992
1067
  if choice in models:
993
1068
  selected_model = choice
994
1069
  else:
995
- console.print("Invalid model name.", style="red")
1070
+ console.print("Invalid model name.", style="yellow")
996
1071
  return
997
1072
 
998
1073
  self.assistant.switch_model(selected_model, host)
@@ -1004,7 +1079,7 @@ class InlineInterface:
1004
1079
  host or os.environ.get("OLLAMA_HOST", "http://localhost:11434"),
1005
1080
  )
1006
1081
  except (ValueError, EOFError):
1007
- console.print("Invalid input.", style="red")
1082
+ console.print("Invalid input.", style="yellow")
1008
1083
 
1009
1084
  async def _run_basic_loop(self) -> None: # pragma: no cover - fallback path
1010
1085
  while True:
@@ -1042,11 +1117,12 @@ async def run_single_prompt(
1042
1117
  code: str | None = None,
1043
1118
  logger: ConversationLogger | None = None,
1044
1119
  suppress_suggestion: bool = False,
1120
+ command_mode: bool | None = None,
1045
1121
  ) -> AssistantResult:
1046
1122
  if logger:
1047
1123
  logger.log_user(prompt)
1048
1124
  try:
1049
- result = await rubber_ducky.send_prompt(prompt=prompt, code=code)
1125
+ result = await rubber_ducky.send_prompt(prompt=prompt, code=code, command_mode=command_mode)
1050
1126
  except Exception as e:
1051
1127
  error_msg = str(e)
1052
1128
  if "unauthorized" in error_msg.lower() or "401" in error_msg:
@@ -1062,10 +1138,10 @@ async def run_single_prompt(
1062
1138
 
1063
1139
  console.print("\nOptions:", style="bold")
1064
1140
  console.print(" 1. Use --local flag to access local models:", style="dim")
1065
- console.print(" ducky --local", style="cyan")
1141
+ console.print(" ducky --local", style="white")
1066
1142
  console.print(" 2. Select a local model with /local command", style="dim")
1067
1143
  console.print(" 3. Set up Ollama cloud API credentials:", style="dim")
1068
- console.print(" export OLLAMA_API_KEY='your-api-key-here'", style="cyan")
1144
+ console.print(" export OLLAMA_API_KEY='your-api-key-here'", style="white")
1069
1145
  console.print("\nGet your API key from: https://ollama.com/account/api-keys", style="dim")
1070
1146
  console.print()
1071
1147
  raise
@@ -1073,12 +1149,14 @@ async def run_single_prompt(
1073
1149
  raise
1074
1150
 
1075
1151
  content = result.content or "(No content returned.)"
1076
- console.print(content, style="green", highlight=False)
1152
+ # Strip <command>...</command> tags from display output
1153
+ display_content = re.sub(r"<command>.*?</command>", "", content, flags=re.DOTALL).strip()
1154
+ console.print(display_content, highlight=False)
1077
1155
  if logger:
1078
1156
  logger.log_assistant(content, result.command)
1079
1157
  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)
1158
+ console.print("\nSuggested command:", style="yellow", highlight=False)
1159
+ console.print(result.command, style="bold yellow", highlight=False)
1082
1160
  return result
1083
1161
 
1084
1162
 
@@ -1108,8 +1186,9 @@ async def interactive_session(
1108
1186
  rubber_ducky: RubberDuck,
1109
1187
  logger: ConversationLogger | None = None,
1110
1188
  code: str | None = None,
1189
+ quiet_mode: bool = False,
1111
1190
  ) -> None:
1112
- ui = InlineInterface(rubber_ducky, logger=logger, code=code)
1191
+ ui = InlineInterface(rubber_ducky, logger=logger, code=code, quiet_mode=quiet_mode)
1113
1192
  await ui.run()
1114
1193
 
1115
1194
 
@@ -1134,6 +1213,12 @@ async def ducky() -> None:
1134
1213
  action="store_true",
1135
1214
  help=" Automatically run the suggested command without confirmation",
1136
1215
  )
1216
+ parser.add_argument(
1217
+ "--quiet",
1218
+ "-q",
1219
+ action="store_true",
1220
+ help="Suppress startup messages and help text",
1221
+ )
1137
1222
  parser.add_argument(
1138
1223
  "single_prompt",
1139
1224
  nargs="*",
@@ -1175,26 +1260,64 @@ async def ducky() -> None:
1175
1260
 
1176
1261
  if piped_prompt is not None:
1177
1262
  if piped_prompt:
1178
- result = await run_single_prompt(
1179
- rubber_ducky, piped_prompt, code=code, logger=logger
1180
- )
1181
- if (
1182
- result.command
1183
- and sys.stdout.isatty()
1184
- and confirm("Run suggested command?")
1185
- ):
1186
- await run_shell_and_print(
1187
- rubber_ducky,
1188
- result.command,
1189
- logger=logger,
1190
- history=rubber_ducky.messages,
1263
+ # Check if user also provided a command-line prompt
1264
+ if args.single_prompt:
1265
+ # Combine piped content with user prompt
1266
+ # User prompt is the instruction, piped content is context
1267
+ user_prompt = " ".join(args.single_prompt)
1268
+ combined_prompt = (
1269
+ f"Context from stdin:\n```\n{piped_prompt}\n```\n\n"
1270
+ f"User request: {user_prompt}"
1191
1271
  )
1272
+ # Disable command_mode for this scenario - user wants an explanation, not a command
1273
+ result = await run_single_prompt(
1274
+ rubber_ducky, combined_prompt, code=code, logger=logger, command_mode=False
1275
+ )
1276
+ else:
1277
+ # Only piped input - proceed with command mode (default behavior)
1278
+ result = await run_single_prompt(
1279
+ rubber_ducky, piped_prompt, code=code, logger=logger
1280
+ )
1281
+ if (
1282
+ result.command
1283
+ and sys.stdout.isatty()
1284
+ and confirm("Run suggested command?")
1285
+ ):
1286
+ await run_shell_and_print(
1287
+ rubber_ducky,
1288
+ result.command,
1289
+ logger=logger,
1290
+ history=rubber_ducky.messages,
1291
+ )
1192
1292
  else:
1193
1293
  console.print("No input received from stdin.", style="yellow")
1194
1294
  return
1195
1295
 
1196
- # Handle crumb invocation mode
1296
+ # Handle crumb list command
1197
1297
  crumb_manager = CrumbManager()
1298
+ if args.single_prompt and args.single_prompt[0] == "crumbs":
1299
+ crumbs = crumb_manager.list_crumbs()
1300
+
1301
+ if not crumbs:
1302
+ console.print("No crumbs saved yet.", style="yellow")
1303
+ else:
1304
+ console.print("Saved Crumbs", style="bold white")
1305
+ console.print("=============", style="bold white")
1306
+ console.print()
1307
+
1308
+ max_name_len = max(len(name) for name in crumbs.keys())
1309
+
1310
+ for name, data in sorted(crumbs.items()):
1311
+ explanation = data.get("explanation", "") or "No explanation yet"
1312
+ command = data.get("command", "") or "No command"
1313
+
1314
+ console.print(
1315
+ f"[bold yellow]{name:<{max_name_len}}[/bold yellow] | [white]{explanation}[/white] | [dim]{command}[/dim]"
1316
+ )
1317
+ console.print(f"\n[dim]Total: {len(crumbs)} crumbs[/dim]")
1318
+ return
1319
+
1320
+ # Handle crumb invocation mode
1198
1321
  if args.single_prompt:
1199
1322
  first_arg = args.single_prompt[0]
1200
1323
  if crumb_manager.has_crumb(first_arg):
@@ -1210,9 +1333,13 @@ async def ducky() -> None:
1210
1333
  if crumb_args and command != "No command":
1211
1334
  command = substitute_placeholders(command, crumb_args)
1212
1335
 
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="")
1336
+ from rich.text import Text
1337
+ crumb_text = Text()
1338
+ crumb_text.append("Crumb: ", style="bold yellow")
1339
+ crumb_text.append(first_arg, style="bold yellow")
1340
+ console.print(f"\n{crumb_text}")
1341
+ console.print(f"Explanation: {explanation}", style="yellow")
1342
+ console.print("Command: ", style="white", end="")
1216
1343
  console.print(command, highlight=False)
1217
1344
 
1218
1345
  if command and command != "No command":
@@ -1243,7 +1370,19 @@ async def ducky() -> None:
1243
1370
  console.print("\n[green]✓[/green] Command copied to clipboard")
1244
1371
  return
1245
1372
 
1246
- await interactive_session(rubber_ducky, logger=logger, code=code)
1373
+ # Validate model is available if using local
1374
+ if not args.single_prompt and not piped_prompt and last_host == "http://localhost:11434":
1375
+ connected = True
1376
+ try:
1377
+ models = await rubber_ducky.list_models()
1378
+ if args.model not in models:
1379
+ console.print(f"Model '{args.model}' not found locally.", style="yellow")
1380
+ console.print(f"Available: {', '.join(models[:5])}...", style="dim")
1381
+ console.print("Use /model to select, or run 'ollama pull <model>'", style="yellow")
1382
+ except Exception:
1383
+ pass
1384
+
1385
+ await interactive_session(rubber_ducky, logger=logger, code=code, quiet_mode=args.quiet)
1247
1386
 
1248
1387
 
1249
1388
  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.5"
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.5
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