Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions integrations/streamlit/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.venv/
__pycache__/
*.pyc
.git/
.gitignore
README.md
29 changes: 29 additions & 0 deletions integrations/streamlit/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
175 changes: 175 additions & 0 deletions integrations/streamlit/README.md
Original file line number Diff line number Diff line change
@@ -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 "<your text + strict output rules>"` 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://<host>: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
```
163 changes: 163 additions & 0 deletions integrations/streamlit/app.py
Original file line number Diff line number Diff line change
@@ -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 "<prompt>"` 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)
Loading