gabriel / muse public

config_cmd.py file-level

at sha256:8 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:4 Merge branch 'dev' into main · gabriel · Jun 17, 2026
1 """muse config β€” local repository configuration.
2
3 Provides structured, typed read/write access to ``.muse/config.toml``.
4 For hub credentials and user identity, use ``muse auth``. For remote
5 connections, use ``muse remote``.
6
7 Settable namespaces
8 --------------------
9 - ``hub.url`` β€” hub fabric URL (alias for ``muse hub connect``)
10 - ``domain.*`` β€” domain-specific keys; read by the active plugin
11 - ``limits.max_walk_commits`` β€” cap on merge-base BFS walk (int > 0)
12 - ``limits.max_ancestors`` β€” cap on ancestor graph nodes (int > 0)
13 - ``limits.max_graph_commits`` β€” cap on full graph traversal (int > 0)
14 - ``limits.shard_prefix_length`` β€” object-store shard width: 2 or 4
15
16 Blocked via ``muse config set``
17 ---------------------------------
18 - ``user.*`` β€” use ``muse auth register`` / ``muse auth whoami``
19 - ``auth.*`` β€” use ``muse auth register``
20 - ``remotes.*`` β€” use ``muse remote add/remove``
21
22 Security model
23 --------------
24 - Domain key names are validated against TOML-structurally significant
25 characters (``]``, ``[``, ``=``, ``"``, newlines) before being written,
26 preventing TOML injection attacks via crafted key strings.
27 - All user-controlled values are passed through ``sanitize_display()``
28 before being printed to prevent ANSI escape injection.
29 - All diagnostic messages go to **stderr**; **stdout** is reserved for
30 config values, JSON output, and TOML text.
31
32 Output format
33 -------------
34 ``muse config read`` emits TOML by default (human-readable). Pass
35 ``--json`` for machine-readable output β€” no credentials are ever included.
36
37 ``muse config get`` emits the raw value to stdout (one line, no quoting).
38 With ``--json`` it emits ``{"key": "...", "value": "..."}``.
39
40 ``muse config set`` confirms the write to stderr. With ``--json`` it
41 emits ``{"status": "ok", "key": "...", "value": "...",
42 "duration_ms": 0.001, "exit_code": 0}``.
43
44 ``duration_ms``
45 Wall-clock time from argument parsing to output.
46 ``exit_code``
47 Mirrors the process exit code (always ``0`` in the success paths).
48
49 JSON schema for ``read``::
50
51 {
52 "hub": {"url": "https://musehub.ai"},
53 "remotes": {"origin": "https://..."},
54 "domain": {"ticks_per_beat": "480"},
55 "limits": {"max_walk_commits": "10000"},
56 "duration_ms": 0.001,
57 "exit_code": 0
58 }
59
60 Examples
61 --------
62 ::
63
64 muse config read
65 muse config read --json
66 muse config get hub.url
67 muse config get domain.ticks_per_beat --json
68 muse config set hub.url https://musehub.ai
69 muse config set domain.ticks_per_beat 480
70 muse config set limits.max_walk_commits 50000
71 muse config edit
72 """
73
74 import argparse
75 import json
76 import logging
77 import os
78 import shlex
79 import subprocess
80 import sys
81 import time
82 from typing import TypedDict
83
84 from muse.cli.config import (
85 ConfigTree,
86 config_as_dict,
87 config_path_for_editor,
88 get_config_value,
89 set_config_value,
90 )
91 from muse.core.errors import ExitCode
92 from muse.core.repo import find_repo_root
93 from muse.core.validation import sanitize_display
94 from muse.core.timing import start_timer
95 from muse.core.envelope import EnvelopeJson, make_envelope
96
97 logger = logging.getLogger(__name__)
98
99 # ── TypedDicts ────────────────────────────────────────────────────────────────
100
101 class _ReadJson(EnvelopeJson):
102 """JSON schema for ``muse config read --json`` (full config dump)."""
103
104 config: ConfigTree
105
106 class _GetJson(EnvelopeJson):
107 """JSON schema for ``muse config get --json``."""
108
109 key: str
110 value: str
111
112 class _SetJson(EnvelopeJson):
113 """JSON schema for ``muse config set --json``."""
114
115 status: str # "ok"
116 key: str
117 value: str
118
119 # ── register ──────────────────────────────────────────────────────────────────
120
121 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
122 """Register the ``muse config`` subcommand tree.
123
124 Every subcommand that produces structured output accepts ``--json``.
125 All diagnostic messages go to stderr; stdout carries config data only.
126 """
127 parser = subparsers.add_parser(
128 "config",
129 help="Local repository configuration.",
130 description=__doc__,
131 formatter_class=argparse.RawDescriptionHelpFormatter,
132 )
133 subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND")
134 subs.required = True
135
136 edit_p = subs.add_parser(
137 "edit",
138 help="Open .muse/config.toml in $EDITOR or $VISUAL.",
139 description=(
140 "Open .muse/config.toml in your preferred editor.\n\n"
141 "Editor resolution order:\n"
142 " 1. $VISUAL (e.g. 'code --wait', 'vim')\n"
143 " 2. $EDITOR (e.g. 'nano', 'emacs')\n"
144 " 3. vi (hard fallback)\n\n"
145 "Multi-word editor commands are supported:\n"
146 " VISUAL='code --wait' muse config edit\n"
147 " EDITOR='emacs -nw' muse config edit\n\n"
148 "If .muse/config.toml does not exist it is created (empty) before\n"
149 "the editor opens, so you can configure a fresh repository.\n\n"
150 "Note: this command opens an interactive editor and is intended for\n"
151 "human use. Agents should use 'muse config set' or 'muse config get'\n"
152 "for programmatic config access.\n\n"
153 "Exit codes: 0 success, 1 editor not found / editor exited non-zero,\n"
154 " 2 not inside a Muse repository."
155 ),
156 formatter_class=argparse.RawDescriptionHelpFormatter,
157 )
158 edit_p.set_defaults(func=run_edit)
159
160 get_p = subs.add_parser(
161 "get",
162 help="Print the value of a single config key.",
163 description=(
164 "Print the value of a single config key by dotted name.\n\n"
165 "Exits non-zero when the key is not set β€” enabling agent branching:\n"
166 " VALUE=$(muse config get hub.url) || echo 'not configured'\n\n"
167 "Supported keys:\n"
168 " hub.url\n"
169 " domain.<key> (any domain-specific key)\n"
170 " limits.max_walk_commits, limits.max_ancestors,\n"
171 " limits.max_graph_commits, limits.shard_prefix_length\n\n"
172 "User identity (handle, type, email) is in identity.toml β€” use muse auth whoami.\n\n"
173 "Agent quickstart:\n"
174 " muse config get hub.url --json\n"
175 " muse config get domain.ticks_per_beat --json | jq -r '.value'\n\n"
176 "Exit codes: 0 key is set, 1 key not set or bad key format."
177 ),
178 formatter_class=argparse.RawDescriptionHelpFormatter,
179 )
180 get_p.add_argument(
181 "key", metavar="KEY",
182 help="Dotted key to read (e.g. hub.url, domain.ticks_per_beat, limits.max_walk_commits).",
183 )
184 get_p.add_argument(
185 "--json", "-j", action="store_true", dest="json_out", default=False,
186 help="Emit {\"key\": \"...\", \"value\": \"...\"} JSON to stdout.",
187 )
188 get_p.set_defaults(func=run_get)
189
190 set_p = subs.add_parser(
191 "set",
192 help="Set a config value by dotted key.",
193 description=(
194 "Set a config value by dotted key (namespace.subkey).\n\n"
195 "Settable namespaces:\n"
196 " hub.url (HTTPS only)\n"
197 " domain.<key> (any domain-specific key)\n"
198 " limits.max_walk_commits (int > 0)\n"
199 " limits.max_ancestors (int > 0)\n"
200 " limits.max_graph_commits (int > 0)\n"
201 " limits.shard_prefix_length (2 or 4)\n\n"
202 "Blocked namespaces β€” use the dedicated command instead:\n"
203 " user.* β†’ muse auth register / muse auth whoami\n"
204 " auth.* β†’ muse auth register\n"
205 " remotes.* β†’ muse remote add\n\n"
206 "Agent quickstart:\n"
207 " muse config set hub.url https://musehub.ai\n"
208 " muse config set domain.ticks_per_beat 480\n"
209 " muse config set limits.max_walk_commits 50000\n\n"
210 "Exit codes: 0 success, 1 bad key / blocked namespace / validation error."
211 ),
212 formatter_class=argparse.RawDescriptionHelpFormatter,
213 )
214 set_p.add_argument(
215 "key", metavar="KEY",
216 help="Dotted key to set (e.g. hub.url, domain.ticks_per_beat, limits.max_walk_commits).",
217 )
218 set_p.add_argument("value", metavar="VALUE", help="New value.")
219 set_p.add_argument(
220 "--json", "-j", action="store_true", dest="json_out", default=False,
221 help="Emit {\"status\": \"ok\", \"key\": \"...\", \"value\": \"...\"} JSON to stdout.",
222 )
223 set_p.set_defaults(func=run_set)
224
225 read_p = subs.add_parser(
226 "read",
227 help="Read the current repository configuration.",
228 description=(
229 "Read the full repository configuration from .muse/config.toml.\n\n"
230 "Credentials are never included β€” the hub section only contains the URL.\n"
231 "Text mode emits TOML to stdout; --json emits an indented JSON object.\n\n"
232 "Agent quickstart:\n"
233 " muse config show --json\n"
234 " muse config show --json | jq '.hub.url'\n"
235 " muse config show --json | jq '.limits.max_walk_commits'\n\n"
236 "JSON schema:\n"
237 " {\"hub\": {\"url\"},\n"
238 " \"remotes\": {\"<name>\": \"<url>\"},\n"
239 " \"domain\": {\"<key>\": \"<val>\"},\n"
240 " \"limits\": {\"max_walk_commits\", \"max_ancestors\", ...}}\n\n"
241 "Exit codes: 0 success (even when config is empty), 1 unknown --format."
242 ),
243 formatter_class=argparse.RawDescriptionHelpFormatter,
244 )
245 read_p.add_argument(
246 "--json", "-j", action="store_true", dest="json_out",
247 help="Emit JSON instead of TOML text.",
248 )
249 read_p.set_defaults(func=run_read)
250
251 # ── run_read ──────────────────────────────────────────────────────────────────
252
253 def run_read(args: argparse.Namespace) -> None:
254 """Display the current repository configuration.
255
256 Emits TOML by default. Credentials are never included. All user-controlled
257 values are sanitised to prevent ANSI injection. JSON output goes to stdout;
258 all diagnostics go to stderr.
259
260 Agent quickstart
261 ----------------
262 ::
263
264 muse config read --json
265 muse config get hub.url --json
266 muse config set domain.ticks_per_beat 480 --json
267
268 JSON fields
269 -----------
270 hub Map with ``url`` key.
271 remotes Map of remote name β†’ URL.
272 domain Domain-specific config map (e.g. ``ticks_per_beat``).
273 limits Limits map (e.g. ``max_walk_commits``).
274
275 Exit codes
276 ----------
277 0 Configuration displayed.
278 1 Invalid ``--format`` value.
279 """
280 elapsed = start_timer()
281 json_out: bool = args.json_out
282
283 root = find_repo_root()
284 data = config_as_dict(root)
285
286 if json_out:
287 print(json.dumps(_ReadJson(**make_envelope(elapsed), config=data)))
288 return
289
290 if not data:
291 print("# No configuration set.")
292 return
293
294 hub = data.get("hub")
295 if hub:
296 print("[hub]")
297 for key, val in sorted(hub.items()):
298 print(f'{sanitize_display(key)} = "{sanitize_display(val)}"')
299 print("")
300
301 remotes = data.get("remotes")
302 if remotes:
303 for remote_name, remote_url in sorted(remotes.items()):
304 print(f"[remotes.{sanitize_display(remote_name)}]")
305 print(f'url = "{sanitize_display(remote_url)}"')
306 print("")
307
308 domain = data.get("domain")
309 if domain:
310 print("[domain]")
311 for key, val in sorted(domain.items()):
312 print(f'{sanitize_display(key)} = "{sanitize_display(val)}"')
313 print("")
314
315 limits = data.get("limits")
316 if limits:
317 print("[limits]")
318 for key, val in sorted(limits.items()):
319 print(f'{sanitize_display(key)} = {sanitize_display(val)}')
320 print("")
321
322 # ── run_get ───────────────────────────────────────────────────────────────────
323
324 def run_get(args: argparse.Namespace) -> None:
325 """Print the value of a single config key.
326
327 Key must be in ``namespace.subkey`` dotted form (e.g. ``hub.url``).
328 Exits non-zero when the key is not set, enabling agent branching on
329 config presence.
330
331 Agent quickstart
332 ----------------
333 ::
334
335 muse config get hub.url --json
336 muse config get domain.ticks_per_beat --json
337
338 JSON fields
339 -----------
340 key The key queried (e.g. ``"hub.url"``).
341 value The string value stored.
342
343 Exit codes
344 ----------
345 0 Key is set β€” value printed.
346 1 Key not set, bad format, or unknown namespace.
347 """
348 elapsed = start_timer()
349 key: str = args.key
350 json_out: bool = args.json_out
351
352 # ── Key format validation ─────────────────────────────────────────────────
353 if "." not in key:
354 print(
355 f"❌ Key must be in 'namespace.subkey' form "
356 f"(e.g. hub.url, domain.ticks_per_beat), got: {sanitize_display(key)!r}",
357 file=sys.stderr,
358 )
359 raise SystemExit(ExitCode.USER_ERROR)
360
361 root = find_repo_root()
362 value = get_config_value(key, root)
363
364 if value is None:
365 print(f"# {sanitize_display(key)} is not set", file=sys.stderr)
366 raise SystemExit(ExitCode.USER_ERROR)
367
368 if json_out:
369 print(json.dumps(_GetJson(**make_envelope(elapsed), key=key, value=value)))
370 return
371
372 print(value)
373
374 # ── run_set ───────────────────────────────────────────────────────────────────
375
376 def run_set(args: argparse.Namespace) -> None:
377 """Set a config value by dotted key.
378
379 Key must be in ``namespace.subkey`` dotted form. For credentials use
380 ``muse auth register``; for remotes use ``muse remote add``. Diagnostics
381 go to stderr; ``--json`` emits the result to stdout.
382
383 Agent quickstart
384 ----------------
385 ::
386
387 muse config set hub.url https://musehub.ai --json
388 muse config set domain.ticks_per_beat 480 --json
389 muse config set limits.max_walk_commits 50000 --json
390
391 JSON fields
392 -----------
393 status ``"ok"`` on success.
394 key The key written.
395 value The value written.
396
397 Exit codes
398 ----------
399 0 Value written successfully.
400 1 Bad key format, blocked namespace, or validation error.
401 2 Not inside a Muse repository.
402 """
403 elapsed = start_timer()
404 key: str = args.key
405 value: str = args.value
406 json_out: bool = args.json_out
407
408 # ── Key format validation ─────────────────────────────────────────────────
409 if "." not in key:
410 print(
411 f"❌ Key must be in 'namespace.subkey' form "
412 f"(e.g. hub.url, domain.ticks_per_beat), got: {sanitize_display(key)!r}",
413 file=sys.stderr,
414 )
415 raise SystemExit(ExitCode.USER_ERROR)
416
417 root = find_repo_root()
418 try:
419 set_config_value(key, value, root)
420 except ValueError as exc:
421 print(f"❌ {sanitize_display(str(exc))}", file=sys.stderr)
422 raise SystemExit(ExitCode.USER_ERROR) from exc
423
424 if json_out:
425 print(json.dumps(_SetJson(**make_envelope(elapsed), status="ok", key=key, value=value)))
426 else:
427 print(
428 f"βœ… {sanitize_display(key)} = {sanitize_display(value)!r}",
429 file=sys.stderr,
430 )
431
432 # ── run_edit ──────────────────────────────────────────────────────────────────
433
434 def run_edit(args: argparse.Namespace) -> None:
435 """Open ``.muse/config.toml`` in ``$VISUAL``, ``$EDITOR``, or ``vi``.
436
437 Editor resolution order:
438 1. ``$VISUAL`` β€” conventional for visual editors (e.g. ``code --wait``)
439 2. ``$EDITOR`` β€” conventional for terminal editors (e.g. ``nano``)
440 3. ``vi`` β€” hard fallback when neither variable is set
441
442 Multi-word editor commands (e.g. ``VISUAL='code --wait'``) are parsed
443 with :func:`shlex.split` and passed to :func:`subprocess.run` as a list
444 so no shell is invoked β€” shell injection via a crafted ``$EDITOR`` value
445 is not possible.
446
447 If ``.muse/config.toml`` does not exist it is created empty before the
448 editor opens. This lets users configure a fresh repository without first
449 running ``muse config set``.
450
451 All error messages go to stderr.
452
453 Note: this command opens an interactive editor and is intended for human
454 use. Agents should use ``muse config set`` / ``muse config get`` for
455 programmatic config access.
456
457 Exit codes:
458 0 Editor exited successfully.
459 1 Editor not found, or editor exited with a non-zero code.
460 2 Not inside a Muse repository.
461 """
462 root = find_repo_root()
463 if root is None:
464 print("❌ Not inside a Muse repository.", file=sys.stderr)
465 raise SystemExit(ExitCode.REPO_NOT_FOUND)
466
467 config_path = config_path_for_editor(root)
468 if not config_path.is_file():
469 # Create an empty config so the editor opens a valid (if empty) file.
470 config_path.parent.mkdir(parents=True, exist_ok=True)
471 config_path.write_text("")
472 print(
473 f"ℹ️ Created {sanitize_display(str(config_path))}",
474 file=sys.stderr,
475 )
476
477 raw_editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "vi"
478 try:
479 editor_cmd = shlex.split(raw_editor)
480 except ValueError as exc:
481 print(
482 f"❌ Could not parse editor command {sanitize_display(raw_editor)!r}: {exc}",
483 file=sys.stderr,
484 )
485 raise SystemExit(ExitCode.USER_ERROR) from exc
486
487 try:
488 subprocess.run(editor_cmd + [str(config_path)], check=True)
489 except FileNotFoundError:
490 print(
491 f"❌ Editor not found: {sanitize_display(raw_editor)!r}",
492 file=sys.stderr,
493 )
494 raise SystemExit(ExitCode.USER_ERROR)
495 except subprocess.CalledProcessError as exc:
496 print(f"❌ Editor exited with code {exc.returncode}", file=sys.stderr)
497 raise SystemExit(ExitCode.USER_ERROR)