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/crumb.py +84 -0
- ducky/ducky.py +188 -492
- rubber_ducky-1.5.1.dist-info/METADATA +198 -0
- rubber_ducky-1.5.1.dist-info/RECORD +13 -0
- {rubber_ducky-1.5.0.dist-info → rubber_ducky-1.5.1.dist-info}/top_level.txt +0 -1
- crumbs/disk-usage/disk-usage.sh +0 -12
- crumbs/disk-usage/info.txt +0 -3
- crumbs/git-log/git-log.sh +0 -24
- crumbs/git-log/info.txt +0 -3
- crumbs/git-status/git-status.sh +0 -21
- crumbs/git-status/info.txt +0 -3
- crumbs/process-list/info.txt +0 -3
- crumbs/process-list/process-list.sh +0 -20
- crumbs/recent-files/info.txt +0 -3
- crumbs/recent-files/recent-files.sh +0 -13
- crumbs/system-health/info.txt +0 -3
- crumbs/system-health/system-health.sh +0 -58
- rubber_ducky-1.5.0.dist-info/METADATA +0 -210
- rubber_ducky-1.5.0.dist-info/RECORD +0 -24
- {rubber_ducky-1.5.0.dist-info → rubber_ducky-1.5.1.dist-info}/WHEEL +0 -0
- {rubber_ducky-1.5.0.dist-info → rubber_ducky-1.5.1.dist-info}/entry_points.txt +0 -0
- {rubber_ducky-1.5.0.dist-info → rubber_ducky-1.5.1.dist-info}/licenses/LICENSE +0 -0
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
418
|
-
break
|
|
276
|
+
command_lines.append(stripped)
|
|
419
277
|
continue
|
|
420
278
|
if stripped:
|
|
421
|
-
command_lines
|
|
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
|
|
428
|
-
|
|
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.
|
|
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.
|
|
675
|
-
await self.
|
|
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
|
|
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
|
|
782
|
-
crumbs = self.
|
|
628
|
+
"""Display all saved crumbs."""
|
|
629
|
+
crumbs = self.crumb_manager.list_crumbs()
|
|
783
630
|
|
|
784
631
|
if not crumbs:
|
|
785
|
-
console.print("No crumbs
|
|
632
|
+
console.print("No crumbs saved yet. Use '/crumb <name>' to save a command.", style="yellow")
|
|
786
633
|
return
|
|
787
634
|
|
|
788
|
-
console.print("\
|
|
789
|
-
console.print("
|
|
635
|
+
console.print("\nSaved Crumbs", style="bold blue")
|
|
636
|
+
console.print("=============", style="bold blue")
|
|
790
637
|
console.print()
|
|
791
638
|
|
|
792
|
-
#
|
|
793
|
-
|
|
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,
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
|
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
|
|
832
|
-
"""Handle /
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
-
|
|
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
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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
|
-
|
|
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
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
|
899
|
-
"""
|
|
900
|
-
|
|
901
|
-
|
|
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
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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
|
|
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 "
|
|
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
|
|