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/config.py
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""The hardened ``~/.aicp/config.json`` layer: a config file is DATA, and
|
|
2
|
+
nothing else.
|
|
3
|
+
|
|
4
|
+
Ported from ``_aicp_load_config`` / ``_aicp_persist_key`` in
|
|
5
|
+
``~/scripts/bin/aicp``, then migrated from ``~/.aicprc`` (KEY=value text) to
|
|
6
|
+
``~/.aicp/config.json`` — a JSON object, written atomically and owner-only —
|
|
7
|
+
matching the storage mechanism the sibling ``ai-accounts`` project uses for
|
|
8
|
+
its own ``~/.ai-accounts/config.json``. Every security control below predates
|
|
9
|
+
that move and is unchanged by it: only the file's serialization envelope
|
|
10
|
+
changed, not what a value is allowed to do. A legacy ``~/.aicprc`` is folded
|
|
11
|
+
in once (see :func:`_maybe_migrate`) and left in place, never deleted.
|
|
12
|
+
|
|
13
|
+
That loader's header comment is the design record; what follows is the short
|
|
14
|
+
version of why each control exists, because every one of them was added in
|
|
15
|
+
response to a hole that was verified live, not imagined.
|
|
16
|
+
|
|
17
|
+
**Parsed as data, never sourced or eval'd.** ``source``/``eval`` on a config
|
|
18
|
+
file runs arbitrary code, and a config file is exactly the kind of file that
|
|
19
|
+
arrives synced from someone else's dotfiles repo. Only ``$HOME/.aicp/config.json``
|
|
20
|
+
(or ``AICP_CONFIG``) is read; no repo-local file is ever consulted, on
|
|
21
|
+
purpose — merely running ``aicp`` inside someone else's clone must never
|
|
22
|
+
execute config lines they wrote.
|
|
23
|
+
|
|
24
|
+
"Never eval'd" is necessary but is NOT sufficient on its own: it stops a
|
|
25
|
+
value from running as syntax, and says nothing about a value that is itself
|
|
26
|
+
later used AS a command or a path. Hence three more layers:
|
|
27
|
+
|
|
28
|
+
1. **Key allowlist** — only ``AICP_[A-Z0-9_]*`` (case-insensitively: on disk
|
|
29
|
+
a key is written ``aicp_do_commit``, matching the environment's
|
|
30
|
+
``AICP_DO_COMMIT`` only once case-folded on read — see
|
|
31
|
+
:func:`_read_json_object`). A line naming ``PATH`` is inert text to this
|
|
32
|
+
loader, not an assignment.
|
|
33
|
+
2. **Value charset allowlist** — letters, digits and
|
|
34
|
+
``/ . _ : @ + -`` plus whitespace. ``=`` and ``,`` are excluded because in
|
|
35
|
+
the zsh original a value like ``PATH=0`` reaching an arithmetic context
|
|
36
|
+
would assign into the real ``$PATH``; ``$``/backtick/``;``/``|``/parens
|
|
37
|
+
are excluded as defence in depth for whatever the next consumer of these
|
|
38
|
+
values does with them.
|
|
39
|
+
3. **Denylist** (:data:`DENYLIST`) — ``AICP_TG_SEND`` (reaches
|
|
40
|
+
``bash "$value"``), ``AICP_TIMING_LOG`` (reaches ``mkdir -p``, ``>>``,
|
|
41
|
+
``mv -f``, ``rm -f``) and ``AICP_CONFIG`` (names the file this loader
|
|
42
|
+
reads, and the file :func:`persist_key` then ``mkdir -p``s and atomically
|
|
43
|
+
replaces) are ENVIRONMENT-VARIABLE ONLY. All three are plain literal paths
|
|
44
|
+
that sail through the charset allowlist, and all three were real holes.
|
|
45
|
+
Whoever adds the next knob that flows into an exec path or a path-mutating
|
|
46
|
+
sink adds its name here — the charset allowlist does not protect against
|
|
47
|
+
this class at all.
|
|
48
|
+
|
|
49
|
+
``AICP_CONFIG`` is the subtlest of the three, because a file naming
|
|
50
|
+
*itself* looks inert: nothing in this module acts on the value. The zsh
|
|
51
|
+
original is immune by accident of ordering — ``: "${AICP_CONFIG:=...}"``
|
|
52
|
+
runs before its loader, so the loader's "already set, environment wins"
|
|
53
|
+
check always skips a file-supplied copy. This port has no such ordering
|
|
54
|
+
guarantee once a caller (``cli.export_settings``) puts accepted values
|
|
55
|
+
back into ``os.environ``: the NEXT ``config_path()`` would resolve to the
|
|
56
|
+
file's chosen path, and the next ``persist_key`` — an ordinary
|
|
57
|
+
``--config`` menu write — would create directories and atomically replace
|
|
58
|
+
a file the user never named. A ``.aicprc`` arriving from someone else's
|
|
59
|
+
dotfiles repo is exactly the delivery mechanism this loader was hardened
|
|
60
|
+
against, so the key is denied at the source rather than filtered at each
|
|
61
|
+
consumer.
|
|
62
|
+
|
|
63
|
+
``AICP_TIMEOUT_BIN`` is the fourth case and is deliberately NOT on the
|
|
64
|
+
denylist: it is never taken from configuration in the first place. See
|
|
65
|
+
:func:`timeout_bin` — it is resolved unconditionally from ``PATH``, so a
|
|
66
|
+
config-supplied decoy binary can never be invoked. Making that resolution
|
|
67
|
+
conditional (``value or which(...)``) would reopen the hole and requires
|
|
68
|
+
adding the key to :data:`DENYLIST` first.
|
|
69
|
+
|
|
70
|
+
Precedence everywhere, highest to lowest: **environment > .aicprc >
|
|
71
|
+
hardcoded default**. Every invalid value announces itself on stderr and
|
|
72
|
+
falls back to a safe default; nothing here ever aborts the run it is only
|
|
73
|
+
supposed to configure.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
from __future__ import annotations
|
|
77
|
+
|
|
78
|
+
import json
|
|
79
|
+
import os
|
|
80
|
+
import re
|
|
81
|
+
import shutil
|
|
82
|
+
import sys
|
|
83
|
+
from collections.abc import Mapping, Sequence
|
|
84
|
+
from dataclasses import dataclass, field
|
|
85
|
+
from pathlib import Path
|
|
86
|
+
|
|
87
|
+
from .contracts import ROSTER
|
|
88
|
+
|
|
89
|
+
#: The pre-JSON config file this loader migrates from, once, on first read of
|
|
90
|
+
#: the default (non-``AICP_CONFIG``-overridden) location. Never written back
|
|
91
|
+
#: to and never deleted — see :func:`_maybe_migrate`.
|
|
92
|
+
_LEGACY_NAME = ".aicprc"
|
|
93
|
+
|
|
94
|
+
__all__ = [
|
|
95
|
+
"DENYLIST",
|
|
96
|
+
"Settings",
|
|
97
|
+
"config_path",
|
|
98
|
+
"load_config",
|
|
99
|
+
"persist_key",
|
|
100
|
+
"resolve",
|
|
101
|
+
"resolve_cli_chain",
|
|
102
|
+
"timeout_bin",
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
#: Exec-path and path-mutation knobs: settable from the real environment (a
|
|
106
|
+
#: boundary the user controls directly), never from a file that can arrive
|
|
107
|
+
#: synced from someone else's dotfiles repo. ``AICP_CONFIG`` belongs here for
|
|
108
|
+
#: the reason spelled out in this module's docstring — a file must not be able
|
|
109
|
+
#: to rename the file the next write lands on.
|
|
110
|
+
DENYLIST = frozenset({"AICP_TG_SEND", "AICP_TIMING_LOG", "AICP_CONFIG"})
|
|
111
|
+
|
|
112
|
+
#: Knobs resolved from the system, never from configuration — see
|
|
113
|
+
#: :func:`timeout_bin`. Kept as a set so the reason is greppable from both
|
|
114
|
+
#: ends.
|
|
115
|
+
_SYSTEM_RESOLVED = frozenset({"AICP_TIMEOUT_BIN"})
|
|
116
|
+
|
|
117
|
+
_KEY_RE = re.compile(r"^AICP_[A-Z0-9_]*$")
|
|
118
|
+
_VALUE_RE = re.compile(r"^[A-Za-z0-9/._:@+\s-]*$")
|
|
119
|
+
# An IANA zone name ("Asia/Taipei", "Etc/UTC", "UTC") and nothing that could
|
|
120
|
+
# also be read as a path: no leading ":", no leading "/", no "..".
|
|
121
|
+
_TZ_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z0-9_+-]+)*$")
|
|
122
|
+
|
|
123
|
+
_DEFAULT_TZ = "Asia/Taipei"
|
|
124
|
+
_LANGUAGES = ("en", "zh-TW")
|
|
125
|
+
_ROSTER_NAMES: tuple[str, ...] = tuple(c.name for c in ROSTER)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _warn(message: str) -> None:
|
|
129
|
+
print(f"aicp: {message}", file=sys.stderr)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class Settings:
|
|
134
|
+
"""Every knob this layer owns, already validated.
|
|
135
|
+
|
|
136
|
+
``values`` carries the raw accepted ``AICP_*`` strings (environment over
|
|
137
|
+
file) for knobs this module does not itself validate — the timeout
|
|
138
|
+
family, which T2's budget calculator validates as plain non-negative
|
|
139
|
+
integers right before it does arithmetic on them. A knob missing from
|
|
140
|
+
``values`` is a knob whose consumer applies its own hardcoded default.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
path: Path
|
|
144
|
+
do_commit: bool = True
|
|
145
|
+
do_push: bool = True
|
|
146
|
+
lang: str = "en"
|
|
147
|
+
tz: str = _DEFAULT_TZ
|
|
148
|
+
cli_chain: tuple[str, ...] = _ROSTER_NAMES
|
|
149
|
+
values: Mapping[str, str] = field(default_factory=dict)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def config_path(env: Mapping[str, str] | None = None) -> Path:
|
|
153
|
+
"""``AICP_CONFIG`` if set, else ``~/.aicp/config.json``. The only file
|
|
154
|
+
ever read (a legacy ``~/.aicprc`` is migrated in once — see
|
|
155
|
+
:func:`_maybe_migrate` — never read directly by this function)."""
|
|
156
|
+
env = os.environ if env is None else env
|
|
157
|
+
override = env.get("AICP_CONFIG")
|
|
158
|
+
return Path(override) if override else Path(os.path.expanduser("~")) / ".aicp" / "config.json"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _accept(key: str, value: str) -> str | None:
|
|
162
|
+
"""*value* if *key* legitimately supplies it, else ``None``.
|
|
163
|
+
|
|
164
|
+
Shared by the JSON reader and the legacy line-parser: the key allowlist,
|
|
165
|
+
denylist, system-resolved exclusion and value charset allowlist are one
|
|
166
|
+
rule set regardless of which file format supplied the candidate pair.
|
|
167
|
+
"""
|
|
168
|
+
if not _KEY_RE.match(key):
|
|
169
|
+
return None
|
|
170
|
+
if key in DENYLIST:
|
|
171
|
+
_warn(
|
|
172
|
+
f"config.json: ignoring {key} (exec-path/path-mutation knob, "
|
|
173
|
+
"environment-variable only)"
|
|
174
|
+
)
|
|
175
|
+
return None
|
|
176
|
+
if key in _SYSTEM_RESOLVED:
|
|
177
|
+
_warn(f"config.json: ignoring {key} (resolved from PATH, never from config)")
|
|
178
|
+
return None
|
|
179
|
+
value = value.strip()
|
|
180
|
+
if not _VALUE_RE.match(value):
|
|
181
|
+
return None
|
|
182
|
+
return value
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _read_json_object(path: Path) -> dict:
|
|
186
|
+
"""*path* parsed as a JSON object — ``{}`` when absent, unreadable, not
|
|
187
|
+
valid JSON, or not an object at the top level.
|
|
188
|
+
|
|
189
|
+
Keys are upper-cased on the way in: on disk (and in whatever a user
|
|
190
|
+
hand-edits) a key is ``aicp_do_commit``, but every other rule in this
|
|
191
|
+
module — :data:`DENYLIST`, :data:`_SYSTEM_RESOLVED`, :data:`_KEY_RE`, the
|
|
192
|
+
environment lookup in :func:`resolve` — is written once, in the
|
|
193
|
+
``AICP_DO_COMMIT`` form shared with the environment. Case-folding here,
|
|
194
|
+
at the one place a JSON object turns into a plain dict, means the rest of
|
|
195
|
+
the module never has to know the file's on-disk casing differs from the
|
|
196
|
+
environment's.
|
|
197
|
+
"""
|
|
198
|
+
try:
|
|
199
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
200
|
+
except OSError:
|
|
201
|
+
return {}
|
|
202
|
+
try:
|
|
203
|
+
data = json.loads(text)
|
|
204
|
+
except ValueError:
|
|
205
|
+
return {}
|
|
206
|
+
if not isinstance(data, dict):
|
|
207
|
+
return {}
|
|
208
|
+
return {key.upper(): value for key, value in data.items()}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _write_json_private(path: Path, data: Mapping[str, str]) -> bool:
|
|
212
|
+
"""Atomically overwrite *path* with *data* as owner-only (0600) JSON.
|
|
213
|
+
|
|
214
|
+
Keys are lower-cased on the way out — ``AICP_DO_COMMIT`` (the form every
|
|
215
|
+
caller passes in, matching the environment) is written as
|
|
216
|
+
``aicp_do_commit``. This is the only place that happens, so every write
|
|
217
|
+
path (:func:`persist_key`, the one-time :func:`_maybe_migrate`) gets the
|
|
218
|
+
lower_case convention for free, including for keys this version has never
|
|
219
|
+
heard of.
|
|
220
|
+
|
|
221
|
+
Created 0600 up front rather than chmod'ed afterwards, so the file is
|
|
222
|
+
never briefly readable by another local user, and swapped in with
|
|
223
|
+
``os.replace`` so a crash mid-write cannot truncate the previous
|
|
224
|
+
contents. Returns ``False`` (never raises) on any ``OSError`` — the
|
|
225
|
+
callers are a settings menu and a best-effort migration, neither of
|
|
226
|
+
which may crash the run over a failed write.
|
|
227
|
+
"""
|
|
228
|
+
try:
|
|
229
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
tmp = path.with_name(f".{path.name}.tmp.{os.getpid()}")
|
|
231
|
+
lowered = {key.lower(): value for key, value in data.items()}
|
|
232
|
+
text = json.dumps(lowered, indent=2, sort_keys=True) + "\n"
|
|
233
|
+
try:
|
|
234
|
+
with os.fdopen(
|
|
235
|
+
os.open(tmp, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600),
|
|
236
|
+
"w",
|
|
237
|
+
encoding="utf-8",
|
|
238
|
+
) as handle:
|
|
239
|
+
handle.write(text)
|
|
240
|
+
os.replace(tmp, path)
|
|
241
|
+
except BaseException:
|
|
242
|
+
tmp.unlink(missing_ok=True)
|
|
243
|
+
raise
|
|
244
|
+
return True
|
|
245
|
+
except OSError:
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _load_legacy_lines(path: Path) -> dict[str, str]:
|
|
250
|
+
"""Parse a pre-JSON ``.aicprc`` (KEY=value text) the same way this loader
|
|
251
|
+
always has — used only by :func:`_maybe_migrate`, once."""
|
|
252
|
+
try:
|
|
253
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
254
|
+
except OSError:
|
|
255
|
+
return {}
|
|
256
|
+
|
|
257
|
+
values: dict[str, str] = {}
|
|
258
|
+
for raw in text.splitlines():
|
|
259
|
+
line = raw.strip()
|
|
260
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
261
|
+
continue
|
|
262
|
+
key, _, value = line.partition("=")
|
|
263
|
+
key = key.rstrip()
|
|
264
|
+
value = value.strip()
|
|
265
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
266
|
+
value = value[1:-1]
|
|
267
|
+
accepted = _accept(key, value)
|
|
268
|
+
if accepted is not None:
|
|
269
|
+
values[key] = accepted
|
|
270
|
+
return values
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _maybe_migrate(path: Path) -> None:
|
|
274
|
+
"""Fold a legacy ``~/.aicprc`` into *path* once, iff *path* is the true
|
|
275
|
+
default location (no ``AICP_CONFIG`` override) and does not exist yet.
|
|
276
|
+
|
|
277
|
+
The legacy file is left in place untouched — this is a one-time copy,
|
|
278
|
+
never a move, so a config synced across machines via the old path keeps
|
|
279
|
+
working on whichever one hasn't migrated yet.
|
|
280
|
+
"""
|
|
281
|
+
if path.exists():
|
|
282
|
+
return
|
|
283
|
+
legacy = Path(os.path.expanduser("~")) / _LEGACY_NAME
|
|
284
|
+
if not legacy.exists():
|
|
285
|
+
return
|
|
286
|
+
migrated = _load_legacy_lines(legacy)
|
|
287
|
+
if _write_json_private(path, migrated):
|
|
288
|
+
_warn(f"migrated {legacy} to {path} (original left in place)")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def load_config(path: Path | str | None = None) -> dict[str, str]:
|
|
292
|
+
"""Parse *path* into the ``AICP_*`` values it legitimately supplies.
|
|
293
|
+
|
|
294
|
+
The file half of :func:`resolve` — no environment is consulted here, and
|
|
295
|
+
no migration is attempted (that is :func:`resolve`'s job, since it alone
|
|
296
|
+
knows whether ``AICP_CONFIG`` was overridden). Non-string JSON values,
|
|
297
|
+
non-``AICP_`` keys, denied keys and values outside the charset allowlist
|
|
298
|
+
are all skipped individually: one bad key never costs the rest of the
|
|
299
|
+
file.
|
|
300
|
+
"""
|
|
301
|
+
path = config_path() if path is None else Path(path)
|
|
302
|
+
raw = _read_json_object(path)
|
|
303
|
+
values: dict[str, str] = {}
|
|
304
|
+
for key, value in raw.items():
|
|
305
|
+
if not isinstance(value, str):
|
|
306
|
+
continue
|
|
307
|
+
accepted = _accept(key, value)
|
|
308
|
+
if accepted is not None:
|
|
309
|
+
values[key] = accepted
|
|
310
|
+
return values
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _boolean(key: str, raw: str | None) -> bool:
|
|
314
|
+
"""``0``/``1`` only. Deliberately not a truthiness test: a hand-written
|
|
315
|
+
``false`` or ``no`` means OFF to whoever typed it, and reading every one
|
|
316
|
+
of them as ON is the worse misread. Anything else is junk, is said out
|
|
317
|
+
loud, and falls back to ON — a skipped push that happens is recoverable,
|
|
318
|
+
one that silently did not is not."""
|
|
319
|
+
if raw is None:
|
|
320
|
+
return True
|
|
321
|
+
if raw in ("0", "1"):
|
|
322
|
+
return raw == "1"
|
|
323
|
+
_warn(f"ignoring {key}={raw} (expected 0 or 1) — using 1")
|
|
324
|
+
return True
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _language(raw: str | None) -> str:
|
|
328
|
+
if raw is None or raw in _LANGUAGES:
|
|
329
|
+
return raw or "en"
|
|
330
|
+
_warn(f"ignoring AICP_LANG={raw} (expected en or zh-TW) — using en")
|
|
331
|
+
return "en"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _timezone(raw: str | None) -> str:
|
|
335
|
+
if raw is None:
|
|
336
|
+
return _DEFAULT_TZ
|
|
337
|
+
if _TZ_RE.match(raw):
|
|
338
|
+
return raw
|
|
339
|
+
_warn(
|
|
340
|
+
f"ignoring AICP_TZ={raw} (not an IANA zone name like Asia/Taipei) "
|
|
341
|
+
f"— using {_DEFAULT_TZ}"
|
|
342
|
+
)
|
|
343
|
+
return _DEFAULT_TZ
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def resolve_cli_chain(order: str | None) -> Sequence[str]:
|
|
347
|
+
"""Resolve *order* (an ``AICP_CLI_ORDER`` string) into the fallback chain.
|
|
348
|
+
|
|
349
|
+
**This is the contract T2 and T4 consume** (see ``contracts.py``): a
|
|
350
|
+
plain ``Sequence[str]`` of roster binary names, passed to them as an
|
|
351
|
+
explicit argument. Neither ever reaches into a config object for it, and
|
|
352
|
+
neither hardcodes :data:`~aicp.contracts.ROSTER` itself.
|
|
353
|
+
|
|
354
|
+
A SUBSET is accepted on purpose, with the roster names it leaves out
|
|
355
|
+
appended behind it in roster order: the roster grows over time, and a
|
|
356
|
+
strict "must be an exact permutation" rule would silently invalidate
|
|
357
|
+
every ``.aicprc`` already written every time it does. Listing a prefix is
|
|
358
|
+
also the honest way to say "these first, then whatever else you know
|
|
359
|
+
about". An unknown or repeated name is refused outright — a typo must
|
|
360
|
+
never silently narrow the chain.
|
|
361
|
+
"""
|
|
362
|
+
if not order or not order.strip():
|
|
363
|
+
return _ROSTER_NAMES
|
|
364
|
+
listed = order.split()
|
|
365
|
+
unknown = [name for name in listed if name not in _ROSTER_NAMES]
|
|
366
|
+
if unknown or len(set(listed)) != len(listed):
|
|
367
|
+
_warn(
|
|
368
|
+
f"ignoring AICP_CLI_ORDER={order} (unknown or repeated CLI name; "
|
|
369
|
+
f"known: {' '.join(_ROSTER_NAMES)}) — using default order"
|
|
370
|
+
)
|
|
371
|
+
return _ROSTER_NAMES
|
|
372
|
+
return (*listed, *(name for name in _ROSTER_NAMES if name not in listed))
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def resolve(env: Mapping[str, str] | None = None) -> Settings:
|
|
376
|
+
"""Environment over ``config.json`` over hardcoded defaults, all validated."""
|
|
377
|
+
env = os.environ if env is None else env
|
|
378
|
+
path = config_path(env)
|
|
379
|
+
if "AICP_CONFIG" not in env:
|
|
380
|
+
_maybe_migrate(path)
|
|
381
|
+
values = load_config(path)
|
|
382
|
+
# The environment always wins, key by key — a file can fill a gap, never
|
|
383
|
+
# overwrite something the user exported for this one run. The one
|
|
384
|
+
# exception is _SYSTEM_RESOLVED: the zsh original clobbers
|
|
385
|
+
# AICP_TIMEOUT_BIN unconditionally, from BOTH sources, so it never
|
|
386
|
+
# appears here at all and no consumer can accidentally prefer it over
|
|
387
|
+
# timeout_bin()'s PATH lookup.
|
|
388
|
+
for key, value in env.items():
|
|
389
|
+
if _KEY_RE.match(key) and key not in _SYSTEM_RESOLVED:
|
|
390
|
+
values[key] = value
|
|
391
|
+
for key in _SYSTEM_RESOLVED:
|
|
392
|
+
values.pop(key, None)
|
|
393
|
+
|
|
394
|
+
return Settings(
|
|
395
|
+
path=path,
|
|
396
|
+
do_commit=_boolean("AICP_DO_COMMIT", values.get("AICP_DO_COMMIT")),
|
|
397
|
+
do_push=_boolean("AICP_DO_PUSH", values.get("AICP_DO_PUSH")),
|
|
398
|
+
lang=_language(values.get("AICP_LANG")),
|
|
399
|
+
tz=_timezone(values.get("AICP_TZ")),
|
|
400
|
+
cli_chain=tuple(resolve_cli_chain(values.get("AICP_CLI_ORDER"))),
|
|
401
|
+
values=values,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def timeout_bin() -> str | None:
|
|
406
|
+
"""The per-CLI-call timeout wrapper, resolved from ``PATH`` ONLY.
|
|
407
|
+
|
|
408
|
+
Prefers GNU coreutils ``timeout``, falls back to Homebrew's ``gtimeout``,
|
|
409
|
+
and returns ``None`` when neither is installed (a hang just isn't caught
|
|
410
|
+
on that machine). Reading no configuration at all IS this knob's security
|
|
411
|
+
boundary: the resolved value is later invoked as a command, so a
|
|
412
|
+
``.aicprc``-supplied path here would be arbitrary code execution once per
|
|
413
|
+
CLI step. Do not make this conditional on a configured value without
|
|
414
|
+
first adding ``AICP_TIMEOUT_BIN`` to :data:`DENYLIST`.
|
|
415
|
+
"""
|
|
416
|
+
return shutil.which("timeout") or shutil.which("gtimeout")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def persist_key(key: str, value: str, path: Path | str | None = None) -> bool:
|
|
420
|
+
"""Merge ``{key: value}`` into *path*'s JSON object. False on failure.
|
|
421
|
+
|
|
422
|
+
Every other key already in the file is kept, so a knob this version has
|
|
423
|
+
never heard of survives a write from the settings menu — the same
|
|
424
|
+
forward-compatibility promise the old KEY=value writer made, just without
|
|
425
|
+
the comment lines JSON has no way to represent. Written atomically and
|
|
426
|
+
owner-only via :func:`_write_json_private`.
|
|
427
|
+
|
|
428
|
+
Returns a bool rather than raising: the caller is an interactive menu
|
|
429
|
+
that must report a failed write and keep running.
|
|
430
|
+
"""
|
|
431
|
+
path = config_path() if path is None else Path(path)
|
|
432
|
+
data = _read_json_object(path)
|
|
433
|
+
data[key] = value
|
|
434
|
+
return _write_json_private(path, data)
|
aicp/contracts.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""aicp's FROZEN shared interface — the contract T2-T5 build against.
|
|
2
|
+
|
|
3
|
+
This module is scaffolded once (by the task that ports the presentation layer
|
|
4
|
+
and test harness) and then frozen: four downstream tasks start in parallel
|
|
5
|
+
immediately afterward and cannot ask its author anything, so every shape here
|
|
6
|
+
is deliberate and every consumer is named. Changing a name or shape here
|
|
7
|
+
after that point is a breaking change to all four.
|
|
8
|
+
|
|
9
|
+
Ported from the zsh original at ``~/scripts/bin/aicp`` (see
|
|
10
|
+
``AICP_CLI_ROSTER`` there for the roster this mirrors) and its sibling
|
|
11
|
+
``~/scripts/test_aicp.sh`` (``ALL_CLIS``) for the fallback-chain order.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"CLI",
|
|
22
|
+
"ROSTER",
|
|
23
|
+
"SKILL_VERSION_MARKER",
|
|
24
|
+
"SKILL_VERSION_SUFFIX",
|
|
25
|
+
"NotifyFn",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class CLI:
|
|
31
|
+
"""One entry in aicp's fallback chain of AI CLIs.
|
|
32
|
+
|
|
33
|
+
Consumer: T2 (the runner that invokes each CLI in turn) and T3 (whatever
|
|
34
|
+
resolves/persists the active chain order, e.g. ``--swap-ai``/``--config``
|
|
35
|
+
equivalents) both key off ``name``; T2 additionally needs ``config_dir``
|
|
36
|
+
to detect whether a CLI is configured at all before trying to run it.
|
|
37
|
+
|
|
38
|
+
``name`` is the literal binary invoked on PATH (``shutil.which(name)``).
|
|
39
|
+
``config_dir`` is where that CLI keeps its own config/session state — and
|
|
40
|
+
is deliberately a SEPARATE field, not derived from ``name``, because it
|
|
41
|
+
does not always match: the ``agy`` binary (this project's wrapper name
|
|
42
|
+
for Google's Gemini CLI) reads its configuration from ``~/.gemini``, NOT
|
|
43
|
+
from ``~/.agy`` (which does not exist on disk at all). Never assume
|
|
44
|
+
``config_dir == Path.home() / f".{name}"`` — always read the field.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
name: str
|
|
48
|
+
config_dir: Path
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Default fallback order, based on AICP_CLI_ROSTER in the current
|
|
52
|
+
# ~/scripts/bin/aicp (copilot agy codex claude vibe), with Grok appended for
|
|
53
|
+
# saved-chain compatibility. T3 is the consumer that reorders/persists a
|
|
54
|
+
# chain derived from this; T2 and T4 must never hardcode this tuple themselves
|
|
55
|
+
# — import it.
|
|
56
|
+
ROSTER: tuple[CLI, ...] = (
|
|
57
|
+
CLI(name="copilot", config_dir=Path.home() / ".copilot"),
|
|
58
|
+
CLI(name="agy", config_dir=Path.home() / ".gemini"), # the trap — see CLI's docstring
|
|
59
|
+
CLI(name="codex", config_dir=Path.home() / ".codex"),
|
|
60
|
+
CLI(name="claude", config_dir=Path.home() / ".claude"),
|
|
61
|
+
CLI(name="vibe", config_dir=Path.home() / ".vibe"),
|
|
62
|
+
CLI(name="grok", config_dir=Path.home() / ".grok"),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# The resolved fallback chain — whatever order a run will actually try its
|
|
66
|
+
# CLIs in — is passed between modules as a plain Sequence[str] of binary
|
|
67
|
+
# names (a subset/permutation of {c.name for c in ROSTER}), e.g.
|
|
68
|
+
# ("copilot", "agy", "codex", "claude", "vibe") or a user-reordered subset.
|
|
69
|
+
#
|
|
70
|
+
# T3 PRODUCES it — resolves AICP_CLI_ORDER / .aicprc / --swap-ai state
|
|
71
|
+
# against ROSTER into this concrete Sequence[str].
|
|
72
|
+
# T2 CONSUMES it — the runner's fallback loop iterates it as a plain
|
|
73
|
+
# parameter (e.g. `def run(chain: Sequence[str], ...)`).
|
|
74
|
+
# T4 CONSUMES it — anything reporting/naming "which CLI is active" (e.g.
|
|
75
|
+
# a "chain: copilot -> agy -> ..." status line) takes it
|
|
76
|
+
# as a parameter too.
|
|
77
|
+
#
|
|
78
|
+
# Never reach into a config object for it (no `config.cli_chain`, no
|
|
79
|
+
# `settings.chain`) — every consumer takes it as an explicit argument, so it
|
|
80
|
+
# can be constructed fresh in a test with no config machinery at all.
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Injected notifier: whatever "tell the user" mechanism a run wants to use
|
|
84
|
+
# (Telegram today, something else later) is passed in as a plain callable,
|
|
85
|
+
# never imported directly.
|
|
86
|
+
#
|
|
87
|
+
# Consumer: T2's runner (and anything else that needs to notify mid-run, e.g.
|
|
88
|
+
# on a per-CLI timeout) accepts `notify: NotifyFn = _noop` as a FUNCTION
|
|
89
|
+
# PARAMETER with a no-op default — it must never `import aicp.notify` (T4's
|
|
90
|
+
# module) itself. T4 owns the real implementation (Telegram send, or the
|
|
91
|
+
# printed-fallback path when that's unavailable) and passes it in at the call
|
|
92
|
+
# site (e.g. the CLI entry point in cli.py wires T4's notifier into T2's
|
|
93
|
+
# runner). This keeps T2 testable with zero network/subprocess dependencies
|
|
94
|
+
# and keeps T2 and T4 decoupled in both directions.
|
|
95
|
+
NotifyFn = Callable[[str], None]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ── installed-skill bookkeeping ──────────────────────────────────────────────
|
|
99
|
+
#
|
|
100
|
+
# aicp records what it installed in ONE file of its own, and writes nothing
|
|
101
|
+
# else anywhere near a CLI's config dir:
|
|
102
|
+
#
|
|
103
|
+
# $HOME/.aicp/state.json
|
|
104
|
+
# {
|
|
105
|
+
# "version": 1,
|
|
106
|
+
# "skills": {
|
|
107
|
+
# "<absolute install target>": {
|
|
108
|
+
# "version": "<aicp.__version__ at install time>",
|
|
109
|
+
# "files": {"<path relative to the target>": "<sha256 hex>", ...}
|
|
110
|
+
# }
|
|
111
|
+
# }
|
|
112
|
+
# }
|
|
113
|
+
#
|
|
114
|
+
# A single-file skill records its one file under the key ".". The state file
|
|
115
|
+
# is keyed on $HOME alone — never on GROK_HOME or any per-CLI root — because
|
|
116
|
+
# it is one record per user covering every target across every CLI.
|
|
117
|
+
#
|
|
118
|
+
# Consumer: T5 (the skills installer). Two properties matter, and they are
|
|
119
|
+
# why the record stores HASHES rather than just a version:
|
|
120
|
+
#
|
|
121
|
+
# * "is this ours?" — a target with no record, whose content does not
|
|
122
|
+
# match what this version would write, is the
|
|
123
|
+
# user's. Never overwrite it without force.
|
|
124
|
+
# * "is it still ours?" — a target we DID install, whose recorded files no
|
|
125
|
+
# longer hash to what we wrote, has been edited by
|
|
126
|
+
# hand since. It is treated as the user's too. A
|
|
127
|
+
# version-only marker cannot see this, which is
|
|
128
|
+
# exactly how a hand-edited skill used to get
|
|
129
|
+
# silently displaced on the next version bump.
|
|
130
|
+
#
|
|
131
|
+
# Only recorded files are hashed, so anything the user ADDS alongside them
|
|
132
|
+
# (an extra script, a .DS_Store, a .bak) is not mistaken for a modification.
|
|
133
|
+
STATE_DIR_NAME = ".aicp" # under $HOME
|
|
134
|
+
STATE_FILE_NAME = "state.json"
|
|
135
|
+
STATE_SCHEMA_VERSION = 1
|
|
136
|
+
|
|
137
|
+
# LEGACY — aicp <= 0.1.0 wrote an in-place sidecar next to every installed
|
|
138
|
+
# skill instead of the state file above: "<path>.aicp-version" for a
|
|
139
|
+
# single-file skill, "<dir>/.aicp-version" at a directory skill's own root,
|
|
140
|
+
# containing one line "x-aicp-version: <version>\n". These names survive only
|
|
141
|
+
# so the installer can find such a marker, fold it into state.json, and DELETE
|
|
142
|
+
# it. Nothing writes them any more; do not reintroduce one.
|
|
143
|
+
SKILL_VERSION_SUFFIX = ".aicp-version" # legacy single-file: "<path>" + this suffix
|
|
144
|
+
SKILL_VERSION_MARKER = ".aicp-version" # legacy directory: this name at the dir root
|