rubber-ducky 1.5.0__py3-none-any.whl → 1.5.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.
ducky/ducky.py CHANGED
@@ -8,7 +8,6 @@ import re
8
8
  import shlex
9
9
  import subprocess
10
10
  import sys
11
- import signal
12
11
  from dataclasses import dataclass
13
12
  from datetime import UTC, datetime
14
13
  from rich.console import Console
@@ -17,25 +16,13 @@ from textwrap import dedent
17
16
  from typing import Any, Dict, List
18
17
 
19
18
 
20
- @dataclass
21
- class Crumb:
22
- name: str
23
- path: Path
24
- type: str
25
- enabled: bool
26
- description: str | None = None
27
- poll: bool = False
28
- poll_type: str | None = None # "interval" or "continuous"
29
- poll_interval: int = 2
30
- poll_prompt: str | None = None
31
-
19
+ from .config import ConfigManager
20
+ from .crumb import CrumbManager
32
21
 
33
22
  from contextlib import nullcontext
34
23
 
35
24
  from ollama import AsyncClient
36
25
 
37
- from .config import ConfigManager
38
-
39
26
  try: # prompt_toolkit is optional at runtime
40
27
  from prompt_toolkit import PromptSession
41
28
  from prompt_toolkit.application import Application
@@ -75,109 +62,15 @@ class ShellResult:
75
62
  HISTORY_DIR = Path.home() / ".ducky"
76
63
  PROMPT_HISTORY_FILE = HISTORY_DIR / "prompt_history"
77
64
  CONVERSATION_LOG_FILE = HISTORY_DIR / "conversation.log"
78
- CRUMBS_DIR = HISTORY_DIR / "crumbs"
79
- CRUMBS: Dict[str, Crumb] = {}
65
+ CRUMBS: Dict[str, Any] = {}
80
66
  console = Console()
81
67
 
82
68
 
83
69
  def ensure_history_dir() -> Path:
84
70
  HISTORY_DIR.mkdir(parents=True, exist_ok=True)
85
- CRUMBS_DIR.mkdir(parents=True, exist_ok=True)
86
71
  return HISTORY_DIR
87
72
 
88
73
 
89
- def load_crumbs() -> Dict[str, Crumb]:
90
- """Populate the global ``CRUMBS`` dictionary from both default and user crumbs.
91
-
92
- Each crumb is expected to be a directory containing an ``info.txt`` and a
93
- script file matching the ``type`` field (``shell`` → ``*.sh``).
94
-
95
- Default crumbs are loaded from the package directory first, then user crumbs
96
- are loaded from ``~/.ducky/crumbs/`` and can override default crumbs if they
97
- have the same name.
98
- """
99
-
100
- global CRUMBS
101
- CRUMBS.clear()
102
-
103
- # Helper function to load crumbs from a directory
104
- def _load_from_dir(dir_path: Path) -> None:
105
- if not dir_path.exists():
106
- return
107
-
108
- for crumb_dir in dir_path.iterdir():
109
- if not crumb_dir.is_dir():
110
- continue
111
- info_path = crumb_dir / "info.txt"
112
- if not info_path.is_file():
113
- continue
114
- # Parse key: value pairs
115
- meta = {}
116
- for line in info_path.read_text(encoding="utf-8").splitlines():
117
- if ":" not in line:
118
- continue
119
- key, val = line.split(":", 1)
120
- meta[key.strip()] = val.strip()
121
- name = meta.get("name", crumb_dir.name)
122
- ctype = meta.get("type", "shell")
123
- description = meta.get("description")
124
- poll = meta.get("poll", "").lower() == "true"
125
- poll_type = meta.get("poll_type")
126
- poll_interval = int(meta.get("poll_interval", 2))
127
- poll_prompt = meta.get("poll_prompt")
128
- # Find script file: look for executable in the directory
129
- script_path: Path | None = None
130
- if ctype == "shell":
131
- # Prefer a file named <name>.sh if present
132
- candidate = crumb_dir / f"{name}.sh"
133
- if candidate.is_file() and os.access(candidate, os.X_OK):
134
- script_path = candidate
135
- else:
136
- # Fallback: first .sh in dir
137
- for p in crumb_dir.glob("*.sh"):
138
- if os.access(p, os.X_OK):
139
- script_path = p
140
- break
141
- # Default to first file if script not found
142
- if script_path is None:
143
- files = list(crumb_dir.iterdir())
144
- if files:
145
- script_path = files[0]
146
- if script_path is None:
147
- continue
148
- crumb = Crumb(
149
- name=name,
150
- path=script_path,
151
- type=ctype,
152
- enabled=False,
153
- description=description,
154
- poll=poll,
155
- poll_type=poll_type,
156
- poll_interval=poll_interval,
157
- poll_prompt=poll_prompt,
158
- )
159
- CRUMBS[name] = crumb
160
-
161
- # Try to load from package directory (where ducky is installed)
162
- try:
163
- # Try to locate the crumbs directory relative to the ducky package
164
- import ducky
165
- # Get the directory containing the ducky package
166
- ducky_dir = Path(ducky.__file__).parent
167
- # Check if crumbs exists in the same directory as ducky package
168
- default_crumbs_dir = ducky_dir.parent / "crumbs"
169
- if default_crumbs_dir.exists():
170
- _load_from_dir(default_crumbs_dir)
171
- except Exception:
172
- # If package directory loading fails, continue without default crumbs
173
- pass
174
-
175
- # Load user crumbs (these can override default crumbs with the same name)
176
- _load_from_dir(CRUMBS_DIR)
177
-
178
- return CRUMBS
179
-
180
-
181
74
  class ConversationLogger:
