@event4u/agent-config 1.41.1 → 2.0.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/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 +1 -1
- package/CHANGELOG.md +31 -0
- package/README.md +34 -25
- package/config/agent-settings.template.yml +25 -0
- package/docs/architecture.md +46 -0
- package/docs/customization.md +125 -9
- package/docs/installation.md +9 -36
- package/docs/migration/v1-to-v2.md +98 -0
- 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/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 +2 -37
- package/scripts/install.py +6 -207
- package/bin/install.php +0 -45
- package/composer.json +0 -33
- package/scripts/postinstall.sh +0 -76
- package/templates/global-install-manifest.yml +0 -91
|
@@ -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)
|
package/scripts/agent-config
CHANGED
|
@@ -94,6 +94,10 @@ Commands:
|
|
|
94
94
|
Usage: council:run <question> --output <path> --confirm
|
|
95
95
|
council:render Re-render a saved council responses JSON to markdown
|
|
96
96
|
Usage: council:render <responses.json>
|
|
97
|
+
update Update the agent_config_version pin in .agent-settings.yml
|
|
98
|
+
Flags: --check (read-only) | --to <version> (explicit pin)
|
|
99
|
+
migrate One-shot migration off legacy composer / npm install paths
|
|
100
|
+
Flags: --dry-run (detect only)
|
|
97
101
|
help Show this help
|
|
98
102
|
--version, -V Print package version
|
|
99
103
|
|
|
@@ -504,6 +508,22 @@ cmd_council() {
|
|
|
504
508
|
exec env PYTHONPATH="$PACKAGE_ROOT" python3 "$script" "$sub" "$@"
|
|
505
509
|
}
|
|
506
510
|
|
|
511
|
+
# `agent-config update` — flip the agent_config_version pin in
|
|
512
|
+
# .agent-settings.yml. See scripts/_cli/cmd_update.py (P3.1 of
|
|
513
|
+
# road-to-portable-runtime-and-update-check.md).
|
|
514
|
+
cmd_update() {
|
|
515
|
+
require_python3
|
|
516
|
+
exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_update "$@"
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
# `agent-config migrate` — one-shot migration off legacy composer / npm
|
|
520
|
+
# install paths onto the npx-only runtime. See scripts/_cli/cmd_migrate.py
|
|
521
|
+
# (P3.5 of road-to-portable-runtime-and-update-check.md).
|
|
522
|
+
cmd_migrate() {
|
|
523
|
+
require_python3
|
|
524
|
+
exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_migrate "$@"
|
|
525
|
+
}
|
|
526
|
+
|
|
507
527
|
main() {
|
|
508
528
|
local cmd="${1-}"
|
|
509
529
|
[[ $# -gt 0 ]] && shift || true
|
|
@@ -542,6 +562,8 @@ main() {
|
|
|
542
562
|
council:estimate) cmd_council estimate "$@" ;;
|
|
543
563
|
council:run) cmd_council run "$@" ;;
|
|
544
564
|
council:render) cmd_council render "$@" ;;
|
|
565
|
+
update) cmd_update "$@" ;;
|
|
566
|
+
migrate) cmd_migrate "$@" ;;
|
|
545
567
|
help|--help|-h|"") usage ;;
|
|
546
568
|
--version|-V) print_version ;;
|
|
547
569
|
*)
|
|
@@ -552,4 +574,54 @@ main() {
|
|
|
552
574
|
esac
|
|
553
575
|
}
|
|
554
576
|
|
|
555
|
-
|
|
577
|
+
# Pre-flight pin resolver: when `.agent-settings.yml` carries a
|
|
578
|
+
# non-empty `agent_config_version` that differs from the running
|
|
579
|
+
# package version, re-exec via `npx @event4u/agent-config@<pin>`.
|
|
580
|
+
# Skipped for `--version`, `help`, `update` and `migrate` (so consumers
|
|
581
|
+
# can escape a bad pin or run the legacy-cleanup before a pin exists),
|
|
582
|
+
# and when `AGENT_CONFIG_NO_PIN_REEXEC=1`. See P3.2 of
|
|
583
|
+
# road-to-portable-runtime-and-update-check.md.
|
|
584
|
+
maybe_pin_reexec() {
|
|
585
|
+
local cmd="${1-}"
|
|
586
|
+
case "$cmd" in
|
|
587
|
+
help|--help|-h|--version|-V|update|migrate|"") return 0 ;;
|
|
588
|
+
esac
|
|
589
|
+
if ! command -v python3 >/dev/null 2>&1; then
|
|
590
|
+
return 0
|
|
591
|
+
fi
|
|
592
|
+
local installed
|
|
593
|
+
installed="$(print_version)"
|
|
594
|
+
[[ -z "$installed" || "$installed" == "unknown" ]] && return 0
|
|
595
|
+
env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._lib.pin_resolver \
|
|
596
|
+
--cwd "$CONSUMER_ROOT" --installed "$installed" -- "$@" || true
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
# Post-subcommand banner: best-effort daily update-check notice on
|
|
600
|
+
# stderr. Runs after the dispatch returns (subshell wrapper below)
|
|
601
|
+
# so the banner appears post-output and never delays the subcommand.
|
|
602
|
+
# Suppressed in CI, on non-TTY stdout, by AGENT_CONFIG_NO_UPDATE_CHECK=1,
|
|
603
|
+
# or by `update_check.enabled: false` in settings. See P2 of
|
|
604
|
+
# road-to-portable-runtime-and-update-check.md.
|
|
605
|
+
run_update_check_banner() {
|
|
606
|
+
local cmd="${1-}"
|
|
607
|
+
case "$cmd" in
|
|
608
|
+
help|--help|-h|--version|-V|"") return 0 ;;
|
|
609
|
+
esac
|
|
610
|
+
if ! command -v python3 >/dev/null 2>&1; then
|
|
611
|
+
return 0
|
|
612
|
+
fi
|
|
613
|
+
local banner_script="$PACKAGE_ROOT/scripts/check_update_banner.py"
|
|
614
|
+
[[ -f "$banner_script" ]] || return 0
|
|
615
|
+
python3 "$banner_script" --cwd "$CONSUMER_ROOT" 2>/dev/null || true
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
# Pin re-exec runs before dispatch — if it triggers, the process is
|
|
619
|
+
# replaced and nothing else here matters.
|
|
620
|
+
maybe_pin_reexec "$@"
|
|
621
|
+
|
|
622
|
+
# Dispatch in a subshell so internal ``exec`` calls do not replace this
|
|
623
|
+
# process — we still get to run the post-subcommand banner.
|
|
624
|
+
( main "$@" )
|
|
625
|
+
rc=$?
|
|
626
|
+
run_update_check_banner "${1-}"
|
|
627
|
+
exit "$rc"
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Guard: ``CASCADE_ELIGIBLE_KINDS`` / ``USER_GLOBAL_OVERLAY_KINDS`` ↔ docs.
|
|
3
|
+
|
|
4
|
+
Phase 1 of road-to-portable-runtime-and-update-check (P1.6). The
|
|
5
|
+
overlay resolver in :mod:`scripts._lib.agents_overlay` ships two
|
|
6
|
+
constants that gate which ``agents/<kind>/`` subdirs participate in
|
|
7
|
+
the cascade and which of those may live at the user-global layer.
|
|
8
|
+
The same lists are restated in
|
|
9
|
+
``docs/customization.md`` § *"agents/ overlay cascade"* so consumers
|
|
10
|
+
can see them without reading source.
|
|
11
|
+
|
|
12
|
+
Drift between code and docs is the failure mode this guard catches.
|
|
13
|
+
|
|
14
|
+
Exit codes: 0 = clean, 1 = drift detected, 3 = internal error.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
23
|
+
|
|
24
|
+
sys.path.insert(0, str(REPO_ROOT))
|
|
25
|
+
|
|
26
|
+
from scripts._lib.agents_overlay import ( # noqa: E402
|
|
27
|
+
CASCADE_ELIGIBLE_KINDS,
|
|
28
|
+
USER_GLOBAL_OVERLAY_KINDS,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
DOCS_PATH = REPO_ROOT / "docs" / "customization.md"
|
|
32
|
+
|
|
33
|
+
# Match `agents/<kind>/` in the first column of the overlay table, plus
|
|
34
|
+
# the ✅/❌ markers in columns 2 and 3.
|
|
35
|
+
ROW_RE = re.compile(
|
|
36
|
+
r"^\|\s*`agents/([a-z][a-z0-9_-]*)/`\s*\|\s*(✅|❌)[^|]*\|\s*(✅|❌)[^|]*\|",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_doc_table(text: str) -> tuple[set[str], set[str], set[str]]:
|
|
41
|
+
"""Return (all-kinds-listed, cascade-yes, user-global-yes) from the table."""
|
|
42
|
+
all_kinds: set[str] = set()
|
|
43
|
+
cascade_yes: set[str] = set()
|
|
44
|
+
user_global_yes: set[str] = set()
|
|
45
|
+
for line in text.splitlines():
|
|
46
|
+
match = ROW_RE.match(line)
|
|
47
|
+
if not match:
|
|
48
|
+
continue
|
|
49
|
+
kind, cascade_mark, user_mark = match.groups()
|
|
50
|
+
all_kinds.add(kind)
|
|
51
|
+
if cascade_mark == "✅":
|
|
52
|
+
cascade_yes.add(kind)
|
|
53
|
+
if user_mark == "✅":
|
|
54
|
+
user_global_yes.add(kind)
|
|
55
|
+
return all_kinds, cascade_yes, user_global_yes
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main() -> int:
|
|
59
|
+
if not DOCS_PATH.is_file():
|
|
60
|
+
print(f"❌ {DOCS_PATH} not found", file=sys.stderr)
|
|
61
|
+
return 3
|
|
62
|
+
text = DOCS_PATH.read_text(encoding="utf-8")
|
|
63
|
+
_, doc_cascade, doc_user_global = _parse_doc_table(text)
|
|
64
|
+
|
|
65
|
+
errors: list[str] = []
|
|
66
|
+
|
|
67
|
+
code_cascade = set(CASCADE_ELIGIBLE_KINDS)
|
|
68
|
+
if doc_cascade != code_cascade:
|
|
69
|
+
only_code = sorted(code_cascade - doc_cascade)
|
|
70
|
+
only_doc = sorted(doc_cascade - code_cascade)
|
|
71
|
+
if only_code:
|
|
72
|
+
errors.append(
|
|
73
|
+
"CASCADE_ELIGIBLE_KINDS has entries missing from "
|
|
74
|
+
f"docs/customization.md table: {only_code}",
|
|
75
|
+
)
|
|
76
|
+
if only_doc:
|
|
77
|
+
errors.append(
|
|
78
|
+
"docs/customization.md table marks these as cascade-eligible "
|
|
79
|
+
f"but the code list does not: {only_doc}",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
code_user_global = set(USER_GLOBAL_OVERLAY_KINDS)
|
|
83
|
+
if doc_user_global != code_user_global:
|
|
84
|
+
only_code = sorted(code_user_global - doc_user_global)
|
|
85
|
+
only_doc = sorted(doc_user_global - code_user_global)
|
|
86
|
+
if only_code:
|
|
87
|
+
errors.append(
|
|
88
|
+
"USER_GLOBAL_OVERLAY_KINDS has entries missing from "
|
|
89
|
+
f"docs/customization.md table: {only_code}",
|
|
90
|
+
)
|
|
91
|
+
if only_doc:
|
|
92
|
+
errors.append(
|
|
93
|
+
"docs/customization.md table marks these as user-global-eligible "
|
|
94
|
+
f"but the code list does not: {only_doc}",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Sanity: user-global subset of cascade-eligible.
|
|
98
|
+
if not code_user_global.issubset(code_cascade):
|
|
99
|
+
errors.append(
|
|
100
|
+
"USER_GLOBAL_OVERLAY_KINDS must be a subset of "
|
|
101
|
+
f"CASCADE_ELIGIBLE_KINDS; surplus: "
|
|
102
|
+
f"{sorted(code_user_global - code_cascade)}",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if errors:
|
|
106
|
+
print("❌ agents/ overlay cascade drift detected:", file=sys.stderr)
|
|
107
|
+
for err in errors:
|
|
108
|
+
print(f" - {err}", file=sys.stderr)
|
|
109
|
+
print(
|
|
110
|
+
"\nFix: update either scripts/_lib/agents_overlay.py "
|
|
111
|
+
"or docs/customization.md so they agree.",
|
|
112
|
+
file=sys.stderr,
|
|
113
|
+
)
|
|
114
|
+
return 1
|
|
115
|
+
|
|
116
|
+
print(
|
|
117
|
+
f"✅ agents/ overlay cascade in sync · "
|
|
118
|
+
f"{len(code_cascade)} cascade-eligible, "
|
|
119
|
+
f"{len(code_user_global)} user-global-eligible",
|
|
120
|
+
)
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
sys.exit(main())
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fail when ``package.json.version`` and the project-template pin drift.
|
|
3
|
+
|
|
4
|
+
CI guard for P3.3 of road-to-portable-runtime-and-update-check.md.
|
|
5
|
+
|
|
6
|
+
A release bump of ``package.json`` must update
|
|
7
|
+
``agent_config_version`` in
|
|
8
|
+
``.agent-src.uncompressed/templates/agents/agent-project-settings.example.yml``
|
|
9
|
+
(and its compressed twin under ``.agent-src/``) in lockstep. Otherwise
|
|
10
|
+
a fresh ``init`` on a new project would bootstrap onto a stale pin,
|
|
11
|
+
and the pin-resolver would re-exec back to the older version.
|
|
12
|
+
|
|
13
|
+
Exit codes:
|
|
14
|
+
0 — pin matches package.json.version (or pin is empty and the
|
|
15
|
+
``--allow-empty`` flag is set, used for early development).
|
|
16
|
+
1 — pin missing, drift detected, or template file unreadable.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
27
|
+
|
|
28
|
+
PACKAGE_JSON = REPO_ROOT / "package.json"
|
|
29
|
+
TEMPLATE_FILES = (
|
|
30
|
+
REPO_ROOT / ".agent-src.uncompressed" / "templates" / "agents" / "agent-project-settings.example.yml",
|
|
31
|
+
REPO_ROOT / ".agent-src" / "templates" / "agents" / "agent-project-settings.example.yml",
|
|
32
|
+
)
|
|
33
|
+
PIN_LINE_RE = re.compile(r"^\s*agent_config_version\s*:\s*\"?([^\"\s#]*)\"?")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _read_package_version() -> str | None:
|
|
37
|
+
try:
|
|
38
|
+
data = json.loads(PACKAGE_JSON.read_text(encoding="utf-8"))
|
|
39
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
40
|
+
return None
|
|
41
|
+
version = data.get("version")
|
|
42
|
+
return version.strip() if isinstance(version, str) else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _read_template_pin(path: Path) -> str | None:
|
|
46
|
+
try:
|
|
47
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
48
|
+
match = PIN_LINE_RE.match(line)
|
|
49
|
+
if match:
|
|
50
|
+
return match.group(1).strip()
|
|
51
|
+
except OSError:
|
|
52
|
+
return None
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main(argv: list[str] | None = None) -> int:
|
|
57
|
+
parser = argparse.ArgumentParser(
|
|
58
|
+
description="Fail when package.json version and template pin drift.",
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--allow-empty",
|
|
62
|
+
action="store_true",
|
|
63
|
+
help="Accept an empty pin (used during early-stage development).",
|
|
64
|
+
)
|
|
65
|
+
args = parser.parse_args(argv)
|
|
66
|
+
|
|
67
|
+
pkg_version = _read_package_version()
|
|
68
|
+
if not pkg_version:
|
|
69
|
+
print("❌ check_template_pin_drift: failed to read package.json version.", file=sys.stderr)
|
|
70
|
+
return 1
|
|
71
|
+
|
|
72
|
+
failures: list[str] = []
|
|
73
|
+
for template in TEMPLATE_FILES:
|
|
74
|
+
if not template.is_file():
|
|
75
|
+
failures.append(f"missing template file: {template.relative_to(REPO_ROOT)}")
|
|
76
|
+
continue
|
|
77
|
+
pin = _read_template_pin(template)
|
|
78
|
+
rel = template.relative_to(REPO_ROOT)
|
|
79
|
+
if pin is None:
|
|
80
|
+
failures.append(f"{rel}: no `agent_config_version:` line found")
|
|
81
|
+
continue
|
|
82
|
+
if pin == "":
|
|
83
|
+
if not args.allow_empty:
|
|
84
|
+
failures.append(
|
|
85
|
+
f"{rel}: agent_config_version is empty; expected {pkg_version}"
|
|
86
|
+
)
|
|
87
|
+
continue
|
|
88
|
+
if pin != pkg_version:
|
|
89
|
+
failures.append(
|
|
90
|
+
f"{rel}: agent_config_version={pin!r} does not match package.json version {pkg_version!r}"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if failures:
|
|
94
|
+
print(
|
|
95
|
+
"❌ check_template_pin_drift: template pin drift detected.",
|
|
96
|
+
file=sys.stderr,
|
|
97
|
+
)
|
|
98
|
+
for line in failures:
|
|
99
|
+
print(f" - {line}", file=sys.stderr)
|
|
100
|
+
print(
|
|
101
|
+
" Fix: update `agent_config_version:` in the listed template(s) to "
|
|
102
|
+
f"{pkg_version!r} before releasing.",
|
|
103
|
+
file=sys.stderr,
|
|
104
|
+
)
|
|
105
|
+
return 1
|
|
106
|
+
|
|
107
|
+
print(f"✅ template pin = package.json version ({pkg_version}).")
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
sys.exit(main())
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Thin CLI wrapper: emit the daily update-check banner to stderr.
|
|
3
|
+
|
|
4
|
+
Invoked by the ``scripts/agent-config`` dispatcher **after** a
|
|
5
|
+
subcommand finishes. Never raises, never exits non-zero — banner is
|
|
6
|
+
best-effort. See ``scripts/_lib/update_check.py`` for the decision
|
|
7
|
+
logic.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python3 scripts/check_update_banner.py [--installed-version X.Y.Z]
|
|
11
|
+
|
|
12
|
+
When ``--installed-version`` is omitted, reads ``package.json`` next to
|
|
13
|
+
the package root (``$PACKAGE_ROOT/package.json``).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
23
|
+
sys.path.insert(0, str(ROOT))
|
|
24
|
+
|
|
25
|
+
from scripts._lib import agent_settings # noqa: E402
|
|
26
|
+
from scripts._lib import update_check # noqa: E402
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _read_installed_version(package_root: Path) -> str:
|
|
30
|
+
candidate = package_root / "package.json"
|
|
31
|
+
try:
|
|
32
|
+
data = json.loads(candidate.read_text(encoding="utf-8"))
|
|
33
|
+
version = data.get("version")
|
|
34
|
+
if isinstance(version, str) and version.strip():
|
|
35
|
+
return version.strip()
|
|
36
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
37
|
+
pass
|
|
38
|
+
return ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _read_settings_flag(cwd: Path) -> bool:
|
|
42
|
+
try:
|
|
43
|
+
settings = agent_settings.load_agent_settings(cwd=cwd)
|
|
44
|
+
except Exception:
|
|
45
|
+
return True
|
|
46
|
+
block = settings.get("update_check")
|
|
47
|
+
if isinstance(block, dict) and block.get("enabled") is False:
|
|
48
|
+
return False
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main(argv: list[str] | None = None) -> int:
|
|
53
|
+
parser = argparse.ArgumentParser(prog="check-update-banner", add_help=False)
|
|
54
|
+
parser.add_argument("--installed-version", default="")
|
|
55
|
+
parser.add_argument("--cwd", default=str(Path.cwd()))
|
|
56
|
+
parser.add_argument("--help", "-h", action="store_true")
|
|
57
|
+
args, _unknown = parser.parse_known_args(argv)
|
|
58
|
+
if args.help:
|
|
59
|
+
print(__doc__ or "")
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
installed = args.installed_version or _read_installed_version(ROOT)
|
|
63
|
+
if not installed:
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
cwd = Path(args.cwd).resolve()
|
|
68
|
+
except OSError:
|
|
69
|
+
cwd = ROOT
|
|
70
|
+
enabled = _read_settings_flag(cwd)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
banner = update_check.check_for_update(
|
|
74
|
+
installed,
|
|
75
|
+
settings_enabled=enabled,
|
|
76
|
+
)
|
|
77
|
+
except Exception:
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
if banner:
|
|
81
|
+
print(banner, file=sys.stderr)
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
sys.exit(main())
|
package/scripts/install
CHANGED
|
@@ -26,12 +26,6 @@
|
|
|
26
26
|
# --quiet Suppress non-error output
|
|
27
27
|
# --skip-sync Skip payload sync (install.sh)
|
|
28
28
|
# --skip-bridges Skip bridge files (install.py)
|
|
29
|
-
# --global Phase-3: ship kernel rules + curated skills to user-scope
|
|
30
|
-
# dirs (~/.claude/, ~/.cursor/, ~/.codeium/windsurf/,
|
|
31
|
-
# ~/.config/agent-config/) so the agent has them in every
|
|
32
|
-
# project. Pair with --tools to scope surfaces; default = all.
|
|
33
|
-
# --uninstall With --global: remove the event4u/ namespace dir from each
|
|
34
|
-
# enabled surface (no effect on user-added files).
|
|
35
29
|
# --help, -h Show this help
|
|
36
30
|
#
|
|
37
31
|
# Examples:
|
|
@@ -59,14 +53,12 @@ QUIET=false
|
|
|
59
53
|
SKIP_SYNC=false
|
|
60
54
|
SKIP_BRIDGES=false
|
|
61
55
|
LIST_TOOLS=false
|
|
62
|
-
GLOBAL_INSTALL=false
|
|
63
|
-
UNINSTALL=false
|
|
64
56
|
|
|
65
57
|
# Single source of truth for valid tool IDs (also referenced by install.sh / install.py).
|
|
66
58
|
VALID_TOOLS="claude-code claude-desktop cursor windsurf cline gemini-cli copilot augment aider codex all"
|
|
67
59
|
|
|
68
60
|
show_help() {
|
|
69
|
-
sed -n '3,
|
|
61
|
+
sed -n '3,29p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
|
70
62
|
}
|
|
71
63
|
|
|
72
64
|
list_tools() {
|
|
@@ -128,8 +120,6 @@ while [[ $# -gt 0 ]]; do
|
|
|
128
120
|
--quiet) QUIET=true; shift ;;
|
|
129
121
|
--skip-sync) SKIP_SYNC=true; shift ;;
|
|
130
122
|
--skip-bridges) SKIP_BRIDGES=true; shift ;;
|
|
131
|
-
--global) GLOBAL_INSTALL=true; shift ;;
|
|
132
|
-
--uninstall) UNINSTALL=true; shift ;;
|
|
133
123
|
--help|-h) show_help; exit 0 ;;
|
|
134
124
|
*) err "Unknown argument: $1"; show_help >&2; exit 1 ;;
|
|
135
125
|
esac
|
|
@@ -175,16 +165,11 @@ prompt_tools() {
|
|
|
175
165
|
echo " ✅ Selected: $TOOLS"
|
|
176
166
|
}
|
|
177
167
|
|
|
178
|
-
if ! $TOOLS_EXPLICIT && ! $YES && ! $QUIET && ! $LIST_TOOLS &&
|
|
168
|
+
if ! $TOOLS_EXPLICIT && ! $YES && ! $QUIET && ! $LIST_TOOLS && [[ -t 0 && -t 1 ]]; then
|
|
179
169
|
prompt_tools
|
|
180
170
|
TOOLS_EXPLICIT=true
|
|
181
171
|
fi
|
|
182
172
|
|
|
183
|
-
if $UNINSTALL && ! $GLOBAL_INSTALL; then
|
|
184
|
-
err "--uninstall is only valid combined with --global"
|
|
185
|
-
exit 1
|
|
186
|
-
fi
|
|
187
|
-
|
|
188
173
|
# Default = "all": backward compatible with pre-Phase-1 invocations. An
|
|
189
174
|
# explicit --tools= (empty value) is rejected by validate_tools — only an
|
|
190
175
|
# absent flag falls through to "all".
|
|
@@ -262,26 +247,6 @@ run_bridges() {
|
|
|
262
247
|
|
|
263
248
|
RC=0
|
|
264
249
|
|
|
265
|
-
# --global: dedicated user-scope path. Skips the project-bridge sync entirely
|
|
266
|
-
# and forwards to install.py --global (Phase 3 / S13). --uninstall pairs with
|
|
267
|
-
# --global to wipe the event4u/ namespace dir under each surface.
|
|
268
|
-
if $GLOBAL_INSTALL; then
|
|
269
|
-
if ! python_bin="$(find_python)"; then
|
|
270
|
-
err "Python 3 not found — required for --global install"
|
|
271
|
-
exit 1
|
|
272
|
-
fi
|
|
273
|
-
if [[ ! -f "$INSTALL_PY" ]]; then
|
|
274
|
-
err "Missing $INSTALL_PY"
|
|
275
|
-
exit 1
|
|
276
|
-
fi
|
|
277
|
-
args=(--package "$SOURCE_DIR" --global --tools="$TOOLS")
|
|
278
|
-
$FORCE && args+=(--force)
|
|
279
|
-
$QUIET && args+=(--quiet)
|
|
280
|
-
$UNINSTALL && args+=(--uninstall)
|
|
281
|
-
"$python_bin" "$INSTALL_PY" "${args[@]}"
|
|
282
|
-
exit $?
|
|
283
|
-
fi
|
|
284
|
-
|
|
285
250
|
if ! $SKIP_SYNC; then
|
|
286
251
|
if [[ ! -f "$INSTALL_SH" ]]; then
|
|
287
252
|
err "Missing $INSTALL_SH"
|