codex-autorunner 1.2.1__py3-none-any.whl → 1.3.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.
Files changed (55) hide show
  1. codex_autorunner/bootstrap.py +26 -5
  2. codex_autorunner/core/config.py +176 -59
  3. codex_autorunner/core/filesystem.py +24 -0
  4. codex_autorunner/core/flows/controller.py +50 -12
  5. codex_autorunner/core/flows/runtime.py +8 -3
  6. codex_autorunner/core/hub.py +293 -16
  7. codex_autorunner/core/lifecycle_events.py +44 -5
  8. codex_autorunner/core/pma_delivery.py +81 -0
  9. codex_autorunner/core/pma_dispatches.py +224 -0
  10. codex_autorunner/core/pma_lane_worker.py +122 -0
  11. codex_autorunner/core/pma_queue.py +167 -18
  12. codex_autorunner/core/pma_reactive.py +91 -0
  13. codex_autorunner/core/pma_safety.py +58 -0
  14. codex_autorunner/core/pma_sink.py +104 -0
  15. codex_autorunner/core/pma_transcripts.py +183 -0
  16. codex_autorunner/core/safe_paths.py +117 -0
  17. codex_autorunner/housekeeping.py +77 -23
  18. codex_autorunner/integrations/agents/codex_backend.py +18 -12
  19. codex_autorunner/integrations/agents/wiring.py +2 -0
  20. codex_autorunner/integrations/app_server/client.py +31 -0
  21. codex_autorunner/integrations/app_server/supervisor.py +3 -0
  22. codex_autorunner/integrations/telegram/constants.py +1 -1
  23. codex_autorunner/integrations/telegram/handlers/commands/execution.py +16 -15
  24. codex_autorunner/integrations/telegram/handlers/commands/files.py +5 -8
  25. codex_autorunner/integrations/telegram/handlers/commands/github.py +10 -6
  26. codex_autorunner/integrations/telegram/handlers/commands/shared.py +9 -8
  27. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +85 -2
  28. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +29 -8
  29. codex_autorunner/integrations/telegram/helpers.py +30 -2
  30. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +54 -3
  31. codex_autorunner/static/docChatCore.js +2 -0
  32. codex_autorunner/static/hub.js +59 -0
  33. codex_autorunner/static/index.html +70 -54
  34. codex_autorunner/static/notificationBell.js +173 -0
  35. codex_autorunner/static/notifications.js +154 -36
  36. codex_autorunner/static/pma.js +96 -35
  37. codex_autorunner/static/styles.css +415 -4
  38. codex_autorunner/static/utils.js +5 -1
  39. codex_autorunner/surfaces/cli/cli.py +206 -129
  40. codex_autorunner/surfaces/cli/template_repos.py +157 -0
  41. codex_autorunner/surfaces/web/app.py +193 -5
  42. codex_autorunner/surfaces/web/routes/file_chat.py +109 -61
  43. codex_autorunner/surfaces/web/routes/flows.py +125 -67
  44. codex_autorunner/surfaces/web/routes/pma.py +638 -57
  45. codex_autorunner/tickets/agent_pool.py +6 -1
  46. codex_autorunner/tickets/outbox.py +27 -14
  47. codex_autorunner/tickets/replies.py +4 -10
  48. codex_autorunner/tickets/runner.py +1 -0
  49. codex_autorunner/workspace/paths.py +8 -3
  50. {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/METADATA +1 -1
  51. {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/RECORD +55 -45
  52. {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/WHEEL +0 -0
  53. {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/entry_points.txt +0 -0
  54. {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/licenses/LICENSE +0 -0
  55. {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/top_level.txt +0 -0
@@ -542,7 +542,7 @@ export function confirmModal(message, options = {}) {
542
542
  });
543
543
  }
544
544
  export function inputModal(message, options = {}) {
545
- const { placeholder = "", defaultValue = "", confirmText = "OK", cancelText = "Cancel" } = options;
545
+ const { placeholder = "", defaultValue = "", confirmText = "OK", cancelText = "Cancel", allowEmpty = false, } = options;
546
546
  return new Promise((resolve) => {
547
547
  const overlay = document.getElementById("input-modal");
548
548
  const messageEl = document.getElementById("input-modal-message");
@@ -576,6 +576,10 @@ export function inputModal(message, options = {}) {
576
576
  };
577
577
  const onOk = () => {
578
578
  const value = inputEl.value.trim();
579
+ if (allowEmpty) {
580
+ finalize(value);
581
+ return;
582
+ }
579
583
  finalize(value || null);
580
584
  };
581
585
  const onCancel = () => {
@@ -38,7 +38,6 @@ from ...core.flows.worker_process import (
38
38
  )
39
39
  from ...core.git_utils import GitError, run_git
40
40
  from ...core.hub import HubSupervisor
41
- from ...core.locks import file_lock
42
41
  from ...core.logging_utils import log_event, setup_rotating_logger
43
42
  from ...core.optional_dependencies import require_optional_dependencies
44
43
  from ...core.runtime import (
@@ -106,11 +105,13 @@ from ...tickets.lint import (
106
105
  from ...voice import VoiceConfig
107
106
  from ..web.app import create_hub_app
108
107
  from .pma_cli import pma_app as pma_cli_app
108
+ from .template_repos import TemplatesConfigError, load_template_repos_manager
109
109
 
110
110
  logger = logging.getLogger("codex_autorunner.cli")
111
111
 
112
112
  app = typer.Typer(add_completion=False)
113
113
  hub_app = typer.Typer(add_completion=False)
114
+ dispatch_app = typer.Typer(add_completion=False)
114
115
  telegram_app = typer.Typer(add_completion=False)
115
116
  templates_app = typer.Typer(add_completion=False)
116
117
  repos_app = typer.Typer(add_completion=False)
@@ -155,26 +156,6 @@ def _require_hub_config(path: Optional[Path]) -> HubConfig:
155
156
  _raise_exit(str(exc), cause=exc)
156
157
 
157
158
 
158
- def _load_hub_config_yaml(path: Path) -> dict:
159
- if not path.exists():
160
- _raise_exit(f"Hub config file not found: {path}")
161
- try:
162
- data = yaml.safe_load(path.read_text(encoding="utf-8"))
163
- if not isinstance(data, dict):
164
- _raise_exit(f"Hub config must be a YAML mapping: {path}")
165
- return data
166
- except yaml.YAMLError as exc:
167
- _raise_exit(f"Invalid YAML in hub config: {exc}", cause=exc)
168
- except OSError as exc:
169
- _raise_exit(f"Failed to read hub config: {exc}", cause=exc)
170
-
171
-
172
- def _write_hub_config_yaml(path: Path, data: dict) -> None:
173
- lock_path = path.parent / (path.name + ".lock")
174
- with file_lock(lock_path):
175
- path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
176
-
177
-
178
159
  def _require_templates_enabled(config: RepoConfig) -> None:
179
160
  if not config.templates.enabled:
180
161
  _raise_exit(
@@ -452,6 +433,31 @@ def _request_json(
452
433
  return data if isinstance(data, dict) else {}
453
434
 
454
435
 
436
+ def _request_form_json(
437
+ method: str,
438
+ url: str,
439
+ form: Optional[dict] = None,
440
+ token_env: Optional[str] = None,
441
+ *,
442
+ force_multipart: bool = False,
443
+ ) -> dict:
444
+ headers = None
445
+ if token_env:
446
+ token = _require_auth_token(token_env)
447
+ headers = {"Authorization": f"Bearer {token}"}
448
+ data = form
449
+ files = None
450
+ if force_multipart:
451
+ data = form or {}
452
+ files = []
453
+ response = httpx.request(
454
+ method, url, data=data, files=files, timeout=5.0, headers=headers
455
+ )
456
+ response.raise_for_status()
457
+ data = response.json()
458
+ return data if isinstance(data, dict) else {}
459
+
460
+
455
461
  def _require_optional_feature(
456
462
  *, feature: str, deps: list[tuple[str, str]], extra: Optional[str] = None
457
463
  ) -> None:
@@ -462,6 +468,7 @@ def _require_optional_feature(
462
468
 
463
469
 
464
470
  app.add_typer(hub_app, name="hub")
471
+ hub_app.add_typer(dispatch_app, name="dispatch")
465
472
  hub_app.add_typer(worktree_app, name="worktree")
466
473
  app.add_typer(telegram_app, name="telegram")
467
474
  app.add_typer(templates_app, name="templates")
@@ -780,16 +787,8 @@ def repos_list(
780
787
  output_json: bool = typer.Option(False, "--json", help="Emit JSON output"),
781
788
  ):
782
789
  """List configured template repos."""
783
- config = _require_hub_config(hub)
784
- hub_config_path = config.root / CONFIG_FILENAME
785
- data = _load_hub_config_yaml(hub_config_path)
786
-
787
- templates_config = data.get("templates", {})
788
- if not isinstance(templates_config, dict):
789
- templates_config = {}
790
- repos = templates_config.get("repos", [])
791
- if not isinstance(repos, list):
792
- repos = []
790
+ manager = load_template_repos_manager(hub)
791
+ repos = manager.list_repos()
793
792
 
794
793
  if output_json:
795
794
  payload = {"repos": repos}
@@ -823,43 +822,16 @@ def repos_add(
823
822
  hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
824
823
  ):
825
824
  """Add a template repo to the hub config."""
826
- config = _require_hub_config(hub)
827
- hub_config_path = config.root / CONFIG_FILENAME
828
- data = _load_hub_config_yaml(hub_config_path)
829
-
830
- templates_config = data.get("templates", {})
831
- if not isinstance(templates_config, dict):
832
- templates_config = {}
833
- enabled = templates_config.get("enabled", True)
834
- if enabled is False:
835
- _raise_exit(
836
- "Templates are disabled. Set templates.enabled=true in the hub config to enable."
837
- )
838
-
839
- templates_config = data.setdefault("templates", {})
840
- if not isinstance(templates_config, dict):
841
- _raise_exit("Invalid templates config in hub config")
842
- templates_config.setdefault("enabled", True)
843
- repos = templates_config.setdefault("repos", [])
844
- if not isinstance(repos, list):
845
- _raise_exit("Invalid repos config in hub config")
846
-
847
- existing_ids = {repo.get("id") for repo in repos if isinstance(repo, dict)}
848
- if repo_id in existing_ids:
849
- _raise_exit(f"Repo ID '{repo_id}' already exists. Use a unique ID.")
850
-
851
- new_repo = {
852
- "id": repo_id,
853
- "url": url,
854
- "default_ref": default_ref,
855
- }
856
- if trusted is not None:
857
- new_repo["trusted"] = trusted
858
-
859
- repos.append(new_repo)
825
+ manager = load_template_repos_manager(hub)
826
+ try:
827
+ manager.add_repo(repo_id, url, trusted, default_ref)
828
+ except TemplatesConfigError as exc:
829
+ _raise_exit(str(exc), cause=exc)
830
+ except OSError as exc:
831
+ _raise_exit(f"Failed to write hub config: {exc}", cause=exc)
860
832
 
861
833
  try:
862
- _write_hub_config_yaml(hub_config_path, data)
834
+ manager.save()
863
835
  except OSError as exc:
864
836
  _raise_exit(f"Failed to write hub config: {exc}", cause=exc)
865
837
 
@@ -872,29 +844,16 @@ def repos_remove(
872
844
  hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
873
845
  ):
874
846
  """Remove a template repo from the hub config."""
875
- config = _require_hub_config(hub)
876
- hub_config_path = config.root / CONFIG_FILENAME
877
- data = _load_hub_config_yaml(hub_config_path)
878
-
879
- templates_config = data.get("templates", {})
880
- if not isinstance(templates_config, dict):
881
- templates_config = {}
882
- repos = templates_config.get("repos", [])
883
- if not isinstance(repos, list):
884
- repos = []
885
-
886
- original_count = len(repos)
887
- filtered_repos = [
888
- repo for repo in repos if isinstance(repo, dict) and repo.get("id") != repo_id
889
- ]
890
-
891
- if len(filtered_repos) == original_count:
892
- _raise_exit(f"Repo ID '{repo_id}' not found in config.")
893
-
894
- templates_config["repos"] = filtered_repos
847
+ manager = load_template_repos_manager(hub)
848
+ try:
849
+ manager.remove_repo(repo_id)
850
+ except TemplatesConfigError as exc:
851
+ _raise_exit(str(exc), cause=exc)
852
+ except OSError as exc:
853
+ _raise_exit(f"Failed to write hub config: {exc}", cause=exc)
895
854
 
896
855
  try:
897
- _write_hub_config_yaml(hub_config_path, data)
856
+ manager.save()
898
857
  except OSError as exc:
899
858
  _raise_exit(f"Failed to write hub config: {exc}", cause=exc)
900
859
 
@@ -907,29 +866,16 @@ def repos_trust(
907
866
  hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
908
867
  ):
909
868
  """Mark a template repo as trusted (skip scanning)."""
910
- config = _require_hub_config(hub)
911
- hub_config_path = config.root / CONFIG_FILENAME
912
- data = _load_hub_config_yaml(hub_config_path)
913
-
914
- templates_config = data.get("templates", {})
915
- if not isinstance(templates_config, dict):
916
- templates_config = {}
917
- repos = templates_config.get("repos", [])
918
- if not isinstance(repos, list):
919
- repos = []
920
-
921
- found = False
922
- for repo in repos:
923
- if isinstance(repo, dict) and repo.get("id") == repo_id:
924
- repo["trusted"] = True
925
- found = True
926
- break
927
-
928
- if not found:
929
- _raise_exit(f"Repo ID '{repo_id}' not found in config.")
869
+ manager = load_template_repos_manager(hub)
870
+ try:
871
+ manager.set_trusted(repo_id, True)
872
+ except TemplatesConfigError as exc:
873
+ _raise_exit(str(exc), cause=exc)
874
+ except OSError as exc:
875
+ _raise_exit(f"Failed to write hub config: {exc}", cause=exc)
930
876
 
931
877
  try:
932
- _write_hub_config_yaml(hub_config_path, data)
878
+ manager.save()
933
879
  except OSError as exc:
934
880
  _raise_exit(f"Failed to write hub config: {exc}", cause=exc)
935
881
 
@@ -942,29 +888,16 @@ def repos_untrust(
942
888
  hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
943
889
  ):
944
890
  """Mark a template repo as untrusted (require scanning)."""
945
- config = _require_hub_config(hub)
946
- hub_config_path = config.root / CONFIG_FILENAME
947
- data = _load_hub_config_yaml(hub_config_path)
948
-
949
- templates_config = data.get("templates", {})
950
- if not isinstance(templates_config, dict):
951
- templates_config = {}
952
- repos = templates_config.get("repos", [])
953
- if not isinstance(repos, list):
954
- repos = []
955
-
956
- found = False
957
- for repo in repos:
958
- if isinstance(repo, dict) and repo.get("id") == repo_id:
959
- repo["trusted"] = False
960
- found = True
961
- break
962
-
963
- if not found:
964
- _raise_exit(f"Repo ID '{repo_id}' not found in config.")
891
+ manager = load_template_repos_manager(hub)
892
+ try:
893
+ manager.set_trusted(repo_id, False)
894
+ except TemplatesConfigError as exc:
895
+ _raise_exit(str(exc), cause=exc)
896
+ except OSError as exc:
897
+ _raise_exit(f"Failed to write hub config: {exc}", cause=exc)
965
898
 
966
899
  try:
967
- _write_hub_config_yaml(hub_config_path, data)
900
+ manager.save()
968
901
  except OSError as exc:
969
902
  _raise_exit(f"Failed to write hub config: {exc}", cause=exc)
970
903
 
@@ -1783,6 +1716,150 @@ def hub_snapshot(
1783
1716
  typer.echo(json.dumps(snapshot, indent=indent))
1784
1717
 
1785
1718
 
1719
+ @dispatch_app.command("reply")
1720
+ def hub_dispatch_reply(
1721
+ repo_id: str = typer.Option(..., "--repo-id", help="Hub repo id"),
1722
+ run_id: str = typer.Option(..., "--run-id", help="Flow run id (UUID)"),
1723
+ message: Optional[str] = typer.Option(None, "--message", help="Reply message body"),
1724
+ message_file: Optional[Path] = typer.Option(
1725
+ None, "--message-file", help="Read reply message body from file"
1726
+ ),
1727
+ resume: bool = typer.Option(
1728
+ True, "--resume/--no-resume", help="Resume run after posting reply"
1729
+ ),
1730
+ idempotency_key: Optional[str] = typer.Option(
1731
+ None, "--idempotency-key", help="Optional key to avoid duplicate replies"
1732
+ ),
1733
+ path: Optional[Path] = typer.Option(None, "--path", "--hub", help="Hub root path"),
1734
+ output_json: bool = typer.Option(
1735
+ True, "--json/--no-json", help="Emit JSON output (default: true)"
1736
+ ),
1737
+ pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON output"),
1738
+ ):
1739
+ """Reply to a paused dispatch and optionally resume the run."""
1740
+ config = _require_hub_config(path)
1741
+
1742
+ if bool(message) == bool(message_file):
1743
+ _raise_exit("Provide exactly one of --message or --message-file.")
1744
+
1745
+ raw_message = message
1746
+ if message_file is not None:
1747
+ try:
1748
+ raw_message = message_file.read_text(encoding="utf-8")
1749
+ except OSError as exc:
1750
+ _raise_exit(f"Failed to read message file: {exc}", cause=exc)
1751
+ body = (raw_message or "").strip()
1752
+ if not body:
1753
+ _raise_exit("Reply message cannot be empty.")
1754
+
1755
+ thread_url = _build_server_url(
1756
+ config, f"/repos/{repo_id}/api/messages/threads/{run_id}"
1757
+ )
1758
+ reply_url = _build_server_url(
1759
+ config, f"/repos/{repo_id}/api/messages/{run_id}/reply"
1760
+ )
1761
+ resume_url = _build_server_url(
1762
+ config, f"/repos/{repo_id}/api/flows/{run_id}/resume"
1763
+ )
1764
+
1765
+ marker = None
1766
+ if idempotency_key:
1767
+ marker = f"<!-- car-idempotency-key:{idempotency_key.strip()} -->"
1768
+
1769
+ try:
1770
+ thread = _request_json(
1771
+ "GET", thread_url, token_env=config.server_auth_token_env
1772
+ )
1773
+ except (
1774
+ httpx.HTTPError,
1775
+ httpx.ConnectError,
1776
+ httpx.TimeoutException,
1777
+ OSError,
1778
+ ) as exc:
1779
+ _raise_exit(
1780
+ "Failed to query run thread via hub server. Ensure 'car hub serve' is running.",
1781
+ cause=exc,
1782
+ )
1783
+
1784
+ run_status = ((thread.get("run") or {}) if isinstance(thread, dict) else {}).get(
1785
+ "status"
1786
+ )
1787
+ if run_status != "paused":
1788
+ _raise_exit(
1789
+ f"Run {run_id} is not paused-awaiting-input (status={run_status or 'unknown'})."
1790
+ )
1791
+
1792
+ duplicate = False
1793
+ reply_seq = None
1794
+ if marker:
1795
+ replies = thread.get("reply_history", []) if isinstance(thread, dict) else []
1796
+ for entry in replies if isinstance(replies, list) else []:
1797
+ reply = entry.get("reply") if isinstance(entry, dict) else None
1798
+ existing_body = (reply.get("body") or "") if isinstance(reply, dict) else ""
1799
+ if marker in existing_body:
1800
+ duplicate = True
1801
+ reply_seq = entry.get("seq") if isinstance(entry, dict) else None
1802
+ break
1803
+
1804
+ if not duplicate:
1805
+ post_body = body
1806
+ if marker:
1807
+ post_body = f"{body}\n\n{marker}"
1808
+ try:
1809
+ reply_resp = _request_form_json(
1810
+ "POST",
1811
+ reply_url,
1812
+ form={"body": post_body},
1813
+ token_env=config.server_auth_token_env,
1814
+ force_multipart=True,
1815
+ )
1816
+ reply_seq = reply_resp.get("seq")
1817
+ except (
1818
+ httpx.HTTPError,
1819
+ httpx.ConnectError,
1820
+ httpx.TimeoutException,
1821
+ OSError,
1822
+ ) as exc:
1823
+ _raise_exit("Failed to post dispatch reply.", cause=exc)
1824
+
1825
+ resumed = False
1826
+ resume_status = None
1827
+ if resume:
1828
+ try:
1829
+ resume_resp = _request_json(
1830
+ "POST", resume_url, payload={}, token_env=config.server_auth_token_env
1831
+ )
1832
+ resumed = True
1833
+ resume_status = resume_resp.get("status")
1834
+ except (
1835
+ httpx.HTTPError,
1836
+ httpx.ConnectError,
1837
+ httpx.TimeoutException,
1838
+ OSError,
1839
+ ) as exc:
1840
+ _raise_exit("Reply posted but resume failed.", cause=exc)
1841
+
1842
+ payload = {
1843
+ "repo_id": repo_id,
1844
+ "run_id": run_id,
1845
+ "reply_seq": reply_seq,
1846
+ "duplicate": duplicate,
1847
+ "resumed": resumed,
1848
+ "resume_status": resume_status,
1849
+ }
1850
+
1851
+ if output_json:
1852
+ typer.echo(json.dumps(payload, indent=2 if pretty else None))
1853
+ return
1854
+
1855
+ typer.echo(
1856
+ f"Reply {'reused' if duplicate else 'posted'} for run {run_id}"
1857
+ + (f" (seq={reply_seq})" if reply_seq else "")
1858
+ )
1859
+ if resume:
1860
+ typer.echo(f"Run resumed: status={resume_status or 'unknown'}")
1861
+
1862
+
1786
1863
  @telegram_app.command("start")
1787
1864
  def telegram_start(
1788
1865
  path: Optional[Path] = typer.Option(None, "--path", help="Repo or hub root path"),
@@ -0,0 +1,157 @@
1
+ """Template repo config manager to centralize repo-config mutations."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Optional
5
+
6
+ import typer
7
+ import yaml
8
+
9
+ from ...core.config import CONFIG_FILENAME, load_hub_config
10
+ from ...core.locks import file_lock
11
+
12
+
13
+ class TemplatesConfigError(Exception):
14
+ """Error in templates configuration."""
15
+
16
+
17
+ class TemplateReposManager:
18
+ """Manager for template repos in hub config."""
19
+
20
+ def __init__(self, hub_config_path: Path) -> None:
21
+ """Initialize the manager with a hub config path."""
22
+ self.hub_config_path = hub_config_path
23
+ self._data: dict[str, Any] = {}
24
+ self._load()
25
+
26
+ def _load(self) -> None:
27
+ """Load the hub config YAML."""
28
+ if not self.hub_config_path.exists():
29
+ raise TemplatesConfigError(
30
+ f"Hub config file not found: {self.hub_config_path}"
31
+ )
32
+ try:
33
+ data = yaml.safe_load(self.hub_config_path.read_text(encoding="utf-8"))
34
+ if not isinstance(data, dict):
35
+ raise TemplatesConfigError(
36
+ f"Hub config must be a YAML mapping: {self.hub_config_path}"
37
+ )
38
+ self._data = data
39
+ except yaml.YAMLError as exc:
40
+ raise TemplatesConfigError(f"Invalid YAML in hub config: {exc}") from exc
41
+ except OSError as exc:
42
+ raise TemplatesConfigError(f"Failed to read hub config: {exc}") from exc
43
+
44
+ def save(self) -> None:
45
+ """Save the hub config YAML."""
46
+ lock_path = self.hub_config_path.parent / (self.hub_config_path.name + ".lock")
47
+ with file_lock(lock_path):
48
+ self.hub_config_path.write_text(
49
+ yaml.safe_dump(self._data, sort_keys=False), encoding="utf-8"
50
+ )
51
+
52
+ def list_repos(self) -> list[dict[str, Any]]:
53
+ """List all configured template repos."""
54
+ templates_config = self._data.get("templates", {})
55
+ if not isinstance(templates_config, dict):
56
+ templates_config = {}
57
+ repos = templates_config.get("repos", [])
58
+ if not isinstance(repos, list):
59
+ repos = []
60
+ return repos
61
+
62
+ def add_repo(
63
+ self,
64
+ repo_id: str,
65
+ url: str,
66
+ trusted: Optional[bool] = None,
67
+ default_ref: str = "main",
68
+ ) -> None:
69
+ """Add a template repo."""
70
+ self._require_templates_enabled()
71
+
72
+ templates_config = self._data.setdefault("templates", {})
73
+ if not isinstance(templates_config, dict):
74
+ raise TemplatesConfigError("Invalid templates config in hub config")
75
+ templates_config.setdefault("enabled", True)
76
+
77
+ repos = templates_config.setdefault("repos", [])
78
+ if not isinstance(repos, list):
79
+ raise TemplatesConfigError("Invalid repos config in hub config")
80
+
81
+ existing_ids = {repo.get("id") for repo in repos if isinstance(repo, dict)}
82
+ if repo_id in existing_ids:
83
+ raise TemplatesConfigError(
84
+ f"Repo ID '{repo_id}' already exists. Use a unique ID."
85
+ )
86
+
87
+ new_repo = {
88
+ "id": repo_id,
89
+ "url": url,
90
+ "default_ref": default_ref,
91
+ }
92
+ if trusted is not None:
93
+ new_repo["trusted"] = trusted
94
+
95
+ repos.append(new_repo)
96
+
97
+ def remove_repo(self, repo_id: str) -> None:
98
+ """Remove a template repo."""
99
+ templates_config = self._data.get("templates", {})
100
+ if not isinstance(templates_config, dict):
101
+ templates_config = {}
102
+ repos = templates_config.get("repos", [])
103
+ if not isinstance(repos, list):
104
+ repos = []
105
+
106
+ original_count = len(repos)
107
+ filtered_repos = [
108
+ repo
109
+ for repo in repos
110
+ if isinstance(repo, dict) and repo.get("id") != repo_id
111
+ ]
112
+
113
+ if len(filtered_repos) == original_count:
114
+ raise TemplatesConfigError(f"Repo ID '{repo_id}' not found in config.")
115
+
116
+ templates_config["repos"] = filtered_repos
117
+
118
+ def set_trusted(self, repo_id: str, trusted: bool) -> None:
119
+ """Set the trusted status of a template repo."""
120
+ templates_config = self._data.get("templates", {})
121
+ if not isinstance(templates_config, dict):
122
+ templates_config = {}
123
+ repos = templates_config.get("repos", [])
124
+ if not isinstance(repos, list):
125
+ repos = []
126
+
127
+ found = False
128
+ for repo in repos:
129
+ if isinstance(repo, dict) and repo.get("id") == repo_id:
130
+ repo["trusted"] = trusted
131
+ found = True
132
+ break
133
+
134
+ if not found:
135
+ raise TemplatesConfigError(f"Repo ID '{repo_id}' not found in config.")
136
+
137
+ def _require_templates_enabled(self) -> None:
138
+ """Ensure templates are enabled in config."""
139
+ templates_config = self._data.get("templates", {})
140
+ if not isinstance(templates_config, dict):
141
+ templates_config = {}
142
+ enabled = templates_config.get("enabled", True)
143
+ if enabled is False:
144
+ raise TemplatesConfigError(
145
+ "Templates are disabled. Set templates.enabled=true in the hub config to enable."
146
+ )
147
+
148
+
149
+ def load_template_repos_manager(hub: Optional[Path]) -> TemplateReposManager:
150
+ """Load a TemplateReposManager for the given hub path."""
151
+ try:
152
+ config = load_hub_config(hub or Path.cwd())
153
+ except Exception as exc:
154
+ typer.echo(str(exc), err=True)
155
+ raise typer.Exit(code=1) from None
156
+ hub_config_path = config.root / CONFIG_FILENAME
157
+ return TemplateReposManager(hub_config_path)