@event4u/agent-config 1.41.2 → 2.1.0
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.
- package/.agent-src/commands/fix/{pr-bots.md → pr-bot-comments.md} +3 -3
- package/.agent-src/commands/fix/{pr.md → pr-comments.md} +6 -6
- package/.agent-src/commands/fix/{pr-developers.md → pr-developer-comments.md} +3 -3
- package/.agent-src/commands/fix.md +6 -6
- package/.agent-src/contexts/communication/rules-auto/slash-command-routing-policy-mechanics.md +2 -2
- package/.agent-src/templates/agents/agent-project-settings.example.yml +14 -0
- package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +120 -11
- package/.claude-plugin/marketplace.json +4 -4
- package/CHANGELOG.md +54 -0
- package/README.md +39 -31
- package/config/agent-settings.template.yml +25 -0
- package/docs/architecture.md +47 -1
- package/docs/catalog.md +3 -3
- package/docs/contracts/command-clusters.md +3 -3
- package/docs/contracts/file-ownership-matrix.json +9 -9
- package/docs/customization.md +125 -9
- package/docs/getting-started.md +16 -25
- package/docs/installation.md +66 -82
- package/docs/migration/v1-to-v2.md +98 -0
- package/docs/migrations/commands-1.15.0.md +3 -3
- package/docs/setup/per-ide/claude-code.md +0 -17
- package/docs/setup/per-ide/claude-desktop.md +35 -48
- package/docs/setup/per-ide/windsurf.md +0 -11
- package/docs/skills-catalog.md +23 -2
- package/docs/troubleshooting.md +20 -32
- package/llms.txt +22 -1
- package/package.json +1 -6
- package/scripts/_cli/__init__.py +0 -0
- package/scripts/_cli/cmd_migrate.py +270 -0
- package/scripts/_cli/cmd_update.py +226 -0
- package/scripts/_lib/agent_settings.py +120 -11
- package/scripts/_lib/agents_overlay.py +109 -0
- package/scripts/_lib/pin_resolver.py +152 -0
- package/scripts/_lib/update_check.py +183 -0
- package/scripts/agent-config +73 -1
- package/scripts/check_overlay_cascade_subdirs.py +125 -0
- package/scripts/check_template_pin_drift.py +112 -0
- package/scripts/check_update_banner.py +86 -0
- package/scripts/install +27 -40
- package/scripts/install.py +17 -228
- package/scripts/install.sh +6 -11
- package/templates/agent-config-wrapper.sh +40 -25
- package/templates/consumer-settings/README.md +2 -2
- package/bin/install.php +0 -45
- package/composer.json +0 -33
- package/scripts/postinstall.sh +0 -76
- package/scripts/setup.sh +0 -230
- package/templates/global-install-manifest.yml +0 -91
|
@@ -3,17 +3,24 @@
|
|
|
3
3
|
Phase 1 of road-to-portable-dev-preferences. Single source of truth for
|
|
4
4
|
how scripts read agent settings — replaces ~15 ad-hoc loaders in P3.
|
|
5
5
|
|
|
6
|
-
Resolution order (
|
|
7
|
-
keys only):
|
|
6
|
+
Resolution order (deepest wins; user-global is whitelist-filtered only):
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
N. ``~/.config/agent-config/agent-settings.yml`` (user-global; whitelist only)
|
|
9
|
+
N-1. ``<repo-root>/.agent-settings.yml`` (project-wide; all keys)
|
|
10
|
+
N-2. ``<intermediate-dir>/.agent-settings.yml`` (subsystem-scoped; all keys)
|
|
11
|
+
1. ``<CWD>/.agent-settings.yml`` (deepest, wins; all keys)
|
|
12
|
+
|
|
13
|
+
``<repo-root>`` is the nearest ancestor that contains ``.git`` (directory
|
|
14
|
+
**or** file — submodule support). The walk stops there — it never drifts
|
|
15
|
+
into a parent repo or ``$HOME``. When ``cwd`` is ``None`` (default), the
|
|
16
|
+
loader behaves identically to the pre-cascade contract: project file +
|
|
17
|
+
user-global only, no ancestor walk. Back-compat is hard.
|
|
12
18
|
|
|
13
19
|
Whitelisted keys (``MERGEABLE_KEYS``) are exact dotted paths. A
|
|
14
20
|
non-whitelisted key in the user-global file is silently ignored — the
|
|
15
21
|
``verbose=True`` flag surfaces ignored paths via ``logging.info`` for
|
|
16
|
-
debugging.
|
|
22
|
+
debugging. Non-root in-project layers (intermediate + CWD) are **not**
|
|
23
|
+
whitelist-filtered — they live inside the project boundary.
|
|
17
24
|
|
|
18
25
|
Contract — pure, read-only, tolerant:
|
|
19
26
|
|
|
@@ -28,7 +35,7 @@ from __future__ import annotations
|
|
|
28
35
|
|
|
29
36
|
import logging
|
|
30
37
|
from pathlib import Path
|
|
31
|
-
from typing import Any
|
|
38
|
+
from typing import Any, Iterator
|
|
32
39
|
|
|
33
40
|
logger = logging.getLogger(__name__)
|
|
34
41
|
|
|
@@ -53,10 +60,70 @@ MERGEABLE_KEYS: tuple[str, ...] = (
|
|
|
53
60
|
_DEFAULTS: dict[str, Any] = {}
|
|
54
61
|
|
|
55
62
|
|
|
63
|
+
def find_project_root(start: Path) -> Path | None:
|
|
64
|
+
"""Walk up from ``start`` looking for ``.git`` (file or directory).
|
|
65
|
+
|
|
66
|
+
Returns the first ancestor that contains ``.git`` as a file (submodule
|
|
67
|
+
pointer) or directory (regular checkout), or ``None`` if the walk
|
|
68
|
+
reaches the filesystem root without finding one. The walk stops at
|
|
69
|
+
the project boundary — it never drifts into a parent repo or
|
|
70
|
+
``$HOME``.
|
|
71
|
+
|
|
72
|
+
Pure read-only; never touches the filesystem beyond ``exists()``
|
|
73
|
+
probes on the ``.git`` entry.
|
|
74
|
+
"""
|
|
75
|
+
current = start.resolve() if start.exists() else start
|
|
76
|
+
# ``Path.parents`` excludes ``current`` itself, so probe it first.
|
|
77
|
+
for candidate in [current, *current.parents]:
|
|
78
|
+
git_marker = candidate / ".git"
|
|
79
|
+
if git_marker.exists():
|
|
80
|
+
return candidate
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _resolve_cascade_paths(
|
|
85
|
+
cwd: Path | None,
|
|
86
|
+
project_path: Path | str | None,
|
|
87
|
+
) -> list[Path]:
|
|
88
|
+
"""Return the ordered cascade of in-project settings files (shallow → deep).
|
|
89
|
+
|
|
90
|
+
When ``cwd`` is provided and ``find_project_root(cwd)`` succeeds, the
|
|
91
|
+
list contains every ``<dir>/.agent-settings.yml`` from the repo root
|
|
92
|
+
down to ``cwd`` (inclusive on both ends), shallowest first. When
|
|
93
|
+
``cwd`` is ``None`` or no ``.git`` is reached, falls back to the
|
|
94
|
+
single legacy project path — back-compat with the pre-cascade
|
|
95
|
+
loader.
|
|
96
|
+
"""
|
|
97
|
+
if cwd is None:
|
|
98
|
+
legacy = Path(project_path) if project_path else Path(DEFAULT_PROJECT_FILE)
|
|
99
|
+
return [legacy]
|
|
100
|
+
|
|
101
|
+
root = find_project_root(cwd)
|
|
102
|
+
if root is None:
|
|
103
|
+
legacy = Path(project_path) if project_path else Path(DEFAULT_PROJECT_FILE)
|
|
104
|
+
return [legacy]
|
|
105
|
+
|
|
106
|
+
cwd_resolved = cwd.resolve()
|
|
107
|
+
# Build the chain root → … → cwd (shallowest first, deepest last).
|
|
108
|
+
chain: list[Path] = []
|
|
109
|
+
cursor = cwd_resolved
|
|
110
|
+
while True:
|
|
111
|
+
chain.append(cursor)
|
|
112
|
+
if cursor == root:
|
|
113
|
+
break
|
|
114
|
+
parent = cursor.parent
|
|
115
|
+
if parent == cursor:
|
|
116
|
+
break
|
|
117
|
+
cursor = parent
|
|
118
|
+
chain.reverse()
|
|
119
|
+
return [d / DEFAULT_PROJECT_FILE for d in chain]
|
|
120
|
+
|
|
121
|
+
|
|
56
122
|
def load_agent_settings(
|
|
57
123
|
project_path: Path | str | None = None,
|
|
58
124
|
user_global_path: Path | str | None = None,
|
|
59
125
|
verbose: bool = False,
|
|
126
|
+
cwd: Path | None = None,
|
|
60
127
|
) -> dict[str, Any]:
|
|
61
128
|
"""Return the merged settings dict.
|
|
62
129
|
|
|
@@ -65,10 +132,16 @@ def load_agent_settings(
|
|
|
65
132
|
``~/.config/agent-config/agent-settings.yml``. Both arguments accept
|
|
66
133
|
``Path`` or ``str``. Pass ``verbose=True`` to log keys present in
|
|
67
134
|
user-global that are not on the whitelist.
|
|
135
|
+
|
|
136
|
+
``cwd`` enables the in-project cascade: when provided **and**
|
|
137
|
+
``find_project_root(cwd)`` reaches a ``.git`` ancestor, the loader
|
|
138
|
+
walks every ``.agent-settings.yml`` from the repo root down to
|
|
139
|
+
``cwd`` and merges them shallowest → deepest (deepest wins).
|
|
140
|
+
Non-root layers are **not** whitelist-filtered (they live inside the
|
|
141
|
+
project boundary). When ``cwd`` is ``None`` (default), the loader
|
|
142
|
+
falls back to the single ``project_path`` behaviour — back-compat
|
|
143
|
+
with pre-cascade callers.
|
|
68
144
|
"""
|
|
69
|
-
project = _read_yaml(
|
|
70
|
-
Path(project_path) if project_path else Path(DEFAULT_PROJECT_FILE),
|
|
71
|
-
) or {}
|
|
72
145
|
user_global_raw = _read_yaml(
|
|
73
146
|
Path(user_global_path) if user_global_path else DEFAULT_USER_GLOBAL_FILE,
|
|
74
147
|
) or {}
|
|
@@ -82,12 +155,48 @@ def load_agent_settings(
|
|
|
82
155
|
sorted(ignored),
|
|
83
156
|
)
|
|
84
157
|
|
|
158
|
+
cascade = _resolve_cascade_paths(cwd, project_path)
|
|
159
|
+
|
|
85
160
|
merged: dict[str, Any] = _deep_copy_defaults(_DEFAULTS)
|
|
86
161
|
_deep_merge(merged, user_global_filtered)
|
|
87
|
-
|
|
162
|
+
for path in cascade:
|
|
163
|
+
layer = _read_yaml(path) or {}
|
|
164
|
+
if layer:
|
|
165
|
+
_deep_merge(merged, layer)
|
|
88
166
|
return merged
|
|
89
167
|
|
|
90
168
|
|
|
169
|
+
def iter_setting_overrides(
|
|
170
|
+
project_path: Path | str | None = None,
|
|
171
|
+
user_global_path: Path | str | None = None,
|
|
172
|
+
cwd: Path | None = None,
|
|
173
|
+
) -> Iterator[tuple[str, Any, Path]]:
|
|
174
|
+
"""Yield ``(dotted_key, value, source_path)`` for every leaf setting.
|
|
175
|
+
|
|
176
|
+
Walks the same cascade as :func:`load_agent_settings` and emits one
|
|
177
|
+
tuple per leaf observed at each layer (user-global → repo-root →
|
|
178
|
+
intermediates → CWD). Callers can detect overrides by grouping
|
|
179
|
+
tuples on ``dotted_key`` — the deepest tuple per group wins. Useful
|
|
180
|
+
for ``task settings:trace`` and other banner-only diagnostics.
|
|
181
|
+
Never blocks, never raises on missing files.
|
|
182
|
+
"""
|
|
183
|
+
user_global_path_resolved = (
|
|
184
|
+
Path(user_global_path) if user_global_path else DEFAULT_USER_GLOBAL_FILE
|
|
185
|
+
)
|
|
186
|
+
user_global_raw = _read_yaml(user_global_path_resolved) or {}
|
|
187
|
+
user_global_filtered, _ = _filter_whitelist(user_global_raw, MERGEABLE_KEYS)
|
|
188
|
+
if user_global_filtered:
|
|
189
|
+
for key in _leaf_paths(user_global_filtered):
|
|
190
|
+
yield key, _get_dotted(user_global_filtered, key), user_global_path_resolved
|
|
191
|
+
|
|
192
|
+
for path in _resolve_cascade_paths(cwd, project_path):
|
|
193
|
+
layer = _read_yaml(path)
|
|
194
|
+
if not layer:
|
|
195
|
+
continue
|
|
196
|
+
for key in _leaf_paths(layer):
|
|
197
|
+
yield key, _get_dotted(layer, key), path
|
|
198
|
+
|
|
199
|
+
|
|
91
200
|
def _read_yaml(path: Path) -> dict[str, Any] | None:
|
|
92
201
|
"""Best-effort YAML read; never raises. Returns ``None`` on any failure."""
|
|
93
202
|
if not path.is_file():
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Resolve files under ``agents/<kind>/<name>.md`` via the cascade.
|
|
2
|
+
|
|
3
|
+
Phase 1 of road-to-portable-runtime-and-update-check. Companion to
|
|
4
|
+
``agent_settings.py``: where the settings loader merges YAML, this
|
|
5
|
+
resolves single-file overlays — overrides, contexts, decisions — to a
|
|
6
|
+
single deepest match across the in-project ancestor chain plus the
|
|
7
|
+
user-global directory (when the ``kind`` is whitelisted).
|
|
8
|
+
|
|
9
|
+
Resolution order (deepest wins, every layer optional):
|
|
10
|
+
|
|
11
|
+
N. ``~/.config/agent-config/agents/<kind>/<name>.md`` (user-global; weakest;
|
|
12
|
+
``kind`` must be in
|
|
13
|
+
``USER_GLOBAL_OVERLAY_KINDS``)
|
|
14
|
+
N-1. ``<repo-root>/agents/<kind>/<name>.md``
|
|
15
|
+
N-2. ``<intermediate-dir>/agents/<kind>/<name>.md`` (optional)
|
|
16
|
+
1. ``<CWD>/agents/<kind>/<name>.md`` (deepest, wins)
|
|
17
|
+
|
|
18
|
+
Asymmetry: ``overrides/`` is the developer's personal layer and may
|
|
19
|
+
live user-global; ``contexts/`` and ``decisions/`` are project-shaped
|
|
20
|
+
and must not leak across projects, so the user-global layer is
|
|
21
|
+
silently skipped for them. Stateful subdirs (``state/``, ``memory/``,
|
|
22
|
+
``roadmaps/``, ``work_engine/``, ``council-*/``) are not cascade-eligible
|
|
23
|
+
at all and raise ``ValueError`` when passed as ``kind``.
|
|
24
|
+
|
|
25
|
+
Contract — pure, read-only, tolerant:
|
|
26
|
+
|
|
27
|
+
* Does not read file contents — returns the resolved ``Path`` only.
|
|
28
|
+
* Missing layer / missing file → silently skipped, never raises.
|
|
29
|
+
* Invalid ``kind`` → ``ValueError`` (programmer error, not user input).
|
|
30
|
+
* No file is ever created or written by this module.
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import logging
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
from scripts._lib.agent_settings import find_project_root
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
#: Subdirs of ``agents/`` that participate in the cascade. Every entry
|
|
42
|
+
#: is **additive** (single-file artefacts; deepest wins). Stateful or
|
|
43
|
+
#: session-scoped subdirs (``state/``, ``memory/``, ``roadmaps/``,
|
|
44
|
+
#: ``work_engine/``, ``.agent-prices.md``, ``council-*/``) are
|
|
45
|
+
#: deliberately excluded — they are project-rooted only.
|
|
46
|
+
CASCADE_ELIGIBLE_KINDS: frozenset[str] = frozenset({
|
|
47
|
+
"overrides",
|
|
48
|
+
"contexts",
|
|
49
|
+
"decisions",
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
#: Subset of :data:`CASCADE_ELIGIBLE_KINDS` allowed to live at the
|
|
53
|
+
#: user-global layer (``~/.config/agent-config/agents/<kind>/``).
|
|
54
|
+
#: ``contexts/`` and ``decisions/`` are project-shaped and must not leak
|
|
55
|
+
#: across projects; only ``overrides/`` — the developer's personal
|
|
56
|
+
#: layer — is whitelisted.
|
|
57
|
+
USER_GLOBAL_OVERLAY_KINDS: frozenset[str] = frozenset({"overrides"})
|
|
58
|
+
|
|
59
|
+
USER_GLOBAL_AGENTS_DIR = Path.home() / ".config" / "agent-config" / "agents"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def resolve_overlay(name: str, kind: str, cwd: Path) -> Path | None:
|
|
63
|
+
"""Return the deepest existing ``agents/<kind>/<name>.md`` or ``None``.
|
|
64
|
+
|
|
65
|
+
Walks the in-project ancestor chain from ``cwd`` to the ``.git``
|
|
66
|
+
repo root (inclusive) and probes each layer for
|
|
67
|
+
``agents/<kind>/<name>.md``. Falls through to the user-global
|
|
68
|
+
directory only when ``kind in USER_GLOBAL_OVERLAY_KINDS``. Returns
|
|
69
|
+
the **deepest** existing file (highest precedence), or ``None`` if
|
|
70
|
+
no layer carries the overlay.
|
|
71
|
+
|
|
72
|
+
``name`` is treated as a basename — no path traversal, no
|
|
73
|
+
subdirectories. Callers that need nested layouts should encode the
|
|
74
|
+
structure inside the overlay file, not the filename.
|
|
75
|
+
"""
|
|
76
|
+
if kind not in CASCADE_ELIGIBLE_KINDS:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"agents_overlay: kind {kind!r} not cascade-eligible "
|
|
79
|
+
f"(allowed: {sorted(CASCADE_ELIGIBLE_KINDS)})",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Candidate layers, shallowest → deepest. Last match wins.
|
|
83
|
+
candidates: list[Path] = []
|
|
84
|
+
|
|
85
|
+
if kind in USER_GLOBAL_OVERLAY_KINDS:
|
|
86
|
+
candidates.append(USER_GLOBAL_AGENTS_DIR / kind / f"{name}.md")
|
|
87
|
+
|
|
88
|
+
root = find_project_root(cwd)
|
|
89
|
+
if root is not None:
|
|
90
|
+
cwd_resolved = cwd.resolve()
|
|
91
|
+
chain: list[Path] = []
|
|
92
|
+
cursor = cwd_resolved
|
|
93
|
+
while True:
|
|
94
|
+
chain.append(cursor)
|
|
95
|
+
if cursor == root:
|
|
96
|
+
break
|
|
97
|
+
parent = cursor.parent
|
|
98
|
+
if parent == cursor:
|
|
99
|
+
break
|
|
100
|
+
cursor = parent
|
|
101
|
+
chain.reverse()
|
|
102
|
+
for layer_dir in chain:
|
|
103
|
+
candidates.append(layer_dir / "agents" / kind / f"{name}.md")
|
|
104
|
+
|
|
105
|
+
deepest: Path | None = None
|
|
106
|
+
for candidate in candidates:
|
|
107
|
+
if candidate.is_file():
|
|
108
|
+
deepest = candidate
|
|
109
|
+
return deepest
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Pin-aware version resolver for the ``agent-config`` dispatcher.
|
|
2
|
+
|
|
3
|
+
Phase 3 of road-to-portable-runtime-and-update-check (P3.2). The
|
|
4
|
+
dispatcher consults this module **before** doing any work. If
|
|
5
|
+
``.agent-settings.yml`` carries a non-empty ``agent_config_version``
|
|
6
|
+
pin and the currently running package version does not match, the
|
|
7
|
+
process re-execs via ``npx @event4u/agent-config@<pin> <argv>``.
|
|
8
|
+
|
|
9
|
+
Determinism is the goal: a consumer's ``npx`` cache may resolve to a
|
|
10
|
+
different version than the project pinned to, and the resolver
|
|
11
|
+
guarantees the pinned version is the one that actually runs.
|
|
12
|
+
|
|
13
|
+
Escape hatch: ``AGENT_CONFIG_NO_PIN_REEXEC=1`` disables the re-exec
|
|
14
|
+
entirely (used for local development of the package itself and for
|
|
15
|
+
the recursion guard described below).
|
|
16
|
+
|
|
17
|
+
Recursion guard: the parent sets ``AGENT_CONFIG_PIN_REEXEC_DEPTH=1``
|
|
18
|
+
on the child env so the re-exec'd child does not loop if the freshly
|
|
19
|
+
spawned ``npx`` resolves to a still-mismatched version. One re-exec
|
|
20
|
+
per process, full stop.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import shutil
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Optional
|
|
29
|
+
|
|
30
|
+
PACKAGE_NAME = "@event4u/agent-config"
|
|
31
|
+
PIN_KEY = "agent_config_version"
|
|
32
|
+
NO_REEXEC_ENV = "AGENT_CONFIG_NO_PIN_REEXEC"
|
|
33
|
+
REEXEC_DEPTH_ENV = "AGENT_CONFIG_PIN_REEXEC_DEPTH"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _normalize(version: str) -> str:
|
|
37
|
+
return version.strip().lstrip("v")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def read_pin(cwd: Path, *, settings_loader=None) -> Optional[str]:
|
|
41
|
+
"""Return the pinned version from the cascaded settings, or ``None``.
|
|
42
|
+
|
|
43
|
+
Empty string and missing key both yield ``None``.
|
|
44
|
+
"""
|
|
45
|
+
if settings_loader is None:
|
|
46
|
+
from scripts._lib import agent_settings # local import (test override)
|
|
47
|
+
|
|
48
|
+
settings_loader = agent_settings.load_agent_settings
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
settings = settings_loader(cwd=cwd)
|
|
52
|
+
except Exception:
|
|
53
|
+
return None
|
|
54
|
+
raw = settings.get(PIN_KEY)
|
|
55
|
+
if not isinstance(raw, str):
|
|
56
|
+
return None
|
|
57
|
+
pin = raw.strip()
|
|
58
|
+
return pin or None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def should_reexec(
|
|
62
|
+
pin: Optional[str],
|
|
63
|
+
installed: str,
|
|
64
|
+
*,
|
|
65
|
+
env: Optional[dict] = None,
|
|
66
|
+
) -> bool:
|
|
67
|
+
"""Pure predicate: do we need to re-exec under the pinned version?"""
|
|
68
|
+
env = env if env is not None else os.environ
|
|
69
|
+
if env.get(NO_REEXEC_ENV) == "1":
|
|
70
|
+
return False
|
|
71
|
+
if env.get(REEXEC_DEPTH_ENV) == "1":
|
|
72
|
+
return False
|
|
73
|
+
if not pin:
|
|
74
|
+
return False
|
|
75
|
+
if not installed:
|
|
76
|
+
return False
|
|
77
|
+
return _normalize(pin) != _normalize(installed)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_reexec_argv(pin: str, argv: list[str]) -> list[str]:
|
|
81
|
+
"""Build the ``npx`` argv that re-execs at the pinned version."""
|
|
82
|
+
return ["npx", "--yes", f"{PACKAGE_NAME}@{_normalize(pin)}", *argv]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def maybe_reexec(
|
|
86
|
+
installed: str,
|
|
87
|
+
*,
|
|
88
|
+
cwd: Optional[Path] = None,
|
|
89
|
+
argv: Optional[list[str]] = None,
|
|
90
|
+
env: Optional[dict] = None,
|
|
91
|
+
runner=None,
|
|
92
|
+
) -> Optional[int]:
|
|
93
|
+
"""Re-exec at the pinned version if needed; return the child exit code.
|
|
94
|
+
|
|
95
|
+
Returns ``None`` when no re-exec is performed (caller continues).
|
|
96
|
+
The injected ``runner`` covers the test path — defaults to
|
|
97
|
+
:func:`os.execvpe` on real invocations.
|
|
98
|
+
"""
|
|
99
|
+
cwd = cwd or Path.cwd()
|
|
100
|
+
argv = argv if argv is not None else sys.argv
|
|
101
|
+
env = env if env is not None else os.environ
|
|
102
|
+
|
|
103
|
+
pin = read_pin(cwd)
|
|
104
|
+
if not should_reexec(pin, installed, env=env):
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
assert pin is not None # narrowed by should_reexec
|
|
108
|
+
npx = shutil.which("npx")
|
|
109
|
+
if not npx:
|
|
110
|
+
# Cannot re-exec without npx — silently fall back to running
|
|
111
|
+
# the locally-installed version. Better to do something than
|
|
112
|
+
# to die because of a missing CLI.
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
new_argv = build_reexec_argv(pin, argv[1:] if argv else [])
|
|
116
|
+
child_env = dict(env)
|
|
117
|
+
child_env[REEXEC_DEPTH_ENV] = "1"
|
|
118
|
+
|
|
119
|
+
if runner is None:
|
|
120
|
+
# Replace the current process; never returns on success.
|
|
121
|
+
os.execvpe(npx, new_argv, child_env)
|
|
122
|
+
return 1 # unreachable on POSIX
|
|
123
|
+
return runner(npx, new_argv, child_env)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _parse_cli(argv: list[str]) -> tuple[Path, str, list[str]]:
|
|
127
|
+
"""Parse the dispatcher-facing argv: ``--cwd X --installed Y -- ARGS``."""
|
|
128
|
+
cwd = Path.cwd()
|
|
129
|
+
installed = ""
|
|
130
|
+
forward: list[str] = []
|
|
131
|
+
i = 0
|
|
132
|
+
while i < len(argv):
|
|
133
|
+
token = argv[i]
|
|
134
|
+
if token == "--cwd" and i + 1 < len(argv):
|
|
135
|
+
cwd = Path(argv[i + 1])
|
|
136
|
+
i += 2
|
|
137
|
+
elif token == "--installed" and i + 1 < len(argv):
|
|
138
|
+
installed = argv[i + 1]
|
|
139
|
+
i += 2
|
|
140
|
+
elif token == "--":
|
|
141
|
+
forward = argv[i + 1:]
|
|
142
|
+
break
|
|
143
|
+
else:
|
|
144
|
+
i += 1
|
|
145
|
+
return cwd, installed, forward
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__": # pragma: no cover
|
|
149
|
+
cwd, installed, forward = _parse_cli(sys.argv[1:])
|
|
150
|
+
# Build the argv the child should see: ``agent-config <forward...>``.
|
|
151
|
+
child_argv = ["agent-config", *forward]
|
|
152
|
+
maybe_reexec(installed, cwd=cwd, argv=child_argv)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Daily update-check banner for the ``agent-config`` dispatcher.
|
|
2
|
+
|
|
3
|
+
Phase 2 of road-to-portable-runtime-and-update-check. Pure functions:
|
|
4
|
+
``check_for_update()`` decides whether a banner should be emitted and
|
|
5
|
+
returns the banner string (or ``None``). The dispatcher prints the
|
|
6
|
+
returned string to ``stderr`` after the subcommand finishes — never
|
|
7
|
+
delaying the work, never prompting.
|
|
8
|
+
|
|
9
|
+
Design constraints (see roadmap P2):
|
|
10
|
+
|
|
11
|
+
- Stdlib only (no new deps); the package's Python floor is stdlib-only.
|
|
12
|
+
- 1 s hard timeout on the registry call; network failure is silent.
|
|
13
|
+
- 24 h cadence gated by ``~/.config/agent-config/update-check.json``.
|
|
14
|
+
- Suppress in CI, on non-TTY stdout, when ``AGENT_CONFIG_NO_UPDATE_CHECK=1``,
|
|
15
|
+
or when ``update_check.enabled: false`` in settings.
|
|
16
|
+
- State file mode is ``0600``.
|
|
17
|
+
|
|
18
|
+
The dispatcher is the only call site. Tests mock ``now``, the state
|
|
19
|
+
path, and ``fetch_latest_from_npm`` to cover every branch.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
import urllib.error
|
|
28
|
+
import urllib.request
|
|
29
|
+
from datetime import datetime, timedelta, timezone
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Optional
|
|
32
|
+
|
|
33
|
+
PACKAGE_NAME = "@event4u/agent-config"
|
|
34
|
+
NPM_REGISTRY_URL = f"https://registry.npmjs.org/{PACKAGE_NAME}/latest"
|
|
35
|
+
FETCH_TIMEOUT_S = 1.0
|
|
36
|
+
CHECK_WINDOW = timedelta(hours=24)
|
|
37
|
+
|
|
38
|
+
DEFAULT_STATE_PATH = Path.home() / ".config" / "agent-config" / "update-check.json"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _now_utc() -> datetime:
|
|
42
|
+
return datetime.now(timezone.utc)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def fetch_latest_from_npm(
|
|
46
|
+
*,
|
|
47
|
+
timeout: float = FETCH_TIMEOUT_S,
|
|
48
|
+
url: str = NPM_REGISTRY_URL,
|
|
49
|
+
) -> Optional[str]:
|
|
50
|
+
"""Return the ``latest`` dist-tag version, or ``None`` on any failure.
|
|
51
|
+
|
|
52
|
+
Hard 1 s timeout. Any exception (network, JSON, missing key) yields
|
|
53
|
+
``None`` — the update check is best-effort.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
req = urllib.request.Request(
|
|
57
|
+
url,
|
|
58
|
+
headers={"Accept": "application/json", "User-Agent": "agent-config-update-check"},
|
|
59
|
+
)
|
|
60
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 — fixed URL
|
|
61
|
+
payload = json.load(resp)
|
|
62
|
+
version = payload.get("version")
|
|
63
|
+
if isinstance(version, str) and version.strip():
|
|
64
|
+
return version.strip()
|
|
65
|
+
except (urllib.error.URLError, TimeoutError, ValueError, OSError, json.JSONDecodeError):
|
|
66
|
+
return None
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _read_state(path: Path) -> dict:
|
|
71
|
+
try:
|
|
72
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
73
|
+
data = json.load(fh)
|
|
74
|
+
if isinstance(data, dict):
|
|
75
|
+
return data
|
|
76
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
77
|
+
pass
|
|
78
|
+
return {}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _write_state(path: Path, payload: dict) -> None:
|
|
82
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
fd, tmp = tempfile.mkstemp(prefix=".update-check-", dir=str(path.parent))
|
|
84
|
+
try:
|
|
85
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
86
|
+
json.dump(payload, fh, indent=2, sort_keys=True)
|
|
87
|
+
os.chmod(tmp, 0o600)
|
|
88
|
+
os.replace(tmp, path)
|
|
89
|
+
except Exception:
|
|
90
|
+
try:
|
|
91
|
+
os.unlink(tmp)
|
|
92
|
+
except OSError:
|
|
93
|
+
pass
|
|
94
|
+
raise
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _should_check(state: dict, now: datetime) -> bool:
|
|
98
|
+
last = state.get("last_check_utc")
|
|
99
|
+
if not isinstance(last, str):
|
|
100
|
+
return True
|
|
101
|
+
try:
|
|
102
|
+
last_dt = datetime.fromisoformat(last.replace("Z", "+00:00"))
|
|
103
|
+
except ValueError:
|
|
104
|
+
return True
|
|
105
|
+
if last_dt.tzinfo is None:
|
|
106
|
+
last_dt = last_dt.replace(tzinfo=timezone.utc)
|
|
107
|
+
return (now - last_dt) >= CHECK_WINDOW
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _format_banner(latest: str, installed: str) -> str:
|
|
111
|
+
return (
|
|
112
|
+
f"ℹ️ agent-config {latest} available (you have {installed}).\n"
|
|
113
|
+
f" Update: npx {PACKAGE_NAME} update"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _is_newer(latest: str, installed: str) -> bool:
|
|
118
|
+
def _parse(v: str) -> tuple:
|
|
119
|
+
parts = v.lstrip("v").split("-", 1)[0].split(".")
|
|
120
|
+
out = []
|
|
121
|
+
for p in parts[:3]:
|
|
122
|
+
try:
|
|
123
|
+
out.append(int(p))
|
|
124
|
+
except ValueError:
|
|
125
|
+
out.append(0)
|
|
126
|
+
while len(out) < 3:
|
|
127
|
+
out.append(0)
|
|
128
|
+
return tuple(out)
|
|
129
|
+
|
|
130
|
+
return _parse(latest) > _parse(installed)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def check_for_update(
|
|
134
|
+
installed_version: str,
|
|
135
|
+
*,
|
|
136
|
+
now: Optional[datetime] = None,
|
|
137
|
+
state_path: Optional[Path] = None,
|
|
138
|
+
env: Optional[dict] = None,
|
|
139
|
+
is_tty: Optional[bool] = None,
|
|
140
|
+
settings_enabled: bool = True,
|
|
141
|
+
fetcher=fetch_latest_from_npm,
|
|
142
|
+
) -> Optional[str]:
|
|
143
|
+
"""Decide whether to show an update banner. Pure (modulo state file).
|
|
144
|
+
|
|
145
|
+
Returns the banner string or ``None``. ``None`` covers every
|
|
146
|
+
suppression branch (CI, non-TTY, opt-out, within 24 h, network
|
|
147
|
+
failure, no update available).
|
|
148
|
+
"""
|
|
149
|
+
env = env if env is not None else os.environ
|
|
150
|
+
if env.get("AGENT_CONFIG_NO_UPDATE_CHECK") == "1":
|
|
151
|
+
return None
|
|
152
|
+
if env.get("CI") in {"1", "true"} or env.get("GITHUB_ACTIONS") == "true":
|
|
153
|
+
return None
|
|
154
|
+
if not settings_enabled:
|
|
155
|
+
return None
|
|
156
|
+
if is_tty is None:
|
|
157
|
+
is_tty = sys.stdout.isatty()
|
|
158
|
+
if not is_tty:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
now = now or _now_utc()
|
|
162
|
+
state_path = state_path or DEFAULT_STATE_PATH
|
|
163
|
+
state = _read_state(state_path)
|
|
164
|
+
if not _should_check(state, now):
|
|
165
|
+
latest = state.get("last_seen_version")
|
|
166
|
+
if isinstance(latest, str) and _is_newer(latest, installed_version):
|
|
167
|
+
return _format_banner(latest, installed_version)
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
latest = fetcher()
|
|
171
|
+
payload = {
|
|
172
|
+
"last_check_utc": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
173
|
+
"last_seen_version": latest or state.get("last_seen_version", ""),
|
|
174
|
+
"installed_version": installed_version,
|
|
175
|
+
}
|
|
176
|
+
try:
|
|
177
|
+
_write_state(state_path, payload)
|
|
178
|
+
except OSError:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
if not latest or not _is_newer(latest, installed_version):
|
|
182
|
+
return None
|
|
183
|
+
return _format_banner(latest, installed_version)
|