@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,116 @@
1
+ """Atomic file-write primitive shared by lockfile-schema-v2 writers.
2
+
3
+ P1.0 of road-to-multi-package-coexistence. Single source of truth for
4
+ crash-safe writes used by ``installed_tools.write_manifest``,
5
+ P1.5 merge tracking, P2.2 uninstall, and P3.x conflict-resolution
6
+ paths. Centralising the mechanism prevents per-phase implementation
7
+ drift (Council amendment, Anthropic 2026-05-12).
8
+
9
+ Guarantees, in order:
10
+
11
+ 1. Write to ``<path>.tmp.<pid>.<rand>`` in the same directory as the
12
+ target. Same-directory keeps the final ``os.replace`` atomic on
13
+ every POSIX filesystem we support; cross-fs renames are not atomic.
14
+ 2. ``fsync(tmp_fd)`` flushes the file's data + metadata to disk before
15
+ we let the temp file become the visible target.
16
+ 3. ``os.replace(tmp, path)`` is the atomic rename. Either the old or
17
+ the new content is visible to readers; never a half-written mix.
18
+ 4. ``fsync(parent_dir_fd)`` durably commits the directory entry so a
19
+ crash immediately after the rename does not resurrect the old file
20
+ on next boot. Skipped on platforms where directory fsync is
21
+ unsupported (Windows) — the rename is still atomic from the
22
+ filesystem's perspective, only durability across power loss is
23
+ weaker there.
24
+
25
+ The temp file is always cleaned up on failure, so a raise mid-write
26
+ never leaves orphaned ``.tmp.*`` siblings behind.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ import tempfile
32
+ from pathlib import Path
33
+ from typing import Union
34
+
35
+ __all__ = ["write_atomic"]
36
+
37
+
38
+ def write_atomic(
39
+ path: Union[str, Path],
40
+ data: Union[str, bytes],
41
+ *,
42
+ encoding: str = "utf-8",
43
+ ) -> Path:
44
+ """Atomically write ``data`` to ``path``; return the resolved path.
45
+
46
+ ``data`` may be ``str`` (encoded via ``encoding``) or ``bytes``
47
+ (written verbatim, ``encoding`` ignored). The parent directory is
48
+ created if missing — callers don't have to ``mkdir`` beforehand.
49
+
50
+ On failure (any exception raised by the OS during write / fsync /
51
+ rename), the temporary file is unlinked and the original target —
52
+ if any — is untouched. The exception propagates so callers can
53
+ distinguish disk-full from permission errors etc.
54
+ """
55
+ target = Path(path)
56
+ target.parent.mkdir(parents=True, exist_ok=True)
57
+
58
+ if isinstance(data, str):
59
+ payload = data.encode(encoding)
60
+ elif isinstance(data, (bytes, bytearray)):
61
+ payload = bytes(data)
62
+ else:
63
+ raise TypeError(
64
+ f"write_atomic: data must be str or bytes, got {type(data).__name__}"
65
+ )
66
+
67
+ fd, tmp_name = tempfile.mkstemp(
68
+ prefix=f".{target.name}.tmp.",
69
+ dir=str(target.parent),
70
+ )
71
+ tmp_path = Path(tmp_name)
72
+ try:
73
+ with os.fdopen(fd, "wb") as fh:
74
+ fh.write(payload)
75
+ fh.flush()
76
+ try:
77
+ os.fsync(fh.fileno())
78
+ except OSError:
79
+ # File-level fsync unsupported (e.g. some tmpfs).
80
+ # Continue — os.replace is still atomic.
81
+ pass
82
+ os.replace(tmp_path, target)
83
+ except Exception:
84
+ try:
85
+ tmp_path.unlink()
86
+ except OSError:
87
+ pass
88
+ raise
89
+
90
+ _fsync_dir(target.parent)
91
+ return target
92
+
93
+
94
+ def _fsync_dir(directory: Path) -> None:
95
+ """Best-effort directory fsync; silent no-op on unsupported platforms.
96
+
97
+ Directory fsync is required on POSIX for the rename's durability
98
+ across power loss. Windows does not expose ``open(dir)`` /
99
+ ``fsync(dir_fd)`` semantics — the kernel commits the directory
100
+ entry implicitly. We swallow the OSError there rather than fail
101
+ the write.
102
+ """
103
+ try:
104
+ dir_fd = os.open(str(directory), os.O_RDONLY)
105
+ except OSError:
106
+ return
107
+ try:
108
+ try:
109
+ os.fsync(dir_fd)
110
+ except OSError:
111
+ # Some filesystems / mounts reject directory fsync.
112
+ # The rename is still atomic — durability is weaker but
113
+ # the write is not corrupted.
114
+ pass
115
+ finally:
116
+ os.close(dir_fd)
@@ -21,17 +21,46 @@ from __future__ import annotations
21
21
 
22
22
  import os
23
23
  import re
24
- import tempfile
25
24
  from datetime import datetime, timezone
26
25
  from pathlib import Path
27
26
  from typing import Any, Optional
28
27
 
28
+ try:
29
+ from scripts._lib.fs_atomic import write_atomic # noqa: PLC0415
30
+ except ImportError: # pragma: no cover — alt sys.path layout (tests)
31
+ from _lib.fs_atomic import write_atomic # type: ignore[no-redef] # noqa: PLC0415
32
+
29
33
  MANIFEST_ENV = "AGENT_CONFIG_INSTALLED_TOOLS"
30
34
  DEFAULT_MANIFEST_RELATIVE = Path("agents") / "installed-tools.lock"
31
- SCHEMA_VERSION = 1
35
+ SCHEMA_VERSION = 2
36
+
37
+ #: Schema versions older writers may have emitted. Reading any of these
38
+ #: succeeds; writing always produces :data:`SCHEMA_VERSION`.
39
+ SCHEMA_VERSIONS_SUPPORTED = (1, 2)
32
40
 
33
41
  _VALID_SCOPES = ("global", "project")
34
42
 
43
+ #: Permitted values for ``files[].kind`` (P1.1, road-to-multi-package-
44
+ #: coexistence). ``bridge`` = team-pointer marker (e.g. ``.cursorrules``);
45
+ #: ``deployed`` = bundle content we wrote (e.g. ``.augment/rules/*.md``);
46
+ #: ``marker`` = one-off sentinel (e.g. ``claude-desktop`` install marker).
47
+ FILE_KINDS = frozenset({"bridge", "deployed", "marker"})
48
+
49
+ #: Stable known deploy roots — directories under which the doctor
50
+ #: command surveys for foreign files. Writers may extend the live
51
+ #: ``deploy_roots`` field per project; this constant is the canonical
52
+ #: default the installer seeds.
53
+ DEFAULT_DEPLOY_ROOTS = (
54
+ ".augment/rules",
55
+ ".augment/skills",
56
+ ".augment/commands",
57
+ ".cursor/rules",
58
+ ".claude/skills",
59
+ ".claude/commands",
60
+ ".clinerules",
61
+ ".windsurf/rules",
62
+ )
63
+
35
64
 
36
65
  def manifest_path(project_root: Path, env: Optional[dict] = None) -> Path:
37
66
  """Return the active manifest path, honoring the env override."""
@@ -56,31 +85,71 @@ def read_manifest(path: Path) -> Optional[dict[str, Any]]:
56
85
 
57
86
  Tolerates partial / malformed files: missing keys yield missing dict
58
87
  entries rather than raising, so a corrupted file does not brick
59
- ``init``.
88
+ ``init``. v1 and v2 wire formats both return the same shape — v2
89
+ optional fields (``deploy_roots``, per-tool ``files`` /
90
+ ``merged_keys``) default to empty lists when absent, so callers can
91
+ iterate without ``.get(..., [])`` boilerplate (P1.2).
60
92
  """
