bunflared 0.8.0

One command, one throwaway link for your localhost, several ports behind it. Chat with your client from the terminal, send them to a page, follow their cursor, catch their reactions 🔥 Or let your coding agent run the session over MCP.
mod agents;
mod clipboard;
mod cloudflared;
mod live;
mod mcp;
mod os;
mod ports;
mod proxy;
mod share;
mod state;
mod tui;
mod tunnel;
mod widget;

use std::io::{IsTerminal, Write};
use std::sync::mpsc;
use std::time::Duration;

use clap::{CommandFactory, Parser, Subcommand};
use tokio::sync::watch;

use share::Event;

const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
const EXIT_USAGE: i32 = 2;

const AFTER_HELP: &str = r#"Examples:
  bunflared                       pick among the apps running here, Enter shares
  bunflared 5173                  share one app
  bunflared 5173 3000             app at /, its API at /_port/3000
  bunflared 5173 3000 --detach    print the ready line, keep sharing in the background
  bunflared 5173 --local          the same, on this computer only: no tunnel, no link
  bunflared ls [--json]           list live shares
  bunflared down <id> | --all     stop shares
  bunflared agents                teach your coding agents to use bunflared
  bunflared mcp                   MCP server on stdio, for coding agents

Add the MCP server to Claude Code with `claude mcp add bunflared -- bunflared mcp`.
Its shares are local unless the agent asks for a public link, and close with the
session. With `claude --dangerously-load-development-channels server:bunflared`,
visitors' messages and notes reach the session by themselves.

In a terminal you get the animated dashboard: ? lists its keys. m messages the
visitors, g sends them to a page, R reloads it, Enter on a request shows it and
p replays it. Picking a visitor with the arrows follows their pointer, and their
page's tab wears a red ring meanwhile. Notes, chat and reactions are saved in
bunflared-feedback/.

Otherwise, or with --json, the ready line is one JSON object on stdout:
  {"id":"4242","pid":4242,"tunnel_pid":4243,"url":"https://....trycloudflare.com",
   "routes":{"/":5173,"/_port/3000":3000},"started_at":1790000000}
and a failure is one JSON object on stderr: {"error":"...","code":N}.
With --local, the url is http://127.0.0.1:<port> and tunnel_pid is 0.

cloudflared is fetched from Cloudflare's releases on the first run when it is
not installed.

Exit codes: 0 ok, 1 tunnel closed, 2 bad arguments, 3 cloudflared missing and
not downloadable, 4 a port is not answering, 5 ~/.cloudflared/config.yaml blocks quick tunnels,
6 the tunnel failed to start.

Limits: anyone with the link reaches your app. Quick tunnels allow 200
requests in flight and do not carry Server-Sent Events."#;

#[derive(Parser)]
#[command(
    version,
    about = "Share local ports on a temporary public https link.",
    after_help = AFTER_HELP,
    args_conflicts_with_subcommands = true,
    subcommand_negates_reqs = true
)]
struct Cli {
    #[command(subcommand)]
    command: Option<Command>,

    /// Ports to share. The first is served at "/", the others under "/_port/<port>".
    /// None in a terminal: a screen lists the apps running here to pick from.
    #[arg(value_parser = clap::value_parser!(u16).range(1..))]
    ports: Vec<u16>,

    /// No dashboard: print one JSON line once the link is live.
    #[arg(long)]
    json: bool,

    /// Print the JSON line once live, then return and keep sharing in the background.
    #[arg(long)]
    detach: bool,

    /// No animations. NO_COLOR also turns them off, with the colors.
    #[arg(long)]
    calm: bool,

    /// Leave the pages as they are: no feedback button, no live session.
    #[arg(long)]
    no_widget: bool,

    /// Only on this computer, at http://127.0.0.1:<port>: no tunnel, no link.
    #[arg(short, long)]
    local: bool,

    /// Colors for a light or dark terminal. Auto asks the terminal.
    #[arg(long, value_enum, default_value_t = ThemeChoice::Auto)]
    theme: ThemeChoice,
}

#[derive(Clone, Copy, clap::ValueEnum)]
enum ThemeChoice {
    Auto,
    Light,
    Dark,
}

#[derive(Subcommand)]
enum Command {
    /// List live shares.
    Ls {
        #[arg(long)]
        json: bool,
    },
    /// Teach the coding agents installed here (Claude Code, Codex, pi, omp, prime-agent) to use bunflared.
    Agents {
        /// Print the note instead, to paste into another agent's rules.
        #[arg(long)]
        print: bool,
        /// Take the note back out.
        #[arg(long, conflicts_with = "print")]
        remove: bool,
    },
    /// Speak MCP on stdio, for a coding agent: it opens shares and talks to the pages.
    Mcp,
    /// Stop a share by id, or all of them.
    Down {
        #[arg(required_unless_present = "all")]
        id: Option<String>,
        #[arg(long)]
        all: bool,
    },
}

