plaud-tools 0.1.8__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.
@@ -0,0 +1,27 @@
1
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
2
+
3
+ from .auth import PlaudAuth
4
+ from .client import PlaudClient, PlaudRecordingQuery
5
+ from .errors import PlaudApiError, PlaudSessionExpiredError
6
+ from .mcp import build_handlers, build_read_handlers
7
+ from .session import FileSessionStore, PlaudSession, SessionManager, SessionStore
8
+
9
+ try:
10
+ __version__ = _pkg_version("plaud-tools")
11
+ except PackageNotFoundError:
12
+ __version__ = "0.0.0+dev"
13
+
14
+ __all__ = [
15
+ "FileSessionStore",
16
+ "PlaudAuth",
17
+ "PlaudApiError",
18
+ "PlaudClient",
19
+ "PlaudRecordingQuery",
20
+ "PlaudSession",
21
+ "PlaudSessionExpiredError",
22
+ "SessionManager",
23
+ "SessionStore",
24
+ "__version__",
25
+ "build_handlers",
26
+ "build_read_handlers",
27
+ ]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,195 @@
1
+ """AI client config detection and MCP wiring for Claude Desktop, Claude Code, Codex CLI."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import re
7
+ import shutil
8
+ import tomllib
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Literal
12
+
13
+ ClientStatus = Literal["not-detected", "not-connected", "connected", "stale"]
14
+
15
+ CLIENTS: dict[str, str] = {
16
+ "claude-desktop": "Claude Desktop",
17
+ "claude-code": "Claude Code",
18
+ "codex": "Codex",
19
+ }
20
+
21
+
22
+ def _client_paths() -> dict[str, Path]:
23
+ home = Path.home()
24
+ appdata = Path(os.environ.get("APPDATA") or home / "AppData" / "Roaming")
25
+ localappdata = Path(os.environ.get("LOCALAPPDATA") or home / "AppData" / "Local")
26
+ return {
27
+ "claude-desktop": _resolve_claude_desktop(localappdata, appdata),
28
+ "claude-code": home / ".claude.json",
29
+ "codex": home / ".codex" / "config.toml",
30
+ }
31
+
32
+
33
+ def _resolve_claude_desktop(localappdata: Path, appdata: Path) -> Path:
34
+ # Microsoft Store version uses a sandboxed Packages path.
35
+ packages = localappdata / "Packages"
36
+ if packages.exists():
37
+ for d in packages.iterdir():
38
+ if d.name.startswith("Claude_"):
39
+ return d / "LocalCache" / "Roaming" / "Claude" / "claude_desktop_config.json"
40
+ return appdata / "Claude" / "claude_desktop_config.json"
41
+
42
+
43
+ def _same_path(a: str, b: str) -> bool:
44
+ return Path(a).resolve().as_posix().lower() == Path(b).resolve().as_posix().lower()
45
+
46
+
47
+ def _backup_once(config_path: Path) -> None:
48
+ if not config_path.exists():
49
+ return
50
+ stamp = datetime.now().strftime("%Y%m%d")
51
+ if not list(config_path.parent.glob(f"{config_path.name}.plaud-backup-*")):
52
+ shutil.copy2(config_path, f"{config_path}.plaud-backup-{stamp}")
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # JSON helpers (Claude Desktop, Claude Code)
57
+ # ---------------------------------------------------------------------------
58
+
59
+ def _read_json(config_path: Path) -> dict:
60
+ if not config_path.exists():
61
+ return {}
62
+ text = config_path.read_text(encoding="utf-8").strip()
63
+ return json.loads(text) if text else {}
64
+
65
+
66
+ def _write_atomic_json(config_path: Path, data: dict) -> None:
67
+ config_path.parent.mkdir(parents=True, exist_ok=True)
68
+ tmp = config_path.with_suffix(".plaud-tmp")
69
+ tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
70
+ tmp.replace(config_path)
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # TOML helpers (Codex CLI)
75
+ # ---------------------------------------------------------------------------
76
+
77
+ def _read_toml(config_path: Path) -> dict:
78
+ if not config_path.exists():
79
+ return {}
80
+ text = config_path.read_text(encoding="utf-8").strip()
81
+ return tomllib.loads(text) if text else {}
82
+
83
+
84
+ # Pattern matches [mcp_servers.plaud] and everything until the next section header.
85
+ _TOML_SECTION_RE = re.compile(
86
+ r"\[mcp_servers\.plaud\][^\[]*",
87
+ re.DOTALL,
88
+ )
89
+
90
+
91
+ def _toml_string(value: str) -> str:
92
+ # Prefer a single-quoted TOML literal string so Windows backslashes don't
93
+ # get interpreted as escape sequences. Fall back to a basic string with
94
+ # escaped backslashes if the value itself contains a single quote.
95
+ if "'" not in value:
96
+ return f"'{value}'"
97
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
98
+
99
+
100
+ def _write_toml_mcp(config_path: Path, command: str | None) -> None:
101
+ """Add/update or remove [mcp_servers.plaud] in a TOML file without touching other content."""
102
+ config_path.parent.mkdir(parents=True, exist_ok=True)
103
+ text = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
104
+
105
+ if command is not None:
106
+ new_section = f'[mcp_servers.plaud]\ncommand = {_toml_string(command)}\n'
107
+ if _TOML_SECTION_RE.search(text):
108
+ # Use a callable replacement so re.sub doesn't treat backslashes
109
+ # in `new_section` (e.g. Windows paths) as group escapes.
110
+ text = _TOML_SECTION_RE.sub(lambda _m: new_section, text)
111
+ else:
112
+ sep = "\n" if text.endswith("\n") else "\n\n"
113
+ text = text.rstrip("\n") + sep + new_section
114
+ else:
115
+ text = _TOML_SECTION_RE.sub("", text).strip()
116
+ if text:
117
+ text += "\n"
118
+
119
+ tmp = config_path.with_suffix(".plaud-tmp")
120
+ tmp.write_text(text, encoding="utf-8")
121
+ tmp.replace(config_path)
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Public API
126
+ # ---------------------------------------------------------------------------
127
+
128
+ def get_status(client_id: str, mcp_exe: str) -> ClientStatus:
129
+ paths = _client_paths()
130
+ config_path = paths.get(client_id)
131
+ if config_path is None or not config_path.exists():
132
+ return "not-detected"
133
+
134
+ if config_path.suffix == ".toml":
135
+ try:
136
+ config = _read_toml(config_path)
137
+ except Exception:
138
+ return "not-connected"
139
+ entry = (config.get("mcp_servers") or {}).get("plaud")
140
+ if not entry or not isinstance(entry.get("command"), str):
141
+ return "not-connected"
142
+ return "connected" if _same_path(entry["command"], mcp_exe) else "stale"
143
+
144
+ try:
145
+ config = _read_json(config_path)
146
+ except Exception:
147
+ return "not-connected"
148
+ entry = (config.get("mcpServers") or {}).get("plaud")
149
+ if not entry or not isinstance(entry.get("command"), str):
150
+ return "not-connected"
151
+ return "connected" if _same_path(entry["command"], mcp_exe) else "stale"
152
+
153
+
154
+ def connect(client_id: str, mcp_exe: str) -> None:
155
+ paths = _client_paths()
156
+ config_path = paths[client_id]
157
+ _backup_once(config_path)
158
+
159
+ if config_path.suffix == ".toml":
160
+ _write_toml_mcp(config_path, mcp_exe)
161
+ return
162
+
163
+ config = _read_json(config_path)
164
+ config.setdefault("mcpServers", {})["plaud"] = {"command": mcp_exe}
165
+ _write_atomic_json(config_path, config)
166
+
167
+
168
+ def disconnect(client_id: str) -> None:
169
+ paths = _client_paths()
170
+ config_path = paths.get(client_id)
171
+ if config_path is None or not config_path.exists():
172
+ return
173
+
174
+ if config_path.suffix == ".toml":
175
+ _write_toml_mcp(config_path, None)
176
+ return
177
+
178
+ config = _read_json(config_path)
179
+ (config.get("mcpServers") or {}).pop("plaud", None)
180
+ _write_atomic_json(config_path, config)
181
+
182
+
183
+ def status_all(mcp_exe: str) -> dict[str, ClientStatus]:
184
+ return {cid: get_status(cid, mcp_exe) for cid in CLIENTS}
185
+
186
+
187
+ def connect_all(mcp_exe: str) -> None:
188
+ for cid in CLIENTS:
189
+ if _client_paths()[cid].exists():
190
+ connect(cid, mcp_exe)
191
+
192
+
193
+ def disconnect_all() -> None:
194
+ for cid in CLIENTS:
195
+ disconnect(cid)
plaud_tools/auth.py ADDED
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from urllib.parse import urlencode
4
+
5
+ from .errors import PlaudApiError
6
+ from .models import BASE_URLS, BROWSER_USER_AGENT
7
+ from .session import PlaudSession, SessionStoreProtocol
8
+ from .transport import Transport, UrllibTransport
9
+
10
+
11
+ class PlaudAuth:
12
+ def __init__(self, store: SessionStoreProtocol, transport: Transport | None = None) -> None:
13
+ self.store = store
14
+ self.transport = transport or UrllibTransport()
15
+
16
+ def login(self, email: str, password: str, region: str) -> PlaudSession:
17
+ body = urlencode({"username": email, "password": password}).encode("utf-8")
18
+ response = self.transport.request(
19
+ method="POST",
20
+ url=f"{BASE_URLS.get(region, BASE_URLS['us'])}/auth/access-token",
21
+ headers={
22
+ "Content-Type": "application/x-www-form-urlencoded",
23
+ "User-Agent": BROWSER_USER_AGENT,
24
+ },
25
+ body=body,
26
+ )
27
+ if response.status_code < 200 or response.status_code >= 300:
28
+ raise PlaudApiError(f"Login request failed: HTTP {response.status_code}")
29
+
30
+ payload = response.json()
31
+ if not isinstance(payload, dict):
32
+ raise PlaudApiError("Login response was not a JSON object.")
33
+
34
+ token = payload.get("access_token")
35
+ if payload.get("status") != 0 or not isinstance(token, str) or not token:
36
+ raise PlaudApiError(str(payload.get("msg") or f"Login failed (status {payload.get('status')})"))
37
+
38
+ session = PlaudSession(access_token=token, region=region, email=email)
39
+ self.store.save(session)
40
+ return session