@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,260 @@
|
|
|
1
|
+
"""JSON-pointer helpers for the v2 ``merged_keys[]`` manifest field.
|
|
2
|
+
|
|
3
|
+
P1.5 of road-to-multi-package-coexistence (Anthropic constraint
|
|
4
|
+
2026-05-12). When a tool merges into a shared JSON file, the manifest
|
|
5
|
+
records which JSON pointers it owns so uninstall can subtract them
|
|
6
|
+
cleanly without touching foreign keys. Two invariants:
|
|
7
|
+
|
|
8
|
+
1. **No array indices.** Pointers MUST target named object keys only.
|
|
9
|
+
``/hooks/PostToolUse`` is valid; ``/hooks/PostToolUse/0`` is not.
|
|
10
|
+
Array indices shift when another tool inserts or removes entries
|
|
11
|
+
at the same array, so an index-based pointer corrupts other
|
|
12
|
+
packages' ownership records on neighbour-tool uninstall.
|
|
13
|
+
2. **Arrays carry a ``value_hash`` discriminator.** A pointer that
|
|
14
|
+
targets a parent whose value is a list records the SHA-256 of the
|
|
15
|
+
JSON-serialised list contents the install wrote, so uninstall can
|
|
16
|
+
identify the owned elements by content rather than position.
|
|
17
|
+
|
|
18
|
+
This module is dependency-free (stdlib only) so it can be imported in
|
|
19
|
+
both the installer (``scripts/install.py``) and the manifest layer
|
|
20
|
+
(``scripts/_lib/installed_tools.py``).
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
from typing import Any, Optional
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ArrayIndexPointerError(ValueError):
|
|
30
|
+
"""Raised when a JSON pointer segment is an array index."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, pointer: str, segment: str):
|
|
33
|
+
super().__init__(
|
|
34
|
+
f"json_pointer {pointer!r} targets array index {segment!r}; "
|
|
35
|
+
"pointers MUST target named object keys only "
|
|
36
|
+
"(see road-to-multi-package-coexistence.md § P1.5)"
|
|
37
|
+
)
|
|
38
|
+
self.pointer = pointer
|
|
39
|
+
self.segment = segment
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _escape_segment(key: str) -> str:
|
|
43
|
+
"""Escape a JSON pointer segment per RFC 6901 § 4."""
|
|
44
|
+
return key.replace("~", "~0").replace("/", "~1")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_pointer(pointer: str) -> None:
|
|
48
|
+
"""Raise :class:`ArrayIndexPointerError` if any segment is an integer.
|
|
49
|
+
|
|
50
|
+
The empty pointer (``""``) is valid (targets the document root).
|
|
51
|
+
Otherwise the pointer must start with ``/`` and split into
|
|
52
|
+
segments; each segment that parses cleanly as a non-negative
|
|
53
|
+
integer is rejected (RFC 6901 array-index syntax).
|
|
54
|
+
"""
|
|
55
|
+
if pointer == "":
|
|
56
|
+
return
|
|
57
|
+
if not pointer.startswith("/"):
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"json_pointer {pointer!r} must start with '/' (RFC 6901)"
|
|
60
|
+
)
|
|
61
|
+
# Skip the leading empty segment from the leading slash.
|
|
62
|
+
segments = pointer.split("/")[1:]
|
|
63
|
+
for seg in segments:
|
|
64
|
+
# RFC 6901 § 4 — array index = unsigned integer, no leading zero
|
|
65
|
+
# except for "0" itself.
|
|
66
|
+
if seg.isdigit() and (seg == "0" or not seg.startswith("0")):
|
|
67
|
+
raise ArrayIndexPointerError(pointer, seg)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def value_hash(value: Any) -> str:
|
|
71
|
+
"""Return a stable SHA-256 hex digest of ``value`` (JSON-serialised).
|
|
72
|
+
|
|
73
|
+
Uses canonical JSON (sorted keys, no whitespace) so the hash is
|
|
74
|
+
insertion-order independent. Used to discriminate tool-owned
|
|
75
|
+
entries in a shared array on uninstall.
|
|
76
|
+
"""
|
|
77
|
+
payload = json.dumps(value, sort_keys=True, separators=(",", ":"))
|
|
78
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def collect_pointers(
|
|
82
|
+
overlay: dict,
|
|
83
|
+
*,
|
|
84
|
+
prefix: str = "",
|
|
85
|
+
include_arrays: bool = True,
|
|
86
|
+
) -> list[dict[str, Any]]:
|
|
87
|
+
"""Walk an overlay dict and return one entry per object-key pointer.
|
|
88
|
+
|
|
89
|
+
Each entry: ``{"json_pointer": str, "value_hash": Optional[str]}``.
|
|
90
|
+
``value_hash`` is set when the targeted value is a list (arrays
|
|
91
|
+
need content-hash discrimination on uninstall); for nested dicts
|
|
92
|
+
we recurse and emit a pointer for each inner key. Scalars get a
|
|
93
|
+
pointer with ``value_hash=None`` (the key/value pair fully
|
|
94
|
+
identifies the merge).
|
|
95
|
+
|
|
96
|
+
The collector NEVER emits array-index pointers — list contents
|
|
97
|
+
are owned wholesale at the parent key.
|
|
98
|
+
"""
|
|
99
|
+
entries: list[dict[str, Any]] = []
|
|
100
|
+
for key, value in overlay.items():
|
|
101
|
+
pointer = f"{prefix}/{_escape_segment(str(key))}"
|
|
102
|
+
if isinstance(value, dict):
|
|
103
|
+
# Recurse so the manifest captures the leaf object keys,
|
|
104
|
+
# not just the root container. Empty dicts get a single
|
|
105
|
+
# entry at the key so an uninstall can still remove them.
|
|
106
|
+
if not value:
|
|
107
|
+
entries.append({"json_pointer": pointer, "value_hash": None})
|
|
108
|
+
else:
|
|
109
|
+
entries.extend(
|
|
110
|
+
collect_pointers(
|
|
111
|
+
value, prefix=pointer, include_arrays=include_arrays,
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
elif isinstance(value, list):
|
|
115
|
+
entries.append(
|
|
116
|
+
{
|
|
117
|
+
"json_pointer": pointer,
|
|
118
|
+
"value_hash": value_hash(value) if include_arrays else None,
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
else:
|
|
122
|
+
entries.append({"json_pointer": pointer, "value_hash": None})
|
|
123
|
+
# Validate every emitted pointer once at the end — cheap and
|
|
124
|
+
# guarantees the invariant even if a future caller hand-crafts
|
|
125
|
+
# entries.
|
|
126
|
+
for entry in entries:
|
|
127
|
+
validate_pointer(entry["json_pointer"])
|
|
128
|
+
return entries
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def build_merge_entries(
|
|
132
|
+
file_label: str,
|
|
133
|
+
overlay: dict,
|
|
134
|
+
) -> list[dict[str, Any]]:
|
|
135
|
+
"""Return v2 ``merged_keys[]`` entries for a single JSON merge.
|
|
136
|
+
|
|
137
|
+
``file_label`` is the manifest-relative file path the merge
|
|
138
|
+
touched (e.g. ``.cursor/hooks.json``). The overlay is the dict the
|
|
139
|
+
installer wrote into the file; only its top-level object keys
|
|
140
|
+
become pointers (recursing through nested objects, halting at
|
|
141
|
+
lists / scalars).
|
|
142
|
+
"""
|
|
143
|
+
pointers = collect_pointers(overlay)
|
|
144
|
+
return [
|
|
145
|
+
{
|
|
146
|
+
"file": file_label,
|
|
147
|
+
"json_pointer": entry["json_pointer"],
|
|
148
|
+
"value_hash": entry["value_hash"],
|
|
149
|
+
}
|
|
150
|
+
for entry in pointers
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# Subtraction (P2.2 — uninstall round-trip)
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _split_segments(pointer: str) -> list[str]:
|
|
160
|
+
"""Split a non-empty pointer into unescaped segments."""
|
|
161
|
+
if pointer == "":
|
|
162
|
+
return []
|
|
163
|
+
# RFC 6901: leading '/' separates segments; unescape ~1 → '/' and ~0 → '~'.
|
|
164
|
+
parts = pointer.split("/")[1:]
|
|
165
|
+
return [p.replace("~1", "/").replace("~0", "~") for p in parts]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _navigate(doc: Any, segments: list[str]) -> tuple[Any, str] | None:
|
|
169
|
+
"""Walk ``doc`` down ``segments`` and return ``(parent_dict, leaf_key)``.
|
|
170
|
+
|
|
171
|
+
Returns ``None`` when any intermediate segment is missing or not a
|
|
172
|
+
dict (we never descend into lists by index, see :func:`validate_pointer`).
|
|
173
|
+
"""
|
|
174
|
+
if not segments:
|
|
175
|
+
return None
|
|
176
|
+
cursor = doc
|
|
177
|
+
for seg in segments[:-1]:
|
|
178
|
+
if not isinstance(cursor, dict) or seg not in cursor:
|
|
179
|
+
return None
|
|
180
|
+
cursor = cursor[seg]
|
|
181
|
+
if not isinstance(cursor, dict):
|
|
182
|
+
return None
|
|
183
|
+
leaf = segments[-1]
|
|
184
|
+
if leaf not in cursor:
|
|
185
|
+
return None
|
|
186
|
+
return cursor, leaf
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def subtract_pointers(
|
|
190
|
+
doc: dict,
|
|
191
|
+
entries: list[dict[str, Any]],
|
|
192
|
+
) -> tuple[dict, list[dict[str, Any]]]:
|
|
193
|
+
"""Remove the pointers in ``entries`` from ``doc``; trim empty ancestors.
|
|
194
|
+
|
|
195
|
+
``entries`` is a list of ``{"json_pointer": str, "value_hash":
|
|
196
|
+
Optional[str]}`` records (the per-file slice of a tool's
|
|
197
|
+
``merged_keys[]``). For each entry:
|
|
198
|
+
|
|
199
|
+
* ``value_hash is None`` → delete the key at the pointer.
|
|
200
|
+
* ``value_hash is set`` → the target is a list owned wholesale by
|
|
201
|
+
the tool. Delete only when the current value's hash still
|
|
202
|
+
matches; otherwise treat as **drift** (a neighbour package or
|
|
203
|
+
the user edited the array) and skip, surfacing a warning.
|
|
204
|
+
|
|
205
|
+
After every leaf removal we walk up the ancestor chain and drop
|
|
206
|
+
any empty dict the removal left behind — but only empty ones. A
|
|
207
|
+
neighbour tool's remaining keys keep the container alive, so its
|
|
208
|
+
contributions are never touched.
|
|
209
|
+
|
|
210
|
+
Returns ``(updated_doc, warnings)`` where ``warnings`` is a list of
|
|
211
|
+
``{"pointer": str, "reason": "missing" | "drift", "expected_hash":
|
|
212
|
+
Optional[str], "actual_hash": Optional[str]}`` entries describing
|
|
213
|
+
pointers that could not be subtracted cleanly.
|
|
214
|
+
"""
|
|
215
|
+
warnings: list[dict[str, Any]] = []
|
|
216
|
+
# Sort longest-first so leaves are removed before their ancestors —
|
|
217
|
+
# otherwise ancestor cleanup races leaf removal in deep trees.
|
|
218
|
+
ordered = sorted(
|
|
219
|
+
entries,
|
|
220
|
+
key=lambda e: len(_split_segments(e["json_pointer"])),
|
|
221
|
+
reverse=True,
|
|
222
|
+
)
|
|
223
|
+
for entry in ordered:
|
|
224
|
+
pointer = entry["json_pointer"]
|
|
225
|
+
expected = entry.get("value_hash")
|
|
226
|
+
segments = _split_segments(pointer)
|
|
227
|
+
nav = _navigate(doc, segments)
|
|
228
|
+
if nav is None:
|
|
229
|
+
warnings.append({
|
|
230
|
+
"pointer": pointer,
|
|
231
|
+
"reason": "missing",
|
|
232
|
+
"expected_hash": expected,
|
|
233
|
+
"actual_hash": None,
|
|
234
|
+
})
|
|
235
|
+
continue
|
|
236
|
+
parent, leaf = nav
|
|
237
|
+
if expected is not None:
|
|
238
|
+
actual = value_hash(parent[leaf])
|
|
239
|
+
if actual != expected:
|
|
240
|
+
warnings.append({
|
|
241
|
+
"pointer": pointer,
|
|
242
|
+
"reason": "drift",
|
|
243
|
+
"expected_hash": expected,
|
|
244
|
+
"actual_hash": actual,
|
|
245
|
+
})
|
|
246
|
+
continue
|
|
247
|
+
del parent[leaf]
|
|
248
|
+
# Trim empty-ancestor chain — never remove a container that
|
|
249
|
+
# still holds foreign keys.
|
|
250
|
+
for depth in range(len(segments) - 1, 0, -1):
|
|
251
|
+
ancestor_segments = segments[:depth]
|
|
252
|
+
anc_nav = _navigate(doc, ancestor_segments)
|
|
253
|
+
if anc_nav is None:
|
|
254
|
+
break
|
|
255
|
+
anc_parent, anc_leaf = anc_nav
|
|
256
|
+
if isinstance(anc_parent[anc_leaf], dict) and not anc_parent[anc_leaf]:
|
|
257
|
+
del anc_parent[anc_leaf]
|
|
258
|
+
continue
|
|
259
|
+
break
|
|
260
|
+
return doc, warnings
|
package/scripts/agent-config
CHANGED
|
@@ -114,6 +114,24 @@ Commands:
|
|
|
114
114
|
validate Read-only drift detection on the manifest
|
|
115
115
|
(marker missing, scope divergence, version drift).
|
|
116
116
|
Exits 1 on drift. Flags: --quiet | --skip-version-check
|
|
117
|
+
uninstall Remove bridge markers (project) or lockfile
|
|
118
|
+
entries (global). Idempotent. User-deployed
|
|
119
|
+
content under ~/.<tool>/ is preserved unless
|
|
120
|
+
--purge is passed (destructive).
|
|
121
|
+
Flags: --global | --tools=<list> | --dry-run
|
|
122
|
+
| --purge | --force | --project=<path>
|
|
123
|
+
prune Remove project bridge markers not declared in
|
|
124
|
+
agents/installed-tools.lock (npm-prune style).
|
|
125
|
+
Hard-floors when lockfile is absent.
|
|
126
|
+
Flags: --dry-run | --json | --project=<path>
|
|
127
|
+
| --all-missing-lock
|
|
128
|
+
doctor Read-only drift report: manifest ↔ filesystem.
|
|
129
|
+
Lists missing, modified, and foreign files.
|
|
130
|
+
Exits 1 on drift, 2 on missing lockfile.
|
|
131
|
+
Flags: --json | --project=<path>
|
|
132
|
+
versions List available @event4u/agent-config versions
|
|
133
|
+
on npm. Marks the current pin and latest.
|
|
134
|
+
Flags: --offline | --limit=N | --json
|
|
117
135
|
help Show this help
|
|
118
136
|
--version, -V Print package version
|
|
119
137
|
|
|
@@ -151,6 +169,17 @@ Examples:
|
|
|
151
169
|
./agent-config sync --dry-run
|
|
152
170
|
./agent-config sync
|
|
153
171
|
./agent-config validate
|
|
172
|
+
./agent-config uninstall --tools=cursor --dry-run
|
|
173
|
+
./agent-config uninstall --global --tools=windsurf --purge
|
|
174
|
+
./agent-config prune --dry-run
|
|
175
|
+
./agent-config prune --json
|
|
176
|
+
./agent-config doctor
|
|
177
|
+
./agent-config doctor --json
|
|
178
|
+
./agent-config versions
|
|
179
|
+
./agent-config versions --limit=10
|
|
180
|
+
./agent-config versions --json
|
|
181
|
+
./agent-config init --offline --tools=claude-code,cursor --yes
|
|
182
|
+
./agent-config update --offline --to=2.2.0
|
|
154
183
|
|
|
155
184
|
All commands operate on the CURRENT DIRECTORY (your project root).
|
|
156
185
|
The CLI is strictly consumer-facing. Maintainer tasks live in Taskfile.yml.
|
|
@@ -596,6 +625,42 @@ cmd_validate() {
|
|
|
596
625
|
exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_validate "$@"
|
|
597
626
|
}
|
|
598
627
|
|
|
628
|
+
# `agent-config uninstall` — remove bridge markers (project) or lockfile
|
|
629
|
+
# entries (global). Idempotent. Pass `--purge` to also delete deployed
|
|
630
|
+
# content directories under user-scope anchors (destructive). See
|
|
631
|
+
# scripts/_cli/cmd_uninstall.py.
|
|
632
|
+
cmd_uninstall() {
|
|
633
|
+
require_python3
|
|
634
|
+
exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_uninstall "$@"
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
# `agent-config prune` — remove orphaned project bridge markers.
|
|
638
|
+
# Drift-cleanup sibling to `uninstall`: compares on-disk markers
|
|
639
|
+
# against agents/installed-tools.lock and unlinks anything not
|
|
640
|
+
# declared. Hard-floors when lockfile is absent. See
|
|
641
|
+
# scripts/_cli/cmd_prune.py.
|
|
642
|
+
cmd_prune() {
|
|
643
|
+
require_python3
|
|
644
|
+
exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_prune "$@"
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
# `agent-config doctor` — read-only drift report against the manifest.
|
|
648
|
+
# Surfaces missing / modified / foreign files. Exit 0 clean, 1 drift,
|
|
649
|
+
# 2 manifest-absent. See scripts/_cli/cmd_doctor.py.
|
|
650
|
+
cmd_doctor() {
|
|
651
|
+
require_python3
|
|
652
|
+
exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_doctor "$@"
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
# `agent-config versions` — list available @event4u/agent-config versions
|
|
656
|
+
# on the npm registry. Marks the current pin (from .agent-settings.yml)
|
|
657
|
+
# and the latest published version. Offline-tolerant. See
|
|
658
|
+
# scripts/_cli/cmd_versions.py.
|
|
659
|
+
cmd_versions() {
|
|
660
|
+
require_python3
|
|
661
|
+
exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_versions "$@"
|
|
662
|
+
}
|
|
663
|
+
|
|
599
664
|
main() {
|
|
600
665
|
local cmd="${1-}"
|
|
601
666
|
[[ $# -gt 0 ]] && shift || true
|
|
@@ -641,6 +706,10 @@ main() {
|
|
|
641
706
|
export) cmd_export "$@" ;;
|
|
642
707
|
sync) cmd_sync "$@" ;;
|
|
643
708
|
validate) cmd_validate "$@" ;;
|
|
709
|
+
uninstall) cmd_uninstall "$@" ;;
|
|
710
|
+
prune) cmd_prune "$@" ;;
|
|
711
|
+
doctor) cmd_doctor "$@" ;;
|
|
712
|
+
versions) cmd_versions "$@" ;;
|
|
644
713
|
help|--help|-h|"") usage ;;
|
|
645
714
|
--version|-V) print_version ;;
|
|
646
715
|
*)
|
package/scripts/compress.py
CHANGED
|
@@ -19,6 +19,8 @@ Usage:
|
|
|
19
19
|
python scripts/compress.py --project-augment # rebuild .augment/ projection
|
|
20
20
|
"""
|
|
21
21
|
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
22
24
|
import hashlib
|
|
23
25
|
import json
|
|
24
26
|
import re
|
|
@@ -38,6 +40,40 @@ AUGMENT_DIR = PROJECT_ROOT / ".augment"
|
|
|
38
40
|
HASH_FILE = PROJECT_ROOT / ".compression-hashes.json"
|
|
39
41
|
SETTINGS_FILE = PROJECT_ROOT / ".agent-settings.yml"
|
|
40
42
|
|
|
43
|
+
# Self-projection tool toggle — see .agent-tools.yml. When the file is
|
|
44
|
+
# absent (e.g. tests run in tmp dirs, consumer projects), `_active_tools`
|
|
45
|
+
# returns ``None`` which is treated as "emit every tool".
|
|
46
|
+
_ALL_TOOLS = frozenset({
|
|
47
|
+
"claude-code", "claude-desktop", "augment", "copilot",
|
|
48
|
+
"cursor", "windsurf", "cline", "gemini",
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _active_tools() -> frozenset[str] | None:
|
|
53
|
+
"""Return the set of active self-projection tools, or None for "all".
|
|
54
|
+
|
|
55
|
+
Reads `.agent-tools.yml` relative to the current `PROJECT_ROOT` so
|
|
56
|
+
test fixtures that monkey-patch `compress.PROJECT_ROOT` see their own
|
|
57
|
+
(empty) project root and get the default "all tools" behaviour.
|
|
58
|
+
"""
|
|
59
|
+
tools_file = PROJECT_ROOT / ".agent-tools.yml"
|
|
60
|
+
if not tools_file.exists():
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
data = yaml.safe_load(tools_file.read_text()) or {}
|
|
64
|
+
except yaml.YAMLError:
|
|
65
|
+
return None
|
|
66
|
+
tools = data.get("tools") if isinstance(data, dict) else None
|
|
67
|
+
if not isinstance(tools, list):
|
|
68
|
+
return None
|
|
69
|
+
return frozenset(str(t) for t in tools if isinstance(t, str))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _tool_active(tool_id: str) -> bool:
|
|
73
|
+
"""True when ``tool_id`` should be emitted by self-projection."""
|
|
74
|
+
active = _active_tools()
|
|
75
|
+
return True if active is None else tool_id in active
|
|
76
|
+
|
|
41
77
|
# Files to copy as-is even if .md (not compressed by agent)
|
|
42
78
|
COPY_AS_IS = {"README.md"}
|
|
43
79
|
|
|
@@ -306,6 +342,24 @@ PERSONA_TOOL_DIRS = {
|
|
|
306
342
|
".cursor/personas": "../../.agent-src/personas",
|
|
307
343
|
}
|
|
308
344
|
|
|
345
|
+
# Map tool-projection directories to the canonical tool ID used by
|
|
346
|
+
# `.agent-tools.yml`. Directories not in this map are always emitted.
|
|
347
|
+
_DIR_TOOL_ID = {
|
|
348
|
+
".claude/rules": "claude-code",
|
|
349
|
+
".cursor/rules": "cursor",
|
|
350
|
+
".clinerules": "cline",
|
|
351
|
+
".claude/personas": "claude-code",
|
|
352
|
+
".cursor/personas": "cursor",
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _filter_tool_dirs(mapping: dict[str, str]) -> dict[str, str]:
|
|
357
|
+
"""Drop entries whose tool ID is not active in `.agent-tools.yml`."""
|
|
358
|
+
return {
|
|
359
|
+
d: p for d, p in mapping.items()
|
|
360
|
+
if _tool_active(_DIR_TOOL_ID.get(d, "claude-code"))
|
|
361
|
+
}
|
|
362
|
+
|
|
309
363
|
|
|
310
364
|
def strip_frontmatter(content: str) -> str:
|
|
311
365
|
"""Remove YAML frontmatter (between --- markers) from content."""
|
|
@@ -461,8 +515,9 @@ def generate_rule_symlinks() -> int:
|
|
|
461
515
|
"""
|
|
462
516
|
# All .md files in .agent-src/rules/ — not just universal ones
|
|
463
517
|
rules = sorted([f.name for f in RULES_SOURCE.glob("*.md")])
|
|
518
|
+
tool_dirs = _filter_tool_dirs(TOOL_DIRS)
|
|
464
519
|
total = 0
|
|
465
|
-
for tool_dir, rel_prefix in
|
|
520
|
+
for tool_dir, rel_prefix in tool_dirs.items():
|
|
466
521
|
target_dir = PROJECT_ROOT / tool_dir
|
|
467
522
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
468
523
|
|
|
@@ -481,13 +536,13 @@ def generate_rule_symlinks() -> int:
|
|
|
481
536
|
|
|
482
537
|
# Verify counts match across all tool directories
|
|
483
538
|
source_count = len(rules)
|
|
484
|
-
for tool_dir in
|
|
539
|
+
for tool_dir in tool_dirs:
|
|
485
540
|
target_dir = PROJECT_ROOT / tool_dir
|
|
486
541
|
tool_count = len([f for f in target_dir.iterdir() if f.is_symlink() and f.suffix == ".md"])
|
|
487
542
|
if tool_count != source_count:
|
|
488
543
|
print(f" ⚠️ {tool_dir}: {tool_count} rules (expected {source_count})")
|
|
489
544
|
|
|
490
|
-
info(f" ✅ Created {total} rule symlinks across {len(
|
|
545
|
+
info(f" ✅ Created {total} rule symlinks across {len(tool_dirs)} tool directories ({source_count} rules each)")
|
|
491
546
|
return total
|
|
492
547
|
|
|
493
548
|
|
|
@@ -812,8 +867,9 @@ def generate_persona_symlinks() -> int:
|
|
|
812
867
|
personas = sorted([
|
|
813
868
|
f.name for f in PERSONAS_SOURCE.glob("*.md") if f.stem != "README"
|
|
814
869
|
])
|
|
870
|
+
tool_dirs = _filter_tool_dirs(PERSONA_TOOL_DIRS)
|
|
815
871
|
total = 0
|
|
816
|
-
for tool_dir, rel_prefix in
|
|
872
|
+
for tool_dir, rel_prefix in tool_dirs.items():
|
|
817
873
|
target_dir = PROJECT_ROOT / tool_dir
|
|
818
874
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
819
875
|
|
|
@@ -830,28 +886,35 @@ def generate_persona_symlinks() -> int:
|
|
|
830
886
|
link.symlink_to(target)
|
|
831
887
|
total += 1
|
|
832
888
|
|
|
833
|
-
info(f" ✅ Created {total} persona symlinks across {len(
|
|
889
|
+
info(f" ✅ Created {total} persona symlinks across {len(tool_dirs)} tool directories ({len(personas)} personas each)")
|
|
834
890
|
return total
|
|
835
891
|
|
|
836
892
|
|
|
837
893
|
def generate_tools() -> None:
|
|
838
|
-
"""Generate all tool-specific directories and files.
|
|
894
|
+
"""Generate all tool-specific directories and files.
|
|
895
|
+
|
|
896
|
+
`.agent-tools.yml` (top-level) gates per-tool emission. When the file
|
|
897
|
+
is missing, every tool is emitted (preserves test fixtures and
|
|
898
|
+
pre-gating behaviour). See `_active_tools()` and `_tool_active()`.
|
|
899
|
+
"""
|
|
839
900
|
info("🔧 Generating multi-agent tool directories...\n")
|
|
840
901
|
rules = generate_rule_symlinks()
|
|
841
|
-
generate_windsurfrules()
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
902
|
+
windsurfrules = generate_windsurfrules() if _tool_active("windsurf") else 0
|
|
903
|
+
if _tool_active("gemini"):
|
|
904
|
+
generate_gemini_md()
|
|
905
|
+
skills = generate_claude_skills() if _tool_active("claude-code") else 0
|
|
906
|
+
commands = generate_claude_commands() if _tool_active("claude-code") else 0
|
|
845
907
|
personas = generate_persona_symlinks()
|
|
846
|
-
cursor_mdc = generate_cursor_mdc_rules()
|
|
847
|
-
windsurf_modern = generate_windsurf_modern_rules()
|
|
848
|
-
cursor_cmds = generate_cursor_commands()
|
|
849
|
-
windsurf_wf = generate_windsurf_workflows()
|
|
908
|
+
cursor_mdc = generate_cursor_mdc_rules() if _tool_active("cursor") else 0
|
|
909
|
+
windsurf_modern = generate_windsurf_modern_rules() if _tool_active("windsurf") else 0
|
|
910
|
+
cursor_cmds = generate_cursor_commands() if _tool_active("cursor") else 0
|
|
911
|
+
windsurf_wf = generate_windsurf_workflows() if _tool_active("windsurf") else 0
|
|
850
912
|
summary = (
|
|
851
913
|
f"✅ generate-tools — rules={rules} skills={skills} "
|
|
852
914
|
f"commands={commands} personas={personas} "
|
|
853
915
|
f"cursor_mdc={cursor_mdc} windsurf_rules={windsurf_modern} "
|
|
854
|
-
f"cursor_commands={cursor_cmds} windsurf_workflows={windsurf_wf}"
|
|
916
|
+
f"cursor_commands={cursor_cmds} windsurf_workflows={windsurf_wf} "
|
|
917
|
+
f"windsurfrules={windsurfrules}"
|
|
855
918
|
)
|
|
856
919
|
if resolve_level() == "verbose":
|
|
857
920
|
print(f"\n{summary}")
|
package/scripts/install
CHANGED
|
@@ -39,6 +39,11 @@
|
|
|
39
39
|
# prompt = always show the 3-option chooser
|
|
40
40
|
# --custom-path <d> Use <d> as the project root when prompted; rejected with
|
|
41
41
|
# --scope=global / --global.
|
|
42
|
+
# --offline Skip every network call (post-install update banner +
|
|
43
|
+
# downstream registry fetchers). Sets AGENT_CONFIG_OFFLINE=1
|
|
44
|
+
# for child subprocesses. All bridge content is bundled
|
|
45
|
+
# in the package, so install itself is already offline-safe;
|
|
46
|
+
# this flag is the explicit air-gap / CI guarantee.
|
|
42
47
|
# --help, -h Show this help
|
|
43
48
|
#
|
|
44
49
|
# Examples:
|
|
@@ -69,6 +74,7 @@ LIST_TOOLS=false
|
|
|
69
74
|
GLOBAL=false
|
|
70
75
|
SCOPE=""
|
|
71
76
|
CUSTOM_PATH=""
|
|
77
|
+
OFFLINE=false
|
|
72
78
|
|
|
73
79
|
# Single source of truth for valid tool IDs (also referenced by install.sh / install.py).
|
|
74
80
|
VALID_TOOLS="claude-code claude-desktop cursor windsurf cline gemini-cli copilot augment aider codex roocode continue kilocode zed jetbrains kiro all"
|
|
@@ -150,6 +156,7 @@ while [[ $# -gt 0 ]]; do
|
|
|
150
156
|
--scope=*) SCOPE="${1#*=}"; shift ;;
|
|
151
157
|
--custom-path) CUSTOM_PATH="$2"; shift 2 ;;
|
|
152
158
|
--custom-path=*) CUSTOM_PATH="${1#*=}"; shift ;;
|
|
159
|
+
--offline) OFFLINE=true; shift ;;
|
|
153
160
|
--help|-h) show_help; exit 0 ;;
|
|
154
161
|
*) err "Unknown argument: $1"; show_help >&2; exit 1 ;;
|
|
155
162
|
esac
|
|
@@ -297,6 +304,7 @@ run_bridges() {
|
|
|
297
304
|
$GLOBAL && args+=(--global)
|
|
298
305
|
[[ -n "$SCOPE" ]] && args+=(--scope="$SCOPE")
|
|
299
306
|
[[ -n "$CUSTOM_PATH" ]] && args+=(--custom-path="$CUSTOM_PATH")
|
|
307
|
+
$OFFLINE && args+=(--offline)
|
|
300
308
|
args+=(--tools="$TOOLS")
|
|
301
309
|
"$python_bin" "$INSTALL_PY" "${args[@]}"
|
|
302
310
|
}
|
package/scripts/install-hooks.sh
CHANGED
|
@@ -119,7 +119,9 @@ if [ -x ./agent-config ]; then
|
|
|
119
119
|
./agent-config chat-history:checkpoint --payload "\$payload" \
|
|
120
120
|
>/dev/null 2>&1 || true
|
|
121
121
|
fi
|
|
122
|
-
exit 0
|
|
122
|
+
# NOTE: no explicit exit 0 here — the auto-sync block (appended below
|
|
123
|
+
# for post-merge / post-checkout) needs to run after this. Every
|
|
124
|
+
# command above is guarded by "|| true", so the implicit exit is 0.
|
|
123
125
|
EOF
|
|
124
126
|
chmod +x "$HOOKS_DIR/$name"
|
|
125
127
|
echo "✅ $name hook installed."
|
|
@@ -129,3 +131,54 @@ write_chat_history_hook "post-commit" "git:post-commit"
|
|
|
129
131
|
write_chat_history_hook "post-merge" "git:post-merge"
|
|
130
132
|
write_chat_history_hook "post-checkout" "git:post-checkout"
|
|
131
133
|
write_chat_history_hook "post-rewrite" "git:post-rewrite"
|
|
134
|
+
|
|
135
|
+
# Auto-sync agent-tool projections after pull / branch-switch ---------------
|
|
136
|
+
#
|
|
137
|
+
# When `.agent-src.uncompressed/`, `.agent-src/`, `scripts/compress.py`,
|
|
138
|
+
# `.agent-tools.yml`, or `Taskfile.yml` change between the previous and
|
|
139
|
+
# new HEAD, the developer's working tree has stale `.claude/`,
|
|
140
|
+
# `.augment/`, etc. projections until they remember to run `task sync`.
|
|
141
|
+
# These hooks bridge that gap: fast idempotent re-projection.
|
|
142
|
+
#
|
|
143
|
+
# Bypass: `git pull --no-verify` does not exist, but devs can disable the
|
|
144
|
+
# hooks per-command via `git -c core.hooksPath=/dev/null ...` or by
|
|
145
|
+
# editing the file. Runtime ~200 ms when nothing relevant changed
|
|
146
|
+
# (path-diff check exits early); ~2 s on full re-projection.
|
|
147
|
+
|
|
148
|
+
append_auto_sync_block() {
|
|
149
|
+
local name="$1"
|
|
150
|
+
local arg_offset="$2" # post-merge: $1=is_squash; post-checkout: $3=is_branch
|
|
151
|
+
cat >> "$HOOKS_DIR/$name" << EOF
|
|
152
|
+
|
|
153
|
+
# --- auto-sync agent-tool projections ---------------------------------------
|
|
154
|
+
# Skip when this is a file-checkout (post-checkout \$3 = 0) — only fire on
|
|
155
|
+
# branch switches and merges, where source files realistically changed.
|
|
156
|
+
if [ "$name" = "post-checkout" ] && [ "\${3:-1}" = "0" ]; then
|
|
157
|
+
exit 0
|
|
158
|
+
fi
|
|
159
|
+
|
|
160
|
+
# Range: prev..new. For post-merge git provides ORIG_HEAD; for
|
|
161
|
+
# post-checkout the previous SHA is \$1.
|
|
162
|
+
if [ "$name" = "post-merge" ]; then
|
|
163
|
+
prev="\$(git rev-parse ORIG_HEAD 2>/dev/null || echo)"
|
|
164
|
+
new="\$(git rev-parse HEAD 2>/dev/null || echo)"
|
|
165
|
+
elif [ "$name" = "post-checkout" ]; then
|
|
166
|
+
prev="\${1:-}"
|
|
167
|
+
new="\${2:-}"
|
|
168
|
+
fi
|
|
169
|
+
|
|
170
|
+
if [ -n "\$prev" ] && [ -n "\$new" ] && [ "\$prev" != "\$new" ]; then
|
|
171
|
+
if git diff --name-only "\$prev" "\$new" 2>/dev/null | \\
|
|
172
|
+
grep -qE '^(\\.agent-src(\\.uncompressed)?/|scripts/compress\\.py|\\.agent-tools\\.yml|Taskfile\\.yml)'; then
|
|
173
|
+
if command -v task >/dev/null 2>&1; then
|
|
174
|
+
task sync >/dev/null 2>&1 || true
|
|
175
|
+
task generate-tools >/dev/null 2>&1 || true
|
|
176
|
+
fi
|
|
177
|
+
fi
|
|
178
|
+
fi
|
|
179
|
+
EOF
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
append_auto_sync_block "post-merge" "1"
|
|
183
|
+
append_auto_sync_block "post-checkout" "3"
|
|
184
|
+
echo "✅ Auto-sync block appended to post-merge / post-checkout hooks."
|