diff --git a/cron/jobs.py b/cron/jobs.py index 6c5222110ed6..f41a877fd0d1 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -865,13 +865,13 @@ def load_jobs() -> List[Dict[str, Any]]: _strict_retry = False # track whether we used the strict=False fallback try: - with open(jobs_file, 'r', encoding='utf-8') as f: + with open(jobs_file, 'r', encoding='utf-8-sig') as f: data = json.load(f) except json.JSONDecodeError: # Retry with strict=False to handle bare control chars in string values _strict_retry = True try: - with open(jobs_file, 'r', encoding='utf-8') as f: + with open(jobs_file, 'r', encoding='utf-8-sig') as f: data = json.loads(f.read(), strict=False) except Exception as e: logger.error("Failed to auto-repair jobs.json: %s", e) diff --git a/hermes_cli/dump.py b/hermes_cli/dump.py index 8f992e928a6c..5c1a8398fc45 100644 --- a/hermes_cli/dump.py +++ b/hermes_cli/dump.py @@ -167,7 +167,7 @@ def _cron_summary(hermes_home: Path) -> str: if not jobs_file.exists(): return "0" try: - with open(jobs_file, encoding="utf-8") as f: + with open(jobs_file, encoding="utf-8-sig") as f: data = json.load(f) jobs = data.get("jobs", []) active = sum(1 for j in jobs if j.get("enabled", True)) diff --git a/hermes_cli/status.py b/hermes_cli/status.py index dc58acaa678b..f2ce8f60c7e3 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -529,7 +529,7 @@ def _resolve_env(env_ref) -> str: if jobs_file.exists(): import json try: - with open(jobs_file, encoding="utf-8") as f: + with open(jobs_file, encoding="utf-8-sig") as f: data = json.load(f) jobs = data.get("jobs", []) enabled_jobs = [j for j in jobs if j.get("enabled", True)] diff --git a/tests/cron/test_jobs.py b/tests/cron/test_jobs.py index f965428ee1d5..1671644307dc 100644 --- a/tests/cron/test_jobs.py +++ b/tests/cron/test_jobs.py @@ -257,6 +257,13 @@ def test_list_jobs(self, tmp_cron_dir): jobs = list_jobs() assert len(jobs) == 2 + def test_load_jobs_reads_utf8_bom_store(self, tmp_cron_dir): + jobs_file = tmp_cron_dir / "cron" / "jobs.json" + jobs_file.parent.mkdir(parents=True) + jobs_file.write_bytes(b'\xef\xbb\xbf{"jobs": [{"id": "bom-job", "enabled": true}]}') + + assert load_jobs() == [{"id": "bom-job", "enabled": True}] + def test_list_jobs_normalizes_partial_legacy_records(self, tmp_cron_dir): save_jobs([ { diff --git a/tests/hermes_cli/test_dump_env_visibility.py b/tests/hermes_cli/test_dump_env_visibility.py index c8ffc61cbdba..2d4b793fcbc0 100644 --- a/tests/hermes_cli/test_dump_env_visibility.py +++ b/tests/hermes_cli/test_dump_env_visibility.py @@ -75,3 +75,13 @@ def test_dump_leaves_unset_key_untouched(monkeypatch, capsys, tmp_path): line = _api_key_line(capsys.readouterr().out, "tavily") assert "not set" in line assert "shell only" not in line + + +def test_cron_summary_reads_utf8_bom_jobs_file(tmp_path): + from hermes_cli.dump import _cron_summary + + jobs_file = tmp_path / "cron" / "jobs.json" + jobs_file.parent.mkdir(parents=True) + jobs_file.write_bytes(b'\xef\xbb\xbf{"jobs": [{"id": "bom-job", "enabled": true}]}') + + assert _cron_summary(tmp_path) == "1 active / 1 total" diff --git a/tests/hermes_cli/test_status.py b/tests/hermes_cli/test_status.py index 8b09dd8377a3..3ba698c80bbe 100644 --- a/tests/hermes_cli/test_status.py +++ b/tests/hermes_cli/test_status.py @@ -138,6 +138,25 @@ def test_show_status_reports_nous_inference_key_without_portal_login(monkeypatch # Helpers shared by xAI OAuth status tests # --------------------------------------------------------------------------- +def test_show_status_reads_utf8_bom_jobs_file(monkeypatch, capsys, tmp_path): + from hermes_cli import status as status_mod + + jobs_file = tmp_path / "cron" / "jobs.json" + jobs_file.parent.mkdir(parents=True) + jobs_file.write_bytes(b'\xef\xbb\xbf{"jobs": [{"id": "bom-job", "enabled": true}]}') + + monkeypatch.setattr(status_mod, "get_env_path", lambda: tmp_path / ".env", raising=False) + monkeypatch.setattr(status_mod, "get_hermes_home", lambda: tmp_path, raising=False) + monkeypatch.setattr(status_mod, "load_config", lambda: {"model": "gpt-5.4"}, raising=False) + monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "openai-codex", raising=False) + monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "openai-codex", raising=False) + monkeypatch.setattr(status_mod, "provider_label", lambda provider: "OpenAI Codex", raising=False) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + + assert "Jobs: 1 active, 1 total" in capsys.readouterr().out + + def _base_xai_mocks(monkeypatch, tmp_path): """Set up the minimal environment for show_status, returning status_mod.""" from hermes_cli import status as status_mod