feat: add clide-web package replacing ttyd + zellij

Python web server that wraps Clide for browser access. Replaces the
previous ttyd (C binary) + zellij (Rust binary) stack with a single
FastAPI application using tmux for session persistence.

Key features:
- WebSocket ↔ PTY bridge via tmux attach
- Project switching via /projects/<name> URL routing
- Vendored xterm.js for offline LAN operation
- Auto-respawn on Clide exit (tmux pane-died hook)
- Setup wizard for first-run configuration
- No scrollbar (TUI manages its own scrolling)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 17:03:43 +01:00
co-authored by Claude Opus 4.6
parent 29a858d7a8
commit 56a8ea4ba4
15 changed files with 1524 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
.PHONY: setup run dev test lint format clean help start-server stop-server restart-server status-server install-server logs-server
PYTHON := python3.12
VENV := .venv
BIN := $(VENV)/bin
SERVICE := clide-web
BLUE := \033[0;34m
GREEN := \033[0;32m
YELLOW := \033[0;33m
RESET := \033[0m
help:
@printf "$(BLUE)clide-web Commands$(RESET)\n\n"
@printf "$(YELLOW)Development$(RESET)\n"
@printf " $(GREEN)setup$(RESET) Create venv, install deps, run setup wizard\n"
@printf " $(GREEN)run$(RESET) Run the web server (foreground)\n"
@printf " $(GREEN)dev$(RESET) Run with auto-reload\n"
@printf " $(GREEN)test$(RESET) Run tests\n"
@printf " $(GREEN)lint$(RESET) Run ruff linter\n"
@printf " $(GREEN)format$(RESET) Run ruff formatter\n"
@printf " $(GREEN)clean$(RESET) Remove build artifacts\n"
@printf "\n$(YELLOW)Service$(RESET)\n"
@printf " $(GREEN)install-server$(RESET) Install systemd service (requires sudo)\n"
@printf " $(GREEN)start-server$(RESET) Start the systemd service\n"
@printf " $(GREEN)stop-server$(RESET) Stop the systemd service\n"
@printf " $(GREEN)restart-server$(RESET) Restart the systemd service\n"
@printf " $(GREEN)status-server$(RESET) Show service status\n"
@printf " $(GREEN)logs-server$(RESET) Tail service logs\n"
# --- Development ---
setup:
$(PYTHON) -m venv $(VENV)
$(BIN)/pip install --upgrade pip
$(BIN)/pip install -e ".[dev]"
$(BIN)/clide-web-setup
@printf "$(GREEN)Setup complete!$(RESET)\n"
run:
$(BIN)/clide-web
dev:
$(BIN)/uvicorn clide_web.server:app --reload --host 0.0.0.0 --port 8888
test:
$(BIN)/pytest tests/
lint:
$(BIN)/ruff check clide_web/
format:
$(BIN)/ruff format clide_web/
$(BIN)/ruff check --fix clide_web/
clean:
rm -rf $(VENV) dist/ build/
rm -rf .pytest_cache/ .mypy_cache/ .ruff_cache/
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
# --- Service ---
install-server:
@if [ "$$(id -u)" -ne 0 ]; then printf "$(YELLOW)Run with sudo: sudo make install-server$(RESET)\n"; exit 1; fi
cp ../deploy/clide-web.service /etc/systemd/system/$(SERVICE).service
systemctl daemon-reload
systemctl enable $(SERVICE)
@printf "$(GREEN)Service installed. Run 'make start-server' to start.$(RESET)\n"
start-server:
sudo systemctl start $(SERVICE)
@systemctl is-active --quiet $(SERVICE) && printf "$(GREEN)$(SERVICE) started$(RESET)\n" || printf "$(YELLOW)Failed to start. Check: make logs-server$(RESET)\n"
stop-server:
sudo systemctl stop $(SERVICE)
@printf "$(GREEN)$(SERVICE) stopped$(RESET)\n"
restart-server:
sudo systemctl restart $(SERVICE)
@systemctl is-active --quiet $(SERVICE) && printf "$(GREEN)$(SERVICE) restarted$(RESET)\n" || printf "$(YELLOW)Failed to restart. Check: make logs-server$(RESET)\n"
status-server:
@systemctl status $(SERVICE) --no-pager || true
logs-server:
journalctl -u $(SERVICE) -f --no-pager
+3
View File
@@ -0,0 +1,3 @@
"""clide-web: Web server for Clide TUI."""
__version__ = "1.0.0"
+6
View File
@@ -0,0 +1,6 @@
"""Entry point for python -m clide_web."""
from clide_web.server import main
if __name__ == "__main__":
main()
+108
View File
@@ -0,0 +1,108 @@
"""Configuration for clide-web using Pydantic Settings + DB preferences."""
from __future__ import annotations
import logging
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
logger = logging.getLogger(__name__)
DB_PATH = Path.home() / ".clide" / "clide.db"
class ClideWebSettings(BaseSettings):
"""Web server settings.
Priority: env vars > .env file > DB preferences > defaults.
"""
model_config = SettingsConfigDict(
env_prefix="CLIDE_WEB_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# Server
host: str = "0.0.0.0"
port: int = 8888
# Paths
projects_dir: Path = Path("/mnt/media/Projects")
clide_bin: str = "clide"
# Database
db_path: Path = DB_PATH
# Sessions
session_timeout_seconds: int = 3600
session_cleanup_interval_seconds: int = 60
# Terminal
default_cols: int = 120
default_rows: int = 40
term: str = "xterm-256color"
# UI
font_family: str = "JetBrains Mono, monospace"
font_size: int = 14
default_theme: str = "summer-night"
def load_settings() -> ClideWebSettings:
"""Load settings, overlaying DB preferences onto defaults.
Env vars still take highest priority (Pydantic handles that).
DB preferences override hardcoded defaults for fields not set via env.
"""
import os
# First load from env/defaults
settings = ClideWebSettings()
# Then overlay DB preferences for fields not explicitly set via env
try:
prefs = _load_db_preferences(settings.db_path)
except Exception:
logger.debug("Could not load DB preferences (DB may not exist yet)")
return settings
env_prefix = "CLIDE_WEB_"
field_map = {
"projects_dir": ("projects_dir", Path),
"clide_bin": ("clide_bin", str),
"port": ("port", int),
"font_size": ("font_size", int),
"default_theme": ("default_theme", str),
"font_family": ("font_family", str),
"host": ("host", str),
}
for pref_key, (field_name, field_type) in field_map.items():
env_var = f"{env_prefix}{field_name.upper()}"
# Only apply DB pref if env var is NOT set
if env_var not in os.environ and pref_key in prefs:
try:
setattr(settings, field_name, field_type(prefs[pref_key]))
except (ValueError, TypeError):
pass
return settings
def _load_db_preferences(db_path: Path) -> dict[str, str]:
"""Read UserPreference records from the database."""
if not db_path.exists():
return {}
from clide.models.db import UserPreference
from clide.services.database import get_engine
from sqlmodel import Session as DBSession
from sqlmodel import select
engine = get_engine(db_path)
with DBSession(engine) as db:
stmt = select(UserPreference)
return {p.key: p.value for p in db.exec(stmt).all()}
Binary file not shown.
+169
View File
@@ -0,0 +1,169 @@
"""PTY bridge: fork a PTY running tmux attach, async I/O to WebSocket clients."""
from __future__ import annotations
import asyncio
import fcntl
import os
import pty
import signal
import struct
import termios
from collections.abc import Callable
class PtyBridge:
"""Manages a single PTY connection to a tmux session.
One PtyBridge per WebSocket connection. The PTY runs `tmux attach -t <session>`.
When the WebSocket disconnects, the PTY is killed but the tmux session persists.
"""
def __init__(
self,
tmux_session: str,
on_output: Callable[[bytes], None],
on_exit: Callable[[], None],
rows: int = 40,
cols: int = 120,
) -> None:
self._tmux_session = tmux_session
self._on_output = on_output
self._on_exit = on_exit
self._rows = rows
self._cols = cols
self._pid: int | None = None
self._master_fd: int | None = None
self._read_task: asyncio.Task | None = None # type: ignore[type-arg]
@property
def is_running(self) -> bool:
if self._pid is None:
return False
try:
pid, _ = os.waitpid(self._pid, os.WNOHANG)
return pid == 0
except ChildProcessError:
return False
def start(self) -> None:
"""Fork a PTY and exec tmux attach."""
pid, master_fd = pty.fork()
if pid == 0:
# Child process
os.environ["TERM"] = "xterm-256color"
os.environ["COLORTERM"] = "truecolor"
os.environ["COLUMNS"] = str(self._cols)
os.environ["LINES"] = str(self._rows)
os.execvp("tmux", ["tmux", "attach-session", "-t", self._tmux_session])
else:
# Parent process
self._pid = pid
self._master_fd = master_fd
# Set non-blocking I/O
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
# Set initial PTY size
self._set_pty_size(self._rows, self._cols)
# Start async read loop
self._read_task = asyncio.create_task(self._read_output())
async def _read_output(self) -> None:
"""Read PTY output and forward to callback."""
if self._master_fd is None:
return
loop = asyncio.get_event_loop()
fd = self._master_fd
try:
while True:
# Wait for data using event loop (more efficient than polling)
await _wait_for_fd(loop, fd)
try:
data = os.read(fd, 65536)
if not data:
break
self._on_output(data)
except BlockingIOError:
continue
except OSError:
break
except asyncio.CancelledError:
pass
finally:
self._on_exit()
def write(self, data: bytes) -> None:
"""Write input data to the PTY."""
if self._master_fd is not None:
try:
os.write(self._master_fd, data)
except OSError:
pass
def resize(self, rows: int, cols: int) -> None:
"""Resize the PTY and notify the child process."""
self._rows = rows
self._cols = cols
if self._master_fd is not None:
self._set_pty_size(rows, cols)
def _set_pty_size(self, rows: int, cols: int) -> None:
"""Set PTY window size via ioctl and send SIGWINCH."""
if self._master_fd is None:
return
try:
winsize = struct.pack("HHHH", rows, cols, 0, 0)
fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, winsize)
if self._pid is not None:
try:
os.kill(self._pid, signal.SIGWINCH)
except OSError:
pass
except OSError:
pass
def stop(self) -> None:
"""Kill the PTY process and clean up."""
if self._read_task:
self._read_task.cancel()
self._read_task = None
if self._pid is not None:
try:
os.kill(self._pid, signal.SIGTERM)
except OSError:
pass
try:
os.waitpid(self._pid, 0)
except ChildProcessError:
pass
self._pid = None
if self._master_fd is not None:
try:
os.close(self._master_fd)
except OSError:
pass
self._master_fd = None
async def _wait_for_fd(loop: asyncio.AbstractEventLoop, fd: int) -> None:
"""Wait until a file descriptor has data ready to read."""
future: asyncio.Future[None] = loop.create_future()
def _ready() -> None:
if not future.done():
future.set_result(None)
loop.add_reader(fd, _ready)
try:
await future
finally:
loop.remove_reader(fd)
+309
View File
@@ -0,0 +1,309 @@
"""FastAPI application: HTTP routes + WebSocket terminal bridge."""
from __future__ import annotations
import asyncio
import json
import logging
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
import uvicorn
from clide.models.db import ConnectionLog
from clide.services.database import get_db, init_db
from fastapi import Depends, FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from sqlmodel import Session as DBSession
from clide_web.config import ClideWebSettings, load_settings
from clide_web.pty_bridge import PtyBridge
from clide_web.sessions import TmuxSessionManager
logger = logging.getLogger(__name__)
# Module-level settings and session manager (initialized in lifespan)
_settings: ClideWebSettings | None = None
_session_mgr: TmuxSessionManager | None = None
_cleanup_task: asyncio.Task | None = None # type: ignore[type-arg]
STATIC_DIR = Path(__file__).parent / "static"
# ------------------------------------------------------------------
# Lifespan
# ------------------------------------------------------------------
@asynccontextmanager
async def lifespan(_app: FastAPI): # type: ignore[no-untyped-def]
global _settings, _session_mgr, _cleanup_task
_settings = load_settings()
_session_mgr = TmuxSessionManager(_settings)
# Initialize database
init_db(_settings.db_path)
logger.info("Database initialized at %s", _settings.db_path)
# Start periodic session cleanup
_cleanup_task = asyncio.create_task(_periodic_cleanup())
yield
# Shutdown
if _cleanup_task:
_cleanup_task.cancel()
try:
await _cleanup_task
except asyncio.CancelledError:
pass
async def _periodic_cleanup() -> None:
"""Periodically sync DB session records with live tmux state."""
assert _settings is not None
assert _session_mgr is not None
while True:
await asyncio.sleep(_settings.session_cleanup_interval_seconds)
try:
db_gen = get_db()
db = next(db_gen)
try:
await _session_mgr.cleanup_dead_sessions(db)
finally:
try:
next(db_gen)
except StopIteration:
pass
except Exception:
logger.exception("Session cleanup failed")
# ------------------------------------------------------------------
# App
# ------------------------------------------------------------------
app = FastAPI(title="clide-web", version="1.0.0", lifespan=lifespan)
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# ------------------------------------------------------------------
# Routes
# ------------------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
async def index():
"""Redirect root to project list or serve HTML."""
return _serve_index()
@app.get("/projects/{project_name}", response_class=HTMLResponse)
@app.head("/projects/{project_name}")
async def project_page(project_name: str): # noqa: ARG001
"""Serve the terminal page for a specific project."""
return _serve_index()
def _serve_index() -> FileResponse | HTMLResponse:
index_path = STATIC_DIR / "index.html"
if index_path.exists():
return FileResponse(index_path, media_type="text/html")
return HTMLResponse("<h1>clide-web</h1><p>index.html not found</p>", status_code=500)
@app.get("/health")
async def health():
"""Health check for reverse proxy."""
return {"status": "ok"}
@app.get("/api/projects")
async def list_projects():
"""List available git projects."""
assert _session_mgr is not None
return {"projects": _session_mgr.list_projects()}
@app.get("/api/sessions")
async def list_sessions():
"""List active tmux sessions."""
assert _session_mgr is not None
sessions = await _session_mgr.list_sessions()
return {"sessions": sessions}
# ------------------------------------------------------------------
# WebSocket terminal bridge
# ------------------------------------------------------------------
@app.websocket("/projects/{project}/ws")
async def websocket_terminal(
ws: WebSocket,
project: str,
db: DBSession = Depends(get_db), # noqa: B008
):
"""Bridge WebSocket ↔ PTY (via tmux attach).
Protocol:
Prefix "0" + data → terminal I/O
Prefix "1" + json → control messages
Prefix "2" + C,R → resize (cols,rows)
"""
assert _settings is not None
assert _session_mgr is not None
await ws.accept()
if not project:
await ws.send_text('1{"type":"error","message":"No project specified"}')
await ws.close(code=1008)
return
# Validate and create/attach tmux session
try:
tmux_name = await _session_mgr.create_session(project, db)
except (ValueError, RuntimeError) as e:
await ws.send_text(f'1{json.dumps({"type": "error", "message": str(e)})}')
await ws.close(code=1008)
return
# Log connection
client_ip = ws.client.host if ws.client else "unknown"
log_entry = ConnectionLog(project_name=project, client_ip=client_ip)
db.add(log_entry)
db.commit()
db.refresh(log_entry)
# Send session info
await ws.send_text(
f'1{json.dumps({"type": "session_info", "project": project, "tmux_session": tmux_name})}'
)
# Set up PTY bridge to tmux session
send_queue: asyncio.Queue[bytes] = asyncio.Queue()
def on_output(data: bytes) -> None:
send_queue.put_nowait(data)
def on_exit() -> None:
send_queue.put_nowait(b"") # Sentinel for EOF
bridge = PtyBridge(
tmux_session=tmux_name,
on_output=on_output,
on_exit=on_exit,
rows=_settings.default_rows,
cols=_settings.default_cols,
)
bridge.start()
# Task to forward PTY output → WebSocket
async def _forward_output() -> None:
while True:
data = await send_queue.get()
if not data:
break
try:
await ws.send_bytes(b"0" + data)
except Exception:
break
output_task = asyncio.create_task(_forward_output())
try:
while True:
message = await ws.receive()
if message["type"] == "websocket.disconnect":
break
if "text" in message:
text = message["text"]
if not text:
continue
prefix = text[0]
payload = text[1:]
if prefix == "0":
# Terminal input
bridge.write(payload.encode("utf-8", errors="surrogateescape"))
elif prefix == "1":
# Control message
await _handle_control(ws, payload, project)
elif prefix == "2":
# Resize: "2cols,rows"
try:
cols_str, rows_str = payload.split(",", 1)
bridge.resize(int(rows_str), int(cols_str))
except (ValueError, IndexError):
pass
elif "bytes" in message:
raw = message["bytes"]
if raw and len(raw) > 1:
prefix = raw[0:1]
payload_bytes = raw[1:]
if prefix == b"0":
bridge.write(payload_bytes)
except WebSocketDisconnect:
pass
except Exception:
logger.exception("WebSocket error for project %s", project)
finally:
bridge.stop()
output_task.cancel()
try:
await output_task
except asyncio.CancelledError:
pass
# Update connection log
log_entry.disconnected_at = datetime.utcnow()
db.add(log_entry)
db.commit()
async def _handle_control(ws: WebSocket, payload: str, project: str) -> None: # noqa: ARG001
"""Handle a JSON control message from the client."""
try:
msg = json.loads(payload)
except json.JSONDecodeError:
return
msg_type = msg.get("type", "")
if msg_type == "list_projects":
assert _session_mgr is not None
projects = _session_mgr.list_projects()
await ws.send_text(f'1{json.dumps({"type": "projects", "projects": projects})}')
elif msg_type == "ping":
await ws.send_text(f'1{json.dumps({"type": "pong"})}')
# ------------------------------------------------------------------
# Entry point
# ------------------------------------------------------------------
def main() -> None:
"""Run the clide-web server."""
settings = load_settings()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
)
logger.info("Starting clide-web on %s:%d", settings.host, settings.port)
uvicorn.run(
app,
host=settings.host,
port=settings.port,
log_level="info",
ws_ping_interval=20,
ws_ping_timeout=20,
)
+240
View File
@@ -0,0 +1,240 @@
"""tmux session manager: create, attach, list, kill sessions."""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING
from clide.models.db import Project, Session
from sqlmodel import Session as DBSession
from sqlmodel import select
if TYPE_CHECKING:
from clide_web.config import ClideWebSettings
logger = logging.getLogger(__name__)
class TmuxSessionManager:
"""Manages tmux sessions, one per project."""
def __init__(self, settings: ClideWebSettings) -> None:
self._settings = settings
# ------------------------------------------------------------------
# Project discovery
# ------------------------------------------------------------------
def list_projects(self) -> list[str]:
"""List git repos in the projects directory."""
projects_dir = self._settings.projects_dir
if not projects_dir.is_dir():
return []
return sorted(
d.name for d in projects_dir.iterdir() if d.is_dir() and (d / ".git").exists()
)
def validate_project(self, name: str) -> Path | None:
"""Return project path if valid, else None."""
project_dir = self._settings.projects_dir / name
if project_dir.is_dir() and (project_dir / ".git").exists():
return project_dir
return None
# ------------------------------------------------------------------
# tmux operations
# ------------------------------------------------------------------
@staticmethod
def _session_name(project: str) -> str:
return f"clide-{project}"
async def session_exists(self, project: str) -> bool:
"""Check if a tmux session exists for this project."""
name = self._session_name(project)
proc = await asyncio.create_subprocess_exec(
"tmux",
"has-session",
"-t",
name,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
return proc.returncode == 0
async def create_session(self, project: str, db: DBSession) -> str:
"""Create a new tmux session running clide for the given project.
Returns the tmux session name.
"""
name = self._session_name(project)
project_dir = self.validate_project(project)
if project_dir is None:
raise ValueError(f"Project '{project}' not found")
# Check if session already exists
if await self.session_exists(project):
logger.info("tmux session %s already exists, reusing", name)
else:
clide_bin = _resolve_clide_bin(self._settings.clide_bin)
env = _build_env(self._settings.term)
proc = await asyncio.create_subprocess_exec(
"tmux",
"new-session",
"-d", # detached
"-s",
name, # session name
"-c",
str(project_dir), # working directory
"-x",
str(self._settings.default_cols),
"-y",
str(self._settings.default_rows),
clide_bin, # command to run
env=env,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"Failed to create tmux session: {stderr.decode().strip()}")
# Configure session: hide status bar, auto-respawn on exit
# pane-died hook: kill the dead pane, clear all history, respawn clean
respawn_cmd = (
f"respawn-pane -k -t {name} -c {project_dir} {clide_bin} \\; "
f"clear-history -t {name}"
)
for opt_args in [
["set-option", "-t", name, "status", "off"],
["set-option", "-t", name, "remain-on-exit", "on"],
["set-hook", "-t", name, "pane-died", respawn_cmd],
]:
await asyncio.create_subprocess_exec(
"tmux",
*opt_args,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
logger.info("Created tmux session %s for project %s", name, project)
# Persist to DB
_upsert_project(db, project, str(project_dir))
_upsert_session(db, project, name)
return name
async def list_sessions(self) -> list[dict[str, str]]:
"""List active tmux sessions matching clide-* pattern."""
proc = await asyncio.create_subprocess_exec(
"tmux",
"list-sessions",
"-F",
"#{session_name}:#{session_created}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
stdout, _ = await proc.communicate()
if proc.returncode != 0:
return []
sessions = []
for line in stdout.decode().strip().splitlines():
if not line.startswith("clide-"):
continue
parts = line.split(":", 1)
name = parts[0]
project = name.removeprefix("clide-")
sessions.append({"name": name, "project": project})
return sessions
async def kill_session(self, project: str) -> None:
"""Kill a tmux session for a project."""
name = self._session_name(project)
proc = await asyncio.create_subprocess_exec(
"tmux",
"kill-session",
"-t",
name,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
logger.info("Killed tmux session %s", name)
async def cleanup_dead_sessions(self, db: DBSession) -> None:
"""Sync DB session records with actual tmux state."""
live = await self.list_sessions()
live_names = {s["name"] for s in live}
stmt = select(Session).where(Session.status == "active")
for session in db.exec(stmt).all():
if session.tmux_session not in live_names:
session.status = "dead"
db.add(session)
db.commit()
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _build_env(term: str) -> dict[str, str]:
"""Build environment for tmux sessions, inheriting the current env."""
import os
env = os.environ.copy()
env["TERM"] = term
env["COLORTERM"] = "truecolor"
return env
def _resolve_clide_bin(clide_bin: str) -> str:
"""Resolve clide binary path to absolute if relative."""
import shutil
path = Path(clide_bin)
if path.is_absolute():
return clide_bin
# Relative path — resolve against cwd
resolved = Path.cwd() / path
if resolved.is_file():
return str(resolved)
# Try to find it on PATH
found = shutil.which(clide_bin)
if found:
return found
return clide_bin # Last resort: return as-is
def _upsert_project(db: DBSession, name: str, path: str) -> Project:
"""Create or update a project record."""
stmt = select(Project).where(Project.name == name)
project = db.exec(stmt).first()
if project is None:
project = Project(name=name, path=path)
project.last_accessed = datetime.utcnow()
db.add(project)
db.commit()
db.refresh(project)
return project
def _upsert_session(db: DBSession, project_name: str, tmux_session: str) -> Session:
"""Create or update a session record."""
stmt = select(Session).where(Session.tmux_session == tmux_session)
session = db.exec(stmt).first()
if session is None:
session = Session(project_name=project_name, tmux_session=tmux_session)
session.status = "active"
session.last_activity = datetime.utcnow()
db.add(session)
db.commit()
db.refresh(session)
return session
+117
View File
@@ -0,0 +1,117 @@
"""Interactive setup wizard for clide-web configuration."""
from __future__ import annotations
import shutil
from pathlib import Path
from clide.models.db import UserPreference
from clide.services.database import get_engine, init_db
from sqlmodel import Session as DBSession
from sqlmodel import select
def run_setup() -> None:
"""Run the interactive setup wizard, persisting config to the database."""
print()
print(" ╔═══════════════════════════════════════╗")
print(" ║ clide-web Setup Wizard ║")
print(" ╚═══════════════════════════════════════╝")
print()
db_path = Path.home() / ".clide" / "clide.db"
init_db(db_path)
engine = get_engine(db_path)
with DBSession(engine) as db:
# Projects directory
current = _get_pref(db, "projects_dir")
default = current or _guess_projects_dir()
projects_dir = _prompt("Projects directory", default)
path = Path(projects_dir).expanduser().resolve()
if not path.is_dir():
print(f" Warning: '{path}' does not exist yet")
_set_pref(db, "projects_dir", str(path))
# Clide binary
current = _get_pref(db, "clide_bin")
default = current or _find_clide_bin()
clide_bin = _prompt("Clide binary path", default)
_set_pref(db, "clide_bin", clide_bin)
# Port
current = _get_pref(db, "port")
port = _prompt("Server port", current or "8888")
_set_pref(db, "port", port)
# Font size
current = _get_pref(db, "font_size")
font_size = _prompt("Terminal font size", current or "14")
_set_pref(db, "font_size", font_size)
db.commit()
print()
print(" Configuration saved to ~/.clide/clide.db")
print()
print(" Run with: clide-web")
print()
def _prompt(label: str, default: str) -> str:
"""Prompt the user with a default value."""
result = input(f" {label} [{default}]: ").strip()
return result if result else default
def _get_pref(db: DBSession, key: str) -> str | None:
"""Get a preference from the database."""
stmt = select(UserPreference).where(UserPreference.key == key)
pref = db.exec(stmt).first()
return pref.value if pref else None
def _set_pref(db: DBSession, key: str, value: str) -> None:
"""Set a preference in the database."""
stmt = select(UserPreference).where(UserPreference.key == key)
pref = db.exec(stmt).first()
if pref is None:
pref = UserPreference(key=key, value=value)
else:
pref.value = value
db.add(pref)
def _guess_projects_dir() -> str:
"""Try to guess a sensible default for projects directory."""
candidates = [
Path.home() / "Projects",
Path.home() / "projects",
Path.home() / "src",
Path.home() / "code",
Path("/mnt/media/Projects"),
]
for p in candidates:
if p.is_dir():
return str(p)
return str(Path.home() / "Projects")
def _find_clide_bin() -> str:
"""Try to find the clide binary."""
found = shutil.which("clide")
if found:
return found
# Check common venv locations
candidates = [
Path.cwd() / ".venv" / "bin" / "clide",
Path.cwd().parent / ".venv" / "bin" / "clide",
]
for p in candidates:
if p.exists():
return str(p)
return "clide"
if __name__ == "__main__":
run_setup()
+388
View File
@@ -0,0 +1,388 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Clide</title>
<link rel="stylesheet" href="/static/vendor/xterm.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #21262f;
font-family: 'JetBrains Mono', monospace;
/* Kill all scrollbars */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
html::-webkit-scrollbar,
body::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
/* Kill xterm.js internal scrollbar — TUI handles its own scrolling */
.xterm-viewport {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
}
.xterm-viewport::-webkit-scrollbar {
display: none !important;
}
/* Toolbar */
#toolbar {
height: 36px;
background: #292e38;
border-bottom: 1px solid #393e48;
display: flex;
align-items: center;
padding: 0 12px;
gap: 12px;
font-family: system-ui, -apple-system, sans-serif;
color: #e2e8f5;
font-size: 13px;
user-select: none;
}
#toolbar .logo {
font-weight: 600;
color: #00a3d2;
font-family: 'JetBrains Mono', monospace;
letter-spacing: 0.5px;
}
#toolbar .separator {
width: 1px;
height: 20px;
background: #393e48;
}
#toolbar select {
background: #393e48;
color: #e2e8f5;
border: 1px solid #525762;
border-radius: 4px;
padding: 4px 8px;
font-size: 12px;
font-family: system-ui, -apple-system, sans-serif;
cursor: pointer;
outline: none;
}
#toolbar select:hover {
border-color: #00a3d2;
}
#toolbar select:focus {
border-color: #00a3d2;
box-shadow: 0 0 0 1px #00a3d2;
}
#toolbar label {
font-size: 11px;
color: #898e9a;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 6px;
}
.toolbar-spacer { flex: 1; }
#status {
font-size: 12px;
display: flex;
align-items: center;
gap: 6px;
}
#status .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #525762;
transition: background 0.3s;
}
#status .dot.connected { background: #00ab9a; }
#status .dot.reconnecting { background: #d08447; animation: pulse 1s infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* Terminal container */
#terminal-container {
height: calc(100% - 36px);
width: 100%;
position: absolute;
top: 36px;
left: 0;
right: 0;
bottom: 0;
}
/* Reconnect overlay */
#overlay {
display: none;
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: rgba(33, 38, 47, 0.92);
z-index: 100;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 12px;
color: #e2e8f5;
font-family: system-ui, -apple-system, sans-serif;
}
#overlay .message {
font-size: 16px;
font-weight: 500;
}
#overlay .sub {
font-size: 13px;
color: #898e9a;
}
</style>
</head>
<body>
<div id="toolbar">
<span class="logo">Clide</span>
<div class="separator"></div>
<div class="toolbar-group">
<label>Project</label>
<select id="project-select"><option value="">Loading...</option></select>
</div>
<div class="toolbar-spacer"></div>
<div id="status">
<div class="dot" id="status-dot"></div>
<span id="status-text">Connecting...</span>
</div>
</div>
<div id="terminal-container"></div>
<div id="overlay">
<div class="message">Reconnecting...</div>
<div class="sub">Session is preserved</div>
</div>
<script src="/static/vendor/xterm.min.js"></script>
<script src="/static/vendor/addon-fit.min.js"></script>
<script src="/static/vendor/addon-web-links.min.js"></script>
<script>
(function() {
"use strict";
// --- State ---
// Extract project from path: /projects/<name> or fallback to ?project=<name>
function getProjectFromUrl() {
var match = location.pathname.match(/^\/projects\/([^/]+)/);
if (match) return decodeURIComponent(match[1]);
return new URLSearchParams(location.search).get("project") || "";
}
let currentProject = getProjectFromUrl();
let ws = null;
let reconnectTimer = null;
let reconnectDelay = 1000;
// --- DOM refs ---
const statusDot = document.getElementById("status-dot");
const statusText = document.getElementById("status-text");
const overlay = document.getElementById("overlay");
const projectSelect = document.getElementById("project-select");
// --- Terminal ---
const term = new Terminal({
fontFamily: "'JetBrains Mono', monospace",
fontSize: 14,
theme: {
background: "#21262f",
foreground: "#e2e8f5",
cursor: "#00a3d2",
cursorAccent: "#21262f",
selectionBackground: "rgba(0, 163, 210, 0.3)",
black: "#21262f",
red: "#f06c6f",
green: "#00ab9a",
yellow: "#d08447",
blue: "#00a3d2",
magenta: "#fa5f8b",
cyan: "#00a9b9",
white: "#e2e8f5",
brightBlack: "#393e48",
brightRed: "#f06c6f",
brightGreen: "#00ab9a",
brightYellow: "#d08447",
brightBlue: "#00a3d2",
brightMagenta: "#fa5f8b",
brightCyan: "#00a9b9",
brightWhite: "#e2e8f5",
},
cursorBlink: true,
allowProposedApi: true,
scrollback: 0,
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon.WebLinksAddon());
term.open(document.getElementById("terminal-container"));
fitAddon.fit();
// --- Status helpers ---
function setStatus(state, text) {
statusDot.className = "dot " + state;
statusText.textContent = text;
}
// --- WebSocket ---
function connect() {
if (ws) {
ws.onclose = null;
ws.close();
}
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const url = proto + "//" + location.host + "/projects/" + encodeURIComponent(currentProject) + "/ws";
ws = new WebSocket(url);
ws.binaryType = "arraybuffer";
ws.onopen = function() {
setStatus("connected", currentProject || "Connected");
overlay.style.display = "none";
reconnectDelay = 1000;
// Send initial terminal size (slight delay to ensure xterm is rendered)
setTimeout(function() {
fitAddon.fit();
sendResize();
}, 50);
};
ws.onmessage = function(evt) {
let prefix, payload;
if (evt.data instanceof ArrayBuffer) {
const bytes = new Uint8Array(evt.data);
if (bytes.length < 1) return;
prefix = String.fromCharCode(bytes[0]);
payload = bytes.slice(1);
if (prefix === "0") {
term.write(payload);
} else if (prefix === "1") {
handleControl(new TextDecoder().decode(payload));
}
} else {
// Text frame
prefix = evt.data[0];
payload = evt.data.slice(1);
if (prefix === "0") {
term.write(payload);
} else if (prefix === "1") {
handleControl(payload);
}
}
};
ws.onclose = function() {
setStatus("reconnecting", "Reconnecting...");
overlay.style.display = "flex";
reconnectTimer = setTimeout(function() {
reconnectDelay = Math.min(reconnectDelay * 1.5, 10000);
connect();
}, reconnectDelay);
};
ws.onerror = function() {
// onclose will fire after this
};
}
// --- Terminal input → WebSocket ---
term.onData(function(data) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send("0" + data);
}
});
// --- Resize ---
function sendResize() {
if (ws && ws.readyState === WebSocket.OPEN) {
const dims = fitAddon.proposeDimensions();
if (dims) {
ws.send("2" + dims.cols + "," + dims.rows);
}
}
}
const resizeObserver = new ResizeObserver(function() {
fitAddon.fit();
sendResize();
});
resizeObserver.observe(document.getElementById("terminal-container"));
// --- Control messages ---
function handleControl(jsonStr) {
let msg;
try { msg = JSON.parse(jsonStr); } catch(e) { return; }
if (msg.type === "projects") {
populateProjects(msg.projects);
} else if (msg.type === "session_info") {
setStatus("connected", msg.project);
document.title = "Clide — " + msg.project;
} else if (msg.type === "error") {
term.write("\r\n\x1b[31mError: " + msg.message + "\x1b[0m\r\n");
}
}
// --- Project management ---
function populateProjects(projects) {
projectSelect.innerHTML = "";
projects.forEach(function(p) {
const opt = document.createElement("option");
opt.value = p;
opt.textContent = p;
if (p === currentProject) opt.selected = true;
projectSelect.appendChild(opt);
});
}
projectSelect.addEventListener("change", function() {
currentProject = projectSelect.value;
history.pushState(null, "", "/projects/" + encodeURIComponent(currentProject));
document.title = "Clide — " + currentProject;
if (reconnectTimer) clearTimeout(reconnectTimer);
term.clear();
term.reset();
connect();
});
// --- Load projects and connect ---
fetch("/api/projects")
.then(function(r) { return r.json(); })
.then(function(data) {
populateProjects(data.projects);
// If no project selected, pick first available
if (!currentProject && data.projects.length > 0) {
currentProject = data.projects[0];
projectSelect.value = currentProject;
history.replaceState(null, "", "/projects/" + encodeURIComponent(currentProject));
}
if (currentProject) {
connect();
} else {
setStatus("", "No projects found");
term.write("\r\nNo projects found in configured projects directory.\r\n");
}
})
.catch(function() {
setStatus("reconnecting", "Server unreachable");
});
})();
</script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
/**
* Skipped minification because the original files appears to be already minified.
* Original file: /npm/@xterm/addon-fit@0.11.0/lib/addon-fit.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core._renderService.dimensions;if(0===e.css.cell.width||0===e.css.cell.height)return;const t=0===this._terminal.options.scrollback?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),i=parseInt(r.getPropertyValue("height")),o=Math.max(0,parseInt(r.getPropertyValue("width"))),s=window.getComputedStyle(this._terminal.element),n=i-(parseInt(s.getPropertyValue("padding-top"))+parseInt(s.getPropertyValue("padding-bottom"))),l=o-(parseInt(s.getPropertyValue("padding-right"))+parseInt(s.getPropertyValue("padding-left")))-t;return{cols:Math.max(2,Math.floor(l/e.css.cell.width)),rows:Math.max(1,Math.floor(n/e.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=addon-fit.js.map
@@ -0,0 +1,8 @@
/**
* Skipped minification because the original files appears to be already minified.
* Original file: /npm/@xterm/addon-web-links@0.12.0/lib/addon-web-links.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.WebLinksAddon=t():e.WebLinksAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={490:(e,t)=>{function n(e){try{const t=new URL(e),n=t.password&&t.username?`${t.protocol}//${t.username}:${t.password}@${t.host}`:t.username?`${t.protocol}//${t.username}@${t.host}`:`${t.protocol}//${t.host}`;return e.toLocaleLowerCase().startsWith(n.toLocaleLowerCase())}catch(e){return!1}}Object.defineProperty(t,"__esModule",{value:!0}),t.LinkComputer=t.WebLinkProvider=void 0,t.WebLinkProvider=class{constructor(e,t,n,o={}){this._terminal=e,this._regex=t,this._handler=n,this._options=o}provideLinks(e,t){const n=o.computeLink(e,this._regex,this._terminal,this._handler);t(this._addCallbacks(n))}_addCallbacks(e){return e.map((e=>(e.leave=this._options.leave,e.hover=(t,n)=>{if(this._options.hover){const{range:o}=e;this._options.hover(t,n,o)}},e)))}};class o{static computeLink(e,t,r,i){const s=new RegExp(t.source,(t.flags||"")+"g"),[a,c]=o._getWindowedLineStrings(e-1,r),l=a.join("");let d;const p=[];for(;d=s.exec(l);){const e=d[0];if(!n(e))continue;const[t,s]=o._mapStrIdx(r,c,0,d.index),[a,l]=o._mapStrIdx(r,t,s,e.length);if(-1===t||-1===s||-1===a||-1===l)continue;const h={start:{x:s+1,y:t+1},end:{x:l,y:a+1}};p.push({range:h,text:e,activate:i})}return p}static _getWindowedLineStrings(e,t){let n,o=e,r=e,i=0,s="";const a=[];if(n=t.buffer.active.getLine(e)){const e=n.translateToString(!0);if(n.isWrapped&&" "!==e[0]){for(i=0;(n=t.buffer.active.getLine(--o))&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),n.isWrapped&&-1===s.indexOf(" ")););a.reverse()}for(a.push(e),i=0;(n=t.buffer.active.getLine(++r))&&n.isWrapped&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),-1===s.indexOf(" ")););}return[a,o]}static _mapStrIdx(e,t,n,o){const r=e.buffer.active,i=r.getNullCell();let s=n;for(;o;){const e=r.getLine(t);if(!e)return[-1,-1];for(let n=s;n<e.length;++n){e.getCell(n,i);const s=i.getChars();if(i.getWidth()&&(o-=s.length||1,n===e.length-1&&""===s)){const e=r.getLine(t+1);e&&e.isWrapped&&(e.getCell(0,i),2===i.getWidth()&&(o+=1))}if(o<0)return[t,n]}t++,s=0}return[t,s]}}t.LinkComputer=o}},t={};function n(o){var r=t[o];if(void 0!==r)return r.exports;var i=t[o]={exports:{}};return e[o](i,i.exports,n),i.exports}var o={};return(()=>{var e=o;Object.defineProperty(e,"__esModule",{value:!0}),e.WebLinksAddon=void 0;const t=n(490),r=/(https?|HTTPS?):[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;function i(e,t){const n=window.open();if(n){try{n.opener=null}catch{}n.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}e.WebLinksAddon=class{constructor(e=i,t={}){this._handler=e,this._options=t}activate(e){this._terminal=e;const n=this._options,o=n.urlRegex||r;this._linkProvider=this._terminal.registerLinkProvider(new t.WebLinkProvider(this._terminal,o,this._handler,n))}dispose(){this._linkProvider?.dispose()}}})(),o})()));
//# sourceMappingURL=addon-web-links.js.map
+8
View File
@@ -0,0 +1,8 @@
/**
* Minified by jsDelivr using clean-css v5.3.3.
* Original file: /npm/@xterm/xterm@5.5.0/css/xterm.css
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
.xterm{cursor:text;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:0}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}.xterm.enable-mouse-events{cursor:default}.xterm .xterm-cursor-pointer,.xterm.xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent;pointer-events:none}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:transparent}.xterm .xterm-accessibility-tree{user-select:text;white-space:pre}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{text-decoration:double underline}.xterm-underline-3{text-decoration:wavy underline}.xterm-underline-4{text-decoration:dotted underline}.xterm-underline-5{text-decoration:dashed underline}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:overline underline}.xterm-overline.xterm-underline-2{text-decoration:overline double underline}.xterm-overline.xterm-underline-3{text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;position:absolute;top:0;right:0;pointer-events:none}.xterm-decoration-top{z-index:2;position:relative}
/*# sourceMappingURL=/sm/97377c0c258e109358121823f5790146c714989366481f90e554c42277efb500.map */
File diff suppressed because one or more lines are too long
+66
View File
@@ -0,0 +1,66 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "clide-web"
version = "1.0.0"
description = "Web server for Clide TUI — replaces ttyd + zellij"
requires-python = ">=3.12"
license = "MIT"
authors = [
{ name = "Jeroen Schweitzer", email = "you@example.com" }
]
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: Web Environment",
"Framework :: FastAPI",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
dependencies = [
"clide",
"fastapi>=0.110",
"uvicorn[standard]>=0.29",
"pydantic-settings>=2.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"httpx>=0.27.0",
"ruff>=0.3.0",
"mypy>=1.8.0",
]
[project.scripts]
clide-web = "clide_web.__main__:main"
clide-web-setup = "clide_web.setup_wizard:run_setup"
[tool.hatch.build.targets.wheel]
packages = ["clide_web"]
[tool.ruff]
target-version = "py312"
line-length = 100
src = ["clide_web"]
[tool.ruff.lint]
select = [
"E", "W", "F", "I", "B", "C4", "UP", "ARG", "SIM", "TCH", "PTH", "ASYNC",
]
ignore = [
"E501", "ARG002", "SIM102", "SIM105", "SIM115", "PTH123", "TCH002", "TCH003",
]
[tool.ruff.lint.isort]
known-first-party = ["clide_web"]
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_configs = true