codex-autorunner 0.1.2__py3-none-any.whl → 1.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.
Files changed (276) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/__main__.py +4 -0
  3. codex_autorunner/agents/codex/harness.py +1 -1
  4. codex_autorunner/agents/opencode/client.py +68 -35
  5. codex_autorunner/agents/opencode/constants.py +3 -0
  6. codex_autorunner/agents/opencode/harness.py +6 -1
  7. codex_autorunner/agents/opencode/logging.py +21 -5
  8. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  9. codex_autorunner/agents/opencode/runtime.py +176 -47
  10. codex_autorunner/agents/opencode/supervisor.py +36 -48
  11. codex_autorunner/agents/registry.py +155 -8
  12. codex_autorunner/api.py +25 -0
  13. codex_autorunner/bootstrap.py +22 -37
  14. codex_autorunner/cli.py +5 -1156
  15. codex_autorunner/codex_cli.py +20 -84
  16. codex_autorunner/core/__init__.py +4 -0
  17. codex_autorunner/core/about_car.py +49 -32
  18. codex_autorunner/core/adapter_utils.py +21 -0
  19. codex_autorunner/core/app_server_ids.py +59 -0
  20. codex_autorunner/core/app_server_logging.py +7 -3
  21. codex_autorunner/core/app_server_prompts.py +27 -260
  22. codex_autorunner/core/app_server_threads.py +26 -28
  23. codex_autorunner/core/app_server_utils.py +165 -0
  24. codex_autorunner/core/archive.py +349 -0
  25. codex_autorunner/core/codex_runner.py +12 -2
  26. codex_autorunner/core/config.py +587 -103
  27. codex_autorunner/core/docs.py +10 -2
  28. codex_autorunner/core/drafts.py +136 -0
  29. codex_autorunner/core/engine.py +1531 -866
  30. codex_autorunner/core/exceptions.py +4 -0
  31. codex_autorunner/core/flows/__init__.py +25 -0
  32. codex_autorunner/core/flows/controller.py +202 -0
  33. codex_autorunner/core/flows/definition.py +82 -0
  34. codex_autorunner/core/flows/models.py +88 -0
  35. codex_autorunner/core/flows/reasons.py +52 -0
  36. codex_autorunner/core/flows/reconciler.py +131 -0
  37. codex_autorunner/core/flows/runtime.py +382 -0
  38. codex_autorunner/core/flows/store.py +568 -0
  39. codex_autorunner/core/flows/transition.py +138 -0
  40. codex_autorunner/core/flows/ux_helpers.py +257 -0
  41. codex_autorunner/core/flows/worker_process.py +242 -0
  42. codex_autorunner/core/git_utils.py +62 -0
  43. codex_autorunner/core/hub.py +136 -16
  44. codex_autorunner/core/locks.py +4 -0
  45. codex_autorunner/core/notifications.py +14 -2
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/core/ports/agent_backend.py +150 -0
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/core/ports/run_event.py +91 -0
  50. codex_autorunner/core/prompt.py +15 -7
  51. codex_autorunner/core/redaction.py +29 -0
  52. codex_autorunner/core/review_context.py +5 -8
  53. codex_autorunner/core/run_index.py +6 -0
  54. codex_autorunner/core/runner_process.py +5 -2
  55. codex_autorunner/core/state.py +0 -88
  56. codex_autorunner/core/state_roots.py +57 -0
  57. codex_autorunner/core/supervisor_protocol.py +15 -0
  58. codex_autorunner/core/supervisor_utils.py +67 -0
  59. codex_autorunner/core/text_delta_coalescer.py +54 -0
  60. codex_autorunner/core/ticket_linter_cli.py +201 -0
  61. codex_autorunner/core/ticket_manager_cli.py +432 -0
  62. codex_autorunner/core/update.py +24 -16
  63. codex_autorunner/core/update_paths.py +28 -0
  64. codex_autorunner/core/update_runner.py +2 -0
  65. codex_autorunner/core/usage.py +164 -12
  66. codex_autorunner/core/utils.py +120 -11
  67. codex_autorunner/discovery.py +2 -4
  68. codex_autorunner/flows/review/__init__.py +17 -0
  69. codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
  70. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  71. codex_autorunner/flows/ticket_flow/definition.py +98 -0
  72. codex_autorunner/integrations/agents/__init__.py +17 -0
  73. codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
  74. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  75. codex_autorunner/integrations/agents/codex_backend.py +448 -0
  76. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  77. codex_autorunner/integrations/agents/opencode_backend.py +598 -0
  78. codex_autorunner/integrations/agents/runner.py +91 -0
  79. codex_autorunner/integrations/agents/wiring.py +271 -0
  80. codex_autorunner/integrations/app_server/client.py +583 -152
  81. codex_autorunner/integrations/app_server/env.py +2 -107
  82. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  83. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  84. codex_autorunner/integrations/telegram/adapter.py +204 -165
  85. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  86. codex_autorunner/integrations/telegram/config.py +221 -0
  87. codex_autorunner/integrations/telegram/constants.py +17 -2
  88. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  89. codex_autorunner/integrations/telegram/doctor.py +47 -0
  90. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -4
  91. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  92. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  93. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  94. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1364 -0
  95. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  96. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  97. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  98. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +137 -478
  99. codex_autorunner/integrations/telegram/handlers/commands_spec.py +17 -4
  100. codex_autorunner/integrations/telegram/handlers/messages.py +121 -9
  101. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  102. codex_autorunner/integrations/telegram/helpers.py +111 -16
  103. codex_autorunner/integrations/telegram/outbox.py +208 -37
  104. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  105. codex_autorunner/integrations/telegram/service.py +221 -42
  106. codex_autorunner/integrations/telegram/state.py +100 -2
  107. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +611 -0
  108. codex_autorunner/integrations/telegram/transport.py +39 -4
  109. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  110. codex_autorunner/manifest.py +2 -0
  111. codex_autorunner/plugin_api.py +22 -0
  112. codex_autorunner/routes/__init__.py +37 -67
  113. codex_autorunner/routes/agents.py +2 -137
  114. codex_autorunner/routes/analytics.py +3 -0
  115. codex_autorunner/routes/app_server.py +2 -131
  116. codex_autorunner/routes/base.py +2 -624
  117. codex_autorunner/routes/file_chat.py +7 -0
  118. codex_autorunner/routes/flows.py +7 -0
  119. codex_autorunner/routes/messages.py +7 -0
  120. codex_autorunner/routes/repos.py +2 -196
  121. codex_autorunner/routes/review.py +2 -147
  122. codex_autorunner/routes/sessions.py +2 -175
  123. codex_autorunner/routes/settings.py +2 -168
  124. codex_autorunner/routes/shared.py +2 -275
  125. codex_autorunner/routes/system.py +4 -188
  126. codex_autorunner/routes/usage.py +3 -0
  127. codex_autorunner/routes/voice.py +2 -119
  128. codex_autorunner/routes/workspace.py +3 -0
  129. codex_autorunner/server.py +3 -2
  130. codex_autorunner/static/agentControls.js +41 -11
  131. codex_autorunner/static/agentEvents.js +248 -0
  132. codex_autorunner/static/app.js +35 -24
  133. codex_autorunner/static/archive.js +826 -0
  134. codex_autorunner/static/archiveApi.js +37 -0
  135. codex_autorunner/static/autoRefresh.js +36 -8
  136. codex_autorunner/static/bootstrap.js +1 -0
  137. codex_autorunner/static/bus.js +1 -0
  138. codex_autorunner/static/cache.js +1 -0
  139. codex_autorunner/static/constants.js +20 -4
  140. codex_autorunner/static/dashboard.js +344 -325
  141. codex_autorunner/static/diffRenderer.js +37 -0
  142. codex_autorunner/static/docChatCore.js +324 -0
  143. codex_autorunner/static/docChatStorage.js +65 -0
  144. codex_autorunner/static/docChatVoice.js +65 -0
  145. codex_autorunner/static/docEditor.js +133 -0
  146. codex_autorunner/static/env.js +1 -0
  147. codex_autorunner/static/eventSummarizer.js +166 -0
  148. codex_autorunner/static/fileChat.js +182 -0
  149. codex_autorunner/static/health.js +155 -0
  150. codex_autorunner/static/hub.js +126 -185
  151. codex_autorunner/static/index.html +839 -863
  152. codex_autorunner/static/liveUpdates.js +1 -0
  153. codex_autorunner/static/loader.js +1 -0
  154. codex_autorunner/static/messages.js +873 -0
  155. codex_autorunner/static/mobileCompact.js +2 -1
  156. codex_autorunner/static/preserve.js +17 -0
  157. codex_autorunner/static/settings.js +149 -217
  158. codex_autorunner/static/smartRefresh.js +52 -0
  159. codex_autorunner/static/styles.css +8850 -3876
  160. codex_autorunner/static/tabs.js +175 -11
  161. codex_autorunner/static/terminal.js +32 -0
  162. codex_autorunner/static/terminalManager.js +34 -59
  163. codex_autorunner/static/ticketChatActions.js +333 -0
  164. codex_autorunner/static/ticketChatEvents.js +16 -0
  165. codex_autorunner/static/ticketChatStorage.js +16 -0
  166. codex_autorunner/static/ticketChatStream.js +264 -0
  167. codex_autorunner/static/ticketEditor.js +844 -0
  168. codex_autorunner/static/ticketVoice.js +9 -0
  169. codex_autorunner/static/tickets.js +1988 -0
  170. codex_autorunner/static/utils.js +43 -3
  171. codex_autorunner/static/voice.js +1 -0
  172. codex_autorunner/static/workspace.js +765 -0
  173. codex_autorunner/static/workspaceApi.js +53 -0
  174. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  175. codex_autorunner/surfaces/__init__.py +5 -0
  176. codex_autorunner/surfaces/cli/__init__.py +6 -0
  177. codex_autorunner/surfaces/cli/cli.py +1224 -0
  178. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  179. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  180. codex_autorunner/surfaces/web/__init__.py +1 -0
  181. codex_autorunner/surfaces/web/app.py +2019 -0
  182. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  183. codex_autorunner/surfaces/web/middleware.py +587 -0
  184. codex_autorunner/surfaces/web/pty_session.py +370 -0
  185. codex_autorunner/surfaces/web/review.py +6 -0
  186. codex_autorunner/surfaces/web/routes/__init__.py +78 -0
  187. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  188. codex_autorunner/surfaces/web/routes/analytics.py +277 -0
  189. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  190. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  191. codex_autorunner/surfaces/web/routes/base.py +615 -0
  192. codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
  193. codex_autorunner/surfaces/web/routes/flows.py +1164 -0
  194. codex_autorunner/surfaces/web/routes/messages.py +459 -0
  195. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  196. codex_autorunner/surfaces/web/routes/review.py +148 -0
  197. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  198. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  199. codex_autorunner/surfaces/web/routes/shared.py +280 -0
  200. codex_autorunner/surfaces/web/routes/system.py +196 -0
  201. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  202. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  203. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  204. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  205. codex_autorunner/surfaces/web/schemas.py +417 -0
  206. codex_autorunner/surfaces/web/static_assets.py +490 -0
  207. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  208. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  209. codex_autorunner/tickets/__init__.py +27 -0
  210. codex_autorunner/tickets/agent_pool.py +399 -0
  211. codex_autorunner/tickets/files.py +89 -0
  212. codex_autorunner/tickets/frontmatter.py +55 -0
  213. codex_autorunner/tickets/lint.py +102 -0
  214. codex_autorunner/tickets/models.py +97 -0
  215. codex_autorunner/tickets/outbox.py +244 -0
  216. codex_autorunner/tickets/replies.py +179 -0
  217. codex_autorunner/tickets/runner.py +881 -0
  218. codex_autorunner/tickets/spec_ingest.py +77 -0
  219. codex_autorunner/web/__init__.py +5 -1
  220. codex_autorunner/web/app.py +2 -1771
  221. codex_autorunner/web/hub_jobs.py +2 -191
  222. codex_autorunner/web/middleware.py +2 -587
  223. codex_autorunner/web/pty_session.py +2 -369
  224. codex_autorunner/web/runner_manager.py +2 -24
  225. codex_autorunner/web/schemas.py +2 -396
  226. codex_autorunner/web/static_assets.py +4 -484
  227. codex_autorunner/web/static_refresh.py +2 -85
  228. codex_autorunner/web/terminal_sessions.py +2 -77
  229. codex_autorunner/workspace/__init__.py +40 -0
  230. codex_autorunner/workspace/paths.py +335 -0
  231. codex_autorunner-1.1.0.dist-info/METADATA +154 -0
  232. codex_autorunner-1.1.0.dist-info/RECORD +308 -0
  233. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +1 -1
  234. codex_autorunner/agents/execution/policy.py +0 -292
  235. codex_autorunner/agents/factory.py +0 -52
  236. codex_autorunner/agents/orchestrator.py +0 -358
  237. codex_autorunner/core/doc_chat.py +0 -1446
  238. codex_autorunner/core/snapshot.py +0 -580
  239. codex_autorunner/integrations/github/chatops.py +0 -268
  240. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  241. codex_autorunner/routes/docs.py +0 -381
  242. codex_autorunner/routes/github.py +0 -327
  243. codex_autorunner/routes/runs.py +0 -250
  244. codex_autorunner/spec_ingest.py +0 -812
  245. codex_autorunner/static/docChatActions.js +0 -287
  246. codex_autorunner/static/docChatEvents.js +0 -300
  247. codex_autorunner/static/docChatRender.js +0 -205
  248. codex_autorunner/static/docChatStream.js +0 -361
  249. codex_autorunner/static/docs.js +0 -20
  250. codex_autorunner/static/docsClipboard.js +0 -69
  251. codex_autorunner/static/docsCrud.js +0 -257
  252. codex_autorunner/static/docsDocUpdates.js +0 -62
  253. codex_autorunner/static/docsDrafts.js +0 -16
  254. codex_autorunner/static/docsElements.js +0 -69
  255. codex_autorunner/static/docsInit.js +0 -285
  256. codex_autorunner/static/docsParse.js +0 -160
  257. codex_autorunner/static/docsSnapshot.js +0 -87
  258. codex_autorunner/static/docsSpecIngest.js +0 -263
  259. codex_autorunner/static/docsState.js +0 -127
  260. codex_autorunner/static/docsThreadRegistry.js +0 -44
  261. codex_autorunner/static/docsUi.js +0 -153
  262. codex_autorunner/static/docsVoice.js +0 -56
  263. codex_autorunner/static/github.js +0 -504
  264. codex_autorunner/static/logs.js +0 -678
  265. codex_autorunner/static/review.js +0 -157
  266. codex_autorunner/static/runs.js +0 -418
  267. codex_autorunner/static/snapshot.js +0 -124
  268. codex_autorunner/static/state.js +0 -94
  269. codex_autorunner/static/todoPreview.js +0 -27
  270. codex_autorunner/workspace.py +0 -16
  271. codex_autorunner-0.1.2.dist-info/METADATA +0 -249
  272. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  273. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  274. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
  275. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
  276. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/top_level.txt +0 -0