61
93
  try:
62
94
  text = path.read_text(encoding="utf-8")
63
95
  except (FileNotFoundError, OSError):
64
96
  return None
97
+ data: Optional[dict[str, Any]] = None
65
98
  try:
66
99
  import yaml # type: ignore[import-untyped]
67
- data = yaml.safe_load(text) or {}
68
- if isinstance(data, dict):
69
- data.setdefault("tools", [])
70
- return data
100
+ loaded = yaml.safe_load(text) or {}
101
+ if isinstance(loaded, dict):
102
+ data = loaded
71
103
  except ImportError:
72
104
  pass
73
105
  except Exception:
74
106
  # Fall through to the manual parser; corrupt YAML is recoverable
75
107
  # from our strict schema as long as the top-level shape holds.
76
108
  pass
77
- return _parse_manual(text)
109
+ if data is None:
110
+ data = _parse_manual(text)
111
+ return _normalise_v2_shape(data)
112
+
113
+
114
+ def _normalise_v2_shape(data: dict[str, Any]) -> dict[str, Any]:
115
+ """Backfill v2 optional fields so consumers can iterate uniformly.
116
+
117
+ Idempotent: calling on an already-normalised dict is a no-op. Does
118
+ not mutate input lists — replaces missing keys with fresh empties.
119
+ """
120
+ if data.get("tools") is None:
121
+ data["tools"] = []
122
+ if data.get("deploy_roots") is None:
123
+ data["deploy_roots"] = []
124
+ for tool in data["tools"]:
125
+ if not isinstance(tool, dict):
126
+ continue
127
+ if tool.get("files") is None:
128
+ tool["files"] = []
129
+ if tool.get("merged_keys") is None:
130
+ tool["merged_keys"] = []
131
+ return data
78
132
 
