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