#!/usr/bin/env python3 """ Lightweight web server for Hermes Kanban boards. Reads directly from the SQLite DB — no Host Header issues, no auth required. Serves on the Tailscale IP so the CEO can view the board from any device. Usage: python3 scripts/kanban-web-server.py [--board ] [--port PORT] The server renders a Kanban-column HTML page with columns: Triage → Todo → Ready → In progress → Blocked → Done plus a JSON API at /api/tasks for programmatic access. """ import sqlite3, json, http.server, socketserver, urllib.parse, argparse, os KANBAN_ROOT = os.path.expanduser("~/.hermes/kanban/boards") HERMES_GLOBAL_DB = os.path.expanduser("~/.hermes/kanban.db") BOARD_COLUMNS = ["triage", "todo", "ready", "running", "blocked", "done"] COLUMN_LABELS = { "triage": "Triage", "todo": "To do", "ready": "Ready", "running": "In progress", "blocked": "Blocked", "done": "Done", } class KanbanHandler(http.server.BaseHTTPRequestHandler): board_path = None def do_GET(self): parsed = urllib.parse.urlparse(self.path) if parsed.path == "/api/tasks": self._send_json(self._get_tasks()) return if parsed.path == "/api/board": self._send_json({"slug": self._board_slug(), "columns": BOARD_COLUMNS}) return self._send_html(self._render_page()) def _board_slug(self): return os.path.basename(self.board_path.rstrip("/")) if self.board_path else "default" def _db_path(self): if self.board_path: p = os.path.join(self.board_path, "kanban.db") if os.path.exists(p): return p # Fallback to global return HERMES_GLOBAL_DB def _get_tasks(self): db = self._db_path() if not os.path.exists(db): return [] conn = sqlite3.connect(db) try: rows = conn.execute( "SELECT id, title, body, status, assignee, priority, created_at, completed_at " "FROM tasks ORDER BY priority, created_at" ).fetchall() finally: conn.close() return [dict(zip( ["id", "title", "body", "status", "assignee", "priority", "created_at", "completed_at"], r )) for r in rows] def _render_page(self): tasks = self._get_tasks() board_slug = self._board_slug() columns_json = json.dumps(BOARD_COLUMNS) labels_json = json.dumps(COLUMN_LABELS, ensure_ascii=False) tasks_json = json.dumps(tasks, ensure_ascii=False) return f""" Kanban — {board_slug}

📋 {board_slug}

Hermes Kanban board — 0 tasks

""" def _send_html(self, html): self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() self.wfile.write(html.encode()) def _send_json(self, data): self.send_response(200) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() self.wfile.write(json.dumps(data, ensure_ascii=False).encode()) def log_message(self, fmt, *args): pass # quiet logging def main(): parser = argparse.ArgumentParser(description="Hermes Kanban Web Server") parser.add_argument("--board", "-b", help="Board slug (default: all boards shown)") parser.add_argument("--port", "-p", type=int, default=9120, help="Listening port") args = parser.parse_args() if args.board: path = os.path.join(KANBAN_ROOT, args.board) if not os.path.isdir(path): print(f"Board not found: {args.board}") exit(1) KanbanHandler.board_path = path print(f"Board: {args.board}") else: # Serve from the global kanban.db if no board specified KanbanHandler.board_path = None print("Board: default (global)") server = socketserver.TCPServer(("0.0.0.0", args.port), KanbanHandler) print(f"Server at http://0.0.0.0:{args.port}") try: server.serve_forever() except KeyboardInterrupt: server.shutdown() if __name__ == "__main__": main()