codex_autorunner/cli.py CHANGED
@@ -1,1159 +1,8 @@
1
- import asyncio
2
- import ipaddress
3
- import json
4
- import logging
5
- import os
6
- import shlex
7
- import subprocess
8
- from pathlib import Path
9
- from typing import NoReturn, Optional
1
+ """Backward-compatible CLI entrypoint.
10
2
 
11
- import httpx
12
- import typer
13
- import uvicorn
14
- import yaml
3
+ Re-export the Typer app from the CLI surface.
4
+ """
15
5
 
16
- from .bootstrap import seed_hub_files, seed_repo_files
17
- from .core.config import (
18
- CONFIG_FILENAME,
19
- ConfigError,
20
- HubConfig,
21
- _normalize_base_path,
22
- find_nearest_hub_config_path,
23
- load_hub_config,
24
- )
25
- from .core.engine import Engine, LockError, clear_stale_lock, doctor
26
- from .core.git_utils import GitError, run_git
27
- from .core.hub import HubSupervisor
28
- from .core.logging_utils import log_event, setup_rotating_logger
29
- from .core.optional_dependencies import require_optional_dependencies
30
- from .core.snapshot import SnapshotError
31
- from .core.state import RunnerState, load_state, now_iso, save_state, state_lock
32
- from .core.usage import (
33
- UsageError,
34
- default_codex_home,
35
- parse_iso_datetime,
36
- summarize_hub_usage,
37
- summarize_repo_usage,
38
- )
39
- from .core.utils import RepoNotFoundError, default_editor, find_repo_root
40
- from .integrations.app_server.env import build_app_server_env
41
- from .integrations.app_server.supervisor import WorkspaceAppServerSupervisor
42
- from .integrations.telegram.adapter import TelegramAPIError, TelegramBotClient
43
- from .integrations.telegram.service import (
44
- TelegramBotConfig,
45
- TelegramBotConfigError,
46
- TelegramBotLockError,
47
- TelegramBotService,
48
- )
49
- from .manifest import load_manifest
50
- from .server import create_hub_app
51
- from .spec_ingest import SpecIngestError, SpecIngestService, clear_work_docs
52
- from .voice import VoiceConfig
6
+ from .surfaces.cli.cli import _resolve_repo_api_path, app, main # noqa: F401
53
7
 
