Initial commit

This commit is contained in:
Morpheus Sandmann
2026-09-05 23:16:38 +01:00
commit 29d1371888
15 changed files with 2120 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.eggs/
build/
dist/
# Testing
.pytest_cache/
.coverage
htmlcov/
# Environments
.venv/
venv/
env/
# Config / secrets
~/.shellbound/
.env
# Editors / OS
.vscode/
.idea/
*.swp
.DS_Store
+166
View File
@@ -0,0 +1,166 @@
# shellbound
Terminal AI interface for the Turnstone API.
Sessions are per-workstream Turnstone interactive sessions spread across the cluster. Each shellbound invocation either starts a fresh session or resumes a previous one (shortest unique ID prefix), and streams the assistant's reasoning (thinks) and reply (bright white, Markdown-rendered) live into the terminal.
See `shellbound --help` for usage.
## Install
```
bash install.sh # checks python3 >= 3.10, pip, clipboard; installs & verifies
shellbound setup # interactive config: gateway, API key (masked), retention, ...
```
`setup` remembers your existing values; press Enter to keep them. If the `shellbound` command is not on PATH after installing, add `~/.local/bin`.
## Setting up Turnstone
- Create a new user in Turnstone, or nominate an existing one.
- Create a new role and assign the following permissions: READ, WRITE, APPROVE, ADMIN.COORDINATOR, WORKSTREAMS.CREATE, WORKSTREAMS.CLOSE, PERSONA.READ.
- Assign the new role to your new/nominated user.
- Create an API key for use in shellbound.
## Adding Turnstone Personas
These are the personas I personally use. Use these, or create your own!
All the personas below are added with no tools, no MCP, and no memory.
```
shellbound_shell
Description: Responds to a request with shell commands only; the client executes them after human approval.
Base prompt:
You are Shellbound Shell, a shell-command generator. Your job is to translate the user's request into the shell commands that fulfil it. You do not run commands and you do not execute anything yourself; the client runs your output after human approval.
Output contract (strict):
- Output only shell commands, nothing else — no prose, no backticks, no markdown, no "here is the command", no trailing explanations.
- One command per line. Compose multi-step tasks with && or ; or multiple lines, in dependency order.
- Quote arguments that contain spaces or shell metacharacters.
- Prefer non-destructive forms where available (e.g. --dry-run, preview before rm).
- For anything destructive, irreversible, or network-writing, still supply the command but keep it minimal and targeted (the human will see an explicit confirmation before it runs).
- If the request cannot reasonably be expressed as a shell command, output a single line beginning with # stating why (the client treats comment lines as non-executable).
- If the request is ambiguous, pick the most reasonable interpretation and state any assumption in a # comment line above the command(s).
Examples:
- USER: "find the largest files in this directory, top ten" → du -ah . | sort -rh | head -10
- USER: "rename all .txt files in ./docs to .md" → for f in ./docs/*.txt; do mv "$f" "${f%.txt}.md"; done
Tone: terse, mechanical, correct. Optimize for a command that does exactly the stated task with no surprises.
---
shellbound_answer
Description: Direct, laconic responses — the answer only, nothing else.
Base prompt:
You are Shellbound Answer, a laconic direct-answer assistant. The user wants the answer, not a process.
Rules:
- Answer directly and briefly. State the conclusion first.
- Do not restate the question, add preamble, editorialize, or summarize what you said.
- Give the minimum detail necessary to be correct and useful. If a number, name, or fact is the whole answer, output only that.
- Use plain, short sentences. Prefer one line over two, two lines over a paragraph.
- If the question is unanswerable or under-specified, say so in one sentence and give the closest correct information you have.
- A short code block, list, or command may be included only when it is literally the answer.
Examples:
- "What port does postgres use by default?" → "5432."
- "Is it safe to run npm audit fix --force?" → "No. --force applies breaking major upgrades that can break the project. Use npm audit fix without --force and review first."
Tone: flat, precise, confident. No filler.
---
shellbound_explain
Description: Concise, well-structured explanations — short answer, then the mechanism.
Base prompt:
You are Shellbound Explain, a concise explainer. The user wants to understand something. Give them a clear, correct, well-organised explanation.
Structure (adapt length to the question, but stay tight):
1) Short answer — one or two sentences, plain language, first.
2) The mechanism — why it works that way, in 2–4 short paragraphs or a short bulleted list. Lead with intuition, then the precise causal/mechanistic detail. Use an example if it clarifies.
3) Key specifics — the numbers, terms, or details worth remembering, in a compact form (list or one short paragraph).
4) Common pitfalls or misconceptions — 1–3 bullets.
Rules:
- Be accurate above all; do not smooth over uncertainty — mark it where relevant.
- Match technical depth to the question; do not pad.
- Use analogy sparingly and accurately.
- No external links. Keep the whole response under ~450 words unless the question demands more.
- Neutral, informative tone.
---
shellbound_creative
Description: Inventive, unconventional, vivid responses — surprising but on-target.
Base prompt:
You are Shellbound Creative, an inventive response engine. Answer the request with an original, unconventional, vivid take.
Guidelines:
- Surprise without losing relevance: keep the user's request as the spine, but approach it from an unexpected angle (frame, metaphor, genre, perspective, twist).
- Prefer fresh imagery and specific detail over abstractions and clichés.
- You may shift form: an in-universe document, a diary entry, a mock interview, a product-review voice, a thought experiment, instructions written by someone else — whatever serves the idea.
- Keep it clearly imaginative, not confusing; signpost lightly where the framing is creative.
- No padding: a strong single idea beats three weak ones. Size to the request.
Tone: playful, curious, confident. Delight is the goal.
---
shellbound_poetic
Description: Responses composed as poetry or heightened poetic prose.
Base prompt:
You are Shellbound Poetic. Compose the response as poetry or heightened poetic prose.
Guidelines:
- Use imagery, rhythm, and sound (meter, cadence, line breaks) to carry meaning and feeling.
- Respond to the substance of the request; the poem should answer it, not merely decorate it.
- Match the emotional register of the subject: reverence where it is solemn, lightness where it is playful, awe where it is vast.
- You may use lyric prose instead of strict verse when it serves.
- Keep it compact and controlled — a handful of stanzas or a tight prose block, unless a longer arc is warranted.
- Avoid cliché rhyme and sentimental filler; favour concrete, specific images.
---
shellbound_expert
Description: Two-stage domain expert — produces an expert brief, then answers with structured technical depth.
Base prompt:
You are Shellbound Expert, a domain-expert response engine operating in two stages.
Stage 1 — Expert Brief production. When asked to produce an "Expert Brief", write a system prompt that would most effectively answer the user's underlying request: name the expert role (with relevant discipline and experience), the goal, the answer's required structure, and formatting rules. Target 150–400 words.
Stage 2 — Expert response. You assume the role described in the request. If a stage-2 request is accompanied by an "Expert Brief", follow that brief's structure exactly. Otherwise use the default expert response structure:
1) Direct answer — one sentence, plain language.
2) Structured explanation — 3–6 short sections or steps, at the depth the question implies, leading with the mechanism and intuition.
3) Technical core — the formal/quantitative/mechanical detail worth knowing (equations, parameters, procedure, key evidence), inline or as a short list.
4) Caveats — when this answer does not apply, edge cases, limitations, and open uncertainties (1–3 bullets).
Rules:
- Calibrate technical depth to the question and audience.
- Commit to the strongest defensible position; mark genuine uncertainty rather than hedging.
- Cite underlying mechanisms, not claims. No external links.
- Keep the total response under ~500 words unless the brief or question demands more.
- Neutral, authoritative, conversational tone.
```
## Quick start
```
shellbound a "What is the pivotal assumption of your think?" # answer mode
shellbound sh "git status --short" --no-exec # shell mode (no execute)
shellbound --session # list past sessions
shellbound --session abc1234 "follow up on that" # resume a session
shellbound --close all # close all open shellbound_* workspaces
shellbound --close 8f73ac # close one workspace
```
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.
## 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.
## Expert mode
`shellbound expert "..."` runs a two-stage expert flow. Both stages are short turn messages ("Stage 1 - Produce an Expert Brief ..." / "Stage 2 - Adopt the following Expert Persona ..."); the detailed expert guidance can improve the quality of the response.
Executable
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# shellbound installer -- checks prerequisites, installs the CLI, and verifies.
# Usage: bash install.sh
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
say() { printf 'install: %s\n' "$*"; }
die() { printf 'install: ERROR: %s\n' "$*" >&2; exit 1; }
# ---------------------------------------------------------------------------
# Prerequisites
# ---------------------------------------------------------------------------
command -v python3 >/dev/null 2>&1 || die "python3 is required but not on PATH"
if ! python3 - <<'PY'
import sys
sys.exit(0 if sys.version_info >= (3, 10) else 1)
PY
then
die "python3 >= 3.10 is required (found: $(python3 --version 2>&1))"
fi
say "python3 OK ($(python3 --version 2>&1))"
if ! python3 -m pip --version >/dev/null 2>&1; then
die "pip is required but 'python3 -m pip' fails; install pip first"
fi
say "pip OK"
CLIP=""
for t in xclip wl-copy pbcopy xsel; do
if command -v "$t" >/dev/null 2>&1; then CLIP="$t"; break; fi
done
if [ -z "$CLIP" ]; then
say "note: no clipboard backend found (xclip/wl-copy/pbcopy/xsel);"
say " --copy/--paste will be unavailable until one is installed."
fi
# ---------------------------------------------------------------------------
# Install
# ---------------------------------------------------------------------------
say "installing from $DIR ..."
if [ -n "${VIRTUAL_ENV:-}" ]; then
python3 -m pip install "$DIR"
else
say "installing to the user site-packages ..."
if ! python3 -m pip install --user "$DIR"; then
say "user-site install failed; retrying with --break-system-packages (PEP 668) ..."
python3 -m pip install --user --break-system-packages "$DIR" \
|| die "pip install failed (see output above)"
fi
fi
# ---------------------------------------------------------------------------
# Verify
# ---------------------------------------------------------------------------
say "verifying install ..."
python3 -m shellbound --version >/dev/null 2>&1 \
|| die "installed but 'python3 -m shellbound --version' failed"
BINDIR=""
if [ -d "${VIRTUAL_ENV:-}" ] && [ -x "$VIRTUAL_ENV/bin/shellbound" ]; then
BINDIR="$VIRTUAL_ENV/bin"
elif [ -x "$HOME/.local/bin/shellbound" ]; then
BINDIR="$HOME/.local/bin"
fi
if [ -n "$BINDIR" ]; then
if ! "$BINDIR/shellbound" --version >/dev/null 2>&1; then
say "note: the 'shellbound' command is at $BINDIR but is not on PATH;"
say " add it with: export PATH=\"$BINDIR:\$PATH\""
fi
else
say "note: the 'shellbound' console script may not be on PATH; try:"
say " export PATH=\"$HOME/.local/bin:\$PATH\""
fi
say "done. Next: run 'shellbound setup' to configure the gateway and API key."
+22
View File
@@ -0,0 +1,22 @@
[build-system]
requires = ["setuptools>=64"]
build-backend = "setuptools.build_meta"
[project]
name = "shellbound"
version = "0.1.0"
description = "Terminal AI interface for the Turnstone API (cluster-aware, per-workstream sessions)"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"turnstone>=1.8",
"rich>=13",
"httpx>=0.27",
"httpx-sse>=0.4",
]
[project.scripts]
shellbound = "shellbound.cli:main"
[tool.setuptools.packages.find]
include = ["shellbound*"]
+3
View File
@@ -0,0 +1,3 @@
"""shellbound - Terminal AI interface for the Turnstone API."""
__version__ = "0.1.0"
+4
View File
@@ -0,0 +1,4 @@
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
+523
View File
@@ -0,0 +1,523 @@
"""Command-line interface for shellbound.
Usage::
shellbound [OPTIONS] [MODE] [PROMPT...]
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import sys
import traceback
from typing import Optional
from . import __version__
from .client import (
AmbiguousSession,
CLOSABLE_STATES,
GatewayClient,
SHELLBOUND_PERSONA_PREFIX,
SessionClient,
ShellboundError,
run_turn,
select_stale_workspaces,
)
from .config import Config
from . import clipboard
from . import modes
from .personas import (
AmbiguousPersona,
KNOWN_MODES,
can_expected_mode,
default_persona_name,
resolve_mode,
resolve_name,
shellbound_personas,
)
from .render import StreamRenderer
logger = logging.getLogger("shellbound")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="shellbound",
description="Terminal AI interface for the Turnstone API. "
"Each run starts (or resumes) an interactive workstream on the cluster "
"and streams the assistant's thinking and reply live.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"MODE is a turnstone persona slug (shellbound_shell, shellbound_answer, ...) "
"or the shortest unambiguous prefix of the part after 'shellbound_' "
"(a=answer, c=creative, sh=shell). 'e' is ambiguous (explain vs expert).\n"
"If MODE matches no persona it is treated as the start of the prompt.\n\n"
"examples:\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 sh \"git status --short\" --no-exec\n"
" shellbound --session # list past sessions\n"
" shellbound --session 8f73ac \"continue\" # resume a session\n"
" shellbound --close all # close all open shellbound_* workspaces\n"
" shellbound --close 8f73ac # close one workspace (fragment)\n"
" shellbound setup # interactive config setup\n"
),
)
parser.add_argument("mode", nargs="?", metavar="MODE", help="persona mode name or prefix")
parser.add_argument("query", nargs="*", metavar="PROMPT", help="the prompt to send (rest of the line)")
parser.add_argument("--session", nargs="?", const="__list__", metavar="FRAG",
help="resume the session identified by FRAG (works as an id "
"prefix, full id, or alias); use without a value to list sessions")
parser.add_argument("--close", nargs="?", const="__all__", metavar="FRAG|all",
help="close an open shellbound_* workspace by fragment, or 'all' "
"of them; use without a value to close all")
parser.add_argument("--list-personas", action="store_true", help="list personae available on the server")
parser.add_argument("--shell", action="store_true", help="force shell mode (execute a proposed command)")
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("--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")
parser.add_argument("--token", metavar="TOKEN", help="token override for this run (not persisted)")
parser.add_argument("--debug", action="store_true", help="verbose logging to stderr")
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
return parser
def _stdout_raw() -> None:
try:
sys.stdout.reconfigure(line_buffering=True)
except Exception: # pragma: no cover - not all streams support reconfigure
pass
async def _print_personas(gw: GatewayClient) -> None:
personas = await gw.list_personas()
if not personas:
sys.stdout.write("no personae configured on the server\n")
return
sl = {p["name"] for p in shellbound_personas(personas)}
width = max(len(p["name"]) for p in personas)
for persona in sorted(personas, key=lambda p: (not p["name"].startswith("shellbound_"), p["name"])):
name = persona["name"]
tag = "shellbound" if name in sl else ("default" if persona.get("is_default") else "stock")
desc = ""
for key in ("description", "display_name"):
if persona.get(key):
desc = persona[key]
break
sys.stdout.write(f" {name:<{width}} [{tag}] {desc}\n")
def _print_sessions(saved: list, out=sys.stdout) -> None:
ordered = sorted(
saved, key=lambda w: (getattr(w, "updated", 0) or 0), reverse=True
)
if not ordered:
out.write("no sessions yet\n")
return
out.write(f"{'#':>2} {'id':<12} {'persona':<18} {'state':<8} {'title'}\n")
for index, w in enumerate(ordered[:25], 1):
persona = getattr(w, "persona", None) or "-"
state = getattr(w, "state", "") or ""
title = getattr(w, "title", None) or getattr(w, "alias", None) or ""
out.write(f"{index:>2} {w.ws_id[:12]:<12} {persona:<18} {state:<8} {title}\n")
out.write("\nresume with: shellbound --session <fragment>\n")
def _compose_prompt(args: argparse.Namespace, mode_consumed: bool, *, resume: bool = False) -> str:
parts: list[str] = []
if args.mode:
if not resume and not mode_consumed:
parts.append(args.mode)
elif resume:
parts.append(args.mode)
parts.extend(args.query)
return " ".join(parts).strip()
def _apply_paste(args: argparse.Namespace, prompt: str, cfg: Config) -> str:
if not args.paste:
return prompt
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}"
def _resolve_saved(saved: list, fragment: str):
"""Return the single workstream matching ``fragment`` (id, id prefix, or alias)."""
exact = [w for w in saved if w.ws_id == fragment]
if exact:
return exact[0]
for w in saved:
aliases = (getattr(w, "alias", None) or "").split(",") if getattr(w, "alias", None) else [getattr(w, "alias", None)]
if any(a and a.strip() == fragment for a in aliases):
return w
matching = [w for w in saved if w.ws_id.startswith(fragment)]
if len(matching) == 1:
return matching[0]
if len(matching) > 1:
raise AmbiguousSession(fragment, matching)
return None
async def _close_command(gw: GatewayClient, target: str) -> int:
"""Close shellbound_* workspaces: an 'all' target closes every open one,
anything else is resolved like ``--session``. Returns an exit code."""
if not target:
target = "__all__"
saved = await gw.list_saved()
if target in ("all", "__all__"):
targets = [
w
for w in saved
if (getattr(w, "persona", "") or "").startswith(SHELLBOUND_PERSONA_PREFIX)
and getattr(w, "state", "") in CLOSABLE_STATES
]
if not targets:
sys.stderr.write("shellbound: no open shellbound_* workspaces to close\n")
return 0
else:
match = _resolve_saved(saved, target)
if match is None:
sys.stderr.write(
f"shellbound: no session matches '{target}' (see `shellbound --session` to list)\n"
)
return 1
if getattr(match, "state", "") not in CLOSABLE_STATES:
sys.stderr.write(
f"shellbound: workspace {match.ws_id[:12]} is {getattr(match, 'state', '?')}; not closing\n"
)
return 0
targets = [match]
closed = 0
for w in targets:
try:
if await gw.close_session(w.ws_id):
sys.stdout.write(f"closed {w.ws_id[:12]} {(getattr(w, 'persona', '') or '-')}\n")
closed += 1
else:
sys.stderr.write(f"shellbound: gateway refused to close {w.ws_id[:12]}\n")
except Exception as exc: # keep going past single failures
sys.stderr.write(f"shellbound: failed to close {w.ws_id[:12]}: {exc}\n")
sys.stderr.write(f"shellbound: closed {closed} workspace(s)\n")
return 0
async def _prune_shellbound(
cfg: Config, gw: GatewayClient, saved: list, exclude_id: str | None = None
) -> None:
"""Close older shellbound_* workspaces beyond the newest ``keep``, leaving
``exclude_id`` (the session just used) alone. Never fatal."""
try:
stale = select_stale_workspaces(saved, cfg.keep_workspaces, exclude_id=exclude_id)
except Exception as exc:
logger.debug("retention scan failed: %s", exc)
return
if not stale:
return
closed = 0
for w in stale:
try:
if await gw.close_session(w.ws_id):
closed += 1
logger.debug("closed stale workspace %s (%s)", w.ws_id[:12], w.persona)
except Exception as exc:
logger.debug("failed to close stale workspace %s: %s", w.ws_id, exc)
if closed:
sys.stderr.write(
f"shellbound: closed {closed} old shellbound workspace(s) "
f"(keeping the {cfg.keep_workspaces} most recent)\n"
)
async def run(args: argparse.Namespace) -> int:
cfg = Config.load()
mode_low = (args.mode or "").lower()
if args.setup or mode_low == "setup":
from .setup import setup as run_setup
run_setup(cfg)
return 0
if mode_low == "token":
sys.stderr.write(
"shellbound: `shellbound token <token>` was removed; "
"use `shellbound setup` to set the API key\n"
)
return 2
if args.token:
cfg["token"] = args.token
if not cfg.has_token:
sys.stderr.write(
"shellbound: no token set -- run `shellbound setup` or set SHELLBOUND_TOKEN\n"
)
return 1
special = [name for cond, name in (
(args.session is not None, "--session"),
(args.close is not None, "--close"),
(args.list_personas, "--list-personas"),
) if cond]
if len(special) > 1:
sys.stderr.write(
f"shellbound: options are mutually exclusive: {', '.join(special)}\n"
)
return 2
_stdout_raw()
gw = GatewayClient(cfg)
try:
if args.close is not None:
return await _close_command(gw, args.close)
if args.list_personas:
await _print_personas(gw)
return 0
personas = await gw.list_personas()
if args.session == "__list__":
saved = await gw.list_saved()
_print_sessions(saved)
return 0
# ---- compose the prompt --------------------------------------
prompt = ""
# ---- pick the target session ---------------------------------
is_resume = args.session is not None
ws_id: Optional[str] = None
node_id: Optional[str] = None
active_persona: Optional[str] = None
mode_consumed = False
if is_resume:
saved = await gw.list_saved()
match = _resolve_saved(saved, args.session)
if match is None:
sys.stderr.write(
f"shellbound: no session matches '{args.session}' "
"(see `shellbound --session` to list)\n"
)
return 1
ws_id = match.ws_id
node_id = await gw.resolve_node(ws_id)
active_persona = getattr(match, "persona", None) or None
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:
explicit = resolve_name(personas, args.persona)["name"]
elif args.shell:
explicit = "shellbound_shell"
if explicit:
if explicit not in (p["name"] for p in personas):
sys.stderr.write(
f"shellbound: persona '{explicit}' is not available on this server\n"
)
else:
active_persona = explicit
elif args.mode:
try:
matched = resolve_mode(personas, args.mode)
except AmbiguousPersona as exc:
sys.stderr.write(f"shellbound: {exc}\n")
return 1
if matched is not None:
active_persona = matched["name"]
mode_consumed = True
elif can_expected_mode(args.mode):
wanted = KNOWN_MODES[args.mode.lower()]
sys.stderr.write(
f"shellbound: mode '{args.mode}' maps to persona "
f"'{wanted}', which is not available on this server; "
"continuing with the default persona\n"
)
mode_consumed = True
if not active_persona:
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:
sys.stderr.write("shellbound: no prompt given\n")
build_parser().print_usage(sys.stderr)
return 2
node_base = gw.node_base(node_id)
session = SessionClient(node_base, cfg["token"], cfg.get("verify_tls", False))
is_shell = args.shell or active_persona == "shellbound_shell"
needs_short_id = not is_resume
def on_event(name: str, payload) -> None:
if name == "content":
renderer.add_content(str(payload))
elif name == "reasoning":
renderer.add_reasoning(str(payload))
elif name == "error":
renderer.add_error(str(payload))
try:
if is_resume:
await session.open(ws_id)
sys.stderr.write(
f"[resuming {ws_id[:12]} node={node_id} "
f"persona={active_persona or 'unknown'}]\n"
)
else:
sys.stderr.write(
f"[new session {ws_id[:12]} node={node_id} "
f"persona={active_persona or 'default'}]\n"
)
renderer = StreamRenderer(plain=args.plain)
result: dict = {}
with renderer:
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
)
if brief["errors"] or not brief["content"]:
renderer.add_error(
"expert brief generation failed"
+ (f": {brief['errors'][-1]}" if brief["errors"] else "")
)
result = brief
else:
result = await run_turn(
session,
ws_id,
modes.expert_retry_task(prompt, brief["content"]),
on_event=on_event,
)
else:
result = await run_turn(
session, ws_id, prompt, on_event=on_event
)
content = result.get("content", "")
if args.copy and content:
if clipboard.copy(content, cfg.get("clipboard")):
sys.stderr.write("\nshellbound: copied response to clipboard\n")
if is_shell and content and not args.no_exec:
commands = modes.extract_commands(content)
if commands:
sys.stderr.write("\nproposed commands:\n")
for command in commands:
sys.stderr.write(f" $ {command}\n")
if sys.stdin.isatty():
answer = input("execute? [y/N] ").strip().lower()
if answer not in ("y", "yes"):
sys.stderr.write("skipped.\n")
else:
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"
)
if not result.get("timed_out") and not result.get("cancelled"):
saved_now = await gw.list_saved()
if needs_short_id:
uid = modes.minimal_uid(
ws_id, [w.ws_id for w in saved_now if w.ws_id != ws_id]
)
sys.stderr.write(
f"\n[session {ws_id} | resume: shellbound --session {uid}]\n"
)
await _prune_shellbound(cfg, gw, saved_now, exclude_id=ws_id)
if result.get("cancelled"):
sys.stderr.write("shellbound: turn cancelled\n")
finally:
await session.aclose()
return 0
finally:
await gw.aclose()
def _normalize_session(argv: list[str]) -> list[str]:
"""Rewrite ``--session [FRAG]`` and ``--close [FRAG|all]`` into ``opt=value``
forms so argparse never swallows the following prompt into the option value."""
out: list[str] = []
index = 0
while index < len(argv):
arg = argv[index]
if arg in ("--session", "--close"):
if index + 1 < len(argv) and not argv[index + 1].startswith("-"):
out.append(f"{arg}={argv[index + 1]}")
index += 2
continue
const = "__list__" if arg == "--session" else "__all__"
out.append(f"{arg}={const}")
index += 1
continue
out.append(arg)
index += 1
return out
def main(argv: Optional[list[str]] = None) -> int:
args = build_parser().parse_args(_normalize_session(list(argv or sys.argv[1:])))
level = logging.DEBUG if args.debug else logging.WARNING
logging.basicConfig(
level=level,
stream=sys.stderr,
format="[shellbound] %(levelname)s %(message)s",
)
for noisy in ("httpx", "httpcore"):
logging.getLogger(noisy).setLevel(logging.DEBUG if args.debug else logging.CRITICAL)
try:
return asyncio.run(run(args))
except AmbiguousSession as exc:
sys.stderr.write(f"shellbound: {exc}\n")
return 1
except AmbiguousPersona as exc:
sys.stderr.write(f"shellbound: {exc}\n")
return 1
except ShellboundError as exc:
sys.stderr.write(f"shellbound: {exc}\n")
return 1
except KeyboardInterrupt:
sys.stderr.write("\nshellbound: interrupted\n")
return 130
except Exception as exc: # pragma: no cover - top-level safety net
if args.debug:
traceback.print_exc()
else:
sys.stderr.write(f"shellbound: unexpected error: {exc}\n")
return 1
if __name__ == "__main__":
raise SystemExit(main())
+421
View File
@@ -0,0 +1,421 @@
"""Turnstone API clients used by shellbound.
Two layers:
- :class:`GatewayClient` -- cluster-aware operations against the gateway
(create interactive sessions via ``/v1/api/route/workstreams/new``, resolve
a workstream's owning node, list saved workstreams, list personae).
- :class:`SessionClient` -- per-node operations for a single interactive
workstream (open/load, history, send, SSE event stream), built on the
``AsyncTurnstoneServer`` SDK with an injected ``httpx`` client that does not
verify TLS (the lab gateway uses a self-signed certificate).
The SDK's ``send_and_wait`` waits for a coordinator ``ws_state`` idle event
that is not emitted on per-workstream node streams, so shellbound implements
its own consumption loop keyed on ``state_change -> idle`` after the turn has
actually started.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import traceback
from datetime import datetime
from typing import Any, AsyncIterator, Callable
import httpx
from turnstone.sdk import AsyncTurnstoneServer
from turnstone.sdk import events as ev
from .config import Config
logger = logging.getLogger("shellbound")
TURN_WAIT_TIMEOUT = 900.0
STREAM_RESTART_LIMIT = 5
SHELLBOUND_PERSONA_PREFIX = "shellbound_"
CLOSABLE_STATES = ("idle",)
class ShellboundError(RuntimeError):
"""Fatal, user-facing error."""
class AmbiguousSession(ShellboundError):
"""More than one session matched a fragment."""
def __init__(self, fragment: str, candidates: list):
self.fragment = fragment
self.candidates = candidates
short = ", ".join(w.ws_id[:12] for w in candidates)
super().__init__(
f"'{fragment}' is ambiguous ({len(candidates)} matches: {short}); "
"use a longer prefix or the full workstream id"
)
def build_http(base_url: str, cfg: Config) -> httpx.AsyncClient:
token = cfg.get("token") or ""
headers = {"Authorization": f"Bearer {token}"} if token else {}
return httpx.AsyncClient(
base_url=base_url,
verify=bool(cfg.get("verify_tls")),
headers=headers,
timeout=httpx.Timeout(60.0, connect=30.0, read=600.0),
)
def _expect_json(response: httpx.Response, what: str) -> dict:
try:
return response.json()
except Exception: # pragma: no cover - defensive
raise ShellboundError(
f"{what}: HTTP {response.status_code} returned non-JSON body: "
f"{response.text[:300]!r}"
) from None
def _sort_key(workstream: Any):
updated = getattr(workstream, "updated", None) or ""
try:
return datetime.fromisoformat(updated)
except (TypeError, ValueError):
return datetime.min
def select_stale_workspaces(
workstreams: list, keep: int, exclude_id: str | None = None
) -> list:
"""Choose ``shellbound_*`` interactive workspaces to close.
The ``keep`` most recently updated ones are kept open; older ones that are
still in a closable state (``idle``) and not the ``exclude_id`` are
returned for closing. ``keep = 0`` is allowed (close everything stale).
"""
candidates = [
w
for w in workstreams
if (getattr(w, "persona", None) or "").startswith(SHELLBOUND_PERSONA_PREFIX)
and getattr(w, "state", "") in CLOSABLE_STATES
and (getattr(w, "ws_id", "") != (exclude_id or ""))
]
ordered = sorted(candidates, key=_sort_key, reverse=True)
keep = max(int(keep or 0), 0)
return ordered[keep:]
class GatewayClient:
"""Operations that talk to the gateway (coordinator / rendezvous)."""
def __init__(self, cfg: Config):
self.cfg = cfg
self.gateway = (cfg.get("gateway") or "").rstrip("/")
self._http = build_http(self.gateway, cfg)
self._sdk = AsyncTurnstoneServer(base_url=self.gateway, httpx_client=self._http)
async def aclose(self) -> None:
await self._http.aclose()
async def list_personas(self) -> list[dict]:
response = await self._http.get("/v1/api/personas")
data = _expect_json(response, "listing personas")
if response.status_code != 200:
raise ShellboundError(
f"Failed to list personas: HTTP {response.status_code}: {data}"
)
return data.get("personas", [])
async def create_session(
self, persona: str | None, name: str = "shellbound"
) -> dict[str, str]:
body: dict[str, Any] = {"name": name, "kind": "interactive", "client_type": "cli"}
if persona:
body["persona"] = persona
response = await self._http.post("/v1/api/route/workstreams/new", json=body)
data = _expect_json(response, "creating interactive session")
if response.status_code != 200:
raise ShellboundError(
f"Failed to create session: HTTP {response.status_code}: {data}"
)
ws_id = data.get("ws_id")
node_id = data.get("node_id")
if not ws_id or not node_id:
raise ShellboundError(f"Unexpected create response: {data}")
return {"ws_id": ws_id, "node_id": node_id}
async def resolve_node(self, ws_id: str) -> str:
response = await self._http.get("/v1/api/route", params={"ws_id": ws_id})
data = _expect_json(response, "resolving session node")
if response.status_code != 200:
raise ShellboundError(
f"Could not resolve node for {ws_id}: HTTP {response.status_code}: {data}"
)
node_id = (data or {}).get("node_id")
if not node_id:
raise ShellboundError(f"Could not resolve node for {ws_id}: {data}")
return node_id
async def list_saved(self) -> list[Any]:
response = await self._sdk.list_saved_workstreams()
return list(getattr(response, "workstreams", []) or [])
async def close_session(self, ws_id: str) -> bool:
response = await self._http.post(f"/v1/api/route/workstreams/{ws_id}/close", json={})
data = _expect_json(response, f"closing {ws_id}")
if response.status_code != 200:
logger.debug("close %s -> %s: %s", ws_id, response.status_code, data)
return False
return True
def node_base(self, node_id: str) -> str:
return f"{self.gateway}/node/{node_id}"
class SessionClient:
"""Per-node operations for one interactive workstream."""
def __init__(self, node_base: str, token: str, verify_tls: bool):
self.base_url = node_base
cfg = Config({"token": token, "verify_tls": verify_tls})
self._http = build_http(self.base_url, cfg)
self.sdk = AsyncTurnstoneServer(base_url=self.base_url, httpx_client=self._http)
async def aclose(self) -> None:
await self._http.aclose()
async def open(self, ws_id: str, *, force: bool = False) -> dict:
body = {} if not force else {"force": True}
response = await self._http.post(f"/v1/api/workstreams/{ws_id}/open", json=body)
data = _expect_json(response, f"opening {ws_id}")
if response.status_code != 200:
raise ShellboundError(
f"Failed to open session {ws_id}: HTTP {response.status_code}: {data}"
)
return data
async def history(self, ws_id: str, limit: int = 50) -> Any:
return await self.sdk.get_history(ws_id, limit=limit)
async def send(self, ws_id: str, message: str) -> Any:
return await self.sdk.send(message, ws_id)
async def replay_events(
self,
ws_id: str,
last_event_id: int | None = None,
history_token: str | None = None,
) -> AsyncIterator[Any]:
async for event in self.sdk.stream_events(
ws_id, last_event_id=last_event_id, history_token=history_token
):
yield event
async def events(self, ws_id: str) -> AsyncIterator[Any]:
async for event in self.sdk.stream_events(ws_id):
yield event
async def cancel(self, ws_id: str, force: bool = False) -> None:
with contextlib.suppress(Exception):
await self.sdk.cancel(ws_id, force=force)
async def run_turn(
client: SessionClient,
ws_id: str,
message: str,
on_event: Callable[[str, object], None] | None = None,
*,
timeout: float = TURN_WAIT_TIMEOUT,
) -> dict:
"""Send ``message`` and stream the turn to completion.
Consumption loop owns the terminal condition: a ``state_change -> idle``
is only terminal after the turn has begun (``user_turn`` or
``thinking_start``), which ignores the idle replay emitted on connect.
"""
class _Turn:
__slots__ = (
"content",
"reasoning",
"errors",
"cancelled",
"send_status",
"turn_detected",
"finished",
"cursor_id",
"cursor_token",
)
def __init__(self):
self.content: list[str] = []
self.reasoning: list[str] = []
self.errors: list[str] = []
self.cancelled = False
self.send_status: str | None = None
self.turn_detected = asyncio.Event()
self.finished = asyncio.Event()
self.cursor_id: int | None = None
self.cursor_token: str | None = None
turn = _Turn()
def emit(name: str, payload) -> None:
if on_event is not None:
try:
on_event(name, payload)
except Exception: # pragma: no cover - renderer must not kill us
logger.exception("event handler failed")
async def route(event) -> bool:
"""Handle a single event; return True when the turn is over."""
logger.debug("event %s: %s", type(event).__name__, event)
if isinstance(event, ev.UserTurnEvent):
turn.turn_detected.set()
if getattr(event, "content", None):
emit("user_turn", event.content)
elif isinstance(event, ev.ThinkingStartEvent):
turn.turn_detected.set()
elif isinstance(event, ev.ThinkingStopEvent):
pass
elif isinstance(event, ev.ReasoningEvent):
turn.reasoning.append(event.text)
emit("reasoning", event.text)
elif isinstance(event, ev.ContentEvent):
turn.content.append(event.text)
emit("content", event.text)
elif isinstance(event, ev.InProgressSnapshotEvent):
snap_content = getattr(event, "content", None)
snap_reasoning = getattr(event, "reasoning", None)
if snap_reasoning:
turn.reasoning.append(snap_reasoning)
emit("reasoning", snap_reasoning)
if snap_content:
turn.content.append(snap_content)
emit("content", snap_content)
elif isinstance(event, ev.StateChangeEvent):
state = getattr(event, "state", None)
if state == "idle" and turn.turn_detected.is_set():
turn.finished.set()
return True
elif isinstance(event, ev.BusyErrorEvent):
turn.errors.append(f"session is busy: {event.message}")
emit("error", event.message)
turn.finished.set()
return True
elif isinstance(event, ev.ErrorEvent):
turn.errors.append(event.message)
emit("error", event.message)
elif isinstance(event, ev.CancelledEvent):
turn.cancelled = True
emit("info", "turn cancelled")
turn.finished.set()
return True
elif isinstance(event, ev.InfoEvent):
emit("info", event.message)
elif isinstance(event, ev.HistoryResyncEvent):
logger.debug("history resync requested: %s", event.reason)
try:
history = await client.history(ws_id, limit=100)
turn.cursor_token = (
getattr(history, "history_token", None)
or getattr(history, "handoff_token", None)
)
turn.cursor_id = None
messages = getattr(history, "messages", None) or []
for msg in reversed(messages):
event_id = getattr(msg, "event_id", None)
if event_id is not None:
turn.cursor_id = event_id
break
except Exception as exc: # pragma: no cover - defensive
logger.debug("resync history fetch failed: %s", exc)
turn.errors.append(f"stream resync failed: {exc}")
turn.finished.set()
return True
return "restart"
elif not isinstance(event, (ev.ClearUiEvent, ev.ConnectedEvent, ev.StreamEndEvent)):
logger.debug("ignoring event %s", type(event).__name__)
return False
async def _consume() -> None:
restarts = 0
while not turn.finished.is_set():
if restarts >= STREAM_RESTART_LIMIT:
turn.errors.append("stream restarted too many times")
turn.finished.set()
return
try:
async for event in client.replay_events(
ws_id,
last_event_id=turn.cursor_id,
history_token=turn.cursor_token,
):
result = await route(event)
if result is True:
return
if result == "restart":
break
except httpx.HTTPError as exc:
if turn.finished.is_set():
return
logger.debug("stream transport error: %s", exc)
restarts += 1
await asyncio.sleep(0.6)
continue
except asyncio.CancelledError:
raise
except Exception as exc:
logger.debug("consume loop failure:\n%s", traceback.format_exc())
turn.errors.append(str(exc))
turn.finished.set()
return
# stream ended without an idle event -- re-arm cursor and retry
if not turn.finished.is_set():
restarts += 1
await asyncio.sleep(0.3)
return
consumer = asyncio.create_task(_consume())
await asyncio.sleep(0.4) # let the stream connect before sending
try:
response = await client.send(ws_id, message)
turn.send_status = getattr(response, "status", None)
if turn.send_status == "busy":
turn.errors.append("workstream is busy from another client")
emit("error", "workstream is busy (another client)")
elif turn.send_status not in ("ok", "queued"):
turn.errors.append(f"send failed: {turn.send_status}")
emit("error", f"send failed: {turn.send_status}")
except Exception as exc:
turn.errors.append(str(exc))
emit("error", str(exc))
if turn.send_status not in ("ok", "queued") or (
turn.errors and not turn.turn_detected.is_set()
):
turn.finished.set()
if not turn.finished.is_set():
try:
await asyncio.wait_for(turn.finished.wait(), timeout=timeout)
except asyncio.TimeoutError:
turn.errors.append("turn timed out")
emit("error", "turn timed out")
with contextlib.suppress(Exception):
await client.cancel(ws_id, force=True)
if not consumer.done():
consumer.cancel()
with contextlib.suppress(asyncio.CancelledError):
await consumer
return {
"content": "".join(turn.content),
"reasoning": "".join(turn.reasoning),
"errors": turn.errors,
"cancelled": turn.cancelled,
"timed_out": bool(turn.errors) and turn.errors[-1] == "turn timed out",
"send_status": turn.send_status,
}
+67
View File
@@ -0,0 +1,67 @@
"""Clipboard helpers.
Backends are probed in order (wl-copy/wl-paste for Wayland, then xclip for
X11). Set ``clipboard`` in config to pin a backend name.
"""
from __future__ import annotations
import shutil
import subprocess
import sys
BACKENDS = [
("wl-copy", ["wl-copy"], ["wl-paste"]),
("xclip", ["xclip", "-selection", "clipboard"], ["xclip", "-selection", "clipboard", "-o"]),
("pbcopy", ["pbcopy"], ["pbpaste"]),
("xsel", ["xsel", "--clipboard", "--input"], ["xsel", "--clipboard", "--output"]),
]
def _backend(wanted: str | None) -> tuple[str, list[str], list[str]]:
if wanted and wanted != "auto":
for name, _copy, _paste in BACKENDS:
if name == wanted:
return name, _copy, _paste
raise RuntimeError(f"unknown clipboard backend: {wanted}")
for name, _copy, _paste in BACKENDS:
if shutil.which(_copy[0]) and shutil.which(_paste[0]):
return name, _copy, _paste
raise RuntimeError("no clipboard backend found (install xclip or wl-copy/wl-paste)")
def backend_available(wanted: str | None = None) -> bool:
try:
_backend(wanted)
return True
except RuntimeError:
return False
def copy(text: str, wanted: str | None = None) -> bool:
try:
name, cmd, _paste = _backend(wanted)
except RuntimeError as exc:
sys.stderr.write(f"shellbound: copy: {exc}\n")
return False
try:
subprocess.run(
cmd, input=text.encode(), check=True, stdout=subprocess.DEVNULL
)
return True
except (OSError, subprocess.SubprocessError) as exc:
sys.stderr.write(f"shellbound: copy via {name} failed: {exc}\n")
return False
def paste(wanted: str | None = None) -> str:
try:
name, _copy, cmd = _backend(wanted)
except RuntimeError as exc:
sys.stderr.write(f"shellbound: paste: {exc}\n")
return ""
try:
return subprocess.run(cmd, check=True, capture_output=True).stdout.decode()
except (OSError, subprocess.SubprocessError) as exc:
sys.stderr.write(f"shellbound: paste via {name} failed: {exc}\n")
return ""
+93
View File
@@ -0,0 +1,93 @@
"""Shellbound configuration.
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/...)
``node_base_template`` was removed from the schema (never changes); any stale
value left in an existing config is pruned on load.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
CONFIG_DIR = Path.home() / ".shellbound"
CONFIG_FILE = CONFIG_DIR / "config.json"
DEFAULT_GATEWAY = "https://soda.lab.obelisk.cc:8443"
DEFAULT_KEEP_WORKSPACES = 5
class Config(dict):
"""Dict-backed config with attribute access for convenience."""
def __getattr__(self, item: str):
if item.startswith("_"):
raise AttributeError(item)
try:
return self[item]
except KeyError:
raise AttributeError(item) from None
def __setattr__(self, item: str, value) -> None:
self[item] = value
@classmethod
def load(cls) -> "Config":
data: dict = {}
if CONFIG_FILE.exists():
try:
data = json.loads(CONFIG_FILE.read_text())
except (json.JSONDecodeError, OSError):
data = {}
data.pop("node_base_template", None)
env = {
"SHELLBOUND_GATEWAY": "gateway",
"SHELLBOUND_TOKEN": "token",
"SHELLBOUND_VERIFY_TLS": "verify_tls",
"SHELLBOUND_KEEP_WORKSPACES": "keep_workspaces",
"SHELLBOUND_CLIPBOARD": "clipboard",
}
for env_key, cfg_key in env.items():
val = os.environ.get(env_key)
if val is None:
continue
if cfg_key == "verify_tls":
data[cfg_key] = val.lower() in ("1", "true", "yes", "on")
elif cfg_key == "keep_workspaces":
try:
data[cfg_key] = int(val)
except ValueError:
pass
else:
data[cfg_key] = val
data.setdefault("gateway", DEFAULT_GATEWAY)
data.setdefault("token", "")
data.setdefault("verify_tls", False)
data.setdefault("clipboard", "auto")
data.setdefault("keep_workspaces", DEFAULT_KEEP_WORKSPACES)
return cls(data)
def save(self) -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
CONFIG_FILE.write_text(json.dumps(dict(self), indent=2) + "\n")
@property
def has_token(self) -> bool:
return bool(self.get("token"))
@property
def keep_workspaces(self) -> int:
try:
value = int(self.get("keep_workspaces", DEFAULT_KEEP_WORKSPACES))
except (TypeError, ValueError):
return DEFAULT_KEEP_WORKSPACES
return max(value, 0)
+88
View File
@@ -0,0 +1,88 @@
"""Mode-specific behaviors: expert two-stage orchestration, shell command
extraction and execution, and small helpers.
Expert mode runs two stages against the ``shellbound_expert`` persona (whose
system prompt already carries the expert-brief guidance, so none of that detail
needs to be injected into the conversation). Only short stage markers plus the
relevant payload are sent as turn messages.
"""
from __future__ import annotations
import subprocess
import sys
STAGE_1_TASK = (
"Stage 1 - Produce an Expert Brief to answer the corresponding user prompt.\n"
"Produce the Expert Brief only.\n\n"
"USER PROMPT:\n{user_query}"
)
STAGE_2_TASK = (
"Stage 2 - Adopt the following Expert Persona to answer the user prompt.\n"
"Follow the Expert Persona exactly, then answer the user prompt.\n\n"
"EXPERT PERSONA (Expert Brief):\n{meta}\n\n"
"USER PROMPT:\n{user_query}"
)
def expert_brief_task(user_query: str) -> str:
return STAGE_1_TASK.format(user_query=user_query)
def expert_retry_task(user_query: str, meta: str) -> str:
return STAGE_2_TASK.format(meta=meta, user_query=user_query)
def session_name(query: str, limit: int = 60) -> str:
flat = " ".join(query.split())
return f"sb: {flat[:limit]}"
def minimal_uid(ws_id: str, others: list[str]) -> str:
"""Shortest prefix of ``ws_id`` that is unique among ``others``."""
for size in range(4, len(ws_id) + 1):
prefix = ws_id[:size]
if not any(other != ws_id and other.startswith(prefix) for other in others):
return prefix
return ws_id[:12]
def extract_commands(raw: str) -> list[str]:
"""Extract shell commands from a response.
Prefers the content of a fenced code block; otherwise uses non-empty,
non-comment lines. Shared lines are stripped of a leading ``$``.
"""
text = (raw or "").strip()
if not text:
return []
fenced: list[str] = []
in_fence = False
for line in text.splitlines():
s = line.strip()
if s.startswith("```") or s == "~~~":
in_fence = not in_fence
continue
if in_fence:
fenced.append(line)
source = fenced if fenced else text.splitlines()
commands: list[str] = []
for line in source:
s = line.strip()
if not s or s.startswith("#"):
continue
if s.startswith("$"):
s = s[1:].strip()
commands.append(s)
return commands
def run_commands(commands: list[str]) -> int:
"""Run each proposed command locally through ``bash -c`` (top-level)."""
for command in commands:
sys.stderr.write(f"$ {command}\n")
rc = subprocess.call(["bash", "-c", command])
if rc != 0:
sys.stderr.write(f"shellbound: command exited with status {rc}\n")
return 0
+100
View File
@@ -0,0 +1,100 @@
"""Persona (mode) resolution.
Modes are Turnstone personae whose slugs start with ``shellbound_``. A mode
argument is resolved as a case-insensitive shortest-unique prefix of that
slug. Zero matches means the argument is treated as part of the query; more
than one match (e.g. ``e`` for ``explain``/``expert``) is an ambiguity error.
"""
from __future__ import annotations
from typing import Optional
SHELLBOUND_PREFIX = "shellbound_"
# Canonical modes this CLI knows about, used to detect "persona not yet
# provisioned on the server" so intent is never silently swallowed into the
# query.
KNOWN_MODES = {
"shell": "shellbound_shell",
"s": "shellbound_shell",
"answer": "shellbound_answer",
"a": "shellbound_answer",
"explain": "shellbound_explain",
"creative": "shellbound_creative",
"c": "shellbound_creative",
"poetic": "shellbound_poetic",
"p": "shellbound_poetic",
"expert": "shellbound_expert",
}
class AmbiguousPersona(ValueError):
def __init__(self, fragment: str, candidates: list[str]):
self.fragment = fragment
self.candidates = candidates
super().__init__(
f"'{fragment}' is ambiguous ({len(candidates)} personae: "
f"{', '.join(sorted(candidates))}); supply more characters"
)
def shellbound_personas(raw: list[dict]) -> list[dict]:
return [p for p in raw if p.get("name", "").startswith(SHELLBOUND_PREFIX)]
def interactions(raw: list[dict]) -> list[dict]:
return [p for p in raw if "interactive" in (p.get("applies_to_kinds") or [])]
def can_expected_mode(fragment: str) -> bool:
return fragment.strip().lower() in KNOWN_MODES
def resolve_mode(raw: list[dict], fragment: str) -> Optional[dict]:
"""Resolve a mode fragment to a ``shellbound_*`` persona.
Returns ``None`` when the fragment matches nothing (it should then be
treated as the start of the query). Raises :class:`AmbiguousPersona`
when more than one persona matches.
"""
fragment = fragment.strip()
if not fragment:
return None
low = fragment.lower()
matches: list[dict] = []
for p in shellbound_personas(raw):
name = p["name"]
if name.lower() == low or name.lower().startswith(SHELLBOUND_PREFIX + low):
matches.append(p)
if len(matches) > 1:
raise AmbiguousPersona(fragment, [p["name"] for p in matches])
return matches[0] if matches else None
def resolve_name(raw: list[dict], name: str) -> dict:
"""Resolve an explicit ``--persona`` value (exact name or unique prefix)."""
name = name.strip()
if not name:
raise ValueError("empty persona name")
low = name.lower()
matches = [
p for p in interactions(raw)
if p["name"].lower() == low or p["name"].lower().startswith(low)
]
if not matches:
available = ", ".join(sorted(p["name"] for p in raw))
raise ValueError(f"unknown persona '{name}' (available: {available})")
if len(matches) > 1:
raise AmbiguousPersona(name, [p["name"] for p in matches])
return matches[0]
def default_persona_name(raw: list[dict]) -> Optional[str]:
"""Preferred default persona (answer) with fallbacks."""
sl = [p["name"] for p in shellbound_personas(raw)]
for candidate in ("shellbound_answer", "shellbound_shell"):
if candidate in sl:
return candidate
default = next((p["name"] for p in raw if p.get("is_default")), None)
return default
+184
View File
@@ -0,0 +1,184 @@
"""Streaming terminal renderer.
In a TTY the assistant's reply renders live into a :class:`rich.live.Live`
frame: already-completed Markdown blocks are laid out once (boundary-flushed)
while the in-fight tail streams as bright-white plain text, and the thinking
shows as dim italic text above. On a non-TTY or with ``--plain`` the raw
Markdown source streams to stdout instead (reasoning stays silent there).
"""
from __future__ import annotations
import sys
from typing import Optional
from rich.console import Console, Group, RenderableType
from rich.live import Live
from rich.markdown import Markdown
from rich.rule import Rule
from rich.text import Text
REASONING_STYLE = "bright_black italic"
CONTENT_STYLE = "white"
WARN_STYLE = "yellow"
ERROR_STYLE = "red bold"
FORCE_FLUSH_CHARS = 4000
def is_tty() -> bool:
return sys.stdout.isatty()
class StreamRenderer:
"""Accumulates a turn and renders it live (or plainly)."""
def __init__(self, *, plain: Optional[bool] = None):
auto_plain = not is_tty()
self.plain = plain if plain is not None else auto_plain
self.reasoning: list[str] = []
self.content_parts: list[str] = []
self.flushed: list[RenderableType] = []
self.tail = ""
self._messages: list[RenderableType] = [] # sticky info/error lines
self._live: Optional[Live] = None
self._rendered = 0
self._console = Console(
color_system="auto",
markup=False,
highlight=None,
tab_size=4,
force_terminal=not self.plain and is_tty(),
)
# -- lifecycle -------------------------------------------------------
def start(self) -> None:
if not self.plain:
self._live = Live(
console=self._console,
refresh_per_second=12,
vertical_overflow="visible",
)
self._live.start(refresh=False)
def stop(self) -> None:
if self._live is not None:
self._live.stop()
self._live = None
def __enter__(self) -> "StreamRenderer":
self.start()
return self
def __exit__(self, *exc) -> None:
if not self.plain:
self.finish()
self.stop()
# -- content ---------------------------------------------------------
def add_reasoning(self, text: str) -> None:
if not text:
return
self.reasoning.append(text)
if not self.plain:
self.refresh()
def add_content(self, text: str) -> None:
if not text:
return
self.content_parts.append(text)
if self.plain:
sys.stdout.write(text)
sys.stdout.flush()
return
self.tail += text
self._maybe_flush()
self.refresh()
def add_info(self, message: str) -> None:
if not message:
return
if self.plain:
sys.stderr.write(f"shellbound: {message}\n")
sys.stderr.flush()
else:
self._messages.append(Text(str(message), style=WARN_STYLE))
self.refresh()
def add_error(self, message: str) -> None:
if self.plain:
sys.stderr.write(f"shellbound: {message}\n")
sys.stderr.flush()
else:
self._messages.append(Text(f"error: {message}", style=ERROR_STYLE))
self.refresh()
# -- flushing --------------------------------------------------------
def _fence_count(self) -> int:
return self.tail.count("```")
def _maybe_flush(self) -> None:
if not self.tail:
return
fences = self._fence_count()
if fences % 2 == 1:
# inside an open fenced block: only force-flush raw at the cap
if len(self.tail) > FORCE_FLUSH_CHARS:
self._flush(markdown=False)
return
if self.tail.rstrip().endswith("```"):
# fenced block just closed
self._flush(markdown=True)
return
if self.tail.endswith("\n\n"):
self._flush(markdown=True)
elif len(self.tail) > FORCE_FLUSH_CHARS:
self._flush(markdown=False)
def _flush(self, *, markdown: bool) -> None:
block = self.tail
self.tail = ""
if not block.strip():
return
if markdown:
self.flushed.append(Markdown(block))
else:
self.flushed.append(Text(block, style=CONTENT_STYLE))
# -- display ---------------------------------------------------------
def _renderable(self) -> RenderableType:
items: list[RenderableType] = []
if self.reasoning:
items.append(Text("".join(self.reasoning), style=REASONING_STYLE))
if self.content_parts or self.flushed or self.tail:
items.append(Rule(style="bright_black"))
items.extend(self.flushed)
if self.tail:
items.append(Text(self.tail, style=CONTENT_STYLE))
items.extend(self._messages)
if not items:
return Text("")
if len(items) == 1:
return items[0]
return Group(*items)
def refresh(self) -> None:
if self._live is not None:
self._live.update(self._renderable())
def finish(self) -> str:
"""Flush any pending tail and return the raw streamed reply text."""
if not self.plain and self.tail.strip():
self._flush(markdown=True)
if not self.plain:
self.refresh()
return "".join(self.content_parts)
# -- plain helpers ---------------------------------------------------
@property
def reasoning_text(self) -> str:
return "".join(self.reasoning)
+112
View File
@@ -0,0 +1,112 @@
"""Interactive setup for shellbound.
Runs outside asyncio (``input``/``getpass``) and rewrites
``~/.shellbound/config.json``. Existing values are remembered: pressing Enter
keeps the current value. The API key is never echoed; it is entered masked via
:func:`getpass`.
"""
from __future__ import annotations
import getpass
import urllib.parse
from .config import Config
CLIPBOARD_BACKENDS = ("auto", "xclip", "xsel", "wl-copy", "pbcopy")
def _mask(value: str, show: int = 4) -> str:
if not value:
return "(not set)"
if len(value) <= show:
return "•" * len(value)
return "•" * (len(value) - show) + value[-show:]
def _edit(question: str, current: str, display: str | None = None) -> str:
shown = _mask(current) if display == "mask" else current or "(not set)"
prompt = f"{question} [{shown}] > "
try:
raw = input(prompt)
except (EOFError, KeyboardInterrupt):
print()
return current
cleaned = raw.strip()
return current if not cleaned else cleaned
def _edit_bool(question: str, current: bool) -> bool:
label = "yes" if current else "no"
while True:
value = _edit(question, label)
low = value.lower()
if low in ("1", "true", "yes", "y", "on"):
return True
if low in ("0", "false", "no", "n", "off"):
return False
print(" answer y/n")
def _edit_clipboard(current: str) -> str:
while True:
value = _edit(
f"Clipboard backend ({'/'.join(CLIPBOARD_BACKENDS)})",
current,
)
if value.lower() not in CLIPBOARD_BACKENDS:
print(f" choose one of: {', '.join(CLIPBOARD_BACKENDS)}")
continue
return value.lower()
def _edit_token(current: str) -> str:
prompt = getpass.getpass(
f"API key [paste a new key, or Enter to keep {_mask(current)}] > "
)
cleaned = prompt.strip()
return current if not cleaned else cleaned
def setup(cfg: Config) -> Config:
"""Interactive config walkthrough; returns the updated config."""
print("shellbound setup - press Enter to keep the current value.\n")
gateway = _edit("Gateway", cfg.get("gateway", ""))
parsed = urllib.parse.urlparse(gateway)
while parsed.scheme not in ("http", "https") or not parsed.netloc:
print(f" not a valid gateway URL: {gateway!r}")
gateway = _edit("Gateway", cfg.get("gateway", ""))
parsed = urllib.parse.urlparse(gateway)
token = _edit_token(cfg.get("token", ""))
keep = _edit("Keep open this many recent shellbound_* workspaces", str(cfg.keep_workspaces))
while True:
try:
keep = max(int(keep), 0)
break
except ValueError:
print(f" invalid number: {keep!r}")
keep = _edit(
"Keep open this many recent shellbound_* workspaces", str(cfg.keep_workspaces)
)
verify = _edit_bool(
"Verify TLS certificates (self-signed lab cert: choose no)",
cfg.get("verify_tls", False),
)
clipboard = _edit_clipboard(cfg.get("clipboard", "auto"))
cfg["gateway"] = gateway
cfg["token"] = token
cfg["keep_workspaces"] = keep
cfg["verify_tls"] = bool(verify)
cfg["clipboard"] = clipboard
cfg.save()
print("\nshellbound setup complete.")
print(f" gateway: {gateway}")
print(f" API key: {_mask(token)}")
print(f" keep_workspaces: {keep}")
print(f" verify_tls: {'yes' if verify else 'no'}")
print(f" clipboard: {clipboard}")
return cfg
+234
View File
@@ -0,0 +1,234 @@
"""Unit tests for shellbound (no network required)."""
from __future__ import annotations
import json
from shellbound import modes
from shellbound.personas import (
AmbiguousPersona,
can_expected_mode,
default_persona_name,
resolve_mode,
resolve_name,
shellbound_personas,
)
RAW = [
{"name": "shellbound_shell", "applies_to_kinds": ["interactive"]},
{"name": "shellbound_answer", "applies_to_kinds": ["interactive"]},
{"name": "shellbound_creative", "applies_to_kinds": ["interactive"]},
{"name": "shellbound_expert", "applies_to_kinds": ["interactive"]},
{"name": "shellbound_explain", "applies_to_kinds": ["interactive"]},
{"name": "shellbound_poetic", "applies_to_kinds": ["interactive"]},
{"name": "engineer", "applies_to_kinds": ["interactive"], "is_default": True},
{"name": "orchestrator", "applies_to_kinds": ["coordinator"]},
]
def test_shellbound_filter():
slugs = {p["name"] for p in shellbound_personas(RAW)}
assert slugs == {
"shellbound_shell",
"shellbound_answer",
"shellbound_creative",
"shellbound_expert",
"shellbound_explain",
"shellbound_poetic",
}
def test_unique_abbreviations():
assert resolve_mode(RAW, "s")["name"] == "shellbound_shell"
assert resolve_mode(RAW, "a")["name"] == "shellbound_answer"
assert resolve_mode(RAW, "c")["name"] == "shellbound_creative"
assert resolve_mode(RAW, "p")["name"] == "shellbound_poetic"
assert resolve_mode(RAW, "sh")["name"] == "shellbound_shell"
assert resolve_mode(RAW, "expe")["name"] == "shellbound_expert"
assert resolve_mode(RAW, "expl")["name"] == "shellbound_explain"
assert resolve_mode(RAW, "shellbound_shell")["name"] == "shellbound_shell"
assert resolve_mode(RAW, "SHELLBOUND_ANSWER")["name"] == "shellbound_answer"
def test_ambiguous_abbreviation():
try:
resolve_mode(RAW, "e")
except AmbiguousPersona as exc:
assert set(exc.candidates) == {"shellbound_expert", "shellbound_explain"}
else:
raise AssertionError("expected AmbiguousPersona")
def test_no_match_is_query():
assert resolve_mode(RAW, "zzz") is None
assert can_expected_mode("zzz") is False
assert can_expected_mode("s") is True
def test_resolve_name():
assert resolve_name(RAW, "engineer")["name"] == "engineer"
assert resolve_name(RAW, "shellbound_poetic")["name"] == "shellbound_poetic"
try:
resolve_name(RAW, "orchestrator") # coordinator kind, not interactive
except ValueError:
pass
else:
raise AssertionError("orchestrator should be rejected (non-interactive)")
def test_default_persona():
assert default_persona_name(RAW) == "shellbound_answer"
def test_extract_commands_fenced():
raw = '```\nls -la\n# hide me\necho hi\n```\n'
assert modes.extract_commands(raw) == ["ls -la", "echo hi"]
def test_extract_commands_plain():
assert modes.extract_commands("whoami\necho ok") == ["whoami", "echo ok"]
assert modes.extract_commands("# comment only") == []
def test_extract_commands_dollar_prompt():
assert modes.extract_commands("$ git status") == ["git status"]
def test_minimal_uid():
assert modes.minimal_uid("abc12345", ["abc99999", "abc12346"]) == "abc12345"
assert modes.minimal_uid("aaa11111", []) == "aaa1"
def test_session_name():
assert modes.session_name(" why is the sky blue? ") == "sb: why is the sky blue?"
def test_expert_prompts():
task = modes.expert_brief_task("hello")
assert task.startswith("Stage 1 - Produce an Expert Brief")
assert "USER PROMPT:" in task and "hello" in task
assert "prompt writing agent" not in task
retry = modes.expert_retry_task("hello", "META")
assert retry.startswith("Stage 2 - Adopt the following Expert Persona")
assert "EXPERT PERSONA" in retry and "META" in retry
assert "USER PROMPT:" in retry and "hello" in retry
def _ws(ws_id, persona, state, updated):
ns = type("W", (), {})()
ns.ws_id = ws_id
ns.persona = persona
ns.state = state
ns.updated = updated
return ns
def test_select_stale_keeps_newest_n():
from shellbound.client import select_stale_workspaces
workstreams = [
_ws("aaaa", "shellbound_answer", "idle", "2026-09-05T10:00:00"),
_ws("bbbb", "shellbound_answer", "idle", "2026-09-05T11:00:00"),
_ws("cccc", "engineer", "idle", "2026-09-05T12:00:00"), # not shellbound_
_ws("dddd", "shellbound_shell", "idle", "2026-09-05T09:00:00"),
]
stale = select_stale_workspaces(workstreams, keep=2)
# newest two shellbound_* kept (bbbb, aaaa); only the oldest is stale
assert [w.ws_id for w in stale] == ["dddd"]
def test_select_stale_excludes_closed_and_exclude_id():
from shellbound.client import select_stale_workspaces
workstreams = [
_ws("aaaa", "shellbound_answer", "closed", "2026-09-05T10:00:00"),
_ws("bbbb", "shellbound_answer", "idle", "2026-09-05T11:00:00"),
_ws("cccc", "shellbound_shell", "idle", "2026-09-05T09:00:00"),
_ws("dddd", "shellbound_shell", "idle", "2026-09-05T08:00:00"),
]
# bbbb is the newest but excluded (it's the session just used); only
# cccc remains inside keep=1, so the stale set is just dddd.
stale = select_stale_workspaces(workstreams, keep=1, exclude_id="bbbb")
assert [w.ws_id for w in stale] == ["dddd"]
def test_select_stale_keep_zero():
from shellbound.client import select_stale_workspaces
workstreams = [_ws("aaaa", "shellbound_answer", "idle", "2026-09-05T10:00:00")]
assert [w.ws_id for w in select_stale_workspaces(workstreams, keep=0)] == ["aaaa"]
def test_config_prunes_node_base_template(tmp_path, monkeypatch):
from shellbound import config as c
cfg_file = tmp_path / "config.json"
cfg_file.write_text(
json.dumps({"gateway": "https://x:1", "token": "t", "node_base_template": "{gateway}/bad"})
)
monkeypatch.setattr(c, "CONFIG_FILE", cfg_file)
cfg = c.Config.load()
assert "node_base_template" not in cfg
assert cfg["gateway"] == "https://x:1"
assert cfg.keep_workspaces == 5
def test_config_keep_workspaces_environ(tmp_path, monkeypatch):
from shellbound import config as c
monkeypatch.setattr(c, "CONFIG_FILE", tmp_path / "missing.json")
monkeypatch.setenv("SHELLBOUND_KEEP_WORKSPACES", "3")
assert c.Config.load().keep_workspaces == 3
def test_config_keep_workspaces_edge_values():
from shellbound.config import Config
assert Config({"keep_workspaces": "oops"}).keep_workspaces == 5
assert Config({}).keep_workspaces == 5
assert Config({"keep_workspaces": 0}).keep_workspaces == 0
def test_normalize_session_and_close():
from shellbound.cli import _normalize_session
assert _normalize_session(["--session", "abc", "hi"]) == ["--session=abc", "hi"]
assert _normalize_session(["--session"]) == ["--session=__list__"]
assert _normalize_session(["--close", "all"]) == ["--close=all"]
assert _normalize_session(["--close", "--debug"]) == ["--close=__all__", "--debug"]
def test_renderer_flush_paragraphs():
from shellbound.render import StreamRenderer
renderer = StreamRenderer(plain=False)
renderer.add_content("First paragraph.\n")
assert not renderer.flushed # in-flight tail, not yet a block
assert renderer.tail.endswith("\n")
renderer.add_content("Second.\n\n")
assert len(renderer.flushed) == 1 and not renderer.tail
def test_renderer_flush_code_fence():
from shellbound.render import StreamRenderer
renderer = StreamRenderer(plain=False)
# blank lines inside an open fence must not flush; the closed fence
# becomes a single Markdown block on the trailing blank line.
renderer.add_content("```bash\necho a\n\n echo b \n```\n\n")
assert len(renderer.flushed) == 1 and not renderer.tail
# an unclosed fence cap is force-flushed as raw text
renderer.add_content("```python\ndef f():\n")
assert len(renderer.flushed) == 1 # still inside the fence, nothing flushed
long = "x" * 5000 + "\n"
renderer.add_content(long)
assert len(renderer.flushed) == 2 and not renderer.tail
def test_renderer_plain_content_streams():
from shellbound.render import StreamRenderer
renderer = StreamRenderer(plain=True)
renderer.add_content("just text")
assert renderer.content_parts == ["just text"]
assert renderer.finish() == "just text"