Adding file attachment with and stdout redirection.

This commit is contained in:
Morpheus Sandmann
2026-09-17 13:48:39 +01:00
parent 1c92cdff60
commit be5b2b8ff0
6 changed files with 320 additions and 26 deletions
+16
View File
@@ -157,6 +157,22 @@ shellbound --close 8f73ac # close one work
Modes are personae on the server named `shellbound_*`. The mode/dora argument may be a full persona name or the shortest unambiguous prefix of its slug (e.g. `a` = answer, `c` = creative). `e` is ambiguous (`explain` vs `expert`) and will ask for more characters.
## Attachments & piping
Attach text files with `--attach` (repeatable) and/or pipe a program's output on stdin; both ride along with the prompt as fenced context blocks (`[file: ...]`, `[stdin]`, and `[clipboard]` when using `--paste`), so the model can tell them from the prompt itself.
```
shellbound a --attach main.py "review this file"
shellbound a --attach main.py --attach utils.py "how are these related?"
cat build.log | shellbound a "what failed and why?"
git diff | shellbound sh "review and commit" --no-exec
```
- Piped stdin is read automatically when it is not a terminal; pass `--no-stdin` to ignore it.
- Files that look binary (NUL bytes) are skipped with a warning.
- Input over `attach_max_bytes` (default 100 KB, per file/stdin) is truncated with a warning, and the truncation is marked inside the block. Tune it via `shellbound setup` or `SHELLBOUND_ATTACH_MAX_BYTES`.
- A message may consist entirely of attachments/stdin — no separate prompt is required.
## Retention
After every completed turn shellbound keeps the most recent `keep_workspaces` (default 5) `shellbound_*` workspaces open and closes older ones (they stay listed and resumable). Tune it via `shellbound setup`, `SHELLBOUND_KEEP_WORKSPACES`, or the `keep_workspaces` config key.
+104
View File
@@ -0,0 +1,104 @@
"""Attaching external text content to outgoing messages.
Reading, formatting and assembly of context blocks that ride along with the
user's prompt: piped stdin (``[stdin]``), attached files (``[file: <path>]``)
and - via :func:`fence` - clipboard content (``[clipboard]``).
Every block is emitted as a fenced ``text`` code block so the model can tell
the prompt from the attached payload. Files that are clearly binary (NUL
bytes) are skipped with a warning; oversized input is truncated at
``max_bytes`` with a warning, and the truncation is marked inside the block.
"""
from __future__ import annotations
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import BinaryIO, Callable
@dataclass
class ReadResult:
"""Decoded text plus flags describing how it was handled."""
content: str = ""
truncated: bool = False
binary: bool = False
def _warn(message: str) -> None:
sys.stderr.write(f"shellbound: {message}\n")
sys.stderr.flush()
def fence(label: str, content: str, note: str = "") -> str:
"""Wrap ``content`` in a fenced ``text`` block labelled ``[label]``.
``note`` (e.g. a truncation marker) is appended inside the fence.
"""
body = (content or "").rstrip("\n")
if note:
body = f"{body}\n{note}" if body else note
return f"[{label}]\n```text\n{body}\n```"
def truncation_note(what: str, max_bytes: int) -> str:
return f"[truncated: kept the first {max_bytes} bytes of {what}]"
def _decode(
data: bytes,
what: str,
max_bytes: int,
warn: Callable[[str], None],
*,
binary: bool = False,
) -> ReadResult:
truncated = False
if len(data) > max_bytes:
truncated = True
warn(f"truncating {what} to {max_bytes} bytes")
data = data[:max_bytes]
return ReadResult(
content=data.decode("utf-8", errors="replace"),
truncated=truncated,
binary=binary,
)
def read_file(
path: str, max_bytes: int, warn: Callable[[str], None] = _warn
) -> ReadResult | None:
"""Read a text file for attachment.
Returns ``None`` when the file is skipped as binary. Unreadable paths
(missing files, directories, permissions) raise the underlying ``OSError``
for the caller to surface.
"""
data = Path(path).read_bytes()
if b"\x00" in data:
warn(f"skipping {path!r}: looks binary; attach a text file instead")
return None
return _decode(data, path, max_bytes, warn)
def read_stdin(
buffer: BinaryIO, max_bytes: int, warn: Callable[[str], None] = _warn
) -> ReadResult:
"""Read piped data from ``buffer`` (``sys.stdin.buffer``) for attachment."""
data = buffer.read()
binary = b"\x00" in data
if binary:
warn("stdin looks binary; decoding it as text")
return _decode(data, "stdin", max_bytes, warn, binary=binary)
def assemble(prompt: str, blocks: list[str]) -> str:
"""Join the prompt with already-fenced context ``blocks``."""
parts = [
p
for p in (prompt.strip(), *(b.strip() for b in blocks))
if p
]
return "\n\n".join(parts)
+55 -18
View File
@@ -15,6 +15,7 @@ import traceback
from typing import Optional
from . import __version__
from . import attach
from .client import (
AmbiguousSession,
CLOSABLE_STATES,
@@ -58,6 +59,10 @@ def build_parser() -> argparse.ArgumentParser:
" shellbound a \"why is the sky blue?\" # answer mode, fresh session\n"
" shellbound c \"a limerick about tests\" -c # creative, then copy reply\n"
" shellbound sh \"git status --short\" --no-exec\n"
" shellbound a --attach main.py \"review this\"\n"
" shellbound a --attach a.py --attach b.py \"diff these?\"\n"
" cat build.log | shellbound a \"what failed?\"\n"
" git diff | shellbound c \"describe the change\"\n"
" shellbound --session # list past sessions\n"
" shellbound --session 8f73ac \"continue\" # resume a session\n"
" shellbound --close all # close all open shellbound_* workspaces\n"
@@ -78,6 +83,10 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--persona", metavar="NAME", help="explicit persona (full name or unique prefix)")
parser.add_argument("--copy", "-c", action="store_true", help="copy the reply to the clipboard")
parser.add_argument("--paste", "-p", action="store_true", help="append clipboard to the prompt (use it as the prompt when none given)")
parser.add_argument("--attach", action="append", metavar="FILE", default=None,
help="attach a text file to the message (repeatable)")
parser.add_argument("--no-stdin", action="store_true",
help="do not read piped stdin into the message")
parser.add_argument("--no-exec", action="store_true", help="shell mode: never execute proposed commands")
parser.add_argument("--plain", action="store_true", help="plain text output (no live Markdown rendering)")
parser.add_argument("--setup", action="store_true", help="interactive config setup")
@@ -139,16 +148,13 @@ def _compose_prompt(args: argparse.Namespace, mode_consumed: bool, *, resume: bo
return " ".join(parts).strip()
def _apply_paste(args: argparse.Namespace, prompt: str, cfg: Config) -> str:
if not args.paste:
return prompt
def _clipboard_block(cfg: Config) -> str | None:
"""Return the clipboard as a fenced ``[clipboard]`` block, or ``None``."""
clipped = clipboard.paste(cfg.get("clipboard"))
if not clipped:
sys.stderr.write("shellbound: --paste: clipboard is empty\n")
return prompt
if not prompt:
return clipped
return f"{prompt}\n\n[clipboard]\n{clipped}"
return None
return attach.fence("clipboard", clipped)
def _resolve_saved(saved: list, fragment: str):
@@ -317,8 +323,6 @@ async def run(args: argparse.Namespace) -> int:
if args.persona or args.shell or args.mode:
logger.debug("ignoring persona/mode arguments while resuming a session")
prompt = _compose_prompt(args, mode_consumed, resume=True)
if args.paste:
prompt = _apply_paste(args, prompt, cfg)
else:
explicit: Optional[str] = None
if args.persona:
@@ -355,16 +359,49 @@ async def run(args: argparse.Namespace) -> int:
active_persona = default_persona_name(personas)
prompt = _compose_prompt(args, mode_consumed)
if args.paste:
prompt = _apply_paste(args, prompt, cfg)
created = await gw.create_session(
active_persona, name=modes.session_name(prompt) or "shellbound"
)
ws_id = created["ws_id"]
node_id = created["node_id"]
if not prompt:
# ---- attach context: clipboard, piped stdin, files --------------
blocks: list[str] = []
if args.paste:
clipped = _clipboard_block(cfg)
if clipped is not None:
blocks.append(clipped)
if not args.no_stdin and not sys.stdin.isatty():
result = attach.read_stdin(sys.stdin.buffer, cfg.attach_max_bytes)
if result.truncated or result.content:
note = (
attach.truncation_note("stdin", cfg.attach_max_bytes)
if result.truncated
else ""
)
blocks.append(attach.fence("stdin", result.content, note=note))
for path in args.attach or []:
try:
result = attach.read_file(path, cfg.attach_max_bytes)
except OSError as exc:
sys.stderr.write(
f"shellbound: --attach: cannot read {path!r}: {exc}\n"
)
return 1
if result is None:
continue # binary file; already warned
note = (
attach.truncation_note(path, cfg.attach_max_bytes)
if result.truncated
else ""
)
blocks.append(attach.fence(f"file: {path}", result.content, note=note))
message = attach.assemble(prompt, blocks)
if not message:
sys.stderr.write("shellbound: no prompt given\n")
build_parser().print_usage(sys.stderr)
return 2
@@ -402,7 +439,7 @@ async def run(args: argparse.Namespace) -> int:
if active_persona == "shellbound_expert":
renderer.add_info("generating expert brief ...")
brief = await run_turn(
session, ws_id, modes.expert_brief_task(prompt), on_event=None
session, ws_id, modes.expert_brief_task(message), on_event=None
)
if brief["errors"] or not brief["content"]:
renderer.add_error(
@@ -414,12 +451,12 @@ async def run(args: argparse.Namespace) -> int:
result = await run_turn(
session,
ws_id,
modes.expert_retry_task(prompt, brief["content"]),
modes.expert_retry_task(message, brief["content"]),
on_event=on_event,
)
else:
result = await run_turn(
session, ws_id, prompt, on_event=on_event
session, ws_id, message, on_event=on_event
)
content = result.get("content", "")
@@ -442,8 +479,8 @@ async def run(args: argparse.Namespace) -> int:
modes.run_commands(commands)
else:
sys.stderr.write(
"shellbound: --no-exec implied (stdin is not a terminal; "
"re-run with --no-exec or a tty to confirm)\n"
"shellbound: --no-exec implied (stdin was consumed as "
"message content; re-run with --no-exec or a tty to confirm)\n"
)
if not result.get("timed_out") and not result.get("cancelled"):
+18 -6
View File
@@ -3,11 +3,12 @@
Stored in ``~/.shellbound/config.json``. Environment variables override the
file values:
- ``SHELLBOUND_GATEWAY`` gateway URL
- ``SHELLBOUND_TOKEN`` API key
- ``SHELLBOUND_VERIFY_TLS`` "true"/"1" to verify TLS certificates
- ``SHELLBOUND_KEEP_WORKSPACES`` how many shellbound_* workspaces to keep open
- ``SHELLBOUND_CLIPBOARD`` clipboard backend (auto/xclip/wl-copy/...)
- ``SHELLBOUND_GATEWAY`` gateway URL
- ``SHELLBOUND_TOKEN`` API key
- ``SHELLBOUND_VERIFY_TLS`` "true"/"1" to verify TLS certificates
- ``SHELLBOUND_KEEP_WORKSPACES`` how many shellbound_* workspaces to keep open
- ``SHELLBOUND_ATTACH_MAX_BYTES`` max bytes per attached file / piped stdin
- ``SHELLBOUND_CLIPBOARD`` clipboard backend (auto/xclip/wl-copy/...)
``node_base_template`` was removed from the schema (never changes); any stale
value left in an existing config is pruned on load.
@@ -24,6 +25,7 @@ CONFIG_FILE = CONFIG_DIR / "config.json"
DEFAULT_GATEWAY = "https://localhost:8443"
DEFAULT_KEEP_WORKSPACES = 5
DEFAULT_ATTACH_MAX_BYTES = 102_400
class Config(dict):
@@ -54,6 +56,7 @@ class Config(dict):
"SHELLBOUND_TOKEN": "token",
"SHELLBOUND_VERIFY_TLS": "verify_tls",
"SHELLBOUND_KEEP_WORKSPACES": "keep_workspaces",
"SHELLBOUND_ATTACH_MAX_BYTES": "attach_max_bytes",
"SHELLBOUND_CLIPBOARD": "clipboard",
}
for env_key, cfg_key in env.items():
@@ -62,7 +65,7 @@ class Config(dict):
continue
if cfg_key == "verify_tls":
data[cfg_key] = val.lower() in ("1", "true", "yes", "on")
elif cfg_key == "keep_workspaces":
elif cfg_key in ("keep_workspaces", "attach_max_bytes"):
try:
data[cfg_key] = int(val)
except ValueError:
@@ -74,6 +77,7 @@ class Config(dict):
data.setdefault("verify_tls", False)
data.setdefault("clipboard", "auto")
data.setdefault("keep_workspaces", DEFAULT_KEEP_WORKSPACES)
data.setdefault("attach_max_bytes", DEFAULT_ATTACH_MAX_BYTES)
return cls(data)
def save(self) -> None:
@@ -91,3 +95,11 @@ class Config(dict):
except (TypeError, ValueError):
return DEFAULT_KEEP_WORKSPACES
return max(value, 0)
@property
def attach_max_bytes(self) -> int:
try:
value = int(self.get("attach_max_bytes", DEFAULT_ATTACH_MAX_BYTES))
except (TypeError, ValueError):
return DEFAULT_ATTACH_MAX_BYTES
return max(value, 1)
+17 -1
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
import getpass
import urllib.parse
from .config import Config
from .config import Config, DEFAULT_ATTACH_MAX_BYTES
CLIPBOARD_BACKENDS = ("auto", "xclip", "xsel", "wl-copy", "pbcopy")
@@ -95,12 +95,27 @@ def setup(cfg: Config) -> Config:
cfg.get("verify_tls", False),
)
clipboard = _edit_clipboard(cfg.get("clipboard", "auto"))
max_bytes = _edit(
"Max bytes to attach from files/stdin",
str(cfg.get("attach_max_bytes", DEFAULT_ATTACH_MAX_BYTES)),
)
while True:
try:
max_bytes = max(int(max_bytes), 1)
break
except ValueError:
print(f" invalid number: {max_bytes!r}")
max_bytes = _edit(
"Max bytes to attach from files/stdin",
str(cfg.get("attach_max_bytes", DEFAULT_ATTACH_MAX_BYTES)),
)
cfg["gateway"] = gateway
cfg["token"] = token
cfg["keep_workspaces"] = keep
cfg["verify_tls"] = bool(verify)
cfg["clipboard"] = clipboard
cfg["attach_max_bytes"] = max_bytes
cfg.save()
print("\nshellbound setup complete.")
@@ -109,4 +124,5 @@ def setup(cfg: Config) -> Config:
print(f" keep_workspaces: {keep}")
print(f" verify_tls: {'yes' if verify else 'no'}")
print(f" clipboard: {clipboard}")
print(f" attach_max_bytes:{max_bytes}")
return cfg
+110 -1
View File
@@ -2,8 +2,10 @@
from __future__ import annotations
import io
import json
from shellbound import attach
from shellbound import modes
from shellbound.personas import (
AmbiguousPersona,
@@ -231,4 +233,111 @@ def test_renderer_plain_content_streams():
renderer = StreamRenderer(plain=True)
renderer.add_content("just text")
assert renderer.content_parts == ["just text"]
assert renderer.finish() == "just text"
assert renderer.finish() == "just text"
# ---- attachments / piped stdin --------------------------------------------
def _warnings():
seen: list[str] = []
def warn(message: str) -> None:
seen.append(message)
return seen, warn
def test_fence_structure():
assert attach.fence("file: a.txt", "one\ntwo") == (
"[file: a.txt]\n```text\none\ntwo\n```"
)
assert attach.fence("stdin", "x", note="[truncated]") == (
"[stdin]\n```text\nx\n[truncated]\n```"
)
assert attach.fence("clipboard", "\n") == "[clipboard]\n```text\n\n```"
def test_assemble_order_and_stripping():
blocks = [
attach.fence("stdin", "pipe"),
attach.fence("file: a.txt", "file body"),
attach.fence("clipboard", "clip"),
]
assert attach.assemble("hello", blocks) == (
"hello\n\n"
"[stdin]\n```text\npipe\n```\n\n"
"[file: a.txt]\n```text\nfile body\n```\n\n"
"[clipboard]\n```text\nclip\n```"
)
def test_assemble_no_prompt_or_blocks():
assert attach.assemble("", []) == ""
assert attach.assemble("just prompt", []) == "just prompt"
assert attach.assemble("", [attach.fence("stdin", "s")]) == "[stdin]\n```text\ns\n```"
def test_read_file_binary_skipped(tmp_path):
p = tmp_path / "bin.dat"
p.write_bytes(b"\x00\x01\x02")
seen, warn = _warnings()
assert attach.read_file(str(p), 1024, warn=warn) is None
assert any("binary" in m for m in seen)
def test_read_file_truncates(tmp_path):
p = tmp_path / "big.txt"
p.write_text("a" * 5000)
seen, warn = _warnings()
result = attach.read_file(str(p), 100, warn=warn)
assert result.truncated
assert result.content == "a" * 100
assert not result.binary
assert any("truncating" in m for m in seen)
def test_read_file_missing_raises(tmp_path):
try:
attach.read_file(str(tmp_path / "nope.txt"), 1024)
except FileNotFoundError:
pass
else:
raise AssertionError("expected FileNotFoundError")
def test_read_stdin_truncates_and_warns():
seen, warn = _warnings()
result = attach.read_stdin(io.BytesIO(b"b" * 500), 10, warn=warn)
assert result.truncated and result.content == "b" * 10
assert any("truncating" in m for m in seen)
def test_read_stdin_empty_and_binary():
result = attach.read_stdin(io.BytesIO(b""), 100)
assert not result.content and not result.truncated
seen, warn = _warnings()
result = attach.read_stdin(io.BytesIO(b"\x00data"), 100, warn=warn)
assert result.binary and result.content == "\x00data"
assert any("binary" in m for m in seen)
def test_truncation_note():
assert attach.truncation_note("stdin", 42) == "[truncated: kept the first 42 bytes of stdin]"
def test_config_attach_max_bytes():
from shellbound.config import Config
assert Config({}).attach_max_bytes == 102_400
assert Config({"attach_max_bytes": "oops"}).attach_max_bytes == 102_400
assert Config({"attach_max_bytes": 0}).attach_max_bytes == 1
assert Config({"attach_max_bytes": 5_000}).attach_max_bytes == 5_000
def test_config_attach_max_bytes_environ(tmp_path, monkeypatch):
from shellbound import config as c
monkeypatch.setattr(c, "CONFIG_FILE", tmp_path / "missing.json")
monkeypatch.setenv("SHELLBOUND_ATTACH_MAX_BYTES", "4048")
assert c.Config.load().attach_max_bytes == 4048