refactor: rename shell tools to terminal tools

This commit is contained in:
Timothy
2026-04-30 19:41:16 -07:00
parent 0e8efa7bcc
commit 0c6f0f8aef
44 changed files with 450 additions and 446 deletions
@@ -1,3 +1,3 @@
{
"include": ["gcu-tools", "hive_tools", "shell-tools"]
"include": ["gcu-tools", "hive_tools", "terminal-tools"]
}
@@ -58,14 +58,14 @@ _TOOL_CATEGORIES: dict[str, list[str]] = {
],
# Shell + process control — engineering personas only.
# Includes the legacy coder-tools commands (run_command, bash_*) and
# the full shell-tools MCP server (foreground exec with auto-promotion,
# the full terminal-tools MCP server (foreground exec with auto-promotion,
# background jobs, persistent PTY sessions, ripgrep/find).
"shell": [
"run_command",
"execute_command_tool",
"bash_kill",
"bash_output",
"@server:shell-tools",
"@server:terminal-tools",
],
# Tabular data. CSV/Excel read/write + DuckDB SQL.
"data": [
+7 -3
View File
@@ -51,9 +51,9 @@ _DEFAULT_LOCAL_SERVERS: dict[str, dict[str, Any]] = {
"description": "File I/O: read, write, edit, search, list, run commands",
"args": ["run", "python", "files_server.py", "--stdio"],
},
"shell-tools": {
"description": "Terminal/shell capabilities: process exec, background jobs, PTY shells, fs search. Bash-only on POSIX.",
"args": ["run", "python", "shell_tools_server.py", "--stdio"],
"terminal-tools": {
"description": "Terminal capabilities: process exec, background jobs, PTY sessions, fs search. Bash-only on POSIX.",
"args": ["run", "python", "terminal_tools_server.py", "--stdio"],
},
}
@@ -62,6 +62,10 @@ _DEFAULT_LOCAL_SERVERS: dict[str, dict[str, Any]] = {
# name so the active agents (queen, credential_tester) can find their tools.
_STALE_DEFAULT_ALIASES: dict[str, str] = {
"hive_tools": "hive-tools",
# 2026-04-30: shell-tools renamed to terminal-tools. Drop the stale name
# on next ensure_defaults() so the queen's allowlist (which now includes
# @server:terminal-tools) actually finds a server with the new name.
"terminal-tools": "shell-tools",
}
@@ -1,96 +0,0 @@
---
name: hive.shell-tools-fs-search
description: Use shell_rg / shell_find when you need raw filesystem search outside the project tree — system configs, /var/log, /etc, archive contents — or when files-tools.search_files is too project-scoped. Teaches the rg vs find vs shell_exec("ls/du/tree") split, common rg flag combos for code/logs/configs, find predicates for mtime/size/type queries, and the rule that for tree views or single-file stat info you should just use shell_exec instead of inventing a tool. Read before reaching for raw shell to grep or find anything.
metadata:
author: hive
type: preset-skill
version: "1.0"
---
# Filesystem search
shell-tools provides two structured search tools: `shell_rg` (ripgrep for content) and `shell_find` (find for predicates). Everything else (tree, stat, du) is just `shell_exec`.
## When to use what
| Task | Tool |
|---|---|
| Find code/text matching a pattern in your **project** | `files-tools.search_files` (project-aware, ranks by relevance) |
| Find code/text matching a pattern in `/var/log`, `/etc`, archives, system dirs | `shell_rg` |
| Find files matching name/glob/predicate | `shell_find` |
| List a directory | `shell_exec("ls -la /path")` |
| Tree view | `shell_exec("tree -L 2 /path")` |
| Single-path stat | `shell_exec("stat /path")` |
| Disk usage | `shell_exec("du -sh /path")` or `shell_exec("du -h --max-depth=2 /")` |
| Count matches across files | `shell_rg(pattern, count=True via extra_args=["-c"])` |
## `shell_rg` — content search
ripgrep is fast, gitignore-aware, and has a deep flag surface. The structured wrapper exposes the most useful flags directly; `extra_args` covers the rest.
### Common patterns
```
# All Python files containing "TODO"
shell_rg(pattern="TODO", path=".", type_filter="py")
# Case-insensitive, with context
shell_rg(pattern="error", path="/var/log", ignore_case=True, context=2)
# Search hidden files (rg ignores them by default)
shell_rg(pattern="api_key", path="~", hidden=True)
# Don't respect .gitignore (find files git would ignore)
shell_rg(pattern="generated", path=".", no_ignore=True)
# Multi-line pattern (e.g., function definitions spanning lines)
shell_rg(pattern=r"def\s+\w+\(.*\n.*\n", path="src", extra_args=["--multiline"])
# Specific filename glob
shell_rg(pattern="version", path=".", glob="*.toml")
```
### rg flag idioms
| Flag | Effect |
|---|---|
| `-tpy` (`type_filter="py"`) | Only Python files |
| `-uu` | Don't respect any ignores (incl. `.git/`) |
| `--multiline` (`extra_args`) | Allow regex spanning lines |
| `--max-count` (`max_count`) | Stop after N matches per file |
| `--max-depth` (`max_depth`) | Limit recursion |
| `-w` (`extra_args`) | Whole word match |
| `-F` (`extra_args`) | Fixed string (no regex) |
See `references/ripgrep_cheatsheet.md` for the long form.
## `shell_find` — predicate search
`find` excels at "files matching N criteria". The wrapper surfaces the most common predicates; combine via the structured arguments.
```
# All .log files modified in the last 7 days, larger than 1MB
shell_find(path="/var/log", iname="*.log", mtime_days=7, size_kb_min=1024)
# All directories named ".git" (find Git repos under a tree)
shell_find(path="~/projects", name=".git", type_filter="d")
# Only the top three levels
shell_find(path="/etc", max_depth=3, type_filter="f")
# Symlinks
shell_find(path=".", type_filter="l")
```
See `references/find_predicates.md` for combinations not directly exposed.
## Output truncation
Both tools return `truncated: true` when their output exceeded the inline cap. For `shell_rg`, this means matches were dropped (refine the pattern or narrow the path); for `shell_find`, results past `max_results` (default 1000) are dropped. Tighten predicates rather than raising the cap.
## Anti-patterns
- **Don't `shell_rg` your project tree** — `files-tools.search_files` is project-aware and ranks results.
- **Don't reach for `shell_find` to list one directory** — `shell_exec("ls -la /path")` is shorter.
- **Don't use `shell_exec("grep ...")`** when `shell_rg` exists — rg is faster, gitignore-aware, and returns structured matches.
- **Don't use `shell_exec("find ...")`** to invent your own predicate combinations — use `shell_find` and report missing capabilities.
@@ -1,31 +1,31 @@
---
name: hive.shell-tools-foundations
description: Required reading whenever any shell_* tool is available. Teaches the foreground/background dichotomy (shell_exec auto-promotes past 30s, returns a job_id you poll with shell_job_logs), the standard envelope shape (exit_code, stdout, stdout_truncated_bytes, output_handle, semantic_status, warning, auto_backgrounded, job_id), output handle pagination via shell_output_get, when to read semantic_status instead of raw exit_code (grep/rg/find/diff/test exit 1 is NOT an error), the destructive-warning surface (rm -rf, git push --force, DROP TABLE), tool preference (use files-tools / gcu-tools / hive_tools before raw shell), and the bash-only-on-macOS policy. Skipping this leads to "tool returned no output" surprises, orphaned jobs, and panic over benign grep exit codes.
name: hive.terminal-tools-foundations
description: Required reading whenever any shell_* tool is available. Teaches the foreground/background dichotomy (terminal_exec auto-promotes past 30s, returns a job_id you poll with terminal_job_logs), the standard envelope shape (exit_code, stdout, stdout_truncated_bytes, output_handle, semantic_status, warning, auto_backgrounded, job_id), output handle pagination via terminal_output_get, when to read semantic_status instead of raw exit_code (grep/rg/find/diff/test exit 1 is NOT an error), the destructive-warning surface (rm -rf, git push --force, DROP TABLE), tool preference (use files-tools / gcu-tools / hive_tools before raw shell), and the bash-only-on-macOS policy. Skipping this leads to "tool returned no output" surprises, orphaned jobs, and panic over benign grep exit codes.
metadata:
author: hive
type: preset-skill
version: "1.0"
---
# shell-tools — foundations
# terminal-tools — foundations
These tools give you a real terminal: foreground exec with smart envelopes, background jobs with offset-based log streaming, persistent PTY shells, and filesystem search. Bash-only on POSIX.
## Tool preference (read first)
Before reaching for shell-tools, check whether a higher-level tool already covers the task. Shell is for system operations the other servers don't reach.
Before reaching for terminal-tools, check whether a higher-level tool already covers the task. Shell is for system operations the other servers don't reach.
- **Reading files**`files-tools.read_file` (handles size, paging, line-numbered output) — NOT `shell_exec("cat ...")`
- **Editing files**`files-tools.edit_file` (atomic patch with diff verification) — NOT `shell_exec("sed -i ...")`
- **Writing files**`files-tools.write_file` — NOT `shell_exec("echo > ...")`
- **In-project search**`files-tools.search_files` (project-scoped, code-aware) — use `shell_rg` only for raw paths outside the project (`/var/log`, `/etc`)
- **Browser / web pages**`gcu-tools.browser_*` for rendered pages — NOT `shell_exec("curl ...")`
- **Reading files**`files-tools.read_file` (handles size, paging, line-numbered output) — NOT `terminal_exec("cat ...")`
- **Editing files**`files-tools.edit_file` (atomic patch with diff verification) — NOT `terminal_exec("sed -i ...")`
- **Writing files**`files-tools.write_file` — NOT `terminal_exec("echo > ...")`
- **In-project search**`files-tools.search_files` (project-scoped, code-aware) — use `terminal_rg` only for raw paths outside the project (`/var/log`, `/etc`)
- **Browser / web pages**`gcu-tools.browser_*` for rendered pages — NOT `terminal_exec("curl ...")`
- **Web search**`hive_tools.web_search` — NOT scraping
- **System operations** (process exec, jobs, PTYs, raw fs search) → shell-tools. This is its territory.
- **System operations** (process exec, jobs, PTYs, raw fs search) → terminal-tools. This is its territory.
## The standard envelope
Every spawn-style call (`shell_exec`, the auto-promoted job state) returns this shape:
Every spawn-style call (`terminal_exec`, the auto-promoted job state) returns this shape:
```jsonc
{
@@ -36,7 +36,7 @@ Every spawn-style call (`shell_exec`, the auto-promoted job state) returns this
"stderr_truncated_bytes": 0,
"runtime_ms": 42,
"pid": 12345,
"output_handle": null, // "out_<hex>" when truncated — paginate with shell_output_get
"output_handle": null, // "out_<hex>" when truncated — paginate with terminal_output_get
"timed_out": false,
"semantic_status": "ok", // "ok" | "signal" | "error" — read THIS, not just exit_code
"semantic_message": null, // e.g. "No matches found" for grep exit 1
@@ -48,7 +48,7 @@ Every spawn-style call (`shell_exec`, the auto-promoted job state) returns this
## Auto-promotion (the core mental model)
`shell_exec` runs commands in the foreground until the **auto-background budget** (default 30s) elapses. Past that point, the process is silently transferred to a background job and the call returns immediately with:
`terminal_exec` runs commands in the foreground until the **auto-background budget** (default 30s) elapses. Past that point, the process is silently transferred to a background job and the call returns immediately with:
```jsonc
{ "auto_backgrounded": true, "exit_code": null, "job_id": "job_<hex>", ... }
@@ -57,7 +57,7 @@ Every spawn-style call (`shell_exec`, the auto-promoted job state) returns this
When you see `auto_backgrounded: true`, **pivot to polling**. The job is still running:
```
shell_job_logs(job_id, since_offset=0, wait_until_exit=true, wait_timeout_sec=60)
terminal_job_logs(job_id, since_offset=0, wait_until_exit=true, wait_timeout_sec=60)
→ blocks server-side until the job exits or the timeout, returns logs + status
```
@@ -91,17 +91,17 @@ If a `warning` appears unexpectedly, stop and verify: was the destructive action
When `stdout_truncated_bytes > 0` or `stderr_truncated_bytes > 0`, the inline output was capped at `max_output_kb` (default 256 KB). The full bytes are stashed under `output_handle` for **5 minutes**. Paginate with:
```
shell_output_get(output_handle, since_offset=0, max_kb=64)
terminal_output_get(output_handle, since_offset=0, max_kb=64)
→ { data, offset, next_offset, eof, expired }
```
Track `next_offset` across calls. If `expired: true`, re-run the command (the handle's TTL has lapsed).
The store has a 64 MB cap with LRU eviction. For huge outputs, prefer `shell_job_start` + `shell_job_logs` polling (4 MB ring buffer per stream, infinite total throughput).
The store has a 64 MB cap with LRU eviction. For huge outputs, prefer `terminal_job_start` + `terminal_job_logs` polling (4 MB ring buffer per stream, infinite total throughput).
## Bash, not zsh — even on macOS
`shell_exec` and `shell_pty_open` always invoke `/bin/bash`. The user's `$SHELL` is ignored. Explicit `shell="/bin/zsh"` is **rejected** with a clear error. This is a deliberate security stance, not aesthetic — zsh has command/builtin classes (`zmodload`, `=cmd` expansion, `zpty`, `ztcp`, `zf_*`) that bypass bash-shaped checks. The `shell-tools-pty-sessions` skill explains the implications for PTY sessions specifically.
`terminal_exec` and `terminal_pty_open` always invoke `/bin/bash`. The user's `$SHELL` is ignored. Explicit `shell="/bin/zsh"` is **rejected** with a clear error. This is a deliberate security stance, not aesthetic — zsh has command/builtin classes (`zmodload`, `=cmd` expansion, `zpty`, `ztcp`, `zf_*`) that bypass bash-shaped checks. The `terminal-tools-pty-sessions` skill explains the implications for PTY sessions specifically.
`ZDOTDIR` and `ZSH_*` env vars are stripped before exec to prevent zsh dotfiles leaking in. Bash dotfiles still apply when invoked interactively (e.g. PTY sessions use `bash --norc --noprofile` to keep things predictable).
@@ -110,14 +110,14 @@ The store has a 64 MB cap with LRU eviction. For huge outputs, prefer `shell_job
Pipes (`|`), redirects (`>`, `<`, `>>`), conditionals (`&&`, `||`, `;`), and globs (`*`, `?`, `[`) are detected automatically. You can pass them with the default `shell=False` and the runtime will transparently route through `/bin/bash -c` and surface `auto_shell: true` in the envelope:
```
shell_exec("ps aux | sort -k3 -rn | head -40")
terminal_exec("ps aux | sort -k3 -rn | head -40")
→ { exit_code: 0, stdout: "...", auto_shell: true, ... }
```
For simple argv commands (no metacharacters) `shell=False` is faster and direct-execs the binary. For commands with shell features but no metacharacters that the detector catches (rare — exotic bash builtins, here-strings), pass `shell=True` explicitly:
```
shell_exec("set -e; complicated bash logic", shell=True)
terminal_exec("set -e; complicated bash logic", shell=True)
```
Quoted strings work either way — the detector uses `shlex.split` which handles `"quoted args with spaces"` correctly.
@@ -126,14 +126,14 @@ Quoted strings work either way — the detector uses `shlex.split` which handles
| Need | Tool |
|---|---|
| One-shot command, ≤30s | `shell_exec` |
| One-shot command, might be longer | `shell_exec` (auto-promotes) |
| Long-running job from the start | `shell_job_start` |
| State across calls (cd, env, REPL) | `shell_pty_open` + `shell_pty_run` |
| Search file contents (raw paths) | `shell_rg` |
| Find files by predicate | `shell_find` |
| Retrieve truncated output | `shell_output_get` |
| Tree / stat / du | `shell_exec("ls -la"/"stat foo"/"du -sh path")` |
| HTTP / DNS / ping / archives | `shell_exec("curl ..."/"dig ..."/"tar xzf ...")` |
| One-shot command, ≤30s | `terminal_exec` |
| One-shot command, might be longer | `terminal_exec` (auto-promotes) |
| Long-running job from the start | `terminal_job_start` |
| State across calls (cd, env, REPL) | `terminal_pty_open` + `terminal_pty_run` |
| Search file contents (raw paths) | `terminal_rg` |
| Find files by predicate | `terminal_find` |
| Retrieve truncated output | `terminal_output_get` |
| Tree / stat / du | `terminal_exec("ls -la"/"stat foo"/"du -sh path")` |
| HTTP / DNS / ping / archives | `terminal_exec("curl ..."/"dig ..."/"tar xzf ...")` |
See `references/exit_codes.md` for the full POSIX + signal-induced + semantic catalog.
@@ -20,7 +20,7 @@ When `exit_code < 0` in the envelope, the process was killed by a signal: `abs(e
## Semantic exits — when exit 1 is NOT an error
shell-tools encodes these in `semantic_status`. The agent should read `semantic_status` first.
terminal-tools encodes these in `semantic_status`. The agent should read `semantic_status` first.
| Command | Code 0 | Code 1 | Code ≥2 |
|---|---|---|---|
@@ -33,7 +33,7 @@ For any command not in this table, the default convention applies (0 = ok, nonze
## When `exit_code` is `null`
- `auto_backgrounded: true` — the process is still running under a `job_id`. Poll with `shell_job_logs`.
- `auto_backgrounded: true` — the process is still running under a `job_id`. Poll with `terminal_job_logs`.
- Pre-spawn error (command not found, exec failed) — see `error` field in the envelope.
- `timed_out: true` and the process refused to die — extremely rare; the kernel has the answer.
@@ -0,0 +1,96 @@
---
name: hive.terminal-tools-fs-search
description: Use terminal_rg / terminal_find when you need raw filesystem search outside the project tree — system configs, /var/log, /etc, archive contents — or when files-tools.search_files is too project-scoped. Teaches the rg vs find vs terminal_exec("ls/du/tree") split, common rg flag combos for code/logs/configs, find predicates for mtime/size/type queries, and the rule that for tree views or single-file stat info you should just use terminal_exec instead of inventing a tool. Read before reaching for raw shell to grep or find anything.
metadata:
author: hive
type: preset-skill
version: "1.0"
---
# Filesystem search
terminal-tools provides two structured search tools: `terminal_rg` (ripgrep for content) and `terminal_find` (find for predicates). Everything else (tree, stat, du) is just `terminal_exec`.
## When to use what
| Task | Tool |
|---|---|
| Find code/text matching a pattern in your **project** | `files-tools.search_files` (project-aware, ranks by relevance) |
| Find code/text matching a pattern in `/var/log`, `/etc`, archives, system dirs | `terminal_rg` |
| Find files matching name/glob/predicate | `terminal_find` |
| List a directory | `terminal_exec("ls -la /path")` |
| Tree view | `terminal_exec("tree -L 2 /path")` |
| Single-path stat | `terminal_exec("stat /path")` |
| Disk usage | `terminal_exec("du -sh /path")` or `terminal_exec("du -h --max-depth=2 /")` |
| Count matches across files | `terminal_rg(pattern, count=True via extra_args=["-c"])` |
## `terminal_rg` — content search
ripgrep is fast, gitignore-aware, and has a deep flag surface. The structured wrapper exposes the most useful flags directly; `extra_args` covers the rest.
### Common patterns
```
# All Python files containing "TODO"
terminal_rg(pattern="TODO", path=".", type_filter="py")
# Case-insensitive, with context
terminal_rg(pattern="error", path="/var/log", ignore_case=True, context=2)
# Search hidden files (rg ignores them by default)
terminal_rg(pattern="api_key", path="~", hidden=True)
# Don't respect .gitignore (find files git would ignore)
terminal_rg(pattern="generated", path=".", no_ignore=True)
# Multi-line pattern (e.g., function definitions spanning lines)
terminal_rg(pattern=r"def\s+\w+\(.*\n.*\n", path="src", extra_args=["--multiline"])
# Specific filename glob
terminal_rg(pattern="version", path=".", glob="*.toml")
```
### rg flag idioms
| Flag | Effect |
|---|---|
| `-tpy` (`type_filter="py"`) | Only Python files |
| `-uu` | Don't respect any ignores (incl. `.git/`) |
| `--multiline` (`extra_args`) | Allow regex spanning lines |
| `--max-count` (`max_count`) | Stop after N matches per file |
| `--max-depth` (`max_depth`) | Limit recursion |
| `-w` (`extra_args`) | Whole word match |
| `-F` (`extra_args`) | Fixed string (no regex) |
See `references/ripgrep_cheatsheet.md` for the long form.
## `terminal_find` — predicate search
`find` excels at "files matching N criteria". The wrapper surfaces the most common predicates; combine via the structured arguments.
```
# All .log files modified in the last 7 days, larger than 1MB
terminal_find(path="/var/log", iname="*.log", mtime_days=7, size_kb_min=1024)
# All directories named ".git" (find Git repos under a tree)
terminal_find(path="~/projects", name=".git", type_filter="d")
# Only the top three levels
terminal_find(path="/etc", max_depth=3, type_filter="f")
# Symlinks
terminal_find(path=".", type_filter="l")
```
See `references/find_predicates.md` for combinations not directly exposed.
## Output truncation
Both tools return `truncated: true` when their output exceeded the inline cap. For `terminal_rg`, this means matches were dropped (refine the pattern or narrow the path); for `terminal_find`, results past `max_results` (default 1000) are dropped. Tighten predicates rather than raising the cap.
## Anti-patterns
- **Don't `terminal_rg` your project tree** — `files-tools.search_files` is project-aware and ranks results.
- **Don't reach for `terminal_find` to list one directory** — `terminal_exec("ls -la /path")` is shorter.
- **Don't use `terminal_exec("grep ...")`** when `terminal_rg` exists — rg is faster, gitignore-aware, and returns structured matches.
- **Don't use `terminal_exec("find ...")`** to invent your own predicate combinations — use `terminal_find` and report missing capabilities.
@@ -1,6 +1,6 @@
# find predicate reference
The `shell_find` wrapper exposes name/iname, type, mtime_days, size bounds, max_depth, max_results. For combinations beyond that, drop to `shell_exec("find ...")`.
The `terminal_find` wrapper exposes name/iname, type, mtime_days, size bounds, max_depth, max_results. For combinations beyond that, drop to `terminal_exec("find ...")`.
## Time predicates
@@ -54,11 +54,11 @@ The `shell_find` wrapper exposes name/iname, type, mtime_days, size bounds, max_
`find` evaluates predicates left-to-right with implicit AND. For OR, use `\(`...\` or .
```
# .log OR .txt (drop to shell_exec for OR)
shell_exec(r"find /path \( -name '*.log' -o -name '*.txt' \) -type f", shell=True)
# .log OR .txt (drop to terminal_exec for OR)
terminal_exec(r"find /path \( -name '*.log' -o -name '*.txt' \) -type f", shell=True)
# NOT in a directory called node_modules
shell_exec("find . -path '*/node_modules' -prune -o -name '*.js' -print", shell=True)
terminal_exec("find . -path '*/node_modules' -prune -o -name '*.js' -print", shell=True)
```
## Actions
@@ -67,12 +67,12 @@ shell_exec("find . -path '*/node_modules' -prune -o -name '*.js' -print", shell=
|---|---|
| Print path (default) | (implicit `-print`) |
| Print null-separated | `-print0` (for piping to xargs -0) |
| Delete | `-delete` (DANGEROUS — use shell_exec with explicit confirmation) |
| Run command per match | `-exec cmd {} \;` (drop to shell_exec) |
| Delete | `-delete` (DANGEROUS — use terminal_exec with explicit confirmation) |
| Run command per match | `-exec cmd {} \;` (drop to terminal_exec) |
| Run command, batched | `-exec cmd {} +` |
## When NOT to use find
- **One directory listing**: `shell_exec("ls -la /path")`
- **Recursive grep**: `shell_rg`
- **Count files**: `shell_exec("find /path -type f | wc -l")`
- **One directory listing**: `terminal_exec("ls -la /path")`
- **Recursive grep**: `terminal_rg`
- **Count files**: `terminal_exec("find /path -type f | wc -l")`
@@ -1,6 +1,6 @@
# ripgrep cheatsheet
For when the structured `shell_rg` flags don't cover the case. Pass via `extra_args=[...]`.
For when the structured `terminal_rg` flags don't cover the case. Pass via `extra_args=[...]`.
## Filtering
@@ -57,14 +57,14 @@ For when the structured `shell_rg` flags don't cover the case. Pass via `extra_a
```
# Find unused imports in Python
shell_rg(pattern=r"^import\s+\w+$", path="src", type_filter="py")
terminal_rg(pattern=r"^import\s+\w+$", path="src", type_filter="py")
# All TODO/FIXME/XXX with file:line
shell_rg(pattern=r"\b(TODO|FIXME|XXX)\b", path=".", extra_args=["-n"])
terminal_rg(pattern=r"\b(TODO|FIXME|XXX)\b", path=".", extra_args=["-n"])
# Functions defined at module top-level
shell_rg(pattern=r"^def\s+\w+", path=".", type_filter="py")
terminal_rg(pattern=r"^def\s+\w+", path=".", type_filter="py")
# Lines that DON'T match a pattern (filtered through awk)
# rg can't invert at line level; use shell_exec with grep -v
# rg can't invert at line level; use terminal_exec with grep -v
```
@@ -1,6 +1,6 @@
---
name: hive.shell-tools-job-control
description: Use when launching anything that runs longer than a minute, anything that streams logs, anything you want to keep running while doing other work — or when shell_exec auto-backgrounded on you and returned a job_id. Teaches the start→poll→wait pattern with shell_job_logs offset bookkeeping, the `wait_until_exit=True` blocking-poll idiom, the truncated_bytes_dropped resumption signal, the merge_stderr decision, the SIGINT→SIGTERM→SIGKILL escalation ladder via shell_job_manage, and the hard rule that jobs die when the shell-tools server restarts. Read before calling shell_job_start, or right after shell_exec auto-backgrounded.
name: hive.terminal-tools-job-control
description: Use when launching anything that runs longer than a minute, anything that streams logs, anything you want to keep running while doing other work — or when terminal_exec auto-backgrounded on you and returned a job_id. Teaches the start→poll→wait pattern with terminal_job_logs offset bookkeeping, the `wait_until_exit=True` blocking-poll idiom, the truncated_bytes_dropped resumption signal, the merge_stderr decision, the SIGINT→SIGTERM→SIGKILL escalation ladder via terminal_job_manage, and the hard rule that jobs die when the terminal-tools server restarts. Read before calling terminal_job_start, or right after terminal_exec auto-backgrounded.
metadata:
author: hive
type: preset-skill
@@ -9,36 +9,36 @@ metadata:
# Background job control
Background jobs are how you do things that take time without blocking your conversation. Three tools cover the surface: `shell_job_start`, `shell_job_logs`, `shell_job_manage`.
Background jobs are how you do things that take time without blocking your conversation. Three tools cover the surface: `terminal_job_start`, `terminal_job_logs`, `terminal_job_manage`.
## When to use a job
- Builds, deploys, long tests
- Processes you want to monitor (streaming a log file, a dev server)
- Anything that auto-backgrounded from `shell_exec` (you have a `job_id`; pivot to this skill's idioms)
- Anything that auto-backgrounded from `terminal_exec` (you have a `job_id`; pivot to this skill's idioms)
For one-shot work expected to finish quickly, `shell_exec` is simpler. The auto-promotion mechanic in `shell_exec` is your safety net — start with `shell_exec`, take over with this skill if needed.
For one-shot work expected to finish quickly, `terminal_exec` is simpler. The auto-promotion mechanic in `terminal_exec` is your safety net — start with `terminal_exec`, take over with this skill if needed.
## Lifecycle
```
shell_job_start(command, ...)
terminal_job_start(command, ...)
→ { job_id, pid, started_at }
shell_job_logs(job_id, since_offset=0, max_bytes=64000)
terminal_job_logs(job_id, since_offset=0, max_bytes=64000)
→ { data, offset, next_offset, status: "running"|"exited", exit_code, ... }
# Repeat with since_offset = previous next_offset until status == "exited"
# Or block once with wait_until_exit=True:
shell_job_logs(job_id, since_offset=N, wait_until_exit=True, wait_timeout_sec=60)
terminal_job_logs(job_id, since_offset=N, wait_until_exit=True, wait_timeout_sec=60)
→ blocks server-side until exit or timeout
```
After exit, the job is retained for inspection (`shell_job_manage(action="list")`) until evicted by FIFO (50 most recent exits kept).
After exit, the job is retained for inspection (`terminal_job_manage(action="list")`) until evicted by FIFO (50 most recent exits kept).
## Offset bookkeeping — the only rule that matters
The job's output lives in a 4 MB ring buffer per stream. Each call to `shell_job_logs` returns:
The job's output lives in a 4 MB ring buffer per stream. Each call to `terminal_job_logs` returns:
- `data` — bytes between `since_offset` and `next_offset`
- `next_offset` — pass this as `since_offset` on your next call
@@ -66,45 +66,45 @@ Pick `merge_stderr=False` when:
## Signal escalation
```
shell_job_manage(action="signal_int", job_id=...) # graceful (Ctrl-C-equivalent)
shell_job_manage(action="signal_term", job_id=...) # polite kill (SIGTERM)
shell_job_manage(action="signal_kill", job_id=...) # forced kill (SIGKILL, uncatchable)
terminal_job_manage(action="signal_int", job_id=...) # graceful (Ctrl-C-equivalent)
terminal_job_manage(action="signal_term", job_id=...) # polite kill (SIGTERM)
terminal_job_manage(action="signal_kill", job_id=...) # forced kill (SIGKILL, uncatchable)
```
The idiom: `signal_int` → wait 2-5s → `signal_term` → wait 2-5s → `signal_kill`. Most well-behaved processes handle SIGINT (graceful) and SIGTERM (cleanup, then exit). SIGKILL bypasses cleanup — use only when the process is truly unresponsive.
After signaling, check exit with `shell_job_logs(job_id, wait_until_exit=True, wait_timeout_sec=2)`.
After signaling, check exit with `terminal_job_logs(job_id, wait_until_exit=True, wait_timeout_sec=2)`.
## Stdin
```
shell_job_manage(action="stdin", job_id=..., data="some input\n")
shell_job_manage(action="close_stdin", job_id=...)
terminal_job_manage(action="stdin", job_id=..., data="some input\n")
terminal_job_manage(action="close_stdin", job_id=...)
```
For tools that read stdin to EOF, `close_stdin` after writing flushes them. For interactive tools that read line-by-line, just write each line.
## Take-over: when shell_exec auto-backgrounds
## Take-over: when terminal_exec auto-backgrounds
When `shell_exec` returned `auto_backgrounded: true, job_id: <X>`, the process is **already** in the JobManager with its output flowing into the ring buffer. Your transition is seamless:
When `terminal_exec` returned `auto_backgrounded: true, job_id: <X>`, the process is **already** in the JobManager with its output flowing into the ring buffer. Your transition is seamless:
```
# Already saw the start of output in shell_exec's stdout/stderr.
# Already saw the start of output in terminal_exec's stdout/stderr.
# Pick up reading where the env left off — use the byte count of the
# initial stdout as your since_offset, OR just request tail output:
shell_job_logs(job_id="job_xxx", tail=True, max_bytes=64000)
terminal_job_logs(job_id="job_xxx", tail=True, max_bytes=64000)
```
Or block until exit and grab everything:
```
shell_job_logs(job_id="job_xxx", since_offset=0, wait_until_exit=True, wait_timeout_sec=120)
terminal_job_logs(job_id="job_xxx", since_offset=0, wait_until_exit=True, wait_timeout_sec=120)
```
## Hard rules
- **Jobs die when the server restarts.** The desktop runtime restarts shell-tools when Hive restarts. There's no re-attach. If you need durability, use `nohup` + `shell_exec` to detach into the system's process tree and track the PID yourself.
- **Server-wide hard cap on concurrent jobs** (`SHELL_TOOLS_MAX_JOBS`, default 32). Past the cap, `shell_job_start` returns an error. Wait for jobs to exit or kill old ones.
- **Jobs die when the server restarts.** The desktop runtime restarts terminal-tools when Hive restarts. There's no re-attach. If you need durability, use `nohup` + `terminal_exec` to detach into the system's process tree and track the PID yourself.
- **Server-wide hard cap on concurrent jobs** (`TERMINAL_TOOLS_MAX_JOBS`, default 32). Past the cap, `terminal_job_start` returns an error. Wait for jobs to exit or kill old ones.
- **No cross-restart output.** Output handles and ring buffers are in-memory only.
See `references/signals.md` for the full signal catalog.
@@ -1,6 +1,6 @@
# Signal reference
shell_job_manage exposes six signals via the action name.
terminal_job_manage exposes six signals via the action name.
| Action | Signal | Number | Purpose | Catchable? |
|---|---|---|---|---|
@@ -15,7 +15,7 @@ shell_job_manage exposes six signals via the action name.
```
1. signal_int (Ctrl-C — graceful)
2. wait 2-5s, check status with shell_job_logs(wait_until_exit=True, wait_timeout_sec=3)
2. wait 2-5s, check status with terminal_job_logs(wait_until_exit=True, wait_timeout_sec=3)
3. if still running: signal_term (cleanup-then-exit)
4. wait 2-5s
5. if still running: signal_kill (forced)
@@ -32,7 +32,7 @@ These are application-defined. Read the target's docs first. Common:
## Reading exit codes after a signal
When a job exits via signal, `shell_job_logs` returns `exit_code: -N` (subprocess convention) where `abs(N)` is the signal number. The shell convention `128 + N` doesn't apply to the JobManager — that's for shell-spawned children.
When a job exits via signal, `terminal_job_logs` returns `exit_code: -N` (subprocess convention) where `abs(N)` is the signal number. The shell convention `128 + N` doesn't apply to the JobManager — that's for shell-spawned children.
| exit_code | Means |
|---|---|
@@ -1,6 +1,6 @@
---
name: hive.shell-tools-pty-sessions
description: Use when you need state across calls — building env vars, navigating with cd, driving REPLs (python -i, mysql, psql, node), or responding to interactive prompts (sudo password, ssh host-key confirmation, mysql connection). Teaches the prompt-sentinel exec pattern (default mode), raw I/O for REPLs (raw_send=True then read_only=True), the one-in-flight-per-session rule, and the close-or-leak-against-the-cap discipline. Bash on macOS — never zsh; explicit shell=/bin/zsh is rejected. Read before calling shell_pty_open.
name: hive.terminal-tools-pty-sessions
description: Use when you need state across calls — building env vars, navigating with cd, driving REPLs (python -i, mysql, psql, node), or responding to interactive prompts (sudo password, ssh host-key confirmation, mysql connection). Teaches the prompt-sentinel exec pattern (default mode), raw I/O for REPLs (raw_send=True then read_only=True), the one-in-flight-per-session rule, and the close-or-leak-against-the-cap discipline. Bash on macOS — never zsh; explicit shell=/bin/zsh is rejected. Read before calling terminal_pty_open.
metadata:
author: hive
type: preset-skill
@@ -15,7 +15,7 @@ PTY sessions are how you talk to interactive programs — programs that detect a
- You're driving a REPL (`python -i`, `mysql`, `psql`, `node`, `irb`)
- A program demands an interactive prompt (`sudo`, `ssh`, `npm login`, `gh auth login`)
For everything else, `shell_exec` is simpler. Sessions cost more (per-session bash process, ring buffer, idle-reaping bookkeeping) and have a hard cap (`SHELL_TOOLS_MAX_PTY`, default 8).
For everything else, `terminal_exec` is simpler. Sessions cost more (per-session bash process, ring buffer, idle-reaping bookkeeping) and have a hard cap (`TERMINAL_TOOLS_MAX_PTY`, default 8).
## Why PTY (and not subprocess pipes)
@@ -25,7 +25,7 @@ The cost: PTY output includes terminal escape codes (cursor moves, color codes).
## Bash on macOS — by deliberate policy
`shell_pty_open` always invokes `/bin/bash`, regardless of the user's `$SHELL`. macOS users: yes, even when zsh is your interactive default. This is the **shell-tools-foundations** policy applied to PTYs.
`terminal_pty_open` always invokes `/bin/bash`, regardless of the user's `$SHELL`. macOS users: yes, even when zsh is your interactive default. This is the **terminal-tools-foundations** policy applied to PTYs.
Reasons:
- zsh has command/builtin classes (`zmodload`, `=cmd` expansion, `zpty`, `ztcp`) that bypass bash-shaped security checks
@@ -34,12 +34,12 @@ Reasons:
The bash invocation uses `--norc --noprofile` so user dotfiles don't leak in. PS1 is set to a unique sentinel for prompt detection. PS2 is empty. PROMPT_COMMAND is empty.
## Three modes of `shell_pty_run`
## Three modes of `terminal_pty_run`
### 1. Default: send command, wait for prompt sentinel
```
shell_pty_run(session_id, command="ls -la")
terminal_pty_run(session_id, command="ls -la")
→ { output, prompt_after: True, ... }
```
@@ -48,7 +48,7 @@ The session writes `ls -la\n`, waits for the sentinel that its custom PS1 emits,
### 2. raw_send: send raw input, no waiting
```
shell_pty_run(session_id, command="print('hi')\n", raw_send=True)
terminal_pty_run(session_id, command="print('hi')\n", raw_send=True)
→ { bytes_sent: 12 }
```
@@ -59,7 +59,7 @@ After a `raw_send`, you typically follow with:
### 3. read_only: drain currently-buffered output
```
shell_pty_run(session_id, read_only=True, timeout_sec=2)
terminal_pty_run(session_id, read_only=True, timeout_sec=2)
→ { output: "hi\n", more: False, ... }
```
@@ -70,16 +70,16 @@ Reads whatever the session has accumulated since the last drain, with a brief se
When the command launches a program with its own prompt (Python REPL's `>>> `, mysql's `mysql> `, sudo's password prompt), the bash sentinel won't appear until the program exits. Override:
```
shell_pty_run(session_id, command="python3", expect=r">>>\s*$", timeout_sec=10)
terminal_pty_run(session_id, command="python3", expect=r">>>\s*$", timeout_sec=10)
→ output up to and including ">>>", then control returns
```
For sudo:
```
shell_pty_run(session_id, command="sudo -k && sudo whoami", expect=r"[Pp]assword:")
shell_pty_run(session_id, command="<password>", raw_send=True, command="<password>\n")
shell_pty_run(session_id, read_only=True, timeout_sec=5)
terminal_pty_run(session_id, command="sudo -k && sudo whoami", expect=r"[Pp]assword:")
terminal_pty_run(session_id, command="<password>", raw_send=True, command="<password>\n")
terminal_pty_run(session_id, read_only=True, timeout_sec=5)
```
(Treat passwords carefully — they end up in the ring buffer.)
@@ -87,10 +87,10 @@ shell_pty_run(session_id, read_only=True, timeout_sec=5)
## Always close
```
shell_pty_close(session_id)
terminal_pty_close(session_id)
```
Leaked sessions count against `SHELL_TOOLS_MAX_PTY` (default 8). Idle reaping happens lazily on every `_open` call (sessions inactive longer than `idle_timeout_sec`, default 1800s, are dropped) — but don't rely on it. Close when you're done.
Leaked sessions count against `TERMINAL_TOOLS_MAX_PTY` (default 8). Idle reaping happens lazily on every `_open` call (sessions inactive longer than `idle_timeout_sec`, default 1800s, are dropped) — but don't rely on it. Close when you're done.
For unresponsive sessions, `force=True` skips the graceful "exit" attempt and goes straight to SIGTERM/SIGKILL.
@@ -99,29 +99,29 @@ For unresponsive sessions, `force=True` skips the graceful "exit" attempt and go
### Stateful navigation
```
sid = shell_pty_open(cwd="/")
shell_pty_run(sid, command="cd /var/log")
shell_pty_run(sid, command="ls -la *.log | head")
shell_pty_close(sid)
sid = terminal_pty_open(cwd="/")
terminal_pty_run(sid, command="cd /var/log")
terminal_pty_run(sid, command="ls -la *.log | head")
terminal_pty_close(sid)
```
### Python REPL
```
sid = shell_pty_open()
shell_pty_run(sid, command="python3", expect=r">>>\s*$")
shell_pty_run(sid, command="x = 42", raw_send=True)
shell_pty_run(sid, command="print(x*x)\n", raw_send=True)
result = shell_pty_run(sid, read_only=True) # → "1764\n>>> "
shell_pty_run(sid, command="exit()", raw_send=True)
shell_pty_close(sid)
sid = terminal_pty_open()
terminal_pty_run(sid, command="python3", expect=r">>>\s*$")
terminal_pty_run(sid, command="x = 42", raw_send=True)
terminal_pty_run(sid, command="print(x*x)\n", raw_send=True)
result = terminal_pty_run(sid, read_only=True) # → "1764\n>>> "
terminal_pty_run(sid, command="exit()", raw_send=True)
terminal_pty_close(sid)
```
### ssh with host-key prompt
```
sid = shell_pty_open()
shell_pty_run(sid, command="ssh user@new-host", expect=r"\(yes/no.*\)\?")
shell_pty_run(sid, command="yes\n", raw_send=True)
shell_pty_run(sid, read_only=True, timeout_sec=10) # password prompt or login
sid = terminal_pty_open()
terminal_pty_run(sid, command="ssh user@new-host", expect=r"\(yes/no.*\)\?")
terminal_pty_run(sid, command="yes\n", raw_send=True)
terminal_pty_run(sid, read_only=True, timeout_sec=10) # password prompt or login
```
@@ -1,13 +1,13 @@
---
name: hive.shell-tools-troubleshooting
description: Read when a shell-tools call returned something surprising — empty stdout despite no error, exit_code is null, output_handle came back expired, "too many jobs" / "session busy" / "too many PTYs", warning was set unexpectedly, semantic_status disagrees with exit_code. Diagnostic recipes only — load on demand. Don't preload; the foundational skill covers the happy path.
name: hive.terminal-tools-troubleshooting
description: Read when a terminal-tools call returned something surprising — empty stdout despite no error, exit_code is null, output_handle came back expired, "too many jobs" / "session busy" / "too many PTYs", warning was set unexpectedly, semantic_status disagrees with exit_code. Diagnostic recipes only — load on demand. Don't preload; the foundational skill covers the happy path.
metadata:
author: hive
type: preset-skill
version: "1.0"
---
# Troubleshooting shell-tools
# Troubleshooting terminal-tools
Recipes for surprising results. Match the symptom to the section.
@@ -27,28 +27,28 @@ Possible causes:
| Auto-backgrounded | `auto_backgrounded: true, job_id: <X>` |
| Hard timeout, process killed | `timed_out: true` |
| Pre-spawn failure (command not found) | `error: ...` set, `pid: null` |
| Still running (in `shell_job_logs`) | `status: "running"` |
| Still running (in `terminal_job_logs`) | `status: "running"` |
## `output_handle` returned `expired: true`
5-minute TTL. Either (a) you waited too long, or (b) the store evicted it under memory pressure (64 MB total cap, LRU eviction). Re-run the command.
To reduce risk: paginate the handle as soon as you receive it, or use `shell_job_*` for huge outputs (4 MB ring buffer with offsets — no expiry).
To reduce risk: paginate the handle as soon as you receive it, or use `terminal_job_*` for huge outputs (4 MB ring buffer with offsets — no expiry).
## "too many jobs" / `JobLimitExceeded`
`SHELL_TOOLS_MAX_JOBS` (default 32) hit. Either:
- Wait for jobs to exit (poll with `shell_job_logs(wait_until_exit=True)`)
- Kill old jobs: `shell_job_manage(action="list")` to see what's running, then `signal_term` the abandoned ones
`TERMINAL_TOOLS_MAX_JOBS` (default 32) hit. Either:
- Wait for jobs to exit (poll with `terminal_job_logs(wait_until_exit=True)`)
- Kill old jobs: `terminal_job_manage(action="list")` to see what's running, then `signal_term` the abandoned ones
- Raise the cap via env (rare)
## "session busy"
A `shell_pty_run` was issued while another `_run` is in flight on the same session. PTY sessions are single-threaded conversations. Wait for the prior call to return, or open a second session.
A `terminal_pty_run` was issued while another `_run` is in flight on the same session. PTY sessions are single-threaded conversations. Wait for the prior call to return, or open a second session.
## "PTY cap reached"
`SHELL_TOOLS_MAX_PTY` (default 8) hit. Close idle sessions (`shell_pty_close`). Idle reaping is lazy; force it by opening — no, actually, opening throws when the cap is hit. Just close manually.
`TERMINAL_TOOLS_MAX_PTY` (default 8) hit. Close idle sessions (`terminal_pty_close`). Idle reaping is lazy; force it by opening — no, actually, opening throws when the cap is hit. Just close manually.
## `warning` is set, the command worked
@@ -68,24 +68,24 @@ The `semantic_message` field explains. Trust `semantic_status`, not raw `exit_co
Shouldn't happen. If it does, file a bug.
## `truncated_bytes_dropped > 0` in `shell_job_logs`
## `truncated_bytes_dropped > 0` in `terminal_job_logs`
Your `since_offset` was older than the ring buffer's floor — bytes evicted before you could read them. Either:
- Poll faster (lower latency between calls)
- Use `merge_stderr=True` (single 4 MB ring instead of 4 MB × 2)
- Accept the gap and move forward from `next_offset`
## `shell_pty_open` succeeds but the first `_run` times out
## `terminal_pty_open` succeeds but the first `_run` times out
The session may not have produced its first prompt sentinel within the 2-second startup window. Try:
- A `shell_pty_run(sid, read_only=True, timeout_sec=2)` to drain whatever's accumulated
- A noop command (`shell_pty_run(sid, command="true")`) to force a prompt cycle
- A `terminal_pty_run(sid, read_only=True, timeout_sec=2)` to drain whatever's accumulated
- A noop command (`terminal_pty_run(sid, command="true")`) to force a prompt cycle
Could also indicate the bash process died at startup — `shell_pty_run(sid, ...)` would then return `"session has exited"`.
Could also indicate the bash process died at startup — `terminal_pty_run(sid, ...)` would then return `"session has exited"`.
## `shell="/bin/zsh"` returned an error
By design. shell-tools is bash-only on POSIX. Use `shell=True` (default `/bin/bash`) or omit `shell=` to exec directly.
By design. terminal-tools is bash-only on POSIX. Use `shell=True` (default `/bin/bash`) or omit `shell=` to exec directly.
## A command in `shell=True` is interpreted differently than expected
+1 -1
View File
@@ -33,7 +33,7 @@ _BUNDLED_DIRS: tuple[Path, ...] = (
# (tool-name prefix, skill directory name, display name)
_TOOL_GATED_SKILLS: list[tuple[str, str, str]] = [
("browser_", "browser-automation", "hive.browser-automation"),
("shell_", "shell-tools-foundations", "hive.shell-tools-foundations"),
("terminal_", "terminal-tools-foundations", "hive.terminal-tools-foundations"),
]
_BODY_CACHE: dict[str, str] = {}
-19
View File
@@ -1,19 +0,0 @@
#!/usr/bin/env python3
"""shell-tools MCP server entry point.
Wired into _DEFAULT_LOCAL_SERVERS in core/framework/loader/mcp_registry.py
so that running ``uv run python shell_tools_server.py --stdio`` from this
directory starts the server. The cwd of ``tools/`` puts ``src/shell_tools``
on the import path via uv's workspace setup.
Usage:
uv run python shell_tools_server.py --stdio # for agent integration
uv run python shell_tools_server.py --port 4004 # HTTP for inspection
"""
from __future__ import annotations
from shell_tools.server import main
if __name__ == "__main__":
main()
-43
View File
@@ -1,43 +0,0 @@
"""shell-tools — Terminal/shell capabilities MCP server.
Exposes ten tools (prefix ``shell_*``) covering:
- Foreground exec with auto-promotion to background (``shell_exec``)
- Background job lifecycle (``shell_job_*``)
- Persistent PTY-backed bash sessions (``shell_pty_*``)
- Filesystem search (``shell_rg``, ``shell_find``)
- Truncation handle retrieval (``shell_output_get``)
Bash-only on POSIX. zsh is rejected at the shell-resolver level. See
``common/limits.py:_resolve_shell`` for the single enforcement point.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from fastmcp import FastMCP
def register_shell_tools(mcp: FastMCP) -> list[str]:
"""Register all ten shell-tools with the FastMCP server.
Returns the list of registered tool names so the caller can log /
smoke-test how many landed.
"""
from shell_tools.exec import register_exec_tools
from shell_tools.jobs.tools import register_job_tools
from shell_tools.output import register_output_tools
from shell_tools.pty.tools import register_pty_tools
from shell_tools.search.tools import register_search_tools
register_exec_tools(mcp)
register_job_tools(mcp)
register_pty_tools(mcp)
register_search_tools(mcp)
register_output_tools(mcp)
return [name for name in mcp._tool_manager._tools.keys() if name.startswith("shell_")]
__all__ = ["register_shell_tools"]
-6
View File
@@ -1,6 +0,0 @@
"""Background job management for shell-tools."""
from shell_tools.jobs.manager import JobManager, JobRecord, get_manager
from shell_tools.jobs.tools import register_job_tools
__all__ = ["JobManager", "JobRecord", "get_manager", "register_job_tools"]
+43
View File
@@ -0,0 +1,43 @@
"""terminal-tools — Terminal capabilities MCP server.
Exposes ten tools (prefix ``terminal_*``) covering:
- Foreground exec with auto-promotion to background (``terminal_exec``)
- Background job lifecycle (``terminal_job_*``)
- Persistent PTY-backed bash sessions (``terminal_pty_*``)
- Filesystem search (``terminal_rg``, ``terminal_find``)
- Truncation handle retrieval (``terminal_output_get``)
Bash-only on POSIX. zsh is rejected at the shell-resolver level. See
``common/limits.py:_resolve_shell`` for the single enforcement point.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from fastmcp import FastMCP
def register_terminal_tools(mcp: FastMCP) -> list[str]:
"""Register all ten terminal-tools with the FastMCP server.
Returns the list of registered tool names so the caller can log /
smoke-test how many landed.
"""
from terminal_tools.exec import register_exec_tools
from terminal_tools.jobs.tools import register_job_tools
from terminal_tools.output import register_output_tools
from terminal_tools.pty.tools import register_pty_tools
from terminal_tools.search.tools import register_search_tools
register_exec_tools(mcp)
register_job_tools(mcp)
register_pty_tools(mcp)
register_search_tools(mcp)
register_output_tools(mcp)
return [name for name in mcp._tool_manager._tools.keys() if name.startswith("terminal_")]
__all__ = ["register_terminal_tools"]
@@ -1,7 +1,7 @@
"""Shell resolution + resource limits.
The single place that decides which shell binary we invoke and how to
strip zsh-specific environment leakage. Per the shell-tools security
strip zsh-specific environment leakage. Per the terminal-tools security
stance (see ``destructive_warning.py`` neighbours), zsh constructs
(``zmodload``, ``=cmd``, ``zpty``, ``ztcp``) bypass bash-shaped
checks refusing zsh isn't aesthetic, it's a deliberate hardening
@@ -47,7 +47,7 @@ def _resolve_shell(shell: bool | str) -> str | None:
lower = shell.lower()
if "zsh" in lower:
raise ZshRefused(
f"shell={shell!r} rejected: shell-tools is bash-only on POSIX. "
f"shell={shell!r} rejected: terminal-tools is bash-only on POSIX. "
"Use shell=True (bash) or omit the shell parameter to exec directly. "
"This is a deliberate security stance — zsh has command/builtin "
"classes (zmodload, =cmd, zpty, ztcp) that bypass bash-shaped checks."
@@ -2,7 +2,7 @@
When an exec produces more output than the inline cap (default 256 KB),
the surplus is kept here under a short-lived handle. The agent passes
the handle to ``shell_output_get`` to paginate the rest. Handles
the handle to ``terminal_output_get`` to paginate the rest. Handles
expire after 5 minutes; total store size is capped at 64 MB with LRU
eviction so the server can't be DoS'd by a chatty subprocess.
@@ -9,9 +9,9 @@ from __future__ import annotations
from collections.abc import Sequence
from shell_tools.common.destructive_warning import get_warning
from shell_tools.common.output_store import get_store
from shell_tools.common.semantic_exit import classify
from terminal_tools.common.destructive_warning import get_warning
from terminal_tools.common.output_store import get_store
from terminal_tools.common.semantic_exit import classify
def _truncate_bytes(buf: bytes, max_bytes: int) -> tuple[str, int, str]:
@@ -51,11 +51,11 @@ def build_exec_envelope(
) -> dict:
"""Construct the standard exec envelope.
See ``shell-tools-foundations`` SKILL for the field semantics. The
See ``terminal-tools-foundations`` SKILL for the field semantics. The
inline ``stdout``/``stderr`` are decoded and trimmed; if either
overflows ``max_output_kb`` the *full* bytes are stashed in the
output store under ``output_handle`` for retrieval via
``shell_output_get``. Both streams share the same handle (with
``terminal_output_get``. Both streams share the same handle (with
``out_<hex>:stdout`` / ``out_<hex>:stderr`` suffixes) when both
overflow the agent uses the suffix to pick a stream.
"""
@@ -1,4 +1,4 @@
"""``shell_exec`` — foreground exec with auto-promotion to background.
"""``terminal_exec`` — foreground exec with auto-promotion to background.
The flagship tool. Most agent terminal interactions go through here:
fast commands (<30s) return inline with the standard envelope; longer
@@ -16,7 +16,7 @@ Implementation notes:
We hand them to JobManager which spawns pump threads to fill ring
buffers from that point on. The agent sees an envelope with
``auto_backgrounded=True, exit_code=None, job_id=<>`` and
transitions to ``shell_job_logs``. **There's no early-output loss**
transitions to ``terminal_job_logs``. **There's no early-output loss**
because the pumps start before we return from the tool call.
- For pure-foreground use (``auto_background_after_sec=0``), we
fall back to ``proc.communicate(timeout=timeout_sec)`` which has
@@ -31,13 +31,19 @@ import threading
import time
from typing import TYPE_CHECKING
from shell_tools.common.limits import (
from terminal_tools.common.limits import (
ZshRefused,
_resolve_shell,
coerce_limits,
make_preexec_fn,
sanitized_env,
)
from terminal_tools.common.ring_buffer import RingBuffer
from terminal_tools.common.truncation import build_exec_envelope
from terminal_tools.jobs.manager import JobLimitExceeded, get_manager
if TYPE_CHECKING:
from fastmcp import FastMCP
# Tokens that indicate the user passed a shell-syntax command (pipes,
@@ -50,17 +56,11 @@ from shell_tools.common.limits import (
_SHELL_METACHARS: frozenset[str] = frozenset(
{"|", "&&", "||", ";", ">", "<", ">>", "<<", "&", "2>", "2>&1", "|&"}
)
from shell_tools.common.ring_buffer import RingBuffer
from shell_tools.common.truncation import build_exec_envelope
from shell_tools.jobs.manager import JobLimitExceeded, get_manager
if TYPE_CHECKING:
from fastmcp import FastMCP
def register_exec_tools(mcp: FastMCP) -> None:
@mcp.tool()
def shell_exec(
def terminal_exec(
command: str,
cwd: str | None = None,
env: dict[str, str] | None = None,
@@ -75,7 +75,7 @@ def register_exec_tools(mcp: FastMCP) -> None:
Past auto_background_after_sec, the call auto-promotes to a background
job and returns immediately with `auto_backgrounded=True, job_id=...`
poll with shell_job_logs(job_id, since_offset=...) to read the rest.
poll with terminal_job_logs(job_id, since_offset=...) to read the rest.
Set auto_background_after_sec=0 to force pure foreground (kill on
timeout_sec).
@@ -98,9 +98,9 @@ def register_exec_tools(mcp: FastMCP) -> None:
limits: Optional setrlimit caps. Keys: cpu_sec, rss_mb,
fsize_mb, nofile.
max_output_kb: Inline output cap. Overflow stashes to an
output_handle for retrieval via shell_output_get.
output_handle for retrieval via terminal_output_get.
Returns the standard envelope: see `shell-tools-foundations` skill.
Returns the standard envelope: see `terminal-tools-foundations` skill.
"""
# Auto-detect shell-syntax commands. If the agent passes
# ``shell=False`` (the default) but the command contains a pipe,
@@ -0,0 +1,6 @@
"""Background job management for terminal-tools."""
from terminal_tools.jobs.manager import JobManager, JobRecord, get_manager
from terminal_tools.jobs.tools import register_job_tools
__all__ = ["JobManager", "JobRecord", "get_manager", "register_job_tools"]
@@ -1,17 +1,17 @@
"""Background job manager.
Owns the long-lived ``Popen`` instances backing ``shell_job_*`` and
``shell_exec`` auto-promotion. Each job has up to two ring buffers
Owns the long-lived ``Popen`` instances backing ``terminal_job_*`` and
``terminal_exec`` auto-promotion. Each job has up to two ring buffers
(stdout / stderr, or one merged) fed by background pump threads.
Design notes:
- We don't use asyncio here. FastMCP's tool handlers run in a worker
thread; subprocess + threads compose more naturally with that
model than asyncio Subprocess (which would need its own loop).
- ``shell_exec`` "promotes" by adopting an already-running Popen
- ``terminal_exec`` "promotes" by adopting an already-running Popen
into the manager it doesn't re-spawn. The pump threads were
already filling buffers in the exec path.
- Hard concurrency cap (env: ``SHELL_TOOLS_MAX_JOBS``, default 32).
- Hard concurrency cap (env: ``TERMINAL_TOOLS_MAX_JOBS``, default 32).
The cap is the only non-bypassable safety pin per the soft-
guardrails design.
- On server shutdown the lifespan hook calls ``shutdown_all()``
@@ -31,11 +31,11 @@ from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import Any
from shell_tools.common.ring_buffer import RingBuffer
from terminal_tools.common.ring_buffer import RingBuffer
_MAX_JOBS_DEFAULT = 32
_DEFAULT_RING_BYTES = 4 * 1024 * 1024
_RECENT_EXIT_KEEP = 50 # exited jobs we still surface to ``shell_job_manage(action="list")``
_RECENT_EXIT_KEEP = 50 # exited jobs we still surface to ``terminal_job_manage(action="list")``
@dataclass(slots=True)
@@ -53,7 +53,7 @@ class JobRecord:
exited_at: float | None = None
exit_code: int | None = None
signaled: bool = False
# Adopted=True when the job started life as a foreground shell_exec
# Adopted=True when the job started life as a foreground terminal_exec
# and was promoted past the auto-background budget.
adopted: bool = False
@@ -88,7 +88,7 @@ class JobLimitExceeded(RuntimeError):
class JobManager:
def __init__(self, max_jobs: int | None = None, ring_bytes: int = _DEFAULT_RING_BYTES):
self._max_jobs = max_jobs or int(os.getenv("SHELL_TOOLS_MAX_JOBS", str(_MAX_JOBS_DEFAULT)))
self._max_jobs = max_jobs or int(os.getenv("TERMINAL_TOOLS_MAX_JOBS", str(_MAX_JOBS_DEFAULT)))
self._ring_bytes = ring_bytes
self._jobs: dict[str, JobRecord] = {}
# FIFO of recently-exited job_ids so list/inspect can still
@@ -116,8 +116,8 @@ class JobManager:
"""Spawn a process and start pumping its output into ring buffers."""
if self.active_count() >= self._max_jobs:
raise JobLimitExceeded(
f"shell-tools job cap reached ({self._max_jobs}). "
"Wait for a job to finish or raise SHELL_TOOLS_MAX_JOBS."
f"terminal-tools job cap reached ({self._max_jobs}). "
"Wait for a job to finish or raise TERMINAL_TOOLS_MAX_JOBS."
)
proc = self._spawn(command, cwd=cwd, env=env, shell=shell, merge_stderr=merge_stderr, preexec_fn=preexec_fn)
@@ -137,10 +137,10 @@ class JobManager:
) -> JobRecord:
"""Adopt a Popen that's already running with pumps in flight.
Used by ``shell_exec`` for auto-promotion: the foreground path
Used by ``terminal_exec`` for auto-promotion: the foreground path
had already started pump threads filling its own ring buffers.
We hand the buffers + pumps over to the manager so the agent
can resume reading via ``shell_job_logs``.
can resume reading via ``terminal_job_logs``.
"""
if self.active_count() >= self._max_jobs:
# Mid-call cap exceeded — kill and report.
@@ -149,7 +149,7 @@ class JobManager:
except Exception:
pass
raise JobLimitExceeded(
f"shell-tools job cap reached ({self._max_jobs}); foreground exec was killed during auto-promotion."
f"terminal-tools job cap reached ({self._max_jobs}); foreground exec was killed during auto-promotion."
)
record = self._wrap(
proc,
@@ -259,7 +259,7 @@ class JobManager:
) -> subprocess.Popen[bytes]:
# Resolve shell: a string shell is coerced to ``[<shell>, "-c", command]``,
# bool=True means /bin/bash with the same shape.
from shell_tools.common.limits import _resolve_shell
from terminal_tools.common.limits import _resolve_shell
resolved = _resolve_shell(shell)
if resolved is not None:
@@ -1,5 +1,5 @@
"""Job-control MCP tools: ``shell_job_start``, ``shell_job_logs``,
``shell_job_manage``.
"""Job-control MCP tools: ``terminal_job_start``, ``terminal_job_logs``,
``terminal_job_manage``.
Three tools, not seven: ``_logs`` rolls in status + wait, ``_manage``
covers list + signals + stdin so the agent has fewer tool names to
@@ -12,8 +12,8 @@ from __future__ import annotations
import signal
from typing import TYPE_CHECKING, Any
from shell_tools.common.limits import coerce_limits, make_preexec_fn, sanitized_env
from shell_tools.jobs.manager import JobLimitExceeded, get_manager
from terminal_tools.common.limits import coerce_limits, make_preexec_fn, sanitized_env
from terminal_tools.jobs.manager import JobLimitExceeded, get_manager
if TYPE_CHECKING:
from fastmcp import FastMCP
@@ -33,7 +33,7 @@ def register_job_tools(mcp: FastMCP) -> None:
manager = get_manager()
@mcp.tool()
def shell_job_start(
def terminal_job_start(
command: str,
cwd: str | None = None,
env: dict[str, str] | None = None,
@@ -42,11 +42,11 @@ def register_job_tools(mcp: FastMCP) -> None:
name: str | None = None,
limits: dict[str, int] | None = None,
) -> dict:
"""Spawn a background process. Returns a job_id you poll with shell_job_logs.
"""Spawn a background process. Returns a job_id you poll with terminal_job_logs.
Use this when work might run >1 minute, when you want to keep doing
other things while it runs, or when you need to stream logs as they
arrive. Jobs die when the shell-tools server restarts they are NOT
arrive. Jobs die when the terminal-tools server restarts they are NOT
persistent across reboots.
Args:
@@ -60,7 +60,7 @@ def register_job_tools(mcp: FastMCP) -> None:
single ring buffer. Convenient for log-shaped output where
ordering matters.
shell: True to invoke /bin/bash -c. Refuses zsh.
name: Optional human label surfaced in shell_job_manage(action="list").
name: Optional human label surfaced in terminal_job_manage(action="list").
limits: Optional resource caps applied via setrlimit before exec.
Keys: cpu_sec, rss_mb, fsize_mb, nofile.
@@ -101,7 +101,7 @@ def register_job_tools(mcp: FastMCP) -> None:
return {"error": f"{type(e).__name__}: {e}"}
@mcp.tool()
def shell_job_logs(
def terminal_job_logs(
job_id: str,
stream: str = "stdout",
since_offset: int = 0,
@@ -117,7 +117,7 @@ def register_job_tools(mcp: FastMCP) -> None:
wait_timeout_sec elapses, then returns logs and final status.
Args:
job_id: From shell_job_start (or auto-promoted from shell_exec).
job_id: From terminal_job_start (or auto-promoted from terminal_exec).
stream: "stdout" | "stderr" | "merged". Use "merged" only when the
job was started with merge_stderr=True.
since_offset: Absolute byte offset to start reading from. Pass 0
@@ -163,7 +163,7 @@ def register_job_tools(mcp: FastMCP) -> None:
}
@mcp.tool()
def shell_job_manage(
def terminal_job_manage(
action: str,
job_id: str | None = None,
data: str | None = None,
@@ -1,10 +1,10 @@
"""``shell_output_get`` — retrieve truncated output via handle."""
"""``terminal_output_get`` — retrieve truncated output via handle."""
from __future__ import annotations
from typing import TYPE_CHECKING
from shell_tools.common.output_store import get_store
from terminal_tools.common.output_store import get_store
if TYPE_CHECKING:
from fastmcp import FastMCP
@@ -12,14 +12,14 @@ if TYPE_CHECKING:
def register_output_tools(mcp: FastMCP) -> None:
@mcp.tool()
def shell_output_get(
def terminal_output_get(
output_handle: str,
since_offset: int = 0,
max_kb: int = 64,
) -> dict:
"""Retrieve a slice of truncated output by handle.
When shell_exec or shell_job_logs returns more output than fits inline,
When terminal_exec or terminal_job_logs returns more output than fits inline,
you'll see `output_handle: "out_<hex>"`. Pass it here with successive
offsets to paginate. The full output is preserved (combined stdout+stderr
with `--- stdout ---` / `--- stderr ---` separators) for 5 minutes.
@@ -1,5 +1,5 @@
"""Persistent PTY-backed shell sessions."""
from shell_tools.pty.tools import register_pty_tools
from terminal_tools.pty.tools import register_pty_tools
__all__ = ["register_pty_tools"]
@@ -33,8 +33,8 @@ import threading
import time
import uuid
from shell_tools.common.limits import _resolve_shell, sanitized_env
from shell_tools.common.ring_buffer import RingBuffer
from terminal_tools.common.limits import _resolve_shell, sanitized_env
from terminal_tools.common.ring_buffer import RingBuffer
_BUF_BYTES = 2 * 1024 * 1024
@@ -63,7 +63,7 @@ class PtySession:
self.session_id = "pty_" + uuid.uuid4().hex[:10]
self.shell_path = _resolve_shell(shell) or "/bin/bash"
self._sentinel_token = uuid.uuid4().hex
self._sentinel = f"__SHELLTOOLS_PROMPT_{self._sentinel_token}__"
self._sentinel = f"__TERMINALTOOLS_PROMPT_{self._sentinel_token}__"
self._sentinel_re = re.compile(re.escape(self._sentinel))
# Build env: zsh leakage stripped, prompt set to our sentinel.
@@ -89,7 +89,7 @@ class PtySession:
argv = [self.shell_path, "--norc", "--noprofile", "-i"]
os.execve(self.shell_path, argv, merged_env)
except Exception as e: # pragma: no cover — child exec
os.write(2, f"shell-tools pty: exec failed: {e}\n".encode())
os.write(2, f"terminal-tools pty: exec failed: {e}\n".encode())
os._exit(127)
# Parent
@@ -1,6 +1,6 @@
"""Three PTY tools: ``shell_pty_open``, ``shell_pty_run``, ``shell_pty_close``.
"""Three PTY tools: ``terminal_pty_open``, ``terminal_pty_run``, ``terminal_pty_close``.
Per-server hard cap on concurrent sessions (env: ``SHELL_TOOLS_MAX_PTY``,
Per-server hard cap on concurrent sessions (env: ``TERMINAL_TOOLS_MAX_PTY``,
default 8) prevents PTY exhaustion. Idle sessions older than
``idle_timeout_sec`` are reaped lazily on every ``_open`` so an
abandoned session can't leak a bash forever.
@@ -14,7 +14,7 @@ import threading
import time
from typing import TYPE_CHECKING
from shell_tools.common.limits import ZshRefused
from terminal_tools.common.limits import ZshRefused
if TYPE_CHECKING:
from fastmcp import FastMCP
@@ -27,7 +27,7 @@ class _PtyRegistry:
def __init__(self):
self._sessions: dict[str, PtySession] = {} # noqa: F821
self._lock = threading.Lock()
self._max = int(os.getenv("SHELL_TOOLS_MAX_PTY", str(_MAX_PTY_DEFAULT)))
self._max = int(os.getenv("TERMINAL_TOOLS_MAX_PTY", str(_MAX_PTY_DEFAULT)))
def reap_idle(self) -> None:
"""Drop sessions whose idle time exceeded their idle_timeout_sec."""
@@ -55,8 +55,8 @@ class _PtyRegistry:
if len(self._sessions) >= self._max:
# Caller should have reaped first; treat as cap.
raise RuntimeError(
f"shell-tools PTY cap reached ({self._max}). "
"Close idle sessions or raise SHELL_TOOLS_MAX_PTY."
f"terminal-tools PTY cap reached ({self._max}). "
"Close idle sessions or raise TERMINAL_TOOLS_MAX_PTY."
)
self._sessions[sess.session_id] = sess
@@ -95,31 +95,31 @@ def register_pty_tools(mcp: FastMCP) -> None:
# Register stub tools that report unsupported; keeps the tool
# surface uniform across platforms even when PTY is unavailable.
@mcp.tool()
def shell_pty_open(*args, **kwargs) -> dict:
def terminal_pty_open(*args, **kwargs) -> dict:
"""Persistent PTY-backed bash session. POSIX-only.
Windows is not supported in v1 use shell_exec / shell_job_*
Windows is not supported in v1 use terminal_exec / terminal_job_*
for non-interactive work. The PTY tools require stdlib pty,
which exists only on Linux + macOS.
"""
return {"error": "shell_pty_* tools are POSIX-only; not supported on Windows"}
return {"error": "terminal_pty_* tools are POSIX-only; not supported on Windows"}
@mcp.tool()
def shell_pty_run(*args, **kwargs) -> dict: # noqa: D401
def terminal_pty_run(*args, **kwargs) -> dict: # noqa: D401
"""Persistent PTY-backed bash session. POSIX-only."""
return {"error": "shell_pty_* tools are POSIX-only; not supported on Windows"}
return {"error": "terminal_pty_* tools are POSIX-only; not supported on Windows"}
@mcp.tool()
def shell_pty_close(*args, **kwargs) -> dict: # noqa: D401
def terminal_pty_close(*args, **kwargs) -> dict: # noqa: D401
"""Persistent PTY-backed bash session. POSIX-only."""
return {"error": "shell_pty_* tools are POSIX-only; not supported on Windows"}
return {"error": "terminal_pty_* tools are POSIX-only; not supported on Windows"}
return
from shell_tools.pty.session import PtySession, SessionBusy
from terminal_tools.pty.session import PtySession, SessionBusy
@mcp.tool()
def shell_pty_open(
def terminal_pty_open(
cwd: str | None = None,
env: dict[str, str] | None = None,
cols: int = 120,
@@ -130,11 +130,11 @@ def register_pty_tools(mcp: FastMCP) -> None:
Use a session when you need state across calls building env vars,
navigating with cd, driving REPLs, or responding to interactive
prompts (sudo, ssh, mysql). For one-shot work, use shell_exec
prompts (sudo, ssh, mysql). For one-shot work, use terminal_exec
instead.
The session runs vanilla bash (--norc --noprofile) so dotfiles
don't surprise you. A unique PS1 sentinel is set so shell_pty_run
don't surprise you. A unique PS1 sentinel is set so terminal_pty_run
can unambiguously detect command completion. macOS users: this
is /bin/bash, not zsh, by deliberate policy explicit
shell="/bin/zsh" overrides are rejected.
@@ -166,7 +166,7 @@ def register_pty_tools(mcp: FastMCP) -> None:
}
@mcp.tool()
def shell_pty_run(
def terminal_pty_run(
session_id: str,
command: str | None = None,
expect: str | None = None,
@@ -187,7 +187,7 @@ def register_pty_tools(mcp: FastMCP) -> None:
Typically follows raw_send.
Args:
session_id: From shell_pty_open.
session_id: From terminal_pty_open.
command: The text to send. None when read_only=True.
expect: Regex to wait for INSTEAD of the default prompt sentinel.
Useful when the command launches a REPL with its own prompt.
@@ -222,12 +222,12 @@ def register_pty_tools(mcp: FastMCP) -> None:
return {"error": str(e)}
@mcp.tool()
def shell_pty_close(session_id: str, force: bool = False) -> dict:
def terminal_pty_close(session_id: str, force: bool = False) -> dict:
"""Terminate a PTY session. Always do this when you're done — leaked
sessions count against the per-server PTY cap.
Args:
session_id: From shell_pty_open.
session_id: From terminal_pty_open.
force: Skip the graceful "exit\\n" attempt and SIGTERM/SIGKILL.
Returns: {exit_code, final_output, already_closed}
@@ -1,5 +1,5 @@
"""Filesystem search tools (rg + find)."""
from shell_tools.search.tools import register_search_tools
from terminal_tools.search.tools import register_search_tools
__all__ = ["register_search_tools"]
@@ -1,4 +1,4 @@
"""``shell_rg`` and ``shell_find`` — structured wrappers over ripgrep / find.
"""``terminal_rg`` and ``terminal_find`` — structured wrappers over ripgrep / find.
Distinct from ``files-tools.search_files`` (project-relative,
code-editor-tuned) these accept arbitrary paths and surface the
@@ -23,7 +23,7 @@ _MAX_OUTPUT_BYTES = 256 * 1024
def register_search_tools(mcp: FastMCP) -> None:
@mcp.tool()
def shell_rg(
def terminal_rg(
pattern: str,
path: str = ".",
glob: str | None = None,
@@ -128,7 +128,7 @@ def register_search_tools(mcp: FastMCP) -> None:
}
@mcp.tool()
def shell_find(
def terminal_find(
path: str,
name: str | None = None,
iname: str | None = None,
@@ -141,7 +141,7 @@ def register_search_tools(mcp: FastMCP) -> None:
) -> dict:
"""Run `find` with structured predicates.
For tree views or stat-like info on a single path, use shell_exec
For tree views or stat-like info on a single path, use terminal_exec
("ls -la", "tree -L 2", "stat foo"). This tool is for predicate-driven
searches (find me .log files modified in the last 7 days bigger than 1MB).
@@ -1,8 +1,8 @@
"""shell-tools FastMCP server — entry module.
"""terminal-tools FastMCP server — entry module.
Run via:
uv run python -m shell_tools.server --stdio
uv run python shell_tools_server.py --stdio (preferred, see _DEFAULT_LOCAL_SERVERS)
uv run python -m terminal_tools.server --stdio
uv run python terminal_tools_server.py --stdio (preferred, see _DEFAULT_LOCAL_SERVERS)
"""
from __future__ import annotations
@@ -23,7 +23,7 @@ def setup_logger() -> None:
if not logger.handlers:
stream = sys.stderr if "--stdio" in sys.argv else sys.stdout
handler = logging.StreamHandler(stream)
handler.setFormatter(logging.Formatter("[shell-tools] %(message)s"))
handler.setFormatter(logging.Formatter("[terminal-tools] %(message)s"))
logger.addHandler(handler)
logger.setLevel(logging.INFO)
@@ -45,9 +45,9 @@ if "--stdio" in sys.argv:
from fastmcp import FastMCP # noqa: E402
from shell_tools import register_shell_tools # noqa: E402
from shell_tools.jobs.manager import get_manager # noqa: E402
from shell_tools.pty.tools import get_registry as get_pty_registry # noqa: E402
from terminal_tools import register_terminal_tools # noqa: E402
from terminal_tools.jobs.manager import get_manager # noqa: E402
from terminal_tools.pty.tools import get_registry as get_pty_registry # noqa: E402
@asynccontextmanager
@@ -93,7 +93,7 @@ async def _parent_watchdog(parent_pid: int) -> None:
while True:
await asyncio.sleep(2.0)
if not _is_alive(parent_pid):
logger.warning("Parent PID %d gone — shell-tools exiting", parent_pid)
logger.warning("Parent PID %d gone — terminal-tools exiting", parent_pid)
try:
get_manager().shutdown_all(grace_sec=1.0)
except Exception:
@@ -119,25 +119,25 @@ def _atexit_reap() -> None:
atexit.register(_atexit_reap)
mcp = FastMCP("shell-tools", lifespan=_lifespan)
mcp = FastMCP("terminal-tools", lifespan=_lifespan)
def main() -> None:
parser = argparse.ArgumentParser(description="shell-tools MCP server")
parser.add_argument("--port", type=int, default=int(os.getenv("SHELL_TOOLS_PORT", "4004")))
parser = argparse.ArgumentParser(description="terminal-tools MCP server")
parser.add_argument("--port", type=int, default=int(os.getenv("TERMINAL_TOOLS_PORT", "4004")))
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--stdio", action="store_true")
args = parser.parse_args()
tools = register_shell_tools(mcp)
tools = register_terminal_tools(mcp)
if not args.stdio:
logger.info("Registered %d shell-tools: %s", len(tools), tools)
logger.info("Registered %d terminal-tools: %s", len(tools), tools)
if args.stdio:
mcp.run(transport="stdio")
else:
logger.info("Starting shell-tools on %s:%d", args.host, args.port)
logger.info("Starting terminal-tools on %s:%d", args.host, args.port)
asyncio.run(mcp.run_async(transport="http", host=args.host, port=args.port))
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env python3
"""terminal-tools MCP server entry point.
Wired into _DEFAULT_LOCAL_SERVERS in core/framework/loader/mcp_registry.py
so that running ``uv run python terminal_tools_server.py --stdio`` from this
directory starts the server. The cwd of ``tools/`` puts ``src/terminal_tools``
on the import path via uv's workspace setup.
Usage:
uv run python terminal_tools_server.py --stdio # for agent integration
uv run python terminal_tools_server.py --port 4004 # HTTP for inspection
"""
from __future__ import annotations
from terminal_tools.server import main
if __name__ == "__main__":
main()
-33
View File
@@ -1,33 +0,0 @@
"""Smoke test: load the server module, register tools, assert all 10 land."""
from __future__ import annotations
EXPECTED_TOOLS = {
"shell_exec",
"shell_job_start",
"shell_job_logs",
"shell_job_manage",
"shell_pty_open",
"shell_pty_run",
"shell_pty_close",
"shell_rg",
"shell_find",
"shell_output_get",
}
def test_register_shell_tools_lands_all_ten(mcp):
from shell_tools import register_shell_tools
names = register_shell_tools(mcp)
assert set(names) == EXPECTED_TOOLS, (
f"missing: {EXPECTED_TOOLS - set(names)}, extra: {set(names) - EXPECTED_TOOLS}"
)
def test_all_tools_have_shell_prefix(mcp):
from shell_tools import register_shell_tools
names = register_shell_tools(mcp)
for n in names:
assert n.startswith("shell_"), f"tool {n!r} missing shell_ prefix"
@@ -1,4 +1,4 @@
"""shell_exec — envelope shape, semantic exits, warnings, auto-promotion."""
"""terminal_exec — envelope shape, semantic exits, warnings, auto-promotion."""
from __future__ import annotations
@@ -9,10 +9,10 @@ import pytest
@pytest.fixture
def exec_tool(mcp):
from shell_tools.exec import register_exec_tools
from terminal_tools.exec import register_exec_tools
register_exec_tools(mcp)
return mcp._tool_manager._tools["shell_exec"].fn
return mcp._tool_manager._tools["terminal_exec"].fn
def test_envelope_shape_simple_echo(exec_tool):
@@ -85,7 +85,7 @@ def test_zsh_refused(exec_tool):
def test_zsh_string_refused():
"""Calling _resolve_shell with zsh path raises ZshRefused."""
from shell_tools.common.limits import ZshRefused, _resolve_shell
from terminal_tools.common.limits import ZshRefused, _resolve_shell
with pytest.raises(ZshRefused):
_resolve_shell("/bin/zsh")
@@ -108,10 +108,10 @@ def test_truncation_via_handle(exec_tool):
def test_output_handle_round_trip(exec_tool, mcp):
from shell_tools.output import register_output_tools
from terminal_tools.output import register_output_tools
register_output_tools(mcp)
output_get = mcp._tool_manager._tools["shell_output_get"].fn
output_get = mcp._tool_manager._tools["terminal_output_get"].fn
result = exec_tool(
command="python3 -c 'import sys; sys.stdout.write(\"x\" * 300_000)'",
@@ -202,7 +202,7 @@ def test_explicit_shell_true_unchanged(exec_tool):
def test_auto_promotion(exec_tool, mcp):
"""Past auto_background_after_sec, the call returns auto_backgrounded=True."""
from shell_tools.jobs.tools import register_job_tools
from terminal_tools.jobs.tools import register_job_tools
register_job_tools(mcp)
# Use a 1s budget so the test runs quickly.
@@ -218,8 +218,8 @@ def test_auto_promotion(exec_tool, mcp):
assert result["exit_code"] is None
assert elapsed < 3, "auto-promotion should return quickly past the budget"
# Take over via shell_job_logs
job_logs = mcp._tool_manager._tools["shell_job_logs"].fn
# Take over via terminal_job_logs
job_logs = mcp._tool_manager._tools["terminal_job_logs"].fn
log_result = job_logs(job_id=result["job_id"], wait_until_exit=True, wait_timeout_sec=10)
assert log_result["status"] == "exited"
assert log_result["exit_code"] == 0
@@ -9,13 +9,13 @@ import pytest
@pytest.fixture
def job_tools(mcp):
from shell_tools.jobs.tools import register_job_tools
from terminal_tools.jobs.tools import register_job_tools
register_job_tools(mcp)
return {
"start": mcp._tool_manager._tools["shell_job_start"].fn,
"logs": mcp._tool_manager._tools["shell_job_logs"].fn,
"manage": mcp._tool_manager._tools["shell_job_manage"].fn,
"start": mcp._tool_manager._tools["terminal_job_start"].fn,
"logs": mcp._tool_manager._tools["terminal_job_logs"].fn,
"manage": mcp._tool_manager._tools["terminal_job_manage"].fn,
}
@@ -12,27 +12,27 @@ pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="PTY is POSIX-on
@pytest.fixture
def pty_tools(mcp):
from shell_tools.pty.tools import register_pty_tools
from terminal_tools.pty.tools import register_pty_tools
register_pty_tools(mcp)
return {
"open": mcp._tool_manager._tools["shell_pty_open"].fn,
"run": mcp._tool_manager._tools["shell_pty_run"].fn,
"close": mcp._tool_manager._tools["shell_pty_close"].fn,
"open": mcp._tool_manager._tools["terminal_pty_open"].fn,
"run": mcp._tool_manager._tools["terminal_pty_run"].fn,
"close": mcp._tool_manager._tools["terminal_pty_close"].fn,
}
def test_open_close_basic(pty_tools):
opened = pty_tools["open"]()
assert "session_id" in opened
assert opened["shell"] == "/bin/bash", "shell-tools must default to bash, not zsh"
assert opened["shell"] == "/bin/bash", "terminal-tools must default to bash, not zsh"
closed = pty_tools["close"](session_id=opened["session_id"])
assert closed.get("already_closed") in (False, None)
def test_bash_on_darwin():
"""Even on macOS, the resolved shell is /bin/bash, not /bin/zsh."""
from shell_tools.common.limits import _resolve_shell
from terminal_tools.common.limits import _resolve_shell
assert _resolve_shell(True) == "/bin/bash"
@@ -1,4 +1,4 @@
"""shell_rg + shell_find — basic functionality, structured output."""
"""terminal_rg + terminal_find — basic functionality, structured output."""
from __future__ import annotations
@@ -9,12 +9,12 @@ import pytest
@pytest.fixture
def search_tools(mcp):
from shell_tools.search.tools import register_search_tools
from terminal_tools.search.tools import register_search_tools
register_search_tools(mcp)
return {
"rg": mcp._tool_manager._tools["shell_rg"].fn,
"find": mcp._tool_manager._tools["shell_find"].fn,
"rg": mcp._tool_manager._tools["terminal_rg"].fn,
"find": mcp._tool_manager._tools["terminal_find"].fn,
}
@@ -6,7 +6,7 @@ import pytest
def test_resolve_shell_rejects_zsh():
from shell_tools.common.limits import ZshRefused, _resolve_shell
from terminal_tools.common.limits import ZshRefused, _resolve_shell
for path in ("/bin/zsh", "/usr/bin/zsh", "/usr/local/bin/zsh", "ZSH"):
with pytest.raises(ZshRefused):
@@ -14,7 +14,7 @@ def test_resolve_shell_rejects_zsh():
def test_resolve_shell_accepts_bash():
from shell_tools.common.limits import _resolve_shell
from terminal_tools.common.limits import _resolve_shell
assert _resolve_shell(True) == "/bin/bash"
assert _resolve_shell("/bin/bash") == "/bin/bash"
@@ -22,7 +22,7 @@ def test_resolve_shell_accepts_bash():
def test_sanitized_env_strips_zsh_vars(monkeypatch):
from shell_tools.common.limits import sanitized_env
from terminal_tools.common.limits import sanitized_env
monkeypatch.setenv("ZDOTDIR", "/some/path")
monkeypatch.setenv("ZSH_VERSION", "5.9")
@@ -38,7 +38,7 @@ def test_sanitized_env_strips_zsh_vars(monkeypatch):
def test_destructive_warning_catalog():
from shell_tools.common.destructive_warning import get_warning
from terminal_tools.common.destructive_warning import get_warning
cases = [
("rm -rf /tmp/foo", "force-remove"),
@@ -59,14 +59,14 @@ def test_destructive_warning_catalog():
def test_destructive_warning_clean_commands():
from shell_tools.common.destructive_warning import get_warning
from terminal_tools.common.destructive_warning import get_warning
for cmd in ["ls -la", "echo hi", "git status", "git commit -m 'x'"]:
assert get_warning(cmd) is None, f"unexpected warning for {cmd!r}"
def test_semantic_exit_grep():
from shell_tools.common.semantic_exit import classify
from terminal_tools.common.semantic_exit import classify
status, msg = classify("grep foo /tmp/x", 0)
assert status == "ok"
@@ -78,7 +78,7 @@ def test_semantic_exit_grep():
def test_semantic_exit_default():
from shell_tools.common.semantic_exit import classify
from terminal_tools.common.semantic_exit import classify
status, msg = classify("ls", 0)
assert status == "ok"
@@ -88,14 +88,14 @@ def test_semantic_exit_default():
def test_semantic_exit_signaled():
from shell_tools.common.semantic_exit import classify
from terminal_tools.common.semantic_exit import classify
status, msg = classify("sleep 999", -15, signaled=True)
assert status == "signal"
def test_semantic_exit_timed_out():
from shell_tools.common.semantic_exit import classify
from terminal_tools.common.semantic_exit import classify
status, msg = classify("sleep 999", None, timed_out=True)
assert status == "error"
+33
View File
@@ -0,0 +1,33 @@
"""Smoke test: load the server module, register tools, assert all 10 land."""
from __future__ import annotations
EXPECTED_TOOLS = {
"terminal_exec",
"terminal_job_start",
"terminal_job_logs",
"terminal_job_manage",
"terminal_pty_open",
"terminal_pty_run",
"terminal_pty_close",
"terminal_rg",
"terminal_find",
"terminal_output_get",
}
def test_register_terminal_tools_lands_all_ten(mcp):
from terminal_tools import register_terminal_tools
names = register_terminal_tools(mcp)
assert set(names) == EXPECTED_TOOLS, (
f"missing: {EXPECTED_TOOLS - set(names)}, extra: {set(names) - EXPECTED_TOOLS}"
)
def test_all_tools_have_terminal_prefix(mcp):
from terminal_tools import register_terminal_tools
names = register_terminal_tools(mcp)
for n in names:
assert n.startswith("terminal_"), f"tool {n!r} missing terminal_ prefix"