@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.
Files changed (48) hide show
  1. package/.agent-src/commands/fix/{pr-bots.md → pr-bot-comments.md} +3 -3
  2. package/.agent-src/commands/fix/{pr.md → pr-comments.md} +6 -6
  3. package/.agent-src/commands/fix/{pr-developers.md → pr-developer-comments.md} +3 -3
  4. package/.agent-src/commands/fix.md +6 -6
  5. package/.agent-src/contexts/communication/rules-auto/slash-command-routing-policy-mechanics.md +2 -2
  6. package/.agent-src/templates/agents/agent-project-settings.example.yml +14 -0
  7. package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +120 -11
  8. package/.claude-plugin/marketplace.json +4 -4
  9. package/CHANGELOG.md +54 -0
  10. package/README.md +39 -31
  11. package/config/agent-settings.template.yml +25 -0
  12. package/docs/architecture.md +47 -1
  13. package/docs/catalog.md +3 -3
  14. package/docs/contracts/command-clusters.md +3 -3
  15. package/docs/contracts/file-ownership-matrix.json +9 -9
  16. package/docs/customization.md +125 -9
  17. package/docs/getting-started.md +16 -25
  18. package/docs/installation.md +66 -82
  19. package/docs/migration/v1-to-v2.md +98 -0
  20. package/docs/migrations/commands-1.15.0.md +3 -3
  21. package/docs/setup/per-ide/claude-code.md +0 -17
  22. package/docs/setup/per-ide/claude-desktop.md +35 -48
  23. package/docs/setup/per-ide/windsurf.md +0 -11
  24. package/docs/skills-catalog.md +23 -2
  25. package/docs/troubleshooting.md +20 -32
  26. package/llms.txt +22 -1
  27. package/package.json +1 -6
  28. package/scripts/_cli/__init__.py +0 -0
  29. package/scripts/_cli/cmd_migrate.py +270 -0
  30. package/scripts/_cli/cmd_update.py +226 -0
  31. package/scripts/_lib/agent_settings.py +120 -11
  32. package/scripts/_lib/agents_overlay.py +109 -0
  33. package/scripts/_lib/pin_resolver.py +152 -0
  34. package/scripts/_lib/update_check.py +183 -0
  35. package/scripts/agent-config +73 -1
  36. package/scripts/check_overlay_cascade_subdirs.py +125 -0
  37. package/scripts/check_template_pin_drift.py +112 -0
  38. package/scripts/check_update_banner.py +86 -0
  39. package/scripts/install +27 -40
  40. package/scripts/install.py +17 -228
  41. package/scripts/install.sh +6 -11
  42. package/templates/agent-config-wrapper.sh +40 -25
  43. package/templates/consumer-settings/README.md +2 -2
  44. package/bin/install.php +0 -45
  45. package/composer.json +0 -33
  46. package/scripts/postinstall.sh +0 -76
  47. package/scripts/setup.sh +0 -230
  48. 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 (project wins, user-global fills gaps for whitelisted
7
- keys only):
6
+ Resolution order (deepest wins; user-global is whitelist-filtered only):
8
7
 
9
- 1. Project ``./.agent-settings.yml`` (full file, all keys)
10
- 2. ``~/.config/agent-config/agent-settings.yml`` (whitelist only)
11
- 3. Built-in defaults (currently empty)
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
- _deep_merge(merged, project)
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)