@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.
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 +49 -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 +8 -0
  37. package/scripts/install-hooks.sh +54 -1
  38. 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())