fn main() {
    // A panic on the main thread must not leave cloudflared running.
    let default_hook = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        if std::thread::current().name() == Some("main") {
            tunnel::kill_orphan();
        }
        default_hook(info);
    }));

    let mut cli = Cli::try_parse().unwrap_or_else(|err| usage(err));
    // A script or an agent never lands on the home screen: no port is the
    // error it always was.
    if cli.command.is_none()
        && cli.ports.is_empty()
        && (machine_output() || !std::io::stdin().is_terminal())
        && let Err(err) = Cli::command()
            .mut_arg("ports", |ports| ports.required(true))
            .try_get_matches()
    {
        usage(err);
    }
    let code = match cli.command {
        Some(Command::Ls { json }) => state::list(json),
        Some(Command::Down { id, all }) => state::down(id, all),
        Some(Command::Agents { print, remove }) => agents::run(print, remove),
        Some(Command::Mcp) => mcp::run(),
        None if cli.detach => state::detach(&cli.ports, cli.no_widget, cli.local),
        None => {
            let no_color = std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty());
            let interactive = !cli.json && std::io::stdout().is_terminal();
            let light = interactive.then(|| match cli.theme {
                ThemeChoice::Light => true,
                ThemeChoice::Dark => false,
                ThemeChoice::Auto => tui::light_terminal(),
            });
            if cli.ports.is_empty() {
                let theme = tui::Theme {
                    calm: cli.calm || no_color,
                    color: !no_color,
                    light: light.unwrap_or(false),
                };
                let start = tui::home::Choice {
                    ports: Vec::new(),
                    local: cli.local,
                    widget: !cli.no_widget,
                    calm: cli.calm || no_color,
                };
                let Some(choice) = tui::home::run(&theme, start) else {
                    std::process::exit(0);
                };
                cli.ports = choice.ports;
                cli.local = choice.local;
                cli.no_widget = !choice.widget;
                cli.calm = choice.calm;
            }
            let theme = light.map(|light| tui::Theme {
                calm: cli.calm || no_color,
                color: !no_color,
                light,
            });
            let feedback = (!cli.no_widget)
                .then(|| std::env::current_dir().ok())
                .flatten()
                .map(|dir| dir.join(widget::FOLDER));
            let spec = share::Spec {
                ports: cli.ports,
                local: cli.local,
                id: std::process::id().to_string(),
                copy: true,
            };
            share(spec, theme, feedback)
        }
    };
    std::process::exit(code);
}

fn usage(err: clap::Error) -> ! {
    if err.use_stderr() && machine_output() {
        let message = err.to_string();
        let summary = message.split("\n\n").next().unwrap_or("bad arguments");
        let summary = summary.split_whitespace().collect::<Vec<_>>().join(" ");
        let json = serde_json::json!({ "error": summary.trim_start_matches("error: "), "code": EXIT_USAGE });
        eprintln!("{json}");
        std::process::exit(EXIT_USAGE);
    }
    err.exit()
}

fn machine_output() -> bool {
    std::env::args().any(|arg| arg == "--json" || arg == "--detach")
        || !std::io::stdout().is_terminal()
}

/// Runs the share with the dashboard when a theme is given, JSON otherwise.
fn share(
    spec: share::Spec,
    theme: Option<tui::Theme>,
    feedback: Option<std::path::PathBuf>,
) -> i32 {
    let runtime = tokio::runtime::Runtime::new().expect("tokio runtime");
    let (tx, rx) = mpsc::channel();
    let (stop, stop_rx) = watch::channel(false);
    let hub = std::sync::Arc::new(live::Hub::new(tx.clone(), feedback.clone()));
    let ports = spec.ports.clone();
    let backend_hub = hub.clone();
    let replayer = proxy::Replayer {
        runtime: runtime.handle().clone(),
        tx: tx.clone(),
    };
    let dashboard = theme.is_some();
    let backend = runtime.spawn(async move {
        let result = share::run(&spec, &tx, stop_rx, feedback, backend_hub).await;
        let _ = tx.send(Event::Done(result));
        if dashboard {
            tokio::spawn(leave_on_signal());
        }
    });

    let code = match theme {
        Some(theme) => tui::run(rx, &stop, &ports, theme, hub, replayer),
        None => print_json(rx),
    };

    let _ = stop.send(true);
    runtime.block_on(async {
        let _ = tokio::time::timeout(SHUTDOWN_TIMEOUT, backend).await;
    });
    code
}

/// The share is gone but the dashboard can still be on screen, playing its
/// goodbye or showing a failure. Its input loop spins on a dead tty without
/// ever returning, so a signal from now on ends the process from here.
async fn leave_on_signal() {
    let hung_up = os::quit().await;
    if !hung_up {
        ratatui::restore();
    }
    std::process::exit(if hung_up { share::EXIT_HANGUP } else { 0 });
}

// Writes ignore errors: a detached share outlives the pipe it was started with.
fn print_json(rx: mpsc::Receiver<Event>) -> i32 {
    for event in rx {
        match event {
            Event::Ready { record, .. } => {
                let mut out = std::io::stdout();
                let _ = writeln!(
                    out,
                    "{}",
                    serde_json::to_string(&record).unwrap_or_default()
                );
                let _ = out.flush();
            }
            Event::Done(Ok(())) => return 0,
            Event::Done(Err(failure)) => {
                let json = serde_json::json!({ "error": failure.message, "code": failure.code });
                let _ = writeln!(std::io::stderr(), "{json}");
                return failure.code;
            }
            _ => {}
        }
    }
    0
}