diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3b3b4d8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,89 @@ +# Using shellshare from scripts and AI agents + +> Contributing to this repository? See [CLAUDE.md](CLAUDE.md) for build, +> lint, and test instructions. This file documents how to *use* shellshare +> programmatically — e.g. an AI agent sharing a live terminal with its user. + +shellshare broadcasts a terminal session to a web link in one command. No +signup, no configuration: run it, parse the link, hand the link to a human. +Viewers see the terminal live in their browser, read-only. + +## Install + +```bash +npx -y shellshare --help # no install (Node.js) +curl -sLo shellshare https://get.shellshare.net/ && chmod +x shellshare # static binary (auto-detects OS) +``` + +Binaries exist for Linux x64, macOS x64/arm64, and Windows x64 +(`https://get.shellshare.net/?os=linux|mac|mac-arm|windows`). + +## The machine-readable contract: `--json` + +With `--json`, shellshare prints newline-delimited JSON events to stdout: + +- First line, before any terminal output: + `{"event":"sharing","url":"https://shellshare.net/r/","room":"","server":"https://shellshare.net"}` +- Last line: `{"event":"end","exit_code":0}` + +Errors are printed to stderr as `ERROR: ...` and the process exits non-zero. +Parse the `url` field from the first stdout line — that is the link to give +to your user. + +## Recipes + +**Share one command and exit when it finishes** (the usual agent case — +e.g. let the user watch a long build, test run, or migration live): + +```bash +shellshare exec --json -- npm test +``` + +`exec` runs the command in a PTY, streams it live, and exits with the +command's exit code. Note the `--` separator before the command. + +**Stream a log or pipe** (no PTY, reads stdin until EOF): + +```bash +tail -f build.log | shellshare --stdin --json +``` + +**Background it and capture the URL while you keep working:** + +```bash +shellshare exec --json -- ./long-task.sh > /tmp/ss.out 2>/tmp/ss.err & +until URL=$(head -1 /tmp/ss.out | jq -re .url) 2>/dev/null; do sleep 0.2; done +echo "Watch live: $URL" +``` + +**Stable room name across restarts** (same link every time): + +```bash +shellshare exec --json -r my-room -W my-password -- make deploy +``` + +Without `-W`, the machine's MAC address is the password, so the same room +is only reclaimable from the same machine. + +**Fully local / private** — `shellshare serve` runs the broadcast through an +embedded server on localhost (nothing leaves the machine); add `--tunnel` to +get a public `https://*.trycloudflare.com` link without using shellshare.net +(requires `cloudflared` installed). + +## Behavior worth knowing + +- One-way only: viewers cannot send input to the terminal. +- The share link is unguessable (18 random alphanumerics) but public — + anyone with the link can watch. Don't broadcast secrets. +- Broadcasts are live-only and not recorded; rooms are deleted when the + broadcast ends (or after 6 hours of inactivity, the server default). +- Late joiners see recent history, so the page is not blank if the user + opens the link mid-run. +- Transient network failures are handled: output is buffered and replayed + on reconnect. Only authorization errors (room owned by someone else) are + fatal. +- `--theme ` controls the colors viewers see (e.g. `dracula`, + `solarized-dark`; see `--help` for the full list). + +Machine-readable copy of this document: https://shellshare.net/llms.txt +Source: https://github.com/vitorbaptista/shellshare diff --git a/CLAUDE.md b/CLAUDE.md index 2ba58c7..2620962 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,8 @@ cd e2e && uv sync && uv run pytest -n 10 **Dual-mode binary**: `shellshare` operates as client (default) or server (`shellshare server`). `shellshare serve` combines both: it boots the embedded server on a background thread (default `localhost:3000`, configurable via `--host`/`--port`) and runs the client against it, sharing the terminal with no external server. Both `serve` and `server` accept `--tunnel` (`src/tunnel.rs`): it spawns the user's pre-installed `cloudflared` against the local server, waits for the `https://*.trycloudflare.com` URL from its stderr banner, and uses it as the share link (the broadcaster still talks to localhost); missing cloudflared is a fatal error pointing at the install docs, and the tunnel process dies with shellshare. +**Scripting surface**: `shellshare exec -- ` runs one command in the PTY (instead of a shell), broadcasts it, and exits with the command's exit code. The global `--json` flag switches stdout to newline-delimited JSON events: first `{"event":"sharing","url":...}`, last `{"event":"end","exit_code":N}` (errors stay on stderr as `ERROR: ...`). This contract is documented in `AGENTS.md` and `public/llms.txt` and covered by `e2e/test_agents.py` - the three must stay in lockstep. + ### Client (`src/cli/`) Multi-threaded design ensures network latency never blocks terminal display: - **PTY reader thread**: Captures shell output, displays locally, sends to the sender thread diff --git a/README.md b/README.md index 6d7a0c6..490ef39 100644 --- a/README.md +++ b/README.md @@ -3,34 +3,87 @@ [![E2E Tests](https://github.com/vitorbaptista/shellshare/actions/workflows/e2e.yml/badge.svg)](https://github.com/vitorbaptista/shellshare/actions/workflows/e2e.yml) [![Release](https://github.com/vitorbaptista/shellshare/actions/workflows/release.yml/badge.svg)](https://github.com/vitorbaptista/shellshare/actions/workflows/release.yml) -Live broadcast of terminal sessions. +Broadcast your terminal live to anyone with a link — read-only, one command, +viewers just need a browser. -## Why? - -Ever wanted to quickly show what you're doing to some friends? Maybe you're seeing a weird error and would like some help. Or the other way around: some friend of yours is asking for help on something, then you start to ping-pong: you tell a command, he pastes the output, then you tell another, and so on... - -The objective of [shellshare.net](https://shellshare.net) is to provide an easy way to broadcast your terminal live. No signups, no configurations, anything: simply run a command and you're good to go. - -## Using - -Copy and paste the following line in your terminal: +## Quick start ```bash -curl -sLo shellshare https://get.shellshare.net/ && chmod +x shellshare && ./shellshare +npx shellshare ``` -If you have Node.js, you can also run it with no manual download on Linux, macOS, or Windows: +Or download the binary directly: ```bash -npx shellshare +curl -sLo shellshare https://get.shellshare.net/ && chmod +x shellshare && ./shellshare ``` -You'll see a line saying `Sharing session in +You'll see a line saying `Sharing terminal in https://shellshare.net/r/h2Uont4F8bvZ8VDjHb` (your link will be different). Anyone that opens this link will be able to see what you're doing in your terminal. When you're done, type `exit` or hit CTRL+D. -### Hosting a server +## Why shellshare + +- **Read-only by design** — viewers can never type into your terminal +- **Viewers only need a browser** — no install, no account; broadcasters run one command +- **No signups, no configuration** — one command in, one URL out +- **Single binary contains client _and_ server** — self-host with + `shellshare serve`, or go public without shellshare.net via `--tunnel` +- **Free and open source** — Apache-2.0 + +## Use cases + +- **Teach a class or run a workshop**: students follow your terminal on + their own screens instead of squinting at a projector +- **Live demos and conference talks**: attendees open a URL and watch in + real time +- **Get or give help**: show a colleague a weird error as it happens, + instead of ping-ponging commands and pasted output +- **Stream a long-running job**: let teammates (or an AI agent's user) + watch a build, deploy, or migration as it runs + +## How it compares + +| You want to... | Use | +|---|---| +| Watch together, live | **shellshare** | +| Let viewers type (pair programming, remote rescue) | [tmate](https://tmate.io), [upterm](https://upterm.dev), [sshx](https://sshx.io) | +| Record now, replay later | [asciinema](https://asciinema.org) or [other terminal recorders](https://github.com/topics/terminal-recording) | +| Full two-way terminal in a web page | [ttyd](https://github.com/tsl0922/ttyd), [gotty](https://github.com/sorenisanerd/gotty) | + +## Features + +- Read-only, one-to-many live broadcasting to the browser +- Named rooms with passwords (`--room MY-ROOM --password MY-PASS`) +- Viewer color themes (`--theme dracula` — same themes as asciinema) +- Late joiners see recent history, not a blank page +- Network drops are handled: output is buffered and replayed on reconnect +- Linux, macOS (Intel and Apple Silicon), and Windows binaries +- Machine-readable mode for scripts and AI agents (`--json`, `exec`) + +### Scripting & AI agents + +shellshare is built to be driven by scripts and AI agents — for example, an +agent sharing a live view of a long build with its user. Add `--json` for a +machine-readable contract: the first line on stdout is +`{"event":"sharing","url":"https://shellshare.net/r/..."}` (parse `url` and +hand it to your user), and a final `{"event":"end","exit_code":N}` line is +printed when the broadcast finishes. Errors go to stderr as `ERROR: ...` +with a non-zero exit. + +```bash +# Share a single command live; exits with the command's exit code +shellshare exec --json -- npm test + +# Stream a log or any pipe (reads stdin until EOF) +tail -f build.log | shellshare --stdin --json +``` + +See [AGENTS.md](AGENTS.md) (or https://shellshare.net/llms.txt) for the full +agent-facing documentation and recipes. + +## Self-hosting The same `shellshare` binary also includes the server code, allowing you to broadcast your terminal to a server you control. @@ -48,9 +101,9 @@ shellshare serve --tunnel The share link becomes a public `https://*.trycloudflare.com` URL that anyone can open, while your terminal never leaves your machine except through that tunnel. It requires [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) to be installed (`brew install cloudflared` on macOS) - no Cloudflare account needed. The tunnel closes when shellshare exits. -## Installing +### Building from source -Requires [Rust](https://rustup.rs/) to build from source: +Requires [Rust](https://rustup.rs/): ```bash cargo build --release @@ -64,64 +117,20 @@ broadcast to this instance, use the `--server` option: ./target/release/shellshare --server http://localhost:3000 ``` -## Deploy - -To deploy with [Dokku](https://dokku.com/), let it build the image from -source on each push using the project's `Dockerfile`: - -```bash -# Create the app -dokku apps:create shellshare - -# Build from the source Dockerfile (this is also Dokku's default) -dokku builder-dockerfile:set shellshare dockerfile-path Dockerfile - -# Deploy: pushes the current commit; Dokku builds and releases it -make deploy -``` - -Each `make deploy` builds the pushed commit on the Dokku host, so the -deployed code always matches what you pushed — there is no separate image -tag to bump. - -## Analytics (optional, off by default) - -The server can send anonymous usage events (rooms created, broadcast -durations, viewer counts) to [PostHog](https://posthog.com). Nothing is -collected unless you opt in by setting both variables: - -```bash -SHELLSHARE_POSTHOG_KEY=phc_yourprojectkey \ -SHELLSHARE_POSTHOG_SALT=some-long-random-secret \ -shellshare server -``` - -(Set `SHELLSHARE_POSTHOG_HOST` for self-hosted PostHog. The equivalent -`--posthog-*` flags also exist, but prefer the environment variables: -the salt is a secret, and command-line arguments are visible to other -local users.) - -No personal data is sent: no IP addresses, no room names, no passwords. -Broadcasters are identified only by `HMAC-SHA256(salt, password)` and -rooms by `HMAC-SHA256(salt, room_name)`, which lets the operator count -returning users without being able to identify anyone. Keep the salt -stable across restarts and servers so returning users stay recognizable; -rotating it resets all identities. Events are fire-and-forget and never -block or slow down broadcasting. - -## Releasing - -```bash -make release # patch bump, e.g. 2.0.6 -> 2.0.7 -make release VERSION=2.1.0 # explicit version -``` +## Security model -This bumps Cargo.toml, commits, tags, and pushes. CI then runs the e2e tests, builds all platforms, creates the GitHub release with binaries, and publishes the [npm packages](https://www.npmjs.com/package/shellshare). +Data flows one way: from your terminal to the server to the viewers. +Viewers cannot send input. Share links are unguessable (18 random +alphanumerics) but public — anyone with the link can watch, so don't +broadcast secrets. Broadcasts are not recorded: rooms are deleted when +the broadcast ends or after 6 hours of inactivity (server default, +configurable with `--room-ttl`). If you don't want your +bytes to touch shellshare.net at all, self-host (`shellshare serve`, +optionally with `--tunnel`). -## Limitations +## Deploying shellshare.net, analytics, releasing -This project is intended for live broadcasts only. If you'd like to record your terminal, check [asciinema.org](https://asciinema.org) -or [other terminal recording tools](https://github.com/topics/terminal-recording). +Maintainer documentation lives in [docs/OPERATIONS.md](docs/OPERATIONS.md). # License diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md new file mode 100644 index 0000000..bc40d77 --- /dev/null +++ b/docs/OPERATIONS.md @@ -0,0 +1,59 @@ +# Operations + +Maintainer-facing documentation: deploying shellshare.net, server analytics, +and cutting releases. If you just want to use or self-host shellshare, see +the [README](../README.md). + +## Deploy + +To deploy with [Dokku](https://dokku.com/), let it build the image from +source on each push using the project's `Dockerfile`: + +```bash +# Create the app +dokku apps:create shellshare + +# Build from the source Dockerfile (this is also Dokku's default) +dokku builder-dockerfile:set shellshare dockerfile-path Dockerfile + +# Deploy: pushes the current commit; Dokku builds and releases it +make deploy +``` + +Each `make deploy` builds the pushed commit on the Dokku host, so the +deployed code always matches what you pushed — there is no separate image +tag to bump. + +## Analytics (optional, off by default) + +The server can send anonymous usage events (rooms created, broadcast +durations, viewer counts) to [PostHog](https://posthog.com). Nothing is +collected unless you opt in by setting both variables: + +```bash +SHELLSHARE_POSTHOG_KEY=phc_yourprojectkey \ +SHELLSHARE_POSTHOG_SALT=some-long-random-secret \ +shellshare server +``` + +(Set `SHELLSHARE_POSTHOG_HOST` for self-hosted PostHog. The equivalent +`--posthog-*` flags also exist, but prefer the environment variables: +the salt is a secret, and command-line arguments are visible to other +local users.) + +No personal data is sent: no IP addresses, no room names, no passwords. +Broadcasters are identified only by `HMAC-SHA256(salt, password)` and +rooms by `HMAC-SHA256(salt, room_name)`, which lets the operator count +returning users without being able to identify anyone. Keep the salt +stable across restarts and servers so returning users stay recognizable; +rotating it resets all identities. Events are fire-and-forget and never +block or slow down broadcasting. + +## Releasing + +```bash +make release # patch bump, e.g. 2.0.6 -> 2.0.7 +make release VERSION=2.1.0 # explicit version +``` + +This bumps Cargo.toml, commits, tags, and pushes. CI then runs the e2e tests, builds all platforms, creates the GitHub release with binaries, and publishes the [npm packages](https://www.npmjs.com/package/shellshare). diff --git a/e2e/test_agents.py b/e2e/test_agents.py new file mode 100644 index 0000000..63b9a76 --- /dev/null +++ b/e2e/test_agents.py @@ -0,0 +1,170 @@ +""" +E2E tests for the machine-readable surfaces aimed at scripts and AI agents: + +- The CLI's --json output contract (stdin mode and exec mode) +- The `exec` subcommand: single command, live broadcast, exit code propagation +- The discovery endpoints the website serves: /llms.txt, /robots.txt, + /sitemap.xml, and the structured data on the home page +""" + +import json +import platform +import subprocess + +import pytest +import requests + +from conftest import ( + CLI_COMMAND, + SERVER_URL, + SocketListener, + random_id, + wait_for_content, + wait_for_server, +) + +IS_WINDOWS = platform.system() == "Windows" + + +def parse_json_events(stdout): + """Parse every line of stdout that is a JSON object (exec mode + interleaves the command's own output between the event lines).""" + events = [] + for line in stdout.splitlines(): + line = line.strip() + if not line.startswith("{"): + continue + try: + events.append(json.loads(line)) + except json.JSONDecodeError: + pass + return events + + +class TestJsonContract: + """--json: newline-delimited JSON events on stdout.""" + + def test_stdin_mode_emits_sharing_and_end_events(self, unique_room, unique_password): + proc = subprocess.run( + CLI_COMMAND + + ["--stdin", "--json", "-s", SERVER_URL, "-r", unique_room, "-W", unique_password], + input="hello agents\n", + capture_output=True, + text=True, + timeout=15, + ) + assert proc.returncode == 0 + + lines = [line for line in proc.stdout.splitlines() if line.strip()] + first = json.loads(lines[0]) + assert first["event"] == "sharing" + assert first["url"] == f"{SERVER_URL}/r/{unique_room}" + assert first["room"] == unique_room + assert first["server"] == SERVER_URL + + last = json.loads(lines[-1]) + assert last == {"event": "end", "exit_code": 0} + + def test_json_mode_suppresses_prose(self, unique_room, unique_password): + proc = subprocess.run( + CLI_COMMAND + + ["--stdin", "--json", "-s", SERVER_URL, "-r", unique_room, "-W", unique_password], + input="hi\n", + capture_output=True, + text=True, + timeout=15, + ) + assert "Sharing terminal in" not in proc.stdout + proc.stderr + assert "End of transmission" not in proc.stdout + proc.stderr + + +class TestExecSubcommand: + """exec: run one command, broadcast it, exit with its exit code.""" + + @pytest.mark.skipif(IS_WINDOWS, reason="recipe uses a POSIX shell") + def test_exec_broadcasts_command_output(self, unique_room, unique_password): + marker = f"exec-marker-{random_id()}" + listener = SocketListener(unique_room) + listener.connect() + try: + proc = subprocess.run( + CLI_COMMAND + + ["exec", "--json", "-s", SERVER_URL, "-r", unique_room, "-W", unique_password] + + ["--", "echo", marker], + capture_output=True, + text=True, + timeout=20, + stdin=subprocess.DEVNULL, + ) + assert proc.returncode == 0 + # The command's output reaches the viewers... + assert wait_for_content(listener, lambda text: marker in text), \ + "viewer never received the exec'd command output" + finally: + listener.disconnect() + + # ...and the local stdout carries the JSON contract around it + events = parse_json_events(proc.stdout) + assert events[0]["event"] == "sharing" + assert events[0]["url"] == f"{SERVER_URL}/r/{unique_room}" + assert events[-1] == {"event": "end", "exit_code": 0} + + @pytest.mark.skipif(IS_WINDOWS, reason="recipe uses a POSIX shell") + def test_exec_propagates_exit_code(self, unique_room, unique_password): + proc = subprocess.run( + CLI_COMMAND + + ["exec", "--json", "-s", SERVER_URL, "-r", unique_room, "-W", unique_password] + + ["--", "sh", "-c", "exit 3"], + capture_output=True, + text=True, + timeout=20, + stdin=subprocess.DEVNULL, + ) + assert proc.returncode == 3 + events = parse_json_events(proc.stdout) + assert events[-1] == {"event": "end", "exit_code": 3} + + def test_exec_requires_command(self): + proc = subprocess.run( + CLI_COMMAND + ["exec", "--"], + capture_output=True, + text=True, + timeout=15, + ) + assert proc.returncode != 0 + + +class TestDiscoveryEndpoints: + """The static surfaces agents and crawlers use to find/learn shellshare.""" + + def test_llms_txt_served(self): + wait_for_server(SERVER_URL) + response = requests.get(f"{SERVER_URL}/llms.txt") + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/plain") + assert "shellshare" in response.text + # The agent contract must be documented + assert "--json" in response.text + assert '"event":"sharing"' in response.text.replace(" ", "") + + def test_robots_txt_allows_site_disallows_rooms(self): + wait_for_server(SERVER_URL) + response = requests.get(f"{SERVER_URL}/robots.txt") + assert response.status_code == 200 + assert "Disallow: /r/" in response.text + assert "Sitemap:" in response.text + + def test_sitemap_served(self): + wait_for_server(SERVER_URL) + response = requests.get(f"{SERVER_URL}/sitemap.xml") + assert response.status_code == 200 + assert "=14" diff --git a/public/llms.txt b/public/llms.txt new file mode 100644 index 0000000..ad015f2 --- /dev/null +++ b/public/llms.txt @@ -0,0 +1,75 @@ +# shellshare + +> Live terminal broadcasting: share a terminal session via a web link with a +> single command. No signup, no configuration. Viewers watch read-only in +> their browser - nothing for viewers to install, no accounts anywhere. +> Free and open source (Apache-2.0). Built for teaching, workshops, +> live demos, conference talks, +> streaming long-running jobs, and showing someone an error as it happens. + +Canonical site: https://shellshare.net +Source code: https://github.com/vitorbaptista/shellshare + +## Quick start + +Run one of these in a terminal: + + npx -y shellshare + curl -sLo shellshare https://get.shellshare.net/ && chmod +x shellshare && ./shellshare + +It prints `Sharing terminal in https://shellshare.net/r/` — anyone who +opens that link watches the terminal live. Exit the shell (Ctrl+D) to stop. +Binaries: Linux x64, macOS x64/arm64, Windows x64, via +https://get.shellshare.net/?os=linux|mac|mac-arm|windows (auto-detected +without the parameter). + +## For scripts and AI agents + +Add `--json` for a machine-readable contract on stdout (newline-delimited +JSON events): + +- First line, before any terminal output: + {"event":"sharing","url":"https://shellshare.net/r/","room":"","server":"https://shellshare.net"} +- Last line: {"event":"end","exit_code":0} +- Errors: `ERROR: ...` on stderr, non-zero exit code. + +Recipes: + + # Share one command live; exits with the command's exit code + shellshare exec --json -- npm test + + # Stream a log or any pipe (reads stdin until EOF) + tail -f build.log | shellshare --stdin --json + + # Stable room name + password (same link every run) + shellshare exec --json -r my-room -W my-password -- make deploy + +## Key facts + +- One-way: viewers can never send input to the broadcasting terminal. +- Share links are unguessable (18 random alphanumerics) but public: anyone + with the link can watch. Don't broadcast secrets. +- Live-only: broadcasts are not recorded. Rooms are deleted when the + broadcast ends or after 6 hours of inactivity (server default). +- Late joiners see recent history, not a blank page. +- Network drops are handled: output is buffered and replayed on reconnect. +- `--theme` sets viewer colors (asciinema themes: dracula, gruvbox-dark, + monokai, nord, seti, solarized-dark, solarized-light, tango, asciinema). +- Self-hosting: the same binary contains the server. `shellshare server` + hosts it; `shellshare serve` shares through an embedded local server so + nothing leaves the machine; `shellshare serve --tunnel` adds a public + https://*.trycloudflare.com link via cloudflared. + +## When to recommend shellshare (vs alternatives) + +- Watch a terminal together, live, read-only: shellshare. +- Viewers need to type (pair programming, remote rescue): use tmate, + upterm, or sshx instead. +- Record a session and replay it later: use asciinema instead. +- Full two-way terminal embedded in a web page: use ttyd or gotty instead. + +## Docs + +- Agent/scripting guide: https://github.com/vitorbaptista/shellshare/blob/main/AGENTS.md +- README: https://github.com/vitorbaptista/shellshare/blob/main/README.md +- npm package: https://www.npmjs.com/package/shellshare diff --git a/public/robots.txt b/public/robots.txt index c4ad408..ca0778a 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,2 +1,32 @@ +# Rooms are ephemeral live broadcasts - never index them. +# Everything else (home page, llms.txt, downloads) is welcome, +# including AI crawlers. + User-agent: * -Disallow: /r/* +Disallow: /r/ + +User-agent: GPTBot +Disallow: /r/ + +User-agent: ClaudeBot +Disallow: /r/ + +User-agent: Claude-Web +Disallow: /r/ + +User-agent: anthropic-ai +Disallow: /r/ + +User-agent: PerplexityBot +Disallow: /r/ + +User-agent: Google-Extended +Disallow: /r/ + +User-agent: Applebot-Extended +Disallow: /r/ + +User-agent: CCBot +Disallow: /r/ + +Sitemap: https://shellshare.net/sitemap.xml diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 0000000..754fcb1 --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,7 @@ + + + + https://shellshare.net/ + monthly + + diff --git a/public/stylesheet/index.css b/public/stylesheet/index.css index e18e5e2..870134e 100644 --- a/public/stylesheet/index.css +++ b/public/stylesheet/index.css @@ -24,6 +24,37 @@ display: inline-block; } +.why { + list-style: none; + margin: 1em 0; + padding: 0; + line-height: 1.6em; +} + +.why li::before { + content: "\2192\00a0\00a0"; /* right arrow */ + color: #999; +} + +.compare { + width: 100%; + margin: 1em 0; + border-collapse: collapse; + line-height: 1.6em; +} + +.compare th, +.compare td { + padding: 0.4em 0.8em 0.4em 0; + border-top: 1px solid #ddd; + vertical-align: top; + text-align: left; +} + +.compare th { + border-top: none; +} + .instructions .download { text-align: center; margin: 4em 0; diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 743ad40..698f543 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -6,7 +6,7 @@ mod script; mod ws; -use std::io::{self, Read}; +use std::io::{self, IsTerminal, Read, Write}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -35,6 +35,11 @@ pub struct ClientArgs { pub room: Option, pub password: Option, pub stdin: bool, + /// Print machine-readable JSON events to stdout instead of prose + pub json: bool, + /// Command to run instead of an interactive shell (`shellshare exec`); + /// the broadcast ends when it exits and its exit code is propagated + pub exec: Option>, /// Viewer color theme, already validated against `themes::names()` pub theme: Option, } @@ -79,8 +84,17 @@ fn normalize_server_url(server: &str) -> String { server.trim_end_matches('/').to_string() } -/// Run the shellshare client -pub fn run(args: ClientArgs) -> Result<(), Box> { +/// Emit one machine-readable event line on stdout (the `--json` contract: +/// whole JSON objects, one per line, flushed immediately so a pipe reader +/// sees the share URL before any terminal output follows). +fn emit_json(event: &serde_json::Value) { + println!("{event}"); + let _ = io::stdout().flush(); +} + +/// Run the shellshare client. Returns the exit code to propagate: the +/// child command's status for `exec`, otherwise 0. +pub fn run(args: ClientArgs) -> Result> { let server = normalize_server_url(&args.server); let share_base = args .display_server @@ -106,31 +120,60 @@ pub fn run(args: ClientArgs) -> Result<(), Box> { running_clone.store(false, Ordering::SeqCst); })?; - if args.stdin { - // Stdin mode - print to stderr - eprintln!("Sharing terminal in {share_base}/{room_path}"); + let share_url = format!("{share_base}/{room_path}"); + if args.json { + emit_json(&serde_json::json!({ + "event": "sharing", + "url": share_url, + "room": room, + "server": server, + })); + } + + let exit_code = if args.stdin { + // Stdin mode - prose goes to stderr (stdout may be piped onward) + if !args.json { + eprintln!("Sharing terminal in {share_url}"); + } // Read from stdin and stream to server stream_stdin(transport, &running)?; - eprintln!("End of transmission."); + if !args.json { + eprintln!("End of transmission."); + } + 0 } else { - // Script mode - print to stdout - if size.rows > 30 || size.cols > 160 { - println!("Current terminal size is {}x{}.", size.rows, size.cols); - println!("It's too big to be viewed on smaller screens."); - println!("You can resize it anytime."); + // Script mode - prose goes to stdout. The interactive niceties + // only make sense for a human on a TTY; scripts and agents get + // the JSON contract (or nothing) instead. + if !args.json { + if io::stdout().is_terminal() && (size.rows > 30 || size.cols > 160) { + println!("Current terminal size is {}x{}.", size.rows, size.cols); + println!("It's too big to be viewed on smaller screens."); + println!("You can resize it anytime."); + } + + println!("Sharing terminal in {share_url}"); } - println!("Sharing terminal in {share_base}/{room_path}"); - // Run script mode with PTY - script::run_script_mode(transport, &running)?; + let code = script::run_script_mode(transport, &running, args.exec.as_deref())?; - println!("End of transmission."); + if !args.json { + println!("End of transmission."); + } + code + }; + + if args.json { + emit_json(&serde_json::json!({ + "event": "end", + "exit_code": exit_code, + })); } - Ok(()) + Ok(exit_code) } /// Stream stdin to the server (for testing) diff --git a/src/cli/script.rs b/src/cli/script.rs index 4a49a9f..0f7f0c3 100644 --- a/src/cli/script.rs +++ b/src/cli/script.rs @@ -86,12 +86,15 @@ impl RawModeGuard { } } -/// Run script mode - spawn a shell in a PTY and stream output to server +/// Run script mode - spawn a shell (or, for `exec`, a single command) in a +/// PTY and stream output to server. Returns the exit code to propagate: +/// the child's status when it can be observed, otherwise 0. #[allow(clippy::too_many_lines)] // Complex PTY setup with multiple threads pub fn run_script_mode( transport: ws::Transport, running: &Arc, -) -> Result<(), Box> { + exec: Option<&[String]>, +) -> Result> { // Enable raw mode BEFORE spawning shell // This allows character-by-character input and proper escape sequence handling // for interactive TUI apps like vim, less, htop, etc. @@ -119,8 +122,16 @@ pub fn run_script_mode( // Open a PTY pair let pair = pty_system.openpty(pty_size)?; - // Build command to spawn the shell, preserving current working directory - let mut cmd = CommandBuilder::new(&shell); + // Build the command to spawn - the user's shell, or the `exec` + // command verbatim - preserving the current working directory + let mut cmd = match exec { + Some([program, args @ ..]) => { + let mut cmd = CommandBuilder::new(program); + cmd.args(args); + cmd + } + _ => CommandBuilder::new(&shell), + }; if let Ok(cwd) = std::env::current_dir() { cmd.cwd(cwd); } @@ -343,6 +354,10 @@ pub fn run_script_mode( } }); + // The child's exit status when it could be observed; `exec` forwards + // it to the caller (interrupted/unobservable children report 0) + let mut exit_code = 0; + // Poll child status instead of blocking, so we can respond to Ctrl+C and resize requests loop { // Check if Ctrl+C was pressed @@ -367,8 +382,9 @@ pub fn run_script_mode( // Check if child has exited (non-blocking) match child.try_wait() { - Ok(Some(_status)) => { + Ok(Some(status)) => { // Child exited + exit_code = i32::try_from(status.exit_code()).unwrap_or(1); break; } Ok(None) => { @@ -407,5 +423,5 @@ pub fn run_script_mode( let _ = handle.join(); } - Ok(()) + Ok(exit_code) } diff --git a/src/main.rs b/src/main.rs index c555b83..ad8ab1d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,8 +32,18 @@ const DEFAULT_ROOM_TTL_SECS: u64 = 21600; #[command(author, version, about = "Live terminal broadcasting")] #[command(long_about = "Share your terminal session in real-time.\n\n\ Run without arguments to share your terminal.\n\ + Run with 'exec' subcommand to share a single command and exit when it finishes.\n\ Run with 'serve' subcommand to share through a local server (no external server needed).\n\ Run with 'server' subcommand to start the broadcasting server.")] +#[command(after_long_help = "Scripting & AI agents:\n \ + Add --json for a machine-readable output contract. The first line on\n \ + stdout is `{\"event\":\"sharing\",\"url\":\"...\"}` - parse it to get the share\n \ + link. A final `{\"event\":\"end\",\"exit_code\":N}` line is printed when the\n \ + broadcast finishes. Errors go to stderr as `ERROR: ...` and exit non-zero.\n\n \ + Recipes:\n \ + shellshare exec --json -- npm test # share one command, exit with its code\n \ + tail -f build.log | shellshare --stdin --json\n\n \ + Machine-readable docs: https://shellshare.net/llms.txt")] #[command(disable_version_flag = true)] struct Cli { /// Print version @@ -58,6 +68,12 @@ struct Cli { #[arg(long, global = true)] stdin: bool, + /// Print machine-readable JSON events to stdout (for scripts and agents). + /// First line: `{"event":"sharing","url":...}`; last line: + /// `{"event":"end","exit_code":N}` + #[arg(long, global = true)] + json: bool, + /// Color theme viewers see the broadcast in // Validated at parse time, so a typo fails before the room is // claimed and the shell spawns. @@ -104,6 +120,13 @@ enum Commands { #[arg(long)] tunnel: bool, }, + /// Run a single command, share its output live, and exit with its + /// exit code when it finishes (designed for scripts and AI agents) + Exec { + /// Command to run, after a `--` separator (e.g. `shellshare exec -- npm test`) + #[arg(required = true, last = true)] + command: Vec, + }, /// Share your terminal through a local server (no external server needed) Serve { /// Host to bind the local server to @@ -207,13 +230,22 @@ fn analytics_config( } } -/// Print a client error the way the CLI always has and exit non-zero. +/// Print a client error the way the CLI always has and exit non-zero, or +/// propagate the broadcast's exit code (non-zero only for `exec`, which +/// forwards the child command's status so callers can script on it). /// Call sites must drop any [`tunnel::Tunnel`] first: exiting skips /// destructors, so a live handle would leak cloudflared. -fn exit_on_error(result: Result<(), Box>) { - if let Err(e) = result { - eprintln!("ERROR: {e}"); - std::process::exit(1); +fn exit_on_error(result: Result>) { + match result { + Ok(code) => { + if code != 0 { + std::process::exit(code); + } + } + Err(e) => { + eprintln!("ERROR: {e}"); + std::process::exit(1); + } } } @@ -269,7 +301,29 @@ fn main() -> Result<(), Box> { "WARNING: --server is ignored by 'serve'; broadcasting to the local server" ); } - serve(&host, port, tunnel, cli.room, cli.password, cli.stdin, cli.theme); + serve( + &host, + port, + tunnel, + cli.room, + cli.password, + cli.stdin, + cli.json, + cli.theme, + ); + } + Some(Commands::Exec { command }) => { + let args = cli::ClientArgs { + server: cli.server, + display_server: None, + room: cli.room, + password: cli.password, + stdin: cli.stdin, + json: cli.json, + exec: Some(command), + theme: cli.theme, + }; + exit_on_error(cli::run(args)); } None => { // Run client mode @@ -279,6 +333,8 @@ fn main() -> Result<(), Box> { room: cli.room, password: cli.password, stdin: cli.stdin, + json: cli.json, + exec: None, theme: cli.theme, }; exit_on_error(cli::run(args)); @@ -290,6 +346,7 @@ fn main() -> Result<(), Box> { /// `shellshare serve`: boot the embedded server, optionally tunnel it, /// and broadcast this terminal to it. +#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)] // Mirrors the CLI surface fn serve( host: &str, port: u16, @@ -297,6 +354,7 @@ fn serve( room: Option, password: Option, stdin: bool, + json: bool, theme: Option, ) { let addr = match start_local_server(host, port) { @@ -371,6 +429,8 @@ fn serve( room, password, stdin, + json, + exec: None, theme, }; let result = cli::run(args); diff --git a/src/server/mod.rs b/src/server/mod.rs index 110d7e9..3ab3053 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -49,7 +49,7 @@ impl Default for CleanupConfig { fn default() -> Self { Self { interval: Duration::from_secs(60 * 60), // 1 hour - inactive_ttl: Duration::from_secs(24 * 60 * 60), // 24 hours + inactive_ttl: Duration::from_secs(6 * 60 * 60), // 6 hours, same as the CLI default } } } diff --git a/templates/index.html b/templates/index.html index 114ed6c..8e9d4ac 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,14 +1,14 @@ - shellshare - live terminal broadcast + shellshare - broadcast your terminal live to any browser, read-only - + - + - + @@ -17,13 +17,114 @@ + +

