rubber-ducky 1.3.0__py3-none-any.whl → 1.4.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/ducky.py +459 -50
- 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.4.0.dist-info/METADATA +210 -0
- rubber_ducky-1.4.0.dist-info/RECORD +24 -0
- rubber_ducky-1.4.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.4.0.dist-info}/WHEEL +0 -0
- {rubber_ducky-1.3.0.dist-info → rubber_ducky-1.4.0.dist-info}/entry_points.txt +0 -0
- {rubber_ducky-1.3.0.dist-info → rubber_ducky-1.4.0.dist-info}/licenses/LICENSE +0 -0
ducky/ducky.py
CHANGED
|
@@ -4,7 +4,10 @@ import argparse
|
|
|
4
4
|
import asyncio
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
7
9
|
import sys
|
|
10
|
+
import signal
|
|
8
11
|
from dataclasses import dataclass
|
|
9
12
|
from datetime import UTC, datetime
|
|
10
13
|
from rich.console import Console
|
|
@@ -20,6 +23,10 @@ class Crumb:
|
|
|
20
23
|
type: str
|
|
21
24
|
enabled: bool
|
|
22
25
|
description: str | None = None
|
|
26
|
+
poll: bool = False
|
|
27
|
+
poll_type: str | None = None # "interval" or "continuous"
|
|
28
|
+
poll_interval: int = 2
|
|
29
|
+
poll_prompt: str | None = None
|
|
23
30
|
|
|
24
31
|
|
|
25
32
|
from contextlib import nullcontext
|
|
@@ -79,61 +86,93 @@ def ensure_history_dir() -> Path:
|
|
|
79
86
|
|
|
80
87
|
|
|
81
88
|
def load_crumbs() -> Dict[str, Crumb]:
|
|
82
|
-
"""Populate the global ``CRUMBS`` dictionary from
|
|
89
|
+
"""Populate the global ``CRUMBS`` dictionary from both default and user crumbs.
|
|
83
90
|
|
|
84
91
|
Each crumb is expected to be a directory containing an ``info.txt`` and a
|
|
85
92
|
script file matching the ``type`` field (``shell`` → ``*.sh``).
|
|
93
|
+
|
|
94
|
+
Default crumbs are loaded from the package directory first, then user crumbs
|
|
95
|
+
are loaded from ``~/.ducky/crumbs/`` and can override default crumbs if they
|
|
96
|
+
have the same name.
|
|
86
97
|
"""
|
|
87
98
|
|
|
88
99
|
global CRUMBS
|
|
89
100
|
CRUMBS.clear()
|
|
90
|
-
if not CRUMBS_DIR.exists():
|
|
91
|
-
return CRUMBS
|
|
92
101
|
|
|
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:
|
|
102
|
+
# Helper function to load crumbs from a directory
|
|
103
|
+
def _load_from_dir(dir_path: Path) -> None:
|
|
104
|
+
if not dir_path.exists():
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
for crumb_dir in dir_path.iterdir():
|
|
108
|
+
if not crumb_dir.is_dir():
|
|
103
109
|
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
|
-
|
|
110
|
+
info_path = crumb_dir / "info.txt"
|
|
111
|
+
if not info_path.is_file():
|
|
112
|
+
continue
|
|
113
|
+
# Parse key: value pairs
|
|
114
|
+
meta = {}
|
|
115
|
+
for line in info_path.read_text(encoding="utf-8").splitlines():
|
|
116
|
+
if ":" not in line:
|
|
117
|
+
continue
|
|
118
|
+
key, val = line.split(":", 1)
|
|
119
|
+
meta[key.strip()] = val.strip()
|
|
120
|
+
name = meta.get("name", crumb_dir.name)
|
|
121
|
+
ctype = meta.get("type", "shell")
|
|
122
|
+
description = meta.get("description")
|
|
123
|
+
poll = meta.get("poll", "").lower() == "true"
|
|
124
|
+
poll_type = meta.get("poll_type")
|
|
125
|
+
poll_interval = int(meta.get("poll_interval", 2))
|
|
126
|
+
poll_prompt = meta.get("poll_prompt")
|
|
127
|
+
# Find script file: look for executable in the directory
|
|
128
|
+
script_path: Path | None = None
|
|
129
|
+
if ctype == "shell":
|
|
130
|
+
# Prefer a file named <name>.sh if present
|
|
131
|
+
candidate = crumb_dir / f"{name}.sh"
|
|
132
|
+
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
133
|
+
script_path = candidate
|
|
134
|
+
else:
|
|
135
|
+
# Fallback: first .sh in dir
|
|
136
|
+
for p in crumb_dir.glob("*.sh"):
|
|
137
|
+
if os.access(p, os.X_OK):
|
|
138
|
+
script_path = p
|
|
139
|
+
break
|
|
140
|
+
# Default to first file if script not found
|
|
141
|
+
if script_path is None:
|
|
142
|
+
files = list(crumb_dir.iterdir())
|
|
143
|
+
if files:
|
|
144
|
+
script_path = files[0]
|
|
145
|
+
if script_path is None:
|
|
146
|
+
continue
|
|
147
|
+
crumb = Crumb(
|
|
148
|
+
name=name,
|
|
149
|
+
path=script_path,
|
|
150
|
+
type=ctype,
|
|
151
|
+
enabled=False,
|
|
152
|
+
description=description,
|
|
153
|
+
poll=poll,
|
|
154
|
+
poll_type=poll_type,
|
|
155
|
+
poll_interval=poll_interval,
|
|
156
|
+
poll_prompt=poll_prompt,
|
|
157
|
+
)
|
|
158
|
+
CRUMBS[name] = crumb
|
|
159
|
+
|
|
160
|
+
# Try to load from package directory (where ducky is installed)
|
|
161
|
+
try:
|
|
162
|
+
# Try to locate the crumbs directory relative to the ducky package
|
|
163
|
+
import ducky
|
|
164
|
+
# Get the directory containing the ducky package
|
|
165
|
+
ducky_dir = Path(ducky.__file__).parent
|
|
166
|
+
# Check if crumbs exists in the same directory as ducky package
|
|
167
|
+
default_crumbs_dir = ducky_dir.parent / "crumbs"
|
|
168
|
+
if default_crumbs_dir.exists():
|
|
169
|
+
_load_from_dir(default_crumbs_dir)
|
|
170
|
+
except Exception:
|
|
171
|
+
# If package directory loading fails, continue without default crumbs
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
# Load user crumbs (these can override default crumbs with the same name)
|
|
175
|
+
_load_from_dir(CRUMBS_DIR)
|
|
137
176
|
|
|
138
177
|
return CRUMBS
|
|
139
178
|
|
|
@@ -293,7 +332,7 @@ class RubberDuck:
|
|
|
293
332
|
self.messages.insert(0, {"role": "system", "content": self.system_prompt})
|
|
294
333
|
|
|
295
334
|
async def send_prompt(
|
|
296
|
-
self, prompt: str | None = None, code: str | None = None
|
|
335
|
+
self, prompt: str | None = None, code: str | None = None, command_mode: bool | None = None
|
|
297
336
|
) -> AssistantResult:
|
|
298
337
|
user_content = (prompt or "").strip()
|
|
299
338
|
|
|
@@ -305,7 +344,10 @@ class RubberDuck:
|
|
|
305
344
|
if self.quick and user_content:
|
|
306
345
|
user_content += ". Return a command and be extremely concise"
|
|
307
346
|
|
|
308
|
-
|
|
347
|
+
# Use provided command_mode, or fall back to self.command_mode
|
|
348
|
+
effective_command_mode = command_mode if command_mode is not None else self.command_mode
|
|
349
|
+
|
|
350
|
+
if effective_command_mode:
|
|
309
351
|
instruction = (
|
|
310
352
|
"Return a single bash command that accomplishes the task. Unless user wants something els"
|
|
311
353
|
"Do not include explanations or formatting other than the command itself."
|
|
@@ -336,7 +378,7 @@ class RubberDuck:
|
|
|
336
378
|
if thinking:
|
|
337
379
|
self.last_thinking = thinking
|
|
338
380
|
|
|
339
|
-
command = self._extract_command(content) if
|
|
381
|
+
command = self._extract_command(content) if effective_command_mode else None
|
|
340
382
|
|
|
341
383
|
return AssistantResult(content=content, command=command, thinking=thinking)
|
|
342
384
|
|
|
@@ -454,6 +496,7 @@ class InlineInterface:
|
|
|
454
496
|
self.pending_command: str | None = None
|
|
455
497
|
self.session: PromptSession | None = None
|
|
456
498
|
self.selected_model: str | None = None
|
|
499
|
+
self.running_polling: bool = False
|
|
457
500
|
|
|
458
501
|
if (
|
|
459
502
|
PromptSession is not None
|
|
@@ -623,6 +666,18 @@ class InlineInterface:
|
|
|
623
666
|
await self._show_help()
|
|
624
667
|
return
|
|
625
668
|
|
|
669
|
+
if stripped.lower() == "/crumbs":
|
|
670
|
+
await self._show_crumbs()
|
|
671
|
+
return
|
|
672
|
+
|
|
673
|
+
if stripped.lower() == "/stop-poll":
|
|
674
|
+
await self._stop_polling()
|
|
675
|
+
return
|
|
676
|
+
|
|
677
|
+
if stripped.startswith("/poll"):
|
|
678
|
+
await self._handle_poll_command(stripped)
|
|
679
|
+
return
|
|
680
|
+
|
|
626
681
|
if stripped.startswith("!"):
|
|
627
682
|
command = stripped[1:].strip()
|
|
628
683
|
await run_shell_and_print(
|
|
@@ -677,6 +732,7 @@ class InlineInterface:
|
|
|
677
732
|
|
|
678
733
|
commands = [
|
|
679
734
|
("[bold]/help[/bold]", "Show this help message"),
|
|
735
|
+
("[bold]/crumbs[/bold]", "List all available crumbs"),
|
|
680
736
|
("[bold]/model[/bold]", "Select a model interactively (local or cloud)"),
|
|
681
737
|
(
|
|
682
738
|
"[bold]/local[/bold]",
|
|
@@ -687,6 +743,22 @@ class InlineInterface:
|
|
|
687
743
|
"[bold]/clear[/bold] or [bold]/reset[/bold]",
|
|
688
744
|
"Clear conversation history",
|
|
689
745
|
),
|
|
746
|
+
(
|
|
747
|
+
"[bold]/poll <crumb>[/bold]",
|
|
748
|
+
"Start polling session for a crumb",
|
|
749
|
+
),
|
|
750
|
+
(
|
|
751
|
+
"[bold]/poll <crumb> -i 5[/bold]",
|
|
752
|
+
"Start polling with 5s interval",
|
|
753
|
+
),
|
|
754
|
+
(
|
|
755
|
+
"[bold]/poll <crumb> -p <text>[/bold]",
|
|
756
|
+
"Start polling with custom prompt",
|
|
757
|
+
),
|
|
758
|
+
(
|
|
759
|
+
"[bold]/stop-poll[/bold]",
|
|
760
|
+
"Stop current polling session",
|
|
761
|
+
),
|
|
690
762
|
(
|
|
691
763
|
"[bold]/run[/bold]",
|
|
692
764
|
"Re-run the last suggested command",
|
|
@@ -702,16 +774,140 @@ class InlineInterface:
|
|
|
702
774
|
]
|
|
703
775
|
|
|
704
776
|
for command, description in commands:
|
|
705
|
-
console.print(f"{command:<
|
|
777
|
+
console.print(f"{command:<45} {description}")
|
|
706
778
|
|
|
707
779
|
console.print()
|
|
708
780
|
|
|
781
|
+
async def _show_crumbs(self) -> None:
|
|
782
|
+
"""Display all available crumbs."""
|
|
783
|
+
crumbs = self.assistant.crumbs
|
|
784
|
+
|
|
785
|
+
if not crumbs:
|
|
786
|
+
console.print("No crumbs available.", style="yellow")
|
|
787
|
+
return
|
|
788
|
+
|
|
789
|
+
console.print("\nAvailable Crumbs", style="bold blue")
|
|
790
|
+
console.print("===============", style="bold blue")
|
|
791
|
+
console.print()
|
|
792
|
+
|
|
793
|
+
# Group crumbs by source (default vs user)
|
|
794
|
+
default_crumbs = []
|
|
795
|
+
user_crumbs = []
|
|
796
|
+
|
|
797
|
+
for name, crumb in sorted(crumbs.items()):
|
|
798
|
+
path_str = str(crumb.path)
|
|
799
|
+
if "crumbs/" in path_str and "/.ducky/crumbs/" not in path_str:
|
|
800
|
+
default_crumbs.append((name, crumb))
|
|
801
|
+
else:
|
|
802
|
+
user_crumbs.append((name, crumb))
|
|
803
|
+
|
|
804
|
+
# Show default crumbs
|
|
805
|
+
if default_crumbs:
|
|
806
|
+
console.print("[bold cyan]Default Crumbs (shipped with ducky):[/bold cyan]", style="cyan")
|
|
807
|
+
for name, crumb in default_crumbs:
|
|
808
|
+
description = crumb.description or "No description"
|
|
809
|
+
# Check if it has polling enabled
|
|
810
|
+
poll_info = " [dim](polling enabled)[/dim]" if crumb.poll else ""
|
|
811
|
+
console.print(f" [bold]{name}[/bold]{poll_info}: {description}")
|
|
812
|
+
console.print()
|
|
813
|
+
|
|
814
|
+
# Show user crumbs
|
|
815
|
+
if user_crumbs:
|
|
816
|
+
console.print("[bold green]Your Crumbs:[/bold green]", style="green")
|
|
817
|
+
for name, crumb in user_crumbs:
|
|
818
|
+
description = crumb.description or "No description"
|
|
819
|
+
# Check if it has polling enabled
|
|
820
|
+
poll_info = " [dim](polling enabled)[/dim]" if crumb.poll else ""
|
|
821
|
+
console.print(f" [bold]{name}[/bold]{poll_info}: {description}")
|
|
822
|
+
console.print()
|
|
823
|
+
|
|
824
|
+
console.print(f"[dim]Total: {len(crumbs)} crumbs available[/dim]")
|
|
825
|
+
|
|
709
826
|
async def _clear_history(self) -> None:
|
|
710
827
|
self.assistant.clear_history()
|
|
711
828
|
self.last_command = None
|
|
712
829
|
self.pending_command = None
|
|
713
830
|
self.last_shell_output = None
|
|
714
831
|
|
|
832
|
+
async def _handle_poll_command(self, command: str) -> None:
|
|
833
|
+
"""Handle /poll command with optional arguments."""
|
|
834
|
+
if self.running_polling:
|
|
835
|
+
console.print(
|
|
836
|
+
"A polling session is already running. Use /stop-poll first.",
|
|
837
|
+
style="yellow",
|
|
838
|
+
)
|
|
839
|
+
return
|
|
840
|
+
|
|
841
|
+
# Parse command: /poll <crumb> [-i interval] [-p prompt]
|
|
842
|
+
parts = command.split()
|
|
843
|
+
if len(parts) < 2:
|
|
844
|
+
console.print("Usage: /poll <crumb-name> [-i interval] [-p prompt]", style="yellow")
|
|
845
|
+
console.print("Example: /poll log-crumb -i 5", style="dim")
|
|
846
|
+
return
|
|
847
|
+
|
|
848
|
+
crumb_name = parts[1]
|
|
849
|
+
interval = None
|
|
850
|
+
prompt = None
|
|
851
|
+
|
|
852
|
+
# Parse optional arguments
|
|
853
|
+
i = 2
|
|
854
|
+
while i < len(parts):
|
|
855
|
+
if parts[i] in {"-i", "--interval"} and i + 1 < len(parts):
|
|
856
|
+
try:
|
|
857
|
+
interval = int(parts[i + 1])
|
|
858
|
+
i += 2
|
|
859
|
+
except ValueError:
|
|
860
|
+
console.print("Invalid interval value.", style="red")
|
|
861
|
+
return
|
|
862
|
+
elif parts[i] in {"-p", "--prompt"} and i + 1 < len(parts):
|
|
863
|
+
prompt = " ".join(parts[i + 1:])
|
|
864
|
+
break
|
|
865
|
+
else:
|
|
866
|
+
i += 1
|
|
867
|
+
|
|
868
|
+
if crumb_name not in self.assistant.crumbs:
|
|
869
|
+
console.print(f"Crumb '{crumb_name}' not found.", style="red")
|
|
870
|
+
console.print(
|
|
871
|
+
f"Available crumbs: {', '.join(self.assistant.crumbs.keys())}",
|
|
872
|
+
style="yellow",
|
|
873
|
+
)
|
|
874
|
+
return
|
|
875
|
+
|
|
876
|
+
crumb = self.assistant.crumbs[crumb_name]
|
|
877
|
+
|
|
878
|
+
if not crumb.poll:
|
|
879
|
+
console.print(
|
|
880
|
+
f"Warning: Crumb '{crumb_name}' doesn't have polling enabled.",
|
|
881
|
+
style="yellow",
|
|
882
|
+
)
|
|
883
|
+
console.print("Proceeding anyway with default polling mode.", style="dim")
|
|
884
|
+
|
|
885
|
+
console.print("Starting polling session... Press Ctrl+C to stop.", style="bold cyan")
|
|
886
|
+
|
|
887
|
+
self.running_polling = True
|
|
888
|
+
try:
|
|
889
|
+
await polling_session(
|
|
890
|
+
self.assistant,
|
|
891
|
+
crumb,
|
|
892
|
+
interval=interval,
|
|
893
|
+
prompt_override=prompt,
|
|
894
|
+
)
|
|
895
|
+
finally:
|
|
896
|
+
self.running_polling = False
|
|
897
|
+
console.print("Polling stopped. Returning to interactive mode.", style="green")
|
|
898
|
+
|
|
899
|
+
async def _stop_polling(self) -> None:
|
|
900
|
+
"""Handle /stop-poll command."""
|
|
901
|
+
if not self.running_polling:
|
|
902
|
+
console.print("No polling session is currently running.", style="yellow")
|
|
903
|
+
return
|
|
904
|
+
|
|
905
|
+
# This is handled by the signal handler in polling_session
|
|
906
|
+
console.print(
|
|
907
|
+
"Stopping polling... (press Ctrl+C if it doesn't stop automatically)",
|
|
908
|
+
style="yellow",
|
|
909
|
+
)
|
|
910
|
+
|
|
715
911
|
async def _select_model(self, host: str = "") -> None:
|
|
716
912
|
"""Show available models and allow user to select one with arrow keys."""
|
|
717
913
|
if PromptSession is None or KeyBindings is None:
|
|
@@ -881,6 +1077,174 @@ async def interactive_session(
|
|
|
881
1077
|
await ui.run()
|
|
882
1078
|
|
|
883
1079
|
|
|
1080
|
+
async def polling_session(
|
|
1081
|
+
rubber_ducky: RubberDuck,
|
|
1082
|
+
crumb: Crumb,
|
|
1083
|
+
interval: int | None = None,
|
|
1084
|
+
prompt_override: str | None = None,
|
|
1085
|
+
) -> None:
|
|
1086
|
+
"""Run a polling session for a crumb.
|
|
1087
|
+
|
|
1088
|
+
For interval polling: Runs the crumb repeatedly at the specified interval.
|
|
1089
|
+
For continuous polling: Runs the crumb once in background and analyzes output periodically.
|
|
1090
|
+
|
|
1091
|
+
Args:
|
|
1092
|
+
rubber_ducky: The RubberDuck assistant
|
|
1093
|
+
crumb: The crumb to poll
|
|
1094
|
+
interval: Override the crumb's default interval
|
|
1095
|
+
prompt_override: Override the crumb's default poll prompt
|
|
1096
|
+
"""
|
|
1097
|
+
# Use overrides or crumb defaults
|
|
1098
|
+
poll_interval = interval or crumb.poll_interval
|
|
1099
|
+
poll_prompt = prompt_override or crumb.poll_prompt or "Analyze this output."
|
|
1100
|
+
poll_type = crumb.poll_type or "interval"
|
|
1101
|
+
|
|
1102
|
+
if not crumb.poll_prompt and not prompt_override:
|
|
1103
|
+
console.print("Warning: No poll prompt configured for this crumb.", style="yellow")
|
|
1104
|
+
console.print(f"Using default prompt: '{poll_prompt}'", style="dim")
|
|
1105
|
+
|
|
1106
|
+
if poll_type == "continuous":
|
|
1107
|
+
await _continuous_polling(rubber_ducky, crumb, poll_interval, poll_prompt)
|
|
1108
|
+
else:
|
|
1109
|
+
await _interval_polling(rubber_ducky, crumb, poll_interval, poll_prompt)
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
async def _interval_polling(
|
|
1113
|
+
rubber_ducky: RubberDuck,
|
|
1114
|
+
crumb: Crumb,
|
|
1115
|
+
interval: int,
|
|
1116
|
+
poll_prompt: str,
|
|
1117
|
+
) -> None:
|
|
1118
|
+
"""Poll by running crumb script at intervals and analyzing with AI."""
|
|
1119
|
+
console.print(
|
|
1120
|
+
f"\nStarting interval polling for '{crumb.name}' (interval: {interval}s)...\n"
|
|
1121
|
+
f"Poll prompt: {poll_prompt}\n"
|
|
1122
|
+
f"Press Ctrl+C to stop polling.\n",
|
|
1123
|
+
style="bold cyan",
|
|
1124
|
+
)
|
|
1125
|
+
|
|
1126
|
+
shutdown_event = asyncio.Event()
|
|
1127
|
+
|
|
1128
|
+
def signal_handler():
|
|
1129
|
+
console.print("\nStopping polling...", style="yellow")
|
|
1130
|
+
shutdown_event.set()
|
|
1131
|
+
|
|
1132
|
+
loop = asyncio.get_running_loop()
|
|
1133
|
+
loop.add_signal_handler(signal.SIGINT, signal_handler)
|
|
1134
|
+
|
|
1135
|
+
try:
|
|
1136
|
+
while not shutdown_event.is_set():
|
|
1137
|
+
timestamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")
|
|
1138
|
+
console.print(f"\n[{timestamp}] Polling {crumb.name}...", style="bold blue")
|
|
1139
|
+
|
|
1140
|
+
# Run crumb script
|
|
1141
|
+
result = await rubber_ducky.run_shell_command(str(crumb.path))
|
|
1142
|
+
|
|
1143
|
+
script_output = result.stdout if result.stdout.strip() else "(no output)"
|
|
1144
|
+
if result.stderr.strip():
|
|
1145
|
+
script_output += f"\n[stderr]\n{result.stderr}"
|
|
1146
|
+
|
|
1147
|
+
console.print(f"Script output: {len(result.stdout)} bytes\n", style="dim")
|
|
1148
|
+
|
|
1149
|
+
# Send to AI with prompt
|
|
1150
|
+
full_prompt = f"{poll_prompt}\n\nScript output:\n{script_output}"
|
|
1151
|
+
ai_result = await rubber_ducky.send_prompt(prompt=full_prompt, command_mode=False)
|
|
1152
|
+
|
|
1153
|
+
console.print(f"AI: {ai_result.content}", style="green", highlight=False)
|
|
1154
|
+
|
|
1155
|
+
# Wait for next interval
|
|
1156
|
+
await asyncio.sleep(interval)
|
|
1157
|
+
except asyncio.CancelledError:
|
|
1158
|
+
console.print("\nPolling stopped.", style="yellow")
|
|
1159
|
+
finally:
|
|
1160
|
+
loop.remove_signal_handler(signal.SIGINT)
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
async def _continuous_polling(
|
|
1164
|
+
rubber_ducky: RubberDuck,
|
|
1165
|
+
crumb: Crumb,
|
|
1166
|
+
interval: int,
|
|
1167
|
+
poll_prompt: str,
|
|
1168
|
+
) -> None:
|
|
1169
|
+
"""Poll by running crumb continuously and analyzing output periodically."""
|
|
1170
|
+
console.print(
|
|
1171
|
+
f"\nStarting continuous polling for '{crumb.name}' (analysis interval: {interval}s)...\n"
|
|
1172
|
+
f"Poll prompt: {poll_prompt}\n"
|
|
1173
|
+
f"Press Ctrl+C to stop polling.\n",
|
|
1174
|
+
style="bold cyan",
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
shutdown_event = asyncio.Event()
|
|
1178
|
+
accumulated_output: list[str] = []
|
|
1179
|
+
|
|
1180
|
+
def signal_handler():
|
|
1181
|
+
console.print("\nStopping polling...", style="yellow")
|
|
1182
|
+
shutdown_event.set()
|
|
1183
|
+
|
|
1184
|
+
loop = asyncio.get_running_loop()
|
|
1185
|
+
loop.add_signal_handler(signal.SIGINT, signal_handler)
|
|
1186
|
+
|
|
1187
|
+
# Start crumb process
|
|
1188
|
+
process = None
|
|
1189
|
+
try:
|
|
1190
|
+
process = await asyncio.create_subprocess_shell(
|
|
1191
|
+
str(crumb.path),
|
|
1192
|
+
stdout=asyncio.subprocess.PIPE,
|
|
1193
|
+
stderr=asyncio.subprocess.PIPE,
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
async def read_stream(stream, name: str):
|
|
1197
|
+
"""Read output from stream non-blocking."""
|
|
1198
|
+
while not shutdown_event.is_set():
|
|
1199
|
+
try:
|
|
1200
|
+
line = await asyncio.wait_for(stream.readline(), timeout=0.1)
|
|
1201
|
+
if not line:
|
|
1202
|
+
break
|
|
1203
|
+
line_text = line.decode(errors="replace")
|
|
1204
|
+
accumulated_output.append(line_text)
|
|
1205
|
+
except asyncio.TimeoutError:
|
|
1206
|
+
continue
|
|
1207
|
+
except Exception:
|
|
1208
|
+
break
|
|
1209
|
+
|
|
1210
|
+
# Read both stdout and stderr
|
|
1211
|
+
asyncio.create_task(read_stream(process.stdout, "stdout"))
|
|
1212
|
+
asyncio.create_task(read_stream(process.stderr, "stderr"))
|
|
1213
|
+
|
|
1214
|
+
# Main polling loop - analyze accumulated output
|
|
1215
|
+
last_analyzed_length = 0
|
|
1216
|
+
|
|
1217
|
+
while not shutdown_event.is_set():
|
|
1218
|
+
await asyncio.sleep(interval)
|
|
1219
|
+
|
|
1220
|
+
# Only analyze if there's new output
|
|
1221
|
+
current_length = len(accumulated_output)
|
|
1222
|
+
if current_length > last_analyzed_length:
|
|
1223
|
+
timestamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")
|
|
1224
|
+
console.print(f"\n[{timestamp}] Polling {crumb.name}...", style="bold blue")
|
|
1225
|
+
|
|
1226
|
+
# Get new output since last analysis
|
|
1227
|
+
new_output = "".join(accumulated_output[last_analyzed_length:])
|
|
1228
|
+
|
|
1229
|
+
console.print(f"New script output: {len(new_output)} bytes\n", style="dim")
|
|
1230
|
+
|
|
1231
|
+
# Send to AI with prompt
|
|
1232
|
+
full_prompt = f"{poll_prompt}\n\nScript output:\n{new_output}"
|
|
1233
|
+
ai_result = await rubber_ducky.send_prompt(prompt=full_prompt, command_mode=False)
|
|
1234
|
+
|
|
1235
|
+
console.print(f"AI: {ai_result.content}", style="green", highlight=False)
|
|
1236
|
+
|
|
1237
|
+
last_analyzed_length = current_length
|
|
1238
|
+
|
|
1239
|
+
except asyncio.CancelledError:
|
|
1240
|
+
console.print("\nPolling stopped.", style="yellow")
|
|
1241
|
+
finally:
|
|
1242
|
+
if process:
|
|
1243
|
+
process.kill()
|
|
1244
|
+
await process.wait()
|
|
1245
|
+
loop.remove_signal_handler(signal.SIGINT)
|
|
1246
|
+
|
|
1247
|
+
|
|
884
1248
|
async def ducky() -> None:
|
|
885
1249
|
parser = argparse.ArgumentParser()
|
|
886
1250
|
parser.add_argument(
|
|
@@ -893,6 +1257,24 @@ async def ducky() -> None:
|
|
|
893
1257
|
action="store_true",
|
|
894
1258
|
help="Run DuckY offline using a local Ollama instance on localhost:11434",
|
|
895
1259
|
)
|
|
1260
|
+
parser.add_argument(
|
|
1261
|
+
"--poll",
|
|
1262
|
+
help="Start polling mode for the specified crumb",
|
|
1263
|
+
default=None,
|
|
1264
|
+
)
|
|
1265
|
+
parser.add_argument(
|
|
1266
|
+
"--interval",
|
|
1267
|
+
"-i",
|
|
1268
|
+
type=int,
|
|
1269
|
+
help="Override crumb's polling interval in seconds",
|
|
1270
|
+
default=None,
|
|
1271
|
+
)
|
|
1272
|
+
parser.add_argument(
|
|
1273
|
+
"--prompt",
|
|
1274
|
+
"-p",
|
|
1275
|
+
help="Override crumb's polling prompt",
|
|
1276
|
+
default=None,
|
|
1277
|
+
)
|
|
896
1278
|
args, _ = parser.parse_known_args()
|
|
897
1279
|
|
|
898
1280
|
ensure_history_dir()
|
|
@@ -946,6 +1328,33 @@ async def ducky() -> None:
|
|
|
946
1328
|
console.print("No input received from stdin.", style="yellow")
|
|
947
1329
|
return
|
|
948
1330
|
|
|
1331
|
+
# Handle polling mode
|
|
1332
|
+
if args.poll:
|
|
1333
|
+
crumb_name = args.poll
|
|
1334
|
+
if crumb_name not in rubber_ducky.crumbs:
|
|
1335
|
+
console.print(f"Crumb '{crumb_name}' not found.", style="red")
|
|
1336
|
+
console.print(
|
|
1337
|
+
f"Available crumbs: {', '.join(rubber_ducky.crumbs.keys())}",
|
|
1338
|
+
style="yellow",
|
|
1339
|
+
)
|
|
1340
|
+
return
|
|
1341
|
+
|
|
1342
|
+
crumb = rubber_ducky.crumbs[crumb_name]
|
|
1343
|
+
if not crumb.poll:
|
|
1344
|
+
console.print(
|
|
1345
|
+
f"Warning: Crumb '{crumb_name}' doesn't have polling enabled.",
|
|
1346
|
+
style="yellow",
|
|
1347
|
+
)
|
|
1348
|
+
console.print("Proceeding anyway with default polling mode.", style="dim")
|
|
1349
|
+
|
|
1350
|
+
await polling_session(
|
|
1351
|
+
rubber_ducky,
|
|
1352
|
+
crumb,
|
|
1353
|
+
interval=args.interval,
|
|
1354
|
+
prompt_override=args.prompt,
|
|
1355
|
+
)
|
|
1356
|
+
return
|
|
1357
|
+
|
|
949
1358
|
await interactive_session(rubber_ducky, logger=logger, code=code)
|
|
950
1359
|
|
|
951
1360
|
|