gabriel / muse public
config_cmd.py python
222 lines 6.2 KB
e8c4265e feat(hardening): final sweep — security, performance, API consistency, docs Gabriel Cardona <gabriel@tellurstori.com> 2d 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
15 Blocked via ``muse config set``
16 ---------------------------------
17 - ``auth.*`` — use ``muse auth login``
18 - ``remotes.*`` — use ``muse remote add/remove``
19
20 Output format
21 -------------
22 ``muse config show`` emits TOML by default (human-readable). Pass
23 ``--json`` for machine-readable output — no credentials are ever included.
24
25 Examples
26 --------
27 ::
28
29 muse config show
30 muse config show --json
31 muse config get user.name
32 muse config set user.name "Alice"
33 muse config set user.type agent
34 muse config set domain.ticks_per_beat 480
35 muse config edit
36 """
37
38 from __future__ import annotations
39
40 import json
41 import logging
42 import os
43 import subprocess
44 import sys
45
46 import typer
47
48 from muse.cli.config import (
49 config_as_dict,
50 config_path_for_editor,
51 get_config_value,
52 set_config_value,
53 )
54 from muse.core.errors import ExitCode
55 from muse.core.repo import find_repo_root
56
57 logger = logging.getLogger(__name__)
58
59 app = typer.Typer(no_args_is_help=True)
60
61
62 # ---------------------------------------------------------------------------
63 # Commands
64 # ---------------------------------------------------------------------------
65
66
67 @app.command()
68 def show(
69 json_output: bool = typer.Option(
70 False,
71 "--json",
72 help="Emit JSON instead of TOML.",
73 ),
74 fmt: str = typer.Option("text", "--format", "-f", help="Output format: text or json (alias for --json)."),
75 ) -> None:
76 """Display the current repository configuration.
77
78 Output is TOML by default. Use ``--json`` or ``--format json`` for
79 agent-friendly output. Credentials are never included regardless of format.
80
81 JSON payload (top-level keys present only when set)::
82
83 {
84 "user": {"name": "...", "email": "...", "type": "human|agent"},
85 "hub": {"url": "https://musehub.ai"},
86 "remotes": {"origin": "https://..."},
87 "domain": {"ticks_per_beat": "480"}
88 }
89 """
90 if fmt == "json":
91 json_output = True
92 elif fmt != "text":
93 from muse.core.validation import sanitize_display
94 typer.echo(f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.", err=True)
95 raise typer.Exit(code=ExitCode.USER_ERROR)
96
97 root = find_repo_root()
98
99 data = config_as_dict(root)
100
101 if json_output:
102 typer.echo(json.dumps(data, indent=2))
103 return
104
105 # Render as TOML-like display
106 if not data:
107 typer.echo("# No configuration set.")
108 return
109
110 user = data.get("user")
111 if user:
112 typer.echo("[user]")
113 for key, val in sorted(user.items()):
114 typer.echo(f'{key} = "{val}"')
115 typer.echo("")
116
117 hub = data.get("hub")
118 if hub:
119 typer.echo("[hub]")
120 for key, val in sorted(hub.items()):
121 typer.echo(f'{key} = "{val}"')
122 typer.echo("")
123
124 remotes = data.get("remotes")
125 if remotes:
126 for remote_name, remote_url in sorted(remotes.items()):
127 typer.echo(f"[remotes.{remote_name}]")
128 typer.echo(f'url = "{remote_url}"')
129 typer.echo("")
130
131 domain = data.get("domain")
132 if domain:
133 typer.echo("[domain]")
134 for key, val in sorted(domain.items()):
135 typer.echo(f'{key} = "{val}"')
136 typer.echo("")
137
138
139 @app.command()
140 def get(
141 key: str = typer.Argument(
142 ...,
143 metavar="KEY",
144 help="Dotted key to read (e.g. user.name, hub.url, domain.ticks_per_beat).",
145 ),
146 ) -> None:
147 """Print the value of a single config key.
148
149 Exits non-zero when the key is not set, so agents can branch::
150
151 VALUE=$(muse config get user.type) || echo "not set"
152 """
153 root = find_repo_root()
154 value = get_config_value(key, root)
155
156 if value is None:
157 typer.echo(f"# {key} is not set", err=True)
158 raise typer.Exit(code=ExitCode.USER_ERROR)
159
160 typer.echo(value)
161
162
163 @app.command()
164 def set( # noqa: A001
165 key: str = typer.Argument(
166 ...,
167 metavar="KEY",
168 help="Dotted key to set (e.g. user.name, domain.ticks_per_beat).",
169 ),
170 value: str = typer.Argument(
171 ...,
172 metavar="VALUE",
173 help="New value (always stored as a string).",
174 ),
175 ) -> None:
176 """Set a config value by dotted key.
177
178 Examples::
179
180 muse config set user.name "Alice"
181 muse config set user.type agent
182 muse config set hub.url https://musehub.ai
183 muse config set domain.ticks_per_beat 480
184
185 For credentials, use ``muse auth login``.
186 For remotes, use ``muse remote add``.
187 """
188 root = find_repo_root()
189 try:
190 set_config_value(key, value, root)
191 except ValueError as exc:
192 typer.echo(f"❌ {exc}")
193 raise typer.Exit(code=ExitCode.USER_ERROR) from exc
194
195 typer.echo(f"✅ {key} = {value!r}")
196
197
198 @app.command()
199 def edit() -> None:
200 """Open ``.muse/config.toml`` in ``$EDITOR`` or ``$VISUAL``.
201
202 Falls back to ``vi`` when neither environment variable is set.
203 """
204 root = find_repo_root()
205 if root is None:
206 typer.echo("❌ Not inside a Muse repository.")
207 raise typer.Exit(code=ExitCode.REPO_NOT_FOUND)
208
209 config_path = config_path_for_editor(root)
210 if not config_path.is_file():
211 typer.echo(f"❌ Config file not found: {config_path}")
212 raise typer.Exit(code=ExitCode.USER_ERROR)
213
214 editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "vi"
215 try:
216 subprocess.run([editor, str(config_path)], check=True)
217 except FileNotFoundError:
218 typer.echo(f"❌ Editor not found: {editor!r}")
219 raise typer.Exit(code=ExitCode.USER_ERROR)
220 except subprocess.CalledProcessError as exc:
221 typer.echo(f"❌ Editor exited with code {exc.returncode}")
222 raise typer.Exit(code=ExitCode.USER_ERROR)