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