Skip to content

Commit 7b38ce6

Browse files
committed
Added support for shell_tools and 'before' runner.
1 parent db0c9c5 commit 7b38ce6

4 files changed

Lines changed: 472 additions & 0 deletions

File tree

README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,53 @@ FileNotFoundError: [Errno 2] No such file or directory: 'missing.txt'
355355
I couldn't read that file because it doesn't exist. Would you like me to try a different path?
356356
```
357357

358+
### Shell tools
359+
360+
Define simple shell script tools inline in your prompt:
361+
362+
```yaml
363+
---
364+
model: anthropic/claude-sonnet-4-20250514
365+
shell_tools:
366+
git_status: git status --short
367+
count_py_files: find . -name "*.py" | wc -l
368+
---
369+
What's the current git status and how many Python files are there?
370+
```
371+
372+
**Long form with options:**
373+
374+
```yaml
375+
shell_tools:
376+
git_log:
377+
cmd: git log --oneline
378+
safe: true
379+
description: Show recent git commits
380+
search_code:
381+
cmd: grep -r
382+
safe: true
383+
description: Search for text in files
384+
```
385+
386+
**Fields:**
387+
- `cmd` (required): The shell command to execute
388+
- `safe` (optional, default: false): Mark as safe for auto-approval with `--safe-yes`
389+
- `description` (optional, default: cmd): Description shown to the LLM
390+
391+
**Arguments:**
392+
393+
The LLM can pass:
394+
- `args` (string): Appended to the command
395+
- Environment variables as named parameters
396+
397+
Example LLM call:
398+
```
399+
git_log(args="--author=alice -n 5")
400+
search_code(args="TODO", PATH="/src")
401+
```
402+
403+
Shell tools use the same shell resolution as `before:` commands (`$SHELL`, defaulting to `/bin/sh`).
404+
358405
### Builtin tools
359406

360407
Runprompt includes builtin tools that can be used without creating external Python files:

runprompt

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,13 @@ def main():
297297
if tool_specs:
298298
tools = load_tools(tool_specs, search_paths)
299299
log("Loaded %d tools: %s" % (len(tools), list(tools.keys())))
300+
301+
# Load shell_tools
302+
shell_tool_specs = meta.get("shell_tools", {})
303+
if shell_tool_specs:
304+
shell_tools = load_shell_tools(shell_tool_specs)
305+
tools.update(shell_tools)
306+
log("Loaded %d shell tools: %s" % (len(shell_tools), list(shell_tools.keys())))
300307

301308
# Determine effective provider early for file reading
302309
base_url = get_conf("base_url") or get_base_url()
@@ -1384,6 +1391,90 @@ def build_tool_result_message(tool_call, result, error, provider):
13841391
# === TOOL LOADING & SCHEMA ===
13851392

13861393

1394+
def load_shell_tools(shell_tool_specs):
1395+
"""Load shell script tools from shell_tools: frontmatter."""
1396+
tools = {}
1397+
1398+
for name, spec in shell_tool_specs.items():
1399+
# Handle short form: name: "command"
1400+
if isinstance(spec, str):
1401+
cmd = spec
1402+
safe = False
1403+
description = cmd
1404+
# Handle long form: name: {cmd: ..., safe: ..., description: ...}
1405+
elif isinstance(spec, dict):
1406+
cmd = spec.get("cmd")
1407+
if not cmd:
1408+
print("%sWarning: shell_tool '%s' missing 'cmd' field%s" %
1409+
(YELLOW, name, RESET), file=sys.stderr)
1410+
continue
1411+
safe = spec.get("safe", False)
1412+
description = spec.get("description", cmd)
1413+
else:
1414+
print("%sWarning: shell_tool '%s' has invalid spec%s" %
1415+
(YELLOW, name, RESET), file=sys.stderr)
1416+
continue
1417+
1418+
# Create the tool function
1419+
func = create_shell_tool(name, cmd, description, safe)
1420+
schema = function_to_tool_schema(func)
1421+
if schema:
1422+
schema['function']['name'] = name
1423+
tools[name] = {"schema": schema, "func": func}
1424+
log("Loaded shell tool: %s" % name)
1425+
1426+
return tools
1427+
1428+
1429+
def create_shell_tool(name, cmd, description, safe):
1430+
"""Create a shell tool function with the given command."""
1431+
def shell_tool(args: str = "", **env_vars):
1432+
"""Execute shell command with optional args and environment variables."""
1433+
import subprocess
1434+
1435+
# Build environment with provided env vars
1436+
env = os.environ.copy()
1437+
for key, value in env_vars.items():
1438+
env[key] = str(value)
1439+
1440+
# Build full command
1441+
full_cmd = cmd
1442+
if args:
1443+
full_cmd = "%s %s" % (cmd, args)
1444+
1445+
# Use user's configured shell, fallback to /bin/sh
1446+
shell = env.get("SHELL", "/bin/sh")
1447+
1448+
log("Running shell tool '%s': %s" % (name, full_cmd))
1449+
try:
1450+
result = subprocess.run(
1451+
[shell, "-c", full_cmd],
1452+
capture_output=True,
1453+
text=True,
1454+
timeout=30,
1455+
env=env,
1456+
)
1457+
if result.returncode == 0:
1458+
return result.stdout.rstrip('\n')
1459+
else:
1460+
return {
1461+
"returncode": result.returncode,
1462+
"stdout": result.stdout.rstrip('\n'),
1463+
"stderr": result.stderr.rstrip('\n'),
1464+
}
1465+
except subprocess.TimeoutExpired:
1466+
raise RuntimeError("Command timed out after 30 seconds")
1467+
except Exception as e:
1468+
raise RuntimeError("Command failed: %s" % str(e))
1469+
1470+
# Set docstring and metadata
1471+
shell_tool.__doc__ = description
1472+
shell_tool.safe = safe
1473+
shell_tool.__name__ = name
1474+
1475+
return shell_tool
1476+
1477+
13871478
def load_tools(tool_specs, search_paths):
13881479
tools = {} # name -> {"schema": ..., "func": ...}
13891480
for spec in tool_specs:

tests/runtests

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ run_test "cache tests" python3 tests/test-cache.py
3434

3535
run_test "tools tests" python3 tests/test-tools.py
3636

37+
run_test "shell tools tests" python3 tests/test-shell-tools.py
38+
3739
run_test "required inputs tests" python3 tests/test-required-inputs.py
3840

3941
run_test "read file tests" python3 tests/test-read.py

0 commit comments

Comments
 (0)