codexopt 0.1.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.
codexopt/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """codexopt package."""
2
+
3
+ __all__ = ["__version__"]
4
+ __version__ = "0.1.0"
codexopt/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
codexopt/applier.py ADDED
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from datetime import timezone
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ def apply_optimization_result(
10
+ optimization_result: dict[str, Any],
11
+ repo_root: Path,
12
+ backup_root: Path,
13
+ dry_run: bool = False,
14
+ ) -> dict[str, Any]:
15
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
16
+ backup_dir = backup_root / timestamp
17
+ applied: list[str] = []
18
+ skipped: list[str] = []
19
+
20
+ if not dry_run:
21
+ backup_dir.mkdir(parents=True, exist_ok=True)
22
+
23
+ for item in optimization_result.get("results", []):
24
+ path = Path(item["path"])
25
+ if item.get("delta", 0.0) <= 0.0:
26
+ skipped.append(f"{path} (no improvement)")
27
+ continue
28
+ if not path.exists():
29
+ skipped.append(f"{path} (missing)")
30
+ continue
31
+
32
+ new_content = str(item.get("best_content", ""))
33
+ current = path.read_text(encoding="utf-8", errors="replace")
34
+ if current == new_content:
35
+ skipped.append(f"{path} (no changes)")
36
+ continue
37
+
38
+ if dry_run:
39
+ applied.append(str(path))
40
+ continue
41
+
42
+ try:
43
+ rel = path.resolve().relative_to(repo_root.resolve())
44
+ except Exception:
45
+ rel = Path(path.name)
46
+ backup_path = backup_dir / rel
47
+ backup_path.parent.mkdir(parents=True, exist_ok=True)
48
+ backup_path.write_text(current, encoding="utf-8")
49
+ path.write_text(new_content, encoding="utf-8")
50
+ applied.append(str(path))
51
+
52
+ return {
53
+ "dry_run": dry_run,
54
+ "backup_dir": str(backup_dir),
55
+ "applied_count": len(applied),
56
+ "skipped_count": len(skipped),
57
+ "applied": applied,
58
+ "skipped": skipped,
59
+ }
60
+
61
+
62
+ def print_apply_summary(result: dict[str, Any]) -> None:
63
+ print(f"dry_run: {result['dry_run']}")
64
+ print(f"applied_count: {result['applied_count']}")
65
+ print(f"skipped_count: {result['skipped_count']}")
66
+ if not result["dry_run"]:
67
+ print(f"backup_dir: {result['backup_dir']}")
68
+ for item in result["applied"]:
69
+ print(f"- applied: {item}")
70
+ for item in result["skipped"]:
71
+ print(f"- skipped: {item}")
codexopt/artifacts.py ADDED
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime
5
+ from datetime import timezone
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ STATE_FILENAME = "state.json"
11
+
12
+
13
+ def ensure_output_dirs(root_dir: Path) -> None:
14
+ (root_dir / "runs").mkdir(parents=True, exist_ok=True)
15
+ (root_dir / "backups").mkdir(parents=True, exist_ok=True)
16
+
17
+
18
+ def load_state(root_dir: Path) -> dict[str, Any]:
19
+ state_path = root_dir / STATE_FILENAME
20
+ if not state_path.exists():
21
+ return {}
22
+ try:
23
+ return json.loads(state_path.read_text(encoding="utf-8"))
24
+ except Exception:
25
+ return {}
26
+
27
+
28
+ def save_state(root_dir: Path, state: dict[str, Any]) -> None:
29
+ state_path = root_dir / STATE_FILENAME
30
+ state_path.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8")
31
+
32
+
33
+ def new_run_dir(root_dir: Path, kind: str) -> tuple[str, Path]:
34
+ ensure_output_dirs(root_dir)
35
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
36
+ run_id = f"{stamp}-{kind}"
37
+ run_dir = root_dir / "runs" / run_id
38
+ run_dir.mkdir(parents=True, exist_ok=False)
39
+ return run_id, run_dir
40
+
41
+
42
+ def write_json(path: Path, data: dict[str, Any]) -> None:
43
+ path.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")
44
+
45
+
46
+ def read_json(path: Path) -> dict[str, Any]:
47
+ return json.loads(path.read_text(encoding="utf-8"))
48
+
49
+
50
+ def set_latest_run(root_dir: Path, state_key: str, run_id: str) -> None:
51
+ state = load_state(root_dir)
52
+ state[state_key] = run_id
53
+ save_state(root_dir, state)
54
+
55
+
56
+ def resolve_run_id(root_dir: Path, state_key: str, explicit_run_id: str | None) -> str | None:
57
+ if explicit_run_id:
58
+ return explicit_run_id
59
+ state = load_state(root_dir)
60
+ value = state.get(state_key)
61
+ if isinstance(value, str):
62
+ return value
63
+ return None
codexopt/benchmark.py ADDED
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .types import FileScore
6
+
7
+
8
+ def _score_agents(entry: dict[str, Any]) -> FileScore:
9
+ score = 1.0
10
+ issues: list[str] = []
11
+ details: dict[str, Any] = {}
12
+ words = int(entry.get("words", 0))
13
+ tokens = int(entry.get("token_estimate", 0))
14
+
15
+ if words < 80:
16
+ score -= 0.2
17
+ issues.append("too_short")
18
+ if words > 2400:
19
+ score -= min(0.35, (words - 2400) / 10000.0)
20
+ issues.append("too_long")
21
+ if tokens > 7000:
22
+ score -= 0.15
23
+ issues.append("token_heavy")
24
+ if "empty_agents" in entry.get("issues", []):
25
+ score -= 0.6
26
+ issues.append("empty")
27
+
28
+ details["words"] = words
29
+ details["token_estimate"] = tokens
30
+ return FileScore(
31
+ path=entry["path"],
32
+ kind="agents",
33
+ score=max(0.0, min(1.0, score)),
34
+ issues=issues,
35
+ details=details,
36
+ )
37
+
38
+
39
+ def _score_skill(entry: dict[str, Any]) -> FileScore:
40
+ score = 1.0
41
+ issues: list[str] = []
42
+ details: dict[str, Any] = {}
43
+ words = int(entry.get("words", 0))
44
+ entry_issues = set(entry.get("issues", []))
45
+
46
+ if "missing_frontmatter" in entry_issues:
47
+ score -= 0.6
48
+ issues.append("missing_frontmatter")
49
+ if "missing_name" in entry_issues:
50
+ score -= 0.2
51
+ issues.append("missing_name")
52
+ if "missing_description" in entry_issues:
53
+ score -= 0.2
54
+ issues.append("missing_description")
55
+ if "name_too_long" in entry_issues:
56
+ score -= 0.15
57
+ issues.append("name_too_long")
58
+ if "description_too_long" in entry_issues:
59
+ score -= 0.15
60
+ issues.append("description_too_long")
61
+ if words < 40:
62
+ score -= 0.15
63
+ issues.append("too_short")
64
+ if words > 1800:
65
+ score -= min(0.25, (words - 1800) / 10000.0)
66
+ issues.append("too_long")
67
+
68
+ details["words"] = words
69
+ details["frontmatter_present"] = entry.get("metadata", {}).get("frontmatter_present", False)
70
+ return FileScore(
71
+ path=entry["path"],
72
+ kind="skill",
73
+ score=max(0.0, min(1.0, score)),
74
+ issues=issues,
75
+ details=details,
76
+ )
77
+
78
+
79
+ def score_entry(entry: dict[str, Any]) -> FileScore:
80
+ kind = entry.get("kind")
81
+ if kind == "agents":
82
+ return _score_agents(entry)
83
+ return _score_skill(entry)
84
+
85
+
86
+ def run_benchmark(scan_result: dict[str, Any]) -> dict[str, Any]:
87
+ scores: list[FileScore] = [score_entry(entry) for entry in scan_result["entries"]]
88
+ if scores:
89
+ overall = sum(item.score for item in scores) / len(scores)
90
+ else:
91
+ overall = 0.0
92
+
93
+ return {
94
+ "counts": scan_result["counts"],
95
+ "overall_score": round(overall, 4),
96
+ "files": [
97
+ {
98
+ "path": item.path,
99
+ "kind": item.kind,
100
+ "score": round(item.score, 4),
101
+ "issues": item.issues,
102
+ "details": item.details,
103
+ }
104
+ for item in scores
105
+ ],
106
+ }
107
+
108
+
109
+ def print_benchmark_summary(result: dict[str, Any]) -> None:
110
+ print(f"overall_score: {result['overall_score']:.4f}")
111
+ for file_result in result["files"]:
112
+ issues = ", ".join(file_result["issues"]) if file_result["issues"] else "ok"
113
+ print(f"- {file_result['kind']}: {file_result['path']}")
114
+ print(f" score={file_result['score']:.4f} issues={issues}")
codexopt/cli.py ADDED
@@ -0,0 +1,233 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .applier import apply_optimization_result
9
+ from .applier import print_apply_summary
10
+ from .artifacts import new_run_dir
11
+ from .artifacts import read_json
12
+ from .artifacts import resolve_run_id
13
+ from .artifacts import set_latest_run
14
+ from .artifacts import write_json
15
+ from .benchmark import print_benchmark_summary
16
+ from .benchmark import run_benchmark
17
+ from .config import DEFAULT_CONFIG_FILENAME
18
+ from .config import load_config
19
+ from .config import write_default_config
20
+ from .optimizer import optimize_entries
21
+ from .optimizer import print_optimization_summary
22
+ from .reporter import build_markdown_report
23
+ from .scanner import print_scan_summary
24
+ from .scanner import scan_project
25
+
26
+
27
+ def _resolve_config(config_path: str | None) -> tuple[Any, Path]:
28
+ path = Path(config_path).resolve() if config_path else None
29
+ return load_config(path)
30
+
31
+
32
+ def cmd_init(args: argparse.Namespace) -> int:
33
+ path = Path(args.path or DEFAULT_CONFIG_FILENAME).resolve()
34
+ write_default_config(path, force=args.force)
35
+ print(f"wrote {path}")
36
+ return 0
37
+
38
+
39
+ def cmd_scan(args: argparse.Namespace) -> int:
40
+ cfg, _cfg_path = _resolve_config(args.config)
41
+ cwd = Path.cwd()
42
+ result = scan_project(cwd, cfg)
43
+
44
+ output_root = Path(cfg.output.root_dir)
45
+ run_id, run_dir = new_run_dir(output_root, "scan")
46
+ write_json(run_dir / "scan.json", result)
47
+ set_latest_run(output_root, "latest_scan_run", run_id)
48
+
49
+ print_scan_summary(result)
50
+ print(f"run_id: {run_id}")
51
+ return 0
52
+
53
+
54
+ def cmd_benchmark(args: argparse.Namespace) -> int:
55
+ cfg, _cfg_path = _resolve_config(args.config)
56
+ cwd = Path.cwd()
57
+ scan_result = scan_project(cwd, cfg)
58
+ benchmark_result = run_benchmark(scan_result)
59
+
60
+ output_root = Path(cfg.output.root_dir)
61
+ run_id, run_dir = new_run_dir(output_root, "benchmark")
62
+ write_json(run_dir / "scan.json", scan_result)
63
+ write_json(run_dir / "benchmark.json", benchmark_result)
64
+ set_latest_run(output_root, "latest_scan_run", run_id)
65
+ set_latest_run(output_root, "latest_benchmark_run", run_id)
66
+
67
+ print_benchmark_summary(benchmark_result)
68
+ print(f"run_id: {run_id}")
69
+ return 0
70
+
71
+
72
+ def _optimize(args: argparse.Namespace, kind: str) -> int:
73
+ cfg, _cfg_path = _resolve_config(args.config)
74
+ cwd = Path.cwd()
75
+
76
+ agents_patterns = args.file if kind == "agents" and args.file else None
77
+ skills_patterns = args.glob if kind == "skill" and args.glob else None
78
+
79
+ scan_result = scan_project(
80
+ cwd=cwd,
81
+ config=cfg,
82
+ agents_files=agents_patterns,
83
+ skills_globs=skills_patterns,
84
+ )
85
+ result = optimize_entries(
86
+ entries=scan_result["entries"],
87
+ kind=kind,
88
+ engine=args.engine or cfg.optimization.engine,
89
+ min_delta=cfg.optimization.min_apply_delta,
90
+ reflection_model=args.reflection_model or cfg.optimization.reflection_model,
91
+ max_metric_calls=args.max_metric_calls or cfg.optimization.max_metric_calls,
92
+ )
93
+
94
+ output_root = Path(cfg.output.root_dir)
95
+ run_label = "optimize-agents" if kind == "agents" else "optimize-skills"
96
+ run_id, run_dir = new_run_dir(output_root, run_label)
97
+ write_json(run_dir / "scan.json", scan_result)
98
+ write_json(run_dir / "optimize.json", result)
99
+ key = "latest_optimize_agents_run" if kind == "agents" else "latest_optimize_skills_run"
100
+ set_latest_run(output_root, key, run_id)
101
+
102
+ print_optimization_summary(result)
103
+ print(f"run_id: {run_id}")
104
+ return 0
105
+
106
+
107
+ def cmd_optimize_agents(args: argparse.Namespace) -> int:
108
+ return _optimize(args, "agents")
109
+
110
+
111
+ def cmd_optimize_skills(args: argparse.Namespace) -> int:
112
+ return _optimize(args, "skill")
113
+
114
+
115
+ def cmd_apply(args: argparse.Namespace) -> int:
116
+ cfg, _cfg_path = _resolve_config(args.config)
117
+ output_root = Path(cfg.output.root_dir)
118
+
119
+ if args.kind == "agents":
120
+ state_key = "latest_optimize_agents_run"
121
+ else:
122
+ state_key = "latest_optimize_skills_run"
123
+
124
+ run_id = resolve_run_id(output_root, state_key, args.run_id)
125
+ if not run_id:
126
+ print("no optimization run found", file=sys.stderr)
127
+ return 2
128
+
129
+ run_dir = output_root / "runs" / run_id
130
+ optimize_path = run_dir / "optimize.json"
131
+ if not optimize_path.exists():
132
+ print(f"missing optimize artifact: {optimize_path}", file=sys.stderr)
133
+ return 2
134
+ optimize_result = read_json(optimize_path)
135
+ apply_result = apply_optimization_result(
136
+ optimization_result=optimize_result,
137
+ repo_root=Path.cwd(),
138
+ backup_root=output_root / "backups",
139
+ dry_run=args.dry_run,
140
+ )
141
+
142
+ apply_run_id, apply_run_dir = new_run_dir(output_root, "apply")
143
+ write_json(apply_run_dir / "apply.json", apply_result)
144
+ set_latest_run(output_root, "latest_apply_run", apply_run_id)
145
+
146
+ print_apply_summary(apply_result)
147
+ print(f"source_run_id: {run_id}")
148
+ print(f"run_id: {apply_run_id}")
149
+ return 0
150
+
151
+
152
+ def cmd_report(args: argparse.Namespace) -> int:
153
+ cfg, _cfg_path = _resolve_config(args.config)
154
+ output_root = Path(cfg.output.root_dir)
155
+ report = build_markdown_report(output_root)
156
+ if args.output:
157
+ out = Path(args.output)
158
+ out.write_text(report, encoding="utf-8")
159
+ print(f"wrote {out}")
160
+ else:
161
+ print(report)
162
+ return 0
163
+
164
+
165
+ def build_parser() -> argparse.ArgumentParser:
166
+ parser = argparse.ArgumentParser(prog="codexopt", description="Optimize Codex AGENTS.md and SKILL.md")
167
+ parser.add_argument("--config", help="Path to codexopt.yaml", default=None)
168
+ sub = parser.add_subparsers(dest="command", required=True)
169
+
170
+ p_init = sub.add_parser("init", help="Write codexopt.yaml template")
171
+ p_init.add_argument("--path", default=None, help="Output config path")
172
+ p_init.add_argument("--force", action="store_true", help="Overwrite if exists")
173
+ p_init.set_defaults(func=cmd_init)
174
+
175
+ p_scan = sub.add_parser("scan", help="Scan AGENTS and SKILL files")
176
+ p_scan.set_defaults(func=cmd_scan)
177
+
178
+ p_bench = sub.add_parser("benchmark", help="Benchmark current instruction assets")
179
+ p_bench.set_defaults(func=cmd_benchmark)
180
+
181
+ p_opt = sub.add_parser("optimize", help="Optimize instruction assets")
182
+ opt_sub = p_opt.add_subparsers(dest="target", required=True)
183
+
184
+ p_opt_agents = opt_sub.add_parser("agents", help="Optimize AGENTS files")
185
+ p_opt_agents.add_argument(
186
+ "--file",
187
+ action="append",
188
+ help="AGENTS glob pattern (can be repeated). Defaults from config.",
189
+ )
190
+ p_opt_agents.add_argument("--engine", choices=["heuristic", "gepa"], default=None)
191
+ p_opt_agents.add_argument("--reflection-model", default=None)
192
+ p_opt_agents.add_argument("--max-metric-calls", type=int, default=None)
193
+ p_opt_agents.set_defaults(func=cmd_optimize_agents)
194
+
195
+ p_opt_skills = opt_sub.add_parser("skills", help="Optimize SKILL files")
196
+ p_opt_skills.add_argument(
197
+ "--glob",
198
+ action="append",
199
+ help="SKILL glob pattern (can be repeated). Defaults from config.",
200
+ )
201
+ p_opt_skills.add_argument("--engine", choices=["heuristic", "gepa"], default=None)
202
+ p_opt_skills.add_argument("--reflection-model", default=None)
203
+ p_opt_skills.add_argument("--max-metric-calls", type=int, default=None)
204
+ p_opt_skills.set_defaults(func=cmd_optimize_skills)
205
+
206
+ p_apply = sub.add_parser("apply", help="Apply best optimized candidates")
207
+ p_apply.add_argument("--kind", choices=["agents", "skills"], default="agents")
208
+ p_apply.add_argument("--run-id", default=None, help="Optimization run id to apply")
209
+ p_apply.add_argument("--dry-run", action="store_true")
210
+ p_apply.set_defaults(func=cmd_apply)
211
+
212
+ p_report = sub.add_parser("report", help="Render markdown report from latest runs")
213
+ p_report.add_argument("--output", default=None, help="Optional output markdown file")
214
+ p_report.set_defaults(func=cmd_report)
215
+
216
+ return parser
217
+
218
+
219
+ def main(argv: list[str] | None = None) -> int:
220
+ parser = build_parser()
221
+ args = parser.parse_args(argv)
222
+ try:
223
+ return int(args.func(args))
224
+ except FileExistsError as exc:
225
+ print(str(exc), file=sys.stderr)
226
+ return 2
227
+ except Exception as exc: # pragma: no cover
228
+ print(f"error: {exc}", file=sys.stderr)
229
+ return 1
230
+
231
+
232
+ if __name__ == "__main__": # pragma: no cover
233
+ raise SystemExit(main())
codexopt/config.py ADDED
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from dataclasses import field
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ try:
9
+ import yaml
10
+ except Exception: # pragma: no cover
11
+ yaml = None
12
+
13
+
14
+ DEFAULT_CONFIG_FILENAME = "codexopt.yaml"
15
+
16
+
17
+ @dataclass
18
+ class TargetsConfig:
19
+ agents_files: list[str] = field(
20
+ default_factory=lambda: ["AGENTS.md", "**/AGENTS.md", "**/AGENTS.override.md"]
21
+ )
22
+ skills_globs: list[str] = field(
23
+ default_factory=lambda: [".codex/skills/**/SKILL.md", "**/.codex/skills/**/SKILL.md"]
24
+ )
25
+ exclude_globs: list[str] = field(
26
+ default_factory=lambda: [
27
+ ".git/**",
28
+ ".codexopt/**",
29
+ ".venv/**",
30
+ "node_modules/**",
31
+ "reference/**",
32
+ ]
33
+ )
34
+
35
+
36
+ @dataclass
37
+ class OutputConfig:
38
+ root_dir: str = ".codexopt"
39
+
40
+
41
+ @dataclass
42
+ class OptimizationConfig:
43
+ engine: str = "heuristic"
44
+ min_apply_delta: float = 0.01
45
+ max_metric_calls: int = 60
46
+ reflection_model: str | None = None
47
+
48
+
49
+ @dataclass
50
+ class CodexOptConfig:
51
+ version: int = 1
52
+ targets: TargetsConfig = field(default_factory=TargetsConfig)
53
+ output: OutputConfig = field(default_factory=OutputConfig)
54
+ optimization: OptimizationConfig = field(default_factory=OptimizationConfig)
55
+
56
+
57
+ def default_config() -> CodexOptConfig:
58
+ return CodexOptConfig()
59
+
60
+
61
+ def _merge_dict(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
62
+ out = dict(base)
63
+ for key, value in patch.items():
64
+ if isinstance(value, dict) and isinstance(out.get(key), dict):
65
+ out[key] = _merge_dict(out[key], value)
66
+ else:
67
+ out[key] = value
68
+ return out
69
+
70
+
71
+ def _config_to_dict(cfg: CodexOptConfig) -> dict[str, Any]:
72
+ return {
73
+ "version": cfg.version,
74
+ "targets": {
75
+ "agents_files": cfg.targets.agents_files,
76
+ "skills_globs": cfg.targets.skills_globs,
77
+ "exclude_globs": cfg.targets.exclude_globs,
78
+ },
79
+ "output": {"root_dir": cfg.output.root_dir},
80
+ "optimization": {
81
+ "engine": cfg.optimization.engine,
82
+ "min_apply_delta": cfg.optimization.min_apply_delta,
83
+ "max_metric_calls": cfg.optimization.max_metric_calls,
84
+ "reflection_model": cfg.optimization.reflection_model,
85
+ },
86
+ }
87
+
88
+
89
+ def _dict_to_config(data: dict[str, Any]) -> CodexOptConfig:
90
+ targets = data.get("targets", {})
91
+ output = data.get("output", {})
92
+ optimization = data.get("optimization", {})
93
+ return CodexOptConfig(
94
+ version=int(data.get("version", 1)),
95
+ targets=TargetsConfig(
96
+ agents_files=list(targets.get("agents_files", TargetsConfig().agents_files)),
97
+ skills_globs=list(targets.get("skills_globs", TargetsConfig().skills_globs)),
98
+ exclude_globs=list(targets.get("exclude_globs", TargetsConfig().exclude_globs)),
99
+ ),
100
+ output=OutputConfig(root_dir=str(output.get("root_dir", ".codexopt"))),
101
+ optimization=OptimizationConfig(
102
+ engine=str(optimization.get("engine", "heuristic")),
103
+ min_apply_delta=float(optimization.get("min_apply_delta", 0.01)),
104
+ max_metric_calls=int(optimization.get("max_metric_calls", 60)),
105
+ reflection_model=optimization.get("reflection_model"),
106
+ ),
107
+ )
108
+
109
+
110
+ def load_config(path: Path | None = None) -> tuple[CodexOptConfig, Path]:
111
+ cfg_path = path or Path.cwd() / DEFAULT_CONFIG_FILENAME
112
+ cfg = default_config()
113
+ if not cfg_path.exists():
114
+ return cfg, cfg_path
115
+ if yaml is None:
116
+ raise RuntimeError("PyYAML is required to read codexopt.yaml")
117
+
118
+ raw = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
119
+ merged = _merge_dict(_config_to_dict(cfg), raw)
120
+ return _dict_to_config(merged), cfg_path
121
+
122
+
123
+ def write_default_config(path: Path, force: bool = False) -> None:
124
+ if path.exists() and not force:
125
+ raise FileExistsError(f"{path} already exists; pass --force to overwrite")
126
+ if yaml is None:
127
+ raise RuntimeError("PyYAML is required to write codexopt.yaml")
128
+
129
+ data = _config_to_dict(default_config())
130
+ rendered = yaml.safe_dump(data, sort_keys=False)
131
+ path.write_text(rendered, encoding="utf-8")