@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,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
@@ -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
  *)
@@ -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 TOOL_DIRS.items():
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 TOOL_DIRS:
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(TOOL_DIRS)} tool directories ({source_count} rules each)")
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 PERSONA_TOOL_DIRS.items():
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(PERSONA_TOOL_DIRS)} tool directories ({len(personas)} personas each)")
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
- generate_gemini_md()
843
- skills = generate_claude_skills()
844
- commands = generate_claude_commands()
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
@@ -230,17 +237,18 @@ if [[ -z "$TARGET_DIR" ]]; then
230
237
  fi
231
238
 
232
239
  # Source-repo guard: refuse to install into the agent-config dev tree itself.
233
- # Mirrors packages/create-agent-config/src/install.js defense-in-depth so a
234
- # direct `bash scripts/install` from inside the source checkout (without the
235
- # Node wrapper) cannot corrupt .augment/ symlinks. Override for self-tests:
240
+ # Defense-in-depth so a direct `bash scripts/install` from inside the source
241
+ # checkout cannot corrupt .augment/ symlinks. Skipped for --global because
242
+ # global installs only write to user-scope paths (~/.config/agent-config/,
243
+ # ~/.claude/, …) and never touch the source tree. Override for self-tests:
236
244
  # AGENT_CONFIG_ALLOW_SELF_INSTALL=1.
237
- if [[ "${AGENT_CONFIG_ALLOW_SELF_INSTALL:-0}" != "1" ]]; then
245
+ if ! $GLOBAL && [[ "${AGENT_CONFIG_ALLOW_SELF_INSTALL:-0}" != "1" ]]; then
238
246
  self_marker=""
239
247
  if [[ -d "$TARGET_DIR/.agent-src.uncompressed" ]]; then
240
248
  self_marker=".agent-src.uncompressed/"
241
249
  elif [[ -f "$TARGET_DIR/package.json" ]] && \
242
- grep -qE '"name"[[:space:]]*:[[:space:]]*"@event4u/(create-)?agent-config"' "$TARGET_DIR/package.json" 2>/dev/null; then
243
- self_marker='package.json::name === "@event4u/(create-)agent-config"'
250
+ grep -qE '"name"[[:space:]]*:[[:space:]]*"@event4u/agent-config"' "$TARGET_DIR/package.json" 2>/dev/null; then
251
+ self_marker='package.json::name === "@event4u/agent-config"'
244
252
  fi
245
253
  if [[ -n "$self_marker" ]]; then
246
254
  err "Refusing to install agent-config into its own source checkout."
@@ -296,6 +304,7 @@ run_bridges() {
296
304
  $GLOBAL && args+=(--global)
297
305
  [[ -n "$SCOPE" ]] && args+=(--scope="$SCOPE")
298
306
  [[ -n "$CUSTOM_PATH" ]] && args+=(--custom-path="$CUSTOM_PATH")
307
+ $OFFLINE && args+=(--offline)
299
308
  args+=(--tools="$TOOLS")
300
309
  "$python_bin" "$INSTALL_PY" "${args[@]}"
301
310
  }
@@ -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."