182
75
  def __init__(self, log_path: Path) -> None:
183
76
  self.log_path = log_path
@@ -210,18 +103,43 @@ class ConversationLogger:
210
103
  handle.write("\n")
211
104
 
212
105
 
213
- def print_shell_result(result: ShellResult) -> None:
214
- printed = False
106
+ def print_shell_result(result: ShellResult, truncate: bool = True) -> None:
107
+ """Print shell command output with optional truncation.
108
+
109
+ Args:
110
+ result: The ShellResult containing command output
111
+ truncate: If True and output is long (>10 lines), show truncated version
112
+ """
113
+ # Determine if we should truncate
114
+ stdout_lines = result.stdout.rstrip().split('\n') if result.stdout else []
115
+ stderr_lines = result.stderr.rstrip().split('\n') if result.stderr else []
116
+ total_lines = len(stdout_lines) + len(stderr_lines)
117
+
118
+ should_truncate = truncate and total_lines > 10
119
+
215
120
  if result.stdout.strip():
216
- console.print(result.stdout.rstrip(), highlight=False)
217
- printed = True
121
+ if should_truncate:
122
+ # Show first 8 lines of stdout
123
+ show_lines = stdout_lines[:8]
124
+ console.print('\n'.join(show_lines), highlight=False)
125
+ console.print(f"... ({len(stdout_lines) - 8} more lines, use /expand to see full output)", style="dim cyan")
126
+ else:
127
+ console.print(result.stdout.rstrip(), highlight=False)
128
+
218
129
  if result.stderr.strip():
219
- if printed:
130
+ if result.stdout.strip():
220
131
  console.print()
221
132
  console.print("[stderr]", style="bold red")
222
- console.print(result.stderr.rstrip(), style="red", highlight=False)
223
- printed = True
224
- if result.returncode != 0 or not printed:
133
+ if should_truncate:
134
+ # Show first 5 lines of stderr
135
+ show_lines = stderr_lines[:5]
136
+ console.print('\n'.join(show_lines), style="red", highlight=False)
137
+ if len(stderr_lines) > 5:
138
+ console.print(f"... ({len(stderr_lines) - 5} more lines)", style="dim red")
139
+ else:
140
+ console.print(result.stderr.rstrip(), style="red", highlight=False)
141
+
142
+ if result.returncode != 0 or (not result.stdout.strip() and not result.stderr.strip()):
225
143
  suffix = (
226
144
  f"(exit status {result.returncode})"
227
145
  if result.returncode != 0
@@ -235,10 +153,11 @@ async def run_shell_and_print(
235
153
  command: str,
236
154
  logger: ConversationLogger | None = None,
237
155
  history: list[dict[str, str]] | None = None,
238
- ) -> None:
156
+ ) -> ShellResult:
157
+ """Run a shell command and print output. Returns the ShellResult."""
239
158
  if not command:
240
159
  console.print("No command provided.", style="yellow")
241
- return
160
+ return ShellResult(command="", stdout="", stderr="", returncode=-1)
242
161
  console.print(f"$ {command}", style="bold magenta")
243
162
  result = await assistant.run_shell_command(command)
244
163
  print_shell_result(result)