79
133
 
80
134
  def _parse_manual(text: str) -> dict[str, Any]:
135
+ """Strict v1 manual parser; v2 nested fields are skipped, not raised.
136
+
137
+ The fallback parser handles the canonical v1 wire format (top-level
138
+ scalars + ``tools`` array with single-level key:value entries). For
139
+ v2 manifests it still extracts the top-level scalars and the
140
+ per-tool scalar fields, but silently drops nested arrays
141
+ (``files``, ``merged_keys``) and top-level ``deploy_roots``. Callers
142
+ that need full v2 fidelity must have ``pyyaml`` available — the
143
+ manual path is a degraded read, not a v2 round-trip.
144
+ """
81
145
  data: dict[str, Any] = {"tools": []}
82
146
  in_tools = False
83
147
  current: Optional[dict[str, Any]] = None
148
+ # When the current tool entry opened a nested array (``files:`` or
149
+ # ``merged_keys:``), we suppress recognition of the deeper ``- key``
150
+ # lines as new tools until the indent climbs back to the per-tool
151
+ # level (4 spaces).
152
+ skip_until_outdent = False
84
153
  for raw in text.splitlines():
85
154
  stripped = raw.strip()
86
155
  if not stripped or stripped.startswith("#"):
@@ -88,25 +157,39 @@ def _parse_manual(text: str) -> dict[str, Any]:
88
157
  if stripped == "tools:":
89
158
  in_tools = True
90
159
  current = None
160
+ skip_until_outdent = False
91
161
  continue
92
162
  if in_tools:
163
+ indent = len(raw) - len(raw.lstrip(" "))
164
+ if skip_until_outdent and indent > 4:
165
+ continue
166
+ skip_until_outdent = False
93
167
  m = _LIST_DASH_RE.match(raw)
94
- if m:
168
+ if m and indent == 2:
95
169
  first = m.group(1)
96
170
  current = {}
97
171
  data["tools"].append(current)
98
- # Could be inline like `- name: foo` — handle that.
99
172
  inline = _TOP_KEY_RE.match(first)
100
173
  if inline:
101
174
  current[inline.group(1)] = inline.group(2)
102
175
  continue
103
176
  mk = _INDENT_KEY_RE.match(raw)
104
- if mk and current is not None:
105
- current[mk.group(1)] = mk.group(2)
177
+ if mk and current is not None and indent == 4:
178
+ key, val = mk.group(1), mk.group(2)
179
+ if key in ("files", "merged_keys") and not val:
180
+ skip_until_outdent = True
181
+ continue
182
+ current[key] = val
106
183
  continue
107
184
  m_top = _TOP_KEY_RE.match(raw)
108
185
  if m_top:
109
186
  key, value = m_top.group(1), m_top.group(2)
