@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.
- package/.agent-src/rules/external-reference-deep-dive.md +69 -0
- package/.agent-src/templates/copilot-instructions.md +7 -0
- package/.claude-plugin/marketplace.json +27 -1
- package/CHANGELOG.md +57 -0
- package/README.md +1 -8
- package/docs/architecture.md +1 -1
- package/docs/contracts/installed-tools-lockfile.md +138 -0
- package/docs/development.md +37 -0
- package/docs/getting-started.md +1 -1
- package/docs/installation.md +14 -0
- package/docs/setup/per-ide/antigravity.md +63 -0
- package/docs/setup/per-ide/augment.md +77 -0
- package/docs/setup/per-ide/codebuddy.md +63 -0
- package/docs/setup/per-ide/continue.md +68 -0
- package/docs/setup/per-ide/droid.md +65 -0
- package/docs/setup/per-ide/jetbrains.md +76 -0
- package/docs/setup/per-ide/kilocode.md +66 -0
- package/docs/setup/per-ide/kiro.md +72 -0
- package/docs/setup/per-ide/opencode.md +62 -0
- package/docs/setup/per-ide/qoder.md +63 -0
- package/docs/setup/per-ide/roocode.md +68 -0
- package/docs/setup/per-ide/trae.md +63 -0
- package/docs/setup/per-ide/warp.md +63 -0
- package/docs/setup/per-ide/zed.md +73 -0
- package/package.json +1 -1
- package/scripts/_cli/cmd_doctor.py +351 -0
- package/scripts/_cli/cmd_prune.py +317 -0
- package/scripts/_cli/cmd_uninstall.py +465 -0
- package/scripts/_cli/cmd_update.py +26 -3
- package/scripts/_cli/cmd_versions.py +147 -0
- package/scripts/_lib/fs_atomic.py +116 -0
- package/scripts/_lib/installed_tools.py +188 -44
- package/scripts/_lib/json_pointers.py +260 -0
- package/scripts/agent-config +69 -0
- package/scripts/compress.py +78 -15
- package/scripts/install +15 -6
- package/scripts/install-hooks.sh +54 -1
- 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 =
|
|
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
|
-
|
|
68
|
-
if isinstance(
|
|
69
|
-
data
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
|