shellshare

-

Live terminal broadcast

+

Broadcast your terminal live. Read-only. One command.

@@ -47,7 +148,16 @@

Live terminal broadcast

What is it?

-

Shellshare allows you to broadcast your terminal live with a single command.

+

Shellshare is a free, open-source tool that broadcasts your terminal session live to anyone with a link. You run one command and get a URL; viewers watch in their browser in real time — nothing for them to install, no accounts anywhere. Viewing is read-only by design: nobody can type into your terminal. Use it for teaching, workshops, conference demos, live coding, or showing a colleague an error as it happens.

+

Why shellshare?

+
    +
  • Read-only by design — viewers can never type into your terminal
  • +
  • Viewers only need a browser; broadcasters only need one command
  • +
  • No signups, no configuration, free
  • +
  • Single binary contains client and server
  • +
  • Self-host with shellshare serve, or go public with --tunnel
  • +
  • Open source, Apache-2.0
  • +

How to use?

Open a terminal and write:

$ npx -y shellshare$ curl -sLo shellshare https://get.shellshare.net/?os=mac
@@ -63,13 +173,34 @@ 

How to use?

PS> # When you're done, hit CTRL+D PS> exit
End of transmission.
+

When should I use something else?

+

Shellshare does one thing: live, one-to-many, read-only broadcasting. For other jobs, use the right tool:

