codex-autorunner 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.
- codex_autorunner/__init__.py +3 -0
- codex_autorunner/bootstrap.py +151 -0
- codex_autorunner/cli.py +886 -0
- codex_autorunner/codex_cli.py +79 -0
- codex_autorunner/codex_runner.py +17 -0
- codex_autorunner/core/__init__.py +1 -0
- codex_autorunner/core/about_car.py +125 -0
- codex_autorunner/core/codex_runner.py +100 -0
- codex_autorunner/core/config.py +1465 -0
- codex_autorunner/core/doc_chat.py +547 -0
- codex_autorunner/core/docs.py +37 -0
- codex_autorunner/core/engine.py +720 -0
- codex_autorunner/core/git_utils.py +206 -0
- codex_autorunner/core/hub.py +756 -0
- codex_autorunner/core/injected_context.py +9 -0
- codex_autorunner/core/locks.py +57 -0
- codex_autorunner/core/logging_utils.py +158 -0
- codex_autorunner/core/notifications.py +465 -0
- codex_autorunner/core/optional_dependencies.py +41 -0
- codex_autorunner/core/prompt.py +107 -0
- codex_autorunner/core/prompts.py +275 -0
- codex_autorunner/core/request_context.py +21 -0
- codex_autorunner/core/runner_controller.py +116 -0
- codex_autorunner/core/runner_process.py +29 -0
- codex_autorunner/core/snapshot.py +576 -0
- codex_autorunner/core/state.py +156 -0
- codex_autorunner/core/update.py +567 -0
- codex_autorunner/core/update_runner.py +44 -0
- codex_autorunner/core/usage.py +1221 -0
- codex_autorunner/core/utils.py +108 -0
- codex_autorunner/discovery.py +102 -0
- codex_autorunner/housekeeping.py +423 -0
- codex_autorunner/integrations/__init__.py +1 -0
- codex_autorunner/integrations/app_server/__init__.py +6 -0
- codex_autorunner/integrations/app_server/client.py +1386 -0
- codex_autorunner/integrations/app_server/supervisor.py +206 -0
- codex_autorunner/integrations/github/__init__.py +10 -0
- codex_autorunner/integrations/github/service.py +889 -0
- codex_autorunner/integrations/telegram/__init__.py +1 -0
- codex_autorunner/integrations/telegram/adapter.py +1401 -0
- codex_autorunner/integrations/telegram/commands_registry.py +104 -0
- codex_autorunner/integrations/telegram/config.py +450 -0
- codex_autorunner/integrations/telegram/constants.py +154 -0
- codex_autorunner/integrations/telegram/dispatch.py +162 -0
- codex_autorunner/integrations/telegram/handlers/__init__.py +0 -0
- codex_autorunner/integrations/telegram/handlers/approvals.py +241 -0
- codex_autorunner/integrations/telegram/handlers/callbacks.py +72 -0
- codex_autorunner/integrations/telegram/handlers/commands.py +160 -0
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +5262 -0
- codex_autorunner/integrations/telegram/handlers/messages.py +477 -0
- codex_autorunner/integrations/telegram/handlers/selections.py +545 -0
- codex_autorunner/integrations/telegram/helpers.py +2084 -0
- codex_autorunner/integrations/telegram/notifications.py +164 -0
- codex_autorunner/integrations/telegram/outbox.py +174 -0
- codex_autorunner/integrations/telegram/rendering.py +102 -0
- codex_autorunner/integrations/telegram/retry.py +37 -0
- codex_autorunner/integrations/telegram/runtime.py +270 -0
- codex_autorunner/integrations/telegram/service.py +921 -0
- codex_autorunner/integrations/telegram/state.py +1223 -0
- codex_autorunner/integrations/telegram/transport.py +318 -0
- codex_autorunner/integrations/telegram/types.py +57 -0
- codex_autorunner/integrations/telegram/voice.py +413 -0
- codex_autorunner/manifest.py +150 -0
- codex_autorunner/routes/__init__.py +53 -0
- codex_autorunner/routes/base.py +470 -0
- codex_autorunner/routes/docs.py +275 -0
- codex_autorunner/routes/github.py +197 -0
- codex_autorunner/routes/repos.py +121 -0
- codex_autorunner/routes/sessions.py +137 -0
- codex_autorunner/routes/shared.py +137 -0
- codex_autorunner/routes/system.py +175 -0
- codex_autorunner/routes/terminal_images.py +107 -0
- codex_autorunner/routes/voice.py +128 -0
- codex_autorunner/server.py +23 -0
- codex_autorunner/spec_ingest.py +113 -0
- codex_autorunner/static/app.js +95 -0
- codex_autorunner/static/autoRefresh.js +209 -0
- codex_autorunner/static/bootstrap.js +105 -0
- codex_autorunner/static/bus.js +23 -0
- codex_autorunner/static/cache.js +52 -0
- codex_autorunner/static/constants.js +48 -0
- codex_autorunner/static/dashboard.js +795 -0
- codex_autorunner/static/docs.js +1514 -0
- codex_autorunner/static/env.js +99 -0
- codex_autorunner/static/github.js +168 -0
- codex_autorunner/static/hub.js +1511 -0
- codex_autorunner/static/index.html +622 -0
- codex_autorunner/static/loader.js +28 -0
- codex_autorunner/static/logs.js +690 -0
- codex_autorunner/static/mobileCompact.js +300 -0
- codex_autorunner/static/snapshot.js +116 -0
- codex_autorunner/static/state.js +87 -0
- codex_autorunner/static/styles.css +4966 -0
- codex_autorunner/static/tabs.js +50 -0
- codex_autorunner/static/terminal.js +21 -0
- codex_autorunner/static/terminalManager.js +3535 -0
- codex_autorunner/static/todoPreview.js +25 -0
- codex_autorunner/static/types.d.ts +8 -0
- codex_autorunner/static/utils.js +597 -0
- codex_autorunner/static/vendor/LICENSE.xterm +24 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/OFL.txt +93 -0
- codex_autorunner/static/vendor/xterm-addon-fit.js +2 -0
- codex_autorunner/static/vendor/xterm.css +209 -0
- codex_autorunner/static/vendor/xterm.js +2 -0
- codex_autorunner/static/voice.js +591 -0
- codex_autorunner/voice/__init__.py +39 -0
- codex_autorunner/voice/capture.py +349 -0
- codex_autorunner/voice/config.py +167 -0
- codex_autorunner/voice/provider.py +66 -0
- codex_autorunner/voice/providers/__init__.py +7 -0
- codex_autorunner/voice/providers/openai_whisper.py +345 -0
- codex_autorunner/voice/resolver.py +36 -0
- codex_autorunner/voice/service.py +210 -0
- codex_autorunner/web/__init__.py +1 -0
- codex_autorunner/web/app.py +1037 -0
- codex_autorunner/web/hub_jobs.py +181 -0
- codex_autorunner/web/middleware.py +552 -0
- codex_autorunner/web/pty_session.py +357 -0
- codex_autorunner/web/runner_manager.py +25 -0
- codex_autorunner/web/schemas.py +253 -0
- codex_autorunner/web/static_assets.py +430 -0
- codex_autorunner/web/terminal_sessions.py +78 -0
- codex_autorunner/workspace.py +16 -0
- codex_autorunner-0.1.0.dist-info/METADATA +240 -0
- codex_autorunner-0.1.0.dist-info/RECORD +147 -0
- codex_autorunner-0.1.0.dist-info/WHEEL +5 -0
- codex_autorunner-0.1.0.dist-info/entry_points.txt +3 -0
- codex_autorunner-0.1.0.dist-info/licenses/LICENSE +21 -0
- codex_autorunner-0.1.0.dist-info/top_level.txt +1 -0
codex_autorunner/cli.py
ADDED
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import ipaddress
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import NoReturn, Optional
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import typer
|
|
12
|
+
import uvicorn
|
|
13
|
+
|
|
14
|
+
from .bootstrap import seed_hub_files, seed_repo_files
|
|
15
|
+
from .core.config import ConfigError, HubConfig, _normalize_base_path, load_config
|
|
16
|
+
from .core.engine import Engine, LockError, clear_stale_lock, doctor
|
|
17
|
+
from .core.hub import HubSupervisor
|
|
18
|
+
from .core.logging_utils import log_event, setup_rotating_logger
|
|
19
|
+
from .core.optional_dependencies import require_optional_dependencies
|
|
20
|
+
from .core.snapshot import SnapshotError, generate_snapshot
|
|
21
|
+
from .core.state import RunnerState, load_state, now_iso, save_state, state_lock
|
|
22
|
+
from .core.usage import (
|
|
23
|
+
UsageError,
|
|
24
|
+
default_codex_home,
|
|
25
|
+
parse_iso_datetime,
|
|
26
|
+
summarize_hub_usage,
|
|
27
|
+
summarize_repo_usage,
|
|
28
|
+
)
|
|
29
|
+
from .core.utils import RepoNotFoundError, default_editor, find_repo_root
|
|
30
|
+
from .integrations.telegram.adapter import TelegramAPIError, TelegramBotClient
|
|
31
|
+
from .integrations.telegram.service import (
|
|
32
|
+
TelegramBotConfig,
|
|
33
|
+
TelegramBotConfigError,
|
|
34
|
+
TelegramBotLockError,
|
|
35
|
+
TelegramBotService,
|
|
36
|
+
)
|
|
37
|
+
from .manifest import load_manifest
|
|
38
|
+
from .server import create_app, create_hub_app
|
|
39
|
+
from .spec_ingest import (
|
|
40
|
+
SpecIngestError,
|
|
41
|
+
clear_work_docs,
|
|
42
|
+
generate_docs_from_spec,
|
|
43
|
+
write_ingested_docs,
|
|
44
|
+
)
|
|
45
|
+
from .voice import VoiceConfig
|
|
46
|
+
|
|
47
|
+
app = typer.Typer(add_completion=False)
|
|
48
|
+
hub_app = typer.Typer(add_completion=False)
|
|
49
|
+
telegram_app = typer.Typer(add_completion=False)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _raise_exit(message: str, *, cause: Optional[BaseException] = None) -> NoReturn:
|
|
53
|
+
typer.echo(message, err=True)
|
|
54
|
+
if cause is not None:
|
|
55
|
+
raise typer.Exit(code=1) from cause
|
|
56
|
+
raise typer.Exit(code=1)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _require_repo_config(repo: Optional[Path]) -> Engine:
|
|
60
|
+
try:
|
|
61
|
+
config = load_config(repo or Path.cwd())
|
|
62
|
+
except ConfigError as exc:
|
|
63
|
+
_raise_exit(str(exc), cause=exc)
|
|
64
|
+
if config.mode != "repo":
|
|
65
|
+
_raise_exit("This command must be run in repo mode (config.mode=repo).")
|
|
66
|
+
return Engine(config.root)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _require_hub_config(path: Optional[Path]) -> HubConfig:
|
|
70
|
+
try:
|
|
71
|
+
config = load_config(path or Path.cwd())
|
|
72
|
+
except ConfigError as exc:
|
|
73
|
+
_raise_exit(str(exc), cause=exc)
|
|
74
|
+
if not isinstance(config, HubConfig):
|
|
75
|
+
_raise_exit("This command requires hub mode (config.mode=hub).")
|
|
76
|
+
return config
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _build_server_url(config, path: str) -> str:
|
|
80
|
+
base_path = config.server_base_path or ""
|
|
81
|
+
if base_path.endswith("/") and path.startswith("/"):
|
|
82
|
+
base_path = base_path[:-1]
|
|
83
|
+
return f"http://{config.server_host}:{config.server_port}{base_path}{path}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _resolve_auth_token(env_name: str) -> Optional[str]:
|
|
87
|
+
if not env_name:
|
|
88
|
+
return None
|
|
89
|
+
value = os.environ.get(env_name)
|
|
90
|
+
if value is None:
|
|
91
|
+
return None
|
|
92
|
+
value = value.strip()
|
|
93
|
+
return value or None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _require_auth_token(env_name: Optional[str]) -> Optional[str]:
|
|
97
|
+
if not env_name:
|
|
98
|
+
return None
|
|
99
|
+
token = _resolve_auth_token(env_name)
|
|
100
|
+
if not token:
|
|
101
|
+
_raise_exit(
|
|
102
|
+
f"server.auth_token_env is set to {env_name}, but the environment variable is missing."
|
|
103
|
+
)
|
|
104
|
+
return token
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _is_loopback_host(host: str) -> bool:
|
|
108
|
+
if host == "localhost":
|
|
109
|
+
return True
|
|
110
|
+
try:
|
|
111
|
+
return ipaddress.ip_address(host).is_loopback
|
|
112
|
+
except ValueError:
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _enforce_bind_auth(host: str, token_env: str) -> None:
|
|
117
|
+
if _is_loopback_host(host):
|
|
118
|
+
return
|
|
119
|
+
if _resolve_auth_token(token_env):
|
|
120
|
+
return
|
|
121
|
+
_raise_exit(
|
|
122
|
+
"Refusing to bind to a non-loopback host without server.auth_token_env set."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _request_json(
|
|
127
|
+
method: str,
|
|
128
|
+
url: str,
|
|
129
|
+
payload: Optional[dict] = None,
|
|
130
|
+
token_env: Optional[str] = None,
|
|
131
|
+
) -> dict:
|
|
132
|
+
headers = None
|
|
133
|
+
if token_env:
|
|
134
|
+
token = _require_auth_token(token_env)
|
|
135
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
136
|
+
response = httpx.request(method, url, json=payload, timeout=2.0, headers=headers)
|
|
137
|
+
response.raise_for_status()
|
|
138
|
+
data = response.json()
|
|
139
|
+
return data if isinstance(data, dict) else {}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _require_optional_feature(
|
|
143
|
+
*, feature: str, deps: list[tuple[str, str]], extra: Optional[str] = None
|
|
144
|
+
) -> None:
|
|
145
|
+
try:
|
|
146
|
+
require_optional_dependencies(feature=feature, deps=deps, extra=extra)
|
|
147
|
+
except ConfigError as exc:
|
|
148
|
+
_raise_exit(str(exc), cause=exc)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
app.add_typer(hub_app, name="hub")
|
|
152
|
+
app.add_typer(telegram_app, name="telegram")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _has_nested_git(path: Path) -> bool:
|
|
156
|
+
try:
|
|
157
|
+
for child in path.iterdir():
|
|
158
|
+
if not child.is_dir() or child.is_symlink():
|
|
159
|
+
continue
|
|
160
|
+
if (child / ".git").exists():
|
|
161
|
+
return True
|
|
162
|
+
if _has_nested_git(child):
|
|
163
|
+
return True
|
|
164
|
+
except OSError:
|
|
165
|
+
return False
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@app.command()
|
|
170
|
+
def init(
|
|
171
|
+
path: Optional[Path] = typer.Argument(None, help="Repo path; defaults to CWD"),
|
|
172
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing files"),
|
|
173
|
+
git_init: bool = typer.Option(False, "--git-init", help="Run git init if missing"),
|
|
174
|
+
mode: str = typer.Option(
|
|
175
|
+
"auto",
|
|
176
|
+
"--mode",
|
|
177
|
+
help="Initialization mode: repo, hub, or auto (default)",
|
|
178
|
+
),
|
|
179
|
+
):
|
|
180
|
+
"""Initialize a repo for Codex autorunner."""
|
|
181
|
+
start_path = (path or Path.cwd()).resolve()
|
|
182
|
+
mode = (mode or "auto").lower()
|
|
183
|
+
if mode not in ("auto", "repo", "hub"):
|
|
184
|
+
_raise_exit("Invalid mode; expected repo, hub, or auto")
|
|
185
|
+
|
|
186
|
+
git_required = True
|
|
187
|
+
target_root: Optional[Path] = None
|
|
188
|
+
selected_mode = mode
|
|
189
|
+
|
|
190
|
+
# First try to treat this as a repo init if requested or auto-detected via .git.
|
|
191
|
+
if mode in ("auto", "repo"):
|
|
192
|
+
try:
|
|
193
|
+
target_root = find_repo_root(start_path)
|
|
194
|
+
selected_mode = "repo"
|
|
195
|
+
except RepoNotFoundError:
|
|
196
|
+
target_root = None
|
|
197
|
+
|
|
198
|
+
# If no git root was found, decide between hub or repo-with-git-init.
|
|
199
|
+
if target_root is None:
|
|
200
|
+
target_root = start_path
|
|
201
|
+
if mode in ("hub",) or (mode == "auto" and _has_nested_git(target_root)):
|
|
202
|
+
selected_mode = "hub"
|
|
203
|
+
git_required = False
|
|
204
|
+
elif git_init:
|
|
205
|
+
selected_mode = "repo"
|
|
206
|
+
subprocess.run(["git", "init"], cwd=target_root, check=False)
|
|
207
|
+
else:
|
|
208
|
+
_raise_exit("No .git directory found; rerun with --git-init to create one")
|
|
209
|
+
|
|
210
|
+
ca_dir = target_root / ".codex-autorunner"
|
|
211
|
+
ca_dir.mkdir(parents=True, exist_ok=True)
|
|
212
|
+
|
|
213
|
+
if selected_mode == "hub":
|
|
214
|
+
seed_hub_files(target_root, force=force)
|
|
215
|
+
typer.echo(f"Initialized hub at {ca_dir}")
|
|
216
|
+
else:
|
|
217
|
+
seed_repo_files(target_root, force=force, git_required=git_required)
|
|
218
|
+
typer.echo(f"Initialized repo at {ca_dir}")
|
|
219
|
+
typer.echo("Init complete")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@app.command()
|
|
223
|
+
def status(repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path")):
|
|
224
|
+
"""Show autorunner status."""
|
|
225
|
+
engine = _require_repo_config(repo)
|
|
226
|
+
state = load_state(engine.state_path)
|
|
227
|
+
outstanding, _ = engine.docs.todos()
|
|
228
|
+
session_id = state.repo_to_session.get(str(engine.repo_root))
|
|
229
|
+
session_record = state.sessions.get(session_id) if session_id else None
|
|
230
|
+
typer.echo(f"Repo: {engine.repo_root}")
|
|
231
|
+
typer.echo(f"Status: {state.status}")
|
|
232
|
+
typer.echo(f"Last run id: {state.last_run_id}")
|
|
233
|
+
typer.echo(f"Last exit code: {state.last_exit_code}")
|
|
234
|
+
typer.echo(f"Last start: {state.last_run_started_at}")
|
|
235
|
+
typer.echo(f"Last finish: {state.last_run_finished_at}")
|
|
236
|
+
typer.echo(f"Runner pid: {state.runner_pid}")
|
|
237
|
+
if session_id:
|
|
238
|
+
detail = ""
|
|
239
|
+
if session_record:
|
|
240
|
+
detail = f" (status={session_record.status}, last_seen={session_record.last_seen_at})"
|
|
241
|
+
typer.echo(f"Terminal session: {session_id}{detail}")
|
|
242
|
+
else:
|
|
243
|
+
typer.echo("Terminal session: none")
|
|
244
|
+
typer.echo(f"Outstanding TODO items: {len(outstanding)}")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@app.command()
|
|
248
|
+
def sessions(
|
|
249
|
+
repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
|
|
250
|
+
output_json: bool = typer.Option(False, "--json", help="Emit JSON output"),
|
|
251
|
+
):
|
|
252
|
+
"""List active terminal sessions."""
|
|
253
|
+
engine = _require_repo_config(repo)
|
|
254
|
+
config = engine.config
|
|
255
|
+
url = _build_server_url(config, "/api/sessions")
|
|
256
|
+
auth_token = _resolve_auth_token(config.server_auth_token_env)
|
|
257
|
+
if auth_token:
|
|
258
|
+
url = f"{url}?include_abs_paths=1"
|
|
259
|
+
payload = None
|
|
260
|
+
source = "server"
|
|
261
|
+
try:
|
|
262
|
+
payload = _request_json("GET", url, token_env=config.server_auth_token_env)
|
|
263
|
+
except Exception:
|
|
264
|
+
state = load_state(engine.state_path)
|
|
265
|
+
payload = {
|
|
266
|
+
"sessions": [
|
|
267
|
+
{
|
|
268
|
+
"session_id": session_id,
|
|
269
|
+
"repo_path": record.repo_path,
|
|
270
|
+
"created_at": record.created_at,
|
|
271
|
+
"last_seen_at": record.last_seen_at,
|
|
272
|
+
"status": record.status,
|
|
273
|
+
"alive": None,
|
|
274
|
+
}
|
|
275
|
+
for session_id, record in state.sessions.items()
|
|
276
|
+
],
|
|
277
|
+
"repo_to_session": dict(state.repo_to_session),
|
|
278
|
+
}
|
|
279
|
+
source = "state"
|
|
280
|
+
|
|
281
|
+
if output_json:
|
|
282
|
+
if source != "server":
|
|
283
|
+
payload["source"] = source
|
|
284
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
sessions_payload = payload.get("sessions", []) if isinstance(payload, dict) else []
|
|
288
|
+
typer.echo(f"Sessions ({source}): {len(sessions_payload)}")
|
|
289
|
+
for entry in sessions_payload:
|
|
290
|
+
if not isinstance(entry, dict):
|
|
291
|
+
continue
|
|
292
|
+
session_id = entry.get("session_id") or "unknown"
|
|
293
|
+
repo_path = entry.get("abs_repo_path") or entry.get("repo_path") or "unknown"
|
|
294
|
+
status = entry.get("status") or "unknown"
|
|
295
|
+
last_seen = entry.get("last_seen_at") or "unknown"
|
|
296
|
+
alive = entry.get("alive")
|
|
297
|
+
alive_text = "unknown" if alive is None else str(bool(alive))
|
|
298
|
+
typer.echo(
|
|
299
|
+
f"- {session_id}: repo={repo_path} status={status} last_seen={last_seen} alive={alive_text}"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@app.command("stop-session")
|
|
304
|
+
def stop_session(
|
|
305
|
+
repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
|
|
306
|
+
session_id: Optional[str] = typer.Option(
|
|
307
|
+
None, "--session", help="Session id to stop"
|
|
308
|
+
),
|
|
309
|
+
):
|
|
310
|
+
"""Stop a terminal session by id or repo path."""
|
|
311
|
+
engine = _require_repo_config(repo)
|
|
312
|
+
config = engine.config
|
|
313
|
+
payload: dict[str, str] = {}
|
|
314
|
+
if session_id:
|
|
315
|
+
payload["session_id"] = session_id
|
|
316
|
+
else:
|
|
317
|
+
payload["repo_path"] = str(engine.repo_root)
|
|
318
|
+
|
|
319
|
+
url = _build_server_url(config, "/api/sessions/stop")
|
|
320
|
+
try:
|
|
321
|
+
response = _request_json(
|
|
322
|
+
"POST", url, payload, token_env=config.server_auth_token_env
|
|
323
|
+
)
|
|
324
|
+
stopped_id = response.get("session_id", payload.get("session_id", ""))
|
|
325
|
+
typer.echo(f"Stopped session {stopped_id}")
|
|
326
|
+
return
|
|
327
|
+
except Exception:
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
with state_lock(engine.state_path):
|
|
331
|
+
state = load_state(engine.state_path)
|
|
332
|
+
target_id = payload.get("session_id")
|
|
333
|
+
if not target_id:
|
|
334
|
+
repo_lookup = payload.get("repo_path")
|
|
335
|
+
if repo_lookup:
|
|
336
|
+
target_id = state.repo_to_session.get(repo_lookup)
|
|
337
|
+
if not target_id:
|
|
338
|
+
_raise_exit("Session not found (server unavailable)")
|
|
339
|
+
state.sessions.pop(target_id, None)
|
|
340
|
+
state.repo_to_session = {
|
|
341
|
+
repo_key: sid
|
|
342
|
+
for repo_key, sid in state.repo_to_session.items()
|
|
343
|
+
if sid != target_id
|
|
344
|
+
}
|
|
345
|
+
save_state(engine.state_path, state)
|
|
346
|
+
typer.echo(f"Stopped session {target_id} (state only)")
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@app.command()
|
|
350
|
+
def usage(
|
|
351
|
+
repo: Optional[Path] = typer.Option(
|
|
352
|
+
None, "--repo", help="Repo or hub path; defaults to CWD"
|
|
353
|
+
),
|
|
354
|
+
codex_home: Optional[Path] = typer.Option(
|
|
355
|
+
None, "--codex-home", help="Override CODEX_HOME (defaults to env or ~/.codex)"
|
|
356
|
+
),
|
|
357
|
+
since: Optional[str] = typer.Option(
|
|
358
|
+
None,
|
|
359
|
+
"--since",
|
|
360
|
+
help="ISO timestamp filter, e.g. 2025-12-01 or 2025-12-01T12:00Z",
|
|
361
|
+
),
|
|
362
|
+
until: Optional[str] = typer.Option(
|
|
363
|
+
None, "--until", help="Upper bound ISO timestamp filter"
|
|
364
|
+
),
|
|
365
|
+
output_json: bool = typer.Option(False, "--json", help="Emit JSON output"),
|
|
366
|
+
):
|
|
367
|
+
"""Show Codex token usage for a repo or hub by reading CODEX_HOME session logs."""
|
|
368
|
+
try:
|
|
369
|
+
config = load_config(repo or Path.cwd())
|
|
370
|
+
except ConfigError as exc:
|
|
371
|
+
_raise_exit(str(exc), cause=exc)
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
since_dt = parse_iso_datetime(since)
|
|
375
|
+
until_dt = parse_iso_datetime(until)
|
|
376
|
+
except UsageError as exc:
|
|
377
|
+
_raise_exit(str(exc), cause=exc)
|
|
378
|
+
|
|
379
|
+
codex_root = (codex_home or default_codex_home()).expanduser()
|
|
380
|
+
|
|
381
|
+
if isinstance(config, HubConfig):
|
|
382
|
+
manifest = load_manifest(config.manifest_path, config.root)
|
|
383
|
+
repo_map = [(entry.id, (config.root / entry.path)) for entry in manifest.repos]
|
|
384
|
+
per_repo, unmatched = summarize_hub_usage(
|
|
385
|
+
repo_map,
|
|
386
|
+
codex_root,
|
|
387
|
+
since=since_dt,
|
|
388
|
+
until=until_dt,
|
|
389
|
+
)
|
|
390
|
+
if output_json:
|
|
391
|
+
payload = {
|
|
392
|
+
"mode": "hub",
|
|
393
|
+
"hub_root": str(config.root),
|
|
394
|
+
"codex_home": str(codex_root),
|
|
395
|
+
"since": since,
|
|
396
|
+
"until": until,
|
|
397
|
+
"repos": {
|
|
398
|
+
repo_id: summary.to_dict() for repo_id, summary in per_repo.items()
|
|
399
|
+
},
|
|
400
|
+
"unmatched": unmatched.to_dict(),
|
|
401
|
+
}
|
|
402
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
typer.echo(f"Hub: {config.root}")
|
|
406
|
+
typer.echo(f"CODEX_HOME: {codex_root}")
|
|
407
|
+
typer.echo(f"Repos: {len(per_repo)}")
|
|
408
|
+
for repo_id, summary in per_repo.items():
|
|
409
|
+
typer.echo(
|
|
410
|
+
f"- {repo_id}: total={summary.totals.total_tokens} "
|
|
411
|
+
f"(input={summary.totals.input_tokens}, cached={summary.totals.cached_input_tokens}, "
|
|
412
|
+
f"output={summary.totals.output_tokens}, reasoning={summary.totals.reasoning_output_tokens}) "
|
|
413
|
+
f"events={summary.events}"
|
|
414
|
+
)
|
|
415
|
+
if unmatched.events or unmatched.totals.total_tokens:
|
|
416
|
+
typer.echo(
|
|
417
|
+
f"- unmatched: total={unmatched.totals.total_tokens} events={unmatched.events}"
|
|
418
|
+
)
|
|
419
|
+
return
|
|
420
|
+
|
|
421
|
+
engine = _require_repo_config(repo)
|
|
422
|
+
summary = summarize_repo_usage(
|
|
423
|
+
engine.repo_root,
|
|
424
|
+
codex_root,
|
|
425
|
+
since=since_dt,
|
|
426
|
+
until=until_dt,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
if output_json:
|
|
430
|
+
payload = {
|
|
431
|
+
"mode": "repo",
|
|
432
|
+
"repo": str(engine.repo_root),
|
|
433
|
+
"codex_home": str(codex_root),
|
|
434
|
+
"since": since,
|
|
435
|
+
"until": until,
|
|
436
|
+
"usage": summary.to_dict(),
|
|
437
|
+
}
|
|
438
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
439
|
+
return
|
|
440
|
+
|
|
441
|
+
typer.echo(f"Repo: {engine.repo_root}")
|
|
442
|
+
typer.echo(f"CODEX_HOME: {codex_root}")
|
|
443
|
+
typer.echo(
|
|
444
|
+
f"Totals: total={summary.totals.total_tokens} "
|
|
445
|
+
f"(input={summary.totals.input_tokens}, cached={summary.totals.cached_input_tokens}, "
|
|
446
|
+
f"output={summary.totals.output_tokens}, reasoning={summary.totals.reasoning_output_tokens})"
|
|
447
|
+
)
|
|
448
|
+
typer.echo(f"Events counted: {summary.events}")
|
|
449
|
+
if summary.latest_rate_limits:
|
|
450
|
+
primary = summary.latest_rate_limits.get("primary", {}) or {}
|
|
451
|
+
secondary = summary.latest_rate_limits.get("secondary", {}) or {}
|
|
452
|
+
typer.echo(
|
|
453
|
+
f"Latest rate limits: primary_used={primary.get('used_percent')}%/{primary.get('window_minutes')}m, "
|
|
454
|
+
f"secondary_used={secondary.get('used_percent')}%/{secondary.get('window_minutes')}m"
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@app.command()
|
|
459
|
+
def run(
|
|
460
|
+
repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
|
|
461
|
+
force: bool = typer.Option(False, "--force", help="Ignore existing lock"),
|
|
462
|
+
):
|
|
463
|
+
"""Run the autorunner loop."""
|
|
464
|
+
engine: Optional[Engine] = None
|
|
465
|
+
try:
|
|
466
|
+
engine = _require_repo_config(repo)
|
|
467
|
+
engine.clear_stop_request()
|
|
468
|
+
engine.acquire_lock(force=force)
|
|
469
|
+
engine.run_loop()
|
|
470
|
+
except (ConfigError, LockError) as exc:
|
|
471
|
+
_raise_exit(str(exc), cause=exc)
|
|
472
|
+
finally:
|
|
473
|
+
if engine:
|
|
474
|
+
try:
|
|
475
|
+
engine.release_lock()
|
|
476
|
+
except Exception:
|
|
477
|
+
pass
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@app.command()
|
|
481
|
+
def once(
|
|
482
|
+
repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
|
|
483
|
+
force: bool = typer.Option(False, "--force", help="Ignore existing lock"),
|
|
484
|
+
):
|
|
485
|
+
"""Execute a single Codex run."""
|
|
486
|
+
engine: Optional[Engine] = None
|
|
487
|
+
try:
|
|
488
|
+
engine = _require_repo_config(repo)
|
|
489
|
+
engine.clear_stop_request()
|
|
490
|
+
engine.acquire_lock(force=force)
|
|
491
|
+
engine.run_once()
|
|
492
|
+
except (ConfigError, LockError) as exc:
|
|
493
|
+
_raise_exit(str(exc), cause=exc)
|
|
494
|
+
finally:
|
|
495
|
+
if engine:
|
|
496
|
+
try:
|
|
497
|
+
engine.release_lock()
|
|
498
|
+
except Exception:
|
|
499
|
+
pass
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
@app.command()
|
|
503
|
+
def kill(repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path")):
|
|
504
|
+
"""Force-kill a running autorunner and clear stale lock/state."""
|
|
505
|
+
engine = _require_repo_config(repo)
|
|
506
|
+
pid = engine.kill_running_process()
|
|
507
|
+
with state_lock(engine.state_path):
|
|
508
|
+
state = load_state(engine.state_path)
|
|
509
|
+
new_state = RunnerState(
|
|
510
|
+
last_run_id=state.last_run_id,
|
|
511
|
+
status="error",
|
|
512
|
+
last_exit_code=137,
|
|
513
|
+
last_run_started_at=state.last_run_started_at,
|
|
514
|
+
last_run_finished_at=now_iso(),
|
|
515
|
+
runner_pid=None,
|
|
516
|
+
sessions=state.sessions,
|
|
517
|
+
repo_to_session=state.repo_to_session,
|
|
518
|
+
)
|
|
519
|
+
save_state(engine.state_path, new_state)
|
|
520
|
+
clear_stale_lock(engine.lock_path)
|
|
521
|
+
if pid:
|
|
522
|
+
typer.echo(f"Sent SIGTERM to pid {pid}")
|
|
523
|
+
else:
|
|
524
|
+
typer.echo("No active autorunner process found; cleared stale lock if any.")
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
@app.command()
|
|
528
|
+
def resume(
|
|
529
|
+
repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
|
|
530
|
+
once: bool = typer.Option(False, "--once", help="Resume with a single run"),
|
|
531
|
+
force: bool = typer.Option(False, "--force", help="Override active lock"),
|
|
532
|
+
):
|
|
533
|
+
"""Resume a stopped/errored autorunner, clearing stale locks if needed."""
|
|
534
|
+
engine: Optional[Engine] = None
|
|
535
|
+
try:
|
|
536
|
+
engine = _require_repo_config(repo)
|
|
537
|
+
engine.clear_stop_request()
|
|
538
|
+
clear_stale_lock(engine.lock_path)
|
|
539
|
+
engine.acquire_lock(force=force)
|
|
540
|
+
engine.run_loop(stop_after_runs=1 if once else None)
|
|
541
|
+
except (ConfigError, LockError) as exc:
|
|
542
|
+
_raise_exit(str(exc), cause=exc)
|
|
543
|
+
finally:
|
|
544
|
+
if engine:
|
|
545
|
+
try:
|
|
546
|
+
engine.release_lock()
|
|
547
|
+
except Exception:
|
|
548
|
+
pass
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
@app.command()
|
|
552
|
+
def log(
|
|
553
|
+
repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
|
|
554
|
+
run_id: Optional[int] = typer.Option(None, "--run", help="Show a specific run"),
|
|
555
|
+
tail: Optional[int] = typer.Option(None, "--tail", help="Tail last N lines"),
|
|
556
|
+
):
|
|
557
|
+
"""Show autorunner log output."""
|
|
558
|
+
engine = _require_repo_config(repo)
|
|
559
|
+
if not engine.log_path.exists():
|
|
560
|
+
_raise_exit("Log file not found; run init")
|
|
561
|
+
|
|
562
|
+
if run_id is not None:
|
|
563
|
+
block = engine.read_run_block(run_id)
|
|
564
|
+
if not block:
|
|
565
|
+
_raise_exit("run not found")
|
|
566
|
+
typer.echo(block)
|
|
567
|
+
return
|
|
568
|
+
|
|
569
|
+
if tail is not None:
|
|
570
|
+
typer.echo(engine.tail_log(tail))
|
|
571
|
+
else:
|
|
572
|
+
state = load_state(engine.state_path)
|
|
573
|
+
last_id = state.last_run_id
|
|
574
|
+
if last_id is None:
|
|
575
|
+
typer.echo("No runs recorded yet")
|
|
576
|
+
return
|
|
577
|
+
block = engine.read_run_block(last_id)
|
|
578
|
+
if not block:
|
|
579
|
+
typer.echo("No run block found (log may have rotated)")
|
|
580
|
+
return
|
|
581
|
+
typer.echo(block)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
@app.command()
|
|
585
|
+
def edit(
|
|
586
|
+
target: str = typer.Argument(..., help="todo|progress|opinions|spec"),
|
|
587
|
+
repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
|
|
588
|
+
):
|
|
589
|
+
"""Open one of the docs in $EDITOR."""
|
|
590
|
+
engine = _require_repo_config(repo)
|
|
591
|
+
config = engine.config
|
|
592
|
+
key = target.lower()
|
|
593
|
+
if key not in ("todo", "progress", "opinions", "spec"):
|
|
594
|
+
_raise_exit("Invalid target; choose todo, progress, opinions, or spec")
|
|
595
|
+
path = config.doc_path(key)
|
|
596
|
+
editor = default_editor()
|
|
597
|
+
typer.echo(f"Opening {path} with {editor}")
|
|
598
|
+
subprocess.run([editor, str(path)])
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
@app.command("ingest-spec")
|
|
602
|
+
def ingest_spec_cmd(
|
|
603
|
+
repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
|
|
604
|
+
spec: Optional[Path] = typer.Option(
|
|
605
|
+
None, "--spec", help="Path to SPEC (defaults to configured docs.spec)"
|
|
606
|
+
),
|
|
607
|
+
force: bool = typer.Option(
|
|
608
|
+
False, "--force", help="Overwrite TODO/PROGRESS/OPINIONS"
|
|
609
|
+
),
|
|
610
|
+
):
|
|
611
|
+
"""Generate TODO/PROGRESS/OPINIONS from SPEC using Codex."""
|
|
612
|
+
try:
|
|
613
|
+
engine = _require_repo_config(repo)
|
|
614
|
+
docs = generate_docs_from_spec(engine, spec_path=spec)
|
|
615
|
+
write_ingested_docs(engine, docs, force=force)
|
|
616
|
+
except (ConfigError, SpecIngestError) as exc:
|
|
617
|
+
_raise_exit(str(exc), cause=exc)
|
|
618
|
+
|
|
619
|
+
typer.echo("Ingested SPEC into TODO/PROGRESS/OPINIONS.")
|
|
620
|
+
for key, content in docs.items():
|
|
621
|
+
lines = len(content.splitlines())
|
|
622
|
+
typer.echo(f"- {key.upper()}: {lines} lines")
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
@app.command("clear-docs")
|
|
626
|
+
def clear_docs_cmd(
|
|
627
|
+
repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
|
|
628
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
629
|
+
):
|
|
630
|
+
"""Clear TODO/PROGRESS/OPINIONS to empty templates."""
|
|
631
|
+
if not yes:
|
|
632
|
+
confirm = input("Clear TODO/PROGRESS/OPINIONS? Type CLEAR to confirm: ").strip()
|
|
633
|
+
if confirm.upper() != "CLEAR":
|
|
634
|
+
_raise_exit("Aborted.")
|
|
635
|
+
engine = _require_repo_config(repo)
|
|
636
|
+
try:
|
|
637
|
+
clear_work_docs(engine)
|
|
638
|
+
except ConfigError as exc:
|
|
639
|
+
_raise_exit(str(exc), cause=exc)
|
|
640
|
+
typer.echo("Cleared TODO/PROGRESS/OPINIONS.")
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
@app.command("doctor")
|
|
644
|
+
def doctor_cmd(repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path")):
|
|
645
|
+
"""Validate repo setup."""
|
|
646
|
+
engine = _require_repo_config(repo)
|
|
647
|
+
try:
|
|
648
|
+
doctor(engine.repo_root)
|
|
649
|
+
except ConfigError as exc:
|
|
650
|
+
_raise_exit(str(exc), cause=exc)
|
|
651
|
+
typer.echo("Doctor check passed")
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
@app.command()
|
|
655
|
+
def snapshot(
|
|
656
|
+
repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
|
|
657
|
+
):
|
|
658
|
+
"""Generate or update `.codex-autorunner/SNAPSHOT.md`."""
|
|
659
|
+
engine = _require_repo_config(repo)
|
|
660
|
+
try:
|
|
661
|
+
generate_snapshot(engine)
|
|
662
|
+
except SnapshotError as exc:
|
|
663
|
+
_raise_exit(str(exc), cause=exc)
|
|
664
|
+
typer.echo("Snapshot written to .codex-autorunner/SNAPSHOT.md")
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
@app.command()
|
|
668
|
+
def serve(
|
|
669
|
+
repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
|
|
670
|
+
host: Optional[str] = typer.Option(None, "--host", help="Host to bind"),
|
|
671
|
+
port: Optional[int] = typer.Option(None, "--port", help="Port to bind"),
|
|
672
|
+
base_path: Optional[str] = typer.Option(
|
|
673
|
+
None, "--base-path", help="Base path for the server"
|
|
674
|
+
),
|
|
675
|
+
):
|
|
676
|
+
"""Start the web server and UI API."""
|
|
677
|
+
try:
|
|
678
|
+
config = load_config(repo or Path.cwd())
|
|
679
|
+
except ConfigError as exc:
|
|
680
|
+
_raise_exit(str(exc), cause=exc)
|
|
681
|
+
if isinstance(config, HubConfig):
|
|
682
|
+
bind_host = host or config.server_host
|
|
683
|
+
bind_port = port or config.server_port
|
|
684
|
+
normalized_base = (
|
|
685
|
+
_normalize_base_path(base_path)
|
|
686
|
+
if base_path is not None
|
|
687
|
+
else config.server_base_path
|
|
688
|
+
)
|
|
689
|
+
_enforce_bind_auth(bind_host, config.server_auth_token_env)
|
|
690
|
+
typer.echo(
|
|
691
|
+
f"Serving hub on http://{bind_host}:{bind_port}{normalized_base or ''}"
|
|
692
|
+
)
|
|
693
|
+
uvicorn.run(
|
|
694
|
+
create_hub_app(config.root, base_path=normalized_base),
|
|
695
|
+
host=bind_host,
|
|
696
|
+
port=bind_port,
|
|
697
|
+
root_path="",
|
|
698
|
+
access_log=config.server_access_log,
|
|
699
|
+
)
|
|
700
|
+
return
|
|
701
|
+
engine = _require_repo_config(repo)
|
|
702
|
+
normalized_base = (
|
|
703
|
+
_normalize_base_path(base_path)
|
|
704
|
+
if base_path is not None
|
|
705
|
+
else engine.config.server_base_path
|
|
706
|
+
)
|
|
707
|
+
app_instance = create_app(engine.repo_root, base_path=normalized_base)
|
|
708
|
+
bind_host = host or engine.config.server_host
|
|
709
|
+
bind_port = port or engine.config.server_port
|
|
710
|
+
_enforce_bind_auth(bind_host, engine.config.server_auth_token_env)
|
|
711
|
+
typer.echo(f"Serving repo on http://{bind_host}:{bind_port}{normalized_base or ''}")
|
|
712
|
+
uvicorn.run(
|
|
713
|
+
app_instance,
|
|
714
|
+
host=bind_host,
|
|
715
|
+
port=bind_port,
|
|
716
|
+
root_path="",
|
|
717
|
+
access_log=engine.config.server_access_log,
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
@hub_app.command("create")
|
|
722
|
+
def hub_create(
|
|
723
|
+
repo_id: str = typer.Argument(..., help="Repo id to create and initialize"),
|
|
724
|
+
repo_path: Optional[Path] = typer.Option(
|
|
725
|
+
None,
|
|
726
|
+
"--repo-path",
|
|
727
|
+
help="Custom repo path relative to hub repos_root",
|
|
728
|
+
),
|
|
729
|
+
path: Optional[Path] = typer.Option(None, "--path", help="Hub root path"),
|
|
730
|
+
force: bool = typer.Option(False, "--force", help="Allow existing directory"),
|
|
731
|
+
git_init: bool = typer.Option(
|
|
732
|
+
True, "--git-init/--no-git-init", help="Run git init in the new repo"
|
|
733
|
+
),
|
|
734
|
+
):
|
|
735
|
+
"""Create a new git repo under the hub and initialize codex-autorunner files."""
|
|
736
|
+
config = _require_hub_config(path)
|
|
737
|
+
supervisor = HubSupervisor(config)
|
|
738
|
+
try:
|
|
739
|
+
snapshot = supervisor.create_repo(
|
|
740
|
+
repo_id, repo_path, git_init=git_init, force=force
|
|
741
|
+
)
|
|
742
|
+
except Exception as exc:
|
|
743
|
+
_raise_exit(str(exc), cause=exc)
|
|
744
|
+
typer.echo(f"Created repo {snapshot.id} at {snapshot.path}")
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
@hub_app.command("serve")
|
|
748
|
+
def hub_serve(
|
|
749
|
+
path: Optional[Path] = typer.Option(None, "--path", help="Hub root path"),
|
|
750
|
+
host: Optional[str] = typer.Option(None, "--host", help="Host to bind"),
|
|
751
|
+
port: Optional[int] = typer.Option(None, "--port", help="Port to bind"),
|
|
752
|
+
base_path: Optional[str] = typer.Option(
|
|
753
|
+
None, "--base-path", help="Base path for the server"
|
|
754
|
+
),
|
|
755
|
+
):
|
|
756
|
+
"""Start the hub supervisor server."""
|
|
757
|
+
config = _require_hub_config(path)
|
|
758
|
+
normalized_base = (
|
|
759
|
+
_normalize_base_path(base_path)
|
|
760
|
+
if base_path is not None
|
|
761
|
+
else config.server_base_path
|
|
762
|
+
)
|
|
763
|
+
bind_host = host or config.server_host
|
|
764
|
+
bind_port = port or config.server_port
|
|
765
|
+
_enforce_bind_auth(bind_host, config.server_auth_token_env)
|
|
766
|
+
typer.echo(f"Serving hub on http://{bind_host}:{bind_port}{normalized_base or ''}")
|
|
767
|
+
uvicorn.run(
|
|
768
|
+
create_hub_app(config.root, base_path=normalized_base),
|
|
769
|
+
host=bind_host,
|
|
770
|
+
port=bind_port,
|
|
771
|
+
root_path="",
|
|
772
|
+
access_log=config.server_access_log,
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
@hub_app.command("scan")
|
|
777
|
+
def hub_scan(path: Optional[Path] = typer.Option(None, "--path", help="Hub root path")):
|
|
778
|
+
"""Trigger discovery/init and print repo statuses."""
|
|
779
|
+
config = _require_hub_config(path)
|
|
780
|
+
supervisor = HubSupervisor(config)
|
|
781
|
+
snapshots = supervisor.scan()
|
|
782
|
+
typer.echo(f"Scanned hub at {config.root} (repos_root={config.repos_root})")
|
|
783
|
+
for snap in snapshots:
|
|
784
|
+
typer.echo(
|
|
785
|
+
f"- {snap.id}: {snap.status.value}, initialized={snap.initialized}, exists={snap.exists_on_disk}"
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
@telegram_app.command("start")
|
|
790
|
+
def telegram_start(
|
|
791
|
+
path: Optional[Path] = typer.Option(None, "--path", help="Repo or hub root path"),
|
|
792
|
+
):
|
|
793
|
+
"""Start the Telegram bot (polling)."""
|
|
794
|
+
_require_optional_feature(
|
|
795
|
+
feature="telegram",
|
|
796
|
+
deps=[("httpx", "httpx")],
|
|
797
|
+
extra="telegram",
|
|
798
|
+
)
|
|
799
|
+
try:
|
|
800
|
+
config = load_config(path or Path.cwd())
|
|
801
|
+
except ConfigError as exc:
|
|
802
|
+
_raise_exit(str(exc), cause=exc)
|
|
803
|
+
telegram_cfg = TelegramBotConfig.from_raw(
|
|
804
|
+
config.raw.get("telegram_bot") if isinstance(config.raw, dict) else None,
|
|
805
|
+
root=config.root,
|
|
806
|
+
)
|
|
807
|
+
if not telegram_cfg.enabled:
|
|
808
|
+
_raise_exit("telegram_bot is disabled; set telegram_bot.enabled: true")
|
|
809
|
+
try:
|
|
810
|
+
telegram_cfg.validate()
|
|
811
|
+
except TelegramBotConfigError as exc:
|
|
812
|
+
_raise_exit(str(exc), cause=exc)
|
|
813
|
+
logger = setup_rotating_logger("codex-autorunner-telegram", config.log)
|
|
814
|
+
log_event(
|
|
815
|
+
logger,
|
|
816
|
+
logging.INFO,
|
|
817
|
+
"telegram.bot.starting",
|
|
818
|
+
root=str(config.root),
|
|
819
|
+
mode=("hub" if isinstance(config, HubConfig) else "repo"),
|
|
820
|
+
)
|
|
821
|
+
voice_raw = config.raw.get("voice") if isinstance(config.raw, dict) else None
|
|
822
|
+
voice_config = VoiceConfig.from_raw(voice_raw, env=os.environ)
|
|
823
|
+
update_repo_url = config.update_repo_url if isinstance(config, HubConfig) else None
|
|
824
|
+
update_repo_ref = config.update_repo_ref if isinstance(config, HubConfig) else None
|
|
825
|
+
|
|
826
|
+
async def _run() -> None:
|
|
827
|
+
service = TelegramBotService(
|
|
828
|
+
telegram_cfg,
|
|
829
|
+
logger=logger,
|
|
830
|
+
hub_root=config.root if isinstance(config, HubConfig) else None,
|
|
831
|
+
manifest_path=(
|
|
832
|
+
config.manifest_path if isinstance(config, HubConfig) else None
|
|
833
|
+
),
|
|
834
|
+
voice_config=voice_config,
|
|
835
|
+
housekeeping_config=config.housekeeping,
|
|
836
|
+
update_repo_url=update_repo_url,
|
|
837
|
+
update_repo_ref=update_repo_ref,
|
|
838
|
+
)
|
|
839
|
+
await service.run_polling()
|
|
840
|
+
|
|
841
|
+
try:
|
|
842
|
+
asyncio.run(_run())
|
|
843
|
+
except TelegramBotLockError as exc:
|
|
844
|
+
_raise_exit(str(exc), cause=exc)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
@telegram_app.command("health")
|
|
848
|
+
def telegram_health(
|
|
849
|
+
path: Optional[Path] = typer.Option(None, "--path", help="Repo or hub root path"),
|
|
850
|
+
timeout: float = typer.Option(5.0, "--timeout", help="Timeout (seconds)"),
|
|
851
|
+
):
|
|
852
|
+
"""Check Telegram API connectivity for the configured bot."""
|
|
853
|
+
_require_optional_feature(
|
|
854
|
+
feature="telegram",
|
|
855
|
+
deps=[("httpx", "httpx")],
|
|
856
|
+
extra="telegram",
|
|
857
|
+
)
|
|
858
|
+
try:
|
|
859
|
+
config = load_config(path or Path.cwd())
|
|
860
|
+
except ConfigError as exc:
|
|
861
|
+
_raise_exit(str(exc), cause=exc)
|
|
862
|
+
telegram_cfg = TelegramBotConfig.from_raw(
|
|
863
|
+
config.raw.get("telegram_bot") if isinstance(config.raw, dict) else None,
|
|
864
|
+
root=config.root,
|
|
865
|
+
)
|
|
866
|
+
if not telegram_cfg.enabled:
|
|
867
|
+
_raise_exit("telegram_bot is disabled; set telegram_bot.enabled: true")
|
|
868
|
+
bot_token = telegram_cfg.bot_token
|
|
869
|
+
if not bot_token:
|
|
870
|
+
_raise_exit(f"missing bot token env '{telegram_cfg.bot_token_env}'")
|
|
871
|
+
timeout_seconds = max(float(timeout), 0.1)
|
|
872
|
+
|
|
873
|
+
async def _run() -> None:
|
|
874
|
+
async with TelegramBotClient(bot_token) as client:
|
|
875
|
+
await asyncio.wait_for(client.get_me(), timeout=timeout_seconds)
|
|
876
|
+
|
|
877
|
+
try:
|
|
878
|
+
asyncio.run(_run())
|
|
879
|
+
except TelegramAPIError as exc:
|
|
880
|
+
_raise_exit(f"Telegram health check failed: {exc}", cause=exc)
|
|
881
|
+
except Exception as exc:
|
|
882
|
+
_raise_exit(f"Telegram health check failed: {exc}", cause=exc)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
if __name__ == "__main__":
|
|
886
|
+
app()
|