@event4u/agent-config 1.41.2 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agent-src/commands/fix/{pr-bots.md → pr-bot-comments.md} +3 -3
- package/.agent-src/commands/fix/{pr.md → pr-comments.md} +6 -6
- package/.agent-src/commands/fix/{pr-developers.md → pr-developer-comments.md} +3 -3
- package/.agent-src/commands/fix.md +6 -6
- package/.agent-src/contexts/communication/rules-auto/slash-command-routing-policy-mechanics.md +2 -2
- package/.agent-src/templates/agents/agent-project-settings.example.yml +14 -0
- package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +120 -11
- package/.claude-plugin/marketplace.json +4 -4
- package/CHANGELOG.md +54 -0
- package/README.md +39 -31
- package/config/agent-settings.template.yml +25 -0
- package/docs/architecture.md +47 -1
- package/docs/catalog.md +3 -3
- package/docs/contracts/command-clusters.md +3 -3
- package/docs/contracts/file-ownership-matrix.json +9 -9
- package/docs/customization.md +125 -9
- package/docs/getting-started.md +16 -25
- package/docs/installation.md +66 -82
- package/docs/migration/v1-to-v2.md +98 -0
- package/docs/migrations/commands-1.15.0.md +3 -3
- package/docs/setup/per-ide/claude-code.md +0 -17
- package/docs/setup/per-ide/claude-desktop.md +35 -48
- package/docs/setup/per-ide/windsurf.md +0 -11
- package/docs/skills-catalog.md +23 -2
- package/docs/troubleshooting.md +20 -32
- package/llms.txt +22 -1
- package/package.json +1 -6
- package/scripts/_cli/__init__.py +0 -0
- package/scripts/_cli/cmd_migrate.py +270 -0
- package/scripts/_cli/cmd_update.py +226 -0
- package/scripts/_lib/agent_settings.py +120 -11
- package/scripts/_lib/agents_overlay.py +109 -0
- package/scripts/_lib/pin_resolver.py +152 -0
- package/scripts/_lib/update_check.py +183 -0
- package/scripts/agent-config +73 -1
- package/scripts/check_overlay_cascade_subdirs.py +125 -0
- package/scripts/check_template_pin_drift.py +112 -0
- package/scripts/check_update_banner.py +86 -0
- package/scripts/install +27 -40
- package/scripts/install.py +17 -228
- package/scripts/install.sh +6 -11
- package/templates/agent-config-wrapper.sh +40 -25
- package/templates/consumer-settings/README.md +2 -2
- package/bin/install.php +0 -45
- package/composer.json +0 -33
- package/scripts/postinstall.sh +0 -76
- package/scripts/setup.sh +0 -230
- package/templates/global-install-manifest.yml +0 -91
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@event4u/agent-config",
|
|
3
|
-
"version": "1.
|
|
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
|
+
|