@@ -256,6 +175,7 @@ async def run_shell_and_print(
256
175
  if not combined_output:
257
176
  combined_output.append("(command produced no output)")
258
177
  history.append({"role": "assistant", "content": "\n\n".join(combined_output)})
178
+ return result
259
179
 
260
180
 
261
181
  class RubberDuck:
@@ -293,52 +213,16 @@ class RubberDuck:
293
213
  self.model = model
294
214
  self.quick = quick
295
215
  self.command_mode = command_mode
296
- self.crumbs = load_crumbs()
216
+ self.last_result: AssistantResult | None = None
297
217
  self.messages: List[Dict[str, str]] = [
298
218
  {"role": "system", "content": self.system_prompt}
299
219
  ]
300
- # Update system prompt to include enabled crumb descriptions
301
-
302
- def update_system_prompt(self) -> None:
303
- """Append enabled crumb descriptions to the system prompt.
304
-
305
- The system prompt is stored in ``self.system_prompt`` and injected as the
306
- first system message. When crumbs are enabled, we add a section that
307
- lists the crumb names and their descriptions. The format is simple:
308
-
309
- ``Crumbs:``\n
310
- ``- <name>: <description>``\n
311
- If no crumbs are enabled the prompt is unchanged.
312
- """
313
- # Start with the base system prompt
314
- prompt_lines = [self.system_prompt]
315
-
316
- if self.crumbs:
317
- prompt_lines.append(
318
- "\nCrumbs are simple scripts you can run with bash, uv, or bun."
319
- )
320
- prompt_lines.append("Crumbs:")
321
- for c in self.crumbs.values():
322
- description = c.description or "no description"
323
- prompt_lines.append(f"- {c.name}: {description}")
324
-
325
- # Update the system prompt
326
- self.system_prompt = "\n".join(prompt_lines)
327
-
328
- # Update the first system message in the messages list
329
- if self.messages and self.messages[0]["role"] == "system":
330
- self.messages[0]["content"] = self.system_prompt
331
- else:
332
- # If there's no system message, add one
333
- self.messages.insert(0, {"role": "system", "content": self.system_prompt})
334
220
 
335
221
  async def send_prompt(
336
222
  self, prompt: str | None = None, code: str | None = None, command_mode: bool | None = None
337
223
  ) -> AssistantResult:
338
224
  user_content = (prompt or "").strip()
339
225
 
340
- self.update_system_prompt()
341
-
342
226
  if code:
343
227
  user_content = f"{user_content}\n\n{code}" if user_content else code
344
228
 
@@ -381,7 +265,9 @@ class RubberDuck:
381
265
 
382
266
  command = self._extract_command(content) if effective_command_mode else None
383
267
 
384
- return AssistantResult(content=content, command=command, thinking=thinking)
268
+ result = AssistantResult(content=content, command=command, thinking=thinking)
269
+ self.last_result = result
270
+ return result
385
271
 
386
272
  async def run_shell_command(self, command: str) -> ShellResult:
387
273
  process = await asyncio.create_subprocess_shell(
@@ -403,8 +289,8 @@ class RubberDuck:
403
289
  return None
404
290
 
405
291
  command_lines: List[str] = []
406
-
407
292
  in_block = False
293
+
408
294
  for line in lines:
409
295
  stripped = line.strip()
410
296
  if stripped.startswith("```"):
@@ -414,20 +300,18 @@ class RubberDuck:
414
300
  continue
415
301
  if in_block:
416
302
  if stripped:
417
- command_lines = [stripped]
418
- break
303
+ command_lines.append(stripped)
419
304
  continue
420
305
  if stripped:
421
- command_lines = [stripped]
306
+ command_lines.append(stripped)
307
+ # If not in a block, only take the first line
422
308
  break
423
309
 
424
310
  if not command_lines:
425
311
  return None
426
312
 
427
- command = command_lines[0]
428
- first_semicolon = command.find(";")
429
- if first_semicolon != -1:
430
- command = command[:first_semicolon].strip()
313
+ # Join all command lines with newlines for multi-line commands
314
+ command = "\n".join(command_lines)
431
315
 
432
316
  return command or None
433
317
 
@@ -494,10 +378,11 @@ class InlineInterface:
494
378
  self.code = code
495
379
  self._code_sent = False
496
380
  self.last_shell_output: str | None = None
381
+ self.last_shell_result: ShellResult | None = None
497
382
  self.pending_command: str | None = None
498
383
  self.session: PromptSession | None = None
499
384
  self.selected_model: str | None = None
500
- self.running_polling: bool = False
385
+ self.crumb_manager = CrumbManager()
501
386
 
502
387
  if (
503
388
  PromptSession is not None
@@ -618,12 +503,7 @@ class InlineInterface:
618
503
  if not self.last_command:
619
504
  console.print("No suggested command available yet.", style="yellow")
620
505
  return
621
- await run_shell_and_print(
622
- self.assistant,
623
- self.last_command,
624
- logger=self.logger,
625
- history=self.assistant.messages,
626
- )
506
+ await self._run_shell_command(self.last_command)
627
507
  # Add the command to prompt history so user can recall it with up arrow
628
508
  if self.session and self.session.history and self.last_command:
629
509
  self.session.history.append_string(self.last_command)
@@ -631,6 +511,29 @@ class InlineInterface:
631
511
  self.pending_command = None
632
512
  self.last_command = None
633
513
 
514
+ async def _run_shell_command(self, command: str) -> None:
515
+ """Run a shell command, print output (with truncation), and store result."""
516
+ result = await run_shell_and_print(
517
+ self.assistant,
518
+ command,
519
+ logger=self.logger,
520
+ history=self.assistant.messages,
521
+ )
522
+ # Store the result for expansion later
523
+ self.last_shell_result = result
524
+
525
+ async def _expand_last_output(self) -> None:
526
+ """Expand and show the full output of the last shell command."""
527
+ if not self.last_shell_result:
528
+ console.print("No previous shell output to expand.", style="yellow")
529
+ return
530
+
531
+ console.print()
532
+ console.print(f"[Full output for: {self.last_shell_result.command}]", style="bold cyan")
533
+ console.print()
534
+ print_shell_result(self.last_shell_result, truncate=False)
535
+ console.print()
536
+
634
537
  async def _process_text(self, text: str) -> None:
635
538
  stripped = text.strip()
636
539
  if not stripped:
@@ -643,6 +546,12 @@ class InlineInterface:
643
546
  console.print("Nothing to run yet.", style="yellow")
644
547
  return
645
548
 
549
+ # Check if first word is a crumb name
550
+ first_word = stripped.split()[0].lower()
551
+ if self.crumb_manager.has_crumb(first_word):
552
+ await self._use_crumb(first_word)
553
+ return
554
+
646
555
  if stripped.lower() in {":run", "/run"}:
647
556
  await self._run_last_command()
648
557
  return
@@ -671,22 +580,17 @@ class InlineInterface:
671
580
  await self._show_crumbs()
672
581
  return
673
582
 
674
- if stripped.lower() == "/stop-poll":
675
- await self._stop_polling()
583
+ if stripped.startswith("/crumb"):
584
+ await self._handle_crumb_command(stripped)
676
585
  return
677
586
 
678
- if stripped.startswith("/poll"):
679
- await self._handle_poll_command(stripped)
587
+ if stripped.lower() == "/expand":
588
+ await self._expand_last_output()
680
589
  return
681
590
 
682
591
  if stripped.startswith("!"):
683
592
  command = stripped[1:].strip()
684
- await run_shell_and_print(
685
- self.assistant,
686
- command,
687
- logger=self.logger,
688
- history=self.assistant.messages,
689
- )
593
+ await self._run_shell_command(command)
690
594
  # Add the command to prompt history so user can recall it with up arrow
691
595
  if self.session and self.session.history and command:
692
596
  self.session.history.append_string(command)
@@ -731,7 +635,12 @@ class InlineInterface:
731
635
 
732
636
  commands = [
733
637
  ("[bold]/help[/bold]", "Show this help message"),
734
- ("[bold]/crumbs[/bold]", "List all available crumbs"),
638
+ ("[bold]/crumbs[/bold]", "List all saved crumb shortcuts"),
639
+ ("[bold]/crumb <name>[/bold]", "Save last result as a crumb"),
640
+ ("[bold]/crumb add <name> <cmd>[/bold]", "Manually add a crumb"),
641
+ ("[bold]/crumb del <name>[/bold]", "Delete a crumb"),
642
+ ("[bold]<name>[/bold]", "Invoke a saved crumb"),
643
+ ("[bold]/expand[/bold]", "Show full output of last shell command"),
735
644
  ("[bold]/model[/bold]", "Select a model interactively (local or cloud)"),
736
645
  (
737
646
  "[bold]/local[/bold]",
@@ -742,22 +651,6 @@ class InlineInterface:
742
651
  "[bold]/clear[/bold] or [bold]/reset[/bold]",
743
652
  "Clear conversation history",
744
653
  ),
745
- (
746
- "[bold]/poll <crumb>[/bold]",
747
- "Start polling session for a crumb",
748
- ),
749
- (
750
- "[bold]/poll <crumb> -i 5[/bold]",
751
- "Start polling with 5s interval",
752
- ),
753
- (
754
- "[bold]/poll <crumb> -p <text>[/bold]",
755
- "Start polling with custom prompt",
756
- ),
757
- (
758
- "[bold]/stop-poll[/bold]",
759
- "Stop current polling session",
760
- ),
761
654
  (
762
655
  "[bold]/run[/bold]",
763
656
  "Re-run the last suggested command",
@@ -778,49 +671,31 @@ class InlineInterface:
778
671
  console.print()
779
672
 
780
673
  async def _show_crumbs(self) -> None:
781
- """Display all available crumbs."""
782
- crumbs = self.assistant.crumbs
674
+ """Display all saved crumbs."""
675
+ crumbs = self.crumb_manager.list_crumbs()
783
676
 
784
677
  if not crumbs:
785
- console.print("No crumbs available.", style="yellow")
678
+ console.print("No crumbs saved yet. Use '/crumb <name>' to save a command.", style="yellow")
786
679
  return
787
680
 
788
- console.print("\nAvailable Crumbs", style="bold blue")
789
- console.print("===============", style="bold blue")
681
+ console.print("\nSaved Crumbs", style="bold blue")
682
+ console.print("=============", style="bold blue")
790
683
  console.print()
791
684
 
792
- # Group crumbs by source (default vs user)
793
- default_crumbs = []
794
- user_crumbs = []
685
+ # Calculate max name length for alignment
686
+ max_name_len = max(len(name) for name in crumbs.keys())
795
687
 
796
- for name, crumb in sorted(crumbs.items()):
797
- path_str = str(crumb.path)
798
- if "crumbs/" in path_str and "/.ducky/crumbs/" not in path_str:
799
- default_crumbs.append((name, crumb))
800
- else:
801
- user_crumbs.append((name, crumb))
802
-
803
- # Show default crumbs
804
- if default_crumbs:
805
- console.print("[bold cyan]Default Crumbs (shipped with ducky):[/bold cyan]", style="cyan")
806
- for name, crumb in default_crumbs:
807
- description = crumb.description or "No description"
808
- # Check if it has polling enabled
809
- poll_info = " [dim](polling enabled)[/dim]" if crumb.poll else ""
810
- console.print(f" [bold]{name}[/bold]{poll_info}: {description}")
811
- console.print()
688
+ for name, data in sorted(crumbs.items()):
689
+ explanation = data.get("explanation", "") or "No explanation yet"
690
+ command = data.get("command", "") or "No command"
691
+ created_at = data.get("created_at", "")
812
692
 
813
- # Show user crumbs
814
- if user_crumbs:
815
- console.print("[bold green]Your Crumbs:[/bold green]", style="green")
816
- for name, crumb in user_crumbs:
817
- description = crumb.description or "No description"
818
- # Check if it has polling enabled
819
- poll_info = " [dim](polling enabled)[/dim]" if crumb.poll else ""
820
- console.print(f" [bold]{name}[/bold]{poll_info}: {description}")
821
- console.print()
693
+ # Format: name | explanation | command
694
+ console.print(
695
+ f"[bold]{name:<{max_name_len}}[/bold] | [cyan]{explanation}[/cyan] | [dim]{command}[/dim]"
696
+ )
822
697
 
823
- console.print(f"[dim]Total: {len(crumbs)} crumbs available[/dim]")
698
+ console.print(f"\n[dim]Total: {len(crumbs)} crumbs[/dim]")
824
699
 
825
700
  async def _clear_history(self) -> None:
826
701
  self.assistant.clear_history()
@@ -828,85 +703,141 @@ class InlineInterface:
828
703
  self.pending_command = None
829
704
  self.last_shell_output = None
830
705
 
831
- async def _handle_poll_command(self, command: str) -> None:
832
- """Handle /poll command with optional arguments."""
833
- if self.running_polling:
834
- console.print(
835
- "A polling session is already running. Use /stop-poll first.",
836
- style="yellow",
837
- )
706
+ async def _handle_crumb_command(self, command: str) -> None:
707
+ """Handle /crumb commands."""
708
+ parts = command.split()
709
+ if len(parts) == 1:
710
+ # Just "/crumbs" - show list
711
+ await self._show_crumbs()
838
712
  return
839
713
 
840
- # Parse command: /poll <crumb> [-i interval] [-p prompt]
841
- parts = command.split()
842
- if len(parts) < 2:
843
- console.print("Usage: /poll <crumb-name> [-i interval] [-p prompt]", style="yellow")
844
- console.print("Example: /poll log-crumb -i 5", style="dim")
714
+ if len(parts) == 2:
715
+ # "/crumb <name>" - save last result
716
+ name = parts[1]
717
+ await self._save_crumb(name)
845
718
  return
846
719
 
847
- crumb_name = parts[1]
848
- interval = None
849
- prompt = None
720
+ if len(parts) >= 3 and parts[1] == "add":
721
+ # "/crumb add <name> <...command>"
722
+ if len(parts) < 4:
723
+ console.print("Usage: /crumb add <name> <command>", style="yellow")
724
+ return
725
+ name = parts[2]
726
+ cmd = " ".join(parts[3:])
727
+ await self._add_crumb_manual(name, cmd)
728
+ return
850
729
 
851
- # Parse optional arguments
852
- i = 2
853
- while i < len(parts):
854
- if parts[i] in {"-i", "--interval"} and i + 1 < len(parts):
855
- try:
856
- interval = int(parts[i + 1])
857
- i += 2
858
- except ValueError:
859
- console.print("Invalid interval value.", style="red")
860
- return
861
- elif parts[i] in {"-p", "--prompt"} and i + 1 < len(parts):
862
- prompt = " ".join(parts[i + 1:])
863
- break
864
- else:
865
- i += 1
730
+ if len(parts) == 3 and parts[1] == "del":
731
+ # "/crumb del <name>"
732
+ name = parts[2]
733
+ await self._delete_crumb(name)
734
+ return
866
735
 
867
- if crumb_name not in self.assistant.crumbs:
868
- console.print(f"Crumb '{crumb_name}' not found.", style="red")
869
- console.print(
870
- f"Available crumbs: {', '.join(self.assistant.crumbs.keys())}",
871
- style="yellow",
872
- )
736
+ console.print(
737
+ "Usage: /crumb <name> | /crumb add <name> <cmd> | /crumb del <name>",
738
+ style="yellow",
739
+ )
740
+
741
+ async def _save_crumb(self, name: str) -> None:
742
+ """Save the last result as a crumb."""
743
+ if not self.assistant.last_result:
744
+ console.print("No previous command to save. Run a command first.", style="yellow")
873
745
  return
874
746
 
875
- crumb = self.assistant.crumbs[crumb_name]
747
+ if not self.assistant.last_result.command:
748
+ console.print("Last response had no command to save.", style="yellow")
749
+ return
876
750
 
877
- if not crumb.poll:
878
- console.print(
879
- f"Warning: Crumb '{crumb_name}' doesn't have polling enabled.",
880
- style="yellow",
881
- )
882
- console.print("Proceeding anyway with default polling mode.", style="dim")
751
+ # Find the last user prompt from messages
752
+ last_prompt = ""
753
+ for msg in reversed(self.assistant.messages):
754
+ if msg["role"] == "user":
755
+ last_prompt = msg["content"]
756
+ break
883
757
 
884
- console.print("Starting polling session... Press Ctrl+C to stop.", style="bold cyan")
758
+ self.crumb_manager.save_crumb(
759
+ name=name,
760
+ prompt=last_prompt,
761
+ response=self.assistant.last_result.content,
762
+ command=self.assistant.last_result.command,
763
+ )
885
764
 
886
- self.running_polling = True
887
- try:
888
- await polling_session(
889
- self.assistant,
890
- crumb,
891
- interval=interval,
892
- prompt_override=prompt,
893
- )
894
- finally:
895
- self.running_polling = False
896
- console.print("Polling stopped. Returning to interactive mode.", style="green")
765
+ console.print(f"Saved crumb '{name}'!", style="green")
766
+ console.print("Generating explanation...", style="dim")
767
+
768
+ # Spawn subprocess to generate explanation asynchronously
769
+ asyncio.create_task(self._generate_crumb_explanation(name))
897
770
 
898
- async def _stop_polling(self) -> None:
899
- """Handle /stop-poll command."""
900
- if not self.running_polling:
901
- console.print("No polling session is currently running.", style="yellow")
771
+ async def _generate_crumb_explanation(self, name: str) -> None:
772
+ """Generate AI explanation for a crumb."""
773
+ crumb = self.crumb_manager.get_crumb(name)
774
+ if not crumb:
902
775
  return
903
776
 
904
- # This is handled by the signal handler in polling_session
905
- console.print(
906
- "Stopping polling... (press Ctrl+C if it doesn't stop automatically)",
907
- style="yellow",
777
+ command = crumb.get("command", "")
778
+ if not command:
779
+ return
780
+
781
+ try:
782
+ explanation_prompt = f"Summarize this command in one line (10-15 words max): {command}"
783
+ result = await self.assistant.send_prompt(prompt=explanation_prompt, command_mode=False)
784
+ explanation = result.content.strip()
785
+
786
+ if explanation:
787
+ self.crumb_manager.update_explanation(name, explanation)
788
+ from rich.text import Text
789
+
790
+ # Strip ANSI codes from explanation
791
+ clean_explanation = re.sub(r'\x1b\[([0-9;]*[mGK])', '', explanation)
792
+
793
+ text = Text()
794
+ text.append("Explanation added: ", style="cyan")
795
+ text.append(clean_explanation)
796
+ console.print(text)
797
+ except Exception as e:
798
+ console.print(f"Could not generate explanation: {e}", style="yellow")
799
+
800
+ async def _add_crumb_manual(self, name: str, command: str) -> None:
801
+ """Manually add a crumb with a command."""
802
+ self.crumb_manager.save_crumb(
803
+ name=name,
804
+ prompt="Manual addition",
805
+ response="",
806
+ command=command,
908
807
  )
909
808
 
809
+ console.print(f"Added crumb '{name}'!", style="green")
810
+ console.print("Generating explanation...", style="dim")
811
+
812
+ # Spawn subprocess to generate explanation asynchronously
813
+ asyncio.create_task(self._generate_crumb_explanation(name))
814
+
815
+ async def _delete_crumb(self, name: str) -> None:
816
+ """Delete a crumb."""
817
+ if self.crumb_manager.delete_crumb(name):
818
+ console.print(f"Deleted crumb '{name}'.", style="green")
819
+ else:
820
+ console.print(f"Crumb '{name}' not found.", style="yellow")
821
+
822
+ async def _use_crumb(self, name: str) -> None:
823
+ """Recall and execute a saved crumb."""
824
+ crumb = self.crumb_manager.get_crumb(name)
825
+ if not crumb:
826
+ console.print(f"Crumb '{name}' not found.", style="yellow")
827
+ return
828
+
829
+ explanation = crumb.get("explanation", "") or "No explanation"
830
+ command = crumb.get("command", "") or "No command"
831
+
832
+ console.print(f"\n[bold cyan]Crumb: {name}[/bold cyan]")
833
+ console.print(f"Explanation: {explanation}", style="green")
834
+ console.print(f"Command: ", style="cyan", end="")
835
+ console.print(command, highlight=False)
836
+
837
+ if command and command != "No command":
838
+ # Execute the command
839
+ await self._run_shell_command(command)
840
+
910
841
  async def _select_model(self, host: str = "") -> None:
911
842
  """Show available models and allow user to select one with arrow keys."""
912
843
  if PromptSession is None or KeyBindings is None:
@@ -1086,174 +1017,6 @@ async def interactive_session(
1086
1017
  await ui.run()
1087
1018
 
1088
1019
 
1089
- async def polling_session(
1090
- rubber_ducky: RubberDuck,
1091
- crumb: Crumb,
1092
- interval: int | None = None,
1093
- prompt_override: str | None = None,
1094
- ) -> None:
1095
- """Run a polling session for a crumb.
1096
-
1097
- For interval polling: Runs the crumb repeatedly at the specified interval.
1098
- For continuous polling: Runs the crumb once in background and analyzes output periodically.
1099
-
1100
- Args:
1101
- rubber_ducky: The RubberDuck assistant
1102
- crumb: The crumb to poll
1103
- interval: Override the crumb's default interval
1104
- prompt_override: Override the crumb's default poll prompt
1105
- """
1106
- # Use overrides or crumb defaults
1107
- poll_interval = interval or crumb.poll_interval
1108
- poll_prompt = prompt_override or crumb.poll_prompt or "Analyze this output."
1109
- poll_type = crumb.poll_type or "interval"
1110
-
1111
- if not crumb.poll_prompt and not prompt_override:
1112
- console.print("Warning: No poll prompt configured for this crumb.", style="yellow")
1113
- console.print(f"Using default prompt: '{poll_prompt}'", style="dim")
1114
-
1115
- if poll_type == "continuous":
1116
- await _continuous_polling(rubber_ducky, crumb, poll_interval, poll_prompt)
1117
- else:
1118
- await _interval_polling(rubber_ducky, crumb, poll_interval, poll_prompt)
1119
-
1120
-
1121
- async def _interval_polling(
1122
- rubber_ducky: RubberDuck,
1123
- crumb: Crumb,
1124
- interval: int,
1125
- poll_prompt: str,
1126
- ) -> None:
1127
- """Poll by running crumb script at intervals and analyzing with AI."""
1128
- console.print(
1129
- f"\nStarting interval polling for '{crumb.name}' (interval: {interval}s)...\n"
1130
- f"Poll prompt: {poll_prompt}\n"
1131
- f"Press Ctrl+C to stop polling.\n",
1132
- style="bold cyan",
1133
- )
1134
-
1135
- shutdown_event = asyncio.Event()
1136
-
1137
- def signal_handler():
1138
- console.print("\nStopping polling...", style="yellow")
1139
- shutdown_event.set()
1140
-
1141
- loop = asyncio.get_running_loop()
1142
- loop.add_signal_handler(signal.SIGINT, signal_handler)
1143
-
1144
- try:
1145
- while not shutdown_event.is_set():
1146
- timestamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")
1147
- console.print(f"\n[{timestamp}] Polling {crumb.name}...", style="bold blue")
1148
-
1149
- # Run crumb script
1150
- result = await rubber_ducky.run_shell_command(str(crumb.path))
1151
-
1152
- script_output = result.stdout if result.stdout.strip() else "(no output)"
1153
- if result.stderr.strip():
1154
- script_output += f"\n[stderr]\n{result.stderr}"
1155
-
1156
- console.print(f"Script output: {len(result.stdout)} bytes\n", style="dim")
1157
-
1158
- # Send to AI with prompt
1159
- full_prompt = f"{poll_prompt}\n\nScript output:\n{script_output}"
1160
- ai_result = await rubber_ducky.send_prompt(prompt=full_prompt, command_mode=False)
1161
-
1162
- console.print(f"AI: {ai_result.content}", style="green", highlight=False)
1163
-
1164
- # Wait for next interval
1165
- await asyncio.sleep(interval)
1166
- except asyncio.CancelledError:
1167
- console.print("\nPolling stopped.", style="yellow")
1168
- finally:
1169
- loop.remove_signal_handler(signal.SIGINT)
1170
-
1171
-
1172
- async def _continuous_polling(
1173
- rubber_ducky: RubberDuck,
1174
- crumb: Crumb,
1175
- interval: int,
1176
- poll_prompt: str,
1177
- ) -> None:
1178
- """Poll by running crumb continuously and analyzing output periodically."""
1179
- console.print(
1180
- f"\nStarting continuous polling for '{crumb.name}' (analysis interval: {interval}s)...\n"
1181
- f"Poll prompt: {poll_prompt}\n"
1182
- f"Press Ctrl+C to stop polling.\n",
1183
- style="bold cyan",
1184
- )
1185
-
1186
- shutdown_event = asyncio.Event()
1187
- accumulated_output: list[str] = []
1188
-
1189
- def signal_handler():
1190
- console.print("\nStopping polling...", style="yellow")
1191
- shutdown_event.set()
1192
-
1193
- loop = asyncio.get_running_loop()
1194
- loop.add_signal_handler(signal.SIGINT, signal_handler)
1195
-
1196
- # Start crumb process
1197
- process = None
1198
- try:
1199
- process = await asyncio.create_subprocess_shell(
1200
- str(crumb.path),
1201
- stdout=asyncio.subprocess.PIPE,
1202
- stderr=asyncio.subprocess.PIPE,
1203
- )
1204
-
1205
- async def read_stream(stream, name: str):
1206
- """Read output from stream non-blocking."""
1207
- while not shutdown_event.is_set():
1208
- try:
1209
- line = await asyncio.wait_for(stream.readline(), timeout=0.1)
1210
- if not line:
1211
- break
1212
- line_text = line.decode(errors="replace")
1213
- accumulated_output.append(line_text)
1214
- except asyncio.TimeoutError:
1215
- continue
1216
- except Exception:
1217
- break
1218
-
1219
- # Read both stdout and stderr
1220
- asyncio.create_task(read_stream(process.stdout, "stdout"))
1221
- asyncio.create_task(read_stream(process.stderr, "stderr"))
1222
-
1223
- # Main polling loop - analyze accumulated output
1224
- last_analyzed_length = 0
1225
-
1226
- while not shutdown_event.is_set():
1227
- await asyncio.sleep(interval)
1228
-
1229
- # Only analyze if there's new output
1230
- current_length = len(accumulated_output)
1231
- if current_length > last_analyzed_length:
1232
- timestamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")
1233
- console.print(f"\n[{timestamp}] Polling {crumb.name}...", style="bold blue")
1234
-
1235
- # Get new output since last analysis
1236
- new_output = "".join(accumulated_output[last_analyzed_length:])
1237
-
1238
- console.print(f"New script output: {len(new_output)} bytes\n", style="dim")
1239
-
1240
- # Send to AI with prompt
1241
- full_prompt = f"{poll_prompt}\n\nScript output:\n{new_output}"
1242
- ai_result = await rubber_ducky.send_prompt(prompt=full_prompt, command_mode=False)
1243
-
1244
- console.print(f"AI: {ai_result.content}", style="green", highlight=False)
1245
-
1246
- last_analyzed_length = current_length
1247
-
1248
- except asyncio.CancelledError:
1249
- console.print("\nPolling stopped.", style="yellow")
1250
- finally:
1251
- if process:
1252
- process.kill()
1253
- await process.wait()
1254
- loop.remove_signal_handler(signal.SIGINT)
1255
-
1256
-
1257
1020
  async def ducky() -> None:
1258
1021
  parser = argparse.ArgumentParser()
1259
1022
  parser.add_argument(
@@ -1266,24 +1029,6 @@ async def ducky() -> None:
1266
1029
  action="store_true",
1267
1030
  help="Run DuckY offline using a local Ollama instance on localhost:11434",
1268
1031
  )
1269
- parser.add_argument(
1270
- "--poll",
1271
- help="Start polling mode for the specified crumb",
1272
- default=None,
1273
- )
1274
- parser.add_argument(
1275
- "--interval",
1276
- "-i",
1277
- type=int,
1278
- help="Override crumb's polling interval in seconds",
1279
- default=None,
1280
- )
1281
- parser.add_argument(
1282
- "--prompt",
1283
- "-p",
1284
- help="Override crumb's polling prompt",
1285
- default=None,
1286
- )
1287
1032
  parser.add_argument(
1288
1033
  "single_prompt",
1289
1034
  nargs="?",
@@ -1301,9 +1046,9 @@ async def ducky() -> None:
1301
1046
 
1302
1047
  # If --local flag is used, override with local settings
1303
1048
  if getattr(args, "local", False):
1304
- # Point Ollama client to local host and use gemma3 as default model
1049
+ # Point Ollama client to local host and use qwen3 as default model
1305
1050
  os.environ["OLLAMA_HOST"] = "http://localhost:11434"
1306
- args.model = args.model or "gemma2:9b"
1051
+ args.model = args.model or "qwen3"
1307
1052
  last_host = "http://localhost:11434"
1308
1053
  # If no model is specified, use the last used model
1309
1054
  elif args.model is None:
@@ -1343,6 +1088,29 @@ async def ducky() -> None:
1343
1088
  console.print("No input received from stdin.", style="yellow")
1344
1089
  return
1345
1090
 
1091
+ # Handle crumb invocation mode
1092
+ crumb_manager = CrumbManager()
1093
+ if args.single_prompt and crumb_manager.has_crumb(args.single_prompt):
1094
+ crumb = crumb_manager.get_crumb(args.single_prompt)
1095
+ if crumb:
1096
+ explanation = crumb.get("explanation", "") or "No explanation"
1097
+ command = crumb.get("command", "") or "No command"
1098
+
1099
+ console.print(f"\n[bold cyan]Crumb: {args.single_prompt}[/bold cyan]")
1100
+ console.print(f"Explanation: {explanation}", style="green")
1101
+ console.print(f"Command: ", style="cyan", end="")
1102
+ console.print(command, highlight=False)
1103
+
1104
+ if command and command != "No command":
1105
+ # Execute the command
1106
+ await run_shell_and_print(
1107
+ rubber_ducky,
1108
+ command,
1109
+ logger=logger,
1110
+ history=rubber_ducky.messages,
1111
+ )
1112
+ return
1113
+
1346
1114
  # Handle single prompt mode
1347
1115
  if args.single_prompt:
1348
1116
  result = await run_single_prompt(
@@ -1353,33 +1121,6 @@ async def ducky() -> None:
1353
1121
  console.print("\n[green]✓[/green] Command copied to clipboard")
1354
1122
  return
1355
1123
 
1356
- # Handle polling mode
1357
- if args.poll:
1358
- crumb_name = args.poll
1359
- if crumb_name not in rubber_ducky.crumbs:
1360
- console.print(f"Crumb '{crumb_name}' not found.", style="red")
1361
- console.print(
1362
- f"Available crumbs: {', '.join(rubber_ducky.crumbs.keys())}",
1363
- style="yellow",
1364
- )
1365
- return
1366
-
1367
- crumb = rubber_ducky.crumbs[crumb_name]
1368
- if not crumb.poll:
1369
- console.print(
1370
- f"Warning: Crumb '{crumb_name}' doesn't have polling enabled.",
1371
- style="yellow",
1372
- )
1373
- console.print("Proceeding anyway with default polling mode.", style="dim")
1374
-
1375
- await polling_session(
1376
- rubber_ducky,
1377
- crumb,
1378
- interval=args.interval,
1379
- prompt_override=args.prompt,
1380
- )
1381
- return
1382
-
1383
1124
  await interactive_session(rubber_ducky, logger=logger, code=code)
1384
1125
 
1385
1126