progi 0.1.0__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.
progi/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """progi — an MCP server with an optional web UI over a shared SQLite database."""
2
+
3
+ __version__ = "0.1.0"
progi/cli.py ADDED
@@ -0,0 +1,80 @@
1
+ """Command-line entry point for `progi`.
2
+
3
+ Run modes
4
+ ---------
5
+ progi -> MCP server (stdio) + web UI (background thread)
6
+ progi --no-web -> MCP server only
7
+ progi-web -> web UI only (separate entry point, see web.app:main)
8
+
9
+ Why the web server runs in a background daemon thread in bundled mode:
10
+ the MCP server speaks the protocol over stdout and must run in the foreground.
11
+ uvicorn is started on a separate thread with logging pinned to stderr so it
12
+ never writes to stdout and corrupts the MCP stream. The thread is a daemon, so
13
+ it exits when the MCP server (foreground) exits.
14
+
15
+ Flags override environment variables; environment variables override defaults.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import threading
22
+
23
+ from . import mcp_server
24
+ from .config import Config, load_config
25
+ from .logging_setup import configure_logging
26
+
27
+
28
+ def _start_web_in_thread(cfg: Config) -> threading.Thread:
29
+ import uvicorn
30
+
31
+ from .web.app import app
32
+
33
+ app.state.cfg = cfg
34
+ server = uvicorn.Server(
35
+ uvicorn.Config(
36
+ app,
37
+ host=cfg.web_host,
38
+ port=cfg.web_port,
39
+ # Keep uvicorn quiet on stdout; warnings/errors go to stderr via our
40
+ # logging config. log_config=None prevents uvicorn from installing
41
+ # its own stdout handlers.
42
+ log_config=None,
43
+ log_level="warning",
44
+ )
45
+ )
46
+ thread = threading.Thread(target=server.run, name="progi-web", daemon=True)
47
+ thread.start()
48
+ return thread
49
+
50
+
51
+ def main() -> None:
52
+ parser = argparse.ArgumentParser(prog="progi", description="progi MCP server + web UI")
53
+ parser.add_argument(
54
+ "--no-web",
55
+ action="store_true",
56
+ help="Run only the MCP server (skip the web UI).",
57
+ )
58
+ parser.add_argument("--web-host", default=None, help="Override web bind host.")
59
+ parser.add_argument("--web-port", type=int, default=None, help="Override web port.")
60
+ args = parser.parse_args()
61
+
62
+ base = load_config()
63
+ cfg = Config(
64
+ db_path=base.db_path,
65
+ web_host=args.web_host or base.web_host,
66
+ web_port=args.web_port or base.web_port,
67
+ no_web=args.no_web or base.no_web,
68
+ )
69
+
70
+ configure_logging()
71
+
72
+ if not cfg.no_web:
73
+ _start_web_in_thread(cfg)
74
+
75
+ # Foreground: MCP server over stdio. Blocks until the client disconnects.
76
+ mcp_server.run(cfg)
77
+
78
+
79
+ if __name__ == "__main__":
80
+ main()
progi/config.py ADDED
@@ -0,0 +1,63 @@
1
+ """Runtime configuration, resolved from environment variables with sane defaults.
2
+
3
+ All configuration funnels through here so the MCP server, the web app, and the
4
+ CLI agree on the same values (database location, web bind address, etc.).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ try:
14
+ # platformdirs gives us OS-appropriate data locations
15
+ # (~/.local/share/progi on Linux, ~/Library/Application Support/progi on
16
+ # macOS, %LOCALAPPDATA%\progi on Windows).
17
+ from platformdirs import user_data_dir
18
+
19
+ _DEFAULT_DATA_DIR = Path(user_data_dir("progi", appauthor=False))
20
+ except Exception: # pragma: no cover - platformdirs is a hard dependency, but be safe
21
+ _DEFAULT_DATA_DIR = Path.home() / ".progi"
22
+
23
+
24
+ def _env_bool(name: str, default: bool = False) -> bool:
25
+ raw = os.environ.get(name)
26
+ if raw is None:
27
+ return default
28
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
29
+
30
+
31
+ def _resolve_db_path() -> Path:
32
+ """Where the SQLite file lives. Override with PROGI_DB_PATH."""
33
+ override = os.environ.get("PROGI_DB_PATH")
34
+ if override:
35
+ return Path(override).expanduser().resolve()
36
+ return (_DEFAULT_DATA_DIR / "progi.db").resolve()
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Config:
41
+ db_path: Path
42
+ web_host: str
43
+ web_port: int
44
+ # When True, the bundled run mode skips starting the web server.
45
+ no_web: bool
46
+
47
+ @property
48
+ def sqlalchemy_url(self) -> str:
49
+ # SQLite URL. The path is absolute so it does not depend on CWD.
50
+ return f"sqlite:///{self.db_path}"
51
+
52
+ def ensure_dirs(self) -> None:
53
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
54
+
55
+
56
+ def load_config() -> Config:
57
+ cfg = Config(
58
+ db_path=_resolve_db_path(),
59
+ web_host=os.environ.get("PROGI_WEB_HOST", "127.0.0.1"),
60
+ web_port=int(os.environ.get("PROGI_WEB_PORT", "8000")),
61
+ no_web=_env_bool("PROGI_NO_WEB", default=False),
62
+ )
63
+ return cfg