open-edison 0.1.44__py3-none-any.whl → 0.1.72rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-edison
3
- Version: 0.1.44
3
+ Version: 0.1.72rc1
4
4
  Summary: Open-source MCP security, aggregation, and monitoring. Single-user, self-hosted MCP proxy.
5
5
  Author-email: Hugo Berg <hugo@edison.watch>
6
6
  License-File: LICENSE
@@ -72,13 +72,7 @@ curl -fsSL https://raw.githubusercontent.com/Edison-Watch/open-edison/main/curl_
72
72
  ```
73
73
 
74
74
  Run locally with uvx: `uvx open-edison`
75
-
76
- Optionally, run the setup wizard to import/configure MCP:
77
-
78
- ```bash
79
- uv run python -m src.setup_tui.main
80
- # add --dry-run to preview without writing
81
- ```
75
+ That will run the setup wizard if necessary.
82
76
 
83
77
  <details>
84
78
  <summary>⬇️ Install Node.js/npm (optional for MCP tools)</summary>
@@ -129,19 +123,6 @@ OPEN_EDISON_CONFIG_DIR=~/edison-config open-edison run
129
123
 
130
124
  </details>
131
125
 
132
- <details>
133
- <summary>🔄 Import from Cursor/VS Code/Claude Code</summary>
134
-
135
- Run the interactive setup wizard to detect clients, import servers, and configure your editor:
136
-
137
- ```bash
138
- uv run python -m src.setup_tui.main
139
- ```
140
-
141
- Use `--dry-run` to preview without writing.
142
-
143
- </details>
144
-
145
126
  <details>
146
127
  <summary><img src="https://img.shields.io/badge/Docker-2CA5E0?style=for-the-badge&logo=docker&logoColor=white" alt="Docker"> Run with Docker</summary>
147
128
 
@@ -0,0 +1,41 @@
1
+ src/__init__.py,sha256=bEYMwBiuW9jzF07iWhas4Vb30EcpnqfpNfz_Q6yO1jU,209
2
+ src/__main__.py,sha256=kQsaVyzRa_ESC57JpKDSQJAHExuXme0rM5beJsYxFeA,161
3
+ src/cli.py,sha256=kr93TljS2y8E0N-L0Cga0udJpa1POo4bjmJbtg1WJ-s,4737
4
+ src/config.py,sha256=cFbdY55w2JimV6kLv3XDnxDORz533R-GkW0EmKfNqCc,11325
5
+ src/config.pyi,sha256=FgehEGli8ZXSjGlANBgMGv5497q4XskQciOc1fUcxqM,2033
6
+ src/events.py,sha256=KkhrQ9CE5-WBBCeDkUgdCZURKsXakNP3Kj3gP91NYQM,5046
7
+ src/mcp_stdio_capture.py,sha256=SpMnUqQYm207WpiEwBahUxq4JFG9bnmNkUQVotZX7cc,5458
8
+ src/oauth_manager.py,sha256=qw87VxfRLfvd3YI1EhMmEJJ51N_WJsNpo17GUCvi13c,9971
9
+ src/oauth_override.py,sha256=C7QS8sPA6JqJDiNZA0FGeXcB7jU-yYu-k8V56QVpsqU,393
10
+ src/permissions.py,sha256=G_rCkVRC6yvNEoTe7g6gHXmmnwcmpS6vFScm7bI8oeY,14632
11
+ src/server.py,sha256=aKXEZsw6DXL4mm8LPASPYXehkmjojXqILtb2apaAu8Q,47419
12
+ src/single_user_mcp.py,sha256=ML-rBl1Nr2okzmLl2fLJYEvkphr-71n6eVtXpilmqio,24909
13
+ src/telemetry.py,sha256=-RZPIjpI53zbsKmp-63REeZ1JirWHV5WvpSRa2nqZEk,11321
14
+ src/vulture_whitelist.py,sha256=CjBOSsarbzbQt_9ATWc8MbruBsYX3hJVa_ysbRD9ZYM,135
15
+ src/frontend_dist/index.html,sha256=eCG8DLoWNQc5GImBg0Z9dT8HZUMPhAw-4Wc6dEqgKCU,673
16
+ src/frontend_dist/sw.js,sha256=YXwdeSQVg0BaSyhS2REI2DorNEO1kGUn_YCFw14D8VI,3321
17
+ src/frontend_dist/assets/index-D05VN_1l.css,sha256=TVt1HETNMBbfMcrUfYuEE0a-tfpxCsATihxmA8Jp890,17953
18
+ src/frontend_dist/assets/index-D6ziuTsl.js,sha256=lwKVKobmch7-DzowsVXL3VazVV0nr7-D8gmAsoJ-Hj8,264213
19
+ src/mcp_importer/__init__.py,sha256=Mk59pVr7OMGfYGWeSYk8-URfhIcrs3SPLYS7fmJbMII,275
20
+ src/mcp_importer/__main__.py,sha256=mFcxXFqJMC0SFEqIP-9WVEqLJSYqShC0x1Ht7PQZPm8,479
21
+ src/mcp_importer/api.py,sha256=N5oVaTj3OMIROLx__UOSr60VMqXXX20JsOHmeHIGP48,17431
22
+ src/mcp_importer/cli.py,sha256=Pe0GLWm1nMd1VuNXOSkxIrFZuGNFc9dNvfBsvf-bdBI,3487
23
+ src/mcp_importer/export_cli.py,sha256=Fw0jDQCI8gGW4BDrJLzWjLUtV4q6v0h2QZ7HF1V2Jcg,6279
24
+ src/mcp_importer/exporters.py,sha256=NsXBa1FvwPmIak0AJUrfbCwhnBIdGc5oXjTtMblFRwk,11345
25
+ src/mcp_importer/import_api.py,sha256=wD5yqxWwFfn1MQNKE79rEeyZODdmPgUDhsRYdCJYh4Q,59
26
+ src/mcp_importer/importers.py,sha256=zGN8lT7qQJ95jDTd-ck09j_w5PSvH-uj33TILoHfHbs,2191
27
+ src/mcp_importer/merge.py,sha256=KIGT7UgbAm07-LdyoUXEJ7ABSIiPTFlj_qjz669yFxg,1569
28
+ src/mcp_importer/parsers.py,sha256=MDhzODsvX5t1U_CI8byBxCpx6rA4WkpqX4bJMiNE74s,6298
29
+ src/mcp_importer/paths.py,sha256=4L-cPr7KCM9X9gAUP7Da6ictLNrPWuQ_IM419zqY-2I,2700
30
+ src/mcp_importer/quick_cli.py,sha256=Vv2vjNzpSOaic0YHFbPAuX0nZByawS2kDw6KiCtEX3A,1798
31
+ src/mcp_importer/types.py,sha256=nSaOLGqpCmA3R14QCO6wrpgX75VaLz9HfslUWzw_GPQ,102
32
+ src/middleware/data_access_tracker.py,sha256=ZW-E44U_iOE4jRArRmnQ1HK_zRKs_WZvz4Aa49wXaZk,17105
33
+ src/middleware/session_tracking.py,sha256=_igPVEH_l2hQ5onLb5cdn7MOXNtSxr9USEdJklhM_OA,26984
34
+ src/setup_tui/__init__.py,sha256=mDFrQoiOtQOHc0sFfGKrNXVLEDeB1S0O5aISBVzfxYo,184
35
+ src/setup_tui/main.py,sha256=9-m5p3HUnENGbfzd7Mk6LI5NjWtgMeXCWtFi65EBsVc,11257
36
+ src/tools/io.py,sha256=hhc4pv3eUzYWSZ7BbThclxSMwWBQaGMoGsItIPf_pco,1047
37
+ open_edison-0.1.72rc1.dist-info/METADATA,sha256=y9zSbh-9YyBsUonrlHIi3Vf0OFU-sTId55qENBR4Qt0,11996
38
+ open_edison-0.1.72rc1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
39
+ open_edison-0.1.72rc1.dist-info/entry_points.txt,sha256=YiGNm9x2I00hgT10HDyB4gxC1LcaV_mu8bXFjolu0Yw,171
40
+ open_edison-0.1.72rc1.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
41
+ open_edison-0.1.72rc1.dist-info/RECORD,,
src/cli.py CHANGED
@@ -7,8 +7,6 @@ Provides `open-edison` executable when installed via pip/uvx/pipx.
7
7
  import argparse
8
8
  import asyncio
9
9
  import os
10
- import subprocess as _subprocess
11
- from contextlib import suppress
12
10
  from pathlib import Path
13
11
  from typing import Any, NoReturn
14
12
 
@@ -17,6 +15,7 @@ from loguru import logger as _log # type: ignore[reportMissingImports]
17
15
  from src.config import Config, get_config_dir, get_config_json_path
18
16
  from src.mcp_importer.cli import run_cli
19
17
  from src.server import OpenEdisonProxy
18
+ from src.setup_tui.main import run_import_tui
20
19
 
21
20
  log: Any = _log
22
21
 
@@ -37,6 +36,22 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
37
36
  parser.add_argument(
38
37
  "--port", type=int, help="Server port override (FastMCP on port, FastAPI on port+1)"
39
38
  )
39
+ # For the setup wizard
40
+ parser.add_argument(
41
+ "--wizard-dry-run",
42
+ action="store_true",
43
+ help="(For the setup wizard) Show changes without writing to config.json",
44
+ )
45
+ parser.add_argument(
46
+ "--wizard-skip-oauth",
47
+ action="store_true",
48
+ help="(For the setup wizard) Skip OAuth for remote servers (they will be omitted from import)",
49
+ )
50
+ parser.add_argument(
51
+ "--wizard-force",
52
+ action="store_true",
53
+ help="(For the setup wizard) Force running the setup wizard even if it has already been run",
54
+ )
40
55
  # Website runs from packaged assets by default; no extra website flags
41
56
 
42
57
  # Subcommands (extensible)
@@ -88,93 +103,7 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
88
103
  return parser.parse_args(argv)
89
104
 
90
105
 
91
- def _spawn_frontend_dev( # noqa: C901 - pragmatic complexity for env probing
92
- port: int,
93
- override_dir: Path | None = None,
94
- config_dir: Path | None = None,
95
- ) -> tuple[int, _subprocess.Popen[bytes] | None]:
96
- """Try to start the frontend dev server by running `npm run dev`.
97
-
98
- Search order for working directory:
99
- 1) Packaged project path: <pkg_root>/frontend
100
- 2) Current working directory (if it contains a package.json)
101
- """
102
- candidates: list[Path] = []
103
- # Prefer packaged static assets; if present, the backend serves /dashboard
104
- static_candidates = [
105
- Path(__file__).parent / "frontend_dist", # inside package dir
106
- Path(__file__).parent.parent / "frontend_dist", # site-packages root
107
- ]
108
- static_dir = next((p for p in static_candidates if p.exists() and p.is_dir()), None)
109
- if static_dir is not None:
110
- log.info(
111
- f"Packaged dashboard detected at {static_dir}. It will be served at /dashboard by the API server."
112
- )
113
- # No separate website process needed. Return sentinel port (-1) so caller knows not to warn.
114
- return (-1, None)
115
-
116
- if static_dir is None:
117
- raise RuntimeError(
118
- "No packaged dashboard detected. The website will be served from the frontend directory."
119
- )
120
-
121
- pkg_frontend_candidates = [
122
- Path(__file__).parent / "frontend", # inside package dir
123
- Path(__file__).parent.parent / "frontend", # site-packages root
124
- ]
125
- if override_dir is not None:
126
- candidates.append(override_dir)
127
- for pf in pkg_frontend_candidates:
128
- if pf.exists():
129
- candidates.append(pf)
130
- if config_dir is not None and (config_dir / "package.json").exists():
131
- candidates.append(config_dir)
132
- cwd_pkg = Path.cwd()
133
- if (cwd_pkg / "package.json").exists():
134
- candidates.append(cwd_pkg)
135
-
136
- if not candidates:
137
- log.warning(
138
- "No frontend directory found (no packaged frontend and no package.json in CWD). Skipping website."
139
- )
140
- return (port, None)
141
-
142
- for candidate in candidates:
143
- try:
144
- # If no package.json but directory exists, try a basic npm i per user request
145
- if not (candidate / "package.json").exists():
146
- log.info(f"No package.json in {candidate}. Running 'npm i' as best effort...")
147
- _ = _subprocess.call(["npm", "i"], cwd=str(candidate))
148
-
149
- # Install deps if needed
150
- if (
151
- not (candidate / "node_modules").exists()
152
- and (candidate / "package-lock.json").exists()
153
- ):
154
- log.info(f"Installing frontend dependencies with npm ci in {candidate}...")
155
- r_install = _subprocess.call(["npm", "ci"], cwd=str(candidate))
156
- if r_install != 0:
157
- log.error("Failed to install frontend dependencies")
158
- continue
159
-
160
- log.info(f"Starting frontend dev server in {candidate} on port {port}...")
161
- cmd_default = ["npm", "run", "dev", "--", "--port", str(port)]
162
- proc = _subprocess.Popen(cmd_default, cwd=str(candidate))
163
- return (port, proc)
164
- except FileNotFoundError:
165
- log.error("npm not found. Please install Node.js to run the website dev server.")
166
- return (port, None)
167
-
168
- # If all candidates failed
169
- return (port, None)
170
-
171
-
172
106
  async def _run_server(args: Any) -> None:
173
- # TODO check this works as we want it to
174
- # Resolve config dir and expose via env for the rest of the app
175
- config_dir_arg = getattr(args, "config_dir", None)
176
- if config_dir_arg is not None:
177
- os.environ["OPEN_EDISON_CONFIG_DIR"] = str(Path(config_dir_arg).expanduser().resolve())
178
107
  config_dir = get_config_dir()
179
108
 
180
109
  # Load config after setting env override
@@ -186,44 +115,32 @@ async def _run_server(args: Any) -> None:
186
115
  log.info(f"Using config directory: {config_dir}")
187
116
  proxy = OpenEdisonProxy(host=host, port=port)
188
117
 
189
- # Website served from packaged assets by default; still detect and log
190
- frontend_proc = None
191
- used_port, frontend_proc = _spawn_frontend_dev(5173, None, config_dir)
192
- if frontend_proc is None and used_port == -1:
193
- log.info("Frontend is being served from packaged assets at /dashboard")
194
-
195
118
  try:
196
119
  await proxy.start()
197
- _ = await asyncio.Event().wait()
198
120
  except KeyboardInterrupt:
199
121
  log.info("Received shutdown signal")
200
- finally:
201
- if frontend_proc is not None:
202
- with suppress(Exception):
203
- frontend_proc.terminate()
204
- _ = frontend_proc.wait(timeout=5)
205
- with suppress(Exception):
206
- frontend_proc.kill()
207
-
208
-
209
- def _run_website(port: int, website_dir: Path | None = None) -> int:
210
- # Use the same spawning logic, then return 0 if started or 1 if failed
211
- _, proc = _spawn_frontend_dev(port, website_dir)
212
- return 0 if proc is not None else 1
213
122
 
214
123
 
215
124
  def main(argv: list[str] | None = None) -> NoReturn: # noqa: C901
216
125
  args = _parse_args(argv)
217
126
 
218
- if getattr(args, "command", None) == "website":
219
- exit_code = _run_website(port=args.port, website_dir=getattr(args, "dir", None))
220
- raise SystemExit(exit_code)
127
+ # Resolve config dir and expose via env for the rest of the app
128
+ config_dir_arg = getattr(args, "config_dir", None)
129
+ if config_dir_arg is not None:
130
+ os.environ["OPEN_EDISON_CONFIG_DIR"] = str(Path(config_dir_arg).expanduser().resolve())
131
+
132
+ if args.command is None:
133
+ args.command = "run"
221
134
 
222
- if getattr(args, "command", None) == "import-mcp":
135
+ if args.command == "import-mcp":
223
136
  result_code = run_cli(argv)
224
137
  raise SystemExit(result_code)
225
138
 
226
- # default: run server (top-level flags)
139
+ # Run import tui if necessary
140
+ tui_success = run_import_tui(args, force=args.wizard_force)
141
+ if not tui_success:
142
+ raise SystemExit(1)
143
+
227
144
  try:
228
145
  asyncio.run(_run_server(args))
229
146
  raise SystemExit(0)
src/config.py CHANGED
@@ -108,12 +108,23 @@ class MCPServerConfig:
108
108
  Remote servers use mcp-remote with HTTPS URLs and may require OAuth.
109
109
  Local servers run as child processes and don't need OAuth.
110
110
  """
111
- return (
112
- self.command == "npx"
113
- and len(self.args) >= 3
114
- and self.args[1] == "mcp-remote"
115
- and self.args[2].startswith("https://")
116
- )
111
+ # TODO find out if having the remote_server functionality is necessary, and if so how we can make it interact well with servers who don't like the fastmcp networking stack.
112
+ return False
113
+ # if self.command != "npx":
114
+ # return False
115
+
116
+ # # Be tolerant of npx flags by scanning for 'mcp-remote' and the subsequent HTTPS URL
117
+ # try:
118
+ # if "mcp-remote" not in self.args:
119
+ # return False
120
+ # idx: int = self.args.index("mcp-remote")
121
+ # # Look for first https?:// argument after 'mcp-remote'
122
+ # for candidate in self.args[idx + 1 :]:
123
+ # if candidate.startswith(("https://", "http://")):
124
+ # return candidate.startswith("https://")
125
+ # return False
126
+ # except Exception:
127
+ # return False
117
128
 
118
129
  def get_remote_url(self) -> str | None:
119
130
  """
@@ -122,9 +133,19 @@ class MCPServerConfig:
122
133
  Returns:
123
134
  The HTTPS URL if this is a remote server, None otherwise
124
135
  """
125
- if self.is_remote_server():
126
- return self.args[2]
136
+ # TODO see above
127
137
  return None
138
+ # # Reuse the same tolerant parsing as is_remote_server
139
+ # if self.command != "npx" or "mcp-remote" not in self.args:
140
+ # return None
141
+ # try:
142
+ # # idx: int = self.args.index("mcp-remote")
143
+ # for candidate in self.args[:]:
144
+ # if candidate.startswith(("https://", "http://")):
145
+ # return candidate
146
+ # return None
147
+ # except Exception:
148
+ # return None
128
149
 
129
150
 
130
151
  @dataclass
@@ -148,7 +169,7 @@ def load_json_file(path: Path) -> dict[str, Any]:
148
169
 
149
170
 
150
171
  def clear_json_file_cache() -> None:
151
- """Clear the cache for the given JSON file path"""
172
+ """Clear the cache for the JSON file loading"""
152
173
  load_json_file.cache_clear()
153
174
 
154
175
 
src/events.py CHANGED
@@ -26,12 +26,15 @@ def _approval_key(session_id: str, kind: str, name: str) -> str:
26
26
 
27
27
 
28
28
  def requires_loop(func: Callable[..., Any]) -> Callable[..., None | Any]: # noqa: ANN401
29
- """Decorator to ensure the function is called when there is an asyncio event loop.
29
+ """Decorator to ensure the function is called when there is a running asyncio loop.
30
30
  This is for sync(!) functions that return None / can do so on error"""
31
31
 
32
32
  @wraps(func)
33
33
  def wrapper(*args: Any, **kwargs: Any) -> None | Any:
34
- if asyncio.get_event_loop_policy()._local._loop is None: # type: ignore[attr-defined]
34
+ try:
35
+ # get_running_loop() raises RuntimeError if no loop is running in this thread
36
+ _ = asyncio.get_running_loop()
37
+ except RuntimeError:
35
38
  log.warning("fire_and_forget called in non-async context")
36
39
  return None
37
40
  return func(*args, **kwargs)
@@ -0,0 +1 @@
1
+ *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.\!container{width:100%!important}.container{width:100%}@media (min-width: 640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width: 768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width: 1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width: 1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width: 1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.-inset-0\.5{top:-.125rem;right:-.125rem;bottom:-.125rem;left:-.125rem}.bottom-4{bottom:1rem}.left-4{left:1rem}.right-4{right:1rem}.right-auto{right:auto}.z-50{z-index:50}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-2{height:.5rem}.h-3{height:.75rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-\[580px\]{height:580px}.w-10{width:2.5rem}.w-2{width:.5rem}.w-3{width:.75rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-\[260px\]{width:260px}.w-\[min\(92vw\,28rem\)\]{width:min(92vw,28rem)}.w-full{width:100%}.min-w-\[240px\]{min-width:240px}.max-w-\[1400px\]{max-width:1400px}.max-w-\[260px\]{max-width:260px}.max-w-sm{max-width:24rem}.border-collapse{border-collapse:collapse}.translate-x-1{--tw-translate-x: .25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-5{--tw-translate-x: 1.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-y-\[-4px\]{--tw-translate-y: -4px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.animate-\[pop_300ms_ease-out\]{animation:pop .3s ease-out}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-amber-400\/30{border-color:#fbbf244d}.border-app-accent{border-color:var(--accent)}.border-app-border{border-color:var(--border)}.border-blue-400\/30{border-color:#60a5fa4d}.border-blue-400\/60{border-color:#60a5fa99}.border-rose-400\/30{border-color:#fb71854d}.\!bg-blue-100{--tw-bg-opacity: 1 !important;background-color:rgb(219 234 254 / var(--tw-bg-opacity, 1))!important}.\!bg-blue-600{--tw-bg-opacity: 1 !important;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1))!important}.bg-amber-400{--tw-bg-opacity: 1;background-color:rgb(251 191 36 / var(--tw-bg-opacity, 1))}.bg-app-accent{background-color:var(--accent)}.bg-app-bg{background-color:var(--bg)}.bg-app-border{background-color:var(--border)}.bg-blue-400{--tw-bg-opacity: 1;background-color:rgb(96 165 250 / var(--tw-bg-opacity, 1))}.bg-blue-400\/20{background-color:#60a5fa33}.bg-blue-50{--tw-bg-opacity: 1;background-color:rgb(239 246 255 / var(--tw-bg-opacity, 1))}.bg-blue-500{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity, 1))}.bg-green-500{--tw-bg-opacity: 1;background-color:rgb(34 197 94 / var(--tw-bg-opacity, 1))}.bg-red-500{--tw-bg-opacity: 1;background-color:rgb(239 68 68 / var(--tw-bg-opacity, 1))}.bg-rose-400{--tw-bg-opacity: 1;background-color:rgb(251 113 133 / var(--tw-bg-opacity, 1))}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.bg-yellow-500{--tw-bg-opacity: 1;background-color:rgb(234 179 8 / var(--tw-bg-opacity, 1))}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.\!px-2{padding-left:.5rem!important;padding-right:.5rem!important}.\!px-3{padding-left:.75rem!important;padding-right:.75rem!important}.\!py-1\.5{padding-top:.375rem!important;padding-bottom:.375rem!important}.\!py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.pb-2{padding-bottom:.5rem}.text-left{text-align:left}.text-center{text-align:center}.align-bottom{vertical-align:bottom}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.\!text-white{--tw-text-opacity: 1 !important;color:rgb(255 255 255 / var(--tw-text-opacity, 1))!important}.text-amber-400{--tw-text-opacity: 1;color:rgb(251 191 36 / var(--tw-text-opacity, 1))}.text-app-accent{color:var(--accent)}.text-app-muted{color:var(--muted)}.text-app-text{color:var(--text)}.text-blue-400{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.text-blue-500{--tw-text-opacity: 1;color:rgb(59 130 246 / var(--tw-text-opacity, 1))}.text-blue-900{--tw-text-opacity: 1;color:rgb(30 58 138 / var(--tw-text-opacity, 1))}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.text-green-400{--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.text-green-500{--tw-text-opacity: 1;color:rgb(34 197 94 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-red-500{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.text-rose-400{--tw-text-opacity: 1;color:rgb(251 113 133 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.text-yellow-500{--tw-text-opacity: 1;color:rgb(234 179 8 / var(--tw-text-opacity, 1))}.accent-blue-500{accent-color:#3b82f6}.shadow{--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.blur-md{--tw-blur: blur(12px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-300{transition-duration:.3s}:root{--bg: #0b0c10;--card: #111318;--border: #1f2430;--text: #e6e6e6;--muted: #a0a7b4;--accent: #7c3aed;--success: #10b981;--warning: #f59e0b;--danger: #ef4444}[data-theme=dark]{--bg: #0b0c10;--card: #111318;--border: #1f2430;--text: #e6e6e6;--muted: #a0a7b4}[data-theme=light]{--bg: #f8fafc;--card: #ffffff;--border: #e5e7eb;--text: #0f172a;--muted: #475569}@media (prefers-color-scheme: light){:root{--bg: #f8fafc;--card: #ffffff;--border: #e5e7eb;--text: #0f172a;--muted: #475569}}html,body,#root{height:100%}body{margin:0;background:var(--bg);color:var(--text)}.container{margin:0 auto;padding:24px;max-width:1100px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px}.card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:16px;box-shadow:0 1px 2px #0000000a,0 2px 12px #00000014}.stat{display:flex;align-items:center;gap:12px}.badge{display:inline-block;font-size:12px;padding:2px 8px;border-radius:999px;border:1px solid var(--border);background:#7c3aed14;color:var(--text)}.table{width:100%;border-collapse:collapse}.table th,.table td{border-bottom:1px solid var(--border);padding:8px 4px;text-align:left}.muted{color:var(--muted)}.accent{color:var(--accent)}.success{color:var(--success)}.warning{color:var(--warning)}.danger{color:var(--danger)}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px}.button{border:1px solid var(--border);background:var(--card);color:var(--text);padding:6px 10px;border-radius:8px;cursor:pointer}.button:hover{filter:brightness(1.05)}.hover\:\!bg-blue-700:hover{--tw-bg-opacity: 1 !important;background-color:rgb(29 78 216 / var(--tw-bg-opacity, 1))!important}.hover\:text-gray-200:hover{--tw-text-opacity: 1;color:rgb(229 231 235 / var(--tw-text-opacity, 1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus-visible\:ring-blue-400:focus-visible{--tw-ring-opacity: 1;--tw-ring-color: rgb(96 165 250 / var(--tw-ring-opacity, 1))}@media (min-width: 640px){.sm\:flex{display:flex}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}}@media (min-width: 1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-\[220px_1fr\]{grid-template-columns:220px 1fr}}@media (prefers-color-scheme: dark){.dark\:\!bg-blue-800\/40{background-color:#1e40af66!important}.dark\:bg-blue-900\/20{background-color:#1e3a8a33}.dark\:text-blue-200{--tw-text-opacity: 1;color:rgb(191 219 254 / var(--tw-text-opacity, 1))}}