refactor: rename shell tools to terminal tools
This commit is contained in:
@@ -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": [
|
||||
|
||||
@@ -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.
|
||||
+28
-28
@@ -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.
|
||||
+2
-2
@@ -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.
|
||||
+9
-9
@@ -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")`
|
||||
+5
-5
@@ -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
|
||||
```
|
||||
+23
-23
@@ -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.
|
||||
+3
-3
@@ -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 |
|
||||
|---|---|
|
||||
+29
-29
@@ -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
|
||||
```
|
||||
+16
-16
@@ -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
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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."
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+5
-5
@@ -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
-1
@@ -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))
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
+9
-9
@@ -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"
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user