aicp-cli 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aicp/__init__.py +18 -0
- aicp/_keyreader.py +210 -0
- aicp/_skills_data/commit.md +90 -0
- aicp/_skills_data/safe-git-push/SKILL.md +55 -0
- aicp/_skills_data/safe-git-push/scripts/safe_push.py +248 -0
- aicp/_utils.py +183 -0
- aicp/budget.py +158 -0
- aicp/cli.py +595 -0
- aicp/config.py +434 -0
- aicp/contracts.py +144 -0
- aicp/gitflow.py +456 -0
- aicp/i18n.py +228 -0
- aicp/menu.py +1013 -0
- aicp/notify.py +87 -0
- aicp/present.py +303 -0
- aicp/quota.py +115 -0
- aicp/runner.py +560 -0
- aicp/secrets.py +291 -0
- aicp/skills.py +679 -0
- aicp/timing.py +116 -0
- aicp_cli-0.3.0.dist-info/METADATA +298 -0
- aicp_cli-0.3.0.dist-info/RECORD +25 -0
- aicp_cli-0.3.0.dist-info/WHEEL +4 -0
- aicp_cli-0.3.0.dist-info/entry_points.txt +2 -0
- aicp_cli-0.3.0.dist-info/licenses/LICENSE +20 -0
aicp/notify.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""The injected notifier — Telegram when it is there, a printed line when not.
|
|
2
|
+
|
|
3
|
+
Port of ``_aicp_notify`` in ``~/scripts/bin/aicp``. Telegram lives in a
|
|
4
|
+
different project (``$HOME/.claude``), so it is strictly optional: without it
|
|
5
|
+
the same text is printed instead of being sent, and aicp keeps working for
|
|
6
|
+
anyone who only has this repo.
|
|
7
|
+
|
|
8
|
+
:func:`notify` is the real implementation behind ``contracts.NotifyFn`` — it
|
|
9
|
+
is passed INTO the runner at the CLI entry point, never imported by it, so
|
|
10
|
+
the runner stays free of any network or subprocess dependency. Two
|
|
11
|
+
consequences are deliberate:
|
|
12
|
+
|
|
13
|
+
* **Nothing raises out of here.** A notification is the last step of a run
|
|
14
|
+
that has usually already succeeded; a missing script, an unreadable path, a
|
|
15
|
+
non-zero exit or a killed child all degrade to the printed line.
|
|
16
|
+
* **The script path comes from the environment only**, resolved per call.
|
|
17
|
+
``AICP_TG_SEND`` is never read from ``.aicprc``: a config file is a
|
|
18
|
+
checked-in, shareable artifact, and a path in it would be an arbitrary
|
|
19
|
+
program this module then executes.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import subprocess
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from ._utils import DIM, RESET, have
|
|
29
|
+
from .i18n import t
|
|
30
|
+
|
|
31
|
+
__all__ = ["notify", "tg_send_script"]
|
|
32
|
+
|
|
33
|
+
# Seconds the send script gets before it is killed and the printed line takes
|
|
34
|
+
# over. A notification is the last step of a run that has usually already
|
|
35
|
+
# committed and pushed, so an unbounded wait here holds the whole run hostage
|
|
36
|
+
# to a hung network call — the exact opposite of the "degrades to a printed
|
|
37
|
+
# line" promise in this module's docstring. Generous enough for a real
|
|
38
|
+
# Telegram round trip, short enough that nobody watches a dead terminal.
|
|
39
|
+
SEND_TIMEOUT = 15.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def tg_send_script() -> Path:
|
|
43
|
+
"""``$AICP_TG_SEND``, else ``$HOME/.claude/scripts/tg-send.sh``."""
|
|
44
|
+
override = os.environ.get("AICP_TG_SEND")
|
|
45
|
+
if override:
|
|
46
|
+
return Path(override)
|
|
47
|
+
return Path.home() / ".claude" / "scripts" / "tg-send.sh"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _send(message: str) -> bool:
|
|
51
|
+
script = tg_send_script()
|
|
52
|
+
try:
|
|
53
|
+
if not script.is_file():
|
|
54
|
+
return False
|
|
55
|
+
# tg-send.sh is a bash script; run it under bash the way the zsh
|
|
56
|
+
# original does, falling back to executing it directly where bash is
|
|
57
|
+
# not on PATH (a Windows box without Git Bash).
|
|
58
|
+
cmd = (
|
|
59
|
+
["bash", str(script), "send", message]
|
|
60
|
+
if have("bash")
|
|
61
|
+
else [str(script), "send", message]
|
|
62
|
+
)
|
|
63
|
+
done = subprocess.run(
|
|
64
|
+
cmd,
|
|
65
|
+
stdout=subprocess.DEVNULL,
|
|
66
|
+
stderr=subprocess.DEVNULL,
|
|
67
|
+
check=False,
|
|
68
|
+
timeout=SEND_TIMEOUT,
|
|
69
|
+
)
|
|
70
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
71
|
+
# A hung script degrades exactly like a missing one: subprocess.run has
|
|
72
|
+
# already killed and reaped it by the time TimeoutExpired is raised, so
|
|
73
|
+
# nothing is left running and the caller gets the printed line.
|
|
74
|
+
return False
|
|
75
|
+
return done.returncode == 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def notify(message: str) -> None:
|
|
79
|
+
"""Send *message*, or say out loud that it could not be sent.
|
|
80
|
+
|
|
81
|
+
The NotifyFn the CLI entry point wires into the runner. Never raises, and
|
|
82
|
+
never prints the message itself on the failure path — only that the send
|
|
83
|
+
did not happen.
|
|
84
|
+
"""
|
|
85
|
+
if _send(message):
|
|
86
|
+
return
|
|
87
|
+
print(f"{DIM}" + t("telegram_unavailable", "(telegram unavailable — message not sent)") + RESET)
|
aicp/present.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Terminal presentation: the spinner animation and the framed panel/table
|
|
2
|
+
renderer, ported from ``~/scripts/lib/spinner.sh`` and
|
|
3
|
+
``~/scripts/lib/box_render.py``.
|
|
4
|
+
|
|
5
|
+
Pure Python, no subprocess: the zsh original shells out to ``box_render.py``
|
|
6
|
+
once per render (a real, measured fork cost per panel drawn); this port pays
|
|
7
|
+
that cost once, at import, and never again.
|
|
8
|
+
|
|
9
|
+
Both pieces are TTY/``NO_COLOR``-gated the same way the shell/Python
|
|
10
|
+
originals were: the spinner animation only draws when stderr is a TTY and
|
|
11
|
+
``NO_COLOR`` is unset, falling back to ASCII frames off a UTF-8 locale.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
import unicodedata
|
|
22
|
+
from collections.abc import Sequence
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from ._utils import BOLD, DIM, RESET
|
|
26
|
+
from .i18n import t
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Spinner",
|
|
30
|
+
"render",
|
|
31
|
+
"render_panel",
|
|
32
|
+
"render_table",
|
|
33
|
+
"spinner_capture",
|
|
34
|
+
"spinner_enabled",
|
|
35
|
+
"spinner_run",
|
|
36
|
+
"width",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# ── spinner (lib/spinner.sh port) ────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
_FRAMES_BRAILLE = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
|
42
|
+
_FRAMES_ASCII = ("|", "/", "-", "\\")
|
|
43
|
+
_SPINNER_INTERVAL = 0.08
|
|
44
|
+
_SPINNER_COLOR = "\033[38;5;87m"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def spinner_enabled(stream=None) -> bool:
|
|
48
|
+
"""True when the animation should be drawn: *stream* (default stderr) is
|
|
49
|
+
a TTY and ``NO_COLOR`` is unset — the same gate the shell original uses."""
|
|
50
|
+
stream = stream if stream is not None else sys.stderr
|
|
51
|
+
try:
|
|
52
|
+
if not stream.isatty():
|
|
53
|
+
return False
|
|
54
|
+
except Exception: # noqa: BLE001 - a stream with no working isatty() is never a TTY
|
|
55
|
+
return False
|
|
56
|
+
return not os.environ.get("NO_COLOR")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _locale_is_utf8() -> bool:
|
|
60
|
+
"""Braille needs a UTF-8-capable locale; anything else gets ASCII frames.
|
|
61
|
+
|
|
62
|
+
Checked in the same precedence as the shell original: LC_ALL, then
|
|
63
|
+
LC_CTYPE, then LANG.
|
|
64
|
+
"""
|
|
65
|
+
value = os.environ.get("LC_ALL") or os.environ.get("LC_CTYPE") or os.environ.get("LANG") or ""
|
|
66
|
+
return "utf-8" in value.lower() or "utf8" in value.lower()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _frames() -> tuple[str, ...]:
|
|
70
|
+
return _FRAMES_BRAILLE if _locale_is_utf8() else _FRAMES_ASCII
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Spinner:
|
|
74
|
+
"""Animate a message on stderr until stopped. Disabled (a no-op) when
|
|
75
|
+
:func:`spinner_enabled` is False — matching spinner.sh's TTY/NO_COLOR gate.
|
|
76
|
+
|
|
77
|
+
Usage::
|
|
78
|
+
|
|
79
|
+
spinner = Spinner("Fetching…")
|
|
80
|
+
spinner.start()
|
|
81
|
+
...
|
|
82
|
+
spinner.stop()
|
|
83
|
+
|
|
84
|
+
or as a context manager: ``with Spinner("Fetching…"): ...``.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, message: str = "Working…", *, stream=None) -> None:
|
|
88
|
+
self._message = message
|
|
89
|
+
self._stream = stream if stream is not None else sys.stderr
|
|
90
|
+
self._enabled = spinner_enabled(self._stream)
|
|
91
|
+
self._frames = _frames()
|
|
92
|
+
self._stop_event = threading.Event()
|
|
93
|
+
self._thread: threading.Thread | None = None
|
|
94
|
+
self._lock = threading.Lock()
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def enabled(self) -> bool:
|
|
98
|
+
return self._enabled
|
|
99
|
+
|
|
100
|
+
def update(self, message: str) -> None:
|
|
101
|
+
with self._lock:
|
|
102
|
+
self._message = message
|
|
103
|
+
|
|
104
|
+
def _run(self) -> None:
|
|
105
|
+
i = 0
|
|
106
|
+
n = len(self._frames)
|
|
107
|
+
while not self._stop_event.is_set():
|
|
108
|
+
with self._lock:
|
|
109
|
+
message = self._message
|
|
110
|
+
frame = self._frames[i % n]
|
|
111
|
+
self._stream.write(f"\r{_SPINNER_COLOR}{frame}{RESET} {message}\033[K")
|
|
112
|
+
self._stream.flush()
|
|
113
|
+
i += 1
|
|
114
|
+
self._stop_event.wait(_SPINNER_INTERVAL)
|
|
115
|
+
self._stream.write("\r\033[K")
|
|
116
|
+
self._stream.flush()
|
|
117
|
+
|
|
118
|
+
def start(self) -> None:
|
|
119
|
+
if not self._enabled:
|
|
120
|
+
return
|
|
121
|
+
self.stop() # matches spinner_start's own leading spinner_stop
|
|
122
|
+
self._stop_event.clear()
|
|
123
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
124
|
+
self._thread.start()
|
|
125
|
+
|
|
126
|
+
def stop(self) -> None:
|
|
127
|
+
if self._thread is None:
|
|
128
|
+
return
|
|
129
|
+
self._stop_event.set()
|
|
130
|
+
self._thread.join()
|
|
131
|
+
self._thread = None
|
|
132
|
+
|
|
133
|
+
def __enter__(self) -> Spinner: # noqa: PYI034 - Self needs 3.11+; this package supports 3.10
|
|
134
|
+
self.start()
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
138
|
+
self.stop()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def spinner_run(message: str, cmd: Sequence[str], **kwargs) -> subprocess.CompletedProcess[str]:
|
|
142
|
+
"""Animate *message* while *cmd* runs; relay its exit status.
|
|
143
|
+
|
|
144
|
+
Port of spinner.sh's ``spinner_run``. Output is inherited (not captured) —
|
|
145
|
+
use :func:`spinner_capture` to redirect it to a log instead.
|
|
146
|
+
"""
|
|
147
|
+
spinner = Spinner(message)
|
|
148
|
+
spinner.start()
|
|
149
|
+
check = kwargs.pop("check", False)
|
|
150
|
+
try:
|
|
151
|
+
return subprocess.run(cmd, check=check, **kwargs)
|
|
152
|
+
finally:
|
|
153
|
+
spinner.stop()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def spinner_capture(
|
|
157
|
+
logfile: str | Path, message: str, cmd: Sequence[str], **kwargs
|
|
158
|
+
) -> subprocess.CompletedProcess[str]:
|
|
159
|
+
"""Animate *message* while *cmd* runs, sending its combined stdout/stderr
|
|
160
|
+
to *logfile* instead of the screen. Port of spinner.sh's ``spinner_capture``.
|
|
161
|
+
"""
|
|
162
|
+
spinner = Spinner(message)
|
|
163
|
+
spinner.start()
|
|
164
|
+
check = kwargs.pop("check", False)
|
|
165
|
+
try:
|
|
166
|
+
with open(logfile, "w", encoding="utf-8") as handle:
|
|
167
|
+
return subprocess.run(
|
|
168
|
+
cmd, stdout=handle, stderr=subprocess.STDOUT, check=check, **kwargs
|
|
169
|
+
)
|
|
170
|
+
finally:
|
|
171
|
+
spinner.stop()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ── box render (lib/box_render.py port) ──────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
FRAME = "\033[38;5;240m"
|
|
177
|
+
HEADER = "\033[48;5;238;38;5;255m"
|
|
178
|
+
STRIPE = "\033[48;5;235;38;5;252m"
|
|
179
|
+
# Resolved at import like every other message: the column widths below are
|
|
180
|
+
# measured from it, and a table cannot re-measure itself mid-run.
|
|
181
|
+
VALUE_HEADER = t("panel_value_header", "VALUE")
|
|
182
|
+
|
|
183
|
+
_ANSI = re.compile(r"\033\[[0-9;]*m")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def width(text: str) -> int:
|
|
187
|
+
"""Visible columns of *text*: ANSI escapes stripped, East-Asian
|
|
188
|
+
Wide/Fullwidth glyphs count two columns, combining marks count none."""
|
|
189
|
+
return sum(
|
|
190
|
+
0 if unicodedata.combining(ch) else 2 if unicodedata.east_asian_width(ch) in "WF" else 1
|
|
191
|
+
for ch in _ANSI.sub("", text)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _reband(text: str, band: str) -> str:
|
|
196
|
+
"""*text* with *band* re-armed after every RESET it embeds — a colored
|
|
197
|
+
cell ends in RESET, which clears the background too; without re-arming, a
|
|
198
|
+
banded row would lose its stripe from that cell onward."""
|
|
199
|
+
return text.replace(RESET, RESET + band) if band else text
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def render_panel(
|
|
203
|
+
rows: Sequence[tuple[str, str]],
|
|
204
|
+
title: str,
|
|
205
|
+
accent: str,
|
|
206
|
+
zebra: bool = False,
|
|
207
|
+
notes: Sequence[str] = (),
|
|
208
|
+
) -> list[str]:
|
|
209
|
+
"""Render *rows* (label, value pairs) as a titled rounded-frame panel.
|
|
210
|
+
|
|
211
|
+
``zebra`` shades every other row full-width; a single-row panel never
|
|
212
|
+
bands (nothing to alternate against). ``notes`` are extra full-width,
|
|
213
|
+
already-colored lines drawn inside the same frame below the rows (a blank
|
|
214
|
+
separator first, then one line per note) — for content that isn't a
|
|
215
|
+
(label, value) pair, such as the config menu's per-row help text and its
|
|
216
|
+
key-hint footer. A row whose ``value`` is empty is a section heading
|
|
217
|
+
(e.g. the config menu's "Steps"/"General"/"Tools" groups) — bold, then a
|
|
218
|
+
dim rule out to the frame, spanning the full width instead of sharing the
|
|
219
|
+
label/value columns. Weight and the rule carry the separation, not a
|
|
220
|
+
fifth hue: bold against DIM labels reads as a heading at a glance, while
|
|
221
|
+
CYAN/GREEN/MAGENTA stay free to mean what a *value* means elsewhere (a
|
|
222
|
+
changeable setting, a state, the repo name), and the frame's own colour
|
|
223
|
+
stays chrome. Bold on the terminal's default foreground — the same
|
|
224
|
+
treatment as *title* — rather than a hardcoded white, so the heading
|
|
225
|
+
survives a light-background theme.
|
|
226
|
+
"""
|
|
227
|
+
label_w = max(width(k) for k, v in rows if v)
|
|
228
|
+
value_w = max((width(v) for _, v in rows if v), default=0)
|
|
229
|
+
inner = max(
|
|
230
|
+
label_w + value_w + 6,
|
|
231
|
+
width(title) + 3,
|
|
232
|
+
*(width(n) + 4 for n in notes),
|
|
233
|
+
*(width(k) + 4 for k, v in rows if not v),
|
|
234
|
+
)
|
|
235
|
+
dashes = inner - width(title) - 3
|
|
236
|
+
out = [f"{accent}╭─ {BOLD}{title}{RESET}{accent} {'─' * dashes}╮{RESET}"]
|
|
237
|
+
for i, (label, value) in enumerate(rows):
|
|
238
|
+
if not value:
|
|
239
|
+
rule = max(inner - 4 - width(label), 0)
|
|
240
|
+
tail = f" {DIM}{'─' * rule}{RESET} " if rule else " " * (inner - 2 - width(label))
|
|
241
|
+
out.append(f"{accent}│{RESET} {BOLD}{label}{RESET}{tail}{accent}│{RESET}")
|
|
242
|
+
continue
|
|
243
|
+
pad = " " * (label_w - width(label))
|
|
244
|
+
vpad = " " * (value_w - width(value))
|
|
245
|
+
trail = " " * (inner - 4 - label_w - value_w)
|
|
246
|
+
content = f" {DIM}{label}{pad}{RESET} {value}{vpad}{trail}"
|
|
247
|
+
band = STRIPE if (zebra and len(rows) > 1 and i % 2) else ""
|
|
248
|
+
body = f"{band}{_reband(content, band)}{RESET}" if band else content
|
|
249
|
+
out.append(f"{accent}│{RESET}{body}{accent}│{RESET}")
|
|
250
|
+
if notes:
|
|
251
|
+
out.append(f"{accent}│{RESET}{' ' * inner}{accent}│{RESET}")
|
|
252
|
+
for note in notes:
|
|
253
|
+
pad = " " * (inner - 2 - width(note))
|
|
254
|
+
out.append(f"{accent}│{RESET} {note}{pad}{accent}│{RESET}")
|
|
255
|
+
out.append(f"{accent}╰{'─' * inner}╯{RESET}")
|
|
256
|
+
return out
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def render_table(rows: Sequence[tuple[str, str]], title: str) -> list[str]:
|
|
260
|
+
"""Render *rows* (label, value pairs) as a two-column table with a
|
|
261
|
+
banded header row and zebra-striped body rows."""
|
|
262
|
+
widths = [
|
|
263
|
+
max(width(title), *(width(k) for k, _ in rows)),
|
|
264
|
+
max(width(VALUE_HEADER), *(width(v) for _, v in rows)),
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
def rule(left: str, mid: str, right: str) -> str:
|
|
268
|
+
return FRAME + left + mid.join("─" * (w + 2) for w in widths) + right + RESET
|
|
269
|
+
|
|
270
|
+
def row(cells: Sequence[str], band: str = "") -> str:
|
|
271
|
+
parts = [
|
|
272
|
+
f" {_reband(c, band)}{' ' * max(w - width(c), 0)} " for c, w in zip(cells, widths)
|
|
273
|
+
]
|
|
274
|
+
edge = f"{FRAME}│{RESET}"
|
|
275
|
+
if not band:
|
|
276
|
+
return edge + edge.join(parts) + edge
|
|
277
|
+
return f"{edge}{band}{(FRAME + '│' + band).join(parts)}{RESET}{edge}"
|
|
278
|
+
|
|
279
|
+
return [
|
|
280
|
+
rule("╭", "┬", "╮"),
|
|
281
|
+
row([f"{BOLD}{title}{RESET}", f"{BOLD}{VALUE_HEADER}{RESET}"], HEADER),
|
|
282
|
+
rule("├", "┼", "┤"),
|
|
283
|
+
*(row([k, v], STRIPE if i % 2 else "") for i, (k, v) in enumerate(rows)),
|
|
284
|
+
rule("╰", "┴", "╯"),
|
|
285
|
+
]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def render(
|
|
289
|
+
rows: Sequence[tuple[str, str]],
|
|
290
|
+
title: str,
|
|
291
|
+
*,
|
|
292
|
+
mode: str = "table",
|
|
293
|
+
accent: str = FRAME,
|
|
294
|
+
zebra: bool = False,
|
|
295
|
+
) -> list[str]:
|
|
296
|
+
"""Dispatch to :func:`render_panel`/:func:`render_table`, drawing nothing
|
|
297
|
+
for empty *rows* — the single entry point callers should use (mirrors
|
|
298
|
+
``box_render.py``'s CLI, which exits 0 with no output for empty stdin,
|
|
299
|
+
rather than calling the two renderers directly and having to remember
|
|
300
|
+
the empty-input guard themselves)."""
|
|
301
|
+
if not rows:
|
|
302
|
+
return []
|
|
303
|
+
return render_panel(rows, title, accent, zebra) if mode == "panel" else render_table(rows, title)
|
aicp/quota.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Cross-run quota cooldown: ``{"<cli>": <expiry epoch>}`` in ``~/.aicp/quota.json``.
|
|
2
|
+
|
|
3
|
+
:attr:`aicp.runner.StepResult.quota_clis` excludes an exhausted CLI for the rest
|
|
4
|
+
of ONE run. That is too short: a token/rate-limit wall usually stands for hours,
|
|
5
|
+
so every later run walked into it again and paid a full budget per step for a CLI
|
|
6
|
+
that could not succeed. This module is the persistent half — a CLI that reported
|
|
7
|
+
a supported quota signal is skipped until its window expires.
|
|
8
|
+
|
|
9
|
+
Deliberately its OWN file rather than a key in ``state.json``:
|
|
10
|
+
:func:`aicp.skills._save_state` rewrites that file as ``{"version", "skills"}``
|
|
11
|
+
and would drop anything else at the top level, and :mod:`aicp.runner` imports no
|
|
12
|
+
sibling feature module (see its docstring), which rules out reaching for the
|
|
13
|
+
skills installer's reader. A peer of ``timing.log`` in aicp's own directory keeps
|
|
14
|
+
both properties.
|
|
15
|
+
|
|
16
|
+
The window is ``AICP_QUOTA_COOLDOWN`` seconds. ``0`` switches the feature off
|
|
17
|
+
entirely — the escape hatch for a CLI wrongly sidelined — and any value that is
|
|
18
|
+
not a plain integer in ``(0, _MAX_COOLDOWN]`` falls back to :data:`DEFAULT_COOLDOWN`,
|
|
19
|
+
which is :mod:`aicp.budget`'s posture: a bad config value never bricks aicp.
|
|
20
|
+
|
|
21
|
+
Nothing here is ever allowed to break a run. A missing, corrupt or unwritable
|
|
22
|
+
file reads as "no cooldowns", so the worst a failure can cost is one wasted CLI
|
|
23
|
+
invocation — never a changed exit code, and never a blocked commit.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import time
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
__all__ = ["DEFAULT_COOLDOWN", "cooldown_seconds", "cooling", "quota_path", "record"]
|
|
34
|
+
|
|
35
|
+
#: One hour. Short on purpose: over-skipping sidelines a CLI that has already
|
|
36
|
+
#: recovered, while under-skipping costs a single wasted invocation, so the
|
|
37
|
+
#: asymmetry favors erring short and self-healing fast.
|
|
38
|
+
DEFAULT_COOLDOWN = 3600
|
|
39
|
+
|
|
40
|
+
#: A week. A cooldown longer than any real quota window is almost certainly a
|
|
41
|
+
#: typo, and accepting it would sideline a CLI for good with nothing on screen
|
|
42
|
+
#: to explain why.
|
|
43
|
+
_MAX_COOLDOWN = 7 * 24 * 3600
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def quota_path() -> Path:
|
|
47
|
+
"""``~/.aicp/quota.json`` — beside ``timing.log``, never in a CLI's own dir."""
|
|
48
|
+
return Path.home() / ".aicp" / "quota.json"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cooldown_seconds() -> int:
|
|
52
|
+
"""The configured window: ``0`` (disabled), else 1..:data:`_MAX_COOLDOWN`."""
|
|
53
|
+
raw = os.environ.get("AICP_QUOTA_COOLDOWN")
|
|
54
|
+
if raw is None:
|
|
55
|
+
return DEFAULT_COOLDOWN
|
|
56
|
+
try:
|
|
57
|
+
value = int(raw)
|
|
58
|
+
except ValueError:
|
|
59
|
+
return DEFAULT_COOLDOWN
|
|
60
|
+
if value == 0:
|
|
61
|
+
return 0
|
|
62
|
+
return value if 0 < value <= _MAX_COOLDOWN else DEFAULT_COOLDOWN
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _expiry(value: object) -> float | None:
|
|
66
|
+
"""*value* as an epoch, or None if the file holds something else there.
|
|
67
|
+
|
|
68
|
+
``isinstance(True, int)`` is true in Python, so a JSON ``true`` would other-
|
|
69
|
+
wise read as the epoch 1 and count as a (long-expired) entry.
|
|
70
|
+
"""
|
|
71
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
72
|
+
return None
|
|
73
|
+
return float(value)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _load(path: Path) -> dict[str, object]:
|
|
77
|
+
"""Every recorded cooldown. Missing/corrupt/unreadable all mean "none"."""
|
|
78
|
+
try:
|
|
79
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
80
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
81
|
+
return {}
|
|
82
|
+
return data if isinstance(data, dict) else {}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cooling(cli: str, *, path: Path | None = None, now: float | None = None) -> int:
|
|
86
|
+
"""Seconds left on *cli*'s cooldown, or 0 if it is free to run."""
|
|
87
|
+
if cooldown_seconds() <= 0:
|
|
88
|
+
return 0
|
|
89
|
+
expiry = _expiry(_load(path or quota_path()).get(cli))
|
|
90
|
+
if expiry is None:
|
|
91
|
+
return 0
|
|
92
|
+
return max(0, int(expiry - (time.time() if now is None else now)))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def record(cli: str, *, path: Path | None = None, now: float | None = None) -> None:
|
|
96
|
+
"""Start *cli*'s cooldown, pruning expired entries. Never raises."""
|
|
97
|
+
window = cooldown_seconds()
|
|
98
|
+
if window <= 0:
|
|
99
|
+
return
|
|
100
|
+
stamp = time.time() if now is None else now
|
|
101
|
+
target = path or quota_path()
|
|
102
|
+
try:
|
|
103
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
live = {
|
|
105
|
+
name: expiry
|
|
106
|
+
for name, value in _load(target).items()
|
|
107
|
+
if (expiry := _expiry(value)) is not None and expiry > stamp
|
|
108
|
+
}
|
|
109
|
+
live[cli] = int(stamp + window)
|
|
110
|
+
# A plain write, not the atomic replace state.json needs: a torn file
|
|
111
|
+
# already reads as "no cooldowns" via _load, so the guard that makes a
|
|
112
|
+
# corrupt file harmless makes atomicity buy nothing on top.
|
|
113
|
+
target.write_text(json.dumps(live, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
114
|
+
except OSError:
|
|
115
|
+
return
|