rubber-ducky 1.5.0__py3-none-any.whl → 1.5.2__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 +253 -512
- rubber_ducky-1.5.2.dist-info/METADATA +198 -0
- rubber_ducky-1.5.2.dist-info/RECORD +13 -0
- {rubber_ducky-1.5.0.dist-info → rubber_ducky-1.5.2.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.2.dist-info}/WHEEL +0 -0
- {rubber_ducky-1.5.0.dist-info → rubber_ducky-1.5.2.dist-info}/entry_points.txt +0 -0
- {rubber_ducky-1.5.0.dist-info → rubber_ducky-1.5.2.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
|
|
@@ -210,18 +103,43 @@ class ConversationLogger:
|
|
|
210
103
|
handle.write("\n")
|
|
211
104
|
|
|
212
105
|
|
|
213
|
-
def print_shell_result(result: ShellResult) -> None:
|
|
214
|
-
|
|
106
|
+
def print_shell_result(result: ShellResult, truncate: bool = True) -> None:
|
|
107
|
+
"""Print shell command output with optional truncation.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
result: The ShellResult containing command output
|
|
111
|
+
truncate: If True and output is long (>10 lines), show truncated version
|
|
112
|
+
"""
|
|
113
|
+
# Determine if we should truncate
|
|
114
|
+
stdout_lines = result.stdout.rstrip().split('\n') if result.stdout else []
|
|
115
|
+
stderr_lines = result.stderr.rstrip().split('\n') if result.stderr else []
|
|
116
|
+
total_lines = len(stdout_lines) + len(stderr_lines)
|
|
117
|
+
|
|
118
|
+
should_truncate = truncate and total_lines > 10
|
|
119
|
+
|
|
215
120
|
if result.stdout.strip():
|
|
216
|
-
|
|
217
|
-
|
|
121
|
+
if should_truncate:
|
|
122
|
+
# Show first 8 lines of stdout
|
|
123
|
+
show_lines = stdout_lines[:8]
|
|
124
|
+
console.print('\n'.join(show_lines), highlight=False)
|
|
125
|
+
console.print(f"... ({len(stdout_lines) - 8} more lines, use /expand to see full output)", style="dim cyan")
|
|
126
|
+
else:
|
|
127
|
+
console.print(result.stdout.rstrip(), highlight=False)
|
|
128
|
+
|
|
218
129
|
if result.stderr.strip():
|
|
219
|
-
if
|
|
130
|
+
if result.stdout.strip():
|
|
220
131
|
console.print()
|
|
221
132
|
console.print("[stderr]", style="bold red")
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
133
|
+
if should_truncate:
|
|
134
|
+
# Show first 5 lines of stderr
|
|
135
|
+
show_lines = stderr_lines[:5]
|
|
136
|
+
console.print('\n'.join(show_lines), style="red", highlight=False)
|
|
137
|
+
if len(stderr_lines) > 5:
|
|
138
|
+
console.print(f"... ({len(stderr_lines) - 5} more lines)", style="dim red")
|
|
139
|
+
else:
|
|
140
|
+
console.print(result.stderr.rstrip(), style="red", highlight=False)
|
|
141
|
+
|
|
142
|
+
if result.returncode != 0 or (not result.stdout.strip() and not result.stderr.strip()):
|
|
225
143
|
suffix = (
|
|
226
144
|
f"(exit status {result.returncode})"
|
|
227
145
|
if result.returncode != 0
|
|
@@ -235,10 +153,11 @@ async def run_shell_and_print(
|
|
|
235
153
|
command: str,
|
|
236
154
|
logger: ConversationLogger | None = None,
|
|
237
155
|
history: list[dict[str, str]] | None = None,
|
|
238
|
-
) ->
|
|
156
|
+
) -> ShellResult:
|
|
157
|
+
"""Run a shell command and print output. Returns the ShellResult."""
|
|
239
158
|
if not command:
|
|
240
159
|
console.print("No command provided.", style="yellow")
|
|
241
|
-
return
|
|
160
|
+
return ShellResult(command="", stdout="", stderr="", returncode=-1)
|
|
242
161
|
console.print(f"$ {command}", style="bold magenta")
|
|
243
162
|
result = await assistant.run_shell_command(command)
|
|
244
163
|
print_shell_result(result)
|
|
@@ -256,6 +175,7 @@ async def run_shell_and_print(
|
|
|
256
175
|
if not combined_output:
|
|
257
176
|
combined_output.append("(command produced no output)")
|
|
258
177
|
history.append({"role": "assistant", "content": "\n\n".join(combined_output)})
|
|
178
|
+
return result
|
|
259
179
|
|
|
260
180
|
|
|
261
181
|
class RubberDuck:
|
|
@@ -293,52 +213,16 @@ class RubberDuck:
|
|
|
293
213
|
self.model = model
|
|
294
214
|
self.quick = quick
|
|
295
215
|
self.command_mode = command_mode
|
|
296
|
-
self.
|
|
216
|
+
self.last_result: AssistantResult | None = None
|
|
297
217
|
self.messages: List[Dict[str, str]] = [
|
|
298
218
|
{"role": "system", "content": self.system_prompt}
|
|
299
219
|
]
|
|
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
220
|
|
|
335
221
|
async def send_prompt(
|
|
336
222
|
self, prompt: str | None = None, code: str | None = None, command_mode: bool | None = None
|
|
337
223
|
) -> AssistantResult:
|
|
338
224
|
user_content = (prompt or "").strip()
|
|
339
225
|
|
|
340
|
-
self.update_system_prompt()
|
|
341
|
-
|
|
342
226
|
if code:
|
|
343
227
|
user_content = f"{user_content}\n\n{code}" if user_content else code
|
|
344
228
|
|
|
@@ -381,7 +265,9 @@ class RubberDuck:
|
|
|
381
265
|
|
|
382
266
|
command = self._extract_command(content) if effective_command_mode else None
|
|
383
267
|
|
|
384
|
-
|
|
268
|
+
result = AssistantResult(content=content, command=command, thinking=thinking)
|
|
269
|
+
self.last_result = result
|
|
270
|
+
return result
|
|
385
271
|
|
|
386
272
|
async def run_shell_command(self, command: str) -> ShellResult:
|
|
387
273
|
process = await asyncio.create_subprocess_shell(
|
|
@@ -403,8 +289,8 @@ class RubberDuck:
|
|
|
403
289
|
return None
|
|
404
290
|
|
|
405
291
|
command_lines: List[str] = []
|
|
406
|
-
|
|
407
292
|
in_block = False
|
|
293
|
+
|
|
408
294
|
for line in lines:
|
|
409
295
|
stripped = line.strip()
|
|
410
296
|
if stripped.startswith("```"):
|
|
@@ -414,20 +300,18 @@ class RubberDuck:
|
|
|
414
300
|
continue
|
|
415
301
|
if in_block:
|
|
416
302
|
if stripped:
|
|
417
|
-
command_lines
|
|
418
|
-
break
|
|
303
|
+
command_lines.append(stripped)
|
|
419
304
|
continue
|
|
420
305
|
if stripped:
|
|
421
|
-
command_lines
|
|
306
|
+
command_lines.append(stripped)
|
|
307
|
+
# If not in a block, only take the first line
|
|
422
308
|
break
|
|
423
309
|
|
|
424
310
|
if not command_lines:
|
|
425
311
|
return None
|
|
426
312
|
|
|
427
|
-
command
|
|
428
|
-
|
|
429
|
-
if first_semicolon != -1:
|
|
430
|
-
command = command[:first_semicolon].strip()
|
|
313
|
+
# Join all command lines with newlines for multi-line commands
|
|
314
|
+
command = "\n".join(command_lines)
|
|
431
315
|
|
|
432
316
|
return command or None
|
|
433
317
|
|
|
@@ -494,10 +378,11 @@ class InlineInterface:
|
|
|
494
378
|
self.code = code
|
|
495
379
|
self._code_sent = False
|
|
496
380
|
self.last_shell_output: str | None = None
|
|
381
|
+
self.last_shell_result: ShellResult | None = None
|
|
497
382
|
self.pending_command: str | None = None
|
|
498
383
|
self.session: PromptSession | None = None
|
|
499
384
|
self.selected_model: str | None = None
|
|
500
|
-
self.
|
|
385
|
+
self.crumb_manager = CrumbManager()
|
|
501
386
|
|
|
502
387
|
if (
|
|
503
388
|
PromptSession is not None
|
|
@@ -618,12 +503,7 @@ class InlineInterface:
|
|
|
618
503
|
if not self.last_command:
|
|
619
504
|
console.print("No suggested command available yet.", style="yellow")
|
|
620
505
|
return
|
|
621
|
-
await
|
|
622
|
-
self.assistant,
|
|
623
|
-
self.last_command,
|
|
624
|
-
logger=self.logger,
|
|
625
|
-
history=self.assistant.messages,
|
|
626
|
-
)
|
|
506
|
+
await self._run_shell_command(self.last_command)
|
|
627
507
|
# Add the command to prompt history so user can recall it with up arrow
|
|
628
508
|
if self.session and self.session.history and self.last_command:
|
|
629
509
|
self.session.history.append_string(self.last_command)
|
|
@@ -631,6 +511,29 @@ class InlineInterface:
|
|
|
631
511
|
self.pending_command = None
|
|
632
512
|
self.last_command = None
|
|
633
513
|
|
|
514
|
+
async def _run_shell_command(self, command: str) -> None:
|
|
515
|
+
"""Run a shell command, print output (with truncation), and store result."""
|
|
516
|
+
result = await run_shell_and_print(
|
|
517
|
+
self.assistant,
|
|
518
|
+
command,
|
|
519
|
+
logger=self.logger,
|
|
520
|
+
history=self.assistant.messages,
|
|
521
|
+
)
|
|
522
|
+
# Store the result for expansion later
|
|
523
|
+
self.last_shell_result = result
|
|
524
|
+
|
|
525
|
+
async def _expand_last_output(self) -> None:
|
|
526
|
+
"""Expand and show the full output of the last shell command."""
|
|
527
|
+
if not self.last_shell_result:
|
|
528
|
+
console.print("No previous shell output to expand.", style="yellow")
|
|
529
|
+
return
|
|
530
|
+
|
|
531
|
+
console.print()
|
|
532
|
+
console.print(f"[Full output for: {self.last_shell_result.command}]", style="bold cyan")
|
|
533
|
+
console.print()
|
|
534
|
+
print_shell_result(self.last_shell_result, truncate=False)
|
|
535
|
+
console.print()
|
|
536
|
+
|
|
634
537
|
async def _process_text(self, text: str) -> None:
|
|
635
538
|
stripped = text.strip()
|
|
636
539
|
if not stripped:
|
|
@@ -643,6 +546,12 @@ class InlineInterface:
|
|
|
643
546
|
console.print("Nothing to run yet.", style="yellow")
|
|
644
547
|
return
|
|
645
548
|
|
|
549
|
+
# Check if first word is a crumb name
|
|
550
|
+
first_word = stripped.split()[0].lower()
|
|
551
|
+
if self.crumb_manager.has_crumb(first_word):
|
|
552
|
+
await self._use_crumb(first_word)
|
|
553
|
+
return
|
|
554
|
+
|
|
646
555
|
if stripped.lower() in {":run", "/run"}:
|
|
647
556
|
await self._run_last_command()
|
|
648
557
|
return
|
|
@@ -671,22 +580,17 @@ class InlineInterface:
|
|
|
671
580
|
await self._show_crumbs()
|
|
672
581
|
return
|
|
673
582
|
|
|
674
|
-
if stripped.
|
|
675
|
-
await self.
|
|
583
|
+
if stripped.startswith("/crumb"):
|
|
584
|
+
await self._handle_crumb_command(stripped)
|
|
676
585
|
return
|
|
677
586
|
|
|
678
|
-
if stripped.
|
|
679
|
-
await self.
|
|
587
|
+
if stripped.lower() == "/expand":
|
|
588
|
+
await self._expand_last_output()
|
|
680
589
|
return
|
|
681
590
|
|
|
682
591
|
if stripped.startswith("!"):
|
|
683
592
|
command = stripped[1:].strip()
|
|
684
|
-
await
|
|
685
|
-
self.assistant,
|
|
686
|
-
command,
|
|
687
|
-
logger=self.logger,
|
|
688
|
-
history=self.assistant.messages,
|
|
689
|
-
)
|
|
593
|
+
await self._run_shell_command(command)
|
|
690
594
|
# Add the command to prompt history so user can recall it with up arrow
|
|
691
595
|
if self.session and self.session.history and command:
|
|
692
596
|
self.session.history.append_string(command)
|
|
@@ -731,7 +635,12 @@ class InlineInterface:
|
|
|
731
635
|
|
|
732
636
|
commands = [
|
|
733
637
|
("[bold]/help[/bold]", "Show this help message"),
|
|
734
|
-
("[bold]/crumbs[/bold]", "List all
|
|
638
|
+
("[bold]/crumbs[/bold]", "List all saved crumb shortcuts"),
|
|
639
|
+
("[bold]/crumb <name>[/bold]", "Save last result as a crumb"),
|
|
640
|
+
("[bold]/crumb add <name> <cmd>[/bold]", "Manually add a crumb"),
|
|
641
|
+
("[bold]/crumb del <name>[/bold]", "Delete a crumb"),
|
|
642
|
+
("[bold]<name>[/bold]", "Invoke a saved crumb"),
|
|
643
|
+
("[bold]/expand[/bold]", "Show full output of last shell command"),
|
|
735
644
|
("[bold]/model[/bold]", "Select a model interactively (local or cloud)"),
|
|
736
645
|
(
|
|
737
646
|
"[bold]/local[/bold]",
|
|
@@ -742,22 +651,6 @@ class InlineInterface:
|
|
|
742
651
|
"[bold]/clear[/bold] or [bold]/reset[/bold]",
|
|
743
652
|
"Clear conversation history",
|
|
744
653
|
),
|
|
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
654
|
(
|
|
762
655
|
"[bold]/run[/bold]",
|
|
763
656
|
"Re-run the last suggested command",
|
|
@@ -778,49 +671,31 @@ class InlineInterface:
|
|
|
778
671
|
console.print()
|
|
779
672
|
|
|
780
673
|
async def _show_crumbs(self) -> None:
|
|
781
|
-
"""Display all
|
|
782
|
-
crumbs = self.
|
|
674
|
+
"""Display all saved crumbs."""
|
|
675
|
+
crumbs = self.crumb_manager.list_crumbs()
|
|
783
676
|
|
|
784
677
|
if not crumbs:
|
|
785
|
-
console.print("No crumbs
|
|
678
|
+
console.print("No crumbs saved yet. Use '/crumb <name>' to save a command.", style="yellow")
|
|
786
679
|
return
|
|
787
680
|
|
|
788
|
-
console.print("\
|
|
789
|
-
console.print("
|
|
681
|
+
console.print("\nSaved Crumbs", style="bold blue")
|
|
682
|
+
console.print("=============", style="bold blue")
|
|
790
683
|
console.print()
|
|
791
684
|
|
|
792
|
-
#
|
|
793
|
-
|
|
794
|
-
user_crumbs = []
|
|
685
|
+
# Calculate max name length for alignment
|
|
686
|
+
max_name_len = max(len(name) for name in crumbs.keys())
|
|
795
687
|
|
|
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()
|
|
688
|
+
for name, data in sorted(crumbs.items()):
|
|
689
|
+
explanation = data.get("explanation", "") or "No explanation yet"
|
|
690
|
+
command = data.get("command", "") or "No command"
|
|
691
|
+
created_at = data.get("created_at", "")
|
|
812
692
|
|
|
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()
|
|
693
|
+
# Format: name | explanation | command
|
|
694
|
+
console.print(
|
|
695
|
+
f"[bold]{name:<{max_name_len}}[/bold] | [cyan]{explanation}[/cyan] | [dim]{command}[/dim]"
|
|
696
|
+
)
|
|
822
697
|
|
|
823
|
-
console.print(f"[dim]Total: {len(crumbs)} crumbs
|
|
698
|
+
console.print(f"\n[dim]Total: {len(crumbs)} crumbs[/dim]")
|
|
824
699
|
|
|
825
700
|
async def _clear_history(self) -> None:
|
|
826
701
|
self.assistant.clear_history()
|
|
@@ -828,85 +703,141 @@ class InlineInterface:
|
|
|
828
703
|
self.pending_command = None
|
|
829
704
|
self.last_shell_output = None
|
|
830
705
|
|
|
831
|
-
async def
|
|
832
|
-
"""Handle /
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
)
|
|
706
|
+
async def _handle_crumb_command(self, command: str) -> None:
|
|
707
|
+
"""Handle /crumb commands."""
|
|
708
|
+
parts = command.split()
|
|
709
|
+
if len(parts) == 1:
|
|
710
|
+
# Just "/crumbs" - show list
|
|
711
|
+
await self._show_crumbs()
|
|
838
712
|
return
|
|
839
713
|
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
console.print("Example: /poll log-crumb -i 5", style="dim")
|
|
714
|
+
if len(parts) == 2:
|
|
715
|
+
# "/crumb <name>" - save last result
|
|
716
|
+
name = parts[1]
|
|
717
|
+
await self._save_crumb(name)
|
|
845
718
|
return
|
|
846
719
|
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
720
|
+
if len(parts) >= 3 and parts[1] == "add":
|
|
721
|
+
# "/crumb add <name> <...command>"
|
|
722
|
+
if len(parts) < 4:
|
|
723
|
+
console.print("Usage: /crumb add <name> <command>", style="yellow")
|
|
724
|
+
return
|
|
725
|
+
name = parts[2]
|
|
726
|
+
cmd = " ".join(parts[3:])
|
|
727
|
+
await self._add_crumb_manual(name, cmd)
|
|
728
|
+
return
|
|
850
729
|
|
|
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
|
|
730
|
+
if len(parts) == 3 and parts[1] == "del":
|
|
731
|
+
# "/crumb del <name>"
|
|
732
|
+
name = parts[2]
|
|
733
|
+
await self._delete_crumb(name)
|
|
734
|
+
return
|
|
866
735
|
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
736
|
+
console.print(
|
|
737
|
+
"Usage: /crumb <name> | /crumb add <name> <cmd> | /crumb del <name>",
|
|
738
|
+
style="yellow",
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
async def _save_crumb(self, name: str) -> None:
|
|
742
|
+
"""Save the last result as a crumb."""
|
|
743
|
+
if not self.assistant.last_result:
|
|
744
|
+
console.print("No previous command to save. Run a command first.", style="yellow")
|
|
873
745
|
return
|
|
874
746
|
|
|
875
|
-
|
|
747
|
+
if not self.assistant.last_result.command:
|
|
748
|
+
console.print("Last response had no command to save.", style="yellow")
|
|
749
|
+
return
|
|
876
750
|
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
751
|
+
# Find the last user prompt from messages
|
|
752
|
+
last_prompt = ""
|
|
753
|
+
for msg in reversed(self.assistant.messages):
|
|
754
|
+
if msg["role"] == "user":
|
|
755
|
+
last_prompt = msg["content"]
|
|
756
|
+
break
|
|
883
757
|
|
|
884
|
-
|
|
758
|
+
self.crumb_manager.save_crumb(
|
|
759
|
+
name=name,
|
|
760
|
+
prompt=last_prompt,
|
|
761
|
+
response=self.assistant.last_result.content,
|
|
762
|
+
command=self.assistant.last_result.command,
|
|
763
|
+
)
|
|
885
764
|
|
|
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")
|
|
765
|
+
console.print(f"Saved crumb '{name}'!", style="green")
|
|
766
|
+
console.print("Generating explanation...", style="dim")
|
|
767
|
+
|
|
768
|
+
# Spawn subprocess to generate explanation asynchronously
|
|
769
|
+
asyncio.create_task(self._generate_crumb_explanation(name))
|
|
897
770
|
|
|
898
|
-
async def
|
|
899
|
-
"""
|
|
900
|
-
|
|
901
|
-
|
|
771
|
+
async def _generate_crumb_explanation(self, name: str) -> None:
|
|
772
|
+
"""Generate AI explanation for a crumb."""
|
|
773
|
+
crumb = self.crumb_manager.get_crumb(name)
|
|
774
|
+
if not crumb:
|
|
902
775
|
return
|
|
903
776
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
777
|
+
command = crumb.get("command", "")
|
|
778
|
+
if not command:
|
|
779
|
+
return
|
|
780
|
+
|
|
781
|
+
try:
|
|
782
|
+
explanation_prompt = f"Summarize this command in one line (10-15 words max): {command}"
|
|
783
|
+
result = await self.assistant.send_prompt(prompt=explanation_prompt, command_mode=False)
|
|
784
|
+
explanation = result.content.strip()
|
|
785
|
+
|
|
786
|
+
if explanation:
|
|
787
|
+
self.crumb_manager.update_explanation(name, explanation)
|
|
788
|
+
from rich.text import Text
|
|
789
|
+
|
|
790
|
+
# Strip ANSI codes from explanation
|
|
791
|
+
clean_explanation = re.sub(r'\x1b\[([0-9;]*[mGK])', '', explanation)
|
|
792
|
+
|
|
793
|
+
text = Text()
|
|
794
|
+
text.append("Explanation added: ", style="cyan")
|
|
795
|
+
text.append(clean_explanation)
|
|
796
|
+
console.print(text)
|
|
797
|
+
except Exception as e:
|
|
798
|
+
console.print(f"Could not generate explanation: {e}", style="yellow")
|
|
799
|
+
|
|
800
|
+
async def _add_crumb_manual(self, name: str, command: str) -> None:
|
|
801
|
+
"""Manually add a crumb with a command."""
|
|
802
|
+
self.crumb_manager.save_crumb(
|
|
803
|
+
name=name,
|
|
804
|
+
prompt="Manual addition",
|
|
805
|
+
response="",
|
|
806
|
+
command=command,
|
|
908
807
|
)
|
|
909
808
|
|
|
809
|
+
console.print(f"Added crumb '{name}'!", style="green")
|
|
810
|
+
console.print("Generating explanation...", style="dim")
|
|
811
|
+
|
|
812
|
+
# Spawn subprocess to generate explanation asynchronously
|
|
813
|
+
asyncio.create_task(self._generate_crumb_explanation(name))
|
|
814
|
+
|
|
815
|
+
async def _delete_crumb(self, name: str) -> None:
|
|
816
|
+
"""Delete a crumb."""
|
|
817
|
+
if self.crumb_manager.delete_crumb(name):
|
|
818
|
+
console.print(f"Deleted crumb '{name}'.", style="green")
|
|
819
|
+
else:
|
|
820
|
+
console.print(f"Crumb '{name}' not found.", style="yellow")
|
|
821
|
+
|
|
822
|
+
async def _use_crumb(self, name: str) -> None:
|
|
823
|
+
"""Recall and execute a saved crumb."""
|
|
824
|
+
crumb = self.crumb_manager.get_crumb(name)
|
|
825
|
+
if not crumb:
|
|
826
|
+
console.print(f"Crumb '{name}' not found.", style="yellow")
|
|
827
|
+
return
|
|
828
|
+
|
|
829
|
+
explanation = crumb.get("explanation", "") or "No explanation"
|
|
830
|
+
command = crumb.get("command", "") or "No command"
|
|
831
|
+
|
|
832
|
+
console.print(f"\n[bold cyan]Crumb: {name}[/bold cyan]")
|
|
833
|
+
console.print(f"Explanation: {explanation}", style="green")
|
|
834
|
+
console.print(f"Command: ", style="cyan", end="")
|
|
835
|
+
console.print(command, highlight=False)
|
|
836
|
+
|
|
837
|
+
if command and command != "No command":
|
|
838
|
+
# Execute the command
|
|
839
|
+
await self._run_shell_command(command)
|
|
840
|
+
|
|
910
841
|
async def _select_model(self, host: str = "") -> None:
|
|
911
842
|
"""Show available models and allow user to select one with arrow keys."""
|
|
912
843
|
if PromptSession is None or KeyBindings is None:
|
|
@@ -1086,174 +1017,6 @@ async def interactive_session(
|
|
|
1086
1017
|
await ui.run()
|
|
1087
1018
|
|
|
1088
1019
|
|
|
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
1020
|
async def ducky() -> None:
|
|
1258
1021
|
parser = argparse.ArgumentParser()
|
|
1259
1022
|
parser.add_argument(
|
|
@@ -1266,24 +1029,6 @@ async def ducky() -> None:
|
|
|
1266
1029
|
action="store_true",
|
|
1267
1030
|
help="Run DuckY offline using a local Ollama instance on localhost:11434",
|
|
1268
1031
|
)
|
|
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
1032
|
parser.add_argument(
|
|
1288
1033
|
"single_prompt",
|
|
1289
1034
|
nargs="?",
|
|
@@ -1301,9 +1046,9 @@ async def ducky() -> None:
|
|
|
1301
1046
|
|
|
1302
1047
|
# If --local flag is used, override with local settings
|
|
1303
1048
|
if getattr(args, "local", False):
|
|
1304
|
-
# Point Ollama client to local host and use
|
|
1049
|
+
# Point Ollama client to local host and use qwen3 as default model
|
|
1305
1050
|
os.environ["OLLAMA_HOST"] = "http://localhost:11434"
|
|
1306
|
-
args.model = args.model or "
|
|
1051
|
+
args.model = args.model or "qwen3"
|
|
1307
1052
|
last_host = "http://localhost:11434"
|
|
1308
1053
|
# If no model is specified, use the last used model
|
|
1309
1054
|
elif args.model is None:
|
|
@@ -1343,6 +1088,29 @@ async def ducky() -> None:
|
|
|
1343
1088
|
console.print("No input received from stdin.", style="yellow")
|
|
1344
1089
|
return
|
|
1345
1090
|
|
|
1091
|
+
# Handle crumb invocation mode
|
|
1092
|
+
crumb_manager = CrumbManager()
|
|
1093
|
+
if args.single_prompt and crumb_manager.has_crumb(args.single_prompt):
|
|
1094
|
+
crumb = crumb_manager.get_crumb(args.single_prompt)
|
|
1095
|
+
if crumb:
|
|
1096
|
+
explanation = crumb.get("explanation", "") or "No explanation"
|
|
1097
|
+
command = crumb.get("command", "") or "No command"
|
|
1098
|
+
|
|
1099
|
+
console.print(f"\n[bold cyan]Crumb: {args.single_prompt}[/bold cyan]")
|
|
1100
|
+
console.print(f"Explanation: {explanation}", style="green")
|
|
1101
|
+
console.print(f"Command: ", style="cyan", end="")
|
|
1102
|
+
console.print(command, highlight=False)
|
|
1103
|
+
|
|
1104
|
+
if command and command != "No command":
|
|
1105
|
+
# Execute the command
|
|
1106
|
+
await run_shell_and_print(
|
|
1107
|
+
rubber_ducky,
|
|
1108
|
+
command,
|
|
1109
|
+
logger=logger,
|
|
1110
|
+
history=rubber_ducky.messages,
|
|
1111
|
+
)
|
|
1112
|
+
return
|
|
1113
|
+
|
|
1346
1114
|
# Handle single prompt mode
|
|
1347
1115
|
if args.single_prompt:
|
|
1348
1116
|
result = await run_single_prompt(
|
|
@@ -1353,33 +1121,6 @@ async def ducky() -> None:
|
|
|
1353
1121
|
console.print("\n[green]✓[/green] Command copied to clipboard")
|
|
1354
1122
|
return
|
|
1355
1123
|
|
|
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
1124
|
await interactive_session(rubber_ducky, logger=logger, code=code)
|
|
1384
1125
|
|
|
1385
1126
|
|