codex-quota 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ # src/codex_quota/__init__.py
2
+
3
+ """codex-quota: isolated Codex CLI profile management and quota monitoring."""
4
+
5
+ __version__ = "1.0.0"
@@ -0,0 +1,3 @@
1
+ from .cli import app
2
+
3
+ app()
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import subprocess
5
+ import time
6
+ from typing import Any
7
+
8
+ from . import __version__
9
+ from .codex import codex_env, find_codex
10
+ from .errors import AppServerError
11
+ from .filesystem import ensure_private_dir
12
+ from .models import Profile, RateLimits, WindowLimit
13
+
14
+
15
+ def _window(data: object) -> WindowLimit | None:
16
+ if not isinstance(data, dict):
17
+ return None
18
+ return WindowLimit(
19
+ used_percent=int(data.get("usedPercent", 0)),
20
+ window_duration_mins=int(data.get("windowDurationMins", 0)),
21
+ resets_at=data.get("resetsAt"),
22
+ raw=data,
23
+ )
24
+
25
+
26
+ def decode_rate_limits(payload: dict[str, Any]) -> RateLimits:
27
+ rate_limits = payload.get("rateLimits") or payload.get("result", {}).get("rateLimits")
28
+ if not isinstance(rate_limits, dict):
29
+ raise AppServerError(f"Unexpected rate limit response: {payload}")
30
+
31
+ credits_data = rate_limits.get("credits")
32
+ return RateLimits(
33
+ primary=_window(rate_limits.get("primary")),
34
+ secondary=_window(rate_limits.get("secondary")),
35
+ plan_type=rate_limits.get("planType"),
36
+ rate_limit_reached_type=rate_limits.get("rateLimitReachedType"),
37
+ credits=credits_data if isinstance(credits_data, dict) else {},
38
+ raw=rate_limits,
39
+ )
40
+
41
+
42
+ class AppServerClient:
43
+ def __init__(
44
+ self,
45
+ profile: Profile,
46
+ *,
47
+ codex_bin: str = "codex",
48
+ timeout: float = 30.0,
49
+ ) -> None:
50
+ self.profile = profile
51
+ self.codex_bin = codex_bin
52
+ self.timeout = timeout
53
+
54
+ def read_rate_limits(self) -> RateLimits:
55
+ binary = find_codex(self.codex_bin)
56
+ ensure_private_dir(self.profile.codex_home, parents=True, exist_ok=True)
57
+ with subprocess.Popen(
58
+ [binary, "app-server", "--stdio"],
59
+ stdin=subprocess.PIPE,
60
+ stdout=subprocess.PIPE,
61
+ stderr=subprocess.PIPE,
62
+ text=True,
63
+ env=codex_env(self.profile),
64
+ ) as proc:
65
+ stdin = proc.stdin
66
+ stdout = proc.stdout
67
+ stderr = proc.stderr
68
+ if stdin is None or stdout is None or stderr is None:
69
+ raise AppServerError("codex app-server stdio pipes were not available")
70
+
71
+ def send(obj: dict[str, Any]) -> None:
72
+ stdin.write(json.dumps(obj) + "\n")
73
+ stdin.flush()
74
+
75
+ try:
76
+ request_id = 1
77
+ send(
78
+ {
79
+ "jsonrpc": "2.0",
80
+ "id": request_id,
81
+ "method": "initialize",
82
+ "params": {
83
+ "clientInfo": {
84
+ "name": "codex-quota",
85
+ "version": __version__,
86
+ },
87
+ "capabilities": {"experimentalApi": True},
88
+ },
89
+ }
90
+ )
91
+
92
+ deadline = time.monotonic() + self.timeout
93
+ initialized = False
94
+
95
+ while time.monotonic() < deadline:
96
+ line = stdout.readline()
97
+
98
+ if not line:
99
+ if proc.poll() is not None:
100
+ stderr_output = stderr.read()
101
+ raise AppServerError(
102
+ stderr_output.strip() or "codex app-server exited early"
103
+ )
104
+ time.sleep(0.02)
105
+ continue
106
+
107
+ try:
108
+ msg = json.loads(line)
109
+ except json.JSONDecodeError:
110
+ continue
111
+
112
+ if not isinstance(msg, dict):
113
+ continue
114
+
115
+ if msg.get("error"):
116
+ raise AppServerError(json.dumps(msg["error"]))
117
+
118
+ if msg.get("id") == 1 and not initialized:
119
+ initialized = True
120
+ send({"jsonrpc": "2.0", "method": "initialized", "params": {}})
121
+ request_id = 2
122
+ send(
123
+ {
124
+ "jsonrpc": "2.0",
125
+ "id": request_id,
126
+ "method": "account/rateLimits/read",
127
+ "params": {},
128
+ }
129
+ )
130
+ continue
131
+
132
+ if msg.get("id") == request_id:
133
+ result = msg.get("result")
134
+ if not isinstance(result, dict):
135
+ raise AppServerError(f"Malformed JSON-RPC response: {msg}")
136
+ return decode_rate_limits(result)
137
+
138
+ raise AppServerError("Timed out waiting for Codex app-server rate limits")
139
+ finally:
140
+ proc.terminate()
141
+ try:
142
+ proc.wait(timeout=2)
143
+ except subprocess.TimeoutExpired:
144
+ proc.kill()
145
+
146
+
147
+ def read_rate_limits(
148
+ profile: Profile,
149
+ codex_bin: str = "codex",
150
+ timeout: float = 30.0,
151
+ ) -> RateLimits:
152
+ return AppServerClient(profile, codex_bin=codex_bin, timeout=timeout).read_rate_limits()
codex_quota/cli.py ADDED
@@ -0,0 +1,308 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from typing import Annotated, Any
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from .codex import codex_version, simple_exec
11
+ from .codex import login as codex_login
12
+ from .config import ensure_config, load_config
13
+ from .errors import CodexUsageError, ProfileNotFoundError
14
+ from .models import AppConfig, ProfileStatus
15
+ from .paths import default_paths
16
+ from .profiles import add_profile, discover_profiles, ensure_profiles_dir, get_profile, profile_path
17
+ from .profiles import remove_profile as remove_profile_dir
18
+ from .render import render_profile_table, render_status_table
19
+ from .service import check_failed, collect_statuses
20
+ from .tui import run_fullscreen_tui
21
+
22
+ app = typer.Typer(no_args_is_help=True, help="Manage Codex CLI profiles and quota usage.")
23
+ profile_app = typer.Typer(no_args_is_help=True, help="Manage isolated Codex profiles.")
24
+ app.add_typer(profile_app, name="profile")
25
+ console = Console()
26
+
27
+
28
+ def _config() -> AppConfig:
29
+ return load_config()
30
+
31
+
32
+ def _print_error(exc: Exception) -> None:
33
+ console.print(f"[red]{exc}[/red]")
34
+
35
+
36
+ def _window_payload(window: Any) -> dict[str, Any] | None:
37
+ if window is None:
38
+ return None
39
+ return {
40
+ "used_percent": window.used_percent,
41
+ "remaining_percent": window.remaining_percent,
42
+ "window_duration_mins": window.window_duration_mins,
43
+ "resets_at": window.resets_at,
44
+ }
45
+
46
+
47
+ def _rate_limits_payload(
48
+ status: ProfileStatus,
49
+ *,
50
+ include_raw: bool = False,
51
+ ) -> dict[str, Any] | None:
52
+ limits = status.rate_limits
53
+ if limits is None:
54
+ return None
55
+
56
+ payload: dict[str, Any] = {
57
+ "blocked": limits.is_blocked,
58
+ "rate_limit_reached_type": limits.rate_limit_reached_type,
59
+ "primary": _window_payload(limits.primary),
60
+ "secondary": _window_payload(limits.secondary),
61
+ }
62
+ if include_raw:
63
+ payload["plan_type"] = limits.plan_type
64
+ payload["credits"] = limits.credits
65
+ payload["raw"] = limits.raw
66
+ return payload
67
+
68
+
69
+ def _status_payload(
70
+ statuses: list[ProfileStatus],
71
+ *,
72
+ include_path: bool = False,
73
+ include_raw: bool = False,
74
+ ) -> list[dict[str, Any]]:
75
+ return [
76
+ {
77
+ "profile": item.profile.name,
78
+ "auth_ok": item.auth_ok,
79
+ "codex_ok": item.codex_ok,
80
+ "ok": item.ok,
81
+ "error": item.error,
82
+ **({"path": str(item.profile.codex_home)} if include_path else {}),
83
+ "rate_limits": _rate_limits_payload(item, include_raw=include_raw),
84
+ }
85
+ for item in statuses
86
+ ]
87
+
88
+
89
+ @app.callback()
90
+ def main() -> None:
91
+ """Manage isolated Codex profiles and inspect quota usage."""
92
+
93
+
94
+ @app.command()
95
+ def init(
96
+ overwrite_config: Annotated[bool, typer.Option("--overwrite-config")] = False,
97
+ verify: Annotated[bool, typer.Option("--verify", "--check")] = False,
98
+ ) -> None:
99
+ """Bootstrap directories and validate the local Codex environment."""
100
+ try:
101
+ config = ensure_config(overwrite=overwrite_config)
102
+ ensure_profiles_dir(config)
103
+ profiles = discover_profiles(config)
104
+
105
+ paths = default_paths()
106
+ console.print(f"Config file: {paths.config_file}")
107
+ console.print(f"Profiles dir: {config.profiles_dir}")
108
+ console.print(render_profile_table(profiles))
109
+
110
+ try:
111
+ console.print(f"Codex: {codex_version(config.codex_bin)}")
112
+ except CodexUsageError as exc:
113
+ console.print(f"[yellow]{exc}[/yellow]")
114
+ if verify:
115
+ raise typer.Exit(2) from exc
116
+
117
+ if verify:
118
+ statuses = collect_statuses(config)
119
+ console.print(render_status_table(statuses))
120
+ raise typer.Exit(2 if check_failed(statuses) else 0)
121
+ except CodexUsageError as exc:
122
+ _print_error(exc)
123
+ raise typer.Exit(2) from exc
124
+
125
+
126
+ @profile_app.command("list")
127
+ def profile_list() -> None:
128
+ """List profiles discovered under profiles_dir."""
129
+ try:
130
+ console.print(render_profile_table(discover_profiles(_config())))
131
+ except CodexUsageError as exc:
132
+ _print_error(exc)
133
+ raise typer.Exit(2) from exc
134
+
135
+
136
+ @profile_app.command("add")
137
+ def profile_add(
138
+ name: Annotated[str, typer.Argument(help="Profile name")],
139
+ exist_ok: Annotated[bool, typer.Option("--exist-ok", "--force")] = False,
140
+ run_login: Annotated[bool, typer.Option("--login")] = False,
141
+ device_auth: Annotated[
142
+ bool,
143
+ typer.Option("--device-auth", help="Pass --device-auth to codex login."),
144
+ ] = False,
145
+ ) -> None:
146
+ """Create a new isolated profile directory."""
147
+ try:
148
+ if device_auth and not run_login:
149
+ raise CodexUsageError("--device-auth requires --login")
150
+ config = _config()
151
+ profile = add_profile(config, name, exist_ok=exist_ok)
152
+ console.print(f"Created profile: {profile.name}")
153
+ console.print(str(profile.codex_home))
154
+
155
+ if run_login:
156
+ raise typer.Exit(codex_login(profile, config.codex_bin, device_auth=device_auth))
157
+ except CodexUsageError as exc:
158
+ _print_error(exc)
159
+ raise typer.Exit(2) from exc
160
+
161
+
162
+ @profile_app.command("remove")
163
+ def profile_remove(
164
+ name: Annotated[str, typer.Argument(help="Profile name")],
165
+ yes: Annotated[bool, typer.Option("--yes", "-y")] = False,
166
+ ) -> None:
167
+ """Remove a profile directory after safety checks."""
168
+ try:
169
+ config = _config()
170
+ profile = get_profile(config, name)
171
+
172
+ if profile.has_auth:
173
+ console.print("[yellow]Warning: auth.json exists in this profile.[/yellow]")
174
+
175
+ if not yes:
176
+ confirmed = typer.confirm(f"Delete profile '{name}' at {profile.codex_home}?")
177
+ if not confirmed:
178
+ raise typer.Exit(1)
179
+
180
+ removed = remove_profile_dir(config, name)
181
+ console.print(f"Removed profile: {removed.name}")
182
+ except CodexUsageError as exc:
183
+ _print_error(exc)
184
+ raise typer.Exit(2) from exc
185
+
186
+
187
+ @profile_app.command("path")
188
+ def profile_path_cmd(name: Annotated[str, typer.Argument(help="Profile name")]) -> None:
189
+ """Print the CODEX_HOME path for scripting."""
190
+ try:
191
+ typer.echo(str(profile_path(_config(), name)))
192
+ except CodexUsageError as exc:
193
+ _print_error(exc)
194
+ raise typer.Exit(2) from exc
195
+
196
+
197
+ @app.command()
198
+ def login(
199
+ profile_name: Annotated[str, typer.Argument(help="Profile name")],
200
+ create: Annotated[bool, typer.Option("--create")] = False,
201
+ device_auth: Annotated[
202
+ bool,
203
+ typer.Option("--device-auth", help="Pass --device-auth to codex login."),
204
+ ] = False,
205
+ ) -> None:
206
+ """Run the official Codex login flow for an existing profile."""
207
+ try:
208
+ config = _config()
209
+ profile = (
210
+ add_profile(config, profile_name, exist_ok=True)
211
+ if create
212
+ else get_profile(config, profile_name)
213
+ )
214
+ raise typer.Exit(codex_login(profile, config.codex_bin, device_auth=device_auth))
215
+ except CodexUsageError as exc:
216
+ _print_error(exc)
217
+ raise typer.Exit(2) from exc
218
+
219
+
220
+ @app.command()
221
+ def status(
222
+ profile_name: Annotated[str | None, typer.Argument(help="Optional profile name")] = None,
223
+ json_out: Annotated[bool, typer.Option("--json", help="Print machine-readable JSON.")] = False,
224
+ json_paths: Annotated[
225
+ bool,
226
+ typer.Option("--json-paths", help="Include absolute profile paths in JSON output."),
227
+ ] = False,
228
+ json_raw: Annotated[
229
+ bool,
230
+ typer.Option("--json-raw", help="Include raw rate-limit payload in JSON output."),
231
+ ] = False,
232
+ ) -> None:
233
+ """Read quota status for one or all discovered profiles."""
234
+ try:
235
+ statuses = collect_statuses(_config(), profile_name)
236
+ if json_out:
237
+ console.print_json(
238
+ json.dumps(_status_payload(statuses, include_path=json_paths, include_raw=json_raw))
239
+ )
240
+ else:
241
+ console.print(render_status_table(statuses))
242
+ except ProfileNotFoundError as exc:
243
+ _print_error(exc)
244
+ raise typer.Exit(2) from exc
245
+ except CodexUsageError as exc:
246
+ _print_error(exc)
247
+ raise typer.Exit(2) from exc
248
+
249
+
250
+ @app.command()
251
+ def check(
252
+ profile_name: Annotated[str | None, typer.Argument(help="Optional profile name")] = None,
253
+ ) -> None:
254
+ """Exit non-zero if any selected profile has auth, quota, or Codex problems."""
255
+ try:
256
+ statuses = collect_statuses(_config(), profile_name)
257
+ console.print(render_status_table(statuses))
258
+ raise typer.Exit(2 if check_failed(statuses) else 0)
259
+ except CodexUsageError as exc:
260
+ _print_error(exc)
261
+ raise typer.Exit(2) from exc
262
+
263
+
264
+ @app.command("exec")
265
+ def exec_prompt(
266
+ profile_name: Annotated[str, typer.Argument(help="Profile name")],
267
+ prompt: Annotated[str, typer.Argument(help="Prompt to send")],
268
+ raw: Annotated[bool, typer.Option("--raw", help="Do not force Codex JSON output.")] = False,
269
+ ) -> None:
270
+ """Run official codex exec under a selected CODEX_HOME."""
271
+ try:
272
+ config = _config()
273
+ proc = simple_exec(get_profile(config, profile_name), prompt, config, json_output=not raw)
274
+
275
+ if proc.stdout:
276
+ console.print(proc.stdout.rstrip())
277
+ if proc.stderr:
278
+ console.print(proc.stderr.rstrip(), style="yellow")
279
+
280
+ raise typer.Exit(proc.returncode)
281
+ except CodexUsageError as exc:
282
+ _print_error(exc)
283
+ raise typer.Exit(2) from exc
284
+
285
+
286
+ @app.command()
287
+ def tui(refresh: Annotated[float | None, typer.Option("--refresh", "-r")] = None) -> None:
288
+ """Open a Textual live quota view."""
289
+ try:
290
+ config = _config()
291
+ refresh_seconds = refresh or config.refresh_seconds
292
+
293
+ if refresh_seconds <= 0:
294
+ raise typer.BadParameter("--refresh must be greater than zero")
295
+
296
+ if not sys.stdin.isatty() or not console.is_terminal:
297
+ statuses = collect_statuses(config)
298
+ console.print(render_status_table(statuses))
299
+ raise typer.Exit(2 if check_failed(statuses) else 0)
300
+
301
+ run_fullscreen_tui(config, refresh_seconds)
302
+ except CodexUsageError as exc:
303
+ _print_error(exc)
304
+ raise typer.Exit(2) from exc
305
+
306
+
307
+ if __name__ == "__main__":
308
+ app()
codex_quota/codex.py ADDED
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+
7
+ from .errors import CodexCommandError, CodexNotFoundError
8
+ from .filesystem import ensure_private_dir
9
+ from .models import AppConfig, Profile
10
+
11
+
12
+ def find_codex(codex_bin: str = "codex") -> str:
13
+ found = shutil.which(codex_bin)
14
+ if not found:
15
+ raise CodexNotFoundError(
16
+ "Codex CLI was not found. Install the official Codex CLI and ensure "
17
+ "it is available on PATH, or set codex_bin in config.json."
18
+ )
19
+ return found
20
+
21
+
22
+ def codex_env(profile: Profile) -> dict[str, str]:
23
+ return {**os.environ, "CODEX_HOME": str(profile.codex_home)}
24
+
25
+
26
+ def run_codex(
27
+ args: list[str],
28
+ *,
29
+ profile: Profile | None = None,
30
+ codex_bin: str = "codex",
31
+ timeout: float = 60.0,
32
+ check: bool = False,
33
+ ) -> subprocess.CompletedProcess[str]:
34
+ binary = find_codex(codex_bin)
35
+ env = codex_env(profile) if profile else os.environ.copy()
36
+ try:
37
+ proc = subprocess.run(
38
+ [binary, *args],
39
+ text=True,
40
+ capture_output=True,
41
+ env=env,
42
+ timeout=timeout,
43
+ check=False,
44
+ )
45
+ except subprocess.TimeoutExpired as exc:
46
+ raise CodexCommandError(f"codex {' '.join(args)} timed out") from exc
47
+ if check and proc.returncode != 0:
48
+ raise CodexCommandError(
49
+ proc.stderr.strip() or proc.stdout.strip() or "codex command failed"
50
+ )
51
+ return proc
52
+
53
+
54
+ def codex_version(codex_bin: str = "codex") -> str:
55
+ proc = run_codex(["--version"], codex_bin=codex_bin, timeout=10)
56
+ if proc.returncode != 0:
57
+ raise CodexCommandError(proc.stderr.strip() or "Failed to read Codex CLI version")
58
+ return proc.stdout.strip() or proc.stderr.strip() or "unknown"
59
+
60
+
61
+ def login_status(profile: Profile, codex_bin: str = "codex") -> bool:
62
+ if not profile.auth_file.is_file():
63
+ return False
64
+ proc = run_codex(["login", "status"], profile=profile, codex_bin=codex_bin, timeout=20)
65
+ output = f"{proc.stdout}\n{proc.stderr}".lower()
66
+ return proc.returncode == 0 and ("logged in" in output or "authenticated" in output)
67
+
68
+
69
+ def login(
70
+ profile: Profile,
71
+ codex_bin: str = "codex",
72
+ *,
73
+ device_auth: bool = False,
74
+ ) -> int:
75
+ binary = find_codex(codex_bin)
76
+ ensure_private_dir(profile.codex_home, parents=True, exist_ok=True)
77
+ args = [binary, "login"]
78
+ if device_auth:
79
+ args.append("--device-auth")
80
+ return subprocess.call(args, env=codex_env(profile))
81
+
82
+
83
+ def simple_exec(
84
+ profile: Profile,
85
+ prompt: str,
86
+ config: AppConfig,
87
+ *,
88
+ json_output: bool = True,
89
+ timeout: float = 180.0,
90
+ ) -> subprocess.CompletedProcess[str]:
91
+ args = [
92
+ "exec",
93
+ "--skip-git-repo-check",
94
+ "--ephemeral",
95
+ "-m",
96
+ config.default_model,
97
+ "-c",
98
+ f'model_reasoning_effort="{config.reasoning_effort}"',
99
+ ]
100
+ if json_output:
101
+ args.append("--json")
102
+ args.append(prompt)
103
+ return run_codex(args, profile=profile, codex_bin=config.codex_bin, timeout=timeout)
codex_quota/config.py ADDED
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from json import JSONDecodeError
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .errors import ConfigError
9
+ from .filesystem import ensure_private_dir
10
+ from .models import AppConfig
11
+ from .paths import default_paths
12
+
13
+ CONFIG_KEYS = {"profiles_dir", "codex_bin", "default_model", "reasoning_effort", "refresh_seconds"}
14
+
15
+
16
+ def create_default_config() -> AppConfig:
17
+ paths = default_paths()
18
+ return AppConfig(profiles_dir=paths.profiles_dir)
19
+
20
+
21
+ def _as_path(value: object, fallback: Path) -> Path:
22
+ if not isinstance(value, str) or not value.strip():
23
+ return fallback
24
+ return Path(value).expanduser()
25
+
26
+
27
+ def load_config(path: Path | None = None) -> AppConfig:
28
+ paths = default_paths()
29
+ config_file = path or paths.config_file
30
+ if not config_file.exists():
31
+ return create_default_config()
32
+
33
+ try:
34
+ data = json.loads(config_file.read_text(encoding="utf-8"))
35
+ except JSONDecodeError as exc:
36
+ raise ConfigError(f"Malformed config JSON: {config_file}: {exc}") from exc
37
+ except OSError as exc:
38
+ raise ConfigError(f"Could not read config: {config_file}: {exc}") from exc
39
+
40
+ if not isinstance(data, dict):
41
+ raise ConfigError(f"Config must be a JSON object: {config_file}")
42
+
43
+ refresh = data.get("refresh_seconds", 30.0)
44
+ try:
45
+ refresh_seconds = float(refresh)
46
+ except (TypeError, ValueError) as exc:
47
+ raise ConfigError("config refresh_seconds must be a number") from exc
48
+
49
+ extra = {key: value for key, value in data.items() if key not in CONFIG_KEYS}
50
+ return AppConfig(
51
+ profiles_dir=_as_path(data.get("profiles_dir"), paths.profiles_dir),
52
+ codex_bin=str(data.get("codex_bin", "codex")),
53
+ default_model=str(data.get("default_model", "gpt-5.4-mini")),
54
+ reasoning_effort=str(data.get("reasoning_effort", "low")),
55
+ refresh_seconds=refresh_seconds,
56
+ extra=extra,
57
+ )
58
+
59
+
60
+ def config_to_json(config: AppConfig, *, preserve_extra: bool = True) -> dict[str, Any]:
61
+ data: dict[str, Any] = dict(config.extra) if preserve_extra else {}
62
+ data.update(
63
+ {
64
+ "profiles_dir": str(config.profiles_dir),
65
+ "codex_bin": config.codex_bin,
66
+ "default_model": config.default_model,
67
+ "reasoning_effort": config.reasoning_effort,
68
+ "refresh_seconds": config.refresh_seconds,
69
+ }
70
+ )
71
+ return data
72
+
73
+
74
+ def save_config(
75
+ config: AppConfig,
76
+ path: Path | None = None,
77
+ *,
78
+ preserve_extra: bool = True,
79
+ ) -> Path:
80
+ paths = default_paths()
81
+ config_file = path or paths.config_file
82
+ ensure_private_dir(config_file.parent, parents=True, exist_ok=True)
83
+ data = config_to_json(config, preserve_extra=preserve_extra)
84
+ try:
85
+ config_file.write_text(
86
+ json.dumps(data, indent=2, sort_keys=True) + "\n",
87
+ encoding="utf-8",
88
+ )
89
+ except OSError as exc:
90
+ raise ConfigError(f"Could not write config: {config_file}: {exc}") from exc
91
+ return config_file
92
+
93
+
94
+ def ensure_config(path: Path | None = None, *, overwrite: bool = False) -> AppConfig:
95
+ paths = default_paths()
96
+ config_file = path or paths.config_file
97
+ ensure_private_dir(config_file.parent, parents=True, exist_ok=True)
98
+ if config_file.exists() and not overwrite:
99
+ return load_config(config_file)
100
+ config = create_default_config()
101
+ save_config(config, config_file, preserve_extra=False)
102
+ return config