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/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