bugcap 0.2.2__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.
- bugcap/__init__.py +1 -0
- bugcap/agent_api.py +221 -0
- bugcap/backends.py +133 -0
- bugcap/capture.py +84 -0
- bugcap/cli.py +942 -0
- bugcap/config.py +86 -0
- bugcap/dashboard/__init__.py +3 -0
- bugcap/dashboard/api.py +105 -0
- bugcap/dashboard/media.py +73 -0
- bugcap/dashboard/server.py +213 -0
- bugcap/dashboard/static/app.css +43 -0
- bugcap/dashboard/static/app.js +179 -0
- bugcap/dashboard/static/index.html +45 -0
- bugcap/drafts.py +83 -0
- bugcap/errors.py +30 -0
- bugcap/ghcli.py +175 -0
- bugcap/ingest.py +210 -0
- bugcap/live.py +292 -0
- bugcap/live_session.py +125 -0
- bugcap/logs.py +74 -0
- bugcap/mcp_server.py +221 -0
- bugcap/paths.py +116 -0
- bugcap/recorder.py +390 -0
- bugcap/refs.py +213 -0
- bugcap/repo.py +138 -0
- bugcap/service.py +418 -0
- bugcap/store.py +485 -0
- bugcap/sync.py +424 -0
- bugcap/tomlio.py +37 -0
- bugcap/validation.py +74 -0
- bugcap-0.2.2.dist-info/METADATA +225 -0
- bugcap-0.2.2.dist-info/RECORD +36 -0
- bugcap-0.2.2.dist-info/WHEEL +5 -0
- bugcap-0.2.2.dist-info/entry_points.txt +2 -0
- bugcap-0.2.2.dist-info/licenses/LICENSE +21 -0
- bugcap-0.2.2.dist-info/top_level.txt +1 -0
bugcap/logs.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Logging for `bugcap mcp-serve`.
|
|
2
|
+
|
|
3
|
+
stdout carries the MCP protocol, so nothing here ever writes to it: records go to a rotating
|
|
4
|
+
file in the per-user data dir and to stderr."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from logging.handlers import RotatingFileHandler
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from . import config
|
|
15
|
+
from .paths import data_dir
|
|
16
|
+
|
|
17
|
+
LEVELS = {"debug": logging.DEBUG, "info": logging.INFO, "warning": logging.WARNING, "error": logging.ERROR}
|
|
18
|
+
FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s"
|
|
19
|
+
LOGGER_NAMES = ("bugcap.mcp", "mcp") # ours, and the MCP SDK's own (protocol-level) messages
|
|
20
|
+
_MARK = "_bugcap_handler"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def default_log_path() -> Path:
|
|
24
|
+
path = data_dir() / "logs"
|
|
25
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
return path / "mcp-server.log"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve_level(explicit: Optional[str] = None) -> int:
|
|
30
|
+
"""Flag, then BUGCAP_LOG_LEVEL, then `[log] level` in config.toml, then INFO."""
|
|
31
|
+
for candidate in (explicit, os.environ.get("BUGCAP_LOG_LEVEL"), config.get("log.level")):
|
|
32
|
+
if candidate and str(candidate).strip().lower() in LEVELS:
|
|
33
|
+
return LEVELS[str(candidate).strip().lower()]
|
|
34
|
+
return logging.INFO
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def resolve_path(explicit: Optional[str] = None) -> Path:
|
|
38
|
+
for candidate in (explicit, os.environ.get("BUGCAP_LOG_FILE"), config.get("log.file")):
|
|
39
|
+
if candidate:
|
|
40
|
+
return Path(str(candidate)).expanduser()
|
|
41
|
+
return default_log_path()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def setup_logging(level: Optional[str] = None, path: Optional[str] = None) -> tuple[logging.Logger, Optional[Path]]:
|
|
45
|
+
"""Configure file + stderr logging (idempotent). Returns the logger and the log file
|
|
46
|
+
actually in use (None if the file could not be opened and only stderr is used)."""
|
|
47
|
+
numeric = resolve_level(level)
|
|
48
|
+
target: Optional[Path] = resolve_path(path)
|
|
49
|
+
formatter = logging.Formatter(FORMAT)
|
|
50
|
+
handlers: list[logging.Handler] = []
|
|
51
|
+
try:
|
|
52
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
file_handler = RotatingFileHandler(target, maxBytes=1_000_000, backupCount=3, encoding="utf-8")
|
|
54
|
+
file_handler.setFormatter(formatter)
|
|
55
|
+
handlers.append(file_handler)
|
|
56
|
+
except OSError as exc:
|
|
57
|
+
print(f"bugcap mcp-serve: cannot write the log file {target}: {exc}; logging to stderr only", file=sys.stderr)
|
|
58
|
+
target = None
|
|
59
|
+
stream = logging.StreamHandler(sys.stderr) # never stdout: it carries the protocol
|
|
60
|
+
stream.setFormatter(formatter)
|
|
61
|
+
handlers.append(stream)
|
|
62
|
+
for handler in handlers:
|
|
63
|
+
setattr(handler, _MARK, True)
|
|
64
|
+
|
|
65
|
+
for name in LOGGER_NAMES:
|
|
66
|
+
logger = logging.getLogger(name)
|
|
67
|
+
for old in [h for h in logger.handlers if getattr(h, _MARK, False)]:
|
|
68
|
+
logger.removeHandler(old)
|
|
69
|
+
old.close()
|
|
70
|
+
for handler in handlers:
|
|
71
|
+
logger.addHandler(handler)
|
|
72
|
+
logger.setLevel(numeric)
|
|
73
|
+
logger.propagate = False # the SDK installs its own root handler; avoid duplicates
|
|
74
|
+
return logging.getLogger("bugcap.mcp"), target
|
bugcap/mcp_server.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""stdio MCP server built on the official `mcp` SDK (optional extra `bugcap[mcp]`).
|
|
2
|
+
|
|
3
|
+
The SDK is imported lazily so the core CLI works without it; `bugcap mcp-serve`
|
|
4
|
+
without the extra fails with a clear install hint. Tool logic lives in `agent_api`."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import functools
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from . import agent_api, logs
|
|
16
|
+
from .paths import db_path
|
|
17
|
+
from .store import Store
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger("bugcap.mcp")
|
|
20
|
+
|
|
21
|
+
INSTALL_HINT = (
|
|
22
|
+
"The MCP server needs the 'mcp' extra: pipx install 'bugcap[mcp]' "
|
|
23
|
+
"(or pipx inject bugcap mcp)."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MCPUnavailable(RuntimeError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _image_format(mime: str) -> str:
|
|
32
|
+
return {
|
|
33
|
+
"image/png": "png",
|
|
34
|
+
"image/jpeg": "jpeg",
|
|
35
|
+
"image/gif": "gif",
|
|
36
|
+
"image/webp": "webp",
|
|
37
|
+
"image/bmp": "bmp",
|
|
38
|
+
}.get(mime, "png")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _version() -> str:
|
|
42
|
+
try:
|
|
43
|
+
from importlib.metadata import version
|
|
44
|
+
|
|
45
|
+
return version("bugcap")
|
|
46
|
+
except Exception: # not installed (running from a checkout)
|
|
47
|
+
return "unknown"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _problem(result) -> Optional[str]:
|
|
51
|
+
"""The error text of a tool result, if it reports one (tools return JSON strings or content lists)."""
|
|
52
|
+
first = result[0] if isinstance(result, list) and result else result
|
|
53
|
+
if not isinstance(first, str):
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
data = json.loads(first)
|
|
57
|
+
except ValueError:
|
|
58
|
+
return None
|
|
59
|
+
if isinstance(data, dict):
|
|
60
|
+
if data.get("error"):
|
|
61
|
+
return f"{data.get('code', 'error')}: {data['error']}"
|
|
62
|
+
if data.get("status") in ("error", "cancelled", "non_interactive"):
|
|
63
|
+
return f"{data['status']}: {data.get('message', '')}"
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _logged(fn):
|
|
68
|
+
"""Log each tool call: name and argument names (values only at DEBUG, since notes can be
|
|
69
|
+
private), outcome and duration, and any exception with its traceback."""
|
|
70
|
+
|
|
71
|
+
@functools.wraps(fn)
|
|
72
|
+
def wrapper(*args, **kwargs):
|
|
73
|
+
name = fn.__name__
|
|
74
|
+
started = time.monotonic()
|
|
75
|
+
log.info("tool %s called (arguments: %s)", name, ", ".join(sorted(kwargs)) or "none")
|
|
76
|
+
if log.isEnabledFor(logging.DEBUG):
|
|
77
|
+
log.debug("tool %s arguments: %.500r", name, kwargs)
|
|
78
|
+
try:
|
|
79
|
+
result = fn(*args, **kwargs)
|
|
80
|
+
except Exception:
|
|
81
|
+
log.exception("tool %s failed after %d ms", name, (time.monotonic() - started) * 1000)
|
|
82
|
+
raise
|
|
83
|
+
millis = (time.monotonic() - started) * 1000
|
|
84
|
+
problem = _problem(result)
|
|
85
|
+
if problem:
|
|
86
|
+
log.warning("tool %s returned an error after %d ms: %s", name, millis, problem)
|
|
87
|
+
else:
|
|
88
|
+
log.info("tool %s ok in %d ms", name, millis)
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
return wrapper
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _log_client_initialize() -> None:
|
|
95
|
+
"""Log the client's initialize request (name, version, protocol). Best effort: it relies on
|
|
96
|
+
an SDK internal, so a changed SDK only costs this one log line."""
|
|
97
|
+
try:
|
|
98
|
+
from mcp import types
|
|
99
|
+
from mcp.server.session import ServerSession
|
|
100
|
+
except ImportError: # pragma: no cover
|
|
101
|
+
return
|
|
102
|
+
original = getattr(ServerSession, "_received_request", None)
|
|
103
|
+
if original is None or getattr(original, "_bugcap_wrapped", False):
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
async def received(self, responder):
|
|
107
|
+
try:
|
|
108
|
+
request = responder.request.root
|
|
109
|
+
if isinstance(request, types.InitializeRequest):
|
|
110
|
+
params = request.params
|
|
111
|
+
log.info(
|
|
112
|
+
"client initialize: %s %s (protocol %s)",
|
|
113
|
+
params.clientInfo.name, params.clientInfo.version, params.protocolVersion,
|
|
114
|
+
)
|
|
115
|
+
except Exception: # never let logging break the protocol
|
|
116
|
+
log.debug("could not log the initialize request", exc_info=True)
|
|
117
|
+
return await original(self, responder)
|
|
118
|
+
|
|
119
|
+
received._bugcap_wrapped = True # type: ignore[attr-defined]
|
|
120
|
+
ServerSession._received_request = received # type: ignore[method-assign]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def build_server():
|
|
124
|
+
"""Construct the FastMCP server with the tools registered."""
|
|
125
|
+
try:
|
|
126
|
+
from mcp.server.fastmcp import FastMCP, Image
|
|
127
|
+
except ImportError as exc: # pragma: no cover - exercised via subprocess test
|
|
128
|
+
raise MCPUnavailable(INSTALL_HINT) from exc
|
|
129
|
+
|
|
130
|
+
server = FastMCP("bugcap")
|
|
131
|
+
_log_client_initialize()
|
|
132
|
+
|
|
133
|
+
def tool():
|
|
134
|
+
def register(fn):
|
|
135
|
+
return server.tool()(_logged(fn))
|
|
136
|
+
return register
|
|
137
|
+
|
|
138
|
+
@tool()
|
|
139
|
+
def list_reports(all: bool = False, status: Optional[str] = None, limit: Optional[int] = None) -> str:
|
|
140
|
+
"""List bug reports (current repo by default; `all` for every repo)."""
|
|
141
|
+
with Store() as store:
|
|
142
|
+
return json.dumps(agent_api.list_reports(store, all=all, status=status, limit=limit))
|
|
143
|
+
|
|
144
|
+
@tool()
|
|
145
|
+
def get_report(id: int):
|
|
146
|
+
"""Fetch a report's metadata plus each attached image as image content."""
|
|
147
|
+
with Store() as store:
|
|
148
|
+
out = agent_api.get_report(store, id)
|
|
149
|
+
if "error" in out:
|
|
150
|
+
return json.dumps(out)
|
|
151
|
+
content = [json.dumps(out["report"])]
|
|
152
|
+
for img in out["images"]:
|
|
153
|
+
content.append(Image(data=img["bytes"], format=_image_format(img["mime"])))
|
|
154
|
+
return content
|
|
155
|
+
|
|
156
|
+
@tool()
|
|
157
|
+
def request_screenshot(
|
|
158
|
+
report_id: Optional[int] = None,
|
|
159
|
+
issue: Optional[str] = None,
|
|
160
|
+
message: Optional[str] = None,
|
|
161
|
+
timeout_seconds: int = 300,
|
|
162
|
+
note: Optional[str] = None,
|
|
163
|
+
):
|
|
164
|
+
"""Ask the user to capture a screenshot for a report or issue (blocks when a UI exists).
|
|
165
|
+
`note` is appended to the report's notes, tied to the new image."""
|
|
166
|
+
with Store() as store:
|
|
167
|
+
out = agent_api.request_screenshot(
|
|
168
|
+
store, report_id=report_id, issue=issue, message=message, timeout_seconds=timeout_seconds, note=note
|
|
169
|
+
)
|
|
170
|
+
image = out.pop("image", None)
|
|
171
|
+
content = [json.dumps(out)]
|
|
172
|
+
if image:
|
|
173
|
+
content.append(Image(data=image["bytes"], format=_image_format(image["mime"])))
|
|
174
|
+
return content
|
|
175
|
+
|
|
176
|
+
@tool()
|
|
177
|
+
def pull_issues(repo: Optional[str] = None, labels: Optional[list[str]] = None, limit: int = 30) -> str:
|
|
178
|
+
"""Import GitHub issues as reports (same as `bugcap github pull`)."""
|
|
179
|
+
with Store() as store:
|
|
180
|
+
return json.dumps(agent_api.pull_issues(store, repo=repo, labels=labels, limit=limit))
|
|
181
|
+
|
|
182
|
+
@tool()
|
|
183
|
+
def attach_image(id: int, sources: list[str], labels: Optional[list[Optional[str]]] = None) -> str:
|
|
184
|
+
"""Attach images (file paths, globs or http(s) URLs) to a report, with optional labels
|
|
185
|
+
(one per source, null for none). Notes can then refer to them as @1 or @label."""
|
|
186
|
+
with Store() as store:
|
|
187
|
+
return json.dumps(agent_api.attach_image(store, id, sources, labels))
|
|
188
|
+
|
|
189
|
+
@tool()
|
|
190
|
+
def update_notes(id: int, notes: str) -> str:
|
|
191
|
+
"""Replace a report's notes. @1 / @label references must point at the report's images."""
|
|
192
|
+
with Store() as store:
|
|
193
|
+
return json.dumps(agent_api.update_notes(store, id, notes))
|
|
194
|
+
|
|
195
|
+
return server
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def run(log_level: Optional[str] = None, log_file: Optional[str] = None) -> None:
|
|
199
|
+
"""Run the stdio server (blocking). Raises MCPUnavailable if the SDK is missing.
|
|
200
|
+
Logs startup, client initialize, every tool call and any crash (see `logs.py`)."""
|
|
201
|
+
_, target = logs.setup_logging(log_level, log_file)
|
|
202
|
+
if target is not None:
|
|
203
|
+
print(f"bugcap mcp-serve: logging to {target}", file=sys.stderr, flush=True)
|
|
204
|
+
log.info(
|
|
205
|
+
"starting bugcap %s MCP server (python %s, database %s, pid %s)",
|
|
206
|
+
_version(), sys.version.split()[0], db_path(), os.getpid(),
|
|
207
|
+
)
|
|
208
|
+
try:
|
|
209
|
+
server = build_server()
|
|
210
|
+
except MCPUnavailable as exc:
|
|
211
|
+
log.error("cannot start: %s", exc)
|
|
212
|
+
raise
|
|
213
|
+
try:
|
|
214
|
+
server.run()
|
|
215
|
+
except KeyboardInterrupt:
|
|
216
|
+
log.info("interrupted; shutting down")
|
|
217
|
+
except Exception:
|
|
218
|
+
log.exception("server crashed")
|
|
219
|
+
raise
|
|
220
|
+
finally:
|
|
221
|
+
log.info("server stopped")
|
bugcap/paths.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _home_override() -> Path | None:
|
|
7
|
+
value = os.environ.get("BUGCAP_HOME")
|
|
8
|
+
return Path(value) if value else None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _data_dir_path() -> Path:
|
|
12
|
+
"""The data directory location, without creating it."""
|
|
13
|
+
override = _home_override()
|
|
14
|
+
if override:
|
|
15
|
+
path = override / "data"
|
|
16
|
+
elif sys.platform == "win32":
|
|
17
|
+
base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
|
|
18
|
+
path = Path(base) / "bugcap"
|
|
19
|
+
elif sys.platform == "darwin":
|
|
20
|
+
path = Path.home() / "Library" / "Application Support" / "bugcap"
|
|
21
|
+
else:
|
|
22
|
+
base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
|
|
23
|
+
path = Path(base) / "bugcap"
|
|
24
|
+
return path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def data_dir() -> Path:
|
|
28
|
+
path = _data_dir_path()
|
|
29
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
return path
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def config_dir() -> Path:
|
|
34
|
+
override = _home_override()
|
|
35
|
+
if override:
|
|
36
|
+
path = override / "config"
|
|
37
|
+
elif sys.platform == "win32":
|
|
38
|
+
base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
|
39
|
+
path = Path(base) / "bugcap"
|
|
40
|
+
elif sys.platform == "darwin":
|
|
41
|
+
path = Path.home() / "Library" / "Application Support" / "bugcap"
|
|
42
|
+
else:
|
|
43
|
+
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
44
|
+
path = Path(base) / "bugcap"
|
|
45
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
return path
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def images_dir() -> Path:
|
|
50
|
+
path = data_dir() / "images"
|
|
51
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
return path
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def media_dir() -> Path:
|
|
56
|
+
path = data_dir() / "media"
|
|
57
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
return path
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def drafts_dir() -> Path:
|
|
62
|
+
path = data_dir() / "drafts"
|
|
63
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
return path
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_STORE_DIRS = ("images", "media")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def to_data_relative(path) -> str:
|
|
71
|
+
"""Path relative to the data dir (posix style) if it lies under images/ or media/;
|
|
72
|
+
otherwise the path unchanged (legacy absolute paths)."""
|
|
73
|
+
raw = str(path)
|
|
74
|
+
root = _data_dir_path()
|
|
75
|
+
try:
|
|
76
|
+
real = Path(os.path.realpath(raw))
|
|
77
|
+
for name in _STORE_DIRS:
|
|
78
|
+
base = Path(os.path.realpath(root / name))
|
|
79
|
+
if real == base or base in real.parents:
|
|
80
|
+
return real.relative_to(Path(os.path.realpath(root))).as_posix()
|
|
81
|
+
except (OSError, ValueError):
|
|
82
|
+
pass
|
|
83
|
+
return raw
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def resolve_data_path(rel) -> Path:
|
|
87
|
+
"""Absolute path for a stored data-relative path; ValueError unless its realpath lies
|
|
88
|
+
inside images/ or media/ (blocks traversal, absolute paths and escaping symlinks)."""
|
|
89
|
+
raw = str(rel)
|
|
90
|
+
if not raw or os.path.isabs(raw) or "\x00" in raw:
|
|
91
|
+
raise ValueError(f"path outside the media store: {raw!r}")
|
|
92
|
+
root = _data_dir_path()
|
|
93
|
+
candidate = Path(os.path.realpath(root / raw))
|
|
94
|
+
for name in _STORE_DIRS:
|
|
95
|
+
base = Path(os.path.realpath(root / name))
|
|
96
|
+
if candidate != base and base in candidate.parents:
|
|
97
|
+
return candidate
|
|
98
|
+
raise ValueError(f"path outside the media store: {raw!r}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def absolute_stored_path(stored: str) -> str:
|
|
102
|
+
"""Absolute filesystem path for a stored media path (relative ones live in the data dir)."""
|
|
103
|
+
if os.path.isabs(stored):
|
|
104
|
+
return stored
|
|
105
|
+
first = stored.replace("\\", "/").split("/", 1)[0]
|
|
106
|
+
if first in _STORE_DIRS:
|
|
107
|
+
return str(_data_dir_path() / stored)
|
|
108
|
+
return stored
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def db_path() -> Path:
|
|
112
|
+
return data_dir() / "bugcap.db"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def config_path() -> Path:
|
|
116
|
+
return config_dir() / "config.toml"
|