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 ADDED
@@ -0,0 +1,3 @@
1
+ """pykaxe — a terminal UI for discovering and running small Python CLI tools."""
2
+
3
+ __version__ = "0.1.1"
pykaxe/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from pykaxe.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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()
@@ -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.