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/cli.py
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
"""Command-line entry point (``aicp = "aicp.cli:main"`` in pyproject.toml).
|
|
2
|
+
|
|
3
|
+
Port of ``aicp()`` and the argument parser at the foot of ``~/scripts/bin/aicp``.
|
|
4
|
+
|
|
5
|
+
This module is the only place the feature modules meet. Everything it needs
|
|
6
|
+
already exists and is tested — the fallback-chain runner, the ``.aicprc``
|
|
7
|
+
layer, the git guards, the secret scanner, the skills probe, the settings
|
|
8
|
+
menu — so the job here is wiring, in the one order that is load-bearing:
|
|
9
|
+
|
|
10
|
+
* **git first, an AI CLI only if git says there is work.** A clean, in-sync
|
|
11
|
+
repo answers both of aicp's questions from git alone and exits 0 with no AI
|
|
12
|
+
CLI installed at all; a blanket preflight at the top would turn that into a
|
|
13
|
+
failure. :func:`aicp.gitflow.precheck` owns that ordering (status, then the
|
|
14
|
+
CLI check, then the expensive tree scan) and is called instead of being
|
|
15
|
+
re-implemented here.
|
|
16
|
+
* **Every figure in the RESULT table is re-read from git**, never taken from a
|
|
17
|
+
CLI's own output — :func:`aicp.gitflow.result_summary` does the re-reading.
|
|
18
|
+
* **The notifier is injected, not imported by its consumer.** ``contracts.py``
|
|
19
|
+
makes this module the designated call site: :func:`aicp.notify.notify` is
|
|
20
|
+
passed into :func:`aicp.runner.run_step` as a parameter, which is what keeps
|
|
21
|
+
the runner testable with no network.
|
|
22
|
+
|
|
23
|
+
It is also the designated **config -> consumer bridge** — see
|
|
24
|
+
:func:`export_settings`.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import contextlib
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
from collections.abc import Sequence
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import NoReturn
|
|
38
|
+
|
|
39
|
+
from . import __version__, config, gitflow, i18n, menu, notify, present, runner, skills
|
|
40
|
+
from ._utils import BLUE, BOLD, CYAN, DIM, GREEN, MAGENTA, RED, RESET, YELLOW, have
|
|
41
|
+
from .contracts import ROSTER
|
|
42
|
+
from .i18n import t
|
|
43
|
+
|
|
44
|
+
__all__ = ["build_parser", "export_settings", "main"]
|
|
45
|
+
|
|
46
|
+
_BY_NAME = {entry.name: entry for entry in ROSTER}
|
|
47
|
+
|
|
48
|
+
#: How many commits each side of the "not in sync" tail prints, on screen and
|
|
49
|
+
#: in the notification — the zsh original's `head -5`.
|
|
50
|
+
_TAIL = 5
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ── the config -> consumer bridge (defect D3) ────────────────────────────────
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def export_settings(settings: config.Settings) -> None:
|
|
57
|
+
"""Put the resolved ``AICP_*`` values into ``os.environ``.
|
|
58
|
+
|
|
59
|
+
``config.resolve()`` validates knobs that **no consumer ever reads from
|
|
60
|
+
it**: :mod:`aicp.budget` and :mod:`aicp.gitflow` read ``os.environ``
|
|
61
|
+
directly, and correctly so — ``contracts.py`` forbids them importing the
|
|
62
|
+
config layer. Without this bridge an ``AICP_TZ`` or ``AICP_TIMEOUT_BASE``
|
|
63
|
+
written in ``.aicprc`` was parsed, validated, reported in ``Settings`` —
|
|
64
|
+
and then silently ignored by the only two modules that act on it.
|
|
65
|
+
|
|
66
|
+
``setdefault`` is the whole precedence rule: a real environment variable is
|
|
67
|
+
already in ``os.environ`` and is therefore left exactly as it was, so
|
|
68
|
+
**environment still beats file**. Only a key the file alone supplied is
|
|
69
|
+
added.
|
|
70
|
+
|
|
71
|
+
``lang`` is bridged separately because its consumer does not read the
|
|
72
|
+
environment per call: :data:`aicp.i18n.LANGUAGE` is resolved once at
|
|
73
|
+
import, which is strictly before this function can run, so exporting
|
|
74
|
+
``AICP_LANG`` alone would leave every run English no matter what the
|
|
75
|
+
``--config`` menu wrote. ``settings.lang`` is already validated against
|
|
76
|
+
the same rule :func:`aicp.i18n._resolve_language` applies, and this is its
|
|
77
|
+
only reader.
|
|
78
|
+
|
|
79
|
+
Safe by construction with respect to :data:`aicp.config.DENYLIST`:
|
|
80
|
+
``settings.values`` is what :func:`aicp.config.load_config` accepted, and
|
|
81
|
+
that loader drops every denied key — ``AICP_TG_SEND`` (reaches ``bash
|
|
82
|
+
"$value"``), ``AICP_TIMING_LOG`` (path-mutating sinks) and ``AICP_CONFIG``
|
|
83
|
+
(would re-point the next ``persist_key`` write) — before they ever reach
|
|
84
|
+
here. So a file can never smuggle an exec-path or path-mutation knob into
|
|
85
|
+
the environment through this door. Whoever adds the next such knob adds it
|
|
86
|
+
to ``DENYLIST``, not to a filter here.
|
|
87
|
+
|
|
88
|
+
Two things this deliberately does NOT close, both stated rather than left
|
|
89
|
+
to be discovered:
|
|
90
|
+
|
|
91
|
+
* ``AICP_SKIP_SECRET_SCAN=1`` in a ``.aicprc`` really does switch that
|
|
92
|
+
run's secret scan off. That is parity with the zsh original (whose
|
|
93
|
+
loader ``typeset -g``s the same key) and is what ``.aicprc.example``
|
|
94
|
+
documents; it is accepted knowingly, not overlooked. Denying it would be
|
|
95
|
+
a behaviour change worth making on purpose, not as a side effect here.
|
|
96
|
+
* ``os.environ`` is inherited by children, where zsh's ``typeset -g`` was
|
|
97
|
+
a shell global that was not exported. Every ``AICP_*`` key a file
|
|
98
|
+
supplies is therefore visible to git and to the AI CLI subprocess. The
|
|
99
|
+
blast radius is bounded by the loader's ``AICP_*`` key allowlist, so no
|
|
100
|
+
unrelated variable can be set this way.
|
|
101
|
+
"""
|
|
102
|
+
for key, value in settings.values.items():
|
|
103
|
+
os.environ.setdefault(key, value)
|
|
104
|
+
i18n.LANGUAGE = settings.lang
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ── small local helpers ──────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _git_out(*args: str) -> str:
|
|
111
|
+
"""``git <args>`` stdout, or "" if git fails — the cwd is the repo."""
|
|
112
|
+
try:
|
|
113
|
+
done = subprocess.run(["git", *args], capture_output=True, text=True, check=False)
|
|
114
|
+
except OSError: # git missing, or cwd gone
|
|
115
|
+
return ""
|
|
116
|
+
return done.stdout.strip() if done.returncode == 0 else ""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _echo(lines: Sequence[str]) -> None:
|
|
120
|
+
for line in lines:
|
|
121
|
+
print(line)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _dim(text: str) -> None:
|
|
125
|
+
print(f"{DIM}{text}{RESET}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _spin(label: str, verbose: bool):
|
|
129
|
+
"""Animate *label* while an otherwise silent step runs.
|
|
130
|
+
|
|
131
|
+
``--verbose`` streams instead, the same rule ``run_step`` follows; and
|
|
132
|
+
:class:`aicp.present.Spinner` disables itself off a TTY, so a non-TTY run
|
|
133
|
+
(this suite, CI, cron) prints exactly what it printed before.
|
|
134
|
+
"""
|
|
135
|
+
return contextlib.nullcontext() if verbose else present.Spinner(label)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _tilde(path: str) -> str:
|
|
139
|
+
home = os.path.expanduser("~")
|
|
140
|
+
return f"~{path[len(home):]}" if home and path.startswith(home) else path
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _log_lines(rev_range: str) -> list[str]:
|
|
144
|
+
return _git_out("log", "--oneline", rev_range).splitlines()[:_TAIL]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _nudge(chain: Sequence[str]) -> None:
|
|
148
|
+
"""One dim, non-blocking line when the CLI about to run has no skills.
|
|
149
|
+
|
|
150
|
+
Hot path: only the CLI that will actually be tried first is probed, and
|
|
151
|
+
:func:`aicp.skills.missing_skills` is existence-only (one ``stat`` per
|
|
152
|
+
target, no reads, no sidecar parsing).
|
|
153
|
+
"""
|
|
154
|
+
for name in chain:
|
|
155
|
+
if not have(name):
|
|
156
|
+
continue
|
|
157
|
+
entry = _BY_NAME.get(name)
|
|
158
|
+
if entry is None:
|
|
159
|
+
return
|
|
160
|
+
missing = skills.missing_skills(entry)
|
|
161
|
+
if missing:
|
|
162
|
+
_dim(
|
|
163
|
+
t(
|
|
164
|
+
"skill_nudge",
|
|
165
|
+
"▸ %s is missing the %s skill — run aicp --config to install it",
|
|
166
|
+
name,
|
|
167
|
+
", ".join(missing),
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ── argument parsing ─────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class _Parser(argparse.ArgumentParser):
|
|
177
|
+
"""argparse's own ``error`` exits 2 and prints its full usage; the zsh
|
|
178
|
+
original named the offending argument and exited 1. Keep that contract —
|
|
179
|
+
it is the one this tool's callers and tests already rely on."""
|
|
180
|
+
|
|
181
|
+
def error(self, message: str) -> NoReturn:
|
|
182
|
+
print(f"aicp: {message}", file=sys.stderr)
|
|
183
|
+
self.print_usage(sys.stderr)
|
|
184
|
+
raise SystemExit(1)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
188
|
+
parser = _Parser(
|
|
189
|
+
prog="aicp",
|
|
190
|
+
# argparse's own furniture ("usage:", "options:", -h's text) stays in
|
|
191
|
+
# English: it is argparse's, not aicp's, and gettext-patching a stdlib
|
|
192
|
+
# module to translate four words is not worth the surprise.
|
|
193
|
+
description=t("help_description", "AI commit + push, with a git-verified result summary."),
|
|
194
|
+
# No abbreviation matching: `--conf` must not silently become
|
|
195
|
+
# `--config` (which runs the settings menu instead of a commit).
|
|
196
|
+
allow_abbrev=False,
|
|
197
|
+
)
|
|
198
|
+
parser.add_argument("--version", action="version", version=f"aicp {__version__}")
|
|
199
|
+
parser.add_argument(
|
|
200
|
+
"-v", "--verbose", action="store_true",
|
|
201
|
+
help=t("help_verbose", "stream each CLI's output live instead of a spinner"),
|
|
202
|
+
)
|
|
203
|
+
parser.add_argument(
|
|
204
|
+
"--undo", action="store_true",
|
|
205
|
+
help=t(
|
|
206
|
+
"help_undo",
|
|
207
|
+
"undo the last commit (git reset --soft HEAD^); refuses once it is "
|
|
208
|
+
"on the remote. Runs no AI CLI",
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
parser.add_argument(
|
|
212
|
+
"--swap-ai", action="store_true",
|
|
213
|
+
help=t("help_swap_ai", "move a CLI to #1 in the fallback chain and save it to config.json"),
|
|
214
|
+
)
|
|
215
|
+
parser.add_argument(
|
|
216
|
+
"--config", action="store_true",
|
|
217
|
+
help=t("help_config", "settings menu: the two steps, the language, the CLI order"),
|
|
218
|
+
)
|
|
219
|
+
parser.add_argument(
|
|
220
|
+
"--doctor", action="store_true",
|
|
221
|
+
help=t("help_doctor", "report where each vendored skill stands for each CLI"),
|
|
222
|
+
)
|
|
223
|
+
parser.add_argument("--json", action="store_true", help=t("help_json", "--doctor as JSON"))
|
|
224
|
+
parser.add_argument(
|
|
225
|
+
"--install-skills", action="store_true",
|
|
226
|
+
help=t("help_install_skills", "install the vendored skills into every configured CLI"),
|
|
227
|
+
)
|
|
228
|
+
parser.add_argument(
|
|
229
|
+
"--yes", action="store_true",
|
|
230
|
+
help=t("help_yes", "--install-skills: actually write (without it, only a preview)"),
|
|
231
|
+
)
|
|
232
|
+
parser.add_argument(
|
|
233
|
+
"--force", action="store_true",
|
|
234
|
+
help=t("help_force", "--install-skills: replace a foreign file, keeping a .bak"),
|
|
235
|
+
)
|
|
236
|
+
return parser
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ── the CI escape hatches ────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _doctor(*, as_json: bool) -> int:
|
|
243
|
+
if as_json:
|
|
244
|
+
print(json.dumps(skills.status_json(), indent=2, ensure_ascii=False))
|
|
245
|
+
return 0
|
|
246
|
+
rows = [(f"{s.cli} · {s.skill}", s.version or s.state) for s in skills.full_status()]
|
|
247
|
+
_echo(present.render(rows, t("skills_title", "SKILLS")))
|
|
248
|
+
return 0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _install_skills(*, yes: bool, force: bool) -> int:
|
|
252
|
+
if not yes:
|
|
253
|
+
rows = [(f"{s.cli} · {s.skill}", s.state) for s in skills.full_status()]
|
|
254
|
+
_echo(present.render(rows, t("skills_title", "SKILLS")))
|
|
255
|
+
_dim(t("install_needs_yes", " re-run with --install-skills --yes to write"))
|
|
256
|
+
return 0
|
|
257
|
+
for result in skills.install(force=force):
|
|
258
|
+
backup = f" (backup: {result.backup})" if result.backup else ""
|
|
259
|
+
print(f" {CYAN}{result.cli}{RESET} · {result.skill}: {result.action}{backup}")
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ── the main flow (port of aicp()) ───────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _run_panel(repo: str, top: str, branch: str, remote: str, chain: Sequence[str]) -> None:
|
|
267
|
+
_echo(
|
|
268
|
+
present.render(
|
|
269
|
+
[
|
|
270
|
+
(
|
|
271
|
+
t("panel_repo", "repo"),
|
|
272
|
+
f"{MAGENTA}{BOLD}{repo}{RESET} {DIM}{_tilde(top)}{RESET}",
|
|
273
|
+
),
|
|
274
|
+
(
|
|
275
|
+
t("panel_branch", "branch"),
|
|
276
|
+
f"{CYAN}{branch}{RESET} {DIM}→{RESET} {CYAN}{remote}/{branch}{RESET}",
|
|
277
|
+
),
|
|
278
|
+
],
|
|
279
|
+
"aicp",
|
|
280
|
+
mode="panel",
|
|
281
|
+
accent=BLUE,
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
_dim(t("active_chain", "▸ chain: %s", " → ".join(chain)))
|
|
285
|
+
print()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _commit_step(
|
|
289
|
+
settings: config.Settings, chain: Sequence[str], before: str, *, verbose: bool
|
|
290
|
+
) -> runner.StepResult | None:
|
|
291
|
+
"""Run /commit when needed and return its handler metadata."""
|
|
292
|
+
if not settings.do_commit:
|
|
293
|
+
_dim(
|
|
294
|
+
t(
|
|
295
|
+
"skip_commit_config",
|
|
296
|
+
"▸ /commit is switched off in config (AICP_DO_COMMIT=0) — skipping",
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
return None
|
|
300
|
+
# Read AFTER the config gate, so a run with /commit switched off pays for
|
|
301
|
+
# no status call at all.
|
|
302
|
+
if not _git_out("status", "--porcelain"):
|
|
303
|
+
_dim(t("skip_commit_clean", "▸ no uncommitted changes — skipping /commit"))
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
_nudge(chain)
|
|
307
|
+
result = runner.run_step("/commit", chain, notify=notify.notify, verbose=verbose)
|
|
308
|
+
if result.rc != 0:
|
|
309
|
+
return result
|
|
310
|
+
|
|
311
|
+
# Shown here, attached to the step that produced it, rather than after the
|
|
312
|
+
# push: the panel answers "what did /commit write?", which is stale by the
|
|
313
|
+
# time the push summary arrives. The RESULT table still counts the whole
|
|
314
|
+
# run, so a commit created by /safe-git-push (a rebase) is not lost.
|
|
315
|
+
rows = gitflow.commit_rows(before, _git_out("rev-parse", "HEAD"))
|
|
316
|
+
if rows:
|
|
317
|
+
print()
|
|
318
|
+
_echo(
|
|
319
|
+
present.render(
|
|
320
|
+
[(t("commit_handler", "Commit handler"), result.winner or "—"), *rows],
|
|
321
|
+
f'{t("commits_panel_title", "NEW COMMITS")} · {gitflow.tz_label()}',
|
|
322
|
+
mode="panel",
|
|
323
|
+
accent=GREEN,
|
|
324
|
+
zebra=True,
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
return result
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _push_step(
|
|
331
|
+
settings: config.Settings,
|
|
332
|
+
chain: Sequence[str],
|
|
333
|
+
remote: str,
|
|
334
|
+
branch: str,
|
|
335
|
+
*,
|
|
336
|
+
verbose: bool,
|
|
337
|
+
) -> int | runner.StepResult | None:
|
|
338
|
+
"""Run /safe-git-push when needed; ``int`` means preflight failed."""
|
|
339
|
+
if not settings.do_push:
|
|
340
|
+
_dim(
|
|
341
|
+
t(
|
|
342
|
+
"skip_push_config",
|
|
343
|
+
"▸ /safe-git-push is switched off in config (AICP_DO_PUSH=0) — skipping",
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
return None
|
|
347
|
+
|
|
348
|
+
# Ask git before asking an AI: only a divergence is worth a CLI call.
|
|
349
|
+
# Behind counts too — that is the rebase/pull case /safe-git-push exists
|
|
350
|
+
# for — so the step is skipped only when both sides are already identical.
|
|
351
|
+
# An unresolvable remote ref is never read as "in sync": it runs the step.
|
|
352
|
+
with _spin(t("spin_fetch", "git fetch %s/%s…", remote, branch), verbose):
|
|
353
|
+
work_to_do = gitflow.diverged(remote, branch)
|
|
354
|
+
|
|
355
|
+
if not work_to_do:
|
|
356
|
+
_dim(
|
|
357
|
+
t(
|
|
358
|
+
"skip_push_synced",
|
|
359
|
+
"▸ %s/%s already in sync — skipping /safe-git-push",
|
|
360
|
+
remote,
|
|
361
|
+
branch,
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
ok, lines = gitflow.preflight(chain)
|
|
367
|
+
if not ok:
|
|
368
|
+
_echo(lines)
|
|
369
|
+
return 1
|
|
370
|
+
_nudge(chain)
|
|
371
|
+
return runner.run_step("/safe-git-push", chain, notify=notify.notify, verbose=verbose)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _report(
|
|
375
|
+
summary: gitflow.ResultSummary,
|
|
376
|
+
repo: str,
|
|
377
|
+
remote: str,
|
|
378
|
+
branch: str,
|
|
379
|
+
*,
|
|
380
|
+
commit_handler: str | None,
|
|
381
|
+
push_handler: str | None,
|
|
382
|
+
) -> int:
|
|
383
|
+
"""Print the git-verified RESULT table and decide the exit code."""
|
|
384
|
+
if summary.fetch_note:
|
|
385
|
+
print(summary.fetch_note)
|
|
386
|
+
_echo(
|
|
387
|
+
present.render(
|
|
388
|
+
[
|
|
389
|
+
*summary.rows(),
|
|
390
|
+
(t("commit_handler", "Commit handler"), commit_handler or "—"),
|
|
391
|
+
(t("push_handler", "Push handler"), push_handler or "—"),
|
|
392
|
+
],
|
|
393
|
+
t("result_title", "RESULT"),
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
if summary.in_sync:
|
|
398
|
+
return 0
|
|
399
|
+
|
|
400
|
+
if not summary.verifiable:
|
|
401
|
+
print()
|
|
402
|
+
print(
|
|
403
|
+
f"{RED}"
|
|
404
|
+
+ t(
|
|
405
|
+
"cannot_verify",
|
|
406
|
+
"cannot verify the push: %s/%s does not resolve.",
|
|
407
|
+
remote,
|
|
408
|
+
branch,
|
|
409
|
+
)
|
|
410
|
+
+ RESET
|
|
411
|
+
)
|
|
412
|
+
_dim(
|
|
413
|
+
t(
|
|
414
|
+
"cannot_verify_note",
|
|
415
|
+
" the remote may be unreachable, or the branch was never pushed.",
|
|
416
|
+
)
|
|
417
|
+
)
|
|
418
|
+
notify.notify(
|
|
419
|
+
t(
|
|
420
|
+
"tg_unverifiable",
|
|
421
|
+
"⚠️ aicp: cannot verify the push of %s on %s —— %s/%s does not resolve "
|
|
422
|
+
"(remote unreachable, or the branch was never pushed). %s new commit(s).",
|
|
423
|
+
branch,
|
|
424
|
+
repo,
|
|
425
|
+
remote,
|
|
426
|
+
branch,
|
|
427
|
+
summary.created,
|
|
428
|
+
)
|
|
429
|
+
)
|
|
430
|
+
return 1
|
|
431
|
+
|
|
432
|
+
local_title = t("local_not_pushed", "local not pushed:")
|
|
433
|
+
remote_title = t("remote_not_merged", "remote not merged:")
|
|
434
|
+
local = _log_lines(f"{remote}/{branch}..HEAD")
|
|
435
|
+
incoming = _log_lines(f"HEAD..{remote}/{branch}")
|
|
436
|
+
|
|
437
|
+
print()
|
|
438
|
+
for title, entries in ((local_title, local), (remote_title, incoming)):
|
|
439
|
+
print(f"{YELLOW}{title}{RESET}")
|
|
440
|
+
for line in entries:
|
|
441
|
+
print(f" {DIM}{line}{RESET}")
|
|
442
|
+
|
|
443
|
+
notify.notify(
|
|
444
|
+
t(
|
|
445
|
+
"tg_not_synced",
|
|
446
|
+
"⚠️ aicp: %s on %s is not in sync with %s/%s",
|
|
447
|
+
branch,
|
|
448
|
+
repo,
|
|
449
|
+
remote,
|
|
450
|
+
branch,
|
|
451
|
+
)
|
|
452
|
+
+ "\n\n"
|
|
453
|
+
+ f'| {t("tg_tbl_item", "Item")} | {t("tg_tbl_value", "Value")} |\n'
|
|
454
|
+
+ "| --- | --- |\n"
|
|
455
|
+
# Same three labels as the RESULT table, so the same msgids: the
|
|
456
|
+
# Telegram row and the terminal row must never read differently.
|
|
457
|
+
+ f'| {t("result_new_commits", "New commits")} | {summary.created} |\n'
|
|
458
|
+
+ f'| {t("result_ahead", "Ahead")} | {summary.ahead} |\n'
|
|
459
|
+
+ f'| {t("result_behind", "Behind")} | {summary.behind} |\n\n'
|
|
460
|
+
+ local_title
|
|
461
|
+
+ "\n"
|
|
462
|
+
+ "\n".join(local)
|
|
463
|
+
+ "\n"
|
|
464
|
+
+ remote_title
|
|
465
|
+
+ "\n"
|
|
466
|
+
+ "\n".join(incoming)
|
|
467
|
+
)
|
|
468
|
+
return 1
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _flow(settings: config.Settings, *, verbose: bool) -> int:
|
|
472
|
+
chain = settings.cli_chain
|
|
473
|
+
excluded: set[str] = set()
|
|
474
|
+
|
|
475
|
+
def active_chain() -> tuple[str, ...]:
|
|
476
|
+
return tuple(cli for cli in chain if cli not in excluded)
|
|
477
|
+
|
|
478
|
+
branch = gitflow.current_branch()
|
|
479
|
+
if branch is None:
|
|
480
|
+
_echo(gitflow.not_on_branch_lines())
|
|
481
|
+
return 1
|
|
482
|
+
remote = gitflow.remote_for(branch)
|
|
483
|
+
top = _git_out("rev-parse", "--show-toplevel")
|
|
484
|
+
repo = Path(top).name if top else ""
|
|
485
|
+
before = _git_out("rev-parse", "HEAD")
|
|
486
|
+
|
|
487
|
+
_run_panel(repo, top, branch, remote, chain)
|
|
488
|
+
|
|
489
|
+
# precheck owns the order that matters (git status, then "is any CLI
|
|
490
|
+
# installed", then the expensive secret scan) and refuses the run outright
|
|
491
|
+
# on a secret hit — no AI CLI is invoked on that path.
|
|
492
|
+
# ponytail: _commit_step re-reads `git status --porcelain` to decide
|
|
493
|
+
# "dirty" — one extra, already-warm call rather than widening precheck's
|
|
494
|
+
# frozen return shape. Revisit only if a repo is big enough to notice.
|
|
495
|
+
with _spin(t("spin_precheck", "checking the working tree…"), verbose):
|
|
496
|
+
proceed, lines = gitflow.precheck(chain, do_commit=settings.do_commit)
|
|
497
|
+
_echo(lines)
|
|
498
|
+
if not proceed:
|
|
499
|
+
return 1
|
|
500
|
+
|
|
501
|
+
commit_result = _commit_step(settings, active_chain(), before, verbose=verbose)
|
|
502
|
+
if commit_result is not None:
|
|
503
|
+
excluded.update(commit_result.quota_clis)
|
|
504
|
+
if commit_result.rc != 0:
|
|
505
|
+
return commit_result.rc
|
|
506
|
+
print()
|
|
507
|
+
|
|
508
|
+
push_result = _push_step(settings, active_chain(), remote, branch, verbose=verbose)
|
|
509
|
+
if isinstance(push_result, int):
|
|
510
|
+
return push_result
|
|
511
|
+
if push_result is not None:
|
|
512
|
+
excluded.update(push_result.quota_clis)
|
|
513
|
+
if push_result.rc == runner.ABORT_RC:
|
|
514
|
+
return push_result.rc
|
|
515
|
+
print()
|
|
516
|
+
|
|
517
|
+
# Trust git, not the CLI: read the real state back before reporting.
|
|
518
|
+
after = _git_out("rev-parse", "HEAD")
|
|
519
|
+
with _spin(t("spin_fetch", "git fetch %s/%s…", remote, branch), verbose):
|
|
520
|
+
summary = gitflow.result_summary(before, after, remote, branch)
|
|
521
|
+
return _report(
|
|
522
|
+
summary,
|
|
523
|
+
repo,
|
|
524
|
+
remote,
|
|
525
|
+
branch,
|
|
526
|
+
commit_handler=commit_result.winner if commit_result is not None else None,
|
|
527
|
+
push_handler=push_result.winner if isinstance(push_result, runner.StepResult) else None,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
# ── entry point ──────────────────────────────────────────────────────────────
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
#: Sub-flag -> the flag it qualifies. A sub-flag on its own is refused rather
|
|
535
|
+
#: than ignored: `aicp --json` silently running a full commit and push, because
|
|
536
|
+
#: the user meant `--doctor --json`, is the one misread worth three lines.
|
|
537
|
+
_SUB_FLAGS = {"json": "doctor", "yes": "install_skills", "force": "install_skills"}
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _reject_orphan_sub_flags(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
|
|
541
|
+
for sub, parent in _SUB_FLAGS.items():
|
|
542
|
+
if getattr(args, sub) and not getattr(args, parent):
|
|
543
|
+
parser.error(
|
|
544
|
+
f"--{sub} only applies to --{parent.replace('_', '-')}"
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _dispatch(args: argparse.Namespace) -> int:
|
|
549
|
+
settings = config.resolve()
|
|
550
|
+
export_settings(settings)
|
|
551
|
+
|
|
552
|
+
if args.doctor:
|
|
553
|
+
return _doctor(as_json=args.json)
|
|
554
|
+
if args.install_skills:
|
|
555
|
+
return _install_skills(yes=args.yes, force=args.force)
|
|
556
|
+
if args.config:
|
|
557
|
+
return menu.config_menu() or 0
|
|
558
|
+
if args.swap_ai:
|
|
559
|
+
return menu.swap_ai() or 0
|
|
560
|
+
if args.undo:
|
|
561
|
+
ok, lines = gitflow.undo()
|
|
562
|
+
_echo(lines)
|
|
563
|
+
return 0 if ok else 1
|
|
564
|
+
|
|
565
|
+
# First run: there is nothing to configure from, so ask once rather than
|
|
566
|
+
# committing with defaults the user never saw. Non-interactively the menu
|
|
567
|
+
# reads EOF as "quit", so CI is never blocked by this.
|
|
568
|
+
if not settings.path.exists():
|
|
569
|
+
menu.config_menu()
|
|
570
|
+
settings = config.resolve()
|
|
571
|
+
export_settings(settings)
|
|
572
|
+
|
|
573
|
+
return _flow(settings, verbose=args.verbose)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def main(argv: list[str] | None = None) -> int:
|
|
577
|
+
parser = build_parser()
|
|
578
|
+
try:
|
|
579
|
+
args = parser.parse_args(argv)
|
|
580
|
+
_reject_orphan_sub_flags(parser, args)
|
|
581
|
+
except SystemExit as exc: # --help/--version (0), or a bad flag (1)
|
|
582
|
+
return int(exc.code or 0)
|
|
583
|
+
|
|
584
|
+
try:
|
|
585
|
+
return _dispatch(args)
|
|
586
|
+
except KeyboardInterrupt:
|
|
587
|
+
print(
|
|
588
|
+
f"{YELLOW}⚠{RESET} "
|
|
589
|
+
+ t("interrupted", "interrupted (Ctrl+C) — aborting, no further steps run")
|
|
590
|
+
)
|
|
591
|
+
return runner.ABORT_RC
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
if __name__ == "__main__":
|
|
595
|
+
raise SystemExit(main())
|