@event4u/agent-config 2.2.1 → 2.3.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 (38) hide show
  1. package/.agent-src/rules/external-reference-deep-dive.md +69 -0
  2. package/.agent-src/templates/copilot-instructions.md +7 -0
  3. package/.claude-plugin/marketplace.json +27 -1
  4. package/CHANGELOG.md +57 -0
  5. package/README.md +1 -8
  6. package/docs/architecture.md +1 -1
  7. package/docs/contracts/installed-tools-lockfile.md +138 -0
  8. package/docs/development.md +37 -0
  9. package/docs/getting-started.md +1 -1
  10. package/docs/installation.md +14 -0
  11. package/docs/setup/per-ide/antigravity.md +63 -0
  12. package/docs/setup/per-ide/augment.md +77 -0
  13. package/docs/setup/per-ide/codebuddy.md +63 -0
  14. package/docs/setup/per-ide/continue.md +68 -0
  15. package/docs/setup/per-ide/droid.md +65 -0
  16. package/docs/setup/per-ide/jetbrains.md +76 -0
  17. package/docs/setup/per-ide/kilocode.md +66 -0
  18. package/docs/setup/per-ide/kiro.md +72 -0
  19. package/docs/setup/per-ide/opencode.md +62 -0
  20. package/docs/setup/per-ide/qoder.md +63 -0
  21. package/docs/setup/per-ide/roocode.md +68 -0
  22. package/docs/setup/per-ide/trae.md +63 -0
  23. package/docs/setup/per-ide/warp.md +63 -0
  24. package/docs/setup/per-ide/zed.md +73 -0
  25. package/package.json +1 -1
  26. package/scripts/_cli/cmd_doctor.py +351 -0
  27. package/scripts/_cli/cmd_prune.py +317 -0
  28. package/scripts/_cli/cmd_uninstall.py +465 -0
  29. package/scripts/_cli/cmd_update.py +26 -3
  30. package/scripts/_cli/cmd_versions.py +147 -0
  31. package/scripts/_lib/fs_atomic.py +116 -0
  32. package/scripts/_lib/installed_tools.py +188 -44
  33. package/scripts/_lib/json_pointers.py +260 -0
  34. package/scripts/agent-config +69 -0
  35. package/scripts/compress.py +78 -15
  36. package/scripts/install +15 -6
  37. package/scripts/install-hooks.sh +54 -1
  38. package/scripts/install.py +1061 -52
