@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
- "version": "1.41.2",
3
+ "version": "2.1.0",
4
4
  "description": "Shared agent configuration \u2014 skills, rules, commands, guidelines, and templates for AI coding tools",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -16,7 +16,6 @@
16
16
  ".agent-src/",
17
17
  ".augment-plugin/",
18
18
  ".claude-plugin/",
19
- "bin/",
20
19
  "config/",
21
20
  "docs/",
22
21
  "scripts/",
@@ -26,15 +25,11 @@
26
25
  "CONTRIBUTING.md",
27
26
  "LICENSE",
28
27
  "README.md",
29
- "composer.json",
30
28
  "llms.txt"
31
29
  ],
32
30
  "bin": {
33
31
  "agent-config": "scripts/agent-config"
34
32
  },
35
- "scripts": {
36
- "postinstall": "bash scripts/postinstall.sh"
37
- },
38
33
  "publishConfig": {
39
34
  "access": "public",
40
35
  "provenance": true
File without changes
@@ -0,0 +1,270 @@
1
+ """``agent-config migrate`` — one-shot migration off legacy install paths.
2
+
3
+ P3.5/P3.6 of road-to-portable-runtime-and-update-check.md. Migrates a
4
+ consumer project from the legacy composer / npm install paths onto
5
+ the ``npx``-only runtime described in ``docs/architecture.md``.
6
+
7
+ Steps performed (idempotent):
8
+
9
+ 1. Detect legacy install signals (composer.json entry, package.json
10
+ devDependency, in-project symlinks pointing at vendor/ or
11
+ node_modules/).
12
+ 2. Remove the package entry from composer.json / package.json
13
+ in-place, preserving sibling keys + formatting.
14
+ 3. Delete agent-config managed symlinks that point inside the legacy
15
+ install dirs. User-added links elsewhere are preserved with a
16
+ warning.
17
+ 4. Write a fresh ``.agent-settings.yml`` (only if missing) with
18
+ ``agent_config_version`` pinned to the running version.
19
+ 5. Update the consumer's ``.gitignore`` block (legacy paths out, new
20
+ project-scope entries in).
21
+ 6. Print a summary so the developer can review + commit.
22
+
23
+ Re-runs on an already-migrated repo emit ``already migrated`` and
24
+ exit 0.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import re
31
+ import sys
32
+ from pathlib import Path
33
+ from typing import Iterable, Optional
34
+
35
+ PACKAGE_NAME_NPM = "@event4u/agent-config"
36
+ PACKAGE_NAME_COMPOSER = "event4u/agent-config"
37
+ LEGACY_DIRS = ("vendor", "node_modules")
38
+ MANAGED_SYMLINKS = (
39
+ ".augment",
40
+ ".claude",
41
+ ".cursor",
42
+ ".clinerules",
43
+ ".windsurfrules",
44
+ )
45
+ GITIGNORE_BLOCK_START = "# >>> event4u/agent-config (managed) >>>"
46
+ GITIGNORE_BLOCK_END = "# <<< event4u/agent-config (managed) <<<"
47
+ GITIGNORE_NEW_BODY = (
48
+ ".agent-settings.yml\n"
49
+ "agents/sessions/\n"
50
+ "agents/council-responses/\n"
51
+ "agents/council-sessions/\n"
52
+ )
53
+
54
+
55
+ def _detect_npm(pkg_json: Path) -> bool:
56
+ if not pkg_json.is_file():
57
+ return False
58
+ try:
59
+ data = json.loads(pkg_json.read_text(encoding="utf-8"))
60
+ except (OSError, ValueError, json.JSONDecodeError):
61
+ return False
62
+ for key in ("dependencies", "devDependencies"):
63
+ section = data.get(key) or {}
64
+ if isinstance(section, dict) and PACKAGE_NAME_NPM in section:
65
+ return True
66
+ return False
67
+
68
+
69
+ def _detect_composer(composer_json: Path) -> bool:
70
+ if not composer_json.is_file():
71
+ return False
72
+ try:
73
+ data = json.loads(composer_json.read_text(encoding="utf-8"))
74
+ except (OSError, ValueError, json.JSONDecodeError):
75
+ return False
76
+ for key in ("require", "require-dev"):
77
+ section = data.get(key) or {}
78
+ if isinstance(section, dict) and PACKAGE_NAME_COMPOSER in section:
79
+ return True
80
+ return False
81
+
82
+
83
+ def _strip_npm_entry(pkg_json: Path) -> bool:
84
+ try:
85
+ data = json.loads(pkg_json.read_text(encoding="utf-8"))
86
+ except (OSError, ValueError, json.JSONDecodeError):
87
+ return False
88
+ changed = False
89
+ for key in ("dependencies", "devDependencies"):
90
+ section = data.get(key)
91
+ if isinstance(section, dict) and PACKAGE_NAME_NPM in section:
92
+ del section[PACKAGE_NAME_NPM]
93
+ changed = True
94
+ if not section:
95
+ del data[key]
96
+ if changed:
97
+ pkg_json.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
98
+ return changed
99
+
100
+
101
+ def _strip_composer_entry(composer_json: Path) -> bool:
102
+ try:
103
+ data = json.loads(composer_json.read_text(encoding="utf-8"))
104
+ except (OSError, ValueError, json.JSONDecodeError):
105
+ return False
106
+ changed = False
107
+ for key in ("require", "require-dev"):
108
+ section = data.get(key)
109
+ if isinstance(section, dict) and PACKAGE_NAME_COMPOSER in section:
110
+ del section[PACKAGE_NAME_COMPOSER]
111
+ changed = True
112
+ if not section:
113
+ del data[key]
114
+ if changed:
115
+ composer_json.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
116
+ return changed
117
+
118
+
119
+ def _classify_symlink(link: Path) -> Optional[str]:
120
+ """Return 'legacy' if the link points into vendor/ or node_modules/, 'user' otherwise."""
121
+ if not link.is_symlink():
122
+ return None
123
+ try:
124
+ target = Path(link.readlink()) if hasattr(link, "readlink") else Path(link.resolve())
125
+ except OSError:
126
+ return None
127
+ target_str = str(target)
128
+ if any(seg in target_str.split("/") for seg in LEGACY_DIRS):
129
+ return "legacy"
130
+ return "user"
131
+
132
+
133
+ def _purge_legacy_symlinks(project: Path) -> tuple[list[str], list[str]]:
134
+ removed: list[str] = []
135
+ preserved: list[str] = []
136
+ for name in MANAGED_SYMLINKS:
137
+ link = project / name
138
+ kind = _classify_symlink(link)
139
+ if kind == "legacy":
140
+ try:
141
+ link.unlink()
142
+ removed.append(name)
143
+ except OSError:
144
+ preserved.append(name)
145
+ elif kind == "user":
146
+ preserved.append(name)
147
+ return removed, preserved
148
+
149
+
150
+ def _write_settings(project: Path, version: str) -> bool:
151
+ settings = project / ".agent-settings.yml"
152
+ if settings.exists():
153
+ return False
154
+ body = (
155
+ "# .agent-settings.yml — generated by `agent-config migrate`.\n"
156
+ "# See docs/customization.md for the full key reference.\n"
157
+ f'agent_config_version: "{version}"\n'
158
+ )
159
+ settings.write_text(body, encoding="utf-8")
160
+ try:
161
+ settings.chmod(0o644)
162
+ except OSError:
163
+ pass
164
+ return True
165
+
166
+
167
+ def _update_gitignore(project: Path) -> bool:
168
+ gitignore = project / ".gitignore"
169
+ block = (
170
+ f"{GITIGNORE_BLOCK_START}\n"
171
+ f"{GITIGNORE_NEW_BODY}"
172
+ f"{GITIGNORE_BLOCK_END}\n"
173
+ )
174
+ if not gitignore.exists():
175
+ gitignore.write_text(block, encoding="utf-8")
176
+ return True
177
+
178
+ text = gitignore.read_text(encoding="utf-8")
179
+ pattern = re.compile(
180
+ re.escape(GITIGNORE_BLOCK_START) + r".*?" + re.escape(GITIGNORE_BLOCK_END) + r"\n?",
181
+ re.DOTALL,
182
+ )
183
+ if pattern.search(text):
184
+ new_text = pattern.sub(block, text)
185
+ else:
186
+ new_text = text
187
+ if new_text and not new_text.endswith("\n"):
188
+ new_text += "\n"
189
+ new_text += block
190
+ if new_text == text:
191
+ return False
192
+ gitignore.write_text(new_text, encoding="utf-8")
193
+ return True
194
+
195
+
196
+ def _detect_already_migrated(project: Path) -> bool:
197
+ """A repo counts as migrated when no legacy signal remains."""
198
+ if _detect_npm(project / "package.json"):
199
+ return False
200
+ if _detect_composer(project / "composer.json"):
201
+ return False
202
+ for name in MANAGED_SYMLINKS:
203
+ if _classify_symlink(project / name) == "legacy":
204
+ return False
205
+ return True
206
+
207
+
208
+ def main(
209
+ argv: Optional[list[str]] = None,
210
+ *,
211
+ cwd: Optional[Path] = None,
212
+ version: Optional[str] = None,
213
+ out=sys.stdout,
214
+ err=sys.stderr, # noqa: ARG001 — reserved for future error paths
215
+ ) -> int:
216
+ parser = argparse.ArgumentParser(
217
+ prog="agent-config migrate",
218
+ description="One-shot migration off legacy composer / npm install paths.",
219
+ )
220
+ parser.add_argument("--dry-run", action="store_true",
221
+ help="Detect only; do not write any files.")
222
+ args = parser.parse_args(argv)
223
+
224
+ project = (cwd or Path.cwd()).resolve()
225
+ version = version or _detect_installed_version()
226
+
227
+ if _detect_already_migrated(project):
228
+ print("✅ already migrated — nothing to do.", file=out)
229
+ return 0
230
+
231
+ if args.dry_run:
232
+ print("ℹ️ legacy install detected — re-run without --dry-run to migrate.", file=out)
233
+ return 0
234
+
235
+ summary: list[str] = []
236
+ if _strip_npm_entry(project / "package.json"):
237
+ summary.append(f"removed {PACKAGE_NAME_NPM} from package.json")
238
+ if _strip_composer_entry(project / "composer.json"):
239
+ summary.append(f"removed {PACKAGE_NAME_COMPOSER} from composer.json")
240
+ removed_links, preserved_links = _purge_legacy_symlinks(project)
241
+ for name in removed_links:
242
+ summary.append(f"removed legacy symlink {name}")
243
+ for name in preserved_links:
244
+ summary.append(f"preserved user-managed {name} (review manually)")
245
+ if _write_settings(project, version):
246
+ summary.append(f".agent-settings.yml written (pinned to {version})")
247
+ if _update_gitignore(project):
248
+ summary.append(".gitignore agent-config block refreshed")
249
+
250
+ print("✅ migration complete:", file=out)
251
+ for line in summary:
252
+ print(f" - {line}", file=out)
253
+ print("\n Next: review the diff and commit.", file=out)
254
+ return 0
255
+
256
+
257
+ def _detect_installed_version() -> str:
258
+ pkg_json = Path(__file__).resolve().parents[2] / "package.json"
259
+ try:
260
+ data = json.loads(pkg_json.read_text(encoding="utf-8"))
261
+ version = data.get("version")
262
+ if isinstance(version, str) and version.strip():
263
+ return version.strip()
264
+ except (OSError, ValueError, json.JSONDecodeError):
265
+ pass
266
+ return "0.0.0"
267
+
268
+
269
+ if __name__ == "__main__": # pragma: no cover
270
+ sys.exit(main())
@@ -0,0 +1,226 @@
1
+ """``agent-config update`` — explicit, opt-in update of the version pin.
2
+
3
+ Phase 3 of road-to-portable-runtime-and-update-check (P3.1). The
4
+ command is the only user-driven path that flips
5
+ ``agent_config_version`` in ``.agent-settings.yml``; the daily banner
6
+ (P2) never writes settings files.
7
+
8
+ Flags:
9
+
10
+ * ``--check`` — print the available latest version + return; no write.
11
+ * ``--to <version>`` — pin to an exact version (registry-existence
12
+ checked). Downgrades are allowed; the pin is a project decision.
13
+ * (no flag) — pin to the registry's ``latest`` tag.
14
+
15
+ Write target: the **deepest** ``.agent-settings.yml`` in the project
16
+ cascade that already carries the ``agent_config_version`` key. When no
17
+ file carries it, the repo-root file is created/edited. Comments and
18
+ key ordering are preserved by line-based substitution.
19
+
20
+ The npx cache is warmed via
21
+ ``npx --yes @event4u/agent-config@<new> --version`` so the next
22
+ invocation is offline-fast. The P2 state file is refreshed in
23
+ lockstep — the new ``installed_version`` is recorded so the banner
24
+ does not yell about the old pin.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import re
31
+ import subprocess
32
+ import sys
33
+ import urllib.error
34
+ import urllib.request
35
+ from datetime import datetime, timezone
36
+ from pathlib import Path
37
+ from typing import Optional
38
+
39
+ from scripts._lib import update_check
40
+ from scripts._lib.agent_settings import (
41
+ DEFAULT_PROJECT_FILE,
42
+ _resolve_cascade_paths,
43
+ find_project_root,
44
+ )
45
+
46
+ PACKAGE_NAME = "@event4u/agent-config"
47
+ PIN_KEY = "agent_config_version"
48
+ REGISTRY_VERSION_URL = f"https://registry.npmjs.org/{PACKAGE_NAME}/{{version}}"
49
+ PIN_LINE_RE = re.compile(r"^(\s*agent_config_version\s*:\s*)(.*)$")
50
+
51
+
52
+ def _normalize(version: str) -> str:
53
+ return version.strip().lstrip("v")
54
+
55
+
56
+ def _registry_has_version(version: str, *, timeout: float = 1.0) -> bool:
57
+ url = REGISTRY_VERSION_URL.format(version=_normalize(version))
58
+ try:
59
+ req = urllib.request.Request(url, headers={"Accept": "application/json"})
60
+ with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
61
+ return resp.status == 200
62
+ except (urllib.error.URLError, TimeoutError, OSError):
63
+ return False
64
+
65
+
66
+ def _find_pin_file(cwd: Path) -> Path:
67
+ """Return the deepest cascade file that carries the pin, else repo root."""
68
+ cascade = _resolve_cascade_paths(cwd, None)
69
+ for path in reversed(cascade):
70
+ if path.is_file() and _read_pin_line(path) is not None:
71
+ return path
72
+ # No file carries it — pick the repo-root cascade entry (shallowest).
73
+ if cascade:
74
+ return cascade[0]
75
+ return cwd / DEFAULT_PROJECT_FILE
76
+
77
+
78
+ def _read_pin_line(path: Path) -> Optional[int]:
79
+ try:
80
+ with path.open("r", encoding="utf-8") as fh:
81
+ for idx, line in enumerate(fh):
82
+ if PIN_LINE_RE.match(line):
83
+ return idx
84
+ except OSError:
85
+ return None
86
+ return None
87
+
88
+
89
+ def _write_pin(path: Path, new_version: str) -> bool:
90
+ """Rewrite the pin in ``path``; return ``True`` if the file changed."""
91
+ target = f'agent_config_version: "{_normalize(new_version)}"\n'
92
+ try:
93
+ lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
94
+ except FileNotFoundError:
95
+ path.parent.mkdir(parents=True, exist_ok=True)
96
+ path.write_text(target, encoding="utf-8")
97
+ return True
98
+ for idx, line in enumerate(lines):
99
+ if PIN_LINE_RE.match(line):
100
+ if lines[idx] == target:
101
+ return False
102
+ lines[idx] = target
103
+ path.write_text("".join(lines), encoding="utf-8")
104
+ return True
105
+ # File exists but has no pin line — append at end.
106
+ if lines and not lines[-1].endswith("\n"):
107
+ lines.append("\n")
108
+ lines.append(target)
109
+ path.write_text("".join(lines), encoding="utf-8")
110
+ return True
111
+
112
+
113
+ def _warm_npx_cache(version: str, *, runner=subprocess.run) -> None:
114
+ try:
115
+ runner(
116
+ ["npx", "--yes", f"{PACKAGE_NAME}@{_normalize(version)}", "--version"],
117
+ stdout=subprocess.DEVNULL,
118
+ stderr=subprocess.DEVNULL,
119
+ timeout=120,
120
+ check=False,
121
+ )
122
+ except (OSError, subprocess.TimeoutExpired):
123
+ pass
124
+
125
+
126
+ def _refresh_state(installed: str, latest: str, state_path: Path) -> None:
127
+ state = update_check._read_state(state_path)
128
+ payload = {
129
+ "last_check_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
130
+ "last_seen_version": latest,
131
+ "installed_version": installed,
132
+ }
133
+ state.update(payload)
134
+ try:
135
+ update_check._write_state(state_path, state)
136
+ except OSError:
137
+ pass
138
+
139
+
140
+ def main(
141
+ argv: Optional[list[str]] = None,
142
+ *,
143
+ cwd: Optional[Path] = None,
144
+ installed_version: Optional[str] = None,
145
+ fetcher=update_check.fetch_latest_from_npm,
146
+ version_checker=_registry_has_version,
147
+ cache_warmer=_warm_npx_cache,
148
+ state_path: Optional[Path] = None,
149
+ out=sys.stdout,
150
+ err=sys.stderr,
151
+ ) -> int:
152
+ """Entry point. ``scripts/agent-config`` dispatches here."""
153
+ parser = argparse.ArgumentParser(
154
+ prog="agent-config update",
155
+ description="Update the agent_config_version pin in .agent-settings.yml.",
156
+ )
157
+ parser.add_argument("--check", action="store_true",
158
+ help="Print the latest available version and exit. No file is written.")
159
+ parser.add_argument("--to", metavar="VERSION",
160
+ help="Pin to an explicit version (registry-existence checked).")
161
+ args = parser.parse_args(argv)
162
+
163
+ cwd = (cwd or Path.cwd()).resolve()
164
+ installed_version = installed_version or _detect_installed_version()
165
+ state_path = state_path or update_check.DEFAULT_STATE_PATH
166
+
167
+ if args.to:
168
+ target = _normalize(args.to)
169
+ if not version_checker(target):
170
+ print(
171
+ f"❌ agent-config: version {target} not found on the npm registry.",
172
+ file=err,
173
+ )
174
+ return 1
175
+ latest = target
176
+ else:
177
+ latest = fetcher()
178
+ if not latest:
179
+ print(
180
+ "❌ agent-config: failed to fetch latest version from the npm registry.",
181
+ file=err,
182
+ )
183
+ return 1
184
+ latest = _normalize(latest)
185
+
186
+ if args.check:
187
+ if update_check._is_newer(latest, installed_version):
188
+ print(f"agent-config {latest} available (you have {installed_version}).", file=out)
189
+ print(f"Update: npx {PACKAGE_NAME} update", file=out)
190
+ else:
191
+ print(f"agent-config is up to date ({installed_version}).", file=out)
192
+ return 0
193
+
194
+ pin_file = _find_pin_file(cwd)
195
+ changed = _write_pin(pin_file, latest)
196
+ try:
197
+ rel = pin_file.relative_to(cwd)
198
+ except ValueError:
199
+ rel = pin_file
200
+
201
+ if changed:
202
+ print(f"✅ Pinned {PACKAGE_NAME} to {latest} in {rel}.", file=out)
203
+ else:
204
+ print(f"ℹ️ {rel} already pins to {latest}.", file=out)
205
+
206
+ cache_warmer(latest)
207
+ _refresh_state(latest, latest, state_path)
208
+ return 0
209
+
210
+
211
+ def _detect_installed_version() -> str:
212
+ """Read ``version`` from the package's own ``package.json``."""
213
+ pkg_json = Path(__file__).resolve().parents[2] / "package.json"
214
+ try:
215
+ data = json.loads(pkg_json.read_text(encoding="utf-8"))
216
+ version = data.get("version")
217
+ if isinstance(version, str) and version.strip():
218
+ return version.strip()
219
+ except (OSError, ValueError, json.JSONDecodeError):
220
+ pass
221
+ return "0.0.0"
222
+
223
+
224
+ if __name__ == "__main__": # pragma: no cover
225
+ sys.exit(main())
226
+