rubber-ducky 1.3.0__py3-none-any.whl → 1.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ducky/ducky.py CHANGED
@@ -4,7 +4,11 @@ import argparse
4
4
  import asyncio
5
5
  import json
6
6
  import os
7
+ import re
8
+ import shlex
9
+ import subprocess
7
10
  import sys
11
+ import signal
8
12
  from dataclasses import dataclass
9
13
  from datetime import UTC, datetime
10
14
  from rich.console import Console
@@ -20,6 +24,10 @@ class Crumb:
20
24
  type: str
21
25
  enabled: bool
22
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
23
31
 
24
32
 
25
33
  from contextlib import nullcontext
@@ -79,61 +87,93 @@ def ensure_history_dir() -> Path:
79
87
 
80
88
 
81
89
  def load_crumbs() -> Dict[str, Crumb]:
82
- """Populate the global ``CRUMBS`` dictionary from the ``CRUMBS_DIR``.
90
+ """Populate the global ``CRUMBS`` dictionary from both default and user crumbs.
83
91
 
84
92
  Each crumb is expected to be a directory containing an ``info.txt`` and a
85
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.
86
98
  """
87
99
 
88
100
  global CRUMBS
89
101
  CRUMBS.clear()
90
- if not CRUMBS_DIR.exists():
91
- return CRUMBS
92
102
 
93
- for crumb_dir in CRUMBS_DIR.iterdir():
94
- if not crumb_dir.is_dir():
95
- continue
96
- info_path = crumb_dir / "info.txt"
97
- if not info_path.is_file():
98
- continue
99
- # Parse key: value pairs
100
- meta = {}
101
- for line in info_path.read_text(encoding="utf-8").splitlines():
102
- if ":" not in line:
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():
103
110
  continue
104
- key, val = line.split(":", 1)
105
- meta[key.strip()] = val.strip()
106
- name = meta.get("name", crumb_dir.name)
107
- ctype = meta.get("type", "shell")
108
- description = meta.get("description")
109
- # Find script file: look for executable in the directory
110
- script_path: Path | None = None
111
- if ctype == "shell":
112
- # Prefer a file named <name>.sh if present
113
- candidate = crumb_dir / f"{name}.sh"
114
- if candidate.is_file() and os.access(candidate, os.X_OK):
115
- script_path = candidate
116
- else:
117
- # Fallback: first .sh in dir
118
- for p in crumb_dir.glob("*.sh"):
119
- if os.access(p, os.X_OK):
120
- script_path = p
121
- break
122
- # Default to first file if script not found
123
- if script_path is None:
124
- files = list(crumb_dir.iterdir())
125
- if files:
126
- script_path = files[0]
127
- if script_path is None:
128
- continue
129
- crumb = Crumb(
130
- name=name,
131
- path=script_path,
132
- type=ctype,
133
- enabled=False,
134
- description=description,
135
- )
136
- CRUMBS[name] = crumb
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)
137
177
 
138
178
  return CRUMBS
139
179
 
@@ -293,7 +333,7 @@ class RubberDuck:
293
333
  self.messages.insert(0, {"role": "system", "content": self.system_prompt})
294
334
 
295
335
  async def send_prompt(
296
- self, prompt: str | None = None, code: str | None = None
336
+ self, prompt: str | None = None, code: str | None = None, command_mode: bool | None = None
297
337
  ) -> AssistantResult:
298
338
  user_content = (prompt or "").strip()
299
339
 
@@ -305,7 +345,10 @@ class RubberDuck:
305
345
  if self.quick and user_content:
306
346
  user_content += ". Return a command and be extremely concise"
307
347
 
308
- if self.command_mode:
348
+ # Use provided command_mode, or fall back to self.command_mode
349
+ effective_command_mode = command_mode if command_mode is not None else self.command_mode
350
+
351
+ if effective_command_mode:
309
352
  instruction = (
310
353
  "Return a single bash command that accomplishes the task. Unless user wants something els"
311
354
  "Do not include explanations or formatting other than the command itself."
@@ -321,7 +364,7 @@ class RubberDuck:
321
364
  model=self.model,
322
365
  messages=self.messages,
323
366
  stream=False,
324
- think=True,
367
+ think=False,
325
368
  )
326
369
 
327
370
  assistant_message: Any | None = response.message
@@ -336,7 +379,7 @@ class RubberDuck:
336
379
  if thinking:
337
380
  self.last_thinking = thinking
338
381
 
339
- command = self._extract_command(content) if self.command_mode else None
382
+ command = self._extract_command(content) if effective_command_mode else None
340
383
 
341
384
  return AssistantResult(content=content, command=command, thinking=thinking)
342
385
 
@@ -454,6 +497,7 @@ class InlineInterface:
454
497
  self.pending_command: str | None = None
455
498
  self.session: PromptSession | None = None
456
499
  self.selected_model: str | None = None
500
+ self.running_polling: bool = False
457
501
 
458
502
  if (
459
503
  PromptSession is not None
@@ -623,6 +667,18 @@ class InlineInterface:
623
667
  await self._show_help()
624
668
  return
625
669
 
670
+ if stripped.lower() == "/crumbs":
671
+ await self._show_crumbs()
672
+ return
673
+
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)
680
+ return
681
+
626
682
  if stripped.startswith("!"):
627
683
  command = stripped[1:].strip()
628
684
  await run_shell_and_print(
@@ -648,8 +704,6 @@ class InlineInterface:
648
704
  self._code_sent = True
649
705
  self.last_command = result.command
650
706
  self.pending_command = result.command
651
- # Set last_shell_output to True so empty Enter will explain the result
652
- self.last_shell_output = True
653
707
 
654
708
  async def _explain_last_command(self) -> None:
655
709
  if not self.assistant.messages or len(self.assistant.messages) < 2:
@@ -677,6 +731,7 @@ class InlineInterface:
677
731
 
678
732
  commands = [
679
733
  ("[bold]/help[/bold]", "Show this help message"),
734
+ ("[bold]/crumbs[/bold]", "List all available crumbs"),
680
735
  ("[bold]/model[/bold]", "Select a model interactively (local or cloud)"),
681
736
  (
682
737
  "[bold]/local[/bold]",
@@ -687,6 +742,22 @@ class InlineInterface:
687
742
  "[bold]/clear[/bold] or [bold]/reset[/bold]",
688
743
  "Clear conversation history",
689
744
  ),
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
+ ),
690
761
  (
691
762
  "[bold]/run[/bold]",
692
763
  "Re-run the last suggested command",
@@ -702,16 +773,140 @@ class InlineInterface:
702
773
  ]
703
774
 
704
775
  for command, description in commands:
705
- console.print(f"{command:<30} {description}")
776
+ console.print(f"{command:<45} {description}")
777
+
778
+ console.print()
779
+
780
+ async def _show_crumbs(self) -> None:
781
+ """Display all available crumbs."""
782
+ crumbs = self.assistant.crumbs
783
+
784
+ if not crumbs:
785
+ console.print("No crumbs available.", style="yellow")
786
+ return
706
787
 
788
+ console.print("\nAvailable Crumbs", style="bold blue")
789
+ console.print("===============", style="bold blue")
707
790
  console.print()
708
791
 
792
+ # Group crumbs by source (default vs user)
793
+ default_crumbs = []
794
+ user_crumbs = []
795
+
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()
812
+
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()
822
+
823
+ console.print(f"[dim]Total: {len(crumbs)} crumbs available[/dim]")
824
+
709
825
  async def _clear_history(self) -> None:
710
826
  self.assistant.clear_history()
711
827
  self.last_command = None
712
828
  self.pending_command = None
713
829
  self.last_shell_output = None
714
830
 
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
+ )
838
+ return
839
+
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")
845
+ return
846
+
847
+ crumb_name = parts[1]
848
+ interval = None
849
+ prompt = None
850
+
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
866
+
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
+ )
873
+ return
874
+
875
+ crumb = self.assistant.crumbs[crumb_name]
876
+
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")
883
+
884
+ console.print("Starting polling session... Press Ctrl+C to stop.", style="bold cyan")
885
+
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")
897
+
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")
902
+ return
903
+
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",
908
+ )
909
+
715
910
  async def _select_model(self, host: str = "") -> None:
716
911
  """Show available models and allow user to select one with arrow keys."""
717
912
  if PromptSession is None or KeyBindings is None:
@@ -860,6 +1055,16 @@ async def run_single_prompt(
860
1055
  return result
861
1056
 
862
1057
 
1058
+ def copy_to_clipboard(text: str) -> bool:
1059
+ """Copy text to system clipboard using pbcopy on macOS."""
1060
+ try:
1061
+ process = subprocess.Popen(['pbcopy'], stdin=subprocess.PIPE)
1062
+ process.communicate(text.encode('utf-8'))
1063
+ return process.returncode == 0
1064
+ except Exception:
1065
+ return False
1066
+
1067
+
863
1068
  def confirm(prompt: str, default: bool = False) -> bool:
864
1069
  suffix = " [Y/n]: " if default else " [y/N]: "
865
1070
  try:
@@ -881,6 +1086,174 @@ async def interactive_session(
881
1086
  await ui.run()
882
1087
 
883
1088
 
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
+
884
1257
  async def ducky() -> None:
885
1258
  parser = argparse.ArgumentParser()
886
1259
  parser.add_argument(
@@ -893,7 +1266,31 @@ async def ducky() -> None:
893
1266
  action="store_true",
894
1267
  help="Run DuckY offline using a local Ollama instance on localhost:11434",
895
1268
  )
896
- args, _ = parser.parse_known_args()
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
+ parser.add_argument(
1288
+ "single_prompt",
1289
+ nargs="?",
1290
+ help="Run a single prompt and copy the suggested command to clipboard",
1291
+ default=None,
1292
+ )
1293
+ args = parser.parse_args()
897
1294
 
898
1295
  ensure_history_dir()
899
1296
  logger = ConversationLogger(CONVERSATION_LOG_FILE)
@@ -946,6 +1343,43 @@ async def ducky() -> None:
946
1343
  console.print("No input received from stdin.", style="yellow")
947
1344
  return
948
1345
 
1346
+ # Handle single prompt mode
1347
+ if args.single_prompt:
1348
+ result = await run_single_prompt(
1349
+ rubber_ducky, args.single_prompt, code=code, logger=logger
1350
+ )
1351
+ if result.command:
1352
+ if copy_to_clipboard(result.command):
1353
+ console.print("\n[green]✓[/green] Command copied to clipboard")
1354
+ return
1355
+
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
+
949
1383
  await interactive_session(rubber_ducky, logger=logger, code=code)
950
1384
 
951
1385