diff --git a/src/spicebridge/__main__.py b/src/spicebridge/__main__.py
index 9cd5a19..f88b74c 100644
--- a/src/spicebridge/__main__.py
+++ b/src/spicebridge/__main__.py
@@ -25,7 +25,7 @@ def _run_with_auth(mcp, transport: str, host: str, port: int, api_key: str) -> N
     app = mcp.sse_app() if transport == "sse" else mcp.streamable_http_app()
     app = ApiKeyMiddleware(app, api_key)
 
-    log_level = getattr(mcp.settings, "log_level", "info")
+    log_level = getattr(mcp.settings, "log_level", "info").lower()
 
     async def _serve() -> None:
         config = uvicorn.Config(
@@ -41,9 +41,17 @@ def _run_with_auth(mcp, transport: str, host: str, port: int, api_key: str) -> N
 
 
 def main() -> None:
+    import sys
+
+    if len(sys.argv) >= 2 and sys.argv[1] == "setup-cloud":
+        from spicebridge.setup_wizard import run_wizard
+
+        raise SystemExit(run_wizard(sys.argv[2:]))
+
     parser = argparse.ArgumentParser(
         prog="spicebridge",
         description="SPICEBridge MCP server for AI-powered circuit design",
+        epilog="Use 'spicebridge setup-cloud' for guided Cloudflare tunnel setup.",
     )
     parser.add_argument(
         "--transport",
diff --git a/src/spicebridge/setup_wizard.py b/src/spicebridge/setup_wizard.py
new file mode 100644
index 0000000..9f66720
--- /dev/null
+++ b/src/spicebridge/setup_wizard.py
@@ -0,0 +1,695 @@
+"""Interactive setup wizard for SPICEBridge + Cloudflare tunnel.
+
+Walks the user through installing cloudflared, creating a tunnel,
+generating config, and running both SPICEBridge and the tunnel together.
+
+Usage:
+    spicebridge setup-cloud              # interactive wizard
+    spicebridge setup-cloud --quick      # quick tunnel (no account needed)
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import platform
+import shutil
+import signal
+import subprocess
+import sys
+import time
+import urllib.request
+from pathlib import Path
+
+# ---------------------------------------------------------------------------
+# Constants
+# ---------------------------------------------------------------------------
+
+_DEFAULT_PORT = 8000
+_DEFAULT_HOST = "127.0.0.1"
+_DEFAULT_TRANSPORT = "streamable-http"
+
+_CLOUDFLARED_CONFIG_DIR = Path.home() / ".cloudflared"
+_CLOUDFLARED_CONFIG_FILE = _CLOUDFLARED_CONFIG_DIR / "config.yml"
+
+_BANNER = """\
+╔══════════════════════════════════════════╗
+║   SPICEBridge Cloud Setup Wizard         ║
+╚══════════════════════════════════════════╝
+"""
+
+# ---------------------------------------------------------------------------
+# Argument parsing
+# ---------------------------------------------------------------------------
+
+
+def _parse_wizard_args(argv: list[str] | None = None) -> argparse.Namespace:
+    parser = argparse.ArgumentParser(
+        prog="spicebridge setup-cloud",
+        description="Guided setup for SPICEBridge + Cloudflare tunnel.",
+    )
+    parser.add_argument(
+        "--quick",
+        action="store_true",
+        help="Use a temporary quick tunnel (no Cloudflare account needed)",
+    )
+    parser.add_argument(
+        "--port",
+        type=int,
+        default=_DEFAULT_PORT,
+        help=f"Local port for SPICEBridge server (default: {_DEFAULT_PORT})",
+    )
+    parser.add_argument(
+        "--host",
+        default=_DEFAULT_HOST,
+        help=f"Local host for SPICEBridge server (default: {_DEFAULT_HOST})",
+    )
+    parser.add_argument(
+        "--tunnel-name",
+        default="spicebridge",
+        help="Name for the Cloudflare tunnel (default: spicebridge)",
+    )
+    parser.add_argument(
+        "--domain",
+        default="",
+        help="Custom domain/hostname for the tunnel (e.g. spicebridge.example.com)",
+    )
+    parser.add_argument(
+        "--no-install",
+        action="store_true",
+        help="Skip automatic cloudflared installation",
+    )
+    return parser.parse_args(argv)
+
+
+# ---------------------------------------------------------------------------
+# Prerequisite checks
+# ---------------------------------------------------------------------------
+
+
+def _check_cloudflared() -> str | None:
+    """Return path to cloudflared if found, else None."""
+    return shutil.which("cloudflared")
+
+
+def _check_ngspice() -> str | None:
+    """Return path to ngspice if found, else None."""
+    return shutil.which("ngspice")
+
+
+def _check_cloudflared_login() -> bool:
+    """Return True if cloudflared has a cert.pem (user is logged in)."""
+    return (_CLOUDFLARED_CONFIG_DIR / "cert.pem").exists()
+
+
+def _detect_os() -> str:
+    """Detect OS for install instructions."""
+    system = platform.system()
+    if system == "Darwin":
+        return "macos"
+    if system == "Linux":
+        if shutil.which("apt") is not None:
+            return "linux-deb"
+        return "linux-other"
+    return "other"
+
+
+# ---------------------------------------------------------------------------
+# Install helper
+# ---------------------------------------------------------------------------
+
+
+def _install_cloudflared_instructions() -> str:
+    """Return OS-specific install instructions."""
+    os_type = _detect_os()
+    if os_type == "macos":
+        return (
+            "Install cloudflared:\n"
+            "  brew install cloudflared\n"
+            "\n"
+            "Or download from:\n"
+            "  https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
+        )
+    if os_type == "linux-deb":
+        return (
+            "Install cloudflared:\n"
+            "  curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg "
+            "| sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null\n"
+            "  echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] "
+            "https://pkg.cloudflare.com/cloudflared '$(lsb_release -cs)' main' "
+            "| sudo tee /etc/apt/sources.list.d/cloudflared.list\n"
+            "  sudo apt update && sudo apt install cloudflared\n"
+        )
+    return (
+        "Download cloudflared from:\n"
+        "  https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
+    )
+
+
+def _offer_install_cloudflared(no_install: bool = False) -> bool:
+    """Prompt to install cloudflared. Returns True if installed successfully."""
+    if no_install:
+        print(_install_cloudflared_instructions())
+        return False
+
+    os_type = _detect_os()
+    if os_type not in ("macos", "linux-deb"):
+        print(_install_cloudflared_instructions())
+        return False
+
+    if not _prompt_yes_no("cloudflared not found. Attempt automatic install?"):
+        print(_install_cloudflared_instructions())
+        return False
+
+    try:
+        if os_type == "macos":
+            print("Running: brew install cloudflared")
+            subprocess.run(
+                ["brew", "install", "cloudflared"],
+                check=True,
+                timeout=120,
+            )
+        else:
+            print("Installing cloudflared via apt...")
+            subprocess.run(
+                ["sudo", "apt", "update"],
+                check=True,
+                timeout=60,
+            )
+            subprocess.run(
+                ["sudo", "apt", "install", "-y", "cloudflared"],
+                check=True,
+                timeout=60,
+            )
+    except (
+        subprocess.CalledProcessError,
+        FileNotFoundError,
+        subprocess.TimeoutExpired,
+    ):
+        print("Automatic install failed.")
+        print(_install_cloudflared_instructions())
+        return False
+
+    if _check_cloudflared():
+        print("cloudflared installed successfully.")
+        return True
+
+    print("cloudflared still not found on PATH after install.")
+    return False
+
+
+# ---------------------------------------------------------------------------
+# Interactive prompts
+# ---------------------------------------------------------------------------
+
+
+def _prompt_yes_no(question: str, default: bool = True) -> bool:
+    """Ask a yes/no question. Returns bool."""
+    suffix = " [Y/n] " if default else " [y/N] "
+    while True:
+        answer = input(question + suffix).strip().lower()
+        if answer == "":
+            return default
+        if answer in ("y", "yes"):
+            return True
+        if answer in ("n", "no"):
+            return False
+        print("Please answer y or n.")
+
+
+def _prompt_choice(question: str, options: list[str], default: int = 1) -> int:
+    """Show numbered menu, return 1-based index."""
+    print(question)
+    for i, opt in enumerate(options, 1):
+        marker = " *" if i == default else ""
+        print(f"  {i}) {opt}{marker}")
+    while True:
+        answer = input(f"Choice [{default}]: ").strip()
+        if answer == "":
+            return default
+        try:
+            choice = int(answer)
+            if 1 <= choice <= len(options):
+                return choice
+        except ValueError:
+            pass
+        print(f"Please enter a number 1-{len(options)}.")
+
+
+def _prompt_string(question: str, default: str = "") -> str:
+    """Ask for a string value with optional default."""
+    if default:
+        answer = input(f"{question} [{default}]: ").strip()
+        return answer if answer else default
+    return input(f"{question}: ").strip()
+
+
+# ---------------------------------------------------------------------------
+# Cloudflared management
+# ---------------------------------------------------------------------------
+
+
+def _cloudflared_tunnel_list() -> list[dict]:
+    """Return list of existing tunnels as dicts with 'id' and 'name' keys."""
+    try:
+        result = subprocess.run(
+            ["cloudflared", "tunnel", "list", "--output", "json"],
+            capture_output=True,
+            text=True,
+            timeout=30,
+        )
+        if result.returncode != 0:
+            return []
+        data = json.loads(result.stdout)
+        return data if isinstance(data, list) else []
+    except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
+        return []
+
+
+def _cloudflared_tunnel_create(name: str) -> str:
+    """Create a tunnel and return its UUID."""
+    result = subprocess.run(
+        ["cloudflared", "tunnel", "create", name],
+        capture_output=True,
+        text=True,
+        timeout=30,
+    )
+    if result.returncode != 0:
+        raise RuntimeError(f"Failed to create tunnel '{name}': {result.stderr.strip()}")
+    # Parse UUID from output like "Created tunnel <name> with id <uuid>"
+    for line in (result.stdout + result.stderr).splitlines():
+        if "with id" in line.lower():
+            parts = line.strip().split()
+            return parts[-1]
+    raise RuntimeError(f"Could not parse tunnel ID from: {result.stdout}")
+
+
+def _cloudflared_tunnel_delete(name: str) -> bool:
+    """Delete a tunnel by name. Returns True on success."""
+    result = subprocess.run(
+        ["cloudflared", "tunnel", "delete", name],
+        capture_output=True,
+        text=True,
+        timeout=30,
+    )
+    return result.returncode == 0
+
+
+def _cloudflared_tunnel_route_dns(tunnel_name: str, hostname: str) -> bool:
+    """Route DNS for a hostname to a tunnel. Returns True on success."""
+    result = subprocess.run(
+        ["cloudflared", "tunnel", "route", "dns", tunnel_name, hostname],
+        capture_output=True,
+        text=True,
+        timeout=30,
+    )
+    if result.returncode != 0:
+        print(f"Warning: DNS routing failed: {result.stderr.strip()}")
+        return False
+    return True
+
+
+# ---------------------------------------------------------------------------
+# Config generation (string formatting, no PyYAML)
+# ---------------------------------------------------------------------------
+
+
+def _generate_config_yml(
+    tunnel_id: str,
+    credentials_file: str,
+    hostname: str,
+    local_port: int,
+) -> str:
+    """Generate cloudflared config.yml content."""
+    return (
+        f"tunnel: {tunnel_id}\n"
+        f"credentials-file: {credentials_file}\n"
+        f"\n"
+        f"ingress:\n"
+        f"  - hostname: {hostname}\n"
+        f"    service: http://127.0.0.1:{local_port}\n"
+        f"  - service: http_status:404\n"
+    )
+
+
+def _write_config_yml(content: str) -> Path:
+    """Write config.yml to ~/.cloudflared/. Returns the path."""
+    _CLOUDFLARED_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
+    _CLOUDFLARED_CONFIG_FILE.write_text(content)
+    print(f"Wrote {_CLOUDFLARED_CONFIG_FILE}")
+    return _CLOUDFLARED_CONFIG_FILE
+
+
+def _parse_simple_yaml(text: str) -> dict:
+    """Parse top-level key: value pairs from simple YAML. Ignores indented lines."""
+    result = {}
+    for line in text.splitlines():
+        stripped = line.strip()
+        if not stripped or stripped.startswith("#") or stripped.startswith("-"):
+            continue
+        if line[0] in (" ", "\t"):
+            continue
+        if ":" in stripped:
+            key, _, value = stripped.partition(":")
+            result[key.strip()] = value.strip()
+    return result
+
+
+def _detect_existing_config() -> dict | None:
+    """Parse existing config.yml if present. Returns dict or None."""
+    if not _CLOUDFLARED_CONFIG_FILE.exists():
+        return None
+    try:
+        text = _CLOUDFLARED_CONFIG_FILE.read_text()
+        parsed = _parse_simple_yaml(text)
+        if "tunnel" in parsed:
+            return parsed
+    except OSError:
+        pass
+    return None
+
+
+# ---------------------------------------------------------------------------
+# Process management
+# ---------------------------------------------------------------------------
+
+
+def _start_server(host: str, port: int, transport: str) -> subprocess.Popen:
+    """Start SPICEBridge MCP server as a subprocess."""
+    return subprocess.Popen(
+        [
+            sys.executable,
+            "-m",
+            "spicebridge",
+            "--transport",
+            transport,
+            "--host",
+            host,
+            "--port",
+            str(port),
+        ],
+    )
+
+
+def _wait_for_server(host: str, port: int, timeout: int = 30) -> bool:
+    """Wait for the server to respond. Returns True if ready."""
+    url = f"http://{host}:{port}/mcp"
+    deadline = time.monotonic() + timeout
+    while time.monotonic() < deadline:
+        try:
+            req = urllib.request.Request(url, method="GET")
+            with urllib.request.urlopen(req, timeout=2):  # noqa: S310
+                return True
+        except Exception:  # noqa: BLE001
+            time.sleep(0.5)
+    return False
+
+
+def _start_tunnel_quick(local_port: int) -> tuple[subprocess.Popen, str]:
+    """Start a quick tunnel. Returns (process, tunnel_url)."""
+    proc = subprocess.Popen(
+        ["cloudflared", "tunnel", "--url", f"http://127.0.0.1:{local_port}"],
+        stderr=subprocess.PIPE,
+        text=True,
+    )
+    # Parse the tunnel URL from stderr output
+    url = ""
+    deadline = time.monotonic() + 30
+    while time.monotonic() < deadline:
+        line = proc.stderr.readline()  # type: ignore[union-attr]
+        if not line:
+            if proc.poll() is not None:
+                break
+            continue
+        if "trycloudflare.com" in line:
+            # Extract URL from the line
+            for word in line.split():
+                if "trycloudflare.com" in word:
+                    url = word.strip().rstrip("|")
+                    if not url.startswith("http"):
+                        url = "https://" + url
+                    break
+            if url:
+                break
+    return proc, url
+
+
+def _start_tunnel_named(tunnel_name: str) -> subprocess.Popen:
+    """Start a named tunnel."""
+    return subprocess.Popen(
+        ["cloudflared", "tunnel", "run", tunnel_name],
+    )
+
+
+def _run_processes(server_proc: subprocess.Popen, tunnel_proc: subprocess.Popen) -> int:
+    """Block until Ctrl+C, then cleanly shut down both processes."""
+
+    def _shutdown(signum, frame):  # noqa: ARG001, ANN001
+        raise KeyboardInterrupt
+
+    original_sigint = signal.getsignal(signal.SIGINT)
+    signal.signal(signal.SIGINT, _shutdown)
+
+    try:
+        while True:
+            if server_proc.poll() is not None:
+                print("\nServer process exited.")
+                break
+            if tunnel_proc.poll() is not None:
+                print("\nTunnel process exited.")
+                break
+            time.sleep(1)
+    except KeyboardInterrupt:
+        print("\nShutting down...")
+    finally:
+        signal.signal(signal.SIGINT, original_sigint)
+        for proc in (tunnel_proc, server_proc):
+            if proc.poll() is None:
+                proc.terminate()
+                try:
+                    proc.wait(timeout=5)
+                except subprocess.TimeoutExpired:
+                    proc.kill()
+    return 0
+
+
+# ---------------------------------------------------------------------------
+# Output
+# ---------------------------------------------------------------------------
+
+
+def _print_connection_info(url: str, is_permanent: bool) -> None:
+    """Print the connection info box."""
+    tunnel_type = "Permanent" if is_permanent else "Temporary quick"
+    print()
+    print("=" * 50)
+    print("  SPICEBridge cloud MCP server is running")
+    print("=" * 50)
+    print()
+    print(f"  {tunnel_type} URL: {url}/mcp")
+    print()
+    print("  To connect from Claude.ai:")
+    print("  1. Go to Settings > MCP Servers")
+    print(f"  2. Add server URL: {url}/mcp")
+    print()
+    if not is_permanent:
+        print("  Note: This URL is temporary and will change on restart.")
+        print("  For a permanent URL, run: spicebridge setup-cloud")
+        print()
+    print("  Press Ctrl+C to stop.")
+    print()
+
+
+# ---------------------------------------------------------------------------
+# Main wizard flow
+# ---------------------------------------------------------------------------
+
+
+def run_wizard(argv: list[str] | None = None) -> int:
+    """Run the interactive setup wizard. Returns exit code."""
+    args = _parse_wizard_args(argv)
+
+    print(_BANNER)
+
+    # --- Check ngspice ---
+    if not _check_ngspice():
+        print("Warning: ngspice not found on PATH.")
+        print("SPICEBridge requires ngspice for circuit simulation.")
+        if not _prompt_yes_no("Continue without ngspice?", default=False):
+            return 1
+
+    # --- Check cloudflared ---
+    if not _check_cloudflared() and not _offer_install_cloudflared(
+        no_install=args.no_install
+    ):
+        return 1
+
+    # --- Quick tunnel flow ---
+    if args.quick:
+        return _quick_tunnel_flow(args)
+
+    # --- Ask user: quick or permanent? ---
+    choice = _prompt_choice(
+        "Which tunnel type?",
+        [
+            "Quick tunnel (temporary URL, no account needed)",
+            "Named tunnel (permanent URL, requires Cloudflare account)",
+        ],
+        default=1,
+    )
+    if choice == 1:
+        return _quick_tunnel_flow(args)
+
+    # --- Named tunnel flow ---
+    return _named_tunnel_flow(args)
+
+
+def _quick_tunnel_flow(args: argparse.Namespace) -> int:
+    """Run the quick tunnel flow."""
+    print("\nStarting quick tunnel...")
+
+    server_proc = _start_server(args.host, args.port, _DEFAULT_TRANSPORT)
+    print(f"Server starting on {args.host}:{args.port}...")
+
+    if not _wait_for_server(args.host, args.port):
+        print("Error: Server failed to start within 30 seconds.")
+        server_proc.terminate()
+        return 1
+
+    print("Server ready.")
+    tunnel_proc, url = _start_tunnel_quick(args.port)
+
+    if not url:
+        print("Error: Could not obtain quick tunnel URL.")
+        tunnel_proc.terminate()
+        server_proc.terminate()
+        return 1
+
+    _print_connection_info(url, is_permanent=False)
+    return _run_processes(server_proc, tunnel_proc)
+
+
+def _named_tunnel_flow(args: argparse.Namespace) -> int:
+    """Run the named tunnel flow."""
+    # Check for existing config
+    existing = _detect_existing_config()
+    if existing and "tunnel" in existing:
+        print(f"\nExisting tunnel config found: tunnel={existing['tunnel']}")
+        if _prompt_yes_no("Use existing config and start?"):
+            return _start_named_tunnel(args, args.tunnel_name)
+
+    # Check login
+    if not _check_cloudflared_login():
+        print("\nYou need to log in to Cloudflare.")
+        print("Running: cloudflared tunnel login")
+        result = subprocess.run(
+            ["cloudflared", "tunnel", "login"],
+            timeout=120,
+        )
+        if result.returncode != 0:
+            print("Login failed.")
+            return 1
+
+    # List existing tunnels
+    tunnels = _cloudflared_tunnel_list()
+    tunnel_name = args.tunnel_name
+
+    if tunnels:
+        names = [t.get("name", "unnamed") for t in tunnels]
+        print(f"\nExisting tunnels: {', '.join(names)}")
+
+        choice = _prompt_choice(
+            "What would you like to do?",
+            ["Reuse existing tunnel", "Create new tunnel", "Delete and recreate"],
+            default=1,
+        )
+        if choice == 1:
+            # Pick which tunnel
+            if len(tunnels) == 1:
+                tunnel_name = tunnels[0].get("name", tunnel_name)
+            else:
+                idx = _prompt_choice(
+                    "Which tunnel?",
+                    [t.get("name", "unnamed") for t in tunnels],
+                    default=1,
+                )
+                tunnel_name = tunnels[idx - 1].get("name", tunnel_name)
+        elif choice == 3:
+            _cloudflared_tunnel_delete(tunnel_name)
+            _create_new_tunnel(args, tunnel_name)
+        else:
+            tunnel_name = _prompt_string("Tunnel name", default=tunnel_name)
+            _create_new_tunnel(args, tunnel_name)
+    else:
+        tunnel_name = _prompt_string("Tunnel name", default=tunnel_name)
+        _create_new_tunnel(args, tunnel_name)
+
+    # Ask for domain if not provided
+    hostname = args.domain
+    if not hostname:
+        hostname = _prompt_string(
+            "Hostname for the tunnel (e.g. spicebridge.example.com)",
+            default="",
+        )
+
+    # Generate config if we have enough info
+    if hostname:
+        tunnels = _cloudflared_tunnel_list()
+        tunnel_id = ""
+        for t in tunnels:
+            if t.get("name") == tunnel_name:
+                tunnel_id = t.get("id", "")
+                break
+
+        if tunnel_id:
+            creds_file = str(_CLOUDFLARED_CONFIG_DIR / f"{tunnel_id}.json")
+            config = _generate_config_yml(tunnel_id, creds_file, hostname, args.port)
+
+            if _CLOUDFLARED_CONFIG_FILE.exists():
+                if not _prompt_yes_no("Overwrite existing config.yml?"):
+                    print("Keeping existing config.")
+                else:
+                    _write_config_yml(config)
+            else:
+                _write_config_yml(config)
+
+            # Route DNS
+            print(f"\nRouting DNS: {hostname} -> tunnel {tunnel_name}")
+            _cloudflared_tunnel_route_dns(tunnel_name, hostname)
+
+    return _start_named_tunnel(args, tunnel_name)
+
+
+def _create_new_tunnel(args: argparse.Namespace, tunnel_name: str) -> str:  # noqa: ARG001
+    """Create a new tunnel and return its UUID."""
+    print(f"\nCreating tunnel '{tunnel_name}'...")
+    try:
+        tunnel_id = _cloudflared_tunnel_create(tunnel_name)
+        print(f"Tunnel created: {tunnel_id}")
+        return tunnel_id
+    except RuntimeError as e:
+        print(f"Error: {e}")
+        return ""
+
+
+def _start_named_tunnel(args: argparse.Namespace, tunnel_name: str) -> int:
+    """Start server + named tunnel."""
+    server_proc = _start_server(args.host, args.port, _DEFAULT_TRANSPORT)
+    print(f"\nServer starting on {args.host}:{args.port}...")
+
+    if not _wait_for_server(args.host, args.port):
+        print("Error: Server failed to start within 30 seconds.")
+        server_proc.terminate()
+        return 1
+
+    print("Server ready.")
+    print(f"Starting named tunnel '{tunnel_name}'...")
+    tunnel_proc = _start_tunnel_named(tunnel_name)
+
+    time.sleep(3)
+
+    hostname = args.domain
+    url = f"https://{hostname}" if hostname else "(check Cloudflare dashboard for URL)"
+
+    _print_connection_info(url, is_permanent=True)
+    return _run_processes(server_proc, tunnel_proc)
diff --git a/start_cloud.sh b/start_cloud.sh
index 98b214b..755b163 100755
--- a/start_cloud.sh
+++ b/start_cloud.sh
@@ -36,6 +36,18 @@ if ! command -v cloudflared &>/dev/null; then
     exit 1
 fi
 
+# Check for cloudflared config
+if [ ! -f "$HOME/.cloudflared/config.yml" ]; then
+    echo "ERROR: Cloudflare tunnel config not found at ~/.cloudflared/config.yml"
+    echo ""
+    echo "Run the setup wizard to create one:"
+    echo "  spicebridge setup-cloud"
+    echo ""
+    echo "Or for a quick tunnel (no config needed):"
+    echo "  spicebridge setup-cloud --quick"
+    exit 1
+fi
+
 # --- API key generation ---
 
 if [ -z "${SPICEBRIDGE_API_KEY:-}" ]; then
diff --git a/tests/test_security.py b/tests/test_security.py
index e961f86..48cffe2 100644
--- a/tests/test_security.py
+++ b/tests/test_security.py
@@ -157,20 +157,27 @@ class TestSubprocessSafety:
             )
 
     def test_subprocess_has_timeout(self):
+        # Popen manages lifecycle via wait()/terminate(); timeout is not a
+        # constructor parameter, so only enforce for batch helpers.
+        _batch_funcs = {"run", "call", "check_call", "check_output"}
         for py_file, call_node in _iter_subprocess_calls():
+            func_name = call_node.func.attr
+            if func_name not in _batch_funcs:
+                continue
             kw_names = {kw.arg for kw in call_node.keywords}
             assert "timeout" in kw_names, (
                 f"{py_file.name}:{call_node.lineno} subprocess call missing timeout"
             )
 
     def test_only_simulator_imports_subprocess(self):
+        _allowed = {"simulator.py", "setup_wizard.py"}
         for py_file in sorted(_SRC_DIR.glob("**/*.py")):
             tree = ast.parse(py_file.read_text(), filename=str(py_file))
             for node in ast.walk(tree):
                 if isinstance(node, ast.Import):
                     for alias in node.names:
                         if alias.name == "subprocess":
-                            assert py_file.name == "simulator.py", (
+                            assert py_file.name in _allowed, (
                                 f"{py_file.name} imports subprocess"
                             )
                 elif (
@@ -178,7 +185,7 @@ class TestSubprocessSafety:
                     and node.module
                     and "subprocess" in node.module
                 ):
-                    assert py_file.name == "simulator.py", (
+                    assert py_file.name in _allowed, (
                         f"{py_file.name} imports from subprocess"
                     )
 
diff --git a/tests/test_setup_wizard.py b/tests/test_setup_wizard.py
new file mode 100644
index 0000000..53fb4de
--- /dev/null
+++ b/tests/test_setup_wizard.py
@@ -0,0 +1,351 @@
+"""Tests for the setup wizard module."""
+
+from __future__ import annotations
+
+import json
+import subprocess
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from spicebridge.setup_wizard import (
+    _check_cloudflared,
+    _check_cloudflared_login,
+    _check_ngspice,
+    _cloudflared_tunnel_list,
+    _detect_existing_config,
+    _detect_os,
+    _generate_config_yml,
+    _install_cloudflared_instructions,
+    _parse_simple_yaml,
+    _prompt_choice,
+    _prompt_yes_no,
+    run_wizard,
+)
+
+# ---------------------------------------------------------------------------
+# Prerequisite checks
+# ---------------------------------------------------------------------------
+
+
+class TestCheckCloudflared:
+    def test_found(self):
+        with patch(
+            "spicebridge.setup_wizard.shutil.which", return_value="/usr/bin/cloudflared"
+        ):
+            assert _check_cloudflared() == "/usr/bin/cloudflared"
+
+    def test_not_found(self):
+        with patch("spicebridge.setup_wizard.shutil.which", return_value=None):
+            assert _check_cloudflared() is None
+
+
+class TestCheckNgspice:
+    def test_found(self):
+        with patch(
+            "spicebridge.setup_wizard.shutil.which", return_value="/usr/bin/ngspice"
+        ):
+            assert _check_ngspice() == "/usr/bin/ngspice"
+
+    def test_not_found(self):
+        with patch("spicebridge.setup_wizard.shutil.which", return_value=None):
+            assert _check_ngspice() is None
+
+
+class TestCheckCloudflaredLogin:
+    def test_logged_in(self, tmp_path):
+        config_dir = tmp_path / ".cloudflared"
+        config_dir.mkdir()
+        (config_dir / "cert.pem").touch()
+        with patch("spicebridge.setup_wizard._CLOUDFLARED_CONFIG_DIR", config_dir):
+            assert _check_cloudflared_login() is True
+
+    def test_not_logged_in(self, tmp_path):
+        config_dir = tmp_path / ".cloudflared"
+        config_dir.mkdir()
+        with patch("spicebridge.setup_wizard._CLOUDFLARED_CONFIG_DIR", config_dir):
+            assert _check_cloudflared_login() is False
+
+
+class TestDetectOs:
+    def test_macos(self):
+        with patch("spicebridge.setup_wizard.platform.system", return_value="Darwin"):
+            assert _detect_os() == "macos"
+
+    def test_linux_deb(self):
+        with (
+            patch("spicebridge.setup_wizard.platform.system", return_value="Linux"),
+            patch("spicebridge.setup_wizard.shutil.which", return_value="/usr/bin/apt"),
+        ):
+            assert _detect_os() == "linux-deb"
+
+    def test_linux_other(self):
+        with (
+            patch("spicebridge.setup_wizard.platform.system", return_value="Linux"),
+            patch("spicebridge.setup_wizard.shutil.which", return_value=None),
+        ):
+            assert _detect_os() == "linux-other"
+
+    def test_windows(self):
+        with patch("spicebridge.setup_wizard.platform.system", return_value="Windows"):
+            assert _detect_os() == "other"
+
+
+# ---------------------------------------------------------------------------
+# Config generation
+# ---------------------------------------------------------------------------
+
+
+class TestGenerateConfigYml:
+    def test_basic_config(self):
+        result = _generate_config_yml(
+            "abc-123",
+            "/home/user/.cloudflared/abc-123.json",
+            "spice.example.com",
+            8000,
+        )
+        assert "tunnel: abc-123" in result
+        assert "credentials-file: /home/user/.cloudflared/abc-123.json" in result
+        assert "hostname: spice.example.com" in result
+        assert "service: http://127.0.0.1:8000" in result
+        assert "http_status:404" in result
+
+    def test_custom_port(self):
+        result = _generate_config_yml("x", "y", "z", 9999)
+        assert "service: http://127.0.0.1:9999" in result
+
+
+class TestParseSimpleYaml:
+    def test_basic_pairs(self):
+        text = "tunnel: abc-123\ncredentials-file: /path/to/creds.json\n"
+        result = _parse_simple_yaml(text)
+        assert result["tunnel"] == "abc-123"
+        assert result["credentials-file"] == "/path/to/creds.json"
+
+    def test_ignores_comments(self):
+        text = "# this is a comment\ntunnel: abc\n"
+        result = _parse_simple_yaml(text)
+        assert result == {"tunnel": "abc"}
+
+    def test_ignores_indented_lines(self):
+        text = "tunnel: abc\n  - hostname: foo\n    service: bar\n"
+        result = _parse_simple_yaml(text)
+        assert result == {"tunnel": "abc"}
+
+    def test_ignores_list_items(self):
+        text = "tunnel: abc\n- service: http_status:404\n"
+        result = _parse_simple_yaml(text)
+        assert result == {"tunnel": "abc"}
+
+    def test_empty_string(self):
+        assert _parse_simple_yaml("") == {}
+
+
+class TestDetectExistingConfig:
+    def test_no_file(self, tmp_path):
+        with patch(
+            "spicebridge.setup_wizard._CLOUDFLARED_CONFIG_FILE",
+            tmp_path / "nonexistent.yml",
+        ):
+            assert _detect_existing_config() is None
+
+    def test_valid_config(self, tmp_path):
+        config_file = tmp_path / "config.yml"
+        config_file.write_text("tunnel: abc-123\ncredentials-file: /path\n")
+        with patch("spicebridge.setup_wizard._CLOUDFLARED_CONFIG_FILE", config_file):
+            result = _detect_existing_config()
+            assert result is not None
+            assert result["tunnel"] == "abc-123"
+
+    def test_malformed_config(self, tmp_path):
+        config_file = tmp_path / "config.yml"
+        config_file.write_text("just some random text without colons")
+        with patch("spicebridge.setup_wizard._CLOUDFLARED_CONFIG_FILE", config_file):
+            # No "tunnel" key => returns None
+            assert _detect_existing_config() is None
+
+
+# ---------------------------------------------------------------------------
+# Cloudflared management
+# ---------------------------------------------------------------------------
+
+
+class TestCloudflaredTunnelList:
+    def test_success(self):
+        mock_result = MagicMock()
+        mock_result.returncode = 0
+        mock_result.stdout = json.dumps(
+            [
+                {"id": "abc-123", "name": "spicebridge"},
+            ]
+        )
+        with patch("spicebridge.setup_wizard.subprocess.run", return_value=mock_result):
+            tunnels = _cloudflared_tunnel_list()
+            assert len(tunnels) == 1
+            assert tunnels[0]["name"] == "spicebridge"
+
+    def test_error(self):
+        mock_result = MagicMock()
+        mock_result.returncode = 1
+        mock_result.stdout = ""
+        with patch("spicebridge.setup_wizard.subprocess.run", return_value=mock_result):
+            assert _cloudflared_tunnel_list() == []
+
+    def test_timeout(self):
+        with patch(
+            "spicebridge.setup_wizard.subprocess.run",
+            side_effect=subprocess.TimeoutExpired(cmd=["cloudflared"], timeout=30),
+        ):
+            assert _cloudflared_tunnel_list() == []
+
+
+# ---------------------------------------------------------------------------
+# Prompts
+# ---------------------------------------------------------------------------
+
+
+class TestPromptYesNo:
+    def test_default_yes(self):
+        with patch("builtins.input", return_value=""):
+            assert _prompt_yes_no("Test?", default=True) is True
+
+    def test_default_no(self):
+        with patch("builtins.input", return_value=""):
+            assert _prompt_yes_no("Test?", default=False) is False
+
+    def test_explicit_yes(self):
+        with patch("builtins.input", return_value="y"):
+            assert _prompt_yes_no("Test?") is True
+
+    def test_explicit_no(self):
+        with patch("builtins.input", return_value="n"):
+            assert _prompt_yes_no("Test?") is False
+
+    def test_yes_word(self):
+        with patch("builtins.input", return_value="yes"):
+            assert _prompt_yes_no("Test?") is True
+
+    def test_invalid_then_yes(self):
+        with patch("builtins.input", side_effect=["maybe", "y"]):
+            assert _prompt_yes_no("Test?") is True
+
+
+class TestPromptChoice:
+    def test_default(self):
+        with patch("builtins.input", return_value=""):
+            assert _prompt_choice("Pick:", ["A", "B"], default=1) == 1
+
+    def test_explicit_choice(self):
+        with patch("builtins.input", return_value="2"):
+            assert _prompt_choice("Pick:", ["A", "B"], default=1) == 2
+
+    def test_invalid_then_valid(self):
+        with patch("builtins.input", side_effect=["abc", "3", "1"]):
+            assert _prompt_choice("Pick:", ["A", "B"], default=1) == 1
+
+
+# ---------------------------------------------------------------------------
+# Install instructions
+# ---------------------------------------------------------------------------
+
+
+class TestInstallInstructions:
+    def test_macos(self):
+        with patch("spicebridge.setup_wizard._detect_os", return_value="macos"):
+            text = _install_cloudflared_instructions()
+            assert "brew" in text
+
+    def test_linux_deb(self):
+        with patch("spicebridge.setup_wizard._detect_os", return_value="linux-deb"):
+            text = _install_cloudflared_instructions()
+            assert "apt" in text
+
+    def test_other(self):
+        with patch("spicebridge.setup_wizard._detect_os", return_value="other"):
+            text = _install_cloudflared_instructions()
+            assert "cloudflare.com" in text
+
+
+# ---------------------------------------------------------------------------
+# Integration: run_wizard
+# ---------------------------------------------------------------------------
+
+
+class TestRunWizard:
+    def test_quick_tunnel_happy_path(self):
+        """Quick tunnel with everything mocked succeeds."""
+        mock_server = MagicMock()
+        mock_server.poll.return_value = None
+
+        mock_tunnel = MagicMock()
+        mock_tunnel.poll.return_value = None
+        mock_tunnel.stderr.readline.return_value = "https://test-abc.trycloudflare.com"
+
+        with (
+            patch(
+                "spicebridge.setup_wizard._check_ngspice",
+                return_value="/usr/bin/ngspice",
+            ),
+            patch(
+                "spicebridge.setup_wizard._check_cloudflared",
+                return_value="/usr/bin/cloudflared",
+            ),
+            patch("spicebridge.setup_wizard._start_server", return_value=mock_server),
+            patch("spicebridge.setup_wizard._wait_for_server", return_value=True),
+            patch(
+                "spicebridge.setup_wizard.subprocess.Popen", return_value=mock_tunnel
+            ),
+            patch("spicebridge.setup_wizard._run_processes", return_value=0),
+            patch("spicebridge.setup_wizard.time.monotonic", side_effect=[0, 1, 2]),
+        ):
+            result = run_wizard(["--quick"])
+            assert result == 0
+
+    def test_missing_cloudflared_no_install_exits(self):
+        """Missing cloudflared with --no-install exits with code 1."""
+        with (
+            patch(
+                "spicebridge.setup_wizard._check_ngspice",
+                return_value="/usr/bin/ngspice",
+            ),
+            patch("spicebridge.setup_wizard._check_cloudflared", return_value=None),
+            patch("spicebridge.setup_wizard._detect_os", return_value="other"),
+        ):
+            result = run_wizard(["--quick", "--no-install"])
+            assert result == 1
+
+    def test_server_startup_failure_exits(self):
+        """Server failing to start returns exit code 1."""
+        mock_server = MagicMock()
+        mock_server.terminate = MagicMock()
+
+        with (
+            patch(
+                "spicebridge.setup_wizard._check_ngspice",
+                return_value="/usr/bin/ngspice",
+            ),
+            patch(
+                "spicebridge.setup_wizard._check_cloudflared",
+                return_value="/usr/bin/cloudflared",
+            ),
+            patch("spicebridge.setup_wizard._start_server", return_value=mock_server),
+            patch("spicebridge.setup_wizard._wait_for_server", return_value=False),
+        ):
+            result = run_wizard(["--quick"])
+            assert result == 1
+            mock_server.terminate.assert_called_once()
+
+    def test_missing_ngspice_decline_exits(self):
+        """User declining to continue without ngspice exits with code 1."""
+        with (
+            patch("spicebridge.setup_wizard._check_ngspice", return_value=None),
+            patch("builtins.input", return_value="n"),
+        ):
+            result = run_wizard(["--quick"])
+            assert result == 1
+
+    def test_help_flag(self, capsys):
+        """--help flag exits cleanly."""
+        with pytest.raises(SystemExit) as exc_info:
+            run_wizard(["--help"])
+        assert exc_info.value.code == 0
