pykaxe 0.1.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.
- pykaxe/__init__.py +3 -0
- pykaxe/__main__.py +4 -0
- pykaxe/app.py +615 -0
- pykaxe/assets/PROMPT.md +72 -0
- pykaxe/assets/SKILL.md +58 -0
- pykaxe/assets/__init__.py +0 -0
- pykaxe/cli.py +142 -0
- pykaxe/config.py +79 -0
- pykaxe/examples/character-count.py +35 -0
- pykaxe/examples/sci-fi-quote-loop.py +57 -0
- pykaxe/examples/simple-calculator.py +41 -0
- pykaxe/examples/word-count.py +35 -0
- pykaxe-0.1.1.dist-info/METADATA +185 -0
- pykaxe-0.1.1.dist-info/RECORD +17 -0
- pykaxe-0.1.1.dist-info/WHEEL +4 -0
- pykaxe-0.1.1.dist-info/entry_points.txt +2 -0
- pykaxe-0.1.1.dist-info/licenses/LICENSE +21 -0
pykaxe/__init__.py
ADDED
pykaxe/__main__.py
ADDED
pykaxe/app.py
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import contextlib
|
|
4
|
+
import importlib.util
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from collections import deque
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import ModuleType
|
|
12
|
+
|
|
13
|
+
from pykaxe import config
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import resource
|
|
17
|
+
except ImportError: # Windows has no resource module
|
|
18
|
+
resource = None
|
|
19
|
+
|
|
20
|
+
from textual import events
|
|
21
|
+
from textual.app import App, ComposeResult
|
|
22
|
+
from textual.binding import Binding
|
|
23
|
+
from textual.containers import Horizontal, Vertical
|
|
24
|
+
from textual.widgets import Button, Input, OptionList, RichLog, Static
|
|
25
|
+
from textual.widgets.option_list import Option
|
|
26
|
+
from rich.markup import escape as escape_markup
|
|
27
|
+
from rich.text import Text
|
|
28
|
+
|
|
29
|
+
WHITE = "#f2f2f2"
|
|
30
|
+
MUTED = "#8a8a8a"
|
|
31
|
+
BORDER = "#3a3a3a"
|
|
32
|
+
BG = "ansi_default"
|
|
33
|
+
RESULT = "#7ee787"
|
|
34
|
+
TOOL = "#f2c94c"
|
|
35
|
+
|
|
36
|
+
# Protection limits for running tools. Tool scripts are user-authored and
|
|
37
|
+
# untrusted, so every guard here lives on the app side rather than relying
|
|
38
|
+
# on the script to behave.
|
|
39
|
+
MAX_OUTPUT_LINES = 2000 # scrollback kept per shell (and in the RichLog widget)
|
|
40
|
+
MAX_OUTPUT_BYTES = 4 * 1024 * 1024 # auto-kill a tool that floods stdout/stderr
|
|
41
|
+
MAX_TOOL_MEMORY_BYTES = 256 * 1024 * 1024 # RLIMIT_AS ceiling for a tool process
|
|
42
|
+
MAX_TOOL_CPU_SECONDS = 120 # RLIMIT_CPU ceiling; catches tight busy-loops fast
|
|
43
|
+
MAX_TOOL_RUNTIME_SECONDS = 30 * 60 # wall-clock safety net for polling loops
|
|
44
|
+
|
|
45
|
+
POSIX = sys.platform != "win32"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def footer_label(key: str, label: str) -> str:
|
|
49
|
+
return f"[{WHITE}]{escape_markup(f'[{key}]')}[/] [{MUTED}]{label}[/]"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def fuzzy_filter(query: str, names: list[str]) -> list[str]:
|
|
53
|
+
query = query.lower()
|
|
54
|
+
if not query:
|
|
55
|
+
return sorted(names)
|
|
56
|
+
scored = []
|
|
57
|
+
for name in names:
|
|
58
|
+
low = name.lower()
|
|
59
|
+
pos = -1
|
|
60
|
+
first = None
|
|
61
|
+
ok = True
|
|
62
|
+
for ch in query:
|
|
63
|
+
pos = low.find(ch, pos + 1)
|
|
64
|
+
if pos == -1:
|
|
65
|
+
ok = False
|
|
66
|
+
break
|
|
67
|
+
if first is None:
|
|
68
|
+
first = pos
|
|
69
|
+
if ok:
|
|
70
|
+
scored.append((first, len(name), name))
|
|
71
|
+
scored.sort()
|
|
72
|
+
return [name for _, _, name in scored]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def discover_tools(tools_dir: Path) -> dict[str, ModuleType]:
|
|
76
|
+
tools: dict[str, ModuleType] = {}
|
|
77
|
+
if not tools_dir.is_dir():
|
|
78
|
+
return tools
|
|
79
|
+
for script in tools_dir.glob("*.py"):
|
|
80
|
+
spec = importlib.util.spec_from_file_location(script.stem, script)
|
|
81
|
+
if spec is None or spec.loader is None:
|
|
82
|
+
continue
|
|
83
|
+
module = importlib.util.module_from_spec(spec)
|
|
84
|
+
try:
|
|
85
|
+
spec.loader.exec_module(module)
|
|
86
|
+
except Exception:
|
|
87
|
+
continue
|
|
88
|
+
name = getattr(module, "TOOL_NAME", None)
|
|
89
|
+
if name:
|
|
90
|
+
tools[name] = module
|
|
91
|
+
return tools
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _limit_tool_resources() -> None:
|
|
95
|
+
"""Runs in the child process right before exec (POSIX only). Best
|
|
96
|
+
effort: caps are silently skipped where the platform won't allow them,
|
|
97
|
+
since this is a safety net, not the primary defense (that's the app
|
|
98
|
+
being able to kill the process outright at any time)."""
|
|
99
|
+
if resource is None:
|
|
100
|
+
return
|
|
101
|
+
try:
|
|
102
|
+
resource.setrlimit(resource.RLIMIT_AS, (MAX_TOOL_MEMORY_BYTES, MAX_TOOL_MEMORY_BYTES))
|
|
103
|
+
except (ValueError, OSError):
|
|
104
|
+
pass
|
|
105
|
+
try:
|
|
106
|
+
resource.setrlimit(resource.RLIMIT_CPU, (MAX_TOOL_CPU_SECONDS, MAX_TOOL_CPU_SECONDS))
|
|
107
|
+
except (ValueError, OSError):
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Shell:
|
|
112
|
+
def __init__(self) -> None:
|
|
113
|
+
self.lines: deque[str] = deque(maxlen=MAX_OUTPUT_LINES)
|
|
114
|
+
self.tool: str | None = None
|
|
115
|
+
self.script: Path | None = None
|
|
116
|
+
self.pending_args: list = []
|
|
117
|
+
self.arg_index: int = 0
|
|
118
|
+
self.values: dict[str, str] = {}
|
|
119
|
+
self.process: asyncio.subprocess.Process | None = None
|
|
120
|
+
self.runner_task: asyncio.Task | None = None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class StatusBar(Horizontal):
|
|
124
|
+
def compose(self) -> ComposeResult:
|
|
125
|
+
yield Button(footer_label("esc", "interrupt"), id="interrupt", classes="footer-btn")
|
|
126
|
+
yield Button(footer_label("ctrl+y", "copy"), id="copy_output", classes="footer-btn")
|
|
127
|
+
yield Button(footer_label("ctrl+s", "scan"), id="scan_tools", classes="footer-btn")
|
|
128
|
+
yield Button(footer_label("ctrl+c", "quit"), id="quit", classes="footer-btn")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Pykaxe(App):
|
|
132
|
+
CSS = f"""
|
|
133
|
+
Screen {{
|
|
134
|
+
background: {BG};
|
|
135
|
+
}}
|
|
136
|
+
#badge {{
|
|
137
|
+
display: none;
|
|
138
|
+
height: 1;
|
|
139
|
+
background: {TOOL};
|
|
140
|
+
color: #1a1a1a;
|
|
141
|
+
content-align: left middle;
|
|
142
|
+
padding: 0 1;
|
|
143
|
+
text-style: bold;
|
|
144
|
+
}}
|
|
145
|
+
RichLog {{
|
|
146
|
+
background: {BG};
|
|
147
|
+
border: round {BORDER};
|
|
148
|
+
scrollbar-background: transparent;
|
|
149
|
+
scrollbar-color: transparent;
|
|
150
|
+
scrollbar-corner-color: transparent;
|
|
151
|
+
scrollbar-background-hover: {BG};
|
|
152
|
+
scrollbar-color-hover: {MUTED};
|
|
153
|
+
scrollbar-background-active: {BG};
|
|
154
|
+
scrollbar-color-active: {WHITE};
|
|
155
|
+
}}
|
|
156
|
+
Button {{
|
|
157
|
+
background: {BG};
|
|
158
|
+
border: solid {BORDER};
|
|
159
|
+
text-style: none;
|
|
160
|
+
}}
|
|
161
|
+
Button:hover {{
|
|
162
|
+
background: {BG};
|
|
163
|
+
border: solid {WHITE};
|
|
164
|
+
text-style: none;
|
|
165
|
+
}}
|
|
166
|
+
Button:focus {{
|
|
167
|
+
text-style: none;
|
|
168
|
+
}}
|
|
169
|
+
#bottom {{
|
|
170
|
+
dock: bottom;
|
|
171
|
+
height: auto;
|
|
172
|
+
background: {BG};
|
|
173
|
+
}}
|
|
174
|
+
Input {{
|
|
175
|
+
background: {BG};
|
|
176
|
+
border: round {BORDER};
|
|
177
|
+
}}
|
|
178
|
+
Input:focus {{
|
|
179
|
+
border: round {WHITE};
|
|
180
|
+
}}
|
|
181
|
+
StatusBar {{
|
|
182
|
+
height: 1;
|
|
183
|
+
background: {BG};
|
|
184
|
+
padding: 0 1;
|
|
185
|
+
}}
|
|
186
|
+
.footer-btn {{
|
|
187
|
+
min-width: 0;
|
|
188
|
+
height: 1;
|
|
189
|
+
background: {BG};
|
|
190
|
+
border: none;
|
|
191
|
+
margin-right: 2;
|
|
192
|
+
}}
|
|
193
|
+
#suggestions {{
|
|
194
|
+
display: none;
|
|
195
|
+
height: auto;
|
|
196
|
+
max-height: 6;
|
|
197
|
+
background: {BG};
|
|
198
|
+
border: round {BORDER};
|
|
199
|
+
scrollbar-background: transparent;
|
|
200
|
+
scrollbar-color: transparent;
|
|
201
|
+
scrollbar-corner-color: transparent;
|
|
202
|
+
scrollbar-background-hover: {BG};
|
|
203
|
+
scrollbar-color-hover: {MUTED};
|
|
204
|
+
scrollbar-background-active: {BG};
|
|
205
|
+
scrollbar-color-active: {WHITE};
|
|
206
|
+
}}
|
|
207
|
+
OptionList {{
|
|
208
|
+
background: {BG};
|
|
209
|
+
}}
|
|
210
|
+
OptionList > .option-list--option-highlighted {{
|
|
211
|
+
background: {BORDER};
|
|
212
|
+
text-style: none;
|
|
213
|
+
}}
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
BINDINGS = [
|
|
217
|
+
Binding("ctrl+c", "quit", "Quit", priority=True),
|
|
218
|
+
Binding("ctrl+y", "copy_output", "Copy output"),
|
|
219
|
+
Binding("ctrl+s", "scan_tools", "Scan tools"),
|
|
220
|
+
# priority=True: this must win over whatever widget has focus (e.g.
|
|
221
|
+
# the Input) so a runaway tool can always be killed immediately.
|
|
222
|
+
Binding("escape", "interrupt", "Interrupt", priority=True),
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
def __init__(self, tools_dir: Path) -> None:
|
|
226
|
+
super().__init__(ansi_color=True)
|
|
227
|
+
self.tools_dir = tools_dir
|
|
228
|
+
self.shell = Shell()
|
|
229
|
+
self.tools: dict[str, ModuleType] = {}
|
|
230
|
+
|
|
231
|
+
def compose(self) -> ComposeResult:
|
|
232
|
+
yield Static("", id="badge")
|
|
233
|
+
yield RichLog(markup=True, wrap=True, max_lines=MAX_OUTPUT_LINES)
|
|
234
|
+
with Vertical(id="bottom"):
|
|
235
|
+
yield OptionList(id="suggestions")
|
|
236
|
+
yield Input(placeholder="Type / to load a tool...")
|
|
237
|
+
yield StatusBar()
|
|
238
|
+
|
|
239
|
+
def on_mount(self) -> None:
|
|
240
|
+
self.tools = discover_tools(self.tools_dir)
|
|
241
|
+
self._show_welcome()
|
|
242
|
+
self.update_badge()
|
|
243
|
+
self._focus_input()
|
|
244
|
+
|
|
245
|
+
def _clear_screen(self) -> None:
|
|
246
|
+
self.shell.lines.clear()
|
|
247
|
+
self.query_one(RichLog).clear()
|
|
248
|
+
|
|
249
|
+
def _show_welcome(self) -> None:
|
|
250
|
+
self.write_line(f"[{WHITE}]pykaxe[/]")
|
|
251
|
+
self.write_line("")
|
|
252
|
+
if self.tools:
|
|
253
|
+
self.write_line(f"[{MUTED}]available tools[/]")
|
|
254
|
+
width = max(len(name) for name in self.tools)
|
|
255
|
+
for name in sorted(self.tools):
|
|
256
|
+
desc = getattr(self.tools[name], "TOOL_DESCRIPTION", "")
|
|
257
|
+
line = f" [{TOOL}]/{escape_markup(name)}[/]"
|
|
258
|
+
if desc:
|
|
259
|
+
line += f"{' ' * (width - len(name))} [{MUTED}]{escape_markup(desc)}[/]"
|
|
260
|
+
self.write_line(line)
|
|
261
|
+
self.write_line("")
|
|
262
|
+
self.write_line(f"[{MUTED}]type / to load a tool[/]")
|
|
263
|
+
self.write_line("")
|
|
264
|
+
|
|
265
|
+
def _focus_input(self) -> None:
|
|
266
|
+
self.query_one(Input).focus()
|
|
267
|
+
|
|
268
|
+
def on_click(self, event: events.Click) -> None:
|
|
269
|
+
# The Input is the only thing worth typing into — whatever else got
|
|
270
|
+
# clicked (the log, a suggestion, the interrupt button) has already
|
|
271
|
+
# handled the click itself by the time this fires, so just make
|
|
272
|
+
# sure a cursor is always waiting in the Input afterward.
|
|
273
|
+
self._focus_input()
|
|
274
|
+
|
|
275
|
+
def write_line_to(self, shell: Shell, text: str) -> None:
|
|
276
|
+
shell.lines.append(text)
|
|
277
|
+
if shell is self.shell:
|
|
278
|
+
self.query_one(RichLog).write(text)
|
|
279
|
+
|
|
280
|
+
def write_line(self, text: str) -> None:
|
|
281
|
+
self.write_line_to(self.shell, text)
|
|
282
|
+
|
|
283
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
284
|
+
suggestions = self.query_one("#suggestions", OptionList)
|
|
285
|
+
if not event.value.startswith("/"):
|
|
286
|
+
suggestions.display = False
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
matches = fuzzy_filter(event.value[1:], list(self.tools.keys()))
|
|
290
|
+
suggestions.clear_options()
|
|
291
|
+
if not matches:
|
|
292
|
+
suggestions.display = False
|
|
293
|
+
return
|
|
294
|
+
for name in matches[:8]:
|
|
295
|
+
desc = getattr(self.tools[name], "TOOL_DESCRIPTION", "")
|
|
296
|
+
label = f"[{TOOL}]{escape_markup(name)}[/]"
|
|
297
|
+
if desc:
|
|
298
|
+
label += f": {escape_markup(desc)}"
|
|
299
|
+
suggestions.add_option(Option(label, id=name))
|
|
300
|
+
suggestions.display = True
|
|
301
|
+
|
|
302
|
+
async def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
303
|
+
if event.option_list.id != "suggestions" or event.option.id is None:
|
|
304
|
+
return
|
|
305
|
+
self.query_one(Input).value = ""
|
|
306
|
+
self.query_one("#suggestions", OptionList).display = False
|
|
307
|
+
await self.load_tool(event.option.id)
|
|
308
|
+
self._focus_input()
|
|
309
|
+
|
|
310
|
+
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
311
|
+
text = event.value
|
|
312
|
+
event.input.value = ""
|
|
313
|
+
self.query_one("#suggestions", OptionList).display = False
|
|
314
|
+
|
|
315
|
+
shell = self.shell
|
|
316
|
+
if text.startswith("/"):
|
|
317
|
+
query = text[1:].strip()
|
|
318
|
+
matches = fuzzy_filter(query, list(self.tools.keys()))
|
|
319
|
+
if matches:
|
|
320
|
+
await self.load_tool(matches[0])
|
|
321
|
+
else:
|
|
322
|
+
self.write_line(text)
|
|
323
|
+
self.write_line(f"[{MUTED}]no such tool: {query}[/]")
|
|
324
|
+
self.write_line("")
|
|
325
|
+
elif shell.tool is not None and shell.arg_index < len(shell.pending_args):
|
|
326
|
+
self.write_line(text)
|
|
327
|
+
self.write_line("")
|
|
328
|
+
await self.collect_argument(text)
|
|
329
|
+
elif shell.tool is None:
|
|
330
|
+
self.write_line(f"[{MUTED}]select a tool first — type / to see available tools[/]")
|
|
331
|
+
self.write_line("")
|
|
332
|
+
else:
|
|
333
|
+
self.write_line(text)
|
|
334
|
+
self.write_line("")
|
|
335
|
+
|
|
336
|
+
async def load_tool(self, name: str) -> None:
|
|
337
|
+
shell = self.shell
|
|
338
|
+
if shell.process is not None:
|
|
339
|
+
self.write_line(f"[{MUTED}]a tool is running in this shell — press esc to stop it first[/]")
|
|
340
|
+
self.write_line("")
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
module = self.tools.get(name)
|
|
344
|
+
if module is None:
|
|
345
|
+
self.write_line(f"[{MUTED}]no such tool: {name}[/]")
|
|
346
|
+
self.write_line("")
|
|
347
|
+
return
|
|
348
|
+
if not hasattr(module, "build_parser"):
|
|
349
|
+
self.write_line(f"[{TOOL}]{escape_markup(name)}[/] [{MUTED}]has no build_parser()[/]")
|
|
350
|
+
self.write_line("")
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
parser = module.build_parser()
|
|
354
|
+
actions = [
|
|
355
|
+
action
|
|
356
|
+
for action in parser._actions
|
|
357
|
+
if action.option_strings and action.option_strings[0] != "-h"
|
|
358
|
+
]
|
|
359
|
+
|
|
360
|
+
shell.tool = name
|
|
361
|
+
shell.script = Path(module.__file__)
|
|
362
|
+
shell.pending_args = actions
|
|
363
|
+
shell.arg_index = 0
|
|
364
|
+
shell.values = {}
|
|
365
|
+
|
|
366
|
+
self._clear_screen()
|
|
367
|
+
|
|
368
|
+
desc = getattr(module, "TOOL_DESCRIPTION", "")
|
|
369
|
+
banner = f"[{TOOL}]{escape_markup(name)}[/]"
|
|
370
|
+
if desc:
|
|
371
|
+
banner += f" [{MUTED}]— {escape_markup(desc)}[/]"
|
|
372
|
+
self.write_line(banner)
|
|
373
|
+
self.write_line("")
|
|
374
|
+
|
|
375
|
+
self.update_badge()
|
|
376
|
+
if shell.pending_args:
|
|
377
|
+
self.prompt_next_arg()
|
|
378
|
+
else:
|
|
379
|
+
await self.run_tool(shell)
|
|
380
|
+
|
|
381
|
+
def prompt_next_arg(self) -> None:
|
|
382
|
+
shell = self.shell
|
|
383
|
+
if not shell.pending_args:
|
|
384
|
+
return
|
|
385
|
+
action = shell.pending_args[shell.arg_index]
|
|
386
|
+
prompt = f"enter {action.dest}"
|
|
387
|
+
if action.choices:
|
|
388
|
+
prompt += f" ({'/'.join(str(c) for c in action.choices)})"
|
|
389
|
+
if action.default is not None and action.default is not argparse.SUPPRESS:
|
|
390
|
+
prompt += f" [{action.default}]"
|
|
391
|
+
self.write_line(f"[{MUTED}]{prompt}:[/]")
|
|
392
|
+
if action.help:
|
|
393
|
+
self.write_line(f"[{MUTED}]{escape_markup(action.help)}[/]")
|
|
394
|
+
self.write_line("")
|
|
395
|
+
|
|
396
|
+
async def collect_argument(self, value: str) -> None:
|
|
397
|
+
shell = self.shell
|
|
398
|
+
action = shell.pending_args[shell.arg_index]
|
|
399
|
+
value = value.strip()
|
|
400
|
+
|
|
401
|
+
has_default = action.default is not None and action.default is not argparse.SUPPRESS
|
|
402
|
+
if not value and has_default:
|
|
403
|
+
value = str(action.default)
|
|
404
|
+
|
|
405
|
+
if action.choices and value not in [str(c) for c in action.choices]:
|
|
406
|
+
choices = ", ".join(str(c) for c in action.choices)
|
|
407
|
+
self.write_line(f"[{MUTED}]must be one of: {choices}[/]")
|
|
408
|
+
self.write_line("")
|
|
409
|
+
self.prompt_next_arg()
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
shell.values[action.dest] = value
|
|
413
|
+
shell.arg_index += 1
|
|
414
|
+
|
|
415
|
+
if shell.arg_index < len(shell.pending_args):
|
|
416
|
+
self.prompt_next_arg()
|
|
417
|
+
else:
|
|
418
|
+
await self.run_tool(shell)
|
|
419
|
+
|
|
420
|
+
async def run_tool(self, shell: Shell) -> None:
|
|
421
|
+
if shell.process is not None:
|
|
422
|
+
self.write_line(f"[{MUTED}]a tool is already running in this shell — press esc to stop it first[/]")
|
|
423
|
+
self.write_line("")
|
|
424
|
+
return
|
|
425
|
+
|
|
426
|
+
args = []
|
|
427
|
+
for action in shell.pending_args:
|
|
428
|
+
args.append(action.option_strings[0])
|
|
429
|
+
args.append(shell.values[action.dest])
|
|
430
|
+
|
|
431
|
+
kwargs: dict = {}
|
|
432
|
+
if POSIX:
|
|
433
|
+
# New session -> its own process group, so a kill reaches any
|
|
434
|
+
# children the tool spawns too, not just the direct child.
|
|
435
|
+
kwargs["preexec_fn"] = _limit_tool_resources
|
|
436
|
+
kwargs["start_new_session"] = True
|
|
437
|
+
|
|
438
|
+
try:
|
|
439
|
+
process = await asyncio.create_subprocess_exec(
|
|
440
|
+
sys.executable,
|
|
441
|
+
str(shell.script),
|
|
442
|
+
*args,
|
|
443
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
444
|
+
stdout=asyncio.subprocess.PIPE,
|
|
445
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
446
|
+
**kwargs,
|
|
447
|
+
)
|
|
448
|
+
except Exception as exc:
|
|
449
|
+
self.write_line(f"[{MUTED}]failed to start {shell.tool}: {exc}[/]")
|
|
450
|
+
self.write_line("")
|
|
451
|
+
return
|
|
452
|
+
|
|
453
|
+
shell.process = process
|
|
454
|
+
self.update_badge()
|
|
455
|
+
shell.runner_task = asyncio.create_task(self._run_and_pump(shell, process))
|
|
456
|
+
|
|
457
|
+
async def _run_and_pump(self, shell: Shell, process: asyncio.subprocess.Process) -> None:
|
|
458
|
+
"""Streams a running tool's output as it arrives instead of
|
|
459
|
+
buffering it all in memory until exit — that buffering is what let
|
|
460
|
+
an infinite-loop tool grow without bound and look frozen while it
|
|
461
|
+
produced no visible output at all."""
|
|
462
|
+
watchdog = asyncio.create_task(self._watchdog(shell, process))
|
|
463
|
+
total_bytes = 0
|
|
464
|
+
killed_reason: str | None = None
|
|
465
|
+
|
|
466
|
+
try:
|
|
467
|
+
assert process.stdout is not None
|
|
468
|
+
while True:
|
|
469
|
+
try:
|
|
470
|
+
line = await process.stdout.readline()
|
|
471
|
+
except (asyncio.IncompleteReadError, ValueError):
|
|
472
|
+
break
|
|
473
|
+
if not line:
|
|
474
|
+
break
|
|
475
|
+
total_bytes += len(line)
|
|
476
|
+
text = line.decode(errors="replace").rstrip("\n")
|
|
477
|
+
if text:
|
|
478
|
+
self.write_line_to(shell, f"[{RESULT}]{escape_markup(text)}[/]")
|
|
479
|
+
else:
|
|
480
|
+
self.write_line_to(shell, "")
|
|
481
|
+
if total_bytes > MAX_OUTPUT_BYTES:
|
|
482
|
+
killed_reason = "exceeded output limit"
|
|
483
|
+
self._kill_process(process)
|
|
484
|
+
break
|
|
485
|
+
finally:
|
|
486
|
+
watchdog.cancel()
|
|
487
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
488
|
+
await watchdog
|
|
489
|
+
returncode = await process.wait()
|
|
490
|
+
|
|
491
|
+
if shell.process is process:
|
|
492
|
+
shell.process = None
|
|
493
|
+
shell.runner_task = None
|
|
494
|
+
|
|
495
|
+
if killed_reason:
|
|
496
|
+
self.write_line_to(shell, f"[{MUTED}]tool killed: {killed_reason}[/]")
|
|
497
|
+
elif returncode not in (0, None, -9, -15):
|
|
498
|
+
self.write_line_to(shell, f"[{MUTED}]exited with code {returncode}[/]")
|
|
499
|
+
|
|
500
|
+
self.write_line_to(shell, "")
|
|
501
|
+
if shell is self.shell:
|
|
502
|
+
self.update_badge()
|
|
503
|
+
|
|
504
|
+
shell.arg_index = 0
|
|
505
|
+
shell.values = {}
|
|
506
|
+
if shell is self.shell:
|
|
507
|
+
self.prompt_next_arg()
|
|
508
|
+
|
|
509
|
+
async def _watchdog(self, shell: Shell, process: asyncio.subprocess.Process) -> None:
|
|
510
|
+
await asyncio.sleep(MAX_TOOL_RUNTIME_SECONDS)
|
|
511
|
+
self.write_line_to(
|
|
512
|
+
shell, f"[{MUTED}]tool killed: exceeded {MAX_TOOL_RUNTIME_SECONDS}s runtime limit[/]"
|
|
513
|
+
)
|
|
514
|
+
self._kill_process(process)
|
|
515
|
+
|
|
516
|
+
def _kill_process(self, process: asyncio.subprocess.Process) -> None:
|
|
517
|
+
if process.returncode is not None:
|
|
518
|
+
return
|
|
519
|
+
if POSIX:
|
|
520
|
+
try:
|
|
521
|
+
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
|
522
|
+
return
|
|
523
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
524
|
+
pass
|
|
525
|
+
with contextlib.suppress(ProcessLookupError):
|
|
526
|
+
process.kill()
|
|
527
|
+
|
|
528
|
+
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
529
|
+
if event.button.id == "interrupt":
|
|
530
|
+
self.action_interrupt()
|
|
531
|
+
elif event.button.id == "copy_output":
|
|
532
|
+
self.action_copy_output()
|
|
533
|
+
elif event.button.id == "scan_tools":
|
|
534
|
+
self.action_scan_tools()
|
|
535
|
+
elif event.button.id == "quit":
|
|
536
|
+
await self.action_quit()
|
|
537
|
+
return
|
|
538
|
+
self._focus_input()
|
|
539
|
+
|
|
540
|
+
def action_copy_output(self) -> None:
|
|
541
|
+
shell = self.shell
|
|
542
|
+
text = "\n".join(Text.from_markup(line).plain for line in shell.lines)
|
|
543
|
+
if not text:
|
|
544
|
+
self.write_line(f"[{MUTED}]nothing to copy[/]")
|
|
545
|
+
self.write_line("")
|
|
546
|
+
return
|
|
547
|
+
|
|
548
|
+
if sys.platform == "darwin":
|
|
549
|
+
# OSC 52 (Textual's copy_to_clipboard) is ignored or blocked by
|
|
550
|
+
# most terminals, including Terminal.app — pbcopy is the one
|
|
551
|
+
# path that reliably reaches the system clipboard here.
|
|
552
|
+
try:
|
|
553
|
+
subprocess.run(["pbcopy"], input=text.encode(), check=True)
|
|
554
|
+
except (OSError, subprocess.CalledProcessError) as exc:
|
|
555
|
+
self.write_line(f"[{MUTED}]copy failed: {exc}[/]")
|
|
556
|
+
self.write_line("")
|
|
557
|
+
return
|
|
558
|
+
else:
|
|
559
|
+
self.copy_to_clipboard(text)
|
|
560
|
+
|
|
561
|
+
self.write_line(f"[{MUTED}]output copied to clipboard[/]")
|
|
562
|
+
self.write_line("")
|
|
563
|
+
|
|
564
|
+
async def action_quit(self) -> None:
|
|
565
|
+
"""Stop any running tool and let its output finish draining before
|
|
566
|
+
closing, instead of yanking the terminal away mid-output and
|
|
567
|
+
leaving a killed process to be reaped after the app is gone. Shows
|
|
568
|
+
a closing message for a couple seconds so the exit is visible
|
|
569
|
+
rather than the terminal vanishing instantly."""
|
|
570
|
+
if self.shell.process is not None:
|
|
571
|
+
self.write_line(f"[{MUTED}]shutting down — stopping running tool...[/]")
|
|
572
|
+
self._kill_process(self.shell.process)
|
|
573
|
+
|
|
574
|
+
if self.shell.runner_task is not None:
|
|
575
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
576
|
+
await asyncio.wait_for(self.shell.runner_task, timeout=5)
|
|
577
|
+
|
|
578
|
+
self.write_line(f"[{MUTED}]closing pykaxe...[/]")
|
|
579
|
+
await asyncio.sleep(2)
|
|
580
|
+
self.exit()
|
|
581
|
+
|
|
582
|
+
def action_interrupt(self) -> None:
|
|
583
|
+
suggestions = self.query_one("#suggestions", OptionList)
|
|
584
|
+
if suggestions.display:
|
|
585
|
+
suggestions.display = False
|
|
586
|
+
|
|
587
|
+
shell = self.shell
|
|
588
|
+
if shell.process is not None:
|
|
589
|
+
self._kill_process(shell.process)
|
|
590
|
+
self.write_line(f"[{MUTED}]interrupted[/]")
|
|
591
|
+
|
|
592
|
+
def action_scan_tools(self) -> None:
|
|
593
|
+
self.tools = discover_tools(self.tools_dir)
|
|
594
|
+
self.write_line(f"[{MUTED}]scanned {len(self.tools)} tool(s)[/]")
|
|
595
|
+
self.write_line("")
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def update_badge(self) -> None:
|
|
599
|
+
shell = self.shell
|
|
600
|
+
badge = self.query_one("#badge", Static)
|
|
601
|
+
if shell.tool:
|
|
602
|
+
state = "running" if shell.process is not None else "active"
|
|
603
|
+
badge.update(f"pykaxe {state} > {escape_markup(shell.tool)}")
|
|
604
|
+
badge.display = True
|
|
605
|
+
else:
|
|
606
|
+
badge.update("")
|
|
607
|
+
badge.display = False
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def run() -> None:
|
|
611
|
+
Pykaxe(config.ensure_tools_dir()).run()
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
if __name__ == "__main__":
|
|
615
|
+
run()
|
pykaxe/assets/PROMPT.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Writing a pykaxe tool
|
|
2
|
+
|
|
3
|
+
pykaxe is a terminal app that runs small Python scripts as tools. A tool is a
|
|
4
|
+
single `.py` file that follows this exact contract:
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
import argparse
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
TOOL_NAME = "add-numbers" # kebab-case, becomes the /add-numbers command
|
|
11
|
+
TOOL_DESCRIPTION = "Add two numbers together." # one line
|
|
12
|
+
|
|
13
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
14
|
+
parser = argparse.ArgumentParser(prog=TOOL_NAME, description=TOOL_DESCRIPTION)
|
|
15
|
+
parser.add_argument("--number1", type=float, required=True, help="First number.")
|
|
16
|
+
parser.add_argument("--number2", type=float, required=True, help="Second number.")
|
|
17
|
+
return parser
|
|
18
|
+
|
|
19
|
+
def main() -> int:
|
|
20
|
+
args = build_parser().parse_args()
|
|
21
|
+
try:
|
|
22
|
+
print(f"Result: {args.number1 + args.number2}")
|
|
23
|
+
return 0
|
|
24
|
+
except Exception as exc:
|
|
25
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
26
|
+
return 1
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
sys.exit(main())
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Rules
|
|
33
|
+
|
|
34
|
+
- Exactly one file, no package, no relative imports.
|
|
35
|
+
- Never call `input()` — every value must come from `argparse` flags, since
|
|
36
|
+
pykaxe collects them by prompting the user and passing `--flag value` on
|
|
37
|
+
the command line.
|
|
38
|
+
- Never use `shell=True` or `os.system`.
|
|
39
|
+
- Prefer the standard library. pykaxe runs the script with the current
|
|
40
|
+
Python interpreter directly — there is no isolated environment or
|
|
41
|
+
dependency installer, so a third-party import will fail unless the user
|
|
42
|
+
already has it installed. If a third-party package is unavoidable, say so
|
|
43
|
+
clearly at the top of your reply.
|
|
44
|
+
- `build_parser()` should use `choices=[...]` and `default=...` where it
|
|
45
|
+
makes sense — pykaxe shows both to the user when prompting for that
|
|
46
|
+
argument.
|
|
47
|
+
- `main()` returns `0` on success, non-zero on failure, and prints its
|
|
48
|
+
result to stdout.
|
|
49
|
+
|
|
50
|
+
## Once the script is written
|
|
51
|
+
|
|
52
|
+
**If you cannot access the user's filesystem** (this is the normal case in
|
|
53
|
+
ChatGPT or Claude.ai chat): output the complete script, then tell the user
|
|
54
|
+
to save it as a `.py` file and run:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
pykaxe add path/to/the-script.py
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
That validates and copies it into their tools folder — it will then show up
|
|
61
|
+
next time they type `/` in pykaxe.
|
|
62
|
+
|
|
63
|
+
**If you can write files on this machine** (a coding agent, e.g. Claude
|
|
64
|
+
Code): resolve the tools directory yourself instead of asking the user to
|
|
65
|
+
run `pykaxe add`:
|
|
66
|
+
|
|
67
|
+
1. If the `PYKAXE_TOOLS_DIR` environment variable is set, use it.
|
|
68
|
+
2. Otherwise read `tools_dir` from `~/.pykaxe/config.json`, if it exists.
|
|
69
|
+
3. Otherwise default to `~/.pykaxe/tools` (create it if missing).
|
|
70
|
+
|
|
71
|
+
Write the script directly into that directory. Filename should match
|
|
72
|
+
`TOOL_NAME` with a `.py` extension.
|