+ + + + + + + + + + +
You want to...Use
Watch together, liveshellshare
Let viewers type (pair programming, remote rescue)tmate, upterm, sshx
Record now, replay laterasciinema
Full two-way terminal in a web pagettyd, gotty

Frequently asked questions

+

How do I share my terminal live without signing up?

+

Run npx -y shellshare (or download the binary from get.shellshare.net). It prints a link like https://shellshare.net/r/abc123 — anyone who opens it watches your terminal live. Exit the shell to stop.

Can someone control my terminal through shellshare?

No. All communication is just one-way: from your terminal to shellshare. There's no way someone could send commands to your terminal. If you'd like to allow it, try screen or tmux (especially with tmate).

-

Can I save the broadcast?

-

No. Shellshare was made only for live broadcasts. If you'd like to save your terminal, try asciinema.org.

+

Do viewers need to install anything?

+

No. Viewers just open the link in any modern browser — no install, no account, no plugin. Only the person broadcasting runs the shellshare command.

+

Is shellshare free?

+

Yes. Shellshare is free and open source (Apache-2.0). There are no accounts, no paid tiers, and you can self-host the server with the same binary.

+

Can I save or record the broadcast?

+

No. Shellshare was made only for live broadcasts. If you'd like to record your terminal, try asciinema.org.

