diff --git a/README.md b/README.md index 6dc995b..0b92a9a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/shellbound/attach.py b/shellbound/attach.py new file mode 100644 index 0000000..b5a5967 --- /dev/null +++ b/shellbound/attach.py @@ -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: ]``) +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) \ No newline at end of file diff --git a/shellbound/cli.py b/shellbound/cli.py index a5b6064..3678d71 100644 --- a/shellbound/cli.py +++ b/shellbound/cli.py @@ -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"): diff --git a/shellbound/config.py b/shellbound/config.py index b5243af..fad355c 100644 --- a/shellbound/config.py +++ b/shellbound/config.py @@ -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) diff --git a/shellbound/setup.py b/shellbound/setup.py index 3ac53b4..5ffb59d 100644 --- a/shellbound/setup.py +++ b/shellbound/setup.py @@ -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 \ No newline at end of file diff --git a/tests/test_units.py b/tests/test_units.py index 0e215d7..ca90f78 100644 --- a/tests/test_units.py +++ b/tests/test_units.py @@ -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" \ No newline at end of file + 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 \ No newline at end of file