54
- logger = logging.getLogger("codex_autorunner.cli")
55
-
56
- app = typer.Typer(add_completion=False)
57
- hub_app = typer.Typer(add_completion=False)
58
- telegram_app = typer.Typer(add_completion=False)
59
-
60
-
61
- def _raise_exit(message: str, *, cause: Optional[BaseException] = None) -> NoReturn:
62
- typer.echo(message, err=True)
63
- if cause is not None:
64
- raise typer.Exit(code=1) from cause
65
- raise typer.Exit(code=1)
66
-
67
-
68
- def _require_repo_config(repo: Optional[Path], hub: Optional[Path]) -> Engine:
69
- try:
70
- repo_root = find_repo_root(repo or Path.cwd())
71
- except RepoNotFoundError as exc:
72
- _raise_exit("No .git directory found for repo commands.", cause=exc)
73
- try:
74
- return Engine(repo_root, hub_path=hub)
75
- except ConfigError as exc:
76
- _raise_exit(str(exc), cause=exc)
77
-
78
-
79
- def _require_hub_config(path: Optional[Path]) -> HubConfig:
80
- try:
81
- return load_hub_config(path or Path.cwd())
82
- except ConfigError as exc:
83
- _raise_exit(str(exc), cause=exc)
84
-
85
-
86
- def _build_server_url(config, path: str) -> str:
87
- base_path = config.server_base_path or ""
88
- if base_path.endswith("/") and path.startswith("/"):
89
- base_path = base_path[:-1]
90
- return f"http://{config.server_host}:{config.server_port}{base_path}{path}"
91
-
92
-
93
- def _resolve_hub_config_path_for_cli(
94
- repo_root: Path, hub: Optional[Path]
95
- ) -> Optional[Path]:
96
- if hub:
97
- candidate = hub
98
- if candidate.is_dir():
99
- candidate = candidate / CONFIG_FILENAME
100
- return candidate if candidate.exists() else None
101
- return find_nearest_hub_config_path(repo_root)
102
-
103
-
104
- def _resolve_repo_api_path(repo_root: Path, hub: Optional[Path], path: str) -> str:
105
- if not path.startswith("/"):
106
- path = f"/{path}"
107
- hub_config_path = _resolve_hub_config_path_for_cli(repo_root, hub)
108
- if hub_config_path is None:
109
- return path
110
- hub_root = hub_config_path.parent.parent.resolve()
111
- manifest_rel: Optional[str] = None
112
- try:
113
- raw = yaml.safe_load(hub_config_path.read_text(encoding="utf-8")) or {}
114
- if isinstance(raw, dict):
115
- hub_cfg = raw.get("hub")
116
- if isinstance(hub_cfg, dict):
117
- manifest_value = hub_cfg.get("manifest")
118
- if isinstance(manifest_value, str) and manifest_value.strip():
119
- manifest_rel = manifest_value.strip()
120
- except (OSError, yaml.YAMLError, KeyError, ValueError) as exc:
121
- logger.debug("Failed to read hub config for manifest: %s", exc)
122
- manifest_rel = None
123
- manifest_path = hub_root / (manifest_rel or ".codex-autorunner/manifest.yml")
124
- if not manifest_path.exists():
125
- return path
126
- try:
127
- manifest = load_manifest(manifest_path, hub_root)
128
- except (OSError, ValueError, KeyError) as exc:
129
- logger.debug("Failed to load manifest: %s", exc)
130
- return path
131
- repo_root = repo_root.resolve()
132
- for entry in manifest.repos:
133
- candidate = (hub_root / entry.path).resolve()
134
- if candidate == repo_root:
135
- return f"/repos/{entry.id}{path}"
136
- return path
137
-
138
-
139
- def _resolve_auth_token(env_name: str) -> Optional[str]:
140
- if not env_name:
141
- return None
142
- value = os.environ.get(env_name)
143
- if value is None:
144
- return None
145
- value = value.strip()
146
- return value or None
147
-
148
-
149
- def _require_auth_token(env_name: Optional[str]) -> Optional[str]:
150
- if not env_name:
151
- return None
152
- token = _resolve_auth_token(env_name)
153
- if not token:
154
- _raise_exit(
155
- f"server.auth_token_env is set to {env_name}, but the environment variable is missing."
156
- )
157
- return token
158
-
159
-
160
- def _is_loopback_host(host: str) -> bool:
161
- if host == "localhost":
162
- return True
163
- try:
164
- return ipaddress.ip_address(host).is_loopback
165
- except ValueError:
166
- return False
167
-
168
-
169
- def _enforce_bind_auth(host: str, token_env: str) -> None:
170
- if _is_loopback_host(host):
171
- return
172
- if _resolve_auth_token(token_env):
173
- return
174
- _raise_exit(
175
- "Refusing to bind to a non-loopback host without server.auth_token_env set."
176
- )
177
-
178
-
179
- def _request_json(
180
- method: str,
181
- url: str,
182
- payload: Optional[dict] = None,
183
- token_env: Optional[str] = None,
184
- ) -> dict:
185
- headers = None
186
- if token_env:
187
- token = _require_auth_token(token_env)
188
- headers = {"Authorization": f"Bearer {token}"}
189
- response = httpx.request(method, url, json=payload, timeout=2.0, headers=headers)
190
- response.raise_for_status()
191
- data = response.json()
192
- return data if isinstance(data, dict) else {}
193
-
194
-
195
- def _require_optional_feature(
196
- *, feature: str, deps: list[tuple[str, str]], extra: Optional[str] = None
197
- ) -> None:
198
- try:
199
- require_optional_dependencies(feature=feature, deps=deps, extra=extra)
200
- except ConfigError as exc:
201
- _raise_exit(str(exc), cause=exc)
202
-
203
-
204
- app.add_typer(hub_app, name="hub")
205
- app.add_typer(telegram_app, name="telegram")
206
-
207
-
208
- def _has_nested_git(path: Path) -> bool:
209
- try:
210
- for child in path.iterdir():
211
- if not child.is_dir() or child.is_symlink():
212
- continue
213
- if (child / ".git").exists():
214
- return True
215
- if _has_nested_git(child):
216
- return True
217
- except OSError:
218
- return False
219
- return False
220
-
221
-
222
- @app.command()
223
- def init(
224
- path: Optional[Path] = typer.Argument(None, help="Repo path; defaults to CWD"),
225
- force: bool = typer.Option(False, "--force", help="Overwrite existing files"),
226
- git_init: bool = typer.Option(False, "--git-init", help="Run git init if missing"),
227
- mode: str = typer.Option(
228
- "auto",
229
- "--mode",
230
- help="Initialization mode: repo, hub, or auto (default)",
231
- ),
232
- ):
233
- """Initialize a repo for Codex autorunner."""
234
- start_path = (path or Path.cwd()).resolve()
235
- mode = (mode or "auto").lower()
236
- if mode not in ("auto", "repo", "hub"):
237
- _raise_exit("Invalid mode; expected repo, hub, or auto")
238
-
239
- git_required = True
240
- target_root: Optional[Path] = None
241
- selected_mode = mode
242
-
243
- # First try to treat this as a repo init if requested or auto-detected via .git.
244
- if mode in ("auto", "repo"):
245
- try:
246
- target_root = find_repo_root(start_path)
247
- selected_mode = "repo"
248
- except RepoNotFoundError:
249
- target_root = None
250
-
251
- # If no git root was found, decide between hub or repo-with-git-init.
252
- if target_root is None:
253
- target_root = start_path
254
- if mode in ("hub",) or (mode == "auto" and _has_nested_git(target_root)):
255
- selected_mode = "hub"
256
- git_required = False
257
- elif git_init:
258
- selected_mode = "repo"
259
- try:
260
- proc = run_git(["init"], target_root, check=False)
261
- except GitError as exc:
262
- _raise_exit(f"git init failed: {exc}")
263
- if proc.returncode != 0:
264
- detail = (
265
- proc.stderr or proc.stdout or ""
266
- ).strip() or f"exit {proc.returncode}"
267
- _raise_exit(f"git init failed: {detail}")
268
- else:
269
- _raise_exit("No .git directory found; rerun with --git-init to create one")
270
-
271
- ca_dir = target_root / ".codex-autorunner"
272
- ca_dir.mkdir(parents=True, exist_ok=True)
273
-
274
- hub_config_path = find_nearest_hub_config_path(target_root)
275
- try:
276
- if selected_mode == "hub":
277
- seed_hub_files(target_root, force=force)
278
- typer.echo(f"Initialized hub at {ca_dir}")
279
- else:
280
- seed_repo_files(target_root, force=force, git_required=git_required)
281
- typer.echo(f"Initialized repo at {ca_dir}")
282
- if hub_config_path is None:
283
- seed_hub_files(target_root, force=force)
284
- typer.echo(f"Initialized hub at {ca_dir}")
285
- except ConfigError as exc:
286
- _raise_exit(str(exc), cause=exc)
287
- typer.echo("Init complete")
288
-
289
-
290
- @app.command()
291
- def status(
292
- repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
293
- hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
294
- output_json: bool = typer.Option(False, "--json", help="Emit JSON output"),
295
- ):
296
- """Show autorunner status."""
297
- engine = _require_repo_config(repo, hub)
298
- state = load_state(engine.state_path)
299
- outstanding, _ = engine.docs.todos()
300
- repo_key = str(engine.repo_root)
301
- session_id = state.repo_to_session.get(repo_key) or state.repo_to_session.get(
302
- f"{repo_key}:codex"
303
- )
304
- opencode_session_id = state.repo_to_session.get(f"{repo_key}:opencode")
305
- session_record = state.sessions.get(session_id) if session_id else None
306
- opencode_record = (
307
- state.sessions.get(opencode_session_id) if opencode_session_id else None
308
- )
309
-
310
- if output_json:
311
- hub_config_path = _resolve_hub_config_path_for_cli(engine.repo_root, hub)
312
- payload = {
313
- "repo": str(engine.repo_root),
314
- "hub": (
315
- str(hub_config_path.parent.parent.resolve())
316
- if hub_config_path
317
- else None
318
- ),
319
- "status": state.status,
320
- "last_run_id": state.last_run_id,
321
- "last_exit_code": state.last_exit_code,
322
- "last_run_started_at": state.last_run_started_at,
323
- "last_run_finished_at": state.last_run_finished_at,
324
- "runner_pid": state.runner_pid,
325
- "session_id": session_id,
326
- "session_record": (
327
- {
328
- "repo_path": session_record.repo_path,
329
- "created_at": session_record.created_at,
330
- "last_seen_at": session_record.last_seen_at,
331
- "status": session_record.status,
332
- "agent": session_record.agent,
333
- }
334
- if session_record
335
- else None
336
- ),
337
- "opencode_session_id": opencode_session_id,
338
- "opencode_record": (
339
- {
340
- "repo_path": opencode_record.repo_path,
341
- "created_at": opencode_record.created_at,
342
- "last_seen_at": opencode_record.last_seen_at,
343
- "status": opencode_record.status,
344
- "agent": opencode_record.agent,
345
- }
346
- if opencode_record
347
- else None
348
- ),
349
- "outstanding_todos": len(outstanding),
350
- }
351
- typer.echo(json.dumps(payload, indent=2))
352
- return
353
-
354
- typer.echo(f"Repo: {engine.repo_root}")
355
- typer.echo(f"Status: {state.status}")
356
- typer.echo(f"Last run id: {state.last_run_id}")
357
- typer.echo(f"Last exit code: {state.last_exit_code}")
358
- typer.echo(f"Last start: {state.last_run_started_at}")
359
- typer.echo(f"Last finish: {state.last_run_finished_at}")
360
- typer.echo(f"Runner pid: {state.runner_pid}")
361
- if not session_id and not opencode_session_id:
362
- typer.echo("Terminal session: none")
363
- if session_id:
364
- detail = ""
365
- if session_record:
366
- detail = f" (status={session_record.status}, last_seen={session_record.last_seen_at})"
367
- typer.echo(f"Terminal session (codex): {session_id}{detail}")
368
- if opencode_session_id and opencode_session_id != session_id:
369
- detail = ""
370
- if opencode_record:
371
- detail = f" (status={opencode_record.status}, last_seen={opencode_record.last_seen_at})"
372
- typer.echo(f"Terminal session (opencode): {opencode_session_id}{detail}")
373
- typer.echo(f"Outstanding TODO items: {len(outstanding)}")
374
-
375
-
376
- @app.command()
377
- def sessions(
378
- repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
379
- hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
380
- output_json: bool = typer.Option(False, "--json", help="Emit JSON output"),
381
- ):
382
- """List active terminal sessions."""
383
- engine = _require_repo_config(repo, hub)
384
- config = engine.config
385
- path = _resolve_repo_api_path(engine.repo_root, hub, "/api/sessions")
386
- url = _build_server_url(config, path)
387
- auth_token = _resolve_auth_token(config.server_auth_token_env)
388
- if auth_token:
389
- url = f"{url}?include_abs_paths=1"
390
- payload = None
391
- source = "server"
392
- try:
393
- payload = _request_json("GET", url, token_env=config.server_auth_token_env)
394
- except (
395
- httpx.HTTPError,
396
- httpx.ConnectError,
397
- httpx.TimeoutException,
398
- OSError,
399
- ) as exc:
400
- logger.debug(
401
- "Failed to fetch sessions from server, falling back to state: %s", exc
402
- )
403
- state = load_state(engine.state_path)
404
- payload = {
405
- "sessions": [
406
- {
407
- "session_id": session_id,
408
- "repo_path": record.repo_path,
409
- "created_at": record.created_at,
410
- "last_seen_at": record.last_seen_at,
411
- "status": record.status,
412
- "alive": None,
413
- }
414
- for session_id, record in state.sessions.items()
415
- ],
416
- "repo_to_session": dict(state.repo_to_session),
417
- }
418
- source = "state"
419
-
420
- if output_json:
421
- if source != "server":
422
- payload["source"] = source
423
- typer.echo(json.dumps(payload, indent=2))
424
- return
425
-
426
- sessions_payload = payload.get("sessions", []) if isinstance(payload, dict) else []
427
- typer.echo(f"Sessions ({source}): {len(sessions_payload)}")
428
- for entry in sessions_payload:
429
- if not isinstance(entry, dict):
430
- continue
431
- session_id = entry.get("session_id") or "unknown"
432
- repo_path = entry.get("abs_repo_path") or entry.get("repo_path") or "unknown"
433
- status = entry.get("status") or "unknown"
434
- last_seen = entry.get("last_seen_at") or "unknown"
435
- alive = entry.get("alive")
436
- alive_text = "unknown" if alive is None else str(bool(alive))
437
- typer.echo(
438
- f"- {session_id}: repo={repo_path} status={status} last_seen={last_seen} alive={alive_text}"
439
- )
440
-
441
-
442
- @app.command("stop-session")
443
- def stop_session(
444
- repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
445
- hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
446
- session_id: Optional[str] = typer.Option(
447
- None, "--session", help="Session id to stop"
448
- ),
449
- ):
450
- """Stop a terminal session by id or repo path."""
451
- engine = _require_repo_config(repo, hub)
452
- config = engine.config
453
- payload: dict[str, str] = {}
454
- if session_id:
455
- payload["session_id"] = session_id
456
- else:
457
- payload["repo_path"] = str(engine.repo_root)
458
-
459
- path = _resolve_repo_api_path(engine.repo_root, hub, "/api/sessions/stop")
460
- url = _build_server_url(config, path)
461
- try:
462
- response = _request_json(
463
- "POST", url, payload, token_env=config.server_auth_token_env
464
- )
465
- stopped_id = response.get("session_id", payload.get("session_id", ""))
466
- typer.echo(f"Stopped session {stopped_id}")
467
- return
468
- except (
469
- httpx.HTTPError,
470
- httpx.ConnectError,
471
- httpx.TimeoutException,
472
- OSError,
473
- ) as exc:
474
- logger.debug(
475
- "Failed to stop session via server, falling back to state: %s", exc
476
- )
477
-
478
- with state_lock(engine.state_path):
479
- state = load_state(engine.state_path)
480
- target_id = payload.get("session_id")
481
- if not target_id:
482
- repo_lookup = payload.get("repo_path")
483
- if repo_lookup:
484
- target_id = (
485
- state.repo_to_session.get(repo_lookup)
486
- or state.repo_to_session.get(f"{repo_lookup}:codex")
487
- or state.repo_to_session.get(f"{repo_lookup}:opencode")
488
- )
489
- if not target_id:
490
- _raise_exit("Session not found (server unavailable)")
491
- state.sessions.pop(target_id, None)
492
- state.repo_to_session = {
493
- repo_key: sid
494
- for repo_key, sid in state.repo_to_session.items()
495
- if sid != target_id
496
- }
497
- save_state(engine.state_path, state)
498
- typer.echo(f"Stopped session {target_id} (state only)")
499
-
500
-
501
- @app.command()
502
- def usage(
503
- repo: Optional[Path] = typer.Option(
504
- None, "--repo", help="Repo or hub path; defaults to CWD"
505
- ),
506
- hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
507
- codex_home: Optional[Path] = typer.Option(
508
- None, "--codex-home", help="Override CODEX_HOME (defaults to env or ~/.codex)"
509
- ),
510
- since: Optional[str] = typer.Option(
511
- None,
512
- "--since",
513
- help="ISO timestamp filter, e.g. 2025-12-01 or 2025-12-01T12:00Z",
514
- ),
515
- until: Optional[str] = typer.Option(
516
- None, "--until", help="Upper bound ISO timestamp filter"
517
- ),
518
- output_json: bool = typer.Option(False, "--json", help="Emit JSON output"),
519
- ):
520
- """Show Codex/OpenCode token usage for a repo or hub by reading local session logs."""
521
- try:
522
- since_dt = parse_iso_datetime(since)
523
- until_dt = parse_iso_datetime(until)
524
- except UsageError as exc:
525
- _raise_exit(str(exc), cause=exc)
526
-
527
- codex_root = (codex_home or default_codex_home()).expanduser()
528
-
529
- repo_root: Optional[Path] = None
530
- try:
531
- repo_root = find_repo_root(repo or Path.cwd())
532
- except RepoNotFoundError:
533
- repo_root = None
534
-
535
- if repo_root and (repo_root / ".codex-autorunner" / "state.sqlite3").exists():
536
- engine = _require_repo_config(repo, hub)
537
- else:
538
- try:
539
- config = load_hub_config(hub or repo or Path.cwd())
540
- except ConfigError as exc:
541
- _raise_exit(str(exc), cause=exc)
542
- manifest = load_manifest(config.manifest_path, config.root)
543
- repo_map = [(entry.id, (config.root / entry.path)) for entry in manifest.repos]
544
- per_repo, unmatched = summarize_hub_usage(
545
- repo_map,
546
- codex_root,
547
- since=since_dt,
548
- until=until_dt,
549
- )
550
- if output_json:
551
- payload = {
552
- "mode": "hub",
553
- "hub_root": str(config.root),
554
- "codex_home": str(codex_root),
555
- "since": since,
556
- "until": until,
557
- "repos": {
558
- repo_id: summary.to_dict() for repo_id, summary in per_repo.items()
559
- },
560
- "unmatched": unmatched.to_dict(),
561
- }
562
- typer.echo(json.dumps(payload, indent=2))
563
- return
564
-
565
- typer.echo(f"Hub: {config.root}")
566
- typer.echo(f"CODEX_HOME: {codex_root}")
567
- typer.echo(f"Repos: {len(per_repo)}")
568
- for repo_id, summary in per_repo.items():
569
- typer.echo(
570
- f"- {repo_id}: total={summary.totals.total_tokens} "
571
- f"(input={summary.totals.input_tokens}, cached={summary.totals.cached_input_tokens}, "
572
- f"output={summary.totals.output_tokens}, reasoning={summary.totals.reasoning_output_tokens}) "
573
- f"events={summary.events}"
574
- )
575
- if unmatched.events or unmatched.totals.total_tokens:
576
- typer.echo(
577
- f"- unmatched: total={unmatched.totals.total_tokens} events={unmatched.events}"
578
- )
579
- return
580
-
581
- summary = summarize_repo_usage(
582
- engine.repo_root,
583
- codex_root,
584
- since=since_dt,
585
- until=until_dt,
586
- )
587
-
588
- if output_json:
589
- payload = {
590
- "mode": "repo",
591
- "repo": str(engine.repo_root),
592
- "codex_home": str(codex_root),
593
- "since": since,
594
- "until": until,
595
- "usage": summary.to_dict(),
596
- }
597
- typer.echo(json.dumps(payload, indent=2))
598
- return
599
-
600
- typer.echo(f"Repo: {engine.repo_root}")
601
- typer.echo(f"CODEX_HOME: {codex_root}")
602
- typer.echo(
603
- f"Totals: total={summary.totals.total_tokens} "
604
- f"(input={summary.totals.input_tokens}, cached={summary.totals.cached_input_tokens}, "
605
- f"output={summary.totals.output_tokens}, reasoning={summary.totals.reasoning_output_tokens})"
606
- )
607
- typer.echo(f"Events counted: {summary.events}")
608
- if summary.latest_rate_limits:
609
- primary = summary.latest_rate_limits.get("primary", {}) or {}
610
- secondary = summary.latest_rate_limits.get("secondary", {}) or {}
611
- typer.echo(
612
- f"Latest rate limits: primary_used={primary.get('used_percent')}%/{primary.get('window_minutes')}m, "
613
- f"secondary_used={secondary.get('used_percent')}%/{secondary.get('window_minutes')}m"
614
- )
615
-
616
-
617
- @app.command()
618
- def run(
619
- repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
620
- hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
621
- force: bool = typer.Option(False, "--force", help="Ignore existing lock"),
622
- ):
623
- """Run the autorunner loop."""
624
- engine: Optional[Engine] = None
625
- try:
626
- engine = _require_repo_config(repo, hub)
627
- engine.clear_stop_request()
628
- engine.acquire_lock(force=force)
629
- engine.run_loop()
630
- except (ConfigError, LockError) as exc:
631
- _raise_exit(str(exc), cause=exc)
632
- finally:
633
- if engine:
634
- try:
635
- engine.release_lock()
636
- except OSError as exc:
637
- logger.debug("Failed to release lock in run command: %s", exc)
638
-
639
-
640
- @app.command()
641
- def once(
642
- repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
643
- hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
644
- force: bool = typer.Option(False, "--force", help="Ignore existing lock"),
645
- ):
646
- """Execute a single Codex run."""
647
- engine: Optional[Engine] = None
648
- try:
649
- engine = _require_repo_config(repo, hub)
650
- engine.clear_stop_request()
651
- engine.acquire_lock(force=force)
652
- engine.run_once()
653
- except (ConfigError, LockError) as exc:
654
- _raise_exit(str(exc), cause=exc)
655
- finally:
656
- if engine:
657
- try:
658
- engine.release_lock()
659
- except OSError as exc:
660
- logger.debug("Failed to release lock in once command: %s", exc)
661
-
662
-
663
- @app.command()
664
- def kill(
665
- repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
666
- hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
667
- ):
668
- """Force-kill a running autorunner and clear stale lock/state."""
669
- engine = _require_repo_config(repo, hub)
670
- pid = engine.kill_running_process()
671
- with state_lock(engine.state_path):
672
- state = load_state(engine.state_path)
673
- new_state = RunnerState(
674
- last_run_id=state.last_run_id,
675
- status="error",
676
- last_exit_code=137,
677
- last_run_started_at=state.last_run_started_at,
678
- last_run_finished_at=now_iso(),
679
- autorunner_agent_override=state.autorunner_agent_override,
680
- autorunner_model_override=state.autorunner_model_override,
681
- autorunner_effort_override=state.autorunner_effort_override,
682
- autorunner_approval_policy=state.autorunner_approval_policy,
683
- autorunner_sandbox_mode=state.autorunner_sandbox_mode,
684
- autorunner_workspace_write_network=state.autorunner_workspace_write_network,
685
- runner_pid=None,
686
- sessions=state.sessions,
687
- repo_to_session=state.repo_to_session,
688
- )
689
- save_state(engine.state_path, new_state)
690
- clear_stale_lock(engine.lock_path)
691
- if pid:
692
- typer.echo(f"Sent SIGTERM to pid {pid}")
693
- else:
694
- typer.echo("No active autorunner process found; cleared stale lock if any.")
695
-
696
-
697
- @app.command()
698
- def resume(
699
- repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
700
- hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
701
- once: bool = typer.Option(False, "--once", help="Resume with a single run"),
702
- force: bool = typer.Option(False, "--force", help="Override active lock"),
703
- ):
704
- """Resume a stopped/errored autorunner, clearing stale locks if needed."""
705
- engine: Optional[Engine] = None
706
- try:
707
- engine = _require_repo_config(repo, hub)
708
- engine.clear_stop_request()
709
- clear_stale_lock(engine.lock_path)
710
- engine.acquire_lock(force=force)
711
- engine.run_loop(stop_after_runs=1 if once else None)
712
- except (ConfigError, LockError) as exc:
713
- _raise_exit(str(exc), cause=exc)
714
- finally:
715
- if engine:
716
- try:
717
- engine.release_lock()
718
- except OSError as exc:
719
- logger.debug("Failed to release lock in resume command: %s", exc)
720
-
721
-
722
- @app.command()
723
- def log(
724
- repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
725
- hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
726
- run_id: Optional[int] = typer.Option(None, "--run", help="Show a specific run"),
727
- tail: Optional[int] = typer.Option(None, "--tail", help="Tail last N lines"),
728
- ):
729
- """Show autorunner log output."""
730
- engine = _require_repo_config(repo, hub)
731
- if not engine.log_path.exists():
732
- _raise_exit("Log file not found; run init")
733
-
734
- if run_id is not None:
735
- block = engine.read_run_block(run_id)
736
- if not block:
737
- _raise_exit("run not found")
738
- typer.echo(block)
739
- return
740
-
741
- if tail is not None:
742
- typer.echo(engine.tail_log(tail))
743
- else:
744
- state = load_state(engine.state_path)
745
- last_id = state.last_run_id
746
- if last_id is None:
747
- typer.echo("No runs recorded yet")
748
- return
749
- block = engine.read_run_block(last_id)
750
- if not block:
751
- typer.echo("No run block found (log may have rotated)")
752
- return
753
- typer.echo(block)
754
-
755
-
756
- @app.command()
757
- def edit(
758
- target: str = typer.Argument(..., help="todo|progress|opinions|spec"),
759
- repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
760
- hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
761
- ):
762
- """Open one of the docs in $EDITOR."""
763
- engine = _require_repo_config(repo, hub)
764
- config = engine.config
765
- key = target.lower()
766
- if key not in ("todo", "progress", "opinions", "spec"):
767
- _raise_exit("Invalid target; choose todo, progress, opinions, or spec")
768
- path = config.doc_path(key)
769
- editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or default_editor()
770
- editor_parts = shlex.split(editor)
771
- if not editor_parts:
772
- editor_parts = [editor]
773
- typer.echo(f"Opening {path} with {' '.join(editor_parts)}")
774
- subprocess.run([*editor_parts, str(path)])
775
-
776
-
777
- @app.command("ingest-spec")
778
- def ingest_spec_cmd(
779
- repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
780
- hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
781
- spec: Optional[Path] = typer.Option(
782
- None, "--spec", help="Path to SPEC (defaults to configured docs.spec)"
783
- ),
784
- force: bool = typer.Option(
785
- False, "--force", help="Overwrite TODO/PROGRESS/OPINIONS"
786
- ),
787
- ):
788
- """Generate TODO/PROGRESS/OPINIONS from SPEC using Codex."""
789
- try:
790
- engine = _require_repo_config(repo, hub)
791
- config = engine.config
792
- if not config.app_server.command:
793
- raise SpecIngestError("app_server.command must be configured")
794
-
795
- async def _run_ingest() -> dict:
796
- logger = logging.getLogger("codex_autorunner.cli.app_server")
797
-
798
- def _env_builder(
799
- workspace_root: Path, _workspace_id: str, state_dir: Path
800
- ) -> dict[str, str]:
801
- state_dir.mkdir(parents=True, exist_ok=True)
802
- return build_app_server_env(
803
- config.app_server.command,
804
- workspace_root,
805
- state_dir,
806
- logger=logger,
807
- event_prefix="cli",
808
- )
809
-
810
- supervisor = WorkspaceAppServerSupervisor(
811
- config.app_server.command,
812
- state_root=config.app_server.state_root,
813
- env_builder=_env_builder,
814
- logger=logger,
815
- max_handles=config.app_server.max_handles,
816
- idle_ttl_seconds=config.app_server.idle_ttl_seconds,
817
- request_timeout=config.app_server.request_timeout,
818
- )
819
- service = SpecIngestService(engine, app_server_supervisor=supervisor)
820
- try:
821
- await service.execute(force=force, spec_path=spec, message=None)
822
- return service.apply_patch()
823
- finally:
824
- await supervisor.close_all()
825
-
826
- docs = asyncio.run(_run_ingest())
827
- except (ConfigError, SpecIngestError) as exc:
828
- _raise_exit(str(exc), cause=exc)
829
-
830
- typer.echo("Ingested SPEC into TODO/PROGRESS/OPINIONS.")
831
- for key, content in docs.items():
832
- lines = len(content.splitlines())
833
- typer.echo(f"- {key.upper()}: {lines} lines")
834
-
835
-
836
- @app.command("clear-docs")
837
- def clear_docs_cmd(
838
- repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
839
- hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
840
- yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
841
- ):
842
- """Clear TODO/PROGRESS/OPINIONS to empty templates."""
843
- if not yes:
844
- confirm = input("Clear TODO/PROGRESS/OPINIONS? Type CLEAR to confirm: ").strip()
845
- if confirm.upper() != "CLEAR":
846
- _raise_exit("Aborted.")
847
- engine = _require_repo_config(repo, hub)
848
- try:
849
- clear_work_docs(engine)
850
- except ConfigError as exc:
851
- _raise_exit(str(exc), cause=exc)
852
- typer.echo("Cleared TODO/PROGRESS/OPINIONS.")
853
-
854
-
855
- @app.command("doctor")
856
- def doctor_cmd(
857
- repo: Optional[Path] = typer.Option(None, "--repo", help="Repo or hub path"),
858
- json_output: bool = typer.Option(False, "--json", help="Output JSON for scripting"),
859
- ):
860
- """Validate repo or hub setup."""
861
- try:
862
- report = doctor(repo or Path.cwd())
863
- except ConfigError as exc:
864
- _raise_exit(str(exc), cause=exc)
865
- if json_output:
866
- typer.echo(json.dumps(report.to_dict(), indent=2))
867
- if report.has_errors():
868
- raise typer.Exit(code=1)
869
- return
870
- for check in report.checks:
871
- line = f"- {check.status.upper()}: {check.message}"
872
- if check.fix:
873
- line = f"{line} Fix: {check.fix}"
874
- typer.echo(line)
875
- if report.has_errors():
876
- _raise_exit("Doctor check failed")
877
- typer.echo("Doctor check passed")
878
-
879
-
880
- @app.command()
881
- def snapshot(
882
- repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
883
- hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
884
- ):
885
- """Generate or update `.codex-autorunner/SNAPSHOT.md`."""
886
- try:
887
- engine = _require_repo_config(repo, hub)
888
- config = engine.config
889
- if not config.app_server.command:
890
- raise SnapshotError("app_server.command must be configured")
891
-
892
- async def _run_snapshot() -> None:
893
- logger = logging.getLogger("codex_autorunner.cli.app_server")
894
-
895
- def _env_builder(
896
- workspace_root: Path, _workspace_id: str, state_dir: Path
897
- ) -> dict[str, str]:
898
- state_dir.mkdir(parents=True, exist_ok=True)
899
- return build_app_server_env(
900
- config.app_server.command,
901
- workspace_root,
902
- state_dir,
903
- logger=logger,
904
- event_prefix="cli",
905
- )
906
-
907
- supervisor = WorkspaceAppServerSupervisor(
908
- config.app_server.command,
909
- state_root=config.app_server.state_root,
910
- env_builder=_env_builder,
911
- logger=logger,
912
- max_handles=config.app_server.max_handles,
913
- idle_ttl_seconds=config.app_server.idle_ttl_seconds,
914
- request_timeout=config.app_server.request_timeout,
915
- )
916
- from .core.snapshot import SnapshotService
917
-
918
- service = SnapshotService(engine, app_server_supervisor=supervisor)
919
- try:
920
- await service.generate_snapshot()
921
- finally:
922
- await supervisor.close_all()
923
-
924
- asyncio.run(_run_snapshot())
925
- except (ConfigError, SnapshotError) as exc:
926
- _raise_exit(str(exc), cause=exc)
927
- typer.echo("Snapshot written to .codex-autorunner/SNAPSHOT.md")
928
-
929
-
930
- @app.command()
931
- def serve(
932
- path: Optional[Path] = typer.Option(None, "--path", "--hub", help="Hub root path"),
933
- host: Optional[str] = typer.Option(None, "--host", help="Host to bind"),
934
- port: Optional[int] = typer.Option(None, "--port", help="Port to bind"),
935
- base_path: Optional[str] = typer.Option(
936
- None, "--base-path", help="Base path for the server"
937
- ),
938
- ):
939
- """Start the hub web server and UI API."""
940
- try:
941
- config = load_hub_config(path or Path.cwd())
942
- except ConfigError as exc:
943
- _raise_exit(str(exc), cause=exc)
944
- bind_host = host or config.server_host
945
- bind_port = port or config.server_port
946
- normalized_base = (
947
- _normalize_base_path(base_path)
948
- if base_path is not None
949
- else config.server_base_path
950
- )
951
- _enforce_bind_auth(bind_host, config.server_auth_token_env)
952
- typer.echo(f"Serving hub on http://{bind_host}:{bind_port}{normalized_base or ''}")
953
- uvicorn.run(
954
- create_hub_app(config.root, base_path=normalized_base),
955
- host=bind_host,
956
- port=bind_port,
957
- root_path="",
958
- access_log=config.server_access_log,
959
- )
960
-
961
-
962
- @hub_app.command("create")
963
- def hub_create(
964
- repo_id: str = typer.Argument(..., help="Repo id to create and initialize"),
965
- repo_path: Optional[Path] = typer.Option(
966
- None,
967
- "--repo-path",
968
- help="Custom repo path relative to hub repos_root",
969
- ),
970
- path: Optional[Path] = typer.Option(None, "--path", help="Hub root path"),
971
- force: bool = typer.Option(False, "--force", help="Allow existing directory"),
972
- git_init: bool = typer.Option(
973
- True, "--git-init/--no-git-init", help="Run git init in the new repo"
974
- ),
975
- ):
976
- """Create a new git repo under the hub and initialize codex-autorunner files."""
977
- config = _require_hub_config(path)
978
- supervisor = HubSupervisor(config)
979
- try:
980
- snapshot = supervisor.create_repo(
981
- repo_id, repo_path, git_init=git_init, force=force
982
- )
983
- except Exception as exc:
984
- _raise_exit(str(exc), cause=exc)
985
- typer.echo(f"Created repo {snapshot.id} at {snapshot.path}")
986
-
987
-
988
- @hub_app.command("clone")
989
- def hub_clone(
990
- git_url: str = typer.Option(
991
- ..., "--git-url", help="Git URL or local path to clone"
992
- ),
993
- repo_id: Optional[str] = typer.Option(
994
- None, "--id", help="Repo id to register (defaults from git URL)"
995
- ),
996
- repo_path: Optional[Path] = typer.Option(
997
- None,
998
- "--repo-path",
999
- help="Custom repo path relative to hub repos_root",
1000
- ),
1001
- path: Optional[Path] = typer.Option(None, "--path", help="Hub root path"),
1002
- force: bool = typer.Option(False, "--force", help="Allow existing directory"),
1003
- ):
1004
- """Clone a git repo under the hub and initialize codex-autorunner files."""
1005
- config = _require_hub_config(path)
1006
- supervisor = HubSupervisor(config)
1007
- try:
1008
- snapshot = supervisor.clone_repo(
1009
- git_url=git_url, repo_id=repo_id, repo_path=repo_path, force=force
1010
- )
1011
- except Exception as exc:
1012
- _raise_exit(str(exc), cause=exc)
1013
- typer.echo(
1014
- f"Cloned repo {snapshot.id} at {snapshot.path} (status={snapshot.status.value})"
1015
- )
1016
-
1017
-
1018
- @hub_app.command("serve")
1019
- def hub_serve(
1020
- path: Optional[Path] = typer.Option(None, "--path", help="Hub root path"),
1021
- host: Optional[str] = typer.Option(None, "--host", help="Host to bind"),
1022
- port: Optional[int] = typer.Option(None, "--port", help="Port to bind"),
1023
- base_path: Optional[str] = typer.Option(
1024
- None, "--base-path", help="Base path for the server"
1025
- ),
1026
- ):
1027
- """Start the hub supervisor server."""
1028
- config = _require_hub_config(path)
1029
- normalized_base = (
1030
- _normalize_base_path(base_path)
1031
- if base_path is not None
1032
- else config.server_base_path
1033
- )
1034
- bind_host = host or config.server_host
1035
- bind_port = port or config.server_port
1036
- _enforce_bind_auth(bind_host, config.server_auth_token_env)
1037
- typer.echo(f"Serving hub on http://{bind_host}:{bind_port}{normalized_base or ''}")
1038
- uvicorn.run(
1039
- create_hub_app(config.root, base_path=normalized_base),
1040
- host=bind_host,
1041
- port=bind_port,
1042
- root_path="",
1043
- access_log=config.server_access_log,
1044
- )
1045
-
1046
-
1047
- @hub_app.command("scan")
1048
- def hub_scan(path: Optional[Path] = typer.Option(None, "--path", help="Hub root path")):
1049
- """Trigger discovery/init and print repo statuses."""
1050
- config = _require_hub_config(path)
1051
- supervisor = HubSupervisor(config)
1052
- snapshots = supervisor.scan()
1053
- typer.echo(f"Scanned hub at {config.root} (repos_root={config.repos_root})")
1054
- for snap in snapshots:
1055
- typer.echo(
1056
- f"- {snap.id}: {snap.status.value}, initialized={snap.initialized}, exists={snap.exists_on_disk}"
1057
- )
1058
-
1059
-
1060
- @telegram_app.command("start")
1061
- def telegram_start(
1062
- path: Optional[Path] = typer.Option(None, "--path", help="Repo or hub root path"),
1063
- ):
1064
- """Start the Telegram bot (polling)."""
1065
- _require_optional_feature(
1066
- feature="telegram",
1067
- deps=[("httpx", "httpx")],
1068
- extra="telegram",
1069
- )
1070
- try:
1071
- config = load_hub_config(path or Path.cwd())
1072
- except ConfigError as exc:
1073
- _raise_exit(str(exc), cause=exc)
1074
- telegram_cfg = TelegramBotConfig.from_raw(
1075
- config.raw.get("telegram_bot") if isinstance(config.raw, dict) else None,
1076
- root=config.root,
1077
- agent_binaries=getattr(config, "agents", None)
1078
- and {name: agent.binary for name, agent in config.agents.items()},
1079
- )
1080
- if not telegram_cfg.enabled:
1081
- _raise_exit("telegram_bot is disabled; set telegram_bot.enabled: true")
1082
- try:
1083
- telegram_cfg.validate()
1084
- except TelegramBotConfigError as exc:
1085
- _raise_exit(str(exc), cause=exc)
1086
- logger = setup_rotating_logger("codex-autorunner-telegram", config.log)
1087
- log_event(
1088
- logger,
1089
- logging.INFO,
1090
- "telegram.bot.starting",
1091
- root=str(config.root),
1092
- mode="hub",
1093
- )
1094
- voice_raw = config.repo_defaults.get("voice") if config.repo_defaults else None
1095
- voice_config = VoiceConfig.from_raw(voice_raw, env=os.environ)
1096
- update_repo_url = config.update_repo_url
1097
- update_repo_ref = config.update_repo_ref
1098
-
1099
- async def _run() -> None:
1100
- service = TelegramBotService(
1101
- telegram_cfg,
1102
- logger=logger,
1103
- hub_root=config.root,
1104
- manifest_path=config.manifest_path,
1105
- voice_config=voice_config,
1106
- housekeeping_config=config.housekeeping,
1107
- update_repo_url=update_repo_url,
1108
- update_repo_ref=update_repo_ref,
1109
- )
1110
- await service.run_polling()
1111
-
1112
- try:
1113
- asyncio.run(_run())
1114
- except TelegramBotLockError as exc:
1115
- _raise_exit(str(exc), cause=exc)
1116
-
1117
-
1118
- @telegram_app.command("health")
1119
- def telegram_health(
1120
- path: Optional[Path] = typer.Option(None, "--path", help="Repo or hub root path"),
1121
- timeout: float = typer.Option(5.0, "--timeout", help="Timeout (seconds)"),
1122
- ):
1123
- """Check Telegram API connectivity for the configured bot."""
1124
- _require_optional_feature(
1125
- feature="telegram",
1126
- deps=[("httpx", "httpx")],
1127
- extra="telegram",
1128
- )
1129
- try:
1130
- config = load_hub_config(path or Path.cwd())
1131
- except ConfigError as exc:
1132
- _raise_exit(str(exc), cause=exc)
1133
- telegram_cfg = TelegramBotConfig.from_raw(
1134
- config.raw.get("telegram_bot") if isinstance(config.raw, dict) else None,
1135
- root=config.root,
1136
- agent_binaries=getattr(config, "agents", None)
1137
- and {name: agent.binary for name, agent in config.agents.items()},
1138
- )
1139
- if not telegram_cfg.enabled:
1140
- _raise_exit("telegram_bot is disabled; set telegram_bot.enabled: true")
1141
- bot_token = telegram_cfg.bot_token
1142
- if not bot_token:
1143
- _raise_exit(f"missing bot token env '{telegram_cfg.bot_token_env}'")
1144
- timeout_seconds = max(float(timeout), 0.1)
1145
-
1146
- async def _run() -> None:
1147
- async with TelegramBotClient(bot_token) as client:
1148
- await asyncio.wait_for(client.get_me(), timeout=timeout_seconds)
1149
-
1150
- try:
1151
- asyncio.run(_run())
1152
- except TelegramAPIError as exc:
1153
- _raise_exit(f"Telegram health check failed: {exc}", cause=exc)
1154
- except Exception as exc:
1155
- _raise_exit(f"Telegram health check failed: {exc}", cause=exc)
1156
-
1157
-
1158
- if __name__ == "__main__":
1159
- app()
8
+ __all__ = ["app", "main", "_resolve_repo_api_path"]