diff --git a/integrations/streamlit/.dockerignore b/integrations/streamlit/.dockerignore new file mode 100644 index 00000000..e5c392e3 --- /dev/null +++ b/integrations/streamlit/.dockerignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.pyc +.git/ +.gitignore +README.md diff --git a/integrations/streamlit/Dockerfile b/integrations/streamlit/Dockerfile new file mode 100644 index 00000000..96a2bcc4 --- /dev/null +++ b/integrations/streamlit/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.11-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl ca-certificates git \ + && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +RUN npm install -g @anthropic-ai/claude-code + +RUN useradd -m -u 1000 app + +WORKDIR /app +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +COPY app.py ./ +RUN chown -R app:app /app + +USER app +ENV HOME=/home/app + +EXPOSE 8501 + +CMD ["streamlit", "run", "app.py", \ + "--server.address=0.0.0.0", \ + "--server.port=8501", \ + "--server.headless=true", \ + "--browser.gatherUsageStats=false", \ + "--server.fileWatcherType=none"] diff --git a/integrations/streamlit/README.md b/integrations/streamlit/README.md new file mode 100644 index 00000000..e11bba29 --- /dev/null +++ b/integrations/streamlit/README.md @@ -0,0 +1,175 @@ +# Streamlit wrapper + +A minimal web UI for the `humanizer` skill. Paste text, click **Humanize**, get the rewritten prose back — without the skill's default draft / audit / summary sections. + +Paste text → `claude -p` + humanizer skill → clean final prose, with a copy button. + +--- + +## Prerequisites + +Both the local and Docker paths need: + +1. [Claude Code](https://claude.com/claude-code) installed. Verify with: + ```bash + claude --version + ``` +2. The `humanizer` skill installed (see the repo root `README.md` → _Installation_). +3. Either **one** of: + - A completed Claude Code login (`claude /login`), **or** + - `ANTHROPIC_API_KEY` exported in your shell. +4. Python 3.9+ (for local) **or** Docker 24+ (for container). + +See [Authentication](#authentication) below for details on the two login options. + +--- + +## Option A — Run locally + +```bash +cd integrations/streamlit +./run.sh +``` + +`run.sh` will: + +1. Confirm `claude` is on `$PATH`. +2. Create a local `.venv/` and install `streamlit`. +3. Launch the app on http://localhost:8501. + +**To stop:** press `Ctrl+C` in the same terminal. That sends SIGINT to Streamlit, Streamlit's `atexit` hook kills any in-flight `claude -p` subprocess, and the port is released. + +## Option B — Run with Docker + +```bash +cd integrations/streamlit +docker compose up --build +``` + +Then open http://localhost:8501. + +The compose file mounts your host's `~/.claude/` into the container so any file-based auth + your installed skills carry over. See [Authentication](#authentication) for the macOS caveat. + +**To stop:** two options, either works. + +- **From the terminal running `docker compose up`** — press `Ctrl+C`. Compose sends SIGTERM to the container, waits a few seconds, then SIGKILL if needed. When the container's PID 1 dies, the kernel kills every process inside the namespace, including any in-flight `claude -p`. +- **From a separate terminal** (or if `up` is detached with `-d`): + ```bash + docker compose down # stop + remove container (recommended) + # or + docker compose stop # stop but keep container for a quick restart + # or (last resort, hard kill) + docker kill humanizer-streamlit + ``` + +--- + +## Lifecycle — when does `claude` run, and when does it stop? + +The wrapper keeps **no** persistent `claude` session. Each click is self-contained. + +| Moment | What happens | +|---|---| +| You open the browser | Streamlit boots. **No `claude` process yet.** | +| You paste text | Nothing happens — text just sits in the textarea. | +| You click **Humanize** | `app.py` spawns `claude -p ""` as a subprocess. Right panel shows a spinner + "Running humanizer skill…" status message. | +| Generation finishes (~5–15 s) | Subprocess exits on its own. Status flips to "Done". Output appears in a code block with a native copy icon in the top-right corner. | +| You click **Humanize** again | Any still-running subprocess is killed first, then a fresh one spawns. | +| You close **only** the browser tab | Streamlit server stays up. A subprocess that was already running **will finish on its own within seconds** (its stdout just gets discarded). **Closing the tab alone does not stop Streamlit.** | +| You `Ctrl+C` the local terminal | Streamlit shuts down → `atexit` fires → any in-flight subprocess is killed → port released. | +| You `docker compose down` / `Ctrl+C docker compose up` | Container is stopped → kernel kills every process in its namespace, subprocess included. | + +In Claude Code terms: **each click is a brand-new one-shot session** with no memory of previous clicks. The subprocess is launched with `--no-session-persistence`, so no session file is written to disk either — there is no way for the output of one click to leak into the context of the next. + +--- + +## Authentication + +`claude -p` needs credentials to call the model. Pick one path: + +### Option 1 — OAuth (default, same login as your normal terminal use) + +Log in **once on your host**, before ever opening the app: + +```bash +claude /login +``` + +This opens a browser, completes OAuth, and stores credentials in: +- **macOS:** the system Keychain. +- **Linux / WSL:** `~/.claude/`. + +After that, every `claude -p` call (including the ones this app makes) reuses that login silently. You can verify by running `claude -p "hi"` from any terminal — if it answers without asking for login, you're set. + +### Option 2 — `ANTHROPIC_API_KEY` + +If you'd rather skip OAuth, or you're on a headless box where the Keychain isn't available (Docker on Linux, CI, a remote server), export an API key from [console.anthropic.com](https://console.anthropic.com/): + +```bash +export ANTHROPIC_API_KEY=sk-ant-... +./run.sh +``` + +`claude -p` picks it up automatically. No `/login` needed. The subprocess inherits the env var from the Streamlit process. + +### Authentication inside Docker + +- **Linux host:** Claude Code's file-based auth lives in `~/.claude/`, which `docker-compose.yml` mounts into the container. OAuth works out of the box. +- **macOS host:** OAuth credentials live in the macOS Keychain, which the container cannot read. Two fixes: + + **Fix 1 — Log in once inside the container.** The credentials will persist in the mounted `.claude/` volume: + ```bash + docker compose up -d --build + docker compose exec humanizer claude /login + ``` + + **Fix 2 — Use an API key.** Uncomment the `ANTHROPIC_API_KEY` line in `docker-compose.yml`, then: + ```bash + ANTHROPIC_API_KEY=sk-ant-... docker compose up --build + ``` + +--- + +## Output — do I get just the result? + +Yes. Two layers enforce this: + +1. **Prompt-level:** The request wrapper explicitly tells Claude to output **only** the final rewritten prose — no draft, no "what makes it AI" audit, no summary of changes, no preamble, no markdown headers, no `---` separators, no surrounding quotes. +2. **Post-process-level:** Even if the model leaks a "Here is…" opener or the skill's default "**Summary of changes:**" trailer, `_clean_output()` in `app.py` strips them before the output block is populated. + +While the subprocess is running you'll see a spinner and a status message. Once it finishes, the rewritten prose appears in a `st.code` block — this gives you a **native copy-to-clipboard icon in the top-right corner** of the output so you can paste the result straight into your doc. + +If you ever see leftover garbage in the cleaned output, that's a prompt-obedience failure — tighten `PROMPT_TEMPLATE` or extend `_PREAMBLE_RE` / `_TRAILING_RE` in `app.py`. + +--- + +## Security considerations + +Scoped for **single-user, local use**. A few things to know before you deploy this further: + +- **No authentication on the UI.** Anyone who can reach `http://:8501` can humanize text against your Claude Code credentials. The Docker compose file publishes the port on `127.0.0.1` by default so only your machine can hit it. Don't remove that bind unless you're putting auth in front of it. +- **`~/.claude` is mounted read-write into the container.** This gives the container full access to your host Claude Code auth tokens and installed skills. It needs to be writable because the macOS `claude /login`-inside-container fix writes back there. Treat the container image as trust-equivalent to the host for those credentials — don't run a random image against this compose file. +- **Container runs as a non-root user** (`app`, uid 1000) so a bug in a dependency can't trivially write to host files as root. (Your mounted `~/.claude/` files end up owned by uid 1000; that's normal.) +- **Prompt injection isn't a security issue here** — you are both the user *and* the "attacker". If you paste text that tries to redirect the skill, the worst case is your own output is weird. Don't deploy this in a multi-user setting without thinking that through. + +## Limitations + +- First call has the usual Claude Code startup cost (plugin sync, CLAUDE.md discovery, keychain read); subsequent calls are faster. +- Long texts are passed as a positional argument, so input is bounded by your shell's `ARG_MAX` (≈256 KB on macOS, ≈128 KB on Linux). Enough for any single essay; swap to `--input-format stream-json` if you need more. +- Closing the browser tab alone does not stop an in-flight subprocess (it will self-terminate within seconds anyway). If you need a hard stop, use `Ctrl+C` / `docker compose down` — see [How to stop](#option-a--run-locally) above. +- No history / undo. Each click overwrites the previous output. + +--- + +## Files + +``` +integrations/streamlit/ +├── app.py # Streamlit UI + subprocess call to `claude -p` + output cleanup +├── run.sh # Local launcher (venv + streamlit) +├── Dockerfile # Container with python + node + claude CLI + streamlit +├── docker-compose.yml # Mounts ~/.claude, exposes :8501 +├── .dockerignore +├── requirements.txt # streamlit +└── README.md # This file +``` diff --git a/integrations/streamlit/app.py b/integrations/streamlit/app.py new file mode 100644 index 00000000..2ac12095 --- /dev/null +++ b/integrations/streamlit/app.py @@ -0,0 +1,163 @@ +""" +Streamlit UI that forwards text to the local `claude` CLI and invokes the +`humanizer` skill. No API key required by default; uses the user's existing +Claude Code login. `ANTHROPIC_API_KEY` is also honored if set. + +Lifecycle: +- No `claude` process runs at idle. +- Each click spawns a one-shot `claude -p ""` subprocess. +- The subprocess exits on its own once the answer is finished. +- Clicking Humanize again kills any still-running subprocess first. +- Closing the browser alone does NOT kill an in-flight subprocess — it + will self-terminate within seconds when claude finishes the request. + To stop the server itself, Ctrl+C the terminal (local) or + `docker compose down` (container). See README for details. +""" + +import atexit +import os +import re +import shutil +import subprocess + +import streamlit as st + +CLAUDE_BIN = shutil.which("claude") or os.path.expanduser("~/.local/bin/claude") + +PROMPT_TEMPLATE = """Invoke the humanizer skill on the text below and rewrite it so it does not look AI-generated. + +OUTPUT RULES (STRICT): +- Output ONLY the final rewritten prose. +- No draft version, no "what makes it AI" analysis, no summary of changes. +- No preamble ("Here is...", "I've rewritten..."), no closing remarks. +- No markdown headers, no --- separators, no surrounding quotes. +- First character of your response must be the first character of the rewritten text. +- Last character must be the last character of the rewritten text. + +Text to humanize: + +{text} +""" + +_PREAMBLE_RE = re.compile( + r"^(?:Here(?:'s| is)\b|Below is\b|Sure[,!.]?|Certainly[,!.]?|" + r"I(?:'ve| have)\s+rewritten\b|Got it[,!.]?|Okay[,!.]?|Alright[,!.]?|" + r"Rewritten text:|Output:|Result:)[^\n]*\n+", + re.IGNORECASE, +) +# Match the humanizer skill's own trailing metadata blocks only. Require +# either a `---` separator + bold header, or a bolded skill-specific phrase +# ("Summary of changes", "Changes made", "What makes … AI …", "Draft rewrite", +# "Now make it not"). This avoids truncating legitimate prose that merely +# begins with a word like "Notes" or "Summary". +_TRAILING_RE = re.compile( + r"\n+(?:---+\s*\n+)?\*{2}" + r"(?:Summary\s+of\s+changes?|Changes?\s+made|What\s+makes[^\n]*AI[^\n]*|" + r"Draft\s+rewrite|Now\s+make\s+it\s+not)" + r"[^\n]*\*{2}[\s\S]*$", + re.IGNORECASE, +) +_CODEFENCE_RE = re.compile(r"^\s*```[a-zA-Z]*\n(.*?)\n```\s*$", re.DOTALL) + + +def _clean_output(text: str) -> str: + """Defensive post-processing in case the model leaks preambles or the + skill's default 'Summary of changes' block despite the strict prompt.""" + t = (text or "").strip() + m = _CODEFENCE_RE.match(t) + if m: + t = m.group(1).strip() + t = _PREAMBLE_RE.sub("", t, count=1).strip() + t = _TRAILING_RE.sub("", t, count=1).strip() + if len(t) >= 2 and t[0] in '"\'' and t[-1] == t[0]: + t = t[1:-1].strip() + return t + + +def _kill(proc): + if proc is None: + return + try: + if proc.poll() is None: + proc.kill() + except Exception: + pass + + +@st.cache_resource +def _proc_holder(): + """Single shared handle for the current subprocess. `atexit` is + registered exactly once (via cache_resource) so it doesn't accumulate + across Streamlit reruns.""" + holder = {"proc": None} + atexit.register(lambda: _kill(holder["proc"])) + return holder + + +st.set_page_config(page_title="Humanizer", layout="wide") +st.title("Humanizer") +st.caption( + "Paste text → click **Humanize** → get the final rewritten prose. " + "Uses your local `claude` CLI + the `humanizer` skill." +) + +col_in, col_out = st.columns(2) + +with col_in: + st.subheader("Input") + text = st.text_area( + "Input", + height=560, + key="input_text", + label_visibility="collapsed", + placeholder="Paste the AI-sounding text here…", + ) + go = st.button("Humanize", type="primary", use_container_width=True) + +with col_out: + st.subheader("Output") + status_slot = st.empty() + output_slot = st.empty() + +if go and text.strip(): + holder = _proc_holder() + _kill(holder["proc"]) + + prompt = PROMPT_TEMPLATE.format(text=text) + # --no-session-persistence keeps each invocation fully ephemeral: no + # session file is written to disk, and no prior session can be picked + # up by the next click. Combined with the default absence of -c / -r, + # each Humanize click is a brand-new stateless call. + proc = subprocess.Popen( + [CLAUDE_BIN, "-p", "--no-session-persistence", prompt], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + holder["proc"] = proc + + status_slot.info( + "⏳ Running humanizer skill — first call has ~3–5 s startup " + "overhead. Output appears when the run finishes." + ) + + with st.spinner("Humanizing…"): + stdout, stderr = proc.communicate() + + if proc.returncode != 0: + status_slot.empty() + output_slot.error( + f"`claude` exited with code {proc.returncode}.\n\n" + f"Check that Claude Code is installed and logged in (`claude /login`) " + f"or that `ANTHROPIC_API_KEY` is set.\n\n" + f"stderr:\n```\n{(stderr or '').strip()}\n```" + ) + st.session_state.pop("final_text", None) + else: + cleaned = _clean_output(stdout or "") + st.session_state["final_text"] = cleaned + status_slot.success("Done. Use the copy icon in the top-right of the box below.") + output_slot.code(cleaned, language=None, wrap_lines=True) + +elif "final_text" in st.session_state: + output_slot.code(st.session_state["final_text"], language=None, wrap_lines=True) diff --git a/integrations/streamlit/docker-compose.yml b/integrations/streamlit/docker-compose.yml new file mode 100644 index 00000000..c37a4b40 --- /dev/null +++ b/integrations/streamlit/docker-compose.yml @@ -0,0 +1,26 @@ +services: + humanizer: + build: . + image: humanizer-streamlit + container_name: humanizer-streamlit + ports: + # Published on 127.0.0.1 only so the UI isn't exposed to the LAN. + # Remove the 127.0.0.1 prefix if you actually want LAN access AND + # have put authentication / network controls in front of it. + - "127.0.0.1:8501:8501" + volumes: + # Mount the host's Claude Code config so the container inherits: + # - the installed `humanizer` skill (~/.claude/skills/humanizer/) + # - any file-based auth tokens + # This is read-write because macOS users may need to run + # `claude /login` inside the container (see README), and that writes + # back to this directory. Treat the container as a trust-equivalent + # of the host for these credentials. + - ${HOME}/.claude:/home/app/.claude + environment: + # Fallback auth — uncomment (and export ANTHROPIC_API_KEY) to skip OAuth. + # - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - HOME=/home/app + stdin_open: true + tty: true + restart: unless-stopped diff --git a/integrations/streamlit/requirements.txt b/integrations/streamlit/requirements.txt new file mode 100644 index 00000000..1ee5b87b --- /dev/null +++ b/integrations/streamlit/requirements.txt @@ -0,0 +1 @@ +streamlit>=1.56 diff --git a/integrations/streamlit/run.sh b/integrations/streamlit/run.sh new file mode 100755 index 00000000..5c5ce1ca --- /dev/null +++ b/integrations/streamlit/run.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# One-click launcher for the Streamlit wrapper. Spins up a local venv, +# installs Streamlit, and runs app.py which shells out to `claude -p`. + +set -e +cd "$(dirname "$0")" + +if ! command -v claude >/dev/null 2>&1; then + echo "claude CLI not found in PATH. Install Claude Code first:" + echo " https://claude.com/claude-code" + exit 1 +fi + +if ! command -v python3 >/dev/null 2>&1; then + echo "python3 not found. Install Python 3 and retry." + exit 1 +fi + +if [ ! -d ".venv" ]; then + echo "Creating local Python venv..." + python3 -m venv .venv +fi +# shellcheck disable=SC1091 +source .venv/bin/activate + +if ! python -c "import streamlit" >/dev/null 2>&1; then + echo "Installing dependencies..." + pip install --quiet --upgrade pip + pip install --quiet -r requirements.txt +fi + +echo "Ready. Opening http://localhost:8501 — Ctrl+C here to stop." + +exec streamlit run app.py \ + --server.headless false \ + --browser.gatherUsageStats false \ + --server.fileWatcherType none