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/_utils.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Cross-platform helpers shared across aicp — macOS, Linux, Windows.
|
|
2
|
+
|
|
3
|
+
Patterns (platform flags, ANSI/Windows VT enabling, ``have()``) mirror
|
|
4
|
+
``~/Documents/Workspace/ai-accounts``'s ``src/ai_accounts/_utils.py`` so the
|
|
5
|
+
two projects feel like one family; nothing is imported across the two repos,
|
|
6
|
+
each ships its own copy (same posture the zsh original documents for
|
|
7
|
+
``lib/spinner.sh`` and ``lib/box_render.py``).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import signal
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
IS_WINDOWS = sys.platform == "win32"
|
|
20
|
+
IS_MACOS = sys.platform == "darwin"
|
|
21
|
+
IS_LINUX = sys.platform.startswith("linux")
|
|
22
|
+
|
|
23
|
+
BOLD = "\033[1m"
|
|
24
|
+
DIM = "\033[2m"
|
|
25
|
+
RESET = "\033[0m"
|
|
26
|
+
GREEN = "\033[38;5;82m"
|
|
27
|
+
YELLOW = "\033[38;5;220m"
|
|
28
|
+
RED = "\033[38;5;203m"
|
|
29
|
+
CYAN = "\033[38;5;87m"
|
|
30
|
+
MAGENTA = "\033[38;5;213m"
|
|
31
|
+
BLUE = "\033[38;5;75m"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _enable_windows_ansi() -> bool:
|
|
35
|
+
"""Turn on virtual-terminal processing so ANSI escapes render on Windows.
|
|
36
|
+
|
|
37
|
+
No-op (returns True) on non-Windows. On modern Windows 10+ consoles this
|
|
38
|
+
flips ENABLE_VIRTUAL_TERMINAL_PROCESSING for both stdout and stderr.
|
|
39
|
+
"""
|
|
40
|
+
if not IS_WINDOWS:
|
|
41
|
+
return True
|
|
42
|
+
try:
|
|
43
|
+
import ctypes
|
|
44
|
+
|
|
45
|
+
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
46
|
+
ENABLE_VT = 0x0004
|
|
47
|
+
ok = False
|
|
48
|
+
for std_handle in (-11, -12): # STD_OUTPUT_HANDLE, STD_ERROR_HANDLE
|
|
49
|
+
handle = kernel32.GetStdHandle(std_handle)
|
|
50
|
+
if handle in (0, -1):
|
|
51
|
+
continue
|
|
52
|
+
mode = ctypes.c_uint32()
|
|
53
|
+
if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
54
|
+
continue
|
|
55
|
+
if kernel32.SetConsoleMode(handle, mode.value | ENABLE_VT):
|
|
56
|
+
ok = True
|
|
57
|
+
return ok
|
|
58
|
+
except Exception: # noqa: BLE001 - best-effort VT enable, any ctypes failure means "no"
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Enable VT once at import time so ANSI output renders on Windows terminals.
|
|
63
|
+
_WIN_ANSI_OK = _enable_windows_ansi()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def color_supported(stream=None) -> bool:
|
|
67
|
+
"""Whether ANSI color/animation should be drawn on *stream* (default stderr)."""
|
|
68
|
+
stream = stream if stream is not None else sys.stderr
|
|
69
|
+
try:
|
|
70
|
+
if not stream.isatty():
|
|
71
|
+
return False
|
|
72
|
+
except Exception: # noqa: BLE001 - a stream with no working isatty() is never a TTY
|
|
73
|
+
return False
|
|
74
|
+
if os.environ.get("NO_COLOR"):
|
|
75
|
+
return False
|
|
76
|
+
if IS_WINDOWS:
|
|
77
|
+
return _WIN_ANSI_OK or bool(os.environ.get("WT_SESSION"))
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def have(cmd: str) -> bool:
|
|
82
|
+
"""True if *cmd* resolves to an executable on PATH."""
|
|
83
|
+
return shutil.which(cmd) is not None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def home_config_dir(env_var: str, default: Path) -> Path:
|
|
87
|
+
"""Resolve a config path: ``$<env_var>`` override, else *default*."""
|
|
88
|
+
override = os.environ.get(env_var)
|
|
89
|
+
return Path(override) if override else default
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ── interruptible subprocess ─────────────────────────────────────────────────
|
|
93
|
+
#
|
|
94
|
+
# The zsh original's ``_aicp_timeout`` wraps every CLI invocation with GNU
|
|
95
|
+
# `timeout --foreground` specifically so Ctrl+C reaches the child directly:
|
|
96
|
+
# without --foreground, `timeout` puts the child in a NEW process group, and
|
|
97
|
+
# the terminal only delivers SIGINT to the FOREGROUND process group — so the
|
|
98
|
+
# signal never reached the CLI, the CLI never exited, and the whole script
|
|
99
|
+
# hung waiting on it (a real, previously-observed bug, not a theoretical one;
|
|
100
|
+
# see the long comment above `_aicp_timeout` in bin/aicp). The documented cost
|
|
101
|
+
# there is that a timeout then only signals the CLI itself, not any
|
|
102
|
+
# grandchildren it spawned — an interruptible Ctrl+C is worth more than
|
|
103
|
+
# reaping those.
|
|
104
|
+
#
|
|
105
|
+
# run_interruptible reproduces the same posture without shelling out to GNU
|
|
106
|
+
# timeout at all:
|
|
107
|
+
# POSIX the child is placed in THIS process's own process group (i.e. no
|
|
108
|
+
# new group is created for it — the same effect --foreground
|
|
109
|
+
# achieves), so Ctrl+C at the terminal reaches it directly, same as
|
|
110
|
+
# if it had been run with no wrapper at all.
|
|
111
|
+
# Windows there is no equivalent "foreground process group" concept.
|
|
112
|
+
# CREATE_NEW_PROCESS_GROUP lets this process forward an interrupt to
|
|
113
|
+
# just the child WITHOUT also killing itself (plain Ctrl+C on
|
|
114
|
+
# Windows targets every process attached to the same console) — but
|
|
115
|
+
# that means the child does NOT receive the console's own Ctrl+C
|
|
116
|
+
# directly; the caller must catch KeyboardInterrupt and forward the
|
|
117
|
+
# event itself. The event sent is CTRL_BREAK_EVENT, not
|
|
118
|
+
# CTRL_C_EVENT: Win32's GenerateConsoleCtrlEvent (what
|
|
119
|
+
# Popen.send_signal calls under the hood) documents that
|
|
120
|
+
# CTRL_C_EVENT CANNOT target a specific, non-zero process group —
|
|
121
|
+
# the call reports success but the child is never actually
|
|
122
|
+
# signaled — while CTRL_BREAK_EVENT can. This is an honest
|
|
123
|
+
# limitation, not a full port of the POSIX behavior: a child that
|
|
124
|
+
# ignores CTRL_BREAK_EVENT (or is a console subsystem app with no
|
|
125
|
+
# handler installed), or that treats CTRL_BREAK as an unconditional
|
|
126
|
+
# kill rather than a graceful one (a real difference from Ctrl+C,
|
|
127
|
+
# which most well-behaved CLIs treat as cancellable), will not stop
|
|
128
|
+
# cleanly, and there is no equivalent of --foreground's direct
|
|
129
|
+
# delivery. Callers on Windows needing a hard kill should follow up
|
|
130
|
+
# with Popen.terminate()/kill() rather than assume the event lands.
|
|
131
|
+
def run_interruptible(cmd, **kwargs) -> subprocess.CompletedProcess[str]:
|
|
132
|
+
"""Run *cmd* so Ctrl+C reaches it directly (POSIX) or can be forwarded
|
|
133
|
+
(Windows), returning a ``subprocess.CompletedProcess`` like
|
|
134
|
+
``subprocess.run`` would.
|
|
135
|
+
|
|
136
|
+
T2 layers a timeout and a kill-after grace period on top of this (its own
|
|
137
|
+
fallback-chain budget per CLI); T4 uses it bare for slow, uninterruptible
|
|
138
|
+
calls like ``git fetch``. Extra ``**kwargs`` are passed through to
|
|
139
|
+
``subprocess.Popen`` unchanged (e.g. ``cwd``, ``env``, ``stdout``).
|
|
140
|
+
"""
|
|
141
|
+
kwargs.setdefault("text", True)
|
|
142
|
+
if IS_WINDOWS:
|
|
143
|
+
kwargs["creationflags"] = kwargs.get("creationflags", 0) | subprocess.CREATE_NEW_PROCESS_GROUP
|
|
144
|
+
with subprocess.Popen(cmd, **kwargs) as proc:
|
|
145
|
+
try:
|
|
146
|
+
stdout, stderr = proc.communicate()
|
|
147
|
+
except KeyboardInterrupt:
|
|
148
|
+
try:
|
|
149
|
+
# CTRL_C_EVENT cannot target a specific process group (Win32
|
|
150
|
+
# would report success while never actually signaling the
|
|
151
|
+
# child) — CTRL_BREAK_EVENT is the one that can. See the
|
|
152
|
+
# module comment above for the full explanation.
|
|
153
|
+
proc.send_signal(signal.CTRL_BREAK_EVENT) # type: ignore[attr-defined]
|
|
154
|
+
except Exception: # noqa: BLE001 - CTRL_BREAK_EVENT delivery can fail many ways; fall back to a hard terminate
|
|
155
|
+
proc.terminate()
|
|
156
|
+
stdout, stderr = proc.communicate()
|
|
157
|
+
raise
|
|
158
|
+
except BaseException:
|
|
159
|
+
proc.kill()
|
|
160
|
+
raise
|
|
161
|
+
return subprocess.CompletedProcess(cmd, proc.returncode, stdout, stderr)
|
|
162
|
+
|
|
163
|
+
# POSIX: start_new_session=False (the default) keeps the child in this
|
|
164
|
+
# process's own process group — no new group is created for it, so the
|
|
165
|
+
# terminal's SIGINT (delivered to the whole foreground process group)
|
|
166
|
+
# reaches the child the same way it would with no wrapper at all. This is
|
|
167
|
+
# the direct equivalent of GNU `timeout --foreground`.
|
|
168
|
+
# `with Popen(...)` plus a kill on any unhandled exception, the way
|
|
169
|
+
# subprocess.run itself does it: between spawn and wait, anything escaping
|
|
170
|
+
# that is NOT the interrupt handled below would otherwise leave a live
|
|
171
|
+
# child behind with nobody left to reap it.
|
|
172
|
+
with subprocess.Popen(cmd, **kwargs) as proc:
|
|
173
|
+
try:
|
|
174
|
+
stdout, stderr = proc.communicate()
|
|
175
|
+
except KeyboardInterrupt:
|
|
176
|
+
# The child already received the same SIGINT (same process group);
|
|
177
|
+
# wait for it to unwind rather than second-guessing it with a kill.
|
|
178
|
+
stdout, stderr = proc.communicate()
|
|
179
|
+
raise
|
|
180
|
+
except BaseException:
|
|
181
|
+
proc.kill()
|
|
182
|
+
raise
|
|
183
|
+
return subprocess.CompletedProcess(cmd, proc.returncode, stdout, stderr)
|
aicp/budget.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Per-CLI timeout budget: a floor that grows with the diff, widened by history.
|
|
2
|
+
|
|
3
|
+
Port of ``_aicp_budget``/``_aicp_history_budget`` in ``~/scripts/bin/aicp``.
|
|
4
|
+
|
|
5
|
+
floor AICP_TIMEOUT_BASE covers cold start plus a small prompt
|
|
6
|
+
growth AICP_TIMEOUT_PER_FILE per changed or untracked file
|
|
7
|
+
AICP_TIMEOUT_PER_100L per 100 changed lines in tracked files
|
|
8
|
+
ceiling AICP_TIMEOUT_MAX so a real hang is still caught
|
|
9
|
+
override AICP_STEP_TIMEOUT pins the budget, skipping formula AND history
|
|
10
|
+
|
|
11
|
+
The budget is computed per CLI, inside the fallback loop rather than once above
|
|
12
|
+
it, so a fast CLI's own history never inflates a slow CLI's budget. History may
|
|
13
|
+
only WIDEN the budget above the formula, never shrink it below — and the
|
|
14
|
+
widened value is deliberately NOT re-capped at ``AICP_TIMEOUT_MAX``: that
|
|
15
|
+
ceiling exists to bound the no-history default, while a recorded successful run
|
|
16
|
+
is direct evidence this CLI legitimately needs that long.
|
|
17
|
+
|
|
18
|
+
Every value is validated as a plain non-negative integer before any arithmetic.
|
|
19
|
+
A malformed value falls back to :data:`FLOOR` with a stated note instead of
|
|
20
|
+
crashing: a bad value in a config file would otherwise brick aicp in every repo
|
|
21
|
+
on the machine, not just the one invocation that set it.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import subprocess
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from aicp import timing
|
|
33
|
+
from aicp.i18n import t
|
|
34
|
+
|
|
35
|
+
__all__ = ["FLOOR", "Budget", "compute"]
|
|
36
|
+
|
|
37
|
+
FLOOR = 180 # the zsh original's own documented AICP_TIMEOUT_BASE default
|
|
38
|
+
|
|
39
|
+
_DEFAULTS = {
|
|
40
|
+
"AICP_TIMEOUT_BASE": 180,
|
|
41
|
+
"AICP_TIMEOUT_PER_FILE": 15,
|
|
42
|
+
"AICP_TIMEOUT_PER_100L": 5,
|
|
43
|
+
"AICP_TIMEOUT_MAX": 1800,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_INT = re.compile(r"^[0-9]+$") # zsh's <->; str.isdigit() would accept "²"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Budget:
|
|
51
|
+
"""Seconds allowed for one CLI invocation, plus the note explaining it."""
|
|
52
|
+
|
|
53
|
+
seconds: int
|
|
54
|
+
note: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _env(name: str, default: str) -> str:
|
|
58
|
+
"""*name*'s value, treating exported-but-EMPTY as unset.
|
|
59
|
+
|
|
60
|
+
zsh sets every knob with ``: "${AICP_TIMEOUT_BASE:=180}"``, and ``:=``
|
|
61
|
+
substitutes the default for an unset **or empty** variable — so
|
|
62
|
+
``AICP_TIMEOUT_BASE=""`` computes a real formula there rather than being
|
|
63
|
+
reported as a bad value. ``os.environ.get`` alone would not.
|
|
64
|
+
"""
|
|
65
|
+
raw = os.environ.get(name)
|
|
66
|
+
return default if raw is None or raw == "" else raw
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _int_env(name: str) -> int | None:
|
|
70
|
+
"""The knob as an int, or None if it is set to something non-numeric."""
|
|
71
|
+
raw = _env(name, str(_DEFAULTS[name]))
|
|
72
|
+
return int(raw) if _INT.match(raw) else None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _git_count(args: list[str], cwd: Path | None) -> str:
|
|
76
|
+
try:
|
|
77
|
+
done = subprocess.run(
|
|
78
|
+
["git", *args], cwd=cwd, capture_output=True, text=True, timeout=30, check=False
|
|
79
|
+
)
|
|
80
|
+
except (OSError, subprocess.SubprocessError):
|
|
81
|
+
return ""
|
|
82
|
+
return done.stdout if done.returncode == 0 else ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _pending(cwd: Path | None) -> tuple[int, int]:
|
|
86
|
+
"""``(changed files, changed lines)`` per git, or ``(0, 0)`` if it can't say."""
|
|
87
|
+
files = len(_git_count(["status", "--porcelain"], cwd).splitlines())
|
|
88
|
+
lines = 0
|
|
89
|
+
for row in _git_count(["diff", "HEAD", "--numstat"], cwd).splitlines():
|
|
90
|
+
added, _, rest = row.partition("\t")
|
|
91
|
+
deleted = rest.partition("\t")[0]
|
|
92
|
+
# Binary files report "-" for both counts; awk read those as 0 too.
|
|
93
|
+
lines += sum(int(n) for n in (added, deleted) if _INT.match(n))
|
|
94
|
+
return files, lines
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _history(cli: str) -> int:
|
|
98
|
+
"""This CLI's largest successful run, multiplied out — 0 if there is none."""
|
|
99
|
+
raw_lines = _env("AICP_TIMEOUT_HISTORY_LINES", "500")
|
|
100
|
+
lines = int(raw_lines) if _INT.match(raw_lines) else 0
|
|
101
|
+
try:
|
|
102
|
+
mult = float(_env("AICP_TIMEOUT_HISTORY_MULT", "1.3"))
|
|
103
|
+
except ValueError:
|
|
104
|
+
return 0 # unusable multiplier: no widening, same as no history at all
|
|
105
|
+
best = timing.max_ok_seconds(cli, lines=lines)
|
|
106
|
+
if best <= 0:
|
|
107
|
+
return 0
|
|
108
|
+
try:
|
|
109
|
+
# `(max * mult) + 0.999999` truncated — the zsh/awk expression, i.e. ceil.
|
|
110
|
+
widened = int(best * mult + 0.999999)
|
|
111
|
+
except (OverflowError, ValueError):
|
|
112
|
+
# float() accepts "inf"/"1e400"/"nan" without raising above, and the
|
|
113
|
+
# product then has no integer form at all. The zsh original gates the
|
|
114
|
+
# awk result the same way afterwards -- `[[ "$history" == <-> ]] ||
|
|
115
|
+
# history=0` -- rather than trusting the computation; a value settable
|
|
116
|
+
# from .aicprc must never be able to crash aicp in every repo on the
|
|
117
|
+
# machine, which is this module's stated no-crash invariant.
|
|
118
|
+
return 0
|
|
119
|
+
return max(widened, 0)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def compute(cli: str, *, cwd: Path | None = None) -> Budget:
|
|
123
|
+
"""The seconds *cli* gets for one step, and the note shown beside it."""
|
|
124
|
+
pinned = os.environ.get("AICP_STEP_TIMEOUT")
|
|
125
|
+
if pinned:
|
|
126
|
+
if _INT.match(pinned):
|
|
127
|
+
return Budget(int(pinned), t("budget_note_fixed", "fixed"))
|
|
128
|
+
return Budget(
|
|
129
|
+
FLOOR, t("budget_note_bad_step", "bad AICP_STEP_TIMEOUT — using floor")
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
knobs = {name: _int_env(name) for name in _DEFAULTS}
|
|
133
|
+
if any(value is None for value in knobs.values()):
|
|
134
|
+
return Budget(
|
|
135
|
+
FLOOR, t("budget_note_bad_env", "bad AICP_TIMEOUT_* value — using floor")
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
files, lines = _pending(cwd)
|
|
139
|
+
formula = (
|
|
140
|
+
knobs["AICP_TIMEOUT_BASE"]
|
|
141
|
+
+ knobs["AICP_TIMEOUT_PER_FILE"] * files
|
|
142
|
+
+ knobs["AICP_TIMEOUT_PER_100L"] * lines // 100
|
|
143
|
+
)
|
|
144
|
+
formula = min(formula, knobs["AICP_TIMEOUT_MAX"])
|
|
145
|
+
|
|
146
|
+
history = _history(cli)
|
|
147
|
+
if history > formula:
|
|
148
|
+
return Budget(
|
|
149
|
+
history,
|
|
150
|
+
t(
|
|
151
|
+
"budget_note_history",
|
|
152
|
+
"%s files · %s lines · history %ss",
|
|
153
|
+
files,
|
|
154
|
+
lines,
|
|
155
|
+
history,
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
return Budget(formula, t("budget_note_plain", "%s files · %s lines", files, lines))
|