187
+ if key == "deploy_roots" and not value:
188
+ # Top-level v2 array — skip until next top-level scalar.
189
+ in_tools = False
190
+ current = None
191
+ skip_until_outdent = True
192
+ continue
110
193
  if key == "schema_version":
111
194
  try:
112
195
  data[key] = int(value)
@@ -116,6 +199,7 @@ def _parse_manual(text: str) -> dict[str, Any]:
116
199
  data[key] = value
117
200
  in_tools = False
118
201
  current = None
202
+ skip_until_outdent = False
119
203
  return data
120
204
 
121
205
 
@@ -127,17 +211,54 @@ def _parse_manual(text: str) -> dict[str, Any]:
127
211
  def _render(
128
212
  version: str,
129
213
  tools: list[dict[str, Any]],
214
+ *,
215
+ deploy_roots: Optional[list[str]] = None,
130
216
  ) -> str:
131
217
  lines = [
132
218
  f"schema_version: {SCHEMA_VERSION}",
133
219
  f'agent_config_version: "{version}"',
134
- "tools:",
135
220
  ]
221
+ if deploy_roots:
222
+ lines.append("deploy_roots:")
223
+ for root in deploy_roots:
224
+ lines.append(f" - {root}")
225
+ lines.append("tools:")
136
226
  for tool in tools:
137
227
  lines.append(f" - name: {tool['name']}")
138
228
  lines.append(f" scope: {tool['scope']}")
139
229
  lines.append(f" bridge_marker: {tool['bridge_marker']}")
140
230
  lines.append(f' installed_at: "{tool["installed_at"]}"')
231
+ status = tool.get("status")
232
+ if status:
233
+ lines.append(f" status: {status}")
234
+ files = tool.get("files") or []
235
+ if files:
236
+ # Sort by path ascending — deterministic output for golden-
237
+ # file tests and stable team diffs (P1.3).
238
+ files = sorted(files, key=lambda f: f["path"])
239
+ lines.append(" files:")
240
+ for entry in files:
241
+ lines.append(f" - path: {entry['path']}")
242
+ lines.append(f" kind: {entry['kind']}")
243
+ sha = entry.get("sha256")
244
+ if sha is None:
245
+ lines.append(" sha256: null")
246
+ else:
247
+ lines.append(f' sha256: "{sha}"')
248
+ merged = tool.get("merged_keys") or []
249
+ if merged:
250
+ # Sort by (file, json_pointer) ascending — deterministic
251
+ # output regardless of insertion order (P1.3).
252
+ merged = sorted(
253
+ merged, key=lambda e: (e["file"], e["json_pointer"]),
254
+ )
255
+ lines.append(" merged_keys:")
256
+ for entry in merged:
257
+ lines.append(f" - file: {entry['file']}")
258
+ lines.append(f" json_pointer: \"{entry['json_pointer']}\"")
259
+ vh = entry.get("value_hash")
260
+ if vh is not None:
261
+ lines.append(f' value_hash: "{vh}"')
141
262
  return "\n".join(lines) + "\n"
142
263
 
143
264
 
@@ -145,24 +266,23 @@ def write_manifest(
145
266
  path: Path,
146
267
  version: str,
147
268
  tools: list[dict[str, Any]],
269
+ *,
270
+ deploy_roots: Optional[list[str]] = None,
148
271
  ) -> Path:
149
- """Atomically write the manifest; return the path written."""
150
- path.parent.mkdir(parents=True, exist_ok=True)
151
- rendered = _render(version, tools)
152
- fd, tmp_name = tempfile.mkstemp(
153
- prefix=".installed-tools.lock.", dir=str(path.parent), text=False
154
- )
155
- try:
156
- with os.fdopen(fd, "w", encoding="utf-8") as fh:
157
- fh.write(rendered)
158
- os.replace(tmp_name, path)
159
- except Exception:
160
- try:
161
- os.unlink(tmp_name)
162
- except OSError:
163
- pass
164
- raise
165
- return path
272
+ """Atomically write the manifest; return the path written.
273
+
274
+ Delegates to :func:`scripts._lib.fs_atomic.write_atomic` so the
275
+ crash-safety guarantees (fsync file, atomic rename, fsync parent
276
+ dir) are shared with every other v2 writer. See P1.0 of
277
+ ``road-to-multi-package-coexistence`` for the rationale.
278
+
279
+ ``deploy_roots`` is the optional top-level v2 field listing
280
+ directories the doctor command surveys for foreign files. When
281
+ omitted, the field is not emitted (callers may rely on
282
+ :data:`DEFAULT_DEPLOY_ROOTS` for the survey scope).
283
+ """
284
+ rendered = _render(version, tools, deploy_roots=deploy_roots)
285
+ return write_atomic(path, rendered)
166
286
 
