cli-relay 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.
@@ -0,0 +1,144 @@
1
+ """Any OpenAI-compatible chat endpoint: OpenRouter, a paid API, vLLM, Ollama, LM Studio...
2
+
3
+ This is a *text-only* fallback: it has no tools and cannot edit files. It is meant as the
4
+ last links of the chain ("answer from somewhere rather than nowhere"). Standard library
5
+ only - no HTTP client dependency.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import json
13
+ import os
14
+ import threading
15
+ import time
16
+ import urllib.error
17
+ import urllib.request
18
+ from collections.abc import AsyncIterator
19
+ from typing import Any, ClassVar
20
+
21
+ from cli_relay.adapters.base import Adapter
22
+ from cli_relay.errors import classify, reset_hint
23
+ from cli_relay.events import Completed, Event, Failed, FailureKind, Started, TextDelta
24
+ from cli_relay.models import Task
25
+
26
+ _DONE = object()
27
+
28
+
29
+ class OpenAICompatAdapter(Adapter):
30
+ type_name: ClassVar[str] = "openai"
31
+
32
+ def available(self) -> tuple[bool, str]:
33
+ if not self.settings.base_url:
34
+ return False, "no base_url configured"
35
+ if not self.settings.model:
36
+ return False, "no model configured"
37
+ env = self.settings.api_key_env
38
+ if env and not os.environ.get(env):
39
+ return False, f"environment variable {env} is not set"
40
+ return True, ""
41
+
42
+ async def run(self, task: Task) -> AsyncIterator[Event]:
43
+ ok, reason = self.available()
44
+ if not ok:
45
+ yield Failed(provider=self.name, kind=FailureKind.NOT_INSTALLED, message=reason)
46
+ return
47
+ messages: list[dict[str, str]] = []
48
+ if task.system_prompt:
49
+ messages.append({"role": "system", "content": task.system_prompt})
50
+ messages.append({"role": "user", "content": task.prompt})
51
+ body = json.dumps(
52
+ {"model": self.settings.model, "messages": messages, "stream": True}
53
+ ).encode()
54
+ headers = {"Content-Type": "application/json", "Accept": "text/event-stream"}
55
+ if self.settings.api_key_env:
56
+ headers["Authorization"] = f"Bearer {os.environ[self.settings.api_key_env]}"
57
+ url = self.settings.base_url.rstrip("/") + "/chat/completions" # type: ignore[union-attr]
58
+ req = urllib.request.Request(url, data=body, headers=headers, method="POST") # noqa: S310
59
+
60
+ loop = asyncio.get_running_loop()
61
+ queue: asyncio.Queue[object] = asyncio.Queue()
62
+ stop = threading.Event()
63
+
64
+ def put(item: object) -> None:
65
+ loop.call_soon_threadsafe(queue.put_nowait, item)
66
+
67
+ def worker() -> None:
68
+ try:
69
+ with urllib.request.urlopen(req, timeout=task.timeout_s or 600) as resp: # noqa: S310
70
+ for raw in resp:
71
+ if stop.is_set():
72
+ return
73
+ put(raw)
74
+ except urllib.error.HTTPError as exc:
75
+ put(exc)
76
+ except Exception as exc:
77
+ put(exc)
78
+ finally:
79
+ put(_DONE)
80
+
81
+ started = time.monotonic()
82
+ thread = threading.Thread(target=worker, name=f"cli-relay-{self.name}", daemon=True)
83
+ thread.start()
84
+ yield Started(provider=self.name, model=self.settings.model)
85
+ parts: list[str] = []
86
+ usage: dict[str, Any] = {}
87
+ try:
88
+ while True:
89
+ item = await queue.get()
90
+ if item is _DONE:
91
+ break
92
+ if isinstance(item, urllib.error.HTTPError):
93
+ yield self._http_failure(item)
94
+ return
95
+ if isinstance(item, Exception):
96
+ yield Failed(provider=self.name, kind=classify(item), message=str(item))
97
+ return
98
+ assert isinstance(item, bytes)
99
+ line = item.decode("utf-8", errors="replace").strip()
100
+ if not line.startswith("data:"):
101
+ continue
102
+ payload = line[5:].strip()
103
+ if payload == "[DONE]":
104
+ continue
105
+ with contextlib.suppress(ValueError):
106
+ chunk = json.loads(payload)
107
+ if chunk.get("usage"):
108
+ usage = chunk["usage"]
109
+ for choice in chunk.get("choices") or []:
110
+ text = (choice.get("delta") or {}).get("content")
111
+ if text:
112
+ parts.append(text)
113
+ yield TextDelta(provider=self.name, text=text)
114
+ yield Completed(
115
+ provider=self.name,
116
+ text="".join(parts),
117
+ usage=usage,
118
+ duration_s=time.monotonic() - started,
119
+ )
120
+ finally:
121
+ stop.set()
122
+
123
+ def _http_failure(self, exc: urllib.error.HTTPError) -> Failed:
124
+ try:
125
+ detail = exc.read().decode("utf-8", errors="replace")[:1000]
126
+ except Exception:
127
+ detail = ""
128
+ message = f"HTTP {exc.code}: {detail or exc.reason}"
129
+ resets_at = None
130
+ retry_after = exc.headers.get("Retry-After") if exc.headers else None
131
+ if retry_after and retry_after.strip().isdigit():
132
+ resets_at = time.time() + int(retry_after.strip())
133
+ if exc.code == 429:
134
+ kind = FailureKind.RATE_LIMIT
135
+ elif exc.code in (401, 403):
136
+ kind = FailureKind.AUTH
137
+ else:
138
+ kind = classify(message)
139
+ return Failed(
140
+ provider=self.name,
141
+ kind=kind,
142
+ message=message,
143
+ resets_at=resets_at or reset_hint(message),
144
+ )
cli_relay/cli.py ADDED
@@ -0,0 +1,238 @@
1
+ """Command line interface: ``cli-relay run | status | reset | mcp``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+ from typing import TextIO
12
+
13
+ from cli_relay import __version__
14
+ from cli_relay.config import Config, load
15
+ from cli_relay.events import (
16
+ Completed,
17
+ Event,
18
+ Failed,
19
+ QuotaInfo,
20
+ Skipped,
21
+ Started,
22
+ Switched,
23
+ TextDelta,
24
+ ToolActivity,
25
+ Waiting,
26
+ )
27
+ from cli_relay.models import Permission, Task
28
+ from cli_relay.relay import Relay
29
+
30
+ EXIT_OK, EXIT_FAILED, EXIT_USAGE = 0, 1, 2
31
+
32
+
33
+ def _build_parser() -> argparse.ArgumentParser:
34
+ parser = argparse.ArgumentParser(
35
+ prog="cli-relay",
36
+ description="Run a task on Claude Code / Antigravity / Codex / an API, "
37
+ "falling back to the next one when a provider hits its limit.",
38
+ )
39
+ parser.add_argument("--version", action="version", version=f"cli-relay {__version__}")
40
+ parser.add_argument("--config", help="path to cli-relay.toml")
41
+ sub = parser.add_subparsers(dest="command", required=True)
42
+
43
+ run = sub.add_parser("run", help="run a task")
44
+ run.add_argument("prompt", help="task text, or '-' to read it from stdin")
45
+ run.add_argument("--cwd", type=Path, help="working directory for the agent")
46
+ run.add_argument("--providers", help="comma-separated provider order (overrides config)")
47
+ run.add_argument(
48
+ "--permission",
49
+ choices=[p.value for p in Permission],
50
+ default=Permission.READ_ONLY.value,
51
+ help="what the agent may do (default: read_only)",
52
+ )
53
+ run.add_argument("--system", help="extra system instructions for the agent")
54
+ run.add_argument(
55
+ "--resume",
56
+ action="append",
57
+ default=[],
58
+ metavar="PROVIDER=SESSION",
59
+ help="resume a provider session (repeatable)",
60
+ )
61
+ run.add_argument("--timeout", type=float, help="seconds per provider attempt")
62
+ run.add_argument(
63
+ "--format",
64
+ choices=["text", "ndjson"],
65
+ default="text",
66
+ help="text for humans, ndjson for programs (default: text)",
67
+ )
68
+
69
+ status = sub.add_parser("status", help="show providers and known limits")
70
+ status.add_argument("--format", choices=["text", "json"], default="text")
71
+
72
+ reset = sub.add_parser("reset", help="forget recorded limits")
73
+ reset.add_argument("provider", nargs="?", help="only this provider")
74
+
75
+ sub.add_parser(
76
+ "mcp",
77
+ help="serve cli-relay as an MCP server over stdio (needs: pip install 'cli-relay[mcp]')",
78
+ )
79
+ return parser
80
+
81
+
82
+ def main(argv: list[str] | None = None) -> int:
83
+ for stream in (sys.stdout, sys.stderr):
84
+ if hasattr(stream, "reconfigure"):
85
+ stream.reconfigure(encoding="utf-8", errors="replace")
86
+ args = _build_parser().parse_args(argv)
87
+ try:
88
+ config = load(args.config)
89
+ except (OSError, ValueError) as exc:
90
+ print(f"cli-relay: config error: {exc}", file=sys.stderr)
91
+ return EXIT_USAGE
92
+ try:
93
+ if args.command == "run":
94
+ return asyncio.run(_run(args, config))
95
+ if args.command == "status":
96
+ return _status(args, config)
97
+ if args.command == "reset":
98
+ config.quota().clear(args.provider)
99
+ print("limits cleared" + (f" for {args.provider}" if args.provider else ""))
100
+ return EXIT_OK
101
+ if args.command == "mcp":
102
+ from cli_relay.mcp_server import serve
103
+
104
+ serve(config)
105
+ return EXIT_OK
106
+ except KeyboardInterrupt:
107
+ return 130
108
+ except ValueError as exc:
109
+ print(f"cli-relay: {exc}", file=sys.stderr)
110
+ return EXIT_USAGE
111
+ return EXIT_USAGE
112
+
113
+
114
+ async def _run(args: argparse.Namespace, config: Config) -> int:
115
+ # Read stdin as UTF-8 regardless of the console code page (cp1252/cp866 on Windows).
116
+ prompt = _read_stdin_utf8() if args.prompt == "-" else args.prompt
117
+ if not prompt.strip():
118
+ print("cli-relay: empty prompt", file=sys.stderr)
119
+ return EXIT_USAGE
120
+ resume: dict[str, str] = {}
121
+ for item in args.resume:
122
+ provider, sep, session = item.partition("=")
123
+ if not sep or not session:
124
+ raise ValueError(f"--resume expects PROVIDER=SESSION, got {item!r}")
125
+ resume[provider] = session
126
+ order = [p.strip() for p in args.providers.split(",")] if args.providers else None
127
+ relay = Relay(config.adapters(order), config.quota(), config.policy)
128
+ task = Task(
129
+ prompt=prompt,
130
+ cwd=args.cwd,
131
+ permission=Permission(args.permission),
132
+ system_prompt=args.system,
133
+ resume=resume,
134
+ timeout_s=args.timeout,
135
+ )
136
+ last: Event | None = None
137
+ async for event in relay.run(task):
138
+ last = event
139
+ if args.format == "ndjson":
140
+ print(json.dumps(event.to_dict(), ensure_ascii=False), flush=True)
141
+ else:
142
+ _print_human(event, sys.stdout, sys.stderr)
143
+ return EXIT_OK if isinstance(last, Completed) else EXIT_FAILED
144
+
145
+
146
+ def _read_stdin_utf8() -> str:
147
+ buffer = getattr(sys.stdin, "buffer", None)
148
+ if buffer is None: # replaced stdin (tests, embedding)
149
+ return sys.stdin.read()
150
+ return buffer.read().decode("utf-8-sig", errors="replace") # type: ignore[no-any-return]
151
+
152
+
153
+ def _print_human(event: Event, out: TextIO, err: TextIO) -> None:
154
+ def note(text: str) -> None:
155
+ print(f"\n[cli-relay] {text}", file=err, flush=True)
156
+
157
+ if isinstance(event, TextDelta):
158
+ out.write(event.text)
159
+ out.flush()
160
+ elif isinstance(event, Started):
161
+ note(f"{event.provider} started" + (f" ({event.model})" if event.model else ""))
162
+ elif isinstance(event, ToolActivity):
163
+ note(f"{event.provider}: {event.tool} {event.detail}".rstrip())
164
+ elif isinstance(event, QuotaInfo):
165
+ if event.utilization is not None and event.utilization >= 0.8:
166
+ note(f"{event.provider} {event.window} usage at {event.utilization:.0%}")
167
+ elif isinstance(event, Skipped):
168
+ note(f"skipping {event.provider}: {event.reason}")
169
+ elif isinstance(event, Waiting):
170
+ until = time.strftime("%H:%M", time.localtime(event.until))
171
+ note(f"waiting for {event.provider} until {until}")
172
+ elif isinstance(event, Switched):
173
+ note(f"switching {event.provider} -> {event.to_provider} ({event.reason})")
174
+ elif isinstance(event, Failed):
175
+ note(f"{event.provider} failed [{event.kind.value}]: {event.message}")
176
+ elif isinstance(event, Completed):
177
+ # On a subscription this is the API-equivalent price reported by the CLI, not a bill.
178
+ cost = f", api-equivalent ${event.cost_usd:.4f}" if event.cost_usd else ""
179
+ note(
180
+ f"done by {event.provider}"
181
+ + (f" (session {event.session_id}{cost})" if event.session_id else "")
182
+ )
183
+
184
+
185
+ def _status(args: argparse.Namespace, config: Config) -> int:
186
+ quota = config.quota()
187
+ rows = []
188
+ for adapter in config.adapters():
189
+ ok, reason = adapter.available()
190
+ block = quota.blocked(adapter.name)
191
+ rows.append(
192
+ {
193
+ "provider": adapter.name,
194
+ "type": adapter.settings.type,
195
+ "available": ok,
196
+ "reason": reason,
197
+ "blocked_until": block.until if block else None,
198
+ "blocked_reason": block.reason if block else None,
199
+ "windows": quota.snapshot().get(adapter.name, {}).get("windows", {}),
200
+ }
201
+ )
202
+ if args.format == "json":
203
+ print(
204
+ json.dumps(
205
+ {
206
+ "config": str(config.source) if config.source else None,
207
+ "order": config.order,
208
+ "providers": rows,
209
+ },
210
+ indent=2,
211
+ )
212
+ )
213
+ return EXIT_OK
214
+ print(f"config: {config.source or '(built-in defaults)'}")
215
+ for row in rows:
216
+ state = (
217
+ "ready"
218
+ if row["available"] and not row["blocked_until"]
219
+ else ("limited" if row["blocked_until"] else "unavailable")
220
+ )
221
+ line = f" {row['provider']:<14} {state:<12}"
222
+ if row["blocked_until"]:
223
+ until = time.strftime("%Y-%m-%d %H:%M", time.localtime(row["blocked_until"]))
224
+ reason = " ".join(str(row["blocked_reason"]).split())
225
+ if len(reason) > 80:
226
+ reason = reason[:79] + "…"
227
+ line += f" until {until} ({reason})"
228
+ elif not row["available"]:
229
+ line += f" {row['reason']}"
230
+ for name, window in row["windows"].items():
231
+ if window.get("utilization") is not None:
232
+ line += f" {name}={window['utilization']:.0%}"
233
+ print(line)
234
+ return EXIT_OK
235
+
236
+
237
+ if __name__ == "__main__":
238
+ sys.exit(main())
cli_relay/config.py ADDED
@@ -0,0 +1,131 @@
1
+ """Load ``cli-relay.toml``.
2
+
3
+ Lookup order: ``--config PATH``, ``$CLI_RELAY_CONFIG``, ``./cli-relay.toml``, then the user
4
+ config dir (``%APPDATA%/cli-relay/config.toml`` or ``~/.config/cli-relay/config.toml``).
5
+ Without a file, the default chain is Claude Code -> Antigravity -> Codex.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+ import tomllib
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from cli_relay.adapters import Adapter, create
18
+ from cli_relay.events import FailureKind
19
+ from cli_relay.models import Permission, ProviderSettings
20
+ from cli_relay.quota import QuotaTracker, default_state_path
21
+ from cli_relay.relay import RelayPolicy
22
+
23
+ DEFAULT_ORDER = ["claude", "antigravity", "codex"]
24
+
25
+
26
+ def user_config_path() -> Path:
27
+ if sys.platform == "win32":
28
+ base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming")
29
+ else:
30
+ base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
31
+ return base / "cli-relay" / "config.toml"
32
+
33
+
34
+ def find_config(explicit: str | Path | None = None) -> Path | None:
35
+ candidates = [
36
+ explicit,
37
+ os.environ.get("CLI_RELAY_CONFIG"),
38
+ Path("cli-relay.toml"),
39
+ user_config_path(),
40
+ ]
41
+ for candidate in candidates:
42
+ if candidate and Path(candidate).is_file():
43
+ return Path(candidate)
44
+ if explicit:
45
+ raise FileNotFoundError(f"config file not found: {explicit}")
46
+ return None
47
+
48
+
49
+ @dataclass
50
+ class Config:
51
+ order: list[str]
52
+ providers: dict[str, ProviderSettings]
53
+ policy: RelayPolicy = field(default_factory=RelayPolicy)
54
+ state_path: Path | None = None
55
+ default_cooldown_s: float = 1800
56
+ skip_above_utilization: float | None = 0.98
57
+ #: Highest permission an MCP client may request through ``relay_run``. Defaults to
58
+ #: read-only so a sandboxed agent cannot escalate by delegating to another one.
59
+ mcp_max_permission: Permission = Permission.READ_ONLY
60
+ source: Path | None = None
61
+
62
+ def adapters(self, only: list[str] | None = None) -> list[Adapter]:
63
+ names = only or self.order
64
+ unknown = [n for n in names if n not in self.providers]
65
+ if unknown:
66
+ raise ValueError(
67
+ f"unknown provider(s): {', '.join(unknown)}; "
68
+ f"configured: {', '.join(self.providers)}"
69
+ )
70
+ return [create(self.providers[n]) for n in names]
71
+
72
+ def quota(self) -> QuotaTracker:
73
+ return QuotaTracker(
74
+ self.state_path or default_state_path(),
75
+ default_cooldown_s=self.default_cooldown_s,
76
+ skip_above_utilization=self.skip_above_utilization,
77
+ )
78
+
79
+
80
+ def load(path: str | Path | None = None) -> Config:
81
+ source = find_config(path)
82
+ data: dict[str, Any] = {}
83
+ if source:
84
+ with source.open("rb") as fh:
85
+ data = tomllib.load(fh)
86
+ return parse(data, source)
87
+
88
+
89
+ def parse(data: dict[str, Any], source: Path | None = None) -> Config:
90
+ providers: dict[str, ProviderSettings] = {
91
+ name: ProviderSettings(name=name, type=name) for name in DEFAULT_ORDER
92
+ }
93
+ for name, raw in (data.get("providers") or {}).items():
94
+ if not isinstance(raw, dict):
95
+ raise ValueError(f"[providers.{name}] must be a table")
96
+ permission = raw.get("permission")
97
+ command = raw.get("command")
98
+ if isinstance(command, str):
99
+ command = [command]
100
+ providers[name] = ProviderSettings(
101
+ name=name,
102
+ type=str(raw.get("type", name)),
103
+ command=list(command) if command else None,
104
+ model=raw.get("model"),
105
+ permission=Permission(permission) if permission else None,
106
+ extra_args=[str(a) for a in raw.get("extra_args", [])],
107
+ env={str(k): str(v) for k, v in (raw.get("env") or {}).items()},
108
+ base_url=raw.get("base_url"),
109
+ api_key_env=raw.get("api_key_env"),
110
+ )
111
+ relay = data.get("relay") or {}
112
+ policy = RelayPolicy(
113
+ wait_if_reset_within_s=float(relay.get("wait_if_reset_within_s", 0)),
114
+ write_handoff_file=bool(relay.get("write_handoff_file", False)),
115
+ )
116
+ if "switch_on" in relay:
117
+ policy.switch_on = frozenset(FailureKind(k) for k in relay["switch_on"])
118
+ state = relay.get("state_path")
119
+ skip = relay.get("skip_above_utilization", 0.98)
120
+ return Config(
121
+ order=list(data.get("order") or DEFAULT_ORDER),
122
+ providers=providers,
123
+ policy=policy,
124
+ state_path=Path(state).expanduser() if state else None,
125
+ default_cooldown_s=float(relay.get("default_cooldown_s", 1800)),
126
+ skip_above_utilization=None if skip in (None, False) else float(skip),
127
+ mcp_max_permission=Permission(
128
+ (data.get("mcp") or {}).get("max_permission", Permission.READ_ONLY.value)
129
+ ),
130
+ source=source,
131
+ )
cli_relay/errors.py ADDED
@@ -0,0 +1,85 @@
1
+ """Heuristics that turn provider error text into a :class:`FailureKind`.
2
+
3
+ The CLIs do not document their quota errors consistently, so classification is
4
+ pattern based and deliberately conservative: anything not recognised is ``ERROR``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import time
11
+
12
+ from cli_relay.events import FailureKind
13
+
14
+ _RATE_LIMIT = re.compile(
15
+ r"rate[ _-]?limit|usage[ _-]?limit|limit (?:reached|exceeded)|quota|"
16
+ r"resource[ _-]?exhausted|too many requests|\b429\b|out of (?:credits|usage)|"
17
+ r"overloaded_error|\b529\b|hit your limit|limit will reset",
18
+ re.IGNORECASE,
19
+ )
20
+ _AUTH = re.compile(
21
+ r"\b401\b|\b403\b|unauthori[sz]ed|not (?:logged|signed) in|please (?:log|sign) ?in|"
22
+ r"invalid (?:api[ _-]?key|token|credentials)|authentication|oauth token|login required",
23
+ re.IGNORECASE,
24
+ )
25
+
26
+ _IN_DURATION = re.compile(
27
+ r"\b(?:in|after)\s+(?:(\d+)\s*h(?:ours?|rs?)?\b)?\s*(?:(\d+)\s*m(?:in(?:ute)?s?)?\b)?",
28
+ re.IGNORECASE,
29
+ )
30
+ _EPOCH = re.compile(r"\b(1[6-9]\d{8})\b")
31
+ # "try again at 11:05 AM", "resets 3pm", "resets at 15:30" (local wall-clock time)
32
+ _CLOCK = re.compile(
33
+ r"\b(?:at|resets?(?:\s+at)?)\s+(\d{1,2})(?::(\d{2}))?\s*([ap]\.?m\.?)?(?![\w:])",
34
+ re.IGNORECASE,
35
+ )
36
+
37
+
38
+ def classify(*texts: object) -> FailureKind:
39
+ blob = " ".join(str(t) for t in texts if t)
40
+ if _RATE_LIMIT.search(blob):
41
+ return FailureKind.RATE_LIMIT
42
+ if _AUTH.search(blob):
43
+ return FailureKind.AUTH
44
+ return FailureKind.ERROR
45
+
46
+
47
+ def reset_hint(text: str, now: float | None = None) -> float | None:
48
+ """Best-effort reset time (epoch seconds) from messages like "try again in 2h 30m"."""
49
+ now = time.time() if now is None else now
50
+ for match in _IN_DURATION.finditer(text):
51
+ hours, minutes = match.group(1), match.group(2)
52
+ if hours or minutes:
53
+ return now + int(hours or 0) * 3600 + int(minutes or 0) * 60
54
+ epoch = _EPOCH.search(text)
55
+ if epoch:
56
+ value = float(epoch.group(1))
57
+ if value > now:
58
+ return value
59
+ for match in _CLOCK.finditer(text):
60
+ clock = _next_clock_time(match, now)
61
+ if clock is not None:
62
+ return clock
63
+ return None
64
+
65
+
66
+ def _next_clock_time(match: re.Match[str], now: float) -> float | None:
67
+ hour, minute, ampm = int(match.group(1)), match.group(2), match.group(3)
68
+ if minute is None and ampm is None:
69
+ return None # a bare number ("at 5") is too ambiguous
70
+ if ampm:
71
+ if not 1 <= hour <= 12:
72
+ return None
73
+ hour = hour % 12 + (12 if ampm.lower().startswith("p") else 0)
74
+ elif hour > 23:
75
+ return None
76
+ mins = int(minute or 0)
77
+ if mins > 59:
78
+ return None
79
+ local = time.localtime(now)
80
+ target = time.mktime((local.tm_year, local.tm_mon, local.tm_mday, hour, mins, 0, 0, 0, -1))
81
+ if target <= now: # already passed today -> tomorrow
82
+ target = time.mktime(
83
+ (local.tm_year, local.tm_mon, local.tm_mday + 1, hour, mins, 0, 0, 0, -1)
84
+ )
85
+ return target