feat: package scaffold — pyproject.toml, src/workbench/ layout
This commit is contained in:
@@ -0,0 +1,142 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{TITLE}}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0a0f0c;
|
||||||
|
--panel-bg: #111a15;
|
||||||
|
--border: #2a3a2e;
|
||||||
|
--text: #aaccaa;
|
||||||
|
--text-dim: #557755;
|
||||||
|
--accent: #33ff66;
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { background: var(--bg); color: var(--text); font-family: monospace; font-size: 14px; height: 100vh; overflow: hidden; }
|
||||||
|
|
||||||
|
.layout { display: flex; height: 100vh; }
|
||||||
|
.diag-panel { flex: 1; overflow-y: auto; padding: 16px; }
|
||||||
|
.divider { width: 4px; background: var(--border); cursor: col-resize; }
|
||||||
|
.term-panel { width: 40%; min-width: 300px; }
|
||||||
|
.term-panel iframe { width: 100%; height: 100%; border: none; }
|
||||||
|
|
||||||
|
/* Mobile: stack vertically with tabs */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.layout { flex-direction: column; }
|
||||||
|
.divider { display: none; }
|
||||||
|
.term-panel { width: 100%; height: 50vh; }
|
||||||
|
.diag-panel { height: 50vh; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Log feed at bottom of diag panel */
|
||||||
|
#log-feed { margin-top: 24px; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||||
|
#log-feed h3 { color: var(--text-dim); font-size: 11px; letter-spacing: 2px; text-transform: uppercase; margin-bottom: 8px; }
|
||||||
|
.log-entry { font-size: 12px; color: var(--text-dim); padding: 4px 0; border-bottom: 1px solid #1a2a1e; }
|
||||||
|
.log-entry .log-time { color: var(--accent); margin-right: 8px; }
|
||||||
|
|
||||||
|
/* Status bar */
|
||||||
|
#status { position: fixed; bottom: 0; left: 0; right: 0; background: var(--panel-bg); border-top: 1px solid var(--border); padding: 4px 12px; font-size: 11px; color: var(--text-dim); z-index: 100; }
|
||||||
|
#status.connected { color: var(--accent); }
|
||||||
|
|
||||||
|
/* Content area where AI pushes diagnostic UI */
|
||||||
|
#content { min-height: 200px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="layout">
|
||||||
|
<div class="diag-panel">
|
||||||
|
<div id="content">
|
||||||
|
<h1 style="color: var(--accent); font-size: 18px;">{{TITLE}}</h1>
|
||||||
|
<p style="color: var(--text-dim); margin-top: 8px;">{{DESCRIPTION}}</p>
|
||||||
|
<p style="color: var(--text-dim); margin-top: 16px;">Waiting for diagnostic content...</p>
|
||||||
|
</div>
|
||||||
|
<div id="log-feed">
|
||||||
|
<h3>Session Log</h3>
|
||||||
|
<div id="log-entries"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="divider" id="divider"></div>
|
||||||
|
<div class="term-panel">
|
||||||
|
<iframe src="{{SETHMUX_URL}}" allow="clipboard-read; clipboard-write"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="status">Connecting...</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const WS_URL = `ws://${location.host}/ws`;
|
||||||
|
let ws, reconnectTimer;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
ws = new WebSocket(WS_URL);
|
||||||
|
ws.onopen = () => {
|
||||||
|
document.getElementById('status').textContent = 'Connected';
|
||||||
|
document.getElementById('status').className = 'connected';
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
document.getElementById('status').textContent = 'Disconnected — reconnecting...';
|
||||||
|
document.getElementById('status').className = '';
|
||||||
|
reconnectTimer = setTimeout(connect, 2000);
|
||||||
|
};
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
if (msg.type === 'state') handleState(msg.state);
|
||||||
|
if (msg.type === 'log') handleLog(msg.entry);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleState(state) {
|
||||||
|
// If state has a 'template' field, replace the content area HTML
|
||||||
|
if (state.template) {
|
||||||
|
document.getElementById('content').innerHTML = state.template;
|
||||||
|
}
|
||||||
|
// If state has a 'styles' field, inject/replace a style block
|
||||||
|
if (state.styles) {
|
||||||
|
let el = document.getElementById('dynamic-styles');
|
||||||
|
if (!el) { el = document.createElement('style'); el.id = 'dynamic-styles'; document.head.appendChild(el); }
|
||||||
|
el.textContent = state.styles;
|
||||||
|
}
|
||||||
|
// If state has a 'script' field, execute it
|
||||||
|
if (state.script) {
|
||||||
|
try { new Function(state.script)(); } catch(e) { console.error('Script error:', e); }
|
||||||
|
}
|
||||||
|
// Store full state for AI-generated scripts to access
|
||||||
|
window.__workbench_state = state;
|
||||||
|
// Save to localStorage as fallback
|
||||||
|
try { localStorage.setItem('workbench-state', JSON.stringify(state)); } catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLog(entry) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'log-entry';
|
||||||
|
const time = new Date().toLocaleTimeString();
|
||||||
|
div.innerHTML = `<span class="log-time">${time}</span>${entry}`;
|
||||||
|
const feed = document.getElementById('log-entries');
|
||||||
|
feed.appendChild(div);
|
||||||
|
feed.scrollTop = feed.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore state from localStorage on load
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem('workbench-state'));
|
||||||
|
if (saved) handleState(saved);
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
// Draggable divider
|
||||||
|
const divider = document.getElementById('divider');
|
||||||
|
let dragging = false;
|
||||||
|
divider.addEventListener('mousedown', () => dragging = true);
|
||||||
|
document.addEventListener('mousemove', (e) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
const pct = (e.clientX / window.innerWidth) * 100;
|
||||||
|
document.querySelector('.diag-panel').style.flex = 'none';
|
||||||
|
document.querySelector('.diag-panel').style.width = pct + '%';
|
||||||
|
document.querySelector('.term-panel').style.width = (100 - pct) + '%';
|
||||||
|
});
|
||||||
|
document.addEventListener('mouseup', () => dragging = false);
|
||||||
|
|
||||||
|
connect();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Workbench MCP Server — AI-driven hardware diagnostic tool.
|
||||||
|
|
||||||
|
Exposes 6 MCP tools for scaffolding, state management, and logging
|
||||||
|
of interactive hardware diagnostic web pages served over LAN.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
WORKBENCH_DIR = Path.home() / "workbench"
|
||||||
|
SCAFFOLD_HTML = Path(__file__).parent / "scaffold.html"
|
||||||
|
DEFAULT_PORT = 8070
|
||||||
|
SETHMUX_URL = os.environ.get("WORKBENCH_SETHMUX_URL", "https://mux.sethpc.xyz")
|
||||||
|
|
||||||
|
# Track active projects: {name: {"port": int, "runner": web.AppRunner, "ws_clients": set}}
|
||||||
|
active_projects: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_lan_ip() -> str:
|
||||||
|
"""Get the machine's LAN IP address."""
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.connect(("192.168.0.1", 80))
|
||||||
|
ip = s.getsockname()[0]
|
||||||
|
s.close()
|
||||||
|
return ip
|
||||||
|
except Exception:
|
||||||
|
return "127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
def find_free_port(start: int = DEFAULT_PORT) -> int:
|
||||||
|
"""Find a free port starting from the given port."""
|
||||||
|
for port in range(start, start + 100):
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
s.bind(("0.0.0.0", port))
|
||||||
|
s.close()
|
||||||
|
return port
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
raise RuntimeError(f"No free port found in range {start}-{start+99}")
|
||||||
|
|
||||||
|
|
||||||
|
def now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def project_path(name: str) -> Path:
|
||||||
|
return WORKBENCH_DIR / name
|
||||||
|
|
||||||
|
|
||||||
|
# --- HTTP/WebSocket server per project ---
|
||||||
|
|
||||||
|
async def ws_handler(request):
|
||||||
|
"""WebSocket endpoint for pushing state/log to browser."""
|
||||||
|
ws = web.WebSocketResponse()
|
||||||
|
await ws.prepare(request)
|
||||||
|
project_name = request.app["project_name"]
|
||||||
|
if project_name in active_projects:
|
||||||
|
active_projects[project_name]["ws_clients"].add(ws)
|
||||||
|
try:
|
||||||
|
async for msg in ws:
|
||||||
|
pass # Browser doesn't send us anything we need
|
||||||
|
finally:
|
||||||
|
if project_name in active_projects:
|
||||||
|
active_projects[project_name]["ws_clients"].discard(ws)
|
||||||
|
return ws
|
||||||
|
|
||||||
|
|
||||||
|
async def static_handler(request):
|
||||||
|
"""Serve files from the project directory."""
|
||||||
|
project_name = request.app["project_name"]
|
||||||
|
path = request.match_info.get("path", "index.html") or "index.html"
|
||||||
|
file_path = project_path(project_name) / path
|
||||||
|
if not file_path.exists():
|
||||||
|
return web.Response(status=404, text="Not found")
|
||||||
|
return web.FileResponse(file_path)
|
||||||
|
|
||||||
|
|
||||||
|
async def start_http_server(name: str, port: int) -> web.AppRunner:
|
||||||
|
"""Start an HTTP + WebSocket server for a project."""
|
||||||
|
app = web.Application()
|
||||||
|
app["project_name"] = name
|
||||||
|
app.router.add_get("/ws", ws_handler)
|
||||||
|
app.router.add_get("/{path:.*}", static_handler)
|
||||||
|
app.router.add_get("/", static_handler)
|
||||||
|
runner = web.AppRunner(app)
|
||||||
|
await runner.setup()
|
||||||
|
site = web.TCPSite(runner, "0.0.0.0", port)
|
||||||
|
await site.start()
|
||||||
|
return runner
|
||||||
|
|
||||||
|
|
||||||
|
async def broadcast_ws(name: str, message: dict):
|
||||||
|
"""Send a message to all WebSocket clients of a project."""
|
||||||
|
if name not in active_projects:
|
||||||
|
return
|
||||||
|
clients = active_projects[name]["ws_clients"]
|
||||||
|
dead = set()
|
||||||
|
for ws in clients:
|
||||||
|
try:
|
||||||
|
await ws.send_json(message)
|
||||||
|
except Exception:
|
||||||
|
dead.add(ws)
|
||||||
|
clients -= dead
|
||||||
|
|
||||||
|
|
||||||
|
# --- MCP Tools ---
|
||||||
|
|
||||||
|
mcp = FastMCP("workbench", instructions="Hardware diagnostic workbench — serve interactive diagnostic pages over LAN")
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def workbench_scaffold(name: str, title: str, description: str = "") -> str:
|
||||||
|
"""Create a new workbench project with split-pane diagnostic page + sethmux terminal.
|
||||||
|
|
||||||
|
Returns the LAN URL to open on your phone.
|
||||||
|
"""
|
||||||
|
pdir = project_path(name)
|
||||||
|
if pdir.exists():
|
||||||
|
# Already exists — just start the server if not running
|
||||||
|
if name not in active_projects:
|
||||||
|
port = find_free_port()
|
||||||
|
runner = await start_http_server(name, port)
|
||||||
|
active_projects[name] = {"port": port, "runner": runner, "ws_clients": set()}
|
||||||
|
ip = get_lan_ip()
|
||||||
|
port = active_projects[name]["port"]
|
||||||
|
return json.dumps({"path": str(pdir), "url": f"http://{ip}:{port}"})
|
||||||
|
|
||||||
|
pdir.mkdir(parents=True)
|
||||||
|
(pdir / "assets").mkdir()
|
||||||
|
|
||||||
|
# Copy and fill scaffold
|
||||||
|
template = SCAFFOLD_HTML.read_text()
|
||||||
|
html = template.replace("{{TITLE}}", title)
|
||||||
|
html = html.replace("{{DESCRIPTION}}", description)
|
||||||
|
html = html.replace("{{SETHMUX_URL}}", SETHMUX_URL)
|
||||||
|
(pdir / "index.html").write_text(html)
|
||||||
|
|
||||||
|
# Init log files
|
||||||
|
(pdir / "session.md").write_text(f"# {title} — Diagnostic Session\n\nStarted: {now_iso()}\n\n")
|
||||||
|
(pdir / "session.jsonl").write_text("")
|
||||||
|
(pdir / "cost-log.jsonl").write_text(
|
||||||
|
json.dumps({"ts": now_iso(), "event": "session_start", "project": name}) + "\n"
|
||||||
|
)
|
||||||
|
(pdir / "state.json").write_text("{}")
|
||||||
|
|
||||||
|
# Start server
|
||||||
|
port = find_free_port()
|
||||||
|
runner = await start_http_server(name, port)
|
||||||
|
active_projects[name] = {"port": port, "runner": runner, "ws_clients": set()}
|
||||||
|
|
||||||
|
ip = get_lan_ip()
|
||||||
|
return json.dumps({"path": str(pdir), "url": f"http://{ip}:{port}"})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def workbench_state(project: str, state: str) -> str:
|
||||||
|
"""Push state update to the browser via WebSocket.
|
||||||
|
|
||||||
|
The state is arbitrary JSON — the AI decides the schema.
|
||||||
|
Include a 'template' field (HTML string) to replace the diagnostic content area.
|
||||||
|
Include 'styles' for CSS and 'script' for JS to execute.
|
||||||
|
"""
|
||||||
|
state_obj = json.loads(state)
|
||||||
|
|
||||||
|
# Save to disk
|
||||||
|
pdir = project_path(project)
|
||||||
|
if not pdir.exists():
|
||||||
|
return json.dumps({"error": f"Project '{project}' not found. Run workbench_scaffold first."})
|
||||||
|
|
||||||
|
(pdir / "state.json").write_text(json.dumps(state_obj, indent=2))
|
||||||
|
|
||||||
|
# Push to browser
|
||||||
|
await broadcast_ws(project, {"type": "state", "state": state_obj})
|
||||||
|
|
||||||
|
return json.dumps({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def workbench_log(project: str, entry: str, data: str = "{}") -> str:
|
||||||
|
"""Append a log entry to the session log. Shows in the browser log feed.
|
||||||
|
|
||||||
|
entry: Human-readable markdown string (e.g., "R412 measured 1.05M — drifted +16.7%, FAIL")
|
||||||
|
data: Optional JSON for the machine-readable log
|
||||||
|
"""
|
||||||
|
pdir = project_path(project)
|
||||||
|
if not pdir.exists():
|
||||||
|
return json.dumps({"error": f"Project '{project}' not found."})
|
||||||
|
|
||||||
|
ts = now_iso()
|
||||||
|
|
||||||
|
# Append to session.md
|
||||||
|
with open(pdir / "session.md", "a") as f:
|
||||||
|
f.write(f"\n### {ts}\n{entry}\n")
|
||||||
|
|
||||||
|
# Append to session.jsonl
|
||||||
|
data_obj = json.loads(data) if data else {}
|
||||||
|
data_obj["ts"] = ts
|
||||||
|
data_obj["entry"] = entry
|
||||||
|
with open(pdir / "session.jsonl", "a") as f:
|
||||||
|
f.write(json.dumps(data_obj) + "\n")
|
||||||
|
|
||||||
|
# Push to browser
|
||||||
|
await broadcast_ws(project, {"type": "log", "entry": entry})
|
||||||
|
|
||||||
|
return json.dumps({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def workbench_read_log(project: str, tail: int = 20) -> str:
|
||||||
|
"""Read recent session log entries so AI can resume a session."""
|
||||||
|
pdir = project_path(project)
|
||||||
|
if not pdir.exists():
|
||||||
|
return json.dumps({"error": f"Project '{project}' not found."})
|
||||||
|
|
||||||
|
jsonl_path = pdir / "session.jsonl"
|
||||||
|
if not jsonl_path.exists():
|
||||||
|
return json.dumps({"entries": []})
|
||||||
|
|
||||||
|
lines = jsonl_path.read_text().strip().split("\n")
|
||||||
|
recent = lines[-tail:] if len(lines) > tail else lines
|
||||||
|
entries = []
|
||||||
|
for line in recent:
|
||||||
|
if line.strip():
|
||||||
|
try:
|
||||||
|
entries.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
entries.append({"raw": line})
|
||||||
|
|
||||||
|
return json.dumps({"entries": entries})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def workbench_list() -> str:
|
||||||
|
"""List all workbench projects and their status."""
|
||||||
|
if not WORKBENCH_DIR.exists():
|
||||||
|
return json.dumps({"projects": []})
|
||||||
|
|
||||||
|
projects = []
|
||||||
|
for d in sorted(WORKBENCH_DIR.iterdir()):
|
||||||
|
if d.is_dir():
|
||||||
|
info = {"name": d.name, "active": d.name in active_projects}
|
||||||
|
if d.name in active_projects:
|
||||||
|
ip = get_lan_ip()
|
||||||
|
port = active_projects[d.name]["port"]
|
||||||
|
info["url"] = f"http://{ip}:{port}"
|
||||||
|
info["ws_clients"] = len(active_projects[d.name]["ws_clients"])
|
||||||
|
projects.append(info)
|
||||||
|
|
||||||
|
return json.dumps({"projects": projects})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def workbench_stop(project: str) -> str:
|
||||||
|
"""Stop the HTTP/WebSocket server for a project and log session end."""
|
||||||
|
if project not in active_projects:
|
||||||
|
return json.dumps({"error": f"Project '{project}' is not running."})
|
||||||
|
|
||||||
|
# Log session end to cost-log
|
||||||
|
pdir = project_path(project)
|
||||||
|
jsonl_path = pdir / "session.jsonl"
|
||||||
|
entry_count = 0
|
||||||
|
if jsonl_path.exists():
|
||||||
|
entry_count = sum(1 for line in jsonl_path.read_text().strip().split("\n") if line.strip())
|
||||||
|
|
||||||
|
cost_entry = {
|
||||||
|
"ts": now_iso(),
|
||||||
|
"event": "session_end",
|
||||||
|
"project": project,
|
||||||
|
"log_entries": entry_count,
|
||||||
|
}
|
||||||
|
with open(pdir / "cost-log.jsonl", "a") as f:
|
||||||
|
f.write(json.dumps(cost_entry) + "\n")
|
||||||
|
|
||||||
|
# Shutdown server
|
||||||
|
runner = active_projects[project]["runner"]
|
||||||
|
await runner.cleanup()
|
||||||
|
del active_projects[project]
|
||||||
|
|
||||||
|
return json.dumps({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mcp.run(transport="stdio")
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68.0"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "workbench-server"
|
||||||
|
version = "0.2.0"
|
||||||
|
description = "MCP server that lets AI CLIs build interactive web pages served over LAN"
|
||||||
|
readme = "README.md"
|
||||||
|
license = "MIT"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"mcp>=1.26.0",
|
||||||
|
"aiohttp>=3.9.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
workbench = "workbench.cli:main"
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
mcp>=1.26.0
|
|
||||||
aiohttp>=3.9.0
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Workbench — MCP server for AI-driven interactive web pages."""
|
||||||
|
|
||||||
|
__version__ = "0.2.0"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Allow running as `python -m workbench`."""
|
||||||
|
|
||||||
|
from workbench.cli import main
|
||||||
|
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user