@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.
@@ -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)
@@ -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
- main "$@"
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,35p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
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 && ! $GLOBAL_INSTALL && [[ -t 0 && -t 1 ]]; then
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"