@@ -0,0 +1,465 @@
1
+ """``agent-config uninstall`` — remove bridge markers (Phase 4.1).
2
+
3
+ Removes the per-tool bridge marker files this package created (the
4
+ files listed in ``PROJECT_BRIDGE_MARKERS`` for project scope, the
5
+ lockfile entries for global scope). User-deployed content in
6
+ ``~/.claude/skills/`` etc. is left in place — uninstall removes the
7
+ *link* between the project and agent-config, not the content the user
8
+ may still want. Use ``--purge`` to also delete the deployed content
9
+ directories (opt-in, destructive).
10
+
11
+ Idempotent: removing an already-absent marker is a no-op success.
12
+ Refuses to operate on a non-empty drift unless ``--force`` is passed.
13
+
14
+ Schema v2 (P2.2): when the manifest carries per-tool ``files[]`` and
15
+ ``merged_keys[]`` inventories, uninstall walks them instead of the
16
+ hardcoded ``PROJECT_BRIDGE_MARKERS`` map. JSON merges are subtracted
17
+ key-by-key so neighbour packages' contributions to the same shared
18
+ file (e.g. ``.cursor/hooks.json``) survive. Bridge files that are JSON
19
+ documents are deleted only when subtraction left them empty; if a
20
+ sibling tool still owns keys there, the file stays.
21
+
22
+ Two-phase commit: the tool entry is rewritten with ``status:
23
+ "uninstalling"`` before any deletion, deletions / subtractions run,
24
+ then the entry is removed on success. A crash between the two phases
25
+ leaves the manifest in a state ``cmd_prune`` recognises (the orphaned
26
+ ``files[]`` of an ``uninstalling`` tool resurface for cleanup).
27
+ Manifests without ``files[]`` fall back to the legacy v1 path
28
+ unchanged.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import json
34
+ import shutil
35
+ import sys
36
+ from collections import defaultdict
37
+ from pathlib import Path
38
+ from typing import Any, Iterable
39
+
40
+ from scripts._lib import fs_atomic, installed_lock, installed_tools
41
+ from scripts._lib.json_pointers import subtract_pointers
42
+ from scripts.install import PROJECT_BRIDGE_MARKERS, USER_SCOPE_PATHS
43
+
44
+
45
+ def _resolve_project_root(arg: str | None) -> Path:
46
+ if arg:
47
+ return Path(arg).expanduser().resolve()
48
+ return Path.cwd().resolve()
49
+
50
+
51
+ def _filter_tools(all_tools: Iterable[str], requested: str | None) -> list[str]:
52
+ pool = list(all_tools)
53
+ if not requested or requested.strip() == "all":
54
+ return pool
55
+ wanted = {t.strip() for t in requested.split(",") if t.strip()}
56
+ return [t for t in pool if t in wanted]
57
+
58
+
59
+ def _remove_project_marker(project_root: Path, tool: str, *, dry_run: bool) -> tuple[str, bool]:
60
+ rel = PROJECT_BRIDGE_MARKERS.get(tool)
61
+ if not rel:
62
+ return (f"{tool}: no project marker registered (skipped)", False)
63
+ target = project_root / rel
64
+ if not target.exists():
65
+ return (f"{tool}: {rel} already absent", False)
66
+ if dry_run:
67
+ return (f"{tool}: would remove {rel}", True)
68
+ try:
69
+ target.unlink()
70
+ return (f"{tool}: removed {rel}", True)
71
+ except OSError as exc:
72
+ return (f"{tool}: ❌ failed to remove {rel} ({exc})", False)
73
+
74
+
75
+ def _remove_global_content(tool: str, *, dry_run: bool, purge: bool) -> tuple[str, bool]:
76
+ anchor = USER_SCOPE_PATHS.get(tool)
77
+ if not anchor:
78
+ return (f"{tool}: no global anchor registered (skipped)", False)
79
+ target = Path(anchor).expanduser()
80
+ if not target.exists():
81
+ return (f"{tool}: {anchor} already absent", False)
82
+ if not purge:
83
+ return (f"{tool}: {anchor} preserved (pass --purge to delete)", False)
84
+ if dry_run:
85
+ return (f"{tool}: would purge {anchor}", True)
86
+ try:
87
+ if target.is_dir():
88
+ shutil.rmtree(target)
89
+ else:
90
+ target.unlink()
91
+ return (f"{tool}: purged {anchor}", True)
92
+ except OSError as exc:
93
+ return (f"{tool}: ❌ failed to purge {anchor} ({exc})", False)
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Schema v2 helpers (P2.2 — manifest-driven uninstall)
98
+ # ---------------------------------------------------------------------------
99
+
100
+
101
+ def _is_v2_entry(entry: dict[str, Any]) -> bool:
102
+ """Whether ``entry`` carries v2 per-tool inventories.
103
+
104
+ A tool entry counts as v2 when at least one of ``files[]`` or
105
+ ``merged_keys[]`` is non-empty. Tools written by older installers
106
+ have neither and fall through to the legacy ``PROJECT_BRIDGE_MARKERS``
107
+ path so a manifest written by a v1 ``init`` stays uninstallable.
108
+ """
109
+ return bool(entry.get("files")) or bool(entry.get("merged_keys"))
110
+
111
+
112
+ def _resolve_recorded_path(project_root: Path, recorded: str) -> Path:
113
+ """Resolve a manifest-recorded path against the project root.
114
+
115
+ ``files[].path`` and ``merged_keys[].file`` are written as absolute
116
+ paths by the installer (user-scope content lives outside the
117
+ project tree) but a relative path is accepted for portability and
118
+ resolved against ``project_root``. Returns the absolute path.
119
+ """
120
+ p = Path(recorded)
121
+ if p.is_absolute():
122
+ return p
123
+ return (project_root / p).resolve()
124
+
125
+
126
+ def _set_tool_status(
127
+ manifest_path: Path,
128
+ version: str,
129
+ tools: list[dict[str, Any]],
130
+ name: str,
131
+ status: str,
132
+ *,
133
+ deploy_roots: list[str] | None,
134
+ ) -> list[dict[str, Any]]:
135
+ """Persist ``status`` on the named tool entry and return the new list.
136
+
137
+ Two-phase commit anchor (P2.2): writing ``status: uninstalling``
138
+ before any deletion gives ``cmd_prune`` a stable signal to clean
139
+ up after a crash mid-uninstall.
140
+ """
141
+ new_tools: list[dict[str, Any]] = []
142
+ for entry in tools:
143
+ if entry.get("name") == name:
144
+ entry = {**entry, "status": status}
145
+ new_tools.append(entry)
146
+ installed_tools.write_manifest(
147
+ manifest_path, version, new_tools, deploy_roots=deploy_roots,
148
+ )
149
+ return new_tools
150
+
151
+
152
+ def _subtract_merged_keys(
153
+ entry: dict[str, Any],
154
+ project_root: Path,
155
+ *,
156
+ dry_run: bool,
157
+ ) -> tuple[list[str], set[str], set[str]]:
158
+ """Subtract this tool's ``merged_keys`` from every referenced JSON file.
159
+
160
+ Returns ``(warnings, emptied_files, touched_files)``:
161
+
162
+ * ``touched_files`` — absolute path strings of every JSON file this
163
+ tool recorded merge contributions for (regardless of subtraction
164
+ outcome). Used by :func:`_delete_tool_files` to decide whether a
165
+ JSON bridge is shared (touched + non-empty) or owned solely
166
+ (untouched → delete with the rest).
167
+ * ``emptied_files`` — subset of ``touched_files`` whose document is
168
+ now ``{}`` after subtraction. Foreign keys from neighbour
169
+ packages are preserved by :func:`subtract_pointers`.
170
+ """
171
+ warnings: list[str] = []
172
+ emptied: set[str] = set()
173
+ touched: set[str] = set()
174
+ merged_keys = entry.get("merged_keys") or []
175
+ if not merged_keys:
176
+ return warnings, emptied, touched
177
+ by_file: dict[str, list[dict[str, Any]]] = defaultdict(list)
178
+ for record in merged_keys:
179
+ by_file[record["file"]].append(record)
180
+ for file_label, records in by_file.items():
181
+ target = _resolve_recorded_path(project_root, file_label)
182
+ touched.add(str(target))
183
+ if not target.exists():
184
+ warnings.append(
185
+ f"{file_label}: absent — skipping {len(records)} pointer(s)"
186
+ )
187
+ continue
188
+ try:
189
+ doc = json.loads(target.read_text(encoding="utf-8"))
190
+ except (OSError, json.JSONDecodeError) as exc:
191
+ warnings.append(f"{file_label}: unparseable JSON ({exc}); skipped")
192
+ continue
193
+ if not isinstance(doc, dict):
194
+ warnings.append(f"{file_label}: not a JSON object; skipped")
195
+ continue
196
+ new_doc, sub_warnings = subtract_pointers(doc, records)
197
+ for w in sub_warnings:
198
+ warnings.append(
199
+ f"{file_label}{w['pointer']}: {w['reason']}"
200
+ )
201
+ if dry_run:
202
+ if not new_doc:
203
+ emptied.add(str(target))
204
+ continue
205
+ if new_doc:
206
+ fs_atomic.write_atomic(
207
+ target, json.dumps(new_doc, indent=2) + "\n",
208
+ )
209
+ else:
210
+ emptied.add(str(target))
211
+ return warnings, emptied, touched
212
+
213
+
214
+ def _delete_tool_files(
215
+ entry: dict[str, Any],
216
+ project_root: Path,
217
+ *,
218
+ dry_run: bool,
219
+ purge: bool,
220
+ emptied_files: set[str],
221
+ touched_files: set[str],
222
+ ) -> tuple[list[str], list[str]]:
223
+ """Delete ``files[]`` entries by kind; honour --purge for deployed.
224
+
225
+ ``touched_files`` is the set of JSON paths this tool recorded
226
+ ``merged_keys`` against. A JSON bridge is preserved only when it
227
+ was touched (shared with neighbour tools) AND subtraction left
228
+ foreign keys behind. Untouched JSON bridges are owned solely by
229
+ this tool and removed with the rest.
230
+ """
231
+ deleted: list[str] = []
232
+ skipped: list[str] = []
233
+ for record in entry.get("files") or []:
234
+ path = _resolve_recorded_path(project_root, record["path"])
235
+ kind = record.get("kind")
236
+ label = str(path)
237
+ if kind == "bridge":
238
+ # Shared JSON bridges with foreign keys are kept; otherwise
239
+ # the tool owns the file outright and we remove it.
240
+ is_shared_json = (
241
+ path.exists()
242
+ and path.suffix == ".json"
243
+ and label in touched_files
244
+ and label not in emptied_files
245
+ )
246
+ if is_shared_json:
247
+ skipped.append(f"bridge {label}: foreign keys preserved")
248
+ continue
249
+ if not path.exists():
250
+ skipped.append(f"bridge {label}: already absent")
251
+ continue
252
+ if dry_run:
253
+ deleted.append(f"would remove bridge {label}")
254
+ continue
255
+ try:
256
+ path.unlink()
257
+ deleted.append(f"removed bridge {label}")
258
+ except OSError as exc:
259
+ skipped.append(f"bridge {label}: ❌ {exc}")
260
+ elif kind == "marker":
261
+ if not path.exists():
262
+ skipped.append(f"marker {label}: already absent")
263
+ continue
264
+ if dry_run:
265
+ deleted.append(f"would remove marker {label}")
266
+ continue
267
+ try:
268
+ path.unlink()
269
+ deleted.append(f"removed marker {label}")
270
+ except OSError as exc:
271
+ skipped.append(f"marker {label}: ❌ {exc}")
272
+ elif kind == "deployed":
273
+ if not purge:
274
+ skipped.append(f"deployed {label}: preserved (pass --purge)")
275
+ continue
276
+ if not path.exists():
277
+ skipped.append(f"deployed {label}: already absent")
278
+ continue
279
+ if dry_run:
280
+ deleted.append(f"would purge deployed {label}")
281
+ continue
282
+ try:
283
+ if path.is_dir():
284
+ shutil.rmtree(path)
285
+ else:
286
+ path.unlink()
287
+ deleted.append(f"purged deployed {label}")
288
+ except OSError as exc:
289
+ skipped.append(f"deployed {label}: ❌ {exc}")
290
+ else:
291
+ skipped.append(f"{label}: unknown kind={kind!r}")
292
+ return deleted, skipped
293
+
294
+
295
+ def _parse(argv: list[str]) -> argparse.Namespace:
296
+ parser = argparse.ArgumentParser(
297
+ prog="agent-config uninstall",
298
+ description=(
299
+ "Remove agent-config bridge markers (project) or lockfile "
300
+ "entries (global). Idempotent. Pass --purge to also delete "
301
+ "deployed content directories."
302
+ ),
303
+ )
304
+ parser.add_argument("--global", dest="global_mode", action="store_true",
305
+ help="operate on user-scope lockfile (~/.config/agent-config/installed.lock)")
306
+ parser.add_argument("--tools", default=None,
307
+ help="comma-separated tool IDs to uninstall (default: all in lockfile)")
308
+ parser.add_argument("--project", default=None, help="project root (default: cwd)")
309
+ parser.add_argument("--dry-run", action="store_true",
310
+ help="show what would be removed; make no changes")
311
+ parser.add_argument("--purge", action="store_true",
312
+ help="also delete deployed content under user-scope anchors (destructive)")
313
+ parser.add_argument("--force", action="store_true",
314
+ help="proceed even if lockfile is absent (uninstall by tool list)")
315
+ return parser.parse_args(argv)
316
+
317
+
318
+ def _uninstall_project(opts: argparse.Namespace) -> int:
319
+ project_root = _resolve_project_root(opts.project)
320
+ manifest_path = installed_tools.manifest_path(project_root)
321
+ manifest = installed_tools.read_manifest(manifest_path)
322
+ if manifest is None and not opts.force:
323
+ print(f"❌ no project lockfile at {manifest_path}", file=sys.stderr)
324
+ print(" pass --force to uninstall by --tools=<list> without manifest", file=sys.stderr)
325
+ return 1
326
+ pool = [e.get("name", "") for e in (manifest.get("tools", []) if manifest else [])]
327
+ if not pool and opts.tools:
328
+ pool = [t.strip() for t in opts.tools.split(",") if t.strip()]
329
+ tools = _filter_tools(pool, opts.tools)
330
+ if not tools:
331
+ print("ℹ️ no tools to uninstall")
332
+ return 0
333
+ print(f"{'[dry-run] ' if opts.dry_run else ''}uninstalling {len(tools)} tool(s) from {project_root}:")
334
+
335
+ # --force path without a manifest falls straight to the legacy
336
+ # bridge-marker map; v2 inventories are not available off-manifest.
337
+ if manifest is None:
338
+ for tool in tools:
339
+ line, _ = _remove_project_marker(project_root, tool, dry_run=opts.dry_run)
340
+ print(f" · {line}")
341
+ return 0
342
+
343
+ version = manifest.get("agent_config_version", "")
344
+ deploy_roots = manifest.get("deploy_roots") or None
345
+ tool_entries = list(manifest.get("tools", []))
346
+ removed_names: list[str] = []
347
+
348
+ for tool in tools:
349
+ entry = next((e for e in tool_entries if e.get("name") == tool), None)
350
+ if entry is None:
351
+ # Tool requested but not in the manifest — legacy marker fallback.
352
+ line, removed = _remove_project_marker(
353
+ project_root, tool, dry_run=opts.dry_run,
354
+ )
355
+ print(f" · {line}")
356
+ if removed and not opts.dry_run:
357
+ removed_names.append(tool)
358
+ continue
359
+
360
+ if not _is_v2_entry(entry):
361
+ # v1 entry — keep the legacy single-marker behaviour.
362
+ line, removed = _remove_project_marker(
363
+ project_root, tool, dry_run=opts.dry_run,
364
+ )
365
+ print(f" · {line}")
366
+ if removed and not opts.dry_run:
367
+ removed_names.append(tool)
368
+ continue
369
+
370
+ files_n = len(entry.get("files") or [])
371
+ merges_n = len(entry.get("merged_keys") or [])
372
+ print(
373
+ f" · {tool}: v2 uninstall "
374
+ f"({files_n} file(s), {merges_n} merge pointer(s))"
375
+ )
376
+
377
+ # Phase 1: flag the entry as uninstalling so a crash here is
378
+ # recoverable by ``cmd_prune`` (P2.1).
379
+ if not opts.dry_run:
380
+ tool_entries = _set_tool_status(
381
+ manifest_path, version, tool_entries, tool, "uninstalling",
382
+ deploy_roots=deploy_roots,
383
+ )
384
+ entry = next(
385
+ (e for e in tool_entries if e.get("name") == tool), entry,
386
+ )
387
+
388
+ # Phase 2: subtract this tool's JSON merge contributions.
389
+ warnings, emptied, touched = _subtract_merged_keys(
390
+ entry, project_root, dry_run=opts.dry_run,
391
+ )
392
+ for w in warnings:
393
+ print(f" ⚠️ {w}")
394
+
395
+ # Phase 3: delete files[] entries — bridge files are kept when
396
+ # subtraction left foreign keys behind.
397
+ deleted, skipped = _delete_tool_files(
398
+ entry, project_root,
399
+ dry_run=opts.dry_run, purge=opts.purge,
400
+ emptied_files=emptied,
401
+ touched_files=touched,
402
+ )
403
+ for d in deleted:
404
+ print(f" ✓ {d}")
405
+ for s in skipped:
406
+ print(f" ↷ {s}")
407
+
408
+ if not opts.dry_run:
409
+ removed_names.append(tool)
410
+
411
+ # Phase 4: drop uninstalled entries; persist the manifest atomically.
412
+ if removed_names and not opts.dry_run:
413
+ remaining = [
414
+ e for e in tool_entries if e.get("name") not in removed_names
415
+ ]
416
+ installed_tools.write_manifest(
417
+ manifest_path, version, remaining, deploy_roots=deploy_roots,
418
+ )
419
+ print(f"✅ manifest updated ({len(removed_names)} entries removed)")
420
+ return 0
421
+
422
+
423
+ def _uninstall_global(opts: argparse.Namespace) -> int:
424
+ lock_path = installed_lock.lockfile_path()
425
+ lock = installed_lock.read_lockfile(lock_path)
426
+ if lock is None and not opts.force:
427
+ print(f"❌ no global lockfile at {lock_path}", file=sys.stderr)
428
+ return 1
429
+ pool = list(lock.get("tools", []) if lock else [])
430
+ if not pool and opts.tools:
431
+ pool = [t.strip() for t in opts.tools.split(",") if t.strip()]
432
+ tools = _filter_tools(pool, opts.tools)
433
+ if not tools:
434
+ print("ℹ️ no tools to uninstall")
435
+ return 0
436
+ print(f"{'[dry-run] ' if opts.dry_run else ''}uninstalling {len(tools)} tool(s) from global scope:")
437
+ removed_names: list[str] = []
438
+ for tool in tools:
439
+ line, removed = _remove_global_content(tool, dry_run=opts.dry_run, purge=opts.purge)
440
+ print(f" · {line}")
441
+ if removed and not opts.dry_run:
442
+ removed_names.append(tool)
443
+ if lock is not None and not opts.dry_run:
444
+ remaining = [t for t in lock.get("tools", []) if t not in tools]
445
+ if remaining:
446
+ installed_lock.write_lockfile(remaining, version=lock.get("agent_config_version", ""))
447
+ print(f"✅ lockfile updated ({len(tools)} entries removed, {len(remaining)} kept)")
448
+ else:
449
+ try:
450
+ lock_path.unlink()
451
+ print(f"✅ lockfile deleted ({lock_path})")
452
+ except OSError as exc:
453
+ print(f"⚠️ could not delete lockfile: {exc}")
454
+ return 0
455
+
456
+
457
+ def main(argv: list[str] | None = None) -> int:
458
+ opts = _parse(list(argv) if argv is not None else sys.argv[1:])
459
+ if opts.global_mode:
460
+ return _uninstall_global(opts)
461
+ return _uninstall_project(opts)
462
+
463
+
464
+ if __name__ == "__main__": # pragma: no cover
465
+ raise SystemExit(main())
@@ -27,6 +27,7 @@ from __future__ import annotations
27
27
 
28
28
  import argparse
29
29
  import json
30
+ import os
30
31
  import re
31
32
  import subprocess
32
33
  import sys
@@ -158,21 +159,40 @@ def main(
158
159
  help="Print the latest available version and exit. No file is written.")
159
160
  parser.add_argument("--to", metavar="VERSION",
160
161
  help="Pin to an explicit version (registry-existence checked).")
162
+ parser.add_argument("--offline", action="store_true",
163
+ help="Skip the npm registry check; requires --to <version> "
164
+ "(without --to there is no source for 'latest').")
161
165
  args = parser.parse_args(argv)
162
166
 
163
167
  cwd = (cwd or Path.cwd()).resolve()
164
168
  installed_version = installed_version or _detect_installed_version()
165
169
  state_path = state_path or update_check.DEFAULT_STATE_PATH
166
170
 
171
+ # AGENT_CONFIG_OFFLINE=1 (set by `install.py --offline`) is honored
172
+ # as an env-level kill-switch. Mirrors cmd_versions.py.
173
+ offline = args.offline or os.environ.get("AGENT_CONFIG_OFFLINE") == "1"
174
+
175
+ if offline and not args.to:
176
+ print(
177
+ "❌ agent-config: --offline requires --to <version> "
178
+ "(no registry, no 'latest' to fetch).",
179
+ file=err,
180
+ )
181
+ return 1
182
+
167
183
  if args.to:
168
184
  target = _normalize(args.to)
169
- if not version_checker(target):
185
+ if offline:
186
+ # Trust the caller; air-gapped env can't reach the registry.
187
+ latest = target
188
+ elif not version_checker(target):
170
189
  print(
171
190
  f"❌ agent-config: version {target} not found on the npm registry.",
172
191
  file=err,
173
192
  )
174
193
  return 1
175
- latest = target
194
+ else:
195
+ latest = target
176
196
  else:
177
197
  latest = fetcher()
178
198
  if not latest:
@@ -203,7 +223,10 @@ def main(
203
223
  else:
204
224
  print(f"ℹ️ {rel} already pins to {latest}.", file=out)
205
225
 
206
- cache_warmer(latest)
226
+ # `npx --yes <pkg>@<v> --version` would hit the registry; skip it
227
+ # offline so the air-gap guarantee holds end-to-end.
228
+ if not offline:
229
+ cache_warmer(latest)
207
230
  _refresh_state(latest, latest, state_path)
208
231
  _refresh_global_lockfile(latest, out=out)
209
232
  return 0
@@ -0,0 +1,147 @@
1
+ """``agent-config versions`` — list available package versions (Phase 4.1).
2
+
3
+ Queries the npm registry for available versions of
4
+ ``@event4u/agent-config`` and prints them. Marks the current pin
5
+ (from ``.agent-settings.yml`` ``agent_config_version``) and the latest
6
+ published version.
7
+
8
+ Offline-tolerant: when ``--offline`` is passed or the registry is
9
+ unreachable, falls back to reading the local ``package.json`` version
10
+ and prints a single-line notice instead of failing.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import subprocess
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ PACKAGE_NAME = "@event4u/agent-config"
22
+
23
+
24
+ def _local_package_version() -> str:
25
+ """Return ``version`` from the local ``package.json``, or ``""`` if absent."""
26
+ candidates = [
27
+ Path(__file__).resolve().parents[2] / "package.json",
28
+ Path.cwd() / "package.json",
29
+ ]
30
+ for p in candidates:
31
+ if p.exists():
32
+ try:
33
+ return str(json.loads(p.read_text()).get("version", ""))
34
+ except (json.JSONDecodeError, OSError):
35
+ continue
36
+ return ""
37
+
38
+
39
+ def _pinned_version() -> str:
40
+ """Return the ``agent_config_version`` pin from ``.agent-settings.yml``."""
41
+ settings = Path.cwd() / ".agent-settings.yml"
42
+ if not settings.exists():
43
+ return ""
44
+ try:
45
+ for line in settings.read_text(encoding="utf-8").splitlines():
46
+ line = line.strip()
47
+ if line.startswith("agent_config_version"):
48
+ _, _, rhs = line.partition(":")
49
+ return rhs.strip().strip('"').strip("'")
50
+ except OSError:
51
+ pass
52
+ return ""
53
+
54
+
55
+ def _query_npm() -> list[str]:
56
+ """Run ``npm view <pkg> versions --json``; return parsed list or ``[]``."""
57
+ try:
58
+ proc = subprocess.run(
59
+ ["npm", "view", PACKAGE_NAME, "versions", "--json"],
60
+ capture_output=True, text=True, timeout=15,
61
+ )
62
+ except (FileNotFoundError, subprocess.TimeoutExpired):
63
+ return []
64
+ if proc.returncode != 0:
65
+ return []
66
+ try:
67
+ data = json.loads(proc.stdout)
68
+ except json.JSONDecodeError:
69
+ return []
70
+ if isinstance(data, str):
71
+ return [data]
72
+ if isinstance(data, list):
73
+ return [str(v) for v in data]
74
+ return []
75
+
76
+
77
+ def _format_table(versions: list[str], current: str, pinned: str, limit: int) -> str:
78
+ rows: list[str] = []
79
+ head = versions[-limit:] if limit > 0 else versions
80
+ for v in head:
81
+ marks = []
82
+ if v == pinned:
83
+ marks.append("← pinned")
84
+ if v == current:
85
+ marks.append("← latest")
86
+ suffix = (" " + " ".join(marks)) if marks else ""
87
+ rows.append(f" {v}{suffix}")
88
+ return "\n".join(rows)
89
+
90
+
91
+ def _parse(argv: list[str]) -> argparse.Namespace:
92
+ parser = argparse.ArgumentParser(
93
+ prog="agent-config versions",
94
+ description="List available @event4u/agent-config versions on npm.",
95
+ )
96
+ parser.add_argument("--offline", action="store_true",
97
+ help="skip npm registry query; only show local package + pin")
98
+ parser.add_argument("--limit", type=int, default=20,
99
+ help="show only the N most recent versions (default: 20; 0 = all)")
100
+ parser.add_argument("--json", dest="as_json", action="store_true",
101
+ help="machine-readable output: {pinned, local, latest, versions[]}")
102
+ return parser.parse_args(argv)
103
+
104
+
105
+ def main(argv: list[str] | None = None) -> int:
106
+ opts = _parse(list(argv) if argv is not None else sys.argv[1:])
107
+
108
+ # AGENT_CONFIG_OFFLINE=1 (set by `install.py --offline`) is honored
109
+ # as a global kill-switch even when the per-command --offline flag
110
+ # is absent. Keeps the env-driven offline contract consistent.
111
+ offline = opts.offline or os.environ.get("AGENT_CONFIG_OFFLINE") == "1"
112
+
113
+ local = _local_package_version()
114
+ pinned = _pinned_version()
115
+ versions: list[str] = []
116
+ if not offline:
117
+ versions = _query_npm()
118
+ latest = versions[-1] if versions else local
119
+
120
+ if opts.as_json:
121
+ print(json.dumps({
122
+ "pinned": pinned,
123
+ "local": local,
124
+ "latest": latest,
125
+ "versions": versions,
126
+ "source": "npm" if versions else "local",
127
+ }, indent=2))
128
+ return 0
129
+
130
+ print(f"package: {PACKAGE_NAME}")
131
+ print(f"pinned: {pinned or '— (no .agent-settings.yml)'}")
132
+ print(f"local: {local or '—'}")
133
+ if not versions:
134
+ if offline:
135
+ print("offline mode — registry query skipped")
136
+ else:
137
+ print("⚠️ npm registry unreachable; showing local only")
138
+ return 0
139
+ print(f"latest: {latest}")
140
+ print()
141
+ print(f"available versions ({'last ' + str(opts.limit) if opts.limit > 0 else 'all'}):")
142
+ print(_format_table(versions, latest, pinned, opts.limit))
143
+ return 0
144
+
145
+
146
+ if __name__ == "__main__": # pragma: no cover
147
+ raise SystemExit(main())