rubber-ducky 1.5.0__py3-none-any.whl → 1.5.1__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
@@ -293,52 +186,16 @@ class RubberDuck:
293
186
  self.model = model
294
187
  self.quick = quick
295
188
  self.command_mode = command_mode
296
- self.crumbs = load_crumbs()
189
+ self.last_result: AssistantResult | None = None
297
190
  self.messages: List[Dict[str, str]] = [
298
191
  {"role": "system", "content": self.system_prompt}
299
192
  ]
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
193
 
335
194
  async def send_prompt(
336
195
  self, prompt: str | None = None, code: str | None = None, command_mode: bool | None = None
337
196
  ) -> AssistantResult:
338
197
  user_content = (prompt or "").strip()
339
198
 
340
- self.update_system_prompt()
341
-
342
199
  if code:
343
200
  user_content = f"{user_content}\n\n{code}" if user_content else code
344
201
 
@@ -381,7 +238,9 @@ class RubberDuck:
381
238
 
382
239
  command = self._extract_command(content) if effective_command_mode else None
383
240
 
384
- return AssistantResult(content=content, command=command, thinking=thinking)
241
+ result = AssistantResult(content=content, command=command, thinking=thinking)
242
+ self.last_result = result
243
+ return result
385
244
 
386
245
  async def run_shell_command(self, command: str) -> ShellResult:
387
246
  process = await asyncio.create_subprocess_shell(
@@ -403,8 +262,8 @@ class RubberDuck:
403
262
  return None
404
263
 
405
264
  command_lines: List[str] = []
406
-
407
265
  in_block = False
266
+
408
267
  for line in lines:
409
268
  stripped = line.strip()
410
269
  if stripped.startswith("```"):
@@ -414,20 +273,18 @@ class RubberDuck:
414
273
  continue
415
274
  if in_block:
416
275
  if stripped:
417
- command_lines = [stripped]
418
- break
276
+ command_lines.append(stripped)
419
277
  continue
420
278
  if stripped:
421
- command_lines = [stripped]
279
+ command_lines.append(stripped)
280
+ # If not in a block, only take the first line
422
281
  break
423
282
 
424
283
  if not command_lines:
425
284
  return None
426
285
 
427
- command = command_lines[0]
428
- first_semicolon = command.find(";")
429
- if first_semicolon != -1:
430
- command = command[:first_semicolon].strip()
286
+ # Join all command lines with newlines for multi-line commands
287
+ command = "\n".join(command_lines)
431
288
 
432
289
  return command or None
433
290
 
@@ -497,7 +354,7 @@ class InlineInterface:
497
354
  self.pending_command: str | None = None
498
355
  self.session: PromptSession | None = None
499
356
  self.selected_model: str | None = None
500
- self.running_polling: bool = False
357
+ self.crumb_manager = CrumbManager()
501
358
 
502
359
  if (
503
360
  PromptSession is not None
@@ -643,6 +500,12 @@ class InlineInterface:
643
500
  console.print("Nothing to run yet.", style="yellow")
644
501
  return
645
502
 
503
+ # Check if first word is a crumb name
504
+ first_word = stripped.split()[0].lower()
505
+ if self.crumb_manager.has_crumb(first_word):
506
+ await self._use_crumb(first_word)
507
+ return
508
+
646
509
  if stripped.lower() in {":run", "/run"}:
647
510
  await self._run_last_command()
648
511
  return
@@ -671,12 +534,8 @@ class InlineInterface:
671
534
  await self._show_crumbs()
672
535
  return
673
536
 
674
- if stripped.lower() == "/stop-poll":
675
- await self._stop_polling()
676
- return
677
-
678
- if stripped.startswith("/poll"):
679
- await self._handle_poll_command(stripped)
537
+ if stripped.startswith("/crumb"):
538
+ await self._handle_crumb_command(stripped)
680
539
  return
681
540
 
682
541
  if stripped.startswith("!"):
@@ -731,7 +590,11 @@ class InlineInterface:
731
590
 
732
591
  commands = [
733
592
  ("[bold]/help[/bold]", "Show this help message"),
734
- ("[bold]/crumbs[/bold]", "List all available crumbs"),
593
+ ("[bold]/crumbs[/bold]", "List all saved crumb shortcuts"),
594
+ ("[bold]/crumb <name>[/bold]", "Save last result as a crumb"),
595
+ ("[bold]/crumb add <name> <cmd>[/bold]", "Manually add a crumb"),
596
+ ("[bold]/crumb del <name>[/bold]", "Delete a crumb"),
597
+ ("[bold]<name>[/bold]", "Invoke a saved crumb"),
735
598
  ("[bold]/model[/bold]", "Select a model interactively (local or cloud)"),
736
599
  (
737
600
  "[bold]/local[/bold]",
@@ -742,22 +605,6 @@ class InlineInterface:
742
605
  "[bold]/clear[/bold] or [bold]/reset[/bold]",
743
606
  "Clear conversation history",
744
607
  ),
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
608
  (
762
609
  "[bold]/run[/bold]",
763
610
  "Re-run the last suggested command",
@@ -778,49 +625,31 @@ class InlineInterface:
778
625
  console.print()
779
626
 
780
627
  async def _show_crumbs(self) -> None:
781
- """Display all available crumbs."""
782
- crumbs = self.assistant.crumbs
628
+ """Display all saved crumbs."""
629
+ crumbs = self.crumb_manager.list_crumbs()
783
630
 
784
631
  if not crumbs:
785
- console.print("No crumbs available.", style="yellow")
632
+ console.print("No crumbs saved yet. Use '/crumb <name>' to save a command.", style="yellow")
786
633
  return
787
634
 
788
- console.print("\nAvailable Crumbs", style="bold blue")
789
- console.print("===============", style="bold blue")
635
+ console.print("\nSaved Crumbs", style="bold blue")
636
+ console.print("=============", style="bold blue")
790
637
  console.print()
791
638
 
792
- # Group crumbs by source (default vs user)
793
- default_crumbs = []
794
- user_crumbs = []
639
+ # Calculate max name length for alignment
640
+ max_name_len = max(len(name) for name in crumbs.keys())
795
641
 
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()
642
+ for name, data in sorted(crumbs.items()):
643
+ explanation = data.get("explanation", "") or "No explanation yet"
644
+ command = data.get("command", "") or "No command"
645
+ created_at = data.get("created_at", "")
812
646
 
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()
647
+ # Format: name | explanation | command
648
+ console.print(
649
+ f"[bold]{name:<{max_name_len}}[/bold] | [cyan]{explanation}[/cyan] | [dim]{command}[/dim]"
650
+ )
822
651
 
823
- console.print(f"[dim]Total: {len(crumbs)} crumbs available[/dim]")
652
+ console.print(f"\n[dim]Total: {len(crumbs)} crumbs[/dim]")
824
653
 
825
654
  async def _clear_history(self) -> None:
826
655
  self.assistant.clear_history()
@@ -828,85 +657,142 @@ class InlineInterface:
828
657
  self.pending_command = None
829
658
  self.last_shell_output = None
830
659
 
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
- )
660
+ async def _handle_crumb_command(self, command: str) -> None:
661
+ """Handle /crumb commands."""
662
+ parts = command.split()
663
+ if len(parts) == 1:
664
+ # Just "/crumbs" - show list
665
+ await self._show_crumbs()
838
666
  return
839
667
 
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")
668
+ if len(parts) == 2:
669
+ # "/crumb <name>" - save last result
670
+ name = parts[1]
671
+ await self._save_crumb(name)
845
672
  return
846
673
 
847
- crumb_name = parts[1]
848
- interval = None
849
- prompt = None
674
+ if len(parts) >= 3 and parts[1] == "add":
675
+ # "/crumb add <name> <...command>"
676
+ if len(parts) < 4:
677
+ console.print("Usage: /crumb add <name> <command>", style="yellow")
678
+ return
679
+ name = parts[2]
680
+ cmd = " ".join(parts[3:])
681
+ await self._add_crumb_manual(name, cmd)
682
+ return
850
683
 
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
684
+ if len(parts) == 3 and parts[1] == "del":
685
+ # "/crumb del <name>"
686
+ name = parts[2]
687
+ await self._delete_crumb(name)
688
+ return
866
689
 
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
- )
690
+ console.print(
691
+ "Usage: /crumb <name> | /crumb add <name> <cmd> | /crumb del <name>",
692
+ style="yellow",
693
+ )
694
+
695
+ async def _save_crumb(self, name: str) -> None:
696
+ """Save the last result as a crumb."""
697
+ if not self.assistant.last_result:
698
+ console.print("No previous command to save. Run a command first.", style="yellow")
873
699
  return
874
700
 
875
- crumb = self.assistant.crumbs[crumb_name]
701
+ if not self.assistant.last_result.command:
702
+ console.print("Last response had no command to save.", style="yellow")
703
+ return
876
704
 
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")
705
+ # Find the last user prompt from messages
706
+ last_prompt = ""
707
+ for msg in reversed(self.assistant.messages):
708
+ if msg["role"] == "user":
709
+ last_prompt = msg["content"]
710
+ break
883
711
 
884
- console.print("Starting polling session... Press Ctrl+C to stop.", style="bold cyan")
712
+ self.crumb_manager.save_crumb(
713
+ name=name,
714
+ prompt=last_prompt,
715
+ response=self.assistant.last_result.content,
716
+ command=self.assistant.last_result.command,
717
+ )
885
718
 
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")
719
+ console.print(f"Saved crumb '{name}'!", style="green")
720
+ console.print("Generating explanation...", style="dim")
721
+
722
+ # Spawn subprocess to generate explanation asynchronously
723
+ asyncio.create_task(self._generate_crumb_explanation(name))
897
724
 
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")
725
+ async def _generate_crumb_explanation(self, name: str) -> None:
726
+ """Generate AI explanation for a crumb."""
727
+ crumb = self.crumb_manager.get_crumb(name)
728
+ if not crumb:
902
729
  return
903
730
 
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",
731
+ command = crumb.get("command", "")
732
+ if not command:
733
+ return
734
+
735
+ try:
736
+ explanation_prompt = f"Summarize this command in one line (10-15 words max): {command}"
737
+ result = await self.assistant.send_prompt(prompt=explanation_prompt, command_mode=False)
738
+ explanation = result.content.strip()
739
+
740
+ if explanation:
741
+ self.crumb_manager.update_explanation(name, explanation)
742
+ from rich.text import Text
743
+ text = Text()
744
+ text.append("Explanation added: ", style="cyan")
745
+ text.append(explanation)
746
+ console.print(text)
747
+ except Exception as e:
748
+ console.print(f"Could not generate explanation: {e}", style="yellow")
749
+
750
+ async def _add_crumb_manual(self, name: str, command: str) -> None:
751
+ """Manually add a crumb with a command."""
752
+ self.crumb_manager.save_crumb(
753
+ name=name,
754
+ prompt="Manual addition",
755
+ response="",
756
+ command=command,
908
757
  )
909
758
 
759
+ console.print(f"Added crumb '{name}'!", style="green")
760
+ console.print("Generating explanation...", style="dim")
761
+
762
+ # Spawn subprocess to generate explanation asynchronously
763
+ asyncio.create_task(self._generate_crumb_explanation(name))
764
+
765
+ async def _delete_crumb(self, name: str) -> None:
766
+ """Delete a crumb."""
767
+ if self.crumb_manager.delete_crumb(name):
768
+ console.print(f"Deleted crumb '{name}'.", style="green")
769
+ else:
770
+ console.print(f"Crumb '{name}' not found.", style="yellow")
771
+
772
+ async def _use_crumb(self, name: str) -> None:
773
+ """Recall and execute a saved crumb."""
774
+ crumb = self.crumb_manager.get_crumb(name)
775
+ if not crumb:
776
+ console.print(f"Crumb '{name}' not found.", style="yellow")
777
+ return
778
+
779
+ explanation = crumb.get("explanation", "") or "No explanation"
780
+ command = crumb.get("command", "") or "No command"
781
+
782
+ console.print(f"\n[bold cyan]Crumb: {name}[/bold cyan]")
783
+ console.print(f"Explanation: {explanation}", style="green")
784
+ console.print(f"Command: ", style="cyan", end="")
785
+ console.print(command, highlight=False)
786
+
787
+ if command and command != "No command":
788
+ # Execute the command
789
+ await run_shell_and_print(
790
+ self.assistant,
791
+ command,
792
+ logger=self.logger,
793
+ history=self.assistant.messages,
794
+ )
795
+
910
796
  async def _select_model(self, host: str = "") -> None:
911
797
  """Show available models and allow user to select one with arrow keys."""
912
798
  if PromptSession is None or KeyBindings is None:
@@ -1086,174 +972,6 @@ async def interactive_session(
1086
972
  await ui.run()
1087
973
 
1088
974
 
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
975
  async def ducky() -> None:
1258
976
  parser = argparse.ArgumentParser()
1259
977
  parser.add_argument(
@@ -1266,24 +984,6 @@ async def ducky() -> None:
1266
984
  action="store_true",
1267
985
  help="Run DuckY offline using a local Ollama instance on localhost:11434",
1268
986
  )
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
987
  parser.add_argument(
1288
988
  "single_prompt",
1289
989
  nargs="?",
@@ -1301,9 +1001,9 @@ async def ducky() -> None:
1301
1001
 
1302
1002
  # If --local flag is used, override with local settings
1303
1003
  if getattr(args, "local", False):
1304
- # Point Ollama client to local host and use gemma3 as default model
1004
+ # Point Ollama client to local host and use qwen3 as default model
1305
1005
  os.environ["OLLAMA_HOST"] = "http://localhost:11434"
1306
- args.model = args.model or "gemma2:9b"
1006
+ args.model = args.model or "qwen3"
1307
1007
  last_host = "http://localhost:11434"
1308
1008
  # If no model is specified, use the last used model
1309
1009
  elif args.model is None:
@@ -1343,6 +1043,29 @@ async def ducky() -> None:
1343
1043
  console.print("No input received from stdin.", style="yellow")
1344
1044
  return
1345
1045
 
1046
+ # Handle crumb invocation mode
1047
+ crumb_manager = CrumbManager()
1048
+ if args.single_prompt and crumb_manager.has_crumb(args.single_prompt):
1049
+ crumb = crumb_manager.get_crumb(args.single_prompt)
1050
+ if crumb:
1051
+ explanation = crumb.get("explanation", "") or "No explanation"
1052
+ command = crumb.get("command", "") or "No command"
1053
+
1054
+ console.print(f"\n[bold cyan]Crumb: {args.single_prompt}[/bold cyan]")
1055
+ console.print(f"Explanation: {explanation}", style="green")
1056
+ console.print(f"Command: ", style="cyan", end="")
1057
+ console.print(command, highlight=False)
1058
+
1059
+ if command and command != "No command":
1060
+ # Execute the command
1061
+ await run_shell_and_print(
1062
+ rubber_ducky,
1063
+ command,
1064
+ logger=logger,
1065
+ history=rubber_ducky.messages,
1066
+ )
1067
+ return
1068
+
1346
1069
  # Handle single prompt mode
1347
1070
  if args.single_prompt:
1348
1071
  result = await run_single_prompt(
@@ -1353,33 +1076,6 @@ async def ducky() -> None:
1353
1076
  console.print("\n[green]✓[/green] Command copied to clipboard")
1354
1077
  return
1355
1078
 
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
1079
  await interactive_session(rubber_ducky, logger=logger, code=code)
1384
1080
 
1385
1081