Adding file attachment with and stdout redirection.
This commit is contained in:
@@ -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.
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -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
@@ -15,6 +15,7 @@ import traceback
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
|
from . import attach
|
||||||
from .client import (
|
from .client import (
|
||||||
AmbiguousSession,
|
AmbiguousSession,
|
||||||
CLOSABLE_STATES,
|
CLOSABLE_STATES,
|
||||||
@@ -58,6 +59,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
" shellbound a \"why is the sky blue?\" # answer mode, fresh session\n"
|
" 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 c \"a limerick about tests\" -c # creative, then copy reply\n"
|
||||||
" shellbound sh \"git status --short\" --no-exec\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 # list past sessions\n"
|
||||||
" shellbound --session 8f73ac \"continue\" # resume a session\n"
|
" shellbound --session 8f73ac \"continue\" # resume a session\n"
|
||||||
" shellbound --close all # close all open shellbound_* workspaces\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("--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("--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("--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("--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("--plain", action="store_true", help="plain text output (no live Markdown rendering)")
|
||||||
parser.add_argument("--setup", action="store_true", help="interactive config setup")
|
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()
|
return " ".join(parts).strip()
|
||||||
|
|
||||||
|
|
||||||
def _apply_paste(args: argparse.Namespace, prompt: str, cfg: Config) -> str:
|
def _clipboard_block(cfg: Config) -> str | None:
|
||||||
if not args.paste:
|
"""Return the clipboard as a fenced ``[clipboard]`` block, or ``None``."""
|
||||||
return prompt
|
|
||||||
clipped = clipboard.paste(cfg.get("clipboard"))
|
clipped = clipboard.paste(cfg.get("clipboard"))
|
||||||
if not clipped:
|
if not clipped:
|
||||||
sys.stderr.write("shellbound: --paste: clipboard is empty\n")
|
sys.stderr.write("shellbound: --paste: clipboard is empty\n")
|
||||||
return prompt
|
return None
|
||||||
if not prompt:
|
return attach.fence("clipboard", clipped)
|
||||||
return clipped
|
|
||||||
return f"{prompt}\n\n[clipboard]\n{clipped}"
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_saved(saved: list, fragment: str):
|
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:
|
if args.persona or args.shell or args.mode:
|
||||||
logger.debug("ignoring persona/mode arguments while resuming a session")
|
logger.debug("ignoring persona/mode arguments while resuming a session")
|
||||||
prompt = _compose_prompt(args, mode_consumed, resume=True)
|
prompt = _compose_prompt(args, mode_consumed, resume=True)
|
||||||
if args.paste:
|
|
||||||
prompt = _apply_paste(args, prompt, cfg)
|
|
||||||
else:
|
else:
|
||||||
explicit: Optional[str] = None
|
explicit: Optional[str] = None
|
||||||
if args.persona:
|
if args.persona:
|
||||||
@@ -355,16 +359,49 @@ async def run(args: argparse.Namespace) -> int:
|
|||||||
active_persona = default_persona_name(personas)
|
active_persona = default_persona_name(personas)
|
||||||
|
|
||||||
prompt = _compose_prompt(args, mode_consumed)
|
prompt = _compose_prompt(args, mode_consumed)
|
||||||
if args.paste:
|
|
||||||
prompt = _apply_paste(args, prompt, cfg)
|
|
||||||
|
|
||||||
created = await gw.create_session(
|
created = await gw.create_session(
|
||||||
active_persona, name=modes.session_name(prompt) or "shellbound"
|
active_persona, name=modes.session_name(prompt) or "shellbound"
|
||||||
)
|
)
|
||||||
ws_id = created["ws_id"]
|
ws_id = created["ws_id"]
|
||||||
node_id = created["node_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")
|
sys.stderr.write("shellbound: no prompt given\n")
|
||||||
build_parser().print_usage(sys.stderr)
|
build_parser().print_usage(sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
@@ -402,7 +439,7 @@ async def run(args: argparse.Namespace) -> int:
|
|||||||
if active_persona == "shellbound_expert":
|
if active_persona == "shellbound_expert":
|
||||||
renderer.add_info("generating expert brief ...")
|
renderer.add_info("generating expert brief ...")
|
||||||
brief = await run_turn(
|
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"]:
|
if brief["errors"] or not brief["content"]:
|
||||||
renderer.add_error(
|
renderer.add_error(
|
||||||
@@ -414,12 +451,12 @@ async def run(args: argparse.Namespace) -> int:
|
|||||||
result = await run_turn(
|
result = await run_turn(
|
||||||
session,
|
session,
|
||||||
ws_id,
|
ws_id,
|
||||||
modes.expert_retry_task(prompt, brief["content"]),
|
modes.expert_retry_task(message, brief["content"]),
|
||||||
on_event=on_event,
|
on_event=on_event,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
result = await run_turn(
|
result = await run_turn(
|
||||||
session, ws_id, prompt, on_event=on_event
|
session, ws_id, message, on_event=on_event
|
||||||
)
|
)
|
||||||
|
|
||||||
content = result.get("content", "")
|
content = result.get("content", "")
|
||||||
@@ -442,8 +479,8 @@ async def run(args: argparse.Namespace) -> int:
|
|||||||
modes.run_commands(commands)
|
modes.run_commands(commands)
|
||||||
else:
|
else:
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
"shellbound: --no-exec implied (stdin is not a terminal; "
|
"shellbound: --no-exec implied (stdin was consumed as "
|
||||||
"re-run with --no-exec or a tty to confirm)\n"
|
"message content; re-run with --no-exec or a tty to confirm)\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
if not result.get("timed_out") and not result.get("cancelled"):
|
if not result.get("timed_out") and not result.get("cancelled"):
|
||||||
|
|||||||
+18
-6
@@ -3,11 +3,12 @@
|
|||||||
Stored in ``~/.shellbound/config.json``. Environment variables override the
|
Stored in ``~/.shellbound/config.json``. Environment variables override the
|
||||||
file values:
|
file values:
|
||||||
|
|
||||||
- ``SHELLBOUND_GATEWAY`` gateway URL
|
- ``SHELLBOUND_GATEWAY`` gateway URL
|
||||||
- ``SHELLBOUND_TOKEN`` API key
|
- ``SHELLBOUND_TOKEN`` API key
|
||||||
- ``SHELLBOUND_VERIFY_TLS`` "true"/"1" to verify TLS certificates
|
- ``SHELLBOUND_VERIFY_TLS`` "true"/"1" to verify TLS certificates
|
||||||
- ``SHELLBOUND_KEEP_WORKSPACES`` how many shellbound_* workspaces to keep open
|
- ``SHELLBOUND_KEEP_WORKSPACES`` how many shellbound_* workspaces to keep open
|
||||||
- ``SHELLBOUND_CLIPBOARD`` clipboard backend (auto/xclip/wl-copy/...)
|
- ``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
|
``node_base_template`` was removed from the schema (never changes); any stale
|
||||||
value left in an existing config is pruned on load.
|
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_GATEWAY = "https://localhost:8443"
|
||||||
DEFAULT_KEEP_WORKSPACES = 5
|
DEFAULT_KEEP_WORKSPACES = 5
|
||||||
|
DEFAULT_ATTACH_MAX_BYTES = 102_400
|
||||||
|
|
||||||
|
|
||||||
class Config(dict):
|
class Config(dict):
|
||||||
@@ -54,6 +56,7 @@ class Config(dict):
|
|||||||
"SHELLBOUND_TOKEN": "token",
|
"SHELLBOUND_TOKEN": "token",
|
||||||
"SHELLBOUND_VERIFY_TLS": "verify_tls",
|
"SHELLBOUND_VERIFY_TLS": "verify_tls",
|
||||||
"SHELLBOUND_KEEP_WORKSPACES": "keep_workspaces",
|
"SHELLBOUND_KEEP_WORKSPACES": "keep_workspaces",
|
||||||
|
"SHELLBOUND_ATTACH_MAX_BYTES": "attach_max_bytes",
|
||||||
"SHELLBOUND_CLIPBOARD": "clipboard",
|
"SHELLBOUND_CLIPBOARD": "clipboard",
|
||||||
}
|
}
|
||||||
for env_key, cfg_key in env.items():
|
for env_key, cfg_key in env.items():
|
||||||
@@ -62,7 +65,7 @@ class Config(dict):
|
|||||||
continue
|
continue
|
||||||
if cfg_key == "verify_tls":
|
if cfg_key == "verify_tls":
|
||||||
data[cfg_key] = val.lower() in ("1", "true", "yes", "on")
|
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:
|
try:
|
||||||
data[cfg_key] = int(val)
|
data[cfg_key] = int(val)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -74,6 +77,7 @@ class Config(dict):
|
|||||||
data.setdefault("verify_tls", False)
|
data.setdefault("verify_tls", False)
|
||||||
data.setdefault("clipboard", "auto")
|
data.setdefault("clipboard", "auto")
|
||||||
data.setdefault("keep_workspaces", DEFAULT_KEEP_WORKSPACES)
|
data.setdefault("keep_workspaces", DEFAULT_KEEP_WORKSPACES)
|
||||||
|
data.setdefault("attach_max_bytes", DEFAULT_ATTACH_MAX_BYTES)
|
||||||
return cls(data)
|
return cls(data)
|
||||||
|
|
||||||
def save(self) -> None:
|
def save(self) -> None:
|
||||||
@@ -91,3 +95,11 @@ class Config(dict):
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return DEFAULT_KEEP_WORKSPACES
|
return DEFAULT_KEEP_WORKSPACES
|
||||||
return max(value, 0)
|
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
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|||||||
import getpass
|
import getpass
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
from .config import Config
|
from .config import Config, DEFAULT_ATTACH_MAX_BYTES
|
||||||
|
|
||||||
CLIPBOARD_BACKENDS = ("auto", "xclip", "xsel", "wl-copy", "pbcopy")
|
CLIPBOARD_BACKENDS = ("auto", "xclip", "xsel", "wl-copy", "pbcopy")
|
||||||
|
|
||||||
@@ -95,12 +95,27 @@ def setup(cfg: Config) -> Config:
|
|||||||
cfg.get("verify_tls", False),
|
cfg.get("verify_tls", False),
|
||||||
)
|
)
|
||||||
clipboard = _edit_clipboard(cfg.get("clipboard", "auto"))
|
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["gateway"] = gateway
|
||||||
cfg["token"] = token
|
cfg["token"] = token
|
||||||
cfg["keep_workspaces"] = keep
|
cfg["keep_workspaces"] = keep
|
||||||
cfg["verify_tls"] = bool(verify)
|
cfg["verify_tls"] = bool(verify)
|
||||||
cfg["clipboard"] = clipboard
|
cfg["clipboard"] = clipboard
|
||||||
|
cfg["attach_max_bytes"] = max_bytes
|
||||||
cfg.save()
|
cfg.save()
|
||||||
|
|
||||||
print("\nshellbound setup complete.")
|
print("\nshellbound setup complete.")
|
||||||
@@ -109,4 +124,5 @@ def setup(cfg: Config) -> Config:
|
|||||||
print(f" keep_workspaces: {keep}")
|
print(f" keep_workspaces: {keep}")
|
||||||
print(f" verify_tls: {'yes' if verify else 'no'}")
|
print(f" verify_tls: {'yes' if verify else 'no'}")
|
||||||
print(f" clipboard: {clipboard}")
|
print(f" clipboard: {clipboard}")
|
||||||
|
print(f" attach_max_bytes:{max_bytes}")
|
||||||
return cfg
|
return cfg
|
||||||
+110
-1
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from shellbound import attach
|
||||||
from shellbound import modes
|
from shellbound import modes
|
||||||
from shellbound.personas import (
|
from shellbound.personas import (
|
||||||
AmbiguousPersona,
|
AmbiguousPersona,
|
||||||
@@ -231,4 +233,111 @@ def test_renderer_plain_content_streams():
|
|||||||
renderer = StreamRenderer(plain=True)
|
renderer = StreamRenderer(plain=True)
|
||||||
renderer.add_content("just text")
|
renderer.add_content("just text")
|
||||||
assert renderer.content_parts == ["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
|
||||||
Reference in New Issue
Block a user