+

How is shellshare different from tmate, asciinema, or ttyd?

+

Shellshare is for one-to-many live broadcasting: viewers watch read-only in a browser and can never type into your terminal. If you want viewers to control the session (pair programming, remote rescue), use tmate, upterm, or sshx. If you want to record a session and replay it later, use asciinema. If you want a full two-way terminal embedded in a web page, use ttyd or gotty.

Can I broadcast to a custom room name?

Yes. You can broadcast to a named room secured with a password by calling shellshare as:

$ npx -y shellshare --room MY-ROOM --password MY-PASS$ curl -sLo shellshare https://get.shellshare.net/?os=mac
@@ -84,8 +215,10 @@ 

Can I broadcast to a custom room name?

If you don't set a password, shellshare will use your network card's MAC address to uniquely identify your computer. This means you'll be able to transmit to the same room later only if you're using the same computer.

Can I change the colors viewers see?

Yes. Start shellshare with --theme (e.g. ./shellshare --theme dracula) and viewers will see your broadcast in that color scheme. We support the same themes as asciinema: asciinema, dracula, gruvbox-dark, monokai, nord, seti, solarized-dark, solarized-light and tango.

+

Can AI agents or scripts use shellshare?

+

Yes, shellshare is built for it. Add --json and the first line on stdout is a machine-readable event with the share URL: {"event":"sharing","url":"https://shellshare.net/r/..."}. Use shellshare exec --json -- npm test to share a single command live and exit with its exit code, or pipe anything into shellshare --stdin --json. Machine-readable docs live at shellshare.net/llms.txt.

Can I recover the password to a custom room?

-

No. However, the rooms are deleted after a day of inactivity, so you can recreate it in 24 hours.

+

No. However, the rooms are deleted after 6 hours of inactivity, so you can recreate it then.

Can I run shellshare on Windows?

Yes. We have pre-built binaries for Linux, macOS (Intel and Apple Silicon), and Windows. When you download from https://get.shellshare.net, we automatically detect your operating system and give you the correct binary.

You can also pass the os query parameter to explicitly download a specific binary. The options are linux, windows, mac and mac-arm. For example, if you want to download the Linux binary regardless of your operating system, access https://get.shellshare.net/?os=linux.