gabriel / muse public
config.py python
518 lines 16.5 KB
bda49bdb feat: redesign .museignore as TOML with domain-scoped sections (#100) Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """Muse CLI configuration helpers.
2
3 Reads and writes ``.muse/config.toml`` — the local repository configuration file.
4
5 The config file supports:
6 - ``[auth] token`` — bearer token for Muse Hub authentication (NEVER logged).
7 - ``[remotes.<name>] url`` — remote Hub URL for push/pull sync.
8 - ``[remotes.<name>] branch`` — upstream branch tracking for a remote.
9 - ``[domain]`` — domain-specific key/value pairs; the active plugin reads these.
10
11 Token lifecycle (MVP):
12 1. User obtains a token via ``POST /auth/token``.
13 2. User stores it in ``.muse/config.toml`` under ``[auth] token = "..."``
14 3. CLI commands that contact the Hub read the token here automatically.
15
16 Security note: ``.muse/config.toml`` should be added to ``.gitignore`` to
17 prevent the token from being committed to version control.
18 """
19
20 from __future__ import annotations
21
22 import logging
23 import pathlib
24 import shutil
25 import tomllib
26 from typing import TypedDict
27
28 logger = logging.getLogger(__name__)
29
30 _CONFIG_FILENAME = "config.toml"
31 _MUSE_DIR = ".muse"
32
33
34 # ---------------------------------------------------------------------------
35 # Named configuration types
36 # ---------------------------------------------------------------------------
37
38
39 class AuthEntry(TypedDict, total=False):
40 """``[auth]`` section in ``.muse/config.toml``."""
41
42 token: str
43
44
45 class RemoteEntry(TypedDict, total=False):
46 """``[remotes.<name>]`` section in ``.muse/config.toml``."""
47
48 url: str
49 branch: str
50
51
52 class MuseConfig(TypedDict, total=False):
53 """Structured view of the entire ``.muse/config.toml`` file."""
54
55 auth: AuthEntry
56 remotes: dict[str, RemoteEntry]
57 domain: dict[str, str]
58
59
60 class RemoteConfig(TypedDict):
61 """Public-facing remote descriptor returned by :func:`list_remotes`."""
62
63 name: str
64 url: str
65
66
67 # ---------------------------------------------------------------------------
68 # Internal helpers
69 # ---------------------------------------------------------------------------
70
71
72 def _config_path(repo_root: pathlib.Path | None) -> pathlib.Path:
73 """Return the path to .muse/config.toml for the given (or cwd) root."""
74 root = (repo_root or pathlib.Path.cwd()).resolve()
75 return root / _MUSE_DIR / _CONFIG_FILENAME
76
77
78 def _load_config(config_path: pathlib.Path) -> MuseConfig:
79 """Load and parse config.toml; return an empty MuseConfig if absent or unreadable."""
80 if not config_path.is_file():
81 return {}
82
83 try:
84 with config_path.open("rb") as fh:
85 raw = tomllib.load(fh)
86 except Exception as exc: # noqa: BLE001
87 logger.warning("⚠️ Failed to parse %s: %s", config_path, exc)
88 return {}
89
90 config: MuseConfig = {}
91
92 auth_raw = raw.get("auth")
93 if isinstance(auth_raw, dict):
94 auth: AuthEntry = {}
95 token_val = auth_raw.get("token")
96 if isinstance(token_val, str):
97 auth["token"] = token_val
98 config["auth"] = auth
99
100 remotes_raw = raw.get("remotes")
101 if isinstance(remotes_raw, dict):
102 remotes: dict[str, RemoteEntry] = {}
103 for name, remote_raw in remotes_raw.items():
104 if isinstance(remote_raw, dict):
105 entry: RemoteEntry = {}
106 url_val = remote_raw.get("url")
107 if isinstance(url_val, str):
108 entry["url"] = url_val
109 branch_val = remote_raw.get("branch")
110 if isinstance(branch_val, str):
111 entry["branch"] = branch_val
112 remotes[name] = entry
113 config["remotes"] = remotes
114
115 domain_raw = raw.get("domain")
116 if isinstance(domain_raw, dict):
117 domain: dict[str, str] = {}
118 for key, val in domain_raw.items():
119 if isinstance(val, str):
120 domain[key] = val
121 config["domain"] = domain
122
123 return config
124
125
126 def _dump_toml(config: MuseConfig) -> str:
127 """Serialize a MuseConfig back to TOML text.
128
129 Handles the subset of TOML used by .muse/config.toml:
130 - ``[auth]`` section with a ``token`` string.
131 - ``[remotes.<name>]`` sections with ``url`` and optional ``branch`` strings.
132
133 The ``[auth]`` section is always written first so the file is stable.
134 """
135 lines: list[str] = []
136
137 auth = config.get("auth")
138 if auth:
139 lines.append("[auth]")
140 token = auth.get("token", "")
141 if token:
142 escaped = token.replace("\\", "\\\\").replace('"', '\\"')
143 lines.append(f'token = "{escaped}"')
144 lines.append("")
145
146 remotes = config.get("remotes") or {}
147 for remote_name in sorted(remotes):
148 entry = remotes[remote_name]
149 lines.append(f"[remotes.{remote_name}]")
150 url = entry.get("url", "")
151 if url:
152 escaped = url.replace("\\", "\\\\").replace('"', '\\"')
153 lines.append(f'url = "{escaped}"')
154 branch = entry.get("branch", "")
155 if branch:
156 lines.append(f'branch = "{branch}"')
157 lines.append("")
158
159 return "\n".join(lines)
160
161
162 # ---------------------------------------------------------------------------
163 # Auth helpers
164 # ---------------------------------------------------------------------------
165
166
167 def get_auth_token(repo_root: pathlib.Path | None = None) -> str | None:
168 """Read ``[auth] token`` from ``.muse/config.toml``.
169
170 Returns the token string if present and non-empty, or ``None`` if the
171 file does not exist, ``[auth]`` is absent, or ``token`` is empty/missing.
172
173 The token value is NEVER logged — log lines mask it as ``"Bearer ***"``.
174
175 Args:
176 repo_root: Explicit repository root. Defaults to the current working
177 directory. In tests, pass a ``tmp_path`` fixture value.
178
179 Returns:
180 The raw token string, or ``None``.
181 """
182 config_path = _config_path(repo_root)
183
184 if not config_path.is_file():
185 logger.debug("⚠️ No %s found at %s", _CONFIG_FILENAME, config_path)
186 return None
187
188 config = _load_config(config_path)
189 auth = config.get("auth")
190 if auth is None:
191 logger.debug("⚠️ [auth] section missing in %s", config_path)
192 return None
193
194 token = auth.get("token", "")
195 if not token.strip():
196 logger.debug("⚠️ [auth] token missing or empty in %s", config_path)
197 return None
198
199 logger.debug("✅ Auth token loaded from %s (Bearer ***)", config_path)
200 return token.strip()
201
202
203 # ---------------------------------------------------------------------------
204 # Remote helpers
205 # ---------------------------------------------------------------------------
206
207
208 def get_remote(name: str, repo_root: pathlib.Path | None = None) -> str | None:
209 """Return the URL for remote *name* from ``[remotes.<name>] url``.
210
211 Returns ``None`` when the config file is absent or the named remote has
212 not been configured. Never raises — callers decide what to do on miss.
213
214 Args:
215 name: Remote name (e.g. ``"origin"``).
216 repo_root: Repository root. Defaults to ``Path.cwd()``.
217
218 Returns:
219 URL string, or ``None``.
220 """
221 config = _load_config(_config_path(repo_root))
222 remotes = config.get("remotes")
223 if remotes is None:
224 return None
225 entry = remotes.get(name)
226 if entry is None:
227 return None
228 url = entry.get("url", "")
229 return url.strip() if url.strip() else None
230
231
232 def set_remote(
233 name: str,
234 url: str,
235 repo_root: pathlib.Path | None = None,
236 ) -> None:
237 """Write ``[remotes.<name>] url = "<url>"`` to ``.muse/config.toml``.
238
239 Preserves all other sections already in the config file. Creates the
240 ``.muse/`` directory and ``config.toml`` if they do not exist.
241
242 Args:
243 name: Remote name (e.g. ``"origin"``).
244 url: Remote URL (e.g. ``"https://vcs.example.com/repos/my-repo"``).
245 repo_root: Repository root. Defaults to ``Path.cwd()``.
246 """
247 config_path = _config_path(repo_root)
248 config_path.parent.mkdir(parents=True, exist_ok=True)
249
250 config = _load_config(config_path)
251
252 existing_remotes = config.get("remotes")
253 remotes: dict[str, RemoteEntry] = {}
254 if existing_remotes:
255 remotes.update(existing_remotes)
256 existing_entry = remotes.get(name)
257 entry: RemoteEntry = {}
258 if existing_entry is not None:
259 if "url" in existing_entry:
260 entry["url"] = existing_entry["url"]
261 if "branch" in existing_entry:
262 entry["branch"] = existing_entry["branch"]
263 entry["url"] = url
264 remotes[name] = entry
265 config["remotes"] = remotes
266
267 config_path.write_text(_dump_toml(config), encoding="utf-8")
268 logger.info("✅ Remote %r set to %s", name, url)
269
270
271 def remove_remote(
272 name: str,
273 repo_root: pathlib.Path | None = None,
274 ) -> None:
275 """Remove a named remote and all its tracking refs from ``.muse/``.
276
277 Deletes ``[remotes.<name>]`` from ``config.toml`` and removes the entire
278 ``.muse/remotes/<name>/`` directory tree (tracking head files). Raises
279 ``KeyError`` when the remote does not exist so callers can surface a clear
280 error message to the user.
281
282 Args:
283 name: Remote name to remove (e.g. ``"origin"``).
284 repo_root: Repository root. Defaults to ``Path.cwd()``.
285
286 Raises:
287 KeyError: If *name* is not a configured remote.
288 """
289 config_path = _config_path(repo_root)
290 config = _load_config(config_path)
291
292 remotes = config.get("remotes")
293 if remotes is None or name not in remotes:
294 raise KeyError(name)
295
296 del remotes[name]
297 config["remotes"] = remotes
298
299 config_path.write_text(_dump_toml(config), encoding="utf-8")
300 logger.info("✅ Remote %r removed from config", name)
301
302 root = (repo_root or pathlib.Path.cwd()).resolve()
303 refs_dir = root / _MUSE_DIR / "remotes" / name
304 if refs_dir.is_dir():
305 shutil.rmtree(refs_dir)
306 logger.debug("✅ Removed tracking refs dir %s", refs_dir)
307
308
309 def rename_remote(
310 old_name: str,
311 new_name: str,
312 repo_root: pathlib.Path | None = None,
313 ) -> None:
314 """Rename a remote in ``.muse/config.toml`` and move its tracking refs.
315
316 Updates ``[remotes.<old_name>]`` → ``[remotes.<new_name>]`` in config and
317 moves ``.muse/remotes/<old_name>/`` → ``.muse/remotes/<new_name>/``.
318 Raises ``KeyError`` when *old_name* does not exist. Raises ``ValueError``
319 when *new_name* is already configured.
320
321 Args:
322 old_name: Current remote name.
323 new_name: Desired new remote name.
324 repo_root: Repository root. Defaults to ``Path.cwd()``.
325
326 Raises:
327 KeyError: If *old_name* is not a configured remote.
328 ValueError: If *new_name* already exists as a remote.
329 """
330 config_path = _config_path(repo_root)
331 config = _load_config(config_path)
332
333 remotes = config.get("remotes")
334 if remotes is None or old_name not in remotes:
335 raise KeyError(old_name)
336 if new_name in remotes:
337 raise ValueError(new_name)
338
339 remotes[new_name] = remotes.pop(old_name)
340 config["remotes"] = remotes
341
342 config_path.write_text(_dump_toml(config), encoding="utf-8")
343 logger.info("✅ Remote %r renamed to %r", old_name, new_name)
344
345 root = (repo_root or pathlib.Path.cwd()).resolve()
346 old_refs_dir = root / _MUSE_DIR / "remotes" / old_name
347 new_refs_dir = root / _MUSE_DIR / "remotes" / new_name
348 if old_refs_dir.is_dir():
349 old_refs_dir.rename(new_refs_dir)
350 logger.debug("✅ Moved tracking refs dir %s → %s", old_refs_dir, new_refs_dir)
351
352
353 def list_remotes(repo_root: pathlib.Path | None = None) -> list[RemoteConfig]:
354 """Return all configured remotes as :class:`RemoteConfig` dicts.
355
356 Returns an empty list when the config file is absent or contains no
357 ``[remotes.*]`` sections. Sorted alphabetically by remote name.
358
359 Args:
360 repo_root: Repository root. Defaults to ``Path.cwd()``.
361
362 Returns:
363 List of ``{"name": str, "url": str}`` dicts.
364 """
365 config = _load_config(_config_path(repo_root))
366 remotes = config.get("remotes")
367 if remotes is None:
368 return []
369
370 result: list[RemoteConfig] = []
371 for remote_name in sorted(remotes):
372 entry = remotes[remote_name]
373 url = entry.get("url", "")
374 if url.strip():
375 result.append(RemoteConfig(name=remote_name, url=url.strip()))
376
377 return result
378
379
380 # ---------------------------------------------------------------------------
381 # Remote tracking-head helpers
382 # ---------------------------------------------------------------------------
383
384
385 def _remote_head_path(
386 remote_name: str,
387 branch: str,
388 repo_root: pathlib.Path | None = None,
389 ) -> pathlib.Path:
390 """Return the path to the remote tracking pointer file.
391
392 The file lives at ``.muse/remotes/<remote_name>/<branch>`` and contains
393 the last known commit_id on that remote branch.
394 """
395 root = (repo_root or pathlib.Path.cwd()).resolve()
396 return root / _MUSE_DIR / "remotes" / remote_name / branch
397
398
399 def get_remote_head(
400 remote_name: str,
401 branch: str,
402 repo_root: pathlib.Path | None = None,
403 ) -> str | None:
404 """Return the last-known remote commit ID for *remote_name*/*branch*.
405
406 Returns ``None`` when the tracking pointer file does not exist (i.e. this
407 branch has never been pushed/pulled).
408
409 Args:
410 remote_name: Remote name (e.g. ``"origin"``).
411 branch: Branch name (e.g. ``"main"``).
412 repo_root: Repository root. Defaults to ``Path.cwd()``.
413
414 Returns:
415 Commit ID string, or ``None``.
416 """
417 pointer = _remote_head_path(remote_name, branch, repo_root)
418 if not pointer.is_file():
419 return None
420 raw = pointer.read_text(encoding="utf-8").strip()
421 return raw if raw else None
422
423
424 def set_remote_head(
425 remote_name: str,
426 branch: str,
427 commit_id: str,
428 repo_root: pathlib.Path | None = None,
429 ) -> None:
430 """Write the remote tracking pointer for *remote_name*/*branch*.
431
432 Creates the ``.muse/remotes/<remote_name>/`` directory if needed.
433
434 Args:
435 remote_name: Remote name (e.g. ``"origin"``).
436 branch: Branch name (e.g. ``"main"``).
437 commit_id: Commit ID to record as the known remote HEAD.
438 repo_root: Repository root. Defaults to ``Path.cwd()``.
439 """
440 pointer = _remote_head_path(remote_name, branch, repo_root)
441 pointer.parent.mkdir(parents=True, exist_ok=True)
442 pointer.write_text(commit_id, encoding="utf-8")
443 logger.debug("✅ Remote head %s/%s → %s", remote_name, branch, commit_id[:8])
444
445
446 # ---------------------------------------------------------------------------
447 # Upstream tracking helpers
448 # ---------------------------------------------------------------------------
449
450
451 def set_upstream(
452 branch: str,
453 remote_name: str,
454 repo_root: pathlib.Path | None = None,
455 ) -> None:
456 """Record *remote_name* as the upstream remote for *branch*.
457
458 Writes ``branch = "<branch>"`` under ``[remotes.<remote_name>]`` in
459 ``.muse/config.toml``. This mirrors the git ``--set-upstream`` behaviour:
460 the local branch knows which remote branch to track for future push/pull.
461
462 Args:
463 branch: Local (and remote) branch name (e.g. ``"main"``).
464 remote_name: Remote name (e.g. ``"origin"``).
465 repo_root: Repository root. Defaults to ``Path.cwd()``.
466 """
467 config_path = _config_path(repo_root)
468 config_path.parent.mkdir(parents=True, exist_ok=True)
469
470 config = _load_config(config_path)
471
472 existing_remotes = config.get("remotes")
473 remotes: dict[str, RemoteEntry] = {}
474 if existing_remotes:
475 remotes.update(existing_remotes)
476 existing_entry = remotes.get(remote_name)
477 entry: RemoteEntry = {}
478 if existing_entry is not None:
479 if "url" in existing_entry:
480 entry["url"] = existing_entry["url"]
481 if "branch" in existing_entry:
482 entry["branch"] = existing_entry["branch"]
483 entry["branch"] = branch
484 remotes[remote_name] = entry
485 config["remotes"] = remotes
486
487 config_path.write_text(_dump_toml(config), encoding="utf-8")
488 logger.info("✅ Upstream for branch %r set to %s/%r", branch, remote_name, branch)
489
490
491 def get_upstream(
492 branch: str,
493 repo_root: pathlib.Path | None = None,
494 ) -> str | None:
495 """Return the configured upstream remote name for *branch*, or ``None``.
496
497 Reads ``branch`` under every ``[remotes.*]`` section and returns the first
498 remote whose ``branch`` value matches *branch*.
499
500 Args:
501 branch: Local branch name (e.g. ``"main"``).
502 repo_root: Repository root. Defaults to ``Path.cwd()``.
503
504 Returns:
505 Remote name string (e.g. ``"origin"``), or ``None`` when no upstream
506 is configured for *branch*.
507 """
508 config = _load_config(_config_path(repo_root))
509 remotes = config.get("remotes")
510 if remotes is None:
511 return None
512
513 for rname, entry in remotes.items():
514 tracked = entry.get("branch", "")
515 if tracked.strip() == branch:
516 return rname
517
518 return None