167
287
 
168
288
  # ---------------------------------------------------------------------------
@@ -191,6 +311,8 @@ def upsert_tool(
191
311
  bridge_marker: str,
192
312
  installed_at: Optional[str] = None,
193
313
  force: bool = False,
314
+ files: Optional[list[dict[str, Any]]] = None,
315
+ merged_keys: Optional[list[dict[str, Any]]] = None,
194
316
  ) -> list[dict[str, Any]]:
195
317
  """Return a new tools list with ``name`` added or refreshed.
196
318
 
@@ -199,10 +321,34 @@ def upsert_tool(
199
321
  * Same name, different scope → raise ``ScopeMismatchError`` unless
200
322
  ``force=True``, in which case the entry is rewritten.
201
323
  * New name → appended in install order (not alphabetised).
324
+
325
+ ``files`` / ``merged_keys`` are the v2 per-tool inventories
326
+ (P1.4). When provided, they replace whatever was previously
327
+ recorded on the entry — the installer is authoritative for the
328
+ set of artefacts it just wrote. When ``None``, existing values
329
+ are preserved on the idempotent path and absent on first-write.
202
330
  """
203
331
  if scope not in _VALID_SCOPES:
204
332
  raise ValueError(f"scope must be one of {_VALID_SCOPES}: {scope!r}")
205
333
  stamp = installed_at or _today()
334
+
335
+ def _build(prior: Optional[dict[str, Any]] = None) -> dict[str, Any]:
336
+ entry: dict[str, Any] = {
337
+ "name": name,
338
+ "scope": scope,
339
+ "bridge_marker": bridge_marker,
340
+ "installed_at": stamp,
341
+ }
342
+ if files is not None:
343
+ entry["files"] = list(files)
344
+ elif prior is not None and prior.get("files"):
345
+ entry["files"] = list(prior["files"])
346
+ if merged_keys is not None:
347
+ entry["merged_keys"] = list(merged_keys)
348
+ elif prior is not None and prior.get("merged_keys"):
349
+ entry["merged_keys"] = list(prior["merged_keys"])
350
+ return entry
351
+
206
352
  result: list[dict[str, Any]] = []
207
353
  found = False
208
354
  for entry in existing:
@@ -210,26 +356,24 @@ def upsert_tool(
210
356
  found = True
211
357
  recorded = str(entry.get("scope", ""))
212
358
  if recorded == scope:
213
- # Idempotent no-op preserve original installed_at.
214
- result.append(entry)
359
+ if files is None and merged_keys is None:
360
+ # Idempotent no-op — preserve original entry.
361
+ result.append(entry)
362
+ else:
363
+ # Refresh inventories, preserve installed_at.
364
+ refreshed = _build(prior=entry)
365
+ refreshed["installed_at"] = entry.get(
366
+ "installed_at", stamp,
367
+ )
368
+ result.append(refreshed)
215
369
  continue
216
370
  if not force:
217
371
  raise ScopeMismatchError(name, recorded, scope)
218
- result.append({
219
- "name": name,
220
- "scope": scope,
221
- "bridge_marker": bridge_marker,
222
- "installed_at": stamp,
223
- })
372
+ result.append(_build(prior=entry))
224
373
  continue
225
374
  result.append(entry)
226
375
  if not found:
227
- result.append({
228
- "name": name,
229
- "scope": scope,
230
- "bridge_marker": bridge_marker,
231
- "installed_at": stamp,
232
- })
376
+ result.append(_build())
233
377
  return result
234
378
 
235
379