@event4u/agent-config 2.2.2 → 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 +49 -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 +8 -0
- package/scripts/install-hooks.sh +54 -1
- package/scripts/install.py +1053 -51
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""``agent-config prune`` — remove orphaned project bridge markers.
|
|
2
|
+
|
|
3
|
+
Sibling to ``uninstall``: where ``uninstall`` removes bridges for an
|
|
4
|
+
explicit tool list, ``prune`` removes every bridge marker present on
|
|
5
|
+
disk that is **not** declared in ``agents/installed-tools.lock``. Mirrors
|
|
6
|
+
the ``npm prune`` / ``cargo prune`` convention — the lockfile is the
|
|
7
|
+
source of truth; anything else is drift.
|
|
8
|
+
|
|
9
|
+
Scope: project only. Global pruning would touch user anchor dirs
|
|
10
|
+
(``~/.claude/``, ``~/.cursor/``…) that may contain unrelated user
|
|
11
|
+
content; the safer surface there is ``uninstall --global --purge``.
|
|
12
|
+
|
|
13
|
+
Hard Floor: refuses to operate without a lockfile (would otherwise
|
|
14
|
+
delete every bridge it finds). Pass ``--all-missing-lock`` to opt into
|
|
15
|
+
that behaviour explicitly.
|
|
16
|
+
|
|
17
|
+
Schema v2 (P2.1): when the manifest carries per-tool ``files[]``
|
|
18
|
+
inventories, prune enumerates them in addition to the legacy
|
|
19
|
+
``PROJECT_BRIDGE_MARKERS`` disk scan. Files whose owning tool has
|
|
20
|
+
``status: uninstalling`` (forward-compat for P2.2 two-phase uninstall)
|
|
21
|
+
are surfaced as orphans even when the tool entry still exists. Manifests
|
|
22
|
+
without ``files[]`` fall back to the v1 disk-scan path unchanged.
|
|
23
|
+
|
|
24
|
+
Drift detection (P2.3): orphaned files with a recorded ``sha256`` are
|
|
25
|
+
hashed before deletion. A mismatch flags the file as **modified** —
|
|
26
|
+
prune surfaces the path and skips removal so user / neighbour-tool
|
|
27
|
+
edits to deployed content survive the prune sweep. Files without a
|
|
28
|
+
recorded hash (bridges) skip the check and prune normally.
|
|
29
|
+
|
|
30
|
+
Resume-uninstall (P2.2): ``--resume-uninstall`` narrows prune to the
|
|
31
|
+
crash-recovery scope — only files belonging to tools with
|
|
32
|
+
``status: uninstalling`` are surfaced. The legacy disk scan is skipped
|
|
33
|
+
and healthy tools / unmanaged drift are untouched. Intended for
|
|
34
|
+
re-running after an uninstall crashed mid-flight; no-op when no tool
|
|
35
|
+
is in the ``uninstalling`` state.
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import argparse
|
|
40
|
+
import hashlib
|
|
41
|
+
import json
|
|
42
|
+
import sys
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
from scripts._lib import installed_tools
|
|
46
|
+
from scripts.install import PROJECT_BRIDGE_MARKERS
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _resolve_project_root(arg: str | None) -> Path:
|
|
50
|
+
if arg:
|
|
51
|
+
return Path(arg).expanduser().resolve()
|
|
52
|
+
return Path.cwd().resolve()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _load_manifest(project_root: Path, *, force_empty: bool
|
|
56
|
+
) -> tuple[dict | None, set[str] | None]:
|
|
57
|
+
"""Return ``(manifest, declared)``.
|
|
58
|
+
|
|
59
|
+
``declared`` is the set of project-scope tool names whose entry is
|
|
60
|
+
healthy (``status`` absent or ``installed``). When the manifest is
|
|
61
|
+
missing, returns ``(None, set())`` if ``force_empty`` else
|
|
62
|
+
``(None, None)``.
|
|
63
|
+
"""
|
|
64
|
+
manifest_path = installed_tools.manifest_path(project_root)
|
|
65
|
+
manifest = installed_tools.read_manifest(manifest_path)
|
|
66
|
+
if manifest is None:
|
|
67
|
+
if force_empty:
|
|
68
|
+
return None, set()
|
|
69
|
+
return None, None
|
|
70
|
+
tools = manifest.get("tools") or []
|
|
71
|
+
declared = {
|
|
72
|
+
str(e.get("name", ""))
|
|
73
|
+
for e in tools
|
|
74
|
+
if e.get("name")
|
|
75
|
+
and e.get("scope") == "project"
|
|
76
|
+
and e.get("status", "installed") == "installed"
|
|
77
|
+
}
|
|
78
|
+
return manifest, declared
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_path(project_root: Path, raw: str) -> Path:
|
|
82
|
+
"""Expand a manifest path (repo-relative or absolute / ``~``-prefixed)."""
|
|
83
|
+
p = Path(raw).expanduser()
|
|
84
|
+
if not p.is_absolute():
|
|
85
|
+
p = project_root / p
|
|
86
|
+
return p
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _sha256(path: Path) -> str | None:
|
|
90
|
+
"""Hex SHA-256 of ``path`` content, or ``None`` if unreadable.
|
|
91
|
+
|
|
92
|
+
Mirrors :func:`scripts.install._sha256_of_file` without taking the
|
|
93
|
+
import dependency on the installer module beyond what already
|
|
94
|
+
exists. Drift detection (P2.3) calls this for every orphan with a
|
|
95
|
+
recorded hash.
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
99
|
+
except OSError:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _orphaned(project_root: Path, manifest: dict | None, declared: set[str],
|
|
104
|
+
*, resume_uninstall: bool = False,
|
|
105
|
+
) -> list[tuple[str, Path, str | None, str | None]]:
|
|
106
|
+
"""Return ``[(tool_id, target_path, kind, expected_sha256), …]``.
|
|
107
|
+
|
|
108
|
+
``kind`` is ``"bridge"`` / ``"marker"`` / ``"deployed"`` for v2
|
|
109
|
+
entries, ``None`` for the legacy disk scan (which has no manifest
|
|
110
|
+
context). ``expected_sha256`` is the hash recorded at install time
|
|
111
|
+
and is used by the drift detector (P2.3); ``None`` skips the check.
|
|
112
|
+
|
|
113
|
+
Two sources:
|
|
114
|
+
|
|
115
|
+
1. Disk scan via ``PROJECT_BRIDGE_MARKERS`` — catches legacy / v1
|
|
116
|
+
installs and unmanaged drift not recorded in ``files[]``.
|
|
117
|
+
2. Manifest-driven scan via per-tool ``files[]`` (v2 only) —
|
|
118
|
+
surfaces files whose owning tool has ``status: uninstalling``
|
|
119
|
+
(P2.2 crash recovery) or whose ``kind`` is ``bridge`` /
|
|
120
|
+
``marker`` / ``deployed`` and the tool is no longer declared.
|
|
121
|
+
|
|
122
|
+
Paths already collected by the disk scan are deduplicated so the
|
|
123
|
+
same orphan never appears twice.
|
|
124
|
+
|
|
125
|
+
``resume_uninstall=True`` skips the legacy disk scan and the
|
|
126
|
+
healthy-tool branch entirely — only tools with
|
|
127
|
+
``status == "uninstalling"`` contribute candidates. Used by
|
|
128
|
+
``--resume-uninstall`` to scope crash recovery to half-removed
|
|
129
|
+
tools without touching unmanaged drift.
|
|
130
|
+
"""
|
|
131
|
+
out: list[tuple[str, Path, str | None, str | None]] = []
|
|
132
|
+
seen: set[Path] = set()
|
|
133
|
+
|
|
134
|
+
if not resume_uninstall:
|
|
135
|
+
for tool_id, rel in PROJECT_BRIDGE_MARKERS.items():
|
|
136
|
+
if tool_id in declared:
|
|
137
|
+
continue
|
|
138
|
+
target = project_root / rel
|
|
139
|
+
if not target.exists():
|
|
140
|
+
continue
|
|
141
|
+
out.append((tool_id, target, None, None))
|
|
142
|
+
seen.add(target.resolve())
|
|
143
|
+
|
|
144
|
+
if manifest is None:
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
for tool in manifest.get("tools") or []:
|
|
148
|
+
if tool.get("scope") != "project":
|
|
149
|
+
continue
|
|
150
|
+
files = tool.get("files") or []
|
|
151
|
+
if not files:
|
|
152
|
+
continue
|
|
153
|
+
tool_id = str(tool.get("name", ""))
|
|
154
|
+
status = tool.get("status", "installed")
|
|
155
|
+
if resume_uninstall:
|
|
156
|
+
if status != "uninstalling":
|
|
157
|
+
continue
|
|
158
|
+
elif status == "installed" and tool_id in declared:
|
|
159
|
+
continue
|
|
160
|
+
for entry in files:
|
|
161
|
+
kind = entry.get("kind")
|
|
162
|
+
if kind not in ("bridge", "marker", "deployed"):
|
|
163
|
+
continue
|
|
164
|
+
raw = entry.get("path") or ""
|
|
165
|
+
if not raw:
|
|
166
|
+
continue
|
|
167
|
+
target = _resolve_path(project_root, raw)
|
|
168
|
+
try:
|
|
169
|
+
resolved = target.resolve()
|
|
170
|
+
except OSError:
|
|
171
|
+
resolved = target
|
|
172
|
+
if resolved in seen or not target.exists():
|
|
173
|
+
continue
|
|
174
|
+
out.append((tool_id, target, kind, entry.get("sha256")))
|
|
175
|
+
seen.add(resolved)
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _classify(target: Path, expected_sha: str | None
|
|
180
|
+
) -> tuple[str, str | None]:
|
|
181
|
+
"""Return ``(state, actual_sha)`` for a prune candidate.
|
|
182
|
+
|
|
183
|
+
States:
|
|
184
|
+
|
|
185
|
+
* ``"orphan"`` — safe to delete (hash matches or no hash recorded).
|
|
186
|
+
* ``"modified"`` — recorded hash differs from disk; skip deletion.
|
|
187
|
+
|
|
188
|
+
A missing recorded hash short-circuits to ``"orphan"`` because
|
|
189
|
+
bridges are content-less by design and would otherwise spuriously
|
|
190
|
+
flag as modified after every install.
|
|
191
|
+
"""
|
|
192
|
+
if expected_sha is None:
|
|
193
|
+
return "orphan", None
|
|
194
|
+
actual = _sha256(target)
|
|
195
|
+
if actual is None or actual != expected_sha:
|
|
196
|
+
return "modified", actual
|
|
197
|
+
return "orphan", actual
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _remove(target: Path, *, dry_run: bool) -> tuple[bool, str]:
|
|
201
|
+
if dry_run:
|
|
202
|
+
return True, "would remove"
|
|
203
|
+
try:
|
|
204
|
+
target.unlink()
|
|
205
|
+
return True, "removed"
|
|
206
|
+
except IsADirectoryError:
|
|
207
|
+
return False, "❌ is a directory (refusing — use uninstall --purge)"
|
|
208
|
+
except OSError as exc:
|
|
209
|
+
return False, f"❌ failed ({exc})"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _parse(argv: list[str]) -> argparse.Namespace:
|
|
213
|
+
parser = argparse.ArgumentParser(
|
|
214
|
+
prog="agent-config prune",
|
|
215
|
+
description=(
|
|
216
|
+
"Remove project bridge markers not declared in "
|
|
217
|
+
"agents/installed-tools.lock. Mirrors `npm prune`."
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
parser.add_argument("--project", default=None, help="project root (default: cwd)")
|
|
221
|
+
parser.add_argument("--dry-run", action="store_true",
|
|
222
|
+
help="show what would be removed; make no changes")
|
|
223
|
+
parser.add_argument("--json", action="store_true",
|
|
224
|
+
help="emit a JSON report instead of human text")
|
|
225
|
+
parser.add_argument("--all-missing-lock", action="store_true",
|
|
226
|
+
help="treat a missing lockfile as 'no tools declared' "
|
|
227
|
+
"and prune every known marker (destructive)")
|
|
228
|
+
parser.add_argument("--resume-uninstall", action="store_true",
|
|
229
|
+
help="only sweep files of tools left in "
|
|
230
|
+
"status='uninstalling' (P2.2 crash recovery); "
|
|
231
|
+
"skips the legacy disk scan and healthy tools")
|
|
232
|
+
return parser.parse_args(argv)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _emit_text(
|
|
236
|
+
project_root: Path,
|
|
237
|
+
candidates: list,
|
|
238
|
+
results: list[tuple[str, Path, str, bool, str]],
|
|
239
|
+
*,
|
|
240
|
+
dry_run: bool,
|
|
241
|
+
) -> None:
|
|
242
|
+
prefix = "[dry-run] " if dry_run else ""
|
|
243
|
+
if not candidates:
|
|
244
|
+
print(f"✅ {prefix}no orphaned bridges in {project_root}")
|
|
245
|
+
return
|
|
246
|
+
modified = [r for r in results if r[2] == "modified"]
|
|
247
|
+
orphans = [r for r in results if r[2] == "orphan"]
|
|
248
|
+
print(
|
|
249
|
+
f"{prefix}{len(orphans)} orphaned, {len(modified)} modified "
|
|
250
|
+
f"bridge(s) under {project_root}:"
|
|
251
|
+
)
|
|
252
|
+
for tool_id, target, state, ok, msg in results:
|
|
253
|
+
rel = target.relative_to(project_root) if target.is_absolute() else target
|
|
254
|
+
if state == "modified":
|
|
255
|
+
print(f" ⚠ {tool_id}: modified — skipped {rel}")
|
|
256
|
+
continue
|
|
257
|
+
mark = "·" if ok else "!"
|
|
258
|
+
print(f" {mark} {tool_id}: {msg} {rel}")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _emit_json(
|
|
262
|
+
project_root: Path,
|
|
263
|
+
results: list[tuple[str, Path, str, bool, str]],
|
|
264
|
+
*,
|
|
265
|
+
dry_run: bool,
|
|
266
|
+
) -> None:
|
|
267
|
+
payload = {
|
|
268
|
+
"project_root": str(project_root),
|
|
269
|
+
"dry_run": dry_run,
|
|
270
|
+
"orphans": [
|
|
271
|
+
{
|
|
272
|
+
"tool": tool_id,
|
|
273
|
+
"path": str(target.relative_to(project_root)
|
|
274
|
+
if target.is_absolute() else target),
|
|
275
|
+
"state": state,
|
|
276
|
+
"ok": ok,
|
|
277
|
+
"status": msg,
|
|
278
|
+
}
|
|
279
|
+
for tool_id, target, state, ok, msg in results
|
|
280
|
+
],
|
|
281
|
+
}
|
|
282
|
+
print(json.dumps(payload, indent=2))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def main(argv: list[str] | None = None) -> int:
|
|
286
|
+
opts = _parse(list(argv) if argv is not None else sys.argv[1:])
|
|
287
|
+
project_root = _resolve_project_root(opts.project)
|
|
288
|
+
manifest, declared = _load_manifest(project_root, force_empty=opts.all_missing_lock)
|
|
289
|
+
if declared is None:
|
|
290
|
+
manifest_path = installed_tools.manifest_path(project_root)
|
|
291
|
+
print(f"❌ no project lockfile at {manifest_path}", file=sys.stderr)
|
|
292
|
+
print(" pass --all-missing-lock to prune every known marker (destructive)",
|
|
293
|
+
file=sys.stderr)
|
|
294
|
+
return 1
|
|
295
|
+
candidates = _orphaned(
|
|
296
|
+
project_root, manifest, declared,
|
|
297
|
+
resume_uninstall=opts.resume_uninstall,
|
|
298
|
+
)
|
|
299
|
+
results: list[tuple[str, Path, str, bool, str]] = []
|
|
300
|
+
for tool_id, target, _kind, expected_sha in candidates:
|
|
301
|
+
state, _actual = _classify(target, expected_sha)
|
|
302
|
+
if state == "modified":
|
|
303
|
+
# Drift — leave the file alone, surface in output.
|
|
304
|
+
results.append((tool_id, target, state, True, "skipped (modified)"))
|
|
305
|
+
continue
|
|
306
|
+
ok, msg = _remove(target, dry_run=opts.dry_run)
|
|
307
|
+
results.append((tool_id, target, state, ok, msg))
|
|
308
|
+
if opts.json:
|
|
309
|
+
_emit_json(project_root, results, dry_run=opts.dry_run)
|
|
310
|
+
else:
|
|
311
|
+
_emit_text(project_root, candidates, results, dry_run=opts.dry_run)
|
|
312
|
+
failed = [r for r in results if not r[2]]
|
|
313
|
+
return 1 if failed else 0
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
if __name__ == "__main__": # pragma: no cover
|
|
317
|
+
raise SystemExit(main())
|