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.
- crumbs/disk-usage/disk-usage.sh +12 -0
- crumbs/disk-usage/info.txt +3 -0
- crumbs/git-log/git-log.sh +24 -0
- crumbs/git-log/info.txt +3 -0
- crumbs/git-status/git-status.sh +21 -0
- crumbs/git-status/info.txt +3 -0
- crumbs/process-list/info.txt +3 -0
- crumbs/process-list/process-list.sh +20 -0
- crumbs/recent-files/info.txt +3 -0
- crumbs/recent-files/recent-files.sh +13 -0
- crumbs/system-health/info.txt +3 -0
- crumbs/system-health/system-health.sh +58 -0
- ducky/config.py +3 -3
- ducky/ducky.py +488 -54
- examples/POLLING_USER_GUIDE.md +470 -0
- examples/mock-logs/info.txt +7 -0
- examples/mock-logs/mock-logs.sh +39 -0
- rubber_ducky-1.5.0.dist-info/METADATA +210 -0
- rubber_ducky-1.5.0.dist-info/RECORD +24 -0
- rubber_ducky-1.5.0.dist-info/top_level.txt +3 -0
- rubber_ducky-1.3.0.dist-info/METADATA +0 -98
- rubber_ducky-1.3.0.dist-info/RECORD +0 -9
- rubber_ducky-1.3.0.dist-info/top_level.txt +0 -1
- {rubber_ducky-1.3.0.dist-info → rubber_ducky-1.5.0.dist-info}/WHEEL +0 -0
- {rubber_ducky-1.3.0.dist-info → rubber_ducky-1.5.0.dist-info}/entry_points.txt +0 -0
- {rubber_ducky-1.3.0.dist-info → rubber_ducky-1.5.0.dist-info}/licenses/LICENSE +0 -0
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
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
|
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:<
|
|
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
|
-
|
|
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
|
|