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